Module path: github.com/lestrrat-go/polyclip
A pure-Go library for 2D polygon boolean operations and offsetting. This document records the design decisions behind the library: the public surface and why it is shaped this way, the algorithm chosen and the alternatives rejected, and the design of the scanline engine internals (§11–§12) — chiefly the degenerate-case handling that is the bulk of the engine's complexity (§12.11). It is not a changelog or a status report; for current state, test it.
Section numbers in §4–§12 are stable: source comments reference them (e.g. DESIGN.md §12.11). Subsection anchors must not be renumbered.
A pure-Go library for 2D polygon operations:
- Boolean ops on filled polygonal regions: union, intersection, difference, symmetric difference (XOR).
- Polygon offset ("inflate" / "shrink"): inward and outward, with miter / round / square joins.
- Robust handling of polygons with holes, self-intersections, coincident edges, and overlapping boundaries.
The shape primitive is a simple-polygon-with-holes (ExPolygon) and collections of them (MultiPolygon); every operation is closed over MultiPolygon.
The target workload is 3D-printer slicing, where nearly every quality feature needs reliable polygon arithmetic, and the Go ecosystem lacks a robust pure-Go library for it. The established C++ reference for this problem is Clipper2 (Angus Johnson), whose algorithm polyclip's engine is built on (§4.1).
Goals: correctness on adversarial input (concentric circles, self-touching polygons, collinear/coincident edges, near-degenerate slivers); pure Go (no cgo); closed (MultiPolygon in, MultiPolygon out); idiomatic small API; acceptable performance on slicer workloads.
Non-goals: 3D, curved geometry (NURBS / true arcs — arcs are polyline-approximated), cgo bindings to a native clipping library. The planar-polygon feature surface and the design choice behind each feature are in §7.8.
github.com/lestrrat-go/polyclip
├── polyclip.go package doc, top-level conveniences
├── boolean.go Union, Intersect, Difference, Xor, UnionAll (public API)
├── offset.go Offset, JoinType, EndType, OffsetOptions (public API)
├── offsetpaths.go OffsetPaths — open-polyline ribbon offset with end caps
├── builder.go Builder, Result, PolyTree, ZAssigner (advanced public API)
├── geom/ geometry value types (subpackage, no engine dependency)
│ ├── point.go Point, BBox
│ ├── polygon.go Polygon, ExPolygon, MultiPolygon, Polyline; winding, area, contains, Clean
│ └── validate.go Validate, IssueKind, ValidationIssue; structural diagnostics
├── internal/clip/ scanline boolean engine
│ ├── segment.go fixed-point directed-edge type, source tag
│ ├── preprocess.go snap, dedup, overlap/T-junction splitting
│ ├── bounds.go local-minima / bound construction, ring tracing
│ ├── event.go event queue
│ ├── ael.go active edge list
│ ├── sweep.go scanline loop, DoIntersections, closeBound, lifecycle
│ ├── classify.go winding-count classification
│ ├── poly_ops.go IntersectEdges dispatch
│ ├── output.go OutPt / OutRec ring construction
│ ├── horizontal.go horizontal classification
│ ├── horzjoin.go deferred horizontal joins
│ └── invariants.go post-condition checks
├── internal/fixed/ fixed-point arithmetic (coord.go, mul.go)
├── tools/differential/ Monte-Carlo differential harness (correctness oracle)
└── examples/ runnable, output-verified testable examples (package examples_test)
The internal/clip and internal/fixed packages hold the engine and are not
importable outside the module. The geom subpackage holds the public geometry
value types and carries no dependency on the engine; together with the
top-level polyclip package (which holds the operations) it forms the stable
public API.
The public surface is small; see the Go doc comments for full signatures.
- Core types (package
geom):geom.Point{X,Y float64},geom.BBox,geom.Polygon []geom.Point(implicit closing edge; outer rings CCW, holes CW by convention but either is accepted and normalized),geom.ExPolygon{Outer, Holes},geom.MultiPolygon []geom.ExPolygon, andgeom.Polylinefor open paths. These value types live in packagegeom; the operations below live in the top-levelpolyclippackage and consume them. - Boolean ops (
boolean.go):Union,Intersect,Difference,Xor— each(a, b geom.MultiPolygon) (geom.MultiPolygon, error);UnionAll(...geom.MultiPolygon)for tournament-reduced multi-union. - Offset (
offset.go):Offset(m, d, opts)withOffsetOptions{Join, MiterLimit, ArcTol}andJoinType∈ {miter, round, square}. - Geometry utilities (package
geom):SignedArea,Area,IsCCW,Reverse,BoundingBox,Contains(even-odd, boundary inside);MultiPolygon.Clean(vertexTol, minArea);MultiPolygon.Validate() []ValidationIssue. - Shape builder (package
geom):geom.New()returns a fluent*geom.Builder—Point/Point3extend the current piece's outer ring,Hole(...Polygon)attaches holes (pre-built, literal, or a spread[]Polygon),NextPiecestarts a disjoint piece, andBuild/MustBuildnormalize winding and emit aMultiPolygon.Polygon/MustPolygonextract a fluently-built single ring (e.g. to pass toHole).
error is returned only for caller-fixable problems (e.g. a bounding box too large for the fixed-point grid, §5.1, or an offset that collapses to empty). Validate() issues are diagnostics, not errors.
Open-polyline clipping is available via Builder.AddOpenSubject + Result.Open; open-polyline offset (ribbons with end caps) via OffsetPaths; caller-selectable fill rules (incl. even-odd) via Builder.Fill; nested-hierarchy output via Builder.ExecuteTree (PolyTree); Douglas–Peucker path reduction via SimplifyPaths; bevel joins via JoinBevel; Minkowski sum/difference via MinkowskiSum/MinkowskiDiff; fast axis-aligned rectangle clip via RectClip/RectClipLines.
The engine is a Vatti scanline modeled on Clipper2 (Angus Johnson, CPP/Clipper2Lib/src/clipper.engine.cpp); polyclip's sweep follows its algorithm and data model, and engine.cpp:line references throughout this document point into that source. See NOTICE for attribution.
Plain-English sketch:
- Input prep (§11.2): scale float64 input to a fixed-point integer grid (§5), split each polygon into directed edges tagged subject/clip, split overlaps and T-junctions.
- Local minima / bounds (§12.1): reframe each ring as alternating ascending/descending bounds meeting at local minima/maxima.
- Scanline sweep (§11.5, §12.10): maintain an active edge list (AEL) of edges crossing the current scanline; spawn bounds at local minima, advance cursors, close at maxima.
- Crossings (§12.11): per scanbeam, recompute all edge crossings from the settled AEL (
DoIntersections) and dispatch each throughIntersectEdges(§12.5). - Classification (§11.4): each edge carries winding counts; the op + winding decides whether it bounds the result.
- Output (§11.6, §11.9): contributing edges build doubly-linked rings; postprocess assigns holes and normalizes winding.
clip/preprocess.go— scale/snap, dedup, overlap and T-junction splitting.clip/bounds.go—BuildLocalMinima, bound construction, ring tracing.clip/sweep.go— the scanline loop,DoIntersections, lifecycle (closeBound, cursor advance), degenerate-confluence handling.clip/poly_ops.go—IntersectEdgesdispatch table.clip/classify.go— winding-count classification per op.clip/output.go—OutPt/OutRec,AddLocalMinPoly/AddLocalMaxPoly/JoinOutrecPaths/SwapOutrecs.
Offset walks each input ring once and emits an offset ring directly, vertex by vertex. With n_i the right-hand unit normal of edge ring[i]→ring[i+1] and d the signed distance, each vertex v expands based on its local turn:
a = v + d·prevN,c = v + d·nextN— offset endpoints of the prev/next edges atv.cross = prevN × nextN; the sign ofcross·dclassifies the corner:- Wedge (
cross·d > 0): convex offset corner; emit a join (miter apex, square chamfer, or tessellated arc) perOffsetOptions.Join. - Overlap (
cross·d ≤ 0): the offset edges cross; emit the miter apex (for antiparallel normals, fall back to emittingaandc).
- Wedge (
Holes are offset by -d. The raw ring is emitted unconditionally — when an inward offset overshoots the inradius it self-intersects (a pinched neck, a closing notch, an inside-out collapse) rather than being rejected.
Topology resolution (§7.1). Per input ExPolygon, the raw offset rings (outer by d, holes by -d) are checked for self/mutual intersection (ringsIntersect). If none, topology is unchanged and the rings are returned directly (exact, no engine pass). If they intersect, the piece is re-resolved by a positive-fill self-union: feed the rings to the scanline engine (clip.SweepFill with clip.FillPositive), which keeps exactly the strictly-positively-wound region — the outer winds +1 inside, CW holes −1 — so a pinched ring splits into islands and the negatively-wound overshoot folds drop. An inward result piece is additionally validated against the erosion definition (insetDeepEnough: an interior point must be ≥ |d| from the input boundary), which rejects the convex "inside-out" collapse whose ring is simple and positively oriented yet sits where the offset is empty. If everything collapses, Offset returns an empty MultiPolygon with a nil error.
Degeneracy robustness. The sweep is exact on transversal self-intersections but resolves a snapped degenerate configuration (same-source collinear coincident edges from parallel walls a multiple of 2|d| apart, or a near-pinch crossing) differently — sometimes wrongly — per coordinate frame. Axis-aligned and thin-neck inward offsets hit this. So the self-union is run in several rotated frames (selfUnionResolveAngles) and the most-agreed-upon result (same piece count and area, within 2%) is kept; the correct resolution recurs across frames while each degenerate misresolution is scattered. Angle 0 (no rotation, exact coordinates) is preferred within the agreeing majority, so non-degenerate offsets keep exact output. (The boolean engine's own same-source coincident-edge gap is the deeper root cause; see §7.2.)
Direct ring construction (rather than Clipper2's "fat-edge polygons → union") avoids dense diff-source coincident-edge pile-ups and is O(n) for the common no-topology-change case; only intersecting pieces pay for the multi-frame self-union.
Implementation in offset.go: Offset (orchestration, hole sign, inset validation), offsetRing (per-ring walk), emitVertex (wedge/overlap dispatch), appendMiter/appendMiterApex/appendSquareJoin/appendRoundJoin, resolveOffsetPiece (fast path vs self-union), selfUnionPositive/selfUnionAt (multi-frame positive-fill resolution), ringsIntersect, insetDeepEnough.
- Boolean:
O((n + k) log n),n= edges,k= intersections; the per-scanbeamDoIntersectionsisO(m²)per beam ofmactive edges (correctness-first; a merge-sort inversion counter is the later optimisation). - Offset:
O(n)per ring plusO(n·m)for the inward-overshoot check (early-exits on the first failing vertex).
This separates a working library from a fragile one; treat it as load-bearing.
User input is float64; the engine scales to int64 on a uniform grid: internalCoord = int64(round(userCoord * Scale)). Scale is chosen per-operation from the input bounding box so all intermediate products (intersection determinants are degree-2 in coordinates) stay in range (int128 synthesized from two int64s for the high-precision determinant). Output is descaled to float64.
Integer coordinates eliminate the most common Vatti failure: "the same point computed two ways gives slightly different float64s and breaks topology."
Two segments cross per the signs of orientation determinants:
det(P, Q, R) = (Q.X - P.X)*(R.Y - P.Y) - (Q.Y - P.Y)*(R.X - P.X)
Computed exactly via the int128 helper in fixed/mul.go; never in float64. The intersection point needs division and may be computed in float64 / rounded to the grid — only the orientation predicates need full precision. (Reference: Shewchuk 1997; integer coordinates give exact orientation, so the simpler "exact integer predicates" approach suffices.)
Collinear overlapping edges are split in preprocess so the sweep only ever sees fully coincident pairs (§11.7), then resolved by the winding rule and the coincident-horizontal handling in §12.11.
A vertex of one polygon lying on an edge of another is exactly representable on the integer grid. SplitTJunctions (preprocess) splits the edge at that vertex up front, so the sweep never faces an ambiguous mid-edge vertex. This is the single most common Vatti bug source in implementations that skimp on §5.1.
Self-intersecting input rings are accepted; Vatti's intersection step processes every crossing, including self-crossings. Output is always simple.
The testing approach is itself a design decision, because the obvious oracle (Clipper2) is unusable on the degenerate small-integer inputs that matter most (§12.11). The strategy has four layers, each chosen for what the layer above cannot catch.
- Unit tests alongside each source file cover the pieces in isolation: predicates, segment intersection, AEL/event-queue operations, ring assembly, per-join offset geometry.
- Fuzzing (
testing.F) checks random polygons against algebraic invariants that hold regardless of the concrete geometry —Union(A,A)=A,Difference(A,A)=∅,Xor(A,B)=Union(Diff(A,B),Diff(B,A)), andArea(A∪B)=Area(A)+Area(B)−Area(A∩B). (The fuzz corpus is gitignored, so it is a discovery tool, not a fixed gate.)
- Hand-built adversarial cases pin the configurations known to break Vatti implementations: overlapping squares, square-minus-square annulus, edge- and vertex-touching unions, the self-touching "8", concentric rings, T-junctions, and hole–clip confluences. Each asserts the relevant set identity within tolerance.
- The differential harness (
tools/differential) is the standing correctness oracle. It generates random and forced-degenerate input pairs and checks the noise-free set identitiesidU = U−(A+B−I),idD = D−(A−I),idX = X−(U−I)— which must be exactly zero — plus absolute areas against a Monte-Carlo area oracle. The MC oracle, not Clipper2, is the reference by deliberate choice: on degenerate small-integer inputs Clipper2 rounds fractional crossings to its native integer grid and is itself wrong on all four ops, whereas polyclip's finer fixed-point grid is more accurate (§12.11). Any engine change is gated on this harness staying at zero identity violation.
Beyond the core sweep (§11–§12), each feature area below records the design decision taken and why — including the limitations deliberately accepted and the alternatives rejected. The boolean engine targets the noise-free set identities (§6.2) across random, large, degenerate, and holed inputs at slicer-grade correctness; the choices here extend that surface toward a drop-in slicer geometry layer.
Offset re-resolves inward topology changes — a neck pinched into two islands, a
notch that closes, an over-shrunk ring that collapses to empty — via the per-piece
positive-fill self-union of §4.3, made robust by a rotated majority vote. The vote
is needed because the sweep mis-resolves snapped same-source coincident edges
(§7.2); the design accepts its cost (≈8 sweeps per topology-changing piece) because
non-topology-change offsets keep the exact O(n) fast path and pay nothing.
Simplify(m) runs the engine over m as a single source (NonZero) to make rings
simple: a figure-eight splits into its two oppositely-wound loops, a doubly-traced
ring collapses to one, a doubled-back spur cancels. General-position
self-intersections resolve exactly.
Known limit — exactly coincident same-source walls. A single ring with
same-source collinear coincident walls (axis-aligned and thin-neck inward
offsets produce them — the dumbbell offset by -2 traces one wall twice) is
ambiguous to a single sweep: two parallel coincident walls never cross, so no
event orders them and the winding prefix cannot tell the zero-width sliver from
the boundary. The robust resolution for such exact degeneracies is perturbation,
and the offset self-union's rotated majority vote (§7.1) is exactly that — so the
vote is the intended design here, not a stopgap, barring a future symbolic-
perturbation (Simulation-of-Simplicity) scheme that breaks ties in one sweep. The
offset path runs its self-union on the ordered-minima engine
(clip/sweep_ordered.go SweepRingsFill, which builds minima in traversal order so
the two wall passes occupy distinct positions); under AEL.Ordered the dispatch
keys on the Contributing boundary flag so a positive-fill WindSelf==0 sliver
is not dropped. All gated on fill rule / AEL.Ordered, so the boolean NonZero path
is untouched. See docs/offset-coincidence-perturbation.md.
Performance is correctness-first but two algorithmic choices keep the slicer workload (disjoint contours, big circles, brick walls, meshing gears) off the naive quadratic path:
- Preprocessing (
SplitOverlaps/SplitTJunctions) is a single batch pass each — bucket by exact supporting line, then cut each segment at interior vertices via an X-sorted vertex index — rather thanO(n²)pair scans. This is what makes sparse/disjoint/axis-aligned inputs (the common slicer-layer case) cheap. - Per-scanbeam crossing enumeration (
buildIntersectList) is a merge-sort inversion counter (O(n log n + k)): a beam crossing inverts the pair's X-order between beam bottom and top, so crossings are the inversions between the two orderings. Edge ordering uses exact 128-bit rational X-intercept comparison (fixed.CmpRationals), not floatXAtY— at the grid extremes a float intercept carries enough rounding error to mis-order and drop a crossing. This is what keeps dense mutually-intersecting inputs (meshing gears) tractable.
Acknowledged gap: there is no standing benchmark suite, so absolute throughput is uncharacterized.
Open polylines are offset into ribbons via OffsetPaths (§7.8c). EndType
selects the cap: EndButt/EndSquare/EndRound open-path caps, EndJoined
(closed-loop band), or EndPolygon (the closed Offset behaviour). This serves
the slicer's thin-wall / gap-fill / single-extrusion features.
The legacy per-edge fallback returns ErrHorizontalNotSupported only when
BuildLocalMinima fails AND the fallback's ClassifyHorizontals then meets a
staircase (mid-bound) horizontal. Historically this was readily hit on
non-simple axis-aligned input — a polygon and its hole (or two offset/slicer
loops) snapping onto one grid point makes a degree-4 self-touching vertex that
the old traceRing could not reconstruct, dropping the engine onto the
staircase-rejecting fallback (~32 % of ops on some real slicer layers).
BuildLocalMinima now decomposes self-touching rings (§12.10.7): such a
vertex pinch reconstructs into its simple loops, which the bound model handles
natively (including their mid-bound horizontals), so this case no longer errors.
The error remains reachable only for genuinely open / broken input chains
(reconstruction cannot close a ring) that also carry a mid-bound horizontal, so
it stays part of the contract and callers should handle it — but well-formed
closed input, even self-touching or self-intersecting, no longer hits it.
Distinct from §7.5 (this surfaces after the fallback succeeds, on inputs the bound model handles). Axis-aligned pairs sharing a collinear boundary — dominated by a coincident cross-source vertical wall (one polygon's wall lies exactly on the other's, overlapping in Y) — produce algebraic-identity violations (a spurious half-area lobe, or a whole-region miscount) unless the engine resolves them specially. The design resolves them in two layers:
- Sweep level. Two exactly-coincident AEL edges tie on CurrX and slope, so
they are ordered by where their bounds first diverge just above the scanline
(
coincidentDivergeLess, 128-bit cross product), not an arbitrary slope tie — this fixes which wall carriesWindOtherand hence contributes. At a coincident apex,closeBounddecides whether a ring continues above from the winding strictly above the scanline (opMember/otherSourceWindingAbove), closing at the seam viaAddLocalMaxPolywhen nothing resumes; a hot maxima edge with a cold same-source partner emits its apex so the vertex is kept. - Result level. A
runBooleanOpsubset-invariant filter drops a hole-free piece whose interior point violatesDifference ⊆ A/Intersect ⊆ A∩B(point-in-polygon) — catching any residual over-trace where a maxing bound fails to update an exiting neighbour's winding. Never drops a valid piece.
Xor is computed by composition Difference(Union(a,b), Intersect(a,b)) rather
than a direct OpXor sweep: the direct sweep mis-resolves a residual confluence
class that U/I/D handle correctly, so the public API routes around it. Retiring
both masks — letting OpXor and the result-level filter resolve in-sweep via a
per-segment winding model at confluences — is the eventual goal but not the
current design.
A multipiece subject (one source contributing several disjoint rings) hits the
same coincident cross-source over-trace as §7.6, but with a second piece present
the spurious trace merges with a valid ring, so the §7.6 subset filter cannot drop
it. The design therefore differences a multipiece subject per piece —
(∪ᵢ Pᵢ) ∖ B = ∪ᵢ (Pᵢ ∖ B), exact because a valid MultiPolygon's pieces are
disjoint (results disjoint, union is plain concatenation). Each Pᵢ ∖ B runs the
clean single-subject path, where the over-trace becomes a stray hole-free lobe the
subset filter drops; pieces clear of B short-circuit on bbox. Intersect,
Union, and Xor need no such decomposition — only Difference is asymmetric in
its subject. The single-pass in-sweep resolution that would remove the need is the
same deferred rework as §7.6.
The guiding design principle: each feature beyond the core boolean ops should be
additive — layered over the existing sweep, the containment forest (§11.9), or
Union — so the core engine stays untouched. The map of features to where each
one's design is recorded:
| Feature | Where / how |
|---|---|
Builder accumulator API |
(0) |
| Boolean ops (∪ ∩ − ⊕) | §4, §11–§12 |
| Polygon offset, closed | §4.3 |
| Join Miter / Round / Square | §4.3 |
| Join Bevel | (a) |
| Fill rules incl. EvenOdd | (b) |
| Open-path clipping | (c) |
| Open-path offset (end caps) | (c) / §7.4 |
Nested PolyTree output |
(d) |
| Minkowski sum / difference | (e) |
| RectClip / RectClipLines | (f) |
| Path reduction (Douglas–Peucker) | (g) |
| Z-coords / vertex callback | (h) |
| Triangulation | (i) |
The additive principle holds across the board with one deliberate exception: Z-coords (h) touches the engine, and only minimally — a recording hook in the AEL behind an off-by-default flag. Open-path clipping (c) was expected to need engine changes too but is designed as a standalone post-sweep pass instead.
(0) Builder accumulator API. The entry point the other
features build on: New().AddSubject(…).AddClip(…).Execute(op) returning
Result{Closed, Open}, with a root-package Operation
(OpUnion/OpIntersect/OpDifference/OpXor). The accumulator is the general
path; the named free functions (Union/Intersect/Difference/Xor) are thin
wrappers over the unexported execOp — the single home for the per-op
short-circuits, Xor-by-composition (§7.6), and per-piece Difference (§7.7), so
those decisions live in exactly one place. Execute is non-destructive; Reset
clears the inputs for reuse.
(a) Bevel join. JoinBevel joins by emitting the straight chord between the
two offset-edge endpoints a, c at a convex corner (no apex, no miter-limit
fallback) — a flat chamfer that cuts the corner. It is distinct from JoinSquare,
which extends each endpoint outward by |d|; keeping both matches Clipper2's
distinction.
(b) Caller-selectable fill rules. Exposed at the root as FillRule (NonZero
default, plus EvenOdd / Positive / Negative) selected via Builder.Fill(r); the
named free functions stay NonZero. Under EvenOdd, Classify/isContributing skip
the source-boundary winding-magnitude test (every edge is a boundary) and count
the other source's membership by crossing parity (WindOther toggles 0↔1) rather
than a signed sum; IntersectEdges swaps WindSelf and toggles WindOther on a
crossing — a direct transcription of Clipper2's EvenOdd branches. The key design
choice is that a non-NonZero fill routes through execOpFilled, which drops the
identity/disjoint/per-piece short-circuits: those assume well-formed, simply-wound
inputs, but a non-NonZero fill is chosen precisely to re-resolve self-overlapping
input (e.g. Union(s,∅) must re-fill s, not return it verbatim). Only
fill-independent empty results short-circuit, and Xor stays a composition.
Accepted limitation: a non-NonZero Difference over a multipiece subject runs one
sweep (no per-piece decomposition, §7.7), so it can hit the coincident-confluence
over-trace.
(c) Open paths — clipping and offset. Reverses the former §1/§3/§7.4 non-goal. Open paths are subjects only — a clip region must be closed, matching Clipper2.
- Type:
Polyline []PointalongsidePolygon; a result carries both a closedMultiPolygonand[]Polyline. - Clipping: designed as a standalone post-sweep pass, not the in-sweep
tagged-edge approach Clipper2 uses, so the engine stays untouched.
Builder.AddOpenSubjectaccumulates open subjects;Executeclips them intoResult.Open. Each open segment is split at every crossing of a relevant closed boundary ring (by segment/edge intersection), and each sub-segment is kept iff its midpoint satisfies the op's keep predicate — a direct port of Clipper2'sIsContributingOpen: Intersect keeps points inside the clip region; Difference and Xor keep points outside it (an open path has no area, so ⊕ reduces to −); Union keeps points outside both the subject and clip regions. Membership is the filled-region test (MultiPolygon.Contains). Survivors are stitched into chains, breaking at each dropped sub-segment. Open paths never clip one another. - Offset (§7.4):
OffsetPaths(lines, d, opts)offsets a polyline into a closed ribbon,|d|to each side, capped peropts.End(EndButtflush /EndSquareextended|d|/EndRoundsemicircle). The ribbon is built as one closed ring — start cap, forward-side interior joins, end cap, reverse-side joins — traced CCW so the existing positive-fill self-union (§4.3) resolves the self-overlap of sharp interior turns and overlapping ribbons. This reuses theOffsetjoin emitters andresolveOffsetPieceunchanged; the only new geometry isemitEndCap(a transcription of Clipper2'sDoBevel/DoSquare/DoRoundendpoint case).EndPolygonis rejected (ErrOffsetEndType).EndJoinedcloses each path into a loop (implicit last→first edge) and bands it±|d|viaoffsetJoinedBand: the loop offset outward by|d|is the outer ring, offset inward by|d|and reversed is the hole;resolveOffsetPieceyields an annulus when the loop encloses more than2|d|, a solid ribbon otherwise. A sub-3-vertex loop falls back to the capped ribbon. Mirrors Clipper2'sEndType::Joined.
(d) Nested PolyTree output. Root PolyTree{Children} /
PolyTreeNode{Polygon; IsHole; Children} plus Builder.ExecuteTree(op). The
design choice is to not thread tree assembly through the sweep: ExecuteTree
runs the same execOp as Execute (reusing every path — short-circuits, Xor
composition, per-piece Difference, alternate fills) and rebuilds the containment
forest (§11.9) over the finished MultiPolygon's rings to recover the depth-≥2
nesting a flat MultiPolygon discards (an island inside a hole is a top-level
piece in the flat form, a depth-2 child in the tree). The forest logic is shared
with assembleResult (classifiedRing/buildContainmentForest/ringDepth).
IsHole = odd depth; winding normalized as elsewhere (filled CCW, hole CW). The
correctness contract: the tree flattened (filled nodes → ExPolygon with their
hole-children, islands promoted) equals the flat Result.Closed.
(e) Minkowski sum / difference. MinkowskiSum(pattern, path, closed) emits,
for each consecutive pair of path vertices, the quadrilateral strip between the
two pattern placements (normalized to positive winding) and unions all the
strips with UnionAll under the non-zero rule; MinkowskiDiff reflects the
pattern through the origin (path[i] - pattern[k]). closed strips between the
last and first vertices as well. A faithful port of Clipper2's
Minkowski/MinkowskiSum/MinkowskiDiff, built entirely on the existing Union
so it needs no engine change.
(f) RectClip / RectClipLines. RectClip(m, rect) clips closed rings against an
axis-aligned BBox by Sutherland–Hodgman (four half-plane passes), O(n) per ring
and independent of the sweep — a deliberate fast path for the common "clip a layer
to the build plate" case rather than routing through Intersect. Each ExPolygon
is clipped independently (outer and every hole), so the hole structure is preserved
without rebuilding the containment forest: a hole stays nested because both rings
are clipped by the same rectangle. The enclosed region equals Intersect(m, rectAsPolygon). One accepted representational difference: where the rectangle
splits a concave ring into disjoint pieces, Sutherland–Hodgman returns one ring
joined by a zero-width seam along the rectangle edge rather than separate
ExPolygon values (same area; run Simplify for clean separation).
RectClipLines(lines, rect) clips open polylines by Liang–Barsky per segment,
stitching contiguous inside runs back into one polyline and splitting at each
re-entry; no seam is introduced since open paths carry no interior. Both are
errorless (the clip cannot fail) and treat an empty rect as producing no output.
(g) Path reduction. SimplifyPaths(m, epsilon) reduces each ring's vertex
count via a faithful port of Clipper2's SimplifyPath (a Douglas–Peucker-family
algorithm): every vertex's perpendicular distance to the line through its retained
neighbours is tracked, and vertices within epsilon are removed
smaller-deviation-first so collinear/near-collinear runs collapse cleanly; each
ring is treated as closed. Matching Clipper2's iterative
remove-by-perpendicular-distance (rather than classic recursive RDP) is a
deliberate choice to keep a caller porting from Clipper2 byte-for-byte compatible.
Distinct from Simplify (self-intersection resolution) and Clean
(collinear/tiny removal); named to avoid the clash. A negative epsilon is treated
as zero; rings with <4 vertices pass through; a ring reduced below 3 vertices is
dropped (and an ExPolygon whose outer ring is dropped is omitted). Standalone, no
engine.
(h) Z-coordinates / vertex callback. Clipper2's compile-time USINGZ,
re-cast as a runtime opt-in so the standard path pays nothing. Point gains a
third field Z float64; the engine ignores it (every comparison and the
fixed-point snap are X/Y-only), so it is pure auxiliary data carried input→output.
A ZAssigner interface (AssignZ(e1bot, e1top, e2bot, e2top, crossing Point) float64, the analog of Clipper2's ZCallback) is installed via
Builder.SetZAssigner; nil disables tracking (the default), leaving the standard
path bit-for-bit identical and allocation-free.
Wiring: a zTracker (assigner + a map[fixed.Point]float64 keyed by snapped grid
point) is threaded through execOp/runBooleanOp — nil on the Z-free path.
appendRing records each input vertex's Z under its grid point; the sweep, run via
clip.SweepFillZ, records every crossing IntersectEdges dispatches as a
clip.ZCrossing (four endpoints + crossing point) behind the AEL's off-by-default
RecordCrossings flag. runBooleanOp maps each crossing through the assigner —
input vertices take precedence, so a meeting at an existing vertex keeps that
vertex's Z — and assembleResult reads the table when unsnapping each output
vertex. Composition propagates naturally: Xor and per-piece Difference recurse with
the same assigner, and intermediate results already carry Z, so the next sub-op
sees it as input Z. Z applies to Result.Closed only; open-path output is rebuilt
by interpolation and carries no Z. Consequence to note: adding the field is a
(pre-1.0) breaking change to positional Point{x, y} / Polygon{{x,y},…} literals
— use keyed fields.
(i) Triangulation. Triangulate(MultiPolygon) []Triangle is a standalone implementation of the ear-clipping
algorithm with hole elimination popularized by mapbox/earcut (ISC-licensed):
a doubly-linked-list ear clip, hole bridging via findHoleBridge/splitPolygon,
and the full robustness ladder (filterPoints → cureLocalIntersections →
splitEarcut) so weakly-simple bridged polygons triangulate correctly. The
z-order hashing (a pure performance optimization) is omitted, leaving an O(n²) ear
test — an accepted cost under the correctness-first priority. Output is CCW, uses
only the input's own vertices (no Steiner points), and drops zero-area triangles.
Correctness is checked by an area-conservation oracle (summed triangle area ==
region area catches both overlap and gaps). Input must be well-formed (the form
Simplify produces); degenerate or self-touching geometry should be passed through
Simplify first.
The planar feature surface is complete (§7.8) and the differential oracle holds at zero identity violation (§6.2). What remains is quality, not coverage — each item below is correct as shipped but is a deliberate shortcut over the ideal single-pass engine, or an unverified goal. Recorded here as one list so the remaining work is findable; the rationale lives in the cross-referenced section.
- Native single-pass Xor (§7.6).
Xoris computed by compositionDifference(Union(a,b), Intersect(a,b))— three sweeps, not one — because the directOpXorsweep mis-resolves a residual confluence class. Resolving Xor in-sweep (per-segment winding at confluences, retiring both the composition and the result-level subset filter) is the eventual design. - Single-pass multipiece Difference (§7.7). A multipiece subject is differenced
per piece (
(∪ᵢ Pᵢ)∖B = ∪ᵢ(Pᵢ∖B)) to dodge the same coincident over-trace — exact, but N sweeps. Removing the decomposition is the same rework as item 1. - Non-NonZero-fill Difference over a multipiece subject (§7.8(b)). Routes
through
execOpFilled, which drops the per-piece decomposition of item 2, so it runs one sweep and can hit the coincident-confluence over-trace. Closing items 1–2 closes this. - Performance unmeasured (§7.3). There is no standing benchmark suite, so absolute throughput is uncharacterized. Triangulation is additionally O(n²) (z-order hashing omitted by choice, §7.8(i)). Needs a benchmark harness.
- Representational rough edges.
RectClipreturns a single ring joined by a zero-width seam where a rectangle bisects a concave ring, rather than separateExPolygonvalues (same area;Simplifyseparates them — §7.8(f)).Triangulaterequires well-formed input and should be preceded bySimplify(§7.8(i)).
Findings from a perf/UX-focused review of the shipped code. Each is a refactor,
not a bug — current behaviour is correct. Ordered by impact. Profiled on
BenchmarkUnionManyPieces (400 disjoint output rings) and BrickWallLarge.
Performance
buildContainmentForestX-interval broad phase — DONE. WasO(n²)and the dominant non-sweep cost (12% of total runtime, effectively all ofhundreds. Complemented the existingassembleResulton the many-pieces workload): every output ring matched against every other (canContainRing+ringContains) even when the output is fully disjoint (the common slicer case: many islands, no nesting). It is a stabbing query — for each ring's interior point, which other ring bboxes contain it, and of those the smallest-areapoly.Contains. Now resolved by the low-risk broad phase without touching nesting semantics: ring indices are sorted byMin.X, each query binary-searches theMin.X ≤ query.Xprefix and filtersMax.X ≥ query.X, then runs the unchangedcanContainRing/ringContains/ smallest-area selection over only that window. The candidate set is a sound superset (any real container's X-interval covers the query point), so output is byte-identical; the differential oracle and fuzz corpus confirm.BenchmarkUnionManyPieces−14.5% (13.7→11.7 ms);BrickWallLargeunchanged (sweep-dominated). Worst case (every X-interval overlaps) is stillO(n²); an interval tree would make itO(n log n + k)but was judged unjustified at ninteriorPointhoist intoclassifiedRing; see §7.3 and §7.9 item 4.- Preprocessing reallocates and re-maps three times (
sweepSegments,boolean.go). Partly addressed. The within-call churn is fixed: the three passes now share "append intodst" cores driven byclip.Preprocess, which ping-pongs two reusable[]Segmentbuffers acrossSplitOverlaps → SplitTJunctions → DedupCoincidentEdgesinstead of allocating one per pass, and the per-segmentseen := map[fixed.Point]struct{}{}inside the overlap splitter is replaced by aslices.Containsscan over the (tiny) cut list. Overlap-heavy inputs drop ~20% allocs/op (BrickWall benchmarks) with no behavioural change (differential byte-identical). Remaining: cross-call reuse — pooling the scratch buffers and the per-callbyLine/groupsmaps across the manysweepSegmentscalls inUnionAll/Offset/Simplify— needs a scratch struct threaded throughBuilder→runBooleanOp; deferred as the larger, riskier step.
UX / API consistency
-
SimplifyvsSimplifyPathsname collision. Unrelated operations —Simplifyresolves self-intersection via the engine;SimplifyPathsis Douglas–Peucker vertex decimation. The names give no hint which is which. Pre-1.0: consider renamingSimplifyPaths(e.g.ReducePaths/Decimate), or lead each doc with an explicit "not the other one" line. -
Result aliasing footgun in the boolean short-circuits (
execOp,boolean.go).Union(a,empty)returns the caller's ownaslice (the disjoint-bbox path splices inputs), so mutating the result mutates the input. The code already knows this —buildPolyTreecopies defensively with the comment that short-circuits "can return the caller's own polygons, which Reverse must not mutate". Document on the public functions, or return a shallow copy. -
Minor type asymmetry.
MinkowskiSum/MinkowskiDifftakepath []PointwhileOffsetPathstakes[]Polyline, andPolylineis[]Point. Typing the path parameter asPolylinewould make the open-path intent explicit.(Verified non-issue:
RectClip/RectClipLines/SimplifyPaths/Clean/Triangulatereturn noerror, but each is genuinely sweep-free/geometric — principled, not an oversight.)
- Go version: 1.22+. Concrete types in the engine; generics only where they pay.
- Dependencies: zero external modules — standard library only. Non-negotiable; the point is to be a clean leaf dependency.
- Concurrency: the public API is safe for concurrent use on different inputs. A
MultiPolygonvalue is not synchronized (same rule as[]int). No internal parallelism in the library itself. - Style:
gofmt/go vet/staticcheckclean; errors wrapfmt.Errorf("polyclip: …: %w", …); public symbols have doc comments; no package-global mutable state; no workinginit(). - Pitfalls that look tempting but are wrong: float64 coordinates in the sweep (breaks topology, §5.1); Greiner-Hormann (can't handle coincident edges); offset via miter math without an engine (the naive approach this library replaces).
- Vatti, B. R. (1992). A Generic Solution to Polygon Clipping. CACM 35(7), 56–63.
- Johnson, A. Clipper2. https://github.com/AngusJohnson/Clipper2 — BSL-1.0; the sweep engine's algorithm and data model derive from it (see
NOTICE). - Shewchuk, J. R. (1997). Adaptive Precision Floating-Point Arithmetic and Fast Robust Geometric Predicates. DCG 18(3), 305–363.
- Martínez, F., Rueda, A. J., Feito, F. R. (2009). A new algorithm for computing Boolean operations on polygons. (Rejected alternative.)
MultiPolygon (subject, clip; float64)
│ preprocess (§11.2)
[]Segment (fixed-point, source-tagged; overlaps & T-junctions split)
│ sweep (§11.5, §12)
OutRec rings (doubly-linked, closed)
│ postprocess (§11.9)
MultiPolygon (fixed-point → float64)
Each stage lives in its own file and does not know the next stage's internals.
- Compute the union bbox of subject+clip; build one
fixed.Scale(§5.1). - Per ring, snap each vertex and emit a
Segmentper edge, tagged subject/clip. Normalize orientation (CCW outer / CW hole) by reversing a ring whose signed area disagrees, so the engine's "WindDx from traversal direction" assumption holds for any input winding. - Drop degenerate segments;
simplifyCollinearRingremoves collinear-through input vertices (an exact no-op that removes spurious bound turns). SplitOverlaps— establishes "no partial collinear overlaps" (only fully coincident pairs remain).SplitTJunctions— establishes "no vertex lies in the open interior of any edge" (§5.4). Split points are existing grid vertices, so area-preserving.DedupCoincidentEdges— resolves same-source coincident pairs (§11.7).
An ActiveEdge carries (see §12.2 for the full struct):
WindSelf— signed winding count of this edge's source up to and including this edge.WindOther— signed winding count of the other source to the left (exclusive).WindDx— signed input-traversal direction, ±1, set once at spawn and shared by every segment of the bound (including horizontals).ClassifyusesWindDxas the contribution; this is what lets a horizontal carry its bound's contribution while in the AEL.Contributing— whether this edge bounds the result (set byClassify).Outrec— non-nil iff the edge is "hot" (building a ring).
Winding is computed at spawn from the left neighbour and updated in place at each crossing by IntersectEdges (§12.5) — no left-walk recompute.
An edge contributes iff inside(left) ≠ inside(right), where inside(side) = inside_subject(side) OP inside_clip(side). The other source's count is identical on both sides (only this edge's source flips across it), so:
| Op | Contributes iff |
|---|---|
| Union | WindOther == 0 AND self-count flips across the edge |
| Intersect | WindOther != 0 AND self-count flips |
| Difference | subject edge: WindOther == 0 AND flip; clip edge: WindOther != 0 AND flip |
| Xor | self-count flips (every boundary flip contributes) |
Classify (clip/classify.go) transcribes Clipper2's SetWindCountForClosedPathEdge (NonZero rule); isContributing is IsContributingClosed.
sweep.run() processes events in (Y, X, Kind) order. Per scanline it: resolves all crossings strictly inside the scanbeam from the settled AEL (DoIntersections, §12.11), spawns bounds at local minima, advances/closes bounds at Tops (§12.10), flushes horizontals (§12.6), and reconciles at-vertex crossings. The crossing model is per-scanbeam recompute, not incremental scheduling (§12.11). Ring construction (AddLocalMinPoly/AddLocalMaxPoly/AddOutPt/SwapOutrecs, §12.3–§12.5) happens inside the crossing and close handlers.
type OutPt struct { P fixed.Point; Next, Prev *OutPt; Outrec *OutRec }
type OutRec struct {
FrontEdge, BackEdge *ActiveEdge // the two edges currently building the ring
Pts *OutPt // one vertex of the cycle; nil when merged away
fromInputMin bool // spawned at an input local min vs a crossing (§12.11)
// … Owner/IsHole set by postprocess
}FrontEdge contributes to the head of the chain (prepend), BackEdge to the tail (append). Both are nil once the ring closes. Two contributing edges of the same ring just emit points on their respective ends; two edges of different rings meeting are spliced by JoinOutrecPaths.
After §11.2, the only collinear pairs the sweep sees share both endpoints:
- Same source, same direction (duplicate) — dropped in preprocess.
- Same source, opposite direction — cancel; dropped.
- Different source — handled by the coincident-horizontal rules in §12.11 (a doubled/cancelling boundary skips the transversal-crossing dispatch and is reconnected by the horizontal-join pass), and by the winding rule for sloped coincident pairs.
The bound model places each source's leading horizontal in a separate bound; the at-vertex and coincident-horizontal handling in §12.11 (reconcileSharedVertexCrossings, dispatchIntersect's opposite-interior skip, processHorzJoins) recovers the correct topology. Union(A, A) and analogues — where every edge becomes a same-vertex diff-source coincident pair — are short-circuited at the API level (boolean.go mpolyEqual).
A horizontal has Bot.Y == Top.Y. In the bound model a horizontal is a first-class AEL member carrying its bound's WindDx (§12.6.1), processed by the DoHorizontal pass (§12.6) after the Top events at its scanline. Event ordering at one (Y, X) is Top < Bot/LocalMin < Horiz < Intersection: closing edges leave first, new bounds enter, horizontals then walk the settled AEL. Coincident horizontals are pre-split (§11.2) so the pass never sees partial overlaps.
After the sweep, each closed OutRec becomes a Polygon (walk the cycle, dedup consecutive equal points). Hole nesting is computed by a containment forest over all output rings (any orientation), classifying each ring by depth parity: even depth = a filled region (ExPolygon outer), odd depth = a hole of its (even-depth) parent. The sweep's own CW/CCW orientation is used only to normalize the final winding (filled→CCW, hole→CW), never to classify — an island inside a hole is CCW yet at depth 2 and must become its own top-level piece.
Containment is sampled at a genuine interior point (interiorPoint: midpoint of the widest interior span on a scanline that grazes no vertex and runs along no horizontal edge), not a vertex centroid — a centroid on a shared/collinear edge would test boundary-inclusive Contains as inside and falsely nest touching rings. Only a strictly larger ring can be a container, except that two equal-area coincident rings (the same boundary emitted once CCW and once CW, as Difference/Xor of identical inputs produce) break the tie by orientation and cancel to zero area. Finally, fixed-point coordinates are unsnapped to float64.
Checked as post-conditions by clip.CheckInvariants (from clip/invariants_test.go):
- AEL ordering: after each event the AEL is sorted left-to-right by
CurrX(slope tie-break). (clip.CheckAELSorted.) - WindSelf bounded by the number of input rings of that source.
- Ring cycles well-formed: every closed ring's
Next/Prevlinks round-trip andOutPtback-pointers are consistent. - Rings close or retire: at sweep end every
OutRec.Ptsis either a closed cycle or nil (retired viaJoinOutrecPaths); no partially-open rings.
This chapter distills the parts of the algorithm translated from Clipper2 (CPP/Clipper2Lib/src/clipper.engine.cpp; engine.cpp:line references point into that tree). See NOTICE for attribution.
Each input polygon is reframed as alternating ascending and descending bounds — chains of edges monotonic in Y. A local minimum is where a descending bound meets an ascending one (the ring turns from down to up); a local maximum is the inverse. A bound is a single AEL entry that advances through its edges via in-place cursor advance (§12.10.4), rather than one event per edge.
BuildLocalMinima (clip/bounds.go) walks each ring, finds every Y-direction reversal (horizontals count toward their non-horizontal neighbours' direction), and emits a LocalMinima record with its two emerging bounds, sorted by Y-ascending (X for ties) — the event processing order. polyclip is non-polytree: hole nesting is recomputed in postprocess (§11.9), open paths are out of scope, and the boolean fill rules over this path are NonZero and EvenOdd (Positive/Negative use the ordered-minima self-union, §7.2).
ActiveEdge (mirroring Clipper2's Active) carries: Bound + EdgeIdx cursor (geometric, set once at spawn, never reassigned); CurrX; WindSelf/WindOther/WindDx (§11.3); Contributing; Outrec (logical — non-nil iff hot, changes at crossings). Key invariant: Bound is geometric, Outrec is logical — cursor advance consults only Bound/EdgeIdx and never touches Outrec.
OutRec carries FrontEdge/BackEdge (§11.6). IsFront(e) ≡ e.Outrec.FrontEdge == e; many IntersectEdges/AddLocalMaxPoly decisions branch on it.
Called when two AEL edges become the two sides of a new contributing ring. Allocates an OutRec, assigns both edges to it, and decides which is FrontEdge/BackEdge from the nearest prior hot edge and the isNew flag (true for a real input minimum, false for a synthetic minimum created by a crossing). fromInputMin is recorded from isNew (used by §12.11's figure-8 discriminator).
Convention note: polyclip's FrontEdge is the RIGHT/descending side (the mirror of Clipper2's "front = leftmost"), so its Pts cycle reads CCW. Callers pass (rightAE, leftAE); downstream code depends only on the FrontEdge/BackEdge identity, and postprocess (§11.9) determines orientation independently from signed area.
Called when two edges meeting at a local maximum close (or merge) their ring(s). If both belong to the same OutRec, the ring closes and both edges uncouple. If they belong to different OutRecs, JoinOutrecPaths splices the two doubly-linked chains into one and discards the second. The same-side (both-front / both-back) cases are the figure-8 and reverse-join handling described in §12.11.
The heart of the engine (clip/poly_ops.go). At a crossing of two adjacent AEL edges it updates both winding counts in place, refreshes Contributing, then dispatches on (e1Hot, e2Hot) and the post-update winding:
- Both hot: close/join via
AddLocalMaxPoly, or (interleave)AddOutPton each +SwapOutrecs. - Exactly one hot:
AddOutPton the hot edge, thenSwapOutrecs(the cold edge inherits the ring). - Neither hot:
AddLocalMinPolyspawns a new ring when the op + winding makes the crossing a region entry.
The AEL position swap is deferred to the end of IntersectEdges (Clipper2 dispatches in pre-crossing order then swaps); AddLocalMinPoly resolves left/right from current AEL positions and walks getPrevHotEdge(left), so orientation is argument-order-independent. dispatchIntersect also handles the coincident-horizontal skip (§12.11).
When a bound's cursor reaches a horizontal, DoHorizontal walks the AEL across the horizontal's X-span: at the bound's local-max vertex it calls AddLocalMaxPoly; for a contributing crossing it dispatches through IntersectEdges; otherwise it advances the cursor. Horizontals are queued during their scanline and processed after the Top events (§11.8 ordering), so the AEL is settled when they walk.
Horizontals live in the AEL like any other edge (there is no separate synth-intersect path — every crossing flows through the single IntersectEdges model, §12.5). The crux is the winding model: a horizontal must carry the WindDx (±1) of the bound it belongs to — the sign of the bound's adjacent non-horizontal edge — not 0. A horizontal does not change the winding of a vertical ray it lies along, but it must carry its bound's contribution forward so neighbours classify correctly while it sits in the AEL.
Consequences: spawnBoundActive does not skip a leading horizontal (the bound's ActiveEdge sits on its first segment even when horizontal); advanceBoundCursor walks onto a horizontal in place (§12.10.4); a horizontal local minimum's two ascending bounds enter the AEL first, then the horizontal pass calls AddLocalMinPoly on them. The event order is Top < Bot/LocalMin < Horiz < Intersection (§11.8).
Before the sweep, BuildLocalMinima (§12.1) walks each ring once, finds every Y-direction reversal, and emits sorted LocalMinima records. Horizontals count toward their non-horizontal neighbours' direction.
Several state machines coexist in handleTop: cursor advance, intersection swaps, OutRec front/back rewiring, AEL ordering. The rules below are load-bearing.
loop:
DoIntersections(botY, y) // ALL crossings strictly inside the beam (§12.11)
spawn bounds at local minima at y
advance/close bounds at Tops at y
flush horizontals queued at y
reconcile at-vertex crossings
Crossings are processed strictly inside the scanbeam (botY, y) — even an intersection the algebra puts at the exact top is clamped into the beam (engine.cpp:2353); no crossing ever fires at the same Y as a Top.
When a non-horizontal edge reaches its Top without ending its bound, advance the cursor to the next segment in place — emit the local-max vertex if hot, then update Seg/EdgeIdx/CurrX and queue the next Top (or, for a horizontal next segment, queue the horizontal pass). Do not remove/reinsert in the AEL: the slope may change, but AEL order is fixed by the next scanbeam's DoIntersections. This mirrors Clipper2's UpdateEdgeIntoAEL (engine.cpp:1731). After advancing, schedule fresh intersection checks against the new segment's neighbours (its slope differs from the old). A backward bound carries Reversed=true.
Called when a bound's cursor reaches its last segment (or walks through its trailing horizontals). The local-max vertex is the last segment's Top, or the far endpoint of the trailing horizontal. The two bounds of the maximum may reach it at the same event (close together via AddLocalMaxPoly) or at different events (one runs Case A — emit the apex, remove from the AEL, leave the OutRec coupling for the partner's later Case B close). closeBound is also where the degenerate-confluence handlers fire (§12.11): handoff-through-vertex, the maxima/between-maxima pairing, the Intersect/Difference notch-plateau joins, and the figure-8 same-side close.
- Maxima gate on
IsHotEdge, notContributing. A post-swap reclassification can leave an edge non-contributing yet still hot; its ring must still close/join, or the top half of an overlapping-shapes union is dropped. - Cursor advance must reschedule intersections. The new segment may cross neighbours the old one didn't; without
maybeScheduleIntersectagainst the new neighbours, a crossing silently never fires. BuildLocalMinimais tried beforeClassifyHorizontals. The bound model handles mid-bound horizontals natively (a staircase); the legacyClassifyHorizontalsrejects them. The bound model is used wheneverBuildLocalMinimasucceeds.BuildLocalMinimadecomposes self-touching rings. Input-direction adjacency reconstructs a ring that touches itself at a vertex (two same-source loops pinched at one grid point — e.g. a polygon and its hole whose corners snap together, or non-simple offset/slicer output) as a single closed walk that revisits the pinch vertex.traceRingfollows a same-source unvisited edge at the start vertex (a self-intersecting ring legitimately passes through its own start vertex; a same-source pinched loop must be absorbed) and closes only when the walk returns to the start vertex with no same-source continuation — leaving a different-source touching ring for a separate trace.splitSelfTouchingLoopsthen splits the closed walk into its simple loops (the input-side analogue ofsplitSelfTouchingRings), so each loop builds correct bounds. This keeps the bound model — not the staircase-rejecting legacy fallback — handling non-simple axis-aligned input. A genuinely open chain still returnsErrOpenRingand falls back. Contract: the boolean ops therefore accept self-touching / self-intersecting input directly (resolved by non-zero winding), andSimplifycleans it; callers need not pre-Simplify. Transversal self-crossings remain subject to the fixed-point snap limitation of §7.2.
General-position crossings are handled by the per-scanbeam DoIntersections recompute: for each scanbeam (botY, topY], recompute all crossings from the settled AEL, sort by (pt.Y, pt.X), and dispatch via IntersectEdges; if rounding leaves a node's edges non-adjacent, advance to the next adjacent node first (Clipper2 ProcessIntersectList). This replaced an earlier incremental "schedule-on-adjacency-change" scheduler that silently lost crossings whenever an adjacency formed without a fresh pairwise check.
The residual complexity is degeneracies: shared vertices, vertices on edges, and collinear/coincident edges — especially where a subject hole and the clip meet. Every such failure becomes correct under off-grid perturbation, confirming it is a snap/degeneracy effect, not a structural sweep bug. The mechanisms that handle them (all gated tightly and validated against the differential harness, §6):
- Input normalization (§11.2):
simplifyCollinearRing, winding normalization,SplitOverlaps/SplitTJunctionsinvariants, order-independent crossing-point rounding (segCanonLess, overlap endpoints taken from input vertices not re-projected). - Shared-vertex / through-vertex crossings.
handleLocalMindispatchesIntersectEdgesat the local minimum.reconcileSharedVertexCrossingsdispatches at-vertex crossings (aTouchon the beam boundary, invisible toDoIntersections) by detecting adjacent edges with equalCurrXnow out of slope order; run after the Tops phase and again after the horizontal flush.handoffMaxThroughVertexhands a hot maximum edge's ring onto a cold through-edge that continues strictly above the shared vertex (decided from the bound's apex viaboundContinuesAbove, not the timing-dependent cursor segment). - Maxima pairing.
maximaPartner/isMaximaPartnerpair by same-source apex identity (Clipper2GetMaximaPair), scanning the whole AEL so an interleaved confluence (a-L,b-L,a-R,b-R) is matched, guarded by an apex-column test.resolveBetweenMaximadispatches each between-edge throughIntersectEdgesbefore the pair closes.plateauPartnerPending/plateauMaxPartnerPendingdefer a plateau maximum to its geometric same-source partner soDoHorizontalcloses the pair (deferring a cross-source coupling instead mis-times and drops area). - Notch-plateau joins (hole∪clip void merges).
intersectNotchPlateau(Intersect) anddifferenceNotchPlateauJoin(Difference) handle a hole bound made hot by a "bite" crossing that rides up to the hole apex: the void boundary must continue along the hole's top plateau to its near end, where a cross-source clip ring re-bounds the void, and the two rings join there. Without this the hot bound is dropped and the hole's uncovered region stays filled. - Ring closure / same-side maxima. polyclip's bottom-up sweep, with its mirrored front/back convention, builds rings that meet same-side at an apex where Clipper2's top-down sweep closes a ring on itself.
AddLocalMaxPolyresolves this two ways: a figure-8 pinch (emit the apex on each ring, cross-link the two apexOutPts into one self-touching cycle thatsplitSelfTouchingRingslater decomposes — no orientation guess) for a genuine interleaving; and a reverse-one-ring + opposite-side join when the same-side arrival is a mirror artifact. The two are distinguished by whether a ring was spawned at a crossing (fromInputMin == false, the legitimate figure-8) versus two input-minimum rings meeting same-side (the artifact —fromInputMin, equalWindOther, both-back → reverse+join). The both-back continuing case reverses the spawned ring's sides to restore Clipper2's always-opposite-side invariant. - Coincident horizontals. A coincident different-source horizontal pair does not cross transversally.
dispatchIntersectreturns nil for an opposite-interior pair (read fromSegment.Reversed) — a doubled/cancelling boundary — for non-Xor ops;processHorzJoins(clip/horzjoin.go) reconnects the skip-separated runs once global topology is known. The skip is suppressed at a boundary exit (one bound continues past the overlap), gated bycontinuesCollinearHorizontal,respawnHandoffAtOverlap, and anIsBoundLastrequirement. For Xor the coincident hot edges interleave instead (the tunnel branch would collapse a shared plateau apex to a 2-point spike), and Xor is excluded from the horizontal-join pass. - Postprocess nesting (§11.9) and the
traceRingreconstruction: a ring that touches itself at a vertex is traced as one closed walk and decomposed into simple loops bysplitSelfTouchingLoops(§12.10.7); only a genuinely open chain returnsErrOpenRing(rather than following a sub-cycle to OOM) and falls back.
Validation — Monte-Carlo oracle, NOT Clipper2. Correctness is measured against a Monte-Carlo area oracle and the noise-free set identities (§6). Clipper2 is not a usable reference for degenerate small-integer inputs: at native scale it rounds fractional crossings to the integer grid and is itself wrong on all four ops (pre-scaling its input by 1e6 confirms the MC values). On these inputs polyclip's fine fixed-point grid is more accurate.
Rejected alternatives (measured against the oracle and refuted — do not retry):
- Local discriminators on the dispatch-skip and the same-side
AddLocalMaxPoly. No single local predicate (both-continue, hotness,WindOther, winding parity) separates touch-vs-overlap or merge-vs-separate at the firing — different coincident crossings within one op need opposite decisions. The signals that do work are structural: the opposite-interiorReversedflag, and thefromInputMinspawn provenance for the figure-8 vs reverse-join choice. WindDx-derived parity inAddLocalMinPoly(replacing theoutrecIsAscendingproxy): the two do not coincide for legitimately hole-oriented / cross-source-merged rings; regresses badly.- Don't-split (whole-horizontal) model (dropping
SplitOverlaps' horizontal splitting to match Clipper2): polyclip is built on the no-overlap invariant; reworking winding/ring-construction for whole overlapping horizontals regressed broadly. Keep-split + discriminated skip + join is smaller and sufficient. - Clipper2
splits/owner/SwapFrontBackSidesmachinery is not needed — it is allusing_polytree_-gated; polyclip recomputes ownership in postprocess.