@pixerate/editor 0.32.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"];
@@ -189,9 +191,20 @@ function collectPathNodes(node) {
189
191
  });
190
192
  return out;
191
193
  }
192
- function vectorNodeBounds(node) {
193
- if (node.type === "group") return unionBounds(node.children.map(vectorNodeBounds));
194
- const b = bezierPathBounds(subpathsToBezier(node.subpaths));
194
+ function nodeMatrix(node) {
195
+ return node.transform ?? IDENTITY_MATRIX;
196
+ }
197
+ function vectorPathWorldBezier(node, parent = IDENTITY_MATRIX) {
198
+ const m = multiplyMatrices(nodeMatrix(node), parent);
199
+ const path = subpathsToBezier(node.subpaths);
200
+ return isIdentityMatrix(m) ? path : transformBezierPath(path, m);
201
+ }
202
+ function vectorNodeBounds(node, parent = IDENTITY_MATRIX) {
203
+ if (node.type === "group") {
204
+ const m = multiplyMatrices(nodeMatrix(node), parent);
205
+ return unionBounds(node.children.map((c) => vectorNodeBounds(c, m)));
206
+ }
207
+ const b = bezierPathBounds(vectorPathWorldBezier(node, parent));
195
208
  if (!b) return null;
196
209
  return { x: b.minX, y: b.minY, width: b.maxX - b.minX, height: b.maxY - b.minY };
197
210
  }
@@ -216,10 +229,13 @@ function boundsIntersect(a, b) {
216
229
  function boundsFromPoints(a, b) {
217
230
  return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) };
218
231
  }
219
- function hitTestVectorNode(node, p, tolerance = 0) {
232
+ function hitTestVectorNode(node, p, tolerance = 0, parent = IDENTITY_MATRIX) {
220
233
  if (node.visible === false) return false;
221
- if (node.type === "group") return node.children.some((c) => hitTestVectorNode(c, p, tolerance));
222
- const path = subpathsToBezier(node.subpaths);
234
+ if (node.type === "group") {
235
+ const m = multiplyMatrices(nodeMatrix(node), parent);
236
+ return node.children.some((c) => hitTestVectorNode(c, p, tolerance, m));
237
+ }
238
+ const path = vectorPathWorldBezier(node, parent);
223
239
  if (path.length === 0) return false;
224
240
  const point = [p.x, p.y];
225
241
  if (hasPaint(node.fill) && pointInBezierPath(path, point, node.fillRule ?? "nonzero")) return true;
@@ -268,7 +284,38 @@ function rotateMatrix(degrees, origin = { x: 0, y: 0 }) {
268
284
  function applyMatrix(m, p) {
269
285
  return { x: m[0] * p.x + m[2] * p.y + m[4], y: m[1] * p.x + m[3] * p.y + m[5] };
270
286
  }
271
- function transformVectorNode(node, m) {
287
+ function isIdentityMatrix(m, eps = 1e-12) {
288
+ return Math.abs(m[0] - 1) < eps && Math.abs(m[1]) < eps && Math.abs(m[2]) < eps && Math.abs(m[3] - 1) < eps && Math.abs(m[4]) < eps && Math.abs(m[5]) < eps;
289
+ }
290
+ function invertMatrix(m) {
291
+ const [a, b, c, d, e, f] = m;
292
+ const det = a * d - b * c;
293
+ if (!det || !Number.isFinite(det)) return IDENTITY_MATRIX;
294
+ return [d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det];
295
+ }
296
+ function applyMatrixLinear(m, v) {
297
+ return { x: m[0] * v.x + m[2] * v.y, y: m[1] * v.x + m[3] * v.y };
298
+ }
299
+ function matrixRotation(m) {
300
+ const [a, b, c, d] = m;
301
+ const mirrored = a * d - b * c < 0;
302
+ const deg = (mirrored ? Math.atan2(-b - c, d - a) : Math.atan2(b - c, a + d)) * 180 / Math.PI;
303
+ return Math.abs(deg) < 1e-9 ? 0 : deg;
304
+ }
305
+ function decomposeRigid(m) {
306
+ const [a, b, c, d, e, f] = m;
307
+ const deg = matrixRotation(m);
308
+ if (deg === 0) return { rigid: IDENTITY_MATRIX, residual: m };
309
+ const r = deg * Math.PI / 180;
310
+ const cos = Math.cos(r);
311
+ const sin = Math.sin(r);
312
+ return {
313
+ rigid: [cos, sin, -sin, cos, e, f],
314
+ residual: [cos * a + sin * b, -sin * a + cos * b, cos * c + sin * d, -sin * c + cos * d, 0, 0]
315
+ };
316
+ }
317
+ function bakeIntoGeometry(node, m) {
318
+ if (isIdentityMatrix(m)) return node;
272
319
  if (node.type === "group") return { ...node, children: node.children.map((c) => transformVectorNode(c, m)) };
273
320
  const path = node;
274
321
  return {
@@ -286,6 +333,36 @@ function transformVectorNode(node, m) {
286
333
  }))
287
334
  };
288
335
  }
336
+ function transformVectorNode(node, m) {
337
+ const total = multiplyMatrices(nodeMatrix(node), m);
338
+ const { rigid, residual } = decomposeRigid(total);
339
+ const { transform: _old, ...rest } = node;
340
+ const baked = bakeIntoGeometry(rest, residual);
341
+ return isIdentityMatrix(rigid) ? baked : { ...baked, transform: rigid };
342
+ }
343
+ function vectorNodeRotation(node, parent = IDENTITY_MATRIX) {
344
+ return matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
345
+ }
346
+ function vectorWorldMatrix(nodes, id) {
347
+ const search = (list, parent) => {
348
+ for (const n of list) {
349
+ const m = multiplyMatrices(nodeMatrix(n), parent);
350
+ if (n.id === id) return m;
351
+ if (n.type === "group") {
352
+ const found = search(n.children, m);
353
+ if (found) return found;
354
+ }
355
+ }
356
+ return null;
357
+ };
358
+ return search(nodes, IDENTITY_MATRIX);
359
+ }
360
+ function vectorParentMatrix(nodes, id) {
361
+ const node = findVectorNode(nodes, id);
362
+ const world = vectorWorldMatrix(nodes, id);
363
+ if (!node || !world) return null;
364
+ return multiplyMatrices(invertMatrix(nodeMatrix(node)), world);
365
+ }
289
366
  function translateAnchor(a, dx, dy) {
290
367
  const next = cloneAnchor(a);
291
368
  next.x += dx;
@@ -478,6 +555,7 @@ function vectorDocumentToSvg(doc, options = {}) {
478
555
  const indent = (depth) => pretty ? " ".repeat(depth) : "";
479
556
  const common = (node) => {
480
557
  let out2 = ` id="${escapeAttr(node.id)}"`;
558
+ if (node.transform) out2 += ` transform="matrix(${node.transform.map((n) => formatNumber(n, 6)).join(" ")})"`;
481
559
  if (node.opacity !== void 0 && node.opacity < 1) out2 += ` opacity="${num(node.opacity)}"`;
482
560
  if (node.visible === false) out2 += ` display="none"`;
483
561
  return out2;
@@ -774,7 +852,759 @@ function parseVectorSvg(svg, options = {}) {
774
852
  return { width, height, nodes };
775
853
  }
776
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
+
777
1606
  // src/vector/controller.ts
1607
+ var DEFAULT_VECTOR_SNAP = { grid: 0, objects: true, guides: true, threshold: 5, angles: VECTOR_CONSTRUCTION_ANGLES };
778
1608
  var VECTOR_CLIPBOARD_TYPE = "pixerate/vector";
779
1609
  var DEFAULT_DEFAULTS = {
780
1610
  fill: "#d4d4d8",
@@ -855,6 +1685,24 @@ function getAnchor(doc, ref) {
855
1685
  const node = findVectorNode(doc.nodes, ref.nodeId);
856
1686
  return node?.type === "path" ? node.subpaths[ref.subpath]?.anchors[ref.index] : void 0;
857
1687
  }
1688
+ function getAnchorWorld(doc, ref) {
1689
+ const a = getAnchor(doc, ref);
1690
+ if (!a) return void 0;
1691
+ const m = vectorWorldMatrix(doc.nodes, ref.nodeId) ?? IDENTITY_MATRIX;
1692
+ const p = applyMatrix(m, a);
1693
+ return { x: p.x, y: p.y, ...a.in ? { in: applyMatrix(m, a.in) } : {}, ...a.out ? { out: applyMatrix(m, a.out) } : {}, ...a.type ? { type: a.type } : {} };
1694
+ }
1695
+ function getVectorSelectionFrame(doc, ids) {
1696
+ const nodes = ids.map((id) => ({ node: findVectorNode(doc.nodes, id), parent: vectorParentMatrix(doc.nodes, id) ?? IDENTITY_MATRIX })).filter((n) => n.node);
1697
+ if (!nodes.length) return null;
1698
+ const angles = nodes.map((n) => matrixRotation(multiplyMatrices(nodeMatrix(n.node), n.parent)));
1699
+ const same = angles.every((a) => Math.abs(normalizeAngle(a - angles[0])) < 1e-6);
1700
+ const angle = same ? angles[0] : 0;
1701
+ const unrotate = rotateMatrix(-angle);
1702
+ const b = unionBounds(nodes.map((n) => vectorNodeBounds(n.node, multiplyMatrices(n.parent, unrotate))));
1703
+ return b ? { ...b, angle } : null;
1704
+ }
1705
+ var normalizeAngle = (a) => ((a + 180) % 360 + 360) % 360 - 180;
858
1706
  function getTransformHandlePositions(box) {
859
1707
  const { x, y, width: w, height: h } = box;
860
1708
  return [
@@ -869,17 +1717,28 @@ function getTransformHandlePositions(box) {
869
1717
  { handle: "rotate", x: x + w / 2, y: y - VECTOR_ROTATE_HANDLE_OFFSET }
870
1718
  ];
871
1719
  }
872
- var RESIZE_CURSORS = {
873
- nw: "nwse-resize",
874
- se: "nwse-resize",
875
- ne: "nesw-resize",
876
- sw: "nesw-resize",
877
- n: "ns-resize",
878
- s: "ns-resize",
879
- e: "ew-resize",
880
- w: "ew-resize",
881
- rotate: "grab"
882
- };
1720
+ function getVectorSelectionBoxGeometry(frame, viewport) {
1721
+ const z = viewport.zoom;
1722
+ const box = { x: frame.x * z + viewport.x, y: frame.y * z + viewport.y, width: frame.width * z, height: frame.height * z };
1723
+ const rot = rotateMatrix(frame.angle, { x: viewport.x, y: viewport.y });
1724
+ const toScreen2 = (p) => frame.angle ? applyMatrix(rot, p) : { x: p.x, y: p.y };
1725
+ const handles = getTransformHandlePositions(box).map((h) => ({ ...h, screen: toScreen2(h) }));
1726
+ const corners = [
1727
+ { x: box.x, y: box.y },
1728
+ { x: box.x + box.width, y: box.y },
1729
+ { x: box.x + box.width, y: box.y + box.height },
1730
+ { x: box.x, y: box.y + box.height }
1731
+ ].map(toScreen2);
1732
+ const r = (n) => Math.round(n * 1e3) / 1e3;
1733
+ return { box, angle: frame.angle, transform: frame.angle ? `rotate(${r(frame.angle)} ${r(viewport.x)} ${r(viewport.y)})` : "", handles, corners };
1734
+ }
1735
+ var RESIZE_CURSOR_ORDER = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
1736
+ var HANDLE_ANGLES = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
1737
+ function vectorResizeCursor(handle, angle = 0) {
1738
+ if (handle === "rotate") return "grab";
1739
+ const a = ((HANDLE_ANGLES[handle] + angle) % 180 + 180) % 180;
1740
+ return RESIZE_CURSOR_ORDER[Math.round(a / 45) % 4];
1741
+ }
883
1742
  var TOOL_LABELS = {
884
1743
  select: "Select",
885
1744
  direct: "Direct Select",
@@ -938,6 +1797,7 @@ function mapTree(nodes, fn) {
938
1797
  }
939
1798
  return changed ? out : nodes;
940
1799
  }
1800
+ var isRoot = (m) => m === IDENTITY_MATRIX || m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
941
1801
  var idCounter = 0;
942
1802
  var defaultCreateId = () => `vn-${Date.now().toString(36)}${(++idCounter).toString(36)}`;
943
1803
  var VectorEditorController = class {
@@ -956,6 +1816,11 @@ var VectorEditorController = class {
956
1816
  pointer = null;
957
1817
  drag = null;
958
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;
959
1824
  spaceHeld = false;
960
1825
  clipboard = null;
961
1826
  createId;
@@ -973,7 +1838,7 @@ var VectorEditorController = class {
973
1838
  this.tool = options.tool ?? "select";
974
1839
  this.defaults = { ...DEFAULT_DEFAULTS, ...options.defaults ?? {} };
975
1840
  this.viewport = { x: 0, y: 0, zoom: 1, ...options.viewport ?? {} };
976
- this.snap = { grid: 0, ...options.snap ?? {} };
1841
+ this.snap = { ...DEFAULT_VECTOR_SNAP, ...options.snap ?? {} };
977
1842
  this.hitRadius = options.hitRadius ?? 6;
978
1843
  this.createId = options.createId ?? defaultCreateId;
979
1844
  this.onChange = options.onChange;
@@ -1018,9 +1883,11 @@ var VectorEditorController = class {
1018
1883
  selectedAnchors: this.anchors,
1019
1884
  editingId: this.editingId,
1020
1885
  selectionBounds: this.boundsOf(this.selection),
1886
+ selectionFrame: this.frameOf(this.selectedRoots()),
1021
1887
  viewport: this.viewport,
1022
1888
  defaults: this.defaults,
1023
1889
  snap: this.snap,
1890
+ guides: this.guides,
1024
1891
  hitRadius: this.hitRadius,
1025
1892
  hover: this.hover,
1026
1893
  cursor: this.cursorFor(),
@@ -1048,7 +1915,23 @@ var VectorEditorController = class {
1048
1915
  });
1049
1916
  }
1050
1917
  boundsOf(ids) {
1051
- return unionBounds(ids.map((id) => this.getNode(id)).map((n) => n ? vectorNodeBounds(n) : null));
1918
+ return unionBounds(
1919
+ ids.map((id) => {
1920
+ const n = this.getNode(id);
1921
+ return n ? vectorNodeBounds(n, vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX) : null;
1922
+ })
1923
+ );
1924
+ }
1925
+ frameOf(ids) {
1926
+ return getVectorSelectionFrame(this.doc, ids);
1927
+ }
1928
+ /** Local → document matrix of a node in `doc` (default: the current document). */
1929
+ worldOf(id, doc = this.doc) {
1930
+ return vectorWorldMatrix(doc.nodes, id) ?? IDENTITY_MATRIX;
1931
+ }
1932
+ /** A document point in node `id`'s own space. */
1933
+ toLocal(id, p, doc = this.doc) {
1934
+ return applyMatrix(invertMatrix(this.worldOf(id, doc)), p);
1052
1935
  }
1053
1936
  cursorFor() {
1054
1937
  if (this.drag?.kind === "pan" || this.spaceHeld) return this.drag ? "grabbing" : "grab";
@@ -1057,7 +1940,7 @@ var VectorEditorController = class {
1057
1940
  const h = this.hover;
1058
1941
  switch (this.tool) {
1059
1942
  case "select":
1060
- if (h?.transformHandle) return RESIZE_CURSORS[h.transformHandle];
1943
+ if (h?.transformHandle) return vectorResizeCursor(h.transformHandle, this.frameOf(this.selectedRoots())?.angle ?? 0);
1061
1944
  return h?.nodeId ? "move" : "default";
1062
1945
  case "direct":
1063
1946
  if (h?.anchor || h?.handle) return "pointer";
@@ -1189,6 +2072,7 @@ var VectorEditorController = class {
1189
2072
  this.cancelDrag();
1190
2073
  this.tool = tool;
1191
2074
  this.hover = null;
2075
+ this.guides = null;
1192
2076
  if (tool !== "direct") {
1193
2077
  this.anchors = [];
1194
2078
  this.editingId = null;
@@ -1201,18 +2085,74 @@ var VectorEditorController = class {
1201
2085
  }
1202
2086
  setSnap(snap) {
1203
2087
  this.snap = { ...this.snap, ...snap };
2088
+ if (!this.snap.guides || !this.snap.objects) this.guides = null;
1204
2089
  this.notify();
1205
2090
  }
1206
- snapPoint(p) {
1207
- const g = this.snap.grid;
1208
- if (!g || g <= 0) return p;
1209
- 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 });
1210
2094
  }
1211
- snapDelta(dx, dy, origin) {
2095
+ gridValue(v) {
1212
2096
  const g = this.snap.grid;
1213
- if (!g || g <= 0 || !origin) return { dx, dy };
1214
- const target = this.snapPoint({ x: origin.x + dx, y: origin.y + dy });
1215
- 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;
1216
2156
  }
1217
2157
  // -------------------------------------------------------------------------
1218
2158
  // Document
@@ -1454,6 +2394,11 @@ var VectorEditorController = class {
1454
2394
  // -------------------------------------------------------------------------
1455
2395
  // Transforms
1456
2396
  // -------------------------------------------------------------------------
2397
+ /** `m`, given in a frame rotated by `angle`, as a document-space matrix. */
2398
+ inFrame(angle, m) {
2399
+ if (!angle) return m;
2400
+ return multiplyMatrices(multiplyMatrices(rotateMatrix(-angle), m), rotateMatrix(angle));
2401
+ }
1457
2402
  /** Applies a matrix to the selected (or given) nodes as one undo step. */
1458
2403
  transform(matrix, options = {}) {
1459
2404
  const ids = new Set(options.ids ?? this.selectedRoots());
@@ -1463,8 +2408,25 @@ var VectorEditorController = class {
1463
2408
  });
1464
2409
  return { ok: true };
1465
2410
  }
2411
+ /** `doc` with nodes `ids` moved by `m`, given in document space. */
1466
2412
  transformed(doc, ids, m) {
1467
- const nodes = mapTree(doc.nodes, (n) => ids.has(n.id) && !n.locked ? transformVectorNode(n, m) : n);
2413
+ const visit = (list, parent) => {
2414
+ let changed = false;
2415
+ const out = list.map((n) => {
2416
+ let next = n;
2417
+ if (ids.has(n.id) && !n.locked) {
2418
+ const local = isRoot(parent) ? m : multiplyMatrices(multiplyMatrices(parent, m), invertMatrix(parent));
2419
+ next = transformVectorNode(n, local);
2420
+ } else if (n.type === "group") {
2421
+ const children = visit(n.children, multiplyMatrices(nodeMatrix(n), parent));
2422
+ if (children !== n.children) next = { ...n, children };
2423
+ }
2424
+ if (next !== n) changed = true;
2425
+ return next;
2426
+ });
2427
+ return changed ? out : list;
2428
+ };
2429
+ const nodes = visit(doc.nodes, IDENTITY_MATRIX);
1468
2430
  return nodes === doc.nodes ? doc : { ...doc, nodes };
1469
2431
  }
1470
2432
  translate(dx, dy, options = {}) {
@@ -1476,26 +2438,48 @@ var VectorEditorController = class {
1476
2438
  }
1477
2439
  return this.transform(translateMatrix(dx, dy), { ids: options.ids, label: "Move" });
1478
2440
  }
1479
- /** Scales around `origin` (default: the selection centre). */
2441
+ /** The selection frame's centre in document space. */
2442
+ frameCenter(frame) {
2443
+ return applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
2444
+ }
2445
+ /**
2446
+ * Scales along the selection box's own axes (rotated with the selection when its nodes
2447
+ * share a rotation) around `origin` (default: the box centre, in document space).
2448
+ */
1480
2449
  scale(sx, sy = sx, options = {}) {
1481
- const b = this.boundsOf(options.ids ?? this.selectedRoots());
1482
- if (!b) return { ok: false, message: "Nothing is selected." };
1483
- const origin = options.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 };
1484
- return this.transform(scaleMatrix(sx, sy, origin), { ids: options.ids, label: "Scale" });
2450
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
2451
+ if (!frame) return { ok: false, message: "Nothing is selected." };
2452
+ const origin = applyMatrix(rotateMatrix(-frame.angle), options.origin ?? this.frameCenter(frame));
2453
+ return this.transform(this.inFrame(frame.angle, scaleMatrix(sx, sy, origin)), { ids: options.ids, label: "Scale" });
1485
2454
  }
1486
2455
  /** Rotates clockwise by `degrees` around `origin` (default: the selection centre). */
1487
2456
  rotate(degrees, options = {}) {
1488
- const b = this.boundsOf(options.ids ?? this.selectedRoots());
1489
- if (!b) return { ok: false, message: "Nothing is selected." };
1490
- const origin = options.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 };
2457
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
2458
+ if (!frame) return { ok: false, message: "Nothing is selected." };
2459
+ const origin = options.origin ?? this.frameCenter(frame);
1491
2460
  return this.transform(rotateMatrix(degrees, origin), { ids: options.ids, label: "Rotate" });
1492
2461
  }
2462
+ /** Sets each selected (or given) node's rotation to `degrees`, turning it around its own centre. */
2463
+ setRotation(degrees, options = {}) {
2464
+ const ids = options.ids ?? this.selectedRoots();
2465
+ if (!ids.length) return { ok: false, message: "Nothing is selected." };
2466
+ this.change("Set Rotation", () => {
2467
+ for (const id of ids) {
2468
+ const frame = this.frameOf([id]);
2469
+ if (!frame) continue;
2470
+ const delta = normalizeAngle(degrees - frame.angle);
2471
+ if (Math.abs(delta) > 1e-9) this.doc = this.transformed(this.doc, /* @__PURE__ */ new Set([id]), rotateMatrix(delta, this.frameCenter(frame)));
2472
+ }
2473
+ });
2474
+ return { ok: true };
2475
+ }
2476
+ /** Mirrors along the selection box's own axes. */
1493
2477
  flip(axis, options = {}) {
1494
- const b = this.boundsOf(options.ids ?? this.selectedRoots());
1495
- if (!b) return { ok: false, message: "Nothing is selected." };
1496
- const c = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
2478
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
2479
+ if (!frame) return { ok: false, message: "Nothing is selected." };
2480
+ const c = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
1497
2481
  const m = axis === "horizontal" ? scaleMatrix(-1, 1, c) : scaleMatrix(1, -1, c);
1498
- return this.transform(m, { ids: options.ids, label: axis === "horizontal" ? "Flip Horizontal" : "Flip Vertical" });
2482
+ return this.transform(this.inFrame(frame.angle, m), { ids: options.ids, label: axis === "horizontal" ? "Flip Horizontal" : "Flip Vertical" });
1499
2483
  }
1500
2484
  /**
1501
2485
  * Aligns the selected nodes to their combined bounds, or to the artboard when one node is
@@ -1620,7 +2604,10 @@ var VectorEditorController = class {
1620
2604
  if (set.has(n.id) && n.type === "group") {
1621
2605
  changed = true;
1622
2606
  const opacity = n.opacity;
1623
- out.push(...n.children.map((c) => opacity !== void 0 && opacity < 1 ? { ...c, opacity: (c.opacity ?? 1) * opacity } : c));
2607
+ const m = nodeMatrix(n);
2608
+ out.push(
2609
+ ...n.children.map((c) => n.transform ? transformVectorNode(c, m) : c).map((c) => opacity !== void 0 && opacity < 1 ? { ...c, opacity: (c.opacity ?? 1) * opacity } : c)
2610
+ );
1624
2611
  } else if (n.type === "group") {
1625
2612
  const children = flatten(n.children);
1626
2613
  if (children !== n.children) changed = true;
@@ -1668,15 +2655,22 @@ var VectorEditorController = class {
1668
2655
  if (paths.length < 2) return null;
1669
2656
  const result = pathBoolean(
1670
2657
  BOOLEAN_OPS[op],
1671
- paths.map((p) => ({ path: subpathsToBezier(p.subpaths.filter((s) => s.anchors.length > 1).map((s) => ({ ...s, closed: true }))), fillRule: p.fillRule ?? "nonzero" }))
2658
+ paths.map((p) => ({
2659
+ path: vectorPathWorldBezier(
2660
+ { ...p, subpaths: p.subpaths.filter((s) => s.anchors.length > 1).map((s) => ({ ...s, closed: true })) },
2661
+ vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX
2662
+ ),
2663
+ fillRule: p.fillRule ?? "nonzero"
2664
+ }))
1672
2665
  );
1673
- const subpaths = bezierToSubpaths(result.path);
1674
- if (result.error || !subpaths.length) return null;
1675
2666
  const base = paths[0];
2667
+ const anchorRoot = roots.find((r) => findVectorNode([this.getNode(r)], base.id)) ?? base.id;
2668
+ const subpaths = bezierToSubpaths(this.intoParentSpace(anchorRoot, result.path));
2669
+ if (result.error || !subpaths.length) return null;
1676
2670
  const id = this.newId();
1677
- const node = { ...cloneVectorNode(base), id, name: BOOLEAN_LABELS[op], subpaths };
2671
+ const { transform: _t, ...baseRest } = cloneVectorNode(base);
2672
+ const node = { ...baseRest, id, name: BOOLEAN_LABELS[op], subpaths };
1678
2673
  const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
1679
- const anchorRoot = roots.find((r) => findVectorNode([this.getNode(r)], base.id)) ?? base.id;
1680
2674
  this.change(BOOLEAN_LABELS[op], () => {
1681
2675
  this.setDoc(mapTree(this.doc.nodes, (n) => n.id === anchorRoot ? node : drop.has(n.id) ? null : n));
1682
2676
  this.selection = [id];
@@ -1694,9 +2688,11 @@ var VectorEditorController = class {
1694
2688
  });
1695
2689
  if (paths.length < 2) return null;
1696
2690
  const id = this.newId();
1697
- const node = { ...cloneVectorNode(paths[0]), id, name: "Compound Path", subpaths: paths.flatMap((p) => cloneVectorNode(p).subpaths) };
1698
- const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
1699
2691
  const first = roots.find((r) => findVectorNode([this.getNode(r)], paths[0].id)) ?? paths[0].id;
2692
+ const subpaths = paths.flatMap((p) => bezierToSubpaths(this.intoParentSpace(first, vectorPathWorldBezier(p, vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX))));
2693
+ const { transform: _t, ...baseRest } = cloneVectorNode(paths[0]);
2694
+ const node = { ...baseRest, id, name: "Compound Path", subpaths };
2695
+ const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
1700
2696
  this.change("Combine", () => {
1701
2697
  this.setDoc(mapTree(this.doc.nodes, (n) => n.id === first ? node : drop.has(n.id) ? null : n));
1702
2698
  this.selection = [id];
@@ -1704,6 +2700,11 @@ var VectorEditorController = class {
1704
2700
  });
1705
2701
  return id;
1706
2702
  }
2703
+ /** A document-space path mapped into the parent space of node `id`. */
2704
+ intoParentSpace(id, path) {
2705
+ const parent = vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX;
2706
+ return isRoot(parent) ? path : transformBezierPath(path, invertMatrix(parent));
2707
+ }
1707
2708
  /** Splits selected compound paths into one path per subpath. */
1708
2709
  breakApart(options = {}) {
1709
2710
  const paths = (options.ids ?? this.selection).map((id) => this.getNode(id)).filter((n) => n?.type === "path" && n.subpaths.length > 1);
@@ -1743,23 +2744,30 @@ var VectorEditorController = class {
1743
2744
  });
1744
2745
  return nodes === doc.nodes ? doc : { ...doc, nodes };
1745
2746
  }
2747
+ /** Moves anchors by a document-space offset (converted into each node's own space). */
1746
2748
  movedAnchors(doc, refs, dx, dy) {
1747
- return this.updateAnchors(doc, refs, (a) => translateAnchor(a, dx, dy));
2749
+ const local = /* @__PURE__ */ new Map();
2750
+ for (const r of refs) if (!local.has(r.nodeId)) local.set(r.nodeId, applyMatrixLinear(invertMatrix(this.worldOf(r.nodeId, doc)), { x: dx, y: dy }));
2751
+ return this.updateAnchors(doc, refs, (a, ref) => {
2752
+ const d = local.get(ref.nodeId);
2753
+ return translateAnchor(a, d.x, d.y);
2754
+ });
1748
2755
  }
1749
- /** Moves one anchor to a position (handles follow). */
2756
+ /** Moves one anchor to a document position (handles follow). */
1750
2757
  moveAnchor(ref, to) {
1751
- const a = getAnchor(this.doc, ref);
2758
+ const a = getAnchorWorld(this.doc, ref);
1752
2759
  if (!a) return { ok: false, message: "There is no such anchor." };
1753
2760
  this.change("Move Point", () => {
1754
2761
  this.doc = this.movedAnchors(this.doc, [ref], to.x - a.x, to.y - a.y);
1755
2762
  });
1756
2763
  return { ok: true };
1757
2764
  }
1758
- /** Moves one handle (see `moveAnchorHandle` for how the opposite handle follows). */
2765
+ /** Moves one handle to a document position (see `moveAnchorHandle` for how the opposite handle follows). */
1759
2766
  moveHandle(ref, to, options = {}) {
1760
2767
  if (!getAnchor(this.doc, ref)) return { ok: false, message: "There is no such anchor." };
2768
+ const local = this.toLocal(ref.nodeId, to);
1761
2769
  this.change("Move Handle", () => {
1762
- this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle, to, options.breakHandles));
2770
+ this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle, local, options.breakHandles));
1763
2771
  });
1764
2772
  return { ok: true };
1765
2773
  }
@@ -1778,11 +2786,11 @@ var VectorEditorController = class {
1778
2786
  });
1779
2787
  return { ok: true };
1780
2788
  }
1781
- /** Adds an anchor on the nearest segment of `nodeId` to `point` (shape unchanged). */
2789
+ /** Adds an anchor on the nearest segment of `nodeId` to document point `point` (shape unchanged). */
1782
2790
  insertAnchorAt(nodeId, point) {
1783
2791
  const node = this.getNode(nodeId);
1784
2792
  if (node?.type !== "path") return null;
1785
- const hit = nearestSegment(node, point);
2793
+ const hit = nearestSegment(node, this.toLocal(nodeId, point));
1786
2794
  if (!hit) return null;
1787
2795
  const { subpath, index } = insertAnchor(node.subpaths[hit.subpath], hit.segment, hit.t);
1788
2796
  const ref = { nodeId, subpath: hit.subpath, index };
@@ -1939,19 +2947,17 @@ var VectorEditorController = class {
1939
2947
  }
1940
2948
  hitTransformHandle(screen) {
1941
2949
  if (this.tool !== "select" || !this.selection.length) return null;
1942
- const b = this.boundsOf(this.selectedRoots());
1943
- if (!b) return null;
1944
- const tl = this.documentToScreen(b);
1945
- const box = { x: tl.x, y: tl.y, width: b.width * this.viewport.zoom, height: b.height * this.viewport.zoom };
2950
+ const frame = this.frameOf(this.selectedRoots());
2951
+ if (!frame) return null;
2952
+ const { box, handles } = getVectorSelectionBoxGeometry(frame, this.viewport);
1946
2953
  const r = this.hitRadius + 2;
1947
- const handles = getTransformHandlePositions(box);
1948
2954
  const usable = box.width < 2 * r || box.height < 2 * r ? handles.filter((h) => h.handle.length === 2 || h.handle === "rotate") : handles;
1949
- for (const h of usable) if (Math.abs(h.x - screen.x) <= r && Math.abs(h.y - screen.y) <= r) return h.handle;
2955
+ for (const h of usable) if (Math.hypot(h.screen.x - screen.x, h.screen.y - screen.y) <= r * Math.SQRT2) return h.handle;
1950
2956
  return null;
1951
2957
  }
1952
2958
  hitHandle(screen) {
1953
2959
  for (const ref of getVisibleHandles(this.view()).reverse()) {
1954
- const a = getAnchor(this.doc, ref);
2960
+ const a = getAnchorWorld(this.doc, ref);
1955
2961
  const h = a?.[ref.handle];
1956
2962
  if (!h) continue;
1957
2963
  const s = this.documentToScreen(h);
@@ -1963,7 +2969,7 @@ var VectorEditorController = class {
1963
2969
  let best = null;
1964
2970
  let bestD = Infinity;
1965
2971
  for (const ref of refs) {
1966
- const a = getAnchor(this.doc, ref);
2972
+ const a = getAnchorWorld(this.doc, ref);
1967
2973
  if (!a) continue;
1968
2974
  const s = this.documentToScreen(a);
1969
2975
  const d = Math.hypot(s.x - screen.x, s.y - screen.y);
@@ -1977,18 +2983,18 @@ var VectorEditorController = class {
1977
2983
  /** Deepest unlocked visible path under `p`. */
1978
2984
  hitPath(p) {
1979
2985
  const tol = this.tolerance();
1980
- const search = (list) => {
2986
+ const search = (list, parent) => {
1981
2987
  for (let i = list.length - 1; i >= 0; i--) {
1982
2988
  const n = list[i];
1983
2989
  if (n.visible === false || n.locked) continue;
1984
2990
  if (n.type === "group") {
1985
- const found = search(n.children);
2991
+ const found = search(n.children, multiplyMatrices(nodeMatrix(n), parent));
1986
2992
  if (found) return found;
1987
- } else if (hitTestVectorNode(n, p, tol)) return n;
2993
+ } else if (hitTestVectorNode(n, p, tol, parent)) return n;
1988
2994
  }
1989
2995
  return null;
1990
2996
  };
1991
- return search(this.doc.nodes);
2997
+ return search(this.doc.nodes, IDENTITY_MATRIX);
1992
2998
  }
1993
2999
  /** Endpoints of open subpaths of unlocked visible paths (pen tool resumes from them). */
1994
3000
  openEndpoints() {
@@ -2019,8 +3025,11 @@ var VectorEditorController = class {
2019
3025
  if (anchor) return { nodeId: anchor.nodeId, anchor };
2020
3026
  for (const id of getEditablePathIds(this.view())) {
2021
3027
  const node = this.getNode(id);
2022
- const seg = nearestSegment(node, p);
2023
- if (seg && seg.distance <= this.tolerance()) return { nodeId: id, segment: { nodeId: id, subpath: seg.subpath, segment: seg.segment, t: seg.t, point: seg.point } };
3028
+ const seg = nearestSegment(node, this.toLocal(id, p));
3029
+ if (seg && seg.distance <= this.tolerance()) {
3030
+ const point = applyMatrix(this.worldOf(id), seg.point);
3031
+ return { nodeId: id, segment: { nodeId: id, subpath: seg.subpath, segment: seg.segment, t: seg.t, point } };
3032
+ }
2024
3033
  }
2025
3034
  const path = this.hitPath(p);
2026
3035
  return path ? { nodeId: path.id } : null;
@@ -2059,15 +3068,16 @@ var VectorEditorController = class {
2059
3068
  case "pencil":
2060
3069
  return this.downPencil(p);
2061
3070
  default:
2062
- return this.downShape(p);
3071
+ return this.downShape(p, input);
2063
3072
  }
2064
3073
  }
2065
3074
  downSelect(screen, p, input) {
2066
3075
  const handle = this.hitTransformHandle(screen);
2067
3076
  if (handle) {
2068
- const bounds = this.boundsOf(this.selectedRoots());
3077
+ const frame = this.frameOf(this.selectedRoots());
2069
3078
  const before = this.snapshot();
2070
- this.drag = handle === "rotate" ? { kind: "rotate", start: p, center: { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }, before, base: this.doc } : { kind: "scale", handle, start: p, bounds, before, base: this.doc };
3079
+ const center = applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
3080
+ this.drag = handle === "rotate" ? { kind: "rotate", start: p, center, before, base: this.doc } : { kind: "scale", handle, start: p, frame, before, base: this.doc };
2071
3081
  this.interaction = handle === "rotate" ? { type: "rotate", angle: 0 } : { type: "scale", handle, sx: 1, sy: 1 };
2072
3082
  this.notify();
2073
3083
  return;
@@ -2127,13 +3137,13 @@ var VectorEditorController = class {
2127
3137
  return;
2128
3138
  }
2129
3139
  } else if (!selected) this.anchors = [anchor];
2130
- 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 };
2131
3141
  this.notify();
2132
3142
  return;
2133
3143
  }
2134
3144
  for (const id of getEditablePathIds(this.view())) {
2135
3145
  const node = this.getNode(id);
2136
- const seg = nearestSegment(node, p);
3146
+ const seg = nearestSegment(node, this.toLocal(id, p));
2137
3147
  if (!seg || seg.distance > this.tolerance()) continue;
2138
3148
  const sp = node.subpaths[seg.subpath];
2139
3149
  if (input.alt) {
@@ -2143,7 +3153,7 @@ var VectorEditorController = class {
2143
3153
  const ref = { nodeId: id, subpath: seg.subpath, index };
2144
3154
  this.anchors = [ref];
2145
3155
  this.editingId = id;
2146
- 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 };
2147
3157
  this.notify(true);
2148
3158
  return;
2149
3159
  }
@@ -2151,7 +3161,7 @@ var VectorEditorController = class {
2151
3161
  const b = { nodeId: id, subpath: seg.subpath, index: (seg.segment + 1) % sp.anchors.length };
2152
3162
  this.anchors = input.shift ? [...this.anchors.filter((r) => !sameRef(r, a) && !sameRef(r, b)), a, b] : [a, b];
2153
3163
  this.editingId = id;
2154
- 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 };
2155
3165
  this.notify();
2156
3166
  return;
2157
3167
  }
@@ -2161,7 +3171,7 @@ var VectorEditorController = class {
2161
3171
  if (!input.shift) this.selection = [path.id];
2162
3172
  else if (!this.selection.includes(path.id)) this.selection = [...this.selection, path.id];
2163
3173
  this.anchors = path.subpaths.flatMap((sp, s) => sp.anchors.map((_, i) => ({ nodeId: path.id, subpath: s, index: i })));
2164
- 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 };
2165
3175
  this.notify();
2166
3176
  return;
2167
3177
  }
@@ -2186,9 +3196,10 @@ var VectorEditorController = class {
2186
3196
  this.notify(true);
2187
3197
  return;
2188
3198
  }
2189
- let pt2 = this.snapPoint(p);
2190
- const last = sp.anchors[sp.anchors.length - 1];
2191
- if (input.shift) pt2 = constrain45(last, pt2);
3199
+ const last = getAnchorWorld(this.doc, { nodeId: pen.nodeId, subpath: pen.subpath, index: sp.anchors.length - 1 });
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));
3202
+ pt2 = this.toLocal(pen.nodeId, pt2);
2192
3203
  const node2 = this.getNode(pen.nodeId);
2193
3204
  const anchors = [...sp.anchors, { x: pt2.x, y: pt2.y }];
2194
3205
  this.replaceNode(pen.nodeId, { ...node2, subpaths: node2.subpaths.map((s, i) => i === pen.subpath ? { ...s, anchors } : s) });
@@ -2218,7 +3229,7 @@ var VectorEditorController = class {
2218
3229
  this.notify(this.doc !== before.doc);
2219
3230
  return;
2220
3231
  }
2221
- const pt = this.snapPoint(p);
3232
+ const pt = this.snapDrawn(p, null, input, () => this.drawingTargets(null));
2222
3233
  const node = { id: this.newId(), type: "path", name: "Path", ...this.styleFor(true), subpaths: [{ anchors: [{ x: pt.x, y: pt.y }], closed: false }] };
2223
3234
  this.setDoc([...this.doc.nodes, node]);
2224
3235
  this.pen = { nodeId: node.id, subpath: 0 };
@@ -2246,13 +3257,14 @@ var VectorEditorController = class {
2246
3257
  this.interaction = { type: "draw", tool: "pencil", nodeId: node.id, bounds: { x: p.x, y: p.y, width: 0, height: 0 } };
2247
3258
  this.notify(true);
2248
3259
  }
2249
- downShape(p) {
3260
+ downShape(p, input) {
2250
3261
  const before = this.snapshot();
2251
- const start = this.snapPoint(p);
3262
+ const targets = this.drawingTargets(null);
3263
+ const start = this.snapDrawn(p, null, input, () => targets);
2252
3264
  const kind = this.tool;
2253
3265
  const node = this.createShapeNode({ kind, x: start.x, y: start.y, width: 0, height: 0 });
2254
3266
  this.setDoc([...this.doc.nodes, node]);
2255
- 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 };
2256
3268
  this.interaction = { type: "draw", tool: this.tool, nodeId: node.id, bounds: { x: start.x, y: start.y, width: 0, height: 0 } };
2257
3269
  this.notify(true);
2258
3270
  }
@@ -2263,9 +3275,11 @@ var VectorEditorController = class {
2263
3275
  if (!drag) {
2264
3276
  const hover = this.computeHover(screen);
2265
3277
  const pointerMatters = this.tool === "pen" && !!this.pen;
2266
- if (this.sameHover(hover, this.hover) && !pointerMatters) return;
3278
+ const guides = this.guides;
2267
3279
  this.hover = hover;
2268
- 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;
2269
3283
  this.notify();
2270
3284
  return;
2271
3285
  }
@@ -2286,17 +3300,26 @@ var VectorEditorController = class {
2286
3300
  const created = [];
2287
3301
  drag.base = this.withDuplicates(drag.base, this.selectedRoots(), { x: 0, y: 0 }, created);
2288
3302
  this.selection = created;
3303
+ drag.targets = void 0;
2289
3304
  }
2290
- ({ dx, dy } = this.snapDelta(dx, dy, drag.origin));
3305
+ ({ dx, dy } = this.snapMove(drag, dx, dy, input));
2291
3306
  this.doc = this.transformed(drag.base, new Set(this.selectedRoots()), translateMatrix(dx, dy));
2292
3307
  this.interaction = { type: "move", dx, dy };
2293
3308
  this.notify(true);
2294
3309
  return;
2295
3310
  }
2296
3311
  case "scale": {
2297
- const { sx, sy, origin } = scaleFor(drag.handle, drag.bounds, p, input);
3312
+ const angle = drag.frame.angle;
3313
+ const q = angle ? applyMatrix(rotateMatrix(-angle), p) : p;
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);
2298
3321
  const ids = new Set(this.selectedRoots());
2299
- this.doc = this.transformed(drag.base, ids, scaleMatrix(sx, sy, origin));
3322
+ this.doc = this.transformed(drag.base, ids, this.inFrame(angle, scaleMatrix(sx, sy, origin)));
2300
3323
  this.interaction = { type: "scale", handle: drag.handle, sx, sy };
2301
3324
  this.notify(true);
2302
3325
  return;
@@ -2316,7 +3339,7 @@ var VectorEditorController = class {
2316
3339
  this.interaction = { type: "marquee", bounds };
2317
3340
  if (drag.anchorsMode) {
2318
3341
  const inside = getVisibleAnchors(this.view()).filter((r) => {
2319
- const a = getAnchor(this.doc, r);
3342
+ const a = getAnchorWorld(this.doc, r);
2320
3343
  return a.x >= bounds.x && a.x <= bounds.x + bounds.width && a.y >= bounds.y && a.y <= bounds.y + bounds.height;
2321
3344
  });
2322
3345
  this.anchors = [...drag.baseAnchors, ...inside.filter((r) => !drag.baseAnchors.some((b) => sameRef(b, r)))];
@@ -2335,35 +3358,41 @@ var VectorEditorController = class {
2335
3358
  let dy = p.y - drag.start.y;
2336
3359
  if (!drag.moved && Math.hypot(dx, dy) * this.viewport.zoom < 2) return;
2337
3360
  drag.moved = true;
2338
- if (input.shift) Math.abs(dx) > Math.abs(dy) ? dy = 0 : dx = 0;
2339
- const lead = this.anchors.length ? getAnchor(drag.base, this.anchors[0]) : void 0;
2340
- ({ 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;
2341
3368
  this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
2342
3369
  this.interaction = { type: "anchors", dx, dy };
2343
3370
  this.notify(true);
2344
3371
  return;
2345
3372
  }
2346
3373
  case "handle": {
2347
- const anchor = getAnchor(drag.base, drag.ref);
2348
- let pt = this.snapPoint(p);
2349
- if (input.shift && anchor) pt = constrain45(anchor, pt);
2350
- this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, pt, input.alt));
3374
+ const anchor = getAnchorWorld(drag.base, drag.ref);
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));
3377
+ const local = this.toLocal(drag.ref.nodeId, pt, drag.base);
3378
+ this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, local, input.alt));
2351
3379
  this.interaction = { type: "handle", ref: drag.ref };
2352
3380
  this.notify(true);
2353
3381
  return;
2354
3382
  }
2355
3383
  case "pull": {
2356
- const anchor = getAnchor(drag.base, drag.ref);
3384
+ const anchor = getAnchorWorld(drag.base, drag.ref);
2357
3385
  if (!anchor) return;
2358
3386
  if (Math.hypot(p.x - anchor.x, p.y - anchor.y) * this.viewport.zoom < 2) {
2359
3387
  this.doc = drag.base;
2360
3388
  this.notify(true);
2361
3389
  return;
2362
3390
  }
3391
+ this.guides = null;
2363
3392
  let pt = p;
2364
3393
  if (input.shift) pt = constrain45(anchor, pt);
2365
3394
  const side = drag.closing ? "in" : "out";
2366
- const pos = drag.closing ? { x: 2 * anchor.x - pt.x, y: 2 * anchor.y - pt.y } : pt;
3395
+ const pos = this.toLocal(drag.ref.nodeId, drag.closing ? { x: 2 * anchor.x - pt.x, y: 2 * anchor.y - pt.y } : pt, drag.base);
2367
3396
  this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => {
2368
3397
  if (drag.outOnly || drag.altBreaks && input.alt) return moveAnchorHandle(a, side, pos, true);
2369
3398
  return moveAnchorHandle({ ...a, type: "symmetric" }, side, pos);
@@ -2374,7 +3403,9 @@ var VectorEditorController = class {
2374
3403
  case "shape": {
2375
3404
  const node = this.getNode(drag.nodeId);
2376
3405
  if (node?.type !== "path") return;
2377
- 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);
2378
3409
  let start = drag.start;
2379
3410
  let bounds;
2380
3411
  if (drag.tool === "line") {
@@ -2420,12 +3451,64 @@ var VectorEditorController = class {
2420
3451
  }
2421
3452
  }
2422
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
+ }
2423
3505
  /** Primary button released. */
2424
3506
  pointerUp(screen, input = {}) {
2425
3507
  if (screen && this.drag && this.drag.kind !== "pan") this.pointerMove(screen, input);
2426
3508
  const drag = this.drag;
2427
3509
  this.drag = null;
2428
3510
  this.interaction = null;
3511
+ this.guides = null;
2429
3512
  if (!drag) return;
2430
3513
  switch (drag.kind) {
2431
3514
  case "pan":
@@ -2494,9 +3577,10 @@ var VectorEditorController = class {
2494
3577
  /** The pointer left the canvas. */
2495
3578
  pointerLeave() {
2496
3579
  if (this.drag) return;
2497
- if (!this.hover && !this.pointer) return;
3580
+ if (!this.hover && !this.pointer && !this.guides) return;
2498
3581
  this.hover = null;
2499
3582
  this.pointer = null;
3583
+ this.guides = null;
2500
3584
  this.notify();
2501
3585
  }
2502
3586
  /** Abandons the drag in progress (Escape, lost capture). */
@@ -2505,6 +3589,7 @@ var VectorEditorController = class {
2505
3589
  if (!drag) return;
2506
3590
  this.drag = null;
2507
3591
  this.interaction = null;
3592
+ this.guides = null;
2508
3593
  if (drag.kind === "pan") {
2509
3594
  this.notify();
2510
3595
  return;
@@ -2540,7 +3625,7 @@ var VectorEditorController = class {
2540
3625
  const id = this.hover?.segment?.nodeId ?? this.editingId;
2541
3626
  if (id) {
2542
3627
  const node = this.getNode(id);
2543
- const seg = node ? nearestSegment(node, p) : null;
3628
+ const seg = node ? nearestSegment(node, this.toLocal(id, p)) : null;
2544
3629
  if (seg && seg.distance <= this.tolerance()) this.insertAnchorAt(id, p);
2545
3630
  }
2546
3631
  return;
@@ -2692,11 +3777,12 @@ function scaleFor(handle, b, p, mods = {}) {
2692
3777
 
2693
3778
  // src/vector/render.ts
2694
3779
  var fmt = (n) => Math.round(n * 100) / 100;
3780
+ var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
2695
3781
  function renderNode(node, defs, idPrefix) {
2696
3782
  if (node.visible === false) return null;
2697
3783
  if (node.type === "group") {
2698
3784
  const children = node.children.map((c) => renderNode(c, defs, idPrefix)).filter((c) => !!c);
2699
- return { kind: "group", id: node.id, opacity: node.opacity ?? 1, children };
3785
+ return { kind: "group", id: node.id, ...node.transform ? { transform: fmtMatrix(node.transform) } : {}, opacity: node.opacity ?? 1, children };
2700
3786
  }
2701
3787
  const fill = vectorPaintToSvg(node.fill, `${idPrefix}fill-${node.id}`);
2702
3788
  const stroke = vectorPaintToSvg(node.stroke, `${idPrefix}stroke-${node.id}`);
@@ -2705,6 +3791,7 @@ function renderNode(node, defs, idPrefix) {
2705
3791
  return {
2706
3792
  kind: "path",
2707
3793
  id: node.id,
3794
+ ...node.transform ? { transform: fmtMatrix(node.transform) } : {},
2708
3795
  d: vectorPathData(node),
2709
3796
  fill: fill.value,
2710
3797
  stroke: stroke.value,
@@ -2733,8 +3820,14 @@ function getVectorSceneRenderData(state, options = {}) {
2733
3820
  var toScreen = (state, p) => ({ x: p.x * state.viewport.zoom + state.viewport.x, y: p.y * state.viewport.zoom + state.viewport.y });
2734
3821
  function screenOutline(state, node) {
2735
3822
  const v = state.viewport;
2736
- const paths = node.type === "path" ? [node] : collect(node);
2737
- return paths.map((p) => bezierPathToSvg(transformBezierPath(subpathsToBezier(p.subpaths), [v.zoom, 0, 0, v.zoom, v.x, v.y]), { precision: 2 })).join(" ");
3823
+ const view = [v.zoom, 0, 0, v.zoom, v.x, v.y];
3824
+ const parent = vectorParentMatrix(state.document.nodes, node.id) ?? IDENTITY_MATRIX;
3825
+ return collectWithMatrix(node, parent).map(({ path, parent: m }) => bezierPathToSvg(transformBezierPath(vectorPathWorldBezier(path, m), view), { precision: 2 })).join(" ");
3826
+ }
3827
+ function collectWithMatrix(node, parent) {
3828
+ if (node.type === "path") return [{ path: node, parent }];
3829
+ const m = multiplyMatrices(nodeMatrix(node), parent);
3830
+ return node.children.flatMap((c) => collectWithMatrix(c, m));
2738
3831
  }
2739
3832
  function collect(node) {
2740
3833
  if (node.type === "path") return [node];
@@ -2756,29 +3849,27 @@ function getVectorOverlayRenderData(state) {
2756
3849
  if (state.editingId) outline(state.editingId, true);
2757
3850
  if (state.hover?.nodeId && !state.interaction) outline(state.hover.nodeId, false);
2758
3851
  let selectionBox = null;
2759
- if (state.tool === "select" && state.selectionBounds && state.interaction?.type !== "marquee") {
2760
- const b = state.selectionBounds;
2761
- const tl = toScreen(state, b);
2762
- const box = { x: tl.x, y: tl.y, width: b.width * z, height: b.height * z };
2763
- selectionBox = { ...box, handles: getTransformHandlePositions(box) };
3852
+ if (state.tool === "select" && state.selectionFrame && state.interaction?.type !== "marquee") {
3853
+ const g = getVectorSelectionBoxGeometry(state.selectionFrame, state.viewport);
3854
+ selectionBox = { ...g.box, angle: g.angle, transform: g.transform, corners: g.corners, handles: g.handles };
2764
3855
  }
2765
3856
  const selected = (r) => state.selectedAnchors.some((a) => a.nodeId === r.nodeId && a.subpath === r.subpath && a.index === r.index);
2766
3857
  const sameAnchor = (a, b) => !!a && a.nodeId === b.nodeId && a.subpath === b.subpath && a.index === b.index;
2767
3858
  const anchors = getVisibleAnchors(state).map((ref) => {
2768
- const a = getAnchor(doc, ref);
3859
+ const a = getAnchorWorld(doc, ref);
2769
3860
  const s = toScreen(state, a);
2770
3861
  const closes = !!state.pen && state.pen.nodeId === ref.nodeId && state.pen.subpath === ref.subpath && ref.index === 0;
2771
3862
  return { ref, x: s.x, y: s.y, type: a.type ?? inferAnchorType(a), selected: selected(ref), hovered: sameAnchor(state.hover?.anchor, ref), closes };
2772
3863
  });
2773
3864
  if (state.tool === "pen" && !state.pen && state.hover?.anchor) {
2774
- const a = getAnchor(doc, state.hover.anchor);
3865
+ const a = getAnchorWorld(doc, state.hover.anchor);
2775
3866
  if (a) {
2776
3867
  const s = toScreen(state, a);
2777
3868
  anchors.push({ ref: state.hover.anchor, x: s.x, y: s.y, type: a.type ?? "corner", selected: false, hovered: true, closes: false });
2778
3869
  }
2779
3870
  }
2780
3871
  const handles = getVisibleHandles(state).flatMap((ref) => {
2781
- const a = getAnchor(doc, ref);
3872
+ const a = getAnchorWorld(doc, ref);
2782
3873
  const h = a?.[ref.handle];
2783
3874
  if (!a || !h) return [];
2784
3875
  const s = toScreen(state, h);
@@ -2796,7 +3887,7 @@ function getVectorOverlayRenderData(state) {
2796
3887
  if (state.tool === "pen" && state.pen && state.pointer && !state.interaction) {
2797
3888
  const node = findVectorNode(doc.nodes, state.pen.nodeId);
2798
3889
  const sp = node?.type === "path" ? node.subpaths[state.pen.subpath] : void 0;
2799
- const last = sp?.anchors[sp.anchors.length - 1];
3890
+ const last = sp ? getAnchorWorld(doc, { nodeId: state.pen.nodeId, subpath: state.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
2800
3891
  if (last) {
2801
3892
  const a = toScreen(state, last);
2802
3893
  const p = toScreen(state, state.pointer);
@@ -2811,13 +3902,27 @@ function getVectorOverlayRenderData(state) {
2811
3902
  if (it?.type === "rotate") status = `${Math.round(it.angle)}\xB0`;
2812
3903
  else if (it?.type === "move" || it?.type === "anchors") status = `\u0394 ${fmt(it.dx)}, ${fmt(it.dy)}`;
2813
3904
  else if (it?.type === "scale" || it?.type === "draw") {
2814
- const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionBounds;
3905
+ const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionFrame;
2815
3906
  if (b) status = `W ${fmt(b.width)} \xD7 H ${fmt(b.height)}`;
2816
3907
  }
2817
- 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 };
2818
3921
  }
2819
3922
  var VECTOR_OVERLAY_THEME = {
2820
3923
  accent: "#0ea5e9",
3924
+ /** Smart guide lines and labels. */
3925
+ guide: "#ff3dfc",
2821
3926
  handleFill: "#ffffff",
2822
3927
  marqueeFill: "rgba(14, 165, 233, 0.08)",
2823
3928
  transformHandleSize: 8,
@@ -2908,12 +4013,14 @@ function createVectorCommands(controller, options = {}) {
2908
4013
  const out = [];
2909
4014
  const visit = (nodes, depth) => {
2910
4015
  for (const n of nodes) {
2911
- const b = vectorNodeBounds(n);
4016
+ const parent = vectorParentMatrix(state.document.nodes, n.id) ?? IDENTITY_MATRIX;
4017
+ const b = vectorNodeBounds(n, parent);
2912
4018
  out.push({
2913
4019
  id: n.id,
2914
4020
  type: n.type,
2915
4021
  ...n.name ? { name: n.name } : {},
2916
4022
  depth,
4023
+ rotation: Math.round(vectorNodeRotation(n, parent) * 100) / 100,
2917
4024
  bounds: b && { x: Math.round(b.x * 100) / 100, y: Math.round(b.y * 100) / 100, width: Math.round(b.width * 100) / 100, height: Math.round(b.height * 100) / 100 },
2918
4025
  visible: n.visible !== false,
2919
4026
  locked: !!n.locked,
@@ -3015,10 +4122,18 @@ function createVectorCommands(controller, options = {}) {
3015
4122
  id: `${prefix}transform`,
3016
4123
  label: "Transform\u2026",
3017
4124
  keywords: ["move", "translate", "scale", "resize", "rotate", "flip"],
3018
- description: "Moves (dx, dy), scales (sx, sy) and/or rotates (degrees clockwise) nodes around their combined centre, in that order.",
4125
+ description: "Moves (dx, dy), scales (sx, sy, along the selection box axes) and/or rotates (`rotate`: by degrees clockwise around the combined centre; `angle`: to an absolute rotation, each node around its own centre), in that order.",
3019
4126
  params: {
3020
4127
  type: "object",
3021
- properties: { ids: IDS, dx: { type: "number" }, dy: { type: "number" }, sx: { type: "number" }, sy: { type: "number" }, rotate: { type: "number" } }
4128
+ properties: {
4129
+ ids: IDS,
4130
+ dx: { type: "number" },
4131
+ dy: { type: "number" },
4132
+ sx: { type: "number" },
4133
+ sy: { type: "number" },
4134
+ rotate: { type: "number" },
4135
+ angle: { type: "number" }
4136
+ }
3022
4137
  },
3023
4138
  enabled: enabledFor,
3024
4139
  undoable: true,
@@ -3027,6 +4142,7 @@ function createVectorCommands(controller, options = {}) {
3027
4142
  if (params.dx || params.dy) check(controller.translate(params.dx ?? 0, params.dy ?? 0, { ids: target }));
3028
4143
  if (params.sx !== void 0 || params.sy !== void 0) check(controller.scale(params.sx ?? 1, params.sy ?? params.sx ?? 1, { ids: target }));
3029
4144
  if (params.rotate) check(controller.rotate(params.rotate, { ids: target }));
4145
+ if (params.angle !== void 0) check(controller.setRotation(params.angle, { ids: target }));
3030
4146
  return { ok: true };
3031
4147
  }
3032
4148
  },
@@ -3168,9 +4284,11 @@ function createVectorCommands(controller, options = {}) {
3168
4284
  ];
3169
4285
  }
3170
4286
  export {
4287
+ DEFAULT_VECTOR_SNAP,
3171
4288
  IDENTITY_MATRIX,
3172
4289
  KAPPA,
3173
4290
  VECTOR_CLIPBOARD_TYPE,
4291
+ VECTOR_CONSTRUCTION_ANGLES,
3174
4292
  VECTOR_OVERLAY_THEME,
3175
4293
  VECTOR_ROTATE_HANDLE_OFFSET,
3176
4294
  VECTOR_TOOLS,
@@ -3178,7 +4296,9 @@ export {
3178
4296
  VECTOR_TOOL_SHORTCUTS,
3179
4297
  VECTOR_TRANSFORM_HANDLES,
3180
4298
  VectorEditorController,
4299
+ VectorSnapTargets,
3181
4300
  applyMatrix,
4301
+ applyMatrixLinear,
3182
4302
  bezierToSubpaths,
3183
4303
  boundsFromPoints,
3184
4304
  boundsIntersect,
@@ -3187,15 +4307,20 @@ export {
3187
4307
  cloneVectorDocument,
3188
4308
  cloneVectorNode,
3189
4309
  collectPathNodes,
4310
+ constrainToLeave,
3190
4311
  convertAnchor,
3191
4312
  createVectorCommands,
4313
+ decomposeRigid,
3192
4314
  ellipseSubpath,
3193
4315
  findVectorNode,
3194
4316
  getAnchor,
4317
+ getAnchorWorld,
3195
4318
  getEditablePathIds,
3196
4319
  getTransformHandlePositions,
3197
4320
  getVectorOverlayRenderData,
3198
4321
  getVectorSceneRenderData,
4322
+ getVectorSelectionBoxGeometry,
4323
+ getVectorSelectionFrame,
3199
4324
  getVectorSelectionStyle,
3200
4325
  getVisibleAnchors,
3201
4326
  getVisibleHandles,
@@ -3204,12 +4329,16 @@ export {
3204
4329
  hitTestVectorNodes,
3205
4330
  inferAnchorType,
3206
4331
  insertAnchor,
4332
+ invertMatrix,
4333
+ isIdentityMatrix,
3207
4334
  isVectorTextTarget,
3208
4335
  lineSubpath,
3209
4336
  locateVectorNode,
4337
+ matrixRotation,
3210
4338
  moveAnchorHandle,
3211
4339
  multiplyMatrices,
3212
4340
  nearestSegment,
4341
+ nodeMatrix,
3213
4342
  parseSvgTransform,
3214
4343
  parseVectorSvg,
3215
4344
  polygonSubpath,
@@ -3233,11 +4362,16 @@ export {
3233
4362
  unionBounds,
3234
4363
  vectorDocumentToSvg,
3235
4364
  vectorNodeBounds,
4365
+ vectorNodeRotation,
3236
4366
  vectorNodeToBezier,
3237
4367
  vectorPaintToHex,
3238
4368
  vectorPaintToSvg,
4369
+ vectorParentMatrix,
3239
4370
  vectorPathData,
4371
+ vectorPathWorldBezier,
4372
+ vectorResizeCursor,
3240
4373
  vectorToolLabel,
4374
+ vectorWorldMatrix,
3241
4375
  walkVectorNodes
3242
4376
  };
3243
4377
  //# sourceMappingURL=index.js.map