Init atlas-baker tool with source-dir scan and pair-detection
Creates the sporel-tool-atlas-baker Node CLI scaffold and the first phase of the bake pipeline: scan a source directory for *_diffuse.png files, pair them with optional *_height.png companions, and return alphabetically-sorted sources for deterministic re-bakes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Copyright (c) 2026 Calic. All rights reserved.
|
||||||
|
|
||||||
|
This software is part of the Sporel platform — **Tier 1 (Official /
|
||||||
|
Proprietary)** content per the Three-Tier Licensing Model documented in
|
||||||
|
`meta/docs/archive/design/vision.md §Licensing Model` (current source;
|
||||||
|
migration to `meta/docs/architecture/licensing-model.md` pending).
|
||||||
|
|
||||||
|
⚠ **WIP — Legal review required before public launch.** The terms below
|
||||||
|
reflect design intent only; the formalized license framework will be
|
||||||
|
finalized through legal counsel before the first public release. Until
|
||||||
|
then, this notice serves as a placeholder defending the platform owner's
|
||||||
|
rights against unintentional re-licensing.
|
||||||
|
|
||||||
|
No license is granted to copy, modify, distribute, sublicense, or otherwise
|
||||||
|
use this software in any form without prior written permission from the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
|
References:
|
||||||
|
- Tier 1 (this file): all rights reserved, proprietary, sold/distributed
|
||||||
|
via official channels (Steam, etc.)
|
||||||
|
- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue-
|
||||||
|
share — see vision.md §Licensing Model
|
||||||
|
- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies
|
||||||
|
to community-uploaded libs/modules/assets, not this repo
|
||||||
25
README.md
Normal file
25
README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# sporel-tool-atlas-baker
|
||||||
|
|
||||||
|
Builds paired diffuse + height PNG atlases for the Sporel map-lib v0.3.0+ format.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sporel-atlas-baker \
|
||||||
|
--in <source-dir> \
|
||||||
|
--out <atlas-dir> \
|
||||||
|
--atlas-id <stable-id> \
|
||||||
|
[--tile-size 64] \
|
||||||
|
[--max-size 4096] \
|
||||||
|
[--lock <existing-lock.json>] \
|
||||||
|
[--blocks-sight-pattern <regex>]
|
||||||
|
```
|
||||||
|
|
||||||
|
See `sporel-meta/docs/superpowers/specs/2026-05-21-map-multi-layer-design.md` §4 for the format spec.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
```
|
||||||
1090
package-lock.json
generated
Normal file
1090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"bin": {
|
||||||
|
"sporel-atlas-baker": "bin/atlas-baker.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jimp": "^0.22.10",
|
||||||
|
"pngjs": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/scan.js
Normal file
67
src/scan.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// src/scan.js
|
||||||
|
// Scans a source directory for *_diffuse.{png,webp,jpg,jpeg} files and
|
||||||
|
// pairs them with optional *_height.{png,webp,jpg,jpeg} companions.
|
||||||
|
//
|
||||||
|
// Returns: { sources: Array<{name, diffusePath, heightPath?}>, errors: Array<string> }
|
||||||
|
// `name` = filename without "_diffuse.<ext>" suffix.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const SUPPORTED_EXT = ['.png', '.webp', '.jpg', '.jpeg'];
|
||||||
|
|
||||||
|
function stripSuffix(filename, suffix) {
|
||||||
|
for (const ext of SUPPORTED_EXT) {
|
||||||
|
const full = `${suffix}${ext}`;
|
||||||
|
if (filename.toLowerCase().endsWith(full)) {
|
||||||
|
return filename.slice(0, -full.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanSourceDir(srcDir) {
|
||||||
|
if (!fs.existsSync(srcDir)) {
|
||||||
|
return { sources: [], errors: [`scan: source dir does not exist: ${srcDir}`] };
|
||||||
|
}
|
||||||
|
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
||||||
|
const diffuseMap = new Map(); // name -> path
|
||||||
|
const heightMap = new Map(); // name -> path
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const fn = entry.name;
|
||||||
|
const dName = stripSuffix(fn, '_diffuse');
|
||||||
|
if (dName !== null) {
|
||||||
|
diffuseMap.set(dName, path.join(srcDir, fn));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hName = stripSuffix(fn, '_height');
|
||||||
|
if (hName !== null) {
|
||||||
|
heightMap.set(hName, path.join(srcDir, fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
for (const hName of heightMap.keys()) {
|
||||||
|
if (!diffuseMap.has(hName)) {
|
||||||
|
errors.push(`scan: height without paired diffuse: ${hName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name for determinism
|
||||||
|
const names = [...diffuseMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||||
|
const sources = names.map(name => ({
|
||||||
|
name,
|
||||||
|
diffusePath: diffuseMap.get(name),
|
||||||
|
heightPath: heightMap.get(name) || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
errors.push(`scan: no *_diffuse.{png,webp,jpg,jpeg} found in: ${srcDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sources, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { scanSourceDir };
|
||||||
40
tests/fixtures/_setup.js
vendored
Normal file
40
tests/fixtures/_setup.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// tests/fixtures/_setup.js
|
||||||
|
// Run once to create test fixtures. Not part of regular tests.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { PNG } = require('pngjs');
|
||||||
|
|
||||||
|
function writeSolidPng(filePath, w, h, rgba) {
|
||||||
|
const png = new PNG({ width: w, height: h });
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const i = (y * w + x) * 4;
|
||||||
|
png.data[i + 0] = rgba[0];
|
||||||
|
png.data[i + 1] = rgba[1];
|
||||||
|
png.data[i + 2] = rgba[2];
|
||||||
|
png.data[i + 3] = rgba[3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, PNG.sync.write(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSolidL8Png(filePath, w, h, v) {
|
||||||
|
const png = new PNG({ width: w, height: h, colorType: 0 }); // grayscale
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
png.data[(y * w + x) * 4 + 0] = v;
|
||||||
|
png.data[(y * w + x) * 4 + 1] = v;
|
||||||
|
png.data[(y * w + x) * 4 + 2] = v;
|
||||||
|
png.data[(y * w + x) * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, PNG.sync.write(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.resolve(__dirname, 'small-fresh');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
writeSolidPng(path.join(dir, 'grass_diffuse.png'), 32, 32, [110, 170, 80, 255]);
|
||||||
|
writeSolidPng(path.join(dir, 'stone_diffuse.png'), 32, 32, [120, 120, 120, 255]);
|
||||||
|
writeSolidPng(path.join(dir, 'water_diffuse.png'), 32, 32, [ 60, 110, 180, 255]);
|
||||||
|
writeSolidL8Png(path.join(dir, 'water_height.png'), 32, 32, 0); // L8=0 plane
|
||||||
|
console.log('fixtures written: small-fresh/');
|
||||||
BIN
tests/fixtures/small-fresh/grass_diffuse.png
vendored
Normal file
BIN
tests/fixtures/small-fresh/grass_diffuse.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
BIN
tests/fixtures/small-fresh/stone_diffuse.png
vendored
Normal file
BIN
tests/fixtures/small-fresh/stone_diffuse.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 B |
BIN
tests/fixtures/small-fresh/water_diffuse.png
vendored
Normal file
BIN
tests/fixtures/small-fresh/water_diffuse.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
BIN
tests/fixtures/small-fresh/water_height.png
vendored
Normal file
BIN
tests/fixtures/small-fresh/water_height.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
28
tests/scan.test.js
Normal file
28
tests/scan.test.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// tests/scan.test.js
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { scanSourceDir } = require('../src/scan');
|
||||||
|
|
||||||
|
const FIXTURE_DIR = path.resolve(__dirname, 'fixtures/small-fresh');
|
||||||
|
|
||||||
|
test('scan: 3 diffuse + 1 paired height', () => {
|
||||||
|
const { sources, errors } = scanSourceDir(FIXTURE_DIR);
|
||||||
|
assert.deepStrictEqual(errors, []);
|
||||||
|
assert.strictEqual(sources.length, 3);
|
||||||
|
// Sorted alphabetically
|
||||||
|
assert.strictEqual(sources[0].name, 'grass');
|
||||||
|
assert.strictEqual(sources[1].name, 'stone');
|
||||||
|
assert.strictEqual(sources[2].name, 'water');
|
||||||
|
// grass + stone have no height
|
||||||
|
assert.strictEqual(sources[0].heightPath, null);
|
||||||
|
assert.strictEqual(sources[1].heightPath, null);
|
||||||
|
// water has height
|
||||||
|
assert.ok(sources[2].heightPath?.endsWith('water_height.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan: empty dir -> error', () => {
|
||||||
|
const { sources, errors } = scanSourceDir(path.resolve(__dirname, 'fixtures/_empty'));
|
||||||
|
assert.strictEqual(sources.length, 0);
|
||||||
|
assert.ok(errors.length > 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user