modern-path2d 1.6.1 → 1.7.0
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 +55 -0
- package/dist/index.cjs +249 -63
- package/dist/index.d.cts +56 -2
- package/dist/index.d.mts +56 -2
- package/dist/index.d.ts +56 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +249 -63
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
|
|
27
27
|
- Path triangulate (fill、stroke)
|
|
28
28
|
|
|
29
|
+
- Hit testing (point in fill / stroke, holes-aware)
|
|
30
|
+
|
|
31
|
+
- Analytical bounding box (lines / beziers / arcs / ellipses)
|
|
32
|
+
|
|
29
33
|
- Parse SVG to Path2DSet
|
|
30
34
|
|
|
31
35
|
- TypeScript
|
|
@@ -97,4 +101,55 @@ console.log(path.fillTriangulate())
|
|
|
97
101
|
|
|
98
102
|
// triangulate for stroke
|
|
99
103
|
console.log(path.strokeTriangulate())
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Hit testing
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
// point in fill — holes are honored, fillRule defaults to 'nonzero'
|
|
110
|
+
path.isPointInFill({ x: 75, y: 75 })
|
|
111
|
+
|
|
112
|
+
// concise PathKit-style shorthand for fill containment
|
|
113
|
+
path.contains(75, 75)
|
|
114
|
+
|
|
115
|
+
// point on stroke — within strokeWidth / 2 + tolerance
|
|
116
|
+
path.isPointInStroke({ x: 110, y: 75 }, { strokeWidth: 4, tolerance: 1 })
|
|
117
|
+
|
|
118
|
+
// hit test a whole set top-to-bottom; returns the hit Path2D or undefined
|
|
119
|
+
const set = new Path2DSet([path])
|
|
120
|
+
const hit = set.hitTest({ x: 75, y: 75 }) // Path2D | undefined
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bounding box
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
// analytical bounds, tight for lines / beziers / arcs / ellipses
|
|
127
|
+
const { x, y, width, height } = path.getBoundingBox()
|
|
128
|
+
|
|
129
|
+
// geometry only, ignoring stroke width
|
|
130
|
+
path.getBoundingBox(false)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Low-level geometry helpers
|
|
134
|
+
|
|
135
|
+
Pure functions over flat `[x0, y0, x1, y1, ...]` vertices, useful for building your own hit
|
|
136
|
+
testing. Vertices are treated as an implicitly closed ring.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import {
|
|
140
|
+
pointInPolygon,
|
|
141
|
+
pointInPolygons,
|
|
142
|
+
pointToPolylineDistance,
|
|
143
|
+
pointToSegmentDistance,
|
|
144
|
+
} from 'modern-path2d'
|
|
145
|
+
|
|
146
|
+
// single ring (fillRule: 'nonzero' | 'evenodd', default 'nonzero')
|
|
147
|
+
pointInPolygon({ x: 5, y: 5 }, [0, 0, 10, 0, 10, 10, 0, 10]) // true
|
|
148
|
+
|
|
149
|
+
// multi-ring shape with holes — sums winding / crossings across all rings
|
|
150
|
+
pointInPolygons({ x: 5, y: 5 }, [outerRing, innerHole]) // false (in the hole)
|
|
151
|
+
|
|
152
|
+
// distance to a segment / polyline (for stroke hit testing)
|
|
153
|
+
pointToSegmentDistance({ x: 5, y: 1 }, { x: 0, y: 0 }, { x: 10, y: 0 }) // 1
|
|
154
|
+
pointToPolylineDistance({ x: 5, y: 1 }, [0, 0, 10, 0, 10, 10], true) // closed polyline
|
|
100
155
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -126,7 +126,10 @@ class Vector2 {
|
|
|
126
126
|
return this.set(this._x * x, this._y * y);
|
|
127
127
|
}
|
|
128
128
|
divide(x = 0, y = x) {
|
|
129
|
-
return this.set(
|
|
129
|
+
return this.set(
|
|
130
|
+
x === 0 ? this._x : this._x / x,
|
|
131
|
+
y === 0 ? this._y : this._y / y
|
|
132
|
+
);
|
|
130
133
|
}
|
|
131
134
|
cross(p) {
|
|
132
135
|
return this._x * p.y - this._y * p.x;
|
|
@@ -319,17 +322,11 @@ function getIntersectionPoint(p1, p2, q1, q2) {
|
|
|
319
322
|
const q1p1 = q1.clone().sub(p1);
|
|
320
323
|
const crossRS = r.cross(s);
|
|
321
324
|
if (crossRS === 0) {
|
|
322
|
-
return
|
|
323
|
-
(p1.x + q1.x) / 2,
|
|
324
|
-
(p1.y + q1.y) / 2
|
|
325
|
-
);
|
|
325
|
+
return null;
|
|
326
326
|
}
|
|
327
327
|
const t = q1p1.cross(s) / crossRS;
|
|
328
328
|
if (Math.abs(t) > 1) {
|
|
329
|
-
return
|
|
330
|
-
(p1.x + q1.x) / 2,
|
|
331
|
-
(p1.y + q1.y) / 2
|
|
332
|
-
);
|
|
329
|
+
return null;
|
|
333
330
|
}
|
|
334
331
|
return new Vector2(
|
|
335
332
|
p1.x + t * r.x,
|
|
@@ -621,11 +618,16 @@ function distance(p1, p2) {
|
|
|
621
618
|
const dy = p2[1] - p1[1];
|
|
622
619
|
return Math.sqrt(dx * dx + dy * dy);
|
|
623
620
|
}
|
|
621
|
+
function aabbIntersects(a, b) {
|
|
622
|
+
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
|
623
|
+
}
|
|
624
624
|
function nonzeroFillRule(paths) {
|
|
625
625
|
const results = paths.map((_, i) => ({ index: i }));
|
|
626
|
-
const
|
|
626
|
+
const bboxes = [];
|
|
627
|
+
const testPointsGroups = paths.map((path, pathIndex) => {
|
|
627
628
|
const len = path.length;
|
|
628
629
|
if (!len) {
|
|
630
|
+
bboxes[pathIndex] = null;
|
|
629
631
|
return [];
|
|
630
632
|
}
|
|
631
633
|
let xMinYAuto = [Number.MAX_SAFE_INTEGER, 0];
|
|
@@ -648,6 +650,12 @@ function nonzeroFillRule(paths) {
|
|
|
648
650
|
xAutoYMax = [x, y];
|
|
649
651
|
}
|
|
650
652
|
}
|
|
653
|
+
bboxes[pathIndex] = {
|
|
654
|
+
minX: xMinYAuto[0],
|
|
655
|
+
minY: xAutoYMin[1],
|
|
656
|
+
maxX: xMaxYAuto[0],
|
|
657
|
+
maxY: xAutoYMax[1]
|
|
658
|
+
};
|
|
651
659
|
const mid = [
|
|
652
660
|
(xMinYAuto[0] + xMaxYAuto[0]) / 2,
|
|
653
661
|
(xAutoYMin[1] + xAutoYMax[1]) / 2
|
|
@@ -696,9 +704,14 @@ function nonzeroFillRule(paths) {
|
|
|
696
704
|
for (let i = 0, len = paths.length; i < len; i++) {
|
|
697
705
|
const _results = [];
|
|
698
706
|
const testPoints = testPointsGroups[i];
|
|
707
|
+
const boxI = bboxes[i];
|
|
699
708
|
for (let j = 0; j < len; j++) {
|
|
700
709
|
if (i === j)
|
|
701
710
|
continue;
|
|
711
|
+
const boxJ = bboxes[j];
|
|
712
|
+
if (!boxI || !boxJ || !aabbIntersects(boxI, boxJ)) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
702
715
|
const wnMap = {};
|
|
703
716
|
const wnList = [];
|
|
704
717
|
for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
|
|
@@ -2653,6 +2666,41 @@ function svgToPath2DSet(svg) {
|
|
|
2653
2666
|
class Curve {
|
|
2654
2667
|
arcLengthDivision = 200;
|
|
2655
2668
|
_lengths = [];
|
|
2669
|
+
_adaptiveCache;
|
|
2670
|
+
/**
|
|
2671
|
+
* Parent composite, set lazily when a composite caches its children. Lets
|
|
2672
|
+
* {@link invalidate} propagate up so an ancestor's caches refresh too.
|
|
2673
|
+
*/
|
|
2674
|
+
_owner;
|
|
2675
|
+
_invalidating = false;
|
|
2676
|
+
/**
|
|
2677
|
+
* Drop cached arc lengths and the cached sampled outline used by hit testing, then
|
|
2678
|
+
* bubble up to {@link _owner}. Called automatically by {@link applyTransform} and the
|
|
2679
|
+
* `Path2D` mutators; call it manually after mutating control-point coordinates in place —
|
|
2680
|
+
* the caches cannot observe such mutations.
|
|
2681
|
+
*/
|
|
2682
|
+
invalidate() {
|
|
2683
|
+
if (this._invalidating) {
|
|
2684
|
+
return this;
|
|
2685
|
+
}
|
|
2686
|
+
this._invalidating = true;
|
|
2687
|
+
this._invalidateSelf();
|
|
2688
|
+
this._owner?.invalidate();
|
|
2689
|
+
this._invalidating = false;
|
|
2690
|
+
return this;
|
|
2691
|
+
}
|
|
2692
|
+
/** Clears this curve's own caches. Composites also clear their children (see override). */
|
|
2693
|
+
_invalidateSelf() {
|
|
2694
|
+
this._lengths.length = 0;
|
|
2695
|
+
this._adaptiveCache = void 0;
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* Sampled outline cached for repeated hit tests (read-only — do not mutate the result).
|
|
2699
|
+
* Invalidated by {@link invalidate}.
|
|
2700
|
+
*/
|
|
2701
|
+
_getCachedAdaptiveVertices() {
|
|
2702
|
+
return this._adaptiveCache ??= this.getAdaptiveVertices();
|
|
2703
|
+
}
|
|
2656
2704
|
getPointAt(u, output = new Vector2()) {
|
|
2657
2705
|
return this.getPoint(this.getUToTMapping(u), output);
|
|
2658
2706
|
}
|
|
@@ -2671,6 +2719,7 @@ class Curve {
|
|
|
2671
2719
|
transform.apply(p, p);
|
|
2672
2720
|
}
|
|
2673
2721
|
});
|
|
2722
|
+
this.invalidate();
|
|
2674
2723
|
return this;
|
|
2675
2724
|
}
|
|
2676
2725
|
getUnevenVertices(count = 5, output = []) {
|
|
@@ -2815,12 +2864,25 @@ class Curve {
|
|
|
2815
2864
|
return mid;
|
|
2816
2865
|
}
|
|
2817
2866
|
getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
|
|
2818
|
-
const
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2867
|
+
const vertices = this.getAdaptiveVertices();
|
|
2868
|
+
let minX = min.x;
|
|
2869
|
+
let minY = min.y;
|
|
2870
|
+
let maxX = max.x;
|
|
2871
|
+
let maxY = max.y;
|
|
2872
|
+
for (let i = 0, len = vertices.length; i < len; i += 2) {
|
|
2873
|
+
const x = vertices[i];
|
|
2874
|
+
const y = vertices[i + 1];
|
|
2875
|
+
if (x < minX)
|
|
2876
|
+
minX = x;
|
|
2877
|
+
if (y < minY)
|
|
2878
|
+
minY = y;
|
|
2879
|
+
if (x > maxX)
|
|
2880
|
+
maxX = x;
|
|
2881
|
+
if (y > maxY)
|
|
2882
|
+
maxY = y;
|
|
2823
2883
|
}
|
|
2884
|
+
min.set(minX, minY);
|
|
2885
|
+
max.set(maxX, maxY);
|
|
2824
2886
|
return { min: min.finite(), max: max.finite() };
|
|
2825
2887
|
}
|
|
2826
2888
|
getBoundingBox() {
|
|
@@ -2838,7 +2900,7 @@ class Curve {
|
|
|
2838
2900
|
* are honored — a single `Curve` is always one ring.
|
|
2839
2901
|
*/
|
|
2840
2902
|
isPointInFill(point, options = {}) {
|
|
2841
|
-
return pointInPolygon(point, this.
|
|
2903
|
+
return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
|
|
2842
2904
|
}
|
|
2843
2905
|
/**
|
|
2844
2906
|
* Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
|
|
@@ -2851,7 +2913,7 @@ class Curve {
|
|
|
2851
2913
|
*/
|
|
2852
2914
|
isPointInStroke(point, options = {}) {
|
|
2853
2915
|
const { strokeWidth = 1, tolerance = 0, closed = false } = options;
|
|
2854
|
-
const distance = pointToPolylineDistance(point, this.
|
|
2916
|
+
const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
|
|
2855
2917
|
return distance <= strokeWidth / 2 + tolerance;
|
|
2856
2918
|
}
|
|
2857
2919
|
/**
|
|
@@ -2995,6 +3057,64 @@ class RoundCurve extends Curve {
|
|
|
2995
3057
|
}
|
|
2996
3058
|
return output.set(_x, _y);
|
|
2997
3059
|
}
|
|
3060
|
+
/**
|
|
3061
|
+
* Point on the ellipse at an absolute angle (mirrors {@link getPoint}'s parameterization,
|
|
3062
|
+
* ignoring `_diff`).
|
|
3063
|
+
*/
|
|
3064
|
+
_pointAtAngle(angle, output) {
|
|
3065
|
+
let x = this.cx + this.rx * Math.cos(angle);
|
|
3066
|
+
let y = this.cy + this.ry * Math.sin(angle);
|
|
3067
|
+
if (this.rotate !== 0) {
|
|
3068
|
+
const cos = Math.cos(this.rotate);
|
|
3069
|
+
const sin = Math.sin(this.rotate);
|
|
3070
|
+
const tx = x - this.cx;
|
|
3071
|
+
const ty = y - this.cy;
|
|
3072
|
+
x = tx * cos - ty * sin + this.cx;
|
|
3073
|
+
y = tx * sin + ty * cos + this.cy;
|
|
3074
|
+
}
|
|
3075
|
+
return output.set(x, y);
|
|
3076
|
+
}
|
|
3077
|
+
/**
|
|
3078
|
+
* Analytical bounds of the (elliptical) arc: the start/end points plus the per-axis
|
|
3079
|
+
* extrema angles that fall within the swept interval. Matches {@link getPoint}, so it is
|
|
3080
|
+
* exact for `ArcCurve`/`EllipseCurve`. The `_diff` offset (used only by the legacy
|
|
3081
|
+
* `_getAdaptiveVerticesByCircle` path) is intentionally ignored here.
|
|
3082
|
+
*/
|
|
3083
|
+
getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
|
|
3084
|
+
const { startAngle, rotate } = this;
|
|
3085
|
+
const delta = this._getDeltaAngle();
|
|
3086
|
+
const cosT = Math.cos(rotate);
|
|
3087
|
+
const sinT = Math.sin(rotate);
|
|
3088
|
+
const p = tempV2;
|
|
3089
|
+
let minX = min.x;
|
|
3090
|
+
let minY = min.y;
|
|
3091
|
+
let maxX = max.x;
|
|
3092
|
+
let maxY = max.y;
|
|
3093
|
+
const consider = (angle) => {
|
|
3094
|
+
this._pointAtAngle(angle, p);
|
|
3095
|
+
if (p.x < minX)
|
|
3096
|
+
minX = p.x;
|
|
3097
|
+
if (p.y < minY)
|
|
3098
|
+
minY = p.y;
|
|
3099
|
+
if (p.x > maxX)
|
|
3100
|
+
maxX = p.x;
|
|
3101
|
+
if (p.y > maxY)
|
|
3102
|
+
maxY = p.y;
|
|
3103
|
+
};
|
|
3104
|
+
consider(startAngle);
|
|
3105
|
+
consider(startAngle + delta);
|
|
3106
|
+
const ax = Math.atan2(-this.ry * sinT, this.rx * cosT);
|
|
3107
|
+
const ay = Math.atan2(this.ry * cosT, this.rx * sinT);
|
|
3108
|
+
const bases = [ax, ax + Math.PI, ay, ay + Math.PI];
|
|
3109
|
+
for (let i = 0; i < 4; i++) {
|
|
3110
|
+
if (angleInSweep(bases[i], startAngle, delta)) {
|
|
3111
|
+
consider(bases[i]);
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
min.set(minX, minY);
|
|
3115
|
+
max.set(maxX, maxY);
|
|
3116
|
+
return { min: min.finite(), max: max.finite() };
|
|
3117
|
+
}
|
|
2998
3118
|
toCommands() {
|
|
2999
3119
|
const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
|
|
3000
3120
|
const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
|
|
@@ -3045,6 +3165,7 @@ class RoundCurve extends Curve {
|
|
|
3045
3165
|
} else {
|
|
3046
3166
|
transfEllipseNoSkew(this, transform);
|
|
3047
3167
|
}
|
|
3168
|
+
this.invalidate();
|
|
3048
3169
|
return this;
|
|
3049
3170
|
}
|
|
3050
3171
|
getControlPointRefs() {
|
|
@@ -3185,6 +3306,23 @@ class RoundCurve extends Curve {
|
|
|
3185
3306
|
return this;
|
|
3186
3307
|
}
|
|
3187
3308
|
}
|
|
3309
|
+
function angleInSweep(a, start, delta) {
|
|
3310
|
+
const PI_2 = Math.PI * 2;
|
|
3311
|
+
const eps = 1e-9;
|
|
3312
|
+
if (Math.abs(delta) >= PI_2 - eps) {
|
|
3313
|
+
return true;
|
|
3314
|
+
}
|
|
3315
|
+
let off = (a - start) % PI_2;
|
|
3316
|
+
if (delta >= 0) {
|
|
3317
|
+
if (off < -eps)
|
|
3318
|
+
off += PI_2;
|
|
3319
|
+
return off >= -eps && off <= delta + eps;
|
|
3320
|
+
}
|
|
3321
|
+
if (off > eps) {
|
|
3322
|
+
off -= PI_2;
|
|
3323
|
+
}
|
|
3324
|
+
return off <= eps && off >= delta - eps;
|
|
3325
|
+
}
|
|
3188
3326
|
function transfEllipseGeneric(curve, m) {
|
|
3189
3327
|
const a = curve.rx;
|
|
3190
3328
|
const b = curve.ry;
|
|
@@ -3423,6 +3561,22 @@ class CompositeCurve extends Curve {
|
|
|
3423
3561
|
super();
|
|
3424
3562
|
this.curves = curves;
|
|
3425
3563
|
}
|
|
3564
|
+
_adaptiveCacheLen = -1;
|
|
3565
|
+
_invalidateSelf() {
|
|
3566
|
+
super._invalidateSelf();
|
|
3567
|
+
this._adaptiveCacheLen = -1;
|
|
3568
|
+
this.curves.forEach((curve) => curve.invalidate());
|
|
3569
|
+
}
|
|
3570
|
+
_getCachedAdaptiveVertices() {
|
|
3571
|
+
if (!this._adaptiveCache || this._adaptiveCacheLen !== this.curves.length) {
|
|
3572
|
+
this.curves.forEach((curve) => {
|
|
3573
|
+
curve._owner = this;
|
|
3574
|
+
});
|
|
3575
|
+
this._adaptiveCache = this.getAdaptiveVertices();
|
|
3576
|
+
this._adaptiveCacheLen = this.curves.length;
|
|
3577
|
+
}
|
|
3578
|
+
return this._adaptiveCache;
|
|
3579
|
+
}
|
|
3426
3580
|
getFlatCurves() {
|
|
3427
3581
|
return this.curves.flatMap((curve) => {
|
|
3428
3582
|
if (curve instanceof CompositeCurve) {
|
|
@@ -3468,6 +3622,7 @@ class CompositeCurve extends Curve {
|
|
|
3468
3622
|
updateLengths() {
|
|
3469
3623
|
const lengths = [];
|
|
3470
3624
|
for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
|
|
3625
|
+
this.curves[i]._owner = this;
|
|
3471
3626
|
sum += this.curves[i].getLength();
|
|
3472
3627
|
lengths.push(sum);
|
|
3473
3628
|
}
|
|
@@ -3536,7 +3691,10 @@ class CompositeCurve extends Curve {
|
|
|
3536
3691
|
}
|
|
3537
3692
|
}
|
|
3538
3693
|
applyTransform(transform) {
|
|
3694
|
+
this._invalidating = true;
|
|
3539
3695
|
this.curves.forEach((curve) => curve.applyTransform(transform));
|
|
3696
|
+
this._invalidating = false;
|
|
3697
|
+
this.invalidate();
|
|
3540
3698
|
return this;
|
|
3541
3699
|
}
|
|
3542
3700
|
getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
|
|
@@ -3875,7 +4033,7 @@ class RectangleCurve extends PolygonCurve {
|
|
|
3875
4033
|
}
|
|
3876
4034
|
}
|
|
3877
4035
|
|
|
3878
|
-
class RoundRectangleCurve extends
|
|
4036
|
+
class RoundRectangleCurve extends CompositeCurve {
|
|
3879
4037
|
constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
|
|
3880
4038
|
super();
|
|
3881
4039
|
this.x = x;
|
|
@@ -3886,25 +4044,53 @@ class RoundRectangleCurve extends RoundCurve {
|
|
|
3886
4044
|
this.update();
|
|
3887
4045
|
}
|
|
3888
4046
|
update() {
|
|
3889
|
-
const { x, y, width, height
|
|
3890
|
-
const
|
|
3891
|
-
const
|
|
3892
|
-
const
|
|
3893
|
-
const
|
|
3894
|
-
const
|
|
3895
|
-
const
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
4047
|
+
const { x, y, width, height } = this;
|
|
4048
|
+
const r = Math.max(0, Math.min(this.radius, Math.abs(width) / 2, Math.abs(height) / 2));
|
|
4049
|
+
const x0 = x;
|
|
4050
|
+
const x1 = x + r;
|
|
4051
|
+
const x2 = x + width - r;
|
|
4052
|
+
const x3 = x + width;
|
|
4053
|
+
const y0 = y;
|
|
4054
|
+
const y1 = y + r;
|
|
4055
|
+
const y2 = y + height - r;
|
|
4056
|
+
const y3 = y + height;
|
|
4057
|
+
if (r <= 0) {
|
|
4058
|
+
this.curves = [
|
|
4059
|
+
LineCurve.from(x0, y0, x3, y0),
|
|
4060
|
+
LineCurve.from(x3, y0, x3, y3),
|
|
4061
|
+
LineCurve.from(x3, y3, x0, y3),
|
|
4062
|
+
LineCurve.from(x0, y3, x0, y0)
|
|
4063
|
+
];
|
|
4064
|
+
} else {
|
|
4065
|
+
const HALF_PI = Math.PI / 2;
|
|
4066
|
+
this.curves = [
|
|
4067
|
+
LineCurve.from(x1, y0, x2, y0),
|
|
4068
|
+
// top edge
|
|
4069
|
+
new ArcCurve(x2, y1, r, -HALF_PI, 0, true),
|
|
4070
|
+
// top-right corner
|
|
4071
|
+
LineCurve.from(x3, y1, x3, y2),
|
|
4072
|
+
// right edge
|
|
4073
|
+
new ArcCurve(x2, y2, r, 0, HALF_PI, true),
|
|
4074
|
+
// bottom-right corner
|
|
4075
|
+
LineCurve.from(x2, y3, x1, y3),
|
|
4076
|
+
// bottom edge
|
|
4077
|
+
new ArcCurve(x1, y2, r, HALF_PI, Math.PI, true),
|
|
4078
|
+
// bottom-left corner
|
|
4079
|
+
LineCurve.from(x0, y2, x0, y1),
|
|
4080
|
+
// left edge
|
|
4081
|
+
new ArcCurve(x1, y1, r, Math.PI, Math.PI * 1.5, true)
|
|
4082
|
+
// top-left corner
|
|
4083
|
+
];
|
|
4084
|
+
}
|
|
4085
|
+
this.invalidate();
|
|
3899
4086
|
return this;
|
|
3900
4087
|
}
|
|
3901
4088
|
drawTo(ctx) {
|
|
3902
|
-
|
|
3903
|
-
ctx.roundRect(x, y, width, height, radius);
|
|
4089
|
+
ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
|
|
3904
4090
|
return this;
|
|
3905
4091
|
}
|
|
3906
4092
|
copyFrom(source) {
|
|
3907
|
-
|
|
4093
|
+
this.arcLengthDivision = source.arcLengthDivision;
|
|
3908
4094
|
this.x = source.x;
|
|
3909
4095
|
this.y = source.y;
|
|
3910
4096
|
this.width = source.width;
|
|
@@ -4006,7 +4192,7 @@ class CurvePath extends CompositeCurve {
|
|
|
4006
4192
|
*/
|
|
4007
4193
|
isPointInStroke(point, options = {}) {
|
|
4008
4194
|
const { strokeWidth = 1, tolerance = 0 } = options;
|
|
4009
|
-
const vertices = this.
|
|
4195
|
+
const vertices = this._getCachedAdaptiveVertices();
|
|
4010
4196
|
const len = vertices.length;
|
|
4011
4197
|
const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
|
|
4012
4198
|
return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
|
|
@@ -4184,6 +4370,8 @@ class CurvePath extends CompositeCurve {
|
|
|
4184
4370
|
|
|
4185
4371
|
class Path2D extends CompositeCurve {
|
|
4186
4372
|
_meta;
|
|
4373
|
+
_ringsCache;
|
|
4374
|
+
_ringsCacheLen = -1;
|
|
4187
4375
|
currentCurve = new CurvePath();
|
|
4188
4376
|
style;
|
|
4189
4377
|
get startPoint() {
|
|
@@ -4302,18 +4490,21 @@ class Path2D extends CompositeCurve {
|
|
|
4302
4490
|
this.getControlPointRefs().forEach((point) => {
|
|
4303
4491
|
point.scale(sx, sy, target);
|
|
4304
4492
|
});
|
|
4493
|
+
this.invalidate();
|
|
4305
4494
|
return this;
|
|
4306
4495
|
}
|
|
4307
4496
|
skew(ax, ay = 0, target = { x: 0, y: 0 }) {
|
|
4308
4497
|
this.getControlPointRefs().forEach((point) => {
|
|
4309
4498
|
point.skew(ax, ay, target);
|
|
4310
4499
|
});
|
|
4500
|
+
this.invalidate();
|
|
4311
4501
|
return this;
|
|
4312
4502
|
}
|
|
4313
4503
|
rotate(rad, target = { x: 0, y: 0 }) {
|
|
4314
4504
|
this.getControlPointRefs().forEach((point) => {
|
|
4315
4505
|
point.rotate(rad, target);
|
|
4316
4506
|
});
|
|
4507
|
+
this.invalidate();
|
|
4317
4508
|
return this;
|
|
4318
4509
|
}
|
|
4319
4510
|
bold(b) {
|
|
@@ -4372,6 +4563,7 @@ class Path2D extends CompositeCurve {
|
|
|
4372
4563
|
}
|
|
4373
4564
|
});
|
|
4374
4565
|
});
|
|
4566
|
+
this.invalidate();
|
|
4375
4567
|
return this;
|
|
4376
4568
|
}
|
|
4377
4569
|
/**
|
|
@@ -4384,13 +4576,25 @@ class Path2D extends CompositeCurve {
|
|
|
4384
4576
|
*
|
|
4385
4577
|
* Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
|
|
4386
4578
|
*/
|
|
4579
|
+
_invalidateSelf() {
|
|
4580
|
+
super._invalidateSelf();
|
|
4581
|
+
this._ringsCache = void 0;
|
|
4582
|
+
this._ringsCacheLen = -1;
|
|
4583
|
+
}
|
|
4584
|
+
/** Per-sub-path sampled rings, cached for repeated hit tests. */
|
|
4585
|
+
_getRings() {
|
|
4586
|
+
if (!this._ringsCache || this._ringsCacheLen !== this.curves.length) {
|
|
4587
|
+
this._ringsCache = this.curves.map((curve) => {
|
|
4588
|
+
curve._owner = this;
|
|
4589
|
+
return curve.getAdaptiveVertices();
|
|
4590
|
+
});
|
|
4591
|
+
this._ringsCacheLen = this.curves.length;
|
|
4592
|
+
}
|
|
4593
|
+
return this._ringsCache;
|
|
4594
|
+
}
|
|
4387
4595
|
isPointInFill(point, options = {}) {
|
|
4388
4596
|
const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
|
|
4389
|
-
return pointInPolygons(
|
|
4390
|
-
point,
|
|
4391
|
-
this.curves.map((curve) => curve.getAdaptiveVertices()),
|
|
4392
|
-
fillRule
|
|
4393
|
-
);
|
|
4597
|
+
return pointInPolygons(point, this._getRings(), fillRule);
|
|
4394
4598
|
}
|
|
4395
4599
|
/**
|
|
4396
4600
|
* Test whether a point lies on this path's stroke. A hit on any sub-path counts.
|
|
@@ -4409,35 +4613,17 @@ class Path2D extends CompositeCurve {
|
|
|
4409
4613
|
}));
|
|
4410
4614
|
}
|
|
4411
4615
|
getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
|
|
4412
|
-
const strokeWidth = this.strokeWidth;
|
|
4413
4616
|
this.curves.forEach((curve) => {
|
|
4414
4617
|
curve.getMinMax(min, max);
|
|
4415
|
-
if (withStyle) {
|
|
4416
|
-
if (strokeWidth > 1) {
|
|
4417
|
-
const halfStrokeWidth = strokeWidth / 2;
|
|
4418
|
-
const isClockwise = curve.isClockwise();
|
|
4419
|
-
const points = [];
|
|
4420
|
-
for (let t = 0; t <= 1; t += 1 / curve.arcLengthDivision) {
|
|
4421
|
-
const point = curve.getPoint(t);
|
|
4422
|
-
const normal = curve.getNormal(t);
|
|
4423
|
-
const dist1 = normal.clone().scale(isClockwise ? halfStrokeWidth : -halfStrokeWidth);
|
|
4424
|
-
const dist2 = normal.clone().scale(isClockwise ? -halfStrokeWidth : halfStrokeWidth);
|
|
4425
|
-
points.push(
|
|
4426
|
-
point.clone().add(dist1),
|
|
4427
|
-
point.clone().add(dist2),
|
|
4428
|
-
point.clone().add({ x: halfStrokeWidth, y: 0 }),
|
|
4429
|
-
point.clone().add({ x: -halfStrokeWidth, y: 0 }),
|
|
4430
|
-
point.clone().add({ x: 0, y: halfStrokeWidth }),
|
|
4431
|
-
point.clone().add({ x: 0, y: -halfStrokeWidth }),
|
|
4432
|
-
point.clone().add({ x: halfStrokeWidth, y: halfStrokeWidth }),
|
|
4433
|
-
point.clone().add({ x: -halfStrokeWidth, y: -halfStrokeWidth })
|
|
4434
|
-
);
|
|
4435
|
-
}
|
|
4436
|
-
min.clampMin(...points);
|
|
4437
|
-
max.clampMax(...points);
|
|
4438
|
-
}
|
|
4439
|
-
}
|
|
4440
4618
|
});
|
|
4619
|
+
if (withStyle) {
|
|
4620
|
+
const strokeWidth = this.strokeWidth;
|
|
4621
|
+
if (strokeWidth > 1 && Number.isFinite(min.x)) {
|
|
4622
|
+
const half = strokeWidth / 2;
|
|
4623
|
+
min.set(min.x - half, min.y - half);
|
|
4624
|
+
max.set(max.x + half, max.y + half);
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4441
4627
|
return { min: min.finite(), max: max.finite() };
|
|
4442
4628
|
}
|
|
4443
4629
|
strokeTriangulate(options) {
|
package/dist/index.d.cts
CHANGED
|
@@ -298,7 +298,12 @@ declare function getDirectedArea(vertices: number[]): number;
|
|
|
298
298
|
declare const PI: number;
|
|
299
299
|
declare const PI_2: number;
|
|
300
300
|
declare function toKebabCase(str: string): string;
|
|
301
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Intersection of line p1→p2 with line q1→q2, or `null` when the segments are parallel
|
|
303
|
+
* (`crossRS === 0`) or the intersection lies too far off p1→p2 (`|t| > 1`). Callers must
|
|
304
|
+
* handle `null` (e.g. `Path2D.bold` skips the join when there is no usable point).
|
|
305
|
+
*/
|
|
306
|
+
declare function getIntersectionPoint(p1: Vector2, p2: Vector2, q1: Vector2, q2: Vector2): Vector2 | null;
|
|
302
307
|
|
|
303
308
|
interface NonzeroFillRuleResult {
|
|
304
309
|
index: number;
|
|
@@ -383,7 +388,28 @@ interface IsPointInStrokeOptions {
|
|
|
383
388
|
declare abstract class Curve {
|
|
384
389
|
arcLengthDivision: number;
|
|
385
390
|
protected _lengths: number[];
|
|
391
|
+
protected _adaptiveCache?: number[];
|
|
392
|
+
/**
|
|
393
|
+
* Parent composite, set lazily when a composite caches its children. Lets
|
|
394
|
+
* {@link invalidate} propagate up so an ancestor's caches refresh too.
|
|
395
|
+
*/
|
|
396
|
+
_owner?: Curve;
|
|
397
|
+
protected _invalidating: boolean;
|
|
386
398
|
abstract getPoint(t: number, output?: Vector2): Vector2;
|
|
399
|
+
/**
|
|
400
|
+
* Drop cached arc lengths and the cached sampled outline used by hit testing, then
|
|
401
|
+
* bubble up to {@link _owner}. Called automatically by {@link applyTransform} and the
|
|
402
|
+
* `Path2D` mutators; call it manually after mutating control-point coordinates in place —
|
|
403
|
+
* the caches cannot observe such mutations.
|
|
404
|
+
*/
|
|
405
|
+
invalidate(): this;
|
|
406
|
+
/** Clears this curve's own caches. Composites also clear their children (see override). */
|
|
407
|
+
protected _invalidateSelf(): void;
|
|
408
|
+
/**
|
|
409
|
+
* Sampled outline cached for repeated hit tests (read-only — do not mutate the result).
|
|
410
|
+
* Invalidated by {@link invalidate}.
|
|
411
|
+
*/
|
|
412
|
+
protected _getCachedAdaptiveVertices(): number[];
|
|
387
413
|
getPointAt(u: number, output?: Vector2): Vector2;
|
|
388
414
|
isClockwise(): boolean;
|
|
389
415
|
getControlPointRefs(): Vector2[];
|
|
@@ -470,6 +496,21 @@ declare class RoundCurve extends Curve {
|
|
|
470
496
|
isClockwise(): boolean;
|
|
471
497
|
protected _getDeltaAngle(): number;
|
|
472
498
|
getPoint(t: number, output?: Vector2): Vector2;
|
|
499
|
+
/**
|
|
500
|
+
* Point on the ellipse at an absolute angle (mirrors {@link getPoint}'s parameterization,
|
|
501
|
+
* ignoring `_diff`).
|
|
502
|
+
*/
|
|
503
|
+
protected _pointAtAngle(angle: number, output: Vector2): Vector2;
|
|
504
|
+
/**
|
|
505
|
+
* Analytical bounds of the (elliptical) arc: the start/end points plus the per-axis
|
|
506
|
+
* extrema angles that fall within the swept interval. Matches {@link getPoint}, so it is
|
|
507
|
+
* exact for `ArcCurve`/`EllipseCurve`. The `_diff` offset (used only by the legacy
|
|
508
|
+
* `_getAdaptiveVerticesByCircle` path) is intentionally ignored here.
|
|
509
|
+
*/
|
|
510
|
+
getMinMax(min?: Vector2, max?: Vector2): {
|
|
511
|
+
min: Vector2;
|
|
512
|
+
max: Vector2;
|
|
513
|
+
};
|
|
473
514
|
toCommands(): Path2DCommand[];
|
|
474
515
|
drawTo(ctx: CanvasRenderingContext2D): this;
|
|
475
516
|
applyTransform(transform: Transform2D): this;
|
|
@@ -487,7 +528,10 @@ declare class ArcCurve extends RoundCurve {
|
|
|
487
528
|
|
|
488
529
|
declare class CompositeCurve<T extends Curve = Curve> extends Curve {
|
|
489
530
|
curves: T[];
|
|
531
|
+
protected _adaptiveCacheLen: number;
|
|
490
532
|
constructor(curves?: T[]);
|
|
533
|
+
protected _invalidateSelf(): void;
|
|
534
|
+
protected _getCachedAdaptiveVertices(): number[];
|
|
491
535
|
getFlatCurves(): Curve[];
|
|
492
536
|
addCurve(curve: T): this;
|
|
493
537
|
getPoint(t: number, output?: Vector2): Vector2;
|
|
@@ -599,7 +643,12 @@ declare class RectangleCurve extends PolygonCurve {
|
|
|
599
643
|
copyFrom(source: RectangleCurve): this;
|
|
600
644
|
}
|
|
601
645
|
|
|
602
|
-
|
|
646
|
+
/**
|
|
647
|
+
* A rounded rectangle, modelled as a real composite of 4 `LineCurve` edges + 4 quarter
|
|
648
|
+
* `ArcCurve` corners (like {@link RectangleCurve}). `getPoint`/`getLength`/`getMinMax`/
|
|
649
|
+
* `toCommands` therefore describe the actual rounded outline — not a bare ellipse.
|
|
650
|
+
*/
|
|
651
|
+
declare class RoundRectangleCurve extends CompositeCurve {
|
|
603
652
|
x: number;
|
|
604
653
|
y: number;
|
|
605
654
|
width: number;
|
|
@@ -668,6 +717,8 @@ declare class CurvePath extends CompositeCurve {
|
|
|
668
717
|
*/
|
|
669
718
|
declare class Path2D<T = any> extends CompositeCurve<CurvePath> {
|
|
670
719
|
protected _meta?: T;
|
|
720
|
+
protected _ringsCache?: number[][];
|
|
721
|
+
protected _ringsCacheLen: number;
|
|
671
722
|
currentCurve: CurvePath;
|
|
672
723
|
style: Partial<Path2DStyle>;
|
|
673
724
|
get startPoint(): Vector2 | undefined;
|
|
@@ -705,6 +756,9 @@ declare class Path2D<T = any> extends CompositeCurve<CurvePath> {
|
|
|
705
756
|
*
|
|
706
757
|
* Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
|
|
707
758
|
*/
|
|
759
|
+
protected _invalidateSelf(): void;
|
|
760
|
+
/** Per-sub-path sampled rings, cached for repeated hit tests. */
|
|
761
|
+
protected _getRings(): number[][];
|
|
708
762
|
isPointInFill(point: Vector2Like, options?: IsPointInFillOptions): boolean;
|
|
709
763
|
/**
|
|
710
764
|
* Test whether a point lies on this path's stroke. A hit on any sub-path counts.
|