4f0fe2c8c7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
14 KiB
Markdown
352 lines
14 KiB
Markdown
# 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.md`
|
||
> Date: 2026-03-11
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
- [x] `step_tasks.py` decomposition is already done; `backend/app/tasks/step_tasks.py` is now a compatibility shim over `backend/app/domains/pipeline/tasks/*`
|
||
- [ ] `blender_render.py` decomposition is still pending; current file remains monolithic
|
||
- [ ] Legacy STL-era cleanup is still pending (`stl_quality`, STL endpoints, orphaned directories)
|
||
- [x] Decision: USD authoring library → **`usd-core` (pip)** — provides `pxr` module, no GPU tools needed, pip-installable in render-worker
|
||
- [x] Decision: seam/sharp payload encoding → **index-space primvars** (`primvars:schaeffler:seamEdgeVertexPairs`, `primvars:schaeffler:sharpEdgeVertexPairs`) — survives transforms, no KD-tree needed
|
||
- [x] Decision: preview GLB derivation → **co-author from same tessellation pass** during migration (avoid round-trip loss from USD→GLB export)
|
||
- [x] 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 (`FlattenLayerStack` keeps `instanceable` prims; `UsdStage.Flatten` would 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_master` asset type or scene-manifest endpoint exists yet in backend/frontend code
|
||
- No `partKey` payload exists yet in the GLB export/viewer contract
|
||
- No `gmsh` tessellation 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`
|
||
|
||
```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 |
|
||
|---|---|
|
||
| `schaeffler:partKey` | `generate_part_key(xcaf_label_path)` |
|
||
| `schaeffler:sourceName` | XCAF `TDataStd_Name` attribute |
|
||
| `schaeffler:sourceColor` | XCAF embedded color (hex string) |
|
||
| `schaeffler:rawMaterialName` | from `CadFile.part_materials` if available |
|
||
| `schaeffler:tessellation:linearDeflectionMm` | CLI arg value |
|
||
| `schaeffler:tessellation:angularDeflectionRad` | CLI arg value |
|
||
| `primvars:schaeffler:seamEdgeVertexPairs` | OCC B-rep seam edges (index pairs in mesh-local space) |
|
||
| `primvars:schaeffler:sharpEdgeVertexPairs` | sharp edges from `_extract_sharp_edge_pairs()` |
|
||
|
||
**CLI interface:**
|
||
|
||
```bash
|
||
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 `schaeffler:partKey` attribute
|
||
- Part count matches `export_step_to_gltf.py` output 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`
|
||
|
||
```sql
|
||
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:
|
||
1. Resolves STEP file path from `CadFile`
|
||
2. Calls `export_step_to_usd.py` subprocess
|
||
3. Stores resulting `.usd` as a `usd_master` `MediaAsset`
|
||
4. 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`
|
||
|
||
```python
|
||
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": "SCHAEFFLER_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`:
|
||
|
||
```python
|
||
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:**
|
||
|
||
```python
|
||
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": "SCHAEFFLER_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:
|
||
1. Identify seam edges from face/batch boundaries (STEPper approach: adjacent faces with different batch IDs)
|
||
2. Identify sharp edges from `_extract_sharp_edge_pairs()` (already implemented)
|
||
3. Convert both to **mesh-local vertex index pairs** (not world-space coordinates)
|
||
|
||
Write to USD mesh prim:
|
||
|
||
```python
|
||
mesh_prim.GetPrimvar("schaeffler:seamEdgeVertexPairs").Set(
|
||
Vt.Vec2iArray(seam_pairs), # [(vi0, vi1), ...]
|
||
)
|
||
mesh_prim.GetPrimvar("schaeffler: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`
|
||
|
||
```python
|
||
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("schaeffler_seamEdgeVertexPairs") or []
|
||
sharp_pairs = obj.get("schaeffler_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:
|
||
1. Call `import_usd.py` instead of `export_gltf.py` GLB import
|
||
2. Read `schaeffler:partKey` and `schaeffler:canonicalMaterialName` per mesh object after import
|
||
3. Apply materials by `partKey → material library name` lookup 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`
|
||
|
||
```typescript
|
||
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`, read `mesh.userData.partKey` (set by preview GLB derivation)
|
||
- On click: identify `partKey` from `userData`, not from `mesh.name`
|
||
- Pass `partKey` to `MaterialPanel` for 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 | ~~primvars / JSON sidecar / GLB extras~~ | ✅ **index-space primvars** on mesh prim |
|
||
| 3 | Preview GLB derivation | ~~USD→GLB export / co-author from tessellation pass~~ | ✅ **co-author from same tessellation pass** during migration |
|
||
| 4 | Single-file USD or override layers | ~~flat single file / canonical + overlay 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` / `productionGltfUrl` distinction no longer required by frontend (removed from API contract)
|