feat(N): workflow pipeline, 3D viewer, worker management, QC tests

- workflow_builder.py: fix broken stubs, add render_order_line_still_task
  (resolves step_path from DB instead of passing order_line_id as step_path)
- domains/rendering/tasks.py: add render_order_line_still_task,
  export_gltf_for_order_line_task, export_blend_for_order_line_task,
  generate_gltf_geometry_task (trimesh STL→GLB, no Blender needed)
- tasks/step_tasks.py: add generate_gltf_geometry_task for CadFile GLB export
- cad router: POST /{id}/generate-gltf-geometry endpoint (admin/PM)
- worker router: GET /celery-workers + POST /scale (docker compose subprocess)
- Dockerfile: pip install -e "[dev]" to enable pytest
- docker-compose.yml: docker socket + compose file mount on backend
- ThreeDViewer.tsx: mode toggle (geometry/production), wireframe, env presets,
  download buttons (GLB + .blend)
- CadPreview.tsx: load gltf_geometry/gltf_production/blend_production assets
  from MediaAsset table and pass URLs to ThreeDViewer
- ProductDetail.tsx: "View 3D" button → /cad/:id, "Generate GLB" button
- media router/service: cad_file_id filter on GET /api/media
- WorkerManagement.tsx: new page with worker status, queue depth, scale controls
- App.tsx + Layout.tsx: /workers route + sidebar link (admin/PM)
- tests: test_rendering_service.py, test_orders_service.py (backend)
- tests: WorkerActivity.test.tsx, WorkerManagement.test.tsx (frontend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:56:53 +01:00
parent 208eb21988
commit a70cb55d01
24 changed files with 1828 additions and 448 deletions
+170
View File
@@ -269,6 +269,176 @@ def publish_asset(
return asyncio.get_event_loop().run_until_complete(_run())
def _resolve_step_path_for_order_line(order_line_id: str) -> tuple[str | None, str | None]:
"""Sync helper: resolves (step_path, cad_file_id) from an OrderLine via DB."""
import asyncio
async def _inner() -> tuple[str | None, str | None]:
from app.database import AsyncSessionLocal
from app.domains.orders.models import OrderLine
from app.domains.products.models import Product
from app.models.cad_file import CadFile
from sqlalchemy import select
from sqlalchemy.orm import selectinload
async with AsyncSessionLocal() as db:
res = await db.execute(
select(OrderLine)
.options(selectinload(OrderLine.product))
.where(OrderLine.id == order_line_id)
)
line = res.scalar_one_or_none()
if not line or not line.product or not line.product.cad_file_id:
return None, None
cad_res = await db.execute(
select(CadFile).where(CadFile.id == line.product.cad_file_id)
)
cad = cad_res.scalar_one_or_none()
if not cad or not cad.stored_path:
return None, None
return cad.stored_path, str(line.product.cad_file_id)
return asyncio.get_event_loop().run_until_complete(_inner())
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.render_order_line_still_task",
queue="thumbnail_rendering",
max_retries=2,
)
def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
"""Render a still image for an order line, resolving STEP path from DB.
Wraps render_still_task logic but accepts order_line_id instead of step_path.
On success, creates a MediaAsset record via publish_asset.
"""
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
raise RuntimeError(
f"Cannot resolve STEP path for order_line {order_line_id}: "
"product missing or has no linked CAD file"
)
step = Path(step_path_str)
output_dir = step.parent / "renders"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"line_{order_line_id}.png"
try:
from app.services.render_blender import render_still
result = render_still(
step_path=step,
output_path=output_path,
**params,
)
publish_asset.delay(
order_line_id,
"still",
str(output_path),
render_config=result,
)
logger.info(
"render_order_line_still_task completed for line %s in %.1fs",
order_line_id, result.get("total_duration_s", 0),
)
return result
except Exception as exc:
logger.error("render_order_line_still_task failed for %s: %s", order_line_id, exc)
raise self.retry(exc=exc, countdown=30)
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.export_gltf_for_order_line_task",
queue="thumbnail_rendering",
max_retries=1,
)
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
"""Export a geometry-only GLB from the STL cache using trimesh (no Blender).
Publishes a MediaAsset with asset_type='gltf_geometry'.
Requires the STL low-quality cache to exist.
"""
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(
f"STL cache not found: {stl_path}. Run thumbnail generation first."
)
output_path = step.parent / f"{step.stem}_geometry.glb"
try:
import trimesh
mesh = trimesh.load(str(stl_path))
mesh.export(str(output_path))
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
logger.info("export_gltf_for_order_line_task completed: %s", output_path.name)
return {"glb_path": str(output_path)}
except Exception as exc:
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
raise self.retry(exc=exc, countdown=15)
@celery_app.task(
bind=True,
name="app.domains.rendering.tasks.export_blend_for_order_line_task",
queue="thumbnail_rendering",
max_retries=1,
)
def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
"""Export a production-quality GLB via Blender + asset library (export_gltf.py).
Publishes a MediaAsset with asset_type='blend_production'.
Requires Blender + the render-scripts directory.
"""
import os
import subprocess
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
if not step_path_str:
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
step = Path(step_path_str)
stl_path = step.parent / f"{step.stem}_low.stl"
if not stl_path.exists():
raise RuntimeError(f"STL cache not found: {stl_path}")
output_path = step.parent / f"{step.stem}_production.glb"
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
export_script = scripts_dir / "export_gltf.py"
from app.services.render_blender import find_blender
blender_bin = find_blender()
if not blender_bin:
raise RuntimeError("Blender binary not found — cannot run export_blend task")
try:
cmd = [
blender_bin, "--background",
"--python", str(export_script),
"--",
"--stl_path", str(stl_path),
"--output_path", str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
raise RuntimeError(
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
)
publish_asset.delay(order_line_id, "blend_production", str(output_path))
logger.info("export_blend_for_order_line_task completed: %s", output_path.name)
return {"glb_path": str(output_path)}
except Exception as exc:
logger.error("export_blend_for_order_line_task failed for %s: %s", order_line_id, exc)
raise self.retry(exc=exc, countdown=30)
def _build_ffmpeg_cmd(
frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = ""
) -> list:
@@ -20,6 +20,7 @@ def dispatch_workflow(
"still": _build_still,
"turntable": _build_turntable,
"multi_angle": _build_multi_angle,
"still_with_exports": _build_still_with_exports,
}
builder = builders.get(workflow_type)
if not builder:
@@ -30,24 +31,56 @@ def dispatch_workflow(
def _build_still(order_line_id: str, params: dict):
from app.domains.rendering.tasks import render_still_task
"""Still render: resolves STEP path from order_line DB record."""
from app.domains.rendering.tasks import render_order_line_still_task
return chain(
render_still_task.si(order_line_id, **params)
render_order_line_still_task.si(order_line_id, **params)
)
def _build_turntable(order_line_id: str, params: dict):
"""Turntable animation: requires step_path + output_dir in params."""
from app.domains.rendering.tasks import render_turntable_task
step_path = params.get("step_path")
output_dir = params.get("output_dir")
if not step_path or not output_dir:
raise ValueError(
"turntable workflow requires 'step_path' and 'output_dir' in params"
)
remaining = {k: v for k, v in params.items() if k not in ("step_path", "output_dir")}
return chain(
render_turntable_task.si(order_line_id, **params)
render_turntable_task.si(step_path, output_dir, **remaining)
)
def _build_multi_angle(order_line_id: str, params: dict):
from app.domains.rendering.tasks import render_still_task
angles = params.get("angles", [0, 45, 90])
p = {k: v for k, v in params.items() if k != "angles"}
"""Multi-angle stills: renders the same order_line from multiple rotation_z angles."""
from app.domains.rendering.tasks import render_order_line_still_task
angles = params.pop("angles", [0, 45, 90])
return group(
render_still_task.si(order_line_id, camera_angle=angle, **p)
render_order_line_still_task.si(order_line_id, rotation_z=float(angle), **params)
for angle in angles
)
def _build_still_with_exports(order_line_id: str, params: dict):
"""Still render + parallel GLB exports (geometry + production quality).
Pipeline:
render_order_line_still_task → group(
export_gltf_for_order_line_task,
export_blend_for_order_line_task,
)
"""
from app.domains.rendering.tasks import (
render_order_line_still_task,
export_gltf_for_order_line_task,
export_blend_for_order_line_task,
)
return chain(
render_order_line_still_task.si(order_line_id, **params),
group(
export_gltf_for_order_line_task.si(order_line_id),
export_blend_for_order_line_task.si(order_line_id),
),
)