destroyulator/tools/gen_office_props.py
Monster Robot Party d17e2314aa LANE6: LEVEL 01 is an office — Dunder Mifflin floor plan, and a spawn guard
The game had no room. Props sat on a grey disc in a black void, which quietly broke
the premise: a game about wrecking your workplace needs a workplace.

Office.gd — LEVEL 01
- Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk
  pairs behind cubicle partitions, reception, glass-walled manager's office, conference
  room, break room with vending machines, copier alcove, warehouse roller door.
- Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources
  (the old scene lit an interior with one outdoor directional lamp, which is exactly
  why it read as "props on a plane"), window wall with blinds, magnolia and carpet.
- Office owns the shell + static fittings; Main._populate() owns everything smashable
  and asks Office where things go. Walls/desks/counters are boxes because they ARE
  boxes; chairs, vending machines, microwave and plant come from a new Blender
  generator (tools/gen_office_props.py) because a box reads as wrong for those.

The level no longer falls over on its own
- Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved
  27 cm of interpenetration explosively on frame one and shoved the furniture over
  before the player touched anything. They now stand side by side.
- Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a
  filing cabinet.
- Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and
  depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are
  speed-limited for 0.75 s, and each offender is named once with the position it was
  PLACED at, so the cause stays visible instead of being papered over. It then found
  the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting
  back-to-back with 3 cm to spare. Rows are now 4.8 m apart.
- dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now
  reads 0.00 m/s with zero spawn warnings.

Two real melee bugs found while testing the level
- The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's
  reach fell in front of it and was missed — you could stand against the printer with
  a sledgehammer and swing straight through it. Now a capsule swept from the camera.
- Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even
  standing over it and short-reach weapons could never touch anything on the ground.
  Swings now originate at hand height.

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

322 lines
12 KiB
Python

"""Generate the office props Godot primitives can't fake, as GLBs, in Blender.
The office level builds its walls, desks, partitions and counters from boxes in code —
those ARE boxes, so modelling them is wasted effort. These four aren't: an office chair,
a vending machine, a microwave and a potted plant all have silhouettes that a box reads
as obviously wrong, and all four are things you instantly recognise in a workplace.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_office_props.py -- [--render]
Convention (same as the rest of the repo): glTF Y-up, 1 unit = 1 m, ORIGIN AT
FLOOR-CENTRE, so Office.gd can drop a prop at a floor position with no offset maths.
In Blender that means authoring Z-up with the base sitting on z = 0.
"""
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 ball(r, loc=(0, 0, 0), segs=12, rings=8):
bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings,
location=loc)
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=35.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_chair(M):
"""Task chair: 5-star base, gas lift, seat, backrest, arms. ~1.05 m tall."""
parts = []
# star base + castors
for i in range(5):
a = math.radians(i * 72.0)
d = Vector((math.cos(a), math.sin(a), 0.0))
arm = box((0.055, 0.30, 0.035), loc=tuple(d * 0.16 + Vector((0, 0, 0.055))),
rot=(0, 0, a + math.radians(90)))
parts.append(assign(bevel(arm, 0.010), M["plastic_dark"]))
w = cyl(0.030, 0.022, loc=tuple(d * 0.30 + Vector((0, 0, 0.030))),
rot=(math.radians(90), 0, a), verts=12)
parts.append(assign(w, M["rubber"]))
hub = cyl(0.055, 0.06, loc=(0, 0, 0.075), verts=16)
parts.append(assign(hub, M["plastic_dark"]))
# gas cylinder
col = cyl(0.030, 0.24, loc=(0, 0, 0.22), verts=14)
parts.append(assign(col, M["steel"]))
shroud = cyl(0.042, 0.16, loc=(0, 0, 0.18), r2=0.034, verts=14)
parts.append(assign(shroud, M["plastic_dark"]))
# seat pan
seat = box((0.46, 0.44, 0.075), loc=(0, 0, 0.375))
parts.append(assign(bevel(seat, 0.028, 3), M["fabric"]))
# backrest, reclined
back = box((0.44, 0.070, 0.50), loc=(0, -0.20, 0.70),
rot=(math.radians(-11), 0, 0))
parts.append(assign(bevel(back, 0.026, 3), M["fabric"]))
spine = box((0.07, 0.09, 0.20), loc=(0, -0.185, 0.47))
parts.append(assign(bevel(spine, 0.012), M["plastic_dark"]))
# armrests
for s in (-1, 1):
post = box((0.04, 0.05, 0.16), loc=(s * 0.245, -0.03, 0.475))
parts.append(assign(bevel(post, 0.010), M["plastic_dark"]))
pad = box((0.06, 0.26, 0.035), loc=(s * 0.245, 0.01, 0.565))
parts.append(assign(bevel(pad, 0.014, 3), M["plastic_dark"]))
return parts
def p_vending(M):
"""Drinks machine, 1.83 m. Glass front so it reads as a vending machine, not a box."""
parts = []
shell = box((0.90, 0.78, 1.83), loc=(0, 0, 0.915))
parts.append(assign(bevel(shell, 0.014, 2), M["vend_red"]))
glass = box((0.62, 0.03, 1.28), loc=(-0.10, -0.395, 1.02))
parts.append(assign(glass, M["glass"]))
frame = box((0.68, 0.05, 1.36), loc=(-0.10, -0.375, 1.02))
parts.append(assign(bevel(frame, 0.008), M["plastic_dark"]))
# stacked cans behind the glass
for row in range(5):
for c in range(4):
can = cyl(0.033, 0.115, loc=(-0.31 + c * 0.14, -0.30, 0.52 + row * 0.24),
verts=10)
parts.append(assign(can, M["can"] if (row + c) % 2 else M["can2"]))
# selection panel + delivery flap
panel = box((0.17, 0.04, 1.10), loc=(0.31, -0.39, 1.12))
parts.append(assign(bevel(panel, 0.008), M["plastic_dark"]))
for i in range(5):
btn = box((0.055, 0.02, 0.045), loc=(0.31, -0.41, 1.52 - i * 0.13))
parts.append(assign(btn, M["can2"]))
flap = box((0.60, 0.05, 0.26), loc=(-0.10, -0.38, 0.28))
parts.append(assign(bevel(flap, 0.010), M["plastic_dark"]))
top = box((0.92, 0.80, 0.10), loc=(0, 0, 1.86))
parts.append(assign(bevel(top, 0.012), M["plastic_dark"]))
return parts
def p_microwave(M):
parts = []
shell = box((0.50, 0.38, 0.29), loc=(0, 0, 0.145))
parts.append(assign(bevel(shell, 0.010, 2), M["appliance"]))
door = box((0.34, 0.03, 0.22), loc=(-0.07, -0.20, 0.15))
parts.append(assign(door, M["glass_dark"]))
handle = box((0.030, 0.035, 0.20), loc=(0.115, -0.215, 0.15))
parts.append(assign(bevel(handle, 0.008), M["plastic_dark"]))
pad = box((0.10, 0.02, 0.22), loc=(0.19, -0.20, 0.15))
parts.append(assign(pad, M["plastic_dark"]))
return parts
def p_plant(M):
"""The obligatory sad office ficus."""
parts = []
pot = cyl(0.17, 0.34, loc=(0, 0, 0.17), r2=0.22, verts=18)
parts.append(assign(pot, M["terracotta"]))
rim = cyl(0.235, 0.05, loc=(0, 0, 0.335), verts=18)
parts.append(assign(rim, M["terracotta"]))
soil = cyl(0.20, 0.03, loc=(0, 0, 0.345), verts=18)
parts.append(assign(soil, M["soil"]))
trunk = cyl(0.028, 0.44, loc=(0, 0, 0.56), r2=0.020, verts=8)
parts.append(assign(trunk, M["stem"]))
# Leaves radiate from points ON the trunk and droop outward. Building each one as a
# span between two explicit points keeps it attached — driving it with euler tilts
# left the blades floating in mid-air, disconnected from their stalks.
for i in range(12):
a = math.radians(i * 73.0)
h = 0.60 + (i % 4) * 0.12
droop = -0.10 - (i % 3) * 0.06
out = Vector((math.cos(a), math.sin(a), 0.0))
root = Vector((0.0, 0.0, h)) + out * 0.018
stalk_end = root + out * 0.11 + Vector((0, 0, droop * 0.4))
tip = stalk_end + out * 0.21 + Vector((0, 0, droop))
parts.append(assign(_span(0.009, 0.009, root, stalk_end), M["stem"]))
parts.append(assign(_span(0.075, 0.012, stalk_end, tip, flat=True), M["leaf"]))
return parts
def _span(half_w, half_t, p0, p1, flat=False):
"""A box (or flattened blade) running from p0 to p1, oriented along the span."""
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
n = d.length
if n < 1e-6:
return None
o = box((half_w * 2.0, n, half_t * 2.0) if flat else (half_w * 2.0, n, half_w * 2.0))
quat = Vector((0, 1, 0)).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 bevel(o, 0.006, 2)
PROPS = {
"office-chair": p_chair,
"vending-machine": p_vending,
"microwave": p_microwave,
"office-plant": p_plant,
}
def materials():
return {
"plastic_dark": mat("plastic_dark", (0.09, 0.09, 0.10), rough=0.55),
"rubber": mat("rubber", (0.05, 0.05, 0.06), rough=0.92),
"steel": mat("steel", (0.66, 0.67, 0.70), rough=0.32, metal=1.0),
"fabric": mat("fabric", (0.16, 0.18, 0.24), rough=0.96),
"vend_red": mat("vend_red", (0.55, 0.07, 0.09), rough=0.42),
"glass": mat("glass", (0.72, 0.80, 0.85), rough=0.06, alpha=0.30),
"glass_dark": mat("glass_dark", (0.10, 0.12, 0.13), rough=0.16),
"can": mat("can", (0.72, 0.14, 0.14), rough=0.35, metal=0.7),
"can2": mat("can2", (0.14, 0.34, 0.66), rough=0.35, metal=0.7),
"appliance": mat("appliance", (0.80, 0.80, 0.82), rough=0.36, metal=0.3),
"terracotta": mat("terracotta", (0.52, 0.29, 0.20), rough=0.85),
"soil": mat("soil", (0.14, 0.11, 0.09), rough=1.0),
"stem": mat("stem", (0.22, 0.30, 0.16), rough=0.8),
"leaf": mat("leaf", (0.20, 0.42, 0.19), rough=0.72),
}
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("[office] %-18s -> %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 = 560
scene.render.resolution_y = 560
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("[office] done -> %s" % OUT_DIR)
main()