castle-web-cli 0.4.115 → 0.4.117

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.
Files changed (55) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +6 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.js +27 -3
  7. package/dist/imports.d.ts +4 -0
  8. package/dist/imports.js +59 -10
  9. package/dist/init.d.ts +3 -0
  10. package/dist/init.js +103 -87
  11. package/dist/install.d.ts +1 -1
  12. package/dist/install.js +26 -24
  13. package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
  14. package/dist/shell/index.html +1 -1
  15. package/dist/vitePlugins.js +1 -1
  16. package/kits/physics-2d/CLAUDE.md +28 -16
  17. package/kits/physics-2d/behaviors/Joints.jsx +1 -1
  18. package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
  19. package/kits/physics-2d/blueprints/ball.scene +1 -1
  20. package/kits/physics-2d/blueprints/block.scene +1 -1
  21. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  22. package/kits/physics-2d/blueprints/crate.scene +1 -1
  23. package/kits/physics-2d/castle.json +11 -3
  24. package/kits/physics-2d/docs/pxart-format.md +537 -51
  25. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  26. package/kits/physics-2d/editors/brushFit.js +535 -0
  27. package/kits/physics-2d/editors/brushShapes.js +140 -0
  28. package/kits/physics-2d/editors/mediaFile.js +12 -1
  29. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  30. package/kits/physics-2d/editors/pathTools.js +1906 -0
  31. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  32. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  33. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  34. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  35. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  36. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  37. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  38. package/kits/physics-2d/engine/assets.js +1 -1
  39. package/kits/physics-2d/engine/blueprint.js +3 -3
  40. package/kits/physics-2d/engine/files.js +3 -1
  41. package/kits/physics-2d/engine/liveReload.js +1 -1
  42. package/kits/physics-2d/engine/physics/jointArt.js +3 -3
  43. package/kits/physics-2d/engine/pxart.js +153 -35
  44. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  45. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  46. package/kits/physics-2d/engine/ui.jsx +22 -1
  47. package/kits/physics-2d/engine/ui.module.css +36 -12
  48. package/kits/physics-2d/package-lock.json +1 -1
  49. package/kits/physics-2d/scripts/draw.mjs +7 -7
  50. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  51. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  52. package/package.json +1 -1
  53. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  54. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  55. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
@@ -0,0 +1,1906 @@
1
+ import {
2
+ absoluteSegment,
3
+ bowForSegment,
4
+ bowToSegments,
5
+ closestParamOnEdge,
6
+ cubicPoint,
7
+ clonePathData,
8
+ cloneSubpath,
9
+ deCasteljauSplit,
10
+ deriveShape,
11
+ edgeObjectsForSubpath,
12
+ flattenSubpath,
13
+ isPrimitiveShape,
14
+ LATTICE,
15
+ magneticSnapPoint,
16
+ MIN_ELLIPSE_RADIUS,
17
+ pointsEqual,
18
+ normalizeSubpath,
19
+ primitiveHandleIndices,
20
+ roundCoord,
21
+ sampleEdgeCurve,
22
+ snapBow,
23
+ shapeContainsPoint,
24
+ straightSegment,
25
+ subpathClosesOnStart,
26
+ subpathEdgeCount,
27
+ subpathEdgeFrom,
28
+ subpathPointAt,
29
+ subpathPoints,
30
+ subpathVertexCount,
31
+ transformSegmentHandles,
32
+ translatePathData,
33
+ } from '../engine/pxartPath.js';
34
+
35
+ // Pure data operations + hit-testing for stacked-shape path layers (pen /
36
+ // path-select / fill+stroke recolor). Mutators return a NEW path object; the editor
37
+ // rebakes via withPathAt + history.commit(serializeModel(...)).
38
+
39
+ const HIT_RADIUS = 0.75;
40
+ /** Generous pick radius (cell units) for stroked outlines / edge bodies. */
41
+ const EDGE_HIT = 1.25;
42
+ const STROKE_HIT = EDGE_HIT;
43
+
44
+ /** Screen-space hit targets — converted to cell units via hitRadiusCells. */
45
+ export const HIT_RADIUS_SCREEN_PX = 12;
46
+ export const EDGE_HIT_SCREEN_PX = 20;
47
+ export const OPEN_END_HIT_SCREEN_PX = 10;
48
+
49
+ export function hitRadiusCells(screenPx, cellPx) {
50
+ return cellPx > 0 ? screenPx / cellPx : screenPx / 16;
51
+ }
52
+
53
+ export function snapToWholeCell(x, y) {
54
+ return [Math.round(x), Math.round(y)];
55
+ }
56
+
57
+ export function snapToPixelCenter(x, y, width, height) {
58
+ const sx = Math.round(x - 0.5) + 0.5;
59
+ const sy = Math.round(y - 0.5) + 0.5;
60
+ if (width == null || height == null) return [sx, sy];
61
+ const maxX = Math.max(0.5, width - 0.5);
62
+ const maxY = Math.max(0.5, height - 0.5);
63
+ return [clamp(sx, 0.5, maxX), clamp(sy, 0.5, maxY)];
64
+ }
65
+
66
+ function clamp(value, lo, hi) {
67
+ return Math.min(hi, Math.max(lo, value));
68
+ }
69
+
70
+ // Path placement snap: magnetic pull toward the half-cell lattice (Alt → corners).
71
+ // `cellPull` is in cell units (from magneticCellPull); omit for a tight default.
72
+ export function snapPenAnchor(x, y, halfPixel = false, cellPull = LATTICE / 2) {
73
+ return magneticSnapPoint(x, y, cellPull, halfPixel);
74
+ }
75
+
76
+ export function snapPenAnchorClamped(x, y, halfPixel, cellPull, width, height) {
77
+ const [mx, my] = magneticSnapPoint(x, y, cellPull, halfPixel);
78
+ if (width == null || height == null) return [mx, my];
79
+ return [clamp(mx, 0, width), clamp(my, 0, height)];
80
+ }
81
+
82
+ /**
83
+ * Where a pen click would place/insert an anchor.
84
+ * Returns `{ point, edge }` — `edge` is the hit-test result when insert would
85
+ * fire (same shapeIndex/subIndex/edgeIndex as insertAnchorOnEdge), else null.
86
+ */
87
+ export function previewPenLanding(path, soleShapeIndex, x, y, halfPixel = false, cellPull = LATTICE / 2) {
88
+ if (soleShapeIndex != null && soleShapeIndex >= 0) {
89
+ const hit = hitTestShapeEdge(path, soleShapeIndex, x, y);
90
+ if (hit) {
91
+ const ends = endpointsForEdge(path, hit.shapeIndex, hit.subIndex, hit.edgeIndex);
92
+ if (ends?.edge) {
93
+ const split = splitSegmentAtNearest(ends.edge.from, ends.edge.seg, x, y, cellPull);
94
+ // `hit`, not the segment: callers read shapeIndex/subIndex/edgeIndex off it.
95
+ if (split) return { point: split.mid, edge: hit };
96
+ }
97
+ }
98
+ }
99
+ return { point: snapPenAnchor(x, y, halfPixel, cellPull), edge: null };
100
+ }
101
+
102
+ /** Apply magnetic pull to an absolute position after a free drag. */
103
+ export function snapDragPoint(x, y, halfPixel = false, cellPull = LATTICE / 2) {
104
+ return magneticSnapPoint(x, y, cellPull, halfPixel);
105
+ }
106
+
107
+ function magneticStep(n, step, cellPull) {
108
+ if (!(cellPull > 0) || !Number.isFinite(n)) return n;
109
+ const target = Math.round(n / step) * step;
110
+ return Math.abs(n - target) <= cellPull ? target + 0 : n;
111
+ }
112
+
113
+ /**
114
+ * Magnetize a translation rather than each point it carries.
115
+ *
116
+ * A group move has to be rigid. Magnetizing every point independently pulls
117
+ * on-lattice anchors and off-lattice ones — an arc's inserted midpoint, or
118
+ * imported SVG — by different amounts, so the outline deforms as it travels:
119
+ * a purely horizontal drag slides a curve's midpoint vertically. Snapping the
120
+ * delta instead preserves the shape's internal phase, which also matters
121
+ * because stroke and fill rasterize differently at different phases.
122
+ */
123
+ export function snapDragDelta(dx, dy, halfPixel = false, cellPull = 0) {
124
+ const step = halfPixel ? LATTICE : 1;
125
+ return [magneticStep(dx, step, cellPull), magneticStep(dy, step, cellPull)];
126
+ }
127
+
128
+ function distSq(x0, y0, x1, y1) {
129
+ const dx = x1 - x0;
130
+ const dy = y1 - y0;
131
+ return dx * dx + dy * dy;
132
+ }
133
+
134
+ /** Squared distance from (x,y) to the closest sample on an edge curve. */
135
+ function minDistSqToEdge(edge, verts, x, y) {
136
+ let best = Infinity;
137
+ for (const s of sampleEdgeCurve(edge, verts)) {
138
+ const d = distSq(s.x, s.y, x, y);
139
+ if (d < best) best = d;
140
+ }
141
+ return best;
142
+ }
143
+
144
+ function nearStrokedOutline(shape, x, y, radius = STROKE_HIT) {
145
+ if (!shape?.stroke) return false;
146
+ const r2 = radius * radius;
147
+ for (const sub of shape.subpaths ?? []) {
148
+ const { verts, edges } = edgeObjectsForSubpath(sub, shape.stroke);
149
+ for (const edge of edges) {
150
+ if (minDistSqToEdge(edge, verts, x, y) <= r2) return true;
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /** Topmost shape whose fill contains (x,y) or whose stroke paints nearby, or -1. */
157
+ export function hitTestShape(path, x, y, strokeRadius = STROKE_HIT) {
158
+ const shapes = path?.shapes ?? [];
159
+ for (let i = shapes.length - 1; i >= 0; i--) {
160
+ const shape = shapes[i];
161
+ const inFill = !!shape.fill && shapeContainsPoint(shape, x, y);
162
+ if (inFill || nearStrokedOutline(shape, x, y, strokeRadius)) return i;
163
+ }
164
+ return -1;
165
+ }
166
+
167
+ /** Closest anchor on one shape within HIT_RADIUS, or null. */
168
+ export function hitTestShapeAnchor(path, shapeIndex, x, y, radius = HIT_RADIUS) {
169
+ const shape = path?.shapes?.[shapeIndex];
170
+ if (!shape) return null;
171
+ // A primitive's ring is regenerated from its parameters, so most of it is not
172
+ // grabbable — only the four axis anchors, which drag as radii.
173
+ const handles = isPrimitiveShape(shape) ? new Set(primitiveHandleIndices(shape)) : null;
174
+ const r2 = radius * radius;
175
+ let best = null;
176
+ let bestD = r2;
177
+ (shape.subpaths ?? []).forEach((sub, subIndex) => {
178
+ subpathPoints(sub).forEach((p, pointIndex) => {
179
+ if (handles && !handles.has(pointIndex)) return;
180
+ const d = distSq(p[0], p[1], x, y);
181
+ if (d <= bestD) {
182
+ bestD = d;
183
+ best = { shapeIndex, subIndex, pointIndex };
184
+ }
185
+ });
186
+ });
187
+ return best;
188
+ }
189
+
190
+ /** Closest edge body on one shape (not near an anchor), or null. */
191
+ export function hitTestShapeEdge(path, shapeIndex, x, y, radiusPx = EDGE_HIT) {
192
+ const shape = path?.shapes?.[shapeIndex];
193
+ if (!shape) return null;
194
+ if (isPrimitiveShape(shape)) return null;
195
+ if (hitTestShapeAnchor(path, shapeIndex, x, y)) return null;
196
+ const r2 = radiusPx * radiusPx;
197
+ let best = null;
198
+ let bestD = r2;
199
+ (shape.subpaths ?? []).forEach((sub, subIndex) => {
200
+ const { verts, edges } = edgeObjectsForSubpath(sub, shape.stroke);
201
+ edges.forEach((edge, edgeIndex) => {
202
+ const d = minDistSqToEdge(edge, verts, x, y);
203
+ if (d <= bestD) {
204
+ bestD = d;
205
+ best = {
206
+ shapeIndex,
207
+ subIndex,
208
+ edgeIndex,
209
+ t: closestParamOnEdge(edge, verts, x, y),
210
+ };
211
+ }
212
+ });
213
+ });
214
+ return best;
215
+ }
216
+
217
+ /** Topmost shape (painter's order) with an edge body under (x, y), or null. */
218
+ export function hitTestPathEdge(path, x, y, radiusPx = EDGE_HIT) {
219
+ const shapes = path?.shapes ?? [];
220
+ for (let i = shapes.length - 1; i >= 0; i--) {
221
+ const hit = hitTestShapeEdge(path, i, x, y, radiusPx);
222
+ if (hit) return hit;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ export function shapeFromPenDraft(points, { closed, activeKey }) {
228
+ const pts = points.map((p) => [p[0], p[1]]);
229
+ if (!pts.length) return null;
230
+ const start = pts[0];
231
+ const segs = [];
232
+ for (let i = 1; i < pts.length; i++) segs.push(straightSegment(pts[i]));
233
+ return {
234
+ fill: closed ? activeKey : null,
235
+ stroke: closed ? null : activeKey,
236
+ subpaths: [{ closed: !!closed, start, segs }],
237
+ };
238
+ }
239
+
240
+ /** Open single-subpath shape brush Pen mode can extend (in-file "live" stroke). */
241
+ export function isOpenPenShape(shape) {
242
+ const subs = shape?.subpaths ?? [];
243
+ if (subs.length !== 1) return false;
244
+ return isOpenSubpath(subs[0]);
245
+ }
246
+
247
+ /** Any open subpath the pen can still extend — subpath 0 or a limb. */
248
+ export function isOpenSubpath(sub) {
249
+ return !!sub && !sub.closed && subpathVertexCount(sub) >= 1;
250
+ }
251
+
252
+ /** Append a straight segment to an open subpath. */
253
+ export function appendAnchorToSubpath(path, shapeIndex, subIndex, point) {
254
+ if (!isOpenSubpath(path?.shapes?.[shapeIndex]?.subpaths?.[subIndex])) return path;
255
+ return mapSubpath(path, shapeIndex, subIndex, (sub) => ({
256
+ closed: false,
257
+ start: [...sub.start],
258
+ segs: [...(sub.segs ?? []).map((s) => ({ ...s, to: [...s.to] })), straightSegment(point)],
259
+ }));
260
+ }
261
+
262
+ /**
263
+ * Root a new open subpath on a shape, at a point the shape already occupies.
264
+ *
265
+ * This is how a branch is made. The limb is a SIBLING of the shape's rings
266
+ * rather than an insertion into one, so the ring keeps its silhouette instead of
267
+ * being dragged out of shape to accommodate the spur — which is what went wrong
268
+ * the first time this was attempted. What ties the two together is the shared
269
+ * coordinate: withCoincidentAnchors makes the limb's root and the ring's vertex
270
+ * a single junction that drags as one.
271
+ *
272
+ * The shape's paint is left alone. A limb on a fill-only shape bakes to nothing
273
+ * until the creator gives the shape a stroke, but it is drawn in the overlay
274
+ * throughout, so this is a visible state rather than a silent one — and picking
275
+ * a stroke on their behalf would be the more surprising move.
276
+ */
277
+ export function startLimbOnShape(path, shapeIndex, point) {
278
+ const shape = path?.shapes?.[shapeIndex];
279
+ if (!shape || isPrimitiveShape(shape)) return null;
280
+ const subIndex = (shape.subpaths ?? []).length;
281
+ const next = mapShape(path, shapeIndex, (s) => ({
282
+ ...s,
283
+ subpaths: [
284
+ ...(s.subpaths ?? []),
285
+ { closed: false, start: [point[0], point[1]], segs: [] },
286
+ ],
287
+ }));
288
+ return { path: next, subIndex };
289
+ }
290
+
291
+ function cloneSeg(seg) {
292
+ const out = { to: [seg.to[0], seg.to[1]] };
293
+ if (seg.c1) out.c1 = [seg.c1[0], seg.c1[1]];
294
+ if (seg.c2) out.c2 = [seg.c2[0], seg.c2[1]];
295
+ return out;
296
+ }
297
+
298
+ /** Segments crossed walking a closed subpath forward from vertex `i` to `j`.
299
+ * Goes through subpathEdgeFrom, so an implied closing edge materialises on the
300
+ * way past instead of being skipped. */
301
+ function loopRunSegments(loop, i, j) {
302
+ const n = subpathVertexCount(loop);
303
+ if (n < 2) return null;
304
+ const out = [];
305
+ for (let k = i; k !== j; k = (k + 1) % n) {
306
+ const edge = subpathEdgeFrom(loop, k);
307
+ if (!edge || out.length > n) return null;
308
+ out.push(cloneSeg(edge.seg));
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /** The same run walked backwards. Both handles are already anchor-relative —
314
+ * c1 to the previous anchor, c2 to its own — so reversal swaps them and needs
315
+ * no arithmetic on the control points. */
316
+ function reverseRun(start, segs) {
317
+ const pts = [start, ...segs.map((s) => s.to)];
318
+ const out = [];
319
+ for (let k = segs.length - 1; k >= 0; k--) {
320
+ const seg = { to: [pts[k][0], pts[k][1]] };
321
+ if (segs[k].c2) seg.c1 = [segs[k].c2[0], segs[k].c2[1]];
322
+ if (segs[k].c1) seg.c2 = [segs[k].c1[0], segs[k].c1[1]];
323
+ out.push(seg);
324
+ }
325
+ return out;
326
+ }
327
+
328
+ function subpathArea(sub) {
329
+ const poly = flattenSubpath(sub, 6);
330
+ let acc = 0;
331
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
332
+ acc += (poly[j][0] + poly[i][0]) * (poly[j][1] - poly[i][1]);
333
+ }
334
+ return Math.abs(acc / 2);
335
+ }
336
+
337
+ /** The closed subpath carrying both of an open limb's endpoints, or -1. */
338
+ export function loopSubIndexForLimb(shape, limbSubIndex) {
339
+ const subs = shape?.subpaths ?? [];
340
+ const limb = normalizeSubpath(subs[limbSubIndex]);
341
+ if (!limb || limb.closed) return -1;
342
+ const pts = subpathPoints(limb);
343
+ if (pts.length < 2) return -1;
344
+ const ends = [pts[0], pts[pts.length - 1]];
345
+ for (let i = 0; i < subs.length; i++) {
346
+ if (i === limbSubIndex) continue;
347
+ const sub = normalizeSubpath(subs[i]);
348
+ if (!sub?.closed) continue;
349
+ const loopPts = subpathPoints(sub);
350
+ if (ends.every((e) => loopPts.some((q) => pointsEqual(q, e)))) return i;
351
+ }
352
+ return -1;
353
+ }
354
+
355
+ /**
356
+ * Merge an open limb into the closed subpath it was drawn onto, replacing one
357
+ * of the two runs of that loop lying between the limb's endpoints. The result
358
+ * is a single closed subpath, which is what makes the stroke trace the new
359
+ * silhouette rather than painting the old boundary through the middle of it.
360
+ *
361
+ * Two runs connect those endpoints, so there are two possible results and the
362
+ * geometry alone cannot say which was meant. We keep the larger: a bulge drawn
363
+ * outward gets added, and a chord drawn across cuts off the smaller cap. The
364
+ * other reading is one undo away.
365
+ */
366
+ function spliceParts(shape, loopSubIndex, limbSubIndex) {
367
+ const loop = normalizeSubpath(shape.subpaths[loopSubIndex]);
368
+ const limb = normalizeSubpath(shape.subpaths[limbSubIndex]);
369
+ const limbPts = subpathPoints(limb);
370
+ const loopPts = subpathPoints(loop);
371
+ const at = (p) => loopPts.findIndex((q) => pointsEqual(q, p));
372
+ const i = at(limbPts[0]);
373
+ const j = at(limbPts[limbPts.length - 1]);
374
+ // Both ends on the same vertex is a balloon hanging off the loop, not a
375
+ // splice — there is no run between them to replace.
376
+ if (i < 0 || j < 0 || i === j) return null;
377
+
378
+ const runToJ = loopRunSegments(loop, i, j);
379
+ const runToI = loopRunSegments(loop, j, i);
380
+ if (!runToJ || !runToI) return null;
381
+ return {
382
+ loopPts,
383
+ i,
384
+ j,
385
+ runToJ,
386
+ runToI,
387
+ limbStart: limbPts[0],
388
+ limbSegs: (limb.segs ?? []).map(cloneSeg),
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Merge a run drawn from an outline back to itself into that outline. The
394
+ * gesture is ambiguous between reshaping the silhouette and drawing a line
395
+ * inside the shape, so it does both: the larger of the two closures becomes the
396
+ * new outline, and the run it displaced is kept as an open stroke rather than
397
+ * thrown away.
398
+ */
399
+ export function spliceLimbIntoShape(path, shapeIndex, limbSubIndex) {
400
+ const shape = path?.shapes?.[shapeIndex];
401
+ if (!shape || isPrimitiveShape(shape)) return null;
402
+ const loopSubIndex = loopSubIndexForLimb(shape, limbSubIndex);
403
+ if (loopSubIndex < 0) return null;
404
+ const parts = spliceParts(shape, loopSubIndex, limbSubIndex);
405
+ if (!parts) return null;
406
+ const { loopPts, i, j, runToJ, runToI, limbStart, limbSegs } = parts;
407
+
408
+ const start = [loopPts[i][0], loopPts[i][1]];
409
+ // Replacing one run leaves the limb joined to the other.
410
+ const keepRunToI = { closed: true, start, segs: [...limbSegs, ...runToI] };
411
+ const keepRunToJ = {
412
+ closed: true,
413
+ start,
414
+ segs: [...runToJ, ...reverseRun(limbStart, limbSegs)],
415
+ };
416
+ const keepingRunToI = subpathArea(keepRunToI) >= subpathArea(keepRunToJ);
417
+ const merged = keepingRunToI ? keepRunToI : keepRunToJ;
418
+
419
+ const spliced = (s) =>
420
+ (s.subpaths ?? [])
421
+ .map((sub, k) => (k === loopSubIndex ? normalizeSubpath(merged) : sub))
422
+ .filter((_, k) => k !== limbSubIndex);
423
+
424
+ // The displaced run is still a line that was drawn, so it is kept as an open
425
+ // stroked subpath. Open subpaths stroke without joining the fill, which is
426
+ // free where the run ends up interior to the fill and is a seam where it does
427
+ // not — a chord's bite comes out sealed shut by the edge it replaced.
428
+ const lost = keepingRunToI
429
+ ? { from: loopPts[i], segs: runToJ }
430
+ : { from: loopPts[j], segs: runToI };
431
+ const detail = {
432
+ closed: false,
433
+ start: [lost.from[0], lost.from[1]],
434
+ segs: lost.segs.map(cloneSeg),
435
+ };
436
+
437
+ return mapShape(path, shapeIndex, (s) => ({
438
+ ...s,
439
+ subpaths: spliced(s).concat([normalizeSubpath(detail)]),
440
+ }));
441
+ }
442
+
443
+ const handleOf = (h) => (h && Math.hypot(h[0], h[1]) > 1e-9 ? h : null);
444
+
445
+ function segmentsMatch(a, b) {
446
+ if (!pointsEqual(a.to, b.to)) return false;
447
+ const pairs = [[handleOf(a.c1), handleOf(b.c1)], [handleOf(a.c2), handleOf(b.c2)]];
448
+ return pairs.every(([x, y]) => (x && y ? pointsEqual(x, y) : !x && !y));
449
+ }
450
+
451
+ function edgeList(sub) {
452
+ const out = [];
453
+ for (let e = 0; e < subpathEdgeCount(sub); e++) {
454
+ const edge = subpathEdgeFrom(sub, e);
455
+ if (!edge) return null;
456
+ out.push(edge);
457
+ }
458
+ return out;
459
+ }
460
+
461
+ function runMatchesAt(loopEdges, start, edges) {
462
+ return edges.every((want, k) => {
463
+ const got = loopEdges[(start + k) % loopEdges.length];
464
+ return pointsEqual(got.from, want.from) && segmentsMatch(got.seg, want.seg);
465
+ });
466
+ }
467
+
468
+ /** Whether a closed subpath walks the whole of `open`, either way round. */
469
+ function outlineRetraces(loop, open) {
470
+ const loopEdges = edgeList(loop);
471
+ const edges = edgeList(open);
472
+ if (!loopEdges || !edges?.length || edges.length > loopEdges.length) return false;
473
+ const last = open.segs[open.segs.length - 1].to;
474
+ const back = edgeList({
475
+ closed: false,
476
+ start: [last[0], last[1]],
477
+ segs: reverseRun(open.start, open.segs),
478
+ });
479
+ for (let e = 0; e < loopEdges.length; e++) {
480
+ if (runMatchesAt(loopEdges, e, edges)) return true;
481
+ if (back && runMatchesAt(loopEdges, e, back)) return true;
482
+ }
483
+ return false;
484
+ }
485
+
486
+ /**
487
+ * Drop open subpaths that an outline already walks in full.
488
+ *
489
+ * This is the dual of splice. Splice lifts a run out of a loop and keeps it as
490
+ * an open subpath because it has become interior; this puts it back when the
491
+ * loop comes round to retrace it — after deleting the vertex that made it
492
+ * interior, say. Without the pair, splicing is a ratchet that accumulates edges
493
+ * drawn twice on top of each other.
494
+ *
495
+ * It leans on stroke colour being per-shape, so two coincident edges paint
496
+ * identically and nothing is lost by keeping one.
497
+ */
498
+ export function absorbRetracedSubpaths(path) {
499
+ let changed = false;
500
+ const shapes = (path?.shapes ?? []).map((shape) => {
501
+ const subs = shape?.subpaths ?? [];
502
+ if (isPrimitiveShape(shape) || subs.length < 2) return shape;
503
+ const loops = subs.filter((s) => s?.closed).map(normalizeSubpath);
504
+ if (!loops.length) return shape;
505
+ const keep = subs.filter((sub) => {
506
+ if (sub?.closed) return true;
507
+ const open = normalizeSubpath(sub);
508
+ if (subpathVertexCount(open) < 2) return true;
509
+ if (!loops.some((loop) => outlineRetraces(loop, open))) return true;
510
+ changed = true;
511
+ return false;
512
+ });
513
+ return keep.length === subs.length ? shape : { ...shape, subpaths: keep };
514
+ });
515
+ return changed ? { shapes } : path;
516
+ }
517
+
518
+ /** Drop a subpath, and the shape with it when nothing is left. */
519
+ export function removeSubpath(path, shapeIndex, subIndex) {
520
+ const shape = path?.shapes?.[shapeIndex];
521
+ if (!shape?.subpaths?.[subIndex]) return path;
522
+ const subpaths = shape.subpaths.filter((_, i) => i !== subIndex);
523
+ if (!subpaths.length) return removeShape(path, shapeIndex);
524
+ return mapShape(path, shapeIndex, (s) => ({ ...s, subpaths }));
525
+ }
526
+
527
+ export function appendShape(path, shape) {
528
+ return { shapes: [...(path?.shapes ?? []), shape] };
529
+ }
530
+
531
+ export function removeShape(path, shapeIndex) {
532
+ if (!path?.shapes || shapeIndex < 0 || shapeIndex >= path.shapes.length) return path;
533
+ return { shapes: path.shapes.filter((_, i) => i !== shapeIndex) };
534
+ }
535
+
536
+ export function moveShape(path, shapeIndex, delta) {
537
+ const shapes = path?.shapes ?? [];
538
+ if (shapeIndex < 0 || shapeIndex >= shapes.length || !delta) return path;
539
+ const next = shapeIndex + delta;
540
+ if (next < 0 || next >= shapes.length) return path;
541
+ const copy = shapes.slice();
542
+ const [item] = copy.splice(shapeIndex, 1);
543
+ copy.splice(next, 0, item);
544
+ return { shapes: copy };
545
+ }
546
+
547
+ export function setShapeFill(path, shapeIndex, key) {
548
+ return mapShape(path, shapeIndex, (shape) => ({ ...shape, fill: key }));
549
+ }
550
+
551
+ export function setShapeStroke(path, shapeIndex, key) {
552
+ return mapShape(path, shapeIndex, (shape) => ({ ...shape, stroke: key }));
553
+ }
554
+
555
+ function mapShape(path, shapeIndex, fn) {
556
+ const shapes = path?.shapes ?? [];
557
+ if (shapeIndex < 0 || shapeIndex >= shapes.length) return path;
558
+ return {
559
+ shapes: shapes.map((shape, i) => (i === shapeIndex ? fn(shape) : shape)),
560
+ };
561
+ }
562
+
563
+ /**
564
+ * The write funnel for one subpath, mirroring normalizeSubpath as the read
565
+ * funnel: edits see canonical input and their result is re-canonicalized, so an
566
+ * op that happens to produce a straight closing segment (straightening a bowed
567
+ * close, for one) does not leave a redundant one in editor state.
568
+ */
569
+ function mapSubpath(path, shapeIndex, subIndex, fn) {
570
+ return mapShape(path, shapeIndex, (shape) => ({
571
+ ...shape,
572
+ subpaths: (shape.subpaths ?? []).map((sub, i) =>
573
+ i === subIndex ? normalizeSubpath(fn(normalizeSubpath(sub))) : sub
574
+ ),
575
+ }));
576
+ }
577
+
578
+ /**
579
+ * The runs of surviving edges, each of which becomes one open subpath.
580
+ *
581
+ * A closed subpath is walked starting just after a removed edge so a run that
582
+ * wraps past the end stays whole — otherwise cutting one edge out of a loop
583
+ * would come back as two pieces that happen to meet.
584
+ */
585
+ function survivingEdgeRuns(sub, removed, count) {
586
+ const order = [];
587
+ if (sub.closed) {
588
+ const first = Math.min(...removed);
589
+ for (let k = 1; k <= count; k++) order.push((first + k) % count);
590
+ } else {
591
+ for (let e = 0; e < count; e++) order.push(e);
592
+ }
593
+ const runs = [];
594
+ let run = null;
595
+ for (const e of order) {
596
+ if (removed.has(e)) {
597
+ run = null;
598
+ continue;
599
+ }
600
+ if (!run) {
601
+ run = [];
602
+ runs.push(run);
603
+ }
604
+ run.push(e);
605
+ }
606
+ return runs;
607
+ }
608
+
609
+ /**
610
+ * Remove whole edges from a shape, as `[{ subIndex, edgeIndex }]`.
611
+ *
612
+ * There is no representation for a loop with a gap in it, so cutting an edge out
613
+ * of a closed subpath opens it: the survivors are rebuilt as an open subpath
614
+ * running from one side of the gap round to the other. Cutting from an open
615
+ * subpath splits it. Nothing is filled by an open subpath, so a shape that loses
616
+ * an outline edge stops filling until the loop is closed again — its colours are
617
+ * left alone so closing it back restores what it looked like.
618
+ */
619
+ export function removeEdgesFromShape(path, shapeIndex, edges) {
620
+ const shape = path?.shapes?.[shapeIndex];
621
+ if (!shape || !edges?.length || isPrimitiveShape(shape)) return path;
622
+ const bySub = new Map();
623
+ for (const { subIndex, edgeIndex } of edges) {
624
+ if (!bySub.has(subIndex)) bySub.set(subIndex, new Set());
625
+ bySub.get(subIndex).add(edgeIndex);
626
+ }
627
+ const subpaths = [];
628
+ (shape.subpaths ?? []).forEach((raw, subIndex) => {
629
+ const sub = normalizeSubpath(raw);
630
+ const removed = bySub.get(subIndex);
631
+ const count = subpathEdgeCount(sub);
632
+ if (!removed?.size || !count) {
633
+ subpaths.push(raw);
634
+ return;
635
+ }
636
+ for (const run of survivingEdgeRuns(sub, removed, count)) {
637
+ const first = subpathEdgeFrom(sub, run[0]);
638
+ if (!first) continue;
639
+ subpaths.push(
640
+ normalizeSubpath({
641
+ closed: false,
642
+ start: [first.from[0], first.from[1]],
643
+ segs: run.map((e) => cloneSeg(subpathEdgeFrom(sub, e).seg)),
644
+ })
645
+ );
646
+ }
647
+ });
648
+ if (!subpaths.length) return removeShape(path, shapeIndex);
649
+ return mapShape(path, shapeIndex, (s) => ({ ...s, subpaths }));
650
+ }
651
+
652
+ /**
653
+ * Turn an open subpath around, so the anchor it ends on becomes the one it
654
+ * starts from. The geometry is untouched — this rewrites storage order.
655
+ *
656
+ * The pen only ever grows a subpath from its tail, so picking one back up at
657
+ * the end the user actually clicked means walking it backwards first.
658
+ */
659
+ export function reverseSubpath(path, shapeIndex, subIndex) {
660
+ return mapSubpath(path, shapeIndex, subIndex, (sub) => {
661
+ const segs = sub.segs ?? [];
662
+ if (sub.closed || !segs.length) return sub;
663
+ const last = segs[segs.length - 1].to;
664
+ return { closed: false, start: [last[0], last[1]], segs: reverseRun(sub.start, segs) };
665
+ });
666
+ }
667
+
668
+ /**
669
+ * The loose end of an open subpath near (x, y), as `{ subIndex, end }` where
670
+ * `end` is 'head' or 'tail'. Used to tell "carry on from here" apart from
671
+ * "start a limb here", which at a loose end would only stack a second anchor
672
+ * on top of the one already there.
673
+ */
674
+ export function hitTestOpenEnd(path, shapeIndex, x, y, radius = 0.6) {
675
+ const shape = path?.shapes?.[shapeIndex];
676
+ if (!shape || isPrimitiveShape(shape)) return null;
677
+ let best = null;
678
+ (shape.subpaths ?? []).forEach((raw, subIndex) => {
679
+ const sub = normalizeSubpath(raw);
680
+ if (sub.closed) return;
681
+ const pts = subpathPoints(sub);
682
+ if (pts.length < 2) return;
683
+ for (const [i, end] of [[0, 'head'], [pts.length - 1, 'tail']]) {
684
+ const dx = pts[i][0] - x;
685
+ const dy = pts[i][1] - y;
686
+ const dist = Math.sqrt(dx * dx + dy * dy);
687
+ if (dist <= radius && (!best || dist < best.dist)) best = { subIndex, end, dist };
688
+ }
689
+ });
690
+ return best;
691
+ }
692
+
693
+ /** Phase-preserving (or Alt lattice) drag of one anchor. */
694
+ export function applyAnchorDrag(path, anchorRef, start, dx, dy, halfPixel = false) {
695
+ if (!anchorRef || !start) return path;
696
+ return applyAnchorsDrag(
697
+ path,
698
+ anchorRef.shapeIndex,
699
+ [{ subIndex: anchorRef.subIndex, pointIndex: anchorRef.pointIndex }],
700
+ [start],
701
+ dx,
702
+ dy,
703
+ halfPixel
704
+ );
705
+ }
706
+
707
+ /**
708
+ * Where a primitive's centre was when the drag began, read back out of the
709
+ * anchor starts so this needs no extra bookkeeping at the call sites.
710
+ *
711
+ * The derived anchors are symmetric about the centre, so their mean is it. The
712
+ * mean carries rounding dust on the order of 1e-16; snapping clears it exactly,
713
+ * because a primitive's centre is always a lattice value to begin with.
714
+ */
715
+ function primitiveCentreFromStarts(starts) {
716
+ const sum = starts.reduce((acc, s) => [acc[0] + s.start[0], acc[1] + s.start[1]], [0, 0]);
717
+ return [sum[0] / starts.length, sum[1] / starts.length];
718
+ }
719
+
720
+ /** Rigidly translate a shape, magnetizing the translation rather than its points. */
721
+ export function applyShapeDrag(path, shapeIndex, pointStarts, dx, dy, halfPixel = false, cellPull = LATTICE / 2) {
722
+ if (!pointStarts?.length) return path;
723
+ const [sdx, sdy] = snapDragDelta(dx, dy, halfPixel, cellPull);
724
+ const target = path?.shapes?.[shapeIndex];
725
+ if (isPrimitiveShape(target)) {
726
+ // Same snapped delta as a subpath shape, so a mixed selection cannot drift
727
+ // apart: snapping the centre instead would move an ellipse by one amount
728
+ // and the paths beside it by another.
729
+ const [cx0, cy0] = primitiveCentreFromStarts(pointStarts);
730
+ return mapShape(path, shapeIndex, (shape) =>
731
+ deriveShape({ ...shape, cx: cx0 + sdx, cy: cy0 + sdy })
732
+ );
733
+ }
734
+ return mapShape(path, shapeIndex, (shape) => ({
735
+ ...shape,
736
+ subpaths: (shape.subpaths ?? []).map((sub, subIndex) => {
737
+ const norm = normalizeSubpath(sub);
738
+ const starts = pointStarts.filter((s) => s.subIndex === subIndex);
739
+ if (!starts.length) return norm;
740
+ const byPoint = new Map(starts.map((s) => [s.pointIndex, s.start]));
741
+ const segsIn = norm.segs ?? [];
742
+ const closingIndex = subpathClosesOnStart(norm) ? segsIn.length - 1 : -1;
743
+ const newStart = byPoint.has(0)
744
+ ? [byPoint.get(0)[0] + sdx, byPoint.get(0)[1] + sdy]
745
+ : [...norm.start];
746
+ const newSegs = segsIn.map((seg, segIndex) => {
747
+ const vertIndex = segIndex === closingIndex ? 0 : segIndex + 1;
748
+ const move = byPoint.get(vertIndex);
749
+ const to = move
750
+ ? [move[0] + sdx, move[1] + sdy]
751
+ : [...seg.to];
752
+ return { ...seg, to, c1: seg.c1 ? [...seg.c1] : undefined, c2: seg.c2 ? [...seg.c2] : undefined };
753
+ });
754
+ return { ...sub, start: newStart, segs: newSegs };
755
+ }),
756
+ }));
757
+ }
758
+
759
+ export function collectShapePointStarts(path, shapeIndex) {
760
+ const shape = path?.shapes?.[shapeIndex];
761
+ if (!shape) return [];
762
+ const out = [];
763
+ (shape.subpaths ?? []).forEach((sub, subIndex) => {
764
+ subpathPoints(sub).forEach((p, pointIndex) => {
765
+ out.push({ subIndex, pointIndex, start: [p[0], p[1]] });
766
+ });
767
+ });
768
+ return out;
769
+ }
770
+
771
+ export function bowFromPointer(pa, pb, x, y) {
772
+ const dx = pb[0] - pa[0];
773
+ const dy = pb[1] - pa[1];
774
+ const L = Math.hypot(dx, dy);
775
+ if (L < 1e-12) return 0;
776
+ const nx = -dy / L;
777
+ const ny = dx / L;
778
+ const mx = (pa[0] + pb[0]) / 2;
779
+ const my = (pa[1] + pb[1]) / 2;
780
+ return (x - mx) * nx + (y - my) * ny;
781
+ }
782
+
783
+ function endpointsForEdge(path, shapeIndex, subIndex, edgeIndex) {
784
+ const sub = path?.shapes?.[shapeIndex]?.subpaths?.[subIndex];
785
+ if (!sub) return null;
786
+ const edge = subpathEdgeFrom(sub, edgeIndex);
787
+ if (!edge) return null;
788
+ return { pa: edge.from, pb: edge.seg.to, sub, edge };
789
+ }
790
+
791
+ /** Ratio-detented circular-arc bend (default, non-Alt). */
792
+ export function applyEdgeBow(path, shapeIndex, subIndex, edgeIndex, x, y) {
793
+ const ends = endpointsForEdge(path, shapeIndex, subIndex, edgeIndex);
794
+ if (!ends) return path;
795
+ const { pa, pb } = ends;
796
+ const L = Math.hypot(pb[0] - pa[0], pb[1] - pa[1]);
797
+ const bow = snapBow(bowFromPointer(pa, pb, x, y), L);
798
+ return setEdgeBow(path, shapeIndex, subIndex, edgeIndex, bow);
799
+ }
800
+
801
+ export function setEdgeBow(path, shapeIndex, subIndex, edgeIndex, bow) {
802
+ return mapSubpath(path, shapeIndex, subIndex, (sub) => {
803
+ const norm = normalizeSubpath(sub);
804
+ const edge = subpathEdgeFrom(norm, edgeIndex);
805
+ if (!edge) return sub;
806
+ const newParts = bowToSegments(edge.from, edge.seg.to, bow);
807
+ // Splicing at segs.length materializes an implied closing edge: nothing is
808
+ // removed and the bowed parts land at the end, ending on `start`. A bow of
809
+ // zero produces a straight one, which canonicalization then re-implies.
810
+ const segs = [...(norm.segs ?? [])];
811
+ segs.splice(edgeIndex, 1, ...newParts);
812
+ return { ...norm, segs };
813
+ });
814
+ }
815
+
816
+ export function hitTestFillToolTarget(path, x, y) {
817
+ const shapes = path?.shapes ?? [];
818
+ for (let i = shapes.length - 1; i >= 0; i--) {
819
+ const shape = shapes[i];
820
+ if (nearStrokedOutline(shape, x, y)) return { shapeIndex: i, slot: 'stroke' };
821
+ if (shape.fill && shapeContainsPoint(shape, x, y)) return { shapeIndex: i, slot: 'fill' };
822
+ }
823
+ return null;
824
+ }
825
+
826
+ export function recolorTopShapeAt(path, x, y, key) {
827
+ const target = hitTestFillToolTarget(path, x, y);
828
+ if (!target) return path;
829
+ return target.slot === 'fill'
830
+ ? setShapeFill(path, target.shapeIndex, key)
831
+ : setShapeStroke(path, target.shapeIndex, key);
832
+ }
833
+
834
+ function cloneShape(shape) {
835
+ return clonePathData({ shapes: [shape] }).shapes[0];
836
+ }
837
+
838
+ function translateShape(shape, dx, dy) {
839
+ return translatePathData({ shapes: [shape] }, dx, dy).shapes[0];
840
+ }
841
+
842
+ /** True when a shape's fill coverage or stroke cells intersect an axis-aligned cell rect. */
843
+ export function shapeIntersectsRect(shape, rect) {
844
+ if (!shape || !rect || rect.w <= 0 || rect.h <= 0) return false;
845
+ const x0 = rect.x;
846
+ const y0 = rect.y;
847
+ const x1 = rect.x + rect.w;
848
+ const y1 = rect.y + rect.h;
849
+ const inRect = (x, y) => x >= x0 && x < x1 && y >= y0 && y < y1;
850
+ // Anchors / lattice points that land in or on the box (touch counts).
851
+ for (const sub of shape.subpaths ?? []) {
852
+ for (const p of subpathPoints(sub)) {
853
+ if (p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1) return true;
854
+ }
855
+ }
856
+ if (shape.fill) {
857
+ for (let y = Math.floor(y0); y < y1; y++) {
858
+ for (let x = Math.floor(x0); x < x1; x++) {
859
+ if (shapeContainsPoint(shape, x + 0.5, y + 0.5)) return true;
860
+ }
861
+ }
862
+ // Small marquee entirely inside a large fill.
863
+ const samples = [
864
+ [x0, y0],
865
+ [x1, y0],
866
+ [x0, y1],
867
+ [x1, y1],
868
+ [(x0 + x1) / 2, (y0 + y1) / 2],
869
+ ];
870
+ for (const [sx, sy] of samples) {
871
+ if (shapeContainsPoint(shape, sx, sy)) return true;
872
+ }
873
+ }
874
+ if (shape.stroke) {
875
+ for (const sub of shape.subpaths ?? []) {
876
+ const { verts, edges } = edgeObjectsForSubpath(sub, shape.stroke);
877
+ for (const edge of edges) {
878
+ for (const s of sampleEdgeCurve(edge, verts)) {
879
+ if (inRect(s.x, s.y)) return true;
880
+ }
881
+ }
882
+ }
883
+ }
884
+ return false;
885
+ }
886
+
887
+ export function hitTestShapesInRect(path, rect) {
888
+ const out = [];
889
+ (path?.shapes ?? []).forEach((shape, i) => {
890
+ if (shapeIntersectsRect(shape, rect)) out.push(i);
891
+ });
892
+ return out;
893
+ }
894
+
895
+ /** True when an anchor's lattice point lies inside or on an axis-aligned cell rect. */
896
+ function anchorInRect(x, y, rect) {
897
+ if (!rect || rect.w <= 0 || rect.h <= 0) return false;
898
+ const x0 = rect.x;
899
+ const y0 = rect.y;
900
+ const x1 = rect.x + rect.w;
901
+ const y1 = rect.y + rect.h;
902
+ return x >= x0 && x <= x1 && y >= y0 && y <= y1;
903
+ }
904
+
905
+ /** Anchors on one shape whose points fall inside `rect` (inclusive edges). */
906
+ export function hitTestAnchorsInRect(path, shapeIndex, rect) {
907
+ const shape = path?.shapes?.[shapeIndex];
908
+ if (!shape || !rect || rect.w <= 0 || rect.h <= 0) return [];
909
+ // A marquee over an ellipse would otherwise catch several axis handles at
910
+ // once, and a radius drag only has one radius to set.
911
+ if (isPrimitiveShape(shape)) return [];
912
+ const out = [];
913
+ (shape.subpaths ?? []).forEach((sub, subIndex) => {
914
+ subpathPoints(sub).forEach((p, pointIndex) => {
915
+ if (anchorInRect(p[0], p[1], rect)) out.push({ subIndex, pointIndex });
916
+ });
917
+ });
918
+ return out;
919
+ }
920
+
921
+ /** Translate several shapes; each anchor keeps its own phase unless halfPixel. */
922
+ export function applyShapesDrag(path, shapeIndices, pointStarts, dx, dy, halfPixel = false, cellPull = LATTICE / 2) {
923
+ if (!shapeIndices?.length || !pointStarts?.length) return path;
924
+ let next = path;
925
+ for (const shapeIndex of shapeIndices) {
926
+ const starts = pointStarts.filter((s) => s.shapeIndex === shapeIndex);
927
+ next = applyShapeDrag(next, shapeIndex, starts, dx, dy, halfPixel, cellPull);
928
+ }
929
+ return next;
930
+ }
931
+
932
+ export function collectShapesPointStarts(path, shapeIndices) {
933
+ const out = [];
934
+ for (const shapeIndex of shapeIndices ?? []) {
935
+ for (const s of collectShapePointStarts(path, shapeIndex)) {
936
+ out.push({ shapeIndex, ...s });
937
+ }
938
+ }
939
+ return out;
940
+ }
941
+
942
+ /**
943
+ * Drag an axis handle to resize. Only the axis the handle sits on responds, so
944
+ * the ellipse never shears or drifts out from under the gesture.
945
+ *
946
+ * By default the opposite edge holds still, as it does in Figma and
947
+ * Illustrator; Alt resizes about the centre instead, mirroring the drag. The
948
+ * two are not equivalent under the lattice, and the difference is felt rather
949
+ * than seen: a radius can only be a half-cell multiple, so the width always
950
+ * moves in whole cells. Anchoring the far edge spends that whole cell at the
951
+ * handle, which therefore jumps a full cell at a time; mirroring splits it
952
+ * across both sides, so the handle tracks the pointer twice as finely.
953
+ */
954
+ function dragPrimitiveHandle(path, shapeIndex, anchors, starts, dx, dy, aboutCentre, cellPull = LATTICE / 2) {
955
+ const shape = path.shapes[shapeIndex];
956
+ const handles = primitiveHandleIndices(shape);
957
+ const anchor = anchors.find((a) => handles.includes(a.pointIndex));
958
+ const start = starts[anchors.indexOf(anchor)];
959
+ if (!anchor || !start) return path;
960
+
961
+ const vertical = handles.indexOf(anchor.pointIndex) % 2 === 1;
962
+ const axis = vertical ? 1 : 0;
963
+ const centre = vertical ? shape.cy : shape.cx;
964
+ const pointer = start[axis] + (vertical ? dy : dx);
965
+ const radiusKey = vertical ? 'ry' : 'rx';
966
+ const centreKey = vertical ? 'cy' : 'cx';
967
+
968
+ if (aboutCentre) {
969
+ const raw = Math.abs(pointer - centre);
970
+ const [snapped] = magneticSnapPoint(raw, 0, cellPull, true);
971
+ const radius = Math.max(MIN_ELLIPSE_RADIUS, snapped);
972
+ return mapShape(path, shapeIndex, (s) => deriveShape({ ...s, [radiusKey]: radius }));
973
+ }
974
+
975
+ const far = 2 * centre - start[axis];
976
+ const raw = Math.abs(pointer - far) / 2;
977
+ const [snapped] = magneticSnapPoint(raw, 0, cellPull, true);
978
+ const radius = Math.max(MIN_ELLIPSE_RADIUS, snapped);
979
+ const dir = Math.sign(pointer - far) || Math.sign(start[axis] - far) || 1;
980
+ const newCentre = far + dir * radius;
981
+ const [snappedCentre] = magneticSnapPoint(newCentre, 0, cellPull, true);
982
+ return mapShape(path, shapeIndex, (s) =>
983
+ deriveShape({ ...s, [radiusKey]: radius, [centreKey]: snappedCentre })
984
+ );
985
+ }
986
+
987
+ export function applyAnchorsDrag(
988
+ path,
989
+ shapeIndex,
990
+ anchors,
991
+ starts,
992
+ dx,
993
+ dy,
994
+ halfPixel = false,
995
+ cellPull = LATTICE / 2
996
+ ) {
997
+ if (shapeIndex == null || !anchors?.length || !starts?.length) return path;
998
+ if (isPrimitiveShape(path?.shapes?.[shapeIndex])) {
999
+ return dragPrimitiveHandle(path, shapeIndex, anchors, starts, dx, dy, halfPixel, cellPull);
1000
+ }
1001
+ const bySub = new Map();
1002
+ anchors.forEach((anchor, i) => {
1003
+ const start = starts[i];
1004
+ if (!start) return;
1005
+ if (!bySub.has(anchor.subIndex)) bySub.set(anchor.subIndex, []);
1006
+ bySub.get(anchor.subIndex).push({ pointIndex: anchor.pointIndex, start });
1007
+ });
1008
+ // One site (an anchor, or a junction whose members share a coordinate) is
1009
+ // being placed, so magnetize where it lands. Several distinct sites are being
1010
+ // carried, so magnetize the delta and move them rigidly — otherwise the
1011
+ // selection deforms, since points at different sub-cell phases snap by
1012
+ // different amounts.
1013
+ const siteCount = new Set(starts.filter(Boolean).map((s) => `${s[0]},${s[1]}`)).size;
1014
+ const rigid = siteCount > 1;
1015
+ const [sdx, sdy] = rigid ? snapDragDelta(dx, dy, halfPixel, cellPull) : [dx, dy];
1016
+ const place = (p) =>
1017
+ rigid ? [p[0] + sdx, p[1] + sdy] : snapDragPoint(p[0] + dx, p[1] + dy, halfPixel, cellPull);
1018
+
1019
+ let next = path;
1020
+ for (const [subIndex, dragMoves] of bySub) {
1021
+ next = mapSubpath(next, shapeIndex, subIndex, (sub) => {
1022
+ const moved = new Map(dragMoves.map((m) => [m.pointIndex, m.start]));
1023
+ const norm = normalizeSubpath(sub);
1024
+ const segsIn = norm.segs ?? [];
1025
+ const closingIndex = subpathClosesOnStart(norm) ? segsIn.length - 1 : -1;
1026
+ const newStart = moved.has(0) ? place(moved.get(0)) : [...norm.start];
1027
+ const newSegs = segsIn.map((seg, segIndex) => {
1028
+ const vertIndex = segIndex === closingIndex ? 0 : segIndex + 1;
1029
+ const start = moved.get(vertIndex);
1030
+ const to = start ? place(start) : [...seg.to];
1031
+ return {
1032
+ ...seg,
1033
+ to,
1034
+ c1: seg.c1 ? [seg.c1[0], seg.c1[1]] : undefined,
1035
+ c2: seg.c2 ? [seg.c2[0], seg.c2[1]] : undefined,
1036
+ };
1037
+ });
1038
+ return { ...norm, start: newStart, segs: newSegs };
1039
+ });
1040
+ }
1041
+ return next;
1042
+ }
1043
+
1044
+ function siteKey(p) {
1045
+ return p ? `${p[0]},${p[1]}` : null;
1046
+ }
1047
+
1048
+ function eachShapePoint(shape, visit) {
1049
+ (shape?.subpaths ?? []).forEach((sub, subIndex) => {
1050
+ subpathPoints(sub).forEach((p, pointIndex) => visit(p, subIndex, pointIndex));
1051
+ });
1052
+ }
1053
+
1054
+ /**
1055
+ * Sites carrying two or more of the shape's points — its junctions. The overlay
1056
+ * draws these apart from ordinary anchors, because a join whose only evidence is
1057
+ * geometry moving unexpectedly is a join discovered the wrong way round.
1058
+ */
1059
+ export function coincidentSiteKeys(shape) {
1060
+ const out = new Set();
1061
+ if (!shape || isPrimitiveShape(shape)) return out;
1062
+ const counts = new Map();
1063
+ eachShapePoint(shape, (p) => {
1064
+ const key = siteKey(p);
1065
+ if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
1066
+ });
1067
+ for (const [key, n] of counts) if (n > 1) out.add(key);
1068
+ return out;
1069
+ }
1070
+
1071
+ /**
1072
+ * Grow an anchor selection to every point sharing its lattice site, so a
1073
+ * junction drags as one. This is what keeps an open limb attached to the ring it
1074
+ * was drawn onto: the two subpaths hold the same coordinate, and moving either
1075
+ * end brings the other with it.
1076
+ *
1077
+ * The lattice is what makes this honest rather than fuzzy. Coincidence is exact,
1078
+ * so a junction either exists or it does not — there is no tolerance to tune and
1079
+ * no near-miss to arbitrate. The same manufactured coincidence that made welding
1080
+ * hazardous is what makes joining unambiguous here, and unlike a weld this is
1081
+ * only a drag, so an accident costs one undo rather than a change of topology.
1082
+ *
1083
+ * Scoped within one shape, as Figma scopes its merges. Shapes stacked in
1084
+ * painter's order line up by coincidence constantly, and reaching across them
1085
+ * would fuse art that merely happens to touch.
1086
+ */
1087
+ function anchorSites(shape, anchors) {
1088
+ const subpaths = shape.subpaths ?? [];
1089
+ const sites = new Set();
1090
+ for (const a of anchors) {
1091
+ const key = siteKey(subpathPointAt(subpaths[a.subIndex], a.pointIndex));
1092
+ if (key) sites.add(key);
1093
+ }
1094
+ return sites;
1095
+ }
1096
+
1097
+ export function withCoincidentAnchors(path, shapeIndex, anchors) {
1098
+ const shape = path?.shapes?.[shapeIndex];
1099
+ if (!shape || !anchors?.length || isPrimitiveShape(shape)) return anchors ?? [];
1100
+ const sites = anchorSites(shape, anchors);
1101
+ if (!sites.size) return anchors;
1102
+ const out = anchors.slice();
1103
+ const seen = new Set(anchors.map((a) => `${a.subIndex}:${a.pointIndex}`));
1104
+ eachShapePoint(shape, (p, subIndex, pointIndex) => {
1105
+ const id = `${subIndex}:${pointIndex}`;
1106
+ if (seen.has(id) || !sites.has(siteKey(p))) return;
1107
+ seen.add(id);
1108
+ out.push({ subIndex, pointIndex });
1109
+ });
1110
+ return out;
1111
+ }
1112
+
1113
+ export function collectAnchorStarts(path, shapeIndex, anchors) {
1114
+ const shape = path?.shapes?.[shapeIndex];
1115
+ if (!shape || !anchors?.length) return [];
1116
+ return anchors.map((a) => {
1117
+ const p = subpathPointAt(shape.subpaths?.[a.subIndex], a.pointIndex);
1118
+ return p ? [p[0], p[1]] : null;
1119
+ });
1120
+ }
1121
+
1122
+ function subpathViable(sub) {
1123
+ const n = subpathVertexCount(sub);
1124
+ if (sub.closed) {
1125
+ if (n >= 3) return true;
1126
+ if (n === 2) {
1127
+ const edge = subpathEdgeFrom(sub, 0);
1128
+ return edge && Math.abs(bowForSegment(edge.from, edge.seg)) > 1e-12;
1129
+ }
1130
+ return false;
1131
+ }
1132
+ return n >= 2;
1133
+ }
1134
+
1135
+ function removeVertexFromSubpath(sub, pointIndex) {
1136
+ const norm = normalizeSubpath(sub);
1137
+ const verts = subpathPoints(norm);
1138
+ // Under implicit closure a closed subpath has one fewer segment than vertex,
1139
+ // so segs[n - 1] is absent and the rebuilt closing edge falls through to
1140
+ // straightSegment — which is what an implied close is.
1141
+ const segs = norm.segs ?? [];
1142
+ const closed = !!norm.closed;
1143
+ const n = verts.length;
1144
+ if (pointIndex < 0 || pointIndex >= n) return sub;
1145
+
1146
+ if (!closed && pointIndex === 0) {
1147
+ if (!segs.length) return null;
1148
+ return { closed: false, start: [...segs[0].to], segs: segs.slice(1).map((s) => ({ ...s, to: [...s.to], c1: s.c1 ? [...s.c1] : undefined, c2: s.c2 ? [...s.c2] : undefined })) };
1149
+ }
1150
+ if (!closed && pointIndex === n - 1) {
1151
+ return { closed: false, start: [...norm.start], segs: segs.slice(0, -1).map((s) => ({ ...s, to: [...s.to] })) };
1152
+ }
1153
+
1154
+ const keptIndices = verts.map((_, i) => i).filter((i) => i !== pointIndex);
1155
+ const newVerts = keptIndices.map((i) => verts[i]);
1156
+ const newStart = [newVerts[0][0], newVerts[0][1]];
1157
+ const newN = newVerts.length;
1158
+ const edgeCount = closed ? newN : Math.max(0, newN - 1);
1159
+ const newSegs = [];
1160
+
1161
+ for (let e = 0; e < edgeCount; e++) {
1162
+ const to = newVerts[(e + 1) % newN];
1163
+ const oldFromIdx = keptIndices[e];
1164
+ const oldToIdx = keptIndices[(e + 1) % newN];
1165
+ const consecutive =
1166
+ oldToIdx === oldFromIdx + 1 || (closed && oldFromIdx === n - 1 && oldToIdx === 0);
1167
+ const touchesRemoved = oldFromIdx === pointIndex || oldToIdx === pointIndex;
1168
+ if (consecutive && !touchesRemoved && segs[oldFromIdx]) {
1169
+ newSegs.push({ ...segs[oldFromIdx], to: [to[0], to[1]] });
1170
+ } else {
1171
+ newSegs.push(straightSegment(to));
1172
+ }
1173
+ }
1174
+ return { closed, start: newStart, segs: newSegs };
1175
+ }
1176
+
1177
+ /** Remove one anchor; merged edges become straight. May drop subpath/shape. */
1178
+ export function removeAnchorFromShape(path, shapeIndex, subIndex, pointIndex) {
1179
+ const shape = path?.shapes?.[shapeIndex];
1180
+ const sub = shape?.subpaths?.[subIndex];
1181
+ if (!sub) return path;
1182
+ if (isPrimitiveShape(shape)) return path;
1183
+ const nextSub = removeVertexFromSubpath(sub, pointIndex);
1184
+ if (!nextSub || !subpathViable(nextSub)) {
1185
+ const subpaths = (shape.subpaths ?? []).filter((_, i) => i !== subIndex);
1186
+ if (!subpaths.length) return removeShape(path, shapeIndex);
1187
+ return mapShape(path, shapeIndex, (s) => ({ ...s, subpaths }));
1188
+ }
1189
+ return mapSubpath(path, shapeIndex, subIndex, () => nextSub);
1190
+ }
1191
+
1192
+ /**
1193
+ * Survivor to select for a deleted index (Figma frontier rule):
1194
+ * - Isolated delete (both neighbors remain): prefer the forward neighbor.
1195
+ * - Run endpoint (only one neighbor remains): take that neighbor.
1196
+ * - Run interior (both neighbors also deleted): skip.
1197
+ * - Open-path endpoint whose only side is deleted: walk out of the run.
1198
+ */
1199
+ function nextSurvivorIndex(n, closed, deleted, fromIndex) {
1200
+ if (n <= 0 || fromIndex < 0 || fromIndex >= n) return null;
1201
+ const prev = closed ? (fromIndex - 1 + n) % n : fromIndex - 1;
1202
+ const next = closed ? (fromIndex + 1) % n : fromIndex + 1;
1203
+ const prevOk = (closed || prev >= 0) && !deleted.has(prev);
1204
+ const nextOk = (closed || next < n) && !deleted.has(next);
1205
+
1206
+ if (prevOk && nextOk) return next;
1207
+ if (nextOk) return next;
1208
+ if (prevOk) return prev;
1209
+
1210
+ // Open path: deleted run includes an endpoint — walk out the only way.
1211
+ if (!closed && fromIndex === 0) {
1212
+ for (let j = 1; j < n; j++) if (!deleted.has(j)) return j;
1213
+ return null;
1214
+ }
1215
+ if (!closed && fromIndex === n - 1) {
1216
+ for (let j = n - 2; j >= 0; j--) if (!deleted.has(j)) return j;
1217
+ return null;
1218
+ }
1219
+ return null;
1220
+ }
1221
+
1222
+ function subpathSurvivesDeletion(sub, deletedSet) {
1223
+ if (!deletedSet.size) return subpathViable(sub);
1224
+ let probe = {
1225
+ shapes: [{ fill: 'x', stroke: null, subpaths: [cloneSubpath(sub)] }],
1226
+ };
1227
+ for (const pi of [...deletedSet].sort((a, b) => b - a)) {
1228
+ probe = removeAnchorFromShape(probe, 0, 0, pi);
1229
+ if (!probe?.shapes?.[0]?.subpaths?.[0]) return false;
1230
+ }
1231
+ return true;
1232
+ }
1233
+
1234
+ /**
1235
+ * Figma-style selection advance: each deleted anchor votes for a frontier
1236
+ * survivor (see nextSurvivorIndex); remap to post-delete indices; dedupe.
1237
+ * Empty when the shape (or every touched subpath) disappears.
1238
+ */
1239
+ export function advanceAnchorsAfterRemoval(path, shapeIndex, anchors) {
1240
+ const shape = path?.shapes?.[shapeIndex];
1241
+ if (!shape?.subpaths?.length || !anchors?.length) return [];
1242
+
1243
+ const bySub = new Map();
1244
+ for (const a of anchors) {
1245
+ if (typeof a?.subIndex !== 'number' || typeof a?.pointIndex !== 'number') continue;
1246
+ if (!bySub.has(a.subIndex)) bySub.set(a.subIndex, new Set());
1247
+ bySub.get(a.subIndex).add(a.pointIndex);
1248
+ }
1249
+ if (!bySub.size) return [];
1250
+
1251
+ const oldToNewSub = new Map();
1252
+ let newSub = 0;
1253
+ for (let s = 0; s < shape.subpaths.length; s++) {
1254
+ const deleted = bySub.get(s) ?? new Set();
1255
+ if (subpathSurvivesDeletion(shape.subpaths[s], deleted)) {
1256
+ oldToNewSub.set(s, newSub);
1257
+ newSub += 1;
1258
+ }
1259
+ }
1260
+ if (!oldToNewSub.size) return [];
1261
+
1262
+ const out = [];
1263
+ const seen = new Set();
1264
+ for (const [subIndex, deleted] of bySub) {
1265
+ if (!oldToNewSub.has(subIndex)) continue;
1266
+ const n = subpathVertexCount(shape.subpaths[subIndex]);
1267
+ const closed = !!shape.subpaths[subIndex]?.closed;
1268
+ const newSubIndex = oldToNewSub.get(subIndex);
1269
+ for (const i of deleted) {
1270
+ if (i < 0 || i >= n) continue;
1271
+ const surv = nextSurvivorIndex(n, closed, deleted, i);
1272
+ if (surv == null) continue;
1273
+ let below = 0;
1274
+ for (const d of deleted) if (d < surv) below += 1;
1275
+ const pointIndex = surv - below;
1276
+ const key = `${newSubIndex}:${pointIndex}`;
1277
+ if (seen.has(key)) continue;
1278
+ seen.add(key);
1279
+ out.push({ subIndex: newSubIndex, pointIndex });
1280
+ }
1281
+ }
1282
+ out.sort((a, b) => a.subIndex - b.subIndex || a.pointIndex - b.pointIndex);
1283
+ return out;
1284
+ }
1285
+
1286
+ /**
1287
+ * Remove several anchors from one shape. Refs are {subIndex, pointIndex};
1288
+ * processed high-index-first within each subpath so indices stay valid.
1289
+ * Returns `{ path, anchors }` where `anchors` is the Figma-style advanced
1290
+ * selection (empty if the shape was removed).
1291
+ */
1292
+ export function removeAnchorsFromShape(path, shapeIndex, anchors) {
1293
+ if (!anchors?.length) return { path, anchors: [] };
1294
+ // A junction is every anchor sharing a site, which is already how dragging
1295
+ // treats it. Deleting has to agree, or a subpath is left hanging off a vertex
1296
+ // the outline no longer has.
1297
+ const targets = withCoincidentAnchors(path, shapeIndex, anchors);
1298
+ const nextAnchors = advanceAnchorsAfterRemoval(path, shapeIndex, targets);
1299
+ const bySub = new Map();
1300
+ for (const a of targets) {
1301
+ if (!bySub.has(a.subIndex)) bySub.set(a.subIndex, []);
1302
+ bySub.get(a.subIndex).push(a.pointIndex);
1303
+ }
1304
+ let next = path;
1305
+ // Higher subpaths first so subpath deletion doesn't shift lower indices wrongly
1306
+ // when an entire subpath vanishes — still process one sub at a time.
1307
+ const subIndexes = [...bySub.keys()].sort((a, b) => b - a);
1308
+ for (const subIndex of subIndexes) {
1309
+ const points = bySub.get(subIndex).slice().sort((a, b) => b - a);
1310
+ for (const pointIndex of points) {
1311
+ // Re-resolve: shape may have shrunk; skip if gone.
1312
+ if (!next?.shapes?.[shapeIndex]?.subpaths?.[subIndex]) break;
1313
+ const n = subpathVertexCount(next.shapes[shapeIndex].subpaths[subIndex]);
1314
+ if (pointIndex >= n) continue;
1315
+ next = removeAnchorFromShape(next, shapeIndex, subIndex, pointIndex);
1316
+ // If the shape itself was removed, stop.
1317
+ if (!next?.shapes?.[shapeIndex]) return { path: next, anchors: [] };
1318
+ }
1319
+ }
1320
+ // Keep only anchors that still resolve (belt-and-suspenders vs. advance plan).
1321
+ const shape = next?.shapes?.[shapeIndex];
1322
+ const valid = nextAnchors.filter((a) => {
1323
+ const pts = shape?.subpaths?.[a.subIndex] ? subpathPoints(shape.subpaths[a.subIndex]) : null;
1324
+ return !!pts && a.pointIndex >= 0 && a.pointIndex < pts.length;
1325
+ });
1326
+ return { path: next, anchors: valid };
1327
+ }
1328
+
1329
+ export function removeShapes(path, shapeIndices) {
1330
+ if (!shapeIndices?.length) return path;
1331
+ const drop = new Set(shapeIndices);
1332
+ return { shapes: (path?.shapes ?? []).filter((_, i) => !drop.has(i)) };
1333
+ }
1334
+
1335
+ // ---------------------------------------------------------------------------
1336
+ // Punch Hole (multi-shape → multi-subpath even-odd)
1337
+ // ---------------------------------------------------------------------------
1338
+ // Extract / un-punch (pull a hole subpath back out as its own shape) is
1339
+ // deferred — undo covers the immediate reverse.
1340
+
1341
+ const PUNCH_SAMPLE_SPACING = 0.25;
1342
+
1343
+ /** Densify a flattened polyline to ~spacing cell steps along each segment. */
1344
+ function densifyPolyline(poly, spacing = PUNCH_SAMPLE_SPACING) {
1345
+ if (!poly?.length) return [];
1346
+ if (poly.length === 1) return [[poly[0][0], poly[0][1]]];
1347
+ const out = [];
1348
+ const push = (x, y) => {
1349
+ const last = out[out.length - 1];
1350
+ if (last && Math.abs(last[0] - x) < 1e-12 && Math.abs(last[1] - y) < 1e-12) return;
1351
+ out.push([x, y]);
1352
+ };
1353
+ push(poly[0][0], poly[0][1]);
1354
+ for (let i = 1; i < poly.length; i++) {
1355
+ const [x0, y0] = poly[i - 1];
1356
+ const [x1, y1] = poly[i];
1357
+ const len = Math.hypot(x1 - x0, y1 - y0);
1358
+ if (len < 1e-12) continue;
1359
+ const steps = Math.max(1, Math.ceil(len / spacing));
1360
+ for (let s = 1; s <= steps; s++) {
1361
+ const t = s / steps;
1362
+ push(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t);
1363
+ }
1364
+ }
1365
+ return out;
1366
+ }
1367
+
1368
+ /** Dense outline samples (~0.25-cell) for one subpath (cubics flattened). */
1369
+ export function sampleSubpathOutline(sub, spacing = PUNCH_SAMPLE_SPACING) {
1370
+ const stepsPerUnit = Math.max(4, Math.ceil(1 / spacing));
1371
+ return densifyPolyline(flattenSubpath(sub, stepsPerUnit), spacing);
1372
+ }
1373
+
1374
+ /**
1375
+ * True when every dense outline sample of `inner` lies in `outer`'s even-odd
1376
+ * filled region (shapeContainsPoint). Open inner subpaths → false.
1377
+ */
1378
+ export function shapeOutlineContained(outer, inner, spacing = PUNCH_SAMPLE_SPACING) {
1379
+ if (!outer || !inner) return false;
1380
+ const subs = inner.subpaths ?? [];
1381
+ if (!subs.length) return false;
1382
+ for (const sub of subs) {
1383
+ if (!sub.closed) return false;
1384
+ const samples = sampleSubpathOutline(sub, spacing);
1385
+ if (!samples.length) return false;
1386
+ for (const [x, y] of samples) {
1387
+ if (!shapeContainsPoint(outer, x, y)) return false;
1388
+ }
1389
+ }
1390
+ return true;
1391
+ }
1392
+
1393
+ /**
1394
+ * Geometry-derived punch roles for a multi-selection, or null when the action
1395
+ * does not qualify. Exactly one selected shape must strictly contain every
1396
+ * other (inners); all inner subpaths must be closed.
1397
+ */
1398
+ export function punchHoleRoles(path, shapeIndices) {
1399
+ const idxs = [...new Set(shapeIndices ?? [])];
1400
+ if (idxs.length < 2) return null;
1401
+ const shapes = path?.shapes ?? [];
1402
+ for (const i of idxs) {
1403
+ if (!shapes[i]) return null;
1404
+ // Punching merges rings into one shape, which a primitive cannot hold — it
1405
+ // owns a single derived ring and would overwrite the result.
1406
+ if (isPrimitiveShape(shapes[i])) return null;
1407
+ }
1408
+ let found = null;
1409
+ for (const outerIndex of idxs) {
1410
+ const outer = shapes[outerIndex];
1411
+ const innerIndices = idxs.filter((i) => i !== outerIndex);
1412
+ let ok = true;
1413
+ for (const ii of innerIndices) {
1414
+ const inner = shapes[ii];
1415
+ const subs = inner.subpaths ?? [];
1416
+ if (!subs.length || subs.some((s) => !s.closed)) {
1417
+ ok = false;
1418
+ break;
1419
+ }
1420
+ if (!shapeOutlineContained(outer, inner)) {
1421
+ ok = false;
1422
+ break;
1423
+ }
1424
+ }
1425
+ if (!ok) continue;
1426
+ if (found != null) return null; // more than one candidate outer
1427
+ found = { outerIndex, innerIndices: innerIndices.slice().sort((a, b) => a - b) };
1428
+ }
1429
+ return found;
1430
+ }
1431
+
1432
+ export function canPunchHole(path, shapeIndices) {
1433
+ return punchHoleRoles(path, shapeIndices) != null;
1434
+ }
1435
+
1436
+ /**
1437
+ * Move every inner shape's subpaths into the outer (discarding inner fill/
1438
+ * stroke). Inners are removed; outer keeps stack position. Returns
1439
+ * `{ path, outerIndex }` or null when the selection does not qualify.
1440
+ */
1441
+ export function punchHole(path, shapeIndices) {
1442
+ const roles = punchHoleRoles(path, shapeIndices);
1443
+ if (!roles) return null;
1444
+ const { outerIndex, innerIndices } = roles;
1445
+ const shapes = path?.shapes ?? [];
1446
+ const outer = shapes[outerIndex];
1447
+ const holeSubs = [];
1448
+ for (const ii of innerIndices) {
1449
+ for (const sub of shapes[ii].subpaths ?? []) {
1450
+ holeSubs.push(cloneSubpath(sub));
1451
+ }
1452
+ }
1453
+ const punched = {
1454
+ ...outer,
1455
+ fill: outer.fill ?? null,
1456
+ stroke: outer.stroke ?? null,
1457
+ subpaths: [...(outer.subpaths ?? []).map(cloneSubpath), ...holeSubs],
1458
+ };
1459
+ const drop = new Set(innerIndices);
1460
+ const nextShapes = [];
1461
+ let nextOuterIndex = -1;
1462
+ shapes.forEach((shape, i) => {
1463
+ if (drop.has(i)) return;
1464
+ if (i === outerIndex) {
1465
+ nextOuterIndex = nextShapes.length;
1466
+ nextShapes.push(punched);
1467
+ } else {
1468
+ nextShapes.push(shape);
1469
+ }
1470
+ });
1471
+ return { path: { shapes: nextShapes }, outerIndex: nextOuterIndex };
1472
+ }
1473
+
1474
+ /**
1475
+ * Step selected shapes one slot forward (]) preserving relative order:
1476
+ * topmost-selected down, swap each with its unselected neighbor above.
1477
+ */
1478
+ export function bringShapesForward(path, shapeIndices) {
1479
+ const shapes = (path?.shapes ?? []).slice();
1480
+ const selected = new Set(shapeIndices ?? []);
1481
+ if (!selected.size) return { path, indices: [] };
1482
+ for (let i = shapes.length - 1; i >= 0; i--) {
1483
+ if (!selected.has(i) || i >= shapes.length - 1 || selected.has(i + 1)) continue;
1484
+ const tmp = shapes[i];
1485
+ shapes[i] = shapes[i + 1];
1486
+ shapes[i + 1] = tmp;
1487
+ selected.delete(i);
1488
+ selected.add(i + 1);
1489
+ }
1490
+ return { path: { shapes }, indices: [...selected].sort((a, b) => a - b) };
1491
+ }
1492
+
1493
+ /** Step selected shapes one slot backward ([) — bottommost-selected up. */
1494
+ export function sendShapesBackward(path, shapeIndices) {
1495
+ const shapes = (path?.shapes ?? []).slice();
1496
+ const selected = new Set(shapeIndices ?? []);
1497
+ if (!selected.size) return { path, indices: [] };
1498
+ for (let i = 0; i < shapes.length; i++) {
1499
+ if (!selected.has(i) || i <= 0 || selected.has(i - 1)) continue;
1500
+ const tmp = shapes[i];
1501
+ shapes[i] = shapes[i - 1];
1502
+ shapes[i - 1] = tmp;
1503
+ selected.delete(i);
1504
+ selected.add(i - 1);
1505
+ }
1506
+ return { path: { shapes }, indices: [...selected].sort((a, b) => a - b) };
1507
+ }
1508
+
1509
+ export function canBringShapesForward(path, shapeIndices) {
1510
+ const sel = new Set(shapeIndices ?? []);
1511
+ const n = path?.shapes?.length ?? 0;
1512
+ for (let i = n - 1; i >= 0; i--) {
1513
+ if (sel.has(i) && i < n - 1 && !sel.has(i + 1)) return true;
1514
+ }
1515
+ return false;
1516
+ }
1517
+
1518
+ export function canSendShapesBackward(path, shapeIndices) {
1519
+ const sel = new Set(shapeIndices ?? []);
1520
+ for (let i = 0; i < (path?.shapes?.length ?? 0); i++) {
1521
+ if (sel.has(i) && i > 0 && !sel.has(i - 1)) return true;
1522
+ }
1523
+ return false;
1524
+ }
1525
+
1526
+ /** Deep-copy selected shapes to the top of the stack, offset +1,+1 cell. */
1527
+ export function duplicateShapes(path, shapeIndices, dx = 1, dy = 1) {
1528
+ const shapes = (path?.shapes ?? []).slice();
1529
+ const ordered = [...(shapeIndices ?? [])].sort((a, b) => a - b);
1530
+ const copies = ordered.map((i) => translateShape(cloneShape(shapes[i]), dx, dy));
1531
+ const base = shapes.length;
1532
+ return {
1533
+ path: { shapes: [...shapes, ...copies] },
1534
+ indices: copies.map((_, i) => base + i),
1535
+ };
1536
+ }
1537
+
1538
+ export function setShapesFill(path, shapeIndices, key) {
1539
+ let next = path;
1540
+ for (const i of shapeIndices ?? []) next = setShapeFill(next, i, key);
1541
+ return next;
1542
+ }
1543
+
1544
+ export function setShapesStroke(path, shapeIndices, key) {
1545
+ let next = path;
1546
+ for (const i of shapeIndices ?? []) next = setShapeStroke(next, i, key);
1547
+ return next;
1548
+ }
1549
+
1550
+ /** Shared slot value across indices, or the string 'mixed'. */
1551
+ export function sharedShapeSlot(path, shapeIndices, slot) {
1552
+ const idxs = shapeIndices ?? [];
1553
+ if (!idxs.length) return null;
1554
+ const first = path?.shapes?.[idxs[0]]?.[slot] ?? null;
1555
+ for (let i = 1; i < idxs.length; i++) {
1556
+ const v = path?.shapes?.[idxs[i]]?.[slot] ?? null;
1557
+ if (v !== first) return 'mixed';
1558
+ }
1559
+ return first;
1560
+ }
1561
+
1562
+ export function anchorKey(a) {
1563
+ return `${a.subIndex}:${a.pointIndex}`;
1564
+ }
1565
+
1566
+ export function toggleAnchorInList(list, anchor) {
1567
+ const key = anchorKey(anchor);
1568
+ const has = list.some((a) => anchorKey(a) === key);
1569
+ if (has) return list.filter((a) => anchorKey(a) !== key);
1570
+ return [...list, { subIndex: anchor.subIndex, pointIndex: anchor.pointIndex }];
1571
+ }
1572
+
1573
+ export function toggleIndexInList(list, index) {
1574
+ if (list.includes(index)) return list.filter((i) => i !== index);
1575
+ return [...list, index].sort((a, b) => a - b);
1576
+ }
1577
+
1578
+ // ---------------------------------------------------------------------------
1579
+ // Anchor insertion (edge split)
1580
+ // ---------------------------------------------------------------------------
1581
+
1582
+ const MIN_SPLIT_SEG = 0.5;
1583
+ const BOW_EPS_LOCAL = 1e-12;
1584
+
1585
+ /**
1586
+ * Sample one cubic segment as {t,x,y} points (t in [0,1]).
1587
+ */
1588
+ function sampleSegmentCurve(from, seg, steps = 48) {
1589
+ const edge = { a: 0, b: 1 };
1590
+ if (seg.c1) edge.ha = [seg.c1[0], seg.c1[1]];
1591
+ if (seg.c2) edge.hb = [seg.c2[0], seg.c2[1]];
1592
+ const base = sampleEdgeCurve(edge, [from, seg.to]);
1593
+ if (base.length >= steps) return base;
1594
+ const n = Math.max(8, steps);
1595
+ const out = [];
1596
+ for (let i = 0; i <= n; i++) {
1597
+ const t = i / n;
1598
+ const idx = t * (base.length - 1);
1599
+ const lo = Math.floor(idx);
1600
+ const hi = Math.min(base.length - 1, lo + 1);
1601
+ const f = idx - lo;
1602
+ const a = base[lo];
1603
+ const b = base[hi];
1604
+ out.push({ t, x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f });
1605
+ }
1606
+ return out;
1607
+ }
1608
+
1609
+ /**
1610
+ * de Casteljau split of one segment at the curve point nearest (x,y).
1611
+ * Returns null when either half would be shorter than MIN_SPLIT_SEG.
1612
+ */
1613
+ function segPointAt(from, seg, t) {
1614
+ const { from: p0, to: p3, c1, c2 } = absoluteSegment(from, seg);
1615
+ if (!seg.c1 && !seg.c2) {
1616
+ return [p0[0] + (p3[0] - p0[0]) * t, p0[1] + (p3[1] - p0[1]) * t];
1617
+ }
1618
+ return cubicPoint(p0, c1, c2, p3, t);
1619
+ }
1620
+
1621
+ /** Ternary search for the t in [lo, hi] whose curve point is nearest (px, py). */
1622
+ function refineT(from, seg, px, py, lo, hi) {
1623
+ let a = lo;
1624
+ let b = hi;
1625
+ for (let i = 0; i < 32; i++) {
1626
+ const m1 = a + (b - a) / 3;
1627
+ const m2 = b - (b - a) / 3;
1628
+ const p1 = segPointAt(from, seg, m1);
1629
+ const p2 = segPointAt(from, seg, m2);
1630
+ if (distSq(p1[0], p1[1], px, py) < distSq(p2[0], p2[1], px, py)) b = m2;
1631
+ else a = m1;
1632
+ }
1633
+ return (a + b) / 2;
1634
+ }
1635
+
1636
+ export function splitSegmentAtNearest(from, seg, x, y, cellPull = 0) {
1637
+ const samples = sampleSegmentCurve(from, seg, 64);
1638
+ const nearestIndex = (px, py) => {
1639
+ let best = 0;
1640
+ let bestD = Infinity;
1641
+ for (let i = 0; i < samples.length; i++) {
1642
+ const d = distSq(samples[i].x, samples[i].y, px, py);
1643
+ if (d < bestD) {
1644
+ bestD = d;
1645
+ best = i;
1646
+ }
1647
+ }
1648
+ return best;
1649
+ };
1650
+
1651
+ let t = samples[nearestIndex(x, y)].t;
1652
+ // Magnetize the landing point, then re-solve for the parameter that reaches
1653
+ // it. Splitting at a parameter keeps the anchor exactly on the curve, so a
1654
+ // tidy coordinate is reached by sliding ALONG the edge rather than by pulling
1655
+ // the outline off it. Refining beats picking a sample: at 64 samples a long
1656
+ // edge cannot land on the lattice site the pull just chose.
1657
+ if (cellPull > 0) {
1658
+ const p = segPointAt(from, seg, t);
1659
+ const [mx, my] = magneticSnapPoint(p[0], p[1], cellPull, true);
1660
+ if (mx !== p[0] || my !== p[1]) {
1661
+ const j = nearestIndex(mx, my);
1662
+ const lo = samples[Math.max(0, j - 1)].t;
1663
+ const hi = samples[Math.min(samples.length - 1, j + 1)].t;
1664
+ t = refineT(from, seg, mx, my, lo, hi);
1665
+ }
1666
+ }
1667
+
1668
+ const split = deCasteljauSplit(from, seg, t);
1669
+ // Round to serialization precision: the ternary search lands a hair off the
1670
+ // magnetic target, and a point at 8.0000002 is not coincident with one at 8,
1671
+ // so junction detection would miss it until the file round-tripped.
1672
+ const mid = [roundCoord(split.mid[0]), roundCoord(split.mid[1])];
1673
+ const lenLeft = Math.hypot(mid[0] - from[0], mid[1] - from[1]);
1674
+ const lenRight = Math.hypot(seg.to[0] - mid[0], seg.to[1] - mid[1]);
1675
+ if (lenLeft < MIN_SPLIT_SEG || lenRight < MIN_SPLIT_SEG) return null;
1676
+ return { mid, left: { ...split.left, to: mid }, right: split.right, t };
1677
+ }
1678
+
1679
+ /**
1680
+ * Insert an anchor on the edge nearest (x,y). Returns
1681
+ * `{ path, anchor: { subIndex, pointIndex } }` or null (no-op / degenerate).
1682
+ * Splits along the cubic (`splitSegmentAtNearest`); with cellPull > 0 the
1683
+ * landing point magnetizes toward the lattice by sliding along the curve.
1684
+ */
1685
+ export function insertAnchorOnEdge(path, shapeIndex, subIndex, edgeIndex, x, y, cellPull = 0) {
1686
+ const ends = endpointsForEdge(path, shapeIndex, subIndex, edgeIndex);
1687
+ if (!ends) return null;
1688
+ const { sub, edge } = ends;
1689
+ if (edgeIndex < 0 || edgeIndex >= subpathEdgeCount(sub)) return null;
1690
+ const split = splitSegmentAtNearest(edge.from, edge.seg, x, y, cellPull);
1691
+ if (!split) return null;
1692
+ const norm = normalizeSubpath(sub);
1693
+ const segs = [...(norm.segs ?? [])];
1694
+ segs.splice(edgeIndex, 1, split.left, split.right);
1695
+ const nextPath = mapSubpath(path, shapeIndex, subIndex, () => ({
1696
+ closed: !!norm.closed,
1697
+ start: [...norm.start],
1698
+ segs,
1699
+ }));
1700
+ return {
1701
+ path: nextPath,
1702
+ anchor: { subIndex, pointIndex: edgeIndex + 1 },
1703
+ };
1704
+ }
1705
+
1706
+ // ---------------------------------------------------------------------------
1707
+ // Close / open path
1708
+ // ---------------------------------------------------------------------------
1709
+
1710
+ /**
1711
+ * Close/open toggle is only for a single-subpath shape. Multi-subpath shapes
1712
+ * are import-only donuts — disable the toggle rather than guessing which ring.
1713
+ */
1714
+ export function shapeCloseOpenState(shape, subIndex = 0) {
1715
+ // An ellipse is closed by definition; opening one would be undone the next
1716
+ // time its geometry is derived.
1717
+ if (isPrimitiveShape(shape)) return { mode: 'disabled', reason: 'primitive' };
1718
+ const sub = shape?.subpaths?.[subIndex];
1719
+ if (!sub) return { mode: 'disabled', reason: 'no-subpath' };
1720
+ if (sub.closed) return { mode: 'open', closed: true };
1721
+ return { mode: 'close', closed: false, canClose: canCloseSubpath(sub) };
1722
+ }
1723
+
1724
+ function canCloseSubpath(sub) {
1725
+ const n = subpathVertexCount(sub);
1726
+ if (n < 2) return false;
1727
+ if (n === 2) {
1728
+ const edge = subpathEdgeFrom(sub, 0);
1729
+ return edge && Math.abs(bowForSegment(edge.from, edge.seg)) > BOW_EPS_LOCAL;
1730
+ }
1731
+ return n >= 3;
1732
+ }
1733
+
1734
+ export function closeShape(path, shapeIndex, subIndex = 0) {
1735
+ const shape = path?.shapes?.[shapeIndex];
1736
+ const state = shapeCloseOpenState(shape, subIndex);
1737
+ if (state.mode !== 'close' || !state.canClose) return path;
1738
+ return mapSubpath(path, shapeIndex, subIndex, (sub) => {
1739
+ const start = [...sub.start];
1740
+ // No closing segment is appended: the edge back to `start` is implied.
1741
+ const segs = (sub.segs ?? []).map((s) => ({ ...s, to: [...s.to], c1: s.c1 ? [...s.c1] : undefined, c2: s.c2 ? [...s.c2] : undefined }));
1742
+ return { closed: true, start, segs };
1743
+ });
1744
+ }
1745
+
1746
+ /**
1747
+ * Close the subpath the pen is drawing (geometry only). Keeps stroke /
1748
+ * transparent fill. Returns the path unchanged when close is refused (e.g.
1749
+ * &lt;3 anchors). A limb is a subpath like any other, so which one to close is
1750
+ * the caller's to say — closing subpath 0 by assumption sealed the outline a
1751
+ * limb was drawn onto instead of the limb.
1752
+ */
1753
+ export function closePenShape(path, shapeIndex, subIndex = 0) {
1754
+ return closeShape(path, shapeIndex, subIndex);
1755
+ }
1756
+
1757
+ export function openShape(path, shapeIndex, subIndex = 0) {
1758
+ const shape = path?.shapes?.[shapeIndex];
1759
+ const state = shapeCloseOpenState(shape, subIndex);
1760
+ if (state.mode !== 'open') return path;
1761
+ // Open-subpath fill policy: closedRings skips open subs, so a filled shape
1762
+ // would vanish. If it has a fill and NO stroke, transfer fill→stroke and clear
1763
+ // fill; if it already has a stroke, just clear the fill.
1764
+ let next = mapSubpath(path, shapeIndex, subIndex, (sub) => {
1765
+ const start = [...sub.start];
1766
+ let segs = (sub.segs ?? []).map((s) => ({ ...s, to: [...s.to], c1: s.c1 ? [...s.c1] : undefined, c2: s.c2 ? [...s.c2] : undefined }));
1767
+ // Drop the closing segment only when it is pure redundancy. A curved one
1768
+ // ending on `start` carries the outline's geometry — a lens is two curved
1769
+ // edges between two anchors, so stripping by coordinate match alone
1770
+ // flattened one side of it.
1771
+ const last = segs[segs.length - 1];
1772
+ if (last && pointsEqual(last.to, start) && !last.c1 && !last.c2) segs = segs.slice(0, -1);
1773
+ return { closed: false, start, segs };
1774
+ });
1775
+ const opened = next.shapes[shapeIndex];
1776
+ if (opened.fill && !opened.stroke) {
1777
+ next = mapShape(next, shapeIndex, (s) => ({ ...s, fill: null, stroke: s.fill }));
1778
+ } else if (opened.fill) {
1779
+ next = mapShape(next, shapeIndex, (s) => ({ ...s, fill: null }));
1780
+ }
1781
+ return next;
1782
+ }
1783
+
1784
+ // ---------------------------------------------------------------------------
1785
+ // Flip + rotate 90° (selection bbox center)
1786
+ // ---------------------------------------------------------------------------
1787
+
1788
+ /** Combined axis-aligned bounds of all anchors in the selected shapes. */
1789
+ export function selectionAnchorBounds(path, shapeIndices) {
1790
+ let minX = Infinity;
1791
+ let minY = Infinity;
1792
+ let maxX = -Infinity;
1793
+ let maxY = -Infinity;
1794
+ let any = false;
1795
+ for (const si of shapeIndices ?? []) {
1796
+ const shape = path?.shapes?.[si];
1797
+ if (!shape) continue;
1798
+ for (const sub of shape.subpaths ?? []) {
1799
+ for (const p of subpathPoints(sub)) {
1800
+ any = true;
1801
+ if (p[0] < minX) minX = p[0];
1802
+ if (p[1] < minY) minY = p[1];
1803
+ if (p[0] > maxX) maxX = p[0];
1804
+ if (p[1] > maxY) maxY = p[1];
1805
+ }
1806
+ }
1807
+ }
1808
+ if (!any) return null;
1809
+ return { minX, minY, maxX, maxY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
1810
+ }
1811
+
1812
+ /**
1813
+ * Carry a primitive through a flip or rotation by transforming its parameters.
1814
+ *
1815
+ * Every op routed through mapSelectedShapes is an axis-preserving affine map,
1816
+ * so transforming the centre and the two axis tips is enough to recover the
1817
+ * result: a flip returns the same radii, a quarter turn returns them swapped.
1818
+ * Deriving that from mapPoint rather than branching per-op means a new op of
1819
+ * the same family keeps working without touching this.
1820
+ */
1821
+ function mapPrimitiveShape(shape, mapPoint) {
1822
+ const c = mapPoint([shape.cx, shape.cy]);
1823
+ const tipX = mapPoint([shape.cx + shape.rx, shape.cy]);
1824
+ const tipY = mapPoint([shape.cx, shape.cy + shape.ry]);
1825
+ return deriveShape({
1826
+ ...shape,
1827
+ cx: c[0],
1828
+ cy: c[1],
1829
+ rx: Math.max(Math.abs(tipX[0] - c[0]), Math.abs(tipY[0] - c[0])),
1830
+ ry: Math.max(Math.abs(tipX[1] - c[1]), Math.abs(tipY[1] - c[1])),
1831
+ });
1832
+ }
1833
+
1834
+ function mapSelectedShapes(path, shapeIndices, mapPoint, mapVector) {
1835
+ const sel = new Set(shapeIndices ?? []);
1836
+ if (!sel.size) return path;
1837
+ return {
1838
+ shapes: (path?.shapes ?? []).map((shape, i) => {
1839
+ if (!sel.has(i)) return shape;
1840
+ if (isPrimitiveShape(shape)) return mapPrimitiveShape(shape, mapPoint);
1841
+ return {
1842
+ ...shape,
1843
+ subpaths: (shape.subpaths ?? []).map((sub) => {
1844
+ const norm = normalizeSubpath(sub);
1845
+ return {
1846
+ ...norm,
1847
+ start: mapPoint(norm.start),
1848
+ segs: (norm.segs ?? []).map((seg) => {
1849
+ const to = mapPoint(seg.to);
1850
+ const mapped = transformSegmentHandles(seg, mapVector);
1851
+ return { to, c1: mapped.c1, c2: mapped.c2 };
1852
+ }),
1853
+ };
1854
+ }),
1855
+ };
1856
+ }),
1857
+ };
1858
+ }
1859
+
1860
+ /**
1861
+ * Flip selected shapes about the selection bbox center.
1862
+ * Anchors reflect through the center; relative segment handles go through
1863
+ * mapVector (x or y negated).
1864
+ */
1865
+ export function flipShapesHorizontal(path, shapeIndices) {
1866
+ const b = selectionAnchorBounds(path, shapeIndices);
1867
+ if (!b) return path;
1868
+ const { cx } = b;
1869
+ return mapSelectedShapes(
1870
+ path,
1871
+ shapeIndices,
1872
+ (p) => [2 * cx - p[0], p[1]],
1873
+ ([dx, dy]) => [-dx, dy]
1874
+ );
1875
+ }
1876
+
1877
+ export function flipShapesVertical(path, shapeIndices) {
1878
+ const b = selectionAnchorBounds(path, shapeIndices);
1879
+ if (!b) return path;
1880
+ const { cy } = b;
1881
+ return mapSelectedShapes(
1882
+ path,
1883
+ shapeIndices,
1884
+ (p) => [p[0], 2 * cy - p[1]],
1885
+ ([dx, dy]) => [dx, -dy]
1886
+ );
1887
+ }
1888
+
1889
+ /**
1890
+ * Rotate selected shapes 90° CW about the selection bbox center snapped to the
1891
+ * nearest half-cell. An arbitrary center can take lattice points off-lattice;
1892
+ * with cx,cy on the 0.5 lattice the map preserves it. Relative handles rotate
1893
+ * via mapVector.
1894
+ */
1895
+ export function rotateShapes90CW(path, shapeIndices) {
1896
+ const b = selectionAnchorBounds(path, shapeIndices);
1897
+ if (!b) return path;
1898
+ const cx = Math.round(b.cx * 2) / 2;
1899
+ const cy = Math.round(b.cy * 2) / 2;
1900
+ return mapSelectedShapes(
1901
+ path,
1902
+ shapeIndices,
1903
+ (p) => [cx + (p[1] - cy), cy - (p[0] - cx)],
1904
+ ([dx, dy]) => [dy, -dx]
1905
+ );
1906
+ }