modern-path2d 1.7.0 → 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;
@@ -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;
@@ -867,22 +926,35 @@ function quadraticBezier(t, p0, p1, p2) {
867
926
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
868
927
  }
869
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
+ }
870
948
  const closePointEps = 1e-4;
871
949
  const curveEps = 1e-4;
872
950
  function strokeTriangulate(points, options = {}) {
873
951
  const {
874
952
  vertices = [],
875
953
  indices = [],
876
- lineStyle = {
877
- alignment: 0.5,
878
- cap: "butt",
879
- join: "miter",
880
- width: 1,
881
- miterLimit: 10
882
- },
883
954
  flipAlignment = false,
884
955
  closed = true
885
956
  } = options;
957
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
886
958
  const eps = closePointEps;
887
959
  if (points.length === 0) {
888
960
  return { vertices, indices };
@@ -2710,6 +2782,22 @@ class Curve {
2710
2782
  getControlPointRefs() {
2711
2783
  return [];
2712
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
+ }
2713
2801
  applyTransform(transform) {
2714
2802
  const isFunction = typeof transform === "function";
2715
2803
  this.getControlPointRefs().forEach((p) => {
@@ -2837,6 +2925,22 @@ class Curve {
2837
2925
  getTangentAt(u, output) {
2838
2926
  return this.getTangent(this.getUToTMapping(u), output);
2839
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
+ }
2840
2944
  getNormal(t, output = new Vector2()) {
2841
2945
  this.getTangent(t, output);
2842
2946
  return output.set(-output.y, output.x).normalize();
@@ -2932,10 +3036,28 @@ class Curve {
2932
3036
  options
2933
3037
  );
2934
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
+ }
2935
3054
  strokeTriangulate(options) {
2936
3055
  return strokeTriangulate(
2937
3056
  this.getAdaptiveVertices(),
2938
- options
3057
+ {
3058
+ ...options,
3059
+ closed: options?.closed ?? this.isClosed()
3060
+ }
2939
3061
  );
2940
3062
  }
2941
3063
  toCommands() {
@@ -3030,6 +3152,22 @@ class RoundCurve extends Curve {
3030
3152
  isClockwise() {
3031
3153
  return this.clockwise;
3032
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
+ }
3033
3171
  _getDeltaAngle() {
3034
3172
  const PI_2 = Math.PI * 2;
3035
3173
  let deltaAngle = this.endAngle - this.startAngle;
@@ -3286,9 +3424,25 @@ class RoundCurve extends Curve {
3286
3424
  return output;
3287
3425
  }
3288
3426
  getAdaptiveVertices(output = []) {
3289
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3427
+ const PI2 = Math.PI * 2;
3428
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3290
3429
  return this._getAdaptiveVerticesByCircle(output);
3291
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
+ }
3292
3446
  return this._getAdaptiveVerticesByArc(output);
3293
3447
  }
3294
3448
  copyFrom(source) {
@@ -3498,6 +3652,15 @@ class LineCurve extends Curve {
3498
3652
  getControlPointRefs() {
3499
3653
  return [this.p1, this.p2];
3500
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
+ }
3501
3664
  getAdaptiveVertices(output = []) {
3502
3665
  output.push(
3503
3666
  this.p1.x,
@@ -3661,6 +3824,16 @@ class CompositeCurve extends Curve {
3661
3824
  });
3662
3825
  return output;
3663
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
+ }
3664
3837
  strokeTriangulate(options) {
3665
3838
  if (this.curves.length === 1) {
3666
3839
  return this.curves[0].strokeTriangulate(options);
@@ -3668,6 +3841,13 @@ class CompositeCurve extends Curve {
3668
3841
  return super.strokeTriangulate(options);
3669
3842
  }
3670
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
+ }
3671
3851
  getFillVertices(options) {
3672
3852
  if (this.curves.length === 1) {
3673
3853
  return this.curves[0].getFillVertices(options);
@@ -3763,6 +3943,16 @@ class CubicBezierCurve extends Curve {
3763
3943
  getControlPointRefs() {
3764
3944
  return [this.p1, this.cp1, this.cp2, this.p2];
3765
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
+ }
3766
3956
  _solveQuadratic(a, b, c) {
3767
3957
  if (Math.abs(a) < 1e-12) {
3768
3958
  if (Math.abs(b) < 1e-12)
@@ -3923,6 +4113,14 @@ class QuadraticBezierCurve extends Curve {
3923
4113
  getControlPointRefs() {
3924
4114
  return [this.p1, this.cp, this.p2];
3925
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
+ }
3926
4124
  getAdaptiveVertices(output = []) {
3927
4125
  return getAdaptiveQuadraticBezierCurvePoints(
3928
4126
  this.p1.x,
@@ -4124,6 +4322,11 @@ class SplineCurve extends Curve {
4124
4322
  getControlPointRefs() {
4125
4323
  return this.points;
4126
4324
  }
4325
+ reverse() {
4326
+ this.points.reverse();
4327
+ this.invalidate();
4328
+ return this;
4329
+ }
4127
4330
  copyFrom(source) {
4128
4331
  super.copyFrom(source);
4129
4332
  this.points = [];
@@ -4160,6 +4363,22 @@ class CurvePath extends CompositeCurve {
4160
4363
  this.addCommands(svgPathDataToCommands(data));
4161
4364
  return this;
4162
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
+ }
4163
4382
  _closeVertices(output) {
4164
4383
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
4165
4384
  output.push(output[0], output[1]);
@@ -4596,6 +4815,51 @@ class Path2D extends CompositeCurve {
4596
4815
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4597
4816
  return pointInPolygons(point, this._getRings(), fillRule);
4598
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);
4862
+ }
4599
4863
  /**
4600
4864
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4601
4865
  *
@@ -4695,7 +4959,7 @@ class Path2D extends CompositeCurve {
4695
4959
  }
4696
4960
  drawTo(ctx, style = {}) {
4697
4961
  style = { ...this.style, ...style };
4698
- const { fill = "#000", stroke = "none" } = style;
4962
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4699
4963
  ctx.beginPath();
4700
4964
  ctx.save();
4701
4965
  setCanvasContext(ctx, style);
@@ -4703,7 +4967,7 @@ class Path2D extends CompositeCurve {
4703
4967
  path.drawTo(ctx);
4704
4968
  });
4705
4969
  if (fill !== "none") {
4706
- ctx.fill();
4970
+ ctx.fill(fillRule);
4707
4971
  }
4708
4972
  if (stroke !== "none") {
4709
4973
  ctx.stroke();
@@ -4909,6 +5173,44 @@ ${content}
4909
5173
  }
4910
5174
  }
4911
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
+
4912
5214
  class FFDControlGrid {
4913
5215
  constructor(rows, cols, width = 1, height = 1) {
4914
5216
  this.rows = rows;
@@ -4978,6 +5280,7 @@ exports.PI = PI;
4978
5280
  exports.PI_2 = PI_2;
4979
5281
  exports.Path2D = Path2D;
4980
5282
  exports.Path2DSet = Path2DSet;
5283
+ exports.PathMeasure = PathMeasure;
4981
5284
  exports.PolygonCurve = PolygonCurve;
4982
5285
  exports.QuadraticBezierCurve = QuadraticBezierCurve;
4983
5286
  exports.RectangleCurve = RectangleCurve;
@@ -5004,7 +5307,9 @@ exports.pointInPolygon = pointInPolygon;
5004
5307
  exports.pointInPolygons = pointInPolygons;
5005
5308
  exports.pointToPolylineDistance = pointToPolylineDistance;
5006
5309
  exports.pointToSegmentDistance = pointToSegmentDistance;
5310
+ exports.polygonBoolean = polygonBoolean;
5007
5311
  exports.quadraticBezier = quadraticBezier;
5312
+ exports.resolveLineStyle = resolveLineStyle;
5008
5313
  exports.setCanvasContext = setCanvasContext;
5009
5314
  exports.strokeTriangulate = strokeTriangulate;
5010
5315
  exports.svgPathCommandsAddToPath2D = svgPathCommandsAddToPath2D;