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/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
|
+
}
|
package/src/colorize.js
ADDED
|
@@ -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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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
|
+
}
|