Skip to content

Faster tessellation, accuracy and memory fixes, 2D region boolean fix - #27

Merged
aka-blackboots merged 29 commits into
mainfrom
fix/2.0.18-patch
Oct 11, 2026
Merged

aka-blackboots merged 29 commits into
mainfrom
fix/2.0.18-patch

Conversation

@aka-blackboots

Copy link
Copy Markdown
Contributor

What changes

Four parts, each with its own tests. No TypeScript signature changes: dist/index.d.ts is byte-identical.

  1. Tessellation (speed). The Three.js wrapper tessellates a solid through its kernel object, not through the JSON text of its BRep. The tessellation cache is keyed by the shape and finds an entry without a scan. The midpoints of a trimmed curved face are numbered in a fixed order, so a body tessellates to the same arrays each time. Making the bodies of a building model is 26 to 28 % faster.
  2. Accuracy (fix). The default accuracy of a body comes from its size and reach, not from its distance to the origin. The box of an arc edge is the box of the arc, not of its circle. The walk over the quarter turns of an arc is bounded, so an arc with a huge or infinite angle no longer hangs the wrapper.
  3. Memory (speed). The WebAssembly memory grows in steps, not page by page. The allocator is limited to wasm32 without an operating system, and a refused reserve leaves the kernel working. Opening a model of 2,560 rooms takes about 8.4 ms for each room; it took about 12.
  4. 2D region boolean (fix). Each edge is classed by a probe that cannot cross a neighbouring edge: the reach follows the clearance (it was fixed at 1e-4). A hole belongs to the smallest region that contains it. A union of two regions with a gap between 1e-6 and 1e-4, or of a region thinner than 1e-4, was empty; it is right now.

Behaviour changes

  • Region boolean: a sliver thinner than the tolerance is dropped. An open boundary is an error (RegionBooleanError::OpenBoundary, OrphanHole) where a wrong ring came back before.
  • Rust: six functions of the region boolean path return a Result; edge_probe2 takes two more arguments.
  • A union of unit squares in a grid takes 14 % more time native and 8 to 11 % more in WebAssembly.
  • The .wasm file is about 55 KB larger.

Tests

  • cargo test: 457 pass. Nine new probe tests of the region boolean were red before the fix.
  • Wrapper tests (npm run test:wrapper): 26 pass. New files: the accuracy rule, the memory growth, limit and worst case, and the region boolean.

Review focus

The 2D region boolean (the last three commits) has had no independent review yet.

Not in this pull request

  • No version bump.
  • The same area-against-length test in offset2d.rs, the curved boolean and the 2.5 tree are not changed.

Two fresh tessellations of one body must give the same arrays. For a
curved face whose trim needs refinement they do not: the annular sector
with a round opening gives 0 equal runs of 20 at both deflections, so
the first test fails on this commit. The sector with a raised cutout is
a second trimmed curved body; it is stable already and stays as a
control.

The second test pins the sorted set of triangles of both bodies, so the
fix that follows can change the order of the vertices only. It passes on
this commit. The constants are compared on aarch64-apple-darwin only,
where they were recorded: the last bit of sine and cosine can differ
between targets.
… fixed order

The refinement of a trimmed curved face gave each new midpoint its
vertex number in the iteration order of a hash set. That order follows
the hash keys of the standard library, which change with every hash
collection the process makes, so one body gave different positions,
normals and indices from call to call (in WebAssembly: with the history
of the instance). The split edges are now visited in sorted order.

The set of triangles is the same as before; only the order of the
vertices of such a face is now fixed. No other hash collection of the
tessellation is iterated: the others are used for lookup only.
The wrapper is about to tessellate through the kernel object and the
cache is about to be keyed by shape. Both must leave every array as it
is, so this guard comes first and passes before those changes.

For a cuboid, a linear extrusion with a hole, an annular sector with a
round opening, a planar subtraction, a placed extrusion and a cuboid
with another id and revision, at two deflections: a fresh tessellation
of the body, a fresh tessellation of the body parsed from its JSON text,
the text entry and the object entry give the same bits in all six arrays
and in the achieved deflection, and each result carries the revision of
its body. The renamed cuboid gives the hash of the first cuboid.

The pinned FNV-1a hashes are compared on aarch64-apple-darwin only,
where they were recorded, because the last bit of sine and cosine can
differ between targets; the equal-entries checks run on every target.

The guard is in the test module of wasm.rs because tessellate_cached is
private to that file.
…limit

The wrapper tessellates a solid through the JSON text of its own BRep
only because the text entry is the one that reads max_triangles. The
object entry now takes the same optional options text and reads the
limit with the same function, so the wrapper can call it. A missing
option keeps DEFAULT_MAX_TRIANGLES, as before, so a caller of today does
not change.

The object entry parses no text, so the 64 MiB limit of the text parser
does not apply to it; one test states that with a body whose id alone is
64 MiB long.
AnalyticSolid.tessellate and exportStl asked the kernel for the JSON
text of the body and gave that text back to tessellate_brep, so each
call wrote the whole BRep as text, parsed it and validated it two more
times. Both now call tessellate on the kernel object they already hold.
The arrays are the same: the guard test pins that the text entry and the
object entry give the same bits.

The signatures of tessellate, retessellate and exportStl do not change.
One case does: a body whose BRep text is above 64 MiB was rejected by
the text parser and now tessellates, because no text is parsed.
These tests come before the cache change. Eleven fail on this commit,
each at its cache-hit assertion, because they state the new rule:

- nine "cache_hits_when_only_..." tests: a change of the body id, the
  revision, a face key or one field of the face provenance gives the
  same arrays from a fresh tessellation and must give a cache hit;
- the cache keeps every shape of one body id under the byte bound (the
  old rule kept three for one id);
- two equal shapes share one mesh, and each result carries the revision
  of the body that asked.

Three pass already and must keep passing: the byte total is exact at the
bound, the least recently used mesh goes first and a hit refreshes it,
and a mesh above the bound is returned and not kept.
…out a scan

The cache key was a hash of the whole BRep text, which holds the id of
the body, its revision, the face keys and the face provenance. A body
that is built again gets a new id, so it never hit; the cache filled to
its 64 MiB bound and each call scanned all entries up to three times.

The key is now a hash of what two builds of one shape share: the schema
version, the geometry, the topology (of a face: id, surface, sense, trim
and shell), the solids, the accuracy, the quality, the deflection and
the triangle limit. It leaves out the body id, the revision, the face
key and the face provenance. cache_key takes the envelope, the topology
and each face apart field by field, so a new field does not compile
until someone decides whether it belongs to the key.

A hit now can come from another body, so a clash of the hash would give
the mesh of another shape. The key is therefore 128 bits: two hashers of
the standard library with different fixed prefixes.

The entries are a map by key with a recency order and a running byte
total: one lookup, one recency update on a hit, evictions from the
oldest end until the total is under the bound. The 64 MiB bound stays.

A cached mesh can serve bodies of different revisions, so the result
that goes to JavaScript carries the revision of the body that asked.

cache_tracks_geometry_even_when_revision_is_not_incremented changed in
two places. It changed a face key and expected a miss; a face key is a
label and no longer in the key, so it now changes the radius, which is
what its name says. It counted three entries after six inserts for one
body id; the rule "three entries for one body id" is gone because the
body id is not in the key, so all six stay under the byte bound.
…anges

The "cache_hits_when_only_..." tests guard one direction: a field that
is left out of the key must not change the result. Nothing guarded the
other one: a field that the tessellation reads could be dropped from the
key (for example `accuracy: _`) and no test failed, although the cache
would then give the mesh of another shape.

Each new test changes one such field of a valid body and requires that
the cache gives what a fresh tessellation gives: another mesh and a
second entry, or the same error. Six also require that the fresh result
changes: the geometric accuracy (the fresh call is an error), a vertex
position, a face sense, a face trim, a curve and a surface. For the
intersection accuracy no cheap pair of valid bodies gives another
result, so that test requires the miss alone.

Each case was shown to fail with its field taken out of the key, and
only that case; the key is unchanged by this commit.
The guard called the private tessellate_cached as its object entry, and
on the cache entry that the text call had just made. It now calls
OGAnalyticBrep::tessellate, the method of the wrapper, on a thread of
its own, so the object entry computes its mesh in an empty cache.

The limit test asked for exactly the 12 triangles of the body, so its
result proved nothing. It now also requires a second cache entry, which
exists only when the limit is read from the options.
… revision

Two invariants are not visible in the code. The key must hold every
field that the tessellation reads, and a field may be left out only if
it cannot change the result; a person who adds a field has to decide
that. And the cache is public while a hit can return a mesh that was
made for another body, so the revision inside the mesh is not the
caller's: wasm.rs, the only caller, stamps its own.
The first test of the wrapper. It runs on the built package with
node:test (npm run test:wrapper, after npm run build) and has no
dependency.

Four of its six cases fail on this commit, because the default accuracy
takes the largest absolute profile coordinate as the size of the body:

- a 5 m wall has geometric 5e-8 at the origin and 1.05e-6 at (100, 100);
- a slab centred on the origin gets the accuracy of half its size;
- a 0.05 m prism at (50000, 50000) cannot be made at all: its accuracy
  is 5e-4 and the first tessellation rejects the default deflection;
- a 1 m cuboid at (3e6, 0, 3e6) with a matrix fails the placement check
  of the kernel, which takes the norm of the far corner where the
  wrapper takes the largest component of the frame origin.

Two pass already and must keep passing: an explicit accuracy goes
through unchanged, and a door cut at (5000, 5000) equals the cut at the
origin after the translation.
…reach

The default accuracy of a new body took the largest absolute profile
coordinate as the size of the body. That is the distance from the
origin, not the size, so a wall at 100 m got a geometric tolerance above
1e-6 and one at 40 km could not be tessellated at all. A body at 100 m
is not less exact than the same body at the origin.

For the kinds that are given by points (linear and arc-edged
extrusions, polygon lofts, planar polyhedra) the size is now the largest
side of the box of those points in the frame of the body. The other
kinds keep their size expression. The coordinate floor follows what the
placement check of the kernel computes: the norm, over the world axes,
of the largest absolute coordinate of the corners of that box (for the
other kinds a cube of the size around the frame origin). It is taken
128 times the machine epsilon, twice the factor of the kernel, so a body
that a matrix moves a little still passes that check.

Two results change. A body centred on its frame origin had half its
extent as size and now has the full extent, so its default tolerance is
up to twice the former one. A body far from the origin gets the
tolerance of its size where it got the tolerance of its distance. An
explicit accuracy option is unchanged.
A wall that curves gently has a large radius and a short arc. The size
rule takes the whole circle of an arc edge (centre plus and minus the
radius), so a wall of radius 80 m with a sweep of 10 degrees gets the
size 160.6 m and the tolerance 1.606e-6, where its own box is 10 m wide.

Four new cases; the expected box comes from the end points and the axis
extremes that each case names. Three fail on this commit: the gently
curved wall near and at (5000, 5000), the sweeps that pass one axis
extreme, and the ring written in both directions. The sweep of 270
degrees passes already, because there the box is as wide as the circle.
The box of an arc-edged body took the whole circle of each arc. A wall
that curves gently has a large radius and a short arc: with a radius of
80 m and a sweep of 10 degrees its size was 160.6 m and its tolerance
1.606e-6, where its own box is 10 m wide. Before the size rule such a
wall, with its centre near the origin, had 8e-7, so the rule made a
normal curved wall less exact than it was.

An arc now gives its two end points and each axis extreme of its circle
that the sweep passes. The convention is the one of the kernel
(ProfileEdge::endpoints in analytic/primitives.rs): a point at an angle
is centre + radius * (cos, sin), the arc runs from startAngle to
startAngle + sweepAngle, and the sign of sweepAngle is its direction.
The case of the short arcs beside an extreme did not see a missing
extreme: for a thin ring the largest side of the box is along the chord,
and the extreme only widens the other side. With the extremes taken out
of the box that case still passed, and only the sweep of 270 degrees
failed. The case keeps its bodies under the name of what it does prove.

The new case closes an arc of 20 degrees across each of the four axis
extremes with a slab 200 m deep behind it, so the side along the axis is
the largest and ends at the extreme. Shown with two temporary faults in
the box of an arc: without the extremes all four are missed; with the
directions of 90 and 270 degrees swapped the case fails as well.
The box of an arc steps through the quarter turns between its two
angles. From about 1.41e16 rad the step "quarter += 1" no longer changes
the number, so the loop never ends and memory grows; an infinite angle
makes the wrapper throw a TypeError where the kernel has its own error.
Neither happened before the size rule.

Each case makes the body in a process of its own, with a time limit and
a small heap, so a loop that does not end is a failure and not a hang
of the test run. The case fails on this commit: the four huge angles
give no result (the process runs out of memory) and the two infinite
ones give the TypeError.
… count

The loop over the axis extremes of an arc counted quarter turns. For an
angle of about 1.41e16 rad or more, adding one to the quarter does not
change the number, so the loop did not end; for an infinite angle the
quarter was not a number and the wrapper threw a TypeError.

The loop now counts at most four steps from the first quarter, and it
runs no step when the first quarter is not finite. The body then goes to
the kernel, which accepts it or rejects it with its own error.
…atrix

The kernel bounds the two cap faces of an arc-edged extrusion with the
whole circle of each arc of the outer loop (analytic/primitives.rs, the
loop over loops[0].edges), and the placement check reads those bounds.
The wrapper takes its coordinate floor from the exact box of the arc, so
for a short arc of a large circle the floor is below what the kernel
measures.

Two bodies under a matrix that is not the identity: a 5 m chord of a
circle of radius 1.6e6 m, and a 0.05 m chord of a circle of radius
3.2e4 m. Both fail on this commit with "placement coordinate precision
exceeds geometric tolerance".
…ts outer arcs

The kernel bounds the cap faces of an arc-edged extrusion with the whole
circle of each arc of the outer loop, and its placement check takes the
magnitude of the body from those bounds. The coordinate floor of the
wrapper came from the exact box of the arcs, so a short arc of a large
circle got a floor below the check and could not be placed.

The size of the body still comes from the exact box. Only the reach now
widens that box by centre plus and minus radius for each arc of the
outer loop, as the kernel does. For a wall of radius 80 m this floor is
about 5e-12, so its accuracy does not change.
…urned frame

Five faults in the size rule passed every case. Each now has a case that
fails with it, shown with the fault put in and taken out again:

- a column 0.3 x 0.3 x 3 m gets 3e-8 (the height left out of the box);
- a polygon loft and a planar polyhedron get 4e-8 at the origin and at
  5000 m (either kind back to the largest absolute coordinate);
- an arc wall with its centre at (5000, 5000) in the profile equals the
  wall moved by its frame (the arc extremes without the centre);
- a wall in a frame turned by 45 degrees gets 5e-8 (the size measured
  along a world axis);
- the far cuboid has the floor 128 * EPSILON * hypot(3e6 + 1, 1,
  3e6 + 1), not only a world accuracy equal to the local one (the floor
  with the factor 64).

All pass on this commit; the code is unchanged.
The kernel sets no allocator, so the standard one asks the engine for
one 64 KiB page at each need. A large model grows the memory thousands
of times, and each growth costs more the larger the memory is.

Two tests come before the allocator.

The unit test states the size of the reserve as a pure function of the
memory in pages: one eighth, at least 1 MiB, at most 64 MiB, and no
overflow at 65,536 pages on a 32-bit usize. The function does not exist
yet, so the test build fails on this commit.

The wrapper test boots the built package, keeps 5000 cuboid bodies and
counts the growth steps of the memory. On this commit: 828 steps, where
the case allows 100. Its three other cases pass already and are guards:
the wasm has one memory of its own, not shared and without a maximum;
the memory after the bodies is at most one eighth and one step above
the 55,508,992 bytes of today; one body needs at most 4 MiB.

The module is declared for wasm32 and for tests, so the native build
compiles nothing of it outside the unit test.
The case for 65,536 pages cannot fail on a 64-bit host, where the
product of the pages and the page size fits a usize in any order of the
arithmetic. A page count of usize::MAX overflows on every host unless
the function is total, so this case fails for an arithmetic that can
overflow, on the machine that runs the unit tests. The function still
does not exist, so the test build fails on this commit as before.
The standard allocator of wasm32 asks the engine for one 64 KiB page at
each need and never gives a page back. A model of 2560 rooms grew the
memory 11,129 times, and in V8 one growth costs more the larger the
memory is: 9.4 s of that open.

A global allocator for wasm32 now wraps the system allocator. After a
call that grew the memory it allocates and frees one block of one eighth
of the memory, at least 1 MiB and at most 64 MiB. The freed block
borders the top of the heap and merges into it, so the next allocations
need no growth. The block has the alignment 8, the allocator's own on
wasm32, so it is one plain chunk.

This is the first unsafe code of the crate. Each unsafe block is one
call into the system allocator: the four entries pass on the arguments
of their caller, and the reserve frees the block that it has just got
with the same layout. The size of the reserve is a pure function that is
total, with a unit test on every target. The entries stay out of line
and share one alloc and one dealloc entry; inlined, the wasm was 246 KB
larger.

When the engine refuses a reserve, a flag stops all further reserves.
The allocation of the caller has already succeeded, so nothing fails and
nothing is logged; the kernel then grows page by page as before.

The reserve is address space: the memory that the process touches does
not change. No setting, no dependency, no linker argument.
The gate target_arch = "wasm32" is also true for WASI and Emscripten,
where the system allocator is another one and the reserve is not proven.
The reserve relies on the allocator of wasm32-unknown-unknown, so the
gate now also asks for target_os = "unknown". The build for the browser
compiles the same code as before: its wasm has the same bytes.
"At most one eighth more memory" holds only for small allocations. When
one request is larger than the free top, the memory grows by the whole
request and the old top stays free; if the next reserve does not fit
that old top, one more reserve is added. The free top can then hold two
reserves, about one quarter of the memory.

The case that bounded the 5000 small bodies now says in its title what
it bounds. A new case makes the worst pattern in a kernel of its own:
1 KiB blocks up to a growth after 16 MiB, then one block of a reserve
and two pages. It requires more than one reserve above the memory
without a reserve (5.56 MiB, 1.62 reserves, 25.1 %) and at most two
reserves and two pages. The memory without a reserve comes from a small
model of the standard allocator, which gives the page count of the
build before the allocator exactly for this pattern.

With one quarter as the reserve this case fails; with no reserve it
fails too. The three memory test files share one boot helper.
The engine refuses a growth beyond 65,536 pages, so the refused reserve
can be reached in Node. Large blocks that are never written bring the
memory to a few pages below the limit; there the 64 MiB reserve cannot
be granted. The case requires that nothing traps, that the last pages
come one at a time up to exactly 65,536, and that a small block is still
served there.

It runs in about 0.1 s and touches about 10 MiB; the 4 GiB are address
space. An engine that cannot give 4 GiB of address space skips the case
during the first large blocks, where no reserve is refused yet. With a
panic in place of the refused branch the case fails.

It cannot see the flag that stops further reserves: with or without it
the page counts are the same.
…lean

Step 0 of the fix, a measurement: no production code changes. Every test
of this file is ignored, so the suite is as before.

Six tests state what the region boolean must do and fail today:
two squares with a gap between the tolerance (1e-6) and the probe reach
(1e-4) must stay two regions; a strip thinner than the reach must
survive, alone and across a square; no result may be an open chain
closed by a straight line; a hole must go to the innermost region that
holds it, for two and for three levels of nesting.

Three probes print the mechanism: how each edge of two squares 1e-5
apart is classed (the two facing edges are dropped, because the probe
point at 1e-4 lands inside the other square), which outer rings hold
each hole (the first one in output order takes it), and the map of the
union by gap.

Run them with: cargo test --test region_boolean_probe -- --ignored
--nocapture
The tests of the fix, before the fix. No production code changes here.
Eighteen fail on this commit and three pass.

tests/region_boolean_probe.rs: the probes of step 0 are gone and its
tests are live. All nine fail: two squares with a gap of 2e-6, 1e-5 or
9e-5 at three sizes and three distances; squares under a millimetre; a
thin strip alone and in four positions against a square, in all three
operations; no ring that is an open chain; a hole to the innermost
region, in both operand orders and for three levels; an island 1e-5
inside a hole; an island that the operation clips.

boolean2d.rs: five cases for the argument that a bounded probe is exact.
Three pass already and must keep passing: coincident edges of the two
operands, a zero-area sliver, a T junction. Two fail: a clearance below
twice the tolerance, and the rule that a ring is degenerate when it is
thinner than the tolerance (a ring 4e-6 wide is lost today, a square of
side 3e-4 too).

offset_regions.rs: the same through the function behind the export.
Both fail: the nested hole, and two regions across a gap of 5e-5.

analytic/booleans.rs: the two defects through boolean_brep. Both fail
with an error today: a frame prism united with an island prism
("profile holes 0 and 1 overlap or nest", in one operand order only),
and a profile hole 2e-5 from the edge of its host ("planar boolean
produced a boundary without source ancestry").

region-boolean.test.mjs under test:wrapper: three cases through
booleanRegions2D, all failing: the 5e-5 gap gives no region, the frame
takes the island's hole, and a boundary that cannot be closed comes
back as rings where it must be an error. That input uses an edge of
1 km whose split misses a vertex 0.5 mm from its end.
The 2D region boolean decided the side of a sub-edge with two probe
points at min(len/4, 1e-4) from its midpoint. A boundary nearer than
that made both probes agree, the edge was dropped, and the open chain
that was left came back as a ring. Two squares with a gap between 1e-6
and 1e-4 united to a triangle; a strip thinner than 1e-4 was lost or
took its neighbour with it. Four rules change.

The reach of the probe is now min(len/4, 1e-4, clearance/2), where the
clearance is the least distance from the midpoint to any other sub-edge
that is farther than eps. A probe at that reach is at least half the
clearance away from every other such sub-edge, so it sees the fill
beside this edge alone. The sub-edges within eps of the midpoint are the
coincident ones, which the probes class by the windings of both
operands, as before. The probe of the self-crossing offset loops (cap
1e-3) uses the same rule.

An open chain is an error, RegionBooleanError::OpenBoundary, and never a
ring. The 2D functions return a Result. The analytic callers map it to
the InvalidTopology text they raise for the same fact; the exports give
"Boolean regions produced an open boundary". At the two calls with a
material predicate the predicate's own failure is still reported first.

A ring is degenerate when its area is below eps times half its
perimeter, that is, when it is thinner than eps. The old test compared
an area with a length, so every ring under 1e-6 m2 vanished (squares of
side 3e-4), and a ring 10 m long and 4e-7 wide survived. Changed at the
ring classification, at the loops of a self-crossing ring and at the
result filter of offset_ring_variable.

A hole goes to the smallest outer ring that contains it, the rule of
the curved boolean. A hole that no region contains is an error,
OrphanHole, not a silent drop. Before, the first outer ring in output
order took it, so a frame took the hole of an island inside it.

offset_polyline_regions and offset_polyline_group_regions returned a
Vec and now return a Result, so that no caller can drop the error; their
existing tests unwrap. No existing test changes its result.
@aka-blackboots aka-blackboots changed the title Faster tessellation, accuracy that does not depend on position, memory growth in steps, and a 2D region boolean that classes every edge Faster tessellation, accuracy and memory fixes, 2D region boolean fix Oct 11, 2026
@aka-blackboots
aka-blackboots merged commit cba5c0a into main Oct 11, 2026
3 checks passed
@aka-blackboots
aka-blackboots deleted the fix/2.0.18-patch branch October 11, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant