Klip 3.0.0
dotnet add package Klip --version 3.0.0
NuGet\Install-Package Klip -Version 3.0.0
<PackageReference Include="Klip" Version="3.0.0" />
<PackageVersion Include="Klip" Version="3.0.0" />
<PackageReference Include="Klip" />
paket add Klip --version 3.0.0
#r "nuget: Klip, 3.0.0"
#:package Klip@3.0.0
#addin nuget:?package=Klip&version=3.0.0
#tool nuget:?package=Klip&version=3.0.0

Klip
A F# library for fast and robust polygon clipping.
Klip is a partial port of Clipper2 to F#. Only the general polygon boolean operations (intersection, union, difference, XOR) are ported. Offsetting and rectangle-only clipping is not included. Nor is triangulation.
It uses float numbers throughout, not int64.
All original and many new tests pass.
It runs on .NET and JavaScript via Fable In order to make a port suitable for JS runtimes it is in many parts derived from the TS port of Clipper2 clipper2-ts
Why another port?
1 - Fable is amazing
My geometry related F# code can run inside Rhino or Revit but just as well in a browser app. And I want to use Clipper2 there too.
2 - Precision
A second motivation is precision: this port maintains the full position of the input points. The original Clipper2 (and the clipper2-ts port) snaps every coordinate onto an integer grid before clipping. Klip removes that step - the engine computes directly on the unrounded input float coordinates, so no conversion to integers happens and the exact input positions are preserved.Also intersection points are computed at full floating-point precision rather than snapped to the grid. See Coordinate precision (unrounded floats) below for what this entails.
Scope
Coordinate precision (unrounded floats)
The clipping engine computes on unrounded float coordinates. While Clipper2 uses 64-bit integers. (rounded with a user-specified scale factor)
That snapping has been removed, so vertices created where edges cross are generally not integer-valued and keep full floating-point precision.
What this means in practice:
Point coincidence and colinearity are no longer tested with exact equality but with small tolerances —
abs (a - b) <= Clipper64.CoordEqTolerancefor coordinates, andClipper64.ColinearityTolerancefor cross-product colinearity. These are sized to absorb floating-point noise without fusing genuinely distinct pointsHorizontality is likewise tolerance-based rather than an exact
topY = botYtest: an edge counts as horizontal whenabs Δy <= Clipper64.HorizontalAngleTolerance * abs Δx(a scale-independent slope tolerance). This keeps a shared near-horizontal edge that is a hair off exact (e.g. a top edge at37vs37.00000000001) from landing its two ends on distinct scanlines and sealing an open notch into a phantom hole. Set it to0to restore the exact behaviour.Adjacent-edge join checks also avoid fixed integer-grid windows: the near-top guard scales with the local edge height and is capped at the old integer-grid limit, and the perpendicular join-distance tolerance defaults to the
CoordEqTolerancevalue. You can tune that distance onClipper64viaMergeVertexTolerancewhen your data has unusually noisy or unusually tiny touching edges.There is a
Snapmodule for preprocessing groups of paths to snap almost aligned x or y coordinates onto their average x or y value. (see below).Contours that share a seam are merged, not left separate: horizontal seams join via a dedicated pass when their X-ranges overlap (float noise does not defeat this — a real seam's overlap is far larger than the noise), and sloped or near-vertical seams join via the adjacent-edge checks gated by
MergeVertexTolerance. If seam-sharing pieces still come out separate, the tolerances are too small for your coordinate magnitude — scale them up (see the tolerance notes below) rather than rescaling your input. Contours that touch at a single point (e.g. the two lobes of an XOR) remain separate contours, as in Clipper2.You do not need to scale coordinates before clipping to preserve fractional precision. Use your source units directly unless your own application deliberately wants a different coordinate unit.
If you need integer output, round the solution coordinates yourself after clipping (e.g. with
System.Math.Round).
Core Types
Original Documentation: https://www.angusj.com/clipper2
All types are still named like the original C# version, where the ..64 suffix indicated 64-bit integers. In Klip the XY coordinates are stored as float, and there is no separate ..D suffix API because the regular path types already accept and preserve floating-point coordinates.
New Generic 'Z Metadata
'Z is the generic type parameter for user-defined metadata that can be attached to vertices. It is optional and defaults to unit if not used.
In the original Clipper2 the optional Z value is always a int64 but in Klip it can be any type.
Path64<'Z>: A single contour. X and Y coordinates are stored in a flat interleavedResizeArray<float>asx0, y0, x1, y1, ....Paths64<'Z>: AResizeArray<Path64<'Z>>, representing multiple contours such as an outer polygon and its holes.PolyTree64<'Z>: A tree output structure that preserves parent-child contour relationships, such as holes inside outer contours.ZCallback64<'Z>: A callback for assigning user-defined'Zmetadata to new vertices created at edge intersections.'Zvalues are metadata, not 3D coordinates.
If you do not use 'Z metadata, use the default no-Z helpers such as Path64.createFrom and Paths64.createSingle; these create Path64<unit> and Paths64<unit> values. The 'Z-aware helpers and clipping wrappers live in the parallel Path64/Paths64 ...Z functions and the KlipperZ module.
Path Helpers
The Path64 and Paths64 modules provide construction and utility helpers for flat interleaved coordinates:
Path64.createFrom,Path64.createFromSeq,Paths64.createFrom, andPaths64.createFromSeqcopy coordinate data into new buffers.Path64.createDirectlyandPaths64.createDirectlyreuse the suppliedResizeArraybuffers directly. Coordinates are still floats and are not rounded.Path64.createFromXYMembers/createFromxyMembersand theirPaths64counterparts accept objects withX/Yorx/ymembers.Path64.enableZ,Path64.enableZWith,Paths64.enableZ, andPaths64.enableZWithattach metadata buffers and reject paths that already have Z values.mapXY,iterXY,mapZ,iterZ, orientation helpers, andsignedAreacover common path inspection and transformation tasks.
Boolean Operations
Open vs closed paths
Clipper2 does not infer open/closed from coordinates — a trailing vertex equal to the first one is just stripped, it does not change how the path is treated. Instead, each path is tagged as open or closed when it is added to the engine.
Rules (inherited from Clipper2):
- Subject paths can be considered open or closed.
- Clip paths are always considered closed.
- For
Intersection,Difference, andXor: closed subject paths are ignored when computing the open-path solution, and vice versa — open and closed subjects are effectively processed independently. - For
Union: open subjects are clipped wherever they overlap any closed path (whether that closed path is a subject or a clip).
The Klipper.* wrappers below always treat input as closed polygons. To clip open
paths (polylines / line segments), drop down to Clipper64 directly and call
AddOpenSubject (or AddPaths(paths, PathType.Subject, isOpen = true)):
let c = Clipper64<unit>()
c.AddOpenSubject(openLines) // polylines — endpoints stay endpoints
c.AddSubject(closedPolygons) // optional, closed
c.AddClip(clipPolygons) // clip is always closed
// Execute returns a (closedSolution, openSolution) tuple;
// openSolution is null when no open subjects were added.
let closedSolution, openSolution = c.Execute(ClipType.Intersection, FillRule.EvenOdd)
Calling AddPaths with PathType.Clip and isOpen = true is invalid; clip paths are always closed.
ExecutePolyTree has the same open-output convention as Execute: the open-path result is null when no open subjects were added.
Closed-polygon wrappers
Klipper.intersect clip subject: Returns the intersection of the subject and clip paths.Klipper.union clip subject: Returns the union of subject and clip paths.Klipper.unionSelf subject: Resolves self-intersections within a single subject path.Klipper.unionSelfChecked subject: Reorients all subject paths to positive orientation before unioning them.Klipper.difference clip subject: Returns the regions of the subject that are not inside the clip region.Klipper.xor clip subject: Returns the regions of subject or clip that are not in both.Klipper.removeSelfIntersectionsPositive subjectandKlipper.removeSelfIntersectionsNegative subject: Resolve one self-intersecting path using the matching directional fill rule.
Each wrapper has a counterpart in the KlipperZ module that takes an
option<ZCallback64<'Z>> as the first argument, for attaching 'Z metadata:
KlipperZ.intersect zCallback clip subjectKlipperZ.union zCallback clip subjectKlipperZ.unionSelf zCallback subjectKlipperZ.unionSelfChecked zCallback subjectKlipperZ.difference zCallback clip subjectKlipperZ.xor zCallback clip subjectKlipperZ.removeSelfIntersectionsPositive zCallback subjectKlipperZ.removeSelfIntersectionsNegative zCallback subject
Use the general functions when you need a custom ClipType, FillRule, or PolyTree64 output:
Klipper.booleanOp (clipType, subject, clip, fillRule): Performs a boolean operation and returnsPaths64<unit>.Klipper.booleanOpPolyTree (clipType, subject, clip, fillRule): Returns aPolyTree64<unit>so the parent-child contour hierarchy is preserved.Klipper.polyTreeToPaths64 polyTree: Flattens aPolyTree64<unit>back intoPaths64<unit>.
The KlipperZ module mirrors these with a trailing zCallback argument and the generic 'Z type:
KlipperZ.booleanOp (clipType, subject, clip, fillRule, zCallback)KlipperZ.booleanOpPolyTree (clipType, subject, clip, fillRule, zCallback)KlipperZ.polyTreeToPaths64 polyTree
Like the wrappers, these also treat subjects as closed. For open-subject clipping use
Clipper64 directly as shown above.
open Klip
let subject =
Paths64.createSingle [ 0.0; 0.0; 10.0; 0.0; 10.0; 10.0; 0.0; 10.0 ]
let clip =
Paths64.createSingle [ 5.0; 5.0; 15.0; 5.0; 15.0; 15.0; 5.0; 15.0 ]
let union = Klipper.union clip subject
let intersection = Klipper.intersect clip subject
let nonZeroDifference =
Klipper.booleanOp (ClipType.Difference, subject, clip, FillRule.NonZero)
Direct Clipper64 Options
Use Clipper64<'Z> directly when you need open subjects, repeated execution with the same input, or lower-level tuning:
PreserveColinear: controls whether removable colinear vertices are preserved in closed solutions.CoordEqTolerance: absolute distance below which two coordinates are treated as the same point (default1e-5). Per-instance setting. Independent ofMergeVertexTolerance.MergeVertexTolerance: maximum perpendicular distance from a candidate join point to a neighbouring edge for an adjacent-edge join (default1e-6). This is the main knob for merging near-vertical / sloped touching seams (near-horizontal seams have a separate join pass and tolerate larger noise without tuning). A seam whose two sides are off by a gapgneeds roughlyMergeVertexTolerance > g.ColinearityTolerance: dimensionless angle (sin θ) tolerance for cross-product colinearity tests (default1e-3). Per-instance setting.HorizontalAngleTolerance: dimensionless slope tolerance for treating an edge as horizontal — horizontal whenabs Δy <= HorizontalAngleTolerance * abs Δx(default1e-6, set0for the exacttopY = botYtest). Per-instance setting.NearTopYToleranceFactor/NearTopYToleranceCap: tune the near-top join guard, which suppresses adjacent-edge joins close to an edge's top vertex. The guard window ismin(NearTopYToleranceCap, edgeHeight * NearTopYToleranceFactor)(defaults1e-4and2.0).SmallTriangleTolerance: absolute window below which a 3-point solution ring is culled as a sliver triangle (default2.0, the old integer-grid constant). Per-instance setting.SplitAreaTolerance: absolute area window for the self-intersection split — a ring is discarded below this area and a split-off triangle kept only above half of it (default2.0). Per-instance setting; being an area it scales with the square of the coordinate magnitude.ReverseSolution: reverses output orientation.ZCallback: computes metadata for vertices created at intersections.
The distance tolerances (CoordEqTolerance, MergeVertexTolerance, NearTopYToleranceCap, SmallTriangleTolerance, and the area-valued SplitAreaTolerance) are absolute and do not auto-scale. The engine does not normalize coordinate magnitude — scale the tolerances you provide to your input instead: with M the maximum absolute coordinate, multiply the distance defaults by roughly M (and SplitAreaTolerance by M²); ColinearityTolerance and HorizontalAngleTolerance are angles and are scale-independent.
Snap preprocessing
Optionally call Snap.xAndY tolerance pathGroups or Snap.xAndYSingle tolerance paths to snap x and y coordinates that are almost the same to their respective averages across subject and clip simultaneously.
This is an in-place mutation of the input paths.
You must call this on all paths at once so that x and y get aligned in place across the entire input, and so that the same shared coordinate is used for snapping across subject and clip. This is an optional pre-pass
to cluster nearby input X and Y coordinates independently and mutate the paths before adding them to Clipper64.
Building
for .NET
dotnet build
test:
dotnet test Test/FSharp/Tests/Tests1/Tests1.fsproj
dotnet test Test/FSharp/Tests/Tests2/TestsZ.fsproj
for JS
cd Test
dotnet tool restore
npm install
npm run clean # clean previous Fable output
npm run build # F# → JavaScript via Fable, then vite build
npm run buildts # F# → TypeScript via Fable, then tsc and vite build
cd..
and then to test:
cd Test
npm run build # dotnet fable + vite build
npm test # vitest --run
cd ..
The JavaScript bundle ends up in Test/_dist/Klip.mjs and is what the Vitest suite imports. The TypeScript/Fable build emits a separate bundle under Test/_distTS/Klip.mjs.
Performance
On .NET, the local benchmark harness is roughly on par with Clipper2 C#.
In JavaScript, the latest local benchmark run is about the same as clipper2-ts and about 80% slower than clipper2-wasm on average.
See Test/bench/README.md
and Test/README.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- FSharp.Core (>= 6.0.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
### Added
- `Clipper64.ScanlineArrayThreshold` exposes the size at which the engine switches its pending-scanline container from a small unsorted array (linear scan) to a max-heap plus hash-set, as a **per-instance** setting; `Klipper.setDefaultScanlineArrayThreshold` / `getDefaultScanlineArrayThreshold` adjust the process-wide default (64) used by new instances, including the ones the `Klipper`/`KlipperZ` wrappers create. Performance tuning only — the value never changes clipping results. The two containers are now proper classes (`ScanlineArray` / `ScanlineHeapSet`), replacing the previous inline array/heap/`HashSet` trio, and the formerly inconsistent switch sizes (start with the array at ≤ 16 local minima but only upgrade away from it past 64 pending scanlines) are unified into this single threshold. `Test/bench/scanline-threshold.mjs` benchmarks the switch-over in JS on two workloads (all-distinct vs duplicate-heavy scanline Ys): the array/heap crossover is broad and flat (~32–256 local minima), confirming 64; a set-free dedup-on-pop heap variant was also measured and rejected (up to ~18 % slower on duplicate-heavy inputs).
- `Clipper64.SmallTriangleTolerance` exposes the previously hard-coded `2.0` sliver-triangle cull window (a 3-point solution ring is dropped when two of its vertices are closer than this in both X and Y) as a **per-instance** setting. Default unchanged at `2.0`; like the other absolute tolerances it should be scaled by the caller to the coordinate magnitude of the input.
- `Clipper64.SplitAreaTolerance` exposes the previously hard-coded `4.0`/`2.0` double-area thresholds of the self-intersection split (`doSplitOp`) as a **per-instance** setting: a ring is discarded below this area, and a split-off triangle is kept only above half this area. Default unchanged at `2.0`; being an area it scales with the **square** of the coordinate magnitude.
- `Clipper64.CoordEqTolerance` exposes the in-sweep coordinate-equality tolerance as an adjustable, **per-instance** setting (default `1e-5`), independent of `MergeVertexTolerance`.
- `Clipper64.NearTopYToleranceFactor` and `NearTopYToleranceCap` expose the previously hard-coded constants of the adjacent-edge join near-top guard. Defaults unchanged at `1e-4` and `2.0`.
- `Clipper64.HorizontalAngleTolerance` exposes the scale-relative slope tolerance for treating an edge as horizontal (`abs Δy <= tol * abs Δx`), as a **per-instance** setting. Default `1e-6`; set `0` for the former exact `topY = botY` behaviour.
### Changed
- All hot-loop `ResizeArray` indexing (engine sweep, `Geo.pointInPolygon` and area/containment primitives, `Snap`, the scanline containers, the `Klipper` path helpers) now goes through the `Rarr.getIdx`/`setIdx` emit helpers, which compile to direct `arr[i]` under Fable instead of the bounds-checked fable-library `item()`/`setItem()` calls (a function call per element access). No JS-measurable change on the boolean-op benchmark (its hot core is linked-list traversal), but it removes per-access overhead from `pointInPolygon`-heavy polytree workloads and `Snap`. Behaviour unchanged on .NET.
- **All clipping tolerances are per-instance; there is no module-global tolerance state left.** `CoordEqTolerance`, `ColinearityTolerance` and `HorizontalAngleTolerance` were previously process-wide mutables (`Geo.coordEqTol`, `Geo.crossColinearityToleranceSqrd`, `Clip.horzAngleTol`) that every `Clipper64` shared and the constructor reset, so configuring one instance leaked into others. They are now instance fields, threaded explicitly into the geometry primitives (`isEqualWithin`/`isNotEqualWithin`, `crossIsZero`/`crossProductSign`/`isColinear`/`pointInPolygon`, `isHorizontalCoords`/`getDx`/`isHorizontal`). Two `Clipper64` instances can now be configured with different tolerances without interfering, and constructing a new instance no longer disturbs existing ones. `MergeVertexTolerance` was already per-instance; the near-top guard constants are now exposed as `NearTopYToleranceFactor` / `NearTopYToleranceCap`. Two stale doc defaults were corrected along the way: `ColinearityTolerance` default is `1e-3` (not `1e-9`) and `CoordEqTolerance` default is `1e-5`.
- README files now describe the current float-preserving public API, helper functions, open-path rules, and test gates.
- Input coordinate snapping moved off `Clipper64` into a standalone, opt-in `Snap` module that mutates `Paths64` in place (`Snap.xAndY` for multiple path collections, `Snap.xAndYSingle` for one collection, with `Snap.DefaultTolerance` = `1e-8`). `Clipper64` no longer snaps; the `Clipper64.SnapXandY` / `SnapXandYTolerance` members and the pre/post-snap callbacks are removed. The `Klipper.*` wrappers do not snap either — call `Snap.xAndY` yourself if you want the pre-pass.
- **Failures raise exceptions.** `Clipper64.Execute` / `ExecutePolyTree` now raise `InvalidOperationException` when the sweep fails (previously the internal `succeeded = false` flag was silently ignored and partial output was returned). Adding an empty path (0 points) via `AddPaths` now raises `ArgumentException` (previously an index crash on .NET, and silent NaN corruption under Fable). `KlipperZ.booleanOpPolyTree` now raises on a null subject like its `Klipper` counterpart (it previously returned an empty tree).
- **Wrappers no longer mutate their inputs.** `Klipper.unionSelfChecked` / `KlipperZ.unionSelfChecked` and `Paths64.ensurePositiveOrientations` / `ensureNegativeOrientations` now build a new list (reusing already-correctly-oriented `Path64` instances) instead of replacing entries of the caller's list in place.
- The internal `evalAtTruncate` / `evalAtRound` pair (identical since coordinates stopped being rounded) collapsed into a single `Clip.evalAt`.
### Fixed
- The open-path-enabled flag is no longer a process-global (`Clip.state.openPathsEnabled`): it is now a `Clipper64` instance field passed explicitly into the `Clip` open-path predicates, so two `Clipper64` instances (e.g. an open-path clip interleaved or concurrent with a closed-path clip) no longer corrupt each other's open-edge classification.
- The Z callback is no longer invoked twice for the same output point when an open edge that is already 'hot' crosses a closed edge (`intersectOpenEdges` had a duplicated `setZ` call).
- `convertHorzSegsToJoins` restores upstream Clipper2's early loop exit on the sorted horizontal-segment list (the port had turned the `break` into a `continue` — same results, quadratic scanning).
- Doc corrections: `MergeVertexTolerance` default is `1e-5` (the `CoordEqTolerance` default), not `1e-6`; `Snap` docs now state that only coordinates in near-axis-aligned segment runs are snapped; various typos.
- A horizontal edge whose bound continues into a *near*-horizontal segment (within`HorizontalAngleTolerance`, but not exactly flat) no longer leaves that continuation orphaned inthe active edge list. `doHorizontal`'s loop exits on an exact next-vertex-Y comparison while`updateEdgeIntoAEL` classifies the continuation with the tolerance test, so the segment gotneither a scanline for its top nor a spot in the horizontal queue; its `curX` then evaluated via`dx = ±infinity` to `±infinity` at the next scanbeam and corrupted the sweep (e.g. a simple7-point union returned 4 contours with a missing region). The exit path now re-checks`isHorizontal` and re-queues the edge, mirroring `doTopOfScanbeam`.
- A horizontal bound ending at a local maximum whose maxima-pair edge has not reached the sharednear-flat run yet no longer hangs the sweep in an endless scanbeam ping-pong (with unboundedmemory growth). Clipper2 assumes the pair is already in the AEL — with integer coordinates bothbounds reach a flat run at the same scanline — but with unrounded coordinates the opposite boundcan arrive at a slightly different exact Y, i.e. a later scanbeam. `doHorizontal` then walked theedge *past* the maximum and back up the opposite bound, re-inserting an already-swept scanline onevery beam (e.g. a single 6-point rectangle from `Test/Rhino/polysXY.json` whose bottom edgecarries ~1e-6 Y-noise looped forever). `doHorizontal` now detects the absent pair up front, keepsthe pass-over range checks active, and parks the edge in the AEL at its top — where the oppositebound claims it as its maxima pair a few beams later — mirroring `doMaxima`'s null-pair handling;`Eng.topX` returns `topX` for a parked horizontal-classified edge instead of evaluating`botX + ±infinity * dy`.
- Horizontal-edge detection is now a tolerance test (`HorizontalAngleTolerance`) instead of exact`topY = botY`, so a shared near-horizontal edge left a hair off exact by unrounded input (e.g. atop edge at `37` vs `37.00000000000001`) no longer lands its ends on distinct scanlines and sealsan open notch into a phantom hole — touching polygons union into a single contour without the`Snap` pre-pass. The default `HorizontalAngleTolerance` was raised from `1e-7` to `1e-6` so thata near-horizontal bridge edge with slope ratio ~`5e-7` (e.g. a `2`-wide top edge offset by `1e-6`)is absorbed as horizontal; the `Union-Touching` bridge sweep is now all-success without per-calltuning, and the JS regression suite is unaffected.
- `cleanColinear` now drops a vertex coincident with a neighbour (within `CoordEqTolerance`) independentlyof the colinearity angle test, removing a leftover near-zero-length edge and the horizontal U-turnspike it propped up in half-snapped touching-polygon unions.
- `Clipper64.AddPaths` now rejects open clip paths explicitly; only subject paths may be open.
- `Clipper64.ExecutePolyTree` now matches `Execute` by returning `null` for the open-path result when no open subjects were added.
- `PolyTree64.ToString()` now includes leaf contours instead of only listing nodes that contain children.
- `Path64` / `Paths64` Z-enabling helpers now reject already-Z-enabled paths and mismatched path/Z collection counts with explicit exceptions.
- Fable `Snap` sorting now preserves sub-unit coordinate differences instead of truncating comparator results to `0`, preventing unrelated nearby scanlines from being averaged together.