Skip to content

fix: bound runaway faces in streaming STEP tessellation#217

Merged
Krande merged 6 commits into
mainfrom
fix/unbounded-analytic-face
Jun 10, 2026
Merged

fix: bound runaway faces in streaming STEP tessellation#217
Krande merged 6 commits into
mainfrom
fix/unbounded-analytic-face

Conversation

@Krande

@Krande Krande commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Problem

Converting a large assembly-placed STEP model produced an "exploded" GLB: the model spanned hundreds of kilometres and sat far off-center in the viewer, even though the assembly-placement transforms (#213) were verified correct.

Root causes

  1. Unbounded analytic faces. A cylinder/cone face whose boundary circles are split into arcs crossing the parametric seam keeps the surface's natural (infinite) bounds after BRepBuilderAPI_MakeFace(surface, wire). BRepMesh then emits vertices millions of units out — a single such face explodes the whole model's bounding box.
  2. Sewing-induced runaway vertices. Even when every face builds bounded in isolation, sewing the shell can rebuild pcurves so the solid-level tessellation emits a handful of vertices kilometres out (mostly sane mesh + ~30 wild vertices).
  3. Pool blind spot on native crashes. A tessellation worker that dies mid-solid (uncatchable OCC segfault) produced no result, so its slot sat blocked for the full 120 s per-solid timeout. Hundreds of such solids turned an 18-minute conversion into 90+ minutes.

Fixes

  • make_face_from_geom: detect a runaway by comparing the built face's geometric extent against its own boundary wire (a trimmed face cannot legitimately overrun its boundary 10x); rebuild from the boundary's projected parameter extent (arc-aware sampling); drop the face if that fails too. Gated to cylinder/cone faces — the only surfaces here with an infinite direction. ShapeFix_Face is deliberately not used as the repair: it segfaults on many real-world unbounded cone faces.
  • tessellate_shape: safety net for everything else — drop any triangle with a vertex outside the shape's edge hull inflated 10x (edges stay finite even when a face's trim fails; sphere/torus surface extents are included for edge-poor faces).
  • Tessellation pool: check worker liveness each poll and replace a dead worker immediately (error:WorkerCrashed) instead of burning the per-solid timeout.

Results on the reference model (~7k solids, 779 MB STEP)

before after
model extent ±308,000 m ±46 m (true extent)
out-of-extent nodes 15+ 0
solids meshed 6,983 7,000 / 7,072
wall clock 18 min (3 workers) 4.6 min (8 workers)

Existing STEP/geom/visual-parity suites green (115 tests); two new regression tests (synthetic seam-crossing arc wire reproduces the unbounded-face precondition on stock OCC; triangle-soup filter unit test).

🤖 Generated with Claude Code

A cylinder/cone face whose boundary circles are split into arcs crossing
the parametric seam kept the surface's natural (infinite, or absurdly
large) bounds after BRepBuilderAPI_MakeFace(surface, wire). BRepMesh then
emitted vertices millions of units out, so a handful of such faces
exploded the converted model's bounding box (assembly-placed STEP read
via the streaming reader; the placement transforms themselves were
verified correct).

make_face_from_geom now detects the runaway by comparing the built
face's geometric extent against its own boundary wire (a trimmed face
cannot legitimately overrun its boundary 10x) and rebuilds the face from
the boundary's projected parameter extent (arc-aware sampling); if that
fails too the face is dropped — a hole beats a kilometres-long sliver.
The check is gated to cylinder/cone faces, the only surfaces here with
an infinite direction. ShapeFix_Face was deliberately NOT used as the
repair: it segfaults on many real-world unbounded cone faces.

Those segfaults also exposed a pool blind spot: a tessellation worker
that dies mid-solid (uncatchable native crash) produced no result and
its slot sat blocked for the full 120 s per-solid timeout — hundreds of
such solids turned an 18-minute conversion into 90+ minutes. The pool
loop now checks worker liveness each poll and replaces a dead worker
immediately, recording the solid as a native-crash skip.

Regression test constructs the seam-crossing arc wire from raw geom
objects and asserts the meshed face stays inside the cylinder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

🚀 Profiling Results (Top 20 most expensive calls)

Function Calls Duration (s)
<built-in method builtins.exec> (~:0) 1986 22.4856
<module> (<string>:1) 1 22.4855
test_build_big_ifc_pipe (tests/profiling/test_ifc_creation.py:48) 1 6.2695
to_ifc (src/ada/api/spatial/assembly.py:220) 5 5.6267
sync (src/ada/cadit/ifc/store.py:158) 5 5.5050
sync_added_physical_objects (src/ada/cadit/ifc/write/write_ifc.py:92) 5 5.4525
add (src/ada/cadit/ifc/write/write_ifc.py:493) 2200 5.3777
test_bench_batch_tessellate (tests/profiling/test_cad_backend_bench.py:63) 1 4.9075
__truediv__ (src/ada/api/spatial/part.py:1682) 8 4.8211
run (tests/profiling/test_cad_backend_bench.py:73) 6 4.5336
batch_tessellate (src/ada/occ/tessellating.py:339) 1446 4.5324
add_object (src/ada/api/spatial/part.py:309) 1201 3.8793
add_pipe (src/ada/api/spatial/part.py:164) 200 3.8608
add_section (src/ada/api/spatial/part.py:294) 801 3.2630
add (src/ada/api/containers/sections.py:139) 805 3.2579
tessellate_geom (src/ada/occ/tessellating.py:306) 1440 3.1411
equal_props (src/ada/sections/concept.py:92) 241399 3.0400
unique_props (src/ada/sections/concept.py:99) 482798 2.7277
write_ifc_pipe (src/ada/cadit/ifc/write/write_pipe.py:23) 200 2.3352
tessellate_occ_geom (src/ada/occ/tessellating.py:256) 1440 2.3062

📈 Performance Impact per Commit

Commit Total Top-20 Duration (s) Change
40c156f 116.1544 -
51613b4 118.7475 ⚪ +2.5931 (+2.23%)
9ed37b9 121.4801 ⚪ +2.7326 (+2.30%)
c68f57a 119.8083 ⚪ -1.6718 (-1.38%)

Completes the ada files trio (list/download/upload): upload a local file
to any scope the token can address, presign-first so large files go
S3-direct without pinning an API worker (then POST upload-complete for
the audit row + auto-conversion enqueue), falling back to the
API-tunneled PUT on local-storage deployments. Used in anger to push an
800 MB GLB to a personal scope; previously only the ada-build artefact
flow could upload at all, and only to project scopes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Krande

Krande commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Added a second commit: feat: ada files upload — completes the ada files trio (list/download/upload) with the same presign-first transfer flow as download. Used it to verify this PR end-to-end by pushing the converted 800 MB GLB to a personal viewer scope.

The end-to-end assertion now goes through active_backend() build +
tessellate, so the adacpp integration leg runs it too (verified passing
there) instead of failing on a module-level OCC import. Only the
bug-trigger precondition and the triangle-soup filter unit test remain
pythonocc-gated — they exercise pythonocc internals that adacpp
replaces wholesale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Krande Krande force-pushed the fix/unbounded-analytic-face branch from 3e3a72f to af3f128 Compare June 10, 2026 16:15
Two viewer-facing gaps in the streaming STEP->GLB path:

1. Units. glTF mandates metres but STEP files are very often authored in
   millimetres — the OCC reader converts via xstep.cascade.unit, while the
   kernel-free stream path emitted raw file units. A mm model rendered
   1000x too big and kilometres off-centre, and the viewer's depth
   precision collapsed into z-fighting that read as broken geometry.
   detect_step_length_unit_scale() reads the LENGTH_UNIT record (SI
   prefixes, one- and two-arg SI_UNIT forms, and CONVERSION_BASED_UNIT
   names like MILLIMETRE/INCH) and the scene path scales positions once,
   after instance placement.

2. Hierarchy. Every instance node hung flat under root, so a big assembly
   was an unfoldable list of thousands of solids. The transform walk now
   carries each instance's placement chain as (rep_id, product_name)
   levels (products resolved via SHAPE_DEFINITION_REPRESENTATION), and the
   scene graph nests instances under lazily-created group nodes keyed by
   rep-id prefix — the GLB id_hierarchy mirrors the STEP product tree, so
   good/bad regions can be folded and inspected per sub-assembly.

Also fixes an evaluation-order bug the hierarchy work exposed: the leaf
node id was computed before the group chain existed, so the first group
node silently evicted the leaf's id from the graph (cyclic-looking
id_hierarchy).

Both backends green (115 pythonocc / 112+3-skip adacpp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Krande

Krande commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Two more commits after viewer inspection of the converted model:

  • feat: scale streamed STEP to metres and export the assembly tree — the stream path emitted raw file units (most STEP files are mm) while glTF mandates metres, so models rendered 1000x too big, kilometres off-centre, with depth-buffer z-fighting that read as broken geometry. The reader now detects the LENGTH_UNIT record (SI prefixes, both SI_UNIT arg forms, CONVERSION_BASED_UNIT names) and the scene path scales once after instance placement. The GLB id_hierarchy now also mirrors the STEP product tree (group nodes from the placement chain's PRODUCT names) instead of a flat list of thousands of solids — sub-assemblies fold in the viewer tree.
  • Also fixed an evaluation-order bug where the leaf node id was minted before its group chain existed, corrupting id_hierarchy.

Verified on the reference model: extent ±46 m centered at origin, 0 out-of-extent nodes at a 60 m threshold, 24k-node hierarchy up to 10 levels deep. Both backends green.

Krande and others added 2 commits June 10, 2026 20:37
Loading a multi-hundred-MB GLB spiked past 5 GB of browser memory and took
minutes. Four compounding causes, all on the load path:

1. CustomBatchedMesh cloned the entire source geometry (the original mesh
   is discarded right after), permanently doubling every vertex/index
   buffer. It now shares the source geometry.
2. The GLTFLoader parser (including the raw GLB body ArrayBuffer) was
   stashed on userData for lineage registration and never released. It is
   now dropped as soon as lineage finishes.
3. buildEdgeGeometryWithRangeIds built a THREE sub-geometry per draw range
   (Array.from boxing every index into JS numbers, a full EdgesGeometry
   per range, then mergeGeometries over tens of thousands of geometries).
   Rewritten as a single typed-array pass with the same EdgesGeometry
   semantics: per-range position welding (the streamed GLB is a triangle
   soup), boundary edges always, shared edges past the dihedral threshold,
   per-vertex rangeId. Above 5M indices the 30-degree feature-edge
   threshold is forced — at the 1-degree default a curved 30M-tri model
   emits a line pair for nearly every triangle edge (multi-GB overlay).
4. The GPU picker only used its layout cost model when the flat-picker
   toggle was on; it now always auto-picks the cheaper layout (~0.5 GB on
   a 30M-tri merged CAD mesh) and the toggle becomes a force-flat
   override.

Frontend typecheck clean; 32 unit tests pass incl. a new edge-builder
test (welded soup, crease vs coplanar suppression, rangeId attribution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

👋 Hi there! I have checked your PR and found no issues. Thanks for your contribution!

PR Review:

I found no pr-related issues.

  • ✅ PR title is ok
  • ✅ Release label is ok
  • ✅ SOURCE_KEY is set as a secret
  • ✅ Calculated next version: "0.18.3"

Python Review:

I found no python-related issues.

Python Linting results:

  • ✅ Isort
  • ✅ Black
  • ✅ Ruff

Python Packaging results:

  • ✅ I found the PYPI_API_TOKEN secret.
Packaging Type Package Name Version
pyproject.toml ada-py 0.18.2
pypi ada-py 0.18.2

@Krande Krande merged commit 3028d45 into main Jun 10, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant