modern-path2d 1.7.0 → 1.8.1

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;
@@ -297,6 +298,63 @@ class BoundingBox {
297
298
  }
298
299
  }
299
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
+
300
358
  function catmullRom(t, p0, p1, p2, p3) {
301
359
  const v0 = (p2 - p0) * 0.5;
302
360
  const v1 = (p3 - p1) * 0.5;
@@ -427,6 +485,195 @@ function cubicBezier(t, p0, p1, p2, p3) {
427
485
  return cubicBezierP0(t, p0) + cubicBezierP1(t, p1) + cubicBezierP2(t, p2) + cubicBezierP3(t, p3);
428
486
  }
429
487
 
488
+ function isLeft(ax, ay, bx, by, px, py) {
489
+ return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
490
+ }
491
+ function windingNumber$1(px, py, vertices) {
492
+ const len = vertices.length;
493
+ let wn = 0;
494
+ for (let i = 0; i < len; i += 2) {
495
+ const ax = vertices[i];
496
+ const ay = vertices[i + 1];
497
+ const k = (i + 2) % len;
498
+ const bx = vertices[k];
499
+ const by = vertices[k + 1];
500
+ if (ay <= py) {
501
+ if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
502
+ wn++;
503
+ }
504
+ } else {
505
+ if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
506
+ wn--;
507
+ }
508
+ }
509
+ }
510
+ return wn;
511
+ }
512
+ function crossingNumber(px, py, vertices) {
513
+ const len = vertices.length;
514
+ let cn = 0;
515
+ for (let i = 0; i < len; i += 2) {
516
+ const ax = vertices[i];
517
+ const ay = vertices[i + 1];
518
+ const k = (i + 2) % len;
519
+ const bx = vertices[k];
520
+ const by = vertices[k + 1];
521
+ if (ay <= py && by > py || ay > py && by <= py) {
522
+ const t = (py - ay) / (by - ay);
523
+ if (px < ax + t * (bx - ax)) {
524
+ cn++;
525
+ }
526
+ }
527
+ }
528
+ return cn;
529
+ }
530
+ function segmentDistance(px, py, ax, ay, bx, by) {
531
+ const dx = bx - ax;
532
+ const dy = by - ay;
533
+ const lenSq = dx * dx + dy * dy;
534
+ let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
535
+ if (t < 0) {
536
+ t = 0;
537
+ } else if (t > 1) {
538
+ t = 1;
539
+ }
540
+ const cx = ax + t * dx;
541
+ const cy = ay + t * dy;
542
+ return Math.hypot(px - cx, py - cy);
543
+ }
544
+ function pointInPolygon(point, vertices, fillRule = "nonzero") {
545
+ if (vertices.length < 6) {
546
+ return false;
547
+ }
548
+ if (fillRule === "evenodd") {
549
+ return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
550
+ }
551
+ return windingNumber$1(point.x, point.y, vertices) !== 0;
552
+ }
553
+ function pointInPolygons(point, polygons, fillRule = "nonzero") {
554
+ const { x, y } = point;
555
+ if (fillRule === "evenodd") {
556
+ let cn = 0;
557
+ for (let i = 0, len = polygons.length; i < len; i++) {
558
+ const ring = polygons[i];
559
+ if (ring.length >= 6) {
560
+ cn += crossingNumber(x, y, ring);
561
+ }
562
+ }
563
+ return (cn & 1) === 1;
564
+ }
565
+ let wn = 0;
566
+ for (let i = 0, len = polygons.length; i < len; i++) {
567
+ const ring = polygons[i];
568
+ if (ring.length >= 6) {
569
+ wn += windingNumber$1(x, y, ring);
570
+ }
571
+ }
572
+ return wn !== 0;
573
+ }
574
+ function pointToSegmentDistance(point, a, b) {
575
+ return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
576
+ }
577
+ function pointToPolylineDistance(point, vertices, closed = false) {
578
+ const len = vertices.length;
579
+ if (len < 2) {
580
+ return Infinity;
581
+ }
582
+ const { x: px, y: py } = point;
583
+ if (len === 2) {
584
+ return Math.hypot(px - vertices[0], py - vertices[1]);
585
+ }
586
+ let min = Infinity;
587
+ for (let i = 0; i < len - 2; i += 2) {
588
+ const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
589
+ if (d < min) {
590
+ min = d;
591
+ }
592
+ }
593
+ if (closed && len >= 6) {
594
+ const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
595
+ if (d < min) {
596
+ min = d;
597
+ }
598
+ }
599
+ return min;
600
+ }
601
+
602
+ function boundsOf(ring) {
603
+ if (ring.length < 6) {
604
+ return null;
605
+ }
606
+ let minX = Infinity;
607
+ let minY = Infinity;
608
+ let maxX = -Infinity;
609
+ let maxY = -Infinity;
610
+ for (let i = 0; i < ring.length; i += 2) {
611
+ const x = ring[i];
612
+ const y = ring[i + 1];
613
+ if (x < minX)
614
+ minX = x;
615
+ if (y < minY)
616
+ minY = y;
617
+ if (x > maxX)
618
+ maxX = x;
619
+ if (y > maxY)
620
+ maxY = y;
621
+ }
622
+ return { minX, minY, maxX, maxY };
623
+ }
624
+ function bboxInside(inner, outer) {
625
+ return inner.minX >= outer.minX && inner.maxX <= outer.maxX && inner.minY >= outer.minY && inner.maxY <= outer.maxY;
626
+ }
627
+ function ringInsideRing(inner, outer) {
628
+ const n = inner.length / 2;
629
+ const step = Math.max(1, Math.floor(n / 9));
630
+ let tested = 0;
631
+ let inside = 0;
632
+ for (let i = 0; i < n; i += step) {
633
+ tested++;
634
+ if (pointInPolygon({ x: inner[i * 2], y: inner[i * 2 + 1] }, outer, "evenodd")) {
635
+ inside++;
636
+ }
637
+ }
638
+ return tested > 0 && inside * 2 > tested;
639
+ }
640
+ function evenoddFillRule(paths) {
641
+ const len = paths.length;
642
+ const bboxes = paths.map(boundsOf);
643
+ const depth = Array.from({ length: len }).fill(0);
644
+ const containers = paths.map(() => []);
645
+ for (let i = 0; i < len; i++) {
646
+ const bi = bboxes[i];
647
+ if (!bi) {
648
+ continue;
649
+ }
650
+ for (let j = 0; j < len; j++) {
651
+ if (i === j) {
652
+ continue;
653
+ }
654
+ const bj = bboxes[j];
655
+ if (!bj || !bboxInside(bi, bj)) {
656
+ continue;
657
+ }
658
+ if (ringInsideRing(paths[i], paths[j])) {
659
+ depth[i]++;
660
+ containers[i].push(j);
661
+ }
662
+ }
663
+ }
664
+ return paths.map((_, i) => {
665
+ let parentIndex = -1;
666
+ let bestDepth = -1;
667
+ for (const j of containers[i]) {
668
+ if (depth[j] > bestDepth) {
669
+ bestDepth = depth[j];
670
+ parentIndex = j;
671
+ }
672
+ }
673
+ return { index: i, depth: depth[i], parentIndex };
674
+ });
675
+ }
676
+
430
677
  function fillTriangulate(pointArray, options = {}) {
431
678
  let {
432
679
  vertices = [],
@@ -589,7 +836,7 @@ function getDirectedArea(vertices) {
589
836
  function cross(ax, ay, bx, by, cx, cy) {
590
837
  return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
591
838
  }
592
- function windingNumber$1(px, py, polygon) {
839
+ function windingNumber(px, py, polygon) {
593
840
  const polygonLen = polygon.length;
594
841
  let wn = 0;
595
842
  for (let i = 0, j = polygonLen - 2; i < polygonLen; j = i, i += 2) {
@@ -710,7 +957,7 @@ function nonzeroFillRule(paths) {
710
957
  const wnList = [];
711
958
  for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
712
959
  const [x, y] = testPoints[p];
713
- const winding = windingNumber$1(x, y, paths[j]);
960
+ const winding = windingNumber(x, y, paths[j]);
714
961
  wnMap[winding] = (wnMap[winding] ?? 0) + 1;
715
962
  wnList.push(winding);
716
963
  }
@@ -733,120 +980,6 @@ function nonzeroFillRule(paths) {
733
980
  return results;
734
981
  }
735
982
 
736
- function isLeft(ax, ay, bx, by, px, py) {
737
- return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
738
- }
739
- function windingNumber(px, py, vertices) {
740
- const len = vertices.length;
741
- let wn = 0;
742
- for (let i = 0; i < len; i += 2) {
743
- const ax = vertices[i];
744
- const ay = vertices[i + 1];
745
- const k = (i + 2) % len;
746
- const bx = vertices[k];
747
- const by = vertices[k + 1];
748
- if (ay <= py) {
749
- if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
750
- wn++;
751
- }
752
- } else {
753
- if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
754
- wn--;
755
- }
756
- }
757
- }
758
- return wn;
759
- }
760
- function crossingNumber(px, py, vertices) {
761
- const len = vertices.length;
762
- let cn = 0;
763
- for (let i = 0; i < len; i += 2) {
764
- const ax = vertices[i];
765
- const ay = vertices[i + 1];
766
- const k = (i + 2) % len;
767
- const bx = vertices[k];
768
- const by = vertices[k + 1];
769
- if (ay <= py && by > py || ay > py && by <= py) {
770
- const t = (py - ay) / (by - ay);
771
- if (px < ax + t * (bx - ax)) {
772
- cn++;
773
- }
774
- }
775
- }
776
- return cn;
777
- }
778
- function segmentDistance(px, py, ax, ay, bx, by) {
779
- const dx = bx - ax;
780
- const dy = by - ay;
781
- const lenSq = dx * dx + dy * dy;
782
- let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
783
- if (t < 0) {
784
- t = 0;
785
- } else if (t > 1) {
786
- t = 1;
787
- }
788
- const cx = ax + t * dx;
789
- const cy = ay + t * dy;
790
- return Math.hypot(px - cx, py - cy);
791
- }
792
- function pointInPolygon(point, vertices, fillRule = "nonzero") {
793
- if (vertices.length < 6) {
794
- return false;
795
- }
796
- if (fillRule === "evenodd") {
797
- return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
798
- }
799
- return windingNumber(point.x, point.y, vertices) !== 0;
800
- }
801
- function pointInPolygons(point, polygons, fillRule = "nonzero") {
802
- const { x, y } = point;
803
- if (fillRule === "evenodd") {
804
- let cn = 0;
805
- for (let i = 0, len = polygons.length; i < len; i++) {
806
- const ring = polygons[i];
807
- if (ring.length >= 6) {
808
- cn += crossingNumber(x, y, ring);
809
- }
810
- }
811
- return (cn & 1) === 1;
812
- }
813
- let wn = 0;
814
- for (let i = 0, len = polygons.length; i < len; i++) {
815
- const ring = polygons[i];
816
- if (ring.length >= 6) {
817
- wn += windingNumber(x, y, ring);
818
- }
819
- }
820
- return wn !== 0;
821
- }
822
- function pointToSegmentDistance(point, a, b) {
823
- return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
824
- }
825
- function pointToPolylineDistance(point, vertices, closed = false) {
826
- const len = vertices.length;
827
- if (len < 2) {
828
- return Infinity;
829
- }
830
- const { x: px, y: py } = point;
831
- if (len === 2) {
832
- return Math.hypot(px - vertices[0], py - vertices[1]);
833
- }
834
- let min = Infinity;
835
- for (let i = 0; i < len - 2; i += 2) {
836
- const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
837
- if (d < min) {
838
- min = d;
839
- }
840
- }
841
- if (closed && len >= 6) {
842
- const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
843
- if (d < min) {
844
- min = d;
845
- }
846
- }
847
- return min;
848
- }
849
-
850
983
  function quadraticBezierP0(t, p) {
851
984
  const k = 1 - t;
852
985
  return k * k * p;
@@ -861,22 +994,35 @@ function quadraticBezier(t, p0, p1, p2) {
861
994
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
862
995
  }
863
996
 
997
+ function resolveLineJoin(join) {
998
+ switch (join) {
999
+ case "round":
1000
+ case "bevel":
1001
+ case "miter":
1002
+ return join;
1003
+ default:
1004
+ return "miter";
1005
+ }
1006
+ }
1007
+ function resolveLineStyle(style) {
1008
+ return {
1009
+ width: style?.strokeWidth ?? 1,
1010
+ alignment: 0.5,
1011
+ join: resolveLineJoin(style?.strokeLinejoin),
1012
+ cap: style?.strokeLinecap ?? "butt",
1013
+ miterLimit: style?.strokeMiterlimit ?? 10
1014
+ };
1015
+ }
864
1016
  const closePointEps = 1e-4;
865
1017
  const curveEps = 1e-4;
866
1018
  function strokeTriangulate(points, options = {}) {
867
1019
  const {
868
1020
  vertices = [],
869
1021
  indices = [],
870
- lineStyle = {
871
- alignment: 0.5,
872
- cap: "butt",
873
- join: "miter",
874
- width: 1,
875
- miterLimit: 10
876
- },
877
1022
  flipAlignment = false,
878
1023
  closed = true
879
1024
  } = options;
1025
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
880
1026
  const eps = closePointEps;
881
1027
  if (points.length === 0) {
882
1028
  return { vertices, indices };
@@ -2704,6 +2850,22 @@ class Curve {
2704
2850
  getControlPointRefs() {
2705
2851
  return [];
2706
2852
  }
2853
+ /**
2854
+ * Reverse the traversal direction in place (start ↔ end, same geometry). The base
2855
+ * implementation reverses the order of the control-point *values*, which is correct for
2856
+ * line / Bézier / spline primitives whose {@link getControlPointRefs} order matches their
2857
+ * parametric order. {@link RoundCurve} (angle-based) and composites (child order) override it.
2858
+ */
2859
+ reverse() {
2860
+ const refs = this.getControlPointRefs();
2861
+ const n = refs.length;
2862
+ const snapshot = refs.map((p) => p.clone());
2863
+ for (let i = 0; i < n; i++) {
2864
+ refs[i].copyFrom(snapshot[n - 1 - i]);
2865
+ }
2866
+ this.invalidate();
2867
+ return this;
2868
+ }
2707
2869
  applyTransform(transform) {
2708
2870
  const isFunction = typeof transform === "function";
2709
2871
  this.getControlPointRefs().forEach((p) => {
@@ -2831,6 +2993,22 @@ class Curve {
2831
2993
  getTangentAt(u, output) {
2832
2994
  return this.getTangent(this.getUToTMapping(u), output);
2833
2995
  }
2996
+ /**
2997
+ * PathKit-style sample at an absolute arc-length `distance` along the curve: the point, the unit
2998
+ * tangent, and the tangent `angle` in radians. `distance` is clamped to `[0, getLength()]`, so
2999
+ * passing `0`/`getLength()` always yields the endpoints. See {@link PathMeasure} for a wrapper.
3000
+ */
3001
+ getPosTan(distance) {
3002
+ const length = this.getLength();
3003
+ const u = length > 0 ? Math.min(Math.max(distance / length, 0), 1) : 0;
3004
+ const t = this.getUToTMapping(u);
3005
+ const tangent = this.getTangent(t);
3006
+ return {
3007
+ position: this.getPoint(t),
3008
+ tangent,
3009
+ angle: Math.atan2(tangent.y, tangent.x)
3010
+ };
3011
+ }
2834
3012
  getNormal(t, output = new Vector2()) {
2835
3013
  this.getTangent(t, output);
2836
3014
  return output.set(-output.y, output.x).normalize();
@@ -2926,10 +3104,28 @@ class Curve {
2926
3104
  options
2927
3105
  );
2928
3106
  }
3107
+ /**
3108
+ * Whether this curve forms a closed loop (its outline should be stroked without end caps,
3109
+ * stitching the last vertex back to the first). The base test is purely geometric — the first
3110
+ * sampled vertex coincides with the last. Curves that close without a duplicated endpoint
3111
+ * (a full-revolution {@link RoundCurve}, rectangles, polygons) override this.
3112
+ */
3113
+ isClosed() {
3114
+ const v = this._getCachedAdaptiveVertices();
3115
+ const len = v.length;
3116
+ if (len < 6) {
3117
+ return false;
3118
+ }
3119
+ const eps = 1e-4;
3120
+ return Math.abs(v[0] - v[len - 2]) < eps && Math.abs(v[1] - v[len - 1]) < eps;
3121
+ }
2929
3122
  strokeTriangulate(options) {
2930
3123
  return strokeTriangulate(
2931
3124
  this.getAdaptiveVertices(),
2932
- options
3125
+ {
3126
+ ...options,
3127
+ closed: options?.closed ?? this.isClosed()
3128
+ }
2933
3129
  );
2934
3130
  }
2935
3131
  toCommands() {
@@ -3024,6 +3220,22 @@ class RoundCurve extends Curve {
3024
3220
  isClockwise() {
3025
3221
  return this.clockwise;
3026
3222
  }
3223
+ /**
3224
+ * A circle/ellipse arc is closed when it sweeps (at least) a full revolution — the sampled
3225
+ * outline does not duplicate the start vertex, so the geometric first==last test in the base
3226
+ * class would wrongly report a full circle as open and leave a seam gap in the stroke.
3227
+ */
3228
+ isClosed() {
3229
+ return Math.abs(this.endAngle - this.startAngle) >= Math.PI * 2 - 1e-9 || super.isClosed();
3230
+ }
3231
+ reverse() {
3232
+ const { startAngle, endAngle } = this;
3233
+ this.startAngle = endAngle;
3234
+ this.endAngle = startAngle;
3235
+ this.clockwise = !this.clockwise;
3236
+ this.invalidate();
3237
+ return this;
3238
+ }
3027
3239
  _getDeltaAngle() {
3028
3240
  const PI_2 = Math.PI * 2;
3029
3241
  let deltaAngle = this.endAngle - this.startAngle;
@@ -3280,9 +3492,25 @@ class RoundCurve extends Curve {
3280
3492
  return output;
3281
3493
  }
3282
3494
  getAdaptiveVertices(output = []) {
3283
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3495
+ const PI2 = Math.PI * 2;
3496
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3284
3497
  return this._getAdaptiveVerticesByCircle(output);
3285
3498
  }
3499
+ if (Math.abs(this.endAngle - this.startAngle) >= PI2 - 1e-9) {
3500
+ const tmp = this._getAdaptiveVerticesByCircle([]);
3501
+ const n = tmp.length / 2;
3502
+ if (this.endAngle > this.startAngle) {
3503
+ for (let i = 0; i < tmp.length; i++) {
3504
+ output.push(tmp[i]);
3505
+ }
3506
+ } else {
3507
+ output.push(tmp[0], tmp[1]);
3508
+ for (let i = n - 1; i >= 1; i--) {
3509
+ output.push(tmp[i * 2], tmp[i * 2 + 1]);
3510
+ }
3511
+ }
3512
+ return output;
3513
+ }
3286
3514
  return this._getAdaptiveVerticesByArc(output);
3287
3515
  }
3288
3516
  copyFrom(source) {
@@ -3492,6 +3720,15 @@ class LineCurve extends Curve {
3492
3720
  getControlPointRefs() {
3493
3721
  return [this.p1, this.p2];
3494
3722
  }
3723
+ // Swap endpoint *references* (not values) so corner Vector2s shared with adjacent
3724
+ // segments stay intact and simply re-associate with the reversed segment.
3725
+ reverse() {
3726
+ const { p1, p2 } = this;
3727
+ this.p1 = p2;
3728
+ this.p2 = p1;
3729
+ this.invalidate();
3730
+ return this;
3731
+ }
3495
3732
  getAdaptiveVertices(output = []) {
3496
3733
  output.push(
3497
3734
  this.p1.x,
@@ -3655,6 +3892,16 @@ class CompositeCurve extends Curve {
3655
3892
  });
3656
3893
  return output;
3657
3894
  }
3895
+ /**
3896
+ * A composite is closed when its single child is closed (e.g. a lone full-circle arc), or when
3897
+ * its assembled outline returns to its start (rectangles, polygons, multi-segment loops).
3898
+ */
3899
+ isClosed() {
3900
+ if (this.curves.length === 1) {
3901
+ return this.curves[0].isClosed();
3902
+ }
3903
+ return super.isClosed();
3904
+ }
3658
3905
  strokeTriangulate(options) {
3659
3906
  if (this.curves.length === 1) {
3660
3907
  return this.curves[0].strokeTriangulate(options);
@@ -3662,6 +3909,13 @@ class CompositeCurve extends Curve {
3662
3909
  return super.strokeTriangulate(options);
3663
3910
  }
3664
3911
  }
3912
+ /** Reverse the sub-curve order and reverse each sub-curve, so the whole outline runs backwards. */
3913
+ reverse() {
3914
+ this.curves.reverse();
3915
+ this.curves.forEach((curve) => curve.reverse());
3916
+ this.invalidate();
3917
+ return this;
3918
+ }
3665
3919
  getFillVertices(options) {
3666
3920
  if (this.curves.length === 1) {
3667
3921
  return this.curves[0].getFillVertices(options);
@@ -3757,6 +4011,16 @@ class CubicBezierCurve extends Curve {
3757
4011
  getControlPointRefs() {
3758
4012
  return [this.p1, this.cp1, this.cp2, this.p2];
3759
4013
  }
4014
+ // Swap endpoint and control-point references; keeps shared corner Vector2s intact.
4015
+ reverse() {
4016
+ const { p1, cp1, cp2, p2 } = this;
4017
+ this.p1 = p2;
4018
+ this.cp1 = cp2;
4019
+ this.cp2 = cp1;
4020
+ this.p2 = p1;
4021
+ this.invalidate();
4022
+ return this;
4023
+ }
3760
4024
  _solveQuadratic(a, b, c) {
3761
4025
  if (Math.abs(a) < 1e-12) {
3762
4026
  if (Math.abs(b) < 1e-12)
@@ -3917,6 +4181,14 @@ class QuadraticBezierCurve extends Curve {
3917
4181
  getControlPointRefs() {
3918
4182
  return [this.p1, this.cp, this.p2];
3919
4183
  }
4184
+ // Swap endpoint references (cp is symmetric); keeps shared corner Vector2s intact.
4185
+ reverse() {
4186
+ const { p1, p2 } = this;
4187
+ this.p1 = p2;
4188
+ this.p2 = p1;
4189
+ this.invalidate();
4190
+ return this;
4191
+ }
3920
4192
  getAdaptiveVertices(output = []) {
3921
4193
  return getAdaptiveQuadraticBezierCurvePoints(
3922
4194
  this.p1.x,
@@ -4118,6 +4390,11 @@ class SplineCurve extends Curve {
4118
4390
  getControlPointRefs() {
4119
4391
  return this.points;
4120
4392
  }
4393
+ reverse() {
4394
+ this.points.reverse();
4395
+ this.invalidate();
4396
+ return this;
4397
+ }
4121
4398
  copyFrom(source) {
4122
4399
  super.copyFrom(source);
4123
4400
  this.points = [];
@@ -4154,6 +4431,22 @@ class CurvePath extends CompositeCurve {
4154
4431
  this.addCommands(svgPathDataToCommands(data));
4155
4432
  return this;
4156
4433
  }
4434
+ /**
4435
+ * A sub-path is closed if it was explicitly closed (`autoClose`, i.e. a `Z`/`closePath`), or if
4436
+ * it forms a geometric loop / wraps a single closed primitive (handled by the base class).
4437
+ */
4438
+ isClosed() {
4439
+ return this.autoClose || super.isClosed();
4440
+ }
4441
+ /** Reverse direction, then refresh the {@link startPoint}/{@link currentPoint} cursors. */
4442
+ reverse() {
4443
+ super.reverse();
4444
+ if (this.curves.length) {
4445
+ this.startPoint = this.getPoint(0);
4446
+ this.currentPoint = this.getPoint(1);
4447
+ }
4448
+ return this;
4449
+ }
4157
4450
  _closeVertices(output) {
4158
4451
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
4159
4452
  output.push(output[0], output[1]);
@@ -4590,6 +4883,51 @@ class Path2D extends CompositeCurve {
4590
4883
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4591
4884
  return pointInPolygons(point, this._getRings(), fillRule);
4592
4885
  }
4886
+ /** Build a `Path2D` from flat rings (`[x0,y0,…]` per sub-path); closed-and-filled as sub-paths. */
4887
+ static fromRings(rings, style = {}) {
4888
+ const path = new Path2D(void 0, style);
4889
+ for (const ring of rings) {
4890
+ if (ring.length < 6) {
4891
+ continue;
4892
+ }
4893
+ let end = ring.length;
4894
+ if (ring[0] === ring[end - 2] && ring[1] === ring[end - 1]) {
4895
+ end -= 2;
4896
+ }
4897
+ path.moveTo(ring[0], ring[1]);
4898
+ for (let i = 2; i < end; i += 2) {
4899
+ path.lineTo(ring[i], ring[i + 1]);
4900
+ }
4901
+ path.closePath();
4902
+ }
4903
+ return path;
4904
+ }
4905
+ /**
4906
+ * Boolean (path) operation against another path, returning a NEW `Path2D` whose outline is the
4907
+ * polygonal result. Curves are sampled before clipping, so the result is a polygonal
4908
+ * approximation (see {@link polygonBoolean}). The result inherits this path's `style` unless
4909
+ * overridden via `style`. Holes are emitted as oppositely-wound sub-paths (nonzero fill).
4910
+ */
4911
+ booleanOp(op, other, style) {
4912
+ const rings = polygonBoolean(op, this._getRings(), other._getRings());
4913
+ return Path2D.fromRings(rings, { ...this.style, ...style });
4914
+ }
4915
+ /** `this ∪ other` — the combined filled area. */
4916
+ union(other, style) {
4917
+ return this.booleanOp("union", other, style);
4918
+ }
4919
+ /** `this ∩ other` — only the overlapping area. */
4920
+ intersection(other, style) {
4921
+ return this.booleanOp("intersection", other, style);
4922
+ }
4923
+ /** `this − other` — this path with `other` cut away. */
4924
+ difference(other, style) {
4925
+ return this.booleanOp("difference", other, style);
4926
+ }
4927
+ /** `this ⊕ other` — areas covered by exactly one of the two paths. */
4928
+ xor(other, style) {
4929
+ return this.booleanOp("xor", other, style);
4930
+ }
4593
4931
  /**
4594
4932
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4595
4933
  *
@@ -4672,14 +5010,30 @@ class Path2D extends CompositeCurve {
4672
5010
  });
4673
5011
  }
4674
5012
  } else {
4675
- this.curves.forEach((curve) => {
4676
- curve.fillTriangulate({
5013
+ const paths = this.curves.map((curve) => curve.getFillVertices(_options));
5014
+ const groups = evenoddFillRule(paths);
5015
+ const groupsLen = groups.length;
5016
+ for (let i = 0; i < groupsLen; i++) {
5017
+ const pointArray = paths[i];
5018
+ if ((groups[i].depth & 1) === 1 || !pointArray.length) {
5019
+ continue;
5020
+ }
5021
+ const _pointArray = pointArray.slice();
5022
+ const holes = [];
5023
+ for (let j = 0; j < groupsLen; j++) {
5024
+ if (groups[j].parentIndex === i && (groups[j].depth & 1) === 1) {
5025
+ holes.push(_pointArray.length / 2);
5026
+ _pointArray.push(...paths[j]);
5027
+ }
5028
+ }
5029
+ fillTriangulate(_pointArray, {
4677
5030
  ...options,
4678
5031
  indices,
4679
5032
  vertices,
5033
+ holes,
4680
5034
  style: { ...this.style }
4681
5035
  });
4682
- });
5036
+ }
4683
5037
  }
4684
5038
  return { indices, vertices };
4685
5039
  }
@@ -4689,7 +5043,7 @@ class Path2D extends CompositeCurve {
4689
5043
  }
4690
5044
  drawTo(ctx, style = {}) {
4691
5045
  style = { ...this.style, ...style };
4692
- const { fill = "#000", stroke = "none" } = style;
5046
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4693
5047
  ctx.beginPath();
4694
5048
  ctx.save();
4695
5049
  setCanvasContext(ctx, style);
@@ -4697,7 +5051,7 @@ class Path2D extends CompositeCurve {
4697
5051
  path.drawTo(ctx);
4698
5052
  });
4699
5053
  if (fill !== "none") {
4700
- ctx.fill();
5054
+ ctx.fill(fillRule);
4701
5055
  }
4702
5056
  if (stroke !== "none") {
4703
5057
  ctx.stroke();
@@ -4903,6 +5257,44 @@ ${content}
4903
5257
  }
4904
5258
  }
4905
5259
 
5260
+ class PathMeasure {
5261
+ constructor(curve) {
5262
+ this.curve = curve;
5263
+ }
5264
+ /** Total arc length of the path. */
5265
+ getLength() {
5266
+ return this.curve.getLength();
5267
+ }
5268
+ /** Whether the path forms a closed loop (see {@link Curve.isClosed}). */
5269
+ isClosed() {
5270
+ return this.curve.isClosed();
5271
+ }
5272
+ /** Point + unit tangent + tangent angle at an absolute arc-length `distance` (clamped). */
5273
+ getPosTan(distance) {
5274
+ return this.curve.getPosTan(distance);
5275
+ }
5276
+ /** Point at an absolute arc-length `distance` (clamped to `[0, getLength()]`). */
5277
+ getPosition(distance) {
5278
+ return this.curve.getPosTan(distance).position;
5279
+ }
5280
+ /** Point + tangent at a normalized progress `t ∈ [0, 1]` along the path. */
5281
+ getPosTanAtProgress(t) {
5282
+ return this.curve.getPosTan(this.getLength() * t);
5283
+ }
5284
+ /**
5285
+ * Evenly sample the path into `count + 1` {@link PosTan} entries (arc-length spaced), e.g. to
5286
+ * lay glyphs along a path or drive an `animate(progress)`-style traversal.
5287
+ */
5288
+ sample(count = 100) {
5289
+ const length = this.getLength();
5290
+ const out = [];
5291
+ for (let i = 0; i <= count; i++) {
5292
+ out.push(this.curve.getPosTan(length * i / count));
5293
+ }
5294
+ return out;
5295
+ }
5296
+ }
5297
+
4906
5298
  class FFDControlGrid {
4907
5299
  constructor(rows, cols, width = 1, height = 1) {
4908
5300
  this.rows = rows;
@@ -4958,4 +5350,4 @@ function applyFFD(point, grid, width = grid.width, height = grid.height) {
4958
5350
  point.set(x, y);
4959
5351
  }
4960
5352
 
4961
- 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 };
5353
+ 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, evenoddFillRule, 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 };