# USD Export Agent You are a specialist for the USD pipeline in the HartOMat 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 (hartomat: namespace) part_prim = part_xform.GetPrim() part_prim.SetCustomDataByKey("hartomat:partKey", part_key) part_prim.SetCustomDataByKey("hartomat:sourceName", source_name) part_prim.SetCustomDataByKey("hartomat:sourceColor", hex_color) part_prim.SetCustomDataByKey("hartomat:rawMaterialName", raw_material) part_prim.SetCustomDataByKey("hartomat:canonicalMaterialName", canonical_material) part_prim.SetCustomDataByKey("hartomat:cadFileId", str(cad_file_id)) part_prim.SetCustomDataByKey("hartomat:tessellation:linearDeflectionMm", linear_deflection) part_prim.SetCustomDataByKey("hartomat: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( "hartomat: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( "hartomat: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("hartomat:canonicalMaterialName", "HARTOMAT_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("hartomat:seamEdgeVertexPairs") sharp_attr = obj.data.attributes.get("hartomat: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('hartomat: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('hartomat: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`."