Skip to content

Bentley-Ottmann: three root-cause fixes for the dense shared-edge failures - #394

Open
anaelorlinski wants to merge 3 commits into
tdewolff:masterfrom
anaelorlinski:fix/bo-vertical-first-segment
Open

anaelorlinski wants to merge 3 commits into
tdewolff:masterfrom
anaelorlinski:fix/bo-vertical-first-segment

Conversation

@anaelorlinski

Copy link
Copy Markdown
Contributor

Three independent defects in the Bentley-Ottmann phase, each of which corrupts
the sweep state and makes the result-polygon walk fail. They show up together
on dense geometry with shared or near-coincident edges — the merged outline of
adjacent cells, a shape clipped against an edge that nearly coincides with its
own — where they either panic:

next node for result polygon is nil, probably buggy intersection code
impossible: first segment became vertical and needs reversal, but was already in the sweep status

or, outside DebugPathIntersection, silently close a contour early and drop a
wedge of the shape.

Each commit is self-contained, explains the mechanism in its message, and adds
a regression test that fails before it and passes after. No public API changes.

The three fixes

1. ToleranceEdgeY on vertical segments. The function interpolates a
segment's y at the left and right edges of a tolerance square. A vertical
segment has zero x-span, so whenever rounding puts its raw x one ulp outside
the square, the interpolation divides by zero and returns NaN. NaN loses every
comparison in breakupCrossingSegments, so the segment is never broken up at
the squares it crosses. A vertical segment has both endpoints in the column, so
its y at the tolerance edges is just its endpoints — return them directly.

Hits when a shape is intersected with a clip whose edge nearly coincides with
one of its own, within BentleyOttmannEpsilon. The test intersects a rounded
rectangle with a same-size rectangle offset by 2e-9: the result had two
subpaths and 35% of the area missing. A randomised variant fails ~5% of 1000
cases before, none after.

2. Sweep fields computed bottom-up for overlapping segments. The sweep
fields of the left-endpoints in a tolerance square are computed in CompareH
order, each from the segment below it in the status. Windings propagate bottom-up
along the status, so this is only correct when the two orders agree. For
overlapping segments starting at the same point they need not: a segment reads
the windings of a neighbour that has not been computed yet, gets zeros, and
every segment above the stack inherits the error. mergeOverlapping later
recomputes the merged stack but not the segments above it. Compute a
left-endpoint's fields only after those of the segment below it when that
segment starts in the same point, and mark computed endpoints so none is
computed twice.

3. Verticality decided on the snap grid. The sweep decides whether a
segment is vertical with an exact comparison of the endpoints' x, but
coordinates are only snapped to the grid after the intersection phase. A
segment whose endpoints differ in x by less than the grid is therefore carried
through the sweep as left-to-right and turns vertical the moment it is split;
when the piece before the split points downwards it must be reversed to keep
left-endpoints at the bottom, which is impossible once that left-endpoint is in
the status.

The reasoning above splitAtIntersections concludes that only the second of the
two segments can ever need this reversal, and that it is never in the status.
Both halves fail in practice — the case analysis is done on exact positions
while the intersection is computed in floating point, and the panic fires for
the first segment too.

Two changes, each needed on its own:

  • AddPathEndpoints makes a segment vertical when both endpoints snap to the
    same grid column. This moves a point no further than the snapping that
    follows the sweep would, and removes the segments that turn vertical
    mid-sweep. Because start is carried to the next segment the contour stays
    connected.
  • correctIntersection constrains an intersection to each segment's bounding
    box in x and y independently, which keeps z in the box but lets it land in
    the same column as a downwards-sloped segment's left-endpoint and below it —
    again a downwards vertical first piece. correctIntersectionOrder collapses
    z onto that left-endpoint when the two fall in the same tolerance square,
    so it drops only a split the snapping phase would undo. A genuinely
    near-vertical segment, whose intersections lie further than a square away,
    still splits and reverses normally.

Measurements

Sweep of a seeded 10x10 grid of adjacent cells with corner noise, stroked and
settled, at eleven noise magnitudes from 0 to 1e-9, 60 seeds each (660 cases).
Expected result is one subpath of area 1.44.

failures of which silently wrong
master 339 / 660 0 (all panics)
with all three 0 / 660 0

No case goes from correct to incorrect, and none trades a panic for a silently
wrong result. A further 1671 cases on unseen seeds and a second grid size are
all correct. BenchmarkPathClip and a grid-stroke benchmark are unchanged
(~2.9 ms both ways).

Relationship to #382

#382 reports the same user-visible failure and fixes it in the polygon walk:
search the event ring in both directions, and close a partial contour instead
of panicking. These commits instead stop the malformed sweep state from arising,
so the walk finds its continuation by the normal path.

Verified against #382's own fixture (testdata/voronoi_stroke_pre_settle.gob,
the pre-settle stroke outline of 100 merged Voronoi cells), settled with
Positive:

subpaths area DebugPathIntersection
master 3 1.380000000 panics
this branch 1 1.440000000 passes

1.44 is the exact expected value — the outer square of side 1.2 — returned as a
clean 5-point path. I have not included that fixture here; it belongs to #382,
and the regression tests in this branch reproduce the same failures
deterministically from seeded geometry with no binary testdata.

Worth noting for review order: on current master #382's regression test
TestSettleVoronoiDenseGridStrokeOutline passes even unpatched, because it
asserts only 0 < result.Len() and the truncated three-subpath result is
non-empty. The real failure there is silent. If #382 lands first its fallback
absorbs these cases — the 1.38-instead-of-1.44 result stops panicking in debug
mode too, and becomes undetectable.

The two are not mutually exclusive. I applied #382's path_intersection.go
diff on top of this branch: one textual conflict, where #382 replaces the debug
panic with the unconditional fallback. Resolving it in #382's favour, the full
test suite passes and the fixture still settles to the correct 1.44.

anaelorlinski and others added 3 commits September 5, 2026 22:15
ToleranceEdgeY interpolates a segment's y at the left and right edges of a
tolerance square. For a vertical segment the x-span is zero, so whenever
floating-point rounding puts the segment's raw x one ulp outside
[x - eps/2, x + eps/2) the interpolation divides by zero and returns NaN.
NaN fails every below/above comparison in breakupCrossingSegments, so the
segment is never broken up at the squares it passes through in its column.

That happens in practice when a shape is intersected with a clip whose edge
nearly coincides with one of its own edges (within BentleyOttmannEpsilon):
the crossing snaps to one grid point and the endpoint to the next one up,
the clip's vertical edge should be split there, is not, and the result
polygon walk finds no continuation. Outside debug mode the contour is
closed early and a wedge of the shape goes missing; in debug mode it panics
with "next node for result polygon is nil".

A vertical segment has both endpoints in the column, so its y-values at the
tolerance edges are just its endpoints. Return them directly.

The regression test intersects a rounded rectangle with a same-size
rectangle offset by 2e-9, with the shared edge on a snap half-grid line:
the result had two subpaths and 35% of the area missing. The randomized
variant fails about 5% of 1000 cases before this change and none after.

This does not fix the dense merged-grid case from tdewolff#382, which fails at a
vertex holding three identical overlapping segments and has a different
cause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erlapping segments

The sweep fields of the left-endpoints in a tolerance square are computed
in CompareH order, each from the segment below it in the sweep status.
Windings propagate bottom-up along the status, so this is only correct
when the two orders agree. For overlapping segments that start in the same
point they need not: a segment can then read the windings of a neighbour
that has not been computed yet, gets zeros, and every segment above the
stack inherits the error. mergeOverlapping later recomputes the merged
stack itself, but not the segments above it, so the result polygon walk
arrives at a vertex on a boundary segment and finds no segment leaving
it: "next node for result polygon is nil" in debug mode, a silently
truncated contour otherwise.

Compute a left-endpoint's fields only after those of the segment below it
when that segment starts in the same point, and mark computed endpoints
so a segment is never computed twice. Split pieces start unmarked.

This is the failure behind tdewolff#382: the stroke outline of a merged grid of
nearly-exact cells, where the outlines of adjacent cells overlap exactly
along shared edges. The fixture from that PR now settles to the outer
square in debug mode. The regression test builds equivalent geometry from
a seeded grid with 1e-17 corner noise; four seeds panic before this
change and settle to a single square of area 1.44 after it.

Not addressed: some noisier grids hit "first segment became vertical and
needs reversal, but was already in the sweep status", which is a
different failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…id column

The sweep decides whether a segment is vertical with an exact comparison of
the endpoints' x, but coordinates are only snapped to the grid in the pass
that follows the intersection phase. A segment whose two endpoints differ in
x by less than the snapping grid is therefore carried through the sweep as a
left-to-right segment, and turns vertical as soon as it is split. When the
piece before the split points downwards it has to be reversed to keep
left-endpoints at the bottom, which is impossible once that left-endpoint is
in the sweep status: "impossible: first segment became vertical and needs
reversal, but was already in the sweep status".

The reasoning above splitAtIntersections concludes that only the second of the
two segments can ever need this reversal, and that it is never in the status.
Both halves fail here. The case analysis is done on exact positions while the
intersection is computed in floating-point, and the panic fires for the first
segment as well.

Two changes, each needed on its own:

  - AddPathEndpoints makes a segment vertical when both endpoints snap to the
    same grid column. This moves a point no further than the snapping after
    the sweep would, and removes the segments that turn vertical mid-sweep.
    Because start is carried to the next segment the contour stays connected.

  - correctIntersection constrains an intersection to each segment's bounding
    box in x and y independently, which keeps z in the box but allows it to
    land in the same column as a downwards-sloped segment's left-endpoint and
    below it, so the piece before the split is again a downwards vertical.
    correctIntersectionOrder collapses z onto that left-endpoint when the two
    fall in the same tolerance square, so it drops only a split the snapping
    phase would undo. A genuinely near-vertical segment, whose intersections
    lie further than a square away, still splits and reverses normally while
    its left-endpoint is out of the status.

This is the failure left unaddressed by the two preceding commits. Over a
sweep of the seeded grid at eleven noise magnitudes from 0 to 1e-9, 60 seeds
each, 339 of 660 cases panicked before this change and none after, with no
case going from correct to incorrect and no case trading the panic for a
silently wrong result. 1671 further cases on unseen seeds and a second grid
size are all correct. Path clipping and the grid stroke benchmark are
unchanged. The regression test covers four noise magnitudes where 15 of its
16 cases panic without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqojqx7PxMhWedFEufhyx2
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