prototype-blob-geom: migrate bake pipeline to node atlas-baker v0.2.0
Python atlas_pack.py was a stop-gap substitute while davoryn was
down (preventing pull of the real node baker). With sporel-tool-
atlas-baker v0.2.0 shipping padding + blob-14 schema validation +
collision sidecar pass-through + alpha-analysis opaque flag, the
Python substitute is no longer needed.
Changes:
- scripts/atlas_pack.py removed
- scripts/bake_testbench.py: drop atlas_pack import + pack() call;
now produces source PNGs only. Node baker does the packing.
- scripts/bake_singles.py: same simplification, plus copies the
shared blob_rectilinear collision.json into each single-material
source dir (so atlas-baker E2 validation finds the sidecar).
- scripts/bake.sh: rewritten to call the node baker for all 8
atlases. Single-material variants (4 base styles + 3 tinted
singles) bake with `--schema blob-14` for E1+E2 validation.
Mixed-material blob_testbench bakes without --schema (42 tiles
don't match the canonical 14-slot pattern).
- 3 collision.json copies committed under assets/_sources/blob_
rect_{stone,grass,wood}/ — same content as the shared
blob_rectilinear one (since these single-material atlases share
the same blob geometry).
Single codepath for atlas production now: node baker only. No more
two-tool drift. Future-Python-changes to source generation auto-
flow through to baked atlases via bake.sh.
End-to-end verification: bake.sh produces 8 atlases cleanly; all
single-material bakes pass --schema blob-14 + collision validation;
opaque flag set on slot_13_solid (alpha-all-255), absent on the
other slots (transparent regions). SPOREL_CI=1 vagrant-skeleton
rc=0 with both blob_testbench (legacy multi-material) and
blob_rect_stone (vertex-painted single-material) loading correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""atlas_pack.py - minimal pure-Python substitute for sporel-tool-atlas-baker.
|
||||
|
||||
Packs a directory of <name>_diffuse.png sources into the Sporel runtime
|
||||
atlas format expected by lib-core.maps:
|
||||
|
||||
<out_dir>/
|
||||
tiles.atlas.json ← metadata (atlas_id, atlas_size_px, tile_size_px, tiles[])
|
||||
tiles.atlas.lock.json ← name → id binding + next_id
|
||||
tiles.diffuse.atlas.png ← packed RGBA atlas
|
||||
tiles.height.atlas.png ← 1x1 transparent placeholder (engine reads via pcall)
|
||||
|
||||
Tile entries get sequential integer IDs starting at 1, alphabetical by
|
||||
source filename for stable ordering. Each tile may carry walkable +
|
||||
optional `name_to_walkable` overrides via the API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _pad_edge_replicate(sprite: Image.Image, p: int) -> Image.Image:
|
||||
"""Wrap a sprite with `p` pixels of edge-replicated padding on all
|
||||
sides. The 4 edges + 4 corners are filled by stretching a 1-pixel
|
||||
strip outward (NEAREST resampling). This prevents bilinear sampling
|
||||
near tile boundaries from leaking into neighbouring atlas tiles."""
|
||||
if p <= 0:
|
||||
return sprite
|
||||
w, h = sprite.size
|
||||
pw, ph = w + 2 * p, h + 2 * p
|
||||
new = Image.new(sprite.mode, (pw, ph), (0, 0, 0, 0))
|
||||
new.paste(sprite, (p, p))
|
||||
# 4 edges.
|
||||
top = sprite.crop((0, 0, w, 1)).resize((w, p), Image.Resampling.NEAREST)
|
||||
new.paste(top, (p, 0))
|
||||
bot = sprite.crop((0, h - 1, w, h)).resize((w, p), Image.Resampling.NEAREST)
|
||||
new.paste(bot, (p, h + p))
|
||||
left = sprite.crop((0, 0, 1, h)).resize((p, h), Image.Resampling.NEAREST)
|
||||
new.paste(left, (0, p))
|
||||
right = sprite.crop((w - 1, 0, w, h)).resize((p, h), Image.Resampling.NEAREST)
|
||||
new.paste(right, (w + p, p))
|
||||
# 4 corners.
|
||||
for (sx, sy, dx, dy) in [
|
||||
(0, 0, 0, 0),
|
||||
(w - 1, 0, w + p, 0),
|
||||
(0, h - 1, 0, h + p),
|
||||
(w - 1, h - 1, w + p, h + p),
|
||||
]:
|
||||
corner = sprite.crop((sx, sy, sx + 1, sy + 1)).resize((p, p), Image.Resampling.NEAREST)
|
||||
new.paste(corner, (dx, dy))
|
||||
return new
|
||||
|
||||
|
||||
def pack(
|
||||
source_dir: str,
|
||||
out_dir: str,
|
||||
atlas_id: str,
|
||||
tile_size_px: int,
|
||||
walkable_fn: Optional[Callable[[str], bool]] = None,
|
||||
cols: Optional[int] = None,
|
||||
pad_px: int = 2,
|
||||
) -> None:
|
||||
"""Pack all <name>_diffuse.png in source_dir into a single atlas.
|
||||
|
||||
walkable_fn(tile_name) -> bool decides per-tile walkability; defaults
|
||||
to False if not supplied (safe for wall-like tiles, override for
|
||||
floors). cols controls grid width; default = ceil(sqrt(N)).
|
||||
|
||||
pad_px: edge-replicated padding around each tile in the packed
|
||||
atlas. Prevents bilinear-filtering bleed between neighbouring atlas
|
||||
tiles when sprites are rendered near their UV boundary. UV coords
|
||||
in the metadata still point to the inner non-padded region.
|
||||
"""
|
||||
sources = sorted(
|
||||
f for f in os.listdir(source_dir) if f.endswith("_diffuse.png")
|
||||
)
|
||||
if not sources:
|
||||
raise RuntimeError(f"no _diffuse.png files in {source_dir}")
|
||||
|
||||
n = len(sources)
|
||||
if cols is None:
|
||||
import math
|
||||
cols = math.ceil(math.sqrt(n))
|
||||
rows = (n + cols - 1) // cols
|
||||
stride = tile_size_px + 2 * pad_px # per-tile cell stride including padding
|
||||
atlas_w = cols * stride
|
||||
atlas_h = rows * stride
|
||||
|
||||
atlas = Image.new("RGBA", (atlas_w, atlas_h), (0, 0, 0, 0))
|
||||
|
||||
tiles_meta = []
|
||||
bindings = {}
|
||||
|
||||
for idx, fname in enumerate(sources):
|
||||
name = fname[: -len("_diffuse.png")]
|
||||
col = idx % cols
|
||||
row = idx // cols
|
||||
cell_x = col * stride
|
||||
cell_y = row * stride
|
||||
|
||||
sprite = Image.open(os.path.join(source_dir, fname)).convert("RGBA")
|
||||
if sprite.size != (tile_size_px, tile_size_px):
|
||||
sprite = sprite.resize((tile_size_px, tile_size_px), resample=Image.Resampling.NEAREST)
|
||||
padded = _pad_edge_replicate(sprite, pad_px)
|
||||
atlas.paste(padded, (cell_x, cell_y))
|
||||
|
||||
# UV points to the inner non-padded region.
|
||||
uv_x = cell_x + pad_px
|
||||
uv_y = cell_y + pad_px
|
||||
tile_id = idx + 1
|
||||
bindings[name] = tile_id
|
||||
walkable = walkable_fn(name) if walkable_fn else False
|
||||
tiles_meta.append({
|
||||
"id": tile_id,
|
||||
"name": name,
|
||||
"uv": [uv_x, uv_y, tile_size_px, tile_size_px],
|
||||
"walkable": walkable,
|
||||
})
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
atlas.save(os.path.join(out_dir, "tiles.diffuse.atlas.png"))
|
||||
|
||||
# Height placeholder: 1x1 transparent. Engine reads via pcall and tolerates absence,
|
||||
# but shipping a real PNG avoids a confusing "asset load failed" warning in logs.
|
||||
Image.new("RGBA", (1, 1), (0, 0, 0, 0)).save(
|
||||
os.path.join(out_dir, "tiles.height.atlas.png")
|
||||
)
|
||||
|
||||
atlas_json = {
|
||||
"atlas_id": atlas_id,
|
||||
"atlas_version": 1,
|
||||
"atlas_size_px": [atlas_w, atlas_h],
|
||||
"tile_size_px": tile_size_px,
|
||||
"tiles": tiles_meta,
|
||||
}
|
||||
with open(os.path.join(out_dir, "tiles.atlas.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(atlas_json, f, indent=2)
|
||||
|
||||
lock_json = {
|
||||
"atlas_id": atlas_id,
|
||||
"bindings": bindings,
|
||||
"deleted": [],
|
||||
"next_id": n + 1,
|
||||
}
|
||||
with open(os.path.join(out_dir, "tiles.atlas.lock.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(lock_json, f, indent=2)
|
||||
|
||||
print(
|
||||
f" packed {n} tiles ({cols}x{rows} grid, pad={pad_px}px, "
|
||||
f"{atlas_w}x{atlas_h} px) -> {out_dir}"
|
||||
)
|
||||
@@ -1,25 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# bake.sh - regenerate procedural source sprites + reference sheets,
|
||||
# then pack each of the 4 style atlases via sporel-tool-atlas-baker.
|
||||
#
|
||||
# Idempotent: re-running overwrites all generated outputs.
|
||||
# bake.sh - regenerate source PNGs (+ collision.json + reference sheets +
|
||||
# color showcase) via Python, then pack each atlas via sporel-tool-
|
||||
# atlas-baker (Node). The node baker validates blob-14 schema for the
|
||||
# single-material atlases (E1) + collision.json sidecar (E2) +
|
||||
# computes per-tile opaque flag via alpha-analysis. Atlases ship with
|
||||
# 2 px edge-replicated padding (Rev 4 §13.3) to prevent bilinear bleed.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIB_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BAKER=~/Projects/Sporel/sporel-tool-atlas-baker/bin/atlas-baker.js
|
||||
|
||||
# 1) Regenerate sources for all 4 styles (diffuse PNGs + collision.json)
|
||||
# + the per-style reference sheets + the color showcase.
|
||||
# Ensure node is on PATH (Windows winget-installed Node lives here).
|
||||
export PATH="/c/Program Files/nodejs:$PATH"
|
||||
|
||||
# 1) Regenerate sources for all 4 base styles (+ collision.json + the
|
||||
# per-style reference sheets + the color showcase) via Python.
|
||||
python "$SCRIPT_DIR/generate.py"
|
||||
python "$SCRIPT_DIR/reference_sheet.py"
|
||||
python "$SCRIPT_DIR/color_showcase.py"
|
||||
python "$SCRIPT_DIR/bake_testbench.py"
|
||||
python "$SCRIPT_DIR/bake_singles.py"
|
||||
|
||||
# 2) Pack each style into its own runtime atlas.
|
||||
for style in rectilinear diagonal_cut organic concave; do
|
||||
atlas_id="blob_${style}"
|
||||
# 2) Pack each atlas via the node baker.
|
||||
# Single-material blob-14 atlases (4 styles + 3 tinted singles).
|
||||
# All ship --schema blob-14 so E1 (14-slot validation) + E2 (collision
|
||||
# sidecar validation) run + the collision.json is copied to the atlas
|
||||
# output as tiles.collision.json.
|
||||
for atlas_id in \
|
||||
blob_rectilinear blob_diagonal_cut blob_organic blob_concave \
|
||||
blob_rect_stone blob_rect_grass blob_rect_wood; do
|
||||
node "$BAKER" \
|
||||
--in "$LIB_DIR/assets/_sources/${atlas_id}" \
|
||||
--out "$LIB_DIR/assets/atlases/${atlas_id}" \
|
||||
--atlas-id "${atlas_id}" \
|
||||
--tile-size auto
|
||||
--in "$LIB_DIR/assets/_sources/${atlas_id}" \
|
||||
--out "$LIB_DIR/assets/atlases/${atlas_id}" \
|
||||
--atlas-id "${atlas_id}" \
|
||||
--tile-size 64 \
|
||||
--schema blob-14
|
||||
done
|
||||
|
||||
# Multi-material testbench: 3 materials × 14 slots = 42 tiles in one
|
||||
# atlas. NOT --schema blob-14 (the 42-tile layout doesn't match the
|
||||
# canonical 14-slot enumeration). Collision.json absent → baker
|
||||
# emits no tiles.collision.json sidecar.
|
||||
node "$BAKER" \
|
||||
--in "$LIB_DIR/assets/_sources/blob_testbench" \
|
||||
--out "$LIB_DIR/assets/atlases/blob_testbench" \
|
||||
--atlas-id blob_testbench \
|
||||
--tile-size 64
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""bake_singles.py - bake single-material atlases for vertex-painted
|
||||
autotile layers. Each material is one tinted 14-slot atlas, separate
|
||||
from the mixed-material blob_testbench atlas.
|
||||
"""bake_singles.py - emit single-material source PNGs (+ collision.json)
|
||||
for vertex-painted autotile layers. Each material is one tinted 14-slot
|
||||
source-dir; the node atlas-baker packs them into atlases via bake.sh.
|
||||
|
||||
Atlases produced (under assets/atlases/blob_rect_<material>/):
|
||||
Atlases produced (via the downstream baker invocation):
|
||||
- blob_rect_stone (dark gray, walls/floors)
|
||||
- blob_rect_grass (mid green, organic)
|
||||
- blob_rect_wood (light brown, paths)
|
||||
|
||||
Filenames inside each atlas use the canonical slot_NN_<name> naming so
|
||||
the runtime can build a slot->tile_id index by parsing tile names.
|
||||
Source dirs (this script's output): assets/_sources/blob_rect_<material>/
|
||||
contain 14 PNGs named slot_NN_<canonical>_diffuse.png plus a copy of
|
||||
the shared collision.json (rectilinear geometry, identical across
|
||||
materials since they share the same blob shape).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from generate import SLOTS, render_slot, TILE_PX # noqa: E402
|
||||
from atlas_pack import pack # noqa: E402
|
||||
|
||||
MATERIALS = [
|
||||
("stone", (40, 44, 56)),
|
||||
@@ -43,10 +45,18 @@ def tint(img: Image.Image, rgb: tuple[int, int, int]) -> Image.Image:
|
||||
def main() -> None:
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
lib_dir = os.path.normpath(os.path.join(script_dir, ".."))
|
||||
# Shared collision.json source (written by generate.py for rectilinear).
|
||||
shared_collision = os.path.join(
|
||||
lib_dir, "assets", "_sources", "blob_rectilinear", "collision.json"
|
||||
)
|
||||
if not os.path.exists(shared_collision):
|
||||
raise RuntimeError(
|
||||
f"missing {shared_collision} — run generate.py first"
|
||||
)
|
||||
|
||||
for material, rgb in MATERIALS:
|
||||
atlas_id = f"blob_rect_{material}"
|
||||
src_dir = os.path.join(lib_dir, "assets", "_sources", atlas_id)
|
||||
out_dir = os.path.join(lib_dir, "assets", "atlases", atlas_id)
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
|
||||
# Emit 14 tinted source PNGs with canonical slot_NN naming.
|
||||
@@ -55,22 +65,11 @@ def main() -> None:
|
||||
fname = f"slot_{slot:02d}_{slot_name}_diffuse.png"
|
||||
img.save(os.path.join(src_dir, fname))
|
||||
|
||||
# Walkability: slot_00 (isolated) is non-walkable for stone (decorative
|
||||
# pillar); all others material-dependent. Stone = all non-walkable
|
||||
# (walls). Grass/wood = walkable except isolated.
|
||||
def walk_fn(name, _mat=material):
|
||||
if _mat == "stone":
|
||||
return False
|
||||
return "_isolated" not in name
|
||||
# Copy the shared rectilinear collision.json so the atlas-baker
|
||||
# E2 validation finds it sibling to the diffuse sources.
|
||||
shutil.copyfile(shared_collision, os.path.join(src_dir, "collision.json"))
|
||||
|
||||
pack(
|
||||
source_dir=src_dir,
|
||||
out_dir=out_dir,
|
||||
atlas_id=atlas_id,
|
||||
tile_size_px=TILE_PX,
|
||||
walkable_fn=walk_fn,
|
||||
cols=14, # one row
|
||||
)
|
||||
print(f" wrote {len(SLOTS)} sprites + collision.json -> {src_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,7 +16,6 @@ import os
|
||||
from PIL import Image
|
||||
|
||||
from generate import SLOTS, render_slot, TILE_PX # noqa: E402
|
||||
from atlas_pack import pack # noqa: E402
|
||||
|
||||
# (material_name, FG tint rgb) — same palette as color_showcase.py.
|
||||
TESTBENCH_MATERIALS = [
|
||||
@@ -58,25 +57,15 @@ def main() -> None:
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
|
||||
# 1) Write the 42 tinted source PNGs.
|
||||
# Write the 42 tinted source PNGs. Atlas-packing is delegated to
|
||||
# sporel-tool-atlas-baker (Node) — see scripts/bake.sh.
|
||||
print(f"Writing {len(TESTBENCH_MATERIALS) * len(SLOTS)} tinted sprites -> {src_dir}")
|
||||
for material, rgb in TESTBENCH_MATERIALS:
|
||||
for slot, slot_name, pattern in SLOTS:
|
||||
img = tinted(pattern, rgb)
|
||||
fname = f"{material}_slot_{slot:02d}_{slot_name}_diffuse.png"
|
||||
img.save(os.path.join(src_dir, fname))
|
||||
|
||||
# 2) Pack into the runtime atlas. Use 14 cols x 3 rows so each row is
|
||||
# one material's slot strip (visually navigable when inspecting the
|
||||
# baked PNG directly).
|
||||
pack(
|
||||
source_dir=src_dir,
|
||||
out_dir=out_dir,
|
||||
atlas_id=ATLAS_ID,
|
||||
tile_size_px=TILE_PX,
|
||||
walkable_fn=is_walkable,
|
||||
cols=14,
|
||||
)
|
||||
print(f"out_dir kept for reference: {out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user