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.cjs CHANGED
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const earcut = require('earcut');
4
+ const polygonClipping = require('polygon-clipping');
4
5
 
5
6
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
6
7
 
7
8
  const earcut__default = /*#__PURE__*/_interopDefaultCompat(earcut);
9
+ const polygonClipping__default = /*#__PURE__*/_interopDefaultCompat(polygonClipping);
8
10
 
9
11
  function drawPoint(ctx, x, y, options = {}) {
10
12
  const { radius = 1 } = options;
@@ -126,7 +128,10 @@ class Vector2 {
126
128
  return this.set(this._x * x, this._y * y);
127
129
  }
128
130
  divide(x = 0, y = x) {
129
- return this.set(this._x / x, this._y / y);
131
+ return this.set(
132
+ x === 0 ? this._x : this._x / x,
133
+ y === 0 ? this._y : this._y / y
134
+ );
130
135
  }
131
136
  cross(p) {
132
137
  return this._x * p.y - this._y * p.x;
@@ -300,6 +305,63 @@ class BoundingBox {
300
305
  }
301
306
  }
302
307
 
308
+ function flatRingToPairs(r) {
309
+ const ring = [];
310
+ for (let i = 0; i < r.length; i += 2) {
311
+ ring.push([r[i], r[i + 1]]);
312
+ }
313
+ const first = ring[0];
314
+ const last = ring[ring.length - 1];
315
+ if (first && last && (first[0] !== last[0] || first[1] !== last[1])) {
316
+ ring.push([first[0], first[1]]);
317
+ }
318
+ return ring;
319
+ }
320
+ function ringsToGeom(rings) {
321
+ const valid = rings.filter((r) => r.length >= 6).map(flatRingToPairs);
322
+ if (!valid.length) {
323
+ return [];
324
+ }
325
+ let geom = [[valid[0]]];
326
+ for (let i = 1; i < valid.length; i++) {
327
+ geom = polygonClipping__default.xor(geom, [[valid[i]]]);
328
+ }
329
+ return geom;
330
+ }
331
+ function geomToRings(geom) {
332
+ const out = [];
333
+ for (const poly of geom) {
334
+ for (const ring of poly) {
335
+ const flat = [];
336
+ for (const [x, y] of ring) {
337
+ flat.push(x, y);
338
+ }
339
+ out.push(flat);
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ function polygonBoolean(op, ringsA, ringsB) {
345
+ const a = ringsToGeom(ringsA);
346
+ const b = ringsToGeom(ringsB);
347
+ let res;
348
+ switch (op) {
349
+ case "union":
350
+ res = polygonClipping__default.union(a, b);
351
+ break;
352
+ case "intersection":
353
+ res = polygonClipping__default.intersection(a, b);
354
+ break;
355
+ case "difference":
356
+ res = polygonClipping__default.difference(a, b);
357
+ break;
358
+ case "xor":
359
+ res = polygonClipping__default.xor(a, b);
360
+ break;
361
+ }
362
+ return geomToRings(res);
363
+ }
364
+
303
365
  function catmullRom(t, p0, p1, p2, p3) {
304
366
  const v0 = (p2 - p0) * 0.5;
305
367
  const v1 = (p3 - p1) * 0.5;
@@ -319,17 +381,11 @@ function getIntersectionPoint(p1, p2, q1, q2) {
319
381
  const q1p1 = q1.clone().sub(p1);
320
382
  const crossRS = r.cross(s);
321
383
  if (crossRS === 0) {
322
- return new Vector2(
323
- (p1.x + q1.x) / 2,
324
- (p1.y + q1.y) / 2
325
- );
384
+ return null;
326
385
  }
327
386
  const t = q1p1.cross(s) / crossRS;
328
387
  if (Math.abs(t) > 1) {
329
- return new Vector2(
330
- (p1.x + q1.x) / 2,
331
- (p1.y + q1.y) / 2
332
- );
388
+ return null;
333
389
  }
334
390
  return new Vector2(
335
391
  p1.x + t * r.x,
@@ -621,11 +677,16 @@ function distance(p1, p2) {
621
677
  const dy = p2[1] - p1[1];
622
678
  return Math.sqrt(dx * dx + dy * dy);
623
679
  }
680
+ function aabbIntersects(a, b) {
681
+ return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
682
+ }
624
683
  function nonzeroFillRule(paths) {
625
684
  const results = paths.map((_, i) => ({ index: i }));
626
- const testPointsGroups = paths.map((path) => {
685
+ const bboxes = [];
686
+ const testPointsGroups = paths.map((path, pathIndex) => {
627
687
  const len = path.length;
628
688
  if (!len) {
689
+ bboxes[pathIndex] = null;
629
690
  return [];
630
691
  }
631
692
  let xMinYAuto = [Number.MAX_SAFE_INTEGER, 0];
@@ -648,6 +709,12 @@ function nonzeroFillRule(paths) {
648
709
  xAutoYMax = [x, y];
649
710
  }
650
711
  }
712
+ bboxes[pathIndex] = {
713
+ minX: xMinYAuto[0],
714
+ minY: xAutoYMin[1],
715
+ maxX: xMaxYAuto[0],
716
+ maxY: xAutoYMax[1]
717
+ };
651
718
  const mid = [
652
719
  (xMinYAuto[0] + xMaxYAuto[0]) / 2,
653
720
  (xAutoYMin[1] + xAutoYMax[1]) / 2
@@ -696,9 +763,14 @@ function nonzeroFillRule(paths) {
696
763
  for (let i = 0, len = paths.length; i < len; i++) {
697
764
  const _results = [];
698
765
  const testPoints = testPointsGroups[i];
766
+ const boxI = bboxes[i];
699
767
  for (let j = 0; j < len; j++) {
700
768
  if (i === j)
701
769
  continue;
770
+ const boxJ = bboxes[j];
771
+ if (!boxI || !boxJ || !aabbIntersects(boxI, boxJ)) {
772
+ continue;
773
+ }
702
774
  const wnMap = {};
703
775
  const wnList = [];
704
776
  for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
@@ -854,22 +926,35 @@ function quadraticBezier(t, p0, p1, p2) {
854
926
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
855
927
  }
856
928
 
929
+ function resolveLineJoin(join) {
930
+ switch (join) {
931
+ case "round":
932
+ case "bevel":
933
+ case "miter":
934
+ return join;
935
+ default:
936
+ return "miter";
937
+ }
938
+ }
939
+ function resolveLineStyle(style) {
940
+ return {
941
+ width: style?.strokeWidth ?? 1,
942
+ alignment: 0.5,
943
+ join: resolveLineJoin(style?.strokeLinejoin),
944
+ cap: style?.strokeLinecap ?? "butt",
945
+ miterLimit: style?.strokeMiterlimit ?? 10
946
+ };
947
+ }
857
948
  const closePointEps = 1e-4;
858
949
  const curveEps = 1e-4;
859
950
  function strokeTriangulate(points, options = {}) {
860
951
  const {
861
952
  vertices = [],
862
953
  indices = [],
863
- lineStyle = {
864
- alignment: 0.5,
865
- cap: "butt",
866
- join: "miter",
867
- width: 1,
868
- miterLimit: 10
869
- },
870
954
  flipAlignment = false,
871
955
  closed = true
872
956
  } = options;
957
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
873
958
  const eps = closePointEps;
874
959
  if (points.length === 0) {
875
960
  return { vertices, indices };
@@ -2653,6 +2738,41 @@ function svgToPath2DSet(svg) {
2653
2738
  class Curve {
2654
2739
  arcLengthDivision = 200;
2655
2740
  _lengths = [];
2741
+ _adaptiveCache;
2742
+ /**
2743
+ * Parent composite, set lazily when a composite caches its children. Lets
2744
+ * {@link invalidate} propagate up so an ancestor's caches refresh too.
2745
+ */
2746
+ _owner;
2747
+ _invalidating = false;
2748
+ /**
2749
+ * Drop cached arc lengths and the cached sampled outline used by hit testing, then
2750
+ * bubble up to {@link _owner}. Called automatically by {@link applyTransform} and the
2751
+ * `Path2D` mutators; call it manually after mutating control-point coordinates in place —
2752
+ * the caches cannot observe such mutations.
2753
+ */
2754
+ invalidate() {
2755
+ if (this._invalidating) {
2756
+ return this;
2757
+ }
2758
+ this._invalidating = true;
2759
+ this._invalidateSelf();
2760
+ this._owner?.invalidate();
2761
+ this._invalidating = false;
2762
+ return this;
2763
+ }
2764
+ /** Clears this curve's own caches. Composites also clear their children (see override). */
2765
+ _invalidateSelf() {
2766
+ this._lengths.length = 0;
2767
+ this._adaptiveCache = void 0;
2768
+ }
2769
+ /**
2770
+ * Sampled outline cached for repeated hit tests (read-only — do not mutate the result).
2771
+ * Invalidated by {@link invalidate}.
2772
+ */
2773
+ _getCachedAdaptiveVertices() {
2774
+ return this._adaptiveCache ??= this.getAdaptiveVertices();
2775
+ }
2656
2776
  getPointAt(u, output = new Vector2()) {
2657
2777
  return this.getPoint(this.getUToTMapping(u), output);
2658
2778
  }
@@ -2662,6 +2782,22 @@ class Curve {
2662
2782
  getControlPointRefs() {
2663
2783
  return [];
2664
2784
  }
2785
+ /**
2786
+ * Reverse the traversal direction in place (start ↔ end, same geometry). The base
2787
+ * implementation reverses the order of the control-point *values*, which is correct for
2788
+ * line / Bézier / spline primitives whose {@link getControlPointRefs} order matches their
2789
+ * parametric order. {@link RoundCurve} (angle-based) and composites (child order) override it.
2790
+ */
2791
+ reverse() {
2792
+ const refs = this.getControlPointRefs();
2793
+ const n = refs.length;
2794
+ const snapshot = refs.map((p) => p.clone());
2795
+ for (let i = 0; i < n; i++) {
2796
+ refs[i].copyFrom(snapshot[n - 1 - i]);
2797
+ }
2798
+ this.invalidate();
2799
+ return this;
2800
+ }
2665
2801
  applyTransform(transform) {
2666
2802
  const isFunction = typeof transform === "function";
2667
2803
  this.getControlPointRefs().forEach((p) => {
@@ -2671,6 +2807,7 @@ class Curve {
2671
2807
  transform.apply(p, p);
2672
2808
  }
2673
2809
  });
2810
+ this.invalidate();
2674
2811
  return this;
2675
2812
  }
2676
2813
  getUnevenVertices(count = 5, output = []) {
@@ -2788,6 +2925,22 @@ class Curve {
2788
2925
  getTangentAt(u, output) {
2789
2926
  return this.getTangent(this.getUToTMapping(u), output);
2790
2927
  }
2928
+ /**
2929
+ * PathKit-style sample at an absolute arc-length `distance` along the curve: the point, the unit
2930
+ * tangent, and the tangent `angle` in radians. `distance` is clamped to `[0, getLength()]`, so
2931
+ * passing `0`/`getLength()` always yields the endpoints. See {@link PathMeasure} for a wrapper.
2932
+ */
2933
+ getPosTan(distance) {
2934
+ const length = this.getLength();
2935
+ const u = length > 0 ? Math.min(Math.max(distance / length, 0), 1) : 0;
2936
+ const t = this.getUToTMapping(u);
2937
+ const tangent = this.getTangent(t);
2938
+ return {
2939
+ position: this.getPoint(t),
2940
+ tangent,
2941
+ angle: Math.atan2(tangent.y, tangent.x)
2942
+ };
2943
+ }
2791
2944
  getNormal(t, output = new Vector2()) {
2792
2945
  this.getTangent(t, output);
2793
2946
  return output.set(-output.y, output.x).normalize();
@@ -2815,12 +2968,25 @@ class Curve {
2815
2968
  return mid;
2816
2969
  }
2817
2970
  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);
2971
+ const vertices = this.getAdaptiveVertices();
2972
+ let minX = min.x;
2973
+ let minY = min.y;
2974
+ let maxX = max.x;
2975
+ let maxY = max.y;
2976
+ for (let i = 0, len = vertices.length; i < len; i += 2) {
2977
+ const x = vertices[i];
2978
+ const y = vertices[i + 1];
2979
+ if (x < minX)
2980
+ minX = x;
2981
+ if (y < minY)
2982
+ minY = y;
2983
+ if (x > maxX)
2984
+ maxX = x;
2985
+ if (y > maxY)
2986
+ maxY = y;
2823
2987
  }
2988
+ min.set(minX, minY);
2989
+ max.set(maxX, maxY);
2824
2990
  return { min: min.finite(), max: max.finite() };
2825
2991
  }
2826
2992
  getBoundingBox() {
@@ -2838,7 +3004,7 @@ class Curve {
2838
3004
  * are honored — a single `Curve` is always one ring.
2839
3005
  */
2840
3006
  isPointInFill(point, options = {}) {
2841
- return pointInPolygon(point, this.getAdaptiveVertices(), options.fillRule);
3007
+ return pointInPolygon(point, this._getCachedAdaptiveVertices(), options.fillRule);
2842
3008
  }
2843
3009
  /**
2844
3010
  * Test whether a point lies on this curve's stroke, i.e. within `strokeWidth / 2 + tolerance`
@@ -2851,7 +3017,7 @@ class Curve {
2851
3017
  */
2852
3018
  isPointInStroke(point, options = {}) {
2853
3019
  const { strokeWidth = 1, tolerance = 0, closed = false } = options;
2854
- const distance = pointToPolylineDistance(point, this.getAdaptiveVertices(), closed);
3020
+ const distance = pointToPolylineDistance(point, this._getCachedAdaptiveVertices(), closed);
2855
3021
  return distance <= strokeWidth / 2 + tolerance;
2856
3022
  }
2857
3023
  /**
@@ -2870,10 +3036,28 @@ class Curve {
2870
3036
  options
2871
3037
  );
2872
3038
  }
3039
+ /**
3040
+ * Whether this curve forms a closed loop (its outline should be stroked without end caps,
3041
+ * stitching the last vertex back to the first). The base test is purely geometric — the first
3042
+ * sampled vertex coincides with the last. Curves that close without a duplicated endpoint
3043
+ * (a full-revolution {@link RoundCurve}, rectangles, polygons) override this.
3044
+ */
3045
+ isClosed() {
3046
+ const v = this._getCachedAdaptiveVertices();
3047
+ const len = v.length;
3048
+ if (len < 6) {
3049
+ return false;
3050
+ }
3051
+ const eps = 1e-4;
3052
+ return Math.abs(v[0] - v[len - 2]) < eps && Math.abs(v[1] - v[len - 1]) < eps;
3053
+ }
2873
3054
  strokeTriangulate(options) {
2874
3055
  return strokeTriangulate(
2875
3056
  this.getAdaptiveVertices(),
2876
- options
3057
+ {
3058
+ ...options,
3059
+ closed: options?.closed ?? this.isClosed()
3060
+ }
2877
3061
  );
2878
3062
  }
2879
3063
  toCommands() {
@@ -2968,6 +3152,22 @@ class RoundCurve extends Curve {
2968
3152
  isClockwise() {
2969
3153
  return this.clockwise;
2970
3154
  }
3155
+ /**
3156
+ * A circle/ellipse arc is closed when it sweeps (at least) a full revolution — the sampled
3157
+ * outline does not duplicate the start vertex, so the geometric first==last test in the base
3158
+ * class would wrongly report a full circle as open and leave a seam gap in the stroke.
3159
+ */
3160
+ isClosed() {
3161
+ return Math.abs(this.endAngle - this.startAngle) >= Math.PI * 2 - 1e-9 || super.isClosed();
3162
+ }
3163
+ reverse() {
3164
+ const { startAngle, endAngle } = this;
3165
+ this.startAngle = endAngle;
3166
+ this.endAngle = startAngle;
3167
+ this.clockwise = !this.clockwise;
3168
+ this.invalidate();
3169
+ return this;
3170
+ }
2971
3171
  _getDeltaAngle() {
2972
3172
  const PI_2 = Math.PI * 2;
2973
3173
  let deltaAngle = this.endAngle - this.startAngle;
@@ -2995,6 +3195,64 @@ class RoundCurve extends Curve {
2995
3195
  }
2996
3196
  return output.set(_x, _y);
2997
3197
  }
3198
+ /**
3199
+ * Point on the ellipse at an absolute angle (mirrors {@link getPoint}'s parameterization,
3200
+ * ignoring `_diff`).
3201
+ */
3202
+ _pointAtAngle(angle, output) {
3203
+ let x = this.cx + this.rx * Math.cos(angle);
3204
+ let y = this.cy + this.ry * Math.sin(angle);
3205
+ if (this.rotate !== 0) {
3206
+ const cos = Math.cos(this.rotate);
3207
+ const sin = Math.sin(this.rotate);
3208
+ const tx = x - this.cx;
3209
+ const ty = y - this.cy;
3210
+ x = tx * cos - ty * sin + this.cx;
3211
+ y = tx * sin + ty * cos + this.cy;
3212
+ }
3213
+ return output.set(x, y);
3214
+ }
3215
+ /**
3216
+ * Analytical bounds of the (elliptical) arc: the start/end points plus the per-axis
3217
+ * extrema angles that fall within the swept interval. Matches {@link getPoint}, so it is
3218
+ * exact for `ArcCurve`/`EllipseCurve`. The `_diff` offset (used only by the legacy
3219
+ * `_getAdaptiveVerticesByCircle` path) is intentionally ignored here.
3220
+ */
3221
+ getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
3222
+ const { startAngle, rotate } = this;
3223
+ const delta = this._getDeltaAngle();
3224
+ const cosT = Math.cos(rotate);
3225
+ const sinT = Math.sin(rotate);
3226
+ const p = tempV2;
3227
+ let minX = min.x;
3228
+ let minY = min.y;
3229
+ let maxX = max.x;
3230
+ let maxY = max.y;
3231
+ const consider = (angle) => {
3232
+ this._pointAtAngle(angle, p);
3233
+ if (p.x < minX)
3234
+ minX = p.x;
3235
+ if (p.y < minY)
3236
+ minY = p.y;
3237
+ if (p.x > maxX)
3238
+ maxX = p.x;
3239
+ if (p.y > maxY)
3240
+ maxY = p.y;
3241
+ };
3242
+ consider(startAngle);
3243
+ consider(startAngle + delta);
3244
+ const ax = Math.atan2(-this.ry * sinT, this.rx * cosT);
3245
+ const ay = Math.atan2(this.ry * cosT, this.rx * sinT);
3246
+ const bases = [ax, ax + Math.PI, ay, ay + Math.PI];
3247
+ for (let i = 0; i < 4; i++) {
3248
+ if (angleInSweep(bases[i], startAngle, delta)) {
3249
+ consider(bases[i]);
3250
+ }
3251
+ }
3252
+ min.set(minX, minY);
3253
+ max.set(maxX, maxY);
3254
+ return { min: min.finite(), max: max.finite() };
3255
+ }
2998
3256
  toCommands() {
2999
3257
  const { cx, cy, rx, ry, startAngle, endAngle, clockwise, rotate } = this;
3000
3258
  const startX = cx + rx * Math.cos(startAngle) * Math.cos(rotate) - ry * Math.sin(startAngle) * Math.sin(rotate);
@@ -3045,6 +3303,7 @@ class RoundCurve extends Curve {
3045
3303
  } else {
3046
3304
  transfEllipseNoSkew(this, transform);
3047
3305
  }
3306
+ this.invalidate();
3048
3307
  return this;
3049
3308
  }
3050
3309
  getControlPointRefs() {
@@ -3165,9 +3424,25 @@ class RoundCurve extends Curve {
3165
3424
  return output;
3166
3425
  }
3167
3426
  getAdaptiveVertices(output = []) {
3168
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3427
+ const PI2 = Math.PI * 2;
3428
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3169
3429
  return this._getAdaptiveVerticesByCircle(output);
3170
3430
  }
3431
+ if (Math.abs(this.endAngle - this.startAngle) >= PI2 - 1e-9) {
3432
+ const tmp = this._getAdaptiveVerticesByCircle([]);
3433
+ const n = tmp.length / 2;
3434
+ if (this.endAngle > this.startAngle) {
3435
+ for (let i = 0; i < tmp.length; i++) {
3436
+ output.push(tmp[i]);
3437
+ }
3438
+ } else {
3439
+ output.push(tmp[0], tmp[1]);
3440
+ for (let i = n - 1; i >= 1; i--) {
3441
+ output.push(tmp[i * 2], tmp[i * 2 + 1]);
3442
+ }
3443
+ }
3444
+ return output;
3445
+ }
3171
3446
  return this._getAdaptiveVerticesByArc(output);
3172
3447
  }
3173
3448
  copyFrom(source) {
@@ -3185,6 +3460,23 @@ class RoundCurve extends Curve {
3185
3460
  return this;
3186
3461
  }
3187
3462
  }
3463
+ function angleInSweep(a, start, delta) {
3464
+ const PI_2 = Math.PI * 2;
3465
+ const eps = 1e-9;
3466
+ if (Math.abs(delta) >= PI_2 - eps) {
3467
+ return true;
3468
+ }
3469
+ let off = (a - start) % PI_2;
3470
+ if (delta >= 0) {
3471
+ if (off < -eps)
3472
+ off += PI_2;
3473
+ return off >= -eps && off <= delta + eps;
3474
+ }
3475
+ if (off > eps) {
3476
+ off -= PI_2;
3477
+ }
3478
+ return off <= eps && off >= delta - eps;
3479
+ }
3188
3480
  function transfEllipseGeneric(curve, m) {
3189
3481
  const a = curve.rx;
3190
3482
  const b = curve.ry;
@@ -3360,6 +3652,15 @@ class LineCurve extends Curve {
3360
3652
  getControlPointRefs() {
3361
3653
  return [this.p1, this.p2];
3362
3654
  }
3655
+ // Swap endpoint *references* (not values) so corner Vector2s shared with adjacent
3656
+ // segments stay intact and simply re-associate with the reversed segment.
3657
+ reverse() {
3658
+ const { p1, p2 } = this;
3659
+ this.p1 = p2;
3660
+ this.p2 = p1;
3661
+ this.invalidate();
3662
+ return this;
3663
+ }
3363
3664
  getAdaptiveVertices(output = []) {
3364
3665
  output.push(
3365
3666
  this.p1.x,
@@ -3423,6 +3724,22 @@ class CompositeCurve extends Curve {
3423
3724
  super();
3424
3725
  this.curves = curves;
3425
3726
  }
3727
+ _adaptiveCacheLen = -1;
3728
+ _invalidateSelf() {
3729
+ super._invalidateSelf();
3730
+ this._adaptiveCacheLen = -1;
3731
+ this.curves.forEach((curve) => curve.invalidate());
3732
+ }
3733
+ _getCachedAdaptiveVertices() {
3734
+ if (!this._adaptiveCache || this._adaptiveCacheLen !== this.curves.length) {
3735
+ this.curves.forEach((curve) => {
3736
+ curve._owner = this;
3737
+ });
3738
+ this._adaptiveCache = this.getAdaptiveVertices();
3739
+ this._adaptiveCacheLen = this.curves.length;
3740
+ }
3741
+ return this._adaptiveCache;
3742
+ }
3426
3743
  getFlatCurves() {
3427
3744
  return this.curves.flatMap((curve) => {
3428
3745
  if (curve instanceof CompositeCurve) {
@@ -3468,6 +3785,7 @@ class CompositeCurve extends Curve {
3468
3785
  updateLengths() {
3469
3786
  const lengths = [];
3470
3787
  for (let i = 0, sum = 0, len = this.curves.length; i < len; i++) {
3788
+ this.curves[i]._owner = this;
3471
3789
  sum += this.curves[i].getLength();
3472
3790
  lengths.push(sum);
3473
3791
  }
@@ -3506,6 +3824,16 @@ class CompositeCurve extends Curve {
3506
3824
  });
3507
3825
  return output;
3508
3826
  }
3827
+ /**
3828
+ * A composite is closed when its single child is closed (e.g. a lone full-circle arc), or when
3829
+ * its assembled outline returns to its start (rectangles, polygons, multi-segment loops).
3830
+ */
3831
+ isClosed() {
3832
+ if (this.curves.length === 1) {
3833
+ return this.curves[0].isClosed();
3834
+ }
3835
+ return super.isClosed();
3836
+ }
3509
3837
  strokeTriangulate(options) {
3510
3838
  if (this.curves.length === 1) {
3511
3839
  return this.curves[0].strokeTriangulate(options);
@@ -3513,6 +3841,13 @@ class CompositeCurve extends Curve {
3513
3841
  return super.strokeTriangulate(options);
3514
3842
  }
3515
3843
  }
3844
+ /** Reverse the sub-curve order and reverse each sub-curve, so the whole outline runs backwards. */
3845
+ reverse() {
3846
+ this.curves.reverse();
3847
+ this.curves.forEach((curve) => curve.reverse());
3848
+ this.invalidate();
3849
+ return this;
3850
+ }
3516
3851
  getFillVertices(options) {
3517
3852
  if (this.curves.length === 1) {
3518
3853
  return this.curves[0].getFillVertices(options);
@@ -3536,7 +3871,10 @@ class CompositeCurve extends Curve {
3536
3871
  }
3537
3872
  }
3538
3873
  applyTransform(transform) {
3874
+ this._invalidating = true;
3539
3875
  this.curves.forEach((curve) => curve.applyTransform(transform));
3876
+ this._invalidating = false;
3877
+ this.invalidate();
3540
3878
  return this;
3541
3879
  }
3542
3880
  getMinMax(min = Vector2.MAX, max = Vector2.MIN) {
@@ -3605,6 +3943,16 @@ class CubicBezierCurve extends Curve {
3605
3943
  getControlPointRefs() {
3606
3944
  return [this.p1, this.cp1, this.cp2, this.p2];
3607
3945
  }
3946
+ // Swap endpoint and control-point references; keeps shared corner Vector2s intact.
3947
+ reverse() {
3948
+ const { p1, cp1, cp2, p2 } = this;
3949
+ this.p1 = p2;
3950
+ this.cp1 = cp2;
3951
+ this.cp2 = cp1;
3952
+ this.p2 = p1;
3953
+ this.invalidate();
3954
+ return this;
3955
+ }
3608
3956
  _solveQuadratic(a, b, c) {
3609
3957
  if (Math.abs(a) < 1e-12) {
3610
3958
  if (Math.abs(b) < 1e-12)
@@ -3765,6 +4113,14 @@ class QuadraticBezierCurve extends Curve {
3765
4113
  getControlPointRefs() {
3766
4114
  return [this.p1, this.cp, this.p2];
3767
4115
  }
4116
+ // Swap endpoint references (cp is symmetric); keeps shared corner Vector2s intact.
4117
+ reverse() {
4118
+ const { p1, p2 } = this;
4119
+ this.p1 = p2;
4120
+ this.p2 = p1;
4121
+ this.invalidate();
4122
+ return this;
4123
+ }
3768
4124
  getAdaptiveVertices(output = []) {
3769
4125
  return getAdaptiveQuadraticBezierCurvePoints(
3770
4126
  this.p1.x,
@@ -3875,7 +4231,7 @@ class RectangleCurve extends PolygonCurve {
3875
4231
  }
3876
4232
  }
3877
4233
 
3878
- class RoundRectangleCurve extends RoundCurve {
4234
+ class RoundRectangleCurve extends CompositeCurve {
3879
4235
  constructor(x = 0, y = 0, width = 1, height = 1, radius = 1) {
3880
4236
  super();
3881
4237
  this.x = x;
@@ -3886,25 +4242,53 @@ class RoundRectangleCurve extends RoundCurve {
3886
4242
  this.update();
3887
4243
  }
3888
4244
  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);
4245
+ const { x, y, width, height } = this;
4246
+ const r = Math.max(0, Math.min(this.radius, Math.abs(width) / 2, Math.abs(height) / 2));
4247
+ const x0 = x;
4248
+ const x1 = x + r;
4249
+ const x2 = x + width - r;
4250
+ const x3 = x + width;
4251
+ const y0 = y;
4252
+ const y1 = y + r;
4253
+ const y2 = y + height - r;
4254
+ const y3 = y + height;
4255
+ if (r <= 0) {
4256
+ this.curves = [
4257
+ LineCurve.from(x0, y0, x3, y0),
4258
+ LineCurve.from(x3, y0, x3, y3),
4259
+ LineCurve.from(x3, y3, x0, y3),
4260
+ LineCurve.from(x0, y3, x0, y0)
4261
+ ];
4262
+ } else {
4263
+ const HALF_PI = Math.PI / 2;
4264
+ this.curves = [
4265
+ LineCurve.from(x1, y0, x2, y0),
4266
+ // top edge
4267
+ new ArcCurve(x2, y1, r, -HALF_PI, 0, true),
4268
+ // top-right corner
4269
+ LineCurve.from(x3, y1, x3, y2),
4270
+ // right edge
4271
+ new ArcCurve(x2, y2, r, 0, HALF_PI, true),
4272
+ // bottom-right corner
4273
+ LineCurve.from(x2, y3, x1, y3),
4274
+ // bottom edge
4275
+ new ArcCurve(x1, y2, r, HALF_PI, Math.PI, true),
4276
+ // bottom-left corner
4277
+ LineCurve.from(x0, y2, x0, y1),
4278
+ // left edge
4279
+ new ArcCurve(x1, y1, r, Math.PI, Math.PI * 1.5, true)
4280
+ // top-left corner
4281
+ ];
4282
+ }
4283
+ this.invalidate();
3899
4284
  return this;
3900
4285
  }
3901
4286
  drawTo(ctx) {
3902
- const { x, y, width, height, radius } = this;
3903
- ctx.roundRect(x, y, width, height, radius);
4287
+ ctx.roundRect(this.x, this.y, this.width, this.height, this.radius);
3904
4288
  return this;
3905
4289
  }
3906
4290
  copyFrom(source) {
3907
- super.copyFrom(source);
4291
+ this.arcLengthDivision = source.arcLengthDivision;
3908
4292
  this.x = source.x;
3909
4293
  this.y = source.y;
3910
4294
  this.width = source.width;
@@ -3938,6 +4322,11 @@ class SplineCurve extends Curve {
3938
4322
  getControlPointRefs() {
3939
4323
  return this.points;
3940
4324
  }
4325
+ reverse() {
4326
+ this.points.reverse();
4327
+ this.invalidate();
4328
+ return this;
4329
+ }
3941
4330
  copyFrom(source) {
3942
4331
  super.copyFrom(source);
3943
4332
  this.points = [];
@@ -3974,6 +4363,22 @@ class CurvePath extends CompositeCurve {
3974
4363
  this.addCommands(svgPathDataToCommands(data));
3975
4364
  return this;
3976
4365
  }
4366
+ /**
4367
+ * A sub-path is closed if it was explicitly closed (`autoClose`, i.e. a `Z`/`closePath`), or if
4368
+ * it forms a geometric loop / wraps a single closed primitive (handled by the base class).
4369
+ */
4370
+ isClosed() {
4371
+ return this.autoClose || super.isClosed();
4372
+ }
4373
+ /** Reverse direction, then refresh the {@link startPoint}/{@link currentPoint} cursors. */
4374
+ reverse() {
4375
+ super.reverse();
4376
+ if (this.curves.length) {
4377
+ this.startPoint = this.getPoint(0);
4378
+ this.currentPoint = this.getPoint(1);
4379
+ }
4380
+ return this;
4381
+ }
3977
4382
  _closeVertices(output) {
3978
4383
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
3979
4384
  output.push(output[0], output[1]);
@@ -4006,7 +4411,7 @@ class CurvePath extends CompositeCurve {
4006
4411
  */
4007
4412
  isPointInStroke(point, options = {}) {
4008
4413
  const { strokeWidth = 1, tolerance = 0 } = options;
4009
- const vertices = this.getAdaptiveVertices();
4414
+ const vertices = this._getCachedAdaptiveVertices();
4010
4415
  const len = vertices.length;
4011
4416
  const closed = options.closed ?? (this.autoClose || len >= 6 && vertices[0] === vertices[len - 2] && vertices[1] === vertices[len - 1]);
4012
4417
  return pointToPolylineDistance(point, vertices, closed) <= strokeWidth / 2 + tolerance;
@@ -4184,6 +4589,8 @@ class CurvePath extends CompositeCurve {
4184
4589
 
4185
4590
  class Path2D extends CompositeCurve {
4186
4591
  _meta;
4592
+ _ringsCache;
4593
+ _ringsCacheLen = -1;
4187
4594
  currentCurve = new CurvePath();
4188
4595
  style;
4189
4596
  get startPoint() {
@@ -4302,18 +4709,21 @@ class Path2D extends CompositeCurve {
4302
4709
  this.getControlPointRefs().forEach((point) => {
4303
4710
  point.scale(sx, sy, target);
4304
4711
  });
4712
+ this.invalidate();
4305
4713
  return this;
4306
4714
  }
4307
4715
  skew(ax, ay = 0, target = { x: 0, y: 0 }) {
4308
4716
  this.getControlPointRefs().forEach((point) => {
4309
4717
  point.skew(ax, ay, target);
4310
4718
  });
4719
+ this.invalidate();
4311
4720
  return this;
4312
4721
  }
4313
4722
  rotate(rad, target = { x: 0, y: 0 }) {
4314
4723
  this.getControlPointRefs().forEach((point) => {
4315
4724
  point.rotate(rad, target);
4316
4725
  });
4726
+ this.invalidate();
4317
4727
  return this;
4318
4728
  }
4319
4729
  bold(b) {
@@ -4372,6 +4782,7 @@ class Path2D extends CompositeCurve {
4372
4782
  }
4373
4783
  });
4374
4784
  });
4785
+ this.invalidate();
4375
4786
  return this;
4376
4787
  }
4377
4788
  /**
@@ -4384,13 +4795,70 @@ class Path2D extends CompositeCurve {
4384
4795
  *
4385
4796
  * Defaults `fillRule` to `style.fillRule`, then `'nonzero'` (matching SVG/Canvas).
4386
4797
  */
4798
+ _invalidateSelf() {
4799
+ super._invalidateSelf();
4800
+ this._ringsCache = void 0;
4801
+ this._ringsCacheLen = -1;
4802
+ }
4803
+ /** Per-sub-path sampled rings, cached for repeated hit tests. */
4804
+ _getRings() {
4805
+ if (!this._ringsCache || this._ringsCacheLen !== this.curves.length) {
4806
+ this._ringsCache = this.curves.map((curve) => {
4807
+ curve._owner = this;
4808
+ return curve.getAdaptiveVertices();
4809
+ });
4810
+ this._ringsCacheLen = this.curves.length;
4811
+ }
4812
+ return this._ringsCache;
4813
+ }
4387
4814
  isPointInFill(point, options = {}) {
4388
4815
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4389
- return pointInPolygons(
4390
- point,
4391
- this.curves.map((curve) => curve.getAdaptiveVertices()),
4392
- fillRule
4393
- );
4816
+ return pointInPolygons(point, this._getRings(), fillRule);
4817
+ }
4818
+ /** Build a `Path2D` from flat rings (`[x0,y0,…]` per sub-path); closed-and-filled as sub-paths. */
4819
+ static fromRings(rings, style = {}) {
4820
+ const path = new Path2D(void 0, style);
4821
+ for (const ring of rings) {
4822
+ if (ring.length < 6) {
4823
+ continue;
4824
+ }
4825
+ let end = ring.length;
4826
+ if (ring[0] === ring[end - 2] && ring[1] === ring[end - 1]) {
4827
+ end -= 2;
4828
+ }
4829
+ path.moveTo(ring[0], ring[1]);
4830
+ for (let i = 2; i < end; i += 2) {
4831
+ path.lineTo(ring[i], ring[i + 1]);
4832
+ }
4833
+ path.closePath();
4834
+ }
4835
+ return path;
4836
+ }
4837
+ /**
4838
+ * Boolean (path) operation against another path, returning a NEW `Path2D` whose outline is the
4839
+ * polygonal result. Curves are sampled before clipping, so the result is a polygonal
4840
+ * approximation (see {@link polygonBoolean}). The result inherits this path's `style` unless
4841
+ * overridden via `style`. Holes are emitted as oppositely-wound sub-paths (nonzero fill).
4842
+ */
4843
+ booleanOp(op, other, style) {
4844
+ const rings = polygonBoolean(op, this._getRings(), other._getRings());
4845
+ return Path2D.fromRings(rings, { ...this.style, ...style });
4846
+ }
4847
+ /** `this ∪ other` — the combined filled area. */
4848
+ union(other, style) {
4849
+ return this.booleanOp("union", other, style);
4850
+ }
4851
+ /** `this ∩ other` — only the overlapping area. */
4852
+ intersection(other, style) {
4853
+ return this.booleanOp("intersection", other, style);
4854
+ }
4855
+ /** `this − other` — this path with `other` cut away. */
4856
+ difference(other, style) {
4857
+ return this.booleanOp("difference", other, style);
4858
+ }
4859
+ /** `this ⊕ other` — areas covered by exactly one of the two paths. */
4860
+ xor(other, style) {
4861
+ return this.booleanOp("xor", other, style);
4394
4862
  }
4395
4863
  /**
4396
4864
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
@@ -4409,35 +4877,17 @@ class Path2D extends CompositeCurve {
4409
4877
  }));
4410
4878
  }
4411
4879
  getMinMax(min = Vector2.MAX, max = Vector2.MIN, withStyle = true) {
4412
- const strokeWidth = this.strokeWidth;
4413
4880
  this.curves.forEach((curve) => {
4414
4881
  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
4882
  });
4883
+ if (withStyle) {
4884
+ const strokeWidth = this.strokeWidth;
4885
+ if (strokeWidth > 1 && Number.isFinite(min.x)) {
4886
+ const half = strokeWidth / 2;
4887
+ min.set(min.x - half, min.y - half);
4888
+ max.set(max.x + half, max.y + half);
4889
+ }
4890
+ }
4441
4891
  return { min: min.finite(), max: max.finite() };
4442
4892
  }
4443
4893
  strokeTriangulate(options) {
@@ -4509,7 +4959,7 @@ class Path2D extends CompositeCurve {
4509
4959
  }
4510
4960
  drawTo(ctx, style = {}) {
4511
4961
  style = { ...this.style, ...style };
4512
- const { fill = "#000", stroke = "none" } = style;
4962
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4513
4963
  ctx.beginPath();
4514
4964
  ctx.save();
4515
4965
  setCanvasContext(ctx, style);
@@ -4517,7 +4967,7 @@ class Path2D extends CompositeCurve {
4517
4967
  path.drawTo(ctx);
4518
4968
  });
4519
4969
  if (fill !== "none") {
4520
- ctx.fill();
4970
+ ctx.fill(fillRule);
4521
4971
  }
4522
4972
  if (stroke !== "none") {
4523
4973
  ctx.stroke();
@@ -4723,6 +5173,44 @@ ${content}
4723
5173
  }
4724
5174
  }
4725
5175
 
5176
+ class PathMeasure {
5177
+ constructor(curve) {
5178
+ this.curve = curve;
5179
+ }
5180
+ /** Total arc length of the path. */
5181
+ getLength() {
5182
+ return this.curve.getLength();
5183
+ }
5184
+ /** Whether the path forms a closed loop (see {@link Curve.isClosed}). */
5185
+ isClosed() {
5186
+ return this.curve.isClosed();
5187
+ }
5188
+ /** Point + unit tangent + tangent angle at an absolute arc-length `distance` (clamped). */
5189
+ getPosTan(distance) {
5190
+ return this.curve.getPosTan(distance);
5191
+ }
5192
+ /** Point at an absolute arc-length `distance` (clamped to `[0, getLength()]`). */
5193
+ getPosition(distance) {
5194
+ return this.curve.getPosTan(distance).position;
5195
+ }
5196
+ /** Point + tangent at a normalized progress `t ∈ [0, 1]` along the path. */
5197
+ getPosTanAtProgress(t) {
5198
+ return this.curve.getPosTan(this.getLength() * t);
5199
+ }
5200
+ /**
5201
+ * Evenly sample the path into `count + 1` {@link PosTan} entries (arc-length spaced), e.g. to
5202
+ * lay glyphs along a path or drive an `animate(progress)`-style traversal.
5203
+ */
5204
+ sample(count = 100) {
5205
+ const length = this.getLength();
5206
+ const out = [];
5207
+ for (let i = 0; i <= count; i++) {
5208
+ out.push(this.curve.getPosTan(length * i / count));
5209
+ }
5210
+ return out;
5211
+ }
5212
+ }
5213
+
4726
5214
  class FFDControlGrid {
4727
5215
  constructor(rows, cols, width = 1, height = 1) {
4728
5216
  this.rows = rows;
@@ -4792,6 +5280,7 @@ exports.PI = PI;
4792
5280
  exports.PI_2 = PI_2;
4793
5281
  exports.Path2D = Path2D;
4794
5282
  exports.Path2DSet = Path2DSet;
5283
+ exports.PathMeasure = PathMeasure;
4795
5284
  exports.PolygonCurve = PolygonCurve;
4796
5285
  exports.QuadraticBezierCurve = QuadraticBezierCurve;
4797
5286
  exports.RectangleCurve = RectangleCurve;
@@ -4818,7 +5307,9 @@ exports.pointInPolygon = pointInPolygon;
4818
5307
  exports.pointInPolygons = pointInPolygons;
4819
5308
  exports.pointToPolylineDistance = pointToPolylineDistance;
4820
5309
  exports.pointToSegmentDistance = pointToSegmentDistance;
5310
+ exports.polygonBoolean = polygonBoolean;
4821
5311
  exports.quadraticBezier = quadraticBezier;
5312
+ exports.resolveLineStyle = resolveLineStyle;
4822
5313
  exports.setCanvasContext = setCanvasContext;
4823
5314
  exports.strokeTriangulate = strokeTriangulate;
4824
5315
  exports.svgPathCommandsAddToPath2D = svgPathCommandsAddToPath2D;