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.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;
@@ -303,6 +305,63 @@ class BoundingBox {
303
305
  }
304
306
  }
305
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
+
306
365
  function catmullRom(t, p0, p1, p2, p3) {
307
366
  const v0 = (p2 - p0) * 0.5;
308
367
  const v1 = (p3 - p1) * 0.5;
@@ -433,6 +492,195 @@ function cubicBezier(t, p0, p1, p2, p3) {
433
492
  return cubicBezierP0(t, p0) + cubicBezierP1(t, p1) + cubicBezierP2(t, p2) + cubicBezierP3(t, p3);
434
493
  }
435
494
 
495
+ function isLeft(ax, ay, bx, by, px, py) {
496
+ return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
497
+ }
498
+ function windingNumber$1(px, py, vertices) {
499
+ const len = vertices.length;
500
+ let wn = 0;
501
+ for (let i = 0; i < len; i += 2) {
502
+ const ax = vertices[i];
503
+ const ay = vertices[i + 1];
504
+ const k = (i + 2) % len;
505
+ const bx = vertices[k];
506
+ const by = vertices[k + 1];
507
+ if (ay <= py) {
508
+ if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
509
+ wn++;
510
+ }
511
+ } else {
512
+ if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
513
+ wn--;
514
+ }
515
+ }
516
+ }
517
+ return wn;
518
+ }
519
+ function crossingNumber(px, py, vertices) {
520
+ const len = vertices.length;
521
+ let cn = 0;
522
+ for (let i = 0; i < len; i += 2) {
523
+ const ax = vertices[i];
524
+ const ay = vertices[i + 1];
525
+ const k = (i + 2) % len;
526
+ const bx = vertices[k];
527
+ const by = vertices[k + 1];
528
+ if (ay <= py && by > py || ay > py && by <= py) {
529
+ const t = (py - ay) / (by - ay);
530
+ if (px < ax + t * (bx - ax)) {
531
+ cn++;
532
+ }
533
+ }
534
+ }
535
+ return cn;
536
+ }
537
+ function segmentDistance(px, py, ax, ay, bx, by) {
538
+ const dx = bx - ax;
539
+ const dy = by - ay;
540
+ const lenSq = dx * dx + dy * dy;
541
+ let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
542
+ if (t < 0) {
543
+ t = 0;
544
+ } else if (t > 1) {
545
+ t = 1;
546
+ }
547
+ const cx = ax + t * dx;
548
+ const cy = ay + t * dy;
549
+ return Math.hypot(px - cx, py - cy);
550
+ }
551
+ function pointInPolygon(point, vertices, fillRule = "nonzero") {
552
+ if (vertices.length < 6) {
553
+ return false;
554
+ }
555
+ if (fillRule === "evenodd") {
556
+ return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
557
+ }
558
+ return windingNumber$1(point.x, point.y, vertices) !== 0;
559
+ }
560
+ function pointInPolygons(point, polygons, fillRule = "nonzero") {
561
+ const { x, y } = point;
562
+ if (fillRule === "evenodd") {
563
+ let cn = 0;
564
+ for (let i = 0, len = polygons.length; i < len; i++) {
565
+ const ring = polygons[i];
566
+ if (ring.length >= 6) {
567
+ cn += crossingNumber(x, y, ring);
568
+ }
569
+ }
570
+ return (cn & 1) === 1;
571
+ }
572
+ let wn = 0;
573
+ for (let i = 0, len = polygons.length; i < len; i++) {
574
+ const ring = polygons[i];
575
+ if (ring.length >= 6) {
576
+ wn += windingNumber$1(x, y, ring);
577
+ }
578
+ }
579
+ return wn !== 0;
580
+ }
581
+ function pointToSegmentDistance(point, a, b) {
582
+ return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
583
+ }
584
+ function pointToPolylineDistance(point, vertices, closed = false) {
585
+ const len = vertices.length;
586
+ if (len < 2) {
587
+ return Infinity;
588
+ }
589
+ const { x: px, y: py } = point;
590
+ if (len === 2) {
591
+ return Math.hypot(px - vertices[0], py - vertices[1]);
592
+ }
593
+ let min = Infinity;
594
+ for (let i = 0; i < len - 2; i += 2) {
595
+ const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
596
+ if (d < min) {
597
+ min = d;
598
+ }
599
+ }
600
+ if (closed && len >= 6) {
601
+ const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
602
+ if (d < min) {
603
+ min = d;
604
+ }
605
+ }
606
+ return min;
607
+ }
608
+
609
+ function boundsOf(ring) {
610
+ if (ring.length < 6) {
611
+ return null;
612
+ }
613
+ let minX = Infinity;
614
+ let minY = Infinity;
615
+ let maxX = -Infinity;
616
+ let maxY = -Infinity;
617
+ for (let i = 0; i < ring.length; i += 2) {
618
+ const x = ring[i];
619
+ const y = ring[i + 1];
620
+ if (x < minX)
621
+ minX = x;
622
+ if (y < minY)
623
+ minY = y;
624
+ if (x > maxX)
625
+ maxX = x;
626
+ if (y > maxY)
627
+ maxY = y;
628
+ }
629
+ return { minX, minY, maxX, maxY };
630
+ }
631
+ function bboxInside(inner, outer) {
632
+ return inner.minX >= outer.minX && inner.maxX <= outer.maxX && inner.minY >= outer.minY && inner.maxY <= outer.maxY;
633
+ }
634
+ function ringInsideRing(inner, outer) {
635
+ const n = inner.length / 2;
636
+ const step = Math.max(1, Math.floor(n / 9));
637
+ let tested = 0;
638
+ let inside = 0;
639
+ for (let i = 0; i < n; i += step) {
640
+ tested++;
641
+ if (pointInPolygon({ x: inner[i * 2], y: inner[i * 2 + 1] }, outer, "evenodd")) {
642
+ inside++;
643
+ }
644
+ }
645
+ return tested > 0 && inside * 2 > tested;
646
+ }
647
+ function evenoddFillRule(paths) {
648
+ const len = paths.length;
649
+ const bboxes = paths.map(boundsOf);
650
+ const depth = Array.from({ length: len }).fill(0);
651
+ const containers = paths.map(() => []);
652
+ for (let i = 0; i < len; i++) {
653
+ const bi = bboxes[i];
654
+ if (!bi) {
655
+ continue;
656
+ }
657
+ for (let j = 0; j < len; j++) {
658
+ if (i === j) {
659
+ continue;
660
+ }
661
+ const bj = bboxes[j];
662
+ if (!bj || !bboxInside(bi, bj)) {
663
+ continue;
664
+ }
665
+ if (ringInsideRing(paths[i], paths[j])) {
666
+ depth[i]++;
667
+ containers[i].push(j);
668
+ }
669
+ }
670
+ }
671
+ return paths.map((_, i) => {
672
+ let parentIndex = -1;
673
+ let bestDepth = -1;
674
+ for (const j of containers[i]) {
675
+ if (depth[j] > bestDepth) {
676
+ bestDepth = depth[j];
677
+ parentIndex = j;
678
+ }
679
+ }
680
+ return { index: i, depth: depth[i], parentIndex };
681
+ });
682
+ }
683
+
436
684
  function fillTriangulate(pointArray, options = {}) {
437
685
  let {
438
686
  vertices = [],
@@ -595,7 +843,7 @@ function getDirectedArea(vertices) {
595
843
  function cross(ax, ay, bx, by, cx, cy) {
596
844
  return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
597
845
  }
598
- function windingNumber$1(px, py, polygon) {
846
+ function windingNumber(px, py, polygon) {
599
847
  const polygonLen = polygon.length;
600
848
  let wn = 0;
601
849
  for (let i = 0, j = polygonLen - 2; i < polygonLen; j = i, i += 2) {
@@ -716,7 +964,7 @@ function nonzeroFillRule(paths) {
716
964
  const wnList = [];
717
965
  for (let p = 0, pLen = testPoints.length; p < pLen; p++) {
718
966
  const [x, y] = testPoints[p];
719
- const winding = windingNumber$1(x, y, paths[j]);
967
+ const winding = windingNumber(x, y, paths[j]);
720
968
  wnMap[winding] = (wnMap[winding] ?? 0) + 1;
721
969
  wnList.push(winding);
722
970
  }
@@ -739,120 +987,6 @@ function nonzeroFillRule(paths) {
739
987
  return results;
740
988
  }
741
989
 
742
- function isLeft(ax, ay, bx, by, px, py) {
743
- return (bx - ax) * (py - ay) - (px - ax) * (by - ay);
744
- }
745
- function windingNumber(px, py, vertices) {
746
- const len = vertices.length;
747
- let wn = 0;
748
- for (let i = 0; i < len; i += 2) {
749
- const ax = vertices[i];
750
- const ay = vertices[i + 1];
751
- const k = (i + 2) % len;
752
- const bx = vertices[k];
753
- const by = vertices[k + 1];
754
- if (ay <= py) {
755
- if (by > py && isLeft(ax, ay, bx, by, px, py) > 0) {
756
- wn++;
757
- }
758
- } else {
759
- if (by <= py && isLeft(ax, ay, bx, by, px, py) < 0) {
760
- wn--;
761
- }
762
- }
763
- }
764
- return wn;
765
- }
766
- function crossingNumber(px, py, vertices) {
767
- const len = vertices.length;
768
- let cn = 0;
769
- for (let i = 0; i < len; i += 2) {
770
- const ax = vertices[i];
771
- const ay = vertices[i + 1];
772
- const k = (i + 2) % len;
773
- const bx = vertices[k];
774
- const by = vertices[k + 1];
775
- if (ay <= py && by > py || ay > py && by <= py) {
776
- const t = (py - ay) / (by - ay);
777
- if (px < ax + t * (bx - ax)) {
778
- cn++;
779
- }
780
- }
781
- }
782
- return cn;
783
- }
784
- function segmentDistance(px, py, ax, ay, bx, by) {
785
- const dx = bx - ax;
786
- const dy = by - ay;
787
- const lenSq = dx * dx + dy * dy;
788
- let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq;
789
- if (t < 0) {
790
- t = 0;
791
- } else if (t > 1) {
792
- t = 1;
793
- }
794
- const cx = ax + t * dx;
795
- const cy = ay + t * dy;
796
- return Math.hypot(px - cx, py - cy);
797
- }
798
- function pointInPolygon(point, vertices, fillRule = "nonzero") {
799
- if (vertices.length < 6) {
800
- return false;
801
- }
802
- if (fillRule === "evenodd") {
803
- return (crossingNumber(point.x, point.y, vertices) & 1) === 1;
804
- }
805
- return windingNumber(point.x, point.y, vertices) !== 0;
806
- }
807
- function pointInPolygons(point, polygons, fillRule = "nonzero") {
808
- const { x, y } = point;
809
- if (fillRule === "evenodd") {
810
- let cn = 0;
811
- for (let i = 0, len = polygons.length; i < len; i++) {
812
- const ring = polygons[i];
813
- if (ring.length >= 6) {
814
- cn += crossingNumber(x, y, ring);
815
- }
816
- }
817
- return (cn & 1) === 1;
818
- }
819
- let wn = 0;
820
- for (let i = 0, len = polygons.length; i < len; i++) {
821
- const ring = polygons[i];
822
- if (ring.length >= 6) {
823
- wn += windingNumber(x, y, ring);
824
- }
825
- }
826
- return wn !== 0;
827
- }
828
- function pointToSegmentDistance(point, a, b) {
829
- return segmentDistance(point.x, point.y, a.x, a.y, b.x, b.y);
830
- }
831
- function pointToPolylineDistance(point, vertices, closed = false) {
832
- const len = vertices.length;
833
- if (len < 2) {
834
- return Infinity;
835
- }
836
- const { x: px, y: py } = point;
837
- if (len === 2) {
838
- return Math.hypot(px - vertices[0], py - vertices[1]);
839
- }
840
- let min = Infinity;
841
- for (let i = 0; i < len - 2; i += 2) {
842
- const d = segmentDistance(px, py, vertices[i], vertices[i + 1], vertices[i + 2], vertices[i + 3]);
843
- if (d < min) {
844
- min = d;
845
- }
846
- }
847
- if (closed && len >= 6) {
848
- const d = segmentDistance(px, py, vertices[len - 2], vertices[len - 1], vertices[0], vertices[1]);
849
- if (d < min) {
850
- min = d;
851
- }
852
- }
853
- return min;
854
- }
855
-
856
990
  function quadraticBezierP0(t, p) {
857
991
  const k = 1 - t;
858
992
  return k * k * p;
@@ -867,22 +1001,35 @@ function quadraticBezier(t, p0, p1, p2) {
867
1001
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
868
1002
  }
869
1003
 
1004
+ function resolveLineJoin(join) {
1005
+ switch (join) {
1006
+ case "round":
1007
+ case "bevel":
1008
+ case "miter":
1009
+ return join;
1010
+ default:
1011
+ return "miter";
1012
+ }
1013
+ }
1014
+ function resolveLineStyle(style) {
1015
+ return {
1016
+ width: style?.strokeWidth ?? 1,
1017
+ alignment: 0.5,
1018
+ join: resolveLineJoin(style?.strokeLinejoin),
1019
+ cap: style?.strokeLinecap ?? "butt",
1020
+ miterLimit: style?.strokeMiterlimit ?? 10
1021
+ };
1022
+ }
870
1023
  const closePointEps = 1e-4;
871
1024
  const curveEps = 1e-4;
872
1025
  function strokeTriangulate(points, options = {}) {
873
1026
  const {
874
1027
  vertices = [],
875
1028
  indices = [],
876
- lineStyle = {
877
- alignment: 0.5,
878
- cap: "butt",
879
- join: "miter",
880
- width: 1,
881
- miterLimit: 10
882
- },
883
1029
  flipAlignment = false,
884
1030
  closed = true
885
1031
  } = options;
1032
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
886
1033
  const eps = closePointEps;
887
1034
  if (points.length === 0) {
888
1035
  return { vertices, indices };
@@ -2710,6 +2857,22 @@ class Curve {
2710
2857
  getControlPointRefs() {
2711
2858
  return [];
2712
2859
  }
2860
+ /**
2861
+ * Reverse the traversal direction in place (start ↔ end, same geometry). The base
2862
+ * implementation reverses the order of the control-point *values*, which is correct for
2863
+ * line / Bézier / spline primitives whose {@link getControlPointRefs} order matches their
2864
+ * parametric order. {@link RoundCurve} (angle-based) and composites (child order) override it.
2865
+ */
2866
+ reverse() {
2867
+ const refs = this.getControlPointRefs();
2868
+ const n = refs.length;
2869
+ const snapshot = refs.map((p) => p.clone());
2870
+ for (let i = 0; i < n; i++) {
2871
+ refs[i].copyFrom(snapshot[n - 1 - i]);
2872
+ }
2873
+ this.invalidate();
2874
+ return this;
2875
+ }
2713
2876
  applyTransform(transform) {
2714
2877
  const isFunction = typeof transform === "function";
2715
2878
  this.getControlPointRefs().forEach((p) => {
@@ -2837,6 +3000,22 @@ class Curve {
2837
3000
  getTangentAt(u, output) {
2838
3001
  return this.getTangent(this.getUToTMapping(u), output);
2839
3002
  }
3003
+ /**
3004
+ * PathKit-style sample at an absolute arc-length `distance` along the curve: the point, the unit
3005
+ * tangent, and the tangent `angle` in radians. `distance` is clamped to `[0, getLength()]`, so
3006
+ * passing `0`/`getLength()` always yields the endpoints. See {@link PathMeasure} for a wrapper.
3007
+ */
3008
+ getPosTan(distance) {
3009
+ const length = this.getLength();
3010
+ const u = length > 0 ? Math.min(Math.max(distance / length, 0), 1) : 0;
3011
+ const t = this.getUToTMapping(u);
3012
+ const tangent = this.getTangent(t);
3013
+ return {
3014
+ position: this.getPoint(t),
3015
+ tangent,
3016
+ angle: Math.atan2(tangent.y, tangent.x)
3017
+ };
3018
+ }
2840
3019
  getNormal(t, output = new Vector2()) {
2841
3020
  this.getTangent(t, output);
2842
3021
  return output.set(-output.y, output.x).normalize();
@@ -2932,10 +3111,28 @@ class Curve {
2932
3111
  options
2933
3112
  );
2934
3113
  }
3114
+ /**
3115
+ * Whether this curve forms a closed loop (its outline should be stroked without end caps,
3116
+ * stitching the last vertex back to the first). The base test is purely geometric — the first
3117
+ * sampled vertex coincides with the last. Curves that close without a duplicated endpoint
3118
+ * (a full-revolution {@link RoundCurve}, rectangles, polygons) override this.
3119
+ */
3120
+ isClosed() {
3121
+ const v = this._getCachedAdaptiveVertices();
3122
+ const len = v.length;
3123
+ if (len < 6) {
3124
+ return false;
3125
+ }
3126
+ const eps = 1e-4;
3127
+ return Math.abs(v[0] - v[len - 2]) < eps && Math.abs(v[1] - v[len - 1]) < eps;
3128
+ }
2935
3129
  strokeTriangulate(options) {
2936
3130
  return strokeTriangulate(
2937
3131
  this.getAdaptiveVertices(),
2938
- options
3132
+ {
3133
+ ...options,
3134
+ closed: options?.closed ?? this.isClosed()
3135
+ }
2939
3136
  );
2940
3137
  }
2941
3138
  toCommands() {
@@ -3030,6 +3227,22 @@ class RoundCurve extends Curve {
3030
3227
  isClockwise() {
3031
3228
  return this.clockwise;
3032
3229
  }
3230
+ /**
3231
+ * A circle/ellipse arc is closed when it sweeps (at least) a full revolution — the sampled
3232
+ * outline does not duplicate the start vertex, so the geometric first==last test in the base
3233
+ * class would wrongly report a full circle as open and leave a seam gap in the stroke.
3234
+ */
3235
+ isClosed() {
3236
+ return Math.abs(this.endAngle - this.startAngle) >= Math.PI * 2 - 1e-9 || super.isClosed();
3237
+ }
3238
+ reverse() {
3239
+ const { startAngle, endAngle } = this;
3240
+ this.startAngle = endAngle;
3241
+ this.endAngle = startAngle;
3242
+ this.clockwise = !this.clockwise;
3243
+ this.invalidate();
3244
+ return this;
3245
+ }
3033
3246
  _getDeltaAngle() {
3034
3247
  const PI_2 = Math.PI * 2;
3035
3248
  let deltaAngle = this.endAngle - this.startAngle;
@@ -3286,9 +3499,25 @@ class RoundCurve extends Curve {
3286
3499
  return output;
3287
3500
  }
3288
3501
  getAdaptiveVertices(output = []) {
3289
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3502
+ const PI2 = Math.PI * 2;
3503
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3290
3504
  return this._getAdaptiveVerticesByCircle(output);
3291
3505
  }
3506
+ if (Math.abs(this.endAngle - this.startAngle) >= PI2 - 1e-9) {
3507
+ const tmp = this._getAdaptiveVerticesByCircle([]);
3508
+ const n = tmp.length / 2;
3509
+ if (this.endAngle > this.startAngle) {
3510
+ for (let i = 0; i < tmp.length; i++) {
3511
+ output.push(tmp[i]);
3512
+ }
3513
+ } else {
3514
+ output.push(tmp[0], tmp[1]);
3515
+ for (let i = n - 1; i >= 1; i--) {
3516
+ output.push(tmp[i * 2], tmp[i * 2 + 1]);
3517
+ }
3518
+ }
3519
+ return output;
3520
+ }
3292
3521
  return this._getAdaptiveVerticesByArc(output);
3293
3522
  }
3294
3523
  copyFrom(source) {
@@ -3498,6 +3727,15 @@ class LineCurve extends Curve {
3498
3727
  getControlPointRefs() {
3499
3728
  return [this.p1, this.p2];
3500
3729
  }
3730
+ // Swap endpoint *references* (not values) so corner Vector2s shared with adjacent
3731
+ // segments stay intact and simply re-associate with the reversed segment.
3732
+ reverse() {
3733
+ const { p1, p2 } = this;
3734
+ this.p1 = p2;
3735
+ this.p2 = p1;
3736
+ this.invalidate();
3737
+ return this;
3738
+ }
3501
3739
  getAdaptiveVertices(output = []) {
3502
3740
  output.push(
3503
3741
  this.p1.x,
@@ -3661,6 +3899,16 @@ class CompositeCurve extends Curve {
3661
3899
  });
3662
3900
  return output;
3663
3901
  }
3902
+ /**
3903
+ * A composite is closed when its single child is closed (e.g. a lone full-circle arc), or when
3904
+ * its assembled outline returns to its start (rectangles, polygons, multi-segment loops).
3905
+ */
3906
+ isClosed() {
3907
+ if (this.curves.length === 1) {
3908
+ return this.curves[0].isClosed();
3909
+ }
3910
+ return super.isClosed();
3911
+ }
3664
3912
  strokeTriangulate(options) {
3665
3913
  if (this.curves.length === 1) {
3666
3914
  return this.curves[0].strokeTriangulate(options);
@@ -3668,6 +3916,13 @@ class CompositeCurve extends Curve {
3668
3916
  return super.strokeTriangulate(options);
3669
3917
  }
3670
3918
  }
3919
+ /** Reverse the sub-curve order and reverse each sub-curve, so the whole outline runs backwards. */
3920
+ reverse() {
3921
+ this.curves.reverse();
3922
+ this.curves.forEach((curve) => curve.reverse());
3923
+ this.invalidate();
3924
+ return this;
3925
+ }
3671
3926
  getFillVertices(options) {
3672
3927
  if (this.curves.length === 1) {
3673
3928
  return this.curves[0].getFillVertices(options);
@@ -3763,6 +4018,16 @@ class CubicBezierCurve extends Curve {
3763
4018
  getControlPointRefs() {
3764
4019
  return [this.p1, this.cp1, this.cp2, this.p2];
3765
4020
  }
4021
+ // Swap endpoint and control-point references; keeps shared corner Vector2s intact.
4022
+ reverse() {
4023
+ const { p1, cp1, cp2, p2 } = this;
4024
+ this.p1 = p2;
4025
+ this.cp1 = cp2;
4026
+ this.cp2 = cp1;
4027
+ this.p2 = p1;
4028
+ this.invalidate();
4029
+ return this;
4030
+ }
3766
4031
  _solveQuadratic(a, b, c) {
3767
4032
  if (Math.abs(a) < 1e-12) {
3768
4033
  if (Math.abs(b) < 1e-12)
@@ -3923,6 +4188,14 @@ class QuadraticBezierCurve extends Curve {
3923
4188
  getControlPointRefs() {
3924
4189
  return [this.p1, this.cp, this.p2];
3925
4190
  }
4191
+ // Swap endpoint references (cp is symmetric); keeps shared corner Vector2s intact.
4192
+ reverse() {
4193
+ const { p1, p2 } = this;
4194
+ this.p1 = p2;
4195
+ this.p2 = p1;
4196
+ this.invalidate();
4197
+ return this;
4198
+ }
3926
4199
  getAdaptiveVertices(output = []) {
3927
4200
  return getAdaptiveQuadraticBezierCurvePoints(
3928
4201
  this.p1.x,
@@ -4124,6 +4397,11 @@ class SplineCurve extends Curve {
4124
4397
  getControlPointRefs() {
4125
4398
  return this.points;
4126
4399
  }
4400
+ reverse() {
4401
+ this.points.reverse();
4402
+ this.invalidate();
4403
+ return this;
4404
+ }
4127
4405
  copyFrom(source) {
4128
4406
  super.copyFrom(source);
4129
4407
  this.points = [];
@@ -4160,6 +4438,22 @@ class CurvePath extends CompositeCurve {
4160
4438
  this.addCommands(svgPathDataToCommands(data));
4161
4439
  return this;
4162
4440
  }
4441
+ /**
4442
+ * A sub-path is closed if it was explicitly closed (`autoClose`, i.e. a `Z`/`closePath`), or if
4443
+ * it forms a geometric loop / wraps a single closed primitive (handled by the base class).
4444
+ */
4445
+ isClosed() {
4446
+ return this.autoClose || super.isClosed();
4447
+ }
4448
+ /** Reverse direction, then refresh the {@link startPoint}/{@link currentPoint} cursors. */
4449
+ reverse() {
4450
+ super.reverse();
4451
+ if (this.curves.length) {
4452
+ this.startPoint = this.getPoint(0);
4453
+ this.currentPoint = this.getPoint(1);
4454
+ }
4455
+ return this;
4456
+ }
4163
4457
  _closeVertices(output) {
4164
4458
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
4165
4459
  output.push(output[0], output[1]);
@@ -4596,6 +4890,51 @@ class Path2D extends CompositeCurve {
4596
4890
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4597
4891
  return pointInPolygons(point, this._getRings(), fillRule);
4598
4892
  }
4893
+ /** Build a `Path2D` from flat rings (`[x0,y0,…]` per sub-path); closed-and-filled as sub-paths. */
4894
+ static fromRings(rings, style = {}) {
4895
+ const path = new Path2D(void 0, style);
4896
+ for (const ring of rings) {
4897
+ if (ring.length < 6) {
4898
+ continue;
4899
+ }
4900
+ let end = ring.length;
4901
+ if (ring[0] === ring[end - 2] && ring[1] === ring[end - 1]) {
4902
+ end -= 2;
4903
+ }
4904
+ path.moveTo(ring[0], ring[1]);
4905
+ for (let i = 2; i < end; i += 2) {
4906
+ path.lineTo(ring[i], ring[i + 1]);
4907
+ }
4908
+ path.closePath();
4909
+ }
4910
+ return path;
4911
+ }
4912
+ /**
4913
+ * Boolean (path) operation against another path, returning a NEW `Path2D` whose outline is the
4914
+ * polygonal result. Curves are sampled before clipping, so the result is a polygonal
4915
+ * approximation (see {@link polygonBoolean}). The result inherits this path's `style` unless
4916
+ * overridden via `style`. Holes are emitted as oppositely-wound sub-paths (nonzero fill).
4917
+ */
4918
+ booleanOp(op, other, style) {
4919
+ const rings = polygonBoolean(op, this._getRings(), other._getRings());
4920
+ return Path2D.fromRings(rings, { ...this.style, ...style });
4921
+ }
4922
+ /** `this ∪ other` — the combined filled area. */
4923
+ union(other, style) {
4924
+ return this.booleanOp("union", other, style);
4925
+ }
4926
+ /** `this ∩ other` — only the overlapping area. */
4927
+ intersection(other, style) {
4928
+ return this.booleanOp("intersection", other, style);
4929
+ }
4930
+ /** `this − other` — this path with `other` cut away. */
4931
+ difference(other, style) {
4932
+ return this.booleanOp("difference", other, style);
4933
+ }
4934
+ /** `this ⊕ other` — areas covered by exactly one of the two paths. */
4935
+ xor(other, style) {
4936
+ return this.booleanOp("xor", other, style);
4937
+ }
4599
4938
  /**
4600
4939
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4601
4940
  *
@@ -4678,14 +5017,30 @@ class Path2D extends CompositeCurve {
4678
5017
  });
4679
5018
  }
4680
5019
  } else {
4681
- this.curves.forEach((curve) => {
4682
- curve.fillTriangulate({
5020
+ const paths = this.curves.map((curve) => curve.getFillVertices(_options));
5021
+ const groups = evenoddFillRule(paths);
5022
+ const groupsLen = groups.length;
5023
+ for (let i = 0; i < groupsLen; i++) {
5024
+ const pointArray = paths[i];
5025
+ if ((groups[i].depth & 1) === 1 || !pointArray.length) {
5026
+ continue;
5027
+ }
5028
+ const _pointArray = pointArray.slice();
5029
+ const holes = [];
5030
+ for (let j = 0; j < groupsLen; j++) {
5031
+ if (groups[j].parentIndex === i && (groups[j].depth & 1) === 1) {
5032
+ holes.push(_pointArray.length / 2);
5033
+ _pointArray.push(...paths[j]);
5034
+ }
5035
+ }
5036
+ fillTriangulate(_pointArray, {
4683
5037
  ...options,
4684
5038
  indices,
4685
5039
  vertices,
5040
+ holes,
4686
5041
  style: { ...this.style }
4687
5042
  });
4688
- });
5043
+ }
4689
5044
  }
4690
5045
  return { indices, vertices };
4691
5046
  }
@@ -4695,7 +5050,7 @@ class Path2D extends CompositeCurve {
4695
5050
  }
4696
5051
  drawTo(ctx, style = {}) {
4697
5052
  style = { ...this.style, ...style };
4698
- const { fill = "#000", stroke = "none" } = style;
5053
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4699
5054
  ctx.beginPath();
4700
5055
  ctx.save();
4701
5056
  setCanvasContext(ctx, style);
@@ -4703,7 +5058,7 @@ class Path2D extends CompositeCurve {
4703
5058
  path.drawTo(ctx);
4704
5059
  });
4705
5060
  if (fill !== "none") {
4706
- ctx.fill();
5061
+ ctx.fill(fillRule);
4707
5062
  }
4708
5063
  if (stroke !== "none") {
4709
5064
  ctx.stroke();
@@ -4909,6 +5264,44 @@ ${content}
4909
5264
  }
4910
5265
  }
4911
5266
 
5267
+ class PathMeasure {
5268
+ constructor(curve) {
5269
+ this.curve = curve;
5270
+ }
5271
+ /** Total arc length of the path. */
5272
+ getLength() {
5273
+ return this.curve.getLength();
5274
+ }
5275
+ /** Whether the path forms a closed loop (see {@link Curve.isClosed}). */
5276
+ isClosed() {
5277
+ return this.curve.isClosed();
5278
+ }
5279
+ /** Point + unit tangent + tangent angle at an absolute arc-length `distance` (clamped). */
5280
+ getPosTan(distance) {
5281
+ return this.curve.getPosTan(distance);
5282
+ }
5283
+ /** Point at an absolute arc-length `distance` (clamped to `[0, getLength()]`). */
5284
+ getPosition(distance) {
5285
+ return this.curve.getPosTan(distance).position;
5286
+ }
5287
+ /** Point + tangent at a normalized progress `t ∈ [0, 1]` along the path. */
5288
+ getPosTanAtProgress(t) {
5289
+ return this.curve.getPosTan(this.getLength() * t);
5290
+ }
5291
+ /**
5292
+ * Evenly sample the path into `count + 1` {@link PosTan} entries (arc-length spaced), e.g. to
5293
+ * lay glyphs along a path or drive an `animate(progress)`-style traversal.
5294
+ */
5295
+ sample(count = 100) {
5296
+ const length = this.getLength();
5297
+ const out = [];
5298
+ for (let i = 0; i <= count; i++) {
5299
+ out.push(this.curve.getPosTan(length * i / count));
5300
+ }
5301
+ return out;
5302
+ }
5303
+ }
5304
+
4912
5305
  class FFDControlGrid {
4913
5306
  constructor(rows, cols, width = 1, height = 1) {
4914
5307
  this.rows = rows;
@@ -4978,6 +5371,7 @@ exports.PI = PI;
4978
5371
  exports.PI_2 = PI_2;
4979
5372
  exports.Path2D = Path2D;
4980
5373
  exports.Path2DSet = Path2DSet;
5374
+ exports.PathMeasure = PathMeasure;
4981
5375
  exports.PolygonCurve = PolygonCurve;
4982
5376
  exports.QuadraticBezierCurve = QuadraticBezierCurve;
4983
5377
  exports.RectangleCurve = RectangleCurve;
@@ -4989,6 +5383,7 @@ exports.applyFFD = applyFFD;
4989
5383
  exports.catmullRom = catmullRom;
4990
5384
  exports.cubicBezier = cubicBezier;
4991
5385
  exports.drawPoint = drawPoint;
5386
+ exports.evenoddFillRule = evenoddFillRule;
4992
5387
  exports.fillTriangulate = fillTriangulate;
4993
5388
  exports.getAdaptiveCubicBezierCurvePoints = getAdaptiveCubicBezierCurvePoints;
4994
5389
  exports.getAdaptiveQuadraticBezierCurvePoints = getAdaptiveQuadraticBezierCurvePoints;
@@ -5004,7 +5399,9 @@ exports.pointInPolygon = pointInPolygon;
5004
5399
  exports.pointInPolygons = pointInPolygons;
5005
5400
  exports.pointToPolylineDistance = pointToPolylineDistance;
5006
5401
  exports.pointToSegmentDistance = pointToSegmentDistance;
5402
+ exports.polygonBoolean = polygonBoolean;
5007
5403
  exports.quadraticBezier = quadraticBezier;
5404
+ exports.resolveLineStyle = resolveLineStyle;
5008
5405
  exports.setCanvasContext = setCanvasContext;
5009
5406
  exports.strokeTriangulate = strokeTriangulate;
5010
5407
  exports.svgPathCommandsAddToPath2D = svgPathCommandsAddToPath2D;