@pixerate/editor 0.33.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,21 +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,
21
+ segmentDerivative,
22
+ segmentIntersections,
13
23
  segmentNearest,
14
24
  segmentPoint,
15
25
  splitSegment,
26
+ subSegment,
16
27
  toBezierPath,
17
- transformBezierPath
18
- } from "../chunk-GQTXV2EQ.js";
28
+ transformBezierPath,
29
+ windingNumber
30
+ } from "../chunk-EGPIVUEF.js";
19
31
 
20
32
  // src/vector/types.ts
21
- 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"];
22
34
  var VECTOR_TRANSFORM_HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w", "rotate"];
23
35
 
24
36
  // src/vector/geometry.ts
@@ -850,7 +862,1719 @@ function parseVectorSvg(svg, options = {}) {
850
862
  return { width, height, nodes };
851
863
  }
852
864
 
865
+ // src/vector/snap.ts
866
+ var VECTOR_CONSTRUCTION_ANGLES = [0, 45, 90, 135];
867
+ function constrainToLeave(leave, p) {
868
+ if (!leave?.constrain) return p;
869
+ const dx = p.x - leave.at.x;
870
+ const dy = p.y - leave.at.y;
871
+ switch (leave.constrain) {
872
+ case "axis":
873
+ return Math.abs(dx) > Math.abs(dy) ? { x: p.x, y: leave.at.y } : { x: leave.at.x, y: p.y };
874
+ case "square": {
875
+ const s = Math.max(Math.abs(dx), Math.abs(dy));
876
+ return { x: leave.at.x + Math.sign(dx || 1) * s, y: leave.at.y + Math.sign(dy || 1) * s };
877
+ }
878
+ default: {
879
+ const len = Math.hypot(dx, dy);
880
+ const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
881
+ return { x: leave.at.x + Math.cos(a) * len, y: leave.at.y + Math.sin(a) * len };
882
+ }
883
+ }
884
+ }
885
+ var BUDGET = 2e4;
886
+ var CROSSING_PAIRS = 32;
887
+ var CURVE_SAMPLES = 48;
888
+ var BISECT = 50;
889
+ var EQUAL = 1e-6;
890
+ function bound(sorted, key, v, strict) {
891
+ let lo = 0;
892
+ let hi = sorted.length;
893
+ while (lo < hi) {
894
+ const mid = lo + hi >>> 1;
895
+ const k = key(sorted[mid]);
896
+ if (strict ? k <= v : k < v) lo = mid + 1;
897
+ else hi = mid;
898
+ }
899
+ return lo;
900
+ }
901
+ function within(sorted, key, v, tol) {
902
+ return sorted.slice(bound(sorted, key, v - tol, false), bound(sorted, key, v + tol, true));
903
+ }
904
+ var linesNear = (lines, v, tol) => within(lines, (l) => l.v, v, tol);
905
+ var distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
906
+ var toPoint = (p) => [p.x, p.y];
907
+ var toVec = (p) => ({ x: p[0], y: p[1] });
908
+ var LABELS = {
909
+ anchor: "anchor",
910
+ centre: "centre",
911
+ intersect: "intersect",
912
+ perpendicular: "perpendicular",
913
+ tangent: "tangent",
914
+ path: "path",
915
+ construction: "angle",
916
+ align: "align",
917
+ spacing: "spacing",
918
+ size: "size"
919
+ };
920
+ var fmtLength = (n) => String(Math.round(n * 100) / 100);
921
+ function nodeFrame(node, parent, aabb) {
922
+ const angle = matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
923
+ const b = angle ? vectorNodeBounds(node, multiplyMatrices(parent, rotateMatrix(-angle))) : aabb !== void 0 ? aabb : vectorNodeBounds(node, parent);
924
+ if (!b) return null;
925
+ const c = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
926
+ return { width: b.width, height: b.height, centre: angle ? applyMatrix(rotateMatrix(angle), c) : c };
927
+ }
928
+ function contains(node, ids) {
929
+ if (ids.has(node.id)) return true;
930
+ return node.type === "group" && node.children.some((c) => contains(c, ids));
931
+ }
932
+ var VectorSnapTargets = class _VectorSnapTargets {
933
+ points = [];
934
+ xs = [];
935
+ ys = [];
936
+ segments = [];
937
+ /** Bounds of the objects a moved box spaces itself evenly with. */
938
+ boxes = [];
939
+ /** Side lengths of the other objects (in their own frames), which a resize may match. */
940
+ sizes = [];
941
+ constructor() {
942
+ }
943
+ /** How many targets of each sort were gathered. */
944
+ get counts() {
945
+ return { points: this.points.length, lines: this.xs.length + this.ys.length, segments: this.segments.length, boxes: this.boxes.length };
946
+ }
947
+ /** Targets for moving nodes `ids`: the other art's anchors, bounds and centres, the artboard, and boxes to space evenly with. */
948
+ static forMove(doc, ids) {
949
+ return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
950
+ }
951
+ /** Targets for resizing nodes `ids`: as for a move, plus the other objects' sizes. */
952
+ static forResize(doc, ids) {
953
+ return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
954
+ }
955
+ /**
956
+ * Targets for a drawn point (pen, shape tools): the art but `exclude`, its anchors (as
957
+ * points and in line), centres, edges and segments. The anchors and segments of `exclude`
958
+ * (the path being drawn, whose bounds grow with it) stay targets.
959
+ */
960
+ static forDrawing(doc, exclude = []) {
961
+ const skip = new Set(exclude);
962
+ const t = _VectorSnapTargets.collect(doc, skip, "drawing", false);
963
+ t.pushPaths(doc, skip, () => false, true);
964
+ return t.index();
965
+ }
966
+ /**
967
+ * Targets for dragging anchors: the other art with its segments, and the anchors and
968
+ * segments that stay put on the paths being reshaped (their bounds move along, so they are
969
+ * no targets). The moving anchors are left out.
970
+ */
971
+ static forAnchorDrag(doc, anchors) {
972
+ const moving = /* @__PURE__ */ new Map();
973
+ for (const r of anchors) {
974
+ const set = moving.get(r.nodeId) ?? /* @__PURE__ */ new Set();
975
+ set.add(`${r.subpath}:${r.index}`);
976
+ moving.set(r.nodeId, set);
977
+ }
978
+ const t = _VectorSnapTargets.collect(doc, new Set(moving.keys()), "segments", false);
979
+ t.pushPaths(doc, new Set(moving.keys()), (id, s, i) => !!moving.get(id)?.has(`${s}:${i}`), true);
980
+ return t.index();
981
+ }
982
+ /**
983
+ * Targets for dragging a handle of path `nodeId`: the other art and that path's anchors
984
+ * (its bounds and the segments through the handle move with it, so they would chase it).
985
+ */
986
+ static forHandle(doc, nodeId) {
987
+ const skip = /* @__PURE__ */ new Set([nodeId]);
988
+ const t = _VectorSnapTargets.collect(doc, skip, "bounds", false);
989
+ t.pushPaths(doc, skip, () => false, false);
990
+ return t.index();
991
+ }
992
+ /** The anchors of the paths `ids` as targets (on them and in line), and their segments when `segments`. */
993
+ pushPaths(doc, ids, moving, segments) {
994
+ let anchors = BUDGET;
995
+ let segmentBudget = segments ? BUDGET : 0;
996
+ const visit = (list, parent) => {
997
+ for (const n of list) {
998
+ const m = multiplyMatrices(nodeMatrix(n), parent);
999
+ if (n.type === "group") visit(n.children, m);
1000
+ else if (ids.has(n.id) && n.visible !== false) {
1001
+ n.subpaths.forEach((sp, s) => {
1002
+ sp.anchors.forEach((a, i) => {
1003
+ if (anchors <= 0 || moving(n.id, s, i)) return;
1004
+ this.pushAnchor(applyMatrix(m, a));
1005
+ anchors--;
1006
+ });
1007
+ segmentBudget = this.pushSegments(sp, m, (i) => moving(n.id, s, i), segmentBudget);
1008
+ });
1009
+ }
1010
+ }
1011
+ };
1012
+ visit(doc.nodes, IDENTITY_MATRIX);
1013
+ }
1014
+ pushAnchor(p) {
1015
+ this.points.push({ p, kind: "anchor" });
1016
+ this.xs.push({ v: p.x, from: p, kind: "anchor" });
1017
+ this.ys.push({ v: p.y, from: p, kind: "anchor" });
1018
+ }
1019
+ /** The segments of `sp` (mapped by `m`) that stay put while the anchors `moving` accepts move. */
1020
+ pushSegments(sp, m, moving, budget) {
1021
+ const n = sp.anchors.length;
1022
+ const count = subpathSegmentCount(sp);
1023
+ for (let i = 0; i < count && budget > 0; i++) {
1024
+ if (moving(i) || moving((i + 1) % n)) continue;
1025
+ const seg = subpathSegment(sp, i).map((q) => toPoint(applyMatrix(m, toVec(q))));
1026
+ let minX = Infinity;
1027
+ let minY = Infinity;
1028
+ let maxX = -Infinity;
1029
+ let maxY = -Infinity;
1030
+ for (const [x, y] of seg) {
1031
+ minX = Math.min(minX, x);
1032
+ minY = Math.min(minY, y);
1033
+ maxX = Math.max(maxX, x);
1034
+ maxY = Math.max(maxY, y);
1035
+ }
1036
+ this.segments.push({ seg, minX, minY, maxX, maxY });
1037
+ budget--;
1038
+ }
1039
+ return budget;
1040
+ }
1041
+ pushRect(r, kind, centre) {
1042
+ const corners2 = [
1043
+ { x: r.x, y: r.y },
1044
+ { x: r.x + r.width, y: r.y },
1045
+ { x: r.x + r.width, y: r.y + r.height },
1046
+ { x: r.x, y: r.y + r.height }
1047
+ ];
1048
+ for (const p of corners2) {
1049
+ this.xs.push({ v: p.x, from: p, kind });
1050
+ this.ys.push({ v: p.y, from: p, kind });
1051
+ }
1052
+ if (centre) {
1053
+ this.points.push({ p: centre, kind: "centre" });
1054
+ this.xs.push({ v: centre.x, from: centre, kind: "centre" });
1055
+ this.ys.push({ v: centre.y, from: centre, kind: "centre" });
1056
+ }
1057
+ }
1058
+ /** Sorts the targets for `within` (stable: of targets alike, the first gathered wins). */
1059
+ index() {
1060
+ this.points.sort((a, b) => a.p.x - b.p.x);
1061
+ this.xs.sort((a, b) => a.v - b.v);
1062
+ this.ys.sort((a, b) => a.v - b.v);
1063
+ return this;
1064
+ }
1065
+ /**
1066
+ * Gathers targets from the artboard and every visible node but `exclude` (and what is inside
1067
+ * those): each leaf's anchors (also as lines for `drawing`), its segments (`segments`,
1068
+ * `drawing`), its bounds' corners as lines and its centre; and the top-level objects' boxes and
1069
+ * sizes (inside a group holding an excluded node, that group's other members).
1070
+ */
1071
+ static collect(doc, exclude, gather, sort = true) {
1072
+ const self = new _VectorSnapTargets();
1073
+ const artboard = { x: 0, y: 0, width: doc.width, height: doc.height };
1074
+ self.pushRect(artboard, "artboard", { x: doc.width / 2, y: doc.height / 2 });
1075
+ let anchors = BUDGET;
1076
+ let segments = BUDGET;
1077
+ const visit = (list, parent) => {
1078
+ for (const n of list) {
1079
+ if (exclude.has(n.id) || n.visible === false) continue;
1080
+ const m = multiplyMatrices(nodeMatrix(n), parent);
1081
+ if (n.type === "group") {
1082
+ visit(n.children, m);
1083
+ continue;
1084
+ }
1085
+ if (anchors <= 0) continue;
1086
+ for (const sp of n.subpaths) {
1087
+ for (const a of sp.anchors) {
1088
+ if (anchors-- <= 0) break;
1089
+ const p = applyMatrix(m, a);
1090
+ if (gather === "drawing") self.pushAnchor(p);
1091
+ else self.points.push({ p, kind: "anchor" });
1092
+ }
1093
+ if (gather !== "bounds") segments = self.pushSegments(sp, m, () => false, segments);
1094
+ }
1095
+ const b = vectorNodeBounds(n, parent);
1096
+ const f = nodeFrame(n, parent, b);
1097
+ if (b) self.pushRect(b, "edge", f?.centre ?? null);
1098
+ }
1099
+ };
1100
+ visit(doc.nodes, IDENTITY_MATRIX);
1101
+ const objects = (list, parent) => {
1102
+ for (const n of list) {
1103
+ if (exclude.has(n.id) || n.visible === false || self.boxes.length >= BUDGET) continue;
1104
+ if (n.type === "group" && contains(n, exclude)) {
1105
+ objects(n.children, multiplyMatrices(nodeMatrix(n), parent));
1106
+ continue;
1107
+ }
1108
+ const b = vectorNodeBounds(n, parent);
1109
+ if (b) self.boxes.push(b);
1110
+ const f = nodeFrame(n, parent, b);
1111
+ if (f) self.sizes.push(f.width, f.height);
1112
+ }
1113
+ };
1114
+ objects(doc.nodes, IDENTITY_MATRIX);
1115
+ return sort ? self.index() : self;
1116
+ }
1117
+ // -------------------------------------------------------------------------
1118
+ // Points
1119
+ // -------------------------------------------------------------------------
1120
+ /** Snaps a single point (see `snapFrom`). */
1121
+ snapPoint(p, tol) {
1122
+ return this.snapFrom(p, null, tol);
1123
+ }
1124
+ /**
1125
+ * Snaps a point `p`, drawn or dragged from `leave` when given. With `leave.constrain` it keeps
1126
+ * to its angle round `leave.at`, sliding along that way into line with the nearest target.
1127
+ * Otherwise it lands, in order, on an anchor or centre, an intersection of two paths, the
1128
+ * perpendicular foot or a tangent point from `leave.at`, a path, a construction guide
1129
+ * through `leave.at` (where a target's line crosses it if one does nearby), or else lines up
1130
+ * with targets on x and y.
1131
+ */
1132
+ snapFrom(p, leave, tol) {
1133
+ const none = (q) => ({ point: q, kind: null, x: false, y: false, lines: [], labels: [] });
1134
+ const at = (q, kind, lines2 = []) => ({
1135
+ point: q,
1136
+ kind,
1137
+ x: true,
1138
+ y: true,
1139
+ lines: lines2,
1140
+ labels: [this.label(q, kind)]
1141
+ });
1142
+ if (leave?.constrain) {
1143
+ const q = constrainToLeave(leave, p);
1144
+ const hit = this.slide(leave.at, { x: q.x - leave.at.x, y: q.y - leave.at.y }, q, tol, true);
1145
+ return hit ? { point: hit.point, x: true, y: true, ...this.linedUp(hit.point, hit.from) } : none(q);
1146
+ }
1147
+ const point = this.nearestPoint(p, tol);
1148
+ if (point) return at(point.p, point.kind);
1149
+ const near2 = this.segmentsNear(p, tol);
1150
+ const crossing = this.crossing(near2, p, tol);
1151
+ if (crossing) return at(crossing, "intersect");
1152
+ if (leave && leave.curves !== false) {
1153
+ const foot = this.curvePoint(near2, leave.at, p, tol);
1154
+ if (foot) return at(foot.point, foot.kind, [line("construction", leave.at, foot.point)]);
1155
+ }
1156
+ const on = this.onSegment(near2);
1157
+ if (on) return at(on, "path");
1158
+ if (leave?.angles?.length) {
1159
+ const c = construction(leave, p, tol);
1160
+ if (c) {
1161
+ const hit = this.slide(leave.at, c.dir, c.point, tol, false);
1162
+ if (hit) {
1163
+ const lined = this.linedUp(hit.point, hit.from);
1164
+ return { point: hit.point, x: true, y: true, kind: lined.kind, lines: [line("construction", leave.at, hit.point), ...lined.lines], labels: lined.labels };
1165
+ }
1166
+ return { point: c.point, kind: "construction", x: true, y: true, lines: [line("construction", leave.at, c.point)], labels: [] };
1167
+ }
1168
+ }
1169
+ const nearest = (lines2, v) => {
1170
+ let best = null;
1171
+ for (const l of linesNear(lines2, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
1172
+ return best;
1173
+ };
1174
+ const bx = nearest(this.xs, p.x);
1175
+ const by = nearest(this.ys, p.y);
1176
+ if (!bx && !by) return none(p);
1177
+ const out = { x: bx ? bx.v : p.x, y: by ? by.v : p.y };
1178
+ const lines = [];
1179
+ if (bx) lines.push(line("align", bx.from, { x: out.x, y: out.y }));
1180
+ if (by) lines.push(line("align", by.from, { x: out.x, y: out.y }));
1181
+ return { point: out, kind: "align", x: !!bx, y: !!by, lines, labels: [this.label(out, "align")] };
1182
+ }
1183
+ label(p, kind, text = LABELS[kind]) {
1184
+ return { kind, x: p.x, y: p.y, text };
1185
+ }
1186
+ /** The anchor or centre nearest `p`, within `tol`. */
1187
+ nearestPoint(p, tol) {
1188
+ let best = null;
1189
+ let bestD = Infinity;
1190
+ for (const t of within(this.points, (e) => e.p.x, p.x, tol)) {
1191
+ const d = distance(t.p, p);
1192
+ if (d <= tol && d < bestD) {
1193
+ best = t;
1194
+ bestD = d;
1195
+ }
1196
+ }
1197
+ return best;
1198
+ }
1199
+ /**
1200
+ * Slides `q`, on the line through `at` along `dir` (beyond `at` only for a `ray`), to where
1201
+ * the nearest target line crosses it, at most twice `tol` away: where it lands and the
1202
+ * target point that line comes from.
1203
+ */
1204
+ slide(at, dir, q, tol, ray) {
1205
+ const len2 = dir.x * dir.x + dir.y * dir.y;
1206
+ if (!Number.isFinite(len2) || len2 <= 1e-18) return null;
1207
+ const reach = 2 * tol;
1208
+ let best = null;
1209
+ for (const vertical of [true, false]) {
1210
+ const d = vertical ? dir.x : dir.y;
1211
+ if (Math.abs(d) <= 1e-9) continue;
1212
+ const a = vertical ? at.x : at.y;
1213
+ for (const l of linesNear(vertical ? this.xs : this.ys, vertical ? q.x : q.y, reach)) {
1214
+ const s = (l.v - a) / d;
1215
+ const hit = vertical ? { x: l.v, y: at.y + dir.y * s } : { x: at.x + dir.x * s, y: l.v };
1216
+ const moved = distance(hit, q);
1217
+ if (moved <= reach && (!ray || s > 0) && (!best || moved < best.moved)) best = { moved, point: hit, from: l.from };
1218
+ }
1219
+ }
1220
+ return best && { point: best.point, from: best.from };
1221
+ }
1222
+ /** The guides of a point `q` slid into line with the target at `from`: the line and "align", or the target's own label when `q` sits on it. */
1223
+ linedUp(q, from) {
1224
+ const on = this.nearestPoint(q, 1e-9);
1225
+ if (on) return { kind: on.kind, lines: [], labels: [this.label(on.p, on.kind)] };
1226
+ return { kind: "align", lines: [line("align", from, q)], labels: [this.label(q, "align")] };
1227
+ }
1228
+ /** Target segments whose control box reaches `p` and which pass within `tol` of it, nearest first. */
1229
+ segmentsNear(p, tol) {
1230
+ const out = [];
1231
+ const pt = toPoint(p);
1232
+ for (const s of this.segments) {
1233
+ if (p.x < s.minX - tol || p.x > s.maxX + tol || p.y < s.minY - tol || p.y > s.maxY + tol) continue;
1234
+ const hit = segmentNearest(s.seg, pt);
1235
+ if (hit.distance <= tol) out.push({ seg: s.seg, t: hit.t, distance: hit.distance });
1236
+ }
1237
+ return out.sort((a, b) => a.distance - b.distance);
1238
+ }
1239
+ onSegment(near2) {
1240
+ return near2.length ? toVec(segmentPoint(near2[0].seg, near2[0].t)) : null;
1241
+ }
1242
+ /** Where two of the target segments near `p` cross, the crossing nearest `p` within `tol`. */
1243
+ crossing(near2, p, tol) {
1244
+ const list = near2.slice(0, CROSSING_PAIRS);
1245
+ let best = null;
1246
+ let bestD = Infinity;
1247
+ for (let i = 0; i < list.length; i++) {
1248
+ for (let j = i + 1; j < list.length; j++) {
1249
+ for (const hit of segmentIntersections(list[i].seg, list[j].seg)) {
1250
+ const q = toVec(hit.point);
1251
+ const d = distance(q, p);
1252
+ if (d <= tol && d < bestD) {
1253
+ best = q;
1254
+ bestD = d;
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ return best;
1260
+ }
1261
+ /**
1262
+ * The foot of a perpendicular, or a tangent point, from `base` on a target segment near `p`
1263
+ * (within `tol` of it): roots of `(B(t) − base) · B′(t)` and `(B(t) − base) × B′(t)`
1264
+ * bracketed by sign changes of samples and narrowed by bisection.
1265
+ */
1266
+ curvePoint(near2, base, p, tol) {
1267
+ let best = null;
1268
+ for (const { seg } of near2) {
1269
+ for (const kind of ["perpendicular", "tangent"]) {
1270
+ if (kind === "tangent" && seg.length === 2) continue;
1271
+ for (const q of solveOnSegment(seg, base, kind === "perpendicular" ? dotCondition : crossCondition)) {
1272
+ const d = distance(q, p);
1273
+ if (d <= tol && (!best || d < best.d)) best = { point: q, kind, d };
1274
+ }
1275
+ }
1276
+ }
1277
+ return best && { point: best.point, kind: best.kind };
1278
+ }
1279
+ // -------------------------------------------------------------------------
1280
+ // Boxes
1281
+ // -------------------------------------------------------------------------
1282
+ /**
1283
+ * Snaps a moving box `r` (the moved selection's bounds): its edge or centre nearest a target
1284
+ * line on each axis (`axes` limits which may move). With `spacing`, an axis that lines up
1285
+ * with nothing spaces the box evenly in its row or column. Returns the correction and guides.
1286
+ */
1287
+ snapRect(r, tol, options = {}) {
1288
+ const axes = { x: options.axes?.x !== false, y: options.axes?.y !== false };
1289
+ const nearest = (lines, values) => {
1290
+ let best = null;
1291
+ for (const v of values) for (const l of linesNear(lines, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.d)) best = { d: l.v - v, from: l.from };
1292
+ return best;
1293
+ };
1294
+ const bx = axes.x ? nearest(this.xs, [r.x, r.x + r.width / 2, r.x + r.width]) : null;
1295
+ const by = axes.y ? nearest(this.ys, [r.y, r.y + r.height / 2, r.y + r.height]) : null;
1296
+ const out = { dx: bx?.d ?? 0, dy: by?.d ?? 0, x: !!bx, y: !!by, lines: [], labels: [] };
1297
+ const done = { ...r, x: r.x + out.dx, y: r.y + out.dy };
1298
+ if (bx) {
1299
+ const x = bx.from.x;
1300
+ out.lines.push({ kind: "align", x1: x, y1: Math.min(bx.from.y, done.y), x2: x, y2: Math.max(bx.from.y, done.y + done.height) });
1301
+ }
1302
+ if (by) {
1303
+ const y = by.from.y;
1304
+ out.lines.push({ kind: "align", x1: Math.min(by.from.x, done.x), y1: y, x2: Math.max(by.from.x, done.x + done.width), y2: y });
1305
+ }
1306
+ if (options.spacing && this.boxes.length) {
1307
+ for (const horizontal of [true, false]) {
1308
+ if (horizontal ? out.x || !axes.x : out.y || !axes.y) continue;
1309
+ const moved = { ...r, x: r.x + out.dx, y: r.y + out.dy };
1310
+ const even = this.evenSpacing(moved, horizontal, tol);
1311
+ if (!even) continue;
1312
+ if (horizontal) {
1313
+ out.dx += even.shift;
1314
+ out.x = true;
1315
+ } else {
1316
+ out.dy += even.shift;
1317
+ out.y = true;
1318
+ }
1319
+ for (const gap of even.gaps) this.gapMarks(gap, horizontal, tol, out);
1320
+ }
1321
+ }
1322
+ if (bx || by) out.labels.unshift(this.label({ x: r.x + out.dx + r.width / 2, y: r.y + out.dy + r.height / 2 }, "align"));
1323
+ return out;
1324
+ }
1325
+ /**
1326
+ * Equal spacing along x (`horizontal`) or y for the moved box `r`, among the objects of its
1327
+ * row (column): the shift within `tol` that centres it between its neighbours, or leaves it
1328
+ * as far from the neighbour before or after it as two other objects of the row are apart;
1329
+ * and the gaps that are equal then, as (start, end, where across).
1330
+ */
1331
+ evenSpacing(r, horizontal, tol) {
1332
+ const span = (b, x) => x ? [b.x, b.x + b.width] : [b.y, b.y + b.height];
1333
+ const [r0, r1] = span(r, horizontal);
1334
+ const len = r1 - r0;
1335
+ const [c0, c1] = span(r, !horizontal);
1336
+ const row = this.boxes.map((b) => ({ b, a0: span(b, horizontal)[0], a1: span(b, horizontal)[1] })).filter((e) => span(e.b, !horizontal)[0] < c1 && span(e.b, !horizontal)[1] > c0 && (e.a1 <= r0 + tol || e.a0 >= r1 - tol)).sort((a, b) => a.a0 - b.a0);
1337
+ if (!row.length) return null;
1338
+ const mid = (a, b) => (Math.max(span(a, !horizontal)[0], span(b, !horizontal)[0]) + Math.min(span(a, !horizontal)[1], span(b, !horizontal)[1])) / 2;
1339
+ const pairs = [];
1340
+ for (const p of row) {
1341
+ const next = bound(row, (e) => e.a0, p.a1, false);
1342
+ const q = row[next];
1343
+ if (!q) continue;
1344
+ const straddles = p.a1 <= r0 + tol && q.a0 >= r1 - tol;
1345
+ if (q.a0 > p.a1 && !straddles) pairs.push({ g: q.a0 - p.a1, gap: [p.a1, q.a0, mid(p.b, q.b)] });
1346
+ }
1347
+ let before = null;
1348
+ let after = null;
1349
+ for (const e of row) {
1350
+ if (e.a1 <= r0 + tol && (!before || e.a1 > before.a1)) before = e;
1351
+ if (e.a0 >= r1 - tol && (!after || e.a0 < after.a0)) after = e;
1352
+ }
1353
+ const starts = [];
1354
+ if (before && after && after.a0 - before.a1 > len) starts.push({ start: (before.a1 + after.a0 - len) / 2, repeats: null });
1355
+ for (const { g } of pairs) {
1356
+ if (before) starts.push({ start: before.a1 + g, repeats: g });
1357
+ if (after) starts.push({ start: after.a0 - g - len, repeats: g });
1358
+ }
1359
+ let pick = null;
1360
+ for (const s of starts) if (Math.abs(s.start - r0) <= tol && (!pick || Math.abs(s.start - r0) < Math.abs(pick.start - r0))) pick = s;
1361
+ if (!pick) return null;
1362
+ const { start, repeats } = pick;
1363
+ const at = horizontal ? { ...r, x: start } : { ...r, y: start };
1364
+ const end = start + len;
1365
+ const gaps = [];
1366
+ if (before && (repeats === null || Math.abs(start - before.a1 - repeats) <= EQUAL)) gaps.push([before.a1, start, mid(before.b, at)]);
1367
+ if (after && (repeats === null || Math.abs(after.a0 - end - repeats) <= EQUAL)) gaps.push([end, after.a0, mid(after.b, at)]);
1368
+ if (repeats !== null) {
1369
+ for (const p of pairs) if (Math.abs(p.g - repeats) <= EQUAL) gaps.push(p.gap);
1370
+ }
1371
+ return { shift: start - r0, gaps };
1372
+ }
1373
+ /** A gap mark: a line across the gap with a tick at each end, and its length as a label. */
1374
+ gapMarks([u0, u1, c], horizontal, tick, out) {
1375
+ const pt = (u, v) => horizontal ? { x: u, y: v } : { x: v, y: u };
1376
+ out.lines.push(line("spacing", pt(u0, c), pt(u1, c)), line("spacing", pt(u0, c - tick), pt(u0, c + tick)), line("spacing", pt(u1, c - tick), pt(u1, c + tick)));
1377
+ out.labels.push(this.label(pt((u0 + u1) / 2, c), "spacing", fmtLength(u1 - u0)));
1378
+ }
1379
+ /** The other objects' side length nearest `length`, within `tol`, or null. */
1380
+ matchSize(length2, tol) {
1381
+ let best = null;
1382
+ for (const s of this.sizes) if (Math.abs(s - length2) <= tol && (best === null || Math.abs(s - length2) < Math.abs(best - length2))) best = s;
1383
+ return best;
1384
+ }
1385
+ /**
1386
+ * Snaps a selection-box resize. `sx / sy` are the scale `scaleFor` gave for dragging
1387
+ * `handle` of `frame` (in the box's own, possibly rotated frame), `mods.shift` keeps the
1388
+ * proportions and `mods.alt` scales about the centre. The handle lands on an anchor or
1389
+ * centre within `tol`, else the corner or the side it moves lines up with the nearest
1390
+ * target, whatever the box's angle; a handle with one way to go (a side, or a proportional
1391
+ * corner) slides along it until one of them is on a target. An axis that snaps to nothing
1392
+ * may match another object's width or height instead.
1393
+ */
1394
+ snapScale(frame, handle, sx, sy, mods, tol) {
1395
+ const toDoc = frame.angle ? rotateMatrix(frame.angle) : IDENTITY_MATRIX;
1396
+ const toLocal = frame.angle ? rotateMatrix(-frame.angle) : IDENTITY_MATRIX;
1397
+ const corner = handle.length === 2;
1398
+ const proportional = !!mods.shift;
1399
+ const centre = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
1400
+ const origin = mods.alt ? centre : handlePos(opposite(handle), frame);
1401
+ const hp = handlePos(handle, frame);
1402
+ const d0 = { x: hp.x - origin.x, y: hp.y - origin.y };
1403
+ const valid = (n, s) => Number.isFinite(n) && n * s > 0;
1404
+ const nearest = (v, lines) => {
1405
+ let best = null;
1406
+ for (const l of linesNear(lines, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
1407
+ return best;
1408
+ };
1409
+ let nx = sx;
1410
+ let ny = sy;
1411
+ let snap = { hx: null, hy: null, on: null };
1412
+ if (corner && !proportional) {
1413
+ const p = applyMatrix(toDoc, { x: origin.x + d0.x * sx, y: origin.y + d0.y * sy });
1414
+ const hx = nearest(p.x, this.xs);
1415
+ const hy = nearest(p.y, this.ys);
1416
+ const on = this.nearestPoint(p, tol);
1417
+ const tries = [];
1418
+ if (on) tries.push({ tx: on.p.x, ty: on.p.y, s: { hx: null, hy: null, on } });
1419
+ tries.push({ tx: hx?.v ?? null, ty: hy?.v ?? null, s: { hx, hy, on: null } });
1420
+ tries.push({ tx: hx?.v ?? null, ty: null, s: { hx, hy: null, on: null } });
1421
+ tries.push({ tx: null, ty: hy?.v ?? null, s: { hx: null, hy, on: null } });
1422
+ for (const { tx, ty, s } of tries) {
1423
+ if (tx === null && ty === null) continue;
1424
+ const q = applyMatrix(toLocal, { x: tx ?? p.x, y: ty ?? p.y });
1425
+ let cx = (q.x - origin.x) / d0.x;
1426
+ let cy = (q.y - origin.y) / d0.y;
1427
+ if (!frame.angle) {
1428
+ if (tx === null) cx = sx;
1429
+ if (ty === null) cy = sy;
1430
+ }
1431
+ if (valid(cx, sx) && valid(cy, sy)) {
1432
+ nx = cx;
1433
+ ny = cy;
1434
+ snap = s;
1435
+ break;
1436
+ }
1437
+ }
1438
+ } else {
1439
+ const movesX2 = handle === "e" || handle === "w";
1440
+ let e;
1441
+ let c;
1442
+ let s0;
1443
+ if (corner) [e, c, s0] = [{ x: Math.sign(sx), y: Math.sign(sy) }, { x: 0, y: 0 }, Math.abs(sx)];
1444
+ else if (movesX2) [e, c, s0] = proportional ? [{ x: 1, y: Math.sign(sx) }, { x: 0, y: 0 }, sx] : [{ x: 1, y: 0 }, { x: 0, y: 1 }, sx];
1445
+ else [e, c, s0] = proportional ? [{ x: Math.sign(sy), y: 1 }, { x: 0, y: 0 }, sy] : [{ x: 0, y: 1 }, { x: 1, y: 0 }, sy];
1446
+ const i = RING.indexOf(handle);
1447
+ const ends = corner ? [] : [RING[(i + 1) % 8], RING[(i + 7) % 8]];
1448
+ const travel = Math.hypot(e.x * d0.x, e.y * d0.y);
1449
+ let best = null;
1450
+ const consider = (s, inLine, found2) => {
1451
+ const moved = Math.abs(s - s0) * travel;
1452
+ if (!valid(s, s0) || moved > 2 * tol) return;
1453
+ if (!best || (inLine === best.inLine ? moved < best.moved : !inLine)) best = { s, inLine, moved, snap: found2 };
1454
+ };
1455
+ for (const h of [handle, ...ends]) {
1456
+ const hv = handlePos(h, frame);
1457
+ const v = { x: hv.x - origin.x, y: hv.y - origin.y };
1458
+ const base = applyMatrix(toDoc, { x: origin.x + c.x * v.x, y: origin.y + c.y * v.y });
1459
+ const dir = applyMatrix(toDoc, { x: e.x * v.x, y: e.y * v.y });
1460
+ const p = { x: base.x + dir.x * s0, y: base.y + dir.y * s0 };
1461
+ const l2 = dir.x * dir.x + dir.y * dir.y;
1462
+ const on = l2 > 1e-18 ? this.nearestPoint(p, tol) : null;
1463
+ if (on) consider(((on.p.x - base.x) * dir.x + (on.p.y - base.y) * dir.y) / l2, false, { hx: null, hy: null, on });
1464
+ if (Math.abs(dir.x) > 1e-9) {
1465
+ const hit = nearest(p.x, this.xs);
1466
+ if (hit) consider((hit.v - base.x) / dir.x, true, { hx: hit, hy: null, on: null });
1467
+ }
1468
+ if (Math.abs(dir.y) > 1e-9) {
1469
+ const hit = nearest(p.y, this.ys);
1470
+ if (hit) consider((hit.v - base.y) / dir.y, true, { hx: null, hy: hit, on: null });
1471
+ }
1472
+ }
1473
+ const found = best;
1474
+ if (found) {
1475
+ nx = c.x + found.s * e.x;
1476
+ ny = c.y + found.s * e.y;
1477
+ snap = found.snap;
1478
+ }
1479
+ }
1480
+ const out = { sx: nx, sy: ny, snapped: false, lines: [], labels: [] };
1481
+ const handleAt = () => applyMatrix(toDoc, { x: origin.x + d0.x * out.sx, y: origin.y + d0.y * out.sy });
1482
+ if (snap.on) {
1483
+ out.snapped = true;
1484
+ out.labels.push(this.label(snap.on.p, snap.on.kind));
1485
+ return out;
1486
+ }
1487
+ const movesX = handle !== "n" && handle !== "s";
1488
+ const movesY = handle !== "e" && handle !== "w";
1489
+ const sized = [];
1490
+ if (!proportional) {
1491
+ if (movesX && !snap.hx && !(frame.angle && snap.hy) && frame.width > 1e-9) {
1492
+ const len = this.matchSize(Math.abs(out.sx) * frame.width, tol);
1493
+ if (len !== null && len > 0) {
1494
+ out.sx = Math.sign(out.sx || 1) * (len / frame.width);
1495
+ sized.push("x");
1496
+ }
1497
+ }
1498
+ if (movesY && !snap.hy && !(frame.angle && snap.hx) && frame.height > 1e-9) {
1499
+ const len = this.matchSize(Math.abs(out.sy) * frame.height, tol);
1500
+ if (len !== null && len > 0) {
1501
+ out.sy = Math.sign(out.sy || 1) * (len / frame.height);
1502
+ sized.push("y");
1503
+ }
1504
+ }
1505
+ }
1506
+ const x0 = origin.x + (frame.x - origin.x) * out.sx;
1507
+ const x1 = origin.x + (frame.x + frame.width - origin.x) * out.sx;
1508
+ const y0 = origin.y + (frame.y - origin.y) * out.sy;
1509
+ const y1 = origin.y + (frame.y + frame.height - origin.y) * out.sy;
1510
+ const box = { x0: Math.min(x0, x1), x1: Math.max(x0, x1), y0: Math.min(y0, y1), y1: Math.max(y0, y1) };
1511
+ const page = [
1512
+ { x: box.x0, y: box.y0 },
1513
+ { x: box.x1, y: box.y0 },
1514
+ { x: box.x1, y: box.y1 },
1515
+ { x: box.x0, y: box.y1 }
1516
+ ].map((q) => applyMatrix(toDoc, q));
1517
+ const pminY = Math.min(...page.map((q) => q.y));
1518
+ const pmaxY = Math.max(...page.map((q) => q.y));
1519
+ const pminX = Math.min(...page.map((q) => q.x));
1520
+ const pmaxX = Math.max(...page.map((q) => q.x));
1521
+ if (snap.hx) out.lines.push({ kind: "align", x1: snap.hx.v, y1: Math.min(snap.hx.from.y, pminY), x2: snap.hx.v, y2: Math.max(snap.hx.from.y, pmaxY) });
1522
+ if (snap.hy) out.lines.push({ kind: "align", x1: Math.min(snap.hy.from.x, pminX), y1: snap.hy.v, x2: Math.max(snap.hy.from.x, pmaxX), y2: snap.hy.v });
1523
+ if (snap.hx || snap.hy) {
1524
+ out.snapped = true;
1525
+ out.labels.push(this.label(handleAt(), "align"));
1526
+ }
1527
+ for (const axis of sized) {
1528
+ out.snapped = true;
1529
+ const off = tol * 2;
1530
+ const [a, b] = axis === "x" ? [{ x: box.x0, y: box.y1 + off }, { x: box.x1, y: box.y1 + off }] : [{ x: box.x1 + off, y: box.y0 }, { x: box.x1 + off, y: box.y1 }];
1531
+ const pa = applyMatrix(toDoc, a);
1532
+ const pb = applyMatrix(toDoc, b);
1533
+ out.lines.push(line("size", pa, pb));
1534
+ out.labels.push(this.label({ x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 }, "size", `${axis === "x" ? "W" : "H"} ${fmtLength(axis === "x" ? box.x1 - box.x0 : box.y1 - box.y0)}`));
1535
+ }
1536
+ return out;
1537
+ }
1538
+ };
1539
+ var RING = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
1540
+ function opposite(h) {
1541
+ return RING[(RING.indexOf(h) + 4) % 8];
1542
+ }
1543
+ function handlePos(h, r) {
1544
+ const x = h.includes("w") ? r.x : h.includes("e") ? r.x + r.width : r.x + r.width / 2;
1545
+ const y = h.startsWith("n") ? r.y : h.startsWith("s") ? r.y + r.height : r.y + r.height / 2;
1546
+ return { x, y };
1547
+ }
1548
+ function line(kind, a, b) {
1549
+ return { kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
1550
+ }
1551
+ function construction(leave, p, tol) {
1552
+ const v = { x: p.x - leave.at.x, y: p.y - leave.at.y };
1553
+ if (Math.hypot(v.x, v.y) <= tol) return null;
1554
+ let best = null;
1555
+ for (const a of leave.angles ?? []) {
1556
+ const r = a * Math.PI / 180;
1557
+ const exact = (n) => Math.abs(n) < 1e-12 ? 0 : n;
1558
+ const dir = { x: exact(Math.cos(r)), y: exact(-Math.sin(r)) };
1559
+ const k = v.x * dir.x + v.y * dir.y;
1560
+ const point = { x: leave.at.x + dir.x * k, y: leave.at.y + dir.y * k };
1561
+ const d = distance(point, p);
1562
+ if (d <= tol && (!best || d < best.d)) best = { dir, point, d };
1563
+ }
1564
+ return best && { dir: best.dir, point: best.point };
1565
+ }
1566
+ var dotCondition = (r, d) => r[0] * d[0] + r[1] * d[1];
1567
+ var crossCondition = (r, d) => r[0] * d[1] - r[1] * d[0];
1568
+ function solveOnSegment(seg, base, cond) {
1569
+ const b = [base.x, base.y];
1570
+ const g = (t) => {
1571
+ const p = segmentPoint(seg, t);
1572
+ return cond([p[0] - b[0], p[1] - b[1]], segmentDerivative(seg, t));
1573
+ };
1574
+ const vals = [];
1575
+ let scale = 0;
1576
+ for (let i = 0; i <= CURVE_SAMPLES; i++) {
1577
+ const t = i / CURVE_SAMPLES;
1578
+ const p = segmentPoint(seg, t);
1579
+ const d = segmentDerivative(seg, t);
1580
+ const r = [p[0] - b[0], p[1] - b[1]];
1581
+ vals.push(cond(r, d));
1582
+ scale = Math.max(scale, Math.hypot(r[0], r[1]) * Math.hypot(d[0], d[1]));
1583
+ }
1584
+ if (!(scale > 0) || vals.every((v) => Math.abs(v) <= scale * 1e-12)) return [];
1585
+ const out = [];
1586
+ for (let i = 0; i < CURVE_SAMPLES; i++) {
1587
+ const ga = vals[i];
1588
+ const gb = vals[i + 1];
1589
+ if (!(Number.isFinite(ga) && Number.isFinite(gb)) || ga > 0 === gb > 0 && ga !== 0) continue;
1590
+ let lo = i / CURVE_SAMPLES;
1591
+ let hi = (i + 1) / CURVE_SAMPLES;
1592
+ let flo = ga;
1593
+ for (let k = 0; k < BISECT; k++) {
1594
+ const m = (lo + hi) / 2;
1595
+ const fm = g(m);
1596
+ if (fm === 0) {
1597
+ lo = hi = m;
1598
+ break;
1599
+ }
1600
+ if (fm > 0 === flo > 0) {
1601
+ lo = m;
1602
+ flo = fm;
1603
+ } else hi = m;
1604
+ }
1605
+ const t = (lo + hi) / 2;
1606
+ const p = segmentPoint(seg, t);
1607
+ const dv = segmentDerivative(seg, t);
1608
+ const r = Math.hypot(p[0] - b[0], p[1] - b[1]);
1609
+ const dl = Math.hypot(dv[0], dv[1]);
1610
+ if (!(r > 1e-9 && dl > 0) || Math.abs(cond([p[0] - b[0], p[1] - b[1]], dv)) > 1e-6 * r * dl) continue;
1611
+ if (!out.some((q) => Math.hypot(q.x - p[0], q.y - p[1]) <= 1e-9 * (1 + r))) out.push({ x: p[0], y: p[1] });
1612
+ }
1613
+ return out;
1614
+ }
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
+
853
2574
  // src/vector/controller.ts
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;
854
2578
  var VECTOR_CLIPBOARD_TYPE = "pixerate/vector";
855
2579
  var DEFAULT_DEFAULTS = {
856
2580
  fill: "#d4d4d8",
@@ -969,14 +2693,14 @@ function getVectorSelectionBoxGeometry(frame, viewport) {
969
2693
  const rot = rotateMatrix(frame.angle, { x: viewport.x, y: viewport.y });
970
2694
  const toScreen2 = (p) => frame.angle ? applyMatrix(rot, p) : { x: p.x, y: p.y };
971
2695
  const handles = getTransformHandlePositions(box).map((h) => ({ ...h, screen: toScreen2(h) }));
972
- const corners = [
2696
+ const corners2 = [
973
2697
  { x: box.x, y: box.y },
974
2698
  { x: box.x + box.width, y: box.y },
975
2699
  { x: box.x + box.width, y: box.y + box.height },
976
2700
  { x: box.x, y: box.y + box.height }
977
2701
  ].map(toScreen2);
978
2702
  const r = (n) => Math.round(n * 1e3) / 1e3;
979
- 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 };
980
2704
  }
981
2705
  var RESIZE_CURSOR_ORDER = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
982
2706
  var HANDLE_ANGLES = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
@@ -994,9 +2718,12 @@ var TOOL_LABELS = {
994
2718
  ellipse: "Ellipse",
995
2719
  polygon: "Polygon",
996
2720
  star: "Star",
997
- line: "Line"
2721
+ line: "Line",
2722
+ scissors: "Scissors",
2723
+ knife: "Knife",
2724
+ eraser: "Eraser"
998
2725
  };
999
- 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" };
1000
2727
  function vectorToolLabel(tool) {
1001
2728
  return TOOL_LABELS[tool];
1002
2729
  }
@@ -1009,7 +2736,10 @@ var VECTOR_TOOL_SHORTCUTS = {
1009
2736
  ellipse: "O",
1010
2737
  polygon: "Y",
1011
2738
  star: "S",
1012
- line: "L"
2739
+ line: "L",
2740
+ scissors: "C",
2741
+ knife: "K",
2742
+ eraser: "E"
1013
2743
  };
1014
2744
  var VECTOR_TOOL_GLYPHS = {
1015
2745
  select: "\u2196",
@@ -1020,8 +2750,12 @@ var VECTOR_TOOL_GLYPHS = {
1020
2750
  ellipse: "\u25EF",
1021
2751
  polygon: "\u2B21",
1022
2752
  star: "\u2606",
1023
- line: "\u2571"
2753
+ line: "\u2571",
2754
+ scissors: "\u2702",
2755
+ knife: "\u27CB",
2756
+ eraser: "\u232B"
1024
2757
  };
2758
+ var SHAPE_TOOLS = /* @__PURE__ */ new Set(["rect", "ellipse", "polygon", "star", "line"]);
1025
2759
  var BOOLEAN_OPS = {
1026
2760
  union: "union",
1027
2761
  subtract: "difference",
@@ -1043,6 +2777,28 @@ function mapTree(nodes, fn) {
1043
2777
  }
1044
2778
  return changed ? out : nodes;
1045
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];
1046
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;
1047
2803
  var idCounter = 0;
1048
2804
  var defaultCreateId = () => `vn-${Date.now().toString(36)}${(++idCounter).toString(36)}`;
@@ -1057,11 +2813,17 @@ var VectorEditorController = class {
1057
2813
  defaults;
1058
2814
  viewport;
1059
2815
  snap;
2816
+ editing;
1060
2817
  hitRadius;
1061
2818
  hover = null;
1062
2819
  pointer = null;
1063
2820
  drag = null;
1064
2821
  interaction = null;
2822
+ guides = null;
2823
+ /** Whether the last `snapDrawn` landed on an object (guides may be hidden). */
2824
+ snappedToObject = false;
2825
+ /** Drawing targets for hovering the pen and shape tools, kept while the document and the excluded path stay the same. */
2826
+ hoverTargets = null;
1065
2827
  spaceHeld = false;
1066
2828
  clipboard = null;
1067
2829
  createId;
@@ -1079,7 +2841,8 @@ var VectorEditorController = class {
1079
2841
  this.tool = options.tool ?? "select";
1080
2842
  this.defaults = { ...DEFAULT_DEFAULTS, ...options.defaults ?? {} };
1081
2843
  this.viewport = { x: 0, y: 0, zoom: 1, ...options.viewport ?? {} };
1082
- this.snap = { grid: 0, ...options.snap ?? {} };
2844
+ this.snap = { ...DEFAULT_VECTOR_SNAP, ...options.snap ?? {} };
2845
+ this.editing = { ...DEFAULT_VECTOR_PATH_EDIT, ...options.editing ?? {} };
1083
2846
  this.hitRadius = options.hitRadius ?? 6;
1084
2847
  this.createId = options.createId ?? defaultCreateId;
1085
2848
  this.onChange = options.onChange;
@@ -1128,6 +2891,8 @@ var VectorEditorController = class {
1128
2891
  viewport: this.viewport,
1129
2892
  defaults: this.defaults,
1130
2893
  snap: this.snap,
2894
+ editing: this.editing,
2895
+ guides: this.guides,
1131
2896
  hitRadius: this.hitRadius,
1132
2897
  hover: this.hover,
1133
2898
  cursor: this.cursorFor(),
@@ -1312,6 +3077,7 @@ var VectorEditorController = class {
1312
3077
  this.cancelDrag();
1313
3078
  this.tool = tool;
1314
3079
  this.hover = null;
3080
+ this.guides = null;
1315
3081
  if (tool !== "direct") {
1316
3082
  this.anchors = [];
1317
3083
  this.editingId = null;
@@ -1324,18 +3090,82 @@ var VectorEditorController = class {
1324
3090
  }
1325
3091
  setSnap(snap) {
1326
3092
  this.snap = { ...this.snap, ...snap };
3093
+ if (!this.snap.guides || !this.snap.objects) this.guides = null;
3094
+ this.notify();
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;
1327
3102
  this.notify();
1328
3103
  }
1329
- snapPoint(p) {
1330
- const g = this.snap.grid;
1331
- if (!g || g <= 0) return p;
1332
- return { x: Math.round(p.x / g) * g, y: Math.round(p.y / g) * g };
3104
+ /** Turns object snapping on or off (the toolbar's snapping toggle). */
3105
+ toggleSnapping(on = !this.snap.objects) {
3106
+ this.setSnap({ objects: on });
1333
3107
  }
1334
- snapDelta(dx, dy, origin) {
3108
+ gridValue(v) {
1335
3109
  const g = this.snap.grid;
1336
- if (!g || g <= 0 || !origin) return { dx, dy };
1337
- const target = this.snapPoint({ x: origin.x + dx, y: origin.y + dy });
1338
- return { dx: target.x - origin.x, dy: target.y - origin.y };
3110
+ return g > 0 ? Math.round(v / g) * g : v;
3111
+ }
3112
+ snapPoint(p) {
3113
+ return { x: this.gridValue(p.x), y: this.gridValue(p.y) };
3114
+ }
3115
+ /** The pull distance in document units. */
3116
+ snapTolerance() {
3117
+ return this.snap.threshold / this.viewport.zoom;
3118
+ }
3119
+ setGuides(guides) {
3120
+ this.guides = guides && this.snap.guides && (guides.lines.length || guides.labels.length) ? guides : null;
3121
+ }
3122
+ /**
3123
+ * Where a drawn or dragged point `p` goes: onto the smart guide `targets` (gathered by
3124
+ * `gather` when missing), else onto the grid, kept to `leave`'s constraint. Cmd / Ctrl
3125
+ * bypasses snapping.
3126
+ */
3127
+ snapDrawn(p, leave, input, targets) {
3128
+ this.snappedToObject = false;
3129
+ if (input.mod) {
3130
+ this.setGuides(null);
3131
+ return constrainToLeave(leave, p);
3132
+ }
3133
+ if (this.snap.objects) {
3134
+ const withAngles = leave ? { ...leave, angles: this.snap.angles } : null;
3135
+ const res = targets().snapFrom(p, withAngles, this.snapTolerance());
3136
+ if (res.kind) {
3137
+ this.snappedToObject = true;
3138
+ this.setGuides(res);
3139
+ return leave?.constrain ? res.point : { x: res.x ? res.point.x : this.gridValue(res.point.x), y: res.y ? res.point.y : this.gridValue(res.point.y) };
3140
+ }
3141
+ }
3142
+ this.setGuides(null);
3143
+ return constrainToLeave(leave, this.snapPoint(p));
3144
+ }
3145
+ /** Drawing targets for the pen and shape tools, reused while the document stays the same. */
3146
+ drawingTargets(exclude) {
3147
+ const key = exclude ?? "";
3148
+ const cached = this.hoverTargets;
3149
+ if (cached && cached.doc === this.doc && cached.exclude === key) return cached.targets;
3150
+ const targets = VectorSnapTargets.forDrawing(this.doc, exclude ? [exclude] : []);
3151
+ this.hoverTargets = { doc: this.doc, exclude: key, targets };
3152
+ return targets;
3153
+ }
3154
+ /** Where the pointer at `p` would place a point with the pen or a shape tool (and the guides showing why). */
3155
+ hoverSnap(p, input) {
3156
+ if (this.tool === "pen") {
3157
+ const sp = this.pen ? this.getNode(this.pen.nodeId)?.subpaths[this.pen.subpath] : void 0;
3158
+ if (this.pen && sp && this.hover?.anchor) {
3159
+ this.setGuides(null);
3160
+ return p;
3161
+ }
3162
+ const last = this.pen && sp ? getAnchorWorld(this.doc, { nodeId: this.pen.nodeId, subpath: this.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
3163
+ const leave = last ? { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null } : null;
3164
+ return this.snapDrawn(p, leave, input, () => this.drawingTargets(this.pen?.nodeId ?? null));
3165
+ }
3166
+ if (SHAPE_TOOLS.has(this.tool)) return this.snapDrawn(p, null, input, () => this.drawingTargets(null));
3167
+ this.setGuides(null);
3168
+ return p;
1339
3169
  }
1340
3170
  // -------------------------------------------------------------------------
1341
3171
  // Document
@@ -1936,12 +3766,47 @@ var VectorEditorController = class {
1936
3766
  return translateAnchor(a, d.x, d.y);
1937
3767
  });
1938
3768
  }
1939
- /** Moves one anchor to a document position (handles follow). */
1940
- 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 = {}) {
1941
3774
  const a = getAnchorWorld(this.doc, ref);
1942
3775
  if (!a) return { ok: false, message: "There is no such anchor." };
3776
+ const curvature = options.curvature ?? this.editing.curvatureMoves;
1943
3777
  this.change("Move Point", () => {
1944
- 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);
1945
3810
  });
1946
3811
  return { ok: true };
1947
3812
  }
@@ -1984,15 +3849,24 @@ var VectorEditorController = class {
1984
3849
  });
1985
3850
  return ref;
1986
3851
  }
1987
- /** Deletes the selected anchors; empty subpaths and paths go too. */
1988
- 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 = {}) {
1989
3858
  if (!refs.length) return { ok: false, message: "Select points to delete." };
3859
+ const keepShape = options.keepShape ?? this.editing.keepShapeOnDelete;
1990
3860
  this.change("Delete Points", () => {
1991
3861
  const nodes = mapTree(this.doc.nodes, (n) => {
1992
- if (n.type !== "path") return n;
3862
+ if (n.type !== "path" || n.locked) return n;
1993
3863
  const mine = refs.filter((r) => r.nodeId === n.id);
1994
3864
  if (!mine.length) return n;
1995
- 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);
1996
3870
  return subpaths.length ? { ...n, subpaths } : null;
1997
3871
  });
1998
3872
  this.setDoc(nodes);
@@ -2030,6 +3904,269 @@ var VectorEditorController = class {
2030
3904
  });
2031
3905
  return { ok: true };
2032
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
+ }
2033
4170
  /** Ends the pen path being drawn (a lone anchor is removed). */
2034
4171
  finishPath() {
2035
4172
  if (!this.pen) return;
@@ -2222,10 +4359,38 @@ var VectorEditorController = class {
2222
4359
  const anchor = this.hitAnchor(screen, refs);
2223
4360
  return anchor ? { nodeId: anchor.nodeId, anchor } : null;
2224
4361
  }
4362
+ case "scissors": {
4363
+ const seg = this.nearestOutline(p);
4364
+ return seg ? { nodeId: seg.nodeId, segment: seg } : null;
4365
+ }
2225
4366
  default:
2226
4367
  return null;
2227
4368
  }
2228
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
+ }
2229
4394
  sameHover(a, b) {
2230
4395
  return JSON.stringify(a) === JSON.stringify(b);
2231
4396
  }
@@ -2250,8 +4415,19 @@ var VectorEditorController = class {
2250
4415
  return this.downPen(screen, p, input);
2251
4416
  case "pencil":
2252
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;
2253
4429
  default:
2254
- return this.downShape(p);
4430
+ return this.downShape(p, input);
2255
4431
  }
2256
4432
  }
2257
4433
  downSelect(screen, p, input) {
@@ -2320,7 +4496,7 @@ var VectorEditorController = class {
2320
4496
  return;
2321
4497
  }
2322
4498
  } else if (!selected) this.anchors = [anchor];
2323
- this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: selected && !input.shift ? anchor : null };
4499
+ this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: selected && !input.shift ? anchor : null, lead: anchor };
2324
4500
  this.notify();
2325
4501
  return;
2326
4502
  }
@@ -2336,7 +4512,7 @@ var VectorEditorController = class {
2336
4512
  const ref = { nodeId: id, subpath: seg.subpath, index };
2337
4513
  this.anchors = [ref];
2338
4514
  this.editingId = id;
2339
- this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null };
4515
+ this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null, lead: ref };
2340
4516
  this.notify(true);
2341
4517
  return;
2342
4518
  }
@@ -2344,7 +4520,7 @@ var VectorEditorController = class {
2344
4520
  const b = { nodeId: id, subpath: seg.subpath, index: (seg.segment + 1) % sp.anchors.length };
2345
4521
  this.anchors = input.shift ? [...this.anchors.filter((r) => !sameRef(r, a) && !sameRef(r, b)), a, b] : [a, b];
2346
4522
  this.editingId = id;
2347
- this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: 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 };
2348
4524
  this.notify();
2349
4525
  return;
2350
4526
  }
@@ -2354,7 +4530,7 @@ var VectorEditorController = class {
2354
4530
  if (!input.shift) this.selection = [path.id];
2355
4531
  else if (!this.selection.includes(path.id)) this.selection = [...this.selection, path.id];
2356
4532
  this.anchors = path.subpaths.flatMap((sp, s) => sp.anchors.map((_, i) => ({ nodeId: path.id, subpath: s, index: i })));
2357
- this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null };
4533
+ this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
2358
4534
  this.notify();
2359
4535
  return;
2360
4536
  }
@@ -2379,9 +4555,9 @@ var VectorEditorController = class {
2379
4555
  this.notify(true);
2380
4556
  return;
2381
4557
  }
2382
- let pt2 = this.snapPoint(p);
2383
4558
  const last = getAnchorWorld(this.doc, { nodeId: pen.nodeId, subpath: pen.subpath, index: sp.anchors.length - 1 });
2384
- if (input.shift) pt2 = constrain45(last, pt2);
4559
+ const leave = { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null };
4560
+ let pt2 = this.snapDrawn(p, leave, input, () => this.drawingTargets(pen.nodeId));
2385
4561
  pt2 = this.toLocal(pen.nodeId, pt2);
2386
4562
  const node2 = this.getNode(pen.nodeId);
2387
4563
  const anchors = [...sp.anchors, { x: pt2.x, y: pt2.y }];
@@ -2412,7 +4588,7 @@ var VectorEditorController = class {
2412
4588
  this.notify(this.doc !== before.doc);
2413
4589
  return;
2414
4590
  }
2415
- const pt = this.snapPoint(p);
4591
+ const pt = this.snapDrawn(p, null, input, () => this.drawingTargets(null));
2416
4592
  const node = { id: this.newId(), type: "path", name: "Path", ...this.styleFor(true), subpaths: [{ anchors: [{ x: pt.x, y: pt.y }], closed: false }] };
2417
4593
  this.setDoc([...this.doc.nodes, node]);
2418
4594
  this.pen = { nodeId: node.id, subpath: 0 };
@@ -2436,17 +4612,20 @@ var VectorEditorController = class {
2436
4612
  subpaths: [{ anchors: [{ x: p.x, y: p.y }], closed: false }]
2437
4613
  };
2438
4614
  this.setDoc([...this.doc.nodes, node]);
2439
- 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 };
2440
4618
  this.interaction = { type: "draw", tool: "pencil", nodeId: node.id, bounds: { x: p.x, y: p.y, width: 0, height: 0 } };
2441
4619
  this.notify(true);
2442
4620
  }
2443
- downShape(p) {
4621
+ downShape(p, input) {
2444
4622
  const before = this.snapshot();
2445
- const start = this.snapPoint(p);
4623
+ const targets = this.drawingTargets(null);
4624
+ const start = this.snapDrawn(p, null, input, () => targets);
2446
4625
  const kind = this.tool;
2447
4626
  const node = this.createShapeNode({ kind, x: start.x, y: start.y, width: 0, height: 0 });
2448
4627
  this.setDoc([...this.doc.nodes, node]);
2449
- this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before };
4628
+ this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before, targets };
2450
4629
  this.interaction = { type: "draw", tool: this.tool, nodeId: node.id, bounds: { x: start.x, y: start.y, width: 0, height: 0 } };
2451
4630
  this.notify(true);
2452
4631
  }
@@ -2457,9 +4636,11 @@ var VectorEditorController = class {
2457
4636
  if (!drag) {
2458
4637
  const hover = this.computeHover(screen);
2459
4638
  const pointerMatters = this.tool === "pen" && !!this.pen;
2460
- if (this.sameHover(hover, this.hover) && !pointerMatters) return;
4639
+ const guides = this.guides;
2461
4640
  this.hover = hover;
2462
- this.pointer = p;
4641
+ const pointer = this.hoverSnap(p, input);
4642
+ if (this.sameHover(hover, this.state.hover) && !pointerMatters && JSON.stringify(guides) === JSON.stringify(this.guides)) return;
4643
+ this.pointer = pointer;
2463
4644
  this.notify();
2464
4645
  return;
2465
4646
  }
@@ -2480,8 +4661,9 @@ var VectorEditorController = class {
2480
4661
  const created = [];
2481
4662
  drag.base = this.withDuplicates(drag.base, this.selectedRoots(), { x: 0, y: 0 }, created);
2482
4663
  this.selection = created;
4664
+ drag.targets = void 0;
2483
4665
  }
2484
- ({ dx, dy } = this.snapDelta(dx, dy, drag.origin));
4666
+ ({ dx, dy } = this.snapMove(drag, dx, dy, input));
2485
4667
  this.doc = this.transformed(drag.base, new Set(this.selectedRoots()), translateMatrix(dx, dy));
2486
4668
  this.interaction = { type: "move", dx, dy };
2487
4669
  this.notify(true);
@@ -2490,7 +4672,13 @@ var VectorEditorController = class {
2490
4672
  case "scale": {
2491
4673
  const angle = drag.frame.angle;
2492
4674
  const q = angle ? applyMatrix(rotateMatrix(-angle), p) : p;
2493
- const { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
4675
+ let { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
4676
+ if (this.snap.objects && !input.mod && drag.handle !== "rotate") {
4677
+ drag.targets ??= VectorSnapTargets.forResize(drag.base, this.selectedRoots());
4678
+ const res = drag.targets.snapScale(drag.frame, drag.handle, sx, sy, input, this.snapTolerance());
4679
+ ({ sx, sy } = res);
4680
+ this.setGuides(res);
4681
+ } else this.setGuides(null);
2494
4682
  const ids = new Set(this.selectedRoots());
2495
4683
  this.doc = this.transformed(drag.base, ids, this.inFrame(angle, scaleMatrix(sx, sy, origin)));
2496
4684
  this.interaction = { type: "scale", handle: drag.handle, sx, sy };
@@ -2531,24 +4719,47 @@ var VectorEditorController = class {
2531
4719
  let dy = p.y - drag.start.y;
2532
4720
  if (!drag.moved && Math.hypot(dx, dy) * this.viewport.zoom < 2) return;
2533
4721
  drag.moved = true;
2534
- if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
2535
- const lead = this.anchors.length ? getAnchorWorld(drag.base, this.anchors[0]) : void 0;
2536
- ({ dx, dy } = this.snapDelta(dx, dy, lead ?? null));
2537
- this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
4722
+ const lead = this.leadAnchor(drag);
4723
+ if (lead) {
4724
+ const leave = input.shift ? { at: lead, constrain: "axis", curves: false } : null;
4725
+ const q = this.snapDrawn({ x: lead.x + dx, y: lead.y + dy }, leave, input, () => drag.targets ??= VectorSnapTargets.forAnchorDrag(drag.base, this.anchors));
4726
+ dx = q.x - lead.x;
4727
+ dy = q.y - lead.y;
4728
+ } else if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
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);
2538
4733
  this.interaction = { type: "anchors", dx, dy };
2539
4734
  this.notify(true);
2540
4735
  return;
2541
4736
  }
2542
4737
  case "handle": {
2543
4738
  const anchor = getAnchorWorld(drag.base, drag.ref);
2544
- let pt = this.snapPoint(p);
2545
- if (input.shift && anchor) pt = constrain45(anchor, pt);
4739
+ const leave = anchor ? { at: { x: anchor.x, y: anchor.y }, constrain: input.shift ? "angle" : null } : null;
4740
+ const pt = this.snapDrawn(p, leave, input, () => drag.targets ??= VectorSnapTargets.forHandle(drag.base, drag.ref.nodeId));
2546
4741
  const local = this.toLocal(drag.ref.nodeId, pt, drag.base);
2547
4742
  this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, local, input.alt));
2548
4743
  this.interaction = { type: "handle", ref: drag.ref };
2549
4744
  this.notify(true);
2550
4745
  return;
2551
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
+ }
2552
4763
  case "pull": {
2553
4764
  const anchor = getAnchorWorld(drag.base, drag.ref);
2554
4765
  if (!anchor) return;
@@ -2557,6 +4768,7 @@ var VectorEditorController = class {
2557
4768
  this.notify(true);
2558
4769
  return;
2559
4770
  }
4771
+ this.guides = null;
2560
4772
  let pt = p;
2561
4773
  if (input.shift) pt = constrain45(anchor, pt);
2562
4774
  const side = drag.closing ? "in" : "out";
@@ -2571,7 +4783,9 @@ var VectorEditorController = class {
2571
4783
  case "shape": {
2572
4784
  const node = this.getNode(drag.nodeId);
2573
4785
  if (node?.type !== "path") return;
2574
- let end = this.snapPoint(p);
4786
+ const leave = { at: drag.start, constrain: input.shift ? drag.tool === "line" ? "angle" : "square" : null, curves: false };
4787
+ let end = this.snapDrawn(p, leave, input, () => drag.targets ??= this.drawingTargets(null));
4788
+ if (!this.snappedToObject && drag.tool !== "line" && !input.shift && !input.mod && this.snap.objects && drag.targets) end = this.matchShapeSize(drag.targets, drag.start, end, input.alt);
2575
4789
  let start = drag.start;
2576
4790
  let bounds;
2577
4791
  if (drag.tool === "line") {
@@ -2599,10 +4813,22 @@ var VectorEditorController = class {
2599
4813
  }
2600
4814
  case "pencil": {
2601
4815
  const last = drag.points[drag.points.length - 1];
2602
- if (Math.hypot(p.x - last[0], p.y - last[1]) * this.viewport.zoom < 1.5) return;
2603
- 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();
2604
4823
  const node = this.getNode(drag.nodeId);
2605
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
+ }
2606
4832
  this.replaceNode(drag.nodeId, { ...node, subpaths: [{ anchors: drag.points.map(([x, y]) => ({ x, y })), closed: false }] });
2607
4833
  const xs = drag.points.map((q) => q[0]);
2608
4834
  const ys = drag.points.map((q) => q[1]);
@@ -2610,19 +4836,72 @@ var VectorEditorController = class {
2610
4836
  type: "draw",
2611
4837
  tool: "pencil",
2612
4838
  nodeId: drag.nodeId,
2613
- 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] }] } : {}
2614
4841
  };
2615
4842
  this.notify(true);
2616
4843
  return;
2617
4844
  }
2618
4845
  }
2619
4846
  }
4847
+ /** Snaps a move of the selection by (dx, dy): its bounds line up with targets or space evenly, else go to the grid. */
4848
+ snapMove(drag, dx, dy, input) {
4849
+ const origin = drag.origin;
4850
+ if (input.mod || !origin) {
4851
+ this.setGuides(null);
4852
+ return { dx, dy };
4853
+ }
4854
+ const axes = { x: !input.shift || dx !== 0, y: !input.shift || dy !== 0 };
4855
+ let res = null;
4856
+ if (this.snap.objects) {
4857
+ drag.targets ??= VectorSnapTargets.forMove(drag.base, this.selectedRoots());
4858
+ res = drag.targets.snapRect({ ...origin, x: origin.x + dx, y: origin.y + dy }, this.snapTolerance(), { spacing: true, axes });
4859
+ }
4860
+ this.setGuides(res);
4861
+ return {
4862
+ dx: res?.x ? dx + res.dx : axes.x ? this.gridValue(origin.x + dx) - origin.x : dx,
4863
+ dy: res?.y ? dy + res.dy : axes.y ? this.gridValue(origin.y + dy) - origin.y : dy
4864
+ };
4865
+ }
4866
+ /** The anchor a point drag snaps by, where it started: the grabbed one, else the moving one nearest the press. */
4867
+ leadAnchor(drag) {
4868
+ const ref = drag.lead && this.anchors.some((a) => sameRef(a, drag.lead)) ? drag.lead : null;
4869
+ if (ref) {
4870
+ const a = getAnchorWorld(drag.base, ref);
4871
+ return a ? { x: a.x, y: a.y } : null;
4872
+ }
4873
+ let best = null;
4874
+ for (const r of this.anchors) {
4875
+ const a = getAnchorWorld(drag.base, r);
4876
+ if (a && (!best || Math.hypot(a.x - drag.start.x, a.y - drag.start.y) < Math.hypot(best.x - drag.start.x, best.y - drag.start.y))) best = { x: a.x, y: a.y };
4877
+ }
4878
+ return best;
4879
+ }
4880
+ /** A shape's dragged corner moved so its width or height matches another object's, when one is within reach. */
4881
+ matchShapeSize(targets, start, end, fromCentre = false) {
4882
+ const k = fromCentre ? 2 : 1;
4883
+ const tol = this.snapTolerance() * k;
4884
+ const out = { ...end };
4885
+ const guides = { lines: [], labels: [] };
4886
+ for (const axis of ["x", "y"]) {
4887
+ const d = end[axis] - start[axis];
4888
+ const len = Math.abs(d) > 1e-9 ? targets.matchSize(Math.abs(d) * k, tol) : null;
4889
+ if (len === null || len <= 0) continue;
4890
+ out[axis] = start[axis] + Math.sign(d) * len / k;
4891
+ const from = { ...out, [axis]: fromCentre ? 2 * start[axis] - out[axis] : start[axis] };
4892
+ guides.lines.push({ kind: "size", x1: from.x, y1: from.y, x2: out.x, y2: out.y });
4893
+ guides.labels.push({ kind: "size", x: (from.x + out.x) / 2, y: (from.y + out.y) / 2, text: `${axis === "x" ? "W" : "H"} ${Math.round(len * 100) / 100}` });
4894
+ }
4895
+ this.setGuides(guides);
4896
+ return out;
4897
+ }
2620
4898
  /** Primary button released. */
2621
4899
  pointerUp(screen, input = {}) {
2622
4900
  if (screen && this.drag && this.drag.kind !== "pan") this.pointerMove(screen, input);
2623
4901
  const drag = this.drag;
2624
4902
  this.drag = null;
2625
4903
  this.interaction = null;
4904
+ this.guides = null;
2626
4905
  if (!drag) return;
2627
4906
  switch (drag.kind) {
2628
4907
  case "pan":
@@ -2650,6 +4929,18 @@ var VectorEditorController = class {
2650
4929
  return this.commit(drag.before.doc !== drag.base ? "Add Point" : "Move Points", drag.before);
2651
4930
  case "handle":
2652
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
+ }
2653
4944
  case "pull":
2654
4945
  if (drag.closing) this.pen = null;
2655
4946
  return this.commit(drag.label, drag.before);
@@ -2674,6 +4965,13 @@ var VectorEditorController = class {
2674
4965
  this.notify(true);
2675
4966
  return;
2676
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
+ }
2677
4975
  const fitted = fitCubics(drag.points, 1.5 / this.viewport.zoom, { cornerAngleDeg: 60 });
2678
4976
  const anchors = [];
2679
4977
  for (const c of fitted) {
@@ -2691,9 +4989,10 @@ var VectorEditorController = class {
2691
4989
  /** The pointer left the canvas. */
2692
4990
  pointerLeave() {
2693
4991
  if (this.drag) return;
2694
- if (!this.hover && !this.pointer) return;
4992
+ if (!this.hover && !this.pointer && !this.guides) return;
2695
4993
  this.hover = null;
2696
4994
  this.pointer = null;
4995
+ this.guides = null;
2697
4996
  this.notify();
2698
4997
  }
2699
4998
  /** Abandons the drag in progress (Escape, lost capture). */
@@ -2702,6 +5001,7 @@ var VectorEditorController = class {
2702
5001
  if (!drag) return;
2703
5002
  this.drag = null;
2704
5003
  this.interaction = null;
5004
+ this.guides = null;
2705
5005
  if (drag.kind === "pan") {
2706
5006
  this.notify();
2707
5007
  return;
@@ -2778,6 +5078,8 @@ var VectorEditorController = class {
2778
5078
  case "g":
2779
5079
  input.shift ? this.ungroup() : this.group();
2780
5080
  return true;
5081
+ case "j":
5082
+ return input.alt ? this.averageAnchors("both").ok : this.joinPaths().ok;
2781
5083
  case "]":
2782
5084
  this.arrange(input.alt ? "front" : "forward");
2783
5085
  return true;
@@ -2858,6 +5160,22 @@ var VectorEditorController = class {
2858
5160
  }
2859
5161
  }
2860
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
+ }
2861
5179
  function constrain45(origin, p) {
2862
5180
  const dx = p.x - origin.x;
2863
5181
  const dy = p.y - origin.y;
@@ -3017,12 +5335,41 @@ function getVectorOverlayRenderData(state) {
3017
5335
  const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionFrame;
3018
5336
  if (b) status = `W ${fmt(b.width)} \xD7 H ${fmt(b.height)}`;
3019
5337
  }
3020
- return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, status };
5338
+ const guides = { lines: [], labels: [] };
5339
+ if (state.guides) {
5340
+ for (const l of state.guides.lines) {
5341
+ const a = toScreen(state, { x: l.x1, y: l.y1 });
5342
+ const b = toScreen(state, { x: l.x2, y: l.y2 });
5343
+ guides.lines.push({ kind: l.kind, x1: fmt(a.x), y1: fmt(a.y), x2: fmt(b.x), y2: fmt(b.y) });
5344
+ }
5345
+ for (const l of state.guides.labels) {
5346
+ const p = toScreen(state, l);
5347
+ guides.labels.push({ kind: l.kind, x: fmt(p.x), y: fmt(p.y), text: l.text });
5348
+ }
5349
+ }
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 };
3021
5364
  }
3022
5365
  var VECTOR_OVERLAY_THEME = {
3023
5366
  accent: "#0ea5e9",
5367
+ /** Smart guide lines and labels. */
5368
+ guide: "#ff3dfc",
3024
5369
  handleFill: "#ffffff",
3025
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)",
3026
5373
  transformHandleSize: 8,
3027
5374
  anchorSize: 7,
3028
5375
  handleRadius: 3.5
@@ -3068,12 +5415,39 @@ function vectorPaintToHex(paint) {
3068
5415
  const hex = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
3069
5416
  return `#${hex.toLowerCase()}`;
3070
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
+ ];
3071
5430
 
3072
5431
  // src/vector/commands.ts
3073
5432
  var fail = (message) => {
3074
5433
  throw new Error(message);
3075
5434
  };
3076
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
+ };
3077
5451
  var PAINT = {
3078
5452
  description: 'A CSS colour, `null` for none, or a gradient `{ type: "linear", angle, stops: [{ offset, color }] }`.'
3079
5453
  };
@@ -3106,6 +5480,14 @@ function createVectorCommands(controller, options = {}) {
3106
5480
  return list2;
3107
5481
  };
3108
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
+ };
3109
5491
  const list = () => {
3110
5492
  const state = controller.getState();
3111
5493
  const out = [];
@@ -3344,6 +5726,186 @@ function createVectorCommands(controller, options = {}) {
3344
5726
  return { selection: controller.getState().selection };
3345
5727
  }
3346
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
+ },
3347
5909
  {
3348
5910
  ...base,
3349
5911
  id: `${prefix}setTool`,
@@ -3382,18 +5944,28 @@ function createVectorCommands(controller, options = {}) {
3382
5944
  ];
3383
5945
  }
3384
5946
  export {
5947
+ DEFAULT_VECTOR_PATH_EDIT,
5948
+ DEFAULT_VECTOR_SNAP,
3385
5949
  IDENTITY_MATRIX,
3386
5950
  KAPPA,
5951
+ LazyMouse,
3387
5952
  VECTOR_CLIPBOARD_TYPE,
5953
+ VECTOR_CONSTRUCTION_ANGLES,
5954
+ VECTOR_EDIT_TOGGLES,
5955
+ VECTOR_INK_MAX_CORNERS,
3388
5956
  VECTOR_OVERLAY_THEME,
5957
+ VECTOR_PATH_ACTIONS,
5958
+ VECTOR_PENCIL_STRING_PX,
3389
5959
  VECTOR_ROTATE_HANDLE_OFFSET,
3390
5960
  VECTOR_TOOLS,
3391
5961
  VECTOR_TOOL_GLYPHS,
3392
5962
  VECTOR_TOOL_SHORTCUTS,
3393
5963
  VECTOR_TRANSFORM_HANDLES,
3394
5964
  VectorEditorController,
5965
+ VectorSnapTargets,
3395
5966
  applyMatrix,
3396
5967
  applyMatrixLinear,
5968
+ bendSegment,
3397
5969
  bezierToSubpaths,
3398
5970
  boundsFromPoints,
3399
5971
  boundsIntersect,
@@ -3402,10 +5974,16 @@ export {
3402
5974
  cloneVectorDocument,
3403
5975
  cloneVectorNode,
3404
5976
  collectPathNodes,
5977
+ constrainToLeave,
3405
5978
  convertAnchor,
3406
5979
  createVectorCommands,
5980
+ curvatureMoveAnchor,
5981
+ cutSubpathAt,
3407
5982
  decomposeRigid,
3408
5983
  ellipseSubpath,
5984
+ eraseFromFill,
5985
+ eraseFromOutline,
5986
+ eraserRegion,
3409
5987
  findVectorNode,
3410
5988
  getAnchor,
3411
5989
  getAnchorWorld,
@@ -3426,6 +6004,8 @@ export {
3426
6004
  invertMatrix,
3427
6005
  isIdentityMatrix,
3428
6006
  isVectorTextTarget,
6007
+ joinSubpaths,
6008
+ knifePath,
3429
6009
  lineSubpath,
3430
6010
  locateVectorNode,
3431
6011
  matrixRotation,
@@ -3436,14 +6016,24 @@ export {
3436
6016
  parseSvgTransform,
3437
6017
  parseVectorSvg,
3438
6018
  polygonSubpath,
6019
+ polylineBounds,
6020
+ recognizeInkShape,
3439
6021
  rectSubpath,
6022
+ removeAnchorKeepingShape,
3440
6023
  removeAnchors,
6024
+ removeAnchorsKeepingShape,
6025
+ removeRedundantPoints,
3441
6026
  reverseSubpath,
3442
6027
  rotateMatrix,
3443
6028
  roundSubpaths,
3444
6029
  scaleFor,
3445
6030
  scaleMatrix,
6031
+ simplifyPolyline,
6032
+ smoothSubpath,
6033
+ splitIntoFaces,
6034
+ splitSubpathAt,
3446
6035
  starSubpath,
6036
+ subpathPointAt,
3447
6037
  subpathSegment,
3448
6038
  subpathSegmentCount,
3449
6039
  subpathsToBezier,
@@ -3455,6 +6045,7 @@ export {
3455
6045
  translateMatrix,
3456
6046
  unionBounds,
3457
6047
  vectorDocumentToSvg,
6048
+ vectorInkShapeName,
3458
6049
  vectorNodeBounds,
3459
6050
  vectorNodeRotation,
3460
6051
  vectorNodeToBezier,