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/LICENSE +21 -0
- package/README.md +119 -0
- package/bin/sprite-machine.mjs +81 -0
- package/package.json +52 -0
- package/src/atlas.js +367 -0
- package/src/carve.js +198 -0
- package/src/colorize.js +228 -0
- package/src/constants.js +20 -0
- package/src/diag.js +68 -0
- package/src/faces.js +69 -0
- package/src/gltf.js +256 -0
- package/src/index.js +85 -0
- package/src/ingest.js +150 -0
- package/src/mesh-util.js +65 -0
- package/src/model.js +104 -0
- package/src/node.js +76 -0
- package/src/pipeline.js +56 -0
- package/src/png-chunks.js +240 -0
- package/src/png-encode.js +98 -0
- package/src/regions.js +298 -0
- package/src/skin.js +242 -0
- package/src/t-junction.js +160 -0
- package/src/views.js +244 -0
- package/src/wedge-mesh.js +441 -0
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
|
+
}
|