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/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
|
+
}
|
package/src/pipeline.js
ADDED
|
@@ -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
|
+
}
|