- @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>
11 KiB
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:
_extract_step_objects()—OCC.Core.STEPCAFControl_Reader→ part names (lines 391–425)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 helpergroupRegistryByMaterial(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, andenabledflag. When enabled:- Groups meshes by material key (via
groupRegistryByMaterial) - For each group: calls
BufferGeometryUtils.mergeGeometries()on all mesh geometries (with world transforms applied viamesh.matrixWorld) - Creates one new
THREE.Meshper group with the shared material - Hides original meshes (
visible = false) - Adds merged meshes to the scene
- Returns
{ mergedGroups: MergedGroup[], restore: () => void }—restore()removes merged meshes, re-shows originals
When disabled (or on cleanup): calls
restore().Important: must handle
BufferGeometryUtilsimport fromthree/examples/jsm/utils/BufferGeometryUtils.js. - Groups meshes by material key (via
-
Acceptance gate: TypeScript compiles. Hook can be called with
enabled=falsewithout errors. -
Dependencies: Task A1
-
Risk: Medium —
mergeGeometriesrequires 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:
- Add
perfModestate (boolean, default false) - Add toolbar button (after wireframe toggle, ~line 771):
<TBtn active={perfMode} onClick={() => setPerfMode(p => !p)} title="Performance mode — merges geometries, disables per-part hover">withLayersicon from lucide-react - Call
useGeometryMerge({ meshRegistryRef, partMaterials: effectiveMaterials, pbrMap, enabled: perfMode, sceneRef }) - When
perfModeis true: disable hover handlers (setonPointerOver/onPointerOut/onClickto undefined on the<primitive>element), hide MaterialPanel part list - When
perfModeis false: restore normal interaction - Show draw call count in toolbar badge:
renderer.info.render.calls(read fromglviauseThree)
- Add
- Acceptance gate: Toggle Performance mode →
renderer.info.render.callsdrops 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
perfModetoggle button to toolbar (~line 455). IntegrateuseGeometryMergehook. 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):@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:
- Read STEP once with
STEPCAFControl_Reader(same as_extract_step_objects) - Extract part names from XCAF labels (same logic as current
_extract_step_objects) - Get root shape via
shape_tool.GetShape(label)for each free label - Tessellate at 0.5mm deflection via
BRepMesh_IncrementalMesh - Extract edge topology from the tessellated shape (same logic as current
extract_mesh_edge_datalines 265–382, but operating on the already-loaded shape instead of re-reading) - Extract bbox from the same shape
- Return
StepMetadatadataclass
Must handle both
OCC.Core(pythonocc) andOCP(cadquery) import paths, same as existing code.Keep
_extract_step_objectsandextract_mesh_edge_dataunchanged as fallbacks. - Read STEP once with
-
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()). WithSTEPCAFControl_Reader, the shape comes fromshape_tool.GetShape(label)instead. The edge extraction code usesTopTools_IndexedDataMapOfShapeListOfShapeon the root shape — this should work identically on an XCAF-sourced shape since it's the sameTopoDS_Shapeunderneath. Must verify the_using_ocpvsOCC.Corestatic method dispatch (_ssuffix) 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:- Try
extract_step_metadata()first (single read) - If it succeeds: use
metadata.objectsforparsed_objects,metadata.edge_dataformesh_attributes - If it fails (fallback): call
_extract_step_objects()+extract_mesh_edge_data()separately (existing behavior) - Log which path was taken:
"[STEP] unified read: X objects, Y sharp pairs"vs"[STEP] fallback: separate reads"
- Try
- Acceptance gate: Upload a STEP file → worker log shows single "unified read" message.
parsed_objectsandmesh_attributespopulated 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
-
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).
-
Camera fitting in Performance mode:
CameraFitcomponent likely uses scene bounding box. Merged meshes may have different world-space bounds than originals if transforms aren't baked correctly. Must applymesh.matrixWorldto geometry before merging. -
OCC.Core API differences: pythonocc (
OCC.Core) uses different method naming than OCP (no_ssuffix for static methods). The unified function must handle both, same asextract_mesh_edge_datacurrently does. -
Edge extraction on XCAF shape:
extract_mesh_edge_datacallsreader.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 inexport_step_to_gltf.py(line 696–700). -
Memory:
mergeGeometriescreates 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.