"""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()