sprite-machine 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Portilla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # sprite-machine
2
+
3
+ Turn a **3×2 sheet of pixel-art face sprites** (left / front / top over
4
+ right / back / bottom) into a real, low-poly, textured **three.js mesh** —
5
+ or a **glTF 2.0 binary** any engine's importer reads. The chunky look is
6
+ carved into the geometry, not faked by a shader: 1 pixel = 1 voxel, with
7
+ 45° wedges smoothing every same-colour staircase and the colour riding a
8
+ nearest-sampled skin texture.
9
+
10
+ This is the engine behind [Sprite Machine](https://aportilla.github.io/sprite-machine/),
11
+ the System 7 desktop app that draws these sheets. Keep the sheet as the
12
+ source of truth and derive the model wherever you need it — at a build
13
+ step, at a server's startup, or in the browser, straight into a scene.
14
+
15
+ ```bash
16
+ npm install sprite-machine three
17
+ ```
18
+
19
+ `three` is a peer dependency: the mesh is a `THREE.Mesh` and the mesher
20
+ uses three's geometry utilities. Node 20.19+ / 22.12+.
21
+
22
+ ## The API
23
+
24
+ ```js
25
+ import { buildModel, modelToGlb } from 'sprite-machine';
26
+
27
+ // pixels in: {width, height, data} — an ImageData, or the same shape
28
+ const model = buildModel(sheet, { transforms });
29
+ // → { mesh, dims, triangles, warnings, unitsPerVoxel: 1 }
30
+ // a THREE.Mesh at ONE UNIT PER VOXEL, its skin the material's map;
31
+ // `transforms` is the document's per-view reorientation (optional)
32
+
33
+ const glb = modelToGlb(model, { name: 'car', voxelsPerMeter: 10 });
34
+ // → Uint8Array: one node, one mesh, one primitive, the skin embedded as
35
+ // a PNG behind a NEAREST sampler; `unlit: true` for KHR_materials_unlit
36
+ ```
37
+
38
+ Both are synchronous and pure. A three.js page uses `model.mesh` directly
39
+ and never writes a file; a Node process writes the glb. To run the build
40
+ off a main thread, wrap it in a worker or a child process.
41
+
42
+ ### In Node: a document PNG in
43
+
44
+ ```js
45
+ import { readSheet, sheetToGlb } from 'sprite-machine/node';
46
+
47
+ const { image, name, transforms } = readSheet(bytes); // pngjs decodes; the
48
+ // Title and sprite-machine:transforms chunks are read as the app reads them
49
+ const glb = sheetToGlb(bytes, { voxelsPerMeter: 10 }); // the three calls in one
50
+ ```
51
+
52
+ `sprite-machine/node` is the one entry with a decoder; the root entry takes
53
+ pixels and depends on nothing but three, so a browser bundle never sees
54
+ `pngjs`. In a browser, decode with `createImageBitmap` and a canvas and
55
+ hand `buildModel` the `ImageData`.
56
+
57
+ ### The CLI
58
+
59
+ ```
60
+ npx sprite-machine build sprites/*.png --out models/ [--voxels-per-meter 10] [--unlit]
61
+ ```
62
+
63
+ One `<name>.glb` per sheet — the Title chunk's name, else the file's — and a
64
+ line per file. The first failure exits non-zero.
65
+
66
+ ## The sheet
67
+
68
+ One PNG, six tiles in a fixed layout, empty tiles allowed (a face with no
69
+ view of its own is mirror-filled from its opposite):
70
+
71
+ ```
72
+ LEFT FRONT TOP
73
+ RIGHT BACK BOTTOM
74
+ ```
75
+
76
+ The tile size derives from the image (a 120×80 sheet is 40×40 tiles). Use
77
+ **square tiles**: a tile is a literal slice of the voxel lattice, so a
78
+ pixel's position inside its tile is its position in the object, and the
79
+ faces must be **registered** — a FRONT pixel is solid only where the SIDE
80
+ covers its row and the TOP covers its column. Sprites are hard pixel art:
81
+ every texel fully opaque or fully transparent.
82
+
83
+ World: `+x` right, `+y` up, `+z` toward the front. Draw FRONT and BACK
84
+ head-on and upright; RIGHT and LEFT as the sides with the front pointing
85
+ right and left; TOP and BOTTOM as plan views with the front at the top edge.
86
+ Per-tile `rot` / `flipX` / `flipY` transforms exist for sheets that don't
87
+ follow the convention.
88
+
89
+ The model's origin is the **lattice floor's centre**, Y up, winding CCW.
90
+ `voxelsPerMeter` is the reader's scale: at 10, a 40-voxel car is 4 m long.
91
+
92
+ ## The technique: multi-view visual-hull voxelization
93
+
94
+ 1. **Ingest** — each tile at native size into occupancy and packed-RGB
95
+ arrays. No auto-crop: registration is the whole point.
96
+ 2. **Reconcile dims** — one integer resolution per axis from the tile size
97
+ (`front → W×H`, `side → D×H`, `top → W×D`), views placed at identity.
98
+ 3. **Carve** — a voxel is solid iff it is inside every provided view's
99
+ silhouette: a boolean AND of extruded masks.
100
+ 4. **Surface** — keep the voxels with an exposed face, six-bit masks.
101
+ 5. **Colour** — each exposed face takes the colour of the view that sees it
102
+ first along its axis (depth-aware first hit), snapped to the sprite's
103
+ palette; faces no view sees fall through mirrored opposite → neighbour
104
+ average → dominant body colour.
105
+ 6. **Mesh** — exposed faces merge on occupancy alone into coplanar regions
106
+ (holes included), triangulated by earcut; 45° **wedges** fill every
107
+ concave unit-step notch whose two faces share a material, one quad per
108
+ slope block, the gable caps folded into the walls; a lattice-exact
109
+ T-junction repair keeps it watertight. The colour is the **skin**: a
110
+ chart per multi-colour region, a swatch per colour, packed
111
+ deterministically onto a power-of-two sheet and sampled nearest.
112
+
113
+ The wedge gate is strict and local: paint a riser and its tread the same
114
+ colour and the corner ramps; paint them differently and it stays a crisp
115
+ step. That is the author's control over every slope.
116
+
117
+ ## License
118
+
119
+ MIT © Adam Portilla
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // The CLI: sprite sheets in, glb files out.
4
+ //
5
+ // sprite-machine build <sheet.png>... --out <dir> [--voxels-per-meter N] [--unlit]
6
+ //
7
+ // One <name>.glb per sheet under --out — the Title chunk's name, else the
8
+ // file's — and a line per file naming it, its triangles and its bytes. The
9
+ // first failure exits non-zero. A thin shell over `sprite-machine/node`:
10
+ // nothing here builds anything.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import { parseArgs } from 'node:util';
14
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
15
+ import { basename, extname, join } from 'node:path';
16
+ import { readSheet, sheetToGlb } from '../src/node.js';
17
+
18
+ const USAGE = `usage: sprite-machine build <sheet.png>... --out <dir> [--voxels-per-meter N] [--unlit]
19
+
20
+ build write one <name>.glb per sheet into --out (the Title chunk's name,
21
+ else the file's); --voxels-per-meter is the reader's scale (10: a
22
+ 40-voxel car is 4 m long); --unlit writes KHR_materials_unlit.
23
+ `;
24
+
25
+ function fail(message) {
26
+ process.stderr.write(`sprite-machine: ${message}\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ const { values, positionals } = parseArgs({
31
+ allowPositionals: true,
32
+ options: {
33
+ out: { type: 'string', short: 'o' },
34
+ 'voxels-per-meter': { type: 'string' },
35
+ unlit: { type: 'boolean', default: false },
36
+ help: { type: 'boolean', short: 'h', default: false },
37
+ },
38
+ });
39
+
40
+ if (values.help || positionals.length === 0) {
41
+ process.stdout.write(USAGE);
42
+ process.exit(values.help ? 0 : 1);
43
+ }
44
+
45
+ const [command, ...sheets] = positionals;
46
+ if (command !== 'build') fail(`unknown command "${command}"\n\n${USAGE}`);
47
+ if (sheets.length === 0) fail('no sheets given.');
48
+ if (!values.out) fail('--out <dir> is required.');
49
+
50
+ let voxelsPerMeter;
51
+ if (values['voxels-per-meter'] !== undefined) {
52
+ voxelsPerMeter = Number(values['voxels-per-meter']);
53
+ if (!Number.isFinite(voxelsPerMeter) || voxelsPerMeter <= 0)
54
+ fail(
55
+ `--voxels-per-meter must be a positive number, got "${values['voxels-per-meter']}".`
56
+ );
57
+ }
58
+
59
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
60
+ const generator = `sprite-machine ${pkg.version}`;
61
+
62
+ mkdirSync(values.out, { recursive: true });
63
+ for (const file of sheets) {
64
+ let bytes;
65
+ try {
66
+ bytes = new Uint8Array(readFileSync(file));
67
+ } catch (err) {
68
+ fail(`cannot read ${file}: ${err.message}`);
69
+ }
70
+ let name;
71
+ let glb;
72
+ try {
73
+ name = readSheet(bytes).name ?? basename(file, extname(file));
74
+ glb = sheetToGlb(bytes, { name, voxelsPerMeter, unlit: values.unlit, generator });
75
+ } catch (err) {
76
+ fail(`${file}: ${err.message}`);
77
+ }
78
+ const out = join(values.out, `${name}.glb`);
79
+ writeFileSync(out, glb);
80
+ process.stdout.write(`${out}\t${name}\t${glb.length} bytes\n`);
81
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "sprite-machine",
3
+ "version": "0.1.0",
4
+ "description": "Turn a 3×2 sheet of pixel-art face sprites into a low-poly, textured three.js mesh or a glTF binary — the engine behind Sprite Machine.",
5
+ "license": "MIT",
6
+ "author": "Adam Portilla",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/aportilla/sprite-machine.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "homepage": "https://aportilla.github.io/sprite-machine/",
14
+ "keywords": [
15
+ "pixel-art",
16
+ "sprite",
17
+ "voxel",
18
+ "visual-hull",
19
+ "low-poly",
20
+ "three",
21
+ "threejs",
22
+ "gltf",
23
+ "glb"
24
+ ],
25
+ "engines": {
26
+ "node": "^20.19.0 || >=22.12.0"
27
+ },
28
+ "sideEffects": false,
29
+ "exports": {
30
+ ".": "./src/index.js",
31
+ "./node": "./src/node.js"
32
+ },
33
+ "bin": {
34
+ "sprite-machine": "./bin/sprite-machine.mjs"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "bin",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "test": "node --test test/*.test.mjs",
44
+ "typecheck": "tsc -p tsconfig.json"
45
+ },
46
+ "peerDependencies": {
47
+ "three": "^0.185.0"
48
+ },
49
+ "dependencies": {
50
+ "pngjs": "^7.0.0"
51
+ }
52
+ }
package/src/atlas.js ADDED
@@ -0,0 +1,367 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Atlas slicing: cut a packed sprite sheet into the six named face tiles.
3
+ //
4
+ // Pure (operates on {width,height,data}, returns the same shape) so it's
5
+ // Node-testable and its output drops straight into buildVoxels(). The tile size
6
+ // is derived from the image dimensions and the layout grid unless given
7
+ // explicitly: a 3x2 layout on a 120x80 sheet => 40x40 tiles.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import { VIEW_NAMES, VIEW_IMAGE_AXES } from './views.js';
11
+
12
+ // Grid of view names (row-major). null = an intentionally empty cell.
13
+ export const DEFAULT_ATLAS_LAYOUT = [
14
+ ['left', 'front', 'top'],
15
+ ['right', 'back', 'bottom'],
16
+ ];
17
+
18
+ // Allowed tile-dimension range for the in-app resize control (integers). A tile
19
+ // maps 1:1 onto a lattice axis, so this is also the voxel grid's per-axis range —
20
+ // and the carve/colorize pass is a synchronous O(n³) walk on the main thread. The
21
+ // ceiling is 64 (a 64³ = 262 k-voxel grid still rebuilds live per stroke); larger
22
+ // tiles (a 256³ = 16.7 M-voxel carve) froze the tab for seconds. clampTile pins
23
+ // both the stepper and the ?tile dev hook into this range.
24
+ export const TILE_MIN = 1;
25
+ export const TILE_MAX = 64;
26
+ export const clampTile = (n) =>
27
+ Math.max(TILE_MIN, Math.min(TILE_MAX, Math.round(Number(n) || 0)));
28
+
29
+ export function layoutSize(layout) {
30
+ const rows = layout.length;
31
+ const cols = Math.max(...layout.map((r) => r.length));
32
+ return { rows, cols };
33
+ }
34
+
35
+ /**
36
+ * @param {number} width @param {number} height
37
+ * @param {string[][]} layout
38
+ * @returns {{tileW:number, tileH:number, cols:number, rows:number}}
39
+ */
40
+ export function deriveTileSize(width, height, layout = DEFAULT_ATLAS_LAYOUT) {
41
+ const { rows, cols } = layoutSize(layout);
42
+ return { tileW: width / cols, tileH: height / rows, cols, rows };
43
+ }
44
+
45
+ function subTile(img, sx, sy, w, h) {
46
+ const out = new Uint8ClampedArray(w * h * 4);
47
+ for (let y = 0; y < h; y++) {
48
+ for (let x = 0; x < w; x++) {
49
+ const s = ((sy + y) * img.width + (sx + x)) * 4;
50
+ const d = (y * w + x) * 4;
51
+ out[d] = img.data[s];
52
+ out[d + 1] = img.data[s + 1];
53
+ out[d + 2] = img.data[s + 2];
54
+ out[d + 3] = img.data[s + 3];
55
+ }
56
+ }
57
+ return { width: w, height: h, data: out };
58
+ }
59
+
60
+ // Is a tile fully transparent? (alpha 0 everywhere — stray RGB under alpha 0 is
61
+ // ignored, matching the editor's hard-pixel rule). Exported as the single "is this
62
+ // empty?" predicate so main.js's applyTileEdit doesn't roll its own copy.
63
+ export const isBlank = (tile) => {
64
+ for (let i = 3; i < tile.data.length; i += 4) if (tile.data[i] !== 0) return false;
65
+ return true;
66
+ };
67
+
68
+ /**
69
+ * The tight bounding box of a tile's non-transparent texels — the same
70
+ * alpha!==0 rule as isBlank, so the two can never disagree about emptiness:
71
+ * contentBounds(t) === null exactly when isBlank(t). The icon generator trims
72
+ * to this box so a sprite fills its icon instead of shipping the tile's
73
+ * transparent margin.
74
+ * @param {{width:number,height:number,data:ArrayLike<number>}} tile
75
+ * @returns {{x:number,y:number,width:number,height:number}|null}
76
+ */
77
+ export function contentBounds(tile) {
78
+ const { width: w, height: h, data } = tile;
79
+ let x0 = w;
80
+ let y0 = h;
81
+ let x1 = -1;
82
+ let y1 = -1;
83
+ for (let y = 0; y < h; y++) {
84
+ for (let x = 0; x < w; x++) {
85
+ if (data[(y * w + x) * 4 + 3] === 0) continue;
86
+ if (x < x0) x0 = x;
87
+ if (x > x1) x1 = x;
88
+ if (y < y0) y0 = y;
89
+ y1 = y;
90
+ }
91
+ }
92
+ if (x1 < 0) return null;
93
+ return { x: x0, y: y0, width: x1 - x0 + 1, height: y1 - y0 + 1 };
94
+ }
95
+
96
+ /**
97
+ * Validate an ImageData-like sheet at an ingestion boundary: finite positive
98
+ * dimensions and a data buffer long enough for width*height RGBA texels. Returns
99
+ * an error string (surfaced to the user), or null when the sheet is usable — so
100
+ * sliceAtlas/resizeAtlas downstream can trust their input's shape.
101
+ * @param {{width:number,height:number,data:ArrayLike<number>}|null|undefined} img
102
+ * @returns {string|null}
103
+ */
104
+ export function validateSheet(img) {
105
+ if (!img || !Number.isFinite(img.width) || !Number.isFinite(img.height)) {
106
+ return 'Sprite sheet has no valid dimensions.';
107
+ }
108
+ if (!(img.width > 0) || !(img.height > 0)) {
109
+ return `Sprite sheet is empty (${img.width}×${img.height}px).`;
110
+ }
111
+ const need = img.width * img.height * 4;
112
+ if (!img.data || img.data.length < need) {
113
+ return (
114
+ `Sprite sheet data is too short: got ${img.data ? img.data.length : 0} bytes, ` +
115
+ `need ${need} for a ${img.width}×${img.height}px sheet.`
116
+ );
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * @param {{width:number,height:number,data:ArrayLike<number>}} img
123
+ * @param {{layout?:string[][], tileW?:number, tileH?:number}} [opts]
124
+ * @returns {{views:Record<string,{width,height,data}|null>,
125
+ * tileW:number, tileH:number, cols:number, rows:number,
126
+ * warnings:string[]}}
127
+ */
128
+ export function sliceAtlas(img, opts = {}) {
129
+ const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
130
+ const {
131
+ cols,
132
+ rows,
133
+ tileW: autoW,
134
+ tileH: autoH,
135
+ } = deriveTileSize(img.width, img.height, layout);
136
+ const warnings = [];
137
+
138
+ // Fill each dimension independently so a lone tileW/tileH override survives.
139
+ const tileW = Math.round(opts.tileW || autoW);
140
+ const tileH = Math.round(opts.tileH || autoH);
141
+
142
+ /** @type {Record<string, {width:number,height:number,data:ArrayLike<number>}|null>} */
143
+ const views = {};
144
+
145
+ // Bail on a fundamentally unusable sheet rather than emitting garbage tiles.
146
+ if (!(img.width > 0 && img.height > 0) || tileW < 1 || tileH < 1) {
147
+ warnings.push(
148
+ `Atlas is unusable: a ${img.width}×${img.height}px sheet split into ` +
149
+ `${cols}×${rows} gives ${tileW}×${tileH}px tiles. Check the image and tile size.`
150
+ );
151
+ return { views, tileW, tileH, cols, rows, warnings };
152
+ }
153
+
154
+ if (cols * tileW !== img.width || rows * tileH !== img.height) {
155
+ warnings.push(
156
+ `Layout ${cols}x${rows} at ${tileW}x${tileH} tiles = ` +
157
+ `${cols * tileW}x${rows * tileH}px, but image is ${img.width}x${img.height}px. ` +
158
+ `Tiles are read from the top-left; check tile size / layout.`
159
+ );
160
+ }
161
+
162
+ for (let r = 0; r < rows; r++) {
163
+ for (let c = 0; c < (layout[r] || []).length; c++) {
164
+ const name = layout[r][c];
165
+ if (!name) continue;
166
+ if (!VIEW_NAMES.includes(name)) {
167
+ warnings.push(`Unknown view "${name}" in layout; ignored.`);
168
+ continue;
169
+ }
170
+ const sx = c * tileW;
171
+ const sy = r * tileH;
172
+ if (sx + tileW > img.width || sy + tileH > img.height) continue;
173
+ const tile = subTile(img, sx, sy, tileW, tileH);
174
+ views[name] = isBlank(tile) ? null : tile;
175
+ }
176
+ }
177
+ return { views, tileW, tileH, cols, rows, warnings };
178
+ }
179
+
180
+ /**
181
+ * Inverse of subTile: copy a tile's pixels into a sheet at (sx, sy), in place.
182
+ * Mutates `sheet.data` (does NOT change the ImageData identity, so a canonical
183
+ * `state.atlasImage` reference stays valid). Writes only within the tile's rect
184
+ * and clips to the sheet bounds, so remainder pixels of a non-divisible sheet
185
+ * are left untouched.
186
+ * @param {{width:number,height:number,data:Uint8ClampedArray|number[]}} sheet
187
+ * @param {{width:number,height:number,data:ArrayLike<number>}} tile
188
+ * @param {number} sx @param {number} sy
189
+ * @returns {{width:number,height:number,data:Uint8ClampedArray|number[]}}
190
+ */
191
+ export function blitTile(sheet, tile, sx, sy) {
192
+ const { width: W, height: H } = sheet;
193
+ const { width: w, height: h, data: td } = tile;
194
+ for (let y = 0; y < h; y++) {
195
+ const dy = sy + y;
196
+ if (dy < 0 || dy >= H) continue;
197
+ for (let x = 0; x < w; x++) {
198
+ const dx = sx + x;
199
+ if (dx < 0 || dx >= W) continue;
200
+ const s = (y * w + x) * 4;
201
+ const d = (dy * W + dx) * 4;
202
+ sheet.data[d] = td[s];
203
+ sheet.data[d + 1] = td[s + 1];
204
+ sheet.data[d + 2] = td[s + 2];
205
+ sheet.data[d + 3] = td[s + 3];
206
+ }
207
+ }
208
+ return sheet;
209
+ }
210
+
211
+ /**
212
+ * Core tile-pixel placement: copy `tile` into a fresh (newW×newH) buffer with its
213
+ * top-left corner at (offX, offY), padding the uncovered cells transparent and
214
+ * clipping anything outside (so a NEGATIVE offset crops that edge). Pure — returns a
215
+ * fresh tile. The general primitive under both corner-anchored `resizeTile` and the
216
+ * centered whole-atlas resize.
217
+ * @param {{width:number,height:number,data:ArrayLike<number>}} tile
218
+ * @param {number} newW @param {number} newH
219
+ * @param {number} offX @param {number} offY
220
+ * @returns {{width:number,height:number,data:Uint8ClampedArray}}
221
+ */
222
+ export function resizeTileTo(tile, newW, newH, offX, offY) {
223
+ const { width: w, height: h, data: sd } = tile;
224
+ const out = new Uint8ClampedArray(newW * newH * 4);
225
+ for (let sy = 0; sy < h; sy++) {
226
+ const dy = sy + offY;
227
+ if (dy < 0 || dy >= newH) continue; // clipped when shrinking / negative offset
228
+ for (let sx = 0; sx < w; sx++) {
229
+ const dx = sx + offX;
230
+ if (dx < 0 || dx >= newW) continue;
231
+ const s = (sy * w + sx) * 4;
232
+ const d = (dy * newW + dx) * 4;
233
+ out[d] = sd[s];
234
+ out[d + 1] = sd[s + 1];
235
+ out[d + 2] = sd[s + 2];
236
+ out[d + 3] = sd[s + 3];
237
+ }
238
+ }
239
+ return { width: newW, height: newH, data: out };
240
+ }
241
+
242
+ /**
243
+ * Resize ONE tile's pixels to (newW,newH), anchoring the existing art at a chosen
244
+ * corner and padding the opposite edges with transparency (or cropping them when
245
+ * shrinking). Pure — returns a fresh tile.
246
+ *
247
+ * The anchor is what keeps a resize alignment-safe: a tile is a literal lattice
248
+ * slice, so to hold a texel's world position we must add/remove lattice lines at
249
+ * the FAR end of each axis and leave the anchored end fixed. `anchorRight`/
250
+ * `anchorBottom` pick which image edge stays put (the rest pad/crop). A thin wrapper
251
+ * over resizeTileTo — a corner is just the offset that puts all pad/crop on one end.
252
+ * @param {{width:number,height:number,data:ArrayLike<number>}} tile
253
+ * @param {number} newW @param {number} newH
254
+ * @param {boolean} anchorRight @param {boolean} anchorBottom
255
+ * @returns {{width:number,height:number,data:Uint8ClampedArray}}
256
+ */
257
+ export function resizeTile(tile, newW, newH, anchorRight, anchorBottom) {
258
+ const offX = anchorRight ? newW - tile.width : 0; // all pad/crop lands on the far end
259
+ const offY = anchorBottom ? newH - tile.height : 0;
260
+ return resizeTileTo(tile, newW, newH, offX, offY);
261
+ }
262
+
263
+ /**
264
+ * How many lattice lines to add (+) or remove (−) at the world-LOW (origin) end of
265
+ * an axis to keep the art CENTERED as a tile resizes; the rest of the change lands
266
+ * at the far end. The odd leftover of an odd-sized change is biased by the parity of
267
+ * the NEW size, so consecutive ±1 steps alternate which end moves and the art can't
268
+ * drift into a corner over repeated clicks (an even change always splits evenly, and
269
+ * a typed jump divides the difference as evenly as it can).
270
+ * splitLow(4,5)=1 splitLow(5,6)=0 grow: alternate the extra line
271
+ * splitLow(4,6)=1 even grow: one line each end
272
+ * splitLow(5,4)=0 splitLow(4,3)=−1 shrink: alternate the cropped line
273
+ * @param {number} oldSize @param {number} newSize
274
+ * @returns {number}
275
+ */
276
+ export function splitLow(oldSize, newSize) {
277
+ const delta = newSize - oldSize;
278
+ const half = Math.trunc(delta / 2); // even split, toward zero
279
+ const rem = delta - 2 * half; // 0 (even delta) or ±1 (odd delta)
280
+ return half + (rem && newSize % 2 === 1 ? rem : 0);
281
+ }
282
+
283
+ /**
284
+ * Resize the whole 3x2 sheet to new per-tile dimensions. Each cell's tile is placed
285
+ * with an offset derived from its view's image-axis flips (VIEW_IMAGE_AXES) so every
286
+ * face sharing a world axis shifts IDENTICALLY (registration held) — the padding just
287
+ * lands at a different image edge per face.
288
+ *
289
+ * `opts.anchor` picks how the size change is distributed on each axis:
290
+ * 'origin' (default) — keep the origin line fixed, grow/shrink only at the far edge.
291
+ * A square resize is fully registration-safe AND keeps the object ground-rested
292
+ * (y=0 pinned) at its exact lattice coords. Used by the pipeline; the primitive's
293
+ * stable default (also what the byte-identical-pin test locks).
294
+ * 'center' — split the change around the art on ALL axes (see splitLow) so it stays
295
+ * centered as the tile grows/shrinks. Still registration-safe for a square resize
296
+ * (the whole solid just TRANSLATES by the per-axis pad), but it no longer pins y=0,
297
+ * so a ground-rested sprite floats up as the tile grows. This is what the editor's
298
+ * tile stepper uses (the author asked for centered artwork).
299
+ *
300
+ * A PROPORTIONAL (square, newTileW===newTileH) resize keeps registration for either
301
+ * anchor. An ASYMMETRIC resize (newTileW!==newTileH) intentionally falls OUT of
302
+ * registration — a uniform 3x2 atlas has only two tile dimensions but three lattice
303
+ * axes, and the depth axis nz is the side tile's WIDTH and the top tile's HEIGHT at
304
+ * once, so W!=H gives reconcileDims two disagreeing nz candidates: the carve shears
305
+ * the shared depth axis (dropping voxels) and warns. That trade-off is accepted — the
306
+ * editor lets W and H move independently. Pure — returns a fresh sheet.
307
+ * @param {{width:number,height:number,data:ArrayLike<number>}} img
308
+ * @param {number} newTileW @param {number} newTileH
309
+ * @param {{layout?:string[][], anchor?:'origin'|'center'}} [opts]
310
+ * @returns {{width:number,height:number,data:Uint8ClampedArray}}
311
+ */
312
+ export function resizeAtlas(img, newTileW, newTileH, opts = {}) {
313
+ const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
314
+ const center = opts.anchor === 'center';
315
+ const {
316
+ cols,
317
+ rows,
318
+ tileW: ow,
319
+ tileH: oh,
320
+ } = deriveTileSize(img.width, img.height, layout);
321
+ const oldW = Math.round(ow);
322
+ const oldH = Math.round(oh);
323
+ const dW = newTileW - oldW;
324
+ const dH = newTileH - oldH;
325
+ // Per world axis: how many lattice lines to add/crop at the LOW (origin) end.
326
+ // 'origin' leaves it 0 (all change at the far end); 'center' splits around the art.
327
+ const padLowCol = center ? splitLow(oldW, newTileW) : 0;
328
+ const padLowRow = center ? splitLow(oldH, newTileH) : 0;
329
+ const W = cols * newTileW;
330
+ const H = rows * newTileH;
331
+ const sheet = { width: W, height: H, data: new Uint8ClampedArray(W * H * 4) };
332
+ for (let r = 0; r < rows; r++) {
333
+ for (let c = 0; c < (layout[r] || []).length; c++) {
334
+ const name = layout[r][c];
335
+ if (!name || !VIEW_NAMES.includes(name)) continue;
336
+ const sx = c * oldW;
337
+ const sy = r * oldH;
338
+ if (sx + oldW > img.width || sy + oldH > img.height) continue; // guard a ragged sheet
339
+ const src = subTile(img, sx, sy, oldW, oldH);
340
+ const { colFlip, rowFlip } = VIEW_IMAGE_AXES[name];
341
+ // Map the world-low pad to this face's image corner: a flipped image axis has its
342
+ // low pixel at the world-HIGH end, so it takes the complementary (far) pad. This
343
+ // keeps every face on a shared axis moving together. With 'origin' (padLow=0)
344
+ // this is exactly the old resizeTile(colFlip, rowFlip) corner anchor.
345
+ const offX = colFlip ? dW - padLowCol : padLowCol;
346
+ const offY = rowFlip ? dH - padLowRow : padLowRow;
347
+ const resized = resizeTileTo(src, newTileW, newTileH, offX, offY);
348
+ blitTile(sheet, resized, c * newTileW, r * newTileH);
349
+ }
350
+ }
351
+ return sheet;
352
+ }
353
+
354
+ /**
355
+ * Locate a view's grid cell in the layout without duplicating the layout scan.
356
+ * @param {string} name
357
+ * @param {string[][]} [layout]
358
+ * @returns {{r:number, c:number} | null}
359
+ */
360
+ export function cellOf(name, layout = DEFAULT_ATLAS_LAYOUT) {
361
+ for (let r = 0; r < layout.length; r++) {
362
+ for (let c = 0; c < layout[r].length; c++) {
363
+ if (layout[r][c] === name) return { r, c };
364
+ }
365
+ }
366
+ return null;
367
+ }