destroyulator/tools/gen_level_props.py
Monster Robot Party 4bcd7b49d9 LANE7: four workplaces, built from data
Office.gd is gone. Floorplan.gd consumes a spec Dictionary — bounds, palette, walls
with doors and glazing styles, window runs with mullions and blinds, ceiling style,
light grid and style, slabs, static props, desk clusters, chairs, level-specific
smashables and spawn — and Levels.gd holds four of them.

Making this data rather than four subclasses means a site is authorable in minutes,
levels can be diffed, and a real generator can later emit the same structure (a BSP
split of the footprint -> rooms -> doors on shared walls -> fittings by room type).
That was the actual answer to "can we use this git to generate plans": the repo John
found is OpenSCAD SVG->STL for 3D printing, with no generation, no plan parsing, no
room polygons and GPL-3.0, so it can't help — but Office.gd was already most of a
parametric plan builder, and lifting its numbers out is the real path.

The four sites are deliberately not reskins; they differ in the three things a player
actually reads — palette, light, and what the walls are made of:

  SCRANTON      magnolia, grey carpet, drop ceiling, fluorescent troffers, daylight
                down one glazed wall. The baseline.
  PAWNEE        civic beige and blue-grey, low partitions everywhere, a public
                counter, pinboards. Municipal and over-partitioned.
  THE INCUBATOR timber floor, white walls, 3 m ceiling, PENDANT lights, glass wall
                onto a pool. Nobody has an office; they work at a dining table.
  SUB-LEVEL 4   concrete, NO windows at all, exposed services, bare strip lights, a
                wall of server racks. The light is green and everything is junk.

L cycles sites in-game; _load_level tears down the shell, rebuilds, re-registers task
stations and re-arms the shift.

tools/gen_level_props.py adds ten more procedural props. The filing cabinet is the
important one: it exports as a CARCASS plus a separate DRAWER, each with its own
floor-centre origin, so the Gauntlet can pull a drawer out as its own rigid body and
spill the files. Also file folder, desk phone, guillotine, shredder, server rack,
sofa, wastebin, stapler.

dev/probe_levels.gd builds every level and reports residual motion after a full
second. All four read 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:58:41 +10:00

405 lines
16 KiB
Python

"""Second batch of procedural props: the ones the Gauntlet and the three new workplaces
need that a box can't fake.
Same rule as tools/gen_office_props.py — if it IS a box (counters, partitions, tables,
whiteboards) it gets built in code by the level builder. This file is only for silhouettes.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_level_props.py -- [--render]
Convention: glTF Y-up, 1 unit = 1 m, ORIGIN AT FLOOR-CENTRE, so a level can drop a prop
at a floor position with no offset maths. In Blender that means authoring Z-up with the
base on z = 0.
The cabinet is the exception and the important one: it exports as a CARCASS plus four
separate DRAWER meshes, each with its own origin at its own floor-centre, so the Gauntlet
can pull a drawer out as its own rigid body and spill the files inside it.
"""
import bpy
import math
import os
import sys
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"game", "assets", "store")
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
RENDER = "--render" in sys.argv
def reset():
bpy.ops.wm.read_factory_settings(use_empty=True)
def mat(name, color, rough=0.6, metal=0.0, alpha=1.0):
m = bpy.data.materials.new(name)
m.use_nodes = True
b = m.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha)
b.inputs["Roughness"].default_value = rough
b.inputs["Metallic"].default_value = metal
if alpha < 1.0:
b.inputs["Alpha"].default_value = alpha
m.blend_method = 'BLEND'
return m
def assign(o, m):
o.data.materials.clear()
o.data.materials.append(m)
return o
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(scale=True)
return o
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None):
if r2 is None:
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
location=loc, rotation=rot)
else:
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
vertices=verts, location=loc, rotation=rot)
return bpy.context.active_object
def bevel(o, width=0.008, segments=2):
m = o.modifiers.new("bevel", "BEVEL")
m.width = width
m.segments = segments
m.limit_method = 'ANGLE'
m.angle_limit = math.radians(40)
bpy.context.view_layer.objects.active = o
try:
bpy.ops.object.modifier_apply(modifier=m.name)
except Exception:
o.modifiers.remove(m)
return o
def shade_smooth(o, angle=34.0):
bpy.context.view_layer.objects.active = o
o.select_set(True)
try:
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle))
except Exception:
bpy.ops.object.shade_smooth()
o.select_set(False)
# ---------------------------------------------------------------- props
def p_cabinet_carcass(M):
"""Four-drawer cabinet with the drawer OPENINGS empty — drawers ship separately."""
parts = []
W, D, H = 0.46, 0.62, 1.32
T = 0.022
for s in (-1, 1): # sides
parts.append(assign(box((T, D, H), loc=(s * (W / 2 - T / 2), 0, H / 2)), M["cab"]))
parts.append(assign(box((W, T, H), loc=(0, D / 2 - T / 2, H / 2)), M["cab"])) # back
parts.append(assign(box((W, D, T), loc=(0, 0, H - T / 2)), M["cab_top"])) # top
parts.append(assign(box((W, D, T), loc=(0, 0, T / 2)), M["cab"])) # base
for i in range(5): # shelf rails between drawers
z = 0.045 + i * 0.312
parts.append(assign(box((W - 2 * T, D - T, 0.012), loc=(0, 0, z)), M["cab_dark"]))
plinth = box((W - 0.04, D - 0.04, 0.045), loc=(0, 0, 0.0225))
parts.append(assign(plinth, M["cab_dark"]))
return parts
def p_cabinet_drawer(M):
"""One drawer, origin at ITS OWN floor-centre so it can become its own rigid body."""
parts = []
W, D, H = 0.40, 0.58, 0.27
T = 0.014
parts.append(assign(box((W, T, H), loc=(0, -D / 2 + T / 2, H / 2)), M["cab"])) # front
parts.append(assign(box((W, T, H * 0.7), loc=(0, D / 2 - T / 2, H * 0.35)), M["cab"]))
for s in (-1, 1):
parts.append(assign(box((T, D, H * 0.8), loc=(s * (W / 2 - T / 2), 0, H * 0.4)),
M["cab"]))
parts.append(assign(box((W, D, T), loc=(0, 0, T / 2)), M["cab"]))
handle = box((0.17, 0.030, 0.032), loc=(0, -D / 2 - 0.012, H * 0.60))
parts.append(assign(bevel(handle, 0.008), M["cab_dark"]))
label = box((0.10, 0.006, 0.036), loc=(0, -D / 2 - 0.004, H * 0.26))
parts.append(assign(label, M["paper"]))
return parts
def p_file_folder(M):
"""A manila folder. Lies flat; the Gauntlet stands them up inside a drawer."""
parts = []
body = box((0.24, 0.32, 0.014), loc=(0, 0, 0.007))
parts.append(assign(bevel(body, 0.004), M["manila"]))
tab = box((0.075, 0.030, 0.012), loc=(-0.06, 0.172, 0.006))
parts.append(assign(tab, M["manila"]))
for i in range(3): # paper peeking out
sheet = box((0.225, 0.30, 0.003), loc=(0.002, -0.004 + i * 0.003, 0.012 + i * 0.003))
parts.append(assign(sheet, M["paper"]))
return parts
def p_desk_phone(M):
parts = []
base = box((0.21, 0.24, 0.055), loc=(0, 0, 0.028), rot=(math.radians(-7), 0, 0))
parts.append(assign(bevel(base, 0.010, 2), M["plastic_dark"]))
for r in range(4): # keypad
for c in range(3):
k = box((0.030, 0.024, 0.008),
loc=(-0.038 + c * 0.038, -0.052 + r * 0.032, 0.062 + r * 0.004))
parts.append(assign(k, M["plastic_pale"]))
cradle = box((0.20, 0.075, 0.045), loc=(0, 0.086, 0.085))
parts.append(assign(bevel(cradle, 0.012, 2), M["plastic_dark"]))
handset = box((0.055, 0.235, 0.048), loc=(0, 0.086, 0.128))
parts.append(assign(bevel(handset, 0.020, 3), M["plastic_dark"]))
for s in (-1, 1):
ear = box((0.070, 0.062, 0.036), loc=(0, s * 0.088 + 0.086, 0.142))
parts.append(assign(bevel(ear, 0.016, 3), M["plastic_dark"]))
# coiled cord
pos = Vector((-0.10, 0.02, 0.10))
for i in range(9):
a = i * 1.35
nxt = pos + Vector((math.cos(a) * 0.018 - 0.006, math.sin(a) * 0.018, -0.010))
s = _span(0.006, pos, nxt)
if s:
parts.append(assign(s, M["plastic_dark"]))
pos = nxt
return parts
def p_guillotine(M):
"""Paper trimmer. The alignment guide is 2 mm off and always has been."""
parts = []
base = box((0.42, 0.46, 0.030), loc=(0, 0, 0.015))
parts.append(assign(bevel(base, 0.006), M["board"]))
grid = box((0.34, 0.38, 0.002), loc=(-0.02, 0, 0.031))
parts.append(assign(grid, M["paper"]))
rail = box((0.030, 0.46, 0.028), loc=(0.196, 0, 0.044))
parts.append(assign(bevel(rail, 0.005), M["steel"]))
guide = box((0.30, 0.020, 0.026), loc=(-0.05, -0.16, 0.043))
parts.append(assign(guide, M["steel"]))
arm = box((0.022, 0.44, 0.055), loc=(0.185, 0.02, 0.10), rot=(math.radians(16), 0, 0))
parts.append(assign(bevel(arm, 0.005), M["steel"]))
blade = box((0.016, 0.40, 0.075), loc=(0.170, 0.02, 0.085), rot=(math.radians(16), 0, 0))
parts.append(assign(blade, M["blade"]))
grip = cyl(0.018, 0.10, loc=(0.185, 0.235, 0.175), rot=(math.radians(16), 0, 0), verts=12)
parts.append(assign(grip, M["plastic_dark"]))
return parts
def p_shredder(M):
"""The Gauntlet's perfect tool, if you work out that's what it is."""
parts = []
bin_ = box((0.36, 0.30, 0.44), loc=(0, 0, 0.22))
parts.append(assign(bevel(bin_, 0.012, 2), M["bin_grey"]))
win = box((0.22, 0.006, 0.26), loc=(0, -0.153, 0.24))
parts.append(assign(win, M["glass_dark"]))
head = box((0.40, 0.34, 0.13), loc=(0, 0, 0.505))
parts.append(assign(bevel(head, 0.014, 2), M["plastic_dark"]))
slot = box((0.30, 0.020, 0.012), loc=(0, 0, 0.572))
parts.append(assign(slot, M["black"]))
for i in range(3):
led = box((0.016, 0.014, 0.010), loc=(0.13, -0.15 + i * 0.02, 0.545))
parts.append(assign(led, M["led"] if i == 0 else M["black"]))
return parts
def p_server_rack(M):
parts = []
W, D, H = 0.62, 0.90, 1.90
for s in (-1, 1):
parts.append(assign(box((0.030, D, H), loc=(s * (W / 2), 0, H / 2)), M["black"]))
parts.append(assign(box((W, 0.030, H), loc=(0, D / 2, H / 2)), M["black"]))
parts.append(assign(box((W, D, 0.030), loc=(0, 0, H)), M["black"]))
parts.append(assign(box((W, D, 0.030), loc=(0, 0, 0.015)), M["black"]))
for i in range(9): # blade servers
z = 0.14 + i * 0.19
u = box((W - 0.07, D - 0.10, 0.155), loc=(0, 0.01, z))
parts.append(assign(bevel(u, 0.005), M["server"]))
for j in range(4):
led = box((0.014, 0.010, 0.012), loc=(-0.20 + j * 0.05, -D / 2 + 0.06, z + 0.05))
parts.append(assign(led, M["led"] if (i + j) % 3 else M["led_amber"]))
vent = box((0.22, 0.008, 0.10), loc=(0.09, -D / 2 + 0.055, z))
parts.append(assign(vent, M["black"]))
return parts
def p_sofa(M):
parts = []
seat = box((1.85, 0.86, 0.30), loc=(0, 0, 0.34))
parts.append(assign(bevel(seat, 0.045, 3), M["fabric"]))
back = box((1.85, 0.24, 0.52), loc=(0, 0.31, 0.62), rot=(math.radians(-8), 0, 0))
parts.append(assign(bevel(back, 0.050, 3), M["fabric"]))
for s in (-1, 1):
arm = box((0.20, 0.86, 0.28), loc=(s * 0.825, 0, 0.56))
parts.append(assign(bevel(arm, 0.055, 3), M["fabric"]))
for i in range(2):
cush = box((0.84, 0.76, 0.14), loc=(-0.44 + i * 0.88, -0.02, 0.545))
parts.append(assign(bevel(cush, 0.050, 3), M["fabric_light"]))
for sx in (-1, 1):
for sy in (-1, 1):
parts.append(assign(cyl(0.030, 0.19, loc=(sx * 0.78, sy * 0.34, 0.095), verts=10),
M["wood"]))
return parts
def p_bin(M):
parts = []
body = cyl(0.15, 0.36, loc=(0, 0, 0.18), r2=0.175, verts=18)
parts.append(assign(body, M["bin_grey"]))
rim = cyl(0.182, 0.028, loc=(0, 0, 0.352), verts=18)
parts.append(assign(rim, M["plastic_dark"]))
for i in range(5): # crumpled paper spilling out
a = i * 1.3
ball = box((0.075, 0.075, 0.065),
loc=(math.cos(a) * 0.06, math.sin(a) * 0.06, 0.36 + (i % 2) * 0.05),
rot=(a, a * 0.7, a * 0.3))
parts.append(assign(bevel(ball, 0.022, 2), M["paper"]))
return parts
def p_stapler(M):
parts = []
base = box((0.052, 0.175, 0.022), loc=(0, 0, 0.011))
parts.append(assign(bevel(base, 0.008, 2), M["stapler_red"]))
anvil = box((0.040, 0.040, 0.006), loc=(0, -0.058, 0.024))
parts.append(assign(anvil, M["steel"]))
top = box((0.048, 0.168, 0.030), loc=(0, 0.006, 0.041), rot=(math.radians(4), 0, 0))
parts.append(assign(bevel(top, 0.010, 2), M["stapler_red"]))
hinge = cyl(0.012, 0.052, loc=(0, 0.082, 0.030), rot=(0, math.radians(90), 0), verts=10)
parts.append(assign(hinge, M["steel"]))
return parts
def _span(r, p0, p1, verts=8):
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
n = d.length
if n < 1e-6:
return None
o = cyl(r, n, verts=verts)
quat = Vector((0, 0, 1)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
return o
PROPS = {
"cabinet-carcass": p_cabinet_carcass,
"cabinet-drawer": p_cabinet_drawer,
"file-folder": p_file_folder,
"desk-phone": p_desk_phone,
"guillotine": p_guillotine,
"shredder": p_shredder,
"server-rack": p_server_rack,
"sofa": p_sofa,
"wastebin": p_bin,
"stapler": p_stapler,
}
def materials():
return {
"cab": mat("cab", (0.62, 0.63, 0.64), rough=0.45, metal=0.6),
"cab_top": mat("cab_top", (0.58, 0.59, 0.60), rough=0.42, metal=0.6),
"cab_dark": mat("cab_dark", (0.22, 0.23, 0.25), rough=0.5),
"manila": mat("manila", (0.78, 0.66, 0.40), rough=0.95),
"paper": mat("paper", (0.93, 0.92, 0.88), rough=1.0),
"plastic_dark": mat("plastic_dark", (0.10, 0.10, 0.11), rough=0.55),
"plastic_pale": mat("plastic_pale", (0.80, 0.79, 0.74), rough=0.6),
"black": mat("black", (0.05, 0.05, 0.06), rough=0.6),
"steel": mat("steel", (0.66, 0.67, 0.70), rough=0.32, metal=1.0),
"blade": mat("blade", (0.86, 0.87, 0.90), rough=0.14, metal=1.0),
"board": mat("board", (0.34, 0.24, 0.16), rough=0.75),
"bin_grey": mat("bin_grey", (0.30, 0.31, 0.33), rough=0.7),
"glass_dark": mat("glass_dark", (0.12, 0.14, 0.15), rough=0.2),
"led": mat("led", (0.15, 0.85, 0.30), rough=0.3),
"led_amber": mat("led_amber", (0.90, 0.55, 0.10), rough=0.3),
"server": mat("server", (0.24, 0.25, 0.28), rough=0.5, metal=0.4),
"fabric": mat("fabric", (0.30, 0.33, 0.38), rough=0.98),
"fabric_light": mat("fabric_light", (0.38, 0.41, 0.46), rough=0.98),
"wood": mat("wood", (0.36, 0.24, 0.14), rough=0.6),
"stapler_red": mat("stapler_red", (0.52, 0.08, 0.10), rough=0.4),
}
def export(objs, name):
objs = [o for o in objs if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
path = os.path.join(OUT_DIR, name + ".glb")
bpy.ops.export_scene.gltf(filepath=path, export_format='GLB',
use_selection=True, export_apply=True, export_yup=True)
print("[props] %-20s -> %s" % (name, os.path.basename(path)))
bpy.ops.object.select_all(action='DESELECT')
def render_preview(name):
scene = bpy.context.scene
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
if want in engines:
scene.render.engine = want
break
scene.render.resolution_x = 520
scene.render.resolution_y = 520
if scene.world is None:
scene.world = bpy.data.worlds.new("preview")
scene.world.use_nodes = True
bg = scene.world.node_tree.nodes.get("Background")
if bg:
bg.inputs[0].default_value = (0.07, 0.07, 0.08, 1.0)
lo = Vector((1e9,) * 3)
hi = Vector((-1e9,) * 3)
for o in scene.objects:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
centre = (lo + hi) * 0.5
radius = max((hi - lo).length * 0.5, 0.05)
dist = radius * 2.9
d = Vector((0.66, -0.72, 0.30)).normalized()
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
cam = bpy.context.active_object
cam.data.lens = 58
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
scene.camera = cam
for off, e, s in ((Vector((1.0, -1.0, 1.1)), 58.0, 1.2),
(Vector((-1.1, -0.5, 0.3)), 17.0, 1.8)):
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
L = bpy.context.active_object
L.data.energy = e * (dist ** 2)
L.data.size = s * radius
scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png")
bpy.ops.render.render(write_still=True)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
for name, builder in PROPS.items():
reset()
M = materials()
parts = builder(M)
for p in parts:
shade_smooth(p, 34.0)
export(parts, name)
if RENDER:
render_preview(name)
print("[props] done -> %s" % OUT_DIR)
main()