initial commit

This commit is contained in:
DramaticShape
2026-07-26 16:43:53 -04:00
commit 702d8049f0
136 changed files with 31933 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env python3
"""Build voxel models for the overworld sprites (voxel world mode, Tier 3).
An AUTHORING tool, run by hand against a machine that has imported its own
ROM. It reads the extracted overworld sprite sheets out of the local cache
and emits, per sheet, the mod's own assets/voxels/<name>.lua -- a face list
the runtime (lib/VoxelModels.lua) turns into a LOVE Mesh.
The emitted model is geometry only: it names no sheet and bakes no pixel,
so what ships is the mod's own carving, not cache content (MK301). The
runtime pairs a model with the LIVE sheet the engine already handed it.
Core idea
---------
A GB overworld sheet gives THREE orthographic silhouettes of one character:
facing down (front), facing up (back), facing left (side; right is the
mirror). Three orthogonal views is exactly what visual-hull / space carving
needs:
solid(x, y, z) = (front[y, x] OR back'[y, x]) AND side[y, z]
where back' is the back view mirrored horizontally so it registers with the
front (walk behind someone and left/right swap).
Note the OR. The front and back constrain the SAME axis -- they are two
observations of one [x, y] outline -- so ANDing them erodes the model
wherever the two frames disagree, and GB walk art disagrees often (Seel's
back frame splays its flippers where the front frame has body). Measured
over all 67 sheets, the union is never worse on reprojection IoU and better
on 16, and it fails toward "slightly fat" rather than "chunks missing",
which is the right failure mode for voxel art: 51 sheets whose two frames
agree exactly are bit-identical either way. Only the orthogonal side view
actually carves depth, and it still ANDs. `--silhouette intersect` restores
the strict three-way hull.
Carving grid (sprite-pixel aligned):
x = front-sprite column y = sprite row, 0 = top
z = side-sprite column, 0 = the character's front (nose / hat brim)
Faces are exported in MODEL space, which is the runtime's world convention
(X = map east, Y = up, Z = map south) with the character facing +Z (south,
"down") at rest, so the runtime only has to yaw by facing:
mx = x my = 15 - y mz = 15 - z
Texturing
---------
A face does NOT carry a baked color. It carries the sheet pixel (u, v) of
the view it faces, so the runtime samples the LIVE sprite sheet image. That
is what makes RED++ OBJ-palette recolors (SpriteRenderer's OBP bake) and
mod sprite replacements color the voxel model for free, with no rebuild.
Pipeline
--------
1. decode PNG (RGBA, GB 4-shade) or raw 2bpp -> shade indices 0..3
2. alpha the PNG alpha channel when the source has one (extracted
sheets key GB OBJ color 0 to alpha 0, matching the hardware
rule that OBJ palette index 0 is always transparent); raw
2bpp has no alpha, so it falls back to flood-filling shade 0
in from the frame border
3. anchor bottom-center align each view's opaque bbox (feet on ground)
so views authored with different padding still register
4. carve intersect the three extruded silhouettes
5. clean keep the largest 6-connected component (kills carve speckle)
6. faces emit every exposed voxel face with the sheet pixel it samples
7. export <name>.lua (+ optional .vox / .ply for authoring in
MagicaVoxel / Blender)
8. validate re-project the hull along each axis, report IoU against the
source silhouettes -- the hull can only shrink, so IoU < 1
flags real loss (misregistered views, over-carving)
Poses
-----
6-frame sheets (16x96) carry stand down/up/left in frames 0,1,2 and walk
down/up/left in 3,4,5 -> a "stand" and a "walk" model.
3-frame sheets (16x48) are stand-only.
1-frame sheets (16x16, props: boulder / fossil / clipboard) have no other
view at all, so the side silhouette is the mirrored front -- which carves
the symmetric solid a prop reads as.
Usage
-----
python3 tools/build_voxels.py # whole sprite dir
python3 tools/build_voxels.py --only red,oak
python3 tools/build_voxels.py --debug-exports -o /tmp/vox
"""
from __future__ import annotations
import argparse
import struct
import sys
from collections import deque
from pathlib import Path
import numpy as np
from PIL import Image
FRAME = 16 # overworld sprites are 16x16
SHADES = 4 # GB is 2bpp
# the author's own imported cache is the INPUT to this build step; only the
# carved output below is what the mod ships
SPRITE_DIR = Path("assets/generated/sprites")
# ...and it lands in the mod's own asset tree, never back in the cache
VOXEL_DIR = Path("mods/DRAMATIC_SHAPE/assets/voxels")
# shade index (0 = lightest) -> RGBA, for the .vox / .ply debug exports only
# (the runtime textures from the live sheet instead -- see module docstring)
PALETTES = {
"gray": [(248, 248, 248, 255), (168, 168, 168, 255),
(88, 88, 88, 255), (16, 16, 16, 255)],
"gb": [(224, 248, 208, 255), (136, 192, 112, 255),
(52, 104, 86, 255), (8, 24, 32, 255)],
"sgb": [(255, 239, 206, 255), (222, 148, 74, 255),
(173, 41, 33, 255), (49, 24, 82, 255)],
}
# ---------------------------------------------------------------- decoding --
def decode_2bpp(data: bytes, tiles_wide: int = 2) -> np.ndarray:
"""Raw GB 2bpp -> 2D array of shade indices. 16 bytes per 8x8 tile, two
bytes per row: byte0 = low bitplane, byte1 = high bitplane."""
ntiles = len(data) // 16
tiles = []
for t in range(ntiles):
chunk = data[t * 16:(t + 1) * 16]
tile = np.zeros((8, 8), np.uint8)
for row in range(8):
lo, hi = chunk[row * 2], chunk[row * 2 + 1]
for bit in range(8):
tile[row, bit] = (((hi >> (7 - bit)) & 1) << 1) | \
((lo >> (7 - bit)) & 1)
tiles.append(tile)
rows = [np.hstack(tiles[i:i + tiles_wide])
for i in range(0, ntiles, tiles_wide)]
return np.vstack(rows)
def load_sheet(path: Path):
"""Load a sheet as (shade indices 0..3, alpha mask or None).
The extracted PNGs are RGBA with GB OBJ color 0 written as transparent
white, so their alpha channel IS the opacity mask -- exact, and what the
hardware does. `None` means the source carried no alpha (raw 2bpp, or a
flat grayscale PNG) and the caller must derive opacity itself."""
if path.suffix.lower() == ".2bpp":
return decode_2bpp(path.read_bytes()), None
arr = np.array(Image.open(path).convert("RGBA"))
lum = arr[..., 0].astype(np.int16) # GB art is gray: R == G == B
shade = np.zeros(lum.shape, np.uint8) # bucket; don't trust exact values
shade[lum < 213] = 1
shade[lum < 128] = 2
shade[lum < 43] = 3
alpha = arr[..., 3] > 0 if arr[..., 3].min() < 255 else None
return shade, alpha
def slice_frames(shade: np.ndarray, alpha):
"""Split a sheet into 16x16 frames, row-major over any grid layout.
Returns (shade, alpha_or_None, origin_x, origin_y) per frame; the origin
is the frame's top-left in sheet pixels, which is what the exported UVs
are relative to."""
h, w = shade.shape
out = []
for fy in range(h // FRAME):
for fx in range(w // FRAME):
sy, sx = fy * FRAME, fx * FRAME
sub = shade[sy:sy + FRAME, sx:sx + FRAME]
sub_a = alpha[sy:sy + FRAME, sx:sx + FRAME] if alpha is not None \
else None
out.append((sub, sub_a, sx, sy))
return out
# ------------------------------------------------------------------- alpha --
def flood_alpha(shade: np.ndarray) -> np.ndarray:
"""Fallback opacity for alpha-less sources: shade 0 is transparent ONLY
where it flood-connects to the frame border, so an enclosed light region
(a face, a hand) stays opaque instead of punching a hole through the
model."""
h, w = shade.shape
bg = np.zeros((h, w), bool)
dq = deque()
def seed(y, x):
if shade[y, x] == 0 and not bg[y, x]:
bg[y, x] = True
dq.append((y, x))
for x in range(w):
seed(0, x)
seed(h - 1, x)
for y in range(h):
seed(y, 0)
seed(y, w - 1)
while dq:
y, x = dq.popleft()
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
ny, nx = y + dy, x + dx
if 0 <= ny < h and 0 <= nx < w:
seed(ny, nx)
return ~bg
def anchor_bottom_center(shade: np.ndarray, alpha: np.ndarray):
"""Shift a frame so its opaque bbox sits bottom-anchored and horizontally
centered, returning (shade, alpha, dy, dx). Views are authored with
slightly different padding; without this the hull loses a pixel shell
wherever they disagree. The shifts come back out because the exported UVs
must point at the ORIGINAL sheet pixel, not the shifted one."""
ys, xs = np.nonzero(alpha)
if len(ys) == 0:
return shade, alpha, 0, 0
dy = (FRAME - 1) - int(ys.max())
dx = (FRAME - (int(xs.max()) - int(xs.min()) + 1)) // 2 - int(xs.min())
out_s = np.zeros_like(shade)
out_a = np.zeros_like(alpha)
out_s[ys + dy, xs + dx] = shade[ys, xs]
out_a[ys + dy, xs + dx] = True
return out_s, out_a, dy, dx
class View:
"""One aligned silhouette plus the mapping back to its sheet pixels.
`mirror` is applied BEFORE alignment: the back view is pre-mirrored so it
registers with the front (and a prop's synthetic side view is the
mirrored front), which keeps the carve and the UV lookup working in one
consistent aligned space with no second flip anywhere downstream."""
def __init__(self, shade, alpha, ox, oy, mirror=False):
if mirror:
shade = shade[:, ::-1].copy()
alpha = alpha[:, ::-1].copy()
self.shade, self.alpha, self.dy, self.dx = \
anchor_bottom_center(shade, alpha)
self.ox, self.oy, self.mirror = ox, oy, mirror
def opaque(self, row: int, col: int) -> bool:
return bool(self.alpha[row, col])
def uv(self, row: int, col: int):
"""Aligned (row, col) -> (u, v) sheet pixel. Callers only ask for
positions this view proved opaque (see `sample`), so the inverse
lands inside the frame; the clamp guards a degenerate frame."""
y0 = row - self.dy
x0 = col - self.dx
if self.mirror:
x0 = FRAME - 1 - x0
y0 = min(max(y0, 0), FRAME - 1)
x0 = min(max(x0, 0), FRAME - 1)
return self.ox + x0, self.oy + y0
# ------------------------------------------------------------------- carve --
def carve(front: View, side: View, back: View, mode="union") -> np.ndarray:
"""Visual hull: solid[x, y, z]. `back` is already registered with the
front (pre-mirrored at construction), so no flip here. The front/back
pair combines by `mode` (see the module docstring); the orthogonal side
view always intersects, since it is the only view carving depth."""
fb = front.alpha | back.alpha if mode == "union" else \
front.alpha & back.alpha
return fb.T[:, :, None] & side.alpha[None, :, :] # [x,y,1] & [1,y,z]
def largest_component(solid: np.ndarray) -> np.ndarray:
"""Keep only the largest 6-connected blob (drops carving speckle)."""
labels = np.zeros(solid.shape, np.int32)
best_id, best_n, cur = 0, 0, 0
for idx in zip(*np.nonzero(solid)):
if labels[idx]:
continue
cur += 1
n = 0
dq = deque([idx])
labels[idx] = cur
while dq:
x, y, z = dq.popleft()
n += 1
for dx, dy, dz in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
(0, -1, 0), (0, 0, 1), (0, 0, -1)):
nx, ny, nz = x + dx, y + dy, z + dz
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
and solid[nx, ny, nz] and not labels[nx, ny, nz]:
labels[nx, ny, nz] = cur
dq.append((nx, ny, nz))
if n > best_n:
best_id, best_n = cur, n
return labels == best_id
# ------------------------------------------------------------------- faces --
# carve-grid neighbour offset -> (model direction id, which view colors it).
# Model dirs: 1 = +X east, 2 = -X west, 3 = +Y up, 4 = -Y down,
# 5 = +Z south (toward the camera), 6 = -Z north (away).
# A profile face takes the side view -- and so do TOP faces: the camera in
# game looks steeply down, so tops dominate what you see, and the axis a
# top face varies along on screen is DEPTH (z). The side view is the one
# view that resolves depth -- its pixel at (row, z) puts cap-front red and
# hair-back black in the right z order. Sampling the front view there (the
# obvious first guess, and what shipped first) gives every top voxel of a
# row the same front pixel, which painted the entire head cap-color and
# turned every character into a red-topped blob.
FACE_DIRS = (
((1, 0, 0), 1, "side"),
((-1, 0, 0), 2, "side"),
((0, -1, 0), 3, "side"),
((0, 1, 0), 4, "front"),
((0, 0, -1), 5, "front"),
((0, 0, 1), 6, "back"),
)
def source_view(which, x, y, z, front: View, side: View, back: View):
"""The view a face takes its pixel from, as (view, row, col) in that
view's ALIGNED space, with a fallback chain.
Under the union rule a voxel can be proved solid by the back view alone,
so its front-facing face has no front pixel to take -- sampling one
anyway would read the frame's transparent background and punch a hole in
the model. Fall back to the opposite same-axis view, then to the side
view, which every solid voxel satisfies by construction (the side is the
one view that always intersects), so this always terminates on an opaque
pixel."""
if which == "side":
return side, y, z
first, second = (front, back) if which == "front" else (back, front)
if first.opaque(y, x):
return first, y, x
if second.opaque(y, x):
return second, y, x
return side, y, z
def build_faces(solid, front: View, side: View, back: View):
"""Every exposed voxel face as (mx, my, mz, dir, u, v) in model space.
The model's own underside (my == 0) is dropped: it sits flat on the
ground plane and can never be seen, and it is ~1 face in 8."""
faces = []
for x, y, z in zip(*np.nonzero(solid)):
x, y, z = int(x), int(y), int(z)
mx, my, mz = x, FRAME - 1 - y, FRAME - 1 - z
for (dx, dy, dz), d, which in FACE_DIRS:
nx, ny, nz = x + dx, y + dy, z + dz
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
and solid[nx, ny, nz]:
continue
if d == 4 and my == 0:
continue
v, r, c = source_view(which, x, y, z, front, side, back)
u, w = v.uv(r, c)
faces.append((mx, my, mz, d, u, w))
return faces
def colorize(solid, front: View, side: View, back: View) -> np.ndarray:
"""Per-voxel shade index for the .vox / .ply debug exports. Priority
front > side > back > top, matching the face order the runtime sees;
bottom-only voxels keep the darkest shade (they read as shadow). Shades
resolve through `source_view`'s fallback chain for the same reason the
exported UVs do -- a back-only voxel has no front pixel."""
shade = np.full(solid.shape, 3, np.uint8)
def free(x, y, z, dx, dy, dz):
nx, ny, nz = x + dx, y + dy, z + dz
return not (0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME
and solid[nx, ny, nz])
def shade_at(which, x, y, z):
v, r, c = source_view(which, x, y, z, front, side, back)
return v.shade[r, c]
for x, y, z in zip(*np.nonzero(solid)):
x, y, z = int(x), int(y), int(z)
if free(x, y, z, 0, 0, -1):
shade[x, y, z] = shade_at("front", x, y, z)
elif free(x, y, z, 1, 0, 0) or free(x, y, z, -1, 0, 0):
shade[x, y, z] = shade_at("side", x, y, z)
elif free(x, y, z, 0, 0, 1):
shade[x, y, z] = shade_at("back", x, y, z)
elif free(x, y, z, 0, -1, 0):
shade[x, y, z] = shade_at("side", x, y, z)
return shade
# -------------------------------------------------------------- validation --
def reprojection_iou(solid, front: View, side: View, back: View) -> dict:
"""Project the hull back along each axis against the source silhouettes.
Front and back project along the same axis (the back is pre-registered),
so they share a projection and differ only in what they are compared to."""
proj_fb = solid.any(axis=2).T # [y, x]
proj_side = solid.any(axis=0) # [y, z]
def iou(a, b):
u = (a | b).sum()
return float((a & b).sum()) / u if u else 1.0
return {"front": iou(proj_fb, front.alpha),
"side": iou(proj_side, side.alpha),
"back": iou(proj_fb, back.alpha)}
# ----------------------------------------------------------- debug exports --
def _chunk(cid: bytes, content: bytes, children: bytes = b"") -> bytes:
return cid + struct.pack("<ii", len(content), len(children)) \
+ content + children
def write_vox(path: Path, solid, shade, palette):
"""MagicaVoxel .vox (z-up). Model axes: vox_x = x, vox_y = depth,
vox_z = up."""
xs, ys, zs = np.nonzero(solid)
xyzi = struct.pack("<i", len(xs))
for x, y, z in zip(xs, ys, zs):
xyzi += struct.pack("<4B", int(x), int(FRAME - 1 - z),
int(FRAME - 1 - y), int(shade[x, y, z]) + 1)
pal = bytearray()
for i in range(255):
pal += bytes(palette[i]) if i < len(palette) else b"\x00\x00\x00\xff"
pal += b"\x00\x00\x00\x00"
children = _chunk(b"SIZE", struct.pack("<3i", FRAME, FRAME, FRAME)) \
+ _chunk(b"XYZI", xyzi) + _chunk(b"RGBA", bytes(pal))
path.write_bytes(b"VOX " + struct.pack("<i", 150)
+ _chunk(b"MAIN", b"", children))
_PLY_FACES = { # (dx,dy,dz) -> 4 corner offsets (unit cube, CCW from outside)
(0, 0, -1): [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
(0, 0, 1): [(1, 0, 1), (0, 0, 1), (0, 1, 1), (1, 1, 1)],
(-1, 0, 0): [(0, 0, 1), (0, 0, 0), (0, 1, 0), (0, 1, 1)],
(1, 0, 0): [(1, 0, 0), (1, 0, 1), (1, 1, 1), (1, 1, 0)],
(0, -1, 0): [(0, 0, 1), (1, 0, 1), (1, 0, 0), (0, 0, 0)],
(0, 1, 0): [(0, 1, 0), (1, 1, 0), (1, 1, 1), (0, 1, 1)],
}
def write_ply(path: Path, solid, shade, palette):
"""ASCII PLY, hidden faces culled, vertex colors. y flipped so +up."""
verts, faces = [], []
for x, y, z in zip(*np.nonzero(solid)):
r, g, b, _ = palette[shade[x, y, z]]
for (dx, dy, dz), corners in _PLY_FACES.items():
nx, ny, nz = x + dx, y + dy, z + dz
if 0 <= nx < FRAME and 0 <= ny < FRAME and 0 <= nz < FRAME \
and solid[nx, ny, nz]:
continue
base = len(verts)
for cx, cy, cz in corners:
verts.append((x + cx, FRAME - (y + cy), z + cz, r, g, b))
faces.append((base, base + 1, base + 2, base + 3))
with open(path, "w") as f:
f.write("ply\nformat ascii 1.0\n"
f"element vertex {len(verts)}\n"
"property float x\nproperty float y\nproperty float z\n"
"property uchar red\nproperty uchar green\n"
"property uchar blue\n"
f"element face {len(faces)}\n"
"property list uchar int vertex_indices\nend_header\n")
for v in verts:
f.write("%g %g %g %d %d %d\n" % v)
for a, b, c, d in faces:
f.write(f"4 {a} {b} {c} {d}\n")
# ------------------------------------------------------------- lua exports --
FACES_PER_LINE = 8
def lua_source(name: str, sheet_w: int, sheet_h: int, poses: dict) -> str:
out = [
"-- Generated by tools/build_voxels.py. DO NOT EDIT.",
f"-- Visual-hull voxel model carved from the {name!r} overworld"
.replace("'", '"'),
"-- sprite sheet. Geometry only -- no sheet path, no baked pixel.",
"-- faces: flat runs of 6 ints -- mx, my, mz, dir, u, v.",
"-- mx/my/mz voxel corner in model space (X east, Y up, Z south),",
"-- 0..15, character faces +Z at rest.",
"-- dir 1 +X 2 -X 3 +Y 4 -Y 5 +Z 6 -Z",
"-- u/v pixel this face samples in a sheet of sheetW x sheetH.",
"-- The runtime pairs the model with the LIVE sheet the",
"-- engine handed it, so palette/mod recolors apply.",
"return {",
f" name = {name!r},".replace("'", '"'),
f" sheetW = {sheet_w},",
f" sheetH = {sheet_h},",
f" size = {FRAME},",
" poses = {",
]
for pose in ("stand", "walk"):
p = poses.get(pose)
if not p:
continue
faces = p["faces"]
out.append(f" {pose} = {{")
out.append(f" frames = {{ {', '.join(str(f) for f in p['frames'])} }},")
out.append(f" voxels = {p['voxels']},")
out.append(f" count = {len(faces)},")
out.append(" faces = {")
for i in range(0, len(faces), FACES_PER_LINE):
chunk = faces[i:i + FACES_PER_LINE]
row = " ".join(
",".join(str(n) for n in face) + "," for face in chunk)
out.append(" " + row)
out.append(" },")
out.append(" },")
out.append(" },")
out.append("}")
return "\n".join(out) + "\n"
# ---------------------------------------------------------------- pipeline --
def build_pose(frames, picks, mode="union"):
"""Carve one pose. `picks` is (down, up, left) frame indices, or a
1-tuple for a prop, whose side view is the mirrored front."""
def view(i, mirror=False):
shade, alpha, ox, oy = frames[i]
if alpha is None:
alpha = flood_alpha(shade)
return View(shade, alpha, ox, oy, mirror)
if len(picks) == 1:
front = view(picks[0])
back = view(picks[0], mirror=True) # a prop reads the same from behind
side = view(picks[0], mirror=True)
else:
front = view(picks[0])
back = view(picks[1], mirror=True) # register the back with the front
side = view(picks[2])
solid = largest_component(carve(front, side, back, mode))
return solid, front, side, back
def convert(path: Path, outdir: Path, debug: Path = None,
palette_name="sgb", min_iou=0.0, mode="union") -> dict:
shade, alpha = load_sheet(path)
frames = slice_frames(shade, alpha)
sheet_h, sheet_w = shade.shape
if len(frames) >= 6:
pose_picks = {"stand": (0, 1, 2), "walk": (3, 4, 5)}
elif len(frames) >= 3:
pose_picks = {"stand": (0, 1, 2)}
else:
pose_picks = {"stand": (0,)}
poses, scores = {}, {}
for pose, picks in pose_picks.items():
solid, front, side, back = build_pose(frames, picks, mode)
poses[pose] = {
"frames": picks,
"voxels": int(solid.sum()),
"faces": build_faces(solid, front, side, back),
}
scores[pose] = reprojection_iou(solid, front, side, back)
if debug is not None:
palette = PALETTES[palette_name]
sh = colorize(solid, front, side, back)
debug.mkdir(parents=True, exist_ok=True)
write_vox(debug / f"{path.stem}_{pose}.vox", solid, sh, palette)
write_ply(debug / f"{path.stem}_{pose}.ply", solid, sh, palette)
outdir.mkdir(parents=True, exist_ok=True)
dest = outdir / f"{path.stem}.lua"
dest.write_text(lua_source(path.stem, sheet_w, sheet_h, poses))
# report the WORST pose, not just stand: `ok` gates on every pose, so a
# summary line showing stand's numbers could print "!!" beside three
# healthy-looking scores when it is the walk pose that carved badly
worst_pose = min(scores, key=lambda p: min(scores[p].values()))
worst = min(scores[worst_pose].values())
return {"name": path.stem, "poses": poses, "iou": scores,
"worst": worst, "worstPose": worst_pose, "ok": worst >= min_iou,
"bytes": dest.stat().st_size}
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("input", nargs="?", type=Path, default=SPRITE_DIR,
help="sprite PNG/.2bpp or directory "
f"(default {SPRITE_DIR})")
ap.add_argument("-o", "--outdir", type=Path, default=VOXEL_DIR)
ap.add_argument("--only", help="comma-separated sprite stems to build")
ap.add_argument("--debug-exports", type=Path, default=None,
metavar="DIR", help="also write .vox/.ply there")
ap.add_argument("--palette", choices=PALETTES, default="sgb",
help="debug-export palette only")
ap.add_argument("--min-iou", type=float, default=0.0,
help="fail the run if any view scores below this")
ap.add_argument("--silhouette", choices=("union", "intersect"),
default="union",
help="how the front/back pair combines (see the module "
"docstring); intersect is the strict three-way hull")
args = ap.parse_args(argv)
if args.input.is_dir():
targets = sorted(args.input.glob("*.png")) + \
sorted(args.input.glob("*.2bpp"))
else:
targets = [args.input]
if args.only:
keep = {s.strip() for s in args.only.split(",") if s.strip()}
targets = [t for t in targets if t.stem in keep]
if not targets:
print(f"no sprite sheets found under {args.input}")
return 1
failed, total_faces, total_bytes = 0, 0, 0
for t in targets:
try:
r = convert(t, args.outdir, args.debug_exports, args.palette,
args.min_iou, args.silhouette)
except Exception as e: # noqa: BLE001 - batch robustness
print(f"[FAIL] {t.name}: {e}")
failed += 1
continue
nf = sum(len(p["faces"]) for p in r["poses"].values())
total_faces += nf
total_bytes += r["bytes"]
s = r["iou"][r["worstPose"]]
flag = "" if r["ok"] else " <-- below min IoU"
pose = "" if r["worstPose"] == "stand" else f" [{r['worstPose']}]"
print(f"[{'ok' if r['ok'] else '!!'}] {r['name']:<20}"
f" {len(r['poses'])} pose(s) {nf:>5} faces "
f"IoU f={s['front']:.3f} s={s['side']:.3f} b={s['back']:.3f}"
f"{pose}{flag}")
failed += not r["ok"]
print(f"\n{len(targets) - failed}/{len(targets)} models -> {args.outdir}"
f" ({total_faces} faces, {total_bytes / 1024:.0f} KiB)")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
"""Composite the reference PNGs that
assets/docs/buildings/B##-unnamed-building.md files link to (img/B##.png,
img/B##_x{scale}.png, img/B##_atlas.png) but the repo does not ship, since
they land in the gitignored .scratchpad/ -- run this to regenerate them
locally.
Parses each doc's raw tile-id grid (the `local rows = {...}` Lua array), its
named tileset, and its scale factor straight out of the markdown, then
composites the building out of the tileset atlas the same way
building_voxels.py's sprite() does: paste each tile's 8x8 block, 16 tiles
per row. Emits, per building:
- B##.png native resolution
- B##_x{N}.png nearest-neighbor upscale, at the scale the doc references
- B##_atlas.png a strip of the building's distinct tiles, first-appearance
order (matches the doc's legend lettering)
python mods/DRAMATIC_SHAPE/tools/building_images.py [outdir]
outdir defaults to .scratchpad/img at the repo root.
"""
import os
import re
import sys
import glob
from PIL import Image
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ROOT = os.path.dirname(os.path.dirname(MOD_ROOT))
DOCS = os.path.join(MOD_ROOT, "assets", "docs", "buildings")
TILESETS = os.path.join(ROOT, "assets", "generated", "tilesets")
PER_ROW = 16
def parse_doc(path):
text = open(path, encoding="utf-8").read()
bid = re.search(r"^# (B\d+)", text, re.M).group(1)
scale = int(re.search(r"img/B\d+_x(\d+)\.png", text).group(1))
tileset = re.search(r"assets/generated/tilesets/(\w+)\.png", text).group(1)
lua = re.search(r"local rows = \{(.*?)\n\}", text, re.S).group(1)
rows = []
for line in lua.strip().splitlines():
code = line.split("--")[0]
nums = re.findall(r"\d+", code)
if nums:
rows.append([int(n) for n in nums])
return bid, scale, tileset, rows
def composite(rows, atlas):
h, w = len(rows), len(rows[0])
im = Image.new("RGB", (w * 8, h * 8))
for r, row in enumerate(rows):
for c, t in enumerate(row):
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
im.paste(atlas.crop((ax, ay, ax + 8, ay + 8)), (c * 8, r * 8))
return im
def atlas_strip(rows, atlas, cell=32, pad=2, per_row=12):
"""Every distinct tile the building uses, in first-appearance (row-major)
order -- the same order the doc's legend assigns A, B, C, ... to."""
seen, seenset = [], set()
for row in rows:
for t in row:
if t not in seenset:
seenset.add(t)
seen.append(t)
n = len(seen)
cols = min(n, per_row)
lines = (n + per_row - 1) // per_row
W = cols * cell + (cols + 1) * pad
H = lines * cell + (lines + 1) * pad
img = Image.new("RGB", (W, H), (255, 255, 255))
for i, t in enumerate(seen):
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
tile = atlas.crop((ax, ay, ax + 8, ay + 8)).resize((cell, cell), Image.NEAREST)
col, line = i % per_row, i // per_row
img.paste(tile, (pad + col * (cell + pad), pad + line * (cell + pad)))
return img
def main():
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, ".scratchpad", "img")
os.makedirs(out, exist_ok=True)
atlas_cache = {}
docs = sorted(
glob.glob(os.path.join(DOCS, "B*-unnamed-building.md")),
key=lambda p: int(re.search(r"B(\d+)", os.path.basename(p)).group(1)),
)
for path in docs:
bid, scale, tileset, rows = parse_doc(path)
if tileset not in atlas_cache:
atlas_cache[tileset] = Image.open(
os.path.join(TILESETS, tileset + ".png")
).convert("RGB")
atlas = atlas_cache[tileset]
native = composite(rows, atlas)
native.save(os.path.join(out, f"{bid}.png"))
big = native.resize((native.width * scale, native.height * scale), Image.NEAREST)
big.save(os.path.join(out, f"{bid}_x{scale}.png"))
atl = atlas_strip(rows, atlas)
atl.save(os.path.join(out, f"{bid}_atlas.png"))
print(f"{bid}: {tileset} {native.size} -> x{scale} {big.size}, atlas {atl.size}")
if __name__ == "__main__":
main()
+911
View File
@@ -0,0 +1,911 @@
"""Reference implementation of the building voxelizer -- the Python half of
the dual-implementation parity check demanded by
assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md (Stage 5).
It composites a building out of the tileset atlas exactly the way the map
does, extracts palette + silhouette (light-only flood fill), builds the
voxel model with the same rules lib/Buildings.lua ships, asserts the
geometric intent, and renders isometric previews.
python mods/DRAMATIC_SHAPE/tools/building_voxels.py [outdir]
Keep the TEMPLATES table below in sync with the `buildings` section of
data/voxel_heights.lua: the two implementations are meant to be
independent restatements of the same algorithm, and the voxel/shell
counts they print must match.
"""
import os
import sys
from collections import deque, Counter
from PIL import Image, ImageDraw
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__)))))
TILESETS = os.path.join(ROOT, "assets/generated/tilesets")
PER_ROW = 16
WHITE, GREY, DARK, BLACK = 0, 1, 2, 3 # by luminance, light first
# --- the shape profile, mirroring data/voxel_heights.lua's `buildings` ------
TEMPLATES = {
# B07: Red's house / Blue's house / Bill's / the Copycat's -- 7 placements
"gabled_house": dict(
tiles=[
[5, 6, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 34, 10, 10, 40, 41],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 34, 11, 12, 10, 10, 34, 31],
[78, 26, 27, 28, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=(24, 31),
),
# B31: Oak's lab -- the same architecture with a roof band twice as deep
"lab": dict(
tiles=[
[5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 10, 10, 75, 75, 10, 10, 10, 40, 41],
[15, 34, 34, 34, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 11, 12, 10, 10, 10, 10, 10, 31],
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B03: the flat-roofed commercial block -- 15 placements, the most of
# any voxelized drawing. The lattice is drawn from straight above, so
# the measured taper is flat; the eave course is the roof's own south
# rim, lab-style. Its sprite is inset from its box: the outer columns
# carry no roof.
"flat_commercial": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 11, 12, 75, 75, 75, 31],
[78, 26, 27, 28, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B05: every Pokemon Center in the game (Celadon, Cerulean,
# Cinnabar, Fuchsia, Lavender, Pewter, Saffron, Vermilion, Viridian,
# Mt Moon, Rock Tunnel). B03's block with the POKe sign hung beside
# the door; the sign is too wide to be a pane, so it stays flush.
"pokecenter": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 11, 12, 66, 67, 75, 31],
[78, 26, 27, 28, 74, 74, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B06: every Poke Mart (Cerulean, Cinnabar, Fuchsia, Lavender,
# Pewter, Saffron, Vermilion, Viridian). The Center's twin, MART on
# the sign.
"pokemart": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 11, 12, 68, 69, 75, 31],
[78, 26, 27, 28, 74, 74, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B02: the plain 4x4 block: one window course over blank brick and
# no door. 15 placements, scenery in every city bar the Celadon Mart
# roof stair.
"flat_block_4x4": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B08: the same block two cells deeper, 6 placements, all scenery.
"flat_block_4x6": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B13: the 6x4 scenery block, 4 placements.
"flat_block_6x4": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B14: the 6x6 scenery block, 3 placements.
"flat_block_6x6": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B27: the 8x4 scenery block, one placement on Route 11.
"flat_block_8x4": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B09: the wide storefront: Celadon's Game Corner, the Pokemon
# Mansion, Cinnabar Lab, the Safari Zone gate and Fuchsia's meeting
# room.
"game_corner": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B15: Celadon Mansion and the Route 6 and Route 12 gates.
"celadon_mansion": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B18: the Route 2 gate, and the museum's east entrance beside it in
# Pewter. The museum hall itself is B24 below -- a sloped roof.
"route_2_gate": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 11, 12, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B29: Fuchsia Gym, the block with GYM on the sign.
"fuchsia_gym": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 34, 47, 63, 34, 10, 10, 10, 31],
[15, 75, 75, 75, 34, 34, 34, 34, 75, 75, 75, 31],
[15, 75, 11, 12, 10, 10, 10, 10, 75, 75, 75, 31],
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B28: the Route 5 underground-path gate.
"route_5_gate": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B21: the Route 22 league gate, the widest of the family at 12
# cells.
"route_22_gate": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B25: the Power Plant.
"power_plant": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B22: Celadon's department store: six window courses over the MART
# sign.
"celadon_mart": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 75, 75, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 11, 12, 10, 10, 11, 12, 10, 10, 68, 69, 75, 31],
[78, 26, 26, 26, 27, 28, 26, 26, 27, 28, 26, 26, 74, 74, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B20: Silph Co. Twelve cells of plot and ten courses of windows
# under the same roof band, so it stands as the tallest thing in
# Kanto.
"silph_co": dict(
tiles=[
[76, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 77],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[90, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 90],
[92, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 93],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 75, 75, 75, 75, 31],
[78, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B24: the Pewter museum's hall, and the only building in the family
# with a SLOPED roof: the same 2:1 taper the lab and Red's house are
# drawn with, over a roof band twice the lab's depth. The drawing
# repeats its whole lattice-and-course motif -- rows 8..31 again at
# 32..55 -- which is what fixes the cycle at 24 rather than the bare
# lattice's 8: the drawing proves the period. The last band (rows
# 56..63) is the roof's fascia, wider than the wall it covers, so it
# stays in the roof band and lands on the south rim.
"museum": dict(
tiles=[
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 40, 41],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 75, 75, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 11, 12, 10, 10, 75, 75, 75, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 26, 26, 26, 26, 79],
],
roof_rows=64, roof_back=8, roof_front=8, roof_cycle=(8, 31),
slab=4, front_eave=4, ledge=None,
),
# B10: the gym. Cinnabar, Pewter, Vermilion and Viridian wear this
# drawing, and so does the Fighting Dojo next door to Saffron's.
# Oak's lab's roof band exactly, tile for tile, over a facade with
# GYM on the sign.
"gym": dict(
tiles=[
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 10, 34, 47, 63, 34, 10, 10, 40, 41],
[15, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 11, 12, 10, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B16: the big-city gym: Celadon, Cerulean and Saffron. Two cells
# wider than the standard gym, and it carries the GYM sign twice.
"gym_large": dict(
tiles=[
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 10, 34, 47, 63, 34, 34, 47, 63, 34, 10, 10, 40, 41],
[15, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 12, 10, 31],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 28, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B01: the commonest drawing in the game at 19 placements, and every
# one of them scenery: a gabled block with two window courses and no
# door. Red's house's roof band, tile for tile, but no awning under
# it.
"gabled_block_4x3": dict(
tiles=[
[ 5, 6, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 10, 10, 10, 40, 41],
[15, 34, 34, 34, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 31],
[78, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=None,
),
# B04: the little 4x2 cottage, 12 placements and nearly all of them
# somebody's home: Mr Fuji's, the Cubone house, Bill's grandpa's,
# the Name Rater's, the Viridian school house, the Route 8
# underground path.
"gabled_cottage": dict(
tiles=[
[ 5, 6, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 24, 25],
[37, 38, 11, 12, 10, 10, 40, 41],
[78, 26, 27, 28, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=None,
),
# B17: the wide 6x2 house: Cerulean's badge, trade and trashed
# houses.
"gabled_house_wide": dict(
tiles=[
[ 5, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 11, 12, 35, 10, 10, 35, 10, 10, 40, 41],
[78, 26, 27, 28, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=None,
),
# B11: the 6x2 scenery block, 5 placements, no door.
"gabled_block_6x2": dict(
tiles=[
[ 5, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 34, 35, 10, 10, 35, 10, 10, 40, 41],
[78, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=None,
),
# B34: the 4x2 scenery block, one placement in Fuchsia.
"gabled_block_4x2": dict(
tiles=[
[ 5, 6, 7, 7, 7, 7, 8, 9],
[21, 22, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 34, 10, 10, 40, 41],
[78, 26, 26, 26, 26, 26, 26, 79],
],
roof_rows=16, roof_back=7, roof_front=9, roof_cycle=(5, 8),
slab=4, front_eave=4, ledge=None,
),
# B33: the Route 5 day care.
"daycare": dict(
tiles=[
[ 5, 6, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 24, 25],
[37, 38, 10, 10, 10, 10, 40, 41],
[15, 34, 34, 34, 34, 34, 34, 31],
[15, 10, 10, 10, 11, 12, 10, 31],
[78, 26, 26, 26, 27, 28, 26, 79],
],
roof_rows=32, roof_back=7, roof_front=8, roof_cycle=(5, 12),
slab=4, front_eave=4, ledge=None,
),
# B26: the Route 10 scenery block, structurally the museum's twin.
# Needs `seal`: its drawing has no black base course, so unsealed
# the flood climbs in from the south border and hollows the wall out
# (72% surviving in 65 pieces, against 95% in one).
"gabled_block_6x6": dict(
tiles=[
[ 5, 6, 83, 83, 83, 83, 83, 83, 83, 83, 8, 9],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 56, 18, 18, 18, 18, 18, 18, 18, 18, 56, 25],
[21, 22, 23, 23, 23, 23, 23, 23, 23, 23, 24, 25],
[37, 38, 34, 34, 34, 34, 34, 34, 34, 34, 40, 41],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
[15, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 31],
[15, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 31],
],
roof_rows=64, roof_back=8, roof_front=8, roof_cycle=(8, 31),
slab=4, front_eave=4, ledge=None, seal="s",
),
# B12: the Safari Zone rest houses. A corrugated roof over a plank
# facade; the stripe repeats every 5 rows, not the OVERWORLD
# lattice's 8.
"safari_rest_house": dict(
tiles=[
[ 8, 9, 9, 9, 9, 9, 9, 12],
[24, 25, 25, 25, 25, 25, 25, 28],
[40, 41, 42, 43, 1, 1, 41, 44],
[56, 41, 58, 59, 41, 41, 41, 60],
],
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
slab=4, front_eave=4, ledge=None, tileset="forest",
),
# B23: the Victory Road entrance on Route 23: a rock face with two
# barred doors. The roof band is the pale cliff top seen from above.
"victory_road_gate": dict(
tiles=[
[37, 38, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 37, 38],
[40, 41, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 40, 41],
[21, 22, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 15, 15, 15, 15, 15, 15, 21, 22],
[ 5, 6, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 15, 15, 15, 15, 15, 15, 5, 6],
[ 5, 6, 15, 15, 15, 15, 15, 15, 11, 12, 15, 15, 15, 15, 15, 15, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 11, 12, 15, 15, 15, 15, 5, 6],
[21, 22, 14, 14, 14, 14, 14, 14, 27, 28, 14, 14, 14, 14, 14, 14, 46, 47, 3, 3, 3, 3, 3, 3, 3, 3, 46, 47, 27, 28, 14, 14, 14, 14, 21, 22],
],
roof_rows=16, roof_back=7, roof_front=8, roof_cycle=(9, 12),
slab=4, front_eave=4, ledge=None, tileset="plateau",
),
}
# a recess is a window or a doorway: a non-black region the art seals off
# behind its own black frame. Anything wider or taller than this is a band
# of the facade itself (siding courses, the awning's grey field).
RECESS_MAX = 24
# --------------------------------------------------------------- stage 1 --
def sprite(tiles, seal="", tileset="overworld"):
"""Composite the building and read it the way Structures reads the map:
palette index per pixel plus the light-only silhouette flood.
`seal` names the sides the drawing runs off (a string of n/s/e/w). The
flood does not seed there: a drawing trimmed flush to its art, whose
base course is brick rather than the black threshold every other
building stands on, would otherwise be hollowed out through its own
mortar."""
atlas = Image.open(os.path.join(TILESETS, tileset + ".png")).convert("RGB")
h, w = len(tiles), len(tiles[0])
W, H = w * 8, h * 8
im = Image.new("RGB", (W, H))
for r, row in enumerate(tiles):
for c, t in enumerate(row):
ax, ay = (t % PER_ROW) * 8, (t // PER_ROW) * 8
im.paste(atlas.crop((ax, ay, ax + 8, ay + 8)), (c * 8, r * 8))
px = im.load()
counts = Counter(px[x, y] for y in range(H) for x in range(W))
lum = lambda c: 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
pal = sorted(counts, key=lambda c: -lum(c)) # white, grey, dark, black
assert len(pal) == 4, pal
idx = {c: i for i, c in enumerate(pal)}
col = [[idx[px[x, y]] for x in range(W)] for y in range(H)]
# The flood spreads only through LIGHT pixels: the black outline and the
# #555 shading together are the boundary. A "not black" threshold lets it
# eat the shaded flanks and the silhouette collapses.
out = [[False] * W for _ in range(H)]
q = deque()
def seed(x, y):
if not out[y][x] and col[y][x] <= GREY:
out[y][x] = True
q.append((x, y))
for x in range(W):
if "n" not in seal:
seed(x, 0)
if "s" not in seal:
seed(x, H - 1)
for y in range(H):
if "w" not in seal:
seed(0, y)
if "e" not in seal:
seed(W - 1, y)
while q:
x, y = q.popleft()
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
if 0 <= nx < W and 0 <= ny < H:
seed(nx, ny)
# source texel per pixel, so every voxel can name where its colour came from
src = [[((tiles[y // 8][x // 8] % PER_ROW) * 8 + x % 8,
(tiles[y // 8][x // 8] // PER_ROW) * 8 + y % 8)
for x in range(W)] for y in range(H)]
return dict(W=W, H=H, col=col, out=out, src=src, pal=pal)
# --------------------------------------------------------------- stage 2 --
def profile(sp, t):
"""Everything the band table implies, measured off the mask."""
W, H = sp["W"], sp["H"]
inside = lambda x, y: 0 <= x < W and 0 <= y < H and not sp["out"][y][x]
# the taper IS the slope: the topmost drawn row of each column
top = []
for x in range(W):
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
top.append(min(r, t["roof_rows"]))
wall_h = H - t["roof_rows"]
ytop = wall_h - 1 + t["slab"]
# Recesses: the panes the art seals behind a black frame. Non-black
# pixels of the facade split into components across the black outline;
# a component small enough to be a window or a doorway sinks one voxel.
wall_y0 = t["roof_rows"]
comp = {}
recess = set()
for sy in range(wall_y0, H):
for sx in range(W):
if (sx, sy) in comp or not inside(sx, sy) or sp["col"][sy][sx] == BLACK:
continue
cells, stack = [], [(sx, sy)]
comp[(sx, sy)] = True
x0 = x1 = sx
y0 = y1 = sy
while stack:
cx, cy = stack.pop()
cells.append((cx, cy))
x0, x1 = min(x0, cx), max(x1, cx)
y0, y1 = min(y0, cy), max(y1, cy)
for nx, ny in ((cx + 1, cy), (cx - 1, cy),
(cx, cy + 1), (cx, cy - 1)):
if (ny >= wall_y0 and (nx, ny) not in comp
and inside(nx, ny)
and sp["col"][ny][nx] != BLACK):
comp[(nx, ny)] = True
stack.append((nx, ny))
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
recess.update(cells)
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
inside=inside, D=H, W=W, H=H)
# --------------------------------------------------------------- stage 3 --
def build(sp, pr, t):
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
the roof solid overwrites what it intersects and the walls are trimmed
to the roof's underside."""
W, H, D = pr["W"], pr["H"], pr["D"]
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
col, src = sp["col"], sp["src"]
slab = t["slab"]
def T(x):
return ytop - top[max(0, min(W - 1, x))]
def interior(sx, sy):
"""Side faces must not be slabs of outline black: walk inward for the
first painted colour, the way the drawing's own shading does."""
if col[sy][sx] != BLACK:
return sx
step = 1 if sx < W // 2 else -1
for d in range(1, 4):
nx = sx + step * d
if inside(nx, sy) and col[sy][nx] != BLACK:
return nx
return sx
# the roof's drawn span: a sprite inset from its box leaves outer
# columns undrawn in the roof band, and they carry no roof at all
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
x0d, x1d = (roofed[0], roofed[-1]) if roofed else (0, W - 1)
def trimmed(x, y):
"""Under the roof's underside -- a column with no roof over it has
no underside, and must not be cut by a profile never drawn."""
return top[max(0, min(W - 1, x))] < t["roof_rows"] and y > T(x) - slab
vox = {}
def put(x, y, z, sx, sy):
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
# ---- walls: the facade rows extruded straight back, trimmed under the roof
for sy in range(t["roof_rows"], H):
y = H - 1 - sy
for sx in range(W):
if not inside(sx, sy) or trimmed(sx, y):
continue
ix = interior(sx, sy)
for z in range(D):
if z == 0 or z == D - 1:
put(sx, y, z, sx, sy)
else:
put(sx, y, z, ix, sy)
# a base course: the drawing's last row is the ground the house stands
# on, so without this the wall floats one voxel over its own plot
for x in range(W):
for z in range(D):
if (x, 0, z) not in vox and (x, 1, z) in vox:
vox[(x, 0, z)] = vox[(x, 1, z)]
# ---- ledge: the awning slab juts two voxels past the walls
if t["ledge"]:
l0, l1 = t["ledge"]
for sy in range(l0, l1 + 1):
y = H - 1 - sy
for sx in range(W):
if inside(sx, sy) and not trimmed(sx, y):
for z in (-2, -1, D, D + 1):
put(sx, y, z, sx, sy)
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
for sx, sy in pr["recess"]:
vox.pop((sx, H - 1 - sy, D - 1), None)
# ---- roof: flat top over the plateau, stepped diagonal ends
z0, z1 = 0, D - 1 + t["front_eave"]
back, front = t["roof_back"], t["roof_front"]
c0, c1 = t["roof_cycle"]
def roof_sy(z):
df, db = z - z0, z1 - z # from the north / the south edge
if df < back:
return df # north rim: the drawing's top rows
if db < front:
return t["roof_rows"] - 1 - db # south rim: fascia and eave course
return c0 + (df - c0) % (c1 - c0 + 1)
shade_px = {}
for sy in range(H):
for sx in range(W):
if inside(sx, sy):
shade_px.setdefault(col[sy][sx], (sx, sy))
for x in roofed:
tt = T(x)
for z in range(z0, z1 + 1):
outer = x == x0d or x == x1d or z == z0 or z == z1
# the slope's texture is the drawing's own: clamping into the
# column's first drawn row keeps flank battens running down the
# slope instead of falling off the silhouette
sy = max(roof_sy(z), pr["top"][x])
for y in range(tt - slab + 1, tt + 1):
if y == tt and not outer:
put(x, y, z, x, sy)
else:
# the rim: the drawing's own eave -- a black outline
# over a shaded fascia, closed by the outline again
if not outer:
shade = DARK
elif y == tt or y == tt - slab + 1:
shade = BLACK
else:
shade = DARK
px = shade_px.get(shade) or shade_px[BLACK]
put(x, y, z, px[0], px[1])
return vox
# --------------------------------------------------------------- stage 5 --
def verify(vox, sp, pr, t):
W, H, D = pr["W"], pr["H"], pr["D"]
ytop, top, slab = pr["ytop"], pr["top"], t["slab"]
T = lambda x: ytop - top[max(0, min(W - 1, x))]
for (x, y, z), _ in vox.items():
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
# The roof surface reads over the columns the drawing actually paints:
# a sprite inset from its box says nothing about the rest.
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
assert roofed, "no roof band is drawn"
prof = [T(x) for x in roofed]
assert prof == prof[::-1], "the roof is not symmetric"
plateau = [i for i, v in enumerate(prof) if v == ytop]
assert plateau, "no flat top"
if max(top[x] for x in roofed) > 1:
# a slope: eave tip, one step per N columns, flat plateau
for i in range(1, plateau[0]):
assert 0 <= prof[i] - prof[i - 1] <= 1, "the taper is not monotonic"
# the taper rate the drawing sets is the slope: one step per N
# columns, the same N the whole way down to the eave tip
steps = [i for i in range(1, plateau[0] + 1) if prof[i] != prof[i - 1]]
# a rate needs two steps to be a rate. One step is a lip, not a
# slope, and there is nothing to hold it to.
if len(steps) >= 2:
rate = steps[1] - steps[0]
assert all(b - a == rate for a, b in zip(steps, steps[1:])), \
f"the slope is not a constant {rate}:1"
assert prof[0] == ytop - (plateau[0] + rate - 1) // rate, \
"the eave tip does not land where the drawn taper ends"
else:
# flat: one level roof the whole drawn span, give or take the
# drawing's own corner rounding
assert all(ytop - v <= 1 for v in prof), "the flat roof is not level"
# every wall column carries roof over it -- and a column the roof never
# reaches carries nothing at all, rather than being silently trimmed away
over = set(roofed)
for x in range(W):
for z in range(D):
if x not in over:
assert not any((x, y, z) in vox for y in range(ytop + 1)), \
f"wall stands where no roof reaches at {x},{z}"
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
shell = [k for k in vox if not all(
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
return shell
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
# Mirroring depth swings the camera round to the other side. Mirror
# about the model's own depth range, not a fixed one, or a building
# deeper or shallower than the first two walks off the canvas.
zs = [z for _, _, z in shell]
zlo, zhi = min(zs), max(zs)
pts = [(x, y, (zlo + zhi - z) if flip else z, vox[(x, y, z)][0])
for (x, y, z) in shell]
pts.sort(key=lambda p: (p[0] + p[2], p[1]))
# Fit the projection to the model: Silph Co is four times the height of
# Red's house and would otherwise render off the edge.
px = lambda x, z: 2 * (x - z)
py = lambda x, y, z: (x + z) - 2 * y
xs = [px(x, z) for x, _, z, _ in pts] + [px(x + 1, z + 1) for x, _, z, _ in pts]
ys = [py(x, y, z) for x, y, z, _ in pts] + [py(x + 1, y + 1, z + 1)
for x, y, z, _ in pts]
W, H = canvas
S = max(1, min((W - 2 * pad) // max(1, max(xs) - min(xs)),
(H - 2 * pad) // max(1, max(ys) - min(ys))))
ox = pad - min(xs) * S + (W - 2 * pad - (max(xs) - min(xs)) * S) // 2
oy = pad - min(ys) * S + (H - 2 * pad - (max(ys) - min(ys)) * S) // 2
P = lambda x, y, z: (px(x, z) * S + ox, py(x, y, z) * S + oy)
img = Image.new("RGB", canvas, (0xca, 0xdc, 0x9f))
dr = ImageDraw.Draw(img)
has = {(x, y, z) for x, y, z, _ in pts}
sh = lambda c, f: tuple(int(v * f) for v in sp["pal"][c])
for x, y, z, c in pts:
if (x, y + 1, z) not in has:
dr.polygon([P(x, y + 1, z), P(x + 1, y + 1, z),
P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=sh(c, 1.0))
if (x + 1, y, z) not in has:
dr.polygon([P(x + 1, y, z), P(x + 1, y + 1, z),
P(x + 1, y + 1, z + 1), P(x + 1, y, z + 1)], fill=sh(c, 0.62))
if (x, y, z + 1) not in has:
dr.polygon([P(x, y, z + 1), P(x + 1, y, z + 1),
P(x + 1, y + 1, z + 1), P(x, y + 1, z + 1)], fill=sh(c, 0.82))
img.save(os.path.join(out, name))
def main():
out = sys.argv[1] if len(sys.argv) > 1 else "."
os.makedirs(out, exist_ok=True)
for name, t in TEMPLATES.items():
sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld"))
pr = profile(sp, t)
vox = build(sp, pr, t)
shell = verify(vox, sp, pr, t)
print(f"{name}: {sp['W']}x{sp['H']} sprite, depth {pr['D']}, "
f"height {pr['ytop'] + 1} -> voxels {len(vox)} "
f"shell {len(shell)} recessed {len(pr['recess'])}")
preview(shell, vox, sp, f"{name}_front.png", out, True)
preview(shell, vox, sp, f"{name}_back.png", out, False)
if __name__ == "__main__":
main()