ee6eb34b4c
- GPU: fix Cycles device activation order — set compute_device_type BEFORE engine init, re-set AFTER open_mainfile wipes preferences - GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts - Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys and add prefix fallback in _apply_material_library (blender_render.py) - Material: production GLB now uses get_material_library_path() which checks active AssetLibrary instead of empty legacy system setting - Admin: RenderTemplateTable multi-select output types (M2M frontend) - Admin: MaterialLibraryPanel replaced with link to Asset Libraries - UX: move Toaster to top-left to avoid dispatch button overlap - SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries - Logging: flush=True on all Blender progress prints, stdout reconfigure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
680 lines
28 KiB
Python
680 lines
28 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.
|
||
- 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 -- "
|
||
"<glb_path> <output_path> <width> <height> [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.")
|