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:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user