Files
Papay-Shooter/tools/build_hero_assets.py
Nicholas ButzkeandClaude Fable 5 911066875b feat: hero batch 3 — pedestrian footbridge + viaduct station platforms
- footbridge.glb: 18m truss footbridge over the central avenue one block
  south of the rail line — X-pattern teal trusses, mesh railings, real
  stair flights at both ends. Deck and stairs are WALKABLE (box deck
  collider + invisible ramp colliders over the stair visuals): a third
  elevated crossing route between the viaduct and the plaza, and a new
  grapple anchor over the avenue
- platform.glb: station platform strips on the viaduct deck flanking the
  crossing — canopy on posts, benches, line signs; dresses the deck route
  players actually run
- Fixed a parse error from an undefined color constant caught by the
  capture loop (blank render -> error -> fix -> verified)

Movement 11/11, smoke 0 failures; full-city aerial verified.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:10:05 -04:00

360 lines
14 KiB
Python

#!/usr/bin/env python3
"""Kitbash original hero assets for Akiba Crossing in headless Blender.
Builds detailed modular pieces from beveled primitives with flat cel
palette materials (the game's toonify keeps base colors), origin at
ground center, sized in meters:
konbini.glb - 8m convenience-store front: fascia, sign band, framed
windows, recessed door, awning, drink machine, planter
station.glb - viaduct station entrance: canopy, pillars, real stairs,
handrails, sign totem
clock.glb - plaza clock tower: tiered base, column, 4 faces, ring
torii.glb - proper torii with curved kasagi beam and plaque
Run: blender --background --python tools/build_hero_assets.py
Outputs to assets/props/hero/.
"""
import math
import os
import sys
import bpy
OUT = os.path.join(os.getcwd(), "assets", "props", "hero")
os.makedirs(OUT, exist_ok=True)
_mats = {}
def mat(name, rgba):
if name in _mats:
return _mats[name]
m = bpy.data.materials.new(name)
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*rgba, 1.0)
bsdf.inputs["Roughness"].default_value = 0.9
_mats[name] = m
return m
INK = mat("ink", (0.07, 0.065, 0.09))
WHITE = mat("white", (0.85, 0.84, 0.82))
CREAM = mat("cream", (0.78, 0.74, 0.66))
RED = mat("red", (0.8, 0.2, 0.16))
VERMILION = mat("vermilion", (0.82, 0.24, 0.14))
TEAL = mat("teal", (0.16, 0.65, 0.65))
GLASS = mat("glass", (0.45, 0.68, 0.75))
METAL = mat("metal", (0.4, 0.44, 0.5))
WOOD = mat("wood", (0.42, 0.28, 0.16))
YELLOW = mat("yellow", (0.95, 0.75, 0.2))
GRAY = mat("gray", (0.55, 0.55, 0.58))
def clear():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
def box(name, size, loc, material, bevel=0.02, rot=(0, 0, 0)):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.name = name
o.scale = (size[0] / 2.0, size[1] / 2.0, size[2] / 2.0)
bpy.ops.object.transform_apply(scale=True)
if bevel > 0:
b = o.modifiers.new("bevel", "BEVEL")
b.width = bevel
b.segments = 2
b.limit_method = "ANGLE"
o.data.materials.append(material)
return o
def cyl(name, r, depth, loc, material, rot=(0, 0, 0), verts=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=r, depth=depth,
location=loc, rotation=rot)
o = bpy.context.active_object
o.name = name
o.data.materials.append(material)
return o
def export(path):
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=path, use_selection=True)
print("hero: exported", path)
# ── Konbini storefront (8 x 4.2 x 1.4, front faces +Y) ───────────────────────
def konbini():
clear()
W, H, D = 8.0, 4.2, 1.2
# back panel + side returns
box("back", (W, 0.15, H), (0, -D / 2, H / 2), CREAM)
for sx in (-1, 1):
box("side", (0.3, D, H), (sx * (W / 2 - 0.15), 0, H / 2), CREAM)
# fascia + colored sign band with ink trim
box("fascia", (W, D, 0.5), (0, 0, H - 0.25), WHITE)
box("signband", (W - 0.3, D + 0.06, 0.7), (0, 0, H - 0.85), TEAL, 0.03)
box("trim_top", (W, D + 0.1, 0.08), (0, 0, H - 0.5), INK, 0.01)
box("trim_bot", (W, D + 0.1, 0.08), (0, 0, H - 1.2), INK, 0.01)
# framed windows left+right of the recessed door
for sx in (-1, 1):
cx = sx * (W / 4 + 0.35)
box("winframe", (2.6, 0.18, 2.2), (cx, D / 2 - 0.2, 1.35), INK, 0.015)
box("winglass", (2.3, 0.1, 1.9), (cx, D / 2 - 0.16, 1.35), GLASS, 0.0)
box("sill", (2.7, 0.35, 0.12), (cx, D / 2 - 0.25, 0.28), GRAY)
# window ad strip
box("winad", (1.0, 0.06, 0.5), (cx - 0.5, D / 2 - 0.08, 1.9), RED, 0.01)
# recessed doorway
box("doorback", (1.8, 0.1, 2.4), (0, -0.1, 1.2), INK, 0.0)
box("doorglassL", (0.8, 0.08, 2.1), (-0.45, 0.05, 1.15), GLASS, 0.0)
box("doorglassR", (0.8, 0.08, 2.1), (0.45, 0.05, 1.15), GLASS, 0.0)
box("doorframe", (2.0, 0.12, 0.15), (0, D / 2 - 0.35, 2.42), INK, 0.01)
# awning over the door
box("awning", (2.6, 0.9, 0.1), (0, D / 2 + 0.35, 2.6), RED, 0.02,
rot=(math.radians(12), 0, 0))
# drink machine + planter dressing
box("vending", (0.9, 0.7, 1.9), (W / 2 - 1.0, D / 2 - 0.3, 0.95), RED, 0.03)
box("vendglass", (0.7, 0.06, 1.0), (W / 2 - 1.0, D / 2 + 0.06, 1.3), GLASS, 0.0)
box("planter", (1.4, 0.5, 0.45), (-W / 2 + 1.1, D / 2 + 0.1, 0.225), GRAY, 0.03)
box("hedge", (1.25, 0.4, 0.35), (-W / 2 + 1.1, D / 2 + 0.1, 0.62), mat("leaf", (0.3, 0.55, 0.32)), 0.05)
export(os.path.join(OUT, "konbini.glb"))
# ── Station entrance (6 x 5 x 3.6, opening faces +Y) ─────────────────────────
def station():
clear()
W, D, H = 6.0, 5.0, 3.6
# canopy + ink edge
box("canopy", (W, D, 0.25), (0, 0, H), WHITE, 0.04)
box("canopy_edge", (W + 0.15, D + 0.15, 0.1), (0, 0, H - 0.16), INK, 0.01)
# pillars
for sx in (-1, 1):
for sy in (-1, 1):
cyl("pillar", 0.14, H, (sx * (W / 2 - 0.3), sy * (D / 2 - 0.3), H / 2), METAL)
# side walls (half height)
for sx in (-1, 1):
box("wall", (0.25, D, 1.2), (sx * (W / 2 - 0.125), 0, 0.6), CREAM)
# stairs descending toward -Y (6 steps down 1.8m)
steps = 6
for i in range(steps):
z = -0.15 - i * 0.3
y = -D / 2 + 0.55 + i * 0.7
box("step", (W - 1.2, 0.7, 0.3), (0, y, z), GRAY, 0.01)
# handrails along the stairwell
for sx in (-1, 1):
box("rail", (0.08, D - 0.8, 0.08),
(sx * (W / 2 - 0.7), 0.0, 0.95), INK, 0.0,
rot=(math.radians(-18), 0, 0))
for i in range(4):
box("baluster", (0.06, 0.06, 0.9),
(sx * (W / 2 - 0.7), -D / 2 + 0.8 + i * 1.1, 0.45 - i * 0.28), INK, 0.0)
# sign totem
box("totem", (0.5, 0.35, 4.4), (W / 2 + 0.5, D / 2 - 0.2, 2.2), METAL, 0.03)
box("totem_sign", (0.42, 0.1, 1.6), (W / 2 + 0.5, D / 2 - 0.01, 3.4), YELLOW, 0.01)
export(os.path.join(OUT, "station.glb"))
# ── Plaza clock tower (10m) ──────────────────────────────────────────────────
def clock():
clear()
box("base1", (3.2, 3.2, 0.5), (0, 0, 0.25), GRAY, 0.04)
box("base2", (2.4, 2.4, 0.5), (0, 0, 0.75), WHITE, 0.04)
cyl("column", 0.5, 6.5, (0, 0, 4.25), CREAM, verts=12)
for i in range(3):
cyl("colring", 0.58, 0.18, (0, 0, 1.6 + i * 2.4), INK, verts=12)
# head
box("head", (1.9, 1.9, 1.9), (0, 0, 8.4), WHITE, 0.06)
box("head_trim", (2.1, 2.1, 0.2), (0, 0, 7.45), INK, 0.02)
for i in range(4):
a = math.pi / 2 * i
dx, dy = math.cos(a), math.sin(a)
cyl("face", 0.68, 0.06, (dx * 0.98, dy * 0.98, 8.4), INK,
rot=(0, math.pi / 2, a))
cyl("face_in", 0.6, 0.08, (dx * 0.99, dy * 0.99, 8.4), WHITE,
rot=(0, math.pi / 2, a))
box("hand_h", (0.08, 0.08, 0.4), (dx * 1.05, dy * 1.05, 8.55), INK, 0.0)
box("hand_m", (0.08, 0.08, 0.28), (dx * 1.05 + dy * 0.12, dy * 1.05 + dx * 0.12, 8.32), INK, 0.0)
# crown
box("crown", (1.4, 1.4, 0.3), (0, 0, 9.5), TEAL, 0.03)
cyl("spire", 0.08, 1.2, (0, 0, 10.2), INK, verts=8)
export(os.path.join(OUT, "clock.glb"))
# ── Torii with curved kasagi (10 wide x 8 tall) ──────────────────────────────
def torii():
clear()
for sx in (-1, 1):
cyl("pillar", 0.42, 7.0, (sx * 4.0, 0, 3.5), VERMILION, verts=16)
cyl("pillar_base", 0.55, 0.5, (sx * 4.0, 0, 0.25), INK, verts=16)
# curved kasagi: segmented arc of boxes
segs = 9
for i in range(segs):
t = i / (segs - 1) - 0.5 # -0.5..0.5
x = t * 10.0
z = 7.3 + (1.0 - (2 * t) ** 2) * 0.45 # gentle arch
rot = (0, -t * 0.28, 0)
box("kasagi", (10.0 / segs + 0.15, 1.0, 0.55), (x, 0, z), INK, 0.02, rot=rot)
box("shimaki", (10.0 / segs + 0.15, 0.8, 0.35), (x, 0, z - 0.45), VERMILION, 0.02, rot=rot)
# nuki (lower tie beam) + plaque
box("nuki", (9.4, 0.55, 0.55), (0, 0, 5.6), VERMILION, 0.03)
box("gakuzuka", (0.5, 0.3, 1.1), (0, 0, 6.5), VERMILION, 0.02)
box("plaque", (0.9, 0.18, 1.3), (0, 0.2, 6.5), mat("plaque", (0.9, 0.86, 0.75)), 0.02)
export(os.path.join(OUT, "torii.glb"))
konbini()
station()
clock()
torii()
print("hero: all assets built")
# ── Batch 2: street furniture ────────────────────────────────────────────────
def yatai():
"""Ramen cart: body, roof, noren curtain strips, stools, lantern."""
clear()
box("body", (2.6, 1.2, 1.1), (0, 0, 0.75), WOOD, 0.03)
box("counter", (2.8, 1.35, 0.08), (0, 0, 1.34), mat("counter", (0.62, 0.45, 0.28)), 0.02)
for sx in (-1, 1):
box("post", (0.08, 0.08, 1.3), (sx * 1.2, -0.45, 2.0), INK, 0.0)
cyl("wheel", 0.28, 0.1, (sx * 0.9, 0.62, 0.28), INK, rot=(math.radians(90), 0, 0))
box("roof", (3.0, 1.5, 0.12), (0, 0, 2.7), RED, 0.02)
box("roof_trim", (3.1, 1.6, 0.06), (0, 0, 2.62), INK, 0.0)
# noren curtain strips across the front
for i in range(5):
x = -1.1 + i * 0.55
box("noren", (0.42, 0.04, 0.6), (x, 0.72, 2.28), mat("noren", (0.92, 0.9, 0.85)), 0.0)
box("noren_bar", (2.7, 0.05, 0.05), (0, 0.72, 2.6), INK, 0.0)
for i, x in enumerate((-0.9, 0.0, 0.9)):
cyl("stool", 0.18, 0.5, (x, 1.05, 0.25), RED, verts=12)
cyl("lantern", 0.16, 0.42, (1.35, 0.7, 2.3), mat("lamp", (0.98, 0.62, 0.25)), verts=12)
export(os.path.join(OUT, "yatai.glb"))
def koban():
"""Police box: small building, sign board, red lamp."""
clear()
W, D, H = 3.2, 3.0, 3.2
box("body", (W, D, H), (0, 0, H / 2), CREAM, 0.03)
box("roof", (W + 0.5, D + 0.5, 0.3), (0, 0, H + 0.15), TEAL, 0.03)
box("door", (0.95, 0.1, 2.1), (-0.6, D / 2, 1.05), INK, 0.01)
box("window", (1.1, 0.1, 1.0), (0.7, D / 2, 1.7), GLASS, 0.01)
box("winframe", (1.25, 0.06, 1.15), (0.7, D / 2 - 0.02, 1.7), INK, 0.01)
box("sign", (1.6, 0.12, 0.5), (0, D / 2 + 0.1, 2.75), WHITE, 0.01)
cyl("lamp", 0.12, 0.24, (0, D / 2 + 0.2, 3.05), RED, verts=10)
export(os.path.join(OUT, "koban.glb"))
def phonebooth():
clear()
box("frame", (1.1, 1.1, 2.5), (0, 0, 1.25), mat("booth", (0.2, 0.45, 0.3)), 0.03)
for a in range(4):
ang = math.pi / 2 * a
dx, dy = math.cos(ang), math.sin(ang)
box("glass", (0.06 if a % 2 == 0 else 0.85, 0.85 if a % 2 == 0 else 0.06, 1.6),
(dx * 0.53, dy * 0.53, 1.35), GLASS, 0.0)
box("cap", (1.25, 1.25, 0.18), (0, 0, 2.6), INK, 0.02)
export(os.path.join(OUT, "phonebooth.glb"))
def busstop():
clear()
box("bench", (2.6, 0.5, 0.1), (0, 0.2, 0.55), WOOD, 0.02)
for sx in (-1, 1):
box("leg", (0.1, 0.45, 0.55), (sx * 1.1, 0.2, 0.27), INK, 0.0)
box("cpost", (0.1, 0.1, 2.4), (sx * 1.5, -0.25, 1.2), METAL, 0.0)
box("croof", (3.6, 1.3, 0.1), (0, 0.15, 2.45), GLASS, 0.02)
box("croof_trim", (3.7, 1.4, 0.05), (0, 0.15, 2.38), INK, 0.0)
box("signpole", (0.08, 0.08, 3.0), (2.1, -0.3, 1.5), METAL, 0.0)
cyl("signdisc", 0.35, 0.06, (2.1, -0.3, 2.85), YELLOW, rot=(math.radians(90), 0, 0), verts=16)
export(os.path.join(OUT, "busstop.glb"))
def bicycle():
clear()
for wy in (-0.55, 0.55):
bpy.ops.mesh.primitive_torus_add(major_radius=0.32, minor_radius=0.035,
location=(0, wy, 0.32),
rotation=(math.radians(90), 0, math.radians(90)))
o = bpy.context.active_object
o.data.materials.append(INK)
box("frame1", (0.05, 0.7, 0.05), (0, 0.1, 0.55), RED, 0.0, rot=(math.radians(25), 0, 0))
box("frame2", (0.05, 0.6, 0.05), (0, -0.2, 0.5), RED, 0.0, rot=(math.radians(-35), 0, 0))
box("bar", (0.4, 0.05, 0.05), (0, -0.52, 0.85), INK, 0.0)
box("seat", (0.14, 0.3, 0.06), (0, 0.28, 0.85), INK, 0.01)
box("basket", (0.32, 0.3, 0.25), (0, -0.72, 0.72), METAL, 0.01)
export(os.path.join(OUT, "bicycle.glb"))
yatai()
koban()
phonebooth()
busstop()
bicycle()
print("hero: batch 2 built")
# ── Batch 3: footbridge + station platform ───────────────────────────────────
def footbridge():
"""Pedestrian bridge: 18m deck, X-trusses, railings, stairs both ends."""
clear()
SPAN, DH = 18.0, 5.5
box("deck", (SPAN, 3.0, 0.35), (0, 0, DH), GRAY, 0.03)
box("deck_edge", (SPAN + 0.2, 3.2, 0.12), (0, 0, DH - 0.22), INK, 0.01)
# side trusses: X pattern of rotated bars + top chord
for sy in (-1, 1):
y = sy * 1.45
box("chord", (SPAN, 0.12, 0.12), (0, y, DH + 1.25), TEAL, 0.01)
n = 6
for i in range(n):
x = -SPAN / 2 + SPAN / n * (i + 0.5)
for ang in (0.6, -0.6):
box("truss", (0.09, 0.09, 1.55), (x, y, DH + 0.62), TEAL, 0.0,
rot=(0, ang, 0))
# rail mesh
box("rail", (SPAN, 0.05, 0.5), (0, y, DH + 0.55), mat("railmesh", (0.3, 0.32, 0.38)), 0.0)
# stairs at both ends, descending outward along the bridge axis
steps = 10
for end in (-1, 1):
for i in range(steps):
z = DH - 0.3 - i * (DH - 0.3) / steps
x = end * (SPAN / 2 + 0.5 + i * 0.62)
box("step", (0.62, 2.6, 0.22), (x, 0, z), GRAY, 0.01)
# stair rails
box("stairrail", (steps * 0.64, 0.08, 0.08),
(end * (SPAN / 2 + 0.4 + steps * 0.31), 1.25, DH * 0.55), INK, 0.0,
rot=(0, end * -0.68, 0))
export(os.path.join(OUT, "footbridge.glb"))
def platform():
"""Station platform strip for the viaduct deck: canopy, benches, signs."""
clear()
L = 14.0
for i in range(4):
x = -L / 2 + L / 4 * (i + 0.5)
cyl("post", 0.1, 2.6, (x, 0, 1.3), METAL)
box("proof", (L, 2.2, 0.12), (0, 0, 2.7), WHITE, 0.02)
box("proof_edge", (L + 0.2, 2.4, 0.06), (0, 0, 2.6), TEAL, 0.01)
for x in (-L / 4, L / 4):
box("bench", (1.8, 0.45, 0.08), (x, 0.4, 0.5), WOOD, 0.01)
for sx in (-1, 1):
box("bleg", (0.08, 0.4, 0.5), (x + sx * 0.75, 0.4, 0.25), INK, 0.0)
box("psign", (2.2, 0.1, 0.55), (0, 0.9, 2.15), mat("psign", (0.15, 0.35, 0.6)), 0.01)
export(os.path.join(OUT, "platform.glb"))
footbridge()
platform()
print("hero: batch 3 built")