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:
2026-03-13 15:14:23 +01:00
parent 6c5873d51f
commit 253f11a945
8 changed files with 977 additions and 166 deletions
+373 -11
View File
@@ -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 ──────────────────────────────────────────────────────