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.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;
@@ -861,22 +919,35 @@ function quadraticBezier(t, p0, p1, p2) {
861
919
  return quadraticBezierP0(t, p0) + quadraticBezierP1(t, p1) + quadraticBezierP2(t, p2);
862
920
  }
863
921
 
922
+ function resolveLineJoin(join) {
923
+ switch (join) {
924
+ case "round":
925
+ case "bevel":
926
+ case "miter":
927
+ return join;
928
+ default:
929
+ return "miter";
930
+ }
931
+ }
932
+ function resolveLineStyle(style) {
933
+ return {
934
+ width: style?.strokeWidth ?? 1,
935
+ alignment: 0.5,
936
+ join: resolveLineJoin(style?.strokeLinejoin),
937
+ cap: style?.strokeLinecap ?? "butt",
938
+ miterLimit: style?.strokeMiterlimit ?? 10
939
+ };
940
+ }
864
941
  const closePointEps = 1e-4;
865
942
  const curveEps = 1e-4;
866
943
  function strokeTriangulate(points, options = {}) {
867
944
  const {
868
945
  vertices = [],
869
946
  indices = [],
870
- lineStyle = {
871
- alignment: 0.5,
872
- cap: "butt",
873
- join: "miter",
874
- width: 1,
875
- miterLimit: 10
876
- },
877
947
  flipAlignment = false,
878
948
  closed = true
879
949
  } = options;
950
+ const lineStyle = options.lineStyle ?? resolveLineStyle(options.style);
880
951
  const eps = closePointEps;
881
952
  if (points.length === 0) {
882
953
  return { vertices, indices };
@@ -2704,6 +2775,22 @@ class Curve {
2704
2775
  getControlPointRefs() {
2705
2776
  return [];
2706
2777
  }
2778
+ /**
2779
+ * Reverse the traversal direction in place (start ↔ end, same geometry). The base
2780
+ * implementation reverses the order of the control-point *values*, which is correct for
2781
+ * line / Bézier / spline primitives whose {@link getControlPointRefs} order matches their
2782
+ * parametric order. {@link RoundCurve} (angle-based) and composites (child order) override it.
2783
+ */
2784
+ reverse() {
2785
+ const refs = this.getControlPointRefs();
2786
+ const n = refs.length;
2787
+ const snapshot = refs.map((p) => p.clone());
2788
+ for (let i = 0; i < n; i++) {
2789
+ refs[i].copyFrom(snapshot[n - 1 - i]);
2790
+ }
2791
+ this.invalidate();
2792
+ return this;
2793
+ }
2707
2794
  applyTransform(transform) {
2708
2795
  const isFunction = typeof transform === "function";
2709
2796
  this.getControlPointRefs().forEach((p) => {
@@ -2831,6 +2918,22 @@ class Curve {
2831
2918
  getTangentAt(u, output) {
2832
2919
  return this.getTangent(this.getUToTMapping(u), output);
2833
2920
  }
2921
+ /**
2922
+ * PathKit-style sample at an absolute arc-length `distance` along the curve: the point, the unit
2923
+ * tangent, and the tangent `angle` in radians. `distance` is clamped to `[0, getLength()]`, so
2924
+ * passing `0`/`getLength()` always yields the endpoints. See {@link PathMeasure} for a wrapper.
2925
+ */
2926
+ getPosTan(distance) {
2927
+ const length = this.getLength();
2928
+ const u = length > 0 ? Math.min(Math.max(distance / length, 0), 1) : 0;
2929
+ const t = this.getUToTMapping(u);
2930
+ const tangent = this.getTangent(t);
2931
+ return {
2932
+ position: this.getPoint(t),
2933
+ tangent,
2934
+ angle: Math.atan2(tangent.y, tangent.x)
2935
+ };
2936
+ }
2834
2937
  getNormal(t, output = new Vector2()) {
2835
2938
  this.getTangent(t, output);
2836
2939
  return output.set(-output.y, output.x).normalize();
@@ -2926,10 +3029,28 @@ class Curve {
2926
3029
  options
2927
3030
  );
2928
3031
  }
3032
+ /**
3033
+ * Whether this curve forms a closed loop (its outline should be stroked without end caps,
3034
+ * stitching the last vertex back to the first). The base test is purely geometric — the first
3035
+ * sampled vertex coincides with the last. Curves that close without a duplicated endpoint
3036
+ * (a full-revolution {@link RoundCurve}, rectangles, polygons) override this.
3037
+ */
3038
+ isClosed() {
3039
+ const v = this._getCachedAdaptiveVertices();
3040
+ const len = v.length;
3041
+ if (len < 6) {
3042
+ return false;
3043
+ }
3044
+ const eps = 1e-4;
3045
+ return Math.abs(v[0] - v[len - 2]) < eps && Math.abs(v[1] - v[len - 1]) < eps;
3046
+ }
2929
3047
  strokeTriangulate(options) {
2930
3048
  return strokeTriangulate(
2931
3049
  this.getAdaptiveVertices(),
2932
- options
3050
+ {
3051
+ ...options,
3052
+ closed: options?.closed ?? this.isClosed()
3053
+ }
2933
3054
  );
2934
3055
  }
2935
3056
  toCommands() {
@@ -3024,6 +3145,22 @@ class RoundCurve extends Curve {
3024
3145
  isClockwise() {
3025
3146
  return this.clockwise;
3026
3147
  }
3148
+ /**
3149
+ * A circle/ellipse arc is closed when it sweeps (at least) a full revolution — the sampled
3150
+ * outline does not duplicate the start vertex, so the geometric first==last test in the base
3151
+ * class would wrongly report a full circle as open and leave a seam gap in the stroke.
3152
+ */
3153
+ isClosed() {
3154
+ return Math.abs(this.endAngle - this.startAngle) >= Math.PI * 2 - 1e-9 || super.isClosed();
3155
+ }
3156
+ reverse() {
3157
+ const { startAngle, endAngle } = this;
3158
+ this.startAngle = endAngle;
3159
+ this.endAngle = startAngle;
3160
+ this.clockwise = !this.clockwise;
3161
+ this.invalidate();
3162
+ return this;
3163
+ }
3027
3164
  _getDeltaAngle() {
3028
3165
  const PI_2 = Math.PI * 2;
3029
3166
  let deltaAngle = this.endAngle - this.startAngle;
@@ -3280,9 +3417,25 @@ class RoundCurve extends Curve {
3280
3417
  return output;
3281
3418
  }
3282
3419
  getAdaptiveVertices(output = []) {
3283
- if (this.startAngle === 0 && this.endAngle === Math.PI * 2) {
3420
+ const PI2 = Math.PI * 2;
3421
+ if (this.startAngle === 0 && this.endAngle === PI2) {
3284
3422
  return this._getAdaptiveVerticesByCircle(output);
3285
3423
  }
3424
+ if (Math.abs(this.endAngle - this.startAngle) >= PI2 - 1e-9) {
3425
+ const tmp = this._getAdaptiveVerticesByCircle([]);
3426
+ const n = tmp.length / 2;
3427
+ if (this.endAngle > this.startAngle) {
3428
+ for (let i = 0; i < tmp.length; i++) {
3429
+ output.push(tmp[i]);
3430
+ }
3431
+ } else {
3432
+ output.push(tmp[0], tmp[1]);
3433
+ for (let i = n - 1; i >= 1; i--) {
3434
+ output.push(tmp[i * 2], tmp[i * 2 + 1]);
3435
+ }
3436
+ }
3437
+ return output;
3438
+ }
3286
3439
  return this._getAdaptiveVerticesByArc(output);
3287
3440
  }
3288
3441
  copyFrom(source) {
@@ -3492,6 +3645,15 @@ class LineCurve extends Curve {
3492
3645
  getControlPointRefs() {
3493
3646
  return [this.p1, this.p2];
3494
3647
  }
3648
+ // Swap endpoint *references* (not values) so corner Vector2s shared with adjacent
3649
+ // segments stay intact and simply re-associate with the reversed segment.
3650
+ reverse() {
3651
+ const { p1, p2 } = this;
3652
+ this.p1 = p2;
3653
+ this.p2 = p1;
3654
+ this.invalidate();
3655
+ return this;
3656
+ }
3495
3657
  getAdaptiveVertices(output = []) {
3496
3658
  output.push(
3497
3659
  this.p1.x,
@@ -3655,6 +3817,16 @@ class CompositeCurve extends Curve {
3655
3817
  });
3656
3818
  return output;
3657
3819
  }
3820
+ /**
3821
+ * A composite is closed when its single child is closed (e.g. a lone full-circle arc), or when
3822
+ * its assembled outline returns to its start (rectangles, polygons, multi-segment loops).
3823
+ */
3824
+ isClosed() {
3825
+ if (this.curves.length === 1) {
3826
+ return this.curves[0].isClosed();
3827
+ }
3828
+ return super.isClosed();
3829
+ }
3658
3830
  strokeTriangulate(options) {
3659
3831
  if (this.curves.length === 1) {
3660
3832
  return this.curves[0].strokeTriangulate(options);
@@ -3662,6 +3834,13 @@ class CompositeCurve extends Curve {
3662
3834
  return super.strokeTriangulate(options);
3663
3835
  }
3664
3836
  }
3837
+ /** Reverse the sub-curve order and reverse each sub-curve, so the whole outline runs backwards. */
3838
+ reverse() {
3839
+ this.curves.reverse();
3840
+ this.curves.forEach((curve) => curve.reverse());
3841
+ this.invalidate();
3842
+ return this;
3843
+ }
3665
3844
  getFillVertices(options) {
3666
3845
  if (this.curves.length === 1) {
3667
3846
  return this.curves[0].getFillVertices(options);
@@ -3757,6 +3936,16 @@ class CubicBezierCurve extends Curve {
3757
3936
  getControlPointRefs() {
3758
3937
  return [this.p1, this.cp1, this.cp2, this.p2];
3759
3938
  }
3939
+ // Swap endpoint and control-point references; keeps shared corner Vector2s intact.
3940
+ reverse() {
3941
+ const { p1, cp1, cp2, p2 } = this;
3942
+ this.p1 = p2;
3943
+ this.cp1 = cp2;
3944
+ this.cp2 = cp1;
3945
+ this.p2 = p1;
3946
+ this.invalidate();
3947
+ return this;
3948
+ }
3760
3949
  _solveQuadratic(a, b, c) {
3761
3950
  if (Math.abs(a) < 1e-12) {
3762
3951
  if (Math.abs(b) < 1e-12)
@@ -3917,6 +4106,14 @@ class QuadraticBezierCurve extends Curve {
3917
4106
  getControlPointRefs() {
3918
4107
  return [this.p1, this.cp, this.p2];
3919
4108
  }
4109
+ // Swap endpoint references (cp is symmetric); keeps shared corner Vector2s intact.
4110
+ reverse() {
4111
+ const { p1, p2 } = this;
4112
+ this.p1 = p2;
4113
+ this.p2 = p1;
4114
+ this.invalidate();
4115
+ return this;
4116
+ }
3920
4117
  getAdaptiveVertices(output = []) {
3921
4118
  return getAdaptiveQuadraticBezierCurvePoints(
3922
4119
  this.p1.x,
@@ -4118,6 +4315,11 @@ class SplineCurve extends Curve {
4118
4315
  getControlPointRefs() {
4119
4316
  return this.points;
4120
4317
  }
4318
+ reverse() {
4319
+ this.points.reverse();
4320
+ this.invalidate();
4321
+ return this;
4322
+ }
4121
4323
  copyFrom(source) {
4122
4324
  super.copyFrom(source);
4123
4325
  this.points = [];
@@ -4154,6 +4356,22 @@ class CurvePath extends CompositeCurve {
4154
4356
  this.addCommands(svgPathDataToCommands(data));
4155
4357
  return this;
4156
4358
  }
4359
+ /**
4360
+ * A sub-path is closed if it was explicitly closed (`autoClose`, i.e. a `Z`/`closePath`), or if
4361
+ * it forms a geometric loop / wraps a single closed primitive (handled by the base class).
4362
+ */
4363
+ isClosed() {
4364
+ return this.autoClose || super.isClosed();
4365
+ }
4366
+ /** Reverse direction, then refresh the {@link startPoint}/{@link currentPoint} cursors. */
4367
+ reverse() {
4368
+ super.reverse();
4369
+ if (this.curves.length) {
4370
+ this.startPoint = this.getPoint(0);
4371
+ this.currentPoint = this.getPoint(1);
4372
+ }
4373
+ return this;
4374
+ }
4157
4375
  _closeVertices(output) {
4158
4376
  if (this.autoClose && output.length >= 4 && (output[0] !== output[output.length - 2] && output[1] !== output[output.length - 1])) {
4159
4377
  output.push(output[0], output[1]);
@@ -4590,6 +4808,51 @@ class Path2D extends CompositeCurve {
4590
4808
  const fillRule = options.fillRule ?? this.style.fillRule ?? "nonzero";
4591
4809
  return pointInPolygons(point, this._getRings(), fillRule);
4592
4810
  }
4811
+ /** Build a `Path2D` from flat rings (`[x0,y0,…]` per sub-path); closed-and-filled as sub-paths. */
4812
+ static fromRings(rings, style = {}) {
4813
+ const path = new Path2D(void 0, style);
4814
+ for (const ring of rings) {
4815
+ if (ring.length < 6) {
4816
+ continue;
4817
+ }
4818
+ let end = ring.length;
4819
+ if (ring[0] === ring[end - 2] && ring[1] === ring[end - 1]) {
4820
+ end -= 2;
4821
+ }
4822
+ path.moveTo(ring[0], ring[1]);
4823
+ for (let i = 2; i < end; i += 2) {
4824
+ path.lineTo(ring[i], ring[i + 1]);
4825
+ }
4826
+ path.closePath();
4827
+ }
4828
+ return path;
4829
+ }
4830
+ /**
4831
+ * Boolean (path) operation against another path, returning a NEW `Path2D` whose outline is the
4832
+ * polygonal result. Curves are sampled before clipping, so the result is a polygonal
4833
+ * approximation (see {@link polygonBoolean}). The result inherits this path's `style` unless
4834
+ * overridden via `style`. Holes are emitted as oppositely-wound sub-paths (nonzero fill).
4835
+ */
4836
+ booleanOp(op, other, style) {
4837
+ const rings = polygonBoolean(op, this._getRings(), other._getRings());
4838
+ return Path2D.fromRings(rings, { ...this.style, ...style });
4839
+ }
4840
+ /** `this ∪ other` — the combined filled area. */
4841
+ union(other, style) {
4842
+ return this.booleanOp("union", other, style);
4843
+ }
4844
+ /** `this ∩ other` — only the overlapping area. */
4845
+ intersection(other, style) {
4846
+ return this.booleanOp("intersection", other, style);
4847
+ }
4848
+ /** `this − other` — this path with `other` cut away. */
4849
+ difference(other, style) {
4850
+ return this.booleanOp("difference", other, style);
4851
+ }
4852
+ /** `this ⊕ other` — areas covered by exactly one of the two paths. */
4853
+ xor(other, style) {
4854
+ return this.booleanOp("xor", other, style);
4855
+ }
4593
4856
  /**
4594
4857
  * Test whether a point lies on this path's stroke. A hit on any sub-path counts.
4595
4858
  *
@@ -4689,7 +4952,7 @@ class Path2D extends CompositeCurve {
4689
4952
  }
4690
4953
  drawTo(ctx, style = {}) {
4691
4954
  style = { ...this.style, ...style };
4692
- const { fill = "#000", stroke = "none" } = style;
4955
+ const { fill = "#000", stroke = "none", fillRule = "nonzero" } = style;
4693
4956
  ctx.beginPath();
4694
4957
  ctx.save();
4695
4958
  setCanvasContext(ctx, style);
@@ -4697,7 +4960,7 @@ class Path2D extends CompositeCurve {
4697
4960
  path.drawTo(ctx);
4698
4961
  });
4699
4962
  if (fill !== "none") {
4700
- ctx.fill();
4963
+ ctx.fill(fillRule);
4701
4964
  }
4702
4965
  if (stroke !== "none") {
4703
4966
  ctx.stroke();
@@ -4903,6 +5166,44 @@ ${content}
4903
5166
  }
4904
5167
  }
4905
5168
 
5169
+ class PathMeasure {
5170
+ constructor(curve) {
5171
+ this.curve = curve;
5172
+ }
5173
+ /** Total arc length of the path. */
5174
+ getLength() {
5175
+ return this.curve.getLength();
5176
+ }
5177
+ /** Whether the path forms a closed loop (see {@link Curve.isClosed}). */
5178
+ isClosed() {
5179
+ return this.curve.isClosed();
5180
+ }
5181
+ /** Point + unit tangent + tangent angle at an absolute arc-length `distance` (clamped). */
5182
+ getPosTan(distance) {
5183
+ return this.curve.getPosTan(distance);
5184
+ }
5185
+ /** Point at an absolute arc-length `distance` (clamped to `[0, getLength()]`). */
5186
+ getPosition(distance) {
5187
+ return this.curve.getPosTan(distance).position;
5188
+ }
5189
+ /** Point + tangent at a normalized progress `t ∈ [0, 1]` along the path. */
5190
+ getPosTanAtProgress(t) {
5191
+ return this.curve.getPosTan(this.getLength() * t);
5192
+ }
5193
+ /**
5194
+ * Evenly sample the path into `count + 1` {@link PosTan} entries (arc-length spaced), e.g. to
5195
+ * lay glyphs along a path or drive an `animate(progress)`-style traversal.
5196
+ */
5197
+ sample(count = 100) {
5198
+ const length = this.getLength();
5199
+ const out = [];
5200
+ for (let i = 0; i <= count; i++) {
5201
+ out.push(this.curve.getPosTan(length * i / count));
5202
+ }
5203
+ return out;
5204
+ }
5205
+ }
5206
+
4906
5207
  class FFDControlGrid {
4907
5208
  constructor(rows, cols, width = 1, height = 1) {
4908
5209
  this.rows = rows;
@@ -4958,4 +5259,4 @@ function applyFFD(point, grid, width = grid.width, height = grid.height) {
4958
5259
  point.set(x, y);
4959
5260
  }
4960
5261
 
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 };
5262
+ export { ArcCurve, BoundingBox, CompositeCurve, CubicBezierCurve, Curve, CurvePath, EllipseCurve, EquilateralPolygonCurve, FFDControlGrid, LineCurve, PI, PI_2, Path2D, Path2DSet, PathMeasure, PolygonCurve, QuadraticBezierCurve, RectangleCurve, RoundRectangleCurve, SplineCurve, Transform2D, Vector2, applyFFD, catmullRom, cubicBezier, drawPoint, fillTriangulate, getAdaptiveCubicBezierCurvePoints, getAdaptiveQuadraticBezierCurvePoints, getDirectedArea, getIntersectionPoint, nonzeroFillRule, parseArcCommand, parseCssArg, parseCssArgs, parseCssFunctions, parsePathDataArgs, pointInPolygon, pointInPolygons, pointToPolylineDistance, pointToSegmentDistance, polygonBoolean, quadraticBezier, resolveLineStyle, setCanvasContext, strokeTriangulate, svgPathCommandsAddToPath2D, svgPathCommandsToData, svgPathDataToCommands, svgToDom, svgToPath2DSet, toKebabCase };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "modern-path2d",
3
3
  "type": "module",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "packageManager": "pnpm@9.15.1",
6
6
  "description": "A Path2D library, fully compatible with Web Path2D, with additional support for triangulate、animation、deformation etc.",
7
7
  "author": "wxm",
@@ -56,7 +56,8 @@
56
56
  "typecheck": "tsc --noEmit"
57
57
  },
58
58
  "dependencies": {
59
- "earcut": "^3.0.2"
59
+ "earcut": "^3.0.2",
60
+ "polygon-clipping": "^0.15.7"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@antfu/eslint-config": "^8.2.0",