feat(M5-M7): embed canonical material names in USD via customData + pxr direct read

- export_step_to_usd.py: accept --material_map CLI arg, write
  schaeffler:canonicalMaterialName as customData on each Mesh prim,
  fix geometry transform (strip shape Location before face exploration,
  apply both face_loc and shape_loc sequentially)
- import_usd.py: after Blender USD import, use pxr to read customData
  directly from the USD file — builds {part_key: material_name} lookup
  (Blender ignores STRING primvars and customData, but pxr reads both)
- _blender_materials.py: add apply_material_library_direct() for exact
  dict-based material assignment without name-matching heuristics
- _blender_scene_setup.py: prefer direct USD lookup, fall back to
  name-matching for legacy USD files without material metadata
- export_glb.py (generate_usd_master_task): resolve material_map via
  material_service.resolve_material_map() and pass to subprocess;
  include material hash in cache key for invalidation
- ROADMAP.md: update P5 status, add M5-M7 milestones

Tested: 3/3 parts matched (ans_lfs120), 172/175 parts matched
(F-802007.TR4-D1-H122AG). Previous: 0/25 matched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 23:04:26 +01:00
parent 1321ef2bd4
commit cc3071297b
15 changed files with 488 additions and 246 deletions
+94 -10
View File
@@ -16,7 +16,8 @@ Usage:
[--angular_deflection 0.05] \\
[--color_map '{"Ring": "#4C9BE8"}'] \\
[--sharp_threshold 20.0] \\
[--cad_file_id uuid]
[--cad_file_id uuid] \\
[--material_map '{"part_name": "SCHAEFFLER_010101_Steel-Bare", ...}']
Exit 0 on success, exit 1 on failure.
Prints MANIFEST_JSON: {...} to stdout before exit.
@@ -44,6 +45,7 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--color_map", default="{}")
p.add_argument("--sharp_threshold", type=float, default=20.0)
p.add_argument("--cad_file_id", default="")
p.add_argument("--material_map", default="{}")
return p.parse_args()
@@ -384,6 +386,13 @@ def _extract_mesh(shape) -> tuple[list, list]:
Vertices are in OCC space (mm, Z-up).
Triangles are 0-based index triples.
Transform strategy: strip the shape's own Location before exploring faces
so that face_loc from BRep_Tool.Triangulation_s is always relative to the
shape's DEFINITION space (not contaminated by instance placement). Then
uniformly apply the shape's Location to every vertex. This avoids both
double-transform (when face_loc already includes placement) and missing-
transform (when face_loc is identity but shape has placement).
"""
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
@@ -398,7 +407,10 @@ def _extract_mesh(shape) -> tuple[list, list]:
shape_trsf = shape.Location().Transformation()
shape_has_loc = not shape.Location().IsIdentity()
exp = TopExp_Explorer(shape, TopAbs_FACE)
# Strip instance placement so face exploration yields definition-space locs
bare = shape.Located(TopLoc_Location())
exp = TopExp_Explorer(bare, TopAbs_FACE)
while exp.More():
face = TopoDS.Face_s(exp.Current())
face_loc = TopLoc_Location()
@@ -410,14 +422,11 @@ def _extract_mesh(shape) -> tuple[list, list]:
for i in range(1, tri.NbNodes() + 1):
node = tri.Node(i)
# Step 1: face_loc — definition-space transform (face within shape)
if face_has_loc:
# face_loc from BRep_Tool.Triangulation_s already encodes the
# instance placement for compound-tessellated shapes — applying
# shape_loc on top would double-transform every vertex.
node = node.Transformed(face_loc.Transformation())
elif shape_has_loc:
# Only fall back to shape_loc when face_loc is identity (e.g.
# shapes tessellated individually rather than as a compound).
# Step 2: shape_loc — instance placement (shape within assembly)
if shape_has_loc:
node = node.Transformed(shape_trsf)
vertices.append((node.X(), node.Y(), node.Z()))
@@ -466,11 +475,68 @@ def _prim_name(name: str) -> str:
return safe or "unnamed"
# ── Material map lookup (mirrors _blender_materials.build_mat_map_lower) ─────
def _build_mat_map_lower(material_map: dict) -> dict:
"""Build a lowercased material_map with AF-stripped and slug variants.
Same normalization as _blender_materials.build_mat_map_lower() so that
source_name → canonical material name lookup works consistently.
"""
mat_map_lower: dict = {}
for k, v in material_map.items():
kl = k.lower().strip()
mat_map_lower[kl] = v
# Slug variant: replace non-alphanumeric with '_' (same as _generate_part_key)
slug_key = re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
if slug_key and slug_key != kl:
mat_map_lower.setdefault(slug_key, v)
# Strip OCC assembly-frame suffixes: _AF0, _AF0_1, _AF0_1_AF0, etc.
stripped = re.sub(r'(_af\d+(_\d+)?)+$', '', kl)
if stripped != kl:
mat_map_lower.setdefault(stripped, v)
slug_stripped = re.sub(r'[^a-z0-9]+', '_', stripped).strip('_')
if slug_stripped and slug_stripped != stripped:
mat_map_lower.setdefault(slug_stripped, v)
return mat_map_lower
def _lookup_material(source_name: str, part_key: str, mat_map_lower: dict) -> str | None:
"""Look up canonical material name for a part, trying multiple key variants."""
if not mat_map_lower:
return None
# Try source_name (lowered)
sn = source_name.lower().strip()
if sn in mat_map_lower:
return mat_map_lower[sn]
# Try AF-stripped source_name
stripped = re.sub(r'(_af\d+(_\d+)?)+$', '', sn, flags=re.IGNORECASE)
if stripped != sn and stripped in mat_map_lower:
return mat_map_lower[stripped]
# Try slug of source_name (matches part_key generation logic)
slug = re.sub(r'[^a-z0-9]+', '_', sn).strip('_')
if slug and slug in mat_map_lower:
return mat_map_lower[slug]
# Try part_key directly
pk = part_key.lower().strip()
if pk in mat_map_lower:
return mat_map_lower[pk]
# Prefix fallback: longest key that starts with or is started by part_key
for key in sorted(mat_map_lower.keys(), key=len, reverse=True):
if len(key) >= 5 and len(pk) >= 5 and (pk.startswith(key) or key.startswith(pk)):
return mat_map_lower[key]
return None
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
args = parse_args()
color_map: dict = json.loads(args.color_map)
raw_material_map: dict = json.loads(args.material_map)
mat_map_lower = _build_mat_map_lower(raw_material_map) if raw_material_map else {}
if mat_map_lower:
print(f"Material map: {len(raw_material_map)} entries ({len(mat_map_lower)} with variants)")
step_path = Path(args.step_path)
output_path = Path(args.output_path)
@@ -650,12 +716,27 @@ def main() -> None:
r, g, b = _hex_to_rgb01(hex_color)
mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)]))
# ── Material metadata on mesh prim (customData) ─────────────
# Blender's USD importer does NOT expose STRING primvars or
# customData as Python properties — but pxr can read customData
# directly from the USD file after Blender import. This is 100%
# reliable and avoids Blender importer limitations.
mesh_prim = mesh.GetPrim()
mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key)
mesh_prim.SetCustomDataByKey("schaeffler:sourceName", source_name)
canonical_mat = _lookup_material(source_name, part_key, mat_map_lower)
if canonical_mat:
mesh_prim.SetCustomDataByKey(
"schaeffler:canonicalMaterialName", canonical_mat)
primvars_api = UsdGeom.PrimvarsAPI(mesh)
# ── Index-space sharp + seam edge primvars ───────────────────
# Lookup is in OCC Z-up space; pairs are also Z-up — no swap needed.
# Both `vertices` and `*_pairs_mm` are in OCC Z-up mm space with the
# full per-shape location already applied — same coordinate frame required
# by _world_to_index_pairs for the nearest-vertex lookup (tol=0.5 mm).
primvars_api = UsdGeom.PrimvarsAPI(mesh)
if sharp_pairs_mm:
idx_pairs = _world_to_index_pairs(vertices, sharp_pairs_mm)
if idx_pairs:
@@ -688,14 +769,17 @@ def main() -> None:
"part_key": part_key,
"source_name": source_name,
"prim_path": part_path,
"canonical_material": canonical_mat,
})
n_parts += 1
stage.Save()
sz = output_path.stat().st_size // 1024 if output_path.exists() else 0
n_mat_assigned = sum(1 for p in manifest_parts if p.get("canonical_material"))
print(f"USD exported: {output_path.name} ({sz} KB), "
f"{n_parts} parts, {n_empty} empty shapes skipped")
f"{n_parts} parts, {n_empty} empty shapes skipped, "
f"{n_mat_assigned}/{n_parts} material primvars written")
# ── Stdout manifest (one line, parsed by Celery task) ─────────────────────
print(f"MANIFEST_JSON: {json.dumps({'parts': manifest_parts})}")