Skip to content

Commit 93e1d8d

Browse files
Merge pull request #22 from SixLabors/js/normalize-self-intersections
Add PolygonStroker and RemoveSelfIntersections for robust, high-performance stroke normalization
2 parents fb6c6e1 + 5f2716f commit 93e1d8d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+12929
-165
lines changed

.github/workflows/build-and-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ on:
1111
branches:
1212
- main
1313
- release/*
14-
types: [ labeled, opened, synchronize, reopened ]
14+
types: [ opened, synchronize, reopened ]
1515
jobs:
1616
# Prime a single LFS cache and expose the exact key for the matrix
1717
WarmLFS:

.github/workflows/code-coverage.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ jobs:
8080
XUNIT_PATH: .\tests\PolygonClipper.Tests # Required for xunit
8181

8282
- name: Codecov Update
83-
uses: codecov/codecov-action@v4
83+
uses: codecov/codecov-action@v5
8484
if: matrix.options.codecov == true && startsWith(github.repository, 'SixLabors')
8585
with:
8686
flags: unittests
87+
token: ${{ secrets.CODECOV_TOKEN }}

PolygonClipper.sln

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio Version 17
4-
VisualStudioVersion = 17.12.35707.178
3+
# Visual Studio Version 18
4+
VisualStudioVersion = 18.2.11415.280
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PolygonClipper", "src\PolygonClipper\PolygonClipper.csproj", "{3C8D945E-6074-437E-B6EA-237BD0C80411}"
77
EndProject
@@ -16,11 +16,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
1616
.editorconfig = .editorconfig
1717
.gitattributes = .gitattributes
1818
.gitignore = .gitignore
19+
reference\10.1016@j.advengsoft.2013.04.004.pdf = reference\10.1016@j.advengsoft.2013.04.004.pdf
20+
reference\129902.129906.pdf = reference\129902.129906.pdf
1921
ci-build.ps1 = ci-build.ps1
2022
ci-pack.ps1 = ci-pack.ps1
2123
ci-test.ps1 = ci-test.ps1
2224
LICENSE = LICENSE
2325
README.md = README.md
26+
THIRD-PARTY-NOTICES.md = THIRD-PARTY-NOTICES.md
2427
EndProjectSection
2528
EndProject
2629
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeoJson", "tests\GeoJson\GeoJson.csproj", "{F881441F-D3B6-4B48-8CEF-8DC0746D4578}"

README.md

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,81 @@ SixLabors.PolygonClipper
88
<div align="center">
99

1010
[![Build Status](https://img.shields.io/github/actions/workflow/status/SixLabors/PolygonClipper/build-and-test.yml?branch=main)](https://github.com/SixLabors/PolygonClipper/actions)
11-
[![Code coverage](https://codecov.io/gh/SixLabors/PolygonClipper/branch/main/graph/badge.svg)](https://codecov.io/gh/SixLabors/PolygonClipper)
11+
[![codecov](https://codecov.io/github/SixLabors/PolygonClipper/graph/badge.svg?token=ZEK38fv18V)](https://codecov.io/github/SixLabors/PolygonClipper)
1212
[![License: Six Labors Split](https://img.shields.io/badge/license-Six%20Labors%20Split-%23e30183)](https://github.com/SixLabors/PolygonClipper/blob/main/LICENSE)
1313

1414
</div>
1515

16-
A C# implementation of the Martínez–Rueda algorithm for performing Boolean operations on polygons. This library supports union, intersection, difference, and xor operations on complex polygons with holes, multiple contours, and self-intersections.
16+
SixLabors.PolygonClipper provides high-performance polygon clipping and stroking in C#.
17+
Boolean operations (union, intersection, difference, xor) are implemented with a Martínez-Rueda sweep-line pipeline for complex polygons with holes and multiple contours.
18+
Contour normalization is handled by a dedicated Vatti/Clipper2-inspired pipeline (`PolygonClipper.Normalize`) that resolves self-intersections/overlaps into positive-winding output.
19+
`PolygonStroker` can optionally run that normalization pass on emitted stroke geometry.
1720

1821
## Features
1922

2023
- Works with non-convex polygons, including holes and multiple disjoint regions
2124
- Handles edge cases like overlapping edges and vertical segments
2225
- Preserves topology: output polygons include hole/contour hierarchy
2326
- Deterministic and robust sweep line algorithm with O((n + k) log n) complexity
27+
- Includes `PolygonClipper.Normalize` (Clipper2-inspired) for positive-winding contour normalization
28+
- Includes `PolygonStroker` for configurable geometric stroking (joins, caps, miter limits)
29+
- Uses double precision geometry without coordinate quantization
2430

2531
## Usage
2632

27-
The API centers around `Polygon` and `Contour` types. Construct input polygons using contours, then apply Boolean operations via the `PolygonClipper` class:
33+
The API centers around `Polygon` and `Contour` types. Construct input polygons using contours, then apply Boolean operations via `PolygonClipper`:
2834

2935
```csharp
30-
Polygon result = PolygonClipper.Intersect(subject, clipping);
36+
Polygon result = PolygonClipper.Union(subject, clipping);
3137
```
3238

33-
## Based On
39+
Boolean operations can process self-intersecting inputs directly.
40+
Use normalization when you want canonical positive-winding contours (for example, before export or rendering pipelines that rely on winding semantics):
3441

35-
This implementation is based on the algorithm described in:
42+
```csharp
43+
Polygon clean = PolygonClipper.Normalize(input);
44+
```
45+
46+
`Normalize` uses a fixed positive-winding normalization path.
47+
48+
### Stroking
49+
50+
Use `PolygonStroker` to generate filled stroke polygons from input contours:
51+
52+
```csharp
53+
Polygon stroked = PolygonStroker.Stroke(input, width: 12);
54+
```
55+
56+
Configure join/cap behavior through `StrokeOptions`:
57+
58+
```csharp
59+
StrokeOptions options = new()
60+
{
61+
LineJoin = LineJoin.Round,
62+
LineCap = LineCap.Round,
63+
MiterLimit = 4,
64+
InnerMiterLimit = 1.01,
65+
ArcDetailScale = 1
66+
};
67+
68+
Polygon stroked = PolygonStroker.Stroke(input, width: 12, options);
69+
```
70+
71+
## Algorithm References
72+
73+
This project draws on the following algorithm and implementation references:
74+
75+
> F. Martínez et al., "A simple algorithm for Boolean operations on polygons", *Advances in Engineering Software*, 64 (2013), pp. 11-19.
76+
> https://doi.org/10.1016/j.advengsoft.2013.04.004
77+
78+
> B. R. Vatti, "A generic solution to polygon clipping", *Communications of the ACM*, 35(7), 1992, pp. 56-63.
79+
> https://dl.acm.org/doi/pdf/10.1145/129902.129906
80+
81+
> 21re, *rust-geo-booleanop* (Rust Martínez-Rueda implementation reference).
82+
> https://github.com/21re/rust-geo-booleanop
3683
37-
> F. Martínez et al., "A simple algorithm for Boolean operations on polygons", *Advances in Engineering Software*, 64 (2013), pp. 11–19.
38-
> https://sci-hub.se/10.1016/j.advengsoft.2013.04.004
84+
> Angus Johnson, *Clipper2* polygon clipping library (reference implementation for the normalization pipeline).
85+
> https://github.com/AngusJohnson/Clipper2
3986
4087
## License
4188

THIRD-PARTY-NOTICES.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Third-Party Notices
2+
3+
This project includes code and algorithmic adaptations derived from the following third-party projects.
4+
5+
## Clipper2
6+
7+
- Project: `Clipper2`
8+
- Upstream: <https://github.com/AngusJohnson/Clipper2>
9+
- Referenced commit: `20f05b475ea81e60a230b92e4d33438cd39dd8e1`
10+
- License: `Boost Software License - Version 1.0`
11+
- Upstream copyright notice:
12+
- `Copyright : Angus Johnson 2010-2025`
13+
14+
### Boost Software License - Version 1.0 - August 17th, 2003
15+
16+
Permission is hereby granted, free of charge, to any person or organization
17+
obtaining a copy of the software and accompanying documentation covered by
18+
this license (the "Software") to use, reproduce, display, distribute,
19+
execute, and transmit the Software, and to prepare derivative works of the
20+
Software, and to permit third-parties to whom the Software is furnished to
21+
do so, all subject to the following:
22+
23+
The copyright notices in the Software and this entire statement, including
24+
the above license grant, this restriction and the following disclaimer,
25+
must be included in all copies of the Software, in whole or in part, and
26+
all derivative works of the Software, unless such copies or derivative
27+
works are solely in the form of machine-executable object code generated by
28+
a source language processor.
29+
30+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32+
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
33+
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
34+
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
35+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
36+
DEALINGS IN THE SOFTWARE.
37+
38+
## rust-geo-booleanop
39+
40+
- Project: `rust-geo-booleanop`
41+
- Upstream: <https://github.com/21re/rust-geo-booleanop>
42+
- Referenced commit: `b9bbdbb34ebfa2aaeb99665842e64ede1696ed65`
43+
- License: `MIT License`
44+
- Upstream copyright notice:
45+
- `Copyright (c) 2018 Alexander Milevski`
46+
47+
### MIT License
48+
49+
Copyright (c) 2018 Alexander Milevski
50+
51+
Permission is hereby granted, free of charge, to any person obtaining a copy
52+
of this software and associated documentation files (the "Software"), to deal
53+
in the Software without restriction, including without limitation the rights
54+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
55+
copies of the Software, and to permit persons to whom the Software is
56+
furnished to do so, subject to the following conditions:
57+
58+
The above copyright notice and this permission notice shall be included in all
59+
copies or substantial portions of the Software.
60+
61+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
62+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
63+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
64+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
65+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
66+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
67+
SOFTWARE.

reference/129902.129906.pdf

1.56 MB
Binary file not shown.

src/PolygonClipper/ActiveEdge.cs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Runtime.CompilerServices;
5+
6+
namespace SixLabors.PolygonClipper;
7+
8+
/// <summary>
9+
/// Represents an edge that is currently active in the sweep-line.
10+
/// </summary>
11+
/// <remarks>
12+
/// The sweep assumes a Y-axis-positive-down coordinate system. "Bottom" and "Top"
13+
/// refer to the lower and upper scanline endpoints (larger and smaller Y respectively).
14+
/// </remarks>
15+
internal sealed class ActiveEdge
16+
{
17+
#pragma warning disable SA1401 // Hot sweep state uses fields to avoid accessor overhead.
18+
/// <summary>
19+
/// The lower endpoint of the edge in scanline order.
20+
/// </summary>
21+
public Vertex Bottom;
22+
23+
/// <summary>
24+
/// The upper endpoint of the edge in scanline order.
25+
/// </summary>
26+
public Vertex Top;
27+
28+
/// <summary>
29+
/// The X coordinate where the edge intersects the current scanline.
30+
/// </summary>
31+
public double CurrentX;
32+
33+
/// <summary>
34+
/// The delta-X per delta-Y for the edge (its scanline slope).
35+
/// </summary>
36+
public double Dx;
37+
38+
/// <summary>
39+
/// The winding delta contributed by this edge (+1 or -1).
40+
/// </summary>
41+
public int WindDelta;
42+
43+
/// <summary>
44+
/// The accumulated winding count for this edge.
45+
/// </summary>
46+
public int WindCount;
47+
48+
/// <summary>
49+
/// The output record this edge is contributing to, if any.
50+
/// </summary>
51+
public OutputRecord? OutputRecord;
52+
53+
/// <summary>
54+
/// The previous edge in the Active Edge List (AEL).
55+
/// </summary>
56+
public ActiveEdge? PrevInAel;
57+
58+
/// <summary>
59+
/// The next edge in the Active Edge List (AEL).
60+
/// </summary>
61+
public ActiveEdge? NextInAel;
62+
63+
/// <summary>
64+
/// The previous edge in the Sorted Edge List (SEL).
65+
/// </summary>
66+
public ActiveEdge? PrevInSel;
67+
68+
/// <summary>
69+
/// The next edge in the Sorted Edge List (SEL).
70+
/// </summary>
71+
public ActiveEdge? NextInSel;
72+
73+
/// <summary>
74+
/// The temporary link used when sorting intersections.
75+
/// </summary>
76+
public ActiveEdge? Jump;
77+
78+
/// <summary>
79+
/// The current top vertex for this edge's bound.
80+
/// </summary>
81+
public SweepVertex? VertexTop;
82+
83+
/// <summary>
84+
/// The local minima that spawned this edge.
85+
/// </summary>
86+
public LocalMinima LocalMin;
87+
88+
/// <summary>
89+
/// Indicates whether this edge is the left bound of its pair.
90+
/// </summary>
91+
public bool IsLeftBound;
92+
93+
/// <summary>
94+
/// The pending join state for this edge.
95+
/// </summary>
96+
public JoinWith JoinWith;
97+
#pragma warning restore SA1401
98+
99+
/// <summary>
100+
/// Gets a value indicating whether this edge currently contributes to output.
101+
/// </summary>
102+
public bool IsHot => this.OutputRecord != null;
103+
104+
/// <summary>
105+
/// Gets a value indicating whether the edge is horizontal within tolerance.
106+
/// </summary>
107+
public bool IsHorizontal => this.Top.Y == this.Bottom.Y;
108+
109+
/// <summary>
110+
/// Gets a value indicating whether a horizontal edge is heading right.
111+
/// </summary>
112+
public bool IsHeadingRightHorizontal => double.IsNegativeInfinity(this.Dx);
113+
114+
/// <summary>
115+
/// Gets a value indicating whether a horizontal edge is heading left.
116+
/// </summary>
117+
public bool IsHeadingLeftHorizontal => double.IsPositiveInfinity(this.Dx);
118+
119+
/// <summary>
120+
/// Gets a value indicating whether the current top vertex is a local maxima.
121+
/// </summary>
122+
public bool IsMaxima => this.VertexTop != null && this.VertexTop.IsMaxima;
123+
124+
/// <summary>
125+
/// Gets a value indicating whether this edge is the front edge of its output record.
126+
/// </summary>
127+
public bool IsFront => this.OutputRecord != null && this == this.OutputRecord.FrontEdge;
128+
129+
/// <summary>
130+
/// Gets the next input vertex along the bound in the winding direction.
131+
/// </summary>
132+
public SweepVertex NextVertex => this.WindDelta > 0 ? this.VertexTop!.Next! : this.VertexTop!.Prev!;
133+
134+
/// <summary>
135+
/// Gets the vertex two steps behind the current top, used for turn tests.
136+
/// </summary>
137+
public SweepVertex PrevPrevVertex => this.WindDelta > 0 ? this.VertexTop!.Prev!.Prev! : this.VertexTop!.Next!.Next!;
138+
139+
/// <summary>
140+
/// Finds the previous hot edge in the AEL, if any.
141+
/// </summary>
142+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
143+
public ActiveEdge? GetPrevHotEdge()
144+
{
145+
ActiveEdge? prev = this.PrevInAel;
146+
while (prev != null && !prev.IsHot)
147+
{
148+
prev = prev.PrevInAel;
149+
}
150+
151+
return prev;
152+
}
153+
154+
/// <summary>
155+
/// Calculates the X coordinate where this edge intersects the scanline at <paramref name="currentY" />.
156+
/// </summary>
157+
// This method sits on the hottest path in large self-intersection workloads.
158+
// AggressiveOptimization consistently improves codegen here versus tiered defaults.
159+
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
160+
public static double TopX(ActiveEdge edge, double currentY)
161+
{
162+
if (currentY == edge.Top.Y || edge.Top.X == edge.Bottom.X)
163+
{
164+
return edge.Top.X;
165+
}
166+
167+
if (currentY == edge.Bottom.Y)
168+
{
169+
return edge.Bottom.X;
170+
}
171+
172+
return edge.Bottom.X + (edge.Dx * (currentY - edge.Bottom.Y));
173+
}
174+
175+
/// <summary>
176+
/// Recomputes <see cref="Dx" /> from the current endpoints.
177+
/// </summary>
178+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
179+
public void UpdateDx() => this.Dx = GetDx(this.Bottom, this.Top);
180+
181+
/// <summary>
182+
/// Computes delta-X per delta-Y, returning infinities for horizontal edges.
183+
/// </summary>
184+
private static double GetDx(Vertex pt1, Vertex pt2)
185+
{
186+
double dy = pt2.Y - pt1.Y;
187+
if (dy != 0)
188+
{
189+
return (pt2.X - pt1.X) / dy;
190+
}
191+
192+
return pt2.X > pt1.X ? double.NegativeInfinity : double.PositiveInfinity;
193+
}
194+
}

0 commit comments

Comments
 (0)