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