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/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
- // Builds directly on the working voxel model (result.solid / surfaceMask /
5
- // faceColor) instead of remeshing a silhouette. The ONLY new geometry is a 45°
6
- // WEDGE that fills a concave unit-step notch:
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
- // An EMPTY cell whose two solid orthogonal neighbours sit on ADJACENT sides
9
- // (and whose other two in-plane sides are empty) is the inner corner of a
10
- // staircase. We fill that corner with a triangular prism; its hypotenuse is
11
- // the 45° slope, the two faces it covers become internal (culled), and the
12
- // run is closed at its ends with triangular caps (the "gable" triangles).
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
- // Why this is correct-by-construction where the earlier attempts failed:
15
- // - It's ADDITIVE: wedges only fill notches, so it can never punch a hole or
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; // world-axis name -> [x,y,z] index
34
+ const AXI = AXIS_INDEX;
77
35
  const FLAT_COLOR = 0xffcfcfd6;
78
36
 
79
- // The three ridge axes (the axis a wedge prism extends along) and their two
80
- // in-plane tangent axes (A, B). Order matters: z first so long z-ridges (the
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: a charted region's triangle carries its
90
- * chart and region (its UVs are read off its vertices); everything else — a
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
- // The wedge fires only where its two covered faces are the same material.
107
- // Those faceColor values are already palette-snapped by colorize, but privacy
108
- // browsers "farble" getImageData (~±1/channel), which splits the palette into
109
- // near-duplicate entries, so two same-material faces can land on *adjacent*
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; the alpha byte is always 255 here, so masking it keeps
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
- // --- scan for wedges ------------------------------------------------------
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) culled because a wedge covers them
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; // C must be empty & unclaimed
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 neighbour on A side
148
- const bN = step(x, y, z, B, sB); // solid neighbour on B side
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
- // faces the wedge covers = each neighbour's face pointing back at C
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: fire the wedge iff its two COVERED faces the only two
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
- // The non-flat gate guarantees cA and cB agree, so either is the
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
- // --- geometry emit --------------------------------------------------------
193
- // Collect INTEGER-lattice triangles first (regions + slopes), eliminate the
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 wrt normal
197
- // paint: { chart, region } for a charted region, { swatch } for a
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 to match the explicit outward normal N (backface culling is on)
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 notch cell's corners along its wedge's A and B: the filled corner (the
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. the slopes, one quad per BLOCK. The cells of one 45° plane one
259
- // orientation (R, A, B, sA, sB) and one intercept sA·a + sB·b sit on a
260
- // grid: t = sA·a runs up the staircase (the cell at (a + sA, b − sB) is
261
- // t + 1, its hypotenuse the continuation of this one's), r along the ridge.
262
- // Greedy-merge that grid on one colour, the ridge first (the long runs), and
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. the gable caps, as HALF pieces of the planes they lie on: a cell's end
317
- // is capped unless the prism runs on into a wedge of the same orientation
318
- // there or ends against solid. The cap is the right triangle in the cell's
319
- // ±R face whose right angle sits at the filled corner (Ac, Bc), filed under
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; // the prism runs on
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. the regions: every plane's exposed faces (minus the ones the wedges
347
- // cover) with its caps, traced (regions.js), the skin baked over them once,
348
- // then each triangulated by earcut and emitted with its paint. Flat mode
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. stitch out T-junctions, then flatten to scaled vertex buffers. The UVs
384
- // are read HERE, after the repair, from each vertex's lattice position: a
385
- // charted triangle's vertex is an affine read into its chart (a vertex the
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); // present, and zero, in flat mode
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
- // --- assemble -------------------------------------------------------------
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 coincident lattice vertices by position+normal+uv so distinct-facing
419
- // slope/region vertices stay split and so do two charts' vertices at one
420
- // lattice point (they sample different texels; watertightness is judged on
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, {