poly-prune 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/index.d.ts +1 -0
- package/index.js +3 -0
- package/outerApproximatePolygon.d.ts +45 -0
- package/outerApproximatePolygon.js +1092 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# PolyPrune Outer Approximation
|
|
2
|
+
|
|
3
|
+
Deterministic outer-approximation helper for GeoJSON polygons.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install poly-prune
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What It Guarantees
|
|
12
|
+
|
|
13
|
+
- Strict containment: output polygon never cuts inside the original polygon.
|
|
14
|
+
- Exact vertex count: output has exactly `targetVertexCount` vertices.
|
|
15
|
+
- Minimum deviation objective: algorithm minimizes outward deviation for the requested target.
|
|
16
|
+
- `maxDeviation` is only an upper limit (optional), never the optimization target.
|
|
17
|
+
- Deterministic output: same input always returns the same result.
|
|
18
|
+
|
|
19
|
+
## API
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
outerApproximatePolygon(
|
|
23
|
+
originalPolygon: GeoJSONPolygon,
|
|
24
|
+
targetVertexCount: number,
|
|
25
|
+
maxDeviation?: number
|
|
26
|
+
): {
|
|
27
|
+
polygon: GeoJSONPolygon;
|
|
28
|
+
achievedDeviation: number;
|
|
29
|
+
maxDeviation: number;
|
|
30
|
+
maxDeviationProvided: boolean;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Inputs
|
|
35
|
+
|
|
36
|
+
- `originalPolygon`: GeoJSON `Polygon` geometry (single outer ring, no holes).
|
|
37
|
+
- `targetVertexCount`: integer `>= 3`.
|
|
38
|
+
- `maxDeviation` (optional): non-negative numeric cap.
|
|
39
|
+
|
|
40
|
+
### Output Fields
|
|
41
|
+
|
|
42
|
+
- `polygon`: resulting outer polygon.
|
|
43
|
+
- `achievedDeviation`: measured outward deviation of result.
|
|
44
|
+
- `maxDeviation`: provided cap, or inferred value (`achievedDeviation`) when omitted.
|
|
45
|
+
- `maxDeviationProvided`: `true` if caller passed `maxDeviation`.
|
|
46
|
+
|
|
47
|
+
## Behavior Rules
|
|
48
|
+
|
|
49
|
+
1. Minimize deviation first.
|
|
50
|
+
2. If `maxDeviation` is provided, return only if `achievedDeviation <= maxDeviation`.
|
|
51
|
+
3. If impossible under cap, throws `OuterApproximationError` with code `TARGET_NOT_REACHABLE`.
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
|
|
55
|
+
### CommonJS
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
const { outerApproximatePolygon } = require('poly-prune');
|
|
59
|
+
|
|
60
|
+
const result = outerApproximatePolygon(polygon, 4, 0.005);
|
|
61
|
+
console.log(result.achievedDeviation);
|
|
62
|
+
console.log(JSON.stringify(result.polygon));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### TypeScript
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { outerApproximatePolygon } from 'poly-prune';
|
|
69
|
+
|
|
70
|
+
const result = outerApproximatePolygon(polygon, 4);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Error Handling (Public API)
|
|
74
|
+
|
|
75
|
+
The library throws `OuterApproximationError` with stable `code` values and structured `details`.
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const {
|
|
79
|
+
outerApproximatePolygon,
|
|
80
|
+
OuterApproximationError,
|
|
81
|
+
OUTER_APPROXIMATION_ERROR_CODES,
|
|
82
|
+
} = require('poly-prune');
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const result = outerApproximatePolygon(polygon, 4, 0.005);
|
|
86
|
+
console.log(result.polygon);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof OuterApproximationError) {
|
|
89
|
+
if (error.code === OUTER_APPROXIMATION_ERROR_CODES.TARGET_NOT_REACHABLE) {
|
|
90
|
+
console.error('Not feasible under maxDeviation', error.details);
|
|
91
|
+
} else {
|
|
92
|
+
console.error('Invalid input', error.code, error.details);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Common Error Codes
|
|
101
|
+
|
|
102
|
+
- `TARGET_NOT_REACHABLE`
|
|
103
|
+
- `INVALID_POLYGON_OBJECT`
|
|
104
|
+
- `INVALID_POLYGON_TYPE`
|
|
105
|
+
- `MISSING_COORDINATES`
|
|
106
|
+
- `HOLES_NOT_SUPPORTED`
|
|
107
|
+
- `INVALID_TARGET_VERTEX_COUNT`
|
|
108
|
+
- `INVALID_MAX_DEVIATION`
|
|
109
|
+
- `INVALID_RING_LENGTH`
|
|
110
|
+
- `INVALID_COORDINATE_TUPLE`
|
|
111
|
+
- `INVALID_COORDINATE_VALUE`
|
|
112
|
+
- `RING_NOT_CLOSED`
|
|
113
|
+
- `INSUFFICIENT_UNIQUE_VERTICES`
|
|
114
|
+
- `SELF_INTERSECTING_POLYGON`
|
|
115
|
+
- `DEGENERATE_EDGE_INSERTION`
|
|
116
|
+
|
|
117
|
+
## Local Development
|
|
118
|
+
|
|
119
|
+
- Run tests: `npm test`
|
|
120
|
+
- Preview package artifact: `npm run pack:check`
|
|
121
|
+
|
|
122
|
+
## Publish Checklist
|
|
123
|
+
|
|
124
|
+
1. Update `name` if needed (must be unique on npm).
|
|
125
|
+
2. Update `version` (semver).
|
|
126
|
+
3. `npm test`
|
|
127
|
+
4. `npm run pack:check`
|
|
128
|
+
5. `npm login`
|
|
129
|
+
6. `npm publish --access public`
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./outerApproximatePolygon";
|
package/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type OuterApproximationErrorCode =
|
|
2
|
+
| "INVALID_POLYGON_OBJECT"
|
|
3
|
+
| "INVALID_POLYGON_TYPE"
|
|
4
|
+
| "MISSING_COORDINATES"
|
|
5
|
+
| "HOLES_NOT_SUPPORTED"
|
|
6
|
+
| "INVALID_TARGET_VERTEX_COUNT"
|
|
7
|
+
| "INVALID_MAX_DEVIATION"
|
|
8
|
+
| "INVALID_RING_LENGTH"
|
|
9
|
+
| "INVALID_COORDINATE_TUPLE"
|
|
10
|
+
| "INVALID_COORDINATE_VALUE"
|
|
11
|
+
| "RING_NOT_CLOSED"
|
|
12
|
+
| "INSUFFICIENT_UNIQUE_VERTICES"
|
|
13
|
+
| "SELF_INTERSECTING_POLYGON"
|
|
14
|
+
| "DEGENERATE_EDGE_INSERTION"
|
|
15
|
+
| "TARGET_NOT_REACHABLE";
|
|
16
|
+
|
|
17
|
+
export interface GeoJSONPolygon {
|
|
18
|
+
type: "Polygon";
|
|
19
|
+
coordinates: number[][][];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OuterApproximationResult {
|
|
23
|
+
polygon: GeoJSONPolygon;
|
|
24
|
+
achievedDeviation: number;
|
|
25
|
+
maxDeviation: number;
|
|
26
|
+
maxDeviationProvided: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OuterApproximationErrorDetails {
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class OuterApproximationError extends Error {
|
|
34
|
+
readonly code: OuterApproximationErrorCode;
|
|
35
|
+
readonly details: OuterApproximationErrorDetails;
|
|
36
|
+
constructor(code: OuterApproximationErrorCode, message: string, details?: OuterApproximationErrorDetails);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const OUTER_APPROXIMATION_ERROR_CODES: Readonly<Record<OuterApproximationErrorCode, OuterApproximationErrorCode>>;
|
|
40
|
+
|
|
41
|
+
export function outerApproximatePolygon(
|
|
42
|
+
originalPolygon: GeoJSONPolygon,
|
|
43
|
+
targetVertexCount: number,
|
|
44
|
+
maxDeviation?: number
|
|
45
|
+
): OuterApproximationResult;
|
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const EPS = 1e-9;
|
|
4
|
+
const OUTER_APPROXIMATION_ERROR_CODES = Object.freeze({
|
|
5
|
+
INVALID_POLYGON_OBJECT: "INVALID_POLYGON_OBJECT",
|
|
6
|
+
INVALID_POLYGON_TYPE: "INVALID_POLYGON_TYPE",
|
|
7
|
+
MISSING_COORDINATES: "MISSING_COORDINATES",
|
|
8
|
+
HOLES_NOT_SUPPORTED: "HOLES_NOT_SUPPORTED",
|
|
9
|
+
INVALID_TARGET_VERTEX_COUNT: "INVALID_TARGET_VERTEX_COUNT",
|
|
10
|
+
INVALID_MAX_DEVIATION: "INVALID_MAX_DEVIATION",
|
|
11
|
+
INVALID_RING_LENGTH: "INVALID_RING_LENGTH",
|
|
12
|
+
INVALID_COORDINATE_TUPLE: "INVALID_COORDINATE_TUPLE",
|
|
13
|
+
INVALID_COORDINATE_VALUE: "INVALID_COORDINATE_VALUE",
|
|
14
|
+
RING_NOT_CLOSED: "RING_NOT_CLOSED",
|
|
15
|
+
INSUFFICIENT_UNIQUE_VERTICES: "INSUFFICIENT_UNIQUE_VERTICES",
|
|
16
|
+
SELF_INTERSECTING_POLYGON: "SELF_INTERSECTING_POLYGON",
|
|
17
|
+
DEGENERATE_EDGE_INSERTION: "DEGENERATE_EDGE_INSERTION",
|
|
18
|
+
TARGET_NOT_REACHABLE: "TARGET_NOT_REACHABLE",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
class OuterApproximationError extends Error {
|
|
22
|
+
constructor(code, message, details = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "OuterApproximationError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.details = details;
|
|
27
|
+
Object.setPrototypeOf(this, OuterApproximationError.prototype);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createOuterApproximationError(code, message, details = {}) {
|
|
32
|
+
return new OuterApproximationError(code, message, details);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Deterministic outer-approximation helper.
|
|
37
|
+
*
|
|
38
|
+
* Inputs:
|
|
39
|
+
* - originalPolygon: GeoJSON Polygon geometry (single outer ring required)
|
|
40
|
+
* - targetVertexCount (Y): exact vertex count in the returned outer ring (without closure point)
|
|
41
|
+
* - maxDeviation (Z): maximum allowed outward boundary deviation
|
|
42
|
+
*
|
|
43
|
+
* Output:
|
|
44
|
+
* - GeoJSON Polygon geometry on success
|
|
45
|
+
* - throws OuterApproximationError on invalid input or geometric impossibility
|
|
46
|
+
*/
|
|
47
|
+
function outerApproximatePolygon(originalPolygon, targetVertexCount, maxDeviation) {
|
|
48
|
+
validateInputs(originalPolygon, targetVertexCount, maxDeviation);
|
|
49
|
+
const originalRing = normalizeOuterRing(originalPolygon);
|
|
50
|
+
const originalCount = originalRing.length;
|
|
51
|
+
const hasDeviationLimit = typeof maxDeviation === "number";
|
|
52
|
+
const deviationLimit = hasDeviationLimit ? maxDeviation : Number.POSITIVE_INFINITY;
|
|
53
|
+
|
|
54
|
+
if (targetVertexCount === originalCount) {
|
|
55
|
+
const polygon = toGeoJSONPolygon(originalRing);
|
|
56
|
+
return buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (targetVertexCount > originalCount) {
|
|
60
|
+
const grown = addVerticesWithoutChangingBoundary(originalRing, targetVertexCount);
|
|
61
|
+
const polygon = toGeoJSONPolygon(grown);
|
|
62
|
+
return buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Phase 1: globally optimal direct reduction search.
|
|
66
|
+
const direct = findOptimalDirectReduction(originalRing, targetVertexCount, deviationLimit);
|
|
67
|
+
if (direct) {
|
|
68
|
+
const tightened = maybeTightenRing(direct.ring, originalRing);
|
|
69
|
+
const polygon = toGeoJSONPolygon(tightened);
|
|
70
|
+
return buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Phase 2: deterministic convex-support removals when direct removals are exhausted.
|
|
74
|
+
let current = cloneRing(originalRing);
|
|
75
|
+
let convex = convexHull(current);
|
|
76
|
+
if (convex.length < 3) {
|
|
77
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (signedArea(convex) < 0) {
|
|
81
|
+
convex.reverse();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hullDeviation = directedBoundaryDeviation(convex, originalRing);
|
|
85
|
+
if (hullDeviation > deviationLimit + EPS) {
|
|
86
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!polygonContainsPolygon(convex, originalRing)) {
|
|
90
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
current = convex;
|
|
94
|
+
|
|
95
|
+
if (current.length < targetVertexCount) {
|
|
96
|
+
const grown = addVerticesWithoutChangingBoundary(current, targetVertexCount);
|
|
97
|
+
const grownDeviation = directedBoundaryDeviation(grown, originalRing);
|
|
98
|
+
if (grownDeviation > deviationLimit + EPS) {
|
|
99
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
100
|
+
}
|
|
101
|
+
if (!polygonContainsPolygon(grown, originalRing)) {
|
|
102
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
103
|
+
}
|
|
104
|
+
const polygon = toGeoJSONPolygon(grown);
|
|
105
|
+
return buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const convexBest = findOptimalConvexReduction(current, originalRing, targetVertexCount, deviationLimit);
|
|
109
|
+
if (!convexBest) {
|
|
110
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
111
|
+
}
|
|
112
|
+
current = convexBest.ring;
|
|
113
|
+
|
|
114
|
+
if (!polygonContainsPolygon(current, originalRing)) {
|
|
115
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const finalDeviation = directedBoundaryDeviation(current, originalRing);
|
|
119
|
+
if (finalDeviation > deviationLimit + EPS) {
|
|
120
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (current.length !== targetVertexCount) {
|
|
124
|
+
throw cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const tightened = maybeTightenRing(current, originalRing);
|
|
128
|
+
const polygon = toGeoJSONPolygon(tightened);
|
|
129
|
+
return buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateInputs(originalPolygon, targetVertexCount, maxDeviation) {
|
|
133
|
+
if (!originalPolygon || typeof originalPolygon !== "object") {
|
|
134
|
+
throw createOuterApproximationError("INVALID_POLYGON_OBJECT", "originalPolygon must be a GeoJSON Polygon geometry object.", { receivedType: typeof originalPolygon });
|
|
135
|
+
}
|
|
136
|
+
if (originalPolygon.type !== "Polygon") {
|
|
137
|
+
throw createOuterApproximationError("INVALID_POLYGON_TYPE", "originalPolygon.type must be 'Polygon'.", { receivedType: originalPolygon.type });
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(originalPolygon.coordinates) || originalPolygon.coordinates.length === 0) {
|
|
140
|
+
throw createOuterApproximationError("MISSING_COORDINATES", "originalPolygon.coordinates must contain at least one ring.");
|
|
141
|
+
}
|
|
142
|
+
if (originalPolygon.coordinates.length > 1) {
|
|
143
|
+
throw createOuterApproximationError("HOLES_NOT_SUPPORTED", "Polygons with holes are not supported by this helper.", { ringCount: originalPolygon.coordinates.length });
|
|
144
|
+
}
|
|
145
|
+
if (!Number.isInteger(targetVertexCount) || targetVertexCount < 3) {
|
|
146
|
+
throw createOuterApproximationError("INVALID_TARGET_VERTEX_COUNT", "targetVertexCount must be an integer >= 3.", { targetVertexCount });
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
maxDeviation !== undefined &&
|
|
150
|
+
(typeof maxDeviation !== "number" || !Number.isFinite(maxDeviation) || maxDeviation < 0)
|
|
151
|
+
) {
|
|
152
|
+
throw createOuterApproximationError("INVALID_MAX_DEVIATION", "maxDeviation must be a finite number >= 0 when provided.", { maxDeviation });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cannotReachTargetError(targetVertexCount, maxDeviation, hasDeviationLimit) {
|
|
157
|
+
if (hasDeviationLimit) {
|
|
158
|
+
return createOuterApproximationError(
|
|
159
|
+
"TARGET_NOT_REACHABLE",
|
|
160
|
+
`Cannot reach ${targetVertexCount} coordinates without exceeding max deviation ${maxDeviation}.`,
|
|
161
|
+
{ targetVertexCount, maxDeviation, maxDeviationProvided: true }
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return createOuterApproximationError(
|
|
165
|
+
"TARGET_NOT_REACHABLE",
|
|
166
|
+
`Cannot reach ${targetVertexCount} coordinates while preserving strict containment.`,
|
|
167
|
+
{ targetVertexCount, maxDeviationProvided: false }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildResult(polygon, originalRing, maxDeviation, hasDeviationLimit) {
|
|
172
|
+
const outputRing = polygon.coordinates[0].slice(0, polygon.coordinates[0].length - 1);
|
|
173
|
+
const achievedDeviation = directedBoundaryDeviation(outputRing, originalRing);
|
|
174
|
+
return {
|
|
175
|
+
polygon,
|
|
176
|
+
achievedDeviation,
|
|
177
|
+
maxDeviation: hasDeviationLimit ? maxDeviation : achievedDeviation,
|
|
178
|
+
maxDeviationProvided: hasDeviationLimit,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function findOptimalDirectReduction(originalRing, targetVertexCount, deviationLimit) {
|
|
183
|
+
const n = originalRing.length;
|
|
184
|
+
if (targetVertexCount > n || targetVertexCount < 3) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const startIndices = [];
|
|
189
|
+
for (let i = 0; i < n; i += 1) {
|
|
190
|
+
startIndices.push(i);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const startTight = maybeTightenRing(cloneRing(originalRing), originalRing);
|
|
194
|
+
const startDeviation = directedBoundaryDeviation(startTight, originalRing);
|
|
195
|
+
const stack = [{ indices: startIndices, ring: startTight, deviation: startDeviation }];
|
|
196
|
+
const seen = new Set([indicesKey(startIndices)]);
|
|
197
|
+
|
|
198
|
+
let best = null;
|
|
199
|
+
|
|
200
|
+
while (stack.length > 0) {
|
|
201
|
+
const state = stack.pop();
|
|
202
|
+
|
|
203
|
+
if (state.indices.length === targetVertexCount) {
|
|
204
|
+
if (
|
|
205
|
+
!best ||
|
|
206
|
+
state.deviation < best.deviation - EPS ||
|
|
207
|
+
(almostEqual(state.deviation, best.deviation) && compareIndexArrays(state.indices, best.indices) < 0)
|
|
208
|
+
) {
|
|
209
|
+
best = {
|
|
210
|
+
indices: state.indices.slice(),
|
|
211
|
+
ring: cloneRing(state.ring),
|
|
212
|
+
deviation: state.deviation,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (let removePos = 0; removePos < state.indices.length; removePos += 1) {
|
|
219
|
+
const nextIndices = state.indices.slice(0, removePos).concat(state.indices.slice(removePos + 1));
|
|
220
|
+
if (nextIndices.length < targetVertexCount) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const key = indicesKey(nextIndices);
|
|
225
|
+
if (seen.has(key)) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const candidateBaseRing = ringFromIndices(originalRing, nextIndices);
|
|
230
|
+
if (candidateBaseRing.length < 3) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!isSimplePolygon(candidateBaseRing)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!polygonContainsPolygon(candidateBaseRing, originalRing)) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const candidateRing = maybeTightenRing(candidateBaseRing, originalRing);
|
|
241
|
+
if (!isSimplePolygon(candidateRing)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!polygonContainsPolygon(candidateRing, originalRing)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const deviation = directedBoundaryDeviation(candidateRing, originalRing);
|
|
249
|
+
if (deviation > deviationLimit + EPS) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
seen.add(key);
|
|
254
|
+
stack.push({ indices: nextIndices, ring: candidateRing, deviation });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return best;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ringFromIndices(originalRing, indices) {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (let i = 0; i < indices.length; i += 1) {
|
|
264
|
+
const p = originalRing[indices[i]];
|
|
265
|
+
out.push([p[0], p[1]]);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function indicesKey(indices) {
|
|
271
|
+
return indices.join(",");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function compareIndexArrays(a, b) {
|
|
275
|
+
const n = Math.min(a.length, b.length);
|
|
276
|
+
for (let i = 0; i < n; i += 1) {
|
|
277
|
+
if (a[i] < b[i]) {
|
|
278
|
+
return -1;
|
|
279
|
+
}
|
|
280
|
+
if (a[i] > b[i]) {
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (a.length < b.length) {
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
if (a.length > b.length) {
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
function normalizeOuterRing(polygon) {
|
|
293
|
+
const ring = polygon.coordinates[0];
|
|
294
|
+
if (!Array.isArray(ring) || ring.length < 4) {
|
|
295
|
+
throw createOuterApproximationError("INVALID_RING_LENGTH", "Polygon outer ring must contain at least 4 positions (including closure).", { ringLength: ring.length });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const cleaned = [];
|
|
299
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
300
|
+
const p = ring[i];
|
|
301
|
+
if (!Array.isArray(p) || p.length < 2) {
|
|
302
|
+
throw createOuterApproximationError("INVALID_COORDINATE_TUPLE", "Every coordinate must be a [x, y] tuple.", { index: i });
|
|
303
|
+
}
|
|
304
|
+
const x = Number(p[0]);
|
|
305
|
+
const y = Number(p[1]);
|
|
306
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
307
|
+
throw createOuterApproximationError("INVALID_COORDINATE_VALUE", "Every coordinate must be finite.", { index: i });
|
|
308
|
+
}
|
|
309
|
+
cleaned.push([x, y]);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!samePoint(cleaned[0], cleaned[cleaned.length - 1])) {
|
|
313
|
+
throw createOuterApproximationError("RING_NOT_CLOSED", "GeoJSON linear ring must be explicitly closed (first point == last point).");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Drop closure point and consecutive duplicate points.
|
|
317
|
+
const open = cleaned.slice(0, cleaned.length - 1);
|
|
318
|
+
const deduped = [];
|
|
319
|
+
for (let i = 0; i < open.length; i += 1) {
|
|
320
|
+
if (deduped.length === 0 || !samePoint(deduped[deduped.length - 1], open[i])) {
|
|
321
|
+
deduped.push(open[i]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (deduped.length >= 2 && samePoint(deduped[0], deduped[deduped.length - 1])) {
|
|
325
|
+
deduped.pop();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (deduped.length < 3) {
|
|
329
|
+
throw createOuterApproximationError("INSUFFICIENT_UNIQUE_VERTICES", "Polygon must contain at least 3 unique vertices.");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!isSimplePolygon(deduped)) {
|
|
333
|
+
throw createOuterApproximationError("SELF_INTERSECTING_POLYGON", "Input polygon must be simple (no self-intersections).");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (signedArea(deduped) < 0) {
|
|
337
|
+
deduped.reverse();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return deduped;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toGeoJSONPolygon(openRing) {
|
|
344
|
+
const closed = cloneRing(openRing);
|
|
345
|
+
closed.push([openRing[0][0], openRing[0][1]]);
|
|
346
|
+
return {
|
|
347
|
+
type: "Polygon",
|
|
348
|
+
coordinates: [closed],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findBestDirectRemoval(currentRing, originalRing) {
|
|
353
|
+
let best = null;
|
|
354
|
+
|
|
355
|
+
for (let i = 0; i < currentRing.length; i += 1) {
|
|
356
|
+
const candidate = removeVertex(currentRing, i);
|
|
357
|
+
if (candidate.length < 3) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!isSimplePolygon(candidate)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!polygonContainsPolygon(candidate, originalRing)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const deviation = directedBoundaryDeviation(candidate, originalRing);
|
|
367
|
+
if (!best || deviation < best.deviation - EPS || (almostEqual(deviation, best.deviation) && i < best.index)) {
|
|
368
|
+
best = { ring: candidate, deviation, index: i };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return best;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function findOptimalConvexReduction(startRing, originalRing, targetVertexCount, deviationLimit) {
|
|
376
|
+
if (startRing.length < targetVertexCount || targetVertexCount < 3) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const startTight = maybeTightenRing(cloneRing(startRing), originalRing);
|
|
381
|
+
const startDeviation = directedBoundaryDeviation(startTight, originalRing);
|
|
382
|
+
const stack = [{ ring: startTight, deviation: startDeviation }];
|
|
383
|
+
const seen = new Map([[canonicalRingKey(startTight), startDeviation]]);
|
|
384
|
+
|
|
385
|
+
let best = null;
|
|
386
|
+
|
|
387
|
+
while (stack.length > 0) {
|
|
388
|
+
const state = stack.pop();
|
|
389
|
+
const stateKey = canonicalRingKey(state.ring);
|
|
390
|
+
const bestSeenForState = seen.get(stateKey);
|
|
391
|
+
if (bestSeenForState !== undefined && state.deviation > bestSeenForState + EPS) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (state.ring.length === targetVertexCount) {
|
|
396
|
+
if (
|
|
397
|
+
!best ||
|
|
398
|
+
state.deviation < best.deviation - EPS ||
|
|
399
|
+
(almostEqual(state.deviation, best.deviation) && stateKey < best.signature)
|
|
400
|
+
) {
|
|
401
|
+
best = {
|
|
402
|
+
ring: cloneRing(state.ring),
|
|
403
|
+
deviation: state.deviation,
|
|
404
|
+
signature: stateKey,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (let i = 0; i < state.ring.length; i += 1) {
|
|
411
|
+
const candidateRaw = makeConvexRemovalCandidate(state.ring, i);
|
|
412
|
+
if (!candidateRaw) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (!isSimplePolygon(candidateRaw)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (!isConvexCCW(candidateRaw)) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!polygonContainsPolygon(candidateRaw, originalRing)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const candidate = maybeTightenRing(candidateRaw, originalRing);
|
|
426
|
+
if (!isSimplePolygon(candidate)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!isConvexCCW(candidate)) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!polygonContainsPolygon(candidate, originalRing)) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const deviation = directedBoundaryDeviation(candidate, originalRing);
|
|
437
|
+
if (deviation > deviationLimit + EPS) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const sig = canonicalRingKey(candidate);
|
|
442
|
+
const seenDeviation = seen.get(sig);
|
|
443
|
+
if (seenDeviation !== undefined && seenDeviation <= deviation + EPS) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
seen.set(sig, deviation);
|
|
448
|
+
stack.push({ ring: candidate, deviation });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return best;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function canonicalRingKey(ring) {
|
|
456
|
+
const n = ring.length;
|
|
457
|
+
if (n === 0) {
|
|
458
|
+
return "";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let start = 0;
|
|
462
|
+
for (let i = 1; i < n; i += 1) {
|
|
463
|
+
const pi = ring[i];
|
|
464
|
+
const ps = ring[start];
|
|
465
|
+
if (pi[0] < ps[0] - EPS || (almostEqual(pi[0], ps[0]) && pi[1] < ps[1] - EPS)) {
|
|
466
|
+
start = i;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const parts = [];
|
|
471
|
+
for (let k = 0; k < n; k += 1) {
|
|
472
|
+
const p = ring[(start + k) % n];
|
|
473
|
+
parts.push(`${p[0].toFixed(12)},${p[1].toFixed(12)}`);
|
|
474
|
+
}
|
|
475
|
+
return parts.join("|");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function maybeTightenRing(ring, originalRing) {
|
|
479
|
+
if (!isConvexCCW(ring)) {
|
|
480
|
+
return ring;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let current = cloneRing(ring);
|
|
484
|
+
let currentDeviation = directedBoundaryDeviation(current, originalRing);
|
|
485
|
+
let prevKey = canonicalRingKey(current);
|
|
486
|
+
|
|
487
|
+
for (let iter = 0; iter < 8; iter += 1) {
|
|
488
|
+
const tightened = tightenConvexRingParallelSupportOnce(current, originalRing);
|
|
489
|
+
if (!tightened) {
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
if (!isSimplePolygon(tightened) || !isConvexCCW(tightened)) {
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
if (!polygonContainsPolygon(tightened, originalRing)) {
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const nextDeviation = directedBoundaryDeviation(tightened, originalRing);
|
|
500
|
+
if (nextDeviation > currentDeviation + 1e-12) {
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const nextKey = canonicalRingKey(tightened);
|
|
505
|
+
current = tightened;
|
|
506
|
+
currentDeviation = nextDeviation;
|
|
507
|
+
if (nextKey === prevKey) {
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
prevKey = nextKey;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return current;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function tightenConvexRingParallelSupportOnce(ring, originalRing) {
|
|
517
|
+
const m = ring.length;
|
|
518
|
+
if (m < 3) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const lines = [];
|
|
523
|
+
for (let i = 0; i < m; i += 1) {
|
|
524
|
+
const a = ring[i];
|
|
525
|
+
const b = ring[(i + 1) % m];
|
|
526
|
+
const edge = sub(b, a);
|
|
527
|
+
if (lengthSquared(edge) <= EPS) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const normal = normalize(perpLeft(edge));
|
|
532
|
+
let support = Infinity;
|
|
533
|
+
for (let k = 0; k < originalRing.length; k += 1) {
|
|
534
|
+
support = Math.min(support, dot(normal, originalRing[k]));
|
|
535
|
+
}
|
|
536
|
+
lines.push({ normal, support });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const out = [];
|
|
540
|
+
for (let i = 0; i < m; i += 1) {
|
|
541
|
+
const prev = lines[mod(i - 1, m)];
|
|
542
|
+
const curr = lines[i];
|
|
543
|
+
const v = intersectNormalLines(prev.normal, prev.support, curr.normal, curr.support);
|
|
544
|
+
if (!v) {
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
out.push(v);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const normalized = removeConsecutiveDuplicates(out);
|
|
551
|
+
if (normalized.length !== m) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if (signedArea(normalized) < 0) {
|
|
555
|
+
normalized.reverse();
|
|
556
|
+
}
|
|
557
|
+
return normalized;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function intersectNormalLines(n1, c1, n2, c2) {
|
|
561
|
+
const det = n1[0] * n2[1] - n1[1] * n2[0];
|
|
562
|
+
if (Math.abs(det) <= EPS) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const x = (c1 * n2[1] - n1[1] * c2) / det;
|
|
566
|
+
const y = (n1[0] * c2 - c1 * n2[0]) / det;
|
|
567
|
+
return [x, y];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function findBestConvexRemoval(currentRing, originalRing) {
|
|
571
|
+
let best = null;
|
|
572
|
+
|
|
573
|
+
for (let i = 0; i < currentRing.length; i += 1) {
|
|
574
|
+
const candidate = makeConvexRemovalCandidate(currentRing, i);
|
|
575
|
+
if (!candidate) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (!isSimplePolygon(candidate)) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (!isConvexCCW(candidate)) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (!polygonContainsPolygon(candidate, originalRing)) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const deviation = directedBoundaryDeviation(candidate, originalRing);
|
|
589
|
+
if (!best || deviation < best.deviation - EPS || (almostEqual(deviation, best.deviation) && i < best.index)) {
|
|
590
|
+
best = { ring: candidate, deviation, index: i };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return best;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function makeConvexRemovalCandidate(ring, i) {
|
|
598
|
+
const m = ring.length;
|
|
599
|
+
if (m < 4) {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const im2 = mod(i - 2, m);
|
|
604
|
+
const im1 = mod(i - 1, m);
|
|
605
|
+
const ip1 = mod(i + 1, m);
|
|
606
|
+
const ip2 = mod(i + 2, m);
|
|
607
|
+
|
|
608
|
+
const a = ring[im1];
|
|
609
|
+
const b = ring[i];
|
|
610
|
+
const c = ring[ip1];
|
|
611
|
+
const chord = sub(c, a);
|
|
612
|
+
if (lengthSquared(chord) <= EPS) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let normal = normalize(perpLeft(chord));
|
|
617
|
+
let c0 = dot(normal, a);
|
|
618
|
+
if (dot(normal, b) <= c0 + EPS) {
|
|
619
|
+
normal = [-normal[0], -normal[1]];
|
|
620
|
+
c0 = dot(normal, a);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Tightest support line for this normal that still contains current polygon.
|
|
624
|
+
let support = -Infinity;
|
|
625
|
+
for (let k = 0; k < m; k += 1) {
|
|
626
|
+
support = Math.max(support, dot(normal, ring[k]));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const prevLine = lineThrough(ring[im2], ring[im1]);
|
|
630
|
+
const nextLine = lineThrough(ring[ip1], ring[ip2]);
|
|
631
|
+
const u = intersectGeneralAndNormalLine(prevLine, normal, support);
|
|
632
|
+
const w = intersectGeneralAndNormalLine(nextLine, normal, support);
|
|
633
|
+
if (!u || !w || distanceSquared(u, w) <= EPS) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const candidate = [];
|
|
638
|
+
for (let j = 0; j < m; j += 1) {
|
|
639
|
+
if (j === i) {
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (j === im1) {
|
|
643
|
+
candidate.push(u);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (j === ip1) {
|
|
647
|
+
candidate.push(w);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
candidate.push([ring[j][0], ring[j][1]]);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const normalized = removeConsecutiveDuplicates(candidate);
|
|
654
|
+
if (normalized.length !== m - 1) {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if (signedArea(normalized) < 0) {
|
|
658
|
+
normalized.reverse();
|
|
659
|
+
}
|
|
660
|
+
return normalized;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function addVerticesWithoutChangingBoundary(ring, targetVertexCount) {
|
|
664
|
+
const out = cloneRing(ring);
|
|
665
|
+
while (out.length < targetVertexCount) {
|
|
666
|
+
let bestEdge = -1;
|
|
667
|
+
let bestLen2 = -1;
|
|
668
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
669
|
+
const j = (i + 1) % out.length;
|
|
670
|
+
const len2 = distanceSquared(out[i], out[j]);
|
|
671
|
+
if (len2 > bestLen2 + EPS || (almostEqual(len2, bestLen2) && i < bestEdge)) {
|
|
672
|
+
bestLen2 = len2;
|
|
673
|
+
bestEdge = i;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (bestEdge === -1 || bestLen2 <= EPS) {
|
|
677
|
+
throw createOuterApproximationError("DEGENERATE_EDGE_INSERTION", "Cannot insert more vertices: all edges are degenerate.");
|
|
678
|
+
}
|
|
679
|
+
const j = (bestEdge + 1) % out.length;
|
|
680
|
+
const mid = midpoint(out[bestEdge], out[j]);
|
|
681
|
+
out.splice(j, 0, mid);
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function removeVertex(ring, idx) {
|
|
687
|
+
const out = [];
|
|
688
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
689
|
+
if (i !== idx) {
|
|
690
|
+
out.push([ring[i][0], ring[i][1]]);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return removeConsecutiveDuplicates(out);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function directedBoundaryDeviation(candidateRing, originalRing) {
|
|
697
|
+
const samplePoints = [];
|
|
698
|
+
|
|
699
|
+
for (let i = 0; i < candidateRing.length; i += 1) {
|
|
700
|
+
samplePoints.push(candidateRing[i]);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
for (let i = 0; i < candidateRing.length; i += 1) {
|
|
704
|
+
const a = candidateRing[i];
|
|
705
|
+
const b = candidateRing[(i + 1) % candidateRing.length];
|
|
706
|
+
samplePoints.push(midpoint(a, b));
|
|
707
|
+
|
|
708
|
+
for (let k = 0; k < originalRing.length; k += 1) {
|
|
709
|
+
const v = originalRing[k];
|
|
710
|
+
const t = projectionParameterOnSegment(v, a, b);
|
|
711
|
+
if (t > EPS && t < 1 - EPS) {
|
|
712
|
+
samplePoints.push(interpolate(a, b, t));
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let maxDistance = 0;
|
|
718
|
+
for (let i = 0; i < samplePoints.length; i += 1) {
|
|
719
|
+
const d = distancePointToPolyline(samplePoints[i], originalRing);
|
|
720
|
+
if (d > maxDistance) {
|
|
721
|
+
maxDistance = d;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return maxDistance;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function polygonContainsPolygon(containerRing, containeeRing) {
|
|
729
|
+
for (let i = 0; i < containeeRing.length; i += 1) {
|
|
730
|
+
if (!pointInPolygonOrOnBoundary(containeeRing[i], containerRing)) {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
for (let i = 0; i < containeeRing.length; i += 1) {
|
|
736
|
+
const a1 = containeeRing[i];
|
|
737
|
+
const a2 = containeeRing[(i + 1) % containeeRing.length];
|
|
738
|
+
for (let j = 0; j < containerRing.length; j += 1) {
|
|
739
|
+
const b1 = containerRing[j];
|
|
740
|
+
const b2 = containerRing[(j + 1) % containerRing.length];
|
|
741
|
+
if (properSegmentIntersection(a1, a2, b1, b2)) {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function pointInPolygonOrOnBoundary(point, ring) {
|
|
751
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
752
|
+
const a = ring[i];
|
|
753
|
+
const b = ring[(i + 1) % ring.length];
|
|
754
|
+
if (pointOnSegment(point, a, b)) {
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Ray casting (x direction).
|
|
760
|
+
let inside = false;
|
|
761
|
+
const x = point[0];
|
|
762
|
+
const y = point[1];
|
|
763
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
|
764
|
+
const xi = ring[i][0];
|
|
765
|
+
const yi = ring[i][1];
|
|
766
|
+
const xj = ring[j][0];
|
|
767
|
+
const yj = ring[j][1];
|
|
768
|
+
|
|
769
|
+
const intersects = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
770
|
+
if (intersects) {
|
|
771
|
+
inside = !inside;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return inside;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isSimplePolygon(ring) {
|
|
778
|
+
const n = ring.length;
|
|
779
|
+
if (n < 3) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
for (let i = 0; i < n; i += 1) {
|
|
783
|
+
const a1 = ring[i];
|
|
784
|
+
const a2 = ring[(i + 1) % n];
|
|
785
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
786
|
+
const b1 = ring[j];
|
|
787
|
+
const b2 = ring[(j + 1) % n];
|
|
788
|
+
|
|
789
|
+
if (i === j) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if ((i + 1) % n === j || i === (j + 1) % n) {
|
|
793
|
+
continue; // adjacent edges share one endpoint
|
|
794
|
+
}
|
|
795
|
+
if (segmentsIntersectInclusive(a1, a2, b1, b2)) {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function isConvexCCW(ring) {
|
|
804
|
+
if (ring.length < 3) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
if (signedArea(ring) <= EPS) {
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
let sawPositive = false;
|
|
811
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
812
|
+
const a = ring[i];
|
|
813
|
+
const b = ring[(i + 1) % ring.length];
|
|
814
|
+
const c = ring[(i + 2) % ring.length];
|
|
815
|
+
const z = cross(sub(b, a), sub(c, b));
|
|
816
|
+
if (z < -EPS) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
if (z > EPS) {
|
|
820
|
+
sawPositive = true;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return sawPositive;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function convexHull(points) {
|
|
827
|
+
const unique = uniquePoints(points);
|
|
828
|
+
if (unique.length <= 1) {
|
|
829
|
+
return unique;
|
|
830
|
+
}
|
|
831
|
+
unique.sort((p, q) => (p[0] === q[0] ? p[1] - q[1] : p[0] - q[0]));
|
|
832
|
+
|
|
833
|
+
const lower = [];
|
|
834
|
+
for (let i = 0; i < unique.length; i += 1) {
|
|
835
|
+
while (lower.length >= 2) {
|
|
836
|
+
const p1 = lower[lower.length - 2];
|
|
837
|
+
const p2 = lower[lower.length - 1];
|
|
838
|
+
const p3 = unique[i];
|
|
839
|
+
if (cross(sub(p2, p1), sub(p3, p2)) <= EPS) {
|
|
840
|
+
lower.pop();
|
|
841
|
+
} else {
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
lower.push(unique[i]);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const upper = [];
|
|
849
|
+
for (let i = unique.length - 1; i >= 0; i -= 1) {
|
|
850
|
+
while (upper.length >= 2) {
|
|
851
|
+
const p1 = upper[upper.length - 2];
|
|
852
|
+
const p2 = upper[upper.length - 1];
|
|
853
|
+
const p3 = unique[i];
|
|
854
|
+
if (cross(sub(p2, p1), sub(p3, p2)) <= EPS) {
|
|
855
|
+
upper.pop();
|
|
856
|
+
} else {
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
upper.push(unique[i]);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
lower.pop();
|
|
864
|
+
upper.pop();
|
|
865
|
+
return lower.concat(upper);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function distancePointToPolyline(point, ring) {
|
|
869
|
+
let best = Infinity;
|
|
870
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
871
|
+
const a = ring[i];
|
|
872
|
+
const b = ring[(i + 1) % ring.length];
|
|
873
|
+
const d = distancePointToSegment(point, a, b);
|
|
874
|
+
if (d < best) {
|
|
875
|
+
best = d;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return best;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function distancePointToSegment(p, a, b) {
|
|
882
|
+
const t = projectionParameterOnSegment(p, a, b);
|
|
883
|
+
const q = interpolate(a, b, t);
|
|
884
|
+
return Math.sqrt(distanceSquared(p, q));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function projectionParameterOnSegment(p, a, b) {
|
|
888
|
+
const ab = sub(b, a);
|
|
889
|
+
const denom = dot(ab, ab);
|
|
890
|
+
if (denom <= EPS) {
|
|
891
|
+
return 0;
|
|
892
|
+
}
|
|
893
|
+
const t = dot(sub(p, a), ab) / denom;
|
|
894
|
+
if (t <= 0) {
|
|
895
|
+
return 0;
|
|
896
|
+
}
|
|
897
|
+
if (t >= 1) {
|
|
898
|
+
return 1;
|
|
899
|
+
}
|
|
900
|
+
return t;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function properSegmentIntersection(a, b, c, d) {
|
|
904
|
+
const o1 = orient(a, b, c);
|
|
905
|
+
const o2 = orient(a, b, d);
|
|
906
|
+
const o3 = orient(c, d, a);
|
|
907
|
+
const o4 = orient(c, d, b);
|
|
908
|
+
|
|
909
|
+
return o1 * o2 < -EPS && o3 * o4 < -EPS;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function segmentsIntersectInclusive(a, b, c, d) {
|
|
913
|
+
const o1 = orient(a, b, c);
|
|
914
|
+
const o2 = orient(a, b, d);
|
|
915
|
+
const o3 = orient(c, d, a);
|
|
916
|
+
const o4 = orient(c, d, b);
|
|
917
|
+
|
|
918
|
+
if (o1 * o2 < -EPS && o3 * o4 < -EPS) {
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
if (Math.abs(o1) <= EPS && pointOnSegment(c, a, b)) {
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
if (Math.abs(o2) <= EPS && pointOnSegment(d, a, b)) {
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
if (Math.abs(o3) <= EPS && pointOnSegment(a, c, d)) {
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
if (Math.abs(o4) <= EPS && pointOnSegment(b, c, d)) {
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function pointOnSegment(p, a, b) {
|
|
937
|
+
if (Math.abs(orient(a, b, p)) > EPS) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const minX = Math.min(a[0], b[0]) - EPS;
|
|
941
|
+
const maxX = Math.max(a[0], b[0]) + EPS;
|
|
942
|
+
const minY = Math.min(a[1], b[1]) - EPS;
|
|
943
|
+
const maxY = Math.max(a[1], b[1]) + EPS;
|
|
944
|
+
return p[0] >= minX && p[0] <= maxX && p[1] >= minY && p[1] <= maxY;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function lineThrough(p, q) {
|
|
948
|
+
// Ax + By = C
|
|
949
|
+
const A = q[1] - p[1];
|
|
950
|
+
const B = p[0] - q[0];
|
|
951
|
+
const C = A * p[0] + B * p[1];
|
|
952
|
+
return { A, B, C };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function intersectGeneralAndNormalLine(line, normal, constant) {
|
|
956
|
+
const A1 = line.A;
|
|
957
|
+
const B1 = line.B;
|
|
958
|
+
const C1 = line.C;
|
|
959
|
+
const A2 = normal[0];
|
|
960
|
+
const B2 = normal[1];
|
|
961
|
+
const C2 = constant;
|
|
962
|
+
|
|
963
|
+
const det = A1 * B2 - B1 * A2;
|
|
964
|
+
if (Math.abs(det) <= EPS) {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
const x = (C1 * B2 - B1 * C2) / det;
|
|
968
|
+
const y = (A1 * C2 - C1 * A2) / det;
|
|
969
|
+
return [x, y];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function signedArea(ring) {
|
|
973
|
+
let area2 = 0;
|
|
974
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
975
|
+
const p = ring[i];
|
|
976
|
+
const q = ring[(i + 1) % ring.length];
|
|
977
|
+
area2 += p[0] * q[1] - q[0] * p[1];
|
|
978
|
+
}
|
|
979
|
+
return area2 / 2;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function uniquePoints(points) {
|
|
983
|
+
const out = [];
|
|
984
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
985
|
+
let seen = false;
|
|
986
|
+
for (let j = 0; j < out.length; j += 1) {
|
|
987
|
+
if (samePoint(points[i], out[j])) {
|
|
988
|
+
seen = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (!seen) {
|
|
993
|
+
out.push([points[i][0], points[i][1]]);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return out;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function removeConsecutiveDuplicates(ring) {
|
|
1000
|
+
const out = [];
|
|
1001
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
1002
|
+
if (out.length === 0 || !samePoint(out[out.length - 1], ring[i])) {
|
|
1003
|
+
out.push([ring[i][0], ring[i][1]]);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (out.length > 1 && samePoint(out[0], out[out.length - 1])) {
|
|
1007
|
+
out.pop();
|
|
1008
|
+
}
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function cloneRing(ring) {
|
|
1013
|
+
return ring.map((p) => [p[0], p[1]]);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function samePoint(a, b) {
|
|
1017
|
+
return Math.abs(a[0] - b[0]) <= EPS && Math.abs(a[1] - b[1]) <= EPS;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function midpoint(a, b) {
|
|
1021
|
+
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function interpolate(a, b, t) {
|
|
1025
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function orient(a, b, c) {
|
|
1029
|
+
return cross(sub(b, a), sub(c, a));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function sub(a, b) {
|
|
1033
|
+
return [a[0] - b[0], a[1] - b[1]];
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function dot(a, b) {
|
|
1037
|
+
return a[0] * b[0] + a[1] * b[1];
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function cross(a, b) {
|
|
1041
|
+
return a[0] * b[1] - a[1] * b[0];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function perpLeft(v) {
|
|
1045
|
+
return [-v[1], v[0]];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function normalize(v) {
|
|
1049
|
+
const len = Math.sqrt(dot(v, v));
|
|
1050
|
+
if (len <= EPS) {
|
|
1051
|
+
return [0, 0];
|
|
1052
|
+
}
|
|
1053
|
+
return [v[0] / len, v[1] / len];
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function distanceSquared(a, b) {
|
|
1057
|
+
const dx = a[0] - b[0];
|
|
1058
|
+
const dy = a[1] - b[1];
|
|
1059
|
+
return dx * dx + dy * dy;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function lengthSquared(v) {
|
|
1063
|
+
return dot(v, v);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function almostEqual(a, b) {
|
|
1067
|
+
return Math.abs(a - b) <= EPS;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function mod(x, m) {
|
|
1071
|
+
return ((x % m) + m) % m;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
module.exports = {
|
|
1075
|
+
outerApproximatePolygon,
|
|
1076
|
+
OuterApproximationError,
|
|
1077
|
+
OUTER_APPROXIMATION_ERROR_CODES,
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "poly-prune",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deterministic GeoJSON outer-approximation with exact vertex target and strict deviation cap.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"packageManager": "npm@6.13.4",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"types": "index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"require": "./index.js",
|
|
14
|
+
"default": "./index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"index.d.ts",
|
|
20
|
+
"outerApproximatePolygon.js",
|
|
21
|
+
"outerApproximatePolygon.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"geojson",
|
|
27
|
+
"polygon",
|
|
28
|
+
"geometry",
|
|
29
|
+
"simplification",
|
|
30
|
+
"hull",
|
|
31
|
+
"map"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node tests/outerApproximatePolygon.test.js",
|
|
35
|
+
"pack:check": "npm pack --dry-run",
|
|
36
|
+
"prepublishOnly": "npm test"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=12"
|
|
40
|
+
}
|
|
41
|
+
}
|