feat(P3): add GMSH Frontal-Delaunay tessellation engine

Introduces GMSH as an alternative to OCC BRepMesh for STEP→GLB tessellation.
GMSH produces conforming meshes that eliminate fan triangles at cylinder seam
edges — a structural limitation of OCC BRepMesh that cannot be fixed via
deflection parameters.

Changes:
- render-worker/Dockerfile: install gmsh>=4.15.0 + libglu1-mesa + libxft2
- export_step_to_gltf.py: --tessellation_engine occ|gmsh CLI arg +
  _tessellate_with_gmsh() using BRep→GMSH→Poly_Triangulation write-back
- admin.py: tessellation_engine setting (SETTINGS_DEFAULTS, SettingsOut,
  SettingsUpdate, validation)
- export_glb.py: pass tessellation_engine to export_step_to_gltf.py CLI in
  both geometry and production GLB tasks
- Admin.tsx: radio button UI for OCC vs GMSH selection

Tested: 121 faces meshed, 0 BRepMesh fallback, 649K triangles on sample part.
Clean seam edges for UV unwrap — GMSH respects B-rep periodic face boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 19:17:26 +01:00
parent 9c6ae18b28
commit af320bcdc8
6 changed files with 236 additions and 17 deletions
+181 -9
View File
@@ -1,8 +1,8 @@
"""OCC-native STEP → GLB export script.
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
tessellates with BRepMesh, optionally applies per-part hex colors, and writes
a binary GLB in meters (Y-up, glTF convention).
tessellates with BRepMesh or GMSH Frontal-Delaunay, optionally applies
per-part hex colors, and writes a binary GLB in meters (Y-up, glTF convention).
No Blender required. Uses the same OCP bindings that cadquery ships with.
@@ -12,6 +12,7 @@ Usage:
--output_path /path/to/output.glb \
[--linear_deflection 0.1] \
[--angular_deflection 0.5] \
[--tessellation_engine occ|gmsh] \
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
Exit 0 on success, exit 1 on failure.
@@ -50,6 +51,10 @@ def parse_args() -> argparse.Namespace:
"--sharp_threshold", type=float, default=20.0,
help="Dihedral angle threshold (degrees) for sharp B-rep edge detection. Default 20.0",
)
parser.add_argument(
"--tessellation_engine", choices=["occ", "gmsh"], default="occ",
help="Tessellation backend: 'occ' = BRepMesh (default), 'gmsh' = Frontal-Delaunay",
)
return parser.parse_args()
@@ -259,6 +264,169 @@ def _extract_sharp_edge_pairs(shape, sharp_threshold_deg: float = 20.0) -> list:
return sharp_pairs
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 the shape's tessellation data is available
to RWGltf_CafWriter exactly like BRepMesh output.
GMSH surface tags correspond 1:1 to faces in TopExp_Explorer(FACE) order
after importShapes() from a .brep file — no coordinate-based matching needed.
Falls back silently to BRepMesh for any face that GMSH cannot mesh (degenerate
geometry, e.g. degenerate poles).
Args:
shape: tessellated in-place (OCC TopoDS_Shape)
linear_deflection: controls CharacteristicLengthMax (mm)
angular_deflection: controls minimum circle subdivision points (rad)
"""
import math as _math
import tempfile
import gmsh
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
# Write shape to temporary .brep for GMSH import
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)
gmsh.initialize()
gmsh.option.setNumber("General.Terminal", 0) # suppress console output
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D
gmsh.option.setNumber("Mesh.RecombineAll", 0) # keep triangles (no quads)
# CharacteristicLength controls edge length target in mm
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 3.0)
# Angular resolution via circle point count: 2π / angular_deflection
min_circle_pts = 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)
# Reduce noise from GMSH warnings
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)
# Build lookup: surface tag → list of (node_coords, triangle_node_tags)
# GMSH surface tags from importShapes correspond 1:1 to TopExp_Explorer(FACE) order
surface_tags = [tag for (_, tag) in gmsh.model.getEntities(2)]
# Collect per-surface mesh data
surface_mesh: dict[int, tuple] = {} # tag → (nodes_xyz, triangles)
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
# coords is flat [x0,y0,z0, x1,y1,z1, ...]
node_map = {} # gmsh tag → 1-based local index
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 # 1-based
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: # type 2 = triangle
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)
# Fallback: full BRepMesh on the whole shape
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
# Map GMSH surface data back to OCC faces via TopExp_Explorer (same order as importShapes)
builder = BRep_Builder()
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_index = 0 # 0-based → maps to surface_tags[face_index]
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)
builder.UpdateFace(face, tri)
n_faces_gmsh += 1
n_triangles_total += n_tris
except Exception as _e:
# Fallback for this face only
BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False)
n_faces_fallback += 1
else:
# No GMSH mesh for this surface → BRepMesh fallback for this face
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"
)
def _inject_glb_extras(glb_path: Path, extras: dict) -> None:
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
@@ -337,16 +505,20 @@ def main() -> None:
print(f"Found {free_labels.Length()} root shape(s), tessellating "
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
engine = getattr(args, "tessellation_engine", "occ")
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, # isRelative
args.angular_deflection,
True, # isInParallel
)
if engine == "gmsh":
_tessellate_with_gmsh(shape, args.linear_deflection, args.angular_deflection)
else:
BRepMesh_IncrementalMesh(
shape,
args.linear_deflection,
False, # isRelative
args.angular_deflection,
True, # isInParallel
)
# --- Extract sharp B-rep edge pairs (before mm→m scaling so coords are in mm) ---
# Collect all free shapes into one list for the extraction function.