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:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+12
View File
@@ -63,6 +63,16 @@ def parse_args() -> SimpleNamespace:
_usd_idx = sys.argv.index("--usd-path")
usd_path = sys.argv[_usd_idx + 1] if _usd_idx + 1 < len(sys.argv) else ""
focal_length_mm = None
if "--focal-length" in sys.argv:
_fl_idx = sys.argv.index("--focal-length")
focal_length_mm = float(sys.argv[_fl_idx + 1]) if _fl_idx + 1 < len(sys.argv) else None
sensor_width_mm_override = None
if "--sensor-width" in sys.argv:
_sw_idx = sys.argv.index("--sensor-width")
sensor_width_mm_override = float(sys.argv[_sw_idx + 1]) if _sw_idx + 1 < len(sys.argv) else None
if template_path and not os.path.isfile(template_path):
print(f"[blender_render] ERROR: template not found: {template_path}")
sys.exit(1)
@@ -96,4 +106,6 @@ def parse_args() -> SimpleNamespace:
mesh_attributes=mesh_attributes,
usd_path=usd_path,
use_template=bool(template_path),
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm_override,
)
+14 -6
View File
@@ -10,7 +10,9 @@ SENSOR_WIDTH_MM = 36.0
FILL_FACTOR = 0.85
def setup_auto_camera(parts: list, width: int, height: int):
def setup_auto_camera(parts: list, width: int, height: int,
lens_mm: float | None = None,
sensor_width_mm: float | None = None):
"""Compute bounding sphere and place an isometric auto-camera.
Returns (bbox_center, bsphere_radius) as a tuple so the caller can
@@ -19,6 +21,9 @@ def setup_auto_camera(parts: list, width: int, height: int):
import bpy # type: ignore[import]
from mathutils import Vector, Matrix # type: ignore[import]
_lens = lens_mm if lens_mm is not None else LENS_MM
_sensor = sensor_width_mm if sensor_width_mm is not None else SENSOR_WIDTH_MM
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
@@ -50,18 +55,21 @@ def setup_auto_camera(parts: list, width: int, height: int):
math.sin(elevation_rad),
)).normalized()
fov_h = math.atan(SENSOR_WIDTH_MM / (2.0 * LENS_MM))
fov_v = math.atan(SENSOR_WIDTH_MM * (height / width) / (2.0 * LENS_MM))
fov_h = math.atan(_sensor / (2.0 * _lens))
fov_v = math.atan(_sensor * (height / width) / (2.0 * _lens))
fov_used = min(fov_h, fov_v)
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
dist = max(dist, bsphere_radius * 1.5)
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°")
# Minimum distance: prevent camera from being inside the bounding sphere,
# but scale with FOV so wide-angle lenses can still frame correctly.
min_dist = bsphere_radius * 1.05 # just outside the sphere surface
dist = max(dist, min_dist)
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°, lens={_lens}mm")
cam_location = bbox_center + cam_dir * dist
bpy.ops.object.camera_add(location=cam_location)
cam_obj = bpy.context.active_object
cam_obj.data.lens = LENS_MM
cam_obj.data.lens = _lens
bpy.context.scene.camera = cam_obj
look_dir = (bbox_center - cam_location).normalized()
+48 -7
View File
@@ -8,12 +8,45 @@ import time as _time
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
def _find_material_with_nodes(base_name: str):
"""Find a material by name that actually has shader nodes.
Blender's USD importer creates empty stub materials (use_nodes=True,
node_tree has 0 nodes) from USD material bindings. When we later
append the real material from a .blend library, Blender renames it
with a .001/.002 suffix to avoid the name collision.
This helper searches bpy.data.materials for the version that has
actual shader nodes, preferring exact name match, then .NNN suffixes.
"""
import bpy # type: ignore[import]
# Exact name first
exact = bpy.data.materials.get(base_name)
if exact and exact.node_tree and len(exact.node_tree.nodes) > 0:
return exact
# Search for .NNN suffixed versions
for mat in bpy.data.materials:
if not mat.name.startswith(base_name):
continue
suffix = mat.name[len(base_name):]
if suffix == "" or _re.match(r'^\.\d{3}$', suffix):
if mat.node_tree and len(mat.node_tree.nodes) > 0:
return mat
return None
def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
"""Append multiple materials from a .blend file in a single open.
Uses bpy.data.libraries.load() to open the .blend once instead of
N separate bpy.ops.wm.append() calls (each reopens the file).
Falls back to individual append for any materials that fail to load.
Handles empty material stubs left by Blender's USD importer: when a
stub exists with the target name, the library material gets renamed
with a .NNN suffix. We find it via _find_material_with_nodes().
"""
import bpy # type: ignore[import]
@@ -28,12 +61,16 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
to_load = [n for n in names if n in available]
not_found = names - available
data_to.materials = to_load
# After the context manager closes, materials are loaded into bpy.data
# After the context manager closes, materials are loaded into bpy.data.
# If a USD stub occupied the name, the real material gets a .NNN suffix.
for mat_name in to_load:
mat = bpy.data.materials.get(mat_name)
mat = _find_material_with_nodes(mat_name)
if mat:
result[mat_name] = mat
print(f"[blender_render] batch-appended material: {mat_name}")
if mat.name != mat_name:
print(f"[blender_render] batch-appended material: {mat_name} (as '{mat.name}', stub collision)")
else:
print(f"[blender_render] batch-appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after batch append")
if not_found:
@@ -51,7 +88,7 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
filename=mat_name,
link=False,
)
mat = bpy.data.materials.get(mat_name)
mat = _find_material_with_nodes(mat_name)
if mat:
result[mat_name] = mat
except Exception:
@@ -141,11 +178,15 @@ def apply_material_library_direct(
# Batch-append materials from library (single file open)
appended: dict = {}
_t_append = _time.monotonic()
# Check already-loaded materials first
# Check already-loaded materials first — but skip empty stubs created by
# Blender's USD importer (use_nodes=True but node_tree has 0 nodes).
# Those stubs must be loaded from the library via _batch_append_materials
# which uses _find_material_with_nodes() to resolve stub collisions.
still_needed = set()
for mat_name in needed:
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
existing = _find_material_with_nodes(mat_name)
if existing:
appended[mat_name] = existing
else:
still_needed.add(mat_name)
# Load remaining from .blend in one pass
@@ -111,7 +111,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{args.target_collection}'")
if needs_auto_camera:
setup_auto_camera(parts, args.width, args.height)
setup_auto_camera(parts, args.width, args.height,
lens_mm=args.focal_length_mm,
sensor_width_mm=args.sensor_width_mm)
def _setup_mode_a(args) -> None:
@@ -156,7 +158,9 @@ def _setup_mode_a(args) -> None:
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height)
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height,
lens_mm=args.focal_length_mm,
sensor_width_mm=args.sensor_width_mm)
setup_auto_lights(bbox_center, bsphere_radius)
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
+4 -52
View File
@@ -497,56 +497,6 @@ def _collect_part_key_map(shape_tool, free_labels) -> dict:
return part_key_map
def _apply_glb_mm_to_m_scale(glb_path: Path) -> None:
"""Wrap all GLB scene root nodes under a new root node with scale 0.001.
RWGltf_CafWriter exports geometry in mm (original STEP units).
BRepBuilderAPI_Transform destroys Poly_Triangulation, so we cannot scale
the B-Rep before export. Instead we add a root transform node to the GLB
that scales mm → m. glTF spec uses metres; Three.js and Blender honour
node scale transforms.
The GLB binary is re-serialized in-place.
"""
import struct as _struct
data = glb_path.read_bytes()
json_len = _struct.unpack_from("<I", data, 12)[0]
json_type = _struct.unpack_from("<I", data, 16)[0]
if json_type != 0x4E4F534A: # "JSON"
return
j = json.loads(data[20: 20 + json_len])
if "scenes" not in j or not j["scenes"]:
return
scene = j["scenes"][0]
old_roots = scene.get("nodes", [])
if not old_roots:
return
# Create a new root node with mm→m scale
nodes = j.setdefault("nodes", [])
new_root_idx = len(nodes)
nodes.append({
"name": "__mm_to_m_root__",
"scale": [0.001, 0.001, 0.001],
"children": old_roots,
})
scene["nodes"] = [new_root_idx]
new_json = json.dumps(j, separators=(",", ":"))
pad = (4 - len(new_json) % 4) % 4
new_json_bytes = new_json.encode() + b" " * pad
rest = data[20 + json_len:] # BIN chunk and anything after
new_chunk = _struct.pack("<II", len(new_json_bytes), 0x4E4F534A) + new_json_bytes
new_total = 12 + len(new_chunk) + len(rest)
new_header = _struct.pack("<III", 0x46546C67, 2, new_total)
glb_path.write_bytes(new_header + new_chunk + rest)
def _inject_glb_extras(glb_path: Path, extras: dict, part_key_map: dict | None = None) -> None:
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
@@ -817,8 +767,10 @@ def main() -> None:
except Exception as _exc:
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
# NOTE: RWGltf_CafWriter already converts mm → m and Z-up → Y-up internally.
# No additional scaling or coordinate transform is needed.
# NOTE: RWGltf_CafWriter reads unit metadata from the XDE document (set by
# STEPCAFControl_Reader from the STEP file's SI_UNIT declarations) and converts
# mm → m automatically. It also handles Z-up → Y-up coordinate transform.
# No additional scaling or BRepBuilderAPI_Transform is needed.
try:
+15 -3
View File
@@ -373,6 +373,18 @@ def main():
except Exception:
pass
# 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
os.makedirs(os.path.dirname(output_path), exist_ok=True)
try:
@@ -616,8 +628,8 @@ def main():
# ── Camera (isometric-style, matches blender_render.py) ──────────────
ELEVATION_DEG = 28.0
AZIMUTH_DEG = 40.0
LENS_MM = 50.0
SENSOR_WIDTH_MM = 36.0
LENS_MM = _focal_length if _focal_length is not None else 50.0
SENSOR_WIDTH_MM = _sensor_width if _sensor_width is not None else 36.0
FILL_FACTOR = 0.85
elevation_rad = math.radians(ELEVATION_DEG)
@@ -634,7 +646,7 @@ def main():
fov_used = min(fov_h, fov_v)
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
dist = max(dist, bsphere_radius * 1.5)
dist = max(dist, bsphere_radius * 1.05)
cam_location = bbox_center + cam_dir * dist
bpy.ops.object.camera_add(location=cam_location)
+170 -57
View File
@@ -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.