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/README.md +113 -64
- package/bin/sprite-machine.mjs +2 -10
- package/package.json +11 -4
- package/src/atlas.js +136 -142
- package/src/carve.js +18 -51
- package/src/colorize.js +14 -29
- package/src/constants.js +7 -12
- package/src/diag.js +13 -20
- package/src/faces.js +9 -24
- package/src/gltf.js +22 -41
- package/src/index.js +8 -11
- package/src/ingest.js +18 -35
- package/src/layers.js +55 -0
- package/src/model.js +55 -59
- package/src/node.js +23 -17
- package/src/pipeline.js +124 -9
- package/src/png-chunks.js +26 -46
- package/src/png-encode.js +12 -22
- package/src/regions.js +31 -48
- package/src/skin.js +37 -66
- package/src/t-junction.js +21 -39
- package/src/three.js +60 -0
- package/src/views.js +47 -86
- package/src/wedge-mesh.js +120 -169
- package/src/weld.js +56 -0
- package/src/mesh-util.js +0 -65
package/src/atlas.js
CHANGED
|
@@ -1,31 +1,30 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
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
|
|
70
|
-
*
|
|
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
|
|
98
|
-
*
|
|
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
|
-
|
|
143
|
-
|
|
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}×${
|
|
128
|
+
`${cols}×${gridRows} gives ${tileW}×${tileH}px tiles. Check the image and tile size.`
|
|
150
129
|
);
|
|
151
|
-
return {
|
|
130
|
+
return { blocks, cols, rows, warnings };
|
|
152
131
|
}
|
|
153
132
|
|
|
154
|
-
if (cols * tileW !== img.width ||
|
|
133
|
+
if (cols * tileW !== img.width || gridRows * tileH !== img.height) {
|
|
155
134
|
warnings.push(
|
|
156
|
-
`Layout ${cols}x${
|
|
157
|
-
`${cols * tileW}x${
|
|
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)
|
|
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
|
-
|
|
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
|
-
*
|
|
182
|
-
*
|
|
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
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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;
|
|
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
|
|
244
|
-
*
|
|
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;
|
|
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
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
-
*
|
|
301
|
-
*
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
//
|
|
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
|
|
333
|
-
for (let
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
74
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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'
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
164
|
-
* Also
|
|
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
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
135
|
+
// 3. Relaxation: average already-colored neighbor faces.
|
|
151
136
|
const TANGENTIAL = {
|
|
152
137
|
x: [
|
|
153
138
|
[0, 1, 0],
|