@emasoft/svg-matrix 1.0.12 → 1.0.14
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/bin/svg-matrix.js +97 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +80 -198
- package/src/clip-path-resolver.js +25 -3
- package/src/flatten-pipeline.js +179 -13
- package/src/geometry-to-path.js +136 -0
- package/src/index.js +3 -1
- package/src/verification.js +1242 -0
package/src/flatten-pipeline.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as MeshGradient from './mesh-gradient.js';
|
|
|
26
26
|
import * as GeometryToPath from './geometry-to-path.js';
|
|
27
27
|
import { parseSVG, SVGElement, buildDefsMap, parseUrlReference, serializeSVG, findElementsWithAttribute } from './svg-parser.js';
|
|
28
28
|
import { Logger } from './logger.js';
|
|
29
|
+
import * as Verification from './verification.js';
|
|
29
30
|
|
|
30
31
|
Decimal.set({ precision: 80 });
|
|
31
32
|
|
|
@@ -36,7 +37,11 @@ const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
|
36
37
|
*/
|
|
37
38
|
const DEFAULT_OPTIONS = {
|
|
38
39
|
precision: 6, // Decimal places in output coordinates
|
|
39
|
-
curveSegments: 20, // Samples per curve for polygon conversion
|
|
40
|
+
curveSegments: 20, // Samples per curve for polygon conversion (visual output)
|
|
41
|
+
clipSegments: 64, // Higher samples for clip polygon accuracy (affects E2E precision)
|
|
42
|
+
bezierArcs: 8, // Bezier arcs for circles/ellipses (must be multiple of 4)
|
|
43
|
+
// 8: π/4 optimal base (~0.0004% error)
|
|
44
|
+
// 16: π/8 (~0.000007% error), 32: π/16, 64: π/32 (~0.00000001% error)
|
|
40
45
|
resolveUse: true, // Expand <use> elements
|
|
41
46
|
resolveMarkers: true, // Expand marker instances
|
|
42
47
|
resolvePatterns: true, // Expand pattern fills to geometry
|
|
@@ -46,6 +51,9 @@ const DEFAULT_OPTIONS = {
|
|
|
46
51
|
bakeGradients: true, // Bake gradientTransform into gradient coords
|
|
47
52
|
removeUnusedDefs: true, // Remove defs that are no longer referenced
|
|
48
53
|
preserveIds: false, // Keep original IDs on expanded elements
|
|
54
|
+
// NOTE: Verification is ALWAYS enabled - precision is non-negotiable
|
|
55
|
+
// E2E verification tolerance (configurable for different accuracy needs)
|
|
56
|
+
e2eTolerance: '1e-10', // Default: 1e-10 (very tight with high clipSegments)
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -77,6 +85,19 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
77
85
|
gradientsProcessed: 0,
|
|
78
86
|
defsRemoved: 0,
|
|
79
87
|
errors: [],
|
|
88
|
+
// Verification results - ALWAYS enabled (precision is non-negotiable)
|
|
89
|
+
verifications: {
|
|
90
|
+
transforms: [],
|
|
91
|
+
matrices: [],
|
|
92
|
+
polygons: [],
|
|
93
|
+
gradients: [],
|
|
94
|
+
e2e: [], // End-to-end verification: area conservation, union reconstruction
|
|
95
|
+
passed: 0,
|
|
96
|
+
failed: 0,
|
|
97
|
+
allPassed: true,
|
|
98
|
+
},
|
|
99
|
+
// Store outside fragments from clipping for potential reconstruction
|
|
100
|
+
clipOutsideFragments: [],
|
|
80
101
|
};
|
|
81
102
|
|
|
82
103
|
try {
|
|
@@ -120,21 +141,21 @@ export function flattenSVG(svgString, options = {}) {
|
|
|
120
141
|
|
|
121
142
|
// Step 5: Apply clipPaths (boolean intersection)
|
|
122
143
|
if (opts.resolveClipPaths) {
|
|
123
|
-
const result = applyAllClipPaths(root, defsMap, opts);
|
|
144
|
+
const result = applyAllClipPaths(root, defsMap, opts, stats);
|
|
124
145
|
stats.clipPathsApplied = result.count;
|
|
125
146
|
stats.errors.push(...result.errors);
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
// Step 6: Flatten transforms (bake into coordinates)
|
|
129
150
|
if (opts.flattenTransforms) {
|
|
130
|
-
const result = flattenAllTransforms(root, opts);
|
|
151
|
+
const result = flattenAllTransforms(root, opts, stats);
|
|
131
152
|
stats.transformsFlattened = result.count;
|
|
132
153
|
stats.errors.push(...result.errors);
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
// Step 7: Bake gradient transforms
|
|
136
157
|
if (opts.bakeGradients) {
|
|
137
|
-
const result = bakeAllGradientTransforms(root, opts);
|
|
158
|
+
const result = bakeAllGradientTransforms(root, opts, stats);
|
|
138
159
|
stats.gradientsProcessed = result.count;
|
|
139
160
|
stats.errors.push(...result.errors);
|
|
140
161
|
}
|
|
@@ -430,12 +451,21 @@ function resolveAllMasks(root, defsMap, opts) {
|
|
|
430
451
|
|
|
431
452
|
/**
|
|
432
453
|
* Apply clipPath references by performing boolean intersection.
|
|
454
|
+
* Also computes the difference (outside parts) and verifies area conservation (E2E).
|
|
433
455
|
* @private
|
|
434
456
|
*/
|
|
435
|
-
function applyAllClipPaths(root, defsMap, opts) {
|
|
457
|
+
function applyAllClipPaths(root, defsMap, opts, stats) {
|
|
436
458
|
const errors = [];
|
|
437
459
|
let count = 0;
|
|
438
460
|
|
|
461
|
+
// Initialize E2E verification tracking in stats
|
|
462
|
+
if (!stats.verifications.e2e) {
|
|
463
|
+
stats.verifications.e2e = [];
|
|
464
|
+
}
|
|
465
|
+
if (!stats.clipOutsideFragments) {
|
|
466
|
+
stats.clipOutsideFragments = []; // Store outside fragments for potential reconstruction
|
|
467
|
+
}
|
|
468
|
+
|
|
439
469
|
const elementsWithClip = findElementsWithAttribute(root, 'clip-path');
|
|
440
470
|
|
|
441
471
|
for (const el of elementsWithClip) {
|
|
@@ -466,13 +496,75 @@ function applyAllClipPaths(root, defsMap, opts) {
|
|
|
466
496
|
|
|
467
497
|
if (!clipPathData) continue;
|
|
468
498
|
|
|
469
|
-
// Convert to polygons
|
|
470
|
-
|
|
471
|
-
const
|
|
499
|
+
// Convert to polygons using higher segment count for clip accuracy
|
|
500
|
+
// clipSegments (default 64) provides better curve approximation for E2E verification
|
|
501
|
+
const clipSegs = opts.clipSegments || 64;
|
|
502
|
+
const origPolygon = ClipPathResolver.pathToPolygon(origPathData, clipSegs);
|
|
503
|
+
const clipPolygon = ClipPathResolver.pathToPolygon(clipPathData, clipSegs);
|
|
472
504
|
|
|
473
|
-
// Perform intersection
|
|
505
|
+
// Perform intersection (clipped result - what's kept)
|
|
474
506
|
const clippedPolygon = intersectPolygons(origPolygon, clipPolygon);
|
|
475
507
|
|
|
508
|
+
// Convert polygon arrays to proper format for verification
|
|
509
|
+
const origForVerify = origPolygon.map(p => ({
|
|
510
|
+
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
511
|
+
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
512
|
+
}));
|
|
513
|
+
const clipForVerify = clipPolygon.map(p => ({
|
|
514
|
+
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
515
|
+
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
// VERIFICATION: Verify polygon intersection is valid (ALWAYS runs)
|
|
519
|
+
if (clippedPolygon && clippedPolygon.length > 2) {
|
|
520
|
+
const clippedForVerify = clippedPolygon.map(p => ({
|
|
521
|
+
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
522
|
+
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
523
|
+
}));
|
|
524
|
+
|
|
525
|
+
const polyResult = Verification.verifyPolygonIntersection(origForVerify, clipForVerify, clippedForVerify);
|
|
526
|
+
stats.verifications.polygons.push({
|
|
527
|
+
element: el.tagName,
|
|
528
|
+
clipPathId: refId,
|
|
529
|
+
...polyResult
|
|
530
|
+
});
|
|
531
|
+
if (polyResult.valid) {
|
|
532
|
+
stats.verifications.passed++;
|
|
533
|
+
} else {
|
|
534
|
+
stats.verifications.failed++;
|
|
535
|
+
stats.verifications.allPassed = false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// E2E VERIFICATION: Compute difference (outside parts) and verify area conservation
|
|
539
|
+
// This ensures: area(original) = area(clipped) + area(outside)
|
|
540
|
+
const outsideFragments = Verification.computePolygonDifference(origForVerify, clipForVerify);
|
|
541
|
+
|
|
542
|
+
// Store outside fragments (marked invisible) for potential reconstruction
|
|
543
|
+
stats.clipOutsideFragments.push({
|
|
544
|
+
elementId: el.getAttribute('id') || `clip-${count}`,
|
|
545
|
+
clipPathId: refId,
|
|
546
|
+
fragments: outsideFragments,
|
|
547
|
+
visible: false // These are the "thrown away" parts, stored invisibly
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// E2E Verification: area(original) = area(clipped) + area(outside)
|
|
551
|
+
// Pass configurable tolerance (default 1e-10 with 64 clipSegments)
|
|
552
|
+
const e2eTolerance = opts.e2eTolerance || '1e-10';
|
|
553
|
+
const e2eResult = Verification.verifyClipPathE2E(origForVerify, clippedForVerify, outsideFragments, e2eTolerance);
|
|
554
|
+
stats.verifications.e2e.push({
|
|
555
|
+
element: el.tagName,
|
|
556
|
+
clipPathId: refId,
|
|
557
|
+
type: 'clip-area-conservation',
|
|
558
|
+
...e2eResult
|
|
559
|
+
});
|
|
560
|
+
if (e2eResult.valid) {
|
|
561
|
+
stats.verifications.passed++;
|
|
562
|
+
} else {
|
|
563
|
+
stats.verifications.failed++;
|
|
564
|
+
stats.verifications.allPassed = false;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
476
568
|
if (clippedPolygon && clippedPolygon.length > 2) {
|
|
477
569
|
const clippedPath = ClipPathResolver.polygonToPathData(clippedPolygon, opts.precision);
|
|
478
570
|
|
|
@@ -510,7 +602,7 @@ function applyAllClipPaths(root, defsMap, opts) {
|
|
|
510
602
|
* Flatten all transform attributes by baking into coordinates.
|
|
511
603
|
* @private
|
|
512
604
|
*/
|
|
513
|
-
function flattenAllTransforms(root, opts) {
|
|
605
|
+
function flattenAllTransforms(root, opts, stats) {
|
|
514
606
|
const errors = [];
|
|
515
607
|
let count = 0;
|
|
516
608
|
|
|
@@ -524,21 +616,63 @@ function flattenAllTransforms(root, opts) {
|
|
|
524
616
|
// Parse transform to matrix
|
|
525
617
|
const ctm = SVGFlatten.parseTransformAttribute(transform);
|
|
526
618
|
|
|
619
|
+
// VERIFICATION: Verify matrix is invertible and well-formed (ALWAYS runs)
|
|
620
|
+
const matrixResult = Verification.verifyMatrixInversion(ctm);
|
|
621
|
+
stats.verifications.matrices.push({
|
|
622
|
+
element: el.tagName,
|
|
623
|
+
transform,
|
|
624
|
+
...matrixResult
|
|
625
|
+
});
|
|
626
|
+
if (matrixResult.valid) {
|
|
627
|
+
stats.verifications.passed++;
|
|
628
|
+
} else {
|
|
629
|
+
stats.verifications.failed++;
|
|
630
|
+
stats.verifications.allPassed = false;
|
|
631
|
+
}
|
|
632
|
+
|
|
527
633
|
// Get element path data
|
|
528
634
|
const pathData = getElementPathData(el, opts.precision);
|
|
529
635
|
if (!pathData) {
|
|
530
636
|
// For groups, propagate transform to children
|
|
531
637
|
if (el.tagName === 'g') {
|
|
532
|
-
propagateTransformToChildren(el, ctm, opts);
|
|
638
|
+
propagateTransformToChildren(el, ctm, opts, stats);
|
|
533
639
|
el.removeAttribute('transform');
|
|
534
640
|
count++;
|
|
535
641
|
}
|
|
536
642
|
continue;
|
|
537
643
|
}
|
|
538
644
|
|
|
645
|
+
// VERIFICATION: Sample test points from original path for round-trip verification (ALWAYS runs)
|
|
646
|
+
let testPoints = [];
|
|
647
|
+
// Extract a few key points from the path for verification
|
|
648
|
+
const polygon = ClipPathResolver.pathToPolygon(pathData, 4);
|
|
649
|
+
if (polygon && polygon.length > 0) {
|
|
650
|
+
testPoints = polygon.slice(0, 4).map(p => ({
|
|
651
|
+
x: p.x instanceof Decimal ? p.x : D(p.x),
|
|
652
|
+
y: p.y instanceof Decimal ? p.y : D(p.y)
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
|
|
539
656
|
// Transform the path data
|
|
540
657
|
const transformedPath = SVGFlatten.transformPathData(pathData, ctm, { precision: opts.precision });
|
|
541
658
|
|
|
659
|
+
// VERIFICATION: Verify transform round-trip accuracy for each test point (ALWAYS runs)
|
|
660
|
+
for (let i = 0; i < testPoints.length; i++) {
|
|
661
|
+
const pt = testPoints[i];
|
|
662
|
+
const rtResult = Verification.verifyTransformRoundTrip(ctm, pt.x, pt.y);
|
|
663
|
+
stats.verifications.transforms.push({
|
|
664
|
+
element: el.tagName,
|
|
665
|
+
pointIndex: i,
|
|
666
|
+
...rtResult
|
|
667
|
+
});
|
|
668
|
+
if (rtResult.valid) {
|
|
669
|
+
stats.verifications.passed++;
|
|
670
|
+
} else {
|
|
671
|
+
stats.verifications.failed++;
|
|
672
|
+
stats.verifications.allPassed = false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
542
676
|
// Update or replace element
|
|
543
677
|
if (el.tagName === 'path') {
|
|
544
678
|
el.setAttribute('d', transformedPath);
|
|
@@ -573,7 +707,7 @@ function flattenAllTransforms(root, opts) {
|
|
|
573
707
|
* Propagate transform to all children of a group.
|
|
574
708
|
* @private
|
|
575
709
|
*/
|
|
576
|
-
function propagateTransformToChildren(group, ctm, opts) {
|
|
710
|
+
function propagateTransformToChildren(group, ctm, opts, stats) {
|
|
577
711
|
for (const child of [...group.children]) {
|
|
578
712
|
if (!(child instanceof SVGElement)) continue;
|
|
579
713
|
|
|
@@ -599,6 +733,20 @@ function propagateTransformToChildren(group, ctm, opts) {
|
|
|
599
733
|
combinedCtm = ctm.mul(childCtm);
|
|
600
734
|
}
|
|
601
735
|
|
|
736
|
+
// VERIFICATION: Verify combined transform matrix (ALWAYS runs)
|
|
737
|
+
const matrixResult = Verification.verifyMatrixInversion(combinedCtm);
|
|
738
|
+
stats.verifications.matrices.push({
|
|
739
|
+
element: child.tagName,
|
|
740
|
+
context: 'group-propagation',
|
|
741
|
+
...matrixResult
|
|
742
|
+
});
|
|
743
|
+
if (matrixResult.valid) {
|
|
744
|
+
stats.verifications.passed++;
|
|
745
|
+
} else {
|
|
746
|
+
stats.verifications.failed++;
|
|
747
|
+
stats.verifications.allPassed = false;
|
|
748
|
+
}
|
|
749
|
+
|
|
602
750
|
const transformedPath = SVGFlatten.transformPathData(pathData, combinedCtm, { precision: opts.precision });
|
|
603
751
|
|
|
604
752
|
if (child.tagName === 'path') {
|
|
@@ -627,7 +775,7 @@ function propagateTransformToChildren(group, ctm, opts) {
|
|
|
627
775
|
* Bake gradientTransform into gradient coordinates.
|
|
628
776
|
* @private
|
|
629
777
|
*/
|
|
630
|
-
function bakeAllGradientTransforms(root, opts) {
|
|
778
|
+
function bakeAllGradientTransforms(root, opts, stats) {
|
|
631
779
|
const errors = [];
|
|
632
780
|
let count = 0;
|
|
633
781
|
|
|
@@ -649,6 +797,24 @@ function bakeAllGradientTransforms(root, opts) {
|
|
|
649
797
|
const [tx1, ty1] = Transforms2D.applyTransform(ctm, x1, y1);
|
|
650
798
|
const [tx2, ty2] = Transforms2D.applyTransform(ctm, x2, y2);
|
|
651
799
|
|
|
800
|
+
// VERIFICATION: Verify linear gradient transform (ALWAYS runs)
|
|
801
|
+
const gradResult = Verification.verifyLinearGradientTransform(
|
|
802
|
+
{ x1, y1, x2, y2 },
|
|
803
|
+
{ x1: tx1.toNumber(), y1: ty1.toNumber(), x2: tx2.toNumber(), y2: ty2.toNumber() },
|
|
804
|
+
ctm
|
|
805
|
+
);
|
|
806
|
+
stats.verifications.gradients.push({
|
|
807
|
+
gradientId: grad.getAttribute('id') || 'unknown',
|
|
808
|
+
type: 'linear',
|
|
809
|
+
...gradResult
|
|
810
|
+
});
|
|
811
|
+
if (gradResult.valid) {
|
|
812
|
+
stats.verifications.passed++;
|
|
813
|
+
} else {
|
|
814
|
+
stats.verifications.failed++;
|
|
815
|
+
stats.verifications.allPassed = false;
|
|
816
|
+
}
|
|
817
|
+
|
|
652
818
|
grad.setAttribute('x1', tx1.toFixed(opts.precision));
|
|
653
819
|
grad.setAttribute('y1', ty1.toFixed(opts.precision));
|
|
654
820
|
grad.setAttribute('x2', tx2.toFixed(opts.precision));
|
package/src/geometry-to-path.js
CHANGED
|
@@ -3,6 +3,11 @@ import { Matrix } from './matrix.js';
|
|
|
3
3
|
|
|
4
4
|
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Standard kappa for 90° arcs (4 Bezier curves per circle).
|
|
8
|
+
* kappa = 4/3 * (sqrt(2) - 1) ≈ 0.5522847498
|
|
9
|
+
* Maximum radial error: ~0.027%
|
|
10
|
+
*/
|
|
6
11
|
export function getKappa() {
|
|
7
12
|
const two = new Decimal(2);
|
|
8
13
|
const three = new Decimal(3);
|
|
@@ -10,6 +15,137 @@ export function getKappa() {
|
|
|
10
15
|
return four.mul(two.sqrt().minus(1)).div(three);
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Compute the optimal Bezier control point distance for any arc angle.
|
|
20
|
+
* Formula: L = (4/3) * tan(theta/4) where theta is in radians
|
|
21
|
+
*
|
|
22
|
+
* This is the generalization of the kappa constant.
|
|
23
|
+
* For 90° (π/2): L = (4/3) * tan(π/8) ≈ 0.5522847498 (standard kappa)
|
|
24
|
+
*
|
|
25
|
+
* References:
|
|
26
|
+
* - Spencer Mortensen's optimal Bezier circle approximation:
|
|
27
|
+
* https://spencermortensen.com/articles/bezier-circle/
|
|
28
|
+
* Derives the optimal kappa for minimizing radial error in quarter-circle arcs.
|
|
29
|
+
*
|
|
30
|
+
* - Akhil's ellipse approximation with π/4 step angle:
|
|
31
|
+
* https://www.blog.akhil.cc/ellipse
|
|
32
|
+
* Shows that π/4 (45°) is the optimal step angle for Bezier arc approximation,
|
|
33
|
+
* based on Maisonobe's derivation. This allows precomputing the alpha coefficient.
|
|
34
|
+
*
|
|
35
|
+
* - Math Stack Exchange derivation:
|
|
36
|
+
* https://math.stackexchange.com/questions/873224
|
|
37
|
+
* General formula for control point distance for any arc angle.
|
|
38
|
+
*
|
|
39
|
+
* @param {Decimal|number} thetaRadians - Arc angle in radians
|
|
40
|
+
* @returns {Decimal} Control point distance factor (multiply by radius)
|
|
41
|
+
*/
|
|
42
|
+
export function getKappaForArc(thetaRadians) {
|
|
43
|
+
const theta = D(thetaRadians);
|
|
44
|
+
const four = new Decimal(4);
|
|
45
|
+
const three = new Decimal(3);
|
|
46
|
+
// L = (4/3) * tan(theta/4)
|
|
47
|
+
return four.div(three).mul(Decimal.tan(theta.div(four)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* High-precision circle to path using N Bezier arcs.
|
|
52
|
+
* More arcs = better approximation of the true circle.
|
|
53
|
+
*
|
|
54
|
+
* IMPORTANT: Arc count should be a multiple of 4 (for symmetry) and ideally
|
|
55
|
+
* a multiple of 8 (for optimal π/4 step angle as per Maisonobe's derivation).
|
|
56
|
+
* Reference: https://www.blog.akhil.cc/ellipse
|
|
57
|
+
*
|
|
58
|
+
* Error analysis (measured):
|
|
59
|
+
* - 4 arcs (90° = π/2 each): ~0.027% max radial error (standard)
|
|
60
|
+
* - 8 arcs (45° = π/4 each): ~0.0004% max radial error (optimal base)
|
|
61
|
+
* - 16 arcs (22.5° = π/8 each): ~0.000007% max radial error
|
|
62
|
+
* - 32 arcs (11.25° = π/16 each): ~0.0000004% max radial error
|
|
63
|
+
* - 64 arcs (5.625° = π/32 each): ~0.00000001% max radial error
|
|
64
|
+
*
|
|
65
|
+
* @param {number|Decimal} cx - Center X
|
|
66
|
+
* @param {number|Decimal} cy - Center Y
|
|
67
|
+
* @param {number|Decimal} r - Radius
|
|
68
|
+
* @param {number} arcs - Number of Bezier arcs (must be multiple of 4; 8, 16, 32, 64 recommended)
|
|
69
|
+
* @param {number} precision - Decimal precision for output
|
|
70
|
+
* @returns {string} SVG path data
|
|
71
|
+
*/
|
|
72
|
+
export function circleToPathDataHP(cx, cy, r, arcs = 8, precision = 6) {
|
|
73
|
+
return ellipseToPathDataHP(cx, cy, r, r, arcs, precision);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* High-precision ellipse to path using N Bezier arcs.
|
|
78
|
+
* More arcs = better approximation of the true ellipse.
|
|
79
|
+
*
|
|
80
|
+
* Arc count must be a multiple of 4 for proper symmetry.
|
|
81
|
+
* Multiples of 8 are optimal (π/4 step angle per Maisonobe).
|
|
82
|
+
*
|
|
83
|
+
* @param {number|Decimal} cx - Center X
|
|
84
|
+
* @param {number|Decimal} cy - Center Y
|
|
85
|
+
* @param {number|Decimal} rx - Radius X
|
|
86
|
+
* @param {number|Decimal} ry - Radius Y
|
|
87
|
+
* @param {number} arcs - Number of Bezier arcs (must be multiple of 4; 8, 16, 32, 64 recommended)
|
|
88
|
+
* @param {number} precision - Decimal precision for output
|
|
89
|
+
* @returns {string} SVG path data
|
|
90
|
+
*/
|
|
91
|
+
export function ellipseToPathDataHP(cx, cy, rx, ry, arcs = 8, precision = 6) {
|
|
92
|
+
// Enforce multiple of 4 for symmetry
|
|
93
|
+
if (arcs % 4 !== 0) {
|
|
94
|
+
arcs = Math.ceil(arcs / 4) * 4;
|
|
95
|
+
}
|
|
96
|
+
const cxD = D(cx), cyD = D(cy), rxD = D(rx), ryD = D(ry);
|
|
97
|
+
const f = v => formatNumber(v, precision);
|
|
98
|
+
|
|
99
|
+
// Angle per arc in radians
|
|
100
|
+
const PI = Decimal.acos(-1);
|
|
101
|
+
const TWO_PI = PI.mul(2);
|
|
102
|
+
const arcAngle = TWO_PI.div(arcs);
|
|
103
|
+
|
|
104
|
+
// Control point distance for this arc angle
|
|
105
|
+
const kappa = getKappaForArc(arcAngle);
|
|
106
|
+
|
|
107
|
+
// Generate path
|
|
108
|
+
const commands = [];
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < arcs; i++) {
|
|
111
|
+
const startAngle = arcAngle.mul(i);
|
|
112
|
+
const endAngle = arcAngle.mul(i + 1);
|
|
113
|
+
|
|
114
|
+
// Start and end points on ellipse
|
|
115
|
+
const cosStart = Decimal.cos(startAngle);
|
|
116
|
+
const sinStart = Decimal.sin(startAngle);
|
|
117
|
+
const cosEnd = Decimal.cos(endAngle);
|
|
118
|
+
const sinEnd = Decimal.sin(endAngle);
|
|
119
|
+
|
|
120
|
+
const x0 = cxD.plus(rxD.mul(cosStart));
|
|
121
|
+
const y0 = cyD.plus(ryD.mul(sinStart));
|
|
122
|
+
const x3 = cxD.plus(rxD.mul(cosEnd));
|
|
123
|
+
const y3 = cyD.plus(ryD.mul(sinEnd));
|
|
124
|
+
|
|
125
|
+
// Tangent vectors at start and end (perpendicular to radius, scaled by kappa)
|
|
126
|
+
// Tangent at angle θ: (-sin(θ), cos(θ))
|
|
127
|
+
// Control point 1: start + kappa * tangent_at_start * radius
|
|
128
|
+
// Control point 2: end - kappa * tangent_at_end * radius
|
|
129
|
+
const tx0 = sinStart.neg(); // tangent x at start
|
|
130
|
+
const ty0 = cosStart; // tangent y at start
|
|
131
|
+
const tx3 = sinEnd.neg(); // tangent x at end
|
|
132
|
+
const ty3 = cosEnd; // tangent y at end
|
|
133
|
+
|
|
134
|
+
const x1 = x0.plus(kappa.mul(rxD).mul(tx0));
|
|
135
|
+
const y1 = y0.plus(kappa.mul(ryD).mul(ty0));
|
|
136
|
+
const x2 = x3.minus(kappa.mul(rxD).mul(tx3));
|
|
137
|
+
const y2 = y3.minus(kappa.mul(ryD).mul(ty3));
|
|
138
|
+
|
|
139
|
+
if (i === 0) {
|
|
140
|
+
commands.push(`M ${f(x0)} ${f(y0)}`);
|
|
141
|
+
}
|
|
142
|
+
commands.push(`C ${f(x1)} ${f(y1)} ${f(x2)} ${f(y2)} ${f(x3)} ${f(y3)}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
commands.push('Z');
|
|
146
|
+
return commands.join(' ');
|
|
147
|
+
}
|
|
148
|
+
|
|
13
149
|
function formatNumber(value, precision = 6) {
|
|
14
150
|
// Use toFixed to preserve trailing zeros for consistent output formatting
|
|
15
151
|
return value.toFixed(precision);
|
package/src/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import * as MeshGradient from './mesh-gradient.js';
|
|
|
46
46
|
import * as TextToPath from './text-to-path.js';
|
|
47
47
|
import * as SVGParser from './svg-parser.js';
|
|
48
48
|
import * as FlattenPipeline from './flatten-pipeline.js';
|
|
49
|
+
import * as Verification from './verification.js';
|
|
49
50
|
import { Logger, LogLevel, setLogLevel, getLogLevel as getLoggerLevel, enableFileLogging, disableFileLogging } from './logger.js';
|
|
50
51
|
|
|
51
52
|
// Set high-precision default (80 significant digits) on module load
|
|
@@ -91,7 +92,7 @@ export { SVGFlatten, BrowserVerify };
|
|
|
91
92
|
export { ClipPathResolver, MaskResolver, PatternResolver };
|
|
92
93
|
export { UseSymbolResolver, MarkerResolver };
|
|
93
94
|
export { MeshGradient, TextToPath };
|
|
94
|
-
export { SVGParser, FlattenPipeline };
|
|
95
|
+
export { SVGParser, FlattenPipeline, Verification };
|
|
95
96
|
|
|
96
97
|
// ============================================================================
|
|
97
98
|
// LOGGING: Configurable logging control
|
|
@@ -426,6 +427,7 @@ export default {
|
|
|
426
427
|
TextToPath,
|
|
427
428
|
SVGParser,
|
|
428
429
|
FlattenPipeline,
|
|
430
|
+
Verification,
|
|
429
431
|
|
|
430
432
|
// Logging
|
|
431
433
|
Logger,
|