From c14564ab6aa66d649a26d15f17255c3396a709ab Mon Sep 17 00:00:00 2001 From: Yurii Date: Tue, 15 Sep 2026 01:23:51 +0300 Subject: [PATCH] DoSplitOp: keep a reversed split that lies inside the remaining path 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). --- CPP/Clipper2Lib/src/clipper.engine.cpp | 33 +++++++++++++++++++++++- CSharp/Clipper2Lib/Clipper.Engine.cs | 31 ++++++++++++++++++++++- Delphi/Clipper2Lib/Clipper.Engine.pas | 35 +++++++++++++++++++++++++- Tests/Polygons.txt | 12 ++++++++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/CPP/Clipper2Lib/src/clipper.engine.cpp b/CPP/Clipper2Lib/src/clipper.engine.cpp index 6442c96ad..e3d6f7baf 100644 --- a/CPP/Clipper2Lib/src/clipper.engine.cpp +++ b/CPP/Clipper2Lib/src/clipper.engine.cpp @@ -385,6 +385,32 @@ namespace Clipper2Lib { static_cast(pt2.y + pt3.y) * static_cast(pt2.x - pt3.x)); } + // returns true when the triangle's centroid has a non-zero winding number + // relative to the closed path at op (the centroid is kept in doubles so that + // it isn't rounded outside a very thin triangle) + inline bool TriangleInsidePath(const OutPt* op, + const Point64& pt1, const Point64& pt2, const Point64& pt3) + { + const double x = (static_cast(pt1.x) + pt2.x + pt3.x) / 3; + const double y = (static_cast(pt1.y) + pt2.y + pt3.y) / 3; + int winding = 0; + const OutPt* op2 = op; + do + { + const Point64& p = op2->pt; + const Point64& q = op2->next->pt; + const double cross = static_cast(q.x - p.x) * (y - p.y) - + (x - p.x) * static_cast(q.y - p.y); + if (p.y <= y) + { + if (q.y > y && cross > 0) ++winding; + } + else if (q.y <= y && cross < 0) --winding; + op2 = op2->next; + } while (op2 != op); + return winding != 0; + } + void ReverseOutPts(OutPt* op) { if (!op) return; @@ -1607,8 +1633,13 @@ namespace Clipper2Lib { // So the only way for these areas to have the same sign is if // the split triangle is larger than the path containing prevOp or // if there's more than one self-intersection. + // A smaller split of opposite orientation is usually a spurious twist and + // is discarded. However, when it lies inside the remaining path it is a + // hole that the remaining path already covers, and discarding it would + // fill that hole. So such a split is kept as a separate path. if (absArea2 >= 1 && - (absArea2 > absArea1 || (area2 > 0) == (area1 > 0))) + (absArea2 > absArea1 || (area2 > 0) == (area1 > 0) || + TriangleInsidePath(prevOp, ip, splitOp->pt, splitOp->next->pt))) { OutRec* newOr = NewOutRec(); newOr->owner = outrec->owner; diff --git a/CSharp/Clipper2Lib/Clipper.Engine.cs b/CSharp/Clipper2Lib/Clipper.Engine.cs index 481e723af..ef7d0097a 100644 --- a/CSharp/Clipper2Lib/Clipper.Engine.cs +++ b/CSharp/Clipper2Lib/Clipper.Engine.cs @@ -706,6 +706,30 @@ private static double AreaTriangle(Point64 pt1, Point64 pt2, Point64 pt3) (double) (pt2.Y + pt3.Y) * (pt2.X - pt3.X); } + // returns true when the triangle's centroid has a non-zero winding number + // relative to the closed path at op (the centroid is kept in doubles so that + // it isn't rounded outside a very thin triangle) + private static bool TriangleInsidePath(OutPt op, Point64 pt1, Point64 pt2, Point64 pt3) + { + double x = ((double) pt1.X + pt2.X + pt3.X) / 3; + double y = ((double) pt1.Y + pt2.Y + pt3.Y) / 3; + int winding = 0; + OutPt op2 = op; + do + { + Point64 p = op2.pt; + Point64 q = op2.next!.pt; + double cross = (double) (q.X - p.X) * (y - p.Y) - (x - p.X) * (q.Y - p.Y); + if (p.Y <= y) + { + if (q.Y > y && cross > 0) winding++; + } + else if (q.Y <= y && cross < 0) winding--; + op2 = op2.next; + } while (op2 != op); + return winding != 0; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static OutRec? GetRealOutRec(OutRec? outRec) { @@ -2930,9 +2954,14 @@ private void DoSplitOp(OutRec outrec, OutPt splitOp) // So the only way for these areas to have the same sign is if // the split triangle is larger than the path containing prevOp or // if there's more than one self=intersection. + // A smaller split of opposite orientation is usually a spurious twist and + // is discarded. However, when it lies inside the remaining path it is a + // hole that the remaining path already covers, and discarding it would + // fill that hole. So such a split is kept as a separate path. if (!(absArea2 > 1) || (!(absArea2 > absArea1) && - ((area2 > 0) != (area1 > 0)))) return; + ((area2 > 0) != (area1 > 0)) && + !TriangleInsidePath(prevOp, ip, splitOp.pt, splitOp.next.pt))) return; OutRec newOutRec = NewOutRec(); newOutRec.owner = outrec.owner; splitOp.outrec = newOutRec; diff --git a/Delphi/Clipper2Lib/Clipper.Engine.pas b/Delphi/Clipper2Lib/Clipper.Engine.pas index fba641eae..a5317a2d5 100644 --- a/Delphi/Clipper2Lib/Clipper.Engine.pas +++ b/Delphi/Clipper2Lib/Clipper.Engine.pas @@ -1289,6 +1289,34 @@ function AreaTriangle(const pt1, pt2, pt3: TPoint64): double; end; //------------------------------------------------------------------------------ +function TriangleInsidePath(op: POutPt; const pt1, pt2, pt3: TPoint64): Boolean; +var + op2: POutPt; + x, y, cross: double; + winding: integer; +begin + // returns true when the triangle's centroid has a non-zero winding number + // relative to the closed path at op (the centroid is kept in doubles so + // that it isn't rounded outside a very thin triangle) + x := pt1.X / 3 + pt2.X / 3 + pt3.X / 3; + y := pt1.Y / 3 + pt2.Y / 3 + pt3.Y / 3; + winding := 0; + op2 := op; + repeat + cross := (op2.next.pt.X - op2.pt.X) * (y - op2.pt.Y) - + (x - op2.pt.X) * (op2.next.pt.Y - op2.pt.Y); + if op2.pt.Y <= y then + begin + if (op2.next.pt.Y > y) and (cross > 0) then inc(winding); + end + else if (op2.next.pt.Y <= y) and (cross < 0) then + dec(winding); + op2 := op2.next; + until op2 = op; + Result := winding <> 0; +end; +//------------------------------------------------------------------------------ + function OutrecIsAscending(hotEdge: PActive): Boolean; {$IFDEF INLINING} inline; {$ENDIF} begin @@ -2189,9 +2217,14 @@ procedure TClipperBase.DoSplitOp(outrec: POutRec; splitOp: POutPt); // negative areas), and given area2 is the area of a new triangle containing // splitOp, splitOp.next and the intesect pt, if area1 and area2 have the // same sign then area2 must be the larger of the splits. + // A smaller split of opposite orientation is usually a spurious twist and + // is discarded. However, when it lies inside the remaining path it is a + // hole that the remaining path already covers, and discarding it would + // fill that hole. So such a split is kept as a separate path. if (absArea2 < 1) or - ((absArea2 < absArea1) and ((area2 > 0) <> (area1 > 0))) then + ((absArea2 < absArea1) and ((area2 > 0) <> (area1 > 0)) and + not TriangleInsidePath(prevOp, ip, splitOp.pt, splitOp.next.pt)) then begin Dispose(splitOp.next); Dispose(splitOp); diff --git a/Tests/Polygons.txt b/Tests/Polygons.txt index f7ef19767..adba05a72 100644 --- a/Tests/Polygons.txt +++ b/Tests/Polygons.txt @@ -1922,4 +1922,14 @@ SUBJECTS 795,573 795,575 794,575 794,578 795,578 795,579 804,579 804,578 806,578 806,578 806,573 750,569 750,572 756,572 756,569 750,566 750,569 756,569 756,566 -765,566 765,569 768,569 768,566 \ No newline at end of file +765,566 765,569 768,569 768,566 + +CAPTION: 196. +CLIPTYPE: UNION +FILLRULE: NONZERO +SOL_AREA: 1254 +SOL_COUNT: 2 +SUBJECTS +91,7, -145,-11, -141,-15 +-28,75, -33,76, 1,0 +-22,51, -39,76, -25,-2