From 382a18fd02eecca1cd836b82b6cf26ee1b351415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 6 Mar 2026 23:20:55 +0100 Subject: [PATCH] =?UTF-8?q?feat(O):=20UI-Vollst=C3=A4ndigkeit=20+=20v3-Wor?= =?UTF-8?q?kflows=20+=20OCC-Kantenanalyse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Phase I: notification_configs router (GET/PUT/{event}/{channel}/POST reset) war bereits in notifications.py — add-alias endpoint in uploads.py ergänzt - OutputType schema: workflow_definition_id + workflow_name fields; PATCH unterstützt Workflow-Zuweisung; _enrich_workflow_names() batch query - Dispatch-Integration: orders.py dispatch_renders() → dispatch_render_with_workflow() mit Legacy-Fallback; neues Logging - uploads.py: POST /validations/{id}/add-alias für Material-Lücken Pipeline: - step_processor.py: extract_mesh_edge_data() via OCC — berechnet Dihedralwinkel aller Kanten, liefert suggested_smooth_angle + sharp_edge_midpoints Integriert in extract_cad_metadata() und process_cad_file() - domains/rendering/tasks.py: apply_asset_library_materials_task (K3), export_gltf_for_order_line_task → Blender export_gltf.py (K4), export_blend_for_order_line_task → export_blend.py fix (K5) - render-worker/scripts/still_render.py: _mark_sharp_and_seams() mit OCC midpoint KD-tree matching + UV-Seam-Markierung - render-worker/scripts/blender_render.py: identische Funktion + mesh_attributes parsing Frontend: - Layout.tsx: Upload-Link in Sidebar (alle User); Asset Libraries Link (admin/PM) - App.tsx: /asset-libraries Route - AssetLibrary.tsx: neue Seite (Upload, Catalog-Anzeige, Refresh, Toggle, Delete) - OutputTypeTable.tsx: Workflow-Dropdown + Legacy/Workflow Badge - ProductDetail.tsx: Geometry-Karte (Volumen, Surface, BBox, Sharp-Winkel) - api/outputTypes.ts + api/products.ts: neue Felder - api/imports.ts: ImportValidation API Co-Authored-By: Claude Sonnet 4.6 --- backend/Dockerfile | 1 - backend/app/api/routers/orders.py | 15 +- backend/app/api/routers/output_types.py | 27 +- backend/app/api/routers/uploads.py | 53 ++ backend/app/domains/rendering/schemas.py | 3 + backend/app/domains/rendering/tasks.py | 163 +++++- backend/app/services/step_processor.py | 123 ++++ frontend/src/App.tsx | 9 + frontend/src/api/imports.ts | 23 + frontend/src/api/outputTypes.ts | 1 + frontend/src/api/products.ts | 12 + .../src/components/admin/OutputTypeTable.tsx | 48 +- frontend/src/components/layout/Layout.tsx | 19 +- frontend/src/pages/AssetLibrary.tsx | 336 +++++++++++ frontend/src/pages/ProductDetail.tsx | 45 +- plan.md | 542 +++++++----------- render-worker/scripts/blender_render.py | 83 ++- render-worker/scripts/still_render.py | 74 ++- 18 files changed, 1222 insertions(+), 355 deletions(-) create mode 100644 frontend/src/api/imports.ts create mode 100644 frontend/src/pages/AssetLibrary.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile index 6fd7a9a..82b1a41 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,7 +18,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Copy docker CLI for worker scaling COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker -COPY --from=docker-cli /usr/local/lib/docker/cli-plugins /usr/local/lib/docker/cli-plugins # Install Python dependencies (including dev extras for pytest) COPY pyproject.toml . diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index e9929d5..088e323 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -1,4 +1,5 @@ import io +import logging import os import re import uuid @@ -6,6 +7,8 @@ import zipfile from datetime import datetime from typing import Optional +logger = logging.getLogger(__name__) + from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse from pydantic import BaseModel @@ -906,9 +909,17 @@ async def dispatch_renders( ) await db.commit() - from app.tasks.step_tasks import dispatch_order_line_render + from app.domains.rendering.dispatch_service import dispatch_render_with_workflow for line in lines: - dispatch_order_line_render.delay(str(line.id)) + try: + dispatch_render_with_workflow(str(line.id)) + except Exception as exc: + logger.warning( + "dispatch_render_with_workflow failed for %s, falling back: %s", + line.id, exc, + ) + from app.tasks.step_tasks import dispatch_order_line_render + dispatch_order_line_render.delay(str(line.id)) return {"dispatched": len(lines), "order_status": order.status.value} diff --git a/backend/app/api/routers/output_types.py b/backend/app/api/routers/output_types.py index fae998f..14e5dcc 100644 --- a/backend/app/api/routers/output_types.py +++ b/backend/app/api/routers/output_types.py @@ -13,6 +13,7 @@ from app.models.output_type import OutputType, VALID_RENDER_BACKENDS from app.schemas.output_type import OutputTypeCreate, OutputTypeOut, OutputTypePatch from app.utils.auth import get_current_user, require_admin_or_pm from app.models.user import User +from app.domains.rendering.models import WorkflowDefinition router = APIRouter(prefix="/output-types", tags=["output-types"]) @@ -23,9 +24,26 @@ def _ot_to_out(ot: OutputType) -> OutputTypeOut: if ot.pricing_tier: out.pricing_tier_name = f"{ot.pricing_tier.category_key}/{ot.pricing_tier.quality_level}" out.price_per_item = float(ot.pricing_tier.price_per_item) + # workflow_definition_id is mapped via model_validate from the ORM column. + # workflow_name is resolved by _enrich_workflow_names() after the fact. return out +async def _enrich_workflow_names(db: AsyncSession, items: list[OutputTypeOut]) -> list[OutputTypeOut]: + """Resolve workflow_name for any OutputTypeOut that has a workflow_definition_id set.""" + wf_ids = {item.workflow_definition_id for item in items if item.workflow_definition_id is not None} + if not wf_ids: + return items + wf_result = await db.execute( + select(WorkflowDefinition).where(WorkflowDefinition.id.in_(wf_ids)) + ) + wf_map: dict[uuid.UUID, str] = {wf.id: wf.name for wf in wf_result.scalars().all()} + for item in items: + if item.workflow_definition_id is not None: + item.workflow_name = wf_map.get(item.workflow_definition_id) + return items + + @router.get("", response_model=list[OutputTypeOut]) async def list_output_types( include_inactive: bool = Query(False), @@ -50,7 +68,8 @@ async def list_output_types( ) ) result = await db.execute(stmt) - return [_ot_to_out(ot) for ot in result.scalars().all()] + items = [_ot_to_out(ot) for ot in result.scalars().all()] + return await _enrich_workflow_names(db, items) @router.post("", response_model=OutputTypeOut, status_code=status.HTTP_201_CREATED) @@ -74,7 +93,8 @@ async def create_output_type( result2 = await db.execute( select(OutputType).options(selectinload(OutputType.pricing_tier)).where(OutputType.id == ot.id) ) - return _ot_to_out(result2.scalar_one()) + items = await _enrich_workflow_names(db, [_ot_to_out(result2.scalar_one())]) + return items[0] @router.patch("/{output_type_id}", response_model=OutputTypeOut) @@ -101,7 +121,8 @@ async def update_output_type( result2 = await db.execute( select(OutputType).options(selectinload(OutputType.pricing_tier)).where(OutputType.id == ot.id) ) - return _ot_to_out(result2.scalar_one()) + items = await _enrich_workflow_names(db, [_ot_to_out(result2.scalar_one())]) + return items[0] @router.delete("/{output_type_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/routers/uploads.py b/backend/app/api/routers/uploads.py index 7222433..14fb280 100644 --- a/backend/app/api/routers/uploads.py +++ b/backend/app/api/routers/uploads.py @@ -450,3 +450,56 @@ async def get_import_validation( if not val: raise HTTPException(404, detail="Validation not found") return ImportValidationOut.model_validate(val) + + +class AddAliasRequest(BaseModel): + part_name: str + material_name: str + + +@router.post("/validations/{validation_id}/add-alias", status_code=status.HTTP_201_CREATED) +async def add_material_alias_from_validation( + validation_id: uuid.UUID, + body: AddAliasRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +): + """Create a MaterialAlias entry mapping part_name to an existing material. + + Requires admin or project_manager role. + """ + from app.utils.auth import require_admin_or_pm + from app.domains.imports.models import ImportValidation + from app.domains.materials.models import Material, MaterialAlias + + # Gate to admin/PM + if user.role.value not in ("admin", "project_manager"): + raise HTTPException(status_code=403, detail="Admin or project_manager required") + + # Verify the validation exists + val_result = await db.execute(select(ImportValidation).where(ImportValidation.id == validation_id)) + if not val_result.scalar_one_or_none(): + raise HTTPException(404, detail="Validation not found") + + # Find the target material by name + mat_result = await db.execute(select(Material).where(Material.name == body.material_name)) + material = mat_result.scalar_one_or_none() + if not material: + raise HTTPException(404, detail=f"Material '{body.material_name}' not found in library") + + # Check for duplicate alias (case-insensitive) + from sqlalchemy import func as sql_func + dup_result = await db.execute( + select(MaterialAlias).where( + sql_func.lower(MaterialAlias.alias) == body.part_name.lower() + ) + ) + existing_alias = dup_result.scalar_one_or_none() + if existing_alias: + raise HTTPException(409, detail=f"Alias '{body.part_name}' already exists") + + alias = MaterialAlias(material_id=material.id, alias=body.part_name) + db.add(alias) + await db.commit() + await db.refresh(alias) + return {"id": str(alias.id), "alias": alias.alias, "material_id": str(material.id), "material_name": material.name} diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py index 73549cf..d4229b3 100644 --- a/backend/app/domains/rendering/schemas.py +++ b/backend/app/domains/rendering/schemas.py @@ -33,6 +33,7 @@ class OutputTypePatch(BaseModel): transparent_bg: bool | None = None pricing_tier_id: int | None = None cycles_device: str | None = None + workflow_definition_id: uuid.UUID | None = None class OutputTypeOut(BaseModel): @@ -51,6 +52,8 @@ class OutputTypeOut(BaseModel): pricing_tier_id: int | None = None pricing_tier_name: str | None = None price_per_item: float | None = None + workflow_definition_id: uuid.UUID | None = None + workflow_name: str | None = None is_active: bool created_at: datetime updated_at: datetime diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 7749609..e4c60f3 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -355,11 +355,16 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: 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). + """Export a GLB from the STL cache via Blender subprocess (with trimesh fallback). - Publishes a MediaAsset with asset_type='gltf_geometry'. + Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or + 'gltf_production' (when an asset library is applied). Requires the STL low-quality cache to exist. """ + import json + 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}") @@ -372,14 +377,47 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict: ) output_path = step.parent / f"{step.stem}_geometry.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, is_blender_available + + asset_type = "gltf_geometry" + + if is_blender_available() and export_script.exists(): + blender_bin = find_blender() + cmd = [ + blender_bin, "--background", + "--python", str(export_script), + "--", + "--stl_path", str(stl_path), + "--output_path", str(output_path), + "--asset_library_blend", "", + "--material_map", json.dumps({}), + ] + try: + 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, asset_type, str(output_path)) + logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name) + return {"glb_path": str(output_path), "method": "blender"} + except Exception as exc: + logger.warning( + "Blender GLB export failed for %s, falling back to trimesh: %s", + order_line_id, exc, + ) + + # Trimesh fallback 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)} + publish_asset.delay(order_line_id, asset_type, str(output_path)) + logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name) + return {"glb_path": str(output_path), "method": "trimesh"} 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) @@ -392,11 +430,12 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict: 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). + """Export a production .blend file via Blender + asset library (export_blend.py). Publishes a MediaAsset with asset_type='blend_production'. Requires Blender + the render-scripts directory. """ + import json import os import subprocess @@ -409,15 +448,38 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict: if not stl_path.exists(): raise RuntimeError(f"STL cache not found: {stl_path}") - output_path = step.parent / f"{step.stem}_production.glb" + output_path = step.parent / f"{step.stem}_production.blend" scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) - export_script = scripts_dir / "export_gltf.py" + export_script = scripts_dir / "export_blend.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") + # Resolve asset library path and material map from DB + asset_lib_path = "" + mat_map: dict = {} + try: + from sqlalchemy import create_engine, select as sql_select + from sqlalchemy.orm import Session + from app.config import settings as app_settings + from app.domains.orders.models import OrderLine + from app.domains.products.models import Product + + engine = create_engine(app_settings.database_url_sync) + with Session(engine) as s: + line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none() + if line: + product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none() + if product: + mat_map = { + m.get("part_name", ""): m.get("material", "") + for m in (product.cad_part_materials or []) + } + except Exception as exc: + logger.warning("export_blend_for_order_line_task: DB resolution error (non-fatal): %s", exc) + try: cmd = [ blender_bin, "--background", @@ -425,20 +487,101 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict: "--", "--stl_path", str(stl_path), "--output_path", str(output_path), + "--asset_library_blend", asset_lib_path, + "--material_map", json.dumps(mat_map), ] 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:]}" + f"export_blend.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)} + return {"blend_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) +@celery_app.task( + bind=True, + name="app.domains.rendering.tasks.apply_asset_library_materials_task", + queue="thumbnail_rendering", + max_retries=1, +) +def apply_asset_library_materials_task(self, order_line_id: str, asset_library_id: str) -> dict: + """Apply Blender asset library materials to a render via the asset_library.py script.""" + import json + import os + import subprocess + from pathlib import Path + from app.services.render_blender import find_blender + + blender_bin = find_blender() + if not blender_bin: + raise RuntimeError("Blender not available") + + # Resolve paths from DB + def _inner(): + from sqlalchemy import create_engine, select as sql_select + from sqlalchemy.orm import Session + from app.config import settings + from app.domains.orders.models import OrderLine + from app.domains.products.models import CadFile, Product + + engine = create_engine(settings.database_url_sync) + with Session(engine) as s: + line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none() + if not line: + return None, None, None + product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none() + if not product or not product.cad_file_id: + return None, None, None + cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none() + stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None + + # Resolve asset library blend path + try: + from app.domains.materials.models import AssetLibrary + lib = s.execute(sql_select(AssetLibrary).where(AssetLibrary.id == asset_library_id)).scalar_one_or_none() + blend_path = lib.blend_file_path if lib else None + except Exception: + blend_path = None + + mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])} + return stl_path, blend_path, mat_map + + result = _inner() + if result is None or result[0] is None: + logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id) + return {"status": "skipped"} + + stl_path, blend_path, mat_map = result + if not stl_path or not Path(stl_path).exists(): + logger.warning("STL not found for %s", order_line_id) + return {"status": "skipped", "reason": "stl_not_found"} + + scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) + script = scripts_dir / "asset_library.py" + + cmd = [ + blender_bin, "--background", "--python", str(script), "--", + "--stl_path", stl_path, + "--asset_library_blend", blend_path or "", + "--material_map", json.dumps(mat_map), + ] + + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if proc.returncode != 0: + raise RuntimeError(f"asset_library.py failed: {proc.stderr[-500:]}") + except Exception as exc: + logger.error("apply_asset_library_materials_task failed for %s: %s", order_line_id, exc) + raise self.retry(exc=exc, countdown=15) + + return {"status": "applied", "order_line_id": order_line_id} + + def _build_ffmpeg_cmd( frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = "" ) -> list: diff --git a/backend/app/services/step_processor.py b/backend/app/services/step_processor.py index e67a6b2..34a5e64 100644 --- a/backend/app/services/step_processor.py +++ b/backend/app/services/step_processor.py @@ -124,6 +124,10 @@ def extract_cad_metadata(cad_file_id: str) -> None: objects = _extract_step_objects(step_path) cad_file.parsed_objects = {"objects": objects} + edge_data = extract_mesh_edge_data(str(step_path)) + if edge_data: + cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data} + gltf_path = _convert_to_gltf(step_path, cad_file_id, settings.upload_dir) if gltf_path: cad_file.gltf_path = str(gltf_path) @@ -173,6 +177,11 @@ def process_cad_file(cad_file_id: str) -> None: objects = _extract_step_objects(step_path) cad_file.parsed_objects = {"objects": objects} + # Step 1b: Extract sharp-edge topology data and merge into mesh_attributes + edge_data = extract_mesh_edge_data(str(step_path)) + if edge_data: + cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **edge_data} + # Step 2: Generate thumbnail — pass empty part_colors so the Three.js # renderer extracts named parts and auto-assigns palette colours. # Other renderers (Blender, Pillow) ignore the part_colors argument. @@ -197,6 +206,120 @@ def process_cad_file(cad_file_id: str) -> None: session.commit() +def extract_mesh_edge_data(step_path: str) -> dict: + """Extract sharp edge metrics and suggested smooth angle from STEP topology. + + Returns dict with: + - suggested_smooth_angle: float (degrees) — recommended auto-smooth angle + - has_mechanical_edges: bool — True if part has distinct hard edges (bearings etc.) + - sharp_edge_midpoints: list of [x, y, z] — midpoints of sharp edges in mm (max 500) + """ + try: + from OCC.Core.STEPControl import STEPControl_Reader + from OCC.Core.IFSelect import IFSelect_RetDone + from OCC.Core.TopExp import TopExp_Explorer + from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE + from OCC.Core.BRepAdaptor import BRepAdaptor_Surface + from OCC.Core.BRep import BRep_Tool + from OCC.Core.BRepGProp import brepgprop + from OCC.Core.GProp import GProp_GProps + from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh + from OCC.Core.gp import gp_Pnt + import math + + reader = STEPControl_Reader() + status = reader.ReadFile(step_path) + if status != IFSelect_RetDone: + return {} + reader.TransferRoots() + shape = reader.OneShape() + + # Mesh the shape for geometry access + BRepMesh_IncrementalMesh(shape, 0.5, False, 0.5) + + # Collect face normals per edge (for dihedral angle computation) + from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape + from OCC.Core.TopExp import topexp + + edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() + topexp.MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map) + + dihedral_angles = [] + sharp_midpoints = [] + + for i in range(1, edge_face_map.Extent() + 1): + edge = edge_face_map.FindKey(i) + faces = edge_face_map.FindFromIndex(i) + if faces.Size() < 2: + continue + + # Get the two adjacent faces + face_list = list(faces) + if len(face_list) < 2: + continue + + try: + surf1 = BRepAdaptor_Surface(face_list[0]) + surf2 = BRepAdaptor_Surface(face_list[1]) + + # Get normals at midpoint of edge + from OCC.Core.BRepAdaptor import BRepAdaptor_Curve + curve = BRepAdaptor_Curve(edge) + mid_u = (curve.FirstParameter() + curve.LastParameter()) / 2 + mid_pt = curve.Value(mid_u) + + # Sample face normals at UV center + u1 = (surf1.FirstUParameter() + surf1.LastUParameter()) / 2 + v1 = (surf1.FirstVParameter() + surf1.LastVParameter()) / 2 + n1 = surf1.DN(u1, v1, 0, 1).Crossed(surf1.DN(u1, v1, 1, 0)) + + u2 = (surf2.FirstUParameter() + surf2.LastUParameter()) / 2 + v2 = (surf2.FirstVParameter() + surf2.LastVParameter()) / 2 + n2 = surf2.DN(u2, v2, 0, 1).Crossed(surf2.DN(u2, v2, 1, 0)) + + if n1.Magnitude() > 1e-10 and n2.Magnitude() > 1e-10: + n1.Normalize() + n2.Normalize() + cos_angle = max(-1.0, min(1.0, n1.Dot(n2))) + angle_deg = math.degrees(math.acos(abs(cos_angle))) + dihedral_angles.append(angle_deg) + + if angle_deg > 20 and len(sharp_midpoints) < 500: + sharp_midpoints.append([ + round(mid_pt.X(), 3), + round(mid_pt.Y(), 3), + round(mid_pt.Z(), 3), + ]) + except Exception: + continue + + if not dihedral_angles: + return {} + + import statistics + median_angle = statistics.median(dihedral_angles) + max_angle = max(dihedral_angles) + + # Suggest smooth angle: slightly below the median of hard edges + hard_edges = [a for a in dihedral_angles if a > 20] + if hard_edges: + suggested = max(15.0, min(60.0, statistics.median(hard_edges) * 0.8)) + else: + suggested = 30.0 + + return { + "suggested_smooth_angle": round(suggested, 1), + "has_mechanical_edges": max_angle > 45, + "sharp_edge_midpoints": sharp_midpoints[:500], + } + except ImportError: + # OCC not available (e.g. in backend container) + return {} + except Exception as exc: + logger.warning("extract_mesh_edge_data failed (non-fatal): %s", exc) + return {} + + def _extract_step_objects(step_path: Path) -> list[str]: """Extract part names from STEP file using pythonocc.""" try: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b040464..c8b916a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import WorkflowEditorPage from './pages/WorkflowEditor' import MediaBrowserPage from './pages/MediaBrowser' import BillingPage from './pages/Billing' import WorkerManagementPage from './pages/WorkerManagement' +import AssetLibraryPage from './pages/AssetLibrary' function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token) @@ -113,6 +114,14 @@ export default function App() { } /> + + + + } + /> diff --git a/frontend/src/api/imports.ts b/frontend/src/api/imports.ts new file mode 100644 index 0000000..10affb8 --- /dev/null +++ b/frontend/src/api/imports.ts @@ -0,0 +1,23 @@ +import api from './client' + +export interface ImportValidation { + id: string + status: 'pending' | 'running' | 'completed' | 'failed' + summary: { + total_rows: number + rows_with_cad: number + rows_without_cad: number + unresolvable_materials: Array<{ row_name: string; material: string }> + } | null + created_at: string + completed_at: string | null +} + +export async function getImportValidation(id: string): Promise { + const res = await api.get(`/imports/validation/${id}`) + return res.data +} + +export async function addMaterialAliasFromValidation(validationId: string, partName: string, materialName: string): Promise { + await api.post(`/imports/validation/${validationId}/add-alias`, { part_name: partName, material_name: materialName }) +} diff --git a/frontend/src/api/outputTypes.ts b/frontend/src/api/outputTypes.ts index 301057f..d7d07b7 100644 --- a/frontend/src/api/outputTypes.ts +++ b/frontend/src/api/outputTypes.ts @@ -16,6 +16,7 @@ export interface OutputType { pricing_tier_id: number | null pricing_tier_name: string | null price_per_item: number | null + workflow_definition_id: string | null is_active: boolean created_at: string updated_at: string diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index 855a868..d68a834 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -24,6 +24,14 @@ export interface CadPartMaterial { material: string } +export interface CadFileMeshAttributes { + volume_mm3?: number + surface_area_mm2?: number + bbox?: { x?: number; y?: number; z?: number } + suggested_smooth_angle?: number + [key: string]: unknown +} + export interface Product { id: string pim_id: string @@ -40,6 +48,10 @@ export interface Product { components: ComponentData[] cad_part_materials: CadPartMaterial[] cad_file_id: string | null + cad_file?: { + id: string + mesh_attributes?: CadFileMeshAttributes + } | null thumbnail_url: string | null render_image_url: string | null processing_status: string | null diff --git a/frontend/src/components/admin/OutputTypeTable.tsx b/frontend/src/components/admin/OutputTypeTable.tsx index 59a48fc..fcf4a62 100644 --- a/frontend/src/components/admin/OutputTypeTable.tsx +++ b/frontend/src/components/admin/OutputTypeTable.tsx @@ -8,6 +8,8 @@ import { import type { OutputType } from '../../api/outputTypes' import { listPricingTiers } from '../../api/pricing' import type { PricingTier } from '../../api/pricing' +import { getWorkflows } from '../../api/workflows' +import type { WorkflowDefinition } from '../../api/workflows' const RENDERERS = ['blender', 'pillow'] const FORMATS = ['png', 'jpg', 'gltf', 'stl', 'mp4', 'webm'] @@ -39,6 +41,22 @@ export default function OutputTypeTable() { queryFn: listPricingTiers, }) + const { data: workflows } = useQuery({ + queryKey: ['workflows'], + queryFn: getWorkflows, + }) + + const updateWorkflowMut = useMutation({ + mutationFn: ({ id, workflow_definition_id }: { id: string; workflow_definition_id: string | null }) => + updateOutputType(id, { workflow_definition_id }), + onSuccess: () => { + toast.success('Workflow updated') + qc.invalidateQueries({ queryKey: ['output-types-admin'] }) + qc.invalidateQueries({ queryKey: ['output-types'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update workflow'), + }) + const createMut = useMutation({ mutationFn: () => { const rs: Record = {} @@ -184,6 +202,7 @@ export default function OutputTypeTable() { Categories Resolution Pricing + Workflow Sort Active Actions @@ -192,7 +211,7 @@ export default function OutputTypeTable() { {isLoading && ( - Loading… + Loading… )} {types?.map((ot) => ( @@ -475,6 +494,18 @@ export default function OutputTypeTable() { ))} + + + Category default )} + + {(() => { + const wf = workflows?.find((w) => w.id === ot.workflow_definition_id) + return wf ? ( + + {wf.name} + + ) : ( + + Legacy + + ) + })()} + {ot.sort_order} + — )} + {(user?.role === 'admin' || user?.role === 'project_manager') && ( + + clsx( + 'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors', + isActive + ? 'bg-accent-light text-accent' + : 'text-content-secondary hover:bg-surface-hover', + ) + } + > + + Asset Libraries + + )} {user?.role === 'admin' && ( void }) { + const qc = useQueryClient() + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [file, setFile] = useState(null) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { 'application/octet-stream': ['.blend'] }, + multiple: false, + onDrop: (files) => { if (files[0]) setFile(files[0]) }, + }) + + const uploadMut = useMutation({ + mutationFn: () => { + if (!file || !name.trim()) throw new Error('Name and file required') + return createAssetLibrary({ name: name.trim(), description: description.trim() || undefined, blend_file: file }) + }, + onSuccess: () => { + toast.success('Asset library uploaded') + qc.invalidateQueries({ queryKey: ['asset-libraries'] }) + onClose() + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'), + }) + + return ( +
+
+
+

Upload Asset Library

+ +
+
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ +
+ + {file ? ( +

{file.name}

+ ) : ( + <> + +

+ {isDragActive ? 'Drop the .blend file here' : 'Drag & drop a .blend file, or click to browse'} +

+ + )} +
+
+
+
+ + +
+
+
+ ) +} + +// ── LibraryCard ──────────────────────────────────────────────────────────── + +function LibraryCard({ lib }: { lib: AssetLibrary }) { + const qc = useQueryClient() + const [expanded, setExpanded] = useState(false) + + const refreshMut = useMutation({ + mutationFn: () => refreshAssetLibraryCatalog(lib.id), + onSuccess: () => { + toast.success('Catalog updated') + qc.invalidateQueries({ queryKey: ['asset-libraries'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Refresh failed'), + }) + + const toggleMut = useMutation({ + mutationFn: () => api.patch(`/asset-libraries/${lib.id}`, { is_active: !lib.is_active }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['asset-libraries'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Toggle failed'), + }) + + const deleteMut = useMutation({ + mutationFn: () => deleteAssetLibrary(lib.id), + onSuccess: () => { + toast.success('Library deleted') + qc.invalidateQueries({ queryKey: ['asset-libraries'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'), + }) + + const materialCount = lib.catalog?.materials?.length ?? 0 + const nodeGroupCount = lib.catalog?.node_groups?.length ?? 0 + const MAX_VISIBLE = 10 + + return ( +
+ {/* Header row */} +
+
+
+

{lib.name}

+ + {lib.is_active ? 'active' : 'inactive'} + +
+ {lib.description && ( +

{lib.description}

+ )} + {lib.original_filename && ( +

{lib.original_filename}

+ )} +
+ + {/* Actions */} +
+ {/* Active toggle */} + + + + + +
+
+ + {/* Catalog badges */} +
+ + {materialCount} material{materialCount !== 1 ? 's' : ''} + + {nodeGroupCount > 0 && ( + + {nodeGroupCount} node group{nodeGroupCount !== 1 ? 's' : ''} + + )} +
+ + {/* Expandable material list */} + {materialCount > 0 && ( +
+ + {expanded && ( +
+ {lib.catalog.materials.slice(0, MAX_VISIBLE).map((m) => ( + + {m} + + ))} + {materialCount > MAX_VISIBLE && ( + + ... and {materialCount - MAX_VISIBLE} more + + )} +
+ )} +
+ )} +
+ ) +} + +// ── AssetLibraryPage ─────────────────────────────────────────────────────── + +export default function AssetLibraryPage() { + const [showUpload, setShowUpload] = useState(false) + + const { data: libraries, isLoading, isError } = useQuery({ + queryKey: ['asset-libraries'], + queryFn: listAssetLibraries, + }) + + return ( +
+ {/* Header */} +
+
+

Asset Libraries

+

+ Manage .blend material libraries used for Blender rendering. +

+
+ +
+ + {/* States */} + {isLoading && ( +
+
+ Loading libraries... +
+ )} + + {isError && ( +
+ Failed to load asset libraries. Please try again. +
+ )} + + {!isLoading && !isError && libraries && libraries.length === 0 && ( +
+ +

No asset libraries.

+

+ Upload a .blend file to get started. +

+ +
+ )} + + {!isLoading && !isError && libraries && libraries.length > 0 && ( +
+ {libraries.map((lib) => ( + + ))} +
+ )} + + {showUpload && setShowUpload(false)} />} +
+ ) +} diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index f2b3fc0..db9f7d1 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useDropzone } from 'react-dropzone' import { ArrowLeft, Pencil, Save, X, Box, Image, - RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, + RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, } from 'lucide-react' import { toast } from 'sonner' import { @@ -606,6 +606,49 @@ export default function ProductDetailPage() {
+ {/* Mesh attributes */} + {product.cad_file?.mesh_attributes && Object.keys(product.cad_file.mesh_attributes).length > 0 && (() => { + const mesh_attrs = product.cad_file!.mesh_attributes! + return ( +
+

+ + Geometry +

+
+ {mesh_attrs.volume_mm3 != null && ( + <> + Volume + {((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³ + + )} + {mesh_attrs.surface_area_mm2 != null && ( + <> + Surface + {((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm² + + )} + {mesh_attrs.bbox != null && ( + <> + BBox + + {(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).x?.toFixed(1)} ×{' '} + {(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).y?.toFixed(1)} ×{' '} + {(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).z?.toFixed(1)} mm + + + )} + {mesh_attrs.suggested_smooth_angle !== undefined && ( + <> + Sharp angle + {mesh_attrs.suggested_smooth_angle as number}° + + )} +
+
+ ) + })()} + {/* Material assignments */} {isPrivileged && (
diff --git a/plan.md b/plan.md index 4badec7..7ae7a7c 100644 --- a/plan.md +++ b/plan.md @@ -1,16 +1,6 @@ -# Plan: Phase N — Workflow-Pipeline, 3D-Viewer Production-Modus, Worker-Management, QC-Tests +# Plan: UI-Vollständigkeit + Workflows — Phase O -## Kontext - -Vier offene Bereiche aus dem PLAN.md müssen abgeschlossen werden: - -1. **Workflow-Pipeline verdrahten**: `workflow_builder.py` enthält nur defekte Stubs. `_build_still` übergibt `order_line_id` als `step_path` an `render_still_task` → würde crashen. Der neue `still_with_exports`-Workflow (still + gltf_export + blend_export) ist nicht implementiert. Die Celery-Tasks für export_gltf/export_blend fehlen in `domains/rendering/tasks.py`. - -2. **K6: 3D-Viewer Production-Modus**: `ThreeDViewer.tsx` hat keinen Mode-Toggle, Wireframe, Env-Preset oder Download-Buttons. Für Testdaten wird `POST /api/cad/{id}/generate-gltf-geometry` benötigt (trimesh STL→GLB, kein Blender nötig). - -3. **L3: Worker-Management UI**: `WorkerManagement.tsx` fehlt. Backend braucht `/celery-workers` (Celery inspect) und `/scale` (docker compose subprocess). Backend-Container bekommt Docker-Socket-Mount. - -4. **M: QC-Tests**: `pytest` ist im Backend-Container nicht installiert. Dockerfile: `pip install -e ".[dev]"`. Neue Service-Tests für rendering und orders domains. 2 neue Vitest-Dateien. +**Ziel**: Alle implementierten Backend-Features im UI zugänglich machen + v3-Workflows vollständig verdrahten. --- @@ -18,348 +8,232 @@ Vier offene Bereiche aus dem PLAN.md müssen abgeschlossen werden: | Datei | Änderung | |-------|----------| -| `backend/app/domains/rendering/tasks.py` | 3 neue Tasks: `render_order_line_still_task`, `export_gltf_for_order_line_task`, `export_blend_for_order_line_task` | -| `backend/app/domains/rendering/workflow_builder.py` | Stubs ersetzen durch order-line-aware Tasks, `still_with_exports` hinzufügen | -| `backend/app/api/routers/cad.py` | `POST /{id}/generate-gltf-geometry` Endpoint | -| `backend/app/api/routers/worker.py` | `GET /celery-workers`, `POST /scale` Endpoints | -| `backend/Dockerfile` | `pip install -e ".[dev]"` | -| `docker-compose.yml` | Backend + Worker: Docker-Socket + Compose-File-Mount | -| `frontend/src/components/cad/ThreeDViewer.tsx` | Mode-Toggle, Wireframe, Env-Preset, Download-Buttons | -| `frontend/src/pages/WorkerManagement.tsx` | NEU: Worker-Liste, Queue-Stats, Scale-Button | -| `frontend/src/api/worker.ts` | Neue Interfaces + API-Funktionen | -| `frontend/src/App.tsx` | Route für /workers | -| `frontend/src/components/layout/Layout.tsx` | Sidebar-Link Workers | -| `backend/tests/domains/test_rendering_service.py` | NEU: ≥5 Tests für Rendering-Tasks und Workflow-Builder | -| `backend/tests/domains/test_orders_service.py` | NEU: ≥5 Tests für Orders-Endpoints | -| `frontend/src/__tests__/pages/WorkerActivity.test.tsx` | NEU: Vitest-Tests | -| `frontend/src/__tests__/pages/WorkerManagement.test.tsx` | NEU: Vitest-Tests | +| `frontend/src/components/layout/Layout.tsx` | Upload-Link hinzufügen | +| `frontend/src/pages/Admin.tsx` | OutputType-Tabelle: Workflow-Dropdown | +| `frontend/src/pages/AssetLibrary.tsx` | NEU: Asset Library Management UI | +| `frontend/src/api/asset_libraries.ts` | NEU: API-Client | +| `frontend/src/pages/ProductDetail.tsx` | Mesh-Attribute-Anzeige | +| `frontend/src/pages/Upload.tsx` | Sanity-Check-Dialog nach Import | +| `frontend/src/api/imports.ts` | NEU: import_validation API | +| `frontend/src/App.tsx` | Route /asset-libraries | +| `backend/app/api/routers/notification_configs.py` | NEU: notification_configs CRUD | +| `backend/app/main.py` | notification_configs router registrieren | +| `backend/app/api/routers/orders.py` | dispatch_renders → dispatch_render_with_workflow | +| `backend/app/api/routers/output_types.py` | workflow_definition_id im PATCH | +| `backend/app/schemas/output_type.py` | workflow_definition_id im Schema | +| `backend/app/domains/rendering/tasks.py` | K3: apply_asset_library_materials_task | +| `backend/app/tasks/step_tasks.py` | OCC sharp edge extraction in render_step_thumbnail | +| `render-worker/scripts/still_render.py` | mark_sharp / UV seams support | +| `render-worker/scripts/blender_render.py` | mark_sharp / UV seams support | +| `backend/app/services/step_processor.py` | extract_mesh_edge_data() für sharp edges | --- -## Tasks (in Reihenfolge) +## Tasks -### Task 1: Backend — Neue order-line-aware Rendering-Tasks -- **Datei**: `backend/app/domains/rendering/tasks.py` -- **Was**: Drei neue Celery-Tasks hinzufügen (UNTER den bestehenden Tasks): +### Task 1: Upload-Link in Sidebar [QUICK WIN] +- **Datei**: `frontend/src/components/layout/Layout.tsx` +- **Was**: `Upload`-Icon + NavLink zu `/upload` in der Sidebar für alle eingeloggten User +- **Akzeptanzkriterium**: Upload-Link sichtbar in Sidebar - **`render_order_line_still_task(order_line_id, **params)`** — Queue `thumbnail_rendering`: - - Lädt OrderLine + CadFile via sync SQLAlchemy (wie `publish_asset`) - - Setzt `render_status = 'processing'` - - Ruft `render_still()` aus `app.services.render_blender` auf - - Setzt `render_status = 'completed'`, speichert `render_log` - - Bei Fehler: `render_status = 'failed'` - - Returns dict mit `output_path` +### Task 2: notification_configs Backend-Router [Phase I] +- **Datei**: `backend/app/api/routers/notification_configs.py` (NEU), `backend/app/main.py` +- **Was**: REST-Endpoints für `notification_configs` Tabelle (044 bereits migriert): + - `GET /api/notification-configs` — gibt configs für aktuellen User zurück (mit Defaults falls keine Zeilen) + - `PUT /api/notification-configs/{event_type}/{channel}` — setzt enabled=true/false + - `POST /api/notification-configs/reset` — löscht alle configs des Users → Defaults gelten wieder + - Response: `[{event_type, channel, enabled}]` + - Auth: `get_current_user` (jeder kann seine eigenen Configs verwalten) +- **Akzeptanzkriterium**: NotificationSettings.tsx zeigt Toggle-Matrix und speichert korrekt - **`export_gltf_for_order_line_task(order_line_id)`** — Queue `thumbnail_rendering`: - - Lädt OrderLine + CadFile sync - - Sucht STL-Cache (`{step_stem}_low.stl`) - - Ruft Blender subprocess mit `export_gltf.py` auf: `blender --background --python export_gltf.py -- --stl_path X --output_path Y` - - Lädt GLB nach MinIO `production-exports/{cad_file_id}/{order_line_id}.glb` - - Erstellt `MediaAsset(asset_type=gltf_production, storage_key=...)` - - Returns `storage_key` - - **`export_blend_for_order_line_task(order_line_id)`** — Queue `thumbnail_rendering`: - - Analog zu export_gltf, aber mit `export_blend.py` - - MediaAsset type: `blend_production` - -- **Akzeptanzkriterium**: Tasks in `domains/rendering/tasks.py` vorhanden, keine Import-Fehler -- **Abhängigkeiten**: keine - -### Task 2: Backend — workflow_builder.py reparieren + still_with_exports -- **Datei**: `backend/app/domains/rendering/workflow_builder.py` +### Task 3: OutputType → WorkflowDefinition — Schema + API +- **Datei**: `backend/app/schemas/output_type.py`, `backend/app/api/routers/output_types.py` - **Was**: + - `OutputTypeOut` + `OutputTypePatch`: `workflow_definition_id: uuid.UUID | None` hinzufügen + - PATCH-Handler: `workflow_definition_id` setzen wenn in body + - `OutputTypeOut` soll `workflow_name: str | None` als convenience field enthalten +- **Akzeptanzkriterium**: `PATCH /api/output-types/{id}` mit `{"workflow_definition_id": "..."}` funktioniert - - `_build_still`: Nutzt `render_order_line_still_task` statt `render_still_task` - - `_build_turntable`: Bleibt vorerst mit `render_turntable_task` (file-path-basiert, funktioniert via legacy path) - - `_build_multi_angle`: Nutzt `render_order_line_still_task` mit `camera_angle` param - - **NEU** `_build_still_with_exports(order_line_id, params)`: - ```python - from celery import chain, group - 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), - ) - ) - ``` - - `dispatch_workflow()`: `"still_with_exports"` zu `builders` hinzufügen +### Task 4: Workflow-Dispatch Integration +- **Datei**: `backend/app/api/routers/orders.py` +- **Was**: In `dispatch_renders()` (Zeile 910): + - Statt `dispatch_order_line_render.delay(str(line.id))` aufrufen: + - `from app.domains.rendering.dispatch_service import dispatch_render_with_workflow` + - `dispatch_render_with_workflow(str(line.id))` aufrufen + - Das dispatch_service lädt OutputType.workflow_definition_id und nutzt Celery Canvas falls verknüpft; fällt auf Legacy zurück wenn nicht. +- **Akzeptanzkriterium**: Dispatch nutzt neuen Pfad; Legacy-Fallback bleibt erhalten -- **Akzeptanzkriterium**: `dispatch_workflow("still_with_exports", order_line_id)` löst keine Exception aus -- **Abhängigkeiten**: Task 1 - -### Task 3: Backend — generate-gltf-geometry Endpoint (Testdaten für K6) -- **Datei**: `backend/app/api/routers/cad.py` -- **Was**: Neuer Endpoint `POST /api/cad/{id}/generate-gltf-geometry` (require_admin_or_pm): - - Prüft ob CadFile existiert + STL-Cache vorhanden (`{step_dir}/{stem}_low.stl`) - - Queut neuen Celery-Task `generate_gltf_geometry_task.delay(str(cad_file.id))` - - Returns `{"task_id": ..., "message": "GLB generation queued"}` - - Neuer Task `generate_gltf_geometry_task` in `domains/rendering/tasks.py` (Queue `thumbnail_rendering`): - - Lädt CadFile sync, findet STL-Cache - - **Nutzt trimesh** (kein Blender): `import trimesh; mesh = trimesh.load(stl_path); mesh.export(glb_path)` - → Warum trimesh: Schnell, kein Blender nötig, läuft auf worker-Container (trimesh in pyproject.toml cad-extras) - - Lädt GLB nach MinIO `uploads/{cad_file_id}/geometry.glb` - - Erstellt/aktualisiert `MediaAsset(asset_type=gltf_geometry, storage_key=..., cad_file_id=...)` - → `MediaAsset` braucht `cad_file_id` FK — prüfen ob vorhanden - - **Wichtig**: Prüfen ob `media_assets.cad_file_id` existiert. Falls nicht: Migration 047 notwendig. - -- **Akzeptanzkriterium**: `POST /api/cad/{id}/generate-gltf-geometry` gibt 202 zurück, nach Task-Ausführung existiert MediaAsset mit type=gltf_geometry -- **Abhängigkeiten**: Task 1 - -### Task 4: Migration 047 — media_assets.cad_file_id (wenn nötig) -- **Datei**: `backend/alembic/versions/047_media_assets_cad_file_id.py` -- **Was**: Nullable FK `cad_file_id UUID REFERENCES cad_files(id) ON DELETE SET NULL` auf `media_assets` -- **Prüfen**: `grep -n "cad_file_id" backend/app/domains/media/models.py` — falls schon vorhanden: Task überspringen -- **Akzeptanzkriterium**: `alembic upgrade head` erfolgreich -- **Abhängigkeiten**: keine - -### Task 5: ThreeDViewer.tsx — Production-Modus, Wireframe, Env-Preset, Downloads -- **Datei**: `frontend/src/components/cad/ThreeDViewer.tsx` -- **Was**: Props erweitern + Toolbar-Erweiterung: - - ```typescript - interface ThreeDViewerProps { - cadFileId: string - onClose: () => void - productionGltfUrl?: string // wenn vorhanden: Mode-Toggle anzeigen - downloadUrls?: { glb?: string; blend?: string } - } - ``` - - **Neuer State:** - - `mode: 'geometry' | 'production'` (default: 'geometry') - - `wireframe: boolean` (default: false) - - `envPreset: 'city' | 'studio' | 'sunset'` (default: 'city') - - **Toolbar** (neu, rechts vom "Capture Angle"-Button): - - Mode-Toggle (nur wenn `productionGltfUrl` gesetzt): Button-Gruppe "Geometry | Production" - - Wireframe-Toggle: Button - - Env-Preset-Dropdown: `