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