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:
@@ -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<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 265–382, 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 696–700).
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user