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/src/node.js ADDED
@@ -0,0 +1,76 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Node adapter (`sprite-machine/node`): a document PNG's bytes in, the
3
+ // pixels and the metadata the app would read from it out — and, in one
4
+ // call, the glb. The engine itself takes pixels and never decodes a file
5
+ // (index.js is environment-free); this is the one place a decoder lives,
6
+ // `pngjs`, which reads every PNG a sprite editor exports — indexed, 16-bit,
7
+ // interlaced — as 8-bit RGBA, the ImageData shape the pipeline consumes.
8
+ // The chunks are read best-effort, as the app's loader reads them: a torn
9
+ // chunk list costs the name and the transforms, never the pixels.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { PNG } from 'pngjs';
13
+ import { isPng, readTextChunks } from './png-chunks.js';
14
+ import { buildModel, modelToGlb } from './model.js';
15
+
16
+ const TRANSFORMS_CHUNK = 'sprite-machine:transforms';
17
+
18
+ /**
19
+ * Decode a document PNG.
20
+ * @param {Uint8Array} bytes
21
+ * @returns {{
22
+ * image: {width:number, height:number, data:Uint8ClampedArray},
23
+ * name: string|null,
24
+ * transforms: Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}>,
25
+ * chunks: Record<string, string>,
26
+ * }} the pixels; the Title chunk's name; the transforms chunk, parsed; and
27
+ * every text chunk verbatim (the ring settings ride here, unparsed — a
28
+ * viewing choice, the app's business)
29
+ * @throws when the bytes are not a PNG, or pngjs cannot decode them
30
+ */
31
+ export function readSheet(bytes) {
32
+ if (!isPng(bytes)) throw new Error('readSheet: not a PNG.');
33
+ const png = PNG.sync.read(
34
+ Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
35
+ );
36
+ const image = {
37
+ width: png.width,
38
+ height: png.height,
39
+ data: new Uint8ClampedArray(png.data.buffer, png.data.byteOffset, png.data.length),
40
+ };
41
+ /** @type {Record<string, string>} */
42
+ let chunks = {};
43
+ try {
44
+ chunks = readTextChunks(bytes);
45
+ } catch {
46
+ chunks = {};
47
+ }
48
+ /** @type {Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}>} */
49
+ let transforms = {};
50
+ if (chunks[TRANSFORMS_CHUNK]) {
51
+ try {
52
+ transforms = JSON.parse(chunks[TRANSFORMS_CHUNK]);
53
+ } catch {
54
+ transforms = {};
55
+ }
56
+ }
57
+ return { image, name: chunks.Title ?? null, transforms, chunks };
58
+ }
59
+
60
+ /**
61
+ * A document PNG's bytes → its glb: `readSheet`, `buildModel`, `modelToGlb`.
62
+ * @param {Uint8Array} bytes
63
+ * @param {{name?: string, voxelsPerMeter?: number, unlit?: boolean, generator?: string}} [opts]
64
+ * `name` overrides the Title chunk's; with neither, the model is 'sprite'
65
+ * @returns {Uint8Array} the .glb file
66
+ */
67
+ export function sheetToGlb(bytes, { name, voxelsPerMeter, unlit, generator } = {}) {
68
+ const sheet = readSheet(bytes);
69
+ const model = buildModel(sheet.image, { transforms: sheet.transforms });
70
+ return modelToGlb(model, {
71
+ name: name ?? sheet.name ?? 'sprite',
72
+ voxelsPerMeter,
73
+ unlit,
74
+ generator,
75
+ });
76
+ }
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The pure sprite -> voxel pipeline. No THREE, no DOM: fully testable in Node.
3
+ // Input: a map of view name -> ImageData-like { width, height, data(RGBA) }.
4
+ // Output: the voxel grid, surface, and per-face colors, ready for meshing.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { ingestSprite, applyTransform } from './ingest.js';
8
+ import { reconcileDims, gridViews, carve, extractSurface } from './carve.js';
9
+ import { colorize } from './colorize.js';
10
+ import { VIEW_NAMES } from './views.js';
11
+
12
+ /**
13
+ * @param {Record<string, {width:number,height:number,data:ArrayLike<number>}|null>} rawViews
14
+ * @param {{transforms?:Record<string,{rot?:number,flipX?:boolean,flipY?:boolean}>,
15
+ * mirror?:{x?:boolean,y?:boolean,z?:boolean}}} [opts]
16
+ */
17
+ export function buildVoxels(rawViews, opts = {}) {
18
+ const transforms = opts.transforms || {};
19
+ /** @type {Record<string, any>} */
20
+ const ingested = {};
21
+ for (const name of VIEW_NAMES) {
22
+ let img = rawViews[name];
23
+ if (!img) continue;
24
+ if (transforms[name]) img = applyTransform(img, transforms[name]);
25
+ const v = ingestSprite(img);
26
+ if (v) ingested[name] = v;
27
+ }
28
+
29
+ const { dims, warnings } = reconcileDims(ingested);
30
+ if (Object.keys(ingested).length === 0) {
31
+ warnings.unshift('No usable views: every provided sprite was empty or missing.');
32
+ }
33
+ const gviews = gridViews(ingested, dims);
34
+ const solid = carve(gviews, dims);
35
+ // extractSurface already visits + gates every voxel on `solid`, so it returns
36
+ // solidCount too (no separate full-grid pass needed here).
37
+ const { surfaceMask, count, solidCount } = extractSurface(solid, dims);
38
+ const { faceColor, palette } = colorize(solid, surfaceMask, gviews, dims, opts);
39
+ if (palette.length === 0 && Object.keys(ingested).length > 0) {
40
+ warnings.push('No opaque pixels found — every provided sprite is fully transparent.');
41
+ }
42
+
43
+ return {
44
+ dims,
45
+ solid,
46
+ surfaceMask,
47
+ surfaceCount: count,
48
+ solidCount,
49
+ faceColor,
50
+ palette,
51
+ warnings,
52
+ gviews,
53
+ ingested,
54
+ providedViews: Object.keys(ingested),
55
+ };
56
+ }
@@ -0,0 +1,240 @@
1
+ // ---------------------------------------------------------------------------
2
+ // PNG chunk surgery — the document format's foundation (a Sprite Machine
3
+ // document IS one .png with its metadata in standard text chunks). Pure
4
+ // typed-array code, zero deps, Node-tested like the rest of lib/.
5
+ //
6
+ // A PNG is an 8-byte signature plus a chunk list; each chunk is
7
+ // length(4, big-endian) | type(4, ASCII) | data(length) | crc(4, over
8
+ // type+data). Ancillary text chunks (tEXt: Latin-1, iTXt: UTF-8) are
9
+ // standard, ignored by every decoder, and legal anywhere between IHDR and
10
+ // IEND — we splice ours right after IHDR (before the first IDAT), so a
11
+ // metadata read never has to scan past the pixel data.
12
+ //
13
+ // Reading and writing both handle tEXt AND iTXt; the writer picks tEXt when
14
+ // the text survives Latin-1 and iTXt (UTF-8, uncompressed) otherwise, so a
15
+ // plain ASCII Software tag stays the classic chunk while an emoji title still
16
+ // round-trips. Compressed text chunks (zTXt / iTXt with the compression flag)
17
+ // are passed through untouched but not decoded — nothing we write uses them.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** The 8-byte PNG signature. */
21
+ export const PNG_SIGNATURE = Uint8Array.of(
22
+ 0x89,
23
+ 0x50,
24
+ 0x4e,
25
+ 0x47,
26
+ 0x0d,
27
+ 0x0a,
28
+ 0x1a,
29
+ 0x0a
30
+ );
31
+
32
+ /** @param {Uint8Array} bytes */
33
+ export function isPng(bytes) {
34
+ if (bytes.length < PNG_SIGNATURE.length) return false;
35
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
36
+ if (bytes[i] !== PNG_SIGNATURE[i]) return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ // --- CRC32 (the PNG polynomial), table built once ---------------------------
42
+ const CRC_TABLE = (() => {
43
+ const t = new Uint32Array(256);
44
+ for (let n = 0; n < 256; n++) {
45
+ let c = n;
46
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
47
+ t[n] = c >>> 0;
48
+ }
49
+ return t;
50
+ })();
51
+
52
+ /** CRC32 of a byte range (PNG flavor: over the chunk's type + data). @param {Uint8Array} bytes */
53
+ export function crc32(bytes) {
54
+ let c = 0xffffffff;
55
+ for (let i = 0; i < bytes.length; i++) {
56
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
57
+ }
58
+ return (c ^ 0xffffffff) >>> 0;
59
+ }
60
+
61
+ const readU32 = (b, i) =>
62
+ ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
63
+
64
+ const typeAt = (b, i) => String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
65
+
66
+ /**
67
+ * Parse the chunk list. Each entry's `data` is a SUBARRAY view into `bytes`
68
+ * (no copies); `offset` is the chunk's start (the length field) and `end` one
69
+ * past its CRC, so `bytes.subarray(offset, end)` is the whole chunk verbatim.
70
+ * Throws on a non-PNG signature or a truncated chunk (a torn file should fail
71
+ * loudly, not yield half a list).
72
+ *
73
+ * @param {Uint8Array} bytes
74
+ * @returns {{type:string, data:Uint8Array, offset:number, end:number}[]}
75
+ */
76
+ export function readChunks(bytes) {
77
+ if (!isPng(bytes)) throw new Error('png-chunks: not a PNG (bad signature)');
78
+ const chunks = [];
79
+ let i = PNG_SIGNATURE.length;
80
+ while (i < bytes.length) {
81
+ if (i + 8 > bytes.length) throw new Error('png-chunks: truncated chunk header');
82
+ const length = readU32(bytes, i);
83
+ const end = i + 8 + length + 4;
84
+ if (end > bytes.length) throw new Error('png-chunks: truncated chunk data');
85
+ const type = typeAt(bytes, i + 4);
86
+ chunks.push({ type, data: bytes.subarray(i + 8, i + 8 + length), offset: i, end });
87
+ i = end;
88
+ if (type === 'IEND') break;
89
+ }
90
+ return chunks;
91
+ }
92
+
93
+ // --- text codecs ------------------------------------------------------------
94
+ const latin1Encodable = (s) => {
95
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0xff) return false;
96
+ return true;
97
+ };
98
+ const latin1Bytes = (s) => {
99
+ const out = new Uint8Array(s.length);
100
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
101
+ return out;
102
+ };
103
+ const latin1String = (b) => {
104
+ let s = '';
105
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
106
+ return s;
107
+ };
108
+ const utf8Bytes = (s) => new TextEncoder().encode(s);
109
+ const utf8String = (b) => new TextDecoder().decode(b);
110
+
111
+ /** Serialize one chunk: length | type | data | crc(type+data). Exported for
112
+ * the encoder (png-encode.js), which frames IHDR / IDAT / IEND through it.
113
+ * @param {string} type @param {Uint8Array} data */
114
+ export function buildChunk(type, data) {
115
+ const out = new Uint8Array(8 + data.length + 4);
116
+ const dv = new DataView(out.buffer);
117
+ dv.setUint32(0, data.length);
118
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
119
+ out.set(data, 8);
120
+ dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
121
+ return out;
122
+ }
123
+
124
+ /** A tEXt chunk: keyword | 0 | Latin-1 text. */
125
+ function buildTextChunk(keyword, text) {
126
+ const kw = latin1Bytes(keyword);
127
+ const tx = latin1Bytes(text);
128
+ const data = new Uint8Array(kw.length + 1 + tx.length);
129
+ data.set(kw, 0);
130
+ data.set(tx, kw.length + 1);
131
+ return buildChunk('tEXt', data);
132
+ }
133
+
134
+ /** An iTXt chunk, uncompressed: keyword | 0 | 0 | 0 | lang… | 0 | xlat… | 0 | UTF-8 text. */
135
+ function buildItxtChunk(keyword, text) {
136
+ const kw = latin1Bytes(keyword);
137
+ const tx = utf8Bytes(text);
138
+ const data = new Uint8Array(kw.length + 5 + tx.length);
139
+ data.set(kw, 0);
140
+ // keyword NUL, compression flag 0, compression method 0, empty language tag
141
+ // NUL, empty translated keyword NUL — five zero bytes in a row.
142
+ data.set(tx, kw.length + 5);
143
+ return buildChunk('iTXt', data);
144
+ }
145
+
146
+ // Decode one parsed text chunk to {keyword, text}, or null for a chunk we
147
+ // don't decode (compressed variants pass through unread).
148
+ function decodeTextChunk(chunk) {
149
+ const d = chunk.data;
150
+ const nul = d.indexOf(0);
151
+ if (nul < 0) return null;
152
+ const keyword = latin1String(d.subarray(0, nul));
153
+ if (chunk.type === 'tEXt') {
154
+ return { keyword, text: latin1String(d.subarray(nul + 1)) };
155
+ }
156
+ // iTXt: compressionFlag(1) compressionMethod(1) lang\0 translated\0 text
157
+ const flag = d[nul + 1];
158
+ if (flag !== 0) return null; // compressed — not ours, leave it be
159
+ let i = nul + 3;
160
+ while (i < d.length && d[i] !== 0) i++; // language tag
161
+ i++;
162
+ while (i < d.length && d[i] !== 0) i++; // translated keyword
163
+ i++;
164
+ return { keyword, text: utf8String(d.subarray(i)) };
165
+ }
166
+
167
+ /**
168
+ * Every decodable text entry in the file, in chunk order, as
169
+ * `{keyword: text}` — a later duplicate keyword wins (we never write
170
+ * duplicates; a foreign file's are read leniently). A chunk scan stops
171
+ * before IDAT costs nothing: our own writer puts every text chunk ahead of
172
+ * the pixel data, but a foreign file's trailing chunks are read too.
173
+ *
174
+ * @param {Uint8Array} bytes
175
+ * @returns {Record<string, string>}
176
+ */
177
+ export function readTextChunks(bytes) {
178
+ /** @type {Record<string, string>} */
179
+ const out = {};
180
+ for (const chunk of readChunks(bytes)) {
181
+ if (chunk.type !== 'tEXt' && chunk.type !== 'iTXt') continue;
182
+ const decoded = decodeTextChunk(chunk);
183
+ if (decoded) out[decoded.keyword] = decoded.text;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Return a NEW file with `entries` written as text chunks: any existing
190
+ * tEXt/iTXt chunk whose keyword appears in `entries` is removed (replace
191
+ * semantics), the new chunks are spliced immediately after IHDR, and every
192
+ * other chunk — critical or ancillary, known or unknown — passes through
193
+ * byte-for-byte. An entry whose value is `null`/`undefined` just removes the
194
+ * keyword. Insertion order follows `entries`' key order, so a rewrite is
195
+ * deterministic.
196
+ *
197
+ * @param {Uint8Array} bytes
198
+ * @param {Record<string, string|null|undefined>} entries
199
+ * @returns {Uint8Array}
200
+ */
201
+ export function setTextChunks(bytes, entries) {
202
+ const chunks = readChunks(bytes);
203
+ const replaced = new Set(Object.keys(entries));
204
+
205
+ /** @type {Uint8Array[]} the output's chunk byte-runs, in order */
206
+ const parts = [];
207
+ const fresh = [];
208
+ for (const [keyword, text] of Object.entries(entries)) {
209
+ if (text == null) continue;
210
+ if (!keyword.length || keyword.length > 79) {
211
+ throw new Error(`png-chunks: keyword must be 1–79 bytes ("${keyword}")`);
212
+ }
213
+ fresh.push(
214
+ latin1Encodable(text)
215
+ ? buildTextChunk(keyword, text)
216
+ : buildItxtChunk(keyword, text)
217
+ );
218
+ }
219
+
220
+ for (const chunk of chunks) {
221
+ if (chunk.type === 'tEXt' || chunk.type === 'iTXt') {
222
+ const decoded = decodeTextChunk(chunk);
223
+ if (decoded && replaced.has(decoded.keyword)) continue; // superseded
224
+ }
225
+ parts.push(bytes.subarray(chunk.offset, chunk.end));
226
+ if (chunk.type === 'IHDR') parts.push(...fresh.splice(0));
227
+ }
228
+ if (fresh.length) throw new Error('png-chunks: no IHDR to splice after');
229
+
230
+ let total = PNG_SIGNATURE.length;
231
+ for (const p of parts) total += p.length;
232
+ const out = new Uint8Array(total);
233
+ out.set(PNG_SIGNATURE, 0);
234
+ let at = PNG_SIGNATURE.length;
235
+ for (const p of parts) {
236
+ out.set(p, at);
237
+ at += p.length;
238
+ }
239
+ return out;
240
+ }
@@ -0,0 +1,98 @@
1
+ // ---------------------------------------------------------------------------
2
+ // A PNG ENCODER from bytes — 8-bit RGBA, non-interlaced, every row filter 0,
3
+ // the pixel stream in a zlib container of STORED deflate blocks (no
4
+ // compression) — so an image the app already holds as bytes becomes a file
5
+ // without a canvas. File → Export 3D Model… embeds the skin texture this
6
+ // way: a privacy browser's canvas farble (the readback perturbation that
7
+ // once tripped the wedge gate) never sees it, and the file's texels are the
8
+ // skin's, verbatim. Pure, zero deps, Node-tested against node:zlib's own
9
+ // inflate. (The document PNGs still go through the canvas codec in
10
+ // image-io.js — a document is the canvas's pixels; this is for bytes.)
11
+ //
12
+ // Stored blocks cost nothing but size: a skin is a few kilobytes (64 × 64 ×
13
+ // 4 is sixteen), and the glb it lands in is not a network asset. The zlib
14
+ // framing: the two-byte header (CMF 0x78, FLG 0x01 — deflate, a 32K window,
15
+ // no dictionary, "fastest" flagged; the pair's check passes), then up to
16
+ // 65535 bytes per block behind a five-byte block header (BFINAL on the
17
+ // last, BTYPE 00, LEN, then NLEN its one's complement), then the Adler-32
18
+ // of the raw stream, big-endian.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { PNG_SIGNATURE, buildChunk } from './png-chunks.js';
22
+
23
+ const BLOCK = 65535;
24
+
25
+ /** Adler-32 of a byte string — the zlib trailer. @param {Uint8Array} bytes */
26
+ export function adler32(bytes) {
27
+ let a = 1;
28
+ let b = 0;
29
+ for (let i = 0; i < bytes.length; i++) {
30
+ a = (a + bytes[i]) % 65521;
31
+ b = (b + a) % 65521;
32
+ }
33
+ return ((b << 16) | a) >>> 0;
34
+ }
35
+
36
+ /** Wrap raw bytes as a zlib stream of stored deflate blocks. @param {Uint8Array} raw */
37
+ export function zlibStored(raw) {
38
+ const blocks = Math.max(1, Math.ceil(raw.length / BLOCK));
39
+ const out = new Uint8Array(2 + blocks * 5 + raw.length + 4);
40
+ out[0] = 0x78;
41
+ out[1] = 0x01;
42
+ let at = 2;
43
+ for (let i = 0; i < blocks; i++) {
44
+ const start = i * BLOCK;
45
+ const len = Math.min(BLOCK, raw.length - start);
46
+ out[at++] = i === blocks - 1 ? 1 : 0; // BFINAL; BTYPE 00 = stored
47
+ out[at++] = len & 0xff;
48
+ out[at++] = (len >>> 8) & 0xff;
49
+ out[at++] = ~len & 0xff;
50
+ out[at++] = (~len >>> 8) & 0xff;
51
+ out.set(raw.subarray(start, start + len), at);
52
+ at += len;
53
+ }
54
+ new DataView(out.buffer).setUint32(at, adler32(raw)); // big-endian
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Encode an RGBA image as a PNG file: row 0 the top row, four bytes a texel.
60
+ * @param {{width:number, height:number, data:Uint8Array|Uint8ClampedArray}} img
61
+ * @returns {Uint8Array}
62
+ */
63
+ export function encodePng({ width, height, data }) {
64
+ if (!(width > 0 && height > 0) || !data || data.length < width * height * 4) {
65
+ throw new Error(
66
+ `encodePng: expected {width>0, height>0, data.length>=w*h*4}, got ` +
67
+ `${width}×${height} with ${data ? data.length : 'no'} bytes.`
68
+ );
69
+ }
70
+ const stride = width * 4;
71
+ const raw = new Uint8Array((stride + 1) * height);
72
+ for (let y = 0; y < height; y++) {
73
+ raw[y * (stride + 1)] = 0; // the row's filter: none
74
+ raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
75
+ }
76
+ const ihdr = new Uint8Array(13);
77
+ const iv = new DataView(ihdr.buffer);
78
+ iv.setUint32(0, width);
79
+ iv.setUint32(4, height);
80
+ ihdr[8] = 8; // bit depth
81
+ ihdr[9] = 6; // color type: RGBA
82
+ // compression 0, filter 0, interlace 0 — the zeros the array was born with
83
+ const parts = [
84
+ PNG_SIGNATURE,
85
+ buildChunk('IHDR', ihdr),
86
+ buildChunk('IDAT', zlibStored(raw)),
87
+ buildChunk('IEND', new Uint8Array(0)),
88
+ ];
89
+ let total = 0;
90
+ for (const p of parts) total += p.length;
91
+ const out = new Uint8Array(total);
92
+ let at = 0;
93
+ for (const p of parts) {
94
+ out.set(p, at);
95
+ at += p.length;
96
+ }
97
+ return out;
98
+ }