diff --git a/bin/atlas-baker.js b/bin/atlas-baker.js index 439fbf9..1d5d4b7 100644 --- a/bin/atlas-baker.js +++ b/bin/atlas-baker.js @@ -20,6 +20,8 @@ function parseArgs(argv) { case '--lock': opts.lockPath = next; i++; break; case '--blocks-sight-pattern': 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; default: console.error(`unknown arg: ${a}`); diff --git a/package-lock.json b/package-lock.json index c7b4dfa..90809f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sporel-tool-atlas-baker", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sporel-tool-atlas-baker", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "jimp": "^0.22.10", "pngjs": "^7.0.0" diff --git a/package.json b/package.json index 23e40ed..d45a8cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sporel-tool-atlas-baker", - "version": "0.1.0", - "description": "Bake paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format. Accepts PNG/WebP/JPG sources via jimp.", + "version": "0.2.0", + "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": { "sporel-atlas-baker": "bin/atlas-baker.js" }, diff --git a/src/bake.js b/src/bake.js index 6bf6c97..0164f58 100644 --- a/src/bake.js +++ b/src/bake.js @@ -1,9 +1,11 @@ // 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 { @@ -12,6 +14,8 @@ async function bake(opts) { 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; @@ -19,6 +23,9 @@ async function bake(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) { @@ -27,6 +34,37 @@ async function bake(opts) { 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). @@ -40,6 +78,13 @@ async function bake(opts) { 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')); @@ -52,19 +97,21 @@ async function bake(opts) { const { diffuse, height } = await composeAtlas(packResult.placed, packResult.boundsW, packResult.boundsH); const atlasJson = buildAtlasJson( - atlasId, 1, (tileSize === 'auto') ? null : tileSize, + atlasId, 2, (tileSize === 'auto') ? null : tileSize, packResult.boundsW, packResult.boundsH, packResult.placed, blocksSightPattern, ); - writeOutputs(outDir, diffuse, height, atlasJson, lock); + writeOutputs(outDir, diffuse, height, atlasJson, lock, collisionJson); return { outDir, atlasId, tileCount: packResult.placed.length, boundsW: packResult.boundsW, boundsH: packResult.boundsH, + padPx, + schema, }; } diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..927951d --- /dev/null +++ b/src/schema.js @@ -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 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, +}; diff --git a/src/write.js b/src/write.js index bc6b1c8..ad22ed4 100644 --- a/src/write.js +++ b/src/write.js @@ -1,6 +1,12 @@ // src/write.js // 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. +// +// 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 path = require('node:path'); @@ -31,54 +37,115 @@ async function loadHeightImageOrSynth(filePath, w, h) { if (img.bitmap.width !== w || img.bitmap.height !== h) { 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 { width: w, height: h, data: Buffer.from(img.bitmap.data), }; } - // Synth L8=0 plane in RGBA layout 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 }; } -// 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) { const img = await Jimp.read(filePath); 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) { const diffuse = new PNG({ width: boundsW, height: boundsH }); const height = new PNG({ width: boundsW, height: boundsH }); diffuse.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 (const tile of placedTiles) { - const d = await loadDiffuseImage(tile.diffusePath, tile.w, tile.h); - const h = await loadHeightImageOrSynth(tile.heightPath, tile.w, tile.h); - for (let y = 0; y < tile.h; y++) { - for (let x = 0; x < tile.w; x++) { - const srcIdx = (y * tile.w + x) * 4; - 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; - } - } + const innerW = tile.innerW != null ? tile.innerW : tile.w; + const innerH = tile.innerH != null ? tile.innerH : tile.h; + const d = await loadDiffuseImage(tile.diffusePath, innerW, innerH); + const h = await loadHeightImageOrSynth(tile.heightPath, innerW, innerH); + tile.opaque = placeTileWithPadding(diffuse.data, height.data, boundsW, tile, d.data, h.data); } return { diffuse, height }; } @@ -88,14 +155,21 @@ function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, place .slice() .sort((a, b) => a.id - b.id) .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 = { id: t.id, 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)) { 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 { @@ -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.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.atlas.json'), JSON.stringify(atlasJson, 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 };