@weasel-js/path-tools 0.0.0-stage → 1.11.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/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/icons.table.d.ts +6 -0
- package/dist/icons.table.js +129 -0
- package/dist/icons.table.js.map +1 -0
- package/dist/icons.table.json +121 -0
- package/dist/index.d.ts +1550 -0
- package/dist/index.js +3900 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,3900 @@
|
|
|
1
|
+
import { inPlane, HANDLE_LOCK_ROLES, editedPathSymmetries, pathEditPreview, locksInForce, pathOperationTags, editingLayer, joinPathsCommand, joinSelectedAnchors, makeHandleCommand, depOf, pathEditToolAction, segmentUnder, livePathEdit, reshapeOps, DEFAULT_OVERLAY_STYLE, pathEditToolActive, requiresAnchorSelection, registeredPathEffects, slotOf, pathEffectNode, commitOps, insertAt, DEFAULT_SIMPLIFY_TOLERANCE, deleteSelectedMark, resolveEditablePathOf, registerNodeShape, classifyPathStorage, commitAnchorEdit, anchorEditWouldChange, stepAngles, pathEffectOf, createSetDataOp, derivedDepOf, pathEffectParts, deleteNode, selectedPathNodes, withPath, never, defineTool, resolveBooleanOperands, commitBooleanPieces, PATHFINDER_STROKES, createPathOverlay, viewToTransform, worldToScreen, storeEditablePath, reshapeTo, nodeOutline, pointSnap, createPointerFollower, snapMods, composeAffordanceLayer, pathNodesOf, OVERLAY_ROLE_STROKES } from '@weasel-js/core';
|
|
2
|
+
import { isBalanceGrip, isCornerWidget, isAnchor, ActionDisabledReason, resolveParams } from '@weasel-js/routing';
|
|
3
|
+
import { pathToAnchors, balanceFactorToward, editAnchorSetTracked, scaleSegmentHandles, cornerFrames, cornerSpecAt, harmonizeAnchors, openEndAt, pathEndFrame, pathEndAnchorIndex, lengthToward, lengthenPathEnd, convertSegments, resamplePath, simplifyAnchorsAt, SPIRO_KINDS, CORNER_KEYWORDS, locateAnchor, withCorner, nearestPathAnchor, anchorsUnderBrush, pathAnchorPoints, solvePathConstraints, constraintAnchors, constrainPathsEdit, constrainPathEdit, constraintFits, PathBuilder, unsatisfiedConstraints, conflictingConstraints, segmentBetween, equalizeSegmentHandles, handleBalances, linePath, circlePath, curvatureComb, polylineFromPoints, isCornerShape, cornerShapeK, anchorAt, measureAnchorPath, softSelectionWeights, softSelectionBand, directionMarks, polygonFromPoints, setSubpathStart, reverseSubpaths, segmentsBetweenAnchors, growAnchorSelection, shrinkAnchorSelection, everyNthAnchor, cornerAnchors, smoothAnchors, invertAnchorSelection, worldEditToStorage, smudgePath, isSpiroKind, spiroToPath, spiroContoursOf, spiroControlPath, spiroContoursFromControl, squarePath, trimPathAt, fitAffine, multiply, identity, transformPath, pointInPath, cornerKeywordOf, standoff, cornerShapePieces, openPathEnds, extendToMeet, subpathsOfAnchors, flatIndexesAfterReverse, addExtremaAnchors, pxExtent, nearestSegmentT, cubicPointAt, withinPxRadius, flatAnchorIndex, boundsOfPath, boxToBox, poseFrameOf, invert, meanScale, ellipsePath, pathDistanceToPoint, openEndRay, segmentEnds, applyToPoint, insertAnchorOnSegment, nextCornerShape } from '@weasel-js/geom';
|
|
4
|
+
import { createEmitter } from '@weasel-js/registry';
|
|
5
|
+
import { applyPathWarp, resizeEnvelope, warpControls, moveWarpControl, unwarpPoint, pathWarpMap, envelopeOver, distortOver } from '@weasel-js/geom/warp';
|
|
6
|
+
import { useMemo, useRef, useReducer, useState, useCallback, useSyncExternalStore } from 'react';
|
|
7
|
+
import { useLatest } from '@weasel-js/react';
|
|
8
|
+
import { pathFacePieces, pathUnion } from '@weasel-js/geom/booleans';
|
|
9
|
+
import { vertexHalfWidths, vertexWidthFromSides } from '@weasel-js/paint';
|
|
10
|
+
import { strokeEdgeNear, resolveStrokeWidth } from '@weasel-js/stroke';
|
|
11
|
+
import { cursorFor } from '@weasel-js/cursor';
|
|
12
|
+
import { useActionsRegistry } from '@weasel-js/routing/react';
|
|
13
|
+
|
|
14
|
+
// src/presets.ts
|
|
15
|
+
function gripTarget(dep, kind) {
|
|
16
|
+
if (!dep?.editingId || !kind) return null;
|
|
17
|
+
const m = kind.match(/^balance:(\d+)$/);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
const path = dep.getEditablePath(dep.editingId);
|
|
20
|
+
if (!path || path.kind !== "polygon") return null;
|
|
21
|
+
const loc = locateAnchor(pathToAnchors(path), parseInt(m[1], 10));
|
|
22
|
+
return loc ? { dep, path, sub: loc.sub, segIdx: loc.idx } : null;
|
|
23
|
+
}
|
|
24
|
+
var NO_LOCKS = { angle: false, length: false };
|
|
25
|
+
function gripEnabled(deps) {
|
|
26
|
+
if (!deps?.editAnchors?.editingId || pathEditToolActive(deps)) return ActionDisabledReason.NotApplicable;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
var balanceHandlesAction = inPlane({
|
|
30
|
+
id: "balanceHandles",
|
|
31
|
+
label: "Balance handles",
|
|
32
|
+
icon: (
|
|
33
|
+
/* @icon */
|
|
34
|
+
"path-tools:balanceHandles"
|
|
35
|
+
),
|
|
36
|
+
tags: pathOperationTags("drag"),
|
|
37
|
+
// Ties the marquee's `empty` and move's `selected-body` on specificity and
|
|
38
|
+
// registers ahead of the marquee; move turns path-edit presses down itself.
|
|
39
|
+
defaultBinding: { kind: "drag", target: { kindOf: isBalanceGrip } },
|
|
40
|
+
cursor: "grab",
|
|
41
|
+
eligible: { capability: "edits-anchors" },
|
|
42
|
+
modifierRoles: HANDLE_LOCK_ROLES,
|
|
43
|
+
requires: ["editAnchors", "pathSymmetry"],
|
|
44
|
+
invoker: {
|
|
45
|
+
timing: "ongoing",
|
|
46
|
+
start(ctx) {
|
|
47
|
+
const target = gripTarget(ctx.deps.editAnchors, ctx.drag?.affordance?.kind);
|
|
48
|
+
if (!target) return {};
|
|
49
|
+
const { dep, path: originPath, sub, segIdx } = target;
|
|
50
|
+
const id = dep.editingId;
|
|
51
|
+
const origin = pathToAnchors(originPath);
|
|
52
|
+
const symmetries = editedPathSymmetries(ctx.deps.pathSymmetry, id, origin);
|
|
53
|
+
const press = { x: ctx.world.x, y: ctx.world.y };
|
|
54
|
+
const pressFactor = balanceFactorToward(origin, sub, segIdx, press);
|
|
55
|
+
let current = null;
|
|
56
|
+
const preview = pathEditPreview(dep, id, () => current?.path ?? null);
|
|
57
|
+
if (!preview) return {};
|
|
58
|
+
return {
|
|
59
|
+
kind: "balance-handles",
|
|
60
|
+
onMove(moveCtx) {
|
|
61
|
+
const held = (moveCtx.deps.editAnchors ?? dep).handleLocks ?? NO_LOCKS;
|
|
62
|
+
if (locksInForce(held, moveCtx).length) {
|
|
63
|
+
current = null;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const at2 = balanceFactorToward(origin, sub, segIdx, moveCtx.world);
|
|
67
|
+
const factor = pressFactor > 0 ? at2 / pressFactor : at2;
|
|
68
|
+
current = editAnchorSetTracked(originPath, (set) => scaleSegmentHandles(set, sub, segIdx, factor, symmetries)) ?? current;
|
|
69
|
+
},
|
|
70
|
+
onEnd(_endCtx, reason) {
|
|
71
|
+
const done = current;
|
|
72
|
+
current = null;
|
|
73
|
+
if (reason === "cancel" || !done) return;
|
|
74
|
+
dep.applyEdit(id, done.path, "Balance handles", done.origins);
|
|
75
|
+
},
|
|
76
|
+
previewIds: preview.previewIds,
|
|
77
|
+
previewPose: preview.previewPose,
|
|
78
|
+
previewData: preview.previewData
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
enabled: gripEnabled
|
|
83
|
+
}, editingLayer);
|
|
84
|
+
var equalizeHandlesAction = {
|
|
85
|
+
id: "equalizeHandles",
|
|
86
|
+
label: "Equalize handles",
|
|
87
|
+
icon: (
|
|
88
|
+
/* @icon */
|
|
89
|
+
"path-tools:equalizeHandles"
|
|
90
|
+
),
|
|
91
|
+
tags: pathOperationTags("click"),
|
|
92
|
+
defaultBinding: { kind: "doubleClick", target: { kindOf: isBalanceGrip } },
|
|
93
|
+
eligible: { capability: "edits-anchors" },
|
|
94
|
+
requires: ["editAnchors"],
|
|
95
|
+
invoker: {
|
|
96
|
+
timing: "immediate",
|
|
97
|
+
run: (deps, params) => {
|
|
98
|
+
const kind = params?.affordance?.kind;
|
|
99
|
+
const target = gripTarget(deps.editAnchors, kind);
|
|
100
|
+
if (!target) return;
|
|
101
|
+
const { dep, sub, segIdx } = target;
|
|
102
|
+
commitAnchorEdit(dep, "Equalize handles", (set) => equalizeSegmentHandles(set, sub, segIdx));
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
enabled: gripEnabled
|
|
106
|
+
};
|
|
107
|
+
var PATH_BALANCE_CHROME_ID = "path-edit.balance";
|
|
108
|
+
var BALANCE_DEFAULTS = {
|
|
109
|
+
color: "#9b6bd6",
|
|
110
|
+
gripFill: "#ffffff",
|
|
111
|
+
gripRadiusPx: 4
|
|
112
|
+
};
|
|
113
|
+
function balanceCommands(set, toScreen, style = {}) {
|
|
114
|
+
const { color, gripFill, gripRadiusPx } = { ...BALANCE_DEFAULTS, ...style };
|
|
115
|
+
const paint = { kind: "solid", color };
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const line3 of handleBalances(set)) {
|
|
118
|
+
const grip = toScreen(line3.grip);
|
|
119
|
+
out.push({
|
|
120
|
+
kind: "path",
|
|
121
|
+
path: linePath(toScreen(line3.from), toScreen(line3.to)),
|
|
122
|
+
stroke: { paint, width: 1 }
|
|
123
|
+
});
|
|
124
|
+
out.push({
|
|
125
|
+
kind: "path",
|
|
126
|
+
path: circlePath(grip.x, grip.y, gripRadiusPx),
|
|
127
|
+
fill: { paint: { kind: "solid", color: gripFill } },
|
|
128
|
+
stroke: { paint, width: 1.5 }
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function createBalanceExtension(style = {}) {
|
|
134
|
+
return {
|
|
135
|
+
id: "balance",
|
|
136
|
+
marks: (ctx) => ctx.isVisible(PATH_BALANCE_CHROME_ID) ? balanceCommands(ctx.anchors, ctx.toScreen, style) : [],
|
|
137
|
+
parts: (target) => handleBalances(target.anchors).map((line3) => ({
|
|
138
|
+
x: line3.grip.x,
|
|
139
|
+
y: line3.grip.y,
|
|
140
|
+
hitKind: `balance:${line3.flat}`,
|
|
141
|
+
chromeId: PATH_BALANCE_CHROME_ID
|
|
142
|
+
}))
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
var DEFAULT_BRIDGE_TENSION = 0.4;
|
|
146
|
+
function bridgeTensionOf(params) {
|
|
147
|
+
const t = params?.tension;
|
|
148
|
+
return typeof t === "number" && Number.isFinite(t) ? Math.max(0, t) : DEFAULT_BRIDGE_TENSION;
|
|
149
|
+
}
|
|
150
|
+
var bridgePathsAction = joinPathsCommand(
|
|
151
|
+
{
|
|
152
|
+
id: "bridgePaths",
|
|
153
|
+
label: "Bridge",
|
|
154
|
+
icon: (
|
|
155
|
+
/* @icon */
|
|
156
|
+
"core:bridge"
|
|
157
|
+
),
|
|
158
|
+
// `code` too: with Shift held the key reads as `J`.
|
|
159
|
+
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } }
|
|
160
|
+
},
|
|
161
|
+
(params) => ({ tension: bridgeTensionOf(params) })
|
|
162
|
+
);
|
|
163
|
+
var join = joinSelectedAnchors({});
|
|
164
|
+
var bridgeAnchorsAction = {
|
|
165
|
+
id: "bridgeAnchors",
|
|
166
|
+
label: "Bridge anchors",
|
|
167
|
+
tags: pathOperationTags("command"),
|
|
168
|
+
icon: (
|
|
169
|
+
/* @icon */
|
|
170
|
+
"core:bridge"
|
|
171
|
+
),
|
|
172
|
+
group: "anchors",
|
|
173
|
+
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } },
|
|
174
|
+
eligible: { capability: "edits-anchors" },
|
|
175
|
+
requires: ["editAnchors"],
|
|
176
|
+
invoker: {
|
|
177
|
+
timing: "immediate",
|
|
178
|
+
run: (deps, params) => {
|
|
179
|
+
const dep = depOf(deps);
|
|
180
|
+
if (!dep || dep.selectedAnchors.size !== 2) return;
|
|
181
|
+
const selected = [...dep.selectedAnchors];
|
|
182
|
+
const bridge = joinSelectedAnchors({ tension: bridgeTensionOf(params) });
|
|
183
|
+
if (commitAnchorEdit(dep, "Bridge anchors", (set) => bridge(set, selected))) dep.setSelectedAnchors([]);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
enabled: (deps) => {
|
|
187
|
+
const dep = depOf(deps);
|
|
188
|
+
if (!dep || dep.selectedAnchors.size !== 2) return ActionDisabledReason.SelectionRequired;
|
|
189
|
+
const selected = [...dep.selectedAnchors];
|
|
190
|
+
return anchorEditWouldChange(deps, (set) => join(set, selected)) ? true : ActionDisabledReason.NotApplicable;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var PATH_COMB_CHROME_ID = "path-edit.comb";
|
|
194
|
+
var COMB_DEFAULTS = { scale: 2e3, samplesPerSegment: 24, color: "rgba(224, 102, 58, 0.75)" };
|
|
195
|
+
function combCommands(set, toScreen, opts = {}) {
|
|
196
|
+
const { scale, samplesPerSegment, color } = { ...COMB_DEFAULTS, ...opts };
|
|
197
|
+
const paint = { kind: "solid", color };
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const { teeth } of curvatureComb(set, { scale, samplesPerSegment })) {
|
|
200
|
+
const tips = teeth.map((t) => toScreen({ x: t.tipX, y: t.tipY }));
|
|
201
|
+
teeth.forEach((t, i) => {
|
|
202
|
+
out.push({ kind: "path", path: linePath(toScreen(t), tips[i]), stroke: { paint, width: 1 } });
|
|
203
|
+
});
|
|
204
|
+
out.push({ kind: "path", path: polylineFromPoints(tips), stroke: { paint, width: 1.5 } });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function createCombExtension(options = () => void 0) {
|
|
209
|
+
return {
|
|
210
|
+
id: "curvatureComb",
|
|
211
|
+
marks: (ctx) => ctx.isVisible(PATH_COMB_CHROME_ID) ? combCommands(ctx.anchors, ctx.toScreen, options()) : []
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
var DEFAULT_CORNER_SHAPE = "round";
|
|
215
|
+
function cornerIndexOf(kind) {
|
|
216
|
+
const m = kind.match(/^corner:(\d+)$/);
|
|
217
|
+
return m ? parseInt(m[1], 10) : null;
|
|
218
|
+
}
|
|
219
|
+
function editedPath(dep) {
|
|
220
|
+
const p = dep.getEditablePath(dep.editingId);
|
|
221
|
+
return p && p.kind === "polygon" ? p : null;
|
|
222
|
+
}
|
|
223
|
+
function withRadius(path, targets, radius) {
|
|
224
|
+
let out = path;
|
|
225
|
+
for (const { frame, shape } of targets) {
|
|
226
|
+
out = withCorner(out, frame.index, { shape, radius: Math.max(0, Math.min(radius, frame.maxTrim)) });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
function widgetEnabled(deps) {
|
|
231
|
+
if (!deps?.editAnchors?.editingId || pathEditToolActive(deps)) return ActionDisabledReason.NotApplicable;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
var editCornerAction = inPlane({
|
|
235
|
+
id: "editCorner",
|
|
236
|
+
icon: (
|
|
237
|
+
/* @icon */
|
|
238
|
+
"path-tools:cornerRadius"
|
|
239
|
+
),
|
|
240
|
+
label: "Corner radius",
|
|
241
|
+
tags: pathOperationTags("drag"),
|
|
242
|
+
// Ties the marquee's `empty` and move's `selected-body` on specificity and
|
|
243
|
+
// registers ahead of the marquee; move turns path-edit presses down itself.
|
|
244
|
+
defaultBinding: { kind: "drag", target: { kindOf: isCornerWidget } },
|
|
245
|
+
eligible: { capability: "edits-anchors" },
|
|
246
|
+
requires: ["editAnchors"],
|
|
247
|
+
invoker: {
|
|
248
|
+
timing: "ongoing",
|
|
249
|
+
start(ctx) {
|
|
250
|
+
const dep = ctx.deps.editAnchors;
|
|
251
|
+
const index = cornerIndexOf(ctx.drag?.affordance?.kind ?? "");
|
|
252
|
+
if (!dep?.editingId || index === null) return {};
|
|
253
|
+
const origin = editedPath(dep);
|
|
254
|
+
if (!origin) return {};
|
|
255
|
+
const frames = new Map(cornerFrames(origin).map((f) => [f.index, f]));
|
|
256
|
+
const pressed = frames.get(index);
|
|
257
|
+
if (!pressed) return {};
|
|
258
|
+
const indices = dep.selectedAnchors.has(index) ? [...dep.selectedAnchors] : [index];
|
|
259
|
+
const targets = [];
|
|
260
|
+
for (const i of indices) {
|
|
261
|
+
const frame = frames.get(i);
|
|
262
|
+
if (frame) targets.push({ frame, shape: cornerSpecAt(origin, i)?.shape ?? DEFAULT_CORNER_SHAPE });
|
|
263
|
+
}
|
|
264
|
+
const press = ctx.drag?.start ?? ctx.world;
|
|
265
|
+
let current = origin;
|
|
266
|
+
let active = false;
|
|
267
|
+
const preview = pathEditPreview(dep, dep.editingId, () => active ? current : null);
|
|
268
|
+
if (!preview) return {};
|
|
269
|
+
const id = dep.editingId;
|
|
270
|
+
return {
|
|
271
|
+
kind: "edit-corner",
|
|
272
|
+
onMove(move) {
|
|
273
|
+
const along2 = (move.world.x - press.x) * pressed.bisector.x + (move.world.y - press.y) * pressed.bisector.y;
|
|
274
|
+
const trim = Math.max(0, pressed.trim + along2 / Math.cos(pressed.halfAngle));
|
|
275
|
+
current = withRadius(origin, targets, trim);
|
|
276
|
+
active = true;
|
|
277
|
+
},
|
|
278
|
+
onEnd(_end, reason) {
|
|
279
|
+
active = false;
|
|
280
|
+
if (reason === "cancel" || current === origin) return;
|
|
281
|
+
dep.applyEdit(id, current, "Corner radius");
|
|
282
|
+
},
|
|
283
|
+
previewIds: preview.previewIds,
|
|
284
|
+
previewPose: preview.previewPose,
|
|
285
|
+
previewData: preview.previewData
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
enabled: widgetEnabled
|
|
290
|
+
}, editingLayer);
|
|
291
|
+
|
|
292
|
+
// src/cornerShape/cornerCommands.ts
|
|
293
|
+
var DEFAULT_CORNER_TRIM_SHARE = 0.25;
|
|
294
|
+
function selectedCorners(dep, path) {
|
|
295
|
+
return cornerFrames(path).filter((f) => dep.selectedAnchors.has(f.index));
|
|
296
|
+
}
|
|
297
|
+
function changeCorners(path, frames, change) {
|
|
298
|
+
let out = path;
|
|
299
|
+
for (const f of frames) {
|
|
300
|
+
const spec = cornerSpecAt(path, f.index);
|
|
301
|
+
const shape = change.shape ?? spec?.shape ?? DEFAULT_CORNER_SHAPE;
|
|
302
|
+
let radius = Math.max(0, change.radius ?? spec?.radius ?? 0);
|
|
303
|
+
if (change.shape !== void 0 && change.radius === void 0 && radius <= 0) {
|
|
304
|
+
radius = DEFAULT_CORNER_TRIM_SHARE * f.maxTrim;
|
|
305
|
+
}
|
|
306
|
+
out = withCorner(out, f.index, { shape, radius: Math.min(radius, f.maxTrim) });
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function commitChange(deps, label, change) {
|
|
311
|
+
const dep = depOf(deps);
|
|
312
|
+
const path = dep && editedPath(dep);
|
|
313
|
+
if (!dep || !path) return;
|
|
314
|
+
const frames = selectedCorners(dep, path);
|
|
315
|
+
if (frames.length === 0) return;
|
|
316
|
+
dep.applyEdit(dep.editingId, changeCorners(path, frames, change(frames, path)), label);
|
|
317
|
+
}
|
|
318
|
+
function cornersSelected(deps) {
|
|
319
|
+
const selection = requiresAnchorSelection(deps);
|
|
320
|
+
if (selection !== true) return selection;
|
|
321
|
+
const dep = depOf(deps);
|
|
322
|
+
const path = dep && editedPath(dep);
|
|
323
|
+
return dep && path && selectedCorners(dep, path).length > 0 ? true : ActionDisabledReason.NotApplicable;
|
|
324
|
+
}
|
|
325
|
+
var setCornersAction = {
|
|
326
|
+
id: "setCorners",
|
|
327
|
+
icon: (
|
|
328
|
+
/* @icon */
|
|
329
|
+
"path-tools:cornerRadius"
|
|
330
|
+
),
|
|
331
|
+
label: "Set corners",
|
|
332
|
+
tags: pathOperationTags("command"),
|
|
333
|
+
eligible: { capability: "edits-anchors" },
|
|
334
|
+
requires: ["editAnchors"],
|
|
335
|
+
invoker: {
|
|
336
|
+
timing: "immediate",
|
|
337
|
+
run: (deps, params) => {
|
|
338
|
+
const shape = isCornerShape(params?.shape) ? params.shape : void 0;
|
|
339
|
+
const radius = typeof params?.radius === "number" && Number.isFinite(params.radius) ? params.radius : void 0;
|
|
340
|
+
if (shape === void 0 && radius === void 0) return;
|
|
341
|
+
commitChange(deps, "Set corners", () => ({
|
|
342
|
+
...shape !== void 0 ? { shape } : {},
|
|
343
|
+
...radius !== void 0 ? { radius } : {}
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
enabled: cornersSelected
|
|
348
|
+
};
|
|
349
|
+
var cycleCornerShapeAction = {
|
|
350
|
+
id: "cycleCornerShape",
|
|
351
|
+
icon: (
|
|
352
|
+
/* @icon */
|
|
353
|
+
"path-tools:cornerShape"
|
|
354
|
+
),
|
|
355
|
+
label: "Next corner shape",
|
|
356
|
+
tags: pathOperationTags("command"),
|
|
357
|
+
group: "anchors",
|
|
358
|
+
eligible: { capability: "edits-anchors" },
|
|
359
|
+
requires: ["editAnchors"],
|
|
360
|
+
invoker: {
|
|
361
|
+
timing: "immediate",
|
|
362
|
+
run: (deps) => commitChange(deps, "Next corner shape", (frames, path) => ({
|
|
363
|
+
shape: nextCornerShape(cornerSpecAt(path, frames[0].index)?.shape ?? DEFAULT_CORNER_SHAPE)
|
|
364
|
+
}))
|
|
365
|
+
},
|
|
366
|
+
enabled: cornersSelected
|
|
367
|
+
};
|
|
368
|
+
var PATH_CORNERS_CHROME_ID = "path-edit.corners";
|
|
369
|
+
var CORNER_WIDGET_STANDOFF_PX = 14;
|
|
370
|
+
var MIN_WIDGET_TURN = 0.05;
|
|
371
|
+
function cornerWidgetFrames(path) {
|
|
372
|
+
return cornerFrames(path).filter((f) => f.halfAngle < Math.PI / 2 - MIN_WIDGET_TURN);
|
|
373
|
+
}
|
|
374
|
+
function cornerCutMiddle(frame) {
|
|
375
|
+
const d = frame.trim * Math.cos(frame.halfAngle);
|
|
376
|
+
return { x: frame.x + frame.bisector.x * d, y: frame.y + frame.bisector.y * d };
|
|
377
|
+
}
|
|
378
|
+
function cornerWidgetAt(frame, scale) {
|
|
379
|
+
const at2 = standoff(cornerCutMiddle(frame), frame.bisector, CORNER_WIDGET_STANDOFF_PX, scale);
|
|
380
|
+
return { x: at2.x, y: at2.y };
|
|
381
|
+
}
|
|
382
|
+
var GLYPH_HALF_PX = 4.5;
|
|
383
|
+
var WIDGET_HIT_RADIUS_PX = 6;
|
|
384
|
+
function cornerGlyph(c, toward2, k) {
|
|
385
|
+
const h = GLYPH_HALF_PX;
|
|
386
|
+
const r2 = Math.SQRT1_2;
|
|
387
|
+
const e1 = { x: (toward2.x - toward2.y) * r2, y: (toward2.x + toward2.y) * r2 };
|
|
388
|
+
const e2 = { x: (toward2.x + toward2.y) * r2, y: (toward2.y - toward2.x) * r2 };
|
|
389
|
+
const at2 = (a, b2) => ({ x: c.x + a * e1.x + b2 * e2.x, y: c.y + a * e1.y + b2 * e2.y });
|
|
390
|
+
const cut = 1.2 * h;
|
|
391
|
+
const outer = at2(h, h);
|
|
392
|
+
const b = new PathBuilder().moveTo(at2(-h, -h).x, at2(-h, -h).y).lineTo(at2(h, -h).x, at2(h, -h).y);
|
|
393
|
+
const start = at2(h, h - cut);
|
|
394
|
+
b.lineTo(start.x, start.y);
|
|
395
|
+
for (const piece of cornerShapePieces(k, { start, end: at2(h - cut, h), outer })) {
|
|
396
|
+
if (piece.kind === "line") b.lineTo(piece.to.x, piece.to.y);
|
|
397
|
+
else b.curveTo(piece.c1.x, piece.c1.y, piece.c2.x, piece.c2.y, piece.to.x, piece.to.y);
|
|
398
|
+
}
|
|
399
|
+
const last = at2(-h, h);
|
|
400
|
+
return b.lineTo(last.x, last.y).close().build();
|
|
401
|
+
}
|
|
402
|
+
function cornerWidgetCommands(path, view, style = {}) {
|
|
403
|
+
const out = [];
|
|
404
|
+
const fill = { paint: { kind: "solid", color: style.fill ?? DEFAULT_OVERLAY_STYLE.anchorFill } };
|
|
405
|
+
const stroke = { paint: { kind: "solid", color: style.stroke ?? DEFAULT_OVERLAY_STYLE.anchorStroke }, width: 1 };
|
|
406
|
+
for (const frame of cornerWidgetFrames(path)) {
|
|
407
|
+
const w = cornerWidgetAt(frame, view.scale);
|
|
408
|
+
const x = (w.x - view.x) * view.scale.x;
|
|
409
|
+
const y = (w.y - view.y) * view.scale.y;
|
|
410
|
+
const bx = -frame.bisector.x * view.scale.x, by = -frame.bisector.y * view.scale.y;
|
|
411
|
+
const bl = Math.hypot(bx, by) || 1;
|
|
412
|
+
const k = cornerShapeK(cornerSpecAt(path, frame.index)?.shape ?? "round");
|
|
413
|
+
out.push({ kind: "path", path: cornerGlyph({ x, y }, { x: bx / bl, y: by / bl }, k), fill, stroke });
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
function createCornerWidgetsExtension(style = {}) {
|
|
418
|
+
return {
|
|
419
|
+
id: "cornerShape",
|
|
420
|
+
marks: (ctx) => ctx.isVisible(PATH_CORNERS_CHROME_ID) ? cornerWidgetCommands(ctx.path, ctx.view, style) : [],
|
|
421
|
+
parts: (target) => cornerWidgetFrames(target.path).map((f) => ({
|
|
422
|
+
...cornerCutMiddle(f),
|
|
423
|
+
hitKind: `corner:${f.index}`,
|
|
424
|
+
chromeId: PATH_CORNERS_CHROME_ID,
|
|
425
|
+
hitRadiusPx: WIDGET_HIT_RADIUS_PX,
|
|
426
|
+
standoff: { px: CORNER_WIDGET_STANDOFF_PX, normal: f.bisector }
|
|
427
|
+
}))
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
var NONE = [];
|
|
431
|
+
function constraintsOf(data) {
|
|
432
|
+
const list = data?.constraints;
|
|
433
|
+
return Array.isArray(list) ? list : NONE;
|
|
434
|
+
}
|
|
435
|
+
function constraintAxisOf(node) {
|
|
436
|
+
const storage = classifyPathStorage(node);
|
|
437
|
+
return storage?.kind === "data" ? storage.pose.rotation ?? 0 : 0;
|
|
438
|
+
}
|
|
439
|
+
function conflictsIn(path, constraints, axisAngle) {
|
|
440
|
+
const points = pathAnchorPoints(path);
|
|
441
|
+
const failing = unsatisfiedConstraints(points, constraints, { axisAngle });
|
|
442
|
+
if (failing.length === 0) return [];
|
|
443
|
+
const set = /* @__PURE__ */ new Set([...failing, ...conflictingConstraints(points, constraints, { axisAngle })]);
|
|
444
|
+
return [...set].sort((a, b) => a - b);
|
|
445
|
+
}
|
|
446
|
+
function constraintConflictsOf(node) {
|
|
447
|
+
const constraints = constraintsOf(node?.data);
|
|
448
|
+
const path = constraints.length > 0 ? resolveEditablePathOf(node) : null;
|
|
449
|
+
if (!path) return [];
|
|
450
|
+
return conflictsIn(path, constraints, constraintAxisOf(node));
|
|
451
|
+
}
|
|
452
|
+
function constrainNodeEdit(node, worldPath, origins) {
|
|
453
|
+
const constraints = constraintsOf(node?.data);
|
|
454
|
+
const before = constraints.length > 0 ? resolveEditablePathOf(node) : null;
|
|
455
|
+
if (!before) return { path: worldPath, constraints };
|
|
456
|
+
const edit = constrainPathEdit(before, worldPath, constraints, { origins, axisAngle: constraintAxisOf(node) });
|
|
457
|
+
if (!edit.converged) return null;
|
|
458
|
+
const same2 = edit.constraints.length === constraints.length && edit.constraints.every((c, i) => JSON.stringify(c) === JSON.stringify(constraints[i]));
|
|
459
|
+
return { path: edit.path, constraints: same2 ? constraints : edit.constraints };
|
|
460
|
+
}
|
|
461
|
+
function solveNodeConstraints(node, constraints) {
|
|
462
|
+
const path = resolveEditablePathOf(node);
|
|
463
|
+
if (!path) return null;
|
|
464
|
+
const axisAngle = constraintAxisOf(node);
|
|
465
|
+
const solved = solvePathConstraints(path, constraints, { axisAngle });
|
|
466
|
+
if (solved.converged) return { path: solved.path, conflicts: [] };
|
|
467
|
+
return { path, conflicts: conflictsIn(path, constraints, axisAngle) };
|
|
468
|
+
}
|
|
469
|
+
function constraintLinkOf(data) {
|
|
470
|
+
const c = data?.constraintLink;
|
|
471
|
+
return c && typeof c === "object" && typeof c.kind === "string" ? c : null;
|
|
472
|
+
}
|
|
473
|
+
var CONSTRAINT_LINK_SHAPE = {
|
|
474
|
+
id: "path-tools:constraint-link",
|
|
475
|
+
matches: (node) => constraintLinkOf(node.data) !== null,
|
|
476
|
+
paint: () => [],
|
|
477
|
+
bounds: () => null
|
|
478
|
+
};
|
|
479
|
+
var linkShapeRegistered = false;
|
|
480
|
+
function registerConstraintLinkShape() {
|
|
481
|
+
if (linkShapeRegistered) return;
|
|
482
|
+
linkShapeRegistered = true;
|
|
483
|
+
registerNodeShape(CONSTRAINT_LINK_SHAPE, { priority: "high" });
|
|
484
|
+
}
|
|
485
|
+
function constraintLinkNode(opts) {
|
|
486
|
+
registerConstraintLinkShape();
|
|
487
|
+
return {
|
|
488
|
+
kind: "leaf",
|
|
489
|
+
layer: opts.layer,
|
|
490
|
+
pose: opts.pose ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
491
|
+
data: { constraintLink: opts.constraint },
|
|
492
|
+
dependsOn: opts.nodes,
|
|
493
|
+
pickable: false,
|
|
494
|
+
...opts.id ? { id: opts.id } : {}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function linkAt(ctx, id) {
|
|
498
|
+
const node = ctx.node(id);
|
|
499
|
+
const constraint = constraintLinkOf(node?.data);
|
|
500
|
+
const nodes = node?.dependsOn;
|
|
501
|
+
return constraint && Array.isArray(nodes) ? { id, nodes, constraint } : null;
|
|
502
|
+
}
|
|
503
|
+
function linkedFrom(ctx, start) {
|
|
504
|
+
const nodes = [start];
|
|
505
|
+
const links = /* @__PURE__ */ new Map();
|
|
506
|
+
for (let k = 0; k < nodes.length; k++) {
|
|
507
|
+
for (const id of ctx.dependents(nodes[k])) {
|
|
508
|
+
const link = links.has(id) ? null : linkAt(ctx, id);
|
|
509
|
+
if (!link) continue;
|
|
510
|
+
links.set(id, link);
|
|
511
|
+
for (const n of link.nodes) if (!nodes.includes(n)) nodes.push(n);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return { nodes, links: [...links.values()] };
|
|
515
|
+
}
|
|
516
|
+
var same = (a, b) => a.length === b.length && a.every((c, i) => JSON.stringify(c) === JSON.stringify(b[i]));
|
|
517
|
+
function finishLinkedEdit(ctx, worldPath, origins) {
|
|
518
|
+
const { nodes, links } = linkedFrom(ctx, ctx.id);
|
|
519
|
+
if (links.length === 0) return void 0;
|
|
520
|
+
const items = [];
|
|
521
|
+
for (const [k, id] of nodes.entries()) {
|
|
522
|
+
const node = ctx.node(id);
|
|
523
|
+
const before = ctx.path(id);
|
|
524
|
+
if (!node || !before) return void 0;
|
|
525
|
+
items.push({
|
|
526
|
+
before,
|
|
527
|
+
after: k === 0 ? worldPath : before,
|
|
528
|
+
constraints: constraintsOf(node.data),
|
|
529
|
+
axisAngle: constraintAxisOf(node),
|
|
530
|
+
...k === 0 ? { origins } : {}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const pathLinks = links.map((l) => ({ constraint: l.constraint, paths: l.nodes.map((n) => nodes.indexOf(n)) }));
|
|
534
|
+
const done = constrainPathsEdit(items, pathLinks);
|
|
535
|
+
if (!done.converged) return null;
|
|
536
|
+
const others = [];
|
|
537
|
+
done.items.forEach((item, k) => {
|
|
538
|
+
if (k === 0) return;
|
|
539
|
+
const had = constraintsOf(ctx.node(nodes[k])?.data);
|
|
540
|
+
const changed = !same(had, item.constraints);
|
|
541
|
+
if (item.path !== items[k].after || changed) {
|
|
542
|
+
others.push({ id: nodes[k], path: item.path, ...changed ? { data: { constraints: item.constraints } } : {} });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
done.links.forEach((l, k) => {
|
|
546
|
+
const link = links[k];
|
|
547
|
+
if (!l.constraint) others.push({ id: link.id, remove: true });
|
|
548
|
+
else if (JSON.stringify(l.constraint) !== JSON.stringify(link.constraint)) {
|
|
549
|
+
others.push({ id: link.id, data: { constraintLink: l.constraint } });
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
const own = constraintsOf(ctx.node(ctx.id)?.data);
|
|
553
|
+
const [first] = done.items;
|
|
554
|
+
return { path: first.path, constraints: same(own, first.constraints) ? own : first.constraints, others };
|
|
555
|
+
}
|
|
556
|
+
var OFFSET = 12;
|
|
557
|
+
var at = (p, d, k) => ({ x: p.x + d.x * k, y: p.y + d.y * k });
|
|
558
|
+
function line(b, p, q) {
|
|
559
|
+
b.moveTo(p.x, p.y).lineTo(q.x, q.y);
|
|
560
|
+
}
|
|
561
|
+
function centroidOf(points) {
|
|
562
|
+
const n = points.length || 1;
|
|
563
|
+
return { x: points.reduce((s, p) => s + p.x, 0) / n, y: points.reduce((s, p) => s + p.y, 0) / n };
|
|
564
|
+
}
|
|
565
|
+
function toward(p, to) {
|
|
566
|
+
const len = Math.hypot(to.x - p.x, to.y - p.y);
|
|
567
|
+
return len > 1e-9 ? { x: (to.x - p.x) / len, y: (to.y - p.y) / len } : { x: 0, y: 1 };
|
|
568
|
+
}
|
|
569
|
+
function frameOf(points, [i, j], inside) {
|
|
570
|
+
const a = points[i];
|
|
571
|
+
const c = points[j];
|
|
572
|
+
const len = Math.hypot(c.x - a.x, c.y - a.y);
|
|
573
|
+
const u = len > 1e-9 ? { x: (c.x - a.x) / len, y: (c.y - a.y) / len } : { x: 1, y: 0 };
|
|
574
|
+
const m = { x: (a.x + c.x) / 2, y: (a.y + c.y) / 2 };
|
|
575
|
+
const left = { x: -u.y, y: u.x };
|
|
576
|
+
const n = left.x * (inside.x - m.x) + left.y * (inside.y - m.y) < 0 ? { x: u.y, y: -u.x } : left;
|
|
577
|
+
return { m, u, n, o: at(m, n, OFFSET) };
|
|
578
|
+
}
|
|
579
|
+
function segmentsOf(c) {
|
|
580
|
+
switch (c.kind) {
|
|
581
|
+
case "fixed":
|
|
582
|
+
case "tangent":
|
|
583
|
+
case "onPath":
|
|
584
|
+
case "coincident":
|
|
585
|
+
return [];
|
|
586
|
+
case "horizontal":
|
|
587
|
+
case "vertical":
|
|
588
|
+
case "length":
|
|
589
|
+
return [c.segment];
|
|
590
|
+
default:
|
|
591
|
+
return c.segments;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function markCenters(points, c, inside) {
|
|
595
|
+
if (c.kind === "fixed" || c.kind === "tangent" || c.kind === "onPath" || c.kind === "coincident") {
|
|
596
|
+
const p = points[c.kind === "coincident" ? c.anchors[0] : c.anchor];
|
|
597
|
+
return [at(p, toward(p, inside), c.kind === "fixed" ? 11 : 13)];
|
|
598
|
+
}
|
|
599
|
+
return segmentsOf(c).map((s) => {
|
|
600
|
+
const f = frameOf(points, s, inside);
|
|
601
|
+
return c.kind === "equalLength" ? f.m : f.o;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function letter(b, o, which) {
|
|
605
|
+
if (which === "H") {
|
|
606
|
+
line(b, { x: o.x - 3, y: o.y - 4 }, { x: o.x - 3, y: o.y + 4 });
|
|
607
|
+
line(b, { x: o.x + 3, y: o.y - 4 }, { x: o.x + 3, y: o.y + 4 });
|
|
608
|
+
line(b, { x: o.x - 3, y: o.y }, { x: o.x + 3, y: o.y });
|
|
609
|
+
} else {
|
|
610
|
+
b.moveTo(o.x - 3, o.y - 4).lineTo(o.x, o.y + 4).lineTo(o.x + 3, o.y - 4);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function segmentMark(b, points, kind, s, inside) {
|
|
614
|
+
const { m, u, n, o } = frameOf(points, s, inside);
|
|
615
|
+
switch (kind) {
|
|
616
|
+
case "parallel":
|
|
617
|
+
for (const k of [-2, 2]) line(b, at(at(o, n, k), u, -4), at(at(o, n, k), u, 4));
|
|
618
|
+
return;
|
|
619
|
+
case "perpendicular":
|
|
620
|
+
line(b, at(o, u, -4), at(o, u, 4));
|
|
621
|
+
line(b, o, at(o, n, 7));
|
|
622
|
+
return;
|
|
623
|
+
case "equalLength":
|
|
624
|
+
for (const k of [-2, 2]) line(b, at(at(m, u, k), n, -4), at(at(m, u, k), n, 4));
|
|
625
|
+
return;
|
|
626
|
+
case "length":
|
|
627
|
+
line(b, at(o, u, -8), at(o, u, 8));
|
|
628
|
+
for (const k of [-8, 8]) line(b, at(at(o, u, k), n, -3), at(at(o, u, k), n, 3));
|
|
629
|
+
return;
|
|
630
|
+
default:
|
|
631
|
+
letter(b, o, kind === "vertical" ? "V" : "H");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function glyphOf(b, points, c, inside) {
|
|
635
|
+
switch (c.kind) {
|
|
636
|
+
case "fixed": {
|
|
637
|
+
const p = points[c.anchor];
|
|
638
|
+
const d = toward(p, inside);
|
|
639
|
+
const side = { x: -d.y, y: d.x };
|
|
640
|
+
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();
|
|
641
|
+
line(b, at(at(p, d, 17), side, -7), at(at(p, d, 17), side, 7));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
case "coincident": {
|
|
645
|
+
const p = points[c.anchors[0]];
|
|
646
|
+
const d = toward(p, inside);
|
|
647
|
+
const ring = at(p, d, 13);
|
|
648
|
+
line(b, at(p, d, 6), at(p, d, 9));
|
|
649
|
+
circle(b, ring, 4);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
case "tangent": {
|
|
653
|
+
const p = points[c.anchor];
|
|
654
|
+
const d = toward(p, inside);
|
|
655
|
+
const ring = at(p, d, 13);
|
|
656
|
+
const side = { x: -d.y, y: d.x };
|
|
657
|
+
const touch = at(ring, d, -4);
|
|
658
|
+
line(b, at(touch, side, -6), at(touch, side, 6));
|
|
659
|
+
circle(b, ring, 4);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
case "onPath": {
|
|
663
|
+
const p = points[c.anchor];
|
|
664
|
+
const d = toward(p, inside);
|
|
665
|
+
const dot = at(p, d, 13);
|
|
666
|
+
const side = { x: -d.y, y: d.x };
|
|
667
|
+
line(b, at(dot, side, -7), at(dot, side, 7));
|
|
668
|
+
circle(b, dot, 2.5);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
default:
|
|
672
|
+
for (const s of segmentsOf(c)) segmentMark(b, points, c.kind, s, inside);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function circle(b, o, r) {
|
|
676
|
+
b.moveTo(o.x + r, o.y);
|
|
677
|
+
for (let k = 1; k <= 12; k++) b.lineTo(o.x + r * Math.cos(k * Math.PI / 6), o.y + r * Math.sin(k * Math.PI / 6));
|
|
678
|
+
}
|
|
679
|
+
var xy = (p) => [p.x, p.y];
|
|
680
|
+
function constraintGlyphs(points, constraints, unsatisfied, style, selected = null) {
|
|
681
|
+
const inside = centroidOf(points);
|
|
682
|
+
const out = [];
|
|
683
|
+
constraints.forEach((c, i) => {
|
|
684
|
+
if (!constraintFits(c, points.length)) return;
|
|
685
|
+
const b = new PathBuilder();
|
|
686
|
+
glyphOf(b, points, c, inside);
|
|
687
|
+
const color = unsatisfied.has(i) ? style.conflictStroke : style.stroke;
|
|
688
|
+
out.push({ kind: "path", path: b.build(), stroke: { paint: { kind: "solid", color }, width: i === selected ? 3 : 1.5 } });
|
|
689
|
+
});
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
function constraintMarkAt(points, constraints, p, radius) {
|
|
693
|
+
const inside = centroidOf(points);
|
|
694
|
+
let best = -1;
|
|
695
|
+
let bestD = radius;
|
|
696
|
+
constraints.forEach((c, i) => {
|
|
697
|
+
if (!constraintFits(c, points.length)) return;
|
|
698
|
+
for (const m of markCenters(points, c, inside)) {
|
|
699
|
+
const d = Math.hypot(m.x - p.x, m.y - p.y);
|
|
700
|
+
if (d <= bestD) {
|
|
701
|
+
bestD = d;
|
|
702
|
+
best = i;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
return best;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/constraints/constraintsExtension.ts
|
|
710
|
+
var CONSTRAINTS_EXTENSION_ID = "constraints";
|
|
711
|
+
var MARK_CLICK_PX = 8;
|
|
712
|
+
var DEFAULT_STYLE = { stroke: "#0e9f8e", conflictStroke: "#e5484d" };
|
|
713
|
+
function editedPath2(dep) {
|
|
714
|
+
const p = dep.getEditablePath(dep.editingId);
|
|
715
|
+
return p?.kind === "polygon" ? p : null;
|
|
716
|
+
}
|
|
717
|
+
function setConstraints(dep, constraints, label) {
|
|
718
|
+
const node = dep.getNodeShape(dep.editingId);
|
|
719
|
+
const solved = node && dep.store ? solveNodeConstraints(node, constraints) : null;
|
|
720
|
+
if (!solved) return null;
|
|
721
|
+
dep.setSelectedMark?.(null);
|
|
722
|
+
dep.store(dep.editingId, solved.path, label, { constraints: [...constraints] });
|
|
723
|
+
return solved.conflicts;
|
|
724
|
+
}
|
|
725
|
+
var conflictCache = /* @__PURE__ */ new WeakMap();
|
|
726
|
+
function conflictsOf(path, constraints, axisAngle) {
|
|
727
|
+
const key = `${axisAngle};${Array.from(path.commands).join(",")};${Array.from(path.coords).join(",")}`;
|
|
728
|
+
const hit = conflictCache.get(constraints);
|
|
729
|
+
if (hit?.key === key) return hit.conflicts;
|
|
730
|
+
const conflicts = new Set(conflictsIn(path, constraints, axisAngle));
|
|
731
|
+
conflictCache.set(constraints, { key, conflicts });
|
|
732
|
+
return conflicts;
|
|
733
|
+
}
|
|
734
|
+
function selectedConstraintOf(dep) {
|
|
735
|
+
const mark = dep.selectedMark;
|
|
736
|
+
return mark?.owner === CONSTRAINTS_EXTENSION_ID ? mark.index : null;
|
|
737
|
+
}
|
|
738
|
+
var screenPoints = (target, toScreen) => pathAnchorPoints(target.path).map(toScreen);
|
|
739
|
+
function createConstraintsExtension(style = {}) {
|
|
740
|
+
const colors = { ...DEFAULT_STYLE, ...style };
|
|
741
|
+
registerConstraintLinkShape();
|
|
742
|
+
return {
|
|
743
|
+
id: CONSTRAINTS_EXTENSION_ID,
|
|
744
|
+
marks(ctx) {
|
|
745
|
+
const constraints = constraintsOf(ctx.node.data);
|
|
746
|
+
if (constraints.length === 0) return [];
|
|
747
|
+
const world = pathAnchorPoints(ctx.path);
|
|
748
|
+
const conflicts = conflictsOf(ctx.path, constraints, constraintAxisOf(ctx.node));
|
|
749
|
+
return constraintGlyphs(world.map(ctx.toScreen), constraints, conflicts, colors, ctx.selected);
|
|
750
|
+
},
|
|
751
|
+
markAt(target, toScreen, screen) {
|
|
752
|
+
const constraints = constraintsOf(target.node.data);
|
|
753
|
+
if (constraints.length === 0) return -1;
|
|
754
|
+
return constraintMarkAt(screenPoints(target, toScreen), constraints, screen, MARK_CLICK_PX);
|
|
755
|
+
},
|
|
756
|
+
deleteMark(target, index, store) {
|
|
757
|
+
const current = constraintsOf(target.node.data);
|
|
758
|
+
if (index >= current.length) return;
|
|
759
|
+
const kept = current.filter((_, i) => i !== index);
|
|
760
|
+
const solved = solveNodeConstraints(target.node, kept);
|
|
761
|
+
if (solved) store(target.id, solved.path, "Delete constraint", { constraints: kept });
|
|
762
|
+
},
|
|
763
|
+
finishEdit(node, worldPath, origins, ctx) {
|
|
764
|
+
const linked = ctx ? finishLinkedEdit(ctx, worldPath, origins) : void 0;
|
|
765
|
+
if (linked === null) return null;
|
|
766
|
+
if (linked) {
|
|
767
|
+
return linked.constraints === constraintsOf(node.data) ? { path: linked.path, others: linked.others } : { path: linked.path, data: { constraints: linked.constraints }, others: linked.others };
|
|
768
|
+
}
|
|
769
|
+
const done = constrainNodeEdit(node, worldPath, origins);
|
|
770
|
+
if (!done) return null;
|
|
771
|
+
return done.constraints === constraintsOf(node.data) ? { path: done.path } : { path: done.path, data: { constraints: done.constraints } };
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/constraints/anchorConstraints.ts
|
|
777
|
+
var pairsOf = (anchors) => anchors.slice(1).map((b, i) => [anchors[i], b]);
|
|
778
|
+
function segmentPair(anchors) {
|
|
779
|
+
if (anchors.length === 3) return [[anchors[0], anchors[1]], [anchors[1], anchors[2]]];
|
|
780
|
+
if (anchors.length === 4) return [[anchors[0], anchors[1]], [anchors[2], anchors[3]]];
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
function constraintsForAnchors(kind, anchors, path, length) {
|
|
784
|
+
switch (kind) {
|
|
785
|
+
case "fixed":
|
|
786
|
+
return anchors.map((anchor) => ({ kind, anchor }));
|
|
787
|
+
case "onPath": {
|
|
788
|
+
const segment = anchors.length === 3 ? segmentBetween(path, anchors[1], anchors[2]) : null;
|
|
789
|
+
return segment ? [{ kind, anchor: anchors[0], segment }] : [];
|
|
790
|
+
}
|
|
791
|
+
case "tangent": {
|
|
792
|
+
const points = pathAnchorPoints(path);
|
|
793
|
+
return anchors.filter((a) => points[a]?.inHandle && points[a]?.outHandle).map((anchor) => ({ kind, anchor }));
|
|
794
|
+
}
|
|
795
|
+
case "horizontal":
|
|
796
|
+
case "vertical":
|
|
797
|
+
return pairsOf(anchors).map((segment) => ({ kind, segment }));
|
|
798
|
+
case "coincident":
|
|
799
|
+
return pairsOf(anchors).map((pair) => ({ kind, anchors: pair }));
|
|
800
|
+
case "length": {
|
|
801
|
+
const points = pathAnchorPoints(path);
|
|
802
|
+
return pairsOf(anchors).map((segment) => ({
|
|
803
|
+
kind,
|
|
804
|
+
segment,
|
|
805
|
+
length: length ?? Math.hypot(points[segment[1]].x - points[segment[0]].x, points[segment[1]].y - points[segment[0]].y)
|
|
806
|
+
}));
|
|
807
|
+
}
|
|
808
|
+
default: {
|
|
809
|
+
const segments = segmentPair(anchors);
|
|
810
|
+
return segments ? [{ kind, segments }] : [];
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
var sameConstraint = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
815
|
+
function withAdded(dep, params) {
|
|
816
|
+
const path = editedPath2(dep);
|
|
817
|
+
if (!path || !params?.kind) return null;
|
|
818
|
+
const current = constraintsOf(dep.getNodeShape(dep.editingId)?.data);
|
|
819
|
+
const added = constraintsForAnchors(params.kind, [...dep.selectedAnchors], path, params.length).filter((c) => !current.some((had) => sameConstraint(had, c)));
|
|
820
|
+
return added.length > 0 ? [...current, ...added] : null;
|
|
821
|
+
}
|
|
822
|
+
function withRemoved(dep, kind) {
|
|
823
|
+
const current = constraintsOf(dep.getNodeShape(dep.editingId)?.data);
|
|
824
|
+
const kept = current.filter((c) => kind !== void 0 && c.kind !== kind || !constraintAnchors(c).every((a) => dep.selectedAnchors.has(a)));
|
|
825
|
+
return kept.length < current.length ? kept : null;
|
|
826
|
+
}
|
|
827
|
+
function constraintCommand(id, label, next) {
|
|
828
|
+
const ready = (deps) => {
|
|
829
|
+
const dep = depOf(deps);
|
|
830
|
+
return dep?.store && dep.selectedAnchors.size > 0 ? dep : null;
|
|
831
|
+
};
|
|
832
|
+
return {
|
|
833
|
+
id,
|
|
834
|
+
label,
|
|
835
|
+
group: "anchors",
|
|
836
|
+
tags: pathOperationTags("drag"),
|
|
837
|
+
eligible: { capability: "edits-anchors" },
|
|
838
|
+
requires: ["editAnchors"],
|
|
839
|
+
invoker: {
|
|
840
|
+
timing: "immediate",
|
|
841
|
+
run: (deps, params) => {
|
|
842
|
+
const dep = ready(deps);
|
|
843
|
+
const list = dep && next(dep, params);
|
|
844
|
+
if (dep && list) setConstraints(dep, list, label);
|
|
845
|
+
}
|
|
846
|
+
},
|
|
847
|
+
enabled: (deps) => ready(deps) ? true : ActionDisabledReason.SelectionRequired
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
var deleteConstraintAction = {
|
|
851
|
+
id: "deleteConstraint",
|
|
852
|
+
label: "Delete constraint",
|
|
853
|
+
icon: (
|
|
854
|
+
/* @icon */
|
|
855
|
+
"core:delete"
|
|
856
|
+
),
|
|
857
|
+
eligible: { capability: "edits-anchors" },
|
|
858
|
+
requires: ["editAnchors"],
|
|
859
|
+
invoker: {
|
|
860
|
+
timing: "immediate",
|
|
861
|
+
run: (deps) => {
|
|
862
|
+
const dep = depOf(deps);
|
|
863
|
+
if (dep && selectedConstraintOf(dep) !== null) deleteSelectedMark(dep);
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
enabled: (deps) => {
|
|
867
|
+
const dep = depOf(deps);
|
|
868
|
+
return dep?.store && selectedConstraintOf(dep) !== null ? true : ActionDisabledReason.SelectionRequired;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
var constrainAnchorsAction = constraintCommand(
|
|
872
|
+
"constrainAnchors",
|
|
873
|
+
"Constrain anchors",
|
|
874
|
+
(dep, params) => withAdded(dep, params)
|
|
875
|
+
);
|
|
876
|
+
var unconstrainAnchorsAction = constraintCommand(
|
|
877
|
+
"unconstrainAnchors",
|
|
878
|
+
"Remove constraints",
|
|
879
|
+
(dep, params) => withRemoved(dep, params?.kind)
|
|
880
|
+
);
|
|
881
|
+
var DEFAULT_STROKE_REACH_PX = 6;
|
|
882
|
+
function pressOf(deps, params) {
|
|
883
|
+
const p = params;
|
|
884
|
+
const x = p?.pressX ?? p?.worldX, y = p?.pressY ?? p?.worldY;
|
|
885
|
+
if (x === void 0 || y === void 0) return null;
|
|
886
|
+
const scale = deps.view?.get().scale ?? { x: 1, y: 1 };
|
|
887
|
+
const radius = (p?.radiusPx ?? DEFAULT_STROKE_REACH_PX) / Math.max(Math.abs(scale.x), Math.abs(scale.y));
|
|
888
|
+
return { at: { x, y }, radius };
|
|
889
|
+
}
|
|
890
|
+
function renderOrder(deps) {
|
|
891
|
+
const scene = deps?.scene;
|
|
892
|
+
return scene?.renderOrderNodes?.() ?? [];
|
|
893
|
+
}
|
|
894
|
+
function scenePathNodes(deps) {
|
|
895
|
+
const out = [];
|
|
896
|
+
for (const node of renderOrder(deps)) {
|
|
897
|
+
if (node.kind !== "leaf") continue;
|
|
898
|
+
const path = resolveEditablePathOf(node);
|
|
899
|
+
if (path) out.push({ id: node.id, node, path });
|
|
900
|
+
}
|
|
901
|
+
return out;
|
|
902
|
+
}
|
|
903
|
+
function strokesBesides(deps, id) {
|
|
904
|
+
const out = [];
|
|
905
|
+
for (const node of renderOrder(deps)) {
|
|
906
|
+
if (node.kind !== "leaf" || node.id === id) continue;
|
|
907
|
+
const outline = nodeOutline(node);
|
|
908
|
+
if (outline) out.push(outline);
|
|
909
|
+
}
|
|
910
|
+
return out;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// src/extend/extendToMeet.ts
|
|
914
|
+
var EXTEND_LABEL = "Extend to next path";
|
|
915
|
+
function endsAt(path, anchors) {
|
|
916
|
+
const ends = openPathEnds(path);
|
|
917
|
+
if (anchors.size === 0) return ends;
|
|
918
|
+
const set = pathToAnchors(path);
|
|
919
|
+
const at2 = [...anchors].map((flat) => openEndAt(set, flat));
|
|
920
|
+
const picked = ends.filter((e) => at2.some((o) => o?.sub === e.sub && o.end === e.end));
|
|
921
|
+
return picked.length > 0 ? picked : ends;
|
|
922
|
+
}
|
|
923
|
+
var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
924
|
+
function endNearPress(all, press) {
|
|
925
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
926
|
+
const node = all[i];
|
|
927
|
+
if (pathDistanceToPoint(node.path, press.at.x, press.at.y) > press.radius) continue;
|
|
928
|
+
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];
|
|
929
|
+
if (near) return [{ node, ends: [near.end] }];
|
|
930
|
+
}
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
function planExtend(deps, params) {
|
|
934
|
+
const out = /* @__PURE__ */ new Map();
|
|
935
|
+
const all = scenePathNodes(deps);
|
|
936
|
+
const editing = deps?.editAnchors?.editingId;
|
|
937
|
+
const press = deps ? pressOf(deps, params) : null;
|
|
938
|
+
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) }));
|
|
939
|
+
for (const { node, ends } of subjects) {
|
|
940
|
+
const boundaries = strokesBesides(deps, node.id);
|
|
941
|
+
let path = node.path;
|
|
942
|
+
for (const end of ends) path = extendToMeet(path, boundaries, end)?.path ?? path;
|
|
943
|
+
if (path !== node.path) out.set(node.id, { node, path });
|
|
944
|
+
}
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
var extendToMeetAction = {
|
|
948
|
+
id: "extendToMeet",
|
|
949
|
+
icon: (
|
|
950
|
+
/* @icon */
|
|
951
|
+
"path-tools:extend"
|
|
952
|
+
),
|
|
953
|
+
label: EXTEND_LABEL,
|
|
954
|
+
tags: pathOperationTags("click"),
|
|
955
|
+
group: "path",
|
|
956
|
+
eligible: { capability: "creates-shapes" },
|
|
957
|
+
requires: ["selection", "scene", "applyOps", "editAnchors", "view"],
|
|
958
|
+
invoker: {
|
|
959
|
+
timing: "immediate",
|
|
960
|
+
run: (deps, params) => {
|
|
961
|
+
const ops = [];
|
|
962
|
+
for (const { node, path } of planExtend(deps, params).values()) ops.push(...reshapeOps(node.node, path, EXTEND_LABEL));
|
|
963
|
+
commitOps(deps, ops, EXTEND_LABEL);
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
// A click's point arrives here without the binding's reach, so `run` decides there.
|
|
967
|
+
enabled: (deps, at2) => at2 || planExtend(deps).size > 0 ? true : ActionDisabledReason.NotApplicable
|
|
968
|
+
};
|
|
969
|
+
function makeHandleLockToggle(lock, label, icon) {
|
|
970
|
+
return {
|
|
971
|
+
id: `handleLock.${lock}`,
|
|
972
|
+
label,
|
|
973
|
+
tags: pathOperationTags("drag"),
|
|
974
|
+
icon,
|
|
975
|
+
group: "anchors",
|
|
976
|
+
requires: ["editAnchors"],
|
|
977
|
+
active: (deps) => deps?.editAnchors?.handleLocks?.[lock] === true,
|
|
978
|
+
enabled: (deps) => deps?.editAnchors?.setHandleLocks ? true : ActionDisabledReason.NotApplicable,
|
|
979
|
+
invoker: {
|
|
980
|
+
timing: "immediate",
|
|
981
|
+
run: (deps) => {
|
|
982
|
+
const dep = deps?.editAnchors;
|
|
983
|
+
if (!dep?.setHandleLocks) return;
|
|
984
|
+
const held = dep.handleLocks ?? { angle: false, length: false };
|
|
985
|
+
dep.setHandleLocks({ ...held, [lock]: !held[lock] });
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
var handleLockAngleAction = makeHandleLockToggle(
|
|
991
|
+
"angle",
|
|
992
|
+
"Hold handle angle",
|
|
993
|
+
/* @icon */
|
|
994
|
+
"core:holdHandleAngle"
|
|
995
|
+
);
|
|
996
|
+
var handleLockLengthAction = makeHandleLockToggle(
|
|
997
|
+
"length",
|
|
998
|
+
"Hold handle length",
|
|
999
|
+
/* @icon */
|
|
1000
|
+
"core:holdHandleLength"
|
|
1001
|
+
);
|
|
1002
|
+
var DEG = Math.PI / 180;
|
|
1003
|
+
var DEFAULT_STEP_DEG = 45;
|
|
1004
|
+
var DEFAULT_REACH_DEG = 6;
|
|
1005
|
+
var LINE_OVERSHOOT = 1.25;
|
|
1006
|
+
var reader = (options) => typeof options === "function" ? options : () => options;
|
|
1007
|
+
function handleSnapSource(options = {}) {
|
|
1008
|
+
const read = reader(options);
|
|
1009
|
+
return {
|
|
1010
|
+
id: "handle",
|
|
1011
|
+
roles: [],
|
|
1012
|
+
gestures: ["handle"],
|
|
1013
|
+
collect: () => [],
|
|
1014
|
+
*angles(q) {
|
|
1015
|
+
const opts = read();
|
|
1016
|
+
if (!opts) return;
|
|
1017
|
+
const { pivot } = q;
|
|
1018
|
+
const reach = Math.hypot(q.press.x - pivot.x, q.press.y - pivot.y) * LINE_OVERSHOOT;
|
|
1019
|
+
for (const other of opts.opposite === false ? [] : q.also?.anchors ?? []) {
|
|
1020
|
+
if (other.x === pivot.x && other.y === pivot.y) continue;
|
|
1021
|
+
const angle = Math.atan2(pivot.y - other.y, pivot.x - other.x);
|
|
1022
|
+
yield { angle, mark: { kind: "line", from: other, to: along(pivot, angle, reach) } };
|
|
1023
|
+
}
|
|
1024
|
+
const step = (opts.stepDeg ?? DEFAULT_STEP_DEG) * DEG;
|
|
1025
|
+
if (step <= 0) return;
|
|
1026
|
+
for (const angle of stepAngles(q.theta, step)) yield { angle, mark: stepLine(pivot, angle, reach) };
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function createHandleSnapExtension(options = {}) {
|
|
1031
|
+
const read = reader(options);
|
|
1032
|
+
return {
|
|
1033
|
+
id: "handleSnap",
|
|
1034
|
+
drag(start) {
|
|
1035
|
+
if (start.part === "anchor") return null;
|
|
1036
|
+
const pressed = anchorAt(start.anchors, start.anchorIndex);
|
|
1037
|
+
const handle = start.part === "controlIn" ? pressed?.inHandle : pressed?.outHandle;
|
|
1038
|
+
if (!pressed || !handle) return null;
|
|
1039
|
+
const anchor = { x: pressed.x, y: pressed.y };
|
|
1040
|
+
const other = start.part === "controlIn" ? pressed.outHandle : pressed.inHandle;
|
|
1041
|
+
const session = start.snap?.begin({
|
|
1042
|
+
gesture: "handle",
|
|
1043
|
+
press: { x: handle.x, y: handle.y },
|
|
1044
|
+
pivot: anchor,
|
|
1045
|
+
ids: [start.id],
|
|
1046
|
+
theta0: Math.atan2(handle.y - anchor.y, handle.x - anchor.x),
|
|
1047
|
+
reachRad: (read()?.reachDeg ?? DEFAULT_REACH_DEG) * DEG,
|
|
1048
|
+
...other ? { also: { anchors: [{ x: other.x, y: other.y }] } } : {}
|
|
1049
|
+
});
|
|
1050
|
+
if (!session) return null;
|
|
1051
|
+
return {
|
|
1052
|
+
handle(to, { locks }) {
|
|
1053
|
+
const dx = to.x - anchor.x;
|
|
1054
|
+
const dy = to.y - anchor.y;
|
|
1055
|
+
const len = Math.hypot(dx, dy);
|
|
1056
|
+
const off = !read() || len === 0;
|
|
1057
|
+
const { theta } = session.angle(Math.atan2(dy, dx), { suspend: off, constrain: false, hold: locks.angle });
|
|
1058
|
+
if (off || locks.angle) return to;
|
|
1059
|
+
return along(anchor, theta, len);
|
|
1060
|
+
},
|
|
1061
|
+
end: () => session.end()
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
function along(from, angle, len) {
|
|
1067
|
+
return { x: from.x + Math.cos(angle) * len, y: from.y + Math.sin(angle) * len };
|
|
1068
|
+
}
|
|
1069
|
+
function stepLine(pivot, angle, reach) {
|
|
1070
|
+
return { kind: "line", from: along(pivot, angle + Math.PI, reach), to: along(pivot, angle, reach) };
|
|
1071
|
+
}
|
|
1072
|
+
var MIN_RADIUS_PX = 2;
|
|
1073
|
+
var MAX_RADIUS_PX = 4e3;
|
|
1074
|
+
var WHEEL_GAIN = 15e-4;
|
|
1075
|
+
function createSoftSelectionExtension(options) {
|
|
1076
|
+
return {
|
|
1077
|
+
id: "softSelection",
|
|
1078
|
+
drag(start) {
|
|
1079
|
+
const opts = options();
|
|
1080
|
+
if (!opts || start.part !== "anchor" || start.dragging.length === 0) return null;
|
|
1081
|
+
return softDrag(start, opts);
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
function softDrag(start, opts) {
|
|
1086
|
+
const measure = measureAnchorPath(start.anchors, start.scale ? { scale: start.scale } : {});
|
|
1087
|
+
const falloff = opts.falloff ?? "smooth";
|
|
1088
|
+
let radiusPx = clampRadius(opts.radiusPx);
|
|
1089
|
+
let weights = softSelectionWeights(measure, start.dragging, radiusPx, falloff);
|
|
1090
|
+
return {
|
|
1091
|
+
weights: () => weights,
|
|
1092
|
+
wheel({ deltaY }) {
|
|
1093
|
+
radiusPx = clampRadius(radiusPx * Math.exp(-deltaY * WHEEL_GAIN));
|
|
1094
|
+
weights = softSelectionWeights(measure, start.dragging, radiusPx, falloff);
|
|
1095
|
+
opts.onRadius?.(radiusPx);
|
|
1096
|
+
return true;
|
|
1097
|
+
},
|
|
1098
|
+
overlays: (current) => softSelectionBand(measure, current, start.dragging, radiusPx).map((points) => ({ kind: "polyline", points, role: "falloff" }))
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function clampRadius(px) {
|
|
1102
|
+
return Math.min(MAX_RADIUS_PX, Math.max(MIN_RADIUS_PX, Number.isFinite(px) ? px : MIN_RADIUS_PX));
|
|
1103
|
+
}
|
|
1104
|
+
var harmonizeAnchorsAction = makeHandleCommand(
|
|
1105
|
+
"harmonizeAnchors",
|
|
1106
|
+
"Harmonize curvature",
|
|
1107
|
+
/* @icon */
|
|
1108
|
+
"core:harmonize",
|
|
1109
|
+
harmonizeAnchors,
|
|
1110
|
+
"selectedOrAll"
|
|
1111
|
+
);
|
|
1112
|
+
var LENGTHEN_TOOL = "lengthen";
|
|
1113
|
+
var LENGTHEN_CURSOR = { glyph: "penPlus", fallback: "crosshair" };
|
|
1114
|
+
var END_HIT_PX = 8;
|
|
1115
|
+
function editedPath3(deps) {
|
|
1116
|
+
const dep = depOf(deps);
|
|
1117
|
+
if (!dep || dep.tool !== LENGTHEN_TOOL) return null;
|
|
1118
|
+
const path = dep.getEditablePath(dep.editingId);
|
|
1119
|
+
return path?.kind === "polygon" ? { dep, path } : null;
|
|
1120
|
+
}
|
|
1121
|
+
function endUnder(deps, x, y) {
|
|
1122
|
+
const edited = editedPath3(deps);
|
|
1123
|
+
if (!edited) return false;
|
|
1124
|
+
const scale = deps?.view?.get().scale ?? { x: 1, y: 1 };
|
|
1125
|
+
const hit = nearestPathAnchor([{ id: edited.dep.editingId, path: edited.path }], { x, y }, END_HIT_PX, scale);
|
|
1126
|
+
return !!hit && hit.end !== null && pathToAnchors(edited.path).anchors[hit.sub].length > 1;
|
|
1127
|
+
}
|
|
1128
|
+
function modeOf(opts, flip) {
|
|
1129
|
+
const mode = resolveParams(opts?.params)?.mode;
|
|
1130
|
+
const base = mode === "curvature" ? "curvature" : "tangent";
|
|
1131
|
+
if (!flip) return base;
|
|
1132
|
+
return base === "curvature" ? "tangent" : "curvature";
|
|
1133
|
+
}
|
|
1134
|
+
function pressedAnchor(ctx) {
|
|
1135
|
+
const m = ctx.drag?.affordance?.kind.match(/^anchor:(\d+)$/);
|
|
1136
|
+
return m ? parseInt(m[1], 10) : -1;
|
|
1137
|
+
}
|
|
1138
|
+
var lengthenPathAction = inPlane({
|
|
1139
|
+
id: "lengthenPath",
|
|
1140
|
+
label: "Lengthen path",
|
|
1141
|
+
tags: pathOperationTags("drag"),
|
|
1142
|
+
defaultBinding: { kind: "drag", target: { kindOf: isAnchor }, mods: { shift: "optional" } },
|
|
1143
|
+
cursor: LENGTHEN_CURSOR,
|
|
1144
|
+
eligible: { capability: "edits-anchors" },
|
|
1145
|
+
requires: ["editAnchors", "view"],
|
|
1146
|
+
invoker: {
|
|
1147
|
+
timing: "ongoing",
|
|
1148
|
+
start(ctx, opts) {
|
|
1149
|
+
const edited = editedPath3(ctx.deps);
|
|
1150
|
+
if (!edited) return {};
|
|
1151
|
+
const { dep, path: originPath } = edited;
|
|
1152
|
+
const origin = pathToAnchors(originPath);
|
|
1153
|
+
const end = openEndAt(origin, pressedAnchor(ctx));
|
|
1154
|
+
const frame = end && pathEndFrame(origin, end);
|
|
1155
|
+
if (!end || !frame) return {};
|
|
1156
|
+
const id = dep.editingId;
|
|
1157
|
+
const press = ctx.drag?.start ?? ctx.world;
|
|
1158
|
+
let distance = 0;
|
|
1159
|
+
let current = null;
|
|
1160
|
+
const preview = pathEditPreview(dep, id, () => current?.path ?? null);
|
|
1161
|
+
if (!preview) return {};
|
|
1162
|
+
return {
|
|
1163
|
+
kind: "lengthen-path",
|
|
1164
|
+
onMove(moveCtx) {
|
|
1165
|
+
const mode = modeOf(opts, moveCtx.modifiers.shift);
|
|
1166
|
+
const toward2 = {
|
|
1167
|
+
x: frame.point.x + moveCtx.world.x - press.x,
|
|
1168
|
+
y: frame.point.y + moveCtx.world.y - press.y
|
|
1169
|
+
};
|
|
1170
|
+
distance = lengthToward(origin, end, mode, toward2, distance);
|
|
1171
|
+
current = editAnchorSetTracked(originPath, (set) => lengthenPathEnd(set, end, distance, mode));
|
|
1172
|
+
},
|
|
1173
|
+
onEnd(_endCtx, reason) {
|
|
1174
|
+
const done = current;
|
|
1175
|
+
current = null;
|
|
1176
|
+
if (reason === "cancel" || !done) return;
|
|
1177
|
+
dep.applyEdit(id, done.path, distance < 0 ? "Shorten path" : "Lengthen path", done.origins);
|
|
1178
|
+
const index = pathEndAnchorIndex(pathToAnchors(done.path), end);
|
|
1179
|
+
if (index !== -1) dep.setSelectedAnchors([index]);
|
|
1180
|
+
},
|
|
1181
|
+
previewIds: preview.previewIds,
|
|
1182
|
+
previewPose: preview.previewPose,
|
|
1183
|
+
previewData: preview.previewData
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
},
|
|
1187
|
+
enabled: (deps, at2) => {
|
|
1188
|
+
if (depOf(deps)?.tool !== LENGTHEN_TOOL) return ActionDisabledReason.NotApplicable;
|
|
1189
|
+
if (at2 && !endUnder(deps, at2.x, at2.y)) return ActionDisabledReason.NotApplicable;
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
}, editingLayer);
|
|
1193
|
+
var lengthenToolAction = pathEditToolAction(
|
|
1194
|
+
LENGTHEN_TOOL,
|
|
1195
|
+
"Lengthen",
|
|
1196
|
+
/* @icon */
|
|
1197
|
+
"path-tools:lengthen"
|
|
1198
|
+
);
|
|
1199
|
+
var PATH_DIRECTION_CHROME_ID = "path-edit.direction";
|
|
1200
|
+
var DIRECTION_DEFAULTS = { color: "#e8590c", arrowPx: 9, ringPx: 8 };
|
|
1201
|
+
function directionCommands(set, toScreen, style = {}) {
|
|
1202
|
+
const { color, arrowPx, ringPx } = { ...DIRECTION_DEFAULTS, ...style };
|
|
1203
|
+
const paint = { kind: "solid", color };
|
|
1204
|
+
const out = [];
|
|
1205
|
+
for (const m of directionMarks(set)) {
|
|
1206
|
+
const c = toScreen(m);
|
|
1207
|
+
const ahead = toScreen({ x: m.x + m.dx, y: m.y + m.dy });
|
|
1208
|
+
const k = Math.hypot(ahead.x - c.x, ahead.y - c.y);
|
|
1209
|
+
if (k === 0) continue;
|
|
1210
|
+
const ux = (ahead.x - c.x) / k, uy = (ahead.y - c.y) / k;
|
|
1211
|
+
const tip = { x: c.x + ux * arrowPx / 2, y: c.y + uy * arrowPx / 2 };
|
|
1212
|
+
const back = { x: c.x - ux * arrowPx / 2, y: c.y - uy * arrowPx / 2 };
|
|
1213
|
+
const half = arrowPx * 0.45;
|
|
1214
|
+
out.push({
|
|
1215
|
+
kind: "path",
|
|
1216
|
+
path: polygonFromPoints([tip, { x: back.x - uy * half, y: back.y + ux * half }, { x: back.x + uy * half, y: back.y - ux * half }]),
|
|
1217
|
+
fill: { paint }
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
for (const sub of set.anchors) {
|
|
1221
|
+
if (sub.length === 0) continue;
|
|
1222
|
+
const start = toScreen(sub[0]);
|
|
1223
|
+
out.push({ kind: "path", path: circlePath(start.x, start.y, ringPx), stroke: { paint, width: 2 } });
|
|
1224
|
+
}
|
|
1225
|
+
return out;
|
|
1226
|
+
}
|
|
1227
|
+
function createDirectionExtension(style = {}) {
|
|
1228
|
+
return {
|
|
1229
|
+
id: "pathDirection",
|
|
1230
|
+
marks: (ctx) => ctx.isVisible(PATH_DIRECTION_CHROME_ID) ? directionCommands(ctx.anchors, ctx.toScreen, style) : []
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
var START_LABEL = "Make start point";
|
|
1234
|
+
var REVERSE_LABEL = "Reverse path direction";
|
|
1235
|
+
function loneAnchor(deps) {
|
|
1236
|
+
const dep = depOf(deps);
|
|
1237
|
+
return dep && dep.selectedAnchors.size === 1 ? [...dep.selectedAnchors][0] : null;
|
|
1238
|
+
}
|
|
1239
|
+
var setPathStartAction = {
|
|
1240
|
+
id: "setPathStart",
|
|
1241
|
+
icon: (
|
|
1242
|
+
/* @icon */
|
|
1243
|
+
"path-tools:setPathStart"
|
|
1244
|
+
),
|
|
1245
|
+
label: START_LABEL,
|
|
1246
|
+
tags: pathOperationTags("command"),
|
|
1247
|
+
group: "anchors",
|
|
1248
|
+
eligible: { capability: "edits-anchors" },
|
|
1249
|
+
requires: ["editAnchors"],
|
|
1250
|
+
invoker: {
|
|
1251
|
+
timing: "immediate",
|
|
1252
|
+
run: (deps) => {
|
|
1253
|
+
const dep = depOf(deps);
|
|
1254
|
+
const flat = loneAnchor(deps);
|
|
1255
|
+
if (!dep || flat === null) return;
|
|
1256
|
+
let start = -1;
|
|
1257
|
+
const changed = commitAnchorEdit(dep, START_LABEL, (set) => {
|
|
1258
|
+
const loc = locateAnchor(set, flat);
|
|
1259
|
+
if (!loc) return false;
|
|
1260
|
+
start = flat - loc.idx;
|
|
1261
|
+
return setSubpathStart(set, flat);
|
|
1262
|
+
});
|
|
1263
|
+
if (changed) dep.setSelectedAnchors([start]);
|
|
1264
|
+
}
|
|
1265
|
+
},
|
|
1266
|
+
enabled: (deps) => {
|
|
1267
|
+
const flat = loneAnchor(deps);
|
|
1268
|
+
if (flat === null) return ActionDisabledReason.SelectionRequired;
|
|
1269
|
+
return anchorEditWouldChange(deps, (set) => setSubpathStart(set, flat)) ? true : ActionDisabledReason.NotApplicable;
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
function selectedPaths(deps) {
|
|
1273
|
+
const dep = deps?.editAnchors;
|
|
1274
|
+
if (!dep) return [];
|
|
1275
|
+
return (deps?.selection?.get() ?? []).flatMap((id) => {
|
|
1276
|
+
const path = dep.getEditablePath(id);
|
|
1277
|
+
return path?.kind === "polygon" ? [{ id, path }] : [];
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
function reverseInPathEdit(deps) {
|
|
1281
|
+
const dep = depOf(deps);
|
|
1282
|
+
if (!dep) return false;
|
|
1283
|
+
const selected = [...dep.selectedAnchors];
|
|
1284
|
+
let after = [];
|
|
1285
|
+
const changed = commitAnchorEdit(dep, REVERSE_LABEL, (set) => {
|
|
1286
|
+
const subs = selected.length > 0 ? subpathsOfAnchors(set, selected) : void 0;
|
|
1287
|
+
after = flatIndexesAfterReverse(set, selected, subs);
|
|
1288
|
+
return reverseSubpaths(set, subs);
|
|
1289
|
+
});
|
|
1290
|
+
if (changed) dep.setSelectedAnchors(after);
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
var reversePathAction = {
|
|
1294
|
+
id: "reversePath",
|
|
1295
|
+
icon: (
|
|
1296
|
+
/* @icon */
|
|
1297
|
+
"path-tools:reversePath"
|
|
1298
|
+
),
|
|
1299
|
+
label: REVERSE_LABEL,
|
|
1300
|
+
tags: pathOperationTags("command"),
|
|
1301
|
+
group: "path",
|
|
1302
|
+
requires: ["editAnchors", "selection", "scene", "applyOps"],
|
|
1303
|
+
invoker: {
|
|
1304
|
+
timing: "immediate",
|
|
1305
|
+
run: (deps) => {
|
|
1306
|
+
if (reverseInPathEdit(deps)) return;
|
|
1307
|
+
const edit = deps.editAnchors;
|
|
1308
|
+
if (!edit?.editOps) return;
|
|
1309
|
+
const ops = [];
|
|
1310
|
+
for (const { id, path } of selectedPaths(deps)) {
|
|
1311
|
+
const next = editAnchorSetTracked(path, (set) => reverseSubpaths(set));
|
|
1312
|
+
if (next) ops.push(...edit.editOps(id, next.path, REVERSE_LABEL, next.origins));
|
|
1313
|
+
}
|
|
1314
|
+
commitOps(deps, ops, REVERSE_LABEL);
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1317
|
+
enabled: (deps) => {
|
|
1318
|
+
if (depOf(deps)) return true;
|
|
1319
|
+
return selectedPaths(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired;
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
var LABEL = "Convert segments";
|
|
1323
|
+
var typeOf = (params) => params?.to === "line" || params?.to === "curve" ? params.to : void 0;
|
|
1324
|
+
var convertSegmentsAction = {
|
|
1325
|
+
id: "convertSegments",
|
|
1326
|
+
icon: (
|
|
1327
|
+
/* @icon */
|
|
1328
|
+
"path-tools:convertSegment"
|
|
1329
|
+
),
|
|
1330
|
+
label: LABEL,
|
|
1331
|
+
tags: pathOperationTags("command"),
|
|
1332
|
+
group: "anchors",
|
|
1333
|
+
variants: [
|
|
1334
|
+
{ key: "line", label: "Make segments straight", icon: (
|
|
1335
|
+
/* @icon */
|
|
1336
|
+
"path-tools:straightenSegment"
|
|
1337
|
+
), params: { to: "line" } },
|
|
1338
|
+
{ key: "curve", label: "Make segments curved", icon: (
|
|
1339
|
+
/* @icon */
|
|
1340
|
+
"path-tools:curveSegment"
|
|
1341
|
+
), params: { to: "curve" } }
|
|
1342
|
+
],
|
|
1343
|
+
defaultBinding: { kind: "key", key: ["c", "C"], mods: { shift: true } },
|
|
1344
|
+
eligible: { capability: "edits-anchors" },
|
|
1345
|
+
requires: ["editAnchors"],
|
|
1346
|
+
invoker: {
|
|
1347
|
+
timing: "immediate",
|
|
1348
|
+
run: (deps, params) => {
|
|
1349
|
+
const dep = depOf(deps);
|
|
1350
|
+
if (!dep) return;
|
|
1351
|
+
const selected = [...dep.selectedAnchors];
|
|
1352
|
+
commitAnchorEdit(dep, LABEL, (set) => convertSegments(set, segmentsBetweenAnchors(set, selected), typeOf(params)));
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
enabled: (deps) => {
|
|
1356
|
+
const selected = [...depOf(deps)?.selectedAnchors ?? []];
|
|
1357
|
+
const any = selected.length > 1 && anchorEditWouldChange(deps, (set) => convertSegments(set, segmentsBetweenAnchors(set, selected)));
|
|
1358
|
+
return any ? true : ActionDisabledReason.SelectionRequired;
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
var convertSegmentAtAction = inPlane({
|
|
1362
|
+
id: "convertSegmentAt",
|
|
1363
|
+
icon: (
|
|
1364
|
+
/* @icon */
|
|
1365
|
+
"path-tools:convertSegment"
|
|
1366
|
+
),
|
|
1367
|
+
label: "Convert segment",
|
|
1368
|
+
tags: pathOperationTags("click"),
|
|
1369
|
+
defaultBinding: { kind: "doubleClick", mods: { shift: true } },
|
|
1370
|
+
eligible: { capability: "edits-anchors" },
|
|
1371
|
+
requires: ["editAnchors", "view", "kitSettings"],
|
|
1372
|
+
invoker: {
|
|
1373
|
+
timing: "immediate",
|
|
1374
|
+
run(deps, params) {
|
|
1375
|
+
const wx = params?.worldX;
|
|
1376
|
+
const wy = params?.worldY;
|
|
1377
|
+
if (typeof wx !== "number" || typeof wy !== "number") return;
|
|
1378
|
+
const under = segmentUnder(deps, wx, wy);
|
|
1379
|
+
if (!under) return;
|
|
1380
|
+
const { dep, path, hit } = under;
|
|
1381
|
+
const next = editAnchorSetTracked(path, (set) => convertSegments(set, [hit], typeOf(params)));
|
|
1382
|
+
if (next) dep.applyEdit(dep.editingId, next.path, "Convert segment", next.origins);
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
enabled: (deps, at2) => {
|
|
1386
|
+
if (!deps?.editAnchors?.editingId) return ActionDisabledReason.NotApplicable;
|
|
1387
|
+
if (at2 && !segmentUnder(deps, at2.x, at2.y)) return ActionDisabledReason.NotApplicable;
|
|
1388
|
+
return true;
|
|
1389
|
+
}
|
|
1390
|
+
}, editingLayer);
|
|
1391
|
+
var ANCHOR_SELECTION_GROUP = "anchorSelection";
|
|
1392
|
+
function editedSet(dep) {
|
|
1393
|
+
const path = dep.getEditablePath(dep.editingId);
|
|
1394
|
+
return path && path.kind === "polygon" ? pathToAnchors(path) : null;
|
|
1395
|
+
}
|
|
1396
|
+
var pathOpen = (deps) => depOf(deps) ? true : ActionDisabledReason.SelectionRequired;
|
|
1397
|
+
function makeSelectCommand(id, label, key, needsSelection, pick) {
|
|
1398
|
+
const enabled = needsSelection ? requiresAnchorSelection : pathOpen;
|
|
1399
|
+
return {
|
|
1400
|
+
id,
|
|
1401
|
+
label,
|
|
1402
|
+
group: ANCHOR_SELECTION_GROUP,
|
|
1403
|
+
tags: pathOperationTags("command"),
|
|
1404
|
+
defaultBinding: key,
|
|
1405
|
+
eligible: { capability: "edits-anchors" },
|
|
1406
|
+
requires: ["editAnchors"],
|
|
1407
|
+
invoker: {
|
|
1408
|
+
timing: "immediate",
|
|
1409
|
+
run: (deps, params) => {
|
|
1410
|
+
const dep = depOf(deps);
|
|
1411
|
+
const set = dep && enabled(deps) === true ? editedSet(dep) : null;
|
|
1412
|
+
if (set) dep.setSelectedAnchors(pick(set, [...dep.selectedAnchors], params));
|
|
1413
|
+
}
|
|
1414
|
+
},
|
|
1415
|
+
enabled
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
var altKey = (key, code) => ({ kind: "key", key, code, mods: { alt: true } });
|
|
1419
|
+
var stepOf = (params) => {
|
|
1420
|
+
const n = params?.n;
|
|
1421
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 1 ? Math.floor(n) : 2;
|
|
1422
|
+
};
|
|
1423
|
+
var growAnchorSelectionAction = makeSelectCommand(
|
|
1424
|
+
"growAnchorSelection",
|
|
1425
|
+
"Grow anchor selection",
|
|
1426
|
+
{ kind: "key", key: "]", code: "BracketRight" },
|
|
1427
|
+
true,
|
|
1428
|
+
(set, selected) => growAnchorSelection(set, selected)
|
|
1429
|
+
);
|
|
1430
|
+
var shrinkAnchorSelectionAction = makeSelectCommand(
|
|
1431
|
+
"shrinkAnchorSelection",
|
|
1432
|
+
"Shrink anchor selection",
|
|
1433
|
+
{ kind: "key", key: "[", code: "BracketLeft" },
|
|
1434
|
+
true,
|
|
1435
|
+
(set, selected) => shrinkAnchorSelection(set, selected)
|
|
1436
|
+
);
|
|
1437
|
+
var selectEveryNthAnchorAction = makeSelectCommand(
|
|
1438
|
+
"selectEveryNthAnchor",
|
|
1439
|
+
"Select every Nth anchor",
|
|
1440
|
+
altKey("n", "KeyN"),
|
|
1441
|
+
false,
|
|
1442
|
+
(set, selected, params) => everyNthAnchor(set, stepOf(params), selected)
|
|
1443
|
+
);
|
|
1444
|
+
var selectCornerAnchorsAction = makeSelectCommand(
|
|
1445
|
+
"selectCornerAnchors",
|
|
1446
|
+
"Select corner anchors",
|
|
1447
|
+
altKey("c", "KeyC"),
|
|
1448
|
+
false,
|
|
1449
|
+
(set) => cornerAnchors(set)
|
|
1450
|
+
);
|
|
1451
|
+
var selectSmoothAnchorsAction = makeSelectCommand(
|
|
1452
|
+
"selectSmoothAnchors",
|
|
1453
|
+
"Select smooth anchors",
|
|
1454
|
+
altKey("m", "KeyM"),
|
|
1455
|
+
false,
|
|
1456
|
+
(set) => smoothAnchors(set)
|
|
1457
|
+
);
|
|
1458
|
+
var invertAnchorSelectionAction = makeSelectCommand(
|
|
1459
|
+
"invertAnchorSelection",
|
|
1460
|
+
"Invert anchor selection",
|
|
1461
|
+
altKey("i", "KeyI"),
|
|
1462
|
+
false,
|
|
1463
|
+
(set, selected) => invertAnchorSelection(set, selected)
|
|
1464
|
+
);
|
|
1465
|
+
var LABEL2 = "Add extremes";
|
|
1466
|
+
function selectedSegments(set, selected) {
|
|
1467
|
+
if (selected.size === 0) return void 0;
|
|
1468
|
+
const picked = /* @__PURE__ */ new Set();
|
|
1469
|
+
for (const flat of selected) {
|
|
1470
|
+
const anchor = anchorAt(set, flat);
|
|
1471
|
+
if (anchor) picked.add(anchor);
|
|
1472
|
+
}
|
|
1473
|
+
return (sub, segIdx) => {
|
|
1474
|
+
const ends = segmentEnds(set.anchors, set.closed, sub, segIdx);
|
|
1475
|
+
return ends !== null && picked.has(ends.a) && picked.has(ends.b);
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
function addExtremesTo(set, selected) {
|
|
1479
|
+
const kept = [...selected].flatMap((flat) => anchorAt(set, flat) ?? []);
|
|
1480
|
+
const added = addExtremaAnchors(set, selectedSegments(set, selected));
|
|
1481
|
+
if (added.length === 0) return null;
|
|
1482
|
+
const flatOf = new Map(set.anchors.flat().map((a, k) => [a, k]));
|
|
1483
|
+
return [...kept.map((a) => flatOf.get(a)), ...added];
|
|
1484
|
+
}
|
|
1485
|
+
var addExtremeAnchorsAction = {
|
|
1486
|
+
id: "addExtremeAnchors",
|
|
1487
|
+
icon: (
|
|
1488
|
+
/* @icon */
|
|
1489
|
+
"path-tools:extremeAnchors"
|
|
1490
|
+
),
|
|
1491
|
+
label: LABEL2,
|
|
1492
|
+
group: "anchors",
|
|
1493
|
+
tags: pathOperationTags("command"),
|
|
1494
|
+
// `code` too: with Alt held on a Mac the key reads as another glyph.
|
|
1495
|
+
defaultBinding: { kind: "key", key: "x", code: "KeyX", mods: { alt: true } },
|
|
1496
|
+
eligible: { capability: "edits-anchors" },
|
|
1497
|
+
requires: ["editAnchors"],
|
|
1498
|
+
invoker: {
|
|
1499
|
+
timing: "immediate",
|
|
1500
|
+
run: (deps) => {
|
|
1501
|
+
const dep = depOf(deps);
|
|
1502
|
+
if (!dep) return;
|
|
1503
|
+
let next = null;
|
|
1504
|
+
commitAnchorEdit(dep, LABEL2, (set) => (next = addExtremesTo(set, dep.selectedAnchors)) !== null);
|
|
1505
|
+
if (next) dep.setSelectedAnchors(next);
|
|
1506
|
+
}
|
|
1507
|
+
},
|
|
1508
|
+
enabled: (deps) => {
|
|
1509
|
+
const dep = depOf(deps);
|
|
1510
|
+
if (!dep) return ActionDisabledReason.SelectionRequired;
|
|
1511
|
+
return anchorEditWouldChange(deps, (set) => addExtremesTo(set, dep.selectedAnchors) !== null) ? true : ActionDisabledReason.NotApplicable;
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
var LABEL3 = "Resample";
|
|
1515
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
1516
|
+
function resampleOptions(count2, params) {
|
|
1517
|
+
const spacing = num(params.spacing);
|
|
1518
|
+
const tolerance = num(params.tolerance);
|
|
1519
|
+
return {
|
|
1520
|
+
...spacing !== void 0 && spacing > 0 ? { spacing } : count2 > 0 ? { count: count2 } : {},
|
|
1521
|
+
...tolerance !== void 0 ? { tolerance } : {},
|
|
1522
|
+
keepCorners: params.keepCorners !== false
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
var resamplePathAction = livePathEdit({
|
|
1526
|
+
id: "resamplePath",
|
|
1527
|
+
label: LABEL3,
|
|
1528
|
+
param: "count",
|
|
1529
|
+
initial: 0,
|
|
1530
|
+
edit: ({ path }, count2, params) => resamplePath(path, resampleOptions(count2, params)),
|
|
1531
|
+
commit: (_deps, next) => [...next.values()].flatMap(({ node, path }) => reshapeOps(node.node, path, LABEL3))
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
// src/pathEffects/params.ts
|
|
1535
|
+
var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
1536
|
+
var bool = (v) => typeof v === "boolean" ? v : void 0;
|
|
1537
|
+
var oneOf = (v, options) => typeof v === "string" && options.includes(v) ? v : void 0;
|
|
1538
|
+
function defined(fields) {
|
|
1539
|
+
return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== void 0));
|
|
1540
|
+
}
|
|
1541
|
+
function effectSettings(kind, params) {
|
|
1542
|
+
const p = params ?? {};
|
|
1543
|
+
switch (kind) {
|
|
1544
|
+
case "blend":
|
|
1545
|
+
return defined({
|
|
1546
|
+
steps: num2(p.steps) === void 0 ? void 0 : Math.max(Math.round(num2(p.steps)), 0),
|
|
1547
|
+
autoAlign: bool(p.autoAlign),
|
|
1548
|
+
startOffset: num2(p.startOffset) === void 0 ? void 0 : Math.round(num2(p.startOffset)),
|
|
1549
|
+
reverse: bool(p.reverse),
|
|
1550
|
+
rotate: bool(p.rotate),
|
|
1551
|
+
spaceBy: oneOf(p.spaceBy, ["count", "distance"]),
|
|
1552
|
+
distance: num2(p.distance) === void 0 ? void 0 : Math.max(num2(p.distance), 0.01),
|
|
1553
|
+
orient: bool(p.orient)
|
|
1554
|
+
});
|
|
1555
|
+
case "bendAlong":
|
|
1556
|
+
return defined({
|
|
1557
|
+
fit: oneOf(p.fit, ["stretch", "natural"]),
|
|
1558
|
+
baseline: num2(p.baseline),
|
|
1559
|
+
flip: bool(p.flip),
|
|
1560
|
+
tolerance: num2(p.tolerance) === void 0 ? void 0 : Math.max(num2(p.tolerance), 0.01)
|
|
1561
|
+
});
|
|
1562
|
+
case "repeatAlong":
|
|
1563
|
+
return defined({
|
|
1564
|
+
count: num2(p.count) === void 0 ? void 0 : Math.max(Math.round(num2(p.count)), 0),
|
|
1565
|
+
spacing: num2(p.spacing) === void 0 ? void 0 : Math.max(num2(p.spacing), 0),
|
|
1566
|
+
align: oneOf(p.align, ["start", "center", "end"]),
|
|
1567
|
+
offset: num2(p.offset),
|
|
1568
|
+
orient: bool(p.orient)
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function withEffectSettings(effect, params) {
|
|
1573
|
+
const next = { ...effect, ...effectSettings(effect.kind, params) };
|
|
1574
|
+
if (next.kind === "blend" && params?.reverse === "auto") delete next.reverse;
|
|
1575
|
+
return next;
|
|
1576
|
+
}
|
|
1577
|
+
var EFFECT_DEFAULTS = {
|
|
1578
|
+
blend: { kind: "blend", steps: 4 },
|
|
1579
|
+
bendAlong: { kind: "bendAlong" },
|
|
1580
|
+
repeatAlong: { kind: "repeatAlong" }
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
// src/pathEffects/actions.ts
|
|
1584
|
+
var PATH_EFFECTS_GROUP = "pathEffects";
|
|
1585
|
+
function selectedLeaves(deps) {
|
|
1586
|
+
const scene = deps?.scene;
|
|
1587
|
+
if (!scene || registeredPathEffects(scene.registry) === null) return null;
|
|
1588
|
+
const leaves = (deps?.selection?.get() ?? []).map((id) => scene.get(id)).filter((n) => n !== void 0 && n.kind === "leaf");
|
|
1589
|
+
return leaves.length >= 2 ? leaves : null;
|
|
1590
|
+
}
|
|
1591
|
+
function selectedEffects(deps, ids) {
|
|
1592
|
+
const scene = deps?.scene;
|
|
1593
|
+
if (!scene) return [];
|
|
1594
|
+
return (ids ?? deps?.selection?.get() ?? []).map((id) => scene.get(id)).filter((n) => n !== void 0 && pathEffectOf(n) !== null);
|
|
1595
|
+
}
|
|
1596
|
+
function createEffectAction(spec) {
|
|
1597
|
+
return {
|
|
1598
|
+
id: spec.id,
|
|
1599
|
+
label: spec.label,
|
|
1600
|
+
icon: spec.icon,
|
|
1601
|
+
tags: pathOperationTags("command"),
|
|
1602
|
+
group: PATH_EFFECTS_GROUP,
|
|
1603
|
+
// Alt+Shift, which no major browser takes for these letters (see
|
|
1604
|
+
// `BROWSER_RESERVED_CHORDS`); `code` too, since with Alt held on a Mac the
|
|
1605
|
+
// key reads as another glyph.
|
|
1606
|
+
defaultBinding: { kind: "key", key: spec.key, code: spec.code, mods: { alt: true, shift: true } },
|
|
1607
|
+
eligible: { capability: "creates-shapes" },
|
|
1608
|
+
requires: ["selection", "scene", "applyOps"],
|
|
1609
|
+
invoker: {
|
|
1610
|
+
timing: "immediate",
|
|
1611
|
+
run: (deps, params) => {
|
|
1612
|
+
const scene = deps.scene;
|
|
1613
|
+
const leaves = selectedLeaves(deps);
|
|
1614
|
+
if (!scene || !leaves) return;
|
|
1615
|
+
const derivations = registeredPathEffects(scene.registry);
|
|
1616
|
+
const [a, b, c] = leaves;
|
|
1617
|
+
const sources = spec.third && c ? [a.id, b.id, c.id] : [a.id, b.id];
|
|
1618
|
+
const effect = withEffectSettings(EFFECT_DEFAULTS[spec.kind], params);
|
|
1619
|
+
const sameParent = a.parent === b.parent;
|
|
1620
|
+
const slots = [slotOf(scene, a.id), slotOf(scene, b.id)];
|
|
1621
|
+
const index = !sameParent ? -1 : spec.between ? Math.max(...slots) : Math.max(...slots) + 1;
|
|
1622
|
+
const node = {
|
|
1623
|
+
...pathEffectNode(effect, sources, a.layer, derivations),
|
|
1624
|
+
id: scene.mintId(),
|
|
1625
|
+
parent: sameParent ? a.parent : null
|
|
1626
|
+
};
|
|
1627
|
+
commitOps(deps, [insertAt(node, index, spec.label)], spec.label);
|
|
1628
|
+
deps.selection?.set([node.id]);
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
enabled: (deps) => {
|
|
1632
|
+
const scene = deps?.scene;
|
|
1633
|
+
if (scene && registeredPathEffects(scene.registry) === null) return ActionDisabledReason.NotApplicable;
|
|
1634
|
+
return selectedLeaves(deps) ? true : ActionDisabledReason.SelectionRequired;
|
|
1635
|
+
}
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
var blendAction = createEffectAction({
|
|
1639
|
+
id: "blend",
|
|
1640
|
+
label: "Blend",
|
|
1641
|
+
icon: (
|
|
1642
|
+
/* @icon */
|
|
1643
|
+
"path-tools:blend"
|
|
1644
|
+
),
|
|
1645
|
+
kind: "blend",
|
|
1646
|
+
key: "b",
|
|
1647
|
+
code: "KeyB",
|
|
1648
|
+
between: true,
|
|
1649
|
+
third: true
|
|
1650
|
+
});
|
|
1651
|
+
var bendAlongAction = createEffectAction({
|
|
1652
|
+
id: "bendAlong",
|
|
1653
|
+
label: "Bend Along Path",
|
|
1654
|
+
icon: (
|
|
1655
|
+
/* @icon */
|
|
1656
|
+
"path-tools:bendAlong"
|
|
1657
|
+
),
|
|
1658
|
+
kind: "bendAlong",
|
|
1659
|
+
key: "w",
|
|
1660
|
+
code: "KeyW",
|
|
1661
|
+
between: false
|
|
1662
|
+
});
|
|
1663
|
+
var repeatAlongAction = createEffectAction({
|
|
1664
|
+
id: "repeatAlong",
|
|
1665
|
+
label: "Repeat Along Path",
|
|
1666
|
+
icon: (
|
|
1667
|
+
/* @icon */
|
|
1668
|
+
"path-tools:repeatAlong"
|
|
1669
|
+
),
|
|
1670
|
+
kind: "repeatAlong",
|
|
1671
|
+
key: "r",
|
|
1672
|
+
code: "KeyR",
|
|
1673
|
+
between: false
|
|
1674
|
+
});
|
|
1675
|
+
var EDIT_LABEL = "Edit Path Effect";
|
|
1676
|
+
var editPathEffectAction = {
|
|
1677
|
+
id: "editPathEffect",
|
|
1678
|
+
label: EDIT_LABEL,
|
|
1679
|
+
icon: (
|
|
1680
|
+
/* @icon */
|
|
1681
|
+
"core:tune"
|
|
1682
|
+
),
|
|
1683
|
+
requires: ["scene", "applyOps"],
|
|
1684
|
+
invoker: {
|
|
1685
|
+
timing: "immediate",
|
|
1686
|
+
run: (deps, params) => {
|
|
1687
|
+
const p = params;
|
|
1688
|
+
const ids = Array.isArray(p?.ids) ? p.ids : void 0;
|
|
1689
|
+
const ops = [];
|
|
1690
|
+
for (const node of selectedEffects(deps, ids)) {
|
|
1691
|
+
const effect = pathEffectOf(node);
|
|
1692
|
+
const next = withEffectSettings(effect, p);
|
|
1693
|
+
if (sameEffect(effect, next)) continue;
|
|
1694
|
+
ops.push(createSetDataOp({
|
|
1695
|
+
id: node.id,
|
|
1696
|
+
from: node.data,
|
|
1697
|
+
to: { ...node.data, pathEffect: next },
|
|
1698
|
+
label: EDIT_LABEL,
|
|
1699
|
+
coalesceKey: `${EDIT_LABEL}:${node.id}`
|
|
1700
|
+
}));
|
|
1701
|
+
}
|
|
1702
|
+
commitOps(deps, ops, EDIT_LABEL);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
function sameEffect(a, b) {
|
|
1707
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
1708
|
+
for (const k of keys) {
|
|
1709
|
+
if (a[k] !== b[k]) return false;
|
|
1710
|
+
}
|
|
1711
|
+
return true;
|
|
1712
|
+
}
|
|
1713
|
+
var EXPAND_LABEL = "Expand to Paths";
|
|
1714
|
+
var expandPathEffectAction = {
|
|
1715
|
+
id: "expandPathEffect",
|
|
1716
|
+
label: EXPAND_LABEL,
|
|
1717
|
+
icon: (
|
|
1718
|
+
/* @icon */
|
|
1719
|
+
"path-tools:expand"
|
|
1720
|
+
),
|
|
1721
|
+
group: PATH_EFFECTS_GROUP,
|
|
1722
|
+
defaultBinding: { kind: "key", key: "e", code: "KeyE", mods: { alt: true, shift: true } },
|
|
1723
|
+
eligible: { capability: "creates-shapes" },
|
|
1724
|
+
requires: ["selection", "scene", "applyOps"],
|
|
1725
|
+
invoker: {
|
|
1726
|
+
timing: "immediate",
|
|
1727
|
+
run: (deps) => {
|
|
1728
|
+
const scene = deps.scene;
|
|
1729
|
+
if (!scene) return;
|
|
1730
|
+
const effects = selectedEffects(deps).map((node) => ({ node, at: slotOf(scene, node.id) })).sort((x, y) => y.at - x.at);
|
|
1731
|
+
const ops = [];
|
|
1732
|
+
const made = [];
|
|
1733
|
+
for (const { node, at: at2 } of effects) {
|
|
1734
|
+
const sources = (Array.isArray(node.dependsOn) ? node.dependsOn : []).map((id) => derivedDepOf(scene, id, node));
|
|
1735
|
+
const parts = pathEffectParts(node, sources) ?? [];
|
|
1736
|
+
ops.push(deleteNode(scene, node, EXPAND_LABEL));
|
|
1737
|
+
parts.forEach((part, k) => {
|
|
1738
|
+
const { pose, path } = worldEditToStorage({ x: 0, y: 0, width: 0, height: 0 }, part.path);
|
|
1739
|
+
const data = {
|
|
1740
|
+
path,
|
|
1741
|
+
...part.fill === void 0 ? {} : { fill: part.fill },
|
|
1742
|
+
...part.stroke === void 0 ? {} : { stroke: part.stroke }
|
|
1743
|
+
};
|
|
1744
|
+
const copy = { id: scene.mintId(), kind: "leaf", layer: node.layer, parent: node.parent, pose, data };
|
|
1745
|
+
ops.push(insertAt(copy, at2 < 0 ? at2 : at2 + k, EXPAND_LABEL));
|
|
1746
|
+
made.push(copy.id);
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
if (ops.length === 0) return;
|
|
1750
|
+
commitOps(deps, ops, EXPAND_LABEL);
|
|
1751
|
+
deps.selection?.set(made);
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
enabled: (deps) => selectedEffects(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
1755
|
+
};
|
|
1756
|
+
var SIMPLIFY_BRUSH_TOOL = "simplifyBrush";
|
|
1757
|
+
var SIMPLIFY_BRUSH_RADIUS_PX = 12;
|
|
1758
|
+
function brushRingCursor(radiusPx) {
|
|
1759
|
+
return { glyph: "brush", size: Math.round(radiusPx * 24 / 9.9), fallback: "crosshair" };
|
|
1760
|
+
}
|
|
1761
|
+
var brushing = (deps) => depOf(deps)?.tool === SIMPLIFY_BRUSH_TOOL;
|
|
1762
|
+
function radiusOf(params) {
|
|
1763
|
+
const r = params?.radiusPx;
|
|
1764
|
+
return typeof r === "number" && r > 0 ? r : SIMPLIFY_BRUSH_RADIUS_PX;
|
|
1765
|
+
}
|
|
1766
|
+
function toleranceOf(params) {
|
|
1767
|
+
const t = params?.tolerance;
|
|
1768
|
+
return typeof t === "number" && t >= 0 ? t : DEFAULT_SIMPLIFY_TOLERANCE;
|
|
1769
|
+
}
|
|
1770
|
+
var simplifyBrushAction = inPlane({
|
|
1771
|
+
id: "simplifyBrush",
|
|
1772
|
+
label: "Simplify brush",
|
|
1773
|
+
tags: pathOperationTags("brush"),
|
|
1774
|
+
// Declines through `enabled` unless its tool is on.
|
|
1775
|
+
defaultBinding: [{ spec: { kind: "drag" }, opts: { yields: true } }],
|
|
1776
|
+
cursor: (params) => brushRingCursor(radiusOf(params)),
|
|
1777
|
+
eligible: { capability: "edits-anchors" },
|
|
1778
|
+
requires: ["editAnchors", "view"],
|
|
1779
|
+
invoker: {
|
|
1780
|
+
timing: "ongoing",
|
|
1781
|
+
start(ctx, opts) {
|
|
1782
|
+
const dep = depOf(ctx.deps);
|
|
1783
|
+
if (!dep || dep.tool !== SIMPLIFY_BRUSH_TOOL) return {};
|
|
1784
|
+
const id = dep.editingId;
|
|
1785
|
+
const origin = dep.getEditablePath(id);
|
|
1786
|
+
if (!origin || origin.kind !== "polygon") return {};
|
|
1787
|
+
const originSet = pathToAnchors(origin);
|
|
1788
|
+
const params = () => resolveParams(opts?.params);
|
|
1789
|
+
const radiusPx = () => radiusOf(params());
|
|
1790
|
+
const tolerance = () => toleranceOf(params());
|
|
1791
|
+
const scale = () => ctx.deps.view?.get().scale;
|
|
1792
|
+
const trail = [];
|
|
1793
|
+
const gathered = /* @__PURE__ */ new Set();
|
|
1794
|
+
let current = null;
|
|
1795
|
+
const sweepTo = (p) => {
|
|
1796
|
+
const from = trail[trail.length - 1] ?? p;
|
|
1797
|
+
trail.push({ x: p.x, y: p.y });
|
|
1798
|
+
const before = gathered.size;
|
|
1799
|
+
for (const i of anchorsUnderBrush(originSet, from, p, { radiusPx: radiusPx(), scale: scale() })) {
|
|
1800
|
+
gathered.add(i);
|
|
1801
|
+
}
|
|
1802
|
+
if (gathered.size === before) return;
|
|
1803
|
+
current = editAnchorSetTracked(origin, (set) => simplifyAnchorsAt(set, gathered, tolerance()));
|
|
1804
|
+
};
|
|
1805
|
+
const preview = pathEditPreview(dep, id, () => current?.path ?? null);
|
|
1806
|
+
if (!preview) return {};
|
|
1807
|
+
sweepTo(ctx.world);
|
|
1808
|
+
return {
|
|
1809
|
+
kind: "simplify-brush",
|
|
1810
|
+
cursor: () => brushRingCursor(radiusPx()),
|
|
1811
|
+
onMove(moveCtx) {
|
|
1812
|
+
sweepTo(moveCtx.world);
|
|
1813
|
+
},
|
|
1814
|
+
onEnd(_endCtx, reason) {
|
|
1815
|
+
if (reason === "cancel") {
|
|
1816
|
+
current = null;
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const done = gathered.size > 0 ? editAnchorSetTracked(origin, (set) => simplifyAnchorsAt(set, gathered, tolerance())) : null;
|
|
1820
|
+
current = null;
|
|
1821
|
+
if (!done) return;
|
|
1822
|
+
dep.applyEdit(id, done.path, "Simplify brush", done.origins);
|
|
1823
|
+
dep.setSelectedAnchors([]);
|
|
1824
|
+
},
|
|
1825
|
+
overlay: () => ({ kind: "brush", points: trail.slice(), radiusPx: radiusPx() }),
|
|
1826
|
+
previewIds: preview.previewIds,
|
|
1827
|
+
previewPose: preview.previewPose,
|
|
1828
|
+
previewData: preview.previewData
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
},
|
|
1832
|
+
enabled: (deps) => brushing(deps) ? true : ActionDisabledReason.NotApplicable
|
|
1833
|
+
}, editingLayer);
|
|
1834
|
+
var simplifyBrushToolAction = pathEditToolAction(
|
|
1835
|
+
SIMPLIFY_BRUSH_TOOL,
|
|
1836
|
+
"Simplify brush",
|
|
1837
|
+
/* @icon */
|
|
1838
|
+
"core:simplifyBrush"
|
|
1839
|
+
);
|
|
1840
|
+
var DEFAULT_SMUDGE_RADIUS_PX = 40;
|
|
1841
|
+
var DEFAULT_SMUDGE_STRENGTH = 0.6;
|
|
1842
|
+
var LABEL4 = "Smudge brush";
|
|
1843
|
+
var num3 = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
1844
|
+
function radiusOf2(params) {
|
|
1845
|
+
return Math.max(1, num3(params?.radiusPx, DEFAULT_SMUDGE_RADIUS_PX));
|
|
1846
|
+
}
|
|
1847
|
+
function strengthOf(params) {
|
|
1848
|
+
return Math.min(1, Math.max(0, num3(params?.strength, DEFAULT_SMUDGE_STRENGTH)));
|
|
1849
|
+
}
|
|
1850
|
+
function worldRadius(deps, radiusPx) {
|
|
1851
|
+
const reach = pxExtent(radiusPx, deps.view?.get().scale ?? { x: 1, y: 1 });
|
|
1852
|
+
return Math.max(reach.x, reach.y);
|
|
1853
|
+
}
|
|
1854
|
+
var smudgeBrushAction = {
|
|
1855
|
+
id: "smudgeBrush",
|
|
1856
|
+
label: LABEL4,
|
|
1857
|
+
icon: (
|
|
1858
|
+
/* @icon */
|
|
1859
|
+
"path-tools:smudgeBrush"
|
|
1860
|
+
),
|
|
1861
|
+
tags: pathOperationTags("brush"),
|
|
1862
|
+
group: "path",
|
|
1863
|
+
eligible: { capability: "creates-shapes" },
|
|
1864
|
+
cursor: (params) => brushRingCursor(radiusOf2(params)),
|
|
1865
|
+
requires: ["selection", "scene", "view", "applyOps"],
|
|
1866
|
+
invoker: {
|
|
1867
|
+
timing: "ongoing",
|
|
1868
|
+
start(ctx, opts) {
|
|
1869
|
+
const nodes = selectedPathNodes(ctx.deps);
|
|
1870
|
+
if (nodes.length === 0) return {};
|
|
1871
|
+
const { deps } = ctx;
|
|
1872
|
+
const params = (move) => ({ ...resolveParams(opts?.params), ...move.params });
|
|
1873
|
+
let radiusPx = radiusOf2(params(ctx));
|
|
1874
|
+
let at2 = { x: ctx.drag?.start.x ?? ctx.world.x, y: ctx.drag?.start.y ?? ctx.world.y };
|
|
1875
|
+
const current = /* @__PURE__ */ new Map();
|
|
1876
|
+
const shapeOf2 = (id) => {
|
|
1877
|
+
const entry = current.get(id);
|
|
1878
|
+
return entry ? withPath(entry.node.node, entry.path) : null;
|
|
1879
|
+
};
|
|
1880
|
+
return {
|
|
1881
|
+
kind: "smudge-brush",
|
|
1882
|
+
cursor: () => brushRingCursor(radiusPx),
|
|
1883
|
+
onMove(move) {
|
|
1884
|
+
const p = params(move);
|
|
1885
|
+
radiusPx = radiusOf2(p);
|
|
1886
|
+
const to = { x: move.world.x, y: move.world.y };
|
|
1887
|
+
const radius = worldRadius(deps, radiusPx);
|
|
1888
|
+
for (const node of nodes) {
|
|
1889
|
+
const from = current.get(node.id)?.path ?? node.path;
|
|
1890
|
+
const next = smudgePath(from, {
|
|
1891
|
+
center: at2,
|
|
1892
|
+
delta: { x: to.x - at2.x, y: to.y - at2.y },
|
|
1893
|
+
radius,
|
|
1894
|
+
strength: strengthOf(p)
|
|
1895
|
+
});
|
|
1896
|
+
if (next !== from) current.set(node.id, { node, path: next });
|
|
1897
|
+
}
|
|
1898
|
+
at2 = to;
|
|
1899
|
+
},
|
|
1900
|
+
onEnd(_end, reason) {
|
|
1901
|
+
const done = [...current.values()];
|
|
1902
|
+
current.clear();
|
|
1903
|
+
if (reason === "cancel" || done.length === 0) return;
|
|
1904
|
+
commitOps(deps, done.flatMap(({ node, path }) => reshapeOps(node.node, path, LABEL4)), LABEL4);
|
|
1905
|
+
},
|
|
1906
|
+
previewIds: () => current.size > 0 ? [...current.keys()] : null,
|
|
1907
|
+
previewPose: (id) => shapeOf2(id)?.pose ?? null,
|
|
1908
|
+
previewData: (id) => shapeOf2(id)?.data ?? null
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
enabled: (deps) => selectedPathNodes(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
1913
|
+
};
|
|
1914
|
+
var SPIRO_FORM = "spiro";
|
|
1915
|
+
function asSpiro(form) {
|
|
1916
|
+
const s = form;
|
|
1917
|
+
if (!s || s.kind !== SPIRO_FORM || !Array.isArray(s.kinds) || !Array.isArray(s.at)) return null;
|
|
1918
|
+
if (s.kinds.length !== s.at.length) return null;
|
|
1919
|
+
if (!s.kinds.every(isSpiroKind) || !s.at.every((i) => Number.isInteger(i))) return null;
|
|
1920
|
+
return s;
|
|
1921
|
+
}
|
|
1922
|
+
function spiroOf(data) {
|
|
1923
|
+
return asSpiro(data?.representation);
|
|
1924
|
+
}
|
|
1925
|
+
function withoutSpiro(data) {
|
|
1926
|
+
if (!spiroOf(data)) return data;
|
|
1927
|
+
const { representation: _dropped, ...rest } = data;
|
|
1928
|
+
return rest;
|
|
1929
|
+
}
|
|
1930
|
+
var shapeOf = (kinds, at2) => ({ kind: SPIRO_FORM, kinds, at: at2 });
|
|
1931
|
+
function spiroNodeData(contours, like) {
|
|
1932
|
+
const { path, at: at2 } = spiroToPath(contours, { like });
|
|
1933
|
+
return { path, representation: shapeOf(contours.flatMap((c) => c.points.map((p) => p.kind)), at2) };
|
|
1934
|
+
}
|
|
1935
|
+
function spiroContours(path, spiro) {
|
|
1936
|
+
return spiroContoursOf(path, spiro.kinds, spiro.at);
|
|
1937
|
+
}
|
|
1938
|
+
function spiroEditPath(path, spiro) {
|
|
1939
|
+
const contours = spiroContours(path, spiro);
|
|
1940
|
+
return contours ? spiroControlPath(contours) : null;
|
|
1941
|
+
}
|
|
1942
|
+
function respiro(path, spiro, control) {
|
|
1943
|
+
const before = spiroContours(path, spiro);
|
|
1944
|
+
if (!before) return null;
|
|
1945
|
+
const solved = spiroNodeData(spiroContoursFromControl(control, before), path);
|
|
1946
|
+
return { path: solved.path, spiro: solved.representation };
|
|
1947
|
+
}
|
|
1948
|
+
function respiroWithKind(path, spiro, flatIndexes, kind) {
|
|
1949
|
+
const contours = spiroContours(path, spiro);
|
|
1950
|
+
if (!contours) return null;
|
|
1951
|
+
const picked = new Set(flatIndexes);
|
|
1952
|
+
let i = 0;
|
|
1953
|
+
let changed = false;
|
|
1954
|
+
const next = contours.map((c) => ({
|
|
1955
|
+
closed: c.closed,
|
|
1956
|
+
points: c.points.map((p) => {
|
|
1957
|
+
const hit = picked.has(i++) && p.kind !== kind;
|
|
1958
|
+
changed ||= hit;
|
|
1959
|
+
return hit ? { ...p, kind } : p;
|
|
1960
|
+
})
|
|
1961
|
+
}));
|
|
1962
|
+
if (!changed) return null;
|
|
1963
|
+
const solved = spiroNodeData(next, path);
|
|
1964
|
+
return { path: solved.path, spiro: solved.representation };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// src/spiro/spiroCommands.ts
|
|
1968
|
+
var KIND_LABELS = {
|
|
1969
|
+
smooth: "Smooth spiro point",
|
|
1970
|
+
g4: "Extra-smooth (G4) spiro point",
|
|
1971
|
+
corner: "Corner spiro point",
|
|
1972
|
+
left: "Spiro point after a line",
|
|
1973
|
+
right: "Spiro point before a line"
|
|
1974
|
+
};
|
|
1975
|
+
var nodeOf = (deps, id) => deps?.scene?.get(id);
|
|
1976
|
+
function editedSpiro(deps) {
|
|
1977
|
+
const id = deps?.editAnchors?.editingId;
|
|
1978
|
+
const node = id ? nodeOf(deps, id) : void 0;
|
|
1979
|
+
return node && spiroOf(node.data) ? node : null;
|
|
1980
|
+
}
|
|
1981
|
+
function setKindOps(deps, kind, label) {
|
|
1982
|
+
const node = editedSpiro(deps);
|
|
1983
|
+
const selected = deps?.editAnchors?.selectedAnchors;
|
|
1984
|
+
const world = node ? resolveEditablePathOf(node) : null;
|
|
1985
|
+
if (!node || !world || !selected || selected.size === 0) return null;
|
|
1986
|
+
const solved = respiroWithKind(world, spiroOf(node.data), selected, kind);
|
|
1987
|
+
if (!solved) return null;
|
|
1988
|
+
const stored = storeEditablePath({ pose: node.pose, data: withoutSpiro(node.data) }, solved.path);
|
|
1989
|
+
if (!stored) return null;
|
|
1990
|
+
return reshapeTo(node, { pose: stored.pose, data: { ...stored.data, representation: solved.spiro } }, label);
|
|
1991
|
+
}
|
|
1992
|
+
var kindOf = (params) => isSpiroKind(params?.kind) ? params.kind : "smooth";
|
|
1993
|
+
var setSpiroKindAction = {
|
|
1994
|
+
id: "setSpiroKind",
|
|
1995
|
+
label: "Set spiro point kind",
|
|
1996
|
+
tags: pathOperationTags("command"),
|
|
1997
|
+
group: "spiro",
|
|
1998
|
+
variants: SPIRO_KINDS.map((kind) => ({ key: kind, label: KIND_LABELS[kind], params: { kind } })),
|
|
1999
|
+
eligible: { capability: "edits-anchors" },
|
|
2000
|
+
requires: ["editAnchors", "scene", "applyOps"],
|
|
2001
|
+
invoker: {
|
|
2002
|
+
timing: "immediate",
|
|
2003
|
+
run: (deps, params) => {
|
|
2004
|
+
const kind = kindOf(params);
|
|
2005
|
+
const ops = setKindOps(deps, kind, KIND_LABELS[kind]);
|
|
2006
|
+
if (ops) commitOps(deps, ops, KIND_LABELS[kind]);
|
|
2007
|
+
}
|
|
2008
|
+
},
|
|
2009
|
+
enabled: (deps) => editedSpiro(deps) && deps?.editAnchors?.selectedAnchors.size ? true : ActionDisabledReason.SelectionRequired
|
|
2010
|
+
};
|
|
2011
|
+
function spiroTargets(deps) {
|
|
2012
|
+
const edited = deps?.editAnchors?.editingId;
|
|
2013
|
+
const ids = edited ? [edited] : deps?.selection?.get() ?? [];
|
|
2014
|
+
return ids.flatMap((id) => {
|
|
2015
|
+
const node = nodeOf(deps, id);
|
|
2016
|
+
return node && spiroOf(node.data) ? [node] : [];
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
var RELEASE_LABEL = "Convert to path";
|
|
2020
|
+
var releaseSpiroAction = {
|
|
2021
|
+
id: "releaseSpiro",
|
|
2022
|
+
label: RELEASE_LABEL,
|
|
2023
|
+
tags: pathOperationTags("command"),
|
|
2024
|
+
group: "spiro",
|
|
2025
|
+
requires: ["selection", "scene", "applyOps", "editAnchors"],
|
|
2026
|
+
invoker: {
|
|
2027
|
+
timing: "immediate",
|
|
2028
|
+
run: (deps) => {
|
|
2029
|
+
const nodes = spiroTargets(deps);
|
|
2030
|
+
if (nodes.length === 0) return;
|
|
2031
|
+
commitOps(deps, nodes.map((node) => createSetDataOp({
|
|
2032
|
+
id: node.id,
|
|
2033
|
+
from: node.data,
|
|
2034
|
+
to: withoutSpiro(node.data),
|
|
2035
|
+
label: RELEASE_LABEL
|
|
2036
|
+
})), RELEASE_LABEL);
|
|
2037
|
+
if (deps.editAnchors?.editingId) deps.editAnchors.setSelectedAnchors([]);
|
|
2038
|
+
}
|
|
2039
|
+
},
|
|
2040
|
+
enabled: (deps) => spiroTargets(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
2041
|
+
};
|
|
2042
|
+
var HALF_DISC_STEPS = 12;
|
|
2043
|
+
function halfDisc(x, y, r, toward2) {
|
|
2044
|
+
const mid = Math.atan2(toward2.y, toward2.x);
|
|
2045
|
+
const pts = [];
|
|
2046
|
+
for (let i = 0; i <= HALF_DISC_STEPS; i++) {
|
|
2047
|
+
const a = mid - Math.PI / 2 + Math.PI * i / HALF_DISC_STEPS;
|
|
2048
|
+
pts.push({ x: x + r * Math.cos(a), y: y + r * Math.sin(a) });
|
|
2049
|
+
}
|
|
2050
|
+
return polygonFromPoints(pts);
|
|
2051
|
+
}
|
|
2052
|
+
function spiroMarker(kind) {
|
|
2053
|
+
if (kind === "smooth") return (at2, size) => circlePath(at2.x, at2.y, size / 2);
|
|
2054
|
+
if (kind === "corner") return (at2, size) => squarePath(at2.x, at2.y, size);
|
|
2055
|
+
if (kind === "g4") {
|
|
2056
|
+
return (at2, size) => {
|
|
2057
|
+
const r = size / 2 * 1.25;
|
|
2058
|
+
return polygonFromPoints([
|
|
2059
|
+
{ x: at2.x, y: at2.y - r },
|
|
2060
|
+
{ x: at2.x + r, y: at2.y },
|
|
2061
|
+
{ x: at2.x, y: at2.y + r },
|
|
2062
|
+
{ x: at2.x - r, y: at2.y }
|
|
2063
|
+
]);
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
return (at2, size, prev, next) => {
|
|
2067
|
+
const from = kind === "left" ? prev : next;
|
|
2068
|
+
const away = from ? { x: at2.x - from.x, y: at2.y - from.y } : { x: 1, y: 0 };
|
|
2069
|
+
const toward2 = away.x === 0 && away.y === 0 ? { x: 1, y: 0 } : away;
|
|
2070
|
+
return halfDisc(at2.x, at2.y, size / 2 * 1.2, toward2);
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
var CURVE_HIT_PX = 8;
|
|
2074
|
+
function spiroInsertAt(deps, x, y) {
|
|
2075
|
+
const dep = deps.editAnchors;
|
|
2076
|
+
const spiro = dep?.editingId ? spiroOf(dep.getNodeShape(dep.editingId)?.data) : null;
|
|
2077
|
+
const curve = spiro ? dep.getOutlinePath?.(dep.editingId) : null;
|
|
2078
|
+
if (!dep || !spiro || !curve) return null;
|
|
2079
|
+
const contours = spiroContours(curve, spiro);
|
|
2080
|
+
if (!contours) return null;
|
|
2081
|
+
const set = pathToAnchors(curve);
|
|
2082
|
+
const hit = nearestSegmentT(set.anchors, x, y, set.closed);
|
|
2083
|
+
if (!hit) return null;
|
|
2084
|
+
const scale = deps.view?.get().scale ?? { x: 1, y: 1 };
|
|
2085
|
+
const on = set.anchors[hit.sub];
|
|
2086
|
+
const a = on[hit.segIdx];
|
|
2087
|
+
const b = on[(hit.segIdx + 1) % on.length];
|
|
2088
|
+
const p = cubicPointAt(a, a.outHandle ?? a, b.inHandle ?? b, b, hit.t);
|
|
2089
|
+
if (!withinPxRadius(p.x - x, p.y - y, CURVE_HIT_PX, scale)) return null;
|
|
2090
|
+
const points = contours[hit.sub].points;
|
|
2091
|
+
if (points.some((q) => withinPxRadius(q.x - x, q.y - y, CURVE_HIT_PX, scale))) return null;
|
|
2092
|
+
const flat = flatAnchorIndex(set, hit.sub, hit.segIdx);
|
|
2093
|
+
const first = contours.slice(0, hit.sub).reduce((n, c) => n + c.points.length, 0);
|
|
2094
|
+
const at2 = spiro.at.slice(first, first + points.length);
|
|
2095
|
+
let from = 0;
|
|
2096
|
+
while (from + 1 < at2.length && at2[from + 1] <= flat) from++;
|
|
2097
|
+
return { dep, contours, sub: hit.sub, slot: from + 1 };
|
|
2098
|
+
}
|
|
2099
|
+
function spiroInsertDrag(insert) {
|
|
2100
|
+
const { dep, contours, sub, slot } = insert;
|
|
2101
|
+
const id = dep.editingId;
|
|
2102
|
+
const flat = contours.slice(0, sub).reduce((n, c) => n + c.points.length, 0) + slot;
|
|
2103
|
+
const control = (x, y) => spiroControlPath(contours.map((c, s) => s !== sub ? c : {
|
|
2104
|
+
closed: c.closed,
|
|
2105
|
+
points: [...c.points.slice(0, slot), { x, y, kind: "smooth" }, ...c.points.slice(slot)]
|
|
2106
|
+
}));
|
|
2107
|
+
let current = null;
|
|
2108
|
+
const preview = pathEditPreview(dep, id, () => current);
|
|
2109
|
+
if (!preview) return {};
|
|
2110
|
+
dep.setSelectedAnchors([flat]);
|
|
2111
|
+
return {
|
|
2112
|
+
kind: "spiro-insert-point",
|
|
2113
|
+
onMove(moveCtx) {
|
|
2114
|
+
current = control(moveCtx.world.x, moveCtx.world.y);
|
|
2115
|
+
},
|
|
2116
|
+
onEnd(endCtx, reason) {
|
|
2117
|
+
current = null;
|
|
2118
|
+
if (reason === "cancel") {
|
|
2119
|
+
dep.setSelectedAnchors([]);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
dep.applyEdit(id, control(endCtx.world.x, endCtx.world.y), "Add spiro point");
|
|
2123
|
+
},
|
|
2124
|
+
previewIds: preview.previewIds,
|
|
2125
|
+
previewPose: preview.previewPose,
|
|
2126
|
+
previewData: preview.previewData
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
function spiroSegmentPress(deps, at2) {
|
|
2130
|
+
const insert = spiroInsertAt(deps, at2.x, at2.y);
|
|
2131
|
+
return insert ? () => spiroInsertDrag(insert) : null;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// src/spiro/spiroRepresentation.ts
|
|
2135
|
+
var spiroRepresentation = {
|
|
2136
|
+
kind: SPIRO_FORM,
|
|
2137
|
+
editPath(path, form) {
|
|
2138
|
+
const spiro = asSpiro(form);
|
|
2139
|
+
return spiro && spiroEditPath(path, spiro);
|
|
2140
|
+
},
|
|
2141
|
+
store(path, form, edited) {
|
|
2142
|
+
const spiro = asSpiro(form);
|
|
2143
|
+
const solved = spiro && respiro(path, spiro, edited);
|
|
2144
|
+
return solved && { path: solved.path, form: solved.spiro };
|
|
2145
|
+
},
|
|
2146
|
+
anchorMarkers: (form) => asSpiro(form)?.kinds.map(spiroMarker) ?? [],
|
|
2147
|
+
segmentPress: spiroSegmentPress
|
|
2148
|
+
};
|
|
2149
|
+
var spiroExtension = { id: "spiro", representation: spiroRepresentation };
|
|
2150
|
+
|
|
2151
|
+
// src/spiro/spiroPreset.ts
|
|
2152
|
+
var spiroPreset = {
|
|
2153
|
+
id: "spiro",
|
|
2154
|
+
actions: [setSpiroKindAction, releaseSpiroAction],
|
|
2155
|
+
pathEdit: [spiroExtension]
|
|
2156
|
+
};
|
|
2157
|
+
var TRIM_LABEL = "Trim segment";
|
|
2158
|
+
function planTrim(deps, params) {
|
|
2159
|
+
const press = pressOf(deps, params);
|
|
2160
|
+
if (!press) return null;
|
|
2161
|
+
const all = scenePathNodes(deps);
|
|
2162
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
2163
|
+
const target = all[i];
|
|
2164
|
+
const result = trimPathAt(target.path, strokesBesides(deps, target.id), press.at, { radius: press.radius });
|
|
2165
|
+
if (result) return { target, result };
|
|
2166
|
+
}
|
|
2167
|
+
return null;
|
|
2168
|
+
}
|
|
2169
|
+
var trimPathAction = {
|
|
2170
|
+
id: "trimPath",
|
|
2171
|
+
icon: (
|
|
2172
|
+
/* @icon */
|
|
2173
|
+
"path-tools:trim"
|
|
2174
|
+
),
|
|
2175
|
+
label: TRIM_LABEL,
|
|
2176
|
+
tags: pathOperationTags("click"),
|
|
2177
|
+
group: "path",
|
|
2178
|
+
eligible: { capability: "creates-shapes" },
|
|
2179
|
+
requires: ["scene", "applyOps", "selection", "view"],
|
|
2180
|
+
invoker: {
|
|
2181
|
+
timing: "immediate",
|
|
2182
|
+
run: (deps, params) => {
|
|
2183
|
+
const plan = deps.scene ? planTrim(deps, params) : null;
|
|
2184
|
+
if (!plan) return;
|
|
2185
|
+
const { target, result } = plan;
|
|
2186
|
+
const gone = result.path.commands.length === 0;
|
|
2187
|
+
const ops = gone ? [deleteNode(deps.scene, target.node, TRIM_LABEL)] : reshapeOps(target.node, result.path, TRIM_LABEL);
|
|
2188
|
+
commitOps(deps, ops, TRIM_LABEL);
|
|
2189
|
+
if (gone) deps.selection?.set((deps.selection.get() ?? []).filter((id) => id !== target.id));
|
|
2190
|
+
}
|
|
2191
|
+
},
|
|
2192
|
+
enabled: () => true
|
|
2193
|
+
};
|
|
2194
|
+
var results = /* @__PURE__ */ new WeakMap();
|
|
2195
|
+
function heldWarpResult(held) {
|
|
2196
|
+
let out = results.get(held);
|
|
2197
|
+
if (!out) {
|
|
2198
|
+
out = applyPathWarp(held.source, held.warp, held.tolerance);
|
|
2199
|
+
results.set(held, out);
|
|
2200
|
+
}
|
|
2201
|
+
return out;
|
|
2202
|
+
}
|
|
2203
|
+
function rememberWarpResult(held, result) {
|
|
2204
|
+
results.set(held, result);
|
|
2205
|
+
}
|
|
2206
|
+
function isHeldWarp(v) {
|
|
2207
|
+
if (v === null || typeof v !== "object") return false;
|
|
2208
|
+
const h = v;
|
|
2209
|
+
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;
|
|
2210
|
+
}
|
|
2211
|
+
function heldPathWarpOf(data) {
|
|
2212
|
+
const w = data?.warp;
|
|
2213
|
+
return isHeldWarp(w) ? w : null;
|
|
2214
|
+
}
|
|
2215
|
+
function withoutPathWarp(data) {
|
|
2216
|
+
if (data === null || typeof data !== "object" || !("warp" in data)) return data;
|
|
2217
|
+
const { warp: _dropped, ...rest } = data;
|
|
2218
|
+
return rest;
|
|
2219
|
+
}
|
|
2220
|
+
function storedPathOf(node) {
|
|
2221
|
+
const storage = classifyPathStorage(node);
|
|
2222
|
+
if (!storage) return null;
|
|
2223
|
+
if (storage.kind === "pose") return { path: node.pose, toWorld: identity() };
|
|
2224
|
+
const { pose, data } = storage;
|
|
2225
|
+
const b = boundsOfPath(data.path);
|
|
2226
|
+
const box = boxToBox(b.x, b.y, b.width, b.height, pose.x, pose.y, pose.width, pose.height);
|
|
2227
|
+
return { path: data.path, toWorld: multiply(poseFrameOf(pose) ?? identity(), box) };
|
|
2228
|
+
}
|
|
2229
|
+
function sameUnder(a, m, b) {
|
|
2230
|
+
if (a.coords.length !== b.coords.length || a.commands.length !== b.commands.length) return false;
|
|
2231
|
+
const bb = boundsOfPath(b);
|
|
2232
|
+
const tol = 1e-3 * Math.max(1, Math.abs(bb.x) + bb.width, Math.abs(bb.y) + bb.height);
|
|
2233
|
+
for (let i = 0; i + 1 < a.coords.length; i += 2) {
|
|
2234
|
+
const x = m[0] * a.coords[i] + m[2] * a.coords[i + 1] + m[4];
|
|
2235
|
+
const y = m[1] * a.coords[i] + m[3] * a.coords[i + 1] + m[5];
|
|
2236
|
+
if (Math.abs(x - b.coords[i]) > tol || Math.abs(y - b.coords[i + 1]) > tol) return false;
|
|
2237
|
+
}
|
|
2238
|
+
return true;
|
|
2239
|
+
}
|
|
2240
|
+
function resolvePathWarp(node) {
|
|
2241
|
+
if (!node) return null;
|
|
2242
|
+
const held = heldPathWarpOf(node.data);
|
|
2243
|
+
if (!held) return null;
|
|
2244
|
+
const stored = storedPathOf(node);
|
|
2245
|
+
if (!stored) return null;
|
|
2246
|
+
const made = heldWarpResult(held);
|
|
2247
|
+
let toStorage = held.frame;
|
|
2248
|
+
if (!sameUnder(made, toStorage, stored.path)) {
|
|
2249
|
+
toStorage = made.coords.length === stored.path.coords.length ? fitAffine(made.coords, stored.path.coords) : null;
|
|
2250
|
+
if (!toStorage || !sameUnder(made, toStorage, stored.path)) return null;
|
|
2251
|
+
}
|
|
2252
|
+
return { held, toWorld: multiply(stored.toWorld, toStorage) };
|
|
2253
|
+
}
|
|
2254
|
+
function warpFrameFor(node, toWorld2) {
|
|
2255
|
+
const stored = storedPathOf(node);
|
|
2256
|
+
const back = stored && invert(stored.toWorld);
|
|
2257
|
+
return back ? multiply(back, toWorld2) : null;
|
|
2258
|
+
}
|
|
2259
|
+
var WARP_LABELS = { distort: "Distort", envelope: "Envelope", puppet: "Puppet Warp" };
|
|
2260
|
+
var DEFAULT_WARP_TOLERANCE = 0.5;
|
|
2261
|
+
var DEFAULT_ENVELOPE_SIZE = 3;
|
|
2262
|
+
var count = (v, fallback) => Math.min(16, Math.max(1, Math.round(v ?? fallback)));
|
|
2263
|
+
function freshWarp(kind, box, settings) {
|
|
2264
|
+
switch (kind) {
|
|
2265
|
+
case "distort":
|
|
2266
|
+
return distortOver(box, settings.mode ?? "free");
|
|
2267
|
+
case "envelope":
|
|
2268
|
+
return envelopeOver(
|
|
2269
|
+
box,
|
|
2270
|
+
count(settings.rows, DEFAULT_ENVELOPE_SIZE),
|
|
2271
|
+
count(settings.cols, DEFAULT_ENVELOPE_SIZE),
|
|
2272
|
+
settings.mapping ?? "bicubic"
|
|
2273
|
+
);
|
|
2274
|
+
case "puppet":
|
|
2275
|
+
return { kind: "puppet", pins: [] };
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
function withWarpSettings(held, settings) {
|
|
2279
|
+
const { warp } = held;
|
|
2280
|
+
let next = warp;
|
|
2281
|
+
if (warp.kind === "distort" && settings.mode && settings.mode !== warp.mode) next = { ...warp, mode: settings.mode };
|
|
2282
|
+
if (warp.kind === "envelope") {
|
|
2283
|
+
const sized = resizeEnvelope(
|
|
2284
|
+
warp,
|
|
2285
|
+
count(settings.rows, warp.mesh.rows),
|
|
2286
|
+
count(settings.cols, warp.mesh.cols)
|
|
2287
|
+
);
|
|
2288
|
+
next = settings.mapping && settings.mapping !== sized.mapping ? { ...sized, mapping: settings.mapping } : sized;
|
|
2289
|
+
}
|
|
2290
|
+
const tolerance = settings.tolerance !== void 0 && settings.tolerance > 0 ? settings.tolerance : held.tolerance;
|
|
2291
|
+
if (next === warp && tolerance === held.tolerance) return held;
|
|
2292
|
+
return { ...held, warp: next, tolerance };
|
|
2293
|
+
}
|
|
2294
|
+
function worldBoxOf(nodes) {
|
|
2295
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
2296
|
+
for (const { path } of nodes) {
|
|
2297
|
+
const b = boundsOfPath(path);
|
|
2298
|
+
x0 = Math.min(x0, b.x);
|
|
2299
|
+
y0 = Math.min(y0, b.y);
|
|
2300
|
+
x1 = Math.max(x1, b.x + b.width);
|
|
2301
|
+
y1 = Math.max(y1, b.y + b.height);
|
|
2302
|
+
}
|
|
2303
|
+
return x0 <= x1 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
|
|
2304
|
+
}
|
|
2305
|
+
var freshIds = 0;
|
|
2306
|
+
function freshWarpId() {
|
|
2307
|
+
return `warp-${Date.now().toString(36)}-${(++freshIds).toString(36)}`;
|
|
2308
|
+
}
|
|
2309
|
+
function samePlacement(a, b) {
|
|
2310
|
+
const ca = warpControls(a.held.warp), cb = warpControls(b.held.warp);
|
|
2311
|
+
if (ca.length !== cb.length) return false;
|
|
2312
|
+
const m = a.toWorld, n = b.toWorld;
|
|
2313
|
+
const scale = Math.max(1, Math.abs(m[0]) + Math.abs(m[1]) + Math.abs(m[2]) + Math.abs(m[3]));
|
|
2314
|
+
for (let i = 0; i < 4; i++) if (Math.abs(m[i] - n[i]) > 1e-4 * scale) return false;
|
|
2315
|
+
const box = worldBoxOf([a.node]);
|
|
2316
|
+
const tol = 1e-3 * Math.max(1, box ? Math.abs(box.x) + box.width + Math.abs(box.y) + box.height : 1);
|
|
2317
|
+
if (Math.hypot(m[4] - n[4], m[5] - n[5]) > tol * scale) return false;
|
|
2318
|
+
return ca.every((p, i) => {
|
|
2319
|
+
const [ax, ay] = applyToPoint(m, p.x, p.y);
|
|
2320
|
+
const [bx, by] = applyToPoint(n, cb[i].x, cb[i].y);
|
|
2321
|
+
return Math.hypot(ax - bx, ay - by) <= tol;
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
function splitByWarp(nodes, kind) {
|
|
2325
|
+
const held = [];
|
|
2326
|
+
const bare = [];
|
|
2327
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2328
|
+
for (const node of nodes) {
|
|
2329
|
+
const resolved = resolvePathWarp(node.node);
|
|
2330
|
+
if (!resolved || resolved.held.warp.kind !== kind) {
|
|
2331
|
+
bare.push(node);
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
let target = { node, ...resolved, fresh: false };
|
|
2335
|
+
const id = resolved.held.id;
|
|
2336
|
+
const firsts = groups.get(id) ?? [];
|
|
2337
|
+
const joined = firsts.find((f) => samePlacement(f, target));
|
|
2338
|
+
const groupId = joined ? joined.held.id : firsts.length > 0 ? `${id}~${node.id}` : id;
|
|
2339
|
+
if (groupId !== id) {
|
|
2340
|
+
const parted = { ...resolved.held, id: groupId };
|
|
2341
|
+
rememberWarpResult(parted, heldWarpResult(resolved.held));
|
|
2342
|
+
target = { ...target, held: parted };
|
|
2343
|
+
}
|
|
2344
|
+
if (!joined) groups.set(id, [...firsts, target]);
|
|
2345
|
+
held.push(target);
|
|
2346
|
+
}
|
|
2347
|
+
return { held, bare, box: worldBoxOf(bare) };
|
|
2348
|
+
}
|
|
2349
|
+
function warpTargets(deps, kind, settings) {
|
|
2350
|
+
const { held: out, bare, box } = splitByWarp(selectedPathNodes(deps), kind);
|
|
2351
|
+
if (!box) return out;
|
|
2352
|
+
const id = freshWarpId();
|
|
2353
|
+
const warp = freshWarp(kind, box, settings);
|
|
2354
|
+
const tolerance = settings.tolerance !== void 0 && settings.tolerance > 0 ? settings.tolerance : DEFAULT_WARP_TOLERANCE;
|
|
2355
|
+
for (const node of bare) {
|
|
2356
|
+
const frame = warpFrameFor(node.node, identity());
|
|
2357
|
+
if (!frame) continue;
|
|
2358
|
+
out.push({ node, held: { id, source: node.path, warp, tolerance, frame }, toWorld: identity(), fresh: true });
|
|
2359
|
+
}
|
|
2360
|
+
return out;
|
|
2361
|
+
}
|
|
2362
|
+
function withPathWarp(shape, warp, opts = {}) {
|
|
2363
|
+
const source = resolveEditablePathOf(shape);
|
|
2364
|
+
if (!source) return null;
|
|
2365
|
+
const held = {
|
|
2366
|
+
id: opts.id ?? freshWarpId(),
|
|
2367
|
+
source,
|
|
2368
|
+
warp,
|
|
2369
|
+
tolerance: opts.tolerance ?? DEFAULT_WARP_TOLERANCE,
|
|
2370
|
+
frame: identity()
|
|
2371
|
+
};
|
|
2372
|
+
return storeWarp(shape, held, identity());
|
|
2373
|
+
}
|
|
2374
|
+
function storeWarp(node, held, toWorld2) {
|
|
2375
|
+
const made = heldWarpResult(held);
|
|
2376
|
+
const next = withPath(node, transformPath(made, toWorld2));
|
|
2377
|
+
if (!next) return null;
|
|
2378
|
+
const frame = warpFrameFor(next, toWorld2);
|
|
2379
|
+
if (!frame) return null;
|
|
2380
|
+
const kept = { ...held, frame };
|
|
2381
|
+
rememberWarpResult(kept, made);
|
|
2382
|
+
return { pose: next.pose, data: { ...next.data, warp: kept } };
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// src/warp/warpActions.ts
|
|
2386
|
+
function createWarpLive() {
|
|
2387
|
+
let current = null;
|
|
2388
|
+
const changed = createEmitter();
|
|
2389
|
+
return {
|
|
2390
|
+
get: (id) => current?.get(id),
|
|
2391
|
+
set: (next) => {
|
|
2392
|
+
current = next;
|
|
2393
|
+
changed.emit();
|
|
2394
|
+
},
|
|
2395
|
+
subscribe: changed.subscribe
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
function warpHandleScratchOf(hit) {
|
|
2399
|
+
const payload = hit?.payload;
|
|
2400
|
+
return payload && typeof payload.index === "number" && payload.warpId !== void 0 ? payload : null;
|
|
2401
|
+
}
|
|
2402
|
+
var REQUIRES = ["selection", "scene", "applyOps"];
|
|
2403
|
+
function commitTargets(deps, next, label) {
|
|
2404
|
+
const ops = [];
|
|
2405
|
+
for (const { target, shape } of next.values()) ops.push(...reshapeTo(target.node.node, shape, label));
|
|
2406
|
+
commitOps(deps, ops, label);
|
|
2407
|
+
}
|
|
2408
|
+
function createWarpDragAction(kind, live) {
|
|
2409
|
+
const label = WARP_LABELS[kind];
|
|
2410
|
+
return {
|
|
2411
|
+
id: `${kind}.drag`,
|
|
2412
|
+
label: `${label} \u2014 drag control`,
|
|
2413
|
+
tags: pathOperationTags(kind === "puppet" ? "brush" : "box"),
|
|
2414
|
+
requires: REQUIRES,
|
|
2415
|
+
invoker: {
|
|
2416
|
+
timing: "ongoing",
|
|
2417
|
+
start(ctx, opts) {
|
|
2418
|
+
const scratch = warpHandleScratchOf(ctx.drag?.affordance);
|
|
2419
|
+
if (!scratch) return {};
|
|
2420
|
+
const settings = resolveParams(opts?.params) ?? {};
|
|
2421
|
+
const targets = warpTargets(ctx.deps, kind, settings).filter((t) => scratch.warpId === null ? t.fresh : t.held.id === scratch.warpId);
|
|
2422
|
+
const first = targets[0];
|
|
2423
|
+
const control = first && warpControls(first.held.warp)[scratch.index];
|
|
2424
|
+
if (!control) return {};
|
|
2425
|
+
const deps = ctx.deps;
|
|
2426
|
+
const [cx, cy] = applyToPoint(first.toWorld, control.x, control.y);
|
|
2427
|
+
const press = ctx.drag?.start ?? ctx.world;
|
|
2428
|
+
let current = /* @__PURE__ */ new Map();
|
|
2429
|
+
const publish = () => live?.set(new Map([...current].map(([id, e]) => [id, e.shape])));
|
|
2430
|
+
return {
|
|
2431
|
+
kind: `${kind}.drag`,
|
|
2432
|
+
onMove(m) {
|
|
2433
|
+
const at2 = m.drag?.current ?? m.world;
|
|
2434
|
+
const world = { x: cx + at2.x - press.x, y: cy + at2.y - press.y };
|
|
2435
|
+
current = /* @__PURE__ */ new Map();
|
|
2436
|
+
for (const target of targets) {
|
|
2437
|
+
const back = invert(target.toWorld);
|
|
2438
|
+
if (!back) continue;
|
|
2439
|
+
const [lx, ly] = applyToPoint(back, world.x, world.y);
|
|
2440
|
+
const held = { ...target.held, warp: moveWarpControl(target.held.warp, scratch.index, { x: lx, y: ly }) };
|
|
2441
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
2442
|
+
if (shape) current.set(target.node.id, { target, shape });
|
|
2443
|
+
}
|
|
2444
|
+
publish();
|
|
2445
|
+
},
|
|
2446
|
+
onEnd(_end, reason) {
|
|
2447
|
+
const done = current;
|
|
2448
|
+
current = /* @__PURE__ */ new Map();
|
|
2449
|
+
live?.set(null);
|
|
2450
|
+
if (reason === "commit" && done.size > 0) commitTargets(deps, done, label);
|
|
2451
|
+
},
|
|
2452
|
+
previewIds: () => current.size > 0 ? [...current.keys()] : null,
|
|
2453
|
+
previewPose: (id) => current.get(id)?.shape.pose ?? null,
|
|
2454
|
+
previewData: (id) => current.get(id)?.shape.data ?? null
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
function clickPoint(params) {
|
|
2461
|
+
const x = params?.pressX ?? params?.worldX;
|
|
2462
|
+
const y = params?.pressY ?? params?.worldY;
|
|
2463
|
+
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
|
|
2464
|
+
}
|
|
2465
|
+
function createAddPinAction() {
|
|
2466
|
+
return {
|
|
2467
|
+
id: "puppet.addPin",
|
|
2468
|
+
label: `${WARP_LABELS.puppet} \u2014 add pin`,
|
|
2469
|
+
tags: pathOperationTags("brush"),
|
|
2470
|
+
requires: REQUIRES,
|
|
2471
|
+
invoker: {
|
|
2472
|
+
timing: "immediate",
|
|
2473
|
+
run: (deps, params) => {
|
|
2474
|
+
const at2 = clickPoint(params);
|
|
2475
|
+
if (!at2) return;
|
|
2476
|
+
const next = /* @__PURE__ */ new Map();
|
|
2477
|
+
for (const target of warpTargets(deps, "puppet", params ?? {})) {
|
|
2478
|
+
const { warp } = target.held;
|
|
2479
|
+
const back = invert(target.toWorld);
|
|
2480
|
+
if (warp.kind !== "puppet" || !back) continue;
|
|
2481
|
+
const [lx, ly] = applyToPoint(back, at2.x, at2.y);
|
|
2482
|
+
const to = { x: lx, y: ly };
|
|
2483
|
+
const from = unwarpPoint(pathWarpMap(warp, target.held.source), to);
|
|
2484
|
+
const held = { ...target.held, warp: { ...warp, pins: [...warp.pins, { from, to }] } };
|
|
2485
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
2486
|
+
if (shape) next.set(target.node.id, { target, shape });
|
|
2487
|
+
}
|
|
2488
|
+
commitTargets(deps, next, "Add Pin");
|
|
2489
|
+
}
|
|
2490
|
+
},
|
|
2491
|
+
enabled: (deps) => selectedPathNodes(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
function createRemovePinAction() {
|
|
2495
|
+
return {
|
|
2496
|
+
id: "puppet.removePin",
|
|
2497
|
+
label: `${WARP_LABELS.puppet} \u2014 remove pin`,
|
|
2498
|
+
tags: pathOperationTags("brush"),
|
|
2499
|
+
requires: REQUIRES,
|
|
2500
|
+
invoker: {
|
|
2501
|
+
timing: "immediate",
|
|
2502
|
+
run: (deps, params) => {
|
|
2503
|
+
const scratch = warpHandleScratchOf(params?.affordance);
|
|
2504
|
+
if (!scratch || scratch.warpId === null) return;
|
|
2505
|
+
const next = /* @__PURE__ */ new Map();
|
|
2506
|
+
for (const target of warpTargets(deps, "puppet", {})) {
|
|
2507
|
+
const { warp } = target.held;
|
|
2508
|
+
if (target.held.id !== scratch.warpId || warp.kind !== "puppet") continue;
|
|
2509
|
+
const held = { ...target.held, warp: { ...warp, pins: warp.pins.filter((_, i) => i !== scratch.index) } };
|
|
2510
|
+
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
2511
|
+
if (shape) next.set(target.node.id, { target, shape });
|
|
2512
|
+
}
|
|
2513
|
+
commitTargets(deps, next, "Remove Pin");
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
function createWarpGrabAction(kind) {
|
|
2519
|
+
return {
|
|
2520
|
+
id: `${kind}.grab`,
|
|
2521
|
+
label: `${WARP_LABELS[kind]} \u2014 grab control`,
|
|
2522
|
+
invoker: { timing: "immediate", run: () => {
|
|
2523
|
+
} }
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
var expandWarpAction = {
|
|
2527
|
+
id: "expandWarp",
|
|
2528
|
+
label: "Expand Warp",
|
|
2529
|
+
icon: (
|
|
2530
|
+
/* @icon */
|
|
2531
|
+
"path-tools:expand"
|
|
2532
|
+
),
|
|
2533
|
+
tags: pathOperationTags("command"),
|
|
2534
|
+
group: "path",
|
|
2535
|
+
requires: REQUIRES,
|
|
2536
|
+
invoker: {
|
|
2537
|
+
timing: "immediate",
|
|
2538
|
+
run: (deps) => {
|
|
2539
|
+
const ops = [];
|
|
2540
|
+
for (const { node } of selectedPathNodes(deps)) {
|
|
2541
|
+
if (!heldPathWarpOf(node.data)) continue;
|
|
2542
|
+
ops.push(createSetDataOp({ id: node.id, from: node.data, to: withoutPathWarp(node.data), label: "Expand Warp" }));
|
|
2543
|
+
}
|
|
2544
|
+
commitOps(deps, ops, "Expand Warp");
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
enabled: (deps) => selectedPathNodes(deps).some(({ node }) => heldPathWarpOf(node.data)) ? true : ActionDisabledReason.NotApplicable
|
|
2548
|
+
};
|
|
2549
|
+
var configureWarpAction = {
|
|
2550
|
+
id: "configureWarp",
|
|
2551
|
+
label: "Warp Settings",
|
|
2552
|
+
icon: (
|
|
2553
|
+
/* @icon */
|
|
2554
|
+
"core:tune"
|
|
2555
|
+
),
|
|
2556
|
+
tags: pathOperationTags("command"),
|
|
2557
|
+
group: "path",
|
|
2558
|
+
requires: REQUIRES,
|
|
2559
|
+
invoker: {
|
|
2560
|
+
timing: "immediate",
|
|
2561
|
+
run: (deps, params) => {
|
|
2562
|
+
const settings = params ?? {};
|
|
2563
|
+
const next = /* @__PURE__ */ new Map();
|
|
2564
|
+
for (const node of selectedPathNodes(deps)) {
|
|
2565
|
+
const resolved = resolvePathWarp(node.node);
|
|
2566
|
+
if (!resolved || settings.kind && resolved.held.warp.kind !== settings.kind) continue;
|
|
2567
|
+
const held = withWarpSettings(resolved.held, settings);
|
|
2568
|
+
if (held === resolved.held) continue;
|
|
2569
|
+
const shape = storeWarp(node.node, held, resolved.toWorld);
|
|
2570
|
+
if (shape) next.set(node.id, { target: { node, ...resolved, fresh: false }, shape });
|
|
2571
|
+
}
|
|
2572
|
+
commitTargets(deps, next, "Warp Settings");
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
|
|
2577
|
+
// src/presets.ts
|
|
2578
|
+
var simplifyBrushPreset = {
|
|
2579
|
+
id: "simplifyBrush",
|
|
2580
|
+
actions: [simplifyBrushAction, simplifyBrushToolAction]
|
|
2581
|
+
};
|
|
2582
|
+
var handleLocksPreset = {
|
|
2583
|
+
id: "handleLocks",
|
|
2584
|
+
actions: [handleLockAngleAction, handleLockLengthAction]
|
|
2585
|
+
};
|
|
2586
|
+
function createSoftSelectionPreset(options) {
|
|
2587
|
+
return { id: "softSelection", pathEdit: [createSoftSelectionExtension(options)] };
|
|
2588
|
+
}
|
|
2589
|
+
function createHandleSnapPreset(options = {}) {
|
|
2590
|
+
return { id: "handleSnap", pathEdit: [createHandleSnapExtension(options)], snapSources: [handleSnapSource(options)] };
|
|
2591
|
+
}
|
|
2592
|
+
var handleSnapPreset = createHandleSnapPreset();
|
|
2593
|
+
var bridgePreset = {
|
|
2594
|
+
id: "bridge",
|
|
2595
|
+
actions: [bridgeAnchorsAction, bridgePathsAction]
|
|
2596
|
+
};
|
|
2597
|
+
var trimPreset = { id: "trim", actions: [trimPathAction] };
|
|
2598
|
+
var extendPreset = { id: "extend", actions: [extendToMeetAction] };
|
|
2599
|
+
var lengthenPreset = {
|
|
2600
|
+
id: "lengthen",
|
|
2601
|
+
actions: [lengthenPathAction, lengthenToolAction]
|
|
2602
|
+
};
|
|
2603
|
+
var smudgePreset = { id: "smudge", actions: [smudgeBrushAction] };
|
|
2604
|
+
var harmonizePreset = { id: "harmonize", actions: [harmonizeAnchorsAction] };
|
|
2605
|
+
var anchorSelectionPreset = {
|
|
2606
|
+
id: "anchorSelection",
|
|
2607
|
+
actions: [
|
|
2608
|
+
growAnchorSelectionAction,
|
|
2609
|
+
shrinkAnchorSelectionAction,
|
|
2610
|
+
selectEveryNthAnchorAction,
|
|
2611
|
+
selectCornerAnchorsAction,
|
|
2612
|
+
selectSmoothAnchorsAction,
|
|
2613
|
+
invertAnchorSelectionAction
|
|
2614
|
+
]
|
|
2615
|
+
};
|
|
2616
|
+
var resamplePreset = { id: "resample", actions: [resamplePathAction] };
|
|
2617
|
+
var addExtremesPreset = { id: "addExtremes", actions: [addExtremeAnchorsAction] };
|
|
2618
|
+
function createCurvatureCombPreset(options = () => void 0) {
|
|
2619
|
+
return {
|
|
2620
|
+
id: "curvatureComb",
|
|
2621
|
+
chromeRules: { [PATH_COMB_CHROME_ID]: never },
|
|
2622
|
+
pathEdit: [createCombExtension(options)]
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
var curvatureCombPreset = createCurvatureCombPreset();
|
|
2626
|
+
function createBalancePreset(style = {}) {
|
|
2627
|
+
return {
|
|
2628
|
+
id: "balance",
|
|
2629
|
+
refines: ["paths"],
|
|
2630
|
+
actions: [equalizeHandlesAction, balanceHandlesAction],
|
|
2631
|
+
chromeRules: { [PATH_BALANCE_CHROME_ID]: never },
|
|
2632
|
+
pathEdit: [createBalanceExtension(style)]
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
var balancePreset = createBalancePreset();
|
|
2636
|
+
function createConstraintsPreset(style = {}) {
|
|
2637
|
+
return {
|
|
2638
|
+
id: "constraints",
|
|
2639
|
+
actions: [constrainAnchorsAction, unconstrainAnchorsAction, deleteConstraintAction],
|
|
2640
|
+
pathEdit: [createConstraintsExtension(style)]
|
|
2641
|
+
};
|
|
2642
|
+
}
|
|
2643
|
+
var constraintsPreset = createConstraintsPreset();
|
|
2644
|
+
function createCornerShapePreset(style = {}) {
|
|
2645
|
+
return {
|
|
2646
|
+
id: "cornerShape",
|
|
2647
|
+
refines: ["paths"],
|
|
2648
|
+
actions: [editCornerAction, setCornersAction, cycleCornerShapeAction],
|
|
2649
|
+
pathEdit: [createCornerWidgetsExtension(style)]
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
var cornerShapePreset = createCornerShapePreset();
|
|
2653
|
+
function createPathStructurePreset(style = {}) {
|
|
2654
|
+
return {
|
|
2655
|
+
id: "pathStructure",
|
|
2656
|
+
actions: [setPathStartAction, reversePathAction],
|
|
2657
|
+
chromeRules: { [PATH_DIRECTION_CHROME_ID]: never },
|
|
2658
|
+
pathEdit: [createDirectionExtension(style)]
|
|
2659
|
+
};
|
|
2660
|
+
}
|
|
2661
|
+
var pathStructurePreset = createPathStructurePreset();
|
|
2662
|
+
var segmentTypePreset = {
|
|
2663
|
+
id: "segmentType",
|
|
2664
|
+
refines: ["paths"],
|
|
2665
|
+
actions: [convertSegmentsAction, convertSegmentAtAction]
|
|
2666
|
+
};
|
|
2667
|
+
var pathEffectsPreset = {
|
|
2668
|
+
id: "pathEffects",
|
|
2669
|
+
actions: [blendAction, bendAlongAction, repeatAlongAction, editPathEffectAction, expandPathEffectAction]
|
|
2670
|
+
};
|
|
2671
|
+
var warpPreset = {
|
|
2672
|
+
id: "warp",
|
|
2673
|
+
actions: [expandWarpAction, configureWarpAction]
|
|
2674
|
+
};
|
|
2675
|
+
var pathTools = {
|
|
2676
|
+
id: "pathTools",
|
|
2677
|
+
includes: [
|
|
2678
|
+
simplifyBrushPreset,
|
|
2679
|
+
handleLocksPreset,
|
|
2680
|
+
bridgePreset,
|
|
2681
|
+
spiroPreset,
|
|
2682
|
+
trimPreset,
|
|
2683
|
+
extendPreset,
|
|
2684
|
+
lengthenPreset,
|
|
2685
|
+
smudgePreset,
|
|
2686
|
+
harmonizePreset,
|
|
2687
|
+
anchorSelectionPreset,
|
|
2688
|
+
resamplePreset,
|
|
2689
|
+
addExtremesPreset,
|
|
2690
|
+
curvatureCombPreset,
|
|
2691
|
+
balancePreset,
|
|
2692
|
+
constraintsPreset,
|
|
2693
|
+
pathStructurePreset,
|
|
2694
|
+
segmentTypePreset,
|
|
2695
|
+
cornerShapePreset,
|
|
2696
|
+
pathEffectsPreset,
|
|
2697
|
+
warpPreset
|
|
2698
|
+
]
|
|
2699
|
+
};
|
|
2700
|
+
function useStrokeClickTool(spec, options, extras = {}) {
|
|
2701
|
+
const { keybinding = spec.keybinding, radiusPx } = options;
|
|
2702
|
+
const { id, label, icon, hookName, actionId } = spec;
|
|
2703
|
+
return useMemo(() => {
|
|
2704
|
+
const opts = radiusPx !== void 0 ? { opts: { params: () => ({ radiusPx: typeof radiusPx === "function" ? radiusPx() : radiusPx }) } } : {};
|
|
2705
|
+
return defineTool({
|
|
2706
|
+
id,
|
|
2707
|
+
...keybinding ? { keybinding } : {},
|
|
2708
|
+
capabilities: ["creates-shapes"],
|
|
2709
|
+
hookName,
|
|
2710
|
+
cursor: "crosshair",
|
|
2711
|
+
presentation: { label, group: "path", icon },
|
|
2712
|
+
...extras.actions ? { actions: extras.actions } : {},
|
|
2713
|
+
...extras.overlay ? { overlay: extras.overlay } : {},
|
|
2714
|
+
...extras.onDeactivate ? { onDeactivate: extras.onDeactivate } : {},
|
|
2715
|
+
bindings: [
|
|
2716
|
+
{ spec: { kind: "click" }, actionId, ...opts },
|
|
2717
|
+
...(extras.bindings ?? []).map((b) => ({ ...b, ...opts }))
|
|
2718
|
+
]
|
|
2719
|
+
});
|
|
2720
|
+
}, [id, label, icon, hookName, actionId, keybinding, radiusPx, extras.actions, extras.bindings, extras.overlay, extras.onDeactivate]);
|
|
2721
|
+
}
|
|
2722
|
+
function createPiecePreview() {
|
|
2723
|
+
const changed = createEmitter();
|
|
2724
|
+
let state = null;
|
|
2725
|
+
const current = () => state && state.piece.scene.getVersion() === state.version ? state.piece : null;
|
|
2726
|
+
return {
|
|
2727
|
+
show(next) {
|
|
2728
|
+
if (!next && !state) return;
|
|
2729
|
+
if (next && state && next.path === state.piece.path) return;
|
|
2730
|
+
state = next ? { piece: next, version: next.scene.getVersion() } : null;
|
|
2731
|
+
changed.emit();
|
|
2732
|
+
},
|
|
2733
|
+
current,
|
|
2734
|
+
layer: {
|
|
2735
|
+
id: "stroke-piece-preview",
|
|
2736
|
+
label: "Piece a click would cut",
|
|
2737
|
+
space: "screen",
|
|
2738
|
+
subscribe: (repaint) => changed.subscribe(repaint),
|
|
2739
|
+
draw: (_data, view) => {
|
|
2740
|
+
const piece = current();
|
|
2741
|
+
if (!piece) return [];
|
|
2742
|
+
const t = viewToTransform(view);
|
|
2743
|
+
const stroke = { ...OVERLAY_ROLE_STROKES["cut"], width: 3 };
|
|
2744
|
+
return [{ kind: "path", path: transformPath(piece.path, [t.zoom.x, 0, 0, t.zoom.y, t.panX, t.panY]), stroke }];
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// src/trim/useTrimTool.ts
|
|
2751
|
+
var TRIM_KEY = { key: "Y" };
|
|
2752
|
+
var TRIM_PREVIEW_ID = "trim.preview";
|
|
2753
|
+
function useTrimTool(options = {}) {
|
|
2754
|
+
const extras = useMemo(() => {
|
|
2755
|
+
const preview = createPiecePreview();
|
|
2756
|
+
const action = {
|
|
2757
|
+
id: TRIM_PREVIEW_ID,
|
|
2758
|
+
label: "Trim segment: show the piece under the pointer",
|
|
2759
|
+
eligible: { capability: "creates-shapes" },
|
|
2760
|
+
requires: ["scene", "view"],
|
|
2761
|
+
invoker: {
|
|
2762
|
+
timing: "immediate",
|
|
2763
|
+
run: (deps, params) => {
|
|
2764
|
+
const plan = deps.scene ? planTrim(deps, params) : null;
|
|
2765
|
+
const scene = deps.scene;
|
|
2766
|
+
preview.show(plan && scene ? { path: plan.result.removed, scene } : null);
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2770
|
+
return {
|
|
2771
|
+
actions: [action],
|
|
2772
|
+
bindings: [{ spec: { kind: "hover" }, actionId: TRIM_PREVIEW_ID }],
|
|
2773
|
+
overlay: preview.layer,
|
|
2774
|
+
onDeactivate: () => preview.show(null)
|
|
2775
|
+
};
|
|
2776
|
+
}, []);
|
|
2777
|
+
return useStrokeClickTool(
|
|
2778
|
+
{ id: "trim", label: "Trim segment", icon: (
|
|
2779
|
+
/* @icon */
|
|
2780
|
+
"path-tools:trim"
|
|
2781
|
+
), hookName: "useTrimTool", actionId: "trimPath", keybinding: TRIM_KEY },
|
|
2782
|
+
options,
|
|
2783
|
+
extras
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
var EXTEND_KEY = { key: "U" };
|
|
2787
|
+
function useExtendTool(options = {}) {
|
|
2788
|
+
return useStrokeClickTool(
|
|
2789
|
+
{
|
|
2790
|
+
id: "extend",
|
|
2791
|
+
label: "Extend to next path",
|
|
2792
|
+
icon: (
|
|
2793
|
+
/* @icon */
|
|
2794
|
+
"path-tools:extend"
|
|
2795
|
+
),
|
|
2796
|
+
hookName: "useExtendTool",
|
|
2797
|
+
actionId: "extendToMeet",
|
|
2798
|
+
keybinding: EXTEND_KEY
|
|
2799
|
+
},
|
|
2800
|
+
options
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
var SMUDGE_KEY = { key: "S" };
|
|
2804
|
+
function useSmudgeBrushTool(options = {}) {
|
|
2805
|
+
const { keybinding = SMUDGE_KEY } = options;
|
|
2806
|
+
return useMemo(() => defineTool({
|
|
2807
|
+
id: "smudgeBrush",
|
|
2808
|
+
...keybinding ? { keybinding } : {},
|
|
2809
|
+
capabilities: ["creates-shapes"],
|
|
2810
|
+
hookName: "useSmudgeBrushTool",
|
|
2811
|
+
cursor: "crosshair",
|
|
2812
|
+
presentation: { label: "Smudge brush", group: "path", icon: (
|
|
2813
|
+
/* @icon */
|
|
2814
|
+
"path-tools:smudgeBrush"
|
|
2815
|
+
) },
|
|
2816
|
+
bindings: [{ spec: { kind: "drag" }, actionId: "smudgeBrush" }]
|
|
2817
|
+
}), [keybinding]);
|
|
2818
|
+
}
|
|
2819
|
+
var warpLayerId = (kind) => `warp-${kind}`;
|
|
2820
|
+
var HIT_PX = 8;
|
|
2821
|
+
var SIZE_PX = DEFAULT_OVERLAY_STYLE.anchorSizePx;
|
|
2822
|
+
var PIN_RADIUS_PX = DEFAULT_OVERLAY_STYLE.handleDotRadiusPx;
|
|
2823
|
+
var ACCENT = DEFAULT_OVERLAY_STYLE.anchorStroke;
|
|
2824
|
+
var LINE = { paint: { kind: "solid", color: ACCENT }, width: 1 };
|
|
2825
|
+
var LINE_SAMPLES = 12;
|
|
2826
|
+
var IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
2827
|
+
function groupsOf(src, state) {
|
|
2828
|
+
const scene = src.scene();
|
|
2829
|
+
if (!scene || !src.active()) return [];
|
|
2830
|
+
const nodes = pathNodesOf(scene, state.selection, (id) => src.live.get(id));
|
|
2831
|
+
const { held, bare, box } = splitByWarp(nodes, src.kind);
|
|
2832
|
+
const out = [];
|
|
2833
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2834
|
+
for (const t of held) {
|
|
2835
|
+
if (seen.has(t.held.id)) continue;
|
|
2836
|
+
seen.add(t.held.id);
|
|
2837
|
+
out.push({ warpId: t.held.id, warp: t.held.warp, source: t.held.source, toWorld: t.toWorld });
|
|
2838
|
+
}
|
|
2839
|
+
if (box) out.push({ warpId: null, warp: freshWarp(src.kind, box, src.settings()), source: bare[0].path, toWorld: IDENTITY });
|
|
2840
|
+
return out;
|
|
2841
|
+
}
|
|
2842
|
+
var toWorld = (m, p) => {
|
|
2843
|
+
const [x, y] = applyToPoint(m, p.x, p.y);
|
|
2844
|
+
return { x, y };
|
|
2845
|
+
};
|
|
2846
|
+
function pinPaint(view, at2) {
|
|
2847
|
+
const [x, y] = worldToScreen(at2.x, at2.y, viewToTransform(view));
|
|
2848
|
+
const r = PIN_RADIUS_PX;
|
|
2849
|
+
return [{
|
|
2850
|
+
kind: "path",
|
|
2851
|
+
path: ellipsePath({ x: x - r, y: y - r, width: 2 * r, height: 2 * r }),
|
|
2852
|
+
fill: { paint: { kind: "solid", color: "#ffffff" } },
|
|
2853
|
+
stroke: { paint: { kind: "solid", color: ACCENT }, width: 2 }
|
|
2854
|
+
}];
|
|
2855
|
+
}
|
|
2856
|
+
function guideLines(group) {
|
|
2857
|
+
const { warp, toWorld: m } = group;
|
|
2858
|
+
if (warp.kind === "distort") {
|
|
2859
|
+
const c = warp.corners.map((p) => toWorld(m, p));
|
|
2860
|
+
return [[...c, c[0]]];
|
|
2861
|
+
}
|
|
2862
|
+
if (warp.kind !== "envelope") return [];
|
|
2863
|
+
const map = pathWarpMap(warp, group.source);
|
|
2864
|
+
const { box, mesh } = warp;
|
|
2865
|
+
const at2 = (u, v) => toWorld(m, map({ x: box.x + u * box.width, y: box.y + v * box.height }));
|
|
2866
|
+
const lines = [];
|
|
2867
|
+
for (let i = 0; i <= mesh.rows; i++) {
|
|
2868
|
+
const n = mesh.cols * LINE_SAMPLES;
|
|
2869
|
+
lines.push(Array.from({ length: n + 1 }, (_, k) => at2(k / n, i / mesh.rows)));
|
|
2870
|
+
}
|
|
2871
|
+
for (let j = 0; j <= mesh.cols; j++) {
|
|
2872
|
+
const n = mesh.rows * LINE_SAMPLES;
|
|
2873
|
+
lines.push(Array.from({ length: n + 1 }, (_, k) => at2(j / mesh.cols, k / n)));
|
|
2874
|
+
}
|
|
2875
|
+
return lines;
|
|
2876
|
+
}
|
|
2877
|
+
function warpAffordance(src) {
|
|
2878
|
+
const id = warpLayerId(src.kind);
|
|
2879
|
+
return {
|
|
2880
|
+
id,
|
|
2881
|
+
regions(state) {
|
|
2882
|
+
const out = [];
|
|
2883
|
+
for (const group of groupsOf(src, state)) {
|
|
2884
|
+
warpControls(group.warp).forEach((local, index) => {
|
|
2885
|
+
const p = toWorld(group.toWorld, local);
|
|
2886
|
+
out.push({
|
|
2887
|
+
id: `${group.warpId ?? "fresh"}:${index}`,
|
|
2888
|
+
targetId: null,
|
|
2889
|
+
shape: { kind: "point", x: p.x, y: p.y, hitRadiusPx: HIT_PX },
|
|
2890
|
+
paint: group.warp.kind === "puppet" ? { kind: "custom", draw: ({ world, view }) => pinPaint(view, world) } : {
|
|
2891
|
+
kind: "square",
|
|
2892
|
+
sizePx: SIZE_PX,
|
|
2893
|
+
fill: { paint: { kind: "solid", color: "#ffffff" } },
|
|
2894
|
+
stroke: { paint: { kind: "solid", color: ACCENT }, width: 1 }
|
|
2895
|
+
},
|
|
2896
|
+
cursor: "move",
|
|
2897
|
+
strength: "exclusive",
|
|
2898
|
+
claimedKinds: ["pointer"],
|
|
2899
|
+
bind: () => ({ initialScratch: { warpId: group.warpId, index } })
|
|
2900
|
+
});
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
return out;
|
|
2904
|
+
},
|
|
2905
|
+
decorate(state, view) {
|
|
2906
|
+
const t = viewToTransform(view);
|
|
2907
|
+
const out = [];
|
|
2908
|
+
for (const group of groupsOf(src, state)) {
|
|
2909
|
+
for (const line3 of guideLines(group)) {
|
|
2910
|
+
const screen = line3.map((p) => {
|
|
2911
|
+
const [x, y] = worldToScreen(p.x, p.y, t);
|
|
2912
|
+
return { x, y };
|
|
2913
|
+
});
|
|
2914
|
+
out.push({ kind: "path", path: polylineFromPoints(screen), stroke: LINE });
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return out;
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
function createWarpHandleLayer(src) {
|
|
2922
|
+
const lines = { id: `${warpLayerId(src.kind)}:lines`, regions: () => [], decorate: warpAffordance(src).decorate };
|
|
2923
|
+
const controls = { ...warpAffordance(src), decorate: void 0 };
|
|
2924
|
+
return composeAffordanceLayer(warpLayerId(src.kind), "Warp controls", [lines, controls]);
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// src/warp/useWarpTool.ts
|
|
2928
|
+
var WARP_ICONS = { distort: (
|
|
2929
|
+
/* @icon */
|
|
2930
|
+
"path-tools:distort"
|
|
2931
|
+
), envelope: (
|
|
2932
|
+
/* @icon */
|
|
2933
|
+
"path-tools:envelope"
|
|
2934
|
+
), puppet: (
|
|
2935
|
+
/* @icon */
|
|
2936
|
+
"path-tools:puppetWarp"
|
|
2937
|
+
) };
|
|
2938
|
+
var ANY_MODS = { shift: "optional", alt: "optional", meta: "optional", ctrl: "optional" };
|
|
2939
|
+
function bindingsFor(kind, settings) {
|
|
2940
|
+
const target = `affordance:layer:${warpLayerId(kind)}`;
|
|
2941
|
+
const out = [];
|
|
2942
|
+
if (kind === "puppet") {
|
|
2943
|
+
out.push({ spec: { kind: "click", target, mods: { alt: true } }, actionId: "puppet.removePin" });
|
|
2944
|
+
out.push({ spec: { kind: "click", target: "selected-body" }, actionId: "puppet.addPin", opts: { params: settings } });
|
|
2945
|
+
}
|
|
2946
|
+
out.push(
|
|
2947
|
+
{ spec: { kind: "drag", target }, actionId: `${kind}.drag`, opts: { params: settings } },
|
|
2948
|
+
{ spec: { kind: "pointerDown", target, mods: ANY_MODS }, actionId: `${kind}.grab` },
|
|
2949
|
+
{ spec: { kind: "click", target }, actionId: `${kind}.grab` }
|
|
2950
|
+
);
|
|
2951
|
+
return out;
|
|
2952
|
+
}
|
|
2953
|
+
function definedOf(settings) {
|
|
2954
|
+
return Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== void 0));
|
|
2955
|
+
}
|
|
2956
|
+
var KEYS = { distort: { key: "F" }, envelope: { key: "F", shift: true }, puppet: { key: "B" } };
|
|
2957
|
+
function createWarpToolState() {
|
|
2958
|
+
let active = false;
|
|
2959
|
+
let api = null;
|
|
2960
|
+
let deps = null;
|
|
2961
|
+
const live = createWarpLive();
|
|
2962
|
+
return {
|
|
2963
|
+
live,
|
|
2964
|
+
active: () => active,
|
|
2965
|
+
scene: () => deps?.get("scene"),
|
|
2966
|
+
/** What an invocation of `actionId` passing `given` starts with here,
|
|
2967
|
+
* its tunings included. */
|
|
2968
|
+
paramsOf: (actionId, given) => deps?.get("actionParams")?.(actionId, given) ?? given,
|
|
2969
|
+
setActive(next) {
|
|
2970
|
+
active = next;
|
|
2971
|
+
api?.requestRedraw();
|
|
2972
|
+
},
|
|
2973
|
+
attachTo(nextApi, nextDeps) {
|
|
2974
|
+
api = nextApi;
|
|
2975
|
+
deps = nextDeps;
|
|
2976
|
+
}
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
function useWarpTool(kind, options, settings) {
|
|
2980
|
+
const { label, keybinding = KEYS[kind] } = options;
|
|
2981
|
+
const settingsRef = useLatest(settings);
|
|
2982
|
+
const state = useMemo(() => createWarpToolState(), []);
|
|
2983
|
+
const attach = useMemo(() => {
|
|
2984
|
+
const layer = createWarpHandleLayer({
|
|
2985
|
+
kind,
|
|
2986
|
+
active: state.active,
|
|
2987
|
+
settings: () => state.paramsOf(`${kind}.drag`, definedOf(settingsRef.current)),
|
|
2988
|
+
scene: state.scene,
|
|
2989
|
+
live: state.live
|
|
2990
|
+
});
|
|
2991
|
+
return (api, deps) => {
|
|
2992
|
+
state.attachTo(api, deps);
|
|
2993
|
+
const unregister = api.registerLayer(layer);
|
|
2994
|
+
const unlisten = state.live.subscribe(() => api.requestRedraw());
|
|
2995
|
+
return () => {
|
|
2996
|
+
unlisten();
|
|
2997
|
+
unregister();
|
|
2998
|
+
state.attachTo(null, null);
|
|
2999
|
+
};
|
|
3000
|
+
};
|
|
3001
|
+
}, [kind, settingsRef, state]);
|
|
3002
|
+
const actions = useMemo(() => [
|
|
3003
|
+
createWarpDragAction(kind, state.live),
|
|
3004
|
+
createWarpGrabAction(kind),
|
|
3005
|
+
...kind === "puppet" ? [createAddPinAction(), createRemovePinAction()] : []
|
|
3006
|
+
], [kind, state]);
|
|
3007
|
+
return useMemo(() => {
|
|
3008
|
+
const tool = defineTool({
|
|
3009
|
+
id: kind,
|
|
3010
|
+
...keybinding ? { keybinding } : {},
|
|
3011
|
+
hookName: `use${kind[0].toUpperCase()}${kind.slice(1)}Tool`,
|
|
3012
|
+
capabilities: ["creates-shapes"],
|
|
3013
|
+
cursor: kind === "puppet" ? "crosshair" : "default",
|
|
3014
|
+
actions,
|
|
3015
|
+
presentation: { label: label ?? WARP_LABELS[kind], group: "warp", icon: WARP_ICONS[kind] },
|
|
3016
|
+
onActivate: () => state.setActive(true),
|
|
3017
|
+
onDeactivate: () => state.setActive(false),
|
|
3018
|
+
bindings: bindingsFor(kind, () => definedOf(settingsRef.current))
|
|
3019
|
+
});
|
|
3020
|
+
return Object.assign(tool, { attach });
|
|
3021
|
+
}, [actions, attach, keybinding, kind, label, settingsRef, state]);
|
|
3022
|
+
}
|
|
3023
|
+
function useDistortTool(options = {}) {
|
|
3024
|
+
const { mode, tolerance } = options;
|
|
3025
|
+
return useWarpTool("distort", options, { mode, tolerance });
|
|
3026
|
+
}
|
|
3027
|
+
function useEnvelopeTool(options = {}) {
|
|
3028
|
+
const { rows, cols, mapping, tolerance } = options;
|
|
3029
|
+
return useWarpTool("envelope", options, { rows, cols, mapping, tolerance });
|
|
3030
|
+
}
|
|
3031
|
+
function usePuppetTool(options = {}) {
|
|
3032
|
+
return useWarpTool("puppet", options, { tolerance: options.tolerance });
|
|
3033
|
+
}
|
|
3034
|
+
var LABEL5 = "Shape Builder";
|
|
3035
|
+
function planShapeBuilder(adapter, opts = {}) {
|
|
3036
|
+
const operands = resolveBooleanOperands(adapter, opts);
|
|
3037
|
+
if ("kind" in operands) return operands;
|
|
3038
|
+
const { sources } = operands;
|
|
3039
|
+
const cut = pathFacePieces(...sources.map((s) => s.path)).filter((p) => p.path.commands.length > 0).map((p) => ({ path: p.path, source: p.sources[p.sources.length - 1] }));
|
|
3040
|
+
if (cut.length === 0) return { kind: "noop", reason: "empty-result" };
|
|
3041
|
+
const faces = cut.map(({ path, source }) => {
|
|
3042
|
+
const { id, paint } = sources[source];
|
|
3043
|
+
return paint ? { path, sourceId: id, paint } : { path, sourceId: id };
|
|
3044
|
+
});
|
|
3045
|
+
return {
|
|
3046
|
+
kind: "faces",
|
|
3047
|
+
faces,
|
|
3048
|
+
faceAt: (x, y) => faces.findIndex((f) => pointInPath(f.path, x, y)),
|
|
3049
|
+
commit(picked, mode) {
|
|
3050
|
+
const chosen = [...new Set(picked)].filter((i) => i >= 0 && i < cut.length);
|
|
3051
|
+
if (chosen.length === 0) return { kind: "noop", reason: "empty-result" };
|
|
3052
|
+
const taken = new Set(chosen);
|
|
3053
|
+
const pieces = cut.flatMap((c, i) => taken.has(i) ? [] : [{ ...c, producedBy: "divide" }]);
|
|
3054
|
+
if (mode === "merge") {
|
|
3055
|
+
const first = cut[chosen[0]];
|
|
3056
|
+
const path = chosen.length === 1 ? first.path : pathUnion(...chosen.map((i) => cut[i].path));
|
|
3057
|
+
pieces.push({ path, source: first.source, producedBy: "union" });
|
|
3058
|
+
}
|
|
3059
|
+
pieces.sort((a, b) => a.source - b.source);
|
|
3060
|
+
return commitBooleanPieces(adapter, operands, pieces, LABEL5);
|
|
3061
|
+
}
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
function facesAlong(plan, run, step) {
|
|
3065
|
+
const out = [];
|
|
3066
|
+
const visit = (x, y) => {
|
|
3067
|
+
const i = plan.faceAt(x, y);
|
|
3068
|
+
if (i >= 0 && !out.includes(i)) out.push(i);
|
|
3069
|
+
};
|
|
3070
|
+
if (run.length === 1) visit(run[0].x, run[0].y);
|
|
3071
|
+
for (let k = 1; k < run.length; k++) {
|
|
3072
|
+
const a = run[k - 1];
|
|
3073
|
+
const b = run[k];
|
|
3074
|
+
const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / step));
|
|
3075
|
+
for (let s = k === 1 ? 0 : 1; s <= n; s++) visit(a.x + (b.x - a.x) * s / n, a.y + (b.y - a.y) * s / n);
|
|
3076
|
+
}
|
|
3077
|
+
return out;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// src/shapeBuilder/shapeBuilderActions.ts
|
|
3081
|
+
var SAMPLE_PX = 2;
|
|
3082
|
+
function createPlanCache(opts) {
|
|
3083
|
+
let key = null;
|
|
3084
|
+
let plan = null;
|
|
3085
|
+
return {
|
|
3086
|
+
get(deps) {
|
|
3087
|
+
const adapter = deps.booleansAdapter;
|
|
3088
|
+
if (!adapter) return null;
|
|
3089
|
+
const scale = deps.view?.get().scale;
|
|
3090
|
+
const o = { ...opts(), ...scale ? { scale } : {} };
|
|
3091
|
+
const sel = [...adapter.getSelection()];
|
|
3092
|
+
const next = JSON.stringify([o, sel.map((id) => [
|
|
3093
|
+
id,
|
|
3094
|
+
adapter.getWorldPath(id) ?? adapter.getTextSource?.(id) ?? null,
|
|
3095
|
+
adapter.getPaint?.(id) ?? null
|
|
3096
|
+
])]);
|
|
3097
|
+
if (next !== key) {
|
|
3098
|
+
key = next;
|
|
3099
|
+
const planned = planShapeBuilder(adapter, o);
|
|
3100
|
+
plan = planned.kind === "faces" ? planned : null;
|
|
3101
|
+
}
|
|
3102
|
+
return plan;
|
|
3103
|
+
},
|
|
3104
|
+
clear() {
|
|
3105
|
+
key = null;
|
|
3106
|
+
plan = null;
|
|
3107
|
+
}
|
|
3108
|
+
};
|
|
3109
|
+
}
|
|
3110
|
+
var enoughSelected = (deps) => {
|
|
3111
|
+
if (!deps?.booleansAdapter) return ActionDisabledReason.NotApplicable;
|
|
3112
|
+
return (deps.selection?.get().length ?? 0) >= 2 ? true : ActionDisabledReason.SelectionRequired;
|
|
3113
|
+
};
|
|
3114
|
+
var OTHER = { merge: "delete", delete: "merge" };
|
|
3115
|
+
function createShapeBuilderActions(overlay, settings) {
|
|
3116
|
+
const plans = createPlanCache(() => settings().op);
|
|
3117
|
+
const modeFor = (alt) => alt ? OTHER[settings().mode] : settings().mode;
|
|
3118
|
+
const show = (plan, faces, role) => {
|
|
3119
|
+
overlay.set(faces.map((i) => ({ path: plan.faces[i].path, role })));
|
|
3120
|
+
};
|
|
3121
|
+
const eligible = { capability: "edits-page" };
|
|
3122
|
+
const requires = ["booleansAdapter", "selection", "view"];
|
|
3123
|
+
const hover = {
|
|
3124
|
+
id: "shapeBuilder.hover",
|
|
3125
|
+
label: "Shape Builder \u2014 show the face under the pointer",
|
|
3126
|
+
eligible,
|
|
3127
|
+
requires,
|
|
3128
|
+
invoker: {
|
|
3129
|
+
timing: "immediate",
|
|
3130
|
+
run: (deps, params) => {
|
|
3131
|
+
const p = params;
|
|
3132
|
+
const plan = enoughSelected(deps) === true ? plans.get(deps) : null;
|
|
3133
|
+
const at2 = plan && p?.worldX !== void 0 && p.worldY !== void 0 ? plan.faceAt(p.worldX, p.worldY) : -1;
|
|
3134
|
+
if (!plan || at2 < 0) overlay.clear();
|
|
3135
|
+
else show(plan, [at2], modeFor(p?.mods?.alt) === "delete" ? "remove" : "chrome");
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
};
|
|
3139
|
+
const leave = {
|
|
3140
|
+
id: "shapeBuilder.leave",
|
|
3141
|
+
label: "Shape Builder \u2014 clear the shown face",
|
|
3142
|
+
eligible,
|
|
3143
|
+
invoker: { timing: "immediate", run: () => overlay.clear() }
|
|
3144
|
+
};
|
|
3145
|
+
const click = {
|
|
3146
|
+
id: "shapeBuilder.click",
|
|
3147
|
+
label: "Shape Builder \u2014 take the face clicked",
|
|
3148
|
+
eligible,
|
|
3149
|
+
requires,
|
|
3150
|
+
enabled: enoughSelected,
|
|
3151
|
+
invoker: {
|
|
3152
|
+
timing: "immediate",
|
|
3153
|
+
run: (deps, params) => {
|
|
3154
|
+
const p = params;
|
|
3155
|
+
const plan = plans.get(deps);
|
|
3156
|
+
if (!plan || p?.pressX === void 0 || p.pressY === void 0) return;
|
|
3157
|
+
const at2 = plan.faceAt(p.pressX, p.pressY);
|
|
3158
|
+
if (at2 < 0) return;
|
|
3159
|
+
overlay.clear();
|
|
3160
|
+
plans.clear();
|
|
3161
|
+
plan.commit([at2], modeFor(p.mods?.alt));
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
};
|
|
3165
|
+
const drag = {
|
|
3166
|
+
id: "shapeBuilder",
|
|
3167
|
+
label: "Shape Builder",
|
|
3168
|
+
eligible,
|
|
3169
|
+
requires,
|
|
3170
|
+
enabled: enoughSelected,
|
|
3171
|
+
invoker: {
|
|
3172
|
+
timing: "ongoing",
|
|
3173
|
+
start: (ctx) => {
|
|
3174
|
+
const plan = plans.get(ctx.deps);
|
|
3175
|
+
if (!plan) return {};
|
|
3176
|
+
const scale = ctx.deps.view?.get().scale ?? { x: 1, y: 1 };
|
|
3177
|
+
const step = SAMPLE_PX / Math.max(1e-9, (Math.abs(scale.x) + Math.abs(scale.y)) / 2);
|
|
3178
|
+
let last = ctx.drag?.start ?? ctx.world;
|
|
3179
|
+
const trail = [last];
|
|
3180
|
+
const picked = facesAlong(plan, [last], step);
|
|
3181
|
+
let alt = ctx.modifiers.alt;
|
|
3182
|
+
const paint = () => show(plan, picked, modeFor(alt) === "delete" ? "remove" : "picked");
|
|
3183
|
+
paint();
|
|
3184
|
+
return {
|
|
3185
|
+
kind: "shapeBuilder",
|
|
3186
|
+
onMove: (c) => {
|
|
3187
|
+
const at2 = c.drag?.current ?? c.world;
|
|
3188
|
+
for (const i of facesAlong(plan, [last, at2], step)) if (!picked.includes(i)) picked.push(i);
|
|
3189
|
+
last = at2;
|
|
3190
|
+
trail.push(at2);
|
|
3191
|
+
alt = c.modifiers.alt;
|
|
3192
|
+
paint();
|
|
3193
|
+
},
|
|
3194
|
+
onEnd: (_c, reason) => {
|
|
3195
|
+
overlay.clear();
|
|
3196
|
+
plans.clear();
|
|
3197
|
+
if (reason === "commit") plan.commit(picked, modeFor(alt));
|
|
3198
|
+
},
|
|
3199
|
+
overlay: () => trail.length >= 2 ? { kind: "polyline", points: trail } : null
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
return {
|
|
3205
|
+
actions: [hover, leave, click, drag],
|
|
3206
|
+
reset: () => {
|
|
3207
|
+
overlay.clear();
|
|
3208
|
+
plans.clear();
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
// src/shapeBuilder/useShapeBuilderTool.ts
|
|
3214
|
+
function useShapeBuilderTool(options = {}) {
|
|
3215
|
+
const settingsRef = useLatest({ op: { strokes: options.strokes ?? PATHFINDER_STROKES }, mode: options.mode ?? "merge" });
|
|
3216
|
+
const overlay = useMemo(() => createPathOverlay({ id: "shape-builder-faces", label: "Shape Builder \u2014 faces" }), []);
|
|
3217
|
+
const built = useMemo(
|
|
3218
|
+
() => createShapeBuilderActions(overlay, () => settingsRef.current),
|
|
3219
|
+
[overlay, settingsRef]
|
|
3220
|
+
);
|
|
3221
|
+
const { keybinding } = options;
|
|
3222
|
+
return useMemo(() => defineTool({
|
|
3223
|
+
id: "shapeBuilder",
|
|
3224
|
+
capabilities: ["edits-page"],
|
|
3225
|
+
hookName: "useShapeBuilderTool",
|
|
3226
|
+
cursor: "crosshair",
|
|
3227
|
+
presentation: { label: "Shape Builder", icon: (
|
|
3228
|
+
/* @icon */
|
|
3229
|
+
"core:union"
|
|
3230
|
+
), group: "shape" },
|
|
3231
|
+
...keybinding === null ? {} : { keybinding: keybinding ?? { key: "M", shift: true } },
|
|
3232
|
+
actions: built.actions,
|
|
3233
|
+
overlay: overlay.layer,
|
|
3234
|
+
onDeactivate: built.reset,
|
|
3235
|
+
bindings: [
|
|
3236
|
+
{ spec: { kind: "hover", mods: { alt: "optional" } }, actionId: "shapeBuilder.hover" },
|
|
3237
|
+
{ spec: { kind: "hover", motion: "leave", mods: { alt: "optional" } }, actionId: "shapeBuilder.leave" },
|
|
3238
|
+
{ spec: { kind: "click", mods: { alt: "optional" } }, actionId: "shapeBuilder.click" },
|
|
3239
|
+
// Alt is read live, so it can be pressed or let go mid-drag.
|
|
3240
|
+
{ spec: { kind: "drag", mods: { alt: "optional" } }, actionId: "shapeBuilder", opts: { yields: true } },
|
|
3241
|
+
{ spec: { kind: "drag", mods: { shift: "optional" } }, actionId: "areaSelect" }
|
|
3242
|
+
]
|
|
3243
|
+
}), [built, overlay, keybinding]);
|
|
3244
|
+
}
|
|
3245
|
+
function anchorPoints(path) {
|
|
3246
|
+
return pathToAnchors(path).anchors.flat().map((a) => ({ x: a.x, y: a.y }));
|
|
3247
|
+
}
|
|
3248
|
+
function isWidthSet(entry) {
|
|
3249
|
+
const { left, right } = vertexHalfWidths(entry, NaN);
|
|
3250
|
+
return !Number.isNaN(left) || !Number.isNaN(right);
|
|
3251
|
+
}
|
|
3252
|
+
function targetOf(scene, node, scale) {
|
|
3253
|
+
if (node.kind !== "leaf" || node.derivePath || node.derivePose || scene.isLocked(node.id) || scene.isHidden(node.id)) return null;
|
|
3254
|
+
const stroke = node.data?.stroke;
|
|
3255
|
+
if (!stroke?.paint) return null;
|
|
3256
|
+
const path = resolveEditablePathOf(node);
|
|
3257
|
+
return path ? { id: node.id, node, path, stroke, fallback: resolveStrokeWidth(stroke.width ?? 1, scale) } : null;
|
|
3258
|
+
}
|
|
3259
|
+
function edgeAt(scene, x, y, scale, accept) {
|
|
3260
|
+
const nodes = scene.renderOrderNodes();
|
|
3261
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
3262
|
+
const target = targetOf(scene, nodes[i], scale);
|
|
3263
|
+
if (!target) continue;
|
|
3264
|
+
const edge = strokeEdgeNear(target.path, target.stroke, x, y, { scale });
|
|
3265
|
+
if (edge && accept(edge)) return { target, edge };
|
|
3266
|
+
}
|
|
3267
|
+
return null;
|
|
3268
|
+
}
|
|
3269
|
+
function widthTargetAt(scene, x, y, scale, reach) {
|
|
3270
|
+
return edgeAt(scene, x, y, scale, (edge) => edge.gap <= reach)?.target ?? null;
|
|
3271
|
+
}
|
|
3272
|
+
function widthEdgeAt(scene, x, y, scale, reach) {
|
|
3273
|
+
const found2 = edgeAt(scene, x, y, scale, (edge2) => Math.abs(edge2.gap) <= reach);
|
|
3274
|
+
if (!found2) return null;
|
|
3275
|
+
const { edge } = found2;
|
|
3276
|
+
let index = null;
|
|
3277
|
+
let bestD = reach;
|
|
3278
|
+
const anchors = anchorPoints(found2.target.path);
|
|
3279
|
+
anchors.forEach((p, i) => {
|
|
3280
|
+
const d = Math.hypot(p.x - edge.x, p.y - edge.y);
|
|
3281
|
+
if (d <= bestD) {
|
|
3282
|
+
bestD = d;
|
|
3283
|
+
index = i;
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
return { ...found2, index, at: index === null ? { x: edge.x, y: edge.y } : anchors[index] };
|
|
3287
|
+
}
|
|
3288
|
+
function widthsOf(target, count2) {
|
|
3289
|
+
const own = target.stroke.vertexWidths ?? [];
|
|
3290
|
+
return Array.from({ length: count2 }, (_, i) => isWidthSet(own[i]) ? own[i] : NaN);
|
|
3291
|
+
}
|
|
3292
|
+
function anchorNormals(path) {
|
|
3293
|
+
const set = pathToAnchors(path);
|
|
3294
|
+
return set.anchors.flatMap((sub, s) => sub.map((a, i) => {
|
|
3295
|
+
const closed = set.closed?.[s] ?? false;
|
|
3296
|
+
const next = sub[i + 1] ?? (closed ? sub[0] : void 0);
|
|
3297
|
+
const prev = sub[i - 1] ?? (closed ? sub[sub.length - 1] : void 0);
|
|
3298
|
+
const out = a.outHandle ?? next;
|
|
3299
|
+
const inn = a.inHandle ?? prev;
|
|
3300
|
+
let tx = 0, ty = 0;
|
|
3301
|
+
for (const [from, to] of [[a, out], [inn, a]]) {
|
|
3302
|
+
if (!from || !to) continue;
|
|
3303
|
+
const len2 = Math.hypot(to.x - from.x, to.y - from.y);
|
|
3304
|
+
if (len2 > 0) {
|
|
3305
|
+
tx += (to.x - from.x) / len2;
|
|
3306
|
+
ty += (to.y - from.y) / len2;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
const len = Math.hypot(tx, ty);
|
|
3310
|
+
return len > 0 ? { x: -ty / len, y: tx / len } : { x: 0, y: 1 };
|
|
3311
|
+
}));
|
|
3312
|
+
}
|
|
3313
|
+
function widthPointNear(target, x, y, reach) {
|
|
3314
|
+
const anchors = anchorPoints(target.path);
|
|
3315
|
+
const normals = anchorNormals(target.path);
|
|
3316
|
+
const widths = widthsOf(target, anchors.length);
|
|
3317
|
+
let best = -1;
|
|
3318
|
+
let bestD = reach;
|
|
3319
|
+
anchors.forEach((a, i) => {
|
|
3320
|
+
if (!isWidthSet(widths[i])) return;
|
|
3321
|
+
const { left, right } = vertexHalfWidths(widths[i], target.fallback);
|
|
3322
|
+
const n = normals[i];
|
|
3323
|
+
const along2 = Math.min(right, Math.max(-left, (x - a.x) * n.x + (y - a.y) * n.y));
|
|
3324
|
+
const d = Math.hypot(x - (a.x + n.x * along2), y - (a.y + n.y * along2));
|
|
3325
|
+
if (d <= bestD) {
|
|
3326
|
+
bestD = d;
|
|
3327
|
+
best = i;
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
return best;
|
|
3331
|
+
}
|
|
3332
|
+
function gripAt(hit) {
|
|
3333
|
+
const { target, edge } = hit;
|
|
3334
|
+
const count2 = anchorPoints(target.path).length;
|
|
3335
|
+
const widths = widthsOf(target, count2);
|
|
3336
|
+
const factor = edge.factor > 0.05 ? edge.factor : 1;
|
|
3337
|
+
const base = { target, side: edge.side, normal: { x: edge.nx, y: edge.ny }, factor };
|
|
3338
|
+
if (hit.index !== null) {
|
|
3339
|
+
const start2 = vertexHalfWidths(widths[hit.index], target.fallback);
|
|
3340
|
+
return { ...base, path: target.path, widths, index: hit.index, at: hit.at, inserted: false, start: start2 };
|
|
3341
|
+
}
|
|
3342
|
+
const set = pathToAnchors(target.path);
|
|
3343
|
+
const seg = nearestSegmentT(set.anchors, edge.x, edge.y, set.closed);
|
|
3344
|
+
if (!seg) return null;
|
|
3345
|
+
let inserted = -1;
|
|
3346
|
+
const next = editAnchorSetTracked(target.path, (s) => {
|
|
3347
|
+
inserted = insertAnchorOnSegment(s, seg);
|
|
3348
|
+
return inserted !== -1;
|
|
3349
|
+
});
|
|
3350
|
+
if (!next || inserted < 0) return null;
|
|
3351
|
+
const start = { left: edge.left / factor, right: edge.right / factor };
|
|
3352
|
+
widths.splice(inserted, 0, widths.some(isWidthSet) ? vertexWidthFromSides(start.left, start.right) : NaN);
|
|
3353
|
+
return { ...base, path: next.path, widths, index: inserted, at: anchorPoints(next.path)[inserted], inserted: true, start };
|
|
3354
|
+
}
|
|
3355
|
+
function movedSide(grip, delta) {
|
|
3356
|
+
return Math.max(0, grip.start[grip.side] + delta / grip.factor);
|
|
3357
|
+
}
|
|
3358
|
+
function draggedWidth(grip, moved, oneSided) {
|
|
3359
|
+
if (!oneSided) return 2 * moved;
|
|
3360
|
+
return grip.side === "left" ? vertexWidthFromSides(moved, grip.start.right) : vertexWidthFromSides(grip.start.left, moved);
|
|
3361
|
+
}
|
|
3362
|
+
function storedWith(grip, widths) {
|
|
3363
|
+
const { node, stroke } = grip.target;
|
|
3364
|
+
const shaped = grip.inserted ? withPath(node, grip.path) : { pose: node.pose, data: node.data };
|
|
3365
|
+
if (!shaped) return null;
|
|
3366
|
+
const { vertexWidths: _old, ...rest } = stroke;
|
|
3367
|
+
const next = widths.some(isWidthSet) ? { ...rest, vertexWidths: [...widths] } : rest;
|
|
3368
|
+
return { pose: shaped.pose, data: { ...shaped.data, stroke: next } };
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// src/girth/girthActions.ts
|
|
3372
|
+
var GIRTH_DRAG_ID = "girth.drag";
|
|
3373
|
+
var GIRTH_CLEAR_ID = "girth.clear";
|
|
3374
|
+
var GIRTH_HOVER_ID = "girth.hover";
|
|
3375
|
+
var GIRTH_UNHOVER_ID = "girth.unhover";
|
|
3376
|
+
var LABEL6 = "Stroke width";
|
|
3377
|
+
var REQUIRES2 = ["scene", "view", "selection", "applyOps"];
|
|
3378
|
+
var found = (deps, env) => {
|
|
3379
|
+
const scene = deps.scene;
|
|
3380
|
+
if (!scene) return null;
|
|
3381
|
+
const s = deps.view?.get().scale ?? { x: 1, y: 1 };
|
|
3382
|
+
const e = pxExtent(env.reachPx(), s);
|
|
3383
|
+
return { scene, scale: meanScale(s), reach: Math.max(e.x, e.y) };
|
|
3384
|
+
};
|
|
3385
|
+
var focusOf = (path, widths, fallback, grab) => ({
|
|
3386
|
+
anchors: pathToAnchors(path).anchors.flat().map((a) => ({ x: a.x, y: a.y })),
|
|
3387
|
+
normals: anchorNormals(path),
|
|
3388
|
+
widths,
|
|
3389
|
+
fallback,
|
|
3390
|
+
...grab ? { grab } : {}
|
|
3391
|
+
});
|
|
3392
|
+
var restingFocus = (target) => focusOf(target.path, widthsOf(target, pathToAnchors(target.path).anchors.flat().length), target.fallback);
|
|
3393
|
+
function dropGrab(env) {
|
|
3394
|
+
const last = env.focus();
|
|
3395
|
+
if (!last?.grab) return;
|
|
3396
|
+
const { grab: _grab, ...rest } = last;
|
|
3397
|
+
env.setFocus(rest);
|
|
3398
|
+
}
|
|
3399
|
+
function commit(deps, grip, widths) {
|
|
3400
|
+
const next = storedWith(grip, widths);
|
|
3401
|
+
if (next) commitOps(deps, reshapeTo(grip.target.node, next, LABEL6), LABEL6);
|
|
3402
|
+
}
|
|
3403
|
+
var edgeOf = (grip, moved) => ({
|
|
3404
|
+
x: grip.at.x + grip.normal.x * moved * grip.factor,
|
|
3405
|
+
y: grip.at.y + grip.normal.y * moved * grip.factor
|
|
3406
|
+
});
|
|
3407
|
+
function dragAction(env) {
|
|
3408
|
+
return {
|
|
3409
|
+
timing: "ongoing",
|
|
3410
|
+
start: ({ deps, drag, world }) => {
|
|
3411
|
+
const press = drag?.start ?? world;
|
|
3412
|
+
const at2 = found(deps, env);
|
|
3413
|
+
const hit = at2 && widthEdgeAt(at2.scene, press.x, press.y, at2.scale, at2.reach);
|
|
3414
|
+
const grip = hit && gripAt(hit);
|
|
3415
|
+
if (!grip) return {};
|
|
3416
|
+
const { fallback } = grip.target;
|
|
3417
|
+
let widths = null;
|
|
3418
|
+
let preview = null;
|
|
3419
|
+
env.setFocus(focusOf(grip.path, grip.widths, fallback, { edge: edgeOf(grip, grip.start[grip.side]), at: grip.at }));
|
|
3420
|
+
return {
|
|
3421
|
+
kind: GIRTH_DRAG_ID,
|
|
3422
|
+
onMove: (move) => {
|
|
3423
|
+
const p = move.drag?.current ?? move.world;
|
|
3424
|
+
const moved = movedSide(grip, (p.x - press.x) * grip.normal.x + (p.y - press.y) * grip.normal.y);
|
|
3425
|
+
widths = [...grip.widths];
|
|
3426
|
+
widths[grip.index] = draggedWidth(grip, moved, move.modifiers.alt);
|
|
3427
|
+
preview = storedWith(grip, widths);
|
|
3428
|
+
env.setFocus(focusOf(grip.path, widths, fallback, { edge: edgeOf(grip, moved), at: grip.at }));
|
|
3429
|
+
},
|
|
3430
|
+
// The dispatcher hands `onEnd` a context with empty deps.
|
|
3431
|
+
onEnd: (_end, reason) => {
|
|
3432
|
+
const done = widths;
|
|
3433
|
+
widths = null;
|
|
3434
|
+
preview = null;
|
|
3435
|
+
if (reason === "cancel" || done === null) {
|
|
3436
|
+
env.setFocus(restingFocus(grip.target));
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
commit(deps, grip, done);
|
|
3440
|
+
env.setFocus(focusOf(grip.path, done, fallback));
|
|
3441
|
+
},
|
|
3442
|
+
previewIds: () => preview ? [grip.target.id] : null,
|
|
3443
|
+
previewPose: (id) => preview && id === grip.target.id ? preview.pose : null,
|
|
3444
|
+
previewData: (id) => preview && id === grip.target.id ? preview.data : null
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
function createGirthActions(env) {
|
|
3450
|
+
const eligible = { capability: "edits-page" };
|
|
3451
|
+
const hasScene = (deps) => deps?.scene ? true : ActionDisabledReason.NotApplicable;
|
|
3452
|
+
const base = { eligible, requires: REQUIRES2, enabled: hasScene };
|
|
3453
|
+
return [
|
|
3454
|
+
{ id: GIRTH_DRAG_ID, label: "Girth \u2014 drag a stroke's edge", ...base, invoker: dragAction(env) },
|
|
3455
|
+
{
|
|
3456
|
+
id: GIRTH_CLEAR_ID,
|
|
3457
|
+
label: "Girth \u2014 clear the width point under the pointer",
|
|
3458
|
+
...base,
|
|
3459
|
+
invoker: {
|
|
3460
|
+
timing: "immediate",
|
|
3461
|
+
run: (deps, params) => {
|
|
3462
|
+
const p = params;
|
|
3463
|
+
const at2 = found(deps, env);
|
|
3464
|
+
if (!at2 || p?.pressX === void 0 || p.pressY === void 0) return;
|
|
3465
|
+
const target = widthTargetAt(at2.scene, p.pressX, p.pressY, at2.scale, at2.reach);
|
|
3466
|
+
if (!target) return;
|
|
3467
|
+
const index = widthPointNear(target, p.pressX, p.pressY, at2.reach);
|
|
3468
|
+
const widths = widthsOf(target, pathToAnchors(target.path).anchors.flat().length);
|
|
3469
|
+
if (index < 0 || !isWidthSet(widths[index])) return;
|
|
3470
|
+
widths[index] = NaN;
|
|
3471
|
+
const grip = {
|
|
3472
|
+
target,
|
|
3473
|
+
path: target.path,
|
|
3474
|
+
inserted: false};
|
|
3475
|
+
commit(deps, grip, widths);
|
|
3476
|
+
env.setFocus(focusOf(target.path, widths, target.fallback));
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
},
|
|
3480
|
+
{
|
|
3481
|
+
id: GIRTH_HOVER_ID,
|
|
3482
|
+
label: "Girth \u2014 show the stroke edge under the pointer",
|
|
3483
|
+
...base,
|
|
3484
|
+
invoker: {
|
|
3485
|
+
timing: "immediate",
|
|
3486
|
+
run: (deps, params) => {
|
|
3487
|
+
const p = params;
|
|
3488
|
+
const at2 = found(deps, env);
|
|
3489
|
+
if (!at2 || p?.worldX === void 0 || p.worldY === void 0) return;
|
|
3490
|
+
const hit = widthEdgeAt(at2.scene, p.worldX, p.worldY, at2.scale, at2.reach);
|
|
3491
|
+
if (!hit) {
|
|
3492
|
+
dropGrab(env);
|
|
3493
|
+
return;
|
|
3494
|
+
}
|
|
3495
|
+
const { edge } = hit;
|
|
3496
|
+
const half = edge.side === "right" ? edge.right : edge.left;
|
|
3497
|
+
env.setFocus({
|
|
3498
|
+
...restingFocus(hit.target),
|
|
3499
|
+
grab: { edge: { x: edge.x + edge.nx * half, y: edge.y + edge.ny * half }, at: hit.at }
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
},
|
|
3504
|
+
{
|
|
3505
|
+
id: GIRTH_UNHOVER_ID,
|
|
3506
|
+
label: "Girth \u2014 hide the grab point",
|
|
3507
|
+
...base,
|
|
3508
|
+
invoker: {
|
|
3509
|
+
timing: "immediate",
|
|
3510
|
+
run: () => dropGrab(env)
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
];
|
|
3514
|
+
}
|
|
3515
|
+
var DOT_RADIUS_PX = 3.5;
|
|
3516
|
+
var GRAB_RADIUS_PX = 4.5;
|
|
3517
|
+
var RIM = "#10161d";
|
|
3518
|
+
var SET = "#ffffff";
|
|
3519
|
+
var UNSET = "#8a94a3";
|
|
3520
|
+
var ACCENT2 = "#2f80ed";
|
|
3521
|
+
var ACROSS = { paint: { color: ACCENT2 }, width: 1 };
|
|
3522
|
+
var circle2 = (x, y, r) => ellipsePath({ x: x - r, y: y - r, width: 2 * r, height: 2 * r });
|
|
3523
|
+
var line2 = (a, b) => ({
|
|
3524
|
+
kind: "path",
|
|
3525
|
+
path: polylineFromPoints([{ x: a[0], y: a[1] }, { x: b[0], y: b[1] }]),
|
|
3526
|
+
stroke: ACROSS
|
|
3527
|
+
});
|
|
3528
|
+
function createGirthOverlay(source) {
|
|
3529
|
+
return {
|
|
3530
|
+
id: "width-tool-anchors",
|
|
3531
|
+
label: "Width anchors",
|
|
3532
|
+
space: "screen",
|
|
3533
|
+
subscribe: source.subscribe,
|
|
3534
|
+
draw: (_data, view) => {
|
|
3535
|
+
const focus = source.focus();
|
|
3536
|
+
if (!focus) return [];
|
|
3537
|
+
const m = viewToTransform(view);
|
|
3538
|
+
const at2 = (x, y) => worldToScreen(x, y, m);
|
|
3539
|
+
const lines = [];
|
|
3540
|
+
const dots = [];
|
|
3541
|
+
focus.anchors.forEach((a, i) => {
|
|
3542
|
+
const set = isWidthSet(focus.widths[i]);
|
|
3543
|
+
if (set) {
|
|
3544
|
+
const { left, right } = vertexHalfWidths(focus.widths[i], focus.fallback);
|
|
3545
|
+
const n = focus.normals[i];
|
|
3546
|
+
lines.push(line2(at2(a.x - n.x * left, a.y - n.y * left), at2(a.x + n.x * right, a.y + n.y * right)));
|
|
3547
|
+
}
|
|
3548
|
+
const [x, y] = at2(a.x, a.y);
|
|
3549
|
+
dots.push({
|
|
3550
|
+
kind: "path",
|
|
3551
|
+
path: circle2(x, y, DOT_RADIUS_PX),
|
|
3552
|
+
fill: { paint: { kind: "solid", color: set ? SET : UNSET } },
|
|
3553
|
+
stroke: { paint: { color: RIM }, width: 1 }
|
|
3554
|
+
});
|
|
3555
|
+
});
|
|
3556
|
+
const grab = [];
|
|
3557
|
+
if (focus.grab) {
|
|
3558
|
+
const w = at2(focus.grab.at.x, focus.grab.at.y);
|
|
3559
|
+
const e = at2(focus.grab.edge.x, focus.grab.edge.y);
|
|
3560
|
+
grab.push(
|
|
3561
|
+
line2(w, e),
|
|
3562
|
+
{ kind: "path", path: circle2(w[0], w[1], GRAB_RADIUS_PX + 1.5), stroke: { paint: { color: ACCENT2 }, width: 1.5 } },
|
|
3563
|
+
{
|
|
3564
|
+
kind: "path",
|
|
3565
|
+
path: circle2(e[0], e[1], GRAB_RADIUS_PX),
|
|
3566
|
+
fill: { paint: { kind: "solid", color: ACCENT2 } },
|
|
3567
|
+
stroke: { paint: { color: SET }, width: 1 }
|
|
3568
|
+
}
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
return [...lines, ...dots, ...grab];
|
|
3572
|
+
}
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// src/girth/useGirthTool.ts
|
|
3577
|
+
function useGirthTool(options = {}) {
|
|
3578
|
+
const opts = useLatest({ reach: options.reach ?? 6 });
|
|
3579
|
+
const env = useMemo(() => {
|
|
3580
|
+
const changed = createEmitter();
|
|
3581
|
+
const held = { focus: null };
|
|
3582
|
+
const value = {
|
|
3583
|
+
focus: () => held.focus,
|
|
3584
|
+
setFocus: (next) => {
|
|
3585
|
+
held.focus = next;
|
|
3586
|
+
changed.emit();
|
|
3587
|
+
},
|
|
3588
|
+
reachPx: () => opts.current.reach,
|
|
3589
|
+
listen: changed.subscribe
|
|
3590
|
+
};
|
|
3591
|
+
return value;
|
|
3592
|
+
}, [opts]);
|
|
3593
|
+
const actions = useMemo(() => createGirthActions(env), [env]);
|
|
3594
|
+
const overlay = useMemo(() => createGirthOverlay({ focus: env.focus, subscribe: env.listen }), [env]);
|
|
3595
|
+
const { keybinding } = options;
|
|
3596
|
+
return useMemo(() => defineTool({
|
|
3597
|
+
id: "girth",
|
|
3598
|
+
capabilities: ["edits-page"],
|
|
3599
|
+
hookName: "useGirthTool",
|
|
3600
|
+
cursor: "crosshair",
|
|
3601
|
+
presentation: { label: "Girth", group: "shape", icon: (
|
|
3602
|
+
/* @icon */
|
|
3603
|
+
"path-tools:girth"
|
|
3604
|
+
) },
|
|
3605
|
+
...keybinding === null ? {} : { keybinding: keybinding ?? { key: "W", shift: true } },
|
|
3606
|
+
actions,
|
|
3607
|
+
overlay,
|
|
3608
|
+
onDeactivate: () => env.setFocus(null),
|
|
3609
|
+
bindings: [
|
|
3610
|
+
{ spec: { kind: "drag", mods: { shift: "optional", alt: "optional" } }, actionId: GIRTH_DRAG_ID },
|
|
3611
|
+
{ spec: { kind: "click", mods: { alt: true } }, actionId: GIRTH_CLEAR_ID },
|
|
3612
|
+
{ spec: { kind: "hover", mods: { alt: "optional", shift: "optional" } }, actionId: GIRTH_HOVER_ID },
|
|
3613
|
+
{ spec: { kind: "hover", motion: "leave" }, actionId: GIRTH_UNHOVER_ID }
|
|
3614
|
+
]
|
|
3615
|
+
}), [actions, overlay, keybinding, env]);
|
|
3616
|
+
}
|
|
3617
|
+
var MARKER_PX = 8;
|
|
3618
|
+
var CLOSE_HINT_PX = 8;
|
|
3619
|
+
function createSpiroPreviewLayer(opts) {
|
|
3620
|
+
const color = opts.color ?? "#d4c4a8";
|
|
3621
|
+
const paint = { kind: "solid", color };
|
|
3622
|
+
return {
|
|
3623
|
+
id: "spiroPreview",
|
|
3624
|
+
label: "Spiro preview",
|
|
3625
|
+
space: "screen",
|
|
3626
|
+
draw: (_data, view) => {
|
|
3627
|
+
const { points, cursor, closeHint } = opts.state();
|
|
3628
|
+
if (points.length === 0) return [];
|
|
3629
|
+
const sx = (p) => (p.x - view.x) * view.scale.x;
|
|
3630
|
+
const sy = (p) => (p.y - view.y) * view.scale.y;
|
|
3631
|
+
const out = [];
|
|
3632
|
+
const through = cursor && !closeHint ? [...points, cursor] : [...points];
|
|
3633
|
+
if (through.length >= 2) {
|
|
3634
|
+
const { path } = spiroToPath([{ points: through, closed: closeHint }]);
|
|
3635
|
+
const toScreen = [view.scale.x, 0, 0, view.scale.y, -view.x * view.scale.x, -view.y * view.scale.y];
|
|
3636
|
+
out.push({ kind: "path", path: transformPath(path, toScreen), stroke: { paint, width: 1 } });
|
|
3637
|
+
}
|
|
3638
|
+
const screen = points.map((p) => ({ x: sx(p), y: sy(p) }));
|
|
3639
|
+
points.forEach((p, i) => {
|
|
3640
|
+
const path = spiroMarker(p.kind)(screen[i], MARKER_PX, screen[i - 1] ?? null, screen[i + 1] ?? null);
|
|
3641
|
+
out.push({ kind: "path", path, fill: { paint }, stroke: { paint, width: 1 } });
|
|
3642
|
+
});
|
|
3643
|
+
if (closeHint) {
|
|
3644
|
+
out.push({ kind: "path", path: circlePath(sx(points[0]), sy(points[0]), CLOSE_HINT_PX), stroke: { paint, width: 1.5 } });
|
|
3645
|
+
}
|
|
3646
|
+
return out;
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
// src/spiro/useSpiroTool.ts
|
|
3652
|
+
var fresh = () => ({ points: [], cursor: null, closeHint: false });
|
|
3653
|
+
var NO_KEYS = { alt: false, ctrl: false, meta: false, shift: false };
|
|
3654
|
+
function spiroActions(host) {
|
|
3655
|
+
const s = host.scratch;
|
|
3656
|
+
let followed;
|
|
3657
|
+
const snaps = pointSnap();
|
|
3658
|
+
const nearFirst = (x, y) => {
|
|
3659
|
+
const { points } = s();
|
|
3660
|
+
if (points.length < 3) return false;
|
|
3661
|
+
const scale = followed?.view?.get().scale ?? { x: 1, y: 1 };
|
|
3662
|
+
return withinPxRadius(points[0].x - x, points[0].y - y, host.options().closeHitRadius, scale);
|
|
3663
|
+
};
|
|
3664
|
+
const follower = createPointerFollower(() => track());
|
|
3665
|
+
const track = () => {
|
|
3666
|
+
const scratch = s();
|
|
3667
|
+
if (scratch.points.length === 0) follower.unfollow();
|
|
3668
|
+
const at2 = scratch.points.length > 0 ? follower.pointer()?.get() ?? null : null;
|
|
3669
|
+
if (!at2) snaps.stop();
|
|
3670
|
+
const p = at2 ? snaps.trail(followed?.snap, { x: at2.worldX, y: at2.worldY }, scratch.points.at(-1)) : null;
|
|
3671
|
+
scratch.cursor = p ? { ...p, kind: "smooth" } : null;
|
|
3672
|
+
scratch.closeHint = p !== null && nearFirst(p.x, p.y);
|
|
3673
|
+
host.repaint();
|
|
3674
|
+
};
|
|
3675
|
+
const commit2 = (deps, closed) => {
|
|
3676
|
+
const { points } = s();
|
|
3677
|
+
if (points.length >= 2) {
|
|
3678
|
+
const { path, representation } = spiroNodeData([{ points, closed }]);
|
|
3679
|
+
const id = deps.insert?.commit(boundsOfPath(path), { kind: "path", path, closed, data: { representation } }) ?? null;
|
|
3680
|
+
if (id !== null && host.options().autoSelect) deps.selection?.set([id]);
|
|
3681
|
+
}
|
|
3682
|
+
host.reset();
|
|
3683
|
+
track();
|
|
3684
|
+
};
|
|
3685
|
+
const placing = () => s().points.length > 0 ? true : ActionDisabledReason.NotApplicable;
|
|
3686
|
+
return [
|
|
3687
|
+
{
|
|
3688
|
+
id: "spiro.placePoint",
|
|
3689
|
+
label: "Spiro \u2014 place point",
|
|
3690
|
+
eligible: { capability: "creates-paths" },
|
|
3691
|
+
requires: ["view", "pointer", "insert", "selection", "snap"],
|
|
3692
|
+
invoker: {
|
|
3693
|
+
timing: "immediate",
|
|
3694
|
+
run: (deps, params) => {
|
|
3695
|
+
const p = params;
|
|
3696
|
+
if (p?.pressX === void 0 || p.pressY === void 0) return;
|
|
3697
|
+
followed = deps;
|
|
3698
|
+
follower.follow(deps.pointer);
|
|
3699
|
+
const mods = snapMods({ modifiers: p.mods ?? NO_KEYS });
|
|
3700
|
+
const at2 = snaps.place(deps.snap, { x: p.pressX, y: p.pressY }, mods, s().points.at(-1)).point;
|
|
3701
|
+
if (nearFirst(at2.x, at2.y)) return commit2(deps, true);
|
|
3702
|
+
const kind = isSpiroKind(p.kind) ? p.kind : "smooth";
|
|
3703
|
+
s().points.push({ x: at2.x, y: at2.y, kind });
|
|
3704
|
+
track();
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3707
|
+
},
|
|
3708
|
+
{
|
|
3709
|
+
id: "spiro.finish",
|
|
3710
|
+
label: "Spiro \u2014 finish open curve",
|
|
3711
|
+
eligible: { capability: "creates-paths" },
|
|
3712
|
+
requires: ["insert", "selection"],
|
|
3713
|
+
enabled: placing,
|
|
3714
|
+
invoker: {
|
|
3715
|
+
timing: "immediate",
|
|
3716
|
+
run: (deps, params) => {
|
|
3717
|
+
if (params?.viaDoubleClick) s().points.pop();
|
|
3718
|
+
commit2(deps, false);
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
},
|
|
3722
|
+
{
|
|
3723
|
+
id: "spiro.removeLast",
|
|
3724
|
+
label: "Spiro \u2014 take back the last point",
|
|
3725
|
+
eligible: { capability: "creates-paths" },
|
|
3726
|
+
enabled: placing,
|
|
3727
|
+
invoker: { timing: "immediate", run: () => {
|
|
3728
|
+
s().points.pop();
|
|
3729
|
+
track();
|
|
3730
|
+
} }
|
|
3731
|
+
},
|
|
3732
|
+
{
|
|
3733
|
+
id: "spiro.cancel",
|
|
3734
|
+
label: "Spiro \u2014 discard curve",
|
|
3735
|
+
eligible: { capability: "creates-paths" },
|
|
3736
|
+
// Declining with nothing placed passes Escape on to `escape`.
|
|
3737
|
+
enabled: placing,
|
|
3738
|
+
invoker: { timing: "immediate", run: () => {
|
|
3739
|
+
host.reset();
|
|
3740
|
+
track();
|
|
3741
|
+
} }
|
|
3742
|
+
}
|
|
3743
|
+
];
|
|
3744
|
+
}
|
|
3745
|
+
function useSpiroTool(options = {}) {
|
|
3746
|
+
const { autoSelect = true, closeHitRadius = 8 } = options;
|
|
3747
|
+
const opts = useLatest({ autoSelect, closeHitRadius });
|
|
3748
|
+
const scratchRef = useRef(null);
|
|
3749
|
+
if (scratchRef.current === null) scratchRef.current = fresh();
|
|
3750
|
+
const [, forceRender] = useReducer((n) => n + 1, 0);
|
|
3751
|
+
const [overlay] = useState(() => createSpiroPreviewLayer({ state: () => scratchRef.current }));
|
|
3752
|
+
const actions = useMemo(() => spiroActions({
|
|
3753
|
+
scratch: () => scratchRef.current,
|
|
3754
|
+
reset: () => {
|
|
3755
|
+
scratchRef.current = fresh();
|
|
3756
|
+
},
|
|
3757
|
+
options: () => opts.current,
|
|
3758
|
+
repaint: forceRender
|
|
3759
|
+
}), [opts]);
|
|
3760
|
+
return useMemo(() => defineTool({
|
|
3761
|
+
id: "spiro",
|
|
3762
|
+
keybinding: { key: "P", shift: true },
|
|
3763
|
+
capabilities: ["creates-paths"],
|
|
3764
|
+
hookName: "useSpiroTool",
|
|
3765
|
+
cursor: () => scratchRef.current?.closeHint ? "pointer" : cursorFor("pen", { fallback: "crosshair" }),
|
|
3766
|
+
presentation: { label: "Spiro", icon: (
|
|
3767
|
+
/* @icon */
|
|
3768
|
+
"path-tools:spiro"
|
|
3769
|
+
), group: "draw" },
|
|
3770
|
+
actions,
|
|
3771
|
+
overlay,
|
|
3772
|
+
overlayPosition: "before-selection",
|
|
3773
|
+
onDeactivate: () => {
|
|
3774
|
+
scratchRef.current = fresh();
|
|
3775
|
+
forceRender();
|
|
3776
|
+
},
|
|
3777
|
+
bindings: [
|
|
3778
|
+
{ spec: { kind: "click", mods: { shift: "optional" } }, actionId: "spiro.placePoint" },
|
|
3779
|
+
{ spec: { kind: "click", mods: { alt: true, shift: "optional" } }, actionId: "spiro.placePoint", opts: { params: { kind: "corner" } } },
|
|
3780
|
+
{ spec: { kind: "click", mods: { mod: true, shift: "optional" } }, actionId: "spiro.finish" },
|
|
3781
|
+
{ spec: { kind: "doubleClick", mods: { shift: "optional", alt: "optional" } }, actionId: "spiro.finish", opts: { params: { viaDoubleClick: true } } },
|
|
3782
|
+
{ spec: { kind: "key", key: "Enter" }, actionId: "spiro.finish" },
|
|
3783
|
+
{ spec: { kind: "key", key: "Backspace" }, actionId: "spiro.removeLast" },
|
|
3784
|
+
{ spec: { kind: "key", key: "Escape" }, actionId: "spiro.cancel" }
|
|
3785
|
+
]
|
|
3786
|
+
}), [actions, overlay]);
|
|
3787
|
+
}
|
|
3788
|
+
var CORNER_K_SLIDER_RANGE = 4;
|
|
3789
|
+
var ENABLED = { path: "count", above: 0 };
|
|
3790
|
+
var CORNER_CONTROLS_PROMPT = "Click a corner anchor (Shift-click for more) to edit it";
|
|
3791
|
+
var CORNER_CONTROLS = {
|
|
3792
|
+
name: "Corner shape",
|
|
3793
|
+
children: {
|
|
3794
|
+
count: { kind: "number", name: "Selected corners", description: "", default: 0, hidden: true },
|
|
3795
|
+
shape: {
|
|
3796
|
+
kind: "enum",
|
|
3797
|
+
name: "Shape",
|
|
3798
|
+
description: "",
|
|
3799
|
+
default: "round",
|
|
3800
|
+
control: "toggle",
|
|
3801
|
+
enableIf: ENABLED,
|
|
3802
|
+
options: CORNER_KEYWORDS.map((w) => ({ value: w, label: w })),
|
|
3803
|
+
// A parameter between the keywords, or a sharp corner, presses none.
|
|
3804
|
+
encoding: {
|
|
3805
|
+
read: (stored) => CORNER_KEYWORDS.includes(stored) ? stored : void 0,
|
|
3806
|
+
write: (option) => option
|
|
3807
|
+
}
|
|
3808
|
+
},
|
|
3809
|
+
k: {
|
|
3810
|
+
kind: "number",
|
|
3811
|
+
name: "Curvature",
|
|
3812
|
+
description: "The superellipse() parameter: 1 is round, 0 a bevel, -1 a scoop.",
|
|
3813
|
+
default: 1,
|
|
3814
|
+
min: -CORNER_K_SLIDER_RANGE,
|
|
3815
|
+
max: CORNER_K_SLIDER_RANGE,
|
|
3816
|
+
step: 0.05,
|
|
3817
|
+
control: "slider",
|
|
3818
|
+
enableIf: ENABLED
|
|
3819
|
+
},
|
|
3820
|
+
radius: {
|
|
3821
|
+
kind: "number",
|
|
3822
|
+
name: "Radius",
|
|
3823
|
+
description: "How far along each edge the corner is cut.",
|
|
3824
|
+
default: 0,
|
|
3825
|
+
min: 0,
|
|
3826
|
+
step: 1,
|
|
3827
|
+
control: "input",
|
|
3828
|
+
enableIf: ENABLED
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
};
|
|
3832
|
+
var SHARP = /* @__PURE__ */ Symbol("sharp");
|
|
3833
|
+
function agreed(values) {
|
|
3834
|
+
const first = values[0];
|
|
3835
|
+
if (!values.every((v) => Object.is(v, first))) return { mixed: true };
|
|
3836
|
+
return { value: first === SHARP ? void 0 : first, mixed: false };
|
|
3837
|
+
}
|
|
3838
|
+
function cornerControlsState(path, selected) {
|
|
3839
|
+
if (!path) return { values: { count: 0, shape: null }, mixed: /* @__PURE__ */ new Set() };
|
|
3840
|
+
const specs = cornerFrames(path).filter((f) => selected.has(f.index)).map((f) => cornerSpecAt(path, f.index));
|
|
3841
|
+
const cut = specs.map((c) => c && c.radius > 0 ? c : SHARP);
|
|
3842
|
+
const shape = agreed(cut.map((c) => c === SHARP ? SHARP : keywordOf(c.shape)));
|
|
3843
|
+
const k = agreed(cut.map((c) => c === SHARP ? SHARP : clampK(cornerShapeK(c.shape))));
|
|
3844
|
+
const radius = agreed(specs.map((c) => Math.round((c?.radius ?? 0) * 100) / 100));
|
|
3845
|
+
const mixed = /* @__PURE__ */ new Set();
|
|
3846
|
+
if (shape.mixed) mixed.add("shape");
|
|
3847
|
+
if (k.mixed) mixed.add("k");
|
|
3848
|
+
if (radius.mixed) mixed.add("radius");
|
|
3849
|
+
return {
|
|
3850
|
+
values: {
|
|
3851
|
+
count: specs.length,
|
|
3852
|
+
shape: shape.value ?? null,
|
|
3853
|
+
...k.value !== void 0 ? { k: k.value } : {},
|
|
3854
|
+
...radius.value !== void 0 ? { radius: radius.value } : {}
|
|
3855
|
+
},
|
|
3856
|
+
mixed
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
function keywordOf(shape) {
|
|
3860
|
+
return typeof shape === "number" ? cornerKeywordOf(shape) : shape;
|
|
3861
|
+
}
|
|
3862
|
+
var clampK = (k) => Math.max(-CORNER_K_SLIDER_RANGE, Math.min(CORNER_K_SLIDER_RANGE, k));
|
|
3863
|
+
function cornerControlsParams(field, value) {
|
|
3864
|
+
if (field === "shape" && CORNER_KEYWORDS.includes(value)) return { shape: value };
|
|
3865
|
+
if (field === "k" && typeof value === "number" && Number.isFinite(value)) return { shape: value };
|
|
3866
|
+
if (field === "radius" && typeof value === "number" && Number.isFinite(value)) return { radius: value };
|
|
3867
|
+
return null;
|
|
3868
|
+
}
|
|
3869
|
+
function snapshot(scene, selection) {
|
|
3870
|
+
const id = selection.editingId();
|
|
3871
|
+
const path = id ? resolveEditablePathOf(scene.get(id)) : null;
|
|
3872
|
+
const { values, mixed } = cornerControlsState(path, selection.selectedAnchors());
|
|
3873
|
+
return JSON.stringify({ values, mixed: [...mixed] });
|
|
3874
|
+
}
|
|
3875
|
+
function useCornerControls(scene, selection) {
|
|
3876
|
+
const actions = useActionsRegistry();
|
|
3877
|
+
const subscribe = useCallback((l) => {
|
|
3878
|
+
const offScene = scene.subscribe(l);
|
|
3879
|
+
const offSelection = selection.subscribe(l);
|
|
3880
|
+
return () => {
|
|
3881
|
+
offScene();
|
|
3882
|
+
offSelection();
|
|
3883
|
+
};
|
|
3884
|
+
}, [scene, selection]);
|
|
3885
|
+
const key = useSyncExternalStore(subscribe, () => snapshot(scene, selection));
|
|
3886
|
+
const shown = useMemo(() => {
|
|
3887
|
+
const parsed = JSON.parse(key);
|
|
3888
|
+
return { values: parsed.values, mixed: new Set(parsed.mixed) };
|
|
3889
|
+
}, [key]);
|
|
3890
|
+
const onChange = useCallback((field, value) => {
|
|
3891
|
+
const params = cornerControlsParams(field, value);
|
|
3892
|
+
if (params) actions?.trigger("setCorners", params);
|
|
3893
|
+
}, [actions]);
|
|
3894
|
+
const prompt = shown.values.count === 0 ? CORNER_CONTROLS_PROMPT : null;
|
|
3895
|
+
return { schema: CORNER_CONTROLS, prompt, ...shown, onChange };
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
export { ANCHOR_SELECTION_GROUP, CONSTRAINTS_EXTENSION_ID, CORNER_CONTROLS, CORNER_CONTROLS_PROMPT, CORNER_K_SLIDER_RANGE, CORNER_WIDGET_STANDOFF_PX, DEFAULT_BRIDGE_TENSION, DEFAULT_CORNER_SHAPE, DEFAULT_CORNER_TRIM_SHARE, DEFAULT_ENVELOPE_SIZE, DEFAULT_SMUDGE_RADIUS_PX, DEFAULT_SMUDGE_STRENGTH, DEFAULT_WARP_TOLERANCE, GIRTH_CLEAR_ID, GIRTH_DRAG_ID, GIRTH_HOVER_ID, GIRTH_UNHOVER_ID, LENGTHEN_TOOL, PATH_BALANCE_CHROME_ID, PATH_COMB_CHROME_ID, PATH_CORNERS_CHROME_ID, PATH_DIRECTION_CHROME_ID, PATH_EFFECTS_GROUP, SIMPLIFY_BRUSH_RADIUS_PX, SIMPLIFY_BRUSH_TOOL, SPIRO_FORM, addExtremeAnchorsAction, addExtremesPreset, anchorSelectionPreset, asSpiro, balanceCommands, balanceHandlesAction, balancePreset, bendAlongAction, blendAction, bridgeAnchorsAction, bridgePathsAction, bridgePreset, bridgeTensionOf, brushRingCursor, combCommands, configureWarpAction, constrainAnchorsAction, constraintAxisOf, constraintConflictsOf, constraintLinkNode, constraintLinkOf, constraintsForAnchors, constraintsOf, constraintsPreset, convertSegmentAtAction, convertSegmentsAction, cornerControlsParams, cornerControlsState, cornerShapePreset, cornerWidgetCommands, cornerWidgetFrames, createBalanceExtension, createBalancePreset, createCombExtension, createConstraintsExtension, createConstraintsPreset, createCornerShapePreset, createCornerWidgetsExtension, createCurvatureCombPreset, createDirectionExtension, createGirthActions, createGirthOverlay, createHandleSnapExtension, createHandleSnapPreset, createPathStructurePreset, createSoftSelectionExtension, createSoftSelectionPreset, createSpiroPreviewLayer, curvatureCombPreset, cycleCornerShapeAction, deleteConstraintAction, directionCommands, editCornerAction, editPathEffectAction, equalizeHandlesAction, expandPathEffectAction, expandWarpAction, extendPreset, extendToMeetAction, facesAlong, growAnchorSelectionAction, handleLockAngleAction, handleLockLengthAction, handleLocksPreset, handleSnapPreset, handleSnapSource, harmonizeAnchorsAction, harmonizePreset, heldPathWarpOf, invertAnchorSelectionAction, lengthenPathAction, lengthenPreset, lengthenToolAction, pathEffectsPreset, pathStructurePreset, pathTools, planShapeBuilder, planTrim, releaseSpiroAction, repeatAlongAction, resamplePathAction, resamplePreset, resolvePathWarp, respiro, respiroWithKind, reversePathAction, segmentTypePreset, selectCornerAnchorsAction, selectEveryNthAnchorAction, selectSmoothAnchorsAction, selectedConstraintOf, setConstraints, setCornersAction, setPathStartAction, setSpiroKindAction, shrinkAnchorSelectionAction, simplifyBrushAction, simplifyBrushPreset, simplifyBrushToolAction, smudgeBrushAction, smudgePreset, spiroContours, spiroEditPath, spiroExtension, spiroMarker, spiroNodeData, spiroOf, spiroPreset, spiroRepresentation, trimPathAction, trimPreset, unconstrainAnchorsAction, useCornerControls, useDistortTool, useEnvelopeTool, useExtendTool, useGirthTool, usePuppetTool, useShapeBuilderTool, useSmudgeBrushTool, useSpiroTool, useTrimTool, warpHandleScratchOf, warpLayerId, warpPreset, withPathWarp, withoutPathWarp, withoutSpiro };
|
|
3899
|
+
//# sourceMappingURL=index.js.map
|
|
3900
|
+
//# sourceMappingURL=index.js.map
|