"""USD import helper for Blender headless renders. Runs inside Blender's Python environment (bpy available). Imports a USD stage and restores seam + sharp edges from hartomat:*EdgeVertexPairs primvars. Blender's built-in USD importer does NOT map arbitrary custom primvars (constant Int2Array) to mesh attributes, so we read them directly via the pxr module and apply via bmesh. USD stage convention: mm Y-up, metersPerUnit=0.001. Blender's USD importer respects metersPerUnit and scales objects to metres. """ import sys import bpy # type: ignore[import] import bmesh # type: ignore[import] from mathutils import Vector # type: ignore[import] def import_usd_file(usd_path: str) -> list | tuple: """Import USD stage into current Blender scene. Returns a tuple of (parts, material_lookup) where: - parts: list of imported mesh objects, centred at world origin - material_lookup: dict mapping blender_object_name → canonical_material_name (populated from hartomat:canonicalMaterialName customData, empty dict if absent) USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres. """ bpy.ops.object.select_all(action='DESELECT') bpy.ops.wm.usd_import(filepath=usd_path) parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] if not parts: print(f"[import_usd] ERROR: No mesh objects imported from {usd_path}") sys.exit(1) print(f"[import_usd] imported {len(parts)} part(s) from USD: " f"{[p.name for p in parts[:5]]}", flush=True) _rename_usd_objects(parts) # Read primvars + customData directly via pxr (Blender's USD importer does # NOT expose custom primvars or customData as Python-accessible properties). material_lookup: dict[str, str] = {} edge_data: dict[str, dict] = {} # part_key → {sharp: [...], seam: [...]} try: from pxr import Usd, UsdGeom, Vt # type: ignore[import] stage = Usd.Stage.Open(usd_path) for prim in stage.Traverse(): if prim.GetTypeName() != "Mesh": continue part_key = prim.GetCustomDataByKey("hartomat:partKey") or "" mat_name = prim.GetCustomDataByKey("hartomat:canonicalMaterialName") or "" if not part_key or not mat_name: parent = prim.GetParent() if parent: part_key = part_key or (parent.GetCustomDataByKey("hartomat:partKey") or "") mat_name = mat_name or (parent.GetCustomDataByKey("hartomat:canonicalMaterialName") or "") if part_key and mat_name: material_lookup[part_key] = mat_name # Read seam/sharp primvars from USD mesh prim pvs_api = UsdGeom.PrimvarsAPI(prim) sharp_pv = pvs_api.GetPrimvar("hartomat:sharpEdgeVertexPairs") seam_pv = pvs_api.GetPrimvar("hartomat:seamEdgeVertexPairs") sharp_list = [] seam_list = [] if sharp_pv and sharp_pv.HasValue(): raw = sharp_pv.Get() if raw is not None: sharp_list = [(int(v[0]), int(v[1])) for v in raw] if seam_pv and seam_pv.HasValue(): raw = seam_pv.Get() if raw is not None: seam_list = [(int(v[0]), int(v[1])) for v in raw] if sharp_list or seam_list: # Use part_key as lookup key (matches Blender object name) edge_data[part_key] = {"sharp": sharp_list, "seam": seam_list} except Exception as exc: print(f"[import_usd] WARNING: pxr read failed: {exc}", flush=True) if material_lookup: print(f"[import_usd] pxr material lookup: {len(material_lookup)}/{len(parts)} parts", flush=True) else: print("[import_usd] no hartomat:canonicalMaterialName metadata found (legacy USD)", flush=True) if edge_data: print(f"[import_usd] pxr edge data: {len(edge_data)} parts with seam/sharp primvars", flush=True) # Apply seam + sharp edges to Blender meshes using pxr-read data restored = 0 for part in parts: # Match Blender object name to part_key. Blender may add .001 suffixes # for duplicate names, so try exact match first, then strip suffix. obj_name = part.name data = edge_data.get(obj_name) if data is None: # Try stripping Blender's .NNN duplicate suffix base = obj_name.rsplit('.', 1)[0] if '.' in obj_name else obj_name data = edge_data.get(base) if data: restored += _restore_seam_sharp(part, data["sharp"], data["seam"]) if restored: print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True) else: print("[import_usd] no seam/sharp edges restored (no primvar data or no matches)", flush=True) # Centre combined bbox at world origin (same as import_glb convention) all_corners = [] for p in parts: all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) if all_corners: mins = Vector((min(v.x for v in all_corners), min(v.y for v in all_corners), min(v.z for v in all_corners))) maxs = Vector((max(v.x for v in all_corners), max(v.y for v in all_corners), max(v.z for v in all_corners))) center = (mins + maxs) * 0.5 all_imported = list(bpy.context.selected_objects) root_objects = [o for o in all_imported if o.parent is None] for obj in root_objects: obj.location -= center return parts, material_lookup def _rename_usd_objects(parts: list) -> None: """No-op: mesh prims are now named after part_key in export_step_to_usd.py. Blender 5.0 collapses single-child Xform+Mesh into just the Mesh object, using the mesh prim leaf name as the Blender object name. Since the mesh prim is now named after the part_key (not "Mesh"), Blender imports each object with the correct part name, making material matching work directly. The object["usd:path"] custom property is NOT set by Blender's importer, so the previous path-based rename approach did not work. """ print(f"[import_usd] mesh objects named from USD prim paths: {[p.name for p in parts[:3]]!r}", flush=True) def _restore_seam_sharp(obj, sharp_pairs: list, seam_pairs: list) -> int: """Apply seam+sharp edges from pxr-read primvar data via bmesh. sharp_pairs / seam_pairs: list of (v0, v1) vertex index pairs read from the USD file via pxr.UsdGeom.PrimvarsAPI. Returns 1 if any edge data was applied, 0 otherwise. """ if not sharp_pairs and not seam_pairs: return 0 mesh = obj.data # Ensure single-user data block before bmesh edit if mesh.users > 1: obj.data = mesh.copy() mesh = obj.data bm = bmesh.new() bm.from_mesh(mesh) bm.verts.ensure_lookup_table() n_verts = len(bm.verts) def _apply_pairs(pairs, mark_fn): applied = 0 for v0, v1 in pairs: if 0 <= v0 < n_verts and 0 <= v1 < n_verts: edge = bm.edges.get([bm.verts[v0], bm.verts[v1]]) if edge: mark_fn(edge) applied += 1 return applied n_sharp = _apply_pairs(sharp_pairs, lambda e: setattr(e, 'smooth', False)) n_seam = _apply_pairs(seam_pairs, lambda e: setattr(e, 'seam', True)) bm.to_mesh(mesh) bm.free() if n_sharp or n_seam: print(f"[import_usd] {obj.name}: {n_sharp} sharp edges, {n_seam} seam edges", flush=True) return 1 if (n_sharp or n_seam) else 0