sprite-machine 0.1.0 → 0.2.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/atlas.js CHANGED
@@ -1,31 +1,30 @@
1
- // ---------------------------------------------------------------------------
2
- // Atlas slicing: cut a packed sprite sheet into the six named face tiles.
3
- //
4
- // Pure (operates on {width,height,data}, returns the same shape) so it's
5
- // Node-testable and its output drops straight into buildVoxels(). The tile size
6
- // is derived from the image dimensions and the layout grid unless given
7
- // explicitly: a 3x2 layout on a 120x80 sheet => 40x40 tiles.
8
- // ---------------------------------------------------------------------------
1
+ // Atlas slicing and resizing on ImageData-like {width, height, data} sheets.
2
+ // Unless given, the tile size derives from the sheet and the layout: a 3x2 layout
3
+ // on a 120x80 sheet gives 40x40 tiles. A layered sheet stacks blocks of the
4
+ // layout one under another, every block at the same tile size.
9
5
 
10
6
  import { VIEW_NAMES, VIEW_IMAGE_AXES } from './views.js';
11
7
 
12
- // Grid of view names (row-major). null = an intentionally empty cell.
8
+ // View names by row. null marks an empty cell.
13
9
  export const DEFAULT_ATLAS_LAYOUT = [
14
10
  ['left', 'front', 'top'],
15
11
  ['right', 'back', 'bottom'],
16
12
  ];
17
13
 
18
- // Allowed tile-dimension range for the in-app resize control (integers). A tile
19
- // maps 1:1 onto a lattice axis, so this is also the voxel grid's per-axis range —
20
- // and the carve/colorize pass is a synchronous O(n³) walk on the main thread. The
21
- // ceiling is 64 (a 64³ = 262 k-voxel grid still rebuilds live per stroke); larger
22
- // tiles (a 256³ = 16.7 M-voxel carve) froze the tab for seconds. clampTile pins
23
- // both the stepper and the ?tile dev hook into this range.
14
+ // Tile side range, in px. A tile side is also a voxel grid axis, and the carve is
15
+ // a synchronous O(n³) pass. At 64 a rebuild per stroke stays fast.
24
16
  export const TILE_MIN = 1;
25
17
  export const TILE_MAX = 64;
26
18
  export const clampTile = (n) =>
27
19
  Math.max(TILE_MIN, Math.min(TILE_MAX, Math.round(Number(n) || 0)));
28
20
 
21
+ // The most layers a document stacks. Every layer is carved on its own, so a
22
+ // full rebuild is up to this many carves.
23
+ export const LAYER_MAX = 8;
24
+
25
+ // A `layers` option as a whole count of at least 1.
26
+ const layerOption = (n) => Math.max(1, Math.floor(Number(n) || 1));
27
+
29
28
  export function layoutSize(layout) {
30
29
  const rows = layout.length;
31
30
  const cols = Math.max(...layout.map((r) => r.length));
@@ -57,20 +56,15 @@ function subTile(img, sx, sy, w, h) {
57
56
  return { width: w, height: h, data: out };
58
57
  }
59
58
 
60
- // Is a tile fully transparent? (alpha 0 everywhere — stray RGB under alpha 0 is
61
- // ignored, matching the editor's hard-pixel rule). Exported as the single "is this
62
- // empty?" predicate so main.js's applyTileEdit doesn't roll its own copy.
59
+ // True when every texel has alpha 0.
63
60
  export const isBlank = (tile) => {
64
61
  for (let i = 3; i < tile.data.length; i += 4) if (tile.data[i] !== 0) return false;
65
62
  return true;
66
63
  };
67
64
 
68
65
  /**
69
- * The tight bounding box of a tile's non-transparent texels the same
70
- * alpha!==0 rule as isBlank, so the two can never disagree about emptiness:
71
- * contentBounds(t) === null exactly when isBlank(t). The icon generator trims
72
- * to this box so a sprite fills its icon instead of shipping the tile's
73
- * transparent margin.
66
+ * The tight bounding box of a tile's texels with alpha !== 0. Null exactly when
67
+ * isBlank(tile).
74
68
  * @param {{width:number,height:number,data:ArrayLike<number>}} tile
75
69
  * @returns {{x:number,y:number,width:number,height:number}|null}
76
70
  */
@@ -94,10 +88,8 @@ export function contentBounds(tile) {
94
88
  }
95
89
 
96
90
  /**
97
- * Validate an ImageData-like sheet at an ingestion boundary: finite positive
98
- * dimensions and a data buffer long enough for width*height RGBA texels. Returns
99
- * an error string (surfaced to the user), or null when the sheet is usable — so
100
- * sliceAtlas/resizeAtlas downstream can trust their input's shape.
91
+ * Validate an ImageData-like sheet: finite positive dimensions and at least
92
+ * width*height*4 bytes of data. Returns a user-facing error, or null when usable.
101
93
  * @param {{width:number,height:number,data:ArrayLike<number>}|null|undefined} img
102
94
  * @returns {string|null}
103
95
  */
@@ -118,43 +110,30 @@ export function validateSheet(img) {
118
110
  return null;
119
111
  }
120
112
 
121
- /**
122
- * @param {{width:number,height:number,data:ArrayLike<number>}} img
123
- * @param {{layout?:string[][], tileW?:number, tileH?:number}} [opts]
124
- * @returns {{views:Record<string,{width,height,data}|null>,
125
- * tileW:number, tileH:number, cols:number, rows:number,
126
- * warnings:string[]}}
127
- */
128
- export function sliceAtlas(img, opts = {}) {
129
- const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
130
- const {
131
- cols,
132
- rows,
133
- tileW: autoW,
134
- tileH: autoH,
135
- } = deriveTileSize(img.width, img.height, layout);
136
- const warnings = [];
137
-
138
- // Fill each dimension independently so a lone tileW/tileH override survives.
139
- const tileW = Math.round(opts.tileW || autoW);
140
- const tileH = Math.round(opts.tileH || autoH);
113
+ /** @typedef {Record<string, {width:number,height:number,data:Uint8ClampedArray}|null>} Views */
141
114
 
142
- /** @type {Record<string, {width:number,height:number,data:ArrayLike<number>}|null>} */
143
- const views = {};
115
+ // Slice `count` stacked blocks of the layout at tileW × tileH. Block k's cell
116
+ // (r, c) starts at column c·tileW and row (rows·k + r)·tileH. A cell past the
117
+ // sheet's edge is left out, and a blank tile slices to null.
118
+ function sliceBlocks(img, layout, count, tileW, tileH) {
119
+ const { cols, rows } = layoutSize(layout);
120
+ const gridRows = rows * count;
121
+ const warnings = [];
122
+ /** @type {Views[]} */
123
+ const blocks = Array.from({ length: count }, () => ({}));
144
124
 
145
- // Bail on a fundamentally unusable sheet rather than emitting garbage tiles.
146
125
  if (!(img.width > 0 && img.height > 0) || tileW < 1 || tileH < 1) {
147
126
  warnings.push(
148
127
  `Atlas is unusable: a ${img.width}×${img.height}px sheet split into ` +
149
- `${cols}×${rows} gives ${tileW}×${tileH}px tiles. Check the image and tile size.`
128
+ `${cols}×${gridRows} gives ${tileW}×${tileH}px tiles. Check the image and tile size.`
150
129
  );
151
- return { views, tileW, tileH, cols, rows, warnings };
130
+ return { blocks, cols, rows, warnings };
152
131
  }
153
132
 
154
- if (cols * tileW !== img.width || rows * tileH !== img.height) {
133
+ if (cols * tileW !== img.width || gridRows * tileH !== img.height) {
155
134
  warnings.push(
156
- `Layout ${cols}x${rows} at ${tileW}x${tileH} tiles = ` +
157
- `${cols * tileW}x${rows * tileH}px, but image is ${img.width}x${img.height}px. ` +
135
+ `Layout ${cols}x${gridRows} at ${tileW}x${tileH} tiles = ` +
136
+ `${cols * tileW}x${gridRows * tileH}px, but image is ${img.width}x${img.height}px. ` +
158
137
  `Tiles are read from the top-left; check tile size / layout.`
159
138
  );
160
139
  }
@@ -162,27 +141,65 @@ export function sliceAtlas(img, opts = {}) {
162
141
  for (let r = 0; r < rows; r++) {
163
142
  for (let c = 0; c < (layout[r] || []).length; c++) {
164
143
  const name = layout[r][c];
165
- if (!name) continue;
166
- if (!VIEW_NAMES.includes(name)) {
144
+ if (name && !VIEW_NAMES.includes(name)) {
167
145
  warnings.push(`Unknown view "${name}" in layout; ignored.`);
168
- continue;
169
146
  }
170
- const sx = c * tileW;
171
- const sy = r * tileH;
172
- if (sx + tileW > img.width || sy + tileH > img.height) continue;
173
- const tile = subTile(img, sx, sy, tileW, tileH);
174
- views[name] = isBlank(tile) ? null : tile;
175
147
  }
176
148
  }
177
- return { views, tileW, tileH, cols, rows, warnings };
149
+
150
+ blocks.forEach((views, k) => {
151
+ for (let r = 0; r < rows; r++) {
152
+ for (let c = 0; c < (layout[r] || []).length; c++) {
153
+ const name = layout[r][c];
154
+ if (!name || !VIEW_NAMES.includes(name)) continue;
155
+ const sx = c * tileW;
156
+ const sy = (rows * k + r) * tileH;
157
+ if (sx + tileW > img.width || sy + tileH > img.height) continue;
158
+ const tile = subTile(img, sx, sy, tileW, tileH);
159
+ views[name] = isBlank(tile) ? null : tile;
160
+ }
161
+ }
162
+ });
163
+ return { blocks, cols, rows, warnings };
164
+ }
165
+
166
+ /**
167
+ * @param {{width:number,height:number,data:ArrayLike<number>}} img
168
+ * @param {{layout?:string[][], tileW?:number, tileH?:number}} [opts]
169
+ * @returns {{views:Views, tileW:number, tileH:number, cols:number, rows:number,
170
+ * warnings:string[]}}
171
+ */
172
+ export function sliceAtlas(img, opts = {}) {
173
+ const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
174
+ const auto = deriveTileSize(img.width, img.height, layout);
175
+ const tileW = Math.round(opts.tileW || auto.tileW);
176
+ const tileH = Math.round(opts.tileH || auto.tileH);
177
+ const { blocks, cols, rows, warnings } = sliceBlocks(img, layout, 1, tileW, tileH);
178
+ return { views: blocks[0], tileW, tileH, cols, rows, warnings };
179
+ }
180
+
181
+ /**
182
+ * Slice a layered sheet into one views record per block. The tile is the width
183
+ * over the layout's columns and the height over its rows times `layers`. With
184
+ * one layer this is sliceAtlas.
185
+ * @param {{width:number,height:number,data:ArrayLike<number>}} img
186
+ * @param {{layers?:number, layout?:string[][]}} [opts]
187
+ * @returns {{layers:Views[], tileW:number, tileH:number, cols:number, rows:number,
188
+ * warnings:string[]}} rows is the layout's, not the sheet's
189
+ */
190
+ export function sliceLayers(img, opts = {}) {
191
+ const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
192
+ const count = layerOption(opts.layers);
193
+ const { cols, rows } = layoutSize(layout);
194
+ const tileW = Math.round(img.width / cols);
195
+ const tileH = Math.round(img.height / (rows * count));
196
+ const { blocks, warnings } = sliceBlocks(img, layout, count, tileW, tileH);
197
+ return { layers: blocks, tileW, tileH, cols, rows, warnings };
178
198
  }
179
199
 
180
200
  /**
181
- * Inverse of subTile: copy a tile's pixels into a sheet at (sx, sy), in place.
182
- * Mutates `sheet.data` (does NOT change the ImageData identity, so a canonical
183
- * `state.atlasImage` reference stays valid). Writes only within the tile's rect
184
- * and clips to the sheet bounds, so remainder pixels of a non-divisible sheet
185
- * are left untouched.
201
+ * Copy a tile's pixels into sheet.data at (sx, sy), clipped to the sheet. Mutates
202
+ * sheet.data in place and returns the same sheet object.
186
203
  * @param {{width:number,height:number,data:Uint8ClampedArray|number[]}} sheet
187
204
  * @param {{width:number,height:number,data:ArrayLike<number>}} tile
188
205
  * @param {number} sx @param {number} sy
@@ -209,11 +226,9 @@ export function blitTile(sheet, tile, sx, sy) {
209
226
  }
210
227
 
211
228
  /**
212
- * Core tile-pixel placement: copy `tile` into a fresh (newW×newH) buffer with its
213
- * top-left corner at (offX, offY), padding the uncovered cells transparent and
214
- * clipping anything outside (so a NEGATIVE offset crops that edge). Pure — returns a
215
- * fresh tile. The general primitive under both corner-anchored `resizeTile` and the
216
- * centered whole-atlas resize.
229
+ * Copy a tile into a new newW × newH tile with its top-left at (offX, offY).
230
+ * Uncovered texels are transparent and texels outside are clipped, so a negative
231
+ * offset crops that edge.
217
232
  * @param {{width:number,height:number,data:ArrayLike<number>}} tile
218
233
  * @param {number} newW @param {number} newH
219
234
  * @param {number} offX @param {number} offY
@@ -224,7 +239,7 @@ export function resizeTileTo(tile, newW, newH, offX, offY) {
224
239
  const out = new Uint8ClampedArray(newW * newH * 4);
225
240
  for (let sy = 0; sy < h; sy++) {
226
241
  const dy = sy + offY;
227
- if (dy < 0 || dy >= newH) continue; // clipped when shrinking / negative offset
242
+ if (dy < 0 || dy >= newH) continue;
228
243
  for (let sx = 0; sx < w; sx++) {
229
244
  const dx = sx + offX;
230
245
  if (dx < 0 || dx >= newW) continue;
@@ -240,33 +255,24 @@ export function resizeTileTo(tile, newW, newH, offX, offY) {
240
255
  }
241
256
 
242
257
  /**
243
- * Resize ONE tile's pixels to (newW,newH), anchoring the existing art at a chosen
244
- * corner and padding the opposite edges with transparency (or cropping them when
245
- * shrinking). Pure — returns a fresh tile.
246
- *
247
- * The anchor is what keeps a resize alignment-safe: a tile is a literal lattice
248
- * slice, so to hold a texel's world position we must add/remove lattice lines at
249
- * the FAR end of each axis and leave the anchored end fixed. `anchorRight`/
250
- * `anchorBottom` pick which image edge stays put (the rest pad/crop). A thin wrapper
251
- * over resizeTileTo — a corner is just the offset that puts all pad/crop on one end.
258
+ * Resize a tile to newW × newH with the art held at one corner, picked by
259
+ * anchorRight and anchorBottom. The opposite edges pad transparent or crop.
252
260
  * @param {{width:number,height:number,data:ArrayLike<number>}} tile
253
261
  * @param {number} newW @param {number} newH
254
262
  * @param {boolean} anchorRight @param {boolean} anchorBottom
255
263
  * @returns {{width:number,height:number,data:Uint8ClampedArray}}
256
264
  */
257
265
  export function resizeTile(tile, newW, newH, anchorRight, anchorBottom) {
258
- const offX = anchorRight ? newW - tile.width : 0; // all pad/crop lands on the far end
266
+ const offX = anchorRight ? newW - tile.width : 0;
259
267
  const offY = anchorBottom ? newH - tile.height : 0;
260
268
  return resizeTileTo(tile, newW, newH, offX, offY);
261
269
  }
262
270
 
263
271
  /**
264
- * How many lattice lines to add (+) or remove (−) at the world-LOW (origin) end of
265
- * an axis to keep the art CENTERED as a tile resizes; the rest of the change lands
266
- * at the far end. The odd leftover of an odd-sized change is biased by the parity of
267
- * the NEW size, so consecutive ±1 steps alternate which end moves and the art can't
268
- * drift into a corner over repeated clicks (an even change always splits evenly, and
269
- * a typed jump divides the difference as evenly as it can).
272
+ * Lattice lines to add (+) or remove (−) at the low (origin) end of an axis to keep
273
+ * the art centered as a tile resizes. The rest of the change goes to the far end.
274
+ * The extra line of an odd change follows the parity of the new size, so repeated
275
+ * ±1 steps alternate ends and the art does not drift.
270
276
  * splitLow(4,5)=1 splitLow(5,6)=0 grow: alternate the extra line
271
277
  * splitLow(4,6)=1 even grow: one line each end
272
278
  * splitLow(5,4)=0 splitLow(4,3)=−1 shrink: alternate the cropped line
@@ -281,78 +287,66 @@ export function splitLow(oldSize, newSize) {
281
287
  }
282
288
 
283
289
  /**
284
- * Resize the whole 3x2 sheet to new per-tile dimensions. Each cell's tile is placed
285
- * with an offset derived from its view's image-axis flips (VIEW_IMAGE_AXES) so every
286
- * face sharing a world axis shifts IDENTICALLY (registration held) — the padding just
287
- * lands at a different image edge per face.
290
+ * Resize a sheet to new tile dimensions. Each tile's offset follows its view's
291
+ * image-axis flips (VIEW_IMAGE_AXES), so faces that share a world axis shift
292
+ * together.
293
+ *
294
+ * opts.anchor sets where the change goes on each axis:
295
+ * 'origin' (default): the origin line stays fixed and the far edge moves, so y=0
296
+ * stays put.
297
+ * 'center': the change splits around the art (splitLow). The model translates,
298
+ * so a sprite resting on y=0 lifts off it as the tile grows.
288
299
  *
289
- * `opts.anchor` picks how the size change is distributed on each axis:
290
- * 'origin' (default) keep the origin line fixed, grow/shrink only at the far edge.
291
- * A square resize is fully registration-safe AND keeps the object ground-rested
292
- * (y=0 pinned) at its exact lattice coords. Used by the pipeline; the primitive's
293
- * stable default (also what the byte-identical-pin test locks).
294
- * 'center' — split the change around the art on ALL axes (see splitLow) so it stays
295
- * centered as the tile grows/shrinks. Still registration-safe for a square resize
296
- * (the whole solid just TRANSLATES by the per-axis pad), but it no longer pins y=0,
297
- * so a ground-rested sprite floats up as the tile grows. This is what the editor's
298
- * tile stepper uses (the author asked for centered artwork).
300
+ * A square resize keeps registration with either anchor. When newTileW !== newTileH
301
+ * the depth axis nz gets two sizes (the side tile's width and the top tile's
302
+ * height), so the carve drops voxels and warns.
299
303
  *
300
- * A PROPORTIONAL (square, newTileW===newTileH) resize keeps registration for either
301
- * anchor. An ASYMMETRIC resize (newTileW!==newTileH) intentionally falls OUT of
302
- * registration — a uniform 3x2 atlas has only two tile dimensions but three lattice
303
- * axes, and the depth axis nz is the side tile's WIDTH and the top tile's HEIGHT at
304
- * once, so W!=H gives reconcileDims two disagreeing nz candidates: the carve shears
305
- * the shared depth axis (dropping voxels) and warns. That trade-off is accepted — the
306
- * editor lets W and H move independently. Pure — returns a fresh sheet.
304
+ * opts.layers reads the sheet as that many stacked blocks and resizes each one
305
+ * the same way. Without it the sheet is one block.
307
306
  * @param {{width:number,height:number,data:ArrayLike<number>}} img
308
307
  * @param {number} newTileW @param {number} newTileH
309
- * @param {{layout?:string[][], anchor?:'origin'|'center'}} [opts]
308
+ * @param {{layout?:string[][], anchor?:'origin'|'center', layers?:number}} [opts]
310
309
  * @returns {{width:number,height:number,data:Uint8ClampedArray}}
311
310
  */
312
311
  export function resizeAtlas(img, newTileW, newTileH, opts = {}) {
313
312
  const layout = opts.layout || DEFAULT_ATLAS_LAYOUT;
314
313
  const center = opts.anchor === 'center';
315
- const {
316
- cols,
317
- rows,
318
- tileW: ow,
319
- tileH: oh,
320
- } = deriveTileSize(img.width, img.height, layout);
321
- const oldW = Math.round(ow);
322
- const oldH = Math.round(oh);
314
+ const count = layerOption(opts.layers);
315
+ const { cols, rows } = layoutSize(layout);
316
+ const oldW = Math.round(img.width / cols);
317
+ const oldH = Math.round(img.height / (rows * count));
323
318
  const dW = newTileW - oldW;
324
319
  const dH = newTileH - oldH;
325
- // Per world axis: how many lattice lines to add/crop at the LOW (origin) end.
326
- // 'origin' leaves it 0 (all change at the far end); 'center' splits around the art.
320
+ // Lattice lines to add or crop at each world axis's low end.
327
321
  const padLowCol = center ? splitLow(oldW, newTileW) : 0;
328
322
  const padLowRow = center ? splitLow(oldH, newTileH) : 0;
329
323
  const W = cols * newTileW;
330
- const H = rows * newTileH;
324
+ const H = rows * count * newTileH;
331
325
  const sheet = { width: W, height: H, data: new Uint8ClampedArray(W * H * 4) };
332
- for (let r = 0; r < rows; r++) {
333
- for (let c = 0; c < (layout[r] || []).length; c++) {
334
- const name = layout[r][c];
335
- if (!name || !VIEW_NAMES.includes(name)) continue;
336
- const sx = c * oldW;
337
- const sy = r * oldH;
338
- if (sx + oldW > img.width || sy + oldH > img.height) continue; // guard a ragged sheet
339
- const src = subTile(img, sx, sy, oldW, oldH);
340
- const { colFlip, rowFlip } = VIEW_IMAGE_AXES[name];
341
- // Map the world-low pad to this face's image corner: a flipped image axis has its
342
- // low pixel at the world-HIGH end, so it takes the complementary (far) pad. This
343
- // keeps every face on a shared axis moving together. With 'origin' (padLow=0)
344
- // this is exactly the old resizeTile(colFlip, rowFlip) corner anchor.
345
- const offX = colFlip ? dW - padLowCol : padLowCol;
346
- const offY = rowFlip ? dH - padLowRow : padLowRow;
347
- const resized = resizeTileTo(src, newTileW, newTileH, offX, offY);
348
- blitTile(sheet, resized, c * newTileW, r * newTileH);
326
+ for (let k = 0; k < count; k++) {
327
+ for (let r = 0; r < rows; r++) {
328
+ for (let c = 0; c < (layout[r] || []).length; c++) {
329
+ const name = layout[r][c];
330
+ if (!name || !VIEW_NAMES.includes(name)) continue;
331
+ const sx = c * oldW;
332
+ const sy = (rows * k + r) * oldH;
333
+ if (sx + oldW > img.width || sy + oldH > img.height) continue; // ragged sheet
334
+ const src = subTile(img, sx, sy, oldW, oldH);
335
+ const { colFlip, rowFlip } = VIEW_IMAGE_AXES[name];
336
+ // A flipped image axis has its low pixel at the world-high end, so it
337
+ // takes the far pad.
338
+ const offX = colFlip ? dW - padLowCol : padLowCol;
339
+ const offY = rowFlip ? dH - padLowRow : padLowRow;
340
+ const resized = resizeTileTo(src, newTileW, newTileH, offX, offY);
341
+ blitTile(sheet, resized, c * newTileW, (rows * k + r) * newTileH);
342
+ }
349
343
  }
350
344
  }
351
345
  return sheet;
352
346
  }
353
347
 
354
348
  /**
355
- * Locate a view's grid cell in the layout without duplicating the layout scan.
349
+ * A view's cell in the layout, or null.
356
350
  * @param {string} name
357
351
  * @param {string[][]} [layout]
358
352
  * @returns {{r:number, c:number} | null}
package/src/carve.js CHANGED
@@ -1,24 +1,13 @@
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
- // ---------------------------------------------------------------------------
1
+ // Carve: reconcile the grid dimensions from the views, then compute the visual
2
+ // hull, the intersection of every view's extruded silhouette. Each view projects
3
+ // onto one plane: front/back X-Y, left/right Z-Y, top/bottom X-Z.
14
4
 
15
5
  import { placeView } from './ingest.js';
16
6
  import { VIEWS, VIEW_AXES, FACE_KEYS, FACE_NORMAL } from './views.js';
17
7
 
18
8
  export const voxIndex = (x, y, z, d) => x + d.nx * (y + d.ny * z);
19
9
 
20
- /** Inverse of voxIndex: linear grid index -> {x,y,z}. Kept next to voxIndex so
21
- * the forward and inverse packing can't drift. */
10
+ /** Inverse of voxIndex: a linear grid index to {x, y, z}. */
22
11
  export const unvoxIndex = (idx, d) => {
23
12
  const z = (idx / (d.nx * d.ny)) | 0;
24
13
  const rem = idx - z * d.nx * d.ny;
@@ -27,11 +16,8 @@ export const unvoxIndex = (idx, d) => {
27
16
  };
28
17
 
29
18
  /**
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.
19
+ * One integer resolution per axis from the view sizes. When views disagree on an
20
+ * axis, the largest wins and a warning is added.
35
21
  * @param {Record<string, {w:number,h:number}>} views provided views by name
36
22
  * @returns {{dims:{nx:number,ny:number,nz:number}, warnings:string[]}}
37
23
  */
@@ -70,14 +56,8 @@ export function reconcileDims(views) {
70
56
  }
71
57
 
72
58
  /**
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).
59
+ * Place each view into its imgW × imgH grid at native scale and offset 0. A texel
60
+ * is a fixed lattice line, so views are not re-centered or ground-rested.
81
61
  * @returns {Record<string,{occ:Uint8Array,rgb:Uint32Array,imgW:number,imgH:number}>}
82
62
  */
83
63
  export function gridViews(views, dims) {
@@ -104,32 +84,21 @@ export function carve(gviews, dims) {
104
84
  const { nx, ny, nz } = dims;
105
85
  const solid = new Uint8Array(nx * ny * nz).fill(1);
106
86
  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).
87
+ // No views: nothing carves and the grid stays full.
110
88
  if (active.length === 0) return solid;
111
89
 
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.
90
+ // Views are unioned within a plane, then intersected across planes. The union
91
+ // keeps a 1-texel slip between opposite hand-drawn sprites from eroding thin
92
+ // parts.
123
93
  const planes = new Map(); // planeKey -> [{spec, occ, imgW}, ...]
124
94
  for (const [name, gv] of active) {
125
- const key = VIEW_AXES[name].join(); // e.g. 'nx,ny' — one key per plane
95
+ const key = VIEW_AXES[name].join(); // e.g. 'nx,ny'
126
96
  const group = planes.get(key) || planes.set(key, []).get(key);
127
97
  group.push({ spec: VIEWS[name], occ: gv.occ, imgW: gv.imgW });
128
98
  }
129
99
  const planeList = [...planes.values()];
130
100
 
131
- // One reused scratch for the projection (projectInto mutates it) so the hot
132
- // triple loop below allocates nothing per voxel × view.
101
+ // Reused scratch for projectInto, so the hot loop allocates nothing.
133
102
  const p = { u: 0, v: 0 };
134
103
  for (let z = 0; z < nz; z++) {
135
104
  for (let y = 0; y < ny; y++) {
@@ -155,17 +124,15 @@ export function carve(gviews, dims) {
155
124
  return solid;
156
125
  }
157
126
 
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.
127
+ // Axis-neighbor offsets in FACE_KEYS order (the outward normals).
160
128
  const NEIGHBORS = FACE_KEYS.map((k) => FACE_NORMAL[k]);
161
129
 
162
130
  /**
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.
131
+ * Surface voxels: solid voxels with at least one empty or out-of-bounds neighbor.
132
+ * Also counts all solid voxels in the same pass.
166
133
  * @returns {{surfaceMask:Uint8Array, count:number, solidCount:number}}
167
134
  * surfaceMask[idx] holds a 6-bit exposure mask (bit i => FACE_KEYS[i] exposed);
168
- * count = surface voxels; solidCount = all solid voxels (surface + interior).
135
+ * count = surface voxels; solidCount = all solid voxels.
169
136
  */
170
137
  export function extractSurface(solid, dims) {
171
138
  const { nx, ny, nz } = dims;
package/src/colorize.js CHANGED
@@ -1,23 +1,12 @@
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
- // ---------------------------------------------------------------------------
1
+ // Colorize: a color for every exposed face of every surface voxel. For a face with
2
+ // outward normal n, the first of:
3
+ // 1. The facing view, when nothing solid lies beyond the face along n. The depth
4
+ // test keeps a recessed wall from taking a protruding pixel's color.
5
+ // 2. The opposite view, under the same depth test, when mirror-fill is on for
6
+ // the face's axis.
7
+ // 3. The average of already-colored neighbor faces.
8
+ // 4. The dominant body color.
9
+ // Sampled and averaged colors snap to the nearest palette color.
21
10
 
22
11
  import { unpackRGBA, packRGBA } from './ingest.js';
23
12
  import { voxIndex, unvoxIndex } from './carve.js';
@@ -31,7 +20,7 @@ import {
31
20
  } from './views.js';
32
21
  import { DEFAULT_MIRROR } from './constants.js';
33
22
 
34
- /** Build the deduped palette (union of all solid sprite pixels). */
23
+ /** The distinct colors of all solid view texels. */
35
24
  export function buildPalette(gviews) {
36
25
  const seen = new Set();
37
26
  const palette = [];
@@ -51,8 +40,6 @@ export function buildPalette(gviews) {
51
40
 
52
41
  export function makeSnapper(palette) {
53
42
  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
43
  const pal = palette.map((c) => ({ c: c >>> 0, ...unpackRGBA(c) }));
57
44
  return (color) => {
58
45
  const key = color >>> 0;
@@ -73,7 +60,7 @@ export function makeSnapper(palette) {
73
60
  };
74
61
  }
75
62
 
76
- /** Is `(x,y,z)`'s face `faceKey` the first solid hit from its facing view? */
63
+ /** True when no solid voxel lies beyond face faceKey of (x, y, z) along its normal. */
77
64
  function firstHitFromFace(solid, dims, x, y, z, faceKey) {
78
65
  const [nx, ny, nz] = FACE_NORMAL[faceKey];
79
66
  let cx = x + nx,
@@ -88,8 +75,7 @@ function firstHitFromFace(solid, dims, x, y, z, faceKey) {
88
75
  return true;
89
76
  }
90
77
 
91
- // One reused scratch sampleView reads the projection immediately, so mutating a
92
- // shared object avoids a per-exposed-face allocation.
78
+ // Reused projection scratch. sampleView reads it immediately.
93
79
  const _sampleP = { u: 0, v: 0 };
94
80
  function sampleView(gv, name, x, y, z, dims) {
95
81
  VIEWS[name].projectInto(x, y, z, dims, _sampleP);
@@ -123,8 +109,7 @@ export function colorize(solid, surfaceMask, gviews, dims, opts = {}) {
123
109
  const faceKey = FACE_KEYS[f];
124
110
  const key = idx * 6 + f;
125
111
 
126
- // firstHitFromFace is invariant for this face; memoize so the facing and
127
- // mirror branches march the depth ray at most once between them.
112
+ // Memoized so the depth ray is marched at most once per face.
128
113
  let firstHit;
129
114
  const isFirstHit = () =>
130
115
  (firstHit ??= firstHitFromFace(solid, dims, x, y, z, faceKey));
@@ -147,7 +132,7 @@ export function colorize(solid, surfaceMask, gviews, dims, opts = {}) {
147
132
  }
148
133
  }
149
134
 
150
- // 3. Relaxation: average already-colored neighbors (few passes).
135
+ // 3. Relaxation: average already-colored neighbor faces.
151
136
  const TANGENTIAL = {
152
137
  x: [
153
138
  [0, 1, 0],