modern-path2d 1.6.0 → 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/dist/index.mjs CHANGED
@@ -120,7 +120,10 @@ class Vector2 {
120
120
  return this.set(this._x * x, this._y * y);
121
121
  }
122
122
  divide(x = 0, y = x) {
123
- return this.set(this._x / x, this._y / y);
123
+ return this.set(
124
+ x === 0 ? this._x : this._x / x,
125
+ y === 0 ? this._y : this._y / y
126
+ );
124
127
  }
125
128
  cross(p) {
126
129
  return this._x * p.y - this._y * p.x;
@@ -313,17 +316,11 @@ function getIntersectionPoint(p1, p2, q1, q2) {
313
316
  const q1p1 = q1.clone().sub(p1);
314
317
  const crossRS = r.cross(s);
315
318
  if (crossRS === 0) {
316
- return new Vector2(
317
- (p1.x + q1.x) / 2,
318
- (p1.y + q1.y) / 2
319
- );
319
+ return null;
320
320
  }
321
321
  const t = q1p1.cross(s) / crossRS;
322
322
  if (Math.abs(t) > 1) {
323
- return new Vector2(
324
- (p1.x + q1.x) / 2,
325
- (p1.y + q1.y) / 2
326
- );
323
+ return null;
327
324
  }
328
325
  return new Vector2(
329
326
  p1.x + t * r.x,
@@ -592,7 +589,7 @@ function getDirectedArea(vertices) {
592
589
  function cross(ax, ay, bx, by, cx, cy) {
593
590
  return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
594
591
  }
595
- function windingNumber(px, py, polygon) {
592
+ function windingNumber$1(px, py, polygon) {
596
593
  const polygonLen = polygon.length;
597
594
  let wn = 0;
598
595
  for (let i = 0, j = polygonLen - 2; i < polygonLen; j = i, i += 2) {
@@ -615,11 +612,16 @@ function distance(p1, p2) {
615
612
  const dy = p2[1] - p1[1];
616
613
  return Math.sqrt(dx * dx + dy * dy);
617
614
  }
615
+ function aabbIntersects(a, b) {
616
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
617
+ }
618
618
  function nonzeroFillRule(paths) {
619
619
  const results = paths.map((_, i) => ({ index: i }));
620
- const testPointsGroups = paths.map((path) => {
620
+ const bboxes = [];
621
+ const testPointsGroups = paths.map((path, pathIndex) => {
621
622
  const len = path.length;
622
623
  if (!len) {
624
+ bboxes[pathIndex] = null;
623
625
  return [];
624
626
  }
625
627
  let xMinYAuto = [Number.MAX_SAFE_INTEGER, 0];
@@ -642,6 +644,12 @@ function nonzeroFillRule(paths) {
642
644
  xAutoYMax = [x, y];
643
645
  }
644
646
  }
647
+ bboxes[pathIndex] = {
648
+ minX: xMinYAuto[0],
649
+ minY: xAutoYMin[1],
650
+ maxX: xMaxYAuto[0],
651
+ maxY: xAutoYMax[1]
652
+ };
645
653
  const mid = [
646
654
  (xMinYAuto[0] + xMaxYAuto[0]) / 2,
647
655
  (xAutoYMin[1] + xAutoYMax[1]) / 2
@@ -690,14 +698,19 @@ function nonzeroFillRule(paths) {
690
698
  for (let i = 0, len = paths.length; i < len; i++) {
691
699
  const _results = [];
692
700
  const testPoints = testPointsGroups[i];
701
+ const boxI = bboxes[i];
693
702
  for (let j = 0; j < len; j++) {
694
703
  if (i === j)
695
704
  continue;
705
+ const boxJ = bboxes[j];
706
+ if (!boxI || !boxJ || !aabbIntersects(boxI, boxJ)) {
707
+ continue;
708
+ }
696
709
  const wnMap = {};
697
710
  const wnList = [];
698
711
  for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
699
712
  const [x, y] = testPoints[p];
700
- const winding = windingNumber(x, y, paths[j]);
713
+ const winding = windingNumber$1(x, y, paths[j]);
701
714
  wnMap[winding] = (wnMap[winding] ?? 0) + 1;
702
715
  wnList.push(winding);
703
716
  }
@@ -720,6 +733,120 @@ function nonzeroFillRule(paths) {
720
733
  return results;
721
734
  }
722
735
 
736
+ function isLeft(ax, ay, bx, by, px, py) {
737
+ return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
738
+ }
739
+ function windingNumber(px, py, vertices) {
740
+ const len = vertices.length;
741
+ let wn = 0;
742
+ for (let i = 0; i < len; i += 2) {
743
+ const ax = vertices[i];
744
+ const ay = vertices[i + 1];
745
+ const k = (i + 2) % len;
746
+ const bx = vertices[k];
747
+ const by = vertices[k + 1];
748
+ if (ay <= py) {
749
+ if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
750
+ wn++;
751
+ }
752
+ } else {
753
+ if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
754
+ wn--;
755
+ }
756
+ }
757
+ }
758
+ return wn;
759
+ }
760
+ function crossingNumber(px, py, vertices) {
761
+ const len = vertices.length;
762
+ let cn = 0;
763
+ for (let i = 0; i < len; i += 2) {
764
+ const ax = vertices[i];
765
+ const ay = vertices[i + 1];
766
+ const k = (i + 2) % len;
767
+ const bx = vertices[k];
768
+ const by = vertices[k + 1];
769
+ if (ay <= py && by > py || ay > py && by <= py) {
770
+ const t = (py - ay) / (by - ay);
771
+ if (px < ax + t * (bx - ax)) {
772
+ cn++;
773
+ }
774
+ }
775
+ }
776
+ return cn;
777
+ }
778
+ function segmentDistance(px, py, ax, ay, bx, by) {
779
+ const dx = bx - ax;
780
+ const dy = by - ay;
781
+ const lenSq = dx * dx + dy * dy;
782
+ let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
783
+ if (t < 0) {
784
+ t = 0;
785
+ } else if (t > 1) {
786
+ t = 1;
787
+ }
788
+ const cx = ax + t * dx;
789
+ const cy = ay + t * dy;
790
+ return Math.hypot(px - cx, py - cy);
791
+ }
792
+ function pointInPolygon(point, vertices, fillRule = "nonzero") {
793
+ if (vertices.length < 6) {
794
+ return false;
795
+ }
796
+ if (fillRule === "evenodd") {
797
+ return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
798
+ }
799
+ return windingNumber(point.x, point.y, vertices) !== 0;
800
+ }
801
+ function pointInPolygons(point, polygons, fillRule = "nonzero") {
802
+ const { x, y } = point;
803
+ if (fillRule === "evenodd") {
804
+ let cn = 0;
805
+ for (let i = 0, len = polygons.length; i < len; i++) {
806
+ const ring = polygons[i];
807
+ if (ring.length >= 6) {
808
+ cn += crossingNumber(x, y, ring);
809
+ }
810
+ }
811
+ return (cn & 1) === 1;
812
+ }
813
+ let wn = 0;
814
+ for (let i = 0, len = polygons.length; i < len; i++) {
815
+ const ring = polygons[i];
816
+ if (ring.length >= 6) {
817
+ wn += windingNumber(x, y, ring);
818
+ }
819
+ }
820
+ return wn !== 0;
821
+ }
822
+ function pointToSegmentDistance(point, a, b) {
823
+ return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
824
+ }
825
+ function pointToPolylineDistance(point, vertices, closed = false) {
826
+ const len = vertices.length;
827
+ if (len < 2) {
828
+ return Infinity;
829
+ }
830
+ const { x: px, y: py } = point;
831
+ if (len === 2) {
832
+ return Math.hypot(px - vertices[0], py - vertices[1]);
833
+ }
834
+ let min = Infinity;
835
+ for (let i = 0; i < len - 2; i += 2) {
836
+ const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
837
+ if (d < min) {
838
+ min = d;
839
+ }
840
+ }
841
+ if (closed && len >= 6) {
842
+ const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
843
+ if (d < min) {
844
+ min = d;
845
+ }
846
+ }
847
+ return min;
848
+ }
849
+
723
850
  function quadraticBezierP0(t, p) {
724
851
  const k = 1 - t;
725
852
  return k * k * p;
@@ -2533,6 +2660,41 @@ function svgToPath2DSet(svg) {
2533
2660
  class Curve {
2534
2661
  arcLengthDivision = 200;
2535
2662
  _lengths = [];
2663
+ _adaptiveCache;
2664
+ /**
2665
+ * Parent composite, set lazily when a composite caches its children. Lets
2666
+ * {@link invalidate} propagate up so an ancestor's caches refresh too.
2667
+ */
2668
+ _owner;
2669
+ _invalidating = false;
2670
+ /**
2671
+ * Drop cached arc lengths and the cached sampled outline used by hit testing, then
2672
+ * bubble up to {@link _owner}. Called automatically by {@link applyTransform} and the
2673
+ * `Path2D` mutators; call it manually after mutating control-point coordinates in place —
2674
+ * the caches cannot observe such mutations.
2675
+ */
2676
+ invalidate() {
2677
+ if (this._invalidating) {
2678
+ return this;
2679
+ }
2680
+ this._invalidating = true;
2681
+ this._invalidateSelf();
2682
+ this._owner?.invalidate();
2683
+ this._invalidating = false;
2684
+ return this;
2685
+ }
2686
+ /** Clears this curve's own caches. Composites also clear their children (see override). */
2687
+ _invalidateSelf() {
2688
+ this._lengths.length = 0;
2689
+ this._adaptiveCache = void 0;
2690
+ }
2691
+ /**
2692
+ * Sampled outline cached for repeated hit tests (read-only — do not mutate the result).
2693
+ * Invalidated by {@link invalidate}.
2694
+ */
2695
+ _getCachedAdaptiveVertices() {
2696
+ return this._adaptiveCache ??= this.getAdaptiveVertices();
2697
+ }
2536
2698
  getPointAt(u, output = new Vector2()) {
2537
2699
  return this.getPoint(this.getUToTMapping(u), output);
2538
2700
  }
@@ -2551,6 +2713,7 @@ class Curve {
2551
2713
  transform.apply(p, p);
2552
2714
  }
2553
2715
  });
2716
+ this.invalidate();
2554
2717
  return this;
2555
2718
  }
2556
2719
  getUnevenVertices(count = 5, output = []) {
@@ -2695,18 +2858,65 @@ class Curve {
2695
2858
  return mid;
2696
2859
  }
2697
2860
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
2698
- const potins = this.getPoints();
2699
- for (let i = 0, len = potins.length; i < len; i++) {
2700
- const p = potins[i];
2701
- min.clampMin(p);
2702
- max.clampMax(p);
2861
+ const vertices = this.getAdaptiveVertices();
2862
+ let minX = min.x;
2863
+ let minY = min.y;
2864
+ let maxX = max.x;
2865
+ let maxY = max.y;
2866
+ for (let i = 0, len = vertices.length; i < len; i += 2) {
2867
+ const x = vertices[i];
2868
+ const y = vertices[i + 1];
2869
+ if (x < minX)
2870
+ minX = x;
2871
+ if (y < minY)
2872
+ minY = y;
2873
+ if (x > maxX)
2874
+ maxX = x;
2875
+ if (y > maxY)
2876
+ maxY = y;
2703
2877
  }
2878
+ min.set(minX, minY);
2879
+ max.set(maxX, maxY);
2704
2880
  return { min: min.finite(), max: max.finite() };
2705
2881
  }
2706
2882
  getBoundingBox() {
2707
2883
  const { min, max } = this.getMinMax();
2708
2884
  return new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
2709
2885
  }
2886
+ /**
2887
+ * Test whether a point lies inside the area enclosed by this curve.
2888
+ *
2889
+ * The curve is sampled via {@link getAdaptiveVertices} into a single implicitly closed
2890
+ * ring. This is purely geometric (it ignores any `fill`/`stroke` style), mirroring
2891
+ * `CanvasRenderingContext2D.isPointInPath`.
2892
+ *
2893
+ * Composites that hold multiple sub-paths (e.g. {@link Path2D}) override this so holes
2894
+ * are honored — a single `Curve` is always one ring.
2895
+ */
2896
+ isPointInFill(point, options = {}) {
2897
+ return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
2898
+ }
2899
+ /**
2900
+ * Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
2901
+ * of the sampled outline. The point must be in the same coordinate space as the curve.
2902
+ *
2903
+ * Options: `strokeWidth` (path units, default `1`), `tolerance` (extra hit slack in path
2904
+ * units, default `0` — useful for thin strokes; no coordinate scaling is assumed, so convert
2905
+ * pixel tolerance to path units upstream if your path is normalized), and `closed` (whether
2906
+ * to include the closing edge from the last vertex back to the first).
2907
+ */
2908
+ isPointInStroke(point, options = {}) {
2909
+ const { strokeWidth = 1, tolerance = 0, closed = false } = options;
2910
+ const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
2911
+ return distance <= strokeWidth / 2 + tolerance;
2912
+ }
2913
+ /**
2914
+ * Concise PathKit-style fill containment test: `contains(x, y)` is shorthand for
2915
+ * {@link isPointInFill} with a `{ x, y }` point.
2916
+ */
2917
+ contains(x, y, options = {}) {
2918
+ return this.isPointInFill({ x, y }, options);
2919
+ }
2710
2920
  getFillVertices(_options) {
2711
2921
  return this.getAdaptiveVertices();
2712
2922
  }
@@ -2841,6 +3051,64 @@ class RoundCurve extends Curve {
2841
3051
  }
2842
3052
  return output.set(_x, _y);
2843
3053
  }
3054
+ /**
3055
+ * Point on the ellipse at an absolute angle (mirrors {@link getPoint}'s parameterization,
3056
+ * ignoring `_diff`).
3057
+ */
3058
+ _pointAtAngle(angle, output) {
3059
+ let x = this.cx + this.rx * Math.cos(angle);
3060
+ let y = this.cy + this.ry * Math.sin(angle);
3061
+ if (this.rotate !== 0) {
3062
+ const cos = Math.cos(this.rotate);
3063
+ const sin = Math.sin(this.rotate);
3064
+ const tx = x - this.cx;
3065
+ const ty = y - this.cy;
3066
+ x = tx * cos - ty * sin + this.cx;
3067
+ y = tx * sin + ty * cos + this.cy;
3068
+ }
3069
+ return output.set(x, y);
3070
+ }
3071
+ /**
3072
+ * Analytical bounds of the (elliptical) arc: the start/end points plus the per-axis
3073
+ * extrema angles that fall within the swept interval. Matches {@link getPoint}, so it is
3074
+ * exact for `ArcCurve`/`EllipseCurve`. The `_diff` offset (used only by the legacy
3075
+ * `_getAdaptiveVerticesByCircle` path) is intentionally ignored here.
3076
+ */
3077
+ getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
3078
+ const { startAngle, rotate } = this;
3079
+ const delta = this._getDeltaAngle();
3080
+ const cosT = Math.cos(rotate);
3081
+ const sinT = Math.sin(rotate);
3082
+ const p = tempV2;
3083
+ let minX = min.x;
3084
+ let minY = min.y;
3085
+ let maxX = max.x;
3086
+ let maxY = max.y;
3087
+ const consider = (angle) => {
3088
+ this._pointAtAngle(angle, p);
3089
+ if (p.x < minX)
3090
+ minX = p.x;
3091
+ if (p.y < minY)
3092
+ minY = p.y;
3093
+ if (p.x > maxX)
3094
+ maxX = p.x;
3095
+ if (p.y > maxY)
3096
+ maxY = p.y;
3097
+ };
3098
+ consider(startAngle);
3099
+ consider(startAngle + delta);
3100
+ const ax = Math.atan2(-this.ry * sinT, this.rx * cosT);
3101
+ const ay = Math.atan2(this.ry * cosT, this.rx * sinT);
3102
+ const bases = [ax, ax + Math.PI, ay, ay + Math.PI];
3103
+ for (let i = 0; i < 4; i++) {
3104
+ if (angleInSweep(bases[i], startAngle, delta)) {
3105
+ consider(bases[i]);
3106
+ }
3107
+ }
3108
+ min.set(minX, minY);
3109
+ max.set(maxX, maxY);
3110
+ return { min: min.finite(), max: max.finite() };
3111
+ }
2844
3112
  toCommands() {
2845
3113
  const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
2846
3114
  const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
@@ -2891,6 +3159,7 @@ class RoundCurve extends Curve {
2891
3159
  } else {
2892
3160
  transfEllipseNoSkew(this, transform);
2893
3161
  }
3162
+ this.invalidate();
2894
3163
  return this;
2895
3164
  }
2896
3165
  getControlPointRefs() {
@@ -3031,6 +3300,23 @@ class RoundCurve extends Curve {
3031
3300
  return this;
3032
3301
  }
3033
3302
  }
3303
+ function angleInSweep(a, start, delta) {
3304
+ const PI_2 = Math.PI * 2;
3305
+ const eps = 1e-9;
3306
+ if (Math.abs(delta) >= PI_2 - eps) {
3307
+ return true;
3308
+ }
3309
+ let off = (a - start) % PI_2;
3310
+ if (delta >= 0) {
3311
+ if (off < -eps)
3312
+ off += PI_2;
3313
+ return off >= -eps && off <= delta + eps;
3314
+ }
3315
+ if (off > eps) {
3316
+ off -= PI_2;
3317
+ }
3318
+ return off <= eps && off >= delta - eps;
3319
+ }
3034
3320
  function transfEllipseGeneric(curve, m) {
3035
3321
  const a = curve.rx;
3036
3322
  const b = curve.ry;
@@ -3269,6 +3555,22 @@ class CompositeCurve extends Curve {
3269
3555
  super();
3270
3556
  this.curves = curves;
3271
3557
  }
3558
+ _adaptiveCacheLen = -1;
3559
+ _invalidateSelf() {
3560
+ super._invalidateSelf();
3561
+ this._adaptiveCacheLen = -1;
3562
+ this.curves.forEach((curve) => curve.invalidate());
3563
+ }
3564
+ _getCachedAdaptiveVertices() {
3565
+ if (!this._adaptiveCache || this._adaptiveCacheLen !== this.curves.length) {
3566
+ this.curves.forEach((curve) => {
3567
+ curve._owner = this;
3568
+ });
3569
+ this._adaptiveCache = this.getAdaptiveVertices();
3570
+ this._adaptiveCacheLen = this.curves.length;
3571
+ }
3572
+ return this._adaptiveCache;
3573
+ }
3272
3574
  getFlatCurves() {
3273
3575
  return this.curves.flatMap((curve) => {
3274
3576
  if (curve instanceof CompositeCurve) {
@@ -3314,6 +3616,7 @@ class CompositeCurve extends Curve {
3314
3616
  updateLengths() {
3315
3617
  const lengths = [];
3316
3618
  for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
3619
+ this.curves[i]._owner = this;
3317
3620
  sum += this.curves[i].getLength();
3318
3621
  lengths.push(sum);
3319
3622
  }
@@ -3382,7 +3685,10 @@ class CompositeCurve extends Curve {
3382
3685
  }
3383
3686
  }
3384
3687
  applyTransform(transform) {
3688
+ this._invalidating = true;
3385
3689
  this.curves.forEach((curve) => curve.applyTransform(transform));
3690
+ this._invalidating = false;
3691
+ this.invalidate();
3386
3692
  return this;
3387
3693
  }
3388
3694
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
@@ -3721,7 +4027,7 @@ class RectangleCurve extends PolygonCurve {
3721
4027
  }
3722
4028
  }
3723
4029
 
3724
- class RoundRectangleCurve extends RoundCurve {
4030
+ class RoundRectangleCurve extends CompositeCurve {
3725
4031
  constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
3726
4032
  super();
3727
4033
  this.x = x;
@@ -3732,25 +4038,53 @@ class RoundRectangleCurve extends RoundCurve {
3732
4038
  this.update();
3733
4039
  }
3734
4040
  update() {
3735
- const { x, y, width, height, radius } = this;
3736
- const halfWidth = width / 2;
3737
- const halfHeight = height / 2;
3738
- const cx = x + halfWidth;
3739
- const cy = y + halfHeight;
3740
- const rx = Math.max(0, Math.min(radius, Math.min(halfWidth, halfHeight)));
3741
- const ry = rx;
3742
- this._center = new Vector2(cx, cy);
3743
- this._radius = new Vector2(rx, ry);
3744
- this._diff = new Vector2(halfWidth - rx, halfHeight - ry);
4041
+ const { x, y, width, height } = this;
4042
+ const r = Math.max(0, Math.min(this.radius, Math.abs(width) / 2, Math.abs(height) / 2));
4043
+ const x0 = x;
4044
+ const x1 = x + r;
4045
+ const x2 = x + width - r;
4046
+ const x3 = x + width;
4047
+ const y0 = y;
4048
+ const y1 = y + r;
4049
+ const y2 = y + height - r;
4050
+ const y3 = y + height;
4051
+ if (r <= 0) {
4052
+ this.curves = [
4053
+ LineCurve.from(x0, y0, x3, y0),
4054
+ LineCurve.from(x3, y0, x3, y3),
4055
+ LineCurve.from(x3, y3, x0, y3),
4056
+ LineCurve.from(x0, y3, x0, y0)
4057
+ ];
4058
+ } else {
4059
+ const HALF_PI = Math.PI / 2;
4060
+ this.curves = [
4061
+ LineCurve.from(x1, y0, x2, y0),
4062
+ // top edge
4063
+ new ArcCurve(x2, y1, r, -HALF_PI, 0, true),
4064
+ // top-right corner
4065
+ LineCurve.from(x3, y1, x3, y2),
4066
+ // right edge
4067
+ new ArcCurve(x2, y2, r, 0, HALF_PI, true),
4068
+ // bottom-right corner
4069
+ LineCurve.from(x2, y3, x1, y3),
4070
+ // bottom edge
4071
+ new ArcCurve(x1, y2, r, HALF_PI, Math.PI, true),
4072
+ // bottom-left corner
4073
+ LineCurve.from(x0, y2, x0, y1),
4074
+ // left edge
4075
+ new ArcCurve(x1, y1, r, Math.PI, Math.PI * 1.5, true)
4076
+ // top-left corner
4077
+ ];
4078
+ }
4079
+ this.invalidate();
3745
4080
  return this;
3746
4081
  }
3747
4082
  drawTo(ctx) {
3748
- const { x, y, width, height, radius } = this;
3749
- ctx.roundRect(x, y, width, height, radius);
4083
+ ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
3750
4084
  return this;
3751
4085
  }
3752
4086
  copyFrom(source) {
3753
- super.copyFrom(source);
4087
+ this.arcLengthDivision = source.arcLengthDivision;
3754
4088
  this.x = source.x;
3755
4089
  this.y = source.y;
3756
4090
  this.width = source.width;
@@ -3846,6 +4180,17 @@ class CurvePath extends CompositeCurve {
3846
4180
  super.getFillVertices(options)
3847
4181
  );
3848
4182
  }
4183
+ /**
4184
+ * Same as {@link Curve.isPointInStroke}, but `closed` defaults to this sub-path's actual
4185
+ * closed-ness: explicitly `autoClose`, or geometrically closed (first vertex === last).
4186
+ */
4187
+ isPointInStroke(point, options = {}) {
4188
+ const { strokeWidth = 1, tolerance = 0 } = options;
4189
+ const vertices = this._getCachedAdaptiveVertices();
4190
+ const len = vertices.length;
4191
+ const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
4192
+ return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
4193
+ }
3849
4194
  _setCurrentPoint(point) {
3850
4195
  this.currentPoint = new Vector2(point.x, point.y);
3851
4196
  if (!this.startPoint) {
@@ -4019,6 +4364,8 @@ class CurvePath extends CompositeCurve {
4019
4364
 
4020
4365
  class Path2D extends CompositeCurve {
4021
4366
  _meta;
4367
+ _ringsCache;
4368
+ _ringsCacheLen = -1;
4022
4369
  currentCurve = new CurvePath();
4023
4370
  style;
4024
4371
  get startPoint() {
@@ -4137,18 +4484,21 @@ class Path2D extends CompositeCurve {
4137
4484
  this.getControlPointRefs().forEach((point) => {
4138
4485
  point.scale(sx, sy, target);
4139
4486
  });
4487
+ this.invalidate();
4140
4488
  return this;
4141
4489
  }
4142
4490
  skew(ax, ay = 0, target = { x: 0, y: 0 }) {
4143
4491
  this.getControlPointRefs().forEach((point) => {
4144
4492
  point.skew(ax, ay, target);
4145
4493
  });
4494
+ this.invalidate();
4146
4495
  return this;
4147
4496
  }
4148
4497
  rotate(rad, target = { x: 0, y: 0 }) {
4149
4498
  this.getControlPointRefs().forEach((point) => {
4150
4499
  point.rotate(rad, target);
4151
4500
  });
4501
+ this.invalidate();
4152
4502
  return this;
4153
4503
  }
4154
4504
  bold(b) {
@@ -4207,38 +4557,67 @@ class Path2D extends CompositeCurve {
4207
4557
  }
4208
4558
  });
4209
4559
  });
4560
+ this.invalidate();
4210
4561
  return this;
4211
4562
  }
4563
+ /**
4564
+ * Test whether a point lies inside the filled area of this path.
4565
+ *
4566
+ * Each sub-path ({@link CurvePath}) is sampled into its own ring and all rings are
4567
+ * evaluated together via {@link pointInPolygons}, so holes (donut / hollow shapes) are
4568
+ * honored. This is purely geometric and ignores `style.fill` — for the `fill: 'none'`
4569
+ * fallback, gate the call upstream (see {@link Path2DSet.hitTest}).
4570
+ *
4571
+ * Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
4572
+ */
4573
+ _invalidateSelf() {
4574
+ super._invalidateSelf();
4575
+ this._ringsCache = void 0;
4576
+ this._ringsCacheLen = -1;
4577
+ }
4578
+ /** Per-sub-path sampled rings, cached for repeated hit tests. */
4579
+ _getRings() {
4580
+ if (!this._ringsCache || this._ringsCacheLen !== this.curves.length) {
4581
+ this._ringsCache = this.curves.map((curve) => {
4582
+ curve._owner = this;
4583
+ return curve.getAdaptiveVertices();
4584
+ });
4585
+ this._ringsCacheLen = this.curves.length;
4586
+ }
4587
+ return this._ringsCache;
4588
+ }
4589
+ isPointInFill(point, options = {}) {
4590
+ const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4591
+ return pointInPolygons(point, this._getRings(), fillRule);
4592
+ }
4593
+ /**
4594
+ * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4595
+ *
4596
+ * Defaults `strokeWidth` to this path's own {@link strokeWidth} (which is `0` when
4597
+ * `style.stroke` is `'none'`). Each sub-path infers its own closed-ness unless `closed`
4598
+ * is given explicitly.
4599
+ */
4600
+ isPointInStroke(point, options = {}) {
4601
+ const strokeWidth = options.strokeWidth ?? this.strokeWidth;
4602
+ const { tolerance = 0, closed } = options;
4603
+ return this.curves.some((curve) => curve.isPointInStroke(point, {
4604
+ strokeWidth,
4605
+ tolerance,
4606
+ closed
4607
+ }));
4608
+ }
4212
4609
  getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
4213
- const strokeWidth = this.strokeWidth;
4214
4610
  this.curves.forEach((curve) => {
4215
4611
  curve.getMinMax(min, max);
4216
- if (withStyle) {
4217
- if (strokeWidth > 1) {
4218
- const halfStrokeWidth = strokeWidth / 2;
4219
- const isClockwise = curve.isClockwise();
4220
- const points = [];
4221
- for (let t = 0; t <= 1; t += 1 / curve.arcLengthDivision) {
4222
- const point = curve.getPoint(t);
4223
- const normal = curve.getNormal(t);
4224
- const dist1 = normal.clone().scale(isClockwise ? halfStrokeWidth : -halfStrokeWidth);
4225
- const dist2 = normal.clone().scale(isClockwise ? -halfStrokeWidth : halfStrokeWidth);
4226
- points.push(
4227
- point.clone().add(dist1),
4228
- point.clone().add(dist2),
4229
- point.clone().add({ x: halfStrokeWidth, y: 0 }),
4230
- point.clone().add({ x: -halfStrokeWidth, y: 0 }),
4231
- point.clone().add({ x: 0, y: halfStrokeWidth }),
4232
- point.clone().add({ x: 0, y: -halfStrokeWidth }),
4233
- point.clone().add({ x: halfStrokeWidth, y: halfStrokeWidth }),
4234
- point.clone().add({ x: -halfStrokeWidth, y: -halfStrokeWidth })
4235
- );
4236
- }
4237
- min.clampMin(...points);
4238
- max.clampMax(...points);
4239
- }
4240
- }
4241
4612
  });
4613
+ if (withStyle) {
4614
+ const strokeWidth = this.strokeWidth;
4615
+ if (strokeWidth > 1 && Number.isFinite(min.x)) {
4616
+ const half = strokeWidth / 2;
4617
+ min.set(min.x - half, min.y - half);
4618
+ max.set(max.x + half, max.y + half);
4619
+ }
4620
+ }
4242
4621
  return { min: min.finite(), max: max.finite() };
4243
4622
  }
4244
4623
  strokeTriangulate(options) {
@@ -4386,6 +4765,44 @@ class Path2DSet {
4386
4765
  this.paths = paths;
4387
4766
  this.viewBox = viewBox;
4388
4767
  }
4768
+ /**
4769
+ * Test whether a point lies inside the filled area of any path in this set.
4770
+ * Purely geometric (ignores `fill: 'none'`); use {@link hitTest} for style-aware hits.
4771
+ */
4772
+ isPointInFill(point, options = {}) {
4773
+ return this.paths.some((path) => path.isPointInFill(point, options));
4774
+ }
4775
+ /**
4776
+ * Concise PathKit-style fill containment test across the whole set; shorthand for
4777
+ * {@link isPointInFill} with a `{ x, y }` point.
4778
+ */
4779
+ contains(x, y, options = {}) {
4780
+ return this.isPointInFill({ x, y }, options);
4781
+ }
4782
+ /**
4783
+ * Find the topmost path hit by a point, or `undefined` if none.
4784
+ *
4785
+ * Paths are tested top-to-bottom (last drawn first). For each path a fill hit is checked
4786
+ * first (skipped when `style.fill` is `'none'`), then — if `stroke` is enabled — a stroke
4787
+ * hit (skipped when `style.stroke` is `'none'`). This honors the "fill: none falls back to
4788
+ * stroke" rule; the coordinate space of `point` must match the paths (no scaling assumed).
4789
+ *
4790
+ * Options: `stroke` (also test strokes, default `true`), `tolerance` (extra stroke hit slack
4791
+ * in path units, default `0`), and `fillRule` (overrides each path's own fill rule).
4792
+ */
4793
+ hitTest(point, options = {}) {
4794
+ const { stroke = true, tolerance, fillRule } = options;
4795
+ for (let i = this.paths.length - 1; i >= 0; i--) {
4796
+ const path = this.paths[i];
4797
+ if ((path.style.fill ?? "#000") !== "none" && path.isPointInFill(point, { fillRule })) {
4798
+ return path;
4799
+ }
4800
+ if (stroke && (path.style.stroke ?? "none") !== "none" && path.isPointInStroke(point, { tolerance })) {
4801
+ return path;
4802
+ }
4803
+ }
4804
+ return void 0;
4805
+ }
4389
4806
  getBoundingBox(withStyle = true) {
4390
4807
  if (!this.paths.length) {
4391
4808
  return void 0;
@@ -4541,4 +4958,4 @@ function applyFFD(point, grid, width = grid.width, height = grid.height) {
4541
4958
  point.set(x, y);
4542
4959
  }
4543
4960
 
4544
- export { ArcCurve, BoundingBox, CompositeCurve, CubicBezierCurve, Curve, CurvePath, EllipseCurve, EquilateralPolygonCurve, FFDControlGrid, LineCurve, PI, PI_2, Path2D, Path2DSet, PolygonCurve, QuadraticBezierCurve, RectangleCurve, RoundRectangleCurve, SplineCurve, Transform2D, Vector2, applyFFD, catmullRom, cubicBezier, drawPoint, fillTriangulate, getAdaptiveCubicBezierCurvePoints, getAdaptiveQuadraticBezierCurvePoints, getDirectedArea, getIntersectionPoint, nonzeroFillRule, parseArcCommand, parseCssArg, parseCssArgs, parseCssFunctions, parsePathDataArgs, quadraticBezier, setCanvasContext, strokeTriangulate, svgPathCommandsAddToPath2D, svgPathCommandsToData, svgPathDataToCommands, svgToDom, svgToPath2DSet, toKebabCase };
4961
+ export { ArcCurve, BoundingBox, CompositeCurve, CubicBezierCurve, Curve, CurvePath, EllipseCurve, EquilateralPolygonCurve, FFDControlGrid, LineCurve, PI, PI_2, Path2D, Path2DSet, PolygonCurve, QuadraticBezierCurve, RectangleCurve, RoundRectangleCurve, SplineCurve, Transform2D, Vector2, applyFFD, catmullRom, cubicBezier, drawPoint, fillTriangulate, getAdaptiveCubicBezierCurvePoints, getAdaptiveQuadraticBezierCurvePoints, getDirectedArea, getIntersectionPoint, nonzeroFillRule, parseArcCommand, parseCssArg, parseCssArgs, parseCssFunctions, parsePathDataArgs, pointInPolygon, pointInPolygons, pointToPolylineDistance, pointToSegmentDistance, quadraticBezier, setCanvasContext, strokeTriangulate, svgPathCommandsAddToPath2D, svgPathCommandsToData, svgPathDataToCommands, svgToDom, svgToPath2DSet, toKebabCase };