@weasel-js/path-tools 1.10.1 → 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/README.md +30 -7
- 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 +877 -40
- package/dist/index.js +2258 -88
- package/dist/index.js.map +1 -1
- package/package.json +21 -10
package/dist/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { inPlane, editedPathSymmetries, pathEditPreview, locksInForce, pathOperationTags, editingLayer, joinPathsCommand,
|
|
2
|
-
import { isBalanceGrip, ActionDisabledReason, resolveParams } from '@weasel-js/routing';
|
|
3
|
-
import { pathToAnchors, balanceFactorToward, editAnchorSetTracked, scaleSegmentHandles, harmonizeAnchors, simplifyAnchorsAt, locateAnchor, anchorsUnderBrush, pathAnchorPoints,
|
|
4
|
-
import { jsx } from 'react/jsx-runtime';
|
|
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';
|
|
5
4
|
import { createEmitter } from '@weasel-js/registry';
|
|
6
|
-
import {
|
|
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
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';
|
|
8
13
|
|
|
9
14
|
// src/presets.ts
|
|
10
15
|
function gripTarget(dep, kind) {
|
|
@@ -24,12 +29,17 @@ function gripEnabled(deps) {
|
|
|
24
29
|
var balanceHandlesAction = inPlane({
|
|
25
30
|
id: "balanceHandles",
|
|
26
31
|
label: "Balance handles",
|
|
32
|
+
icon: (
|
|
33
|
+
/* @icon */
|
|
34
|
+
"path-tools:balanceHandles"
|
|
35
|
+
),
|
|
27
36
|
tags: pathOperationTags("drag"),
|
|
28
37
|
// Ties the marquee's `empty` and move's `selected-body` on specificity and
|
|
29
38
|
// registers ahead of the marquee; move turns path-edit presses down itself.
|
|
30
39
|
defaultBinding: { kind: "drag", target: { kindOf: isBalanceGrip } },
|
|
31
40
|
cursor: "grab",
|
|
32
41
|
eligible: { capability: "edits-anchors" },
|
|
42
|
+
modifierRoles: HANDLE_LOCK_ROLES,
|
|
33
43
|
requires: ["editAnchors", "pathSymmetry"],
|
|
34
44
|
invoker: {
|
|
35
45
|
timing: "ongoing",
|
|
@@ -49,7 +59,7 @@ var balanceHandlesAction = inPlane({
|
|
|
49
59
|
kind: "balance-handles",
|
|
50
60
|
onMove(moveCtx) {
|
|
51
61
|
const held = (moveCtx.deps.editAnchors ?? dep).handleLocks ?? NO_LOCKS;
|
|
52
|
-
if (locksInForce(held, moveCtx
|
|
62
|
+
if (locksInForce(held, moveCtx).length) {
|
|
53
63
|
current = null;
|
|
54
64
|
return;
|
|
55
65
|
}
|
|
@@ -74,6 +84,10 @@ var balanceHandlesAction = inPlane({
|
|
|
74
84
|
var equalizeHandlesAction = {
|
|
75
85
|
id: "equalizeHandles",
|
|
76
86
|
label: "Equalize handles",
|
|
87
|
+
icon: (
|
|
88
|
+
/* @icon */
|
|
89
|
+
"path-tools:equalizeHandles"
|
|
90
|
+
),
|
|
77
91
|
tags: pathOperationTags("click"),
|
|
78
92
|
defaultBinding: { kind: "doubleClick", target: { kindOf: isBalanceGrip } },
|
|
79
93
|
eligible: { capability: "edits-anchors" },
|
|
@@ -100,11 +114,11 @@ function balanceCommands(set, toScreen, style = {}) {
|
|
|
100
114
|
const { color, gripFill, gripRadiusPx } = { ...BALANCE_DEFAULTS, ...style };
|
|
101
115
|
const paint = { kind: "solid", color };
|
|
102
116
|
const out = [];
|
|
103
|
-
for (const
|
|
104
|
-
const grip = toScreen(
|
|
117
|
+
for (const line3 of handleBalances(set)) {
|
|
118
|
+
const grip = toScreen(line3.grip);
|
|
105
119
|
out.push({
|
|
106
120
|
kind: "path",
|
|
107
|
-
path: linePath(toScreen(
|
|
121
|
+
path: linePath(toScreen(line3.from), toScreen(line3.to)),
|
|
108
122
|
stroke: { paint, width: 1 }
|
|
109
123
|
});
|
|
110
124
|
out.push({
|
|
@@ -120,10 +134,10 @@ function createBalanceExtension(style = {}) {
|
|
|
120
134
|
return {
|
|
121
135
|
id: "balance",
|
|
122
136
|
marks: (ctx) => ctx.isVisible(PATH_BALANCE_CHROME_ID) ? balanceCommands(ctx.anchors, ctx.toScreen, style) : [],
|
|
123
|
-
parts: (target) => handleBalances(target.anchors).map((
|
|
124
|
-
x:
|
|
125
|
-
y:
|
|
126
|
-
hitKind: `balance:${
|
|
137
|
+
parts: (target) => handleBalances(target.anchors).map((line3) => ({
|
|
138
|
+
x: line3.grip.x,
|
|
139
|
+
y: line3.grip.y,
|
|
140
|
+
hitKind: `balance:${line3.flat}`,
|
|
127
141
|
chromeId: PATH_BALANCE_CHROME_ID
|
|
128
142
|
}))
|
|
129
143
|
};
|
|
@@ -137,7 +151,10 @@ var bridgePathsAction = joinPathsCommand(
|
|
|
137
151
|
{
|
|
138
152
|
id: "bridgePaths",
|
|
139
153
|
label: "Bridge",
|
|
140
|
-
icon:
|
|
154
|
+
icon: (
|
|
155
|
+
/* @icon */
|
|
156
|
+
"core:bridge"
|
|
157
|
+
),
|
|
141
158
|
// `code` too: with Shift held the key reads as `J`.
|
|
142
159
|
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } }
|
|
143
160
|
},
|
|
@@ -148,7 +165,10 @@ var bridgeAnchorsAction = {
|
|
|
148
165
|
id: "bridgeAnchors",
|
|
149
166
|
label: "Bridge anchors",
|
|
150
167
|
tags: pathOperationTags("command"),
|
|
151
|
-
icon:
|
|
168
|
+
icon: (
|
|
169
|
+
/* @icon */
|
|
170
|
+
"core:bridge"
|
|
171
|
+
),
|
|
152
172
|
group: "anchors",
|
|
153
173
|
defaultBinding: { kind: "key", key: "j", code: "KeyJ", mods: { mod: true, shift: true } },
|
|
154
174
|
eligible: { capability: "edits-anchors" },
|
|
@@ -191,6 +211,222 @@ function createCombExtension(options = () => void 0) {
|
|
|
191
211
|
marks: (ctx) => ctx.isVisible(PATH_COMB_CHROME_ID) ? combCommands(ctx.anchors, ctx.toScreen, options()) : []
|
|
192
212
|
};
|
|
193
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
|
+
}
|
|
194
430
|
var NONE = [];
|
|
195
431
|
function constraintsOf(data) {
|
|
196
432
|
const list = data?.constraints;
|
|
@@ -200,11 +436,18 @@ function constraintAxisOf(node) {
|
|
|
200
436
|
const storage = classifyPathStorage(node);
|
|
201
437
|
return storage?.kind === "data" ? storage.pose.rotation ?? 0 : 0;
|
|
202
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
|
+
}
|
|
203
446
|
function constraintConflictsOf(node) {
|
|
204
447
|
const constraints = constraintsOf(node?.data);
|
|
205
448
|
const path = constraints.length > 0 ? resolveEditablePathOf(node) : null;
|
|
206
449
|
if (!path) return [];
|
|
207
|
-
return
|
|
450
|
+
return conflictsIn(path, constraints, constraintAxisOf(node));
|
|
208
451
|
}
|
|
209
452
|
function constrainNodeEdit(node, worldPath, origins) {
|
|
210
453
|
const constraints = constraintsOf(node?.data);
|
|
@@ -212,16 +455,103 @@ function constrainNodeEdit(node, worldPath, origins) {
|
|
|
212
455
|
if (!before) return { path: worldPath, constraints };
|
|
213
456
|
const edit = constrainPathEdit(before, worldPath, constraints, { origins, axisAngle: constraintAxisOf(node) });
|
|
214
457
|
if (!edit.converged) return null;
|
|
215
|
-
const
|
|
216
|
-
return { path: edit.path, constraints:
|
|
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 };
|
|
217
460
|
}
|
|
218
461
|
function solveNodeConstraints(node, constraints) {
|
|
219
462
|
const path = resolveEditablePathOf(node);
|
|
220
463
|
if (!path) return null;
|
|
221
464
|
const axisAngle = constraintAxisOf(node);
|
|
222
465
|
const solved = solvePathConstraints(path, constraints, { axisAngle });
|
|
223
|
-
if (solved.converged) return { path: solved.path,
|
|
224
|
-
return { path,
|
|
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 };
|
|
225
555
|
}
|
|
226
556
|
var OFFSET = 12;
|
|
227
557
|
var at = (p, d, k) => ({ x: p.x + d.x * k, y: p.y + d.y * k });
|
|
@@ -249,6 +579,8 @@ function frameOf(points, [i, j], inside) {
|
|
|
249
579
|
function segmentsOf(c) {
|
|
250
580
|
switch (c.kind) {
|
|
251
581
|
case "fixed":
|
|
582
|
+
case "tangent":
|
|
583
|
+
case "onPath":
|
|
252
584
|
case "coincident":
|
|
253
585
|
return [];
|
|
254
586
|
case "horizontal":
|
|
@@ -260,8 +592,8 @@ function segmentsOf(c) {
|
|
|
260
592
|
}
|
|
261
593
|
}
|
|
262
594
|
function markCenters(points, c, inside) {
|
|
263
|
-
if (c.kind === "fixed" || c.kind === "coincident") {
|
|
264
|
-
const p = points[c.kind === "
|
|
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];
|
|
265
597
|
return [at(p, toward(p, inside), c.kind === "fixed" ? 11 : 13)];
|
|
266
598
|
}
|
|
267
599
|
return segmentsOf(c).map((s) => {
|
|
@@ -314,14 +646,36 @@ function glyphOf(b, points, c, inside) {
|
|
|
314
646
|
const d = toward(p, inside);
|
|
315
647
|
const ring = at(p, d, 13);
|
|
316
648
|
line(b, at(p, d, 6), at(p, d, 9));
|
|
317
|
-
b
|
|
318
|
-
|
|
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);
|
|
319
669
|
return;
|
|
320
670
|
}
|
|
321
671
|
default:
|
|
322
672
|
for (const s of segmentsOf(c)) segmentMark(b, points, c.kind, s, inside);
|
|
323
673
|
}
|
|
324
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
|
+
}
|
|
325
679
|
var xy = (p) => [p.x, p.y];
|
|
326
680
|
function constraintGlyphs(points, constraints, unsatisfied, style, selected = null) {
|
|
327
681
|
const inside = centroidOf(points);
|
|
@@ -356,17 +710,26 @@ function constraintMarkAt(points, constraints, p, radius) {
|
|
|
356
710
|
var CONSTRAINTS_EXTENSION_ID = "constraints";
|
|
357
711
|
var MARK_CLICK_PX = 8;
|
|
358
712
|
var DEFAULT_STYLE = { stroke: "#0e9f8e", conflictStroke: "#e5484d" };
|
|
359
|
-
function
|
|
713
|
+
function editedPath2(dep) {
|
|
360
714
|
const p = dep.getEditablePath(dep.editingId);
|
|
361
715
|
return p?.kind === "polygon" ? p : null;
|
|
362
716
|
}
|
|
363
717
|
function setConstraints(dep, constraints, label) {
|
|
364
718
|
const node = dep.getNodeShape(dep.editingId);
|
|
365
719
|
const solved = node && dep.store ? solveNodeConstraints(node, constraints) : null;
|
|
366
|
-
if (!solved) return
|
|
720
|
+
if (!solved) return null;
|
|
367
721
|
dep.setSelectedMark?.(null);
|
|
368
722
|
dep.store(dep.editingId, solved.path, label, { constraints: [...constraints] });
|
|
369
|
-
return
|
|
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;
|
|
370
733
|
}
|
|
371
734
|
function selectedConstraintOf(dep) {
|
|
372
735
|
const mark = dep.selectedMark;
|
|
@@ -375,15 +738,15 @@ function selectedConstraintOf(dep) {
|
|
|
375
738
|
var screenPoints = (target, toScreen) => pathAnchorPoints(target.path).map(toScreen);
|
|
376
739
|
function createConstraintsExtension(style = {}) {
|
|
377
740
|
const colors = { ...DEFAULT_STYLE, ...style };
|
|
741
|
+
registerConstraintLinkShape();
|
|
378
742
|
return {
|
|
379
743
|
id: CONSTRAINTS_EXTENSION_ID,
|
|
380
744
|
marks(ctx) {
|
|
381
745
|
const constraints = constraintsOf(ctx.node.data);
|
|
382
746
|
if (constraints.length === 0) return [];
|
|
383
747
|
const world = pathAnchorPoints(ctx.path);
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
return constraintGlyphs(world.map(ctx.toScreen), constraints, unsatisfied, colors, ctx.selected);
|
|
748
|
+
const conflicts = conflictsOf(ctx.path, constraints, constraintAxisOf(ctx.node));
|
|
749
|
+
return constraintGlyphs(world.map(ctx.toScreen), constraints, conflicts, colors, ctx.selected);
|
|
387
750
|
},
|
|
388
751
|
markAt(target, toScreen, screen) {
|
|
389
752
|
const constraints = constraintsOf(target.node.data);
|
|
@@ -397,7 +760,12 @@ function createConstraintsExtension(style = {}) {
|
|
|
397
760
|
const solved = solveNodeConstraints(target.node, kept);
|
|
398
761
|
if (solved) store(target.id, solved.path, "Delete constraint", { constraints: kept });
|
|
399
762
|
},
|
|
400
|
-
finishEdit(node, worldPath, origins) {
|
|
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
|
+
}
|
|
401
769
|
const done = constrainNodeEdit(node, worldPath, origins);
|
|
402
770
|
if (!done) return null;
|
|
403
771
|
return done.constraints === constraintsOf(node.data) ? { path: done.path } : { path: done.path, data: { constraints: done.constraints } };
|
|
@@ -416,6 +784,14 @@ function constraintsForAnchors(kind, anchors, path, length) {
|
|
|
416
784
|
switch (kind) {
|
|
417
785
|
case "fixed":
|
|
418
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
|
+
}
|
|
419
795
|
case "horizontal":
|
|
420
796
|
case "vertical":
|
|
421
797
|
return pairsOf(anchors).map((segment) => ({ kind, segment }));
|
|
@@ -437,7 +813,7 @@ function constraintsForAnchors(kind, anchors, path, length) {
|
|
|
437
813
|
}
|
|
438
814
|
var sameConstraint = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
439
815
|
function withAdded(dep, params) {
|
|
440
|
-
const path =
|
|
816
|
+
const path = editedPath2(dep);
|
|
441
817
|
if (!path || !params?.kind) return null;
|
|
442
818
|
const current = constraintsOf(dep.getNodeShape(dep.editingId)?.data);
|
|
443
819
|
const added = constraintsForAnchors(params.kind, [...dep.selectedAnchors], path, params.length).filter((c) => !current.some((had) => sameConstraint(had, c)));
|
|
@@ -474,6 +850,10 @@ function constraintCommand(id, label, next) {
|
|
|
474
850
|
var deleteConstraintAction = {
|
|
475
851
|
id: "deleteConstraint",
|
|
476
852
|
label: "Delete constraint",
|
|
853
|
+
icon: (
|
|
854
|
+
/* @icon */
|
|
855
|
+
"core:delete"
|
|
856
|
+
),
|
|
477
857
|
eligible: { capability: "edits-anchors" },
|
|
478
858
|
requires: ["editAnchors"],
|
|
479
859
|
invoker: {
|
|
@@ -536,11 +916,8 @@ function endsAt(path, anchors) {
|
|
|
536
916
|
const ends = openPathEnds(path);
|
|
537
917
|
if (anchors.size === 0) return ends;
|
|
538
918
|
const set = pathToAnchors(path);
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
if (!at2 || at2.sub !== e.sub) return false;
|
|
542
|
-
return e.end === "first" ? at2.idx === 0 : at2.idx === set.anchors[at2.sub].length - 1;
|
|
543
|
-
}));
|
|
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));
|
|
544
921
|
return picked.length > 0 ? picked : ends;
|
|
545
922
|
}
|
|
546
923
|
var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
@@ -569,6 +946,10 @@ function planExtend(deps, params) {
|
|
|
569
946
|
}
|
|
570
947
|
var extendToMeetAction = {
|
|
571
948
|
id: "extendToMeet",
|
|
949
|
+
icon: (
|
|
950
|
+
/* @icon */
|
|
951
|
+
"path-tools:extend"
|
|
952
|
+
),
|
|
572
953
|
label: EXTEND_LABEL,
|
|
573
954
|
tags: pathOperationTags("click"),
|
|
574
955
|
group: "path",
|
|
@@ -606,18 +987,552 @@ function makeHandleLockToggle(lock, label, icon) {
|
|
|
606
987
|
}
|
|
607
988
|
};
|
|
608
989
|
}
|
|
609
|
-
var handleLockAngleAction = makeHandleLockToggle(
|
|
610
|
-
|
|
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
|
+
}
|
|
611
1104
|
var harmonizeAnchorsAction = makeHandleCommand(
|
|
612
1105
|
"harmonizeAnchors",
|
|
613
1106
|
"Harmonize curvature",
|
|
614
|
-
/* @
|
|
1107
|
+
/* @icon */
|
|
1108
|
+
"core:harmonize",
|
|
615
1109
|
harmonizeAnchors,
|
|
616
1110
|
"selectedOrAll"
|
|
617
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
|
+
});
|
|
618
1533
|
|
|
619
1534
|
// src/pathEffects/params.ts
|
|
620
|
-
var
|
|
1535
|
+
var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
621
1536
|
var bool = (v) => typeof v === "boolean" ? v : void 0;
|
|
622
1537
|
var oneOf = (v, options) => typeof v === "string" && options.includes(v) ? v : void 0;
|
|
623
1538
|
function defined(fields) {
|
|
@@ -628,25 +1543,28 @@ function effectSettings(kind, params) {
|
|
|
628
1543
|
switch (kind) {
|
|
629
1544
|
case "blend":
|
|
630
1545
|
return defined({
|
|
631
|
-
steps:
|
|
1546
|
+
steps: num2(p.steps) === void 0 ? void 0 : Math.max(Math.round(num2(p.steps)), 0),
|
|
632
1547
|
autoAlign: bool(p.autoAlign),
|
|
633
|
-
startOffset:
|
|
1548
|
+
startOffset: num2(p.startOffset) === void 0 ? void 0 : Math.round(num2(p.startOffset)),
|
|
634
1549
|
reverse: bool(p.reverse),
|
|
635
|
-
rotate: bool(p.rotate)
|
|
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)
|
|
636
1554
|
});
|
|
637
1555
|
case "bendAlong":
|
|
638
1556
|
return defined({
|
|
639
1557
|
fit: oneOf(p.fit, ["stretch", "natural"]),
|
|
640
|
-
baseline:
|
|
1558
|
+
baseline: num2(p.baseline),
|
|
641
1559
|
flip: bool(p.flip),
|
|
642
|
-
tolerance:
|
|
1560
|
+
tolerance: num2(p.tolerance) === void 0 ? void 0 : Math.max(num2(p.tolerance), 0.01)
|
|
643
1561
|
});
|
|
644
1562
|
case "repeatAlong":
|
|
645
1563
|
return defined({
|
|
646
|
-
count:
|
|
647
|
-
spacing:
|
|
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),
|
|
648
1566
|
align: oneOf(p.align, ["start", "center", "end"]),
|
|
649
|
-
offset:
|
|
1567
|
+
offset: num2(p.offset),
|
|
650
1568
|
orient: bool(p.orient)
|
|
651
1569
|
});
|
|
652
1570
|
}
|
|
@@ -664,11 +1582,11 @@ var EFFECT_DEFAULTS = {
|
|
|
664
1582
|
|
|
665
1583
|
// src/pathEffects/actions.ts
|
|
666
1584
|
var PATH_EFFECTS_GROUP = "pathEffects";
|
|
667
|
-
function
|
|
1585
|
+
function selectedLeaves(deps) {
|
|
668
1586
|
const scene = deps?.scene;
|
|
669
1587
|
if (!scene || registeredPathEffects(scene.registry) === null) return null;
|
|
670
1588
|
const leaves = (deps?.selection?.get() ?? []).map((id) => scene.get(id)).filter((n) => n !== void 0 && n.kind === "leaf");
|
|
671
|
-
return leaves.length >= 2 ?
|
|
1589
|
+
return leaves.length >= 2 ? leaves : null;
|
|
672
1590
|
}
|
|
673
1591
|
function selectedEffects(deps, ids) {
|
|
674
1592
|
const scene = deps?.scene;
|
|
@@ -679,6 +1597,7 @@ function createEffectAction(spec) {
|
|
|
679
1597
|
return {
|
|
680
1598
|
id: spec.id,
|
|
681
1599
|
label: spec.label,
|
|
1600
|
+
icon: spec.icon,
|
|
682
1601
|
tags: pathOperationTags("command"),
|
|
683
1602
|
group: PATH_EFFECTS_GROUP,
|
|
684
1603
|
// Alt+Shift, which no major browser takes for these letters (see
|
|
@@ -691,16 +1610,17 @@ function createEffectAction(spec) {
|
|
|
691
1610
|
timing: "immediate",
|
|
692
1611
|
run: (deps, params) => {
|
|
693
1612
|
const scene = deps.scene;
|
|
694
|
-
const
|
|
695
|
-
if (!scene || !
|
|
1613
|
+
const leaves = selectedLeaves(deps);
|
|
1614
|
+
if (!scene || !leaves) return;
|
|
696
1615
|
const derivations = registeredPathEffects(scene.registry);
|
|
697
|
-
const [a, b] =
|
|
1616
|
+
const [a, b, c] = leaves;
|
|
1617
|
+
const sources = spec.third && c ? [a.id, b.id, c.id] : [a.id, b.id];
|
|
698
1618
|
const effect = withEffectSettings(EFFECT_DEFAULTS[spec.kind], params);
|
|
699
1619
|
const sameParent = a.parent === b.parent;
|
|
700
1620
|
const slots = [slotOf(scene, a.id), slotOf(scene, b.id)];
|
|
701
1621
|
const index = !sameParent ? -1 : spec.between ? Math.max(...slots) : Math.max(...slots) + 1;
|
|
702
1622
|
const node = {
|
|
703
|
-
...pathEffectNode(effect,
|
|
1623
|
+
...pathEffectNode(effect, sources, a.layer, derivations),
|
|
704
1624
|
id: scene.mintId(),
|
|
705
1625
|
parent: sameParent ? a.parent : null
|
|
706
1626
|
};
|
|
@@ -711,21 +1631,30 @@ function createEffectAction(spec) {
|
|
|
711
1631
|
enabled: (deps) => {
|
|
712
1632
|
const scene = deps?.scene;
|
|
713
1633
|
if (scene && registeredPathEffects(scene.registry) === null) return ActionDisabledReason.NotApplicable;
|
|
714
|
-
return
|
|
1634
|
+
return selectedLeaves(deps) ? true : ActionDisabledReason.SelectionRequired;
|
|
715
1635
|
}
|
|
716
1636
|
};
|
|
717
1637
|
}
|
|
718
1638
|
var blendAction = createEffectAction({
|
|
719
1639
|
id: "blend",
|
|
720
1640
|
label: "Blend",
|
|
1641
|
+
icon: (
|
|
1642
|
+
/* @icon */
|
|
1643
|
+
"path-tools:blend"
|
|
1644
|
+
),
|
|
721
1645
|
kind: "blend",
|
|
722
1646
|
key: "b",
|
|
723
1647
|
code: "KeyB",
|
|
724
|
-
between: true
|
|
1648
|
+
between: true,
|
|
1649
|
+
third: true
|
|
725
1650
|
});
|
|
726
1651
|
var bendAlongAction = createEffectAction({
|
|
727
1652
|
id: "bendAlong",
|
|
728
1653
|
label: "Bend Along Path",
|
|
1654
|
+
icon: (
|
|
1655
|
+
/* @icon */
|
|
1656
|
+
"path-tools:bendAlong"
|
|
1657
|
+
),
|
|
729
1658
|
kind: "bendAlong",
|
|
730
1659
|
key: "w",
|
|
731
1660
|
code: "KeyW",
|
|
@@ -734,6 +1663,10 @@ var bendAlongAction = createEffectAction({
|
|
|
734
1663
|
var repeatAlongAction = createEffectAction({
|
|
735
1664
|
id: "repeatAlong",
|
|
736
1665
|
label: "Repeat Along Path",
|
|
1666
|
+
icon: (
|
|
1667
|
+
/* @icon */
|
|
1668
|
+
"path-tools:repeatAlong"
|
|
1669
|
+
),
|
|
737
1670
|
kind: "repeatAlong",
|
|
738
1671
|
key: "r",
|
|
739
1672
|
code: "KeyR",
|
|
@@ -743,6 +1676,10 @@ var EDIT_LABEL = "Edit Path Effect";
|
|
|
743
1676
|
var editPathEffectAction = {
|
|
744
1677
|
id: "editPathEffect",
|
|
745
1678
|
label: EDIT_LABEL,
|
|
1679
|
+
icon: (
|
|
1680
|
+
/* @icon */
|
|
1681
|
+
"core:tune"
|
|
1682
|
+
),
|
|
746
1683
|
requires: ["scene", "applyOps"],
|
|
747
1684
|
invoker: {
|
|
748
1685
|
timing: "immediate",
|
|
@@ -777,6 +1714,10 @@ var EXPAND_LABEL = "Expand to Paths";
|
|
|
777
1714
|
var expandPathEffectAction = {
|
|
778
1715
|
id: "expandPathEffect",
|
|
779
1716
|
label: EXPAND_LABEL,
|
|
1717
|
+
icon: (
|
|
1718
|
+
/* @icon */
|
|
1719
|
+
"path-tools:expand"
|
|
1720
|
+
),
|
|
780
1721
|
group: PATH_EFFECTS_GROUP,
|
|
781
1722
|
defaultBinding: { kind: "key", key: "e", code: "KeyE", mods: { alt: true, shift: true } },
|
|
782
1723
|
eligible: { capability: "creates-shapes" },
|
|
@@ -790,7 +1731,7 @@ var expandPathEffectAction = {
|
|
|
790
1731
|
const ops = [];
|
|
791
1732
|
const made = [];
|
|
792
1733
|
for (const { node, at: at2 } of effects) {
|
|
793
|
-
const sources = (Array.isArray(node.dependsOn) ? node.dependsOn : []).map((id) => derivedDepOf(scene, id));
|
|
1734
|
+
const sources = (Array.isArray(node.dependsOn) ? node.dependsOn : []).map((id) => derivedDepOf(scene, id, node));
|
|
794
1735
|
const parts = pathEffectParts(node, sources) ?? [];
|
|
795
1736
|
ops.push(deleteNode(scene, node, EXPAND_LABEL));
|
|
796
1737
|
parts.forEach((part, k) => {
|
|
@@ -890,16 +1831,21 @@ var simplifyBrushAction = inPlane({
|
|
|
890
1831
|
},
|
|
891
1832
|
enabled: (deps) => brushing(deps) ? true : ActionDisabledReason.NotApplicable
|
|
892
1833
|
}, editingLayer);
|
|
893
|
-
var simplifyBrushToolAction = pathEditToolAction(
|
|
1834
|
+
var simplifyBrushToolAction = pathEditToolAction(
|
|
1835
|
+
SIMPLIFY_BRUSH_TOOL,
|
|
1836
|
+
"Simplify brush",
|
|
1837
|
+
/* @icon */
|
|
1838
|
+
"core:simplifyBrush"
|
|
1839
|
+
);
|
|
894
1840
|
var DEFAULT_SMUDGE_RADIUS_PX = 40;
|
|
895
1841
|
var DEFAULT_SMUDGE_STRENGTH = 0.6;
|
|
896
|
-
var
|
|
897
|
-
var
|
|
1842
|
+
var LABEL4 = "Smudge brush";
|
|
1843
|
+
var num3 = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
|
|
898
1844
|
function radiusOf2(params) {
|
|
899
|
-
return Math.max(1,
|
|
1845
|
+
return Math.max(1, num3(params?.radiusPx, DEFAULT_SMUDGE_RADIUS_PX));
|
|
900
1846
|
}
|
|
901
1847
|
function strengthOf(params) {
|
|
902
|
-
return Math.min(1, Math.max(0,
|
|
1848
|
+
return Math.min(1, Math.max(0, num3(params?.strength, DEFAULT_SMUDGE_STRENGTH)));
|
|
903
1849
|
}
|
|
904
1850
|
function worldRadius(deps, radiusPx) {
|
|
905
1851
|
const reach = pxExtent(radiusPx, deps.view?.get().scale ?? { x: 1, y: 1 });
|
|
@@ -907,7 +1853,11 @@ function worldRadius(deps, radiusPx) {
|
|
|
907
1853
|
}
|
|
908
1854
|
var smudgeBrushAction = {
|
|
909
1855
|
id: "smudgeBrush",
|
|
910
|
-
label:
|
|
1856
|
+
label: LABEL4,
|
|
1857
|
+
icon: (
|
|
1858
|
+
/* @icon */
|
|
1859
|
+
"path-tools:smudgeBrush"
|
|
1860
|
+
),
|
|
911
1861
|
tags: pathOperationTags("brush"),
|
|
912
1862
|
group: "path",
|
|
913
1863
|
eligible: { capability: "creates-shapes" },
|
|
@@ -923,7 +1873,7 @@ var smudgeBrushAction = {
|
|
|
923
1873
|
let radiusPx = radiusOf2(params(ctx));
|
|
924
1874
|
let at2 = { x: ctx.drag?.start.x ?? ctx.world.x, y: ctx.drag?.start.y ?? ctx.world.y };
|
|
925
1875
|
const current = /* @__PURE__ */ new Map();
|
|
926
|
-
const
|
|
1876
|
+
const shapeOf2 = (id) => {
|
|
927
1877
|
const entry = current.get(id);
|
|
928
1878
|
return entry ? withPath(entry.node.node, entry.path) : null;
|
|
929
1879
|
};
|
|
@@ -951,16 +1901,259 @@ var smudgeBrushAction = {
|
|
|
951
1901
|
const done = [...current.values()];
|
|
952
1902
|
current.clear();
|
|
953
1903
|
if (reason === "cancel" || done.length === 0) return;
|
|
954
|
-
commitOps(deps, done.flatMap(({ node, path }) => reshapeOps(node.node, path,
|
|
1904
|
+
commitOps(deps, done.flatMap(({ node, path }) => reshapeOps(node.node, path, LABEL4)), LABEL4);
|
|
955
1905
|
},
|
|
956
1906
|
previewIds: () => current.size > 0 ? [...current.keys()] : null,
|
|
957
|
-
previewPose: (id) =>
|
|
958
|
-
previewData: (id) =>
|
|
1907
|
+
previewPose: (id) => shapeOf2(id)?.pose ?? null,
|
|
1908
|
+
previewData: (id) => shapeOf2(id)?.data ?? null
|
|
959
1909
|
};
|
|
960
1910
|
}
|
|
961
1911
|
},
|
|
962
1912
|
enabled: (deps) => selectedPathNodes(deps).length > 0 ? true : ActionDisabledReason.SelectionRequired
|
|
963
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
|
+
};
|
|
964
2157
|
var TRIM_LABEL = "Trim segment";
|
|
965
2158
|
function planTrim(deps, params) {
|
|
966
2159
|
const press = pressOf(deps, params);
|
|
@@ -975,6 +2168,10 @@ function planTrim(deps, params) {
|
|
|
975
2168
|
}
|
|
976
2169
|
var trimPathAction = {
|
|
977
2170
|
id: "trimPath",
|
|
2171
|
+
icon: (
|
|
2172
|
+
/* @icon */
|
|
2173
|
+
"path-tools:trim"
|
|
2174
|
+
),
|
|
978
2175
|
label: TRIM_LABEL,
|
|
979
2176
|
tags: pathOperationTags("click"),
|
|
980
2177
|
group: "path",
|
|
@@ -1049,10 +2246,8 @@ function resolvePathWarp(node) {
|
|
|
1049
2246
|
const made = heldWarpResult(held);
|
|
1050
2247
|
let toStorage = held.frame;
|
|
1051
2248
|
if (!sameUnder(made, toStorage, stored.path)) {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
toStorage = multiply(boxToBox(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height), toStorage);
|
|
1055
|
-
if (!sameUnder(made, toStorage, stored.path)) return null;
|
|
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;
|
|
1056
2251
|
}
|
|
1057
2252
|
return { held, toWorld: multiply(stored.toWorld, toStorage) };
|
|
1058
2253
|
}
|
|
@@ -1111,13 +2306,43 @@ var freshIds = 0;
|
|
|
1111
2306
|
function freshWarpId() {
|
|
1112
2307
|
return `warp-${Date.now().toString(36)}-${(++freshIds).toString(36)}`;
|
|
1113
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
|
+
}
|
|
1114
2324
|
function splitByWarp(nodes, kind) {
|
|
1115
2325
|
const held = [];
|
|
1116
2326
|
const bare = [];
|
|
2327
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1117
2328
|
for (const node of nodes) {
|
|
1118
2329
|
const resolved = resolvePathWarp(node.node);
|
|
1119
|
-
if (resolved
|
|
1120
|
-
|
|
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);
|
|
1121
2346
|
}
|
|
1122
2347
|
return { held, bare, box: worldBoxOf(bare) };
|
|
1123
2348
|
}
|
|
@@ -1255,7 +2480,7 @@ function createAddPinAction() {
|
|
|
1255
2480
|
if (warp.kind !== "puppet" || !back) continue;
|
|
1256
2481
|
const [lx, ly] = applyToPoint(back, at2.x, at2.y);
|
|
1257
2482
|
const to = { x: lx, y: ly };
|
|
1258
|
-
const from = unwarpPoint(pathWarpMap(warp), to);
|
|
2483
|
+
const from = unwarpPoint(pathWarpMap(warp, target.held.source), to);
|
|
1259
2484
|
const held = { ...target.held, warp: { ...warp, pins: [...warp.pins, { from, to }] } };
|
|
1260
2485
|
const shape = storeWarp(target.node.node, held, target.toWorld);
|
|
1261
2486
|
if (shape) next.set(target.node.id, { target, shape });
|
|
@@ -1301,6 +2526,10 @@ function createWarpGrabAction(kind) {
|
|
|
1301
2526
|
var expandWarpAction = {
|
|
1302
2527
|
id: "expandWarp",
|
|
1303
2528
|
label: "Expand Warp",
|
|
2529
|
+
icon: (
|
|
2530
|
+
/* @icon */
|
|
2531
|
+
"path-tools:expand"
|
|
2532
|
+
),
|
|
1304
2533
|
tags: pathOperationTags("command"),
|
|
1305
2534
|
group: "path",
|
|
1306
2535
|
requires: REQUIRES,
|
|
@@ -1320,6 +2549,10 @@ var expandWarpAction = {
|
|
|
1320
2549
|
var configureWarpAction = {
|
|
1321
2550
|
id: "configureWarp",
|
|
1322
2551
|
label: "Warp Settings",
|
|
2552
|
+
icon: (
|
|
2553
|
+
/* @icon */
|
|
2554
|
+
"core:tune"
|
|
2555
|
+
),
|
|
1323
2556
|
tags: pathOperationTags("command"),
|
|
1324
2557
|
group: "path",
|
|
1325
2558
|
requires: REQUIRES,
|
|
@@ -1350,14 +2583,38 @@ var handleLocksPreset = {
|
|
|
1350
2583
|
id: "handleLocks",
|
|
1351
2584
|
actions: [handleLockAngleAction, handleLockLengthAction]
|
|
1352
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();
|
|
1353
2593
|
var bridgePreset = {
|
|
1354
2594
|
id: "bridge",
|
|
1355
2595
|
actions: [bridgeAnchorsAction, bridgePathsAction]
|
|
1356
2596
|
};
|
|
1357
2597
|
var trimPreset = { id: "trim", actions: [trimPathAction] };
|
|
1358
2598
|
var extendPreset = { id: "extend", actions: [extendToMeetAction] };
|
|
2599
|
+
var lengthenPreset = {
|
|
2600
|
+
id: "lengthen",
|
|
2601
|
+
actions: [lengthenPathAction, lengthenToolAction]
|
|
2602
|
+
};
|
|
1359
2603
|
var smudgePreset = { id: "smudge", actions: [smudgeBrushAction] };
|
|
1360
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] };
|
|
1361
2618
|
function createCurvatureCombPreset(options = () => void 0) {
|
|
1362
2619
|
return {
|
|
1363
2620
|
id: "curvatureComb",
|
|
@@ -1384,6 +2641,29 @@ function createConstraintsPreset(style = {}) {
|
|
|
1384
2641
|
};
|
|
1385
2642
|
}
|
|
1386
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
|
+
};
|
|
1387
2667
|
var pathEffectsPreset = {
|
|
1388
2668
|
id: "pathEffects",
|
|
1389
2669
|
actions: [blendAction, bendAlongAction, repeatAlongAction, editPathEffectAction, expandPathEffectAction]
|
|
@@ -1398,13 +2678,21 @@ var pathTools = {
|
|
|
1398
2678
|
simplifyBrushPreset,
|
|
1399
2679
|
handleLocksPreset,
|
|
1400
2680
|
bridgePreset,
|
|
2681
|
+
spiroPreset,
|
|
1401
2682
|
trimPreset,
|
|
1402
2683
|
extendPreset,
|
|
2684
|
+
lengthenPreset,
|
|
1403
2685
|
smudgePreset,
|
|
1404
2686
|
harmonizePreset,
|
|
2687
|
+
anchorSelectionPreset,
|
|
2688
|
+
resamplePreset,
|
|
2689
|
+
addExtremesPreset,
|
|
1405
2690
|
curvatureCombPreset,
|
|
1406
2691
|
balancePreset,
|
|
1407
2692
|
constraintsPreset,
|
|
2693
|
+
pathStructurePreset,
|
|
2694
|
+
segmentTypePreset,
|
|
2695
|
+
cornerShapePreset,
|
|
1408
2696
|
pathEffectsPreset,
|
|
1409
2697
|
warpPreset
|
|
1410
2698
|
]
|
|
@@ -1420,7 +2708,7 @@ function useStrokeClickTool(spec, options, extras = {}) {
|
|
|
1420
2708
|
capabilities: ["creates-shapes"],
|
|
1421
2709
|
hookName,
|
|
1422
2710
|
cursor: "crosshair",
|
|
1423
|
-
presentation: { label, group: "path", icon
|
|
2711
|
+
presentation: { label, group: "path", icon },
|
|
1424
2712
|
...extras.actions ? { actions: extras.actions } : {},
|
|
1425
2713
|
...extras.overlay ? { overlay: extras.overlay } : {},
|
|
1426
2714
|
...extras.onDeactivate ? { onDeactivate: extras.onDeactivate } : {},
|
|
@@ -1487,7 +2775,10 @@ function useTrimTool(options = {}) {
|
|
|
1487
2775
|
};
|
|
1488
2776
|
}, []);
|
|
1489
2777
|
return useStrokeClickTool(
|
|
1490
|
-
{ id: "trim", label: "Trim segment", icon:
|
|
2778
|
+
{ id: "trim", label: "Trim segment", icon: (
|
|
2779
|
+
/* @icon */
|
|
2780
|
+
"path-tools:trim"
|
|
2781
|
+
), hookName: "useTrimTool", actionId: "trimPath", keybinding: TRIM_KEY },
|
|
1491
2782
|
options,
|
|
1492
2783
|
extras
|
|
1493
2784
|
);
|
|
@@ -1498,7 +2789,10 @@ function useExtendTool(options = {}) {
|
|
|
1498
2789
|
{
|
|
1499
2790
|
id: "extend",
|
|
1500
2791
|
label: "Extend to next path",
|
|
1501
|
-
icon:
|
|
2792
|
+
icon: (
|
|
2793
|
+
/* @icon */
|
|
2794
|
+
"path-tools:extend"
|
|
2795
|
+
),
|
|
1502
2796
|
hookName: "useExtendTool",
|
|
1503
2797
|
actionId: "extendToMeet",
|
|
1504
2798
|
keybinding: EXTEND_KEY
|
|
@@ -1515,7 +2809,10 @@ function useSmudgeBrushTool(options = {}) {
|
|
|
1515
2809
|
capabilities: ["creates-shapes"],
|
|
1516
2810
|
hookName: "useSmudgeBrushTool",
|
|
1517
2811
|
cursor: "crosshair",
|
|
1518
|
-
presentation: { label: "Smudge brush", group: "path"
|
|
2812
|
+
presentation: { label: "Smudge brush", group: "path", icon: (
|
|
2813
|
+
/* @icon */
|
|
2814
|
+
"path-tools:smudgeBrush"
|
|
2815
|
+
) },
|
|
1519
2816
|
bindings: [{ spec: { kind: "drag" }, actionId: "smudgeBrush" }]
|
|
1520
2817
|
}), [keybinding]);
|
|
1521
2818
|
}
|
|
@@ -1531,15 +2828,15 @@ function groupsOf(src, state) {
|
|
|
1531
2828
|
const scene = src.scene();
|
|
1532
2829
|
if (!scene || !src.active()) return [];
|
|
1533
2830
|
const nodes = pathNodesOf(scene, state.selection, (id) => src.live.get(id));
|
|
1534
|
-
const { held, box } = splitByWarp(nodes, src.kind);
|
|
2831
|
+
const { held, bare, box } = splitByWarp(nodes, src.kind);
|
|
1535
2832
|
const out = [];
|
|
1536
2833
|
const seen = /* @__PURE__ */ new Set();
|
|
1537
2834
|
for (const t of held) {
|
|
1538
2835
|
if (seen.has(t.held.id)) continue;
|
|
1539
2836
|
seen.add(t.held.id);
|
|
1540
|
-
out.push({ warpId: t.held.id, warp: t.held.warp, toWorld: t.toWorld });
|
|
2837
|
+
out.push({ warpId: t.held.id, warp: t.held.warp, source: t.held.source, toWorld: t.toWorld });
|
|
1541
2838
|
}
|
|
1542
|
-
if (box) out.push({ warpId: null, warp: freshWarp(src.kind, box, src.settings()), toWorld: IDENTITY });
|
|
2839
|
+
if (box) out.push({ warpId: null, warp: freshWarp(src.kind, box, src.settings()), source: bare[0].path, toWorld: IDENTITY });
|
|
1543
2840
|
return out;
|
|
1544
2841
|
}
|
|
1545
2842
|
var toWorld = (m, p) => {
|
|
@@ -1563,7 +2860,7 @@ function guideLines(group) {
|
|
|
1563
2860
|
return [[...c, c[0]]];
|
|
1564
2861
|
}
|
|
1565
2862
|
if (warp.kind !== "envelope") return [];
|
|
1566
|
-
const map = pathWarpMap(warp);
|
|
2863
|
+
const map = pathWarpMap(warp, group.source);
|
|
1567
2864
|
const { box, mesh } = warp;
|
|
1568
2865
|
const at2 = (u, v) => toWorld(m, map({ x: box.x + u * box.width, y: box.y + v * box.height }));
|
|
1569
2866
|
const lines = [];
|
|
@@ -1609,8 +2906,8 @@ function warpAffordance(src) {
|
|
|
1609
2906
|
const t = viewToTransform(view);
|
|
1610
2907
|
const out = [];
|
|
1611
2908
|
for (const group of groupsOf(src, state)) {
|
|
1612
|
-
for (const
|
|
1613
|
-
const screen =
|
|
2909
|
+
for (const line3 of guideLines(group)) {
|
|
2910
|
+
const screen = line3.map((p) => {
|
|
1614
2911
|
const [x, y] = worldToScreen(p.x, p.y, t);
|
|
1615
2912
|
return { x, y };
|
|
1616
2913
|
});
|
|
@@ -1628,6 +2925,16 @@ function createWarpHandleLayer(src) {
|
|
|
1628
2925
|
}
|
|
1629
2926
|
|
|
1630
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
|
+
) };
|
|
1631
2938
|
var ANY_MODS = { shift: "optional", alt: "optional", meta: "optional", ctrl: "optional" };
|
|
1632
2939
|
function bindingsFor(kind, settings) {
|
|
1633
2940
|
const target = `affordance:layer:${warpLayerId(kind)}`;
|
|
@@ -1646,7 +2953,7 @@ function bindingsFor(kind, settings) {
|
|
|
1646
2953
|
function definedOf(settings) {
|
|
1647
2954
|
return Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== void 0));
|
|
1648
2955
|
}
|
|
1649
|
-
var KEYS = { distort: { key: "F" }, envelope: { key: "
|
|
2956
|
+
var KEYS = { distort: { key: "F" }, envelope: { key: "F", shift: true }, puppet: { key: "B" } };
|
|
1650
2957
|
function createWarpToolState() {
|
|
1651
2958
|
let active = false;
|
|
1652
2959
|
let api = null;
|
|
@@ -1705,7 +3012,7 @@ function useWarpTool(kind, options, settings) {
|
|
|
1705
3012
|
capabilities: ["creates-shapes"],
|
|
1706
3013
|
cursor: kind === "puppet" ? "crosshair" : "default",
|
|
1707
3014
|
actions,
|
|
1708
|
-
presentation: { label: label ?? WARP_LABELS[kind], group: "warp" },
|
|
3015
|
+
presentation: { label: label ?? WARP_LABELS[kind], group: "warp", icon: WARP_ICONS[kind] },
|
|
1709
3016
|
onActivate: () => state.setActive(true),
|
|
1710
3017
|
onDeactivate: () => state.setActive(false),
|
|
1711
3018
|
bindings: bindingsFor(kind, () => definedOf(settingsRef.current))
|
|
@@ -1724,7 +3031,870 @@ function useEnvelopeTool(options = {}) {
|
|
|
1724
3031
|
function usePuppetTool(options = {}) {
|
|
1725
3032
|
return useWarpTool("puppet", options, { tolerance: options.tolerance });
|
|
1726
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
|
+
}
|
|
1727
3897
|
|
|
1728
|
-
export { CONSTRAINTS_EXTENSION_ID, DEFAULT_BRIDGE_TENSION, DEFAULT_ENVELOPE_SIZE, DEFAULT_SMUDGE_RADIUS_PX, DEFAULT_SMUDGE_STRENGTH, DEFAULT_WARP_TOLERANCE, PATH_BALANCE_CHROME_ID, PATH_COMB_CHROME_ID, PATH_EFFECTS_GROUP, SIMPLIFY_BRUSH_RADIUS_PX, SIMPLIFY_BRUSH_TOOL, balanceCommands, balanceHandlesAction, balancePreset, bendAlongAction, blendAction, bridgeAnchorsAction, bridgePathsAction, bridgePreset, bridgeTensionOf, brushRingCursor, combCommands, configureWarpAction, constrainAnchorsAction, constraintAxisOf, constraintConflictsOf, constraintsForAnchors, constraintsOf, constraintsPreset, createBalanceExtension, createBalancePreset, createCombExtension, createConstraintsExtension, createConstraintsPreset, createCurvatureCombPreset, curvatureCombPreset, deleteConstraintAction, editPathEffectAction, equalizeHandlesAction, expandPathEffectAction, expandWarpAction, extendPreset, extendToMeetAction, handleLockAngleAction, handleLockLengthAction, handleLocksPreset, harmonizeAnchorsAction, harmonizePreset, heldPathWarpOf, pathEffectsPreset, pathTools, planTrim, repeatAlongAction, resolvePathWarp, selectedConstraintOf, setConstraints, simplifyBrushAction, simplifyBrushPreset, simplifyBrushToolAction, smudgeBrushAction, smudgePreset, trimPathAction, trimPreset, unconstrainAnchorsAction, useDistortTool, useEnvelopeTool, useExtendTool, usePuppetTool, useSmudgeBrushTool, useTrimTool, warpHandleScratchOf, warpLayerId, warpPreset, withPathWarp, withoutPathWarp };
|
|
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 };
|
|
1729
3899
|
//# sourceMappingURL=index.js.map
|
|
1730
3900
|
//# sourceMappingURL=index.js.map
|