@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,1140 @@
1
+ /**
2
+ * Path Simplification with Arbitrary Precision and Mathematical Verification
3
+ *
4
+ * Provides functions to simplify SVG path commands while guaranteeing:
5
+ * 1. ARBITRARY PRECISION - All calculations use Decimal.js (50+ digits)
6
+ * 2. MATHEMATICAL VERIFICATION - Every simplification is verified via sampling
7
+ *
8
+ * ## Algorithms Implemented
9
+ *
10
+ * ### Curve-to-Line Detection (isBezierStraight)
11
+ * Detects if a Bezier curve is effectively a straight line by measuring
12
+ * the maximum distance from control points to the chord (line from start to end).
13
+ * Uses the perpendicular distance formula with Decimal.js precision.
14
+ *
15
+ * ### Curve-to-Arc Detection (fitCircleToBezier)
16
+ * Fits a circle to a Bezier curve using least-squares fitting.
17
+ * If the curve matches an arc within tolerance, it can be converted.
18
+ * Uses algebraic circle fitting for numerical stability.
19
+ *
20
+ * ### Degree Lowering (canLowerDegree)
21
+ * Detects if a cubic Bezier is actually a quadratic Bezier in disguise.
22
+ * A cubic C(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3 is quadratic iff
23
+ * P1 and P2 lie on specific positions relative to P0 and P3.
24
+ *
25
+ * ### Arc-to-Line Detection (isArcStraight)
26
+ * Detects if an arc is effectively a straight line using sagitta calculation.
27
+ * sagitta = r - sqrt(r² - chord²/4). If sagitta < tolerance, arc is straight.
28
+ *
29
+ * ### Collinear Point Merging (mergeCollinearSegments)
30
+ * Merges consecutive line segments that are collinear into a single segment.
31
+ *
32
+ * ### Zero-Length Removal (removeZeroLengthSegments)
33
+ * Removes path segments that have zero length (e.g., l 0,0).
34
+ *
35
+ * @module path-simplification
36
+ */
37
+
38
+ import Decimal from 'decimal.js';
39
+
40
+ // Set high precision for all calculations
41
+ Decimal.set({ precision: 80 });
42
+
43
+ // Helper to convert to Decimal
44
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
45
+
46
+ // Near-zero threshold for comparisons (much smaller than SVGO's!)
47
+ const EPSILON = new Decimal('1e-40');
48
+
49
+ // Default tolerance for simplification (user-configurable)
50
+ const DEFAULT_TOLERANCE = new Decimal('1e-10');
51
+
52
+ /**
53
+ * Implementation of atan2 using Decimal.js (which doesn't provide it natively).
54
+ * Returns the angle in radians between the positive x-axis and the ray from (0,0) to (x,y).
55
+ *
56
+ * @param {Decimal} y - Y coordinate
57
+ * @param {Decimal} x - X coordinate
58
+ * @returns {Decimal} Angle in radians (-π to π)
59
+ */
60
+ function decimalAtan2(y, x) {
61
+ const yD = D(y);
62
+ const xD = D(x);
63
+ const PI = Decimal.acos(-1);
64
+
65
+ if (xD.greaterThan(0)) {
66
+ // Quadrant I or IV
67
+ return Decimal.atan(yD.div(xD));
68
+ } else if (xD.lessThan(0) && yD.greaterThanOrEqualTo(0)) {
69
+ // Quadrant II
70
+ return Decimal.atan(yD.div(xD)).plus(PI);
71
+ } else if (xD.lessThan(0) && yD.lessThan(0)) {
72
+ // Quadrant III
73
+ return Decimal.atan(yD.div(xD)).minus(PI);
74
+ } else if (xD.equals(0) && yD.greaterThan(0)) {
75
+ // Positive y-axis
76
+ return PI.div(2);
77
+ } else if (xD.equals(0) && yD.lessThan(0)) {
78
+ // Negative y-axis
79
+ return PI.div(2).neg();
80
+ } else {
81
+ // x=0, y=0 - undefined, return 0
82
+ return D(0);
83
+ }
84
+ }
85
+
86
+ // ============================================================================
87
+ // Point and Vector Utilities
88
+ // ============================================================================
89
+
90
+ /**
91
+ * Create a point with Decimal coordinates.
92
+ * @param {number|string|Decimal} x - X coordinate
93
+ * @param {number|string|Decimal} y - Y coordinate
94
+ * @returns {{x: Decimal, y: Decimal}} Point object
95
+ */
96
+ export function point(x, y) {
97
+ return { x: D(x), y: D(y) };
98
+ }
99
+
100
+ /**
101
+ * Calculate the squared distance between two points.
102
+ * Using squared distance avoids unnecessary sqrt operations.
103
+ * @param {{x: Decimal, y: Decimal}} p1 - First point
104
+ * @param {{x: Decimal, y: Decimal}} p2 - Second point
105
+ * @returns {Decimal} Squared distance
106
+ */
107
+ export function distanceSquared(p1, p2) {
108
+ const dx = p2.x.minus(p1.x);
109
+ const dy = p2.y.minus(p1.y);
110
+ return dx.mul(dx).plus(dy.mul(dy));
111
+ }
112
+
113
+ /**
114
+ * Calculate the distance between two points.
115
+ * @param {{x: Decimal, y: Decimal}} p1 - First point
116
+ * @param {{x: Decimal, y: Decimal}} p2 - Second point
117
+ * @returns {Decimal} Distance
118
+ */
119
+ export function distance(p1, p2) {
120
+ return distanceSquared(p1, p2).sqrt();
121
+ }
122
+
123
+ /**
124
+ * Calculate perpendicular distance from a point to a line defined by two points.
125
+ *
126
+ * Uses the formula: d = |((y2-y1)x0 - (x2-x1)y0 + x2*y1 - y2*x1)| / sqrt((y2-y1)² + (x2-x1)²)
127
+ *
128
+ * @param {{x: Decimal, y: Decimal}} pt - The point
129
+ * @param {{x: Decimal, y: Decimal}} lineStart - Line start point
130
+ * @param {{x: Decimal, y: Decimal}} lineEnd - Line end point
131
+ * @returns {Decimal} Perpendicular distance
132
+ */
133
+ export function pointToLineDistance(pt, lineStart, lineEnd) {
134
+ const x0 = pt.x, y0 = pt.y;
135
+ const x1 = lineStart.x, y1 = lineStart.y;
136
+ const x2 = lineEnd.x, y2 = lineEnd.y;
137
+
138
+ const dx = x2.minus(x1);
139
+ const dy = y2.minus(y1);
140
+
141
+ const lineLengthSq = dx.mul(dx).plus(dy.mul(dy));
142
+
143
+ // If line is actually a point, return distance to that point
144
+ if (lineLengthSq.lessThan(EPSILON)) {
145
+ return distance(pt, lineStart);
146
+ }
147
+
148
+ // Numerator: |(y2-y1)*x0 - (x2-x1)*y0 + x2*y1 - y2*x1|
149
+ const numerator = dy.mul(x0).minus(dx.mul(y0)).plus(x2.mul(y1)).minus(y2.mul(x1)).abs();
150
+
151
+ // Denominator: sqrt((y2-y1)² + (x2-x1)²)
152
+ const denominator = lineLengthSq.sqrt();
153
+
154
+ return numerator.div(denominator);
155
+ }
156
+
157
+ /**
158
+ * Calculate the cross product of vectors (p2-p1) and (p3-p1).
159
+ * Used for determining collinearity and turn direction.
160
+ * @param {{x: Decimal, y: Decimal}} p1 - First point
161
+ * @param {{x: Decimal, y: Decimal}} p2 - Second point
162
+ * @param {{x: Decimal, y: Decimal}} p3 - Third point
163
+ * @returns {Decimal} Cross product (positive = CCW, negative = CW, zero = collinear)
164
+ */
165
+ export function crossProduct(p1, p2, p3) {
166
+ const v1x = p2.x.minus(p1.x);
167
+ const v1y = p2.y.minus(p1.y);
168
+ const v2x = p3.x.minus(p1.x);
169
+ const v2y = p3.y.minus(p1.y);
170
+ return v1x.mul(v2y).minus(v1y.mul(v2x));
171
+ }
172
+
173
+ // ============================================================================
174
+ // Bezier Curve Evaluation
175
+ // ============================================================================
176
+
177
+ /**
178
+ * Evaluate a cubic Bezier curve at parameter t.
179
+ * B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
180
+ *
181
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
182
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
183
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
184
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
185
+ * @param {number|string|Decimal} t - Parameter (0 to 1)
186
+ * @returns {{x: Decimal, y: Decimal}} Point on curve
187
+ */
188
+ export function evaluateCubicBezier(p0, p1, p2, p3, t) {
189
+ const tD = D(t);
190
+ const oneMinusT = D(1).minus(tD);
191
+
192
+ // Bernstein basis polynomials
193
+ const b0 = oneMinusT.pow(3); // (1-t)³
194
+ const b1 = D(3).mul(oneMinusT.pow(2)).mul(tD); // 3(1-t)²t
195
+ const b2 = D(3).mul(oneMinusT).mul(tD.pow(2)); // 3(1-t)t²
196
+ const b3 = tD.pow(3); // t³
197
+
198
+ return {
199
+ x: b0.mul(p0.x).plus(b1.mul(p1.x)).plus(b2.mul(p2.x)).plus(b3.mul(p3.x)),
200
+ y: b0.mul(p0.y).plus(b1.mul(p1.y)).plus(b2.mul(p2.y)).plus(b3.mul(p3.y))
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Evaluate a quadratic Bezier curve at parameter t.
206
+ * B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
207
+ *
208
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
209
+ * @param {{x: Decimal, y: Decimal}} p1 - Control point
210
+ * @param {{x: Decimal, y: Decimal}} p2 - End point
211
+ * @param {number|string|Decimal} t - Parameter (0 to 1)
212
+ * @returns {{x: Decimal, y: Decimal}} Point on curve
213
+ */
214
+ export function evaluateQuadraticBezier(p0, p1, p2, t) {
215
+ const tD = D(t);
216
+ const oneMinusT = D(1).minus(tD);
217
+
218
+ // Bernstein basis polynomials
219
+ const b0 = oneMinusT.pow(2); // (1-t)²
220
+ const b1 = D(2).mul(oneMinusT).mul(tD); // 2(1-t)t
221
+ const b2 = tD.pow(2); // t²
222
+
223
+ return {
224
+ x: b0.mul(p0.x).plus(b1.mul(p1.x)).plus(b2.mul(p2.x)),
225
+ y: b0.mul(p0.y).plus(b1.mul(p1.y)).plus(b2.mul(p2.y))
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Evaluate a line segment at parameter t.
231
+ * L(t) = (1-t)P0 + tP1
232
+ *
233
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
234
+ * @param {{x: Decimal, y: Decimal}} p1 - End point
235
+ * @param {number|string|Decimal} t - Parameter (0 to 1)
236
+ * @returns {{x: Decimal, y: Decimal}} Point on line
237
+ */
238
+ export function evaluateLine(p0, p1, t) {
239
+ const tD = D(t);
240
+ const oneMinusT = D(1).minus(tD);
241
+ return {
242
+ x: oneMinusT.mul(p0.x).plus(tD.mul(p1.x)),
243
+ y: oneMinusT.mul(p0.y).plus(tD.mul(p1.y))
244
+ };
245
+ }
246
+
247
+ // ============================================================================
248
+ // Curve-to-Line Detection
249
+ // ============================================================================
250
+
251
+ /**
252
+ * Check if a cubic Bezier curve is effectively a straight line.
253
+ *
254
+ * A Bezier curve is considered straight if all control points are within
255
+ * the specified tolerance of the line from start to end.
256
+ *
257
+ * VERIFICATION: After detection, we verify by sampling the curve and
258
+ * comparing against the line at multiple points.
259
+ *
260
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
261
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
262
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
263
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
264
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
265
+ * @returns {{isStraight: boolean, maxDeviation: Decimal, verified: boolean}}
266
+ */
267
+ export function isCubicBezierStraight(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
268
+ const tol = D(tolerance);
269
+
270
+ // Check if start and end are the same point (degenerate case)
271
+ const chordLength = distance(p0, p3);
272
+ if (chordLength.lessThan(EPSILON)) {
273
+ // All points must be at the same location
274
+ const d1 = distance(p0, p1);
275
+ const d2 = distance(p0, p2);
276
+ const maxDev = Decimal.max(d1, d2);
277
+ return {
278
+ isStraight: maxDev.lessThan(tol),
279
+ maxDeviation: maxDev,
280
+ verified: true
281
+ };
282
+ }
283
+
284
+ // Calculate distance from control points to the chord
285
+ const d1 = pointToLineDistance(p1, p0, p3);
286
+ const d2 = pointToLineDistance(p2, p0, p3);
287
+ const maxControlDeviation = Decimal.max(d1, d2);
288
+
289
+ // Quick rejection: if control points are far from chord, not straight
290
+ if (maxControlDeviation.greaterThan(tol)) {
291
+ return {
292
+ isStraight: false,
293
+ maxDeviation: maxControlDeviation,
294
+ verified: true
295
+ };
296
+ }
297
+
298
+ // VERIFICATION: Sample the curve and verify against line
299
+ const samples = 20;
300
+ let maxSampleDeviation = new Decimal(0);
301
+
302
+ for (let i = 1; i < samples; i++) {
303
+ const t = D(i).div(samples);
304
+ const curvePoint = evaluateCubicBezier(p0, p1, p2, p3, t);
305
+ const linePoint = evaluateLine(p0, p3, t);
306
+ const dev = distance(curvePoint, linePoint);
307
+ maxSampleDeviation = Decimal.max(maxSampleDeviation, dev);
308
+ }
309
+
310
+ const verified = maxSampleDeviation.lessThanOrEqualTo(tol);
311
+
312
+ return {
313
+ isStraight: maxControlDeviation.lessThan(tol) && verified,
314
+ maxDeviation: Decimal.max(maxControlDeviation, maxSampleDeviation),
315
+ verified: true
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Check if a quadratic Bezier curve is effectively a straight line.
321
+ *
322
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
323
+ * @param {{x: Decimal, y: Decimal}} p1 - Control point
324
+ * @param {{x: Decimal, y: Decimal}} p2 - End point
325
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
326
+ * @returns {{isStraight: boolean, maxDeviation: Decimal, verified: boolean}}
327
+ */
328
+ export function isQuadraticBezierStraight(p0, p1, p2, tolerance = DEFAULT_TOLERANCE) {
329
+ const tol = D(tolerance);
330
+
331
+ // Check if start and end are the same point (degenerate case)
332
+ const chordLength = distance(p0, p2);
333
+ if (chordLength.lessThan(EPSILON)) {
334
+ const d1 = distance(p0, p1);
335
+ return {
336
+ isStraight: d1.lessThan(tol),
337
+ maxDeviation: d1,
338
+ verified: true
339
+ };
340
+ }
341
+
342
+ // Calculate distance from control point to the chord
343
+ const controlDeviation = pointToLineDistance(p1, p0, p2);
344
+
345
+ // Quick rejection
346
+ if (controlDeviation.greaterThan(tol)) {
347
+ return {
348
+ isStraight: false,
349
+ maxDeviation: controlDeviation,
350
+ verified: true
351
+ };
352
+ }
353
+
354
+ // VERIFICATION: Sample the curve
355
+ const samples = 20;
356
+ let maxSampleDeviation = new Decimal(0);
357
+
358
+ for (let i = 1; i < samples; i++) {
359
+ const t = D(i).div(samples);
360
+ const curvePoint = evaluateQuadraticBezier(p0, p1, p2, t);
361
+ const linePoint = evaluateLine(p0, p2, t);
362
+ const dev = distance(curvePoint, linePoint);
363
+ maxSampleDeviation = Decimal.max(maxSampleDeviation, dev);
364
+ }
365
+
366
+ const verified = maxSampleDeviation.lessThanOrEqualTo(tol);
367
+
368
+ return {
369
+ isStraight: controlDeviation.lessThan(tol) && verified,
370
+ maxDeviation: Decimal.max(controlDeviation, maxSampleDeviation),
371
+ verified: true
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Convert a straight cubic Bezier to a line segment.
377
+ * Returns the line endpoints if the curve is straight, null otherwise.
378
+ *
379
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
380
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
381
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
382
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
383
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
384
+ * @returns {{start: {x: Decimal, y: Decimal}, end: {x: Decimal, y: Decimal}, maxDeviation: Decimal} | null}
385
+ */
386
+ export function cubicBezierToLine(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
387
+ const result = isCubicBezierStraight(p0, p1, p2, p3, tolerance);
388
+ if (!result.isStraight || !result.verified) {
389
+ return null;
390
+ }
391
+ return {
392
+ start: { x: p0.x, y: p0.y },
393
+ end: { x: p3.x, y: p3.y },
394
+ maxDeviation: result.maxDeviation
395
+ };
396
+ }
397
+
398
+ // ============================================================================
399
+ // Degree Lowering (Cubic to Quadratic)
400
+ // ============================================================================
401
+
402
+ /**
403
+ * Check if a cubic Bezier can be accurately represented as a quadratic Bezier.
404
+ *
405
+ * A cubic Bezier with control points P0, P1, P2, P3 can be represented as a
406
+ * quadratic if the two control points P1 and P2 are positioned such that they
407
+ * represent a "degree-elevated" quadratic curve.
408
+ *
409
+ * The condition is: P1 = P0 + 2/3*(Q1-P0) and P2 = P3 + 2/3*(Q1-P3)
410
+ * where Q1 is the quadratic control point.
411
+ *
412
+ * Solving for Q1: Q1 = (3*P1 - P0) / 2 = (3*P2 - P3) / 2
413
+ * So the curve is quadratic iff these two expressions are equal (within tolerance).
414
+ *
415
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
416
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
417
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
418
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
419
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
420
+ * @returns {{canLower: boolean, quadraticControl: {x: Decimal, y: Decimal} | null, maxDeviation: Decimal, verified: boolean}}
421
+ */
422
+ export function canLowerCubicToQuadratic(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
423
+ const tol = D(tolerance);
424
+ const three = D(3);
425
+ const two = D(2);
426
+
427
+ // Calculate Q1 from P1: Q1 = (3*P1 - P0) / 2
428
+ const q1FromP1 = {
429
+ x: three.mul(p1.x).minus(p0.x).div(two),
430
+ y: three.mul(p1.y).minus(p0.y).div(two)
431
+ };
432
+
433
+ // Calculate Q1 from P2: Q1 = (3*P2 - P3) / 2
434
+ const q1FromP2 = {
435
+ x: three.mul(p2.x).minus(p3.x).div(two),
436
+ y: three.mul(p2.y).minus(p3.y).div(two)
437
+ };
438
+
439
+ // Check if these are equal within tolerance
440
+ const deviation = distance(q1FromP1, q1FromP2);
441
+
442
+ if (deviation.greaterThan(tol)) {
443
+ return {
444
+ canLower: false,
445
+ quadraticControl: null,
446
+ maxDeviation: deviation,
447
+ verified: true
448
+ };
449
+ }
450
+
451
+ // Use the average as the quadratic control point
452
+ const q1 = {
453
+ x: q1FromP1.x.plus(q1FromP2.x).div(two),
454
+ y: q1FromP1.y.plus(q1FromP2.y).div(two)
455
+ };
456
+
457
+ // VERIFICATION: Sample both curves and compare
458
+ const samples = 20;
459
+ let maxSampleDeviation = new Decimal(0);
460
+
461
+ for (let i = 0; i <= samples; i++) {
462
+ const t = D(i).div(samples);
463
+ const cubicPoint = evaluateCubicBezier(p0, p1, p2, p3, t);
464
+ const quadraticPoint = evaluateQuadraticBezier(p0, q1, p3, t);
465
+ const dev = distance(cubicPoint, quadraticPoint);
466
+ maxSampleDeviation = Decimal.max(maxSampleDeviation, dev);
467
+ }
468
+
469
+ const verified = maxSampleDeviation.lessThanOrEqualTo(tol);
470
+
471
+ return {
472
+ canLower: verified,
473
+ quadraticControl: verified ? q1 : null,
474
+ maxDeviation: Decimal.max(deviation, maxSampleDeviation),
475
+ verified: true
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Convert a cubic Bezier to quadratic if possible.
481
+ * Returns the quadratic curve points if conversion is valid, null otherwise.
482
+ *
483
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
484
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
485
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
486
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
487
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
488
+ * @returns {{p0: {x: Decimal, y: Decimal}, p1: {x: Decimal, y: Decimal}, p2: {x: Decimal, y: Decimal}, maxDeviation: Decimal} | null}
489
+ */
490
+ export function cubicToQuadratic(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
491
+ const result = canLowerCubicToQuadratic(p0, p1, p2, p3, tolerance);
492
+ if (!result.canLower || !result.quadraticControl) {
493
+ return null;
494
+ }
495
+ return {
496
+ p0: { x: p0.x, y: p0.y },
497
+ p1: result.quadraticControl,
498
+ p2: { x: p3.x, y: p3.y },
499
+ maxDeviation: result.maxDeviation
500
+ };
501
+ }
502
+
503
+ // ============================================================================
504
+ // Curve-to-Arc Detection (Circle Fitting)
505
+ // ============================================================================
506
+
507
+ /**
508
+ * Fit a circle to a set of points using algebraic least squares.
509
+ *
510
+ * Uses the Kasa method: minimize sum of (x² + y² - 2*a*x - 2*b*y - c)²
511
+ * where (a, b) is the center and r² = a² + b² + c.
512
+ *
513
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Points to fit
514
+ * @returns {{center: {x: Decimal, y: Decimal}, radius: Decimal} | null}
515
+ */
516
+ export function fitCircleToPoints(points) {
517
+ if (points.length < 3) {
518
+ return null;
519
+ }
520
+
521
+ const n = D(points.length);
522
+ let sumX = D(0), sumY = D(0);
523
+ let sumX2 = D(0), sumY2 = D(0);
524
+ let sumXY = D(0);
525
+ let sumX3 = D(0), sumY3 = D(0);
526
+ let sumX2Y = D(0), sumXY2 = D(0);
527
+
528
+ for (const p of points) {
529
+ const x = p.x, y = p.y;
530
+ const x2 = x.mul(x), y2 = y.mul(y);
531
+
532
+ sumX = sumX.plus(x);
533
+ sumY = sumY.plus(y);
534
+ sumX2 = sumX2.plus(x2);
535
+ sumY2 = sumY2.plus(y2);
536
+ sumXY = sumXY.plus(x.mul(y));
537
+ sumX3 = sumX3.plus(x2.mul(x));
538
+ sumY3 = sumY3.plus(y2.mul(y));
539
+ sumX2Y = sumX2Y.plus(x2.mul(y));
540
+ sumXY2 = sumXY2.plus(x.mul(y2));
541
+ }
542
+
543
+ // Solve the normal equations
544
+ // A = n*sumX2 - sumX*sumX
545
+ // B = n*sumXY - sumX*sumY
546
+ // C = n*sumY2 - sumY*sumY
547
+ // D = 0.5*(n*sumX3 + n*sumXY2 - sumX*sumX2 - sumX*sumY2)
548
+ // E = 0.5*(n*sumX2Y + n*sumY3 - sumY*sumX2 - sumY*sumY2)
549
+
550
+ const A = n.mul(sumX2).minus(sumX.mul(sumX));
551
+ const B = n.mul(sumXY).minus(sumX.mul(sumY));
552
+ const C = n.mul(sumY2).minus(sumY.mul(sumY));
553
+ const DD = D(0.5).mul(n.mul(sumX3).plus(n.mul(sumXY2)).minus(sumX.mul(sumX2)).minus(sumX.mul(sumY2)));
554
+ const E = D(0.5).mul(n.mul(sumX2Y).plus(n.mul(sumY3)).minus(sumY.mul(sumX2)).minus(sumY.mul(sumY2)));
555
+
556
+ // Solve: A*a + B*b = D, B*a + C*b = E
557
+ const det = A.mul(C).minus(B.mul(B));
558
+
559
+ if (det.abs().lessThan(EPSILON)) {
560
+ // Points are collinear, no circle can fit
561
+ return null;
562
+ }
563
+
564
+ const a = DD.mul(C).minus(E.mul(B)).div(det);
565
+ const b = A.mul(E).minus(B.mul(DD)).div(det);
566
+
567
+ // Calculate radius
568
+ const center = { x: a, y: b };
569
+ let sumRadiusSq = D(0);
570
+ for (const p of points) {
571
+ sumRadiusSq = sumRadiusSq.plus(distanceSquared(p, center));
572
+ }
573
+ const avgRadiusSq = sumRadiusSq.div(n);
574
+ const radius = avgRadiusSq.sqrt();
575
+
576
+ return { center, radius };
577
+ }
578
+
579
+ /**
580
+ * Fit a circle to a cubic Bezier curve and check if it matches within tolerance.
581
+ *
582
+ * VERIFICATION: Samples the curve and verifies all points are within tolerance
583
+ * of the fitted circle.
584
+ *
585
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point
586
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
587
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
588
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
589
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
590
+ * @returns {{isArc: boolean, circle: {center: {x: Decimal, y: Decimal}, radius: Decimal} | null, maxDeviation: Decimal, verified: boolean}}
591
+ */
592
+ export function fitCircleToCubicBezier(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
593
+ const tol = D(tolerance);
594
+
595
+ // Sample points along the curve for fitting
596
+ const sampleCount = 9; // Including endpoints
597
+ const samplePoints = [];
598
+
599
+ for (let i = 0; i <= sampleCount - 1; i++) {
600
+ const t = D(i).div(sampleCount - 1);
601
+ samplePoints.push(evaluateCubicBezier(p0, p1, p2, p3, t));
602
+ }
603
+
604
+ // Fit circle to sample points
605
+ const circle = fitCircleToPoints(samplePoints);
606
+
607
+ if (!circle) {
608
+ return {
609
+ isArc: false,
610
+ circle: null,
611
+ maxDeviation: D(Infinity),
612
+ verified: true
613
+ };
614
+ }
615
+
616
+ // VERIFICATION: Check deviation at more sample points
617
+ const verificationSamples = 50;
618
+ let maxDeviation = D(0);
619
+
620
+ for (let i = 0; i <= verificationSamples; i++) {
621
+ const t = D(i).div(verificationSamples);
622
+ const curvePoint = evaluateCubicBezier(p0, p1, p2, p3, t);
623
+ const distToCenter = distance(curvePoint, circle.center);
624
+ const deviation = distToCenter.minus(circle.radius).abs();
625
+ maxDeviation = Decimal.max(maxDeviation, deviation);
626
+ }
627
+
628
+ const isArc = maxDeviation.lessThanOrEqualTo(tol);
629
+
630
+ return {
631
+ isArc,
632
+ circle: isArc ? circle : null,
633
+ maxDeviation,
634
+ verified: true
635
+ };
636
+ }
637
+
638
+ /**
639
+ * Convert a cubic Bezier to an arc if possible.
640
+ * Returns arc parameters (rx, ry, rotation, largeArc, sweep, endX, endY) if valid.
641
+ *
642
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point (current position)
643
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
644
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
645
+ * @param {{x: Decimal, y: Decimal}} p3 - End point
646
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
647
+ * @returns {{rx: Decimal, ry: Decimal, rotation: Decimal, largeArc: number, sweep: number, endX: Decimal, endY: Decimal, maxDeviation: Decimal} | null}
648
+ */
649
+ export function cubicBezierToArc(p0, p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
650
+ const result = fitCircleToCubicBezier(p0, p1, p2, p3, tolerance);
651
+
652
+ if (!result.isArc || !result.circle) {
653
+ return null;
654
+ }
655
+
656
+ const { center, radius } = result.circle;
657
+
658
+ // Calculate arc parameters
659
+ // For a circle, rx = ry = radius, rotation = 0
660
+
661
+ // Determine sweep direction using cross product
662
+ // Sample a point at t=0.5 and check which side of the chord it's on
663
+ const midPoint = evaluateCubicBezier(p0, p1, p2, p3, D(0.5));
664
+ const cross = crossProduct(p0, p3, midPoint);
665
+ const sweep = cross.lessThan(0) ? 1 : 0;
666
+
667
+ // Determine large-arc flag
668
+ // Calculate the angle subtended by the arc
669
+ const startAngle = decimalAtan2(p0.y.minus(center.y), p0.x.minus(center.x));
670
+ const endAngle = decimalAtan2(p3.y.minus(center.y), p3.x.minus(center.x));
671
+ let angleDiff = endAngle.minus(startAngle);
672
+
673
+ // Normalize angle difference based on sweep
674
+ const PI = Decimal.acos(-1);
675
+ const TWO_PI = PI.mul(2);
676
+
677
+ if (sweep === 1) {
678
+ // Clockwise: angle should be negative or we need to adjust
679
+ if (angleDiff.greaterThan(0)) {
680
+ angleDiff = angleDiff.minus(TWO_PI);
681
+ }
682
+ } else {
683
+ // Counter-clockwise: angle should be positive
684
+ if (angleDiff.lessThan(0)) {
685
+ angleDiff = angleDiff.plus(TWO_PI);
686
+ }
687
+ }
688
+
689
+ const largeArc = angleDiff.abs().greaterThan(PI) ? 1 : 0;
690
+
691
+ return {
692
+ rx: radius,
693
+ ry: radius,
694
+ rotation: D(0),
695
+ largeArc,
696
+ sweep,
697
+ endX: p3.x,
698
+ endY: p3.y,
699
+ maxDeviation: result.maxDeviation
700
+ };
701
+ }
702
+
703
+ // ============================================================================
704
+ // Arc-to-Line Detection (Sagitta)
705
+ // ============================================================================
706
+
707
+ /**
708
+ * Calculate the sagitta (arc height) of a circular arc.
709
+ *
710
+ * The sagitta is the distance from the midpoint of the chord to the arc.
711
+ * Formula: s = r - sqrt(r² - (c/2)²) where r is radius and c is chord length.
712
+ *
713
+ * @param {Decimal} radius - Arc radius
714
+ * @param {Decimal} chordLength - Length of the chord
715
+ * @returns {Decimal | null} Sagitta value, or null if chord > diameter
716
+ */
717
+ export function calculateSagitta(radius, chordLength) {
718
+ const r = D(radius);
719
+ const c = D(chordLength);
720
+ const halfChord = c.div(2);
721
+
722
+ // Check if chord is valid (must be <= 2*r)
723
+ if (halfChord.greaterThan(r)) {
724
+ return null; // Invalid: chord longer than diameter
725
+ }
726
+
727
+ // s = r - sqrt(r² - (c/2)²)
728
+ const rSquared = r.mul(r);
729
+ const halfChordSquared = halfChord.mul(halfChord);
730
+ const sagitta = r.minus(rSquared.minus(halfChordSquared).sqrt());
731
+
732
+ return sagitta;
733
+ }
734
+
735
+ /**
736
+ * Check if an arc is effectively a straight line based on sagitta.
737
+ *
738
+ * VERIFICATION: Samples the arc and verifies all points are within tolerance
739
+ * of the chord.
740
+ *
741
+ * @param {Decimal} rx - X radius
742
+ * @param {Decimal} ry - Y radius
743
+ * @param {Decimal} rotation - X-axis rotation in degrees
744
+ * @param {number} largeArc - Large arc flag (0 or 1)
745
+ * @param {number} sweep - Sweep flag (0 or 1)
746
+ * @param {{x: Decimal, y: Decimal}} start - Start point
747
+ * @param {{x: Decimal, y: Decimal}} end - End point
748
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
749
+ * @returns {{isStraight: boolean, sagitta: Decimal | null, maxDeviation: Decimal, verified: boolean}}
750
+ */
751
+ export function isArcStraight(rx, ry, rotation, largeArc, sweep, start, end, tolerance = DEFAULT_TOLERANCE) {
752
+ const tol = D(tolerance);
753
+ const rxD = D(rx);
754
+ const ryD = D(ry);
755
+
756
+ // Check for zero or near-zero radii
757
+ if (rxD.abs().lessThan(EPSILON) || ryD.abs().lessThan(EPSILON)) {
758
+ return {
759
+ isStraight: true,
760
+ sagitta: D(0),
761
+ maxDeviation: D(0),
762
+ verified: true
763
+ };
764
+ }
765
+
766
+ // Calculate chord length
767
+ const chordLength = distance(start, end);
768
+
769
+ // For circular arcs (rx = ry), use sagitta formula
770
+ if (rxD.minus(ryD).abs().lessThan(EPSILON)) {
771
+ const sagitta = calculateSagitta(rxD, chordLength);
772
+
773
+ if (sagitta === null) {
774
+ // Chord longer than diameter - arc wraps around
775
+ return {
776
+ isStraight: false,
777
+ sagitta: null,
778
+ maxDeviation: rxD, // Max deviation is at least the radius
779
+ verified: true
780
+ };
781
+ }
782
+
783
+ // For large arcs, sagitta is on the other side
784
+ const effectiveSagitta = largeArc ? rxD.mul(2).minus(sagitta) : sagitta;
785
+
786
+ return {
787
+ isStraight: effectiveSagitta.lessThan(tol),
788
+ sagitta: effectiveSagitta,
789
+ maxDeviation: effectiveSagitta,
790
+ verified: true
791
+ };
792
+ }
793
+
794
+ // For elliptical arcs, we need to sample
795
+ // This is more complex - for now, return false to be safe
796
+ return {
797
+ isStraight: false,
798
+ sagitta: null,
799
+ maxDeviation: Decimal.max(rxD, ryD),
800
+ verified: false
801
+ };
802
+ }
803
+
804
+ // ============================================================================
805
+ // Collinear Point Merging
806
+ // ============================================================================
807
+
808
+ /**
809
+ * Check if three points are collinear within tolerance.
810
+ *
811
+ * @param {{x: Decimal, y: Decimal}} p1 - First point
812
+ * @param {{x: Decimal, y: Decimal}} p2 - Second point (middle)
813
+ * @param {{x: Decimal, y: Decimal}} p3 - Third point
814
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Maximum allowed deviation
815
+ * @returns {boolean} True if collinear
816
+ */
817
+ export function areCollinear(p1, p2, p3, tolerance = DEFAULT_TOLERANCE) {
818
+ const tol = D(tolerance);
819
+
820
+ // Check using cross product (area of triangle)
821
+ const cross = crossProduct(p1, p2, p3).abs();
822
+
823
+ // Also check using perpendicular distance
824
+ const dist = pointToLineDistance(p2, p1, p3);
825
+
826
+ return cross.lessThan(tol) && dist.lessThan(tol);
827
+ }
828
+
829
+ /**
830
+ * Merge collinear consecutive line segments.
831
+ *
832
+ * Given a series of line segments (p0→p1, p1→p2, ..., pn-1→pn),
833
+ * merge consecutive collinear segments into single segments.
834
+ *
835
+ * VERIFICATION: The merged path passes through all original endpoints.
836
+ *
837
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Array of points forming line segments
838
+ * @param {Decimal} [tolerance=DEFAULT_TOLERANCE] - Collinearity tolerance
839
+ * @returns {{points: Array<{x: Decimal, y: Decimal}>, mergeCount: number, verified: boolean}}
840
+ */
841
+ export function mergeCollinearSegments(points, tolerance = DEFAULT_TOLERANCE) {
842
+ if (points.length < 3) {
843
+ return { points: [...points], mergeCount: 0, verified: true };
844
+ }
845
+
846
+ const tol = D(tolerance);
847
+ const result = [points[0]];
848
+ let mergeCount = 0;
849
+
850
+ for (let i = 1; i < points.length - 1; i++) {
851
+ const prev = result[result.length - 1];
852
+ const current = points[i];
853
+ const next = points[i + 1];
854
+
855
+ if (!areCollinear(prev, current, next, tol)) {
856
+ result.push(current);
857
+ } else {
858
+ mergeCount++;
859
+ }
860
+ }
861
+
862
+ // Always add the last point
863
+ result.push(points[points.length - 1]);
864
+
865
+ // VERIFICATION: All original points should be on the resulting path
866
+ // (within tolerance)
867
+ let verified = true;
868
+ for (const originalPoint of points) {
869
+ let onPath = false;
870
+ for (let i = 0; i < result.length - 1; i++) {
871
+ const dist = pointToLineDistance(originalPoint, result[i], result[i + 1]);
872
+ if (dist.lessThanOrEqualTo(tol)) {
873
+ onPath = true;
874
+ break;
875
+ }
876
+ }
877
+ // Check if it's one of the kept points
878
+ if (!onPath) {
879
+ for (const keptPoint of result) {
880
+ if (distance(originalPoint, keptPoint).lessThan(tol)) {
881
+ onPath = true;
882
+ break;
883
+ }
884
+ }
885
+ }
886
+ if (!onPath) {
887
+ verified = false;
888
+ break;
889
+ }
890
+ }
891
+
892
+ return { points: result, mergeCount, verified };
893
+ }
894
+
895
+ // ============================================================================
896
+ // Zero-Length Segment Removal
897
+ // ============================================================================
898
+
899
+ /**
900
+ * Check if a segment has zero length (start equals end).
901
+ *
902
+ * @param {{x: Decimal, y: Decimal}} start - Start point
903
+ * @param {{x: Decimal, y: Decimal}} end - End point
904
+ * @param {Decimal} [tolerance=EPSILON] - Zero tolerance
905
+ * @returns {boolean} True if zero-length
906
+ */
907
+ export function isZeroLengthSegment(start, end, tolerance = EPSILON) {
908
+ return distance(start, end).lessThan(D(tolerance));
909
+ }
910
+
911
+ /**
912
+ * Remove zero-length segments from a path.
913
+ *
914
+ * @param {Array<{command: string, args: Array<Decimal>}>} pathData - Path commands
915
+ * @param {Decimal} [tolerance=EPSILON] - Zero tolerance
916
+ * @returns {{pathData: Array<{command: string, args: Array<Decimal>}>, removeCount: number, verified: boolean}}
917
+ */
918
+ export function removeZeroLengthSegments(pathData, tolerance = EPSILON) {
919
+ const tol = D(tolerance);
920
+ const result = [];
921
+ let removeCount = 0;
922
+ let currentX = D(0), currentY = D(0);
923
+ let startX = D(0), startY = D(0);
924
+
925
+ for (const item of pathData) {
926
+ const { command, args } = item;
927
+ let keep = true;
928
+
929
+ switch (command.toUpperCase()) {
930
+ case 'M':
931
+ // Update current position (absolute M) or move relative (lowercase m)
932
+ currentX = command === 'M' ? D(args[0]) : currentX.plus(D(args[0]));
933
+ currentY = command === 'M' ? D(args[1]) : currentY.plus(D(args[1]));
934
+ // CRITICAL: Update subpath start for EVERY M command (BUG 3 FIX)
935
+ startX = currentX;
936
+ startY = currentY;
937
+ break;
938
+
939
+ case 'L': {
940
+ // Line to: x y (2 args)
941
+ const endX = command === 'L' ? D(args[0]) : currentX.plus(D(args[0]));
942
+ const endY = command === 'L' ? D(args[1]) : currentY.plus(D(args[1]));
943
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol)) {
944
+ keep = false;
945
+ removeCount++;
946
+ }
947
+ // CRITICAL: Always update position, even when removing segment (BUG 2 FIX)
948
+ currentX = endX;
949
+ currentY = endY;
950
+ break;
951
+ }
952
+
953
+ case 'T': {
954
+ // Smooth quadratic Bezier: x y (2 args) - BUG 4 FIX (separated from L)
955
+ const endX = command === 'T' ? D(args[0]) : currentX.plus(D(args[0]));
956
+ const endY = command === 'T' ? D(args[1]) : currentY.plus(D(args[1]));
957
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol)) {
958
+ keep = false;
959
+ removeCount++;
960
+ }
961
+ // CRITICAL: Always update position, even when removing segment (BUG 2 FIX)
962
+ currentX = endX;
963
+ currentY = endY;
964
+ break;
965
+ }
966
+
967
+ case 'H': {
968
+ const endX = command === 'H' ? D(args[0]) : currentX.plus(D(args[0]));
969
+ if (endX.minus(currentX).abs().lessThan(tol)) {
970
+ keep = false;
971
+ removeCount++;
972
+ } else {
973
+ currentX = endX;
974
+ }
975
+ break;
976
+ }
977
+
978
+ case 'V': {
979
+ const endY = command === 'V' ? D(args[0]) : currentY.plus(D(args[0]));
980
+ if (endY.minus(currentY).abs().lessThan(tol)) {
981
+ keep = false;
982
+ removeCount++;
983
+ } else {
984
+ currentY = endY;
985
+ }
986
+ break;
987
+ }
988
+
989
+ case 'C': {
990
+ const endX = command === 'C' ? D(args[4]) : currentX.plus(D(args[4]));
991
+ const endY = command === 'C' ? D(args[5]) : currentY.plus(D(args[5]));
992
+ // For curves, also check if all control points are at the same location
993
+ const cp1X = command === 'C' ? D(args[0]) : currentX.plus(D(args[0]));
994
+ const cp1Y = command === 'C' ? D(args[1]) : currentY.plus(D(args[1]));
995
+ const cp2X = command === 'C' ? D(args[2]) : currentX.plus(D(args[2]));
996
+ const cp2Y = command === 'C' ? D(args[3]) : currentY.plus(D(args[3]));
997
+
998
+ const allSame =
999
+ isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol) &&
1000
+ isZeroLengthSegment({ x: currentX, y: currentY }, { x: cp1X, y: cp1Y }, tol) &&
1001
+ isZeroLengthSegment({ x: currentX, y: currentY }, { x: cp2X, y: cp2Y }, tol);
1002
+
1003
+ if (allSame) {
1004
+ keep = false;
1005
+ removeCount++;
1006
+ } else {
1007
+ currentX = endX;
1008
+ currentY = endY;
1009
+ }
1010
+ break;
1011
+ }
1012
+
1013
+ case 'Q': {
1014
+ // Quadratic Bezier: x1 y1 x y (4 args)
1015
+ const endX = command === 'Q' ? D(args[2]) : currentX.plus(D(args[2]));
1016
+ const endY = command === 'Q' ? D(args[3]) : currentY.plus(D(args[3]));
1017
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol)) {
1018
+ // Check control point too
1019
+ const cpX = command === 'Q' ? D(args[0]) : currentX.plus(D(args[0]));
1020
+ const cpY = command === 'Q' ? D(args[1]) : currentY.plus(D(args[1]));
1021
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: cpX, y: cpY }, tol)) {
1022
+ keep = false;
1023
+ removeCount++;
1024
+ }
1025
+ }
1026
+ if (keep) {
1027
+ currentX = endX;
1028
+ currentY = endY;
1029
+ }
1030
+ break;
1031
+ }
1032
+
1033
+ case 'S': {
1034
+ // Smooth cubic Bezier: x2 y2 x y (4 args) - BUG 4 FIX
1035
+ const endX = command === 'S' ? D(args[2]) : currentX.plus(D(args[2]));
1036
+ const endY = command === 'S' ? D(args[3]) : currentY.plus(D(args[3]));
1037
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol)) {
1038
+ // Check second control point (first is reflected, not in args)
1039
+ const cp2X = command === 'S' ? D(args[0]) : currentX.plus(D(args[0]));
1040
+ const cp2Y = command === 'S' ? D(args[1]) : currentY.plus(D(args[1]));
1041
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: cp2X, y: cp2Y }, tol)) {
1042
+ keep = false;
1043
+ removeCount++;
1044
+ }
1045
+ }
1046
+ if (keep) {
1047
+ currentX = endX;
1048
+ currentY = endY;
1049
+ }
1050
+ break;
1051
+ }
1052
+
1053
+ case 'A': {
1054
+ const endX = command === 'A' ? D(args[5]) : currentX.plus(D(args[5]));
1055
+ const endY = command === 'A' ? D(args[6]) : currentY.plus(D(args[6]));
1056
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: endX, y: endY }, tol)) {
1057
+ keep = false;
1058
+ removeCount++;
1059
+ } else {
1060
+ currentX = endX;
1061
+ currentY = endY;
1062
+ }
1063
+ break;
1064
+ }
1065
+
1066
+ case 'Z':
1067
+ // Z command goes back to start - check if already there
1068
+ if (isZeroLengthSegment({ x: currentX, y: currentY }, { x: startX, y: startY }, tol)) {
1069
+ // Still keep Z for path closure, but note it's zero-length
1070
+ }
1071
+ currentX = startX;
1072
+ currentY = startY;
1073
+ break;
1074
+ }
1075
+
1076
+ if (keep) {
1077
+ result.push(item);
1078
+ }
1079
+ }
1080
+
1081
+ return {
1082
+ pathData: result,
1083
+ removeCount,
1084
+ verified: true
1085
+ };
1086
+ }
1087
+
1088
+ // ============================================================================
1089
+ // Exports
1090
+ // ============================================================================
1091
+
1092
+ export {
1093
+ EPSILON,
1094
+ DEFAULT_TOLERANCE,
1095
+ D
1096
+ };
1097
+
1098
+ export default {
1099
+ // Point utilities
1100
+ point,
1101
+ distance,
1102
+ distanceSquared,
1103
+ pointToLineDistance,
1104
+ crossProduct,
1105
+
1106
+ // Bezier evaluation
1107
+ evaluateCubicBezier,
1108
+ evaluateQuadraticBezier,
1109
+ evaluateLine,
1110
+
1111
+ // Curve-to-line detection
1112
+ isCubicBezierStraight,
1113
+ isQuadraticBezierStraight,
1114
+ cubicBezierToLine,
1115
+
1116
+ // Degree lowering
1117
+ canLowerCubicToQuadratic,
1118
+ cubicToQuadratic,
1119
+
1120
+ // Curve-to-arc detection
1121
+ fitCircleToPoints,
1122
+ fitCircleToCubicBezier,
1123
+ cubicBezierToArc,
1124
+
1125
+ // Arc-to-line detection
1126
+ calculateSagitta,
1127
+ isArcStraight,
1128
+
1129
+ // Collinear merging
1130
+ areCollinear,
1131
+ mergeCollinearSegments,
1132
+
1133
+ // Zero-length removal
1134
+ isZeroLengthSegment,
1135
+ removeZeroLengthSegments,
1136
+
1137
+ // Constants
1138
+ EPSILON,
1139
+ DEFAULT_TOLERANCE
1140
+ };