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/node.js CHANGED
@@ -1,16 +1,11 @@
1
- // ---------------------------------------------------------------------------
2
- // The Node adapter (`sprite-machine/node`): a document PNG's bytes in, the
3
- // pixels and the metadata the app would read from it out and, in one
4
- // call, the glb. The engine itself takes pixels and never decodes a file
5
- // (index.js is environment-free); this is the one place a decoder lives,
6
- // `pngjs`, which reads every PNG a sprite editor exports — indexed, 16-bit,
7
- // interlaced — as 8-bit RGBA, the ImageData shape the pipeline consumes.
8
- // The chunks are read best-effort, as the app's loader reads them: a torn
9
- // chunk list costs the name and the transforms, never the pixels.
10
- // ---------------------------------------------------------------------------
1
+ // Node adapter (sprite-machine/node): decodes a document PNG to RGBA pixels
2
+ // and its metadata, or straight to a glb. The only PNG decoder in the engine
3
+ // (pngjs). Text chunks are read best-effort: a malformed chunk list loses the
4
+ // name, transforms and layer names but not the pixels.
11
5
 
12
6
  import { PNG } from 'pngjs';
13
7
  import { isPng, readTextChunks } from './png-chunks.js';
8
+ import { LAYERS_CHUNK, parseLayersChunk, layerCount } from './layers.js';
14
9
  import { buildModel, modelToGlb } from './model.js';
15
10
 
16
11
  const TRANSFORMS_CHUNK = 'sprite-machine:transforms';
@@ -22,10 +17,10 @@ const TRANSFORMS_CHUNK = 'sprite-machine:transforms';
22
17
  * image: {width:number, height:number, data:Uint8ClampedArray},
23
18
  * name: string|null,
24
19
  * transforms: Record<string, {rot?:number, flipX?:boolean, flipY?:boolean}>,
20
+ * layers: string[]|null,
25
21
  * chunks: Record<string, string>,
26
- * }} the pixels; the Title chunk's name; the transforms chunk, parsed; and
27
- * every text chunk verbatim (the ring settings ride here, unparsed a
28
- * viewing choice, the app's business)
22
+ * }} the pixels, the Title chunk, the parsed transforms chunk, the layer
23
+ * names from the sprite-machine:layers chunk, and every text chunk verbatim
29
24
  * @throws when the bytes are not a PNG, or pngjs cannot decode them
30
25
  */
31
26
  export function readSheet(bytes) {
@@ -54,19 +49,30 @@ export function readSheet(bytes) {
54
49
  transforms = {};
55
50
  }
56
51
  }
57
- return { image, name: chunks.Title ?? null, transforms, chunks };
52
+ return {
53
+ image,
54
+ name: chunks.Title ?? null,
55
+ transforms,
56
+ layers: parseLayersChunk(chunks[LAYERS_CHUNK]),
57
+ chunks,
58
+ };
58
59
  }
59
60
 
60
61
  /**
61
- * A document PNG's bytes its glb: `readSheet`, `buildModel`, `modelToGlb`.
62
+ * Convert a document PNG to a glb: readSheet, buildModel, then modelToGlb. The
63
+ * sheet builds the layers chunk's count when it divides the height into whole
64
+ * blocks, else one layer.
62
65
  * @param {Uint8Array} bytes
63
66
  * @param {{name?: string, voxelsPerMeter?: number, unlit?: boolean, generator?: string}} [opts]
64
- * `name` overrides the Title chunk's; with neither, the model is 'sprite'
67
+ * `name` overrides the Title chunk; with neither, the name is 'sprite'
65
68
  * @returns {Uint8Array} the .glb file
66
69
  */
67
70
  export function sheetToGlb(bytes, { name, voxelsPerMeter, unlit, generator } = {}) {
68
71
  const sheet = readSheet(bytes);
69
- const model = buildModel(sheet.image, { transforms: sheet.transforms });
72
+ const model = buildModel(sheet.image, {
73
+ transforms: sheet.transforms,
74
+ layers: layerCount(sheet.image.height, sheet.layers) ?? 1,
75
+ });
70
76
  return modelToGlb(model, {
71
77
  name: name ?? sheet.name ?? 'sprite',
72
78
  voxelsPerMeter,
package/src/pipeline.js CHANGED
@@ -1,13 +1,19 @@
1
- // ---------------------------------------------------------------------------
2
- // The pure sprite -> voxel pipeline. No THREE, no DOM: fully testable in Node.
3
- // Input: a map of view name -> ImageData-like { width, height, data(RGBA) }.
4
- // Output: the voxel grid, surface, and per-face colors, ready for meshing.
5
- // ---------------------------------------------------------------------------
1
+ // The sprite-to-voxel pipeline. No THREE or DOM.
2
+ // Input: view name -> ImageData-like { width, height, data (RGBA) }.
3
+ // Output: the voxel grid, surface and per-face colors for meshing. A layered
4
+ // sheet builds each layer on its own, and unionVoxels merges the results.
6
5
 
7
6
  import { ingestSprite, applyTransform } from './ingest.js';
8
- import { reconcileDims, gridViews, carve, extractSurface } from './carve.js';
7
+ import {
8
+ reconcileDims,
9
+ gridViews,
10
+ carve,
11
+ extractSurface,
12
+ voxIndex,
13
+ unvoxIndex,
14
+ } from './carve.js';
9
15
  import { colorize } from './colorize.js';
10
- import { VIEW_NAMES } from './views.js';
16
+ import { VIEW_NAMES, VIEW_AXES, FACE_TO_VIEW, faceKeyOf } from './views.js';
11
17
 
12
18
  /**
13
19
  * @param {Record<string, {width:number,height:number,data:ArrayLike<number>}|null>} rawViews
@@ -32,8 +38,6 @@ export function buildVoxels(rawViews, opts = {}) {
32
38
  }
33
39
  const gviews = gridViews(ingested, dims);
34
40
  const solid = carve(gviews, dims);
35
- // extractSurface already visits + gates every voxel on `solid`, so it returns
36
- // solidCount too (no separate full-grid pass needed here).
37
41
  const { surfaceMask, count, solidCount } = extractSurface(solid, dims);
38
42
  const { faceColor, palette } = colorize(solid, surfaceMask, gviews, dims, opts);
39
43
  if (palette.length === 0 && Object.keys(ingested).length > 0) {
@@ -54,3 +58,114 @@ export function buildVoxels(rawViews, opts = {}) {
54
58
  providedViews: Object.keys(ingested),
55
59
  };
56
60
  }
61
+
62
+ /** @typedef {ReturnType<typeof buildVoxels>} VoxelResult */
63
+
64
+ // Each lattice axis: its dims key and its world axis.
65
+ const AXES = /** @type {const} */ ([
66
+ ['nx', 'x'],
67
+ ['ny', 'y'],
68
+ ['nz', 'z'],
69
+ ]);
70
+
71
+ // Where a layer's lattice starts in the union's. An axis none of the layer's
72
+ // views observes is one voxel deep, and sits at the far end when the layer
73
+ // has the view of that axis's positive face (left, top or front), else at 0.
74
+ // Every other axis starts at 0.
75
+ function layerOffset(r, dims) {
76
+ const off = { x: 0, y: 0, z: 0 };
77
+ for (const [dim, axis] of AXES) {
78
+ const observed = r.providedViews.some((v) => VIEW_AXES[v].includes(dim));
79
+ const positive = FACE_TO_VIEW[faceKeyOf(axis, 1)];
80
+ if (!observed && r.providedViews.includes(positive)) {
81
+ off[axis] = dims[dim] - r.dims[dim];
82
+ }
83
+ }
84
+ return off;
85
+ }
86
+
87
+ /**
88
+ * Merge per-layer buildVoxels results into one model.
89
+ * - A layer with no provided view is dropped, warnings included.
90
+ * - The lattice is the per-axis max of the kept layers' dims, each layer placed
91
+ * by layerOffset. A voxel is solid when any layer holds it, and the surface
92
+ * is extracted from that solid.
93
+ * - An exposed face takes its color from the last layer in block order that
94
+ * holds its voxel. The face is exposed in that layer too, so it has one.
95
+ * - The palette is the union of the layers'. Warnings are prefixed "Layer k:".
96
+ * `layers` holds the results, null where dropped.
97
+ * One result, or none with a provided view, returns the first result untouched.
98
+ * No results is one empty layer.
99
+ * @param {VoxelResult[]} results one per layer, in block order
100
+ */
101
+ export function unionVoxels(results) {
102
+ if (results.length === 0) return buildVoxels({});
103
+ const kept = results.map((r) => (r.providedViews.length > 0 ? r : null));
104
+ const live = kept.filter((r) => r != null);
105
+ if (results.length === 1 || live.length === 0) return results[0];
106
+
107
+ const dims = { nx: 1, ny: 1, nz: 1 };
108
+ for (const r of live) {
109
+ for (const [dim] of AXES) dims[dim] = Math.max(dims[dim], r.dims[dim]);
110
+ }
111
+ const placed = live.map((r) => ({ r, off: layerOffset(r, dims) }));
112
+
113
+ const n = dims.nx * dims.ny * dims.nz;
114
+ const solid = new Uint8Array(n);
115
+ // 1 + the index in `placed` of the last layer holding each voxel, 0 for none.
116
+ const owner = new Uint16Array(n);
117
+ placed.forEach(({ r, off }, i) => {
118
+ const d = r.dims;
119
+ for (let z = 0; z < d.nz; z++) {
120
+ for (let y = 0; y < d.ny; y++) {
121
+ for (let x = 0; x < d.nx; x++) {
122
+ if (!r.solid[voxIndex(x, y, z, d)]) continue;
123
+ const u = voxIndex(x + off.x, y + off.y, z + off.z, dims);
124
+ solid[u] = 1;
125
+ owner[u] = i + 1;
126
+ }
127
+ }
128
+ }
129
+ });
130
+
131
+ const { surfaceMask, count, solidCount } = extractSurface(solid, dims);
132
+ /** @type {Map<number, number>} */
133
+ const faceColor = new Map();
134
+ for (let idx = 0; idx < n; idx++) {
135
+ const mask = surfaceMask[idx];
136
+ if (!mask) continue;
137
+ const { r, off } = placed[owner[idx] - 1];
138
+ const p = unvoxIndex(idx, dims);
139
+ const local = voxIndex(p.x - off.x, p.y - off.y, p.z - off.z, r.dims) * 6;
140
+ for (let f = 0; f < 6; f++) {
141
+ if (mask & (1 << f)) faceColor.set(idx * 6 + f, r.faceColor.get(local + f));
142
+ }
143
+ }
144
+
145
+ return {
146
+ dims,
147
+ solid,
148
+ surfaceMask,
149
+ surfaceCount: count,
150
+ solidCount,
151
+ faceColor,
152
+ palette: [...new Set(live.flatMap((r) => r.palette))],
153
+ warnings: kept.flatMap((r, k) =>
154
+ r ? r.warnings.map((w) => `Layer ${k + 1}: ${w}`) : []
155
+ ),
156
+ providedViews: VIEW_NAMES.filter((v) =>
157
+ live.some((r) => r.providedViews.includes(v))
158
+ ),
159
+ layers: kept,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * buildVoxels for each layer's views, then unionVoxels.
165
+ * @param {Record<string, {width:number,height:number,data:ArrayLike<number>}|null>[]} rawViewsByLayer
166
+ * one views record per layer, in block order
167
+ * @param {Parameters<typeof buildVoxels>[1]} [opts] applied to every layer
168
+ */
169
+ export function buildLayeredVoxels(rawViewsByLayer, opts = {}) {
170
+ return unionVoxels(rawViewsByLayer.map((raw) => buildVoxels(raw, opts)));
171
+ }
package/src/png-chunks.js CHANGED
@@ -1,21 +1,11 @@
1
- // ---------------------------------------------------------------------------
2
- // PNG chunk surgery — the document format's foundation (a Sprite Machine
3
- // document IS one .png with its metadata in standard text chunks). Pure
4
- // typed-array code, zero deps, Node-tested like the rest of lib/.
1
+ // PNG chunk reading and writing. A document is one .png with its metadata in
2
+ // text chunks.
5
3
  //
6
- // A PNG is an 8-byte signature plus a chunk list; each chunk is
7
- // length(4, big-endian) | type(4, ASCII) | data(length) | crc(4, over
8
- // type+data). Ancillary text chunks (tEXt: Latin-1, iTXt: UTF-8) are
9
- // standard, ignored by every decoder, and legal anywhere between IHDR and
10
- // IEND we splice ours right after IHDR (before the first IDAT), so a
11
- // metadata read never has to scan past the pixel data.
12
- //
13
- // Reading and writing both handle tEXt AND iTXt; the writer picks tEXt when
14
- // the text survives Latin-1 and iTXt (UTF-8, uncompressed) otherwise, so a
15
- // plain ASCII Software tag stays the classic chunk while an emoji title still
16
- // round-trips. Compressed text chunks (zTXt / iTXt with the compression flag)
17
- // are passed through untouched but not decoded — nothing we write uses them.
18
- // ---------------------------------------------------------------------------
4
+ // A PNG is an 8-byte signature and a chunk list. Each chunk is
5
+ // length (4, big-endian) | type (4, ASCII) | data | crc (4, over type + data).
6
+ // Text chunks are written right after IHDR, before the first IDAT. The writer
7
+ // uses tEXt when the text is Latin-1 and uncompressed iTXt (UTF-8) otherwise.
8
+ // Compressed text chunks (zTXt, compressed iTXt) pass through undecoded.
19
9
 
20
10
  /** The 8-byte PNG signature. */
21
11
  export const PNG_SIGNATURE = Uint8Array.of(
@@ -38,7 +28,7 @@ export function isPng(bytes) {
38
28
  return true;
39
29
  }
40
30
 
41
- // --- CRC32 (the PNG polynomial), table built once ---------------------------
31
+ // CRC32 lookup table for the PNG polynomial.
42
32
  const CRC_TABLE = (() => {
43
33
  const t = new Uint32Array(256);
44
34
  for (let n = 0; n < 256; n++) {
@@ -49,7 +39,7 @@ const CRC_TABLE = (() => {
49
39
  return t;
50
40
  })();
51
41
 
52
- /** CRC32 of a byte range (PNG flavor: over the chunk's type + data). @param {Uint8Array} bytes */
42
+ /** CRC32 of a byte range (PNG runs it over type + data). @param {Uint8Array} bytes */
53
43
  export function crc32(bytes) {
54
44
  let c = 0xffffffff;
55
45
  for (let i = 0; i < bytes.length; i++) {
@@ -64,11 +54,9 @@ const readU32 = (b, i) =>
64
54
  const typeAt = (b, i) => String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
65
55
 
66
56
  /**
67
- * Parse the chunk list. Each entry's `data` is a SUBARRAY view into `bytes`
68
- * (no copies); `offset` is the chunk's start (the length field) and `end` one
69
- * past its CRC, so `bytes.subarray(offset, end)` is the whole chunk verbatim.
70
- * Throws on a non-PNG signature or a truncated chunk (a torn file should fail
71
- * loudly, not yield half a list).
57
+ * Parse the chunk list. Each entry's `data` is a subarray of `bytes`, `offset`
58
+ * is the chunk's start and `end` is one past its CRC. Throws on a bad
59
+ * signature or a truncated chunk.
72
60
  *
73
61
  * @param {Uint8Array} bytes
74
62
  * @returns {{type:string, data:Uint8Array, offset:number, end:number}[]}
@@ -90,7 +78,7 @@ export function readChunks(bytes) {
90
78
  return chunks;
91
79
  }
92
80
 
93
- // --- text codecs ------------------------------------------------------------
81
+ // Text codecs.
94
82
  const latin1Encodable = (s) => {
95
83
  for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0xff) return false;
96
84
  return true;
@@ -108,8 +96,7 @@ const latin1String = (b) => {
108
96
  const utf8Bytes = (s) => new TextEncoder().encode(s);
109
97
  const utf8String = (b) => new TextDecoder().decode(b);
110
98
 
111
- /** Serialize one chunk: length | type | data | crc(type+data). Exported for
112
- * the encoder (png-encode.js), which frames IHDR / IDAT / IEND through it.
99
+ /** Serialize one chunk: length | type | data | crc(type+data).
113
100
  * @param {string} type @param {Uint8Array} data */
114
101
  export function buildChunk(type, data) {
115
102
  const out = new Uint8Array(8 + data.length + 4);
@@ -131,20 +118,19 @@ function buildTextChunk(keyword, text) {
131
118
  return buildChunk('tEXt', data);
132
119
  }
133
120
 
134
- /** An iTXt chunk, uncompressed: keyword | 0 | 0 | 0 | lang | 0 | xlat | 0 | UTF-8 text. */
121
+ /** An uncompressed iTXt chunk: keyword | 0 | 0 | 0 | lang | 0 | xlat | 0 | UTF-8 text. */
135
122
  function buildItxtChunk(keyword, text) {
136
123
  const kw = latin1Bytes(keyword);
137
124
  const tx = utf8Bytes(text);
138
125
  const data = new Uint8Array(kw.length + 5 + tx.length);
139
126
  data.set(kw, 0);
140
- // keyword NUL, compression flag 0, compression method 0, empty language tag
141
- // NUL, empty translated keyword NUL five zero bytes in a row.
127
+ // The five bytes after the keyword stay zero: its NUL, the compression flag
128
+ // and method, and the NULs ending the empty language tag and translated keyword.
142
129
  data.set(tx, kw.length + 5);
143
130
  return buildChunk('iTXt', data);
144
131
  }
145
132
 
146
- // Decode one parsed text chunk to {keyword, text}, or null for a chunk we
147
- // don't decode (compressed variants pass through unread).
133
+ // Decode a text chunk to {keyword, text}, or null when it is compressed.
148
134
  function decodeTextChunk(chunk) {
149
135
  const d = chunk.data;
150
136
  const nul = d.indexOf(0);
@@ -155,7 +141,7 @@ function decodeTextChunk(chunk) {
155
141
  }
156
142
  // iTXt: compressionFlag(1) compressionMethod(1) lang\0 translated\0 text
157
143
  const flag = d[nul + 1];
158
- if (flag !== 0) return null; // compressed — not ours, leave it be
144
+ if (flag !== 0) return null; // compressed
159
145
  let i = nul + 3;
160
146
  while (i < d.length && d[i] !== 0) i++; // language tag
161
147
  i++;
@@ -165,11 +151,8 @@ function decodeTextChunk(chunk) {
165
151
  }
166
152
 
167
153
  /**
168
- * Every decodable text entry in the file, in chunk order, as
169
- * `{keyword: text}` — a later duplicate keyword wins (we never write
170
- * duplicates; a foreign file's are read leniently). A chunk scan stops
171
- * before IDAT costs nothing: our own writer puts every text chunk ahead of
172
- * the pixel data, but a foreign file's trailing chunks are read too.
154
+ * Every decodable text chunk as `{keyword: text}`, including chunks after
155
+ * IDAT. A later duplicate keyword wins.
173
156
  *
174
157
  * @param {Uint8Array} bytes
175
158
  * @returns {Record<string, string>}
@@ -186,13 +169,10 @@ export function readTextChunks(bytes) {
186
169
  }
187
170
 
188
171
  /**
189
- * Return a NEW file with `entries` written as text chunks: any existing
190
- * tEXt/iTXt chunk whose keyword appears in `entries` is removed (replace
191
- * semantics), the new chunks are spliced immediately after IHDR, and every
192
- * other chunk critical or ancillary, known or unknown — passes through
193
- * byte-for-byte. An entry whose value is `null`/`undefined` just removes the
194
- * keyword. Insertion order follows `entries`' key order, so a rewrite is
195
- * deterministic.
172
+ * Return a new file with `entries` written as text chunks. Existing text
173
+ * chunks with those keywords are removed, the new chunks go right after IHDR
174
+ * in key order, and every other chunk is copied byte for byte. A null or
175
+ * undefined value only removes the keyword.
196
176
  *
197
177
  * @param {Uint8Array} bytes
198
178
  * @param {Record<string, string|null|undefined>} entries
@@ -202,7 +182,7 @@ export function setTextChunks(bytes, entries) {
202
182
  const chunks = readChunks(bytes);
203
183
  const replaced = new Set(Object.keys(entries));
204
184
 
205
- /** @type {Uint8Array[]} the output's chunk byte-runs, in order */
185
+ /** @type {Uint8Array[]} output chunks, in order */
206
186
  const parts = [];
207
187
  const fresh = [];
208
188
  for (const [keyword, text] of Object.entries(entries)) {
package/src/png-encode.js CHANGED
@@ -1,28 +1,18 @@
1
- // ---------------------------------------------------------------------------
2
- // A PNG ENCODER from bytes 8-bit RGBA, non-interlaced, every row filter 0,
3
- // the pixel stream in a zlib container of STORED deflate blocks (no
4
- // compression) — so an image the app already holds as bytes becomes a file
5
- // without a canvas. File → Export 3D Model… embeds the skin texture this
6
- // way: a privacy browser's canvas farble (the readback perturbation that
7
- // once tripped the wedge gate) never sees it, and the file's texels are the
8
- // skin's, verbatim. Pure, zero deps, Node-tested against node:zlib's own
9
- // inflate. (The document PNGs still go through the canvas codec in
10
- // image-io.js — a document is the canvas's pixels; this is for bytes.)
1
+ // PNG encoder from bytes: 8-bit RGBA, non-interlaced, filter 0 on every row,
2
+ // the pixel stream in stored (uncompressed) deflate blocks. It encodes the
3
+ // skin for glb export without a canvas, because privacy browsers perturb
4
+ // canvas readback.
11
5
  //
12
- // Stored blocks cost nothing but size: a skin is a few kilobytes (64 × 64 ×
13
- // 4 is sixteen), and the glb it lands in is not a network asset. The zlib
14
- // framing: the two-byte header (CMF 0x78, FLG 0x01 deflate, a 32K window,
15
- // no dictionary, "fastest" flagged; the pair's check passes), then up to
16
- // 65535 bytes per block behind a five-byte block header (BFINAL on the
17
- // last, BTYPE 00, LEN, then NLEN its one's complement), then the Adler-32
18
- // of the raw stream, big-endian.
19
- // ---------------------------------------------------------------------------
6
+ // zlib framing: the header CMF 0x78, FLG 0x01 (a pair that passes the header
7
+ // check), then blocks of up to 65535 bytes, each behind a 5-byte header
8
+ // (BFINAL, BTYPE 00, LEN, NLEN = ~LEN), then the Adler-32 of the raw stream,
9
+ // big-endian.
20
10
 
21
11
  import { PNG_SIGNATURE, buildChunk } from './png-chunks.js';
22
12
 
23
13
  const BLOCK = 65535;
24
14
 
25
- /** Adler-32 of a byte string — the zlib trailer. @param {Uint8Array} bytes */
15
+ /** Adler-32 checksum, the zlib trailer. @param {Uint8Array} bytes */
26
16
  export function adler32(bytes) {
27
17
  let a = 1;
28
18
  let b = 0;
@@ -56,7 +46,7 @@ export function zlibStored(raw) {
56
46
  }
57
47
 
58
48
  /**
59
- * Encode an RGBA image as a PNG file: row 0 the top row, four bytes a texel.
49
+ * Encode an RGBA image as a PNG file. Row 0 is the top row.
60
50
  * @param {{width:number, height:number, data:Uint8Array|Uint8ClampedArray}} img
61
51
  * @returns {Uint8Array}
62
52
  */
@@ -70,7 +60,7 @@ export function encodePng({ width, height, data }) {
70
60
  const stride = width * 4;
71
61
  const raw = new Uint8Array((stride + 1) * height);
72
62
  for (let y = 0; y < height; y++) {
73
- raw[y * (stride + 1)] = 0; // the row's filter: none
63
+ raw[y * (stride + 1)] = 0; // filter type: none
74
64
  raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
75
65
  }
76
66
  const ihdr = new Uint8Array(13);
@@ -79,7 +69,7 @@ export function encodePng({ width, height, data }) {
79
69
  iv.setUint32(4, height);
80
70
  ihdr[8] = 8; // bit depth
81
71
  ihdr[9] = 6; // color type: RGBA
82
- // compression 0, filter 0, interlace 0 — the zeros the array was born with
72
+ // compression, filter and interlace stay 0
83
73
  const parts = [
84
74
  PNG_SIGNATURE,
85
75
  buildChunk('IHDR', ihdr),
package/src/regions.js CHANGED
@@ -1,38 +1,21 @@
1
- // ---------------------------------------------------------------------------
2
- // Coplanar REGIONS: the union of one plane's exposed unit faces and the gable
3
- // cap half-faces the wedge blocks end on, traced as boundary loops on the
4
- // lattice with every collinear run merged so a wall beside a 45° slope has
5
- // ONE straight diagonal edge where a stack of rectangles plus a sawtooth of
6
- // gable triangles had a vertex at every step, each pinning a vertex on the
7
- // slope that the T-junction repair then had to split the slope at. Pure 2D
8
- // lattice work, no THREE, Node-tested; the triangulation of a region (earcut,
9
- // through THREE's ShapeUtils) is the mesher's (wedge-mesh.js).
1
+ // Coplanar regions: one plane's exposed unit faces plus the gable-cap half
2
+ // faces of the wedge blocks, traced as boundary loops with collinear runs
3
+ // merged. A wall beside a 45° slope gets one straight diagonal edge, so no
4
+ // vertices land on the slope. wedge-mesh.js triangulates the regions.
10
5
  //
11
- // Pieces on one plane, in the face's tangent coordinates (a along
12
- // FACE_GEO[face].A, b along .B): a CELL is the unit square [a, a+1] × [b, b+1]
13
- // an exposed voxel face; a HALF is the right triangle in that square whose
14
- // right angle sits at the corner (a + hiA, b + hiB) — a gable cap, the
15
- // prism's cross-section. Every piece carries its packed colour: a face's from
16
- // colorize, a cap's the wedge's. A texel per piece is a texel per unit
17
- // square, the skin's orientation rule (skin.js).
6
+ // Pieces use the face's tangent coordinates: a along FACE_GEO[face].A, b along
7
+ // .B. A cell is the unit square [a, a+1] × [b, b+1]. A half is the right
8
+ // triangle in that square with its right angle at (a + hiA, b + hiB). Each
9
+ // piece carries a packed color.
18
10
  //
19
- // The trace: every piece contributes its directed edges, CCW in (a, b); an
20
- // edge whose reverse another piece contributes is interior and cancels; the
21
- // survivors are chained by the KEEP-LEFT rule at a vertex with several ways
22
- // on, the sharpest left turn which pairs each arriving edge with the one
23
- // leaving along its own sector of the region. So two cells that meet only at
24
- // a corner are two loops (two regions, as they should be), while a bay that
25
- // opens onto the outside through a corner rides the outer loop through that
26
- // corner twice, and two holes meeting at a corner ride one hole loop twice:
27
- // a self-touching ring, the very shape earcut builds when it bridges a hole
28
- // into the outer, and one its ear test handles by design (the zero-length
29
- // diagonal case); the mesher asserts every region triangulates to its full
30
- // area. A loop with positive signed area is an OUTER, negative a HOLE. A hole
31
- // and a piece belong to the smallest outer containing a point of theirs (a
32
- // piece's centroid, a point a quarter-cell inside a hole's first edge —
33
- // neither ever on a lattice line or a diagonal a piece can have), so an
34
- // island inside a hole is its own region.
35
- // ---------------------------------------------------------------------------
11
+ // Trace: each piece adds its directed edges, CCW in (a, b). An edge whose
12
+ // reverse is also present is interior and cancels. The remaining edges chain
13
+ // by the keep-left rule, taking the sharpest left turn at each vertex. Two
14
+ // cells that touch only at a corner form two loops. A loop can pass through a
15
+ // corner twice, which earcut handles. A loop with positive signed area is an
16
+ // outer, negative a hole. Holes and pieces belong to the smallest outer that
17
+ // contains a test point: a piece's centroid, or a point a quarter cell inside
18
+ // a hole's first edge. Neither can lie on a lattice line or a piece's diagonal.
36
19
 
37
20
  import { FACE_KEYS, FACE_NORMAL } from './views.js';
38
21
  import { FACE_GEO, idxFor } from './faces.js';
@@ -43,15 +26,15 @@ import { FACE_GEO, idxFor } from './faces.js';
43
26
  * @typedef {number[][]} Loop vertices [a, b] in order, the closing vertex not repeated
44
27
  * @typedef {{outer:Loop, holes:Loop[], a:number, b:number, w:number, h:number,
45
28
  * texels:Uint32Array, present:Uint8Array, uniform:number|null, area2:number}} Region2D
46
- * a region: its loops, its bounding box (a, b, w, h) in cells, a texel per
47
- * box cell (`present` marks the pieces'), its one colour or null, and
48
- * twice its area (a cell 2, a half 1).
29
+ * loops, bounding box (a, b, w, h) in cells, a texel per box cell (`present`
30
+ * marks the pieces), the single color or null, and twice the area (a cell 2,
31
+ * a half 1).
49
32
  * @typedef {Region2D & {face:string, s:number, normal:number[]}} Region
50
33
  */
51
34
 
52
35
  const DIM = (dims, axis) => dims['n' + axis];
53
36
 
54
- /** Twice the signed area of a polygon (shoelace); positive is CCW. @param {number[][]} poly */
37
+ /** Twice the signed area (shoelace). Positive is CCW. @param {number[][]} poly */
55
38
  function area2(poly) {
56
39
  let s = 0;
57
40
  for (let i = 0; i < poly.length; i++) {
@@ -62,7 +45,7 @@ function area2(poly) {
62
45
  return s;
63
46
  }
64
47
 
65
- /** Even-odd point-in-polygon; the ray is horizontal, and no test point here has an integer y. */
48
+ /** Even-odd point-in-polygon, horizontal ray. Test points never have an integer y. */
66
49
  function inside(poly, x, y) {
67
50
  let c = false;
68
51
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
@@ -73,7 +56,7 @@ function inside(poly, x, y) {
73
56
  return c;
74
57
  }
75
58
 
76
- /** Drop the vertices a ring runs straight through. Ring edges are unit steps, so a step's sign is its direction. */
59
+ /** Drop collinear vertices. Edges are unit steps, so a step's sign is its direction. */
77
60
  function dropCollinear(ring) {
78
61
  const n = ring.length;
79
62
  const out = [];
@@ -134,7 +117,7 @@ export function traceRegions(cells, halves) {
134
117
  });
135
118
  }
136
119
 
137
- // 1. directed edges; one whose reverse is present is interior and cancels
120
+ // 1. Directed edges. An edge whose reverse is present is interior.
138
121
  const ekey = (p, q) => p[0] + ',' + p[1] + '>' + q[0] + ',' + q[1];
139
122
  /** @type {Map<string, {p:number[], q:number[]}>} */
140
123
  const edges = new Map();
@@ -149,7 +132,7 @@ export function traceRegions(cells, halves) {
149
132
  const boundary = [];
150
133
  for (const e of edges.values()) if (!edges.has(ekey(e.q, e.p))) boundary.push(e);
151
134
 
152
- // 2. the keep-left successor of every boundary edge
135
+ // 2. The keep-left successor of each boundary edge.
153
136
  const vkey = (p) => p[0] + ',' + p[1];
154
137
  /** @type {Map<string, {p:number[], q:number[]}[]>} */
155
138
  const outAt = new Map();
@@ -177,7 +160,7 @@ export function traceRegions(cells, halves) {
177
160
  next.set(e, best);
178
161
  }
179
162
 
180
- // 3. the loops: the cycles of the successor map
163
+ // 3. Loops: the cycles of the successor map.
181
164
  const seen = new Set();
182
165
  /** @type {Loop[]} */
183
166
  const loops = [];
@@ -194,7 +177,7 @@ export function traceRegions(cells, halves) {
194
177
  loops.push(dropCollinear(ring));
195
178
  }
196
179
 
197
- // 4. outers and holes; each hole and each piece to the innermost outer around it
180
+ // 4. Outers and holes. Each hole and piece goes to the innermost outer around it.
198
181
  /** @type {Loop[]} */
199
182
  const outers = [];
200
183
  /** @type {Loop[]} */
@@ -210,8 +193,8 @@ export function traceRegions(cells, halves) {
210
193
  const [p, q] = h;
211
194
  const dx = Math.sign(q[0] - p[0]);
212
195
  const dy = Math.sign(q[1] - p[1]);
213
- // a quarter-cell to the RIGHT of the hole's first edge: the region is on
214
- // the left of every loop, so the right is the hole's own emptiness
196
+ // A quarter cell to the right of the hole's first edge is inside the hole,
197
+ // since the region lies to the left of every loop.
215
198
  groups[
216
199
  owner((p[0] + q[0]) / 2 + 0.25 * dy, (p[1] + q[1]) / 2 - 0.25 * dx)
217
200
  ].holes.push(h);
@@ -259,12 +242,12 @@ export function traceRegions(cells, halves) {
259
242
  });
260
243
  }
261
244
 
262
- /** The key a plane's halves are filed under: a face key at a slice. */
245
+ /** The map key for a plane: a face key and a slice. */
263
246
  export const planeKey = (face, s) => face + '|' + s;
264
247
 
265
248
  /**
266
- * The regions of every plane of the surface: the exposed faces of `surfaceMask`
267
- * (coloured by `faceColor`, keyed idx*6 + f) plus the halves filed per plane.
249
+ * The regions of every surface plane: the exposed faces in `surfaceMask`,
250
+ * colored by `faceColor` (keyed idx*6 + f), plus each plane's halves.
268
251
  * @param {{nx:number, ny:number, nz:number}} dims
269
252
  * @param {Uint8Array} surfaceMask
270
253
  * @param {Map<number, number>} faceColor