diff --git a/.claude/commands/render-pipeline.md b/.claude/commands/render-pipeline.md new file mode 100644 index 0000000..3194d54 --- /dev/null +++ b/.claude/commands/render-pipeline.md @@ -0,0 +1,211 @@ +# Render Pipeline Agent + +You are a specialist for the render script chain in the Schaeffler Automat project. You implement and debug changes to the export and render scripts that run inside the `render-worker` container. + +## Pipeline Overview + +``` +Celery task: render_step_thumbnail [queue: thumbnail_rendering] + │ + ├─ subprocess: export_step_to_gltf.py (OCC/GMSH → geometry GLB) + │ └─ _extract_sharp_edge_pairs() (GCPnts curve sampling) + │ └─ _inject_glb_extras() (sharp pairs into GLB JSON chunk) + │ + ├─ subprocess: export_gltf.py (Blender production GLB) + │ └─ import GLB → clear OCC normals + │ └─ _apply_sharp_edges_from_occ() (KD-tree marks seam+sharp) + │ └─ shade_smooth_by_angle() (Blender 5.0 geometry node) + │ └─ append materials from .blend library + │ └─ export production GLB + │ + └─ subprocess: still_render.py (Blender PNG still) + └─ import production GLB + └─ _activate_gpu() × 3 (before file, after file, after engine) + └─ Cycles render → PNG thumbnail + +Celery task: render_order_line_task [queue: thumbnail_rendering] + ├─ subprocess: still_render.py (order-line PNG) + └─ subprocess: turntable_render.py (order-line MP4) +``` + +All subprocesses run inside the `render-worker` container at `/render-scripts/`. +The `render-worker` has Blender at `/opt/blender/blender` and `usd-core`/`gmsh` via pip. + +## Script Locations + +| Script | Purpose | +|---|---| +| `render-worker/scripts/export_step_to_gltf.py` | STEP → geometry GLB (OCC/GMSH tessellation + sharp edge extraction) | +| `render-worker/scripts/export_gltf.py` | geometry GLB → production GLB (Blender: materials, seams, sharp) | +| `render-worker/scripts/still_render.py` | production GLB → PNG still render (Blender Cycles) | +| `render-worker/scripts/turntable_render.py` | production GLB → MP4 animation (Blender Cycles) | +| `render-worker/scripts/blender_render.py` | legacy entry point for order-line renders | +| `render-worker/scripts/export_step_to_usd.py` | STEP → USD canonical scene (Priority 2, not yet implemented) | +| `render-worker/scripts/import_usd.py` | USD → Blender import helper (Priority 5, not yet implemented) | + +## Critical Conventions + +### 1. Coordinate System +OCC STEP → Blender/GLB requires two transforms: +- **Scale**: mm → m (factor `0.001`) +- **Axis**: OCC Z-up → Blender/glTF Y-up + +```python +# OCC (X, Y, Z) mm → Blender (X, -Z, Y) m +blender_x = occ_x * 0.001 +blender_y = -occ_z * 0.001 +blender_z = occ_y * 0.001 +``` + +Applied in `export_step_to_gltf.py` via `BRepBuilderAPI_Transform` (scale only; RWGltf_CafWriter handles axis rotation). +Applied in `_apply_sharp_edges_from_occ()` in `export_gltf.py` for KD-tree matching. + +### 2. GPU Activation Order (critical — order matters) + +```python +_early_gpu_type = _activate_gpu() # 1. Before open_mainfile +bpy.ops.wm.open_mainfile(filepath=blend) # 2. Resets compute_device_type to NONE +# ... scene setup ... +gpu_type = _activate_gpu() or _early_gpu # 3. Re-activate after file open +scene.render.engine = 'CYCLES' # 4. Set engine AFTER GPU prefs +scene.cycles.device = 'GPU' # 5. Set device AFTER engine +_activate_gpu() # 6. Re-ensure after engine reset +``` + +**Never** set `render.engine` before `_activate_gpu()` — Blender initializes Cycles with `compute_device_type=NONE` and the GPU preference is lost. + +### 3. Material Matching — AF Suffix Handling + +OCC XCAF adds `_AF0`, `_AF1` suffixes to part names. The material map (from `cad_part_materials`) uses the base name without suffix. In `export_gltf.py`: + +```python +import re +# Strip _AF\d+ suffix from both mat_map keys and Blender object names before matching +def _strip_af(name: str) -> str: + return re.sub(r'_AF\d+$', '', name) + +mat_map_lower = {_strip_af(k).lower(): v for k, v in mat_map.items()} +obj_key = _strip_af(obj.name).lower() +material_name = mat_map_lower.get(obj_key) +``` + +### 4. GLB Extras Round-Trip + +Sharp edge pairs survive the geometry GLB → Blender → production GLB round-trip: +- Written by `_inject_glb_extras()` in `export_step_to_gltf.py` into `scenes[0].extras` +- Read by Blender's glTF importer as `bpy.context.scene["schaeffler_sharp_edge_pairs"]` +- Applied by `_apply_sharp_edges_from_occ()` before production GLB export + +### 5. OCC Sharp Edge Extraction + +`BRep_Tool.Polygon3D_s()` returns `None` in XCAF compound context. Always use `GCPnts_UniformAbscissa`: + +```python +from OCP.GCPnts import GCPnts_UniformAbscissa +from OCP.BRepAdaptor import BRepAdaptor_Curve + +SAMPLE_STEP_MM = 0.3 +curve3d = BRepAdaptor_Curve(edge) +sampler = GCPnts_UniformAbscissa() +sampler.Initialize(curve3d, SAMPLE_STEP_MM, 1e-6) +if sampler.IsDone() and sampler.NbPoints() >= 2: + for j in range(1, sampler.NbPoints() + 1): + t = sampler.Parameter(j) + p = curve3d.Value(t) + pts.append([round(p.X(), 4), round(p.Y(), 4), round(p.Z(), 4)]) +``` + +### 6. Blender shade_smooth_by_angle + +In Blender 5.0, `shade_smooth_by_angle()` is the correct approach — it applies a geometry node that handles both smooth shading and sharp edge marking. Do not use `bpy.ops.mesh.edges_select_sharp()` + `mark_sharp()` loop — it was 210s on a 175-part assembly. + +```python +# Applied per mesh object after import: +bpy.ops.object.select_all(action='DESELECT') +obj.select_set(True) +bpy.context.view_layer.objects.active = obj +bpy.ops.object.shade_smooth_by_angle(angle=math.radians(smooth_angle_deg)) +``` + +## Parameter Propagation Rule + +When adding a new parameter to the pipeline, it must flow through **every link**: + +``` +admin.py (system_settings) + → export_glb.py (read setting, build CLI args) + → export_step_to_gltf.py / export_gltf.py / still_render.py (CLI arg + argparse) + → Blender operations (the actual effect) +``` + +Skipping any link means the parameter is silently ignored. + +## Testing Scripts Directly + +```bash +# Test geometry GLB export (OCC) +docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py \ + --step_path /app/uploads/[cad_file_id]/[file].stp \ + --output_path /tmp/test_geom.glb \ + --linear_deflection 0.03 \ + --angular_deflection 0.05 + +# Test geometry GLB export (GMSH) +docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py \ + --step_path /app/uploads/[cad_file_id]/[file].stp \ + --output_path /tmp/test_geom_gmsh.glb \ + --tessellation_engine gmsh \ + --linear_deflection 0.03 \ + --angular_deflection 0.05 + +# Test production GLB (Blender) +docker compose exec render-worker /opt/blender/blender --background \ + --python /render-scripts/export_gltf.py -- \ + --glb_path /tmp/test_geom.glb \ + --output_path /tmp/test_prod.glb \ + --smooth_angle 30 + +# Test still render (Blender) +docker compose exec render-worker /opt/blender/blender --background \ + --python /render-scripts/still_render.py -- \ + --glb_path /tmp/test_prod.glb \ + --output_path /tmp/test_thumb.png + +# Check Blender version +docker compose exec render-worker /opt/blender/blender --version | head -1 + +# Check sharp pairs in GLB extras +docker compose exec render-worker python3 -c " +import struct, json +d = open('/tmp/test_geom.glb', 'rb').read() +jl = struct.unpack_from(' `tenant_admin` > `project_manager` > `client` | +| Celery tasks | **Gap**: `set_tenant_context()` not yet called in all tasks — this is the primary open work | +| RLS policies | Defined in migration 036 for core tables | + +## How RLS Works in This Project + +```sql +-- RLS policy example (from migration 036): +CREATE POLICY tenant_isolation ON products + USING (tenant_id = current_setting('app.current_tenant_id')::uuid); + +-- Set context for a session: +SET LOCAL app.current_tenant_id = 'uuid-here'; +-- After this, all queries on `products` only see rows for that tenant. + +-- global_admin bypasses RLS: +SET LOCAL app.current_tenant_id = 'global'; +-- Or: SET LOCAL app.bypass_rls = 'true'; +``` + +## Audit: HTTP Endpoint + +For a given FastAPI endpoint, verify the full chain: + +### Step 1: Check middleware registration + +```bash +grep -n "TenantContextMiddleware" backend/app/main.py +``` + +Expected: `app.add_middleware(TenantContextMiddleware)` present. + +### Step 2: Check JWT contains tenant_id + +```bash +grep -n "tenant_id" backend/app/utils/auth.py | head -10 +``` + +Expected: `tenant_id` in `create_access_token()` payload. + +### Step 3: Verify RLS policy exists for the table + +```bash +docker compose exec postgres psql -U schaeffler -d schaeffler -c " +SELECT schemaname, tablename, policyname, cmd, qual +FROM pg_policies +WHERE tablename = '[tablename]';" +``` + +### Step 4: Live cross-tenant leak test + +```bash +# Get tenant A and tenant B IDs +docker compose exec postgres psql -U schaeffler -d schaeffler -c " +SELECT id, name FROM tenants LIMIT 5;" + +# Count rows visible to tenant A +docker compose exec postgres psql -U schaeffler -d schaeffler -c " +SET LOCAL app.current_tenant_id = '[tenant_a_id]'; +SELECT COUNT(*) FROM [tablename];" + +# Count total rows (bypass RLS) +docker compose exec postgres psql -U schaeffler -d schaeffler -c " +SELECT COUNT(*) FROM [tablename];" + +# If visible count == total count when tenant B has data → RLS not enforced +``` + +### Step 5: API-level verification + +```bash +# Login as tenant A user, call endpoint, check count +TOKEN=$(curl -s -X POST http://localhost:8888/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"[tenant_a_user]","password":"[password]"}' | jq -r '.access_token') + +curl -s http://localhost:8888/api/products \ + -H "Authorization: Bearer $TOKEN" | jq 'length' + +# Should return count of tenant A's products, not total across all tenants +``` + +## Audit: Celery Task + +For a given task, verify tenant context propagation: + +### Step 1: Check task for set_tenant_context call + +```bash +grep -n "set_tenant_context\|tenant_id" backend/app/domains/pipeline/tasks/[task_file].py +``` + +Expected: `set_tenant_context(db, tenant_id)` called near the start of the task function. + +### Step 2: Check tenant_id passed to task + +Trace back from the Celery `.delay()` call to verify `tenant_id` is in the arguments: + +```bash +grep -n "\.delay\|\.apply_async" backend/app/domains/pipeline/tasks/*.py | grep "[task_name]" +``` + +### Step 3: Add tenant context to a task (fix pattern) + +```python +# In the Celery task function: +@celery_app.task(bind=True, queue='thumbnail_rendering') +def render_step_thumbnail(self, cad_file_id: str, tenant_id: str | None = None): + from app.database import SyncSessionLocal + from app.utils.tenant import set_tenant_context + + with SyncSessionLocal() as db: + if tenant_id: + set_tenant_context(db, tenant_id) + logger.info(f"[TENANT] context set: tenant_id={tenant_id}") + # ... rest of task ... +``` + +And in the caller: +```python +render_step_thumbnail.delay( + str(cad_file_id), + tenant_id=str(current_user.tenant_id) if current_user.tenant_id else None, +) +``` + +## Fix: TenantContextMiddleware (if missing) + +```python +# backend/app/core/middleware.py +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from app.utils.auth import decode_token + +class TenantContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + token = request.headers.get("Authorization", "").removeprefix("Bearer ") + if token: + try: + payload = decode_token(token) + request.state.tenant_id = payload.get("tenant_id") + except Exception: + pass + return await call_next(request) +``` + +The actual DB context (`SET LOCAL`) is set inside the DB dependency via: +```python +# In database.py get_db(): +if hasattr(request.state, 'tenant_id') and request.state.tenant_id: + await db.execute(text(f"SET LOCAL app.current_tenant_id = '{request.state.tenant_id}'")) +``` + +## Tables with RLS Policies (from migration 036) + +Verify these tables have policies: +```bash +docker compose exec postgres psql -U schaeffler -d schaeffler -c " +SELECT tablename, COUNT(*) as policies +FROM pg_policies +GROUP BY tablename +ORDER BY tablename;" +``` + +Key tables that must have RLS: `products`, `cad_files`, `orders`, `order_lines`, `media_assets`, `order_items`. + +## Role Permission Matrix + +| Permission | global_admin | tenant_admin | project_manager | client | +|---|---|---|---|---| +| All tenants data | ✅ bypass RLS | ❌ own tenant only | ❌ | ❌ | +| System settings | ✅ | ✅ | ❌ | ❌ | +| Trigger renders | ✅ | ✅ | ✅ | ❌ | +| Create/view own orders | ✅ | ✅ | ✅ | ✅ | +| Manage users (all tenants) | ✅ | ❌ | ❌ | ❌ | +| Manage users (own tenant) | ✅ | ✅ | ❌ | ❌ | + +## Audit Report Format + +``` +## Tenant Isolation Audit: [endpoint or task name] +Date: [today] + +### Result: ✅ Isolated / ⚠️ Partial / ❌ Leaking + +### Findings + +#### HTTP layer +- Middleware: [present/missing] +- JWT tenant_id: [present/missing] +- RLS policy on table: [present/missing for each table] +- Cross-tenant leak test: [pass/fail with counts] + +#### Celery layer (if applicable) +- set_tenant_context called: [yes/no] +- tenant_id passed in .delay(): [yes/no] + +### Fix Required +[Exact code change needed, or "None — fully isolated"] +``` + +## Completion + +After completing an audit or fix: "Tenant audit complete. Result: [✅/⚠️/❌]. [Summary of findings and changes]." diff --git a/.claude/commands/usd-export.md b/.claude/commands/usd-export.md new file mode 100644 index 0000000..7f710c2 --- /dev/null +++ b/.claude/commands/usd-export.md @@ -0,0 +1,297 @@ +# USD Export Agent + +You are a specialist for the USD pipeline in the Schaeffler Automat project. You implement and debug everything related to `export_step_to_usd.py`, `import_usd.py`, and the `pxr` authoring API. + +## Architecture Decisions (all locked) + +| Decision | Value | +|---|---| +| USD library | `usd-core` pip → `from pxr import Usd, UsdGeom, Sdf, Vt, Gf, UsdShade, UsdUtils` | +| Seam/sharp encoding | Index-space primvars on mesh prim (not world-space coordinates) | +| Preview GLB | Co-authored from same tessellation pass (not USD→GLB round-trip) | +| Layer strategy | Canonical geometry layer + material override layer, flattened via `UsdUtils.FlattenLayerStack()` for delivery | +| Instancing | None in Phase 1 (co-author `FlattenLayerStack` is instancing-safe for later) | + +Full implementation checklist: `docs/plans/0001-step-to-usd-implementation.md` + +## Key pxr API Reference + +### Stage and Layer Setup + +```python +from pxr import Usd, UsdGeom, UsdShade, Sdf, Vt, Gf, UsdUtils + +# Create a new stage (writes to disk) +stage = Usd.Stage.CreateNew("/path/to/output.usd") + +# Set up coordinate system and units +UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y) # glTF/Blender convention +UsdGeom.SetStageMetersPerUnit(stage, 0.001) # STEP is in mm + +# Define root prims +root = UsdGeom.Xform.Define(stage, "/Root") +assembly = UsdGeom.Xform.Define(stage, "/Root/Assembly") + +stage.Save() +``` + +### Part Hierarchy + Custom Metadata + +```python +# Define an Xform for a part +part_xform = UsdGeom.Xform.Define(stage, f"/Root/Assembly/{node_name}/{part_key}") + +# Author custom metadata (schaeffler: namespace) +part_prim = part_xform.GetPrim() +part_prim.SetCustomDataByKey("schaeffler:partKey", part_key) +part_prim.SetCustomDataByKey("schaeffler:sourceName", source_name) +part_prim.SetCustomDataByKey("schaeffler:sourceColor", hex_color) +part_prim.SetCustomDataByKey("schaeffler:rawMaterialName", raw_material) +part_prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", canonical_material) +part_prim.SetCustomDataByKey("schaeffler:cadFileId", str(cad_file_id)) +part_prim.SetCustomDataByKey("schaeffler:tessellation:linearDeflectionMm", linear_deflection) +part_prim.SetCustomDataByKey("schaeffler:tessellation:angularDeflectionRad", angular_deflection) +``` + +### Mesh Geometry + +```python +from pxr import Vt, Gf + +mesh = UsdGeom.Mesh.Define(stage, f"{part_path}/Mesh") + +# Points (vertices) — Gf.Vec3f array +points = Vt.Vec3fArray([Gf.Vec3f(x, y, z) for x, y, z in vertex_list]) +mesh.CreatePointsAttr(points) + +# Face topology +face_vertex_counts = Vt.IntArray([3] * len(triangles)) # all triangles +mesh.CreateFaceVertexCountsAttr(face_vertex_counts) + +face_vertex_indices = Vt.IntArray([idx for tri in triangles for idx in tri]) +mesh.CreateFaceVertexIndicesAttr(face_vertex_indices) + +# Normals (per-vertex or per-face) +normals = Vt.Vec3fArray([Gf.Vec3f(nx, ny, nz) for nx, ny, nz in normal_list]) +mesh.CreateNormalsAttr(normals) +mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex) + +# Display color from OCC +color = Vt.Vec3fArray([Gf.Vec3f(r, g, b)]) +mesh.CreateDisplayColorAttr(color) +``` + +### Seam and Sharp Edge Primvars (index-space) + +```python +from pxr import UsdGeom, Vt, Sdf + +primvars_api = UsdGeom.PrimvarsAPI(mesh) + +# Sharp edges: list of (vertex_index_0, vertex_index_1) pairs +sharp_pairs = [(vi0, vi1), (vi2, vi3), ...] # local mesh vertex indices +sharp_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in sharp_pairs]) +pv_sharp = primvars_api.CreatePrimvar( + "schaeffler:sharpEdgeVertexPairs", + Sdf.ValueTypeNames.Int2Array, + UsdGeom.Tokens.constant, +) +pv_sharp.Set(sharp_array) + +# Seam edges: same structure +seam_pairs = [(vi0, vi1), ...] +seam_array = Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in seam_pairs]) +pv_seam = primvars_api.CreatePrimvar( + "schaeffler:seamEdgeVertexPairs", + Sdf.ValueTypeNames.Int2Array, + UsdGeom.Tokens.constant, +) +pv_seam.Set(seam_array) +``` + +### Material Binding + +```python +from pxr import UsdShade + +# Define material prim +mat_path = f"/Root/Looks/{canonical_material_name}" +material = UsdShade.Material.Define(stage, mat_path) + +# Bind material to part mesh +binding_api = UsdShade.MaterialBindingAPI(mesh.GetPrim()) +binding_api.Bind(material) +``` + +### Override Layer (for material replacements) + +```python +# Create canonical stage +canonical_stage = Usd.Stage.CreateNew("/path/to/canonical.usd") +# ... author geometry ... +canonical_stage.Save() + +# Create override layer for material assignments +override_layer = Sdf.Layer.CreateNew("/path/to/overrides.usd") +override_stage = Usd.Stage.Open("/path/to/canonical.usd") +override_stage.GetRootLayer().subLayerPaths.append("/path/to/overrides.usd") + +# Author override opinions +with Usd.EditContext(override_stage, override_stage.GetRootLayer()): + prim = override_stage.GetPrimAtPath("/Root/Assembly/Node/ring_outer") + prim.SetCustomDataByKey("schaeffler:canonicalMaterialName", "SCHAEFFLER_010102_Steel-Polished") + +override_stage.Save() + +# Flatten for delivery (preserves instanceable prims) +flat_stage = Usd.Stage.CreateNew("/path/to/delivery.usd") +UsdUtils.FlattenLayerStack(override_stage, flat_stage.GetRootLayer()) +flat_stage.Save() +``` + +### Reading Primvars in Blender (import_usd.py) + +```python +import bpy + +# After USD import: +for obj in bpy.context.scene.objects: + if obj.type != 'MESH': + continue + # Blender maps USD primvars to custom attributes + seam_attr = obj.data.attributes.get("schaeffler:seamEdgeVertexPairs") + sharp_attr = obj.data.attributes.get("schaeffler:sharpEdgeVertexPairs") + if seam_attr: + _mark_seams_from_index_pairs(obj, seam_attr.data) + if sharp_attr: + _mark_sharp_from_index_pairs(obj, sharp_attr.data) +``` + +## XCAF Traversal Pattern for Part Keys + +```python +from OCP.XCAFDoc import XCAFDoc_ShapeTool, XCAFDoc_DocumentTool +from OCP.TDF import TDF_LabelSequence +from OCP.TDataStd import TDataStd_Name + +def traverse_xcaf(label, shape_tool, depth=0, path=""): + """Yield (label, part_key, source_name, xcaf_path) for each leaf shape.""" + name_attr = TDataStd_Name() + source_name = "" + if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): + source_name = name_attr.Get().ToExtString() + + components = TDF_LabelSequence() + XCAFDoc_ShapeTool.GetComponents_s(label, components) + + xcaf_path = f"{path}/{source_name}" if source_name else f"{path}/unnamed_{depth}" + + if components.Length() == 0: + # Leaf node + part_key = generate_part_key(xcaf_path, source_name) + yield label, part_key, source_name, xcaf_path + else: + for i in range(1, components.Length() + 1): + yield from traverse_xcaf(components.Value(i), shape_tool, depth+1, xcaf_path) +``` + +## Part Key Generation + +```python +import re, hashlib + +def generate_part_key(xcaf_path: str, source_name: str, seen: set) -> str: + """Deterministic slug, max 64 chars, unique within assembly.""" + base = source_name or xcaf_path.split("/")[-1] or "part" + # Normalize: lowercase, replace non-alphanumeric with underscore + slug = re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_') + slug = slug[:50] or f"part_{hashlib.sha256(xcaf_path.encode()).hexdigest()[:8]}" + # Deduplicate + key = slug + n = 2 + while key in seen: + key = f"{slug}_{n}" + n += 1 + seen.add(key) + return key +``` + +## Coordinate System + +OCC STEP files are in **mm, Z-up**. USD stage is set to **mm, Y-up** (matching Blender and glTF convention). + +Transform when writing points: +```python +# OCC (X, Y, Z) mm Z-up → USD (X, -Z, Y) mm Y-up +usd_x = occ_x +usd_y = -occ_z +usd_z = occ_y +``` + +The `BRepBuilderAPI_Transform` mm→m scaling done in `export_step_to_gltf.py` is **not needed** here — USD stage metadata carries `metersPerUnit=0.001`, so Blender handles the scale on import. + +## CLI Interface for `export_step_to_usd.py` + +```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] \ + [--tessellation_engine occ|gmsh] \ + [--color_map '{"Ring": "#4C9BE8"}'] \ + [--cad_file_id uuid] +``` + +## Test Commands + +```bash +# Verify usd-core is installed +docker compose exec render-worker python3 -c "from pxr import Usd; print(Usd.GetVersion())" + +# Run USD exporter +docker compose exec render-worker python3 /render-scripts/export_step_to_usd.py \ + --step_path /app/uploads/[cad_file_id]/[filename].stp \ + --output_path /tmp/test.usd + +# Inspect output +docker compose exec render-worker python3 -c " +from pxr import Usd +stage = Usd.Stage.Open('/tmp/test.usd') +for prim in stage.Traverse(): + if prim.GetTypeName() == 'Mesh': + print(prim.GetPath(), '| partKey:', prim.GetCustomDataByKey('schaeffler:partKey')) +" + +# Count parts with partKey +docker compose exec render-worker python3 -c " +from pxr import Usd +stage = Usd.Stage.Open('/tmp/test.usd') +parts = [p for p in stage.Traverse() if p.GetCustomDataByKey('schaeffler:partKey')] +print(f'{len(parts)} parts with partKey') +" +``` + +## Common Errors and Fixes + +| Error | Cause | Fix | +|---|---|---| +| `ModuleNotFoundError: pxr` | usd-core not installed | `docker compose build render-worker` after adding to Dockerfile | +| `ValueError: Invalid SdfPath` | Part name contains special chars | Use `generate_part_key()` — strips non-alphanumeric | +| `RuntimeError: stage is invalid` | File path doesn't exist | Ensure output directory exists before `Stage.CreateNew()` | +| Prim hierarchy too deep | Assembly has > 10 levels | Use relative paths from Assembly root, skip empty intermediate nodes | +| Blender doesn't read primvars | Attribute name mismatch | Check USD attribute name matches what Blender's USD importer maps | +| Seam pairs reference wrong vertices | Index computed before tessellation | Always compute index-space pairs AFTER BRepMesh, on the final triangulation | + +## Failure Protocol + +If a task fails, add `[BLOCKED]` to the task in `plan.md` with: +- exact pxr error message +- which XCAF label or mesh caused it +- what was attempted + +Then invoke `/plan` for a refined task specification. + +## Completion + +After implementation: "USD export complete. Test with: `python3 export_step_to_usd.py --step_path [file]`. Please verify with `/review`."