Files
DramaticShapeVoxelMod/tools/building_voxels.py
T
DramaticShape 47363b8d23 iterate version
2026-08-01 16:41:03 -04:00

1205 lines
56 KiB
Python

"""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",
),
# F01: the starter-ball table in Oak's lab -- the first FURNITURE
# through the pipeline, and the first drawing whose plot is smaller
# than its grid. Rows 0-15 are the tabletop seen from above; 16-18
# are the top slab's own front edge (black/#555/black -- exactly
# what the rim treatment paints, so slab=3 and those rows fold into
# the roof band instead of extruding); 19-21 are the base: corner
# feet and the inset dark panel between them. The legs stand on
# open FLOOR, so the measured ground line lands two rows short of
# the grid; `depth` keeps the plot to the blocked cell row -- the
# legs row of the grid is the walkable cell the player faces the
# table from, and D = len(tiles) would stand the model in their
# path. 16 top rows onto a 16px plot map 1:1: no cycling, and
# roof_cycle is unreachable behind roof_back=16.
"lab_table": dict(
tiles=[
[41, 59, 59, 59, 59, 42],
[78, 57, 57, 57, 57, 79],
[88, 89, 89, 89, 89, 90],
],
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
),
# F02: the computer desk in Oak's lab -- and the Hall of Fame's
# recording machine, the same drawing on the GYM atlas (one
# placement each; both registered in voxel_heights). The one
# DESK-SET template: the methodology's region classification at
# part granularity. The desk is the sibling lab table (fascia rows
# 16-18, base 19-21); on it stand a monitor over its keyboard
# (left), a computer tower over a keyboard and mouse (middle), and
# a sheet of paper LYING FLAT (right). Upright parts anchor their
# drawn bottom row to the desk's top plane and wear their own drawn
# tops as lids; flat parts lie one voxel proud with drawn row =
# depth row -- the same 1:1 the tabletop itself is drawn with, so
# an object's height ON the drawing is its position ON the desk.
# The desk's own top is the one synthesized surface (the objects
# cover every pixel of it), continued from the sibling tables'
# pattern in the drawing's own shades.
"lab_computers": dict(
tiles=[
[91, 92, 93, 94],
[54, 55, 85, 95],
[88, 89, 89, 90],
],
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
slab=0, front_eave=0, ledge=None, tileset="gym", depth=2,
desk=dict(fascia=(16, 18), base=(19, 21)),
parts=[
dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10),
depth=4), # the monitor
dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard
dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10),
depth=6), # the tower
dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse
dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper
],
),
# F03: the empty north table beside it -- the starter table's band
# table verbatim on a grid two tiles narrower (one placement).
"lab_table_small": dict(
tiles=[
[41, 59, 59, 42],
[78, 57, 57, 79],
[88, 89, 89, 90],
],
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
),
# F04: the Pokemon Center's PC -- every Center's northeast corner
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
# shares this atlas. The lab desk-set read again: a
# Mac-style unit drawn face-on -- white top band (rows 0-3), bezel,
# screen and drive slot (4-14) -- standing at the back of a low
# desk whose front face is rows 20-23 and whose drawn top (the
# white sliver of row 15 and the margins beside the unit) names the
# lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top
# span, so the flat part carries an authored z origin: it lies at
# the desk's front edge, in front of the unit.
"center_pc": dict(
tiles=[
[66, 70],
[82, 86],
[9, 88],
],
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2,
desk=dict(fascia=(20, 21), base=(22, 23), lid="white"),
parts=[
dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14),
depth=6), # the unit
dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard
],
),
# 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"]))
# The drawing's own ground line: the row after the last drawn one. A
# building ends on the black threshold row it stands on (ground == H),
# but furniture is drawn standing on open floor -- the lab table's
# legs stop two rows short of its grid -- and extruding against H
# would float it that far above its own plot.
ground = max((y for y in range(H) for x in range(W) if inside(x, y)),
default=H - 1) + 1
wall_h = ground - 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)
# Depth is the PLOT. For a whole-drawing building that is the grid
# itself; `depth` (in tile rows) names it when the grid runs past the
# plot onto ground the drawing merely stands its legs on.
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
inside=inside, D=t.get("depth", len(t["tiles"])) * 8,
ground=ground, W=W, H=H)
# --------------------------------------------------------------- stage 3 --
def build_desk_set(sp, pr, t):
"""A desk with separately-classified objects on it (the `parts` list):
upright parts stand on the desk's top plane wearing their own drawn
tops as lids, flat parts lie one voxel proud at drawn row = depth row,
and the desk itself is the lab-table slab + base with a synthesized
lid (the objects cover every drawn pixel of the tabletop)."""
W, H, D = pr["W"], pr["H"], pr["D"]
inside = pr["inside"]
col, src = sp["col"], sp["src"]
ground = pr["ground"]
vox = {}
def put(x, y, z, sx, sy):
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
shade_px = {}
for sy in range(H):
for sx in range(W):
if inside(sx, sy):
shade_px.setdefault(col[sy][sx], (sx, sy))
def interior(sx, sy, lo, hi):
if col[sy][sx] != BLACK:
return sx
step = 1 if sx < (lo + hi) // 2 else -1
for d in range(1, 4):
nx = sx + step * d
if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK:
return nx
return sx
f0, f1 = t["desk"]["fascia"]
b0, b1 = t["desk"]["base"]
plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane
# the base band, extruded exactly like every lab table's
for sy in range(b0, b1 + 1):
y = ground - 1 - sy
for sx in range(W):
if not inside(sx, sy):
continue
ix = interior(sx, sy, 0, W - 1)
for z in range(D):
put(sx, y, z, sx if z in (0, D - 1) else ix, sy)
for sx, sy in pr["recess"]:
if b0 <= sy <= b1:
vox.pop((sx, ground - 1 - sy, D - 1), None)
# the slab: the fascia rows wrap every side; the lid is the one
# synthesized surface in the model -- the drawing never paints the
# tabletop (its objects cover it), so the lid continues the sibling
# tables' pattern in the drawing's own shades: black rim, white
# highlight courses along the north and west, grey field
for i, sy in enumerate(range(f0, f1 + 1)):
y = plane - 1 - i
for sx in range(W):
for z in range(D):
put(sx, y, z, sx, sy)
field = WHITE if t["desk"].get("lid") == "white" else GREY
for sx in range(W):
for z in range(D):
if sx in (0, W - 1) or z in (0, D - 1):
shade = BLACK
elif sx == 1 or z == 1:
shade = WHITE
else:
shade = field
px = shade_px.get(shade) or shade_px[BLACK]
put(sx, plane - 1, z, px[0], px[1])
for p in t["parts"]:
x0, x1 = p["x"]
if p["kind"] == "flat":
r0, r1 = p["rows"]
for sy in range(r0, r1 + 1):
# drawn row = depth row by default; `z` renames the
# origin when the flat sits below the desk's own drawn
# top span (the Center PC's keyboard)
z = p.get("z", r0) + (sy - r0)
if not 0 <= z < D:
continue
for sx in range(x0, x1 + 1):
if inside(sx, sy):
put(sx, plane, z, sx, sy)
continue
tr0, tr1 = p["top"]
fr0, fr1 = p["facade"]
pd = p["depth"]
ytp = plane + (fr1 - fr0)
for sx in range(x0, x1 + 1):
# the lid: the part's drawn top laid across its depth from
# the back, last row continuing forward; the front lid row
# is the facade's own top row -- the drawn front-top edge
for z in range(pd):
sy = fr0 if z == pd - 1 else min(tr0 + z, tr1)
while sy <= tr1 and not inside(sx, sy):
sy += 1
if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)):
continue
put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy)
# the body: facade rows anchored to the desk's top plane
for sy in range(fr0 + 1, fr1 + 1):
y = plane + (fr1 - sy)
if not inside(sx, sy):
continue
ix = interior(sx, sy, x0, x1)
for z in range(pd):
if z == pd - 1:
if (sx, sy) not in pr["recess"]:
put(sx, y, z, sx, sy)
else:
put(sx, y, z, sx if z == 0 else ix, sy)
return vox
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."""
if t.get("parts"):
return build_desk_set(sp, pr, t)
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 = pr["ground"] - 1 - sy # rows below the ground line are floor
if y < 0:
continue
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 = pr["ground"] - 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, pr["ground"] - 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}"
# A desk set answers its own asserts; a facade-only template (roof_rows
# == 0) has no roof band, so there is no surface to hold level or slope
# and no coverage to demand of it; everything with a roof band answers
# the full set.
if t.get("parts"):
verify_desk_set(vox, pr, t)
elif t["roof_rows"] == 0:
# its one geometric intent: a straight extrusion, so each column
# of the drawing is solid from its lowest voxel to its top. NOT
# from the ground -- the drawing's base band is inset like every
# lab table's, and the body's outer columns legitimately
# overhang it -- and not on the recess layer, which sinks panes.
lo, hi = {}, {}
for (x, y, z) in vox:
lo[(x, z)] = min(lo.get((x, z), y), y)
hi[(x, z)] = max(hi.get((x, z), y), y)
for (x, z), h in hi.items():
if z == D - 1:
continue
assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \
f"facade column has a hole at {x},{z}"
else:
verify_roof(vox, pr, t)
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 verify_desk_set(vox, pr, t):
W, D = pr["W"], pr["D"]
f0, f1 = t["desk"]["fascia"]
b0, b1 = t["desk"]["base"]
plane = (b1 - b0 + 1) + (f1 - f0 + 1)
# the slab is a full solid under everything on it
for x in range(W):
for z in range(D):
for y in range(b1 - b0 + 1, plane):
assert (x, y, z) in vox, f"slab hole at {x},{y},{z}"
# each part stands where authored -- upright bodies solid from the
# desk to their own drawn top (bar the front layer, whose panes
# sink) -- and nothing stands anywhere else
tops = {}
for p in t["parts"]:
x0, x1 = p["x"]
if p["kind"] == "flat":
r0, r1 = p["rows"]
z0 = p.get("z", r0)
for x in range(x0, x1 + 1):
for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1):
tops[(x, z)] = max(tops.get((x, z), 0), plane)
else:
fr0, fr1 = p["facade"]
ytp = plane + (fr1 - fr0)
for x in range(x0, x1 + 1):
for z in range(p["depth"]):
tops[(x, z)] = max(tops.get((x, z), 0), ytp)
if z == p["depth"] - 1:
continue
ys = [y for y in range(plane, ytp + 1)
if (x, y, z) in vox]
assert ys == list(range(ys[0], ys[0] + len(ys))) \
if ys else True, f"part column hole at {x},{z}"
for (x, y, z) in vox:
assert y <= tops.get((x, z), plane - 1), \
f"voxel above its part at {x},{y},{z}"
def verify_roof(vox, 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))]
# 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}"
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()