#!/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 # inspect an exported or external GLB exactly as Blender imports it flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- inspect --file some.glb # 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 contextlib import io 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"] # The eight library variants may use more geometry than the frozen npc_base # fallback (6000 tris / 4000 verts). Raised to 8000/8000 on 2026-11-02 so the # remaining face, hand-grip and clothing work is not forced to replace detail. # The glTF vertex count is after UV-seam splits; each avatar is still one # skinned mesh, and `smoke_npc_budget.gd` must confirm the 24-NPC frame time. CHAR_MAX_TRIS = 8000 CHAR_MAX_VERTS = 8000 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"]}, # Crowd-variety prototypes (character development): vary build, head # silhouette, hair/beard and outer layer without labelling behaviour. "npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "gear": ["sunglasses"], "roles": ["tourist", "guest"]}, "npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "beard": True, "gear": ["shoulder_bag"], "roles": ["confused_visitor", "guest"]}, "npc_crowd_c": {"layer": "coat", "hair": "short", "gear": ["umbrella"], "roles": ["intoxicated", "guest"]}, "npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim", "gear": ["cup"], "roles": ["tourist", "guest"]}, } # 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 # Side bags include a strap that crosses the torso, so the full gear bbox cannot # tell where the bag body starts. Measure the body-only y band for this fit. FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)} GEAR = { "cap": "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), "palm_leaf_dark": ((52, 88, 40), 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, width_scale=1.0, thickness_scale=1.0): """Bent tube through 3D points (handrails, umbrella handles, frames). `width_scale`/`thickness_scale` turn the round cross-section into a flat strap for shoulder harnesses; the default keeps the old round tube. """ 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 * width_scale + math.sin(a) * up2 * thickness_scale))) 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.168 * w, 0.116 * d), # 0 pelvis ((0, 0, 0.96), 0.146 * w * bulk, 0.103 * d * bulk), # 1 waist ((0, 0.0, 1.18), 0.168 * w * bulk, 0.114 * d * bulk), # 2 chest ((0, 0.0, 1.355), 0.170 * w * bulk, 0.108 * d * bulk), # 3 upper chest # A slightly fuller neck base and a lower shoulder root give the # head/neck/shoulder line a trapezius slope instead of a thin post on # a hanger. The head and collar still sit above this band. ((0, 0, 1.43), 0.074, 0.072), # 4 neck base ((0, -0.005, 1.53), 0.060, 0.060), # 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.37), 0.080 * t * sleeve, 0.074 * t * sleeve), # shoulder ((0.27 * s * w, 0, 1.23), 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 _shape_head_mesh(head): """Sculpt a brow ridge, cheekbones and a jaw/chin plate into the head sphere. Separate jaw/cheek spheres leave visible intersection rings in the baked AO, which read as a beard shadow. Only forward protrusions are sculpted; recessed creases (nasolabial folds, temple hollows, a chin cleft) bake as dark AO patches on this low-poly head, so they are left to shading. The face front is -Y (Blender) / +Z (Godot); z is up in this builder. """ bm = bmesh.new() bm.from_mesh(head.data) for v in bm.verts: x, y, z = v.co if y >= 0.0: continue ax = abs(x) # Brow ridge: a shallow forward roll above each eye socket. brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.030) * max(0.0, 1.0 - abs(ax - 0.050) / 0.040)) v.co.y -= 0.008 * brow # Cheekbone: a shallow, wide lift under each eye. cheek = (max(0.0, 1.0 - abs(z - 1.612) / 0.040) * max(0.0, 1.0 - abs(ax - 0.070) / 0.050)) v.co.y -= 0.005 * cheek # Jaw/chin plate: forward and slightly wider below the mouth. jaw = (max(0.0, 1.0 - abs(z - 1.565) / 0.055) * max(0.0, 1.0 - ax / 0.075)) v.co.y -= 0.012 * jaw v.co.x *= 1.0 + 0.06 * jaw bm.normal_update() bm.to_mesh(head.data) bm.free() def _face(mats, spec): """Head with realistic face planes: recessed sockets with both lids, a sculpted brow/cheek/jaw, a bridged nose with ala and columella, and separated upper/lower lips. At conversation distance the eye region carries the read, so each eye gets a white sclera, a dark iris, an upper and a lower lid, and the face gets sculpted brow/cheek/jaw planes instead of a bare sphere (realism pass). """ skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] white = material("eye_white", textured_solid=True) head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=24, rings=16, scale=(0.92, 1.0, 1.12)) _shape_head_mesh(head) parts = [head] # Nose: bridge and ball; the ala widen the base into a small triangle. 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.022, (0, -0.162, 1.615), skin, seg=10, rings=6, scale=(1.0, 0.85, 0.80))) for s in (-1.0, 1.0): # Ala widen the nose base into a small triangle rather than a ball. parts.append(sphere("NoseWing", 0.012, (0.016 * s, -0.160, 1.606), skin, seg=6, rings=4, scale=(1.0, 0.65, 0.75))) # Ear with a lobe and a darker concha; the old disc had no depth cue. 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))) parts.append(sphere("EarInner", 0.013, (0.149 * s, -0.003, 1.633), acc, seg=6, rings=4, scale=(0.28, 0.65, 0.90))) # Eye: recess the sclera into the socket, then wrap it with both lids. # The old white ball sat ~1 cm proud of the head; this keeps the iris # readable without the doll-like bulge. ex, ey, ez = 0.052 * s, -0.134, 1.664 parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, rings=6, scale=(1.25, 0.62, 0.85))) parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, rings=5, scale=(1.0, 0.55, 1.0))) parts.append(sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24), rot=(math.radians(-10), 0, 0))) # A thin lower lid keeps the eye from reading as a floating white oval. parts.append(sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18), rot=(math.radians(6), 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))) # Mouth: one rounded dark slot (the old clean read) plus a fuller lower lip # in skin so the mouth line gains a subtle volume cue without a moustache read. parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5, scale=(1.0, 0.28, 0.12))) parts.append(sphere("LowerLip", 0.012, (0, -0.156, 1.578), skin, seg=8, rings=5, scale=(1.2, 0.55, 0.30))) 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 # Wider and shallower than the old ball: a palm, not a wrist knob. parts.append(sphere("Palm", 0.046, (x, -0.012, 0.742), skin, seg=10, rings=6, scale=(0.80, 0.62, 0.88))) # Four bent, tapering fingers with rounded tips. The two segments keep # the hand readable at conversation distance without adding a full # finger skeleton; the outer fingers are slightly shorter. for index, offset in enumerate((-0.024, -0.008, 0.008, 0.024)): fx = x + offset * s tip_z = (0.692, 0.681, 0.684, 0.694)[index] radius = 0.0115 if index in (1, 2) else 0.0105 parts.append(tube("Finger", [(fx, -0.018, 0.758), (fx, -0.030, 0.726), (fx, -0.042, tip_z)], radius, skin, seg=6, caps=False)) parts.append(sphere("Fingertip", radius, (fx, -0.042, tip_z), skin, seg=6, rings=4)) parts.append(tube("Thumb", [(x - 0.010 * s, -0.018, 0.758), (x - 0.028 * s, -0.040, 0.740), (x - 0.034 * s, -0.055, 0.718)], 0.012, skin, seg=6, caps=False)) parts.append(sphere("ThumbTip", 0.012, (x - 0.034 * s, -0.055, 0.718), skin, seg=6, rings=4)) parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe, bevel=0.028, segs=1)) # Round the toe and heel so the low-poly shoe reads as footwear rather # than a box; both stay above the rubber sole contact plane. parts.append(sphere("ShoeToe", 0.045, (0.12 * s, -0.155, 0.045), shoe, seg=8, rings=5, scale=(1.05, 0.80, 0.55))) parts.append(sphere("ShoeHeel", 0.034, (0.12 * s, 0.070, 0.045), shoe, seg=8, rings=5, scale=(1.0, 0.55, 0.55))) 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)) parts.append(sphere("BootToe", 0.055, (0.12 * s, -0.155, 0.075), mats["rubber"], seg=8, rings=5, scale=(1.0, 0.80, 0.55))) parts.append(sphere("BootHeel", 0.045, (0.12 * s, 0.080, 0.055), mats["rubber"], seg=8, rings=5, scale=(1.0, 0.55, 0.50))) 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") # The fuller neck/shoulder skin graph can leave a few collinear triangles # after the decimate step. Remove them before UV/export; Godot warns about # zero-area faces during LOD generation and `audit` rejects them. bm = bmesh.new() bm.from_mesh(mesh.data) bmesh.ops.dissolve_degenerate(bm, dist=2e-4, edges=bm.edges) # Skin-modifier branch cleanup can still leave a handful of zero-area # faces; Godot's LOD generator treats them as malformed. Triangulate first # because a degenerate quad can become a zero-area triangle on export, then # drop the tiny faces explicitly. bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY", ngon_method="BEAUTY") bad = [face for face in bm.faces if face.calc_area() < 1e-10] if bad: bmesh.ops.delete(bm, geom=bad, context="FACES_ONLY") bm.to_mesh(mesh.data) bm.free() # 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 == "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)) # The 2026-11-01 face pass added a brow ridge; lift the helmet 2 cm so # the brim sits above the eyes instead of cutting into the brow. for obj in p: obj.data.transform(Matrix.Translation((0, 0, 0.02))) # 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): # Strap path: over the shoulder, then down the chest outside the # torso front (Godot z≈0.21). The old chest/waist points sat at # z≈0.13 inside the body, so only thin tube fragments read as two # rigid black bars from the front. p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.11 * s, -0.02, 0.29), (0.12 * s, -0.06, 0.25), (0.14 * s, -0.19, 0.18), (0.145 * s, -0.19, -0.08)], 0.016, M("denim_bag"), seg=8, width_scale=1.35, thickness_scale=0.42)) elif name == "shoulder_bag": # Keep the bag's inner face outside the widest skirt flare (dress = 0.26): # 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line. p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"), bevel=0.025, segs=2)) p.append(tube("Strap", [(0.262, 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.262, 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 in two greens. Alternating light/dark clumps break the single-colour read that made the old shrub look like a rock; the facets still catch the light at game distance (the realism-pass foliage note). """ parts = [] import random rng = random.Random(seed) mat_light = material("foliage") mat_dark = material("foliage_dark") 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_light if i % 2 == 0 else mat_dark, 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_dark if i % 2 == 0 else mat_light, 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; flared base collar p.append(lathe("Bollard", [(0.125, 0.0), (0.125, 0.05), (0.095, 0.09), (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)) # Recessed door frame: a dark inset so the glass reads as a window cut # into the door rather than a flat decal on the surface. p.append(box("DoorFrame", (0.36, 0.02, 0.26), (0, -0.152, 0.60), M("dark_metal"), bevel=0.004)) p.append(box("Window", (0.30, 0.005, 0.20), (0, -0.157, 0.60), M("glass"))) p.append(box("Paper", (0.24, 0.004, 0.14), (0, -0.154, 0.60), M("paper"))) # Door seam above the handle so the door reads as a hinged panel. p.append(box("DoorSeam", (0.36, 0.003, 0.002), (0, -0.16, 0.72), M("dark_metal"))) 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.19 body, h ~0.76 + flange, bonnet and outlets # Bolted base flange so the ground seam reads as a bolted fitting. p.append(lathe("Flange", [(0.235, 0.0), (0.235, 0.03), (0.19, 0.06)], M("hydrant_red"), seg=12, sharp=30)) p.append(lathe("Hydrant", [(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)) # Bonnet cap nut on top so the dome reads as a valve, not a bottle. p.append(sphere("BonnetNut", 0.035, (0, 0, 0.78), M("chrome"), seg=8, rings=5)) for s in (-1.0, 1.0): # Larger side outlets so they register in profile. p.append(cyl("Nozzle", 0.062, 0.055, 0.12, (0.17 * s, 0, 0.40), M("hydrant_red"), seg=10, rot=(0, math.radians(90), 0))) p.append(cyl("Cap", 0.068, 0.068, 0.04, (0.245 * s, 0, 0.40), M("chrome"), seg=6, rot=(0, math.radians(90), 0), smooth=False)) # Front outlet (faces -Y = the street) with a proper chrome cap. p.append(cyl("FrontNozzle", 0.072, 0.06, 0.06, (0, -0.155, 0.40), M("hydrant_red"), seg=10, rot=(math.radians(90), 0, 0))) p.append(cyl("FrontCap", 0.082, 0.082, 0.04, (0, -0.215, 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) # Alternate two greens so the crown is not one flat colour. _finish(frond, M("palm_leaf" if i % 2 == 0 else "palm_leaf_dark"), 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 # --------------------------------------------------------------------------- # Texel density (UV area vs world area, per material slot) # --------------------------------------------------------------------------- # The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat # the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their # effective density is four times the authored number. TEXEL_RESOLUTION = 256 TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0} def _tri_area3(a, b, c) -> float: ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2] vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2] cx = uy * vz - uz * vy cy = uz * vx - ux * vz cz = ux * vy - uy * vx return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz) def _tri_area2(a, b, c) -> float: return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])) def texel_density(name: str, path: Path | None = None) -> dict | None: """Per-slot texel density (px/m) for a character GLB. texel density = resolution * sqrt(UV area / world surface area), so the number is independent of how the island is packed. Effective density also applies the runtime UV repeat for cloth/pants. """ path = path or 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 materials = [m.get("name", "") for m in gltf.get("materials", [])] slots: dict[str, dict] = {} for mesh in gltf.get("meshes", []): for prim in mesh.get("primitives", []): attrs = prim.get("attributes", {}) if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs or prim.get("indices") is None): continue mat_index = prim.get("material") slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else "" positions = _accessor(gltf, binary, attrs["POSITION"]) uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"]) indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])] row = slots.setdefault(slot or "unassigned", {"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0}) for k in range(0, len(indices) - 2, 3): i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2] if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs): continue row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2]) row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2]) row["triangles"] += 1 for slot, row in slots.items(): world = row["world_area_m2"] uv = row["uv_area"] authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0 row["td_px_per_m"] = round(authored, 1) row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1) row["world_area_m2"] = round(world, 3) row["uv_area"] = round(uv, 4) values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0] return { "name": name, "resolution": TEXEL_RESOLUTION, "slots": slots, "min_td": min(values) if values else 0.0, "max_td": max(values) if values else 0.0, "total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3), } def texel_report() -> list[dict]: return [row for row in (texel_density(name) for name in CHARACTERS) if row] # --------------------------------------------------------------------------- # Intake inspection (import a GLB the way Blender sees it) # --------------------------------------------------------------------------- # `audit` reads accessor-local bounds and ignores node transforms, so an # external GLB can pass locally and sit metres away in the scene. `inspect` # imports the file, applies node transforms and measures world space. INSPECT_TRANSFORM_TOLERANCE = 0.005 # metres of local/world drift before reporting INSPECT_CATEGORY_DIRS = {"npc": "npc", "npc_gear": "npc_gear", "props": "props", "generated": "props", "blender": "external"} def category_for_path(path) -> str: """Guess the contract family from a GLB path (npc, npc_gear, props, external).""" p = Path(path) stem = p.stem parent = p.parent.name if parent in INSPECT_CATEGORY_DIRS: return INSPECT_CATEGORY_DIRS[parent] if stem in CHARACTERS: return "npc" if stem in GEAR: return "npc_gear" if stem in PROPS: return "props" return "external" def analyse_inspection(stats: dict) -> dict: """Pure contract analysis of `inspect_glb()` measurements (no Blender). Hard contract failures land in `issues`; advisory findings in `warnings`. The category gates the budget/rig checks; `external` assets only get the generic checks so an unadapted download can be measured before intake. """ category = str(stats.get("category", "external")) issues: list[str] = [] warnings: list[str] = [] tris = int(stats.get("triangles", 0)) world_verts = int(stats.get("split_vertices", 0)) meshes = list(stats.get("mesh_nodes", [])) bones = list(stats.get("bones", [])) materials = list(stats.get("materials", [])) bones_ok = {b for b in BONES if b in bones} needed = set(BONES) | {"Hips", "Spine", "Neck"} if not meshes: issues.append("no mesh objects") if tris <= 0: issues.append("no triangles") bounds = stats.get("world_bounds") or {} lo, hi = bounds.get("min"), bounds.get("max") if not lo or not hi: issues.append("empty world bounds") for value in (tris, world_verts, *(lo or []), *(hi or [])): if isinstance(value, float) and not math.isfinite(value): issues.append("non-finite measurement") break if category == "npc": if "npc_base" not in meshes: issues.append("no mesh node named npc_base") for bone in sorted(needed - set(bones)): issues.append(f"missing bone {bone}") for slot in SLOTS: if slot not in materials: issues.append(f"missing material slot {slot}") unskinned = list(stats.get("unskinned", [])) if not stats.get("skinned", False) or unskinned: issues.append("mesh not skinned" + (f" ({', '.join(unskinned)})" if unskinned else "")) if tris > CHAR_MAX_TRIS: issues.append(f"triangles {tris} > {CHAR_MAX_TRIS}") if world_verts > CHAR_MAX_VERTS: issues.append(f"glTF vertices {world_verts} > {CHAR_MAX_VERTS}") if lo and abs(float(lo[1])) > 0.03: issues.append(f"feet not on the ground (min y {float(lo[1]):.3f})") if hi and not (1.6 < float(hi[1]) < 2.1): issues.append(f"unexpected height {float(hi[1]):.2f} m") elif category == "npc_gear": if tris > GEAR_MAX_TRIS: issues.append(f"triangles {tris} > {GEAR_MAX_TRIS}") elif category == "props": if tris > PROP_MAX_TRIS: issues.append(f"triangles {tris} > {PROP_MAX_TRIS}") else: warnings.append("external asset: no project budget or rig contract applied") if int(stats.get("meshes_without_uv", 0)) > 0: issues.append(f"{int(stats['meshes_without_uv'])} mesh(es) without UVs") if int(stats.get("meshes_without_material", 0)) > 0: warnings.append(f"{int(stats['meshes_without_material'])} mesh(es) without a material") if int(stats.get("objects", 0)) > max(4, tris // 2000): warnings.append(f"{stats['objects']} objects: merge before runtime use (draw calls)") if float(stats.get("transform_delta", 0.0)) > INSPECT_TRANSFORM_TOLERANCE: warnings.append( "node transforms changed the pose by " f"{float(stats['transform_delta']):.3f} m; apply transforms before export " "(`audit` reads local bounds)") if int(stats.get("animations", 0)) > 0: warnings.append(f"{int(stats['animations'])} animation(s) imported; the runtime has " "no clip for them") if int(stats.get("images", 0)) == 0 and materials and category in ("npc", "external"): # Library gear/props are intentionally solid-colour palette materials. warnings.append("no images: materials are untextured") return { "ok": not issues, "category": category, "issues": issues, "warnings": warnings, "budget": { "max_triangles": {"npc": CHAR_MAX_TRIS, "npc_gear": GEAR_MAX_TRIS, "props": PROP_MAX_TRIS}.get(category), "max_vertices": CHAR_MAX_VERTS if category == "npc" else None, }, "bones_found": sorted(bones_ok), "bones_missing": sorted(needed - set(bones)) if category == "npc" else [], } def inspect_glb(path, category=None) -> dict: """Import `path` into the current Blender session and measure it. Node transforms are applied first, so bounds and bone rests match what the scene would export rather than the accessor-local values `audit` reads. """ if bpy is None: return {"ok": False, "error": "inspect must run inside Blender", "issues": [], "warnings": [], "category": category or "external"} file_path = Path(path).expanduser() if not file_path.is_file(): return {"ok": False, "error": f"missing file {file_path}", "issues": [], "warnings": [], "category": category or "external"} reset_scene() # The glTF importer logs per-mesh INFO lines to stdout, which would corrupt # `--json` output; keep the log in stats instead of printing it. import_log = io.StringIO() try: with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log): bpy.ops.import_scene.gltf(filepath=str(file_path)) except RuntimeError as exc: return {"ok": False, "error": f"import failed: {exc}", "issues": [], "warnings": [], "category": category or "external"} armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"] # The glTF importer adds a bone-display helper (an Icosphere used as # pose_bone.custom_shape). It is not part of the asset, so never measure it. helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones if pb.custom_shape is not None} meshes = [o for o in bpy.context.scene.objects if o.type == "MESH" and o.name not in helpers] local = _bounds_of(meshes, world=False) world = _bounds_of(meshes, world=True) delta = _bounds_delta(local, world) applied = _apply_imported_transforms(meshes, armatures) if applied: world = _bounds_of(meshes, world=True) bones = [b.name for arm in armatures for b in arm.data.bones] bone_heads = {b.name: _to_godot(arm.matrix_world @ b.head_local, digits=3) for arm in armatures for b in arm.data.bones} uv_layers = sorted({uv.name for o in meshes for uv in o.data.uv_layers}) objects = [{ "name": o.name, "triangles": _triangle_count(o), "vertices": len(o.data.vertices), "split_vertices": _split_vertex_count(o), "uv_layers": [uv.name for uv in o.data.uv_layers], "materials": [s.material.name for s in o.material_slots if s.material], "vertex_groups": len(o.vertex_groups), "skinned": any(m.type == "ARMATURE" for m in o.modifiers), } for o in meshes] stats = { "file": str(file_path), "category": category or category_for_path(file_path), "objects": len(meshes), "mesh_nodes": [o.name for o in meshes], "armatures": [o.name for o in armatures], "helpers": sorted(helpers), "bones": bones, "bone_heads": bone_heads, "materials": sorted({m for row in objects for m in row["materials"]}), "images": len(bpy.data.images), "uv_layers": uv_layers, "meshes_without_uv": sum(1 for row in objects if not row["uv_layers"]), "meshes_without_material": sum(1 for row in objects if not row["materials"]), "unskinned": [row["name"] for row in objects if not row["skinned"]], "skinned": any(row["skinned"] for row in objects), "animations": len(bpy.data.actions), "triangles": sum(row["triangles"] for row in objects), "vertices": sum(row["vertices"] for row in objects), "split_vertices": sum(row["split_vertices"] for row in objects), "local_bounds": local, "world_bounds": world, "transform_delta": delta, "transforms_applied": applied, "import_log": import_log.getvalue().strip().splitlines()[-3:], "detail": objects, } result = analyse_inspection(stats) result.update({"stats": stats}) return result def _to_godot(co, digits: int = 4) -> list: """Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match the contract bounds, HEAD_BAND and the audit's coordinate space.""" return [round(float(co[0]), digits), round(float(co[2]), digits), round(-float(co[1]), digits)] def _bounds_of(objects, world: bool) -> dict: """Bounds in Godot coordinates (Y up, +Z front) for the given meshes.""" lo = [1e18] * 3 hi = [-1e18] * 3 found = False for obj in objects: matrix = obj.matrix_world if world else None for vert in obj.data.vertices: co = (matrix @ vert.co) if matrix is not None else vert.co found = True for k in range(3): lo[k] = min(lo[k], float(co[k])) hi[k] = max(hi[k], float(co[k])) if not found: return {"min": None, "max": None} # Blender is Z-up; Godot is Y-up with +Z forward: (x, y, z) -> (x, z, -y). return { "min": [round(lo[0], 4), round(lo[2], 4), round(-hi[1], 4)], "max": [round(hi[0], 4), round(hi[2], 4), round(-lo[1], 4)], } def _bounds_delta(a: dict, b: dict) -> float: if not a.get("min") or not b.get("min"): return 0.0 return round(max(abs(x - y) for pair in ((a["min"], b["min"]), (a["max"], b["max"])) for x, y in zip(*pair)), 4) def _triangle_count(obj) -> int: mesh = obj.data if hasattr(mesh, "calc_loop_triangles"): mesh.calc_loop_triangles() return len(mesh.loop_triangles) return sum(len(poly.vertices) - 2 for poly in mesh.polygons) def _split_vertex_count(obj) -> int: """Approximate the glTF vertex count: unique (vertex, uv, normal) per loop.""" mesh = obj.data uv_layer = mesh.uv_layers.active seen = set() for poly in mesh.polygons: for loop_index in poly.loop_indices: loop = mesh.loops[loop_index] uv = tuple(round(float(c), 4) for c in (uv_layer.data[loop_index].uv if uv_layer else (0.0, 0.0))) normal = tuple(round(float(c), 3) for c in loop.normal) seen.add((loop.vertex_index, uv, normal)) return len(seen) def _apply_imported_transforms(meshes, armatures) -> bool: """Clear parents and apply object transforms so measurements match export.""" if not meshes and not armatures: return False try: bpy.ops.object.select_all(action="DESELECT") for obj in meshes + armatures: obj.select_set(True) bpy.context.view_layer.objects.active = meshes[0] if meshes else armatures[0] bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM") bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) return True except (RuntimeError, AttributeError): # pragma: no cover - Blender only return False def print_inspection(result: dict) -> None: stats = result.get("stats", {}) if result.get("error"): print(f"INSPECT FAILED {stats.get('file', '')}\n - {result['error']}", file=sys.stderr) return world = stats.get("world_bounds") or {} lo, hi = world.get("min"), world.get("max") print(f"INSPECT {Path(stats['file']).name}") print(f" category {result['category']}") budget = result.get("budget", {}) print(f" triangles {stats.get('triangles', 0)}" + (f" / {budget['max_triangles']}" if budget.get("max_triangles") else "")) print(f" vertices {stats.get('vertices', 0)} raw, " f"{stats.get('split_vertices', 0)} seam-split" + (f" / {budget['max_vertices']}" if budget.get("max_vertices") else "")) if lo and hi: print(f" bounds (m) x=[{lo[0]:+.3f},{hi[0]:+.3f}] " f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] z=[{lo[2]:+.3f},{hi[2]:+.3f}]") if result["category"] == "npc": print(f" height / feet {hi[1]:.3f} m / {lo[1]:+.3f} m") print(f" transforms {'applied' if stats.get('transforms_applied') else 'NOT applied'}" f" (local/world delta {stats.get('transform_delta', 0.0):.3f} m)") print(f" objects {stats.get('objects', 0)} mesh(es) " f"[{', '.join(stats.get('mesh_nodes', [])) or 'none'}], " f"{len(stats.get('armatures', []))} armature(s)" + (f", helpers ignored [{', '.join(stats['helpers'])}]" if stats.get("helpers") else "")) print(f" bones {len(stats.get('bones', []))}" + (f": {', '.join(stats.get('bones', []))}" if stats.get("bones") else "")) print(f" materials {len(stats.get('materials', []))}" + (f": {', '.join(stats.get('materials', []))}" if stats.get("materials") else "")) print(f" uv layers {len(stats.get('uv_layers', []))}" + (f": {', '.join(stats.get('uv_layers', []))}" if stats.get("uv_layers") else "")) print(f" skinning {'yes' if stats.get('skinned') else 'no'}" f" (unskinned: {', '.join(stats.get('unskinned', [])) or 'none'})") print(f" animations {stats.get('animations', 0)}") print(f" issues {'none' if result['ok'] else str(len(result['issues']))}") for issue in result["issues"]: print(" - " + issue) if result["warnings"]: print(" warnings") for warning in result["warnings"]: print(" ! " + warning) 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_local_positions(name): path = asset_path("npc_gear", name) if not path.is_file(): return [] try: _magic, _version, _length, _size, gltf, binary = _read_glb(path) except (struct.error, ValueError, KeyError): return [] out = [] for mesh in gltf.get("meshes", []): for prim in mesh.get("primitives", []): attrs = prim.get("attributes", {}) if "POSITION" in attrs: out.extend(_accessor(gltf, binary, attrs["POSITION"])) return out 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. inner_x = min(abs(lo[0]), abs(hi[0])) if name in FIT_BODY_BANDS: band = FIT_BODY_BANDS[name] body_x = [abs(p[0]) for p in _gear_local_positions(name) if band[0] <= p[1] <= band[1]] if body_x: inner_x = min(body_x) 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({ "inner_x": round(inner_x, 3), "x_extent": round(x_extent, 3), "radial": round(radial, 3), # Positive clearance means the bag's inner face is outside the body. "x_clearance": round(inner_x - reference["clothing_radius"], 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") insp = sub.add_parser("inspect", help="import a GLB and report its contract state") insp.add_argument("--file", action="append", required=True, help="GLB to inspect (repeatable; absolute path, it runs in Blender)") insp.add_argument("--category", default="", choices=["", "npc", "npc_gear", "props", "external"], help="override the path-derived contract category") insp.add_argument("--json", action="store_true") insp.add_argument("--out", default="", help="with --json, write to this file (Blender prints a startup banner)") tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs") tex.add_argument("--only", default="") tex.add_argument("--json", action="store_true") sub.add_parser("sheet") sub.add_parser("list") args = ap.parse_args(raw) if args.cmd == "texels": names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS) report = [row for row in (texel_density(n) for n in names) if row] 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: print(f"{entry['name']:16s} {entry['resolution']}px maps " f"area={entry['total_world_area_m2']:.2f} m2 " f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m") for slot, row in sorted(entry["slots"].items()): note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \ if slot in TEXEL_RUNTIME_UV_SCALE else "" print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 " f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} " f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}") return 0 if args.cmd == "inspect": if bpy is None: print("inspect must run inside Blender:\n flatpak run org.blender.Blender " f"--background --python {Path(__file__).resolve()} -- inspect " "--file ", file=sys.stderr) return 2 results = [inspect_glb(f, args.category or None) for f in args.file] if args.json: payload = json.dumps(results, indent=2) if args.out: Path(args.out).write_text(payload + "\n", encoding="utf-8") print(f"wrote {args.out}") else: # Blender prints its version banner before this script runs, so # scripts should prefer --out for machine-readable output. print(payload) else: for result in results: print_inspection(result) ok = all(r.get("ok", False) for r in results) return 0 if ok else 1 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"inner_x={row['inner_x']:.3f} " 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())