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

11 KiB
Raw Blame History

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):

    @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.