atlas-baker v0.2.0 — padding + blob-14 validation + opaque flag
Implements the §13 contract from the autotile-blob-styles design
paper (Rev 4):
- **Edge-replicated padding** (Rev 4 §13.3, mandatory): each tile
in the packed atlas gets a `--pad-px` ring (default 2) of edge-
replicated pixels. Inner content + UV coords unchanged; pack-
rects inflated by 2 * padPx before MaxRects. write.js's new
placeTileWithPadding helper handles both the inner-copy and
the edge-replicate sweep. Prevents bilinear-sampler bleed from
adjacent tiles in the atlas — was the green-flicker bug we
caught during 0.5.0c integration with vagrant-skeleton.
- **E1 — Schema slot validation**: new `--schema blob-14` flag
runs validateBlob14Sources against the source dir. Errors out
with clear "missing slot 7 (tee_full)" message if the 14 PNGs
don't match the canonical enumeration. New src/schema.js
module holds REQUIRED_SLOTS_BLOB14 + validateBlob14Sources +
validateBlob14Collision.
- **E2 — Collision sidecar**: bake.js looks for collision.json
in the source dir. If present + --schema blob-14: validate
14 slot entries exist via validateBlob14Collision. Copy
through to atlas output as tiles.collision.json (read by
consumer engines via the existing atlas-path resolver).
- **Opaque-flag alpha analysis** (powers lib-core.maps' opaque-
ceiling-cache in v0.5.0e): write.js's placeTileWithPadding
scans each tile's inner content for alpha === 255. If all
inner pixels are opaque, the tile entry in tiles.atlas.json
gets `"opaque": true`. Replaces lib-core.maps' slot-13-only
heuristic with real data.
Backwards-compat: pre-Rev-4 callers (test fixtures) use tile.w/h
without innerW/innerH. write.js falls back to w/h when innerW is
absent and padPx defaults to 0 — old code paths unchanged.
atlas_version bumped 1 -> 2 in tiles.atlas.json output. Consumers
key on atlas_id, not version, so this is a behavioural signal only.
Test surface: all 20 existing node:test cases still pass (pack +
scan + lock + 5 bake-* + write tests).
End-to-end verification: prototype-blob-geom re-baked through new
pipeline (8 atlases: 4 base styles + 3 tinted singles + testbench),
SPOREL_CI=1 vagrant-skeleton rc=0 with 60 render_frame_ok, no
magenta-placeholders or render-hook errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,8 @@ function parseArgs(argv) {
|
|||||||
case '--lock': opts.lockPath = next; i++; break;
|
case '--lock': opts.lockPath = next; i++; break;
|
||||||
case '--blocks-sight-pattern':
|
case '--blocks-sight-pattern':
|
||||||
opts.blocksSightPattern = new RegExp(next); i++; break;
|
opts.blocksSightPattern = new RegExp(next); i++; break;
|
||||||
|
case '--pad-px': opts.padPx = parseInt(next, 10); i++; break;
|
||||||
|
case '--schema': opts.schema = next; i++; break;
|
||||||
case '--verbose': opts.verbose = true; break;
|
case '--verbose': opts.verbose = true; break;
|
||||||
default:
|
default:
|
||||||
console.error(`unknown arg: ${a}`);
|
console.error(`unknown arg: ${a}`);
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sporel-tool-atlas-baker",
|
"name": "sporel-tool-atlas-baker",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sporel-tool-atlas-baker",
|
"name": "sporel-tool-atlas-baker",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jimp": "^0.22.10",
|
"jimp": "^0.22.10",
|
||||||
"pngjs": "^7.0.0"
|
"pngjs": "^7.0.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sporel-tool-atlas-baker",
|
"name": "sporel-tool-atlas-baker",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Bake paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format. Accepts PNG/WebP/JPG sources via jimp.",
|
"description": "Bake paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format. v0.2: edge-replicated padding (Rev 4 §13.3), --schema blob-14 E1 slot validation, collision.json sidecar E2 validation + pass-through, alpha-analysis opaque flag. Accepts PNG/WebP/JPG via jimp.",
|
||||||
"bin": {
|
"bin": {
|
||||||
"sporel-atlas-baker": "bin/atlas-baker.js"
|
"sporel-atlas-baker": "bin/atlas-baker.js"
|
||||||
},
|
},
|
||||||
|
|||||||
51
src/bake.js
51
src/bake.js
@@ -1,9 +1,11 @@
|
|||||||
// src/bake.js
|
// src/bake.js
|
||||||
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { scanSourceDir } = require('./scan');
|
const { scanSourceDir } = require('./scan');
|
||||||
const { loadLock, assignIds, serializeLock } = require('./lock');
|
const { loadLock, assignIds, serializeLock } = require('./lock');
|
||||||
const { packMaxRects } = require('./pack');
|
const { packMaxRects } = require('./pack');
|
||||||
const { probeImageDims, composeAtlas, buildAtlasJson, writeOutputs } = require('./write');
|
const { probeImageDims, composeAtlas, buildAtlasJson, writeOutputs } = require('./write');
|
||||||
|
const { validateBlob14Sources, validateBlob14Collision } = require('./schema');
|
||||||
|
|
||||||
async function bake(opts) {
|
async function bake(opts) {
|
||||||
const {
|
const {
|
||||||
@@ -12,6 +14,8 @@ async function bake(opts) {
|
|||||||
atlasId,
|
atlasId,
|
||||||
tileSize = 64,
|
tileSize = 64,
|
||||||
maxSize = 4096,
|
maxSize = 4096,
|
||||||
|
padPx = 2, // Rev 4 §13.3 default; suppress with --pad-px 0
|
||||||
|
schema = null, // 'blob-14' enables E1+E2 validation
|
||||||
lockPath,
|
lockPath,
|
||||||
blocksSightPattern,
|
blocksSightPattern,
|
||||||
} = opts;
|
} = opts;
|
||||||
@@ -19,6 +23,9 @@ async function bake(opts) {
|
|||||||
if (!atlasId) throw new Error('bake: --atlas-id is required');
|
if (!atlasId) throw new Error('bake: --atlas-id is required');
|
||||||
if (!inDir) throw new Error('bake: --in is required');
|
if (!inDir) throw new Error('bake: --in is required');
|
||||||
if (!outDir) throw new Error('bake: --out is required');
|
if (!outDir) throw new Error('bake: --out is required');
|
||||||
|
if (padPx < 0 || !Number.isInteger(padPx)) {
|
||||||
|
throw new Error(`bake: --pad-px must be non-negative integer, got ${padPx}`);
|
||||||
|
}
|
||||||
|
|
||||||
const scanResult = scanSourceDir(inDir);
|
const scanResult = scanSourceDir(inDir);
|
||||||
if (scanResult.errors.length > 0) {
|
if (scanResult.errors.length > 0) {
|
||||||
@@ -27,6 +34,37 @@ async function bake(opts) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E1 — schema slot-validation (opt-in via --schema)
|
||||||
|
if (schema === 'blob-14') {
|
||||||
|
const e1Errors = validateBlob14Sources(scanResult.sources);
|
||||||
|
if (e1Errors.length > 0) {
|
||||||
|
const e = new Error(e1Errors[0]);
|
||||||
|
e.scanErrors = e1Errors;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E2 — collision sidecar (optional, but required when schema is blob-14)
|
||||||
|
let collisionJson = null;
|
||||||
|
const collisionPath = path.join(inDir, 'collision.json');
|
||||||
|
if (fs.existsSync(collisionPath)) {
|
||||||
|
try {
|
||||||
|
collisionJson = JSON.parse(fs.readFileSync(collisionPath, 'utf8'));
|
||||||
|
} catch (parseErr) {
|
||||||
|
throw new Error(`bake: collision.json in ${inDir} is not valid JSON: ${parseErr.message}`);
|
||||||
|
}
|
||||||
|
if (schema === 'blob-14') {
|
||||||
|
const e2Errors = validateBlob14Collision(collisionJson);
|
||||||
|
if (e2Errors.length > 0) {
|
||||||
|
const e = new Error(e2Errors[0]);
|
||||||
|
e.scanErrors = e2Errors;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (schema === 'blob-14') {
|
||||||
|
throw new Error(`bake: schema blob-14 requires collision.json in ${inDir} (E2 validation)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine each source's tile-dimensions.
|
// Determine each source's tile-dimensions.
|
||||||
// - If --tile-size is a positive integer, all tiles are forced to that
|
// - If --tile-size is a positive integer, all tiles are forced to that
|
||||||
// size (composeAtlas auto-resizes mismatched sources via jimp).
|
// size (composeAtlas auto-resizes mismatched sources via jimp).
|
||||||
@@ -40,6 +78,13 @@ async function bake(opts) {
|
|||||||
src.w = dims.width;
|
src.w = dims.width;
|
||||||
src.h = dims.height;
|
src.h = dims.height;
|
||||||
}
|
}
|
||||||
|
// Remember the pre-pad inner dimensions (used for UV).
|
||||||
|
src.innerW = src.w;
|
||||||
|
src.innerH = src.h;
|
||||||
|
// Inflate the pack-rect by 2 * padPx so each tile gets a padding ring.
|
||||||
|
src.w += 2 * padPx;
|
||||||
|
src.h += 2 * padPx;
|
||||||
|
src.padPx = padPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingLock = loadLock(lockPath || path.join(outDir, 'tiles.atlas.lock.json'));
|
const existingLock = loadLock(lockPath || path.join(outDir, 'tiles.atlas.lock.json'));
|
||||||
@@ -52,19 +97,21 @@ async function bake(opts) {
|
|||||||
|
|
||||||
const { diffuse, height } = await composeAtlas(packResult.placed, packResult.boundsW, packResult.boundsH);
|
const { diffuse, height } = await composeAtlas(packResult.placed, packResult.boundsW, packResult.boundsH);
|
||||||
const atlasJson = buildAtlasJson(
|
const atlasJson = buildAtlasJson(
|
||||||
atlasId, 1, (tileSize === 'auto') ? null : tileSize,
|
atlasId, 2, (tileSize === 'auto') ? null : tileSize,
|
||||||
packResult.boundsW, packResult.boundsH,
|
packResult.boundsW, packResult.boundsH,
|
||||||
packResult.placed,
|
packResult.placed,
|
||||||
blocksSightPattern,
|
blocksSightPattern,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeOutputs(outDir, diffuse, height, atlasJson, lock);
|
writeOutputs(outDir, diffuse, height, atlasJson, lock, collisionJson);
|
||||||
return {
|
return {
|
||||||
outDir,
|
outDir,
|
||||||
atlasId,
|
atlasId,
|
||||||
tileCount: packResult.placed.length,
|
tileCount: packResult.placed.length,
|
||||||
boundsW: packResult.boundsW,
|
boundsW: packResult.boundsW,
|
||||||
boundsH: packResult.boundsH,
|
boundsH: packResult.boundsH,
|
||||||
|
padPx,
|
||||||
|
schema,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
src/schema.js
Normal file
76
src/schema.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// src/schema.js
|
||||||
|
// Schema-specific source-set + collision validators. Currently the
|
||||||
|
// only registered schema is `blob-14` (the S-V2E2-RM-Blob 14-slot
|
||||||
|
// reduction from cr31/Boris-the-Brave classification — see
|
||||||
|
// sporel-meta/docs/design/2026-05-28-autotile-blob-styles-design.md §2).
|
||||||
|
//
|
||||||
|
// Validators return Array<string> of errors. Empty array == passed.
|
||||||
|
|
||||||
|
const REQUIRED_SLOTS_BLOB14 = [
|
||||||
|
'slot_00_isolated',
|
||||||
|
'slot_01_end',
|
||||||
|
'slot_02_corner_open',
|
||||||
|
'slot_03_corner_full',
|
||||||
|
'slot_04_straight',
|
||||||
|
'slot_05_tee_open',
|
||||||
|
'slot_06_tee_half',
|
||||||
|
'slot_07_tee_full',
|
||||||
|
'slot_08_cross_open',
|
||||||
|
'slot_09_cross_q1',
|
||||||
|
'slot_10_cross_q2adj',
|
||||||
|
'slot_11_cross_q2opp',
|
||||||
|
'slot_12_cross_q3',
|
||||||
|
'slot_13_solid',
|
||||||
|
];
|
||||||
|
|
||||||
|
// E1: source-dir contains exactly the 14 canonical slot files.
|
||||||
|
function validateBlob14Sources(sources) {
|
||||||
|
const errors = [];
|
||||||
|
const sourceNames = new Set(sources.map(s => s.name));
|
||||||
|
const required = new Set(REQUIRED_SLOTS_BLOB14);
|
||||||
|
|
||||||
|
for (const name of REQUIRED_SLOTS_BLOB14) {
|
||||||
|
if (!sourceNames.has(name)) {
|
||||||
|
errors.push(`schema blob-14: missing slot source '${name}_diffuse.png'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const src of sources) {
|
||||||
|
if (!required.has(src.name)) {
|
||||||
|
errors.push(`schema blob-14: unexpected source '${src.name}' (not a canonical slot)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// E2: collision sidecar JSON has shapes for all 14 slots.
|
||||||
|
function validateBlob14Collision(collisionJson) {
|
||||||
|
const errors = [];
|
||||||
|
if (!collisionJson || typeof collisionJson !== 'object') {
|
||||||
|
errors.push('schema blob-14: collision.json missing or not an object');
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(collisionJson.slots)) {
|
||||||
|
errors.push('schema blob-14: collision.json.slots must be array');
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
const slotsPresent = new Set();
|
||||||
|
for (const entry of collisionJson.slots) {
|
||||||
|
if (typeof entry.slot !== 'number') {
|
||||||
|
errors.push(`schema blob-14: collision entry missing/invalid 'slot' field: ${JSON.stringify(entry)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
slotsPresent.add(entry.slot);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
if (!slotsPresent.has(i)) {
|
||||||
|
errors.push(`schema blob-14: collision.json missing entry for slot ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REQUIRED_SLOTS_BLOB14,
|
||||||
|
validateBlob14Sources,
|
||||||
|
validateBlob14Collision,
|
||||||
|
};
|
||||||
135
src/write.js
135
src/write.js
@@ -1,6 +1,12 @@
|
|||||||
// src/write.js
|
// src/write.js
|
||||||
// Compose placed tiles into output diffuse + height PNGs, serialize atlas.json.
|
// Compose placed tiles into output diffuse + height PNGs, serialize atlas.json.
|
||||||
// Reads multi-format inputs (PNG/WebP/JPG) via jimp; writes deterministic PNGs via pngjs.
|
// Reads multi-format inputs (PNG/WebP/JPG) via jimp; writes deterministic PNGs via pngjs.
|
||||||
|
//
|
||||||
|
// Rev 4 §13.3: each placed tile has a `padPx` ring of edge-replicated
|
||||||
|
// pixels around its inner content. UV coords in the atlas JSON point
|
||||||
|
// to the inner non-padded region. Prevents bilinear sampler from
|
||||||
|
// leaking adjacent atlas-tile pixels when a sprite is rendered near
|
||||||
|
// its UV boundary.
|
||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -31,54 +37,115 @@ async function loadHeightImageOrSynth(filePath, w, h) {
|
|||||||
if (img.bitmap.width !== w || img.bitmap.height !== h) {
|
if (img.bitmap.width !== w || img.bitmap.height !== h) {
|
||||||
img.resize(w, h, Jimp.RESIZE_BICUBIC);
|
img.resize(w, h, Jimp.RESIZE_BICUBIC);
|
||||||
}
|
}
|
||||||
// Jimp gives RGBA; collapse to grayscale via R-channel (heightmaps are
|
|
||||||
// assumed to be encoded uniformly in R or grayscale-promoted).
|
|
||||||
return {
|
return {
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
data: Buffer.from(img.bitmap.data),
|
data: Buffer.from(img.bitmap.data),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Synth L8=0 plane in RGBA layout
|
|
||||||
const buf = Buffer.alloc(w * h * 4);
|
const buf = Buffer.alloc(w * h * 4);
|
||||||
for (let i = 3; i < buf.length; i += 4) buf[i] = 255; // alpha
|
for (let i = 3; i < buf.length; i += 4) buf[i] = 255;
|
||||||
return { width: w, height: h, data: buf };
|
return { width: w, height: h, data: buf };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the dimensions of a source image without fully decoding the pixels.
|
|
||||||
// Used by the bake orchestrator's auto-tile-size detection (Task 11 extension).
|
|
||||||
async function probeImageDims(filePath) {
|
async function probeImageDims(filePath) {
|
||||||
const img = await Jimp.read(filePath);
|
const img = await Jimp.read(filePath);
|
||||||
return { width: img.bitmap.width, height: img.bitmap.height };
|
return { width: img.bitmap.width, height: img.bitmap.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write 4 RGBA bytes into atlas-data at the given (atlasX, atlasY) pixel.
|
||||||
|
function writePixel(atlasData, boundsW, atlasX, atlasY, r, g, b, a) {
|
||||||
|
const idx = (atlasY * boundsW + atlasX) * 4;
|
||||||
|
atlasData[idx + 0] = r;
|
||||||
|
atlasData[idx + 1] = g;
|
||||||
|
atlasData[idx + 2] = b;
|
||||||
|
atlasData[idx + 3] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read 4 RGBA bytes from a source tile buffer at (tileX, tileY).
|
||||||
|
function readPixel(srcData, innerW, tileX, tileY) {
|
||||||
|
const idx = (tileY * innerW + tileX) * 4;
|
||||||
|
return [
|
||||||
|
srcData[idx + 0],
|
||||||
|
srcData[idx + 1],
|
||||||
|
srcData[idx + 2],
|
||||||
|
srcData[idx + 3],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place a single tile (inner content + edge-replicated padding ring)
|
||||||
|
// into the diffuse + height atlases. Returns the alpha-opaque-flag
|
||||||
|
// of the inner content (true iff every inner pixel has alpha === 255).
|
||||||
|
function placeTileWithPadding(diffuseData, heightData, boundsW, tile, dData, hData) {
|
||||||
|
const padPx = tile.padPx || 0;
|
||||||
|
// Backwards-compat: pre-Rev-4 callers use tile.w/h as the actual tile
|
||||||
|
// dimensions with no padding. New baker-flow sets tile.innerW/innerH
|
||||||
|
// explicitly when padPx > 0.
|
||||||
|
const innerW = tile.innerW != null ? tile.innerW : tile.w;
|
||||||
|
const innerH = tile.innerH != null ? tile.innerH : tile.h;
|
||||||
|
const outerX = tile.x; // top-left of placed rect (including padding)
|
||||||
|
const outerY = tile.y;
|
||||||
|
const innerOriginX = outerX + padPx;
|
||||||
|
const innerOriginY = outerY + padPx;
|
||||||
|
|
||||||
|
let isOpaque = true;
|
||||||
|
|
||||||
|
// 1. Copy inner tile content.
|
||||||
|
for (let y = 0; y < innerH; y++) {
|
||||||
|
for (let x = 0; x < innerW; x++) {
|
||||||
|
const [r, g, b, a] = readPixel(dData, innerW, x, y);
|
||||||
|
writePixel(diffuseData, boundsW, innerOriginX + x, innerOriginY + y, r, g, b, a);
|
||||||
|
if (a !== 255) isOpaque = false;
|
||||||
|
// Height: take R-channel of source as L, repeat across RGB, alpha 255.
|
||||||
|
const hr = hData[(y * innerW + x) * 4];
|
||||||
|
writePixel(heightData, boundsW, innerOriginX + x, innerOriginY + y, hr, hr, hr, 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (padPx === 0) return isOpaque;
|
||||||
|
|
||||||
|
// 2. Edge-replicate padding ring. For each padding pixel, read the
|
||||||
|
// nearest inner-content pixel (clamp to inner bounds) and write
|
||||||
|
// it into the atlas at the padded position.
|
||||||
|
//
|
||||||
|
// Outer rect = [outerX..outerX+innerW+2p) x [outerY..outerY+innerH+2p)
|
||||||
|
// Inner rect = [innerOriginX..innerOriginX+innerW) x [innerOriginY..innerOriginY+innerH)
|
||||||
|
// For each outer pixel NOT inside inner rect, sample the closest
|
||||||
|
// edge inner pixel.
|
||||||
|
const outerW = innerW + 2 * padPx;
|
||||||
|
const outerH = innerH + 2 * padPx;
|
||||||
|
for (let oy = 0; oy < outerH; oy++) {
|
||||||
|
for (let ox = 0; ox < outerW; ox++) {
|
||||||
|
// Inner-coord we should sample: clamp ox-padPx to [0..innerW-1].
|
||||||
|
const ix = Math.max(0, Math.min(innerW - 1, ox - padPx));
|
||||||
|
const iy = Math.max(0, Math.min(innerH - 1, oy - padPx));
|
||||||
|
const isInsideInner = (ox >= padPx && ox < padPx + innerW
|
||||||
|
&& oy >= padPx && oy < padPx + innerH);
|
||||||
|
if (isInsideInner) continue; // already written in step 1
|
||||||
|
|
||||||
|
const [r, g, b, a] = readPixel(dData, innerW, ix, iy);
|
||||||
|
writePixel(diffuseData, boundsW, outerX + ox, outerY + oy, r, g, b, a);
|
||||||
|
const hr = hData[(iy * innerW + ix) * 4];
|
||||||
|
writePixel(heightData, boundsW, outerX + ox, outerY + oy, hr, hr, hr, 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOpaque;
|
||||||
|
}
|
||||||
|
|
||||||
async function composeAtlas(placedTiles, boundsW, boundsH) {
|
async function composeAtlas(placedTiles, boundsW, boundsH) {
|
||||||
const diffuse = new PNG({ width: boundsW, height: boundsH });
|
const diffuse = new PNG({ width: boundsW, height: boundsH });
|
||||||
const height = new PNG({ width: boundsW, height: boundsH });
|
const height = new PNG({ width: boundsW, height: boundsH });
|
||||||
diffuse.data.fill(0);
|
diffuse.data.fill(0);
|
||||||
height.data.fill(0);
|
height.data.fill(0);
|
||||||
// Empty pixels are alpha=0 in diffuse (transparent black). Height-empty
|
|
||||||
// is fully opaque alpha=255 since L8 maps care about R-channel only.
|
|
||||||
for (let i = 3; i < height.data.length; i += 4) height.data[i] = 255;
|
for (let i = 3; i < height.data.length; i += 4) height.data[i] = 255;
|
||||||
|
|
||||||
for (const tile of placedTiles) {
|
for (const tile of placedTiles) {
|
||||||
const d = await loadDiffuseImage(tile.diffusePath, tile.w, tile.h);
|
const innerW = tile.innerW != null ? tile.innerW : tile.w;
|
||||||
const h = await loadHeightImageOrSynth(tile.heightPath, tile.w, tile.h);
|
const innerH = tile.innerH != null ? tile.innerH : tile.h;
|
||||||
for (let y = 0; y < tile.h; y++) {
|
const d = await loadDiffuseImage(tile.diffusePath, innerW, innerH);
|
||||||
for (let x = 0; x < tile.w; x++) {
|
const h = await loadHeightImageOrSynth(tile.heightPath, innerW, innerH);
|
||||||
const srcIdx = (y * tile.w + x) * 4;
|
tile.opaque = placeTileWithPadding(diffuse.data, height.data, boundsW, tile, d.data, h.data);
|
||||||
const dstIdx = ((tile.y + y) * boundsW + (tile.x + x)) * 4;
|
|
||||||
diffuse.data[dstIdx + 0] = d.data[srcIdx + 0];
|
|
||||||
diffuse.data[dstIdx + 1] = d.data[srcIdx + 1];
|
|
||||||
diffuse.data[dstIdx + 2] = d.data[srcIdx + 2];
|
|
||||||
diffuse.data[dstIdx + 3] = d.data[srcIdx + 3];
|
|
||||||
// Height: take R-channel of source as L, repeat across RGB
|
|
||||||
height.data[dstIdx + 0] = h.data[srcIdx + 0];
|
|
||||||
height.data[dstIdx + 1] = h.data[srcIdx + 0];
|
|
||||||
height.data[dstIdx + 2] = h.data[srcIdx + 0];
|
|
||||||
height.data[dstIdx + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { diffuse, height };
|
return { diffuse, height };
|
||||||
}
|
}
|
||||||
@@ -88,14 +155,21 @@ function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, place
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id - b.id)
|
.sort((a, b) => a.id - b.id)
|
||||||
.map(t => {
|
.map(t => {
|
||||||
|
const padPx = t.padPx || 0;
|
||||||
|
const innerW = t.innerW != null ? t.innerW : t.w;
|
||||||
|
const innerH = t.innerH != null ? t.innerH : t.h;
|
||||||
|
// UV points to the inner non-padded region.
|
||||||
const entry = {
|
const entry = {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
uv: [t.x, t.y, t.w, t.h],
|
uv: [t.x + padPx, t.y + padPx, innerW, innerH],
|
||||||
};
|
};
|
||||||
if (blocksSightPattern && blocksSightPattern.test(t.name)) {
|
if (blocksSightPattern && blocksSightPattern.test(t.name)) {
|
||||||
entry.blocks_sight = true;
|
entry.blocks_sight = true;
|
||||||
}
|
}
|
||||||
|
// E2 alpha-analysis: opaque flag if every inner pixel has alpha 255.
|
||||||
|
// Used by lib-core.maps render-opt opaque-ceiling cache.
|
||||||
|
if (t.opaque) entry.opaque = true;
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -107,12 +181,17 @@ function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, place
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeOutputs(outDir, diffusePng, heightPng, atlasJson, lockJson) {
|
function writeOutputs(outDir, diffusePng, heightPng, atlasJson, lockJson, collisionJson) {
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(outDir, 'tiles.diffuse.atlas.png'), PNG.sync.write(diffusePng));
|
fs.writeFileSync(path.join(outDir, 'tiles.diffuse.atlas.png'), PNG.sync.write(diffusePng));
|
||||||
fs.writeFileSync(path.join(outDir, 'tiles.height.atlas.png'), PNG.sync.write(heightPng));
|
fs.writeFileSync(path.join(outDir, 'tiles.height.atlas.png'), PNG.sync.write(heightPng));
|
||||||
fs.writeFileSync(path.join(outDir, 'tiles.atlas.json'), JSON.stringify(atlasJson, null, 2) + '\n');
|
fs.writeFileSync(path.join(outDir, 'tiles.atlas.json'), JSON.stringify(atlasJson, null, 2) + '\n');
|
||||||
fs.writeFileSync(path.join(outDir, 'tiles.atlas.lock.json'), JSON.stringify(lockJson, null, 2) + '\n');
|
fs.writeFileSync(path.join(outDir, 'tiles.atlas.lock.json'), JSON.stringify(lockJson, null, 2) + '\n');
|
||||||
|
// E2 collision sidecar: copy through to baked output for the engine
|
||||||
|
// to consume via the existing atlas-path resolver.
|
||||||
|
if (collisionJson) {
|
||||||
|
fs.writeFileSync(path.join(outDir, 'tiles.collision.json'), JSON.stringify(collisionJson, null, 2) + '\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadDiffuseImage, loadHeightImageOrSynth, probeImageDims, composeAtlas, buildAtlasJson, writeOutputs };
|
module.exports = { loadDiffuseImage, loadHeightImageOrSynth, probeImageDims, composeAtlas, buildAtlasJson, writeOutputs, placeTileWithPadding };
|
||||||
|
|||||||
Reference in New Issue
Block a user