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 ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user