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>
119 lines
4.2 KiB
JavaScript
119 lines
4.2 KiB
JavaScript
// src/bake.js
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { scanSourceDir } = require('./scan');
|
|
const { loadLock, assignIds, serializeLock } = require('./lock');
|
|
const { packMaxRects } = require('./pack');
|
|
const { probeImageDims, composeAtlas, buildAtlasJson, writeOutputs } = require('./write');
|
|
const { validateBlob14Sources, validateBlob14Collision } = require('./schema');
|
|
|
|
async function bake(opts) {
|
|
const {
|
|
inDir,
|
|
outDir,
|
|
atlasId,
|
|
tileSize = 64,
|
|
maxSize = 4096,
|
|
padPx = 2, // Rev 4 §13.3 default; suppress with --pad-px 0
|
|
schema = null, // 'blob-14' enables E1+E2 validation
|
|
lockPath,
|
|
blocksSightPattern,
|
|
} = opts;
|
|
|
|
if (!atlasId) throw new Error('bake: --atlas-id is required');
|
|
if (!inDir) throw new Error('bake: --in 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);
|
|
if (scanResult.errors.length > 0) {
|
|
const e = new Error(scanResult.errors[0]);
|
|
e.scanErrors = scanResult.errors;
|
|
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.
|
|
// - If --tile-size is a positive integer, all tiles are forced to that
|
|
// size (composeAtlas auto-resizes mismatched sources via jimp).
|
|
// - If --tile-size === 'auto', each tile keeps its native dims.
|
|
for (const src of scanResult.sources) {
|
|
if (tileSize && tileSize !== 'auto') {
|
|
src.w = tileSize;
|
|
src.h = tileSize;
|
|
} else {
|
|
const dims = await probeImageDims(src.diffusePath);
|
|
src.w = dims.width;
|
|
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 { tiles, lock } = assignIds(scanResult.sources, existingLock, atlasId);
|
|
|
|
const packResult = packMaxRects(tiles, maxSize);
|
|
if (packResult.error) {
|
|
throw new Error(packResult.message);
|
|
}
|
|
|
|
const { diffuse, height } = await composeAtlas(packResult.placed, packResult.boundsW, packResult.boundsH);
|
|
const atlasJson = buildAtlasJson(
|
|
atlasId, 2, (tileSize === 'auto') ? null : tileSize,
|
|
packResult.boundsW, packResult.boundsH,
|
|
packResult.placed,
|
|
blocksSightPattern,
|
|
);
|
|
|
|
writeOutputs(outDir, diffuse, height, atlasJson, lock, collisionJson);
|
|
return {
|
|
outDir,
|
|
atlasId,
|
|
tileCount: packResult.placed.length,
|
|
boundsW: packResult.boundsW,
|
|
boundsH: packResult.boundsH,
|
|
padPx,
|
|
schema,
|
|
};
|
|
}
|
|
|
|
module.exports = { bake };
|