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 +21 -0
- package/README.md +119 -0
- package/bin/sprite-machine.mjs +81 -0
- package/package.json +52 -0
- package/src/atlas.js +367 -0
- package/src/carve.js +198 -0
- package/src/colorize.js +228 -0
- package/src/constants.js +20 -0
- package/src/diag.js +68 -0
- package/src/faces.js +69 -0
- package/src/gltf.js +256 -0
- package/src/index.js +85 -0
- package/src/ingest.js +150 -0
- package/src/mesh-util.js +65 -0
- package/src/model.js +104 -0
- package/src/node.js +76 -0
- package/src/pipeline.js +56 -0
- package/src/png-chunks.js +240 -0
- package/src/png-encode.js +98 -0
- package/src/regions.js +298 -0
- package/src/skin.js +242 -0
- package/src/t-junction.js +160 -0
- package/src/views.js +244 -0
- package/src/wedge-mesh.js +441 -0
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
|
+
}
|