refactor(A2): replace blender-renderer HTTP service with render-worker Celery container
- Create render-worker/ with Dockerfile (Ubuntu + cadquery + Blender via host mount) - Add render-worker/check_version.py: verifies Blender >= 5.0.1 at startup, Exit 1 on failure - Add render-worker/scripts/: blender_render.py, still_render.py, turntable_render.py - Create backend/app/services/render_blender.py: direct subprocess rendering - convert_step_to_stl() and export_per_part_stls() using cadquery - render_still(): STEP → STL → PNG via Blender subprocess - is_blender_available(): detects BLENDER_BIN env for render-worker context - Create backend/app/domains/rendering/tasks.py: render_still_task + render_turntable_task - Update step_processor.py: use subprocess path when BLENDER_BIN env is set (render-worker) - Update step_tasks.py: generate_stl_cache uses direct cadquery instead of HTTP - Remove blender-renderer and threejs-renderer from docker-compose.yml - Replace worker-thumbnail with render-worker (Ubuntu + cadquery + Blender mount) - Remove Docker SDK from backend Dockerfile (was only for flamenco scaling) - Update .env.example: BLENDER_VERSION=5.0.1 documented - Update celery_app.py: include domains.rendering.tasks in autodiscover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
"""Blender Python script: turntable animation render for Flamenco.
|
||||
|
||||
Usage (from Blender):
|
||||
blender --background --python turntable_render.py -- \
|
||||
<stl_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
|
||||
|
||||
# ── 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_stl 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'
|
||||
|
||||
|
||||
def _scale_mm_to_m(parts):
|
||||
"""Scale imported STL objects from mm to Blender metres (×0.001).
|
||||
|
||||
STEP/STL coordinates are in mm; Blender's default unit is metres.
|
||||
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
|
||||
relative to any template environment designed in metric units.
|
||||
"""
|
||||
if not parts:
|
||||
return
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for p in parts:
|
||||
p.scale = (0.001, 0.001, 0.001)
|
||||
p.location *= 0.001
|
||||
p.select_set(True)
|
||||
bpy.context.view_layer.objects.active = parts[0]
|
||||
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
|
||||
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
|
||||
|
||||
|
||||
def _import_stl(stl_file):
|
||||
"""Import STL into Blender, using per-part STLs if available.
|
||||
|
||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
||||
- Fallback: imports combined STL and splits by loose geometry.
|
||||
|
||||
Returns list of Blender mesh objects, centred at origin.
|
||||
"""
|
||||
stl_dir = os.path.dirname(stl_file)
|
||||
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
|
||||
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
|
||||
manifest_path = os.path.join(parts_dir, "manifest.json")
|
||||
|
||||
parts = []
|
||||
|
||||
if os.path.isfile(manifest_path):
|
||||
# ── Per-part mode ────────────────────────────────────────────────
|
||||
try:
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = json.loads(f.read())
|
||||
part_entries = manifest.get("parts", [])
|
||||
except Exception as e:
|
||||
print(f"[turntable_render] WARNING: failed to read manifest: {e}")
|
||||
part_entries = []
|
||||
|
||||
if part_entries:
|
||||
for entry in part_entries:
|
||||
part_file = os.path.join(parts_dir, entry["file"])
|
||||
part_name = entry["name"]
|
||||
if not os.path.isfile(part_file):
|
||||
print(f"[turntable_render] WARNING: part STL missing: {part_file}")
|
||||
continue
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
bpy.ops.wm.stl_import(filepath=part_file)
|
||||
imported = bpy.context.selected_objects
|
||||
if imported:
|
||||
obj = imported[0]
|
||||
obj.name = part_name
|
||||
if obj.data:
|
||||
obj.data.name = part_name
|
||||
parts.append(obj)
|
||||
|
||||
if parts:
|
||||
print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs")
|
||||
|
||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
||||
if not parts:
|
||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
||||
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
|
||||
if obj is None:
|
||||
print(f"ERROR: No objects imported from {stl_file}")
|
||||
sys.exit(1)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
parts = list(bpy.context.selected_objects)
|
||||
print(f"[turntable_render] fallback: separated into {len(parts)} part(s)")
|
||||
return parts
|
||||
|
||||
# ── Centre per-part imports at origin (combined bbox) ────────────────
|
||||
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
|
||||
for p in parts:
|
||||
p.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.
|
||||
|
||||
With per-part STL import, Blender objects are named after STEP parts,
|
||||
so matching is by name (stripping Blender .NNN suffix for duplicates).
|
||||
Falls back to part_names_ordered index-based matching for combined-STL mode.
|
||||
|
||||
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):
|
||||
# Try name-based matching first (strip Blender .NNN suffix)
|
||||
base_name = _re.sub(r'\.\d{3}$', '', part.name)
|
||||
part_key = base_name.lower().strip()
|
||||
mat_name = mat_map.get(part_key)
|
||||
|
||||
# Fall back to index-based matching via part_names_ordered
|
||||
if not mat_name and part_names_ordered and i < len(part_names_ordered):
|
||||
step_name = part_names_ordered[i]
|
||||
part_key = step_name.lower().strip()
|
||||
mat_name = mat_map.get(part_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:]
|
||||
|
||||
stl_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 schaeffler-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
|
||||
|
||||
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())}")
|
||||
|
||||
# ── SCENE SETUP ──────────────────────────────────────────────────────────
|
||||
|
||||
if use_template:
|
||||
# ── MODE B: Template-based render ────────────────────────────────────
|
||||
print(f"[turntable_render] Opening template: {template_path}")
|
||||
bpy.ops.wm.open_mainfile(filepath=template_path)
|
||||
|
||||
# Find or create target collection
|
||||
target_col = _ensure_collection(target_collection)
|
||||
|
||||
# Import and split STL
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Apply render position rotation before material/camera setup
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# ── 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)
|
||||
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Apply render position rotation before material/camera setup
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
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.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 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
|
||||
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)
|
||||
|
||||
# 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
|
||||
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,
|
||||
))
|
||||
|
||||
# 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
|
||||
|
||||
for part in parts:
|
||||
part.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)
|
||||
|
||||
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
|
||||
# ── 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 not gpu_found:
|
||||
scene.cycles.device = 'CPU'
|
||||
print("[turntable_render] WARNING: GPU not found — falling back to CPU")
|
||||
|
||||
# ── 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)")
|
||||
|
||||
# ── 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()
|
||||
Reference in New Issue
Block a user