14 KiB
Implementation Plan: STEP → USD Canonical Scene Workflow
Execution checklist for ROADMAP.md Priorities 2, 4, and 5. RFC:
docs/rfcs/0001-step-to-usd-workflow.mdDate: 2026-03-11
Prerequisites
step_tasks.pydecomposition is already done;backend/app/tasks/step_tasks.pyis now a compatibility shim overbackend/app/domains/pipeline/tasks/*blender_render.pydecomposition is still pending; current file remains monolithic- Legacy STL-era cleanup is still pending (
stl_quality, STL endpoints, orphaned directories) - Decision: USD authoring library →
usd-core(pip) — providespxrmodule, no GPU tools needed, pip-installable in render-worker - Decision: seam/sharp payload encoding → index-space primvars (
primvars:hartomat:seamEdgeVertexPairs,primvars:hartomat:sharpEdgeVertexPairs) — survives transforms, no KD-tree needed - Decision: preview GLB derivation → co-author from same tessellation pass during migration (avoid round-trip loss from USD→GLB export)
- Decision: single-file vs override layers → Option B: canonical geometry layer + material override layer, flattened via
UsdUtils.FlattenLayerStack()for delivery — preserves hierarchy AND allows instancing later (FlattenLayerStackkeepsinstanceableprims;UsdStage.Flattenwould expand them). Note: Phase 1 uses no instancing (matching current GLB pipeline), but the delivery path is already instancing-safe.
Current Baseline
- No USD exporter exists yet in
render-worker/scripts/ - No
usd_masterasset type or scene-manifest endpoint exists yet in backend/frontend code - No
partKeypayload exists yet in the GLB export/viewer contract - No
gmshtessellation wiring exists yet; Phase 1 should assume current OCC tessellation unless Priority 3 is pulled forward
Phase 1 — Dual-Write USD Beside GLB (Priority 2, M1–M2)
Goal: Export a valid usd_master alongside the existing GLB pipeline without changing any browser behavior.
Task 1.0 — Install usd-core in render-worker
File: render-worker/Dockerfile
# OpenUSD Python bindings — provides pxr module for USD authoring
RUN pip3 install --no-cache-dir "usd-core>=24.11"
Add after the gmsh line. usd-core is the Pixar-maintained pip distribution of OpenUSD — no GPU, no USD imaging, just the core authoring/scene APIs (pxr.Usd, pxr.UsdGeom, pxr.Sdf, pxr.Vt).
Acceptance gate: docker compose exec render-worker python3 -c "from pxr import Usd; print(Usd.GetVersion())" → (24, 11, 0) or later.
Task 1.1 — export_step_to_usd.py scaffolding
File: render-worker/scripts/export_step_to_usd.py
What it must produce:
/Root — Stage root
/Root/Assembly — Top-level assembly prim (Xform)
/Root/Assembly/<AssemblyNode> — Per-component prim (Xform)
/Root/Assembly/<AssemblyNode>/<PartKey> — Leaf part prim (Xform)
/Root/Assembly/<AssemblyNode>/<PartKey>/Mesh — UsdGeomMesh
/Root/Looks/<MaterialName> — UsdShadeMaterial (placeholder binding)
Required per-mesh prim attributes:
| Attribute | Value source |
|---|---|
hartomat:partKey |
generate_part_key(xcaf_label_path) |
hartomat:sourceName |
XCAF TDataStd_Name attribute |
hartomat:sourceColor |
XCAF embedded color (hex string) |
hartomat:rawMaterialName |
from CadFile.part_materials if available |
hartomat:tessellation:linearDeflectionMm |
CLI arg value |
hartomat:tessellation:angularDeflectionRad |
CLI arg value |
primvars:hartomat:seamEdgeVertexPairs |
OCC B-rep seam edges (index pairs in mesh-local space) |
primvars:hartomat:sharpEdgeVertexPairs |
sharp edges from _extract_sharp_edge_pairs() |
CLI interface:
python3 export_step_to_usd.py \
--step_path /path/to/file.stp \
--output_path /path/to/output.usd \
[--linear_deflection 0.03] \
[--angular_deflection 0.05] \
[--color_map '{"Ring": "#4C9BE8"}'] \
[--tessellation_engine occ|gmsh]
Acceptance gate: python3 export_step_to_usd.py --step_path 81113-l_cut.stp --output_path /tmp/test.usd →
- File exists, parseable
- 25 part prims with
hartomat:partKeyattribute - Part count matches
export_step_to_gltf.pyoutput for same file
Task 1.2 — usd_master MediaAsset type
File: backend/app/domains/media/models.py
Add usd_master = "usd_master" to MediaAssetType enum.
Migration: backend/alembic/versions/060_usd_master_asset_type.py
ALTER TYPE mediaassettype ADD VALUE IF NOT EXISTS 'usd_master';
Acceptance gate: GET /api/media?asset_type=usd_master returns 200 (not 422 validation error).
Task 1.3 — generate_usd_master_task Celery task
File: backend/app/domains/pipeline/tasks/export_glb.py
New task that:
- Resolves STEP file path from
CadFile - Calls
export_step_to_usd.pysubprocess - Stores resulting
.usdas ausd_masterMediaAsset - Does NOT touch existing GLB tasks (dual-write, no removal yet)
Queue: step_processing (fast, < 30s — tessellation only, no Blender)
Called by: render_step_thumbnail task after render_step_thumbnail succeeds, OR triggered independently via admin action.
Acceptance gate: After triggering task for a CadFile, GET /api/cad/{id}/media includes an asset with asset_type: "usd_master".
Phase 2 — Canonical Part Identity and Assignment Layers (Priority 2, M3–M5)
Task 2.1 — part_key_service.py
File: backend/app/services/part_key_service.py
def generate_part_key(xcaf_label_path: str, source_name: str) -> str:
"""Deterministic slug from XCAF path + source name.
Format: lowercase alphanumeric + underscores, max 64 chars.
E.g.: 'ring_outer', 'ball_af0', 'cage_inner_ring'
For duplicate names: append _2, _3, etc.
For unnamed parts: 'part_{sha256(xcaf_path)[:8]}'
"""
...
def build_scene_manifest(cad_file: CadFile, usd_asset: MediaAsset) -> dict:
"""Read part metadata from USD or from CadFile.parsed_objects.
Returns dict matching SceneManifest Pydantic schema:
{
"cad_file_id": "...",
"parts": [
{
"part_key": "ring_outer",
"source_name": "RingOuter_AF0",
"prim_path": "/Root/Assembly/Bearing/RingOuter",
"effective_material": "HARTOMAT_010102_...",
"assignment_provenance": "manual|auto|default",
"is_unassigned": false
}
],
"unmatched_source_rows": [...],
"unassigned_parts": [...]
}
"""
...
Task 2.2 — Three-layer material assignment model
File: backend/app/domains/products/models.py
Add three JSONB columns to CadFile:
source_material_assignments: dict | None # from Excel / product import; keyed by source name
resolved_material_assignments: dict | None # auto-matched; keyed by partKey
manual_material_overrides: dict | None # browser-authored; keyed by partKey
Migration: backend/alembic/versions/061_material_assignment_layers.py
Keep existing part_materials column for backward compat during transition.
Service helper:
def get_effective_assignments(cad_file: CadFile) -> dict:
"""Priority: manual_overrides > resolved > source color > 'unassigned'."""
...
Task 2.3 — GET /cad/{id}/scene-manifest endpoint
File: backend/app/api/routers/cad.py
New endpoint returning SceneManifest. Calls build_scene_manifest() — reads from USD metadata if usd_master asset exists, otherwise reads from CadFile.parsed_objects.
Acceptance gate: Returns HTTP 200 with parts array; each part has part_key, effective_material, is_unassigned, assignment_provenance.
Task 2.4 — Update PUT /cad/{id}/part-materials
File: backend/app/api/routers/cad.py
Accept { "part_key": "ring_outer", "material": "HARTOMAT_010102_..." } body (or bulk map). Write to manual_material_overrides column (not the old part_materials column).
Acceptance gate: PUT with partKey → subsequent GET /scene-manifest shows that part's assignment_provenance: "manual".
Phase 3 — Seam/Sharp Payload to USD Mesh Prims (Priority 3 integration)
Task 3.1 — Port seam derivation into USD exporter
File: render-worker/scripts/export_step_to_usd.py
After tessellation (OCC or GMSH), for each mesh face:
- Identify seam edges from face/batch boundaries (STEPper approach: adjacent faces with different batch IDs)
- Identify sharp edges from
_extract_sharp_edge_pairs()(already implemented) - Convert both to mesh-local vertex index pairs (not world-space coordinates)
Write to USD mesh prim:
mesh_prim.GetPrimvar("hartomat:seamEdgeVertexPairs").Set(
Vt.Vec2iArray(seam_pairs), # [(vi0, vi1), ...]
)
mesh_prim.GetPrimvar("hartomat:sharpEdgeVertexPairs").Set(
Vt.Vec2iArray(sharp_pairs),
)
Why index-space: Survives transforms cleanly; avoids KD-tree matching workaround in _apply_sharp_edges_from_occ().
Task 3.2 — import_usd.py Blender helper
File: render-worker/scripts/import_usd.py
def import_usd_and_restore_topology(usd_path: str) -> list:
"""Import USD stage into Blender, restore seam/sharp from primvars.
Returns list of imported mesh objects.
"""
bpy.ops.wm.usd_import(filepath=usd_path)
for obj in bpy.context.scene.objects:
if obj.type != 'MESH':
continue
# Read custom attributes set by USD importer
seam_pairs = obj.get("hartomat_seamEdgeVertexPairs") or []
sharp_pairs = obj.get("hartomat_sharpEdgeVertexPairs") or []
_mark_seams_from_index_pairs(obj, seam_pairs)
_mark_sharp_from_index_pairs(obj, sharp_pairs)
...
Acceptance gate: Blender log shows [USD_IMPORT] 25 parts, 5044 seam edges restored from primvars — no KD-tree needed.
Phase 4 — Blender Render from USD (Priority 5)
Task 4.1 — blender_render.py USD path
File: render-worker/scripts/blender_render.py
Add --usd_path argument. When provided:
- Call
import_usd.pyinstead ofexport_gltf.pyGLB import - Read
hartomat:partKeyandhartomat:canonicalMaterialNameper mesh object after import - Apply materials by
partKey → material library namelookup instead of object-name heuristics
Migration: Keep --glb_path working in parallel; switch production task to prefer --usd_path when usd_master asset exists.
Task 4.2 — Backend render service update
File: backend/app/services/render_blender.py
When building blender_cmd, check if usd_master MediaAsset exists for the CadFile:
- If yes: pass
--usd_path <usd_master_local_path> - If no: fall back to
--glb_path <gltf_production_path>(unchanged)
Phase 5 — Frontend Migration (Priority 4)
Task 5.1 — Scene manifest fetch on product load
File: frontend/src/api/sceneManifest.ts
export interface PartEntry {
part_key: string;
source_name: string;
prim_path: string;
effective_material: string | null;
assignment_provenance: 'manual' | 'auto' | 'default';
is_unassigned: boolean;
}
export interface SceneManifest {
cad_file_id: string;
parts: PartEntry[];
unmatched_source_rows: string[];
unassigned_parts: string[];
}
export async function fetchSceneManifest(cadFileId: string): Promise<SceneManifest> {
const res = await api.get<SceneManifest>(`/cad/${cadFileId}/scene-manifest`);
return res.data;
}
Task 5.2 — ThreeDViewer partKey integration
File: frontend/src/components/cad/ThreeDViewer.tsx
- On GLB load: iterate
scene.children, readmesh.userData.partKey(set by preview GLB derivation) - On click: identify
partKeyfromuserData, not frommesh.name - Pass
partKeytoMaterialPanelfor display and persistence
Task 5.3 — MaterialPanel reconciliation section
File: frontend/src/components/cad/MaterialPanel.tsx
Add reconciliation section showing:
- "N unmatched Excel rows" (source names with no partKey match)
- "M unassigned parts" (partKeys with no resolved material)
Clicking an unassigned part in the viewer auto-focuses it in the MaterialPanel.
Open Questions (must be decided before Phase 1 coding starts)
| # | Question | Options | Default recommendation |
|---|---|---|---|
| 1 | USD authoring library | pxr full / usda text / usd-core pip |
✅ usd-core pip — pip install usd-core in render-worker Dockerfile |
| 2 | Seam/sharp payload encoding | ✅ index-space primvars on mesh prim | |
| 3 | Preview GLB derivation | ✅ co-author from same tessellation pass during migration | |
| 4 | Single-file USD or override layers | ✅ Option B: override layers + UsdUtils.FlattenLayerStack() for delivery — hierarchy preserved, instancing-safe |
Non-Regression Checklist
Before merging any Priority 2–5 work:
- Click a part in ThreeDViewer → selection resolves to stable
partKey - Pin selection → isolate, hide, ghost all work as before
- Unassigned parts are visually highlighted
- Assign a Blender asset-library material name via browser → persisted by
partKey - Reload page → same part still assigned
- Subsequent Blender render uses the same assignment
- CAD file with mismatched Excel names → system produces canonical scene, preview asset, unmatched row count
geometryGltfUrl/productionGltfUrldistinction no longer required by frontend (removed from API contract)