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
+56 -29
View File
@@ -27,7 +27,7 @@ def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> Non
import sys as _sys
linear_deflection = 0.3 if quality == "low" else 0.05
angular_deflection = 0.3 if quality == "low" else 0.1
angular_deflection = 0.5 if quality == "low" else 0.2
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_step_to_gltf.py"
@@ -95,6 +95,7 @@ def render_still(
denoising_quality: str = "",
denoising_use_gpu: str = "",
mesh_attributes: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
@@ -170,49 +171,75 @@ def render_still(
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
return cmd
def _run(eng: str) -> subprocess.CompletedProcess:
def _run(eng: str) -> tuple[int, list[str], list[str]]:
"""Run Blender subprocess, streaming stdout line-by-line.
Returns (returncode, stdout_lines, stderr_lines).
"""
import selectors
proc = subprocess.Popen(
_build_cmd(eng),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=env, start_new_session=True,
)
stdout_lines: list[str] = []
stderr_lines: list[str] = []
deadline = time.monotonic() + 600
sel = selectors.DefaultSelector()
sel.register(proc.stdout, selectors.EVENT_READ, "stdout")
sel.register(proc.stderr, selectors.EVENT_READ, "stderr")
try:
stdout, stderr = proc.communicate(timeout=600)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
stdout, stderr = proc.communicate()
return subprocess.CompletedProcess(_build_cmd(eng), proc.returncode, stdout, stderr)
while sel.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
break
events = sel.select(timeout=min(remaining, 2.0))
for key, _ in events:
line = key.fileobj.readline()
if not line:
sel.unregister(key.fileobj)
continue
line = line.rstrip("\n")
if key.data == "stdout":
stdout_lines.append(line)
logger.info("[blender] %s", line)
if log_callback and "[blender_render]" in line:
log_callback(line)
else:
stderr_lines.append(line)
logger.warning("[blender stderr] %s", line)
finally:
sel.close()
proc.wait(timeout=10)
return proc.returncode, stdout_lines, stderr_lines
t_render = time.monotonic()
result = _run(engine)
returncode, stdout_lines, stderr_lines = _run(engine)
engine_used = engine
log_lines = []
for line in (result.stdout or "").splitlines():
logger.info("[blender] %s", line)
if "[blender_render]" in line:
log_lines.append(line)
for line in (result.stderr or "").splitlines():
logger.warning("[blender stderr] %s", line)
log_lines = [l for l in stdout_lines if "[blender_render]" in l]
# EEVEE fallback to Cycles on non-signal error
if result.returncode > 0 and engine == "eevee":
logger.warning("EEVEE failed (exit %d) — retrying with Cycles", result.returncode)
result = _run("cycles")
if returncode > 0 and engine == "eevee":
logger.warning("EEVEE failed (exit %d) — retrying with Cycles", returncode)
returncode, stdout_lines2, stderr_lines2 = _run("cycles")
engine_used = "cycles (eevee fallback)"
for line in (result.stdout or "").splitlines():
logger.info("[blender-fallback] %s", line)
if "[blender_render]" in line:
log_lines.append(line)
log_lines.extend(l for l in stdout_lines2 if "[blender_render]" in l)
if result.returncode != 0:
if returncode != 0:
stdout_tail = "\n".join(stdout_lines[-50:]) if stdout_lines else ""
stderr_tail = "\n".join(stderr_lines[-20:]) if stderr_lines else ""
raise RuntimeError(
f"Blender exited with code {result.returncode}.\n"
f"stdout: {(result.stdout or '')[-2000:]}\n"
f"stderr: {(result.stderr or '')[-500:]}"
f"Blender exited with code {returncode}.\n"
f"stdout: {stdout_tail[-2000:]}\n"
f"stderr: {stderr_tail[-500:]}"
)
render_duration_s = round(time.monotonic() - t_render, 2)
+8
View File
@@ -715,6 +715,7 @@ def render_to_file(
denoising_prefilter: str = "",
denoising_quality: str = "",
denoising_use_gpu: str = "",
order_line_id: str | None = None,
) -> tuple[bool, dict]:
"""Render a STEP file to a specific output path using current system settings.
@@ -734,6 +735,7 @@ def render_to_file(
target_collection: Blender collection name to import geometry into.
material_library_path: Optional path to material library .blend file.
material_map: Optional {part_name: material_name} for material replacement.
order_line_id: Optional order line ID for live log streaming.
Returns:
(success: bool, render_log: dict)
@@ -819,6 +821,11 @@ def render_to_file(
if denoising_use_gpu:
extra["denoising_use_gpu"] = denoising_use_gpu
from app.services.render_blender import is_blender_available, render_still
# Build live-log callback for streaming Blender output to Redis
_log_cb = None
if order_line_id:
from app.services import render_log as _rl
_log_cb = lambda line: _rl.emit(order_line_id, line)
if is_blender_available():
try:
service_data = render_still(
@@ -845,6 +852,7 @@ def render_to_file(
denoising_prefilter=denoising_prefilter,
denoising_quality=denoising_quality,
denoising_use_gpu=denoising_use_gpu,
log_callback=_log_cb,
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
+47 -17
View File
@@ -4,19 +4,20 @@ Used from Celery tasks (sync context) to find the best matching .blend template
for a given category + output type combination.
Cascade priority (first active match wins):
1. Exact: category_key + output_type_id
2. Category only: category_key + output_type_id IS NULL
3. OT only: category_key IS NULL + output_type_id
4. Global: both NULL
1. Exact: category_key + output_type linked via M2M
2. Category only: category_key + no output_types linked
3. OT only: category_key IS NULL + output_type linked via M2M
4. Global: category_key IS NULL + no output_types linked
5. No template → caller falls back to factory-settings behavior
"""
import logging
from sqlalchemy import create_engine, select, and_
from sqlalchemy import create_engine, select, and_, exists
from sqlalchemy.orm import Session
from app.models.render_template import RenderTemplate
from app.models.system_setting import SystemSetting
from app.domains.rendering.models import render_template_output_types
logger = logging.getLogger(__name__)
@@ -37,63 +38,92 @@ def resolve_template(
) -> RenderTemplate | None:
"""Find the best matching active render template.
Uses the M2M render_template_output_types table for output type matching.
Uses sync SQLAlchemy — safe for Celery tasks.
"""
engine = _get_engine()
with Session(engine) as session:
active = RenderTemplate.is_active == True # noqa: E712
# 1. Exact match
# Helper: subquery checking if a template is linked to a specific OT
def _has_ot(ot_id):
return exists(
select(render_template_output_types.c.template_id).where(and_(
render_template_output_types.c.template_id == RenderTemplate.id,
render_template_output_types.c.output_type_id == ot_id,
))
)
# Helper: subquery checking if a template has NO linked OTs
_no_ots = ~exists(
select(render_template_output_types.c.template_id).where(
render_template_output_types.c.template_id == RenderTemplate.id,
)
)
# 1. Exact match: category_key + output_type in M2M
if category_key and output_type_id:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key == category_key,
RenderTemplate.output_type_id == output_type_id,
_has_ot(output_type_id),
))
).scalar_one_or_none()
).unique().scalar_one_or_none()
if row:
return row
# 2. Category only
# 2. Category only: category_key + no OTs linked
if category_key:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key == category_key,
RenderTemplate.output_type_id.is_(None),
_no_ots,
))
).scalar_one_or_none()
).unique().scalar_one_or_none()
if row:
return row
# 3. OT only
# 3. OT only: no category_key + output_type in M2M
if output_type_id:
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key.is_(None),
RenderTemplate.output_type_id == output_type_id,
_has_ot(output_type_id),
))
).scalar_one_or_none()
).unique().scalar_one_or_none()
if row:
return row
# 4. Global fallback (both NULL)
# 4. Global fallback: no category_key + no OTs linked
row = session.execute(
select(RenderTemplate).where(and_(
active,
RenderTemplate.category_key.is_(None),
RenderTemplate.output_type_id.is_(None),
_no_ots,
))
).scalar_one_or_none()
return row
def get_material_library_path() -> str | None:
"""Read material_library_path from system_settings. Returns None if empty."""
"""Return the blend_file_path of the first active AssetLibrary.
Falls back to the legacy material_library_path system setting.
"""
engine = _get_engine()
with Session(engine) as session:
# Prefer active AssetLibrary
from app.domains.materials.models import AssetLibrary
row = session.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) # noqa: E712
).scalar_one_or_none()
if row and row.blend_file_path:
return row.blend_file_path
# Fallback to legacy system setting
row = session.execute(
select(SystemSetting).where(SystemSetting.key == "material_library_path")
).scalar_one_or_none()