Add atlas composers and output writers
composeAtlas blits placed tile pixels into bounds-sized RGBA + L8 PNGs, synthesizing L8=0 planes for unpaired diffuse tiles. buildAtlasJson emits the metadata with deterministic ID-sorted tile arrays and applies the optional blocks_sight regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
118
src/write.js
Normal file
118
src/write.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { PNG } = require('pngjs');
|
||||||
|
const Jimp = require('jimp');
|
||||||
|
|
||||||
|
// Load a diffuse image as a Jimp instance, resized (cover-style) to (w, h)
|
||||||
|
// if its native dims do not match. Returns { width, height, data } where
|
||||||
|
// data is a Buffer in RGBA8888 layout matching pngjs.PNG.data shape.
|
||||||
|
async function loadDiffuseImage(filePath, targetW, targetH) {
|
||||||
|
const img = await Jimp.read(filePath);
|
||||||
|
if (img.bitmap.width !== targetW || img.bitmap.height !== targetH) {
|
||||||
|
img.resize(targetW, targetH, Jimp.RESIZE_BICUBIC);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: img.bitmap.width,
|
||||||
|
height: img.bitmap.height,
|
||||||
|
data: Buffer.from(img.bitmap.data), // RGBA8888 already
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a height image and convert to L8 layout (in RGBA buffer slot 0 = the L
|
||||||
|
// value, repeated across G/B for downstream uniformity). Resized to (w, h)
|
||||||
|
// if mismatched.
|
||||||
|
async function loadHeightImageOrSynth(filePath, w, h) {
|
||||||
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
|
const img = await Jimp.read(filePath);
|
||||||
|
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
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { diffuse, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, placedTiles, blocksSightPattern) {
|
||||||
|
const tiles = placedTiles
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id - b.id)
|
||||||
|
.map(t => {
|
||||||
|
const entry = {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
uv: [t.x, t.y, t.w, t.h],
|
||||||
|
};
|
||||||
|
if (blocksSightPattern && blocksSightPattern.test(t.name)) {
|
||||||
|
entry.blocks_sight = true;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
atlas_id: atlasId,
|
||||||
|
atlas_version: atlasVersion,
|
||||||
|
atlas_size_px: [boundsW, boundsH],
|
||||||
|
tile_size_px: tileSize,
|
||||||
|
tiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOutputs(outDir, diffusePng, heightPng, atlasJson, lockJson) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadDiffuseImage, loadHeightImageOrSynth, probeImageDims, composeAtlas, buildAtlasJson, writeOutputs };
|
||||||
66
tests/write.test.js
Normal file
66
tests/write.test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// tests/write.test.js
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { PNG } = require('pngjs');
|
||||||
|
const { composeAtlas, buildAtlasJson } = require('../src/write');
|
||||||
|
|
||||||
|
const FIX = path.resolve(__dirname, 'fixtures/small-fresh');
|
||||||
|
|
||||||
|
test('compose: 3 tiles into 96x32 atlas', async () => {
|
||||||
|
const placed = [
|
||||||
|
{ name: 'grass', id: 1, x: 0, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'grass_diffuse.png'), heightPath: null },
|
||||||
|
{ name: 'stone', id: 2, x: 32, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'stone_diffuse.png'), heightPath: null },
|
||||||
|
{ name: 'water', id: 3, x: 64, y: 0, w: 32, h: 32, diffusePath: path.join(FIX, 'water_diffuse.png'), heightPath: path.join(FIX, 'water_height.png') },
|
||||||
|
];
|
||||||
|
const { diffuse, height } = await composeAtlas(placed, 128, 32);
|
||||||
|
assert.strictEqual(diffuse.width, 128);
|
||||||
|
assert.strictEqual(diffuse.height, 32);
|
||||||
|
// Sample grass pixel (0,0): expect RGB ~ (110, 170, 80)
|
||||||
|
const gIdx = 0;
|
||||||
|
assert.strictEqual(diffuse.data[gIdx + 0], 110);
|
||||||
|
assert.strictEqual(diffuse.data[gIdx + 1], 170);
|
||||||
|
assert.strictEqual(diffuse.data[gIdx + 2], 80);
|
||||||
|
// Sample stone pixel (32, 0): expect RGB ~ (120, 120, 120)
|
||||||
|
const sIdx = (0 * 128 + 32) * 4;
|
||||||
|
assert.strictEqual(diffuse.data[sIdx + 0], 120);
|
||||||
|
// Height should be 0 for synth-tiles (grass, stone)
|
||||||
|
assert.strictEqual(height.data[0], 0); // grass position
|
||||||
|
assert.strictEqual(height.data[(0 * 128 + 32) * 4], 0); // stone position
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAtlasJson: sorted by ID, blocks_sight pattern applied', () => {
|
||||||
|
const placed = [
|
||||||
|
{ name: 'grass', id: 1, x: 0, y: 0, w: 32, h: 32 },
|
||||||
|
{ name: 'stone_wall_brick', id: 2, x: 32, y: 0, w: 32, h: 32 },
|
||||||
|
];
|
||||||
|
const json = buildAtlasJson('demo', 1, 32, 64, 32, placed, /^stone_wall_/);
|
||||||
|
assert.strictEqual(json.atlas_id, 'demo');
|
||||||
|
assert.strictEqual(json.tiles.length, 2);
|
||||||
|
assert.strictEqual(json.tiles[0].id, 1);
|
||||||
|
assert.strictEqual(json.tiles[0].name, 'grass');
|
||||||
|
assert.deepStrictEqual(json.tiles[0].uv, [0, 0, 32, 32]);
|
||||||
|
assert.strictEqual(json.tiles[0].blocks_sight, undefined);
|
||||||
|
assert.strictEqual(json.tiles[1].blocks_sight, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose: WebP source loads + resizes', async () => {
|
||||||
|
// Optional integration check — only runs if FA-Starter fixture is staged.
|
||||||
|
// The Task 10 baker run is the real-world verification; this test is a
|
||||||
|
// cheaper inline guard against jimp.read regressions on WebP.
|
||||||
|
const webpFixture = path.resolve(__dirname, 'fixtures/multi-format/sample_diffuse.webp');
|
||||||
|
if (!fs.existsSync(webpFixture)) return; // skip if not staged
|
||||||
|
|
||||||
|
const placed = [{ name: 'sample', id: 1, x: 0, y: 0, w: 64, h: 64,
|
||||||
|
diffusePath: webpFixture, heightPath: null }];
|
||||||
|
const { diffuse } = await composeAtlas(placed, 64, 64);
|
||||||
|
assert.strictEqual(diffuse.width, 64);
|
||||||
|
assert.strictEqual(diffuse.height, 64);
|
||||||
|
// Just confirm it didn't throw and has nonzero alpha somewhere
|
||||||
|
let hasAlpha = false;
|
||||||
|
for (let i = 3; i < diffuse.data.length; i += 4) {
|
||||||
|
if (diffuse.data[i] > 0) { hasAlpha = true; break; }
|
||||||
|
}
|
||||||
|
assert.ok(hasAlpha, 'compose produced fully-transparent atlas');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user