# 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` 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): ` 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 `` 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.