#!/usr/bin/env python3 """Blender-authored NPC characters, NPC gear, and street props for Gig Line. Everything here is modelled procedurally inside Blender (flatpak, headless) so the assets are reproducible from source. Outputs are staged for *later* integration: nothing in the game loads them yet (see `docs/blender-assets.md` for the integration contract). # build every asset (or --only name,name) -> assets/models/blender/**.glb flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- build # Cycles preview renders -> docs/blender_previews/.png (local only) flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- render # outside Blender (stdlib + Pillow) python3 tools/blender_assets.py audit # GLB contract checks python3 tools/blender_assets.py fit # gear-vs-body fit measurements python3 tools/blender_assets.py sheet # sheet_.jpg (committed) python3 tools/blender_assets.py list The flatpak sandbox cwd is /app/blender and it cannot read /tmp, so every path is absolute and derived from this file's location. Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up): * the front of every asset faces Blender -Y, which becomes Godot +Z (the NPC face direction used by `NpcAvatar.yaw_towards`); * grounded assets have their origin on the floor (min y ~= 0); * characters reuse the npc_base contract: mesh node "npc_base", bones Head/HipL/R/KneeL/R/ShoulderL/R/ElbowL/R/HandL/R (+ Hips/Spine/Neck), material slots skin/cloth/pants/accessory/hair, same joint positions; * gear is authored in attach-point space (see GEAR_ATTACH). """ import argparse import json import math import struct import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT_DIR = ROOT / "assets" / "models" / "blender" SRC_DIR = ROOT / "assets" / "models" / "blender_src" PREVIEW_DIR = ROOT / "docs" / "blender_previews" NPC_TEX_DIR = ROOT / "assets" / "textures" / "npc" MANIFEST = OUT_DIR / "manifest.json" SLOTS = ["skin", "cloth", "pants", "accessory", "hair"] BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR"] CHAR_MAX_TRIS = 6000 # Raised from 4000 to 5000 for the 2026-11-01 face/hand detail pass. The glTF # vertex count is after UV-seam splits, so the extra eyes, lids, cheeks, nose and # fingers cost ~700 verts per variant; `smoke_npc_budget.gd` measured 16 avatars at # ~6.9 ms (budget 50 ms), and each avatar is still a single skinned mesh. CHAR_MAX_VERTS = 5000 GEAR_MAX_TRIS = 1500 PROP_MAX_TRIS = 4000 try: import bpy # type: ignore import bmesh # type: ignore from mathutils import Matrix, Vector # type: ignore except ImportError: # pragma: no cover - audit/sheet/list run outside Blender bpy = None # --------------------------------------------------------------------------- # Catalogue (pure data, readable without Blender) # --------------------------------------------------------------------------- # Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can # pick one per avatar later. `tint` lists the slots the runtime should tint; # every other material keeps its authored colour. CHARACTERS = { "npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"], "roles": ["tourist", "confused_visitor", "intoxicated", "guest"]}, "npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"], "roles": ["authorized_staff", "protester", "guest"]}, "npc_coat": {"layer": "coat", "hair": "long", "gear": ["sunglasses", "shoulder_bag", "phone"], "roles": ["influencer", "trespasser", "violent_threat"]}, "npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"], "roles": ["delivery"]}, "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "gear": ["shoulder_bag"], "roles": ["performer"]}, "npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "roles": ["trespasser", "mental_crisis", "violent_threat"]}, "npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad", "gear": ["firefighter_helmet", "scba_tank", "flashlight"], "roles": ["responder"]}, "npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", "gear": ["cap", "wand"], "roles": ["player_guard", "venue_security"]}, } # Gear origin = the attach point; offsets are in the attach node's local space. GEAR_ATTACH = { "head": "Rig/Head marker (y = 1.62 m, head centre)", "hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin", "back": "Rig root, origin at the spine centre (0, 1.10, 0)", "chest": "Rig root, origin at the sternum (0, 1.30, 0)", } # Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)). ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78), "back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)} # Attach points in the authored GLB space (Godot coordinates), used by `fit`. ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02), "back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)} HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres FIT_HEAD_MATERIALS = ("skin",) FIT_CLOTHING_MATERIAL = "cloth" FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests GEAR = { "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "firefighter_helmet": "head", "backpack": "back", "shoulder_bag": "back", "scba_tank": "back", "lanyard": "chest", "phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand", "camera": "hand", "protest_sign": "hand", "flashlight": "hand", "wand": "hand", "radio": "hand", } # Props: the first six replace assets/models/generated/.glb with the same # footprint; the rest are new street/venue pieces. PROPS = { "bollard": {"replaces": "generated/bollard.glb"}, "bench": {"replaces": "generated/bench.glb"}, "trash_bin": {"replaces": "generated/trash_bin.glb"}, "planter_box": {"replaces": "generated/planter_box.glb"}, "newspaper_box": {"replaces": "generated/newspaper_box.glb"}, "fire_hydrant": {"replaces": "generated/fire_hydrant.glb"}, "street_lamp": {"replaces": "WorldBuilder lamp boxes", "emissive": ["bulb"]}, "street_tree": {"replaces": "WorldBuilder cone trees"}, "palm_tree": {}, "round_planter": {}, "a_frame_sign": {}, "wall_lantern": {"emissive": ["bulb"]}, "traffic_cone": {"replaces": "prop accessory 'cone'"}, "crowd_barrier": {}, "stanchion": {}, "handrail": {}, "security_podium": {}, "double_door": {"emissive": ["lock_led"]}, "marquee": {"emissive": ["bulb", "sign_face"]}, "sedan": {"emissive": ["headlight"]}, } def asset_path(category: str, name: str) -> Path: return OUT_DIR / category / f"{name}.glb" def catalogue(): rows = [("npc", n) for n in CHARACTERS] rows += [("npc_gear", n) for n in GEAR] rows += [("props", n) for n in PROPS] return rows # --------------------------------------------------------------------------- # Blender helpers # --------------------------------------------------------------------------- def _srgb_to_linear(c: float) -> float: return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 # name: (sRGB 0-255, roughness, metallic, emission strength) PALETTE = { "black_metal": ((34, 35, 38), 0.45, 0.8, 0.0), "dark_metal": ((52, 55, 60), 0.4, 0.85, 0.0), "steel": ((170, 172, 176), 0.3, 1.0, 0.0), "chrome": ((215, 217, 220), 0.15, 1.0, 0.0), "brass": ((181, 146, 72), 0.3, 1.0, 0.0), "reflective": ((226, 222, 196), 0.25, 0.3, 0.0), "bin_green": ((44, 70, 54), 0.55, 0.3, 0.0), "news_blue": ((40, 88, 150), 0.5, 0.3, 0.0), "hydrant_red": ((176, 46, 36), 0.5, 0.2, 0.0), "wood": ((150, 92, 54), 0.7, 0.0, 0.0), "dark_wood": ((84, 54, 34), 0.7, 0.0, 0.0), "concrete": ((176, 172, 162), 0.9, 0.0, 0.0), "terracotta": ((170, 100, 70), 0.85, 0.0, 0.0), "stone_pot": ((196, 186, 168), 0.85, 0.0, 0.0), "soil": ((58, 42, 30), 1.0, 0.0, 0.0), "foliage": ((54, 104, 46), 0.85, 0.0, 0.0), "foliage_dark": ((36, 78, 36), 0.85, 0.0, 0.0), "palm_leaf": ((70, 112, 50), 0.8, 0.0, 0.0), "bark": ((92, 70, 52), 0.95, 0.0, 0.0), "palm_bark": ((128, 104, 78), 0.95, 0.0, 0.0), "glass": ((90, 120, 140), 0.08, 0.2, 0.0), "dark_glass": ((22, 26, 32), 0.06, 0.3, 0.0), "paper": ((228, 224, 210), 0.9, 0.0, 0.0), "chalkboard": ((32, 38, 36), 0.9, 0.0, 0.0), "chalk": ((232, 232, 226), 0.9, 0.0, 0.0), "plastic_black": ((26, 27, 30), 0.5, 0.0, 0.0), "plastic_grey": ((110, 114, 120), 0.5, 0.0, 0.0), "plastic_white": ((222, 222, 218), 0.45, 0.0, 0.0), "cone_orange": ((236, 96, 24), 0.55, 0.0, 0.0), "rubber": ((22, 22, 24), 0.9, 0.0, 0.0), "velvet_red": ((128, 20, 30), 0.9, 0.0, 0.0), "awning_red": ((150, 34, 38), 0.8, 0.0, 0.0), "door_paint": ((38, 40, 44), 0.5, 0.4, 0.0), "car_paint": ((18, 20, 24), 0.2, 0.6, 0.0), "bulb": ((255, 226, 160), 0.3, 0.0, 6.0), "headlight": ((240, 240, 255), 0.2, 0.0, 3.0), "lock_led": ((255, 40, 30), 0.3, 0.0, 4.0), "sign_face": ((255, 236, 196), 0.4, 0.0, 2.5), "hivis": ((214, 228, 40), 0.7, 0.0, 0.0), "leather": ((54, 38, 28), 0.6, 0.0, 0.0), "denim_bag": ((60, 72, 96), 0.8, 0.0, 0.0), "pack_nylon": ((40, 64, 92), 0.7, 0.0, 0.0), "lens": ((20, 22, 26), 0.05, 0.5, 0.0), "screen": ((26, 40, 70), 0.1, 0.0, 0.6), "cardboard": ((186, 150, 104), 0.9, 0.0, 0.0), "ribbon_blue": ((40, 80, 170), 0.7, 0.0, 0.0), "turnout": ((36, 40, 46), 0.85, 0.0, 0.0), "helmet": ((24, 26, 30), 0.3, 0.1, 0.0), "undershirt": ((60, 62, 68), 0.9, 0.0, 0.0), "uniform": ((30, 34, 46), 0.85, 0.0, 0.0), "patch": ((190, 150, 60), 0.6, 0.2, 0.0), "eye_white": ((236, 233, 226), 0.45, 0.0, 0.0), } _MATS = {} def _solid_image(name, rgb): """4x4 sRGB texture so a runtime albedo_color=WHITE tint keeps the colour.""" img = bpy.data.images.new(f"{name}_albedo", 4, 4, alpha=False) px = [rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0] * 16 img.pixels = px img.pack() return img def material(name, textured_solid=False): """Principled material from PALETTE (or an npc slot texture set).""" key = (name, textured_solid) if key in _MATS: return _MATS[key] mat = bpy.data.materials.new(name) mat.use_nodes = True nodes = mat.node_tree.nodes links = mat.node_tree.links bsdf = nodes.get("Principled BSDF") if name in SLOTS: # Same neutral maps as npc_base, so Materials.npc_slot() tints alike. albedo = nodes.new("ShaderNodeTexImage") albedo.image = bpy.data.images.load(str(NPC_TEX_DIR / f"{name}_albedo.png"), check_existing=True) albedo.image.colorspace_settings.name = "sRGB" links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"]) normal_tex = nodes.new("ShaderNodeTexImage") normal_tex.image = bpy.data.images.load( str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True) normal_tex.image.colorspace_settings.name = "Non-Color" nmap = nodes.new("ShaderNodeNormalMap") links.new(normal_tex.outputs["Color"], nmap.inputs["Color"]) links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92, "accessory": 0.5, "hair": 0.7}[name] bsdf.inputs["Roughness"].default_value = rough bsdf.inputs["Metallic"].default_value = 0.0 else: rgb, rough, metal, emit = PALETTE[name] lin = tuple(_srgb_to_linear(c / 255.0) for c in rgb) + (1.0,) bsdf.inputs["Base Color"].default_value = lin bsdf.inputs["Roughness"].default_value = rough bsdf.inputs["Metallic"].default_value = metal if textured_solid: tex = nodes.new("ShaderNodeTexImage") tex.image = _solid_image(name, rgb) tex.interpolation = "Closest" links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) if emit > 0.0: bsdf.inputs["Emission Color"].default_value = lin bsdf.inputs["Emission Strength"].default_value = emit _MATS[key] = mat return mat def reset_scene(): bpy.ops.wm.read_factory_settings(use_empty=True) _MATS.clear() def _link(name, bm): me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() obj = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(obj) return obj def _xf(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)): from mathutils import Euler m = Matrix.Translation(Vector(loc)) @ Euler(rot, "XYZ").to_matrix().to_4x4() s = Matrix.Diagonal(Vector((scale[0], scale[1], scale[2], 1.0))) return m @ s def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1): obj.data.materials.append(mat) if bevel > 0.0: mod = obj.modifiers.new("Bevel", "BEVEL") mod.width = bevel mod.segments = bevel_segments mod.limit_method = "ANGLE" mod.angle_limit = math.radians(40) _apply_all(obj) _clean(obj) if smooth: obj.data.shade_smooth() obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle)) else: obj.data.shade_flat() return obj def _clean(obj): """Drop zero-area faces (thin bevels, lathe poles) before export.""" bm = bmesh.new() bm.from_mesh(obj.data) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges) bm.to_mesh(obj.data) bm.free() def _apply_all(obj): bpy.context.view_layer.objects.active = obj for o in bpy.context.selected_objects: o.select_set(False) obj.select_set(True) for mod in list(obj.modifiers): bpy.ops.object.modifier_apply(modifier=mod.name) def box(name, size, loc, mat, rot=(0, 0, 0), bevel=0.0, segs=1): bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0, matrix=_xf(loc, rot, size)) return _finish(_link(name, bm), mat, smooth=bevel > 0, bevel=bevel, bevel_segments=segs) def cyl(name, r1, r2, depth, loc, mat, seg=16, rot=(0, 0, 0), smooth=True, scale=(1, 1, 1), caps=True, bevel=0.0): bm = bmesh.new() bmesh.ops.create_cone(bm, cap_ends=caps, cap_tris=False, segments=seg, radius1=r1, radius2=r2, depth=depth, matrix=_xf(loc, rot, scale)) return _finish(_link(name, bm), mat, smooth=smooth, bevel=bevel) def sphere(name, r, loc, mat, seg=16, rings=10, scale=(1, 1, 1), rot=(0, 0, 0), cut_below=None, smooth=True): """UV sphere; `cut_below` drops vertices under a local z (0..1 of radius).""" bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=r) if cut_below is not None: doomed = [v for v in bm.verts if v.co.z < cut_below * r - 1e-6] bmesh.ops.delete(bm, geom=doomed, context="VERTS") bmesh.ops.transform(bm, matrix=_xf(loc, rot, scale), verts=bm.verts) return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=60) def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6, scale=(1, 1, 1)): bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor, major_segments=seg, minor_segments=minor_seg) obj = bpy.context.object obj.name = name obj.data.transform(_xf(loc, rot, scale)) return _finish(obj, mat, smooth=True, sharp_angle=70) def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0, face_front=False, caps=True): """Revolve [(radius, z), ...] around Z (bottom to top), capped. `face_front` rotates the ring half a step so a flat face (not a corner) points at the front (-Y); used for octagonal street furniture. """ phase = (math.pi / seg + math.pi / 2) if face_front else 0.0 bm = bmesh.new() rings = [] for r, z in profile: ring = [] for i in range(seg): a = math.tau * i / seg + phase ring.append(bm.verts.new((loc[0] + r * math.cos(a), loc[1] + r * math.sin(a), loc[2] + z))) rings.append(ring) for a, b in zip(rings, rings[1:]): for i in range(seg): j = (i + 1) % seg bm.faces.new((a[i], a[j], b[j], b[i])) if caps and profile[0][0] > 1e-4: bm.faces.new(list(reversed(rings[0]))) if caps and profile[-1][0] > 1e-4: bm.faces.new(rings[-1]) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=sharp) def tube(name, points, radius, mat, seg=8, caps=True): """Bent tube through 3D points (handrails, umbrella handles, frames).""" bm = bmesh.new() rings = [] pts = [Vector(p) for p in points] for i, p in enumerate(pts): if i == 0: d = (pts[1] - p).normalized() elif i == len(pts) - 1: d = (p - pts[i - 1]).normalized() else: d = ((pts[i + 1] - p).normalized() + (p - pts[i - 1]).normalized()).normalized() up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0)) side = d.cross(up).normalized() up2 = side.cross(d).normalized() ring = [] for k in range(seg): a = math.tau * k / seg ring.append(bm.verts.new(p + radius * (math.cos(a) * side + math.sin(a) * up2))) rings.append(ring) for a, b in zip(rings, rings[1:]): for k in range(seg): j = (k + 1) % seg bm.faces.new((a[k], a[j], b[j], b[k])) if caps: bm.faces.new(list(reversed(rings[0]))) bm.faces.new(rings[-1]) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60) def join(objs, name): for o in bpy.context.selected_objects: o.select_set(False) for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] if len(objs) > 1: bpy.ops.object.join() obj = bpy.context.object obj.name = name obj.data.name = name return obj def uv_unwrap(obj, angle=60.0): for o in bpy.context.selected_objects: o.select_set(False) obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01) bpy.ops.object.mode_set(mode="OBJECT") def export(objs, category, name): path = asset_path(category, name) path.parent.mkdir(parents=True, exist_ok=True) for o in bpy.context.selected_objects: o.select_set(False) for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] bpy.ops.export_scene.gltf( filepath=str(path), export_format="GLB", use_selection=True, export_apply=True, export_animations=False, export_skins=True, export_yup=True, export_materials="EXPORT", export_image_format="AUTO") SRC_DIR.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"), compress=True) return path # --------------------------------------------------------------------------- # Characters # --------------------------------------------------------------------------- # Bone layout copied from tools/npc_pipeline.py so NpcAvatar's proxies line up. def _bone_layout(): b = {"Hips": ((0, 0, 0.62), (0, 0, 0.82), None), "Spine": ((0, 0, 0.82), (0, 0, 1.40), "Hips"), "Neck": ((0, 0, 1.40), (0, 0, 1.52), "Spine"), "Head": ((0, 0, 1.52), (0, 0, 1.82), "Neck")} for s, suf in ((-1.0, "L"), (1.0, "R")): x = 0.27 * s hx = 0.12 * s b[f"Shoulder{suf}"] = ((x, 0, 1.38), (x, 0, 1.06), "Spine") b[f"Elbow{suf}"] = ((x, 0, 1.06), (x, 0, 0.78), f"Shoulder{suf}") b[f"Hand{suf}"] = ((x, 0, 0.78), (x, 0, 0.68), f"Elbow{suf}") b[f"Hip{suf}"] = ((hx, 0, 0.62), (hx, 0, 0.30), "Hips") b[f"Knee{suf}"] = ((hx, 0, 0.30), (hx, 0, 0.0), f"Hip{suf}") return b BUILDS = { # torso width / depth multipliers, limb thickness "average": (1.0, 1.0, 1.0), "slim": (0.9, 0.92, 0.88), "broad": (1.12, 1.08, 1.1), } def _skin_body(spec): """Connected torso + limbs from a Skin modifier graph, subdivided once.""" w, d, t = BUILDS[spec.get("build", "average")] layer = spec["layer"] bulk = 1.0 + {"coat": 0.06, "jacket": 0.05, "hood": 0.06, "responder": 0.16, "guard": 0.03, "vest": 0.02}.get(layer, 0.0) sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22, "guard": 0.05}.get(layer, 0.0) leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0) nodes = [ # (pos, radius_x, radius_y) ((0, 0, 0.80), 0.165 * w, 0.115 * d), # 0 pelvis ((0, 0, 0.96), 0.150 * w * bulk, 0.105 * d * bulk), # 1 waist ((0, 0.0, 1.18), 0.170 * w * bulk, 0.115 * d * bulk), # 2 chest ((0, 0.0, 1.355), 0.172 * w * bulk, 0.108 * d * bulk), # 3 upper chest ((0, 0, 1.44), 0.058, 0.058), # 4 neck base ((0, -0.005, 1.53), 0.052, 0.052), # 5 neck top ] edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)] for s in (-1.0, 1.0): base = len(nodes) nodes += [ ((0.20 * s * w, 0, 1.39), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder ((0.27 * s * w, 0, 1.24), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm ((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow ((0.275 * s * w, -0.01, 0.80), 0.034 * t, 0.030 * t), # wrist ] edges += [(3, base), (base, base + 1), (base + 1, base + 2), (base + 2, base + 3)] hb = len(nodes) nodes += [ ((0.095 * s, 0, 0.72), 0.095 * t * leg, 0.10 * t * leg), # hip joint ((0.115 * s, 0, 0.52), 0.080 * t * leg, 0.085 * t * leg), # thigh ((0.12 * s, 0.0, 0.31), 0.058 * t * leg, 0.062 * t * leg), # knee ((0.12 * s, 0.01, 0.18), 0.055 * t * leg, 0.058 * t * leg), # calf ((0.12 * s, 0, 0.08), 0.040 * leg, 0.042 * leg), # ankle ] edges += [(0, hb), (hb, hb + 1), (hb + 1, hb + 2), (hb + 2, hb + 3), (hb + 3, hb + 4)] me = bpy.data.meshes.new("body") me.from_pydata([n[0] for n in nodes], edges, []) obj = bpy.data.objects.new("body", me) bpy.context.scene.collection.objects.link(obj) skin = obj.modifiers.new("Skin", "SKIN") skin.branch_smoothing = 0.6 for i, (_, rx, ry) in enumerate(nodes): me.skin_vertices[0].data[i].radius = (rx, ry) me.skin_vertices[0].data[0].use_root = True sub = obj.modifiers.new("Sub", "SUBSURF") sub.levels = 2 dec = obj.modifiers.new("Decimate", "DECIMATE") dec.ratio = 0.26 dec.use_symmetry = True dec.symmetry_axis = "X" _apply_all(obj) # Clean edge loops where materials change (waist, cuffs, short sleeves, neck). bm = bmesh.new() bm.from_mesh(obj.data) for z in (0.86, 1.19, 1.43): geom = bm.verts[:] + bm.edges[:] + bm.faces[:] bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1)) # Cuts next to existing vertices leave slivers; weld them. bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4) bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges) bm.to_mesh(obj.data) bm.free() # Front of the chest/backside slightly shaped: push chest forward a touch. for v in obj.data.vertices: if 1.05 < v.co.z < 1.38 and abs(v.co.x) < 0.17 and v.co.y < 0: v.co.y -= 0.012 * (1.0 - abs(v.co.x) / 0.17) if 0.72 < v.co.z < 0.9 and abs(v.co.x) < 0.17 and v.co.y > 0: v.co.y += 0.015 obj.data.shade_smooth() return obj def _assign_body_slots(obj, spec, mats): """Pick a slot per face from its centroid: skin / cloth / pants.""" layer = spec["layer"] names = [m.name for m in mats] obj.data.materials.clear() for m in mats: obj.data.materials.append(m) idx = {n: i for i, n in enumerate(names)} short_sleeve = layer in ("none", "vest", "dress") # Main surfaces always use the tintable contract slots (dress legs = tights). top_slot = "cloth" leg_slot = "pants" waist = 0.86 for poly in obj.data.polygons: c = poly.center arm = abs(c.x) > 0.205 and c.z > 0.78 if c.z > 1.43 and not arm: slot = "skin" elif arm: if c.z < 0.86 or (short_sleeve and c.z < 1.19): slot = "skin" else: slot = top_slot elif c.z < waist: slot = leg_slot if layer == "dress" and c.z > 0.40: slot = "cloth" else: slot = top_slot poly.material_index = idx[slot] def _seg_dist(p, a, b): ab = b - a t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-9))) return (p - (a + ab * t)).length def _auto_weights(obj, layout, rigid=None): """Distance-to-bone weights limited by body region (no heat-solve flakes).""" segs = {n: (Vector(h), Vector(t)) for n, (h, t, _) in layout.items()} groups = {n: obj.vertex_groups.new(name=n) for n in layout} for v in obj.data.vertices: p = v.co side = "L" if p.x < 0 else "R" if rigid: bone = rigid(p) if bone: groups[bone].add([v.index], 1.0, "REPLACE") continue if abs(p.x) > 0.19 and p.z > 0.74 and p.z < 1.42: allowed = ["Spine", f"Shoulder{side}", f"Elbow{side}", f"Hand{side}"] if abs(p.x) > 0.23: allowed.remove("Spine") elif p.z < 0.80: allowed = ["Hips", f"Hip{side}", f"Knee{side}"] if p.z < 0.66: allowed.remove("Hips") elif p.z > 1.40: allowed = ["Spine", "Neck", "Head"] else: allowed = ["Hips", "Spine", "Neck"] scored = sorted((_seg_dist(p, *segs[b]), b) for b in allowed)[:2] ws = [(1.0 / (dd + 0.02) ** 4, b) for dd, b in scored] total = sum(w for w, _ in ws) for w, b in ws: if w / total > 0.02: groups[b].add([v.index], w / total, "REPLACE") def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645), scale=(0.95, 1.04, 1.1)): """Head-hugging cap: cut at the brow in front and lower at the back.""" bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=radius) bmesh.ops.transform(bm, matrix=_xf(center, (0, 0, 0), scale), verts=bm.verts) doomed = [] for v in bm.verts: t = max(0.0, min(1.0, 0.5 - (v.co.y - center[1]) / (radius * 1.2))) cut = back_z + (front_z - back_z) * t if v.co.z < cut: doomed.append(v) bmesh.ops.delete(bm, geom=doomed, context="VERTS") return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70) def _hair(style, mat_hair, broad=False): parts = [] if style == "buzz": parts.append(_hair_cap("Hair", mat_hair, 1.705, 1.58, radius=0.158)) elif style in ("short", "long", "bun"): parts.append(_hair_cap("Hair", mat_hair, 1.715, 1.56)) parts.append(sphere("Fringe", 0.08, (0.025, -0.115, 1.755), mat_hair, seg=10, rings=6, scale=(1.35, 0.5, 0.42), rot=(0.35, 0, -0.18))) if style == "long": parts.append(sphere("HairBack", 0.15, (0, 0.06, 1.52), mat_hair, seg=14, rings=8, scale=(1.0, 0.5, 1.35))) for s in (-1.0, 1.0): parts.append(sphere("Lock", 0.06, (0.125 * s, -0.01, 1.57), mat_hair, seg=8, rings=6, scale=(0.6, 0.9, 1.9))) if style == "bun": parts.append(sphere("Bun", 0.065, (0, 0.13, 1.76), mat_hair, seg=12, rings=8)) return parts def _hood(name, mat, head_center): """Hood shell around the head with an oval face opening and a solid rim.""" cx, cy, cz = head_center bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19) bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0), (0.97, 1.05, 1.06)), verts=bm.verts) doomed = [v for v in bm.verts if v.co.z < cz - 0.17 or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2 + ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)] bmesh.ops.delete(bm, geom=doomed, context="VERTS") # Snap the stair-stepped opening onto the ellipse for a clean edge. for v in bm.verts: if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16: dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165 k = math.hypot(dx, dz) if k > 1e-6: v.co.x = cx + dx / k * 0.125 v.co.z = cz - 0.01 + dz / k * 0.165 # Thicken into a shell so the open edge reads as fabric, not a paper cut. bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60) def _face(mats, spec): """Head with readable eyes, brow ridge, nose, lips and ears. At conversation distance the eye region carries the read, so each eye gets a white sclera, a dark iris and an upper lid, and the face gets cheekbones and a nose bridge instead of a single cone (realism pass detail work). """ skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] white = material("eye_white", textured_solid=True) parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12, scale=(0.92, 1.0, 1.12))] # jaw / chin and cheekbones soften the egg into a face parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.556), skin, seg=12, rings=8, scale=(1.0, 1.0, 0.8))) for s in (-1.0, 1.0): parts.append(sphere("Cheek", 0.034, (0.076 * s, -0.104, 1.606), skin, seg=8, rings=5, scale=(1.0, 0.7, 0.8))) # nose: bridge and ball (nostrils were cut to stay inside the vertex budget) parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8, rot=(math.radians(-72), 0, 0), smooth=True)) parts.append(sphere("NoseTip", 0.024, (0, -0.163, 1.616), skin, seg=10, rings=6, scale=(1.0, 0.9, 0.85))) for s in (-1.0, 1.0): # ear with a lobe, and one crease so it is not a flat disc parts.append(sphere("Ear", 0.036, (0.143 * s, 0.008, 1.63), skin, seg=8, rings=5, scale=(0.42, 1.0, 1.35))) parts.append(sphere("EarLobe", 0.018, (0.142 * s, -0.005, 1.605), skin, seg=8, rings=5, scale=(0.45, 1.0, 0.9))) # eye: sclera, iris, upper lid and brow ex, ey, ez = 0.052 * s, -0.138, 1.664 parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=8, rings=5, scale=(1.25, 0.7, 0.85))) parts.append(sphere("Iris", 0.0105, (ex, ey - 0.013, ez), acc, seg=8, rings=5, scale=(1.0, 0.55, 1.0))) parts.append(box("Lid", (0.038, 0.010, 0.008), (ex, ey + 0.004, ez + 0.014), skin, rot=(math.radians(-10), 0, 0))) parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair, rot=(0, 0.14 * s, 0.0))) # lips: upper and lower instead of a single bar parts.append(box("LipUpper", (0.052, 0.012, 0.008), (0, -0.145, 1.588), acc, rot=(math.radians(6), 0, 0))) parts.append(box("LipLower", (0.046, 0.012, 0.009), (0, -0.143, 1.578), acc)) if spec.get("beard"): parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8, scale=(1.0, 0.9, 0.75), cut_below=-0.2)) return parts def _hands_and_shoes(mats, spec): skin = mats["skin"] shoe = mats["accessory"] parts = [] w = BUILDS[spec.get("build", "average")][0] for s in (-1.0, 1.0): x = 0.275 * s * w parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6, scale=(0.55, 0.95, 1.25))) parts.append(sphere("Thumb", 0.018, (x - 0.014 * s, -0.048, 0.752), skin, seg=8, rings=6, scale=(1.0, 1.0, 1.7), rot=(0.4, 0, 0))) # four fingers: relaxed, slightly curled forward, outer ones shorter for index, offset in enumerate((-0.024, -0.008, 0.008, 0.024)): length = (0.046, 0.052, 0.050, 0.040)[index] parts.append(cyl("Finger", 0.011, 0.009, length, (x + offset * s, -0.030, 0.714 - length * 0.5), skin, seg=6, rot=(math.radians(14), 0, 0))) parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe, bevel=0.028, segs=1)) parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01), material("rubber", textured_solid=True))) return parts def _clothing(spec, mats): layer = spec["layer"] cloth = mats["cloth"] acc = mats["accessory"] parts = [] if layer in ("coat", "dress", "responder"): bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer] flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer] mat = cloth top = 0.17 if layer != "responder" else 0.20 parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1), (top + 0.03, 0.80), (top, 0.93)], mat, seg=16, caps=False)) # Double-sided shell: an inner copy with flipped normals, so the open # hem does not vanish under Godot's back-face culling. bm = bmesh.new() bm.from_mesh(parts[-1].data) bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012) bm.to_mesh(parts[-1].data) bm.free() for o in parts[-1:]: for v in o.data.vertices: # flatten front/back v.co.y *= 0.8 if layer in ("coat", "jacket", "guard", "responder"): mat = cloth parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat, rot=(math.radians(8), 0, 0), seg=16)) # sleeve cuffs at the wrists so the arm does not end in bare skin for s in (-1.0, 1.0): parts.append(torus("Cuff", 0.045, 0.012, (0.275 * s, -0.008, 0.80), mat, seg=12, minor_seg=4)) parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc)) if layer == "vest": hv = mats["hivis"] refl = mats["reflective"] parts.append(lathe("Vest", [(0.162, 0.86), (0.18, 0.98), (0.19, 1.18), (0.19, 1.30), (0.15, 1.40)], hv, seg=20, smooth=True)) for v in parts[-1].data.vertices: v.co.y *= 0.70 for z in (0.98, 1.08): band = torus("Band", 0.188, 0.012, (0, 0, z), refl, seg=24, minor_seg=4) for v in band.data.vertices: v.co.y *= 0.70 parts.append(band) for s in (-1.0, 1.0): parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl, rot=(0, 0.12 * s, 0))) if layer == "hood": parts.append(_hood("Hood", cloth, (0, 0, 1.635))) parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth)) for s in (-1.0, 1.0): parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38), acc, seg=5)) if layer == "responder": refl = mats["reflective"] hv = mats["hivis"] for z, r in ((0.60, 0.252), (1.16, 0.205)): band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4) for v in band.data.vertices: v.co.y *= 0.8 if z < 1.0 else 0.66 parts.append(band) for s in (-1.0, 1.0): parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl, rot=(0, 0, 0), seg=14, minor_seg=4)) parts.append(box("Boot", (0.12, 0.27, 0.22), (0.12 * s, -0.04, 0.11), mats["rubber"], bevel=0.03, segs=2)) if layer == "guard": belt = torus("Belt", 0.168, 0.022, (0, 0, 0.86), mats["leather"], seg=24, minor_seg=4, scale=(1.0, 0.72, 1.4)) parts.append(belt) parts.append(box("Buckle", (0.05, 0.012, 0.035), (0, -0.125, 0.86), mats["steel"])) parts.append(box("Badge", (0.045, 0.01, 0.055), (0.085, -0.128, 1.27), mats["patch"], bevel=0.006)) for s in (-1.0, 1.0): parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28), mats["patch"], bevel=0.005)) parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84), mats["leather"])) parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30), mats["plastic_black"], bevel=0.008)) return parts def build_character(name): spec = dict(CHARACTERS[name]) if name == "npc_hoodie": spec["beard"] = True mats = {slot: material(slot) for slot in SLOTS} for extra in ("hivis", "reflective", "leather", "steel", "patch", "plastic_black", "rubber", "helmet"): mats[extra] = material(extra, textured_solid=True) layout = _bone_layout() body = _skin_body(spec) body_mats = [mats["skin"], mats["cloth"], mats["pants"]] _assign_body_slots(body, spec, body_mats) _auto_weights(body, layout) head_parts = _face(mats, spec) hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz" head_parts += _hair(hair_style, mats["hair"]) head = join(head_parts, "head") _auto_weights(head, layout, rigid=lambda p: "Head") hs = _hands_and_shoes(mats, spec) if spec["layer"] == "responder": # boots replace the shoes hs = [o for o in hs if not o.name.startswith(("Shoe", "Sole"))] or hs limbs = join(hs, "limbs") def limb_bone(p): side = "L" if p.x < 0 else "R" return f"Hand{side}" if p.z > 0.5 else f"Knee{side}" _auto_weights(limbs, layout, rigid=limb_bone) rest = [body, head, limbs] clothes = _clothing(spec, mats) if clothes: cl = join(clothes, "clothing") def cloth_bone(p): if p.z > 1.42: return "Head" if p.z > 1.55 else "Neck" side = "L" if p.x < 0 else "R" if abs(p.x) > 0.26 and p.z > 0.8: return f"Elbow{side}" if p.z < 0.25: return f"Knee{side}" return None _auto_weights(cl, layout, rigid=cloth_bone) rest.append(cl) mesh = join(rest, "npc_base") # Every contract slot must exist even if a variant does not paint with it. present = {m.name for m in mesh.data.materials if m} for slot in SLOTS: if slot not in present: mesh.data.materials.append(mats[slot]) # Fewer, larger islands keep the seam-split vertex count inside the budget. uv_unwrap(mesh, angle=80.0) arm_data = bpy.data.armatures.new("Armature") arm = bpy.data.objects.new("Armature", arm_data) bpy.context.scene.collection.objects.link(arm) bpy.context.view_layer.objects.active = arm for o in bpy.context.selected_objects: o.select_set(False) arm.select_set(True) bpy.ops.object.mode_set(mode="EDIT") eb = arm_data.edit_bones for bone_name, (h, t, parent) in layout.items(): bone = eb.new(bone_name) bone.head, bone.tail = h, t if parent: bone.parent = eb[parent] bpy.ops.object.mode_set(mode="OBJECT") mesh.parent = arm mod = mesh.modifiers.new("Armature", "ARMATURE") mod.object = arm return [arm, mesh] # --------------------------------------------------------------------------- # Gear (static, attach-point space; see GEAR_ATTACH) # --------------------------------------------------------------------------- def build_gear(name): M = lambda n: material(n) # noqa: E731 p = [] # Head gear: origin = head centre (Godot Rig/Head marker at y=1.62); the # Blender model's head centre sits at z=1.635, so offsets are ~ +0.015. if name == "cap": p.append(sphere("Crown", 0.168, (0, 0.01, 0.05), M("plastic_black"), seg=20, rings=12, scale=(1.0, 1.05, 0.85), cut_below=0.0)) p.append(cyl("Brim", 0.11, 0.11, 0.012, (0, -0.17, 0.055), M("plastic_black"), seg=16, scale=(1.0, 0.8, 1.0), rot=(math.radians(-8), 0, 0))) p.append(sphere("Button", 0.014, (0, 0.01, 0.195), M("plastic_black"), seg=8, rings=4)) elif name == "beanie": p.append(sphere("Knit", 0.172, (0, 0.01, 0.04), M("plastic_grey"), seg=20, rings=12, scale=(1.0, 1.04, 1.02), cut_below=-0.05)) p.append(torus("Cuff", 0.163, 0.03, (0, 0.01, 0.04), M("plastic_grey"), seg=20)) elif name == "hood": p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015))) elif name == "sunglasses": # Keep the front frame narrow enough to sit inside the 0.315 m head # silhouette. The arms/hinges set the overall width, so pull those in # before shrinking the lenses; the lens plane stays in front of the eyes. for s in (-1.0, 1.0): p.append(cyl("Lens", 0.024, 0.024, 0.008, (0.04 * s, -0.152, 0.035), M("lens"), seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0))) p.append(box("Hinge", (0.035, 0.008, 0.01), (0.075 * s, -0.150, 0.045), M("plastic_black"))) p.append(box("Arm", (0.006, 0.15, 0.008), (0.098 * s, -0.078, 0.042), M("plastic_black"))) p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black"))) elif name == "firefighter_helmet": p.append(sphere("Shell", 0.19, (0, 0.0, 0.03), M("helmet"), seg=24, rings=12, scale=(1.0, 1.08, 0.95), cut_below=0.0)) p.append(lathe("Brim", [(0.0, 0.025), (0.20, 0.025), (0.26, 0.015), (0.255, 0.035), (0.0, 0.04)], M("helmet"), seg=24)) for v in p[-1].data.vertices: if v.co.y > 0: v.co.y *= 1.35 # long rear brim p.append(torus("Band", 0.188, 0.014, (0, 0, 0.09), M("hivis"), seg=24, minor_seg=4, scale=(1.0, 1.08, 1.0))) p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01)) p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01)) # Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind. elif name == "backpack": p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"), bevel=0.04, segs=2)) p.append(box("Pocket", (0.22, 0.05, 0.16), (0, 0.29, -0.02), M("pack_nylon"), bevel=0.02, segs=2)) p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel"))) for s in (-1.0, 1.0): p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.10 * s, -0.02, 0.29), (0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)], 0.012, M("plastic_black"), seg=6)) elif name == "shoulder_bag": p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"), bevel=0.025, segs=2)) p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26), (-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05), (0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6)) elif name == "scba_tank": p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18)) p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8, cut_below=0.0)) p.append(cyl("Valve", 0.03, 0.03, 0.06, (0, 0.21, -0.29), M("brass"), seg=10)) p.append(box("Frame", (0.30, 0.04, 0.60), (0, 0.12, 0.0), M("plastic_black"), bevel=0.015)) for s in (-1.0, 1.0): p.append(tube("Strap", [(0.11 * s, 0.10, 0.28), (0.11 * s, -0.03, 0.31), (0.12 * s, -0.14, 0.18), (0.13 * s, -0.13, -0.15)], 0.016, M("plastic_black"), seg=6)) # Chest: origin = sternum (Godot 0, 1.30, 0). elif name == "lanyard": p.append(tube("Cord", [(-0.06, 0.0, 0.16), (-0.07, -0.10, 0.10), (-0.04, -0.135, -0.05), (0.0, -0.14, -0.14), (0.04, -0.135, -0.05), (0.07, -0.10, 0.10), (0.06, 0.0, 0.16)], 0.005, M("ribbon_blue"), seg=5, caps=False)) p.append(box("Badge", (0.06, 0.006, 0.085), (0, -0.145, -0.19), M("plastic_white"), bevel=0.004)) p.append(box("Photo", (0.024, 0.002, 0.03), (-0.012, -0.149, -0.175), M("ribbon_blue"))) # Hand items: origin = grip centre; items hang along -Z (Godot -Y). elif name == "phone": p.append(box("Body", (0.075, 0.012, 0.15), (0, -0.02, 0.02), M("plastic_black"), bevel=0.008, segs=2)) p.append(box("Screen", (0.066, 0.002, 0.135), (0, -0.0265, 0.02), M("screen"))) elif name == "cup": p.append(lathe("Cup", [(0.0, -0.06), (0.032, -0.06), (0.042, 0.06), (0.0, 0.06)], M("paper"), seg=16)) p.append(lathe("Lid", [(0.0, 0.06), (0.046, 0.06), (0.044, 0.078), (0.0, 0.082)], M("plastic_white"), seg=16)) p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16, smooth=True)) elif name == "clipboard": p.append(box("Board", (0.23, 0.008, 0.31), (0, -0.04, -0.06), M("dark_wood"), bevel=0.004)) p.append(box("Paper", (0.20, 0.002, 0.26), (0, -0.045, -0.08), M("paper"))) p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, 0.085), M("steel"), bevel=0.004)) elif name == "umbrella": p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14), (0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8)) p.append(cyl("Shaft", 0.009, 0.009, 0.70, (0, 0, 0.29), M("black_metal"), seg=8)) canopy = lathe("Canopy", [(0.0, 0.66), (0.10, 0.65), (0.30, 0.58), (0.45, 0.48)], M("plastic_black"), seg=8, sharp=20) p.append(canopy) elif name == "camera": p.append(box("Body", (0.13, 0.07, 0.085), (0, -0.04, 0.04), M("plastic_black"), bevel=0.01, segs=2)) p.append(cyl("Lens", 0.032, 0.03, 0.07, (0, -0.105, 0.035), M("plastic_black"), seg=16, rot=(math.radians(90), 0, 0))) p.append(cyl("Glass", 0.024, 0.024, 0.004, (0, -0.141, 0.035), M("lens"), seg=16, rot=(math.radians(90), 0, 0))) p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"), bevel=0.006)) p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42), (0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006, M("plastic_black"), seg=5)) elif name == "protest_sign": p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood"))) p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"), bevel=0.003)) for i, z in enumerate((0.90, 0.80, 0.72)): p.append(box("Letters", (0.46 - i * 0.08, 0.002, 0.04), (0, -0.032, z), M("plastic_black"))) elif name == "flashlight": p.append(cyl("Barrel", 0.017, 0.017, 0.20, (0, -0.02, 0.0), M("black_metal"), seg=14, rot=(math.radians(90), 0, 0))) p.append(cyl("Head", 0.027, 0.019, 0.05, (0, -0.135, 0.0), M("black_metal"), seg=14, rot=(math.radians(-90), 0, 0))) p.append(cyl("LensF", 0.023, 0.023, 0.004, (0, -0.161, 0.0), M("bulb"), seg=14, rot=(math.radians(90), 0, 0))) p.append(box("Switch", (0.01, 0.02, 0.008), (0, -0.06, 0.019), M("rubber"))) elif name == "wand": p.append(cyl("Grip", 0.019, 0.017, 0.15, (0, 0, 0.0), M("plastic_black"), seg=12)) p.append(box("Paddle", (0.075, 0.025, 0.25), (0, 0, 0.20), M("plastic_grey"), bevel=0.012, segs=2)) p.append(box("Coil", (0.055, 0.027, 0.18), (0, 0, 0.21), M("plastic_black"), bevel=0.008)) p.append(sphere("Led", 0.007, (0, -0.016, 0.06), M("lock_led"), seg=8, rings=4)) elif name == "radio": p.append(box("Body", (0.058, 0.032, 0.115), (0, 0, 0.0), M("plastic_black"), bevel=0.007, segs=2)) p.append(cyl("Antenna", 0.007, 0.005, 0.09, (0.018, 0.0, 0.10), M("rubber"), seg=8)) p.append(cyl("Knob", 0.008, 0.008, 0.015, (-0.015, 0.0, 0.065), M("plastic_grey"), seg=10)) p.append(box("Grille", (0.044, 0.002, 0.05), (0, -0.017, -0.02), M("dark_metal"))) p.append(sphere("Led", 0.004, (0.0, -0.016, 0.045), M("lock_led"), seg=6, rings=4)) else: raise KeyError(name) obj = join(p, name) if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower obj.data.transform(Matrix.Translation((0, 0, -0.05))) uv_unwrap(obj) return [obj] # --------------------------------------------------------------------------- # Props (grounded, front = Blender -Y) # --------------------------------------------------------------------------- def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42): """Dense foliage cluster: many small flat-shaded clumps, not one smooth blob. Reads as leaves at game distance because the silhouette is broken up and the facets catch the light, which is what the realism-pass foliage note asks for. """ parts = [] import random rng = random.Random(seed) parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii, smooth=False)) for i in range(lumps * 2): # inner clumps fill the volume a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25) ring = rng.uniform(0.25, 0.75) r = rng.uniform(lump * 0.8, lump * 1.25) off = (center[0] + math.cos(a) * radii[0] * ring, center[1] + math.sin(a) * radii[1] * ring, center[2] + rng.uniform(-0.2, 0.55) * radii[2]) parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6, scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9), smooth=False)) for i in range(max(3, lumps // 2)): # outer clumps break the silhouette a = math.tau * i / max(3, lumps // 2) + rng.uniform(-0.3, 0.3) r = rng.uniform(lump * 0.6, lump * 0.85) off = (center[0] + math.cos(a) * radii[0] * rng.uniform(0.85, 1.0), center[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0), center[2] + rng.uniform(-0.05, 0.4) * radii[2]) parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6, scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8), rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)), smooth=False)) return parts def build_prop(name): M = lambda n: material(n) # noqa: E731 p = [] if name == "bollard": # matches generated: r 0.09, h 0.97 p.append(lathe("Bollard", [(0.105, 0.0), (0.105, 0.04), (0.085, 0.07), (0.08, 0.80), (0.09, 0.84), (0.09, 0.90), (0.06, 0.96), (0.0, 0.975)], M("black_metal"), seg=8, sharp=25)) p.append(lathe("Band", [(0.0815, 0.74), (0.0815, 0.79)], M("reflective"), seg=8, smooth=False)) elif name == "bench": # 1.80 x 0.52 seat at 0.46, backrest at back (+Y) for i, y in enumerate((-0.17, -0.055, 0.06, 0.175)): p.append(box("Seat", (1.80, 0.10, 0.04), (0, y, 0.46), M("wood"), bevel=0.012)) for z in (0.62, 0.76): p.append(box("Back", (1.80, 0.035, 0.11), (0, 0.235, z), M("wood"), rot=(math.radians(-10), 0, 0), bevel=0.012)) for x in (-0.72, 0.72): p.append(tube("Frame", [(x, -0.22, 0.0), (x, -0.20, 0.44), (x, 0.20, 0.44), (x, 0.22, 0.0)], 0.022, M("black_metal"), seg=8)) p.append(tube("BackFrame", [(x, 0.20, 0.44), (x, 0.26, 0.84)], 0.02, M("black_metal"), seg=8)) p.append(tube("Arm", [(x, -0.20, 0.44), (x, -0.20, 0.62), (x, 0.08, 0.64), (x, 0.24, 0.62)], 0.016, M("black_metal"), seg=8)) elif name == "trash_bin": # octagonal, r ~0.28, h 0.9 p.append(lathe("Base", [(0.27, 0.0), (0.27, 0.08), (0.25, 0.10)], M("bin_green"), seg=8, smooth=False, face_front=True)) p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8, smooth=False, face_front=True)) p.append(lathe("Lid", [(0.0, 0.78), (0.285, 0.78), (0.285, 0.84), (0.24, 0.90), (0.0, 0.92)], M("bin_green"), seg=8, smooth=False, face_front=True)) p.append(box("Slot", (0.26, 0.03, 0.09), (0, -0.258, 0.70), M("plastic_black"))) apothem = 0.255 * math.cos(math.pi / 8) for a in range(8): # inset panels, one per face ang = math.tau * a / 8 p.append(box("Panel", (0.15, 0.01, 0.40), (math.sin(ang) * apothem, -math.cos(ang) * apothem, 0.38), M("dark_metal"), rot=(0, 0, ang))) elif name == "planter_box": # 0.96 square, 0.54 high + shrub p.append(box("Planter", (0.96, 0.96, 0.50), (0, 0, 0.25), M("stone_pot"), bevel=0.025, segs=2)) p.append(box("Lip", (1.0, 1.0, 0.05), (0, 0, 0.515), M("stone_pot"), bevel=0.015)) p.append(box("Soil", (0.86, 0.86, 0.02), (0, 0, 0.535), M("soil"))) p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=5, seed=3) elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92 p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01)) p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02)) p.append(box("Top", (0.44, 0.38, 0.08), (0, 0.0, 0.84), M("news_blue"), rot=(math.radians(-12), 0, 0), bevel=0.015)) p.append(box("Window", (0.32, 0.01, 0.22), (0, -0.172, 0.62), M("glass"))) p.append(box("Paper", (0.26, 0.004, 0.14), (0, -0.168, 0.60), M("paper"))) p.append(box("Handle", (0.14, 0.03, 0.02), (0, -0.18, 0.47), M("steel"), bevel=0.006)) p.append(box("Coin", (0.06, 0.02, 0.08), (0.14, -0.175, 0.76), M("steel"), bevel=0.005)) elif name == "fire_hydrant": # r 0.15-0.19, h ~0.74 p.append(lathe("Hydrant", [(0.19, 0.0), (0.19, 0.06), (0.15, 0.09), (0.14, 0.52), (0.17, 0.55), (0.17, 0.60), (0.12, 0.66), (0.05, 0.72), (0.05, 0.75), (0.0, 0.76)], M("hydrant_red"), seg=12, sharp=30)) for s in (-1.0, 1.0): p.append(cyl("Nozzle", 0.05, 0.05, 0.10, (0.17 * s, 0, 0.40), M("hydrant_red"), seg=10, rot=(0, math.radians(90), 0))) p.append(cyl("Cap", 0.055, 0.055, 0.03, (0.225 * s, 0, 0.40), M("chrome"), seg=6, rot=(0, math.radians(90), 0), smooth=False)) p.append(cyl("FrontCap", 0.065, 0.065, 0.05, (0, -0.16, 0.40), M("chrome"), seg=6, rot=(math.radians(90), 0, 0), smooth=False)) for a in range(6): ang = math.tau * a / 6 p.append(cyl("Bolt", 0.012, 0.012, 0.02, (math.cos(ang) * 0.16, math.sin(ang) * 0.16, 0.07), M("chrome"), seg=6, smooth=False)) elif name == "street_lamp": # 5 m, arm toward -X, bulb ~4.85 m p.append(lathe("Base", [(0.20, 0.0), (0.20, 0.08), (0.15, 0.14), (0.13, 0.60), (0.10, 0.66), (0.08, 0.70)], M("black_metal"), seg=8, sharp=25)) p.append(lathe("Pole", [(0.08, 0.70), (0.055, 4.70), (0.07, 4.76), (0.07, 4.82), (0.0, 4.84)], M("black_metal"), seg=8, sharp=25)) p.append(tube("Arm", [(0.0, 0, 4.55), (-0.3, 0, 4.85), (-0.7, 0, 4.95), (-0.85, 0, 4.92)], 0.035, M("black_metal"), seg=8)) p.append(lathe("Hood", [(0.0, 4.98), (0.06, 4.97), (0.24, 4.85), (0.25, 4.82)], M("black_metal"), seg=12, loc=(-0.85, 0, 0))) p.append(lathe("BulbGlass", [(0.0, 4.82), (0.17, 4.82), (0.10, 4.74), (0.0, 4.72)], M("bulb"), seg=12, loc=(-0.85, 0, 0))) p.append(torus("Ring", 0.07, 0.015, (0, 0, 3.0), M("black_metal"), seg=12, minor_seg=4)) elif name == "street_tree": # trunk + layered crown, ~3.6 m p.append(lathe("Trunk", [(0.15, 0.0), (0.11, 0.25), (0.09, 1.4), (0.07, 2.0)], M("bark"), seg=8)) p.append(tube("Branch", [(0, 0, 1.6), (0.35, 0.1, 2.2)], 0.04, M("bark"), seg=6)) p.append(tube("Branch", [(0, 0, 1.7), (-0.3, -0.2, 2.3)], 0.04, M("bark"), seg=6)) p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=7, seed=7) p += _shrub("CrownTop", (0.05, 0.05, 3.1), (0.62, 0.62, 0.52), M("foliage_dark"), lumps=4, seed=11) p.append(box("Grate", (1.0, 1.0, 0.02), (0, 0, 0.01), M("dark_metal"))) elif name == "palm_tree": # ~7 m Hollywood palm prof = [] for i in range(12): z = i * 0.6 prof.append((0.17 - i * 0.006 + (0.012 if i % 2 else 0.0), z)) trunk = lathe("Trunk", prof, M("palm_bark"), seg=8) for v in trunk.data.vertices: # gentle lean v.co.x += 0.04 * (v.co.z / 6.6) ** 2 * 6.6 p.append(trunk) top = (0.29, 0.0, 6.6) p.append(sphere("Crown", 0.24, top, M("palm_bark"), seg=10, rings=6)) for i in range(14): a = math.tau * i / 14 droop = 0.6 + 0.35 * (i % 3) / 2 tip = (top[0] + math.cos(a) * 2.0, top[1] + math.sin(a) * 2.0, top[2] - droop * 1.1) mid = (top[0] + math.cos(a) * 1.0, top[1] + math.sin(a) * 1.0, top[2] + 0.35) bm = bmesh.new() pts = [Vector(top), Vector(mid), Vector(tip)] side = Vector((-math.sin(a), math.cos(a), 0)) widths = [0.06, 0.45, 0.0] vs = [] for pt, wd in zip(pts, widths): vs.append((bm.verts.new(pt - side * wd + Vector((0, 0, -0.06))), bm.verts.new(pt + Vector((0, 0, 0.04))), bm.verts.new(pt + side * wd + Vector((0, 0, -0.06))))) for (a0, a1, a2), (b0, b1, b2) in zip(vs, vs[1:]): bm.faces.new((a0, a1, b1, b0)) bm.faces.new((a1, a2, b2, b1)) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4) frond = _link("Frond", bm) _finish(frond, M("palm_leaf"), smooth=True, sharp_angle=80) p.append(frond) elif name == "round_planter": # mockup pot with topiary, ~1.0 m p.append(lathe("Pot", [(0.0, 0.0), (0.22, 0.0), (0.26, 0.05), (0.33, 0.50), (0.36, 0.52), (0.36, 0.58), (0.31, 0.58), (0.0, 0.56)], M("stone_pot"), seg=20, sharp=30)) p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=4, seed=5) elif name == "a_frame_sign": # chalkboard sidewalk sign for s in (-1.0, 1.0): tilt = math.radians(14) * s y = 0.13 * s p.append(box("Frame", (0.62, 0.035, 0.98), (0, y, 0.48), M("dark_wood"), rot=(tilt, 0, 0), bevel=0.008)) p.append(box("Board", (0.52, 0.04, 0.82), (0, y, 0.50), M("chalkboard"), rot=(tilt, 0, 0))) for i, z in enumerate((0.72, 0.60, 0.40)): p.append(box("Chalk", (0.38 - 0.06 * i, 0.044, 0.05), (0, y + 0.0005 * s, z), M("chalk"), rot=(tilt, 0, 0))) p.append(cyl("Hinge", 0.012, 0.012, 0.62, (0, 0, 0.955), M("steel"), seg=8, rot=(0, math.radians(90), 0))) elif name == "wall_lantern": # wall-mounted, origin on wall at mount height p.append(box("Plate", (0.14, 0.03, 0.22), (0, 0.0, 0.0), M("black_metal"), bevel=0.01)) p.append(tube("Arm", [(0, -0.01, 0.05), (0, -0.14, 0.10), (0, -0.22, 0.08)], 0.015, M("black_metal"), seg=6)) p.append(lathe("Cap", [(0.0, 0.16), (0.05, 0.15), (0.11, 0.09), (0.11, 0.07)], M("black_metal"), seg=8, loc=(0, -0.24, 0.0))) p.append(lathe("Glass", [(0.08, -0.12), (0.10, 0.07)], M("bulb"), seg=8, loc=(0, -0.24, 0.0), smooth=False)) p.append(lathe("Bottom", [(0.0, -0.17), (0.09, -0.15), (0.09, -0.12)], M("black_metal"), seg=8, loc=(0, -0.24, 0.0))) elif name == "traffic_cone": p.append(box("Base", (0.38, 0.38, 0.04), (0, 0, 0.02), M("rubber"), bevel=0.02)) p.append(lathe("Cone", [(0.15, 0.04), (0.035, 0.68), (0.0, 0.69)], M("cone_orange"), seg=16)) p.append(lathe("Stripe", [(0.118, 0.22), (0.096, 0.34)], M("reflective"), seg=16)) p.append(lathe("Stripe2", [(0.08, 0.42), (0.064, 0.50)], M("reflective"), seg=16)) elif name == "crowd_barrier": # steel bike-rack barricade, 2.2 m p.append(tube("Frame", [(-1.1, 0, 0.12), (-1.1, 0, 1.05), (1.1, 0, 1.05), (1.1, 0, 0.12), (-1.1, 0, 0.12)], 0.022, M("steel"), seg=8, caps=False)) for i in range(13): x = -0.96 + i * 0.16 p.append(cyl("Bar", 0.01, 0.01, 0.90, (x, 0, 0.585), M("steel"), seg=6)) for x in (-1.0, 1.0): p.append(tube("Foot", [(x, -0.30, 0.0), (x, 0.0, 0.14), (x, 0.30, 0.0)], 0.018, M("steel"), seg=6)) elif name == "stanchion": # queue post with velvet rope stub p.append(lathe("Base", [(0.0, 0.0), (0.17, 0.0), (0.17, 0.025), (0.04, 0.05)], M("chrome"), seg=20)) p.append(cyl("Post", 0.025, 0.025, 0.90, (0, 0, 0.50), M("chrome"), seg=12)) p.append(sphere("Top", 0.04, (0, 0, 0.97), M("chrome"), seg=12, rings=8)) p.append(tube("Rope", [(0, 0.0, 0.92), (0.2, 0.0, 0.80), (0.45, 0.0, 0.74), (0.70, 0.0, 0.80), (0.9, 0.0, 0.92)], 0.018, M("velvet_red"), seg=8)) p.append(cyl("Hook", 0.022, 0.022, 0.05, (0.9, 0, 0.92), M("brass"), seg=8, rot=(0, math.radians(90), 0))) elif name == "handrail": # 2 m stair rail, posts at x=0 and x=2 (stairs run +X up) p.append(tube("Rail", [(-0.1, 0, 0.92), (0.0, 0, 0.95), (2.0, 0, 1.75), (2.1, 0, 1.76)], 0.025, M("black_metal"), seg=10)) for x, z in ((0.0, 0.0), (1.0, 0.4), (2.0, 0.8)): p.append(cyl("Post", 0.022, 0.022, 0.95 + 0.0, (x, 0, z + 0.475 + (x * 0.0)), M("black_metal"), seg=8)) p.append(cyl("Flange", 0.05, 0.05, 0.015, (x, 0, z + 0.008), M("black_metal"), seg=10)) elif name == "security_podium": # check-in lectern for the door p.append(box("Plinth", (0.62, 0.46, 0.06), (0, 0, 0.03), M("plastic_black"), bevel=0.01)) p.append(box("Body", (0.56, 0.40, 1.0), (0, 0, 0.56), M("dark_wood"), bevel=0.015)) p.append(box("Desk", (0.64, 0.48, 0.04), (0, 0.02, 1.10), M("dark_wood"), rot=(math.radians(-12), 0, 0), bevel=0.01)) p.append(box("Panel", (0.44, 0.01, 0.62), (0, -0.205, 0.55), M("black_metal"))) p.append(box("Badge", (0.18, 0.012, 0.18), (0, -0.212, 0.66), M("patch"), bevel=0.01)) p.append(box("Tablet", (0.26, 0.18, 0.012), (0, 0.0, 1.13), M("screen"), rot=(math.radians(-12), 0, 0))) elif name == "double_door": # 1.8 x 2.6 framed entrance doors p.append(box("FrameL", (0.10, 0.20, 2.70), (-0.95, 0, 1.35), M("door_paint"), bevel=0.01)) p.append(box("FrameR", (0.10, 0.20, 2.70), (0.95, 0, 1.35), M("door_paint"), bevel=0.01)) p.append(box("Header", (2.0, 0.20, 0.12), (0, 0, 2.66), M("door_paint"), bevel=0.01)) for s in (-1.0, 1.0): x = 0.45 * s p.append(box("Leaf", (0.88, 0.06, 2.58), (x, 0.03, 1.30), M("door_paint"), bevel=0.008)) p.append(box("Glass", (0.62, 0.065, 1.50), (x, 0.03, 1.55), M("dark_glass"))) p.append(box("Kick", (0.80, 0.07, 0.22), (x, 0.03, 0.14), M("steel"))) p.append(cyl("Pull", 0.014, 0.014, 0.50, (0.07 * s, -0.06, 1.15), M("chrome"), seg=8)) p.append(box("Reader", (0.09, 0.03, 0.14), (1.12, -0.06, 1.25), M("plastic_black"), bevel=0.008)) p.append(box("LockLed", (0.03, 0.01, 0.03), (1.12, -0.078, 1.29), M("lock_led"))) elif name == "marquee": # canopy sign for the venue, 6 m wide, origin at wall base p.append(box("Canopy", (6.0, 1.6, 0.18), (0, -0.80, 0.0), M("black_metal"), bevel=0.02)) p.append(box("SignBox", (5.4, 0.35, 1.0), (0, -1.45, 0.60), M("black_metal"), bevel=0.03)) p.append(box("SignFace", (4.8, 0.02, 0.62), (0, -1.63, 0.62), M("sign_face"))) for i in range(24): x = -2.6 + i * (5.2 / 23) for z in (0.17, 1.06): p.append(sphere("Bulb", 0.035, (x, -1.635, z), M("bulb"), seg=8, rings=4)) for i in range(10): x = -2.7 + i * 0.6 p.append(sphere("Under", 0.03, (x, -1.0, -0.10), M("bulb"), seg=6, rings=4)) for s in (-1.0, 1.0): p.append(tube("Rod", [(2.6 * s, -1.5, 1.05), (2.6 * s, -0.4, 1.9)], 0.02, M("black_metal"), seg=6)) elif name == "sedan": # ~4.6 m dark sedan, front toward -Y p.append(box("Lower", (1.80, 4.60, 0.62), (0, 0, 0.52), M("car_paint"), bevel=0.12, segs=3)) cabin = box("Cabin", (1.56, 2.30, 0.55), (0, 0.25, 1.05), M("car_paint"), bevel=0.10, segs=2) for v in cabin.data.vertices: # taper the greenhouse if v.co.z > 1.1: v.co.x *= 0.88 v.co.y = 0.25 + (v.co.y - 0.25) * 0.72 p.append(cabin) glass = box("Glass", (1.58, 2.10, 0.36), (0, 0.25, 1.06), M("dark_glass"), bevel=0.06) for v in glass.data.vertices: if v.co.z > 1.1: v.co.x *= 0.89 v.co.y = 0.25 + (v.co.y - 0.25) * 0.74 p.append(glass) for x in (-0.80, 0.80): for y in (-1.45, 1.45): p.append(cyl("Tyre", 0.34, 0.34, 0.24, (x, y, 0.34), M("rubber"), seg=18, rot=(0, math.radians(90), 0))) p.append(cyl("Rim", 0.22, 0.22, 0.25, (x, y, 0.34), M("chrome"), seg=12, rot=(0, math.radians(90), 0))) for x in (-0.62, 0.62): p.append(box("Headlight", (0.38, 0.04, 0.10), (x, -2.29, 0.66), M("headlight"), bevel=0.02)) p.append(box("Tail", (0.40, 0.04, 0.10), (x, 2.29, 0.70), M("lock_led"), bevel=0.02)) p.append(box("Grille", (0.70, 0.03, 0.14), (0, -2.30, 0.55), M("chrome"), bevel=0.02)) else: raise KeyError(name) obj = join(p, name) uv_unwrap(obj) return [obj] # --------------------------------------------------------------------------- # Build + render entry points (inside Blender) # --------------------------------------------------------------------------- def _build_one(category, name): reset_scene() if category == "npc": objs = build_character(name) elif category == "npc_gear": objs = build_gear(name) else: objs = build_prop(name) path = export(objs, category, name) print(f"wrote {path.relative_to(ROOT)}") return path def _selected(only): wanted = {n.strip() for n in only.split(",") if n.strip()} if only else None return [(c, n) for c, n in catalogue() if wanted is None or n in wanted] def _setup_render(objs, out_path, character=False): scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 48 scene.cycles.use_denoising = True scene.render.resolution_x = 640 scene.render.resolution_y = 640 scene.render.film_transparent = False scene.view_settings.view_transform = "AgX" world = bpy.data.worlds.new("World") world.use_nodes = True bg = world.node_tree.nodes.get("Background") bg.inputs["Color"].default_value = (0.42, 0.44, 0.48, 1.0) bg.inputs["Strength"].default_value = 0.7 scene.world = world mins = Vector((1e9, 1e9, 1e9)) maxs = Vector((-1e9, -1e9, -1e9)) for o in objs: if o.type != "MESH" or o.name.startswith("Icosphere") or o.hide_render: continue for corner in o.bound_box: w = o.matrix_world @ Vector(corner) mins = Vector(map(min, mins, w)) maxs = Vector(map(max, maxs, w)) center = (mins + maxs) / 2 size = max((maxs - mins).length, 0.05) bpy.ops.mesh.primitive_plane_add(size=max(size * 6, 2.0), location=(0, 0, min(mins.z, 0))) floor = bpy.context.object floor.data.materials.append(material("concrete")) sun_data = bpy.data.lights.new("Sun", "SUN") sun_data.energy = 3.5 sun_data.angle = math.radians(8) sun = bpy.data.objects.new("Sun", sun_data) sun.rotation_euler = (math.radians(50), 0, math.radians(-35)) scene.collection.objects.link(sun) fill_data = bpy.data.lights.new("Fill", "AREA") fill_data.energy = 40.0 * size * size fill_data.size = size * 2 fill = bpy.data.objects.new("Fill", fill_data) fill.location = center + Vector((size * 1.2, -size * 1.4, size * 0.6)) fill.rotation_euler = (Vector((0, 0, 0)) - (fill.location - center)).to_track_quat( "-Z", "Y").to_euler() scene.collection.objects.link(fill) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50 cam = bpy.data.objects.new("Cam", cam_data) direction = Vector((0.55, -1.0, 0.32 if not character else 0.12)).normalized() cam.location = center + direction * size * 1.45 cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler() scene.collection.objects.link(cam) scene.camera = cam scene.render.filepath = str(out_path) # Preview-only tints (sRGB) standing in for NpcAvatar.outfit_for(); the GLBs # keep neutral slot textures so the runtime tint stays authoritative. PREVIEW_TINTS = { "npc_casual": {"cloth": (34, 40, 62), "pants": (150, 120, 84)}, "npc_jacket": {"cloth": (52, 70, 58), "pants": (40, 46, 60)}, "npc_coat": {"cloth": (150, 112, 74), "pants": (30, 32, 38)}, "npc_vest": {"cloth": (70, 74, 82), "pants": (44, 50, 64)}, "npc_dress": {"cloth": (150, 30, 44), "pants": (30, 28, 30)}, "npc_hoodie": {"cloth": (78, 80, 86), "pants": (34, 38, 48)}, "npc_firefighter": {"cloth": (40, 42, 46), "pants": (40, 42, 46)}, "npc_guard": {"cloth": (30, 34, 46), "pants": (30, 34, 46)}, } DEFAULT_TINTS = {"skin": (214, 170, 136), "hair": (58, 40, 28), "accessory": (30, 30, 34)} def _tint_preview(name): tints = dict(DEFAULT_TINTS) tints.update(PREVIEW_TINTS.get(name, {})) for mat in bpy.data.materials: slot = mat.name.split(".")[0] if slot not in tints or not mat.node_tree: continue nodes = mat.node_tree.nodes bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None) if bsdf is None or not bsdf.inputs["Base Color"].links: continue src = bsdf.inputs["Base Color"].links[0].from_socket mix = nodes.new("ShaderNodeMix") mix.data_type = "RGBA" mix.blend_type = "MULTIPLY" mix.inputs["Factor"].default_value = 1.0 mat.node_tree.links.new(src, mix.inputs[6]) rgb = tints[slot] # neutral textures are light; lift the tint so the product lands near it mix.inputs[7].default_value = tuple( min(1.0, _srgb_to_linear(c / 255.0) * 1.5) for c in rgb) + (1.0,) mat.node_tree.links.new(mix.outputs[2], bsdf.inputs["Base Color"]) def _render_one(category, name): path = asset_path(category, name) if not path.is_file(): print(f"skip {name}: not built") return reset_scene() bpy.ops.import_scene.gltf(filepath=str(path)) objs = list(bpy.context.scene.objects) if category == "npc": _tint_preview(name) # Dress the preview with the recommended gear at the documented attach # points; this also checks the attach-point convention visually. for gear in CHARACTERS[name].get("gear", []): gpath = asset_path("npc_gear", gear) if not gpath.is_file(): continue before = set(bpy.context.scene.objects) bpy.ops.import_scene.gltf(filepath=str(gpath)) for o in set(bpy.context.scene.objects) - before: if o.parent is None: o.location = Vector(o.location) + Vector(ATTACH_BLENDER[GEAR[gear]]) objs = list(bpy.context.scene.objects) if category == "npc_gear": # show gear at a readable scale on the floor mins_z = min((o.matrix_world @ Vector(c)).z for o in objs if o.type == "MESH" for c in o.bound_box) for o in objs: if o.parent is None: o.location.z -= mins_z bpy.context.view_layer.update() PREVIEW_DIR.mkdir(parents=True, exist_ok=True) out = PREVIEW_DIR / f"{name}.png" _setup_render(objs, out, character=category == "npc") bpy.ops.render.render(write_still=True) print(f"rendered {out.relative_to(ROOT)}") # --------------------------------------------------------------------------- # Audit + contact sheets (stdlib / Pillow, outside Blender) # --------------------------------------------------------------------------- def _read_glb(path): data = path.read_bytes() magic, version, length = struct.unpack_from(" CHAR_MAX_TRIS: res["issues"].append(f"triangles {tris} > {CHAR_MAX_TRIS}") if verts > CHAR_MAX_VERTS: res["issues"].append(f"vertices {verts} > {CHAR_MAX_VERTS}") if verts and abs(mins[1]) > 0.03: res["issues"].append(f"feet not on the ground (min y {mins[1]:.3f})") if verts and not (1.6 < maxs[1] < 2.1): res["issues"].append(f"unexpected height {maxs[1]:.2f}") elif category == "npc_gear": if tris > GEAR_MAX_TRIS: res["issues"].append(f"triangles {tris} > {GEAR_MAX_TRIS}") else: if tris > PROP_MAX_TRIS: res["issues"].append(f"triangles {tris} > {PROP_MAX_TRIS}") grounded = name not in ("wall_lantern", "marquee") if grounded and verts and abs(mins[1]) > 0.03: res["issues"].append(f"not on the ground (min y {mins[1]:.3f})") if not (SRC_DIR / f"{name}.blend").is_file(): res["issues"].append("editable .blend source missing") res["ok"] = not res["issues"] return res def character_fit_reference(name): """Measure a character's head width and hip/skirt radius from its GLB. Head width uses skin/hair vertices whose strongest joint is Head inside the fixed head band. Clothing radius uses the cloth vertices in the hip/skirt band. Both are reported in metres and rounded to millimetres. """ path = asset_path("npc", name) if not path.is_file(): return None try: _magic, _version, _length, _size, gltf, binary = _read_glb(path) except (struct.error, ValueError, KeyError): return None nodes = gltf.get("nodes", []) joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])] materials = [m.get("name", "") for m in gltf.get("materials", [])] head_x: list[float] = [] head_y: list[float] = [] cloth_x: list[float] = [] hip_x: list[float] = [] for mesh in gltf.get("meshes", []): for prim in mesh.get("primitives", []): attrs = prim.get("attributes", {}) if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs: continue mat_index = prim.get("material") mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else "" positions = _accessor(gltf, binary, attrs["POSITION"]) joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"]) weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"]) for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows): strongest = max(range(4), key=lambda i: weight_row[i]) bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else "" if (mat_name in FIT_HEAD_MATERIALS and bone == "Head" and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]): head_x.append(pos[0]) head_y.append(pos[1]) if (mat_name == FIT_CLOTHING_MATERIAL and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]): cloth_x.append(pos[0]) if (mat_name == "pants" and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]): hip_x.append(pos[0]) skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0 hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0 clothing_radius = max(skirt_radius, hip_radius) return { "head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0, "head_y_min": round(min(head_y), 3) if head_y else 0.0, "head_y_max": round(max(head_y), 3) if head_y else 0.0, "clothing_radius": round(clothing_radius, 3), "clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip", "head_band": HEAD_BAND, } def _gear_fit(name, reference): attach = GEAR.get(name) if attach not in ("head", "back", "chest"): return None audit = audit_asset("npc_gear", name) bounds = audit.get("bounds") if not bounds: return None offset = ATTACH_GODOT[attach] lo = [bounds["min"][i] + offset[i] for i in range(3)] hi = [bounds["max"][i] + offset[i] for i in range(3)] result = { "name": name, "attach": attach, "min": [round(v, 3) for v in lo], "max": [round(v, 3) for v in hi], } if attach == "head": result.update({ "width": round(hi[0] - lo[0], 3), "width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3) if reference["head_width"] else 0.0, "inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005, }) else: # Compare the widest point (|x|) and the corner radial extent with the # skirt flare. The radial value catches bags that sit diagonally out. x_extent = max(abs(lo[0]), abs(hi[0])) radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2])) result.update({ "x_extent": round(x_extent, 3), "radial": round(radial, 3), "x_clearance": round(reference["clothing_radius"] - x_extent, 3), "radial_clearance": round(reference["clothing_radius"] - radial, 3), }) return result def fit_report(): """Return per-character gear fit measurements for the `fit` command/tests.""" report = [] for character, spec in CHARACTERS.items(): reference = character_fit_reference(character) if reference is None: continue rows = [] for gear_name in spec.get("gear", []): row = _gear_fit(gear_name, reference) if row: rows.append(row) report.append({"character": character, "reference": reference, "gear": rows}) return report def write_manifest(results): out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH, "assets": {}} for r in results: entry = {"path": str(asset_path(r["category"], r["name"]).relative_to(ROOT)), "category": r["category"], "triangles": r["triangles"], "vertices": r["vertices"], "materials": r["materials"], "bounds_godot": r["bounds"]} if r["category"] == "npc": spec = CHARACTERS[r["name"]] entry.update({"layer": spec["layer"], "roles": spec["roles"], "tint_slots": SLOTS, "mesh_node": "npc_base"}) elif r["category"] == "npc_gear": entry["attach"] = GEAR[r["name"]] else: entry.update({k: v for k, v in PROPS[r["name"]].items()}) out["assets"][r["name"]] = entry MANIFEST.parent.mkdir(parents=True, exist_ok=True) MANIFEST.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n", encoding="utf-8") def contact_sheets(): from PIL import Image, ImageDraw groups = {} for cat, name in catalogue(): groups.setdefault(cat, []).append(name) written = [] for cat, names in groups.items(): tiles = [(n, PREVIEW_DIR / f"{n}.png") for n in names if (PREVIEW_DIR / f"{n}.png").is_file()] if not tiles: continue cols = 4 if len(tiles) > 4 else len(tiles) rows = (len(tiles) + cols - 1) // cols cell = 320 sheet = Image.new("RGB", (cols * cell, rows * (cell + 24)), (24, 26, 30)) draw = ImageDraw.Draw(sheet) for i, (n, path) in enumerate(tiles): with Image.open(path) as im: im = im.convert("RGB").resize((cell, cell), Image.Resampling.LANCZOS) x, y = (i % cols) * cell, (i // cols) * (cell + 24) sheet.paste(im, (x, y)) draw.text((x + 8, y + cell + 5), n, fill=(230, 230, 230)) # JPEG keeps the committed review sheets small; per-asset PNG renders # stay local (gitignored) and are rebuilt with `render`. out = PREVIEW_DIR / f"sheet_{cat}.jpg" sheet.save(out, quality=86, optimize=True) written.append(out) print(f"wrote {out.relative_to(ROOT)}") return written def main(argv=None): raw = list(argv if argv is not None else sys.argv[1:]) if "--" in raw: raw = raw[raw.index("--") + 1:] ap = argparse.ArgumentParser(description="Blender asset builder") sub = ap.add_subparsers(dest="cmd", required=True) for cmd in ("build", "render"): s = sub.add_parser(cmd) s.add_argument("--only", default="") a = sub.add_parser("audit") a.add_argument("--json", action="store_true") fit = sub.add_parser("fit") fit.add_argument("--json", action="store_true") sub.add_parser("sheet") sub.add_parser("list") args = ap.parse_args(raw) if args.cmd in ("build", "render"): if bpy is None: print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender " f"--background --python {Path(__file__).resolve()} -- {args.cmd}", file=sys.stderr) return 2 for cat, name in _selected(args.only): (_build_one if args.cmd == "build" else _render_one)(cat, name) if args.cmd == "build": results = [audit_asset(c, n) for c, n in catalogue()] write_manifest([r for r in results if r["vertices"]]) bad = [r for r in results if not r["ok"] and r["name"] in {n for _, n in _selected(args.only)}] for r in bad: print("BAD", r["name"], r["issues"]) return 1 if bad else 0 return 0 if args.cmd == "list": for cat, name in catalogue(): extra = GEAR.get(name, "") if cat == "npc_gear" else \ CHARACTERS.get(name, {}).get("layer", "") if cat == "npc" else \ PROPS[name].get("replaces", "new") print(f"{cat:9s} {name:20s} {extra}") return 0 if args.cmd == "sheet": contact_sheets() return 0 if args.cmd == "fit": report = fit_report() if not report: print("no built character assets; run build first", file=sys.stderr) return 2 if args.json: print(json.dumps(report, indent=2)) else: for entry in report: ref = entry["reference"] print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m " f"clothing_radius={ref['clothing_radius']:.3f} m " f"({ref['clothing_source']})") for row in entry["gear"]: lo, hi = row["min"], row["max"] box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] " f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] " f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]") if row["attach"] == "head": flag = "inside" if row["inside_band"] else "OUTSIDE" print(f" {row['name']:20s} head {box} " f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) " f"band={flag}") else: clip = "CLIP" if row["x_clearance"] < 0 else "clear" print(f" {row['name']:20s} {row['attach']:5s} {box} " f"clearance x={row['x_clearance']:+.3f} " f"r={row['radial_clearance']:+.3f} {clip}") return 0 results = [audit_asset(c, n) for c, n in catalogue()] if args.json: print(json.dumps(results, indent=2)) else: for r in results: print(f"{'OK ' if r['ok'] else 'BAD'} {r['category']:9s} {r['name']:20s} " f"tris={r['triangles']:5d} verts={r['vertices']:5d}") for issue in r["issues"]: print(" -", issue) return 0 if all(r["ok"] for r in results) else 1 if __name__ == "__main__": sys.exit(main())