Files
HartOMat/render-worker/scripts/_blender_import.py
T
Hartmut cc3071297b 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>
2026-03-12 23:04:26 +01:00

100 lines
3.9 KiB
Python

"""GLB import and geometry helpers for Blender headless renders."""
from __future__ import annotations
import math
import sys
def import_glb(glb_file: str) -> list:
"""Import OCC-generated GLB into Blender.
OCC exports one mesh object per STEP part, already in metres.
Blender's native GLTF importer preserves part names.
Returns list of Blender mesh objects, centred at world origin.
"""
import bpy # type: ignore[import]
from mathutils import Vector # type: ignore[import]
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
if not parts:
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
# Remove OCC-baked custom normals so shade_smooth_by_angle can recompute
# normals from scratch (respecting our sharp edge marks).
cleared = 0
for p in parts:
if "custom_normal" in p.data.attributes:
p.data.attributes.remove(p.data.attributes["custom_normal"])
cleared += 1
if cleared:
print(f"[blender_render] cleared OCC custom_normal from {cleared} mesh objects")
# Centre combined bbox at world origin
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
# Move root objects (parentless) to centre. Adjusting a child's local
# .location by a world-space vector gives wrong results when the GLB has
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
# the entire hierarchy correctly.
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
def apply_rotation(parts: list, rx: float, ry: float, rz: float) -> None:
"""Apply Euler rotation (degrees, XYZ order) to all parts around world origin.
After import_glb the combined bbox center is at world origin,
so rotating around origin is equivalent to rotating around the assembly center.
"""
if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):
return
import bpy # type: ignore[import]
from mathutils import Euler # type: ignore[import]
rot_mat = Euler((math.radians(rx), math.radians(ry), math.radians(rz)), 'XYZ').to_matrix().to_4x4()
for p in parts:
p.matrix_world = rot_mat @ p.matrix_world
# Bake rotation into mesh data so camera bbox calculations see the rotated geometry
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
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
result = _impl(usd_path)
# Backward compat: old import_usd returned just a list
if isinstance(result, tuple):
return result
return result, {}