Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e4ac32f
Converted turf-union to use clipper2-ts.
smallsaucepan Jan 4, 2026
e6c7431
Ported intersect to clipper2. Fixed windings of some geojson input te…
smallsaucepan Jan 4, 2026
13bc9fc
Retrofitting more compact implementation to union.
smallsaucepan Jan 4, 2026
ba8b43e
Ported difference to clipper2. Fixed windings of some geojson input t…
smallsaucepan Jan 4, 2026
00c2074
Added minimal package setup to @turf/internal to get it building and …
smallsaucepan Jan 5, 2026
39035eb
Converted to floating point version of clipper2 (works much better wi…
smallsaucepan Jan 5, 2026
495bc0d
Babel plugin to fix a few bigint replacements the third party transfo…
smallsaucepan Jan 9, 2026
fc80ef3
Relocating turf-internal to just internal. This indicates it's not a …
smallsaucepan Jan 9, 2026
6b45ae3
Updated rollup config with custom transform to es5 to make allowances…
smallsaucepan Jan 9, 2026
f9641d3
Missed reverting turf-union union3 test back to full input precision.
smallsaucepan Jan 9, 2026
61fe366
Fixed clipper2 util that wasn't processing nested polygons properly, …
smallsaucepan Jan 17, 2026
08b2034
Rolling back changes to @turf/turf babel setup, instead favouring a p…
smallsaucepan Jan 17, 2026
5032695
Couple of suggested coding / performance changes.
smallsaucepan Jan 17, 2026
b4e0728
Rejigging new internal package so nx recognises its source files as a…
smallsaucepan Jan 17, 2026
1ea01aa
Merge branch 'master' into union-etc-to-clipper2
smallsaucepan Jan 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .monorepolint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export default {
},
},
includePackages: [...TS_PACKAGES, ...JS_PACKAGES],
excludePackages: ["@turf/internal"],
}),

packageEntry({
Expand All @@ -157,6 +158,7 @@ export default {
},
},
includePackages: [...TS_PACKAGES, ...JS_PACKAGES],
excludePackages: ["@turf/internal"],
}),

packageEntry({
Expand All @@ -165,6 +167,7 @@ export default {
funding: "https://opencollective.com/turf",
},
},
excludePackages: ["@turf/internal"],
}),

packageScript({
Expand All @@ -174,7 +177,7 @@ export default {
test: "pnpm run /test:.*/",
},
},
excludePackages: [MAIN_PACKAGE],
excludePackages: [MAIN_PACKAGE, "@turf/internal"],
}),

packageScript({
Expand All @@ -184,6 +187,7 @@ export default {
},
},
includePackages: [...TS_PACKAGES, ...JS_PACKAGES],
excludePackages: ["@turf/internal"],
}),

packageScript({
Expand Down Expand Up @@ -226,6 +230,7 @@ export default {
},
},
includePackages: [...TS_PACKAGES, ...JS_PACKAGES],
excludePackages: ["@turf/internal"],
}),

requireDependency({
Expand All @@ -240,6 +245,7 @@ export default {
},
},
includePackages: TS_PACKAGES,
excludePackages: ["@turf/internal"],
}),

requireDependency({
Expand Down
6 changes: 5 additions & 1 deletion nx.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
"{projectRoot}/package.json",
"{projectRoot}/tsconfig.json"
],
"sources": ["{projectRoot}/index.{js,ts}", "{projectRoot}/lib/**"]
"sources": [
"{projectRoot}/index.{js,ts}",
"{projectRoot}/lib/**",
"{projectRoot}/src/**"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this under src rather than reusing lib as feel copy pasting external source into lib is an anti-pattern we may like to keep easily auditable in turf-* packages.

]
},
"targetDefaults": {
"build": {
Expand Down
20 changes: 20 additions & 0 deletions packages/internal/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
The MIT License (MIT)

Copyright (c) 2017 TurfJS

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 changes: 23 additions & 0 deletions packages/internal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# @turf/internal

<!-- Generated by documentation.js. Update this documentation by updating the source code. -->

<!-- This file is automatically generated. Please don't edit it directly. If you find an error, edit the source file of the module in question (likely index.js or index.ts), and re-run "yarn docs" from the root of the turf project. -->

---

This module is part of the [Turfjs project](https://turfjs.org/), an open source module collection dedicated to geographic algorithms. It is maintained in the [Turfjs/turf](https://github.com/Turfjs/turf) repository, where you can create PRs and issues.

### Installation

Install this single module individually:

```sh
$ npm install @turf/internal
```

Or install the all-encompassing @turf/turf module that includes all modules as functions:

```sh
$ npm install @turf/turf
```
59 changes: 59 additions & 0 deletions packages/internal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@turf/internal",
"version": "7.3.1",
"description": "Common functionality used across multiple Turf packages.",
"author": "Turf Authors",
"contributors": [
"James Beard <@smallsaucepan>"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/Turfjs/turf/issues"
},
"homepage": "https://github.com/Turfjs/turf",
"repository": {
"type": "git",
"url": "git://github.com/Turfjs/turf.git"
},
"funding": "https://opencollective.com/turf",
"publishConfig": {
"access": "public"
},
"type": "module",
"exports": {
"./package.json": "./package.json",
"./clipper2": {
"import": {
"types": "./dist/esm/clipper2.d.ts",
"default": "./dist/esm/clipper2.js"
},
"require": {
"types": "./dist/cjs/clipper2.d.cts",
"default": "./dist/cjs/clipper2.cjs"
}
}
},
"sideEffects": false,
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"test": "pnpm run /test:.*/",
"test:tape": "tsx test.ts"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/types": "^7.26.10",
"@types/babel__core": "^7.20.5",
"@types/tape": "^5.8.1",
"tape": "^5.9.0",
"tsup": "^8.4.0"
},
"dependencies": {
"@types/geojson": "^7946.0.10",
"clipper2-ts": "^2.0.1",
"jsbi": "^4.3.2",
"tslib": "^2.8.1"
}
}
188 changes: 188 additions & 0 deletions packages/internal/src/clipper2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { PolyPathD, PathD, PathsD, PolyTreeD, areaD } from "clipper2-ts";
import { Polygon, MultiPolygon, Position } from "geojson";

/*
* Clipper2 scaling factor used when converting between decimal and integer
* values for internal clipper2 calculations.
* For Turf's purposes defaults to 8 decimal places retained, which is approx
* 1mm if using decimal degrees at the equator.
*/
const TURF_CLIPPER2_SCALE_FACTOR = 8;

/**
* Converts a multipolygon to a flattened array of clipper2 paths.
*/
function multiPolygonToPaths(coords: Position[][][]): PathsD {
const paths: PathsD = [];

for (const polygon of coords) {
for (const ring of polygonToPaths(polygon)) {
paths.push(ring);
}
}

return paths;
}

/**
* Converts a polygon to a flattened array of clipper2 paths.
*/
function polygonToPaths(coords: Position[][]): PathsD {
const paths: PathsD = [];

for (const [idx, ring] of coords.entries()) {
// Defensive checking against incorrectly wound Geojson polygons.
const checkedRing =
idx === 0
? enforceOuterRing(ringToPath(ring))
: enforceInnerRing(ringToPath(ring));

paths.push(checkedRing);
}

return paths;
}

/**
* Make sure this ring is wound as an outer ring, according to clipper2
* expectations. That is, clockwise.
*/
function enforceOuterRing(path: PathD): PathD {
if (areaD(path) < 0) {
return path.reverse();
}

return path;
}

/**
* Make sure this ring is wound as an inner ring, according to clipper2
* expectations. That is, counter clockwise.
*/
function enforceInnerRing(path: PathD): PathD {
if (areaD(path) > 0) {
// Leave original array untouched.
return [...path].reverse();
}

return path;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that we should start telling the users to get the winding right before passing them in, but that's likely a major version issue.


/**
* Converts a ring to a clipper2 path.
*/
function ringToPath(ring: Position[]): PathD {
return ring.map(([x, y]) => ({ x, y }));
}

/**
* Construct the output Geojson based on a clipper2 tree. The tree is useful
* for propertly handing holes.
*
* @param polyTree hierarchy of outer and inner contours found by clipper2
*/
function polyTreeToGeoJSON(polyTree: PolyTreeD): Polygon | MultiPolygon | null {
const polygons: Position[][][] = [];

// Process each top-level polygon (initally all outer contours)
for (let i = 0; i < polyTree.count; i++) {
const child = polyTree.child(i);
processPolyPath(child, polygons);
}

if (polygons.length === 0) {
return null;
}

// If exactly 1 polygon return as Geojson Polygon
if (polygons.length === 1) {
return {
type: "Polygon",
coordinates: polygons[0],
};
}

// If anything else return as MultiPolygon
return {
type: "MultiPolygon",
coordinates: polygons,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of want to just always return MultiPolygon's even if there's only 1 polygon, but again... major version.
We still have to consider the case where we get 0 polygons back and whether that should be a MultiPolygon.

Copy link
Member Author

@smallsaucepan smallsaucepan Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would make for a simpler interface. Plenty of options for what to return for 0, depending on how many liberties we're willing to take. MultiPolygon with empty coordinates array (not kosher from what I understand but used in the wild). Feature with null geometry (more correct, but back to where we started as far as return type conditional logic). Straight up null as we are now?

}

/**
* Processes a polyPath. Depending on whether the path denotes an outer ring or a hole, will add a polygon to the list of polygons.
* Recurses to children of this polyPath in turn.
*
* @param polyPath outer or inner contour of a polygon to process
* @param polygons array of completed polygons that this function may add to
*/
function processPolyPath(polyPath: PolyPathD, polygons: Position[][][]) {
// Don't look closely at holes during recursion ...
if (!polyPath.isHole) {
const rings: Position[][] = [];
// Add the outer ring.
const outerRing = pathToCoordinates(polyPath.poly);
if (outerRing.length > 0) {
rings.push(outerRing);
}

// ... instead add holes here.
// Add any holes (direct children are the holes). Do this now rather than
// during recursion given we already have the outer ring handy.
for (let i = 0; i < polyPath.count; i++) {
const child = polyPath.child(i);

const holeRing = pathToCoordinates(child.poly);
if (holeRing.length > 0) {
rings.push(holeRing);
}
}
polygons.push(rings);
}

// Now recurse into each child to handle nested levels.
for (let i = 0; i < polyPath.count; i++) {
processPolyPath(polyPath.child(i), polygons);
}
}

/**
* Converts a clipper2 path to an array of Geojson Positions.
* Automatically closes the path unless overridden e.g. for a lineString.
*
* @param path clipper2 path to convert to Positions
* @param [closeIt=true] close the path by making sure first and last positions are equal
*/
function pathToCoordinates(
path: PathD | null,
closeIt: boolean = true
): Position[] {
const coords: Position[] = [];

if (!path || typeof path.length !== "number") {
return coords;
}

for (let i = 0; i < path.length; i++) {
const pt = path[i];
coords.push([pt.x, pt.y]);
}

// GeoJSON requires the first and last coordinates to be identical (closed ring)
if (coords.length > 0 && closeIt) {
const first = coords[0];
const last = coords[coords.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
coords.push([first[0], first[1]]);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine, as long as we never do it with a LineString, but everything going through here should be Polygon rings.

Copy link
Member Author

@smallsaucepan smallsaucepan Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a parameter to at least highlight to future generations that it's closing the ring by default.


return coords;
}

export {
multiPolygonToPaths,
polygonToPaths,
polyTreeToGeoJSON,
TURF_CLIPPER2_SCALE_FACTOR,
};
16 changes: 16 additions & 0 deletions packages/internal/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import test from "tape";
import { FillRule, ClipType, PolyTreeD, ClipperD } from "clipper2-ts";
import {
TURF_CLIPPER2_SCALE_FACTOR,
multiPolygonToPaths,
polygonToPaths,
polyTreeToGeoJSON,
} from "./src/clipper2/index.js";

test("clipper2", (t) => {
const clipper = new ClipperD(TURF_CLIPPER2_SCALE_FACTOR);

clipper.clear();

t.end();
});
3 changes: 3 additions & 0 deletions packages/internal/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.shared.json"
}
Loading