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/carve.js ADDED
@@ -0,0 +1,198 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Carve: reconcile grid dimensions from the ingested views, then compute the
3
+ // visual hull = intersection of every provided view's extruded silhouette.
4
+ //
5
+ // Insight that keeps this simple: each view projects onto ONE of three planes
6
+ // FRONT/BACK -> X-Y, LEFT/RIGHT -> Z-Y, TOP/BOTTOM -> X-Z.
7
+ // The two views of a pair produce the same silhouette (mirror images), so for
8
+ // CARVING a single view per plane fully constrains that axis. Mirroring is only
9
+ // needed for COLOR (see colorize.js). So carving is: UNION the views within
10
+ // each plane (opposite silhouettes are identical in theory, so this is robust
11
+ // to a 1-texel registration slip between hand-drawn opposite sprites — see
12
+ // carve()), then AND across the planes. No camera math, no CSG.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { placeView } from './ingest.js';
16
+ import { VIEWS, VIEW_AXES, FACE_KEYS, FACE_NORMAL } from './views.js';
17
+
18
+ export const voxIndex = (x, y, z, d) => x + d.nx * (y + d.ny * z);
19
+
20
+ /** Inverse of voxIndex: linear grid index -> {x,y,z}. Kept next to voxIndex so
21
+ * the forward and inverse packing can't drift. */
22
+ export const unvoxIndex = (idx, d) => {
23
+ const z = (idx / (d.nx * d.ny)) | 0;
24
+ const rem = idx - z * d.nx * d.ny;
25
+ const y = (rem / d.nx) | 0;
26
+ return { x: rem - y * d.nx, y, z };
27
+ };
28
+
29
+ /**
30
+ * Reconcile one integer resolution per axis from the (uncropped) tile sizes.
31
+ * For a well-formed sheet every view is the same size, so each axis has a single
32
+ * candidate and the grid is exactly the tile size. Unequal sizes (a malformed
33
+ * sheet, or non-square tiles whose depth differs between side-width and
34
+ * top-height) take the max and warn — the shorter view under-constrains the tail.
35
+ * @param {Record<string, {w:number,h:number}>} views provided views by name
36
+ * @returns {{dims:{nx:number,ny:number,nz:number}, warnings:string[]}}
37
+ */
38
+ export function reconcileDims(views) {
39
+ const cand = { nx: [], ny: [], nz: [] };
40
+ for (const [name, v] of Object.entries(views)) {
41
+ if (!v) continue;
42
+ const [wAxis, hAxis] = VIEW_AXES[name];
43
+ cand[wAxis].push(v.w);
44
+ cand[hAxis].push(v.h);
45
+ }
46
+ const warnings = [];
47
+ const pick = (axis, label) => {
48
+ const c = cand[axis];
49
+ if (c.length === 0) {
50
+ warnings.push(
51
+ `Axis ${label} is unconstrained (no view observes it); defaulting to 1. ` +
52
+ `Provide a view that sees ${label} for real depth.`
53
+ );
54
+ return 1;
55
+ }
56
+ const mn = Math.min(...c);
57
+ const mx = Math.max(...c);
58
+ if (mn !== mx) {
59
+ warnings.push(
60
+ `Views disagree on ${label} (${c.join(', ')}); strict registration ` +
61
+ `expects uniform, square tiles. Using ${mx}; a smaller view is placed ` +
62
+ `from the origin (not re-centered) and leaves the far end of ${label} ` +
63
+ `uncarved — check the atlas tile size.`
64
+ );
65
+ }
66
+ return mx;
67
+ };
68
+ const dims = { nx: pick('nx', 'X'), ny: pick('ny', 'Y'), nz: pick('nz', 'Z') };
69
+ return { dims, warnings };
70
+ }
71
+
72
+ /**
73
+ * Place each provided view into the reconciled (imgW,imgH) grid at NATIVE scale
74
+ * and IDENTITY position (offX=offY=0) — strict registration: a tile's texel
75
+ * (u,v) is a fixed lattice line, so it is NOT re-centered or bottom-anchored.
76
+ * For a well-formed (uniform-tile) sheet each view already equals the grid on
77
+ * the axes it constrains, so this is a 1:1 copy. There is no auto ground-rest:
78
+ * where the object sits in Y is wherever the artist painted it (paint at the
79
+ * tile's bottom rows to rest on y=0). A malformed sheet with unequal-size views
80
+ * lands each at the origin and warns (reconcileDims).
81
+ * @returns {Record<string,{occ:Uint8Array,rgb:Uint32Array,imgW:number,imgH:number}>}
82
+ */
83
+ export function gridViews(views, dims) {
84
+ /** @type {Record<string, {occ:Uint8Array,rgb:Uint32Array,imgW:number,imgH:number}>} */
85
+ const out = {};
86
+ for (const [name, v] of Object.entries(views)) {
87
+ if (!v) continue;
88
+ const spec = VIEWS[name];
89
+ const imgW = spec.imgW(dims);
90
+ const imgH = spec.imgH(dims);
91
+ const { occ, rgb } = placeView(v, imgW, imgH, 0, 0);
92
+ out[name] = { occ, rgb, imgW, imgH };
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Carve the visual hull.
99
+ * @param {Record<string,{occ:Uint8Array,imgW:number}>} gviews grid-sized views
100
+ * @param {{nx:number,ny:number,nz:number}} dims
101
+ * @returns {Uint8Array} solid occupancy, length nx*ny*nz
102
+ */
103
+ export function carve(gviews, dims) {
104
+ const { nx, ny, nz } = dims;
105
+ const solid = new Uint8Array(nx * ny * nz).fill(1);
106
+ const active = Object.entries(gviews);
107
+ // Contract: with no views, nothing carves — the grid stays filled to its
108
+ // bounding box. reconcileDims defaults every unconstrained axis to 1, so a
109
+ // fully empty input yields a single solid voxel (pipeline.js warns about it).
110
+ if (active.length === 0) return solid;
111
+
112
+ // Group the provided views by the projection PLANE they constrain
113
+ // (front/back -> X-Y, left/right -> Z-Y, top/bottom -> X-Z). A real solid's
114
+ // two opposite silhouettes are identical, so WITHIN a plane we UNION the
115
+ // views — a voxel is covered if ANY view on that plane sees it. This is what
116
+ // the header means by "a single view per plane fully constrains that axis":
117
+ // the opposite view is redundant for carving, not an extra constraint.
118
+ // ANDing the pair instead lets a 1-texel registration slip between two
119
+ // hand-drawn opposite sprites erode thin protrusions — e.g. a car's side
120
+ // mirror that survives in the top sprite but sits one row over in the bottom
121
+ // sprite has an empty top∧bottom intersection, so its outer column vanishes.
122
+ // We then intersect ACROSS the (up to three) planes to get the visual hull.
123
+ const planes = new Map(); // planeKey -> [{spec, occ, imgW}, ...]
124
+ for (const [name, gv] of active) {
125
+ const key = VIEW_AXES[name].join(); // e.g. 'nx,ny' — one key per plane
126
+ const group = planes.get(key) || planes.set(key, []).get(key);
127
+ group.push({ spec: VIEWS[name], occ: gv.occ, imgW: gv.imgW });
128
+ }
129
+ const planeList = [...planes.values()];
130
+
131
+ // One reused scratch for the projection (projectInto mutates it) so the hot
132
+ // triple loop below allocates nothing per voxel × view.
133
+ const p = { u: 0, v: 0 };
134
+ for (let z = 0; z < nz; z++) {
135
+ for (let y = 0; y < ny; y++) {
136
+ for (let x = 0; x < nx; x++) {
137
+ const idx = voxIndex(x, y, z, dims);
138
+ for (const group of planeList) {
139
+ let covered = false;
140
+ for (const { spec, occ, imgW } of group) {
141
+ spec.projectInto(x, y, z, dims, p);
142
+ if (occ[p.v * imgW + p.u]) {
143
+ covered = true;
144
+ break;
145
+ }
146
+ }
147
+ if (!covered) {
148
+ solid[idx] = 0;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return solid;
156
+ }
157
+
158
+ // 6 axis-neighbor offsets in FACE_KEYS order — the outward normals themselves.
159
+ // Derived from FACE_NORMAL so they can't drift from the face convention.
160
+ const NEIGHBORS = FACE_KEYS.map((k) => FACE_NORMAL[k]);
161
+
162
+ /**
163
+ * Extract surface voxels: a solid voxel with >=1 empty/out-of-bounds neighbor.
164
+ * Also tallies the total solid count in the same pass (every voxel is visited and
165
+ * gated on solid here), so the pipeline needn't re-walk the grid a third time.
166
+ * @returns {{surfaceMask:Uint8Array, count:number, solidCount:number}}
167
+ * surfaceMask[idx] holds a 6-bit exposure mask (bit i => FACE_KEYS[i] exposed);
168
+ * count = surface voxels; solidCount = all solid voxels (surface + interior).
169
+ */
170
+ export function extractSurface(solid, dims) {
171
+ const { nx, ny, nz } = dims;
172
+ const surfaceMask = new Uint8Array(nx * ny * nz);
173
+ let count = 0;
174
+ let solidCount = 0;
175
+ for (let z = 0; z < nz; z++) {
176
+ for (let y = 0; y < ny; y++) {
177
+ for (let x = 0; x < nx; x++) {
178
+ const idx = voxIndex(x, y, z, dims);
179
+ if (!solid[idx]) continue;
180
+ solidCount++;
181
+ let mask = 0;
182
+ for (let f = 0; f < 6; f++) {
183
+ const [dx, dy, dz] = NEIGHBORS[f];
184
+ const ax = x + dx,
185
+ ay = y + dy,
186
+ az = z + dz;
187
+ const outside = ax < 0 || ay < 0 || az < 0 || ax >= nx || ay >= ny || az >= nz;
188
+ if (outside || !solid[voxIndex(ax, ay, az, dims)]) mask |= 1 << f;
189
+ }
190
+ if (mask) {
191
+ surfaceMask[idx] = mask;
192
+ count++;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return { surfaceMask, count, solidCount };
198
+ }
@@ -0,0 +1,228 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Colorize: assign a color to every EXPOSED face of every surface voxel.
3
+ //
4
+ // The corrected rule (the naive "stamp one sprite pixel down the whole depth
5
+ // ray" smears color and was rejected):
6
+ // SURFACE-ONLY, PER-EXPOSED-FACE, DEPTH-AWARE (first-hit), CLOSEST-FACE-NORMAL,
7
+ // NEAREST-PALETTE.
8
+ //
9
+ // For each exposed face f with outward normal n:
10
+ // 1. Facing view = the view whose normal == n. Sample it ONLY IF this voxel
11
+ // is the first solid hit marching from that view inward — i.e. nothing
12
+ // solid lies beyond the face along +n. This is what prevents a recessed
13
+ // step wall from being painted with the protruding front pixel's color.
14
+ // 2. Else, if mirror-fill is enabled for this face's axis (on by default for
15
+ // all axes) and the OPPOSITE view exists, sample it mirrored (symmetry).
16
+ // 3. Else relax: average already-assigned neighbor face colors.
17
+ // 4. Else: the object's dominant body color.
18
+ // Every sampled color is snapped to the sprite palette so AA fringe never
19
+ // produces a muddy off-palette pixel.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { unpackRGBA, packRGBA } from './ingest.js';
23
+ import { voxIndex, unvoxIndex } from './carve.js';
24
+ import {
25
+ VIEWS,
26
+ FACE_KEYS,
27
+ FACE_NORMAL,
28
+ FACE_TO_VIEW,
29
+ FACE_OPPOSITE,
30
+ FACE_AXIS,
31
+ } from './views.js';
32
+ import { DEFAULT_MIRROR } from './constants.js';
33
+
34
+ /** Build the deduped palette (union of all solid sprite pixels). */
35
+ export function buildPalette(gviews) {
36
+ const seen = new Set();
37
+ const palette = [];
38
+ for (const gv of Object.values(gviews)) {
39
+ const { occ, rgb } = gv;
40
+ for (let i = 0; i < occ.length; i++) {
41
+ if (!occ[i]) continue;
42
+ const c = rgb[i] >>> 0;
43
+ if (!seen.has(c)) {
44
+ seen.add(c);
45
+ palette.push(c);
46
+ }
47
+ }
48
+ }
49
+ return palette;
50
+ }
51
+
52
+ export function makeSnapper(palette) {
53
+ const cache = new Map();
54
+ // Unpack each palette entry once (keeping its packed value) instead of
55
+ // re-splitting bytes on every query iteration.
56
+ const pal = palette.map((c) => ({ c: c >>> 0, ...unpackRGBA(c) }));
57
+ return (color) => {
58
+ const key = color >>> 0;
59
+ const hit = cache.get(key);
60
+ if (hit !== undefined) return hit;
61
+ const { r, g, b } = unpackRGBA(key);
62
+ let best = key;
63
+ let bestD = Infinity;
64
+ for (const p of pal) {
65
+ const d = (r - p.r) ** 2 + (g - p.g) ** 2 + (b - p.b) ** 2;
66
+ if (d < bestD) {
67
+ bestD = d;
68
+ best = p.c;
69
+ }
70
+ }
71
+ cache.set(key, best);
72
+ return best;
73
+ };
74
+ }
75
+
76
+ /** Is `(x,y,z)`'s face `faceKey` the first solid hit from its facing view? */
77
+ function firstHitFromFace(solid, dims, x, y, z, faceKey) {
78
+ const [nx, ny, nz] = FACE_NORMAL[faceKey];
79
+ let cx = x + nx,
80
+ cy = y + ny,
81
+ cz = z + nz;
82
+ while (cx >= 0 && cy >= 0 && cz >= 0 && cx < dims.nx && cy < dims.ny && cz < dims.nz) {
83
+ if (solid[voxIndex(cx, cy, cz, dims)]) return false; // occluded
84
+ cx += nx;
85
+ cy += ny;
86
+ cz += nz;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ // One reused scratch — sampleView reads the projection immediately, so mutating a
92
+ // shared object avoids a per-exposed-face allocation.
93
+ const _sampleP = { u: 0, v: 0 };
94
+ function sampleView(gv, name, x, y, z, dims) {
95
+ VIEWS[name].projectInto(x, y, z, dims, _sampleP);
96
+ const i = _sampleP.v * gv.imgW + _sampleP.u;
97
+ return gv.occ[i] ? gv.rgb[i] >>> 0 : null;
98
+ }
99
+
100
+ /**
101
+ * @param {Uint8Array} solid
102
+ * @param {Uint8Array} surfaceMask 6-bit exposure per voxel
103
+ * @param {Record<string,{occ,rgb,imgW,imgH}>} gviews
104
+ * @param {{nx,ny,nz}} dims
105
+ * @param {{mirror?:{x?:boolean,y?:boolean,z?:boolean}}} [opts]
106
+ * @returns {{faceColor: Map<number, number>, palette: number[]}}
107
+ * faceColor key = idx*6 + faceIndex, value = packed RGBA; palette = solid colors.
108
+ */
109
+ export function colorize(solid, surfaceMask, gviews, dims, opts = {}) {
110
+ const mirror = { ...DEFAULT_MIRROR, ...(opts.mirror || {}) };
111
+ const palette = buildPalette(gviews);
112
+ const snap = palette.length ? makeSnapper(palette) : (c) => c;
113
+ const faceColor = new Map();
114
+ const pending = []; // faces needing relaxation/fallback
115
+
116
+ for (let idx = 0; idx < surfaceMask.length; idx++) {
117
+ const mask = surfaceMask[idx];
118
+ if (!mask) continue;
119
+ const { x, y, z } = unvoxIndex(idx, dims);
120
+
121
+ for (let f = 0; f < 6; f++) {
122
+ if (!(mask & (1 << f))) continue;
123
+ const faceKey = FACE_KEYS[f];
124
+ const key = idx * 6 + f;
125
+
126
+ // firstHitFromFace is invariant for this face; memoize so the facing and
127
+ // mirror branches march the depth ray at most once between them.
128
+ let firstHit;
129
+ const isFirstHit = () =>
130
+ (firstHit ??= firstHitFromFace(solid, dims, x, y, z, faceKey));
131
+
132
+ // 1. Facing view, depth-gated.
133
+ const facing = FACE_TO_VIEW[faceKey];
134
+ let color = null;
135
+ if (gviews[facing] && isFirstHit()) {
136
+ color = sampleView(gviews[facing], facing, x, y, z, dims);
137
+ }
138
+ // 2. Mirrored opposite view.
139
+ if (color == null && mirror[FACE_AXIS[faceKey]]) {
140
+ const opp = FACE_TO_VIEW[FACE_OPPOSITE[faceKey]];
141
+ if (gviews[opp] && isFirstHit()) {
142
+ color = sampleView(gviews[opp], opp, x, y, z, dims);
143
+ }
144
+ }
145
+ if (color != null) faceColor.set(key, snap(color));
146
+ else pending.push({ key, idx, x, y, z, f });
147
+ }
148
+ }
149
+
150
+ // 3. Relaxation: average already-colored neighbors (few passes).
151
+ const TANGENTIAL = {
152
+ x: [
153
+ [0, 1, 0],
154
+ [0, -1, 0],
155
+ [0, 0, 1],
156
+ [0, 0, -1],
157
+ ],
158
+ y: [
159
+ [1, 0, 0],
160
+ [-1, 0, 0],
161
+ [0, 0, 1],
162
+ [0, 0, -1],
163
+ ],
164
+ z: [
165
+ [1, 0, 0],
166
+ [-1, 0, 0],
167
+ [0, 1, 0],
168
+ [0, -1, 0],
169
+ ],
170
+ };
171
+ for (let pass = 0; pass < 4 && pending.length; pass++) {
172
+ const still = [];
173
+ for (const item of pending) {
174
+ const faceKey = FACE_KEYS[item.f];
175
+ let r = 0,
176
+ g = 0,
177
+ b = 0,
178
+ n = 0;
179
+ // same voxel, other colored faces
180
+ for (let f2 = 0; f2 < 6; f2++) {
181
+ const c = faceColor.get(item.idx * 6 + f2);
182
+ if (c != null) {
183
+ const u = unpackRGBA(c);
184
+ r += u.r;
185
+ g += u.g;
186
+ b += u.b;
187
+ n++;
188
+ }
189
+ }
190
+ // same face on tangential neighbor voxels
191
+ for (const [dx, dy, dz] of TANGENTIAL[FACE_AXIS[faceKey]]) {
192
+ const ax = item.x + dx,
193
+ ay = item.y + dy,
194
+ az = item.z + dz;
195
+ if (ax < 0 || ay < 0 || az < 0) continue;
196
+ if (ax >= dims.nx || ay >= dims.ny || az >= dims.nz) continue;
197
+ const c = faceColor.get(voxIndex(ax, ay, az, dims) * 6 + item.f);
198
+ if (c != null) {
199
+ const u = unpackRGBA(c);
200
+ r += u.r;
201
+ g += u.g;
202
+ b += u.b;
203
+ n++;
204
+ }
205
+ }
206
+ if (n > 0) {
207
+ faceColor.set(
208
+ item.key,
209
+ snap(packRGBA(Math.round(r / n), Math.round(g / n), Math.round(b / n)))
210
+ );
211
+ } else still.push(item);
212
+ }
213
+ pending.length = 0;
214
+ pending.push(...still);
215
+ }
216
+
217
+ // 4. Dominant body color for anything left.
218
+ if (pending.length) {
219
+ const tally = new Map();
220
+ for (const c of faceColor.values()) tally.set(c, (tally.get(c) || 0) + 1);
221
+ let dom = palette[0] ?? packRGBA(200, 200, 200);
222
+ let domN = -1;
223
+ for (const [c, k] of tally) if (k > domN) (domN = k), (dom = c);
224
+ for (const item of pending) faceColor.set(item.key, dom);
225
+ }
226
+
227
+ return { faceColor, palette };
228
+ }
@@ -0,0 +1,20 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The engine's defaults. Centralized so the pipeline, the mesher and every
3
+ // consumer can never drift on what "the defaults" are. Import and spread
4
+ // (`{ ...DEFAULT_MIRROR }`) rather than mutating these objects in place.
5
+ // (The editor's palettes, which shared this file before the engine became a
6
+ // package, are the app's src/lib/palette.js.)
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Per-axis mirror-fill: a face with no view of its own is always filled from the
11
+ * mirrored opposite view. On for every axis — objects are treated as symmetric,
12
+ * so a half-drawn sheet (e.g. no LEFT/BACK/BOTTOM) still colors every face.
13
+ */
14
+ export const DEFAULT_MIRROR = { x: true, y: true, z: true };
15
+
16
+ /**
17
+ * World-space size the largest grid axis is scaled to fill — the app's stage
18
+ * units. A headless build (model.js) uses one unit per voxel instead.
19
+ */
20
+ export const DEFAULT_WORLD_SIZE = 2.5;
package/src/diag.js ADDED
@@ -0,0 +1,68 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Geometry self-check — a dev probe (the app reads it under ?diag=1; a
3
+ // consumer can read it off any built mesh). Watertightness via
4
+ // position-based edge parity + a per-face normal histogram: a closed surface
5
+ // uses every undirected edge exactly twice; edges used an odd number of times
6
+ // are boundaries — a genuine hole in a mesh that should be closed.
7
+ //
8
+ // MODE MATTERS: a nonzero boundary/odd count is only a "hole" for a mesh that
9
+ // claims to be watertight. The low-poly (wedge) mesh is — its T-junctions are
10
+ // repaired (t-junction.js) — but the greedy-voxel mesh (low-poly OFF) deliberately
11
+ // leaves its step-riser T-junctions unrepaired, so it reports nonzero boundary/odd
12
+ // edges as EXPECTED artifacts, not holes. main.js tags the ?diag=1 title with the
13
+ // mode so the two conditions aren't conflated. This function just counts; it never
14
+ // asserts watertightness on its own.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** @param {import('three').BufferGeometry} geo */
18
+ export function computeDiag(geo) {
19
+ const pos = geo.attributes.position.array;
20
+ const nrm = geo.attributes.normal.array;
21
+ const idx = geo.index ? geo.index.array : null;
22
+ const triCount = idx ? idx.length / 3 : pos.length / 9;
23
+ const key = (i) => {
24
+ const x = Math.round(pos[i * 3] * 1e4);
25
+ const y = Math.round(pos[i * 3 + 1] * 1e4);
26
+ const z = Math.round(pos[i * 3 + 2] * 1e4);
27
+ return x + ',' + y + ',' + z;
28
+ };
29
+ const edges = new Map();
30
+ const axis = (i) => {
31
+ const ax = Math.abs(nrm[i * 3]),
32
+ ay = Math.abs(nrm[i * 3 + 1]),
33
+ az = Math.abs(nrm[i * 3 + 2]);
34
+ if (ax >= ay && ax >= az) return nrm[i * 3] > 0 ? 'px' : 'nx';
35
+ if (ay >= az) return nrm[i * 3 + 1] > 0 ? 'py' : 'ny';
36
+ return nrm[i * 3 + 2] > 0 ? 'pz' : 'nz';
37
+ };
38
+ const hist = { px: 0, nx: 0, py: 0, ny: 0, pz: 0, nz: 0 };
39
+ for (let t = 0; t < triCount; t++) {
40
+ const a = idx ? idx[t * 3] : t * 3;
41
+ const b = idx ? idx[t * 3 + 1] : t * 3 + 1;
42
+ const c = idx ? idx[t * 3 + 2] : t * 3 + 2;
43
+ hist[axis(a)]++;
44
+ for (const [p, q] of [
45
+ [a, b],
46
+ [b, c],
47
+ [c, a],
48
+ ]) {
49
+ const ka = key(p),
50
+ kb = key(q);
51
+ const e = ka < kb ? ka + '|' + kb : kb + '|' + ka;
52
+ edges.set(e, (edges.get(e) || 0) + 1);
53
+ }
54
+ }
55
+ let boundary = 0,
56
+ odd = 0;
57
+ for (const n of edges.values()) {
58
+ if (n === 1) boundary++;
59
+ if (n % 2 === 1) odd++;
60
+ }
61
+ return {
62
+ triCount,
63
+ hist,
64
+ uniqueEdges: edges.size,
65
+ boundaryEdges: boundary,
66
+ oddEdges: odd,
67
+ };
68
+ }
package/src/faces.js ADDED
@@ -0,0 +1,69 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The face vocabulary's GEOMETRY: for each face key, the axes its plane spans
3
+ // (A, B — the tangent frame every 2D read of a face uses: the regions, the
4
+ // skin's charts and the UV read) and the axis it faces along (N), plus the two
5
+ // lattice reads built on them — a tangent coordinate's voxel index (idxFor)
6
+ // and its 3D point (pointOf). Pure, no THREE, Node-testable.
7
+ //
8
+ // A face's plane sits at slice s along N: at s for a negative face (the
9
+ // voxel's near side), at s + 1 for a positive one. Tangent (a, b) is the
10
+ // voxel's own A/B coordinate, so the unit square [a, a+1] × [b, b+1] on the
11
+ // plane IS that voxel's face, and a texel per face is a texel per unit
12
+ // square — the skin's orientation rule, stated once (skin.js).
13
+ //
14
+ // Until Sep 7 2026 this module also merged faces into greedy rectangles
15
+ // (culledQuads / greedyQuads); the mesher traces coplanar REGIONS now
16
+ // (regions.js) — a rectangle being the region with four corners — so the quad
17
+ // builders went with their last consumer.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { voxIndex } from './carve.js';
21
+ import { FACE_NORMAL, AXIS_INDEX } from './views.js';
22
+
23
+ // Per-face tangent axes and the normal axis. The outward normal is read from
24
+ // the shared FACE_NORMAL (views.js), not restated here.
25
+ export const FACE_GEO = {
26
+ px: { N: 'x', A: 'y', B: 'z' },
27
+ nx: { N: 'x', A: 'y', B: 'z' },
28
+ py: { N: 'y', A: 'x', B: 'z' },
29
+ ny: { N: 'y', A: 'x', B: 'z' },
30
+ pz: { N: 'z', A: 'x', B: 'y' },
31
+ nz: { N: 'z', A: 'x', B: 'y' },
32
+ };
33
+
34
+ /**
35
+ * The voxel index behind tangent coords (a, b) on slice s of `face` — the
36
+ * lattice cell whose face that is. One home: the regions, the skin's baker
37
+ * and every test compose it here.
38
+ * @param {string} face a FACE_KEYS key
39
+ * @param {number} a along FACE_GEO[face].A
40
+ * @param {number} b along FACE_GEO[face].B
41
+ * @param {number} s the slice along FACE_GEO[face].N
42
+ * @param {{nx:number, ny:number, nz:number}} dims
43
+ */
44
+ export function idxFor(face, a, b, s, dims) {
45
+ const g = FACE_GEO[face];
46
+ const c = { x: 0, y: 0, z: 0 };
47
+ c[g.N] = s;
48
+ c[g.A] = a;
49
+ c[g.B] = b;
50
+ return voxIndex(c.x, c.y, c.z, dims);
51
+ }
52
+
53
+ /**
54
+ * The 3D lattice point at tangent (a, b) on the plane of `face` at slice s —
55
+ * the plane at s + 1 for a positive face, at s for a negative one.
56
+ * @param {string} face
57
+ * @param {number} a
58
+ * @param {number} b
59
+ * @param {number} s
60
+ * @returns {number[]}
61
+ */
62
+ export function pointOf(face, a, b, s) {
63
+ const g = FACE_GEO[face];
64
+ const c = { x: 0, y: 0, z: 0 };
65
+ c[g.N] = s + (FACE_NORMAL[face][AXIS_INDEX[g.N]] > 0 ? 1 : 0);
66
+ c[g.A] = a;
67
+ c[g.B] = b;
68
+ return [c.x, c.y, c.z];
69
+ }