castle-web-cli 0.4.74 → 0.4.76

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 (72) hide show
  1. package/dist/agent-prompts.d.ts +4 -0
  2. package/dist/agent-prompts.js +20 -8
  3. package/dist/agent.js +301 -28
  4. package/dist/castle-host/host.js +59 -5
  5. package/dist/commonInstructions.d.ts +1 -1
  6. package/dist/commonInstructions.js +5 -1
  7. package/dist/ide.d.ts +1 -0
  8. package/dist/ide.js +250 -1
  9. package/dist/init.js +46 -4
  10. package/dist/save-deck.js +8 -1
  11. package/dist/serve.js +27 -1
  12. package/dist/shell/assets/index-DNWEQd4R.js +141 -0
  13. package/dist/shell/assets/{index-WNbOHPBj.css → index-DuKq-Grp.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/basic-2d/CLAUDE.md +12 -4
  16. package/kits/basic-2d/castle.json +15 -0
  17. package/kits/basic-2d/drawings/pig.pxart +22 -55
  18. package/kits/basic-2d/editors/PxArtEditor.jsx +237 -4
  19. package/kits/basic-2d/editors/SceneEditor.jsx +0 -3
  20. package/kits/basic-2d/editors/SingleEditor.jsx +6 -51
  21. package/kits/basic-2d/editors/pixelEditorChrome.jsx +17 -11
  22. package/kits/basic-2d/editors/pixelGeometry.js +95 -0
  23. package/kits/basic-2d/editors/pixelInspector.jsx +229 -51
  24. package/kits/basic-2d/editors/pxArtTools.js +109 -1
  25. package/kits/basic-2d/engine/ScenePlayer.jsx +11 -163
  26. package/kits/basic-2d/engine/ui.jsx +15 -0
  27. package/kits/basic-2d/engine/ui.module.css +44 -117
  28. package/kits/basic-2d/main.jsx +6 -10
  29. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  30. package/package.json +10 -1
  31. package/dist/shell/assets/index-Dfn29Bkt.js +0 -108
  32. package/kits/basic-2d/editors/App.jsx +0 -226
  33. package/kits/basic-2d/editors/CodeEditor.jsx +0 -79
  34. package/kits/basic-2d/editors/FileBrowser.jsx +0 -349
  35. package/kits/basic-2d/editors/codeTheme.js +0 -135
  36. package/kits/basic-2d/engine/TouchControls.jsx +0 -136
  37. package/kits/basic-2d/engine/playConsole.js +0 -66
  38. package/kits/basic-3d/.prettierrc +0 -8
  39. package/kits/basic-3d/CLAUDE.md +0 -162
  40. package/kits/basic-3d/behaviors/Camera.jsx +0 -56
  41. package/kits/basic-3d/behaviors/Collider.jsx +0 -78
  42. package/kits/basic-3d/behaviors/Mesh.jsx +0 -82
  43. package/kits/basic-3d/behaviors/Model.jsx +0 -61
  44. package/kits/basic-3d/behaviors/Transform.jsx +0 -35
  45. package/kits/basic-3d/editors/App.jsx +0 -147
  46. package/kits/basic-3d/editors/CodeEditor.jsx +0 -112
  47. package/kits/basic-3d/editors/FileBrowser.jsx +0 -143
  48. package/kits/basic-3d/editors/ModelEditor.jsx +0 -400
  49. package/kits/basic-3d/editors/PlayOnly.jsx +0 -22
  50. package/kits/basic-3d/editors/SceneEditor.jsx +0 -1087
  51. package/kits/basic-3d/editors/behaviorRegistry.js +0 -24
  52. package/kits/basic-3d/editors/editorHistory.js +0 -52
  53. package/kits/basic-3d/editors/viewportRig.js +0 -90
  54. package/kits/basic-3d/engine/ScenePlayer.jsx +0 -61
  55. package/kits/basic-3d/engine/SceneUI.jsx +0 -67
  56. package/kits/basic-3d/engine/SceneViewport.jsx +0 -102
  57. package/kits/basic-3d/engine/TouchControls.jsx +0 -136
  58. package/kits/basic-3d/engine/autoInspector.jsx +0 -51
  59. package/kits/basic-3d/engine/files.js +0 -73
  60. package/kits/basic-3d/engine/scene.js +0 -502
  61. package/kits/basic-3d/engine/threeUtil.js +0 -260
  62. package/kits/basic-3d/engine/ui.jsx +0 -352
  63. package/kits/basic-3d/engine/ui.module.css +0 -944
  64. package/kits/basic-3d/eslint.config.js +0 -51
  65. package/kits/basic-3d/index.html +0 -11
  66. package/kits/basic-3d/main.jsx +0 -10
  67. package/kits/basic-3d/models/block.model +0 -14
  68. package/kits/basic-3d/package-lock.json +0 -2713
  69. package/kits/basic-3d/package.json +0 -41
  70. package/kits/basic-3d/pnpm-lock.yaml +0 -1769
  71. package/kits/basic-3d/scenes/main.scene +0 -76
  72. package/kits/basic-3d/vite.config.js +0 -1
@@ -11,13 +11,33 @@ export const BRUSH_SIZES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
11
11
  export const PIXEL_TOOLS = [
12
12
  { id: 'brush', label: 'Brush', icon: 'pencil' },
13
13
  { id: 'fill', label: 'Fill', icon: 'fill' },
14
+ { id: 'shape', label: 'Shape', icon: 'shapes' },
15
+ { id: 'select', label: 'Select', icon: 'marquee' },
14
16
  { id: 'move-all', label: 'Move all', icon: 'move' },
15
17
  { id: 'erase', label: 'Erase', icon: 'eraser' },
16
18
  ];
17
19
 
20
+ // Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
21
+ // the diagonal, square the rectangle, circle the inscribed ellipse, triangle an
22
+ // isosceles triangle. `filled` (a separate toggle) applies to the closed three.
23
+ export const SHAPE_MODES = [
24
+ { id: 'line', label: 'Line', icon: 'slash' },
25
+ { id: 'square', label: 'Square', icon: 'square' },
26
+ { id: 'circle', label: 'Circle', icon: 'circle' },
27
+ { id: 'triangle', label: 'Triangle', icon: 'triangle' },
28
+ ];
29
+
30
+ // Momentary transform actions the move tool exposes (it has no persistent
31
+ // settings). Each `id` maps to a handler on the editor's `moveActions`.
32
+ export const MOVE_ACTIONS = [
33
+ { id: 'flipH', label: 'Flip horizontal', icon: 'flip-h' },
34
+ { id: 'flipV', label: 'Flip vertical', icon: 'flip-v' },
35
+ { id: 'rotateCW', label: 'Rotate clockwise', icon: 'redo' },
36
+ ];
37
+
18
38
  // Tools whose work is driven by the active palette color, so the palette panel
19
39
  // (and its eyedropper action) shows.
20
- export const PALETTE_TOOLS = new Set(['brush', 'fill', 'eyedropper']);
40
+ export const PALETTE_TOOLS = new Set(['brush', 'fill', 'shape', 'eyedropper']);
21
41
 
22
42
  // Shared tool-mode state for the pixel editors: the selected tool plus the
23
43
  // brush/erase sizes and the fill/erase mode toggles. The active color/key stays
@@ -33,6 +53,8 @@ export function usePixelToolState() {
33
53
  const [eraseSize, setEraseSize] = useState(4);
34
54
  const [eraseFillMode, setEraseFillMode] = useState(false);
35
55
  const [fillSwapMode, setFillSwapMode] = useState(false);
56
+ const [shapeMode, setShapeMode] = useState('line');
57
+ const [shapeFill, setShapeFill] = useState(false);
36
58
  return {
37
59
  tool,
38
60
  setTool,
@@ -46,6 +68,10 @@ export function usePixelToolState() {
46
68
  setEraseFillMode,
47
69
  fillSwapMode,
48
70
  setFillSwapMode,
71
+ shapeMode,
72
+ setShapeMode,
73
+ shapeFill,
74
+ setShapeFill,
49
75
  };
50
76
  }
51
77
 
@@ -175,6 +201,54 @@ export function PalettePopover({ open, anchorRef, onClose, children }) {
175
201
  );
176
202
  }
177
203
 
204
+ // The shape tool's sub-mode picker, shared by the compact paint strip and the
205
+ // wide-layout settings panel. `buttonClass` styles each glyph button for its
206
+ // host strip; `radio` switches the a11y semantics to a radiogroup.
207
+ function ShapeModeButtons({ shapeMode, setShapeMode, buttonClass, radio = false }) {
208
+ return (
209
+ <>
210
+ {SHAPE_MODES.map((mode) => {
211
+ const selected = shapeMode === mode.id;
212
+ return (
213
+ <button
214
+ key={mode.id}
215
+ type="button"
216
+ role={radio ? 'radio' : undefined}
217
+ className={cx(buttonClass, selected && styles.drawingToolSelected)}
218
+ title={mode.label}
219
+ aria-label={mode.label}
220
+ aria-pressed={radio ? undefined : selected ? 'true' : 'false'}
221
+ aria-checked={radio ? (selected ? 'true' : 'false') : undefined}
222
+ onClick={() => setShapeMode(mode.id)}>
223
+ <Icon name={mode.icon} />
224
+ </button>
225
+ );
226
+ })}
227
+ </>
228
+ );
229
+ }
230
+
231
+ // The move tool's transform actions (flip H / flip V / rotate CW), shared by the
232
+ // compact paint strip and the wide-layout settings panel. These are momentary
233
+ // buttons (no selected state); `buttonClass` styles them for the host strip.
234
+ function MoveActionButtons({ moveActions, buttonClass }) {
235
+ return (
236
+ <>
237
+ {MOVE_ACTIONS.map((action) => (
238
+ <button
239
+ key={action.id}
240
+ type="button"
241
+ className={buttonClass}
242
+ title={action.label}
243
+ aria-label={action.label}
244
+ onClick={moveActions?.[action.id]}>
245
+ <Icon name={action.icon} />
246
+ </button>
247
+ ))}
248
+ </>
249
+ );
250
+ }
251
+
178
252
  // Right-side paint strip (compact layout): color swatch, vertical size slider, and
179
253
  // tool-contextual mode toggles. Color tap opens the palette popover.
180
254
  export function PaintStrip({
@@ -187,6 +261,8 @@ export function PaintStrip({
187
261
  onSelectKey,
188
262
  paletteKeys,
189
263
  palette,
264
+ moveActions,
265
+ selectActions,
190
266
  }) {
191
267
  const {
192
268
  tool,
@@ -196,10 +272,14 @@ export function PaintStrip({
196
272
  eraseSize,
197
273
  eraseFillMode,
198
274
  fillSwapMode,
275
+ shapeMode,
276
+ shapeFill,
199
277
  setBrushSize,
200
278
  setEraseSize,
201
279
  setEraseFillMode,
202
280
  setFillSwapMode,
281
+ setShapeMode,
282
+ setShapeFill,
203
283
  } = toolState;
204
284
 
205
285
  const showBrushSize = tool === 'brush';
@@ -212,59 +292,112 @@ export function PaintStrip({
212
292
  return (
213
293
  <>
214
294
  <div className={styles.paintStrip} aria-label="Paint controls">
215
- <button
216
- ref={colorButtonRef}
217
- type="button"
218
- className={styles.paintStripColor}
219
- title={activeColor}
220
- aria-label={`Active color ${activeColor}. Open palette.`}
221
- aria-expanded={paletteOpen ? 'true' : 'false'}
222
- style={{ background: activeColor }}
223
- onClick={onTogglePalette}
224
- />
295
+ {PALETTE_TOOLS.has(tool) || picking ? (
296
+ <div className={styles.paintStripSection}>
297
+ <button
298
+ ref={colorButtonRef}
299
+ type="button"
300
+ className={styles.paintStripColor}
301
+ title={activeColor}
302
+ aria-label={`Active color ${activeColor}. Open palette.`}
303
+ aria-expanded={paletteOpen ? 'true' : 'false'}
304
+ style={{ background: activeColor }}
305
+ onClick={onTogglePalette}
306
+ />
307
+ </div>
308
+ ) : null}
225
309
  {showBrushSize || showEraseSize ? (
226
- <div className={styles.paintStripSlider}>
227
- <input
228
- className={styles.drawingSizeSliderVertical}
229
- type="range"
230
- min="0"
231
- max={BRUSH_SIZES.length - 1}
232
- step="1"
233
- value={sliderIndex}
234
- disabled={sliderDisabled}
235
- aria-label={showBrushSize ? 'Brush size' : 'Erase size'}
236
- title={`${showBrushSize ? 'Brush' : 'Erase'} size ${size}`}
237
- onChange={(event) => onSizeChange(BRUSH_SIZES[Number(event.target.value)])}
310
+ <div className={styles.paintStripSection}>
311
+ <div className={styles.paintStripSlider}>
312
+ <input
313
+ className={styles.drawingSizeSliderVertical}
314
+ type="range"
315
+ min="0"
316
+ max={BRUSH_SIZES.length - 1}
317
+ step="1"
318
+ value={sliderIndex}
319
+ disabled={sliderDisabled}
320
+ aria-label={showBrushSize ? 'Brush size' : 'Erase size'}
321
+ title={`${showBrushSize ? 'Brush' : 'Erase'} size ${size}`}
322
+ onChange={(event) => onSizeChange(BRUSH_SIZES[Number(event.target.value)])}
323
+ />
324
+ </div>
325
+ </div>
326
+ ) : null}
327
+ {tool === 'shape' ? (
328
+ <div className={styles.paintStripSection}>
329
+ <ShapeModeButtons
330
+ shapeMode={shapeMode}
331
+ setShapeMode={setShapeMode}
332
+ buttonClass={styles.paintStripModeButton}
238
333
  />
239
334
  </div>
240
335
  ) : null}
336
+ {tool === 'move-all' ? (
337
+ <div className={styles.paintStripSection}>
338
+ <MoveActionButtons moveActions={moveActions} buttonClass={styles.paintStripModeButton} />
339
+ </div>
340
+ ) : null}
341
+ {tool === 'select' ? (
342
+ <div className={styles.paintStripSection}>
343
+ <button
344
+ type="button"
345
+ className={styles.paintStripModeButton}
346
+ title="Delete selection"
347
+ aria-label="Delete selection"
348
+ disabled={!selectActions?.hasSelection}
349
+ onClick={selectActions?.deletePixels}>
350
+ <Icon name="trash" />
351
+ </button>
352
+ </div>
353
+ ) : null}
241
354
  {tool === 'erase' ? (
242
- <button
243
- type="button"
244
- className={cx(
245
- styles.paintStripModeButton,
246
- eraseFillMode && styles.drawingToolSelected
247
- )}
248
- title="Fill mode"
249
- aria-label="Fill mode"
250
- aria-pressed={eraseFillMode ? 'true' : 'false'}
251
- onClick={() => setEraseFillMode(!eraseFillMode)}>
252
- <Icon name="fill" />
253
- </button>
355
+ <div className={styles.paintStripSection}>
356
+ <button
357
+ type="button"
358
+ className={cx(
359
+ styles.paintStripModeButton,
360
+ eraseFillMode && styles.drawingToolSelected
361
+ )}
362
+ title="Fill mode"
363
+ aria-label="Fill mode"
364
+ aria-pressed={eraseFillMode ? 'true' : 'false'}
365
+ onClick={() => setEraseFillMode(!eraseFillMode)}>
366
+ <Icon name="fill" />
367
+ </button>
368
+ </div>
254
369
  ) : null}
255
370
  {tool === 'fill' ? (
256
- <button
257
- type="button"
258
- className={cx(
259
- styles.paintStripModeButton,
260
- fillSwapMode && styles.drawingToolSelected
261
- )}
262
- title="Swap mode"
263
- aria-label="Swap mode"
264
- aria-pressed={fillSwapMode ? 'true' : 'false'}
265
- onClick={() => setFillSwapMode(!fillSwapMode)}>
266
- <Icon name="rotate" />
267
- </button>
371
+ <div className={styles.paintStripSection}>
372
+ <button
373
+ type="button"
374
+ className={cx(
375
+ styles.paintStripModeButton,
376
+ fillSwapMode && styles.drawingToolSelected
377
+ )}
378
+ title="Swap mode"
379
+ aria-label="Swap mode"
380
+ aria-pressed={fillSwapMode ? 'true' : 'false'}
381
+ onClick={() => setFillSwapMode(!fillSwapMode)}>
382
+ <Icon name="rotate" />
383
+ </button>
384
+ </div>
385
+ ) : null}
386
+ {tool === 'shape' ? (
387
+ <div className={styles.paintStripSection}>
388
+ <button
389
+ type="button"
390
+ className={cx(
391
+ styles.paintStripModeButton,
392
+ shapeFill && styles.drawingToolSelected
393
+ )}
394
+ title="Fill shape"
395
+ aria-label="Fill shape"
396
+ aria-pressed={shapeFill ? 'true' : 'false'}
397
+ onClick={() => setShapeFill(!shapeFill)}>
398
+ <Icon name="fill" />
399
+ </button>
400
+ </div>
268
401
  ) : null}
269
402
  </div>
270
403
  <PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
@@ -293,7 +426,7 @@ export function PaintStrip({
293
426
  }
294
427
 
295
428
  // Wide-layout docked inspector: palette (when relevant) + tool settings only.
296
- export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteKeys, palette }) {
429
+ export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteKeys, palette, moveActions, selectActions }) {
297
430
  const { tool, picking, setPicking } = toolState;
298
431
  return (
299
432
  <aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
@@ -318,7 +451,7 @@ export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteK
318
451
  />
319
452
  </Panel>
320
453
  ) : null}
321
- <ToolSettingsPanel toolState={toolState} />
454
+ <ToolSettingsPanel toolState={toolState} moveActions={moveActions} selectActions={selectActions} />
322
455
  </div>
323
456
  </aside>
324
457
  );
@@ -344,17 +477,21 @@ export function ModeToggle({ label, active, onChange }) {
344
477
  // The "Tool Settings" panel for the pixel editor: brush/erase size sliders plus
345
478
  // the per-tool mode toggle / hint. It reads the whole usePixelToolState bundle
346
479
  // to keep callers from re-spreading the same prop list.
347
- export function ToolSettingsPanel({ toolState }) {
480
+ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
348
481
  const {
349
482
  tool,
350
483
  brushSize,
351
484
  eraseSize,
352
485
  eraseFillMode,
353
486
  fillSwapMode,
487
+ shapeMode,
488
+ shapeFill,
354
489
  setBrushSize,
355
490
  setEraseSize,
356
491
  setEraseFillMode,
357
492
  setFillSwapMode,
493
+ setShapeMode,
494
+ setShapeFill,
358
495
  } = toolState;
359
496
  return (
360
497
  <Panel title="Tool Settings">
@@ -376,8 +513,49 @@ export function ToolSettingsPanel({ toolState }) {
376
513
  {tool === 'fill' ? (
377
514
  <ModeToggle label="Swap Mode" active={fillSwapMode} onChange={setFillSwapMode} />
378
515
  ) : null}
516
+ {tool === 'shape' ? (
517
+ <div className={styles.drawingSizePicker}>
518
+ <div className={styles.drawingSizeLabel}>
519
+ <span>Shape</span>
520
+ </div>
521
+ <div className={styles.drawingShapeModeRow} role="radiogroup" aria-label="Shape">
522
+ <ShapeModeButtons
523
+ shapeMode={shapeMode}
524
+ setShapeMode={setShapeMode}
525
+ buttonClass={styles.drawingToolButton}
526
+ radio
527
+ />
528
+ </div>
529
+ <ModeToggle label="Fill Shape" active={shapeFill} onChange={setShapeFill} />
530
+ </div>
531
+ ) : null}
379
532
  {tool === 'move-all' ? (
380
- <div className={styles.drawingToolHint}>Drag the canvas to shift all pixels.</div>
533
+ <div className={styles.drawingSizePicker}>
534
+ <div className={styles.drawingSizeLabel}>
535
+ <span>Transform</span>
536
+ </div>
537
+ <div className={styles.drawingShapeModeRow}>
538
+ <MoveActionButtons moveActions={moveActions} buttonClass={styles.drawingToolButton} />
539
+ </div>
540
+ </div>
541
+ ) : null}
542
+ {tool === 'select' ? (
543
+ <div className={styles.drawingSizePicker}>
544
+ <div className={styles.drawingSizeLabel}>
545
+ <span>Selection</span>
546
+ </div>
547
+ <div className={styles.drawingShapeModeRow}>
548
+ <button
549
+ type="button"
550
+ className={styles.drawingToolButton}
551
+ title="Delete selection"
552
+ aria-label="Delete selection"
553
+ disabled={!selectActions?.hasSelection}
554
+ onClick={selectActions?.deletePixels}>
555
+ <Icon name="trash" />
556
+ </button>
557
+ </div>
558
+ </div>
381
559
  ) : null}
382
560
  {tool === 'eyedropper' ? (
383
561
  <div className={styles.drawingToolHint}>Click a pixel to make it the active color.</div>
@@ -1,5 +1,11 @@
1
1
  import { EDG64, KEY_ALPHABET, normalizeHex, TRANSPARENT } from '../engine/pxart';
2
- import { forEachDab, forEachLinePoint } from './pixelGeometry';
2
+ import {
3
+ forEachDab,
4
+ forEachEllipseCells,
5
+ forEachLinePoint,
6
+ forEachRectCells,
7
+ forEachTriangleCells,
8
+ } from './pixelGeometry';
3
9
 
4
10
  // The PxArt editor paints over the FIXED full Endesga-64 palette (no free color
5
11
  // picking). Each palette color gets a stable single-char key (KEY_ALPHABET, 64
@@ -46,6 +52,9 @@ export function editorKeyForHex(hex) {
46
52
  return bestKey;
47
53
  }
48
54
 
55
+ // The swatch the editor opens on: Endesga-64's lead bright red (#ff0040).
56
+ export const DEFAULT_KEY = editorKeyForHex('#ff0040');
57
+
49
58
  function isInside(cells, x, y) {
50
59
  return y >= 0 && y < cells.length && x >= 0 && x < (cells[0]?.length ?? 0);
51
60
  }
@@ -75,6 +84,28 @@ export function paintLine(cells, from, to, key, size = 1) {
75
84
  return changed ? next : cells;
76
85
  }
77
86
 
87
+ // Draw a shape with `key` into a copy of `cells`, defined by the drag from
88
+ // `from` to `to`. `mode` selects line / square / circle / triangle; `filled`
89
+ // applies to the closed shapes (the line ignores it). Returns a new matrix when
90
+ // anything changed, else the original (so callers can skip no-op commits).
91
+ export function paintShape(cells, from, to, key, mode, filled = false) {
92
+ const next = copyCells(cells);
93
+ let changed = false;
94
+ const write = (x, y) => {
95
+ changed = writeCell(next, x, y, key) || changed;
96
+ };
97
+ if (mode === 'line') {
98
+ forEachLinePoint(from, to, write);
99
+ } else if (mode === 'square') {
100
+ forEachRectCells(from, to, filled, write);
101
+ } else if (mode === 'circle') {
102
+ forEachEllipseCells(from, to, filled, write);
103
+ } else if (mode === 'triangle') {
104
+ forEachTriangleCells(from, to, filled, write);
105
+ }
106
+ return changed ? next : cells;
107
+ }
108
+
78
109
  // Both region tools share the same guard: bail when the start is out of bounds
79
110
  // or already the target key, else return a working copy plus the key to replace.
80
111
  function beginKeyReplace(cells, start, key) {
@@ -122,3 +153,80 @@ export function swapKey(cells, start, key) {
122
153
  export function sampleKey(cells, point) {
123
154
  return isInside(cells, point.x, point.y) ? cells[point.y][point.x] : TRANSPARENT;
124
155
  }
156
+
157
+ // Mirror the canvas-window matrix left-to-right (dimensions unchanged).
158
+ export function flipCellsH(cells) {
159
+ return cells.map((row) => row.slice().reverse());
160
+ }
161
+
162
+ // Mirror the canvas-window matrix top-to-bottom (dimensions unchanged).
163
+ export function flipCellsV(cells) {
164
+ return cells.slice().reverse().map((row) => row.slice());
165
+ }
166
+
167
+ // Clear a rectangular region of the canvas-window matrix to transparent (the
168
+ // marquee delete). `rect` is { x, y, w, h } in canvas cells. Returns a new
169
+ // matrix when anything changed, else the original.
170
+ export function clearRegion(cells, rect) {
171
+ const next = copyCells(cells);
172
+ let changed = false;
173
+ for (let y = rect.y; y < rect.y + rect.h; y++) {
174
+ for (let x = rect.x; x < rect.x + rect.w; x++) {
175
+ changed = writeCell(next, x, y, TRANSPARENT) || changed;
176
+ }
177
+ }
178
+ return changed ? next : cells;
179
+ }
180
+
181
+ // Lift the pixels in `rect`, clear that source region to transparent, then stamp
182
+ // the lifted OPAQUE pixels back at (dx, dy) offset — the marquee move's lift &
183
+ // float, applied as one combined result. Transparent cells in the lifted region
184
+ // don't erase the destination (they let underlying art show through). Pixels
185
+ // pushed outside the canvas window are clipped. Returns the original matrix on a
186
+ // zero delta (so a click-without-drag records no history); otherwise a new copy.
187
+ export function moveRegion(cells, rect, dx, dy) {
188
+ if (dx === 0 && dy === 0) return cells;
189
+ const buffer = [];
190
+ for (let y = 0; y < rect.h; y++) {
191
+ const row = [];
192
+ for (let x = 0; x < rect.w; x++) {
193
+ const sx = rect.x + x;
194
+ const sy = rect.y + y;
195
+ row.push(isInside(cells, sx, sy) ? cells[sy][sx] : TRANSPARENT);
196
+ }
197
+ buffer.push(row);
198
+ }
199
+ const next = copyCells(cells);
200
+ for (let y = 0; y < rect.h; y++) {
201
+ for (let x = 0; x < rect.w; x++) writeCell(next, rect.x + x, rect.y + y, TRANSPARENT);
202
+ }
203
+ for (let y = 0; y < rect.h; y++) {
204
+ for (let x = 0; x < rect.w; x++) {
205
+ const key = buffer[y][x];
206
+ if (key !== TRANSPARENT) writeCell(next, rect.x + x + dx, rect.y + y + dy, key);
207
+ }
208
+ }
209
+ return next;
210
+ }
211
+
212
+ // Rotate the canvas-window matrix 90° clockwise about the canvas center, keeping
213
+ // the same width × height window (so it merges back through withCellsAt). For a
214
+ // square canvas this is the exact lossless rotation; on a non-square canvas the
215
+ // rotated corners that fall outside the window are clipped (filled transparent).
216
+ export function rotateCellsCW(cells) {
217
+ const height = cells.length;
218
+ const width = height ? cells[0].length : 0;
219
+ const cx = (width - 1) / 2;
220
+ const cy = (height - 1) / 2;
221
+ const out = [];
222
+ for (let oy = 0; oy < height; oy++) {
223
+ const row = [];
224
+ for (let ox = 0; ox < width; ox++) {
225
+ const sx = Math.round(cx + (oy - cy));
226
+ const sy = Math.round(cy - (ox - cx));
227
+ row.push(sy >= 0 && sy < height && sx >= 0 && sx < width ? cells[sy][sx] : TRANSPARENT);
228
+ }
229
+ out.push(row);
230
+ }
231
+ return out;
232
+ }