modern-path2d 1.6.1 → 1.8.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
@@ -1,4 +1,5 @@
1
1
  import earcut from 'earcut';
2
+ import polygonClipping from 'polygon-clipping';
2
3
 
3
4
  function drawPoint(ctx, x, y, options = {}) {
4
5
  const { radius = 1 } = options;
@@ -120,7 +121,10 @@ class Vector2 {
120
121
  return this.set(this._x * x, this._y * y);
121
122
  }
122
123
  divide(x = 0, y = x) {
123
- return this.set(this._x / x, this._y / y);
124
+ return this.set(
125
+ x === 0 ? this._x : this._x / x,
126
+ y === 0 ? this._y : this._y / y
127
+ );
124
128
  }
125
129
  cross(p) {
126
130
  return this._x * p.y - this._y * p.x;
@@ -294,6 +298,63 @@ class BoundingBox {
294
298
  }
295
299
  }
296
300
 
301
+ function flatRingToPairs(r) {
302
+ const ring = [];
303
+ for (let i = 0; i < r.length; i += 2) {
304
+ ring.push([r[i], r[i + 1]]);
305
+ }
306
+ const first = ring[0];
307
+ const last = ring[ring.length - 1];
308
+ if (first && last && (first[0] !== last[0] || first[1] !== last[1])) {
309
+ ring.push([first[0], first[1]]);
310
+ }
311
+ return ring;
312
+ }
313
+ function ringsToGeom(rings) {
314
+ const valid = rings.filter((r) => r.length >= 6).map(flatRingToPairs);
315
+ if (!valid.length) {
316
+ return [];
317
+ }
318
+ let geom = [[valid[0]]];
319
+ for (let i = 1; i < valid.length; i++) {
320
+ geom = polygonClipping.xor(geom, [[valid[i]]]);
321
+ }
322
+ return geom;
323
+ }
324
+ function geomToRings(geom) {
325
+ const out = [];
326
+ for (const poly of geom) {
327
+ for (const ring of poly) {
328
+ const flat = [];
329
+ for (const [x, y] of ring) {
330
+ flat.push(x, y);
331
+ }
332
+ out.push(flat);
333
+ }
334
+ }
335
+ return out;
336
+ }
337
+ function polygonBoolean(op, ringsA, ringsB) {
338
+ const a = ringsToGeom(ringsA);
339
+ const b = ringsToGeom(ringsB);
340
+ let res;
341
+ switch (op) {
342
+ case "union":
343
+ res = polygonClipping.union(a, b);
344
+ break;
345
+ case "intersection":
346
+ res = polygonClipping.intersection(a, b);
347
+ break;
348
+ case "difference":
349
+ res = polygonClipping.difference(a, b);
350
+ break;
351
+ case "xor":
352
+ res = polygonClipping.xor(a, b);
353
+ break;
354
+ }
355
+ return geomToRings(res);
356
+ }
357
+
297
358
  function catmullRom(t, p0, p1, p2, p3) {
298
359
  const v0 = (p2 - p0) * 0.5;
299
360
  const v1 = (p3 - p1) * 0.5;
@@ -313,17 +374,11 @@ function getIntersectionPoint(p1, p2, q1, q2) {
313
374
  const q1p1 = q1.clone().sub(p1);
314
375
  const crossRS = r.cross(s);
315
376
  if (crossRS === 0) {
316
- return new Vector2(
317
- (p1.x + q1.x) / 2,
318
- (p1.y + q1.y) / 2
319
- );
377
+ return null;
320
378
  }
321
379
  const t = q1p1.cross(s) / crossRS;
322
380
  if (Math.abs(t) > 1) {
323
- return new Vector2(
324
- (p1.x + q1.x) / 2,
325
- (p1.y + q1.y) / 2
326
- );
381
+ return null;
327
382
  }
328
383
  return new Vector2(
329
384
  p1.x + t * r.x,
@@ -615,11 +670,16 @@ function distance(p1, p2) {
615
670
  const dy = p2[1] - p1[1];
616
671
  return Math.sqrt(dx * dx + dy * dy);
617
672
  }
673
+ function aabbIntersects(a, b) {
674
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
675
+ }
618
676
  function nonzeroFillRule(paths) {
619
677
  const results = paths.map((_, i) => ({ index: i }));
620
- const testPointsGroups = paths.map((path) => {
678
+ const bboxes = [];
679
+ const testPointsGroups = paths.map((path, pathIndex) => {
621
680
  const len = path.length;
622
681
  if (!len) {
682
+ bboxes[pathIndex] = null;
623
683
  return [];
624
684
  }
625
685
  let xMinYAuto = [Number.MAX_SAFE_INTEGER, 0];
@@ -642,6 +702,12 @@ function nonzeroFillRule(paths) {
642
702
  xAutoYMax = [x, y];
643
703
  }
644
704
  }
705
+ bboxes[pathIndex] = {
706
+ minX: xMinYAuto[0],
707
+ minY: xAutoYMin[1],
708
+ maxX: xMaxYAuto[0],
709
+ maxY: xAutoYMax[1]
710
+ };
645
711
  const mid = [
646
712
  (xMinYAuto[0] + xMaxYAuto[0]) / 2,
647
713
  (xAutoYMin[1] + xAutoYMax[1]) / 2
@@ -690,9 +756,14 @@ function nonzeroFillRule(paths) {
690
756
  for (let i = 0, len = paths.length; i < len; i++) {
691
757
  const _results = [];
692
758
  const testPoints = testPointsGroups[i];
759
+ const boxI = bboxes[i];
693
760
  for (let j = 0; j < len; j++) {
694
761
  if (i === j)
695
762
  continue;
763
+ const boxJ = bboxes[j];
764
+ if (!boxI || !boxJ || !aabbIntersects(boxI, boxJ)) {
765
+ continue;
766
+ }
696
767
  const wnMap = {};
697
768
  const wnList = [];
698
769
  for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
@@ -848,22 +919,35 @@ function quadraticBezier(t, p0, p1, p2) {
848
919
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
849
920
  }
850
921
 
922
+ function resolveLineJoin(join) {
923
+ switch (join) {
924
+ case "round":
925
+ case "bevel":
926
+ case "miter":
927
+ return join;
928
+ default:
929
+ return "miter";
930
+ }
931
+ }
932
+ function resolveLineStyle(style) {
933
+ return {
934
+ width: style?.strokeWidth ?? 1,
935
+ alignment: 0.5,
936
+ join: resolveLineJoin(style?.strokeLinejoin),
937
+ cap: style?.strokeLinecap ?? "butt",
938
+ miterLimit: style?.strokeMiterlimit ?? 10
939
+ };
940
+ }
851
941
  const closePointEps = 1e-4;
852
942
  const curveEps = 1e-4;
853
943
  function strokeTriangulate(points, options = {}) {
854
944
  const {
855
945
  vertices = [],
856
946
  indices = [],
857
- lineStyle = {
858
- alignment: 0.5,
859
- cap: "butt",
860
- join: "miter",
861
- width: 1,
862
- miterLimit: 10
863
- },
864
947
  flipAlignment = false,
865
948
  closed = true
866
949
  } = options;
950
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
867
951
  const eps = closePointEps;
868
952
  if (points.length === 0) {
869
953
  return { vertices, indices };
@@ -2647,6 +2731,41 @@ function svgToPath2DSet(svg) {
2647
2731
  class Curve {
2648
2732
  arcLengthDivision = 200;
2649
2733
  _lengths = [];
2734
+ _adaptiveCache;
2735
+ /**
2736
+ * Parent composite, set lazily when a composite caches its children. Lets
2737
+ * {@link invalidate} propagate up so an ancestor's caches refresh too.
2738
+ */
2739
+ _owner;
2740
+ _invalidating = false;
2741
+ /**
2742
+ * Drop cached arc lengths and the cached sampled outline used by hit testing, then
2743
+ * bubble up to {@link _owner}. Called automatically by {@link applyTransform} and the
2744
+ * `Path2D` mutators; call it manually after mutating control-point coordinates in place —
2745
+ * the caches cannot observe such mutations.
2746
+ */
2747
+ invalidate() {
2748
+ if (this._invalidating) {
2749
+ return this;
2750
+ }
2751
+ this._invalidating = true;
2752
+ this._invalidateSelf();
2753
+ this._owner?.invalidate();
2754
+ this._invalidating = false;
2755
+ return this;
2756
+ }
2757
+ /** Clears this curve's own caches. Composites also clear their children (see override). */
2758
+ _invalidateSelf() {
2759
+ this._lengths.length = 0;
2760
+ this._adaptiveCache = void 0;
2761
+ }
2762
+ /**
2763
+ * Sampled outline cached for repeated hit tests (read-only — do not mutate the result).
2764
+ * Invalidated by {@link invalidate}.
2765
+ */
2766
+ _getCachedAdaptiveVertices() {
2767
+ return this._adaptiveCache ??= this.getAdaptiveVertices();
2768
+ }
2650
2769
  getPointAt(u, output = new Vector2()) {
2651
2770
  return this.getPoint(this.getUToTMapping(u), output);
2652
2771
  }
@@ -2656,6 +2775,22 @@ class Curve {
2656
2775
  getControlPointRefs() {
2657
2776
  return [];
2658
2777
  }
2778
+ /**
2779
+ * Reverse the traversal direction in place (start ↔ end, same geometry). The base
2780
+ * implementation reverses the order of the control-point *values*, which is correct for
2781
+ * line / Bézier / spline primitives whose {@link getControlPointRefs} order matches their
2782
+ * parametric order. {@link RoundCurve} (angle-based) and composites (child order) override it.
2783
+ */
2784
+ reverse() {
2785
+ const refs = this.getControlPointRefs();
2786
+ const n = refs.length;
2787
+ const snapshot = refs.map((p) => p.clone());
2788
+ for (let i = 0; i < n; i++) {
2789
+ refs[i].copyFrom(snapshot[n - 1 - i]);
2790
+ }
2791
+ this.invalidate();
2792
+ return this;
2793
+ }
2659
2794
  applyTransform(transform) {
2660
2795
  const isFunction = typeof transform === "function";
2661
2796
  this.getControlPointRefs().forEach((p) => {
@@ -2665,6 +2800,7 @@ class Curve {
2665
2800
  transform.apply(p, p);
2666
2801
  }
2667
2802
  });
2803
+ this.invalidate();
2668
2804
  return this;
2669
2805
  }
2670
2806
  getUnevenVertices(count = 5, output = []) {
@@ -2782,6 +2918,22 @@ class Curve {
2782
2918
  getTangentAt(u, output) {
2783
2919
  return this.getTangent(this.getUToTMapping(u), output);
2784
2920
  }
2921
+ /**
2922
+ * PathKit-style sample at an absolute arc-length `distance` along the curve: the point, the unit
2923
+ * tangent, and the tangent `angle` in radians. `distance` is clamped to `[0, getLength()]`, so
2924
+ * passing `0`/`getLength()` always yields the endpoints. See {@link PathMeasure} for a wrapper.
2925
+ */
2926
+ getPosTan(distance) {
2927
+ const length = this.getLength();
2928
+ const u = length > 0 ? Math.min(Math.max(distance / length, 0), 1) : 0;
2929
+ const t = this.getUToTMapping(u);
2930
+ const tangent = this.getTangent(t);
2931
+ return {
2932
+ position: this.getPoint(t),
2933
+ tangent,
2934
+ angle: Math.atan2(tangent.y, tangent.x)
2935
+ };
2936
+ }
2785
2937
  getNormal(t, output = new Vector2()) {
2786
2938
  this.getTangent(t, output);
2787
2939
  return output.set(-output.y, output.x).normalize();
@@ -2809,12 +2961,25 @@ class Curve {
2809
2961
  return mid;
2810
2962
  }
2811
2963
  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);
2964
+ const vertices = this.getAdaptiveVertices();
2965
+ let minX = min.x;
2966
+ let minY = min.y;
2967
+ let maxX = max.x;
2968
+ let maxY = max.y;
2969
+ for (let i = 0, len = vertices.length; i < len; i += 2) {
2970
+ const x = vertices[i];
2971
+ const y = vertices[i + 1];
2972
+ if (x < minX)
2973
+ minX = x;
2974
+ if (y < minY)
2975
+ minY = y;
2976
+ if (x > maxX)
2977
+ maxX = x;
2978
+ if (y > maxY)
2979
+ maxY = y;
2817
2980
  }
2981
+ min.set(minX, minY);
2982
+ max.set(maxX, maxY);
2818
2983
  return { min: min.finite(), max: max.finite() };
2819
2984
  }
2820
2985
  getBoundingBox() {
@@ -2832,7 +2997,7 @@ class Curve {
2832
2997
  * are honored — a single `Curve` is always one ring.
2833
2998
  */
2834
2999
  isPointInFill(point, options = {}) {
2835
- return pointInPolygon(point, this.getAdaptiveVertices(), options.fillRule);
3000
+ return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
2836
3001
  }
2837
3002
  /**
2838
3003
  * Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
@@ -2845,7 +3010,7 @@ class Curve {
2845
3010
  */
2846
3011
  isPointInStroke(point, options = {}) {
2847
3012
  const { strokeWidth = 1, tolerance = 0, closed = false } = options;
2848
- const distance = pointToPolylineDistance(point, this.getAdaptiveVertices(), closed);
3013
+ const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
2849
3014
  return distance <= strokeWidth / 2 + tolerance;
2850
3015
  }
2851
3016
  /**
@@ -2864,10 +3029,28 @@ class Curve {
2864
3029
  options
2865
3030
  );
2866
3031
  }
3032
+ /**
3033
+ * Whether this curve forms a closed loop (its outline should be stroked without end caps,
3034
+ * stitching the last vertex back to the first). The base test is purely geometric — the first
3035
+ * sampled vertex coincides with the last. Curves that close without a duplicated endpoint
3036
+ * (a full-revolution {@link RoundCurve}, rectangles, polygons) override this.
3037
+ */
3038
+ isClosed() {
3039
+ const v = this._getCachedAdaptiveVertices();
3040
+ const len = v.length;
3041
+ if (len < 6) {
3042
+ return false;
3043
+ }
3044
+ const eps = 1e-4;
3045
+ return Math.abs(v[0] - v[len - 2]) < eps && Math.abs(v[1] - v[len - 1]) < eps;
3046
+ }
2867
3047
  strokeTriangulate(options) {
2868
3048
  return strokeTriangulate(
2869
3049
  this.getAdaptiveVertices(),
2870
- options
3050
+ {
3051
+ ...options,
3052
+ closed: options?.closed ?? this.isClosed()
3053
+ }
2871
3054
  );
2872
3055
  }
2873
3056
  toCommands() {
@@ -2962,6 +3145,22 @@ class RoundCurve extends Curve {
2962
3145
  isClockwise() {
2963
3146
  return this.clockwise;
2964
3147
  }
3148
+ /**
3149
+ * A circle/ellipse arc is closed when it sweeps (at least) a full revolution — the sampled
3150
+ * outline does not duplicate the start vertex, so the geometric first==last test in the base
3151
+ * class would wrongly report a full circle as open and leave a seam gap in the stroke.
3152
+ */
3153
+ isClosed() {
3154
+ return Math.abs(this.endAngle - this.startAngle) >= Math.PI * 2 - 1e-9 || super.isClosed();
3155
+ }
3156
+ reverse() {
3157
+ const { startAngle, endAngle } = this;
3158
+ this.startAngle = endAngle;
3159
+ this.endAngle = startAngle;
3160
+ this.clockwise = !this.clockwise;
3161
+ this.invalidate();
3162
+ return this;
3163
+ }
2965
3164
  _getDeltaAngle() {
2966
3165
  const PI_2 = Math.PI * 2;
2967
3166
  let deltaAngle = this.endAngle - this.startAngle;
@@ -2989,6 +3188,64 @@ class RoundCurve extends Curve {
2989
3188
  }
2990
3189
  return output.set(_x, _y);
2991
3190
  }
3191
+ /**
3192
+ * Point on the ellipse at an absolute angle (mirrors {@link getPoint}'s parameterization,
3193
+ * ignoring `_diff`).
3194
+ */
3195
+ _pointAtAngle(angle, output) {
3196
+ let x = this.cx + this.rx * Math.cos(angle);
3197
+ let y = this.cy + this.ry * Math.sin(angle);
3198
+ if (this.rotate !== 0) {
3199
+ const cos = Math.cos(this.rotate);
3200
+ const sin = Math.sin(this.rotate);
3201
+ const tx = x - this.cx;
3202
+ const ty = y - this.cy;
3203
+ x = tx * cos - ty * sin + this.cx;
3204
+ y = tx * sin + ty * cos + this.cy;
3205
+ }
3206
+ return output.set(x, y);
3207
+ }
3208
+ /**
3209
+ * Analytical bounds of the (elliptical) arc: the start/end points plus the per-axis
3210
+ * extrema angles that fall within the swept interval. Matches {@link getPoint}, so it is
3211
+ * exact for `ArcCurve`/`EllipseCurve`. The `_diff` offset (used only by the legacy
3212
+ * `_getAdaptiveVerticesByCircle` path) is intentionally ignored here.
3213
+ */
3214
+ getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
3215
+ const { startAngle, rotate } = this;
3216
+ const delta = this._getDeltaAngle();
3217
+ const cosT = Math.cos(rotate);
3218
+ const sinT = Math.sin(rotate);
3219
+ const p = tempV2;
3220
+ let minX = min.x;
3221
+ let minY = min.y;
3222
+ let maxX = max.x;
3223
+ let maxY = max.y;
3224
+ const consider = (angle) => {
3225
+ this._pointAtAngle(angle, p);
3226
+ if (p.x < minX)
3227
+ minX = p.x;
3228
+ if (p.y < minY)
3229
+ minY = p.y;
3230
+ if (p.x > maxX)
3231
+ maxX = p.x;
3232
+ if (p.y > maxY)
3233
+ maxY = p.y;
3234
+ };
3235
+ consider(startAngle);
3236
+ consider(startAngle + delta);
3237
+ const ax = Math.atan2(-this.ry * sinT, this.rx * cosT);
3238
+ const ay = Math.atan2(this.ry * cosT, this.rx * sinT);
3239
+ const bases = [ax, ax + Math.PI, ay, ay + Math.PI];
3240
+ for (let i = 0; i < 4; i++) {
3241
+ if (angleInSweep(bases[i], startAngle, delta)) {
3242
+ consider(bases[i]);
3243
+ }
3244
+ }
3245
+ min.set(minX, minY);
3246
+ max.set(maxX, maxY);
3247
+ return { min: min.finite(), max: max.finite() };
3248
+ }
2992
3249
  toCommands() {
2993
3250
  const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
2994
3251
  const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
@@ -3039,6 +3296,7 @@ class RoundCurve extends Curve {
3039
3296
  } else {
3040
3297
  transfEllipseNoSkew(this, transform);
3041
3298
  }
3299
+ this.invalidate();
3042
3300
  return this;
3043
3301
  }
3044
3302
  getControlPointRefs() {
@@ -3159,9 +3417,25 @@ class RoundCurve extends Curve {
3159
3417
  return output;
3160
3418
  }
3161
3419
  getAdaptiveVertices(output = []) {
3162
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3420
+ const PI2 = Math.PI * 2;
3421
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3163
3422
  return this._getAdaptiveVerticesByCircle(output);
3164
3423
  }
3424
+ if (Math.abs(this.endAngle - this.startAngle) >= PI2 - 1e-9) {
3425
+ const tmp = this._getAdaptiveVerticesByCircle([]);
3426
+ const n = tmp.length / 2;
3427
+ if (this.endAngle > this.startAngle) {
3428
+ for (let i = 0; i < tmp.length; i++) {
3429
+ output.push(tmp[i]);
3430
+ }
3431
+ } else {
3432
+ output.push(tmp[0], tmp[1]);
3433
+ for (let i = n - 1; i >= 1; i--) {
3434
+ output.push(tmp[i * 2], tmp[i * 2 + 1]);
3435
+ }
3436
+ }
3437
+ return output;
3438
+ }
3165
3439
  return this._getAdaptiveVerticesByArc(output);
3166
3440
  }
3167
3441
  copyFrom(source) {
@@ -3179,6 +3453,23 @@ class RoundCurve extends Curve {
3179
3453
  return this;
3180
3454
  }
3181
3455
  }
3456
+ function angleInSweep(a, start, delta) {
3457
+ const PI_2 = Math.PI * 2;
3458
+ const eps = 1e-9;
3459
+ if (Math.abs(delta) >= PI_2 - eps) {
3460
+ return true;
3461
+ }
3462
+ let off = (a - start) % PI_2;
3463
+ if (delta >= 0) {
3464
+ if (off < -eps)
3465
+ off += PI_2;
3466
+ return off >= -eps && off <= delta + eps;
3467
+ }
3468
+ if (off > eps) {
3469
+ off -= PI_2;
3470
+ }
3471
+ return off <= eps && off >= delta - eps;
3472
+ }
3182
3473
  function transfEllipseGeneric(curve, m) {
3183
3474
  const a = curve.rx;
3184
3475
  const b = curve.ry;
@@ -3354,6 +3645,15 @@ class LineCurve extends Curve {
3354
3645
  getControlPointRefs() {
3355
3646
  return [this.p1, this.p2];
3356
3647
  }
3648
+ // Swap endpoint *references* (not values) so corner Vector2s shared with adjacent
3649
+ // segments stay intact and simply re-associate with the reversed segment.
3650
+ reverse() {
3651
+ const { p1, p2 } = this;
3652
+ this.p1 = p2;
3653
+ this.p2 = p1;
3654
+ this.invalidate();
3655
+ return this;
3656
+ }
3357
3657
  getAdaptiveVertices(output = []) {
3358
3658
  output.push(
3359
3659
  this.p1.x,
@@ -3417,6 +3717,22 @@ class CompositeCurve extends Curve {
3417
3717
  super();
3418
3718
  this.curves = curves;
3419
3719
  }
3720
+ _adaptiveCacheLen = -1;
3721
+ _invalidateSelf() {
3722
+ super._invalidateSelf();
3723
+ this._adaptiveCacheLen = -1;
3724
+ this.curves.forEach((curve) => curve.invalidate());
3725
+ }
3726
+ _getCachedAdaptiveVertices() {
3727
+ if (!this._adaptiveCache || this._adaptiveCacheLen !== this.curves.length) {
3728
+ this.curves.forEach((curve) => {
3729
+ curve._owner = this;
3730
+ });
3731
+ this._adaptiveCache = this.getAdaptiveVertices();
3732
+ this._adaptiveCacheLen = this.curves.length;
3733
+ }
3734
+ return this._adaptiveCache;
3735
+ }
3420
3736
  getFlatCurves() {
3421
3737
  return this.curves.flatMap((curve) => {
3422
3738
  if (curve instanceof CompositeCurve) {
@@ -3462,6 +3778,7 @@ class CompositeCurve extends Curve {
3462
3778
  updateLengths() {
3463
3779
  const lengths = [];
3464
3780
  for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
3781
+ this.curves[i]._owner = this;
3465
3782
  sum += this.curves[i].getLength();
3466
3783
  lengths.push(sum);
3467
3784
  }
@@ -3500,6 +3817,16 @@ class CompositeCurve extends Curve {
3500
3817
  });
3501
3818
  return output;
3502
3819
  }
3820
+ /**
3821
+ * A composite is closed when its single child is closed (e.g. a lone full-circle arc), or when
3822
+ * its assembled outline returns to its start (rectangles, polygons, multi-segment loops).
3823
+ */
3824
+ isClosed() {
3825
+ if (this.curves.length === 1) {
3826
+ return this.curves[0].isClosed();
3827
+ }
3828
+ return super.isClosed();
3829
+ }
3503
3830
  strokeTriangulate(options) {
3504
3831
  if (this.curves.length === 1) {
3505
3832
  return this.curves[0].strokeTriangulate(options);
@@ -3507,6 +3834,13 @@ class CompositeCurve extends Curve {
3507
3834
  return super.strokeTriangulate(options);
3508
3835
  }
3509
3836
  }
3837
+ /** Reverse the sub-curve order and reverse each sub-curve, so the whole outline runs backwards. */
3838
+ reverse() {
3839
+ this.curves.reverse();
3840
+ this.curves.forEach((curve) => curve.reverse());
3841
+ this.invalidate();
3842
+ return this;
3843
+ }
3510
3844
  getFillVertices(options) {
3511
3845
  if (this.curves.length === 1) {
3512
3846
  return this.curves[0].getFillVertices(options);
@@ -3530,7 +3864,10 @@ class CompositeCurve extends Curve {
3530
3864
  }
3531
3865
  }
3532
3866
  applyTransform(transform) {
3867
+ this._invalidating = true;
3533
3868
  this.curves.forEach((curve) => curve.applyTransform(transform));
3869
+ this._invalidating = false;
3870
+ this.invalidate();
3534
3871
  return this;
3535
3872
  }
3536
3873
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
@@ -3599,6 +3936,16 @@ class CubicBezierCurve extends Curve {
3599
3936
  getControlPointRefs() {
3600
3937
  return [this.p1, this.cp1, this.cp2, this.p2];
3601
3938
  }
3939
+ // Swap endpoint and control-point references; keeps shared corner Vector2s intact.
3940
+ reverse() {
3941
+ const { p1, cp1, cp2, p2 } = this;
3942
+ this.p1 = p2;
3943
+ this.cp1 = cp2;
3944
+ this.cp2 = cp1;
3945
+ this.p2 = p1;
3946
+ this.invalidate();
3947
+ return this;
3948
+ }
3602
3949
  _solveQuadratic(a, b, c) {
3603
3950
  if (Math.abs(a) < 1e-12) {
3604
3951
  if (Math.abs(b) < 1e-12)
@@ -3759,6 +4106,14 @@ class QuadraticBezierCurve extends Curve {
3759
4106
  getControlPointRefs() {
3760
4107
  return [this.p1, this.cp, this.p2];
3761
4108
  }
4109
+ // Swap endpoint references (cp is symmetric); keeps shared corner Vector2s intact.
4110
+ reverse() {
4111
+ const { p1, p2 } = this;
4112
+ this.p1 = p2;
4113
+ this.p2 = p1;
4114
+ this.invalidate();
4115
+ return this;
4116
+ }
3762
4117
  getAdaptiveVertices(output = []) {
3763
4118
  return getAdaptiveQuadraticBezierCurvePoints(
3764
4119
  this.p1.x,
@@ -3869,7 +4224,7 @@ class RectangleCurve extends PolygonCurve {
3869
4224
  }
3870
4225
  }
3871
4226
 
3872
- class RoundRectangleCurve extends RoundCurve {
4227
+ class RoundRectangleCurve extends CompositeCurve {
3873
4228
  constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
3874
4229
  super();
3875
4230
  this.x = x;
@@ -3880,25 +4235,53 @@ class RoundRectangleCurve extends RoundCurve {
3880
4235
  this.update();
3881
4236
  }
3882
4237
  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);
4238
+ const { x, y, width, height } = this;
4239
+ const r = Math.max(0, Math.min(this.radius, Math.abs(width) / 2, Math.abs(height) / 2));
4240
+ const x0 = x;
4241
+ const x1 = x + r;
4242
+ const x2 = x + width - r;
4243
+ const x3 = x + width;
4244
+ const y0 = y;
4245
+ const y1 = y + r;
4246
+ const y2 = y + height - r;
4247
+ const y3 = y + height;
4248
+ if (r <= 0) {
4249
+ this.curves = [
4250
+ LineCurve.from(x0, y0, x3, y0),
4251
+ LineCurve.from(x3, y0, x3, y3),
4252
+ LineCurve.from(x3, y3, x0, y3),
4253
+ LineCurve.from(x0, y3, x0, y0)
4254
+ ];
4255
+ } else {
4256
+ const HALF_PI = Math.PI / 2;
4257
+ this.curves = [
4258
+ LineCurve.from(x1, y0, x2, y0),
4259
+ // top edge
4260
+ new ArcCurve(x2, y1, r, -HALF_PI, 0, true),
4261
+ // top-right corner
4262
+ LineCurve.from(x3, y1, x3, y2),
4263
+ // right edge
4264
+ new ArcCurve(x2, y2, r, 0, HALF_PI, true),
4265
+ // bottom-right corner
4266
+ LineCurve.from(x2, y3, x1, y3),
4267
+ // bottom edge
4268
+ new ArcCurve(x1, y2, r, HALF_PI, Math.PI, true),
4269
+ // bottom-left corner
4270
+ LineCurve.from(x0, y2, x0, y1),
4271
+ // left edge
4272
+ new ArcCurve(x1, y1, r, Math.PI, Math.PI * 1.5, true)
4273
+ // top-left corner
4274
+ ];
4275
+ }
4276
+ this.invalidate();
3893
4277
  return this;
3894
4278
  }
3895
4279
  drawTo(ctx) {
3896
- const { x, y, width, height, radius } = this;
3897
- ctx.roundRect(x, y, width, height, radius);
4280
+ ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
3898
4281
  return this;
3899
4282
  }
3900
4283
  copyFrom(source) {
3901
- super.copyFrom(source);
4284
+ this.arcLengthDivision = source.arcLengthDivision;
3902
4285
  this.x = source.x;
3903
4286
  this.y = source.y;
3904
4287
  this.width = source.width;
@@ -3932,6 +4315,11 @@ class SplineCurve extends Curve {
3932
4315
  getControlPointRefs() {
3933
4316
  return this.points;
3934
4317
  }
4318
+ reverse() {
4319
+ this.points.reverse();
4320
+ this.invalidate();
4321
+ return this;
4322
+ }
3935
4323
  copyFrom(source) {
3936
4324
  super.copyFrom(source);
3937
4325
  this.points = [];
@@ -3968,6 +4356,22 @@ class CurvePath extends CompositeCurve {
3968
4356
  this.addCommands(svgPathDataToCommands(data));
3969
4357
  return this;
3970
4358
  }
4359
+ /**
4360
+ * A sub-path is closed if it was explicitly closed (`autoClose`, i.e. a `Z`/`closePath`), or if
4361
+ * it forms a geometric loop / wraps a single closed primitive (handled by the base class).
4362
+ */
4363
+ isClosed() {
4364
+ return this.autoClose || super.isClosed();
4365
+ }
4366
+ /** Reverse direction, then refresh the {@link startPoint}/{@link currentPoint} cursors. */
4367
+ reverse() {
4368
+ super.reverse();
4369
+ if (this.curves.length) {
4370
+ this.startPoint = this.getPoint(0);
4371
+ this.currentPoint = this.getPoint(1);
4372
+ }
4373
+ return this;
4374
+ }
3971
4375
  _closeVertices(output) {
3972
4376
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
3973
4377
  output.push(output[0], output[1]);
@@ -4000,7 +4404,7 @@ class CurvePath extends CompositeCurve {
4000
4404
  */
4001
4405
  isPointInStroke(point, options = {}) {
4002
4406
  const { strokeWidth = 1, tolerance = 0 } = options;
4003
- const vertices = this.getAdaptiveVertices();
4407
+ const vertices = this._getCachedAdaptiveVertices();
4004
4408
  const len = vertices.length;
4005
4409
  const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
4006
4410
  return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
@@ -4178,6 +4582,8 @@ class CurvePath extends CompositeCurve {
4178
4582
 
4179
4583
  class Path2D extends CompositeCurve {
4180
4584
  _meta;
4585
+ _ringsCache;
4586
+ _ringsCacheLen = -1;
4181
4587
  currentCurve = new CurvePath();
4182
4588
  style;
4183
4589
  get startPoint() {
@@ -4296,18 +4702,21 @@ class Path2D extends CompositeCurve {
4296
4702
  this.getControlPointRefs().forEach((point) => {
4297
4703
  point.scale(sx, sy, target);
4298
4704
  });
4705
+ this.invalidate();
4299
4706
  return this;
4300
4707
  }
4301
4708
  skew(ax, ay = 0, target = { x: 0, y: 0 }) {
4302
4709
  this.getControlPointRefs().forEach((point) => {
4303
4710
  point.skew(ax, ay, target);
4304
4711
  });
4712
+ this.invalidate();
4305
4713
  return this;
4306
4714
  }
4307
4715
  rotate(rad, target = { x: 0, y: 0 }) {
4308
4716
  this.getControlPointRefs().forEach((point) => {
4309
4717
  point.rotate(rad, target);
4310
4718
  });
4719
+ this.invalidate();
4311
4720
  return this;
4312
4721
  }
4313
4722
  bold(b) {
@@ -4366,6 +4775,7 @@ class Path2D extends CompositeCurve {
4366
4775
  }
4367
4776
  });
4368
4777
  });
4778
+ this.invalidate();
4369
4779
  return this;
4370
4780
  }
4371
4781
  /**
@@ -4378,13 +4788,70 @@ class Path2D extends CompositeCurve {
4378
4788
  *
4379
4789
  * Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
4380
4790
  */
4791
+ _invalidateSelf() {
4792
+ super._invalidateSelf();
4793
+ this._ringsCache = void 0;
4794
+ this._ringsCacheLen = -1;
4795
+ }
4796
+ /** Per-sub-path sampled rings, cached for repeated hit tests. */
4797
+ _getRings() {
4798
+ if (!this._ringsCache || this._ringsCacheLen !== this.curves.length) {
4799
+ this._ringsCache = this.curves.map((curve) => {
4800
+ curve._owner = this;
4801
+ return curve.getAdaptiveVertices();
4802
+ });
4803
+ this._ringsCacheLen = this.curves.length;
4804
+ }
4805
+ return this._ringsCache;
4806
+ }
4381
4807
  isPointInFill(point, options = {}) {
4382
4808
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4383
- return pointInPolygons(
4384
- point,
4385
- this.curves.map((curve) => curve.getAdaptiveVertices()),
4386
- fillRule
4387
- );
4809
+ return pointInPolygons(point, this._getRings(), fillRule);
4810
+ }
4811
+ /** Build a `Path2D` from flat rings (`[x0,y0,…]` per sub-path); closed-and-filled as sub-paths. */
4812
+ static fromRings(rings, style = {}) {
4813
+ const path = new Path2D(void 0, style);
4814
+ for (const ring of rings) {
4815
+ if (ring.length < 6) {
4816
+ continue;
4817
+ }
4818
+ let end = ring.length;
4819
+ if (ring[0] === ring[end - 2] && ring[1] === ring[end - 1]) {
4820
+ end -= 2;
4821
+ }
4822
+ path.moveTo(ring[0], ring[1]);
4823
+ for (let i = 2; i < end; i += 2) {
4824
+ path.lineTo(ring[i], ring[i + 1]);
4825
+ }
4826
+ path.closePath();
4827
+ }
4828
+ return path;
4829
+ }
4830
+ /**
4831
+ * Boolean (path) operation against another path, returning a NEW `Path2D` whose outline is the
4832
+ * polygonal result. Curves are sampled before clipping, so the result is a polygonal
4833
+ * approximation (see {@link polygonBoolean}). The result inherits this path's `style` unless
4834
+ * overridden via `style`. Holes are emitted as oppositely-wound sub-paths (nonzero fill).
4835
+ */
4836
+ booleanOp(op, other, style) {
4837
+ const rings = polygonBoolean(op, this._getRings(), other._getRings());
4838
+ return Path2D.fromRings(rings, { ...this.style, ...style });
4839
+ }
4840
+ /** `this ∪ other` — the combined filled area. */
4841
+ union(other, style) {
4842
+ return this.booleanOp("union", other, style);
4843
+ }
4844
+ /** `this ∩ other` — only the overlapping area. */
4845
+ intersection(other, style) {
4846
+ return this.booleanOp("intersection", other, style);
4847
+ }
4848
+ /** `this − other` — this path with `other` cut away. */
4849
+ difference(other, style) {
4850
+ return this.booleanOp("difference", other, style);
4851
+ }
4852
+ /** `this ⊕ other` — areas covered by exactly one of the two paths. */
4853
+ xor(other, style) {
4854
+ return this.booleanOp("xor", other, style);
4388
4855
  }
4389
4856
  /**
4390
4857
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
@@ -4403,35 +4870,17 @@ class Path2D extends CompositeCurve {
4403
4870
  }));
4404
4871
  }
4405
4872
  getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
4406
- const strokeWidth = this.strokeWidth;
4407
4873
  this.curves.forEach((curve) => {
4408
4874
  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
4875
  });
4876
+ if (withStyle) {
4877
+ const strokeWidth = this.strokeWidth;
4878
+ if (strokeWidth > 1 && Number.isFinite(min.x)) {
4879
+ const half = strokeWidth / 2;
4880
+ min.set(min.x - half, min.y - half);
4881
+ max.set(max.x + half, max.y + half);
4882
+ }
4883
+ }
4435
4884
  return { min: min.finite(), max: max.finite() };
4436
4885
  }
4437
4886
  strokeTriangulate(options) {
@@ -4503,7 +4952,7 @@ class Path2D extends CompositeCurve {
4503
4952
  }
4504
4953
  drawTo(ctx, style = {}) {
4505
4954
  style = { ...this.style, ...style };
4506
- const { fill = "#000", stroke = "none" } = style;
4955
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4507
4956
  ctx.beginPath();
4508
4957
  ctx.save();
4509
4958
  setCanvasContext(ctx, style);
@@ -4511,7 +4960,7 @@ class Path2D extends CompositeCurve {
4511
4960
  path.drawTo(ctx);
4512
4961
  });
4513
4962
  if (fill !== "none") {
4514
- ctx.fill();
4963
+ ctx.fill(fillRule);
4515
4964
  }
4516
4965
  if (stroke !== "none") {
4517
4966
  ctx.stroke();
@@ -4717,6 +5166,44 @@ ${content}
4717
5166
  }
4718
5167
  }
4719
5168
 
5169
+ class PathMeasure {
5170
+ constructor(curve) {
5171
+ this.curve = curve;
5172
+ }
5173
+ /** Total arc length of the path. */
5174
+ getLength() {
5175
+ return this.curve.getLength();
5176
+ }
5177
+ /** Whether the path forms a closed loop (see {@link Curve.isClosed}). */
5178
+ isClosed() {
5179
+ return this.curve.isClosed();
5180
+ }
5181
+ /** Point + unit tangent + tangent angle at an absolute arc-length `distance` (clamped). */
5182
+ getPosTan(distance) {
5183
+ return this.curve.getPosTan(distance);
5184
+ }
5185
+ /** Point at an absolute arc-length `distance` (clamped to `[0, getLength()]`). */
5186
+ getPosition(distance) {
5187
+ return this.curve.getPosTan(distance).position;
5188
+ }
5189
+ /** Point + tangent at a normalized progress `t ∈ [0, 1]` along the path. */
5190
+ getPosTanAtProgress(t) {
5191
+ return this.curve.getPosTan(this.getLength() * t);
5192
+ }
5193
+ /**
5194
+ * Evenly sample the path into `count + 1` {@link PosTan} entries (arc-length spaced), e.g. to
5195
+ * lay glyphs along a path or drive an `animate(progress)`-style traversal.
5196
+ */
5197
+ sample(count = 100) {
5198
+ const length = this.getLength();
5199
+ const out = [];
5200
+ for (let i = 0; i <= count; i++) {
5201
+ out.push(this.curve.getPosTan(length * i / count));
5202
+ }
5203
+ return out;
5204
+ }
5205
+ }
5206
+
4720
5207
  class FFDControlGrid {
4721
5208
  constructor(rows, cols, width = 1, height = 1) {
4722
5209
  this.rows = rows;
@@ -4772,4 +5259,4 @@ function applyFFD(point, grid, width = grid.width, height = grid.height) {
4772
5259
  point.set(x, y);
4773
5260
  }
4774
5261
 
4775
- 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 };
5262
+ export { ArcCurve, BoundingBox, CompositeCurve, CubicBezierCurve, Curve, CurvePath, EllipseCurve, EquilateralPolygonCurve, FFDControlGrid, LineCurve, PI, PI_2, Path2D, Path2DSet, PathMeasure, 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, polygonBoolean, quadraticBezier, resolveLineStyle, setCanvasContext, strokeTriangulate, svgPathCommandsAddToPath2D, svgPathCommandsToData, svgPathDataToCommands, svgToDom, svgToPath2DSet, toKebabCase };