@pixerate/editor 0.34.0 → 0.35.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.
@@ -4,23 +4,33 @@ import {
4
4
  import {
5
5
  bezierPathBounds,
6
6
  bezierPathToSvg,
7
+ closeContour,
8
+ contourSignedArea,
9
+ contoursToPath,
7
10
  distanceToBezierPath,
11
+ fitCubicWithTangents,
8
12
  fitCubics,
9
13
  formatNumber,
10
14
  gradientToSvgDef,
15
+ normalizeBezierPath,
16
+ outlineStroke,
11
17
  pathBoolean,
18
+ pathToContours,
12
19
  pointInBezierPath,
20
+ segmentBounds,
13
21
  segmentDerivative,
14
22
  segmentIntersections,
15
23
  segmentNearest,
16
24
  segmentPoint,
17
25
  splitSegment,
26
+ subSegment,
18
27
  toBezierPath,
19
- transformBezierPath
20
- } from "../chunk-RK6HUKDI.js";
28
+ transformBezierPath,
29
+ windingNumber
30
+ } from "../chunk-EGPIVUEF.js";
21
31
 
22
32
  // src/vector/types.ts
23
- var VECTOR_TOOLS = ["select", "direct", "pen", "pencil", "rect", "ellipse", "polygon", "star", "line"];
33
+ var VECTOR_TOOLS = ["select", "direct", "pen", "pencil", "rect", "ellipse", "polygon", "star", "line", "scissors", "knife", "eraser"];
24
34
  var VECTOR_TRANSFORM_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w", "rotate"];
25
35
 
26
36
  // src/vector/geometry.ts
@@ -1029,13 +1039,13 @@ var VectorSnapTargets = class _VectorSnapTargets {
1029
1039
  return budget;
1030
1040
  }
1031
1041
  pushRect(r, kind, centre) {
1032
- const corners = [
1042
+ const corners2 = [
1033
1043
  { x: r.x, y: r.y },
1034
1044
  { x: r.x + r.width, y: r.y },
1035
1045
  { x: r.x + r.width, y: r.y + r.height },
1036
1046
  { x: r.x, y: r.y + r.height }
1037
1047
  ];
1038
- for (const p of corners) {
1048
+ for (const p of corners2) {
1039
1049
  this.xs.push({ v: p.x, from: p, kind });
1040
1050
  this.ys.push({ v: p.y, from: p, kind });
1041
1051
  }
@@ -1136,14 +1146,14 @@ var VectorSnapTargets = class _VectorSnapTargets {
1136
1146
  }
1137
1147
  const point = this.nearestPoint(p, tol);
1138
1148
  if (point) return at(point.p, point.kind);
1139
- const near = this.segmentsNear(p, tol);
1140
- const crossing = this.crossing(near, p, tol);
1149
+ const near2 = this.segmentsNear(p, tol);
1150
+ const crossing = this.crossing(near2, p, tol);
1141
1151
  if (crossing) return at(crossing, "intersect");
1142
1152
  if (leave && leave.curves !== false) {
1143
- const foot = this.curvePoint(near, leave.at, p, tol);
1153
+ const foot = this.curvePoint(near2, leave.at, p, tol);
1144
1154
  if (foot) return at(foot.point, foot.kind, [line("construction", leave.at, foot.point)]);
1145
1155
  }
1146
- const on = this.onSegment(near);
1156
+ const on = this.onSegment(near2);
1147
1157
  if (on) return at(on, "path");
1148
1158
  if (leave?.angles?.length) {
1149
1159
  const c = construction(leave, p, tol);
@@ -1226,12 +1236,12 @@ var VectorSnapTargets = class _VectorSnapTargets {
1226
1236
  }
1227
1237
  return out.sort((a, b) => a.distance - b.distance);
1228
1238
  }
1229
- onSegment(near) {
1230
- return near.length ? toVec(segmentPoint(near[0].seg, near[0].t)) : null;
1239
+ onSegment(near2) {
1240
+ return near2.length ? toVec(segmentPoint(near2[0].seg, near2[0].t)) : null;
1231
1241
  }
1232
1242
  /** Where two of the target segments near `p` cross, the crossing nearest `p` within `tol`. */
1233
- crossing(near, p, tol) {
1234
- const list = near.slice(0, CROSSING_PAIRS);
1243
+ crossing(near2, p, tol) {
1244
+ const list = near2.slice(0, CROSSING_PAIRS);
1235
1245
  let best = null;
1236
1246
  let bestD = Infinity;
1237
1247
  for (let i = 0; i < list.length; i++) {
@@ -1253,9 +1263,9 @@ var VectorSnapTargets = class _VectorSnapTargets {
1253
1263
  * (within `tol` of it): roots of `(B(t) − base) · B′(t)` and `(B(t) − base) × B′(t)`
1254
1264
  * bracketed by sign changes of samples and narrowed by bisection.
1255
1265
  */
1256
- curvePoint(near, base, p, tol) {
1266
+ curvePoint(near2, base, p, tol) {
1257
1267
  let best = null;
1258
- for (const { seg } of near) {
1268
+ for (const { seg } of near2) {
1259
1269
  for (const kind of ["perpendicular", "tangent"]) {
1260
1270
  if (kind === "tangent" && seg.length === 2) continue;
1261
1271
  for (const q of solveOnSegment(seg, base, kind === "perpendicular" ? dotCondition : crossCondition)) {
@@ -1603,8 +1613,968 @@ function solveOnSegment(seg, base, cond) {
1603
1613
  return out;
1604
1614
  }
1605
1615
 
1616
+ // src/vector/pathEdit.ts
1617
+ var EPS2 = 1e-9;
1618
+ var unit = (x, y) => {
1619
+ const l = Math.hypot(x, y);
1620
+ return l > 1e-12 && Number.isFinite(l) ? [x / l, y / l] : null;
1621
+ };
1622
+ function cubicOf(sp, i) {
1623
+ const s = subpathSegment(sp, i);
1624
+ if (s.length === 4) return s;
1625
+ const [a, b] = s;
1626
+ return [a, [a[0] + (b[0] - a[0]) / 3, a[1] + (b[1] - a[1]) / 3], [a[0] + 2 * (b[0] - a[0]) / 3, a[1] + 2 * (b[1] - a[1]) / 3], b];
1627
+ }
1628
+ var isLine = (sp, i) => subpathSegment(sp, i).length === 2;
1629
+ function startTangent(c) {
1630
+ for (let k = 1; k < 4; k++) {
1631
+ const u = unit(c[k][0] - c[0][0], c[k][1] - c[0][1]);
1632
+ if (u) return u;
1633
+ }
1634
+ return [1, 0];
1635
+ }
1636
+ function endTangent(c) {
1637
+ for (let k = 2; k >= 0; k--) {
1638
+ const u = unit(c[3][0] - c[k][0], c[3][1] - c[k][1]);
1639
+ if (u) return u;
1640
+ }
1641
+ return [1, 0];
1642
+ }
1643
+ function sample(c, n, out, withStart) {
1644
+ for (let i = withStart ? 0 : 1; i <= n; i++) out.push(segmentPoint(c, i / n));
1645
+ }
1646
+ var onAnchor = (a, h) => !h || Math.abs(h.x - a.x) < EPS2 && Math.abs(h.y - a.y) < EPS2;
1647
+ function tidy(a, keepType = true) {
1648
+ const next = cloneAnchor(a);
1649
+ if (onAnchor(next, next.in)) delete next.in;
1650
+ if (onAnchor(next, next.out)) delete next.out;
1651
+ const shown = inferAnchorType(next);
1652
+ const type = keepType && a.type ? a.type === "symmetric" && shown !== "symmetric" ? shown === "corner" ? "corner" : "smooth" : a.type === "smooth" && shown === "corner" ? "corner" : a.type : shown;
1653
+ if (type === "corner") delete next.type;
1654
+ else next.type = type;
1655
+ return next;
1656
+ }
1657
+ function neighbours(sp, i) {
1658
+ const n = sp.anchors.length;
1659
+ const wrap = sp.closed && n > 1;
1660
+ return [i > 0 ? i - 1 : wrap ? n - 1 : -1, i < n - 1 ? i + 1 : wrap ? 0 : -1];
1661
+ }
1662
+ function goldenMin(f, a, b) {
1663
+ const R = 0.618033988749895;
1664
+ let x1 = b - R * (b - a);
1665
+ let x2 = a + R * (b - a);
1666
+ let f1 = f(x1);
1667
+ let f2 = f(x2);
1668
+ for (let i = 0; i < 40; i++) {
1669
+ if (f1.error < f2.error || Number.isNaN(f2.error)) {
1670
+ b = x2;
1671
+ x2 = x1;
1672
+ f2 = f1;
1673
+ x1 = b - R * (b - a);
1674
+ f1 = f(x1);
1675
+ } else {
1676
+ a = x1;
1677
+ x1 = x2;
1678
+ f1 = f2;
1679
+ x2 = a + R * (b - a);
1680
+ f2 = f(x2);
1681
+ }
1682
+ }
1683
+ return f1.error <= f2.error ? f1 : f2;
1684
+ }
1685
+ function fitAcross(pts, N, t0, t1) {
1686
+ const fit = (k) => {
1687
+ const u = [];
1688
+ for (let i = 0; i <= N; i++) u.push(k * i / N);
1689
+ for (let i = 1; i <= N; i++) u.push(k + (1 - k) * i / N);
1690
+ return fitCubicWithTangents(pts, u, t0, t1);
1691
+ };
1692
+ return goldenMin(fit, 0, 1);
1693
+ }
1694
+ function removeAnchorKeepingShape(sp, index) {
1695
+ const anchors = sp.anchors.map(cloneAnchor);
1696
+ const n = anchors.length;
1697
+ if (index < 0 || index >= n) return { anchors, closed: sp.closed };
1698
+ if ((sp.closed || index > 0 && index < n - 1) && n >= 3) {
1699
+ const pi = (index + n - 1) % n;
1700
+ const ni = (index + 1) % n;
1701
+ if (!(isLine(sp, pi) && isLine(sp, index))) {
1702
+ const left = cubicOf(sp, pi);
1703
+ const right = cubicOf(sp, index);
1704
+ const N = 16;
1705
+ const pts = [];
1706
+ sample(left, N, pts, true);
1707
+ sample(right, N, pts, false);
1708
+ const t0 = startTangent(left);
1709
+ const t1 = endTangent(right);
1710
+ const { curve } = fitAcross(pts, N, t0, t1);
1711
+ if ([curve.p1, curve.p2].every((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]))) {
1712
+ anchors[pi] = tidy({ ...anchors[pi], out: { x: curve.p1[0], y: curve.p1[1] } });
1713
+ anchors[ni] = tidy({ ...anchors[ni], in: { x: curve.p2[0], y: curve.p2[1] } });
1714
+ }
1715
+ }
1716
+ }
1717
+ anchors.splice(index, 1);
1718
+ let closed = sp.closed;
1719
+ if (!closed && anchors.length) {
1720
+ anchors[0] = tidy({ ...anchors[0], in: null });
1721
+ const last = anchors.length - 1;
1722
+ anchors[last] = tidy({ ...anchors[last], out: null });
1723
+ }
1724
+ if (anchors.length < 3) closed = closed && anchors.length === 2 && anchors.some((a) => a.in || a.out);
1725
+ return { anchors, closed };
1726
+ }
1727
+ function removeAnchorsKeepingShape(sp, indices) {
1728
+ const order = [...new Set(indices)].filter((i) => i >= 0 && i < sp.anchors.length).sort((a, b) => b - a);
1729
+ return order.reduce((acc, i) => removeAnchorKeepingShape(acc, i), { anchors: sp.anchors.map(cloneAnchor), closed: sp.closed });
1730
+ }
1731
+ function bendSegment(sp, segment, t, p) {
1732
+ const anchors = sp.anchors.map(cloneAnchor);
1733
+ const n = anchors.length;
1734
+ if (segment < 0 || segment >= subpathSegmentCount(sp) || !Number.isFinite(t) || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return { anchors, closed: sp.closed };
1735
+ const tc = Math.min(0.95, Math.max(0.05, t));
1736
+ const ia = segment;
1737
+ const ib = (segment + 1) % n;
1738
+ const a = anchors[ia];
1739
+ const b = anchors[ib];
1740
+ const k = 3 * tc * (1 - tc);
1741
+ const ox = (p.x - (a.x + (b.x - a.x) * tc)) / k;
1742
+ const oy = (p.y - (a.y + (b.y - a.y) * tc)) / k;
1743
+ const c1 = { x: a.x + (b.x - a.x) / 3 + ox, y: a.y + (b.y - a.y) / 3 + oy };
1744
+ const c2 = { x: b.x + (a.x - b.x) / 3 + ox, y: b.y + (a.y - b.y) / 3 + oy };
1745
+ anchors[ia] = moveAnchorHandle(a, "out", c1);
1746
+ anchors[ib] = moveAnchorHandle(b, "in", c2);
1747
+ return { anchors, closed: sp.closed };
1748
+ }
1749
+ function subpathPointAt(sp, segment, t) {
1750
+ const [x, y] = segmentPoint(subpathSegment(sp, segment), t);
1751
+ return { x, y };
1752
+ }
1753
+ function curvatureMoveAnchor(sp, index, q) {
1754
+ const anchors = sp.anchors.map(cloneAnchor);
1755
+ const a = sp.anchors[index];
1756
+ if (!a) return { anchors, closed: sp.closed };
1757
+ const own = [
1758
+ a.in && !onAnchor(a, a.in) ? { x: a.in.x - a.x, y: a.in.y - a.y } : null,
1759
+ a.out && !onAnchor(a, a.out) ? { x: a.out.x - a.x, y: a.out.y - a.y } : null
1760
+ ];
1761
+ const ends = [null, null];
1762
+ neighbours(sp, index).forEach((j, side) => {
1763
+ if (j < 0 || j === index) return;
1764
+ const b = anchors[j];
1765
+ const old = Math.hypot(a.x - b.x, a.y - b.y);
1766
+ const s = old > 1e-9 ? Math.hypot(q.x - b.x, q.y - b.y) / old : 1;
1767
+ const key = side === 0 ? "out" : "in";
1768
+ const h = b[key];
1769
+ if (h) b[key] = { x: b.x + (h.x - b.x) * s, y: b.y + (h.y - b.y) * s };
1770
+ const o = own[side];
1771
+ if (o) own[side] = { x: o.x * s, y: o.y * s };
1772
+ ends[side] = { x: b.x, y: b.y };
1773
+ });
1774
+ const moved = { x: q.x, y: q.y };
1775
+ if (own[0]) moved.in = { x: q.x + own[0].x, y: q.y + own[0].y };
1776
+ if (own[1]) moved.out = { x: q.x + own[1].x, y: q.y + own[1].y };
1777
+ if (a.type) moved.type = a.type;
1778
+ const [prev, next] = ends;
1779
+ if (a.type && a.type !== "corner" && prev && next) {
1780
+ const dx = next.x - prev.x;
1781
+ const dy = next.y - prev.y;
1782
+ const l = Math.hypot(dx, dy);
1783
+ if (l > 1e-9) {
1784
+ const len = (h, to) => h ? Math.hypot(h.x, h.y) : Math.hypot(q.x - to.x, q.y - to.y) / 3;
1785
+ let li = len(own[0], prev);
1786
+ let lo = len(own[1], next);
1787
+ if (a.type === "symmetric") li = lo = (li + lo) / 2;
1788
+ moved.in = { x: q.x - dx * li / l, y: q.y - dy * li / l };
1789
+ moved.out = { x: q.x + dx * lo / l, y: q.y + dy * lo / l };
1790
+ }
1791
+ }
1792
+ anchors[index] = moved;
1793
+ return { anchors, closed: sp.closed };
1794
+ }
1795
+ function cutSubpathAt(sp, at) {
1796
+ const n = sp.anchors.length;
1797
+ const cuts = [...new Set(at)].filter((i) => Number.isInteger(i) && i >= 0 && i < n && (sp.closed || i > 0 && i < n - 1)).sort((a, b) => a - b);
1798
+ if (!cuts.length || n < 2) return [{ anchors: sp.anchors.map(cloneAnchor), closed: sp.closed }];
1799
+ const first = cuts[0];
1800
+ const run = sp.closed ? Array.from({ length: n + 1 }, (_, i) => sp.anchors[(first + i) % n]) : sp.anchors;
1801
+ const cutSet = new Set(sp.closed ? [...cuts.map((c) => (c - first + n) % n), n] : cuts);
1802
+ const pieces = [];
1803
+ let cur = [];
1804
+ run.forEach((a, i) => {
1805
+ if (!cutSet.has(i)) {
1806
+ cur.push(cloneAnchor(a));
1807
+ return;
1808
+ }
1809
+ if (cur.length) {
1810
+ cur.push(tidy({ ...a, out: null }, false));
1811
+ pieces.push({ anchors: cur, closed: false });
1812
+ cur = [];
1813
+ }
1814
+ if (i < n) cur.push(tidy({ ...a, in: null }, false));
1815
+ });
1816
+ if (cur.length > 1) pieces.push({ anchors: cur, closed: false });
1817
+ return pieces;
1818
+ }
1819
+ function splitSubpathAt(sp, segment, t, endTolerance = 1e-6) {
1820
+ const n = sp.anchors.length;
1821
+ if (segment < 0 || segment >= subpathSegmentCount(sp)) return [{ anchors: sp.anchors.map(cloneAnchor), closed: sp.closed }];
1822
+ if (t <= endTolerance) return cutSubpathAt(sp, [segment]);
1823
+ if (t >= 1 - endTolerance) return cutSubpathAt(sp, [(segment + 1) % n]);
1824
+ const { subpath, index } = insertAnchor(sp, segment, t);
1825
+ return cutSubpathAt(subpath, [index]);
1826
+ }
1827
+ var endOf = (sp, last) => sp.anchors[last ? sp.anchors.length - 1 : 0];
1828
+ var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
1829
+ function reverse(sp) {
1830
+ return {
1831
+ closed: sp.closed,
1832
+ anchors: sp.anchors.slice().reverse().map((a) => {
1833
+ const next = { x: a.x, y: a.y };
1834
+ if (a.out) next.in = { ...a.out };
1835
+ if (a.in) next.out = { ...a.in };
1836
+ if (a.type) next.type = a.type;
1837
+ return next;
1838
+ })
1839
+ };
1840
+ }
1841
+ function joinSubpaths(subpaths, tolerance) {
1842
+ const closed = [];
1843
+ let open = [];
1844
+ for (const sp of subpaths) {
1845
+ const copy = { anchors: sp.anchors.map(cloneAnchor), closed: sp.closed };
1846
+ if (sp.closed || sp.anchors.length < 2) {
1847
+ if (sp.anchors.length) closed.push(copy);
1848
+ } else open.push(copy);
1849
+ }
1850
+ if (open.length === 1) {
1851
+ const sp = open[0];
1852
+ const first = sp.anchors[0];
1853
+ const last = sp.anchors[sp.anchors.length - 1];
1854
+ if (sp.anchors.length > 2 && dist(first, last) <= tolerance) {
1855
+ sp.anchors.pop();
1856
+ sp.anchors[0] = tidy({ ...first, in: last.in ?? null }, false);
1857
+ } else {
1858
+ sp.anchors[0] = tidy({ ...first, in: null }, false);
1859
+ sp.anchors[sp.anchors.length - 1] = tidy({ ...sp.anchors[sp.anchors.length - 1], out: null }, false);
1860
+ }
1861
+ sp.closed = true;
1862
+ return [...closed, sp];
1863
+ }
1864
+ while (open.length > 1) {
1865
+ let best = { d: Infinity, i: 0, j: 1, ei: true, ej: false };
1866
+ for (let i = 0; i < open.length; i++) {
1867
+ for (let j = i + 1; j < open.length; j++) {
1868
+ for (const ei of [false, true]) {
1869
+ for (const ej of [false, true]) {
1870
+ const d = dist(endOf(open[i], ei), endOf(open[j], ej));
1871
+ if (d < best.d) best = { d, i, j, ei, ej };
1872
+ }
1873
+ }
1874
+ }
1875
+ }
1876
+ let a = open[best.i];
1877
+ let b = open[best.j];
1878
+ open = open.filter((_, k) => k !== best.j);
1879
+ if (!best.ei) a = reverse(a);
1880
+ if (best.ej) b = reverse(b);
1881
+ const anchors = a.anchors;
1882
+ if (best.d <= tolerance) {
1883
+ const [head, ...rest] = b.anchors;
1884
+ const l = anchors[anchors.length - 1];
1885
+ anchors[anchors.length - 1] = tidy({ ...l, out: head.out ?? null }, false);
1886
+ anchors.push(...rest);
1887
+ } else {
1888
+ const l = anchors[anchors.length - 1];
1889
+ anchors[anchors.length - 1] = tidy({ ...l, out: null }, false);
1890
+ anchors.push(tidy({ ...b.anchors[0], in: null }, false), ...b.anchors.slice(1));
1891
+ }
1892
+ open[best.i > best.j ? best.i - 1 : best.i] = { anchors, closed: false };
1893
+ }
1894
+ return [...closed, ...open];
1895
+ }
1896
+ function smoothSubpath(sp, amount = 1, only) {
1897
+ const t = Math.min(1, Math.max(0, amount));
1898
+ const orig = sp.anchors;
1899
+ const n = orig.length;
1900
+ const anchors = orig.map(cloneAnchor);
1901
+ if (n < 3 || t === 0) return { anchors, closed: sp.closed };
1902
+ for (let i = 0; i < n; i++) {
1903
+ if (!sp.closed && (i === 0 || i === n - 1)) continue;
1904
+ if (only && !only.has(i)) continue;
1905
+ const prev = orig[(i + n - 1) % n];
1906
+ const next = orig[(i + 1) % n];
1907
+ const p = orig[i];
1908
+ const dir = unit(next.x - prev.x, next.y - prev.y);
1909
+ if (!dir) continue;
1910
+ const li = dist(p, prev) / 3;
1911
+ const lo = dist(p, next) / 3;
1912
+ const hin = p.in ?? p;
1913
+ const hout = p.out ?? p;
1914
+ const target = (h, x, y) => ({ x: h.x + (x - h.x) * t, y: h.y + (y - h.y) * t });
1915
+ anchors[i] = tidy(
1916
+ {
1917
+ x: p.x,
1918
+ y: p.y,
1919
+ in: target(hin, p.x - dir[0] * li, p.y - dir[1] * li),
1920
+ out: target(hout, p.x + dir[0] * lo, p.y + dir[1] * lo)
1921
+ },
1922
+ false
1923
+ );
1924
+ }
1925
+ return { anchors, closed: sp.closed };
1926
+ }
1927
+ function segsOf(sp) {
1928
+ const out = [];
1929
+ for (let i = 0; i < subpathSegmentCount(sp); i++) {
1930
+ const s = subpathSegment(sp, i);
1931
+ out.push(s.length === 2 ? { c: s, line: true } : { c: s, line: false });
1932
+ }
1933
+ return out;
1934
+ }
1935
+ function segsToSubpath(segs, closed, types) {
1936
+ if (!segs.length) return null;
1937
+ const anchors = [];
1938
+ const at = (p) => ({ x: p[0], y: p[1] });
1939
+ anchors.push(at(segs[0].c[0]));
1940
+ for (const s of segs) {
1941
+ const end = s.c[s.c.length - 1];
1942
+ if (!s.line) anchors[anchors.length - 1].out = { x: s.c[1][0], y: s.c[1][1] };
1943
+ const next = at(end);
1944
+ if (!s.line) next.in = { x: s.c[2][0], y: s.c[2][1] };
1945
+ anchors.push(next);
1946
+ }
1947
+ if (closed) {
1948
+ const last = anchors.pop();
1949
+ if (last.in) anchors[0].in = last.in;
1950
+ }
1951
+ const typed = anchors.map((a) => {
1952
+ const type = types.get(`${a.x},${a.y}`);
1953
+ return tidy(type ? { ...a, type } : a, !!type);
1954
+ });
1955
+ return { anchors: typed, closed };
1956
+ }
1957
+ var isZeroSeg = (s) => s.c.every((p) => Math.abs(p[0] - s.c[0][0]) < 1e-9 && Math.abs(p[1] - s.c[0][1]) < 1e-9);
1958
+ function mergePair(a, b, tol) {
1959
+ const a0 = a.c[0];
1960
+ const a3 = a.c[a.c.length - 1];
1961
+ const b3 = b.c[b.c.length - 1];
1962
+ if (a.line && b.line) {
1963
+ const cx = b3[0] - a0[0];
1964
+ const cy = b3[1] - a0[1];
1965
+ const len = Math.hypot(cx, cy);
1966
+ if (len < 1e-12) return null;
1967
+ const mx = a3[0] - a0[0];
1968
+ const my = a3[1] - a0[1];
1969
+ const along = (mx * cx + my * cy) / len;
1970
+ return Math.abs((mx * cy - my * cx) / len) <= tol && along >= 0 && along <= len ? { c: [a0, b3], line: true } : null;
1971
+ }
1972
+ const ca = a.line ? [a0, a0, a3, a3] : a.c;
1973
+ const cb = b.line ? [a3, a3, b3, b3] : b.c;
1974
+ const ta = endTangent(ca);
1975
+ const tb = startTangent(cb);
1976
+ if (ta[0] * tb[0] + ta[1] * tb[1] < 0.99) return null;
1977
+ const pts = [];
1978
+ sample(ca, 16, pts, true);
1979
+ sample(cb, 16, pts, false);
1980
+ const { curve, error } = fitAcross(pts, 16, startTangent(ca), endTangent(cb));
1981
+ return error <= tol ? { c: [curve.p0, curve.p1, curve.p2, curve.p3], line: false } : null;
1982
+ }
1983
+ function removeRedundantPoints(sp, tolerance) {
1984
+ const tol = Math.max(tolerance, 1e-9);
1985
+ const types = new Map(sp.anchors.filter((a) => a.type).map((a) => [`${a.x},${a.y}`, a.type]));
1986
+ const segs = segsOf(sp).filter((s) => !isZeroSeg(s));
1987
+ if (!segs.length) return { anchors: sp.anchors.map(cloneAnchor), closed: sp.closed };
1988
+ const minSegs = sp.closed ? 2 : 1;
1989
+ let changed = true;
1990
+ while (changed && segs.length > minSegs) {
1991
+ changed = false;
1992
+ const n = segs.length;
1993
+ const joints = sp.closed ? n : n - 1;
1994
+ for (let j = 0; j < joints; j++) {
1995
+ const [i0, i1] = sp.closed ? [(j + n - 1) % n, j] : [j, j + 1];
1996
+ const m = mergePair(segs[i0], segs[i1], tol);
1997
+ if (!m) continue;
1998
+ segs[i0] = m;
1999
+ segs.splice(i1, 1);
2000
+ if (sp.closed && i1 === 0) segs.unshift(segs.pop());
2001
+ changed = true;
2002
+ break;
2003
+ }
2004
+ }
2005
+ return segsToSubpath(segs, sp.closed, types) ?? { anchors: sp.anchors.map(cloneAnchor), closed: sp.closed };
2006
+ }
2007
+
2008
+ // src/vector/pathCut.ts
2009
+ var loopArea = (l) => contourSignedArea(l.segs);
2010
+ function loopSample(l) {
2011
+ const k = l.chord.findIndex((c) => !c);
2012
+ return segmentPoint(l.segs[k >= 0 ? k : 0], 0.5);
2013
+ }
2014
+ var contains2 = (l, p) => windingNumber([l.segs], p) !== 0;
2015
+ function groupFaces(loops) {
2016
+ const usable = loops.filter((l) => l.segs.length && Math.abs(loopArea(l)) > 1e-12);
2017
+ if (!usable.length) return [];
2018
+ const largest = usable.reduce((a, b) => Math.abs(loopArea(b)) > Math.abs(loopArea(a)) ? b : a);
2019
+ const sign = Math.sign(loopArea(largest));
2020
+ const outers = usable.filter((l) => Math.sign(loopArea(l)) === sign).sort((a, b) => Math.abs(loopArea(a)) - Math.abs(loopArea(b)));
2021
+ const faces = new Map(outers.map((o) => [o, { loops: [o] }]));
2022
+ for (const hole of usable.filter((l) => Math.sign(loopArea(l)) !== sign)) {
2023
+ const p = loopSample(hole);
2024
+ const parent = outers.find((o) => Math.abs(loopArea(o)) > Math.abs(loopArea(hole)) && contains2(o, p));
2025
+ if (parent) faces.get(parent).loops.push(hole);
2026
+ }
2027
+ return outers.reverse().map((o) => faces.get(o));
2028
+ }
2029
+ function faceToPath(face) {
2030
+ return contoursToPath(face.loops.map((l) => ({ segments: l.segs, closed: true })));
2031
+ }
2032
+ function facesOf(path, fillRule) {
2033
+ const normalized = normalizeBezierPath(path, fillRule);
2034
+ const loops = pathToContours(normalized).map((c) => {
2035
+ const segs = closeContour(c, true).map((s) => s.slice());
2036
+ return { segs, chord: segs.map(() => false) };
2037
+ });
2038
+ return groupFaces(loops);
2039
+ }
2040
+ function splitIntoFaces(path, fillRule = "nonzero") {
2041
+ return facesOf(path, fillRule).map(faceToPath);
2042
+ }
2043
+ function rootsInUnit(a, b, c, d, scale) {
2044
+ const f = (t) => ((a * t + b) * t + c) * t + d;
2045
+ const tol = 1e-12 * Math.max(1, scale);
2046
+ const cuts = [0];
2047
+ const qa = 3 * a;
2048
+ const qb = 2 * b;
2049
+ if (Math.abs(qa) > 1e-18) {
2050
+ const disc = qb * qb - 4 * qa * c;
2051
+ if (disc >= 0) {
2052
+ const r = Math.sqrt(disc);
2053
+ for (const t of [(-qb - r) / (2 * qa), (-qb + r) / (2 * qa)].sort((x, y) => x - y)) if (t > 0 && t < 1) cuts.push(t);
2054
+ }
2055
+ } else if (Math.abs(qb) > 1e-18) {
2056
+ const t = -c / qb;
2057
+ if (t > 0 && t < 1) cuts.push(t);
2058
+ }
2059
+ cuts.push(1);
2060
+ const roots = [];
2061
+ const push = (t) => {
2062
+ if (!roots.some((r) => Math.abs(r - t) < 1e-9)) roots.push(t);
2063
+ };
2064
+ for (let i = 0; i < cuts.length - 1; i++) {
2065
+ let lo = cuts[i];
2066
+ let hi = cuts[i + 1];
2067
+ let flo = f(lo);
2068
+ const fhi = f(hi);
2069
+ if (Math.abs(flo) <= tol) push(lo);
2070
+ if (Math.abs(fhi) <= tol) push(hi);
2071
+ if (flo * fhi >= 0) continue;
2072
+ for (let k = 0; k < 80; k++) {
2073
+ const m = (lo + hi) / 2;
2074
+ const fm = f(m);
2075
+ if (fm === 0) {
2076
+ lo = hi = m;
2077
+ break;
2078
+ }
2079
+ if (fm * flo < 0) hi = m;
2080
+ else {
2081
+ lo = m;
2082
+ flo = fm;
2083
+ }
2084
+ }
2085
+ push((lo + hi) / 2);
2086
+ }
2087
+ return roots.sort((x, y) => x - y);
2088
+ }
2089
+ function lineHits(p, q, seg) {
2090
+ const dx = q[0] - p[0];
2091
+ const dy = q[1] - p[1];
2092
+ const len2 = dx * dx + dy * dy;
2093
+ if (len2 < 1e-24) return [];
2094
+ const h = (pt) => (pt[0] - p[0]) * -dy + (pt[1] - p[1]) * dx;
2095
+ let coeffs;
2096
+ if (seg.length === 2) coeffs = [0, 0, h(seg[1]) - h(seg[0]), h(seg[0])];
2097
+ else {
2098
+ const [h0, h1, h2, h3] = seg.map(h);
2099
+ coeffs = [-h0 + 3 * h1 - 3 * h2 + h3, 3 * h0 - 6 * h1 + 3 * h2, -3 * h0 + 3 * h1, h0];
2100
+ }
2101
+ if (coeffs.every((c) => Math.abs(c) < 1e-12 * Math.max(1, len2))) return [];
2102
+ const scale = Math.sqrt(len2) * Math.max(1, ...seg.map((pt) => Math.abs(pt[0]) + Math.abs(pt[1])));
2103
+ const out = [];
2104
+ for (const t of rootsInUnit(...coeffs, scale)) {
2105
+ const point = segmentPoint(seg, t);
2106
+ const s = ((point[0] - p[0]) * dx + (point[1] - p[1]) * dy) / len2;
2107
+ if (s >= -1e-9 && s <= 1 + 1e-9) out.push({ s: Math.min(1, Math.max(0, s)), t, point });
2108
+ }
2109
+ return out;
2110
+ }
2111
+ var near = (a, b, eps) => Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
2112
+ var degenerate = (seg, eps) => seg.every((p) => near(p, seg[0], eps));
2113
+ function arc(l, a, b, eps) {
2114
+ const n = l.segs.length;
2115
+ const segs = [];
2116
+ const chord = [];
2117
+ const push = (k, t0, t1) => {
2118
+ const piece = t0 <= 0 && t1 >= 1 ? l.segs[k].slice() : subSegment(l.segs[k], t0, t1);
2119
+ if (!degenerate(piece, eps)) {
2120
+ segs.push(piece);
2121
+ chord.push(l.chord[k]);
2122
+ }
2123
+ };
2124
+ if (a.seg === b.seg && b.t >= a.t + 1e-12) {
2125
+ push(a.seg, a.t, b.t);
2126
+ return { segs, chord };
2127
+ }
2128
+ push(a.seg, a.t, 1);
2129
+ for (let k = (a.seg + 1) % n; k !== b.seg; k = (k + 1) % n) push(k, 0, 1);
2130
+ push(b.seg, 0, b.t);
2131
+ return { segs, chord };
2132
+ }
2133
+ function polySegs(points, eps) {
2134
+ const out = [];
2135
+ for (let i = 1; i < points.length; i++) if (!near(points[i - 1], points[i], eps)) out.push([points[i - 1], points[i]]);
2136
+ return out;
2137
+ }
2138
+ function knifePath(path, fillRule, knife) {
2139
+ const pts = knife.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
2140
+ if (pts.length < 2) return null;
2141
+ let faces = facesOf(path, fillRule);
2142
+ if (!faces.length) return null;
2143
+ const b = bezierPathBounds(path);
2144
+ const size = b ? Math.max(b.maxX - b.minX, b.maxY - b.minY, 1) : 1;
2145
+ const eps = size * 1e-9;
2146
+ let cursor = -Infinity;
2147
+ let cuts = 0;
2148
+ for (let guard = 0; guard < 512; guard++) {
2149
+ let best = null;
2150
+ faces.forEach((face2, fi2) => {
2151
+ const hits = [];
2152
+ for (let m = 0; m < pts.length - 1; m++) {
2153
+ if (m + 1 < cursor - 1e-9) continue;
2154
+ const kb = { minX: Math.min(pts[m][0], pts[m + 1][0]), maxX: Math.max(pts[m][0], pts[m + 1][0]), minY: Math.min(pts[m][1], pts[m + 1][1]), maxY: Math.max(pts[m][1], pts[m + 1][1]) };
2155
+ face2.loops.forEach(
2156
+ (l, li) => l.segs.forEach((seg, si) => {
2157
+ const sb = segmentBounds(seg);
2158
+ if (sb.maxX < kb.minX - eps || sb.minX > kb.maxX + eps || sb.maxY < kb.minY - eps || sb.minY > kb.maxY + eps) return;
2159
+ for (const h of lineHits(pts[m], pts[m + 1], seg)) {
2160
+ const s = m + h.s;
2161
+ if (s >= cursor - 1e-9) hits.push({ s, loop: li, seg: si, t: h.t, point: h.point });
2162
+ }
2163
+ })
2164
+ );
2165
+ }
2166
+ hits.sort((x, y) => x.s - y.s);
2167
+ const unique = hits.filter((h, i) => i === 0 || h.s - hits[i - 1].s > 1e-9 || !near(h.point, hits[i - 1].point, eps * 10));
2168
+ const inside = (s) => {
2169
+ const m = Math.min(pts.length - 2, Math.floor(s));
2170
+ const u = s - m;
2171
+ const q = [pts[m][0] + (pts[m + 1][0] - pts[m][0]) * u, pts[m][1] + (pts[m + 1][1] - pts[m][1]) * u];
2172
+ return windingNumber(face2.loops.map((l) => l.segs), q) !== 0;
2173
+ };
2174
+ for (let i = 0; i < unique.length - 1; i++) {
2175
+ const from2 = unique[i];
2176
+ const to2 = unique[i + 1];
2177
+ if (near(from2.point, to2.point, eps * 10) || !inside((from2.s + to2.s) / 2)) continue;
2178
+ if (!best || from2.s < best.from.s) best = { face: fi2, from: from2, to: to2 };
2179
+ break;
2180
+ }
2181
+ });
2182
+ if (!best) break;
2183
+ const { face: fi, from, to } = best;
2184
+ const face = faces[fi];
2185
+ const corners2 = [from.point];
2186
+ for (let m = Math.floor(from.s) + 1; m <= to.s - 1e-12 && m < pts.length; m++) if (m > from.s) corners2.push(pts[m]);
2187
+ corners2.push(to.point);
2188
+ const forward = polySegs(corners2, eps);
2189
+ const back = polySegs(corners2.slice().reverse(), eps);
2190
+ const la = face.loops[from.loop];
2191
+ const loops = face.loops.filter((_, i) => i !== from.loop && i !== to.loop);
2192
+ if (from.loop === to.loop) {
2193
+ const ab = arc(la, from, to, eps);
2194
+ const ba = arc(la, to, from, eps);
2195
+ loops.push(
2196
+ { segs: [...ab.segs, ...back], chord: [...ab.chord, ...back.map(() => true)] },
2197
+ { segs: [...ba.segs, ...forward], chord: [...ba.chord, ...forward.map(() => true)] }
2198
+ );
2199
+ } else {
2200
+ const lb = face.loops[to.loop];
2201
+ const roundA = arc(la, from, from, eps);
2202
+ const roundB = arc(lb, to, to, eps);
2203
+ loops.push({
2204
+ segs: [...roundA.segs, ...forward, ...roundB.segs, ...back],
2205
+ chord: [...roundA.chord, ...forward.map(() => true), ...roundB.chord, ...back.map(() => true)]
2206
+ });
2207
+ }
2208
+ faces = [...faces.slice(0, fi), ...groupFaces(loops), ...faces.slice(fi + 1)];
2209
+ cursor = to.s;
2210
+ cuts++;
2211
+ }
2212
+ return cuts ? faces.map(faceToPath) : null;
2213
+ }
2214
+ function eraserRegion(points, width) {
2215
+ const pts = points.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
2216
+ const r = width / 2;
2217
+ if (!pts.length || !(r > 0)) return [];
2218
+ const moved = pts.some((p) => !near(p, pts[0], 1e-9));
2219
+ if (!moved) {
2220
+ const [cx, cy] = pts[0];
2221
+ const k = r * 0.5522847498307936;
2222
+ return [
2223
+ { type: "M", x: cx, y: cy - r },
2224
+ { type: "C", x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy },
2225
+ { type: "C", x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r },
2226
+ { type: "C", x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy },
2227
+ { type: "C", x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r },
2228
+ { type: "Z" }
2229
+ ];
2230
+ }
2231
+ const line2 = [{ type: "M", x: pts[0][0], y: pts[0][1] }, ...pts.slice(1).map((p) => ({ type: "L", x: p[0], y: p[1] }))];
2232
+ return outlineStroke(line2, { width, cap: "round", join: "round" });
2233
+ }
2234
+ function eraseFromFill(path, fillRule, region) {
2235
+ const overlap = pathBoolean("intersection", [{ path, fillRule }, { path: region }]).path;
2236
+ if (!pathToContours(overlap).some((c) => Math.abs(contourSignedArea(closeContour(c, true))) > 1e-9)) return null;
2237
+ const rest = pathBoolean("difference", [{ path, fillRule }, { path: region }]).path;
2238
+ return splitIntoFaces(rest);
2239
+ }
2240
+ function eraseFromOutline(sp, region) {
2241
+ const regionSegs = pathToContours(region).flatMap((c) => closeContour(c, true));
2242
+ if (!regionSegs.length) return null;
2243
+ const rb = bezierPathBounds(region);
2244
+ const count = subpathSegmentCount(sp);
2245
+ if (!rb || count === 0) return null;
2246
+ const removed = (p) => pointInBezierPath(region, p, "nonzero");
2247
+ const runs = [];
2248
+ let cur = [];
2249
+ let any = false;
2250
+ for (let i = 0; i < count; i++) {
2251
+ const seg = subpathSegment(sp, i);
2252
+ const sb = segmentBounds(seg);
2253
+ const ts = [0, 1];
2254
+ if (!(sb.maxX < rb.minX || sb.minX > rb.maxX || sb.maxY < rb.minY || sb.minY > rb.maxY)) {
2255
+ for (const r of regionSegs) {
2256
+ const b = segmentBounds(r);
2257
+ if (b.maxX < sb.minX || b.minX > sb.maxX || b.maxY < sb.minY || b.minY > sb.maxY) continue;
2258
+ for (const hit of segmentIntersections(seg, r)) if (hit.ta > 1e-9 && hit.ta < 1 - 1e-9) ts.push(hit.ta);
2259
+ }
2260
+ }
2261
+ ts.sort((a, b) => a - b);
2262
+ for (let k = 0; k < ts.length - 1; k++) {
2263
+ const t0 = ts[k];
2264
+ const t1 = ts[k + 1];
2265
+ if (t1 - t0 < 1e-9) continue;
2266
+ if (removed(segmentPoint(seg, (t0 + t1) / 2))) {
2267
+ any = true;
2268
+ if (cur.length) runs.push(cur);
2269
+ cur = [];
2270
+ } else cur.push({ seg: i, t0, t1 });
2271
+ }
2272
+ }
2273
+ if (cur.length) runs.push(cur);
2274
+ if (!any) return null;
2275
+ if (sp.closed && runs.length >= 2) {
2276
+ const first = runs[0][0];
2277
+ const lastRun = runs[runs.length - 1];
2278
+ const last = lastRun[lastRun.length - 1];
2279
+ if (first.seg === 0 && first.t0 === 0 && last.seg === count - 1 && last.t1 === 1) lastRun.push(...runs.shift());
2280
+ }
2281
+ const out = [];
2282
+ for (const run of runs) {
2283
+ const anchors = [];
2284
+ for (const { seg, t0, t1 } of run) {
2285
+ const c = subSegment(subpathSegment(sp, seg), t0, t1);
2286
+ const line2 = c.length === 2;
2287
+ const start = c[0];
2288
+ const end = c[c.length - 1];
2289
+ if (!anchors.length) anchors.push({ x: start[0], y: start[1] });
2290
+ if (!line2) anchors[anchors.length - 1].out = { x: c[1][0], y: c[1][1] };
2291
+ const next = { x: end[0], y: end[1] };
2292
+ if (!line2) next.in = { x: c[2][0], y: c[2][1] };
2293
+ anchors.push(next);
2294
+ }
2295
+ if (anchors.length < 2) continue;
2296
+ for (const a of anchors) {
2297
+ const type = inferAnchorType(a);
2298
+ if (type !== "corner") a.type = type;
2299
+ }
2300
+ out.push({ anchors, closed: false });
2301
+ }
2302
+ return out;
2303
+ }
2304
+ function polylineBounds(points, pad = 0) {
2305
+ if (!points.length) return null;
2306
+ let minX = Infinity;
2307
+ let minY = Infinity;
2308
+ let maxX = -Infinity;
2309
+ let maxY = -Infinity;
2310
+ for (const [x, y] of points) {
2311
+ minX = Math.min(minX, x);
2312
+ minY = Math.min(minY, y);
2313
+ maxX = Math.max(maxX, x);
2314
+ maxY = Math.max(maxY, y);
2315
+ }
2316
+ return { x: minX - pad, y: minY - pad, width: maxX - minX + 2 * pad, height: maxY - minY + 2 * pad };
2317
+ }
2318
+ function simplifyPolyline(points, tolerance) {
2319
+ const n = points.length;
2320
+ if (n < 3) return points.slice();
2321
+ const keep = new Array(n).fill(false);
2322
+ keep[0] = keep[n - 1] = true;
2323
+ const stack = [[0, n - 1]];
2324
+ while (stack.length) {
2325
+ const [a, b] = stack.pop();
2326
+ const [ax, ay] = points[a];
2327
+ const dx = points[b][0] - ax;
2328
+ const dy = points[b][1] - ay;
2329
+ const len = Math.hypot(dx, dy);
2330
+ let far = -1;
2331
+ let farD = 0;
2332
+ for (let i = a + 1; i < b; i++) {
2333
+ const [px, py] = points[i];
2334
+ const d = len < 1e-12 ? Math.hypot(px - ax, py - ay) : Math.abs((px - ax) * dy - (py - ay) * dx) / len;
2335
+ if (d > farD) {
2336
+ farD = d;
2337
+ far = i;
2338
+ }
2339
+ }
2340
+ if (far >= 0 && farD > tolerance) {
2341
+ keep[far] = true;
2342
+ stack.push([a, far], [far, b]);
2343
+ }
2344
+ }
2345
+ return points.filter((_, i) => keep[i]);
2346
+ }
2347
+
2348
+ // src/vector/ink.ts
2349
+ var LazyMouse = class {
2350
+ /** `length` is the string length in the units of the points (0 turns smoothing off). */
2351
+ constructor(length2) {
2352
+ this.length = length2;
2353
+ }
2354
+ length;
2355
+ pos = null;
2356
+ last = null;
2357
+ push(p) {
2358
+ this.last = p;
2359
+ if (!(this.length > 0)) return [p];
2360
+ const pos = this.pos;
2361
+ if (!pos) {
2362
+ this.pos = p;
2363
+ return [p];
2364
+ }
2365
+ const dx = p[0] - pos[0];
2366
+ const dy = p[1] - pos[1];
2367
+ const d = Math.hypot(dx, dy);
2368
+ if (d <= this.length) return [];
2369
+ const k = (d - this.length) / d;
2370
+ const next = [pos[0] + dx * k, pos[1] + dy * k];
2371
+ this.pos = next;
2372
+ return [next];
2373
+ }
2374
+ /** The string from the pen to the pointer while they are apart, else null. */
2375
+ string() {
2376
+ const { pos, last } = this;
2377
+ if (!(this.length > 0) || !pos || !last || pos[0] === last[0] && pos[1] === last[1]) return null;
2378
+ return [pos, last];
2379
+ }
2380
+ /** End of stroke: the pen catches up with the pointer (the point to add, if it lagged). */
2381
+ finish() {
2382
+ const { pos, last } = this;
2383
+ if (!(this.length > 0) || !pos || !last || pos[0] === last[0] && pos[1] === last[1]) return [];
2384
+ this.pos = last;
2385
+ return [last];
2386
+ }
2387
+ };
2388
+ var VECTOR_INK_MAX_CORNERS = 8;
2389
+ var MAX_POINTS = 1e5;
2390
+ function vectorInkShapeName(shape) {
2391
+ switch (shape.kind) {
2392
+ case "line":
2393
+ return "line";
2394
+ case "rect":
2395
+ return shape.width === shape.height ? "square" : "rectangle";
2396
+ case "ellipse":
2397
+ return shape.width === shape.height ? "circle" : "ellipse";
2398
+ case "polygon":
2399
+ return shape.points.length === 3 ? "triangle" : "polygon";
2400
+ }
2401
+ }
2402
+ var dist2 = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
2403
+ function bbox(pts) {
2404
+ if (!pts.length) return [0, 0, 0, 0];
2405
+ let [x0, y0] = pts[0];
2406
+ let [x1, y1] = pts[0];
2407
+ for (const [x, y] of pts) {
2408
+ x0 = Math.min(x0, x);
2409
+ y0 = Math.min(y0, y);
2410
+ x1 = Math.max(x1, x);
2411
+ y1 = Math.max(y1, y);
2412
+ }
2413
+ return [x0, y0, x1 - x0, y1 - y0];
2414
+ }
2415
+ function segDistance(a, b, p) {
2416
+ const dx = b[0] - a[0];
2417
+ const dy = b[1] - a[1];
2418
+ const len2 = dx * dx + dy * dy;
2419
+ const t = len2 > 0 ? Math.min(1, Math.max(0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2)) : 0;
2420
+ return dist2([a[0] + t * dx, a[1] + t * dy], p);
2421
+ }
2422
+ function polygonDistance(c, p) {
2423
+ let best = Infinity;
2424
+ for (let i = 0; i < c.length; i++) best = Math.min(best, segDistance(c[i], c[(i + 1) % c.length], p));
2425
+ return best;
2426
+ }
2427
+ function ellipseRms(p, x0, y0, w, h) {
2428
+ const rx = w / 2;
2429
+ const ry = h / 2;
2430
+ if (rx <= 0 || ry <= 0 || !p.length) return Infinity;
2431
+ const cx = x0 + rx;
2432
+ const cy = y0 + ry;
2433
+ let sum = 0;
2434
+ for (const [x, y] of p) sum += (Math.hypot((x - cx) / rx, (y - cy) / ry) - 1) ** 2;
2435
+ return Math.sqrt(sum / p.length);
2436
+ }
2437
+ function snapSquare(x, y, w, h) {
2438
+ if (Math.abs(w - h) <= 0.12 * Math.max(w, h)) {
2439
+ const s = (w + h) / 2;
2440
+ return [x + (w - s) / 2, y + (h - s) / 2, s, s];
2441
+ }
2442
+ return [x, y, w, h];
2443
+ }
2444
+ function turning(a, b, c) {
2445
+ const u = [b[0] - a[0], b[1] - a[1]];
2446
+ const v = [c[0] - b[0], c[1] - b[1]];
2447
+ const lu = Math.hypot(u[0], u[1]);
2448
+ const lv = Math.hypot(v[0], v[1]);
2449
+ if (lu <= 0 || lv <= 0) return 0;
2450
+ return Math.acos(Math.min(1, Math.max(-1, (u[0] * v[0] + u[1] * v[1]) / (lu * lv)))) * 180 / Math.PI;
2451
+ }
2452
+ function rdp(p, eps) {
2453
+ const n = p.length;
2454
+ if (n < 3) return p.slice();
2455
+ const keep = new Array(n).fill(false);
2456
+ keep[0] = keep[n - 1] = true;
2457
+ const stack = [[0, n - 1]];
2458
+ while (stack.length) {
2459
+ const [a, b] = stack.pop();
2460
+ let far = a;
2461
+ let farD = 0;
2462
+ for (let i = a + 1; i < b; i++) {
2463
+ const d = segDistance(p[a], p[b], p[i]);
2464
+ if (d > farD) {
2465
+ farD = d;
2466
+ far = i;
2467
+ }
2468
+ }
2469
+ if (farD > eps) {
2470
+ keep[far] = true;
2471
+ stack.push([a, far], [far, b]);
2472
+ }
2473
+ }
2474
+ return p.filter((_, i) => keep[i]);
2475
+ }
2476
+ function corners(p, diag) {
2477
+ const eps = 0.05 * diag;
2478
+ const start = p[0];
2479
+ let far = 0;
2480
+ let farD = 0;
2481
+ p.forEach((q, i) => {
2482
+ const d = dist2(start, q);
2483
+ if (d > farD) {
2484
+ farD = d;
2485
+ far = i;
2486
+ }
2487
+ });
2488
+ const a = p.slice(0, far);
2489
+ const b = p.slice(far);
2490
+ const ring = rdp([...a, ...b.slice(0, 1)], eps);
2491
+ const back = rdp([...b, start], eps);
2492
+ ring.pop();
2493
+ back.pop();
2494
+ ring.push(...back);
2495
+ while (ring.length > 3) {
2496
+ const n = ring.length;
2497
+ const turn = (i) => turning(ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]);
2498
+ let flat = 0;
2499
+ let flatT = Infinity;
2500
+ for (let i = 0; i < n; i++) {
2501
+ const t = turn(i);
2502
+ if (t < flatT) {
2503
+ flatT = t;
2504
+ flat = i;
2505
+ }
2506
+ }
2507
+ let short = -1;
2508
+ for (let j = 0; j < n; j++) {
2509
+ if (dist2(ring[j], ring[(j + 1) % n]) < 0.1 * diag) {
2510
+ short = j;
2511
+ break;
2512
+ }
2513
+ }
2514
+ if (flatT < 30) ring.splice(flat, 1);
2515
+ else if (short >= 0) {
2516
+ const k = turn(short) < turn((short + 1) % n) ? short : (short + 1) % n;
2517
+ ring.splice(k, 1);
2518
+ } else break;
2519
+ }
2520
+ return ring;
2521
+ }
2522
+ function upright(c) {
2523
+ const n = c.length;
2524
+ const dirs = c.map((a, i) => {
2525
+ const b = c[(i + 1) % n];
2526
+ const deg = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI % 180 + 180) % 180;
2527
+ if (deg <= 15 || deg >= 165) return "across";
2528
+ if (deg >= 75 && deg <= 105) return "down";
2529
+ return null;
2530
+ });
2531
+ return dirs.every((d) => d !== null) && dirs.every((d, i) => d !== dirs[(i + 1) % n]);
2532
+ }
2533
+ function recognizeInkShape(points, options = {}) {
2534
+ const minSize = options.minSize ?? 6;
2535
+ const p = [];
2536
+ for (const q of points.slice(0, MAX_POINTS)) {
2537
+ if (!Number.isFinite(q[0]) || !Number.isFinite(q[1]) || Math.abs(q[0]) >= 1e9 || Math.abs(q[1]) >= 1e9) continue;
2538
+ const last2 = p[p.length - 1];
2539
+ if (!last2 || last2[0] !== q[0] || last2[1] !== q[1]) p.push([q[0], q[1]]);
2540
+ }
2541
+ if (p.length < 2) return null;
2542
+ const first = p[0];
2543
+ const last = p[p.length - 1];
2544
+ const [x0, y0, w, h] = bbox(p);
2545
+ const size = Math.max(w, h);
2546
+ const diag = Math.hypot(w, h);
2547
+ if (size < minSize) return null;
2548
+ let length2 = 0;
2549
+ for (let i = 1; i < p.length; i++) length2 += dist2(p[i - 1], p[i]);
2550
+ const gap = dist2(first, last);
2551
+ if (gap > 0.85 * length2 && gap >= minSize) {
2552
+ const off = Math.max(...p.map((q) => segDistance(first, last, q)));
2553
+ if (off <= Math.max(0.06 * gap, 1.5 * minSize / 6)) return { kind: "line", a: first, b: last };
2554
+ }
2555
+ if (gap > 0.2 * size || length2 < 2 * size) return null;
2556
+ const rms = ellipseRms(p, x0, y0, w, h);
2557
+ const roundish = w > 0.2 * size && h > 0.2 * size;
2558
+ const ellipse = () => {
2559
+ const [x, y, sw, sh] = snapSquare(x0, y0, w, h);
2560
+ return { kind: "ellipse", x, y, width: sw, height: sh };
2561
+ };
2562
+ if (roundish && rms < 0.06) return ellipse();
2563
+ const c = corners(p, diag);
2564
+ const fits = c.length >= 3 && p.every((q) => polygonDistance(c, q) <= 0.08 * diag);
2565
+ if (fits && c.length === 4 && upright(c)) {
2566
+ const [bx, by, bw, bh] = bbox(c);
2567
+ const [x, y, sw, sh] = snapSquare(bx, by, bw, bh);
2568
+ return { kind: "rect", x, y, width: sw, height: sh };
2569
+ }
2570
+ if (fits && c.length <= VECTOR_INK_MAX_CORNERS) return { kind: "polygon", points: c };
2571
+ return roundish && rms < 0.1 ? ellipse() : null;
2572
+ }
2573
+
1606
2574
  // src/vector/controller.ts
1607
2575
  var DEFAULT_VECTOR_SNAP = { grid: 0, objects: true, guides: true, threshold: 5, angles: VECTOR_CONSTRUCTION_ANGLES };
2576
+ var DEFAULT_VECTOR_PATH_EDIT = { keepShapeOnDelete: true, curvatureMoves: false, pencilSmoothing: 0, recognizeShapes: false, eraserSize: 10 };
2577
+ var VECTOR_PENCIL_STRING_PX = 100;
1608
2578
  var VECTOR_CLIPBOARD_TYPE = "pixerate/vector";
1609
2579
  var DEFAULT_DEFAULTS = {
1610
2580
  fill: "#d4d4d8",
@@ -1723,14 +2693,14 @@ function getVectorSelectionBoxGeometry(frame, viewport) {
1723
2693
  const rot = rotateMatrix(frame.angle, { x: viewport.x, y: viewport.y });
1724
2694
  const toScreen2 = (p) => frame.angle ? applyMatrix(rot, p) : { x: p.x, y: p.y };
1725
2695
  const handles = getTransformHandlePositions(box).map((h) => ({ ...h, screen: toScreen2(h) }));
1726
- const corners = [
2696
+ const corners2 = [
1727
2697
  { x: box.x, y: box.y },
1728
2698
  { x: box.x + box.width, y: box.y },
1729
2699
  { x: box.x + box.width, y: box.y + box.height },
1730
2700
  { x: box.x, y: box.y + box.height }
1731
2701
  ].map(toScreen2);
1732
2702
  const r = (n) => Math.round(n * 1e3) / 1e3;
1733
- return { box, angle: frame.angle, transform: frame.angle ? `rotate(${r(frame.angle)} ${r(viewport.x)} ${r(viewport.y)})` : "", handles, corners };
2703
+ return { box, angle: frame.angle, transform: frame.angle ? `rotate(${r(frame.angle)} ${r(viewport.x)} ${r(viewport.y)})` : "", handles, corners: corners2 };
1734
2704
  }
1735
2705
  var RESIZE_CURSOR_ORDER = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
1736
2706
  var HANDLE_ANGLES = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
@@ -1748,9 +2718,12 @@ var TOOL_LABELS = {
1748
2718
  ellipse: "Ellipse",
1749
2719
  polygon: "Polygon",
1750
2720
  star: "Star",
1751
- line: "Line"
2721
+ line: "Line",
2722
+ scissors: "Scissors",
2723
+ knife: "Knife",
2724
+ eraser: "Eraser"
1752
2725
  };
1753
- var TOOL_KEYS = { v: "select", a: "direct", p: "pen", n: "pencil", r: "rect", o: "ellipse", e: "ellipse", y: "polygon", s: "star", l: "line" };
2726
+ var TOOL_KEYS = { v: "select", a: "direct", p: "pen", n: "pencil", r: "rect", o: "ellipse", y: "polygon", s: "star", l: "line", c: "scissors", k: "knife", e: "eraser" };
1754
2727
  function vectorToolLabel(tool) {
1755
2728
  return TOOL_LABELS[tool];
1756
2729
  }
@@ -1763,7 +2736,10 @@ var VECTOR_TOOL_SHORTCUTS = {
1763
2736
  ellipse: "O",
1764
2737
  polygon: "Y",
1765
2738
  star: "S",
1766
- line: "L"
2739
+ line: "L",
2740
+ scissors: "C",
2741
+ knife: "K",
2742
+ eraser: "E"
1767
2743
  };
1768
2744
  var VECTOR_TOOL_GLYPHS = {
1769
2745
  select: "\u2196",
@@ -1774,8 +2750,12 @@ var VECTOR_TOOL_GLYPHS = {
1774
2750
  ellipse: "\u25EF",
1775
2751
  polygon: "\u2B21",
1776
2752
  star: "\u2606",
1777
- line: "\u2571"
2753
+ line: "\u2571",
2754
+ scissors: "\u2702",
2755
+ knife: "\u27CB",
2756
+ eraser: "\u232B"
1778
2757
  };
2758
+ var SHAPE_TOOLS = /* @__PURE__ */ new Set(["rect", "ellipse", "polygon", "star", "line"]);
1779
2759
  var BOOLEAN_OPS = {
1780
2760
  union: "union",
1781
2761
  subtract: "difference",
@@ -1797,6 +2777,28 @@ function mapTree(nodes, fn) {
1797
2777
  }
1798
2778
  return changed ? out : nodes;
1799
2779
  }
2780
+ function spliceTree(nodes, id, replacements) {
2781
+ let changed = false;
2782
+ const out = [];
2783
+ for (const node of nodes) {
2784
+ if (node.id === id) {
2785
+ changed = true;
2786
+ out.push(...replacements);
2787
+ continue;
2788
+ }
2789
+ if (node.type === "group") {
2790
+ const children = spliceTree(node.children, id, replacements);
2791
+ if (children !== node.children) {
2792
+ changed = true;
2793
+ out.push({ ...node, children });
2794
+ continue;
2795
+ }
2796
+ }
2797
+ out.push(node);
2798
+ }
2799
+ return changed ? out : nodes;
2800
+ }
2801
+ var toPoint2 = (p) => [p.x, p.y];
1800
2802
  var isRoot = (m) => m === IDENTITY_MATRIX || m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
1801
2803
  var idCounter = 0;
1802
2804
  var defaultCreateId = () => `vn-${Date.now().toString(36)}${(++idCounter).toString(36)}`;
@@ -1811,6 +2813,7 @@ var VectorEditorController = class {
1811
2813
  defaults;
1812
2814
  viewport;
1813
2815
  snap;
2816
+ editing;
1814
2817
  hitRadius;
1815
2818
  hover = null;
1816
2819
  pointer = null;
@@ -1839,6 +2842,7 @@ var VectorEditorController = class {
1839
2842
  this.defaults = { ...DEFAULT_DEFAULTS, ...options.defaults ?? {} };
1840
2843
  this.viewport = { x: 0, y: 0, zoom: 1, ...options.viewport ?? {} };
1841
2844
  this.snap = { ...DEFAULT_VECTOR_SNAP, ...options.snap ?? {} };
2845
+ this.editing = { ...DEFAULT_VECTOR_PATH_EDIT, ...options.editing ?? {} };
1842
2846
  this.hitRadius = options.hitRadius ?? 6;
1843
2847
  this.createId = options.createId ?? defaultCreateId;
1844
2848
  this.onChange = options.onChange;
@@ -1887,6 +2891,7 @@ var VectorEditorController = class {
1887
2891
  viewport: this.viewport,
1888
2892
  defaults: this.defaults,
1889
2893
  snap: this.snap,
2894
+ editing: this.editing,
1890
2895
  guides: this.guides,
1891
2896
  hitRadius: this.hitRadius,
1892
2897
  hover: this.hover,
@@ -2088,6 +3093,14 @@ var VectorEditorController = class {
2088
3093
  if (!this.snap.guides || !this.snap.objects) this.guides = null;
2089
3094
  this.notify();
2090
3095
  }
3096
+ /** Changes how deleting, dragging anchors, the pencil and the eraser behave. */
3097
+ setEditing(editing) {
3098
+ const next = { ...this.editing, ...editing };
3099
+ next.pencilSmoothing = Math.min(1, Math.max(0, Number.isFinite(next.pencilSmoothing) ? next.pencilSmoothing : 0));
3100
+ if (!(next.eraserSize > 0)) next.eraserSize = this.editing.eraserSize;
3101
+ this.editing = next;
3102
+ this.notify();
3103
+ }
2091
3104
  /** Turns object snapping on or off (the toolbar's snapping toggle). */
2092
3105
  toggleSnapping(on = !this.snap.objects) {
2093
3106
  this.setSnap({ objects: on });
@@ -2150,7 +3163,7 @@ var VectorEditorController = class {
2150
3163
  const leave = last ? { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null } : null;
2151
3164
  return this.snapDrawn(p, leave, input, () => this.drawingTargets(this.pen?.nodeId ?? null));
2152
3165
  }
2153
- if (this.tool !== "select" && this.tool !== "direct" && this.tool !== "pencil") return this.snapDrawn(p, null, input, () => this.drawingTargets(null));
3166
+ if (SHAPE_TOOLS.has(this.tool)) return this.snapDrawn(p, null, input, () => this.drawingTargets(null));
2154
3167
  this.setGuides(null);
2155
3168
  return p;
2156
3169
  }
@@ -2753,12 +3766,47 @@ var VectorEditorController = class {
2753
3766
  return translateAnchor(a, d.x, d.y);
2754
3767
  });
2755
3768
  }
2756
- /** Moves one anchor to a document position (handles follow). */
2757
- moveAnchor(ref, to) {
3769
+ /**
3770
+ * Moves one anchor to a document position. Its handles follow; with `curvature` (default:
3771
+ * the `curvatureMoves` setting) its two segments bend instead, as the Curvature tool does.
3772
+ */
3773
+ moveAnchor(ref, to, options = {}) {
2758
3774
  const a = getAnchorWorld(this.doc, ref);
2759
3775
  if (!a) return { ok: false, message: "There is no such anchor." };
3776
+ const curvature = options.curvature ?? this.editing.curvatureMoves;
2760
3777
  this.change("Move Point", () => {
2761
- this.doc = this.movedAnchors(this.doc, [ref], to.x - a.x, to.y - a.y);
3778
+ this.doc = curvature ? this.curvatureMoved(this.doc, ref, to) : this.movedAnchors(this.doc, [ref], to.x - a.x, to.y - a.y);
3779
+ });
3780
+ return { ok: true };
3781
+ }
3782
+ /** `doc` with anchor `ref` moved to document point `to` by a Curvature-tool move. */
3783
+ curvatureMoved(doc, ref, to) {
3784
+ const node = findVectorNode(doc.nodes, ref.nodeId);
3785
+ if (node?.type !== "path" || node.locked || !node.subpaths[ref.subpath]) return doc;
3786
+ const q = this.toLocal(ref.nodeId, to, doc);
3787
+ const subpaths = node.subpaths.map((sp, i) => i === ref.subpath ? curvatureMoveAnchor(sp, ref.index, q) : sp);
3788
+ const nodes = mapTree(doc.nodes, (n) => n.id === ref.nodeId ? { ...node, subpaths } : n);
3789
+ return { ...doc, nodes };
3790
+ }
3791
+ /** `doc` with segment `ref` bent through document point `p` at parameter `t`. */
3792
+ bent(doc, ref, t, p) {
3793
+ const node = findVectorNode(doc.nodes, ref.nodeId);
3794
+ if (node?.type !== "path" || node.locked || !node.subpaths[ref.subpath]) return doc;
3795
+ const q = this.toLocal(ref.nodeId, p, doc);
3796
+ const subpaths = node.subpaths.map((sp, i) => i === ref.subpath ? bendSegment(sp, ref.segment, t, q) : sp);
3797
+ return { ...doc, nodes: mapTree(doc.nodes, (n) => n.id === ref.nodeId ? { ...node, subpaths } : n) };
3798
+ }
3799
+ /**
3800
+ * Bends a segment so the curve passes through document point `to` at parameter `t`
3801
+ * (default 0.5, clamped to 0.05–0.95); see `bendSegment` in `pathEdit`.
3802
+ */
3803
+ bendSegment(ref, to, t = 0.5) {
3804
+ const node = this.getNode(ref.nodeId);
3805
+ const sp = node?.type === "path" ? node.subpaths[ref.subpath] : void 0;
3806
+ if (!sp || ref.segment < 0 || ref.segment >= (sp.closed ? sp.anchors.length : sp.anchors.length - 1) || sp.anchors.length < 2) return { ok: false, message: "There is no such segment." };
3807
+ if (node.locked) return { ok: false, message: "The path is locked." };
3808
+ this.change("Bend Segment", () => {
3809
+ this.doc = this.bent(this.doc, ref, t, to);
2762
3810
  });
2763
3811
  return { ok: true };
2764
3812
  }
@@ -2801,15 +3849,24 @@ var VectorEditorController = class {
2801
3849
  });
2802
3850
  return ref;
2803
3851
  }
2804
- /** Deletes the selected anchors; empty subpaths and paths go too. */
2805
- deleteAnchors(refs = this.anchors) {
3852
+ /**
3853
+ * Deletes the selected (or given) anchors; subpaths left with one anchor and empty paths go
3854
+ * too. With `keepShape` (default: the `keepShapeOnDelete` setting, on) the neighbours'
3855
+ * handles are refitted so the path keeps its shape; off, the neighbours are joined as they are.
3856
+ */
3857
+ deleteAnchors(refs = this.anchors, options = {}) {
2806
3858
  if (!refs.length) return { ok: false, message: "Select points to delete." };
3859
+ const keepShape = options.keepShape ?? this.editing.keepShapeOnDelete;
2807
3860
  this.change("Delete Points", () => {
2808
3861
  const nodes = mapTree(this.doc.nodes, (n) => {
2809
- if (n.type !== "path") return n;
3862
+ if (n.type !== "path" || n.locked) return n;
2810
3863
  const mine = refs.filter((r) => r.nodeId === n.id);
2811
3864
  if (!mine.length) return n;
2812
- const subpaths = n.subpaths.map((sp, s) => removeAnchors(sp, mine.filter((r) => r.subpath === s).map((r) => r.index))).filter((sp) => sp.anchors.length > 1);
3865
+ const subpaths = n.subpaths.map((sp, s) => {
3866
+ const indices = mine.filter((r) => r.subpath === s).map((r) => r.index);
3867
+ if (!indices.length) return sp;
3868
+ return keepShape ? removeAnchorsKeepingShape(sp, indices) : removeAnchors(sp, indices);
3869
+ }).filter((sp) => sp.anchors.length > 1);
2813
3870
  return subpaths.length ? { ...n, subpaths } : null;
2814
3871
  });
2815
3872
  this.setDoc(nodes);
@@ -2847,6 +3904,269 @@ var VectorEditorController = class {
2847
3904
  });
2848
3905
  return { ok: true };
2849
3906
  }
3907
+ // -------------------------------------------------------------------------
3908
+ // Path clean-up, Scissors, Knife and Eraser
3909
+ // -------------------------------------------------------------------------
3910
+ /** Unlocked path nodes in the given roots (default: the paths being edited, else the selection). */
3911
+ pathTargets(ids) {
3912
+ const roots = ids ?? (this.tool === "direct" && this.anchors.length ? [...new Set(this.anchors.map((r) => r.nodeId))] : this.selection);
3913
+ const out = /* @__PURE__ */ new Map();
3914
+ for (const id of roots) {
3915
+ const node = this.getNode(id);
3916
+ if (node && !node.locked) {
3917
+ for (const p of collectPathNodes(node)) if (!p.locked) out.set(p.id, p);
3918
+ }
3919
+ }
3920
+ const order = [];
3921
+ walkVectorNodes(this.doc.nodes, (n) => {
3922
+ if (out.has(n.id)) order.push(n.id);
3923
+ });
3924
+ return order.map((id) => out.get(id));
3925
+ }
3926
+ /** Subpaths of node `from` mapped into node `to`'s own space. */
3927
+ subpathsIn(from, to) {
3928
+ const m = multiplyMatrices(this.worldOf(from.id), invertMatrix(this.worldOf(to)));
3929
+ if (isRoot(m)) return from.subpaths;
3930
+ const map = (p) => applyMatrix(m, p);
3931
+ return from.subpaths.map((sp) => ({
3932
+ closed: sp.closed,
3933
+ anchors: sp.anchors.map((a) => ({ ...map(a), ...a.in ? { in: map(a.in) } : {}, ...a.out ? { out: map(a.out) } : {}, ...a.type ? { type: a.type } : {} }))
3934
+ }));
3935
+ }
3936
+ /**
3937
+ * Replaces a path with pieces: each entry becomes one path (the first keeps the node's id and
3938
+ * place, the others go just above it with the same style). No pieces removes the path.
3939
+ * Returns the ids of the paths now standing in for it.
3940
+ */
3941
+ replaceWithPieces(node, pieces) {
3942
+ const nodes = pieces.filter((p) => p.length).map((subpaths, i) => ({ ...node, id: i === 0 ? node.id : this.newId(), subpaths }));
3943
+ this.setDoc(spliceTree(this.doc.nodes, node.id, nodes));
3944
+ return nodes.map((n) => n.id);
3945
+ }
3946
+ /** Clears anchor selections, editing and pen state that no longer point at anything. */
3947
+ dropStaleRefs() {
3948
+ this.selection = this.selection.filter((id) => this.getNode(id));
3949
+ this.anchors = this.anchors.filter((r) => getAnchor(this.doc, r));
3950
+ if (this.editingId && !this.getNode(this.editingId)) this.editingId = null;
3951
+ if (this.pen && !this.getNode(this.pen.nodeId)) this.pen = null;
3952
+ }
3953
+ /**
3954
+ * Join: the open subpaths of the selected (or given) paths become one open path, joined
3955
+ * nearest endpoints first (ends within `tolerance` document units merge into one anchor,
3956
+ * others get a straight segment); a single open subpath is closed. The result replaces the
3957
+ * lowest path, in its space and style. Default tolerance: the pick distance.
3958
+ */
3959
+ joinPaths(options = {}) {
3960
+ const paths = this.pathTargets(options.ids);
3961
+ if (!paths.length) return { ok: false, message: "Select paths to join." };
3962
+ const base = paths[0];
3963
+ const all = paths.flatMap((p) => this.subpathsIn(p, base.id));
3964
+ if (!all.some((sp) => !sp.closed && sp.anchors.length > 1)) return { ok: false, message: "There are no open paths to join." };
3965
+ const subpaths = joinSubpaths(all, options.tolerance ?? this.tolerance());
3966
+ const drop = new Set(paths.slice(1).map((p) => p.id));
3967
+ this.change("Join", () => {
3968
+ this.setDoc(mapTree(this.doc.nodes, (n) => n.id === base.id ? { ...base, subpaths } : drop.has(n.id) ? null : n));
3969
+ this.selection = [base.id];
3970
+ this.anchors = [];
3971
+ if (this.editingId && drop.has(this.editingId)) this.editingId = base.id;
3972
+ this.dropStaleRefs();
3973
+ });
3974
+ return { ok: true };
3975
+ }
3976
+ /** Average: moves the selected (or given) anchors onto their average position along `axis`, in document space. */
3977
+ averageAnchors(axis = "both", refs = this.anchors) {
3978
+ const points = refs.map((r) => ({ ref: r, at: getAnchorWorld(this.doc, r) })).filter((p) => !!p.at);
3979
+ if (points.length < 2) return { ok: false, message: "Select at least two points to average." };
3980
+ const cx = points.reduce((s, p) => s + p.at.x, 0) / points.length;
3981
+ const cy = points.reduce((s, p) => s + p.at.y, 0) / points.length;
3982
+ this.change("Average", () => {
3983
+ for (const { ref, at } of points) {
3984
+ const dx = axis === "horizontal" ? 0 : cx - at.x;
3985
+ const dy = axis === "vertical" ? 0 : cy - at.y;
3986
+ if (dx || dy) this.doc = this.movedAnchors(this.doc, [ref], dx, dy);
3987
+ }
3988
+ });
3989
+ return { ok: true };
3990
+ }
3991
+ /** Applies `fn` to every subpath of the target paths as one undo step. */
3992
+ editSubpaths(label, ids, fn) {
3993
+ const paths = this.pathTargets(ids);
3994
+ if (!paths.length) return { ok: false, message: "Select a path." };
3995
+ const set = new Set(paths.map((p) => p.id));
3996
+ this.change(label, () => {
3997
+ this.setDoc(mapTree(this.doc.nodes, (n) => n.type === "path" && set.has(n.id) ? { ...n, subpaths: n.subpaths.map((sp, i) => fn(sp, n, i)) } : n));
3998
+ this.dropStaleRefs();
3999
+ });
4000
+ return { ok: true };
4001
+ }
4002
+ /**
4003
+ * Remove Redundant Points: drops anchors of the selected (or given) paths that can go
4004
+ * without moving the outline more than `tolerance` document units (default 0.5).
4005
+ */
4006
+ removeRedundantPoints(options = {}) {
4007
+ const tolerance = options.tolerance ?? 0.5;
4008
+ return this.editSubpaths("Remove Redundant Points", options.ids, (sp) => removeRedundantPoints(sp, tolerance));
4009
+ }
4010
+ /**
4011
+ * Smooth: pulls handles towards smooth tangents by `amount` (0–1, default 1). With the direct
4012
+ * tool and points selected, only those points change.
4013
+ */
4014
+ smoothPaths(options = {}) {
4015
+ const refs = options.refs ?? (!options.ids && this.tool === "direct" && this.anchors.length ? this.anchors : null);
4016
+ const ids = options.ids ?? (refs ? [...new Set(refs.map((r) => r.nodeId))] : void 0);
4017
+ return this.editSubpaths("Smooth", ids, (sp, node, i) => {
4018
+ const only = refs ? new Set(refs.filter((r) => r.nodeId === node.id && r.subpath === i).map((r) => r.index)) : void 0;
4019
+ return only && !only.size ? sp : smoothSubpath(sp, options.amount ?? 1, only);
4020
+ });
4021
+ }
4022
+ /** Splits a cut subpath into pieces: separate paths for a one-subpath node, subpaths in place for a compound one. */
4023
+ applyCuts(node, cut) {
4024
+ const results = node.subpaths.map((sp, i) => cut(sp, i));
4025
+ if (results.every((r) => !r)) return null;
4026
+ const subpaths = node.subpaths.flatMap((sp, i) => results[i] ?? [sp]);
4027
+ if (node.subpaths.length === 1) return this.replaceWithPieces(node, subpaths.map((sp) => [sp]));
4028
+ this.replaceNode(node.id, { ...node, subpaths });
4029
+ return [node.id];
4030
+ }
4031
+ /**
4032
+ * Scissors: cuts path `nodeId` at the outline point nearest document point `point` (at an
4033
+ * anchor when within the pick distance of one). A closed subpath opens there; an open one
4034
+ * splits in two (a one-subpath path becomes two paths). Returns the ids of the paths.
4035
+ */
4036
+ cutPathAt(nodeId, point) {
4037
+ const node = this.getNode(nodeId);
4038
+ if (node?.type !== "path" || node.locked) return null;
4039
+ const local = this.toLocal(nodeId, point);
4040
+ const hit = nearestSegment(node, local);
4041
+ if (!hit) return null;
4042
+ const sp = node.subpaths[hit.subpath];
4043
+ const n = sp.anchors.length;
4044
+ const near2 = [hit.segment, (hit.segment + 1) % n].find((i) => Math.hypot(sp.anchors[i].x - hit.point.x, sp.anchors[i].y - hit.point.y) <= this.tolerance());
4045
+ if (!sp.closed && n > 1 && (near2 === 0 || near2 === n - 1) && !(hit.t > 1e-6 && hit.t < 1 - 1e-6)) return null;
4046
+ let ids = null;
4047
+ this.change("Cut Path", () => {
4048
+ ids = this.applyCuts(node, (s, i) => i !== hit.subpath ? null : near2 !== void 0 ? cutSubpathAt(s, [near2]) : splitSubpathAt(s, hit.segment, hit.t));
4049
+ if (ids) {
4050
+ this.selection = ids;
4051
+ this.anchors = [];
4052
+ if (this.editingId === nodeId) this.editingId = null;
4053
+ }
4054
+ this.dropStaleRefs();
4055
+ });
4056
+ return ids;
4057
+ }
4058
+ /** Scissors on the selected (or given) anchors: each cuts its subpath there (see `cutPathAt`). */
4059
+ cutAtAnchors(refs = this.anchors) {
4060
+ const byNode = /* @__PURE__ */ new Map();
4061
+ for (const r of refs) if (getAnchor(this.doc, r)) byNode.set(r.nodeId, [...byNode.get(r.nodeId) ?? [], r]);
4062
+ if (!byNode.size) return { ok: false, message: "Select points to cut at." };
4063
+ const created = [];
4064
+ this.change("Cut Path", () => {
4065
+ for (const [id, list] of byNode) {
4066
+ const node = this.getNode(id);
4067
+ if (node.locked) continue;
4068
+ const ids = this.applyCuts(node, (sp, i) => {
4069
+ const at = list.filter((r) => r.subpath === i).map((r) => r.index);
4070
+ const pieces = at.length ? cutSubpathAt(sp, at) : null;
4071
+ return pieces && !(pieces.length === 1 && pieces[0].closed === sp.closed) ? pieces : null;
4072
+ });
4073
+ if (ids) created.push(...ids);
4074
+ }
4075
+ if (created.length) this.selection = created;
4076
+ this.anchors = [];
4077
+ this.dropStaleRefs();
4078
+ });
4079
+ return created.length ? { ok: true } : { ok: false, message: "Points at the ends of open paths do not cut." };
4080
+ }
4081
+ /** Visible, unlocked paths a stroke over `points` can reach: in `ids`, the selection, or (with neither) any whose bounds it crosses. */
4082
+ strokeTargets(points, ids, pad) {
4083
+ const blocked = /* @__PURE__ */ new Set();
4084
+ walkVectorNodes(this.doc.nodes, (n, parent) => {
4085
+ if (n.visible === false || n.locked || parent && blocked.has(parent.id)) blocked.add(n.id);
4086
+ });
4087
+ const visible = (p) => !blocked.has(p.id);
4088
+ if (ids?.length || this.selection.length) return this.pathTargets(ids ?? this.selection).filter(visible);
4089
+ const box = polylineBounds(points.map(toPoint2), pad);
4090
+ if (!box) return [];
4091
+ const out = [];
4092
+ walkVectorNodes(this.doc.nodes, (n) => {
4093
+ if (n.type !== "path" || !visible(n)) return;
4094
+ const b = vectorNodeBounds(n, vectorParentMatrix(this.doc.nodes, n.id) ?? IDENTITY_MATRIX);
4095
+ if (b && boundsIntersect(b, box)) out.push(n);
4096
+ });
4097
+ return out;
4098
+ }
4099
+ /** A stroke's points in node `id`'s own space. */
4100
+ localPoints(id, points) {
4101
+ const inv = invertMatrix(this.worldOf(id));
4102
+ return points.map((p) => toPoint2(applyMatrix(inv, p)));
4103
+ }
4104
+ /**
4105
+ * Knife: cuts closed paths along the polyline `points` (document space) wherever it crosses
4106
+ * them from edge to edge, with no gap between the pieces. It cuts the given paths, the
4107
+ * selection, or (with neither) every path it crosses. Returns the ids of the pieces (empty
4108
+ * when nothing was cut).
4109
+ */
4110
+ knife(points, options = {}) {
4111
+ const pts = simplifyPolyline(points.map(toPoint2), 0.25 / this.viewport.zoom).map(([x, y]) => ({ x, y }));
4112
+ if (pts.length < 2) return [];
4113
+ const plan = [];
4114
+ for (const node of this.strokeTargets(pts, options.ids, 0)) {
4115
+ const closed = node.subpaths.filter((sp) => sp.anchors.length > 1);
4116
+ if (!closed.length || closed.some((sp) => !sp.closed)) continue;
4117
+ const result = knifePath(subpathsToBezier(closed), node.fillRule ?? "nonzero", this.localPoints(node.id, pts));
4118
+ if (result) plan.push({ node, pieces: result.map((path) => bezierToSubpaths(path)) });
4119
+ }
4120
+ if (!plan.length) return [];
4121
+ const created = [];
4122
+ this.change("Knife", () => {
4123
+ for (const { node, pieces } of plan) created.push(...this.replaceWithPieces(node, pieces));
4124
+ this.selection = created;
4125
+ this.anchors = [];
4126
+ this.dropStaleRefs();
4127
+ });
4128
+ return created;
4129
+ }
4130
+ /**
4131
+ * Eraser: rubs out a round stroke of diameter `size` (default: the `eraserSize` setting)
4132
+ * along `points` (document space). Filled closed paths lose that area (pieces that come
4133
+ * apart become separate paths); outlines are cut where they pass under it. It erases from
4134
+ * the given paths, the selection, or (with neither) every path it reaches. Returns the ids
4135
+ * of the paths it changed or created.
4136
+ */
4137
+ erase(points, options = {}) {
4138
+ const width = options.size ?? this.editing.eraserSize;
4139
+ if (!(width > 0) || !points.length) return [];
4140
+ const pts = simplifyPolyline(points.map(toPoint2), Math.min(width * 0.05, 0.25 / this.viewport.zoom));
4141
+ const region = eraserRegion(pts, width);
4142
+ if (!region.length) return [];
4143
+ const plan = [];
4144
+ const rb = bezierPathBounds(region);
4145
+ const reach = rb ? [{ x: rb.minX, y: rb.minY }, { x: rb.maxX, y: rb.maxY }] : points;
4146
+ for (const node of this.strokeTargets(reach, options.ids, 0)) {
4147
+ const local = transformBezierPath(region, invertMatrix(this.worldOf(node.id)));
4148
+ const live = node.subpaths.filter((sp) => sp.anchors.length > 1);
4149
+ if (!live.length) continue;
4150
+ if (hasPaint(node.fill) && live.every((sp) => sp.closed)) {
4151
+ const faces = eraseFromFill(subpathsToBezier(live), node.fillRule ?? "nonzero", local);
4152
+ if (faces) plan.push({ node, pieces: faces.map((f) => bezierToSubpaths(f)) });
4153
+ continue;
4154
+ }
4155
+ const results = live.map((sp) => eraseFromOutline(sp, local));
4156
+ if (results.every((r) => !r)) continue;
4157
+ const subpaths = live.flatMap((sp, i) => results[i] ?? [sp]);
4158
+ plan.push({ node, pieces: live.length === 1 ? subpaths.map((sp) => [sp]) : [subpaths] });
4159
+ }
4160
+ if (!plan.length) return [];
4161
+ const changed = [];
4162
+ this.change("Erase", () => {
4163
+ for (const { node, pieces } of plan) changed.push(...this.replaceWithPieces(node, pieces));
4164
+ this.anchors = [];
4165
+ this.dropStaleRefs();
4166
+ if (this.selection.length || options.ids) this.selection = [.../* @__PURE__ */ new Set([...this.selection, ...changed.filter((id) => this.getNode(id))])];
4167
+ });
4168
+ return changed;
4169
+ }
2850
4170
  /** Ends the pen path being drawn (a lone anchor is removed). */
2851
4171
  finishPath() {
2852
4172
  if (!this.pen) return;
@@ -3039,10 +4359,38 @@ var VectorEditorController = class {
3039
4359
  const anchor = this.hitAnchor(screen, refs);
3040
4360
  return anchor ? { nodeId: anchor.nodeId, anchor } : null;
3041
4361
  }
4362
+ case "scissors": {
4363
+ const seg = this.nearestOutline(p);
4364
+ return seg ? { nodeId: seg.nodeId, segment: seg } : null;
4365
+ }
3042
4366
  default:
3043
4367
  return null;
3044
4368
  }
3045
4369
  }
4370
+ /** The nearest outline point of any visible, unlocked path within the pick distance of document point `p`. */
4371
+ nearestOutline(p) {
4372
+ let best = null;
4373
+ const tol = this.tolerance();
4374
+ const visit = (list, parent) => {
4375
+ for (let i = list.length - 1; i >= 0; i--) {
4376
+ const n = list[i];
4377
+ if (n.visible === false || n.locked) continue;
4378
+ const m = multiplyMatrices(nodeMatrix(n), parent);
4379
+ if (n.type === "group") {
4380
+ visit(n.children, m);
4381
+ continue;
4382
+ }
4383
+ const hit = nearestSegment(n, applyMatrix(invertMatrix(m), p));
4384
+ if (hit && hit.distance <= tol && (!best || hit.distance < best.distance - 1e-9)) {
4385
+ best = { nodeId: n.id, subpath: hit.subpath, segment: hit.segment, t: hit.t, point: applyMatrix(m, hit.point), distance: hit.distance };
4386
+ }
4387
+ }
4388
+ };
4389
+ visit(this.doc.nodes, IDENTITY_MATRIX);
4390
+ if (!best) return null;
4391
+ const { distance: _d, ...seg } = best;
4392
+ return seg;
4393
+ }
3046
4394
  sameHover(a, b) {
3047
4395
  return JSON.stringify(a) === JSON.stringify(b);
3048
4396
  }
@@ -3067,6 +4415,17 @@ var VectorEditorController = class {
3067
4415
  return this.downPen(screen, p, input);
3068
4416
  case "pencil":
3069
4417
  return this.downPencil(p);
4418
+ case "scissors": {
4419
+ const seg = this.nearestOutline(p);
4420
+ if (seg) this.cutPathAt(seg.nodeId, seg.point);
4421
+ return;
4422
+ }
4423
+ case "knife":
4424
+ case "eraser":
4425
+ this.drag = { kind: "stroke", tool: this.tool, points: [p], before: this.snapshot() };
4426
+ this.interaction = { type: "stroke", tool: this.tool, points: [p], width: this.tool === "eraser" ? this.editing.eraserSize : 0 };
4427
+ this.notify();
4428
+ return;
3070
4429
  default:
3071
4430
  return this.downShape(p, input);
3072
4431
  }
@@ -3161,7 +4520,7 @@ var VectorEditorController = class {
3161
4520
  const b = { nodeId: id, subpath: seg.subpath, index: (seg.segment + 1) % sp.anchors.length };
3162
4521
  this.anchors = input.shift ? [...this.anchors.filter((r) => !sameRef(r, a) && !sameRef(r, b)), a, b] : [a, b];
3163
4522
  this.editingId = id;
3164
- this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
4523
+ this.drag = { kind: "bend", ref: { nodeId: id, subpath: seg.subpath, segment: seg.segment }, t: seg.t, start: p, before: this.snapshot(), base: this.doc, moved: false };
3165
4524
  this.notify();
3166
4525
  return;
3167
4526
  }
@@ -3253,7 +4612,9 @@ var VectorEditorController = class {
3253
4612
  subpaths: [{ anchors: [{ x: p.x, y: p.y }], closed: false }]
3254
4613
  };
3255
4614
  this.setDoc([...this.doc.nodes, node]);
3256
- this.drag = { kind: "pencil", nodeId: node.id, points: [[p.x, p.y]], before };
4615
+ const lazy = new LazyMouse(this.editing.pencilSmoothing * VECTOR_PENCIL_STRING_PX / this.viewport.zoom);
4616
+ lazy.push([p.x, p.y]);
4617
+ this.drag = { kind: "pencil", nodeId: node.id, points: [[p.x, p.y]], before, lazy };
3257
4618
  this.interaction = { type: "draw", tool: "pencil", nodeId: node.id, bounds: { x: p.x, y: p.y, width: 0, height: 0 } };
3258
4619
  this.notify(true);
3259
4620
  }
@@ -3365,7 +4726,10 @@ var VectorEditorController = class {
3365
4726
  dx = q.x - lead.x;
3366
4727
  dy = q.y - lead.y;
3367
4728
  } else if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
3368
- this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
4729
+ const single = this.anchors.length === 1 ? this.anchors[0] : null;
4730
+ const from = single ? getAnchorWorld(drag.base, single) : void 0;
4731
+ if (single && from && this.editing.curvatureMoves !== !!input.alt) this.doc = this.curvatureMoved(drag.base, single, { x: from.x + dx, y: from.y + dy });
4732
+ else this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
3369
4733
  this.interaction = { type: "anchors", dx, dy };
3370
4734
  this.notify(true);
3371
4735
  return;
@@ -3380,6 +4744,22 @@ var VectorEditorController = class {
3380
4744
  this.notify(true);
3381
4745
  return;
3382
4746
  }
4747
+ case "bend": {
4748
+ if (!drag.moved && Math.hypot(p.x - drag.start.x, p.y - drag.start.y) * this.viewport.zoom < 2) return;
4749
+ drag.moved = true;
4750
+ this.doc = this.bent(drag.base, drag.ref, drag.t, p);
4751
+ this.interaction = { type: "bend", ref: drag.ref };
4752
+ this.notify(true);
4753
+ return;
4754
+ }
4755
+ case "stroke": {
4756
+ const last = drag.points[drag.points.length - 1];
4757
+ if (Math.hypot(p.x - last.x, p.y - last.y) * this.viewport.zoom < 1.5) return;
4758
+ drag.points.push(p);
4759
+ this.interaction = { type: "stroke", tool: drag.tool, points: drag.points.slice(), width: drag.tool === "eraser" ? this.editing.eraserSize : 0 };
4760
+ this.notify();
4761
+ return;
4762
+ }
3383
4763
  case "pull": {
3384
4764
  const anchor = getAnchorWorld(drag.base, drag.ref);
3385
4765
  if (!anchor) return;
@@ -3433,10 +4813,22 @@ var VectorEditorController = class {
3433
4813
  }
3434
4814
  case "pencil": {
3435
4815
  const last = drag.points[drag.points.length - 1];
3436
- if (Math.hypot(p.x - last[0], p.y - last[1]) * this.viewport.zoom < 1.5) return;
3437
- drag.points.push([p.x, p.y]);
4816
+ let moved = false;
4817
+ for (const q of drag.lazy.push([p.x, p.y])) {
4818
+ if (Math.hypot(q[0] - last[0], q[1] - last[1]) * this.viewport.zoom < 1.5) continue;
4819
+ drag.points.push(q);
4820
+ moved = true;
4821
+ }
4822
+ const string = drag.lazy.string();
3438
4823
  const node = this.getNode(drag.nodeId);
3439
4824
  if (node?.type !== "path") return;
4825
+ if (!moved) {
4826
+ if (string && this.interaction?.type === "draw") {
4827
+ this.interaction = { ...this.interaction, string: [{ x: string[0][0], y: string[0][1] }, { x: string[1][0], y: string[1][1] }] };
4828
+ this.notify();
4829
+ }
4830
+ return;
4831
+ }
3440
4832
  this.replaceNode(drag.nodeId, { ...node, subpaths: [{ anchors: drag.points.map(([x, y]) => ({ x, y })), closed: false }] });
3441
4833
  const xs = drag.points.map((q) => q[0]);
3442
4834
  const ys = drag.points.map((q) => q[1]);
@@ -3444,7 +4836,8 @@ var VectorEditorController = class {
3444
4836
  type: "draw",
3445
4837
  tool: "pencil",
3446
4838
  nodeId: drag.nodeId,
3447
- bounds: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }
4839
+ bounds: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) },
4840
+ ...string ? { string: [{ x: string[0][0], y: string[0][1] }, { x: string[1][0], y: string[1][1] }] } : {}
3448
4841
  };
3449
4842
  this.notify(true);
3450
4843
  return;
@@ -3536,6 +4929,18 @@ var VectorEditorController = class {
3536
4929
  return this.commit(drag.before.doc !== drag.base ? "Add Point" : "Move Points", drag.before);
3537
4930
  case "handle":
3538
4931
  return this.commit("Move Handle", drag.before);
4932
+ case "bend":
4933
+ if (!drag.moved) {
4934
+ this.notify();
4935
+ return;
4936
+ }
4937
+ return this.commit("Bend Segment", drag.before);
4938
+ case "stroke": {
4939
+ this.notify();
4940
+ if (drag.tool === "knife") this.knife(drag.points);
4941
+ else this.erase(drag.points);
4942
+ return;
4943
+ }
3539
4944
  case "pull":
3540
4945
  if (drag.closing) this.pen = null;
3541
4946
  return this.commit(drag.label, drag.before);
@@ -3560,6 +4965,13 @@ var VectorEditorController = class {
3560
4965
  this.notify(true);
3561
4966
  return;
3562
4967
  }
4968
+ for (const q of drag.lazy.finish()) drag.points.push(q);
4969
+ const shape = this.editing.recognizeShapes ? recognizeInkShape(drag.points, { minSize: 6 / this.viewport.zoom }) : null;
4970
+ if (shape) {
4971
+ this.replaceNode(drag.nodeId, { ...node, name: inkShapeLabel(shape), subpaths: [inkShapeSubpath(shape)] });
4972
+ this.selection = [drag.nodeId];
4973
+ return this.commit(`Draw ${inkShapeLabel(shape)}`, drag.before);
4974
+ }
3563
4975
  const fitted = fitCubics(drag.points, 1.5 / this.viewport.zoom, { cornerAngleDeg: 60 });
3564
4976
  const anchors = [];
3565
4977
  for (const c of fitted) {
@@ -3666,6 +5078,8 @@ var VectorEditorController = class {
3666
5078
  case "g":
3667
5079
  input.shift ? this.ungroup() : this.group();
3668
5080
  return true;
5081
+ case "j":
5082
+ return input.alt ? this.averageAnchors("both").ok : this.joinPaths().ok;
3669
5083
  case "]":
3670
5084
  this.arrange(input.alt ? "front" : "forward");
3671
5085
  return true;
@@ -3746,6 +5160,22 @@ var VectorEditorController = class {
3746
5160
  }
3747
5161
  }
3748
5162
  };
5163
+ function inkShapeLabel(shape) {
5164
+ const name = vectorInkShapeName(shape);
5165
+ return name[0].toUpperCase() + name.slice(1);
5166
+ }
5167
+ function inkShapeSubpath(shape) {
5168
+ switch (shape.kind) {
5169
+ case "line":
5170
+ return lineSubpath({ x: shape.a[0], y: shape.a[1] }, { x: shape.b[0], y: shape.b[1] });
5171
+ case "rect":
5172
+ return rectSubpath(shape);
5173
+ case "ellipse":
5174
+ return ellipseSubpath(shape);
5175
+ case "polygon":
5176
+ return { anchors: shape.points.map(([x, y]) => ({ x, y })), closed: true };
5177
+ }
5178
+ }
3749
5179
  function constrain45(origin, p) {
3750
5180
  const dx = p.x - origin.x;
3751
5181
  const dy = p.y - origin.y;
@@ -3917,7 +5347,20 @@ function getVectorOverlayRenderData(state) {
3917
5347
  guides.labels.push({ kind: l.kind, x: fmt(p.x), y: fmt(p.y), text: l.text });
3918
5348
  }
3919
5349
  }
3920
- return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status };
5350
+ let toolStroke = null;
5351
+ if (it?.type === "stroke") {
5352
+ const pts = it.points.map((q) => toScreen(state, q));
5353
+ const d = pts.map((q, i) => `${i ? "L" : "M"}${fmt(q.x)} ${fmt(q.y)}`).join("") + (pts.length === 1 ? `L${fmt(pts[0].x)} ${fmt(pts[0].y)}` : "");
5354
+ toolStroke = { tool: it.tool, d, width: fmt(it.width * z) };
5355
+ }
5356
+ let pencilString = null;
5357
+ if (it?.type === "draw" && it.string) {
5358
+ const a = toScreen(state, it.string[0]);
5359
+ const b = toScreen(state, it.string[1]);
5360
+ pencilString = { x1: fmt(a.x), y1: fmt(a.y), x2: fmt(b.x), y2: fmt(b.y) };
5361
+ }
5362
+ const cutPoint = state.tool === "scissors" && state.hover?.segment && !it ? (({ x, y }) => ({ x: fmt(x), y: fmt(y) }))(toScreen(state, state.hover.segment.point)) : null;
5363
+ return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status, toolStroke, pencilString, cutPoint };
3921
5364
  }
3922
5365
  var VECTOR_OVERLAY_THEME = {
3923
5366
  accent: "#0ea5e9",
@@ -3925,6 +5368,8 @@ var VECTOR_OVERLAY_THEME = {
3925
5368
  guide: "#ff3dfc",
3926
5369
  handleFill: "#ffffff",
3927
5370
  marqueeFill: "rgba(14, 165, 233, 0.08)",
5371
+ /** The eraser's swath while it is dragged. */
5372
+ eraserFill: "rgba(239, 68, 68, 0.25)",
3928
5373
  transformHandleSize: 8,
3929
5374
  anchorSize: 7,
3930
5375
  handleRadius: 3.5
@@ -3970,12 +5415,39 @@ function vectorPaintToHex(paint) {
3970
5415
  const hex = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
3971
5416
  return `#${hex.toLowerCase()}`;
3972
5417
  }
5418
+ var VECTOR_PATH_ACTIONS = [
5419
+ { id: "join", label: "\u22C8", title: "Join", shortcut: "J", enabled: (s, p) => s + p > 0, run: (c) => c.joinPaths() },
5420
+ { id: "average", label: "\u22B9", title: "Average Points", shortcut: "Alt+J", enabled: (_s, p) => p > 1, run: (c) => c.averageAnchors("both") },
5421
+ { id: "cut", label: "\u2702\xB7", title: "Cut at Points", enabled: (_s, p) => p > 0, run: (c) => c.cutAtAnchors() },
5422
+ { id: "clean", label: "\u2301", title: "Remove Redundant Points", enabled: (s, p) => s + p > 0, run: (c) => c.removeRedundantPoints() },
5423
+ { id: "smooth", label: "\u223F", title: "Smooth", enabled: (s, p) => s + p > 0, run: (c) => c.smoothPaths() }
5424
+ ];
5425
+ var VECTOR_EDIT_TOGGLES = [
5426
+ { key: "keepShapeOnDelete", label: "Keep", title: "Keep shape when deleting points" },
5427
+ { key: "curvatureMoves", label: "Curv", title: "Curvature point moves (Alt flips while dragging)" },
5428
+ { key: "recognizeShapes", label: "Shapes", title: "Turn pencil scribbles into shapes" }
5429
+ ];
3973
5430
 
3974
5431
  // src/vector/commands.ts
3975
5432
  var fail = (message) => {
3976
5433
  throw new Error(message);
3977
5434
  };
3978
5435
  var IDS = { type: "array", items: { type: "string" }, description: "Node ids (default: the selection)." };
5436
+ var ANCHORS = {
5437
+ type: "array",
5438
+ items: {
5439
+ type: "object",
5440
+ properties: { nodeId: { type: "string" }, subpath: { type: "integer", minimum: 0 }, index: { type: "integer", minimum: 0 } },
5441
+ required: ["nodeId", "subpath", "index"]
5442
+ },
5443
+ description: "Anchors as { nodeId, subpath, index } (default: the selected points)."
5444
+ };
5445
+ var POINTS = {
5446
+ type: "array",
5447
+ items: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 },
5448
+ minItems: 1,
5449
+ description: "Polyline [[x, y], \u2026] in document units."
5450
+ };
3979
5451
  var PAINT = {
3980
5452
  description: 'A CSS colour, `null` for none, or a gradient `{ type: "linear", angle, stops: [{ offset, color }] }`.'
3981
5453
  };
@@ -4008,6 +5480,14 @@ function createVectorCommands(controller, options = {}) {
4008
5480
  return list2;
4009
5481
  };
4010
5482
  const enabledFor = (_, params) => params?.ids?.length ? true : hasSelection();
5483
+ const anchorsOf = (refs) => {
5484
+ if (!refs) return [...controller.getState().selectedAnchors];
5485
+ for (const r of refs) {
5486
+ const node = controller.getNode(r.nodeId);
5487
+ if (node?.type !== "path" || !node.subpaths[r.subpath]?.anchors[r.index]) fail(`There is no anchor ${r.index} on subpath ${r.subpath} of "${r.nodeId}".`);
5488
+ }
5489
+ return refs;
5490
+ };
4011
5491
  const list = () => {
4012
5492
  const state = controller.getState();
4013
5493
  const out = [];
@@ -4246,6 +5726,186 @@ function createVectorCommands(controller, options = {}) {
4246
5726
  return { selection: controller.getState().selection };
4247
5727
  }
4248
5728
  },
5729
+ {
5730
+ ...base,
5731
+ id: `${prefix}deleteAnchors`,
5732
+ label: "Delete Points",
5733
+ keywords: ["remove", "anchor", "point", "node"],
5734
+ description: "Deletes anchors. With `keepShape` (default: the editor setting, on) the neighbouring handles are refitted so the outline keeps its shape; off, the neighbours are joined as they are.",
5735
+ params: { type: "object", properties: { anchors: ANCHORS, keepShape: { type: "boolean" } } },
5736
+ undoable: true,
5737
+ run: (_ctx, params) => {
5738
+ check(controller.deleteAnchors(anchorsOf(params?.anchors), { keepShape: params?.keepShape }));
5739
+ return { ok: true };
5740
+ }
5741
+ },
5742
+ {
5743
+ ...base,
5744
+ id: `${prefix}moveAnchor`,
5745
+ label: "Move Point\u2026",
5746
+ keywords: ["anchor", "drag", "curvature"],
5747
+ description: "Moves one anchor to (x, y) in document units. `curvature: true` bends its two segments like the Curvature tool instead of carrying its handles along.",
5748
+ params: {
5749
+ type: "object",
5750
+ properties: { nodeId: { type: "string" }, subpath: { type: "integer", minimum: 0 }, index: { type: "integer", minimum: 0 }, x: { type: "number" }, y: { type: "number" }, curvature: { type: "boolean" } },
5751
+ required: ["nodeId", "subpath", "index", "x", "y"]
5752
+ },
5753
+ undoable: true,
5754
+ run: (_ctx, params) => {
5755
+ check(controller.moveAnchor({ nodeId: params.nodeId, subpath: params.subpath, index: params.index }, { x: params.x, y: params.y }, { curvature: params.curvature }));
5756
+ return { ok: true };
5757
+ }
5758
+ },
5759
+ {
5760
+ ...base,
5761
+ id: `${prefix}bendSegment`,
5762
+ label: "Bend Segment\u2026",
5763
+ keywords: ["curve", "segment", "drag"],
5764
+ description: "Bends segment `segment` (from anchor `segment` to the next) so the curve passes through (x, y) at parameter `t` (default 0.5).",
5765
+ params: {
5766
+ type: "object",
5767
+ properties: {
5768
+ nodeId: { type: "string" },
5769
+ subpath: { type: "integer", minimum: 0 },
5770
+ segment: { type: "integer", minimum: 0 },
5771
+ x: { type: "number" },
5772
+ y: { type: "number" },
5773
+ t: { type: "number", minimum: 0, maximum: 1 }
5774
+ },
5775
+ required: ["nodeId", "subpath", "segment", "x", "y"]
5776
+ },
5777
+ undoable: true,
5778
+ run: (_ctx, params) => {
5779
+ check(controller.bendSegment({ nodeId: params.nodeId, subpath: params.subpath, segment: params.segment }, { x: params.x, y: params.y }, params.t));
5780
+ return { ok: true };
5781
+ }
5782
+ },
5783
+ {
5784
+ ...base,
5785
+ id: `${prefix}join`,
5786
+ label: "Join",
5787
+ shortcut: "Mod+J",
5788
+ keywords: ["connect", "close", "merge", "endpoints"],
5789
+ description: "Joins the open subpaths of paths nearest endpoints first (ends within `tolerance` merge); a single open path is closed.",
5790
+ params: { type: "object", properties: { ids: IDS, tolerance: { type: "number", minimum: 0 } } },
5791
+ undoable: true,
5792
+ run: (_ctx, params) => {
5793
+ check(controller.joinPaths({ ids: params?.ids ? ids(params) : void 0, tolerance: params?.tolerance }));
5794
+ return { selection: controller.getState().selection };
5795
+ }
5796
+ },
5797
+ {
5798
+ ...base,
5799
+ id: `${prefix}average`,
5800
+ label: "Average Points\u2026",
5801
+ shortcut: "Mod+Alt+J",
5802
+ keywords: ["align", "points", "anchors", "collapse"],
5803
+ description: "Moves anchors onto their average position: the same y (`horizontal`), the same x (`vertical`) or the same point (`both`, default).",
5804
+ params: { type: "object", properties: { axis: { type: "string", enum: ["horizontal", "vertical", "both"] }, anchors: ANCHORS } },
5805
+ undoable: true,
5806
+ run: (_ctx, params) => {
5807
+ check(controller.averageAnchors(params?.axis ?? "both", anchorsOf(params?.anchors)));
5808
+ return { ok: true };
5809
+ }
5810
+ },
5811
+ {
5812
+ ...base,
5813
+ id: `${prefix}removeRedundantPoints`,
5814
+ label: "Remove Redundant Points",
5815
+ keywords: ["simplify", "clean up", "anchors", "collinear"],
5816
+ description: "Drops anchors that can go without moving the outline more than `tolerance` document units (default 0.5).",
5817
+ params: { type: "object", properties: { ids: IDS, tolerance: { type: "number", minimum: 0 } } },
5818
+ enabled: enabledFor,
5819
+ undoable: true,
5820
+ run: (_ctx, params) => {
5821
+ check(controller.removeRedundantPoints({ ids: params?.ids ? ids(params) : void 0, tolerance: params?.tolerance }));
5822
+ return { ok: true };
5823
+ }
5824
+ },
5825
+ {
5826
+ ...base,
5827
+ id: `${prefix}smooth`,
5828
+ label: "Smooth Path",
5829
+ keywords: ["round", "soften", "handles", "curves"],
5830
+ description: "Pulls the handles of paths (or of the selected points) towards smooth tangents by `amount` (0\u20131, default 1).",
5831
+ params: { type: "object", properties: { ids: IDS, amount: { type: "number", minimum: 0, maximum: 1 }, anchors: ANCHORS } },
5832
+ undoable: true,
5833
+ run: (_ctx, params) => {
5834
+ check(controller.smoothPaths({ ids: params?.ids ? ids(params) : void 0, amount: params?.amount, refs: params?.anchors }));
5835
+ return { ok: true };
5836
+ }
5837
+ },
5838
+ {
5839
+ ...base,
5840
+ id: `${prefix}cut`,
5841
+ label: "Cut Path (Scissors)\u2026",
5842
+ keywords: ["scissors", "split", "open", "break"],
5843
+ description: "Scissors: cuts path `nodeId` at the outline point nearest (x, y), or cuts at `anchors`. A closed path opens; an open one splits in two.",
5844
+ params: { type: "object", properties: { nodeId: { type: "string" }, x: { type: "number" }, y: { type: "number" }, anchors: ANCHORS } },
5845
+ undoable: true,
5846
+ run: (_ctx, params) => {
5847
+ if (params?.nodeId !== void 0) {
5848
+ if (!controller.getNode(params.nodeId)) fail(`There is no node "${params.nodeId}".`);
5849
+ if (params.x === void 0 || params.y === void 0) fail("Pass x and y with nodeId.");
5850
+ const created = controller.cutPathAt(params.nodeId, { x: params.x, y: params.y });
5851
+ if (!created) fail("Nothing to cut there (the end of an open path, or a locked path).");
5852
+ return { ids: created };
5853
+ }
5854
+ check(controller.cutAtAnchors(anchorsOf(params?.anchors)));
5855
+ return { ids: controller.getState().selection };
5856
+ }
5857
+ },
5858
+ {
5859
+ ...base,
5860
+ id: `${prefix}knife`,
5861
+ label: "Knife\u2026",
5862
+ keywords: ["slice", "cut", "split", "divide"],
5863
+ description: "Cuts closed paths along a polyline wherever it crosses them edge to edge (no gap between pieces). Cuts `ids`, else the selection, else every path the line crosses.",
5864
+ params: { type: "object", properties: { points: { ...POINTS, minItems: 2 }, ids: IDS }, required: ["points"] },
5865
+ undoable: true,
5866
+ run: (_ctx, params) => {
5867
+ const created = controller.knife(params.points.map(([x, y]) => ({ x, y })), { ids: params.ids ? ids(params) : void 0 });
5868
+ if (!created.length) fail("The knife did not cross any closed path from edge to edge.");
5869
+ return { ids: created };
5870
+ }
5871
+ },
5872
+ {
5873
+ ...base,
5874
+ id: `${prefix}erase`,
5875
+ label: "Erase\u2026",
5876
+ keywords: ["eraser", "rub out", "remove"],
5877
+ description: "Erases a round stroke of diameter `size` (default: the eraser size setting) along a polyline: filled shapes lose the area, outlines are cut under it. Erases from `ids`, else the selection, else every path it reaches.",
5878
+ params: { type: "object", properties: { points: POINTS, size: { type: "number", exclusiveMinimum: 0 }, ids: IDS }, required: ["points"] },
5879
+ undoable: true,
5880
+ run: (_ctx, params) => {
5881
+ const changed = controller.erase(params.points.map(([x, y]) => ({ x, y })), { size: params.size, ids: params.ids ? ids(params) : void 0 });
5882
+ if (!changed.length) fail("The eraser did not touch any path.");
5883
+ return { ids: changed.filter((id) => controller.getNode(id)) };
5884
+ }
5885
+ },
5886
+ {
5887
+ ...base,
5888
+ id: `${prefix}setEditing`,
5889
+ label: "Path Editing Options\u2026",
5890
+ keywords: ["settings", "curvature", "pencil", "smoothing", "lazy mouse", "shapes", "eraser"],
5891
+ description: "Sets how editing behaves: `keepShapeOnDelete`, `curvatureMoves` (anchor drags bend like the Curvature tool), `pencilSmoothing` (lazy mouse, 0\u20131), `recognizeShapes` (pencil scribbles become lines, rectangles, ellipses, polygons) and `eraserSize`.",
5892
+ params: {
5893
+ type: "object",
5894
+ properties: {
5895
+ keepShapeOnDelete: { type: "boolean" },
5896
+ curvatureMoves: { type: "boolean" },
5897
+ pencilSmoothing: { type: "number", minimum: 0, maximum: 1 },
5898
+ recognizeShapes: { type: "boolean" },
5899
+ eraserSize: { type: "number", exclusiveMinimum: 0 }
5900
+ }
5901
+ },
5902
+ run: (_ctx, params) => {
5903
+ const patch = {};
5904
+ for (const key of ["keepShapeOnDelete", "curvatureMoves", "pencilSmoothing", "recognizeShapes", "eraserSize"]) if (params?.[key] !== void 0) patch[key] = params[key];
5905
+ controller.setEditing(patch);
5906
+ return { editing: controller.getState().editing };
5907
+ }
5908
+ },
4249
5909
  {
4250
5910
  ...base,
4251
5911
  id: `${prefix}setTool`,
@@ -4284,12 +5944,18 @@ function createVectorCommands(controller, options = {}) {
4284
5944
  ];
4285
5945
  }
4286
5946
  export {
5947
+ DEFAULT_VECTOR_PATH_EDIT,
4287
5948
  DEFAULT_VECTOR_SNAP,
4288
5949
  IDENTITY_MATRIX,
4289
5950
  KAPPA,
5951
+ LazyMouse,
4290
5952
  VECTOR_CLIPBOARD_TYPE,
4291
5953
  VECTOR_CONSTRUCTION_ANGLES,
5954
+ VECTOR_EDIT_TOGGLES,
5955
+ VECTOR_INK_MAX_CORNERS,
4292
5956
  VECTOR_OVERLAY_THEME,
5957
+ VECTOR_PATH_ACTIONS,
5958
+ VECTOR_PENCIL_STRING_PX,
4293
5959
  VECTOR_ROTATE_HANDLE_OFFSET,
4294
5960
  VECTOR_TOOLS,
4295
5961
  VECTOR_TOOL_GLYPHS,
@@ -4299,6 +5965,7 @@ export {
4299
5965
  VectorSnapTargets,
4300
5966
  applyMatrix,
4301
5967
  applyMatrixLinear,
5968
+ bendSegment,
4302
5969
  bezierToSubpaths,
4303
5970
  boundsFromPoints,
4304
5971
  boundsIntersect,
@@ -4310,8 +5977,13 @@ export {
4310
5977
  constrainToLeave,
4311
5978
  convertAnchor,
4312
5979
  createVectorCommands,
5980
+ curvatureMoveAnchor,
5981
+ cutSubpathAt,
4313
5982
  decomposeRigid,
4314
5983
  ellipseSubpath,
5984
+ eraseFromFill,
5985
+ eraseFromOutline,
5986
+ eraserRegion,
4315
5987
  findVectorNode,
4316
5988
  getAnchor,
4317
5989
  getAnchorWorld,
@@ -4332,6 +6004,8 @@ export {
4332
6004
  invertMatrix,
4333
6005
  isIdentityMatrix,
4334
6006
  isVectorTextTarget,
6007
+ joinSubpaths,
6008
+ knifePath,
4335
6009
  lineSubpath,
4336
6010
  locateVectorNode,
4337
6011
  matrixRotation,
@@ -4342,14 +6016,24 @@ export {
4342
6016
  parseSvgTransform,
4343
6017
  parseVectorSvg,
4344
6018
  polygonSubpath,
6019
+ polylineBounds,
6020
+ recognizeInkShape,
4345
6021
  rectSubpath,
6022
+ removeAnchorKeepingShape,
4346
6023
  removeAnchors,
6024
+ removeAnchorsKeepingShape,
6025
+ removeRedundantPoints,
4347
6026
  reverseSubpath,
4348
6027
  rotateMatrix,
4349
6028
  roundSubpaths,
4350
6029
  scaleFor,
4351
6030
  scaleMatrix,
6031
+ simplifyPolyline,
6032
+ smoothSubpath,
6033
+ splitIntoFaces,
6034
+ splitSubpathAt,
4352
6035
  starSubpath,
6036
+ subpathPointAt,
4353
6037
  subpathSegment,
4354
6038
  subpathSegmentCount,
4355
6039
  subpathsToBezier,
@@ -4361,6 +6045,7 @@ export {
4361
6045
  translateMatrix,
4362
6046
  unionBounds,
4363
6047
  vectorDocumentToSvg,
6048
+ vectorInkShapeName,
4364
6049
  vectorNodeBounds,
4365
6050
  vectorNodeRotation,
4366
6051
  vectorNodeToBezier,