#!/usr/bin/env python3 """Generate small authored textures for Step Line (assets/ import convention). All outputs are deterministic (fixed seeds), <=1024px, power-of-two where possible. Run from the repo root: python3 tools/generate_textures.py """ import math import os import random import stat from PIL import Image, ImageDraw, ImageFilter, ImageFont ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ASSETS = os.path.join(ROOT, "assets", "textures") FONT_CANDIDATES = [ "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf", "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", ] def font(size: int): for path in FONT_CANDIDATES: if os.path.exists(path): return ImageFont.truetype(path, size) return ImageFont.load_default() def save(img: Image.Image, name: str) -> None: os.makedirs(ASSETS, exist_ok=True) path = os.path.join(ASSETS, name) os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path, optimize=True) print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") def grain(size, base, spread, seed, blur=1.0): rnd = random.Random(seed) img = Image.new("L", (size, size), base) px = img.load() for y in range(size): for x in range(size): v = base + rnd.randint(-spread, spread) px[x, y] = max(0, min(255, v)) return img.filter(ImageFilter.GaussianBlur(blur)) def brick_facade(): size = 512 mortar = (118, 110, 104) base = (150, 92, 78) img = Image.new("RGB", (size, size), mortar) d = ImageDraw.Draw(img) rnd = random.Random(11) brick_h = size // 22 mortar_t = 5 offset = 0 for row in range(22): y = row * brick_h cols = 2 if row % 2 == 0 else 3 brick_w = (size - mortar_t) // (cols if cols > 1 else 1) for c in range(cols + 1): x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0) x1 = x0 + brick_w # wrap horizontally so the tile repeats seamlessly d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base) # per-brick shading pass for _ in range(size * size // 900): bx = rnd.randint(0, size - 1) by = rnd.randint(0, size - 1) d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), max(0, base[1] - 10 + rnd.randint(0, 22)), max(0, base[2] - 8 + rnd.randint(0, 18)))) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) for y in [-mortar_t, size - mortar_t]: d.rectangle([0, y, size, y + mortar_t], fill=mortar) save(img, "brick_facade.png") def seamless_concrete(): size = 512 img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") d = ImageDraw.Draw(img) rnd = random.Random(19) for _ in range(90): x, y = rnd.randint(0, size), rnd.randint(0, size) r = rnd.choice([2, 3, 4]) d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) for _ in range(8): # subtle crack segments x, y = rnd.randint(0, size), rnd.randint(0, size) pts = [(x, y)] for _ in range(6): x += rnd.randint(-24, 24) y += rnd.randint(-8, 8) pts.append((x % size, y % size)) d.line(pts, fill=(70, 70, 70, 170), width=1) save(img, "concrete.png") def asphalt(): size = 512 img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") d = ImageDraw.Draw(img) rnd = random.Random(31) for _ in range(600): x, y = rnd.randint(0, size), rnd.randint(0, size) d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) save(img, "asphalt.png") def awning(): w, h = 1024, 256 img = Image.new("RGB", (w, h), (140, 40, 45)) d = ImageDraw.Draw(img) stripe = 64 for i in range(w // stripe): if i % 2 == 1: d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) # scalloped bottom edge for x in range(0, w, 32): d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) f = font(96) tw = d.textlength("STEP LINE", font=f) d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235)) # subtle top shading shade = Image.new("RGBA", (w, h), (0, 0, 0, 0)) sd = ImageDraw.Draw(shade) sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90)) img = Image.alpha_composite(img.convert("RGBA"), shade) save(img, "awning.png") def skyline(): w, h = 1024, 256 img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) d = ImageDraw.Draw(img) rnd = random.Random(41) build_color = (16, 18, 30, 255) x = -40 while x < w + 40: bw = rnd.randint(64, 170) bh = rnd.randint(150, 400) d.rectangle([x, h - bh, x + bw, h], fill=build_color) # few lit windows for _ in range(bw * bh // 900): wx = x + rnd.randint(4, max(4, bw - 8)) wy = h - rnd.randint(4, bh - 4) if rnd.random() < 0.5: d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) x += bw + rnd.randint(10, 46) d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) save(img, "skyline.png") def vignette(): size = 512 img = Image.new("L", (size, size), 0) px = img.load() c = size / 2.0 max_d = math.hypot(c, c) for y in range(size): for x in range(size): dist = math.hypot(x - c, y - c) / max_d t = max(0.0, (dist - 0.55) / 0.45) px[x, y] = int(255 * (t * t)) save(img, "vignette.png") def icon(name, draw_fn): size = 64 img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) d = ImageDraw.Draw(img) draw_fn(d, size) save(img, name) def icons(): def flashlight_on(d, s): d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255)) d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255)) def door_locked(d, s): d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5) d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5) def wand(d, s): d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255)) d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) def rec(d, s): d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5) d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) icon("icons/flashlight_on.png", flashlight_on) icon("icons/door_locked.png", door_locked) icon("icons/wand.png", wand) icon("icons/rec_dot.png", rec) def cards(): CW, CH = 256, 128 def base(card_name, bg, label, band=(0,0,0,0)): img = Image.new("RGBA", (CW, CH), bg + (255,)) d = ImageDraw.Draw(img) d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) if band and band[3] == 255: d.rectangle([0, CH-34, CW, CH], fill=band) f = font(26) d.text((14, 10), label, font=f, fill=(255,255,255,235)) # body text lines f2 = font(15) y = 52 for _ in range(4): w = 150 - (24 if _ == 3 else 0) d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) y += 24 # photo square for id-like cards if "photo" in card_name: d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) save(img, f"cards/{card_name}.png") base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) base("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 255)) base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) if __name__ == "__main__": brick_facade() seamless_concrete() asphalt() awning() skyline() vignette() icons() cards() print("done")