Skip to content

fix(overlay): boolean-operation and buffer correctness fixes - #37

Merged
pentatonick merged 20 commits into
mainfrom
feature/pending-changes
Sep 5, 2026
Merged

fix(overlay): boolean-operation and buffer correctness fixes#37
pentatonick merged 20 commits into
mainfrom
feature/pending-changes

Conversation

@pentatonick

@pentatonick pentatonick commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fix several overlay/boolean-operation correctness issues (turn ordering, self-intersection detection, ring/vertex emission order matching Boost)
  • Add multi-polygon operand support for boolean operations and a multi-polygon centroid strategy
  • Fix remove_spikes vertex collapsing and add zero-width polygon buffer handling

Test plan

  • Existing test suite passes

`trace_rings` closed a ring only when the walk returned to the node it
started from. Where two lobes of the result touch at one point, the
traversal passes through that point twice, so carrying on to the seed
spliced the lobes into a single self-touching ring — geometry that
`is_valid` rejects, and that `boost::geometry::intersection` never
produces.

Close the ring at any node already on the current walk instead, emit it,
and carry on from there. This is the rule Boost's own traversal follows,
and the one the `geometry/correct.hpp` dissolve uses.

Verified against C++ Boost 1.83, which returns two valid polygons for the
case now covered by `intersection_splits_lobes_meeting_at_a_point`:

  box  (0,0)-(10,10)
  ring (5,-1) (6,-2) (2,-3) (1,1) (3,2) (2,6) (4,7) (5,0) (6,1) (7,-1)

  before: one ring through (5,0) twice, area 16.125 but self-touching
  after:  (1.25,0)(1,1)(3,2)(2,6)(4,7)(5,0) and (5,0)(6,1)(6.5,0)

Found while scoring this crate against C++ Boost on real polygon/box
clips taken from a tilemaker run: it takes those from 3/10 to 6/10
matching Boost's ring set, with no change to the rest of the suite.
`is_valid_ring_with` tested orientation last, so a ring that was both
wound the wrong way and self-intersecting came back
`SelfIntersection`. Boost reports `failure_wrong_orientation` for it:
orientation is checked first, and only a correctly wound ring goes on to
be reported as self-intersecting. Spikes still precede both.

The distinction is not cosmetic for callers that branch on the code —
tilemaker's `buildWayGeometry` redoes its clip on
`failure_self_intersections` and leaves the geometry alone on
`failure_wrong_orientation`.

Measured against Boost 1.83 on 2000 random rings (clean, bow-tie,
figure-of-eight and meander shapes, both windings): 203 of the first 400
disagreed before, 0 of 2000 after. The order the new
`orientation_is_reported_before_self_intersection` test pins:

  ccw + real spike          failure=12  area=-16
  ccw + selfint, no spike   failure=22  area=-17
  cw self-int, +area        failure=21  area=+98
  bowtie                    failure=22  area=+0

Two existing bow-tie assertions expected the old order; a bow-tie's lobes
cancel to zero signed area, so Boost fails it on orientation. Updated
with the measured value.
The predicate required the dot product to be strictly negative, so a
zero-length step never qualified and every repeated vertex survived.
Boost's is `point_is_spike_or_equal` — the `or_equal` half is what
collapses a duplicate, and `remove_spikes` needs it to clean up after
itself: peeling the apex off `(4,0) (6,0) (4,0)` leaves `(4,0) (4,0)`
adjacent, which a spike-only predicate then leaves in the output.

`dot <= 0` cannot over-match. Two non-zero steps that are both parallel
(`cross == 0`) and perpendicular (`dot == 0`) do not exist, so the new
arm fires only when one step has zero length.

Boost 1.83 on a clockwise `model::polygon`, all five now covered by
`repeated_vertices_are_collapsed`:

  consecutive dup  -> (0,0) (0,4) (4,4) (4,0) (0,0)
  dup at start     -> (0,0) (0,4) (4,4) (4,0) (0,0)
  triple dup       -> (0,0) (0,4) (4,4) (4,0) (0,0)
  real spike       -> (0,0) (0,4) (4,4) (4,0) (0,0)
  dup + spike      -> (0,0) (0,4) (4,4) (4,0) (0,0)

`is_valid`'s own spike predicate stays strict and keeps `dot < 0`:
Boost's default validity policy accepts duplicate points, so the two
answer different questions. Noted where they diverge.

Measured against Boost on 2000 random rings: 1 in 400 disagreed before,
0 of 2000 after.
`centroid` was a compile error on a multi-polygon; Boost has had one all
along. Adds `CartesianMultiPolygonCentroid` and the `MultiPolygonTag`
picker impl.

It runs one Bashein-Detmer accumulator over every ring of every member and
divides once, which is `centroid_multi` in
`boost/geometry/algorithms/centroid.hpp` — not a combine of per-member
centroids weighted by area. The two are not the same function. A member
with zero area has zero weight and vanishes from the weighted combine, but
its numerator is not zero and Boost keeps it.

`CartesianPolygonCentroid` had the same shape and the same gap, so it now
accumulates too. Boost 1.83 on a polygon whose exterior is a bow-tie and
whose hole has real area:

  bowtie exterior + hole  -> (10.6667, 11)   area=-4
  weighted combine gives  -> (11, 11)

and across members:

  zero-area MP            -> (0, 0)          area=0
  mixed MP                -> (11.3333, 11)   area=4

Both are the accumulated answer, and both are now covered by
`a_zero_area_part_still_moves_the_centroid`. The existing polygon tests are
unchanged: for a polygon with well-formed rings the two formulations agree
identically, because the per-ring area cancels against the divisor.
…rong

Every inter-member defect was reported as `IntersectingInteriors`. Boost
reports `failure_self_intersections` for most of them and reserves
`failure_intersecting_interiors` for genuine nesting. Boost 1.83:

  overlapping members     failure=21   boundaries cross
  edge-touching members   failure=21   boundaries share a curve
  identical members       failure=21
  one inside another      failure=40   interiors overlap, boundaries never meet
  point-touching members  valid
  disjoint members        valid

So a boundary that meets the other at all is 21, and only interiors that
overlap while the boundaries stay apart is 40.

Callers branch on the difference: tilemaker's `buildWayGeometry` throws its
fast clip away and redoes it with the robust intersection on 21, and leaves
the geometry alone on 40.

The existing parity test asserted 40 for the overlapping and edge-touching
cases; both measure 21. Updated, and a nested case added to cover the 40
arm that is now reachable on its own.
`intersection`, `union`, `difference` and `sym_difference` took a pair of
polygons only, so a caller holding multi-polygons had to decompose them,
run the pairs, and re-combine — which is not the same function, and quietly
changes the answer when the pieces meet.

Boost dispatches every areal Boolean through one overlay whatever the
operand arity: `bg::intersection(mp, box, out)` and
`bg::difference(mp1, mp2, out)` are the same algorithm as the polygon pair.
So the arrangement kernel is now shared, and `*_multi` runs it over the
union of both operands' rings. A single polygon is the one-member case.

No behaviour change for the existing entry points: `overlay` builds the
same `Shape` and segment list it always did, and the split-edge core is
byte-identical to what it was.

Covered by `multi_polygon_operands`, where the operands overlap across
members: two disjoint unit squares against one that straddles the first.
…rsection

`is_valid_polygon_with` reported `DisconnectedInterior` for any hole that
overlapped the exterior boundary. Boost reports
`failure_self_intersections` when they share a *curve*, and reserves
`failure_disconnected_interior` for isolated contacts that cut the
interior in two. Boost 1.83, exterior (0,0)-(10,10) clockwise:

  hole fully interior                      valid
  hole touching the exterior at one point  valid
  hole sharing a segment of it             failure=21
  hole touching it at two isolated points  failure=32

The inner-vs-inner loop a few lines below already made that distinction;
the exterior-vs-inner one did not.

Found in tilemaker: a building clipped to a tile edge acquires a hole that
runs along the clip boundary, so it hits this on real data. The two codes
take different branches there — `buildWayGeometry` redoes the clip with the
robust intersection on 21 and only repairs in place on 32 — and the tile
came out with two vertices the reference does not have.

The existing `polygon_detects_disconnected_interior` case asserted 32 for a
hole sharing the left edge; it measures 21. Replaced with the four shapes
above, so both arms are covered by a case that reaches them on its own.
…erminant

`line_cross_point` solved the two-line determinant, whose terms are
products of *absolute* coordinates. On geographic input that is the wrong
arithmetic: a polygon spanning 1e-4 degrees at longitude 7.4, latitude
48.7 forms terms around 362 and asks for a residue ten orders of magnitude
smaller, so the crossing lands off the line it is supposed to be on.

  segment (7.4264305,48.7356554)-(7.4263938,48.7355668)
  against the horizontal clip edge y = 48.7355713

  determinant  y off by 9.24e-14, and 4.97e-14 off the sloped segment
  parametric   y exact,           3.09e-20 off the sloped segment

Split edges that should meet at a shared node then do not, the arrangement
is left unclosed, and the traversal strands: `intersection` returned
`OverlayError::Unsupported` for a building clipped to a tile edge.

The parametric form touches only coordinate *differences* — the size of
the geometry rather than of its position — and anchors the answer on the
first segment's start, so it stays on that segment by construction. Same
point in exact arithmetic, and the same cost.

Measured on 17 polygon-against-clip-box cases taken from real tilemaker
runs, where the expected output is C++ Boost 1.83's: 14 differed and 2
refused before, all 17 exact after.
Which vertex a ring starts at and which polygon comes first are both
observable, and both were falling out of whichever boundary edge the
traversal happened to seed from. Boost's are not arbitrary:

* a ring begins at a **turn**, the first one in source order;
* a ring with no turn was copied whole from one operand and keeps that
  operand's own starting vertex — not the end the traversal reached it
  from, which for a hole is the other one, since a hole is walked against
  its stored direction;
* the rings come out in source order, so the polygons do too.

Nodes are numbered as the operands are walked, so "first in source order"
is just the smallest node index; the tracer now records which nodes are
turns (`push_split` marks them, including a crossing that lands on a
vertex) and rotates each cycle onto its earliest one.

This matters to any consumer that pins a ring's first vertex. tilemaker's
`simplify_ring` never removes one, so a rotation of one place shows up in
the tile as one extra vertex and one missing.

Measured on 27 polygon-against-clip-box cases from real tilemaker runs,
compared to C++ Boost 1.83 including ring start and polygon order: 0 of 27
matched before, 25 of 27 after. The two that remain are one case whose two
output rings begin at the same vertex, where source order does not
separate them.
A union ring begins at a turn — the first one along the first operand's
boundary — which `push_ring` decided by node index, the order the nodes
happened to be created in. That order is wrong at exactly one place: a turn
landing on a vertex is created when the *leaving* segment is split, so a turn
on the first operand's own first vertex came out as position zero.

Boost normalises the other way. `get_turns` attaches an intersection at a
segment endpoint to the segment it **terminates**, so that turn is the *last*
position on its ring, not the first. Nodes now carry an `arrival` order built
with that normalisation — only the far end of a split counts — and the start
turn is chosen by it.

Measured against C++ Boost 1.83. Two quadrilaterals sharing an edge, the first
given starting at one of that edge's endpoints:

  before  D C B A F E D
  after   A F E D C B A      = Boost

This is the tenth defect found by diffing the tilemaker port against the C++
it was ported from, and it is what made that port's building outlines differ:
`make_valid`'s dissolve merges a self-intersecting outline's two lobes with
`union_`, and the merged hole came back rotated three vertices. With this, two
of the port's three remaining building differences are gone.

Unions whose operands share a *collinear edge* are still not exact — Boost
drops the starting turn there when the outline runs straight through it, which
is its collinear turn classification and a separate piece of work. 104 of 112
such pairs differ; of the 200 pairs that meet only at crossings or points, 169
are exact and 25 of the rest differ only in the last bits of an irrational
crossing.

Gate: 1606 tests pass, fmt and clippy clean.
Two more pieces of Boost's overlay, both read out of its source rather than
inferred from its output. Together they take unions whose operands share a
collinear edge from 8 of 112 exact against C++ Boost 1.83 to 89.

**`clean_closing_dups_and_spikes`.** `traverse_with_operation` runs it over
every ring the traversal produces, and it erases the ring's first point while
that point is collinear with its two neighbours, repeating until it is not. A
ring starts at a turn, and where two operands share an edge a turn need not be
a corner — the outline can run straight through the very point the traversal
seeded from. This is why Boost's union of two squares sharing an edge has five
distinct vertices where the obvious answer has six.

Only the *start* is cleaned, which is what makes the behaviour look arbitrary
from outside: the identical straight-through vertex at the far end of the same
shared edge survives. And it has to run once the ring is in its final winding,
because reversing a ring changes which vertex the erasure leaves at the front —
so it moved into `assemble`, next to `orient_ring`, and applies to the rings
the traversal assembled rather than to one copied whole from an operand.

**Turn order is a pair.** `get_turns` walks the first operand's sections in the
outer loop and the second's in the inner, so its turns come out ordered by the
pair. Two turns on the same stretch of the first operand are separated by where
they sit on the *second* — and rotating the second operand moves the answer,
which a single position counter cannot express. `Node::arrival` is now the
segment index per operand, with the fraction demoted to a third key so it
cannot outrank the second operand.

Measured over 312 valid polygon pairs — every rotation of squares and
rectangles sharing an edge in whole and in part, meeting at a corner, crossing,
contained, disjoint, plus radial polygons:

                            before            after
  collinear edge (112)      8 exact           89 exact
  crossings only (200)      169 exact         173 exact, 26 of the 27
                                              remaining differ only in the
                                              last bits of an irrational
                                              crossing

The 23 that remain share a collinear edge and need what is not here:
`get_turns`' sectionalisation, which groups monotone runs so that turns Boost
treats as tied are not, and its collinear turn classification, which decides
whether a same-direction overlap contributes a turn point at all.

Gate: 1608 tests pass, fmt and clippy clean.
Between two turns Boost copies the vertices of the single operand it is
walking, so a point that operand has no vertex at is passed over. This
arrangement splits a segment wherever anything touches it, so the *other*
operand's corner landing part-way along became a vertex of the traced ring that
Boost never emits.

Edges now carry who runs along them and nodes know whose vertex they are, which
is what the rule needs: along a stretch both operands carry, the one walked is
the first, so a point the first operand has no vertex at goes. Two guards keep
it honest — the point must be collinear with its neighbours, because anywhere
the outline actually turns the point is part of the shape whoever owns it, and
a point the first operand does have a vertex at stays.

Measured over the same 312 valid polygon pairs, against C++ Boost 1.83 and
compared exactly, ring start included:

                                before      after
  shared edge, same way (32)    9 exact     20 exact
  shared edge, opposite (80)    80 exact    80 exact
  no shared edge (200)          173 exact   173 exact, 26 of the 27 remaining
                                            differing only in an irrational
                                            crossing's last bits

Two other formulations were tried and dropped, both of which looked right on
the cases they were reasoned from and measured worse: dropping any point that
is a vertex of exactly one operand (23 differ becomes 32), and dropping the
*entry* of a shared stretch (23 becomes 20, and it loses cases this rule
keeps).

That second one is the signpost for the 12 that remain. They are all
same-direction shared edges, where `get_turn_info`'s collinear handler emits
one turn for the whole overlap and puts it at the far end
(`non_opposite_to_index`), so the near end is not a turn and never reaches the
output. Reproducing that needs `get_turns`' own collinear turn handling, not a
further rule layered over the arrangement; the limit and its current number are
recorded on the function.

Gate: 1609 tests pass, fmt and clippy clean. tilemaker's `scripts/verify.sh`:
16 passed, 0 failed, unchanged.
…ns by section

The previous commit approximated the traversal's vertex handling with a rule
over the finished cycle, and left twelve shared-edge pairs it could not reach.
This replaces the rule with the mechanism, read out of Boost's source: the
traversal appends a *turn* through `append_no_collinear`, which drops any point
the new one now runs straight through, and copies the vertices between two
turns through `copy_segments`, which does no such check. So a shared edge's near
end goes not because anything decided it was interior to a walked segment, but
because the far end of the overlap is a turn collinear with it and takes its
place — while an operand's own straight-through vertex, merely copied, stays.

Two things had to come with it.

`append_no_collinear` and `clean_closing_dups_and_spikes` both judge the point
*before* the one they are given, so which vertex of a straight run survives
depends on the direction the ring runs in. Edges are now oriented with the
filled side on the right, the winding Boost's traversal produces.

And the turn order needed `sectionalize`. `get_turns` partitions each operand
into sections — runs of consecutive segments heading the same way in both
dimensions, capped at ten — and walks the section pairs, so the section pair
outranks the segment index: two turns on one segment of the first operand can
come out in either order depending on which section of the second operand each
one meets. Ring starts are ordered by that pair first, and two rings starting
at the same node by which operand carries the edge leaving it, since `iterate`
tries operation 0 before operation 1.

`Node::is_vertex` was only there for the rule this removes, and goes with it.

Measured over 312 valid polygon pairs against C++ Boost 1.83, compared exactly,
ring start and ring order included:

                                before                 after
  shares a collinear edge (112) 100 exact              112 exact
  meets only at crossings (200) 173 exact              173 exact
  differing structurally         13                      0

The 27 that still differ all sit on an irrational crossing and differ in its
last bits alone, worst 2.84e-14 across the set.

Gate: 1612 tests pass, fmt and clippy clean. tilemaker's `scripts/verify.sh`:
16 passed, 0 failed.
`difference` does not dispatch the overlay the way `union_` and `intersection`
do: it passes `Reverse2 = true`, so `sectionalize` reads the second operand
through a reversed view and every section and segment index `get_turns` sees
for that operand counts from the other end of the ring. Since those indices are
what order the turns, they decide which turn a result ring starts at — and a
bite taken out of a single edge ties on everything the first operand can say,
leaving the reversal as the whole decision. Ten of the 312 valid pairs started
their ring at the wrong end of such a bite.

The direction reaches nothing else here. The arrangement orients each edge by
which side the result lies on, and the point-in-polygon test that classifies
them counts crossings, so both are indifferent to how a ring was stored.

The remaining three were the order of the polygons, not their contents.
`add_rings` walks the selected rings in `ring_identifier` order, which puts
every ring copied whole from an operand ahead of the traversed ones and orders
the traversed ones by when `traverse` started them — that is, by where
`get_turns` put the turn each one started from. Ordering by the node a ring
starts at got close because nodes are created along the first operand's
boundary, but it cannot separate two rings whose start turns differ only in the
second operand. `TurnOrder` is now the key for both choices, the ring's start
turn and the ring's place in the output, instead of the comparison being
written out once inline and approximated once by node index.

Measured over 312 valid polygon pairs against C++ Boost 1.83, compared exactly,
ring start and ring order included:

                          before                 after
  difference    36 differ, 13 structurally     25 differ, 0 structurally
  union         27 differ,  0 structurally     unchanged
  intersection  27 differ,  0 structurally     unchanged

Every remaining difference across the three operations sits on an irrational
crossing and differs in its last bits alone, worst 2.84e-14. The generator's
self-intersecting inputs are unaffected either way, at 75, 94 and 161 of 600.

Gate: 1614 tests pass, fmt and clippy clean. tilemaker's `scripts/verify.sh`:
16 passed, 0 failed.
`traverse` never sees a ring that nothing crossed. `add_rings` copies it out of
its operand through `convert_ring`, which appends nothing and drops nothing, so
every vertex it was stored with survives — including one the outline runs
straight through on its way back to the first.

This arrangement traces such a ring like any other and closed it the way the
traversal closes a *traced* ring, through `append_no_collinear`. That function
exists to drop the point a new turn now runs straight past, and on the closing
append it did exactly that to the last vertex of an untouched ring. Closing is
now a plain append unless a turn actually started the ring.

Found in tilemaker rather than in the parity set, which has no case where a
whole ring survives an operation untouched: the dissolve it repairs polygons
with finishes on `difference(outers, inners)`, and a repaired piece that
nothing else touched came back a vertex short. Over the 767 `simplify` calls a
liechtenstein run makes, the dissolve went from 7 outputs differing against C++
Boost 1.83 to 3, and the whole post-simplify tail — `correct`, the validity
check and the repair — from 5 to 3; what is left there is ring order, not
content. tilemaker's own gate has liechtenstein identical for the first time.

The 312 valid polygon pairs are unchanged at zero structural differences for
union, intersection and difference alike.

Gate: 1615 tests pass, fmt and clippy clean.
Boost's kernel does not compare coordinates exactly. `math::equals` calls two
values equal when they differ by no more than one epsilon of the larger — or of
`1`, so values near zero still have to agree absolutely — and `side_by_triangle`
opens by calling three points collinear whenever any *two* of them are equal by
that rule, before it computes any determinant at all
(`side_by_triangle.hpp:150-164`). Every predicate built on the side test
inherits it.

`CoordinateScalar::tolerant_eq` is that rule, exact for an integer or a
rational and relative for a float, and both the side predicate and
`remove_spikes` now use it. The side predicate's own documentation already
claimed the coincident-pair behaviour; it had the short-circuit only for points
that were equal bit for bit.

What it changes in practice is what counts as a spike. A hairline whose two
ends are four last bits apart at a coordinate of 3540 is one point to Boost,
which makes the ring a spike and collapses it to nothing; to an exact cross
product it is a sliver with real area, and it survived into a monaco tile that
the reference drew as nothing. Measured on tilemaker's own runs, `remove_spikes`
now agrees with C++ Boost 1.83 on every call a whole extract makes: 0 of 767
under `config-example`, 0 of 5429 under OpenMapTiles, and 0 of 300 synthetic.

The 312 valid polygon pairs are unchanged at zero structural differences for
union, intersection and difference — the epsilon only fires where an exact
determinant was already down in the last bits.

Gate: 1618 tests pass, fmt and clippy clean.
Two things decide which polygon comes out of an overlay first, and neither was
right once an operand had more than one ring.

**`partition`.** `get_turns` does not compare every section of one operand
against every section of the other. It hands both lists to
`geometry::partition`, which halves the plane recursively and visits only the
pairs that can still meet — and only when *both* lists are longer than sixteen;
under that it runs the plain nested loop. The turns land in `m_turns` in the
order that walk finds them, `traverse` starts a ring at the first turn it has
not used, and `add_rings` emits the rings in that order. Ordering the turns
lexicographically by section pair is the same answer for a single small polygon
and a different one as soon as the division kicks in, which is why this only
ever showed up on multi-polygon operands. `section_partition` is that walk;
`Node` carries the rank of its section pair instead of the pair itself.

**Ring identity.** A ring no turn lands on is emitted under its own
`ring_identifier` — source, then position within that operand — ahead of every
traversed ring. The port ordered those by the lowest node on the cycle, which
is not the same thing: this arrangement gives two rings that meet at a point
the *same* node, so a piece could inherit its neighbour's position and overtake
it. Edges now carry the section they came from, and since a section never spans
a ring, the lowest section on a cycle names the ring it came out of.

Measured against C++ Boost 1.83, compared exactly, ring order included:

  union / intersection / difference, 312 valid pairs      0 structural (unchanged)
  the same three over multi-polygon operands, 300 each    1 -> 0 structural
  the vendored dissolve, over the 767 `simplify` calls    3 -> 0 differ at all
  a liechtenstein run makes

That last one is the whole of `correct(polygon, out, 1E-12)`, per-polygon
simplification and repair included, and it is now exact on every call.

Gate: 1618 tests pass, fmt and clippy clean. tilemaker's `scripts/verify.sh`:
16 passed, 0 failed, with liechtenstein identical.
`buffer(g, 0)` is Boost's make-valid idiom and `repair_one_polygon` falls back
on it when the dissolve gives up. This crate refused it outright, so an outline
the dissolve could not fix came back as nothing.

It is not a no-op and not a special case. `buffer_inserter` runs its whole
pipeline: each side offsets — onto itself, at zero — into an **offsetted ring**,
each join adds what it adds, the turns between those rings are found and
checked against the original, and what survives is traversed. Two things fall
out of the zero:

* A **convex** corner adds nothing. `join_miter` returns early when the two
  offset points coincide, and at zero they always do.
* A **concave** corner adds two `buffered_concave` pieces, each contributing
  the corner again — so that vertex is emitted three times.

The winding a ring is stored with is what decides which corners are which:
`closed_clockwise_view` is keyed on the ring *type*, not on how the ring is
actually wound, so it is the identity for a type that is already closed and
clockwise. Normalising the winding first inverts every corner, and closing the
ring after reversing it rather than before starts it at the wrong vertex; both
were tried and measured.

Where the offsetted rings meet nothing, `discard_rings` drops nothing and the
traversal has no work: the rings are the answer. That is the case the repair
needs, and it is the case this implements. Where they do meet, what survives
rests on the winding strategy's verdict for a turn point `get_turns` computed —
a crossing part-way along two segments is decided by the last bits of a
determinant. Reproducing the verdict without the arithmetic that produced the
point was tried and measured (right 132 of 300, wrong 148), so it declines
instead.

Against C++ Boost 1.83 with the strategies tilemaker passes, over 300 synthetic
polygons: **122 answered, 122 exact, 0 wrong, 178 declined** — up from 300
declined. And the outline that sent tilemaker here, an exterior collapsed to a
single point around an interior that still encloses something, now comes back
identical: twenty-one points, six corners tripled.

`buffer_with(polygon, 0)` no longer returns `Unsupported` for a polygon whose
rings meet nothing, which is a change to the public contract and to the test
that pinned it.

Gate: 1621 tests pass, fmt and clippy clean. tilemaker's buildings case is
identical for the first time — 28,769 features, no differences.
Stable moved to 1.98.1, which adds clippy::manual_midpoint, detects the
unused trait imports, and rewords a trait-obligation diagnostic. None of
this is a behaviour change; f64::midpoint matches (a + b) * 0.5 for
in-range values.
@codecov-commenter

Copy link
Copy Markdown

…ations

The multi-polygon Boolean entry points were added without refreshing the
generated table, which CI checks against the pub-use tags.
@pentatonick
pentatonick merged commit 288d5de into main Sep 5, 2026
3 checks passed
@pentatonick
pentatonick deleted the feature/pending-changes branch September 5, 2026 03:34
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.

2 participants