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>
14 KiB
Plan: GMSH — Fix Mirror Instances + Reduce Mesh Size to ≤120% of OCC
Context
Two bugs introduced by the GMSH tessellation path:
Bug 1 — Missing parts (mirror instances)
TopExp_Explorer(root_shape, SOLID) visits every occurrence of a solid, including
mirrored instances. In a typical STEP bearing assembly the inner ring is defined once
but instanced twice: normal + Y=-1 mirror. Both occurrences share the exact same
underlying TShape pointer.
Tessellation loop calls _tessellate_with_gmsh(solidA) then
_tessellate_with_gmsh(solidB). Both reach BRep_Builder.UpdateFace(face, tri) on
the same BRep_TFace objects. The second call overwrites the triangulation written
by the first — with coordinates from the mirrored geometry. The XCAF writer then tries
to apply the instance Location on top of already-mirrored coordinates → part appears
at the wrong position or vanishes entirely.
Fix: deduplicate by TShape. Each unique TShape must be tessellated exactly once,
in its definition-space geometry (location stripped). The XCAF writer handles instance
transforms at write time — it does not need the triangulation to be pre-transformed.
Bug 2 — GLB 7× too large (21 MB vs OCC 3 MB; target ≤3.6 MB)
CharacteristicLengthMax = linear_deflection × 15 = 1.5 mm is much smaller than the
effective OCC edge length. OCC with angular_deflection=0.1 rad on a 50 mm radius
cylinder produces edges ≈ 2 × 50 × sin(0.05) ≈ 5 mm. The 15× multiplier only
reaches 1.5 mm → 3× more edge subdivisions along every cylinder → ~9× more triangles.
MinimumCirclePoints = min(20, ceil(2π/0.1)) = 20 adds further density.
Fix: CharacteristicLengthMax = linear_deflection × 50 (≈5 mm for default 0.1 mm),
MinimumCirclePoints = min(12, ceil(2π/angular_deflection)).
Affected Files
| File | Change |
|---|---|
render-worker/scripts/export_step_to_gltf.py |
Fix 1: deduplicate TShape; Fix 2: new mesh-density formula |
Only one file changes. No DB migration, no frontend change, no backend task change.
Tasks (in order)
[x] Task 1: Deduplicate TShape in the per-solid tessellation loop
File: render-worker/scripts/export_step_to_gltf.py
Root cause: TopExp_Explorer(root_shape, SOLID) returns every occurrence (instance)
of a solid. Mirrored instances share the same TShape. The second UpdateFace call on the
same TShape overwrites the first tessellation.
What:
Replace the current per-solid loop (lines 535–553) with a version that:
- Extracts
TShapeidentity for each solid viasolid.TShape() - Tracks already-processed TShapes in a
set(using Pythonid()on the TShape object) - For a solid whose TShape was already processed → skip (the triangulation is already set)
- For a solid with a mirror transform (negative determinant) → use BRepMesh fallback instead of GMSH, to avoid inverted-Jacobian issues
- For new, non-mirrored solids → strip location before calling GMSH, then restore
Why strip location?
BRepTools.Write_s(solid_with_location, brep_path) writes the solid in world coordinates
(location applied). GMSH then tessellates in world coordinates. UpdateFace stores the
world-coordinate triangulation on the TShape, which the XCAF writer then double-transforms
(applies instance location again) → geometry is wrong.
With location stripped (solid.Located(TopLoc_Location())) the BRep file contains the
definition-space geometry, GMSH tessellates in definition space, and the XCAF writer
applies the instance transforms correctly at write time.
Exact code to replace lines 535–553:
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy as _BRepCopy
_seen_tshapes: set = set() # TShape identity → already tessellated
for solid in solids:
tshape_id = id(solid.TShape())
if tshape_id in _seen_tshapes:
# Shared TShape already tessellated — skip duplicate instance
continue
# Detect mirror transform: determinant of rotation part < 0
loc = solid.Location()
trsf = loc.IsIdentity() and None or loc.IsIdentity() # placeholder — see below
_is_mirror = False
if not loc.IsIdentity():
from OCP.gp import gp_Trsf as _gp_Trsf
m = loc.IsIdentity() # placeholder
try:
t = loc.IsIdentity() # will be replaced below
pass
except Exception:
pass
_tessellate_with_gmsh(solid, args.linear_deflection, args.angular_deflection)
_seen_tshapes.add(tshape_id)
Actual correct implementation (the placeholder above is incomplete; here is the full, correct replacement):
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
_seen_tshapes: set = set() # set of id(TShape) already tessellated
for solid in solids:
tshape_id = id(solid.TShape())
# Skip duplicate instances — same TShape, different location (e.g. mirrored copy)
if tshape_id in _seen_tshapes:
continue
# Detect mirror transform (negative determinant → inverted Jacobian in GMSH)
loc = solid.Location()
_is_mirror = False
if not loc.IsIdentity():
t = loc.IsIdentity() # placeholder — actual det check below
try:
trsf = loc.IsIdentity() and None # will be overridden
# Real OCP API: loc.IsIdentity() returns bool; the transform is:
# trsf = gp_Trsf(); loc gives access via loc.IsIdentity() (no)
# Correct: the 3×3 rotation matrix determinant via VectorForm
pass
except Exception:
pass
if _is_mirror:
# Mirrored solid — GMSH produces inverted Jacobian; use BRepMesh fallback
_BrepMesh(solid, args.linear_deflection, False, args.angular_deflection, True)
else:
# Strip location: tessellate in definition space so XCAF writer can apply
# the instance transform correctly at GLB export time
solid_def = solid.Located(_TopLoc_Location())
_tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection)
_seen_tshapes.add(tshape_id)
Exact mirror-detection snippet (the gp_Trsf determinant check):
from OCP.gp import gp_Trsf as _gp_Trsf
def _is_mirror_transform(location) -> bool:
"""Return True if the TopLoc_Location has a negative-determinant (mirror) transform."""
if location.IsIdentity():
return False
trsf = location.IsIdentity() # placeholder — real API below
# OCP: TopLoc_Location has no direct Transformation() Python binding in all versions.
# Reliable alternative: check IsIdentity first; then use gp_Trsf from the location's
# IsIdentity() — actually TopLoc_Location.IsIdentity() returns bool.
# The correct OCP call:
try:
from OCP.gp import gp_GTrsf as _gp_GTrsf
# TopLoc_Location stores a gp_Trsf — access via:
trsf: _gp_Trsf = location.IsIdentity() and _gp_Trsf() or location.IsIdentity()
except Exception:
return False
# det = trsf.Value(1,1)*(trsf.Value(2,2)*trsf.Value(3,3) - trsf.Value(2,3)*trsf.Value(3,2))
# ...
return False # expand when OCP binding is confirmed
Note for implementer: The OCP Python binding for
TopLoc_Locationdoes expose.IsIdentity()(bool). The transform matrix is accessible via:from OCP.gp import gp_Trsf trsf = gp_Trsf() location.IsIdentity() # bool # The actual matrix getter is not directly .Transformation() in all OCP builds. # Safest approach: use BRep_Tool or directly check the shape's TShape flags. # Alternative: use shape.Orientation() — mirrored solids in OCC have REVERSED orientation.Recommended simpler check:
solid.Orientation() == TopAbs_REVERSED(fromOCP.TopAbs import TopAbs_REVERSED). In OCC, a mirrored instance is stored as the same solid withREVERSEDorientation. This is the correct, idiomatic OCC check.Full deduplication + mirror-detection loop (final version):
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location from OCP.TopAbs import TopAbs_REVERSED as _REVERSED _seen_tshapes: set = set() for solid in solids: tshape_id = id(solid.TShape()) if tshape_id in _seen_tshapes: continue # duplicate instance — triangulation already set on the shared TShape if solid.Orientation() == _REVERSED: # Mirrored/reversed solid → GMSH produces inverted-Jacobian mesh; BRepMesh fallback _BrepMesh(solid, args.linear_deflection, False, args.angular_deflection, True) else: # Strip location so GMSH sees definition-space geometry solid_def = solid.Located(_TopLoc_Location()) _tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection) _seen_tshapes.add(tshape_id)
Acceptance gate:
docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py --step_path /app/uploads/step_files/341ee748-3f04-4c4e-b358-5f2dcd18f848.stp --output_path /tmp/test_mirror.glb --tessellation_engine gmshcompletes without error- Log shows no "skipped node without triangulation data" for any mirrored-instance part that previously showed geometry
- GLB loaded in Blender shows all parts (including mirrored halves) at correct positions
Dependencies: none
[x] Task 2: Tune GMSH density parameters to ≤120% of OCC output size
File: render-worker/scripts/export_step_to_gltf.py
Root cause: CharacteristicLengthMax = linear_deflection × 15 = 1.5 mm → 3× more
edge subdivisions than OCC on cylindrical surfaces → ~9× more triangles.
MinimumCirclePoints = 20 adds further overhead.
What: In _tessellate_with_gmsh(), replace lines 324–329:
# BEFORE
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 15.0)
min_circle_pts = min(20, max(12, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))))
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
# AFTER
# OCC linear_deflection (mm) is a surface-deviation tolerance.
# Empirically: OCC with 0.1mm deflection on a 50mm cylinder produces ~5mm edge lengths.
# Match that with CharacteristicLengthMax = deflection × 50.
# MinimumCirclePoints: OCC angular_deflection=0.1rad → ceil(2π/0.1)=63 pts/circle but
# spread unevenly; effective uniform subdivision is closer to 12–16. Cap at 12.
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)
Expected result for linear_deflection=0.1, angular_deflection=0.1:
CharacteristicLengthMax = 5 mm(vs 1.5 mm before)MinimumCirclePoints = 12(vs 20 before)- Triangle count: ~(1.5/5)² × (12/20) × previous = ~0.054× → 21 MB × 0.054 ≈ 1.1 MB (this estimate is rough; target is ≤ 3.6 MB which is 120% of OCC's ~3 MB)
- If result is still too large, increase multiplier further (60×, 70×)
Acceptance gate:
# Run both OCC and GMSH, compare sizes:
python3 /render-scripts/export_step_to_gltf.py \
--step_path /app/uploads/step_files/341ee748*.stp \
--output_path /tmp/occ.glb --tessellation_engine occ \
--linear_deflection 0.1 --angular_deflection 0.1
python3 /render-scripts/export_step_to_gltf.py \
--step_path /app/uploads/step_files/341ee748*.stp \
--output_path /tmp/gmsh.glb --tessellation_engine gmsh \
--linear_deflection 0.1 --angular_deflection 0.1
# gmsh.glb must be ≤ 120% of occ.glb
python3 -c "
import os; occ=os.path.getsize('/tmp/occ.glb'); gmsh=os.path.getsize('/tmp/gmsh.glb')
print(f'OCC: {occ//1024}KB, GMSH: {gmsh//1024}KB, ratio: {gmsh/occ:.2f}')
assert gmsh <= occ * 1.20, f'GMSH {gmsh//1024}KB > 120% of OCC {occ//1024}KB'
print('PASS')
"
Dependencies: none (independent of Task 1, can run in parallel)
Migration Check
No migration required. Rendering-pipeline-only changes.
Order Recommendation
Tasks 1 and 2 are independent — implement both in the same file edit, then test together.
Task 1 (deduplicate TShape + orientation check)
Task 2 (CharacteristicLengthMax ×50, MinimumCirclePoints ≤12)
→ docker compose cp updated script into render-worker
→ run benchmark (both OCC and GMSH on rolling bearing)
→ verify size ≤120% and no missing mirror parts
Risks / Open Questions
-
solid.Located(_TopLoc_Location())strips transform correctly? Yes —TopoDS_Shape.Located(loc)returns a new shape reference with the given location applied.TopLoc_Location()(default constructor) is identity. The underlying TShape geometry is unchanged; only the Shape wrapper's location changes.BRepTools.Write_swill then write the definition-space geometry. -
solid.Orientation() == TopAbs_REVERSEDfor ALL mirrored instances? In XCAF assemblies loaded from STEP, mirrored instances are typically stored withREVERSEDorientation. However, some STEP exporters encode mirrors as a proper negative-scale transform in the Location rather than using REVERSED orientation. Safeguard: also checkloc.IsIdentity() == Falseand computedet(trsf_rotation):# Fallback determinant check if orientation check misses some cases from OCP.gp import gp_Trsf # trsf available via: shape._ptr ... (no direct Python binding in all OCP versions) # Use BRepBuilderAPI_Transform trick: transform shape by identity and check inversionIn practice, the
TopAbs_REVERSEDcheck handles the majority of STEP mirror instances. The BRepMesh fallback for reversed solids is safe (no visual difference vs before GMSH). -
Does
CharacteristicLengthMax × 50produce fan-free meshes? Yes — GMSH Frontal-Delaunay at any density produces conforming meshes without fan triangles. The density reduction does NOT affect the seam topology quality; only the triangle count changes. The UV-unwrap seam advantage of GMSH is preserved at anyCharacteristicLengthMax. -
Multiplier tuning: If 50× still produces GLB > 120% of OCC, try 70× or 100×. The goal is seam-correctness, not mesh fidelity — larger triangles are fine for the viewer and for UV unwrapping (seams are topological, not density-dependent).