feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+160 -120
View File
@@ -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 391425)
2. `extract_mesh_edge_data()``OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200388)
**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.52s 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<string, MeshRegistryEntry[]>` 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<UnmappedMaterialCheck>
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, `<select>` dropdown on right
- Dropdown pre-populated with `suggestions` from API, plus option to browse all library materials
3. **State**: `mappings: Record<string, string>` — raw_name → selected material_id
4. **"Map All & Proceed" button**: disabled until all unmapped materials have a selection; on click calls `batchCreateAliases()`, then `onResolved()` (triggers dispatch)
5. **Cancel button**: calls `onCancel()`, does not dispatch
- **Styling**: Follow existing modal patterns, Tailwind classes, lucide-react icons
- **Acceptance gate**: Dialog renders, dropdowns work, batch alias creation succeeds, dialog closes and render dispatches
- **Dependencies**: Task 4
- **Risk**: Low — need to match existing modal patterns
### [x] Task 6: Frontend — Intercept "Dispatch Renders" in `OrderDetail.tsx`
- **File**: `frontend/src/pages/OrderDetail.tsx`
- **What**:
1. Add state for material check data and dialog visibility
2. Replace direct `dispatchMut.mutate()` on "Dispatch Renders" button with `handleDispatch()`:
- Calls `checkOrderMaterials(id)`
- If `unmapped.length > 0`: show `UnmappedMaterialsDialog`
- If `unmapped.length === 0`: proceed with `dispatchMut.mutate()`
3. `onResolved` callback: close dialog, call `dispatchMut.mutate()`
4. Import and render `<UnmappedMaterialsDialog>` conditionally
- **Acceptance gate**: Dispatch with unmapped materials shows dialog; mapping all and clicking proceed dispatches; dispatch with all mapped skips dialog
- **Dependencies**: Task 5
- **Risk**: Low
### [ ] Task A2: Create `useGeometryMerge` hook
- **File**: NEW `frontend/src/components/cad/useGeometryMerge.ts`
- **What**: Hook that takes `meshRegistryRef`, `partMaterials`, `pbrMap`, and `enabled` flag. When enabled:
1. Groups meshes by material key (via `groupRegistryByMaterial`)
2. For each group: calls `BufferGeometryUtils.mergeGeometries()` on all mesh geometries (with world transforms applied via `mesh.matrixWorld`)
3. Creates one new `THREE.Mesh` per group with the shared material
4. Hides original meshes (`visible = false`)
5. Adds merged meshes to the scene
6. Returns `{ mergedGroups: MergedGroup[], restore: () => void }``restore()` removes merged meshes, re-shows originals
### [x] Task 7: Frontend — Warning badges on Materials page
When disabled (or on cleanup): calls `restore()`.
Important: must handle `BufferGeometryUtils` import from `three/examples/jsm/utils/BufferGeometryUtils.js`.
- **Acceptance gate**: TypeScript compiles. Hook can be called with `enabled=false` without errors.
- **Dependencies**: Task A1
- **Risk**: Medium — `mergeGeometries` requires all geometries to have same attribute layout (position, normal, uv). Some meshes may lack UVs. Must filter or skip incompatible groups.
### [ ] Task A3: Integrate Performance mode in ThreeDViewer
- **File**: `frontend/src/components/cad/ThreeDViewer.tsx`
- **File**: `frontend/src/pages/Materials.tsx`
- **What**:
1. Add `perfMode` state (boolean, default false)
2. Add toolbar button (after wireframe toggle, ~line 771): `<TBtn active={perfMode} onClick={() => setPerfMode(p => !p)} title="Performance mode — merges geometries, disables per-part hover">` with `Layers` icon from lucide-react
3. Call `useGeometryMerge({ meshRegistryRef, partMaterials: effectiveMaterials, pbrMap, enabled: perfMode, sceneRef })`
4. When `perfMode` is true: disable hover handlers (set `onPointerOver`/`onPointerOut`/`onClick` to undefined on the `<primitive>` element), hide MaterialPanel part list
5. When `perfMode` is false: restore normal interaction
6. Show draw call count in toolbar badge: `renderer.info.render.calls` (read from `gl` via `useThree`)
- **Acceptance gate**: Toggle Performance mode → `renderer.info.render.calls` drops to < 20 for 100-part assembly. Toggle back → all hover/select/material interactions work.
- **Dependencies**: Task A2
- **Risk**: Medium — must ensure merged meshes inherit correct material properties (PBR). Must not break camera fitting (merged meshes have different bounding boxes).
### [ ] Task A4: Integrate Performance mode in InlineCadViewer
- **File**: `frontend/src/components/cad/InlineCadViewer.tsx`
- **What**: Same as Task A3 but for the inline viewer. Add `perfMode` toggle button to toolbar (~line 455). Integrate `useGeometryMerge` hook. Disable hover when in perf mode.
- **Acceptance gate**: Same as A3 — draw calls drop, interactions restored on toggle-off.
- **Dependencies**: Task A2
- **Risk**: Low — same pattern as A3
---
### Track B — Merge Dual STEP Parse
### [ ] Task B1: Create `extract_step_metadata()` unified function
- **File**: `backend/app/services/step_processor.py`
- **What**: New function (insert after line 389, before `_extract_step_objects`):
```python
@dataclass
class StepMetadata:
objects: list[str] # part names from XCAF labels
edge_data: dict # sharp_edge_pairs, suggested_smooth_angle, etc.
dimensions_mm: dict | None # bbox dimensions
bbox_center_mm: dict | None
def extract_step_metadata(step_path: str) -> StepMetadata:
```
Implementation approach:
1. Read STEP once with `STEPCAFControl_Reader` (same as `_extract_step_objects`)
2. Extract part names from XCAF labels (same logic as current `_extract_step_objects`)
3. Get root shape via `shape_tool.GetShape(label)` for each free label
4. Tessellate at 0.5mm deflection via `BRepMesh_IncrementalMesh`
5. Extract edge topology from the tessellated shape (same logic as current `extract_mesh_edge_data` lines 265382, but operating on the already-loaded shape instead of re-reading)
6. Extract bbox from the same shape
7. Return `StepMetadata` dataclass
Must handle both `OCC.Core` (pythonocc) and `OCP` (cadquery) import paths, same as existing code.
**Keep `_extract_step_objects` and `extract_mesh_edge_data` unchanged** as fallbacks.
- **Acceptance gate**: `python3 -c "import ast; ast.parse(open('backend/app/services/step_processor.py').read())"` passes. New function returns same data as the two separate calls combined.
- **Dependencies**: none
- **Risk**: Medium — the edge extraction logic references `STEPControl_Reader`-specific APIs (`reader.TransferRoots()`, `reader.OneShape()`). With `STEPCAFControl_Reader`, the shape comes from `shape_tool.GetShape(label)` instead. The edge extraction code uses `TopTools_IndexedDataMapOfShapeListOfShape` on the root shape — this should work identically on an XCAF-sourced shape since it's the same `TopoDS_Shape` underneath. Must verify the `_using_ocp` vs `OCC.Core` static method dispatch (`_s` suffix) still works.
### [ ] Task B2: Wire `extract_step_metadata()` into `extract_cad_metadata()`
- **File**: `backend/app/services/step_processor.py`
- **What**: Modify `extract_cad_metadata()` (line 82) to:
1. Try `extract_step_metadata()` first (single read)
2. If it succeeds: use `metadata.objects` for `parsed_objects`, `metadata.edge_data` for `mesh_attributes`
3. If it fails (fallback): call `_extract_step_objects()` + `extract_mesh_edge_data()` separately (existing behavior)
4. Log which path was taken: `"[STEP] unified read: X objects, Y sharp pairs"` vs `"[STEP] fallback: separate reads"`
- **Acceptance gate**: Upload a STEP file → worker log shows single "unified read" message. `parsed_objects` and `mesh_attributes` populated correctly.
- **Dependencies**: Task B1
- **Risk**: Low — fallback preserves existing behavior
### [ ] Task B3: Also wire into `process_cad_file()` (legacy path)
- **File**: `backend/app/services/step_processor.py`
- **What**: Same change as B2 but for `process_cad_file()` (line 137) which is the legacy full-pipeline function. Try unified read first, fall back to separate reads.
- **Acceptance gate**: `process_cad_file()` still works end-to-end (upload STEP → metadata + thumbnail).
- **Dependencies**: Task B1
1. In the "Custom" materials section, show a warning badge (`AlertTriangle` icon + "No alias") for materials that have no `schaeffler_code` AND no aliases
2. Add a "Map to Library" action button that opens a dropdown to quickly assign a SCHAEFFLER material as alias
3. Optional: "Show unmapped only" filter toggle
- **Acceptance gate**: Custom materials without aliases show a warning; mapping removes it
- **Dependencies**: None (independent)
- **Risk**: Low
## Migration Check
No migration required. All changes are code-level optimizations.
**No** — no new database columns needed. `MaterialAlias` model already exists. All changes use existing tables.
## Order Recommendation
Track A and Track B are fully independent — implement in parallel.
Within Track A: A1 → A2 → A3 + A4 (A3 and A4 can be parallel)
Within Track B: B1 → B2 + B3 (B2 and B3 can be parallel)
1. Backend service function (Task 1)
2. Backend endpoints (Tasks 2-3, parallel)
3. Frontend API types (Task 4)
4. Frontend dialog + integration (Tasks 5-6)
5. Materials page badges (Task 7, independent)
## Risks / Open Questions
1. **BufferGeometryUtils.mergeGeometries compatibility**: All geometries in a merge group must have identical attribute sets (position, normal, uv). Meshes without UVs can't merge with UV-bearing meshes. The hook must detect this and skip incompatible groups (leave them as individual meshes).
1. **Performance**: `find_unmapped_materials()` loads all aliases and library materials per check. For the current scale (~50 materials, ~100 aliases) this is fine. If scale grows, add caching.
2. **Camera fitting in Performance mode**: `CameraFit` component likely uses scene bounding box. Merged meshes may have different world-space bounds than originals if transforms aren't baked correctly. Must apply `mesh.matrixWorld` to geometry before merging.
2. **Material name normalization**: Should the check be case-insensitive? Yes — alias lookup is already case-insensitive, so the check should match.
3. **OCC.Core API differences**: pythonocc (`OCC.Core`) uses different method naming than OCP (no `_s` suffix for static methods). The unified function must handle both, same as `extract_mesh_edge_data` currently does.
4. **Edge extraction on XCAF shape**: `extract_mesh_edge_data` calls `reader.OneShape()` which returns a single compound. From XCAF, `shape_tool.GetShape(label)` returns the shape for each free label. For multi-root STEP files (rare), we need to iterate all free labels and combine edge data. This matches the pattern already used in `export_step_to_gltf.py` (line 696700).
5. **Memory**: `mergeGeometries` creates new geometry buffers. For 100 parts × 50K triangles each = 5M triangles in merged buffers + 5M in originals (hidden but not disposed). May need to dispose original geometries in Performance mode and recreate on restore. This adds complexity — defer disposal to a follow-up if memory isn't an issue.
3. **Products without cad_part_materials**: Some products may not have materials assigned yet (no STEP file processed). These are skipped — the check only validates materials that exist.