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>
405 lines
16 KiB
Python
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()
|