@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.
@@ -0,0 +1,940 @@
1
+ /**
2
+ * @fileoverview Arbitrary-Precision Arc Length Computation
3
+ *
4
+ * Provides high-precision arc length calculations and inverse arc length
5
+ * (finding parameter t for a given arc length) using adaptive quadrature.
6
+ *
7
+ * @module arc-length
8
+ * @version 1.0.0
9
+ *
10
+ * Key features:
11
+ * - Adaptive Gauss-Legendre quadrature with arbitrary precision
12
+ * - Inverse arc length using Newton-Raphson with controlled convergence
13
+ * - 10^65x better precision than float64 implementations
14
+ */
15
+
16
+ import Decimal from 'decimal.js';
17
+ import { bezierDerivative, bezierPoint } from './bezier-analysis.js';
18
+
19
+ // Ensure high precision
20
+ Decimal.set({ precision: 80 });
21
+
22
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
23
+
24
+ // ============================================================================
25
+ // GAUSS-LEGENDRE QUADRATURE NODES AND WEIGHTS
26
+ // ============================================================================
27
+
28
+ /**
29
+ * Precomputed Gauss-Legendre nodes and weights for various orders.
30
+ * These are exact to 50 digits for high-precision integration.
31
+ */
32
+ const GAUSS_LEGENDRE = {
33
+ // 5-point rule (sufficient for most cases)
34
+ 5: {
35
+ nodes: [
36
+ '-0.90617984593866399279762687829939296512565191076',
37
+ '-0.53846931010568309103631442070020880496728660690',
38
+ '0',
39
+ '0.53846931010568309103631442070020880496728660690',
40
+ '0.90617984593866399279762687829939296512565191076'
41
+ ],
42
+ weights: [
43
+ '0.23692688505618908751426404071991736264326000221',
44
+ '0.47862867049936646804129151483563819291229555035',
45
+ '0.56888888888888888888888888888888888888888888889',
46
+ '0.47862867049936646804129151483563819291229555035',
47
+ '0.23692688505618908751426404071991736264326000221'
48
+ ]
49
+ },
50
+ // 10-point rule (for higher accuracy)
51
+ 10: {
52
+ nodes: [
53
+ '-0.97390652851717172007796401208445205342826994669',
54
+ '-0.86506336668898451073209668842349304852754301497',
55
+ '-0.67940956829902440623432736511487357576929471183',
56
+ '-0.43339539412924719079926594316578416220007183765',
57
+ '-0.14887433898163121088482600112971998461756485942',
58
+ '0.14887433898163121088482600112971998461756485942',
59
+ '0.43339539412924719079926594316578416220007183765',
60
+ '0.67940956829902440623432736511487357576929471183',
61
+ '0.86506336668898451073209668842349304852754301497',
62
+ '0.97390652851717172007796401208445205342826994669'
63
+ ],
64
+ weights: [
65
+ '0.06667134430868813759356880989333179285786483432',
66
+ '0.14945134915058059314577633965769733240255644326',
67
+ '0.21908636251598204399553493422816219682140867715',
68
+ '0.26926671930999635509122692156946935285975993846',
69
+ '0.29552422471475287017389299465133832942104671702',
70
+ '0.29552422471475287017389299465133832942104671702',
71
+ '0.26926671930999635509122692156946935285975993846',
72
+ '0.21908636251598204399553493422816219682140867715',
73
+ '0.14945134915058059314577633965769733240255644326',
74
+ '0.06667134430868813759356880989333179285786483432'
75
+ ]
76
+ }
77
+ };
78
+
79
+ // ============================================================================
80
+ // NUMERICAL CONSTANTS (documented magic numbers)
81
+ // ============================================================================
82
+
83
+ /**
84
+ * Threshold for near-zero speed detection (cusp handling in Newton's method).
85
+ * WHY: Speeds below this threshold indicate cusps or near-singular points where
86
+ * the curve derivative is essentially zero. At such points, Newton's method
87
+ * would divide by near-zero, causing instability. We switch to bisection instead.
88
+ */
89
+ const NEAR_ZERO_SPEED_THRESHOLD = new Decimal('1e-60');
90
+
91
+ /**
92
+ * Default tolerance for arc length computation.
93
+ * WHY: This tolerance determines when adaptive quadrature stops subdividing.
94
+ * The value 1e-30 provides extremely high precision (suitable for arbitrary
95
+ * precision arithmetic) while still converging in reasonable time.
96
+ */
97
+ const DEFAULT_ARC_LENGTH_TOLERANCE = '1e-30';
98
+
99
+ /**
100
+ * Subdivision convergence threshold.
101
+ * WHY: Used in adaptive quadrature to determine if subdivision has converged
102
+ * by comparing 5-point and 10-point Gauss-Legendre results. When results
103
+ * differ by less than this, we accept the higher-order result.
104
+ */
105
+ const SUBDIVISION_CONVERGENCE_THRESHOLD = new Decimal('1e-15');
106
+
107
+ /**
108
+ * Tolerance for table roundtrip verification.
109
+ * WHY: When verifying arc length tables, we check if lookup->compute->verify
110
+ * produces consistent results. This tolerance accounts for interpolation error
111
+ * in table-based lookups.
112
+ */
113
+ const TABLE_ROUNDTRIP_TOLERANCE = new Decimal('1e-20');
114
+
115
+ /**
116
+ * Maximum relative error for subdivision comparison verification.
117
+ * WHY: When comparing adaptive quadrature vs subdivision methods, this tolerance
118
+ * accounts for the inherent approximation in chord-based subdivision.
119
+ */
120
+ const SUBDIVISION_COMPARISON_TOLERANCE = '1e-20';
121
+
122
+ // ============================================================================
123
+ // ARC LENGTH COMPUTATION
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Compute arc length of a Bezier curve using adaptive Gauss-Legendre quadrature.
128
+ *
129
+ * The arc length integral: L = integral from t0 to t1 of |B'(t)| dt
130
+ * where |B'(t)| = sqrt(x'(t)^2 + y'(t)^2)
131
+ *
132
+ * @param {Array} points - Bezier control points [[x,y], ...]
133
+ * @param {number|string|Decimal} [t0=0] - Start parameter
134
+ * @param {number|string|Decimal} [t1=1] - End parameter
135
+ * @param {Object} [options] - Options
136
+ * @param {string|number|Decimal} [options.tolerance='1e-30'] - Error tolerance
137
+ * @param {number} [options.maxDepth=50] - Maximum recursion depth
138
+ * @param {number} [options.minDepth=3] - Minimum recursion depth
139
+ * @returns {Decimal} Arc length
140
+ *
141
+ * @example
142
+ * const length = arcLength(cubicPoints);
143
+ * const partialLength = arcLength(cubicPoints, 0, 0.5);
144
+ */
145
+ export function arcLength(points, t0 = 0, t1 = 1, options = {}) {
146
+ // INPUT VALIDATION: Ensure points array is valid
147
+ // WHY: Arc length computation requires evaluating bezierDerivative, which needs
148
+ // at least 2 control points to define a curve. Catching this early prevents
149
+ // cryptic errors deep in the computation.
150
+ if (!points || !Array.isArray(points) || points.length < 2) {
151
+ throw new Error('arcLength: points must be an array with at least 2 control points');
152
+ }
153
+
154
+ const {
155
+ tolerance = DEFAULT_ARC_LENGTH_TOLERANCE,
156
+ maxDepth = 50,
157
+ minDepth = 3
158
+ } = options;
159
+
160
+ const t0D = D(t0);
161
+ const t1D = D(t1);
162
+
163
+ // PARAMETER VALIDATION: Handle reversed parameters
164
+ // WHY: Some callers might accidentally pass t0 > t1. Rather than silently
165
+ // returning negative arc length or crashing, we swap them.
166
+ if (t0D.gt(t1D)) {
167
+ // Swap parameters - arc length from t1 to t0 equals arc length from t0 to t1
168
+ return arcLength(points, t1, t0, options);
169
+ }
170
+
171
+ const tol = D(tolerance);
172
+
173
+ // Use adaptive quadrature
174
+ return adaptiveQuadrature(
175
+ t => speedAtT(points, t),
176
+ t0D,
177
+ t1D,
178
+ tol,
179
+ maxDepth,
180
+ minDepth,
181
+ 0
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Compute speed |B'(t)| at parameter t.
187
+ *
188
+ * WHY: Speed is the magnitude of the velocity vector (first derivative).
189
+ * This is the integrand for arc length: L = integral of |B'(t)| dt.
190
+ *
191
+ * @param {Array} points - Control points
192
+ * @param {Decimal} t - Parameter
193
+ * @returns {Decimal} Speed (magnitude of derivative)
194
+ */
195
+ function speedAtT(points, t) {
196
+ const [dx, dy] = bezierDerivative(points, t, 1);
197
+ const speedSquared = dx.times(dx).plus(dy.times(dy));
198
+
199
+ // NUMERICAL STABILITY: Handle near-zero speed (cusp) gracefully
200
+ // WHY: At cusps or inflection points, the derivative may be very small or zero.
201
+ // We return the actual computed value (sqrt of speedSquared) rather than
202
+ // special-casing zero, because the caller (Newton's method in inverseArcLength)
203
+ // already handles near-zero speeds appropriately by switching to bisection.
204
+ // This approach maintains accuracy for all curve geometries.
205
+ return speedSquared.sqrt();
206
+ }
207
+
208
+ /**
209
+ * Adaptive quadrature using Gauss-Legendre with interval subdivision.
210
+ *
211
+ * Subdivides intervals where the integrand varies significantly,
212
+ * ensuring accuracy while minimizing computation.
213
+ *
214
+ * @param {Function} f - Function to integrate
215
+ * @param {Decimal} a - Start of interval
216
+ * @param {Decimal} b - End of interval
217
+ * @param {Decimal} tol - Error tolerance
218
+ * @param {number} maxDepth - Maximum recursion depth
219
+ * @param {number} minDepth - Minimum recursion depth
220
+ * @param {number} depth - Current depth
221
+ * @returns {Decimal} Integral value
222
+ */
223
+ function adaptiveQuadrature(f, a, b, tol, maxDepth, minDepth, depth) {
224
+ // Compute integral using 5-point and 10-point rules
225
+ const I5 = gaussLegendre(f, a, b, 5);
226
+ const I10 = gaussLegendre(f, a, b, 10);
227
+
228
+ const error = I5.minus(I10).abs();
229
+
230
+ // Check convergence
231
+ if (depth >= minDepth && (error.lt(tol) || depth >= maxDepth)) {
232
+ return I10;
233
+ }
234
+
235
+ // Subdivide
236
+ const mid = a.plus(b).div(2);
237
+ const halfTol = tol.div(2);
238
+
239
+ const leftIntegral = adaptiveQuadrature(f, a, mid, halfTol, maxDepth, minDepth, depth + 1);
240
+ const rightIntegral = adaptiveQuadrature(f, mid, b, halfTol, maxDepth, minDepth, depth + 1);
241
+
242
+ return leftIntegral.plus(rightIntegral);
243
+ }
244
+
245
+ /**
246
+ * Gauss-Legendre quadrature.
247
+ *
248
+ * Transforms integral from [a,b] to [-1,1] and applies formula:
249
+ * integral ≈ (b-a)/2 * sum of (weight[i] * f(transformed_node[i]))
250
+ *
251
+ * @param {Function} f - Function to integrate
252
+ * @param {Decimal} a - Start of interval
253
+ * @param {Decimal} b - End of interval
254
+ * @param {number} order - Number of points (5 or 10)
255
+ * @returns {Decimal} Integral approximation
256
+ */
257
+ function gaussLegendre(f, a, b, order) {
258
+ const gl = GAUSS_LEGENDRE[order];
259
+ const halfWidth = b.minus(a).div(2);
260
+ const center = a.plus(b).div(2);
261
+
262
+ let sum = D(0);
263
+
264
+ for (let i = 0; i < order; i++) {
265
+ const node = D(gl.nodes[i]);
266
+ const weight = D(gl.weights[i]);
267
+
268
+ // Transform node from [-1, 1] to [a, b]
269
+ const t = center.plus(halfWidth.times(node));
270
+
271
+ // Evaluate function and add weighted contribution
272
+ const fValue = f(t);
273
+ sum = sum.plus(weight.times(fValue));
274
+ }
275
+
276
+ return sum.times(halfWidth);
277
+ }
278
+
279
+ // ============================================================================
280
+ // INVERSE ARC LENGTH
281
+ // ============================================================================
282
+
283
+ /**
284
+ * Find parameter t such that arc length from 0 to t equals targetLength.
285
+ *
286
+ * Uses Newton-Raphson method:
287
+ * - Function: f(t) = arcLength(0, t) - targetLength
288
+ * - Derivative: f'(t) = speed(t)
289
+ * - Update: t_new = t - f(t) / f'(t)
290
+ *
291
+ * @param {Array} points - Bezier control points
292
+ * @param {number|string|Decimal} targetLength - Desired arc length
293
+ * @param {Object} [options] - Options
294
+ * @param {string|number|Decimal} [options.tolerance='1e-30'] - Convergence tolerance
295
+ * @param {number} [options.maxIterations=100] - Maximum Newton iterations
296
+ * @param {string|number|Decimal} [options.lengthTolerance='1e-30'] - Arc length computation tolerance
297
+ * @param {string|number|Decimal} [options.initialT] - Initial guess for t (improves convergence when provided)
298
+ * @returns {{t: Decimal, length: Decimal, iterations: number, converged: boolean}}
299
+ *
300
+ * @example
301
+ * const totalLength = arcLength(points);
302
+ * const { t } = inverseArcLength(points, totalLength.div(2)); // Find midpoint by arc length
303
+ */
304
+ export function inverseArcLength(points, targetLength, options = {}) {
305
+ // INPUT VALIDATION: Ensure points array is valid
306
+ // WHY: inverseArcLength calls arcLength internally, which requires valid points.
307
+ // Catching this early provides clearer error messages to users.
308
+ if (!points || !Array.isArray(points) || points.length < 2) {
309
+ throw new Error('inverseArcLength: points must be an array with at least 2 control points');
310
+ }
311
+
312
+ const {
313
+ tolerance = DEFAULT_ARC_LENGTH_TOLERANCE,
314
+ maxIterations = 100,
315
+ lengthTolerance = DEFAULT_ARC_LENGTH_TOLERANCE,
316
+ initialT
317
+ } = options;
318
+
319
+ const target = D(targetLength);
320
+ const tol = D(tolerance);
321
+ const lengthOpts = { tolerance: lengthTolerance };
322
+
323
+ // FAIL FAST: Negative arc length is mathematically invalid
324
+ // WHY: Arc length is always non-negative by definition (it's an integral of
325
+ // a magnitude). Accepting negative values would be nonsensical and lead to
326
+ // incorrect results or infinite loops in Newton's method.
327
+ if (target.lt(0)) {
328
+ throw new Error('inverseArcLength: targetLength must be non-negative');
329
+ }
330
+
331
+ // Handle edge case: zero length
332
+ if (target.isZero()) {
333
+ return { t: D(0), length: D(0), iterations: 0, converged: true };
334
+ }
335
+
336
+ const totalLength = arcLength(points, 0, 1, lengthOpts);
337
+
338
+ if (target.gte(totalLength)) {
339
+ return { t: D(1), length: totalLength, iterations: 0, converged: true };
340
+ }
341
+
342
+ // Initial guess: use provided initialT or linear approximation
343
+ let t = initialT !== undefined ? D(initialT) : target.div(totalLength);
344
+ // Clamp initial guess to valid range
345
+ if (t.lt(0)) t = D(0);
346
+ if (t.gt(1)) t = D(1);
347
+ let converged = false;
348
+ let iterations = 0;
349
+
350
+ for (let i = 0; i < maxIterations; i++) {
351
+ iterations++;
352
+
353
+ // f(t) = arcLength(0, t) - target
354
+ const currentLength = arcLength(points, 0, t, lengthOpts);
355
+ const f = currentLength.minus(target);
356
+
357
+ // Check convergence
358
+ if (f.abs().lt(tol)) {
359
+ converged = true;
360
+ break;
361
+ }
362
+
363
+ // f'(t) = speed(t)
364
+ const fPrime = speedAtT(points, t);
365
+
366
+ // NUMERICAL STABILITY: Handle near-zero speed (cusps)
367
+ // WHY: At cusps, the curve has zero or near-zero velocity. Newton's method
368
+ // requires division by f'(t), which becomes unstable when f'(t) ≈ 0.
369
+ // We switch to bisection in these cases to ensure robust convergence.
370
+ if (fPrime.lt(NEAR_ZERO_SPEED_THRESHOLD)) {
371
+ // Near-zero speed (cusp), use bisection step
372
+ if (f.isNegative()) {
373
+ t = t.plus(D(1).minus(t).div(2));
374
+ } else {
375
+ t = t.div(2);
376
+ }
377
+ continue;
378
+ }
379
+
380
+ // Newton step
381
+ const delta = f.div(fPrime);
382
+ const tNew = t.minus(delta);
383
+
384
+ // Clamp to [0, 1]
385
+ if (tNew.lt(0)) {
386
+ t = t.div(2);
387
+ } else if (tNew.gt(1)) {
388
+ t = t.plus(D(1).minus(t).div(2));
389
+ } else {
390
+ t = tNew;
391
+ }
392
+
393
+ // Check for convergence by step size
394
+ if (delta.abs().lt(tol)) {
395
+ converged = true;
396
+ break;
397
+ }
398
+ }
399
+
400
+ const finalLength = arcLength(points, 0, t, lengthOpts);
401
+
402
+ return {
403
+ t,
404
+ length: finalLength,
405
+ iterations,
406
+ converged
407
+ };
408
+ }
409
+
410
+ // ============================================================================
411
+ // PATH ARC LENGTH
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Compute total arc length of a path (multiple segments).
416
+ *
417
+ * @param {Array} segments - Array of segments, each being control points array
418
+ * @param {Object} [options] - Options passed to arcLength
419
+ * @returns {Decimal} Total arc length
420
+ */
421
+ export function pathArcLength(segments, options = {}) {
422
+ // INPUT VALIDATION: Ensure segments is a valid array
423
+ // WHY: We need to iterate over segments and call arcLength on each.
424
+ // Catching invalid input early prevents cryptic errors in the loop.
425
+ if (!segments || !Array.isArray(segments)) {
426
+ throw new Error('pathArcLength: segments must be an array');
427
+ }
428
+ if (segments.length === 0) {
429
+ throw new Error('pathArcLength: segments array must not be empty');
430
+ }
431
+
432
+ let total = D(0);
433
+
434
+ for (const segment of segments) {
435
+ // Each segment validation is handled by arcLength itself
436
+ total = total.plus(arcLength(segment, 0, 1, options));
437
+ }
438
+
439
+ return total;
440
+ }
441
+
442
+ /**
443
+ * Find parameter (segment index, t) for a given arc length along a path.
444
+ *
445
+ * @param {Array} segments - Array of segments
446
+ * @param {number|string|Decimal} targetLength - Target arc length from start
447
+ * @param {Object} [options] - Options
448
+ * @returns {{segmentIndex: number, t: Decimal, totalLength: Decimal}}
449
+ */
450
+ export function pathInverseArcLength(segments, targetLength, options = {}) {
451
+ // INPUT VALIDATION: Ensure segments is a valid array
452
+ // WHY: We need to iterate over segments to find which one contains the target length.
453
+ if (!segments || !Array.isArray(segments)) {
454
+ throw new Error('pathInverseArcLength: segments must be an array');
455
+ }
456
+ if (segments.length === 0) {
457
+ throw new Error('pathInverseArcLength: segments array must not be empty');
458
+ }
459
+
460
+ const target = D(targetLength);
461
+
462
+ // FAIL FAST: Negative arc length is invalid
463
+ // WHY: Same reason as inverseArcLength - arc length is non-negative by definition.
464
+ if (target.lt(0)) {
465
+ throw new Error('pathInverseArcLength: targetLength must be non-negative');
466
+ }
467
+
468
+ // EDGE CASE: Zero target length
469
+ // WHY: If target is 0, we're at the start of the first segment
470
+ if (target.isZero()) {
471
+ return {
472
+ segmentIndex: 0,
473
+ t: D(0),
474
+ totalLength: D(0)
475
+ };
476
+ }
477
+
478
+ let accumulated = D(0);
479
+
480
+ for (let i = 0; i < segments.length; i++) {
481
+ const segmentLength = arcLength(segments[i], 0, 1, options);
482
+ const nextAccumulated = accumulated.plus(segmentLength);
483
+
484
+ if (target.lte(nextAccumulated)) {
485
+ // Target is within this segment
486
+ const localTarget = target.minus(accumulated);
487
+ const { t } = inverseArcLength(segments[i], localTarget, options);
488
+
489
+ return {
490
+ segmentIndex: i,
491
+ t,
492
+ totalLength: accumulated.plus(arcLength(segments[i], 0, t, options))
493
+ };
494
+ }
495
+
496
+ accumulated = nextAccumulated;
497
+ }
498
+
499
+ // Target exceeds total length
500
+ return {
501
+ segmentIndex: segments.length - 1,
502
+ t: D(1),
503
+ totalLength: accumulated
504
+ };
505
+ }
506
+
507
+ // ============================================================================
508
+ // PARAMETERIZATION BY ARC LENGTH
509
+ // ============================================================================
510
+
511
+ /**
512
+ * Create a lookup table for arc length parameterization.
513
+ *
514
+ * This allows O(1) approximate lookup of t from arc length,
515
+ * with optional refinement.
516
+ *
517
+ * @param {Array} points - Control points
518
+ * @param {number} [samples=100] - Number of sample points
519
+ * @param {Object} [options] - Arc length options
520
+ * @returns {Object} Lookup table with methods
521
+ */
522
+ export function createArcLengthTable(points, samples = 100, options = {}) {
523
+ // Input validation
524
+ if (!points || points.length < 2) {
525
+ throw new Error('createArcLengthTable: points must have at least 2 control points');
526
+ }
527
+ if (samples < 2) {
528
+ throw new Error('createArcLengthTable: samples must be at least 2 (for binary search to work)');
529
+ }
530
+
531
+ const table = [];
532
+ let totalLength = D(0);
533
+
534
+ // Build table by accumulating arc length segments
535
+ for (let i = 0; i <= samples; i++) {
536
+ const t = D(i).div(samples);
537
+
538
+ if (i > 0) {
539
+ // Compute arc length from previous sample point to current
540
+ const prevT = D(i - 1).div(samples);
541
+ const segmentLength = arcLength(points, prevT, t, options);
542
+ totalLength = totalLength.plus(segmentLength);
543
+ }
544
+
545
+ // Store cumulative arc length at this t value
546
+ table.push({ t, length: totalLength });
547
+ }
548
+
549
+ return {
550
+ table,
551
+ totalLength,
552
+
553
+ /**
554
+ * Get approximate t for given arc length using binary search.
555
+ * @param {number|string|Decimal} s - Arc length
556
+ * @returns {Decimal} Approximate t
557
+ */
558
+ getT(s) {
559
+ const sD = D(s);
560
+
561
+ if (sD.lte(0)) return D(0);
562
+ if (sD.gte(this.totalLength)) return D(1);
563
+
564
+ // EDGE CASE: Handle degenerate table
565
+ // WHY: If table has only 1 entry (shouldn't happen with samples >= 2, but defensive)
566
+ if (table.length < 2) {
567
+ return sD.div(this.totalLength);
568
+ }
569
+
570
+ // Binary search
571
+ let lo = 0;
572
+ let hi = table.length - 1;
573
+
574
+ while (lo < hi - 1) {
575
+ const mid = Math.floor((lo + hi) / 2);
576
+ if (table[mid].length.lt(sD)) {
577
+ lo = mid;
578
+ } else {
579
+ hi = mid;
580
+ }
581
+ }
582
+
583
+ // Linear interpolation between lo and hi
584
+ const s0 = table[lo].length;
585
+ const s1 = table[hi].length;
586
+ const t0 = table[lo].t;
587
+ const t1 = table[hi].t;
588
+
589
+ const fraction = sD.minus(s0).div(s1.minus(s0));
590
+ return t0.plus(t1.minus(t0).times(fraction));
591
+ },
592
+
593
+ /**
594
+ * Get refined t using table lookup + Newton refinement.
595
+ * @param {number|string|Decimal} s - Arc length
596
+ * @param {Object} [opts] - Options for inverseArcLength
597
+ * @returns {Decimal} Refined t
598
+ */
599
+ getTRefined(s, opts = {}) {
600
+ const approxT = this.getT(s);
601
+ // Use approxT as starting point for Newton
602
+ const { t } = inverseArcLength(points, s, { ...opts, initialT: approxT });
603
+ return t;
604
+ }
605
+ };
606
+ }
607
+
608
+ // ============================================================================
609
+ // VERIFICATION (INVERSE OPERATIONS)
610
+ // ============================================================================
611
+
612
+ /**
613
+ * Verify arc length computation by comparing with chord length bounds.
614
+ *
615
+ * For any curve: chord_length <= arc_length <= sum_of_control_polygon_edges
616
+ *
617
+ * @param {Array} points - Control points
618
+ * @param {Decimal} [computedLength] - Computed arc length (if not provided, computes it)
619
+ * @returns {{valid: boolean, chordLength: Decimal, polygonLength: Decimal, arcLength: Decimal, ratio: Decimal, errors: string[]}}
620
+ */
621
+ export function verifyArcLength(points, computedLength = null) {
622
+ // INPUT VALIDATION: Ensure points array is valid
623
+ // WHY: This function needs to access points[0], points[length-1], and iterate
624
+ // over points to compute polygon length. Invalid input would cause errors.
625
+ if (!points || !Array.isArray(points) || points.length < 2) {
626
+ throw new Error('verifyArcLength: points must be an array with at least 2 control points');
627
+ }
628
+
629
+ const errors = [];
630
+
631
+ // Compute arc length if not provided
632
+ const length = computedLength !== null ? D(computedLength) : arcLength(points);
633
+
634
+ // Chord length (straight line from start to end)
635
+ const [x0, y0] = [D(points[0][0]), D(points[0][1])];
636
+ const [xn, yn] = [D(points[points.length - 1][0]), D(points[points.length - 1][1])];
637
+ const chordLength = xn.minus(x0).pow(2).plus(yn.minus(y0).pow(2)).sqrt();
638
+
639
+ // Control polygon length
640
+ let polygonLength = D(0);
641
+ for (let i = 0; i < points.length - 1; i++) {
642
+ const [x1, y1] = [D(points[i][0]), D(points[i][1])];
643
+ const [x2, y2] = [D(points[i + 1][0]), D(points[i + 1][1])];
644
+ polygonLength = polygonLength.plus(x2.minus(x1).pow(2).plus(y2.minus(y1).pow(2)).sqrt());
645
+ }
646
+
647
+ // Check bounds
648
+ if (length.lt(chordLength)) {
649
+ errors.push(`Arc length ${length} < chord length ${chordLength}`);
650
+ }
651
+ if (length.gt(polygonLength)) {
652
+ errors.push(`Arc length ${length} > polygon length ${polygonLength}`);
653
+ }
654
+
655
+ return {
656
+ valid: errors.length === 0,
657
+ chordLength,
658
+ polygonLength,
659
+ arcLength: length,
660
+ ratio: chordLength.gt(0) ? length.div(chordLength) : D(1),
661
+ errors
662
+ };
663
+ }
664
+
665
+ /**
666
+ * Verify inverse arc length by roundtrip: length -> t -> length.
667
+ * The computed length at returned t should match the target length.
668
+ *
669
+ * @param {Array} points - Control points
670
+ * @param {number|string|Decimal} targetLength - Target arc length
671
+ * @param {number|string|Decimal} [tolerance='1e-25'] - Maximum error
672
+ * @returns {{valid: boolean, targetLength: Decimal, foundT: Decimal, verifiedLength: Decimal, error: Decimal}}
673
+ */
674
+ export function verifyInverseArcLength(points, targetLength, tolerance = '1e-25') {
675
+ // INPUT VALIDATION: Ensure points array is valid
676
+ // WHY: This function calls inverseArcLength and arcLength, both of which require
677
+ // valid points. We validate early for clearer error messages.
678
+ if (!points || !Array.isArray(points) || points.length < 2) {
679
+ throw new Error('verifyInverseArcLength: points must be an array with at least 2 control points');
680
+ }
681
+
682
+ const target = D(targetLength);
683
+
684
+ // FAIL FAST: Validate targetLength is non-negative
685
+ // WHY: Negative arc lengths are mathematically invalid. This prevents nonsensical tests.
686
+ if (target.lt(0)) {
687
+ throw new Error('verifyInverseArcLength: targetLength must be non-negative');
688
+ }
689
+
690
+ const tol = D(tolerance);
691
+
692
+ // Forward: find t for target length
693
+ const { t: foundT, converged } = inverseArcLength(points, target);
694
+
695
+ // Reverse: compute length at foundT
696
+ const verifiedLength = arcLength(points, 0, foundT);
697
+
698
+ // Check roundtrip error
699
+ const error = verifiedLength.minus(target).abs();
700
+
701
+ return {
702
+ valid: error.lte(tol) && converged,
703
+ targetLength: target,
704
+ foundT,
705
+ verifiedLength,
706
+ error,
707
+ converged
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Verify arc length by computing via subdivision and comparing.
713
+ * Two independent methods should produce the same result.
714
+ *
715
+ * @param {Array} points - Control points
716
+ * @param {number} [subdivisions=16] - Number of subdivisions for comparison method
717
+ * @param {number|string|Decimal} [tolerance='1e-20'] - Maximum difference
718
+ * @returns {{valid: boolean, quadratureLength: Decimal, subdivisionLength: Decimal, difference: Decimal}}
719
+ */
720
+ export function verifyArcLengthBySubdivision(points, subdivisions = 16, tolerance = SUBDIVISION_COMPARISON_TOLERANCE) {
721
+ // INPUT VALIDATION: Ensure points array is valid
722
+ // WHY: This function calls arcLength and bezierPoint, both of which require
723
+ // valid control points. Early validation provides better error messages.
724
+ if (!points || !Array.isArray(points) || points.length < 2) {
725
+ throw new Error('verifyArcLengthBySubdivision: points must be an array with at least 2 control points');
726
+ }
727
+
728
+ const tol = D(tolerance);
729
+
730
+ // Method 1: Adaptive quadrature
731
+ const quadratureLength = arcLength(points);
732
+
733
+ // Method 2: Sum of chord lengths after subdivision
734
+ let subdivisionLength = D(0);
735
+ let prevPoint = bezierPoint(points, 0);
736
+
737
+ for (let i = 1; i <= subdivisions; i++) {
738
+ const t = D(i).div(subdivisions);
739
+ const currPoint = bezierPoint(points, t);
740
+
741
+ const dx = D(currPoint[0]).minus(D(prevPoint[0]));
742
+ const dy = D(currPoint[1]).minus(D(prevPoint[1]));
743
+ subdivisionLength = subdivisionLength.plus(dx.pow(2).plus(dy.pow(2)).sqrt());
744
+
745
+ prevPoint = currPoint;
746
+ }
747
+
748
+ const difference = quadratureLength.minus(subdivisionLength).abs();
749
+
750
+ // Subdivision should slightly underestimate (chord < arc)
751
+ // But with enough subdivisions, should be very close
752
+ return {
753
+ valid: difference.lte(tol),
754
+ quadratureLength,
755
+ subdivisionLength,
756
+ difference,
757
+ underestimate: quadratureLength.gt(subdivisionLength)
758
+ };
759
+ }
760
+
761
+ /**
762
+ * Verify arc length additivity: length(0,t) + length(t,1) = length(0,1).
763
+ *
764
+ * @param {Array} points - Control points
765
+ * @param {number|string|Decimal} t - Split parameter
766
+ * @param {number|string|Decimal} [tolerance='1e-30'] - Maximum error
767
+ * @returns {{valid: boolean, totalLength: Decimal, leftLength: Decimal, rightLength: Decimal, sum: Decimal, error: Decimal}}
768
+ */
769
+ export function verifyArcLengthAdditivity(points, t, tolerance = DEFAULT_ARC_LENGTH_TOLERANCE) {
770
+ // INPUT VALIDATION: Ensure points array is valid
771
+ // WHY: This function calls arcLength multiple times with the same points array.
772
+ // Validating once here is more efficient than letting each call validate.
773
+ if (!points || !Array.isArray(points) || points.length < 2) {
774
+ throw new Error('verifyArcLengthAdditivity: points must be an array with at least 2 control points');
775
+ }
776
+
777
+ const tD = D(t);
778
+ // PARAMETER VALIDATION: t must be in [0, 1] for additivity to make sense
779
+ // WHY: Arc length additivity L(0,t) + L(t,1) = L(0,1) only holds for t in [0,1]
780
+ if (tD.lt(0) || tD.gt(1)) {
781
+ throw new Error('verifyArcLengthAdditivity: t must be in range [0, 1]');
782
+ }
783
+
784
+ const tol = D(tolerance);
785
+
786
+ const totalLength = arcLength(points, 0, 1);
787
+ const leftLength = arcLength(points, 0, tD);
788
+ const rightLength = arcLength(points, tD, 1);
789
+
790
+ const sum = leftLength.plus(rightLength);
791
+ const error = sum.minus(totalLength).abs();
792
+
793
+ return {
794
+ valid: error.lte(tol),
795
+ totalLength,
796
+ leftLength,
797
+ rightLength,
798
+ sum,
799
+ error
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Verify arc length table consistency and monotonicity.
805
+ *
806
+ * @param {Array} points - Control points
807
+ * @param {number} [samples=50] - Number of samples in table
808
+ * @returns {{valid: boolean, errors: string[], isMonotonic: boolean, maxGap: Decimal}}
809
+ */
810
+ export function verifyArcLengthTable(points, samples = 50) {
811
+ // INPUT VALIDATION: Ensure points array is valid
812
+ // WHY: This function calls createArcLengthTable and arcLength, both requiring
813
+ // valid points. Early validation provides better diagnostics.
814
+ if (!points || !Array.isArray(points) || points.length < 2) {
815
+ throw new Error('verifyArcLengthTable: points must be an array with at least 2 control points');
816
+ }
817
+
818
+ const errors = [];
819
+ const table = createArcLengthTable(points, samples);
820
+
821
+ let isMonotonic = true;
822
+ let maxGap = D(0);
823
+
824
+ // Check monotonicity
825
+ for (let i = 1; i < table.table.length; i++) {
826
+ const curr = table.table[i].length;
827
+ const prev = table.table[i - 1].length;
828
+
829
+ if (curr.lt(prev)) {
830
+ isMonotonic = false;
831
+ errors.push(`Table not monotonic at index ${i}: ${prev} > ${curr}`);
832
+ }
833
+
834
+ const gap = curr.minus(prev);
835
+ if (gap.gt(maxGap)) {
836
+ maxGap = gap;
837
+ }
838
+ }
839
+
840
+ // Verify table boundaries
841
+ const firstEntry = table.table[0];
842
+ if (!firstEntry.t.isZero() || !firstEntry.length.isZero()) {
843
+ errors.push(`First entry should be t=0, length=0, got t=${firstEntry.t}, length=${firstEntry.length}`);
844
+ }
845
+
846
+ const lastEntry = table.table[table.table.length - 1];
847
+ if (!lastEntry.t.eq(1)) {
848
+ errors.push(`Last entry should have t=1, got t=${lastEntry.t}`);
849
+ }
850
+
851
+ // Verify total length consistency
852
+ const directLength = arcLength(points);
853
+ const tableTotalDiff = table.totalLength.minus(directLength).abs();
854
+ // WHY: Use TABLE_ROUNDTRIP_TOLERANCE to account for accumulated segment errors
855
+ if (tableTotalDiff.gt(TABLE_ROUNDTRIP_TOLERANCE)) {
856
+ errors.push(`Table total length ${table.totalLength} differs from direct computation ${directLength}`);
857
+ }
858
+
859
+ // Verify getT roundtrip for a few values
860
+ for (const fraction of [0.25, 0.5, 0.75]) {
861
+ const targetLength = table.totalLength.times(fraction);
862
+ const foundT = table.getT(targetLength);
863
+ const recoveredLength = arcLength(points, 0, foundT);
864
+ const roundtripError = recoveredLength.minus(targetLength).abs();
865
+
866
+ if (roundtripError.gt(table.totalLength.div(samples).times(2))) {
867
+ errors.push(`getT roundtrip error too large at ${fraction}: ${roundtripError}`);
868
+ }
869
+ }
870
+
871
+ return {
872
+ valid: errors.length === 0,
873
+ errors,
874
+ isMonotonic,
875
+ maxGap,
876
+ tableSize: table.table.length,
877
+ totalLength: table.totalLength
878
+ };
879
+ }
880
+
881
+ /**
882
+ * Comprehensive verification of all arc length functions.
883
+ *
884
+ * @param {Array} points - Control points
885
+ * @param {Object} [options] - Options
886
+ * @returns {{valid: boolean, results: Object}}
887
+ */
888
+ export function verifyAllArcLengthFunctions(points, options = {}) {
889
+ // INPUT VALIDATION: Ensure points array is valid
890
+ // WHY: This function orchestrates multiple verification functions, all of which
891
+ // require valid points. Validating once at the top prevents redundant checks.
892
+ if (!points || !Array.isArray(points) || points.length < 2) {
893
+ throw new Error('verifyAllArcLengthFunctions: points must be an array with at least 2 control points');
894
+ }
895
+
896
+ const results = {};
897
+
898
+ // 1. Verify basic arc length bounds
899
+ results.bounds = verifyArcLength(points);
900
+
901
+ // 2. Verify subdivision comparison
902
+ results.subdivision = verifyArcLengthBySubdivision(points, 32);
903
+
904
+ // 3. Verify additivity at midpoint
905
+ results.additivity = verifyArcLengthAdditivity(points, 0.5);
906
+
907
+ // 4. Verify inverse arc length roundtrip
908
+ const totalLength = arcLength(points);
909
+ results.inverseRoundtrip = verifyInverseArcLength(points, totalLength.div(2));
910
+
911
+ // 5. Verify table
912
+ results.table = verifyArcLengthTable(points, 20);
913
+
914
+ const allValid = Object.values(results).every(r => r.valid);
915
+
916
+ return {
917
+ valid: allValid,
918
+ results
919
+ };
920
+ }
921
+
922
+ // ============================================================================
923
+ // EXPORTS
924
+ // ============================================================================
925
+
926
+ export default {
927
+ arcLength,
928
+ inverseArcLength,
929
+ pathArcLength,
930
+ pathInverseArcLength,
931
+ createArcLengthTable,
932
+
933
+ // Verification (inverse operations)
934
+ verifyArcLength,
935
+ verifyInverseArcLength,
936
+ verifyArcLengthBySubdivision,
937
+ verifyArcLengthAdditivity,
938
+ verifyArcLengthTable,
939
+ verifyAllArcLengthFunctions
940
+ };