Files
security-game/tools/blender_assets.py
T

2921 lines
138 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<asset>.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_<category>.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"]
# Character cap raised to 16000/16000 on 2026-11-04 for the head/face/hand
# overhaul and possible MakeHuman parts. The required 13 core bones stay, but
# optional extra bones (fingers, face, toes) are allowed; they must not move the
# core rest positions. 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 before accepting the increase.
CHAR_MAX_TRIS = 16000
CHAR_MAX_VERTS = 16000
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/<name>.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.78), 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:
if p.z < 0.82:
# Wrist: track the Hand bone fully so the forearm-end moves with
# the rigid hand (the old Hand+Elbow blend left a visible gap at
# the wrist when the hand rotated).
allowed = [f"Hand{side}"]
else:
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:
# Legs: keep the shin rigid to the knee and the thigh rigid to the
# hip, with a short blend only at the knee joint, so deep flexion
# (seated) no longer collapses the thigh or pinches the knee.
if p.z < 0.28:
allowed = [f"Knee{side}"]
elif p.z < 0.34:
allowed = [f"Hip{side}", f"Knee{side}"]
elif p.z < 0.62:
allowed = [f"Hip{side}"]
else:
allowed = ["Hips", f"Hip{side}"]
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 _head_proto(mats, spec, subdiv=48):
"""Connected head shell with sculpted sockets, nose, lips, ears and brow/cheek/jaw.
The model-overhaul prototype: the skin features are displaced out of one
head mesh instead of floating spheres, so the face is a single connected
surface with loop-based eye sockets and protruding nose/lip/ear volumes. The
eyeballs (sclera/iris/lids) stay separate — they are not skin. Blender Z-up,
face front −Y. `subdiv` is the UV-sphere u-segments (24/48/64 variants); s48
is the chosen default after the 1.5 m/1.85 m comparison.
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
rings = max(14, subdiv // 2 + 2)
head = sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=subdiv, rings=rings,
scale=(0.88, 0.95, 1.18))
_shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes
bm = bmesh.new()
bm.from_mesh(head.data)
# Pass 1: skull shape — flatten the face plane and taper the jaw/chin.
for v in bm.verts:
x, y, z = v.co
if y < -0.03 and 1.42 < z < 1.80:
v.co.y = -0.13 + (y + 0.13) * 0.05 # flatten the forward bulge
if y < -0.03 and z > 1.70:
v.co.y += (z - 1.70) * 0.45 # forehead slopes back to the crown
if z < 1.58:
taper = max(0.0, 1.0 - (1.58 - z) / 0.12)
v.co.x *= 1.0 - 0.16 * taper # narrow the lower face
# Pass 2: features on the flattened face.
for v in bm.verts:
x, y, z = v.co
ax = abs(x)
# --- brow ledge: a clear ridge where the forehead meets the nose ---
brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.024)
* max(0.0, 1.0 - abs(ax - 0.050) / 0.045))
if brow > 0:
v.co.y -= 0.028 * brow
# --- eye sockets: recess the floor, leave a raised orbital rim ---
for s in (-1.0, 1.0):
r = math.hypot(x - 0.052 * s, z - 1.664)
if r < 0.036:
v.co.y += 0.018 * (1.0 - r / 0.036)
elif r < 0.046:
v.co.y -= 0.010 * (1.0 - (r - 0.036) / 0.010)
# --- nose: a wide bridge/tip with ala wings ---
if y < 0 and ax < 0.080:
band = max(0.0, 1.0 - abs(z - 1.632) / 0.055)
if band > 0:
tip = max(0.0, 1.0 - abs(z - 1.615) / 0.034)
v.co.y -= (0.062 * tip + 0.020 * max(0.0, band - tip)) \
* (1.0 - ax / 0.080)
for s in (-1.0, 1.0):
ar = math.hypot((x - 0.026 * s) / 0.015, (z - 1.604) / 0.014)
if ar < 1.0:
v.co.y -= 0.024 * (1.0 - ar)
# --- chin wedge + jaw step ---
if y < 0 and ax < 0.044:
chin = max(0.0, 1.0 - abs(z - 1.548) / 0.024)
if chin > 0:
v.co.y -= 0.034 * chin
step = max(0.0, 1.0 - abs(z - 1.516) / 0.016)
if step > 0:
v.co.y += 0.026 * step
# --- mouth: recessed line + smooth protruding upper/lower lip block ---
if y < 0 and ax < 0.036:
if abs(z - 1.583) < 0.014:
v.co.y += 0.006
if abs(z - 1.590) < 0.012:
v.co.y -= 0.016
if abs(z - 1.577) < 0.014:
v.co.y -= 0.020
# --- ears: raised C-shape helix + concha recess + a lobe ---
for v in bm.verts:
x, y, z = v.co
r = math.hypot((z - 1.62) / 0.062, (y - 0.002) / 0.048)
near = abs(x) - 0.138
sign = 1.0 if x >= 0 else -1.0
if -0.02 < near < 0.07 and r < 1.0:
if r < 0.36:
v.co.x -= 0.016 * (1.0 - r / 0.36) * sign
else:
v.co.x += 0.048 * (1.0 - r) * sign
lr = math.hypot((z - 1.585) / 0.018, (y + 0.012) / 0.017)
if -0.02 < near < 0.06 and lr < 1.0:
v.co.x += 0.034 * (1.0 - lr) * sign
bm.normal_update()
bm.to_mesh(head.data)
bm.free()
# Eyeballs stay separate for readability; lids wrap the socket rim.
parts = [head]
for s in (-1.0, 1.0):
ex, ey, ez = 0.052 * s, -0.127, 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)))
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.137, 1.699),
hair, rot=(0, 0.14 * s, 0.0)))
return parts
def _makehuman_head(mats, spec):
"""MakeHuman head shape plus the project's eye/mouth/brow pieces.
The AI-judged head rework (2026-11-04) picked the MakeHuman head over the
displacement shell: it keeps the nose/chin/jaw landmarks. Newer part builds
carry their own embedded eye/iris/pupil/mouth geometry, so those materials
are preserved; the older shell-only asset still gets the project's
sclera/iris/lid/brow pieces added at the MakeHuman landmarks. Blender Z-up,
face front −Y.
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
src = SRC_DIR / "makehuman_head.glb"
before = {o.name for o in bpy.context.scene.objects}
bpy.ops.import_scene.gltf(filepath=str(src))
# Drop the imported armature / glTF neutral bone; build_character re-weights
# the head to the contract rig.
for a in list(bpy.context.scene.objects):
if a.type == "ARMATURE" and a.name not in before:
bpy.data.objects.remove(a, do_unlink=True)
# Pick the newly imported mesh, not the already-built body.
head = next((o for o in bpy.context.scene.objects
if o.type == "MESH" and o.name not in before
and o.data.materials), None)
if head is None:
raise SystemExit(f"no head mesh in {src}")
head.name = "HeadShell"
# Newer MakeHuman part builds carry their own eye/mouth/brow materials and
# geometry. Use those instead of adding the project's primitive face pieces
# a second time; only the old shell-only asset falls through to that path.
embedded = {m.name.split(".")[0] for m in head.data.materials if m}
if "eye_white" in embedded:
# Preserve the embedded iris/pupil/mouth materials; only map the
# project contract slots onto the imported head.
remap = {"skin": skin, "eye_white": white, "hair": hair}
for index, mat in enumerate(list(head.data.materials)):
if mat:
target = remap.get(mat.name.split(".")[0])
if target is not None:
head.data.materials[index] = target
return [head]
# The old decimated MakeHuman "hair" scalp slot reads as shaggy shards;
# collapse the head to one skin slot so the join does not collide with the
# body's cloth/pants slots (the project `_hair` cap provides hair later).
head.data.materials.clear()
head.data.materials.append(skin)
# Clean the ragged neck cut left by the Head-group extraction: bisect below
# the jaw and cap the hole so the head ends on a clean solid underside.
bm = bmesh.new()
bm.from_mesh(head.data)
bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
plane_co=(0, 0, 1.50), plane_no=(0, 0, -1),
clear_outer=True)
boundary = [e for e in bm.edges if e.is_boundary]
if boundary:
bmesh.ops.holes_fill(bm, edges=boundary)
bm.to_mesh(head.data)
bm.free()
# Lids and brows weld into the head shell so the face reads as one surface;
# the sclera/iris stay separate eyeballs.
shell_parts = [head]
for s in (-1.0, 1.0):
ex, ez = 0.052 * s, 1.664
shell_parts.append(sphere("UpperLid", 0.020, (ex, -0.135, ez + 0.014),
skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24),
rot=(math.radians(-10), 0, 0)))
shell_parts.append(sphere("LowerLid", 0.017, (ex, -0.135, ez - 0.012),
skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18),
rot=(math.radians(6), 0, 0)))
shell_parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.165, 1.705),
hair, rot=(0, 0.14 * s, 0.0)))
shell = join(shell_parts, "HeadShell")
bm = bmesh.new()
bm.from_mesh(shell.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.008)
bm.to_mesh(shell.data)
bm.free()
parts = [shell]
for s in (-1.0, 1.0):
# The MakeHuman socket is a deep cavity (floor ≈ −0.105); keep the
# sclera recessed so it never breaks the face silhouette.
ex, ey, ez = 0.052 * s, -0.125, 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)))
# A subtle mouth line on top of the MakeHuman lips for readability.
parts.append(sphere("Mouth", 0.028, (0, -0.165, 1.58), acc, seg=8, rings=5,
scale=(1.0, 0.28, 0.12)))
# A skull-conforming buzz cap sized for the smaller MakeHuman cranium.
parts.append(_hair_cap("Hair", hair, 1.70, 1.56, radius=0.115,
center=(0, 0.008, 1.66), scale=(0.95, 1.04, 1.05)))
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.762), 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.712, 0.701, 0.704, 0.714)[index]
radius = 0.0115 if index in (1, 2) else 0.0105
parts.append(tube("Finger", [(fx, -0.018, 0.778),
(fx, -0.030, 0.746),
(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.778),
(x - 0.028 * s, -0.040, 0.760),
(x - 0.034 * s, -0.055, 0.738)],
0.012, skin, seg=6, caps=False))
parts.append(sphere("ThumbTip", 0.012, (x - 0.034 * s, -0.055, 0.738), 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 _hand_proto(mats, spec, side=-1.0):
"""Connected hand: palm, four fingers and a thumb fused into one mesh.
The shipped hand is separate primitive parts that read as a mitten at
conversation distance; fusing the finger bases into the palm gives a
connected hand with readable finger separation. `side` is −1 (left, x<0)
or +1 (right, x>0).
"""
hs = _hands_and_shoes(mats, spec)
def _cx(o):
xs = [v.co.x for v in o.data.vertices]
return (min(xs) + max(xs)) / 2
hand = [o for o in hs
if o.name.startswith(("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip"))
and (_cx(o) < 0 if side < 0 else _cx(o) > 0)]
obj = join(hand, "HandL" if side < 0 else "HandR")
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.006)
bm.to_mesh(obj.data)
bm.free()
return obj
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, head="face"):
"""Build one character. `head` picks the head source: "face" (current
primitive face), "proto" (connected-shell displacement prototype) or
"mh" (decimated MakeHuman head + project eyes/brows/mouth)."""
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)
if head == "mh":
head_parts = _makehuman_head(mats, spec)
elif head == "proto":
head_parts = _head_proto(mats, spec)
else:
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:
# Sleeve: the cuff follows the hand, the upper sleeve the elbow,
# so the sleeve does not separate from the hand at the wrist.
return f"Hand{side}" if p.z < 1.06 else 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("<III", data, 0)
json_len, _ = struct.unpack_from("<II", data, 12)
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
bin_off = 20 + json_len
bin_len = struct.unpack_from("<I", data, bin_off)[0] if bin_off < len(data) else 0
binary = data[bin_off + 8:bin_off + 8 + bin_len]
return magic, version, length, len(data), gltf, binary
def _accessor(gltf, binary, index):
acc = gltf["accessors"][index]
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[acc["type"]]
fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(acc["componentType"])]
view = gltf["bufferViews"][acc["bufferView"]]
off = int(view.get("byteOffset", 0)) + int(acc.get("byteOffset", 0))
stride = int(view.get("byteStride", width * struct.calcsize(fmt)))
return [struct.unpack_from("<" + fmt * width, binary, off + i * stride)
for i in range(int(acc["count"]))]
def _degenerate(gltf, binary, pos_index, idx_index):
pos = _accessor(gltf, binary, pos_index)
idx = [i[0] for i in _accessor(gltf, binary, idx_index)]
count = 0
for k in range(0, len(idx) - 2, 3):
a, b, c = pos[idx[k]], pos[idx[k + 1]], pos[idx[k + 2]]
u = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
v = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
cx = u[1] * v[2] - u[2] * v[1]
cy = u[2] * v[0] - u[0] * v[2]
cz = u[0] * v[1] - u[1] * v[0]
if cx * cx + cy * cy + cz * cz < 1e-14:
count += 1
return count
def audit_asset(category, name):
path = asset_path(category, name)
res = {"name": name, "category": category, "ok": False, "issues": [],
"triangles": 0, "vertices": 0, "materials": [], "bounds": None}
if not path.is_file():
res["issues"].append("missing glb")
return res
try:
magic, version, length, size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError) as exc:
res["issues"].append(f"unreadable: {exc}")
return res
if magic != 0x46546C67 or version != 2 or length != size:
res["issues"].append("bad GLB container")
mins = [1e9] * 3
maxs = [-1e9] * 3
tris = verts = degenerate = 0
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
for req in ("POSITION", "NORMAL", "TEXCOORD_0"):
if req not in attrs:
res["issues"].append(f"primitive missing {req}")
if "POSITION" in attrs:
acc = gltf["accessors"][attrs["POSITION"]]
verts += int(acc["count"])
for k in range(3):
mins[k] = min(mins[k], acc["min"][k])
maxs[k] = max(maxs[k], acc["max"][k])
if "TEXCOORD_0" in attrs:
for u, v in _accessor(gltf, binary, attrs["TEXCOORD_0"]):
if not (-1e-4 <= u <= 1.0001 and -1e-4 <= v <= 1.0001):
res["issues"].append("UV outside [0, 1]")
break
if prim.get("indices") is not None:
tris += gltf["accessors"][prim["indices"]]["count"] // 3
if "POSITION" in attrs:
degenerate += _degenerate(gltf, binary, attrs["POSITION"],
prim["indices"])
res["triangles"], res["vertices"] = tris, verts
if degenerate:
# Godot warns "non-finite normal in LOD generation" for these.
res["issues"].append(f"{degenerate} zero-area triangles")
res["materials"] = [m.get("name", "") for m in gltf.get("materials", [])]
if verts:
res["bounds"] = {"min": [round(v, 3) for v in mins],
"max": [round(v, 3) for v in maxs]}
if category == "npc":
nodes = gltf.get("nodes", [])
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
for j in s.get("joints", [])]
for b in BONES:
if b not in bones:
res["issues"].append(f"missing bone {b}")
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
res["issues"].append("no mesh node named npc_base")
for slot in SLOTS:
if slot not in res["materials"]:
res["issues"].append(f"missing material slot {slot}")
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
if "JOINTS_0" not in prim.get("attributes", {}):
res["issues"].append("primitive not skinned")
if tris > 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 adapt_glb(path, out_path, target_tris=16000):
"""Import an external GLB and adapt it toward the library contract.
Applies node transforms, grounds the feet (lowest vertex at y = 0), welds
duplicated seam vertices and decimates to `target_tris`, then exports the
result to `out_path` as a GLB. The five tint slots and the required 13 core
bones (optional extra bones are allowed) are deliberately NOT auto-mapped —
that is a manual retopology/rig step. Returns before/after measurements for
the intake report.
"""
if bpy is None:
return {"ok": False, "error": "adapt must run inside Blender"}
file_path = Path(path).expanduser()
if not file_path.is_file():
return {"ok": False, "error": f"missing file {file_path}"}
reset_scene()
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}"}
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
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]
if not meshes:
return {"ok": False, "error": "no meshes in the file"}
_apply_imported_transforms(meshes, armatures)
before = sum(_triangle_count(o) for o in meshes)
# Ground: translate so the lowest vertex sits at y = 0.
min_y = min((o.matrix_world @ v.co).y for o in meshes for v in o.data.vertices)
for o in meshes:
o.location.y -= min_y
# Weld seam-split duplicates left by the glTF importer.
for o in meshes:
bm = bmesh.new()
bm.from_mesh(o.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
bm.to_mesh(o.data)
bm.free()
# Decimate to the target budget (modifier applied so the count is real).
total = sum(_triangle_count(o) for o in meshes)
if total > target_tris:
ratio = max(0.05, target_tris / total)
for o in meshes:
o.modifiers.new("AdaptDecimate", "DECIMATE").ratio = ratio
_apply_all(o)
after = sum(_triangle_count(o) for o in meshes)
bpy.ops.object.select_all(action="DESELECT")
for o in meshes:
o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
out = Path(out_path).expanduser()
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=str(out), 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")
return {"ok": True, "before_triangles": before, "after_triangles": after,
"out": str(out), "bones": [b.name for a in armatures for b in a.data.bones],
"meshes": [o.name for o in meshes]}
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)")
ada = sub.add_parser("adapt", help="import + ground + weld + decimate an external GLB")
ada.add_argument("--file", required=True,
help="external GLB to adapt (absolute path; runs in Blender)")
ada.add_argument("--out", required=True, help="where to write the adapted GLB")
ada.add_argument("--target-tris", type=int, default=16000,
help="decimate target (default 16000)")
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 <path.glb>", 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 == "adapt":
if bpy is None:
print("adapt must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- adapt "
"--file <in.glb> --out <out.glb>", file=sys.stderr)
return 2
result = adapt_glb(args.file, args.out, args.target_tris)
if not result.get("ok"):
print(f"ADAPT FAILED {result.get('error', 'unknown error')}", file=sys.stderr)
return 1
print(f"adapted {Path(result['out']).name}: "
f"{result['before_triangles']} -> {result['after_triangles']} tris"
f" (bones={len(result['bones'])}, meshes={len(result['meshes'])})")
return 0
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())