@emasoft/svg-matrix 1.0.4 → 1.0.6
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/README.md +341 -304
- package/package.json +19 -1
- package/src/browser-verify.js +463 -0
- package/src/clip-path-resolver.js +759 -0
- package/src/geometry-to-path.js +348 -0
- package/src/index.js +413 -6
- package/src/marker-resolver.js +1006 -0
- package/src/mask-resolver.js +1407 -0
- package/src/mesh-gradient.js +1215 -0
- package/src/pattern-resolver.js +844 -0
- package/src/polygon-clip.js +1491 -0
- package/src/svg-flatten.js +1615 -76
- package/src/text-to-path.js +820 -0
- package/src/transforms2d.js +493 -37
- package/src/transforms3d.js +418 -47
- package/src/use-symbol-resolver.js +1126 -0
- package/samples/test.svg +0 -39
|
@@ -0,0 +1,1491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polygon Boolean Operations with Arbitrary Precision
|
|
3
|
+
*
|
|
4
|
+
* A comprehensive library for 2D polygon operations using arbitrary-precision
|
|
5
|
+
* arithmetic via Decimal.js. Eliminates floating-point errors in geometric
|
|
6
|
+
* computations, making it suitable for CAD, GIS, and other applications
|
|
7
|
+
* requiring exact geometric calculations.
|
|
8
|
+
*
|
|
9
|
+
* ## Key Algorithms Implemented
|
|
10
|
+
*
|
|
11
|
+
* ### Sutherland-Hodgman Clipping
|
|
12
|
+
* Classic O(n) algorithm for clipping a polygon against a convex clipping
|
|
13
|
+
* window. Processes the subject polygon against each edge of the clipping
|
|
14
|
+
* polygon sequentially, outputting vertices that lie inside the half-plane
|
|
15
|
+
* defined by each edge.
|
|
16
|
+
*
|
|
17
|
+
* Reference: Sutherland, I. E., & Hodgman, G. W. (1974). "Reentrant polygon
|
|
18
|
+
* clipping." Communications of the ACM, 17(1), 32-42.
|
|
19
|
+
*
|
|
20
|
+
* ### Graham Scan (Convex Hull)
|
|
21
|
+
* Efficient O(n log n) algorithm for computing the convex hull of a point set.
|
|
22
|
+
* Sorts points by polar angle from a pivot (lowest point), then uses a stack
|
|
23
|
+
* to maintain hull vertices, removing points that create right turns.
|
|
24
|
+
*
|
|
25
|
+
* Reference: Graham, R. L. (1972). "An efficient algorithm for determining
|
|
26
|
+
* the convex hull of a finite planar set." Information Processing Letters, 1(4).
|
|
27
|
+
*
|
|
28
|
+
* ### Winding Number (Point in Polygon)
|
|
29
|
+
* Robust point-in-polygon test using winding number algorithm. Counts how many
|
|
30
|
+
* times the polygon winds around the test point by tracking upward and downward
|
|
31
|
+
* edge crossings of a horizontal ray cast from the point.
|
|
32
|
+
*
|
|
33
|
+
* ### Shoelace Formula (Polygon Area)
|
|
34
|
+
* Also known as surveyor's formula or Gauss's area formula. Computes signed
|
|
35
|
+
* polygon area in O(n) time using only vertex coordinates. Derived from
|
|
36
|
+
* Green's theorem applied to the polygon boundary.
|
|
37
|
+
*
|
|
38
|
+
* ### Boolean Operations
|
|
39
|
+
* Simplified polygon intersection, union, and difference operations. Uses
|
|
40
|
+
* point collection and convex hull for general polygons. For exact results
|
|
41
|
+
* with complex concave polygons, consider full implementations like:
|
|
42
|
+
* - Greiner-Hormann clipping
|
|
43
|
+
* - Martinez-Rueda-Feito algorithm
|
|
44
|
+
* - Vatti clipping algorithm
|
|
45
|
+
*
|
|
46
|
+
* ## Usage Examples
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Create points with arbitrary precision
|
|
50
|
+
* import { point, polygonArea, polygonIntersection } from './polygon-clip.js';
|
|
51
|
+
*
|
|
52
|
+
* const square = [
|
|
53
|
+
* point('0', '0'),
|
|
54
|
+
* point('10', '0'),
|
|
55
|
+
* point('10', '10'),
|
|
56
|
+
* point('0', '10')
|
|
57
|
+
* ];
|
|
58
|
+
*
|
|
59
|
+
* // Compute area (returns Decimal with value 100)
|
|
60
|
+
* const area = polygonArea(square);
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // Boolean intersection
|
|
64
|
+
* const square1 = [point(0,0), point(2,0), point(2,2), point(0,2)];
|
|
65
|
+
* const square2 = [point(1,1), point(3,1), point(3,3), point(1,3)];
|
|
66
|
+
* const intersection = polygonIntersection(square1, square2);
|
|
67
|
+
* // Returns [point(1,1), point(2,1), point(2,2), point(1,2)]
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* // Convex hull of point cloud
|
|
71
|
+
* const points = [point(0,0), point(2,1), point(1,2), point(1,1)];
|
|
72
|
+
* const hull = convexHull(points);
|
|
73
|
+
* // Returns vertices of minimal convex polygon containing all points
|
|
74
|
+
*
|
|
75
|
+
* ## Precision Configuration
|
|
76
|
+
*
|
|
77
|
+
* This module sets Decimal.js precision to 80 decimal places and uses an
|
|
78
|
+
* EPSILON threshold of 1e-40 for near-zero comparisons. These can be
|
|
79
|
+
* adjusted based on application requirements.
|
|
80
|
+
*
|
|
81
|
+
* ## Coordinate System
|
|
82
|
+
*
|
|
83
|
+
* - Counter-clockwise (CCW) vertex order is considered positive orientation
|
|
84
|
+
* - Cross product > 0 indicates left turn (CCW)
|
|
85
|
+
* - Cross product < 0 indicates right turn (CW)
|
|
86
|
+
* - Y-axis points upward (standard mathematical convention)
|
|
87
|
+
*
|
|
88
|
+
* @module polygon-clip
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
import Decimal from 'decimal.js';
|
|
92
|
+
|
|
93
|
+
// Set high precision for all calculations
|
|
94
|
+
Decimal.set({ precision: 80 });
|
|
95
|
+
|
|
96
|
+
// Helper to convert to Decimal
|
|
97
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
98
|
+
|
|
99
|
+
// Near-zero threshold for comparisons
|
|
100
|
+
const EPSILON = new Decimal('1e-40');
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Point and Vector Primitives
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a point with arbitrary-precision Decimal coordinates.
|
|
108
|
+
*
|
|
109
|
+
* This is the fundamental building block for all polygon operations in this library.
|
|
110
|
+
* Accepts numbers, strings, or Decimal instances and converts them to high-precision
|
|
111
|
+
* Decimal coordinates to avoid floating-point errors in geometric calculations.
|
|
112
|
+
*
|
|
113
|
+
* @param {number|string|Decimal} x - The x-coordinate (will be converted to Decimal)
|
|
114
|
+
* @param {number|string|Decimal} y - The y-coordinate (will be converted to Decimal)
|
|
115
|
+
* @returns {{x: Decimal, y: Decimal}} Point object with Decimal coordinates
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* // Create a point from numbers
|
|
119
|
+
* const p1 = point(3, 4);
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* // Create a point from strings for exact precision
|
|
123
|
+
* const p2 = point('0.1', '0.2'); // Avoids 0.1 + 0.2 != 0.3 issues
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* // Create a point from existing Decimals
|
|
127
|
+
* const p3 = point(new Decimal('1.5'), new Decimal('2.5'));
|
|
128
|
+
*/
|
|
129
|
+
export function point(x, y) {
|
|
130
|
+
return { x: D(x), y: D(y) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if two points are equal within a specified tolerance.
|
|
135
|
+
*
|
|
136
|
+
* Uses the absolute difference between coordinates to determine equality.
|
|
137
|
+
* This is essential for geometric algorithms where exact equality is rarely
|
|
138
|
+
* achievable due to numerical computation.
|
|
139
|
+
*
|
|
140
|
+
* @param {Object} p1 - First point with {x: Decimal, y: Decimal}
|
|
141
|
+
* @param {Object} p2 - Second point with {x: Decimal, y: Decimal}
|
|
142
|
+
* @param {Decimal} [tolerance=EPSILON] - Maximum allowed difference (default: 1e-40)
|
|
143
|
+
* @returns {boolean} True if points are within tolerance of each other
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const p1 = point(1, 2);
|
|
147
|
+
* const p2 = point(1.0000000001, 2.0000000001);
|
|
148
|
+
* pointsEqual(p1, p2); // true (within default tolerance)
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* const p1 = point(0, 0);
|
|
152
|
+
* const p2 = point(1, 1);
|
|
153
|
+
* pointsEqual(p1, p2); // false
|
|
154
|
+
*/
|
|
155
|
+
export function pointsEqual(p1, p2, tolerance = EPSILON) {
|
|
156
|
+
return p1.x.minus(p2.x).abs().lt(tolerance) &&
|
|
157
|
+
p1.y.minus(p2.y).abs().lt(tolerance);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compute the 2D cross product (z-component of 3D cross product).
|
|
162
|
+
*
|
|
163
|
+
* The cross product is fundamental for determining orientation and turns in 2D geometry.
|
|
164
|
+
* It computes the signed area of the parallelogram formed by vectors (o→a) and (o→b).
|
|
165
|
+
*
|
|
166
|
+
* Algorithm:
|
|
167
|
+
* - Forms vectors v1 = a - o and v2 = b - o
|
|
168
|
+
* - Returns v1.x * v2.y - v1.y * v2.x
|
|
169
|
+
* - This is equivalent to |v1| * |v2| * sin(θ) where θ is the angle from v1 to v2
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} o - Origin point with {x: Decimal, y: Decimal}
|
|
172
|
+
* @param {Object} a - First point with {x: Decimal, y: Decimal}
|
|
173
|
+
* @param {Object} b - Second point with {x: Decimal, y: Decimal}
|
|
174
|
+
* @returns {Decimal} Positive if b is left of o→a (counter-clockwise turn),
|
|
175
|
+
* negative if right (clockwise turn),
|
|
176
|
+
* zero if points are collinear
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* // Counter-clockwise turn (left turn)
|
|
180
|
+
* const o = point(0, 0);
|
|
181
|
+
* const a = point(1, 0);
|
|
182
|
+
* const b = point(0, 1);
|
|
183
|
+
* cross(o, a, b); // > 0 (CCW)
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* // Clockwise turn (right turn)
|
|
187
|
+
* const o = point(0, 0);
|
|
188
|
+
* const a = point(0, 1);
|
|
189
|
+
* const b = point(1, 0);
|
|
190
|
+
* cross(o, a, b); // < 0 (CW)
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* // Collinear points
|
|
194
|
+
* const o = point(0, 0);
|
|
195
|
+
* const a = point(1, 1);
|
|
196
|
+
* const b = point(2, 2);
|
|
197
|
+
* cross(o, a, b); // ≈ 0
|
|
198
|
+
*/
|
|
199
|
+
export function cross(o, a, b) {
|
|
200
|
+
const ax = a.x.minus(o.x);
|
|
201
|
+
const ay = a.y.minus(o.y);
|
|
202
|
+
const bx = b.x.minus(o.x);
|
|
203
|
+
const by = b.y.minus(o.y);
|
|
204
|
+
return ax.mul(by).minus(ay.mul(bx));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Compute the dot product of vectors (o→a) and (o→b).
|
|
209
|
+
*
|
|
210
|
+
* The dot product measures how much two vectors point in the same direction.
|
|
211
|
+
* It's used for angle calculations and projections.
|
|
212
|
+
*
|
|
213
|
+
* Algorithm:
|
|
214
|
+
* - Forms vectors v1 = a - o and v2 = b - o
|
|
215
|
+
* - Returns v1.x * v2.x + v1.y * v2.y
|
|
216
|
+
* - This is equivalent to |v1| * |v2| * cos(θ) where θ is the angle between vectors
|
|
217
|
+
*
|
|
218
|
+
* @param {Object} o - Origin point with {x: Decimal, y: Decimal}
|
|
219
|
+
* @param {Object} a - First point with {x: Decimal, y: Decimal}
|
|
220
|
+
* @param {Object} b - Second point with {x: Decimal, y: Decimal}
|
|
221
|
+
* @returns {Decimal} Dot product value
|
|
222
|
+
* Positive if angle < 90°,
|
|
223
|
+
* zero if perpendicular,
|
|
224
|
+
* negative if angle > 90°
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* // Parallel vectors (same direction)
|
|
228
|
+
* const o = point(0, 0);
|
|
229
|
+
* const a = point(1, 0);
|
|
230
|
+
* const b = point(2, 0);
|
|
231
|
+
* dot(o, a, b); // > 0
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* // Perpendicular vectors
|
|
235
|
+
* const o = point(0, 0);
|
|
236
|
+
* const a = point(1, 0);
|
|
237
|
+
* const b = point(0, 1);
|
|
238
|
+
* dot(o, a, b); // = 0
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* // Opposite direction vectors
|
|
242
|
+
* const o = point(0, 0);
|
|
243
|
+
* const a = point(1, 0);
|
|
244
|
+
* const b = point(-1, 0);
|
|
245
|
+
* dot(o, a, b); // < 0
|
|
246
|
+
*/
|
|
247
|
+
export function dot(o, a, b) {
|
|
248
|
+
const ax = a.x.minus(o.x);
|
|
249
|
+
const ay = a.y.minus(o.y);
|
|
250
|
+
const bx = b.x.minus(o.x);
|
|
251
|
+
const by = b.y.minus(o.y);
|
|
252
|
+
return ax.mul(bx).plus(ay.mul(by));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Determine the sign of a value with tolerance.
|
|
257
|
+
*
|
|
258
|
+
* Returns -1, 0, or 1 to indicate negative, near-zero, or positive values.
|
|
259
|
+
* Uses EPSILON threshold to treat very small values as zero, avoiding
|
|
260
|
+
* numerical precision issues in geometric tests.
|
|
261
|
+
*
|
|
262
|
+
* @param {Decimal} val - Value to test
|
|
263
|
+
* @returns {number} -1 if val < -EPSILON,
|
|
264
|
+
* 0 if |val| <= EPSILON,
|
|
265
|
+
* 1 if val > EPSILON
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* sign(new Decimal('-5')); // -1
|
|
269
|
+
* sign(new Decimal('1e-50')); // 0 (within tolerance)
|
|
270
|
+
* sign(new Decimal('5')); // 1
|
|
271
|
+
*/
|
|
272
|
+
export function sign(val) {
|
|
273
|
+
if (val.abs().lt(EPSILON)) return 0;
|
|
274
|
+
return val.lt(0) ? -1 : 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ============================================================================
|
|
278
|
+
// Segment Intersection
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Compute the intersection point of two line segments using parametric form.
|
|
283
|
+
*
|
|
284
|
+
* Algorithm:
|
|
285
|
+
* - Represents segments parametrically:
|
|
286
|
+
* Segment 1: P(t) = a + t(b - a) for t ∈ [0, 1]
|
|
287
|
+
* Segment 2: Q(s) = c + s(d - c) for s ∈ [0, 1]
|
|
288
|
+
* - Solves P(t) = Q(s) for parameters t and s
|
|
289
|
+
* - Uses Cramer's rule with determinant denom = (b-a) × (d-c)
|
|
290
|
+
* - Returns null if segments are parallel (denom ≈ 0)
|
|
291
|
+
* - Returns null if t or s are outside [0, 1] (intersection beyond segment endpoints)
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} a - Start point of first segment with {x: Decimal, y: Decimal}
|
|
294
|
+
* @param {Object} b - End point of first segment with {x: Decimal, y: Decimal}
|
|
295
|
+
* @param {Object} c - Start point of second segment with {x: Decimal, y: Decimal}
|
|
296
|
+
* @param {Object} d - End point of second segment with {x: Decimal, y: Decimal}
|
|
297
|
+
* @returns {Object|null} Intersection point {x: Decimal, y: Decimal, t: Decimal, s: Decimal}
|
|
298
|
+
* where t is parameter on first segment, s on second segment,
|
|
299
|
+
* or null if segments don't intersect
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* // Intersecting segments
|
|
303
|
+
* const a = point(0, 0);
|
|
304
|
+
* const b = point(2, 2);
|
|
305
|
+
* const c = point(0, 2);
|
|
306
|
+
* const d = point(2, 0);
|
|
307
|
+
* const intersection = segmentIntersection(a, b, c, d);
|
|
308
|
+
* // Returns {x: 1, y: 1, t: 0.5, s: 0.5}
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* // Non-intersecting segments
|
|
312
|
+
* const a = point(0, 0);
|
|
313
|
+
* const b = point(1, 0);
|
|
314
|
+
* const c = point(0, 1);
|
|
315
|
+
* const d = point(1, 1);
|
|
316
|
+
* segmentIntersection(a, b, c, d); // null
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* // Parallel segments
|
|
320
|
+
* const a = point(0, 0);
|
|
321
|
+
* const b = point(1, 0);
|
|
322
|
+
* const c = point(0, 1);
|
|
323
|
+
* const d = point(1, 1);
|
|
324
|
+
* segmentIntersection(a, b, c, d); // null
|
|
325
|
+
*/
|
|
326
|
+
export function segmentIntersection(a, b, c, d) {
|
|
327
|
+
// Direction vectors
|
|
328
|
+
const dx1 = b.x.minus(a.x);
|
|
329
|
+
const dy1 = b.y.minus(a.y);
|
|
330
|
+
const dx2 = d.x.minus(c.x);
|
|
331
|
+
const dy2 = d.y.minus(c.y);
|
|
332
|
+
|
|
333
|
+
// Cross product of directions (determinant)
|
|
334
|
+
const denom = dx1.mul(dy2).minus(dy1.mul(dx2));
|
|
335
|
+
|
|
336
|
+
// Check if lines are parallel
|
|
337
|
+
if (denom.abs().lt(EPSILON)) {
|
|
338
|
+
return null; // Parallel or collinear
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Vector from a to c
|
|
342
|
+
const dx3 = c.x.minus(a.x);
|
|
343
|
+
const dy3 = c.y.minus(a.y);
|
|
344
|
+
|
|
345
|
+
// Parametric values
|
|
346
|
+
const t = dx3.mul(dy2).minus(dy3.mul(dx2)).div(denom);
|
|
347
|
+
const s = dx3.mul(dy1).minus(dy3.mul(dx1)).div(denom);
|
|
348
|
+
|
|
349
|
+
// Check if intersection is within both segments [0, 1]
|
|
350
|
+
const zero = D(0);
|
|
351
|
+
const one = D(1);
|
|
352
|
+
|
|
353
|
+
if (t.gte(zero) && t.lte(one) && s.gte(zero) && s.lte(one)) {
|
|
354
|
+
return {
|
|
355
|
+
x: a.x.plus(dx1.mul(t)),
|
|
356
|
+
y: a.y.plus(dy1.mul(t)),
|
|
357
|
+
t: t,
|
|
358
|
+
s: s
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Compute intersection of an infinite line with a finite segment.
|
|
367
|
+
*
|
|
368
|
+
* Unlike segmentIntersection, this treats the first input as an infinite line
|
|
369
|
+
* (extending beyond lineA and lineB) while the second input is a bounded segment.
|
|
370
|
+
* Used primarily in clipping algorithms where edges define infinite half-planes.
|
|
371
|
+
*
|
|
372
|
+
* Algorithm:
|
|
373
|
+
* - Line defined by two points: extends infinitely through lineA and lineB
|
|
374
|
+
* - Segment bounded: only between segA and segB
|
|
375
|
+
* - Computes parameter s ∈ [0, 1] for the segment
|
|
376
|
+
* - Returns intersection if s is valid, null otherwise
|
|
377
|
+
*
|
|
378
|
+
* @param {Object} lineA - First point defining the infinite line
|
|
379
|
+
* @param {Object} lineB - Second point defining the infinite line
|
|
380
|
+
* @param {Object} segA - Start point of the bounded segment
|
|
381
|
+
* @param {Object} segB - End point of the bounded segment
|
|
382
|
+
* @returns {Object|null} Intersection point {x: Decimal, y: Decimal, s: Decimal}
|
|
383
|
+
* where s is the parameter on the segment,
|
|
384
|
+
* or null if no intersection exists
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* // Line intersects segment
|
|
388
|
+
* const lineA = point(0, 0);
|
|
389
|
+
* const lineB = point(1, 1);
|
|
390
|
+
* const segA = point(0, 1);
|
|
391
|
+
* const segB = point(1, 0);
|
|
392
|
+
* lineSegmentIntersection(lineA, lineB, segA, segB);
|
|
393
|
+
* // Returns intersection point
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* // Line parallel to segment
|
|
397
|
+
* const lineA = point(0, 0);
|
|
398
|
+
* const lineB = point(1, 0);
|
|
399
|
+
* const segA = point(0, 1);
|
|
400
|
+
* const segB = point(1, 1);
|
|
401
|
+
* lineSegmentIntersection(lineA, lineB, segA, segB); // null
|
|
402
|
+
*/
|
|
403
|
+
export function lineSegmentIntersection(lineA, lineB, segA, segB) {
|
|
404
|
+
const dx1 = lineB.x.minus(lineA.x);
|
|
405
|
+
const dy1 = lineB.y.minus(lineA.y);
|
|
406
|
+
const dx2 = segB.x.minus(segA.x);
|
|
407
|
+
const dy2 = segB.y.minus(segA.y);
|
|
408
|
+
|
|
409
|
+
const denom = dx1.mul(dy2).minus(dy1.mul(dx2));
|
|
410
|
+
|
|
411
|
+
if (denom.abs().lt(EPSILON)) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const dx3 = segA.x.minus(lineA.x);
|
|
416
|
+
const dy3 = segA.y.minus(lineA.y);
|
|
417
|
+
|
|
418
|
+
const s = dx3.mul(dy1).minus(dy3.mul(dx1)).div(denom);
|
|
419
|
+
|
|
420
|
+
if (s.gte(0) && s.lte(1)) {
|
|
421
|
+
return {
|
|
422
|
+
x: segA.x.plus(dx2.mul(s)),
|
|
423
|
+
y: segA.y.plus(dy2.mul(s)),
|
|
424
|
+
s: s
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ============================================================================
|
|
432
|
+
// Point in Polygon (Ray Casting)
|
|
433
|
+
// ============================================================================
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Test if a point is inside a polygon using the winding number algorithm.
|
|
437
|
+
*
|
|
438
|
+
* This implementation uses a modified ray casting approach that computes the
|
|
439
|
+
* winding number - the number of times the polygon winds around the point.
|
|
440
|
+
* For simple (non-self-intersecting) polygons:
|
|
441
|
+
* - winding = 0 means outside
|
|
442
|
+
* - winding ≠ 0 means inside
|
|
443
|
+
*
|
|
444
|
+
* Algorithm (Winding Number):
|
|
445
|
+
* 1. Cast a horizontal ray from the test point to the right (+x direction)
|
|
446
|
+
* 2. Count upward edge crossings as +1
|
|
447
|
+
* 3. Count downward edge crossings as -1
|
|
448
|
+
* 4. Use cross product to determine if crossing is left of the point
|
|
449
|
+
* 5. Return winding number (non-zero = inside, zero = outside)
|
|
450
|
+
*
|
|
451
|
+
* This is more robust than even-odd ray casting for complex polygons.
|
|
452
|
+
*
|
|
453
|
+
* @param {Object} pt - Point to test with {x: Decimal, y: Decimal}
|
|
454
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
455
|
+
* @returns {number} 1 if point is strictly inside,
|
|
456
|
+
* 0 if point is on the boundary,
|
|
457
|
+
* -1 if point is strictly outside
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* // Point inside square
|
|
461
|
+
* const square = [point(0,0), point(2,0), point(2,2), point(0,2)];
|
|
462
|
+
* pointInPolygon(point(1, 1), square); // 1 (inside)
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* // Point outside square
|
|
466
|
+
* pointInPolygon(point(3, 3), square); // -1 (outside)
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* // Point on edge
|
|
470
|
+
* pointInPolygon(point(1, 0), square); // 0 (on boundary)
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* // Concave polygon
|
|
474
|
+
* const concave = [point(0,0), point(4,0), point(4,4), point(2,2), point(0,4)];
|
|
475
|
+
* pointInPolygon(point(3, 2), concave); // 1 (inside concave region)
|
|
476
|
+
*/
|
|
477
|
+
export function pointInPolygon(pt, polygon) {
|
|
478
|
+
const n = polygon.length;
|
|
479
|
+
if (n < 3) return -1;
|
|
480
|
+
|
|
481
|
+
let winding = 0;
|
|
482
|
+
|
|
483
|
+
for (let i = 0; i < n; i++) {
|
|
484
|
+
const p1 = polygon[i];
|
|
485
|
+
const p2 = polygon[(i + 1) % n];
|
|
486
|
+
|
|
487
|
+
// Check if point is on the edge
|
|
488
|
+
if (pointOnSegment(pt, p1, p2)) {
|
|
489
|
+
return 0; // On boundary
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Ray casting from pt going right (+x direction)
|
|
493
|
+
if (p1.y.lte(pt.y)) {
|
|
494
|
+
if (p2.y.gt(pt.y)) {
|
|
495
|
+
// Upward crossing
|
|
496
|
+
if (cross(p1, p2, pt).gt(0)) {
|
|
497
|
+
winding++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
if (p2.y.lte(pt.y)) {
|
|
502
|
+
// Downward crossing
|
|
503
|
+
if (cross(p1, p2, pt).lt(0)) {
|
|
504
|
+
winding--;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return winding !== 0 ? 1 : -1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Check if a point lies on a line segment.
|
|
515
|
+
*
|
|
516
|
+
* Uses two tests:
|
|
517
|
+
* 1. Collinearity: cross product must be near zero
|
|
518
|
+
* 2. Bounding box: point must be within segment's axis-aligned bounding box
|
|
519
|
+
*
|
|
520
|
+
* Algorithm:
|
|
521
|
+
* - Compute cross product to test collinearity
|
|
522
|
+
* - If collinear, check if point is between segment endpoints
|
|
523
|
+
* - Uses bounding box test for efficiency
|
|
524
|
+
*
|
|
525
|
+
* @param {Object} pt - Point to test with {x: Decimal, y: Decimal}
|
|
526
|
+
* @param {Object} a - Segment start point with {x: Decimal, y: Decimal}
|
|
527
|
+
* @param {Object} b - Segment end point with {x: Decimal, y: Decimal}
|
|
528
|
+
* @returns {boolean} True if point lies on the segment (within EPSILON tolerance)
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* // Point on segment
|
|
532
|
+
* const a = point(0, 0);
|
|
533
|
+
* const b = point(2, 2);
|
|
534
|
+
* const pt = point(1, 1);
|
|
535
|
+
* pointOnSegment(pt, a, b); // true
|
|
536
|
+
*
|
|
537
|
+
* @example
|
|
538
|
+
* // Point on line but not segment
|
|
539
|
+
* const pt2 = point(3, 3);
|
|
540
|
+
* pointOnSegment(pt2, a, b); // false (beyond endpoint)
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* // Point not on line
|
|
544
|
+
* const pt3 = point(1, 2);
|
|
545
|
+
* pointOnSegment(pt3, a, b); // false (not collinear)
|
|
546
|
+
*/
|
|
547
|
+
export function pointOnSegment(pt, a, b) {
|
|
548
|
+
// Check collinearity
|
|
549
|
+
const crossVal = cross(a, b, pt);
|
|
550
|
+
if (crossVal.abs().gt(EPSILON)) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Check if pt is between a and b
|
|
555
|
+
const minX = Decimal.min(a.x, b.x);
|
|
556
|
+
const maxX = Decimal.max(a.x, b.x);
|
|
557
|
+
const minY = Decimal.min(a.y, b.y);
|
|
558
|
+
const maxY = Decimal.max(a.y, b.y);
|
|
559
|
+
|
|
560
|
+
return pt.x.gte(minX.minus(EPSILON)) && pt.x.lte(maxX.plus(EPSILON)) &&
|
|
561
|
+
pt.y.gte(minY.minus(EPSILON)) && pt.y.lte(maxY.plus(EPSILON));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ============================================================================
|
|
565
|
+
// Sutherland-Hodgman Algorithm (Convex Polygon Clipping)
|
|
566
|
+
// ============================================================================
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Clip a polygon against a convex clipping polygon using the Sutherland-Hodgman algorithm.
|
|
570
|
+
*
|
|
571
|
+
* The Sutherland-Hodgman algorithm is a classic polygon clipping technique that works
|
|
572
|
+
* efficiently for convex clipping windows. It processes the subject polygon against
|
|
573
|
+
* each edge of the clipping polygon sequentially.
|
|
574
|
+
*
|
|
575
|
+
* Algorithm (Sutherland-Hodgman):
|
|
576
|
+
* 1. Initialize output with subject polygon vertices
|
|
577
|
+
* 2. For each edge of the clipping polygon:
|
|
578
|
+
* a. Create a temporary input list from current output
|
|
579
|
+
* b. Clear output list
|
|
580
|
+
* c. For each edge in the input polygon:
|
|
581
|
+
* - If current vertex is inside: output it (and intersection if entering)
|
|
582
|
+
* - If current vertex is outside but previous was inside: output intersection
|
|
583
|
+
* d. Replace output with new list
|
|
584
|
+
* 3. Return final output polygon
|
|
585
|
+
*
|
|
586
|
+
* "Inside" is defined as being on the left side (or on) the directed clipping edge
|
|
587
|
+
* for counter-clockwise oriented polygons.
|
|
588
|
+
*
|
|
589
|
+
* Limitations:
|
|
590
|
+
* - Clipping polygon MUST be convex
|
|
591
|
+
* - Clipping polygon vertices MUST be in counter-clockwise order
|
|
592
|
+
* - Does not handle holes or self-intersecting polygons
|
|
593
|
+
*
|
|
594
|
+
* @param {Array} subject - Subject polygon vertices [{x, y}, ...] (can be convex or concave)
|
|
595
|
+
* @param {Array} clip - Clipping polygon vertices (MUST be convex, CCW order)
|
|
596
|
+
* @returns {Array} Clipped polygon vertices in CCW order, or empty array if no intersection
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* // Clip a square against a triangle
|
|
600
|
+
* const subject = [point(0,0), point(4,0), point(4,4), point(0,4)];
|
|
601
|
+
* const clip = [point(1,1), point(5,1), point(1,5)];
|
|
602
|
+
* const result = clipPolygonSH(subject, clip);
|
|
603
|
+
* // Returns clipped polygon vertices
|
|
604
|
+
*
|
|
605
|
+
* @example
|
|
606
|
+
* // No intersection case
|
|
607
|
+
* const subject = [point(0,0), point(1,0), point(1,1), point(0,1)];
|
|
608
|
+
* const clip = [point(10,10), point(11,10), point(11,11), point(10,11)];
|
|
609
|
+
* clipPolygonSH(subject, clip); // []
|
|
610
|
+
*/
|
|
611
|
+
export function clipPolygonSH(subject, clip) {
|
|
612
|
+
if (subject.length < 3 || clip.length < 3) {
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Convert all points to Decimal
|
|
617
|
+
let output = subject.map(p => point(p.x, p.y));
|
|
618
|
+
const clipPoly = clip.map(p => point(p.x, p.y));
|
|
619
|
+
|
|
620
|
+
// Clip against each edge of the clipping polygon
|
|
621
|
+
for (let i = 0; i < clipPoly.length; i++) {
|
|
622
|
+
if (output.length === 0) {
|
|
623
|
+
return [];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const clipEdgeStart = clipPoly[i];
|
|
627
|
+
const clipEdgeEnd = clipPoly[(i + 1) % clipPoly.length];
|
|
628
|
+
|
|
629
|
+
const input = output;
|
|
630
|
+
output = [];
|
|
631
|
+
|
|
632
|
+
for (let j = 0; j < input.length; j++) {
|
|
633
|
+
const current = input[j];
|
|
634
|
+
const prev = input[(j + input.length - 1) % input.length];
|
|
635
|
+
|
|
636
|
+
const currentInside = isInsideEdge(current, clipEdgeStart, clipEdgeEnd);
|
|
637
|
+
const prevInside = isInsideEdge(prev, clipEdgeStart, clipEdgeEnd);
|
|
638
|
+
|
|
639
|
+
if (currentInside) {
|
|
640
|
+
if (!prevInside) {
|
|
641
|
+
// Entering: add intersection point
|
|
642
|
+
const intersection = lineIntersection(prev, current, clipEdgeStart, clipEdgeEnd);
|
|
643
|
+
if (intersection) {
|
|
644
|
+
output.push(intersection);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
output.push(current);
|
|
648
|
+
} else if (prevInside) {
|
|
649
|
+
// Leaving: add intersection point
|
|
650
|
+
const intersection = lineIntersection(prev, current, clipEdgeStart, clipEdgeEnd);
|
|
651
|
+
if (intersection) {
|
|
652
|
+
output.push(intersection);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return output;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Check if a point is inside (left of) a directed edge.
|
|
663
|
+
*
|
|
664
|
+
* Used by Sutherland-Hodgman algorithm. For counter-clockwise oriented
|
|
665
|
+
* polygons, "inside" means on the left side of the directed edge.
|
|
666
|
+
*
|
|
667
|
+
* @private
|
|
668
|
+
* @param {Object} pt - Point to test
|
|
669
|
+
* @param {Object} edgeStart - Edge start point
|
|
670
|
+
* @param {Object} edgeEnd - Edge end point
|
|
671
|
+
* @returns {boolean} True if point is on left side or on the edge
|
|
672
|
+
*/
|
|
673
|
+
function isInsideEdge(pt, edgeStart, edgeEnd) {
|
|
674
|
+
// Point is "inside" if it's on the left side of the edge (CCW polygon)
|
|
675
|
+
return cross(edgeStart, edgeEnd, pt).gte(0);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Compute intersection of two infinite lines (not segments).
|
|
680
|
+
*
|
|
681
|
+
* Unlike segment intersection, this doesn't check parameter bounds.
|
|
682
|
+
* Used by Sutherland-Hodgman when clipping against infinite half-planes.
|
|
683
|
+
*
|
|
684
|
+
* @private
|
|
685
|
+
* @param {Object} a - First point on first line
|
|
686
|
+
* @param {Object} b - Second point on first line
|
|
687
|
+
* @param {Object} c - First point on second line
|
|
688
|
+
* @param {Object} d - Second point on second line
|
|
689
|
+
* @returns {Object|null} Intersection point {x, y} or null if parallel
|
|
690
|
+
*/
|
|
691
|
+
function lineIntersection(a, b, c, d) {
|
|
692
|
+
const dx1 = b.x.minus(a.x);
|
|
693
|
+
const dy1 = b.y.minus(a.y);
|
|
694
|
+
const dx2 = d.x.minus(c.x);
|
|
695
|
+
const dy2 = d.y.minus(c.y);
|
|
696
|
+
|
|
697
|
+
const denom = dx1.mul(dy2).minus(dy1.mul(dx2));
|
|
698
|
+
|
|
699
|
+
if (denom.abs().lt(EPSILON)) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const dx3 = c.x.minus(a.x);
|
|
704
|
+
const dy3 = c.y.minus(a.y);
|
|
705
|
+
|
|
706
|
+
const t = dx3.mul(dy2).minus(dy3.mul(dx2)).div(denom);
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
x: a.x.plus(dx1.mul(t)),
|
|
710
|
+
y: a.y.plus(dy1.mul(t))
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ============================================================================
|
|
715
|
+
// Polygon Area and Orientation
|
|
716
|
+
// ============================================================================
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Compute the signed area of a polygon using the Shoelace formula.
|
|
720
|
+
*
|
|
721
|
+
* The Shoelace formula (also known as surveyor's formula or Gauss's area formula)
|
|
722
|
+
* efficiently computes polygon area using only vertex coordinates. The sign of
|
|
723
|
+
* the area indicates polygon orientation.
|
|
724
|
+
*
|
|
725
|
+
* Algorithm (Shoelace Formula):
|
|
726
|
+
* 1. For each edge (i, i+1), compute: x[i] * y[i+1] - x[i+1] * y[i]
|
|
727
|
+
* 2. Sum all edge contributions
|
|
728
|
+
* 3. Divide by 2 to get signed area
|
|
729
|
+
*
|
|
730
|
+
* The formula comes from Green's theorem applied to the region enclosed by the polygon.
|
|
731
|
+
*
|
|
732
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
733
|
+
* @returns {Decimal} Signed area of the polygon
|
|
734
|
+
* Positive if vertices are in counter-clockwise order
|
|
735
|
+
* Negative if vertices are in clockwise order
|
|
736
|
+
* Zero if polygon is degenerate (< 3 vertices or collinear)
|
|
737
|
+
*
|
|
738
|
+
* @example
|
|
739
|
+
* // Counter-clockwise square (area = 4)
|
|
740
|
+
* const square = [point(0,0), point(2,0), point(2,2), point(0,2)];
|
|
741
|
+
* polygonArea(square); // 4
|
|
742
|
+
*
|
|
743
|
+
* @example
|
|
744
|
+
* // Clockwise square (area = -4)
|
|
745
|
+
* const squareCW = [point(0,0), point(0,2), point(2,2), point(2,0)];
|
|
746
|
+
* polygonArea(squareCW); // -4
|
|
747
|
+
*
|
|
748
|
+
* @example
|
|
749
|
+
* // Triangle
|
|
750
|
+
* const triangle = [point(0,0), point(3,0), point(0,4)];
|
|
751
|
+
* polygonArea(triangle); // 6
|
|
752
|
+
*/
|
|
753
|
+
export function polygonArea(polygon) {
|
|
754
|
+
const n = polygon.length;
|
|
755
|
+
if (n < 3) return D(0);
|
|
756
|
+
|
|
757
|
+
let area = D(0);
|
|
758
|
+
for (let i = 0; i < n; i++) {
|
|
759
|
+
const p1 = polygon[i];
|
|
760
|
+
const p2 = polygon[(i + 1) % n];
|
|
761
|
+
area = area.plus(p1.x.mul(p2.y).minus(p2.x.mul(p1.y)));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return area.div(2);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Check if polygon vertices are in counter-clockwise order.
|
|
769
|
+
*
|
|
770
|
+
* Uses the signed area to determine orientation. A positive area indicates
|
|
771
|
+
* counter-clockwise (CCW) orientation, which is the standard for many
|
|
772
|
+
* geometric algorithms.
|
|
773
|
+
*
|
|
774
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
775
|
+
* @returns {boolean} True if vertices are in counter-clockwise order
|
|
776
|
+
*
|
|
777
|
+
* @example
|
|
778
|
+
* const ccw = [point(0,0), point(1,0), point(0,1)];
|
|
779
|
+
* isCounterClockwise(ccw); // true
|
|
780
|
+
*
|
|
781
|
+
* @example
|
|
782
|
+
* const cw = [point(0,0), point(0,1), point(1,0)];
|
|
783
|
+
* isCounterClockwise(cw); // false
|
|
784
|
+
*/
|
|
785
|
+
export function isCounterClockwise(polygon) {
|
|
786
|
+
return polygonArea(polygon).gt(0);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Reverse the order of polygon vertices.
|
|
791
|
+
*
|
|
792
|
+
* This effectively flips the orientation from CCW to CW or vice versa.
|
|
793
|
+
* Useful for ensuring consistent winding order in boolean operations.
|
|
794
|
+
*
|
|
795
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
796
|
+
* @returns {Array} New array with vertices in reverse order
|
|
797
|
+
*
|
|
798
|
+
* @example
|
|
799
|
+
* const poly = [point(0,0), point(1,0), point(1,1)];
|
|
800
|
+
* const reversed = reversePolygon(poly);
|
|
801
|
+
* // reversed = [point(1,1), point(1,0), point(0,0)]
|
|
802
|
+
*/
|
|
803
|
+
export function reversePolygon(polygon) {
|
|
804
|
+
return [...polygon].reverse();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Ensure polygon vertices are in counter-clockwise order.
|
|
809
|
+
*
|
|
810
|
+
* Many geometric algorithms require CCW orientation. This function
|
|
811
|
+
* checks the current orientation and reverses if necessary.
|
|
812
|
+
*
|
|
813
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
814
|
+
* @returns {Array} Polygon with vertices in CCW order (may be a new array or the original)
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
* const cw = [point(0,0), point(0,1), point(1,0)];
|
|
818
|
+
* const ccw = ensureCCW(cw);
|
|
819
|
+
* isCounterClockwise(ccw); // true
|
|
820
|
+
*/
|
|
821
|
+
export function ensureCCW(polygon) {
|
|
822
|
+
if (!isCounterClockwise(polygon)) {
|
|
823
|
+
return reversePolygon(polygon);
|
|
824
|
+
}
|
|
825
|
+
return polygon;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ============================================================================
|
|
829
|
+
// Convex Hull (Graham Scan)
|
|
830
|
+
// ============================================================================
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Compute the convex hull of a set of points using the Graham scan algorithm.
|
|
834
|
+
*
|
|
835
|
+
* The Graham scan is an efficient O(n log n) algorithm for computing the convex hull
|
|
836
|
+
* of a point set. The convex hull is the smallest convex polygon containing all points.
|
|
837
|
+
*
|
|
838
|
+
* Algorithm (Graham Scan):
|
|
839
|
+
* 1. Find the lowest point (leftmost if tie) - this is the pivot
|
|
840
|
+
* 2. Sort all other points by polar angle with respect to the pivot
|
|
841
|
+
* 3. For ties in angle, sort by distance (closer points first)
|
|
842
|
+
* 4. Initialize stack with pivot point
|
|
843
|
+
* 5. For each sorted point:
|
|
844
|
+
* a. While stack has 2+ points and last 3 points make a right turn (CW):
|
|
845
|
+
* - Pop from stack (this point is interior, not on hull)
|
|
846
|
+
* b. Push current point onto stack
|
|
847
|
+
* 6. Stack contains convex hull vertices in CCW order
|
|
848
|
+
*
|
|
849
|
+
* The cross product test determines turns:
|
|
850
|
+
* - cross > 0: left turn (CCW) - keep the point
|
|
851
|
+
* - cross ≤ 0: right turn (CW) or collinear - remove previous point
|
|
852
|
+
*
|
|
853
|
+
* @param {Array} points - Array of input points [{x, y}, ...]
|
|
854
|
+
* @returns {Array} Convex hull vertices in counter-clockwise order
|
|
855
|
+
*
|
|
856
|
+
* @example
|
|
857
|
+
* // Square with interior point
|
|
858
|
+
* const points = [
|
|
859
|
+
* point(0,0), point(2,0), point(2,2), point(0,2),
|
|
860
|
+
* point(1,1) // interior point
|
|
861
|
+
* ];
|
|
862
|
+
* const hull = convexHull(points);
|
|
863
|
+
* // Returns [point(0,0), point(2,0), point(2,2), point(0,2)]
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* // Collinear points
|
|
867
|
+
* const collinear = [point(0,0), point(1,1), point(2,2), point(3,3)];
|
|
868
|
+
* const hull = convexHull(collinear);
|
|
869
|
+
* // Returns [point(0,0), point(3,3)] (or similar minimal hull)
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* // Random point cloud
|
|
873
|
+
* const cloud = [point(1,1), point(3,2), point(0,0), point(2,4), point(4,0)];
|
|
874
|
+
* const hull = convexHull(cloud);
|
|
875
|
+
* // Returns vertices of convex boundary in CCW order
|
|
876
|
+
*/
|
|
877
|
+
export function convexHull(points) {
|
|
878
|
+
if (points.length < 3) {
|
|
879
|
+
return points.map(p => point(p.x, p.y));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Convert to Decimal points
|
|
883
|
+
const pts = points.map(p => point(p.x, p.y));
|
|
884
|
+
|
|
885
|
+
// Find the lowest point (and leftmost if tie)
|
|
886
|
+
let lowest = 0;
|
|
887
|
+
for (let i = 1; i < pts.length; i++) {
|
|
888
|
+
if (pts[i].y.lt(pts[lowest].y) ||
|
|
889
|
+
(pts[i].y.eq(pts[lowest].y) && pts[i].x.lt(pts[lowest].x))) {
|
|
890
|
+
lowest = i;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Swap lowest to front
|
|
895
|
+
[pts[0], pts[lowest]] = [pts[lowest], pts[0]];
|
|
896
|
+
const pivot = pts[0];
|
|
897
|
+
|
|
898
|
+
// Sort by polar angle with pivot
|
|
899
|
+
const sorted = pts.slice(1).sort((a, b) => {
|
|
900
|
+
const crossVal = cross(pivot, a, b);
|
|
901
|
+
if (crossVal.abs().lt(EPSILON)) {
|
|
902
|
+
// Collinear: sort by distance
|
|
903
|
+
const distA = a.x.minus(pivot.x).pow(2).plus(a.y.minus(pivot.y).pow(2));
|
|
904
|
+
const distB = b.x.minus(pivot.x).pow(2).plus(b.y.minus(pivot.y).pow(2));
|
|
905
|
+
return distA.minus(distB).toNumber();
|
|
906
|
+
}
|
|
907
|
+
return -crossVal.toNumber(); // CCW order
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// Build hull
|
|
911
|
+
const hull = [pivot];
|
|
912
|
+
|
|
913
|
+
for (const pt of sorted) {
|
|
914
|
+
while (hull.length > 1 && cross(hull[hull.length - 2], hull[hull.length - 1], pt).lte(0)) {
|
|
915
|
+
hull.pop();
|
|
916
|
+
}
|
|
917
|
+
hull.push(pt);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return hull;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ============================================================================
|
|
924
|
+
// Bounding Box Operations
|
|
925
|
+
// ============================================================================
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Compute the axis-aligned bounding box (AABB) of a polygon.
|
|
929
|
+
*
|
|
930
|
+
* The bounding box is the smallest axis-aligned rectangle that contains
|
|
931
|
+
* all vertices of the polygon. Used for quick intersection tests and
|
|
932
|
+
* spatial queries.
|
|
933
|
+
*
|
|
934
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
935
|
+
* @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}|null}
|
|
936
|
+
* Bounding box with min/max coordinates, or null if polygon is empty
|
|
937
|
+
*
|
|
938
|
+
* @example
|
|
939
|
+
* const triangle = [point(1,1), point(4,2), point(2,5)];
|
|
940
|
+
* const bbox = boundingBox(triangle);
|
|
941
|
+
* // {minX: 1, minY: 1, maxX: 4, maxY: 5}
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* const empty = [];
|
|
945
|
+
* boundingBox(empty); // null
|
|
946
|
+
*/
|
|
947
|
+
export function boundingBox(polygon) {
|
|
948
|
+
if (polygon.length === 0) {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let minX = D(polygon[0].x);
|
|
953
|
+
let minY = D(polygon[0].y);
|
|
954
|
+
let maxX = D(polygon[0].x);
|
|
955
|
+
let maxY = D(polygon[0].y);
|
|
956
|
+
|
|
957
|
+
for (const p of polygon) {
|
|
958
|
+
const x = D(p.x);
|
|
959
|
+
const y = D(p.y);
|
|
960
|
+
if (x.lt(minX)) minX = x;
|
|
961
|
+
if (y.lt(minY)) minY = y;
|
|
962
|
+
if (x.gt(maxX)) maxX = x;
|
|
963
|
+
if (y.gt(maxY)) maxY = y;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return { minX, minY, maxX, maxY };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Check if two axis-aligned bounding boxes intersect.
|
|
971
|
+
*
|
|
972
|
+
* Two AABBs intersect if they overlap in both x and y dimensions.
|
|
973
|
+
* This is a fast O(1) test used to quickly reject non-intersecting polygons
|
|
974
|
+
* before performing more expensive exact intersection tests.
|
|
975
|
+
*
|
|
976
|
+
* Algorithm:
|
|
977
|
+
* - Boxes DO NOT intersect if:
|
|
978
|
+
* - bb1's right edge is left of bb2's left edge, OR
|
|
979
|
+
* - bb2's right edge is left of bb1's left edge, OR
|
|
980
|
+
* - bb1's top edge is below bb2's bottom edge, OR
|
|
981
|
+
* - bb2's top edge is below bb1's bottom edge
|
|
982
|
+
* - Otherwise they intersect (or touch)
|
|
983
|
+
*
|
|
984
|
+
* @param {Object} bb1 - First bounding box {minX, minY, maxX, maxY}
|
|
985
|
+
* @param {Object} bb2 - Second bounding box {minX, minY, maxX, maxY}
|
|
986
|
+
* @returns {boolean} True if bounding boxes overlap or touch
|
|
987
|
+
*
|
|
988
|
+
* @example
|
|
989
|
+
* const bb1 = {minX: D(0), minY: D(0), maxX: D(2), maxY: D(2)};
|
|
990
|
+
* const bb2 = {minX: D(1), minY: D(1), maxX: D(3), maxY: D(3)};
|
|
991
|
+
* bboxIntersects(bb1, bb2); // true (overlapping)
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* const bb3 = {minX: D(5), minY: D(5), maxX: D(7), maxY: D(7)};
|
|
995
|
+
* bboxIntersects(bb1, bb3); // false (separate)
|
|
996
|
+
*/
|
|
997
|
+
export function bboxIntersects(bb1, bb2) {
|
|
998
|
+
if (!bb1 || !bb2) return false;
|
|
999
|
+
return !(bb1.maxX.lt(bb2.minX) || bb2.maxX.lt(bb1.minX) ||
|
|
1000
|
+
bb1.maxY.lt(bb2.minY) || bb2.maxY.lt(bb1.minY));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ============================================================================
|
|
1004
|
+
// General Polygon Intersection (Weiler-Atherton based)
|
|
1005
|
+
// ============================================================================
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Compute the intersection of two simple polygons.
|
|
1009
|
+
*
|
|
1010
|
+
* Returns the region(s) where both polygons overlap. This implementation
|
|
1011
|
+
* handles both convex and concave polygons, though complex cases may produce
|
|
1012
|
+
* simplified results (convex hull of intersection points).
|
|
1013
|
+
*
|
|
1014
|
+
* Algorithm:
|
|
1015
|
+
* 1. Quick rejection: check if bounding boxes intersect
|
|
1016
|
+
* 2. If clip polygon is convex: use efficient Sutherland-Hodgman algorithm
|
|
1017
|
+
* 3. Otherwise: use general point collection method:
|
|
1018
|
+
* a. Find all edge-edge intersection points
|
|
1019
|
+
* b. Find subject vertices inside clip polygon
|
|
1020
|
+
* c. Find clip vertices inside subject polygon
|
|
1021
|
+
* d. Compute convex hull of all collected points
|
|
1022
|
+
*
|
|
1023
|
+
* Note: For complex concave polygons with multiple intersection regions,
|
|
1024
|
+
* this returns a simplified convex hull. Full Weiler-Atherton would be
|
|
1025
|
+
* needed for exact results with holes and multiple components.
|
|
1026
|
+
*
|
|
1027
|
+
* @param {Array} subject - Subject polygon vertices [{x, y}, ...]
|
|
1028
|
+
* @param {Array} clip - Clipping polygon vertices [{x, y}, ...]
|
|
1029
|
+
* @returns {Array<Array>} Array of result polygons (usually one polygon,
|
|
1030
|
+
* or empty array if no intersection)
|
|
1031
|
+
*
|
|
1032
|
+
* @example
|
|
1033
|
+
* // Two overlapping squares
|
|
1034
|
+
* const square1 = [point(0,0), point(2,0), point(2,2), point(0,2)];
|
|
1035
|
+
* const square2 = [point(1,1), point(3,1), point(3,3), point(1,3)];
|
|
1036
|
+
* const result = polygonIntersection(square1, square2);
|
|
1037
|
+
* // Returns intersection region: [point(1,1), point(2,1), point(2,2), point(1,2)]
|
|
1038
|
+
*
|
|
1039
|
+
* @example
|
|
1040
|
+
* // No intersection
|
|
1041
|
+
* const square1 = [point(0,0), point(1,0), point(1,1), point(0,1)];
|
|
1042
|
+
* const square2 = [point(5,5), point(6,5), point(6,6), point(5,6)];
|
|
1043
|
+
* polygonIntersection(square1, square2); // []
|
|
1044
|
+
*/
|
|
1045
|
+
export function polygonIntersection(subject, clip) {
|
|
1046
|
+
// Convert to Decimal points
|
|
1047
|
+
const subjectPoly = subject.map(p => point(p.x, p.y));
|
|
1048
|
+
const clipPoly = clip.map(p => point(p.x, p.y));
|
|
1049
|
+
|
|
1050
|
+
// Quick bounding box check
|
|
1051
|
+
const bb1 = boundingBox(subjectPoly);
|
|
1052
|
+
const bb2 = boundingBox(clipPoly);
|
|
1053
|
+
|
|
1054
|
+
if (!bboxIntersects(bb1, bb2)) {
|
|
1055
|
+
return [];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// For simple cases, use Sutherland-Hodgman if clip is convex
|
|
1059
|
+
if (isConvex(clipPoly)) {
|
|
1060
|
+
const result = clipPolygonSH(subjectPoly, ensureCCW(clipPoly));
|
|
1061
|
+
return result.length >= 3 ? [result] : [];
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// For general case, use point collection approach
|
|
1065
|
+
return generalPolygonIntersection(subjectPoly, clipPoly);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Check if a polygon is convex.
|
|
1070
|
+
*
|
|
1071
|
+
* A polygon is convex if all interior angles are less than 180°, or equivalently,
|
|
1072
|
+
* if all turns along the boundary are in the same direction (all left or all right).
|
|
1073
|
+
*
|
|
1074
|
+
* Algorithm:
|
|
1075
|
+
* 1. For each triple of consecutive vertices (p0, p1, p2):
|
|
1076
|
+
* - Compute cross product at p1
|
|
1077
|
+
* - Determine turn direction (left/right)
|
|
1078
|
+
* 2. If all non-zero turns are in the same direction: convex
|
|
1079
|
+
* 3. If turns change direction: concave
|
|
1080
|
+
*
|
|
1081
|
+
* @param {Array} polygon - Array of polygon vertices [{x, y}, ...]
|
|
1082
|
+
* @returns {boolean} True if polygon is convex, false if concave or degenerate
|
|
1083
|
+
*
|
|
1084
|
+
* @example
|
|
1085
|
+
* // Convex square
|
|
1086
|
+
* const square = [point(0,0), point(1,0), point(1,1), point(0,1)];
|
|
1087
|
+
* isConvex(square); // true
|
|
1088
|
+
*
|
|
1089
|
+
* @example
|
|
1090
|
+
* // Concave polygon (L-shape)
|
|
1091
|
+
* const lshape = [point(0,0), point(2,0), point(2,1), point(1,1), point(1,2), point(0,2)];
|
|
1092
|
+
* isConvex(lshape); // false
|
|
1093
|
+
*
|
|
1094
|
+
* @example
|
|
1095
|
+
* // Triangle (always convex)
|
|
1096
|
+
* const triangle = [point(0,0), point(2,0), point(1,2)];
|
|
1097
|
+
* isConvex(triangle); // true
|
|
1098
|
+
*/
|
|
1099
|
+
export function isConvex(polygon) {
|
|
1100
|
+
const n = polygon.length;
|
|
1101
|
+
if (n < 3) return false;
|
|
1102
|
+
|
|
1103
|
+
let sign = 0;
|
|
1104
|
+
|
|
1105
|
+
for (let i = 0; i < n; i++) {
|
|
1106
|
+
const p0 = polygon[i];
|
|
1107
|
+
const p1 = polygon[(i + 1) % n];
|
|
1108
|
+
const p2 = polygon[(i + 2) % n];
|
|
1109
|
+
|
|
1110
|
+
const crossVal = cross(p0, p1, p2);
|
|
1111
|
+
const currentSign = crossVal.gt(0) ? 1 : (crossVal.lt(0) ? -1 : 0);
|
|
1112
|
+
|
|
1113
|
+
if (currentSign !== 0) {
|
|
1114
|
+
if (sign === 0) {
|
|
1115
|
+
sign = currentSign;
|
|
1116
|
+
} else if (sign !== currentSign) {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return true;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* General polygon intersection using point collection method.
|
|
1127
|
+
*
|
|
1128
|
+
* A simplified approach for general (possibly concave) polygon intersection.
|
|
1129
|
+
* Collects all relevant points and computes their convex hull.
|
|
1130
|
+
*
|
|
1131
|
+
* Algorithm:
|
|
1132
|
+
* 1. Find all edge-edge intersection points
|
|
1133
|
+
* 2. Find subject vertices inside or on clip polygon
|
|
1134
|
+
* 3. Find clip vertices inside or on subject polygon
|
|
1135
|
+
* 4. Remove duplicate points
|
|
1136
|
+
* 5. Compute convex hull of all collected points
|
|
1137
|
+
*
|
|
1138
|
+
* Note: Returns convex hull approximation. Not exact for concave results.
|
|
1139
|
+
*
|
|
1140
|
+
* @private
|
|
1141
|
+
* @param {Array} subject - Subject polygon vertices
|
|
1142
|
+
* @param {Array} clip - Clipping polygon vertices
|
|
1143
|
+
* @returns {Array} Single result polygon or empty array
|
|
1144
|
+
*/
|
|
1145
|
+
function generalPolygonIntersection(subject, clip) {
|
|
1146
|
+
const intersectionPoints = [];
|
|
1147
|
+
|
|
1148
|
+
// Find all edge intersection points
|
|
1149
|
+
for (let i = 0; i < subject.length; i++) {
|
|
1150
|
+
const s1 = subject[i];
|
|
1151
|
+
const s2 = subject[(i + 1) % subject.length];
|
|
1152
|
+
|
|
1153
|
+
for (let j = 0; j < clip.length; j++) {
|
|
1154
|
+
const c1 = clip[j];
|
|
1155
|
+
const c2 = clip[(j + 1) % clip.length];
|
|
1156
|
+
|
|
1157
|
+
const intersection = segmentIntersection(s1, s2, c1, c2);
|
|
1158
|
+
if (intersection) {
|
|
1159
|
+
intersectionPoints.push(point(intersection.x, intersection.y));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Find subject vertices inside clip
|
|
1165
|
+
const subjectInside = subject.filter(p => pointInPolygon(p, clip) >= 0);
|
|
1166
|
+
|
|
1167
|
+
// Find clip vertices inside subject
|
|
1168
|
+
const clipInside = clip.filter(p => pointInPolygon(p, subject) >= 0);
|
|
1169
|
+
|
|
1170
|
+
// Collect all points
|
|
1171
|
+
const allPoints = [...intersectionPoints, ...subjectInside, ...clipInside];
|
|
1172
|
+
|
|
1173
|
+
if (allPoints.length < 3) {
|
|
1174
|
+
return [];
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Remove duplicates
|
|
1178
|
+
const unique = removeDuplicatePoints(allPoints);
|
|
1179
|
+
|
|
1180
|
+
if (unique.length < 3) {
|
|
1181
|
+
return [];
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Sort points to form a valid polygon (convex hull of intersection)
|
|
1185
|
+
const hull = convexHull(unique);
|
|
1186
|
+
|
|
1187
|
+
return hull.length >= 3 ? [hull] : [];
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Remove duplicate points from an array using tolerance-based equality.
|
|
1192
|
+
*
|
|
1193
|
+
* Compares each point against already-accepted points using the
|
|
1194
|
+
* pointsEqual function with EPSILON tolerance.
|
|
1195
|
+
*
|
|
1196
|
+
* @private
|
|
1197
|
+
* @param {Array} points - Array of points to deduplicate
|
|
1198
|
+
* @returns {Array} Array with duplicates removed
|
|
1199
|
+
*/
|
|
1200
|
+
function removeDuplicatePoints(points) {
|
|
1201
|
+
const result = [];
|
|
1202
|
+
|
|
1203
|
+
for (const p of points) {
|
|
1204
|
+
let isDuplicate = false;
|
|
1205
|
+
for (const r of result) {
|
|
1206
|
+
if (pointsEqual(p, r)) {
|
|
1207
|
+
isDuplicate = true;
|
|
1208
|
+
break;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (!isDuplicate) {
|
|
1212
|
+
result.push(p);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
return result;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ============================================================================
|
|
1220
|
+
// Polygon Union
|
|
1221
|
+
// ============================================================================
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Compute the union of two simple polygons.
|
|
1225
|
+
*
|
|
1226
|
+
* Returns the combined region covered by either or both polygons.
|
|
1227
|
+
* This implementation provides simplified results for complex cases.
|
|
1228
|
+
*
|
|
1229
|
+
* Algorithm:
|
|
1230
|
+
* 1. Quick optimization: if bounding boxes don't intersect, return both polygons
|
|
1231
|
+
* 2. If both polygons are convex: compute convex hull of all vertices
|
|
1232
|
+
* 3. Otherwise: use point collection method:
|
|
1233
|
+
* a. Find all edge-edge intersection points
|
|
1234
|
+
* b. Find vertices of each polygon outside the other
|
|
1235
|
+
* c. Compute convex hull of boundary points
|
|
1236
|
+
*
|
|
1237
|
+
* Note: This is a simplified union that works well for convex polygons
|
|
1238
|
+
* and gives approximate results for concave polygons. For exact results
|
|
1239
|
+
* with concave polygons, a full polygon clipping algorithm (like
|
|
1240
|
+
* Greiner-Hormann or Martinez-Rueda) would be needed.
|
|
1241
|
+
*
|
|
1242
|
+
* @param {Array} polygon1 - First polygon vertices [{x, y}, ...]
|
|
1243
|
+
* @param {Array} polygon2 - Second polygon vertices [{x, y}, ...]
|
|
1244
|
+
* @returns {Array<Array>} Array of result polygons
|
|
1245
|
+
* (one polygon if they overlap or are merged,
|
|
1246
|
+
* two polygons if separate)
|
|
1247
|
+
*
|
|
1248
|
+
* @example
|
|
1249
|
+
* // Two overlapping squares
|
|
1250
|
+
* const square1 = [point(0,0), point(2,0), point(2,2), point(0,2)];
|
|
1251
|
+
* const square2 = [point(1,1), point(3,1), point(3,3), point(1,3)];
|
|
1252
|
+
* const result = polygonUnion(square1, square2);
|
|
1253
|
+
* // Returns combined region covering both squares
|
|
1254
|
+
*
|
|
1255
|
+
* @example
|
|
1256
|
+
* // Non-overlapping polygons
|
|
1257
|
+
* const square1 = [point(0,0), point(1,0), point(1,1), point(0,1)];
|
|
1258
|
+
* const square2 = [point(5,5), point(6,5), point(6,6), point(5,6)];
|
|
1259
|
+
* polygonUnion(square1, square2); // [square1, square2]
|
|
1260
|
+
*/
|
|
1261
|
+
export function polygonUnion(polygon1, polygon2) {
|
|
1262
|
+
const poly1 = polygon1.map(p => point(p.x, p.y));
|
|
1263
|
+
const poly2 = polygon2.map(p => point(p.x, p.y));
|
|
1264
|
+
|
|
1265
|
+
const bb1 = boundingBox(poly1);
|
|
1266
|
+
const bb2 = boundingBox(poly2);
|
|
1267
|
+
|
|
1268
|
+
// If no overlap, return both polygons
|
|
1269
|
+
if (!bboxIntersects(bb1, bb2)) {
|
|
1270
|
+
return [poly1, poly2];
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// For convex polygons, compute the convex hull of all vertices
|
|
1274
|
+
// This is a simplification - full union requires more complex algorithms
|
|
1275
|
+
if (isConvex(poly1) && isConvex(poly2)) {
|
|
1276
|
+
const allPoints = [...poly1, ...poly2];
|
|
1277
|
+
const hull = convexHull(allPoints);
|
|
1278
|
+
return [hull];
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// For general case, use point collection approach
|
|
1282
|
+
return generalPolygonUnion(poly1, poly2);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* General polygon union using point collection method.
|
|
1287
|
+
*
|
|
1288
|
+
* Simplified union for general (possibly concave) polygons.
|
|
1289
|
+
* Collects boundary points and computes their convex hull.
|
|
1290
|
+
*
|
|
1291
|
+
* Algorithm:
|
|
1292
|
+
* 1. Find all edge-edge intersection points
|
|
1293
|
+
* 2. Find poly1 vertices outside poly2
|
|
1294
|
+
* 3. Find poly2 vertices outside poly1
|
|
1295
|
+
* 4. If no boundary points found, one polygon contains the other
|
|
1296
|
+
* 5. Otherwise, compute convex hull of all boundary points
|
|
1297
|
+
*
|
|
1298
|
+
* Note: Returns convex hull approximation. Not exact for concave results.
|
|
1299
|
+
*
|
|
1300
|
+
* @private
|
|
1301
|
+
* @param {Array} poly1 - First polygon vertices
|
|
1302
|
+
* @param {Array} poly2 - Second polygon vertices
|
|
1303
|
+
* @returns {Array} Array containing result polygon(s)
|
|
1304
|
+
*/
|
|
1305
|
+
function generalPolygonUnion(poly1, poly2) {
|
|
1306
|
+
// Find intersection points
|
|
1307
|
+
const intersectionPoints = [];
|
|
1308
|
+
|
|
1309
|
+
for (let i = 0; i < poly1.length; i++) {
|
|
1310
|
+
const s1 = poly1[i];
|
|
1311
|
+
const s2 = poly1[(i + 1) % poly1.length];
|
|
1312
|
+
|
|
1313
|
+
for (let j = 0; j < poly2.length; j++) {
|
|
1314
|
+
const c1 = poly2[j];
|
|
1315
|
+
const c2 = poly2[(j + 1) % poly2.length];
|
|
1316
|
+
|
|
1317
|
+
const intersection = segmentIntersection(s1, s2, c1, c2);
|
|
1318
|
+
if (intersection) {
|
|
1319
|
+
intersectionPoints.push(point(intersection.x, intersection.y));
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Find vertices outside the other polygon
|
|
1325
|
+
const poly1Outside = poly1.filter(p => pointInPolygon(p, poly2) < 0);
|
|
1326
|
+
const poly2Outside = poly2.filter(p => pointInPolygon(p, poly1) < 0);
|
|
1327
|
+
|
|
1328
|
+
// All boundary points
|
|
1329
|
+
const allPoints = [...intersectionPoints, ...poly1Outside, ...poly2Outside];
|
|
1330
|
+
|
|
1331
|
+
if (allPoints.length < 3) {
|
|
1332
|
+
// One contains the other or identical
|
|
1333
|
+
const area1 = polygonArea(poly1).abs();
|
|
1334
|
+
const area2 = polygonArea(poly2).abs();
|
|
1335
|
+
return area1.gt(area2) ? [poly1] : [poly2];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Compute convex hull (simplified union)
|
|
1339
|
+
const hull = convexHull(allPoints);
|
|
1340
|
+
return hull.length >= 3 ? [hull] : [];
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ============================================================================
|
|
1344
|
+
// Polygon Difference
|
|
1345
|
+
// ============================================================================
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Compute the difference of two polygons (polygon1 - polygon2).
|
|
1349
|
+
*
|
|
1350
|
+
* Returns the region(s) in polygon1 that are NOT covered by polygon2.
|
|
1351
|
+
* This is the "subtraction" operation in polygon boolean algebra.
|
|
1352
|
+
*
|
|
1353
|
+
* Algorithm:
|
|
1354
|
+
* 1. Quick optimization: if bounding boxes don't intersect, return polygon1
|
|
1355
|
+
* 2. Find all edge-edge intersection points
|
|
1356
|
+
* 3. Find polygon1 vertices outside polygon2
|
|
1357
|
+
* 4. Find polygon2 vertices inside polygon1 (these define the "hole" boundary)
|
|
1358
|
+
* 5. Handle special cases:
|
|
1359
|
+
* - polygon2 entirely outside polygon1: return polygon1
|
|
1360
|
+
* - polygon1 entirely inside polygon2: return empty
|
|
1361
|
+
* 6. Compute convex hull of remaining points (simplified result)
|
|
1362
|
+
*
|
|
1363
|
+
* Note: This is a simplified difference operation that works well for
|
|
1364
|
+
* convex cases. For complex concave polygons with holes, a full
|
|
1365
|
+
* polygon clipping algorithm would be needed.
|
|
1366
|
+
*
|
|
1367
|
+
* @param {Array} polygon1 - First polygon (subject) [{x, y}, ...]
|
|
1368
|
+
* @param {Array} polygon2 - Second polygon (to subtract) [{x, y}, ...]
|
|
1369
|
+
* @returns {Array<Array>} Array of result polygons (possibly empty)
|
|
1370
|
+
*
|
|
1371
|
+
* @example
|
|
1372
|
+
* // Subtract overlapping region
|
|
1373
|
+
* const square1 = [point(0,0), point(3,0), point(3,3), point(0,3)];
|
|
1374
|
+
* const square2 = [point(1,1), point(4,1), point(4,4), point(1,4)];
|
|
1375
|
+
* const result = polygonDifference(square1, square2);
|
|
1376
|
+
* // Returns portion of square1 not covered by square2
|
|
1377
|
+
*
|
|
1378
|
+
* @example
|
|
1379
|
+
* // No overlap - return original
|
|
1380
|
+
* const square1 = [point(0,0), point(1,0), point(1,1), point(0,1)];
|
|
1381
|
+
* const square2 = [point(5,5), point(6,5), point(6,6), point(5,6)];
|
|
1382
|
+
* polygonDifference(square1, square2); // [square1]
|
|
1383
|
+
*
|
|
1384
|
+
* @example
|
|
1385
|
+
* // Complete coverage - return empty
|
|
1386
|
+
* const small = [point(1,1), point(2,1), point(2,2), point(1,2)];
|
|
1387
|
+
* const large = [point(0,0), point(3,0), point(3,3), point(0,3)];
|
|
1388
|
+
* polygonDifference(small, large); // []
|
|
1389
|
+
*/
|
|
1390
|
+
export function polygonDifference(polygon1, polygon2) {
|
|
1391
|
+
const poly1 = polygon1.map(p => point(p.x, p.y));
|
|
1392
|
+
const poly2 = polygon2.map(p => point(p.x, p.y));
|
|
1393
|
+
|
|
1394
|
+
const bb1 = boundingBox(poly1);
|
|
1395
|
+
const bb2 = boundingBox(poly2);
|
|
1396
|
+
|
|
1397
|
+
// If no overlap, return original
|
|
1398
|
+
if (!bboxIntersects(bb1, bb2)) {
|
|
1399
|
+
return [poly1];
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Find intersection points
|
|
1403
|
+
const intersectionPoints = [];
|
|
1404
|
+
|
|
1405
|
+
for (let i = 0; i < poly1.length; i++) {
|
|
1406
|
+
const s1 = poly1[i];
|
|
1407
|
+
const s2 = poly1[(i + 1) % poly1.length];
|
|
1408
|
+
|
|
1409
|
+
for (let j = 0; j < poly2.length; j++) {
|
|
1410
|
+
const c1 = poly2[j];
|
|
1411
|
+
const c2 = poly2[(j + 1) % poly2.length];
|
|
1412
|
+
|
|
1413
|
+
const intersection = segmentIntersection(s1, s2, c1, c2);
|
|
1414
|
+
if (intersection) {
|
|
1415
|
+
intersectionPoints.push(point(intersection.x, intersection.y));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Find poly1 vertices outside poly2
|
|
1421
|
+
const poly1Outside = poly1.filter(p => pointInPolygon(p, poly2) < 0);
|
|
1422
|
+
|
|
1423
|
+
// Find poly2 vertices inside poly1 (these form the "hole" boundary)
|
|
1424
|
+
const poly2Inside = poly2.filter(p => pointInPolygon(p, poly1) > 0);
|
|
1425
|
+
|
|
1426
|
+
// If poly2 is entirely outside poly1, return poly1
|
|
1427
|
+
if (poly2Inside.length === 0 && intersectionPoints.length === 0) {
|
|
1428
|
+
return [poly1];
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// If poly1 is entirely inside poly2, return empty
|
|
1432
|
+
if (poly1Outside.length === 0) {
|
|
1433
|
+
return [];
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Simplified: return poly1 vertices outside poly2 + intersection points
|
|
1437
|
+
const allPoints = [...intersectionPoints, ...poly1Outside];
|
|
1438
|
+
|
|
1439
|
+
if (allPoints.length < 3) {
|
|
1440
|
+
return [];
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const hull = convexHull(allPoints);
|
|
1444
|
+
return hull.length >= 3 ? [hull] : [];
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ============================================================================
|
|
1448
|
+
// Exports
|
|
1449
|
+
// ============================================================================
|
|
1450
|
+
|
|
1451
|
+
export default {
|
|
1452
|
+
// Primitives
|
|
1453
|
+
point,
|
|
1454
|
+
pointsEqual,
|
|
1455
|
+
cross,
|
|
1456
|
+
dot,
|
|
1457
|
+
sign,
|
|
1458
|
+
|
|
1459
|
+
// Segment operations
|
|
1460
|
+
segmentIntersection,
|
|
1461
|
+
lineSegmentIntersection,
|
|
1462
|
+
|
|
1463
|
+
// Point in polygon
|
|
1464
|
+
pointInPolygon,
|
|
1465
|
+
pointOnSegment,
|
|
1466
|
+
|
|
1467
|
+
// Sutherland-Hodgman clipping
|
|
1468
|
+
clipPolygonSH,
|
|
1469
|
+
|
|
1470
|
+
// Polygon properties
|
|
1471
|
+
polygonArea,
|
|
1472
|
+
isCounterClockwise,
|
|
1473
|
+
reversePolygon,
|
|
1474
|
+
ensureCCW,
|
|
1475
|
+
isConvex,
|
|
1476
|
+
|
|
1477
|
+
// Convex hull
|
|
1478
|
+
convexHull,
|
|
1479
|
+
|
|
1480
|
+
// Bounding box
|
|
1481
|
+
boundingBox,
|
|
1482
|
+
bboxIntersects,
|
|
1483
|
+
|
|
1484
|
+
// Boolean operations
|
|
1485
|
+
polygonIntersection,
|
|
1486
|
+
polygonUnion,
|
|
1487
|
+
polygonDifference,
|
|
1488
|
+
|
|
1489
|
+
// Constants
|
|
1490
|
+
EPSILON
|
|
1491
|
+
};
|