Files
HartOMat/render-worker/scripts/turntable_render.py
T

933 lines
41 KiB
Python

"""Blender Python script: turntable animation render for Flamenco.
Usage (from Blender):
blender --background --python turntable_render.py -- \
<glb_path> <frames_dir> <frame_count> <degrees> <width> <height> \
<engine> <samples> <part_colors_json> \
[template_path] [target_collection] [material_library_path] [material_map_json]
"""
import bpy
import sys
import os
import json
import math
from mathutils import Vector, Matrix
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _blender_template_inputs import apply_template_inputs
# ── Colour palette (matches blender_render.py / Three.js renderer) ───────────
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def _srgb_to_linear(c: int) -> float:
v = c / 255.0
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
def _hex_to_linear(hex_color: str) -> tuple:
h = hex_color.lstrip('#')
return (
_srgb_to_linear(int(h[0:2], 16)),
_srgb_to_linear(int(h[2:4], 16)),
_srgb_to_linear(int(h[4:6], 16)),
1.0,
)
PALETTE_LINEAR = [_hex_to_linear(h) for h in PALETTE_HEX]
SMOOTH_ANGLE = 30 # degrees
# ── Helper functions ─────────────────────────────────────────────────────────
def _ensure_collection(name: str):
"""Return a collection by name, creating it if needed."""
if name in bpy.data.collections:
return bpy.data.collections[name]
col = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(col)
return col
def _assign_palette_material(part_obj, index):
"""Assign a palette colour material to a mesh part."""
color = PALETTE_LINEAR[index % len(PALETTE_LINEAR)]
mat = bpy.data.materials.new(name=f"Part_{index}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = 0.35
bsdf.inputs["Roughness"].default_value = 0.40
try:
bsdf.inputs["Specular IOR Level"].default_value = 0.5
except KeyError:
pass
part_obj.data.materials.clear()
part_obj.data.materials.append(mat)
def _apply_smooth(part_obj, angle_deg):
"""Apply smooth or flat shading to a mesh object."""
bpy.context.view_layer.objects.active = part_obj
part_obj.select_set(True)
if angle_deg > 0:
try:
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
except AttributeError:
bpy.ops.object.shade_smooth()
part_obj.data.use_auto_smooth = True
part_obj.data.auto_smooth_angle = math.radians(angle_deg)
else:
bpy.ops.object.shade_flat()
import re as _re
def _apply_rotation(parts, rx, ry, rz):
"""Apply Euler XYZ rotation (degrees) to all parts by modifying matrix_world.
Rotates around world origin, which equals the assembly centre because
_import_glb already centres parts there. Applied before material assignment
and camera/bbox calculations so everything downstream sees the final pose.
"""
if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):
return
from mathutils import Euler
rot_mat = Euler((math.radians(rx), math.radians(ry), math.radians(rz)), 'XYZ').to_matrix().to_4x4()
for p in parts:
p.matrix_world = rot_mat @ p.matrix_world
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
print(f"[turntable_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
def _axis_rotation(axis: str, degrees: float) -> tuple:
"""Map turntable axis name to Euler (x, y, z) rotation in radians."""
rad = math.radians(degrees)
if axis == "world_x":
return (rad, 0.0, 0.0)
elif axis == "world_y":
return (0.0, rad, 0.0)
else: # "world_z" default
return (0.0, 0.0, rad)
def _set_fcurves_linear(action):
"""Set LINEAR interpolation on all fcurves.
Handles both the legacy Blender < 4.4 API (action.fcurves) and the new
Baklava layered-action API introduced in Blender 4.4 / 5.x
(action.layers[*].strips[*].channelbags[*].fcurves).
"""
try:
# New layered-action API (Blender 4.4+ / 5.x)
for layer in action.layers:
for strip in layer.strips:
for channelbag in strip.channelbags:
for fc in channelbag.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
except AttributeError:
# Legacy API (Blender < 4.4)
for fc in action.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'LINEAR'
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
"""Apply topology-based shading settings from OCC analysis."""
import math
if not mesh_attributes or mesh_attributes.get("error"):
return
curved_ratio = mesh_attributes.get("curved_ratio", 0.0)
threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0)
threshold_rad = threshold_deg * math.pi / 180.0
for obj in objects:
if obj.type != 'MESH':
continue
# Enable smooth shading for predominantly curved parts (bearings etc.)
if curved_ratio > 0.3:
for poly in obj.data.polygons:
poly.use_smooth = True
# Auto-smooth at topology threshold
obj.data.use_auto_smooth = True
obj.data.auto_smooth_angle = threshold_rad
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
if not parts:
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
print(f"[turntable_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
# Move root objects (parentless) to centre. Adjusting a child's local
# .location by a world-space vector gives wrong results when the GLB has
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
# the entire hierarchy correctly.
all_imported = list(bpy.context.selected_objects)
root_objects = [o for o in all_imported if o.parent is None]
for obj in root_objects:
obj.location -= center
return parts
def _resolve_part_name(index, part_obj, part_names_ordered):
"""Get the STEP part name for a Blender part by index.
With per-part import, part_obj.name IS the STEP name (possibly with
Blender .NNN suffix). Falls back to part_names_ordered for combined-STL mode.
"""
base_name = _re.sub(r'\.\d{3}$', '', part_obj.name)
if part_names_ordered and index < len(part_names_ordered):
return part_names_ordered[index]
return base_name
def _apply_material_library(parts, mat_lib_path, mat_map, part_names_ordered=None):
"""Append materials from library .blend and assign to parts via material_map.
Matching priority per part:
1. GLB object name (strip Blender .NNN suffix + OCC _AF0/_AF1 suffix)
2. Prefix fallback (longest mat_map key that is a prefix of / contains part name)
3. Index-based via part_names_ordered (also strips _AF suffix)
mat_map: {part_name_lower: material_name}
Parts without a match keep their current material.
"""
if not mat_lib_path or not os.path.isfile(mat_lib_path):
print(f"[turntable_render] material library not found: {mat_lib_path}")
return
# Collect unique material names needed
needed = set(mat_map.values())
if not needed:
return
# Append materials from library
appended = {}
for mat_name in needed:
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[turntable_render] appended material: {mat_name}")
else:
print(f"[turntable_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[turntable_render] WARNING: failed to append material '{mat_name}': {exc}")
if not appended:
return
# Assign materials to parts — primary: name-based (per-part STL mode),
# secondary: index-based via part_names_ordered (combined STL fallback)
assigned_count = 0
for i, part in enumerate(parts):
# 1. Name-based: strip Blender .NNN suffix, then OCC _AF0/_AF1 suffix
base_name = _re.sub(r'\.\d{3}$', '', part.name)
_prev = None
while _prev != base_name:
_prev = base_name
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
# 2. Prefix fallback: longest mat_map key that is a prefix/suffix match
if not mat_name:
for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
if len(key) >= 5 and len(part_key) >= 5 and (
part_key.startswith(key) or key.startswith(part_key)
):
mat_name = val
break
# 3. Index-based fallback via part_names_ordered (also strips _AF suffix)
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
step_key = step_name.lower().strip()
mat_name = mat_map.get(step_key)
if not mat_name:
_p2 = None
while _p2 != step_key:
_p2 = step_key
step_key = _re.sub(r'_af\d+$', '', step_key)
mat_name = mat_map.get(step_key)
if mat_name and mat_name in appended:
part.data.materials.clear()
part.data.materials.append(appended[mat_name])
assigned_count += 1
print(f"[turntable_render] assigned '{mat_name}' to part '{part.name}'")
print(f"[turntable_render] material assignment: {assigned_count}/{len(parts)} parts matched")
def main():
argv = sys.argv
# Everything after "--" is our args
args = argv[argv.index("--") + 1:]
glb_path = args[0]
frames_dir = args[1]
frame_count = int(args[2])
degrees = int(args[3])
width = int(args[4])
height = int(args[5])
engine = args[6]
samples = int(args[7])
part_colors_json = args[8] if len(args) > 8 else "{}"
# Template + material library args (passed by hartomat-turntable.js)
template_path = args[9] if len(args) > 9 and args[9] else ""
target_collection = args[10] if len(args) > 10 else "Product"
material_library_path = args[11] if len(args) > 11 and args[11] else ""
material_map_raw = args[12] if len(args) > 12 else "{}"
part_names_ordered_raw = args[13] if len(args) > 13 else "[]"
lighting_only = args[14] == "1" if len(args) > 14 else False
cycles_device = args[15].lower() if len(args) > 15 else "auto" # "auto", "gpu", "cpu"
shadow_catcher = args[16] == "1" if len(args) > 16 else False
rotation_x = float(args[17]) if len(args) > 17 else 0.0
rotation_y = float(args[18]) if len(args) > 18 else 0.0
rotation_z = float(args[19]) if len(args) > 19 else 0.0
turntable_axis = args[20] if len(args) > 20 else "world_z"
bg_color = args[21] if len(args) > 21 else ""
transparent_bg = args[22] == "1" if len(args) > 22 else False
# Named argument: --mesh-attributes <json>
_mesh_attrs: dict = {}
if "--mesh-attributes" in argv:
_idx = argv.index("--mesh-attributes")
try:
_mesh_attrs = json.loads(argv[_idx + 1])
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 ""
# 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
# Named argument: --material-override <material_name>
_material_override = None
if "--material-override" in argv:
_idx = argv.index("--material-override")
_material_override = argv[_idx + 1] if _idx + 1 < len(argv) else None
template_inputs = {}
if "--template-inputs" in argv:
_idx = argv.index("--template-inputs")
try:
template_inputs = json.loads(argv[_idx + 1]) if _idx + 1 < len(argv) else {}
except Exception:
template_inputs = {}
# 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:
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:
part_colors = json.loads(part_colors_json)
except json.JSONDecodeError:
part_colors = {}
try:
material_map = json.loads(material_map_raw) if material_map_raw else {}
except json.JSONDecodeError:
material_map = {}
try:
part_names_ordered = json.loads(part_names_ordered_raw) if part_names_ordered_raw else []
except json.JSONDecodeError:
part_names_ordered = []
# Validate template path: if provided it MUST exist on disk.
if template_path and not os.path.isfile(template_path):
print(f"[turntable_render] ERROR: template_path was provided but file not found: {template_path}")
print("[turntable_render] Ensure the blend-templates directory is accessible on this worker.")
sys.exit(1)
use_template = bool(template_path)
print(f"[turntable_render] engine={engine}, samples={samples}, size={width}x{height}, "
f"frames={frame_count}, degrees={degrees}")
print(f"[turntable_render] part_names_ordered: {len(part_names_ordered)} entries")
if use_template:
print(f"[turntable_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
else:
print("[turntable_render] no template — using factory settings (Mode A)")
if material_library_path:
print(f"[turntable_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
if template_inputs:
print(f"[turntable_render] template_inputs={template_inputs}")
# ── SCENE SETUP ──────────────────────────────────────────────────────────
_usd_mat_lookup: dict = {} # populated by import_usd_file when USD path is used
if use_template:
# ── MODE B: Template-based render ────────────────────────────────────
print(f"[turntable_render] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
apply_template_inputs(template_inputs)
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import geometry: USD path when available, otherwise GLB
if usd_path and _import_usd_file:
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
_apply_mesh_attributes(parts, _mesh_attrs)
# Move imported parts into target collection
for part in parts:
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
# Apply smooth shading
for part in parts:
_apply_smooth(part, SMOOTH_ANGLE)
# Apply material override if set
if _material_override:
print(f"[turntable_render] material_override active: all parts → {_material_override}", flush=True)
if _usd_mat_lookup:
_usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
if material_map:
material_map = {k: _material_override for k in material_map}
# 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 == "HARTOMAT_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:
sc_col_name = "Shadowcatcher"
sc_obj_name = "Shadowcatcher"
for vl in bpy.context.scene.view_layers:
def _enable_col_recursive(layer_col):
if layer_col.collection.name == sc_col_name:
layer_col.exclude = False
layer_col.collection.hide_render = False
layer_col.collection.hide_viewport = False
return True
for child in layer_col.children:
if _enable_col_recursive(child):
return True
return False
_enable_col_recursive(vl.layer_collection)
sc_obj = bpy.data.objects.get(sc_obj_name)
if sc_obj:
all_world_z = []
for part in parts:
for corner in part.bound_box:
all_world_z.append((part.matrix_world @ Vector(corner)).z)
if all_world_z:
sc_obj.location.z = min(all_world_z)
print(f"[turntable_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
else:
print(f"[turntable_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")
# lighting_only: always use auto-framing; normal template: use camera if present
needs_auto_camera = (lighting_only and not shadow_catcher) or not bpy.context.scene.camera
if lighting_only and not shadow_catcher:
print("[turntable_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
elif needs_auto_camera:
print("[turntable_render] WARNING: template has no camera — will create auto-camera")
# Set very close near clip on template camera for mm-scale parts (now in metres)
if not needs_auto_camera and bpy.context.scene.camera:
bpy.context.scene.camera.data.clip_start = 0.001
print(f"[turntable_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")
else:
# ── MODE A: Factory settings ─────────────────────────────────────────
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
if usd_path and _import_usd_file:
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Apply OCC topology-based shading overrides
_apply_mesh_attributes(parts, _mesh_attrs)
for i, part in enumerate(parts):
_apply_smooth(part, SMOOTH_ANGLE)
# Apply material override if set (object-rotation mode)
if _material_override:
print(f"[turntable_render] material_override active (obj-rot): all parts → {_material_override}", flush=True)
if _usd_mat_lookup:
_usd_mat_lookup = {k: _material_override for k in _usd_mat_lookup}
if material_map:
material_map = {k: _material_override for k in material_map}
# 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 == "HARTOMAT_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):
step_name = _resolve_part_name(i, part, part_names_ordered)
color_hex = part_colors.get(step_name)
if color_hex:
mat = bpy.data.materials.new(name=f"mat_{part.name}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
color = _hex_to_linear(color_hex)
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = 0.35
bsdf.inputs["Roughness"].default_value = 0.40
try:
bsdf.inputs["Specular IOR Level"].default_value = 0.5
except KeyError:
pass
part.data.materials.clear()
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 ──────────────────────────
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
bbox_min = Vector((
min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners),
))
bbox_max = Vector((
max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners),
))
bbox_center = (bbox_min + bbox_max) * 0.5
bbox_dims = bbox_max - bbox_min
bsphere_radius = max(bbox_dims.length * 0.5, 0.001)
print(f"[turntable_render] bbox_dims={tuple(round(d, 4) for d in bbox_dims)}, "
f"bsphere_radius={bsphere_radius:.4f}")
# ── Lighting — only in Mode A (factory settings) ─────────────────────
# In template mode the .blend file provides its own World/HDRI lighting.
# Adding auto-lights would overpower the template's intended look.
if not use_template:
light_dist = bsphere_radius * 6.0
bpy.ops.object.light_add(type='SUN', location=(
bbox_center.x + light_dist * 0.5,
bbox_center.y - light_dist * 0.35,
bbox_center.z + light_dist,
))
sun = bpy.context.active_object
sun.data.energy = 4.0
sun.rotation_euler = (math.radians(45), 0, math.radians(30))
bpy.ops.object.light_add(type='AREA', location=(
bbox_center.x - light_dist * 0.4,
bbox_center.y + light_dist * 0.4,
bbox_center.z + light_dist * 0.7,
))
fill = bpy.context.active_object
fill.data.energy = max(800.0, bsphere_radius ** 2 * 2000.0)
fill.data.size = max(4.0, bsphere_radius * 4.0)
# ── Camera ───────────────────────────────────────────────────────────
_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,
bbox_center.z + bsphere_radius * 0.5,
))
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
# Track-to constraint for look-at
empty = bpy.data.objects.new("target", None)
bpy.context.collection.objects.link(empty)
empty.location = bbox_center
track = camera.constraints.new(type='TRACK_TO')
track.target = empty
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'
# ── World background — only in Mode A ───────────────────────────────
# In template mode the .blend file owns its World (HDRI, sky texture,
# studio lighting). Overwriting it would destroy the HDR look.
if not use_template:
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
bg.inputs["Strength"].default_value = 0.15
# ── Turntable animation ──────────────────────────────────────────────
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
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")
else:
# Template has its own camera (not auto-camera)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
# Calculate model center for pivot
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
bbox_center = Vector((
(min(v.x for v in all_corners) + max(v.x for v in all_corners)) * 0.5,
(min(v.y for v in all_corners) + max(v.y for v in all_corners)) * 0.5,
(min(v.z for v in all_corners) + max(v.z for v in all_corners)) * 0.5,
))
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
template_cam.parent = pivot
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 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.
# Overwriting them would destroy the intended HDR/tonemapping look.
# In factory-settings mode force Standard to avoid the grey Filmic tint.
scene = bpy.context.scene
if not use_template:
scene.view_settings.view_transform = 'Standard'
scene.view_settings.exposure = 0.0
scene.view_settings.gamma = 1.0
try:
scene.view_settings.look = 'None'
except Exception:
pass
# ── Render engine ────────────────────────────────────────────────────────
if engine == "eevee":
eevee_ok = False
for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
try:
scene.render.engine = eevee_id
eevee_ok = True
print(f"[turntable_render] EEVEE engine id: {eevee_id}")
break
except TypeError:
continue
if eevee_ok:
for attr in ('taa_render_samples', 'samples'):
try:
setattr(scene.eevee, attr, samples)
break
except AttributeError:
continue
else:
print("[turntable_render] WARNING: EEVEE not available, falling back to Cycles")
engine = "cycles"
if engine != "eevee":
scene.render.engine = 'CYCLES'
scene.cycles.samples = samples
scene.cycles.use_denoising = True
scene.cycles.denoiser = 'OPENIMAGEDENOISE' # GPU-accelerated when CUDA/OptiX active
# Device selection: "cpu" forces CPU, "gpu" forces GPU (warns if unavailable),
# "auto" (default) tries GPU first and falls back to CPU.
print(f"[turntable_render] cycles_device={cycles_device}")
gpu_found = False
if cycles_device != "cpu":
try:
cycles_prefs = bpy.context.preferences.addons['cycles'].preferences
for device_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
try:
cycles_prefs.compute_device_type = device_type
cycles_prefs.get_devices()
gpu_devs = [d for d in cycles_prefs.devices if d.type != 'CPU']
if gpu_devs:
for d in gpu_devs:
d.use = True
scene.cycles.device = 'GPU'
gpu_found = True
print(f"[turntable_render] Cycles GPU ({device_type})")
break
except Exception:
continue
except Exception:
pass
if gpu_found:
print(f"RENDER_DEVICE_USED: engine=CYCLES device=GPU compute_type={device_type}", flush=True)
else:
scene.cycles.device = 'CPU'
print("[turntable_render] WARNING: GPU not found — falling back to CPU")
print("RENDER_DEVICE_USED: engine=CYCLES device=CPU compute_type=NONE (fallback)", flush=True)
import os as _os
if _os.environ.get("CYCLES_DEVICE", "auto").lower() == "gpu":
print("GPU_REQUIRED_BUT_CPU_USED: strict mode active (CYCLES_DEVICE=gpu)", flush=True)
sys.exit(2)
# ── Render settings ──────────────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
# ── Transparent background ────────────────────────────────────────────────
# bg_color compositing is handled by FFmpeg in the compose-video task.
# Blender renders transparent PNG frames when bg_color is set.
if bg_color or transparent_bg:
scene.render.film_transparent = True
if bg_color:
print(f"[turntable_render] film_transparent=True for FFmpeg bg_color compositing ({bg_color})")
else:
print("[turntable_render] transparent_bg enabled (alpha PNG frames)")
# ── Persistent data (Cycles BVH caching between frames) ────────────────
scene.render.use_persistent_data = True
print("[turntable_render] persistent_data enabled — BVH cached between frames", flush=True)
# ── Render all frames ────────────────────────────────────────────────────
# Per-frame loop with write_still=True. In a single Blender session,
# Cycles keeps the GPU scene (BVH, textures, material graph) loaded
# between frames — only the animated pivot transform is updated each step.
# bpy.ops.render.render(animation=True) does NOT work reliably in
# background mode after wm.open_mainfile() in Blender 5.x (silently
# writes no files), so we use the explicit per-frame approach.
import time as _time
_render_start = _time.time()
for frame in range(1, frame_count + 1):
scene.frame_set(frame)
scene.render.filepath = os.path.join(frames_dir, f"frame_{frame:04d}")
bpy.ops.render.render(write_still=True)
elapsed = _time.time() - _render_start
fps_so_far = frame / elapsed
print(f"[turntable_render] Frame {frame}/{frame_count}{elapsed:.1f}s elapsed ({fps_so_far:.2f} fps)")
total = _time.time() - _render_start
print(f"[turntable_render] Turntable render complete: {frame_count} frames in {total:.1f}s ({frame_count/total:.2f} fps avg)")
if __name__ == "__main__":
main()