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