diff --git a/bin/atlas-baker.js b/bin/atlas-baker.js new file mode 100644 index 0000000..439fbf9 --- /dev/null +++ b/bin/atlas-baker.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// CLI entry. Parses args, dispatches to bake(), prints summary. + +const { bake } = require('../src/bake'); + +function parseArgs(argv) { + const opts = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = argv[i + 1]; + switch (a) { + case '--in': opts.inDir = next; i++; break; + case '--out': opts.outDir = next; i++; break; + case '--atlas-id': opts.atlasId = next; i++; break; + case '--tile-size': + opts.tileSize = (next === 'auto') ? 'auto' : parseInt(next, 10); + i++; + break; + case '--max-size': opts.maxSize = parseInt(next, 10); i++; break; + case '--lock': opts.lockPath = next; i++; break; + case '--blocks-sight-pattern': + opts.blocksSightPattern = new RegExp(next); i++; break; + case '--verbose': opts.verbose = true; break; + default: + console.error(`unknown arg: ${a}`); + process.exit(2); + } + } + return opts; +} + +(async () => { + try { + const opts = parseArgs(process.argv.slice(2)); + const result = await bake(opts); + console.log( + `OK ${result.atlasId}: ${result.tileCount} tiles, ` + + `${result.boundsW}x${result.boundsH} px atlas` + ); + process.exit(0); + } catch (err) { + console.error('ERROR:', err.message); + if (err.scanErrors) { + for (const e of err.scanErrors) console.error(' ', e); + } + process.exit(1); + } +})(); diff --git a/src/bake.js b/src/bake.js new file mode 100644 index 0000000..6bf6c97 --- /dev/null +++ b/src/bake.js @@ -0,0 +1,71 @@ +// src/bake.js +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'); + +async function bake(opts) { + const { + inDir, + outDir, + atlasId, + tileSize = 64, + maxSize = 4096, + 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'); + + const scanResult = scanSourceDir(inDir); + if (scanResult.errors.length > 0) { + const e = new Error(scanResult.errors[0]); + e.scanErrors = scanResult.errors; + throw e; + } + + // 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; + } + } + + 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, 1, (tileSize === 'auto') ? null : tileSize, + packResult.boundsW, packResult.boundsH, + packResult.placed, + blocksSightPattern, + ); + + writeOutputs(outDir, diffuse, height, atlasJson, lock); + return { + outDir, + atlasId, + tileCount: packResult.placed.length, + boundsW: packResult.boundsW, + boundsH: packResult.boundsH, + }; +} + +module.exports = { bake }; diff --git a/tests/bake-deleted-tile.test.js b/tests/bake-deleted-tile.test.js new file mode 100644 index 0000000..9886a72 --- /dev/null +++ b/tests/bake-deleted-tile.test.js @@ -0,0 +1,38 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +test('bake-deleted: missing source moves ID to deleted[]', async () => { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-src-')); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-out-')); + try { + // Copy only grass + stone from fixture (omit water) + const FIX = path.resolve(__dirname, 'fixtures/small-fresh'); + fs.copyFileSync(path.join(FIX, 'grass_diffuse.png'), path.join(srcDir, 'grass_diffuse.png')); + fs.copyFileSync(path.join(FIX, 'stone_diffuse.png'), path.join(srcDir, 'stone_diffuse.png')); + + // Pre-existing lock has all 3 names + fs.writeFileSync( + path.join(outDir, 'tiles.atlas.lock.json'), + JSON.stringify({ + atlas_id: 'small_demo', + bindings: { grass: 1, stone: 2, water: 3 }, + deleted: [], + next_id: 4, + }, null, 2), + ); + + await bake({ inDir: srcDir, outDir, atlasId: 'small_demo', tileSize: 32 }); + + const lock = JSON.parse(fs.readFileSync(path.join(outDir, 'tiles.atlas.lock.json'), 'utf8')); + assert.strictEqual(lock.bindings.water, undefined); + assert.deepStrictEqual(lock.deleted, [3]); + assert.strictEqual(lock.next_id, 4); // not advanced + } finally { + fs.rmSync(srcDir, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); diff --git a/tests/bake-determinism.test.js b/tests/bake-determinism.test.js new file mode 100644 index 0000000..f9ff8ad --- /dev/null +++ b/tests/bake-determinism.test.js @@ -0,0 +1,34 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const crypto = require('node:crypto'); +const { bake } = require('../src/bake'); + +const FIX = path.resolve(__dirname, 'fixtures/small-fresh'); + +function hashFile(p) { + return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +} + +test('bake-determinism: two fresh bakes produce identical outputs', async () => { + const out1 = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-det1-')); + const out2 = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-det2-')); + try { + await bake({ inDir: FIX, outDir: out1, atlasId: 'd', tileSize: 32 }); + await bake({ inDir: FIX, outDir: out2, atlasId: 'd', tileSize: 32 }); + const files = ['tiles.diffuse.atlas.png', 'tiles.height.atlas.png', + 'tiles.atlas.json', 'tiles.atlas.lock.json']; + for (const f of files) { + assert.strictEqual( + hashFile(path.join(out1, f)), + hashFile(path.join(out2, f)), + `${f} not bit-identical`, + ); + } + } finally { + fs.rmSync(out1, { recursive: true, force: true }); + fs.rmSync(out2, { recursive: true, force: true }); + } +}); diff --git a/tests/bake-fresh.test.js b/tests/bake-fresh.test.js new file mode 100644 index 0000000..3de872c --- /dev/null +++ b/tests/bake-fresh.test.js @@ -0,0 +1,43 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +const FIX = path.resolve(__dirname, 'fixtures/small-fresh'); + +test('bake-fresh: 3 sources, no lock, IDs 1..3', async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-bake-fresh-')); + try { + const result = await bake({ + inDir: FIX, + outDir, + atlasId: 'small_demo', + tileSize: 32, + }); + assert.strictEqual(result.tileCount, 3); + + const atlas = JSON.parse(fs.readFileSync(path.join(outDir, 'tiles.atlas.json'), 'utf8')); + assert.strictEqual(atlas.atlas_id, 'small_demo'); + assert.strictEqual(atlas.tiles.length, 3); + // Alphabetical sort: grass < stone < water + assert.strictEqual(atlas.tiles[0].name, 'grass'); + assert.strictEqual(atlas.tiles[0].id, 1); + assert.strictEqual(atlas.tiles[1].name, 'stone'); + assert.strictEqual(atlas.tiles[1].id, 2); + assert.strictEqual(atlas.tiles[2].name, 'water'); + assert.strictEqual(atlas.tiles[2].id, 3); + + const lock = JSON.parse(fs.readFileSync(path.join(outDir, 'tiles.atlas.lock.json'), 'utf8')); + assert.strictEqual(lock.bindings.grass, 1); + assert.strictEqual(lock.bindings.water, 3); + assert.strictEqual(lock.next_id, 4); + assert.deepStrictEqual(lock.deleted, []); + + assert.ok(fs.existsSync(path.join(outDir, 'tiles.diffuse.atlas.png'))); + assert.ok(fs.existsSync(path.join(outDir, 'tiles.height.atlas.png'))); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); diff --git a/tests/bake-oversize-fail.test.js b/tests/bake-oversize-fail.test.js new file mode 100644 index 0000000..58cabed --- /dev/null +++ b/tests/bake-oversize-fail.test.js @@ -0,0 +1,32 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { PNG } = require('pngjs'); +const { bake } = require('../src/bake'); + +test('bake-oversize: tile that does not fit hard-fails', async () => { + const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-over-src-')); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-over-out-')); + try { + // Single huge 256x256 tile, max canvas 128 -> cannot fit + const png = new PNG({ width: 256, height: 256 }); + png.data.fill(255); + fs.writeFileSync(path.join(srcDir, 'huge_diffuse.png'), PNG.sync.write(png)); + + await assert.rejects( + () => bake({ + inDir: srcDir, + outDir, + atlasId: 'over', + tileSize: 256, + maxSize: 128, + }), + /does not fit/, + ); + } finally { + fs.rmSync(srcDir, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); diff --git a/tests/bake-with-lock.test.js b/tests/bake-with-lock.test.js new file mode 100644 index 0000000..4b1253b --- /dev/null +++ b/tests/bake-with-lock.test.js @@ -0,0 +1,43 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { bake } = require('../src/bake'); + +const FIX = path.resolve(__dirname, 'fixtures/small-fresh'); + +test('bake-with-lock: existing IDs preserved, new tile appended', async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-bake-lock-')); + try { + // Pre-seed lock with grass=5, stone=6 (non-default IDs) + fs.writeFileSync( + path.join(outDir, 'tiles.atlas.lock.json'), + JSON.stringify({ + atlas_id: 'small_demo', + bindings: { grass: 5, stone: 6 }, + deleted: [], + next_id: 7, + }, null, 2), + ); + const result = await bake({ + inDir: FIX, + outDir, + atlasId: 'small_demo', + tileSize: 32, + }); + assert.strictEqual(result.tileCount, 3); + + const lock = JSON.parse(fs.readFileSync(path.join(outDir, 'tiles.atlas.lock.json'), 'utf8')); + assert.strictEqual(lock.bindings.grass, 5); // preserved + assert.strictEqual(lock.bindings.stone, 6); // preserved + assert.strictEqual(lock.bindings.water, 7); // new + assert.strictEqual(lock.next_id, 8); + + const atlas = JSON.parse(fs.readFileSync(path.join(outDir, 'tiles.atlas.json'), 'utf8')); + const ids = atlas.tiles.map(t => t.id).sort(); + assert.deepStrictEqual(ids, [5, 6, 7]); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } +});