_mesh_attrs: dict = {}
-_sys_argv = sys.argv
-if "--mesh-attributes" in _sys_argv:
- _idx = _sys_argv.index("--mesh-attributes")
+if "--mesh-attributes" in sys.argv:
+ _idx = sys.argv.index("--mesh-attributes")
try:
- _mesh_attrs = _json.loads(_sys_argv[_idx + 1])
+ _mesh_attrs = _json.loads(sys.argv[_idx + 1])
except Exception:
pass
-# Validate template path: if provided it MUST exist on disk.
-# Fail loudly rather than silently rendering with factory settings.
if template_path and not os.path.isfile(template_path):
- print(f"[blender_render] ERROR: template_path was provided but file not found: {template_path}")
- print("[blender_render] Check that the blend-templates directory is on the shared volume.")
+ 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")
-if use_template:
- print(f"[blender_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
-else:
- print("[blender_render] no template — using factory settings (Mode A)")
+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())}")
-# ── Helper: find or create collection by name ────────────────────────────────
+# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ─────
+_early_gpu_type = activate_gpu(cycles_device)
-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 _apply_smooth_batch(parts, angle_deg):
- """Apply smooth shading to ALL parts in a single operator call.
-
- bpy.ops.object.shade_smooth_by_angle() operates on all selected objects
- at once (one C-level call), so batching reduces O(n) operator overhead to O(1).
- Per-part calls cost ~90ms each × 175 parts = 16s; batch call costs ~0.2s total.
- """
- bpy.ops.object.select_all(action='DESELECT')
- mesh_parts = [p for p in parts if p.type == 'MESH']
- for part in mesh_parts:
- part.select_set(True)
- if not mesh_parts:
- return
- bpy.context.view_layer.objects.active = mesh_parts[0]
- if angle_deg > 0:
- try:
- bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
- except AttributeError:
- bpy.ops.object.shade_smooth()
- for part in mesh_parts:
- if hasattr(part.data, 'use_auto_smooth'):
- part.data.use_auto_smooth = True
- part.data.auto_smooth_angle = math.radians(angle_deg)
- else:
- bpy.ops.object.shade_flat()
- bpy.ops.object.select_all(action='DESELECT')
-
-
-def _assign_failed_material(part_obj):
- """Assign the standard fallback material (magenta) when no library material matches.
-
- Tries to reuse SCHAEFFLER_059999_FailedMaterial from the library first.
- Creates a simple magenta Principled BSDF if the library material is not loaded.
- """
- mat = bpy.data.materials.get(FAILED_MATERIAL_NAME)
- if mat is None:
- mat = bpy.data.materials.new(name=FAILED_MATERIAL_NAME)
- mat.use_nodes = True
- bsdf = mat.node_tree.nodes.get("Principled BSDF")
- if bsdf:
- bsdf.inputs["Base Color"].default_value = (1.0, 0.0, 1.0, 1.0) # magenta
- bsdf.inputs["Roughness"].default_value = 0.6
- part_obj.data.materials.clear()
- part_obj.data.materials.append(mat)
-
-
-import re as _re
-
-
-# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
-
-
-def _apply_rotation(parts, rx, ry, rz):
- """Apply Euler rotation (degrees, XYZ order) to all parts around world origin.
-
- After _import_glb the combined bbox center is at world origin,
- so rotating around origin is equivalent to rotating around the assembly center.
- """
- 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
- # Bake rotation into mesh data so camera bbox calculations see the rotated geometry
- 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"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
-
-
-def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=None):
- """Mark sharp edges and UV seams based on angle threshold and optional midpoints."""
- import math
- import bpy
-
- # Ensure we're working with the right object
- bpy.context.view_layer.objects.active = obj
- obj.select_set(True)
-
- # Set auto-smooth angle
- if hasattr(obj.data, 'auto_smooth_angle'):
- obj.data.auto_smooth_angle = math.radians(smooth_angle_deg)
-
- # Enter edit mode to mark edges
- bpy.ops.object.mode_set(mode='EDIT')
- bpy.ops.mesh.select_all(action='DESELECT')
-
- # Select edges above threshold angle and mark sharp
- bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(smooth_angle_deg))
- bpy.ops.mesh.mark_sharp()
-
- # Mark same edges as UV seams
- bpy.ops.mesh.mark_seam(clear=False)
-
- # If we have OCC-derived midpoints, try to mark additional edges
- if sharp_edge_midpoints and len(sharp_edge_midpoints) > 0:
- try:
- import bmesh
- bpy.ops.object.mode_set(mode='OBJECT')
- bm = bmesh.new()
- bm.from_mesh(obj.data)
- bm.edges.ensure_lookup_table()
- bm.verts.ensure_lookup_table()
-
- # Build KD-tree for edge midpoints
- import mathutils
- kd = mathutils.kdtree.KDTree(len(bm.edges))
- for i, edge in enumerate(bm.edges):
- midpt = (edge.verts[0].co + edge.verts[1].co) / 2
- kd.insert(midpt, i)
- kd.balance()
-
- # For each OCC sharp midpoint, find nearest Blender edge
- tol = 0.5 # 0.5 mm tolerance (coordinates in mm before scale)
- for mp in sharp_edge_midpoints[:200]:
- vec = mathutils.Vector(mp)
- co, idx, dist = kd.find(vec)
- if dist < tol:
- bm.edges[idx].seam = True
- try:
- bm.edges[idx].smooth = False
- except Exception:
- pass
-
- bm.to_mesh(obj.data)
- bm.free()
- except Exception:
- pass # Non-fatal
-
- # Return to object mode
- bpy.ops.object.mode_set(mode='OBJECT')
-
-
-def _apply_sharp_edges_from_occ(parts, sharp_edge_pairs):
- """Mark edges sharp using OCC-derived vertex-pair data.
-
- `sharp_edge_pairs` is a list of [[x0,y0,z0],[x1,y1,z1]] in mm.
- Blender mesh coordinates are in metres (STEP mm * 0.001 scale applied).
- We match each OCC vertex pair against bmesh vertex positions with a 0.5 mm
- tolerance (0.0005 m) and mark the matched edge as sharp.
- """
- if not sharp_edge_pairs:
- return
-
- import bmesh
- import mathutils
-
- SCALE = 0.001 # mm → m
- TOL = 0.0005 # 0.5 mm in metres
-
- # OCC STEP space (Z-up, mm) → Blender (Z-up, m):
- # RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
- # Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
- occ_pairs = []
- for pair in sharp_edge_pairs:
- v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
- v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
- occ_pairs.append((v0, v1))
-
- marked_total = 0
- for obj in parts:
- bm = bmesh.new()
- bm.from_mesh(obj.data)
- bm.verts.ensure_lookup_table()
- bm.edges.ensure_lookup_table()
-
- # Build KD-tree on vertices in WORLD space — OCC pairs are world coords,
- # but mesh vertices are in local space (assembly node transform in GLB).
- world_mat = obj.matrix_world
- kd = mathutils.kdtree.KDTree(len(bm.verts))
- for v in bm.verts:
- kd.insert(world_mat @ v.co, v.index)
- kd.balance()
-
- marked = 0
- for v0_occ, v1_occ in occ_pairs:
- # Find closest Blender vertex to each OCC endpoint
- _co0, idx0, dist0 = kd.find(v0_occ)
- _co1, idx1, dist1 = kd.find(v1_occ)
- if dist0 > TOL or dist1 > TOL:
- continue
- if idx0 == idx1:
- continue # degenerate — both endpoints map to same vertex
- # Find the edge shared by these two vertices
- bv0 = bm.verts[idx0]
- bv1 = bm.verts[idx1]
- edge = bm.edges.get((bv0, bv1))
- if edge is None:
- edge = bm.edges.get((bv1, bv0))
- if edge is not None and edge.smooth:
- edge.smooth = False
- marked += 1
-
- bm.to_mesh(obj.data)
- bm.free()
- marked_total += marked
-
- print(f"[blender_render] OCC sharp edges applied: {marked_total} edges marked across {len(parts)} parts", flush=True)
-
-
-def _import_glb(glb_file):
- """Import OCC-generated GLB into Blender.
-
- OCC exports one mesh object per STEP part, already in metres.
- Blender's native GLTF importer preserves part names.
-
- 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"[blender_render] imported {len(parts)} part(s) from GLB: "
- f"{[p.name for p in parts[:5]]}")
-
- # Remove OCC-baked custom normals so shade_smooth_by_angle can recompute
- # normals from scratch (respecting our sharp edge marks).
- cleared = 0
- for p in parts:
- if "custom_normal" in p.data.attributes:
- p.data.attributes.remove(p.data.attributes["custom_normal"])
- cleared += 1
- if cleared:
- print(f"[blender_render] cleared OCC custom_normal from {cleared} mesh objects")
-
- # 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):
- """Get the STEP part name for a Blender part by index.
-
- With GLB import, part_obj.name IS the STEP name (possibly with
- Blender .NNN suffix for duplicates). Strip that suffix for lookup.
- Falls back to part_names_ordered index mapping.
- """
- # Strip Blender auto-suffix (.001, .002, etc.)
- base_name = _re.sub(r'\.\d{3}$', '', part_obj.name)
- # If the base name looks like a real STEP part name (not generic "Cube" etc.),
- # use it directly
- 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):
- """Append materials from library .blend and assign to parts via material_map.
-
- GLB-imported 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.
-
- 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"[blender_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"[blender_render] appended material: {mat_name}")
- else:
- print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
- except Exception as exc:
- print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
-
- if not appended:
- return
-
- # Assign materials to parts — primary: name-based (GLB object names),
- # secondary: index-based via part_names_ordered
- assigned_count = 0
- unmatched_names = []
- for i, part in enumerate(parts):
- # Try name-based matching first (strip Blender .NNN suffix)
- base_name = _re.sub(r'\.\d{3}$', '', part.name)
- # Strip OCC assembly-instance suffix (_AF0, _AF1, …) — GLB object
- # names may or may not have them while mat_map keys might.
- _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)
-
- # Prefix fallback: if a mat_map key starts with our base name or
- # vice-versa, use the longest matching key (most-specific wins).
- 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
-
- # 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]
- step_key = step_name.lower().strip()
- mat_name = mat_map.get(step_key)
- # Also try stripping AF from part_names_ordered entry
- 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
- else:
- unmatched_names.append(part.name)
-
- print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched", flush=True)
- if unmatched_names:
- print(f"[blender_render] unmatched parts → assigning {FAILED_MATERIAL_NAME}: {unmatched_names[:10]}", flush=True)
- unmatched_set = set(unmatched_names)
- for part in parts:
- if part.name in unmatched_set:
- if part.data.users > 1:
- part.data = part.data.copy()
- _assign_failed_material(part)
-
-
-# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ────
-# Blender compiles Cycles kernels when the engine first initializes. If the
-# compute_device_type is NONE at that point, Cycles locks to CPU for the rest
-# of the session. We therefore probe + enable GPU devices NOW, before any
-# .blend template (which may trigger Cycles init) is loaded.
-def _activate_gpu():
- """Probe for GPU compute devices and activate them. Returns device type or None."""
- if cycles_device == "cpu":
- return None
- try:
- cprefs = bpy.context.preferences.addons['cycles'].preferences
- for dt in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
- try:
- cprefs.compute_device_type = dt
- cprefs.get_devices()
- gpu = [d for d in cprefs.devices if d.type != 'CPU']
- if gpu:
- for d in cprefs.devices:
- d.use = (d.type != 'CPU')
- print(f"[blender_render] early GPU activation: {dt}, "
- f"devices={[(d.name, d.type) for d in gpu]}", flush=True)
- return dt
- except Exception as e:
- print(f"[blender_render] {dt} not available: {e}", flush=True)
- except Exception as e:
- print(f"[blender_render] early GPU probe failed: {e}", flush=True)
- return None
-
-_early_gpu_type = _activate_gpu()
-
-# ── Timing harness ────────────────────────────────────────────────────────────
+# ── Timing harness ─────────────────────────────────────────────────────────────
import time as _time
_t0 = _time.monotonic()
_timings: dict = {}
+
def _lap(label: str) -> None:
- """Record elapsed time since the last _lap() call and since t0."""
- global _t_last
now = _time.monotonic()
if not hasattr(_lap, '_last'):
_lap._last = _t0
@@ -538,259 +120,77 @@ def _lap(label: str) -> None:
print(f"[blender_render] TIMING {label}={delta:.2f}s (total={total:.2f}s)", flush=True)
_lap._last = now
-# ── SCENE SETUP ──────────────────────────────────────────────────────────────
+
+# ── SCENE SETUP ───────────────────────────────────────────────────────────────
if use_template:
- # ── MODE B: Template-based render ────────────────────────────────────────
+ # ── MODE B: Template-based render ─────────────────────────────────────────
print(f"[blender_render] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
_lap("template_load")
- # Find or create target collection
- target_col = _ensure_collection(target_collection)
-
- # Import OCC GLB (already in metres, one object per STEP part)
- parts = _import_glb(glb_path)
+ target_col = ensure_collection(target_collection)
+ parts = import_glb(glb_path)
_lap("glb_import")
- # Apply render position rotation (before camera/bbox calculations)
- _apply_rotation(parts, rotation_x, rotation_y, rotation_z)
+ apply_rotation(parts, rotation_x, rotation_y, rotation_z)
_lap("rotation")
- # Move imported parts into target collection
for part in parts:
- # Remove from all existing collections
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
- # Batch smooth shading: select all parts, call shade_smooth_by_angle ONCE.
- # In Blender 5 this adds a "Smooth by Angle" GeoNodes modifier to every
- # selected object in a single C call — same effect as calling per-object
- # but ~100× faster (0.2s vs 16s for 175 parts).
- _apply_smooth_batch(parts, smooth_angle)
- # If OCC extracted sharp edge vertex pairs, mark them explicitly.
+ 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)
+ apply_sharp_edges_from_occ(parts, _occ_pairs)
_lap("smooth_shading")
- # Material assignment: library materials if available, otherwise palette
if material_library_path and material_map:
- # Build lowercased material_map for matching.
- # Include BOTH the original key AND the key with _AF\d+ stripped,
- # so GLB names (which may lack AF suffixes) can match.
- mat_map_lower = {}
- for k, v in material_map.items():
- kl = k.lower().strip()
- mat_map_lower[kl] = v
- # Also add AF-stripped version
- _stripped = kl
- _p = None
- while _p != _stripped:
- _p = _stripped
- _stripped = _re.sub(r'_af\d+$', '', _stripped)
- if _stripped != kl:
- mat_map_lower.setdefault(_stripped, v)
- _apply_material_library(parts, material_library_path, mat_map_lower)
- # Parts not matched by library get the failed-material fallback (magenta)
- unmatched = []
- for part in parts:
- if not part.data.materials or len(part.data.materials) == 0:
- _assign_failed_material(part)
- unmatched.append(part.name)
- if unmatched:
- print(f"[blender_render] WARNING: {len(unmatched)} parts unmatched, assigned {FAILED_MATERIAL_NAME}: {unmatched[:5]}", flush=True)
+ apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
else:
- # No material library — assign fallback to all parts
for part in parts:
- _assign_failed_material(part)
+ assign_failed_material(part)
_lap("material_assign")
- # ── Shadow catcher (Cycles only, template mode only) ─────────────────────
if shadow_catcher:
- sc_col_name = "Shadowcatcher"
- sc_obj_name = "Shadowcatcher"
- # Enable the Shadowcatcher collection in all view layers
- 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)
+ setup_shadow_catcher(parts)
- sc_obj = bpy.data.objects.get(sc_obj_name)
- if sc_obj:
- # Calculate product bbox min Z (world space)
- all_world_corners = []
- for part in parts:
- for corner in part.bound_box:
- all_world_corners.append((part.matrix_world @ Vector(corner)).z)
- if all_world_corners:
- sc_obj.location.z = min(all_world_corners)
- print(f"[blender_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
- else:
- print(f"[blender_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")
-
- # lighting_only: use template World/HDRI but force auto-camera UNLESS the shadow
- # catcher is enabled — in that case the template camera is already positioned to
- # show both the product and its shadow on the ground plane.
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")
- # 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"[blender_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")
else:
- # ── MODE A: Factory settings (original behavior) ─────────────────────────
+ # ── MODE A: Factory settings ───────────────────────────────────────────────
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
- # Import OCC GLB (already in metres, one object per STEP part)
- parts = _import_glb(glb_path)
- # Apply render position rotation (before camera/bbox calculations)
- _apply_rotation(parts, rotation_x, rotation_y, rotation_z)
+ parts = import_glb(glb_path)
+ apply_rotation(parts, rotation_x, rotation_y, rotation_z)
- import time as _time
_t_smooth_a = _time.time()
- _apply_smooth_batch(parts, smooth_angle)
+ 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)
+ apply_sharp_edges_from_occ(parts, _occ_pairs)
for part in parts:
- _assign_failed_material(part)
+ assign_failed_material(part)
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
- # Apply material library on top of palette colours (same logic as Mode B).
- # material_library_path / material_map are parsed from argv even in Mode A
- # but were previously never used here — that was the bug.
if material_library_path and material_map:
- mat_map_lower = {}
- for k, v in material_map.items():
- kl = k.lower().strip()
- mat_map_lower[kl] = v
- _stripped = kl
- _p = None
- while _p != _stripped:
- _p = _stripped
- _stripped = _re.sub(r'_af\d+$', '', _stripped)
- if _stripped != kl:
- mat_map_lower.setdefault(_stripped, v)
- _apply_material_library(parts, material_library_path, mat_map_lower)
- # Parts not matched by the library keep their fallback material (already set above)
+ apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
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"[blender_render] bbox_dims={tuple(round(d,4) for d in bbox_dims)}, "
- f"bsphere_radius={bsphere_radius:.4f}, center={tuple(round(c,4) for c in bbox_center)}")
-
- # ── 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 ───────────────────────────────────────────────────────────────
- ELEVATION_DEG = 28.0
- AZIMUTH_DEG = 40.0
- LENS_MM = 50.0
- SENSOR_WIDTH_MM = 36.0
- FILL_FACTOR = 0.85
-
- elevation_rad = math.radians(ELEVATION_DEG)
- azimuth_rad = math.radians(AZIMUTH_DEG)
-
- cam_dir = Vector((
- math.cos(elevation_rad) * math.cos(azimuth_rad),
- math.cos(elevation_rad) * math.sin(azimuth_rad),
- math.sin(elevation_rad),
- )).normalized()
-
- fov_h = math.atan(SENSOR_WIDTH_MM / (2.0 * LENS_MM))
- fov_v = math.atan(SENSOR_WIDTH_MM * (height / width) / (2.0 * LENS_MM))
- fov_used = min(fov_h, fov_v)
-
- dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
- dist = max(dist, bsphere_radius * 1.5)
- print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°")
-
- cam_location = bbox_center + cam_dir * dist
- bpy.ops.object.camera_add(location=cam_location)
- cam_obj = bpy.context.active_object
- cam_obj.data.lens = LENS_MM
- bpy.context.scene.camera = cam_obj
-
- look_dir = (bbox_center - cam_location).normalized()
- up_world = Vector((0.0, 0.0, 1.0))
- right = look_dir.cross(up_world)
- if right.length < 1e-6:
- right = Vector((1.0, 0.0, 0.0))
- right.normalize()
- cam_up = right.cross(look_dir).normalized()
-
- rot_mat = Matrix((
- ( right.x, right.y, right.z),
- ( cam_up.x, cam_up.y, cam_up.z),
- (-look_dir.x, -look_dir.y, -look_dir.z),
- )).transposed()
- cam_obj.rotation_euler = rot_mat.to_euler('XYZ')
-
- cam_obj.data.clip_start = max(dist * 0.001, 0.0001)
- cam_obj.data.clip_end = dist + bsphere_radius * 3.0
- print(f"[blender_render] clip {cam_obj.data.clip_start:.6f} … {cam_obj.data.clip_end:.4f}")
-
- # ── 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 the template was
- # designed to use (e.g. Alpha-HDR output types with Filmic tonemapping).
+ 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
@@ -798,88 +198,16 @@ if needs_auto_camera:
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
bg.inputs["Strength"].default_value = 0.15
-# ── Render engine ─────────────────────────────────────────────────────────────
+# ── 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,
+)
-if engine == "eevee":
- # Blender 4.x used 'BLENDER_EEVEE_NEXT'; Blender 5.x reverted to 'BLENDER_EEVEE'.
- # Try both names so the script works across versions.
- set_ok = False
- for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
- try:
- scene.render.engine = eevee_id
- set_ok = True
- print(f"[blender_render] EEVEE engine id: {eevee_id}")
- break
- except TypeError:
- continue
-
- if not set_ok:
- print("[blender_render] WARNING: could not set EEVEE engine – falling back to Cycles")
- engine = "cycles"
-
- if engine == "eevee":
- # Sample attribute name changed across minor versions
- for attr in ('taa_render_samples', 'samples'):
- try:
- setattr(scene.eevee, attr, samples)
- print(f"[blender_render] EEVEE samples: scene.eevee.{attr}={samples}")
- break
- except AttributeError:
- continue
-
-if engine != "eevee": # covers both explicit Cycles and EEVEE-fallback
- # ── GPU preferences (before engine activation) ───────────────────────
- # Set compute_device_type in preferences so Cycles can find GPU kernels.
- gpu_type_found = _activate_gpu() or _early_gpu_type
-
- # ── Activate Cycles engine ───────────────────────────────────────────
- scene.render.engine = 'CYCLES'
-
- # ── Device selection AFTER engine activation ─────────────────────────
- # IMPORTANT: scene.cycles.device must be set AFTER scene.render.engine
- # = 'CYCLES'. Setting it before can be overwritten when Cycles inits
- # and reads the scene's saved properties (template may have device=CPU).
- if gpu_type_found:
- scene.cycles.device = 'GPU'
- # Re-ensure preferences are set (engine activation may have reset them)
- _activate_gpu()
- print(f"[blender_render] Cycles GPU ({gpu_type_found}), samples={samples}", flush=True)
- print(f"RENDER_DEVICE_USED: engine=CYCLES device=GPU compute_type={gpu_type_found}", flush=True)
- else:
- scene.cycles.device = 'CPU'
- print(f"[blender_render] WARNING: GPU not found — falling back to CPU, samples={samples}", flush=True)
- 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)
-
- scene.cycles.samples = samples
- scene.cycles.use_denoising = True
- scene.cycles.denoiser = denoiser_arg if denoiser_arg else 'OPENIMAGEDENOISE'
- if denoising_input_passes_arg:
- try: scene.cycles.denoising_input_passes = denoising_input_passes_arg
- except Exception: pass
- if denoising_prefilter_arg:
- try: scene.cycles.denoising_prefilter = denoising_prefilter_arg
- except Exception: pass
- if denoising_quality_arg:
- try: scene.cycles.denoising_quality = denoising_quality_arg
- except Exception: pass
- if denoising_use_gpu_arg:
- try: scene.cycles.denoising_use_gpu = (denoising_use_gpu_arg == "1")
- except AttributeError: pass
- if noise_threshold_arg:
- scene.cycles.use_adaptive_sampling = True
- scene.cycles.adaptive_threshold = float(noise_threshold_arg)
-
-# ── Colour management ─────────────────────────────────────────────────────────
-# In template mode the .blend file owns its colour management (e.g. Filmic/
-# AgX for HDR, custom exposure for Alpha-HDR output types). Overwriting it
-# would destroy the look the template was designed for.
-# In factory-settings mode (Mode A) force Standard to avoid the grey Filmic
-# tint that Blender applies by default.
+# ── Colour management ──────────────────────────────────────────────────────────
if not use_template:
scene.view_settings.view_transform = 'Standard'
scene.view_settings.exposure = 0.0
@@ -889,7 +217,7 @@ if not use_template:
except Exception:
pass
-# ── Render settings ───────────────────────────────────────────────────────────
+# ── Render settings ────────────────────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
@@ -897,8 +225,7 @@ scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = output_path
scene.render.film_transparent = transparent_bg
-# ── Render ────────────────────────────────────────────────────────────────────
-# Final verification of render device settings
+# ── Final verification + render ────────────────────────────────────────────────
if scene.render.engine == 'CYCLES':
cprefs = bpy.context.preferences.addons['cycles'].preferences
print(f"[blender_render] VERIFY: engine={scene.render.engine}, "
@@ -906,6 +233,7 @@ if scene.render.engine == 'CYCLES':
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()
@@ -913,7 +241,7 @@ bpy.ops.render.render(write_still=True)
print("[blender_render] render done.", flush=True)
_lap("gpu_render")
-# ── Final timing summary ──────────────────────────────────────────────────────
+# ── 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)
diff --git a/renderproblems_tmp/tesselation_probllem2.png b/renderproblems_tmp/tesselation_probllem2.png
deleted file mode 100644
index 1a8e811..0000000
Binary files a/renderproblems_tmp/tesselation_probllem2.png and /dev/null differ
diff --git a/renderproblems_tmp/tesselation_probllem3.png b/renderproblems_tmp/tesselation_probllem3.png
deleted file mode 100644
index 0ac7388..0000000
Binary files a/renderproblems_tmp/tesselation_probllem3.png and /dev/null differ
diff --git a/renderproblems_tmp/tesselation_probllempng.png b/renderproblems_tmp/tesselation_probllempng.png
deleted file mode 100644
index eb0ff2e..0000000
Binary files a/renderproblems_tmp/tesselation_probllempng.png and /dev/null differ
diff --git a/review-report.md b/review-report.md
index 59b77a0..d032fa6 100644
--- a/review-report.md
+++ b/review-report.md
@@ -1,88 +1,36 @@
-# Review Report: CAD Viewer Material Assignment Fix + Feature Parity
-Datum: 2026-03-10
+# Review Report: Pipeline Cleanup (M1 + M3)
+Date: 2026-03-11
-## Ergebnis: ✅ Freigabe
+## Result: ✅ Approved (2 low-severity unused imports fixed inline)
---
-## Gefundene Probleme
+## Problems Found
-### [InlineCadViewer.tsx + ThreeDViewer.tsx] Misleading comment on isolateMode reset effect
-**Schwere**: Gering (Kommentar)
+### render-worker/scripts/blender_render.py:20 — Unused `import math`
+**Severity**: Low
+**Description**: `import math` is at the top of the entry-point but `math` is no longer referenced there — all math operations moved to submodules.
+**Fix**: Remove the import. Applied inline.
-In both files the comment reads:
-```tsx
-// Reset isolateMode and hideAssigned when no part is pinned
-useEffect(() => {
- if (!pinnedPart) setIsolateMode('none') // ← only resets isolateMode, not hideAssigned!
-}, [pinnedPart])
-```
-The comment says "and hideAssigned" but the effect only calls `setIsolateMode('none')`. The behavior is actually correct — `hideAssigned` should NOT be reset when unpinning (it's a persistent view toggle). Only the comment is wrong.
-
-**Empfehlung**: Change to `// Reset isolateMode when no part is pinned`.
+### render-worker/scripts/_blender_import.py:5 — Unused `import re as _re`
+**Severity**: Low
+**Description**: `re` module is imported at module level but not used anywhere in `_blender_import.py`. The `_re.sub` calls live in `_blender_materials.py`.
+**Fix**: Remove the import. Applied inline.
---
-## Positiv aufgefallen
+## Positives
-### Bug fix: MaterialPanel invisible in ThreeDViewer — root cause correctly identified
-The diagnosis was precise: the outer ` setPinnedPart(null)}>` was receiving the
-native DOM bubble from every canvas click, calling `setPinnedPart(null)` in the same React batch as
-`setPinnedPart(name)` from the THREE.js event handler — final state always `null`.
-
-The two-part fix is clean and idiomatic:
-- `onClick={(e) => e.stopPropagation()}` on the viewport div absorbs DOM clicks
-- `onPointerMissed={() => setPinnedPart(null)}` on the R3F Canvas handles the "click empty space"
- case via the THREE.js raycaster (fires only when no mesh is hit) — this is exactly the right
- R3F API for this use case
-
-### cadUtils.ts — normalization regex extension
-`/_AF\d+(_ASM)?$/i` is minimal and correct. It handles:
-- `_AF0`, `_AF1` (existing, unchanged)
-- `_AF0_ASM`, `_AF1_ASM` (new — assembly-node suffix)
-- Case-insensitive flag is defensive and correct
-- The loop-until-stable pattern handles nested suffixes as before
-- `_ASM` alone (without `_AF\d+`) is NOT stripped — correct, it's part of base names like
- `GE360-HF_000_P_ASM_ASM`
-
-### Combined visibility useEffect — correct design
-Merging `hideAssigned` + `isolateMode` into a single traversal effect avoids
-ordering ambiguity between two independent effects competing on the same `mesh.visible` and
-`mat.opacity`. The priority order (hideAssigned first, then isolateMode) is explicit and logical.
-The pinned part (`isSelected`) is always protected from hiding regardless of mode. ✓
-
-### Effect separation is clean
-- Color-apply effect: only touches `mat.color` → deps `[modelReady, partMaterials]`
-- Unassigned glow effect: only touches `mat.emissive` → deps `[modelReady, showUnassigned, partMaterials]`
-- Combined visibility effect: only touches `mesh.visible` / `mat.opacity` / `mat.transparent` → deps `[modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials]`
-
-No effect touches another effect's properties — no race conditions.
-
-### GPU hint and DPR cap
-`gl={{ powerPreference: 'high-performance' }}` + `dpr={[1, 1.5]}` on both Canvas elements.
-`preserveDrawingBuffer: true` correctly kept only in ThreeDViewer (required for screenshot capture).
-
-### "Hide assigned" toolbar button correctly conditional
-`{assignedCount > 0 && (...)}` in InlineCadViewer and
-`{modelReady && Object.keys(partMaterials).length > 0 && (...)}` in ThreeDViewer — button only
-appears when there is something to hide.
-
-### Debug log is dev-only
-`if (!import.meta.env.DEV || ...)` guard ensures the console output and traversal overhead
-never reach production. The output logs both matched and unmatched keys, which is exactly what's
-needed to diagnose remaining name mismatches after the normalization fix.
-
-### Feature parity achieved
-ThreeDViewer and InlineCadViewer now have matching material-assignment features:
-- ✓ `showUnassigned` highlight toggle with count badge
-- ✓ `hideAssigned` toggle (new, both viewers)
-- ✓ `isolateMode` (ghost / hide) via MaterialPanel (both viewers)
-- ✓ `onPointerMissed` closes panel on empty-space click in ThreeDViewer
+- **Dead code thoroughly removed**: `VALID_STL_QUALITIES`, `stl_quality` (7 locations in admin.py), 6 frontend files, `_mark_sharp_and_seams()` (62 lines), `_render_via_service()` (33 lines), 2 dead `elif renderer == "threejs"` branches — all gone. All acceptance gates pass.
+- **Submodule decomposition is clean**: `blender_render.py` went 858 → 249 lines. Each submodule has a clear single responsibility with correct `sys.path.insert(0, ...)` for Blender Python discovery.
+- **GPU activation order preserved**: `activate_gpu()` still called before `open_mainfile`, and again after engine init — the critical 3-call sequence is intact in `configure_engine()`.
+- **FailedMaterial sentinel preserved**: `assign_failed_material` in `_blender_materials.py` matches the original logic; unmatched parts in `apply_material_library` are now handled internally.
+- **`part_names_ordered` global → parameter**: Correctly converted to an explicit parameter in `apply_material_library()`.
+- **No security issues**: No hardcoded credentials, no SQL injections, no new endpoints, no new models.
+- **No render pipeline regressions**: No references to removed blender-renderer or threejs-renderer services.
+- **Frontend**: TypeScript errors in output are pre-existing (Admin.tsx GPUProbeResult, InlineCadViewer.tsx), not introduced by this change.
---
-## Empfehlung
-
-**Freigabe.** The one Gering comment issue can be fixed inline.
-
-Review abgeschlossen. Ergebnis: ✅
+## Recommendation
+Approved. Two unused imports fixed inline before commit.