refactor(P1): M1 dead code removal + M3 blender_render.py split

M1 — dead code removed:
- Delete blender-renderer/ and threejs-renderer/ source files
- Remove PIL/Pillow fallback block from step_processor.py
  (_generate_thumbnail_placeholder, _finalise_image JPG path)
- Remove stl_quality param from render_blender.py, render_still_task,
  render_turntable_task (was always "low"; hardcode deflection values)
- render_turntable_task now reads scene_linear/angular_deflection from
  system_settings (consistent with export_glb.py pipeline)

M3 — blender_render.py split from 263 → 68 lines:
- _blender_args.py: parse_args() — all 25 positional + named args
- _blender_scene_setup.py: setup_scene() — MODE A/B including USD import
- _blender_render_config.py: configure_and_render() — engine + output

Post-review fixes:
- _db_engine.dispose() after settings read in render_turntable_task
- _finalise_image() fmt param removed (always PNG; PIL never installed)
- _blender_import.py committed together with new submodules to satisfy
  import_usd_file dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 12:54:40 +01:00
parent 393e4b92a7
commit 47b5d42bb5
10 changed files with 471 additions and 496 deletions
+99
View File
@@ -0,0 +1,99 @@
"""Argument parsing for blender_render.py.
Parses positional and named CLI arguments passed after the '--' separator
when Blender is invoked as:
blender --background --python blender_render.py -- <glb_path> ...
"""
import json as _json
import os
import sys
from types import SimpleNamespace
def parse_args() -> SimpleNamespace:
"""Parse CLI arguments and return a SimpleNamespace of all render options."""
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 4:
print("Usage: blender --background --python blender_render.py -- "
"<glb_path> <output_path> <width> <height> ...")
sys.exit(1)
def _arg(n, default="", transform=str):
return transform(argv[n]) if len(argv) > n and argv[n] else default
glb_path = argv[0]
output_path = argv[1]
width = int(argv[2])
height = int(argv[3])
engine = _arg(4, "cycles", str.lower)
samples = _arg(5, None, int)
smooth_angle = _arg(6, 30, int)
cycles_device = _arg(7, "auto", str.lower)
transparent_bg = argv[8] == "1" if len(argv) > 8 else False
template_path = _arg(9, "")
target_collection = _arg(10, "Product")
material_library_path = _arg(11, "")
material_map = _json.loads(_arg(12, "{}")) if _arg(12, "{}") else {}
part_names_ordered = _json.loads(_arg(13, "[]")) if _arg(13, "[]") else []
lighting_only = argv[14] == "1" if len(argv) > 14 else False
shadow_catcher = argv[15] == "1" if len(argv) > 15 else False
rotation_x = _arg(16, 0.0, float)
rotation_y = _arg(17, 0.0, float)
rotation_z = _arg(18, 0.0, float)
noise_threshold = _arg(19, "")
denoiser = _arg(20, "")
denoising_input_passes = _arg(21, "")
denoising_prefilter = _arg(22, "")
denoising_quality = _arg(23, "")
denoising_use_gpu = _arg(24, "")
if samples is None:
samples = 64 if engine == "eevee" else 256
mesh_attributes: dict = {}
if "--mesh-attributes" in sys.argv:
_idx = sys.argv.index("--mesh-attributes")
try:
mesh_attributes = _json.loads(sys.argv[_idx + 1])
except Exception:
pass
usd_path = ""
if "--usd-path" in sys.argv:
_usd_idx = sys.argv.index("--usd-path")
usd_path = sys.argv[_usd_idx + 1] if _usd_idx + 1 < len(sys.argv) else ""
if template_path and not os.path.isfile(template_path):
print(f"[blender_render] ERROR: template not found: {template_path}")
sys.exit(1)
return SimpleNamespace(
glb_path=glb_path,
output_path=output_path,
width=width,
height=height,
engine=engine,
samples=samples,
smooth_angle=smooth_angle,
cycles_device=cycles_device,
transparent_bg=transparent_bg,
template_path=template_path,
target_collection=target_collection,
material_library_path=material_library_path,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=lighting_only,
shadow_catcher=shadow_catcher,
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
noise_threshold=noise_threshold,
denoiser=denoiser,
denoising_input_passes=denoising_input_passes,
denoising_prefilter=denoising_prefilter,
denoising_quality=denoising_quality,
denoising_use_gpu=denoising_use_gpu,
mesh_attributes=mesh_attributes,
usd_path=usd_path,
use_template=bool(template_path),
)
+6
View File
@@ -83,3 +83,9 @@ def apply_rotation(parts: list, rx: float, ry: float, rz: float) -> None:
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
def import_usd_file(usd_path: str) -> list:
"""Import USD stage into current Blender scene — delegates to import_usd module."""
from import_usd import import_usd_file as _impl
return _impl(usd_path)
@@ -0,0 +1,55 @@
"""Engine configuration and final render call for blender_render.py."""
import sys
from typing import Callable
import bpy # type: ignore[import]
from _blender_gpu import configure_engine
def configure_and_render(args, early_gpu_type, use_template: bool, lap_fn: Callable[[str], None]) -> None:
"""Configure render engine, colour management, resolution, then render.
Reads engine, samples, device, denoiser, and output settings from args.
lap_fn is called with label strings at timing checkpoints.
"""
scene = bpy.context.scene
configure_engine(
scene, args.engine, args.samples, args.cycles_device, early_gpu_type,
args.noise_threshold, args.denoiser,
args.denoising_input_passes, args.denoising_prefilter,
args.denoising_quality, args.denoising_use_gpu,
)
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
scene.render.resolution_x = args.width
scene.render.resolution_y = args.height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.filepath = args.output_path
scene.render.film_transparent = args.transparent_bg
if scene.render.engine == "CYCLES":
cprefs = bpy.context.preferences.addons["cycles"].preferences
print(
f"[blender_render] VERIFY: engine={scene.render.engine}, "
f"cycles.device={scene.cycles.device}, "
f"compute_device_type={cprefs.compute_device_type}, "
f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}",
flush=True,
)
lap_fn("pre_render_setup")
print(f"[blender_render] Rendering → {args.output_path} (Blender {bpy.app.version_string})", flush=True)
sys.stdout.flush()
bpy.ops.render.render(write_still=True)
print("[blender_render] render done.", flush=True)
lap_fn("gpu_render")
@@ -0,0 +1,129 @@
"""MODE A / MODE B scene setup for blender_render.py.
MODE A — factory settings (no template): auto-camera + auto-lights
MODE B — template file: load .blend, import into named collection
"""
import time as _time
from typing import Callable
import bpy # type: ignore[import]
from _blender_camera import setup_auto_camera, setup_auto_lights
from _blender_import import import_glb, apply_rotation, import_usd_file
from _blender_materials import (
assign_failed_material,
build_mat_map_lower,
apply_material_library,
)
from _blender_scene import (
ensure_collection,
apply_smooth_batch,
apply_sharp_edges_from_occ,
setup_shadow_catcher,
)
def setup_scene(args, lap_fn: Callable[[str], None]) -> None:
"""Set up the Blender scene according to args (MODE A or B).
Handles import, rotation, smooth shading, material assignment, shadow
catcher, and auto-camera/lights. lap_fn is called with a label string
at each timing checkpoint.
"""
if args.use_template:
_setup_mode_b(args, lap_fn)
else:
_setup_mode_a(args)
def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
"""MODE B: Template-based render — load .blend, import into collection."""
print(f"[blender_render] Opening template: {args.template_path}")
bpy.ops.wm.open_mainfile(filepath=args.template_path)
lap_fn("template_load")
target_col = ensure_collection(args.target_collection)
if args.usd_path:
parts = import_usd_file(args.usd_path)
else:
parts = import_glb(args.glb_path)
lap_fn("glb_import")
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
lap_fn("rotation")
for part in parts:
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
apply_smooth_batch(parts, args.smooth_angle)
if not args.usd_path:
_occ_pairs = args.mesh_attributes.get("sharp_edge_pairs") or []
if _occ_pairs:
apply_sharp_edges_from_occ(parts, _occ_pairs)
lap_fn("smooth_shading")
if args.material_library_path and args.material_map:
apply_material_library(
parts, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
else:
for part in parts:
assign_failed_material(part)
lap_fn("material_assign")
if args.shadow_catcher:
setup_shadow_catcher(parts)
needs_auto_camera = (
(args.lighting_only and not args.shadow_catcher)
or not bpy.context.scene.camera
)
if args.lighting_only and not args.shadow_catcher:
print("[blender_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
elif needs_auto_camera:
print("[blender_render] WARNING: template has no camera — will create auto-camera")
if not needs_auto_camera and bpy.context.scene.camera:
bpy.context.scene.camera.data.clip_start = 0.001
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)
def _setup_mode_a(args) -> None:
"""MODE A: Factory settings — auto-camera + auto-lights."""
bpy.ops.wm.read_factory_settings(use_empty=True)
if args.usd_path:
parts = import_usd_file(args.usd_path)
else:
parts = import_glb(args.glb_path)
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
_t = _time.time()
apply_smooth_batch(parts, args.smooth_angle)
if not args.usd_path:
_occ_pairs = args.mesh_attributes.get("sharp_edge_pairs") or []
if _occ_pairs:
apply_sharp_edges_from_occ(parts, _occ_pairs)
for part in parts:
assign_failed_material(part)
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t:.2f}s)", flush=True)
if args.material_library_path and args.material_map:
apply_material_library(
parts, args.material_library_path,
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height)
setup_auto_lights(bbox_center, bsphere_radius)
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
+17 -197
View File
@@ -17,231 +17,51 @@ Features:
"""
import sys
import os
import time as _time
# Force unbuffered stdout so render log lines appear immediately
os.environ["PYTHONUNBUFFERED"] = "1"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
# Add script directory to sys.path so Blender Python finds our submodules
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bpy # type: ignore[import]
from _blender_gpu import activate_gpu, configure_engine
from _blender_import import import_glb, apply_rotation
from _blender_materials import (
FAILED_MATERIAL_NAME, assign_failed_material,
build_mat_map_lower, apply_material_library,
)
from _blender_camera import setup_auto_camera, setup_auto_lights
from _blender_scene import (
ensure_collection, apply_smooth_batch,
apply_sharp_edges_from_occ, setup_shadow_catcher,
)
from _blender_gpu import activate_gpu
from _blender_args import parse_args
from _blender_scene_setup import setup_scene
from _blender_render_config import configure_and_render
# ── Parse arguments ────────────────────────────────────────────────────────────
import json as _json
def _arg(n, default="", transform=str):
return transform(argv[n]) if len(argv) > n and argv[n] else default
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 4:
print("Usage: blender --background --python blender_render.py -- "
"<glb_path> <output_path> <width> <height> ...")
sys.exit(1)
glb_path = argv[0]
output_path = argv[1]
width = int(argv[2])
height = int(argv[3])
engine = _arg(4, "cycles", str.lower)
samples = _arg(5, None, int)
smooth_angle = _arg(6, 30, int)
cycles_device = _arg(7, "auto", str.lower)
transparent_bg = argv[8] == "1" if len(argv) > 8 else False
template_path = _arg(9, "")
target_collection = _arg(10, "Product")
material_library_path = _arg(11, "")
material_map = _json.loads(_arg(12, "{}")) if _arg(12, "{}") else {}
part_names_ordered = _json.loads(_arg(13, "[]")) if _arg(13, "[]") else []
lighting_only = argv[14] == "1" if len(argv) > 14 else False
shadow_catcher = argv[15] == "1" if len(argv) > 15 else False
rotation_x = _arg(16, 0.0, float)
rotation_y = _arg(17, 0.0, float)
rotation_z = _arg(18, 0.0, float)
noise_threshold_arg = _arg(19, "")
denoiser_arg = _arg(20, "")
denoising_input_passes_arg = _arg(21, "")
denoising_prefilter_arg = _arg(22, "")
denoising_quality_arg = _arg(23, "")
denoising_use_gpu_arg = _arg(24, "")
if samples is None:
samples = 64 if engine == "eevee" else 256
# Named argument: --mesh-attributes <json>
_mesh_attrs: dict = {}
if "--mesh-attributes" in sys.argv:
_idx = sys.argv.index("--mesh-attributes")
try:
_mesh_attrs = _json.loads(sys.argv[_idx + 1])
except Exception:
pass
if template_path and not os.path.isfile(template_path):
print(f"[blender_render] ERROR: template not found: {template_path}")
sys.exit(1)
use_template = bool(template_path)
print(f"[blender_render] engine={engine}, samples={samples}, size={width}x{height}, smooth_angle={smooth_angle}°, device={cycles_device}, transparent={transparent_bg}")
print(f"[blender_render] part_names_ordered: {len(part_names_ordered)} entries")
print(f"[blender_render] {'template='+template_path+', collection='+target_collection+', lighting_only='+str(lighting_only) if use_template else 'no template — Mode A'}")
if material_library_path:
print(f"[blender_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
args = parse_args()
print(f"[blender_render] engine={args.engine}, samples={args.samples}, size={args.width}x{args.height}, smooth_angle={args.smooth_angle}°, device={args.cycles_device}, transparent={args.transparent_bg}")
print(f"[blender_render] part_names_ordered: {len(args.part_names_ordered)} entries")
print(f"[blender_render] {'template='+args.template_path+', collection='+args.target_collection+', lighting_only='+str(args.lighting_only) if args.use_template else 'no template — Mode A'}")
if args.material_library_path:
print(f"[blender_render] material_library={args.material_library_path}, material_map keys={list(args.material_map.keys())}")
# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ─────
_early_gpu_type = activate_gpu(cycles_device)
_early_gpu_type = activate_gpu(args.cycles_device)
# ── Timing harness ─────────────────────────────────────────────────────────────
import time as _time
_t0 = _time.monotonic()
_timings: dict = {}
def _lap(label: str) -> None:
now = _time.monotonic()
if not hasattr(_lap, '_last'):
if not hasattr(_lap, "_last"):
_lap._last = _t0
delta = now - _lap._last
total = now - _t0
_timings[label] = round(delta, 3)
print(f"[blender_render] TIMING {label}={delta:.2f}s (total={total:.2f}s)", flush=True)
print(f"[blender_render] TIMING {label}={delta:.2f}s (total={now - _t0:.2f}s)", flush=True)
_lap._last = now
# ── SCENE SETUP ───────────────────────────────────────────────────────────────
# ── Scene setup + render ───────────────────────────────────────────────────────
setup_scene(args, _lap)
configure_and_render(args, _early_gpu_type, args.use_template, _lap)
if use_template:
# ── MODE B: Template-based render ─────────────────────────────────────────
print(f"[blender_render] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
_lap("template_load")
target_col = ensure_collection(target_collection)
parts = import_glb(glb_path)
_lap("glb_import")
apply_rotation(parts, rotation_x, rotation_y, rotation_z)
_lap("rotation")
for part in parts:
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
apply_smooth_batch(parts, smooth_angle)
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
if _occ_pairs:
apply_sharp_edges_from_occ(parts, _occ_pairs)
_lap("smooth_shading")
if material_library_path and material_map:
apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
else:
for part in parts:
assign_failed_material(part)
_lap("material_assign")
if shadow_catcher:
setup_shadow_catcher(parts)
needs_auto_camera = (lighting_only and not shadow_catcher) or not bpy.context.scene.camera
if lighting_only and not shadow_catcher:
print("[blender_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
elif needs_auto_camera:
print("[blender_render] WARNING: template has no camera — will create auto-camera")
if not needs_auto_camera and bpy.context.scene.camera:
bpy.context.scene.camera.data.clip_start = 0.001
print(f"[blender_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_glb(glb_path)
apply_rotation(parts, rotation_x, rotation_y, rotation_z)
_t_smooth_a = _time.time()
apply_smooth_batch(parts, smooth_angle)
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
if _occ_pairs:
apply_sharp_edges_from_occ(parts, _occ_pairs)
for part in parts:
assign_failed_material(part)
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
if material_library_path and material_map:
apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
if needs_auto_camera:
bbox_center, bsphere_radius = setup_auto_camera(parts, width, height)
if not use_template:
setup_auto_lights(bbox_center, bsphere_radius)
# Mode A world background
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
# ── Render engine ──────────────────────────────────────────────────────────────
scene = bpy.context.scene
engine = configure_engine(
scene, engine, samples, cycles_device, _early_gpu_type,
noise_threshold_arg, denoiser_arg,
denoising_input_passes_arg, denoising_prefilter_arg,
denoising_quality_arg, denoising_use_gpu_arg,
)
# ── Colour management ──────────────────────────────────────────────────────────
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 settings ────────────────────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = output_path
scene.render.film_transparent = transparent_bg
# ── Final verification + render ────────────────────────────────────────────────
if scene.render.engine == 'CYCLES':
cprefs = bpy.context.preferences.addons['cycles'].preferences
print(f"[blender_render] VERIFY: engine={scene.render.engine}, "
f"cycles.device={scene.cycles.device}, "
f"compute_device_type={cprefs.compute_device_type}, "
f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}",
flush=True)
_lap("pre_render_setup")
print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})", flush=True)
sys.stdout.flush()
bpy.ops.render.render(write_still=True)
print("[blender_render] render done.", flush=True)
_lap("gpu_render")
# ── Final timing summary ───────────────────────────────────────────────────────
_total = _time.monotonic() - _t0
print(f"[blender_render] TIMING_SUMMARY total={_total:.2f}s | " +
" | ".join(f"{k}={v:.2f}s" for k, v in _timings.items()), flush=True)