@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,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform Decomposition with Arbitrary Precision and Mathematical Verification
|
|
3
|
+
*
|
|
4
|
+
* Decomposes 2D affine transformation matrices into their geometric components:
|
|
5
|
+
* translation, rotation, scale, and skew.
|
|
6
|
+
*
|
|
7
|
+
* Guarantees:
|
|
8
|
+
* 1. ARBITRARY PRECISION - All calculations use Decimal.js (50+ digits)
|
|
9
|
+
* 2. MATHEMATICAL VERIFICATION - Recomposition must match original matrix
|
|
10
|
+
*
|
|
11
|
+
* ## Mathematical Background
|
|
12
|
+
*
|
|
13
|
+
* A 2D affine transformation matrix has the form:
|
|
14
|
+
* | a c e |
|
|
15
|
+
* | b d f |
|
|
16
|
+
* | 0 0 1 |
|
|
17
|
+
*
|
|
18
|
+
* This can be decomposed into:
|
|
19
|
+
* M = T * R * Sk * S
|
|
20
|
+
*
|
|
21
|
+
* Where:
|
|
22
|
+
* - T = translation(e, f)
|
|
23
|
+
* - R = rotation(theta)
|
|
24
|
+
* - Sk = skewX(skewAngle)
|
|
25
|
+
* - S = scale(sx, sy)
|
|
26
|
+
*
|
|
27
|
+
* The decomposition uses QR-like factorization:
|
|
28
|
+
* 1. Translation (e, f) is directly from the matrix
|
|
29
|
+
* 2. The 2x2 submatrix [[a,c],[b,d]] is decomposed via polar decomposition
|
|
30
|
+
*
|
|
31
|
+
* @module transform-decomposition
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import Decimal from 'decimal.js';
|
|
35
|
+
import { Matrix } from './matrix.js';
|
|
36
|
+
|
|
37
|
+
// Set high precision for all calculations
|
|
38
|
+
Decimal.set({ precision: 80 });
|
|
39
|
+
|
|
40
|
+
// Helper to convert to Decimal
|
|
41
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
42
|
+
|
|
43
|
+
// Near-zero threshold for comparisons
|
|
44
|
+
const EPSILON = new Decimal('1e-40');
|
|
45
|
+
|
|
46
|
+
// Verification tolerance (larger than EPSILON for practical use)
|
|
47
|
+
const VERIFICATION_TOLERANCE = new Decimal('1e-30');
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Matrix Utilities
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a 3x3 identity matrix with Decimal values.
|
|
55
|
+
* @returns {Matrix} 3x3 identity matrix
|
|
56
|
+
*/
|
|
57
|
+
export function identityMatrix() {
|
|
58
|
+
return Matrix.identity(3);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a 2D translation matrix.
|
|
63
|
+
* @param {number|string|Decimal} tx - X translation
|
|
64
|
+
* @param {number|string|Decimal} ty - Y translation
|
|
65
|
+
* @returns {Matrix} 3x3 translation matrix
|
|
66
|
+
*/
|
|
67
|
+
export function translationMatrix(tx, ty) {
|
|
68
|
+
return Matrix.from([
|
|
69
|
+
[1, 0, D(tx)],
|
|
70
|
+
[0, 1, D(ty)],
|
|
71
|
+
[0, 0, 1]
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a 2D rotation matrix.
|
|
77
|
+
* @param {number|string|Decimal} angle - Rotation angle in radians
|
|
78
|
+
* @returns {Matrix} 3x3 rotation matrix
|
|
79
|
+
*/
|
|
80
|
+
export function rotationMatrix(angle) {
|
|
81
|
+
const theta = D(angle);
|
|
82
|
+
const cos = Decimal.cos(theta);
|
|
83
|
+
const sin = Decimal.sin(theta);
|
|
84
|
+
return Matrix.from([
|
|
85
|
+
[cos, sin.neg(), 0],
|
|
86
|
+
[sin, cos, 0],
|
|
87
|
+
[0, 0, 1]
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a 2D scale matrix.
|
|
93
|
+
* @param {number|string|Decimal} sx - X scale factor
|
|
94
|
+
* @param {number|string|Decimal} sy - Y scale factor
|
|
95
|
+
* @returns {Matrix} 3x3 scale matrix
|
|
96
|
+
*/
|
|
97
|
+
export function scaleMatrix(sx, sy) {
|
|
98
|
+
return Matrix.from([
|
|
99
|
+
[D(sx), 0, 0],
|
|
100
|
+
[0, D(sy), 0],
|
|
101
|
+
[0, 0, 1]
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a 2D skewX matrix.
|
|
107
|
+
* @param {number|string|Decimal} angle - Skew angle in radians
|
|
108
|
+
* @returns {Matrix} 3x3 skewX matrix
|
|
109
|
+
*/
|
|
110
|
+
export function skewXMatrix(angle) {
|
|
111
|
+
const tan = Decimal.tan(D(angle));
|
|
112
|
+
return Matrix.from([
|
|
113
|
+
[1, tan, 0],
|
|
114
|
+
[0, 1, 0],
|
|
115
|
+
[0, 0, 1]
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a 2D skewY matrix.
|
|
121
|
+
* @param {number|string|Decimal} angle - Skew angle in radians
|
|
122
|
+
* @returns {Matrix} 3x3 skewY matrix
|
|
123
|
+
*/
|
|
124
|
+
export function skewYMatrix(angle) {
|
|
125
|
+
const tan = Decimal.tan(D(angle));
|
|
126
|
+
return Matrix.from([
|
|
127
|
+
[1, 0, 0],
|
|
128
|
+
[tan, 1, 0],
|
|
129
|
+
[0, 0, 1]
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract the 2x2 linear transformation submatrix from a 3x3 affine matrix.
|
|
135
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
136
|
+
* @returns {{a: Decimal, b: Decimal, c: Decimal, d: Decimal}}
|
|
137
|
+
*/
|
|
138
|
+
export function extractLinearPart(matrix) {
|
|
139
|
+
const data = matrix.data;
|
|
140
|
+
return {
|
|
141
|
+
a: data[0][0],
|
|
142
|
+
b: data[1][0],
|
|
143
|
+
c: data[0][1],
|
|
144
|
+
d: data[1][1]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract the translation from a 3x3 affine matrix.
|
|
150
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
151
|
+
* @returns {{tx: Decimal, ty: Decimal}}
|
|
152
|
+
*/
|
|
153
|
+
export function extractTranslation(matrix) {
|
|
154
|
+
const data = matrix.data;
|
|
155
|
+
return {
|
|
156
|
+
tx: data[0][2],
|
|
157
|
+
ty: data[1][2]
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// Matrix Decomposition
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Decompose a 2D affine transformation matrix into geometric components.
|
|
167
|
+
*
|
|
168
|
+
* Uses QR decomposition approach:
|
|
169
|
+
* M = T * R * Sk * S
|
|
170
|
+
*
|
|
171
|
+
* Where T=translation, R=rotation, Sk=skewX, S=scale
|
|
172
|
+
*
|
|
173
|
+
* VERIFICATION: After decomposition, recompose and verify it matches original.
|
|
174
|
+
*
|
|
175
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
176
|
+
* @returns {{
|
|
177
|
+
* translateX: Decimal,
|
|
178
|
+
* translateY: Decimal,
|
|
179
|
+
* rotation: Decimal,
|
|
180
|
+
* scaleX: Decimal,
|
|
181
|
+
* scaleY: Decimal,
|
|
182
|
+
* skewX: Decimal,
|
|
183
|
+
* skewY: Decimal,
|
|
184
|
+
* verified: boolean,
|
|
185
|
+
* maxError?: Decimal,
|
|
186
|
+
* verificationError?: Decimal,
|
|
187
|
+
* singular?: boolean
|
|
188
|
+
* }}
|
|
189
|
+
*/
|
|
190
|
+
export function decomposeMatrix(matrix) {
|
|
191
|
+
const { a, b, c, d } = extractLinearPart(matrix);
|
|
192
|
+
const { tx, ty } = extractTranslation(matrix);
|
|
193
|
+
|
|
194
|
+
// Calculate determinant to detect reflection
|
|
195
|
+
const det = a.mul(d).minus(b.mul(c));
|
|
196
|
+
|
|
197
|
+
// Calculate scale factors using column norms
|
|
198
|
+
let scaleX = a.mul(a).plus(b.mul(b)).sqrt();
|
|
199
|
+
|
|
200
|
+
// BUG FIX 1: Check for singular matrix (scaleX = 0) before division
|
|
201
|
+
if (scaleX.abs().lessThan(EPSILON)) {
|
|
202
|
+
// Handle degenerate/singular matrix case (e.g., scale(0, y))
|
|
203
|
+
return {
|
|
204
|
+
translateX: D(0),
|
|
205
|
+
translateY: D(0),
|
|
206
|
+
rotation: D(0),
|
|
207
|
+
scaleX: D(0),
|
|
208
|
+
scaleY: D(0),
|
|
209
|
+
skewX: D(0),
|
|
210
|
+
skewY: D(0),
|
|
211
|
+
verified: false,
|
|
212
|
+
verificationError: D('Infinity'),
|
|
213
|
+
singular: true // Flag to indicate singular matrix
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let scaleY = det.div(scaleX);
|
|
218
|
+
|
|
219
|
+
// Handle reflection (negative determinant)
|
|
220
|
+
// We put the reflection in scaleX
|
|
221
|
+
if (det.lessThan(0)) {
|
|
222
|
+
scaleX = scaleX.neg();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Calculate rotation angle
|
|
226
|
+
// theta = atan2(b, a) for the first column after normalization
|
|
227
|
+
let rotation;
|
|
228
|
+
if (scaleX.abs().lessThan(EPSILON)) {
|
|
229
|
+
// Degenerate case: first column is zero
|
|
230
|
+
rotation = Decimal.atan2(d, c);
|
|
231
|
+
} else {
|
|
232
|
+
rotation = Decimal.atan2(b, a);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Calculate skew
|
|
236
|
+
// After removing rotation and scale, we get the skew
|
|
237
|
+
// The skew is: skewX = atan((a*c + b*d) / (a*d - b*c))
|
|
238
|
+
// But we need to be careful about the decomposition order
|
|
239
|
+
|
|
240
|
+
// Alternative approach: use the fact that
|
|
241
|
+
// [a c] = [cos -sin] [sx 0 ] [1 tan(skewX)]
|
|
242
|
+
// [b d] [sin cos] [0 sy ] [0 1 ]
|
|
243
|
+
|
|
244
|
+
// After rotation by -theta:
|
|
245
|
+
// [a' c'] = [a*cos+b*sin c*cos+d*sin]
|
|
246
|
+
// [b' d'] [-a*sin+b*cos -c*sin+d*cos]
|
|
247
|
+
|
|
248
|
+
const cosTheta = Decimal.cos(rotation);
|
|
249
|
+
const sinTheta = Decimal.sin(rotation);
|
|
250
|
+
|
|
251
|
+
// Rotate back to get the scale+skew matrix
|
|
252
|
+
const aPrime = a.mul(cosTheta).plus(b.mul(sinTheta));
|
|
253
|
+
const bPrime = a.neg().mul(sinTheta).plus(b.mul(cosTheta));
|
|
254
|
+
const cPrime = c.mul(cosTheta).plus(d.mul(sinTheta));
|
|
255
|
+
const dPrime = c.neg().mul(sinTheta).plus(d.mul(cosTheta));
|
|
256
|
+
|
|
257
|
+
// Now [aPrime cPrime] should be [scaleX, scaleX*tan(skewX)]
|
|
258
|
+
// [bPrime dPrime] [0, scaleY ]
|
|
259
|
+
|
|
260
|
+
// BUG FIX 2: Calculate skewX from tan(skewX) = cPrime/aPrime
|
|
261
|
+
// Note: atan2 gives the angle of a vector, but we need atan of the ratio
|
|
262
|
+
let skewX;
|
|
263
|
+
if (aPrime.abs().greaterThan(EPSILON)) {
|
|
264
|
+
// skewX = atan(cPrime/aPrime) because tan(skewX) = cPrime/aPrime
|
|
265
|
+
skewX = Decimal.atan(cPrime.div(aPrime));
|
|
266
|
+
} else {
|
|
267
|
+
// If aPrime is near zero, skewX is undefined or π/2
|
|
268
|
+
skewX = D(0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// BUG FIX 3: Document skewY limitation
|
|
272
|
+
// LIMITATION: This decomposition order (T * R * SkX * S) can only handle skewX.
|
|
273
|
+
// A matrix with both skewX and skewY cannot be uniquely decomposed into this form.
|
|
274
|
+
// To decompose matrices with skewY, a different decomposition order would be needed
|
|
275
|
+
// (e.g., T * R * SkY * SkX * S), but that would change the semantic meaning.
|
|
276
|
+
// For standard 2D affine transforms, skewY is typically 0.
|
|
277
|
+
const skewY = D(0);
|
|
278
|
+
|
|
279
|
+
// Recalculate scaleY from dPrime
|
|
280
|
+
// dPrime should equal scaleY after removing skew
|
|
281
|
+
const cosSkewX = Decimal.cos(skewX);
|
|
282
|
+
if (cosSkewX.abs().greaterThan(EPSILON)) {
|
|
283
|
+
// scaleY = dPrime / cos(skewX) - but we already have it from det/scaleX
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// VERIFICATION: Recompose and compare
|
|
287
|
+
const recomposed = composeTransform({
|
|
288
|
+
translateX: tx,
|
|
289
|
+
translateY: ty,
|
|
290
|
+
rotation,
|
|
291
|
+
scaleX,
|
|
292
|
+
scaleY,
|
|
293
|
+
skewX,
|
|
294
|
+
skewY: D(0)
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const maxError = matrixMaxDifference(matrix, recomposed);
|
|
298
|
+
const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
translateX: tx,
|
|
302
|
+
translateY: ty,
|
|
303
|
+
rotation,
|
|
304
|
+
scaleX,
|
|
305
|
+
scaleY,
|
|
306
|
+
skewX,
|
|
307
|
+
skewY,
|
|
308
|
+
verified,
|
|
309
|
+
maxError
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Alternative decomposition: Decompose into translate, rotate, scaleX, scaleY only (no skew).
|
|
315
|
+
*
|
|
316
|
+
* This is a simpler decomposition that ignores skew.
|
|
317
|
+
* Useful when you know the transform doesn't have skew.
|
|
318
|
+
*
|
|
319
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
320
|
+
* @returns {{
|
|
321
|
+
* translateX: Decimal,
|
|
322
|
+
* translateY: Decimal,
|
|
323
|
+
* rotation: Decimal,
|
|
324
|
+
* scaleX: Decimal,
|
|
325
|
+
* scaleY: Decimal,
|
|
326
|
+
* verified: boolean,
|
|
327
|
+
* maxError: Decimal
|
|
328
|
+
* }}
|
|
329
|
+
*/
|
|
330
|
+
export function decomposeMatrixNoSkew(matrix) {
|
|
331
|
+
const { a, b, c, d } = extractLinearPart(matrix);
|
|
332
|
+
const { tx, ty } = extractTranslation(matrix);
|
|
333
|
+
|
|
334
|
+
// Calculate determinant
|
|
335
|
+
const det = a.mul(d).minus(b.mul(c));
|
|
336
|
+
|
|
337
|
+
// Calculate rotation from the first column
|
|
338
|
+
const rotation = Decimal.atan2(b, a);
|
|
339
|
+
|
|
340
|
+
// Calculate scales
|
|
341
|
+
const scaleX = a.mul(a).plus(b.mul(b)).sqrt();
|
|
342
|
+
let scaleY = c.mul(c).plus(d.mul(d)).sqrt();
|
|
343
|
+
|
|
344
|
+
// Adjust scaleY sign based on determinant
|
|
345
|
+
if (det.lessThan(0)) {
|
|
346
|
+
scaleY = scaleY.neg();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// VERIFICATION
|
|
350
|
+
const recomposed = composeTransformNoSkew({
|
|
351
|
+
translateX: tx,
|
|
352
|
+
translateY: ty,
|
|
353
|
+
rotation,
|
|
354
|
+
scaleX,
|
|
355
|
+
scaleY
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const maxError = matrixMaxDifference(matrix, recomposed);
|
|
359
|
+
const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
translateX: tx,
|
|
363
|
+
translateY: ty,
|
|
364
|
+
rotation,
|
|
365
|
+
scaleX,
|
|
366
|
+
scaleY,
|
|
367
|
+
verified,
|
|
368
|
+
maxError
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Decompose using CSS-style decomposition order.
|
|
374
|
+
*
|
|
375
|
+
* CSS transforms are applied right-to-left:
|
|
376
|
+
* transform: translate(tx, ty) rotate(r) skewX(sk) scale(sx, sy)
|
|
377
|
+
*
|
|
378
|
+
* This means: M = T * R * Sk * S
|
|
379
|
+
*
|
|
380
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
381
|
+
* @returns {{
|
|
382
|
+
* translateX: Decimal,
|
|
383
|
+
* translateY: Decimal,
|
|
384
|
+
* rotation: Decimal,
|
|
385
|
+
* scaleX: Decimal,
|
|
386
|
+
* scaleY: Decimal,
|
|
387
|
+
* skewX: Decimal,
|
|
388
|
+
* verified: boolean,
|
|
389
|
+
* maxError: Decimal
|
|
390
|
+
* }}
|
|
391
|
+
*/
|
|
392
|
+
export function decomposeMatrixCSS(matrix) {
|
|
393
|
+
// This is the same as decomposeMatrix but named for clarity
|
|
394
|
+
return decomposeMatrix(matrix);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Decompose using SVG-style decomposition.
|
|
399
|
+
*
|
|
400
|
+
* SVG transforms are also applied right-to-left, same as CSS.
|
|
401
|
+
*
|
|
402
|
+
* @param {Matrix} matrix - 3x3 affine transformation matrix
|
|
403
|
+
* @returns {Object} Decomposition result
|
|
404
|
+
*/
|
|
405
|
+
export function decomposeMatrixSVG(matrix) {
|
|
406
|
+
return decomposeMatrix(matrix);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// Matrix Composition
|
|
411
|
+
// ============================================================================
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Compose a transformation matrix from geometric components.
|
|
415
|
+
*
|
|
416
|
+
* Order: T * R * SkX * S (translate, then rotate, then skew, then scale)
|
|
417
|
+
*
|
|
418
|
+
* @param {{
|
|
419
|
+
* translateX: number|string|Decimal,
|
|
420
|
+
* translateY: number|string|Decimal,
|
|
421
|
+
* rotation: number|string|Decimal,
|
|
422
|
+
* scaleX: number|string|Decimal,
|
|
423
|
+
* scaleY: number|string|Decimal,
|
|
424
|
+
* skewX: number|string|Decimal,
|
|
425
|
+
* skewY?: number|string|Decimal
|
|
426
|
+
* }} components - Transform components
|
|
427
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
428
|
+
*/
|
|
429
|
+
export function composeTransform(components) {
|
|
430
|
+
const {
|
|
431
|
+
translateX,
|
|
432
|
+
translateY,
|
|
433
|
+
rotation,
|
|
434
|
+
scaleX,
|
|
435
|
+
scaleY,
|
|
436
|
+
skewX,
|
|
437
|
+
skewY = 0
|
|
438
|
+
} = components;
|
|
439
|
+
|
|
440
|
+
// Build matrices
|
|
441
|
+
const T = translationMatrix(translateX, translateY);
|
|
442
|
+
const R = rotationMatrix(rotation);
|
|
443
|
+
const SkX = skewXMatrix(skewX);
|
|
444
|
+
const SkY = skewYMatrix(skewY);
|
|
445
|
+
const S = scaleMatrix(scaleX, scaleY);
|
|
446
|
+
|
|
447
|
+
// Compose: T * R * SkY * SkX * S
|
|
448
|
+
// Note: Matrix.mul does right multiplication, so we chain left-to-right
|
|
449
|
+
return T.mul(R).mul(SkY).mul(SkX).mul(S);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Compose a transformation matrix without skew.
|
|
454
|
+
*
|
|
455
|
+
* @param {{
|
|
456
|
+
* translateX: number|string|Decimal,
|
|
457
|
+
* translateY: number|string|Decimal,
|
|
458
|
+
* rotation: number|string|Decimal,
|
|
459
|
+
* scaleX: number|string|Decimal,
|
|
460
|
+
* scaleY: number|string|Decimal
|
|
461
|
+
* }} components - Transform components
|
|
462
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
463
|
+
*/
|
|
464
|
+
export function composeTransformNoSkew(components) {
|
|
465
|
+
const { translateX, translateY, rotation, scaleX, scaleY } = components;
|
|
466
|
+
|
|
467
|
+
const T = translationMatrix(translateX, translateY);
|
|
468
|
+
const R = rotationMatrix(rotation);
|
|
469
|
+
const S = scaleMatrix(scaleX, scaleY);
|
|
470
|
+
|
|
471
|
+
return T.mul(R).mul(S);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ============================================================================
|
|
475
|
+
// Verification Utilities
|
|
476
|
+
// ============================================================================
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Calculate the maximum absolute difference between two matrices.
|
|
480
|
+
*
|
|
481
|
+
* @param {Matrix} m1 - First matrix
|
|
482
|
+
* @param {Matrix} m2 - Second matrix
|
|
483
|
+
* @returns {Decimal} Maximum absolute difference
|
|
484
|
+
*/
|
|
485
|
+
export function matrixMaxDifference(m1, m2) {
|
|
486
|
+
let maxDiff = D(0);
|
|
487
|
+
|
|
488
|
+
for (let i = 0; i < m1.rows; i++) {
|
|
489
|
+
for (let j = 0; j < m1.cols; j++) {
|
|
490
|
+
const diff = m1.data[i][j].minus(m2.data[i][j]).abs();
|
|
491
|
+
if (diff.greaterThan(maxDiff)) {
|
|
492
|
+
maxDiff = diff;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return maxDiff;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Check if two matrices are equal within tolerance.
|
|
502
|
+
*
|
|
503
|
+
* @param {Matrix} m1 - First matrix
|
|
504
|
+
* @param {Matrix} m2 - Second matrix
|
|
505
|
+
* @param {Decimal} [tolerance=VERIFICATION_TOLERANCE] - Maximum allowed difference
|
|
506
|
+
* @returns {boolean} True if matrices are equal within tolerance
|
|
507
|
+
*/
|
|
508
|
+
export function matricesEqual(m1, m2, tolerance = VERIFICATION_TOLERANCE) {
|
|
509
|
+
return matrixMaxDifference(m1, m2).lessThan(D(tolerance));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Verify a decomposition by recomposing and comparing to original.
|
|
514
|
+
*
|
|
515
|
+
* @param {Matrix} original - Original matrix
|
|
516
|
+
* @param {Object} decomposition - Decomposition result from decomposeMatrix
|
|
517
|
+
* @returns {{verified: boolean, maxError: Decimal}}
|
|
518
|
+
*/
|
|
519
|
+
export function verifyDecomposition(original, decomposition) {
|
|
520
|
+
const recomposed = composeTransform(decomposition);
|
|
521
|
+
const maxError = matrixMaxDifference(original, recomposed);
|
|
522
|
+
return {
|
|
523
|
+
verified: maxError.lessThan(VERIFICATION_TOLERANCE),
|
|
524
|
+
maxError
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ============================================================================
|
|
529
|
+
// SVG Transform String Parsing/Generation
|
|
530
|
+
// ============================================================================
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Create a matrix from SVG transform string values [a, b, c, d, e, f].
|
|
534
|
+
*
|
|
535
|
+
* SVG matrix(a, b, c, d, e, f) corresponds to:
|
|
536
|
+
* | a c e |
|
|
537
|
+
* | b d f |
|
|
538
|
+
* | 0 0 1 |
|
|
539
|
+
*
|
|
540
|
+
* @param {number|string|Decimal} a - Scale X
|
|
541
|
+
* @param {number|string|Decimal} b - Skew Y
|
|
542
|
+
* @param {number|string|Decimal} c - Skew X
|
|
543
|
+
* @param {number|string|Decimal} d - Scale Y
|
|
544
|
+
* @param {number|string|Decimal} e - Translate X
|
|
545
|
+
* @param {number|string|Decimal} f - Translate Y
|
|
546
|
+
* @returns {Matrix} 3x3 transformation matrix
|
|
547
|
+
*/
|
|
548
|
+
export function matrixFromSVGValues(a, b, c, d, e, f) {
|
|
549
|
+
return Matrix.from([
|
|
550
|
+
[D(a), D(c), D(e)],
|
|
551
|
+
[D(b), D(d), D(f)],
|
|
552
|
+
[0, 0, 1]
|
|
553
|
+
]);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Convert a matrix to SVG transform string values [a, b, c, d, e, f].
|
|
558
|
+
*
|
|
559
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
560
|
+
* @param {number} [precision=6] - Decimal places for output
|
|
561
|
+
* @returns {{a: string, b: string, c: string, d: string, e: string, f: string}}
|
|
562
|
+
*/
|
|
563
|
+
export function matrixToSVGValues(matrix, precision = 6) {
|
|
564
|
+
const data = matrix.data;
|
|
565
|
+
return {
|
|
566
|
+
a: data[0][0].toFixed(precision),
|
|
567
|
+
b: data[1][0].toFixed(precision),
|
|
568
|
+
c: data[0][1].toFixed(precision),
|
|
569
|
+
d: data[1][1].toFixed(precision),
|
|
570
|
+
e: data[0][2].toFixed(precision),
|
|
571
|
+
f: data[1][2].toFixed(precision)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Convert decomposition to SVG transform string.
|
|
577
|
+
*
|
|
578
|
+
* @param {Object} decomposition - Decomposition result
|
|
579
|
+
* @param {number} [precision=6] - Decimal places for output
|
|
580
|
+
* @returns {string} SVG transform string
|
|
581
|
+
*/
|
|
582
|
+
export function decompositionToSVGString(decomposition, precision = 6) {
|
|
583
|
+
const { translateX, translateY, rotation, scaleX, scaleY, skewX, skewY } = decomposition;
|
|
584
|
+
|
|
585
|
+
const parts = [];
|
|
586
|
+
|
|
587
|
+
// Add translate if non-zero
|
|
588
|
+
if (!D(translateX).abs().lessThan(EPSILON) || !D(translateY).abs().lessThan(EPSILON)) {
|
|
589
|
+
parts.push(`translate(${D(translateX).toFixed(precision)}, ${D(translateY).toFixed(precision)})`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Add rotate if non-zero
|
|
593
|
+
const PI = Decimal.acos(-1);
|
|
594
|
+
const rotationDegrees = D(rotation).mul(180).div(PI);
|
|
595
|
+
if (!rotationDegrees.abs().lessThan(EPSILON)) {
|
|
596
|
+
parts.push(`rotate(${rotationDegrees.toFixed(precision)})`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Add skewX if non-zero
|
|
600
|
+
if (skewX && !D(skewX).abs().lessThan(EPSILON)) {
|
|
601
|
+
const skewXDegrees = D(skewX).mul(180).div(PI);
|
|
602
|
+
parts.push(`skewX(${skewXDegrees.toFixed(precision)})`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Add skewY if non-zero
|
|
606
|
+
if (skewY && !D(skewY).abs().lessThan(EPSILON)) {
|
|
607
|
+
const skewYDegrees = D(skewY).mul(180).div(PI);
|
|
608
|
+
parts.push(`skewY(${skewYDegrees.toFixed(precision)})`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Add scale if not identity
|
|
612
|
+
const sxIsOne = D(scaleX).minus(1).abs().lessThan(EPSILON);
|
|
613
|
+
const syIsOne = D(scaleY).minus(1).abs().lessThan(EPSILON);
|
|
614
|
+
|
|
615
|
+
if (!sxIsOne || !syIsOne) {
|
|
616
|
+
if (D(scaleX).minus(D(scaleY)).abs().lessThan(EPSILON)) {
|
|
617
|
+
parts.push(`scale(${D(scaleX).toFixed(precision)})`);
|
|
618
|
+
} else {
|
|
619
|
+
parts.push(`scale(${D(scaleX).toFixed(precision)}, ${D(scaleY).toFixed(precision)})`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return parts.length > 0 ? parts.join(' ') : '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Convert matrix to minimal SVG transform string.
|
|
628
|
+
*
|
|
629
|
+
* Decomposes the matrix and outputs the shortest valid representation.
|
|
630
|
+
*
|
|
631
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
632
|
+
* @param {number} [precision=6] - Decimal places for output
|
|
633
|
+
* @returns {{transform: string, isIdentity: boolean, verified: boolean}}
|
|
634
|
+
*/
|
|
635
|
+
export function matrixToMinimalSVGTransform(matrix, precision = 6) {
|
|
636
|
+
// Check if identity
|
|
637
|
+
const identity = Matrix.identity(3);
|
|
638
|
+
if (matricesEqual(matrix, identity, EPSILON)) {
|
|
639
|
+
return { transform: '', isIdentity: true, verified: true };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Decompose
|
|
643
|
+
const decomposition = decomposeMatrix(matrix);
|
|
644
|
+
|
|
645
|
+
// Generate string
|
|
646
|
+
const transform = decompositionToSVGString(decomposition, precision);
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
transform,
|
|
650
|
+
isIdentity: false,
|
|
651
|
+
verified: decomposition.verified
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// Special Cases
|
|
657
|
+
// ============================================================================
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Check if a matrix represents a pure translation.
|
|
661
|
+
*
|
|
662
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
663
|
+
* @returns {{isTranslation: boolean, tx: Decimal, ty: Decimal}}
|
|
664
|
+
*/
|
|
665
|
+
export function isPureTranslation(matrix) {
|
|
666
|
+
const { a, b, c, d } = extractLinearPart(matrix);
|
|
667
|
+
const { tx, ty } = extractTranslation(matrix);
|
|
668
|
+
|
|
669
|
+
const isIdentityLinear =
|
|
670
|
+
a.minus(1).abs().lessThan(EPSILON) &&
|
|
671
|
+
b.abs().lessThan(EPSILON) &&
|
|
672
|
+
c.abs().lessThan(EPSILON) &&
|
|
673
|
+
d.minus(1).abs().lessThan(EPSILON);
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
isTranslation: isIdentityLinear,
|
|
677
|
+
tx,
|
|
678
|
+
ty
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Check if a matrix represents a pure rotation (around origin).
|
|
684
|
+
*
|
|
685
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
686
|
+
* @returns {{isRotation: boolean, angle: Decimal}}
|
|
687
|
+
*/
|
|
688
|
+
export function isPureRotation(matrix) {
|
|
689
|
+
const { a, b, c, d } = extractLinearPart(matrix);
|
|
690
|
+
const { tx, ty } = extractTranslation(matrix);
|
|
691
|
+
|
|
692
|
+
// Check no translation
|
|
693
|
+
if (!tx.abs().lessThan(EPSILON) || !ty.abs().lessThan(EPSILON)) {
|
|
694
|
+
return { isRotation: false, angle: D(0) };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Check orthogonality: a*c + b*d = 0
|
|
698
|
+
const orthogonal = a.mul(c).plus(b.mul(d)).abs().lessThan(EPSILON);
|
|
699
|
+
|
|
700
|
+
// Check unit columns: a² + b² = 1, c² + d² = 1
|
|
701
|
+
const col1Norm = a.mul(a).plus(b.mul(b));
|
|
702
|
+
const col2Norm = c.mul(c).plus(d.mul(d));
|
|
703
|
+
const unitNorm = col1Norm.minus(1).abs().lessThan(EPSILON) &&
|
|
704
|
+
col2Norm.minus(1).abs().lessThan(EPSILON);
|
|
705
|
+
|
|
706
|
+
// Check determinant = 1 (no reflection)
|
|
707
|
+
const det = a.mul(d).minus(b.mul(c));
|
|
708
|
+
const detOne = det.minus(1).abs().lessThan(EPSILON);
|
|
709
|
+
|
|
710
|
+
if (orthogonal && unitNorm && detOne) {
|
|
711
|
+
const angle = Decimal.atan2(b, a);
|
|
712
|
+
return { isRotation: true, angle };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return { isRotation: false, angle: D(0) };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Check if a matrix represents a pure scale (uniform or non-uniform).
|
|
720
|
+
*
|
|
721
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
722
|
+
* @returns {{isScale: boolean, scaleX: Decimal, scaleY: Decimal, isUniform: boolean}}
|
|
723
|
+
*/
|
|
724
|
+
export function isPureScale(matrix) {
|
|
725
|
+
const { a, b, c, d } = extractLinearPart(matrix);
|
|
726
|
+
const { tx, ty } = extractTranslation(matrix);
|
|
727
|
+
|
|
728
|
+
// Check no translation
|
|
729
|
+
if (!tx.abs().lessThan(EPSILON) || !ty.abs().lessThan(EPSILON)) {
|
|
730
|
+
return { isScale: false, scaleX: D(1), scaleY: D(1), isUniform: false };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Check diagonal: b = 0, c = 0
|
|
734
|
+
if (!b.abs().lessThan(EPSILON) || !c.abs().lessThan(EPSILON)) {
|
|
735
|
+
return { isScale: false, scaleX: D(1), scaleY: D(1), isUniform: false };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const isUniform = a.minus(d).abs().lessThan(EPSILON);
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
isScale: true,
|
|
742
|
+
scaleX: a,
|
|
743
|
+
scaleY: d,
|
|
744
|
+
isUniform
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Check if a matrix is the identity matrix.
|
|
750
|
+
*
|
|
751
|
+
* @param {Matrix} matrix - 3x3 transformation matrix
|
|
752
|
+
* @returns {boolean} True if identity
|
|
753
|
+
*/
|
|
754
|
+
export function isIdentityMatrix(matrix) {
|
|
755
|
+
const identity = Matrix.identity(3);
|
|
756
|
+
return matricesEqual(matrix, identity, EPSILON);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ============================================================================
|
|
760
|
+
// Exports
|
|
761
|
+
// ============================================================================
|
|
762
|
+
|
|
763
|
+
export {
|
|
764
|
+
EPSILON,
|
|
765
|
+
VERIFICATION_TOLERANCE,
|
|
766
|
+
D
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
export default {
|
|
770
|
+
// Matrix utilities
|
|
771
|
+
identityMatrix,
|
|
772
|
+
translationMatrix,
|
|
773
|
+
rotationMatrix,
|
|
774
|
+
scaleMatrix,
|
|
775
|
+
skewXMatrix,
|
|
776
|
+
skewYMatrix,
|
|
777
|
+
extractLinearPart,
|
|
778
|
+
extractTranslation,
|
|
779
|
+
|
|
780
|
+
// Decomposition
|
|
781
|
+
decomposeMatrix,
|
|
782
|
+
decomposeMatrixNoSkew,
|
|
783
|
+
decomposeMatrixCSS,
|
|
784
|
+
decomposeMatrixSVG,
|
|
785
|
+
|
|
786
|
+
// Composition
|
|
787
|
+
composeTransform,
|
|
788
|
+
composeTransformNoSkew,
|
|
789
|
+
|
|
790
|
+
// Verification
|
|
791
|
+
matrixMaxDifference,
|
|
792
|
+
matricesEqual,
|
|
793
|
+
verifyDecomposition,
|
|
794
|
+
|
|
795
|
+
// SVG utilities
|
|
796
|
+
matrixFromSVGValues,
|
|
797
|
+
matrixToSVGValues,
|
|
798
|
+
decompositionToSVGString,
|
|
799
|
+
matrixToMinimalSVGTransform,
|
|
800
|
+
|
|
801
|
+
// Special cases
|
|
802
|
+
isPureTranslation,
|
|
803
|
+
isPureRotation,
|
|
804
|
+
isPureScale,
|
|
805
|
+
isIdentityMatrix,
|
|
806
|
+
|
|
807
|
+
// Constants
|
|
808
|
+
EPSILON,
|
|
809
|
+
VERIFICATION_TOLERANCE
|
|
810
|
+
};
|