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:
@@ -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.001–10.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.05–1.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.001–10.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.05–1.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),
|
||||
|
||||
@@ -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."}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user