""" Blender Python script for rendering an STL 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: - Disconnected mesh islands split into separate objects and painted with palette colours (same 10-colour palette as the Three.js renderer). - 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 "" # Named argument: --mesh-attributes _mesh_attrs: dict = {} _sys_argv = sys.argv if "--mesh-attributes" in _sys_argv: _idx = _sys_argv.index("--mesh-attributes") try: _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.") 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 # _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_stl + _scale_mm_to_m 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 _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]]}") # 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 per-part 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 for combined-STL mode. """ # 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. With per-part STL import, Blender objects are named after STEP parts, so matching is by name (stripping Blender .NNN suffix for duplicates). Falls back to part_names_ordered index-based matching for combined-STL mode. mat_map: {part_name_lower: material_name} Parts without a match keep their current material. """ if not mat_lib_path or not os.path.isfile(mat_lib_path): print(f"[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 (per-part STL mode), # secondary: index-based via part_names_ordered (combined STL fallback) assigned_count = 0 for i, part in enumerate(parts): # Try name-based matching first (strip Blender .NNN suffix) base_name = _re.sub(r'\.\d{3}$', '', part.name) part_key = base_name.lower().strip() mat_name = mat_map.get(part_key) # Fall back to index-based matching via part_names_ordered if not mat_name and part_names_ordered and i < len(part_names_ordered): step_name = part_names_ordered[i] part_key = step_name.lower().strip() mat_name = mat_map.get(part_key) if mat_name and mat_name in appended: part.data.materials.clear() part.data.materials.append(appended[mat_name]) assigned_count += 1 print(f"[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 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) # 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 and mark sharp edges / UV seams for part in parts: _apply_smooth(part, smooth_angle) _mark_sharp_and_seams( part, smooth_angle, sharp_edge_midpoints=_mesh_attrs.get('sharp_edge_midpoints'), ) # 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) # 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) for i, part in enumerate(parts): _apply_smooth(part, smooth_angle) _mark_sharp_and_seams( part, smooth_angle, sharp_edge_midpoints=_mesh_attrs.get('sharp_edge_midpoints'), ) _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(stl_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.")