sprite-machine 0.1.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/regions.js ADDED
@@ -0,0 +1,298 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Coplanar REGIONS: the union of one plane's exposed unit faces and the gable
3
+ // cap half-faces the wedge blocks end on, traced as boundary loops on the
4
+ // lattice with every collinear run merged — so a wall beside a 45° slope has
5
+ // ONE straight diagonal edge where a stack of rectangles plus a sawtooth of
6
+ // gable triangles had a vertex at every step, each pinning a vertex on the
7
+ // slope that the T-junction repair then had to split the slope at. Pure 2D
8
+ // lattice work, no THREE, Node-tested; the triangulation of a region (earcut,
9
+ // through THREE's ShapeUtils) is the mesher's (wedge-mesh.js).
10
+ //
11
+ // Pieces on one plane, in the face's tangent coordinates (a along
12
+ // FACE_GEO[face].A, b along .B): a CELL is the unit square [a, a+1] × [b, b+1]
13
+ // — an exposed voxel face; a HALF is the right triangle in that square whose
14
+ // right angle sits at the corner (a + hiA, b + hiB) — a gable cap, the
15
+ // prism's cross-section. Every piece carries its packed colour: a face's from
16
+ // colorize, a cap's the wedge's. A texel per piece is a texel per unit
17
+ // square, the skin's orientation rule (skin.js).
18
+ //
19
+ // The trace: every piece contributes its directed edges, CCW in (a, b); an
20
+ // edge whose reverse another piece contributes is interior and cancels; the
21
+ // survivors are chained by the KEEP-LEFT rule — at a vertex with several ways
22
+ // on, the sharpest left turn — which pairs each arriving edge with the one
23
+ // leaving along its own sector of the region. So two cells that meet only at
24
+ // a corner are two loops (two regions, as they should be), while a bay that
25
+ // opens onto the outside through a corner rides the outer loop through that
26
+ // corner twice, and two holes meeting at a corner ride one hole loop twice:
27
+ // a self-touching ring, the very shape earcut builds when it bridges a hole
28
+ // into the outer, and one its ear test handles by design (the zero-length
29
+ // diagonal case); the mesher asserts every region triangulates to its full
30
+ // area. A loop with positive signed area is an OUTER, negative a HOLE. A hole
31
+ // and a piece belong to the smallest outer containing a point of theirs (a
32
+ // piece's centroid, a point a quarter-cell inside a hole's first edge —
33
+ // neither ever on a lattice line or a diagonal a piece can have), so an
34
+ // island inside a hole is its own region.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ import { FACE_KEYS, FACE_NORMAL } from './views.js';
38
+ import { FACE_GEO, idxFor } from './faces.js';
39
+
40
+ /**
41
+ * @typedef {{a:number, b:number, color:number}} Cell
42
+ * @typedef {{a:number, b:number, hiA:boolean, hiB:boolean, color:number}} Half
43
+ * @typedef {number[][]} Loop vertices [a, b] in order, the closing vertex not repeated
44
+ * @typedef {{outer:Loop, holes:Loop[], a:number, b:number, w:number, h:number,
45
+ * texels:Uint32Array, present:Uint8Array, uniform:number|null, area2:number}} Region2D
46
+ * a region: its loops, its bounding box (a, b, w, h) in cells, a texel per
47
+ * box cell (`present` marks the pieces'), its one colour or null, and
48
+ * twice its area (a cell 2, a half 1).
49
+ * @typedef {Region2D & {face:string, s:number, normal:number[]}} Region
50
+ */
51
+
52
+ const DIM = (dims, axis) => dims['n' + axis];
53
+
54
+ /** Twice the signed area of a polygon (shoelace); positive is CCW. @param {number[][]} poly */
55
+ function area2(poly) {
56
+ let s = 0;
57
+ for (let i = 0; i < poly.length; i++) {
58
+ const p = poly[i];
59
+ const q = poly[(i + 1) % poly.length];
60
+ s += p[0] * q[1] - q[0] * p[1];
61
+ }
62
+ return s;
63
+ }
64
+
65
+ /** Even-odd point-in-polygon; the ray is horizontal, and no test point here has an integer y. */
66
+ function inside(poly, x, y) {
67
+ let c = false;
68
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
69
+ const [xi, yi] = poly[i];
70
+ const [xj, yj] = poly[j];
71
+ if (yi > y !== yj > y && x < xi + ((y - yi) * (xj - xi)) / (yj - yi)) c = !c;
72
+ }
73
+ return c;
74
+ }
75
+
76
+ /** Drop the vertices a ring runs straight through. Ring edges are unit steps, so a step's sign is its direction. */
77
+ function dropCollinear(ring) {
78
+ const n = ring.length;
79
+ const out = [];
80
+ for (let i = 0; i < n; i++) {
81
+ const p = ring[(i - 1 + n) % n];
82
+ const c = ring[i];
83
+ const q = ring[(i + 1) % n];
84
+ const straight =
85
+ Math.sign(c[0] - p[0]) === Math.sign(q[0] - c[0]) &&
86
+ Math.sign(c[1] - p[1]) === Math.sign(q[1] - c[1]);
87
+ if (!straight) out.push(c);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Trace the regions of one plane from its pieces.
94
+ * @param {Cell[]} cells
95
+ * @param {Half[]} halves
96
+ * @returns {Region2D[]}
97
+ */
98
+ export function traceRegions(cells, halves) {
99
+ /** @type {{poly:number[][], at:number[], color:number, a:number, b:number, half:boolean}[]} */
100
+ const pieces = [];
101
+ for (const c of cells) {
102
+ pieces.push({
103
+ poly: [
104
+ [c.a, c.b],
105
+ [c.a + 1, c.b],
106
+ [c.a + 1, c.b + 1],
107
+ [c.a, c.b + 1],
108
+ ],
109
+ at: [c.a + 0.5, c.b + 0.5],
110
+ color: c.color >>> 0,
111
+ a: c.a,
112
+ b: c.b,
113
+ half: false,
114
+ });
115
+ }
116
+ for (const h of halves) {
117
+ const ca = h.a + (h.hiA ? 1 : 0); // the right angle's corner
118
+ const cb = h.b + (h.hiB ? 1 : 0);
119
+ const qa = h.a + (h.hiA ? 0 : 1); // the legs' far ends
120
+ const sb = h.b + (h.hiB ? 0 : 1);
121
+ let poly = [
122
+ [ca, cb],
123
+ [qa, cb],
124
+ [ca, sb],
125
+ ];
126
+ if (area2(poly) < 0) poly = [poly[0], poly[2], poly[1]];
127
+ pieces.push({
128
+ poly,
129
+ at: [(2 * ca + qa) / 3, (2 * cb + sb) / 3],
130
+ color: h.color >>> 0,
131
+ a: h.a,
132
+ b: h.b,
133
+ half: true,
134
+ });
135
+ }
136
+
137
+ // 1. directed edges; one whose reverse is present is interior and cancels
138
+ const ekey = (p, q) => p[0] + ',' + p[1] + '>' + q[0] + ',' + q[1];
139
+ /** @type {Map<string, {p:number[], q:number[]}>} */
140
+ const edges = new Map();
141
+ for (const pc of pieces)
142
+ for (let i = 0; i < pc.poly.length; i++) {
143
+ const p = pc.poly[i];
144
+ const q = pc.poly[(i + 1) % pc.poly.length];
145
+ if (edges.has(ekey(p, q)))
146
+ throw new Error(`regions: two pieces share the edge ${ekey(p, q)}`);
147
+ edges.set(ekey(p, q), { p, q });
148
+ }
149
+ const boundary = [];
150
+ for (const e of edges.values()) if (!edges.has(ekey(e.q, e.p))) boundary.push(e);
151
+
152
+ // 2. the keep-left successor of every boundary edge
153
+ const vkey = (p) => p[0] + ',' + p[1];
154
+ /** @type {Map<string, {p:number[], q:number[]}[]>} */
155
+ const outAt = new Map();
156
+ for (const e of boundary) {
157
+ const k = vkey(e.p);
158
+ if (!outAt.has(k)) outAt.set(k, []);
159
+ outAt.get(k).push(e);
160
+ }
161
+ const next = new Map();
162
+ for (const e of boundary) {
163
+ const dx = e.q[0] - e.p[0];
164
+ const dy = e.q[1] - e.p[1];
165
+ let best = null;
166
+ let bestTurn = -Infinity;
167
+ for (const f of outAt.get(vkey(e.q)) || []) {
168
+ const fx = f.q[0] - f.p[0];
169
+ const fy = f.q[1] - f.p[1];
170
+ const turn = Math.atan2(dx * fy - dy * fx, dx * fx + dy * fy);
171
+ if (turn > bestTurn) {
172
+ bestTurn = turn;
173
+ best = f;
174
+ }
175
+ }
176
+ if (!best) throw new Error('regions: the boundary is open at ' + vkey(e.q));
177
+ next.set(e, best);
178
+ }
179
+
180
+ // 3. the loops: the cycles of the successor map
181
+ const seen = new Set();
182
+ /** @type {Loop[]} */
183
+ const loops = [];
184
+ for (const e0 of boundary) {
185
+ if (seen.has(e0)) continue;
186
+ const ring = [];
187
+ let e = e0;
188
+ do {
189
+ seen.add(e);
190
+ ring.push(e.p);
191
+ e = next.get(e);
192
+ } while (e !== e0 && !seen.has(e));
193
+ if (e !== e0) throw new Error('regions: the boundary walk did not close');
194
+ loops.push(dropCollinear(ring));
195
+ }
196
+
197
+ // 4. outers and holes; each hole and each piece to the innermost outer around it
198
+ /** @type {Loop[]} */
199
+ const outers = [];
200
+ /** @type {Loop[]} */
201
+ const holes = [];
202
+ for (const loop of loops) (area2(loop) > 0 ? outers : holes).push(loop);
203
+ outers.sort((p, q) => area2(p) - area2(q));
204
+ const owner = (x, y) => {
205
+ for (let i = 0; i < outers.length; i++) if (inside(outers[i], x, y)) return i;
206
+ throw new Error(`regions: (${x}, ${y}) lies in no outer loop`);
207
+ };
208
+ const groups = outers.map((outer) => ({ outer, holes: [], pieces: [] }));
209
+ for (const h of holes) {
210
+ const [p, q] = h;
211
+ const dx = Math.sign(q[0] - p[0]);
212
+ const dy = Math.sign(q[1] - p[1]);
213
+ // a quarter-cell to the RIGHT of the hole's first edge: the region is on
214
+ // the left of every loop, so the right is the hole's own emptiness
215
+ groups[
216
+ owner((p[0] + q[0]) / 2 + 0.25 * dy, (p[1] + q[1]) / 2 - 0.25 * dx)
217
+ ].holes.push(h);
218
+ }
219
+ for (const pc of pieces) groups[owner(pc.at[0], pc.at[1])].pieces.push(pc);
220
+
221
+ return groups.map(({ outer, holes, pieces }) => {
222
+ let a0 = Infinity,
223
+ b0 = Infinity,
224
+ a1 = -Infinity,
225
+ b1 = -Infinity;
226
+ for (const [a, b] of outer) {
227
+ if (a < a0) a0 = a;
228
+ if (b < b0) b0 = b;
229
+ if (a > a1) a1 = a;
230
+ if (b > b1) b1 = b;
231
+ }
232
+ const w = a1 - a0;
233
+ const h = b1 - b0;
234
+ const texels = new Uint32Array(w * h);
235
+ const present = new Uint8Array(w * h);
236
+ let uniform = null;
237
+ let mixed = false;
238
+ let area = 0;
239
+ for (const pc of pieces) {
240
+ const i = pc.a - a0 + (pc.b - b0) * w;
241
+ texels[i] = pc.color;
242
+ present[i] = 1;
243
+ area += pc.half ? 1 : 2;
244
+ if (uniform === null) uniform = pc.color;
245
+ else if (uniform !== pc.color) mixed = true;
246
+ }
247
+ return {
248
+ outer,
249
+ holes,
250
+ a: a0,
251
+ b: b0,
252
+ w,
253
+ h,
254
+ texels,
255
+ present,
256
+ uniform: mixed ? null : uniform,
257
+ area2: area,
258
+ };
259
+ });
260
+ }
261
+
262
+ /** The key a plane's halves are filed under: a face key at a slice. */
263
+ export const planeKey = (face, s) => face + '|' + s;
264
+
265
+ /**
266
+ * The regions of every plane of the surface: the exposed faces of `surfaceMask`
267
+ * (coloured by `faceColor`, keyed idx*6 + f) plus the halves filed per plane.
268
+ * @param {{nx:number, ny:number, nz:number}} dims
269
+ * @param {Uint8Array} surfaceMask
270
+ * @param {Map<number, number>} faceColor
271
+ * @param {Map<string, Half[]>} [halves] by planeKey(face, s)
272
+ * @returns {Region[]}
273
+ */
274
+ export function faceRegions(dims, surfaceMask, faceColor, halves = new Map()) {
275
+ /** @type {Region[]} */
276
+ const out = [];
277
+ FACE_KEYS.forEach((face, f) => {
278
+ const g = FACE_GEO[face];
279
+ const normal = FACE_NORMAL[face];
280
+ const dimN = DIM(dims, g.N);
281
+ const dimA = DIM(dims, g.A);
282
+ const dimB = DIM(dims, g.B);
283
+ for (let s = 0; s < dimN; s++) {
284
+ /** @type {Cell[]} */
285
+ const cells = [];
286
+ for (let b = 0; b < dimB; b++)
287
+ for (let a = 0; a < dimA; a++) {
288
+ const idx = idxFor(face, a, b, s, dims);
289
+ if (surfaceMask[idx] & (1 << f))
290
+ cells.push({ a, b, color: faceColor.get(idx * 6 + f) >>> 0 });
291
+ }
292
+ const hs = halves.get(planeKey(face, s)) || [];
293
+ if (!cells.length && !hs.length) continue;
294
+ for (const r of traceRegions(cells, hs)) out.push({ face, s, normal, ...r });
295
+ }
296
+ });
297
+ return out;
298
+ }
package/src/skin.js ADDED
@@ -0,0 +1,242 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The skin: the model's color as a TEXTURE, so the mesher can merge on
3
+ // occupancy alone (regions.js). Pure — no THREE, no canvas — and Node-tested;
4
+ // mesh-util.js turns the bytes into the DataTexture the material samples.
5
+ //
6
+ // What is in it. A region whose pieces are NOT all one color becomes a
7
+ // CHART: the region's bounding box as texels, one per cell, holding its
8
+ // pieces' colors verbatim — a face's, or a gable cap's wedge color — padded
9
+ // by one texel on every side (the gutter) and with every texel the pieces do
10
+ // not cover (the gutter, a hole, the box outside a diagonal edge) filled
11
+ // from the NEAREST piece texel, a breadth-first flood from the pieces
12
+ // outward: a fragment on the region's edge that rounds to the neighbouring
13
+ // texel still reads its own color, and an importer with bilinear filtering
14
+ // on gets no bleed. A region of one color — every one-color wall, every
15
+ // solid cube's face — gets no chart: its triangles point at the SWATCH
16
+ // STRIP, one 1×1 chart per distinct color (the palette, plus anything the
17
+ // faces actually hold), sampled at the texel's center — one texel read at
18
+ // its middle needs no gutter. A wedge's slope is one material by the gate,
19
+ // so it points at a swatch too. The skin is therefore only the regions that
20
+ // cross a color, plus the strip — a fraction of "every exposed face" — and
21
+ // its size is bounded by the multi-color regions' boxes, never by the grid.
22
+ //
23
+ // Packing is a shelf packer, deterministic (the goldens depend on it): the
24
+ // padded charts sorted by height then width, descending, laid left to right
25
+ // on shelves, the swatches after them. The width starts at the smallest
26
+ // power of two holding the widest padded chart (16 at least) and doubles
27
+ // while the packed height, rounded up to a power of two, would exceed it —
28
+ // no ceiling (a skin past a device's texture limit is a sprite the carve
29
+ // could not have rebuilt live either). Power-of-two sides are not required
30
+ // by three or by WebGL2; exporters and older engines are happier with them,
31
+ // and it costs nothing here.
32
+ //
33
+ // Orientation is stated ONCE: a chart's texel (i, j) is the cell at tangent
34
+ // (a + i, b + j) of the region's box — i along FACE_GEO[face].A, j along .B —
35
+ // and a vertex's UV is the same affine read of its lattice position
36
+ // (uvOfLattice). There is no per-face flip table, so nothing can drift; if a
37
+ // face ever renders mirrored the bug is in the corner-to-UV read, not the
38
+ // bake. The texture's row 0 is v = 0 (DataTexture's flipY is false) — leave
39
+ // it there.
40
+ //
41
+ // Built from bytes: the packed colors are written straight into the RGBA
42
+ // array. No 2D canvas, no getImageData, so a privacy browser's canvas farble
43
+ // (the Helium bug, wedge-mesh.test.mjs) cannot touch it. Keep it that way:
44
+ // no canvas in this file, ever.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ import { FACE_GEO } from './faces.js';
48
+ import { unpackRGBA } from './ingest.js';
49
+ import { AXIS_INDEX } from './views.js';
50
+
51
+ /**
52
+ * @typedef {{u0:number, v0:number, w:number, h:number}} Chart
53
+ * a charted region's texels, in texel coords, the gutter excluded.
54
+ * @typedef {{width:number, height:number, data:Uint8Array,
55
+ * charts:(Chart|null)[], swatch:Map<number, {u:number, v:number}>}} Skin
56
+ * charts[i] is regions[i]'s chart — null where the region is one color (a
57
+ * swatch); swatch maps a packed color to its 1×1 chart's texel.
58
+ */
59
+
60
+ const GUTTER = 1;
61
+ const MIN_WIDTH = 16;
62
+
63
+ const pow2ceil = (n) => {
64
+ let p = 1;
65
+ while (p < n) p *= 2;
66
+ return p;
67
+ };
68
+
69
+ // Lay `items` (each {pw, ph}, padded sizes) on shelves of `width`, left to
70
+ // right, a new shelf when the row is full. Every item is narrower than the
71
+ // width by construction. Returns the packed height and each item's origin.
72
+ function shelfPack(items, width) {
73
+ const at = new Array(items.length);
74
+ let x = 0,
75
+ y = 0,
76
+ shelf = 0;
77
+ items.forEach((it, i) => {
78
+ if (x + it.pw > width) {
79
+ x = 0;
80
+ y += shelf;
81
+ shelf = 0;
82
+ }
83
+ at[i] = { x, y };
84
+ x += it.pw;
85
+ if (it.ph > shelf) shelf = it.ph;
86
+ });
87
+ return { height: y + shelf, at };
88
+ }
89
+
90
+ // The padded box of a region as texel colors: the pieces' own, and every
91
+ // other texel — gutter, hole, the box beyond a diagonal — the color of the
92
+ // nearest piece texel, a multi-source breadth-first flood (deterministic:
93
+ // the pieces seed in row order, the four neighbours in a fixed order).
94
+ function floodBox(region) {
95
+ const { w, h, texels, present } = region;
96
+ const W = w + 2 * GUTTER;
97
+ const H = h + 2 * GUTTER;
98
+ const fill = new Uint32Array(W * H);
99
+ const done = new Uint8Array(W * H);
100
+ const queue = [];
101
+ for (let j = 0; j < h; j++)
102
+ for (let k = 0; k < w; k++)
103
+ if (present[j * w + k]) {
104
+ const n = (j + GUTTER) * W + k + GUTTER;
105
+ fill[n] = texels[j * w + k];
106
+ done[n] = 1;
107
+ queue.push(n);
108
+ }
109
+ const STEPS = [1, -1, W, -W];
110
+ for (let qi = 0; qi < queue.length; qi++) {
111
+ const n = queue[qi];
112
+ const x = n % W;
113
+ for (const d of STEPS) {
114
+ if ((d === 1 && x === W - 1) || (d === -1 && x === 0)) continue;
115
+ const m = n + d;
116
+ if (m < 0 || m >= W * H || done[m]) continue;
117
+ fill[m] = fill[n];
118
+ done[m] = 1;
119
+ queue.push(m);
120
+ }
121
+ }
122
+ return { W, H, fill };
123
+ }
124
+
125
+ /**
126
+ * Bake the skin for a mesh: a chart per multi-color region, a swatch per color.
127
+ * @param {import('./regions.js').Region2D[]} regions the base regions, in emit order
128
+ * @param {number[]} colors colors to give a swatch (the build's palette);
129
+ * every value the faces and the regions hold is unioned in, the guard for
130
+ * a relaxed or dominant color the palette snap left off it
131
+ * @param {Map<number, number>} faceColor colorize's per-face colors, keyed idx*6 + f
132
+ * @returns {Skin}
133
+ */
134
+ export function bakeSkin(regions, colors, faceColor) {
135
+ // 1. The regions that chart: the ones not of one color.
136
+ /** @type {{i:number, region:import('./regions.js').Region2D}[]} */
137
+ const bodies = [];
138
+ /** @type {(Chart|null)[]} */
139
+ const charts = new Array(regions.length).fill(null);
140
+ regions.forEach((region, i) => {
141
+ if (region.uniform === null) bodies.push({ i, region });
142
+ });
143
+
144
+ // 2. The swatch colors: the given palette, then anything the faces and the
145
+ // regions' pieces hold (a cap's wedge color rides a region, never a face).
146
+ const seen = new Set();
147
+ /** @type {number[]} */
148
+ const swatchColors = [];
149
+ const addColor = (c) => {
150
+ const k = c >>> 0;
151
+ if (seen.has(k)) return;
152
+ seen.add(k);
153
+ swatchColors.push(k);
154
+ };
155
+ for (const c of colors) addColor(c);
156
+ for (const c of faceColor.values()) addColor(c);
157
+ for (const region of regions)
158
+ for (let i = 0; i < region.present.length; i++)
159
+ if (region.present[i]) addColor(region.texels[i]);
160
+
161
+ // 3. Pack: padded charts by height then width, descending (the region's own
162
+ // order the tiebreak, so the pack is a pure function of the input); the
163
+ // swatches, 1×1 and unpadded, after them.
164
+ /** @type {{pw:number, ph:number, body?:{i:number, region:import('./regions.js').Region2D}, color?:number}[]} */
165
+ const items = bodies.map((body) => ({
166
+ body,
167
+ pw: body.region.w + 2 * GUTTER,
168
+ ph: body.region.h + 2 * GUTTER,
169
+ }));
170
+ items.sort((p, q) => q.ph - p.ph || q.pw - p.pw || p.body.i - q.body.i);
171
+ for (const color of swatchColors) items.push({ color, pw: 1, ph: 1 });
172
+
173
+ let widest = 1;
174
+ for (const it of items) if (it.pw > widest) widest = it.pw;
175
+ let width = Math.max(MIN_WIDTH, pow2ceil(widest));
176
+ let packed = shelfPack(items, width);
177
+ let height = pow2ceil(packed.height);
178
+ while (height > width) {
179
+ width *= 2;
180
+ packed = shelfPack(items, width);
181
+ height = pow2ceil(packed.height);
182
+ }
183
+
184
+ // 4. Bake. Unused texels stay transparent black — nothing samples them.
185
+ const data = new Uint8Array(width * height * 4);
186
+ const put = (x, y, c) => {
187
+ const o = (y * width + x) * 4;
188
+ const { r, g, b } = unpackRGBA(c);
189
+ data[o] = r;
190
+ data[o + 1] = g;
191
+ data[o + 2] = b;
192
+ data[o + 3] = 255;
193
+ };
194
+ /** @type {Map<number, {u:number, v:number}>} */
195
+ const swatch = new Map();
196
+ items.forEach((it, n) => {
197
+ const { x, y } = packed.at[n];
198
+ if (it.body) {
199
+ const { i, region } = it.body;
200
+ const { W, H, fill } = floodBox(region);
201
+ for (let j = 0; j < H; j++)
202
+ for (let k = 0; k < W; k++) put(x + k, y + j, fill[j * W + k]);
203
+ charts[i] = { u0: x + GUTTER, v0: y + GUTTER, w: region.w, h: region.h };
204
+ } else {
205
+ put(x, y, it.color);
206
+ swatch.set(it.color, { u: x, v: y });
207
+ }
208
+ });
209
+
210
+ return { width, height, data, charts, swatch };
211
+ }
212
+
213
+ /**
214
+ * The texel coordinates of a point on a charted region's plane: an affine
215
+ * read of its position along the face's tangent axes from the region's box
216
+ * origin, so a box corner lands on the chart's corner exactly, and a vertex
217
+ * the T-junction repair inserted along an edge lands on the texel line
218
+ * between two cells. Divide by the skin's width and height for the UV.
219
+ * @param {Chart} chart
220
+ * @param {{face:string, a:number, b:number}} region
221
+ * @param {number[]} p a point [x, y, z] on the region's plane, in voxel units
222
+ * @returns {[number, number]}
223
+ */
224
+ export function uvOfLattice(chart, region, p) {
225
+ const g = FACE_GEO[region.face];
226
+ return [
227
+ chart.u0 + (p[AXIS_INDEX[g.A]] - region.a),
228
+ chart.v0 + (p[AXIS_INDEX[g.B]] - region.b),
229
+ ];
230
+ }
231
+
232
+ /**
233
+ * The texel CENTER of a color's swatch, in texel coordinates.
234
+ * @param {Skin} skin
235
+ * @param {number} packed a packed RGBA color the skin holds a swatch for
236
+ * @returns {[number, number]}
237
+ */
238
+ export function swatchUV(skin, packed) {
239
+ const at = skin.swatch.get(packed >>> 0);
240
+ if (!at) throw new Error(`skin: no swatch for color 0x${(packed >>> 0).toString(16)}`);
241
+ return [at.u + 0.5, at.v + 0.5];
242
+ }
@@ -0,0 +1,160 @@
1
+ // ---------------------------------------------------------------------------
2
+ // T-junction elimination for lattice meshes.
3
+ //
4
+ // Greedy-merging faces into large rectangles introduces T-junctions: a vertex
5
+ // that lands in the MIDDLE of a longer triangle's edge — e.g. where a merged
6
+ // base rectangle meets a unit-scale wedge edge, or two faces merged to different
7
+ // extents meet at an object corner. It's invisible for opaque flat-shaded
8
+ // geometry, but it makes the surface NON-manifold (edges no longer paired),
9
+ // which breaks the watertight weld and clean glTF export.
10
+ //
11
+ // Every vertex here sits on the integer lattice, so repair is exact — no
12
+ // floating-point tolerance. Collect all vertex positions, then split each
13
+ // triangle edge at any vertex lying strictly on its interior, re-triangulating
14
+ // the (convex) result with lattice-only vertices. Pure integer geometry: no
15
+ // THREE, Node-testable.
16
+ //
17
+ // Two kinds of edge can carry interior lattice points here: an AXIS-ALIGNED
18
+ // one (a region polygon's, a slope's ridge edge) and a 45° DIAGONAL (a slope
19
+ // block's staircase edge, a region's cut beside it — both long since the
20
+ // merges of Sep 7 2026). Any other segment is a triangulation chord across a
21
+ // region's or a slope's interior, where a valid surface never has a vertex,
22
+ // so it is left alone.
23
+ //
24
+ // A triangle is an opaque record beyond its three vertices: every other field
25
+ // (the normal, and the mesher's paint — a chart and its rect, or a swatch
26
+ // color) is copied onto each piece a split produces, so the repair never has
27
+ // to know what rides on a triangle. UVs are NOT carried through here: they
28
+ // are a function of position (skin.js uvOfLattice), read after the repair.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const key = (p) => p[0] + ',' + p[1] + ',' + p[2];
32
+
33
+ // Integer lattice points strictly interior to segment p->q that are in `vset`,
34
+ // ordered p->q. Returns [] unless p->q is axis-aligned or a 45° diagonal (two
35
+ // axes stepping by the same magnitude).
36
+ function interiorPointsOnEdge(p, q, vset) {
37
+ const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
38
+ const axes = [];
39
+ for (let i = 0; i < 3; i++) if (d[i] !== 0) axes.push(i);
40
+ if (axes.length === 0 || axes.length === 3) return []; // degenerate, or a chord
41
+ const n = Math.abs(d[axes[0]]);
42
+ if (axes.length === 2 && Math.abs(d[axes[1]]) !== n) return []; // not 45°
43
+ const out = [];
44
+ for (let t = 1; t < n; t++) {
45
+ const pt = [p[0], p[1], p[2]];
46
+ for (const i of axes) pt[i] += Math.sign(d[i]) * t;
47
+ if (vset.has(key(pt))) out.push(pt);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // Triangulate a convex polygon (wound CCW wrt `normal`) using only its own
53
+ // vertices, tolerating collinear boundary points. Clip only a strictly-convex
54
+ // corner whose EAR EDGE (prev->next) carries no other vertex — otherwise the ear
55
+ // would span a subdivided edge and re-introduce the T-junction we're removing.
56
+ function triangulateConvex(ring, normal, emit) {
57
+ const turn = (o, a, b) => {
58
+ const ux = a[0] - o[0],
59
+ uy = a[1] - o[1],
60
+ uz = a[2] - o[2];
61
+ const vx = b[0] - o[0],
62
+ vy = b[1] - o[1],
63
+ vz = b[2] - o[2];
64
+ return (
65
+ (uy * vz - uz * vy) * normal[0] +
66
+ (uz * vx - ux * vz) * normal[1] +
67
+ (ux * vy - uy * vx) * normal[2]
68
+ );
69
+ };
70
+ // v strictly interior to segment p->q (collinear + between the endpoints).
71
+ const onSeg = (p, q, v) => {
72
+ const dx = q[0] - p[0],
73
+ dy = q[1] - p[1],
74
+ dz = q[2] - p[2];
75
+ const ex = v[0] - p[0],
76
+ ey = v[1] - p[1],
77
+ ez = v[2] - p[2];
78
+ if (dy * ez - dz * ey || dz * ex - dx * ez || dx * ey - dy * ex) return false;
79
+ const dot = dx * ex + dy * ey + dz * ez;
80
+ return dot > 0 && dot < dx * dx + dy * dy + dz * dz;
81
+ };
82
+ const poly = ring.slice();
83
+ let guard = poly.length * poly.length + 8;
84
+ while (poly.length > 3 && guard-- > 0) {
85
+ let clipped = false;
86
+ for (let i = 0; i < poly.length; i++) {
87
+ const prev = poly[(i - 1 + poly.length) % poly.length];
88
+ const cur = poly[i];
89
+ const next = poly[(i + 1) % poly.length];
90
+ if (turn(prev, cur, next) <= 0) continue; // reflex or collinear
91
+ let blocked = false;
92
+ for (let j = 0; j < poly.length && !blocked; j++)
93
+ if (
94
+ poly[j] !== prev &&
95
+ poly[j] !== cur &&
96
+ poly[j] !== next &&
97
+ onSeg(prev, next, poly[j])
98
+ )
99
+ blocked = true;
100
+ if (blocked) continue;
101
+ emit(prev, cur, next);
102
+ poly.splice(i, 1);
103
+ clipped = true;
104
+ break;
105
+ }
106
+ if (!clipped) {
107
+ // Defensive: fan from a strictly-convex vertex (clean-ear clipping should
108
+ // always progress for a convex polygon, so this is a safety net only).
109
+ let ai = 0;
110
+ for (let i = 0; i < poly.length; i++) {
111
+ const prev = poly[(i - 1 + poly.length) % poly.length];
112
+ const next = poly[(i + 1) % poly.length];
113
+ if (turn(prev, poly[i], next) > 0) {
114
+ ai = i;
115
+ break;
116
+ }
117
+ }
118
+ for (let k = 1; k < poly.length - 1; k++) {
119
+ const w1 = poly[(ai + k) % poly.length];
120
+ const w2 = poly[(ai + k + 1) % poly.length];
121
+ if (turn(poly[ai], w1, w2) !== 0) emit(poly[ai], w1, w2);
122
+ }
123
+ return;
124
+ }
125
+ }
126
+ if (poly.length === 3 && turn(poly[0], poly[1], poly[2]) !== 0)
127
+ emit(poly[0], poly[1], poly[2]);
128
+ }
129
+
130
+ /**
131
+ * @template {{a:number[], b:number[], c:number[], normal:number[]}} T
132
+ * @param {T[]} tris
133
+ * triangles with INTEGER-lattice vertex coords, wound CCW wrt `normal`.
134
+ * @returns {T[]}
135
+ * an equivalent surface with no T-junctions (every edge split at interior
136
+ * verts); a split triangle's pieces carry every field of their source but
137
+ * the three vertices.
138
+ */
139
+ export function eliminateTJunctions(tris) {
140
+ const vset = new Set();
141
+ for (const t of tris) {
142
+ vset.add(key(t.a));
143
+ vset.add(key(t.b));
144
+ vset.add(key(t.c));
145
+ }
146
+
147
+ const out = [];
148
+ for (const t of tris) {
149
+ const ab = interiorPointsOnEdge(t.a, t.b, vset);
150
+ const bc = interiorPointsOnEdge(t.b, t.c, vset);
151
+ const ca = interiorPointsOnEdge(t.c, t.a, vset);
152
+ if (!ab.length && !bc.length && !ca.length) {
153
+ out.push(t);
154
+ continue;
155
+ }
156
+ const ring = [t.a, ...ab, t.b, ...bc, t.c, ...ca];
157
+ triangulateConvex(ring, t.normal, (a, b, c) => out.push({ ...t, a, b, c }));
158
+ }
159
+ return out;
160
+ }