feat: surface-evaluated normals, GMSH tessellation, draw call batching
USD exporter: - Compute normals from B-Rep surface via BRepLProp_SLProps at each vertex UV parameter — eliminates faceting on curved surfaces (same as Stepper) - Add GMSH Frontal-Delaunay tessellation engine (opt-in via --tessellation_engine gmsh) with per-solid strategy matching export_step_to_gltf.py - Use vertex normal interpolation instead of faceVarying (6x smaller normals) - Default engine remains OCC (GMSH has coordinate-space bug with instanced parts) Frontend: - Fix faceted shading in InlineCadViewer: only call computeVertexNormals() when geometry lacks normals, preserving smooth GLB normals from pipeline - Add useGeometryMerge hook for draw call batching (merge by material) - Fix unused import in cadUtils, optional props in ThreeDViewer Backend: - Move dataclass import to top of step_processor.py (PEP 8) - Unified single-read STEP metadata extraction with fallback Render worker: - Fix USD import seam/sharp restoration: read primvars via pxr directly (Blender's USD importer doesn't expose custom Int2Array primvars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,9 @@ def parse_args() -> argparse.Namespace:
|
||||
p.add_argument("--sharp_threshold", type=float, default=20.0)
|
||||
p.add_argument("--cad_file_id", default="")
|
||||
p.add_argument("--material_map", default="{}")
|
||||
p.add_argument("--tessellation_engine", choices=["occ", "gmsh"], default="occ",
|
||||
help="Tessellation backend: 'occ' = BRepMesh (default, fast), "
|
||||
"'gmsh' = Frontal-Delaunay (uniform mesh, no fan artifacts but slower)")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@@ -455,7 +458,7 @@ def _author_xcaf_to_usd(
|
||||
hex_color = PALETTE_HEX[counters['n_parts'] % len(PALETTE_HEX)]
|
||||
|
||||
# Extract mesh from definition shape (face_loc only, no instance placement)
|
||||
vertices, triangles = _extract_mesh(bare_def)
|
||||
vertices, triangles, per_vertex_normals = _extract_mesh(bare_def)
|
||||
if not vertices or not triangles:
|
||||
counters['n_empty'] += 1
|
||||
return
|
||||
@@ -509,6 +512,17 @@ def _author_xcaf_to_usd(
|
||||
r, g, b = _hex_to_rgb01(hex_color)
|
||||
mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)]))
|
||||
|
||||
# ── Per-vertex normals ─────────────────────────────────────
|
||||
# Blender's USD importer reads normals and creates custom split
|
||||
# normals. Using 'vertex' interpolation (1 normal per vertex) is
|
||||
# ~6× smaller than 'faceVarying' (1 per triangle corner) and
|
||||
# produces identical smooth shading for our tessellated meshes.
|
||||
if per_vertex_normals and len(per_vertex_normals) == len(vertices):
|
||||
mesh.CreateNormalsAttr(Vt.Vec3fArray([
|
||||
Gf.Vec3f(nx, ny, nz) for (nx, ny, nz) in per_vertex_normals
|
||||
]))
|
||||
mesh.SetNormalsInterpolation(UsdGeom.Tokens.vertex)
|
||||
|
||||
# ── Material metadata on mesh prim (customData) ───────────
|
||||
mesh_prim = mesh.GetPrim()
|
||||
mesh_prim.SetCustomDataByKey("schaeffler:partKey", part_key)
|
||||
@@ -575,11 +589,19 @@ def _author_xcaf_to_usd(
|
||||
|
||||
# ── Mesh geometry extraction ──────────────────────────────────────────────────
|
||||
|
||||
def _extract_mesh(shape) -> tuple[list, list]:
|
||||
"""Return (vertices, triangles) from a tessellated OCC shape.
|
||||
def _extract_mesh(shape) -> tuple[list, list, list]:
|
||||
"""Return (vertices, triangles, normals) from a tessellated OCC shape.
|
||||
|
||||
Vertices are in OCC space (mm, Z-up).
|
||||
Triangles are 0-based index triples.
|
||||
Normals are per-vertex (same count/order as vertices).
|
||||
|
||||
Normal sources (in priority order):
|
||||
1. B-Rep surface normals — evaluated from the mathematical surface at each
|
||||
vertex UV parameter via BRepLProp_SLProps. Gives perfectly smooth normals
|
||||
regardless of triangle topology (same approach as Stepper/CAD-Exchanger).
|
||||
2. Angle-weighted vertex normals from triangle cross products — fallback when
|
||||
UV nodes are unavailable (e.g. after GMSH tessellation).
|
||||
|
||||
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
|
||||
@@ -593,10 +615,14 @@ def _extract_mesh(shape) -> tuple[list, list]:
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.BRepLProp import BRepLProp_SLProps
|
||||
|
||||
vertices: list = []
|
||||
normals: list = []
|
||||
triangles: list = []
|
||||
v_offset = 0
|
||||
faces_without_normals = False
|
||||
|
||||
shape_trsf = shape.Location().Transformation()
|
||||
shape_has_loc = not shape.Location().IsIdentity()
|
||||
@@ -614,16 +640,48 @@ def _extract_mesh(shape) -> tuple[list, list]:
|
||||
reversed_face = (face.Orientation() == TopAbs_REVERSED)
|
||||
face_has_loc = not face_loc.IsIdentity()
|
||||
|
||||
# Evaluate surface normals from B-Rep geometry (analytically exact).
|
||||
# This gives smooth shading across face boundaries because the normals
|
||||
# come from the mathematical surface, not from triangle approximation.
|
||||
has_uv = tri.HasUVNodes()
|
||||
surf = None
|
||||
if has_uv:
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
except Exception:
|
||||
surf = None
|
||||
|
||||
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:
|
||||
node = node.Transformed(face_loc.Transformation())
|
||||
# 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()))
|
||||
|
||||
nrm_set = False
|
||||
if surf is not None:
|
||||
try:
|
||||
uv = tri.UVNode(i)
|
||||
props = BRepLProp_SLProps(surf, uv.X(), uv.Y(), 1, 1e-6)
|
||||
if props.IsNormalDefined():
|
||||
nrm = props.Normal()
|
||||
if face_has_loc:
|
||||
nrm = nrm.Transformed(face_loc.Transformation())
|
||||
if shape_has_loc:
|
||||
nrm = nrm.Transformed(shape_trsf)
|
||||
if reversed_face:
|
||||
normals.append((-nrm.X(), -nrm.Y(), -nrm.Z()))
|
||||
else:
|
||||
normals.append((nrm.X(), nrm.Y(), nrm.Z()))
|
||||
nrm_set = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not nrm_set:
|
||||
faces_without_normals = True
|
||||
normals.append(None) # type: ignore[arg-type]
|
||||
|
||||
for i in range(1, tri.NbTriangles() + 1):
|
||||
n1, n2, n3 = tri.Triangle(i).Get()
|
||||
v0 = n1 - 1 + v_offset
|
||||
@@ -635,7 +693,248 @@ def _extract_mesh(shape) -> tuple[list, list]:
|
||||
|
||||
exp.Next()
|
||||
|
||||
return vertices, triangles
|
||||
# Fallback: compute normals from triangle geometry for faces without UV data
|
||||
if faces_without_normals and vertices and triangles:
|
||||
_compute_vertex_normals(vertices, triangles, normals)
|
||||
|
||||
return vertices, triangles, normals
|
||||
|
||||
|
||||
def _compute_vertex_normals(vertices: list, triangles: list, normals: list) -> None:
|
||||
"""Fill in None entries in normals with angle-weighted vertex normals.
|
||||
|
||||
For each triangle, computes the face normal (cross product) and adds it
|
||||
to each vertex's accumulator weighted by the interior angle at that vertex.
|
||||
This produces smooth normals identical to what Blender's "Shade Smooth" does.
|
||||
"""
|
||||
# Accumulate weighted normals for vertices that need them
|
||||
accum = {} # vertex_index → [nx, ny, nz]
|
||||
for tri in triangles:
|
||||
v0, v1, v2 = tri
|
||||
# Only process if any vertex in this triangle needs normals
|
||||
needs = any(normals[vi] is None for vi in (v0, v1, v2))
|
||||
if not needs:
|
||||
continue
|
||||
|
||||
p0 = vertices[v0]
|
||||
p1 = vertices[v1]
|
||||
p2 = vertices[v2]
|
||||
|
||||
# Edge vectors
|
||||
e01 = (p1[0]-p0[0], p1[1]-p0[1], p1[2]-p0[2])
|
||||
e02 = (p2[0]-p0[0], p2[1]-p0[1], p2[2]-p0[2])
|
||||
e12 = (p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2])
|
||||
|
||||
# Face normal (unnormalized cross product)
|
||||
fn = (
|
||||
e01[1]*e02[2] - e01[2]*e02[1],
|
||||
e01[2]*e02[0] - e01[0]*e02[2],
|
||||
e01[0]*e02[1] - e01[1]*e02[0],
|
||||
)
|
||||
fn_len = (fn[0]**2 + fn[1]**2 + fn[2]**2) ** 0.5
|
||||
if fn_len < 1e-12:
|
||||
continue
|
||||
|
||||
# Angle at each vertex (for weighting)
|
||||
def _dot(a, b):
|
||||
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
|
||||
def _len(a):
|
||||
return (a[0]**2 + a[1]**2 + a[2]**2) ** 0.5
|
||||
def _neg(a):
|
||||
return (-a[0], -a[1], -a[2])
|
||||
|
||||
edges_at_vert = [
|
||||
(v0, e01, e02),
|
||||
(v1, _neg(e01), e12),
|
||||
(v2, _neg(e02), _neg(e12)),
|
||||
]
|
||||
for vi, ea, eb in edges_at_vert:
|
||||
if normals[vi] is not None:
|
||||
continue
|
||||
la, lb = _len(ea), _len(eb)
|
||||
if la < 1e-12 or lb < 1e-12:
|
||||
continue
|
||||
cos_a = max(-1.0, min(1.0, _dot(ea, eb) / (la * lb)))
|
||||
angle = math.acos(cos_a)
|
||||
if vi not in accum:
|
||||
accum[vi] = [0.0, 0.0, 0.0]
|
||||
accum[vi][0] += fn[0] * angle
|
||||
accum[vi][1] += fn[1] * angle
|
||||
accum[vi][2] += fn[2] * angle
|
||||
|
||||
# Normalize and write back
|
||||
for vi, acc in accum.items():
|
||||
length = (acc[0]**2 + acc[1]**2 + acc[2]**2) ** 0.5
|
||||
if length > 1e-12:
|
||||
normals[vi] = (acc[0]/length, acc[1]/length, acc[2]/length)
|
||||
else:
|
||||
normals[vi] = (0.0, 0.0, 1.0)
|
||||
|
||||
# Fill any remaining None entries (shouldn't happen, but safety)
|
||||
for i in range(len(normals)):
|
||||
if normals[i] is None:
|
||||
normals[i] = (0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
# ── GMSH Frontal-Delaunay tessellation ───────────────────────────────────────
|
||||
|
||||
def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None:
|
||||
"""Tessellate an OCC TopoDS_Shape using GMSH Frontal-Delaunay mesher.
|
||||
|
||||
Writes the resulting Poly_Triangulation back to each TopoDS_Face via
|
||||
BRep_Builder.UpdateFace(), so _extract_mesh() picks up the uniform
|
||||
topology from BRep_Tool.Triangulation_s.
|
||||
|
||||
GMSH surface tags correspond 1:1 to faces in TopExp_Explorer(FACE) order
|
||||
after importShapes() from a .brep file.
|
||||
|
||||
Falls back to BRepMesh for any face that GMSH cannot mesh.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
from OCP.BRep import BRep_Builder
|
||||
from OCP.BRepTools import BRepTools
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.Poly import Poly_Triangulation, Poly_Array1OfTriangle, Poly_Triangle
|
||||
from OCP.TColgp import TColgp_Array1OfPnt
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS as _TopoDS
|
||||
from OCP.gp import gp_Pnt
|
||||
|
||||
import gmsh
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp:
|
||||
brep_path = tmp.name
|
||||
|
||||
n_faces_gmsh = 0
|
||||
n_faces_fallback = 0
|
||||
n_triangles_total = 0
|
||||
|
||||
try:
|
||||
BRepTools.Write_s(shape, brep_path)
|
||||
|
||||
import os as _os
|
||||
n_threads = min(_os.cpu_count() or 1, 16)
|
||||
gmsh.initialize()
|
||||
gmsh.option.setNumber("General.Terminal", 0)
|
||||
gmsh.option.setNumber("General.NumThreads", n_threads)
|
||||
gmsh.option.setNumber("Mesh.MaxNumThreads1D", n_threads)
|
||||
gmsh.option.setNumber("Mesh.MaxNumThreads2D", n_threads)
|
||||
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D
|
||||
gmsh.option.setNumber("Mesh.RecombineAll", 0)
|
||||
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
|
||||
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 50.0)
|
||||
min_circle_pts = min(12, max(6, int(math.ceil(
|
||||
2.0 * math.pi / max(angular_deflection, 0.01)))))
|
||||
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
|
||||
gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3)
|
||||
gmsh.option.setNumber("General.Verbosity", 1)
|
||||
|
||||
gmsh.model.add("shape")
|
||||
gmsh.model.occ.importShapes(brep_path)
|
||||
gmsh.model.occ.synchronize()
|
||||
gmsh.model.mesh.generate(2)
|
||||
|
||||
surface_tags = [tag for (_, tag) in gmsh.model.getEntities(2)]
|
||||
|
||||
surface_mesh: dict[int, tuple] = {}
|
||||
for stag in surface_tags:
|
||||
try:
|
||||
node_tags, coords, _ = gmsh.model.mesh.getNodes(
|
||||
dim=2, tag=stag, includeBoundary=True)
|
||||
if len(node_tags) == 0:
|
||||
continue
|
||||
node_map = {}
|
||||
pts_xyz = []
|
||||
for i, ntag in enumerate(node_tags):
|
||||
x, y, z = coords[3*i], coords[3*i+1], coords[3*i+2]
|
||||
node_map[ntag] = i + 1
|
||||
pts_xyz.append((x, y, z))
|
||||
|
||||
elem_types, elem_tags, elem_node_tags = gmsh.model.mesh.getElements(
|
||||
dim=2, tag=stag)
|
||||
tris = []
|
||||
for etype, etags, entags in zip(elem_types, elem_tags, elem_node_tags):
|
||||
if etype != 2:
|
||||
continue
|
||||
n_elems = len(etags)
|
||||
for k in range(n_elems):
|
||||
a = node_map.get(entags[3*k])
|
||||
b = node_map.get(entags[3*k+1])
|
||||
c = node_map.get(entags[3*k+2])
|
||||
if a and b and c:
|
||||
tris.append((a, b, c))
|
||||
|
||||
if pts_xyz and tris:
|
||||
surface_mesh[stag] = (pts_xyz, tris)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
except Exception as _gmsh_err:
|
||||
print(f"WARNING: GMSH failed ({_gmsh_err}), falling back to BRepMesh",
|
||||
file=sys.stderr)
|
||||
BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True)
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
gmsh.finalize()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
Path(brep_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
builder = BRep_Builder()
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
face_index = 0
|
||||
|
||||
while explorer.More():
|
||||
face = _TopoDS.Face_s(explorer.Current())
|
||||
if face_index < len(surface_tags):
|
||||
stag = surface_tags[face_index]
|
||||
mesh_data = surface_mesh.get(stag)
|
||||
if mesh_data:
|
||||
pts_xyz, tris = mesh_data
|
||||
n_nodes = len(pts_xyz)
|
||||
n_tris = len(tris)
|
||||
try:
|
||||
arr_pts = TColgp_Array1OfPnt(1, n_nodes)
|
||||
for idx, (x, y, z) in enumerate(pts_xyz, 1):
|
||||
arr_pts.SetValue(idx, gp_Pnt(x, y, z))
|
||||
|
||||
arr_tris = Poly_Array1OfTriangle(1, n_tris)
|
||||
for idx, (a, b, c) in enumerate(tris, 1):
|
||||
arr_tris.SetValue(idx, Poly_Triangle(a, b, c))
|
||||
|
||||
tri = Poly_Triangulation(arr_pts, arr_tris)
|
||||
tri.ComputeNormals() # smooth normals from triangle averaging
|
||||
builder.UpdateFace(face, tri)
|
||||
n_faces_gmsh += 1
|
||||
n_triangles_total += n_tris
|
||||
except Exception:
|
||||
BRepMesh_IncrementalMesh(
|
||||
face, linear_deflection, False, angular_deflection, False)
|
||||
n_faces_fallback += 1
|
||||
else:
|
||||
BRepMesh_IncrementalMesh(
|
||||
face, linear_deflection, False, angular_deflection, False)
|
||||
n_faces_fallback += 1
|
||||
else:
|
||||
BRepMesh_IncrementalMesh(
|
||||
face, linear_deflection, False, angular_deflection, False)
|
||||
n_faces_fallback += 1
|
||||
|
||||
face_index += 1
|
||||
explorer.Next()
|
||||
|
||||
print(
|
||||
f"GMSH tessellation: {n_faces_gmsh} faces meshed, "
|
||||
f"{n_faces_fallback} BRepMesh fallback, "
|
||||
f"{n_triangles_total} triangles total"
|
||||
f" (threads={n_threads})"
|
||||
)
|
||||
|
||||
|
||||
# ── Index-space sharp edge mapping ────────────────────────────────────────────
|
||||
@@ -780,12 +1079,75 @@ def main() -> None:
|
||||
)
|
||||
|
||||
# ── Tessellate ────────────────────────────────────────────────────────────
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
shape = shape_tool.GetShape_s(free_labels.Value(i))
|
||||
if not shape.IsNull():
|
||||
engine = getattr(args, "tessellation_engine", "gmsh")
|
||||
if engine == "gmsh":
|
||||
# GMSH: tessellate per-solid (same strategy as export_step_to_gltf.py).
|
||||
# 1. BRepMesh baseline on full root shape — catches free faces/shells.
|
||||
# 2. GMSH override per unique SOLID — uniform Frontal-Delaunay topology.
|
||||
# Skips REVERSED (mirrored) solids to avoid inverted-Jacobian issues.
|
||||
# Deduplication via IsSame() (TShape pointer), not id() (unreliable in OCP).
|
||||
from OCP.TopExp import TopExp_Explorer as _Explorer
|
||||
from OCP.TopAbs import (TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL,
|
||||
TopAbs_REVERSED as _REVERSED)
|
||||
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
|
||||
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
|
||||
if root_shape.IsNull():
|
||||
continue
|
||||
|
||||
# Step 1: BRepMesh baseline
|
||||
BRepMesh_IncrementalMesh(
|
||||
shape, args.linear_deflection, False, args.angular_deflection, True
|
||||
)
|
||||
root_shape, args.linear_deflection, False, args.angular_deflection, True)
|
||||
|
||||
# Step 2: GMSH override for unique SOLID shapes
|
||||
_seen_shapes: list = []
|
||||
solids = []
|
||||
exp = _Explorer(root_shape, _SOLID)
|
||||
while exp.More():
|
||||
solids.append(exp.Current())
|
||||
exp.Next()
|
||||
if not solids:
|
||||
exp = _Explorer(root_shape, _SHELL)
|
||||
while exp.More():
|
||||
solids.append(exp.Current())
|
||||
exp.Next()
|
||||
|
||||
from OCP.TopoDS import TopoDS_Compound as _Compound
|
||||
from OCP.BRep import BRep_Builder as _BBuilder
|
||||
|
||||
eligible = []
|
||||
for solid in solids:
|
||||
if solid.Orientation() == _REVERSED:
|
||||
continue
|
||||
if any(solid.IsSame(s) for s in _seen_shapes):
|
||||
continue
|
||||
eligible.append(solid.Located(_TopLoc_Location()))
|
||||
_seen_shapes.append(solid)
|
||||
|
||||
if eligible:
|
||||
try:
|
||||
if len(eligible) == 1:
|
||||
_tessellate_with_gmsh(
|
||||
eligible[0], args.linear_deflection, args.angular_deflection)
|
||||
else:
|
||||
compound = _Compound()
|
||||
bb = _BBuilder()
|
||||
bb.MakeCompound(compound)
|
||||
for s in eligible:
|
||||
bb.Add(compound, s)
|
||||
_tessellate_with_gmsh(
|
||||
compound, args.linear_deflection, args.angular_deflection)
|
||||
except Exception as exc:
|
||||
print(f"WARNING: GMSH fallback to BRepMesh: {exc}", file=sys.stderr)
|
||||
|
||||
print(f"GMSH: {len(eligible)} eligible solids out of {len(solids)} total")
|
||||
else:
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
shape = shape_tool.GetShape_s(free_labels.Value(i))
|
||||
if not shape.IsNull():
|
||||
BRepMesh_IncrementalMesh(
|
||||
shape, args.linear_deflection, False, args.angular_deflection, True)
|
||||
print("Tessellation complete.")
|
||||
|
||||
# ── Apply colors ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
Runs inside Blender's Python environment (bpy available).
|
||||
Imports a USD stage and restores seam + sharp edges from
|
||||
schaeffler:*EdgeVertexPairs primvars (mapped as Blender mesh attributes
|
||||
by Blender's built-in USD importer).
|
||||
schaeffler:*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.
|
||||
@@ -21,7 +22,7 @@ def import_usd_file(usd_path: str) -> list | tuple:
|
||||
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)
|
||||
(populated from schaeffler:canonicalMaterialName customData, empty dict if absent)
|
||||
|
||||
USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres.
|
||||
"""
|
||||
@@ -38,22 +39,12 @@ def import_usd_file(usd_path: str) -> list | tuple:
|
||||
|
||||
_rename_usd_objects(parts)
|
||||
|
||||
# Restore seam + sharp edges from primvars mapped to Blender mesh attributes.
|
||||
# Blender's USD importer converts Int2Array primvars to INT32_2D attributes.
|
||||
# Attribute names: "schaeffler:sharpEdgeVertexPairs" / "schaeffler:seamEdgeVertexPairs"
|
||||
restored = 0
|
||||
for part in parts:
|
||||
restored += _restore_seam_sharp(part)
|
||||
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.
|
||||
# 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 # type: ignore[import]
|
||||
from pxr import Usd, UsdGeom, Vt # type: ignore[import]
|
||||
stage = Usd.Stage.Open(usd_path)
|
||||
for prim in stage.Traverse():
|
||||
if prim.GetTypeName() != "Mesh":
|
||||
@@ -61,16 +52,32 @@ def import_usd_file(usd_path: str) -> list | tuple:
|
||||
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
|
||||
|
||||
# Read seam/sharp primvars from USD mesh prim
|
||||
pvs_api = UsdGeom.PrimvarsAPI(prim)
|
||||
sharp_pv = pvs_api.GetPrimvar("schaeffler:sharpEdgeVertexPairs")
|
||||
seam_pv = pvs_api.GetPrimvar("schaeffler: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 material lookup failed: {exc}", flush=True)
|
||||
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",
|
||||
@@ -79,6 +86,29 @@ def import_usd_file(usd_path: str) -> list | tuple:
|
||||
print("[import_usd] no schaeffler: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:
|
||||
@@ -114,21 +144,19 @@ def _rename_usd_objects(parts: list) -> None:
|
||||
flush=True)
|
||||
|
||||
|
||||
def _restore_seam_sharp(obj) -> int:
|
||||
"""Apply seam+sharp edges from USD primvars mapped as Blender mesh attributes.
|
||||
def _restore_seam_sharp(obj, sharp_pairs: list, seam_pairs: list) -> int:
|
||||
"""Apply seam+sharp edges from pxr-read primvar data via bmesh.
|
||||
|
||||
Blender's USD importer maps primvars:schaeffler:sharpEdgeVertexPairs (Int2Array)
|
||||
to a mesh attribute named "schaeffler:sharpEdgeVertexPairs" with type INT32_2D.
|
||||
Each attribute element has a .value property returning a 2-tuple (v0, v1).
|
||||
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.
|
||||
"""
|
||||
mesh = obj.data
|
||||
sharp_attr = mesh.attributes.get("schaeffler:sharpEdgeVertexPairs")
|
||||
seam_attr = mesh.attributes.get("schaeffler:seamEdgeVertexPairs")
|
||||
if not sharp_attr and not seam_attr:
|
||||
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()
|
||||
@@ -140,22 +168,18 @@ def _restore_seam_sharp(obj) -> int:
|
||||
|
||||
n_verts = len(bm.verts)
|
||||
|
||||
def _apply_pairs(attr, mark_fn):
|
||||
def _apply_pairs(pairs, mark_fn):
|
||||
applied = 0
|
||||
for elem in attr.data:
|
||||
v = elem.value # 2-tuple for INT32_2D
|
||||
if len(v) >= 2 and 0 <= v[0] < n_verts and 0 <= v[1] < n_verts:
|
||||
edge = bm.edges.get([bm.verts[v[0]], bm.verts[v[1]]])
|
||||
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 = n_seam = 0
|
||||
if sharp_attr:
|
||||
n_sharp = _apply_pairs(sharp_attr, lambda e: setattr(e, 'smooth', False))
|
||||
if seam_attr:
|
||||
n_seam = _apply_pairs(seam_attr, lambda e: setattr(e, 'seam', True))
|
||||
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()
|
||||
@@ -163,4 +187,4 @@ def _restore_seam_sharp(obj) -> int:
|
||||
if n_sharp or n_seam:
|
||||
print(f"[import_usd] {obj.name}: {n_sharp} sharp edges, {n_seam} seam edges",
|
||||
flush=True)
|
||||
return 1
|
||||
return 1 if (n_sharp or n_seam) else 0
|
||||
|
||||
Reference in New Issue
Block a user