@emasoft/svg-matrix 1.0.18 → 1.0.20
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 +256 -759
- package/bin/svg-matrix.js +171 -2
- package/bin/svglinter.cjs +1162 -0
- package/package.json +8 -2
- package/scripts/postinstall.js +6 -9
- package/src/animation-optimization.js +394 -0
- package/src/animation-references.js +440 -0
- package/src/arc-length.js +940 -0
- package/src/bezier-analysis.js +1626 -0
- package/src/bezier-intersections.js +1369 -0
- package/src/clip-path-resolver.js +110 -2
- package/src/convert-path-data.js +583 -0
- package/src/css-specificity.js +443 -0
- package/src/douglas-peucker.js +356 -0
- package/src/flatten-pipeline.js +109 -4
- package/src/geometry-to-path.js +126 -16
- package/src/gjk-collision.js +840 -0
- package/src/index.js +175 -2
- package/src/off-canvas-detection.js +1222 -0
- package/src/path-analysis.js +1241 -0
- package/src/path-data-plugins.js +928 -0
- package/src/path-optimization.js +825 -0
- package/src/path-simplification.js +1140 -0
- package/src/polygon-clip.js +376 -99
- package/src/svg-boolean-ops.js +898 -0
- package/src/svg-collections.js +910 -0
- package/src/svg-parser.js +175 -16
- package/src/svg-rendering-context.js +627 -0
- package/src/svg-toolbox.js +7495 -0
- package/src/svg-validation-data.js +944 -0
- package/src/transform-decomposition.js +810 -0
- package/src/transform-optimization.js +936 -0
- package/src/use-symbol-resolver.js +75 -7
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Arbitrary-Precision Path Analysis
|
|
3
|
+
*
|
|
4
|
+
* Advanced path analysis operations including:
|
|
5
|
+
* - Area calculation using Green's theorem
|
|
6
|
+
* - Closest/farthest point on path
|
|
7
|
+
* - Point-in-path testing
|
|
8
|
+
* - Path continuity and smoothness analysis
|
|
9
|
+
*
|
|
10
|
+
* @module path-analysis
|
|
11
|
+
* @version 1.0.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Decimal from 'decimal.js';
|
|
15
|
+
import {
|
|
16
|
+
bezierPoint,
|
|
17
|
+
bezierDerivative,
|
|
18
|
+
bezierTangent,
|
|
19
|
+
bezierBoundingBox
|
|
20
|
+
} from './bezier-analysis.js';
|
|
21
|
+
import { arcLength } from './arc-length.js';
|
|
22
|
+
|
|
23
|
+
Decimal.set({ precision: 80 });
|
|
24
|
+
|
|
25
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
26
|
+
const PI = new Decimal('3.1415926535897932384626433832795028841971693993751058209749445923078164062862090');
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// NUMERICAL CONSTANTS (documented magic numbers)
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/** Tolerance for boundary detection in point-in-path - very small to catch points on curve */
|
|
33
|
+
const BOUNDARY_TOLERANCE = new Decimal('1e-20');
|
|
34
|
+
|
|
35
|
+
/** Default tolerance for path closed/continuous checks - detects microscopic gaps */
|
|
36
|
+
const DEFAULT_CONTINUITY_TOLERANCE = '1e-20';
|
|
37
|
+
|
|
38
|
+
/** Default tolerance for path smoothness (tangent angle) checks - allows tiny angle differences */
|
|
39
|
+
const DEFAULT_SMOOTHNESS_TOLERANCE = '1e-10';
|
|
40
|
+
|
|
41
|
+
/** Tolerance for centroid-based direction calculations - avoids division by near-zero */
|
|
42
|
+
const CENTROID_ZERO_THRESHOLD = new Decimal('1e-30');
|
|
43
|
+
|
|
44
|
+
/** Small epsilon for neighbor point testing - small offset for nearby point checks */
|
|
45
|
+
const NEIGHBOR_TEST_EPSILON = new Decimal('1e-10');
|
|
46
|
+
|
|
47
|
+
/** Threshold for considering tangents anti-parallel (180-degree turn) - dot product ~ -1 */
|
|
48
|
+
const ANTI_PARALLEL_THRESHOLD = new Decimal('-0.99');
|
|
49
|
+
|
|
50
|
+
/** Tolerance for Newton-Raphson singular Jacobian detection - avoids division by zero */
|
|
51
|
+
const JACOBIAN_SINGULARITY_THRESHOLD = new Decimal('1e-60');
|
|
52
|
+
|
|
53
|
+
/** Numerical precision tolerance for farthest point verification.
|
|
54
|
+
* WHY: Accounts for floating-point rounding in distance comparisons, not sampling error.
|
|
55
|
+
* The found distance should be >= max sampled within this numerical tolerance. */
|
|
56
|
+
const FARTHEST_POINT_NUMERICAL_TOLERANCE = new Decimal('1e-10');
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// AREA CALCULATION (GREEN'S THEOREM)
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute the signed area enclosed by a closed Bezier path using Green's theorem.
|
|
64
|
+
*
|
|
65
|
+
* Green's theorem: Area = (1/2) * integral of (x * dy - y * dx)
|
|
66
|
+
*
|
|
67
|
+
* For Bezier curves, this integral can be computed exactly for polynomial segments,
|
|
68
|
+
* or numerically for arc segments.
|
|
69
|
+
*
|
|
70
|
+
* Positive area = counter-clockwise path
|
|
71
|
+
* Negative area = clockwise path
|
|
72
|
+
*
|
|
73
|
+
* @param {Array} segments - Array of Bezier segments (each is control points array)
|
|
74
|
+
* @param {Object} [options] - Options
|
|
75
|
+
* @param {number} [options.samples=50] - Samples per segment for numerical integration
|
|
76
|
+
* @returns {Decimal} Signed area
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* // Rectangle as 4 line segments
|
|
80
|
+
* const rect = [
|
|
81
|
+
* [[0,0], [100,0]], // bottom
|
|
82
|
+
* [[100,0], [100,50]], // right
|
|
83
|
+
* [[100,50], [0,50]], // top
|
|
84
|
+
* [[0,50], [0,0]] // left
|
|
85
|
+
* ];
|
|
86
|
+
* const area = pathArea(rect); // 5000 (100 * 50)
|
|
87
|
+
*/
|
|
88
|
+
export function pathArea(segments, options = {}) {
|
|
89
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
90
|
+
if (!segments || !Array.isArray(segments)) {
|
|
91
|
+
throw new Error('pathArea: segments must be an array');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { samples = 50 } = options;
|
|
95
|
+
|
|
96
|
+
let area = D(0);
|
|
97
|
+
|
|
98
|
+
for (const points of segments) {
|
|
99
|
+
const n = points.length - 1; // Degree
|
|
100
|
+
|
|
101
|
+
if (n === 1) {
|
|
102
|
+
// Line segment: exact formula
|
|
103
|
+
// Area contribution = (1/2) * (x0*y1 - x1*y0 + x0*dy - y0*dx) integrated
|
|
104
|
+
// For line from P0 to P1: contribution = (x0*y1 - x1*y0) / 2
|
|
105
|
+
// But using Green's theorem: integral of x*dy = integral of x(t) * y'(t) dt
|
|
106
|
+
const [x0, y0] = [D(points[0][0]), D(points[0][1])];
|
|
107
|
+
const [x1, y1] = [D(points[1][0]), D(points[1][1])];
|
|
108
|
+
|
|
109
|
+
// x(t) = x0 + t*(x1-x0)
|
|
110
|
+
// y'(t) = y1 - y0
|
|
111
|
+
// integral from 0 to 1 of x(t)*y'(t) dt = (y1-y0) * integral of (x0 + t*(x1-x0)) dt
|
|
112
|
+
// = (y1-y0) * (x0 + (x1-x0)/2) = (y1-y0) * (x0+x1)/2
|
|
113
|
+
|
|
114
|
+
const lineIntegralXdY = y1.minus(y0).times(x0.plus(x1).div(2));
|
|
115
|
+
|
|
116
|
+
// Similarly: integral of y*dx = (x1-x0) * (y0+y1)/2
|
|
117
|
+
const lineIntegralYdX = x1.minus(x0).times(y0.plus(y1).div(2));
|
|
118
|
+
|
|
119
|
+
// Green: (1/2) * integral of (x*dy - y*dx)
|
|
120
|
+
area = area.plus(lineIntegralXdY.minus(lineIntegralYdX).div(2));
|
|
121
|
+
|
|
122
|
+
} else if (n === 2 || n === 3) {
|
|
123
|
+
// Quadratic or Cubic: use exact polynomial integration
|
|
124
|
+
area = area.plus(bezierAreaContribution(points));
|
|
125
|
+
|
|
126
|
+
} else {
|
|
127
|
+
// Higher degree: numerical integration
|
|
128
|
+
area = area.plus(numericalAreaContribution(points, samples));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return area;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Exact area contribution from a quadratic or cubic Bezier using polynomial integration.
|
|
137
|
+
*
|
|
138
|
+
* @param {Array} points - Control points
|
|
139
|
+
* @returns {Decimal} Area contribution
|
|
140
|
+
*/
|
|
141
|
+
function bezierAreaContribution(points) {
|
|
142
|
+
const n = points.length - 1;
|
|
143
|
+
|
|
144
|
+
// Convert to Decimal
|
|
145
|
+
const P = points.map(([x, y]) => [D(x), D(y)]);
|
|
146
|
+
|
|
147
|
+
if (n === 2) {
|
|
148
|
+
// Quadratic Bezier
|
|
149
|
+
// B(t) = (1-t)^2*P0 + 2(1-t)t*P1 + t^2*P2
|
|
150
|
+
// x(t) = x0(1-t)^2 + 2x1(1-t)t + x2*t^2
|
|
151
|
+
// y'(t) = 2(y1-y0)(1-t) + 2(y2-y1)t = 2(y1-y0) + 2(y2-2y1+y0)t
|
|
152
|
+
|
|
153
|
+
const [x0, y0] = P[0];
|
|
154
|
+
const [x1, y1] = P[1];
|
|
155
|
+
const [x2, y2] = P[2];
|
|
156
|
+
|
|
157
|
+
// Integral of x(t)*y'(t) from 0 to 1
|
|
158
|
+
// This expands to a polynomial integral that can be computed exactly
|
|
159
|
+
// After expansion and integration:
|
|
160
|
+
const integral_x_dy = x0.times(y1.minus(y0))
|
|
161
|
+
.plus(x0.times(y2.minus(y1.times(2)).plus(y0)).div(2))
|
|
162
|
+
.plus(x1.times(2).minus(x0.times(2)).times(y1.minus(y0)).div(2))
|
|
163
|
+
.plus(x1.times(2).minus(x0.times(2)).times(y2.minus(y1.times(2)).plus(y0)).div(3))
|
|
164
|
+
.plus(x2.minus(x1.times(2)).plus(x0).times(y1.minus(y0)).div(3))
|
|
165
|
+
.plus(x2.minus(x1.times(2)).plus(x0).times(y2.minus(y1.times(2)).plus(y0)).div(4));
|
|
166
|
+
|
|
167
|
+
// Similarly for integral of y(t)*x'(t)
|
|
168
|
+
const integral_y_dx = y0.times(x1.minus(x0))
|
|
169
|
+
.plus(y0.times(x2.minus(x1.times(2)).plus(x0)).div(2))
|
|
170
|
+
.plus(y1.times(2).minus(y0.times(2)).times(x1.minus(x0)).div(2))
|
|
171
|
+
.plus(y1.times(2).minus(y0.times(2)).times(x2.minus(x1.times(2)).plus(x0)).div(3))
|
|
172
|
+
.plus(y2.minus(y1.times(2)).plus(y0).times(x1.minus(x0)).div(3))
|
|
173
|
+
.plus(y2.minus(y1.times(2)).plus(y0).times(x2.minus(x1.times(2)).plus(x0)).div(4));
|
|
174
|
+
|
|
175
|
+
return integral_x_dy.minus(integral_y_dx).div(2);
|
|
176
|
+
|
|
177
|
+
} else if (n === 3) {
|
|
178
|
+
// Cubic Bezier - use numerical integration
|
|
179
|
+
// WHY: The exact polynomial integration for cubic Bezier area is complex
|
|
180
|
+
// and the numerical method with 20 samples provides sufficient accuracy
|
|
181
|
+
// for 80-digit precision arithmetic. Future improvement could add exact formula.
|
|
182
|
+
return numericalAreaContribution(points, 20);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return D(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Numerical area contribution using Gauss-Legendre quadrature.
|
|
190
|
+
*
|
|
191
|
+
* @param {Array} points - Control points
|
|
192
|
+
* @param {number} samples - Number of sample points
|
|
193
|
+
* @returns {Decimal} Area contribution
|
|
194
|
+
*/
|
|
195
|
+
function numericalAreaContribution(points, samples) {
|
|
196
|
+
// Use composite Simpson's rule
|
|
197
|
+
let integral_x_dy = D(0);
|
|
198
|
+
let integral_y_dx = D(0);
|
|
199
|
+
|
|
200
|
+
const h = D(1).div(samples);
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i <= samples; i++) {
|
|
203
|
+
const t = h.times(i);
|
|
204
|
+
const weight = i === 0 || i === samples ? D(1) : (i % 2 === 0 ? D(2) : D(4));
|
|
205
|
+
|
|
206
|
+
const [x, y] = bezierPoint(points, t);
|
|
207
|
+
const [dx, dy] = bezierDerivative(points, t, 1);
|
|
208
|
+
|
|
209
|
+
integral_x_dy = integral_x_dy.plus(weight.times(x).times(dy));
|
|
210
|
+
integral_y_dx = integral_y_dx.plus(weight.times(y).times(dx));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
integral_x_dy = integral_x_dy.times(h).div(3);
|
|
214
|
+
integral_y_dx = integral_y_dx.times(h).div(3);
|
|
215
|
+
|
|
216
|
+
return integral_x_dy.minus(integral_y_dx).div(2);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Compute the absolute (unsigned) area of a closed path.
|
|
221
|
+
*
|
|
222
|
+
* @param {Array} segments - Array of Bezier segments
|
|
223
|
+
* @param {Object} [options] - Options
|
|
224
|
+
* @returns {Decimal} Absolute area
|
|
225
|
+
*/
|
|
226
|
+
export function pathAbsoluteArea(segments, options = {}) {
|
|
227
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
228
|
+
if (!segments || !Array.isArray(segments)) {
|
|
229
|
+
throw new Error('pathAbsoluteArea: segments must be an array');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return pathArea(segments, options).abs();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// CLOSEST POINT ON PATH
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Find the closest point on a path to a given point.
|
|
241
|
+
*
|
|
242
|
+
* Uses a combination of:
|
|
243
|
+
* 1. Coarse sampling to find approximate location
|
|
244
|
+
* 2. Newton-Raphson refinement for exact solution
|
|
245
|
+
*
|
|
246
|
+
* @param {Array} segments - Array of Bezier segments
|
|
247
|
+
* @param {Array} point - Query point [x, y]
|
|
248
|
+
* @param {Object} [options] - Options
|
|
249
|
+
* @param {number} [options.samples=50] - Samples per segment for initial search
|
|
250
|
+
* @param {number} [options.maxIterations=30] - Max Newton iterations
|
|
251
|
+
* @param {string} [options.tolerance='1e-30'] - Convergence tolerance
|
|
252
|
+
* @returns {{point: Array, distance: Decimal, segmentIndex: number, t: Decimal}}
|
|
253
|
+
*/
|
|
254
|
+
export function closestPointOnPath(segments, point, options = {}) {
|
|
255
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
256
|
+
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
|
257
|
+
throw new Error('closestPointOnPath: segments must be a non-empty array');
|
|
258
|
+
}
|
|
259
|
+
if (!point || !Array.isArray(point) || point.length < 2) {
|
|
260
|
+
throw new Error('closestPointOnPath: point must be an array [x, y]');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const { samples = 50, maxIterations = 30, tolerance = '1e-30' } = options;
|
|
264
|
+
|
|
265
|
+
const px = D(point[0]);
|
|
266
|
+
const py = D(point[1]);
|
|
267
|
+
const tol = D(tolerance);
|
|
268
|
+
|
|
269
|
+
let bestSegment = 0;
|
|
270
|
+
let bestT = D(0);
|
|
271
|
+
let bestDist = new Decimal(Infinity);
|
|
272
|
+
|
|
273
|
+
// Coarse sampling
|
|
274
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
275
|
+
const pts = segments[segIdx];
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i <= samples; i++) {
|
|
278
|
+
const t = D(i).div(samples);
|
|
279
|
+
const [x, y] = bezierPoint(pts, t);
|
|
280
|
+
const dist = px.minus(x).pow(2).plus(py.minus(y).pow(2));
|
|
281
|
+
|
|
282
|
+
if (dist.lt(bestDist)) {
|
|
283
|
+
bestDist = dist;
|
|
284
|
+
bestSegment = segIdx;
|
|
285
|
+
bestT = t;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Newton-Raphson refinement
|
|
291
|
+
const pts = segments[bestSegment];
|
|
292
|
+
|
|
293
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
294
|
+
const [x, y] = bezierPoint(pts, bestT);
|
|
295
|
+
const [dx, dy] = bezierDerivative(pts, bestT, 1);
|
|
296
|
+
const [d2x, d2y] = bezierDerivative(pts, bestT, 2);
|
|
297
|
+
|
|
298
|
+
// f(t) = (x(t) - px)^2 + (y(t) - py)^2 (distance squared)
|
|
299
|
+
// f'(t) = 2(x-px)*dx + 2(y-py)*dy
|
|
300
|
+
// f''(t) = 2(dx^2 + dy^2 + (x-px)*d2x + (y-py)*d2y)
|
|
301
|
+
|
|
302
|
+
const diffX = x.minus(px);
|
|
303
|
+
const diffY = y.minus(py);
|
|
304
|
+
|
|
305
|
+
const fPrime = diffX.times(dx).plus(diffY.times(dy)).times(2);
|
|
306
|
+
const fDoublePrime = dx.pow(2).plus(dy.pow(2))
|
|
307
|
+
.plus(diffX.times(d2x)).plus(diffY.times(d2y)).times(2);
|
|
308
|
+
|
|
309
|
+
// WHY: Use named constant instead of magic number for clarity
|
|
310
|
+
if (fDoublePrime.abs().lt(JACOBIAN_SINGULARITY_THRESHOLD)) break;
|
|
311
|
+
|
|
312
|
+
const delta = fPrime.div(fDoublePrime);
|
|
313
|
+
|
|
314
|
+
// Clamp to [0, 1]
|
|
315
|
+
let newT = bestT.minus(delta);
|
|
316
|
+
if (newT.lt(0)) newT = D(0);
|
|
317
|
+
if (newT.gt(1)) newT = D(1);
|
|
318
|
+
|
|
319
|
+
bestT = newT;
|
|
320
|
+
|
|
321
|
+
if (delta.abs().lt(tol)) break;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Also check all segment endpoints - the closest point might be at an endpoint
|
|
325
|
+
// WHY: Newton refinement finds local minima within a segment, but segment
|
|
326
|
+
// endpoints might be closer than any interior critical point
|
|
327
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
328
|
+
const pts = segments[segIdx];
|
|
329
|
+
for (const tVal of [D(0), D(1)]) {
|
|
330
|
+
const [x, y] = bezierPoint(pts, tVal);
|
|
331
|
+
const dist = px.minus(x).pow(2).plus(py.minus(y).pow(2));
|
|
332
|
+
if (dist.lt(bestDist)) {
|
|
333
|
+
bestDist = dist;
|
|
334
|
+
bestSegment = segIdx;
|
|
335
|
+
bestT = tVal;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Final result
|
|
341
|
+
const [finalX, finalY] = bezierPoint(segments[bestSegment], bestT);
|
|
342
|
+
const finalDist = px.minus(finalX).pow(2).plus(py.minus(finalY).pow(2)).sqrt();
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
point: [finalX, finalY],
|
|
346
|
+
distance: finalDist,
|
|
347
|
+
segmentIndex: bestSegment,
|
|
348
|
+
t: bestT
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Find the farthest point on a path from a given point.
|
|
354
|
+
*
|
|
355
|
+
* @param {Array} segments - Array of Bezier segments
|
|
356
|
+
* @param {Array} point - Query point [x, y]
|
|
357
|
+
* @param {Object} [options] - Options
|
|
358
|
+
* @returns {{point: Array, distance: Decimal, segmentIndex: number, t: Decimal}}
|
|
359
|
+
*/
|
|
360
|
+
export function farthestPointOnPath(segments, point, options = {}) {
|
|
361
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
362
|
+
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
|
363
|
+
throw new Error('farthestPointOnPath: segments must be a non-empty array');
|
|
364
|
+
}
|
|
365
|
+
if (!point || !Array.isArray(point) || point.length < 2) {
|
|
366
|
+
throw new Error('farthestPointOnPath: point must be an array [x, y]');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { samples = 50, maxIterations = 30, tolerance = '1e-30' } = options;
|
|
370
|
+
|
|
371
|
+
const px = D(point[0]);
|
|
372
|
+
const py = D(point[1]);
|
|
373
|
+
const tol = D(tolerance);
|
|
374
|
+
|
|
375
|
+
let bestSegment = 0;
|
|
376
|
+
let bestT = D(0);
|
|
377
|
+
let bestDist = D(0);
|
|
378
|
+
|
|
379
|
+
// Coarse sampling
|
|
380
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
381
|
+
const pts = segments[segIdx];
|
|
382
|
+
|
|
383
|
+
for (let i = 0; i <= samples; i++) {
|
|
384
|
+
const t = D(i).div(samples);
|
|
385
|
+
const [x, y] = bezierPoint(pts, t);
|
|
386
|
+
const dist = px.minus(x).pow(2).plus(py.minus(y).pow(2));
|
|
387
|
+
|
|
388
|
+
if (dist.gt(bestDist)) {
|
|
389
|
+
bestDist = dist;
|
|
390
|
+
bestSegment = segIdx;
|
|
391
|
+
bestT = t;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Newton-Raphson refinement (maximize distance = minimize negative distance)
|
|
397
|
+
const pts = segments[bestSegment];
|
|
398
|
+
|
|
399
|
+
for (let iter = 0; iter < maxIterations; iter++) {
|
|
400
|
+
const [x, y] = bezierPoint(pts, bestT);
|
|
401
|
+
const [dx, dy] = bezierDerivative(pts, bestT, 1);
|
|
402
|
+
const [d2x, d2y] = bezierDerivative(pts, bestT, 2);
|
|
403
|
+
|
|
404
|
+
const diffX = x.minus(px);
|
|
405
|
+
const diffY = y.minus(py);
|
|
406
|
+
|
|
407
|
+
// For maximum: f'(t) = 0, f''(t) < 0
|
|
408
|
+
const fPrime = diffX.times(dx).plus(diffY.times(dy)).times(2);
|
|
409
|
+
const fDoublePrime = dx.pow(2).plus(dy.pow(2))
|
|
410
|
+
.plus(diffX.times(d2x)).plus(diffY.times(d2y)).times(2);
|
|
411
|
+
|
|
412
|
+
// WHY: Use named constant instead of magic number for clarity
|
|
413
|
+
if (fDoublePrime.abs().lt(JACOBIAN_SINGULARITY_THRESHOLD)) break;
|
|
414
|
+
|
|
415
|
+
// Note: for maximum, we still find critical point where fPrime = 0
|
|
416
|
+
const delta = fPrime.div(fDoublePrime);
|
|
417
|
+
|
|
418
|
+
let newT = bestT.minus(delta);
|
|
419
|
+
if (newT.lt(0)) newT = D(0);
|
|
420
|
+
if (newT.gt(1)) newT = D(1);
|
|
421
|
+
|
|
422
|
+
bestT = newT;
|
|
423
|
+
|
|
424
|
+
if (delta.abs().lt(tol)) break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Also check endpoints
|
|
428
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
429
|
+
const pts = segments[segIdx];
|
|
430
|
+
for (const t of [D(0), D(1)]) {
|
|
431
|
+
const [x, y] = bezierPoint(pts, t);
|
|
432
|
+
const dist = px.minus(x).pow(2).plus(py.minus(y).pow(2));
|
|
433
|
+
if (dist.gt(bestDist)) {
|
|
434
|
+
bestDist = dist;
|
|
435
|
+
bestSegment = segIdx;
|
|
436
|
+
bestT = t;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const [finalX, finalY] = bezierPoint(segments[bestSegment], bestT);
|
|
442
|
+
const finalDist = px.minus(finalX).pow(2).plus(py.minus(finalY).pow(2)).sqrt();
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
point: [finalX, finalY],
|
|
446
|
+
distance: finalDist,
|
|
447
|
+
segmentIndex: bestSegment,
|
|
448
|
+
t: bestT
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============================================================================
|
|
453
|
+
// POINT IN PATH (RAY CASTING)
|
|
454
|
+
// ============================================================================
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Test if a point is inside a closed path using ray casting algorithm.
|
|
458
|
+
*
|
|
459
|
+
* Counts intersections of a horizontal ray from the point to infinity.
|
|
460
|
+
* Odd count = inside, even count = outside.
|
|
461
|
+
*
|
|
462
|
+
* @param {Array} segments - Array of Bezier segments (must form closed path)
|
|
463
|
+
* @param {Array} point - Test point [x, y]
|
|
464
|
+
* @param {Object} [options] - Options
|
|
465
|
+
* @param {number} [options.samples=100] - Samples for curve approximation
|
|
466
|
+
* @returns {{inside: boolean, windingNumber: number, onBoundary: boolean}}
|
|
467
|
+
*/
|
|
468
|
+
export function pointInPath(segments, point, options = {}) {
|
|
469
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
470
|
+
if (!segments || !Array.isArray(segments) || segments.length === 0) {
|
|
471
|
+
throw new Error('pointInPath: segments must be a non-empty array');
|
|
472
|
+
}
|
|
473
|
+
if (!point || !Array.isArray(point) || point.length < 2) {
|
|
474
|
+
throw new Error('pointInPath: point must be an array [x, y]');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const { samples = 100 } = options;
|
|
478
|
+
|
|
479
|
+
const px = D(point[0]);
|
|
480
|
+
const py = D(point[1]);
|
|
481
|
+
|
|
482
|
+
let windingNumber = 0;
|
|
483
|
+
// WHY: Use named constant instead of magic number for clarity and maintainability
|
|
484
|
+
const boundaryTolerance = BOUNDARY_TOLERANCE;
|
|
485
|
+
|
|
486
|
+
for (const pts of segments) {
|
|
487
|
+
// Sample the segment and count crossings
|
|
488
|
+
let prevX = null;
|
|
489
|
+
let prevY = null;
|
|
490
|
+
|
|
491
|
+
for (let i = 0; i <= samples; i++) {
|
|
492
|
+
const t = D(i).div(samples);
|
|
493
|
+
const [x, y] = bezierPoint(pts, t);
|
|
494
|
+
|
|
495
|
+
// Check if point is on boundary
|
|
496
|
+
const distToPoint = px.minus(x).pow(2).plus(py.minus(y).pow(2)).sqrt();
|
|
497
|
+
if (distToPoint.lt(boundaryTolerance)) {
|
|
498
|
+
return { inside: false, windingNumber: 0, onBoundary: true };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (prevX !== null) {
|
|
502
|
+
// Check for crossing of horizontal ray to the right from (px, py)
|
|
503
|
+
// Ray goes from (px, py) to (infinity, py)
|
|
504
|
+
|
|
505
|
+
const y1 = prevY;
|
|
506
|
+
const y2 = y;
|
|
507
|
+
const x1 = prevX;
|
|
508
|
+
const x2 = x;
|
|
509
|
+
|
|
510
|
+
// Check if segment crosses the ray's y-level
|
|
511
|
+
if ((y1.lte(py) && y2.gt(py)) || (y1.gt(py) && y2.lte(py))) {
|
|
512
|
+
// Find x at intersection
|
|
513
|
+
const fraction = py.minus(y1).div(y2.minus(y1));
|
|
514
|
+
const xIntersect = x1.plus(x2.minus(x1).times(fraction));
|
|
515
|
+
|
|
516
|
+
// Count if intersection is to the right of point
|
|
517
|
+
if (xIntersect.gt(px)) {
|
|
518
|
+
// Determine winding direction
|
|
519
|
+
if (y2.gt(y1)) {
|
|
520
|
+
windingNumber++;
|
|
521
|
+
} else {
|
|
522
|
+
windingNumber--;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
prevX = x;
|
|
529
|
+
prevY = y;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
inside: windingNumber !== 0,
|
|
535
|
+
windingNumber,
|
|
536
|
+
onBoundary: false
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ============================================================================
|
|
541
|
+
// PATH CONTINUITY ANALYSIS
|
|
542
|
+
// ============================================================================
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Check if a path is closed (endpoints match).
|
|
546
|
+
*
|
|
547
|
+
* @param {Array} segments - Array of Bezier segments
|
|
548
|
+
* @param {string} [tolerance='1e-20'] - Distance tolerance
|
|
549
|
+
* @returns {boolean}
|
|
550
|
+
*/
|
|
551
|
+
export function isPathClosed(segments, tolerance = DEFAULT_CONTINUITY_TOLERANCE) {
|
|
552
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
553
|
+
if (!segments || !Array.isArray(segments)) {
|
|
554
|
+
throw new Error('isPathClosed: segments must be an array');
|
|
555
|
+
}
|
|
556
|
+
if (segments.length === 0) return false;
|
|
557
|
+
|
|
558
|
+
const tol = D(tolerance);
|
|
559
|
+
const firstSeg = segments[0];
|
|
560
|
+
const lastSeg = segments[segments.length - 1];
|
|
561
|
+
|
|
562
|
+
const [x0, y0] = [D(firstSeg[0][0]), D(firstSeg[0][1])];
|
|
563
|
+
const [xn, yn] = [D(lastSeg[lastSeg.length - 1][0]), D(lastSeg[lastSeg.length - 1][1])];
|
|
564
|
+
|
|
565
|
+
const dist = x0.minus(xn).pow(2).plus(y0.minus(yn).pow(2)).sqrt();
|
|
566
|
+
return dist.lt(tol);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Check if a path is continuous (segment endpoints match).
|
|
571
|
+
*
|
|
572
|
+
* @param {Array} segments - Array of Bezier segments
|
|
573
|
+
* @param {string} [tolerance='1e-20'] - Distance tolerance
|
|
574
|
+
* @returns {{continuous: boolean, gaps: Array}}
|
|
575
|
+
*/
|
|
576
|
+
export function isPathContinuous(segments, tolerance = DEFAULT_CONTINUITY_TOLERANCE) {
|
|
577
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
578
|
+
if (!segments || !Array.isArray(segments)) {
|
|
579
|
+
throw new Error('isPathContinuous: segments must be an array');
|
|
580
|
+
}
|
|
581
|
+
if (segments.length <= 1) return { continuous: true, gaps: [] };
|
|
582
|
+
|
|
583
|
+
const tol = D(tolerance);
|
|
584
|
+
const gaps = [];
|
|
585
|
+
|
|
586
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
587
|
+
const seg1 = segments[i];
|
|
588
|
+
const seg2 = segments[i + 1];
|
|
589
|
+
|
|
590
|
+
const [x1, y1] = [D(seg1[seg1.length - 1][0]), D(seg1[seg1.length - 1][1])];
|
|
591
|
+
const [x2, y2] = [D(seg2[0][0]), D(seg2[0][1])];
|
|
592
|
+
|
|
593
|
+
const dist = x1.minus(x2).pow(2).plus(y1.minus(y2).pow(2)).sqrt();
|
|
594
|
+
|
|
595
|
+
if (dist.gte(tol)) {
|
|
596
|
+
gaps.push({
|
|
597
|
+
segmentIndex: i,
|
|
598
|
+
gap: dist,
|
|
599
|
+
from: [x1, y1],
|
|
600
|
+
to: [x2, y2]
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
continuous: gaps.length === 0,
|
|
607
|
+
gaps
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Check if a path is smooth (C1 continuous - tangents match at joins).
|
|
613
|
+
*
|
|
614
|
+
* @param {Array} segments - Array of Bezier segments
|
|
615
|
+
* @param {string} [tolerance='1e-10'] - Tangent angle tolerance (radians)
|
|
616
|
+
* @returns {{smooth: boolean, kinks: Array}}
|
|
617
|
+
*/
|
|
618
|
+
export function isPathSmooth(segments, tolerance = DEFAULT_SMOOTHNESS_TOLERANCE) {
|
|
619
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
620
|
+
if (!segments || !Array.isArray(segments)) {
|
|
621
|
+
throw new Error('isPathSmooth: segments must be an array');
|
|
622
|
+
}
|
|
623
|
+
if (segments.length <= 1) return { smooth: true, kinks: [] };
|
|
624
|
+
|
|
625
|
+
const tol = D(tolerance);
|
|
626
|
+
const kinks = [];
|
|
627
|
+
|
|
628
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
629
|
+
const seg1 = segments[i];
|
|
630
|
+
const seg2 = segments[i + 1];
|
|
631
|
+
|
|
632
|
+
// Tangent at end of seg1
|
|
633
|
+
const [tx1, ty1] = bezierTangent(seg1, 1);
|
|
634
|
+
|
|
635
|
+
// Tangent at start of seg2
|
|
636
|
+
const [tx2, ty2] = bezierTangent(seg2, 0);
|
|
637
|
+
|
|
638
|
+
// Compute angle between tangents
|
|
639
|
+
const dot = tx1.times(tx2).plus(ty1.times(ty2));
|
|
640
|
+
const cross = tx1.times(ty2).minus(ty1.times(tx2));
|
|
641
|
+
|
|
642
|
+
// WHY: Compute actual angle between tangents using atan2 for accuracy
|
|
643
|
+
// The old comment said "Simplified for small angles" but used cross.abs() which is only
|
|
644
|
+
// accurate for very small angles (< 0.1 radians). For larger angles, this approximation
|
|
645
|
+
// breaks down. Using atan2 gives the true angle for any angle magnitude.
|
|
646
|
+
const angleDiff = Decimal.atan2(cross.abs(), dot.abs());
|
|
647
|
+
|
|
648
|
+
// Also check if tangents are parallel but opposite (180-degree turn)
|
|
649
|
+
const antiParallel = dot.lt(ANTI_PARALLEL_THRESHOLD);
|
|
650
|
+
|
|
651
|
+
if (angleDiff.gt(tol) || antiParallel) {
|
|
652
|
+
kinks.push({
|
|
653
|
+
segmentIndex: i,
|
|
654
|
+
angle: Decimal.atan2(cross, dot).abs(),
|
|
655
|
+
tangent1: [tx1, ty1],
|
|
656
|
+
tangent2: [tx2, ty2]
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
smooth: kinks.length === 0,
|
|
663
|
+
kinks
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Find all kinks (non-differentiable points) in a path.
|
|
669
|
+
*
|
|
670
|
+
* @param {Array} segments - Array of Bezier segments
|
|
671
|
+
* @param {string} [tolerance='1e-10'] - Angle tolerance
|
|
672
|
+
* @returns {Array} Array of kink locations
|
|
673
|
+
*/
|
|
674
|
+
export function findKinks(segments, tolerance = DEFAULT_SMOOTHNESS_TOLERANCE) {
|
|
675
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
676
|
+
if (!segments || !Array.isArray(segments)) {
|
|
677
|
+
throw new Error('findKinks: segments must be an array');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const { kinks } = isPathSmooth(segments, tolerance);
|
|
681
|
+
|
|
682
|
+
// Convert to path parameter
|
|
683
|
+
return kinks.map((k, i) => ({
|
|
684
|
+
segmentIndex: k.segmentIndex,
|
|
685
|
+
globalT: k.segmentIndex + 1, // At junction between segments
|
|
686
|
+
angle: k.angle,
|
|
687
|
+
angleRadians: k.angle,
|
|
688
|
+
angleDegrees: k.angle.times(180).div(PI)
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ============================================================================
|
|
693
|
+
// BOUNDING BOX FOR PATH
|
|
694
|
+
// ============================================================================
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Compute bounding box for entire path.
|
|
698
|
+
*
|
|
699
|
+
* @param {Array} segments - Array of Bezier segments
|
|
700
|
+
* @returns {{xmin: Decimal, xmax: Decimal, ymin: Decimal, ymax: Decimal}}
|
|
701
|
+
*/
|
|
702
|
+
export function pathBoundingBox(segments) {
|
|
703
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
704
|
+
if (!segments || !Array.isArray(segments)) {
|
|
705
|
+
throw new Error('pathBoundingBox: segments must be an array');
|
|
706
|
+
}
|
|
707
|
+
if (segments.length === 0) {
|
|
708
|
+
return { xmin: D(0), xmax: D(0), ymin: D(0), ymax: D(0) };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let xmin = new Decimal(Infinity);
|
|
712
|
+
let xmax = new Decimal(-Infinity);
|
|
713
|
+
let ymin = new Decimal(Infinity);
|
|
714
|
+
let ymax = new Decimal(-Infinity);
|
|
715
|
+
|
|
716
|
+
for (const pts of segments) {
|
|
717
|
+
const bbox = bezierBoundingBox(pts);
|
|
718
|
+
xmin = Decimal.min(xmin, bbox.xmin);
|
|
719
|
+
xmax = Decimal.max(xmax, bbox.xmax);
|
|
720
|
+
ymin = Decimal.min(ymin, bbox.ymin);
|
|
721
|
+
ymax = Decimal.max(ymax, bbox.ymax);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return { xmin, xmax, ymin, ymax };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Check if two path bounding boxes overlap.
|
|
729
|
+
*
|
|
730
|
+
* @param {Object} bbox1 - First bounding box
|
|
731
|
+
* @param {Object} bbox2 - Second bounding box
|
|
732
|
+
* @returns {boolean}
|
|
733
|
+
*/
|
|
734
|
+
export function boundingBoxesOverlap(bbox1, bbox2) {
|
|
735
|
+
// INPUT VALIDATION
|
|
736
|
+
// WHY: Prevent cryptic errors from undefined/null bounding boxes
|
|
737
|
+
if (!bbox1 || !bbox2) {
|
|
738
|
+
throw new Error('boundingBoxesOverlap: both bounding boxes are required');
|
|
739
|
+
}
|
|
740
|
+
if (bbox1.xmin === undefined || bbox1.xmax === undefined ||
|
|
741
|
+
bbox1.ymin === undefined || bbox1.ymax === undefined) {
|
|
742
|
+
throw new Error('boundingBoxesOverlap: bbox1 must have xmin, xmax, ymin, ymax');
|
|
743
|
+
}
|
|
744
|
+
if (bbox2.xmin === undefined || bbox2.xmax === undefined ||
|
|
745
|
+
bbox2.ymin === undefined || bbox2.ymax === undefined) {
|
|
746
|
+
throw new Error('boundingBoxesOverlap: bbox2 must have xmin, xmax, ymin, ymax');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return !(bbox1.xmax.lt(bbox2.xmin) ||
|
|
750
|
+
bbox1.xmin.gt(bbox2.xmax) ||
|
|
751
|
+
bbox1.ymax.lt(bbox2.ymin) ||
|
|
752
|
+
bbox1.ymin.gt(bbox2.ymax));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ============================================================================
|
|
756
|
+
// PATH LENGTH
|
|
757
|
+
// ============================================================================
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Compute total length of a path.
|
|
761
|
+
*
|
|
762
|
+
* @param {Array} segments - Array of Bezier segments
|
|
763
|
+
* @param {Object} [options] - Arc length options
|
|
764
|
+
* @returns {Decimal} Total path length
|
|
765
|
+
*/
|
|
766
|
+
export function pathLength(segments, options = {}) {
|
|
767
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
768
|
+
if (!segments || !Array.isArray(segments)) {
|
|
769
|
+
throw new Error('pathLength: segments must be an array');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let total = D(0);
|
|
773
|
+
|
|
774
|
+
for (const pts of segments) {
|
|
775
|
+
total = total.plus(arcLength(pts, 0, 1, options));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return total;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ============================================================================
|
|
782
|
+
// VERIFICATION (INVERSE OPERATIONS)
|
|
783
|
+
// ============================================================================
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Verify path area by comparing with shoelace formula for approximated polygon.
|
|
787
|
+
* Two independent methods should produce similar results.
|
|
788
|
+
*
|
|
789
|
+
* @param {Array} segments - Path segments
|
|
790
|
+
* @param {number} [samples=100] - Samples per segment for polygon approximation
|
|
791
|
+
* @param {number|string|Decimal} [tolerance='1e-5'] - Relative error tolerance
|
|
792
|
+
* @returns {{valid: boolean, greenArea: Decimal, shoelaceArea: Decimal, relativeError: Decimal}}
|
|
793
|
+
*/
|
|
794
|
+
export function verifyPathArea(segments, samples = 100, tolerance = '1e-5') {
|
|
795
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
796
|
+
if (!segments || !Array.isArray(segments)) {
|
|
797
|
+
throw new Error('verifyPathArea: segments must be an array');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const tol = D(tolerance);
|
|
801
|
+
|
|
802
|
+
// Method 1: Green's theorem (main implementation)
|
|
803
|
+
const greenArea = pathArea(segments);
|
|
804
|
+
|
|
805
|
+
// Method 2: Shoelace formula on sampled polygon
|
|
806
|
+
const polygon = [];
|
|
807
|
+
for (const pts of segments) {
|
|
808
|
+
for (let i = 0; i <= samples; i++) {
|
|
809
|
+
const t = D(i).div(samples);
|
|
810
|
+
const [x, y] = bezierPoint(pts, t);
|
|
811
|
+
// Avoid duplicates at segment boundaries
|
|
812
|
+
if (i === 0 && polygon.length > 0) continue;
|
|
813
|
+
polygon.push([x, y]);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Shoelace formula
|
|
818
|
+
let shoelaceArea = D(0);
|
|
819
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
820
|
+
const j = (i + 1) % polygon.length;
|
|
821
|
+
const [x1, y1] = polygon[i];
|
|
822
|
+
const [x2, y2] = polygon[j];
|
|
823
|
+
shoelaceArea = shoelaceArea.plus(x1.times(y2).minus(x2.times(y1)));
|
|
824
|
+
}
|
|
825
|
+
shoelaceArea = shoelaceArea.div(2);
|
|
826
|
+
|
|
827
|
+
const absGreen = greenArea.abs();
|
|
828
|
+
const absShoelace = shoelaceArea.abs();
|
|
829
|
+
|
|
830
|
+
let relativeError;
|
|
831
|
+
// WHY: Use named constant to avoid division by near-zero values
|
|
832
|
+
const AREA_ZERO_THRESHOLD = new Decimal('1e-30');
|
|
833
|
+
if (absGreen.gt(AREA_ZERO_THRESHOLD)) {
|
|
834
|
+
relativeError = absGreen.minus(absShoelace).abs().div(absGreen);
|
|
835
|
+
} else {
|
|
836
|
+
relativeError = absGreen.minus(absShoelace).abs();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
valid: relativeError.lte(tol),
|
|
841
|
+
greenArea,
|
|
842
|
+
shoelaceArea,
|
|
843
|
+
relativeError,
|
|
844
|
+
sameSign: greenArea.isNegative() === shoelaceArea.isNegative()
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Verify closest point by checking it satisfies the perpendicularity condition.
|
|
850
|
+
* At the closest point, the vector from query to curve should be perpendicular to tangent.
|
|
851
|
+
*
|
|
852
|
+
* @param {Array} segments - Path segments
|
|
853
|
+
* @param {Array} queryPoint - Query point [x, y]
|
|
854
|
+
* @param {number|string|Decimal} [tolerance='1e-10'] - Perpendicularity tolerance
|
|
855
|
+
* @returns {{valid: boolean, closestPoint: Object, dotProduct: Decimal, isEndpoint: boolean}}
|
|
856
|
+
*/
|
|
857
|
+
export function verifyClosestPoint(segments, queryPoint, tolerance = '1e-10') {
|
|
858
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
859
|
+
if (!segments || !Array.isArray(segments)) {
|
|
860
|
+
throw new Error('verifyClosestPoint: segments must be an array');
|
|
861
|
+
}
|
|
862
|
+
if (!queryPoint || !Array.isArray(queryPoint) || queryPoint.length < 2) {
|
|
863
|
+
throw new Error('verifyClosestPoint: queryPoint must be an array [x, y]');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const tol = D(tolerance);
|
|
867
|
+
const qx = D(queryPoint[0]);
|
|
868
|
+
const qy = D(queryPoint[1]);
|
|
869
|
+
|
|
870
|
+
const result = closestPointOnPath(segments, queryPoint);
|
|
871
|
+
const { point, segmentIndex, t } = result;
|
|
872
|
+
|
|
873
|
+
const [cx, cy] = [D(point[0]), D(point[1])];
|
|
874
|
+
const pts = segments[segmentIndex];
|
|
875
|
+
|
|
876
|
+
// Vector from closest point to query point
|
|
877
|
+
const vx = qx.minus(cx);
|
|
878
|
+
const vy = qy.minus(cy);
|
|
879
|
+
|
|
880
|
+
// Tangent at closest point
|
|
881
|
+
const [tx, ty] = bezierTangent(pts, t);
|
|
882
|
+
|
|
883
|
+
// Dot product (should be 0 if perpendicular)
|
|
884
|
+
const dotProduct = vx.times(tx).plus(vy.times(ty));
|
|
885
|
+
|
|
886
|
+
// WHY: Check if at endpoint (where perpendicularity may not hold)
|
|
887
|
+
// Use a small threshold to determine if t is effectively 0 or 1
|
|
888
|
+
const ENDPOINT_THRESHOLD = new Decimal('1e-10');
|
|
889
|
+
const isEndpoint = t.lt(ENDPOINT_THRESHOLD) || t.gt(D(1).minus(ENDPOINT_THRESHOLD));
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
valid: dotProduct.abs().lte(tol) || isEndpoint,
|
|
893
|
+
closestPoint: result,
|
|
894
|
+
dotProduct,
|
|
895
|
+
isEndpoint,
|
|
896
|
+
vectorToQuery: [vx, vy],
|
|
897
|
+
tangent: [tx, ty]
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Verify farthest point actually maximizes distance.
|
|
903
|
+
* Sample many points and verify none are farther.
|
|
904
|
+
*
|
|
905
|
+
* @param {Array} segments - Path segments
|
|
906
|
+
* @param {Array} queryPoint - Query point [x, y]
|
|
907
|
+
* @param {number} [samples=200] - Sample points to check
|
|
908
|
+
* @returns {{valid: boolean, farthestPoint: Object, maxSampledDistance: Decimal, foundDistance: Decimal}}
|
|
909
|
+
*/
|
|
910
|
+
export function verifyFarthestPoint(segments, queryPoint, samples = 200) {
|
|
911
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
912
|
+
if (!segments || !Array.isArray(segments)) {
|
|
913
|
+
throw new Error('verifyFarthestPoint: segments must be an array');
|
|
914
|
+
}
|
|
915
|
+
if (!queryPoint || !Array.isArray(queryPoint) || queryPoint.length < 2) {
|
|
916
|
+
throw new Error('verifyFarthestPoint: queryPoint must be an array [x, y]');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const qx = D(queryPoint[0]);
|
|
920
|
+
const qy = D(queryPoint[1]);
|
|
921
|
+
|
|
922
|
+
const result = farthestPointOnPath(segments, queryPoint);
|
|
923
|
+
const foundDistance = result.distance;
|
|
924
|
+
|
|
925
|
+
// Sample all segments to find maximum distance
|
|
926
|
+
let maxSampledDistance = D(0);
|
|
927
|
+
|
|
928
|
+
for (const pts of segments) {
|
|
929
|
+
for (let i = 0; i <= samples; i++) {
|
|
930
|
+
const t = D(i).div(samples);
|
|
931
|
+
const [x, y] = bezierPoint(pts, t);
|
|
932
|
+
const dist = qx.minus(x).pow(2).plus(qy.minus(y).pow(2)).sqrt();
|
|
933
|
+
|
|
934
|
+
if (dist.gt(maxSampledDistance)) {
|
|
935
|
+
maxSampledDistance = dist;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// WHY: Found distance should be >= max sampled distance (or very close due to sampling resolution)
|
|
941
|
+
// The old logic used 0.999 which INCORRECTLY allowed found to be 0.1% SMALLER than max sampled
|
|
942
|
+
// This defeats the purpose of verification - we want to ensure the found point is actually the farthest
|
|
943
|
+
// Instead, we check that foundDistance is at least as large as maxSampledDistance
|
|
944
|
+
// with a small tolerance for numerical precision (not sampling error, but floating point rounding)
|
|
945
|
+
const valid = foundDistance.gte(maxSampledDistance.minus(FARTHEST_POINT_NUMERICAL_TOLERANCE));
|
|
946
|
+
|
|
947
|
+
return {
|
|
948
|
+
valid,
|
|
949
|
+
farthestPoint: result,
|
|
950
|
+
maxSampledDistance,
|
|
951
|
+
foundDistance
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Verify point-in-path by testing nearby points.
|
|
957
|
+
* If point is inside, points slightly toward center should also be inside.
|
|
958
|
+
* If point is outside, points slightly away should also be outside.
|
|
959
|
+
*
|
|
960
|
+
* @param {Array} segments - Path segments (closed)
|
|
961
|
+
* @param {Array} testPoint - Test point [x, y]
|
|
962
|
+
* @returns {{valid: boolean, result: Object, consistentWithNeighbors: boolean}}
|
|
963
|
+
*/
|
|
964
|
+
export function verifyPointInPath(segments, testPoint) {
|
|
965
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
966
|
+
if (!segments || !Array.isArray(segments)) {
|
|
967
|
+
throw new Error('verifyPointInPath: segments must be an array');
|
|
968
|
+
}
|
|
969
|
+
if (!testPoint || !Array.isArray(testPoint) || testPoint.length < 2) {
|
|
970
|
+
throw new Error('verifyPointInPath: testPoint must be an array [x, y]');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const result = pointInPath(segments, testPoint);
|
|
974
|
+
|
|
975
|
+
if (result.onBoundary) {
|
|
976
|
+
return { valid: true, result, consistentWithNeighbors: true };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Compute centroid of path for direction reference
|
|
980
|
+
let sumX = D(0);
|
|
981
|
+
let sumY = D(0);
|
|
982
|
+
let count = 0;
|
|
983
|
+
|
|
984
|
+
for (const pts of segments) {
|
|
985
|
+
const [x, y] = bezierPoint(pts, 0.5);
|
|
986
|
+
sumX = sumX.plus(x);
|
|
987
|
+
sumY = sumY.plus(y);
|
|
988
|
+
count++;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const centroidX = sumX.div(count);
|
|
992
|
+
const centroidY = sumY.div(count);
|
|
993
|
+
|
|
994
|
+
const px = D(testPoint[0]);
|
|
995
|
+
const py = D(testPoint[1]);
|
|
996
|
+
|
|
997
|
+
// Direction from point to centroid
|
|
998
|
+
const dx = centroidX.minus(px);
|
|
999
|
+
const dy = centroidY.minus(py);
|
|
1000
|
+
const len = dx.pow(2).plus(dy.pow(2)).sqrt();
|
|
1001
|
+
|
|
1002
|
+
// WHY: Use named constant instead of magic number for clarity
|
|
1003
|
+
if (len.lt(CENTROID_ZERO_THRESHOLD)) {
|
|
1004
|
+
return { valid: true, result, consistentWithNeighbors: true };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const epsilon = NEIGHBOR_TEST_EPSILON;
|
|
1008
|
+
const unitDx = dx.div(len).times(epsilon);
|
|
1009
|
+
const unitDy = dy.div(len).times(epsilon);
|
|
1010
|
+
|
|
1011
|
+
// Test point slightly toward centroid
|
|
1012
|
+
const towardCentroid = pointInPath(segments, [px.plus(unitDx), py.plus(unitDy)]);
|
|
1013
|
+
|
|
1014
|
+
// Test point slightly away from centroid
|
|
1015
|
+
const awayFromCentroid = pointInPath(segments, [px.minus(unitDx), py.minus(unitDy)]);
|
|
1016
|
+
|
|
1017
|
+
// If inside, moving toward centroid should stay inside
|
|
1018
|
+
// If outside, moving toward centroid should stay outside or become inside (not suddenly outside)
|
|
1019
|
+
let consistentWithNeighbors = true;
|
|
1020
|
+
|
|
1021
|
+
if (result.inside) {
|
|
1022
|
+
// Inside: toward center should also be inside
|
|
1023
|
+
if (!towardCentroid.inside && !towardCentroid.onBoundary) {
|
|
1024
|
+
consistentWithNeighbors = false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return {
|
|
1029
|
+
valid: consistentWithNeighbors,
|
|
1030
|
+
result,
|
|
1031
|
+
consistentWithNeighbors,
|
|
1032
|
+
towardCentroid,
|
|
1033
|
+
awayFromCentroid
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Verify bounding box contains all path points.
|
|
1039
|
+
*
|
|
1040
|
+
* @param {Array} segments - Path segments
|
|
1041
|
+
* @param {number} [samples=100] - Samples per segment
|
|
1042
|
+
* @returns {{valid: boolean, bbox: Object, allInside: boolean, errors: string[]}}
|
|
1043
|
+
*/
|
|
1044
|
+
export function verifyPathBoundingBox(segments, samples = 100) {
|
|
1045
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
1046
|
+
if (!segments || !Array.isArray(segments)) {
|
|
1047
|
+
throw new Error('verifyPathBoundingBox: segments must be an array');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const bbox = pathBoundingBox(segments);
|
|
1051
|
+
const errors = [];
|
|
1052
|
+
let allInside = true;
|
|
1053
|
+
|
|
1054
|
+
const tolerance = new Decimal('1e-40');
|
|
1055
|
+
|
|
1056
|
+
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
|
|
1057
|
+
const pts = segments[segIdx];
|
|
1058
|
+
|
|
1059
|
+
for (let i = 0; i <= samples; i++) {
|
|
1060
|
+
const t = D(i).div(samples);
|
|
1061
|
+
const [x, y] = bezierPoint(pts, t);
|
|
1062
|
+
|
|
1063
|
+
if (x.lt(bbox.xmin.minus(tolerance)) || x.gt(bbox.xmax.plus(tolerance))) {
|
|
1064
|
+
errors.push(`Segment ${segIdx}, t=${t}: x=${x} outside [${bbox.xmin}, ${bbox.xmax}]`);
|
|
1065
|
+
allInside = false;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (y.lt(bbox.ymin.minus(tolerance)) || y.gt(bbox.ymax.plus(tolerance))) {
|
|
1069
|
+
errors.push(`Segment ${segIdx}, t=${t}: y=${y} outside [${bbox.ymin}, ${bbox.ymax}]`);
|
|
1070
|
+
allInside = false;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return {
|
|
1076
|
+
valid: errors.length === 0,
|
|
1077
|
+
bbox,
|
|
1078
|
+
allInside,
|
|
1079
|
+
errors
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Verify path continuity by checking endpoint distances.
|
|
1085
|
+
*
|
|
1086
|
+
* @param {Array} segments - Path segments
|
|
1087
|
+
* @returns {{valid: boolean, continuous: boolean, gaps: Array, maxGap: Decimal}}
|
|
1088
|
+
*/
|
|
1089
|
+
export function verifyPathContinuity(segments) {
|
|
1090
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
1091
|
+
if (!segments || !Array.isArray(segments)) {
|
|
1092
|
+
throw new Error('verifyPathContinuity: segments must be an array');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const { continuous, gaps } = isPathContinuous(segments);
|
|
1096
|
+
|
|
1097
|
+
let maxGap = D(0);
|
|
1098
|
+
for (const gap of gaps) {
|
|
1099
|
+
if (gap.gap.gt(maxGap)) {
|
|
1100
|
+
maxGap = gap.gap;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Also verify each segment has valid control points
|
|
1105
|
+
let allValid = true;
|
|
1106
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1107
|
+
const pts = segments[i];
|
|
1108
|
+
if (!Array.isArray(pts) || pts.length < 2) {
|
|
1109
|
+
allValid = false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
valid: allValid,
|
|
1115
|
+
continuous,
|
|
1116
|
+
gaps,
|
|
1117
|
+
maxGap
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Verify path length by comparing with sum of segment chord lengths.
|
|
1123
|
+
* Arc length should be >= sum of chord lengths.
|
|
1124
|
+
*
|
|
1125
|
+
* @param {Array} segments - Path segments
|
|
1126
|
+
* @returns {{valid: boolean, arcLength: Decimal, chordSum: Decimal, ratio: Decimal}}
|
|
1127
|
+
*/
|
|
1128
|
+
export function verifyPathLength(segments) {
|
|
1129
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
1130
|
+
if (!segments || !Array.isArray(segments)) {
|
|
1131
|
+
throw new Error('verifyPathLength: segments must be an array');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const totalArcLength = pathLength(segments);
|
|
1135
|
+
|
|
1136
|
+
let chordSum = D(0);
|
|
1137
|
+
for (const pts of segments) {
|
|
1138
|
+
const [x0, y0] = [D(pts[0][0]), D(pts[0][1])];
|
|
1139
|
+
const [xn, yn] = [D(pts[pts.length - 1][0]), D(pts[pts.length - 1][1])];
|
|
1140
|
+
const chord = xn.minus(x0).pow(2).plus(yn.minus(y0).pow(2)).sqrt();
|
|
1141
|
+
chordSum = chordSum.plus(chord);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const ratio = chordSum.gt(0) ? totalArcLength.div(chordSum) : D(1);
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
valid: totalArcLength.gte(chordSum),
|
|
1148
|
+
arcLength: totalArcLength,
|
|
1149
|
+
chordSum,
|
|
1150
|
+
ratio // Should be >= 1
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Comprehensive verification of all path analysis functions.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {Array} segments - Path segments
|
|
1158
|
+
* @param {Object} [options] - Options
|
|
1159
|
+
* @returns {{valid: boolean, results: Object}}
|
|
1160
|
+
*/
|
|
1161
|
+
export function verifyAllPathFunctions(segments, options = {}) {
|
|
1162
|
+
// WHY: Validate input to prevent undefined behavior and provide clear error messages
|
|
1163
|
+
if (!segments || !Array.isArray(segments)) {
|
|
1164
|
+
throw new Error('verifyAllPathFunctions: segments must be an array');
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const results = {};
|
|
1168
|
+
|
|
1169
|
+
// 1. Verify area
|
|
1170
|
+
results.area = verifyPathArea(segments);
|
|
1171
|
+
|
|
1172
|
+
// 2. Verify bounding box
|
|
1173
|
+
results.boundingBox = verifyPathBoundingBox(segments);
|
|
1174
|
+
|
|
1175
|
+
// 3. Verify continuity
|
|
1176
|
+
results.continuity = verifyPathContinuity(segments);
|
|
1177
|
+
|
|
1178
|
+
// 4. Verify length
|
|
1179
|
+
results.length = verifyPathLength(segments);
|
|
1180
|
+
|
|
1181
|
+
// 5. Verify closest point (use centroid as test point)
|
|
1182
|
+
const bbox = pathBoundingBox(segments);
|
|
1183
|
+
const centerX = bbox.xmin.plus(bbox.xmax).div(2);
|
|
1184
|
+
const centerY = bbox.ymin.plus(bbox.ymax).div(2);
|
|
1185
|
+
results.closestPoint = verifyClosestPoint(segments, [centerX, centerY]);
|
|
1186
|
+
|
|
1187
|
+
// 6. Verify farthest point
|
|
1188
|
+
results.farthestPoint = verifyFarthestPoint(segments, [centerX, centerY]);
|
|
1189
|
+
|
|
1190
|
+
// 7. Verify point-in-path (only for closed paths)
|
|
1191
|
+
if (isPathClosed(segments)) {
|
|
1192
|
+
results.pointInPath = verifyPointInPath(segments, [centerX, centerY]);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const allValid = Object.values(results).every(r => r.valid);
|
|
1196
|
+
|
|
1197
|
+
return {
|
|
1198
|
+
valid: allValid,
|
|
1199
|
+
results
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ============================================================================
|
|
1204
|
+
// EXPORTS
|
|
1205
|
+
// ============================================================================
|
|
1206
|
+
|
|
1207
|
+
export default {
|
|
1208
|
+
// Area
|
|
1209
|
+
pathArea,
|
|
1210
|
+
pathAbsoluteArea,
|
|
1211
|
+
|
|
1212
|
+
// Closest/Farthest point
|
|
1213
|
+
closestPointOnPath,
|
|
1214
|
+
farthestPointOnPath,
|
|
1215
|
+
|
|
1216
|
+
// Point-in-path
|
|
1217
|
+
pointInPath,
|
|
1218
|
+
|
|
1219
|
+
// Continuity
|
|
1220
|
+
isPathClosed,
|
|
1221
|
+
isPathContinuous,
|
|
1222
|
+
isPathSmooth,
|
|
1223
|
+
findKinks,
|
|
1224
|
+
|
|
1225
|
+
// Bounding box
|
|
1226
|
+
pathBoundingBox,
|
|
1227
|
+
boundingBoxesOverlap,
|
|
1228
|
+
|
|
1229
|
+
// Length
|
|
1230
|
+
pathLength,
|
|
1231
|
+
|
|
1232
|
+
// Verification (inverse operations)
|
|
1233
|
+
verifyPathArea,
|
|
1234
|
+
verifyClosestPoint,
|
|
1235
|
+
verifyFarthestPoint,
|
|
1236
|
+
verifyPointInPath,
|
|
1237
|
+
verifyPathBoundingBox,
|
|
1238
|
+
verifyPathContinuity,
|
|
1239
|
+
verifyPathLength,
|
|
1240
|
+
verifyAllPathFunctions
|
|
1241
|
+
};
|