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:
@@ -45,17 +45,93 @@ def build_mat_map_lower(material_map: dict) -> dict:
|
||||
slug_key = _re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
|
||||
if slug_key and slug_key != kl:
|
||||
mat_map_lower.setdefault(slug_key, v)
|
||||
# _AF\d+ stripping for GLB object names
|
||||
stripped = kl
|
||||
prev = None
|
||||
while prev != stripped:
|
||||
prev = stripped
|
||||
stripped = _re.sub(r'_af\d+$', '', stripped)
|
||||
# Strip OCC assembly-frame suffixes: _AF0, _AF0_1, _AF0_1_AF0, etc.
|
||||
# Pattern matches one or more groups of _AF<n> optionally followed by
|
||||
# an instance number _<n>, anchored at end of string.
|
||||
stripped = _re.sub(r'(_af\d+(_\d+)?)+$', '', kl)
|
||||
if stripped != kl:
|
||||
mat_map_lower.setdefault(stripped, v)
|
||||
# Also slug the AF-stripped key for USD path where part_key is
|
||||
# both AF-stripped AND slugified (e.g. "ge360-hf_..." → "ge360_hf_...")
|
||||
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 apply_material_library_direct(
|
||||
parts: list,
|
||||
mat_lib_path: str,
|
||||
material_lookup: dict[str, str],
|
||||
) -> None:
|
||||
"""Assign materials from library using a direct object_name → material_name mapping.
|
||||
|
||||
This bypasses all name-matching heuristics — the mapping comes from USD
|
||||
customData (schaeffler:canonicalMaterialName) read via pxr after Blender import.
|
||||
Parts not present in material_lookup receive FAILED_MATERIAL_NAME.
|
||||
|
||||
material_lookup: {blender_object_name: canonical_material_name}
|
||||
"""
|
||||
if not mat_lib_path or not os.path.isfile(mat_lib_path):
|
||||
print(f"[blender_render] material library not found: {mat_lib_path}")
|
||||
return
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
# Collect unique material names needed
|
||||
needed = set(material_lookup.values())
|
||||
if not needed:
|
||||
return
|
||||
|
||||
# Append materials from library
|
||||
appended: dict = {}
|
||||
for mat_name in needed:
|
||||
if mat_name in bpy.data.materials:
|
||||
appended[mat_name] = bpy.data.materials[mat_name]
|
||||
continue
|
||||
inner_path = f"{mat_lib_path}/Material/{mat_name}"
|
||||
try:
|
||||
bpy.ops.wm.append(
|
||||
filepath=inner_path,
|
||||
directory=f"{mat_lib_path}/Material/",
|
||||
filename=mat_name,
|
||||
link=False,
|
||||
)
|
||||
if mat_name in bpy.data.materials:
|
||||
appended[mat_name] = bpy.data.materials[mat_name]
|
||||
print(f"[blender_render] appended material: {mat_name}")
|
||||
else:
|
||||
print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
|
||||
except Exception as exc:
|
||||
print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
|
||||
|
||||
if not appended:
|
||||
return
|
||||
|
||||
assigned_count = 0
|
||||
unmatched_names = []
|
||||
for part in parts:
|
||||
mat_name = material_lookup.get(part.name)
|
||||
if mat_name and mat_name in appended:
|
||||
if part.data.users > 1:
|
||||
part.data = part.data.copy()
|
||||
part.data.materials.clear()
|
||||
part.data.materials.append(appended[mat_name])
|
||||
assigned_count += 1
|
||||
else:
|
||||
unmatched_names.append(part.name)
|
||||
|
||||
print(f"[blender_render] direct material assignment (USD primvars): "
|
||||
f"{assigned_count}/{len(parts)} parts matched", flush=True)
|
||||
if unmatched_names:
|
||||
print(f"[blender_render] unmatched (no primvar): {unmatched_names[:10]}", flush=True)
|
||||
for part in parts:
|
||||
if part.name in set(unmatched_names):
|
||||
if part.data.users > 1:
|
||||
part.data = part.data.copy()
|
||||
assign_failed_material(part)
|
||||
|
||||
|
||||
def apply_material_library(
|
||||
parts: list,
|
||||
mat_lib_path: str,
|
||||
|
||||
Reference in New Issue
Block a user