// 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 };