@weasel-js/path-tools 0.0.0-stage → 1.10.1
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/LICENSE +21 -0
- package/README.md +47 -2
- package/dist/index.d.ts +713 -0
- package/dist/index.js +1730 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1730 @@
|
|
|
1
|
+
import { inPlane, editedPathSymmetries, pathEditPreview, locksInForce, pathOperationTags, editingLayer, joinPathsCommand, BridgeIcon, joinSelectedAnchors, makeHandleCommand, HarmonizeIcon, depOf, pathEditToolAction, SimplifyBrushIcon, DEFAULT_OVERLAY_STYLE, pathEditToolActive, registeredPathEffects, slotOf, pathEffectNode, commitOps, insertAt, DEFAULT_SIMPLIFY_TOLERANCE, deleteSelectedMark, resolveEditablePathOf, classifyPathStorage, commitAnchorEdit, anchorEditWouldChange, reshapeOps, HoldHandleAngleIcon, HoldHandleLengthIcon, pathEffectOf, createSetDataOp, derivedDepOf, pathEffectParts, deleteNode, selectedPathNodes, withPath, never, defineTool, nodeOutline, reshapeTo, composeAffordanceLayer, viewToTransform, worldToScreen, pathNodesOf, OVERLAY_ROLE_STROKES, SliceIcon } from '@weasel-js/core';
|
|
2
|
+
import { isBalanceGrip, ActionDisabledReason, resolveParams } from '@weasel-js/routing';
|
|
3
|
+
import { pathToAnchors, balanceFactorToward, editAnchorSetTracked, scaleSegmentHandles, harmonizeAnchors, simplifyAnchorsAt, locateAnchor, anchorsUnderBrush, pathAnchorPoints, unsatisfiedConstraints, solvePathConstraints, constraintAnchors, constrainPathEdit, constraintFits, PathBuilder, equalizeSegmentHandles, handleBalances, linePath, circlePath, curvatureComb, polylineFromPoints, worldEditToStorage, smudgePath, trimPathAt, boundsOfPath, transformPath, multiply, boxToBox, identity, openPathEnds, extendToMeet, pxExtent, poseFrameOf, applyPathWarp, invert, resizeEnvelope, pathDistanceToPoint, openEndRay, warpControls, applyToPoint, moveWarpControl, unwarpPoint, pathWarpMap, envelopeOver, distortOver, ellipsePath } from '@weasel-js/geom';
|
|
4
|
+
import { jsx } from 'react/jsx-runtime';
|
|
5
|
+
import { createEmitter } from '@weasel-js/registry';
|
|
6
|
+
import { useMemo, createElement } from 'react';
|
|
7
|
+
import { useLatest } from '@weasel-js/react';
|
|
8
|
+
|
|
9
|
+
// src/presets.ts
|
|
10
|
+
function gripTarget(dep, kind) {
|
|
11
|
+
if (!dep?.editingId || !kind) return null;
|
|
12
|
+
const m = kind.match(/^balance:(\d+)$/);
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
const path = dep.getEditablePath(dep.editingId);
|
|
15
|
+
if (!path || path.kind !== "polygon") return null;
|
|
16
|
+
const loc = locateAnchor(pathToAnchors(path), parseInt(m[1], 10));
|
|
17
|
+
return loc ? { dep, path, sub: loc.sub, segIdx: loc.idx } : null;
|
|
18
|
+
}
|
|
19
|
+
var NO_LOCKS = { angle: false, length: false };
|
|
20
|
+
function gripEnabled(deps) {
|
|
21
|
+
if (!deps?.editAnchors?.editingId || pathEditToolActive(deps)) return ActionDisabledReason.NotApplicable;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
var balanceHandlesAction = inPlane({
|
|
25
|
+
id: "balanceHandles",
|
|
26
|
+
label: "Balance handles",
|
|
27
|
+
tags: pathOperationTags("drag"),
|
|
28
|
+
// Ties the marquee's `empty` and move's `selected-body` on specificity and
|
|
29
|
+
// registers ahead of the marquee; move turns path-edit presses down itself.
|
|
30
|
+
defaultBinding: { kind: "drag", target: { kindOf: isBalanceGrip } },
|
|
31
|
+
cursor: "grab",
|
|
32
|
+
eligible: { capability: "edits-anchors" },
|
|
33
|
+
requires: ["editAnchors", "pathSymmetry"],
|
|
34
|
+
invoker: {
|
|
35
|
+
timing: "ongoing",
|
|
36
|
+
start(ctx) {
|
|
37
|
+
const target = gripTarget(ctx.deps.editAnchors, ctx.drag?.affordance?.kind);
|
|
38
|
+
if (!target) return {};
|
|
39
|
+
const { dep, path: originPath, sub, segIdx } = target;
|
|
40
|
+
const id = dep.editingId;
|
|
41
|
+
const origin = pathToAnchors(originPath);
|
|
42
|
+
const symmetries = editedPathSymmetries(ctx.deps.pathSymmetry, id, origin);
|
|
43
|
+
const press = { x: ctx.world.x, y: ctx.world.y };
|
|
44
|
+
const pressFactor = balanceFactorToward(origin, sub, segIdx, press);
|
|
45
|
+
let current = null;
|
|
46
|
+
const preview = pathEditPreview(dep, id, () => current?.path ?? null);
|
|
47
|
+
if (!preview) return {};
|
|
48
|
+
return {
|
|
49
|
+
kind: "balance-handles",
|
|
50
|
+
onMove(moveCtx) {
|
|
51
|
+
const held = (moveCtx.deps.editAnchors ?? dep).handleLocks ?? NO_LOCKS;
|
|
52
|
+
if (locksInForce(held, moveCtx.modifiers).length) {
|
|
53
|
+
current = null;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const at2 = balanceFactorToward(origin, sub, segIdx, moveCtx.world);
|
|
57
|
+
const factor = pressFactor > 0 ? at2 / pressFactor : at2;
|
|
58
|
+
current = editAnchorSetTracked(originPath, (set) => scaleSegmentHandles(set, sub, segIdx, factor, symmetries)) ?? current;
|
|
59
|
+
},
|
|
60
|
+
onEnd(_endCtx, reason) {
|
|
61
|
+
const done = current;
|
|
62
|
+
current = null;
|
|
63
|
+
if (reason === "cancel" || !done) return;
|
|
64
|
+
dep.applyEdit(id, done.path, "Balance handles", done.origins);
|
|
65
|
+
},
|
|
66
|
+
previewIds: preview.previewIds,
|
|
67
|
+
previewPose: preview.previewPose,
|
|
68
|
+
previewData: preview.previewData
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
enabled: gripEnabled
|
|
73
|
+
}, editingLayer);
|
|
74
|
+
var equalizeHandlesAction = {
|
|
75
|
+
id: "equalizeHandles",
|
|
76
|
+
label: "Equalize handles",
|
|
77
|
+
tags: pathOperationTags("click"),
|
|
78
|
+
defaultBinding: { kind: "doubleClick", target: { kindOf: isBalanceGrip } },
|
|
79
|
+
eligible: { capability: "edits-anchors" },
|
|
80
|
+
requires: ["editAnchors"],
|
|
81
|
+
invoker: {
|
|
82
|
+
timing: "immediate",
|
|
83
|
+
run: (deps, params) => {
|
|
84
|
+
const kind = params?.affordance?.kind;
|
|
85
|
+
const target = gripTarget(deps.editAnchors, kind);
|
|
86
|
+
if (!target) return;
|
|
87
|
+
const { dep, sub, segIdx } = target;
|
|
88
|
+
commitAnchorEdit(dep, "Equalize handles", (set) => equalizeSegmentHandles(set, sub, segIdx));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
enabled: gripEnabled
|
|
92
|
+
};
|
|
93
|
+
var PATH_BALANCE_CHROME_ID = "path-edit.balance";
|
|
94
|
+
var BALANCE_DEFAULTS = {
|
|
95
|
+
color: "#9b6bd6",
|
|
96
|
+
gripFill: "#ffffff",
|
|
97
|
+
gripRadiusPx: 4
|
|
98
|
+
};
|
|
99
|
+
function balanceCommands(set, toScreen, style = {}) {
|
|
100
|
+
const { color, gripFill, gripRadiusPx } = { ...BALANCE_DEFAULTS, ...style };
|
|
101
|
+
const paint = { kind: "solid", color };
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const line2 of handleBalances(set)) {
|
|
104
|
+
const grip = toScreen(line2.grip);
|
|
105
|
+
out.push({
|
|
106
|
+
kind: "path",
|
|
107
|
+
path: linePath(toScreen(line2.from), toScreen(line2.to)),
|
|
108
|
+
stroke: { paint, width: 1 }
|
|
109
|
+
});
|
|
110
|
+
out.push({
|
|
111
|
+
kind: "path",
|
|
112
|
+
path: circlePath(grip.x, grip.y, gripRadiusPx),
|
|
113
|
+
fill: { paint: { kind: "solid", color: gripFill } },
|
|
114
|
+
stroke: { paint, width: 1.5 }
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function createBalanceExtension(style = {}) {
|
|
120
|
+
return {
|
|
121
|
+
id: "balance",
|
|
122
|
+
marks: (ctx) => ctx.isVisible(PATH_BALANCE_CHROME_ID) ? balanceCommands(ctx.anchors, ctx.toScreen, style) : [],
|
|
123
|
+
parts: (target) => handleBalances(target.anchors).map((line2) => ({
|
|
124
|
+
x: line2.grip.x,
|
|
125
|
+
y: line2.grip.y,
|
|
126
|
+
hitKind: `balance:${line2.flat}`,
|
|
127
|
+
chromeId: PATH_BALANCE_CHROME_ID
|
|
128
|
+
}))
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
var DEFAULT_BRIDGE_TENSION = 0.4;
|
|
132
|
+
function bridgeTensionOf(params) {
|
|
133
|
+
const t = params?.tension;
|
|
134
|
+
return typeof t === "number" && Number.isFinite(t) ? Math.max(0, t) : DEFAULT_BRIDGE_TENSION;
|
|
135
|
+
}
|
|
136
|
+
var bridgePathsAction = joinPathsCommand(
|
|
137
|
+
{
|
|
138
|
+
id: "bridgePaths",
|
|
139
|
+
label: "Bridge",
|
|
140
|
+
icon: BridgeIcon,
|
|
141
|
+
// `code` too: with Shift held the key reads as `J`.
|
|
142
|
+
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } }
|
|
143
|
+
},
|
|
144
|
+
(params) => ({ tension: bridgeTensionOf(params) })
|
|
145
|
+
);
|
|
146
|
+
var join = joinSelectedAnchors({});
|
|
147
|
+
var bridgeAnchorsAction = {
|
|
148
|
+
id: "bridgeAnchors",
|
|
149
|
+
label: "Bridge anchors",
|
|
150
|
+
tags: pathOperationTags("command"),
|
|
151
|
+
icon: BridgeIcon,
|
|
152
|
+
group: "anchors",
|
|
153
|
+
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } },
|
|
154
|
+
eligible: { capability: "edits-anchors" },
|
|
155
|
+
requires: ["editAnchors"],
|
|
156
|
+
invoker: {
|
|
157
|
+
timing: "immediate",
|
|
158
|
+
run: (deps, params) => {
|
|
159
|
+
const dep = depOf(deps);
|
|
160
|
+
if (!dep || dep.selectedAnchors.size !== 2) return;
|
|
161
|
+
const selected = [...dep.selectedAnchors];
|
|
162
|
+
const bridge = joinSelectedAnchors({ tension: bridgeTensionOf(params) });
|
|
163
|
+
if (commitAnchorEdit(dep, "Bridge anchors", (set) => bridge(set, selected))) dep.setSelectedAnchors([]);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
enabled: (deps) => {
|
|
167
|
+
const dep = depOf(deps);
|
|
168
|
+
if (!dep || dep.selectedAnchors.size !== 2) return ActionDisabledReason.SelectionRequired;
|
|
169
|
+
const selected = [...dep.selectedAnchors];
|
|
170
|
+
return anchorEditWouldChange(deps, (set) => join(set, selected)) ? true : ActionDisabledReason.NotApplicable;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
var PATH_COMB_CHROME_ID = "path-edit.comb";
|
|
174
|
+
var COMB_DEFAULTS = { scale: 2e3, samplesPerSegment: 24, color: "rgba(224, 102, 58, 0.75)" };
|
|
175
|
+
function combCommands(set, toScreen, opts = {}) {
|
|
176
|
+
const { scale, samplesPerSegment, color } = { ...COMB_DEFAULTS, ...opts };
|
|
177
|
+
const paint = { kind: "solid", color };
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const { teeth } of curvatureComb(set, { scale, samplesPerSegment })) {
|
|
180
|
+
const tips = teeth.map((t) => toScreen({ x: t.tipX, y: t.tipY }));
|
|
181
|
+
teeth.forEach((t, i) => {
|
|
182
|
+
out.push({ kind: "path", path: linePath(toScreen(t), tips[i]), stroke: { paint, width: 1 } });
|
|
183
|
+
});
|
|
184
|
+
out.push({ kind: "path", path: polylineFromPoints(tips), stroke: { paint, width: 1.5 } });
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function createCombExtension(options = () => void 0) {
|
|
189
|
+
return {
|
|
190
|
+
id: "curvatureComb",
|
|
191
|
+
marks: (ctx) => ctx.isVisible(PATH_COMB_CHROME_ID) ? combCommands(ctx.anchors, ctx.toScreen, options()) : []
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
var NONE = [];
|
|
195
|
+
function constraintsOf(data) {
|
|
196
|
+
const list = data?.constraints;
|
|
197
|
+
return Array.isArray(list) ? list : NONE;
|
|
198
|
+
}
|
|
199
|
+
function constraintAxisOf(node) {
|
|
200
|
+
const storage = classifyPathStorage(node);
|
|
201
|
+
return storage?.kind === "data" ? storage.pose.rotation ?? 0 : 0;
|
|
202
|
+
}
|
|
203
|
+
function constraintConflictsOf(node) {
|
|
204
|
+
const constraints = constraintsOf(node?.data);
|
|
205
|
+
const path = constraints.length > 0 ? resolveEditablePathOf(node) : null;
|
|
206
|
+
if (!path) return [];
|
|
207
|
+
return unsatisfiedConstraints(pathAnchorPoints(path), constraints, { axisAngle: constraintAxisOf(node) });
|
|
208
|
+
}
|
|
209
|
+
function constrainNodeEdit(node, worldPath, origins) {
|
|
210
|
+
const constraints = constraintsOf(node?.data);
|
|
211
|
+
const before = constraints.length > 0 ? resolveEditablePathOf(node) : null;
|
|
212
|
+
if (!before) return { path: worldPath, constraints };
|
|
213
|
+
const edit = constrainPathEdit(before, worldPath, constraints, { origins, axisAngle: constraintAxisOf(node) });
|
|
214
|
+
if (!edit.converged) return null;
|
|
215
|
+
const same = edit.constraints.length === constraints.length && edit.constraints.every((c, i) => JSON.stringify(c) === JSON.stringify(constraints[i]));
|
|
216
|
+
return { path: edit.path, constraints: same ? constraints : edit.constraints };
|
|
217
|
+
}
|
|
218
|
+
function solveNodeConstraints(node, constraints) {
|
|
219
|
+
const path = resolveEditablePathOf(node);
|
|
220
|
+
if (!path) return null;
|
|
221
|
+
const axisAngle = constraintAxisOf(node);
|
|
222
|
+
const solved = solvePathConstraints(path, constraints, { axisAngle });
|
|
223
|
+
if (solved.converged) return { path: solved.path, unsatisfied: [] };
|
|
224
|
+
return { path, unsatisfied: unsatisfiedConstraints(pathAnchorPoints(path), constraints, { axisAngle }) };
|
|
225
|
+
}
|
|
226
|
+
var OFFSET = 12;
|
|
227
|
+
var at = (p, d, k) => ({ x: p.x + d.x * k, y: p.y + d.y * k });
|
|
228
|
+
function line(b, p, q) {
|
|
229
|
+
b.moveTo(p.x, p.y).lineTo(q.x, q.y);
|
|
230
|
+
}
|
|
231
|
+
function centroidOf(points) {
|
|
232
|
+
const n = points.length || 1;
|
|
233
|
+
return { x: points.reduce((s, p) => s + p.x, 0) / n, y: points.reduce((s, p) => s + p.y, 0) / n };
|
|
234
|
+
}
|
|
235
|
+
function toward(p, to) {
|
|
236
|
+
const len = Math.hypot(to.x - p.x, to.y - p.y);
|
|
237
|
+
return len > 1e-9 ? { x: (to.x - p.x) / len, y: (to.y - p.y) / len } : { x: 0, y: 1 };
|
|
238
|
+
}
|
|
239
|
+
function frameOf(points, [i, j], inside) {
|
|
240
|
+
const a = points[i];
|
|
241
|
+
const c = points[j];
|
|
242
|
+
const len = Math.hypot(c.x - a.x, c.y - a.y);
|
|
243
|
+
const u = len > 1e-9 ? { x: (c.x - a.x) / len, y: (c.y - a.y) / len } : { x: 1, y: 0 };
|
|
244
|
+
const m = { x: (a.x + c.x) / 2, y: (a.y + c.y) / 2 };
|
|
245
|
+
const left = { x: -u.y, y: u.x };
|
|
246
|
+
const n = left.x * (inside.x - m.x) + left.y * (inside.y - m.y) < 0 ? { x: u.y, y: -u.x } : left;
|
|
247
|
+
return { m, u, n, o: at(m, n, OFFSET) };
|
|
248
|
+
}
|
|
249
|
+
function segmentsOf(c) {
|
|
250
|
+
switch (c.kind) {
|
|
251
|
+
case "fixed":
|
|
252
|
+
case "coincident":
|
|
253
|
+
return [];
|
|
254
|
+
case "horizontal":
|
|
255
|
+
case "vertical":
|
|
256
|
+
case "length":
|
|
257
|
+
return [c.segment];
|
|
258
|
+
default:
|
|
259
|
+
return c.segments;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function markCenters(points, c, inside) {
|
|
263
|
+
if (c.kind === "fixed" || c.kind === "coincident") {
|
|
264
|
+
const p = points[c.kind === "fixed" ? c.anchor : c.anchors[0]];
|
|
265
|
+
return [at(p, toward(p, inside), c.kind === "fixed" ? 11 : 13)];
|
|
266
|
+
}
|
|
267
|
+
return segmentsOf(c).map((s) => {
|
|
268
|
+
const f = frameOf(points, s, inside);
|
|
269
|
+
return c.kind === "equalLength" ? f.m : f.o;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function letter(b, o, which) {
|
|
273
|
+
if (which === "H") {
|
|
274
|
+
line(b, { x: o.x - 3, y: o.y - 4 }, { x: o.x - 3, y: o.y + 4 });
|
|
275
|
+
line(b, { x: o.x + 3, y: o.y - 4 }, { x: o.x + 3, y: o.y + 4 });
|
|
276
|
+
line(b, { x: o.x - 3, y: o.y }, { x: o.x + 3, y: o.y });
|
|
277
|
+
} else {
|
|
278
|
+
b.moveTo(o.x - 3, o.y - 4).lineTo(o.x, o.y + 4).lineTo(o.x + 3, o.y - 4);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function segmentMark(b, points, kind, s, inside) {
|
|
282
|
+
const { m, u, n, o } = frameOf(points, s, inside);
|
|
283
|
+
switch (kind) {
|
|
284
|
+
case "parallel":
|
|
285
|
+
for (const k of [-2, 2]) line(b, at(at(o, n, k), u, -4), at(at(o, n, k), u, 4));
|
|
286
|
+
return;
|
|
287
|
+
case "perpendicular":
|
|
288
|
+
line(b, at(o, u, -4), at(o, u, 4));
|
|
289
|
+
line(b, o, at(o, n, 7));
|
|
290
|
+
return;
|
|
291
|
+
case "equalLength":
|
|
292
|
+
for (const k of [-2, 2]) line(b, at(at(m, u, k), n, -4), at(at(m, u, k), n, 4));
|
|
293
|
+
return;
|
|
294
|
+
case "length":
|
|
295
|
+
line(b, at(o, u, -8), at(o, u, 8));
|
|
296
|
+
for (const k of [-8, 8]) line(b, at(at(o, u, k), n, -3), at(at(o, u, k), n, 3));
|
|
297
|
+
return;
|
|
298
|
+
default:
|
|
299
|
+
letter(b, o, kind === "vertical" ? "V" : "H");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function glyphOf(b, points, c, inside) {
|
|
303
|
+
switch (c.kind) {
|
|
304
|
+
case "fixed": {
|
|
305
|
+
const p = points[c.anchor];
|
|
306
|
+
const d = toward(p, inside);
|
|
307
|
+
const side = { x: -d.y, y: d.x };
|
|
308
|
+
b.moveTo(...xy(at(p, d, 6))).lineTo(...xy(at(at(p, d, 14), side, -5))).lineTo(...xy(at(at(p, d, 14), side, 5))).close();
|
|
309
|
+
line(b, at(at(p, d, 17), side, -7), at(at(p, d, 17), side, 7));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case "coincident": {
|
|
313
|
+
const p = points[c.anchors[0]];
|
|
314
|
+
const d = toward(p, inside);
|
|
315
|
+
const ring = at(p, d, 13);
|
|
316
|
+
line(b, at(p, d, 6), at(p, d, 9));
|
|
317
|
+
b.moveTo(ring.x + 4, ring.y);
|
|
318
|
+
for (let k = 1; k <= 12; k++) b.lineTo(ring.x + 4 * Math.cos(k * Math.PI / 6), ring.y + 4 * Math.sin(k * Math.PI / 6));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
default:
|
|
322
|
+
for (const s of segmentsOf(c)) segmentMark(b, points, c.kind, s, inside);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
var xy = (p) => [p.x, p.y];
|
|
326
|
+
function constraintGlyphs(points, constraints, unsatisfied, style, selected = null) {
|
|
327
|
+
const inside = centroidOf(points);
|
|
328
|
+
const out = [];
|
|
329
|
+
constraints.forEach((c, i) => {
|
|
330
|
+
if (!constraintFits(c, points.length)) return;
|
|
331
|
+
const b = new PathBuilder();
|
|
332
|
+
glyphOf(b, points, c, inside);
|
|
333
|
+
const color = unsatisfied.has(i) ? style.conflictStroke : style.stroke;
|
|
334
|
+
out.push({ kind: "path", path: b.build(), stroke: { paint: { kind: "solid", color }, width: i === selected ? 3 : 1.5 } });
|
|
335
|
+
});
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
function constraintMarkAt(points, constraints, p, radius) {
|
|
339
|
+
const inside = centroidOf(points);
|
|
340
|
+
let best = -1;
|
|
341
|
+
let bestD = radius;
|
|
342
|
+
constraints.forEach((c, i) => {
|
|
343
|
+
if (!constraintFits(c, points.length)) return;
|
|
344
|
+
for (const m of markCenters(points, c, inside)) {
|
|
345
|
+
const d = Math.hypot(m.x - p.x, m.y - p.y);
|
|
346
|
+
if (d <= bestD) {
|
|
347
|
+
bestD = d;
|
|
348
|
+
best = i;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return best;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/constraints/constraintsExtension.ts
|
|
356
|
+
var CONSTRAINTS_EXTENSION_ID = "constraints";
|
|
357
|
+
var MARK_CLICK_PX = 8;
|
|
358
|
+
var DEFAULT_STYLE = { stroke: "#0e9f8e", conflictStroke: "#e5484d" };
|
|
359
|
+
function editedPath(dep) {
|
|
360
|
+
const p = dep.getEditablePath(dep.editingId);
|
|
361
|
+
return p?.kind === "polygon" ? p : null;
|
|
362
|
+
}
|
|
363
|
+
function setConstraints(dep, constraints, label) {
|
|
364
|
+
const node = dep.getNodeShape(dep.editingId);
|
|
365
|
+
const solved = node && dep.store ? solveNodeConstraints(node, constraints) : null;
|
|
366
|
+
if (!solved) return false;
|
|
367
|
+
dep.setSelectedMark?.(null);
|
|
368
|
+
dep.store(dep.editingId, solved.path, label, { constraints: [...constraints] });
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
function selectedConstraintOf(dep) {
|
|
372
|
+
const mark = dep.selectedMark;
|
|
373
|
+
return mark?.owner === CONSTRAINTS_EXTENSION_ID ? mark.index : null;
|
|
374
|
+
}
|
|
375
|
+
var screenPoints = (target, toScreen) => pathAnchorPoints(target.path).map(toScreen);
|
|
376
|
+
function createConstraintsExtension(style = {}) {
|
|
377
|
+
const colors = { ...DEFAULT_STYLE, ...style };
|
|
378
|
+
return {
|
|
379
|
+
id: CONSTRAINTS_EXTENSION_ID,
|
|
380
|
+
marks(ctx) {
|
|
381
|
+
const constraints = constraintsOf(ctx.node.data);
|
|
382
|
+
if (constraints.length === 0) return [];
|
|
383
|
+
const world = pathAnchorPoints(ctx.path);
|
|
384
|
+
const axisAngle = constraintAxisOf(ctx.node);
|
|
385
|
+
const unsatisfied = new Set(unsatisfiedConstraints(world, constraints, { axisAngle }));
|
|
386
|
+
return constraintGlyphs(world.map(ctx.toScreen), constraints, unsatisfied, colors, ctx.selected);
|
|
387
|
+
},
|
|
388
|
+
markAt(target, toScreen, screen) {
|
|
389
|
+
const constraints = constraintsOf(target.node.data);
|
|
390
|
+
if (constraints.length === 0) return -1;
|
|
391
|
+
return constraintMarkAt(screenPoints(target, toScreen), constraints, screen, MARK_CLICK_PX);
|
|
392
|
+
},
|
|
393
|
+
deleteMark(target, index, store) {
|
|
394
|
+
const current = constraintsOf(target.node.data);
|
|
395
|
+
if (index >= current.length) return;
|
|
396
|
+
const kept = current.filter((_, i) => i !== index);
|
|
397
|
+
const solved = solveNodeConstraints(target.node, kept);
|
|
398
|
+
if (solved) store(target.id, solved.path, "Delete constraint", { constraints: kept });
|
|
399
|
+
},
|
|
400
|
+
finishEdit(node, worldPath, origins) {
|
|
401
|
+
const done = constrainNodeEdit(node, worldPath, origins);
|
|
402
|
+
if (!done) return null;
|
|
403
|
+
return done.constraints === constraintsOf(node.data) ? { path: done.path } : { path: done.path, data: { constraints: done.constraints } };
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/constraints/anchorConstraints.ts
|
|
409
|
+
var pairsOf = (anchors) => anchors.slice(1).map((b, i) => [anchors[i], b]);
|
|
410
|
+
function segmentPair(anchors) {
|
|
411
|
+
if (anchors.length === 3) return [[anchors[0], anchors[1]], [anchors[1], anchors[2]]];
|
|
412
|
+
if (anchors.length === 4) return [[anchors[0], anchors[1]], [anchors[2], anchors[3]]];
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
function constraintsForAnchors(kind, anchors, path, length) {
|
|
416
|
+
switch (kind) {
|
|
417
|
+
case "fixed":
|
|
418
|
+
return anchors.map((anchor) => ({ kind, anchor }));
|
|
419
|
+
case "horizontal":
|
|
420
|
+
case "vertical":
|
|
421
|
+
return pairsOf(anchors).map((segment) => ({ kind, segment }));
|
|
422
|
+
case "coincident":
|
|
423
|
+
return pairsOf(anchors).map((pair) => ({ kind, anchors: pair }));
|
|
424
|
+
case "length": {
|
|
425
|
+
const points = pathAnchorPoints(path);
|
|
426
|
+
return pairsOf(anchors).map((segment) => ({
|
|
427
|
+
kind,
|
|
428
|
+
segment,
|
|
429
|
+
length: length ?? Math.hypot(points[segment[1]].x - points[segment[0]].x, points[segment[1]].y - points[segment[0]].y)
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
default: {
|
|
433
|
+
const segments = segmentPair(anchors);
|
|
434
|
+
return segments ? [{ kind, segments }] : [];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
var sameConstraint = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
439
|
+
function withAdded(dep, params) {
|
|
440
|
+
const path = editedPath(dep);
|
|
441
|
+
if (!path || !params?.kind) return null;
|
|
442
|
+
const current = constraintsOf(dep.getNodeShape(dep.editingId)?.data);
|
|
443
|
+
const added = constraintsForAnchors(params.kind, [...dep.selectedAnchors], path, params.length).filter((c) => !current.some((had) => sameConstraint(had, c)));
|
|
444
|
+
return added.length > 0 ? [...current, ...added] : null;
|
|
445
|
+
}
|
|
446
|
+
function withRemoved(dep, kind) {
|
|
447
|
+
const current = constraintsOf(dep.getNodeShape(dep.editingId)?.data);
|
|
448
|
+
const kept = current.filter((c) => kind !== void 0 && c.kind !== kind || !constraintAnchors(c).every((a) => dep.selectedAnchors.has(a)));
|
|
449
|
+
return kept.length < current.length ? kept : null;
|
|
450
|
+
}
|
|
451
|
+
function constraintCommand(id, label, next) {
|
|
452
|
+
const ready = (deps) => {
|
|
453
|
+
const dep = depOf(deps);
|
|
454
|
+
return dep?.store && dep.selectedAnchors.size > 0 ? dep : null;
|
|
455
|
+
};
|
|
456
|
+
return {
|
|
457
|
+
id,
|
|
458
|
+
label,
|
|
459
|
+
group: "anchors",
|
|
460
|
+
tags: pathOperationTags("drag"),
|
|
461
|
+
eligible: { capability: "edits-anchors" },
|
|
462
|
+
requires: ["editAnchors"],
|
|
463
|
+
invoker: {
|
|
464
|
+
timing: "immediate",
|
|
465
|
+
run: (deps, params) => {
|
|
466
|
+
const dep = ready(deps);
|
|
467
|
+
const list = dep && next(dep, params);
|
|
468
|
+
if (dep && list) setConstraints(dep, list, label);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
enabled: (deps) => ready(deps) ? true : ActionDisabledReason.SelectionRequired
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
var deleteConstraintAction = {
|
|
475
|
+
id: "deleteConstraint",
|
|
476
|
+
label: "Delete constraint",
|
|
477
|
+
eligible: { capability: "edits-anchors" },
|
|
478
|
+
requires: ["editAnchors"],
|
|
479
|
+
invoker: {
|
|
480
|
+
timing: "immediate",
|
|
481
|
+
run: (deps) => {
|
|
482
|
+
const dep = depOf(deps);
|
|
483
|
+
if (dep && selectedConstraintOf(dep) !== null) deleteSelectedMark(dep);
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
enabled: (deps) => {
|
|
487
|
+
const dep = depOf(deps);
|
|
488
|
+
return dep?.store && selectedConstraintOf(dep) !== null ? true : ActionDisabledReason.SelectionRequired;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
var constrainAnchorsAction = constraintCommand(
|
|
492
|
+
"constrainAnchors",
|
|
493
|
+
"Constrain anchors",
|
|
494
|
+
(dep, params) => withAdded(dep, params)
|
|
495
|
+
);
|
|
496
|
+
var unconstrainAnchorsAction = constraintCommand(
|
|
497
|
+
"unconstrainAnchors",
|
|
498
|
+
"Remove constraints",
|
|
499
|
+
(dep, params) => withRemoved(dep, params?.kind)
|
|
500
|
+
);
|
|
501
|
+
var DEFAULT_STROKE_REACH_PX = 6;
|
|
502
|
+
function pressOf(deps, params) {
|
|
503
|
+
const p = params;
|
|
504
|
+
const x = p?.pressX ?? p?.worldX, y = p?.pressY ?? p?.worldY;
|
|
505
|
+
if (x === void 0 || y === void 0) return null;
|
|
506
|
+
const scale = deps.view?.get().scale ?? { x: 1, y: 1 };
|
|
507
|
+
const radius = (p?.radiusPx ?? DEFAULT_STROKE_REACH_PX) / Math.max(Math.abs(scale.x), Math.abs(scale.y));
|
|
508
|
+
return { at: { x, y }, radius };
|
|
509
|
+
}
|
|
510
|
+
function renderOrder(deps) {
|
|
511
|
+
const scene = deps?.scene;
|
|
512
|
+
return scene?.renderOrderNodes?.() ?? [];
|
|
513
|
+
}
|
|
514
|
+
function scenePathNodes(deps) {
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const node of renderOrder(deps)) {
|
|
517
|
+
if (node.kind !== "leaf") continue;
|
|
518
|
+
const path = resolveEditablePathOf(node);
|
|
519
|
+
if (path) out.push({ id: node.id, node, path });
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
function strokesBesides(deps, id) {
|
|
524
|
+
const out = [];
|
|
525
|
+
for (const node of renderOrder(deps)) {
|
|
526
|
+
if (node.kind !== "leaf" || node.id === id) continue;
|
|
527
|
+
const outline = nodeOutline(node);
|
|
528
|
+
if (outline) out.push(outline);
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/extend/extendToMeet.ts
|
|
534
|
+
var EXTEND_LABEL = "Extend to next path";
|
|
535
|
+
function endsAt(path, anchors) {
|
|
536
|
+
const ends = openPathEnds(path);
|
|
537
|
+
if (anchors.size === 0) return ends;
|
|
538
|
+
const set = pathToAnchors(path);
|
|
539
|
+
const picked = ends.filter((e) => [...anchors].some((flat) => {
|
|
540
|
+
const at2 = locateAnchor(set, flat);
|
|
541
|
+
if (!at2 || at2.sub !== e.sub) return false;
|
|
542
|
+
return e.end === "first" ? at2.idx === 0 : at2.idx === set.anchors[at2.sub].length - 1;
|
|
543
|
+
}));
|
|
544
|
+
return picked.length > 0 ? picked : ends;
|
|
545
|
+
}
|
|
546
|
+
var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
547
|
+
function endNearPress(all, press) {
|
|
548
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
549
|
+
const node = all[i];
|
|
550
|
+
if (pathDistanceToPoint(node.path, press.at.x, press.at.y) > press.radius) continue;
|
|
551
|
+
const near = openPathEnds(node.path).map((end) => ({ end, ray: openEndRay(node.path, end) })).filter((e) => e.ray).sort((a, b) => dist(a.ray.at, press.at) - dist(b.ray.at, press.at))[0];
|
|
552
|
+
if (near) return [{ node, ends: [near.end] }];
|
|
553
|
+
}
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
function planExtend(deps, params) {
|
|
557
|
+
const out = /* @__PURE__ */ new Map();
|
|
558
|
+
const all = scenePathNodes(deps);
|
|
559
|
+
const editing = deps?.editAnchors?.editingId;
|
|
560
|
+
const press = deps ? pressOf(deps, params) : null;
|
|
561
|
+
const subjects = press ? endNearPress(all, press) : editing ? all.filter((n) => n.id === editing).map((node) => ({ node, ends: endsAt(node.path, deps.editAnchors.selectedAnchors) })) : selectedPathNodes(deps).map((node) => ({ node, ends: openPathEnds(node.path) }));
|
|
562
|
+
for (const { node, ends } of subjects) {
|
|
563
|
+
const boundaries = strokesBesides(deps, node.id);
|
|
564
|
+
let path = node.path;
|
|
565
|
+
for (const end of ends) path = extendToMeet(path, boundaries, end)?.path ?? path;
|
|
566
|
+
if (path !== node.path) out.set(node.id, { node, path });
|
|
567
|
+
}
|
|
568
|
+
return out;
|
|
569
|
+
}
|
|
570
|
+
var extendToMeetAction = {
|
|
571
|
+
id: "extendToMeet",
|
|
572
|
+
label: EXTEND_LABEL,
|
|
573
|
+
tags: pathOperationTags("click"),
|
|
574
|
+
group: "path",
|
|
575
|
+
eligible: { capability: "creates-shapes" },
|
|
576
|
+
requires: ["selection", "scene", "applyOps", "editAnchors", "view"],
|
|
577
|
+
invoker: {
|
|
578
|
+
timing: "immediate",
|
|
579
|
+
run: (deps, params) => {
|
|
580
|
+
const ops = [];
|
|
581
|
+
for (const { node, path } of planExtend(deps, params).values()) ops.push(...reshapeOps(node.node, path, EXTEND_LABEL));
|
|
582
|
+
commitOps(deps, ops, EXTEND_LABEL);
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
// A click's point arrives here without the binding's reach, so `run` decides there.
|
|
586
|
+
enabled: (deps, at2) => at2 || planExtend(deps).size > 0 ? true : ActionDisabledReason.NotApplicable
|
|
587
|
+
};
|
|
588
|
+
function makeHandleLockToggle(lock, label, icon) {
|
|
589
|
+
return {
|
|
590
|
+
id: `handleLock.${lock}`,
|
|
591
|
+
label,
|
|
592
|
+
tags: pathOperationTags("drag"),
|
|
593
|
+
icon,
|
|
594
|
+
group: "anchors",
|
|
595
|
+
requires: ["editAnchors"],
|
|
596
|
+
active: (deps) => deps?.editAnchors?.handleLocks?.[lock] === true,
|
|
597
|
+
enabled: (deps) => deps?.editAnchors?.setHandleLocks ? true : ActionDisabledReason.NotApplicable,
|
|
598
|
+
invoker: {
|
|
599
|
+
timing: "immediate",
|
|
600
|
+
run: (deps) => {
|
|
601
|
+
const dep = deps?.editAnchors;
|
|
602
|
+
if (!dep?.setHandleLocks) return;
|
|
603
|
+
const held = dep.handleLocks ?? { angle: false, length: false };
|
|
604
|
+
dep.setHandleLocks({ ...held, [lock]: !held[lock] });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
var handleLockAngleAction = makeHandleLockToggle("angle", "Hold handle angle", /* @__PURE__ */ jsx(HoldHandleAngleIcon, {}));
|
|
610
|
+
var handleLockLengthAction = makeHandleLockToggle("length", "Hold handle length", /* @__PURE__ */ jsx(HoldHandleLengthIcon, {}));
|
|
611
|
+
var harmonizeAnchorsAction = makeHandleCommand(
|
|
612
|
+
"harmonizeAnchors",
|
|
613
|
+
"Harmonize curvature",
|
|
614
|
+
/* @__PURE__ */ jsx(HarmonizeIcon, {}),
|
|
615
|
+
harmonizeAnchors,
|
|
616
|
+
"selectedOrAll"
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// src/pathEffects/params.ts
|
|
620
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
621
|
+
var bool = (v) => typeof v === "boolean" ? v : void 0;
|
|
622
|
+
var oneOf = (v, options) => typeof v === "string" && options.includes(v) ? v : void 0;
|
|
623
|
+
function defined(fields) {
|
|
624
|
+
return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== void 0));
|
|
625
|
+
}
|
|
626
|
+
function effectSettings(kind, params) {
|
|
627
|
+
const p = params ?? {};
|
|
628
|
+
switch (kind) {
|
|
629
|
+
case "blend":
|
|
630
|
+
return defined({
|
|
631
|
+
steps: num(p.steps) === void 0 ? void 0 : Math.max(Math.round(num(p.steps)), 0),
|
|
632
|
+
autoAlign: bool(p.autoAlign),
|
|
633
|
+
startOffset: num(p.startOffset) === void 0 ? void 0 : Math.round(num(p.startOffset)),
|
|
634
|
+
reverse: bool(p.reverse),
|
|
635
|
+
rotate: bool(p.rotate)
|
|
636
|
+
});
|
|
637
|
+
case "bendAlong":
|
|
638
|
+
return defined({
|
|
639
|
+
fit: oneOf(p.fit, ["stretch", "natural"]),
|
|
640
|
+
baseline: num(p.baseline),
|
|
641
|
+
flip: bool(p.flip),
|
|
642
|
+
tolerance: num(p.tolerance) === void 0 ? void 0 : Math.max(num(p.tolerance), 0.01)
|
|
643
|
+
});
|
|
644
|
+
case "repeatAlong":
|
|
645
|
+
return defined({
|
|
646
|
+
count: num(p.count) === void 0 ? void 0 : Math.max(Math.round(num(p.count)), 0),
|
|
647
|
+
spacing: num(p.spacing) === void 0 ? void 0 : Math.max(num(p.spacing), 0),
|
|
648
|
+
align: oneOf(p.align, ["start", "center", "end"]),
|
|
649
|
+
offset: num(p.offset),
|
|
650
|
+
orient: bool(p.orient)
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function withEffectSettings(effect, params) {
|
|
655
|
+
const next = { ...effect, ...effectSettings(effect.kind, params) };
|
|
656
|
+
if (next.kind === "blend" && params?.reverse === "auto") delete next.reverse;
|
|
657
|
+
return next;
|
|
658
|
+
}
|
|
659
|
+
var EFFECT_DEFAULTS = {
|
|
660
|
+
blend: { kind: "blend", steps: 4 },
|
|
661
|
+
bendAlong: { kind: "bendAlong" },
|
|
662
|
+
repeatAlong: { kind: "repeatAlong" }
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
// src/pathEffects/actions.ts
|
|
666
|
+
var PATH_EFFECTS_GROUP = "pathEffects";
|
|
667
|
+
function selectedPair(deps) {
|
|
668
|
+
const scene = deps?.scene;
|
|
669
|
+
if (!scene || registeredPathEffects(scene.registry) === null) return null;
|
|
670
|
+
const leaves = (deps?.selection?.get() ?? []).map((id) => scene.get(id)).filter((n) => n !== void 0 && n.kind === "leaf");
|
|
671
|
+
return leaves.length >= 2 ? [leaves[0], leaves[1]] : null;
|
|
672
|
+
}
|
|
673
|
+
function selectedEffects(deps, ids) {
|
|
674
|
+
const scene = deps?.scene;
|
|
675
|
+
if (!scene) return [];
|
|
676
|
+
return (ids ?? deps?.selection?.get() ?? []).map((id) => scene.get(id)).filter((n) => n !== void 0 && pathEffectOf(n) !== null);
|
|
677
|
+
}
|
|
678
|
+
function createEffectAction(spec) {
|
|
679
|
+
return {
|
|
680
|
+
id: spec.id,
|
|
681
|
+
label: spec.label,
|
|
682
|
+
tags: pathOperationTags("command"),
|
|
683
|
+
group: PATH_EFFECTS_GROUP,
|
|
684
|
+
// Alt+Shift, which no major browser takes for these letters (see
|
|
685
|
+
// `BROWSER_RESERVED_CHORDS`); `code` too, since with Alt held on a Mac the
|
|
686
|
+
// key reads as another glyph.
|
|
687
|
+
defaultBinding: { kind: "key", key: spec.key, code: spec.code, mods: { alt: true, shift: true } },
|
|
688
|
+
eligible: { capability: "creates-shapes" },
|
|
689
|
+
requires: ["selection", "scene", "applyOps"],
|
|
690
|
+
invoker: {
|
|
691
|
+
timing: "immediate",
|
|
692
|
+
run: (deps, params) => {
|
|
693
|
+
const scene = deps.scene;
|
|
694
|
+
const pair = selectedPair(deps);
|
|
695
|
+
if (!scene || !pair) return;
|
|
696
|
+
const derivations = registeredPathEffects(scene.registry);
|
|
697
|
+
const [a, b] = pair;
|
|
698
|
+
const effect = withEffectSettings(EFFECT_DEFAULTS[spec.kind], params);
|
|
699
|
+
const sameParent = a.parent === b.parent;
|
|
700
|
+
const slots = [slotOf(scene, a.id), slotOf(scene, b.id)];
|
|
701
|
+
const index = !sameParent ? -1 : spec.between ? Math.max(...slots) : Math.max(...slots) + 1;
|
|
702
|
+
const node = {
|
|
703
|
+
...pathEffectNode(effect, [a.id, b.id], a.layer, derivations),
|
|
704
|
+
id: scene.mintId(),
|
|
705
|
+
parent: sameParent ? a.parent : null
|
|
706
|
+
};
|
|
707
|
+
commitOps(deps, [insertAt(node, index, spec.label)], spec.label);
|
|
708
|
+
deps.selection?.set([node.id]);
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
enabled: (deps) => {
|
|
712
|
+
const scene = deps?.scene;
|
|
713
|
+
if (scene && registeredPathEffects(scene.registry) === null) return ActionDisabledReason.NotApplicable;
|
|
714
|
+
return selectedPair(deps) ? true : ActionDisabledReason.SelectionRequired;
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
var blendAction = createEffectAction({
|
|
719
|
+
id: "blend",
|
|
720
|
+
label: "Blend",
|
|
721
|
+
kind: "blend",
|
|
722
|
+
key: "b",
|
|
723
|
+
code: "KeyB",
|
|
724
|
+
between: true
|
|
725
|
+
});
|
|
726
|
+
var bendAlongAction = createEffectAction({
|
|
727
|
+
id: "bendAlong",
|
|
728
|
+
label: "Bend Along Path",
|
|
729
|
+
kind: "bendAlong",
|
|
730
|
+
key: "w",
|
|
731
|
+
code: "KeyW",
|
|
732
|
+
between: false
|
|
733
|
+
});
|
|
734
|
+
var repeatAlongAction = createEffectAction({
|
|
735
|
+
id: "repeatAlong",
|
|
736
|
+
label: "Repeat Along Path",
|
|
737
|
+
kind: "repeatAlong",
|
|
738
|
+
key: "r",
|
|
739
|
+
code: "KeyR",
|
|
740
|
+
between: false
|
|
741
|
+
});
|
|
742
|
+
var EDIT_LABEL = "Edit Path Effect";
|
|
743
|
+
var editPathEffectAction = {
|
|
744
|
+
id: "editPathEffect",
|
|
745
|
+
label: EDIT_LABEL,
|
|
746
|
+
requires: ["scene", "applyOps"],
|
|
747
|
+
invoker: {
|
|
748
|
+
timing: "immediate",
|
|
749
|
+
run: (deps, params) => {
|
|
750
|
+
const p = params;
|
|
751
|
+
const ids = Array.isArray(p?.ids) ? p.ids : void 0;
|
|
752
|
+
const ops = [];
|
|
753
|
+
for (const node of selectedEffects(deps, ids)) {
|
|
754
|
+
const effect = pathEffectOf(node);
|
|
755
|
+
const next = withEffectSettings(effect, p);
|
|
756
|
+
if (sameEffect(effect, next)) continue;
|
|
757
|
+
ops.push(createSetDataOp({
|
|
758
|
+
id: node.id,
|
|
759
|
+
from: node.data,
|
|
760
|
+
to: { ...node.data, pathEffect: next },
|
|
761
|
+
label: EDIT_LABEL,
|
|
762
|
+
coalesceKey: `${EDIT_LABEL}:${node.id}`
|
|
763
|
+
}));
|
|
764
|
+
}
|
|
765
|
+
commitOps(deps, ops, EDIT_LABEL);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
function sameEffect(a, b) {
|
|
770
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
771
|
+
for (const k of keys) {
|
|
772
|
+
if (a[k] !== b[k]) return false;
|
|
773
|
+
}
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
var EXPAND_LABEL = "Expand to Paths";
|
|
777
|
+
var expandPathEffectAction = {
|
|
778
|
+
id: "expandPathEffect",
|
|
779
|
+
label: EXPAND_LABEL,
|
|
780
|
+
group: PATH_EFFECTS_GROUP,
|
|
781
|
+
defaultBinding: { kind: "key", key: "e", code: "KeyE", mods: { alt: true, shift: true } },
|
|
782
|
+
eligible: { capability: "creates-shapes" },
|
|
783
|
+
requires: ["selection", "scene", "applyOps"],
|
|
784
|
+
invoker: {
|
|
785
|
+
timing: "immediate",
|
|
786
|
+
run: (deps) => {
|
|
787
|
+
const scene = deps.scene;
|
|
788
|
+
if (!scene) return;
|
|
789
|
+
const effects = selectedEffects(deps).map((node) => ({ node, at: slotOf(scene, node.id) })).sort((x, y) => y.at - x.at);
|
|
790
|
+
const ops = [];
|
|
791
|
+
const made = [];
|
|
792
|
+
for (const { node, at: at2 } of effects) {
|
|
793
|
+
const sources = (Array.isArray(node.dependsOn) ? node.dependsOn : []).map((id) => derivedDepOf(scene, id));
|
|
794
|
+
const parts = pathEffectParts(node, sources) ?? [];
|
|
795
|
+
ops.push(deleteNode(scene, node, EXPAND_LABEL));
|
|
796
|
+
parts.forEach((part, k) => {
|
|
797
|
+
const { pose, path } = worldEditToStorage({ x: 0, y: 0, width: 0, height: 0 }, part.path);
|
|
798
|
+
const data = {
|
|
799
|
+
path,
|
|
800
|
+
...part.fill === void 0 ? {} : { fill: part.fill },
|
|
801
|
+
...part.stroke === void 0 ? {} : { stroke: part.stroke }
|
|
802
|
+
};
|
|
803
|
+
const copy = { id: scene.mintId(), kind: "leaf", layer: node.layer, parent: node.parent, pose, data };
|
|
804
|
+
ops.push(insertAt(copy, at2 < 0 ? at2 : at2 + k, EXPAND_LABEL));
|
|
805
|
+
made.push(copy.id);
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
if (ops.length === 0) return;
|
|
809
|
+
commitOps(deps, ops, EXPAND_LABEL);
|
|
810
|
+
deps.selection?.set(made);
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
enabled: (deps) => selectedEffects(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
814
|
+
};
|
|
815
|
+
var SIMPLIFY_BRUSH_TOOL = "simplifyBrush";
|
|
816
|
+
var SIMPLIFY_BRUSH_RADIUS_PX = 12;
|
|
817
|
+
function brushRingCursor(radiusPx) {
|
|
818
|
+
return { glyph: "brush", size: Math.round(radiusPx * 24 / 9.9), fallback: "crosshair" };
|
|
819
|
+
}
|
|
820
|
+
var brushing = (deps) => depOf(deps)?.tool === SIMPLIFY_BRUSH_TOOL;
|
|
821
|
+
function radiusOf(params) {
|
|
822
|
+
const r = params?.radiusPx;
|
|
823
|
+
return typeof r === "number" && r > 0 ? r : SIMPLIFY_BRUSH_RADIUS_PX;
|
|
824
|
+
}
|
|
825
|
+
function toleranceOf(params) {
|
|
826
|
+
const t = params?.tolerance;
|
|
827
|
+
return typeof t === "number" && t >= 0 ? t : DEFAULT_SIMPLIFY_TOLERANCE;
|
|
828
|
+
}
|
|
829
|
+
var simplifyBrushAction = inPlane({
|
|
830
|
+
id: "simplifyBrush",
|
|
831
|
+
label: "Simplify brush",
|
|
832
|
+
tags: pathOperationTags("brush"),
|
|
833
|
+
// Declines through `enabled` unless its tool is on.
|
|
834
|
+
defaultBinding: [{ spec: { kind: "drag" }, opts: { yields: true } }],
|
|
835
|
+
cursor: (params) => brushRingCursor(radiusOf(params)),
|
|
836
|
+
eligible: { capability: "edits-anchors" },
|
|
837
|
+
requires: ["editAnchors", "view"],
|
|
838
|
+
invoker: {
|
|
839
|
+
timing: "ongoing",
|
|
840
|
+
start(ctx, opts) {
|
|
841
|
+
const dep = depOf(ctx.deps);
|
|
842
|
+
if (!dep || dep.tool !== SIMPLIFY_BRUSH_TOOL) return {};
|
|
843
|
+
const id = dep.editingId;
|
|
844
|
+
const origin = dep.getEditablePath(id);
|
|
845
|
+
if (!origin || origin.kind !== "polygon") return {};
|
|
846
|
+
const originSet = pathToAnchors(origin);
|
|
847
|
+
const params = () => resolveParams(opts?.params);
|
|
848
|
+
const radiusPx = () => radiusOf(params());
|
|
849
|
+
const tolerance = () => toleranceOf(params());
|
|
850
|
+
const scale = () => ctx.deps.view?.get().scale;
|
|
851
|
+
const trail = [];
|
|
852
|
+
const gathered = /* @__PURE__ */ new Set();
|
|
853
|
+
let current = null;
|
|
854
|
+
const sweepTo = (p) => {
|
|
855
|
+
const from = trail[trail.length - 1] ?? p;
|
|
856
|
+
trail.push({ x: p.x, y: p.y });
|
|
857
|
+
const before = gathered.size;
|
|
858
|
+
for (const i of anchorsUnderBrush(originSet, from, p, { radiusPx: radiusPx(), scale: scale() })) {
|
|
859
|
+
gathered.add(i);
|
|
860
|
+
}
|
|
861
|
+
if (gathered.size === before) return;
|
|
862
|
+
current = editAnchorSetTracked(origin, (set) => simplifyAnchorsAt(set, gathered, tolerance()));
|
|
863
|
+
};
|
|
864
|
+
const preview = pathEditPreview(dep, id, () => current?.path ?? null);
|
|
865
|
+
if (!preview) return {};
|
|
866
|
+
sweepTo(ctx.world);
|
|
867
|
+
return {
|
|
868
|
+
kind: "simplify-brush",
|
|
869
|
+
cursor: () => brushRingCursor(radiusPx()),
|
|
870
|
+
onMove(moveCtx) {
|
|
871
|
+
sweepTo(moveCtx.world);
|
|
872
|
+
},
|
|
873
|
+
onEnd(_endCtx, reason) {
|
|
874
|
+
if (reason === "cancel") {
|
|
875
|
+
current = null;
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const done = gathered.size > 0 ? editAnchorSetTracked(origin, (set) => simplifyAnchorsAt(set, gathered, tolerance())) : null;
|
|
879
|
+
current = null;
|
|
880
|
+
if (!done) return;
|
|
881
|
+
dep.applyEdit(id, done.path, "Simplify brush", done.origins);
|
|
882
|
+
dep.setSelectedAnchors([]);
|
|
883
|
+
},
|
|
884
|
+
overlay: () => ({ kind: "brush", points: trail.slice(), radiusPx: radiusPx() }),
|
|
885
|
+
previewIds: preview.previewIds,
|
|
886
|
+
previewPose: preview.previewPose,
|
|
887
|
+
previewData: preview.previewData
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
enabled: (deps) => brushing(deps) ? true : ActionDisabledReason.NotApplicable
|
|
892
|
+
}, editingLayer);
|
|
893
|
+
var simplifyBrushToolAction = pathEditToolAction(SIMPLIFY_BRUSH_TOOL, "Simplify brush", SimplifyBrushIcon);
|
|
894
|
+
var DEFAULT_SMUDGE_RADIUS_PX = 40;
|
|
895
|
+
var DEFAULT_SMUDGE_STRENGTH = 0.6;
|
|
896
|
+
var LABEL = "Smudge brush";
|
|
897
|
+
var num2 = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
898
|
+
function radiusOf2(params) {
|
|
899
|
+
return Math.max(1, num2(params?.radiusPx, DEFAULT_SMUDGE_RADIUS_PX));
|
|
900
|
+
}
|
|
901
|
+
function strengthOf(params) {
|
|
902
|
+
return Math.min(1, Math.max(0, num2(params?.strength, DEFAULT_SMUDGE_STRENGTH)));
|
|
903
|
+
}
|
|
904
|
+
function worldRadius(deps, radiusPx) {
|
|
905
|
+
const reach = pxExtent(radiusPx, deps.view?.get().scale ?? { x: 1, y: 1 });
|
|
906
|
+
return Math.max(reach.x, reach.y);
|
|
907
|
+
}
|
|
908
|
+
var smudgeBrushAction = {
|
|
909
|
+
id: "smudgeBrush",
|
|
910
|
+
label: LABEL,
|
|
911
|
+
tags: pathOperationTags("brush"),
|
|
912
|
+
group: "path",
|
|
913
|
+
eligible: { capability: "creates-shapes" },
|
|
914
|
+
cursor: (params) => brushRingCursor(radiusOf2(params)),
|
|
915
|
+
requires: ["selection", "scene", "view", "applyOps"],
|
|
916
|
+
invoker: {
|
|
917
|
+
timing: "ongoing",
|
|
918
|
+
start(ctx, opts) {
|
|
919
|
+
const nodes = selectedPathNodes(ctx.deps);
|
|
920
|
+
if (nodes.length === 0) return {};
|
|
921
|
+
const { deps } = ctx;
|
|
922
|
+
const params = (move) => ({ ...resolveParams(opts?.params), ...move.params });
|
|
923
|
+
let radiusPx = radiusOf2(params(ctx));
|
|
924
|
+
let at2 = { x: ctx.drag?.start.x ?? ctx.world.x, y: ctx.drag?.start.y ?? ctx.world.y };
|
|
925
|
+
const current = /* @__PURE__ */ new Map();
|
|
926
|
+
const shapeOf = (id) => {
|
|
927
|
+
const entry = current.get(id);
|
|
928
|
+
return entry ? withPath(entry.node.node, entry.path) : null;
|
|
929
|
+
};
|
|
930
|
+
return {
|
|
931
|
+
kind: "smudge-brush",
|
|
932
|
+
cursor: () => brushRingCursor(radiusPx),
|
|
933
|
+
onMove(move) {
|
|
934
|
+
const p = params(move);
|
|
935
|
+
radiusPx = radiusOf2(p);
|
|
936
|
+
const to = { x: move.world.x, y: move.world.y };
|
|
937
|
+
const radius = worldRadius(deps, radiusPx);
|
|
938
|
+
for (const node of nodes) {
|
|
939
|
+
const from = current.get(node.id)?.path ?? node.path;
|
|
940
|
+
const next = smudgePath(from, {
|
|
941
|
+
center: at2,
|
|
942
|
+
delta: { x: to.x - at2.x, y: to.y - at2.y },
|
|
943
|
+
radius,
|
|
944
|
+
strength: strengthOf(p)
|
|
945
|
+
});
|
|
946
|
+
if (next !== from) current.set(node.id, { node, path: next });
|
|
947
|
+
}
|
|
948
|
+
at2 = to;
|
|
949
|
+
},
|
|
950
|
+
onEnd(_end, reason) {
|
|
951
|
+
const done = [...current.values()];
|
|
952
|
+
current.clear();
|
|
953
|
+
if (reason === "cancel" || done.length === 0) return;
|
|
954
|
+
commitOps(deps, done.flatMap(({ node, path }) => reshapeOps(node.node, path, LABEL)), LABEL);
|
|
955
|
+
},
|
|
956
|
+
previewIds: () => current.size > 0 ? [...current.keys()] : null,
|
|
957
|
+
previewPose: (id) => shapeOf(id)?.pose ?? null,
|
|
958
|
+
previewData: (id) => shapeOf(id)?.data ?? null
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
enabled: (deps) => selectedPathNodes(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
963
|
+
};
|
|
964
|
+
var TRIM_LABEL = "Trim segment";
|
|
965
|
+
function planTrim(deps, params) {
|
|
966
|
+
const press = pressOf(deps, params);
|
|
967
|
+
if (!press) return null;
|
|
968
|
+
const all = scenePathNodes(deps);
|
|
969
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
970
|
+
const target = all[i];
|
|
971
|
+
const result = trimPathAt(target.path, strokesBesides(deps, target.id), press.at, { radius: press.radius });
|
|
972
|
+
if (result) return { target, result };
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
var trimPathAction = {
|
|
977
|
+
id: "trimPath",
|
|
978
|
+
label: TRIM_LABEL,
|
|
979
|
+
tags: pathOperationTags("click"),
|
|
980
|
+
group: "path",
|
|
981
|
+
eligible: { capability: "creates-shapes" },
|
|
982
|
+
requires: ["scene", "applyOps", "selection", "view"],
|
|
983
|
+
invoker: {
|
|
984
|
+
timing: "immediate",
|
|
985
|
+
run: (deps, params) => {
|
|
986
|
+
const plan = deps.scene ? planTrim(deps, params) : null;
|
|
987
|
+
if (!plan) return;
|
|
988
|
+
const { target, result } = plan;
|
|
989
|
+
const gone = result.path.commands.length === 0;
|
|
990
|
+
const ops = gone ? [deleteNode(deps.scene, target.node, TRIM_LABEL)] : reshapeOps(target.node, result.path, TRIM_LABEL);
|
|
991
|
+
commitOps(deps, ops, TRIM_LABEL);
|
|
992
|
+
if (gone) deps.selection?.set((deps.selection.get() ?? []).filter((id) => id !== target.id));
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
enabled: () => true
|
|
996
|
+
};
|
|
997
|
+
var results = /* @__PURE__ */ new WeakMap();
|
|
998
|
+
function heldWarpResult(held) {
|
|
999
|
+
let out = results.get(held);
|
|
1000
|
+
if (!out) {
|
|
1001
|
+
out = applyPathWarp(held.source, held.warp, held.tolerance);
|
|
1002
|
+
results.set(held, out);
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
1006
|
+
function rememberWarpResult(held, result) {
|
|
1007
|
+
results.set(held, result);
|
|
1008
|
+
}
|
|
1009
|
+
function isHeldWarp(v) {
|
|
1010
|
+
if (v === null || typeof v !== "object") return false;
|
|
1011
|
+
const h = v;
|
|
1012
|
+
return typeof h.id === "string" && typeof h.tolerance === "number" && h.source?.kind === "polygon" && typeof h.warp?.kind === "string" && Array.isArray(h.frame) && h.frame.length === 6;
|
|
1013
|
+
}
|
|
1014
|
+
function heldPathWarpOf(data) {
|
|
1015
|
+
const w = data?.warp;
|
|
1016
|
+
return isHeldWarp(w) ? w : null;
|
|
1017
|
+
}
|
|
1018
|
+
function withoutPathWarp(data) {
|
|
1019
|
+
if (data === null || typeof data !== "object" || !("warp" in data)) return data;
|
|
1020
|
+
const { warp: _dropped, ...rest } = data;
|
|
1021
|
+
return rest;
|
|
1022
|
+
}
|
|
1023
|
+
function storedPathOf(node) {
|
|
1024
|
+
const storage = classifyPathStorage(node);
|
|
1025
|
+
if (!storage) return null;
|
|
1026
|
+
if (storage.kind === "pose") return { path: node.pose, toWorld: identity() };
|
|
1027
|
+
const { pose, data } = storage;
|
|
1028
|
+
const b = boundsOfPath(data.path);
|
|
1029
|
+
const box = boxToBox(b.x, b.y, b.width, b.height, pose.x, pose.y, pose.width, pose.height);
|
|
1030
|
+
return { path: data.path, toWorld: multiply(poseFrameOf(pose) ?? identity(), box) };
|
|
1031
|
+
}
|
|
1032
|
+
function sameUnder(a, m, b) {
|
|
1033
|
+
if (a.coords.length !== b.coords.length || a.commands.length !== b.commands.length) return false;
|
|
1034
|
+
const bb = boundsOfPath(b);
|
|
1035
|
+
const tol = 1e-3 * Math.max(1, Math.abs(bb.x) + bb.width, Math.abs(bb.y) + bb.height);
|
|
1036
|
+
for (let i = 0; i + 1 < a.coords.length; i += 2) {
|
|
1037
|
+
const x = m[0] * a.coords[i] + m[2] * a.coords[i + 1] + m[4];
|
|
1038
|
+
const y = m[1] * a.coords[i] + m[3] * a.coords[i + 1] + m[5];
|
|
1039
|
+
if (Math.abs(x - b.coords[i]) > tol || Math.abs(y - b.coords[i + 1]) > tol) return false;
|
|
1040
|
+
}
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
1043
|
+
function resolvePathWarp(node) {
|
|
1044
|
+
if (!node) return null;
|
|
1045
|
+
const held = heldPathWarpOf(node.data);
|
|
1046
|
+
if (!held) return null;
|
|
1047
|
+
const stored = storedPathOf(node);
|
|
1048
|
+
if (!stored) return null;
|
|
1049
|
+
const made = heldWarpResult(held);
|
|
1050
|
+
let toStorage = held.frame;
|
|
1051
|
+
if (!sameUnder(made, toStorage, stored.path)) {
|
|
1052
|
+
const a = boundsOfPath(transformPath(made, toStorage));
|
|
1053
|
+
const b = boundsOfPath(stored.path);
|
|
1054
|
+
toStorage = multiply(boxToBox(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height), toStorage);
|
|
1055
|
+
if (!sameUnder(made, toStorage, stored.path)) return null;
|
|
1056
|
+
}
|
|
1057
|
+
return { held, toWorld: multiply(stored.toWorld, toStorage) };
|
|
1058
|
+
}
|
|
1059
|
+
function warpFrameFor(node, toWorld2) {
|
|
1060
|
+
const stored = storedPathOf(node);
|
|
1061
|
+
const back = stored && invert(stored.toWorld);
|
|
1062
|
+
return back ? multiply(back, toWorld2) : null;
|
|
1063
|
+
}
|
|
1064
|
+
var WARP_LABELS = { distort: "Distort", envelope: "Envelope", puppet: "Puppet Warp" };
|
|
1065
|
+
var DEFAULT_WARP_TOLERANCE = 0.5;
|
|
1066
|
+
var DEFAULT_ENVELOPE_SIZE = 3;
|
|
1067
|
+
var count = (v, fallback) => Math.min(16, Math.max(1, Math.round(v ?? fallback)));
|
|
1068
|
+
function freshWarp(kind, box, settings) {
|
|
1069
|
+
switch (kind) {
|
|
1070
|
+
case "distort":
|
|
1071
|
+
return distortOver(box, settings.mode ?? "free");
|
|
1072
|
+
case "envelope":
|
|
1073
|
+
return envelopeOver(
|
|
1074
|
+
box,
|
|
1075
|
+
count(settings.rows, DEFAULT_ENVELOPE_SIZE),
|
|
1076
|
+
count(settings.cols, DEFAULT_ENVELOPE_SIZE),
|
|
1077
|
+
settings.mapping ?? "bicubic"
|
|
1078
|
+
);
|
|
1079
|
+
case "puppet":
|
|
1080
|
+
return { kind: "puppet", pins: [] };
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function withWarpSettings(held, settings) {
|
|
1084
|
+
const { warp } = held;
|
|
1085
|
+
let next = warp;
|
|
1086
|
+
if (warp.kind === "distort" && settings.mode && settings.mode !== warp.mode) next = { ...warp, mode: settings.mode };
|
|
1087
|
+
if (warp.kind === "envelope") {
|
|
1088
|
+
const sized = resizeEnvelope(
|
|
1089
|
+
warp,
|
|
1090
|
+
count(settings.rows, warp.mesh.rows),
|
|
1091
|
+
count(settings.cols, warp.mesh.cols)
|
|
1092
|
+
);
|
|
1093
|
+
next = settings.mapping && settings.mapping !== sized.mapping ? { ...sized, mapping: settings.mapping } : sized;
|
|
1094
|
+
}
|
|
1095
|
+
const tolerance = settings.tolerance !== void 0 && settings.tolerance > 0 ? settings.tolerance : held.tolerance;
|
|
1096
|
+
if (next === warp && tolerance === held.tolerance) return held;
|
|
1097
|
+
return { ...held, warp: next, tolerance };
|
|
1098
|
+
}
|
|
1099
|
+
function worldBoxOf(nodes) {
|
|
1100
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
1101
|
+
for (const { path } of nodes) {
|
|
1102
|
+
const b = boundsOfPath(path);
|
|
1103
|
+
x0 = Math.min(x0, b.x);
|
|
1104
|
+
y0 = Math.min(y0, b.y);
|
|
1105
|
+
x1 = Math.max(x1, b.x + b.width);
|
|
1106
|
+
y1 = Math.max(y1, b.y + b.height);
|
|
1107
|
+
}
|
|
1108
|
+
return x0 <= x1 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
|
|
1109
|
+
}
|
|
1110
|
+
var freshIds = 0;
|
|
1111
|
+
function freshWarpId() {
|
|
1112
|
+
return `warp-${Date.now().toString(36)}-${(++freshIds).toString(36)}`;
|
|
1113
|
+
}
|
|
1114
|
+
function splitByWarp(nodes, kind) {
|
|
1115
|
+
const held = [];
|
|
1116
|
+
const bare = [];
|
|
1117
|
+
for (const node of nodes) {
|
|
1118
|
+
const resolved = resolvePathWarp(node.node);
|
|
1119
|
+
if (resolved && resolved.held.warp.kind === kind) held.push({ node, ...resolved, fresh: false });
|
|
1120
|
+
else bare.push(node);
|
|
1121
|
+
}
|
|
1122
|
+
return { held, bare, box: worldBoxOf(bare) };
|
|
1123
|
+
}
|
|
1124
|
+
function warpTargets(deps, kind, settings) {
|
|
1125
|
+
const { held: out, bare, box } = splitByWarp(selectedPathNodes(deps), kind);
|
|
1126
|
+
if (!box) return out;
|
|
1127
|
+
const id = freshWarpId();
|
|
1128
|
+
const warp = freshWarp(kind, box, settings);
|
|
1129
|
+
const tolerance = settings.tolerance !== void 0 && settings.tolerance > 0 ? settings.tolerance : DEFAULT_WARP_TOLERANCE;
|
|
1130
|
+
for (const node of bare) {
|
|
1131
|
+
const frame = warpFrameFor(node.node, identity());
|
|
1132
|
+
if (!frame) continue;
|
|
1133
|
+
out.push({ node, held: { id, source: node.path, warp, tolerance, frame }, toWorld: identity(), fresh: true });
|
|
1134
|
+
}
|
|
1135
|
+
return out;
|
|
1136
|
+
}
|
|
1137
|
+
function withPathWarp(shape, warp, opts = {}) {
|
|
1138
|
+
const source = resolveEditablePathOf(shape);
|
|
1139
|
+
if (!source) return null;
|
|
1140
|
+
const held = {
|
|
1141
|
+
id: opts.id ?? freshWarpId(),
|
|
1142
|
+
source,
|
|
1143
|
+
warp,
|
|
1144
|
+
tolerance: opts.tolerance ?? DEFAULT_WARP_TOLERANCE,
|
|
1145
|
+
frame: identity()
|
|
1146
|
+
};
|
|
1147
|
+
return storeWarp(shape, held, identity());
|
|
1148
|
+
}
|
|
1149
|
+
function storeWarp(node, held, toWorld2) {
|
|
1150
|
+
const made = heldWarpResult(held);
|
|
1151
|
+
const next = withPath(node, transformPath(made, toWorld2));
|
|
1152
|
+
if (!next) return null;
|
|
1153
|
+
const frame = warpFrameFor(next, toWorld2);
|
|
1154
|
+
if (!frame) return null;
|
|
1155
|
+
const kept = { ...held, frame };
|
|
1156
|
+
rememberWarpResult(kept, made);
|
|
1157
|
+
return { pose: next.pose, data: { ...next.data, warp: kept } };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// src/warp/warpActions.ts
|
|
1161
|
+
function createWarpLive() {
|
|
1162
|
+
let current = null;
|
|
1163
|
+
const changed = createEmitter();
|
|
1164
|
+
return {
|
|
1165
|
+
get: (id) => current?.get(id),
|
|
1166
|
+
set: (next) => {
|
|
1167
|
+
current = next;
|
|
1168
|
+
changed.emit();
|
|
1169
|
+
},
|
|
1170
|
+
subscribe: changed.subscribe
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
function warpHandleScratchOf(hit) {
|
|
1174
|
+
const payload = hit?.payload;
|
|
1175
|
+
return payload && typeof payload.index === "number" && payload.warpId !== void 0 ? payload : null;
|
|
1176
|
+
}
|
|
1177
|
+
var REQUIRES = ["selection", "scene", "applyOps"];
|
|
1178
|
+
function commitTargets(deps, next, label) {
|
|
1179
|
+
const ops = [];
|
|
1180
|
+
for (const { target, shape } of next.values()) ops.push(...reshapeTo(target.node.node, shape, label));
|
|
1181
|
+
commitOps(deps, ops, label);
|
|
1182
|
+
}
|
|
1183
|
+
function createWarpDragAction(kind, live) {
|
|
1184
|
+
const label = WARP_LABELS[kind];
|
|
1185
|
+
return {
|
|
1186
|
+
id: `${kind}.drag`,
|
|
1187
|
+
label: `${label} \u2014 drag control`,
|
|
1188
|
+
tags: pathOperationTags(kind === "puppet" ? "brush" : "box"),
|
|
1189
|
+
requires: REQUIRES,
|
|
1190
|
+
invoker: {
|
|
1191
|
+
timing: "ongoing",
|
|
1192
|
+
start(ctx, opts) {
|
|
1193
|
+
const scratch = warpHandleScratchOf(ctx.drag?.affordance);
|
|
1194
|
+
if (!scratch) return {};
|
|
1195
|
+
const settings = resolveParams(opts?.params) ?? {};
|
|
1196
|
+
const targets = warpTargets(ctx.deps, kind, settings).filter((t) => scratch.warpId === null ? t.fresh : t.held.id === scratch.warpId);
|
|
1197
|
+
const first = targets[0];
|
|
1198
|
+
const control = first && warpControls(first.held.warp)[scratch.index];
|
|
1199
|
+
if (!control) return {};
|
|
1200
|
+
const deps = ctx.deps;
|
|
1201
|
+
const [cx, cy] = applyToPoint(first.toWorld, control.x, control.y);
|
|
1202
|
+
const press = ctx.drag?.start ?? ctx.world;
|
|
1203
|
+
let current = /* @__PURE__ */ new Map();
|
|
1204
|
+
const publish = () => live?.set(new Map([...current].map(([id, e]) => [id, e.shape])));
|
|
1205
|
+
return {
|
|
1206
|
+
kind: `${kind}.drag`,
|
|
1207
|
+
onMove(m) {
|
|
1208
|
+
const at2 = m.drag?.current ?? m.world;
|
|
1209
|
+
const world = { x: cx + at2.x - press.x, y: cy + at2.y - press.y };
|
|
1210
|
+
current = /* @__PURE__ */ new Map();
|
|
1211
|
+
for (const target of targets) {
|
|
1212
|
+
const back = invert(target.toWorld);
|
|
1213
|
+
if (!back) continue;
|
|
1214
|
+
const [lx, ly] = applyToPoint(back, world.x, world.y);
|
|
1215
|
+
const held = { ...target.held, warp: moveWarpControl(target.held.warp, scratch.index, { x: lx, y: ly }) };
|
|
1216
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
1217
|
+
if (shape) current.set(target.node.id, { target, shape });
|
|
1218
|
+
}
|
|
1219
|
+
publish();
|
|
1220
|
+
},
|
|
1221
|
+
onEnd(_end, reason) {
|
|
1222
|
+
const done = current;
|
|
1223
|
+
current = /* @__PURE__ */ new Map();
|
|
1224
|
+
live?.set(null);
|
|
1225
|
+
if (reason === "commit" && done.size > 0) commitTargets(deps, done, label);
|
|
1226
|
+
},
|
|
1227
|
+
previewIds: () => current.size > 0 ? [...current.keys()] : null,
|
|
1228
|
+
previewPose: (id) => current.get(id)?.shape.pose ?? null,
|
|
1229
|
+
previewData: (id) => current.get(id)?.shape.data ?? null
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function clickPoint(params) {
|
|
1236
|
+
const x = params?.pressX ?? params?.worldX;
|
|
1237
|
+
const y = params?.pressY ?? params?.worldY;
|
|
1238
|
+
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
|
|
1239
|
+
}
|
|
1240
|
+
function createAddPinAction() {
|
|
1241
|
+
return {
|
|
1242
|
+
id: "puppet.addPin",
|
|
1243
|
+
label: `${WARP_LABELS.puppet} \u2014 add pin`,
|
|
1244
|
+
tags: pathOperationTags("brush"),
|
|
1245
|
+
requires: REQUIRES,
|
|
1246
|
+
invoker: {
|
|
1247
|
+
timing: "immediate",
|
|
1248
|
+
run: (deps, params) => {
|
|
1249
|
+
const at2 = clickPoint(params);
|
|
1250
|
+
if (!at2) return;
|
|
1251
|
+
const next = /* @__PURE__ */ new Map();
|
|
1252
|
+
for (const target of warpTargets(deps, "puppet", params ?? {})) {
|
|
1253
|
+
const { warp } = target.held;
|
|
1254
|
+
const back = invert(target.toWorld);
|
|
1255
|
+
if (warp.kind !== "puppet" || !back) continue;
|
|
1256
|
+
const [lx, ly] = applyToPoint(back, at2.x, at2.y);
|
|
1257
|
+
const to = { x: lx, y: ly };
|
|
1258
|
+
const from = unwarpPoint(pathWarpMap(warp), to);
|
|
1259
|
+
const held = { ...target.held, warp: { ...warp, pins: [...warp.pins, { from, to }] } };
|
|
1260
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
1261
|
+
if (shape) next.set(target.node.id, { target, shape });
|
|
1262
|
+
}
|
|
1263
|
+
commitTargets(deps, next, "Add Pin");
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1266
|
+
enabled: (deps) => selectedPathNodes(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function createRemovePinAction() {
|
|
1270
|
+
return {
|
|
1271
|
+
id: "puppet.removePin",
|
|
1272
|
+
label: `${WARP_LABELS.puppet} \u2014 remove pin`,
|
|
1273
|
+
tags: pathOperationTags("brush"),
|
|
1274
|
+
requires: REQUIRES,
|
|
1275
|
+
invoker: {
|
|
1276
|
+
timing: "immediate",
|
|
1277
|
+
run: (deps, params) => {
|
|
1278
|
+
const scratch = warpHandleScratchOf(params?.affordance);
|
|
1279
|
+
if (!scratch || scratch.warpId === null) return;
|
|
1280
|
+
const next = /* @__PURE__ */ new Map();
|
|
1281
|
+
for (const target of warpTargets(deps, "puppet", {})) {
|
|
1282
|
+
const { warp } = target.held;
|
|
1283
|
+
if (target.held.id !== scratch.warpId || warp.kind !== "puppet") continue;
|
|
1284
|
+
const held = { ...target.held, warp: { ...warp, pins: warp.pins.filter((_, i) => i !== scratch.index) } };
|
|
1285
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
1286
|
+
if (shape) next.set(target.node.id, { target, shape });
|
|
1287
|
+
}
|
|
1288
|
+
commitTargets(deps, next, "Remove Pin");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
function createWarpGrabAction(kind) {
|
|
1294
|
+
return {
|
|
1295
|
+
id: `${kind}.grab`,
|
|
1296
|
+
label: `${WARP_LABELS[kind]} \u2014 grab control`,
|
|
1297
|
+
invoker: { timing: "immediate", run: () => {
|
|
1298
|
+
} }
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
var expandWarpAction = {
|
|
1302
|
+
id: "expandWarp",
|
|
1303
|
+
label: "Expand Warp",
|
|
1304
|
+
tags: pathOperationTags("command"),
|
|
1305
|
+
group: "path",
|
|
1306
|
+
requires: REQUIRES,
|
|
1307
|
+
invoker: {
|
|
1308
|
+
timing: "immediate",
|
|
1309
|
+
run: (deps) => {
|
|
1310
|
+
const ops = [];
|
|
1311
|
+
for (const { node } of selectedPathNodes(deps)) {
|
|
1312
|
+
if (!heldPathWarpOf(node.data)) continue;
|
|
1313
|
+
ops.push(createSetDataOp({ id: node.id, from: node.data, to: withoutPathWarp(node.data), label: "Expand Warp" }));
|
|
1314
|
+
}
|
|
1315
|
+
commitOps(deps, ops, "Expand Warp");
|
|
1316
|
+
}
|
|
1317
|
+
},
|
|
1318
|
+
enabled: (deps) => selectedPathNodes(deps).some(({ node }) => heldPathWarpOf(node.data)) ? true : ActionDisabledReason.NotApplicable
|
|
1319
|
+
};
|
|
1320
|
+
var configureWarpAction = {
|
|
1321
|
+
id: "configureWarp",
|
|
1322
|
+
label: "Warp Settings",
|
|
1323
|
+
tags: pathOperationTags("command"),
|
|
1324
|
+
group: "path",
|
|
1325
|
+
requires: REQUIRES,
|
|
1326
|
+
invoker: {
|
|
1327
|
+
timing: "immediate",
|
|
1328
|
+
run: (deps, params) => {
|
|
1329
|
+
const settings = params ?? {};
|
|
1330
|
+
const next = /* @__PURE__ */ new Map();
|
|
1331
|
+
for (const node of selectedPathNodes(deps)) {
|
|
1332
|
+
const resolved = resolvePathWarp(node.node);
|
|
1333
|
+
if (!resolved || settings.kind && resolved.held.warp.kind !== settings.kind) continue;
|
|
1334
|
+
const held = withWarpSettings(resolved.held, settings);
|
|
1335
|
+
if (held === resolved.held) continue;
|
|
1336
|
+
const shape = storeWarp(node.node, held, resolved.toWorld);
|
|
1337
|
+
if (shape) next.set(node.id, { target: { node, ...resolved, fresh: false }, shape });
|
|
1338
|
+
}
|
|
1339
|
+
commitTargets(deps, next, "Warp Settings");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
// src/presets.ts
|
|
1345
|
+
var simplifyBrushPreset = {
|
|
1346
|
+
id: "simplifyBrush",
|
|
1347
|
+
actions: [simplifyBrushAction, simplifyBrushToolAction]
|
|
1348
|
+
};
|
|
1349
|
+
var handleLocksPreset = {
|
|
1350
|
+
id: "handleLocks",
|
|
1351
|
+
actions: [handleLockAngleAction, handleLockLengthAction]
|
|
1352
|
+
};
|
|
1353
|
+
var bridgePreset = {
|
|
1354
|
+
id: "bridge",
|
|
1355
|
+
actions: [bridgeAnchorsAction, bridgePathsAction]
|
|
1356
|
+
};
|
|
1357
|
+
var trimPreset = { id: "trim", actions: [trimPathAction] };
|
|
1358
|
+
var extendPreset = { id: "extend", actions: [extendToMeetAction] };
|
|
1359
|
+
var smudgePreset = { id: "smudge", actions: [smudgeBrushAction] };
|
|
1360
|
+
var harmonizePreset = { id: "harmonize", actions: [harmonizeAnchorsAction] };
|
|
1361
|
+
function createCurvatureCombPreset(options = () => void 0) {
|
|
1362
|
+
return {
|
|
1363
|
+
id: "curvatureComb",
|
|
1364
|
+
chromeRules: { [PATH_COMB_CHROME_ID]: never },
|
|
1365
|
+
pathEdit: [createCombExtension(options)]
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
var curvatureCombPreset = createCurvatureCombPreset();
|
|
1369
|
+
function createBalancePreset(style = {}) {
|
|
1370
|
+
return {
|
|
1371
|
+
id: "balance",
|
|
1372
|
+
refines: ["paths"],
|
|
1373
|
+
actions: [equalizeHandlesAction, balanceHandlesAction],
|
|
1374
|
+
chromeRules: { [PATH_BALANCE_CHROME_ID]: never },
|
|
1375
|
+
pathEdit: [createBalanceExtension(style)]
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
var balancePreset = createBalancePreset();
|
|
1379
|
+
function createConstraintsPreset(style = {}) {
|
|
1380
|
+
return {
|
|
1381
|
+
id: "constraints",
|
|
1382
|
+
actions: [constrainAnchorsAction, unconstrainAnchorsAction, deleteConstraintAction],
|
|
1383
|
+
pathEdit: [createConstraintsExtension(style)]
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
var constraintsPreset = createConstraintsPreset();
|
|
1387
|
+
var pathEffectsPreset = {
|
|
1388
|
+
id: "pathEffects",
|
|
1389
|
+
actions: [blendAction, bendAlongAction, repeatAlongAction, editPathEffectAction, expandPathEffectAction]
|
|
1390
|
+
};
|
|
1391
|
+
var warpPreset = {
|
|
1392
|
+
id: "warp",
|
|
1393
|
+
actions: [expandWarpAction, configureWarpAction]
|
|
1394
|
+
};
|
|
1395
|
+
var pathTools = {
|
|
1396
|
+
id: "pathTools",
|
|
1397
|
+
includes: [
|
|
1398
|
+
simplifyBrushPreset,
|
|
1399
|
+
handleLocksPreset,
|
|
1400
|
+
bridgePreset,
|
|
1401
|
+
trimPreset,
|
|
1402
|
+
extendPreset,
|
|
1403
|
+
smudgePreset,
|
|
1404
|
+
harmonizePreset,
|
|
1405
|
+
curvatureCombPreset,
|
|
1406
|
+
balancePreset,
|
|
1407
|
+
constraintsPreset,
|
|
1408
|
+
pathEffectsPreset,
|
|
1409
|
+
warpPreset
|
|
1410
|
+
]
|
|
1411
|
+
};
|
|
1412
|
+
function useStrokeClickTool(spec, options, extras = {}) {
|
|
1413
|
+
const { keybinding = spec.keybinding, radiusPx } = options;
|
|
1414
|
+
const { id, label, icon, hookName, actionId } = spec;
|
|
1415
|
+
return useMemo(() => {
|
|
1416
|
+
const opts = radiusPx !== void 0 ? { opts: { params: () => ({ radiusPx: typeof radiusPx === "function" ? radiusPx() : radiusPx }) } } : {};
|
|
1417
|
+
return defineTool({
|
|
1418
|
+
id,
|
|
1419
|
+
...keybinding ? { keybinding } : {},
|
|
1420
|
+
capabilities: ["creates-shapes"],
|
|
1421
|
+
hookName,
|
|
1422
|
+
cursor: "crosshair",
|
|
1423
|
+
presentation: { label, group: "path", icon: createElement(icon) },
|
|
1424
|
+
...extras.actions ? { actions: extras.actions } : {},
|
|
1425
|
+
...extras.overlay ? { overlay: extras.overlay } : {},
|
|
1426
|
+
...extras.onDeactivate ? { onDeactivate: extras.onDeactivate } : {},
|
|
1427
|
+
bindings: [
|
|
1428
|
+
{ spec: { kind: "click" }, actionId, ...opts },
|
|
1429
|
+
...(extras.bindings ?? []).map((b) => ({ ...b, ...opts }))
|
|
1430
|
+
]
|
|
1431
|
+
});
|
|
1432
|
+
}, [id, label, icon, hookName, actionId, keybinding, radiusPx, extras.actions, extras.bindings, extras.overlay, extras.onDeactivate]);
|
|
1433
|
+
}
|
|
1434
|
+
function createPiecePreview() {
|
|
1435
|
+
const changed = createEmitter();
|
|
1436
|
+
let state = null;
|
|
1437
|
+
const current = () => state && state.piece.scene.getVersion() === state.version ? state.piece : null;
|
|
1438
|
+
return {
|
|
1439
|
+
show(next) {
|
|
1440
|
+
if (!next && !state) return;
|
|
1441
|
+
if (next && state && next.path === state.piece.path) return;
|
|
1442
|
+
state = next ? { piece: next, version: next.scene.getVersion() } : null;
|
|
1443
|
+
changed.emit();
|
|
1444
|
+
},
|
|
1445
|
+
current,
|
|
1446
|
+
layer: {
|
|
1447
|
+
id: "stroke-piece-preview",
|
|
1448
|
+
label: "Piece a click would cut",
|
|
1449
|
+
space: "screen",
|
|
1450
|
+
subscribe: (repaint) => changed.subscribe(repaint),
|
|
1451
|
+
draw: (_data, view) => {
|
|
1452
|
+
const piece = current();
|
|
1453
|
+
if (!piece) return [];
|
|
1454
|
+
const t = viewToTransform(view);
|
|
1455
|
+
const stroke = { ...OVERLAY_ROLE_STROKES["cut"], width: 3 };
|
|
1456
|
+
return [{ kind: "path", path: transformPath(piece.path, [t.zoom.x, 0, 0, t.zoom.y, t.panX, t.panY]), stroke }];
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/trim/useTrimTool.ts
|
|
1463
|
+
var TRIM_KEY = { key: "Y" };
|
|
1464
|
+
var TRIM_PREVIEW_ID = "trim.preview";
|
|
1465
|
+
function useTrimTool(options = {}) {
|
|
1466
|
+
const extras = useMemo(() => {
|
|
1467
|
+
const preview = createPiecePreview();
|
|
1468
|
+
const action = {
|
|
1469
|
+
id: TRIM_PREVIEW_ID,
|
|
1470
|
+
label: "Trim segment: show the piece under the pointer",
|
|
1471
|
+
eligible: { capability: "creates-shapes" },
|
|
1472
|
+
requires: ["scene", "view"],
|
|
1473
|
+
invoker: {
|
|
1474
|
+
timing: "immediate",
|
|
1475
|
+
run: (deps, params) => {
|
|
1476
|
+
const plan = deps.scene ? planTrim(deps, params) : null;
|
|
1477
|
+
const scene = deps.scene;
|
|
1478
|
+
preview.show(plan && scene ? { path: plan.result.removed, scene } : null);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
return {
|
|
1483
|
+
actions: [action],
|
|
1484
|
+
bindings: [{ spec: { kind: "hover" }, actionId: TRIM_PREVIEW_ID }],
|
|
1485
|
+
overlay: preview.layer,
|
|
1486
|
+
onDeactivate: () => preview.show(null)
|
|
1487
|
+
};
|
|
1488
|
+
}, []);
|
|
1489
|
+
return useStrokeClickTool(
|
|
1490
|
+
{ id: "trim", label: "Trim segment", icon: SliceIcon, hookName: "useTrimTool", actionId: "trimPath", keybinding: TRIM_KEY },
|
|
1491
|
+
options,
|
|
1492
|
+
extras
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
var EXTEND_KEY = { key: "U" };
|
|
1496
|
+
function useExtendTool(options = {}) {
|
|
1497
|
+
return useStrokeClickTool(
|
|
1498
|
+
{
|
|
1499
|
+
id: "extend",
|
|
1500
|
+
label: "Extend to next path",
|
|
1501
|
+
icon: SliceIcon,
|
|
1502
|
+
hookName: "useExtendTool",
|
|
1503
|
+
actionId: "extendToMeet",
|
|
1504
|
+
keybinding: EXTEND_KEY
|
|
1505
|
+
},
|
|
1506
|
+
options
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
var SMUDGE_KEY = { key: "S" };
|
|
1510
|
+
function useSmudgeBrushTool(options = {}) {
|
|
1511
|
+
const { keybinding = SMUDGE_KEY } = options;
|
|
1512
|
+
return useMemo(() => defineTool({
|
|
1513
|
+
id: "smudgeBrush",
|
|
1514
|
+
...keybinding ? { keybinding } : {},
|
|
1515
|
+
capabilities: ["creates-shapes"],
|
|
1516
|
+
hookName: "useSmudgeBrushTool",
|
|
1517
|
+
cursor: "crosshair",
|
|
1518
|
+
presentation: { label: "Smudge brush", group: "path" },
|
|
1519
|
+
bindings: [{ spec: { kind: "drag" }, actionId: "smudgeBrush" }]
|
|
1520
|
+
}), [keybinding]);
|
|
1521
|
+
}
|
|
1522
|
+
var warpLayerId = (kind) => `warp-${kind}`;
|
|
1523
|
+
var HIT_PX = 8;
|
|
1524
|
+
var SIZE_PX = DEFAULT_OVERLAY_STYLE.anchorSizePx;
|
|
1525
|
+
var PIN_RADIUS_PX = DEFAULT_OVERLAY_STYLE.handleDotRadiusPx;
|
|
1526
|
+
var ACCENT = DEFAULT_OVERLAY_STYLE.anchorStroke;
|
|
1527
|
+
var LINE = { paint: { kind: "solid", color: ACCENT }, width: 1 };
|
|
1528
|
+
var LINE_SAMPLES = 12;
|
|
1529
|
+
var IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
1530
|
+
function groupsOf(src, state) {
|
|
1531
|
+
const scene = src.scene();
|
|
1532
|
+
if (!scene || !src.active()) return [];
|
|
1533
|
+
const nodes = pathNodesOf(scene, state.selection, (id) => src.live.get(id));
|
|
1534
|
+
const { held, box } = splitByWarp(nodes, src.kind);
|
|
1535
|
+
const out = [];
|
|
1536
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1537
|
+
for (const t of held) {
|
|
1538
|
+
if (seen.has(t.held.id)) continue;
|
|
1539
|
+
seen.add(t.held.id);
|
|
1540
|
+
out.push({ warpId: t.held.id, warp: t.held.warp, toWorld: t.toWorld });
|
|
1541
|
+
}
|
|
1542
|
+
if (box) out.push({ warpId: null, warp: freshWarp(src.kind, box, src.settings()), toWorld: IDENTITY });
|
|
1543
|
+
return out;
|
|
1544
|
+
}
|
|
1545
|
+
var toWorld = (m, p) => {
|
|
1546
|
+
const [x, y] = applyToPoint(m, p.x, p.y);
|
|
1547
|
+
return { x, y };
|
|
1548
|
+
};
|
|
1549
|
+
function pinPaint(view, at2) {
|
|
1550
|
+
const [x, y] = worldToScreen(at2.x, at2.y, viewToTransform(view));
|
|
1551
|
+
const r = PIN_RADIUS_PX;
|
|
1552
|
+
return [{
|
|
1553
|
+
kind: "path",
|
|
1554
|
+
path: ellipsePath({ x: x - r, y: y - r, width: 2 * r, height: 2 * r }),
|
|
1555
|
+
fill: { paint: { kind: "solid", color: "#ffffff" } },
|
|
1556
|
+
stroke: { paint: { kind: "solid", color: ACCENT }, width: 2 }
|
|
1557
|
+
}];
|
|
1558
|
+
}
|
|
1559
|
+
function guideLines(group) {
|
|
1560
|
+
const { warp, toWorld: m } = group;
|
|
1561
|
+
if (warp.kind === "distort") {
|
|
1562
|
+
const c = warp.corners.map((p) => toWorld(m, p));
|
|
1563
|
+
return [[...c, c[0]]];
|
|
1564
|
+
}
|
|
1565
|
+
if (warp.kind !== "envelope") return [];
|
|
1566
|
+
const map = pathWarpMap(warp);
|
|
1567
|
+
const { box, mesh } = warp;
|
|
1568
|
+
const at2 = (u, v) => toWorld(m, map({ x: box.x + u * box.width, y: box.y + v * box.height }));
|
|
1569
|
+
const lines = [];
|
|
1570
|
+
for (let i = 0; i <= mesh.rows; i++) {
|
|
1571
|
+
const n = mesh.cols * LINE_SAMPLES;
|
|
1572
|
+
lines.push(Array.from({ length: n + 1 }, (_, k) => at2(k / n, i / mesh.rows)));
|
|
1573
|
+
}
|
|
1574
|
+
for (let j = 0; j <= mesh.cols; j++) {
|
|
1575
|
+
const n = mesh.rows * LINE_SAMPLES;
|
|
1576
|
+
lines.push(Array.from({ length: n + 1 }, (_, k) => at2(j / mesh.cols, k / n)));
|
|
1577
|
+
}
|
|
1578
|
+
return lines;
|
|
1579
|
+
}
|
|
1580
|
+
function warpAffordance(src) {
|
|
1581
|
+
const id = warpLayerId(src.kind);
|
|
1582
|
+
return {
|
|
1583
|
+
id,
|
|
1584
|
+
regions(state) {
|
|
1585
|
+
const out = [];
|
|
1586
|
+
for (const group of groupsOf(src, state)) {
|
|
1587
|
+
warpControls(group.warp).forEach((local, index) => {
|
|
1588
|
+
const p = toWorld(group.toWorld, local);
|
|
1589
|
+
out.push({
|
|
1590
|
+
id: `${group.warpId ?? "fresh"}:${index}`,
|
|
1591
|
+
targetId: null,
|
|
1592
|
+
shape: { kind: "point", x: p.x, y: p.y, hitRadiusPx: HIT_PX },
|
|
1593
|
+
paint: group.warp.kind === "puppet" ? { kind: "custom", draw: ({ world, view }) => pinPaint(view, world) } : {
|
|
1594
|
+
kind: "square",
|
|
1595
|
+
sizePx: SIZE_PX,
|
|
1596
|
+
fill: { paint: { kind: "solid", color: "#ffffff" } },
|
|
1597
|
+
stroke: { paint: { kind: "solid", color: ACCENT }, width: 1 }
|
|
1598
|
+
},
|
|
1599
|
+
cursor: "move",
|
|
1600
|
+
strength: "exclusive",
|
|
1601
|
+
claimedKinds: ["pointer"],
|
|
1602
|
+
bind: () => ({ initialScratch: { warpId: group.warpId, index } })
|
|
1603
|
+
});
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
return out;
|
|
1607
|
+
},
|
|
1608
|
+
decorate(state, view) {
|
|
1609
|
+
const t = viewToTransform(view);
|
|
1610
|
+
const out = [];
|
|
1611
|
+
for (const group of groupsOf(src, state)) {
|
|
1612
|
+
for (const line2 of guideLines(group)) {
|
|
1613
|
+
const screen = line2.map((p) => {
|
|
1614
|
+
const [x, y] = worldToScreen(p.x, p.y, t);
|
|
1615
|
+
return { x, y };
|
|
1616
|
+
});
|
|
1617
|
+
out.push({ kind: "path", path: polylineFromPoints(screen), stroke: LINE });
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return out;
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
function createWarpHandleLayer(src) {
|
|
1625
|
+
const lines = { id: `${warpLayerId(src.kind)}:lines`, regions: () => [], decorate: warpAffordance(src).decorate };
|
|
1626
|
+
const controls = { ...warpAffordance(src), decorate: void 0 };
|
|
1627
|
+
return composeAffordanceLayer(warpLayerId(src.kind), "Warp controls", [lines, controls]);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/warp/useWarpTool.ts
|
|
1631
|
+
var ANY_MODS = { shift: "optional", alt: "optional", meta: "optional", ctrl: "optional" };
|
|
1632
|
+
function bindingsFor(kind, settings) {
|
|
1633
|
+
const target = `affordance:layer:${warpLayerId(kind)}`;
|
|
1634
|
+
const out = [];
|
|
1635
|
+
if (kind === "puppet") {
|
|
1636
|
+
out.push({ spec: { kind: "click", target, mods: { alt: true } }, actionId: "puppet.removePin" });
|
|
1637
|
+
out.push({ spec: { kind: "click", target: "selected-body" }, actionId: "puppet.addPin", opts: { params: settings } });
|
|
1638
|
+
}
|
|
1639
|
+
out.push(
|
|
1640
|
+
{ spec: { kind: "drag", target }, actionId: `${kind}.drag`, opts: { params: settings } },
|
|
1641
|
+
{ spec: { kind: "pointerDown", target, mods: ANY_MODS }, actionId: `${kind}.grab` },
|
|
1642
|
+
{ spec: { kind: "click", target }, actionId: `${kind}.grab` }
|
|
1643
|
+
);
|
|
1644
|
+
return out;
|
|
1645
|
+
}
|
|
1646
|
+
function definedOf(settings) {
|
|
1647
|
+
return Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== void 0));
|
|
1648
|
+
}
|
|
1649
|
+
var KEYS = { distort: { key: "F" }, envelope: { key: "M" }, puppet: { key: "B" } };
|
|
1650
|
+
function createWarpToolState() {
|
|
1651
|
+
let active = false;
|
|
1652
|
+
let api = null;
|
|
1653
|
+
let deps = null;
|
|
1654
|
+
const live = createWarpLive();
|
|
1655
|
+
return {
|
|
1656
|
+
live,
|
|
1657
|
+
active: () => active,
|
|
1658
|
+
scene: () => deps?.get("scene"),
|
|
1659
|
+
/** What an invocation of `actionId` passing `given` starts with here,
|
|
1660
|
+
* its tunings included. */
|
|
1661
|
+
paramsOf: (actionId, given) => deps?.get("actionParams")?.(actionId, given) ?? given,
|
|
1662
|
+
setActive(next) {
|
|
1663
|
+
active = next;
|
|
1664
|
+
api?.requestRedraw();
|
|
1665
|
+
},
|
|
1666
|
+
attachTo(nextApi, nextDeps) {
|
|
1667
|
+
api = nextApi;
|
|
1668
|
+
deps = nextDeps;
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
function useWarpTool(kind, options, settings) {
|
|
1673
|
+
const { label, keybinding = KEYS[kind] } = options;
|
|
1674
|
+
const settingsRef = useLatest(settings);
|
|
1675
|
+
const state = useMemo(() => createWarpToolState(), []);
|
|
1676
|
+
const attach = useMemo(() => {
|
|
1677
|
+
const layer = createWarpHandleLayer({
|
|
1678
|
+
kind,
|
|
1679
|
+
active: state.active,
|
|
1680
|
+
settings: () => state.paramsOf(`${kind}.drag`, definedOf(settingsRef.current)),
|
|
1681
|
+
scene: state.scene,
|
|
1682
|
+
live: state.live
|
|
1683
|
+
});
|
|
1684
|
+
return (api, deps) => {
|
|
1685
|
+
state.attachTo(api, deps);
|
|
1686
|
+
const unregister = api.registerLayer(layer);
|
|
1687
|
+
const unlisten = state.live.subscribe(() => api.requestRedraw());
|
|
1688
|
+
return () => {
|
|
1689
|
+
unlisten();
|
|
1690
|
+
unregister();
|
|
1691
|
+
state.attachTo(null, null);
|
|
1692
|
+
};
|
|
1693
|
+
};
|
|
1694
|
+
}, [kind, settingsRef, state]);
|
|
1695
|
+
const actions = useMemo(() => [
|
|
1696
|
+
createWarpDragAction(kind, state.live),
|
|
1697
|
+
createWarpGrabAction(kind),
|
|
1698
|
+
...kind === "puppet" ? [createAddPinAction(), createRemovePinAction()] : []
|
|
1699
|
+
], [kind, state]);
|
|
1700
|
+
return useMemo(() => {
|
|
1701
|
+
const tool = defineTool({
|
|
1702
|
+
id: kind,
|
|
1703
|
+
...keybinding ? { keybinding } : {},
|
|
1704
|
+
hookName: `use${kind[0].toUpperCase()}${kind.slice(1)}Tool`,
|
|
1705
|
+
capabilities: ["creates-shapes"],
|
|
1706
|
+
cursor: kind === "puppet" ? "crosshair" : "default",
|
|
1707
|
+
actions,
|
|
1708
|
+
presentation: { label: label ?? WARP_LABELS[kind], group: "warp" },
|
|
1709
|
+
onActivate: () => state.setActive(true),
|
|
1710
|
+
onDeactivate: () => state.setActive(false),
|
|
1711
|
+
bindings: bindingsFor(kind, () => definedOf(settingsRef.current))
|
|
1712
|
+
});
|
|
1713
|
+
return Object.assign(tool, { attach });
|
|
1714
|
+
}, [actions, attach, keybinding, kind, label, settingsRef, state]);
|
|
1715
|
+
}
|
|
1716
|
+
function useDistortTool(options = {}) {
|
|
1717
|
+
const { mode, tolerance } = options;
|
|
1718
|
+
return useWarpTool("distort", options, { mode, tolerance });
|
|
1719
|
+
}
|
|
1720
|
+
function useEnvelopeTool(options = {}) {
|
|
1721
|
+
const { rows, cols, mapping, tolerance } = options;
|
|
1722
|
+
return useWarpTool("envelope", options, { rows, cols, mapping, tolerance });
|
|
1723
|
+
}
|
|
1724
|
+
function usePuppetTool(options = {}) {
|
|
1725
|
+
return useWarpTool("puppet", options, { tolerance: options.tolerance });
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
export { CONSTRAINTS_EXTENSION_ID, DEFAULT_BRIDGE_TENSION, DEFAULT_ENVELOPE_SIZE, DEFAULT_SMUDGE_RADIUS_PX, DEFAULT_SMUDGE_STRENGTH, DEFAULT_WARP_TOLERANCE, PATH_BALANCE_CHROME_ID, PATH_COMB_CHROME_ID, PATH_EFFECTS_GROUP, SIMPLIFY_BRUSH_RADIUS_PX, SIMPLIFY_BRUSH_TOOL, balanceCommands, balanceHandlesAction, balancePreset, bendAlongAction, blendAction, bridgeAnchorsAction, bridgePathsAction, bridgePreset, bridgeTensionOf, brushRingCursor, combCommands, configureWarpAction, constrainAnchorsAction, constraintAxisOf, constraintConflictsOf, constraintsForAnchors, constraintsOf, constraintsPreset, createBalanceExtension, createBalancePreset, createCombExtension, createConstraintsExtension, createConstraintsPreset, createCurvatureCombPreset, curvatureCombPreset, deleteConstraintAction, editPathEffectAction, equalizeHandlesAction, expandPathEffectAction, expandWarpAction, extendPreset, extendToMeetAction, handleLockAngleAction, handleLockLengthAction, handleLocksPreset, harmonizeAnchorsAction, harmonizePreset, heldPathWarpOf, pathEffectsPreset, pathTools, planTrim, repeatAlongAction, resolvePathWarp, selectedConstraintOf, setConstraints, simplifyBrushAction, simplifyBrushPreset, simplifyBrushToolAction, smudgeBrushAction, smudgePreset, trimPathAction, trimPreset, unconstrainAnchorsAction, useDistortTool, useEnvelopeTool, useExtendTool, usePuppetTool, useSmudgeBrushTool, useTrimTool, warpHandleScratchOf, warpLayerId, warpPreset, withPathWarp, withoutPathWarp };
|
|
1729
|
+
//# sourceMappingURL=index.js.map
|
|
1730
|
+
//# sourceMappingURL=index.js.map
|