sprite-machine 0.1.0 → 0.2.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/constants.js CHANGED
@@ -1,20 +1,15 @@
1
- // ---------------------------------------------------------------------------
2
- // The engine's defaults. Centralized so the pipeline, the mesher and every
3
- // consumer can never drift on what "the defaults" are. Import and spread
4
- // (`{ ...DEFAULT_MIRROR }`) rather than mutating these objects in place.
5
- // (The editor's palettes, which shared this file before the engine became a
6
- // package, are the app's src/lib/palette.js.)
7
- // ---------------------------------------------------------------------------
1
+ // Engine defaults. Treat them as read-only and copy with a spread
2
+ // (`{ ...DEFAULT_MIRROR }`).
8
3
 
9
4
  /**
10
- * Per-axis mirror-fill: a face with no view of its own is always filled from the
11
- * mirrored opposite view. On for every axis objects are treated as symmetric,
12
- * so a half-drawn sheet (e.g. no LEFT/BACK/BOTTOM) still colors every face.
5
+ * Per-axis mirror-fill: a face with no view of its own is colored from the
6
+ * mirrored opposite view. On for every axis, so a half-drawn sheet still colors
7
+ * every face.
13
8
  */
14
9
  export const DEFAULT_MIRROR = { x: true, y: true, z: true };
15
10
 
16
11
  /**
17
- * World-space size the largest grid axis is scaled to fill the app's stage
18
- * units. A headless build (model.js) uses one unit per voxel instead.
12
+ * World-space size the largest grid axis is scaled to, in the app's stage units.
13
+ * model.js uses one unit per voxel.
19
14
  */
20
15
  export const DEFAULT_WORLD_SIZE = 2.5;
package/src/diag.js CHANGED
@@ -1,24 +1,17 @@
1
- // ---------------------------------------------------------------------------
2
- // Geometry self-check a dev probe (the app reads it under ?diag=1; a
3
- // consumer can read it off any built mesh). Watertightness via
4
- // position-based edge parity + a per-face normal histogram: a closed surface
5
- // uses every undirected edge exactly twice; edges used an odd number of times
6
- // are boundaries — a genuine hole in a mesh that should be closed.
7
- //
8
- // MODE MATTERS: a nonzero boundary/odd count is only a "hole" for a mesh that
9
- // claims to be watertight. The low-poly (wedge) mesh is — its T-junctions are
10
- // repaired (t-junction.js) — but the greedy-voxel mesh (low-poly OFF) deliberately
11
- // leaves its step-riser T-junctions unrepaired, so it reports nonzero boundary/odd
12
- // edges as EXPECTED artifacts, not holes. main.js tags the ?diag=1 title with the
13
- // mode so the two conditions aren't conflated. This function just counts; it never
14
- // asserts watertightness on its own.
15
- // ---------------------------------------------------------------------------
1
+ // Geometry self-check for development (the app's ?diag=1). Edges are matched by
2
+ // vertex position. A closed surface uses every undirected edge exactly twice, so
3
+ // edges used an odd number of times are boundaries. Also returns a histogram of
4
+ // triangle normals by axis.
16
5
 
17
- /** @param {import('three').BufferGeometry} geo */
18
- export function computeDiag(geo) {
19
- const pos = geo.attributes.position.array;
20
- const nrm = geo.attributes.normal.array;
21
- const idx = geo.index ? geo.index.array : null;
6
+ /**
7
+ * @param {{position:ArrayLike<number>, normal:ArrayLike<number>,
8
+ * index?:ArrayLike<number>|null}} geometry
9
+ * flat triangle buffers, non-indexed when index is null
10
+ */
11
+ export function computeDiag(geometry) {
12
+ const pos = geometry.position;
13
+ const nrm = geometry.normal;
14
+ const idx = geometry.index ?? null;
22
15
  const triCount = idx ? idx.length / 3 : pos.length / 9;
23
16
  const key = (i) => {
24
17
  const x = Math.round(pos[i * 3] * 1e4);
package/src/faces.js CHANGED
@@ -1,27 +1,15 @@
1
- // ---------------------------------------------------------------------------
2
- // The face vocabulary's GEOMETRY: for each face key, the axes its plane spans
3
- // (A, B — the tangent frame every 2D read of a face uses: the regions, the
4
- // skin's charts and the UV read) and the axis it faces along (N), plus the two
5
- // lattice reads built on them — a tangent coordinate's voxel index (idxFor)
6
- // and its 3D point (pointOf). Pure, no THREE, Node-testable.
1
+ // Face geometry: each face key's tangent axes (A, B) and normal axis (N), and the
2
+ // lattice lookups idxFor (tangent coords to voxel index) and pointOf (tangent
3
+ // coords to a 3D point).
7
4
  //
8
- // A face's plane sits at slice s along N: at s for a negative face (the
9
- // voxel's near side), at s + 1 for a positive one. Tangent (a, b) is the
10
- // voxel's own A/B coordinate, so the unit square [a, a+1] × [b, b+1] on the
11
- // plane IS that voxel's face, and a texel per face is a texel per unit
12
- // square — the skin's orientation rule, stated once (skin.js).
13
- //
14
- // Until Sep 7 2026 this module also merged faces into greedy rectangles
15
- // (culledQuads / greedyQuads); the mesher traces coplanar REGIONS now
16
- // (regions.js) — a rectangle being the region with four corners — so the quad
17
- // builders went with their last consumer.
18
- // ---------------------------------------------------------------------------
5
+ // A face's plane at slice s sits at s along N for a negative face and at s + 1 for
6
+ // a positive one. Tangent (a, b) is the voxel's own A/B coordinate, so the unit
7
+ // square [a, a+1] × [b, b+1] on the plane is that voxel's face.
19
8
 
20
9
  import { voxIndex } from './carve.js';
21
10
  import { FACE_NORMAL, AXIS_INDEX } from './views.js';
22
11
 
23
- // Per-face tangent axes and the normal axis. The outward normal is read from
24
- // the shared FACE_NORMAL (views.js), not restated here.
12
+ // The outward normal's sign is in FACE_NORMAL.
25
13
  export const FACE_GEO = {
26
14
  px: { N: 'x', A: 'y', B: 'z' },
27
15
  nx: { N: 'x', A: 'y', B: 'z' },
@@ -32,9 +20,7 @@ export const FACE_GEO = {
32
20
  };
33
21
 
34
22
  /**
35
- * The voxel index behind tangent coords (a, b) on slice s of `face` — the
36
- * lattice cell whose face that is. One home: the regions, the skin's baker
37
- * and every test compose it here.
23
+ * The index of the voxel whose face is at tangent coords (a, b) on slice s.
38
24
  * @param {string} face a FACE_KEYS key
39
25
  * @param {number} a along FACE_GEO[face].A
40
26
  * @param {number} b along FACE_GEO[face].B
@@ -51,8 +37,7 @@ export function idxFor(face, a, b, s, dims) {
51
37
  }
52
38
 
53
39
  /**
54
- * The 3D lattice point at tangent (a, b) on the plane of `face` at slice s
55
- * the plane at s + 1 for a positive face, at s for a negative one.
40
+ * The 3D lattice point at tangent (a, b) on the plane of face at slice s.
56
41
  * @param {string} face
57
42
  * @param {number} a
58
43
  * @param {number} b
package/src/gltf.js CHANGED
@@ -1,32 +1,15 @@
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.
1
+ // glTF 2.0 binary (glb) writer for one textured mesh, and a reader. Typed arrays
2
+ // and a PNG in, bytes out. No THREE.
11
3
  //
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.
4
+ // The PNG bytes are stored as given, not drawn through a canvas, because privacy
5
+ // browsers perturb canvas readback.
19
6
  //
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
- // ---------------------------------------------------------------------------
7
+ // Layout (little-endian): a 12-byte header ('glTF', version 2, file length), a
8
+ // JSON chunk padded with spaces to 4 bytes, and a BIN chunk padded with zeros.
9
+ // The BIN chunk holds positions, normals, UVs, indices and the PNG, each in a
10
+ // 4-aligned bufferView. Positions are multiplied by scale here, so the accessor
11
+ // min/max are the real extent and the node has no transform. UVs pass through
12
+ // unflipped: glTF v = 0 is the image's top row, as in the skin. Winding is CCW.
30
13
 
31
14
  const MAGIC = 0x46546c67; // 'glTF'
32
15
  const VERSION = 2;
@@ -44,12 +27,11 @@ const TRIANGLES = 4;
44
27
  const pad4 = (n) => (n + 3) & ~3;
45
28
 
46
29
  /**
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.
30
+ * The model to write. position and normal are per-vertex xyz (stage units, unit
31
+ * length), uv per-vertex pairs in [0, 1], index three CCW vertex indices per
32
+ * triangle. scale multiplies every position (default 1). image is the skin as an
33
+ * encoded PNG, or null for a flat color (linear RGB 0..1). unlit adds
34
+ * KHR_materials_unlit. generator and extras go on the asset.
53
35
  * @typedef {{
54
36
  * name: string,
55
37
  * position: ArrayLike<number>,
@@ -80,7 +62,7 @@ export function glbFromModel(model) {
80
62
  if (index.length % 3 !== 0 || index.length < 3)
81
63
  throw new Error('gltf: indices come in triangles');
82
64
 
83
- // The vertex buffers — positions scaled, with their extent.
65
+ // Scaled positions and their extent.
84
66
  const pos = new Float32Array(n * 3);
85
67
  const min = [Infinity, Infinity, Infinity];
86
68
  const max = [-Infinity, -Infinity, -Infinity];
@@ -91,7 +73,7 @@ export function glbFromModel(model) {
91
73
  if (v < min[k]) min[k] = v;
92
74
  if (v > max[k]) max[k] = v;
93
75
  }
94
- // Float32 rounding: the accessor's bounds must contain the stored values.
76
+ // Round the bounds to Float32 so they contain the stored values.
95
77
  for (let k = 0; k < 3; k++) {
96
78
  min[k] = Math.fround(min[k]);
97
79
  max[k] = Math.fround(max[k]);
@@ -101,7 +83,7 @@ export function glbFromModel(model) {
101
83
  const wide = n > 65535;
102
84
  const idx = wide ? Uint32Array.from(index) : Uint16Array.from(index);
103
85
 
104
- // The BIN chunk: each view at a 4-aligned offset.
86
+ // BIN chunk: each view at a 4-aligned offset.
105
87
  /** @type {{bytes: Uint8Array, target?: number}[]} */
106
88
  const views = [
107
89
  { bytes: new Uint8Array(pos.buffer), target: ARRAY_BUFFER },
@@ -196,7 +178,7 @@ export function glbFromModel(model) {
196
178
  buffers: [{ byteLength: binLength }],
197
179
  };
198
180
 
199
- // The file: header, the JSON chunk (space-padded), the BIN chunk (zero-padded).
181
+ // Header, JSON chunk (space-padded), BIN chunk (zero-padded).
200
182
  const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
201
183
  const jsonLength = pad4(jsonBytes.length);
202
184
  const total = 12 + 8 + jsonLength + 8 + binLength;
@@ -217,9 +199,8 @@ export function glbFromModel(model) {
217
199
  }
218
200
 
219
201
  /**
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.
202
+ * Read a glb: its parsed JSON and its BIN chunk (a view into bytes). Throws unless
203
+ * it is a version-2 glb with a JSON chunk first.
223
204
  * @param {Uint8Array} bytes
224
205
  * @returns {{json: any, bin: Uint8Array}}
225
206
  */
@@ -244,7 +225,7 @@ export function glbParts(bytes) {
244
225
  }
245
226
 
246
227
  /**
247
- * A bufferView's bytes out of a read glb (a reader convenience).
228
+ * A bufferView's bytes from a read glb.
248
229
  * @param {{json: any, bin: Uint8Array}} parts @param {number} index
249
230
  */
250
231
  export function glbViewBytes(parts, index) {
package/src/index.js CHANGED
@@ -1,12 +1,6 @@
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
- // ---------------------------------------------------------------------------
1
+ // Public API of the sprite-machine package. Exports are listed by name: with
2
+ // export *, a name exported by two modules is silently dropped. The Node adapter
3
+ // is sprite-machine/node, the three adapter sprite-machine/three.
10
4
 
11
5
  export {
12
6
  packRGBA,
@@ -25,7 +19,7 @@ export {
25
19
  extractSurface,
26
20
  } from './carve.js';
27
21
  export { buildPalette, makeSnapper, colorize } from './colorize.js';
28
- export { buildVoxels } from './pipeline.js';
22
+ export { buildVoxels, unionVoxels, buildLayeredVoxels } from './pipeline.js';
29
23
  export {
30
24
  VIEW_TO_FACE,
31
25
  FACE_TO_VIEW,
@@ -50,6 +44,7 @@ export {
50
44
  DEFAULT_ATLAS_LAYOUT,
51
45
  TILE_MIN,
52
46
  TILE_MAX,
47
+ LAYER_MAX,
53
48
  clampTile,
54
49
  layoutSize,
55
50
  deriveTileSize,
@@ -57,6 +52,7 @@ export {
57
52
  contentBounds,
58
53
  validateSheet,
59
54
  sliceAtlas,
55
+ sliceLayers,
60
56
  blitTile,
61
57
  resizeTileTo,
62
58
  resizeTile,
@@ -64,10 +60,11 @@ export {
64
60
  resizeAtlas,
65
61
  cellOf,
66
62
  } from './atlas.js';
63
+ export { LAYERS_CHUNK, layersChunk, parseLayersChunk, layerCount } from './layers.js';
67
64
  export { traceRegions, planeKey, faceRegions } from './regions.js';
68
65
  export { eliminateTJunctions } from './t-junction.js';
69
66
  export { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
70
- export { skinTexture, finishVoxelMesh } from './mesh-util.js';
67
+ export { weldVertices } from './weld.js';
71
68
  export { wedgeMesh } from './wedge-mesh.js';
72
69
  export {
73
70
  PNG_SIGNATURE,
package/src/ingest.js CHANGED
@@ -1,23 +1,14 @@
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.
1
+ // Ingest: a sprite image to occupancy and color typed arrays, at native size. No
2
+ // THREE or DOM.
4
3
  //
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.
4
+ // Tiles are not cropped to their alpha bounds. A texel's place in its tile is its
5
+ // lattice position and must line up across faces.
11
6
  //
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
- // ---------------------------------------------------------------------------
7
+ // Input is ImageData-like { width, height, data } with RGBA bytes. Colors pack into
8
+ // a Uint32 as bytes r, g, b, a, little-endian.
16
9
 
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).
10
+ // Sprites are hard pixel art. A texel is solid at alpha >= 128, which tolerates the
11
+ // ±1 alpha noise privacy browsers add to canvas reads.
21
12
  const ALPHA_SOLID = 128;
22
13
 
23
14
  export const packRGBA = (r, g, b, a = 255) =>
@@ -50,9 +41,8 @@ function rot90cw(img) {
50
41
  return { width: nW, height: nH, data: out };
51
42
  }
52
43
 
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.
44
+ // Mirror an image horizontally (flipX) and/or vertically (flipY). With both false
45
+ // it returns the same object, otherwise a new copy.
56
46
  export function flip(img, flipX, flipY) {
57
47
  if (!flipX && !flipY) return img;
58
48
  const { width: W, height: H, data } = img;
@@ -73,8 +63,8 @@ export function flip(img, flipX, flipY) {
73
63
  }
74
64
 
75
65
  /**
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.
66
+ * Reorient a sprite to the pipeline's view conventions before ingest. rot is
67
+ * quarter-turns clockwise (0-3). Flips apply after the rotation.
78
68
  * @param {{width,height,data}} img
79
69
  * @param {{rot?:number, flipX?:boolean, flipY?:boolean}} t
80
70
  */
@@ -86,10 +76,8 @@ export function applyTransform(img, t = {}) {
86
76
  }
87
77
 
88
78
  /**
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).
79
+ * Occupancy and color for a whole tile at native size. A fully transparent tile
80
+ * returns null and counts as absent.
93
81
  * @param {{width:number,height:number,data:ArrayLike<number>}} img
94
82
  * @returns {{w:number,h:number,occ:Uint8Array,rgb:Uint32Array} | null}
95
83
  */
@@ -115,23 +103,18 @@ export function ingestSprite(img) {
115
103
  }
116
104
  }
117
105
  }
118
- if (!any) return null; // fully transparent -> absent (mirror-filled)
106
+ if (!any) return null;
119
107
  return { w: W, h: H, occ, rgb };
120
108
  }
121
109
 
122
110
  /**
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.
111
+ * Copy a view into a targetW × targetH grid at native scale with its (0,0) texel
112
+ * at (offX, offY). Uncovered cells stay empty and texels outside are clipped.
130
113
  * @returns {{occ:Uint8Array, rgb:Uint32Array}}
131
114
  */
132
115
  export function placeView(view, targetW, targetH, offX, offY) {
133
116
  const { w, h, occ, rgb } = view;
134
- if (w === targetW && h === targetH) return { occ, rgb }; // exact fit (offX/offY==0)
117
+ if (w === targetW && h === targetH) return { occ, rgb }; // assumes offX = offY = 0
135
118
  const outOcc = new Uint8Array(targetW * targetH);
136
119
  const outRgb = new Uint32Array(targetW * targetH);
137
120
  for (let sy = 0; sy < h; sy++) {
package/src/layers.js ADDED
@@ -0,0 +1,55 @@
1
+ // The sprite-machine:layers chunk: a document PNG's layer names in block order.
2
+ // Its length is the layer count.
3
+
4
+ import { DEFAULT_ATLAS_LAYOUT, LAYER_MAX, layoutSize } from './atlas.js';
5
+
6
+ /** PNG text chunk keyword. */
7
+ export const LAYERS_CHUNK = 'sprite-machine:layers';
8
+
9
+ /**
10
+ * The chunk's text, `{"layers":[{"name":"Layer 1"}]}` for one layer.
11
+ * @param {string[]} names one per block, in block order
12
+ * @returns {string}
13
+ */
14
+ export function layersChunk(names) {
15
+ return JSON.stringify({ layers: names.map((name) => ({ name })) });
16
+ }
17
+
18
+ /**
19
+ * The names in a chunk's text. Null for a missing or malformed chunk, or one
20
+ * naming no layers or more than LAYER_MAX.
21
+ * @param {string|null|undefined} text
22
+ * @returns {string[]|null}
23
+ */
24
+ export function parseLayersChunk(text) {
25
+ if (!text) return null;
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(text);
29
+ } catch {
30
+ return null;
31
+ }
32
+ const list = parsed?.layers;
33
+ if (!Array.isArray(list) || list.length < 1 || list.length > LAYER_MAX) return null;
34
+ const names = [];
35
+ for (const entry of list) {
36
+ if (typeof entry?.name !== 'string') return null;
37
+ names.push(entry.name);
38
+ }
39
+ return names;
40
+ }
41
+
42
+ /**
43
+ * The layer count of a sheet `height` px tall whose chunk holds `names`: their
44
+ * count when it divides the height into whole blocks, else null.
45
+ * @param {number} height
46
+ * @param {string[]|null|undefined} names
47
+ * @returns {number|null}
48
+ */
49
+ export function layerCount(height, names) {
50
+ const n = names?.length ?? 0;
51
+ const { rows } = layoutSize(DEFAULT_ATLAS_LAYOUT);
52
+ return n > 0 && Number.isInteger(height) && height > 0 && height % (rows * n) === 0
53
+ ? n
54
+ : null;
55
+ }
package/src/model.js CHANGED
@@ -1,102 +1,98 @@
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.
1
+ // Headless entry: a sheet's pixels to a model, and a model to a glb.
6
2
  //
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
- // ---------------------------------------------------------------------------
3
+ // buildModel runs slice, ingest, carve, colorize, the layer union and the
4
+ // wedge mesher, and returns the record at one unit per voxel, so a position is
5
+ // a lattice coordinate. modelToGlb writes the same glb as the app's export,
6
+ // with the skin encoded from bytes. Both are synchronous.
19
7
 
20
- import { sliceAtlas, validateSheet } from './atlas.js';
21
- import { buildVoxels } from './pipeline.js';
8
+ import { sliceLayers, validateSheet } from './atlas.js';
9
+ import { buildLayeredVoxels } from './pipeline.js';
22
10
  import { wedgeMesh } from './wedge-mesh.js';
23
11
  import { encodePng } from './png-encode.js';
24
12
  import { glbFromModel } from './gltf.js';
13
+ import { unpackRGBA } from './ingest.js';
25
14
  import { VIEW_NAMES } from './views.js';
26
15
 
27
16
  /**
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,
17
+ * A built model: the mesher's record, the lattice dims and the units per voxel
18
+ * (1 from buildModel).
19
+ * @typedef {import('./wedge-mesh.js').Built & {
32
20
  * dims: {nx:number, ny:number, nz:number},
33
21
  * unitsPerVoxel: number,
34
- * triangles: number,
35
22
  * warnings: string[],
36
23
  * }} Model
37
24
  */
38
25
 
39
26
  /**
40
- * Build the model of a 3×2 sprite sheet.
27
+ * Build the model of a sprite sheet: one 3×2 block of tiles, or `layers` blocks
28
+ * stacked top to bottom, whose hulls are unioned (unionVoxels).
41
29
  * @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
30
+ * the atlas's RGBA pixels, ImageData-shaped
31
+ * @param {{transforms?: Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}>,
32
+ * layers?: number}} [opts]
33
+ * `transforms`: per-view reorientation, as stored in the
34
+ * `sprite-machine:transforms` chunk, applied in every layer; `layers`: the
35
+ * sheet's block count
36
+ * @returns {Model} the model at one unit per voxel
37
+ * @throws on an invalid sheet, or one with no painted view in any layer
47
38
  */
48
- export function buildModel(sheet, { transforms = {} } = {}) {
39
+ export function buildModel(sheet, { transforms = {}, layers = 1 } = {}) {
49
40
  const bad = validateSheet(sheet);
50
41
  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++;
42
+ const sliced = sliceLayers(sheet, { layers });
43
+ const result = buildLayeredVoxels(
44
+ sliced.layers.map((views) =>
45
+ Object.fromEntries(VIEW_NAMES.map((n) => [n, views[n] || null]))
46
+ ),
47
+ { transforms }
48
+ );
49
+ if (result.providedViews.length === 0) {
50
+ throw new Error('buildModel: the sheet has no painted view.');
58
51
  }
59
- if (provided === 0) throw new Error('buildModel: the sheet has no painted view.');
60
- const result = buildVoxels(rawViews, { transforms });
61
52
  const { nx, ny, nz } = result.dims;
62
- const mesh = wedgeMesh(result, { worldSize: Math.max(nx, ny, nz) });
63
53
  return {
64
- mesh,
54
+ ...wedgeMesh(result, { worldSize: Math.max(nx, ny, nz) }),
65
55
  dims: result.dims,
66
56
  unitsPerVoxel: 1,
67
- triangles: Number(mesh.userData.triangles) || 0,
68
57
  warnings: [...sliced.warnings, ...(result.warnings || [])],
69
58
  };
70
59
  }
71
60
 
61
+ /** The sRGB transfer to linear, one channel in 0..1. */
62
+ const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
63
+
64
+ /** A packed sRGB color as linear RGB in 0..1. */
65
+ function linearRGB(packed) {
66
+ const { r, g, b } = unpackRGBA(packed);
67
+ return [r / 255, g / 255, b / 255].map(srgbToLinear);
68
+ }
69
+
72
70
  /**
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
71
+ * The model as a glb, with its skin embedded behind a nearest sampler, or its
72
+ * flat color, made linear, when it has no skin.
73
+ * @param {{geometry: import('./wedge-mesh.js').Geometry,
74
+ * skin: import('./skin.js').Skin|null, color?: number|null,
75
+ * dims: {nx:number, ny:number, nz:number}, unitsPerVoxel?: number}} model
76
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
77
+ * `voxelsPerMeter` sets the scale (at 10, a 40-voxel car is 4 m long);
78
+ * `unlit` adds KHR_materials_unlit; `generator` is written to the asset
80
79
  * @returns {Uint8Array} the .glb file
81
80
  */
82
81
  export function modelToGlb(
83
82
  model,
84
83
  { name, voxelsPerMeter = 10, unlit = false, generator }
85
84
  ) {
86
- const { mesh, dims } = model;
85
+ const { geometry, skin, dims } = model;
87
86
  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
87
  return glbFromModel({
92
88
  name,
93
- position: geo.attributes.position.array,
94
- normal: geo.attributes.normal.array,
95
- uv: geo.attributes.uv.array,
96
- index: geo.index.array,
89
+ position: geometry.position,
90
+ normal: geometry.normal,
91
+ uv: geometry.uv,
92
+ index: geometry.index,
97
93
  scale: 1 / (unitsPerVoxel * voxelsPerMeter),
98
- image: map ? { bytes: encodePng(map.image) } : null,
99
- color: map ? null : material.color.toArray(),
94
+ image: skin ? { bytes: encodePng(skin) } : null,
95
+ color: skin || model.color == null ? null : linearRGB(model.color),
100
96
  unlit,
101
97
  generator,
102
98
  extras: { 'sprite-machine': { voxelsPerMeter, dims: { ...dims } } },