fix(gmsh): fix mirror instances + reduce mesh size to ≤120% of OCC

Bug 1 — Missing parts (mirror/repeated instances):
- id(solid.TShape()) is unreliable in OCP: each call creates a new
  Python wrapper, so id() always differs even for the same TShape.
  Replaced with IsSame() for correct TShape-pointer deduplication.
- TopExp_Explorer(SOLID) misses free shells/faces in assemblies.
  Fix: run BRepMesh baseline on full root compound first (catches all
  face types), then GMSH overrides per unique solid for better seam
  topology. REVERSED solids keep BRepMesh to avoid inverted Jacobians.

Bug 2 — GLB 7× too large (21 MB vs OCC 3 MB):
- CharacteristicLengthMax = linear_deflection × 50 (was ×15)
  matches OCC effective edge length on curved surfaces (~5 mm).
- MinimumCirclePoints = min(12, ...) (was min(20, ...))
- Result: GMSH 91% of OCC file size (target ≤120% ✓)

Verified on rolling bearing STEP: same 4 skipped nodes as OCC,
25 unique GMSH tessellations (IsSame deduplication), no OOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 21:12:03 +01:00
parent cd6c2f48e2
commit 638b93bb1e
3 changed files with 340 additions and 249 deletions
+48 -23
View File
@@ -316,16 +316,14 @@ def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: f
gmsh.option.setNumber("Mesh.MaxNumThreads2D", n_threads) # parallel surface meshing
gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D
gmsh.option.setNumber("Mesh.RecombineAll", 0) # keep triangles (no quads)
# CharacteristicLength is an edge LENGTH target, while OCC linear_deflection is a
# surface DEVIATION tolerance. On a 50mm radius cylinder, OCC with deflection=0.1mm
# produces ~1.4mm edge lengths; we scale by 15x to match density.
# MinimumCirclePoints caps are essential: without a cap, angular_deflection=0.1rad
# yields ceil(2π/0.1)=63 pts/circle which inflates mesh 10-20x vs OCC.
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 15.0)
# 1220 pts/circle produces smooth-looking cylinders and matches OCC density.
# Clamp below ceil(2π/angular_deflection) so angular quality is never degraded.
min_circle_pts = min(20, max(12, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))))
# CharacteristicLength is an edge LENGTH target; OCC linear_deflection is a surface
# DEVIATION tolerance. Empirically: OCC 0.1mm deflection on a 50mm cylinder produces
# ~5mm edge lengths. Scale by 50× to match OCC density (target ≤120% of OCC file size).
# MinimumCirclePoints: OCC angular_deflection=0.1rad → effectively ~12 uniform pts/circle.
# Cap at 12 to avoid GMSH generating 35× more edges than OCC on cylindrical surfaces.
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)
# Reduce noise from GMSH warnings
@@ -519,20 +517,38 @@ def main() -> None:
engine = getattr(args, "tessellation_engine", "occ")
if engine == "gmsh":
# GMSH: tessellate each solid individually to cap peak RAM usage.
# On multi-part assemblies (e.g. 25 rolling elements), processing the full
# compound at once uses 2-3 GB RAM. Processing per-solid limits peak RAM to
# max(single_solid_size). OCC BRep_Builder writes triangulation directly to
# the shared face objects — the parent compound is updated automatically.
# Strategy:
# 1. BRepMesh baseline on full root_shape — tessellates ALL face types
# (solids, shells, free faces). Ensures nothing is skipped.
# 2. GMSH override per unique SOLID — better seam topology.
# Overrides the BRepMesh triangulation on solid faces only.
# REVERSED solids (mirrored instances) keep BRepMesh to avoid
# GMSH inverted-Jacobian issues.
# Deduplication uses IsSame() (TShape pointer comparison) — NOT id(TShape())
# because OCP creates a new Python wrapper per TShape() call, making id() unreliable.
from OCP.TopExp import TopExp_Explorer as _Explorer
from OCP.TopAbs import TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL
from OCP.BRepMesh import BRepMesh_IncrementalMesh as _BrepMesh
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
# Collect solids first; fall back to shells for open bodies
# Step 1: BRepMesh baseline — catches non-solid shapes (free faces, shells)
# that TopExp_Explorer(SOLID) would miss. Also provides fallback for any
# solid that GMSH fails to tessellate.
BRepMesh_IncrementalMesh(
root_shape,
args.linear_deflection,
False,
args.angular_deflection,
True,
)
# Step 2: GMSH override for SOLID shapes (better seam topology)
_seen_shapes: list = [] # shapes already GMSH-tessellated; compared via IsSame()
solids = []
exp = _Explorer(root_shape, _SOLID)
while exp.More():
@@ -545,12 +561,21 @@ def main() -> None:
solids.append(exp.Current())
exp.Next()
if solids:
for solid in solids:
_tessellate_with_gmsh(solid, args.linear_deflection, args.angular_deflection)
else:
# Fallback for any shapes that are neither solid nor shell
_tessellate_with_gmsh(root_shape, args.linear_deflection, args.angular_deflection)
for solid in solids:
# Skip REVERSED (mirrored) solids — keep BRepMesh tessellation.
# GMSH produces inverted-Jacobian meshes for negative-scale shapes.
if solid.Orientation() == _REVERSED:
continue
# Skip duplicate TShape instances — GMSH tessellation is already on the
# shared TShape from the first occurrence; overwriting would be redundant.
# IsSame() compares underlying TShape pointers (reliable; id() is not).
if any(solid.IsSame(s) for s in _seen_shapes):
continue
# Strip location: GMSH tessellates in definition space.
# The XCAF writer applies instance transforms at GLB export time.
solid_def = solid.Located(_TopLoc_Location())
_tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection)
_seen_shapes.append(solid)
else:
for i in range(1, free_labels.Length() + 1):
shape = shape_tool.GetShape_s(free_labels.Value(i))