Bentley-Ottmann: three root-cause fixes for the dense shared-edge failures - #394
Open
anaelorlinski wants to merge 3 commits into
Open
anaelorlinski wants to merge 3 commits into
anaelorlinski wants to merge 3 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
or, outside
DebugPathIntersection, silently close a contour early and drop awedge 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.
ToleranceEdgeYon vertical segments. The function interpolates asegment'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 atthe 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 roundedrectangle 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
CompareHorder, 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.
mergeOverlappinglaterrecomputes 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
splitAtIntersectionsconcludes that only the second of thetwo 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:
AddPathEndpointsmakes a segment vertical when both endpoints snap to thesame 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
startis carried to the next segment the contour staysconnected.
correctIntersectionconstrains an intersection to each segment's boundingbox in x and y independently, which keeps
zin the box but lets it land inthe same column as a downwards-sloped segment's left-endpoint and below it —
again a downwards vertical first piece.
correctIntersectionOrdercollapseszonto 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.
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.
BenchmarkPathClipand 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:DebugPathIntersection1.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
TestSettleVoronoiDenseGridStrokeOutlinepasses even unpatched, because itasserts only
0 < result.Len()and the truncated three-subpath result isnon-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.godiff 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.