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/wedge-mesh.js CHANGED
@@ -1,84 +1,40 @@
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.
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
- import * as THREE from 'three';
65
- import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
22
+ import earcut from 'earcut';
66
23
  import { voxIndex } from './carve.js';
67
24
  import { FACE_GEO, pointOf } from './faces.js';
68
25
  import { faceRegions, planeKey } from './regions.js';
69
26
  import { unpackRGBA } from './ingest.js';
70
27
  import { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
71
28
  import { eliminateTJunctions } from './t-junction.js';
72
- import { finishVoxelMesh, skinTexture } from './mesh-util.js';
29
+ import { weldVertices } from './weld.js';
73
30
  import { AXIS_INDEX, FACE_INDEX, faceKeyOf } from './views.js';
74
31
  import { DEFAULT_WORLD_SIZE } from './constants.js';
75
32
 
76
- const AXI = AXIS_INDEX; // world-axis name -> [x,y,z] index
33
+ const AXI = AXIS_INDEX;
77
34
  const FLAT_COLOR = 0xffcfcfd6;
78
35
 
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.
36
+ // Ridge axes (R, the axis a prism extends along) with their in-plane axes A and
37
+ // B. Order matters: z comes first, so z-ridges win the one-wedge-per-cell tie.
82
38
  const RIDGES = [
83
39
  { R: 'z', A: 'x', B: 'y' },
84
40
  { R: 'x', A: 'z', B: 'y' },
@@ -86,16 +42,36 @@ const RIDGES = [
86
42
  ];
87
43
 
88
44
  /**
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.
45
+ * A lattice triangle and its paint: its chart and region when the region is
46
+ * charted, otherwise the packed color of the swatch it samples.
93
47
  * @typedef {{a:number[], b:number[], c:number[], normal:number[],
94
48
  * chart:import('./skin.js').Chart|null,
95
49
  * region:import('./regions.js').Region|null,
96
50
  * swatch:number|null}} Tri
97
51
  */
98
52
 
53
+ /**
54
+ * Indexed triangle buffers: xyz positions centered on X and Z with Y as
55
+ * authored (see carve.js), unit normals, uv pairs in [0, 1] (zero in flat
56
+ * mode), CCW index triples and the positions' bounds.
57
+ * @typedef {{position:Float32Array, normal:Float32Array, uv:Float32Array,
58
+ * index:Uint32Array, bounds:{min:number[], max:number[]}}} Geometry
59
+ */
60
+
61
+ /**
62
+ * The mesher's output: the geometry, the skin or, in flat mode, a packed flat
63
+ * color, and the counts. sprite-machine/three turns it into a THREE.Mesh.
64
+ * @typedef {{geometry:Geometry, skin:import('./skin.js').Skin|null,
65
+ * color:number|null, triangles:number, wedges:number,
66
+ * slopes:number, charts:number}} Built
67
+ */
68
+
69
+ /**
70
+ * @param {object} result a buildVoxels result
71
+ * @param {{flat?:boolean, worldSize?:number}} [opts] `flat` skips the skin
72
+ * and the wedge gate; `worldSize` is the longest side's length
73
+ * @returns {Built}
74
+ */
99
75
  export function wedgeMesh(result, opts = {}) {
100
76
  const { dims, solid, surfaceMask, faceColor } = result;
101
77
  const { nx, ny, nz } = dims;
@@ -103,18 +79,13 @@ export function wedgeMesh(result, opts = {}) {
103
79
  const worldSize = opts.worldSize ?? DEFAULT_WORLD_SIZE;
104
80
  const s = worldSize / Math.max(nx, ny, nz);
105
81
 
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
82
+ // Privacy browsers perturb getImageData by about ±1 per channel, which can
83
+ // split one material into near-duplicate palette entries. sameMat allows a
84
+ // squared RGB distance up to TOL2, which also absorbs antialiasing.
85
+ const TOL2 = 12 * 12;
114
86
  const sameMat = (a, b) => {
115
87
  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.
88
+ // Compare RGB only. Alpha is always 255 here.
118
89
  if (((a >>> 0) & 0xffffff) === ((b >>> 0) & 0xffffff)) return true;
119
90
  const A = unpackRGBA(a);
120
91
  const B = unpackRGBA(b);
@@ -129,9 +100,9 @@ export function wedgeMesh(result, opts = {}) {
129
100
  z + sg * +(ax === 'z'),
130
101
  ];
131
102
 
132
- // --- scan for wedges ------------------------------------------------------
103
+ // Scan for wedges.
133
104
  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
105
+ const removed = new Set(); // base faces (idx*6+f) covered by a wedge
135
106
  const wedges = [];
136
107
 
137
108
  for (const ridge of RIDGES) {
@@ -140,18 +111,18 @@ export function wedgeMesh(result, opts = {}) {
140
111
  for (let y = 0; y < ny; y++)
141
112
  for (let x = 0; x < nx; x++) {
142
113
  const cidx = voxIndex(x, y, z, dims);
143
- if (solid[cidx] || wedgeCell.has(cidx)) continue; // C must be empty & unclaimed
114
+ if (solid[cidx] || wedgeCell.has(cidx)) continue; // empty and unclaimed
144
115
  for (const sA of [-1, 1]) {
145
116
  let placed = false;
146
117
  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
118
+ const aN = step(x, y, z, A, sA); // solid neighbor on A side
119
+ const bN = step(x, y, z, B, sB); // solid neighbor on B side
149
120
  if (!solidAt(...aN) || !solidAt(...bN)) continue;
150
121
  // opposite sides must be empty -> exactly two adjacent solids
151
122
  if (solidAt(...step(x, y, z, A, -sA))) continue;
152
123
  if (solidAt(...step(x, y, z, B, -sB))) continue;
153
124
 
154
- // faces the wedge covers = each neighbour's face pointing back at C
125
+ // the covered faces: each neighbor's face pointing back at the cell
155
126
  const faceA = faceKeyOf(A, -sA);
156
127
  const faceB = faceKeyOf(B, -sB);
157
128
  const aKey = voxIndex(...aN, dims) * 6 + FACE_INDEX[faceA];
@@ -159,23 +130,9 @@ export function wedgeMesh(result, opts = {}) {
159
130
  const cA = faceColor.get(aKey);
160
131
  const cB = faceColor.get(bKey);
161
132
 
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.)
133
+ // Gate: the covered riser (cA) and tread (cB) must be the same material.
175
134
  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.)
135
+ // cA and cB agree unless flat, where FLAT_COLOR replaces the color.
179
136
  const color = (cA != null ? cA : cB) >>> 0;
180
137
  wedgeCell.set(cidx, { R, A, B, sA, sB, color });
181
138
  removed.add(aKey);
@@ -189,15 +146,13 @@ export function wedgeMesh(result, opts = {}) {
189
146
  }
190
147
  }
191
148
 
192
- // --- geometry emit --------------------------------------------------------
193
- // Collect INTEGER-lattice triangles first (regions + slopes), eliminate the
194
- // T-junctions the merges leave, THEN build the scaled buffers.
149
+ // Emit integer lattice triangles, repair T-junctions, then build the scaled
150
+ // buffers.
195
151
  /** @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)
152
+ const tris = []; // CCW about normal
153
+ // paint: { chart, region } for a charted region, otherwise { swatch }
199
154
  const pushTri = (a, b, c, N, paint) => {
200
- // wind to match the explicit outward normal N (backface culling is on)
155
+ // wind CCW about the outward normal N (backface culling is on)
201
156
  const ux = b[0] - a[0],
202
157
  uy = b[1] - a[1],
203
158
  uz = b[2] - a[2];
@@ -241,8 +196,7 @@ export function wedgeMesh(result, opts = {}) {
241
196
  const L = Math.hypot(p[0], p[1], p[2]) || 1;
242
197
  return [p[0] / L, p[1] / L, p[2] / L];
243
198
  };
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
199
+ // a wedge cell's corners along A and B: c toward the two solids, o opposite
246
200
  const cornersOf = (w) => {
247
201
  const p = { x: w.x, y: w.y, z: w.z };
248
202
  const aC = p[w.A];
@@ -255,14 +209,11 @@ export function wedgeMesh(result, opts = {}) {
255
209
  };
256
210
  };
257
211
 
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.
212
+ // 1. Slopes, one quad per block. The cells of one 45° plane share an
213
+ // orientation (R, A, B, sA, sB) and an intercept sA·a + sB·b. They form a grid
214
+ // with t = sA·a up the staircase and r along the ridge, greedy-merged by
215
+ // color along r first. A block's quad runs from its first cell's corners to
216
+ // its last cell's, across its r range, and samples its color's swatch.
266
217
  const planes = new Map(); // plane key -> Map<'t,r', {t, r, w}>
267
218
  for (const w of wedges) {
268
219
  const p = { x: w.x, y: w.y, z: w.z };
@@ -313,11 +264,10 @@ export function wedgeMesh(result, opts = {}) {
313
264
  }
314
265
  }
315
266
 
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.
267
+ // 2. Gable caps, as half pieces of the planes they lie on. A cell end gets a
268
+ // cap unless it meets solid or a wedge of the same orientation. The cap is
269
+ // the right triangle in the cell's ±R face with its right angle at the filled
270
+ // corner (Ac, Bc), in that face's tangent frame.
321
271
  /** @type {Map<string, import('./regions.js').Half[]>} */
322
272
  const halves = new Map();
323
273
  for (const w of wedges) {
@@ -327,7 +277,7 @@ export function wedgeMesh(result, opts = {}) {
327
277
  if (solidAt(ex, ey, ez)) continue; // internal against solid
328
278
  if (inBounds(ex, ey, ez)) {
329
279
  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
280
+ if (wn && wn.R === w.R && wn.sA === w.sA && wn.sB === w.sB) continue;
331
281
  }
332
282
  const face = faceKeyOf(w.R, sg);
333
283
  const g = FACE_GEO[face];
@@ -343,10 +293,9 @@ export function wedgeMesh(result, opts = {}) {
343
293
  }
344
294
  }
345
295
 
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.
296
+ // 3. Regions: each plane's uncovered exposed faces plus its caps. Bake the
297
+ // skin once, then triangulate each region with earcut. Flat mode has no
298
+ // skin: every triangle is FLAT_COLOR and the UVs are zero.
350
299
  const baseMask = surfaceMask.slice();
351
300
  for (const rk of removed) baseMask[(rk / 6) | 0] &= ~(1 << rk % 6);
352
301
  const regions = faceRegions(dims, baseMask, faceColor, halves);
@@ -356,15 +305,18 @@ export function wedgeMesh(result, opts = {}) {
356
305
  const paint = chart
357
306
  ? { chart, region }
358
307
  : { swatch: flat ? FLAT_COLOR : /** @type {number} */ (region.uniform) >>> 0 };
359
- const toV2 = (loop) => loop.map(([a, b]) => new THREE.Vector2(a, b));
360
- const faces = THREE.ShapeUtils.triangulateShape(
361
- toV2(region.outer),
362
- region.holes.map(toV2)
363
- );
308
+ // earcut takes the loops flat, the holes by their first vertex's index.
309
+ const coords = [];
310
+ const holeStarts = [];
311
+ for (const hole of [region.outer, ...region.holes]) {
312
+ if (hole !== region.outer) holeStarts.push(coords.length / 2);
313
+ for (const [a, b] of hole) coords.push(a, b);
314
+ }
315
+ const faces = earcut(coords, holeStarts);
364
316
  const verts = [region.outer, ...region.holes].flat();
365
317
  let area = 0;
366
- for (const [i0, i1, i2] of faces) {
367
- const [p, q, r] = [verts[i0], verts[i1], verts[i2]];
318
+ for (let k = 0; k < faces.length; k += 3) {
319
+ const [p, q, r] = [verts[faces[k]], verts[faces[k + 1]], verts[faces[k + 2]]];
368
320
  area += Math.abs((q[0] - p[0]) * (r[1] - p[1]) - (r[0] - p[0]) * (q[1] - p[1]));
369
321
  pushTri(
370
322
  pointOf(region.face, p[0], p[1], region.s),
@@ -380,15 +332,13 @@ export function wedgeMesh(result, opts = {}) {
380
332
  );
381
333
  });
382
334
 
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.
335
+ // 4. Repair T-junctions, then flatten to scaled buffers. UVs are read after
336
+ // the repair: a charted vertex maps into its chart by lattice position, and
337
+ // a swatch triangle's vertices sit at the swatch texel's center.
388
338
  const repaired = eliminateTJunctions(tris);
389
339
  const pos = new Float32Array(repaired.length * 9);
390
340
  const nrm = new Float32Array(repaired.length * 9);
391
- const uv = new Float32Array(repaired.length * 6); // present, and zero, in flat mode
341
+ const uv = new Float32Array(repaired.length * 6); // zero in flat mode
392
342
  let o = 0;
393
343
  let q = 0;
394
344
  for (const t of repaired) {
@@ -410,32 +360,33 @@ export function wedgeMesh(result, opts = {}) {
410
360
  }
411
361
  }
412
362
 
413
- // --- assemble -------------------------------------------------------------
414
- let geo = new THREE.BufferGeometry();
415
- geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
416
- geo.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
417
- 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).
424
- geo = mergeVertices(geo, 1e-4);
363
+ // 5. Weld by position, normal and uv, so vertices of differently facing
364
+ // triangles or of different charts stay split. Flat shading ignores the
365
+ // normals, but this weld and diag.js depend on them. Then center X and Z and
366
+ // take the bounds.
367
+ const welded = weldVertices(pos, nrm, uv);
368
+ const { position } = welded;
369
+ const dx = (-nx * s) / 2;
370
+ const dz = (-nz * s) / 2;
371
+ const min = [Infinity, Infinity, Infinity];
372
+ const max = [-Infinity, -Infinity, -Infinity];
373
+ for (let i = 0; i < position.length; i += 3) {
374
+ position[i] += dx;
375
+ position[i + 2] += dz;
376
+ for (let k = 0; k < 3; k++) {
377
+ const v = position[i + k];
378
+ if (v < min[k]) min[k] = v;
379
+ if (v > max[k]) max[k] = v;
380
+ }
381
+ }
425
382
 
426
- // finishVoxelMesh centres X/Z and leaves Y as authored (wedge-mesh.test pins it).
427
- const charted = skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0;
428
- const paint = skin ? { map: skinTexture(skin) } : { color: FLAT_COLOR };
429
- return finishVoxelMesh(geo, {
430
- nx,
431
- nz,
432
- s,
433
- ...paint,
434
- userData: {
435
- triangles: geo.index ? geo.index.count / 3 : repaired.length,
436
- wedges: wedges.length, // cells
437
- slopes,
438
- skin: skin ? { width: skin.width, height: skin.height, charts: charted } : null,
439
- },
440
- });
383
+ return {
384
+ geometry: { ...welded, bounds: { min, max } },
385
+ skin,
386
+ color: skin ? null : FLAT_COLOR,
387
+ triangles: welded.index.length / 3,
388
+ wedges: wedges.length, // cells
389
+ slopes,
390
+ charts: skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0,
391
+ };
441
392
  }
package/src/weld.js ADDED
@@ -0,0 +1,56 @@
1
+ // Vertex weld for the mesher. Indexes flat triangle buffers, merging the
2
+ // vertices whose position, normal and uv agree. A component's key is its value
3
+ // scaled by ten thousand, offset by a half and truncated, the rule three's
4
+ // mergeVertices applies at its default tolerance, so the welded buffers match
5
+ // what it produced. Vertices keep first-seen order.
6
+
7
+ const SCALE = 1e4;
8
+ const key = (v) => ~~(v * SCALE + 0.5);
9
+
10
+ /**
11
+ * @param {Float32Array} position xyz per vertex, three vertices per triangle
12
+ * @param {Float32Array} normal xyz per vertex
13
+ * @param {Float32Array} uv uv per vertex
14
+ * @returns {{position:Float32Array, normal:Float32Array, uv:Float32Array, index:Uint32Array}}
15
+ * the unique vertices and a triangle index into them
16
+ */
17
+ export function weldVertices(position, normal, uv) {
18
+ const count = position.length / 3;
19
+ if (!Number.isInteger(count) || normal.length !== count * 3 || uv.length !== count * 2)
20
+ throw new Error('weldVertices: one normal and one uv per position');
21
+ const outPosition = new Float32Array(position.length);
22
+ const outNormal = new Float32Array(normal.length);
23
+ const outUv = new Float32Array(uv.length);
24
+ const index = new Uint32Array(count);
25
+ const seen = new Map();
26
+ let next = 0;
27
+ for (let i = 0; i < count; i++) {
28
+ const p = i * 3;
29
+ const t = i * 2;
30
+ const k =
31
+ `${key(position[p])},${key(position[p + 1])},${key(position[p + 2])},` +
32
+ `${key(normal[p])},${key(normal[p + 1])},${key(normal[p + 2])},` +
33
+ `${key(uv[t])},${key(uv[t + 1])}`;
34
+ let j = seen.get(k);
35
+ if (j === undefined) {
36
+ j = next++;
37
+ seen.set(k, j);
38
+ const q = j * 3;
39
+ outPosition[q] = position[p];
40
+ outPosition[q + 1] = position[p + 1];
41
+ outPosition[q + 2] = position[p + 2];
42
+ outNormal[q] = normal[p];
43
+ outNormal[q + 1] = normal[p + 1];
44
+ outNormal[q + 2] = normal[p + 2];
45
+ outUv[j * 2] = uv[t];
46
+ outUv[j * 2 + 1] = uv[t + 1];
47
+ }
48
+ index[i] = j;
49
+ }
50
+ return {
51
+ position: outPosition.slice(0, next * 3),
52
+ normal: outNormal.slice(0, next * 3),
53
+ uv: outUv.slice(0, next * 2),
54
+ index,
55
+ };
56
+ }
package/src/mesh-util.js DELETED
@@ -1,65 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // Shared helpers for the wedge mesh builder (wedge-mesh.js), and the ONE place
3
- // THREE meets the skin: the texture the pure bake (skin.js) becomes, and the
4
- // framing + material the builder finishes with. (The plain voxel builder that
5
- // once shared them, mesh.js, went with the test trim of Sep 5 2026 — dead
6
- // code with no consumer; the vertex-color linearizer went with the skin on
7
- // Sep 7 2026 — the GPU's sampler decodes sRGB now, where the CPU used to.)
8
- // ---------------------------------------------------------------------------
9
-
10
- import * as THREE from 'three';
11
- import { unpackRGBA } from './ingest.js';
12
-
13
- /**
14
- * The skin as a texture. DataTexture's constructor already states the skin's
15
- * sampling contract — nearest filtering both ways, no mipmaps, flipY false
16
- * (texel row 0 is v = 0, the bake's orientation), unpackAlignment 1 — those
17
- * are the class's own defaults, named here as documentation, never restated
18
- * as a correction. The bytes are sRGB, so the texture says so and the
19
- * sampler decodes them on the way to the renderers' sRGB output.
20
- * @param {import('./skin.js').Skin} skin
21
- * @returns {THREE.DataTexture}
22
- */
23
- export function skinTexture(skin) {
24
- const tex = new THREE.DataTexture(skin.data, skin.width, skin.height, THREE.RGBAFormat);
25
- tex.colorSpace = THREE.SRGBColorSpace;
26
- tex.needsUpdate = true;
27
- return tex;
28
- }
29
-
30
- /**
31
- * Shared final assembly: center the geometry on X/Z, leave Y exactly as
32
- * authored (no ground-rest — the Y translate is a hard 0; where the object
33
- * sits vertically is wherever the artist painted it, see carve.js), compute
34
- * bounds, and wrap it in the standard flat-shaded material with shadows on —
35
- * the skin as its `map`, or, with no map (flat mode), one packed `color`
36
- * (sRGB bytes) as its albedo.
37
- * @param {THREE.BufferGeometry} geo
38
- * @param {{nx:number, nz:number, s:number, map?:THREE.Texture|null, color?:number|null,
39
- * userData?:Record<string,unknown>}} opts
40
- * @returns {THREE.Mesh}
41
- */
42
- export function finishVoxelMesh(
43
- geo,
44
- { nx, nz, s, map = null, color = null, userData = {} }
45
- ) {
46
- geo.translate((-nx * s) / 2, 0, (-nz * s) / 2); // center X/Z; Y left as authored
47
- geo.computeBoundingBox();
48
- geo.computeBoundingSphere();
49
-
50
- const mat = new THREE.MeshStandardMaterial({
51
- flatShading: true,
52
- metalness: 0,
53
- roughness: 1,
54
- ...(map ? { map } : {}),
55
- });
56
- if (!map && color != null) {
57
- const { r, g, b } = unpackRGBA(color);
58
- mat.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
59
- }
60
- const mesh = new THREE.Mesh(geo, mat);
61
- mesh.castShadow = true;
62
- mesh.receiveShadow = true;
63
- Object.assign(mesh.userData, userData);
64
- return mesh;
65
- }