Files
HartOMat/plan.md
T
Hartmut 6c5873d51f feat: performance optimizations + part-materials validation
- @timed_step decorator with wall-clock + RSS tracking (pipeline_logger)
- Blender timing laps for sharp edges and material assignment
- MeshRegistry pattern: eliminate 13 scene.traverse() calls across viewers
- Lazy material cloning (clone-on-first-write in both viewers)
- _pipeline_session context manager: 7 create_engine() → 2 in render_thumbnail
- KD-tree spatial pre-filter for sharp edge marking (bbox-based pruning)
- Batch material library append: N bpy.ops.wm.append → single bpy.data.libraries.load
- GMSH single-session batching: compound all solids into one tessellation call
- Validate part-materials save endpoints against parsed_objects (prevents bogus keys)
- ROADMAP updated with completion status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:53:14 +01:00

152 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Draw Call Batching + Merge Dual STEP Parse
## Context
Two independent optimization tracks:
**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.
**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)
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.
**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.
## 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 |
## Tasks (in order)
---
### Track A — Draw Call Batching
### [ ] 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
- **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
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`
- **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
- **Risk**: Low
## Migration Check
No migration required. All changes are code-level optimizations.
## 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)
## 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).
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.
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.