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:
@@ -85,7 +85,15 @@ def apply_rotation(parts: list, rx: float, ry: float, rz: float) -> None:
|
||||
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
|
||||
|
||||
|
||||
def import_usd_file(usd_path: str) -> list:
|
||||
"""Import USD stage into current Blender scene — delegates to import_usd module."""
|
||||
def import_usd_file(usd_path: str) -> tuple[list, dict]:
|
||||
"""Import USD stage into current Blender scene — delegates to import_usd module.
|
||||
|
||||
Returns (parts, material_lookup) where material_lookup maps
|
||||
blender_object_name → canonical SCHAEFFLER material name (from USD primvars).
|
||||
"""
|
||||
from import_usd import import_usd_file as _impl
|
||||
return _impl(usd_path)
|
||||
result = _impl(usd_path)
|
||||
# Backward compat: old import_usd returned just a list
|
||||
if isinstance(result, tuple):
|
||||
return result
|
||||
return result, {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ from _blender_materials import (
|
||||
assign_failed_material,
|
||||
build_mat_map_lower,
|
||||
apply_material_library,
|
||||
apply_material_library_direct,
|
||||
)
|
||||
from _blender_scene import (
|
||||
ensure_collection,
|
||||
@@ -43,8 +44,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
|
||||
lap_fn("template_load")
|
||||
|
||||
target_col = ensure_collection(args.target_collection)
|
||||
usd_material_lookup: dict = {}
|
||||
if args.usd_path:
|
||||
parts = import_usd_file(args.usd_path)
|
||||
parts, usd_material_lookup = import_usd_file(args.usd_path)
|
||||
else:
|
||||
parts = import_glb(args.glb_path)
|
||||
lap_fn("glb_import")
|
||||
@@ -63,7 +65,25 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
|
||||
apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||
lap_fn("smooth_shading")
|
||||
|
||||
if args.material_library_path and args.material_map:
|
||||
if args.material_library_path and usd_material_lookup:
|
||||
# USD primvar path: direct material assignment (no name-matching needed)
|
||||
apply_material_library_direct(
|
||||
parts, args.material_library_path, usd_material_lookup,
|
||||
)
|
||||
# Fall back to name-matching for any parts missing primvars
|
||||
if args.material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and
|
||||
p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
print(f"[blender_render] {len(_unassigned)} parts without USD primvar — "
|
||||
f"falling back to name-matching", flush=True)
|
||||
apply_material_library(
|
||||
_unassigned, args.material_library_path,
|
||||
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||
)
|
||||
elif args.material_library_path and args.material_map:
|
||||
apply_material_library(
|
||||
parts, args.material_library_path,
|
||||
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||
@@ -97,8 +117,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
|
||||
def _setup_mode_a(args) -> None:
|
||||
"""MODE A: Factory settings — auto-camera + auto-lights."""
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
usd_material_lookup: dict = {}
|
||||
if args.usd_path:
|
||||
parts = import_usd_file(args.usd_path)
|
||||
parts, usd_material_lookup = import_usd_file(args.usd_path)
|
||||
else:
|
||||
parts = import_glb(args.glb_path)
|
||||
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
|
||||
@@ -113,7 +134,23 @@ def _setup_mode_a(args) -> None:
|
||||
assign_failed_material(part)
|
||||
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t:.2f}s)", flush=True)
|
||||
|
||||
if args.material_library_path and args.material_map:
|
||||
if args.material_library_path and usd_material_lookup:
|
||||
# USD primvar path: direct material assignment
|
||||
apply_material_library_direct(
|
||||
parts, args.material_library_path, usd_material_lookup,
|
||||
)
|
||||
# Fall back to name-matching for parts without primvars
|
||||
if args.material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and
|
||||
p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
apply_material_library(
|
||||
_unassigned, args.material_library_path,
|
||||
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||
)
|
||||
elif args.material_library_path and args.material_map:
|
||||
apply_material_library(
|
||||
parts, args.material_library_path,
|
||||
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||
|
||||
@@ -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})}")
|
||||
|
||||
@@ -15,10 +15,14 @@ import bmesh # type: ignore[import]
|
||||
from mathutils import Vector # type: ignore[import]
|
||||
|
||||
|
||||
def import_usd_file(usd_path: str) -> list:
|
||||
def import_usd_file(usd_path: str) -> list | tuple:
|
||||
"""Import USD stage into current Blender scene.
|
||||
|
||||
Returns list of imported mesh objects, centred at world origin.
|
||||
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 schaeffler:canonicalMaterialName primvars, 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')
|
||||
@@ -43,6 +47,38 @@ def import_usd_file(usd_path: str) -> list:
|
||||
if restored:
|
||||
print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True)
|
||||
|
||||
# Extract material lookup via pxr direct read of the USD file.
|
||||
# Blender's USD importer does NOT expose STRING primvars or customData as
|
||||
# Python-accessible properties — but the pxr module (available in render-worker)
|
||||
# can read them perfectly from the same file.
|
||||
material_lookup: dict[str, str] = {}
|
||||
try:
|
||||
from pxr import Usd, UsdGeom # type: ignore[import]
|
||||
stage = Usd.Stage.Open(usd_path)
|
||||
for prim in stage.Traverse():
|
||||
if prim.GetTypeName() != "Mesh":
|
||||
continue
|
||||
part_key = prim.GetCustomDataByKey("schaeffler:partKey") or ""
|
||||
mat_name = prim.GetCustomDataByKey("schaeffler:canonicalMaterialName") or ""
|
||||
if not part_key or not mat_name:
|
||||
# Also check parent Xform prim (metadata may be on container)
|
||||
parent = prim.GetParent()
|
||||
if parent:
|
||||
part_key = part_key or (parent.GetCustomDataByKey("schaeffler:partKey") or "")
|
||||
mat_name = mat_name or (parent.GetCustomDataByKey("schaeffler:canonicalMaterialName") or "")
|
||||
if part_key and mat_name:
|
||||
# Blender object name = mesh prim leaf name (part_key)
|
||||
material_lookup[part_key] = mat_name
|
||||
except Exception as exc:
|
||||
print(f"[import_usd] WARNING: pxr material lookup 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 schaeffler:canonicalMaterialName metadata found (legacy USD)",
|
||||
flush=True)
|
||||
|
||||
# Centre combined bbox at world origin (same as import_glb convention)
|
||||
all_corners = []
|
||||
for p in parts:
|
||||
@@ -60,7 +96,7 @@ def import_usd_file(usd_path: str) -> list:
|
||||
for obj in root_objects:
|
||||
obj.location -= center
|
||||
|
||||
return parts
|
||||
return parts, material_lookup
|
||||
|
||||
|
||||
def _rename_usd_objects(parts: list) -> None:
|
||||
|
||||
Reference in New Issue
Block a user