sprite-machine 0.1.0 → 0.1.1
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 +93 -63
- package/bin/sprite-machine.mjs +2 -10
- package/package.json +2 -2
- 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 +4 -15
- package/src/faces.js +9 -24
- package/src/gltf.js +22 -41
- package/src/index.js +7 -10
- package/src/ingest.js +18 -35
- package/src/layers.js +55 -0
- package/src/mesh-util.js +10 -21
- package/src/model.js +32 -41
- 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/views.js +47 -86
- package/src/wedge-mesh.js +62 -140
package/src/wedge-mesh.js
CHANGED
|
@@ -1,65 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Additive-wedge low-poly engine.
|
|
1
|
+
// Low-poly wedge mesher over the voxel model (solid, surfaceMask, faceColor).
|
|
3
2
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Wedges: an empty cell with solid neighbors on two adjacent in-plane sides,
|
|
4
|
+
// and empty cells on the other two, is the inner corner of a staircase. It is
|
|
5
|
+
// filled with a triangular prism whose hypotenuse is a 45° slope. The two faces
|
|
6
|
+
// it covers are culled and its open ends get triangular gable caps. A wedge
|
|
7
|
+
// fires only when the two covered faces are the same material, so a color
|
|
8
|
+
// boundary stays a step. Wedges only fill notches, so convex corners stay
|
|
9
|
+
// sharp. One wedge per cell, and the first ridge in RIDGES wins.
|
|
7
10
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// Geometry is emitted per plane:
|
|
12
|
+
// - Slopes: the wedge cells of one 45° plane form a grid, greedy-merged by
|
|
13
|
+
// color into one quad per block.
|
|
14
|
+
// - Base faces: coplanar regions (regions.js) of exposed faces and cap halves,
|
|
15
|
+
// triangulated with earcut (THREE.ShapeUtils).
|
|
16
|
+
// Color comes from the skin (skin.js), so regions merge on occupancy alone.
|
|
17
|
+
// UVs are read from lattice positions after the T-junction repair.
|
|
13
18
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// eat the object (unlike the melted planar-remesh / slab-loft dead ends).
|
|
17
|
-
// - A wedge is ONE MATERIAL by its gate: it fires only where the two covered
|
|
18
|
-
// faces already agree on colour, so a window/body seam stays a sharp 45°
|
|
19
|
-
// edge with no depth guessing (the whole point of the reverted per-color-
|
|
20
|
-
// parts task), and its whole surface points at that colour's swatch.
|
|
21
|
-
// - A lone cube has no concave notch, so it gets NO wedges and stays sharp —
|
|
22
|
-
// the additive rule self-guards convex structural corners.
|
|
23
|
-
//
|
|
24
|
-
// COLOR IS THE SKIN, NOT THE GEOMETRY (Sep 7 2026). The base faces merge on
|
|
25
|
-
// occupancy alone and are painted by a texture (skin.js): a region that
|
|
26
|
-
// crosses a colour boundary carries a chart — a texel per cell — and a
|
|
27
|
-
// one-colour region, like every wedge, points at its colour's swatch; every
|
|
28
|
-
// triangle's UVs are an affine read of its vertices' lattice positions, taken
|
|
29
|
-
// AFTER the T-junction repair, so no UV is ever plumbed through a split.
|
|
30
|
-
// Until then every triangle carried a vertex colour and the merge could only
|
|
31
|
-
// join faces of one colour — a painted wall shattered into a rect per
|
|
32
|
-
// region, each boundary feeding the repair. The Car: 1784 → 900 triangles,
|
|
33
|
-
// the same 236 wedges. Opening the wedge gate to match was measured and
|
|
34
|
-
// rejected (1092: more wedges are more caps and more split faces), so the
|
|
35
|
-
// strict same-material gate stays exactly as it was.
|
|
36
|
-
//
|
|
37
|
-
// THE PLANAR MERGE (Sep 7 2026, the same day, in two steps). The scan fires
|
|
38
|
-
// per notch cell, but the geometry is emitted per PLANE:
|
|
39
|
-
// - A SLOPE is one quad per BLOCK: the wedge cells of one 45° plane (one
|
|
40
|
-
// orientation, one intercept) form a grid — t along the staircase, r along
|
|
41
|
-
// the ridge — that is greedy-merged on one colour. Emitted per cell, a
|
|
42
|
-
// windshield was a grid of unit quads, and every unit edge along a roof's
|
|
43
|
-
// rim pinned a vertex on it that the repair then had to fan the roof
|
|
44
|
-
// around (the Car's 17×10 roof: 20 triangles). Merging the runs along the
|
|
45
|
-
// ridge alone took the Car 900 → 408; across the staircase too it would
|
|
46
|
-
// have gained nothing (392) as long as the gable caps stayed a sawtooth
|
|
47
|
-
// of triangles whose corners split the slope's long diagonal edge back.
|
|
48
|
-
// - So the base faces are coplanar REGIONS (regions.js), not greedy rects:
|
|
49
|
-
// a plane's exposed faces AND the cap half-faces the blocks end on, traced
|
|
50
|
-
// as one polygon with every collinear run merged — the wall beside a
|
|
51
|
-
// windshield has one straight diagonal edge, and the slope beside it is
|
|
52
|
-
// two triangles. Each region is triangulated by earcut (THREE's
|
|
53
|
-
// ShapeUtils, holes included) and painted as one: a chart over its box
|
|
54
|
-
// where it crosses a colour, a swatch where it does not.
|
|
55
|
-
// The T-junction repair stays, now reading 45° edges too: a region's edge
|
|
56
|
-
// and a slope's ridge still meet to different extents where a corner of
|
|
57
|
-
// another plane lands on them, and every vertex is on the lattice.
|
|
58
|
-
//
|
|
59
|
-
// Scope: additive wedges only. Convex staircases (a hood sloping
|
|
60
|
-
// down-and-out) still step, and true 3-D corners where two ridges meet degrade
|
|
61
|
-
// to a step rather than a corner tile. One wedge per cell (first ridge wins).
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
19
|
+
// Not handled: convex staircases still step, and 3D corners where two ridges
|
|
20
|
+
// meet become a step.
|
|
63
21
|
|
|
64
22
|
import * as THREE from 'three';
|
|
65
23
|
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
@@ -73,12 +31,11 @@ import { finishVoxelMesh, skinTexture } from './mesh-util.js';
|
|
|
73
31
|
import { AXIS_INDEX, FACE_INDEX, faceKeyOf } from './views.js';
|
|
74
32
|
import { DEFAULT_WORLD_SIZE } from './constants.js';
|
|
75
33
|
|
|
76
|
-
const AXI = AXIS_INDEX;
|
|
34
|
+
const AXI = AXIS_INDEX;
|
|
77
35
|
const FLAT_COLOR = 0xffcfcfd6;
|
|
78
36
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// common extruded roof/windshield) win the one-wedge-per-cell tie.
|
|
37
|
+
// Ridge axes (R, the axis a prism extends along) with their in-plane axes A and
|
|
38
|
+
// B. Order matters: z comes first, so z-ridges win the one-wedge-per-cell tie.
|
|
82
39
|
const RIDGES = [
|
|
83
40
|
{ R: 'z', A: 'x', B: 'y' },
|
|
84
41
|
{ R: 'x', A: 'z', B: 'y' },
|
|
@@ -86,10 +43,8 @@ const RIDGES = [
|
|
|
86
43
|
];
|
|
87
44
|
|
|
88
45
|
/**
|
|
89
|
-
* A lattice triangle and its paint:
|
|
90
|
-
*
|
|
91
|
-
* one-colour region's, a slope's — carries the packed colour whose swatch it
|
|
92
|
-
* samples. The T-junction repair copies the paint onto every piece.
|
|
46
|
+
* A lattice triangle and its paint: its chart and region when the region is
|
|
47
|
+
* charted, otherwise the packed color of the swatch it samples.
|
|
93
48
|
* @typedef {{a:number[], b:number[], c:number[], normal:number[],
|
|
94
49
|
* chart:import('./skin.js').Chart|null,
|
|
95
50
|
* region:import('./regions.js').Region|null,
|
|
@@ -103,18 +58,13 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
103
58
|
const worldSize = opts.worldSize ?? DEFAULT_WORLD_SIZE;
|
|
104
59
|
const s = worldSize / Math.max(nx, ny, nz);
|
|
105
60
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
// entries. sameMat therefore compares with a small squared-L2 tolerance;
|
|
111
|
-
// distinct authored materials sit ~180 apart, far above the ~12 slack, so a
|
|
112
|
-
// colour boundary the artist drew still gates crisply.
|
|
113
|
-
const TOL2 = 12 * 12; // ~12 per-channel slack (squared L2): covers farble + AA
|
|
61
|
+
// Privacy browsers perturb getImageData by about ±1 per channel, which can
|
|
62
|
+
// split one material into near-duplicate palette entries. sameMat allows a
|
|
63
|
+
// squared RGB distance up to TOL2, which also absorbs antialiasing.
|
|
64
|
+
const TOL2 = 12 * 12;
|
|
114
65
|
const sameMat = (a, b) => {
|
|
115
66
|
if (a == null || b == null) return false;
|
|
116
|
-
// Compare RGB only
|
|
117
|
-
// the exact fast path and the tolerant path judging identity on the same bits.
|
|
67
|
+
// Compare RGB only. Alpha is always 255 here.
|
|
118
68
|
if (((a >>> 0) & 0xffffff) === ((b >>> 0) & 0xffffff)) return true;
|
|
119
69
|
const A = unpackRGBA(a);
|
|
120
70
|
const B = unpackRGBA(b);
|
|
@@ -129,9 +79,9 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
129
79
|
z + sg * +(ax === 'z'),
|
|
130
80
|
];
|
|
131
81
|
|
|
132
|
-
//
|
|
82
|
+
// Scan for wedges.
|
|
133
83
|
const wedgeCell = new Map(); // cellIdx -> chosen {R,A,B,sA,sB,color}
|
|
134
|
-
const removed = new Set(); // base faces (idx*6+f)
|
|
84
|
+
const removed = new Set(); // base faces (idx*6+f) covered by a wedge
|
|
135
85
|
const wedges = [];
|
|
136
86
|
|
|
137
87
|
for (const ridge of RIDGES) {
|
|
@@ -140,18 +90,18 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
140
90
|
for (let y = 0; y < ny; y++)
|
|
141
91
|
for (let x = 0; x < nx; x++) {
|
|
142
92
|
const cidx = voxIndex(x, y, z, dims);
|
|
143
|
-
if (solid[cidx] || wedgeCell.has(cidx)) continue; //
|
|
93
|
+
if (solid[cidx] || wedgeCell.has(cidx)) continue; // empty and unclaimed
|
|
144
94
|
for (const sA of [-1, 1]) {
|
|
145
95
|
let placed = false;
|
|
146
96
|
for (const sB of [-1, 1]) {
|
|
147
|
-
const aN = step(x, y, z, A, sA); // solid
|
|
148
|
-
const bN = step(x, y, z, B, sB); // solid
|
|
97
|
+
const aN = step(x, y, z, A, sA); // solid neighbor on A side
|
|
98
|
+
const bN = step(x, y, z, B, sB); // solid neighbor on B side
|
|
149
99
|
if (!solidAt(...aN) || !solidAt(...bN)) continue;
|
|
150
100
|
// opposite sides must be empty -> exactly two adjacent solids
|
|
151
101
|
if (solidAt(...step(x, y, z, A, -sA))) continue;
|
|
152
102
|
if (solidAt(...step(x, y, z, B, -sB))) continue;
|
|
153
103
|
|
|
154
|
-
//
|
|
104
|
+
// the covered faces: each neighbor's face pointing back at the cell
|
|
155
105
|
const faceA = faceKeyOf(A, -sA);
|
|
156
106
|
const faceB = faceKeyOf(B, -sB);
|
|
157
107
|
const aKey = voxIndex(...aN, dims) * 6 + FACE_INDEX[faceA];
|
|
@@ -159,23 +109,9 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
159
109
|
const cA = faceColor.get(aKey);
|
|
160
110
|
const cB = faceColor.get(bKey);
|
|
161
111
|
|
|
162
|
-
// Gate:
|
|
163
|
-
// surfaces the prism merges (the riser cA and the tread cB) — are
|
|
164
|
-
// the same material. Nothing else is consulted: no profile/facing
|
|
165
|
-
// view sampling, no occlusion march. This is deliberate and gives
|
|
166
|
-
// the sprite author exact, local control over every wedge: paint the
|
|
167
|
-
// two faces a corner joins the same colour and it ramps; paint them
|
|
168
|
-
// differently and it stays a crisp step. A slope smooths only where
|
|
169
|
-
// its riser and its up-facing tread read the same colour, so the
|
|
170
|
-
// top-view art over a slope must match the face it caps — the author
|
|
171
|
-
// decides which corners round, not a heuristic guess about "slopes".
|
|
172
|
-
// (And, since the skin: a looser gate would COST triangles — more
|
|
173
|
-
// wedges are more caps and more split base faces, measured on the
|
|
174
|
-
// Car — so the strictness is the count's too.)
|
|
112
|
+
// Gate: the covered riser (cA) and tread (cB) must be the same material.
|
|
175
113
|
if (!flat && !sameMat(cA, cB)) continue;
|
|
176
|
-
//
|
|
177
|
-
// surface's true colour. (In flat mode the wedge colour is overridden
|
|
178
|
-
// to FLAT_COLOR downstream, so a null here can never render.)
|
|
114
|
+
// cA and cB agree unless flat, where FLAT_COLOR replaces the color.
|
|
179
115
|
const color = (cA != null ? cA : cB) >>> 0;
|
|
180
116
|
wedgeCell.set(cidx, { R, A, B, sA, sB, color });
|
|
181
117
|
removed.add(aKey);
|
|
@@ -189,15 +125,13 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
189
125
|
}
|
|
190
126
|
}
|
|
191
127
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// T-junctions the merges leave, THEN build the scaled buffers.
|
|
128
|
+
// Emit integer lattice triangles, repair T-junctions, then build the scaled
|
|
129
|
+
// buffers.
|
|
195
130
|
/** @type {Tri[]} */
|
|
196
|
-
const tris = []; // CCW
|
|
197
|
-
// paint: { chart, region } for a charted region, { swatch }
|
|
198
|
-
// one-material primitive (a one-colour region, a slope)
|
|
131
|
+
const tris = []; // CCW about normal
|
|
132
|
+
// paint: { chart, region } for a charted region, otherwise { swatch }
|
|
199
133
|
const pushTri = (a, b, c, N, paint) => {
|
|
200
|
-
// wind
|
|
134
|
+
// wind CCW about the outward normal N (backface culling is on)
|
|
201
135
|
const ux = b[0] - a[0],
|
|
202
136
|
uy = b[1] - a[1],
|
|
203
137
|
uz = b[2] - a[2];
|
|
@@ -241,8 +175,7 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
241
175
|
const L = Math.hypot(p[0], p[1], p[2]) || 1;
|
|
242
176
|
return [p[0] / L, p[1] / L, p[2] / L];
|
|
243
177
|
};
|
|
244
|
-
// a
|
|
245
|
-
// one toward the two solids) and the opposite one
|
|
178
|
+
// a wedge cell's corners along A and B: c toward the two solids, o opposite
|
|
246
179
|
const cornersOf = (w) => {
|
|
247
180
|
const p = { x: w.x, y: w.y, z: w.z };
|
|
248
181
|
const aC = p[w.A];
|
|
@@ -255,14 +188,11 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
255
188
|
};
|
|
256
189
|
};
|
|
257
190
|
|
|
258
|
-
// 1.
|
|
259
|
-
// orientation (R, A, B, sA, sB) and
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
// a block's slope is one quad from its first staircase cell's far corners to
|
|
264
|
-
// its last's, swept over its r-range. One material each by the gate, so
|
|
265
|
-
// every slope samples its colour's swatch.
|
|
191
|
+
// 1. Slopes, one quad per block. The cells of one 45° plane share an
|
|
192
|
+
// orientation (R, A, B, sA, sB) and an intercept sA·a + sB·b. They form a grid
|
|
193
|
+
// with t = sA·a up the staircase and r along the ridge, greedy-merged by
|
|
194
|
+
// color along r first. A block's quad runs from its first cell's corners to
|
|
195
|
+
// its last cell's, across its r range, and samples its color's swatch.
|
|
266
196
|
const planes = new Map(); // plane key -> Map<'t,r', {t, r, w}>
|
|
267
197
|
for (const w of wedges) {
|
|
268
198
|
const p = { x: w.x, y: w.y, z: w.z };
|
|
@@ -313,11 +243,10 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
313
243
|
}
|
|
314
244
|
}
|
|
315
245
|
|
|
316
|
-
// 2.
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// that face's plane in the face's own tangent frame.
|
|
246
|
+
// 2. Gable caps, as half pieces of the planes they lie on. A cell end gets a
|
|
247
|
+
// cap unless it meets solid or a wedge of the same orientation. The cap is
|
|
248
|
+
// the right triangle in the cell's ±R face with its right angle at the filled
|
|
249
|
+
// corner (Ac, Bc), in that face's tangent frame.
|
|
321
250
|
/** @type {Map<string, import('./regions.js').Half[]>} */
|
|
322
251
|
const halves = new Map();
|
|
323
252
|
for (const w of wedges) {
|
|
@@ -327,7 +256,7 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
327
256
|
if (solidAt(ex, ey, ez)) continue; // internal against solid
|
|
328
257
|
if (inBounds(ex, ey, ez)) {
|
|
329
258
|
const wn = wedgeCell.get(voxIndex(ex, ey, ez, dims));
|
|
330
|
-
if (wn && wn.R === w.R && wn.sA === w.sA && wn.sB === w.sB) continue;
|
|
259
|
+
if (wn && wn.R === w.R && wn.sA === w.sA && wn.sB === w.sB) continue;
|
|
331
260
|
}
|
|
332
261
|
const face = faceKeyOf(w.R, sg);
|
|
333
262
|
const g = FACE_GEO[face];
|
|
@@ -343,10 +272,9 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
343
272
|
}
|
|
344
273
|
}
|
|
345
274
|
|
|
346
|
-
// 3.
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// has no skin: every primitive is the flat grey, the UVs zero.
|
|
275
|
+
// 3. Regions: each plane's uncovered exposed faces plus its caps. Bake the
|
|
276
|
+
// skin once, then triangulate each region with earcut. Flat mode has no
|
|
277
|
+
// skin: every triangle is FLAT_COLOR and the UVs are zero.
|
|
350
278
|
const baseMask = surfaceMask.slice();
|
|
351
279
|
for (const rk of removed) baseMask[(rk / 6) | 0] &= ~(1 << rk % 6);
|
|
352
280
|
const regions = faceRegions(dims, baseMask, faceColor, halves);
|
|
@@ -380,15 +308,13 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
380
308
|
);
|
|
381
309
|
});
|
|
382
310
|
|
|
383
|
-
// 4.
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
// repair inserted along an edge included), a swatch triangle's three sit at
|
|
387
|
-
// its texel's centre. Texel coords over the skin's size make the [0,1] UV.
|
|
311
|
+
// 4. Repair T-junctions, then flatten to scaled buffers. UVs are read after
|
|
312
|
+
// the repair: a charted vertex maps into its chart by lattice position, and
|
|
313
|
+
// a swatch triangle's vertices sit at the swatch texel's center.
|
|
388
314
|
const repaired = eliminateTJunctions(tris);
|
|
389
315
|
const pos = new Float32Array(repaired.length * 9);
|
|
390
316
|
const nrm = new Float32Array(repaired.length * 9);
|
|
391
|
-
const uv = new Float32Array(repaired.length * 6); //
|
|
317
|
+
const uv = new Float32Array(repaired.length * 6); // zero in flat mode
|
|
392
318
|
let o = 0;
|
|
393
319
|
let q = 0;
|
|
394
320
|
for (const t of repaired) {
|
|
@@ -410,20 +336,16 @@ export function wedgeMesh(result, opts = {}) {
|
|
|
410
336
|
}
|
|
411
337
|
}
|
|
412
338
|
|
|
413
|
-
//
|
|
339
|
+
// Assemble.
|
|
414
340
|
let geo = new THREE.BufferGeometry();
|
|
415
341
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
416
342
|
geo.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
|
|
417
343
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
|
418
|
-
// Weld
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
// positions, so the split costs it nothing). Normals are load-bearing HERE
|
|
422
|
-
// and in diag.js — not for lighting (flatShading recomputes them per-face
|
|
423
|
-
// in the shader).
|
|
344
|
+
// Weld by position, normal and uv, so vertices of differently facing
|
|
345
|
+
// triangles or of different charts stay split. flatShading ignores the
|
|
346
|
+
// normals, but this weld and diag.js depend on them.
|
|
424
347
|
geo = mergeVertices(geo, 1e-4);
|
|
425
348
|
|
|
426
|
-
// finishVoxelMesh centres X/Z and leaves Y as authored (wedge-mesh.test pins it).
|
|
427
349
|
const charted = skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0;
|
|
428
350
|
const paint = skin ? { map: skinTexture(skin) } : { color: FLAT_COLOR };
|
|
429
351
|
return finishVoxelMesh(geo, {
|