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
+67
View File
@@ -41,6 +41,11 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"smtp_user": "",
"smtp_password": "",
"smtp_from_address": "",
# glTF tessellation quality (OCC BRepMesh)
"gltf_preview_linear_deflection": "0.1", # mm — geometry GLB for viewer
"gltf_preview_angular_deflection": "0.5", # rad
"gltf_production_linear_deflection": "0.03", # mm — production GLB
"gltf_production_angular_deflection": "0.2", # rad
# 3D viewer / glTF export settings
"gltf_scale_factor": "0.001",
"gltf_smooth_normals": "true",
@@ -71,6 +76,10 @@ class SettingsOut(BaseModel):
smtp_user: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
gltf_preview_linear_deflection: float = 0.1
gltf_preview_angular_deflection: float = 0.5
gltf_production_linear_deflection: float = 0.03
gltf_production_angular_deflection: float = 0.2
gltf_scale_factor: float = 0.001
gltf_smooth_normals: bool = True
viewer_max_distance: float = 50.0
@@ -99,6 +108,10 @@ class SettingsUpdate(BaseModel):
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from_address: str | None = None
gltf_preview_linear_deflection: float | None = None
gltf_preview_angular_deflection: float | None = None
gltf_production_linear_deflection: float | None = None
gltf_production_angular_deflection: float | None = None
gltf_scale_factor: float | None = None
gltf_smooth_normals: bool | None = None
viewer_max_distance: float | None = None
@@ -213,6 +226,10 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_from_address=raw.get("smtp_from_address", ""),
gltf_preview_linear_deflection=float(raw.get("gltf_preview_linear_deflection", "0.1")),
gltf_preview_angular_deflection=float(raw.get("gltf_preview_angular_deflection", "0.5")),
gltf_production_linear_deflection=float(raw.get("gltf_production_linear_deflection", "0.03")),
gltf_production_angular_deflection=float(raw.get("gltf_production_angular_deflection", "0.2")),
gltf_scale_factor=float(raw.get("gltf_scale_factor", "0.001")),
gltf_smooth_normals=raw.get("gltf_smooth_normals", "true") == "true",
viewer_max_distance=float(raw.get("viewer_max_distance", "50")),
@@ -328,6 +345,22 @@ async def update_settings(
updates["gltf_pbr_roughness"] = str(body.gltf_pbr_roughness)
if body.gltf_pbr_metallic is not None:
updates["gltf_pbr_metallic"] = str(body.gltf_pbr_metallic)
if body.gltf_preview_linear_deflection is not None:
if not (0.001 <= body.gltf_preview_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_preview_linear_deflection must be 0.00110.0 mm")
updates["gltf_preview_linear_deflection"] = str(body.gltf_preview_linear_deflection)
if body.gltf_preview_angular_deflection is not None:
if not (0.05 <= body.gltf_preview_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_preview_angular_deflection must be 0.051.5 rad")
updates["gltf_preview_angular_deflection"] = str(body.gltf_preview_angular_deflection)
if body.gltf_production_linear_deflection is not None:
if not (0.001 <= body.gltf_production_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_production_linear_deflection must be 0.00110.0 mm")
updates["gltf_production_linear_deflection"] = str(body.gltf_production_linear_deflection)
if body.gltf_production_angular_deflection is not None:
if not (0.05 <= body.gltf_production_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_production_angular_deflection must be 0.051.5 rad")
updates["gltf_production_angular_deflection"] = str(body.gltf_production_angular_deflection)
for k, v in updates.items():
await _save_setting(db, k, v)
@@ -470,6 +503,40 @@ async def generate_missing_geometry_glbs(
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
@router.post("/settings/recover-stuck-processing", status_code=status.HTTP_200_OK)
async def recover_stuck_processing(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'.
Call this when a CAD file shows 'processing' indefinitely. The auto-recovery
beat task also runs every 5 minutes, so this is just for immediate relief.
"""
from datetime import datetime, timedelta
from sqlalchemy import update as sql_update, and_
cutoff = datetime.utcnow() - timedelta(minutes=10)
result = await db.execute(
sql_update(CadFile)
.where(
and_(
CadFile.processing_status == ProcessingStatus.processing,
CadFile.updated_at < cutoff,
)
)
.values(
processing_status=ProcessingStatus.failed,
error_message="Processing timed out — worker may have crashed. Use 'Regenerate Thumbnail' to retry.",
)
.returning(CadFile.id)
)
reset_ids = [str(r[0]) for r in result.fetchall()]
await db.commit()
return {"reset": len(reset_ids), "ids": reset_ids,
"message": f"Reset {len(reset_ids)} stuck file(s) to 'failed'"}
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
async def seed_workflows(
admin: User = Depends(require_admin),
+29
View File
@@ -348,3 +348,32 @@ async def regenerate_thumbnail(
}
@router.post("/{id}/reset-stuck", status_code=status.HTTP_200_OK)
async def reset_stuck_processing(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Force-reset a CAD file that is stuck in 'processing' to 'failed'.
Use when a file shows 'processing' indefinitely due to a worker crash.
After resetting, click 'Regen thumbnail' to retry.
"""
if user.role.value not in ("admin", "project_manager"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
if cad.processing_status != ProcessingStatus.processing:
raise HTTPException(
status_code=400,
detail=f"CAD file is not stuck — current status: {cad.processing_status.value}",
)
cad.processing_status = ProcessingStatus.failed
cad.error_message = "Manually reset — worker may have crashed. Use 'Regen thumbnail' to retry."
await db.commit()
return {"cad_file_id": str(cad.id), "status": "failed", "message": "Reset to 'failed'. Use 'Regen thumbnail' to retry."}
+56 -16
View File
@@ -35,8 +35,10 @@ class RenderTemplateOut(BaseModel):
id: str
name: str
category_key: str | None
output_type_id: str | None
output_type_name: str | None
output_type_id: str | None # legacy single FK
output_type_name: str | None # legacy
output_type_ids: list[str] # M2M
output_type_names: list[str] # M2M display names
blend_file_path: str
original_filename: str
target_collection: str
@@ -54,7 +56,7 @@ class RenderTemplateOut(BaseModel):
class RenderTemplateUpdate(BaseModel):
name: str | None = None
category_key: str | None = None
output_type_id: str | None = None
output_type_ids: list[str] | None = None # replaces output_type_id
target_collection: str | None = None
material_replace_enabled: bool | None = None
lighting_only: bool | None = None
@@ -74,12 +76,17 @@ def _to_out(t: RenderTemplate) -> dict:
ot_name = None
if t.output_type:
ot_name = t.output_type.name
# M2M output types
ot_ids = [str(ot.id) for ot in t.output_types] if t.output_types else []
ot_names = [ot.name for ot in t.output_types] if t.output_types else []
return {
"id": str(t.id),
"name": t.name,
"category_key": t.category_key,
"output_type_id": str(t.output_type_id) if t.output_type_id else None,
"output_type_name": ot_name,
"output_type_ids": ot_ids,
"output_type_names": ot_names,
"blend_file_path": t.blend_file_path,
"original_filename": t.original_filename,
"target_collection": t.target_collection,
@@ -103,7 +110,7 @@ async def list_render_templates(
result = await db.execute(
select(RenderTemplate).order_by(RenderTemplate.created_at.desc())
)
return [_to_out(t) for t in result.scalars().all()]
return [_to_out(t) for t in result.unique().scalars().all()]
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
@@ -151,6 +158,17 @@ async def create_render_template(
camera_orbit=camera_orbit,
)
db.add(tmpl)
await db.flush()
# Sync M2M from initial output_type_id
if ot_uuid:
from app.domains.rendering.models import render_template_output_types
await db.execute(
render_template_output_types.insert().values(
template_id=template_id, output_type_id=ot_uuid,
)
)
await db.commit()
await db.refresh(tmpl)
@@ -170,7 +188,7 @@ async def update_render_template(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.scalar_one_or_none()
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
@@ -179,12 +197,9 @@ async def update_render_template(
# Normalise empty strings to None for nullable fields
if "category_key" in updates and updates["category_key"] in ("", "null"):
updates["category_key"] = None
if "output_type_id" in updates:
val = updates["output_type_id"]
if val in ("", "null", None):
updates["output_type_id"] = None
else:
updates["output_type_id"] = uuid.UUID(val)
# Handle M2M output_type_ids
new_ot_ids: list[str] | None = updates.pop("output_type_ids", None)
if updates:
updates["updated_at"] = datetime.utcnow()
@@ -193,9 +208,34 @@ async def update_render_template(
.where(RenderTemplate.id == template_id)
.values(**updates)
)
await db.commit()
await db.refresh(tmpl)
# Sync M2M relationship
if new_ot_ids is not None:
from app.domains.rendering.models import render_template_output_types
# Delete existing links
await db.execute(
sql_delete(render_template_output_types).where(
render_template_output_types.c.template_id == template_id
)
)
# Insert new links
for ot_id in new_ot_ids:
await db.execute(
render_template_output_types.insert().values(
template_id=template_id,
output_type_id=uuid.UUID(ot_id),
)
)
# Also update legacy FK to first OT (for backward compat)
legacy_ot = uuid.UUID(new_ot_ids[0]) if new_ot_ids else None
await db.execute(
sql_update(RenderTemplate)
.where(RenderTemplate.id == template_id)
.values(output_type_id=legacy_ot, updated_at=datetime.utcnow())
)
await db.commit()
await db.refresh(tmpl)
return _to_out(tmpl)
@@ -206,7 +246,7 @@ async def delete_render_template(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.scalar_one_or_none()
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
@@ -231,7 +271,7 @@ async def upload_blend_file(
raise HTTPException(400, detail="File must be a .blend file")
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.scalar_one_or_none()
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
@@ -266,7 +306,7 @@ async def download_blend_file(
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
tmpl = result.scalar_one_or_none()
tmpl = result.unique().scalar_one_or_none()
if not tmpl:
raise HTTPException(404, detail="Render template not found")
+13 -1
View File
@@ -1,10 +1,19 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, Integer, Float, ForeignKey
from sqlalchemy import String, DateTime, Boolean, Text, Integer, Float, ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
# M2M: render templates ↔ output types
render_template_output_types = Table(
"render_template_output_types",
Base.metadata,
Column("template_id", UUID(as_uuid=True), ForeignKey("render_templates.id", ondelete="CASCADE"), primary_key=True),
Column("output_type_id", UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="CASCADE"), primary_key=True),
)
VALID_RENDER_BACKENDS = {"celery"}
@@ -66,7 +75,10 @@ class RenderTemplate(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()")
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow)
# Legacy single FK (kept for backward compat, prefer output_types M2M)
output_type = relationship("OutputType", lazy="joined")
# M2M: multiple output types per template
output_types = relationship("OutputType", secondary=render_template_output_types, lazy="joined")
class ProductRenderPosition(Base):
+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()
+49
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta
from celery import shared_task
@@ -31,3 +32,51 @@ def broadcast_queue_status() -> None:
logger.debug("Broadcast queue_update: %s", depths)
except Exception as exc:
logger.warning("broadcast_queue_status failed: %s", exc)
@shared_task(name="app.tasks.beat_tasks.recover_stuck_cad_files", queue="step_processing")
def recover_stuck_cad_files() -> None:
"""Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'.
This recovers from worker crashes (container restarts, OOM kills) that leave
the processing_status committed as 'processing' with no task running to complete it.
Runs every 5 minutes via Celery Beat.
"""
try:
from sqlalchemy import create_engine, update, and_
from sqlalchemy.orm import Session
from app.config import settings
from app.models.cad_file import CadFile, ProcessingStatus
cutoff = datetime.utcnow() - timedelta(minutes=10)
sync_url = settings.database_url.replace("+asyncpg", "")
engine = create_engine(sync_url)
with Session(engine) as session:
result = session.execute(
update(CadFile)
.where(
and_(
CadFile.processing_status == ProcessingStatus.processing,
CadFile.updated_at < cutoff,
)
)
.values(
processing_status=ProcessingStatus.failed,
error_message="Processing timed out — worker may have crashed. Use 'Regenerate Thumbnail' to retry.",
)
.returning(CadFile.id, CadFile.original_name)
)
rows = result.fetchall()
session.commit()
engine.dispose()
if rows:
names = [r[1] for r in rows]
logger.warning(
"recover_stuck_cad_files: reset %d stuck file(s) to failed: %s",
len(rows), names,
)
else:
logger.debug("recover_stuck_cad_files: no stuck files found")
except Exception as exc:
logger.error("recover_stuck_cad_files failed: %s", exc)
+4
View File
@@ -34,5 +34,9 @@ celery_app.conf.update(
"task": "app.tasks.beat_tasks.broadcast_queue_status",
"schedule": 10.0, # every 10 seconds
},
"recover-stuck-cad-files-every-5m": {
"task": "app.tasks.beat_tasks.recover_stuck_cad_files",
"schedule": 300.0, # every 5 minutes
},
},
)
+118 -44
View File
@@ -363,6 +363,8 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
from app.config import settings as app_settings
from app.models.cad_file import CadFile
from app.models.system_setting import SystemSetting as _SysSetting
sync_url = app_settings.database_url.replace("+asyncpg", "")
eng = create_engine(sync_url)
with Session(eng) as session:
@@ -386,8 +388,14 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
hex_color = entry.get("hex_color") or entry.get("color", "")
if part_name and hex_color:
color_map[part_name] = hex_color
settings_rows = session.execute(_select(_SysSetting)).scalars().all()
sys_settings = {s.key: s.value for s in settings_rows}
eng.dispose()
linear_deflection = float(sys_settings.get("gltf_preview_linear_deflection", "0.1"))
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.5"))
step = _Path(step_path_str)
if not step.exists():
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
@@ -411,7 +419,14 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
"--step_path", str(step),
"--output_path", str(output_path),
"--color_map", _json.dumps(color_map),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
]
log_task_event(
self.request.id,
f"OCC tessellation: linear={linear_deflection}mm, angular={angular_deflection}rad",
"info",
)
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
@@ -485,6 +500,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
import json as _json
import os as _os
import subprocess as _subprocess
import sys as _sys
import uuid as _uuid
from pathlib import Path as _Path
@@ -500,53 +516,97 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
_sync_url = app_settings.database_url.replace("+asyncpg", "")
_eng = _ce(_sync_url)
# --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset ---
with _Session(_eng) as _sess:
_row = _sess.execute(
_sel(MediaAsset).where(
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
).scalar_one_or_none()
geom_glb_key = _row.storage_key if _row else None
if not geom_glb_key:
# Trigger geometry generation first and retry this task
log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info")
generate_gltf_geometry_task.delay(cad_file_id, product_id)
raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2)
geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key
if not geom_glb_path.exists():
raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}")
# --- 2. Resolve material map (SCHAEFFLER library names) ---
from app.services.material_service import resolve_material_map
with _Session(_eng) as _sess:
from app.models.cad_file import CadFile as _CF
_cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none()
raw_mat_map: dict = {}
if _cad and _cad.cad_part_materials:
raw_mat_map = _cad.cad_part_materials
mat_map = resolve_material_map(raw_mat_map)
# --- 3. Resolve asset library .blend path from system settings ---
# --- 1. Resolve STEP file path and system settings ---
from app.models.cad_file import CadFile as _CF
from app.models.system_setting import SystemSetting
with _Session(_eng) as _sess:
_setting = _sess.execute(
_sel(SystemSetting).where(SystemSetting.key == "asset_library_blend")
).scalar_one_or_none()
asset_library_blend = _setting.value if _setting and _setting.value else ""
_eng.dispose()
# Output path next to geometry GLB
output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb")
with _Session(_eng) as _sess:
_cad = _sess.execute(
_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))
).scalar_one_or_none()
step_path_str = _cad.stored_path if _cad else None
settings_rows = _sess.execute(_sel(SystemSetting)).scalars().all()
sys_settings = {s.key: s.value for s in settings_rows}
if not step_path_str:
raise RuntimeError(f"CadFile {cad_file_id} not found in DB")
step_path = _Path(step_path_str)
if not step_path.exists():
raise RuntimeError(f"STEP file not found: {step_path}")
smooth_angle = float(sys_settings.get("blender_smooth_angle", "30"))
prod_linear = float(sys_settings.get("gltf_production_linear_deflection", "0.03"))
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.2"))
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
occ_script = scripts_dir / "export_step_to_gltf.py"
if not occ_script.exists():
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb"
python_bin = _sys.executable
occ_cmd = [
python_bin, str(occ_script),
"--step_path", str(step_path),
"--output_path", str(prod_geom_glb),
"--linear_deflection", str(prod_linear),
"--angular_deflection", str(prod_angular),
]
log_task_event(
self.request.id,
f"Re-exporting STEP at production quality (linear={prod_linear}mm, angular={prod_angular}rad)",
"info",
)
try:
occ_result = _subprocess.run(occ_cmd, capture_output=True, text=True, timeout=180)
for line in occ_result.stdout.splitlines():
logger.info("[occ-prod] %s", line)
if occ_result.returncode != 0 or not prod_geom_glb.exists() or prod_geom_glb.stat().st_size == 0:
raise RuntimeError(
f"OCC export failed (exit {occ_result.returncode}): {occ_result.stderr[-500:]}"
)
except Exception as exc:
log_task_event(self.request.id, f"OCC re-export failed: {exc}", "error")
raise self.retry(exc=exc, countdown=30)
geom_glb_path = prod_geom_glb
# --- 2. Resolve material map from Product.cad_part_materials (SCHAEFFLER library names) ---
# cad_part_materials lives on Product (list[dict]), NOT on CadFile.
# We look up the Product that owns this CadFile (prefer product_id arg if given).
from app.services.material_service import resolve_material_map
from app.domains.products.models import Product as _Product
with _Session(_eng) as _sess:
_prod_query = _sel(_Product).where(_Product.cad_file_id == _uuid.UUID(cad_file_id))
if product_id:
_prod_query = _prod_query.where(_Product.id == _uuid.UUID(product_id))
_product = _sess.execute(_prod_query).scalars().first()
raw_materials: list[dict] = _product.cad_part_materials if _product else []
# Convert list[{"part_name": X, "material": Y}] → dict[str, str] for resolve_material_map
raw_mat_map: dict[str, str] = {
m["part_name"]: m["material"]
for m in raw_materials
if m.get("part_name") and m.get("material")
}
mat_map = resolve_material_map(raw_mat_map)
logger.info(
"generate_gltf_production_task: resolved %d material(s) for cad %s (product: %s)",
len(mat_map), cad_file_id, _product.id if _product else "none",
)
# --- 3. Run Blender: apply materials + smooth shading + export production GLB ---
# Use get_material_library_path() which checks active AssetLibrary first,
# then falls back to the legacy material_library_path system setting.
from app.services.template_service import get_material_library_path
asset_library_blend = get_material_library_path() or ""
_eng.dispose()
output_path = step_path.parent / f"{step_path.stem}_production.glb"
export_script = scripts_dir / "export_gltf.py"
if not is_blender_available():
raise RuntimeError("Blender is not available — cannot generate production GLB")
if not export_script.exists():
@@ -560,13 +620,20 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
"--glb_path", str(geom_glb_path),
"--output_path", str(output_path),
"--material_map", _json.dumps(mat_map),
"--smooth_angle", str(smooth_angle),
]
if asset_library_blend:
cmd += ["--asset_library_blend", asset_library_blend]
log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info")
log_task_event(
self.request.id,
f"Running Blender export_gltf.py — {len(mat_map)} material(s), smooth={smooth_angle}°",
"info",
)
try:
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
for line in result.stdout.splitlines():
logger.info("[export-gltf] %s", line)
if result.returncode != 0:
raise RuntimeError(
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
@@ -575,6 +642,12 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
raise self.retry(exc=exc, countdown=30)
finally:
# Clean up the high-quality temp geometry GLB (not needed after Blender export)
try:
prod_geom_glb.unlink(missing_ok=True)
except Exception:
pass
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
@@ -888,7 +961,7 @@ def render_order_line_task(self, order_line_id: str):
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
else:
# ── Still image path ────────────────────────────────────────
emit(order_line_id, f"Calling renderer (STEP → STL → still) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
emit(order_line_id, f"Calling renderer (STEP → GLB → Blender) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
from app.services.step_processor import render_to_file
success, render_log = render_to_file(
@@ -912,6 +985,7 @@ def render_order_line_task(self, order_line_id: str):
rotation_y=rotation_y,
rotation_z=rotation_z,
job_id=order_line_id,
order_line_id=order_line_id,
noise_threshold=noise_threshold,
denoiser=denoiser,
denoising_input_passes=denoising_input_passes,