@pixerate/editor 0.33.0 → 0.34.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.
@@ -10,12 +10,14 @@ import {
10
10
  gradientToSvgDef,
11
11
  pathBoolean,
12
12
  pointInBezierPath,
13
+ segmentDerivative,
14
+ segmentIntersections,
13
15
  segmentNearest,
14
16
  segmentPoint,
15
17
  splitSegment,
16
18
  toBezierPath,
17
19
  transformBezierPath
18
- } from "../chunk-GQTXV2EQ.js";
20
+ } from "../chunk-RK6HUKDI.js";
19
21
 
20
22
  // src/vector/types.ts
21
23
  var VECTOR_TOOLS = ["select", "direct", "pen", "pencil", "rect", "ellipse", "polygon", "star", "line"];
@@ -850,7 +852,759 @@ function parseVectorSvg(svg, options = {}) {
850
852
  return { width, height, nodes };
851
853
  }
852
854
 
855
+ // src/vector/snap.ts
856
+ var VECTOR_CONSTRUCTION_ANGLES = [0, 45, 90, 135];
857
+ function constrainToLeave(leave, p) {
858
+ if (!leave?.constrain) return p;
859
+ const dx = p.x - leave.at.x;
860
+ const dy = p.y - leave.at.y;
861
+ switch (leave.constrain) {
862
+ case "axis":
863
+ return Math.abs(dx) > Math.abs(dy) ? { x: p.x, y: leave.at.y } : { x: leave.at.x, y: p.y };
864
+ case "square": {
865
+ const s = Math.max(Math.abs(dx), Math.abs(dy));
866
+ return { x: leave.at.x + Math.sign(dx || 1) * s, y: leave.at.y + Math.sign(dy || 1) * s };
867
+ }
868
+ default: {
869
+ const len = Math.hypot(dx, dy);
870
+ const a = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
871
+ return { x: leave.at.x + Math.cos(a) * len, y: leave.at.y + Math.sin(a) * len };
872
+ }
873
+ }
874
+ }
875
+ var BUDGET = 2e4;
876
+ var CROSSING_PAIRS = 32;
877
+ var CURVE_SAMPLES = 48;
878
+ var BISECT = 50;
879
+ var EQUAL = 1e-6;
880
+ function bound(sorted, key, v, strict) {
881
+ let lo = 0;
882
+ let hi = sorted.length;
883
+ while (lo < hi) {
884
+ const mid = lo + hi >>> 1;
885
+ const k = key(sorted[mid]);
886
+ if (strict ? k <= v : k < v) lo = mid + 1;
887
+ else hi = mid;
888
+ }
889
+ return lo;
890
+ }
891
+ function within(sorted, key, v, tol) {
892
+ return sorted.slice(bound(sorted, key, v - tol, false), bound(sorted, key, v + tol, true));
893
+ }
894
+ var linesNear = (lines, v, tol) => within(lines, (l) => l.v, v, tol);
895
+ var distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
896
+ var toPoint = (p) => [p.x, p.y];
897
+ var toVec = (p) => ({ x: p[0], y: p[1] });
898
+ var LABELS = {
899
+ anchor: "anchor",
900
+ centre: "centre",
901
+ intersect: "intersect",
902
+ perpendicular: "perpendicular",
903
+ tangent: "tangent",
904
+ path: "path",
905
+ construction: "angle",
906
+ align: "align",
907
+ spacing: "spacing",
908
+ size: "size"
909
+ };
910
+ var fmtLength = (n) => String(Math.round(n * 100) / 100);
911
+ function nodeFrame(node, parent, aabb) {
912
+ const angle = matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
913
+ const b = angle ? vectorNodeBounds(node, multiplyMatrices(parent, rotateMatrix(-angle))) : aabb !== void 0 ? aabb : vectorNodeBounds(node, parent);
914
+ if (!b) return null;
915
+ const c = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
916
+ return { width: b.width, height: b.height, centre: angle ? applyMatrix(rotateMatrix(angle), c) : c };
917
+ }
918
+ function contains(node, ids) {
919
+ if (ids.has(node.id)) return true;
920
+ return node.type === "group" && node.children.some((c) => contains(c, ids));
921
+ }
922
+ var VectorSnapTargets = class _VectorSnapTargets {
923
+ points = [];
924
+ xs = [];
925
+ ys = [];
926
+ segments = [];
927
+ /** Bounds of the objects a moved box spaces itself evenly with. */
928
+ boxes = [];
929
+ /** Side lengths of the other objects (in their own frames), which a resize may match. */
930
+ sizes = [];
931
+ constructor() {
932
+ }
933
+ /** How many targets of each sort were gathered. */
934
+ get counts() {
935
+ return { points: this.points.length, lines: this.xs.length + this.ys.length, segments: this.segments.length, boxes: this.boxes.length };
936
+ }
937
+ /** Targets for moving nodes `ids`: the other art's anchors, bounds and centres, the artboard, and boxes to space evenly with. */
938
+ static forMove(doc, ids) {
939
+ return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
940
+ }
941
+ /** Targets for resizing nodes `ids`: as for a move, plus the other objects' sizes. */
942
+ static forResize(doc, ids) {
943
+ return _VectorSnapTargets.collect(doc, new Set(ids), "bounds");
944
+ }
945
+ /**
946
+ * Targets for a drawn point (pen, shape tools): the art but `exclude`, its anchors (as
947
+ * points and in line), centres, edges and segments. The anchors and segments of `exclude`
948
+ * (the path being drawn, whose bounds grow with it) stay targets.
949
+ */
950
+ static forDrawing(doc, exclude = []) {
951
+ const skip = new Set(exclude);
952
+ const t = _VectorSnapTargets.collect(doc, skip, "drawing", false);
953
+ t.pushPaths(doc, skip, () => false, true);
954
+ return t.index();
955
+ }
956
+ /**
957
+ * Targets for dragging anchors: the other art with its segments, and the anchors and
958
+ * segments that stay put on the paths being reshaped (their bounds move along, so they are
959
+ * no targets). The moving anchors are left out.
960
+ */
961
+ static forAnchorDrag(doc, anchors) {
962
+ const moving = /* @__PURE__ */ new Map();
963
+ for (const r of anchors) {
964
+ const set = moving.get(r.nodeId) ?? /* @__PURE__ */ new Set();
965
+ set.add(`${r.subpath}:${r.index}`);
966
+ moving.set(r.nodeId, set);
967
+ }
968
+ const t = _VectorSnapTargets.collect(doc, new Set(moving.keys()), "segments", false);
969
+ t.pushPaths(doc, new Set(moving.keys()), (id, s, i) => !!moving.get(id)?.has(`${s}:${i}`), true);
970
+ return t.index();
971
+ }
972
+ /**
973
+ * Targets for dragging a handle of path `nodeId`: the other art and that path's anchors
974
+ * (its bounds and the segments through the handle move with it, so they would chase it).
975
+ */
976
+ static forHandle(doc, nodeId) {
977
+ const skip = /* @__PURE__ */ new Set([nodeId]);
978
+ const t = _VectorSnapTargets.collect(doc, skip, "bounds", false);
979
+ t.pushPaths(doc, skip, () => false, false);
980
+ return t.index();
981
+ }
982
+ /** The anchors of the paths `ids` as targets (on them and in line), and their segments when `segments`. */
983
+ pushPaths(doc, ids, moving, segments) {
984
+ let anchors = BUDGET;
985
+ let segmentBudget = segments ? BUDGET : 0;
986
+ const visit = (list, parent) => {
987
+ for (const n of list) {
988
+ const m = multiplyMatrices(nodeMatrix(n), parent);
989
+ if (n.type === "group") visit(n.children, m);
990
+ else if (ids.has(n.id) && n.visible !== false) {
991
+ n.subpaths.forEach((sp, s) => {
992
+ sp.anchors.forEach((a, i) => {
993
+ if (anchors <= 0 || moving(n.id, s, i)) return;
994
+ this.pushAnchor(applyMatrix(m, a));
995
+ anchors--;
996
+ });
997
+ segmentBudget = this.pushSegments(sp, m, (i) => moving(n.id, s, i), segmentBudget);
998
+ });
999
+ }
1000
+ }
1001
+ };
1002
+ visit(doc.nodes, IDENTITY_MATRIX);
1003
+ }
1004
+ pushAnchor(p) {
1005
+ this.points.push({ p, kind: "anchor" });
1006
+ this.xs.push({ v: p.x, from: p, kind: "anchor" });
1007
+ this.ys.push({ v: p.y, from: p, kind: "anchor" });
1008
+ }
1009
+ /** The segments of `sp` (mapped by `m`) that stay put while the anchors `moving` accepts move. */
1010
+ pushSegments(sp, m, moving, budget) {
1011
+ const n = sp.anchors.length;
1012
+ const count = subpathSegmentCount(sp);
1013
+ for (let i = 0; i < count && budget > 0; i++) {
1014
+ if (moving(i) || moving((i + 1) % n)) continue;
1015
+ const seg = subpathSegment(sp, i).map((q) => toPoint(applyMatrix(m, toVec(q))));
1016
+ let minX = Infinity;
1017
+ let minY = Infinity;
1018
+ let maxX = -Infinity;
1019
+ let maxY = -Infinity;
1020
+ for (const [x, y] of seg) {
1021
+ minX = Math.min(minX, x);
1022
+ minY = Math.min(minY, y);
1023
+ maxX = Math.max(maxX, x);
1024
+ maxY = Math.max(maxY, y);
1025
+ }
1026
+ this.segments.push({ seg, minX, minY, maxX, maxY });
1027
+ budget--;
1028
+ }
1029
+ return budget;
1030
+ }
1031
+ pushRect(r, kind, centre) {
1032
+ const corners = [
1033
+ { x: r.x, y: r.y },
1034
+ { x: r.x + r.width, y: r.y },
1035
+ { x: r.x + r.width, y: r.y + r.height },
1036
+ { x: r.x, y: r.y + r.height }
1037
+ ];
1038
+ for (const p of corners) {
1039
+ this.xs.push({ v: p.x, from: p, kind });
1040
+ this.ys.push({ v: p.y, from: p, kind });
1041
+ }
1042
+ if (centre) {
1043
+ this.points.push({ p: centre, kind: "centre" });
1044
+ this.xs.push({ v: centre.x, from: centre, kind: "centre" });
1045
+ this.ys.push({ v: centre.y, from: centre, kind: "centre" });
1046
+ }
1047
+ }
1048
+ /** Sorts the targets for `within` (stable: of targets alike, the first gathered wins). */
1049
+ index() {
1050
+ this.points.sort((a, b) => a.p.x - b.p.x);
1051
+ this.xs.sort((a, b) => a.v - b.v);
1052
+ this.ys.sort((a, b) => a.v - b.v);
1053
+ return this;
1054
+ }
1055
+ /**
1056
+ * Gathers targets from the artboard and every visible node but `exclude` (and what is inside
1057
+ * those): each leaf's anchors (also as lines for `drawing`), its segments (`segments`,
1058
+ * `drawing`), its bounds' corners as lines and its centre; and the top-level objects' boxes and
1059
+ * sizes (inside a group holding an excluded node, that group's other members).
1060
+ */
1061
+ static collect(doc, exclude, gather, sort = true) {
1062
+ const self = new _VectorSnapTargets();
1063
+ const artboard = { x: 0, y: 0, width: doc.width, height: doc.height };
1064
+ self.pushRect(artboard, "artboard", { x: doc.width / 2, y: doc.height / 2 });
1065
+ let anchors = BUDGET;
1066
+ let segments = BUDGET;
1067
+ const visit = (list, parent) => {
1068
+ for (const n of list) {
1069
+ if (exclude.has(n.id) || n.visible === false) continue;
1070
+ const m = multiplyMatrices(nodeMatrix(n), parent);
1071
+ if (n.type === "group") {
1072
+ visit(n.children, m);
1073
+ continue;
1074
+ }
1075
+ if (anchors <= 0) continue;
1076
+ for (const sp of n.subpaths) {
1077
+ for (const a of sp.anchors) {
1078
+ if (anchors-- <= 0) break;
1079
+ const p = applyMatrix(m, a);
1080
+ if (gather === "drawing") self.pushAnchor(p);
1081
+ else self.points.push({ p, kind: "anchor" });
1082
+ }
1083
+ if (gather !== "bounds") segments = self.pushSegments(sp, m, () => false, segments);
1084
+ }
1085
+ const b = vectorNodeBounds(n, parent);
1086
+ const f = nodeFrame(n, parent, b);
1087
+ if (b) self.pushRect(b, "edge", f?.centre ?? null);
1088
+ }
1089
+ };
1090
+ visit(doc.nodes, IDENTITY_MATRIX);
1091
+ const objects = (list, parent) => {
1092
+ for (const n of list) {
1093
+ if (exclude.has(n.id) || n.visible === false || self.boxes.length >= BUDGET) continue;
1094
+ if (n.type === "group" && contains(n, exclude)) {
1095
+ objects(n.children, multiplyMatrices(nodeMatrix(n), parent));
1096
+ continue;
1097
+ }
1098
+ const b = vectorNodeBounds(n, parent);
1099
+ if (b) self.boxes.push(b);
1100
+ const f = nodeFrame(n, parent, b);
1101
+ if (f) self.sizes.push(f.width, f.height);
1102
+ }
1103
+ };
1104
+ objects(doc.nodes, IDENTITY_MATRIX);
1105
+ return sort ? self.index() : self;
1106
+ }
1107
+ // -------------------------------------------------------------------------
1108
+ // Points
1109
+ // -------------------------------------------------------------------------
1110
+ /** Snaps a single point (see `snapFrom`). */
1111
+ snapPoint(p, tol) {
1112
+ return this.snapFrom(p, null, tol);
1113
+ }
1114
+ /**
1115
+ * Snaps a point `p`, drawn or dragged from `leave` when given. With `leave.constrain` it keeps
1116
+ * to its angle round `leave.at`, sliding along that way into line with the nearest target.
1117
+ * Otherwise it lands, in order, on an anchor or centre, an intersection of two paths, the
1118
+ * perpendicular foot or a tangent point from `leave.at`, a path, a construction guide
1119
+ * through `leave.at` (where a target's line crosses it if one does nearby), or else lines up
1120
+ * with targets on x and y.
1121
+ */
1122
+ snapFrom(p, leave, tol) {
1123
+ const none = (q) => ({ point: q, kind: null, x: false, y: false, lines: [], labels: [] });
1124
+ const at = (q, kind, lines2 = []) => ({
1125
+ point: q,
1126
+ kind,
1127
+ x: true,
1128
+ y: true,
1129
+ lines: lines2,
1130
+ labels: [this.label(q, kind)]
1131
+ });
1132
+ if (leave?.constrain) {
1133
+ const q = constrainToLeave(leave, p);
1134
+ const hit = this.slide(leave.at, { x: q.x - leave.at.x, y: q.y - leave.at.y }, q, tol, true);
1135
+ return hit ? { point: hit.point, x: true, y: true, ...this.linedUp(hit.point, hit.from) } : none(q);
1136
+ }
1137
+ const point = this.nearestPoint(p, tol);
1138
+ if (point) return at(point.p, point.kind);
1139
+ const near = this.segmentsNear(p, tol);
1140
+ const crossing = this.crossing(near, p, tol);
1141
+ if (crossing) return at(crossing, "intersect");
1142
+ if (leave && leave.curves !== false) {
1143
+ const foot = this.curvePoint(near, leave.at, p, tol);
1144
+ if (foot) return at(foot.point, foot.kind, [line("construction", leave.at, foot.point)]);
1145
+ }
1146
+ const on = this.onSegment(near);
1147
+ if (on) return at(on, "path");
1148
+ if (leave?.angles?.length) {
1149
+ const c = construction(leave, p, tol);
1150
+ if (c) {
1151
+ const hit = this.slide(leave.at, c.dir, c.point, tol, false);
1152
+ if (hit) {
1153
+ const lined = this.linedUp(hit.point, hit.from);
1154
+ return { point: hit.point, x: true, y: true, kind: lined.kind, lines: [line("construction", leave.at, hit.point), ...lined.lines], labels: lined.labels };
1155
+ }
1156
+ return { point: c.point, kind: "construction", x: true, y: true, lines: [line("construction", leave.at, c.point)], labels: [] };
1157
+ }
1158
+ }
1159
+ const nearest = (lines2, v) => {
1160
+ let best = null;
1161
+ for (const l of linesNear(lines2, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
1162
+ return best;
1163
+ };
1164
+ const bx = nearest(this.xs, p.x);
1165
+ const by = nearest(this.ys, p.y);
1166
+ if (!bx && !by) return none(p);
1167
+ const out = { x: bx ? bx.v : p.x, y: by ? by.v : p.y };
1168
+ const lines = [];
1169
+ if (bx) lines.push(line("align", bx.from, { x: out.x, y: out.y }));
1170
+ if (by) lines.push(line("align", by.from, { x: out.x, y: out.y }));
1171
+ return { point: out, kind: "align", x: !!bx, y: !!by, lines, labels: [this.label(out, "align")] };
1172
+ }
1173
+ label(p, kind, text = LABELS[kind]) {
1174
+ return { kind, x: p.x, y: p.y, text };
1175
+ }
1176
+ /** The anchor or centre nearest `p`, within `tol`. */
1177
+ nearestPoint(p, tol) {
1178
+ let best = null;
1179
+ let bestD = Infinity;
1180
+ for (const t of within(this.points, (e) => e.p.x, p.x, tol)) {
1181
+ const d = distance(t.p, p);
1182
+ if (d <= tol && d < bestD) {
1183
+ best = t;
1184
+ bestD = d;
1185
+ }
1186
+ }
1187
+ return best;
1188
+ }
1189
+ /**
1190
+ * Slides `q`, on the line through `at` along `dir` (beyond `at` only for a `ray`), to where
1191
+ * the nearest target line crosses it, at most twice `tol` away: where it lands and the
1192
+ * target point that line comes from.
1193
+ */
1194
+ slide(at, dir, q, tol, ray) {
1195
+ const len2 = dir.x * dir.x + dir.y * dir.y;
1196
+ if (!Number.isFinite(len2) || len2 <= 1e-18) return null;
1197
+ const reach = 2 * tol;
1198
+ let best = null;
1199
+ for (const vertical of [true, false]) {
1200
+ const d = vertical ? dir.x : dir.y;
1201
+ if (Math.abs(d) <= 1e-9) continue;
1202
+ const a = vertical ? at.x : at.y;
1203
+ for (const l of linesNear(vertical ? this.xs : this.ys, vertical ? q.x : q.y, reach)) {
1204
+ const s = (l.v - a) / d;
1205
+ const hit = vertical ? { x: l.v, y: at.y + dir.y * s } : { x: at.x + dir.x * s, y: l.v };
1206
+ const moved = distance(hit, q);
1207
+ if (moved <= reach && (!ray || s > 0) && (!best || moved < best.moved)) best = { moved, point: hit, from: l.from };
1208
+ }
1209
+ }
1210
+ return best && { point: best.point, from: best.from };
1211
+ }
1212
+ /** 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. */
1213
+ linedUp(q, from) {
1214
+ const on = this.nearestPoint(q, 1e-9);
1215
+ if (on) return { kind: on.kind, lines: [], labels: [this.label(on.p, on.kind)] };
1216
+ return { kind: "align", lines: [line("align", from, q)], labels: [this.label(q, "align")] };
1217
+ }
1218
+ /** Target segments whose control box reaches `p` and which pass within `tol` of it, nearest first. */
1219
+ segmentsNear(p, tol) {
1220
+ const out = [];
1221
+ const pt = toPoint(p);
1222
+ for (const s of this.segments) {
1223
+ if (p.x < s.minX - tol || p.x > s.maxX + tol || p.y < s.minY - tol || p.y > s.maxY + tol) continue;
1224
+ const hit = segmentNearest(s.seg, pt);
1225
+ if (hit.distance <= tol) out.push({ seg: s.seg, t: hit.t, distance: hit.distance });
1226
+ }
1227
+ return out.sort((a, b) => a.distance - b.distance);
1228
+ }
1229
+ onSegment(near) {
1230
+ return near.length ? toVec(segmentPoint(near[0].seg, near[0].t)) : null;
1231
+ }
1232
+ /** Where two of the target segments near `p` cross, the crossing nearest `p` within `tol`. */
1233
+ crossing(near, p, tol) {
1234
+ const list = near.slice(0, CROSSING_PAIRS);
1235
+ let best = null;
1236
+ let bestD = Infinity;
1237
+ for (let i = 0; i < list.length; i++) {
1238
+ for (let j = i + 1; j < list.length; j++) {
1239
+ for (const hit of segmentIntersections(list[i].seg, list[j].seg)) {
1240
+ const q = toVec(hit.point);
1241
+ const d = distance(q, p);
1242
+ if (d <= tol && d < bestD) {
1243
+ best = q;
1244
+ bestD = d;
1245
+ }
1246
+ }
1247
+ }
1248
+ }
1249
+ return best;
1250
+ }
1251
+ /**
1252
+ * The foot of a perpendicular, or a tangent point, from `base` on a target segment near `p`
1253
+ * (within `tol` of it): roots of `(B(t) − base) · B′(t)` and `(B(t) − base) × B′(t)`
1254
+ * bracketed by sign changes of samples and narrowed by bisection.
1255
+ */
1256
+ curvePoint(near, base, p, tol) {
1257
+ let best = null;
1258
+ for (const { seg } of near) {
1259
+ for (const kind of ["perpendicular", "tangent"]) {
1260
+ if (kind === "tangent" && seg.length === 2) continue;
1261
+ for (const q of solveOnSegment(seg, base, kind === "perpendicular" ? dotCondition : crossCondition)) {
1262
+ const d = distance(q, p);
1263
+ if (d <= tol && (!best || d < best.d)) best = { point: q, kind, d };
1264
+ }
1265
+ }
1266
+ }
1267
+ return best && { point: best.point, kind: best.kind };
1268
+ }
1269
+ // -------------------------------------------------------------------------
1270
+ // Boxes
1271
+ // -------------------------------------------------------------------------
1272
+ /**
1273
+ * Snaps a moving box `r` (the moved selection's bounds): its edge or centre nearest a target
1274
+ * line on each axis (`axes` limits which may move). With `spacing`, an axis that lines up
1275
+ * with nothing spaces the box evenly in its row or column. Returns the correction and guides.
1276
+ */
1277
+ snapRect(r, tol, options = {}) {
1278
+ const axes = { x: options.axes?.x !== false, y: options.axes?.y !== false };
1279
+ const nearest = (lines, values) => {
1280
+ let best = null;
1281
+ 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 };
1282
+ return best;
1283
+ };
1284
+ const bx = axes.x ? nearest(this.xs, [r.x, r.x + r.width / 2, r.x + r.width]) : null;
1285
+ const by = axes.y ? nearest(this.ys, [r.y, r.y + r.height / 2, r.y + r.height]) : null;
1286
+ const out = { dx: bx?.d ?? 0, dy: by?.d ?? 0, x: !!bx, y: !!by, lines: [], labels: [] };
1287
+ const done = { ...r, x: r.x + out.dx, y: r.y + out.dy };
1288
+ if (bx) {
1289
+ const x = bx.from.x;
1290
+ 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) });
1291
+ }
1292
+ if (by) {
1293
+ const y = by.from.y;
1294
+ 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 });
1295
+ }
1296
+ if (options.spacing && this.boxes.length) {
1297
+ for (const horizontal of [true, false]) {
1298
+ if (horizontal ? out.x || !axes.x : out.y || !axes.y) continue;
1299
+ const moved = { ...r, x: r.x + out.dx, y: r.y + out.dy };
1300
+ const even = this.evenSpacing(moved, horizontal, tol);
1301
+ if (!even) continue;
1302
+ if (horizontal) {
1303
+ out.dx += even.shift;
1304
+ out.x = true;
1305
+ } else {
1306
+ out.dy += even.shift;
1307
+ out.y = true;
1308
+ }
1309
+ for (const gap of even.gaps) this.gapMarks(gap, horizontal, tol, out);
1310
+ }
1311
+ }
1312
+ 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"));
1313
+ return out;
1314
+ }
1315
+ /**
1316
+ * Equal spacing along x (`horizontal`) or y for the moved box `r`, among the objects of its
1317
+ * row (column): the shift within `tol` that centres it between its neighbours, or leaves it
1318
+ * as far from the neighbour before or after it as two other objects of the row are apart;
1319
+ * and the gaps that are equal then, as (start, end, where across).
1320
+ */
1321
+ evenSpacing(r, horizontal, tol) {
1322
+ const span = (b, x) => x ? [b.x, b.x + b.width] : [b.y, b.y + b.height];
1323
+ const [r0, r1] = span(r, horizontal);
1324
+ const len = r1 - r0;
1325
+ const [c0, c1] = span(r, !horizontal);
1326
+ 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);
1327
+ if (!row.length) return null;
1328
+ 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;
1329
+ const pairs = [];
1330
+ for (const p of row) {
1331
+ const next = bound(row, (e) => e.a0, p.a1, false);
1332
+ const q = row[next];
1333
+ if (!q) continue;
1334
+ const straddles = p.a1 <= r0 + tol && q.a0 >= r1 - tol;
1335
+ if (q.a0 > p.a1 && !straddles) pairs.push({ g: q.a0 - p.a1, gap: [p.a1, q.a0, mid(p.b, q.b)] });
1336
+ }
1337
+ let before = null;
1338
+ let after = null;
1339
+ for (const e of row) {
1340
+ if (e.a1 <= r0 + tol && (!before || e.a1 > before.a1)) before = e;
1341
+ if (e.a0 >= r1 - tol && (!after || e.a0 < after.a0)) after = e;
1342
+ }
1343
+ const starts = [];
1344
+ if (before && after && after.a0 - before.a1 > len) starts.push({ start: (before.a1 + after.a0 - len) / 2, repeats: null });
1345
+ for (const { g } of pairs) {
1346
+ if (before) starts.push({ start: before.a1 + g, repeats: g });
1347
+ if (after) starts.push({ start: after.a0 - g - len, repeats: g });
1348
+ }
1349
+ let pick = null;
1350
+ 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;
1351
+ if (!pick) return null;
1352
+ const { start, repeats } = pick;
1353
+ const at = horizontal ? { ...r, x: start } : { ...r, y: start };
1354
+ const end = start + len;
1355
+ const gaps = [];
1356
+ if (before && (repeats === null || Math.abs(start - before.a1 - repeats) <= EQUAL)) gaps.push([before.a1, start, mid(before.b, at)]);
1357
+ if (after && (repeats === null || Math.abs(after.a0 - end - repeats) <= EQUAL)) gaps.push([end, after.a0, mid(after.b, at)]);
1358
+ if (repeats !== null) {
1359
+ for (const p of pairs) if (Math.abs(p.g - repeats) <= EQUAL) gaps.push(p.gap);
1360
+ }
1361
+ return { shift: start - r0, gaps };
1362
+ }
1363
+ /** A gap mark: a line across the gap with a tick at each end, and its length as a label. */
1364
+ gapMarks([u0, u1, c], horizontal, tick, out) {
1365
+ const pt = (u, v) => horizontal ? { x: u, y: v } : { x: v, y: u };
1366
+ 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)));
1367
+ out.labels.push(this.label(pt((u0 + u1) / 2, c), "spacing", fmtLength(u1 - u0)));
1368
+ }
1369
+ /** The other objects' side length nearest `length`, within `tol`, or null. */
1370
+ matchSize(length2, tol) {
1371
+ let best = null;
1372
+ for (const s of this.sizes) if (Math.abs(s - length2) <= tol && (best === null || Math.abs(s - length2) < Math.abs(best - length2))) best = s;
1373
+ return best;
1374
+ }
1375
+ /**
1376
+ * Snaps a selection-box resize. `sx / sy` are the scale `scaleFor` gave for dragging
1377
+ * `handle` of `frame` (in the box's own, possibly rotated frame), `mods.shift` keeps the
1378
+ * proportions and `mods.alt` scales about the centre. The handle lands on an anchor or
1379
+ * centre within `tol`, else the corner or the side it moves lines up with the nearest
1380
+ * target, whatever the box's angle; a handle with one way to go (a side, or a proportional
1381
+ * corner) slides along it until one of them is on a target. An axis that snaps to nothing
1382
+ * may match another object's width or height instead.
1383
+ */
1384
+ snapScale(frame, handle, sx, sy, mods, tol) {
1385
+ const toDoc = frame.angle ? rotateMatrix(frame.angle) : IDENTITY_MATRIX;
1386
+ const toLocal = frame.angle ? rotateMatrix(-frame.angle) : IDENTITY_MATRIX;
1387
+ const corner = handle.length === 2;
1388
+ const proportional = !!mods.shift;
1389
+ const centre = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
1390
+ const origin = mods.alt ? centre : handlePos(opposite(handle), frame);
1391
+ const hp = handlePos(handle, frame);
1392
+ const d0 = { x: hp.x - origin.x, y: hp.y - origin.y };
1393
+ const valid = (n, s) => Number.isFinite(n) && n * s > 0;
1394
+ const nearest = (v, lines) => {
1395
+ let best = null;
1396
+ for (const l of linesNear(lines, v, tol)) if (!best || Math.abs(l.v - v) < Math.abs(best.v - v)) best = l;
1397
+ return best;
1398
+ };
1399
+ let nx = sx;
1400
+ let ny = sy;
1401
+ let snap = { hx: null, hy: null, on: null };
1402
+ if (corner && !proportional) {
1403
+ const p = applyMatrix(toDoc, { x: origin.x + d0.x * sx, y: origin.y + d0.y * sy });
1404
+ const hx = nearest(p.x, this.xs);
1405
+ const hy = nearest(p.y, this.ys);
1406
+ const on = this.nearestPoint(p, tol);
1407
+ const tries = [];
1408
+ if (on) tries.push({ tx: on.p.x, ty: on.p.y, s: { hx: null, hy: null, on } });
1409
+ tries.push({ tx: hx?.v ?? null, ty: hy?.v ?? null, s: { hx, hy, on: null } });
1410
+ tries.push({ tx: hx?.v ?? null, ty: null, s: { hx, hy: null, on: null } });
1411
+ tries.push({ tx: null, ty: hy?.v ?? null, s: { hx: null, hy, on: null } });
1412
+ for (const { tx, ty, s } of tries) {
1413
+ if (tx === null && ty === null) continue;
1414
+ const q = applyMatrix(toLocal, { x: tx ?? p.x, y: ty ?? p.y });
1415
+ let cx = (q.x - origin.x) / d0.x;
1416
+ let cy = (q.y - origin.y) / d0.y;
1417
+ if (!frame.angle) {
1418
+ if (tx === null) cx = sx;
1419
+ if (ty === null) cy = sy;
1420
+ }
1421
+ if (valid(cx, sx) && valid(cy, sy)) {
1422
+ nx = cx;
1423
+ ny = cy;
1424
+ snap = s;
1425
+ break;
1426
+ }
1427
+ }
1428
+ } else {
1429
+ const movesX2 = handle === "e" || handle === "w";
1430
+ let e;
1431
+ let c;
1432
+ let s0;
1433
+ if (corner) [e, c, s0] = [{ x: Math.sign(sx), y: Math.sign(sy) }, { x: 0, y: 0 }, Math.abs(sx)];
1434
+ 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];
1435
+ 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];
1436
+ const i = RING.indexOf(handle);
1437
+ const ends = corner ? [] : [RING[(i + 1) % 8], RING[(i + 7) % 8]];
1438
+ const travel = Math.hypot(e.x * d0.x, e.y * d0.y);
1439
+ let best = null;
1440
+ const consider = (s, inLine, found2) => {
1441
+ const moved = Math.abs(s - s0) * travel;
1442
+ if (!valid(s, s0) || moved > 2 * tol) return;
1443
+ if (!best || (inLine === best.inLine ? moved < best.moved : !inLine)) best = { s, inLine, moved, snap: found2 };
1444
+ };
1445
+ for (const h of [handle, ...ends]) {
1446
+ const hv = handlePos(h, frame);
1447
+ const v = { x: hv.x - origin.x, y: hv.y - origin.y };
1448
+ const base = applyMatrix(toDoc, { x: origin.x + c.x * v.x, y: origin.y + c.y * v.y });
1449
+ const dir = applyMatrix(toDoc, { x: e.x * v.x, y: e.y * v.y });
1450
+ const p = { x: base.x + dir.x * s0, y: base.y + dir.y * s0 };
1451
+ const l2 = dir.x * dir.x + dir.y * dir.y;
1452
+ const on = l2 > 1e-18 ? this.nearestPoint(p, tol) : null;
1453
+ if (on) consider(((on.p.x - base.x) * dir.x + (on.p.y - base.y) * dir.y) / l2, false, { hx: null, hy: null, on });
1454
+ if (Math.abs(dir.x) > 1e-9) {
1455
+ const hit = nearest(p.x, this.xs);
1456
+ if (hit) consider((hit.v - base.x) / dir.x, true, { hx: hit, hy: null, on: null });
1457
+ }
1458
+ if (Math.abs(dir.y) > 1e-9) {
1459
+ const hit = nearest(p.y, this.ys);
1460
+ if (hit) consider((hit.v - base.y) / dir.y, true, { hx: null, hy: hit, on: null });
1461
+ }
1462
+ }
1463
+ const found = best;
1464
+ if (found) {
1465
+ nx = c.x + found.s * e.x;
1466
+ ny = c.y + found.s * e.y;
1467
+ snap = found.snap;
1468
+ }
1469
+ }
1470
+ const out = { sx: nx, sy: ny, snapped: false, lines: [], labels: [] };
1471
+ const handleAt = () => applyMatrix(toDoc, { x: origin.x + d0.x * out.sx, y: origin.y + d0.y * out.sy });
1472
+ if (snap.on) {
1473
+ out.snapped = true;
1474
+ out.labels.push(this.label(snap.on.p, snap.on.kind));
1475
+ return out;
1476
+ }
1477
+ const movesX = handle !== "n" && handle !== "s";
1478
+ const movesY = handle !== "e" && handle !== "w";
1479
+ const sized = [];
1480
+ if (!proportional) {
1481
+ if (movesX && !snap.hx && !(frame.angle && snap.hy) && frame.width > 1e-9) {
1482
+ const len = this.matchSize(Math.abs(out.sx) * frame.width, tol);
1483
+ if (len !== null && len > 0) {
1484
+ out.sx = Math.sign(out.sx || 1) * (len / frame.width);
1485
+ sized.push("x");
1486
+ }
1487
+ }
1488
+ if (movesY && !snap.hy && !(frame.angle && snap.hx) && frame.height > 1e-9) {
1489
+ const len = this.matchSize(Math.abs(out.sy) * frame.height, tol);
1490
+ if (len !== null && len > 0) {
1491
+ out.sy = Math.sign(out.sy || 1) * (len / frame.height);
1492
+ sized.push("y");
1493
+ }
1494
+ }
1495
+ }
1496
+ const x0 = origin.x + (frame.x - origin.x) * out.sx;
1497
+ const x1 = origin.x + (frame.x + frame.width - origin.x) * out.sx;
1498
+ const y0 = origin.y + (frame.y - origin.y) * out.sy;
1499
+ const y1 = origin.y + (frame.y + frame.height - origin.y) * out.sy;
1500
+ const box = { x0: Math.min(x0, x1), x1: Math.max(x0, x1), y0: Math.min(y0, y1), y1: Math.max(y0, y1) };
1501
+ const page = [
1502
+ { x: box.x0, y: box.y0 },
1503
+ { x: box.x1, y: box.y0 },
1504
+ { x: box.x1, y: box.y1 },
1505
+ { x: box.x0, y: box.y1 }
1506
+ ].map((q) => applyMatrix(toDoc, q));
1507
+ const pminY = Math.min(...page.map((q) => q.y));
1508
+ const pmaxY = Math.max(...page.map((q) => q.y));
1509
+ const pminX = Math.min(...page.map((q) => q.x));
1510
+ const pmaxX = Math.max(...page.map((q) => q.x));
1511
+ 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) });
1512
+ 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 });
1513
+ if (snap.hx || snap.hy) {
1514
+ out.snapped = true;
1515
+ out.labels.push(this.label(handleAt(), "align"));
1516
+ }
1517
+ for (const axis of sized) {
1518
+ out.snapped = true;
1519
+ const off = tol * 2;
1520
+ 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 }];
1521
+ const pa = applyMatrix(toDoc, a);
1522
+ const pb = applyMatrix(toDoc, b);
1523
+ out.lines.push(line("size", pa, pb));
1524
+ 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)}`));
1525
+ }
1526
+ return out;
1527
+ }
1528
+ };
1529
+ var RING = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
1530
+ function opposite(h) {
1531
+ return RING[(RING.indexOf(h) + 4) % 8];
1532
+ }
1533
+ function handlePos(h, r) {
1534
+ const x = h.includes("w") ? r.x : h.includes("e") ? r.x + r.width : r.x + r.width / 2;
1535
+ const y = h.startsWith("n") ? r.y : h.startsWith("s") ? r.y + r.height : r.y + r.height / 2;
1536
+ return { x, y };
1537
+ }
1538
+ function line(kind, a, b) {
1539
+ return { kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y };
1540
+ }
1541
+ function construction(leave, p, tol) {
1542
+ const v = { x: p.x - leave.at.x, y: p.y - leave.at.y };
1543
+ if (Math.hypot(v.x, v.y) <= tol) return null;
1544
+ let best = null;
1545
+ for (const a of leave.angles ?? []) {
1546
+ const r = a * Math.PI / 180;
1547
+ const exact = (n) => Math.abs(n) < 1e-12 ? 0 : n;
1548
+ const dir = { x: exact(Math.cos(r)), y: exact(-Math.sin(r)) };
1549
+ const k = v.x * dir.x + v.y * dir.y;
1550
+ const point = { x: leave.at.x + dir.x * k, y: leave.at.y + dir.y * k };
1551
+ const d = distance(point, p);
1552
+ if (d <= tol && (!best || d < best.d)) best = { dir, point, d };
1553
+ }
1554
+ return best && { dir: best.dir, point: best.point };
1555
+ }
1556
+ var dotCondition = (r, d) => r[0] * d[0] + r[1] * d[1];
1557
+ var crossCondition = (r, d) => r[0] * d[1] - r[1] * d[0];
1558
+ function solveOnSegment(seg, base, cond) {
1559
+ const b = [base.x, base.y];
1560
+ const g = (t) => {
1561
+ const p = segmentPoint(seg, t);
1562
+ return cond([p[0] - b[0], p[1] - b[1]], segmentDerivative(seg, t));
1563
+ };
1564
+ const vals = [];
1565
+ let scale = 0;
1566
+ for (let i = 0; i <= CURVE_SAMPLES; i++) {
1567
+ const t = i / CURVE_SAMPLES;
1568
+ const p = segmentPoint(seg, t);
1569
+ const d = segmentDerivative(seg, t);
1570
+ const r = [p[0] - b[0], p[1] - b[1]];
1571
+ vals.push(cond(r, d));
1572
+ scale = Math.max(scale, Math.hypot(r[0], r[1]) * Math.hypot(d[0], d[1]));
1573
+ }
1574
+ if (!(scale > 0) || vals.every((v) => Math.abs(v) <= scale * 1e-12)) return [];
1575
+ const out = [];
1576
+ for (let i = 0; i < CURVE_SAMPLES; i++) {
1577
+ const ga = vals[i];
1578
+ const gb = vals[i + 1];
1579
+ if (!(Number.isFinite(ga) && Number.isFinite(gb)) || ga > 0 === gb > 0 && ga !== 0) continue;
1580
+ let lo = i / CURVE_SAMPLES;
1581
+ let hi = (i + 1) / CURVE_SAMPLES;
1582
+ let flo = ga;
1583
+ for (let k = 0; k < BISECT; k++) {
1584
+ const m = (lo + hi) / 2;
1585
+ const fm = g(m);
1586
+ if (fm === 0) {
1587
+ lo = hi = m;
1588
+ break;
1589
+ }
1590
+ if (fm > 0 === flo > 0) {
1591
+ lo = m;
1592
+ flo = fm;
1593
+ } else hi = m;
1594
+ }
1595
+ const t = (lo + hi) / 2;
1596
+ const p = segmentPoint(seg, t);
1597
+ const dv = segmentDerivative(seg, t);
1598
+ const r = Math.hypot(p[0] - b[0], p[1] - b[1]);
1599
+ const dl = Math.hypot(dv[0], dv[1]);
1600
+ if (!(r > 1e-9 && dl > 0) || Math.abs(cond([p[0] - b[0], p[1] - b[1]], dv)) > 1e-6 * r * dl) continue;
1601
+ 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] });
1602
+ }
1603
+ return out;
1604
+ }
1605
+
853
1606
  // src/vector/controller.ts
1607
+ var DEFAULT_VECTOR_SNAP = { grid: 0, objects: true, guides: true, threshold: 5, angles: VECTOR_CONSTRUCTION_ANGLES };
854
1608
  var VECTOR_CLIPBOARD_TYPE = "pixerate/vector";
855
1609
  var DEFAULT_DEFAULTS = {
856
1610
  fill: "#d4d4d8",
@@ -1062,6 +1816,11 @@ var VectorEditorController = class {
1062
1816
  pointer = null;
1063
1817
  drag = null;
1064
1818
  interaction = null;
1819
+ guides = null;
1820
+ /** Whether the last `snapDrawn` landed on an object (guides may be hidden). */
1821
+ snappedToObject = false;
1822
+ /** Drawing targets for hovering the pen and shape tools, kept while the document and the excluded path stay the same. */
1823
+ hoverTargets = null;
1065
1824
  spaceHeld = false;
1066
1825
  clipboard = null;
1067
1826
  createId;
@@ -1079,7 +1838,7 @@ var VectorEditorController = class {
1079
1838
  this.tool = options.tool ?? "select";
1080
1839
  this.defaults = { ...DEFAULT_DEFAULTS, ...options.defaults ?? {} };
1081
1840
  this.viewport = { x: 0, y: 0, zoom: 1, ...options.viewport ?? {} };
1082
- this.snap = { grid: 0, ...options.snap ?? {} };
1841
+ this.snap = { ...DEFAULT_VECTOR_SNAP, ...options.snap ?? {} };
1083
1842
  this.hitRadius = options.hitRadius ?? 6;
1084
1843
  this.createId = options.createId ?? defaultCreateId;
1085
1844
  this.onChange = options.onChange;
@@ -1128,6 +1887,7 @@ var VectorEditorController = class {
1128
1887
  viewport: this.viewport,
1129
1888
  defaults: this.defaults,
1130
1889
  snap: this.snap,
1890
+ guides: this.guides,
1131
1891
  hitRadius: this.hitRadius,
1132
1892
  hover: this.hover,
1133
1893
  cursor: this.cursorFor(),
@@ -1312,6 +2072,7 @@ var VectorEditorController = class {
1312
2072
  this.cancelDrag();
1313
2073
  this.tool = tool;
1314
2074
  this.hover = null;
2075
+ this.guides = null;
1315
2076
  if (tool !== "direct") {
1316
2077
  this.anchors = [];
1317
2078
  this.editingId = null;
@@ -1324,18 +2085,74 @@ var VectorEditorController = class {
1324
2085
  }
1325
2086
  setSnap(snap) {
1326
2087
  this.snap = { ...this.snap, ...snap };
2088
+ if (!this.snap.guides || !this.snap.objects) this.guides = null;
1327
2089
  this.notify();
1328
2090
  }
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 };
2091
+ /** Turns object snapping on or off (the toolbar's snapping toggle). */
2092
+ toggleSnapping(on = !this.snap.objects) {
2093
+ this.setSnap({ objects: on });
1333
2094
  }
1334
- snapDelta(dx, dy, origin) {
2095
+ gridValue(v) {
1335
2096
  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 };
2097
+ return g > 0 ? Math.round(v / g) * g : v;
2098
+ }
2099
+ snapPoint(p) {
2100
+ return { x: this.gridValue(p.x), y: this.gridValue(p.y) };
2101
+ }
2102
+ /** The pull distance in document units. */
2103
+ snapTolerance() {
2104
+ return this.snap.threshold / this.viewport.zoom;
2105
+ }
2106
+ setGuides(guides) {
2107
+ this.guides = guides && this.snap.guides && (guides.lines.length || guides.labels.length) ? guides : null;
2108
+ }
2109
+ /**
2110
+ * Where a drawn or dragged point `p` goes: onto the smart guide `targets` (gathered by
2111
+ * `gather` when missing), else onto the grid, kept to `leave`'s constraint. Cmd / Ctrl
2112
+ * bypasses snapping.
2113
+ */
2114
+ snapDrawn(p, leave, input, targets) {
2115
+ this.snappedToObject = false;
2116
+ if (input.mod) {
2117
+ this.setGuides(null);
2118
+ return constrainToLeave(leave, p);
2119
+ }
2120
+ if (this.snap.objects) {
2121
+ const withAngles = leave ? { ...leave, angles: this.snap.angles } : null;
2122
+ const res = targets().snapFrom(p, withAngles, this.snapTolerance());
2123
+ if (res.kind) {
2124
+ this.snappedToObject = true;
2125
+ this.setGuides(res);
2126
+ 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) };
2127
+ }
2128
+ }
2129
+ this.setGuides(null);
2130
+ return constrainToLeave(leave, this.snapPoint(p));
2131
+ }
2132
+ /** Drawing targets for the pen and shape tools, reused while the document stays the same. */
2133
+ drawingTargets(exclude) {
2134
+ const key = exclude ?? "";
2135
+ const cached = this.hoverTargets;
2136
+ if (cached && cached.doc === this.doc && cached.exclude === key) return cached.targets;
2137
+ const targets = VectorSnapTargets.forDrawing(this.doc, exclude ? [exclude] : []);
2138
+ this.hoverTargets = { doc: this.doc, exclude: key, targets };
2139
+ return targets;
2140
+ }
2141
+ /** Where the pointer at `p` would place a point with the pen or a shape tool (and the guides showing why). */
2142
+ hoverSnap(p, input) {
2143
+ if (this.tool === "pen") {
2144
+ const sp = this.pen ? this.getNode(this.pen.nodeId)?.subpaths[this.pen.subpath] : void 0;
2145
+ if (this.pen && sp && this.hover?.anchor) {
2146
+ this.setGuides(null);
2147
+ return p;
2148
+ }
2149
+ const last = this.pen && sp ? getAnchorWorld(this.doc, { nodeId: this.pen.nodeId, subpath: this.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
2150
+ const leave = last ? { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null } : null;
2151
+ return this.snapDrawn(p, leave, input, () => this.drawingTargets(this.pen?.nodeId ?? null));
2152
+ }
2153
+ if (this.tool !== "select" && this.tool !== "direct" && this.tool !== "pencil") return this.snapDrawn(p, null, input, () => this.drawingTargets(null));
2154
+ this.setGuides(null);
2155
+ return p;
1339
2156
  }
1340
2157
  // -------------------------------------------------------------------------
1341
2158
  // Document
@@ -2251,7 +3068,7 @@ var VectorEditorController = class {
2251
3068
  case "pencil":
2252
3069
  return this.downPencil(p);
2253
3070
  default:
2254
- return this.downShape(p);
3071
+ return this.downShape(p, input);
2255
3072
  }
2256
3073
  }
2257
3074
  downSelect(screen, p, input) {
@@ -2320,7 +3137,7 @@ var VectorEditorController = class {
2320
3137
  return;
2321
3138
  }
2322
3139
  } 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 };
3140
+ this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: selected && !input.shift ? anchor : null, lead: anchor };
2324
3141
  this.notify();
2325
3142
  return;
2326
3143
  }
@@ -2336,7 +3153,7 @@ var VectorEditorController = class {
2336
3153
  const ref = { nodeId: id, subpath: seg.subpath, index };
2337
3154
  this.anchors = [ref];
2338
3155
  this.editingId = id;
2339
- this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null };
3156
+ this.drag = { kind: "anchors", start: p, before, base: this.doc, moved: true, clickRef: null, lead: ref };
2340
3157
  this.notify(true);
2341
3158
  return;
2342
3159
  }
@@ -2344,7 +3161,7 @@ var VectorEditorController = class {
2344
3161
  const b = { nodeId: id, subpath: seg.subpath, index: (seg.segment + 1) % sp.anchors.length };
2345
3162
  this.anchors = input.shift ? [...this.anchors.filter((r) => !sameRef(r, a) && !sameRef(r, b)), a, b] : [a, b];
2346
3163
  this.editingId = id;
2347
- this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null };
3164
+ this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
2348
3165
  this.notify();
2349
3166
  return;
2350
3167
  }
@@ -2354,7 +3171,7 @@ var VectorEditorController = class {
2354
3171
  if (!input.shift) this.selection = [path.id];
2355
3172
  else if (!this.selection.includes(path.id)) this.selection = [...this.selection, path.id];
2356
3173
  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 };
3174
+ this.drag = { kind: "anchors", start: p, before: this.snapshot(), base: this.doc, moved: false, clickRef: null, lead: null };
2358
3175
  this.notify();
2359
3176
  return;
2360
3177
  }
@@ -2379,9 +3196,9 @@ var VectorEditorController = class {
2379
3196
  this.notify(true);
2380
3197
  return;
2381
3198
  }
2382
- let pt2 = this.snapPoint(p);
2383
3199
  const last = getAnchorWorld(this.doc, { nodeId: pen.nodeId, subpath: pen.subpath, index: sp.anchors.length - 1 });
2384
- if (input.shift) pt2 = constrain45(last, pt2);
3200
+ const leave = { at: { x: last.x, y: last.y }, constrain: input.shift ? "angle" : null };
3201
+ let pt2 = this.snapDrawn(p, leave, input, () => this.drawingTargets(pen.nodeId));
2385
3202
  pt2 = this.toLocal(pen.nodeId, pt2);
2386
3203
  const node2 = this.getNode(pen.nodeId);
2387
3204
  const anchors = [...sp.anchors, { x: pt2.x, y: pt2.y }];
@@ -2412,7 +3229,7 @@ var VectorEditorController = class {
2412
3229
  this.notify(this.doc !== before.doc);
2413
3230
  return;
2414
3231
  }
2415
- const pt = this.snapPoint(p);
3232
+ const pt = this.snapDrawn(p, null, input, () => this.drawingTargets(null));
2416
3233
  const node = { id: this.newId(), type: "path", name: "Path", ...this.styleFor(true), subpaths: [{ anchors: [{ x: pt.x, y: pt.y }], closed: false }] };
2417
3234
  this.setDoc([...this.doc.nodes, node]);
2418
3235
  this.pen = { nodeId: node.id, subpath: 0 };
@@ -2440,13 +3257,14 @@ var VectorEditorController = class {
2440
3257
  this.interaction = { type: "draw", tool: "pencil", nodeId: node.id, bounds: { x: p.x, y: p.y, width: 0, height: 0 } };
2441
3258
  this.notify(true);
2442
3259
  }
2443
- downShape(p) {
3260
+ downShape(p, input) {
2444
3261
  const before = this.snapshot();
2445
- const start = this.snapPoint(p);
3262
+ const targets = this.drawingTargets(null);
3263
+ const start = this.snapDrawn(p, null, input, () => targets);
2446
3264
  const kind = this.tool;
2447
3265
  const node = this.createShapeNode({ kind, x: start.x, y: start.y, width: 0, height: 0 });
2448
3266
  this.setDoc([...this.doc.nodes, node]);
2449
- this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before };
3267
+ this.drag = { kind: "shape", tool: this.tool, start, nodeId: node.id, before, targets };
2450
3268
  this.interaction = { type: "draw", tool: this.tool, nodeId: node.id, bounds: { x: start.x, y: start.y, width: 0, height: 0 } };
2451
3269
  this.notify(true);
2452
3270
  }
@@ -2457,9 +3275,11 @@ var VectorEditorController = class {
2457
3275
  if (!drag) {
2458
3276
  const hover = this.computeHover(screen);
2459
3277
  const pointerMatters = this.tool === "pen" && !!this.pen;
2460
- if (this.sameHover(hover, this.hover) && !pointerMatters) return;
3278
+ const guides = this.guides;
2461
3279
  this.hover = hover;
2462
- this.pointer = p;
3280
+ const pointer = this.hoverSnap(p, input);
3281
+ if (this.sameHover(hover, this.state.hover) && !pointerMatters && JSON.stringify(guides) === JSON.stringify(this.guides)) return;
3282
+ this.pointer = pointer;
2463
3283
  this.notify();
2464
3284
  return;
2465
3285
  }
@@ -2480,8 +3300,9 @@ var VectorEditorController = class {
2480
3300
  const created = [];
2481
3301
  drag.base = this.withDuplicates(drag.base, this.selectedRoots(), { x: 0, y: 0 }, created);
2482
3302
  this.selection = created;
3303
+ drag.targets = void 0;
2483
3304
  }
2484
- ({ dx, dy } = this.snapDelta(dx, dy, drag.origin));
3305
+ ({ dx, dy } = this.snapMove(drag, dx, dy, input));
2485
3306
  this.doc = this.transformed(drag.base, new Set(this.selectedRoots()), translateMatrix(dx, dy));
2486
3307
  this.interaction = { type: "move", dx, dy };
2487
3308
  this.notify(true);
@@ -2490,7 +3311,13 @@ var VectorEditorController = class {
2490
3311
  case "scale": {
2491
3312
  const angle = drag.frame.angle;
2492
3313
  const q = angle ? applyMatrix(rotateMatrix(-angle), p) : p;
2493
- const { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
3314
+ let { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
3315
+ if (this.snap.objects && !input.mod && drag.handle !== "rotate") {
3316
+ drag.targets ??= VectorSnapTargets.forResize(drag.base, this.selectedRoots());
3317
+ const res = drag.targets.snapScale(drag.frame, drag.handle, sx, sy, input, this.snapTolerance());
3318
+ ({ sx, sy } = res);
3319
+ this.setGuides(res);
3320
+ } else this.setGuides(null);
2494
3321
  const ids = new Set(this.selectedRoots());
2495
3322
  this.doc = this.transformed(drag.base, ids, this.inFrame(angle, scaleMatrix(sx, sy, origin)));
2496
3323
  this.interaction = { type: "scale", handle: drag.handle, sx, sy };
@@ -2531,9 +3358,13 @@ var VectorEditorController = class {
2531
3358
  let dy = p.y - drag.start.y;
2532
3359
  if (!drag.moved && Math.hypot(dx, dy) * this.viewport.zoom < 2) return;
2533
3360
  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));
3361
+ const lead = this.leadAnchor(drag);
3362
+ if (lead) {
3363
+ const leave = input.shift ? { at: lead, constrain: "axis", curves: false } : null;
3364
+ const q = this.snapDrawn({ x: lead.x + dx, y: lead.y + dy }, leave, input, () => drag.targets ??= VectorSnapTargets.forAnchorDrag(drag.base, this.anchors));
3365
+ dx = q.x - lead.x;
3366
+ dy = q.y - lead.y;
3367
+ } else if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
2537
3368
  this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
2538
3369
  this.interaction = { type: "anchors", dx, dy };
2539
3370
  this.notify(true);
@@ -2541,8 +3372,8 @@ var VectorEditorController = class {
2541
3372
  }
2542
3373
  case "handle": {
2543
3374
  const anchor = getAnchorWorld(drag.base, drag.ref);
2544
- let pt = this.snapPoint(p);
2545
- if (input.shift && anchor) pt = constrain45(anchor, pt);
3375
+ const leave = anchor ? { at: { x: anchor.x, y: anchor.y }, constrain: input.shift ? "angle" : null } : null;
3376
+ const pt = this.snapDrawn(p, leave, input, () => drag.targets ??= VectorSnapTargets.forHandle(drag.base, drag.ref.nodeId));
2546
3377
  const local = this.toLocal(drag.ref.nodeId, pt, drag.base);
2547
3378
  this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, local, input.alt));
2548
3379
  this.interaction = { type: "handle", ref: drag.ref };
@@ -2557,6 +3388,7 @@ var VectorEditorController = class {
2557
3388
  this.notify(true);
2558
3389
  return;
2559
3390
  }
3391
+ this.guides = null;
2560
3392
  let pt = p;
2561
3393
  if (input.shift) pt = constrain45(anchor, pt);
2562
3394
  const side = drag.closing ? "in" : "out";
@@ -2571,7 +3403,9 @@ var VectorEditorController = class {
2571
3403
  case "shape": {
2572
3404
  const node = this.getNode(drag.nodeId);
2573
3405
  if (node?.type !== "path") return;
2574
- let end = this.snapPoint(p);
3406
+ const leave = { at: drag.start, constrain: input.shift ? drag.tool === "line" ? "angle" : "square" : null, curves: false };
3407
+ let end = this.snapDrawn(p, leave, input, () => drag.targets ??= this.drawingTargets(null));
3408
+ 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
3409
  let start = drag.start;
2576
3410
  let bounds;
2577
3411
  if (drag.tool === "line") {
@@ -2617,12 +3451,64 @@ var VectorEditorController = class {
2617
3451
  }
2618
3452
  }
2619
3453
  }
3454
+ /** Snaps a move of the selection by (dx, dy): its bounds line up with targets or space evenly, else go to the grid. */
3455
+ snapMove(drag, dx, dy, input) {
3456
+ const origin = drag.origin;
3457
+ if (input.mod || !origin) {
3458
+ this.setGuides(null);
3459
+ return { dx, dy };
3460
+ }
3461
+ const axes = { x: !input.shift || dx !== 0, y: !input.shift || dy !== 0 };
3462
+ let res = null;
3463
+ if (this.snap.objects) {
3464
+ drag.targets ??= VectorSnapTargets.forMove(drag.base, this.selectedRoots());
3465
+ res = drag.targets.snapRect({ ...origin, x: origin.x + dx, y: origin.y + dy }, this.snapTolerance(), { spacing: true, axes });
3466
+ }
3467
+ this.setGuides(res);
3468
+ return {
3469
+ dx: res?.x ? dx + res.dx : axes.x ? this.gridValue(origin.x + dx) - origin.x : dx,
3470
+ dy: res?.y ? dy + res.dy : axes.y ? this.gridValue(origin.y + dy) - origin.y : dy
3471
+ };
3472
+ }
3473
+ /** The anchor a point drag snaps by, where it started: the grabbed one, else the moving one nearest the press. */
3474
+ leadAnchor(drag) {
3475
+ const ref = drag.lead && this.anchors.some((a) => sameRef(a, drag.lead)) ? drag.lead : null;
3476
+ if (ref) {
3477
+ const a = getAnchorWorld(drag.base, ref);
3478
+ return a ? { x: a.x, y: a.y } : null;
3479
+ }
3480
+ let best = null;
3481
+ for (const r of this.anchors) {
3482
+ const a = getAnchorWorld(drag.base, r);
3483
+ 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 };
3484
+ }
3485
+ return best;
3486
+ }
3487
+ /** A shape's dragged corner moved so its width or height matches another object's, when one is within reach. */
3488
+ matchShapeSize(targets, start, end, fromCentre = false) {
3489
+ const k = fromCentre ? 2 : 1;
3490
+ const tol = this.snapTolerance() * k;
3491
+ const out = { ...end };
3492
+ const guides = { lines: [], labels: [] };
3493
+ for (const axis of ["x", "y"]) {
3494
+ const d = end[axis] - start[axis];
3495
+ const len = Math.abs(d) > 1e-9 ? targets.matchSize(Math.abs(d) * k, tol) : null;
3496
+ if (len === null || len <= 0) continue;
3497
+ out[axis] = start[axis] + Math.sign(d) * len / k;
3498
+ const from = { ...out, [axis]: fromCentre ? 2 * start[axis] - out[axis] : start[axis] };
3499
+ guides.lines.push({ kind: "size", x1: from.x, y1: from.y, x2: out.x, y2: out.y });
3500
+ 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}` });
3501
+ }
3502
+ this.setGuides(guides);
3503
+ return out;
3504
+ }
2620
3505
  /** Primary button released. */
2621
3506
  pointerUp(screen, input = {}) {
2622
3507
  if (screen && this.drag && this.drag.kind !== "pan") this.pointerMove(screen, input);
2623
3508
  const drag = this.drag;
2624
3509
  this.drag = null;
2625
3510
  this.interaction = null;
3511
+ this.guides = null;
2626
3512
  if (!drag) return;
2627
3513
  switch (drag.kind) {
2628
3514
  case "pan":
@@ -2691,9 +3577,10 @@ var VectorEditorController = class {
2691
3577
  /** The pointer left the canvas. */
2692
3578
  pointerLeave() {
2693
3579
  if (this.drag) return;
2694
- if (!this.hover && !this.pointer) return;
3580
+ if (!this.hover && !this.pointer && !this.guides) return;
2695
3581
  this.hover = null;
2696
3582
  this.pointer = null;
3583
+ this.guides = null;
2697
3584
  this.notify();
2698
3585
  }
2699
3586
  /** Abandons the drag in progress (Escape, lost capture). */
@@ -2702,6 +3589,7 @@ var VectorEditorController = class {
2702
3589
  if (!drag) return;
2703
3590
  this.drag = null;
2704
3591
  this.interaction = null;
3592
+ this.guides = null;
2705
3593
  if (drag.kind === "pan") {
2706
3594
  this.notify();
2707
3595
  return;
@@ -3017,10 +3905,24 @@ function getVectorOverlayRenderData(state) {
3017
3905
  const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionFrame;
3018
3906
  if (b) status = `W ${fmt(b.width)} \xD7 H ${fmt(b.height)}`;
3019
3907
  }
3020
- return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, status };
3908
+ const guides = { lines: [], labels: [] };
3909
+ if (state.guides) {
3910
+ for (const l of state.guides.lines) {
3911
+ const a = toScreen(state, { x: l.x1, y: l.y1 });
3912
+ const b = toScreen(state, { x: l.x2, y: l.y2 });
3913
+ guides.lines.push({ kind: l.kind, x1: fmt(a.x), y1: fmt(a.y), x2: fmt(b.x), y2: fmt(b.y) });
3914
+ }
3915
+ for (const l of state.guides.labels) {
3916
+ const p = toScreen(state, l);
3917
+ guides.labels.push({ kind: l.kind, x: fmt(p.x), y: fmt(p.y), text: l.text });
3918
+ }
3919
+ }
3920
+ return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, guides, status };
3021
3921
  }
3022
3922
  var VECTOR_OVERLAY_THEME = {
3023
3923
  accent: "#0ea5e9",
3924
+ /** Smart guide lines and labels. */
3925
+ guide: "#ff3dfc",
3024
3926
  handleFill: "#ffffff",
3025
3927
  marqueeFill: "rgba(14, 165, 233, 0.08)",
3026
3928
  transformHandleSize: 8,
@@ -3382,9 +4284,11 @@ function createVectorCommands(controller, options = {}) {
3382
4284
  ];
3383
4285
  }
3384
4286
  export {
4287
+ DEFAULT_VECTOR_SNAP,
3385
4288
  IDENTITY_MATRIX,
3386
4289
  KAPPA,
3387
4290
  VECTOR_CLIPBOARD_TYPE,
4291
+ VECTOR_CONSTRUCTION_ANGLES,
3388
4292
  VECTOR_OVERLAY_THEME,
3389
4293
  VECTOR_ROTATE_HANDLE_OFFSET,
3390
4294
  VECTOR_TOOLS,
@@ -3392,6 +4296,7 @@ export {
3392
4296
  VECTOR_TOOL_SHORTCUTS,
3393
4297
  VECTOR_TRANSFORM_HANDLES,
3394
4298
  VectorEditorController,
4299
+ VectorSnapTargets,
3395
4300
  applyMatrix,
3396
4301
  applyMatrixLinear,
3397
4302
  bezierToSubpaths,
@@ -3402,6 +4307,7 @@ export {
3402
4307
  cloneVectorDocument,
3403
4308
  cloneVectorNode,
3404
4309
  collectPathNodes,
4310
+ constrainToLeave,
3405
4311
  convertAnchor,
3406
4312
  createVectorCommands,
3407
4313
  decomposeRigid,