feat: GPU rendering + material matching + perf improvements

- 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>
This commit is contained in:
2026-03-08 19:05:03 +01:00
parent 934728da77
commit ee6eb34b4c
34 changed files with 1274 additions and 511 deletions
+147 -61
View File
@@ -1,16 +1,15 @@
"""
Blender Python script for rendering an STL file to PNG.
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 -- \
<stl_path> <output_path> <width> <height> [engine] [samples]
<glb_path> <output_path> <width> <height> [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).
- 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.
@@ -20,6 +19,12 @@ Features:
import sys
import os
import math
# Force unbuffered stdout so render log lines appear immediately
os.environ["PYTHONUNBUFFERED"] = "1"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
import bpy
from mathutils import Vector, Matrix
@@ -179,7 +184,7 @@ 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_stl + _scale_mm_to_m the combined bbox center is at 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):
@@ -301,9 +306,9 @@ def _import_glb(glb_file):
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
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 for combined-STL mode.
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)
@@ -317,9 +322,9 @@ def _resolve_part_name(index, part_obj):
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.
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.
@@ -355,30 +360,88 @@ def _apply_material_library(parts, mat_lib_path, mat_map):
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)
# 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]
part_key = step_name.lower().strip()
mat_name = mat_map.get(part_key)
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
print(f"[blender_render] assigned '{mat_name}' to part '{part.name}'")
print(f"[blender_render] assigned '{mat_name}' to part '{part.name}'", flush=True)
else:
unmatched_names.append(part.name)
print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched")
print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched", flush=True)
if unmatched_names:
print(f"[blender_render] unmatched parts (palette fallback): {unmatched_names[:10]}", flush=True)
# ── 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()
# ── SCENE SETUP ──────────────────────────────────────────────────────────────
if use_template:
@@ -401,18 +464,32 @@ if use_template:
col.objects.unlink(part)
target_col.objects.link(part)
# Apply smooth shading and mark sharp edges / UV seams
for part in parts:
# Apply smooth shading (Blender 5.0+ shade_smooth_by_angle adds a geometry
# node modifier that handles both smooth shading AND sharp edge marking
# automatically — no need for the old _mark_sharp_and_seams edit-mode loop)
import time as _time
_t_smooth = _time.time()
for _si, 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'),
)
print(f"[blender_render] smooth shading: {len(parts)} parts ({_time.time()-_t_smooth:.1f}s)", flush=True)
# 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()}
# 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 palette fallback
for i, part in enumerate(parts):
@@ -477,19 +554,28 @@ else:
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
import time as _time
_t_smooth_a = _time.time()
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)
print(f"[blender_render] smooth+palette: {len(parts)} parts ({_time.time()-_t_smooth_a:.1f}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 = {k.lower(): v for k, v in material_map.items()}
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 palette material (already set above)
@@ -633,7 +719,26 @@ if engine == "eevee":
continue
if engine != "eevee": # covers both explicit Cycles and EEVEE-fallback
scene.render.engine = 'CYCLES'
# ── 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)
else:
scene.cycles.device = 'CPU'
print(f"[blender_render] WARNING: GPU not found — falling back to CPU, samples={samples}", flush=True)
scene.cycles.samples = samples
scene.cycles.use_denoising = True
scene.cycles.denoiser = denoiser_arg if denoiser_arg else 'OPENIMAGEDENOISE'
@@ -653,34 +758,6 @@ if engine != "eevee": # covers both explicit Cycles and EEVEE-fallback
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
@@ -705,9 +782,18 @@ scene.render.filepath = output_path
scene.render.film_transparent = transparent_bg
# ── Render ────────────────────────────────────────────────────────────────────
print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})")
# Final verification of render device settings
if scene.render.engine == 'CYCLES':
cprefs = bpy.context.preferences.addons['cycles'].preferences
print(f"[blender_render] VERIFY: engine={scene.render.engine}, "
f"cycles.device={scene.cycles.device}, "
f"compute_device_type={cprefs.compute_device_type}, "
f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}",
flush=True)
print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})", flush=True)
sys.stdout.flush()
bpy.ops.render.render(write_still=True)
print("[blender_render] render done.")
print("[blender_render] render done.", flush=True)
# ── Pillow post-processing: green bar + model name label ─────────────────────
# Skip overlay for transparent renders to keep clean alpha channel
@@ -726,7 +812,7 @@ else:
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]
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)),
+2 -2
View File
@@ -2,7 +2,7 @@
Usage (from Blender):
blender --background --python turntable_render.py -- \
<stl_path> <frames_dir> <frame_count> <degrees> <width> <height> \
<glb_path> <frames_dir> <frame_count> <degrees> <width> <height> \
<engine> <samples> <part_colors_json> \
[template_path] [target_collection] [material_library_path] [material_map_json]
"""
@@ -88,7 +88,7 @@ def _apply_rotation(parts, rx, ry, rz):
"""Apply Euler XYZ rotation (degrees) to all parts by modifying matrix_world.
Rotates around world origin, which equals the assembly centre because
_import_stl already centres parts there. Applied before material assignment
_import_glb already centres parts there. Applied before material assignment
and camera/bbox calculations so everything downstream sees the final pose.
"""
if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):