@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/dist/index.js CHANGED
@@ -1,10 +1,15 @@
1
- import { inPlane, editedPathSymmetries, pathEditPreview, locksInForce, pathOperationTags, editingLayer, joinPathsCommand, BridgeIcon, joinSelectedAnchors, makeHandleCommand, HarmonizeIcon, depOf, pathEditToolAction, SimplifyBrushIcon, DEFAULT_OVERLAY_STYLE, pathEditToolActive, registeredPathEffects, slotOf, pathEffectNode, commitOps, insertAt, DEFAULT_SIMPLIFY_TOLERANCE, deleteSelectedMark, resolveEditablePathOf, classifyPathStorage, commitAnchorEdit, anchorEditWouldChange, reshapeOps, HoldHandleAngleIcon, HoldHandleLengthIcon, pathEffectOf, createSetDataOp, derivedDepOf, pathEffectParts, deleteNode, selectedPathNodes, withPath, never, defineTool, nodeOutline, reshapeTo, composeAffordanceLayer, viewToTransform, worldToScreen, pathNodesOf, OVERLAY_ROLE_STROKES, SliceIcon } from '@weasel-js/core';
2
- import { isBalanceGrip, ActionDisabledReason, resolveParams } from '@weasel-js/routing';
3
- import { pathToAnchors, balanceFactorToward, editAnchorSetTracked, scaleSegmentHandles, harmonizeAnchors, simplifyAnchorsAt, locateAnchor, anchorsUnderBrush, pathAnchorPoints, unsatisfiedConstraints, solvePathConstraints, constraintAnchors, constrainPathEdit, constraintFits, PathBuilder, equalizeSegmentHandles, handleBalances, linePath, circlePath, curvatureComb, polylineFromPoints, worldEditToStorage, smudgePath, trimPathAt, boundsOfPath, transformPath, multiply, boxToBox, identity, openPathEnds, extendToMeet, pxExtent, poseFrameOf, applyPathWarp, invert, resizeEnvelope, pathDistanceToPoint, openEndRay, warpControls, applyToPoint, moveWarpControl, unwarpPoint, pathWarpMap, envelopeOver, distortOver, ellipsePath } from '@weasel-js/geom';
4
- import { jsx } from 'react/jsx-runtime';
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 { useMemo, createElement } from 'react';
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.modifiers).length) {
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 line2 of handleBalances(set)) {
104
- const grip = toScreen(line2.grip);
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(line2.from), toScreen(line2.to)),
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((line2) => ({
124
- x: line2.grip.x,
125
- y: line2.grip.y,
126
- hitKind: `balance:${line2.flat}`,
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: BridgeIcon,
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: BridgeIcon,
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 unsatisfiedConstraints(pathAnchorPoints(path), constraints, { axisAngle: constraintAxisOf(node) });
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 same = edit.constraints.length === constraints.length && edit.constraints.every((c, i) => JSON.stringify(c) === JSON.stringify(constraints[i]));
216
- return { path: edit.path, constraints: same ? constraints : edit.constraints };
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, unsatisfied: [] };
224
- return { path, unsatisfied: unsatisfiedConstraints(pathAnchorPoints(path), constraints, { axisAngle }) };
466
+ if (solved.converged) return { path: solved.path, conflicts: [] };
467
+ return { path, conflicts: conflictsIn(path, constraints, axisAngle) };
468
+ }
469
+ function constraintLinkOf(data) {
470
+ const c = data?.constraintLink;
471
+ return c && typeof c === "object" && typeof c.kind === "string" ? c : null;
472
+ }
473
+ var CONSTRAINT_LINK_SHAPE = {
474
+ id: "path-tools:constraint-link",
475
+ matches: (node) => constraintLinkOf(node.data) !== null,
476
+ paint: () => [],
477
+ bounds: () => null
478
+ };
479
+ var linkShapeRegistered = false;
480
+ function registerConstraintLinkShape() {
481
+ if (linkShapeRegistered) return;
482
+ linkShapeRegistered = true;
483
+ registerNodeShape(CONSTRAINT_LINK_SHAPE, { priority: "high" });
484
+ }
485
+ function constraintLinkNode(opts) {
486
+ registerConstraintLinkShape();
487
+ return {
488
+ kind: "leaf",
489
+ layer: opts.layer,
490
+ pose: opts.pose ?? { x: 0, y: 0, width: 0, height: 0 },
491
+ data: { constraintLink: opts.constraint },
492
+ dependsOn: opts.nodes,
493
+ pickable: false,
494
+ ...opts.id ? { id: opts.id } : {}
495
+ };
496
+ }
497
+ function linkAt(ctx, id) {
498
+ const node = ctx.node(id);
499
+ const constraint = constraintLinkOf(node?.data);
500
+ const nodes = node?.dependsOn;
501
+ return constraint && Array.isArray(nodes) ? { id, nodes, constraint } : null;
502
+ }
503
+ function linkedFrom(ctx, start) {
504
+ const nodes = [start];
505
+ const links = /* @__PURE__ */ new Map();
506
+ for (let k = 0; k < nodes.length; k++) {
507
+ for (const id of ctx.dependents(nodes[k])) {
508
+ const link = links.has(id) ? null : linkAt(ctx, id);
509
+ if (!link) continue;
510
+ links.set(id, link);
511
+ for (const n of link.nodes) if (!nodes.includes(n)) nodes.push(n);
512
+ }
513
+ }
514
+ return { nodes, links: [...links.values()] };
515
+ }
516
+ var same = (a, b) => a.length === b.length && a.every((c, i) => JSON.stringify(c) === JSON.stringify(b[i]));
517
+ function finishLinkedEdit(ctx, worldPath, origins) {
518
+ const { nodes, links } = linkedFrom(ctx, ctx.id);
519
+ if (links.length === 0) return void 0;
520
+ const items = [];
521
+ for (const [k, id] of nodes.entries()) {
522
+ const node = ctx.node(id);
523
+ const before = ctx.path(id);
524
+ if (!node || !before) return void 0;
525
+ items.push({
526
+ before,
527
+ after: k === 0 ? worldPath : before,
528
+ constraints: constraintsOf(node.data),
529
+ axisAngle: constraintAxisOf(node),
530
+ ...k === 0 ? { origins } : {}
531
+ });
532
+ }
533
+ const pathLinks = links.map((l) => ({ constraint: l.constraint, paths: l.nodes.map((n) => nodes.indexOf(n)) }));
534
+ const done = constrainPathsEdit(items, pathLinks);
535
+ if (!done.converged) return null;
536
+ const others = [];
537
+ done.items.forEach((item, k) => {
538
+ if (k === 0) return;
539
+ const had = constraintsOf(ctx.node(nodes[k])?.data);
540
+ const changed = !same(had, item.constraints);
541
+ if (item.path !== items[k].after || changed) {
542
+ others.push({ id: nodes[k], path: item.path, ...changed ? { data: { constraints: item.constraints } } : {} });
543
+ }
544
+ });
545
+ done.links.forEach((l, k) => {
546
+ const link = links[k];
547
+ if (!l.constraint) others.push({ id: link.id, remove: true });
548
+ else if (JSON.stringify(l.constraint) !== JSON.stringify(link.constraint)) {
549
+ others.push({ id: link.id, data: { constraintLink: l.constraint } });
550
+ }
551
+ });
552
+ const own = constraintsOf(ctx.node(ctx.id)?.data);
553
+ const [first] = done.items;
554
+ return { path: first.path, constraints: same(own, first.constraints) ? own : first.constraints, others };
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 === "fixed" ? c.anchor : c.anchors[0]];
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.moveTo(ring.x + 4, ring.y);
318
- for (let k = 1; k <= 12; k++) b.lineTo(ring.x + 4 * Math.cos(k * Math.PI / 6), ring.y + 4 * Math.sin(k * Math.PI / 6));
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 editedPath(dep) {
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 false;
720
+ if (!solved) return null;
367
721
  dep.setSelectedMark?.(null);
368
722
  dep.store(dep.editingId, solved.path, label, { constraints: [...constraints] });
369
- return true;
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 axisAngle = constraintAxisOf(ctx.node);
385
- const unsatisfied = new Set(unsatisfiedConstraints(world, constraints, { axisAngle }));
386
- return constraintGlyphs(world.map(ctx.toScreen), constraints, unsatisfied, colors, ctx.selected);
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 = editedPath(dep);
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 picked = ends.filter((e) => [...anchors].some((flat) => {
540
- const at2 = locateAnchor(set, flat);
541
- if (!at2 || at2.sub !== e.sub) return false;
542
- return e.end === "first" ? at2.idx === 0 : at2.idx === set.anchors[at2.sub].length - 1;
543
- }));
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("angle", "Hold handle angle", /* @__PURE__ */ jsx(HoldHandleAngleIcon, {}));
610
- var handleLockLengthAction = makeHandleLockToggle("length", "Hold handle length", /* @__PURE__ */ jsx(HoldHandleLengthIcon, {}));
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
- /* @__PURE__ */ jsx(HarmonizeIcon, {}),
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 num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
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: num(p.steps) === void 0 ? void 0 : Math.max(Math.round(num(p.steps)), 0),
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: num(p.startOffset) === void 0 ? void 0 : Math.round(num(p.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: num(p.baseline),
1558
+ baseline: num2(p.baseline),
641
1559
  flip: bool(p.flip),
642
- tolerance: num(p.tolerance) === void 0 ? void 0 : Math.max(num(p.tolerance), 0.01)
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: num(p.count) === void 0 ? void 0 : Math.max(Math.round(num(p.count)), 0),
647
- spacing: num(p.spacing) === void 0 ? void 0 : Math.max(num(p.spacing), 0),
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: num(p.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 selectedPair(deps) {
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 ? [leaves[0], leaves[1]] : null;
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 pair = selectedPair(deps);
695
- if (!scene || !pair) return;
1613
+ const leaves = selectedLeaves(deps);
1614
+ if (!scene || !leaves) return;
696
1615
  const derivations = registeredPathEffects(scene.registry);
697
- const [a, b] = pair;
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, [a.id, b.id], a.layer, derivations),
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 selectedPair(deps) ? true : ActionDisabledReason.SelectionRequired;
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(SIMPLIFY_BRUSH_TOOL, "Simplify brush", SimplifyBrushIcon);
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 LABEL = "Smudge brush";
897
- var num2 = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
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, num2(params?.radiusPx, DEFAULT_SMUDGE_RADIUS_PX));
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, num2(params?.strength, DEFAULT_SMUDGE_STRENGTH)));
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: 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 shapeOf = (id) => {
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, LABEL)), LABEL);
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) => shapeOf(id)?.pose ?? null,
958
- previewData: (id) => shapeOf(id)?.data ?? null
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
- const a = boundsOfPath(transformPath(made, toStorage));
1053
- const b = boundsOfPath(stored.path);
1054
- toStorage = multiply(boxToBox(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height), toStorage);
1055
- if (!sameUnder(made, toStorage, stored.path)) return null;
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 && resolved.held.warp.kind === kind) held.push({ node, ...resolved, fresh: false });
1120
- else bare.push(node);
2330
+ if (!resolved || resolved.held.warp.kind !== kind) {
2331
+ bare.push(node);
2332
+ continue;
2333
+ }
2334
+ let target = { node, ...resolved, fresh: false };
2335
+ const id = resolved.held.id;
2336
+ const firsts = groups.get(id) ?? [];
2337
+ const joined = firsts.find((f) => samePlacement(f, target));
2338
+ const groupId = joined ? joined.held.id : firsts.length > 0 ? `${id}~${node.id}` : id;
2339
+ if (groupId !== id) {
2340
+ const parted = { ...resolved.held, id: groupId };
2341
+ rememberWarpResult(parted, heldWarpResult(resolved.held));
2342
+ target = { ...target, held: parted };
2343
+ }
2344
+ if (!joined) groups.set(id, [...firsts, target]);
2345
+ held.push(target);
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: createElement(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: SliceIcon, hookName: "useTrimTool", actionId: "trimPath", keybinding: TRIM_KEY },
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: SliceIcon,
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 line2 of guideLines(group)) {
1613
- const screen = line2.map((p) => {
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: "M" }, puppet: { key: "B" } };
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