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
@@ -6,6 +6,8 @@ import {
6
6
  forEachCelPixel,
7
7
  fromCells,
8
8
  frameCount,
9
+ GRID_RENDERER,
10
+ isVectorRenderer,
9
11
  paletteLookup,
10
12
  parseFull,
11
13
  RESOLUTION_MAX,
@@ -15,6 +17,7 @@ import {
15
17
  serializeFull,
16
18
  snapResolutionDim,
17
19
  TRANSPARENT,
20
+ VECTOR_RENDERER,
18
21
  } from '../engine/pxart';
19
22
  import {
20
23
  blankCells,
@@ -22,6 +25,13 @@ import {
22
25
  EDITOR_PALETTE,
23
26
  editorKeyForHex,
24
27
  } from './pxArtTools';
28
+ import {
29
+ bakePathCell,
30
+ clonePathData,
31
+ emptyPathData,
32
+ isEmptyPathData,
33
+ translatePathData,
34
+ } from '../engine/pxartPath';
25
35
 
26
36
  // ============================================================================
27
37
  // Working-state helpers for the full Sprite the PxArt editor edits in memory.
@@ -78,17 +88,23 @@ function blankSprite() {
78
88
  defaultDurationMs: DEFAULT_DURATION_MS,
79
89
  tags: [],
80
90
  defaultTag: undefined,
91
+ renderer: GRID_RENDERER,
81
92
  cornerRadius: 0,
82
93
  layers: [makeLayer('layer-0', 'Layer 1', [null])],
83
94
  };
84
95
  }
85
96
 
86
- // Set the file-level corner-rounding radius (0 = sharp/pixel). A no-op if
87
- // it's already that value (keeps identical sprites from producing spurious
88
- // history entries).
89
- export function setCornerRadius(sprite, radius) {
90
- const clamped = clampCornerRadius(radius);
91
- return sprite.cornerRadius === clamped ? sprite : { ...sprite, cornerRadius: clamped };
97
+ // Set the file-level finish: which renderer draws the sprite, and how much
98
+ // corner rounding the grid renderer applies. Either may be omitted to keep its
99
+ // current value. A no-op when nothing changes, so identical sprites don't
100
+ // produce spurious history entries.
101
+ export function setFinish(sprite, { renderer, cornerRadius } = {}) {
102
+ const nextRenderer = isVectorRenderer(renderer ?? sprite.renderer)
103
+ ? VECTOR_RENDERER
104
+ : GRID_RENDERER;
105
+ const nextRadius = clampCornerRadius(cornerRadius ?? sprite.cornerRadius);
106
+ if (sprite.renderer === nextRenderer && sprite.cornerRadius === nextRadius) return sprite;
107
+ return { ...sprite, renderer: nextRenderer, cornerRadius: nextRadius };
92
108
  }
93
109
 
94
110
  function makeLayer(id, name, cells) {
@@ -119,7 +135,22 @@ function remapCell(cell, lookup) {
119
135
  if (cell == null) return null;
120
136
  if ('link' in cell) return { link: cell.link };
121
137
  // Preserve the cel's offset while remapping its palette keys.
122
- return gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
138
+ const next = gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
139
+ return cell.path ? { ...next, path: remapPathKeys(cell.path, lookup) } : next;
140
+ }
141
+
142
+ function remapPathKeys(path, lookup) {
143
+ const next = clonePathData(path);
144
+ for (const shape of next.shapes) {
145
+ if (shape.fill) shape.fill = remapKey(shape.fill, lookup);
146
+ if (shape.stroke) shape.stroke = remapKey(shape.stroke, lookup);
147
+ }
148
+ return next;
149
+ }
150
+
151
+ function remapKey(key, lookup) {
152
+ const hex = colorForKeyV2(lookup, key);
153
+ return hex ? editorKeyForHex(hex) : key;
123
154
  }
124
155
 
125
156
  function remapRow(row, lookup) {
@@ -168,6 +199,13 @@ export function withCellsAt(sprite, layerIndex, frameIndex, cells) {
168
199
  const layer = sprite.layers[layerIndex];
169
200
  const target = writeTargetIndex(layer, frameIndex);
170
201
  const prev = layer ? resolveCell(layer, target) : null;
202
+ // A path cel's grid is derived — it is whatever bakePathCell says. Writing
203
+ // pixels straight into it would either desync the grid from the source (the
204
+ // next path edit silently reverts the paint) or drop the source to keep them
205
+ // in step (silently destroying the vector art). The pixel tools are already
206
+ // gated off path layers in the UI, so this is unreachable today; it refuses
207
+ // rather than corrupting if a shared tool is ever wired up.
208
+ if (prev?.path) return sprite;
171
209
  const merged = mergeWindowIntoCell(prev, cells, sprite.resolution);
172
210
  return updateLayerCell(sprite, layerIndex, target, merged);
173
211
  }
@@ -259,6 +297,21 @@ export function setCellOffset(sprite, layerIndex, frameIndex, x, y) {
259
297
  const cell = layer.cells[target];
260
298
  if (cell == null || 'link' in cell) return sprite;
261
299
  if ((cell.x ?? 0) === x && (cell.y ?? 0) === y) return sprite;
300
+ if (cell.path) {
301
+ // A path cel has no offset to carry (§6.1.1): moving it means moving the
302
+ // source. Callers pass an ABSOLUTE offset computed against a gesture-start
303
+ // snapshot, and a path cel's offset is always 0, so the requested offset IS
304
+ // the delta. Snapping it keeps the translated points on the lattice even if
305
+ // the caller hands us a fractional drag.
306
+ const dx = x - (cell.x ?? 0);
307
+ const dy = y - (cell.y ?? 0);
308
+ const path = translatePathData(cell.path, dx, dy);
309
+ const { width, height } = sprite.resolution;
310
+ return updateLayerCell(sprite, layerIndex, target, {
311
+ grid: bakePathCell(path, width, height),
312
+ path,
313
+ });
314
+ }
262
315
  return updateLayerCell(sprite, layerIndex, target, gridCell(cell.grid, x, y));
263
316
  }
264
317
 
@@ -295,6 +348,59 @@ export function cellKind(layer, frameIndex) {
295
348
  return 'grid';
296
349
  }
297
350
 
351
+ // ---------------------------------------------------------------------------
352
+ // path layers
353
+ // ---------------------------------------------------------------------------
354
+
355
+ export function pathAt(sprite, layerIndex, frameIndex) {
356
+ const layer = sprite.layers[layerIndex];
357
+ const resolved = layer ? resolveCell(layer, frameIndex) : null;
358
+ return resolved?.path ?? emptyPathData();
359
+ }
360
+
361
+ // Replace vector source and its baked pixel-grid atomically.
362
+ export function withPathAt(sprite, layerIndex, frameIndex, path) {
363
+ const layer = sprite.layers[layerIndex];
364
+ const target = writeTargetIndex(layer, frameIndex);
365
+ const prev = layer ? resolveCell(layer, target) : null;
366
+ const { width, height } = sprite.resolution;
367
+ const committed = clonePathData(path ?? emptyPathData());
368
+ const grid = bakePathCell(committed, width, height);
369
+ // The bake is canvas-framed, so the cel it produces sits at the origin — it
370
+ // does NOT inherit `prev`'s offset (§6.1.1). Inheriting would double-count the
371
+ // placement, since the points already carry it.
372
+ const cell = { grid };
373
+ const withPath = isEmptyPathData(committed) ? cell : { ...cell, path: committed };
374
+ return updateLayerCell(sprite, layerIndex, target, withPath);
375
+ }
376
+
377
+ // Convert layer kind. Path conversion creates an EMPTY path source (no
378
+ // vectorization of existing pixels) and rebakes a transparent grid. Pixel
379
+ // conversion strips the vector source and keeps the baked grid as truth.
380
+ export function setLayerKind(sprite, layerIndex, kind) {
381
+ const nextKind = kind === 'path' ? 'path' : 'pixel';
382
+ let next = patchLayer(sprite, layerIndex, { kind: nextKind });
383
+ const layer = next.layers[layerIndex];
384
+ if (!layer) return next;
385
+ if (nextKind === 'path') {
386
+ const { width, height } = next.resolution;
387
+ const grid = bakePathCell(emptyPathData(), width, height);
388
+ const cells = layer.cells.map((cell) => {
389
+ if (cell && 'link' in cell) return cell;
390
+ const prev = cell && !('link' in cell) ? cell : null;
391
+ // Empty path source — omit `path` (same as withPathAt for empty).
392
+ return gridCell(grid, 0, 0);
393
+ });
394
+ return patchLayer(next, layerIndex, { cells });
395
+ }
396
+ const cells = layer.cells.map((cell) => {
397
+ if (!cell || 'link' in cell || !cell.path) return cell;
398
+ const { path: _path, ...rest } = cell;
399
+ return rest;
400
+ });
401
+ return patchLayer(next, layerIndex, { cells });
402
+ }
403
+
298
404
  // ---------------------------------------------------------------------------
299
405
  // linked cells
300
406
  // ---------------------------------------------------------------------------
@@ -325,7 +431,8 @@ export function unlinkCell(sprite, layerIndex, frameIndex) {
325
431
  const layer = sprite.layers[layerIndex];
326
432
  const resolved = layer ? resolveCell(layer, frameIndex) : null;
327
433
  // Copy the shared image AND its offset so the detached cel looks identical.
328
- const cell = resolved ? gridCell(resolved.grid.slice(), resolved.x, resolved.y) : null;
434
+ const grid = resolved ? gridCell(resolved.grid.slice(), resolved.x, resolved.y) : null;
435
+ const cell = grid && resolved.path ? { ...grid, path: clonePathData(resolved.path) } : grid;
329
436
  return updateLayerCell(sprite, layerIndex, frameIndex, cell);
330
437
  }
331
438
 
@@ -342,15 +449,40 @@ function uniqueLayerId(sprite) {
342
449
  }
343
450
 
344
451
  // Insert a fresh empty layer directly ABOVE the active one (Aseprite stacks new
345
- // layers on top; layer 0 is the bottom of the stack).
346
- export function addLayer(sprite, layerIndex) {
347
- const cells = sprite.frames.map(() => null);
452
+ // layers on top; layer 0 is the bottom of the stack). `kind` is 'pixel' (default)
453
+ // or 'path' a new path layer gets an empty path source and a transparent baked
454
+ // grid per frame (same empty-path treatment as setLayerKind → path).
455
+ export function addLayer(sprite, layerIndex, kind = 'pixel') {
456
+ const nextKind = kind === 'path' ? 'path' : 'pixel';
457
+ let cells = sprite.frames.map(() => null);
458
+ if (nextKind === 'path') {
459
+ const { width, height } = sprite.resolution;
460
+ const grid = bakePathCell(emptyPathData(), width, height);
461
+ cells = sprite.frames.map(() => gridCell(grid, 0, 0));
462
+ }
348
463
  const layer = makeLayer(uniqueLayerId(sprite), `Layer ${sprite.layers.length + 1}`, cells);
464
+ layer.kind = nextKind;
349
465
  const layers = sprite.layers.slice();
350
466
  layers.splice(layerIndex + 1, 0, layer);
351
467
  return { ...sprite, layers };
352
468
  }
353
469
 
470
+ /** True when any frame of the layer has a non-transparent baked pixel. */
471
+ export function layerHasOpaquePixels(sprite, layerIndex) {
472
+ const layer = sprite.layers[layerIndex];
473
+ if (!layer) return false;
474
+ for (let f = 0; f < layer.cells.length; f++) {
475
+ const resolved = resolveCell(layer, f);
476
+ if (!resolved?.grid) continue;
477
+ for (const row of resolved.grid) {
478
+ for (const ch of row) {
479
+ if (ch !== TRANSPARENT) return true;
480
+ }
481
+ }
482
+ }
483
+ return false;
484
+ }
485
+
354
486
  export function deleteLayer(sprite, layerIndex) {
355
487
  if (sprite.layers.length <= 1) return sprite;
356
488
  const layers = sprite.layers.filter((_, i) => i !== layerIndex);
@@ -363,6 +495,7 @@ export function duplicateLayer(sprite, layerIndex) {
363
495
  const copy = makeLayer(uniqueLayerId(sprite), `${src.name} copy`, src.cells.map(cloneCell));
364
496
  copy.visible = src.visible;
365
497
  copy.opacity = src.opacity;
498
+ copy.kind = src.kind;
366
499
  const layers = sprite.layers.slice();
367
500
  layers.splice(layerIndex + 1, 0, copy);
368
501
  return { ...sprite, layers };
@@ -385,7 +518,8 @@ export function patchLayer(sprite, layerIndex, patch) {
385
518
  function cloneCell(cell) {
386
519
  if (cell == null) return null;
387
520
  if ('link' in cell) return { link: cell.link };
388
- return gridCell(cell.grid.slice(), cell.x ?? 0, cell.y ?? 0);
521
+ const next = gridCell(cell.grid.slice(), cell.x ?? 0, cell.y ?? 0);
522
+ return cell.path ? { ...next, path: clonePathData(cell.path) } : next;
389
523
  }
390
524
 
391
525
  // ---------------------------------------------------------------------------
@@ -410,6 +544,7 @@ function layerSlots(layer) {
410
544
  image: resolved?.grid ?? null,
411
545
  x: resolved?.x ?? 0,
412
546
  y: resolved?.y ?? 0,
547
+ path: resolved?.path,
413
548
  wasLink: cell != null && 'link' in cell,
414
549
  };
415
550
  });
@@ -438,11 +573,16 @@ function relinkSlots(slots) {
438
573
  slot.wasLink &&
439
574
  gridsEqual(slots[i - 1].image, slot.image) &&
440
575
  slots[i - 1].x === slot.x &&
441
- slots[i - 1].y === slot.y
576
+ slots[i - 1].y === slot.y &&
577
+ slots[i - 1].path === slot.path
442
578
  ) {
443
579
  return { link: i - 1 };
444
580
  }
445
- return gridCell(slot.image.slice(), slot.x, slot.y);
581
+ const cell = gridCell(slot.image.slice(), slot.x, slot.y);
582
+ // Slots that resolved through a link chain share one source object, so a
583
+ // deep copy is what makes each materialized cel independent — otherwise two
584
+ // frames that merely LOOKED alike would keep pointing at a single path.
585
+ return slot.path ? { ...cell, path: clonePathData(slot.path) } : cell;
446
586
  });
447
587
  }
448
588
 
@@ -503,6 +643,7 @@ export function addFrame(sprite, frameIndex) {
503
643
  image: resolved?.grid ?? null,
504
644
  x: resolved?.x ?? 0,
505
645
  y: resolved?.y ?? 0,
646
+ path: resolved?.path,
506
647
  wasLink: resolved != null,
507
648
  };
508
649
  });
@@ -521,6 +662,7 @@ export function duplicateFrame(sprite, frameIndex) {
521
662
  image: resolved ? resolved.grid.slice() : null,
522
663
  x: resolved?.x ?? 0,
523
664
  y: resolved?.y ?? 0,
665
+ path: resolved?.path ? clonePathData(resolved.path) : undefined,
524
666
  wasLink: false,
525
667
  };
526
668
  });
@@ -625,13 +767,23 @@ export function resizeSprite(sprite, width, height) {
625
767
  const dy = Math.floor((h - sprite.resolution.height) / 2);
626
768
  const layers = sprite.layers.map((layer) => ({
627
769
  ...layer,
628
- cells: layer.cells.map((cell) => shiftCellOffset(cell, dx, dy)),
770
+ cells: layer.cells.map((cell) => shiftCellOffset(cell, dx, dy, w, h)),
629
771
  }));
630
772
  return { ...sprite, resolution: { width: w, height: h }, layers };
631
773
  }
632
774
 
633
- function shiftCellOffset(cell, dx, dy) {
775
+ // Reframe one cel by the centered delta. A pixel cel moves via its offset; a
776
+ // path cel cannot, because path points are canvas-framed and a path cell's x/y
777
+ // must stay 0 (§6.1.1). Shifting the offset instead would leave the vector
778
+ // source describing the OLD frame, so the grid and the source would disagree —
779
+ // invisible until the next edit re-baked the art back to where the points still
780
+ // said it was. Translating the source and re-baking keeps the two in step.
781
+ function shiftCellOffset(cell, dx, dy, width, height) {
634
782
  if (cell == null || 'link' in cell) return cell;
783
+ if (cell.path) {
784
+ const path = translatePathData(cell.path, dx, dy);
785
+ return { grid: bakePathCell(path, width, height), path };
786
+ }
635
787
  return gridCell(cell.grid, (cell.x ?? 0) + dx, (cell.y ?? 0) + dy);
636
788
  }
637
789
 
@@ -647,6 +799,7 @@ function shiftCellOffset(cell, dx, dy) {
647
799
  export function serializeModel(sprite) {
648
800
  const compact = serializeCompact({
649
801
  ...fromCells(EDITOR_PALETTE, cellsAt(sprite, 0, 0)),
802
+ renderer: sprite.renderer,
650
803
  cornerRadius: sprite.cornerRadius,
651
804
  });
652
805
  const roundTripped = parseFull(compact);
@@ -672,7 +825,10 @@ function losslesslyCompact(sprite, roundTripped) {
672
825
  if (frameCount(sprite) !== frameCount(roundTripped)) return false;
673
826
  if (sprite.tags.length !== roundTripped.tags.length) return false;
674
827
  if ((sprite.defaultTag ?? null) !== (roundTripped.defaultTag ?? null)) return false;
828
+ // Both finish fields, so a compact form that dropped either falls through to
829
+ // the full form instead of silently reverting the sprite's look.
675
830
  if (sprite.cornerRadius !== roundTripped.cornerRadius) return false;
831
+ if ((sprite.renderer ?? GRID_RENDERER) !== (roundTripped.renderer ?? GRID_RENDERER)) return false;
676
832
  if (sprite.layers.length !== roundTripped.layers.length) return false;
677
833
 
678
834
  const lookupA = paletteLookup(sprite.palette);
@@ -1,8 +1,21 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { faEye, faEyeSlash, faLink, faStopwatch } from '@fortawesome/free-solid-svg-icons';
3
- import { colorForKeyV2, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
4
- import { Icon, IconButton, SheetGrabHandle, styles as ui, useCompactViewport, useMobileSheet } from '../engine/ui';
5
- import { cellKind, forEachGridCell, setupSpriteCanvas } from './pxArtEditorModel';
3
+ import { colorForKeyV2, displayFinish, paletteLookup, resolveCell, resolveCellGrid } from '../engine/pxart';
4
+ import { renderSmoothLayerCell } from '../engine/pxartSmooth';
5
+ import {
6
+ ConfirmDialog,
7
+ Icon,
8
+ IconButton,
9
+ SheetGrabHandle,
10
+ styles as ui,
11
+ useCompactViewport,
12
+ useMobileSheet,
13
+ } from '../engine/ui';
14
+ import {
15
+ cellKind,
16
+ forEachGridCell,
17
+ layerHasOpaquePixels,
18
+ } from './pxArtEditorModel';
6
19
  import tl from './pxArtTimeline.module.css';
7
20
 
8
21
  // Tags and per-frame duration are intentionally hidden for now — we plan to
@@ -131,7 +144,7 @@ function useResizableTimelineHeight() {
131
144
  // the top, Aseprite-style) by frame COLUMNS, with thumbnails per cell, layer +
132
145
  // frame controls, linked-cell affordances, onion-skin toggles, playback, and a
133
146
  // tags editor. All mutation flows through `actions` (wired in PxArtEditor).
134
- export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
147
+ export function PxArtTimeline({ sprite, text, selection, playing, onion, actions }) {
135
148
  // Hooks run unconditionally (order-stable); only the render branches on viewport.
136
149
  const compact = useCompactViewport();
137
150
  const resizable = useResizableTimelineHeight();
@@ -144,7 +157,7 @@ export function PxArtTimeline({ sprite, selection, playing, onion, actions }) {
144
157
  });
145
158
  const grid = (
146
159
  <div className={tl.scroll}>
147
- <TimelineGrid sprite={sprite} selection={selection} actions={actions} />
160
+ <TimelineGrid sprite={sprite} text={text} selection={selection} actions={actions} />
148
161
  </div>
149
162
  );
150
163
  if (compact) {
@@ -212,8 +225,16 @@ function TimelineBar({ sprite, selection, playing, onion, actions }) {
212
225
  return (
213
226
  <div className={tl.bar}>
214
227
  <div className={tl.barGroup}>
215
- <IconButton icon="layer-group" label="Layer" onClick={() => actions.addLayer(layerIndex)} />
216
- <span className={tl.barLabel}>Layer</span>
228
+ <IconButton icon="grid" label="Pixel" onClick={() => actions.addLayer(layerIndex)} />
229
+ <span className={tl.barLabel}>Pixel</span>
230
+ </div>
231
+ <div className={tl.barGroup}>
232
+ <IconButton
233
+ icon="draw-polygon"
234
+ label="Path"
235
+ onClick={() => actions.addLayer(layerIndex, 'path')}
236
+ />
237
+ <span className={tl.barLabel}>Path</span>
217
238
  </div>
218
239
  <div className={tl.barGroup}>
219
240
  <IconButton icon="film" label="Frame" onClick={() => actions.addFrame(frameIndex)} />
@@ -299,11 +320,12 @@ function OnionControls({ onion, actions }) {
299
320
  );
300
321
  }
301
322
 
302
- function TimelineGrid({ sprite, selection, actions }) {
323
+ function TimelineGrid({ sprite, text, selection, actions }) {
303
324
  const frames = sprite.frames;
304
325
  // Render rows top-down: the last layer (top of the composite stack) first.
305
326
  const rows = sprite.layers.map((_, i) => i).reverse();
306
327
  const [menu, setMenu] = useState(null);
328
+ const [convertConfirm, setConvertConfirm] = useState(null);
307
329
  const closeMenu = useCallback(() => setMenu(null), []);
308
330
 
309
331
  // Each opener stops the browser's native menu, anchors at the cursor, and
@@ -359,6 +381,7 @@ function TimelineGrid({ sprite, selection, actions }) {
359
381
  <CellThumb
360
382
  key={f}
361
383
  sprite={sprite}
384
+ text={text}
362
385
  layerIndex={li}
363
386
  frameIndex={f}
364
387
  active={li === selection.layerIndex && f === selection.frameIndex}
@@ -372,10 +395,32 @@ function TimelineGrid({ sprite, selection, actions }) {
372
395
  <ContextMenu
373
396
  x={menu.x}
374
397
  y={menu.y}
375
- items={buildMenuItems(menu, sprite, actions)}
398
+ items={buildMenuItems(menu, sprite, actions, {
399
+ requestConvertToPath: (layerIndex) => {
400
+ const layer = sprite.layers[layerIndex];
401
+ const name = layer?.name || `Layer ${layerIndex + 1}`;
402
+ if (layerHasOpaquePixels(sprite, layerIndex)) {
403
+ setConvertConfirm({ layerIndex, name });
404
+ } else {
405
+ actions.setLayerKind(layerIndex, 'path');
406
+ }
407
+ },
408
+ })}
376
409
  onClose={closeMenu}
377
410
  />
378
411
  ) : null}
412
+ {convertConfirm ? (
413
+ <ConfirmDialog
414
+ title={`Convert ${convertConfirm.name} to a path layer?`}
415
+ message={`${convertConfirm.name} has painted pixels. Converting to a path layer clears those pixels (no vectorization). This can't be undone except via Undo.`}
416
+ confirmLabel="Convert"
417
+ onCancel={() => setConvertConfirm(null)}
418
+ onConfirm={() => {
419
+ actions.setLayerKind(convertConfirm.layerIndex, 'path');
420
+ setConvertConfirm(null);
421
+ }}
422
+ />
423
+ ) : null}
379
424
  </div>
380
425
  );
381
426
  }
@@ -441,16 +486,29 @@ function ContextMenu({ x, y, items, onClose }) {
441
486
  );
442
487
  }
443
488
 
444
- function buildMenuItems(menu, sprite, actions) {
445
- if (menu.kind === 'layer') return layerMenuItems(menu, sprite, actions);
489
+ function buildMenuItems(menu, sprite, actions, layerExtras = {}) {
490
+ if (menu.kind === 'layer') return layerMenuItems(menu, sprite, actions, layerExtras);
446
491
  if (menu.kind === 'frame') return frameMenuItems(menu, sprite, actions);
447
492
  return cellMenuItems(menu, sprite, actions);
448
493
  }
449
494
 
450
- function layerMenuItems(menu, sprite, actions) {
495
+ function layerMenuItems(menu, sprite, actions, { requestConvertToPath } = {}) {
451
496
  const { layerIndex } = menu;
497
+ const isPath = sprite.layers[layerIndex]?.kind === 'path';
452
498
  return [
453
499
  { key: 'duplicate', label: 'Duplicate', onClick: () => actions.duplicateLayer(layerIndex) },
500
+ {
501
+ key: 'toggle-kind',
502
+ label: isPath ? 'Convert to Pixel Layer' : 'Convert to Path Layer',
503
+ onClick: () => {
504
+ if (isPath) {
505
+ actions.setLayerKind(layerIndex, 'pixel');
506
+ return;
507
+ }
508
+ if (requestConvertToPath) requestConvertToPath(layerIndex);
509
+ else actions.setLayerKind(layerIndex, 'path');
510
+ },
511
+ },
454
512
  {
455
513
  key: 'up',
456
514
  label: 'Move up',
@@ -546,6 +604,15 @@ function FrameHeader({ frame, index, globalMs, active, onSelect, onDuration, onC
546
604
  }
547
605
 
548
606
  const btnReset = { background: 'none', border: 0, color: 'inherit', cursor: 'pointer', padding: 0 };
607
+ const kindBadgeStyle = {
608
+ flex: '0 0 auto',
609
+ display: 'inline-flex',
610
+ alignItems: 'center',
611
+ justifyContent: 'center',
612
+ color: 'var(--castle-muted, #888)',
613
+ fontSize: 11,
614
+ opacity: 0.85,
615
+ };
549
616
 
550
617
  // A free-form text input that holds a local draft while focused and commits it
551
618
  // on blur/Enter, re-syncing to `text` when idle. The caller's `onCommit(draft)`
@@ -621,6 +688,26 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
621
688
  onClick={onSelect}
622
689
  onContextMenu={(event) => onContextMenu(event, index)}>
623
690
  <div className={tl.layerTopRow}>
691
+ <span
692
+ style={kindBadgeStyle}
693
+ title={layer.kind === 'path' ? 'Path layer' : 'Pixel layer'}>
694
+ <Icon name={layer.kind === 'path' ? 'draw-polygon' : 'grid'} />
695
+ </span>
696
+ <span className={tl.name} title={layer.name}>
697
+ {layer.name}
698
+ </span>
699
+ </div>
700
+ <div className={tl.layerBottomRow}>
701
+ <input
702
+ className={tl.opacity}
703
+ type="range"
704
+ min="0"
705
+ max="100"
706
+ value={Math.round(layer.opacity * 100)}
707
+ title={`Opacity ${Math.round(layer.opacity * 100)}%`}
708
+ onClick={(event) => event.stopPropagation()}
709
+ onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
710
+ />
624
711
  <button
625
712
  type="button"
626
713
  className={layer.visible ? tl.visBtn : tl.visBtn + ' ' + tl.visOff}
@@ -631,31 +718,26 @@ function LayerHeader({ layer, index, active, onSelect, actions, onContextMenu })
631
718
  }}>
632
719
  <FaGlyph icon={layer.visible ? faEye : faEyeSlash} />
633
720
  </button>
634
- <span className={tl.name} title={layer.name}>
635
- {layer.name}
636
- </span>
637
721
  </div>
638
- <input
639
- className={tl.opacity}
640
- type="range"
641
- min="0"
642
- max="100"
643
- value={Math.round(layer.opacity * 100)}
644
- title={`Opacity ${Math.round(layer.opacity * 100)}%`}
645
- onClick={(event) => event.stopPropagation()}
646
- onChange={(event) => actions.patchLayer(index, { opacity: Number(event.target.value) / 100 })}
647
- />
648
722
  </div>
649
723
  );
650
724
  }
651
725
 
652
- function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
726
+ function CellThumb({ sprite, text, layerIndex, frameIndex, active, onSelect, onContextMenu }) {
653
727
  const canvasRef = useRef(null);
654
728
  const layer = sprite.layers[layerIndex];
655
729
  const kind = cellKind(layer, frameIndex);
656
- useEffect(() => {
730
+ const smoothThumb = displayFinish(sprite).smooth;
731
+ // Gated on `text` rather than `sprite`, which deriveSprite rebuilds every
732
+ // render. Without a dependency list every thumb redrew on every timeline
733
+ // render — including on every pointer move over the artboard — and at a
734
+ // rounded finish each redraw runs the corner kernel, so a many-cel sprite
735
+ // burned tens of milliseconds repainting thumbs that had not changed.
736
+ // Content is the only trigger needed: cells are a fixed 52px, so a thumb's
737
+ // size never changes after mount.
738
+ useLayoutEffect(() => {
657
739
  if (canvasRef.current && kind !== 'empty') drawCellThumb(canvasRef.current, sprite, layerIndex, frameIndex);
658
- });
740
+ }, [text, kind, layerIndex, frameIndex]);
659
741
  return (
660
742
  <button
661
743
  type="button"
@@ -667,7 +749,10 @@ function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContext
667
749
  <span className={tl.empty} />
668
750
  ) : (
669
751
  <span className={kind === 'link' ? tl.thumb + ' ' + tl.linkRing : tl.thumb}>
670
- <canvas ref={canvasRef} className={tl.thumbCanvas} />
752
+ <canvas
753
+ ref={canvasRef}
754
+ className={smoothThumb ? tl.thumbCanvasSmooth : tl.thumbCanvas}
755
+ />
671
756
  </span>
672
757
  )}
673
758
  {kind === 'link' ? (
@@ -679,19 +764,46 @@ function CellThumb({ sprite, layerIndex, frameIndex, active, onSelect, onContext
679
764
  );
680
765
  }
681
766
 
682
- // Paint a single (layer, frame) cell's resolved image onto a native-resolution
683
- // canvas (1px/cell), following links. Used for timeline thumbnails. Offset-aware
684
- // and clipping to match renderSpriteFrame: a cel resolves to { grid, x, y } (a
685
- // link shares its source's image AND offset), and a source pixel (gx, gy) lands
686
- // at canvas (gx + x, gy + y), skipped when outside [0, width) x [0, height).
687
- // (Iterating the full grid via Infinity bounds, then clipping per pixel — the
688
- // canvas window, not the source extent, is the clip.)
767
+ // Paint a single (layer, frame) cell onto a display-resolution canvas (crisp at
768
+ // the timeline cell size). Offset-aware clipping matches renderSpriteFrame.
769
+ function setupThumbCanvas(canvas, resolution) {
770
+ const cssW = canvas.clientWidth;
771
+ const cssH = canvas.clientHeight;
772
+ if (!cssW || !cssH) return null;
773
+ const dpr = window.devicePixelRatio || 1;
774
+ const bw = Math.round(cssW * dpr);
775
+ const bh = Math.round(cssH * dpr);
776
+ if (canvas.width !== bw) canvas.width = bw;
777
+ if (canvas.height !== bh) canvas.height = bh;
778
+ const ctx = canvas.getContext('2d');
779
+ if (!ctx) return null;
780
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
781
+ ctx.clearRect(0, 0, bw, bh);
782
+ return { ctx, sx: bw / resolution.width, sy: bh / resolution.height };
783
+ }
784
+
689
785
  function drawCellThumb(canvas, sprite, layerIndex, frameIndex) {
690
- const ctx = setupSpriteCanvas(canvas, sprite.resolution);
691
- if (!ctx) return;
786
+ const setup = setupThumbCanvas(canvas, sprite.resolution);
787
+ if (!setup) return;
788
+ const { ctx, sx, sy } = setup;
789
+ const { width, height } = sprite.resolution;
790
+
791
+ // Thumbnails show the sprite's chosen finish, so a Smooth sprite reads as
792
+ // Smooth in the timeline. renderSmoothLayerCell handles the whole finish
793
+ // space: analytic shapes for path layers under the vector renderer, corner
794
+ // kernel otherwise.
795
+ if (displayFinish(sprite).smooth) {
796
+ const scale = (sx + sy) / 2;
797
+ const off = document.createElement('canvas');
798
+ renderSmoothLayerCell(sprite, layerIndex, frameIndex, off, { scale });
799
+ ctx.imageSmoothingEnabled = true;
800
+ ctx.drawImage(off, 0, 0, off.width, off.height, 0, 0, canvas.width, canvas.height);
801
+ return;
802
+ }
803
+
804
+ ctx.imageSmoothingEnabled = false;
692
805
  const resolved = resolveCell(sprite.layers[layerIndex], frameIndex);
693
806
  if (!resolved) return;
694
- const { width, height } = sprite.resolution;
695
807
  const { grid, x: offsetX, y: offsetY } = resolved;
696
808
  const lookup = paletteLookup(sprite.palette);
697
809
  forEachGridCell(grid, Infinity, Infinity, (gx, gy, key) => {
@@ -699,10 +811,18 @@ function drawCellThumb(canvas, sprite, layerIndex, frameIndex) {
699
811
  const cy = gy + offsetY;
700
812
  if (cx < 0 || cx >= width || cy < 0 || cy >= height) return;
701
813
  const color = colorForKeyV2(lookup, key);
702
- if (color) {
703
- ctx.fillStyle = color;
704
- ctx.fillRect(cx, cy, 1, 1);
705
- }
814
+ if (!color) return;
815
+ // Snap every cell to whole device pixels. The scale here is fractional (a
816
+ // 16px sprite in a 43px cell is 5.375), and a fractional fillRect
817
+ // anti-aliases both sides of each shared edge; two half-covered columns
818
+ // composite to ~75% rather than 100%, which is the faint grid that shows
819
+ // over flat colour. Deriving both edges from the same rounded expression
820
+ // makes neighbours land on exactly the same boundary. The cost is that
821
+ // cells differ by up to one device pixel in size.
822
+ const x0 = Math.round(cx * sx);
823
+ const y0 = Math.round(cy * sy);
824
+ ctx.fillStyle = color;
825
+ ctx.fillRect(x0, y0, Math.round((cx + 1) * sx) - x0, Math.round((cy + 1) * sy) - y0);
706
826
  });
707
827
  }
708
828