Files
HartOMat/render-worker/scripts/blender_render.py
T
Hartmut 393e4b92a7 refactor(P1): complete pipeline cleanup — M1 dead code + M3 blender split
M1 dead code removal:
- admin.py: remove VALID_STL_QUALITIES + stl_quality (7 locations)
- frontend: remove stl_quality from 6 files (api/orders.ts, api/worker.ts,
  WorkerActivity.tsx, RenderInfoModal.tsx, helpTexts.ts, mocks/handlers.ts)
- blender_render.py: delete _mark_sharp_and_seams() — dead, never called (62 lines)
- step_processor.py: delete _render_via_service() + 2 elif renderer=="threejs" branches
- renderproblems_tmp/: remove 3 orphaned debug images

M3 blender_render.py decomposition (858 → 248 lines):
- _blender_gpu.py: activate_gpu(), configure_engine()
- _blender_import.py: import_glb(), apply_rotation()
- _blender_materials.py: FAILED_MATERIAL_NAME, assign_failed_material(),
  build_mat_map_lower(), apply_material_library()
- _blender_camera.py: setup_auto_camera(), setup_auto_lights()
- _blender_scene.py: ensure_collection(), apply_smooth_batch(),
  apply_sharp_edges_from_occ(), setup_shadow_catcher()
- Entry-point: sys.path.insert for submodule discovery; arg-parse + Mode A/B orchestration only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:19:59 +01:00

249 lines
11 KiB
Python

"""
Blender Python script for rendering a GLB file to PNG.
Targets Blender 5.0+ (EEVEE / Cycles).
Called by Blender:
blender --background --python blender_render.py -- \
<glb_path> <output_path> <width> <height> [engine] [samples]
engine: "cycles" (default) | "eevee"
Features:
- OCC-generated GLB: one mesh per STEP part, already in metres.
- Bounding-box-aware camera: object fills ~85 % of the frame.
- Isometric-style angle (elevation 28°, azimuth 40°).
- Dynamic clip planes.
- Standard (non-Filmic) colour management → no grey tint.
"""
import sys
import os
# 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,
)
# ── 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())}")
# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ─────
_early_gpu_type = activate_gpu(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'):
_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)
_lap._last = now
# ── SCENE SETUP ───────────────────────────────────────────────────────────────
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)
print("[blender_render] Done.")