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/gltf.js ADDED
@@ -0,0 +1,256 @@
1
+ // ---------------------------------------------------------------------------
2
+ // A glTF 2.0 BINARY writer for one textured mesh — what File → Export 3D
3
+ // Model… hands over — and its reader (the tests' and the drive's). Pure:
4
+ // typed arrays and a PNG in, bytes out; no THREE (model.js reads the
5
+ // arrays off the mesh). One node, one mesh, one primitive, one
6
+ // material, one texture: the model and its skin (skin.js) sampled
7
+ // NEAREST both ways with clamped wrap — the hard texel is part of the file
8
+ // — under a metallic-roughness material (metalness 0, roughness 1, the 3D
9
+ // View's) or, asked for, the KHR_materials_unlit extension: the paint
10
+ // exact under no light.
11
+ //
12
+ // Why glb: every engine's first-party importer reads it, its material IS
13
+ // three's MeshStandardMaterial, and a browser gives one download per
14
+ // gesture — a .gltf is three files, or one inflated by base64. Why a
15
+ // writer of our own: three's GLTFExporter encodes a texture by drawing it
16
+ // into a canvas and reading it back, the readback privacy browsers
17
+ // perturb; this one takes the PNG as bytes (png-encode.js), so the skin
18
+ // lands verbatim. Its subset is small enough to own, like the zip writer.
19
+ //
20
+ // The layout (the spec's, little-endian): a 12-byte header (the magic
21
+ // 'glTF', version 2, the file's length), a JSON chunk padded with spaces to
22
+ // four bytes, a BIN chunk padded with zeros holding the positions, normals,
23
+ // UVs, indices and the PNG, each in its own 4-aligned bufferView. Positions
24
+ // are baked in the caller's units (the scale — the reader's meters per
25
+ // stage unit — applied here, so the accessor's min/max are the real extent
26
+ // and the node carries no transform). UVs pass through: glTF's v = 0 is the
27
+ // image's TOP row, and the skin's texel row 0 IS v = 0 (a DataTexture,
28
+ // flipY false), so there is nothing to flip. The winding is CCW, three's.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const MAGIC = 0x46546c67; // 'glTF'
32
+ const VERSION = 2;
33
+ const CHUNK_JSON = 0x4e4f534a; // 'JSON'
34
+ const CHUNK_BIN = 0x004e4942; // 'BIN\0'
35
+ const FLOAT = 5126;
36
+ const USHORT = 5123;
37
+ const UINT = 5125;
38
+ const ARRAY_BUFFER = 34962;
39
+ const ELEMENT_ARRAY_BUFFER = 34963;
40
+ const NEAREST = 9728;
41
+ const CLAMP_TO_EDGE = 33071;
42
+ const TRIANGLES = 4;
43
+
44
+ const pad4 = (n) => (n + 3) & ~3;
45
+
46
+ /**
47
+ * The model to write: `position` / `normal` / `uv` are per-vertex triples,
48
+ * triples and pairs (stage units, unit length, [0, 1]); `index` three vertex
49
+ * indices per triangle, CCW; `scale` a factor onto every position (1);
50
+ * `image` the skin as an encoded PNG, or null for a flat `color` (the
51
+ * material's base color, linear RGB 0..1); `unlit` puts KHR_materials_unlit
52
+ * on the material; `generator` and `extras` land on the asset.
53
+ * @typedef {{
54
+ * name: string,
55
+ * position: ArrayLike<number>,
56
+ * normal: ArrayLike<number>,
57
+ * uv: ArrayLike<number>,
58
+ * index: ArrayLike<number>,
59
+ * scale?: number,
60
+ * image?: {bytes: Uint8Array, mimeType?: string}|null,
61
+ * color?: ArrayLike<number>|null,
62
+ * unlit?: boolean,
63
+ * generator?: string,
64
+ * extras?: object,
65
+ * }} GlbModel
66
+ */
67
+
68
+ /**
69
+ * @param {GlbModel} model
70
+ * @returns {Uint8Array} the .glb file
71
+ */
72
+ export function glbFromModel(model) {
73
+ const { name, position, normal, uv, index } = model;
74
+ const scale = model.scale ?? 1;
75
+ const n = position.length / 3;
76
+ if (!Number.isInteger(n) || n < 1)
77
+ throw new Error('gltf: positions must be xyz triples');
78
+ if (normal.length !== n * 3) throw new Error('gltf: one normal per vertex');
79
+ if (uv.length !== n * 2) throw new Error('gltf: one uv per vertex');
80
+ if (index.length % 3 !== 0 || index.length < 3)
81
+ throw new Error('gltf: indices come in triangles');
82
+
83
+ // The vertex buffers — positions scaled, with their extent.
84
+ const pos = new Float32Array(n * 3);
85
+ const min = [Infinity, Infinity, Infinity];
86
+ const max = [-Infinity, -Infinity, -Infinity];
87
+ for (let i = 0; i < n * 3; i++) {
88
+ const v = position[i] * scale;
89
+ pos[i] = v;
90
+ const k = i % 3;
91
+ if (v < min[k]) min[k] = v;
92
+ if (v > max[k]) max[k] = v;
93
+ }
94
+ // Float32 rounding: the accessor's bounds must contain the stored values.
95
+ for (let k = 0; k < 3; k++) {
96
+ min[k] = Math.fround(min[k]);
97
+ max[k] = Math.fround(max[k]);
98
+ }
99
+ const nrm = Float32Array.from(normal);
100
+ const tex = Float32Array.from(uv);
101
+ const wide = n > 65535;
102
+ const idx = wide ? Uint32Array.from(index) : Uint16Array.from(index);
103
+
104
+ // The BIN chunk: each view at a 4-aligned offset.
105
+ /** @type {{bytes: Uint8Array, target?: number}[]} */
106
+ const views = [
107
+ { bytes: new Uint8Array(pos.buffer), target: ARRAY_BUFFER },
108
+ { bytes: new Uint8Array(nrm.buffer), target: ARRAY_BUFFER },
109
+ { bytes: new Uint8Array(tex.buffer), target: ARRAY_BUFFER },
110
+ { bytes: new Uint8Array(idx.buffer), target: ELEMENT_ARRAY_BUFFER },
111
+ ];
112
+ if (model.image) views.push({ bytes: model.image.bytes });
113
+ const bufferViews = [];
114
+ let binLength = 0;
115
+ for (const v of views) {
116
+ const view = { buffer: 0, byteOffset: binLength, byteLength: v.bytes.length };
117
+ if (v.target) view.target = v.target;
118
+ bufferViews.push(view);
119
+ binLength += pad4(v.bytes.length);
120
+ }
121
+ const bin = new Uint8Array(binLength);
122
+ views.forEach((v, i) => bin.set(v.bytes, bufferViews[i].byteOffset));
123
+
124
+ const material = {
125
+ name,
126
+ pbrMetallicRoughness: {
127
+ ...(model.image
128
+ ? { baseColorTexture: { index: 0 } }
129
+ : {
130
+ baseColorFactor: Array.from(model.color ?? [1, 1, 1])
131
+ .slice(0, 3)
132
+ .concat(1),
133
+ }),
134
+ metallicFactor: 0,
135
+ roughnessFactor: 1,
136
+ },
137
+ };
138
+ if (model.unlit) material.extensions = { KHR_materials_unlit: {} };
139
+
140
+ const json = {
141
+ asset: {
142
+ version: '2.0',
143
+ ...(model.generator ? { generator: model.generator } : {}),
144
+ ...(model.extras ? { extras: model.extras } : {}),
145
+ },
146
+ ...(model.unlit ? { extensionsUsed: ['KHR_materials_unlit'] } : {}),
147
+ scene: 0,
148
+ scenes: [{ nodes: [0] }],
149
+ nodes: [{ mesh: 0, name }],
150
+ meshes: [
151
+ {
152
+ name,
153
+ primitives: [
154
+ {
155
+ attributes: { POSITION: 0, NORMAL: 1, TEXCOORD_0: 2 },
156
+ indices: 3,
157
+ material: 0,
158
+ mode: TRIANGLES,
159
+ },
160
+ ],
161
+ },
162
+ ],
163
+ materials: [material],
164
+ ...(model.image
165
+ ? {
166
+ textures: [{ sampler: 0, source: 0, name: `${name} skin` }],
167
+ samplers: [
168
+ {
169
+ magFilter: NEAREST,
170
+ minFilter: NEAREST,
171
+ wrapS: CLAMP_TO_EDGE,
172
+ wrapT: CLAMP_TO_EDGE,
173
+ },
174
+ ],
175
+ images: [
176
+ {
177
+ name: `${name} skin`,
178
+ mimeType: model.image.mimeType ?? 'image/png',
179
+ bufferView: 4,
180
+ },
181
+ ],
182
+ }
183
+ : {}),
184
+ accessors: [
185
+ { bufferView: 0, componentType: FLOAT, count: n, type: 'VEC3', min, max },
186
+ { bufferView: 1, componentType: FLOAT, count: n, type: 'VEC3' },
187
+ { bufferView: 2, componentType: FLOAT, count: n, type: 'VEC2' },
188
+ {
189
+ bufferView: 3,
190
+ componentType: wide ? UINT : USHORT,
191
+ count: idx.length,
192
+ type: 'SCALAR',
193
+ },
194
+ ],
195
+ bufferViews,
196
+ buffers: [{ byteLength: binLength }],
197
+ };
198
+
199
+ // The file: header, the JSON chunk (space-padded), the BIN chunk (zero-padded).
200
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
201
+ const jsonLength = pad4(jsonBytes.length);
202
+ const total = 12 + 8 + jsonLength + 8 + binLength;
203
+ const out = new Uint8Array(total);
204
+ const v = new DataView(out.buffer);
205
+ v.setUint32(0, MAGIC, true);
206
+ v.setUint32(4, VERSION, true);
207
+ v.setUint32(8, total, true);
208
+ v.setUint32(12, jsonLength, true);
209
+ v.setUint32(16, CHUNK_JSON, true);
210
+ out.set(jsonBytes, 20);
211
+ out.fill(0x20, 20 + jsonBytes.length, 20 + jsonLength);
212
+ const binAt = 20 + jsonLength;
213
+ v.setUint32(binAt, binLength, true);
214
+ v.setUint32(binAt + 4, CHUNK_BIN, true);
215
+ out.set(bin, binAt + 8);
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Read a glb back: its parsed JSON and its BIN chunk (a view into `bytes`).
221
+ * The writer's mirror — tests and the drive read the export through it.
222
+ * Throws on anything but a version-2 glb with a JSON chunk first.
223
+ * @param {Uint8Array} bytes
224
+ * @returns {{json: any, bin: Uint8Array}}
225
+ */
226
+ export function glbParts(bytes) {
227
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
228
+ if (bytes.length < 20 || v.getUint32(0, true) !== MAGIC)
229
+ throw new Error('not a glb: bad magic');
230
+ if (v.getUint32(4, true) !== VERSION) throw new Error('not a glb 2');
231
+ if (v.getUint32(8, true) !== bytes.length)
232
+ throw new Error('glb: length disagrees with the file');
233
+ const jsonLength = v.getUint32(12, true);
234
+ if (v.getUint32(16, true) !== CHUNK_JSON)
235
+ throw new Error('glb: the first chunk is not JSON');
236
+ const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)));
237
+ let bin = new Uint8Array(0);
238
+ const binAt = 20 + jsonLength;
239
+ if (binAt + 8 <= bytes.length && v.getUint32(binAt + 4, true) === CHUNK_BIN) {
240
+ const binLength = v.getUint32(binAt, true);
241
+ bin = bytes.subarray(binAt + 8, binAt + 8 + binLength);
242
+ }
243
+ return { json, bin };
244
+ }
245
+
246
+ /**
247
+ * A bufferView's bytes out of a read glb (a reader convenience).
248
+ * @param {{json: any, bin: Uint8Array}} parts @param {number} index
249
+ */
250
+ export function glbViewBytes(parts, index) {
251
+ const view = parts.json.bufferViews[index];
252
+ return parts.bin.subarray(
253
+ view.byteOffset ?? 0,
254
+ (view.byteOffset ?? 0) + view.byteLength
255
+ );
256
+ }
package/src/index.js ADDED
@@ -0,0 +1,85 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The engine's public surface — what `import … from 'sprite-machine'` gives:
3
+ // the pipeline (ingest → carve → colorize), the mesher (regions, wedges, the
4
+ // T-junction repair, the skin), the atlas's slicing and resizing, the file
5
+ // formats (PNG chunks, the PNG encoder, glb) and the vocabularies. Explicit
6
+ // names, never `export *`: two modules re-exporting one name through a star
7
+ // would silently export neither. The headless entry is model.js; the Node
8
+ // adapter (a PNG's pixels in, a glb file out) is `sprite-machine/node`.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export {
12
+ packRGBA,
13
+ unpackRGBA,
14
+ flip,
15
+ applyTransform,
16
+ ingestSprite,
17
+ placeView,
18
+ } from './ingest.js';
19
+ export {
20
+ voxIndex,
21
+ unvoxIndex,
22
+ reconcileDims,
23
+ gridViews,
24
+ carve,
25
+ extractSurface,
26
+ } from './carve.js';
27
+ export { buildPalette, makeSnapper, colorize } from './colorize.js';
28
+ export { buildVoxels } from './pipeline.js';
29
+ export {
30
+ VIEW_TO_FACE,
31
+ FACE_TO_VIEW,
32
+ FACE_NORMAL,
33
+ FACE_KEYS,
34
+ FACE_INDEX,
35
+ AXIS_INDEX,
36
+ faceKeyOf,
37
+ FACE_AXIS,
38
+ FACE_OPPOSITE,
39
+ VIEWS,
40
+ VIEW_NAMES,
41
+ VIEW_DISPLAY_ORDER,
42
+ VIEW_FRONT_EDGE,
43
+ VIEW_OPPOSITE,
44
+ MIRROR_AXIS,
45
+ VIEW_AXES,
46
+ VIEW_IMAGE_AXES,
47
+ } from './views.js';
48
+ export { FACE_GEO, idxFor, pointOf } from './faces.js';
49
+ export {
50
+ DEFAULT_ATLAS_LAYOUT,
51
+ TILE_MIN,
52
+ TILE_MAX,
53
+ clampTile,
54
+ layoutSize,
55
+ deriveTileSize,
56
+ isBlank,
57
+ contentBounds,
58
+ validateSheet,
59
+ sliceAtlas,
60
+ blitTile,
61
+ resizeTileTo,
62
+ resizeTile,
63
+ splitLow,
64
+ resizeAtlas,
65
+ cellOf,
66
+ } from './atlas.js';
67
+ export { traceRegions, planeKey, faceRegions } from './regions.js';
68
+ export { eliminateTJunctions } from './t-junction.js';
69
+ export { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
70
+ export { skinTexture, finishVoxelMesh } from './mesh-util.js';
71
+ export { wedgeMesh } from './wedge-mesh.js';
72
+ export {
73
+ PNG_SIGNATURE,
74
+ isPng,
75
+ crc32,
76
+ readChunks,
77
+ buildChunk,
78
+ readTextChunks,
79
+ setTextChunks,
80
+ } from './png-chunks.js';
81
+ export { adler32, zlibStored, encodePng } from './png-encode.js';
82
+ export { glbFromModel, glbParts, glbViewBytes } from './gltf.js';
83
+ export { computeDiag } from './diag.js';
84
+ export { DEFAULT_MIRROR, DEFAULT_WORLD_SIZE } from './constants.js';
85
+ export { buildModel, modelToGlb } from './model.js';
package/src/ingest.js ADDED
@@ -0,0 +1,150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Ingest: sprite image -> occupancy + color typed arrays, at NATIVE size — the
3
+ // tile is NOT cropped. No THREE / no DOM here so it runs unchanged in Node tests.
4
+ //
5
+ // Strict registration: a tile is a literal slice of the voxel lattice, so texel
6
+ // (u,v) maps 1:1 to a fixed lattice line. We deliberately do NOT crop to the
7
+ // alpha bounding box — where a pixel sits inside its tile IS its position in the
8
+ // object, and must line up across faces (a FRONT pixel only survives the carve
9
+ // where the SIDE covers its row and the TOP covers its column). Cropping would
10
+ // throw that registration away.
11
+ //
12
+ // Input shape is ImageData-compatible: { width, height, data } where data is an
13
+ // RGBA byte array (canvas.getImageData().data in the browser; a plain array in
14
+ // tests). Output packs color as a Uint32 (bytes r,g,b,a, little-endian).
15
+ // ---------------------------------------------------------------------------
16
+
17
+ // Sprites are assumed to be HARD pixel art: every texel is either fully opaque
18
+ // or fully transparent, no partial coverage. A pixel counts as solid at alpha
19
+ // >= 128 — the 50%-coverage midpoint, robust to privacy-browser canvas farbling
20
+ // that perturbs a 0/255 alpha by ±1 (see the wedge-mesh farbling note).
21
+ const ALPHA_SOLID = 128;
22
+
23
+ export const packRGBA = (r, g, b, a = 255) =>
24
+ ((r & 255) | ((g & 255) << 8) | ((b & 255) << 16) | ((a & 255) << 24)) >>> 0;
25
+
26
+ export const unpackRGBA = (v) => ({
27
+ r: v & 255,
28
+ g: (v >>> 8) & 255,
29
+ b: (v >>> 16) & 255,
30
+ a: (v >>> 24) & 255,
31
+ });
32
+
33
+ function rot90cw(img) {
34
+ const { width: W, height: H, data } = img;
35
+ const nW = H,
36
+ nH = W;
37
+ const out = new Uint8ClampedArray(nW * nH * 4);
38
+ for (let dy = 0; dy < nH; dy++) {
39
+ for (let dx = 0; dx < nW; dx++) {
40
+ const sx = dy,
41
+ sy = H - 1 - dx;
42
+ const s = (sy * W + sx) * 4;
43
+ const d = (dy * nW + dx) * 4;
44
+ out[d] = data[s];
45
+ out[d + 1] = data[s + 1];
46
+ out[d + 2] = data[s + 2];
47
+ out[d + 3] = data[s + 3];
48
+ }
49
+ }
50
+ return { width: nW, height: nH, data: out };
51
+ }
52
+
53
+ // Axis-flip blit: mirror an image horizontally (flipX) and/or vertically (flipY).
54
+ // Exported so derive.js's display-only mirrorImage reuses one flip implementation.
55
+ // A no-op (both false) returns the SAME object; any flip returns a fresh copy.
56
+ export function flip(img, flipX, flipY) {
57
+ if (!flipX && !flipY) return img;
58
+ const { width: W, height: H, data } = img;
59
+ const out = new Uint8ClampedArray(W * H * 4);
60
+ for (let y = 0; y < H; y++) {
61
+ for (let x = 0; x < W; x++) {
62
+ const sx = flipX ? W - 1 - x : x;
63
+ const sy = flipY ? H - 1 - y : y;
64
+ const s = (sy * W + sx) * 4;
65
+ const d = (y * W + x) * 4;
66
+ out[d] = data[s];
67
+ out[d + 1] = data[s + 1];
68
+ out[d + 2] = data[s + 2];
69
+ out[d + 3] = data[s + 3];
70
+ }
71
+ }
72
+ return { width: W, height: H, data: out };
73
+ }
74
+
75
+ /**
76
+ * Reorient a sprite so it matches the pipeline's view conventions. Applied
77
+ * before ingest. `rot` is quarter-turns clockwise (0-3); flips run after rot.
78
+ * @param {{width,height,data}} img
79
+ * @param {{rot?:number, flipX?:boolean, flipY?:boolean}} t
80
+ */
81
+ export function applyTransform(img, t = {}) {
82
+ let out = img;
83
+ const rot = (((t.rot || 0) % 4) + 4) % 4;
84
+ for (let i = 0; i < rot; i++) out = rot90cw(out);
85
+ return flip(out, !!t.flipX, !!t.flipY);
86
+ }
87
+
88
+ /**
89
+ * Ingest a sprite at NATIVE size — occupancy/color for the WHOLE tile, no crop.
90
+ * The tile's own dimensions become the view's dimensions, so its texels register
91
+ * 1:1 against the other faces. A fully transparent tile has nothing to constrain
92
+ * and returns null (treated as absent — the mirror partner colors it).
93
+ * @param {{width:number,height:number,data:ArrayLike<number>}} img
94
+ * @returns {{w:number,h:number,occ:Uint8Array,rgb:Uint32Array} | null}
95
+ */
96
+ export function ingestSprite(img) {
97
+ const { width: W, height: H, data } = img;
98
+ if (!(W > 0) || !(H > 0) || !data || data.length < W * H * 4) {
99
+ throw new Error(
100
+ `ingestSprite: expected {width>0, height>0, data.length>=w*h*4}, got ` +
101
+ `${W}×${H} with ${data ? data.length : 'no'} bytes.`
102
+ );
103
+ }
104
+ const occ = new Uint8Array(W * H);
105
+ const rgb = new Uint32Array(W * H);
106
+ let any = false;
107
+ for (let y = 0; y < H; y++) {
108
+ for (let x = 0; x < W; x++) {
109
+ const si = (y * W + x) * 4;
110
+ if (data[si + 3] >= ALPHA_SOLID) {
111
+ const di = y * W + x;
112
+ occ[di] = 1;
113
+ rgb[di] = packRGBA(data[si], data[si + 1], data[si + 2], 255);
114
+ any = true;
115
+ }
116
+ }
117
+ }
118
+ if (!any) return null; // fully transparent -> absent (mirror-filled)
119
+ return { w: W, h: H, occ, rgb };
120
+ }
121
+
122
+ /**
123
+ * Copy a view into a (targetW,targetH) grid buffer at NATIVE scale — no
124
+ * resampling — with its (0,0) texel at (offX,offY). Under strict registration
125
+ * gridViews passes offX=offY=0, so for a well-formed (uniform-tile) sheet every
126
+ * view already equals the grid on the axes it constrains and this is the exact
127
+ * fast-path identity copy below. It stays general only to pad the degenerate
128
+ * case where a malformed sheet gives views of unequal size (origin-anchored,
129
+ * far end left empty); the bounds guards keep that from indexing out of range.
130
+ * @returns {{occ:Uint8Array, rgb:Uint32Array}}
131
+ */
132
+ export function placeView(view, targetW, targetH, offX, offY) {
133
+ const { w, h, occ, rgb } = view;
134
+ if (w === targetW && h === targetH) return { occ, rgb }; // exact fit (offX/offY==0)
135
+ const outOcc = new Uint8Array(targetW * targetH);
136
+ const outRgb = new Uint32Array(targetW * targetH);
137
+ for (let sy = 0; sy < h; sy++) {
138
+ const ty = sy + offY;
139
+ if (ty < 0 || ty >= targetH) continue;
140
+ for (let sx = 0; sx < w; sx++) {
141
+ const tx = sx + offX;
142
+ if (tx < 0 || tx >= targetW) continue;
143
+ const di = ty * targetW + tx;
144
+ const si = sy * w + sx;
145
+ outOcc[di] = occ[si];
146
+ outRgb[di] = rgb[si];
147
+ }
148
+ }
149
+ return { occ: outOcc, rgb: outRgb };
150
+ }
@@ -0,0 +1,65 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared helpers for the wedge mesh builder (wedge-mesh.js), and the ONE place
3
+ // THREE meets the skin: the texture the pure bake (skin.js) becomes, and the
4
+ // framing + material the builder finishes with. (The plain voxel builder that
5
+ // once shared them, mesh.js, went with the test trim of Sep 5 2026 — dead
6
+ // code with no consumer; the vertex-color linearizer went with the skin on
7
+ // Sep 7 2026 — the GPU's sampler decodes sRGB now, where the CPU used to.)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import * as THREE from 'three';
11
+ import { unpackRGBA } from './ingest.js';
12
+
13
+ /**
14
+ * The skin as a texture. DataTexture's constructor already states the skin's
15
+ * sampling contract — nearest filtering both ways, no mipmaps, flipY false
16
+ * (texel row 0 is v = 0, the bake's orientation), unpackAlignment 1 — those
17
+ * are the class's own defaults, named here as documentation, never restated
18
+ * as a correction. The bytes are sRGB, so the texture says so and the
19
+ * sampler decodes them on the way to the renderers' sRGB output.
20
+ * @param {import('./skin.js').Skin} skin
21
+ * @returns {THREE.DataTexture}
22
+ */
23
+ export function skinTexture(skin) {
24
+ const tex = new THREE.DataTexture(skin.data, skin.width, skin.height, THREE.RGBAFormat);
25
+ tex.colorSpace = THREE.SRGBColorSpace;
26
+ tex.needsUpdate = true;
27
+ return tex;
28
+ }
29
+
30
+ /**
31
+ * Shared final assembly: center the geometry on X/Z, leave Y exactly as
32
+ * authored (no ground-rest — the Y translate is a hard 0; where the object
33
+ * sits vertically is wherever the artist painted it, see carve.js), compute
34
+ * bounds, and wrap it in the standard flat-shaded material with shadows on —
35
+ * the skin as its `map`, or, with no map (flat mode), one packed `color`
36
+ * (sRGB bytes) as its albedo.
37
+ * @param {THREE.BufferGeometry} geo
38
+ * @param {{nx:number, nz:number, s:number, map?:THREE.Texture|null, color?:number|null,
39
+ * userData?:Record<string,unknown>}} opts
40
+ * @returns {THREE.Mesh}
41
+ */
42
+ export function finishVoxelMesh(
43
+ geo,
44
+ { nx, nz, s, map = null, color = null, userData = {} }
45
+ ) {
46
+ geo.translate((-nx * s) / 2, 0, (-nz * s) / 2); // center X/Z; Y left as authored
47
+ geo.computeBoundingBox();
48
+ geo.computeBoundingSphere();
49
+
50
+ const mat = new THREE.MeshStandardMaterial({
51
+ flatShading: true,
52
+ metalness: 0,
53
+ roughness: 1,
54
+ ...(map ? { map } : {}),
55
+ });
56
+ if (!map && color != null) {
57
+ const { r, g, b } = unpackRGBA(color);
58
+ mat.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
59
+ }
60
+ const mesh = new THREE.Mesh(geo, mat);
61
+ mesh.castShadow = true;
62
+ mesh.receiveShadow = true;
63
+ Object.assign(mesh.userData, userData);
64
+ return mesh;
65
+ }
package/src/model.js ADDED
@@ -0,0 +1,104 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The headless entry: a sheet's pixels → the model → a glb. What File →
3
+ // Export 3D Model… does in the app, with no app around it — a Node script at
4
+ // a project's startup, a build step, or a three.js page that wants the mesh
5
+ // itself and no file at all.
6
+ //
7
+ // `buildModel` runs the whole chain — slice the 3×2 atlas, ingest, carve,
8
+ // colorize, the low-poly wedge mesh with its skin — and hands back the
9
+ // THREE.Mesh at ONE UNIT PER VOXEL: a position is a lattice coordinate, the
10
+ // natural unit for a model whose author painted it texel by texel (the app's
11
+ // stage scales the same mesh to its own world size; DEFAULT_WORLD_SIZE is
12
+ // that stage's business). `modelToGlb` writes it as the glb the app exports,
13
+ // so the export dialog and this path are one function: the mesh's buffers,
14
+ // the skin encoded from bytes (never a canvas), the scale from the model's
15
+ // units to glTF's meters, and the `sprite-machine` extras. Both are
16
+ // synchronous and pure; a consumer that wants them off a main thread wraps
17
+ // them in a worker or a child process.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { sliceAtlas, validateSheet } from './atlas.js';
21
+ import { buildVoxels } from './pipeline.js';
22
+ import { wedgeMesh } from './wedge-mesh.js';
23
+ import { encodePng } from './png-encode.js';
24
+ import { glbFromModel } from './gltf.js';
25
+ import { VIEW_NAMES } from './views.js';
26
+
27
+ /**
28
+ * A built model: the mesh, the lattice it was carved on, and the mesh's
29
+ * units per voxel (1 from `buildModel`; the app's stage passes its own).
30
+ * @typedef {{
31
+ * mesh: import('three').Mesh,
32
+ * dims: {nx:number, ny:number, nz:number},
33
+ * unitsPerVoxel: number,
34
+ * triangles: number,
35
+ * warnings: string[],
36
+ * }} Model
37
+ */
38
+
39
+ /**
40
+ * Build the model of a 3×2 sprite sheet.
41
+ * @param {{width:number, height:number, data:ArrayLike<number>}} sheet
42
+ * the atlas's RGBA pixels (an ImageData, or the same shape)
43
+ * @param {{transforms?: Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}>}} [opts]
44
+ * per-view reorientation, the document's `sprite-machine:transforms` chunk
45
+ * @returns {Model} the mesh at one unit per voxel
46
+ * @throws on a sheet that is not a sheet, or one with no painted view
47
+ */
48
+ export function buildModel(sheet, { transforms = {} } = {}) {
49
+ const bad = validateSheet(sheet);
50
+ if (bad) throw new Error(`buildModel: ${bad}`);
51
+ const sliced = sliceAtlas(sheet);
52
+ /** @type {Record<string, {width:number, height:number, data:ArrayLike<number>}|null>} */
53
+ const rawViews = {};
54
+ let provided = 0;
55
+ for (const n of VIEW_NAMES) {
56
+ rawViews[n] = sliced.views[n] || null;
57
+ if (rawViews[n]) provided++;
58
+ }
59
+ if (provided === 0) throw new Error('buildModel: the sheet has no painted view.');
60
+ const result = buildVoxels(rawViews, { transforms });
61
+ const { nx, ny, nz } = result.dims;
62
+ const mesh = wedgeMesh(result, { worldSize: Math.max(nx, ny, nz) });
63
+ return {
64
+ mesh,
65
+ dims: result.dims,
66
+ unitsPerVoxel: 1,
67
+ triangles: Number(mesh.userData.triangles) || 0,
68
+ warnings: [...sliced.warnings, ...(result.warnings || [])],
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The model as a glb: its buffers at the given scale, its skin embedded (or,
74
+ * with no map, the material's flat color) behind a NEAREST sampler.
75
+ * @param {{mesh: import('three').Mesh, dims: {nx:number, ny:number, nz:number}, unitsPerVoxel?: number}} model
76
+ * @param {{name: string, voxelsPerMeter?: number, unlit?: boolean, generator?: string}} opts
77
+ * `voxelsPerMeter` is the reader's scale (10: a 40-voxel car is 4 m long);
78
+ * `unlit` puts KHR_materials_unlit on the material; `generator` names the
79
+ * writer in the asset
80
+ * @returns {Uint8Array} the .glb file
81
+ */
82
+ export function modelToGlb(
83
+ model,
84
+ { name, voxelsPerMeter = 10, unlit = false, generator }
85
+ ) {
86
+ const { mesh, dims } = model;
87
+ const unitsPerVoxel = model.unitsPerVoxel ?? 1;
88
+ const geo = mesh.geometry;
89
+ const material = /** @type {import('three').MeshStandardMaterial} */ (mesh.material);
90
+ const map = material.map;
91
+ return glbFromModel({
92
+ name,
93
+ position: geo.attributes.position.array,
94
+ normal: geo.attributes.normal.array,
95
+ uv: geo.attributes.uv.array,
96
+ index: geo.index.array,
97
+ scale: 1 / (unitsPerVoxel * voxelsPerMeter),
98
+ image: map ? { bytes: encodePng(map.image) } : null,
99
+ color: map ? null : material.color.toArray(),
100
+ unlit,
101
+ generator,
102
+ extras: { 'sprite-machine': { voxelsPerMeter, dims: { ...dims } } },
103
+ });
104
+ }