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/node.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
27
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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, {
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
7
|
-
// length(4, big-endian) | type(4, ASCII) | data
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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
|
|
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
|
|
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
|
|
169
|
-
*
|
|
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
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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[]}
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
|
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; //
|
|
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
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
214
|
-
// the
|
|
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
|
|
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
|
|
267
|
-
*
|
|
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
|