feat: per-position camera settings, material alias dialog, product delete, media browser links
- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -342,18 +342,45 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Named argument: --camera-orbit — rotate camera around model instead of rotating model
|
||||
camera_orbit = "--camera-orbit" in argv
|
||||
|
||||
# Named argument: --usd-path <path> — when set, import USD instead of GLB
|
||||
usd_path = ""
|
||||
if "--usd-path" in argv:
|
||||
_usd_idx = argv.index("--usd-path")
|
||||
usd_path = argv[_usd_idx + 1] if _usd_idx + 1 < len(argv) else ""
|
||||
|
||||
# Pre-load USD import helper once (used in both MODE A and MODE B)
|
||||
# Named argument: --focal-length <mm>
|
||||
_focal_length = None
|
||||
if "--focal-length" in argv:
|
||||
_idx = argv.index("--focal-length")
|
||||
_focal_length = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
# Named argument: --sensor-width <mm>
|
||||
_sensor_width = None
|
||||
if "--sensor-width" in argv:
|
||||
_idx = argv.index("--sensor-width")
|
||||
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
# Ensure scripts dir is on path for shared module imports
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
# Pre-load USD import helper (used in both MODE A and MODE B)
|
||||
_import_usd_file = None
|
||||
if usd_path:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from import_usd import import_usd_file as _import_usd_file # type: ignore[assignment]
|
||||
|
||||
# Shared material helpers (handle USD stub collisions correctly)
|
||||
from _blender_materials import (
|
||||
apply_material_library_direct as _apply_material_library_direct,
|
||||
apply_material_library as _apply_material_library_shared,
|
||||
build_mat_map_lower as _build_mat_map_lower,
|
||||
assign_failed_material as _assign_failed_material,
|
||||
)
|
||||
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
@@ -390,6 +417,7 @@ def main():
|
||||
print(f"[turntable_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
|
||||
|
||||
# ── SCENE SETUP ──────────────────────────────────────────────────────────
|
||||
_usd_mat_lookup: dict = {} # populated by import_usd_file when USD path is used
|
||||
|
||||
if use_template:
|
||||
# ── MODE B: Template-based render ────────────────────────────────────
|
||||
@@ -401,7 +429,7 @@ def main():
|
||||
|
||||
# Import geometry: USD path when available, otherwise GLB
|
||||
if usd_path and _import_usd_file:
|
||||
parts = _import_usd_file(usd_path)
|
||||
parts, _usd_mat_lookup = _import_usd_file(usd_path)
|
||||
else:
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
@@ -419,20 +447,30 @@ def main():
|
||||
for part in parts:
|
||||
_apply_smooth(part, SMOOTH_ANGLE)
|
||||
|
||||
# Material assignment: library materials if available, otherwise palette
|
||||
if material_library_path and material_map:
|
||||
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
|
||||
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
|
||||
# Parts not matched by library get palette fallback
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
else:
|
||||
for i, part in enumerate(parts):
|
||||
step_name = _resolve_part_name(i, part, part_names_ordered)
|
||||
color_hex = part_colors.get(step_name)
|
||||
if not color_hex:
|
||||
_assign_palette_material(part, i)
|
||||
# Material assignment: USD primvar path first, then name-matching fallback
|
||||
if material_library_path and _usd_mat_lookup:
|
||||
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
|
||||
# Fall back to name-matching for parts without USD primvars
|
||||
if material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
print(f"[turntable_render] {len(_unassigned)} parts without USD primvar — "
|
||||
f"falling back to name-matching", flush=True)
|
||||
_apply_material_library_shared(
|
||||
_unassigned, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
elif material_library_path and material_map:
|
||||
_apply_material_library_shared(
|
||||
parts, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
# Palette fallback for any parts still without materials
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
|
||||
# ── Shadow catcher (Cycles only, template mode only) ─────────────────
|
||||
if shadow_catcher:
|
||||
@@ -482,7 +520,7 @@ def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
if usd_path and _import_usd_file:
|
||||
parts = _import_usd_file(usd_path)
|
||||
parts, _usd_mat_lookup = _import_usd_file(usd_path)
|
||||
else:
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
@@ -493,14 +531,23 @@ def main():
|
||||
for i, part in enumerate(parts):
|
||||
_apply_smooth(part, SMOOTH_ANGLE)
|
||||
|
||||
# Material assignment: library materials if available, else part_colors/palette
|
||||
if material_library_path and material_map:
|
||||
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
|
||||
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
|
||||
# Palette fallback for unmatched parts
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
# Material assignment: USD primvar path first, then name-matching fallback
|
||||
if material_library_path and _usd_mat_lookup:
|
||||
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
|
||||
if material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
_apply_material_library_shared(
|
||||
_unassigned, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
elif material_library_path and material_map:
|
||||
_apply_material_library_shared(
|
||||
parts, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
else:
|
||||
# part_colors or palette — use index-based lookup via part_names_ordered
|
||||
for i, part in enumerate(parts):
|
||||
@@ -523,6 +570,10 @@ def main():
|
||||
part.data.materials.append(mat)
|
||||
else:
|
||||
_assign_palette_material(part, i)
|
||||
# Palette fallback for any parts still without materials
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
|
||||
if needs_auto_camera:
|
||||
# ── Combined bounding box / bounding sphere ──────────────────────────
|
||||
@@ -573,7 +624,19 @@ def main():
|
||||
fill.data.size = max(4.0, bsphere_radius * 4.0)
|
||||
|
||||
# ── Camera ───────────────────────────────────────────────────────────
|
||||
cam_dist = bsphere_radius * 2.5
|
||||
_lens = _focal_length if _focal_length is not None else 50.0
|
||||
_sw = _sensor_width if _sensor_width is not None else 36.0
|
||||
if _focal_length is not None:
|
||||
# FOV-based distance when focal length is explicitly set
|
||||
_fov_h = math.atan(_sw / (2.0 * _lens))
|
||||
_fov_v = math.atan(_sw * (height / width) / (2.0 * _lens))
|
||||
_fov_used = min(_fov_h, _fov_v)
|
||||
_FILL_FACTOR = 0.85
|
||||
cam_dist = (bsphere_radius / math.tan(_fov_used)) / _FILL_FACTOR
|
||||
cam_dist = max(cam_dist, bsphere_radius * 1.05)
|
||||
print(f"[turntable_render] FOV-based cam_dist={cam_dist:.4f}, lens={_lens}mm")
|
||||
else:
|
||||
cam_dist = bsphere_radius * 2.5
|
||||
cam_location = Vector((
|
||||
bbox_center.x + cam_dist,
|
||||
bbox_center.y,
|
||||
@@ -582,6 +645,7 @@ def main():
|
||||
bpy.ops.object.camera_add(location=cam_location)
|
||||
camera = bpy.context.active_object
|
||||
bpy.context.scene.camera = camera
|
||||
camera.data.lens = _lens
|
||||
camera.data.clip_start = max(cam_dist * 0.001, 0.0001)
|
||||
camera.data.clip_end = cam_dist * 10.0
|
||||
|
||||
@@ -606,30 +670,57 @@ def main():
|
||||
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
|
||||
bg.inputs["Strength"].default_value = 0.15
|
||||
|
||||
# ── Turntable pivot ──────────────────────────────────────────────────
|
||||
pivot = bpy.data.objects.new("pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
# Parent camera to pivot
|
||||
camera.parent = pivot
|
||||
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
|
||||
|
||||
# Keyframe pivot rotation
|
||||
# ── Turntable animation ──────────────────────────────────────────────
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = frame_count
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
if camera_orbit:
|
||||
# Camera orbit: parent camera to pivot, rotate pivot
|
||||
pivot = bpy.data.objects.new("pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
camera.parent = pivot
|
||||
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=True — rotating camera around model")
|
||||
else:
|
||||
# Object rotation: camera stays fixed, model parts rotate around bbox center
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Reparent parts to pivot while preserving world positions.
|
||||
# Parts may have existing USD parents (Xform nodes), so simple
|
||||
# matrix_parent_inverse = pivot.inverted() is NOT enough — it
|
||||
# loses the old parent's contribution. Instead, capture world
|
||||
# matrix, reparent, then restore world position via local matrix.
|
||||
for part in parts:
|
||||
mw = part.matrix_world.copy()
|
||||
part.parent = pivot
|
||||
part.matrix_parent_inverse.identity()
|
||||
bpy.context.view_layer.update()
|
||||
part.matrix_local = pivot.matrix_world.inverted() @ mw
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=False — rotating model in front of camera")
|
||||
|
||||
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
|
||||
else:
|
||||
# Template has camera — set up turntable on the model parts instead
|
||||
# Template has its own camera (not auto-camera)
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = frame_count
|
||||
@@ -645,22 +736,44 @@ def main():
|
||||
(min(v.z for v in all_corners) + max(v.z for v in all_corners)) * 0.5,
|
||||
))
|
||||
|
||||
# Create a pivot empty and parent all parts to it
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
if camera_orbit:
|
||||
# Camera orbit mode: rotate the template's camera around the model
|
||||
template_cam = scene.camera
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
for part in parts:
|
||||
part.parent = pivot
|
||||
template_cam.parent = pivot
|
||||
|
||||
# Keyframe pivot rotation
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=True — rotating template camera around model")
|
||||
else:
|
||||
# Object rotation mode: rotate the model in front of template camera
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Reparent preserving world positions (parts may have USD parents)
|
||||
for part in parts:
|
||||
mw = part.matrix_world.copy()
|
||||
part.parent = pivot
|
||||
part.matrix_parent_inverse.identity()
|
||||
bpy.context.view_layer.update()
|
||||
part.matrix_local = pivot.matrix_world.inverted() @ mw
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=False — rotating model in front of template camera")
|
||||
|
||||
# ── Colour management ────────────────────────────────────────────────────
|
||||
# In template mode the .blend file owns its colour management settings.
|
||||
|
||||
Reference in New Issue
Block a user