castle-web-cli 0.4.115 → 0.4.116

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 +99 -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
@@ -1,16 +1,81 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
- import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { displayFinish, frameCount, isVectorRenderer, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
+ import { emptyPathData, LATTICE, magneticCellPull, subpathEdgeCount, subpathPointAt, subpathPoints, subpathVertexCount } from '../engine/pxartPath';
4
+ import {
5
+ renderSmoothCellsPreview,
6
+ renderSmoothCompositeFrame,
7
+ renderSmoothSpriteFrame,
8
+ } from '../engine/pxartSmooth';
4
9
  import { basename } from '../engine/files';
5
10
  import { EditorBody, Icon, IconButton, styles } from '../engine/ui';
6
- import { eventToCell } from './pixelCanvas';
11
+ import { eventToCell, eventToPoint } from './pixelCanvas';
7
12
  import { forEachDab } from './pixelGeometry';
13
+ import { drawPathOverlay } from './pathOverlay';
14
+ import {
15
+ appendAnchorToSubpath,
16
+ appendShape,
17
+ applyAnchorsDrag,
18
+ applyEdgeBow,
19
+ applyShapesDrag,
20
+ anchorKey,
21
+ bringShapesForward,
22
+ canBringShapesForward,
23
+ canPunchHole,
24
+ canSendShapesBackward,
25
+ closePenShape,
26
+ collectAnchorStarts,
27
+ collectShapesPointStarts,
28
+ duplicateShapes,
29
+ flipShapesHorizontal,
30
+ flipShapesVertical,
31
+ hitTestShape,
32
+ hitTestShapeAnchor,
33
+ hitTestShapeEdge,
34
+ hitTestAnchorsInRect,
35
+ hitTestFillToolTarget,
36
+ hitTestPathEdge,
37
+ hitTestShapesInRect,
38
+ HIT_RADIUS_SCREEN_PX,
39
+ EDGE_HIT_SCREEN_PX,
40
+ OPEN_END_HIT_SCREEN_PX,
41
+ hitRadiusCells,
42
+ absorbRetracedSubpaths,
43
+ insertAnchorOnEdge,
44
+ spliceLimbIntoShape,
45
+ isOpenPenShape,
46
+ isOpenSubpath,
47
+ punchHole,
48
+ hitTestOpenEnd,
49
+ removeEdgesFromShape,
50
+ reverseSubpath,
51
+ removeSubpath,
52
+ startLimbOnShape,
53
+ recolorTopShapeAt,
54
+ removeAnchorsFromShape,
55
+ removeShape,
56
+ removeShapes,
57
+ rotateShapes90CW,
58
+ sendShapesBackward,
59
+ setShapesFill,
60
+ setShapesStroke,
61
+ shapeFromPenDraft,
62
+ sharedShapeSlot,
63
+ snapPenAnchor,
64
+ previewPenLanding,
65
+ toggleAnchorInList,
66
+ toggleIndexInList,
67
+ withCoincidentAnchors,
68
+ } from './pathTools';
69
+ import { appendInkPoint, fitBrushInk, shapeFromBrushFit } from './brushFit';
70
+ import { ghostFromBrushDrag, shapeFromBrushDrag } from './brushShapes';
8
71
  import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
9
72
  import {
10
73
  BRUSH_SIZES,
11
74
  CanvasSizeBar,
12
- CornerRadiusBar,
75
+ FinishBar,
13
76
  PaintStrip,
77
+ PATH_TOOLS,
78
+ PATH_BRUSH_MODE_BY_KEY,
14
79
  PIXEL_TOOLS,
15
80
  PixelToolStrip,
16
81
  PxArtInspectorDock,
@@ -49,6 +114,7 @@ import {
49
114
  linkCell,
50
115
  moveFrame,
51
116
  moveLayer,
117
+ pathAt,
52
118
  patchLayer,
53
119
  patchTag,
54
120
  resizeSprite,
@@ -56,11 +122,13 @@ import {
56
122
  setCellOffset,
57
123
  setDefaultDuration,
58
124
  setDefaultTag,
59
- setCornerRadius,
125
+ setFinish,
60
126
  setFrameDuration,
127
+ setLayerKind,
61
128
  setupSpriteCanvas,
62
129
  unlinkCell,
63
130
  withCellsAt,
131
+ withPathAt,
64
132
  } from './pxArtEditorModel';
65
133
  import { frameDurations, playbackOrder, resolveActiveTag, usePreviewPlayback } from './pxArtPlayback';
66
134
  import { PxArtTimeline } from './pxArtTimeline';
@@ -103,13 +171,30 @@ const TOOL_CURSORS = {
103
171
  shape: 'crosshair',
104
172
  select: 'crosshair',
105
173
  'move-all': 'move',
174
+ 'path-select': 'crosshair',
175
+ bend: 'crosshair',
106
176
  };
107
177
 
178
+ function isPathBrushStroke(stroke) {
179
+ return stroke?.tool === 'brush' && stroke.brushMode != null;
180
+ }
181
+
182
+ // Tools valid while a path layer is active (includes shared ones like fill).
183
+ const PATH_TOOL_IDS = new Set(PATH_TOOLS.map((candidate) => candidate.id));
184
+ // Path-only tools — leaving a path layer remaps these to brush. Shared tools
185
+ // (fill) must NOT be in this set or bitmap Fill immediately snaps back to Brush.
186
+ const PATH_ONLY_TOOL_IDS = new Set(
187
+ PATH_TOOLS.map((candidate) => candidate.id).filter(
188
+ (id) => !PIXEL_TOOLS.some((pixel) => pixel.id === id)
189
+ )
190
+ );
191
+
108
192
  export function PxArtEditor({ path, text, onChange, ...chrome }) {
109
193
  const canvasRef = useRef(null);
110
194
  const smoothRef = useRef(null);
111
195
  const overlayRef = useRef(null);
112
196
  const strokeRef = useRef(null);
197
+ const refitActiveBrushStrokeRef = useRef(() => {});
113
198
  // The serialized text this editor last wrote itself. Lets us tell our own
114
199
  // edits apart from external rewrites (undo/redo) so the marquee can be dropped
115
200
  // when the art changes out from under it.
@@ -124,6 +209,35 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
124
209
  // active cel. `antsOffset` ticks the marching-ants animation while it's shown.
125
210
  const [marquee, setMarquee] = useState(null);
126
211
  const [antsOffset, setAntsOffset] = useState(0);
212
+ // Path-layer freehand ink (unquantized cell coords) + throttled fit ghost.
213
+ const [brushInk, setBrushInk] = useState([]);
214
+ const [brushGhost, setBrushGhost] = useState(null);
215
+ const brushInkRef = useRef([]);
216
+ brushInkRef.current = brushInk;
217
+ // Index of a 1-point open pen stub, so leave/Escape can drop it once (Strict Mode safe).
218
+ const penStubIndexRef = useRef(-1);
219
+ // The limb currently being drawn onto an existing shape: { shapeIndex, subIndex }.
220
+ // Null while the pen is doing its ordinary one-subpath-per-shape work.
221
+ const penLimbRef = useRef(null);
222
+ const penBakeAtRef = useRef(0);
223
+ // Selected shape indices in the active path layer's shapes[] stack.
224
+ const [selectedShapeIndices, setSelectedShapeIndices] = useState([]);
225
+ // Within-shape anchor selection (only meaningful when exactly one shape is selected).
226
+ const [selectedAnchors, setSelectedAnchors] = useState([]);
227
+ // Edges picked out of the selected shape: { shapeIndex, edges: [{ subIndex,
228
+ // edgeIndex }] }. Only meaningful while that shape is the sole selection; see
229
+ // activeEdges, which drops the pick once it stops describing what is there.
230
+ const [selectedEdges, setSelectedEdges] = useState(null);
231
+ const [hoverEdge, setHoverEdge] = useState(null);
232
+ // Time and identity of the last edge click, for double-click to take the run.
233
+ const lastEdgeClickRef = useRef({ at: 0, key: '' });
234
+ // Rubber-band rect for path-select marquee (cell coords), or null.
235
+ const [shapeMarquee, setShapeMarquee] = useState(null);
236
+ const [hoverPoint, setHoverPoint] = useState(null);
237
+ const [hoverInsertEdge, setHoverInsertEdge] = useState(null);
238
+ const [fillHoverTarget, setFillHoverTarget] = useState(null);
239
+ // History-free path for pen hover bake preview (null = show committed art only).
240
+ const [pathBakePreview, setPathBakePreview] = useState(null);
127
241
  const toolState = usePixelToolState();
128
242
  const {
129
243
  tool,
@@ -134,6 +248,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
134
248
  fillSwapMode,
135
249
  shapeMode,
136
250
  shapeFill,
251
+ brushMode,
252
+ setBrushMode,
137
253
  brushSize,
138
254
  setBrushSize,
139
255
  eraseSize,
@@ -151,13 +267,41 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
151
267
  ? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
152
268
  : rawSelection;
153
269
 
270
+ const activeLayer = sprite?.layers[selection.layerIndex];
271
+ const isPathLayer = activeLayer?.kind === 'path';
272
+ const activePath = sprite && isPathLayer ? pathAt(sprite, selection.layerIndex, selection.frameIndex) : emptyPathData();
273
+ // A pick only counts while its shape is still the sole selection and every
274
+ // edge in it still exists, so an edit elsewhere leaves a stale pick inert
275
+ // rather than deleting whatever moved into those slots.
276
+ const activeEdges = useMemo(() => {
277
+ if (!selectedEdges || selectedShapeIndices.length !== 1) return null;
278
+ // Anchors and edges are both sub-shape picks, and Delete has to mean one of
279
+ // them. Whichever was picked last wins: picking an edge clears the anchors,
280
+ // and a live anchor selection makes an older edge pick inert rather than
281
+ // leaving Delete pointed at something the overlay stopped emphasising.
282
+ if (selectedAnchors.length) return null;
283
+ if (selectedShapeIndices[0] !== selectedEdges.shapeIndex) return null;
284
+ const shape = activePath?.shapes?.[selectedEdges.shapeIndex];
285
+ if (!shape) return null;
286
+ const edges = selectedEdges.edges.filter(({ subIndex, edgeIndex }) => {
287
+ const sub = shape.subpaths?.[subIndex];
288
+ return sub && edgeIndex < subpathEdgeCount(sub);
289
+ });
290
+ return edges.length ? { shapeIndex: selectedEdges.shapeIndex, edges } : null;
291
+ }, [selectedEdges, selectedAnchors, selectedShapeIndices, activePath]);
292
+
154
293
  const activeTag = sprite ? resolveActiveTag(sprite, sprite.defaultTag) : null;
155
294
  const order = sprite ? playbackOrder(sprite, activeTag) : [0];
156
295
  const durations = sprite ? frameDurations(sprite, order) : [100];
157
296
  const repeat = activeTag ? activeTag.repeat : 0;
158
297
  const previewIndex = usePreviewPlayback({ order, durations, repeat }, playing, selection.frameIndex);
159
298
 
160
- useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, {
299
+ const artboardSprite =
300
+ pathBakePreview && sprite
301
+ ? withPathAt(sprite, selection.layerIndex, selection.frameIndex, pathBakePreview)
302
+ : sprite;
303
+
304
+ useArtboardRender(canvasRef, text, artboardSprite, previewIndex, onion, playing, {
161
305
  selection,
162
306
  hoverCell,
163
307
  tool,
@@ -165,6 +309,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
165
309
  brushSize,
166
310
  eraseSize,
167
311
  activeKey,
312
+ pathBakePreview,
168
313
  });
169
314
  const { canvasStyle, displaySize } = useArtboardFit({
170
315
  wrapRef: canvasWrapRef,
@@ -180,6 +325,20 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
180
325
  const [zoom, setZoom] = useState(1);
181
326
  const zoomRef = useRef(zoom);
182
327
  zoomRef.current = zoom;
328
+ const pathCellPull =
329
+ displaySize?.width && sprite?.resolution?.width
330
+ ? magneticCellPull(sprite.resolution.width, displaySize.width, zoom)
331
+ : LATTICE / 2;
332
+ const pathCellPx =
333
+ displaySize?.width && sprite?.resolution?.width
334
+ ? (displaySize.width * zoom) / sprite.resolution.width
335
+ : 16;
336
+ const pathHitRadii = {
337
+ anchor: hitRadiusCells(HIT_RADIUS_SCREEN_PX, pathCellPx),
338
+ edge: hitRadiusCells(EDGE_HIT_SCREEN_PX, pathCellPx),
339
+ stroke: hitRadiusCells(EDGE_HIT_SCREEN_PX, pathCellPx),
340
+ openEnd: hitRadiusCells(OPEN_END_HIT_SCREEN_PX, pathCellPx),
341
+ };
183
342
  // Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
184
343
  // anchorY) client coords — defaulting to the viewport center. Scroll is fixed
185
344
  // up on the next frame, once the resized canvas has laid out.
@@ -304,8 +463,18 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
304
463
  // effect has no way to know the wrap resized (nothing about `text`,
305
464
  // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
306
465
  // it would keep rendering at whatever scale was last measured.
307
- useSmoothPreviewRender(smoothRef, text, sprite, previewIndex, canvasStyle?.width);
308
- useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
466
+ useSmoothPreviewRender(smoothRef, text, artboardSprite, previewIndex, canvasStyle?.width, pathBakePreview);
467
+ useToolShortcuts({
468
+ tool,
469
+ picking,
470
+ brushMode,
471
+ setTool,
472
+ setPicking,
473
+ setBrushSize,
474
+ setEraseSize,
475
+ setBrushMode,
476
+ isPathLayer,
477
+ });
309
478
  const isCompactLayout = useEditorCompactLayout(editorBodyRef);
310
479
  useEffect(() => {
311
480
  if (!isCompactLayout) setPaletteOpen(false);
@@ -319,14 +488,83 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
319
488
  useEffect(() => {
320
489
  setMarquee(null);
321
490
  }, [selection.layerIndex, selection.frameIndex]);
322
- // Undo/redo (or any external rewrite) restores the art text but not the
323
- // separate marquee state, which would strand the selection box over the wrong
324
- // pixels. A text value this editor didn't produce means an external change, so
325
- // drop the selection to keep it from ever desyncing from the art.
491
+ useEffect(() => {
492
+ if (isPathLayer && !PATH_TOOL_IDS.has(tool)) setTool('brush');
493
+ else if (!isPathLayer && PATH_ONLY_TOOL_IDS.has(tool)) setTool('brush');
494
+ }, [isPathLayer, tool, setTool]);
495
+ useEffect(() => {
496
+ // Points are already in the file; drop a lone 1-point stub when leaving pen mode.
497
+ if (!(isPathLayer && tool === 'brush' && brushMode === 'pen')) {
498
+ dropLoneOpenPenPoint();
499
+ }
500
+ if (tool !== 'path-select') {
501
+ setShapeMarquee(null);
502
+ // Keep selection across brush/bend ↔ select so the live shape stays useful.
503
+ if (!isPathLayer || (tool !== 'brush' && tool !== 'bend')) {
504
+ setSelectedShapeIndices([]);
505
+ setSelectedAnchors([]);
506
+ }
507
+ }
508
+ if (!isPathLayer || tool !== 'brush') {
509
+ clearBrushGesture();
510
+ } else if (brushMode !== 'pen') {
511
+ setPathBakePreview(null);
512
+ }
513
+ }, [tool, brushMode, isPathLayer]);
514
+ useEffect(() => {
515
+ clearBrushGesture();
516
+ setSelectedShapeIndices([]);
517
+ setSelectedAnchors([]);
518
+ setSelectedEdges(null);
519
+ setShapeMarquee(null);
520
+ setHoverEdge(null);
521
+ setHoverPoint(null);
522
+ setHoverInsertEdge(null);
523
+ setFillHoverTarget(null);
524
+ penLimbRef.current = null;
525
+ penStubIndexRef.current = -1;
526
+ lastEdgeClickRef.current = { at: 0, key: '' };
527
+ }, [selection.layerIndex, selection.frameIndex]);
528
+ // Selection is UI-only — clear on play, never restored by undo.
529
+ useEffect(() => {
530
+ if (!playing) return;
531
+ setSelectedShapeIndices([]);
532
+ setSelectedAnchors([]);
533
+ setShapeMarquee(null);
534
+ clearBrushGesture();
535
+ }, [playing]);
536
+ // Undo/redo (or any external rewrite) restores the art text but not UI chrome.
537
+ // Keep shape indices that still exist so pen point-undo leaves the open stroke
538
+ // selected; drop anything out of range.
326
539
  useEffect(() => {
327
540
  if (text === lastSelfTextRef.current) return;
328
541
  lastSelfTextRef.current = text;
329
542
  setMarquee(null);
543
+ setShapeMarquee(null);
544
+ // Brush create-drags preview via pathBakePreview (no mid-gesture writes).
545
+ // Clearing them here on a stale fs echo left the rest of the drag blank.
546
+ if (!isPathBrushStroke(strokeRef.current)) clearBrushGesture();
547
+ const spr = deriveSprite(text);
548
+ const nextPath =
549
+ spr && selection ? pathAt(spr, selection.layerIndex, selection.frameIndex) : null;
550
+ const n = nextPath?.shapes?.length ?? 0;
551
+ setSelectedShapeIndices((prev) => prev.filter((i) => i >= 0 && i < n));
552
+ // Anchor picks used to be dropped wholesale here. That made any rewrite that
553
+ // did not match our own last write — a stale echo of the very art we just
554
+ // committed, say — deselect the point mid-gesture. Keep the refs that still
555
+ // name a live point, on the same terms the shape indices above are kept.
556
+ setSelectedAnchors((prev) => {
557
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
558
+ const shape = sole >= 0 && sole < n ? nextPath?.shapes?.[sole] : null;
559
+ if (!shape) return prev.length ? [] : prev;
560
+ const kept = prev.filter((a) => {
561
+ const sub = shape.subpaths?.[a.subIndex];
562
+ return sub && a.pointIndex < subpathVertexCount(sub);
563
+ });
564
+ return kept.length === prev.length ? prev : kept;
565
+ });
566
+ if (penStubIndexRef.current >= n) penStubIndexRef.current = -1;
567
+ if (penLimbRef.current && penLimbRef.current.shapeIndex >= n) penLimbRef.current = null;
330
568
  }, [text]);
331
569
  // March the ants only while a selection is visible.
332
570
  useEffect(() => {
@@ -334,12 +572,81 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
334
572
  const id = setInterval(() => setAntsOffset((o) => (o + 1) % 1000), 80);
335
573
  return () => clearInterval(id);
336
574
  }, [tool, marquee]);
337
- // Draw the marching-ants selection on a display-resolution overlay canvas so
338
- // the outline stays crisp (drawing it on the upscaled native artboard renders
339
- // the thin dashes sub-pixel and washed out).
340
575
  useEffect(() => {
341
- renderMarqueeOverlay(overlayRef.current, sprite?.resolution, playing ? null : marquee, antsOffset);
342
- }, [marquee, antsOffset, playing, canvasStyle, sprite?.resolution.width, sprite?.resolution.height]);
576
+ if (isPathLayer) {
577
+ drawPathOverlay(overlayRef.current, sprite?.resolution, playing ? null : activePath, {
578
+ tool,
579
+ brushMode,
580
+ palette: EDITOR_PALETTE,
581
+ brushInk,
582
+ brushGhost,
583
+ hoverPoint,
584
+ hoverInsertEdge,
585
+ penLimb: penLimbRef.current,
586
+ selectedShapeIndices,
587
+ selectedAnchors,
588
+ selectedEdges: activeEdges,
589
+ hoverEdge,
590
+ shapeMarquee,
591
+ fillHoverTarget,
592
+ activeColor: EDITOR_PALETTE[activeKey],
593
+ smoothFinish: isVectorRenderer(sprite?.renderer),
594
+ });
595
+ } else {
596
+ const finish = displayFinish(sprite);
597
+ const preview =
598
+ playing || !finish.smooth
599
+ ? null
600
+ : buildToolPreview({
601
+ sprite,
602
+ selection,
603
+ hoverCell,
604
+ tool,
605
+ picking,
606
+ brushSize,
607
+ eraseSize,
608
+ activeKey,
609
+ playing,
610
+ });
611
+ renderPixelOverlay(overlayRef.current, sprite?.resolution, {
612
+ marquee: playing ? null : marquee,
613
+ antsOffset,
614
+ preview,
615
+ cornerRadius: finish.cornerRadius,
616
+ });
617
+ }
618
+ }, [
619
+ isPathLayer,
620
+ activePath,
621
+ tool,
622
+ brushMode,
623
+ marquee,
624
+ antsOffset,
625
+ playing,
626
+ canvasStyle,
627
+ brushInk,
628
+ brushGhost,
629
+ hoverPoint,
630
+ hoverInsertEdge,
631
+ selectedShapeIndices,
632
+ selectedAnchors,
633
+ activeEdges,
634
+ hoverEdge,
635
+ shapeMarquee,
636
+ fillHoverTarget,
637
+ activeKey,
638
+ hoverCell,
639
+ picking,
640
+ brushSize,
641
+ eraseSize,
642
+ selection.layerIndex,
643
+ selection.frameIndex,
644
+ sprite?.cornerRadius,
645
+ sprite?.renderer,
646
+ sprite?.resolution.width,
647
+ sprite?.resolution.height,
648
+ text,
649
+ ]);
343
650
  // Escape deselects; Delete/Backspace clears the selected pixels. Both only
344
651
  // while the select tool is active and a marquee exists.
345
652
  useEffect(() => {
@@ -358,6 +665,110 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
358
665
  window.addEventListener('keydown', onKeyDown);
359
666
  return () => window.removeEventListener('keydown', onKeyDown);
360
667
  }, [tool, marquee, text, selection.layerIndex, selection.frameIndex]);
668
+ // Path-layer keyboard: pen close/open, shape/anchor delete / z-order / nudge / duplicate.
669
+ // Plain [ / ] are free on path tools (brush-size adjust only binds on brush/erase).
670
+ useEffect(() => {
671
+ if (!isPathLayer) return undefined;
672
+ function onKeyDown(event) {
673
+ if (isTypingTarget(event.target || document.activeElement)) return;
674
+ if (tool === 'brush') {
675
+ if (brushMode === 'pen') {
676
+ if (event.key === 'Escape') {
677
+ endPenSession({ dropLone: true });
678
+ event.preventDefault();
679
+ } else if (event.key === 'Enter') {
680
+ closeLivePenShape();
681
+ event.preventDefault();
682
+ }
683
+ return;
684
+ }
685
+ if (event.key === 'Escape') {
686
+ cancelBrushGesture();
687
+ event.preventDefault();
688
+ }
689
+ return;
690
+ }
691
+ if (tool !== 'path-select') return;
692
+ const mod = event.metaKey || event.ctrlKey;
693
+ if (mod && !event.altKey && event.key.toLowerCase() === 'a') {
694
+ const n = activePath?.shapes?.length ?? 0;
695
+ setSelectedShapeIndices(Array.from({ length: n }, (_, i) => i));
696
+ setSelectedAnchors([]);
697
+ setSelectedEdges(null);
698
+ event.preventDefault();
699
+ return;
700
+ }
701
+ if (mod && !event.altKey && event.key.toLowerCase() === 'd') {
702
+ if (selectedShapeIndices.length) {
703
+ duplicateSelectedShapes();
704
+ event.preventDefault();
705
+ }
706
+ return;
707
+ }
708
+ if (event.key === 'Escape') {
709
+ setSelectedShapeIndices([]);
710
+ setSelectedAnchors([]);
711
+ setSelectedEdges(null);
712
+ setShapeMarquee(null);
713
+ event.preventDefault();
714
+ return;
715
+ }
716
+ if (!selectedShapeIndices.length) return;
717
+ if (event.key === 'Delete' || event.key === 'Backspace') {
718
+ if (!mod && !event.altKey) {
719
+ deleteSelection();
720
+ event.preventDefault();
721
+ }
722
+ } else if (event.key === ']' && !event.altKey && !mod) {
723
+ bringSelectedForward();
724
+ event.preventDefault();
725
+ } else if (event.key === '[' && !event.altKey && !mod) {
726
+ sendSelectedBackward();
727
+ event.preventDefault();
728
+ } else if (
729
+ (event.key === 'ArrowLeft' ||
730
+ event.key === 'ArrowRight' ||
731
+ event.key === 'ArrowUp' ||
732
+ event.key === 'ArrowDown') &&
733
+ !mod
734
+ ) {
735
+ nudgeSelection(event.key, !!event.altKey);
736
+ event.preventDefault();
737
+ }
738
+ }
739
+ window.addEventListener('keydown', onKeyDown);
740
+ return () => window.removeEventListener('keydown', onKeyDown);
741
+ }, [
742
+ isPathLayer,
743
+ tool,
744
+ brushMode,
745
+ selectedShapeIndices,
746
+ selectedAnchors,
747
+ text,
748
+ selection.layerIndex,
749
+ selection.frameIndex,
750
+ activeKey,
751
+ activePath,
752
+ ]);
753
+ // Shift/Alt during a brush primitive drag only update on pointermove unless
754
+ // we also listen here — toggling mid-stroke otherwise feels random.
755
+ useEffect(() => {
756
+ function onBrushModifierKey(event) {
757
+ if (event.key !== 'Shift' && event.key !== 'Alt') return;
758
+ if (isTypingTarget(event.target || document.activeElement)) return;
759
+ const stroke = strokeRef.current;
760
+ if (!isPathBrushStroke(stroke) || stroke.brushMode === 'pen') return;
761
+ stroke.halfPixel = event.getModifierState('Alt');
762
+ if (stroke.brushMode !== 'freehand') stroke.constrain = event.getModifierState('Shift');
763
+ refitActiveBrushStrokeRef.current();
764
+ }
765
+ window.addEventListener('keydown', onBrushModifierKey);
766
+ window.addEventListener('keyup', onBrushModifierKey);
767
+ return () => {
768
+ window.removeEventListener('keydown', onBrushModifierKey);
769
+ window.removeEventListener('keyup', onBrushModifierKey);
770
+ };
771
+ }, []);
361
772
 
362
773
  if (!sprite) {
363
774
  return (
@@ -373,12 +784,47 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
373
784
  ? 'crosshair'
374
785
  : overSelection
375
786
  ? 'move'
376
- : (TOOL_CURSORS[tool] ?? 'default');
787
+ : isPathLayer && tool === 'brush'
788
+ ? 'crosshair'
789
+ : (TOOL_CURSORS[tool] ?? 'default');
377
790
 
378
791
  function togglePalette(next) {
379
792
  setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
380
793
  }
381
794
 
795
+ function brushModifiersFromEvent(event) {
796
+ if (!event?.getModifierState) {
797
+ return { halfPixel: !!event?.altKey, constrain: !!event?.shiftKey };
798
+ }
799
+ return {
800
+ halfPixel: event.getModifierState('Alt'),
801
+ constrain: event.getModifierState('Shift'),
802
+ };
803
+ }
804
+
805
+ function applyBrushModifiers(stroke, event) {
806
+ if (!isPathBrushStroke(stroke) || !event) return;
807
+ const { halfPixel, constrain } = brushModifiersFromEvent(event);
808
+ stroke.halfPixel = halfPixel;
809
+ if (stroke.brushMode !== 'freehand') stroke.constrain = constrain;
810
+ }
811
+
812
+ function refitActiveBrushStroke() {
813
+ const stroke = strokeRef.current;
814
+ if (!isPathBrushStroke(stroke) || stroke.brushMode === 'pen') return;
815
+ if (stroke.brushMode === 'freehand') {
816
+ refitBrushGhost(brushInkRef.current, stroke.halfPixel);
817
+ return;
818
+ }
819
+ refitBrushPrimitiveGhost(
820
+ stroke.startPoint,
821
+ stroke.lastPoint ?? stroke.startPoint,
822
+ stroke.halfPixel,
823
+ stroke.constrain
824
+ );
825
+ }
826
+ refitActiveBrushStrokeRef.current = refitActiveBrushStroke;
827
+
382
828
  // Push a draft Sprite to the file (live, mid-stroke). Records ONE undo
383
829
  // snapshot for the whole gesture and dedups by serialized text.
384
830
  function liveSprite(nextSprite) {
@@ -386,6 +832,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
386
832
  if (!stroke) return;
387
833
  const nextText = serializeModel(nextSprite);
388
834
  if (nextText === stroke.draftText) return;
835
+ // Click-without-drag (path select, etc.): identical to gesture-start text —
836
+ // do not open a history entry or notify.
837
+ if (!stroke.recorded && stroke.baseText != null && nextText === stroke.baseText) return;
389
838
  if (!stroke.recorded) {
390
839
  history.recordSnapshot();
391
840
  stroke.recorded = true;
@@ -437,6 +886,11 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
437
886
  if (!stroke) return;
438
887
  strokeRef.current = null;
439
888
  if (stroke.tool === 'select') setMarquee(stroke.originRect ?? null);
889
+ // The path rubber band is drawn from its own state rather than from the
890
+ // selection, so unlike the pixel marquee it survives an abort unless it is
891
+ // cleared here; there is no prior band to roll back to.
892
+ if (stroke.tool === 'path-select') setShapeMarquee(null);
893
+ if (isPathBrushStroke(stroke)) clearBrushGesture();
440
894
  if (stroke.recorded) {
441
895
  const baseText = serializeModel(stroke.base);
442
896
  lastSelfTextRef.current = baseText;
@@ -447,6 +901,879 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
447
901
  history.cancelSnapshot();
448
902
  }
449
903
  }
904
+ function commitPath(nextPath, options) {
905
+ // Hold the invariant that an edge exists once, after any edit rather than
906
+ // only the one that happened to produce a duplicate. Skipped mid-limb: a
907
+ // half-drawn run can lie on the outline on its way somewhere else.
908
+ const settled = penLimbRef.current ? nextPath : absorbRetracedSubpaths(nextPath);
909
+ const nextText = serializeModel(withPathAt(sprite, selection.layerIndex, selection.frameIndex, settled));
910
+ lastSelfTextRef.current = nextText;
911
+ history.commit(nextText, options);
912
+ }
913
+ /**
914
+ * Run the same pass at the end of a drag.
915
+ *
916
+ * A gesture's final state arrives through liveSprite, which cannot absorb as
917
+ * it goes: mid-drag a run may legitimately lie along the outline on its way
918
+ * somewhere else. Settling on release is what stops dragging from being the
919
+ * one way to leave a duplicate edge in the file. This extends the gesture's
920
+ * open history entry rather than opening a second one, so it undoes together
921
+ * with the move that caused it.
922
+ */
923
+ function settleDraggedPath(stroke) {
924
+ if (!stroke?.recorded || !stroke.draftText) return;
925
+ if (stroke.tool !== 'path-select' && stroke.tool !== 'bend') return;
926
+ if (penLimbRef.current) return;
927
+ const spr = deriveSprite(stroke.draftText);
928
+ const drawn = spr ? pathAt(spr, stroke.layerIndex, stroke.frameIndex) : null;
929
+ if (!drawn) return;
930
+ const settled = absorbRetracedSubpaths(drawn);
931
+ if (settled === drawn) return;
932
+ const nextText = serializeModel(
933
+ withPathAt(spr, stroke.layerIndex, stroke.frameIndex, settled)
934
+ );
935
+ lastSelfTextRef.current = nextText;
936
+ onChange(nextText);
937
+ }
938
+ function clearBrushGesture() {
939
+ brushInkRef.current = [];
940
+ setBrushInk([]);
941
+ setBrushGhost(null);
942
+ setPathBakePreview(null);
943
+ if (isPathBrushStroke(strokeRef.current)) strokeRef.current = null;
944
+ }
945
+ function cancelBrushGesture() {
946
+ clearBrushGesture();
947
+ }
948
+ // History-free live bake (same channel as pen hover). Mutating the file on
949
+ // every pointermove was thrashing onChange/text effects and could abort the
950
+ // gesture mid-drag — preview would vanish until mouse-up.
951
+ function liveBrushBake(shape) {
952
+ const stroke = strokeRef.current;
953
+ if (!isPathBrushStroke(stroke) || !stroke.path) return;
954
+ if (!shape) return;
955
+ setPathBakePreview(appendShape(stroke.path, shape));
956
+ }
957
+ function refitBrushGhost(ink, halfPixel = false) {
958
+ const fit = fitBrushInk(ink, { halfPixel, cellPull: pathCellPull });
959
+ const shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
960
+ // Cyan ghost stays visible even when the bake blends into existing pixels.
961
+ // Skip clearing on a null fit so the preview doesn't blink in the
962
+ // degenerate snap cell around the gesture start.
963
+ if (shape) setBrushGhost(shape);
964
+ liveBrushBake(shape);
965
+ }
966
+ function refitBrushPrimitiveGhost(start, end, halfPixel = false, constrain = false) {
967
+ const stroke = strokeRef.current;
968
+ const mode = stroke?.brushMode ?? brushMode;
969
+ const shape = ghostFromBrushDrag(mode, start, end, { halfPixel, constrain, activeKey, cellPull: pathCellPull });
970
+ if (shape) setBrushGhost(shape);
971
+ liveBrushBake(shape);
972
+ }
973
+ function commitBrushGesture(endPoint) {
974
+ const stroke = strokeRef.current;
975
+ if (!isPathBrushStroke(stroke)) return;
976
+ const halfPixel = !!stroke.halfPixel;
977
+ const constrain = !!stroke.constrain;
978
+ let shape = null;
979
+ if (stroke.brushMode === 'freehand') {
980
+ const fit = fitBrushInk(brushInkRef.current, { halfPixel, cellPull: pathCellPull });
981
+ shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
982
+ } else {
983
+ const start = stroke.startPoint;
984
+ const end = endPoint ?? stroke.lastPoint ?? start;
985
+ shape = shapeFromBrushDrag(stroke.brushMode, start, end, {
986
+ halfPixel,
987
+ constrain,
988
+ activeKey,
989
+ cellPull: pathCellPull,
990
+ });
991
+ }
992
+ const base = stroke.base;
993
+ const path = stroke.path;
994
+ const layerIndex = stroke.layerIndex;
995
+ const frameIndex = stroke.frameIndex;
996
+ const top = path?.shapes?.length ?? 0;
997
+ clearBrushGesture();
998
+ if (!shape || !base) return;
999
+ const nextText = serializeModel(withPathAt(base, layerIndex, frameIndex, appendShape(path, shape)));
1000
+ lastSelfTextRef.current = nextText;
1001
+ history.commit(nextText);
1002
+ setSelectedShapeIndices([top]);
1003
+ setSelectedAnchors([]);
1004
+ }
1005
+ /** Does a click here come back to the live subpath's first anchor and close it?
1006
+ * A limb closes the same way: coming back to where it started makes a loop,
1007
+ * and a loop is what fills. Landing anywhere else on the outline is a splice. */
1008
+ function penClickWouldClose(target, sx, sy) {
1009
+ if (!target) return false;
1010
+ const pts = subpathPoints(activePath.shapes[target.shapeIndex].subpaths[target.subIndex]);
1011
+ if (pts.length < 3) return false;
1012
+ const [fx, fy] = pts[0];
1013
+ return (sx - fx) * (sx - fx) + (sy - fy) * (sy - fy) <= 0.6 * 0.6;
1014
+ }
1015
+ function buildPenHoverBakePath(point, halfPixel) {
1016
+ const path = activePath;
1017
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
1018
+ const target = livePenTarget();
1019
+ const preview = previewPenLanding(path, sole, point.x, point.y, halfPixel, pathCellPull);
1020
+ const [sx, sy] = preview.point;
1021
+
1022
+ if (penClickWouldClose(target, sx, sy)) {
1023
+ const closed = closePenShape(path, target.shapeIndex, target.subIndex);
1024
+ return closed !== path ? closed : null;
1025
+ }
1026
+
1027
+ // A branch about to be rooted previews as the shape with its new anchor.
1028
+ if (!target && sole >= 0) {
1029
+ const root = penBranchRoot(point, sole);
1030
+ if (root) return root.path;
1031
+ }
1032
+
1033
+ // Preview the merged silhouette, so which of the two runs gets replaced is
1034
+ // visible before committing to it.
1035
+ const spliced = spliceAt(point, penLimbRef.current);
1036
+ if (spliced) return spliced;
1037
+
1038
+ if (preview.edge) {
1039
+ const inserted = insertAnchorOnEdge(
1040
+ path,
1041
+ preview.edge.shapeIndex,
1042
+ preview.edge.subIndex,
1043
+ preview.edge.edgeIndex,
1044
+ point.x,
1045
+ point.y,
1046
+ pathCellPull
1047
+ );
1048
+ return inserted?.path ?? null;
1049
+ }
1050
+
1051
+ if (target) {
1052
+ return appendAnchorToSubpath(path, target.shapeIndex, target.subIndex, [sx, sy]);
1053
+ }
1054
+
1055
+ return appendShape(path, shapeFromPenDraft([[sx, sy]], { closed: false, activeKey }));
1056
+ }
1057
+ function updatePenHoverBake(point, halfPixel) {
1058
+ const now = performance.now();
1059
+ if (now - penBakeAtRef.current < 30) return;
1060
+ penBakeAtRef.current = now;
1061
+ setPathBakePreview(buildPenHoverBakePath(point, halfPixel));
1062
+ }
1063
+ function livePenShapeIndex() {
1064
+ if (selectedShapeIndices.length !== 1) return -1;
1065
+ const i = selectedShapeIndices[0];
1066
+ return isOpenPenShape(activePath?.shapes?.[i]) ? i : -1;
1067
+ }
1068
+ /** The open subpath the next pen click extends, or null. A limb wins if one is live. */
1069
+ function livePenTarget() {
1070
+ const limb = penLimbRef.current;
1071
+ if (limb) {
1072
+ const sub = activePath?.shapes?.[limb.shapeIndex]?.subpaths?.[limb.subIndex];
1073
+ if (isOpenSubpath(sub)) return limb;
1074
+ penLimbRef.current = null;
1075
+ }
1076
+ const i = livePenShapeIndex();
1077
+ return i >= 0 ? { shapeIndex: i, subIndex: 0 } : null;
1078
+ }
1079
+ function dropLoneLimbPoint() {
1080
+ const limb = penLimbRef.current;
1081
+ penLimbRef.current = null;
1082
+ if (!limb) return false;
1083
+ const sub = activePath?.shapes?.[limb.shapeIndex]?.subpaths?.[limb.subIndex];
1084
+ if (isOpenSubpath(sub) && subpathVertexCount(sub) === 1) {
1085
+ commitPath(removeSubpath(activePath, limb.shapeIndex, limb.subIndex));
1086
+ }
1087
+ setSelectedShapeIndices([]);
1088
+ setSelectedAnchors([]);
1089
+ return true;
1090
+ }
1091
+ function dropLoneOpenPenPoint() {
1092
+ if (dropLoneLimbPoint()) return;
1093
+ const i = penStubIndexRef.current;
1094
+ if (i < 0) return;
1095
+ penStubIndexRef.current = -1;
1096
+ const shape = activePath?.shapes?.[i];
1097
+ if (!isOpenPenShape(shape)) return;
1098
+ const pts = subpathPoints(shape.subpaths[0]);
1099
+ if (pts.length !== 1) return;
1100
+ commitPath(removeShape(activePath, i));
1101
+ setSelectedShapeIndices([]);
1102
+ setSelectedAnchors([]);
1103
+ }
1104
+ function endPenSession({ dropLone = false } = {}) {
1105
+ if (dropLone) dropLoneOpenPenPoint();
1106
+ else {
1107
+ penStubIndexRef.current = -1;
1108
+ penLimbRef.current = null;
1109
+ }
1110
+ setSelectedShapeIndices([]);
1111
+ setSelectedAnchors([]);
1112
+ }
1113
+ /** Where a branch would root on `shapeIndex`: an existing anchor, or a fresh one
1114
+ * inserted into the edge under the cursor. Returns `{ path, at }` or null. */
1115
+ function penBranchRoot(point, shapeIndex) {
1116
+ const anchor = hitTestShapeAnchor(activePath, shapeIndex, point.x, point.y, pathHitRadii.anchor);
1117
+ if (anchor) {
1118
+ const p = subpathPointAt(activePath.shapes[shapeIndex].subpaths[anchor.subIndex], anchor.pointIndex);
1119
+ return { path: activePath, at: p };
1120
+ }
1121
+ const edge = hitTestShapeEdge(activePath, shapeIndex, point.x, point.y, pathHitRadii.edge);
1122
+ if (!edge) return null;
1123
+ const inserted = insertAnchorOnEdge(
1124
+ activePath,
1125
+ edge.shapeIndex,
1126
+ edge.subIndex,
1127
+ edge.edgeIndex,
1128
+ point.x,
1129
+ point.y,
1130
+ pathCellPull
1131
+ );
1132
+ if (!inserted) return null;
1133
+ const { subIndex, pointIndex } = inserted.anchor;
1134
+ return { path: inserted.path, at: subpathPointAt(inserted.path.shapes[shapeIndex].subpaths[subIndex], pointIndex) };
1135
+ }
1136
+ /**
1137
+ * A live limb whose next click lands back on a closed subpath of the same
1138
+ * shape is a splice: the drawn run replaces one run of that loop and the two
1139
+ * become a single closed path. Pure — returns the resulting path or null, so
1140
+ * the hover preview and the click can share one answer.
1141
+ *
1142
+ * Landing on the limb's own geometry is not a splice; only closed subpaths
1143
+ * are candidates, which is what keeps a stroked branch a branch.
1144
+ */
1145
+ function spliceAt(point, limb) {
1146
+ if (!limb) return null;
1147
+ const shape = activePath?.shapes?.[limb.shapeIndex];
1148
+ const closedSub = (subIndex) => !!shape?.subpaths?.[subIndex]?.closed;
1149
+ if (subpathVertexCount(shape?.subpaths?.[limb.subIndex]) < 2) return null;
1150
+
1151
+ let rooted = activePath;
1152
+ let at = null;
1153
+ const anchor = hitTestShapeAnchor(activePath, limb.shapeIndex, point.x, point.y, pathHitRadii.anchor);
1154
+ // A junction holds one anchor per subpath meeting there, and the hit lands
1155
+ // on whichever was stored last — usually a divider left by an earlier
1156
+ // splice. Resolve through the site to the loop's own copy, or landing on an
1157
+ // existing junction would fail to close the region.
1158
+ const onLoop = anchor
1159
+ ? withCoincidentAnchors(activePath, limb.shapeIndex, [anchor]).find((a) =>
1160
+ closedSub(a.subIndex)
1161
+ )
1162
+ : null;
1163
+ if (onLoop) {
1164
+ at = subpathPointAt(shape.subpaths[onLoop.subIndex], onLoop.pointIndex);
1165
+ } else {
1166
+ const edge = hitTestShapeEdge(activePath, limb.shapeIndex, point.x, point.y, pathHitRadii.edge);
1167
+ if (!edge || !closedSub(edge.subIndex)) return null;
1168
+ const inserted = insertAnchorOnEdge(
1169
+ activePath,
1170
+ edge.shapeIndex,
1171
+ edge.subIndex,
1172
+ edge.edgeIndex,
1173
+ point.x,
1174
+ point.y,
1175
+ pathCellPull
1176
+ );
1177
+ if (!inserted) return null;
1178
+ rooted = inserted.path;
1179
+ at = subpathPointAt(
1180
+ rooted.shapes[limb.shapeIndex].subpaths[inserted.anchor.subIndex],
1181
+ inserted.anchor.pointIndex
1182
+ );
1183
+ }
1184
+ if (!at) return null;
1185
+ const extended = appendAnchorToSubpath(rooted, limb.shapeIndex, limb.subIndex, at);
1186
+ return spliceLimbIntoShape(extended, limb.shapeIndex, limb.subIndex);
1187
+ }
1188
+ function startPenBranch(point, shapeIndex) {
1189
+ const root = penBranchRoot(point, shapeIndex);
1190
+ if (!root) return false;
1191
+ const limb = startLimbOnShape(root.path, shapeIndex, root.at);
1192
+ if (!limb) return false;
1193
+ penStubIndexRef.current = -1;
1194
+ penLimbRef.current = { shapeIndex, subIndex: limb.subIndex };
1195
+ commitPath(limb.path);
1196
+ setSelectedShapeIndices([shapeIndex]);
1197
+ setSelectedAnchors([]);
1198
+ return true;
1199
+ }
1200
+ /**
1201
+ * Clicking the loose end of an open subpath picks the pen back up there.
1202
+ *
1203
+ * Without this the only open subpath the pen could carry on with was a lone
1204
+ * one, so a loop opened by cutting an edge out of a shape that also carries a
1205
+ * line — which is most of them — could only be closed again by undoing.
1206
+ */
1207
+ function resumeOpenEnd(sx, sy, shapeIndex) {
1208
+ const hit = hitTestOpenEnd(activePath, shapeIndex, sx, sy, pathHitRadii.openEnd);
1209
+ if (!hit) return false;
1210
+ if (hit.end === 'head') {
1211
+ const turned = reverseSubpath(activePath, shapeIndex, hit.subIndex);
1212
+ if (turned !== activePath) commitPath(turned);
1213
+ }
1214
+ penLimbRef.current = { shapeIndex, subIndex: hit.subIndex };
1215
+ penStubIndexRef.current = -1;
1216
+ setSelectedShapeIndices([shapeIndex]);
1217
+ setSelectedAnchors([]);
1218
+ return true;
1219
+ }
1220
+ function closeLivePenShape() {
1221
+ const target = livePenTarget();
1222
+ penLimbRef.current = null;
1223
+ if (!target) return false;
1224
+ const i = target.shapeIndex;
1225
+ const next = closePenShape(activePath, i, target.subIndex);
1226
+ if (next === activePath) return false;
1227
+ penStubIndexRef.current = -1;
1228
+ commitPath(next);
1229
+ // Keep the closed shape selected so edge-insert / further edits stay available.
1230
+ setSelectedShapeIndices([i]);
1231
+ setSelectedAnchors([]);
1232
+ return true;
1233
+ }
1234
+ function deleteSelection() {
1235
+ if (!selectedShapeIndices.length) return;
1236
+ if (activeEdges) {
1237
+ const next = removeEdgesFromShape(activePath, activeEdges.shapeIndex, activeEdges.edges);
1238
+ commitPath(next);
1239
+ setSelectedEdges(null);
1240
+ setSelectedAnchors([]);
1241
+ // Cutting a shape's last edge takes the shape with it.
1242
+ if (!next?.shapes?.[activeEdges.shapeIndex]) setSelectedShapeIndices([]);
1243
+ return;
1244
+ }
1245
+ if (selectedShapeIndices.length === 1 && selectedAnchors.length) {
1246
+ const shapeIndex = selectedShapeIndices[0];
1247
+ const { path: next, anchors: advanced } = removeAnchorsFromShape(
1248
+ activePath,
1249
+ shapeIndex,
1250
+ selectedAnchors
1251
+ );
1252
+ commitPath(next);
1253
+ if (!next?.shapes?.[shapeIndex]) {
1254
+ setSelectedShapeIndices([]);
1255
+ setSelectedAnchors([]);
1256
+ return;
1257
+ }
1258
+ // Advance selection to next survivors so repeated Delete chews along the path.
1259
+ setSelectedAnchors(advanced);
1260
+ return;
1261
+ }
1262
+ commitPath(removeShapes(activePath, selectedShapeIndices));
1263
+ setSelectedShapeIndices([]);
1264
+ setSelectedAnchors([]);
1265
+ }
1266
+ function bringSelectedForward() {
1267
+ if (!canBringShapesForward(activePath, selectedShapeIndices)) return;
1268
+ const { path: next, indices } = bringShapesForward(activePath, selectedShapeIndices);
1269
+ commitPath(next);
1270
+ setSelectedShapeIndices(indices);
1271
+ setSelectedAnchors([]);
1272
+ }
1273
+ function sendSelectedBackward() {
1274
+ if (!canSendShapesBackward(activePath, selectedShapeIndices)) return;
1275
+ const { path: next, indices } = sendShapesBackward(activePath, selectedShapeIndices);
1276
+ commitPath(next);
1277
+ setSelectedShapeIndices(indices);
1278
+ setSelectedAnchors([]);
1279
+ }
1280
+ function duplicateSelectedShapes() {
1281
+ if (!selectedShapeIndices.length) return;
1282
+ const { path: next, indices } = duplicateShapes(activePath, selectedShapeIndices, 1, 1);
1283
+ commitPath(next);
1284
+ setSelectedShapeIndices(indices);
1285
+ setSelectedAnchors([]);
1286
+ }
1287
+ function punchSelectedHole() {
1288
+ const result = punchHole(activePath, selectedShapeIndices);
1289
+ if (!result) return;
1290
+ commitPath(result.path);
1291
+ setSelectedShapeIndices([result.outerIndex]);
1292
+ setSelectedAnchors([]);
1293
+ }
1294
+ function nudgeSelection(arrowKey, halfCell) {
1295
+ if (!selectedShapeIndices.length) return;
1296
+ let dx = 0;
1297
+ let dy = 0;
1298
+ const step = halfCell ? 0.5 : 1;
1299
+ if (arrowKey === 'ArrowLeft') dx = -step;
1300
+ else if (arrowKey === 'ArrowRight') dx = step;
1301
+ else if (arrowKey === 'ArrowUp') dy = -step;
1302
+ else if (arrowKey === 'ArrowDown') dy = step;
1303
+ else return;
1304
+ const coalesceKey = `path-nudge:${halfCell ? 'h' : 'w'}:${arrowKey}`;
1305
+ const commitNudge = (path) => commitPath(path, { coalesceKey });
1306
+ // A sub-shape pick moves by its anchors, whether it was made by clicking
1307
+ // them or by picking the edges between them — the arrows have to agree with
1308
+ // what dragging the same selection does.
1309
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
1310
+ const picked = activeEdges
1311
+ ? anchorsForEdges(activePath, sole, activeEdges.edges)
1312
+ : sole >= 0 && selectedAnchors.length
1313
+ ? selectedAnchors
1314
+ : null;
1315
+ if (picked?.length) {
1316
+ const anchors = withCoincidentAnchors(activePath, sole, picked);
1317
+ const starts = collectAnchorStarts(activePath, sole, anchors);
1318
+ commitNudge(applyAnchorsDrag(activePath, sole, anchors, starts, dx, dy, halfCell, pathCellPull));
1319
+ return;
1320
+ }
1321
+ const pointStarts = collectShapesPointStarts(activePath, selectedShapeIndices);
1322
+ const next = applyShapesDrag(
1323
+ activePath,
1324
+ selectedShapeIndices,
1325
+ pointStarts,
1326
+ dx,
1327
+ dy,
1328
+ halfCell,
1329
+ pathCellPull
1330
+ );
1331
+ commitNudge(next);
1332
+ }
1333
+ const fillSlot = sharedShapeSlot(activePath, selectedShapeIndices, 'fill');
1334
+ const strokeSlot = sharedShapeSlot(activePath, selectedShapeIndices, 'stroke');
1335
+ const pathActions = {
1336
+ hasSelection: selectedShapeIndices.length > 0,
1337
+ selectedShapeIndices,
1338
+ fillSlot,
1339
+ strokeSlot,
1340
+ canForward: canBringShapesForward(activePath, selectedShapeIndices),
1341
+ canBackward: canSendShapesBackward(activePath, selectedShapeIndices),
1342
+ canPunchHole: canPunchHole(activePath, selectedShapeIndices),
1343
+ deleteSelected: deleteSelection,
1344
+ bringForward: bringSelectedForward,
1345
+ sendBackward: sendSelectedBackward,
1346
+ punchHole: punchSelectedHole,
1347
+ flipH: () => {
1348
+ if (!selectedShapeIndices.length) return;
1349
+ commitPath(flipShapesHorizontal(activePath, selectedShapeIndices));
1350
+ },
1351
+ flipV: () => {
1352
+ if (!selectedShapeIndices.length) return;
1353
+ commitPath(flipShapesVertical(activePath, selectedShapeIndices));
1354
+ },
1355
+ rotateCW: () => {
1356
+ if (!selectedShapeIndices.length) return;
1357
+ commitPath(rotateShapes90CW(activePath, selectedShapeIndices));
1358
+ },
1359
+ setFill: (key) => {
1360
+ if (!selectedShapeIndices.length) return;
1361
+ commitPath(setShapesFill(activePath, selectedShapeIndices, key));
1362
+ },
1363
+ setStroke: (key) => {
1364
+ if (!selectedShapeIndices.length) return;
1365
+ commitPath(setShapesStroke(activePath, selectedShapeIndices, key));
1366
+ },
1367
+ };
1368
+ function startPenStroke(point, event) {
1369
+ const halfPixel = !!event?.altKey;
1370
+ const [sx, sy] = snapPenAnchor(point.x, point.y, halfPixel, pathCellPull);
1371
+ const target = livePenTarget();
1372
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
1373
+
1374
+ if (penClickWouldClose(target, sx, sy)) {
1375
+ closeLivePenShape();
1376
+ return;
1377
+ }
1378
+
1379
+ // Nothing live, but a shape is selected: a click on it starts a branch there
1380
+ // and keeps drawing, rather than dropping a point and ending the operation.
1381
+ if (!target && sole >= 0 && resumeOpenEnd(sx, sy, sole)) return;
1382
+ if (!target && sole >= 0 && startPenBranch(point, sole)) return;
1383
+
1384
+ // A limb landing back on the loop it was drawn from splices into it, rather
1385
+ // than dropping a stray anchor and leaving the region unclosable.
1386
+ const limb = penLimbRef.current;
1387
+ const spliced = spliceAt(point, limb);
1388
+ if (spliced) {
1389
+ penLimbRef.current = null;
1390
+ penStubIndexRef.current = -1;
1391
+ commitPath(spliced);
1392
+ setSelectedShapeIndices([limb.shapeIndex]);
1393
+ setSelectedAnchors([]);
1394
+ return;
1395
+ }
1396
+
1397
+ // Click on the selected shape's edge → insert (open or closed).
1398
+ if (target && sole >= 0) {
1399
+ const edge = hitTestShapeEdge(activePath, sole, point.x, point.y, pathHitRadii.edge);
1400
+ if (edge) {
1401
+ const inserted = insertAnchorOnEdge(
1402
+ activePath,
1403
+ edge.shapeIndex,
1404
+ edge.subIndex,
1405
+ edge.edgeIndex,
1406
+ point.x,
1407
+ point.y,
1408
+ pathCellPull
1409
+ );
1410
+ if (inserted) {
1411
+ penStubIndexRef.current = -1;
1412
+ commitPath(inserted.path);
1413
+ setSelectedAnchors([inserted.anchor]);
1414
+ return;
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ if (target) {
1420
+ penStubIndexRef.current = -1;
1421
+ commitPath(
1422
+ appendAnchorToSubpath(activePath, target.shapeIndex, target.subIndex, [sx, sy])
1423
+ );
1424
+ setSelectedAnchors([]);
1425
+ return;
1426
+ }
1427
+
1428
+ // Start a new open stroked shape at this anchor.
1429
+ const shape = shapeFromPenDraft([[sx, sy]], { closed: false, activeKey });
1430
+ const next = appendShape(activePath, shape);
1431
+ const top = (next.shapes?.length ?? 1) - 1;
1432
+ penStubIndexRef.current = top;
1433
+ commitPath(next);
1434
+ setSelectedShapeIndices([top]);
1435
+ setSelectedAnchors([]);
1436
+ }
1437
+ const edgeKey = (e) => `${e.subIndex}:${e.edgeIndex}`;
1438
+ /** The anchors at both ends of each edge, deduplicated. */
1439
+ function anchorsForEdges(pathData, shapeIndex, edges) {
1440
+ const out = [];
1441
+ const seen = new Set();
1442
+ for (const { subIndex, edgeIndex } of edges) {
1443
+ const sub = pathData?.shapes?.[shapeIndex]?.subpaths?.[subIndex];
1444
+ if (!sub) continue;
1445
+ const count = subpathVertexCount(sub);
1446
+ // The last edge of a closed subpath comes back round to point 0.
1447
+ for (const pointIndex of [edgeIndex, (edgeIndex + 1) % count]) {
1448
+ const id = `${subIndex}:${pointIndex}`;
1449
+ if (seen.has(id)) continue;
1450
+ seen.add(id);
1451
+ out.push({ subIndex, pointIndex });
1452
+ }
1453
+ }
1454
+ return out;
1455
+ }
1456
+ function edgesAreSelected(refs) {
1457
+ const have = new Set((activeEdges?.edges ?? []).map(edgeKey));
1458
+ return refs.length > 0 && refs.every((e) => have.has(edgeKey(e)));
1459
+ }
1460
+ /** Every edge of the subpath an edge belongs to, for double-click. */
1461
+ function edgesOfSubpath(pathData, shapeIndex, subIndex) {
1462
+ const sub = pathData?.shapes?.[shapeIndex]?.subpaths?.[subIndex];
1463
+ const count = sub ? subpathEdgeCount(sub) : 0;
1464
+ return Array.from({ length: count }, (_, edgeIndex) => ({ subIndex, edgeIndex }));
1465
+ }
1466
+ /**
1467
+ * What an edge press means. Pressing an edge that is already picked moves the
1468
+ * pick; pressing anywhere else on the outline still moves the shape, which is
1469
+ * how a shape has always been dragged by its stroke.
1470
+ */
1471
+ function edgePressIntent(pathData, shapeIndex, x, y) {
1472
+ const hit = hitTestShapeEdge(pathData, shapeIndex, x, y, pathHitRadii.edge);
1473
+ if (!hit) return null;
1474
+ const ref = { subIndex: hit.subIndex, edgeIndex: hit.edgeIndex };
1475
+ const now = performance.now();
1476
+ const last = lastEdgeClickRef.current;
1477
+ const double = now - last.at < 400 && last.key === edgeKey(ref);
1478
+ lastEdgeClickRef.current = { at: now, key: edgeKey(ref) };
1479
+ return { ref, double, moving: edgesAreSelected([ref]) };
1480
+ }
1481
+ function startPathSelectStroke(point, event) {
1482
+ const pathData = activePath;
1483
+ const shift = !!event?.shiftKey;
1484
+ const baseText = serializeModel(sprite);
1485
+ const soleIndex = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : null;
1486
+
1487
+ // Single-shape only: anchors and edge hits (edge drag moves the shape).
1488
+ if (soleIndex != null) {
1489
+ const anchor = hitTestShapeAnchor(pathData, soleIndex, point.x, point.y, pathHitRadii.anchor);
1490
+ if (anchor) {
1491
+ // Picking an anchor drops the edge pick outright instead of leaving it
1492
+ // stored-but-inert: a pick still in storage springs back into view the
1493
+ // moment the anchors clear, reading as the selection reverting.
1494
+ setSelectedEdges(null);
1495
+ if (shift) {
1496
+ setSelectedAnchors((prev) => toggleAnchorInList(prev, anchor));
1497
+ strokeRef.current = null;
1498
+ return;
1499
+ }
1500
+ const anchorRef = { subIndex: anchor.subIndex, pointIndex: anchor.pointIndex };
1501
+ const already =
1502
+ selectedAnchors.length > 0 &&
1503
+ selectedAnchors.some(
1504
+ (a) => a.subIndex === anchorRef.subIndex && a.pointIndex === anchorRef.pointIndex
1505
+ );
1506
+ let anchorsForDrag;
1507
+ let pendingCollapse = null;
1508
+ if (already && selectedAnchors.length > 1) {
1509
+ // Click-vs-drag: keep multi until up without drag collapses to this anchor.
1510
+ anchorsForDrag = selectedAnchors;
1511
+ pendingCollapse = anchorRef;
1512
+ } else {
1513
+ anchorsForDrag = [anchorRef];
1514
+ setSelectedAnchors([anchorRef]);
1515
+ }
1516
+ // Selection stays what was clicked; the drag carries the whole junction.
1517
+ anchorsForDrag = withCoincidentAnchors(pathData, soleIndex, anchorsForDrag);
1518
+ const starts = collectAnchorStarts(pathData, soleIndex, anchorsForDrag);
1519
+ strokeRef.current = {
1520
+ tool: 'path-select',
1521
+ mode: 'anchors',
1522
+ base: sprite,
1523
+ baseText,
1524
+ layerIndex: selection.layerIndex,
1525
+ frameIndex: selection.frameIndex,
1526
+ path: pathData,
1527
+ shapeIndex: soleIndex,
1528
+ anchors: anchorsForDrag,
1529
+ starts,
1530
+ pendingCollapse,
1531
+ startPoint: point,
1532
+ dragged: false,
1533
+ draftText: null,
1534
+ recorded: false,
1535
+ };
1536
+ return;
1537
+ }
1538
+ const intent = edgePressIntent(pathData, soleIndex, point.x, point.y);
1539
+ if (intent?.moving) {
1540
+ // An edge moves by its endpoints, so this is an anchor drag whose
1541
+ // anchors happen to be picked out by edges instead of clicked directly.
1542
+ setSelectedAnchors([]);
1543
+ const anchors = withCoincidentAnchors(
1544
+ pathData,
1545
+ soleIndex,
1546
+ anchorsForEdges(pathData, soleIndex, activeEdges.edges)
1547
+ );
1548
+ strokeRef.current = {
1549
+ tool: 'path-select',
1550
+ mode: 'anchors',
1551
+ base: sprite,
1552
+ baseText,
1553
+ layerIndex: selection.layerIndex,
1554
+ frameIndex: selection.frameIndex,
1555
+ path: pathData,
1556
+ shapeIndex: soleIndex,
1557
+ anchors,
1558
+ starts: collectAnchorStarts(pathData, soleIndex, anchors),
1559
+ pendingCollapse: null,
1560
+ startPoint: point,
1561
+ dragged: false,
1562
+ draftText: null,
1563
+ recorded: false,
1564
+ };
1565
+ return;
1566
+ }
1567
+ if (intent) {
1568
+ setSelectedAnchors([]);
1569
+ const pointStarts = collectShapesPointStarts(pathData, [soleIndex]);
1570
+ strokeRef.current = {
1571
+ tool: 'path-select',
1572
+ mode: 'shapes',
1573
+ base: sprite,
1574
+ baseText,
1575
+ layerIndex: selection.layerIndex,
1576
+ frameIndex: selection.frameIndex,
1577
+ path: pathData,
1578
+ shapeIndices: [soleIndex],
1579
+ pointStarts,
1580
+ startPoint: point,
1581
+ dragged: false,
1582
+ pendingCollapse: null,
1583
+ // Dragging an unpicked edge still moves the shape. Releasing without
1584
+ // moving means the press was aimed at the edge itself.
1585
+ pendingEdge: { ref: intent.ref, double: intent.double, additive: shift },
1586
+ draftText: null,
1587
+ recorded: false,
1588
+ };
1589
+ return;
1590
+ }
1591
+ }
1592
+
1593
+ const hit = hitTestShape(pathData, point.x, point.y, pathHitRadii.stroke);
1594
+ if (hit >= 0) {
1595
+ if (shift) {
1596
+ setSelectedShapeIndices((prev) => toggleIndexInList(prev, hit));
1597
+ setSelectedAnchors([]);
1598
+ strokeRef.current = null;
1599
+ return;
1600
+ }
1601
+ if (selectedShapeIndices.includes(hit)) {
1602
+ // Pointer-down on already-selected: don't collapse yet; drag moves all.
1603
+ setSelectedAnchors([]);
1604
+ strokeRef.current = {
1605
+ tool: 'path-select',
1606
+ mode: 'shapes',
1607
+ base: sprite,
1608
+ baseText,
1609
+ layerIndex: selection.layerIndex,
1610
+ frameIndex: selection.frameIndex,
1611
+ path: pathData,
1612
+ shapeIndices: selectedShapeIndices.slice(),
1613
+ pointStarts: collectShapesPointStarts(pathData, selectedShapeIndices),
1614
+ pendingCollapse: hit,
1615
+ startPoint: point,
1616
+ dragged: false,
1617
+ draftText: null,
1618
+ recorded: false,
1619
+ };
1620
+ return;
1621
+ }
1622
+ // Plain click on unselected → select only it; allow immediate drag.
1623
+ setSelectedShapeIndices([hit]);
1624
+ setSelectedAnchors([]);
1625
+ strokeRef.current = {
1626
+ tool: 'path-select',
1627
+ mode: 'shapes',
1628
+ base: sprite,
1629
+ baseText,
1630
+ layerIndex: selection.layerIndex,
1631
+ frameIndex: selection.frameIndex,
1632
+ path: pathData,
1633
+ shapeIndices: [hit],
1634
+ pointStarts: collectShapesPointStarts(pathData, [hit]),
1635
+ pendingCollapse: null,
1636
+ startPoint: point,
1637
+ dragged: false,
1638
+ draftText: null,
1639
+ recorded: false,
1640
+ };
1641
+ return;
1642
+ }
1643
+
1644
+ // Empty area: marquee (shift adds shapes; alt selects anchors on sole shape).
1645
+ const alt = !!event?.altKey;
1646
+ if (alt && soleIndex == null) {
1647
+ strokeRef.current = null;
1648
+ return;
1649
+ }
1650
+ if (alt && soleIndex != null) {
1651
+ if (!shift) setSelectedAnchors([]);
1652
+ strokeRef.current = {
1653
+ tool: 'path-select',
1654
+ mode: 'marquee',
1655
+ target: 'anchors',
1656
+ shapeIndex: soleIndex,
1657
+ additive: shift,
1658
+ base: sprite,
1659
+ baseText,
1660
+ layerIndex: selection.layerIndex,
1661
+ frameIndex: selection.frameIndex,
1662
+ startPoint: point,
1663
+ lastPoint: point,
1664
+ originAnchors: shift ? selectedAnchors.map((a) => ({ ...a })) : [],
1665
+ draftText: null,
1666
+ recorded: false,
1667
+ };
1668
+ const cell = { x: Math.floor(point.x), y: Math.floor(point.y) };
1669
+ setShapeMarquee({ x: cell.x, y: cell.y, w: 1, h: 1 });
1670
+ return;
1671
+ }
1672
+ setSelectedAnchors([]);
1673
+ strokeRef.current = {
1674
+ tool: 'path-select',
1675
+ mode: 'marquee',
1676
+ target: 'shapes',
1677
+ additive: shift,
1678
+ base: sprite,
1679
+ baseText,
1680
+ layerIndex: selection.layerIndex,
1681
+ frameIndex: selection.frameIndex,
1682
+ startPoint: point,
1683
+ lastPoint: point,
1684
+ originSelection: shift ? selectedShapeIndices.slice() : [],
1685
+ draftText: null,
1686
+ recorded: false,
1687
+ };
1688
+ if (!shift) {
1689
+ setSelectedShapeIndices([]);
1690
+ }
1691
+ const cell = { x: Math.floor(point.x), y: Math.floor(point.y) };
1692
+ setShapeMarquee({ x: cell.x, y: cell.y, w: 1, h: 1 });
1693
+ }
1694
+ function startBrushStroke(point, event) {
1695
+ if (brushMode === 'pen') {
1696
+ setPathBakePreview(null);
1697
+ startPenStroke(point, event);
1698
+ return;
1699
+ }
1700
+ const { halfPixel, constrain } = brushModifiersFromEvent(event);
1701
+ const base = sprite;
1702
+ const baseText = serializeModel(sprite);
1703
+ const path = activePath;
1704
+ if (brushMode === 'freehand') {
1705
+ const ink = appendInkPoint([], point.x, point.y);
1706
+ brushInkRef.current = ink;
1707
+ setBrushInk(ink.slice());
1708
+ setBrushGhost(null);
1709
+ strokeRef.current = {
1710
+ tool: 'brush',
1711
+ brushMode: 'freehand',
1712
+ lastFitAt: 0,
1713
+ halfPixel,
1714
+ base,
1715
+ baseText,
1716
+ layerIndex: selection.layerIndex,
1717
+ frameIndex: selection.frameIndex,
1718
+ path,
1719
+ draftText: null,
1720
+ recorded: false,
1721
+ };
1722
+ refitBrushGhost(ink, halfPixel);
1723
+ return;
1724
+ }
1725
+ brushInkRef.current = [];
1726
+ setBrushInk([]);
1727
+ setBrushGhost(null);
1728
+ strokeRef.current = {
1729
+ tool: 'brush',
1730
+ brushMode,
1731
+ startPoint: point,
1732
+ lastPoint: point,
1733
+ halfPixel,
1734
+ constrain,
1735
+ base,
1736
+ baseText,
1737
+ layerIndex: selection.layerIndex,
1738
+ frameIndex: selection.frameIndex,
1739
+ path,
1740
+ draftText: null,
1741
+ recorded: false,
1742
+ };
1743
+ refitBrushPrimitiveGhost(point, point, halfPixel, constrain);
1744
+ }
1745
+ function startBendStroke(point) {
1746
+ const edge = hitTestPathEdge(activePath, point.x, point.y, pathHitRadii.edge);
1747
+ if (!edge) return;
1748
+ // Hit-any-edge: adopt the owning shape so inspector / Delete stay in sync.
1749
+ setSelectedShapeIndices([edge.shapeIndex]);
1750
+ setSelectedAnchors([]);
1751
+ strokeRef.current = {
1752
+ tool: 'bend',
1753
+ mode: 'bend',
1754
+ base: sprite,
1755
+ baseText: serializeModel(sprite),
1756
+ layerIndex: selection.layerIndex,
1757
+ frameIndex: selection.frameIndex,
1758
+ path: activePath,
1759
+ edge,
1760
+ startPoint: point,
1761
+ draftText: null,
1762
+ recorded: false,
1763
+ };
1764
+ }
1765
+ function startPathTool(point, event) {
1766
+ if (tool === 'fill') {
1767
+ const next = recolorTopShapeAt(activePath, point.x, point.y, activeKey);
1768
+ if (next !== activePath) commitPath(next);
1769
+ } else if (tool === 'brush') {
1770
+ startBrushStroke(point, event);
1771
+ } else if (tool === 'path-select') {
1772
+ startPathSelectStroke(point, event);
1773
+ } else if (tool === 'bend') {
1774
+ startBendStroke(point);
1775
+ }
1776
+ }
450
1777
  function startTool(event) {
451
1778
  // Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
452
1779
  // handler (bound on this iframe's window) instead of falling through to the
@@ -454,6 +1781,23 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
454
1781
  // after clicking a non-focusable canvas, so we focus it explicitly. Done
455
1782
  // before the early-return so even a click outside a cell claims focus.
456
1783
  event.currentTarget.focus({ preventScroll: true });
1784
+ if (isPathLayer) {
1785
+ event.currentTarget.setPointerCapture(event.pointerId);
1786
+ const point = eventToPoint(event, width, height);
1787
+ if (picking) {
1788
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
1789
+ setActiveKey(
1790
+ sampleKey(cells, {
1791
+ x: Math.min(width - 1, Math.floor(point.x)),
1792
+ y: Math.min(height - 1, Math.floor(point.y)),
1793
+ })
1794
+ );
1795
+ setPicking(false);
1796
+ return;
1797
+ }
1798
+ startPathTool(point, event);
1799
+ return;
1800
+ }
457
1801
  const point = eventToCell(event, width, height);
458
1802
  if (!point) return;
459
1803
  event.currentTarget.setPointerCapture(event.pointerId);
@@ -520,13 +1864,72 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
520
1864
  function updateTool(event) {
521
1865
  const stroke = strokeRef.current;
522
1866
  if (!stroke || event.buttons !== 1) return;
1867
+ if (isPathLayer) {
1868
+ updateStroke(eventToPoint(event, width, height), event);
1869
+ return;
1870
+ }
523
1871
  const point = eventToCell(event, width, height);
524
- if (point) updateStroke(point);
1872
+ if (point) updateStroke(point, event);
525
1873
  }
526
1874
  // Track the hovered cell for the non-destructive tool preview overlay. While a
527
1875
  // stroke is active the preview is hidden so it never fights the live paint.
528
1876
  function handlePointerMove(event) {
529
1877
  updateTool(event);
1878
+ if (isPathLayer) {
1879
+ if (strokeRef.current) {
1880
+ setHoverPoint(null);
1881
+ setHoverInsertEdge(null);
1882
+ setHoverEdge(null);
1883
+ setFillHoverTarget(null);
1884
+ // Brush create-drags write pathBakePreview in updateTool; clearing it
1885
+ // here made the last setState(null) win every move and blank the bake.
1886
+ if (!isPathBrushStroke(strokeRef.current)) setPathBakePreview(null);
1887
+ return;
1888
+ }
1889
+ const point = eventToPoint(event, width, height);
1890
+ const halfPixel = !!event.altKey;
1891
+ // Brush: snapped landing ghost (pen mode also previews edge-insert + bake).
1892
+ // Bend: highlight the edge that would bend.
1893
+ if (tool === 'brush' && brushMode === 'pen') {
1894
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
1895
+ const preview = previewPenLanding(activePath, sole, point.x, point.y, halfPixel, pathCellPull);
1896
+ setHoverPoint(preview.point);
1897
+ setHoverInsertEdge(preview.edge);
1898
+ setFillHoverTarget(null);
1899
+ updatePenHoverBake(point, halfPixel);
1900
+ } else if (tool === 'brush') {
1901
+ setHoverPoint(snapPenAnchor(point.x, point.y, halfPixel, pathCellPull));
1902
+ setHoverInsertEdge(null);
1903
+ setFillHoverTarget(null);
1904
+ setPathBakePreview(null);
1905
+ } else if (tool === 'bend') {
1906
+ setHoverPoint(null);
1907
+ setHoverInsertEdge(hitTestPathEdge(activePath, point.x, point.y, pathHitRadii.edge));
1908
+ setFillHoverTarget(null);
1909
+ setPathBakePreview(null);
1910
+ } else if (tool === 'fill') {
1911
+ setHoverPoint(null);
1912
+ setHoverInsertEdge(null);
1913
+ setFillHoverTarget(hitTestFillToolTarget(activePath, point.x, point.y));
1914
+ setPathBakePreview(null);
1915
+ } else if (tool === 'path-select') {
1916
+ const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
1917
+ const hit = sole >= 0 ? hitTestShapeEdge(activePath, sole, point.x, point.y, pathHitRadii.edge) : null;
1918
+ setHoverEdge(
1919
+ hit ? { shapeIndex: sole, subIndex: hit.subIndex, edgeIndex: hit.edgeIndex } : null
1920
+ );
1921
+ setHoverPoint([point.x, point.y]);
1922
+ setHoverInsertEdge(null);
1923
+ setFillHoverTarget(null);
1924
+ setPathBakePreview(null);
1925
+ } else {
1926
+ setHoverPoint([point.x, point.y]);
1927
+ setHoverInsertEdge(null);
1928
+ setFillHoverTarget(null);
1929
+ setPathBakePreview(null);
1930
+ }
1931
+ return;
1932
+ }
530
1933
  if (strokeRef.current) {
531
1934
  setHoverCell(null);
532
1935
  return;
@@ -536,8 +1939,15 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
536
1939
  }
537
1940
  function clearHover() {
538
1941
  setHoverCell(null);
1942
+ setHoverPoint(null);
1943
+ setHoverInsertEdge(null);
1944
+ setHoverEdge(null);
1945
+ setFillHoverTarget(null);
1946
+ // pointerleave still fires under capture when exiting the artboard box —
1947
+ // don't wipe an in-flight brush bake preview.
1948
+ if (!isPathBrushStroke(strokeRef.current)) setPathBakePreview(null);
539
1949
  }
540
- function updateStroke(point) {
1950
+ function updateStroke(point, event) {
541
1951
  const stroke = strokeRef.current;
542
1952
  if (!stroke) return;
543
1953
  if (stroke.tool === 'select') {
@@ -552,7 +1962,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
552
1962
  stroke.lastPoint = point;
553
1963
  return;
554
1964
  }
555
- if (stroke.tool === 'brush') {
1965
+ if (stroke.tool === 'brush' && stroke.brushMode == null) {
556
1966
  liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
557
1967
  stroke.lastPoint = point;
558
1968
  } else if (stroke.tool === 'erase') {
@@ -569,10 +1979,103 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
569
1979
  const x = stroke.startOffset.x + (point.x - stroke.startPoint.x);
570
1980
  const y = stroke.startOffset.y + (point.y - stroke.startPoint.y);
571
1981
  liveSprite(setCellOffset(stroke.base, stroke.layerIndex, stroke.frameIndex, x, y));
1982
+ } else if (isPathBrushStroke(stroke)) {
1983
+ applyBrushModifiers(stroke, event);
1984
+ if (stroke.brushMode === 'freehand') {
1985
+ const ink = brushInkRef.current;
1986
+ appendInkPoint(ink, point.x, point.y);
1987
+ brushInkRef.current = ink;
1988
+ setBrushInk(ink.slice());
1989
+ const now = performance.now();
1990
+ if (now - (stroke.lastFitAt || 0) >= 30) {
1991
+ stroke.lastFitAt = now;
1992
+ refitBrushGhost(ink, stroke.halfPixel);
1993
+ }
1994
+ } else {
1995
+ stroke.lastPoint = point;
1996
+ refitBrushPrimitiveGhost(stroke.startPoint, point, stroke.halfPixel, stroke.constrain);
1997
+ }
1998
+ } else if (stroke.tool === 'path-select') {
1999
+ const dx = point.x - stroke.startPoint.x;
2000
+ const dy = point.y - stroke.startPoint.y;
2001
+ if (Math.hypot(dx, dy) > 0.2) stroke.dragged = true;
2002
+ if (stroke.mode === 'marquee') {
2003
+ stroke.lastPoint = point;
2004
+ setShapeMarquee(
2005
+ rectFromPoints(
2006
+ { x: Math.floor(stroke.startPoint.x), y: Math.floor(stroke.startPoint.y) },
2007
+ { x: Math.floor(point.x), y: Math.floor(point.y) },
2008
+ width,
2009
+ height
2010
+ )
2011
+ );
2012
+ } else if (stroke.mode === 'anchors') {
2013
+ liveSprite(
2014
+ withPathAt(
2015
+ stroke.base,
2016
+ stroke.layerIndex,
2017
+ stroke.frameIndex,
2018
+ applyAnchorsDrag(
2019
+ stroke.path,
2020
+ stroke.shapeIndex,
2021
+ stroke.anchors,
2022
+ stroke.starts,
2023
+ dx,
2024
+ dy,
2025
+ !!event?.altKey,
2026
+ pathCellPull
2027
+ )
2028
+ )
2029
+ );
2030
+ } else if (stroke.mode === 'shapes') {
2031
+ liveSprite(
2032
+ withPathAt(
2033
+ stroke.base,
2034
+ stroke.layerIndex,
2035
+ stroke.frameIndex,
2036
+ applyShapesDrag(
2037
+ stroke.path,
2038
+ stroke.shapeIndices,
2039
+ stroke.pointStarts,
2040
+ dx,
2041
+ dy,
2042
+ !!event?.altKey,
2043
+ pathCellPull
2044
+ )
2045
+ )
2046
+ );
2047
+ }
2048
+ } else if (stroke.tool === 'bend') {
2049
+ updateBendStroke(point);
2050
+ }
2051
+ }
2052
+ function updateBendStroke(point) {
2053
+ const stroke = strokeRef.current;
2054
+ if (!stroke || stroke.tool !== 'bend') return;
2055
+ const nextPath = applyEdgeBow(
2056
+ stroke.path,
2057
+ stroke.edge.shapeIndex,
2058
+ stroke.edge.subIndex,
2059
+ stroke.edge.edgeIndex,
2060
+ point.x,
2061
+ point.y
2062
+ );
2063
+ if (nextPath) {
2064
+ stroke.currentPath = nextPath;
2065
+ liveSprite(withPathAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextPath));
572
2066
  }
573
2067
  }
574
- function finishTool() {
2068
+ function finishTool(event) {
575
2069
  const stroke = strokeRef.current;
2070
+ if (isPathBrushStroke(stroke)) {
2071
+ // Honor modifiers at release — stroke.constrain is only updated on move, so
2072
+ // releasing Shift before mouse-up would otherwise commit a loose oval.
2073
+ if (event && stroke.brushMode !== 'freehand') {
2074
+ applyBrushModifiers(stroke, event);
2075
+ }
2076
+ commitBrushGesture(stroke.lastPoint);
2077
+ return;
2078
+ }
576
2079
  if (stroke && stroke.tool === 'select') {
577
2080
  if (stroke.mode === 'new' && samePoint(stroke.startPoint, stroke.lastPoint)) {
578
2081
  // A click with no drag deselects.
@@ -583,7 +2086,71 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
583
2086
  // ended fully off-canvas.
584
2087
  setMarquee((rect) => clampRectToCanvas(rect, width, height));
585
2088
  }
2089
+ } else if (stroke && stroke.tool === 'path-select') {
2090
+ if (stroke.mode === 'marquee') {
2091
+ setShapeMarquee(null);
2092
+ if (stroke.dragged) {
2093
+ const rect = rectFromPoints(
2094
+ { x: Math.floor(stroke.startPoint.x), y: Math.floor(stroke.startPoint.y) },
2095
+ {
2096
+ x: Math.floor((stroke.lastPoint ?? stroke.startPoint).x),
2097
+ y: Math.floor((stroke.lastPoint ?? stroke.startPoint).y),
2098
+ },
2099
+ width,
2100
+ height
2101
+ );
2102
+ if (stroke.target === 'anchors') {
2103
+ const hits = hitTestAnchorsInRect(activePath, stroke.shapeIndex, rect);
2104
+ if (stroke.additive) {
2105
+ const merged = new Map();
2106
+ for (const anchor of [...stroke.originAnchors, ...hits]) {
2107
+ merged.set(anchorKey(anchor), anchor);
2108
+ }
2109
+ setSelectedAnchors([...merged.values()]);
2110
+ } else {
2111
+ setSelectedAnchors(hits);
2112
+ }
2113
+ } else {
2114
+ const hits = hitTestShapesInRect(activePath, rect);
2115
+ if (stroke.additive) {
2116
+ const merged = new Set([...stroke.originSelection, ...hits]);
2117
+ setSelectedShapeIndices([...merged].sort((a, b) => a - b));
2118
+ } else {
2119
+ setSelectedShapeIndices(hits);
2120
+ }
2121
+ setSelectedAnchors([]);
2122
+ }
2123
+ }
2124
+ // Click on empty with no drag: selection already cleared on pointer-down
2125
+ // (additive click-without-drag leaves originSelection alone).
2126
+ } else if (stroke.mode === 'shapes' && stroke.pendingCollapse != null && !stroke.dragged) {
2127
+ setSelectedShapeIndices([stroke.pendingCollapse]);
2128
+ setSelectedAnchors([]);
2129
+ } else if (stroke.mode === 'shapes' && stroke.pendingEdge && !stroke.dragged) {
2130
+ const shapeIndex = stroke.shapeIndices[0];
2131
+ const { ref, double, additive } = stroke.pendingEdge;
2132
+ const picked = double
2133
+ ? edgesOfSubpath(activePath, shapeIndex, ref.subIndex)
2134
+ : [ref];
2135
+ setSelectedEdges((prev) => {
2136
+ const keep = additive && prev?.shapeIndex === shapeIndex ? prev.edges : [];
2137
+ const merged = new Map(keep.map((e) => [edgeKey(e), e]));
2138
+ // Shift-clicking an edge that is already in the pick takes it back out.
2139
+ for (const e of picked) {
2140
+ const id = edgeKey(e);
2141
+ if (additive && !double && merged.has(id)) merged.delete(id);
2142
+ else merged.set(id, e);
2143
+ }
2144
+ return merged.size ? { shapeIndex, edges: [...merged.values()] } : null;
2145
+ });
2146
+ setSelectedAnchors([]);
2147
+ } else if (stroke.mode === 'anchors' && stroke.pendingCollapse != null && !stroke.dragged) {
2148
+ setSelectedAnchors([stroke.pendingCollapse]);
2149
+ }
586
2150
  }
2151
+ // After the selection above has resolved against the path as dragged, so a
2152
+ // subpath absorbed here cannot shift the indices that resolution just used.
2153
+ settleDraggedPath(stroke);
587
2154
  strokeRef.current = null;
588
2155
  }
589
2156
 
@@ -596,6 +2163,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
596
2163
  playing,
597
2164
  setOnion,
598
2165
  });
2166
+ function selectPaletteKey(key) {
2167
+ setActiveKey(key);
2168
+ }
599
2169
 
600
2170
  return (
601
2171
  <PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
@@ -604,13 +2174,13 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
604
2174
  <div className={tl.artboardRegion} style={{ position: 'relative' }}>
605
2175
  <div className={styles.artboardToolColumn}>
606
2176
  <PixelToolStrip
607
- tools={PIXEL_TOOLS}
2177
+ tools={isPathLayer ? PATH_TOOLS : PIXEL_TOOLS}
608
2178
  tool={tool}
609
2179
  onSelectTool={(id) => {
610
2180
  setPicking(false);
611
2181
  setTool(id);
612
2182
  }}
613
- ariaLabel="Sprite tools"
2183
+ ariaLabel={isPathLayer ? 'Path tools' : 'Sprite tools'}
614
2184
  />
615
2185
  {/* Undo/redo in their own band below the tools (the flow mount shows
616
2186
  no editor header, so this is the on-screen affordance here). */}
@@ -644,9 +2214,10 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
644
2214
  height={height}
645
2215
  onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
646
2216
  />
647
- <CornerRadiusBar
648
- radius={sprite.cornerRadius}
649
- onChange={(radius) => history.commit(serializeModel(setCornerRadius(sprite, radius)))}
2217
+ <FinishBar
2218
+ renderer={sprite.renderer}
2219
+ cornerRadius={sprite.cornerRadius}
2220
+ onChange={(finish) => history.commit(serializeModel(setFinish(sprite, finish)))}
650
2221
  />
651
2222
  </div>
652
2223
  </div>
@@ -662,7 +2233,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
662
2233
  canvasRef={canvasRef}
663
2234
  smoothRef={smoothRef}
664
2235
  overlayRef={overlayRef}
665
- style={{ ...artboardCanvasStyle, cursor }}
2236
+ smoothPreview={displayFinish(sprite).smooth}
2237
+ style={{
2238
+ ...artboardCanvasStyle,
2239
+ ...latticeBackground(isPathLayer, artboardCanvasStyle, width, height),
2240
+ cursor,
2241
+ }}
666
2242
  handlers={{
667
2243
  onPointerDown: startTool,
668
2244
  onPointerMove: handlePointerMove,
@@ -682,11 +2258,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
682
2258
  paletteOpen={paletteOpen}
683
2259
  onTogglePalette={togglePalette}
684
2260
  colorButtonRef={colorButtonRef}
685
- onSelectKey={setActiveKey}
2261
+ onSelectKey={selectPaletteKey}
686
2262
  paletteKeys={EDITOR_KEYS}
687
2263
  palette={EDITOR_PALETTE}
688
2264
  moveActions={moveActions}
689
2265
  selectActions={selectActions}
2266
+ pathActions={isPathLayer ? pathActions : null}
690
2267
  />
691
2268
  </div>
692
2269
  <div style={PXART_ZOOM_CONTROLS_STYLE}>
@@ -705,6 +2282,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
705
2282
  </div>
706
2283
  <PxArtTimeline
707
2284
  sprite={sprite}
2285
+ text={text}
708
2286
  selection={selection}
709
2287
  playing={playing}
710
2288
  onion={onion}
@@ -714,11 +2292,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
714
2292
  <PxArtInspectorDock
715
2293
  toolState={toolState}
716
2294
  activeKey={activeKey}
717
- onSelectKey={setActiveKey}
2295
+ onSelectKey={selectPaletteKey}
718
2296
  paletteKeys={EDITOR_KEYS}
719
2297
  palette={EDITOR_PALETTE}
720
2298
  moveActions={moveActions}
721
2299
  selectActions={selectActions}
2300
+ pathActions={isPathLayer ? pathActions : null}
722
2301
  />
723
2302
  </div>
724
2303
  </PxArtShell>
@@ -733,8 +2312,8 @@ function buildActions({ sprite, selection, history, setSelection, setPlaying, pl
733
2312
  const commit = (next) => history.commit(serializeModel(next));
734
2313
  return {
735
2314
  selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
736
- addLayer: (li) => {
737
- commit(addLayer(sprite, li));
2315
+ addLayer: (li, kind = 'pixel') => {
2316
+ commit(addLayer(sprite, li, kind));
738
2317
  setSelection({ layerIndex: li + 1, frameIndex });
739
2318
  },
740
2319
  deleteLayer: (li) => {
@@ -750,6 +2329,7 @@ function buildActions({ sprite, selection, history, setSelection, setPlaying, pl
750
2329
  setSelection({ layerIndex: li + dir, frameIndex });
751
2330
  },
752
2331
  patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
2332
+ setLayerKind: (li, kind) => commit(setLayerKind(sprite, li, kind)),
753
2333
  addFrame: (fi) => {
754
2334
  commit(addFrame(sprite, fi));
755
2335
  setSelection({ layerIndex, frameIndex: fi + 1 });
@@ -822,6 +2402,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
822
2402
  p.activeKey,
823
2403
  p.selection.layerIndex,
824
2404
  p.selection.frameIndex,
2405
+ p.pathBakePreview,
825
2406
  ]);
826
2407
  }
827
2408
 
@@ -836,12 +2417,13 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
836
2417
  // triggers a re-render at the new supersample scale, not just a content
837
2418
  // change. The supersample factor auto-fits to the displayed size (capped
838
2419
  // between 4x and 16x).
839
- function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
2420
+ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth, bakePreview) {
840
2421
  const cornerRadius = sprite?.cornerRadius ?? 0;
2422
+ const vector = isVectorRenderer(sprite?.renderer);
841
2423
  useEffect(() => {
842
2424
  const canvas = smoothRef.current;
843
2425
  if (!canvas) return;
844
- if (!sprite || cornerRadius <= 0) {
2426
+ if (!sprite || (!vector && cornerRadius === 0)) {
845
2427
  canvas.width = 0;
846
2428
  canvas.height = 0;
847
2429
  return;
@@ -849,20 +2431,42 @@ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidt
849
2431
  const cssWidth = canvas.clientWidth || sprite.resolution.width;
850
2432
  const dpr = window.devicePixelRatio || 1;
851
2433
  const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
852
- renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
853
- }, [text, frameIndex, smoothRef, cornerRadius, displayWidth]);
2434
+ if (vector) {
2435
+ renderSmoothCompositeFrame(sprite, frameIndex, canvas, { scale: autoScale });
2436
+ } else {
2437
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
2438
+ }
2439
+ }, [text, frameIndex, smoothRef, cornerRadius, vector, displayWidth, bakePreview]);
2440
+ }
2441
+
2442
+ // Snap-lattice dot field for path layers — always on the artboard frame so it
2443
+ // stays consistent across Pixel / ¼ / ½ / Smooth (shows through transparent
2444
+ // canvas areas; the inline background overrides the frame checkerboard).
2445
+ function latticeBackground(isPathLayer, canvasStyle, gridWidth, gridHeight) {
2446
+ if (!isPathLayer || !canvasStyle) return {};
2447
+ const pitchX = parseFloat(canvasStyle.width) / gridWidth;
2448
+ const pitchY = parseFloat(canvasStyle.height) / gridHeight;
2449
+ if (!Number.isFinite(pitchX) || !Number.isFinite(pitchY)) return {};
2450
+ // Dots centered on cell centers (half-cell offset from the corner lattice).
2451
+ const dots =
2452
+ Math.min(pitchX, pitchY) >= 5
2453
+ ? `radial-gradient(circle, rgba(255, 255, 255, 0.28) 1px, transparent 1.6px) ` +
2454
+ `0 0 / ${pitchX}px ${pitchY}px, `
2455
+ : '';
2456
+ return { background: `${dots}#121212` };
854
2457
  }
855
2458
 
856
2459
  // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
857
2460
  function useToolShortcuts(ctx) {
858
- const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
2461
+ const { tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer } =
2462
+ ctx;
859
2463
  useEffect(() => {
860
2464
  function onKeyDown(event) {
861
2465
  if (handleToolShortcut(event, ctx)) event.preventDefault();
862
2466
  }
863
2467
  window.addEventListener('keydown', onKeyDown);
864
2468
  return () => window.removeEventListener('keydown', onKeyDown);
865
- }, [tool, picking, setTool, setPicking, setBrushSize, setEraseSize]);
2469
+ }, [tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer]);
866
2470
  }
867
2471
 
868
2472
  function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
@@ -875,27 +2479,26 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
875
2479
  }
876
2480
  }
877
2481
  ctx.globalAlpha = 1;
878
- // "Smooth" sprites are drawn by the display-resolution .drawingSmooth canvas
879
- // BEHIND this one (see useSmoothPreviewRender); leaving this canvas
880
- // transparent here lets that show through while pointer math, the marquee,
881
- // and the tool-preview ghost below all keep working in native grid units.
882
- if (sprite.cornerRadius <= 0) {
2482
+ // Any non-Pixel finish is drawn by the display-resolution .drawingSmooth
2483
+ // canvas BEHIND this one (see useSmoothPreviewRender); leaving this canvas
2484
+ // transparent lets that show through while pointer math, the marquee, and the
2485
+ // tool-preview ghost below all keep working in native grid units.
2486
+ const { smooth } = displayFinish(sprite);
2487
+ if (!smooth) {
883
2488
  ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
884
2489
  }
885
- if (preview) drawToolPreview(ctx, canvas, preview);
2490
+ // Smooth-finish previews render on the display-resolution overlay so they
2491
+ // match the supersampled corner kernel instead of blurring on upscale.
2492
+ if (preview && !smooth) drawToolPreview(ctx, canvas, preview);
886
2493
  }
887
2494
 
888
- // Render-only animated marching ants for the marquee selection, drawn on a
889
- // display-resolution overlay canvas (1 device pixel per CSS pixel) so the dashed
890
- // outline is crisp and bold rather than sub-pixel on the upscaled artboard.
891
- // `rect` is in canvas cells; null clears the overlay.
892
- function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
893
- if (!canvas || !resolution) return;
2495
+ function setupOverlayCanvas(canvas, resolution) {
2496
+ if (!canvas || !resolution) return null;
894
2497
  const ctx = canvas.getContext('2d');
895
- if (!ctx) return;
2498
+ if (!ctx) return null;
896
2499
  const cssW = canvas.clientWidth;
897
2500
  const cssH = canvas.clientHeight;
898
- if (!cssW || !cssH) return;
2501
+ if (!cssW || !cssH) return null;
899
2502
  const dpr = window.devicePixelRatio || 1;
900
2503
  const bw = Math.round(cssW * dpr);
901
2504
  const bh = Math.round(cssH * dpr);
@@ -903,21 +2506,36 @@ function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
903
2506
  if (canvas.height !== bh) canvas.height = bh;
904
2507
  ctx.setTransform(1, 0, 0, 1, 0, 0);
905
2508
  ctx.clearRect(0, 0, bw, bh);
906
- if (!rect) return;
2509
+ return {
2510
+ ctx,
2511
+ dpr,
2512
+ sx: bw / resolution.width,
2513
+ sy: bh / resolution.height,
2514
+ };
2515
+ }
2516
+
2517
+ // Display-resolution overlay for pixel layers: smooth tool preview + marquee.
2518
+ function renderPixelOverlay(canvas, resolution, { marquee, antsOffset, preview, cornerRadius }) {
2519
+ const overlay = setupOverlayCanvas(canvas, resolution);
2520
+ if (!overlay) return;
2521
+ const { ctx, dpr, sx, sy } = overlay;
2522
+ const scale = (sx + sy) / 2;
2523
+
2524
+ if (preview && cornerRadius !== 0) {
2525
+ drawSmoothToolPreview(ctx, preview, { scale, sx, sy, dpr, cornerRadius });
2526
+ }
2527
+
2528
+ if (!marquee) return;
907
2529
 
908
- const sx = bw / resolution.width;
909
- const sy = bh / resolution.height;
910
2530
  const lineWidth = Math.max(2, Math.round(2 * dpr));
911
2531
  const dash = Math.max(5, Math.round(5 * dpr));
912
- // Advance ~2 device px per tick (not a whole dash) so the ants crawl smoothly
913
- // instead of teleporting a full dash each frame.
914
2532
  const step = Math.max(1, Math.round(dpr));
915
2533
  const move = (antsOffset * step) % (dash * 2);
916
2534
  const half = lineWidth / 2;
917
- const x = rect.x * sx + half;
918
- const y = rect.y * sy + half;
919
- const w = Math.max(lineWidth, rect.w * sx - lineWidth);
920
- const h = Math.max(lineWidth, rect.h * sy - lineWidth);
2535
+ const x = marquee.x * sx + half;
2536
+ const y = marquee.y * sy + half;
2537
+ const w = Math.max(lineWidth, marquee.w * sx - lineWidth);
2538
+ const h = Math.max(lineWidth, marquee.h * sy - lineWidth);
921
2539
 
922
2540
  ctx.lineWidth = lineWidth;
923
2541
  ctx.setLineDash([dash, dash]);
@@ -994,7 +2612,21 @@ function onionTint(src, color) {
994
2612
 
995
2613
  // Single-letter tool picks. The eyedropper ('i') is handled separately because
996
2614
  // it toggles the transient picker rather than swapping the sidebar tool.
997
- const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', s: 'shape', m: 'select', e: 'erase', v: 'move-all' };
2615
+ // Path layers: V select, B brush/freehand, P/L/R/O brush sub-modes, N bend, G fill.
2616
+ const PIXEL_SHORTCUT_TOOLS = {
2617
+ b: 'brush',
2618
+ g: 'fill',
2619
+ s: 'shape',
2620
+ m: 'select',
2621
+ e: 'erase',
2622
+ v: 'move-all',
2623
+ };
2624
+ const PATH_SHORTCUT_TOOLS = {
2625
+ v: 'path-select',
2626
+ b: 'brush',
2627
+ n: 'bend',
2628
+ g: 'fill',
2629
+ };
998
2630
 
999
2631
  // True when focus is on a text-entry control, so typing there isn't hijacked.
1000
2632
  function isTypingTarget(node) {
@@ -1014,7 +2646,7 @@ function stepBrushSize(current, delta) {
1014
2646
  // Resize the active tool with [ / ]. Only brush/erase have a size; other tools
1015
2647
  // make this a no-op (returns false so the caller leaves the key alone).
1016
2648
  function adjustToolSize(delta, ctx) {
1017
- if (ctx.tool === 'brush') {
2649
+ if (ctx.tool === 'brush' && !ctx.isPathLayer) {
1018
2650
  ctx.setBrushSize((prev) => stepBrushSize(prev, delta));
1019
2651
  return true;
1020
2652
  }
@@ -1031,7 +2663,25 @@ function handleToolShortcut(event, ctx) {
1031
2663
  if (event.metaKey || event.ctrlKey || event.altKey) return false;
1032
2664
  if (isTypingTarget(event.target || document.activeElement)) return false;
1033
2665
  const key = event.key.toLowerCase();
1034
- const toolId = SHORTCUT_TOOLS[key];
2666
+ if (ctx.isPathLayer) {
2667
+ const brushMode = PATH_BRUSH_MODE_BY_KEY[key];
2668
+ if (brushMode) {
2669
+ ctx.setPicking(false);
2670
+ ctx.setTool('brush');
2671
+ ctx.setBrushMode?.(brushMode);
2672
+ return true;
2673
+ }
2674
+ if (key === 'b') {
2675
+ ctx.setPicking(false);
2676
+ if (ctx.tool === 'brush' && ctx.brushMode !== 'freehand') {
2677
+ ctx.setBrushMode?.('freehand');
2678
+ return true;
2679
+ }
2680
+ ctx.setTool('brush');
2681
+ return true;
2682
+ }
2683
+ }
2684
+ const toolId = (ctx.isPathLayer ? PATH_SHORTCUT_TOOLS : PIXEL_SHORTCUT_TOOLS)[key];
1035
2685
  if (toolId) {
1036
2686
  ctx.setPicking(false);
1037
2687
  ctx.setTool(toolId);
@@ -1138,6 +2788,85 @@ function fillCellList(ctx, cells, color, alpha) {
1138
2788
  for (const cell of cells) ctx.fillRect(cell.x, cell.y, 1, 1);
1139
2789
  }
1140
2790
 
2791
+ function dabFootprintCells(cell, size) {
2792
+ const cells = [];
2793
+ forEachDab(size, (dx, dy) => cells.push({ x: cell.x + dx, y: cell.y + dy }));
2794
+ return cells;
2795
+ }
2796
+
2797
+ function dabFootprintBounds(cell, size) {
2798
+ let minX = Infinity;
2799
+ let minY = Infinity;
2800
+ let maxX = -Infinity;
2801
+ let maxY = -Infinity;
2802
+ forEachDab(size, (dx, dy) => {
2803
+ minX = Math.min(minX, dx);
2804
+ minY = Math.min(minY, dy);
2805
+ maxX = Math.max(maxX, dx);
2806
+ maxY = Math.max(maxY, dy);
2807
+ });
2808
+ if (minX === Infinity) return null;
2809
+ return { minX, minY, maxX, maxY };
2810
+ }
2811
+
2812
+ // Smooth-finish tool ghost at display resolution (overlay canvas).
2813
+ function drawSmoothToolPreview(ctx, preview, { scale, sx, sy, dpr, cornerRadius }) {
2814
+ const lineWidth = Math.max(2, Math.round(2 * dpr));
2815
+ const half = lineWidth / 2;
2816
+
2817
+ if (preview.kind === 'footprint') {
2818
+ renderSmoothCellsPreview(ctx, dabFootprintCells(preview.cell, preview.size), preview.color, {
2819
+ scale,
2820
+ cornerRadius,
2821
+ alpha: PREVIEW_PAINT_ALPHA,
2822
+ });
2823
+ } else if (preview.kind === 'erase') {
2824
+ renderSmoothCellsPreview(
2825
+ ctx,
2826
+ dabFootprintCells(preview.cell, preview.size),
2827
+ '#ffffff',
2828
+ { scale, cornerRadius, alpha: PREVIEW_WASH_ALPHA },
2829
+ );
2830
+ const bounds = dabFootprintBounds(preview.cell, preview.size);
2831
+ if (bounds) {
2832
+ ctx.save();
2833
+ ctx.globalAlpha = 0.85;
2834
+ ctx.strokeStyle = '#1c1c1c';
2835
+ ctx.lineWidth = lineWidth;
2836
+ ctx.strokeRect(
2837
+ (preview.cell.x + bounds.minX) * sx + half,
2838
+ (preview.cell.y + bounds.minY) * sy + half,
2839
+ (bounds.maxX - bounds.minX + 1) * sx - lineWidth,
2840
+ (bounds.maxY - bounds.minY + 1) * sy - lineWidth,
2841
+ );
2842
+ ctx.restore();
2843
+ }
2844
+ } else if (preview.kind === 'region') {
2845
+ renderSmoothCellsPreview(ctx, preview.cells, preview.color, {
2846
+ scale,
2847
+ cornerRadius,
2848
+ alpha: PREVIEW_HIGHLIGHT_ALPHA,
2849
+ });
2850
+ } else if (preview.kind === 'cell') {
2851
+ renderSmoothCellsPreview(ctx, [preview.cell], '#ffffff', {
2852
+ scale,
2853
+ cornerRadius,
2854
+ alpha: PREVIEW_HIGHLIGHT_ALPHA,
2855
+ });
2856
+ ctx.save();
2857
+ ctx.globalAlpha = 0.9;
2858
+ ctx.strokeStyle = '#1c1c1c';
2859
+ ctx.lineWidth = lineWidth;
2860
+ ctx.strokeRect(
2861
+ preview.cell.x * sx + half,
2862
+ preview.cell.y * sy + half,
2863
+ sx - lineWidth,
2864
+ sy - lineWidth,
2865
+ );
2866
+ ctx.restore();
2867
+ }
2868
+ }
2869
+
1141
2870
  // Render-only: paint the ghost overlay for the current tool onto the artboard
1142
2871
  // after the sprite. Never mutates the sprite, history, or serialized output.
1143
2872
  function drawToolPreview(ctx, canvas, preview) {