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