Files
DramaticShapeVoxelMod/assets/docs/buidling_to_voxel/voxel_build_verify.py
T
DramaticShape 702d8049f0 initial commit
2026-07-26 16:43:53 -04:00

163 lines
6.2 KiB
Python

"""Stages 3+5: reference voxel builder (mirrors the JS in the HTML),
hidden-face culling, and isometric painter previews for verification.
Run after sprite_extract.py in the same directory."""
import json
from PIL import Image, ImageDraw
data = json.load(open('sprite_data.json'))
rows = data['rows']
WHITE, GREY, DARK, BLACK = 0, 1, 2, 3
PAL = [(0xff, 0xff, 0xff), (0xaa, 0xaa, 0xaa), (0x55, 0x55, 0x55), (0x00, 0x00, 0x00),
(0xb7, 0xc7, 0x8d), (0xb1, 0xc1, 0x87), (0xd9, 0xd3, 0xc2)]
GROUND1, GROUND2, PATH = 4, 5, 6
inside = lambda x, y: 0 <= x < 64 and 0 <= y < 48 and rows[y][x] != '.'
col = lambda x, y: int(rows[y][x])
D = 26 # wall depth in voxels
vox = {}
def interior_color(sx, sy):
"""side faces shouldn't be solid outline-black: sample inward past the outline"""
c = col(sx, sy)
if c != BLACK:
return c
step = 1 if sx < 32 else -1
for d in range(1, 4):
nx = sx + step * d
if inside(nx, sy) and col(nx, sy) != BLACK:
return col(nx, sy)
return c
# ---- walls: sprite rows 16..47 extruded straight back --------------------
for sy in range(16, 48):
y = 47 - sy
for sx in range(64):
if not inside(sx, sy):
continue
front, mid = col(sx, sy), interior_color(sx, sy)
for z in range(D):
vox[(sx, y, z)] = front if (z == 0 or z == D - 1) else mid
# ---- awning ledge: rows 24..31 protrude 2 front and back -----------------
for sy in range(24, 32):
y = 47 - sy
for sx in range(64):
if inside(sx, sy):
c = col(sx, sy)
for z in (-2, -1, D, D + 1):
vox[(sx, y, z)] = c
# ---- recess panes: delete the front voxel of every non-black pixel -------
RECESS = [(16, 23, 17, 23), (32, 39, 17, 23), (40, 47, 17, 23), # gable windows
(32, 39, 33, 39), (40, 47, 33, 39), # ground windows
(19, 28, 34, 45)] # door interior
for x0, x1, y0, y1 in RECESS:
for sy in range(y0, y1 + 1):
for sx in range(x0, x1 + 1):
if inside(sx, sy) and col(sx, sy) != BLACK:
vox.pop((sx, 47 - sy, 0), None)
# ---- roof: flat top over x16..47, DIAGONAL sloped ends (1 down per 2 out)
ROOF_Z0, ROOF_Z1 = -4, 29
ROOF_X0, ROOF_X1 = -4, 67
YTOP = 35
def T(x): # elevation profile = the sprite's / \ taper
if x < 16: return YTOP - ((16 - x + 1) // 2)
if x > 47: return YTOP - ((x - 47 + 1) // 2)
return YTOP # [2,1]/[3,1]: horizontal, flat
def roof_sy(z):
df, db = z - ROOF_Z0, ROOF_Z1 - z
if db <= 6: return db # back rows 0..6 (rim + courses)
if df <= 8: return 15 - df # front rows 15..7 (fascia + rim)
return 5 + ((df - 9) % 4) # middle: cycle rows 5..8 (course rhythm)
def roof_col(x, z):
sy = roof_sy(z)
if 0 <= x < 64 and inside(x, sy):
return col(x, sy) # real sprite pixels: slats on ends, courses on top
if x < 16: return WHITE if x % 3 == 1 else DARK
if x > 47: return WHITE if x % 3 == 2 else DARK
return GREY
for x in range(ROOF_X0, ROOF_X1 + 1):
t = T(x)
for z in range(ROOF_Z0, ROOF_Z1 + 1):
outer = x in (ROOF_X0, ROOF_X1) or z in (ROOF_Z0, ROOF_Z1)
for y in range(t - 3, t + 1):
if y == t:
c = BLACK if outer else roof_col(x, z)
elif outer:
c = GREY if y == t - 1 else BLACK
else:
c = DARK
vox[(x, y, z)] = c
# trim wall corners that would poke above the sloped ends
for x in range(0, 64):
t = T(x)
if t < 31:
for y in range(t + 1, 32):
for z in range(0, D):
vox.pop((x, y, z), None)
# ---- ground plate + path -------------------------------------------------
for x in range(-10, 75):
for z in range(-12, 39):
vox[(x, -1, z)] = GROUND1 if (x + z) & 1 else GROUND2
for x in range(17, 31):
for z in range(-12, 0):
vox[(x, -1, z)] = PATH
# ---- cull to shell -------------------------------------------------------
shell = []
for (x, y, z), c in vox.items():
if all((x + dx, y + dy, z + dz) in vox for dx, dy, dz in
((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))):
continue
shell.append((x, y, z, c))
print('voxels:', len(vox), 'shell:', len(shell))
# ---- isometric preview (front = -z, so mirror z for the render) ----------
S = 4
ZM = 40
pts = []
for x, y, z, c in shell:
pts.append((x, y, ZM - z, c))
pts.sort(key=lambda p: (p[0] + p[2], p[1]))
def P(x, y, z):
return (2 * (x - z) * S + 1250, ((x + z) - 2 * y) * S + 300)
img = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
dr = ImageDraw.Draw(img)
has = {(x, y, z) for x, y, z, c in pts}
def shade(c, f):
r, g, b = PAL[c]
return (int(r * f), int(g * f), int(b * f))
for x, y, z, c in pts:
if (x, y + 1, z) not in has: # top
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=shade(c, 1.0))
if (x + 1, y, z) not in has: # right (+x)
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=shade(c, 0.62))
if (x, y, z + 1) not in has: # toward viewer (original -z front)
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=shade(c, 0.82))
img.save('preview_front.png')
# second angle: from the back-right, no mirror
pts2 = sorted(((x, y, z, c) for x, y, z, c in shell), key=lambda p: (p[0] + p[2], p[1]))
img2 = Image.new('RGB', (1800, 900), (0xca, 0xdc, 0x9f))
dr2 = ImageDraw.Draw(img2)
has2 = {(x, y, z) for x, y, z, c in pts2}
for x, y, z, c in pts2:
if (x, y + 1, z) not in has2:
dr2.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=shade(c, 1.0))
if (x + 1, y, z) not in has2:
dr2.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=shade(c, 0.62))
if (x, y, z + 1) not in has2:
dr2.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=shade(c, 0.82))
img2.save('preview_back.png')
print('previews written')