Skip to content

DoSplitOp: keep a reversed split that lies inside the remaining path (union could fill a hole) - #1109

Open
JuFrolov wants to merge 1 commit into
AngusJohnson:mainfrom
JuFrolov:fix/keep-inner-split-loop
Open

JuFrolov wants to merge 1 commit into
AngusJohnson:mainfrom
JuFrolov:fix/keep-inner-split-loop

Conversation

@JuFrolov

Copy link
Copy Markdown

Problem

Paths64 subject = {
  {{91,7},{-145,-11},{-141,-15}},
  {{-28,75},{-33,76},{1,0}},
  {{-22,51},{-39,76},{-25,-2}},
};
Paths64 solution = Union(subject, FillRule::NonZero);

Point (−7, 7) is inside none of the three triangles (in exact arithmetic triangles 2 and 3 do not touch; there is a narrow crack between them), but it is inside the solution. The solution is one path:

(-23,52) (-39,76) (-25,-1) (-145,-11) (-141,-15) (91,7) (0,0) (-28,75) (-33,76)

Its area is 1 865, of which 649 lies outside the subject paths.

Cause

The intersections (0.95, 0.13) and (0.94, 0.13) become (0, 0). That moves edge (−33,76)→(0,0) across edge (−24,−1)→(−22,51) and closes the crack into a small hole. Before CleanCollinear the output path is still correct at (−7, 7):

(-141,-15) (91,7) (0,0) (-28,75) (-33,76) (0,0) (-24,-1) (-22,51) (-39,76) (-25,-1) (-145,-11)

The main part winds +1 there, and the loop (0,0) (−24,−1) (−22,50) winds −1. FixSelfIntersects calls DoSplitOp on those two segments. The loop is smaller than the remaining path and of opposite orientation, so it takes the discard branch — the −1 is lost and the remaining path covers the hole.

Discarding a reversed split that lies outside the remaining path only removes a sliver; discarding one that lies inside fills a hole.

Change

Before discarding a smaller split of opposite orientation, check whether its centroid (kept in doubles, so it is not rounded outside a thin triangle) has a non-zero winding number relative to the remaining path. If so, the split is kept as a separate path. Splits outside the remaining path are discarded as before.

Same change in C++, C# and Delphi (TriangleInsidePath next to AreaTriangle, one extra term in the DoSplitOp condition). New case 196 in Tests/Polygons.txt: the three triangles above, expected area 1 254 and 2 paths (the outer path and the hole (0,0) (-24,-1) (-22,50)).

Verification

  • CI on the fork, all green on this commit: C++ (windows-latest, ubuntu gcc default and gcc 11, ubuntu clang default and clang 17, macos-latest) and C# (windows-latest). Delphi was not compiled.
  • The same change was first made and measured in the TypeScript port (clipper2-ts), whose DoSplitOp is a line-by-line port and which returns the identical wrong path on this input:
    • the three triangles: winding at (−7, 7) 1 → 0, area outside the input 649 → 38;
    • a 5 426-path real input (union of many visibility polygons): area outside the input 523 779 700 → 961 222.5, for both Paths64 and PolyTree64; union time 89.2 → 91.9 ms;
    • random stress, 40 000 sets of 3–8 thin triangles/quads, 1.8 M probe points more than 2 units from any input edge: points covered outside the input 2 → 0, input points lost 0 → 0;
    • the port's copy of Polygons.txt and the rest of its suite pass.

Related issues

Not the same as #1083 (a non-simple output path without extra area) or #1085 (a zero-width bridge; its input is unaffected by this change). #1067 also reported extra area with sliver triangles, but current C++ already returns the correct result for that input.

When integer intersection points close a narrow crack between thin edges
into a small hole, the output path before cleanup is still correct there:
the remaining path winds +1 and a smaller split of opposite orientation
winds -1. DoSplitOp discarded that split as a spurious twist, so the
remaining path filled the hole and Union covered area outside all inputs.

Before discarding such a split, check whether its centroid has a non-zero
winding number relative to the remaining path; if it does, keep the split
as a separate path. Splits outside the remaining path are still discarded.

Same change in C++, C# and Delphi. Tests/Polygons.txt case 196: three thin
triangles whose union previously covered (-7, 7).
@bgtnt

bgtnt commented Sep 24, 2026

Copy link
Copy Markdown

Independent confirmation, with more cases. I ran into this while validating an exact boundary-winding area implementation against Clipper2 (PolylineKit, WindingArea). Tested in C# only; C++ and Delphi were not built.

Every disagreement I found between Clipper2 and exact references is fixed by this PR:

Case NuGet 2.0.0 main @ f9c5eb6 this PR @ c14564a
13-vertex self-intersecting integer path, Union NonZero wrong (+1/12) wrong ok
integer region pair #81, NonZero (Union / Xor) wrong (up to 0.55) ok ok
integer region pair #137, NonZero wrong (0.033) wrong ok
integer region pair #137, EvenOdd wrong (0.033) wrong ok
integer region pair #146, NonZero wrong (0.037) ok ok
4 real gesture pairs ($1 dataset, open strokes bridged into one path, PathsD precision 8) wrong (4.5e-6 to 6.5e-5) wrong ok (≤ 7e-9)

"ok" means within 1e-6 of the reference; the PR's largest remaining difference on the integer cases is 6.9e-7, consistent with quantization at precision 8. References:

  • integer cases: exact rational slab integration, plus a scanline integral with a stated error bound;
  • real pairs: an exact horizontal-slab integral.

The integer inputs are listed in clipper-disagreements.json. The real pairs are not degenerate (no exact collinearity or shared vertices); their coordinates are derived from the $1 dataset, which I don't redistribute, but I can send them.

Minimal case (7 vertices, single path). Vertex (2,1) lies exactly on the path's own edge (3,0)→(0,3):

(0,0) (1,3) (3,0) (0,3) (3,2) (1,1) (2,1)
  • Exact NonZero area: 3169/840 = 3.772619…
  • main returns 3.939286 at any scale from 10³ to 10⁸, or any PathsD precision from 2 to 8. That is exactly 1/6 too much: the triangle (1,1), (2,1), (5/3,4/3) has winding number 0 but is filled.
  • Hand check at (1.5, 1.1): the rightward ray crosses (1,3)→(3,0) downward and (3,0)→(0,3) upward, so the winding number is 0. PointInPolygon on main's single solution path returns IsInside.
  • Moving (2,1) one unit off the edge at scale 10⁶ gives the correct area. EvenOdd is correct.
  • With this PR the solution has an additional path of area −1/6, and the area is correct.

Suggested regression test, which fails on main and passes with this PR (Tests1: 5/5):

CAPTION: 197.
CLIPTYPE: UNION
FILLRULE: NONZERO
SOL_AREA: 3772619
SOL_COUNT: 2
SUBJECTS
0,0, 1000,3000, 3000,0, 0,3000, 3000,2000, 1000,1000, 2000,1000

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