@emasoft/svg-matrix 1.0.19 → 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,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
+ };