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/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
+ }