@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,936 @@
1
+ /**
2
+ * Transform Optimization with Arbitrary Precision and Mathematical Verification
3
+ *
4
+ * Optimizes sequences of 2D affine transformations by merging compatible transforms,
5
+ * removing redundancies, and converting matrices to simpler forms when possible.
6
+ *
7
+ * Guarantees:
8
+ * 1. ARBITRARY PRECISION - All calculations use Decimal.js (80 digits)
9
+ * 2. MATHEMATICAL VERIFICATION - Every optimization verifies that the result is equivalent
10
+ *
11
+ * ## Optimization Strategies
12
+ *
13
+ * 1. **Merge Adjacent Transforms**: Combine consecutive transforms of the same type
14
+ * - translate + translate → single translate
15
+ * - rotate + rotate (same center) → single rotate
16
+ * - scale + scale → single scale
17
+ *
18
+ * 2. **Remove Identity Transforms**: Remove transforms that have no effect
19
+ * - translate(0, 0)
20
+ * - rotate(0) or rotate(2π)
21
+ * - scale(1, 1)
22
+ *
23
+ * 3. **Matrix Simplification**: Convert general matrices to simpler forms
24
+ * - Pure translation matrix → translate()
25
+ * - Pure rotation matrix → rotate()
26
+ * - Pure scale matrix → scale()
27
+ *
28
+ * 4. **Shorthand Notation**: Combine translate-rotate-translate sequences
29
+ * - translate + rotate + translate⁻¹ → rotate(angle, cx, cy)
30
+ *
31
+ * @module transform-optimization
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 (imported patterns)
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 rotation matrix around a specific point.
93
+ * @param {number|string|Decimal} angle - Rotation angle in radians
94
+ * @param {number|string|Decimal} cx - X coordinate of rotation center
95
+ * @param {number|string|Decimal} cy - Y coordinate of rotation center
96
+ * @returns {Matrix} 3x3 rotation matrix around point (cx, cy)
97
+ */
98
+ export function rotationMatrixAroundPoint(angle, cx, cy) {
99
+ const cxD = D(cx);
100
+ const cyD = D(cy);
101
+ const T1 = translationMatrix(cxD.neg(), cyD.neg());
102
+ const R = rotationMatrix(angle);
103
+ const T2 = translationMatrix(cxD, cyD);
104
+ return T2.mul(R).mul(T1);
105
+ }
106
+
107
+ /**
108
+ * Create a 2D scale matrix.
109
+ * @param {number|string|Decimal} sx - X scale factor
110
+ * @param {number|string|Decimal} sy - Y scale factor
111
+ * @returns {Matrix} 3x3 scale matrix
112
+ */
113
+ export function scaleMatrix(sx, sy) {
114
+ return Matrix.from([
115
+ [D(sx), 0, 0],
116
+ [0, D(sy), 0],
117
+ [0, 0, 1]
118
+ ]);
119
+ }
120
+
121
+ /**
122
+ * Calculate the maximum absolute difference between two matrices.
123
+ * @param {Matrix} m1 - First matrix
124
+ * @param {Matrix} m2 - Second matrix
125
+ * @returns {Decimal} Maximum absolute difference
126
+ */
127
+ export function matrixMaxDifference(m1, m2) {
128
+ let maxDiff = D(0);
129
+
130
+ for (let i = 0; i < m1.rows; i++) {
131
+ for (let j = 0; j < m1.cols; j++) {
132
+ const diff = m1.data[i][j].minus(m2.data[i][j]).abs();
133
+ if (diff.greaterThan(maxDiff)) {
134
+ maxDiff = diff;
135
+ }
136
+ }
137
+ }
138
+
139
+ return maxDiff;
140
+ }
141
+
142
+ /**
143
+ * Check if two matrices are equal within tolerance.
144
+ * @param {Matrix} m1 - First matrix
145
+ * @param {Matrix} m2 - Second matrix
146
+ * @param {Decimal} [tolerance=VERIFICATION_TOLERANCE] - Maximum allowed difference
147
+ * @returns {boolean} True if matrices are equal within tolerance
148
+ */
149
+ export function matricesEqual(m1, m2, tolerance = VERIFICATION_TOLERANCE) {
150
+ return matrixMaxDifference(m1, m2).lessThan(D(tolerance));
151
+ }
152
+
153
+ // ============================================================================
154
+ // Transform Merging Functions
155
+ // ============================================================================
156
+
157
+ /**
158
+ * Merge two translation transforms into a single translation.
159
+ *
160
+ * Mathematical formula:
161
+ * translate(a, b) × translate(c, d) = translate(a + c, b + d)
162
+ *
163
+ * VERIFICATION: The result matrix must equal the product of the two input matrices.
164
+ *
165
+ * @param {{tx: number|string|Decimal, ty: number|string|Decimal}} t1 - First translation
166
+ * @param {{tx: number|string|Decimal, ty: number|string|Decimal}} t2 - Second translation
167
+ * @returns {{
168
+ * tx: Decimal,
169
+ * ty: Decimal,
170
+ * verified: boolean,
171
+ * maxError: Decimal
172
+ * }} Merged translation with verification result
173
+ *
174
+ * @example
175
+ * // Merge translate(5, 10) and translate(3, -2)
176
+ * const result = mergeTranslations({tx: 5, ty: 10}, {tx: 3, ty: -2});
177
+ * // Result: {tx: 8, ty: 8, verified: true}
178
+ */
179
+ export function mergeTranslations(t1, t2) {
180
+ // Calculate merged translation: sum of components
181
+ const tx = D(t1.tx).plus(D(t2.tx));
182
+ const ty = D(t1.ty).plus(D(t2.ty));
183
+
184
+ // VERIFICATION: Matrix multiplication must give same result
185
+ const M1 = translationMatrix(t1.tx, t1.ty);
186
+ const M2 = translationMatrix(t2.tx, t2.ty);
187
+ const product = M1.mul(M2);
188
+ const merged = translationMatrix(tx, ty);
189
+
190
+ const maxError = matrixMaxDifference(product, merged);
191
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
192
+
193
+ return {
194
+ tx,
195
+ ty,
196
+ verified,
197
+ maxError
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Merge two rotation transforms around the origin into a single rotation.
203
+ *
204
+ * Mathematical formula:
205
+ * rotate(a) × rotate(b) = rotate(a + b)
206
+ *
207
+ * Note: This only works for rotations around the SAME point (origin).
208
+ * For rotations around different points, this function should not be used.
209
+ *
210
+ * VERIFICATION: The result matrix must equal the product of the two input matrices.
211
+ *
212
+ * @param {{angle: number|string|Decimal}} r1 - First rotation
213
+ * @param {{angle: number|string|Decimal}} r2 - Second rotation
214
+ * @returns {{
215
+ * angle: Decimal,
216
+ * verified: boolean,
217
+ * maxError: Decimal
218
+ * }} Merged rotation with verification result
219
+ *
220
+ * @example
221
+ * // Merge rotate(π/4) and rotate(π/4)
222
+ * const result = mergeRotations({angle: Math.PI/4}, {angle: Math.PI/4});
223
+ * // Result: {angle: π/2, verified: true}
224
+ */
225
+ export function mergeRotations(r1, r2) {
226
+ // Calculate merged rotation: sum of angles
227
+ const angle = D(r1.angle).plus(D(r2.angle));
228
+
229
+ // Normalize angle to [-π, π]
230
+ const PI = Decimal.acos(-1);
231
+ const TWO_PI = PI.mul(2);
232
+ let normalizedAngle = angle.mod(TWO_PI);
233
+ if (normalizedAngle.greaterThan(PI)) {
234
+ normalizedAngle = normalizedAngle.minus(TWO_PI);
235
+ } else if (normalizedAngle.lessThan(PI.neg())) {
236
+ normalizedAngle = normalizedAngle.plus(TWO_PI);
237
+ }
238
+
239
+ // VERIFICATION: Matrix multiplication must give same result
240
+ const M1 = rotationMatrix(r1.angle);
241
+ const M2 = rotationMatrix(r2.angle);
242
+ const product = M1.mul(M2);
243
+ const merged = rotationMatrix(normalizedAngle);
244
+
245
+ const maxError = matrixMaxDifference(product, merged);
246
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
247
+
248
+ return {
249
+ angle: normalizedAngle,
250
+ verified,
251
+ maxError
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Merge two scale transforms into a single scale.
257
+ *
258
+ * Mathematical formula:
259
+ * scale(a, b) × scale(c, d) = scale(a × c, b × d)
260
+ *
261
+ * VERIFICATION: The result matrix must equal the product of the two input matrices.
262
+ *
263
+ * @param {{sx: number|string|Decimal, sy: number|string|Decimal}} s1 - First scale
264
+ * @param {{sx: number|string|Decimal, sy: number|string|Decimal}} s2 - Second scale
265
+ * @returns {{
266
+ * sx: Decimal,
267
+ * sy: Decimal,
268
+ * verified: boolean,
269
+ * maxError: Decimal
270
+ * }} Merged scale with verification result
271
+ *
272
+ * @example
273
+ * // Merge scale(2, 3) and scale(1.5, 0.5)
274
+ * const result = mergeScales({sx: 2, sy: 3}, {sx: 1.5, sy: 0.5});
275
+ * // Result: {sx: 3, sy: 1.5, verified: true}
276
+ */
277
+ export function mergeScales(s1, s2) {
278
+ // Calculate merged scale: product of components
279
+ const sx = D(s1.sx).mul(D(s2.sx));
280
+ const sy = D(s1.sy).mul(D(s2.sy));
281
+
282
+ // VERIFICATION: Matrix multiplication must give same result
283
+ const M1 = scaleMatrix(s1.sx, s1.sy);
284
+ const M2 = scaleMatrix(s2.sx, s2.sy);
285
+ const product = M1.mul(M2);
286
+ const merged = scaleMatrix(sx, sy);
287
+
288
+ const maxError = matrixMaxDifference(product, merged);
289
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
290
+
291
+ return {
292
+ sx,
293
+ sy,
294
+ verified,
295
+ maxError
296
+ };
297
+ }
298
+
299
+ // ============================================================================
300
+ // Matrix to Transform Conversion Functions
301
+ // ============================================================================
302
+
303
+ /**
304
+ * Convert a matrix to a translate transform if it represents a pure translation.
305
+ *
306
+ * A pure translation matrix has the form:
307
+ * [1 0 tx]
308
+ * [0 1 ty]
309
+ * [0 0 1]
310
+ *
311
+ * VERIFICATION: The matrices must be equal.
312
+ *
313
+ * @param {Matrix} matrix - 3x3 transformation matrix
314
+ * @returns {{
315
+ * isTranslation: boolean,
316
+ * tx: Decimal|null,
317
+ * ty: Decimal|null,
318
+ * verified: boolean,
319
+ * maxError: Decimal
320
+ * }} Translation parameters if matrix is pure translation
321
+ *
322
+ * @example
323
+ * // Check if a matrix is a pure translation
324
+ * const M = translationMatrix(5, 10);
325
+ * const result = matrixToTranslate(M);
326
+ * // Result: {isTranslation: true, tx: 5, ty: 10, verified: true}
327
+ */
328
+ export function matrixToTranslate(matrix) {
329
+ const data = matrix.data;
330
+
331
+ // Check if linear part is identity
332
+ const isIdentityLinear =
333
+ data[0][0].minus(1).abs().lessThan(EPSILON) &&
334
+ data[0][1].abs().lessThan(EPSILON) &&
335
+ data[1][0].abs().lessThan(EPSILON) &&
336
+ data[1][1].minus(1).abs().lessThan(EPSILON);
337
+
338
+ if (!isIdentityLinear) {
339
+ return {
340
+ isTranslation: false,
341
+ tx: null,
342
+ ty: null,
343
+ verified: false,
344
+ maxError: D(0)
345
+ };
346
+ }
347
+
348
+ // Extract translation
349
+ const tx = data[0][2];
350
+ const ty = data[1][2];
351
+
352
+ // VERIFICATION: Matrices must be equal
353
+ const reconstructed = translationMatrix(tx, ty);
354
+ const maxError = matrixMaxDifference(matrix, reconstructed);
355
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
356
+
357
+ return {
358
+ isTranslation: true,
359
+ tx,
360
+ ty,
361
+ verified,
362
+ maxError
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Convert a matrix to a rotate transform if it represents a pure rotation around origin.
368
+ *
369
+ * A pure rotation matrix has the form:
370
+ * [cos(θ) -sin(θ) 0]
371
+ * [sin(θ) cos(θ) 0]
372
+ * [ 0 0 1]
373
+ *
374
+ * Properties:
375
+ * - Orthogonal columns: a·c + b·d = 0
376
+ * - Unit column lengths: a² + b² = 1, c² + d² = 1
377
+ * - Determinant = 1 (no reflection)
378
+ * - No translation: tx = ty = 0
379
+ *
380
+ * VERIFICATION: The matrices must be equal.
381
+ *
382
+ * @param {Matrix} matrix - 3x3 transformation matrix
383
+ * @returns {{
384
+ * isRotation: boolean,
385
+ * angle: Decimal|null,
386
+ * verified: boolean,
387
+ * maxError: Decimal
388
+ * }} Rotation angle if matrix is pure rotation
389
+ *
390
+ * @example
391
+ * // Check if a matrix is a pure rotation
392
+ * const M = rotationMatrix(Math.PI / 4);
393
+ * const result = matrixToRotate(M);
394
+ * // Result: {isRotation: true, angle: π/4, verified: true}
395
+ */
396
+ export function matrixToRotate(matrix) {
397
+ const data = matrix.data;
398
+
399
+ // Extract components
400
+ const a = data[0][0];
401
+ const b = data[1][0];
402
+ const c = data[0][1];
403
+ const d = data[1][1];
404
+ const tx = data[0][2];
405
+ const ty = data[1][2];
406
+
407
+ // Check no translation
408
+ if (!tx.abs().lessThan(EPSILON) || !ty.abs().lessThan(EPSILON)) {
409
+ return {
410
+ isRotation: false,
411
+ angle: null,
412
+ verified: false,
413
+ maxError: D(0)
414
+ };
415
+ }
416
+
417
+ // Check orthogonality: a*c + b*d = 0
418
+ const orthogonal = a.mul(c).plus(b.mul(d)).abs().lessThan(EPSILON);
419
+
420
+ // Check unit columns: a² + b² = 1, c² + d² = 1
421
+ const col1Norm = a.mul(a).plus(b.mul(b));
422
+ const col2Norm = c.mul(c).plus(d.mul(d));
423
+ const unitNorm = col1Norm.minus(1).abs().lessThan(EPSILON) &&
424
+ col2Norm.minus(1).abs().lessThan(EPSILON);
425
+
426
+ // Check determinant = 1 (no reflection)
427
+ const det = a.mul(d).minus(b.mul(c));
428
+ const detOne = det.minus(1).abs().lessThan(EPSILON);
429
+
430
+ if (!orthogonal || !unitNorm || !detOne) {
431
+ return {
432
+ isRotation: false,
433
+ angle: null,
434
+ verified: false,
435
+ maxError: D(0)
436
+ };
437
+ }
438
+
439
+ // Calculate rotation angle
440
+ const angle = Decimal.atan2(b, a);
441
+
442
+ // VERIFICATION: Matrices must be equal
443
+ const reconstructed = rotationMatrix(angle);
444
+ const maxError = matrixMaxDifference(matrix, reconstructed);
445
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
446
+
447
+ return {
448
+ isRotation: true,
449
+ angle,
450
+ verified,
451
+ maxError
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Convert a matrix to a scale transform if it represents a pure scale.
457
+ *
458
+ * A pure scale matrix has the form:
459
+ * [sx 0 0]
460
+ * [0 sy 0]
461
+ * [0 0 1]
462
+ *
463
+ * Properties:
464
+ * - Diagonal matrix in linear part: b = c = 0
465
+ * - No translation: tx = ty = 0
466
+ *
467
+ * VERIFICATION: The matrices must be equal.
468
+ *
469
+ * @param {Matrix} matrix - 3x3 transformation matrix
470
+ * @returns {{
471
+ * isScale: boolean,
472
+ * sx: Decimal|null,
473
+ * sy: Decimal|null,
474
+ * isUniform: boolean,
475
+ * verified: boolean,
476
+ * maxError: Decimal
477
+ * }} Scale factors if matrix is pure scale
478
+ *
479
+ * @example
480
+ * // Check if a matrix is a pure scale
481
+ * const M = scaleMatrix(2, 2);
482
+ * const result = matrixToScale(M);
483
+ * // Result: {isScale: true, sx: 2, sy: 2, isUniform: true, verified: true}
484
+ */
485
+ export function matrixToScale(matrix) {
486
+ const data = matrix.data;
487
+
488
+ // Extract components
489
+ const a = data[0][0];
490
+ const b = data[1][0];
491
+ const c = data[0][1];
492
+ const d = data[1][1];
493
+ const tx = data[0][2];
494
+ const ty = data[1][2];
495
+
496
+ // Check no translation
497
+ if (!tx.abs().lessThan(EPSILON) || !ty.abs().lessThan(EPSILON)) {
498
+ return {
499
+ isScale: false,
500
+ sx: null,
501
+ sy: null,
502
+ isUniform: false,
503
+ verified: false,
504
+ maxError: D(0)
505
+ };
506
+ }
507
+
508
+ // Check diagonal: b = 0, c = 0
509
+ if (!b.abs().lessThan(EPSILON) || !c.abs().lessThan(EPSILON)) {
510
+ return {
511
+ isScale: false,
512
+ sx: null,
513
+ sy: null,
514
+ isUniform: false,
515
+ verified: false,
516
+ maxError: D(0)
517
+ };
518
+ }
519
+
520
+ // Extract scale factors
521
+ const sx = a;
522
+ const sy = d;
523
+
524
+ // Check if uniform
525
+ const isUniform = sx.minus(sy).abs().lessThan(EPSILON);
526
+
527
+ // VERIFICATION: Matrices must be equal
528
+ const reconstructed = scaleMatrix(sx, sy);
529
+ const maxError = matrixMaxDifference(matrix, reconstructed);
530
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
531
+
532
+ return {
533
+ isScale: true,
534
+ sx,
535
+ sy,
536
+ isUniform,
537
+ verified,
538
+ maxError
539
+ };
540
+ }
541
+
542
+ // ============================================================================
543
+ // Transform List Optimization Functions
544
+ // ============================================================================
545
+
546
+ /**
547
+ * Remove identity transforms from a transform list.
548
+ *
549
+ * Identity transforms are those that have no effect:
550
+ * - translate(0, 0)
551
+ * - rotate(0) or rotate(2πn) for integer n
552
+ * - scale(1, 1)
553
+ *
554
+ * This function does NOT perform verification (it only removes transforms).
555
+ *
556
+ * @param {Array<{type: string, params: Object}>} transforms - Array of transform objects
557
+ * @returns {{
558
+ * transforms: Array<{type: string, params: Object}>,
559
+ * removedCount: number
560
+ * }} Filtered transform list
561
+ *
562
+ * @example
563
+ * // Remove identity transforms
564
+ * const transforms = [
565
+ * {type: 'translate', params: {tx: 5, ty: 10}},
566
+ * {type: 'rotate', params: {angle: 0}},
567
+ * {type: 'scale', params: {sx: 1, sy: 1}},
568
+ * {type: 'translate', params: {tx: 0, ty: 0}}
569
+ * ];
570
+ * const result = removeIdentityTransforms(transforms);
571
+ * // Result: {transforms: [{type: 'translate', params: {tx: 5, ty: 10}}], removedCount: 3}
572
+ */
573
+ export function removeIdentityTransforms(transforms) {
574
+ const PI = Decimal.acos(-1);
575
+ const TWO_PI = PI.mul(2);
576
+
577
+ const filtered = transforms.filter(t => {
578
+ switch (t.type) {
579
+ case 'translate': {
580
+ const tx = D(t.params.tx);
581
+ const ty = D(t.params.ty);
582
+ return !tx.abs().lessThan(EPSILON) || !ty.abs().lessThan(EPSILON);
583
+ }
584
+
585
+ case 'rotate': {
586
+ const angle = D(t.params.angle);
587
+ // Normalize angle to [0, 2π)
588
+ const normalized = angle.mod(TWO_PI);
589
+ return !normalized.abs().lessThan(EPSILON);
590
+ }
591
+
592
+ case 'scale': {
593
+ const sx = D(t.params.sx);
594
+ const sy = D(t.params.sy);
595
+ return !sx.minus(1).abs().lessThan(EPSILON) || !sy.minus(1).abs().lessThan(EPSILON);
596
+ }
597
+
598
+ case 'matrix': {
599
+ // Check if matrix is identity
600
+ const m = t.params.matrix;
601
+ const identity = identityMatrix();
602
+ return !matricesEqual(m, identity, EPSILON);
603
+ }
604
+
605
+ default:
606
+ // Keep unknown transform types
607
+ return true;
608
+ }
609
+ });
610
+
611
+ return {
612
+ transforms: filtered,
613
+ removedCount: transforms.length - filtered.length
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Convert translate-rotate-translate sequence to rotate around point shorthand.
619
+ *
620
+ * Detects the pattern:
621
+ * translate(tx, ty) × rotate(angle) × translate(-tx, -ty)
622
+ *
623
+ * And converts it to:
624
+ * rotate(angle, tx, ty)
625
+ *
626
+ * This is a common optimization for rotating around a point other than the origin.
627
+ *
628
+ * VERIFICATION: The matrices must be equal.
629
+ *
630
+ * @param {number|string|Decimal} translateX - First translation X
631
+ * @param {number|string|Decimal} translateY - First translation Y
632
+ * @param {number|string|Decimal} angle - Rotation angle in radians
633
+ * @param {number|string|Decimal} centerX - Expected rotation center X
634
+ * @param {number|string|Decimal} centerY - Expected rotation center Y
635
+ * @returns {{
636
+ * angle: Decimal,
637
+ * cx: Decimal,
638
+ * cy: Decimal,
639
+ * verified: boolean,
640
+ * maxError: Decimal
641
+ * }} Shorthand rotation parameters with verification
642
+ *
643
+ * @example
644
+ * // Convert translate-rotate-translate to rotate around point
645
+ * const result = shortRotate(100, 50, Math.PI/4, 100, 50);
646
+ * // Result: {angle: π/4, cx: 100, cy: 50, verified: true}
647
+ */
648
+ export function shortRotate(translateX, translateY, angle, centerX, centerY) {
649
+ const txD = D(translateX);
650
+ const tyD = D(translateY);
651
+ const angleD = D(angle);
652
+ const cxD = D(centerX);
653
+ const cyD = D(centerY);
654
+
655
+ // Build the sequence: T(tx, ty) × R(angle) × T(-tx, -ty)
656
+ const T1 = translationMatrix(txD, tyD);
657
+ const R = rotationMatrix(angleD);
658
+ const T2 = translationMatrix(txD.neg(), tyD.neg());
659
+ const sequence = T1.mul(R).mul(T2);
660
+
661
+ // Build the shorthand: R(angle, cx, cy)
662
+ const shorthand = rotationMatrixAroundPoint(angleD, cxD, cyD);
663
+
664
+ // VERIFICATION: Matrices must be equal
665
+ const maxError = matrixMaxDifference(sequence, shorthand);
666
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
667
+
668
+ return {
669
+ angle: angleD,
670
+ cx: cxD,
671
+ cy: cyD,
672
+ verified,
673
+ maxError
674
+ };
675
+ }
676
+
677
+ /**
678
+ * Optimize a list of transforms by applying all optimization strategies.
679
+ *
680
+ * Optimization strategies applied:
681
+ * 1. Remove identity transforms
682
+ * 2. Merge consecutive transforms of the same type
683
+ * 3. Detect and convert translate-rotate-translate to rotate around point
684
+ * 4. Convert matrices to simpler forms when possible
685
+ *
686
+ * VERIFICATION: The combined matrix of the optimized list must equal the
687
+ * combined matrix of the original list.
688
+ *
689
+ * @param {Array<{type: string, params: Object}>} transforms - Array of transform objects
690
+ * @returns {{
691
+ * transforms: Array<{type: string, params: Object}>,
692
+ * optimizationCount: number,
693
+ * verified: boolean,
694
+ * maxError: Decimal
695
+ * }} Optimized transform list with verification
696
+ *
697
+ * @example
698
+ * // Optimize a transform list
699
+ * const transforms = [
700
+ * {type: 'translate', params: {tx: 5, ty: 10}},
701
+ * {type: 'translate', params: {tx: 3, ty: -2}},
702
+ * {type: 'rotate', params: {angle: 0}},
703
+ * {type: 'scale', params: {sx: 2, sy: 2}},
704
+ * {type: 'scale', params: {sx: 0.5, sy: 0.5}}
705
+ * ];
706
+ * const result = optimizeTransformList(transforms);
707
+ * // Result: optimized list with merged translations and scales, identity rotation removed
708
+ */
709
+ export function optimizeTransformList(transforms) {
710
+ // Calculate original combined matrix for verification
711
+ let originalMatrix = identityMatrix();
712
+ for (const t of transforms) {
713
+ let m;
714
+ switch (t.type) {
715
+ case 'translate':
716
+ m = translationMatrix(t.params.tx, t.params.ty);
717
+ break;
718
+ case 'rotate':
719
+ if (t.params.cx !== undefined && t.params.cy !== undefined) {
720
+ m = rotationMatrixAroundPoint(t.params.angle, t.params.cx, t.params.cy);
721
+ } else {
722
+ m = rotationMatrix(t.params.angle);
723
+ }
724
+ break;
725
+ case 'scale':
726
+ m = scaleMatrix(t.params.sx, t.params.sy);
727
+ break;
728
+ case 'matrix':
729
+ m = t.params.matrix;
730
+ break;
731
+ default:
732
+ continue;
733
+ }
734
+ originalMatrix = originalMatrix.mul(m);
735
+ }
736
+
737
+ // Step 1: Remove identity transforms
738
+ const { transforms: step1, removedCount } = removeIdentityTransforms(transforms);
739
+ let optimized = step1.slice();
740
+
741
+ // Step 2: Merge consecutive transforms of the same type
742
+ let i = 0;
743
+ while (i < optimized.length - 1) {
744
+ const current = optimized[i];
745
+ const next = optimized[i + 1];
746
+
747
+ // Try to merge
748
+ let merged = null;
749
+
750
+ if (current.type === 'translate' && next.type === 'translate') {
751
+ const result = mergeTranslations(current.params, next.params);
752
+ if (result.verified) {
753
+ merged = {
754
+ type: 'translate',
755
+ params: { tx: result.tx, ty: result.ty }
756
+ };
757
+ }
758
+ } else if (current.type === 'rotate' && next.type === 'rotate') {
759
+ // Only merge if both are around origin
760
+ if (!current.params.cx && !current.params.cy && !next.params.cx && !next.params.cy) {
761
+ const result = mergeRotations(current.params, next.params);
762
+ if (result.verified) {
763
+ merged = {
764
+ type: 'rotate',
765
+ params: { angle: result.angle }
766
+ };
767
+ }
768
+ }
769
+ } else if (current.type === 'scale' && next.type === 'scale') {
770
+ const result = mergeScales(current.params, next.params);
771
+ if (result.verified) {
772
+ merged = {
773
+ type: 'scale',
774
+ params: { sx: result.sx, sy: result.sy }
775
+ };
776
+ }
777
+ }
778
+
779
+ if (merged) {
780
+ // Replace current and next with merged
781
+ optimized.splice(i, 2, merged);
782
+ // Don't increment i, check if we can merge again
783
+ } else {
784
+ i++;
785
+ }
786
+ }
787
+
788
+ // Step 3: Detect translate-rotate-translate patterns
789
+ i = 0;
790
+ while (i < optimized.length - 2) {
791
+ const t1 = optimized[i];
792
+ const t2 = optimized[i + 1];
793
+ const t3 = optimized[i + 2];
794
+
795
+ if (t1.type === 'translate' && t2.type === 'rotate' && t3.type === 'translate') {
796
+ // Check if t3 is inverse of t1
797
+ const tx1 = D(t1.params.tx);
798
+ const ty1 = D(t1.params.ty);
799
+ const tx3 = D(t3.params.tx);
800
+ const ty3 = D(t3.params.ty);
801
+
802
+ if (tx1.plus(tx3).abs().lessThan(EPSILON) && ty1.plus(ty3).abs().lessThan(EPSILON)) {
803
+ // This is a rotate around point pattern
804
+ const result = shortRotate(tx1, ty1, t2.params.angle, tx1, ty1);
805
+ if (result.verified) {
806
+ const merged = {
807
+ type: 'rotate',
808
+ params: { angle: result.angle, cx: result.cx, cy: result.cy }
809
+ };
810
+ optimized.splice(i, 3, merged);
811
+ // Don't increment i, might be able to merge more
812
+ continue;
813
+ }
814
+ }
815
+ }
816
+
817
+ i++;
818
+ }
819
+
820
+ // Step 4: Convert matrices to simpler forms
821
+ for (let j = 0; j < optimized.length; j++) {
822
+ const t = optimized[j];
823
+ if (t.type === 'matrix') {
824
+ const m = t.params.matrix;
825
+
826
+ // Try to convert to simpler forms
827
+ const translateResult = matrixToTranslate(m);
828
+ if (translateResult.isTranslation && translateResult.verified) {
829
+ optimized[j] = {
830
+ type: 'translate',
831
+ params: { tx: translateResult.tx, ty: translateResult.ty }
832
+ };
833
+ continue;
834
+ }
835
+
836
+ const rotateResult = matrixToRotate(m);
837
+ if (rotateResult.isRotation && rotateResult.verified) {
838
+ optimized[j] = {
839
+ type: 'rotate',
840
+ params: { angle: rotateResult.angle }
841
+ };
842
+ continue;
843
+ }
844
+
845
+ const scaleResult = matrixToScale(m);
846
+ if (scaleResult.isScale && scaleResult.verified) {
847
+ optimized[j] = {
848
+ type: 'scale',
849
+ params: { sx: scaleResult.sx, sy: scaleResult.sy }
850
+ };
851
+ continue;
852
+ }
853
+ }
854
+ }
855
+
856
+ // Final removal of any new identity transforms created by optimization
857
+ const { transforms: final } = removeIdentityTransforms(optimized);
858
+
859
+ // Calculate optimized combined matrix for verification
860
+ let optimizedMatrix = identityMatrix();
861
+ for (const t of final) {
862
+ let m;
863
+ switch (t.type) {
864
+ case 'translate':
865
+ m = translationMatrix(t.params.tx, t.params.ty);
866
+ break;
867
+ case 'rotate':
868
+ if (t.params.cx !== undefined && t.params.cy !== undefined) {
869
+ m = rotationMatrixAroundPoint(t.params.angle, t.params.cx, t.params.cy);
870
+ } else {
871
+ m = rotationMatrix(t.params.angle);
872
+ }
873
+ break;
874
+ case 'scale':
875
+ m = scaleMatrix(t.params.sx, t.params.sy);
876
+ break;
877
+ case 'matrix':
878
+ m = t.params.matrix;
879
+ break;
880
+ default:
881
+ continue;
882
+ }
883
+ optimizedMatrix = optimizedMatrix.mul(m);
884
+ }
885
+
886
+ // VERIFICATION: Combined matrices must be equal
887
+ const maxError = matrixMaxDifference(originalMatrix, optimizedMatrix);
888
+ const verified = maxError.lessThan(VERIFICATION_TOLERANCE);
889
+
890
+ return {
891
+ transforms: final,
892
+ optimizationCount: transforms.length - final.length,
893
+ verified,
894
+ maxError
895
+ };
896
+ }
897
+
898
+ // ============================================================================
899
+ // Exports
900
+ // ============================================================================
901
+
902
+ export {
903
+ EPSILON,
904
+ VERIFICATION_TOLERANCE,
905
+ D
906
+ };
907
+
908
+ export default {
909
+ // Matrix utilities
910
+ identityMatrix,
911
+ translationMatrix,
912
+ rotationMatrix,
913
+ rotationMatrixAroundPoint,
914
+ scaleMatrix,
915
+ matrixMaxDifference,
916
+ matricesEqual,
917
+
918
+ // Transform merging
919
+ mergeTranslations,
920
+ mergeRotations,
921
+ mergeScales,
922
+
923
+ // Matrix to transform conversion
924
+ matrixToTranslate,
925
+ matrixToRotate,
926
+ matrixToScale,
927
+
928
+ // Transform list optimization
929
+ removeIdentityTransforms,
930
+ shortRotate,
931
+ optimizeTransformList,
932
+
933
+ // Constants
934
+ EPSILON,
935
+ VERIFICATION_TOLERANCE
936
+ };