diff --git a/LEARNINGS.md b/LEARNINGS.md index 909d8e1..8d17d38 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -7,6 +7,12 @@ ## Learnings +### 2026-03-14 | Blender | OBJ-Rotation Turntable: Reparenting von USD-Parts verschiebt Geometrie +USD-importierte Parts haben existierende Eltern-Objekte (Xform-Nodes). `part.parent = pivot; part.matrix_parent_inverse = pivot.matrix_world.inverted()` verliert den Beitrag des alten Parents zur Welt-Position → Parts verschieben sich ~14m. Fix: World-Matrix vor Reparenting sichern, dann via `part.matrix_local = pivot.inverted() @ saved_world` wiederherstellen. Zusätzlich `bpy.context.view_layer.update()` vor dem Parenting aufrufen, damit `pivot.matrix_world` aktuell ist. + +### 2026-03-13 | Blender/USD | USD-Import erzeugt leere Material-Stubs → schwarze Renders +Blender's `bpy.ops.wm.usd_import()` erstellt leere Material-Stubs (use_nodes=True, nodes=0) aus USD MaterialBinding-Referenzen. Wenn `apply_material_library_direct` dann die echten Materialien aus der .blend-Bibliothek laden will, findet es die Stubs bereits in `bpy.data.materials` und nutzt sie direkt — ohne die echten Shader-Node-Trees zu laden. Leere Materialien rendern in Cycles als pures Schwarz (RGB max=1). Fix: `_find_material_with_nodes()` Hilfsfunktion, die `.NNN`-Suffix-Kollisionen auflöst und nur Materialien mit echten Nodes zurückgibt. + ### 2026-03-12 | Caching | Composite Cache Keys für Tessellierung Hash-basiertes Caching in Celery Tasks muss alle relevanten Parameter einschließen, nicht nur den Datei-Hash. Bei `generate_gltf_geometry_task` und `generate_usd_master_task` wurde der Cache-Key auf `{hash}:{linear}:{angular}:{engine}` erweitert. Außerdem: immer Disk-Existenz des gecachten Assets prüfen (`storage_key.exists()`) bevor ein Cache-Hit zurückgegeben wird — der Asset-Record kann existieren, die Datei aber nicht. @@ -466,11 +472,7 @@ for obj in mesh_objects: --- ## Offene Fragen -- [ ] Azure AI Credentials für Phase 4 (Bildvalidierung) noch nicht konfiguriert -- [ ] pythonOCC verfügbar im render-worker (via cadquery dependency)? Deployment-Test ausstehend -- [ ] @xyflow/react noch nicht installiert — npm install nötig nach nächstem `docker compose up --build frontend` - [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab -- [ ] Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen ### 2026-03-11 | OCP/Python | id(solid.TShape()) ist nicht stabil In OCP (pybind11-basiert) gibt jeder Aufruf von `solid.TShape()` ein neues Python-Wrapper-Objekt zurück, das dieselbe C++ TShape-Instanz wrapet. `id()` gibt daher jedes Mal einen anderen Wert → Deduplizierung per `id()` schlägt immer fehl. **Lösung:** `solid.IsSame(other_solid)` verwenden (vergleicht TShape-Zeiger intern, liefert True für gleiche TShape mit unterschiedlicher Location/Orientation). @@ -492,3 +494,12 @@ GMSH 4.15.1 in render-worker installiert. `tessellation_engine=gmsh` ist der akt ### 2026-03-12 | Debugging | Stale GLB-Cache maskiert Code-Fixes Bug "Wälzkörper an falscher Position" war in Code durch commit 638b93b (IsSame-Fix) bereits behoben. Aber gecachtes Produktions-GLB (vor dem Fix generiert) zeigte weiterhin falsche Positionen im Viewer. Lösung: Geometry-GLB manuell neu generieren oder `step_file_hash = NULL` in DB um Cache-Invalidierung zu erzwingen. Nach Code-Fixes an Tessellierung/Export IMMER alle betroffenen GLB-Caches invalidieren. + +### 2026-03-13 | OCC/GLB | BRepBuilderAPI_Transform zerstört Tessellierung + RWGltf_CafWriter MergeFaces +**Problem**: GLB-Dateien hatten nur ~164 Vertices pro Bauteil statt ~7000. Zwei Ursachen: +1. `BRepBuilderAPI_Transform(shape, trsf, copy=True)` zerstört **alle** `Poly_Triangulation`-Daten. Die mm→m-Skalierung vor dem Export löschte die BRepMesh-Tessellierung. +2. `RWGltf_CafWriter` mit `MergeFaces=False` (Default) findet keine per-Face-Tessellierung aus der XCAF-Komponentenhierarchie und produziert degenerierte Meshes. +**Lösung**: +- `BRepBuilderAPI_Transform` komplett entfernt — `RWGltf_CafWriter` konvertiert intern mm→m und Z-up→Y-up. +- `writer.SetMergeFaces(True)` hinzugefügt — composited Face-Triangulationen zu korrekten Shape-Buffern (1.212 → 46.573 Vertices). +**Merke**: OCC `TopLoc_Location` kann keine Skalierung (wirft `Standard_DomainError`). Für Skalierung entweder den Writer intern konvertieren lassen oder GLB post-process. diff --git a/backend/Dockerfile b/backend/Dockerfile index f572e58..48f20e1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,8 +16,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libffi-dev \ && rm -rf /var/lib/apt/lists/* -# Copy docker CLI for worker scaling +# Copy docker CLI + compose plugin for worker scaling COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose # Install Python dependencies (dev + cad extras: pytest, trimesh, pygltflib) COPY pyproject.toml . diff --git a/backend/alembic/versions/4c15abf3cf40_add_focal_length_mm_and_sensor_width_mm_.py b/backend/alembic/versions/4c15abf3cf40_add_focal_length_mm_and_sensor_width_mm_.py new file mode 100644 index 0000000..d66613a --- /dev/null +++ b/backend/alembic/versions/4c15abf3cf40_add_focal_length_mm_and_sensor_width_mm_.py @@ -0,0 +1,36 @@ +"""add focal_length_mm and sensor_width_mm to render positions + +Revision ID: 4c15abf3cf40 +Revises: 6ebfe2737531 +Create Date: 2026-03-14 06:31:13.141830 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4c15abf3cf40' +down_revision: Union[str, None] = '6ebfe2737531' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('global_render_positions', sa.Column('focal_length_mm', sa.Float(), nullable=True)) + op.add_column('global_render_positions', sa.Column('sensor_width_mm', sa.Float(), nullable=True)) + op.add_column('product_render_positions', sa.Column('focal_length_mm', sa.Float(), nullable=True)) + op.add_column('product_render_positions', sa.Column('sensor_width_mm', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('product_render_positions', 'sensor_width_mm') + op.drop_column('product_render_positions', 'focal_length_mm') + op.drop_column('global_render_positions', 'sensor_width_mm') + op.drop_column('global_render_positions', 'focal_length_mm') + # ### end Alembic commands ### diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index c89717f..a801e1c 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -825,3 +825,60 @@ async def import_existing_media_assets( await db.commit() return {"created": created, "skipped": skipped} + +@router.delete("/settings/purge-render-media", status_code=status.HTTP_200_OK) +async def purge_render_media( + admin: User = Depends(require_global_admin), + db: AsyncSession = Depends(get_db), +): + """Delete all still and turntable MediaAsset records and their backing files. + + This removes rendered images and animations but leaves thumbnails, GLBs, + STLs, and USD masters intact. + """ + import logging + from pathlib import Path + from app.config import settings + from app.core.storage import get_storage + from app.domains.media.models import MediaAsset, MediaAssetType + + logger = logging.getLogger(__name__) + storage = get_storage() + + result = await db.execute( + select(MediaAsset).where( + MediaAsset.asset_type.in_([MediaAssetType.still, MediaAssetType.turntable]) + ) + ) + assets = result.scalars().all() + + deleted_db = 0 + deleted_files = 0 + freed_bytes = 0 + + for asset in assets: + # Delete backing file + key = asset.storage_key + try: + candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key + if candidate.exists(): + freed_bytes += candidate.stat().st_size + candidate.unlink() + deleted_files += 1 + elif hasattr(storage, 'delete'): + storage.delete(key) + deleted_files += 1 + except Exception as exc: + logger.warning("Could not delete file for asset %s (%s): %s", asset.id, key, exc) + + await db.delete(asset) + deleted_db += 1 + + await db.commit() + return { + "deleted_records": deleted_db, + "deleted_files": deleted_files, + "freed_mb": round(freed_bytes / 1024 / 1024, 1), + "message": f"Purged {deleted_db} still/turntable asset(s), freed {round(freed_bytes / 1024 / 1024, 1)} MB", + } + diff --git a/backend/app/api/routers/cad.py b/backend/app/api/routers/cad.py index 06ee2f7..c248689 100644 --- a/backend/app/api/routers/cad.py +++ b/backend/app/api/routers/cad.py @@ -313,7 +313,7 @@ async def regenerate_thumbnail( db: AsyncSession = Depends(get_db), ): """Queue a Celery task to reprocess the STEP file and regenerate its thumbnail.""" - if user.role.value != "admin": + if user.role.value not in ("admin", "global_admin", "tenant_admin"): raise HTTPException( status_code=403, detail="Only admins can trigger thumbnail regeneration", diff --git a/backend/app/api/routers/materials.py b/backend/app/api/routers/materials.py index 2246aaa..8adcc49 100644 --- a/backend/app/api/routers/materials.py +++ b/backend/app/api/routers/materials.py @@ -174,6 +174,62 @@ async def seed_aliases( return {"inserted": inserted, "total": total} +class BatchAliasMapping(BaseModel): + alias: str + material_id: uuid.UUID + + +class BatchAliasCreate(BaseModel): + mappings: list[BatchAliasMapping] + + +@router.post("/batch-aliases") +async def batch_create_aliases( + body: BatchAliasCreate, + user: User = Depends(require_admin_or_pm), + db: AsyncSession = Depends(get_db), +): + """Create multiple material aliases in one request. + + Skips aliases that already exist (case-insensitive). Validates that + each material_id exists. + """ + created = 0 + skipped = 0 + + for mapping in body.mappings: + alias_str = mapping.alias.strip() + if not alias_str: + skipped += 1 + continue + + # Verify material exists + mat_result = await db.execute( + select(Material).where(Material.id == mapping.material_id) + ) + if not mat_result.scalar_one_or_none(): + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Material {mapping.material_id} not found", + ) + + # Check if alias already exists (case-insensitive) + existing = await db.execute( + select(MaterialAlias).where( + func.lower(MaterialAlias.alias) == alias_str.lower() + ) + ) + if existing.scalar_one_or_none(): + skipped += 1 + continue + + db.add(MaterialAlias(material_id=mapping.material_id, alias=alias_str)) + created += 1 + + await db.commit() + return {"created": created, "skipped": skipped} + + @router.delete("/aliases/{alias_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_alias( alias_id: uuid.UUID, diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index fe11f1a..042e6a9 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -865,6 +865,50 @@ async def add_order_line( return _build_line_out(line_loaded) +@router.get("/{order_id}/check-materials") +async def check_materials( + order_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Check if all materials in this order's products are mapped to library materials.""" + from app.domains.materials.service import find_unmapped_materials + + result = await db.execute(select(Order).where(Order.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(404, detail="Order not found") + + lines_result = await db.execute( + select(OrderLine) + .options(selectinload(OrderLine.product)) + .where(OrderLine.order_id == order_id) + ) + lines = lines_result.scalars().all() + + # Collect all unique material names from all products + all_material_names: list[str] = [] + seen: set[str] = set() + for line in lines: + if not line.product or not line.product.cad_part_materials: + continue + for entry in line.product.cad_part_materials: + mat_name = entry.get("material", "") + if mat_name and mat_name.lower() not in seen: + seen.add(mat_name.lower()) + all_material_names.append(mat_name) + + unmapped = await find_unmapped_materials(all_material_names, db) + total = len(all_material_names) + mapped = total - len(unmapped) + + return { + "unmapped": unmapped, + "total_materials": total, + "mapped_count": mapped, + } + + @router.post("/{order_id}/dispatch-renders") async def dispatch_renders( order_id: uuid.UUID, @@ -1000,6 +1044,58 @@ async def cancel_line_render( } +@router.post("/{order_id}/lines/{line_id}/dispatch-render") +async def dispatch_single_line_render( + order_id: uuid.UUID, + line_id: uuid.UUID, + user: User = Depends(require_admin_or_pm), + db: AsyncSession = Depends(get_db), +): + """Dispatch (or retry) a render for a single order line (admin/PM only).""" + result = await db.execute(select(Order).where(Order.id == order_id)) + order = result.scalar_one_or_none() + if not order: + raise HTTPException(404, detail="Order not found") + + line_result = await db.execute( + select(OrderLine).where(OrderLine.id == line_id, OrderLine.order_id == order.id) + ) + line = line_result.scalar_one_or_none() + if not line: + raise HTTPException(404, detail="Order line not found") + + if line.render_status not in ("pending", "failed", "cancelled"): + raise HTTPException(400, detail=f"Cannot dispatch line in {line.render_status} status") + + # Reset to pending + from sqlalchemy import update as sql_update + await db.execute( + sql_update(OrderLine) + .where(OrderLine.id == line.id) + .values(render_status="pending", render_completed_at=None, render_log=None) + ) + + # Auto-advance order to processing if needed + if order.status in (OrderStatus.submitted, OrderStatus.completed): + now = datetime.utcnow() + order.status = OrderStatus.processing + order.processing_started_at = now + order.completed_at = None + order.updated_at = now + + await db.commit() + + from app.domains.rendering.dispatch_service import dispatch_render_with_workflow + try: + dispatch_render_with_workflow(str(line.id)) + except Exception as exc: + logger.warning("dispatch_render_with_workflow failed for %s: %s", line.id, exc) + from app.tasks.step_tasks import dispatch_order_line_render + dispatch_order_line_render.delay(str(line.id)) + + return {"dispatched": True, "line_id": str(line.id)} + + class RejectLineBody(BaseModel): reason: str = "" diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index bc7776c..d290a29 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -270,12 +270,73 @@ async def delete_product( raise HTTPException(404, detail="Product not found") if hard: from sqlalchemy import delete as sql_delete - # Delete order_lines referencing this product + from app.domains.media.models import MediaAsset + from app.core.storage import get_storage + + # 1. Collect storage keys from MediaAssets before cascade deletes them + media_result = await db.execute( + select(MediaAsset.storage_key).where(MediaAsset.product_id == product_id) + ) + storage_keys = [row[0] for row in media_result.all() if row[0]] + + # 2. Collect render result paths from order lines + ol_result = await db.execute( + select(OrderLine.result_path).where( + OrderLine.product_id == product_id, + OrderLine.result_path.isnot(None), + ) + ) + result_paths = [row[0] for row in ol_result.all() if row[0]] + + # 3. Check if CadFile is used by other products + cad_file_id = product.cad_file_id + orphan_cad = False + if cad_file_id: + other_count = await db.execute( + select(func.count(Product.id)).where( + Product.cad_file_id == cad_file_id, + Product.id != product_id, + ) + ) + orphan_cad = (other_count.scalar() or 0) == 0 + + # 4. Delete order_lines referencing this product await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == product_id)) + + # 5. Delete orphaned CadFile if no other products reference it + if orphan_cad and cad_file_id: + from app.models.cad_file import CadFile + # Collect CadFile media assets too + cad_media_result = await db.execute( + select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id) + ) + storage_keys.extend(row[0] for row in cad_media_result.all() if row[0]) + product.cad_file_id = None + await db.flush() + await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id)) + + # 6. Delete product (cascades MediaAsset + ProductRenderPosition) await db.delete(product) + await db.commit() + + # 7. Clean up storage files (best-effort, after commit) + storage = get_storage() + for key in storage_keys: + try: + storage.delete(key) + except Exception: + pass + # Clean up render result files on disk + import os + for path in result_paths: + try: + if os.path.isfile(path): + os.unlink(path) + except Exception: + pass else: product.is_active = False - await db.commit() + await db.commit() @router.post("/{product_id}/cad", status_code=status.HTTP_201_CREATED) diff --git a/backend/app/api/routers/render_templates.py b/backend/app/api/routers/render_templates.py index 79f7edc..e40e7c2 100644 --- a/backend/app/api/routers/render_templates.py +++ b/backend/app/api/routers/render_templates.py @@ -116,9 +116,11 @@ async def list_render_templates( @router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED) async def create_render_template( name: str = Form(...), - file: UploadFile = File(...), + file: UploadFile | None = File(None), + clone_blend_from: str | None = Form(None), category_key: str | None = Form(None), output_type_id: str | None = Form(None), + output_type_ids: str | None = Form(None), target_collection: str = Form("Product"), material_replace_enabled: bool = Form(False), lighting_only: bool = Form(False), @@ -127,30 +129,54 @@ async def create_render_template( user: User = Depends(require_admin_or_pm), db: AsyncSession = Depends(get_db), ): - if not file.filename or not file.filename.endswith(".blend"): - raise HTTPException(400, detail="File must be a .blend file") - # Normalise empty strings from form data to None if category_key == "" or category_key == "null": category_key = None if output_type_id == "" or output_type_id == "null": output_type_id = None + if clone_blend_from == "" or clone_blend_from == "null": + clone_blend_from = None template_id = uuid.uuid4() blend_path = _blend_dir() / f"{template_id}.blend" - with open(blend_path, "wb") as f: - shutil.copyfileobj(file.file, f) + if file and file.filename: + if not file.filename.endswith(".blend"): + raise HTTPException(400, detail="File must be a .blend file") + with open(blend_path, "wb") as f: + shutil.copyfileobj(file.file, f) + original_filename = file.filename + final_blend_path = str(blend_path) + elif clone_blend_from: + # Share the same .blend file (no copy — just reference the same path) + source = await db.execute( + select(RenderTemplate).where(RenderTemplate.id == uuid.UUID(clone_blend_from)) + ) + source_tmpl = source.unique().scalar_one_or_none() + if not source_tmpl: + raise HTTPException(404, detail="Source template not found") + source_path = Path(source_tmpl.blend_file_path) + if not source_path.exists(): + raise HTTPException(404, detail="Source .blend file not found on disk") + final_blend_path = source_tmpl.blend_file_path + original_filename = source_tmpl.original_filename + else: + raise HTTPException(400, detail="Provide either a .blend file or clone_blend_from template ID") ot_uuid = uuid.UUID(output_type_id) if output_type_id else None + # Parse M2M output_type_ids (comma-separated string from FormData) + m2m_ot_ids: list[str] = [] + if output_type_ids and output_type_ids.strip(): + m2m_ot_ids = [s.strip() for s in output_type_ids.split(",") if s.strip()] + tmpl = RenderTemplate( id=template_id, name=name, category_key=category_key, output_type_id=ot_uuid, - blend_file_path=str(blend_path), - original_filename=file.filename, + blend_file_path=final_blend_path, + original_filename=original_filename, target_collection=target_collection, material_replace_enabled=material_replace_enabled, lighting_only=lighting_only, @@ -160,12 +186,13 @@ async def create_render_template( 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 + # Sync M2M output types + from app.domains.rendering.models import render_template_output_types + ot_ids_to_link = m2m_ot_ids if m2m_ot_ids else ([str(ot_uuid)] if ot_uuid else []) + for ot_id_str in ot_ids_to_link: await db.execute( render_template_output_types.insert().values( - template_id=template_id, output_type_id=ot_uuid, + template_id=template_id, output_type_id=uuid.UUID(ot_id_str), ) ) @@ -250,9 +277,15 @@ async def delete_render_template( if not tmpl: raise HTTPException(404, detail="Render template not found") - # Delete .blend file + # Only delete .blend file if no other template shares it blend_path = Path(tmpl.blend_file_path) - if blend_path.exists(): + other_refs = await db.execute( + select(RenderTemplate.id).where( + RenderTemplate.blend_file_path == tmpl.blend_file_path, + RenderTemplate.id != template_id, + ) + ) + if not other_refs.first() and blend_path.exists(): blend_path.unlink(missing_ok=True) await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id)) @@ -277,10 +310,17 @@ async def upload_blend_file( blend_path = _blend_dir() / f"{template_id}.blend" - # Remove old file if path changed + # Only remove old file if no other template shares it old_path = Path(tmpl.blend_file_path) if old_path.exists() and old_path != blend_path: - old_path.unlink(missing_ok=True) + other_refs = await db.execute( + select(RenderTemplate.id).where( + RenderTemplate.blend_file_path == tmpl.blend_file_path, + RenderTemplate.id != template_id, + ) + ) + if not other_refs.first(): + old_path.unlink(missing_ok=True) with open(blend_path, "wb") as f: shutil.copyfileobj(file.file, f) diff --git a/backend/app/api/routers/worker.py b/backend/app/api/routers/worker.py index 445151a..bec0910 100644 --- a/backend/app/api/routers/worker.py +++ b/backend/app/api/routers/worker.py @@ -429,14 +429,20 @@ async def scale_workers( compose_dir = os.environ.get("COMPOSE_PROJECT_DIR", "/compose") compose_file = os.path.join(compose_dir, "docker-compose.yml") + # Derive project name from compose dir on host (directory name = project name). + # Inside the container the compose file is at /compose, but the host project + # dir name determines the container naming prefix (e.g. "schaefflerautomat"). + compose_project = os.environ.get("COMPOSE_PROJECT_NAME", "schaefflerautomat") def _scale() -> subprocess.CompletedProcess: return subprocess.run( [ "docker", "compose", "-f", compose_file, + "-p", compose_project, "up", "--scale", f"{body.service}={body.count}", + "--no-build", "--no-recreate", "-d", ], diff --git a/backend/app/domains/materials/service.py b/backend/app/domains/materials/service.py index c7ec3aa..48d9f75 100644 --- a/backend/app/domains/materials/service.py +++ b/backend/app/domains/materials/service.py @@ -9,6 +9,7 @@ Resolution chain: 3. Pass through unchanged → Blender will show FailedMaterial magenta """ import logging +from difflib import SequenceMatcher from sqlalchemy import create_engine, select, func from sqlalchemy.orm import Session, selectinload @@ -138,3 +139,66 @@ async def seed_material_aliases_from_mappings( await db.flush() return {"created": created, "skipped": skipped} + + +async def find_unmapped_materials( + material_names: list[str], db: AsyncSession +) -> list[dict]: + """Find material names that have no alias or library match. + + Returns a list of {"raw_name": str, "suggestions": [...]} for each + unmapped name. Suggestions are the top 5 SCHAEFFLER library materials + by string similarity. + """ + if not material_names: + return [] + + # Load all aliases (case-insensitive lookup) + alias_rows = (await db.execute(select(MaterialAlias))).scalars().all() + alias_set: set[str] = {a.alias.lower() for a in alias_rows} + + # Load all materials + mat_rows = (await db.execute(select(Material))).scalars().all() + # Library materials have a schaeffler_code + library_mats = [m for m in mat_rows if m.schaeffler_code is not None] + # All material names (case-insensitive) for exact-match check + name_lookup: dict[str, Material] = {m.name.lower(): m for m in mat_rows} + + unmapped: list[dict] = [] + seen: set[str] = set() + + for raw_name in material_names: + raw_lower = raw_name.lower() + if raw_lower in seen: + continue + seen.add(raw_lower) + + # 1. Alias match → mapped + if raw_lower in alias_set: + continue + + # 2. Exact name match with a library material → mapped + matched_mat = name_lookup.get(raw_lower) + if matched_mat and matched_mat.schaeffler_code is not None: + continue + + # Unmapped — compute suggestions from library materials + scored = [] + for lib_mat in library_mats: + ratio = SequenceMatcher(None, raw_lower, lib_mat.name.lower()).ratio() + if ratio > 0.3: + scored.append((ratio, lib_mat)) + scored.sort(key=lambda x: x[0], reverse=True) + + suggestions = [ + { + "id": str(m.id), + "name": m.name, + "schaeffler_code": str(m.schaeffler_code), + } + for _, m in scored[:5] + ] + + unmapped.append({"raw_name": raw_name, "suggestions": suggestions}) + + return unmapped diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py index 1d65fe7..14dec35 100644 --- a/backend/app/domains/media/router.py +++ b/backend/app/domains/media/router.py @@ -432,6 +432,24 @@ async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends return {"ok": True} +@router.post("/batch-delete") +async def batch_delete_assets( + asset_ids: list[uuid.UUID], + _user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Permanently delete multiple MediaAsset records.""" + from app.utils.auth import require_global_admin + require_global_admin(_user) + + deleted = 0 + for aid in asset_ids: + ok = await service.delete_media_asset(db, aid) + if ok: + deleted += 1 + return {"deleted": deleted, "requested": len(asset_ids)} + + @router.post("/cleanup-orphaned") async def cleanup_orphaned_assets( _user: User = Depends(get_current_user), diff --git a/backend/app/domains/pipeline/tasks/export_glb.py b/backend/app/domains/pipeline/tasks/export_glb.py index 8e5dd34..3094d7f 100644 --- a/backend/app/domains/pipeline/tasks/export_glb.py +++ b/backend/app/domains/pipeline/tasks/export_glb.py @@ -95,9 +95,9 @@ def generate_gltf_geometry_task(self, cad_file_id: str): _cache_hit_asset_id = None # Composite cache key includes deflection settings so changing them invalidates cache - # v2: tessellation now happens after mm→m scaling (fixes destroyed tessellation) + # v3: removed BRepBuilderAPI_Transform, writer handles mm→m from STEP unit metadata effective_cache_key = ( - f"v2:{_current_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}" + f"v3:{_current_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}" if _current_hash else None ) diff --git a/backend/app/domains/pipeline/tasks/render_order_line.py b/backend/app/domains/pipeline/tasks/render_order_line.py index d8e9adb..f24e907 100644 --- a/backend/app/domains/pipeline/tasks/render_order_line.py +++ b/backend/app/domains/pipeline/tasks/render_order_line.py @@ -208,18 +208,26 @@ def render_order_line_task(self, order_line_id: str): cad_name = cad_file.original_name if cad_file else "?" # Load render_position for rotation values (per-product takes priority, falls back to global) rotation_x = rotation_y = rotation_z = 0.0 + focal_length_mm = None + sensor_width_mm = None if line.render_position_id: from app.models.render_position import ProductRenderPosition rp = session.get(ProductRenderPosition, line.render_position_id) if rp: rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z - emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)") + focal_length_mm = rp.focal_length_mm + sensor_width_mm = rp.sensor_width_mm + emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" + + (f" focal_length={focal_length_mm}mm" if focal_length_mm else "")) elif line.global_render_position_id: from app.models import GlobalRenderPosition grp = session.get(GlobalRenderPosition, line.global_render_position_id) if grp: rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z - emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)") + focal_length_mm = grp.focal_length_mm + sensor_width_mm = grp.sensor_width_mm + emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" + + (f" focal_length={focal_length_mm}mm" if focal_length_mm else "")) emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)") @@ -334,7 +342,10 @@ def render_order_line_task(self, order_line_id: str): rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z, + camera_orbit=bool(template.camera_orbit) if template else True, usd_path=usd_render_path, + focal_length_mm=focal_length_mm, + sensor_width_mm=sensor_width_mm, ) success = True render_log = { @@ -391,6 +402,8 @@ def render_order_line_task(self, order_line_id: str): rotation_x=rotation_x, rotation_y=rotation_y, rotation_z=rotation_z, + focal_length_mm=focal_length_mm, + sensor_width_mm=sensor_width_mm, job_id=order_line_id, order_line_id=order_line_id, noise_threshold=noise_threshold, diff --git a/backend/app/domains/rendering/models.py b/backend/app/domains/rendering/models.py index 252a72b..87f782b 100644 --- a/backend/app/domains/rendering/models.py +++ b/backend/app/domains/rendering/models.py @@ -94,6 +94,8 @@ class ProductRenderPosition(Base): rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) + sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False @@ -113,6 +115,8 @@ class GlobalRenderPosition(Base): rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) + sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py index c014502..a2a57aa 100644 --- a/backend/app/domains/rendering/schemas.py +++ b/backend/app/domains/rendering/schemas.py @@ -68,6 +68,8 @@ class RenderPositionCreate(BaseModel): rotation_z: float = 0.0 is_default: bool = False sort_order: int = 0 + focal_length_mm: float | None = None + sensor_width_mm: float | None = None class RenderPositionPatch(BaseModel): @@ -77,6 +79,8 @@ class RenderPositionPatch(BaseModel): rotation_z: float | None = None is_default: bool | None = None sort_order: int | None = None + focal_length_mm: float | None = None + sensor_width_mm: float | None = None class RenderPositionOut(BaseModel): @@ -88,6 +92,8 @@ class RenderPositionOut(BaseModel): rotation_z: float is_default: bool sort_order: int + focal_length_mm: float | None = None + sensor_width_mm: float | None = None created_at: datetime updated_at: datetime @@ -101,6 +107,8 @@ class GlobalRenderPositionCreate(BaseModel): rotation_z: float = 0.0 is_default: bool = False sort_order: int = 0 + focal_length_mm: float | None = None + sensor_width_mm: float | None = None class GlobalRenderPositionPatch(BaseModel): @@ -110,6 +118,8 @@ class GlobalRenderPositionPatch(BaseModel): rotation_z: float | None = None is_default: bool | None = None sort_order: int | None = None + focal_length_mm: float | None = None + sensor_width_mm: float | None = None class GlobalRenderPositionOut(BaseModel): @@ -120,6 +130,8 @@ class GlobalRenderPositionOut(BaseModel): rotation_z: float is_default: bool sort_order: int + focal_length_mm: float | None = None + sensor_width_mm: float | None = None created_at: datetime updated_at: datetime diff --git a/backend/app/services/material_service.py b/backend/app/services/material_service.py index 9626a10..77324c1 100644 --- a/backend/app/services/material_service.py +++ b/backend/app/services/material_service.py @@ -1,3 +1,3 @@ # Compat shim — use app.domains.materials.service instead -from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings -__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings"] +from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings, find_unmapped_materials +__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings", "find_unmapped_materials"] diff --git a/backend/app/services/render_blender.py b/backend/app/services/render_blender.py index db946fa..74232af 100644 --- a/backend/app/services/render_blender.py +++ b/backend/app/services/render_blender.py @@ -92,6 +92,8 @@ def render_still( log_callback: "Callable[[str], None] | None" = None, usd_path: "Path | None" = None, tessellation_engine: str = "occ", + focal_length_mm: float | None = None, + sensor_width_mm: float | None = None, ) -> dict: """Convert STEP → GLB (OCC or GMSH) → PNG (Blender subprocess). @@ -179,6 +181,10 @@ def render_still( logger.debug("[render_blender] usd_path active — mesh_attributes ignored") elif mesh_attributes: cmd += ["--mesh-attributes", json.dumps(mesh_attributes)] + if focal_length_mm is not None: + cmd += ["--focal-length", str(focal_length_mm)] + if sensor_width_mm is not None: + cmd += ["--sensor-width", str(sensor_width_mm)] return cmd def _run(eng: str) -> tuple[int, list[str], list[str]]: @@ -311,8 +317,11 @@ def render_turntable_to_file( rotation_x: float = 0.0, rotation_y: float = 0.0, rotation_z: float = 0.0, + camera_orbit: bool = True, usd_path: "Path | None" = None, tessellation_engine: str = "occ", + focal_length_mm: float | None = None, + sensor_width_mm: float | None = None, ) -> dict: """Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg). @@ -391,8 +400,14 @@ def render_turntable_to_file( bg_color or "", "1" if transparent_bg else "0", ] + if camera_orbit: + cmd += ["--camera-orbit"] if use_usd: cmd += ["--usd-path", str(usd_path)] + if focal_length_mm is not None: + cmd += ["--focal-length", str(focal_length_mm)] + if sensor_width_mm is not None: + cmd += ["--sensor-width", str(sensor_width_mm)] log_lines: list[str] = [] diff --git a/backend/app/services/step_processor.py b/backend/app/services/step_processor.py index 1369d3f..70479b9 100644 --- a/backend/app/services/step_processor.py +++ b/backend/app/services/step_processor.py @@ -891,6 +891,8 @@ def render_to_file( order_line_id: str | None = None, usd_path: "Path | None" = None, tessellation_engine: str | None = None, + focal_length_mm: float | None = None, + sensor_width_mm: float | None = None, ) -> tuple[bool, dict]: """Render a STEP file to a specific output path using current system settings. @@ -1027,6 +1029,8 @@ def render_to_file( log_callback=_log_cb, usd_path=usd_path, tessellation_engine=tessellation_engine or settings["tessellation_engine"], + focal_length_mm=focal_length_mm, + sensor_width_mm=sensor_width_mm, ) rendered_png = tmp_png if tmp_png.exists() else None except Exception as exc: diff --git a/frontend/src/api/materials.ts b/frontend/src/api/materials.ts index d883cff..aceaafc 100644 --- a/frontend/src/api/materials.ts +++ b/frontend/src/api/materials.ts @@ -87,3 +87,37 @@ export async function seedAliases(): Promise<{ inserted: number; total: number } const res = await api.post<{ inserted: number; total: number }>('/materials/seed-aliases') return res.data } + +// --- Material check / batch alias --- + +export interface MaterialSuggestion { + id: string + name: string + schaeffler_code: string +} + +export interface UnmappedMaterial { + raw_name: string + suggestions: MaterialSuggestion[] +} + +export interface UnmappedMaterialCheck { + unmapped: UnmappedMaterial[] + total_materials: number + mapped_count: number +} + +export async function checkOrderMaterials(orderId: string): Promise { + const res = await api.get(`/orders/${orderId}/check-materials`) + return res.data +} + +export async function batchCreateAliases( + mappings: Array<{ alias: string; material_id: string }> +): Promise<{ created: number; skipped: number }> { + const res = await api.post<{ created: number; skipped: number }>( + '/materials/batch-aliases', + { mappings } + ) + return res.data +} diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index ca7609c..c14aa52 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -133,3 +133,6 @@ export const archiveMediaAsset = (id: string): Promise => export const deleteMediaAssetPermanent = (id: string): Promise => api.delete(`/media/${id}/permanent`).then(() => undefined) + +export const batchDeleteAssets = (ids: string[]): Promise<{ deleted: number; requested: number }> => + api.post('/media/batch-delete', ids).then(r => r.data) diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 9a4820b..8195229 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -235,6 +235,13 @@ export async function cancelLineRender(orderId: string, lineId: string) { return res.data } +export async function dispatchLineRender(orderId: string, lineId: string) { + const res = await api.post<{ dispatched: boolean; line_id: string }>( + `/orders/${orderId}/lines/${lineId}/dispatch-render` + ) + return res.data +} + export async function cancelOrderRenders(orderId: string) { const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>( `/orders/${orderId}/cancel-renders` diff --git a/frontend/src/api/renderPositions.ts b/frontend/src/api/renderPositions.ts index fe7ae88..733b64d 100644 --- a/frontend/src/api/renderPositions.ts +++ b/frontend/src/api/renderPositions.ts @@ -8,6 +8,8 @@ export interface GlobalRenderPosition { rotation_z: number is_default: boolean sort_order: number + focal_length_mm: number | null + sensor_width_mm: number | null created_at: string updated_at: string } @@ -19,6 +21,8 @@ export interface GlobalRenderPositionCreate { rotation_z?: number is_default?: boolean sort_order?: number + focal_length_mm?: number | null + sensor_width_mm?: number | null } export interface GlobalRenderPositionPatch { @@ -28,6 +32,8 @@ export interface GlobalRenderPositionPatch { rotation_z?: number is_default?: boolean sort_order?: number + focal_length_mm?: number | null + sensor_width_mm?: number | null } export async function listGlobalRenderPositions(): Promise { diff --git a/frontend/src/api/renderTemplates.ts b/frontend/src/api/renderTemplates.ts index ab948ad..9031aa7 100644 --- a/frontend/src/api/renderTemplates.ts +++ b/frontend/src/api/renderTemplates.ts @@ -39,6 +39,26 @@ export async function createRenderTemplate(formData: FormData): Promise> & { output_type_ids?: string[] }, +): Promise { + const fd = new FormData(); + fd.append('name', overrides.name || 'Untitled (copy)'); + fd.append('clone_blend_from', sourceId); + fd.append('category_key', overrides.category_key || ''); + fd.append('output_type_ids', (overrides.output_type_ids || []).join(',')); + fd.append('target_collection', overrides.target_collection || 'Product'); + fd.append('material_replace_enabled', String(overrides.material_replace_enabled ?? false)); + fd.append('lighting_only', String(overrides.lighting_only ?? false)); + fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false)); + fd.append('camera_orbit', String(overrides.camera_orbit ?? true)); + const { data } = await api.post('/render-templates', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; +} + export async function updateRenderTemplate( id: string, updates: Partial>, diff --git a/frontend/src/api/worker.ts b/frontend/src/api/worker.ts index b11314e..0363e04 100644 --- a/frontend/src/api/worker.ts +++ b/frontend/src/api/worker.ts @@ -146,7 +146,7 @@ export interface CeleryWorkersResponse { } export interface ScaleRequest { - service: 'render-worker' | 'worker' | 'worker-thumbnail' + service: 'render-worker' | 'worker' count: number } diff --git a/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx b/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx index 6fefbda..1b12030 100644 --- a/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx +++ b/frontend/src/components/admin/GlobalRenderPositionsPanel.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Pencil, Trash2, Check, X } from 'lucide-react' +import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react' import { listGlobalRenderPositions, createGlobalRenderPosition, @@ -18,6 +18,7 @@ interface EditState { rotation_z: number is_default: boolean sort_order: number + focal_length_mm: number | null } const EMPTY_EDIT: EditState = { @@ -28,6 +29,7 @@ const EMPTY_EDIT: EditState = { rotation_z: 0, is_default: false, sort_order: 0, + focal_length_mm: null, } export default function GlobalRenderPositionsPanel() { @@ -66,6 +68,7 @@ export default function GlobalRenderPositionsPanel() { rotation_z: pos.rotation_z, is_default: pos.is_default, sort_order: pos.sort_order, + focal_length_mm: pos.focal_length_mm, }) } @@ -129,6 +132,7 @@ export default function GlobalRenderPositionsPanel() { Rot X° Rot Y° Rot Z° + Focal mm Default Order @@ -151,6 +155,16 @@ export default function GlobalRenderPositionsPanel() { {rotField('', 'rotation_x')} {rotField('', 'rotation_y')} {rotField('', 'rotation_z')} + + setEditing({ ...editing!, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })} + /> + {pos.rotation_x} {pos.rotation_y} {pos.rotation_z} + + {pos.focal_length_mm != null ? pos.focal_length_mm : 50} + {pos.is_default && } {pos.sort_order} + + + + + + + + ) +} diff --git a/frontend/src/components/shared/ImageLightbox.tsx b/frontend/src/components/shared/ImageLightbox.tsx new file mode 100644 index 0000000..afdd5cd --- /dev/null +++ b/frontend/src/components/shared/ImageLightbox.tsx @@ -0,0 +1,107 @@ +import { useEffect, useCallback } from 'react' +import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react' + +export interface LightboxItem { + url: string + label?: string +} + +interface Props { + items: LightboxItem[] + index: number + onClose: () => void + onIndexChange: (i: number) => void +} + +export default function ImageLightbox({ items, index, onClose, onIndexChange }: Props) { + const item = items[index] + const hasPrev = index > 0 + const hasNext = index < items.length - 1 + + const prev = useCallback(() => { if (hasPrev) onIndexChange(index - 1) }, [hasPrev, index, onIndexChange]) + const next = useCallback(() => { if (hasNext) onIndexChange(index + 1) }, [hasNext, index, onIndexChange]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + else if (e.key === 'ArrowLeft') prev() + else if (e.key === 'ArrowRight') next() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose, prev, next]) + + // Prevent body scroll while open + useEffect(() => { + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = '' } + }, []) + + if (!item) return null + + return ( +
+ {/* Close */} + + + {/* Counter + label */} +
+ {index + 1} / {items.length} + {item.label && {item.label}} +
+ + {/* Download */} + e.stopPropagation()} + title="Download" + > + + + + {/* Prev arrow */} + {hasPrev && ( + + )} + + {/* Next arrow */} + {hasNext && ( + + )} + + {/* Image */} + {item.label e.stopPropagation()} + draggable={false} + /> +
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index fdebe60..ed8f826 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -243,6 +243,12 @@ export default function AdminPage() { onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) + const purgeRenderMediaMut = useMutation({ + mutationFn: () => api.delete('/admin/settings/purge-render-media'), + onSuccess: (res) => toast.success(res.data.message || 'Render media purged'), + onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), + }) + const [smtpDraft, setSmtpDraft] = useState>({}) const smtp = { ...settings, ...smtpDraft } as Settings @@ -1017,6 +1023,23 @@ export default function AdminPage() {

Removes STEP files, thumbnails, and DB records not linked to any product.

+
+ +

Deletes all rendered images and animations. Thumbnails, GLBs, and USD files are preserved.

+
+ +
+ ) : ( + + )} + + )}

{mat.description || '—'}

diff --git a/frontend/src/pages/MediaBrowser.tsx b/frontend/src/pages/MediaBrowser.tsx index 5d9dbcd..58b71c8 100644 --- a/frontend/src/pages/MediaBrowser.tsx +++ b/frontend/src/pages/MediaBrowser.tsx @@ -1,14 +1,17 @@ import { useState, useEffect, useRef } from 'react' -import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { Search, Image, Film, Box, Layers, FileCode2, ChevronLeft, ChevronRight, Download, Loader2, - CheckSquare, Square, X, ZoomIn, Archive, + CheckSquare, Square, X, ZoomIn, Archive, Trash2, } from 'lucide-react' import { getMediaAssets, zipDownloadAssets, + batchDeleteAssets, } from '../api/media' +import { toast } from 'sonner' import type { MediaAssetItem, MediaAssetType } from '../api/media' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -142,7 +145,15 @@ function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => vo >
- {asset.product_name &&

{asset.product_name}

} + {asset.product_name && ( + asset.product_id ? ( + + {asset.product_name} + + ) : ( +

{asset.product_name}

+ ) + )}

{asset.asset_type} {asset.product_pim_id && ` · ${asset.product_pim_id}`} @@ -268,9 +279,20 @@ function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProp )}

{asset.product_name && ( -

- {asset.product_name} -

+ asset.product_id ? ( + e.stopPropagation()} + > + {asset.product_name} + + ) : ( +

+ {asset.product_name} +

+ ) )} {asset.product_pim_id && (

{asset.product_pim_id}

@@ -326,6 +348,8 @@ export default function MediaBrowserPage() { // Selection const [selected, setSelected] = useState>(new Set()) const [zipping, setZipping] = useState(false) + const [deleting, setDeleting] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) // Lightbox const [previewAsset, setPreviewAsset] = useState(null) @@ -379,6 +403,8 @@ export default function MediaBrowserPage() { } } + const qc = useQueryClient() + async function handleZipDownload() { if (selected.size === 0) return setZipping(true) @@ -389,6 +415,22 @@ export default function MediaBrowserPage() { } } + async function handleBatchDelete() { + if (selected.size === 0) return + setDeleting(true) + try { + const result = await batchDeleteAssets(Array.from(selected)) + toast.success(`Deleted ${result.deleted} asset${result.deleted !== 1 ? 's' : ''}`) + setSelected(new Set()) + setConfirmDelete(false) + qc.invalidateQueries({ queryKey: ['media-browser'] }) + } catch { + toast.error('Failed to delete assets') + } finally { + setDeleting(false) + } + } + return (
{/* Lightbox */} @@ -553,8 +595,39 @@ export default function MediaBrowserPage() { : <> Download ZIP } +
+ {confirmDelete ? ( +
+ Delete {selected.size} asset{selected.size !== 1 ? 's' : ''}? + + +
+ ) : ( + + )} +
)} {canReject && ( @@ -753,6 +778,18 @@ export default function OrderDetailPage() { )}
+ {/* Unmapped Materials Dialog */} + {showMaterialDialog && ( + { + setShowMaterialDialog(false) + dispatchMut.mutate() + }} + onCancel={() => setShowMaterialDialog(false)} + /> + )} + {/* Reject Order Modal */} {rejectModalOpen && (
@@ -846,6 +883,15 @@ function OrderLineRow({ onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'), }) + const dispatchLineMut = useMutation({ + mutationFn: () => dispatchLineRender(orderId, line.id), + onSuccess: () => { + toast.success('Render re-submitted') + qc.invalidateQueries({ queryKey: ['order', orderId] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-submit failed'), + }) + const rejectLineMut = useMutation({ mutationFn: () => rejectOrderLine(orderId, line.id, rejectLineReason), onSuccess: () => { @@ -986,6 +1032,19 @@ function OrderLineRow({ {cancelMut.isPending ? : } )} + {isPrivileged && (line.render_status === 'failed' || line.render_status === 'cancelled' || line.render_status === 'pending') && line.output_type_id && ( + + )} {line.render_log && ( ) : ( - + <> + + {confirmDeleteProduct ? ( +
+ Delete permanently? + + +
+ ) : ( + + )} + )}
)} @@ -742,9 +801,9 @@ export default function ProductDetailPage() { label="USD Master" url={usdMasterUrl} filename={`${product.name ?? product.pim_id}_master.usd`} - onGenerate={() => generateGeometryGlbMut.mutate()} - isGenerating={generateGeometryGlbMut.isPending} - title="USD canonical scene (auto-generated after Viewer GLB)" + onGenerate={() => generateUsdMasterMut.mutate()} + isGenerating={generateUsdMasterMut.isPending} + title="Regenerate USD canonical scene" />
@@ -999,10 +1058,12 @@ export default function ProductDetailPage() {

) : (
- {filteredRenders.map((r) => { + {filteredRenders.map((r, _ri) => { const isConfirming = pendingDelete === r.order_line_id const isDeleting = deleteRenderMut.isPending && isConfirming const isSelected = selectedIds.has(r.order_line_id) + // Index into lightboxItems (image-only renders) + const imgIdx = r.is_video ? -1 : lightboxItems.findIndex(li => li.url === r.render_url) return (
) : (
- selectMode && e.preventDefault()} + {/* Select mode: checkbox top-left */} {selectMode && ( @@ -1451,6 +1515,15 @@ export default function ProductDetailPage() { title="CAD Thumbnail" renderLog={product.cad_render_log} /> + {/* Image lightbox */} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onIndexChange={setLightboxIndex} + /> + )}
) } diff --git a/frontend/src/pages/WorkerManagement.tsx b/frontend/src/pages/WorkerManagement.tsx index 4a87396..1161f15 100644 --- a/frontend/src/pages/WorkerManagement.tsx +++ b/frontend/src/pages/WorkerManagement.tsx @@ -80,9 +80,8 @@ function WorkerCard({ worker }: { worker: CeleryWorker }) { type ScalableService = ScaleRequest['service'] const SCALABLE_SERVICES: { service: ScalableService; label: string; description: string }[] = [ - { service: 'render-worker', label: 'Render Worker', description: 'Blender renders — concurrency=1' }, - { service: 'worker', label: 'Step Worker', description: 'STEP processing — concurrency=8' }, - { service: 'worker-thumbnail', label: 'Thumbnail Worker', description: 'Thumbnail rendering' }, + { service: 'render-worker', label: 'Render Worker (asset_pipeline)', description: 'Blender renders, thumbnails, GLB, USD — concurrency=1' }, + { service: 'worker', label: 'Step Worker (step_processing)', description: 'STEP metadata extraction — concurrency=8' }, ] function ScaleControl({ diff --git a/plan.md b/plan.md index 7a4f88c..ea653df 100644 --- a/plan.md +++ b/plan.md @@ -1,151 +1,191 @@ -# Plan: Draw Call Batching + Merge Dual STEP Parse +# Plan: Material Alias Completeness with Blocking Dialog ## Context -Two independent optimization tracks: +The material system currently has three resolution tiers in `resolve_material_map()`: +1. **Alias lookup** (case-insensitive) — maps raw names to SCHAEFFLER library materials +2. **Exact Material.name match** — the raw name IS a library material +3. **Pass-through** — unresolved names fall through, causing magenta "FailedMaterial" in Blender renders -**Track A — Draw Call Batching (Frontend):** Assemblies with 100+ parts create 100+ draw calls. Three.js issues one draw call per mesh. For large assemblies this saturates the GPU command buffer and drops frame rate below 30fps. Solution: merge meshes that share the same material into single geometries, togglable via a "Performance mode" button. +Materials are stored on `Product.cad_part_materials` as `[{part_name, material}]` JSONB. They are auto-populated from Excel components during STEP processing (`_auto_populate_materials_for_cad`). The Materials page shows "Custom" materials (no `schaeffler_code`) separately from categorized SCHAEFFLER library materials. -**Track B — Merge Dual STEP Parse (Backend):** `extract_cad_metadata()` reads the same STEP file twice: -1. `_extract_step_objects()` — `OCC.Core.STEPCAFControl_Reader` → part names (lines 391–425) -2. `extract_mesh_edge_data()` — `OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200–388) +**Problem**: When a product has a material name like "Steel--Stahl" that has no alias pointing to a SCHAEFFLER library material, the render silently produces magenta parts. There is no pre-flight check before dispatching renders. -Both readers produce a `TopoDS_Shape`. The XCAF reader (`STEPCAFControl`) gives us both the labeled hierarchy AND the shape, so we can extract edge data from the same read. This eliminates ~0.5–2s of redundant STEP parsing per file. +**Solution**: Add a blocking dialog at "Dispatch Renders" that checks for unmapped materials, lets the user map them inline to library materials, and only proceeds when all materials are resolved. -**Important constraint for Track B:** `_extract_step_objects` runs on the `worker` container (has `OCC.Core` / pythonocc), while `extract_mesh_edge_data` has dual-import fallback (`OCP` first, then `OCC.Core`). The unified function must work with `OCC.Core` (pythonocc) since that's what the `worker` container has. +### Architecture + +``` +Frontend (OrderDetail.tsx) + | + |-- Click "Dispatch Renders" + |-- Call GET /api/orders/{id}/check-materials (new endpoint) + |-- If unmapped materials exist: + | |-- Show UnmappedMaterialsDialog (new component) + | |-- User maps each material via dropdown + | |-- Call POST /api/materials/batch-aliases (new endpoint) + | |-- On success, proceed with dispatchRenders() + |-- If all mapped: proceed directly +``` ## Affected Files -| File | Track | Change | -|------|-------|--------| -| `frontend/src/components/cad/useGeometryMerge.ts` | A | NEW — hook for merge/unmerge logic | -| `frontend/src/components/cad/ThreeDViewer.tsx` | A | Add Performance mode toggle + integrate hook | -| `frontend/src/components/cad/InlineCadViewer.tsx` | A | Same Performance mode toggle | -| `frontend/src/components/cad/cadUtils.ts` | A | Add `MergedGroup` type | -| `backend/app/services/step_processor.py` | B | New `extract_step_metadata()`, refactor callers | -| `backend/app/domains/pipeline/tasks/extract_metadata.py` | B | Use new unified function | +| File | Change | +|------|--------| +| `backend/app/services/material_service.py` | Add `find_unmapped_materials()` function | +| `backend/app/api/routers/orders.py` | Add `GET /orders/{id}/check-materials` endpoint | +| `backend/app/api/routers/materials.py` | Add `POST /materials/batch-aliases` endpoint | +| `frontend/src/api/materials.ts` | Add `checkOrderMaterials()`, `batchCreateAliases()` API functions + types | +| `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` | New blocking dialog component | +| `frontend/src/pages/OrderDetail.tsx` | Intercept "Dispatch Renders" with material check | +| `frontend/src/pages/Materials.tsx` | Add warning badges for unmapped custom materials | ## Tasks (in order) ---- +### [x] Task 1: Backend — `find_unmapped_materials()` service function -### Track A — Draw Call Batching +- **File**: `backend/app/services/material_service.py` +- **What**: Add an async function `find_unmapped_materials(material_names: list[str], db: AsyncSession) -> list[dict]` that: + 1. Takes a list of raw material name strings + 2. Loads all `MaterialAlias` records and all `Material` records with `schaeffler_code` + 3. For each raw name, checks: (a) alias match (case-insensitive), (b) exact `Material.name` match where it has a `schaeffler_code` (i.e. it IS a library material) + 4. Returns a list of `{"raw_name": str, "suggestions": [{"id": str, "name": str, "schaeffler_code": str}]}` for each unmapped name + 5. `suggestions`: top 5 SCHAEFFLER materials by `difflib.SequenceMatcher` similarity (ratio > 0.3) +- **Acceptance gate**: Returns empty list when all materials are mapped; returns unmapped entries with suggestions when some are not +- **Dependencies**: None +- **Risk**: None -### [ ] Task A1: Add `MergedGroup` type and merge utility to cadUtils.ts -- **File**: `frontend/src/components/cad/cadUtils.ts` -- **What**: Add type `MergedGroup = { mergedMesh: any; sourceEntries: MeshRegistryEntry[]; materialKey: string }`. Add helper `groupRegistryByMaterial(registry: MeshRegistryEntry[], partMaterials: PartMaterialMap, pbrMap: MaterialPBRMap): Map` that groups registry entries by their resolved material name (or `__unassigned__` for parts without material). -- **Acceptance gate**: TypeScript compiles (`tsc --noEmit`). Helper is pure — no side effects, no THREE import. -- **Dependencies**: none +### [x] Task 2: Backend — `GET /orders/{id}/check-materials` endpoint + +- **File**: `backend/app/api/routers/orders.py` +- **What**: Add endpoint that: + 1. Loads all `OrderLine` records for the order, joining `Product` + 2. Collects all unique material names from `product.cad_part_materials[*].material` across all products + 3. Calls `find_unmapped_materials()` with those names + 4. Returns `{"unmapped": [...], "total_materials": int, "mapped_count": int}` +- **Response schema**: + ```python + class MaterialSuggestion(BaseModel): + id: uuid.UUID + name: str + schaeffler_code: str + + class UnmappedMaterial(BaseModel): + raw_name: str + suggestions: list[MaterialSuggestion] + + class UnmappedMaterialCheck(BaseModel): + unmapped: list[UnmappedMaterial] + total_materials: int + mapped_count: int + ``` +- **Auth**: `get_current_user` (any authenticated user can check) +- **Acceptance gate**: Returns correct unmapped materials for an order with mixed mapped/unmapped materials +- **Dependencies**: Task 1 +- **Risk**: None + +### [x] Task 3: Backend — `POST /materials/batch-aliases` endpoint + +- **File**: `backend/app/api/routers/materials.py` +- **What**: Add batch alias creation endpoint: + 1. Accepts `{"mappings": [{"alias": str, "material_id": uuid}]}` + 2. For each mapping, creates a `MaterialAlias` record (skips if alias already exists, case-insensitive) + 3. Returns `{"created": int, "skipped": int}` +- **Auth**: `require_admin_or_pm` — only admins/PMs should be able to create aliases +- **Validation**: Verify each `material_id` exists; reject if alias is empty; skip duplicate aliases +- **Acceptance gate**: Batch creates multiple aliases in one request; subsequent `resolve_material_map()` calls resolve through new aliases +- **Dependencies**: None +- **Risk**: None + +### [x] Task 4: Frontend — API functions for material checking and batch alias creation + +- **File**: `frontend/src/api/materials.ts` +- **What**: Add TypeScript interfaces and API functions: + ```typescript + export interface MaterialSuggestion { + id: string + name: string + schaeffler_code: string + } + + export interface UnmappedMaterial { + raw_name: string + suggestions: MaterialSuggestion[] + } + + export interface UnmappedMaterialCheck { + unmapped: UnmappedMaterial[] + total_materials: number + mapped_count: number + } + + export async function checkOrderMaterials(orderId: string): Promise + export async function batchCreateAliases(mappings: Array<{ alias: string; material_id: string }>): Promise<{ created: number; skipped: number }> + ``` +- **Acceptance gate**: Types compile; functions callable from components +- **Dependencies**: Tasks 2, 3 +- **Risk**: None + +### [x] Task 5: Frontend — `UnmappedMaterialsDialog` component + +- **File**: `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` +- **What**: Create a modal dialog component: + 1. **Props**: `open: boolean`, `unmapped: UnmappedMaterial[]`, `onResolved: () => void`, `onCancel: () => void` + 2. **UI**: Fixed overlay (same pattern as existing modals), wider (`max-w-xl`): + - Warning icon (`AlertTriangle` from lucide-react) + title "Unmapped Materials" + - Subtitle: "The following materials have no alias to a library material. Map them before rendering." + - For each unmapped material: row with raw name on left, `