@pixerate/editor 0.32.0 → 0.33.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.
@@ -189,9 +189,20 @@ function collectPathNodes(node) {
189
189
  });
190
190
  return out;
191
191
  }
192
- function vectorNodeBounds(node) {
193
- if (node.type === "group") return unionBounds(node.children.map(vectorNodeBounds));
194
- const b = bezierPathBounds(subpathsToBezier(node.subpaths));
192
+ function nodeMatrix(node) {
193
+ return node.transform ?? IDENTITY_MATRIX;
194
+ }
195
+ function vectorPathWorldBezier(node, parent = IDENTITY_MATRIX) {
196
+ const m = multiplyMatrices(nodeMatrix(node), parent);
197
+ const path = subpathsToBezier(node.subpaths);
198
+ return isIdentityMatrix(m) ? path : transformBezierPath(path, m);
199
+ }
200
+ function vectorNodeBounds(node, parent = IDENTITY_MATRIX) {
201
+ if (node.type === "group") {
202
+ const m = multiplyMatrices(nodeMatrix(node), parent);
203
+ return unionBounds(node.children.map((c) => vectorNodeBounds(c, m)));
204
+ }
205
+ const b = bezierPathBounds(vectorPathWorldBezier(node, parent));
195
206
  if (!b) return null;
196
207
  return { x: b.minX, y: b.minY, width: b.maxX - b.minX, height: b.maxY - b.minY };
197
208
  }
@@ -216,10 +227,13 @@ function boundsIntersect(a, b) {
216
227
  function boundsFromPoints(a, b) {
217
228
  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
229
  }
219
- function hitTestVectorNode(node, p, tolerance = 0) {
230
+ function hitTestVectorNode(node, p, tolerance = 0, parent = IDENTITY_MATRIX) {
220
231
  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);
232
+ if (node.type === "group") {
233
+ const m = multiplyMatrices(nodeMatrix(node), parent);
234
+ return node.children.some((c) => hitTestVectorNode(c, p, tolerance, m));
235
+ }
236
+ const path = vectorPathWorldBezier(node, parent);
223
237
  if (path.length === 0) return false;
224
238
  const point = [p.x, p.y];
225
239
  if (hasPaint(node.fill) && pointInBezierPath(path, point, node.fillRule ?? "nonzero")) return true;
@@ -268,7 +282,38 @@ function rotateMatrix(degrees, origin = { x: 0, y: 0 }) {
268
282
  function applyMatrix(m, p) {
269
283
  return { x: m[0] * p.x + m[2] * p.y + m[4], y: m[1] * p.x + m[3] * p.y + m[5] };
270
284
  }
271
- function transformVectorNode(node, m) {
285
+ function isIdentityMatrix(m, eps = 1e-12) {
286
+ 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;
287
+ }
288
+ function invertMatrix(m) {
289
+ const [a, b, c, d, e, f] = m;
290
+ const det = a * d - b * c;
291
+ if (!det || !Number.isFinite(det)) return IDENTITY_MATRIX;
292
+ return [d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det];
293
+ }
294
+ function applyMatrixLinear(m, v) {
295
+ return { x: m[0] * v.x + m[2] * v.y, y: m[1] * v.x + m[3] * v.y };
296
+ }
297
+ function matrixRotation(m) {
298
+ const [a, b, c, d] = m;
299
+ const mirrored = a * d - b * c < 0;
300
+ const deg = (mirrored ? Math.atan2(-b - c, d - a) : Math.atan2(b - c, a + d)) * 180 / Math.PI;
301
+ return Math.abs(deg) < 1e-9 ? 0 : deg;
302
+ }
303
+ function decomposeRigid(m) {
304
+ const [a, b, c, d, e, f] = m;
305
+ const deg = matrixRotation(m);
306
+ if (deg === 0) return { rigid: IDENTITY_MATRIX, residual: m };
307
+ const r = deg * Math.PI / 180;
308
+ const cos = Math.cos(r);
309
+ const sin = Math.sin(r);
310
+ return {
311
+ rigid: [cos, sin, -sin, cos, e, f],
312
+ residual: [cos * a + sin * b, -sin * a + cos * b, cos * c + sin * d, -sin * c + cos * d, 0, 0]
313
+ };
314
+ }
315
+ function bakeIntoGeometry(node, m) {
316
+ if (isIdentityMatrix(m)) return node;
272
317
  if (node.type === "group") return { ...node, children: node.children.map((c) => transformVectorNode(c, m)) };
273
318
  const path = node;
274
319
  return {
@@ -286,6 +331,36 @@ function transformVectorNode(node, m) {
286
331
  }))
287
332
  };
288
333
  }
334
+ function transformVectorNode(node, m) {
335
+ const total = multiplyMatrices(nodeMatrix(node), m);
336
+ const { rigid, residual } = decomposeRigid(total);
337
+ const { transform: _old, ...rest } = node;
338
+ const baked = bakeIntoGeometry(rest, residual);
339
+ return isIdentityMatrix(rigid) ? baked : { ...baked, transform: rigid };
340
+ }
341
+ function vectorNodeRotation(node, parent = IDENTITY_MATRIX) {
342
+ return matrixRotation(multiplyMatrices(nodeMatrix(node), parent));
343
+ }
344
+ function vectorWorldMatrix(nodes, id) {
345
+ const search = (list, parent) => {
346
+ for (const n of list) {
347
+ const m = multiplyMatrices(nodeMatrix(n), parent);
348
+ if (n.id === id) return m;
349
+ if (n.type === "group") {
350
+ const found = search(n.children, m);
351
+ if (found) return found;
352
+ }
353
+ }
354
+ return null;
355
+ };
356
+ return search(nodes, IDENTITY_MATRIX);
357
+ }
358
+ function vectorParentMatrix(nodes, id) {
359
+ const node = findVectorNode(nodes, id);
360
+ const world = vectorWorldMatrix(nodes, id);
361
+ if (!node || !world) return null;
362
+ return multiplyMatrices(invertMatrix(nodeMatrix(node)), world);
363
+ }
289
364
  function translateAnchor(a, dx, dy) {
290
365
  const next = cloneAnchor(a);
291
366
  next.x += dx;
@@ -478,6 +553,7 @@ function vectorDocumentToSvg(doc, options = {}) {
478
553
  const indent = (depth) => pretty ? " ".repeat(depth) : "";
479
554
  const common = (node) => {
480
555
  let out2 = ` id="${escapeAttr(node.id)}"`;
556
+ if (node.transform) out2 += ` transform="matrix(${node.transform.map((n) => formatNumber(n, 6)).join(" ")})"`;
481
557
  if (node.opacity !== void 0 && node.opacity < 1) out2 += ` opacity="${num(node.opacity)}"`;
482
558
  if (node.visible === false) out2 += ` display="none"`;
483
559
  return out2;
@@ -855,6 +931,24 @@ function getAnchor(doc, ref) {
855
931
  const node = findVectorNode(doc.nodes, ref.nodeId);
856
932
  return node?.type === "path" ? node.subpaths[ref.subpath]?.anchors[ref.index] : void 0;
857
933
  }
934
+ function getAnchorWorld(doc, ref) {
935
+ const a = getAnchor(doc, ref);
936
+ if (!a) return void 0;
937
+ const m = vectorWorldMatrix(doc.nodes, ref.nodeId) ?? IDENTITY_MATRIX;
938
+ const p = applyMatrix(m, a);
939
+ 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 } : {} };
940
+ }
941
+ function getVectorSelectionFrame(doc, ids) {
942
+ const nodes = ids.map((id) => ({ node: findVectorNode(doc.nodes, id), parent: vectorParentMatrix(doc.nodes, id) ?? IDENTITY_MATRIX })).filter((n) => n.node);
943
+ if (!nodes.length) return null;
944
+ const angles = nodes.map((n) => matrixRotation(multiplyMatrices(nodeMatrix(n.node), n.parent)));
945
+ const same = angles.every((a) => Math.abs(normalizeAngle(a - angles[0])) < 1e-6);
946
+ const angle = same ? angles[0] : 0;
947
+ const unrotate = rotateMatrix(-angle);
948
+ const b = unionBounds(nodes.map((n) => vectorNodeBounds(n.node, multiplyMatrices(n.parent, unrotate))));
949
+ return b ? { ...b, angle } : null;
950
+ }
951
+ var normalizeAngle = (a) => ((a + 180) % 360 + 360) % 360 - 180;
858
952
  function getTransformHandlePositions(box) {
859
953
  const { x, y, width: w, height: h } = box;
860
954
  return [
@@ -869,17 +963,28 @@ function getTransformHandlePositions(box) {
869
963
  { handle: "rotate", x: x + w / 2, y: y - VECTOR_ROTATE_HANDLE_OFFSET }
870
964
  ];
871
965
  }
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
- };
966
+ function getVectorSelectionBoxGeometry(frame, viewport) {
967
+ const z = viewport.zoom;
968
+ const box = { x: frame.x * z + viewport.x, y: frame.y * z + viewport.y, width: frame.width * z, height: frame.height * z };
969
+ const rot = rotateMatrix(frame.angle, { x: viewport.x, y: viewport.y });
970
+ const toScreen2 = (p) => frame.angle ? applyMatrix(rot, p) : { x: p.x, y: p.y };
971
+ const handles = getTransformHandlePositions(box).map((h) => ({ ...h, screen: toScreen2(h) }));
972
+ const corners = [
973
+ { x: box.x, y: box.y },
974
+ { x: box.x + box.width, y: box.y },
975
+ { x: box.x + box.width, y: box.y + box.height },
976
+ { x: box.x, y: box.y + box.height }
977
+ ].map(toScreen2);
978
+ const r = (n) => Math.round(n * 1e3) / 1e3;
979
+ return { box, angle: frame.angle, transform: frame.angle ? `rotate(${r(frame.angle)} ${r(viewport.x)} ${r(viewport.y)})` : "", handles, corners };
980
+ }
981
+ var RESIZE_CURSOR_ORDER = ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"];
982
+ var HANDLE_ANGLES = { e: 0, se: 45, s: 90, sw: 135, w: 180, nw: 225, n: 270, ne: 315 };
983
+ function vectorResizeCursor(handle, angle = 0) {
984
+ if (handle === "rotate") return "grab";
985
+ const a = ((HANDLE_ANGLES[handle] + angle) % 180 + 180) % 180;
986
+ return RESIZE_CURSOR_ORDER[Math.round(a / 45) % 4];
987
+ }
883
988
  var TOOL_LABELS = {
884
989
  select: "Select",
885
990
  direct: "Direct Select",
@@ -938,6 +1043,7 @@ function mapTree(nodes, fn) {
938
1043
  }
939
1044
  return changed ? out : nodes;
940
1045
  }
1046
+ 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
1047
  var idCounter = 0;
942
1048
  var defaultCreateId = () => `vn-${Date.now().toString(36)}${(++idCounter).toString(36)}`;
943
1049
  var VectorEditorController = class {
@@ -1018,6 +1124,7 @@ var VectorEditorController = class {
1018
1124
  selectedAnchors: this.anchors,
1019
1125
  editingId: this.editingId,
1020
1126
  selectionBounds: this.boundsOf(this.selection),
1127
+ selectionFrame: this.frameOf(this.selectedRoots()),
1021
1128
  viewport: this.viewport,
1022
1129
  defaults: this.defaults,
1023
1130
  snap: this.snap,
@@ -1048,7 +1155,23 @@ var VectorEditorController = class {
1048
1155
  });
1049
1156
  }
1050
1157
  boundsOf(ids) {
1051
- return unionBounds(ids.map((id) => this.getNode(id)).map((n) => n ? vectorNodeBounds(n) : null));
1158
+ return unionBounds(
1159
+ ids.map((id) => {
1160
+ const n = this.getNode(id);
1161
+ return n ? vectorNodeBounds(n, vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX) : null;
1162
+ })
1163
+ );
1164
+ }
1165
+ frameOf(ids) {
1166
+ return getVectorSelectionFrame(this.doc, ids);
1167
+ }
1168
+ /** Local → document matrix of a node in `doc` (default: the current document). */
1169
+ worldOf(id, doc = this.doc) {
1170
+ return vectorWorldMatrix(doc.nodes, id) ?? IDENTITY_MATRIX;
1171
+ }
1172
+ /** A document point in node `id`'s own space. */
1173
+ toLocal(id, p, doc = this.doc) {
1174
+ return applyMatrix(invertMatrix(this.worldOf(id, doc)), p);
1052
1175
  }
1053
1176
  cursorFor() {
1054
1177
  if (this.drag?.kind === "pan" || this.spaceHeld) return this.drag ? "grabbing" : "grab";
@@ -1057,7 +1180,7 @@ var VectorEditorController = class {
1057
1180
  const h = this.hover;
1058
1181
  switch (this.tool) {
1059
1182
  case "select":
1060
- if (h?.transformHandle) return RESIZE_CURSORS[h.transformHandle];
1183
+ if (h?.transformHandle) return vectorResizeCursor(h.transformHandle, this.frameOf(this.selectedRoots())?.angle ?? 0);
1061
1184
  return h?.nodeId ? "move" : "default";
1062
1185
  case "direct":
1063
1186
  if (h?.anchor || h?.handle) return "pointer";
@@ -1454,6 +1577,11 @@ var VectorEditorController = class {
1454
1577
  // -------------------------------------------------------------------------
1455
1578
  // Transforms
1456
1579
  // -------------------------------------------------------------------------
1580
+ /** `m`, given in a frame rotated by `angle`, as a document-space matrix. */
1581
+ inFrame(angle, m) {
1582
+ if (!angle) return m;
1583
+ return multiplyMatrices(multiplyMatrices(rotateMatrix(-angle), m), rotateMatrix(angle));
1584
+ }
1457
1585
  /** Applies a matrix to the selected (or given) nodes as one undo step. */
1458
1586
  transform(matrix, options = {}) {
1459
1587
  const ids = new Set(options.ids ?? this.selectedRoots());
@@ -1463,8 +1591,25 @@ var VectorEditorController = class {
1463
1591
  });
1464
1592
  return { ok: true };
1465
1593
  }
1594
+ /** `doc` with nodes `ids` moved by `m`, given in document space. */
1466
1595
  transformed(doc, ids, m) {
1467
- const nodes = mapTree(doc.nodes, (n) => ids.has(n.id) && !n.locked ? transformVectorNode(n, m) : n);
1596
+ const visit = (list, parent) => {
1597
+ let changed = false;
1598
+ const out = list.map((n) => {
1599
+ let next = n;
1600
+ if (ids.has(n.id) && !n.locked) {
1601
+ const local = isRoot(parent) ? m : multiplyMatrices(multiplyMatrices(parent, m), invertMatrix(parent));
1602
+ next = transformVectorNode(n, local);
1603
+ } else if (n.type === "group") {
1604
+ const children = visit(n.children, multiplyMatrices(nodeMatrix(n), parent));
1605
+ if (children !== n.children) next = { ...n, children };
1606
+ }
1607
+ if (next !== n) changed = true;
1608
+ return next;
1609
+ });
1610
+ return changed ? out : list;
1611
+ };
1612
+ const nodes = visit(doc.nodes, IDENTITY_MATRIX);
1468
1613
  return nodes === doc.nodes ? doc : { ...doc, nodes };
1469
1614
  }
1470
1615
  translate(dx, dy, options = {}) {
@@ -1476,26 +1621,48 @@ var VectorEditorController = class {
1476
1621
  }
1477
1622
  return this.transform(translateMatrix(dx, dy), { ids: options.ids, label: "Move" });
1478
1623
  }
1479
- /** Scales around `origin` (default: the selection centre). */
1624
+ /** The selection frame's centre in document space. */
1625
+ frameCenter(frame) {
1626
+ return applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
1627
+ }
1628
+ /**
1629
+ * Scales along the selection box's own axes (rotated with the selection when its nodes
1630
+ * share a rotation) around `origin` (default: the box centre, in document space).
1631
+ */
1480
1632
  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" });
1633
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
1634
+ if (!frame) return { ok: false, message: "Nothing is selected." };
1635
+ const origin = applyMatrix(rotateMatrix(-frame.angle), options.origin ?? this.frameCenter(frame));
1636
+ return this.transform(this.inFrame(frame.angle, scaleMatrix(sx, sy, origin)), { ids: options.ids, label: "Scale" });
1485
1637
  }
1486
1638
  /** Rotates clockwise by `degrees` around `origin` (default: the selection centre). */
1487
1639
  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 };
1640
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
1641
+ if (!frame) return { ok: false, message: "Nothing is selected." };
1642
+ const origin = options.origin ?? this.frameCenter(frame);
1491
1643
  return this.transform(rotateMatrix(degrees, origin), { ids: options.ids, label: "Rotate" });
1492
1644
  }
1645
+ /** Sets each selected (or given) node's rotation to `degrees`, turning it around its own centre. */
1646
+ setRotation(degrees, options = {}) {
1647
+ const ids = options.ids ?? this.selectedRoots();
1648
+ if (!ids.length) return { ok: false, message: "Nothing is selected." };
1649
+ this.change("Set Rotation", () => {
1650
+ for (const id of ids) {
1651
+ const frame = this.frameOf([id]);
1652
+ if (!frame) continue;
1653
+ const delta = normalizeAngle(degrees - frame.angle);
1654
+ if (Math.abs(delta) > 1e-9) this.doc = this.transformed(this.doc, /* @__PURE__ */ new Set([id]), rotateMatrix(delta, this.frameCenter(frame)));
1655
+ }
1656
+ });
1657
+ return { ok: true };
1658
+ }
1659
+ /** Mirrors along the selection box's own axes. */
1493
1660
  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 };
1661
+ const frame = this.frameOf(options.ids ?? this.selectedRoots());
1662
+ if (!frame) return { ok: false, message: "Nothing is selected." };
1663
+ const c = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
1497
1664
  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" });
1665
+ return this.transform(this.inFrame(frame.angle, m), { ids: options.ids, label: axis === "horizontal" ? "Flip Horizontal" : "Flip Vertical" });
1499
1666
  }
1500
1667
  /**
1501
1668
  * Aligns the selected nodes to their combined bounds, or to the artboard when one node is
@@ -1620,7 +1787,10 @@ var VectorEditorController = class {
1620
1787
  if (set.has(n.id) && n.type === "group") {
1621
1788
  changed = true;
1622
1789
  const opacity = n.opacity;
1623
- out.push(...n.children.map((c) => opacity !== void 0 && opacity < 1 ? { ...c, opacity: (c.opacity ?? 1) * opacity } : c));
1790
+ const m = nodeMatrix(n);
1791
+ out.push(
1792
+ ...n.children.map((c) => n.transform ? transformVectorNode(c, m) : c).map((c) => opacity !== void 0 && opacity < 1 ? { ...c, opacity: (c.opacity ?? 1) * opacity } : c)
1793
+ );
1624
1794
  } else if (n.type === "group") {
1625
1795
  const children = flatten(n.children);
1626
1796
  if (children !== n.children) changed = true;
@@ -1668,15 +1838,22 @@ var VectorEditorController = class {
1668
1838
  if (paths.length < 2) return null;
1669
1839
  const result = pathBoolean(
1670
1840
  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" }))
1841
+ paths.map((p) => ({
1842
+ path: vectorPathWorldBezier(
1843
+ { ...p, subpaths: p.subpaths.filter((s) => s.anchors.length > 1).map((s) => ({ ...s, closed: true })) },
1844
+ vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX
1845
+ ),
1846
+ fillRule: p.fillRule ?? "nonzero"
1847
+ }))
1672
1848
  );
1673
- const subpaths = bezierToSubpaths(result.path);
1674
- if (result.error || !subpaths.length) return null;
1675
1849
  const base = paths[0];
1850
+ const anchorRoot = roots.find((r) => findVectorNode([this.getNode(r)], base.id)) ?? base.id;
1851
+ const subpaths = bezierToSubpaths(this.intoParentSpace(anchorRoot, result.path));
1852
+ if (result.error || !subpaths.length) return null;
1676
1853
  const id = this.newId();
1677
- const node = { ...cloneVectorNode(base), id, name: BOOLEAN_LABELS[op], subpaths };
1854
+ const { transform: _t, ...baseRest } = cloneVectorNode(base);
1855
+ const node = { ...baseRest, id, name: BOOLEAN_LABELS[op], subpaths };
1678
1856
  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
1857
  this.change(BOOLEAN_LABELS[op], () => {
1681
1858
  this.setDoc(mapTree(this.doc.nodes, (n) => n.id === anchorRoot ? node : drop.has(n.id) ? null : n));
1682
1859
  this.selection = [id];
@@ -1694,9 +1871,11 @@ var VectorEditorController = class {
1694
1871
  });
1695
1872
  if (paths.length < 2) return null;
1696
1873
  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
1874
  const first = roots.find((r) => findVectorNode([this.getNode(r)], paths[0].id)) ?? paths[0].id;
1875
+ const subpaths = paths.flatMap((p) => bezierToSubpaths(this.intoParentSpace(first, vectorPathWorldBezier(p, vectorParentMatrix(this.doc.nodes, p.id) ?? IDENTITY_MATRIX))));
1876
+ const { transform: _t, ...baseRest } = cloneVectorNode(paths[0]);
1877
+ const node = { ...baseRest, id, name: "Compound Path", subpaths };
1878
+ const drop = /* @__PURE__ */ new Set([...paths.map((p) => p.id), ...roots]);
1700
1879
  this.change("Combine", () => {
1701
1880
  this.setDoc(mapTree(this.doc.nodes, (n) => n.id === first ? node : drop.has(n.id) ? null : n));
1702
1881
  this.selection = [id];
@@ -1704,6 +1883,11 @@ var VectorEditorController = class {
1704
1883
  });
1705
1884
  return id;
1706
1885
  }
1886
+ /** A document-space path mapped into the parent space of node `id`. */
1887
+ intoParentSpace(id, path) {
1888
+ const parent = vectorParentMatrix(this.doc.nodes, id) ?? IDENTITY_MATRIX;
1889
+ return isRoot(parent) ? path : transformBezierPath(path, invertMatrix(parent));
1890
+ }
1707
1891
  /** Splits selected compound paths into one path per subpath. */
1708
1892
  breakApart(options = {}) {
1709
1893
  const paths = (options.ids ?? this.selection).map((id) => this.getNode(id)).filter((n) => n?.type === "path" && n.subpaths.length > 1);
@@ -1743,23 +1927,30 @@ var VectorEditorController = class {
1743
1927
  });
1744
1928
  return nodes === doc.nodes ? doc : { ...doc, nodes };
1745
1929
  }
1930
+ /** Moves anchors by a document-space offset (converted into each node's own space). */
1746
1931
  movedAnchors(doc, refs, dx, dy) {
1747
- return this.updateAnchors(doc, refs, (a) => translateAnchor(a, dx, dy));
1932
+ const local = /* @__PURE__ */ new Map();
1933
+ 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 }));
1934
+ return this.updateAnchors(doc, refs, (a, ref) => {
1935
+ const d = local.get(ref.nodeId);
1936
+ return translateAnchor(a, d.x, d.y);
1937
+ });
1748
1938
  }
1749
- /** Moves one anchor to a position (handles follow). */
1939
+ /** Moves one anchor to a document position (handles follow). */
1750
1940
  moveAnchor(ref, to) {
1751
- const a = getAnchor(this.doc, ref);
1941
+ const a = getAnchorWorld(this.doc, ref);
1752
1942
  if (!a) return { ok: false, message: "There is no such anchor." };
1753
1943
  this.change("Move Point", () => {
1754
1944
  this.doc = this.movedAnchors(this.doc, [ref], to.x - a.x, to.y - a.y);
1755
1945
  });
1756
1946
  return { ok: true };
1757
1947
  }
1758
- /** Moves one handle (see `moveAnchorHandle` for how the opposite handle follows). */
1948
+ /** Moves one handle to a document position (see `moveAnchorHandle` for how the opposite handle follows). */
1759
1949
  moveHandle(ref, to, options = {}) {
1760
1950
  if (!getAnchor(this.doc, ref)) return { ok: false, message: "There is no such anchor." };
1951
+ const local = this.toLocal(ref.nodeId, to);
1761
1952
  this.change("Move Handle", () => {
1762
- this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle, to, options.breakHandles));
1953
+ this.doc = this.updateAnchors(this.doc, [ref], (a) => moveAnchorHandle(a, ref.handle, local, options.breakHandles));
1763
1954
  });
1764
1955
  return { ok: true };
1765
1956
  }
@@ -1778,11 +1969,11 @@ var VectorEditorController = class {
1778
1969
  });
1779
1970
  return { ok: true };
1780
1971
  }
1781
- /** Adds an anchor on the nearest segment of `nodeId` to `point` (shape unchanged). */
1972
+ /** Adds an anchor on the nearest segment of `nodeId` to document point `point` (shape unchanged). */
1782
1973
  insertAnchorAt(nodeId, point) {
1783
1974
  const node = this.getNode(nodeId);
1784
1975
  if (node?.type !== "path") return null;
1785
- const hit = nearestSegment(node, point);
1976
+ const hit = nearestSegment(node, this.toLocal(nodeId, point));
1786
1977
  if (!hit) return null;
1787
1978
  const { subpath, index } = insertAnchor(node.subpaths[hit.subpath], hit.segment, hit.t);
1788
1979
  const ref = { nodeId, subpath: hit.subpath, index };
@@ -1939,19 +2130,17 @@ var VectorEditorController = class {
1939
2130
  }
1940
2131
  hitTransformHandle(screen) {
1941
2132
  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 };
2133
+ const frame = this.frameOf(this.selectedRoots());
2134
+ if (!frame) return null;
2135
+ const { box, handles } = getVectorSelectionBoxGeometry(frame, this.viewport);
1946
2136
  const r = this.hitRadius + 2;
1947
- const handles = getTransformHandlePositions(box);
1948
2137
  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;
2138
+ 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
2139
  return null;
1951
2140
  }
1952
2141
  hitHandle(screen) {
1953
2142
  for (const ref of getVisibleHandles(this.view()).reverse()) {
1954
- const a = getAnchor(this.doc, ref);
2143
+ const a = getAnchorWorld(this.doc, ref);
1955
2144
  const h = a?.[ref.handle];
1956
2145
  if (!h) continue;
1957
2146
  const s = this.documentToScreen(h);
@@ -1963,7 +2152,7 @@ var VectorEditorController = class {
1963
2152
  let best = null;
1964
2153
  let bestD = Infinity;
1965
2154
  for (const ref of refs) {
1966
- const a = getAnchor(this.doc, ref);
2155
+ const a = getAnchorWorld(this.doc, ref);
1967
2156
  if (!a) continue;
1968
2157
  const s = this.documentToScreen(a);
1969
2158
  const d = Math.hypot(s.x - screen.x, s.y - screen.y);
@@ -1977,18 +2166,18 @@ var VectorEditorController = class {
1977
2166
  /** Deepest unlocked visible path under `p`. */
1978
2167
  hitPath(p) {
1979
2168
  const tol = this.tolerance();
1980
- const search = (list) => {
2169
+ const search = (list, parent) => {
1981
2170
  for (let i = list.length - 1; i >= 0; i--) {
1982
2171
  const n = list[i];
1983
2172
  if (n.visible === false || n.locked) continue;
1984
2173
  if (n.type === "group") {
1985
- const found = search(n.children);
2174
+ const found = search(n.children, multiplyMatrices(nodeMatrix(n), parent));
1986
2175
  if (found) return found;
1987
- } else if (hitTestVectorNode(n, p, tol)) return n;
2176
+ } else if (hitTestVectorNode(n, p, tol, parent)) return n;
1988
2177
  }
1989
2178
  return null;
1990
2179
  };
1991
- return search(this.doc.nodes);
2180
+ return search(this.doc.nodes, IDENTITY_MATRIX);
1992
2181
  }
1993
2182
  /** Endpoints of open subpaths of unlocked visible paths (pen tool resumes from them). */
1994
2183
  openEndpoints() {
@@ -2019,8 +2208,11 @@ var VectorEditorController = class {
2019
2208
  if (anchor) return { nodeId: anchor.nodeId, anchor };
2020
2209
  for (const id of getEditablePathIds(this.view())) {
2021
2210
  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 } };
2211
+ const seg = nearestSegment(node, this.toLocal(id, p));
2212
+ if (seg && seg.distance <= this.tolerance()) {
2213
+ const point = applyMatrix(this.worldOf(id), seg.point);
2214
+ return { nodeId: id, segment: { nodeId: id, subpath: seg.subpath, segment: seg.segment, t: seg.t, point } };
2215
+ }
2024
2216
  }
2025
2217
  const path = this.hitPath(p);
2026
2218
  return path ? { nodeId: path.id } : null;
@@ -2065,9 +2257,10 @@ var VectorEditorController = class {
2065
2257
  downSelect(screen, p, input) {
2066
2258
  const handle = this.hitTransformHandle(screen);
2067
2259
  if (handle) {
2068
- const bounds = this.boundsOf(this.selectedRoots());
2260
+ const frame = this.frameOf(this.selectedRoots());
2069
2261
  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 };
2262
+ const center = applyMatrix(rotateMatrix(frame.angle), { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 });
2263
+ this.drag = handle === "rotate" ? { kind: "rotate", start: p, center, before, base: this.doc } : { kind: "scale", handle, start: p, frame, before, base: this.doc };
2071
2264
  this.interaction = handle === "rotate" ? { type: "rotate", angle: 0 } : { type: "scale", handle, sx: 1, sy: 1 };
2072
2265
  this.notify();
2073
2266
  return;
@@ -2133,7 +2326,7 @@ var VectorEditorController = class {
2133
2326
  }
2134
2327
  for (const id of getEditablePathIds(this.view())) {
2135
2328
  const node = this.getNode(id);
2136
- const seg = nearestSegment(node, p);
2329
+ const seg = nearestSegment(node, this.toLocal(id, p));
2137
2330
  if (!seg || seg.distance > this.tolerance()) continue;
2138
2331
  const sp = node.subpaths[seg.subpath];
2139
2332
  if (input.alt) {
@@ -2187,8 +2380,9 @@ var VectorEditorController = class {
2187
2380
  return;
2188
2381
  }
2189
2382
  let pt2 = this.snapPoint(p);
2190
- const last = sp.anchors[sp.anchors.length - 1];
2383
+ const last = getAnchorWorld(this.doc, { nodeId: pen.nodeId, subpath: pen.subpath, index: sp.anchors.length - 1 });
2191
2384
  if (input.shift) pt2 = constrain45(last, pt2);
2385
+ pt2 = this.toLocal(pen.nodeId, pt2);
2192
2386
  const node2 = this.getNode(pen.nodeId);
2193
2387
  const anchors = [...sp.anchors, { x: pt2.x, y: pt2.y }];
2194
2388
  this.replaceNode(pen.nodeId, { ...node2, subpaths: node2.subpaths.map((s, i) => i === pen.subpath ? { ...s, anchors } : s) });
@@ -2294,9 +2488,11 @@ var VectorEditorController = class {
2294
2488
  return;
2295
2489
  }
2296
2490
  case "scale": {
2297
- const { sx, sy, origin } = scaleFor(drag.handle, drag.bounds, p, input);
2491
+ const angle = drag.frame.angle;
2492
+ const q = angle ? applyMatrix(rotateMatrix(-angle), p) : p;
2493
+ const { sx, sy, origin } = scaleFor(drag.handle, drag.frame, q, input);
2298
2494
  const ids = new Set(this.selectedRoots());
2299
- this.doc = this.transformed(drag.base, ids, scaleMatrix(sx, sy, origin));
2495
+ this.doc = this.transformed(drag.base, ids, this.inFrame(angle, scaleMatrix(sx, sy, origin)));
2300
2496
  this.interaction = { type: "scale", handle: drag.handle, sx, sy };
2301
2497
  this.notify(true);
2302
2498
  return;
@@ -2316,7 +2512,7 @@ var VectorEditorController = class {
2316
2512
  this.interaction = { type: "marquee", bounds };
2317
2513
  if (drag.anchorsMode) {
2318
2514
  const inside = getVisibleAnchors(this.view()).filter((r) => {
2319
- const a = getAnchor(this.doc, r);
2515
+ const a = getAnchorWorld(this.doc, r);
2320
2516
  return a.x >= bounds.x && a.x <= bounds.x + bounds.width && a.y >= bounds.y && a.y <= bounds.y + bounds.height;
2321
2517
  });
2322
2518
  this.anchors = [...drag.baseAnchors, ...inside.filter((r) => !drag.baseAnchors.some((b) => sameRef(b, r)))];
@@ -2336,7 +2532,7 @@ var VectorEditorController = class {
2336
2532
  if (!drag.moved && Math.hypot(dx, dy) * this.viewport.zoom < 2) return;
2337
2533
  drag.moved = true;
2338
2534
  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;
2535
+ const lead = this.anchors.length ? getAnchorWorld(drag.base, this.anchors[0]) : void 0;
2340
2536
  ({ dx, dy } = this.snapDelta(dx, dy, lead ?? null));
2341
2537
  this.doc = this.movedAnchors(drag.base, this.anchors, dx, dy);
2342
2538
  this.interaction = { type: "anchors", dx, dy };
@@ -2344,16 +2540,17 @@ var VectorEditorController = class {
2344
2540
  return;
2345
2541
  }
2346
2542
  case "handle": {
2347
- const anchor = getAnchor(drag.base, drag.ref);
2543
+ const anchor = getAnchorWorld(drag.base, drag.ref);
2348
2544
  let pt = this.snapPoint(p);
2349
2545
  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));
2546
+ const local = this.toLocal(drag.ref.nodeId, pt, drag.base);
2547
+ this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => moveAnchorHandle(a, drag.ref.handle, local, input.alt));
2351
2548
  this.interaction = { type: "handle", ref: drag.ref };
2352
2549
  this.notify(true);
2353
2550
  return;
2354
2551
  }
2355
2552
  case "pull": {
2356
- const anchor = getAnchor(drag.base, drag.ref);
2553
+ const anchor = getAnchorWorld(drag.base, drag.ref);
2357
2554
  if (!anchor) return;
2358
2555
  if (Math.hypot(p.x - anchor.x, p.y - anchor.y) * this.viewport.zoom < 2) {
2359
2556
  this.doc = drag.base;
@@ -2363,7 +2560,7 @@ var VectorEditorController = class {
2363
2560
  let pt = p;
2364
2561
  if (input.shift) pt = constrain45(anchor, pt);
2365
2562
  const side = drag.closing ? "in" : "out";
2366
- const pos = drag.closing ? { x: 2 * anchor.x - pt.x, y: 2 * anchor.y - pt.y } : pt;
2563
+ 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
2564
  this.doc = this.updateAnchors(drag.base, [drag.ref], (a) => {
2368
2565
  if (drag.outOnly || drag.altBreaks && input.alt) return moveAnchorHandle(a, side, pos, true);
2369
2566
  return moveAnchorHandle({ ...a, type: "symmetric" }, side, pos);
@@ -2540,7 +2737,7 @@ var VectorEditorController = class {
2540
2737
  const id = this.hover?.segment?.nodeId ?? this.editingId;
2541
2738
  if (id) {
2542
2739
  const node = this.getNode(id);
2543
- const seg = node ? nearestSegment(node, p) : null;
2740
+ const seg = node ? nearestSegment(node, this.toLocal(id, p)) : null;
2544
2741
  if (seg && seg.distance <= this.tolerance()) this.insertAnchorAt(id, p);
2545
2742
  }
2546
2743
  return;
@@ -2692,11 +2889,12 @@ function scaleFor(handle, b, p, mods = {}) {
2692
2889
 
2693
2890
  // src/vector/render.ts
2694
2891
  var fmt = (n) => Math.round(n * 100) / 100;
2892
+ var fmtMatrix = (m) => `matrix(${m.map((n) => Math.round(n * 1e6) / 1e6).join(" ")})`;
2695
2893
  function renderNode(node, defs, idPrefix) {
2696
2894
  if (node.visible === false) return null;
2697
2895
  if (node.type === "group") {
2698
2896
  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 };
2897
+ return { kind: "group", id: node.id, ...node.transform ? { transform: fmtMatrix(node.transform) } : {}, opacity: node.opacity ?? 1, children };
2700
2898
  }
2701
2899
  const fill = vectorPaintToSvg(node.fill, `${idPrefix}fill-${node.id}`);
2702
2900
  const stroke = vectorPaintToSvg(node.stroke, `${idPrefix}stroke-${node.id}`);
@@ -2705,6 +2903,7 @@ function renderNode(node, defs, idPrefix) {
2705
2903
  return {
2706
2904
  kind: "path",
2707
2905
  id: node.id,
2906
+ ...node.transform ? { transform: fmtMatrix(node.transform) } : {},
2708
2907
  d: vectorPathData(node),
2709
2908
  fill: fill.value,
2710
2909
  stroke: stroke.value,
@@ -2733,8 +2932,14 @@ function getVectorSceneRenderData(state, options = {}) {
2733
2932
  var toScreen = (state, p) => ({ x: p.x * state.viewport.zoom + state.viewport.x, y: p.y * state.viewport.zoom + state.viewport.y });
2734
2933
  function screenOutline(state, node) {
2735
2934
  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(" ");
2935
+ const view = [v.zoom, 0, 0, v.zoom, v.x, v.y];
2936
+ const parent = vectorParentMatrix(state.document.nodes, node.id) ?? IDENTITY_MATRIX;
2937
+ return collectWithMatrix(node, parent).map(({ path, parent: m }) => bezierPathToSvg(transformBezierPath(vectorPathWorldBezier(path, m), view), { precision: 2 })).join(" ");
2938
+ }
2939
+ function collectWithMatrix(node, parent) {
2940
+ if (node.type === "path") return [{ path: node, parent }];
2941
+ const m = multiplyMatrices(nodeMatrix(node), parent);
2942
+ return node.children.flatMap((c) => collectWithMatrix(c, m));
2738
2943
  }
2739
2944
  function collect(node) {
2740
2945
  if (node.type === "path") return [node];
@@ -2756,29 +2961,27 @@ function getVectorOverlayRenderData(state) {
2756
2961
  if (state.editingId) outline(state.editingId, true);
2757
2962
  if (state.hover?.nodeId && !state.interaction) outline(state.hover.nodeId, false);
2758
2963
  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) };
2964
+ if (state.tool === "select" && state.selectionFrame && state.interaction?.type !== "marquee") {
2965
+ const g = getVectorSelectionBoxGeometry(state.selectionFrame, state.viewport);
2966
+ selectionBox = { ...g.box, angle: g.angle, transform: g.transform, corners: g.corners, handles: g.handles };
2764
2967
  }
2765
2968
  const selected = (r) => state.selectedAnchors.some((a) => a.nodeId === r.nodeId && a.subpath === r.subpath && a.index === r.index);
2766
2969
  const sameAnchor = (a, b) => !!a && a.nodeId === b.nodeId && a.subpath === b.subpath && a.index === b.index;
2767
2970
  const anchors = getVisibleAnchors(state).map((ref) => {
2768
- const a = getAnchor(doc, ref);
2971
+ const a = getAnchorWorld(doc, ref);
2769
2972
  const s = toScreen(state, a);
2770
2973
  const closes = !!state.pen && state.pen.nodeId === ref.nodeId && state.pen.subpath === ref.subpath && ref.index === 0;
2771
2974
  return { ref, x: s.x, y: s.y, type: a.type ?? inferAnchorType(a), selected: selected(ref), hovered: sameAnchor(state.hover?.anchor, ref), closes };
2772
2975
  });
2773
2976
  if (state.tool === "pen" && !state.pen && state.hover?.anchor) {
2774
- const a = getAnchor(doc, state.hover.anchor);
2977
+ const a = getAnchorWorld(doc, state.hover.anchor);
2775
2978
  if (a) {
2776
2979
  const s = toScreen(state, a);
2777
2980
  anchors.push({ ref: state.hover.anchor, x: s.x, y: s.y, type: a.type ?? "corner", selected: false, hovered: true, closes: false });
2778
2981
  }
2779
2982
  }
2780
2983
  const handles = getVisibleHandles(state).flatMap((ref) => {
2781
- const a = getAnchor(doc, ref);
2984
+ const a = getAnchorWorld(doc, ref);
2782
2985
  const h = a?.[ref.handle];
2783
2986
  if (!a || !h) return [];
2784
2987
  const s = toScreen(state, h);
@@ -2796,7 +2999,7 @@ function getVectorOverlayRenderData(state) {
2796
2999
  if (state.tool === "pen" && state.pen && state.pointer && !state.interaction) {
2797
3000
  const node = findVectorNode(doc.nodes, state.pen.nodeId);
2798
3001
  const sp = node?.type === "path" ? node.subpaths[state.pen.subpath] : void 0;
2799
- const last = sp?.anchors[sp.anchors.length - 1];
3002
+ const last = sp ? getAnchorWorld(doc, { nodeId: state.pen.nodeId, subpath: state.pen.subpath, index: sp.anchors.length - 1 }) : void 0;
2800
3003
  if (last) {
2801
3004
  const a = toScreen(state, last);
2802
3005
  const p = toScreen(state, state.pointer);
@@ -2811,7 +3014,7 @@ function getVectorOverlayRenderData(state) {
2811
3014
  if (it?.type === "rotate") status = `${Math.round(it.angle)}\xB0`;
2812
3015
  else if (it?.type === "move" || it?.type === "anchors") status = `\u0394 ${fmt(it.dx)}, ${fmt(it.dy)}`;
2813
3016
  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;
3017
+ const b = it.type === "draw" ? findVectorNode(doc.nodes, it.nodeId) ? vectorNodeBounds(findVectorNode(doc.nodes, it.nodeId)) : null : state.selectionFrame;
2815
3018
  if (b) status = `W ${fmt(b.width)} \xD7 H ${fmt(b.height)}`;
2816
3019
  }
2817
3020
  return { outlines, selectionBox, anchors, handles, marquee, penPreview, artboard, status };
@@ -2908,12 +3111,14 @@ function createVectorCommands(controller, options = {}) {
2908
3111
  const out = [];
2909
3112
  const visit = (nodes, depth) => {
2910
3113
  for (const n of nodes) {
2911
- const b = vectorNodeBounds(n);
3114
+ const parent = vectorParentMatrix(state.document.nodes, n.id) ?? IDENTITY_MATRIX;
3115
+ const b = vectorNodeBounds(n, parent);
2912
3116
  out.push({
2913
3117
  id: n.id,
2914
3118
  type: n.type,
2915
3119
  ...n.name ? { name: n.name } : {},
2916
3120
  depth,
3121
+ rotation: Math.round(vectorNodeRotation(n, parent) * 100) / 100,
2917
3122
  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
3123
  visible: n.visible !== false,
2919
3124
  locked: !!n.locked,
@@ -3015,10 +3220,18 @@ function createVectorCommands(controller, options = {}) {
3015
3220
  id: `${prefix}transform`,
3016
3221
  label: "Transform\u2026",
3017
3222
  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.",
3223
+ 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
3224
  params: {
3020
3225
  type: "object",
3021
- properties: { ids: IDS, dx: { type: "number" }, dy: { type: "number" }, sx: { type: "number" }, sy: { type: "number" }, rotate: { type: "number" } }
3226
+ properties: {
3227
+ ids: IDS,
3228
+ dx: { type: "number" },
3229
+ dy: { type: "number" },
3230
+ sx: { type: "number" },
3231
+ sy: { type: "number" },
3232
+ rotate: { type: "number" },
3233
+ angle: { type: "number" }
3234
+ }
3022
3235
  },
3023
3236
  enabled: enabledFor,
3024
3237
  undoable: true,
@@ -3027,6 +3240,7 @@ function createVectorCommands(controller, options = {}) {
3027
3240
  if (params.dx || params.dy) check(controller.translate(params.dx ?? 0, params.dy ?? 0, { ids: target }));
3028
3241
  if (params.sx !== void 0 || params.sy !== void 0) check(controller.scale(params.sx ?? 1, params.sy ?? params.sx ?? 1, { ids: target }));
3029
3242
  if (params.rotate) check(controller.rotate(params.rotate, { ids: target }));
3243
+ if (params.angle !== void 0) check(controller.setRotation(params.angle, { ids: target }));
3030
3244
  return { ok: true };
3031
3245
  }
3032
3246
  },
@@ -3179,6 +3393,7 @@ export {
3179
3393
  VECTOR_TRANSFORM_HANDLES,
3180
3394
  VectorEditorController,
3181
3395
  applyMatrix,
3396
+ applyMatrixLinear,
3182
3397
  bezierToSubpaths,
3183
3398
  boundsFromPoints,
3184
3399
  boundsIntersect,
@@ -3189,13 +3404,17 @@ export {
3189
3404
  collectPathNodes,
3190
3405
  convertAnchor,
3191
3406
  createVectorCommands,
3407
+ decomposeRigid,
3192
3408
  ellipseSubpath,
3193
3409
  findVectorNode,
3194
3410
  getAnchor,
3411
+ getAnchorWorld,
3195
3412
  getEditablePathIds,
3196
3413
  getTransformHandlePositions,
3197
3414
  getVectorOverlayRenderData,
3198
3415
  getVectorSceneRenderData,
3416
+ getVectorSelectionBoxGeometry,
3417
+ getVectorSelectionFrame,
3199
3418
  getVectorSelectionStyle,
3200
3419
  getVisibleAnchors,
3201
3420
  getVisibleHandles,
@@ -3204,12 +3423,16 @@ export {
3204
3423
  hitTestVectorNodes,
3205
3424
  inferAnchorType,
3206
3425
  insertAnchor,
3426
+ invertMatrix,
3427
+ isIdentityMatrix,
3207
3428
  isVectorTextTarget,
3208
3429
  lineSubpath,
3209
3430
  locateVectorNode,
3431
+ matrixRotation,
3210
3432
  moveAnchorHandle,
3211
3433
  multiplyMatrices,
3212
3434
  nearestSegment,
3435
+ nodeMatrix,
3213
3436
  parseSvgTransform,
3214
3437
  parseVectorSvg,
3215
3438
  polygonSubpath,
@@ -3233,11 +3456,16 @@ export {
3233
3456
  unionBounds,
3234
3457
  vectorDocumentToSvg,
3235
3458
  vectorNodeBounds,
3459
+ vectorNodeRotation,
3236
3460
  vectorNodeToBezier,
3237
3461
  vectorPaintToHex,
3238
3462
  vectorPaintToSvg,
3463
+ vectorParentMatrix,
3239
3464
  vectorPathData,
3465
+ vectorPathWorldBezier,
3466
+ vectorResizeCursor,
3240
3467
  vectorToolLabel,
3468
+ vectorWorldMatrix,
3241
3469
  walkVectorNodes
3242
3470
  };
3243
3471
  //# sourceMappingURL=index.js.map