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 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(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,
@@ -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,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 potins = this.getPoints();
2819
- for (let i = 0, len = potins.length; i < len; i++) {
2820
- const p = potins[i];
2821
- min.clampMin(p);
2822
- 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;
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.getAdaptiveVertices(), options.fillRule);
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.getAdaptiveVertices(), closed);
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 RoundCurve {
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, radius } = this;
3890
- const halfWidth = width / 2;
3891
- const halfHeight = height / 2;
3892
- const cx = x + halfWidth;
3893
- const cy = y + halfHeight;
3894
- const rx = Math.max(0, Math.min(radius, Math.min(halfWidth, halfHeight)));
3895
- const ry = rx;
3896
- this._center = new Vector2(cx, cy);
3897
- this._radius = new Vector2(rx, ry);
3898
- 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();
3899
4086
  return this;
3900
4087
  }
3901
4088
  drawTo(ctx) {
3902
- const { x, y, width, height, radius } = this;
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
- super.copyFrom(source);
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.getAdaptiveVertices();
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
- declare function getIntersectionPoint(p1: Vector2, p2: Vector2, q1: Vector2, q2: Vector2): Vector2;
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
- declare class RoundRectangleCurve extends RoundCurve {
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.