castle-web-cli 0.4.71 → 0.4.73

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 (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BY21Og40.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -7,11 +7,12 @@
7
7
 
8
8
  import React, { useEffect, useRef, useState } from 'react';
9
9
  import { onBeforeRestart, writeFile } from 'castle-web-sdk';
10
- import { formatJson, getFileKind, initialFiles, parseJsonFile } from '../engine/files';
10
+ import { getFileKind, initialFiles } from '../engine/files';
11
+ import { collectAssets } from '../engine/assets';
11
12
  import { MainEditor, styles } from '../engine/ui';
12
13
  import { CodeEditor } from './CodeEditor';
13
- import { DrawingEditor } from './DrawingEditor';
14
- import { FileTree } from './FileBrowser';
14
+ import { PxArtEditor } from './PxArtEditor';
15
+ import { FileBrowser } from './FileBrowser';
15
16
  import { SceneEditor } from './SceneEditor';
16
17
 
17
18
  // Debounced writeFile per path, flushed on reload -- the same save behavior the
@@ -64,12 +65,7 @@ export function SingleEditor({ path, editor }) {
64
65
  const [selectedActorIds, setSelectedActorIds] = useState([]);
65
66
  const [multiSelectMode, setMultiSelectMode] = useState(false);
66
67
  const schedule = useFileSaver();
67
- const drawings = {};
68
- for (const [filePath, text] of Object.entries(files)) {
69
- if (!filePath.endsWith('.drawing')) continue;
70
- const parsed = parseJsonFile(filePath, text);
71
- if (parsed.value) drawings[filePath] = parsed.value;
72
- }
68
+ const { sprites } = collectAssets(files);
73
69
  function onChange(nextText) {
74
70
  setFiles((current) => ({ ...current, [path]: nextText }));
75
71
  schedule(path, nextText);
@@ -83,21 +79,22 @@ export function SingleEditor({ path, editor }) {
83
79
  path={path}
84
80
  text={text}
85
81
  files={files}
86
- drawings={drawings}
82
+ sprites={sprites}
87
83
  onChange={onChange}
88
84
  selectedActorIds={selectedActorIds}
89
85
  onSelectActorIds={setSelectedActorIds}
90
86
  multiSelectMode={multiSelectMode}
91
87
  onSetMultiSelectMode={setMultiSelectMode}
92
- bare
93
88
  />
94
89
  );
95
- } else if (kind === 'drawing') {
96
- body = <DrawingEditor path={path} text={text} onChange={onChange} bare />;
97
- } else if (kind === 'text') {
98
- body = <CodeEditor path={path} text={formatJson(text)} onChange={onChange} bare />;
90
+ } else if (kind === 'pxart') {
91
+ body = <PxArtEditor path={path} text={text} onChange={onChange} />;
99
92
  } else {
100
- body = <CodeEditor path={path} text={text} onChange={onChange} bare />;
93
+ // 'code' and 'text' both render raw. Note: do NOT run plain text through
94
+ // formatJson (JSON.stringify) -- getFileKind only routes non-JSON files
95
+ // here, so it would quote/escape the contents (a new file.txt would show
96
+ // and save back as "Hello I'm a file" with literal quotes).
97
+ body = <CodeEditor path={path} text={text} onChange={onChange} />;
101
98
  }
102
99
  return (
103
100
  <div className={styles.panelBare}>
@@ -106,19 +103,40 @@ export function SingleEditor({ path, editor }) {
106
103
  );
107
104
  }
108
105
 
109
- // File panel: the shared tree (all dirs + files, no header), bare. Clicking a
110
- // file asks the shell to open / focus its editor panel.
106
+ // File panel: a read-only file browser (bare, no sidebar/sheet chrome).
107
+ // Clicking a file asks the shell to open / focus its editor panel. File
108
+ // mutation (add / duplicate / rename / delete) is intentionally NOT wired here:
109
+ // under the kit's static-glob content model those edits only persist within a
110
+ // session, so the browser is browse-and-open only until real persistence lands.
111
111
  export function FileList({ selectedPath }) {
112
+ const [files] = useState(initialFiles);
113
+ const [selected, setSelected] = useState(selectedPath);
114
+ useEffect(() => {
115
+ setSelected(selectedPath);
116
+ }, [selectedPath]);
117
+
112
118
  function open(path) {
119
+ setSelected(path);
113
120
  try {
114
121
  window.parent.postMessage({ type: 'castle-open-file', path }, '*');
115
122
  } catch {
116
123
  /* not embedded */
117
124
  }
118
125
  }
126
+ // Safari won't paint this static panel inside its composited dockview iframe
127
+ // until an invalidation is forced (the rows only appear once devtools opens).
128
+ // translateZ(0) promotes it to its own compositing layer so Safari paints and
129
+ // keeps it. The container sits at the iframe's viewport origin and isn't the
130
+ // scrollport's scrolling box, so it doesn't shift the fixed context menu.
119
131
  return (
120
- <div style={{ height: '100vh', overflow: 'auto', background: 'var(--castle-sheet-bg, #fff)' }}>
121
- <FileTree files={initialFiles} selectedPath={selectedPath} onSelect={open} />
132
+ <div
133
+ style={{
134
+ height: '100vh',
135
+ overflow: 'auto',
136
+ background: 'var(--castle-sheet-bg, #0d0d0d)',
137
+ transform: 'translateZ(0)',
138
+ }}>
139
+ <FileBrowser bare files={files} selectedPath={selected} onSelect={open} />
122
140
  </div>
123
141
  );
124
142
  }
@@ -0,0 +1,135 @@
1
+ import { HighlightStyle } from '@codemirror/language';
2
+ import { EditorView } from '@codemirror/view';
3
+ import { tags } from '@lezer/highlight';
4
+
5
+ // Dark theme for the in-kit CodeMirror editor. Surfaces reference the kit's
6
+ // shared `--castle-*` tokens (defined on `.editor-root` in ui.module.css) so
7
+ // the editor stays in sync with the rest of the dark inspector palette. Syntax
8
+ // token colors are literal hex chosen to read well on the #0d0d0d background.
9
+ const selectionBg = 'rgba(230, 230, 230, 0.22)';
10
+
11
+ export const castleCodeTheme = EditorView.theme(
12
+ {
13
+ '&': {
14
+ height: '100%',
15
+ fontSize: '9pt',
16
+ color: 'var(--castle-inspector-text)',
17
+ backgroundColor: 'var(--castle-inspector-bg)',
18
+ },
19
+ '&.cm-editor.cm-focused': {
20
+ outline: 'none',
21
+ },
22
+ '.cm-scroller': {
23
+ overflow: 'auto',
24
+ fontFamily: 'Menlo, Monaco, Lucida Console, monospace',
25
+ },
26
+ '.cm-content': {
27
+ minHeight: '100%',
28
+ caretColor: 'var(--castle-black)',
29
+ color: 'var(--castle-inspector-text)',
30
+ paddingBottom: '400px',
31
+ paddingRight: '80px',
32
+ },
33
+ '.cm-cursor, .cm-dropCursor': {
34
+ borderLeftColor: 'var(--castle-black)',
35
+ },
36
+ '&.cm-focused .cm-cursor': {
37
+ borderLeftColor: 'var(--castle-black)',
38
+ },
39
+ '.cm-selectionBackground, .cm-content ::selection': {
40
+ backgroundColor: selectionBg,
41
+ },
42
+ '&.cm-focused .cm-selectionBackground, &.cm-focused .cm-content ::selection': {
43
+ backgroundColor: selectionBg,
44
+ },
45
+ '.cm-gutters': {
46
+ // Gutters are hidden in this editor; keep them themed in case they are
47
+ // re-enabled so they don't flash a light surface.
48
+ display: 'none',
49
+ backgroundColor: 'var(--castle-inspector-bg)',
50
+ color: 'var(--castle-inspector-muted)',
51
+ borderRight: '1px solid var(--castle-inspector-divider)',
52
+ },
53
+ '.cm-activeLine': {
54
+ backgroundColor: 'var(--castle-inspector-input-bg)',
55
+ },
56
+ '.cm-activeLineGutter': {
57
+ color: 'var(--castle-inspector-text)',
58
+ backgroundColor: 'var(--castle-inspector-input-bg)',
59
+ },
60
+ '.cm-foldPlaceholder': {
61
+ backgroundColor: 'var(--castle-inspector-input-bg)',
62
+ border: '1px solid var(--castle-inspector-border)',
63
+ color: 'var(--castle-inspector-muted)',
64
+ },
65
+ '.cm-matchingBracket, &.cm-focused .cm-matchingBracket': {
66
+ backgroundColor: 'rgba(230, 230, 230, 0.18)',
67
+ outline: '1px solid var(--castle-black)',
68
+ color: 'inherit',
69
+ },
70
+ '.cm-nonmatchingBracket, &.cm-focused .cm-nonmatchingBracket': {
71
+ backgroundColor: 'rgba(232, 106, 115, 0.35)',
72
+ },
73
+ '.cm-searchMatch': {
74
+ backgroundColor: 'rgba(255, 203, 107, 0.25)',
75
+ outline: '1px solid rgba(255, 203, 107, 0.5)',
76
+ },
77
+ '.cm-searchMatch.cm-searchMatch-selected': {
78
+ backgroundColor: 'rgba(255, 203, 107, 0.45)',
79
+ },
80
+ '.cm-selectionMatch': {
81
+ backgroundColor: 'rgba(230, 230, 230, 0.14)',
82
+ },
83
+ '.cm-panels': {
84
+ backgroundColor: 'var(--castle-inspector-input-bg)',
85
+ color: 'var(--castle-inspector-text)',
86
+ },
87
+ '.cm-panels.cm-panels-top': {
88
+ borderBottom: '1px solid var(--castle-inspector-divider)',
89
+ },
90
+ '.cm-panels.cm-panels-bottom': {
91
+ borderTop: '1px solid var(--castle-inspector-divider)',
92
+ },
93
+ '.cm-panel.cm-search input, .cm-panel.cm-search button, .cm-panel.cm-search label': {
94
+ color: 'var(--castle-inspector-text)',
95
+ },
96
+ '.cm-panel.cm-search input': {
97
+ backgroundColor: 'var(--castle-inspector-bg)',
98
+ border: '1px solid var(--castle-inspector-border)',
99
+ },
100
+ '.cm-tooltip': {
101
+ backgroundColor: 'var(--castle-inspector-input-bg)',
102
+ border: '1px solid var(--castle-inspector-border)',
103
+ color: 'var(--castle-inspector-text)',
104
+ },
105
+ '.cm-tooltip-autocomplete > ul > li[aria-selected]': {
106
+ backgroundColor: 'var(--castle-black)',
107
+ color: 'var(--castle-inspector-text)',
108
+ },
109
+ },
110
+ { dark: true },
111
+ );
112
+
113
+ export const castleHighlightStyle = HighlightStyle.define([
114
+ { tag: [tags.keyword, tags.modifier, tags.operatorKeyword], color: '#b3b9d1' },
115
+ { tag: [tags.controlKeyword, tags.moduleKeyword], color: '#b3b9d1' },
116
+ { tag: [tags.definitionKeyword, tags.self], color: '#b3b9d1' },
117
+ { tag: [tags.string, tags.special(tags.string), tags.regexp], color: '#c3e88d' },
118
+ { tag: tags.escape, color: '#f78c6c' },
119
+ { tag: [tags.number, tags.integer, tags.float, tags.bool, tags.null], color: '#f78c6c' },
120
+ { tag: [tags.literal, tags.atom, tags.constant(tags.variableName)], color: '#f78c6c' },
121
+ { tag: [tags.comment, tags.lineComment, tags.blockComment, tags.docComment], color: '#676e95', fontStyle: 'italic' },
122
+ { tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: '#82aaff' },
123
+ { tag: tags.definition(tags.variableName), color: '#e6e6e6' },
124
+ { tag: [tags.variableName, tags.labelName], color: '#e6e6e6' },
125
+ { tag: tags.propertyName, color: '#b2ccd6' },
126
+ { tag: [tags.typeName, tags.className, tags.namespace], color: '#ffcb6b' },
127
+ { tag: [tags.operator, tags.punctuation, tags.bracket, tags.angleBracket, tags.brace, tags.squareBracket], color: '#89ddff' },
128
+ { tag: [tags.tagName], color: '#f07178' },
129
+ { tag: [tags.attributeName], color: '#b3b9d1' },
130
+ { tag: [tags.attributeValue], color: '#c3e88d' },
131
+ { tag: tags.link, color: '#82aaff', textDecoration: 'underline' },
132
+ { tag: tags.strong, fontWeight: 'bold' },
133
+ { tag: tags.emphasis, fontStyle: 'italic' },
134
+ { tag: tags.invalid, color: '#e86a73' },
135
+ ]);
@@ -1,10 +1,16 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  const HISTORY_LIMIT = 50;
3
3
  // Text-undo/redo for file-backed editors. `text` is the canonical current
4
4
  // value; `onChange` writes the new value back. The hook owns the undo/redo
5
5
  // stacks; it never mutates `text` directly.
6
6
  export function useEditHistory(text, onChange) {
7
7
  const [history, setHistory] = useState({ undo: [], redo: [] });
8
+ // Mirror the latest stacks so undo/redo can read them synchronously in the
9
+ // event handler. onChange writes the PARENT's state, so it must never run
10
+ // inside a setHistory updater -- updaters execute in React's render phase,
11
+ // which would update the parent while this editor renders (setState-in-render).
12
+ const historyRef = useRef(history);
13
+ historyRef.current = history;
8
14
  function commit(nextText) {
9
15
  if (nextText === text) return;
10
16
  setHistory((current) => ({
@@ -20,26 +26,24 @@ export function useEditHistory(text, onChange) {
20
26
  }));
21
27
  }
22
28
  function undo() {
23
- setHistory((current) => {
24
- const previous = current.undo.at(-1);
25
- if (!previous) return current;
26
- onChange(previous);
27
- return {
28
- undo: current.undo.slice(0, -1),
29
- redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
30
- };
29
+ const current = historyRef.current;
30
+ const previous = current.undo.at(-1);
31
+ if (previous === undefined) return;
32
+ setHistory({
33
+ undo: current.undo.slice(0, -1),
34
+ redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
31
35
  });
36
+ onChange(previous);
32
37
  }
33
38
  function redo() {
34
- setHistory((current) => {
35
- const next = current.redo[0];
36
- if (!next) return current;
37
- onChange(next);
38
- return {
39
- undo: [...current.undo, text].slice(-HISTORY_LIMIT),
40
- redo: current.redo.slice(1),
41
- };
39
+ const current = historyRef.current;
40
+ const next = current.redo[0];
41
+ if (next === undefined) return;
42
+ setHistory({
43
+ undo: [...current.undo, text].slice(-HISTORY_LIMIT),
44
+ redo: current.redo.slice(1),
42
45
  });
46
+ onChange(next);
43
47
  }
44
48
  return {
45
49
  commit,
@@ -50,3 +54,26 @@ export function useEditHistory(text, onChange) {
50
54
  recordSnapshot,
51
55
  };
52
56
  }
57
+
58
+ export function useUndoRedoShortcuts(history) {
59
+ const historyRef = useRef(history);
60
+ historyRef.current = history;
61
+ useEffect(() => {
62
+ function onKeyDown(event) {
63
+ if (event.defaultPrevented || isEditableTarget(event.target)) return;
64
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
65
+ event.preventDefault();
66
+ if (event.shiftKey) historyRef.current.redo();
67
+ else historyRef.current.undo();
68
+ }
69
+ window.addEventListener('keydown', onKeyDown);
70
+ return () => window.removeEventListener('keydown', onKeyDown);
71
+ }, []);
72
+ }
73
+
74
+ function isEditableTarget(target) {
75
+ return (
76
+ target instanceof Element &&
77
+ !!target.closest('input, textarea, select, [contenteditable="true"]')
78
+ );
79
+ }
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { styles, useMobileSheet } from '../engine/ui';
3
+
4
+ // Inspector panel as a bottom sheet on compact viewports, docked on desktop.
5
+ // Used by the pixel-art (PxArt) editor. `inspectorOpen` drives visibility;
6
+ // tapping the grab handle toggles between the high and low snaps.
7
+ export function useInspectorSheet(inspectorOpen) {
8
+ const [snap, setSnap] = useState('high');
9
+ useEffect(() => {
10
+ if (inspectorOpen) setSnap('high');
11
+ }, [inspectorOpen]);
12
+ const effectiveSnap = inspectorOpen ? snap : 'hidden';
13
+ return useMobileSheet({
14
+ snap: effectiveSnap,
15
+ baseClassName: styles.inspector,
16
+ onTransition: (direction) => {
17
+ if (!inspectorOpen) return;
18
+ if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
19
+ else if (direction === 'down') setSnap('low');
20
+ else if (direction === 'up') setSnap('high');
21
+ },
22
+ });
23
+ }
@@ -0,0 +1,11 @@
1
+ // Map a pointer event over a pixel-art canvas element to integer cell
2
+ // coordinates within a `width x height` raster, or null when the pointer is
3
+ // outside the raster. Used by the PxArt editor, whose artboard stretches a
4
+ // native-resolution canvas to fit via CSS.
5
+ export function eventToCell(event, width, height) {
6
+ const rect = event.currentTarget.getBoundingClientRect();
7
+ const x = Math.floor(((event.clientX - rect.left) / rect.width) * width);
8
+ const y = Math.floor(((event.clientY - rect.top) / rect.height) * height);
9
+ if (x < 0 || y < 0 || x >= width || y >= height) return null;
10
+ return { x, y };
11
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { EditorHeader, IconButton, styles } from '../engine/ui';
3
+ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
4
+
5
+ // Shared shell state for the pixel editors: text undo/redo history and undo/redo
6
+ // keyboard shortcuts. Returns the `history` controller and `headerShell` for the
7
+ // header (undo/redo only — paint controls live in the artboard layout).
8
+ export function usePixelEditorShell(text, onChange) {
9
+ const history = useEditHistory(text, onChange);
10
+ useUndoRedoShortcuts(history);
11
+ const headerShell = { history };
12
+ return { history, headerShell };
13
+ }
14
+
15
+ function PixelEditorTools({ history }) {
16
+ return (
17
+ <>
18
+ <IconButton icon="undo" label="Undo" onClick={history.undo} disabled={!history.canUndo} />
19
+ <IconButton icon="redo" label="Redo" onClick={history.redo} disabled={!history.canRedo} />
20
+ </>
21
+ );
22
+ }
23
+
24
+ // The full editor header shared by the pixel editors: title/subtitle plus undo/redo.
25
+ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
26
+ return (
27
+ <EditorHeader
28
+ title={title}
29
+ subtitle={subtitle}
30
+ right={<PixelEditorTools history={shell.history} />}
31
+ onToggleFiles={chrome.onToggleFiles}
32
+ filesOpen={chrome.filesOpen}
33
+ headerHost={chrome.headerHost}
34
+ />
35
+ );
36
+ }
37
+
38
+ // The native-resolution canvas inside its artboard frame. `style` sets the fitted
39
+ // display size; `handlers` are the pointer callbacks the editor wires for tools.
40
+ export function PixelArtboard({ canvasRef, style, handlers }) {
41
+ return (
42
+ <div className={styles.drawingArtboard}>
43
+ <canvas
44
+ ref={canvasRef}
45
+ className={styles.drawingCanvas}
46
+ style={style}
47
+ onPointerDown={handlers.onPointerDown}
48
+ onPointerMove={handlers.onPointerMove}
49
+ onPointerUp={handlers.onPointerUp}
50
+ onPointerCancel={handlers.onPointerCancel}
51
+ onPointerLeave={handlers.onPointerLeave}
52
+ />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,45 @@
1
+ // Pure pixel-raster geometry for the PxArt tools. These helpers know nothing
2
+ // about pixel storage (flat array vs cell matrix) — they only emit integer
3
+ // coordinates, so the editor can apply its own write.
4
+
5
+ // Visit the integer offsets covering one brush "dab" of `size`, relative to the
6
+ // dab center. A diameter < 4 paints a solid square; 4+ uses a circular mask so
7
+ // larger brushes round off. `visit(dx, dy)` receives signed offsets.
8
+ export function forEachDab(size, visit) {
9
+ const diameter = Math.max(1, Math.round(size));
10
+ const offset = Math.floor(diameter / 2);
11
+ const useCircle = diameter >= 4;
12
+ const radius = diameter * 0.5;
13
+ const radiusSquared = radius * radius;
14
+ const centerOffset = (diameter - 1) * 0.5;
15
+ for (let ly = 0; ly < diameter; ly++) {
16
+ for (let lx = 0; lx < diameter; lx++) {
17
+ if (useCircle) {
18
+ const dx = lx - centerOffset;
19
+ const dy = ly - centerOffset;
20
+ if (dx * dx + dy * dy > radiusSquared) continue;
21
+ }
22
+ visit(lx - offset, ly - offset);
23
+ }
24
+ }
25
+ }
26
+
27
+ // Visit the deduped integer points along the segment from `from` to `to`,
28
+ // supersampled so a fast drag paints a continuous run. `visit(x, y)` receives
29
+ // rounded cell coordinates.
30
+ export function forEachLinePoint(from, to, visit) {
31
+ const dx = to.x - from.x;
32
+ const dy = to.y - from.y;
33
+ const distance = Math.hypot(dx, dy);
34
+ const steps = Math.max(1, Math.ceil(distance * 2));
35
+ let lastKey = '';
36
+ for (let step = 0; step <= steps; step++) {
37
+ const t = step / steps;
38
+ const x = Math.round(from.x + dx * t);
39
+ const y = Math.round(from.y + dy * t);
40
+ const key = `${x},${y}`;
41
+ if (key === lastKey) continue;
42
+ lastKey = key;
43
+ visit(x, y);
44
+ }
45
+ }