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