@emasoft/svg-matrix 1.0.19 → 1.0.21

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.
@@ -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
+ };