6c5873d51f
- @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>
152 lines
11 KiB
Markdown
152 lines
11 KiB
Markdown
# 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 391–425)
|
||
2. `extract_mesh_edge_data()` — `OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200–388)
|
||
|
||
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.
|
||
|
||
**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 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
|
||
- **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 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.
|