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:
calic
2026-05-28 22:55:05 +02:00
parent 247cdf0575
commit 1a99e22e1f
7 changed files with 871 additions and 204 deletions

View File

@@ -0,0 +1,270 @@
{
"schema": "blob-14",
"tile_size_px": 64,
"sub_cell_px": 16,
"shape_kind": "rect_list",
"_note": "Collision matches the rectilinear visual exactly. For diagonal_cut/organic/concave styles, the visible boundary deviates by up to 5 px at convex external corners \u2014 acceptable for typical gameplay collision. Modders needing pixel-perfect physics ship per-style polygon overrides.",
"slots": [
{
"slot": 0,
"name": "isolated",
"bitmask": "0b00000000",
"rects": [
[
16,
16,
32,
32
]
]
},
{
"slot": 1,
"name": "end",
"bitmask": "0b00000001",
"rects": [
[
16,
0,
32,
48
]
]
},
{
"slot": 2,
"name": "corner_open",
"bitmask": "0b00000101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
]
]
},
{
"slot": 3,
"name": "corner_full",
"bitmask": "0b00000111",
"rects": [
[
16,
0,
48,
48
]
]
},
{
"slot": 4,
"name": "straight",
"bitmask": "0b00010001",
"rects": [
[
16,
0,
32,
64
]
]
},
{
"slot": 5,
"name": "tee_open",
"bitmask": "0b00010101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 6,
"name": "tee_half",
"bitmask": "0b00010111",
"rects": [
[
16,
0,
48,
48
],
[
16,
48,
32,
16
]
]
},
{
"slot": 7,
"name": "tee_full",
"bitmask": "0b00011111",
"rects": [
[
16,
0,
48,
64
]
]
},
{
"slot": 8,
"name": "cross_open",
"bitmask": "0b01010101",
"rects": [
[
16,
0,
32,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 9,
"name": "cross_q1",
"bitmask": "0b01010111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 10,
"name": "cross_q2adj",
"bitmask": "0b01011111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
48,
16
]
]
},
{
"slot": 11,
"name": "cross_q2opp",
"bitmask": "0b01110111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
0,
48,
48,
16
]
]
},
{
"slot": 12,
"name": "cross_q3",
"bitmask": "0b01111111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
48
]
]
},
{
"slot": 13,
"name": "solid",
"bitmask": "0b11111111",
"rects": [
[
0,
0,
64,
64
]
]
}
],
"style": "rectilinear"
}

View File

@@ -0,0 +1,270 @@
{
"schema": "blob-14",
"tile_size_px": 64,
"sub_cell_px": 16,
"shape_kind": "rect_list",
"_note": "Collision matches the rectilinear visual exactly. For diagonal_cut/organic/concave styles, the visible boundary deviates by up to 5 px at convex external corners \u2014 acceptable for typical gameplay collision. Modders needing pixel-perfect physics ship per-style polygon overrides.",
"slots": [
{
"slot": 0,
"name": "isolated",
"bitmask": "0b00000000",
"rects": [
[
16,
16,
32,
32
]
]
},
{
"slot": 1,
"name": "end",
"bitmask": "0b00000001",
"rects": [
[
16,
0,
32,
48
]
]
},
{
"slot": 2,
"name": "corner_open",
"bitmask": "0b00000101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
]
]
},
{
"slot": 3,
"name": "corner_full",
"bitmask": "0b00000111",
"rects": [
[
16,
0,
48,
48
]
]
},
{
"slot": 4,
"name": "straight",
"bitmask": "0b00010001",
"rects": [
[
16,
0,
32,
64
]
]
},
{
"slot": 5,
"name": "tee_open",
"bitmask": "0b00010101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 6,
"name": "tee_half",
"bitmask": "0b00010111",
"rects": [
[
16,
0,
48,
48
],
[
16,
48,
32,
16
]
]
},
{
"slot": 7,
"name": "tee_full",
"bitmask": "0b00011111",
"rects": [
[
16,
0,
48,
64
]
]
},
{
"slot": 8,
"name": "cross_open",
"bitmask": "0b01010101",
"rects": [
[
16,
0,
32,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 9,
"name": "cross_q1",
"bitmask": "0b01010111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 10,
"name": "cross_q2adj",
"bitmask": "0b01011111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
48,
16
]
]
},
{
"slot": 11,
"name": "cross_q2opp",
"bitmask": "0b01110111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
0,
48,
48,
16
]
]
},
{
"slot": 12,
"name": "cross_q3",
"bitmask": "0b01111111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
48
]
]
},
{
"slot": 13,
"name": "solid",
"bitmask": "0b11111111",
"rects": [
[
0,
0,
64,
64
]
]
}
],
"style": "rectilinear"
}

View File

@@ -0,0 +1,270 @@
{
"schema": "blob-14",
"tile_size_px": 64,
"sub_cell_px": 16,
"shape_kind": "rect_list",
"_note": "Collision matches the rectilinear visual exactly. For diagonal_cut/organic/concave styles, the visible boundary deviates by up to 5 px at convex external corners \u2014 acceptable for typical gameplay collision. Modders needing pixel-perfect physics ship per-style polygon overrides.",
"slots": [
{
"slot": 0,
"name": "isolated",
"bitmask": "0b00000000",
"rects": [
[
16,
16,
32,
32
]
]
},
{
"slot": 1,
"name": "end",
"bitmask": "0b00000001",
"rects": [
[
16,
0,
32,
48
]
]
},
{
"slot": 2,
"name": "corner_open",
"bitmask": "0b00000101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
]
]
},
{
"slot": 3,
"name": "corner_full",
"bitmask": "0b00000111",
"rects": [
[
16,
0,
48,
48
]
]
},
{
"slot": 4,
"name": "straight",
"bitmask": "0b00010001",
"rects": [
[
16,
0,
32,
64
]
]
},
{
"slot": 5,
"name": "tee_open",
"bitmask": "0b00010101",
"rects": [
[
16,
0,
32,
16
],
[
16,
16,
48,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 6,
"name": "tee_half",
"bitmask": "0b00010111",
"rects": [
[
16,
0,
48,
48
],
[
16,
48,
32,
16
]
]
},
{
"slot": 7,
"name": "tee_full",
"bitmask": "0b00011111",
"rects": [
[
16,
0,
48,
64
]
]
},
{
"slot": 8,
"name": "cross_open",
"bitmask": "0b01010101",
"rects": [
[
16,
0,
32,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 9,
"name": "cross_q1",
"bitmask": "0b01010111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
32,
16
]
]
},
{
"slot": 10,
"name": "cross_q2adj",
"bitmask": "0b01011111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
16,
48,
48,
16
]
]
},
{
"slot": 11,
"name": "cross_q2opp",
"bitmask": "0b01110111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
32
],
[
0,
48,
48,
16
]
]
},
{
"slot": 12,
"name": "cross_q3",
"bitmask": "0b01111111",
"rects": [
[
16,
0,
48,
16
],
[
0,
16,
64,
48
]
]
},
{
"slot": 13,
"name": "solid",
"bitmask": "0b11111111",
"rects": [
[
0,
0,
64,
64
]
]
}
],
"style": "rectilinear"
}

View File

@@ -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}"
)

View File

@@ -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

View File

@@ -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__":

View File

@@ -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__":