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/views.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// View conventions and projection mappings.
|
|
3
|
+
//
|
|
4
|
+
// World frame: +x = right, +y = up, +z = toward the front (camera).
|
|
5
|
+
// A "view" is an orthographic face render. Each view has:
|
|
6
|
+
// - normal: the world-space outward normal of the face it observes.
|
|
7
|
+
// - axis: the world axis it looks ALONG (the depth / extrusion axis).
|
|
8
|
+
// - project(x, y, z, dims) -> {u, v}: which pixel of the view a voxel maps to.
|
|
9
|
+
// u,v are image coords with (0,0) = top-left, v growing DOWN.
|
|
10
|
+
//
|
|
11
|
+
// The image width/height of a view are tied to two of the grid dims:
|
|
12
|
+
// FRONT/BACK : image is (nx wide, ny tall) -> sees the X/Y plane
|
|
13
|
+
// LEFT/RIGHT : image is (nz wide, ny tall) -> sees the Z/Y plane
|
|
14
|
+
// TOP/BOTTOM : image is (nx wide, nz tall) -> sees the X/Z plane
|
|
15
|
+
//
|
|
16
|
+
// These six mappings are set-and-forget: identical for every model. They were
|
|
17
|
+
// chosen so that, standing at the camera and looking at each face, the sprite's
|
|
18
|
+
// pixel (col,row) lands where a human artist expects (art drawn upright and
|
|
19
|
+
// left-to-right as seen from outside the object).
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** @typedef {{nx:number, ny:number, nz:number}} Dims */
|
|
23
|
+
|
|
24
|
+
// Human-facing view names -> face normals. Its inverse
|
|
25
|
+
// FACE_TO_VIEW is consumed by colorize, VIEW_TO_FACE by the edge hints (which
|
|
26
|
+
// need a view's own face to know which way it looks); every pair is already
|
|
27
|
+
// implied by the face metadata below.
|
|
28
|
+
export const VIEW_TO_FACE = {
|
|
29
|
+
right: 'nx',
|
|
30
|
+
left: 'px',
|
|
31
|
+
top: 'py',
|
|
32
|
+
bottom: 'ny',
|
|
33
|
+
front: 'pz',
|
|
34
|
+
back: 'nz',
|
|
35
|
+
};
|
|
36
|
+
export const FACE_TO_VIEW = Object.fromEntries(
|
|
37
|
+
Object.entries(VIEW_TO_FACE).map(([k, v]) => [v, k])
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Outward unit normals per face key. The single source of truth for per-face
|
|
41
|
+
// axis/direction — FACE_AXIS, carve's NEIGHBORS, and faces.js's quad normals are
|
|
42
|
+
// all derived from this so they can't drift from the convention the 6-bit surface
|
|
43
|
+
// mask and the faceColor keying (idx*6+f) depend on.
|
|
44
|
+
export const FACE_NORMAL = {
|
|
45
|
+
px: [1, 0, 0],
|
|
46
|
+
nx: [-1, 0, 0],
|
|
47
|
+
py: [0, 1, 0],
|
|
48
|
+
ny: [0, -1, 0],
|
|
49
|
+
pz: [0, 0, 1],
|
|
50
|
+
nz: [0, 0, -1],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Canonical face-key order: the 6-bit surface-exposure mask and the faceColor map
|
|
54
|
+
// (keyed idx*6+f) both index by this position, so it is load-bearing. Co-located
|
|
55
|
+
// with FACE_NORMAL; carve.js re-exports it for the consumers that read it there.
|
|
56
|
+
export const FACE_KEYS = ['px', 'nx', 'py', 'ny', 'pz', 'nz'];
|
|
57
|
+
|
|
58
|
+
// Face key -> its index in FACE_KEYS (memoized indexOf).
|
|
59
|
+
export const FACE_INDEX = Object.fromEntries(FACE_KEYS.map((k, i) => [k, i]));
|
|
60
|
+
|
|
61
|
+
// World-axis name -> its index in an [x, y, z] triple.
|
|
62
|
+
export const AXIS_INDEX = { x: 0, y: 1, z: 2 };
|
|
63
|
+
|
|
64
|
+
// The face key whose outward normal points along world `axis` with `sign` (±1).
|
|
65
|
+
// Derived from FACE_NORMAL so an (axis, sign) pair can never drift from the normals.
|
|
66
|
+
export const faceKeyOf = (axis, sign) =>
|
|
67
|
+
FACE_KEYS.find((k) => FACE_NORMAL[k][AXIS_INDEX[axis]] === sign);
|
|
68
|
+
|
|
69
|
+
// Which world axis each face's outward normal lies on. Derived from
|
|
70
|
+
// FACE_NORMAL so it can't drift; used by colorize for mirror-fill.
|
|
71
|
+
export const FACE_AXIS = Object.fromEntries(
|
|
72
|
+
Object.entries(FACE_NORMAL).map(([k, n]) => [k, n[0] ? 'x' : n[1] ? 'y' : 'z'])
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Opposite face (for mirror-fill).
|
|
76
|
+
export const FACE_OPPOSITE = {
|
|
77
|
+
px: 'nx',
|
|
78
|
+
nx: 'px',
|
|
79
|
+
py: 'ny',
|
|
80
|
+
ny: 'py',
|
|
81
|
+
pz: 'nz',
|
|
82
|
+
nz: 'pz',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// For each face/view: the image dimensions in grid units and the pixel projection.
|
|
86
|
+
// The march axis + direction used for first-hit visibility is NOT stored here — it
|
|
87
|
+
// is derived from FACE_NORMAL in colorize.firstHitFromFace, the single source of
|
|
88
|
+
// truth (a `step`/`from` field here would be a silent drift hazard).
|
|
89
|
+
//
|
|
90
|
+
// projectInto(x,y,z,d,out) writes integer image coords into the reused `out` (no
|
|
91
|
+
// per-voxel allocation in carve's hot triple loop); project() is the allocating
|
|
92
|
+
// convenience that delegates to it, so each view has exactly ONE formula. `imgW`/
|
|
93
|
+
// `imgH` give the expected view image size for a grid, so carve can place each view
|
|
94
|
+
// at native scale (padding, never stretching) and index it 1:1.
|
|
95
|
+
export const VIEWS = {
|
|
96
|
+
// FRONT: looks toward -z from +z. Sees +z face. Image = X (right) by Y (up).
|
|
97
|
+
front: {
|
|
98
|
+
imgW: (d) => d.nx,
|
|
99
|
+
imgH: (d) => d.ny,
|
|
100
|
+
projectInto: (x, y, z, d, o) => ((o.u = x), (o.v = d.ny - 1 - y), o),
|
|
101
|
+
project(x, y, z, d) {
|
|
102
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
// BACK: looks toward +z from -z. Sees -z face. Left-right mirrored vs front.
|
|
106
|
+
back: {
|
|
107
|
+
imgW: (d) => d.nx,
|
|
108
|
+
imgH: (d) => d.ny,
|
|
109
|
+
projectInto: (x, y, z, d, o) => ((o.u = d.nx - 1 - x), (o.v = d.ny - 1 - y), o),
|
|
110
|
+
project(x, y, z, d) {
|
|
111
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
// LEFT: the atlas tile drawn as the object's LEFT side. It colors the +x face
|
|
115
|
+
// — viewed straight-on from +x that face reads as a left-side profile, so the
|
|
116
|
+
// *tile* is named by how it reads (a deliberate labeling choice; see README),
|
|
117
|
+
// not by the world axis it happens to occupy. Image = Z by Y. u = nz-1-z puts
|
|
118
|
+
// the object's front (+z) at the left column, matching a nose-left profile.
|
|
119
|
+
left: {
|
|
120
|
+
imgW: (d) => d.nz,
|
|
121
|
+
imgH: (d) => d.ny,
|
|
122
|
+
projectInto: (x, y, z, d, o) => ((o.u = d.nz - 1 - z), (o.v = d.ny - 1 - y), o),
|
|
123
|
+
project(x, y, z, d) {
|
|
124
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
// RIGHT: the object's RIGHT side; colors the -x face, which reads as a
|
|
128
|
+
// right-side profile. Mirror of left along z — u = z puts front at the right column.
|
|
129
|
+
right: {
|
|
130
|
+
imgW: (d) => d.nz,
|
|
131
|
+
imgH: (d) => d.ny,
|
|
132
|
+
projectInto: (x, y, z, d, o) => ((o.u = z), (o.v = d.ny - 1 - y), o),
|
|
133
|
+
project(x, y, z, d) {
|
|
134
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
// TOP: looks toward -y from +y. Sees +y face. Image = X by Z.
|
|
138
|
+
// Looking straight down: v = nz-1-z puts the object's front (+z, z=nz-1) on
|
|
139
|
+
// the TOP row of the image (v=0), matching the FRONT view's top-is-v=0.
|
|
140
|
+
top: {
|
|
141
|
+
imgW: (d) => d.nx,
|
|
142
|
+
imgH: (d) => d.nz,
|
|
143
|
+
projectInto: (x, y, z, d, o) => ((o.u = x), (o.v = d.nz - 1 - z), o),
|
|
144
|
+
project(x, y, z, d) {
|
|
145
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
// BOTTOM: looks toward +y from -y. Sees -y face. The car is flipped SIDEWAYS
|
|
149
|
+
// (rolled about its front-back axis), NOT end-over-end — so the front stays on
|
|
150
|
+
// the TOP row like TOP (v = nz-1-z) and only left/right swap (u = nx-1-x). That
|
|
151
|
+
// way the TOP and BOTTOM tiles register front-to-front on the same edge.
|
|
152
|
+
bottom: {
|
|
153
|
+
imgW: (d) => d.nx,
|
|
154
|
+
imgH: (d) => d.nz,
|
|
155
|
+
projectInto: (x, y, z, d, o) => ((o.u = d.nx - 1 - x), (o.v = d.nz - 1 - z), o),
|
|
156
|
+
project(x, y, z, d) {
|
|
157
|
+
return this.projectInto(x, y, z, d, { u: 0, v: 0 });
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const VIEW_NAMES = Object.keys(VIEWS);
|
|
163
|
+
|
|
164
|
+
// The six view names in atlas-sheet (row-major) order, matching atlas.js
|
|
165
|
+
// DEFAULT_ATLAS_LAYOUT (LEFT FRONT TOP / RIGHT BACK BOTTOM). A pinned convention
|
|
166
|
+
// (a test asserts it equals the layout) — there is no on-screen faces-preview
|
|
167
|
+
// grid; the editor switches faces with text tabs.
|
|
168
|
+
export const VIEW_DISPLAY_ORDER = ['left', 'front', 'top', 'right', 'back', 'bottom'];
|
|
169
|
+
|
|
170
|
+
// Which image edge of a view's tile the object's FRONT (+z, the "nose") points
|
|
171
|
+
// toward. A projection-convention pin (a test checks it against VIEWS' projections)
|
|
172
|
+
// and the reference behind the README's per-face "Front points" column. Derived
|
|
173
|
+
// meaning: in LEFT, front (z=nz-1) maps to u=0, the left column; TOP and BOTTOM
|
|
174
|
+
// both put the front on their TOP edge (BOTTOM is the sideways flip of TOP);
|
|
175
|
+
// FRONT/BACK look straight down +z/-z, so their nose points out of / into the
|
|
176
|
+
// screen — there is no in-plane front edge (null).
|
|
177
|
+
export const VIEW_FRONT_EDGE = {
|
|
178
|
+
right: 'right',
|
|
179
|
+
left: 'left',
|
|
180
|
+
top: 'top',
|
|
181
|
+
bottom: 'top',
|
|
182
|
+
front: null,
|
|
183
|
+
back: null,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Each view's opposite (the mirror-fill source when a view has no art of its own).
|
|
187
|
+
export const VIEW_OPPOSITE = {
|
|
188
|
+
right: 'left',
|
|
189
|
+
left: 'right',
|
|
190
|
+
front: 'back',
|
|
191
|
+
back: 'front',
|
|
192
|
+
top: 'bottom',
|
|
193
|
+
bottom: 'top',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// To DISPLAY a mirror-derived face, flip its opposite view's tile along this
|
|
197
|
+
// IMAGE axis. It is 'x' for EVERY pair (a single constant, not a per-view table
|
|
198
|
+
// that would imply the axis varies): the projections make each pair mirror
|
|
199
|
+
// HORIZONTALLY — left↔right and front↔back on the X/Z planes, and top↔bottom too
|
|
200
|
+
// because BOTTOM is the sideways (left/right) flip of TOP, not an end-over-end one.
|
|
201
|
+
// (mirrorImage's 'y' branch in derive.js is therefore unexercised in practice.)
|
|
202
|
+
export const MIRROR_AXIS = 'x';
|
|
203
|
+
|
|
204
|
+
// Which grid axes a view's (imgW, imgH) constrain. Used by dimension
|
|
205
|
+
// reconciliation. Each entry: [axisForImgW, axisForImgH].
|
|
206
|
+
export const VIEW_AXES = {
|
|
207
|
+
front: ['nx', 'ny'],
|
|
208
|
+
back: ['nx', 'ny'],
|
|
209
|
+
right: ['nz', 'ny'],
|
|
210
|
+
left: ['nz', 'ny'],
|
|
211
|
+
top: ['nx', 'nz'],
|
|
212
|
+
bottom: ['nx', 'nz'],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// For each view: which world axis its image COLUMNS (u) and ROWS (v) run along,
|
|
216
|
+
// and whether the image index runs the SAME direction as the world coordinate
|
|
217
|
+
// (flip:false) or the OPPOSITE (flip:true). Probed from project() at load so it
|
|
218
|
+
// can never drift from the projections above (test/views.test.mjs pins it).
|
|
219
|
+
// Consumed by the sheet resize (atlas.js resizeAtlas) to place each
|
|
220
|
+
// face's tile so every face sharing a world axis shifts identically.
|
|
221
|
+
const AXIS_ARG = { nx: 0, ny: 1, nz: 2 };
|
|
222
|
+
function probeFlip(spec, axisName, which) {
|
|
223
|
+
const d = { nx: 2, ny: 2, nz: 2 };
|
|
224
|
+
const at = (coord) => {
|
|
225
|
+
const p = [0, 0, 0];
|
|
226
|
+
p[AXIS_ARG[axisName]] = coord;
|
|
227
|
+
return spec.project(p[0], p[1], p[2], d)[which];
|
|
228
|
+
};
|
|
229
|
+
return at(0) > at(1); // world coord 0 -> higher image index => flipped
|
|
230
|
+
}
|
|
231
|
+
export const VIEW_IMAGE_AXES = Object.fromEntries(
|
|
232
|
+
VIEW_NAMES.map((name) => {
|
|
233
|
+
const [colAxis, rowAxis] = VIEW_AXES[name];
|
|
234
|
+
return [
|
|
235
|
+
name,
|
|
236
|
+
{
|
|
237
|
+
colAxis,
|
|
238
|
+
colFlip: probeFlip(VIEWS[name], colAxis, 'u'),
|
|
239
|
+
rowAxis,
|
|
240
|
+
rowFlip: probeFlip(VIEWS[name], rowAxis, 'v'),
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
})
|
|
244
|
+
);
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Additive-wedge low-poly engine.
|
|
3
|
+
//
|
|
4
|
+
// Builds directly on the working voxel model (result.solid / surfaceMask /
|
|
5
|
+
// faceColor) instead of remeshing a silhouette. The ONLY new geometry is a 45°
|
|
6
|
+
// WEDGE that fills a concave unit-step notch:
|
|
7
|
+
//
|
|
8
|
+
// An EMPTY cell whose two solid orthogonal neighbours sit on ADJACENT sides
|
|
9
|
+
// (and whose other two in-plane sides are empty) is the inner corner of a
|
|
10
|
+
// staircase. We fill that corner with a triangular prism; its hypotenuse is
|
|
11
|
+
// the 45° slope, the two faces it covers become internal (culled), and the
|
|
12
|
+
// run is closed at its ends with triangular caps (the "gable" triangles).
|
|
13
|
+
//
|
|
14
|
+
// Why this is correct-by-construction where the earlier attempts failed:
|
|
15
|
+
// - It's ADDITIVE: wedges only fill notches, so it can never punch a hole or
|
|
16
|
+
// eat the object (unlike the melted planar-remesh / slab-loft dead ends).
|
|
17
|
+
// - A wedge is ONE MATERIAL by its gate: it fires only where the two covered
|
|
18
|
+
// faces already agree on colour, so a window/body seam stays a sharp 45°
|
|
19
|
+
// edge with no depth guessing (the whole point of the reverted per-color-
|
|
20
|
+
// parts task), and its whole surface points at that colour's swatch.
|
|
21
|
+
// - A lone cube has no concave notch, so it gets NO wedges and stays sharp —
|
|
22
|
+
// the additive rule self-guards convex structural corners.
|
|
23
|
+
//
|
|
24
|
+
// COLOR IS THE SKIN, NOT THE GEOMETRY (Sep 7 2026). The base faces merge on
|
|
25
|
+
// occupancy alone and are painted by a texture (skin.js): a region that
|
|
26
|
+
// crosses a colour boundary carries a chart — a texel per cell — and a
|
|
27
|
+
// one-colour region, like every wedge, points at its colour's swatch; every
|
|
28
|
+
// triangle's UVs are an affine read of its vertices' lattice positions, taken
|
|
29
|
+
// AFTER the T-junction repair, so no UV is ever plumbed through a split.
|
|
30
|
+
// Until then every triangle carried a vertex colour and the merge could only
|
|
31
|
+
// join faces of one colour — a painted wall shattered into a rect per
|
|
32
|
+
// region, each boundary feeding the repair. The Car: 1784 → 900 triangles,
|
|
33
|
+
// the same 236 wedges. Opening the wedge gate to match was measured and
|
|
34
|
+
// rejected (1092: more wedges are more caps and more split faces), so the
|
|
35
|
+
// strict same-material gate stays exactly as it was.
|
|
36
|
+
//
|
|
37
|
+
// THE PLANAR MERGE (Sep 7 2026, the same day, in two steps). The scan fires
|
|
38
|
+
// per notch cell, but the geometry is emitted per PLANE:
|
|
39
|
+
// - A SLOPE is one quad per BLOCK: the wedge cells of one 45° plane (one
|
|
40
|
+
// orientation, one intercept) form a grid — t along the staircase, r along
|
|
41
|
+
// the ridge — that is greedy-merged on one colour. Emitted per cell, a
|
|
42
|
+
// windshield was a grid of unit quads, and every unit edge along a roof's
|
|
43
|
+
// rim pinned a vertex on it that the repair then had to fan the roof
|
|
44
|
+
// around (the Car's 17×10 roof: 20 triangles). Merging the runs along the
|
|
45
|
+
// ridge alone took the Car 900 → 408; across the staircase too it would
|
|
46
|
+
// have gained nothing (392) as long as the gable caps stayed a sawtooth
|
|
47
|
+
// of triangles whose corners split the slope's long diagonal edge back.
|
|
48
|
+
// - So the base faces are coplanar REGIONS (regions.js), not greedy rects:
|
|
49
|
+
// a plane's exposed faces AND the cap half-faces the blocks end on, traced
|
|
50
|
+
// as one polygon with every collinear run merged — the wall beside a
|
|
51
|
+
// windshield has one straight diagonal edge, and the slope beside it is
|
|
52
|
+
// two triangles. Each region is triangulated by earcut (THREE's
|
|
53
|
+
// ShapeUtils, holes included) and painted as one: a chart over its box
|
|
54
|
+
// where it crosses a colour, a swatch where it does not.
|
|
55
|
+
// The T-junction repair stays, now reading 45° edges too: a region's edge
|
|
56
|
+
// and a slope's ridge still meet to different extents where a corner of
|
|
57
|
+
// another plane lands on them, and every vertex is on the lattice.
|
|
58
|
+
//
|
|
59
|
+
// Scope: additive wedges only. Convex staircases (a hood sloping
|
|
60
|
+
// down-and-out) still step, and true 3-D corners where two ridges meet degrade
|
|
61
|
+
// to a step rather than a corner tile. One wedge per cell (first ridge wins).
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
import * as THREE from 'three';
|
|
65
|
+
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
66
|
+
import { voxIndex } from './carve.js';
|
|
67
|
+
import { FACE_GEO, pointOf } from './faces.js';
|
|
68
|
+
import { faceRegions, planeKey } from './regions.js';
|
|
69
|
+
import { unpackRGBA } from './ingest.js';
|
|
70
|
+
import { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
|
|
71
|
+
import { eliminateTJunctions } from './t-junction.js';
|
|
72
|
+
import { finishVoxelMesh, skinTexture } from './mesh-util.js';
|
|
73
|
+
import { AXIS_INDEX, FACE_INDEX, faceKeyOf } from './views.js';
|
|
74
|
+
import { DEFAULT_WORLD_SIZE } from './constants.js';
|
|
75
|
+
|
|
76
|
+
const AXI = AXIS_INDEX; // world-axis name -> [x,y,z] index
|
|
77
|
+
const FLAT_COLOR = 0xffcfcfd6;
|
|
78
|
+
|
|
79
|
+
// The three ridge axes (the axis a wedge prism extends along) and their two
|
|
80
|
+
// in-plane tangent axes (A, B). Order matters: z first so long z-ridges (the
|
|
81
|
+
// common extruded roof/windshield) win the one-wedge-per-cell tie.
|
|
82
|
+
const RIDGES = [
|
|
83
|
+
{ R: 'z', A: 'x', B: 'y' },
|
|
84
|
+
{ R: 'x', A: 'z', B: 'y' },
|
|
85
|
+
{ R: 'y', A: 'x', B: 'z' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A lattice triangle and its paint: a charted region's triangle carries its
|
|
90
|
+
* chart and region (its UVs are read off its vertices); everything else — a
|
|
91
|
+
* one-colour region's, a slope's — carries the packed colour whose swatch it
|
|
92
|
+
* samples. The T-junction repair copies the paint onto every piece.
|
|
93
|
+
* @typedef {{a:number[], b:number[], c:number[], normal:number[],
|
|
94
|
+
* chart:import('./skin.js').Chart|null,
|
|
95
|
+
* region:import('./regions.js').Region|null,
|
|
96
|
+
* swatch:number|null}} Tri
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
export function wedgeMesh(result, opts = {}) {
|
|
100
|
+
const { dims, solid, surfaceMask, faceColor } = result;
|
|
101
|
+
const { nx, ny, nz } = dims;
|
|
102
|
+
const flat = !!opts.flat;
|
|
103
|
+
const worldSize = opts.worldSize ?? DEFAULT_WORLD_SIZE;
|
|
104
|
+
const s = worldSize / Math.max(nx, ny, nz);
|
|
105
|
+
|
|
106
|
+
// The wedge fires only where its two covered faces are the same material.
|
|
107
|
+
// Those faceColor values are already palette-snapped by colorize, but privacy
|
|
108
|
+
// browsers "farble" getImageData (~±1/channel), which splits the palette into
|
|
109
|
+
// near-duplicate entries, so two same-material faces can land on *adjacent*
|
|
110
|
+
// entries. sameMat therefore compares with a small squared-L2 tolerance;
|
|
111
|
+
// distinct authored materials sit ~180 apart, far above the ~12 slack, so a
|
|
112
|
+
// colour boundary the artist drew still gates crisply.
|
|
113
|
+
const TOL2 = 12 * 12; // ~12 per-channel slack (squared L2): covers farble + AA
|
|
114
|
+
const sameMat = (a, b) => {
|
|
115
|
+
if (a == null || b == null) return false;
|
|
116
|
+
// Compare RGB only; the alpha byte is always 255 here, so masking it keeps
|
|
117
|
+
// the exact fast path and the tolerant path judging identity on the same bits.
|
|
118
|
+
if (((a >>> 0) & 0xffffff) === ((b >>> 0) & 0xffffff)) return true;
|
|
119
|
+
const A = unpackRGBA(a);
|
|
120
|
+
const B = unpackRGBA(b);
|
|
121
|
+
return (A.r - B.r) ** 2 + (A.g - B.g) ** 2 + (A.b - B.b) ** 2 <= TOL2;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const inBounds = (x, y, z) => x >= 0 && y >= 0 && z >= 0 && x < nx && y < ny && z < nz;
|
|
125
|
+
const solidAt = (x, y, z) => inBounds(x, y, z) && solid[voxIndex(x, y, z, dims)];
|
|
126
|
+
const step = (x, y, z, ax, sg) => [
|
|
127
|
+
x + sg * +(ax === 'x'),
|
|
128
|
+
y + sg * +(ax === 'y'),
|
|
129
|
+
z + sg * +(ax === 'z'),
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// --- scan for wedges ------------------------------------------------------
|
|
133
|
+
const wedgeCell = new Map(); // cellIdx -> chosen {R,A,B,sA,sB,color}
|
|
134
|
+
const removed = new Set(); // base faces (idx*6+f) culled because a wedge covers them
|
|
135
|
+
const wedges = [];
|
|
136
|
+
|
|
137
|
+
for (const ridge of RIDGES) {
|
|
138
|
+
const { R, A, B } = ridge;
|
|
139
|
+
for (let z = 0; z < nz; z++)
|
|
140
|
+
for (let y = 0; y < ny; y++)
|
|
141
|
+
for (let x = 0; x < nx; x++) {
|
|
142
|
+
const cidx = voxIndex(x, y, z, dims);
|
|
143
|
+
if (solid[cidx] || wedgeCell.has(cidx)) continue; // C must be empty & unclaimed
|
|
144
|
+
for (const sA of [-1, 1]) {
|
|
145
|
+
let placed = false;
|
|
146
|
+
for (const sB of [-1, 1]) {
|
|
147
|
+
const aN = step(x, y, z, A, sA); // solid neighbour on A side
|
|
148
|
+
const bN = step(x, y, z, B, sB); // solid neighbour on B side
|
|
149
|
+
if (!solidAt(...aN) || !solidAt(...bN)) continue;
|
|
150
|
+
// opposite sides must be empty -> exactly two adjacent solids
|
|
151
|
+
if (solidAt(...step(x, y, z, A, -sA))) continue;
|
|
152
|
+
if (solidAt(...step(x, y, z, B, -sB))) continue;
|
|
153
|
+
|
|
154
|
+
// faces the wedge covers = each neighbour's face pointing back at C
|
|
155
|
+
const faceA = faceKeyOf(A, -sA);
|
|
156
|
+
const faceB = faceKeyOf(B, -sB);
|
|
157
|
+
const aKey = voxIndex(...aN, dims) * 6 + FACE_INDEX[faceA];
|
|
158
|
+
const bKey = voxIndex(...bN, dims) * 6 + FACE_INDEX[faceB];
|
|
159
|
+
const cA = faceColor.get(aKey);
|
|
160
|
+
const cB = faceColor.get(bKey);
|
|
161
|
+
|
|
162
|
+
// Gate: fire the wedge iff its two COVERED faces — the only two
|
|
163
|
+
// surfaces the prism merges (the riser cA and the tread cB) — are
|
|
164
|
+
// the same material. Nothing else is consulted: no profile/facing
|
|
165
|
+
// view sampling, no occlusion march. This is deliberate and gives
|
|
166
|
+
// the sprite author exact, local control over every wedge: paint the
|
|
167
|
+
// two faces a corner joins the same colour and it ramps; paint them
|
|
168
|
+
// differently and it stays a crisp step. A slope smooths only where
|
|
169
|
+
// its riser and its up-facing tread read the same colour, so the
|
|
170
|
+
// top-view art over a slope must match the face it caps — the author
|
|
171
|
+
// decides which corners round, not a heuristic guess about "slopes".
|
|
172
|
+
// (And, since the skin: a looser gate would COST triangles — more
|
|
173
|
+
// wedges are more caps and more split base faces, measured on the
|
|
174
|
+
// Car — so the strictness is the count's too.)
|
|
175
|
+
if (!flat && !sameMat(cA, cB)) continue;
|
|
176
|
+
// The non-flat gate guarantees cA and cB agree, so either is the
|
|
177
|
+
// surface's true colour. (In flat mode the wedge colour is overridden
|
|
178
|
+
// to FLAT_COLOR downstream, so a null here can never render.)
|
|
179
|
+
const color = (cA != null ? cA : cB) >>> 0;
|
|
180
|
+
wedgeCell.set(cidx, { R, A, B, sA, sB, color });
|
|
181
|
+
removed.add(aKey);
|
|
182
|
+
removed.add(bKey);
|
|
183
|
+
wedges.push({ x, y, z, R, A, B, sA, sB, color });
|
|
184
|
+
placed = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (placed) break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- geometry emit --------------------------------------------------------
|
|
193
|
+
// Collect INTEGER-lattice triangles first (regions + slopes), eliminate the
|
|
194
|
+
// T-junctions the merges leave, THEN build the scaled buffers.
|
|
195
|
+
/** @type {Tri[]} */
|
|
196
|
+
const tris = []; // CCW wrt normal
|
|
197
|
+
// paint: { chart, region } for a charted region, { swatch } for a
|
|
198
|
+
// one-material primitive (a one-colour region, a slope)
|
|
199
|
+
const pushTri = (a, b, c, N, paint) => {
|
|
200
|
+
// wind to match the explicit outward normal N (backface culling is on)
|
|
201
|
+
const ux = b[0] - a[0],
|
|
202
|
+
uy = b[1] - a[1],
|
|
203
|
+
uz = b[2] - a[2];
|
|
204
|
+
const vx = c[0] - a[0],
|
|
205
|
+
vy = c[1] - a[1],
|
|
206
|
+
vz = c[2] - a[2];
|
|
207
|
+
const gx = uy * vz - uz * vy,
|
|
208
|
+
gy = uz * vx - ux * vz,
|
|
209
|
+
gz = ux * vy - uy * vx;
|
|
210
|
+
if (gx * N[0] + gy * N[1] + gz * N[2] < 0) {
|
|
211
|
+
const t = b;
|
|
212
|
+
b = c;
|
|
213
|
+
c = t;
|
|
214
|
+
}
|
|
215
|
+
tris.push({
|
|
216
|
+
a,
|
|
217
|
+
b,
|
|
218
|
+
c,
|
|
219
|
+
normal: N,
|
|
220
|
+
chart: paint.chart ?? null,
|
|
221
|
+
region: paint.region ?? null,
|
|
222
|
+
swatch: paint.swatch ?? null,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
const pushQuad = (a, b, c, d, N, paint) => {
|
|
226
|
+
pushTri(a, b, c, N, paint);
|
|
227
|
+
pushTri(a, c, d, N, paint);
|
|
228
|
+
};
|
|
229
|
+
// build a point [x,y,z] from three axis/value pairs
|
|
230
|
+
const mk = (a1, v1, a2, v2, a3, v3) => {
|
|
231
|
+
const p = [0, 0, 0];
|
|
232
|
+
p[AXI[a1]] = v1;
|
|
233
|
+
p[AXI[a2]] = v2;
|
|
234
|
+
p[AXI[a3]] = v3;
|
|
235
|
+
return p;
|
|
236
|
+
};
|
|
237
|
+
const axisVec = (a1, s1, a2, s2) => {
|
|
238
|
+
const p = [0, 0, 0];
|
|
239
|
+
p[AXI[a1]] = s1;
|
|
240
|
+
if (a2) p[AXI[a2]] = s2;
|
|
241
|
+
const L = Math.hypot(p[0], p[1], p[2]) || 1;
|
|
242
|
+
return [p[0] / L, p[1] / L, p[2] / L];
|
|
243
|
+
};
|
|
244
|
+
// a notch cell's corners along its wedge's A and B: the filled corner (the
|
|
245
|
+
// one toward the two solids) and the opposite one
|
|
246
|
+
const cornersOf = (w) => {
|
|
247
|
+
const p = { x: w.x, y: w.y, z: w.z };
|
|
248
|
+
const aC = p[w.A];
|
|
249
|
+
const bC = p[w.B];
|
|
250
|
+
return {
|
|
251
|
+
Ac: w.sA < 0 ? aC : aC + 1,
|
|
252
|
+
Ao: w.sA < 0 ? aC + 1 : aC,
|
|
253
|
+
Bc: w.sB < 0 ? bC : bC + 1,
|
|
254
|
+
Bo: w.sB < 0 ? bC + 1 : bC,
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// 1. the slopes, one quad per BLOCK. The cells of one 45° plane — one
|
|
259
|
+
// orientation (R, A, B, sA, sB) and one intercept sA·a + sB·b — sit on a
|
|
260
|
+
// grid: t = sA·a runs up the staircase (the cell at (a + sA, b − sB) is
|
|
261
|
+
// t + 1, its hypotenuse the continuation of this one's), r along the ridge.
|
|
262
|
+
// Greedy-merge that grid on one colour, the ridge first (the long runs), and
|
|
263
|
+
// a block's slope is one quad from its first staircase cell's far corners to
|
|
264
|
+
// its last's, swept over its r-range. One material each by the gate, so
|
|
265
|
+
// every slope samples its colour's swatch.
|
|
266
|
+
const planes = new Map(); // plane key -> Map<'t,r', {t, r, w}>
|
|
267
|
+
for (const w of wedges) {
|
|
268
|
+
const p = { x: w.x, y: w.y, z: w.z };
|
|
269
|
+
const aC = p[w.A];
|
|
270
|
+
const bC = p[w.B];
|
|
271
|
+
const key = [w.R, w.A, w.B, w.sA, w.sB, w.sA * aC + w.sB * bC].join('|');
|
|
272
|
+
let grid = planes.get(key);
|
|
273
|
+
if (!grid) planes.set(key, (grid = new Map()));
|
|
274
|
+
const t = w.sA * aC;
|
|
275
|
+
const r = p[w.R];
|
|
276
|
+
grid.set(t + ',' + r, { t, r, w });
|
|
277
|
+
}
|
|
278
|
+
let slopes = 0;
|
|
279
|
+
for (const grid of planes.values()) {
|
|
280
|
+
const used = new Set();
|
|
281
|
+
const cells = [...grid.values()].sort((p, q) => p.t - q.t || p.r - q.r);
|
|
282
|
+
for (const c0 of cells) {
|
|
283
|
+
const k0 = c0.t + ',' + c0.r;
|
|
284
|
+
if (used.has(k0)) continue;
|
|
285
|
+
const w0 = c0.w;
|
|
286
|
+
const free = (t, r) => {
|
|
287
|
+
const k = t + ',' + r;
|
|
288
|
+
const c = grid.get(k);
|
|
289
|
+
return !!c && !used.has(k) && c.w.color === w0.color;
|
|
290
|
+
};
|
|
291
|
+
let rl = 1;
|
|
292
|
+
while (free(c0.t, c0.r + rl)) rl++;
|
|
293
|
+
let tl = 1;
|
|
294
|
+
grow: for (; ; tl++) {
|
|
295
|
+
for (let j = 0; j < rl; j++) if (!free(c0.t + tl, c0.r + j)) break grow;
|
|
296
|
+
}
|
|
297
|
+
for (let i = 0; i < tl; i++)
|
|
298
|
+
for (let j = 0; j < rl; j++) used.add(c0.t + i + ',' + (c0.r + j));
|
|
299
|
+
const first = cornersOf(w0);
|
|
300
|
+
const last = cornersOf(grid.get(c0.t + tl - 1 + ',' + c0.r).w);
|
|
301
|
+
const rLo = c0.r;
|
|
302
|
+
const rHi = c0.r + rl;
|
|
303
|
+
const pt = (av, bv, rv) => mk(w0.A, av, w0.B, bv, w0.R, rv);
|
|
304
|
+
pushQuad(
|
|
305
|
+
pt(first.Ao, first.Bc, rLo),
|
|
306
|
+
pt(first.Ao, first.Bc, rHi),
|
|
307
|
+
pt(last.Ac, last.Bo, rHi),
|
|
308
|
+
pt(last.Ac, last.Bo, rLo),
|
|
309
|
+
axisVec(w0.A, -w0.sA, w0.B, -w0.sB),
|
|
310
|
+
{ swatch: flat ? FLAT_COLOR : w0.color >>> 0 }
|
|
311
|
+
);
|
|
312
|
+
slopes++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 2. the gable caps, as HALF pieces of the planes they lie on: a cell's end
|
|
317
|
+
// is capped unless the prism runs on into a wedge of the same orientation
|
|
318
|
+
// there or ends against solid. The cap is the right triangle in the cell's
|
|
319
|
+
// ±R face whose right angle sits at the filled corner (Ac, Bc), filed under
|
|
320
|
+
// that face's plane in the face's own tangent frame.
|
|
321
|
+
/** @type {Map<string, import('./regions.js').Half[]>} */
|
|
322
|
+
const halves = new Map();
|
|
323
|
+
for (const w of wedges) {
|
|
324
|
+
const p = { x: w.x, y: w.y, z: w.z };
|
|
325
|
+
for (const sg of [-1, 1]) {
|
|
326
|
+
const [ex, ey, ez] = step(w.x, w.y, w.z, w.R, sg);
|
|
327
|
+
if (solidAt(ex, ey, ez)) continue; // internal against solid
|
|
328
|
+
if (inBounds(ex, ey, ez)) {
|
|
329
|
+
const wn = wedgeCell.get(voxIndex(ex, ey, ez, dims));
|
|
330
|
+
if (wn && wn.R === w.R && wn.sA === w.sA && wn.sB === w.sB) continue; // the prism runs on
|
|
331
|
+
}
|
|
332
|
+
const face = faceKeyOf(w.R, sg);
|
|
333
|
+
const g = FACE_GEO[face];
|
|
334
|
+
const key = planeKey(face, p[w.R]);
|
|
335
|
+
if (!halves.has(key)) halves.set(key, []);
|
|
336
|
+
halves.get(key).push({
|
|
337
|
+
a: p[g.A],
|
|
338
|
+
b: p[g.B],
|
|
339
|
+
hiA: g.A === w.A ? w.sA > 0 : w.sB > 0,
|
|
340
|
+
hiB: g.B === w.A ? w.sA > 0 : w.sB > 0,
|
|
341
|
+
color: flat ? FLAT_COLOR : w.color >>> 0,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 3. the regions: every plane's exposed faces (minus the ones the wedges
|
|
347
|
+
// cover) with its caps, traced (regions.js), the skin baked over them once,
|
|
348
|
+
// then each triangulated by earcut and emitted with its paint. Flat mode
|
|
349
|
+
// has no skin: every primitive is the flat grey, the UVs zero.
|
|
350
|
+
const baseMask = surfaceMask.slice();
|
|
351
|
+
for (const rk of removed) baseMask[(rk / 6) | 0] &= ~(1 << rk % 6);
|
|
352
|
+
const regions = faceRegions(dims, baseMask, faceColor, halves);
|
|
353
|
+
const skin = flat ? null : bakeSkin(regions, result.palette ?? [], faceColor);
|
|
354
|
+
regions.forEach((region, i) => {
|
|
355
|
+
const chart = skin ? skin.charts[i] : null;
|
|
356
|
+
const paint = chart
|
|
357
|
+
? { chart, region }
|
|
358
|
+
: { swatch: flat ? FLAT_COLOR : /** @type {number} */ (region.uniform) >>> 0 };
|
|
359
|
+
const toV2 = (loop) => loop.map(([a, b]) => new THREE.Vector2(a, b));
|
|
360
|
+
const faces = THREE.ShapeUtils.triangulateShape(
|
|
361
|
+
toV2(region.outer),
|
|
362
|
+
region.holes.map(toV2)
|
|
363
|
+
);
|
|
364
|
+
const verts = [region.outer, ...region.holes].flat();
|
|
365
|
+
let area = 0;
|
|
366
|
+
for (const [i0, i1, i2] of faces) {
|
|
367
|
+
const [p, q, r] = [verts[i0], verts[i1], verts[i2]];
|
|
368
|
+
area += Math.abs((q[0] - p[0]) * (r[1] - p[1]) - (r[0] - p[0]) * (q[1] - p[1]));
|
|
369
|
+
pushTri(
|
|
370
|
+
pointOf(region.face, p[0], p[1], region.s),
|
|
371
|
+
pointOf(region.face, q[0], q[1], region.s),
|
|
372
|
+
pointOf(region.face, r[0], r[1], region.s),
|
|
373
|
+
region.normal,
|
|
374
|
+
paint
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (area !== region.area2)
|
|
378
|
+
throw new Error(
|
|
379
|
+
`wedge-mesh: the ${region.face} region at slice ${region.s} triangulated to ${area / 2} of its ${region.area2 / 2} cells`
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// 4. stitch out T-junctions, then flatten to scaled vertex buffers. The UVs
|
|
384
|
+
// are read HERE, after the repair, from each vertex's lattice position: a
|
|
385
|
+
// charted triangle's vertex is an affine read into its chart (a vertex the
|
|
386
|
+
// repair inserted along an edge included), a swatch triangle's three sit at
|
|
387
|
+
// its texel's centre. Texel coords over the skin's size make the [0,1] UV.
|
|
388
|
+
const repaired = eliminateTJunctions(tris);
|
|
389
|
+
const pos = new Float32Array(repaired.length * 9);
|
|
390
|
+
const nrm = new Float32Array(repaired.length * 9);
|
|
391
|
+
const uv = new Float32Array(repaired.length * 6); // present, and zero, in flat mode
|
|
392
|
+
let o = 0;
|
|
393
|
+
let q = 0;
|
|
394
|
+
for (const t of repaired) {
|
|
395
|
+
const sw = skin && !t.region ? swatchUV(skin, t.swatch) : null;
|
|
396
|
+
for (const v of [t.a, t.b, t.c]) {
|
|
397
|
+
pos[o] = v[0] * s;
|
|
398
|
+
pos[o + 1] = v[1] * s;
|
|
399
|
+
pos[o + 2] = v[2] * s;
|
|
400
|
+
nrm[o] = t.normal[0];
|
|
401
|
+
nrm[o + 1] = t.normal[1];
|
|
402
|
+
nrm[o + 2] = t.normal[2];
|
|
403
|
+
if (skin) {
|
|
404
|
+
const [tu, tv] = sw || uvOfLattice(t.chart, t.region, v);
|
|
405
|
+
uv[q] = tu / skin.width;
|
|
406
|
+
uv[q + 1] = tv / skin.height;
|
|
407
|
+
}
|
|
408
|
+
o += 3;
|
|
409
|
+
q += 2;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- assemble -------------------------------------------------------------
|
|
414
|
+
let geo = new THREE.BufferGeometry();
|
|
415
|
+
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
416
|
+
geo.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
|
|
417
|
+
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
|
418
|
+
// Weld coincident lattice vertices by position+normal+uv so distinct-facing
|
|
419
|
+
// slope/region vertices stay split — and so do two charts' vertices at one
|
|
420
|
+
// lattice point (they sample different texels; watertightness is judged on
|
|
421
|
+
// positions, so the split costs it nothing). Normals are load-bearing HERE
|
|
422
|
+
// and in diag.js — not for lighting (flatShading recomputes them per-face
|
|
423
|
+
// in the shader).
|
|
424
|
+
geo = mergeVertices(geo, 1e-4);
|
|
425
|
+
|
|
426
|
+
// finishVoxelMesh centres X/Z and leaves Y as authored (wedge-mesh.test pins it).
|
|
427
|
+
const charted = skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0;
|
|
428
|
+
const paint = skin ? { map: skinTexture(skin) } : { color: FLAT_COLOR };
|
|
429
|
+
return finishVoxelMesh(geo, {
|
|
430
|
+
nx,
|
|
431
|
+
nz,
|
|
432
|
+
s,
|
|
433
|
+
...paint,
|
|
434
|
+
userData: {
|
|
435
|
+
triangles: geo.index ? geo.index.count / 3 : repaired.length,
|
|
436
|
+
wedges: wedges.length, // cells
|
|
437
|
+
slopes,
|
|
438
|
+
skin: skin ? { width: skin.width, height: skin.height, charts: charted } : null,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|