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/README.md +113 -64
- package/bin/sprite-machine.mjs +2 -10
- package/package.json +11 -4
- package/src/atlas.js +136 -142
- package/src/carve.js +18 -51
- package/src/colorize.js +14 -29
- package/src/constants.js +7 -12
- package/src/diag.js +13 -20
- package/src/faces.js +9 -24
- package/src/gltf.js +22 -41
- package/src/index.js +8 -11
- package/src/ingest.js +18 -35
- package/src/layers.js +55 -0
- package/src/model.js +55 -59
- package/src/node.js +23 -17
- package/src/pipeline.js +124 -9
- package/src/png-chunks.js +26 -46
- package/src/png-encode.js +12 -22
- package/src/regions.js +31 -48
- package/src/skin.js +37 -66
- package/src/t-junction.js +21 -39
- package/src/three.js +60 -0
- package/src/views.js +47 -86
- package/src/wedge-mesh.js +120 -169
- package/src/weld.js +56 -0
- package/src/mesh-util.js +0 -65
package/src/constants.js
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
11
|
-
* mirrored opposite view. On for every axis
|
|
12
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// are
|
|
25
|
-
//
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
221
|
-
*
|
|
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
|
|
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
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
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
|
|
13
|
-
//
|
|
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
|
|
18
|
-
//
|
|
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
|
-
//
|
|
54
|
-
//
|
|
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
|
|
77
|
-
*
|
|
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
|
-
*
|
|
90
|
-
*
|
|
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;
|
|
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
|
|
124
|
-
*
|
|
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 }; //
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
|
29
|
-
*
|
|
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
|
|
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
|
|
43
|
-
* @param {{transforms?: Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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 =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
*
|
|
75
|
-
* @param {{
|
|
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`
|
|
78
|
-
* `unlit`
|
|
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 {
|
|
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:
|
|
94
|
-
normal:
|
|
95
|
-
uv:
|
|
96
|
-
index:
|
|
89
|
+
position: geometry.position,
|
|
90
|
+
normal: geometry.normal,
|
|
91
|
+
uv: geometry.uv,
|
|
92
|
+
index: geometry.index,
|
|
97
93
|
scale: 1 / (unitsPerVoxel * voxelsPerMeter),
|
|
98
|
-
image:
|
|
99
|
-
color:
|
|
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 } } },
|