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.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(this._x / x, this._y / y);
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 new Vector2(
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 new Vector2(
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,
@@ -598,7 +595,7 @@ function getDirectedArea(vertices) {
598
595
  function cross(ax, ay, bx, by, cx, cy) {
599
596
  return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
600
597
  }
601
- function windingNumber(px, py, polygon) {
598
+ function windingNumber$1(px, py, polygon) {
602
599
  const polygonLen = polygon.length;
603
600
  let wn = 0;
604
601
  for (let i = 0, j = polygonLen - 2; i < polygonLen; j = i, i += 2) {
@@ -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 testPointsGroups = paths.map((path) => {
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,14 +704,19 @@ 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++) {
705
718
  const [x, y] = testPoints[p];
706
- const winding = windingNumber(x, y, paths[j]);
719
+ const winding = windingNumber$1(x, y, paths[j]);
707
720
  wnMap[winding] = (wnMap[winding] ?? 0) + 1;
708
721
  wnList.push(winding);
709
722
  }
@@ -726,6 +739,120 @@ function nonzeroFillRule(paths) {
726
739
  return results;
727
740
  }
728
741
 
742
+ function isLeft(ax, ay, bx, by, px, py) {
743
+ return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
744
+ }
745
+ function windingNumber(px, py, vertices) {
746
+ const len = vertices.length;
747
+ let wn = 0;
748
+ for (let i = 0; i < len; i += 2) {
749
+ const ax = vertices[i];
750
+ const ay = vertices[i + 1];
751
+ const k = (i + 2) % len;
752
+ const bx = vertices[k];
753
+ const by = vertices[k + 1];
754
+ if (ay <= py) {
755
+ if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
756
+ wn++;
757
+ }
758
+ } else {
759
+ if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
760
+ wn--;
761
+ }
762
+ }
763
+ }
764
+ return wn;
765
+ }
766
+ function crossingNumber(px, py, vertices) {
767
+ const len = vertices.length;
768
+ let cn = 0;
769
+ for (let i = 0; i < len; i += 2) {
770
+ const ax = vertices[i];
771
+ const ay = vertices[i + 1];
772
+ const k = (i + 2) % len;
773
+ const bx = vertices[k];
774
+ const by = vertices[k + 1];
775
+ if (ay <= py && by > py || ay > py && by <= py) {
776
+ const t = (py - ay) / (by - ay);
777
+ if (px < ax + t * (bx - ax)) {
778
+ cn++;
779
+ }
780
+ }
781
+ }
782
+ return cn;
783
+ }
784
+ function segmentDistance(px, py, ax, ay, bx, by) {
785
+ const dx = bx - ax;
786
+ const dy = by - ay;
787
+ const lenSq = dx * dx + dy * dy;
788
+ let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
789
+ if (t < 0) {
790
+ t = 0;
791
+ } else if (t > 1) {
792
+ t = 1;
793
+ }
794
+ const cx = ax + t * dx;
795
+ const cy = ay + t * dy;
796
+ return Math.hypot(px - cx, py - cy);
797
+ }
798
+ function pointInPolygon(point, vertices, fillRule = "nonzero") {
799
+ if (vertices.length < 6) {
800
+ return false;
801
+ }
802
+ if (fillRule === "evenodd") {
803
+ return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
804
+ }
805
+ return windingNumber(point.x, point.y, vertices) !== 0;
806
+ }
807
+ function pointInPolygons(point, polygons, fillRule = "nonzero") {
808
+ const { x, y } = point;
809
+ if (fillRule === "evenodd") {
810
+ let cn = 0;
811
+ for (let i = 0, len = polygons.length; i < len; i++) {
812
+ const ring = polygons[i];
813
+ if (ring.length >= 6) {
814
+ cn += crossingNumber(x, y, ring);
815
+ }
816
+ }
817
+ return (cn & 1) === 1;
818
+ }
819
+ let wn = 0;
820
+ for (let i = 0, len = polygons.length; i < len; i++) {
821
+ const ring = polygons[i];
822
+ if (ring.length >= 6) {
823
+ wn += windingNumber(x, y, ring);
824
+ }
825
+ }
826
+ return wn !== 0;
827
+ }
828
+ function pointToSegmentDistance(point, a, b) {
829
+ return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
830
+ }
831
+ function pointToPolylineDistance(point, vertices, closed = false) {
832
+ const len = vertices.length;
833
+ if (len < 2) {
834
+ return Infinity;
835
+ }
836
+ const { x: px, y: py } = point;
837
+ if (len === 2) {
838
+ return Math.hypot(px - vertices[0], py - vertices[1]);
839
+ }
840
+ let min = Infinity;
841
+ for (let i = 0; i < len - 2; i += 2) {
842
+ const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
843
+ if (d < min) {
844
+ min = d;
845
+ }
846
+ }
847
+ if (closed && len >= 6) {
848
+ const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
849
+ if (d < min) {
850
+ min = d;
851
+ }
852
+ }
853
+ return min;
854
+ }
855
+
729
856
  function quadraticBezierP0(t, p) {
730
857
  const k = 1 - t;
731
858
  return k * k * p;
@@ -2539,6 +2666,41 @@ function svgToPath2DSet(svg) {
2539
2666
  class Curve {
2540
2667
  arcLengthDivision = 200;
2541
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
+ }
2542
2704
  getPointAt(u, output = new Vector2()) {
2543
2705
  return this.getPoint(this.getUToTMapping(u), output);
2544
2706
  }
@@ -2557,6 +2719,7 @@ class Curve {
2557
2719
  transform.apply(p, p);
2558
2720
  }
2559
2721
  });
2722
+ this.invalidate();
2560
2723
  return this;
2561
2724
  }
2562
2725
  getUnevenVertices(count = 5, output = []) {
@@ -2701,18 +2864,65 @@ class Curve {
2701
2864
  return mid;
2702
2865
  }
2703
2866
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
2704
- const potins = this.getPoints();
2705
- for (let i = 0, len = potins.length; i < len; i++) {
2706
- const p = potins[i];
2707
- min.clampMin(p);
2708
- max.clampMax(p);
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;
2709
2883
  }
2884
+ min.set(minX, minY);
2885
+ max.set(maxX, maxY);
2710
2886
  return { min: min.finite(), max: max.finite() };
2711
2887
  }
2712
2888
  getBoundingBox() {
2713
2889
  const { min, max } = this.getMinMax();
2714
2890
  return new BoundingBox(min.x, min.y, max.x - min.x, max.y - min.y);
2715
2891
  }
2892
+ /**
2893
+ * Test whether a point lies inside the area enclosed by this curve.
2894
+ *
2895
+ * The curve is sampled via {@link getAdaptiveVertices} into a single implicitly closed
2896
+ * ring. This is purely geometric (it ignores any `fill`/`stroke` style), mirroring
2897
+ * `CanvasRenderingContext2D.isPointInPath`.
2898
+ *
2899
+ * Composites that hold multiple sub-paths (e.g. {@link Path2D}) override this so holes
2900
+ * are honored — a single `Curve` is always one ring.
2901
+ */
2902
+ isPointInFill(point, options = {}) {
2903
+ return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
2904
+ }
2905
+ /**
2906
+ * Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
2907
+ * of the sampled outline. The point must be in the same coordinate space as the curve.
2908
+ *
2909
+ * Options: `strokeWidth` (path units, default `1`), `tolerance` (extra hit slack in path
2910
+ * units, default `0` — useful for thin strokes; no coordinate scaling is assumed, so convert
2911
+ * pixel tolerance to path units upstream if your path is normalized), and `closed` (whether
2912
+ * to include the closing edge from the last vertex back to the first).
2913
+ */
2914
+ isPointInStroke(point, options = {}) {
2915
+ const { strokeWidth = 1, tolerance = 0, closed = false } = options;
2916
+ const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
2917
+ return distance <= strokeWidth / 2 + tolerance;
2918
+ }
2919
+ /**
2920
+ * Concise PathKit-style fill containment test: `contains(x, y)` is shorthand for
2921
+ * {@link isPointInFill} with a `{ x, y }` point.
2922
+ */
2923
+ contains(x, y, options = {}) {
2924
+ return this.isPointInFill({ x, y }, options);
2925
+ }
2716
2926
  getFillVertices(_options) {
2717
2927
  return this.getAdaptiveVertices();
2718
2928
  }
@@ -2847,6 +3057,64 @@ class RoundCurve extends Curve {
2847
3057
  }
2848
3058
  return output.set(_x, _y);
2849
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
+ }
2850
3118
  toCommands() {
2851
3119
  const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
2852
3120
  const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
@@ -2897,6 +3165,7 @@ class RoundCurve extends Curve {
2897
3165
  } else {
2898
3166
  transfEllipseNoSkew(this, transform);
2899
3167
  }
3168
+ this.invalidate();
2900
3169
  return this;
2901
3170
  }
2902
3171
  getControlPointRefs() {
@@ -3037,6 +3306,23 @@ class RoundCurve extends Curve {
3037
3306
  return this;
3038
3307
  }
3039
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
+ }
3040
3326
  function transfEllipseGeneric(curve, m) {
3041
3327
  const a = curve.rx;
3042
3328
  const b = curve.ry;
@@ -3275,6 +3561,22 @@ class CompositeCurve extends Curve {
3275
3561
  super();
3276
3562
  this.curves = curves;
3277
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
+ }
3278
3580
  getFlatCurves() {
3279
3581
  return this.curves.flatMap((curve) => {
3280
3582
  if (curve instanceof CompositeCurve) {
@@ -3320,6 +3622,7 @@ class CompositeCurve extends Curve {
3320
3622
  updateLengths() {
3321
3623
  const lengths = [];
3322
3624
  for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
3625
+ this.curves[i]._owner = this;
3323
3626
  sum += this.curves[i].getLength();
3324
3627
  lengths.push(sum);
3325
3628
  }
@@ -3388,7 +3691,10 @@ class CompositeCurve extends Curve {
3388
3691
  }
3389
3692
  }
3390
3693
  applyTransform(transform) {
3694
+ this._invalidating = true;
3391
3695
  this.curves.forEach((curve) => curve.applyTransform(transform));
3696
+ this._invalidating = false;
3697
+ this.invalidate();
3392
3698
  return this;
3393
3699
  }
3394
3700
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
@@ -3727,7 +4033,7 @@ class RectangleCurve extends PolygonCurve {
3727
4033
  }
3728
4034
  }
3729
4035
 
3730
- class RoundRectangleCurve extends RoundCurve {
4036
+ class RoundRectangleCurve extends CompositeCurve {
3731
4037
  constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
3732
4038
  super();
3733
4039
  this.x = x;
@@ -3738,25 +4044,53 @@ class RoundRectangleCurve extends RoundCurve {
3738
4044
  this.update();
3739
4045
  }
3740
4046
  update() {
3741
- const { x, y, width, height, radius } = this;
3742
- const halfWidth = width / 2;
3743
- const halfHeight = height / 2;
3744
- const cx = x + halfWidth;
3745
- const cy = y + halfHeight;
3746
- const rx = Math.max(0, Math.min(radius, Math.min(halfWidth, halfHeight)));
3747
- const ry = rx;
3748
- this._center = new Vector2(cx, cy);
3749
- this._radius = new Vector2(rx, ry);
3750
- this._diff = new Vector2(halfWidth - rx, halfHeight - ry);
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();
3751
4086
  return this;
3752
4087
  }
3753
4088
  drawTo(ctx) {
3754
- const { x, y, width, height, radius } = this;
3755
- ctx.roundRect(x, y, width, height, radius);
4089
+ ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
3756
4090
  return this;
3757
4091
  }
3758
4092
  copyFrom(source) {
3759
- super.copyFrom(source);
4093
+ this.arcLengthDivision = source.arcLengthDivision;
3760
4094
  this.x = source.x;
3761
4095
  this.y = source.y;
3762
4096
  this.width = source.width;
@@ -3852,6 +4186,17 @@ class CurvePath extends CompositeCurve {
3852
4186
  super.getFillVertices(options)
3853
4187
  );
3854
4188
  }
4189
+ /**
4190
+ * Same as {@link Curve.isPointInStroke}, but `closed` defaults to this sub-path's actual
4191
+ * closed-ness: explicitly `autoClose`, or geometrically closed (first vertex === last).
4192
+ */
4193
+ isPointInStroke(point, options = {}) {
4194
+ const { strokeWidth = 1, tolerance = 0 } = options;
4195
+ const vertices = this._getCachedAdaptiveVertices();
4196
+ const len = vertices.length;
4197
+ const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
4198
+ return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
4199
+ }
3855
4200
  _setCurrentPoint(point) {
3856
4201
  this.currentPoint = new Vector2(point.x, point.y);
3857
4202
  if (!this.startPoint) {
@@ -4025,6 +4370,8 @@ class CurvePath extends CompositeCurve {
4025
4370
 
4026
4371
  class Path2D extends CompositeCurve {
4027
4372
  _meta;
4373
+ _ringsCache;
4374
+ _ringsCacheLen = -1;
4028
4375
  currentCurve = new CurvePath();
4029
4376
  style;
4030
4377
  get startPoint() {
@@ -4143,18 +4490,21 @@ class Path2D extends CompositeCurve {
4143
4490
  this.getControlPointRefs().forEach((point) => {
4144
4491
  point.scale(sx, sy, target);
4145
4492
  });
4493
+ this.invalidate();
4146
4494
  return this;
4147
4495
  }
4148
4496
  skew(ax, ay = 0, target = { x: 0, y: 0 }) {
4149
4497
  this.getControlPointRefs().forEach((point) => {
4150
4498
  point.skew(ax, ay, target);
4151
4499
  });
4500
+ this.invalidate();
4152
4501
  return this;
4153
4502
  }
4154
4503
  rotate(rad, target = { x: 0, y: 0 }) {
4155
4504
  this.getControlPointRefs().forEach((point) => {
4156
4505
  point.rotate(rad, target);
4157
4506
  });
4507
+ this.invalidate();
4158
4508
  return this;
4159
4509
  }
4160
4510
  bold(b) {
@@ -4213,38 +4563,67 @@ class Path2D extends CompositeCurve {
4213
4563
  }
4214
4564
  });
4215
4565
  });
4566
+ this.invalidate();
4216
4567
  return this;
4217
4568
  }
4569
+ /**
4570
+ * Test whether a point lies inside the filled area of this path.
4571
+ *
4572
+ * Each sub-path ({@link CurvePath}) is sampled into its own ring and all rings are
4573
+ * evaluated together via {@link pointInPolygons}, so holes (donut / hollow shapes) are
4574
+ * honored. This is purely geometric and ignores `style.fill` — for the `fill: 'none'`
4575
+ * fallback, gate the call upstream (see {@link Path2DSet.hitTest}).
4576
+ *
4577
+ * Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
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
+ }
4595
+ isPointInFill(point, options = {}) {
4596
+ const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4597
+ return pointInPolygons(point, this._getRings(), fillRule);
4598
+ }
4599
+ /**
4600
+ * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4601
+ *
4602
+ * Defaults `strokeWidth` to this path's own {@link strokeWidth} (which is `0` when
4603
+ * `style.stroke` is `'none'`). Each sub-path infers its own closed-ness unless `closed`
4604
+ * is given explicitly.
4605
+ */
4606
+ isPointInStroke(point, options = {}) {
4607
+ const strokeWidth = options.strokeWidth ?? this.strokeWidth;
4608
+ const { tolerance = 0, closed } = options;
4609
+ return this.curves.some((curve) => curve.isPointInStroke(point, {
4610
+ strokeWidth,
4611
+ tolerance,
4612
+ closed
4613
+ }));
4614
+ }
4218
4615
  getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
4219
- const strokeWidth = this.strokeWidth;
4220
4616
  this.curves.forEach((curve) => {
4221
4617
  curve.getMinMax(min, max);
4222
- if (withStyle) {
4223
- if (strokeWidth > 1) {
4224
- const halfStrokeWidth = strokeWidth / 2;
4225
- const isClockwise = curve.isClockwise();
4226
- const points = [];
4227
- for (let t = 0; t <= 1; t += 1 / curve.arcLengthDivision) {
4228
- const point = curve.getPoint(t);
4229
- const normal = curve.getNormal(t);
4230
- const dist1 = normal.clone().scale(isClockwise ? halfStrokeWidth : -halfStrokeWidth);
4231
- const dist2 = normal.clone().scale(isClockwise ? -halfStrokeWidth : halfStrokeWidth);
4232
- points.push(
4233
- point.clone().add(dist1),
4234
- point.clone().add(dist2),
4235
- point.clone().add({ x: halfStrokeWidth, y: 0 }),
4236
- point.clone().add({ x: -halfStrokeWidth, y: 0 }),
4237
- point.clone().add({ x: 0, y: halfStrokeWidth }),
4238
- point.clone().add({ x: 0, y: -halfStrokeWidth }),
4239
- point.clone().add({ x: halfStrokeWidth, y: halfStrokeWidth }),
4240
- point.clone().add({ x: -halfStrokeWidth, y: -halfStrokeWidth })
4241
- );
4242
- }
4243
- min.clampMin(...points);
4244
- max.clampMax(...points);
4245
- }
4246
- }
4247
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
+ }
4248
4627
  return { min: min.finite(), max: max.finite() };
4249
4628
  }
4250
4629
  strokeTriangulate(options) {
@@ -4392,6 +4771,44 @@ class Path2DSet {
4392
4771
  this.paths = paths;
4393
4772
  this.viewBox = viewBox;
4394
4773
  }
4774
+ /**
4775
+ * Test whether a point lies inside the filled area of any path in this set.
4776
+ * Purely geometric (ignores `fill: 'none'`); use {@link hitTest} for style-aware hits.
4777
+ */
4778
+ isPointInFill(point, options = {}) {
4779
+ return this.paths.some((path) => path.isPointInFill(point, options));
4780
+ }
4781
+ /**
4782
+ * Concise PathKit-style fill containment test across the whole set; shorthand for
4783
+ * {@link isPointInFill} with a `{ x, y }` point.
4784
+ */
4785
+ contains(x, y, options = {}) {
4786
+ return this.isPointInFill({ x, y }, options);
4787
+ }
4788
+ /**
4789
+ * Find the topmost path hit by a point, or `undefined` if none.
4790
+ *
4791
+ * Paths are tested top-to-bottom (last drawn first). For each path a fill hit is checked
4792
+ * first (skipped when `style.fill` is `'none'`), then — if `stroke` is enabled — a stroke
4793
+ * hit (skipped when `style.stroke` is `'none'`). This honors the "fill: none falls back to
4794
+ * stroke" rule; the coordinate space of `point` must match the paths (no scaling assumed).
4795
+ *
4796
+ * Options: `stroke` (also test strokes, default `true`), `tolerance` (extra stroke hit slack
4797
+ * in path units, default `0`), and `fillRule` (overrides each path's own fill rule).
4798
+ */
4799
+ hitTest(point, options = {}) {
4800
+ const { stroke = true, tolerance, fillRule } = options;
4801
+ for (let i = this.paths.length - 1; i >= 0; i--) {
4802
+ const path = this.paths[i];
4803
+ if ((path.style.fill ?? "#000") !== "none" && path.isPointInFill(point, { fillRule })) {
4804
+ return path;
4805
+ }
4806
+ if (stroke && (path.style.stroke ?? "none") !== "none" && path.isPointInStroke(point, { tolerance })) {
4807
+ return path;
4808
+ }
4809
+ }
4810
+ return void 0;
4811
+ }
4395
4812
  getBoundingBox(withStyle = true) {
4396
4813
  if (!this.paths.length) {
4397
4814
  return void 0;
@@ -4583,6 +5000,10 @@ exports.parseCssArg = parseCssArg;
4583
5000
  exports.parseCssArgs = parseCssArgs;
4584
5001
  exports.parseCssFunctions = parseCssFunctions;
4585
5002
  exports.parsePathDataArgs = parsePathDataArgs;
5003
+ exports.pointInPolygon = pointInPolygon;
5004
+ exports.pointInPolygons = pointInPolygons;
5005
+ exports.pointToPolylineDistance = pointToPolylineDistance;
5006
+ exports.pointToSegmentDistance = pointToSegmentDistance;
4586
5007
  exports.quadraticBezier = quadraticBezier;
4587
5008
  exports.setCanvasContext = setCanvasContext;
4588
5009
  exports.strokeTriangulate = strokeTriangulate;