""" 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 -- \ [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. - Schaeffler green top bar + model name label via Pillow post-processing. """ import sys import os import math import bpy from mathutils import Vector, Matrix # ── Colour palette (matches Three.js renderer) ─────────────────────────────── PALETTE_HEX = [ "#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8", "#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8", ] def _srgb_to_linear(c: int) -> float: """Convert 0-255 sRGB integer to linear 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: """Return (r, g, b, 1.0) in Blender linear colour space.""" 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] # ── Parse arguments ─────────────────────────────────────────────────────────── argv = sys.argv if "--" in argv: argv = argv[argv.index("--") + 1:] else: argv = [] if len(argv) < 4: print("Usage: blender --background --python blender_render.py -- " " [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]") sys.exit(1) import json as _json glb_path = argv[0] output_path = argv[1] width = int(argv[2]) height = int(argv[3]) engine = argv[4].lower() if len(argv) > 4 else "cycles" samples = int(argv[5]) if len(argv) > 5 else (64 if engine == "eevee" else 256) smooth_angle = int(argv[6]) if len(argv) > 6 else 30 # degrees; 0 = flat shading cycles_device = argv[7].lower() if len(argv) > 7 else "auto" # "auto", "gpu", "cpu" transparent_bg = argv[8] == "1" if len(argv) > 8 else False template_path = argv[9] if len(argv) > 9 and argv[9] else "" target_collection = argv[10] if len(argv) > 10 else "Product" material_library_path = argv[11] if len(argv) > 11 and argv[11] else "" material_map_raw = argv[12] if len(argv) > 12 else "{}" try: material_map = _json.loads(material_map_raw) if material_map_raw else {} except _json.JSONDecodeError: material_map = {} part_names_ordered_raw = argv[13] if len(argv) > 13 else "[]" try: part_names_ordered = _json.loads(part_names_ordered_raw) if part_names_ordered_raw else [] except _json.JSONDecodeError: part_names_ordered = [] lighting_only = argv[14] == "1" if len(argv) > 14 else False shadow_catcher = argv[15] == "1" if len(argv) > 15 else False rotation_x = float(argv[16]) if len(argv) > 16 else 0.0 rotation_y = float(argv[17]) if len(argv) > 17 else 0.0 rotation_z = float(argv[18]) if len(argv) > 18 else 0.0 noise_threshold_arg = argv[19] if len(argv) > 19 else "" denoiser_arg = argv[20] if len(argv) > 20 else "" denoising_input_passes_arg = argv[21] if len(argv) > 21 else "" denoising_prefilter_arg = argv[22] if len(argv) > 22 else "" denoising_quality_arg = argv[23] if len(argv) > 23 else "" denoising_use_gpu_arg = argv[24] if len(argv) > 24 else "" # 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.") 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)") 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 ──────────────────────────────── 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(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() 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) import re as _re 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 _import_glb(glb_file): """Import OCC-generated GLB into Blender. OCC exports one mesh object per STEP part, already in metres. Returns list of Blender mesh objects, centred at world origin. """ bpy.ops.object.select_all(action='DESELECT') bpy.ops.import_scene.gltf(filepath=glb_file) parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] if not parts: print(f"ERROR: No mesh objects imported from {glb_file}") sys.exit(1) print(f"[blender_render] imported {len(parts)} part(s) from GLB: " f"{[p.name for p in parts[:5]]}") # Centre combined bbox at world origin all_corners = [] for p in parts: all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) if all_corners: mins = Vector((min(v.x for v in all_corners), min(v.y for v in all_corners), min(v.z for v in all_corners))) maxs = Vector((max(v.x for v in all_corners), max(v.y for v in all_corners), max(v.z for v in all_corners))) center = (mins + maxs) * 0.5 for p in parts: p.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 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"[blender_render] assigned '{mat_name}' to part '{part.name}'") print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched") # ── 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) # Find or create target collection target_col = _ensure_collection(target_collection) # Import GLB (already in metres from OCC export) parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) # 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) # 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: # Build lowercased material_map for matching mat_map_lower = {k.lower(): v for k, v in material_map.items()} _apply_material_library(parts, material_library_path, mat_map_lower) # 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): _assign_palette_material(part, i) # ── 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) 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) ───────────────────────── needs_auto_camera = True bpy.ops.wm.read_factory_settings(use_empty=True) parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) for i, part in enumerate(parts): _apply_smooth(part, smooth_angle) _assign_palette_material(part, i) # 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 = {k.lower(): v for k, v in material_map.items()} _apply_material_library(parts, material_library_path, mat_map_lower) # Parts not matched by the library keep their palette material (already set above) 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). 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 # ── Render engine ───────────────────────────────────────────────────────────── scene = bpy.context.scene 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 scene.render.engine = 'CYCLES' 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) # ── Device selection: "cpu" forces CPU, "gpu" forces GPU (fail if unavailable), # "auto" tries GPU first and falls back to CPU. gpu_type_found = None 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 gpu_type_found = device_type break except Exception as e: print(f"[blender_render] {device_type} not available: {e}") except Exception as e: print(f"[blender_render] GPU probe failed: {e}") if gpu_type_found: scene.cycles.device = 'GPU' print(f"[blender_render] Cycles GPU ({gpu_type_found}), samples={samples}") else: scene.cycles.device = 'CPU' print(f"[blender_render] WARNING: GPU not found — falling back to CPU, samples={samples}") # ── 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. 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 # ── Render ──────────────────────────────────────────────────────────────────── print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})") bpy.ops.render.render(write_still=True) print("[blender_render] render done.") # ── Pillow post-processing: green bar + model name label ───────────────────── # Skip overlay for transparent renders to keep clean alpha channel if transparent_bg: print("[blender_render] Transparent mode — skipping Pillow overlay.") else: try: from PIL import Image, ImageDraw, ImageFont img = Image.open(output_path).convert("RGBA") draw = ImageDraw.Draw(img) W, H = img.size # Schaeffler green top bar bar_h = max(8, H // 32) draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255)) # Model name strip at bottom model_name = os.path.splitext(os.path.basename(glb_path))[0] label_h = max(20, H // 20) img.alpha_composite( Image.new("RGBA", (W, label_h), (30, 30, 30, 180)), dest=(0, H - label_h), ) font_size = max(10, label_h - 6) font = None for fp in [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", ]: if os.path.exists(fp): try: font = ImageFont.truetype(fp, font_size) break except Exception: pass if font is None: font = ImageFont.load_default() tb = draw.textbbox((0, 0), model_name, font=font) text_w = tb[2] - tb[0] draw.text( ((W - text_w) // 2, H - label_h + (label_h - (tb[3] - tb[1])) // 2), model_name, font=font, fill=(255, 255, 255, 255), ) img.convert("RGB").save(output_path, format="PNG") print(f"[blender_render] Pillow overlay applied.") except ImportError: print("[blender_render] Pillow not in Blender Python – skipping overlay.") except Exception as exc: print(f"[blender_render] Pillow overlay failed (non-fatal): {exc}") print("[blender_render] Done.")