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