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/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,
@@ -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,9 +698,14 @@ 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++) {
@@ -2647,6 +2660,41 @@ function svgToPath2DSet(svg) {
2647
2660
  class Curve {
2648
2661
  arcLengthDivision = 200;
2649
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
+ }
2650
2698
  getPointAt(u, output = new Vector2()) {
2651
2699
  return this.getPoint(this.getUToTMapping(u), output);
2652
2700
  }
@@ -2665,6 +2713,7 @@ class Curve {
2665
2713
  transform.apply(p, p);
2666
2714
  }
2667
2715
  });
2716
+ this.invalidate();
2668
2717
  return this;
2669
2718
  }
2670
2719
  getUnevenVertices(count = 5, output = []) {
@@ -2809,12 +2858,25 @@ class Curve {
2809
2858
  return mid;
2810
2859
  }
2811
2860
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
2812
- const potins = this.getPoints();
2813
- for (let i = 0, len = potins.length; i < len; i++) {
2814
- const p = potins[i];
2815
- min.clampMin(p);
2816
- 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;
2817
2877
  }
2878
+ min.set(minX, minY);
2879
+ max.set(maxX, maxY);
2818
2880
  return { min: min.finite(), max: max.finite() };
2819
2881
  }
2820
2882
  getBoundingBox() {
@@ -2832,7 +2894,7 @@ class Curve {
2832
2894
  * are honored — a single `Curve` is always one ring.
2833
2895
  */
2834
2896
  isPointInFill(point, options = {}) {
2835
- return pointInPolygon(point, this.getAdaptiveVertices(), options.fillRule);
2897
+ return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
2836
2898
  }
2837
2899
  /**
2838
2900
  * Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
@@ -2845,7 +2907,7 @@ class Curve {
2845
2907
  */
2846
2908
  isPointInStroke(point, options = {}) {
2847
2909
  const { strokeWidth = 1, tolerance = 0, closed = false } = options;
2848
- const distance = pointToPolylineDistance(point, this.getAdaptiveVertices(), closed);
2910
+ const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
2849
2911
  return distance <= strokeWidth / 2 + tolerance;
2850
2912
  }
2851
2913
  /**
@@ -2989,6 +3051,64 @@ class RoundCurve extends Curve {
2989
3051
  }
2990
3052
  return output.set(_x, _y);
2991
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
+ }
2992
3112
  toCommands() {
2993
3113
  const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
2994
3114
  const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
@@ -3039,6 +3159,7 @@ class RoundCurve extends Curve {
3039
3159
  } else {
3040
3160
  transfEllipseNoSkew(this, transform);
3041
3161
  }
3162
+ this.invalidate();
3042
3163
  return this;
3043
3164
  }
3044
3165
  getControlPointRefs() {
@@ -3179,6 +3300,23 @@ class RoundCurve extends Curve {
3179
3300
  return this;
3180
3301
  }
3181
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
+ }
3182
3320
  function transfEllipseGeneric(curve, m) {
3183
3321
  const a = curve.rx;
3184
3322
  const b = curve.ry;
@@ -3417,6 +3555,22 @@ class CompositeCurve extends Curve {
3417
3555
  super();
3418
3556
  this.curves = curves;
3419
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
+ }
3420
3574
  getFlatCurves() {
3421
3575
  return this.curves.flatMap((curve) => {
3422
3576
  if (curve instanceof CompositeCurve) {
@@ -3462,6 +3616,7 @@ class CompositeCurve extends Curve {
3462
3616
  updateLengths() {
3463
3617
  const lengths = [];
3464
3618
  for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
3619
+ this.curves[i]._owner = this;
3465
3620
  sum += this.curves[i].getLength();
3466
3621
  lengths.push(sum);
3467
3622
  }
@@ -3530,7 +3685,10 @@ class CompositeCurve extends Curve {
3530
3685
  }
3531
3686
  }
3532
3687
  applyTransform(transform) {
3688
+ this._invalidating = true;
3533
3689
  this.curves.forEach((curve) => curve.applyTransform(transform));
3690
+ this._invalidating = false;
3691
+ this.invalidate();
3534
3692
  return this;
3535
3693
  }
3536
3694
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
@@ -3869,7 +4027,7 @@ class RectangleCurve extends PolygonCurve {
3869
4027
  }
3870
4028
  }
3871
4029
 
3872
- class RoundRectangleCurve extends RoundCurve {
4030
+ class RoundRectangleCurve extends CompositeCurve {
3873
4031
  constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
3874
4032
  super();
3875
4033
  this.x = x;
@@ -3880,25 +4038,53 @@ class RoundRectangleCurve extends RoundCurve {
3880
4038
  this.update();
3881
4039
  }
3882
4040
  update() {
3883
- const { x, y, width, height, radius } = this;
3884
- const halfWidth = width / 2;
3885
- const halfHeight = height / 2;
3886
- const cx = x + halfWidth;
3887
- const cy = y + halfHeight;
3888
- const rx = Math.max(0, Math.min(radius, Math.min(halfWidth, halfHeight)));
3889
- const ry = rx;
3890
- this._center = new Vector2(cx, cy);
3891
- this._radius = new Vector2(rx, ry);
3892
- 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();
3893
4080
  return this;
3894
4081
  }
3895
4082
  drawTo(ctx) {
3896
- const { x, y, width, height, radius } = this;
3897
- ctx.roundRect(x, y, width, height, radius);
4083
+ ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
3898
4084
  return this;
3899
4085
  }
3900
4086
  copyFrom(source) {
3901
- super.copyFrom(source);
4087
+ this.arcLengthDivision = source.arcLengthDivision;
3902
4088
  this.x = source.x;
3903
4089
  this.y = source.y;
3904
4090
  this.width = source.width;
@@ -4000,7 +4186,7 @@ class CurvePath extends CompositeCurve {
4000
4186
  */
4001
4187
  isPointInStroke(point, options = {}) {
4002
4188
  const { strokeWidth = 1, tolerance = 0 } = options;
4003
- const vertices = this.getAdaptiveVertices();
4189
+ const vertices = this._getCachedAdaptiveVertices();
4004
4190
  const len = vertices.length;
4005
4191
  const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
4006
4192
  return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
@@ -4178,6 +4364,8 @@ class CurvePath extends CompositeCurve {
4178
4364
 
4179
4365
  class Path2D extends CompositeCurve {
4180
4366
  _meta;
4367
+ _ringsCache;
4368
+ _ringsCacheLen = -1;
4181
4369
  currentCurve = new CurvePath();
4182
4370
  style;
4183
4371
  get startPoint() {
@@ -4296,18 +4484,21 @@ class Path2D extends CompositeCurve {
4296
4484
  this.getControlPointRefs().forEach((point) => {
4297
4485
  point.scale(sx, sy, target);
4298
4486
  });
4487
+ this.invalidate();
4299
4488
  return this;
4300
4489
  }
4301
4490
  skew(ax, ay = 0, target = { x: 0, y: 0 }) {
4302
4491
  this.getControlPointRefs().forEach((point) => {
4303
4492
  point.skew(ax, ay, target);
4304
4493
  });
4494
+ this.invalidate();
4305
4495
  return this;
4306
4496
  }
4307
4497
  rotate(rad, target = { x: 0, y: 0 }) {
4308
4498
  this.getControlPointRefs().forEach((point) => {
4309
4499
  point.rotate(rad, target);
4310
4500
  });
4501
+ this.invalidate();
4311
4502
  return this;
4312
4503
  }
4313
4504
  bold(b) {
@@ -4366,6 +4557,7 @@ class Path2D extends CompositeCurve {
4366
4557
  }
4367
4558
  });
4368
4559
  });
4560
+ this.invalidate();
4369
4561
  return this;
4370
4562
  }
4371
4563
  /**
@@ -4378,13 +4570,25 @@ class Path2D extends CompositeCurve {
4378
4570
  *
4379
4571
  * Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
4380
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
+ }
4381
4589
  isPointInFill(point, options = {}) {
4382
4590
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4383
- return pointInPolygons(
4384
- point,
4385
- this.curves.map((curve) => curve.getAdaptiveVertices()),
4386
- fillRule
4387
- );
4591
+ return pointInPolygons(point, this._getRings(), fillRule);
4388
4592
  }
4389
4593
  /**
4390
4594
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
@@ -4403,35 +4607,17 @@ class Path2D extends CompositeCurve {
4403
4607
  }));
4404
4608
  }
4405
4609
  getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
4406
- const strokeWidth = this.strokeWidth;
4407
4610
  this.curves.forEach((curve) => {
4408
4611
  curve.getMinMax(min, max);
4409
- if (withStyle) {
4410
- if (strokeWidth > 1) {
4411
- const halfStrokeWidth = strokeWidth / 2;
4412
- const isClockwise = curve.isClockwise();
4413
- const points = [];
4414
- for (let t = 0; t <= 1; t += 1 / curve.arcLengthDivision) {
4415
- const point = curve.getPoint(t);
4416
- const normal = curve.getNormal(t);
4417
- const dist1 = normal.clone().scale(isClockwise ? halfStrokeWidth : -halfStrokeWidth);
4418
- const dist2 = normal.clone().scale(isClockwise ? -halfStrokeWidth : halfStrokeWidth);
4419
- points.push(
4420
- point.clone().add(dist1),
4421
- point.clone().add(dist2),
4422
- point.clone().add({ x: halfStrokeWidth, y: 0 }),
4423
- point.clone().add({ x: -halfStrokeWidth, y: 0 }),
4424
- point.clone().add({ x: 0, y: halfStrokeWidth }),
4425
- point.clone().add({ x: 0, y: -halfStrokeWidth }),
4426
- point.clone().add({ x: halfStrokeWidth, y: halfStrokeWidth }),
4427
- point.clone().add({ x: -halfStrokeWidth, y: -halfStrokeWidth })
4428
- );
4429
- }
4430
- min.clampMin(...points);
4431
- max.clampMax(...points);
4432
- }
4433
- }
4434
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
+ }
4435
4621
  return { min: min.finite(), max: max.finite() };
4436
4622
  }
4437
4623
  strokeTriangulate(options) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "modern-path2d",
3
3
  "type": "module",
4
- "version": "1.6.1",
4
+ "version": "1.7.0",
5
5
  "packageManager": "pnpm@9.15.1",
6
6
  "description": "A Path2D library, fully compatible with Web Path2D, with additional support for triangulate、animation、deformation etc.",
7
7
  "author": "wxm",
@@ -52,6 +52,7 @@
52
52
  "release": "bumpp package.json --commit \"release: v%s\" --push --all --tag",
53
53
  "start": "esno src/index.ts",
54
54
  "test": "vitest",
55
+ "bench": "vitest bench --run",
55
56
  "typecheck": "tsc --noEmit"
56
57
  },
57
58
  "dependencies": {