Files
security-game/tools/blender_assets.py
T
zwitschi 98f618c501
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped
Copy the hoodie eye pair onto the Worker head and lift every hat except the firefighter helmet
2026-11-06 15:11:57 +01:00

3695 lines
172 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"]
# ADR-0018: a Quaternius-derived character keeps the upstream 62-joint armature
# instead of the 13-core skeleton, so these are the joints `NpcAvatar` maps onto
# the animation proxies and the ones the audit requires.
QUATERNIUS_BONES = ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", "LowerLeg.R",
"UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
"Wrist.L", "Wrist.R"]
# Character cap raised to 24000/24000 on 2026-11-05 for the MPFB body, the
# accepted MakeHuman head and a seamless neck/shoulder junction. 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 the 24-NPC
# smoke test must confirm frame time before shipping.
CHAR_MAX_TRIS = 24000
CHAR_MAX_VERTS = 24000
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.
# `base` selects the CC0 Quaternius source the character is baked from
# (ADR-0017/ADR-0018, owner chose the full roster swap); `slots` overrides the
# source's material -> tint-slot map for that role (nothing is deleted: the
# modular Worker's vest is its torso and its hard hat is its crown).
# Rebuild one procedurally with `build --source procedural --head mh`.
CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
"base": "hoodie",
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"base": "casual",
"roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie",
"gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"base": "worker",
# Delivery keeps the hi-vis stripe bands as authored; every
# other worker role tints them with the shirt.
"slots": {"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "keep"}},
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["shoulder_bag"],
"roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"base": "worker",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"base": "worker",
"gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): the three Quaternius
# sources are spread across the crowd pools so a crowd never shows one mesh.
"npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["sunglasses"], "roles": ["tourist", "guest"]},
"npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "base": "casual",
"beard": True, "gear": ["shoulder_bag"],
"roles": ["confused_visitor", "guest"]},
"npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
"base": "worker",
"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
# Head gear is authored at the procedural library's head size; the shipped
# Quaternius roster has a 0.20 m head whose centre sits higher (see
# `build_gear`). 0.64 = 0.201 / 0.315.
HEAD_GEAR_SCALE = 0.64
# The gear origin is the Head *bone* (Godot y 1.587 on the Quaternius rig), but the
# gear is authored around the head *centre* (1.62 in the procedural library). The
# Quaternius head centre sits at 1.69, so head gear needs +0.103 or the cap brim
# lands on the brow and reads as a black visor across the eyes.
HEAD_GEAR_LIFT = 0.103
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"])
# The hair normal map was authored against the old cap UVs. Reusing it
# on the skull-conforming MakeHuman cap produces large dark tangent
# patches, so hair keeps its albedo but takes flat shading normals.
if name != "hair":
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.158 * w, 0.112 * d), # 0 pelvis
((0, 0, 0.96), 0.142 * w * bulk, 0.101 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.155 * w * bulk, 0.110 * d * bulk), # 2 chest
((0, 0.0, 1.335), 0.158 * w * bulk, 0.105 * d * bulk), # 3 upper chest
# Narrow neck and a lower, sloped shoulder root. The old wide neck base
# and horizontal shoulder shelf made the accepted head read as small.
((0, 0, 1.42), 0.060, 0.060), # 4 neck base
((0, -0.005, 1.53), 0.054, 0.054), # 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.19 * s * w, 0, 1.345), 0.070 * t * sleeve, 0.068 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.22), 0.054 * t * sleeve, 0.055 * 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.21
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_curve(style):
"""Hairline endpoints and skull offset for a hair style."""
if style == "buzz":
return 1.734, 1.608, 1.035
return 1.714, 1.588, 1.055
def _trim_hidden_scalp(head, style):
"""Delete the scalp that sits under the hair cap to free triangle budget.
The cap is a scaled copy of the skull, so any head face whose scaled
vertices all fall outside the hairline sphere is fully covered. Keeping
boundary-touching faces leaves a safety band under the hair rim; the open
hole is never visible and the accepted face geometry is untouched.
"""
front_z, back_z, scale = _hair_curve(style)
center = Vector((0.0, 0.0, 1.665))
cutter_z = (front_z + back_z) * 0.5 - 0.396
bm = bmesh.new()
bm.from_mesh(head.data)
def covered(p):
q = center + (p - center) * scale
return (q.x * q.x + (q.y + 0.41) ** 2
+ (q.z - cutter_z) ** 2) > 0.56 ** 2
doomed = [f for f in bm.faces if all(covered(v.co) for v in f.verts)]
if doomed:
bmesh.ops.delete(bm, geom=doomed, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(head.data)
bm.free()
def _hair_from_head(source, style, mat_hair):
"""Build the hair cap as a solid shell derived from the skull.
A separate hair ellipsoid cannot follow the broad MakeHuman crown, and an
open, face-deleted copy produces a sawtooth hairline. Scaling the closed
head shell outwards and boolean-cutting the face with a large front sphere
gives a watertight cap whose curved hairline is smooth from every angle.
"""
front_z, back_z, scale = _hair_curve(style)
cap = source.copy()
cap.data = source.data.copy()
cap.name = "HairCap"
bpy.context.scene.collection.objects.link(cap)
center = Vector((0.0, 0.0, 1.665))
for v in cap.data.vertices:
v.co = center + (v.co - center) * scale
# The cutter is a big sphere in front of the face: its upper surface rises
# toward the brow and falls toward the nape, which is the hairline curve.
mid_z = (front_z + back_z) * 0.5
cutter_z = mid_z - 0.396
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=20, radius=0.56,
location=(0.0, -0.41, cutter_z))
cutter = bpy.context.object
bpy.context.view_layer.objects.active = cap
mod = cap.modifiers.new("Hairline", "BOOLEAN")
mod.operation = "DIFFERENCE"
mod.solver = "EXACT"
mod.object = cutter
bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.data.objects.remove(cutter, do_unlink=True)
# The boolean result is still head-density. Hair needs silhouette, not
# pores, and the character budget has to stay under 16k triangles.
dec = cap.modifiers.new("HairReduce", "DECIMATE")
dec.ratio = 0.34
dec.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier=dec.name)
bm = bmesh.new()
bm.from_mesh(cap.data)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(cap.data)
bm.free()
cap.data.materials.clear()
cap.data.materials.append(mat_hair)
cap.data.shade_smooth()
cap.data.set_sharp_from_angle(angle=math.radians(55))
parts = [cap]
if style == "long":
parts.append(sphere("HairBack", 0.095, (0, 0.060, 1.560), mat_hair,
seg=14, rings=9, scale=(0.98, 0.55, 1.55)))
for s in (-1.0, 1.0):
parts.append(sphere("Lock", 0.048, (0.089 * s, -0.012, 1.585),
mat_hair, seg=8, rings=6,
scale=(0.55, 0.85, 2.05)))
if style == "bun":
parts.append(sphere("Bun", 0.060, (0, 0.100, 1.775), mat_hair,
seg=12, rings=8, scale=(1.0, 0.9, 0.95)))
return parts
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
# Bridge the 1.48-1.53 m neck gap between the MakeHuman cut and the
# procedurally built torso. The plug sits inside both shells, so only
# the visible band reads as neck and no seam ring reaches the surface.
plug = cyl("NeckPlug", 0.062, 0.052, 0.095, (0, 0.002, 1.500), skin,
seg=12, scale=(1.10, 1.0, 1.0))
hair_style = "buzz" if spec["layer"] == "hood" else spec["hair"]
hair_parts = _hair_from_head(head, hair_style, hair)
_trim_hidden_scalp(head, hair_style)
return [head, plug] + hair_parts
# 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"
if head != "mh":
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]
# ---------------------------------------------------------------------------
# Quaternius roster (ADR-0017 / ADR-0018)
# ---------------------------------------------------------------------------
#
# The CC0 sources live under `blender_src/quaternius/` (gitignored for Godot via
# the parent `.gdignore`) and are build inputs, not runtime assets. Every source
# is 4 skinned meshes on the same 62-joint armature; the build joins them
# **without welding** (a weld corrupts the skin weights), renames the upstream
# materials onto the five tint slots, unwraps the degenerate source UVs and bakes
# the arm-down rest. See docs/npc-model-sourcing.md for the measurements.
QUATERNIUS_SRC = SRC_DIR / "quaternius"
# Material -> contract slot per source. `"keep"` leaves the authored colour
# (face, hair detail and hivis fabrics); anything not listed is kept too, so a
# new upstream material shows up as an extra fixed-colour slot instead of
# silently disappearing. The maps were derived from the per-material height bands
# measured in docs/npc-model-sourcing.md.
QUATERNIUS_SOURCES = {
"casual": {
"file": "Casual.glb",
"slots": {"Skin": "skin", "White": "cloth", "Orange": "pants",
"Grey": "accessory", "Hair_Blond": "hair",
"Brown": "keep", "Hair_Brown": "keep"},
"notes": "white tee + orange trousers + grey shoes; blond hair",
},
"hoodie": {
"file": "Casual_Hoodie.glb",
"slots": {"Skin": "skin", "Purple": "cloth", "LightBlue": "pants",
"Hair": "hair", "White": "accessory",
"Eye": "keep", "Eyebrows": "keep"},
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
},
"worker": {
"file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
# The modular Worker has no torso under the vest and no crown
# under the hard hat, so both must stay in the mesh: the vest
# becomes the role's shirt and only the hat/crown part of the
# yellow material stays, tinted as hair. The stripes below the
# collar follow the shirt.
"Worker_Vest": "cloth",
"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "cloth"},
# The bar, the oversized eyes and the hard hat are replaced by
# `_transplant_worker_head()`; those materials are gone by here.
"Eyebrows": "keep",
"Brows": "keep",
"Eye": "keep", "Moustache": "keep"},
"donor_head": True,
"notes": "work shirt (vest geometry) + trousers + hard hat/crown",
},
}
## The modular Worker head has three defects the roster cannot live with, and no
## other upstream head has parts that fit it directly, because the three heads are
## different meshes at different detail levels. `casual_hoodie` is the closest
## sibling, so its brows, eyes and hair are transplanted onto the Worker head:
##
## - `Eyebrows` is one continuous bar across the face (reads as a unibrow);
## - `Eye` is two oversized quads that nearly touch (the same dark mass);
## - `Worker_Yellow` above the collar is a hard hat that doubles as the skull
## crown, so a guard or responder looks like it is wearing a helmet to bed.
##
## This is the only place the build removes geometry. Everything else is
## remapped, because the Worker's vest *is* its torso and its hat *is* its crown.
WORKER_HEAD_DONOR = {
"source": "Casual_Hoodie.glb",
# donor material -> contract slot name.
"parts": {"Eyebrows": "Brows", "Eye": "Eye", "Hair": "hair"},
# The three upstream heads carry the same-sized eye quads (x +-0.057), but the
# Worker's face is flatter, so unchecked they sit proud of the skin and merge
# into one dark bar -- the "unibrow" the owner saw. Set them back into the
# socket so the skin crops their edges, and lift the brows clear of them.
# Brows lift clear of the eye line; the eyes are pushed 4 mm forward so they
# clear the Worker's flatter face instead of sinking into it.
"offset": {"Brows": (0.0, 0.0, 0.020), "Eye": (0.0, -0.004, 0.0)},
# Our material -> the height below which its faces go away. `Eyebrows` is the
# bar *and* the forehead shell, so only the bar is cut: removing the whole
# material opened the skull and the interior rendered as a black band across
# the brows. `Eye` goes entirely (its quads read as one dark mass).
# The Worker's own eye quads sit inside the head and never show (the owner
# reported the Worker roles as eyeless), so they are replaced by the hoodie's
# pair, which is the same geometry the other eight characters use.
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
"bone": "Head",
}
def _purge_unused_materials(obj):
"""Drop material slots no face uses, so a donor can take the freed name.
`IDMaterials.pop()` repoints the polygons that used the slot, which corrupted
the mesh the first time this was tried; rebuilding the list from the per-face
names is explicit and leaves no dangling index.
"""
names = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
names.append(material.name if material else "")
kept, index_of = [], {}
for name in names:
if name in index_of:
continue
source = bpy.data.materials.get(name)
if source is None:
continue
index_of[name] = len(kept)
kept.append(source)
if len(kept) == len(obj.data.materials):
return
obj.data.materials.clear()
for material in kept:
obj.data.materials.append(material)
for poly, name in zip(obj.data.polygons, names):
poly.material_index = index_of.get(name, 0)
# A removed slot leaves its datablock behind with zero users, which still owns
# the name and forces the donor's material to `Eye.001`.
for material in [m for m in bpy.data.materials if m.users == 0]:
bpy.data.materials.remove(material)
def _base_name(name):
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
return head if sep and tail.isdigit() else name
def _transplant_worker_head(obj):
"""Replace the Worker brow bar, eyes and hard hat with the hoodie's head gear."""
spec = WORKER_HEAD_DONOR
# Resolve the targets before anything is deleted or imported: the material
# objects are stable through the join, names are not.
targets = {}
for name in spec["remove"]:
material = next((m for m in obj.data.materials if m and m.name == name), None)
if material is not None:
targets[material.name] = material
doomed = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
if material is None or material.name not in targets:
continue
limit = spec["remove"].get(material.name)
if limit is not None and _face_centroid(obj, poly)[2] <= float(limit):
continue
doomed.append(poly.index)
if doomed:
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bm.to_mesh(obj.data)
bm.free()
print(f" removed {len(doomed)} Worker head faces")
_purge_unused_materials(obj)
donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file():
print(f" head donor {donor_path.name} missing, Worker head left bare")
return
before = set(o.name for o in bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(donor_path))
added = [o for o in bpy.context.scene.objects if o.name not in before]
donor = None
for candidate in added:
if candidate.type != "MESH":
continue
wanted = [i for i, m in enumerate(candidate.data.materials)
if m is not None and _base_name(m.name) in spec["parts"]]
if not wanted:
continue
bm = bmesh.new()
bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table()
drop = [f for f in bm.faces if f.material_index not in wanted]
if drop:
bmesh.ops.delete(bm, geom=drop, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
if not bm.faces:
bm.free()
continue
bm.to_mesh(candidate.data)
bm.free()
# Collect the kept materials, then remap the faces: clearing the material
# list resets every polygon to slot 0.
kept = []
remap = {}
for index, m in enumerate(candidate.data.materials):
if m is None:
continue
slot = spec["parts"].get(_base_name(m.name))
if slot is None:
continue
m.name = slot
remap[index] = len(kept)
kept.append(m)
for slot_name, rule in spec.get("narrow", {}).items():
local = [i for i, m in enumerate(candidate.data.materials)
if m is not None and _base_name(m.name) == slot_name]
if not local:
continue
bm = bmesh.new()
bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table()
moved = {v for f in bm.faces if f.material_index in local for v in f.verts}
centre = float(rule["centre"])
factor = float(rule["scale"])
for v in moved:
box = centre if v.co.x >= 0.0 else -centre
v.co.x = box + (v.co.x - box) * factor
bm.to_mesh(candidate.data)
bm.free()
for slot_name, offset in spec.get("offset", {}).items():
local = [i for i, m in enumerate(candidate.data.materials)
if m is not None and _base_name(m.name) == slot_name]
if not local:
continue
bm = bmesh.new()
bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table()
moved = {v for f in bm.faces if f.material_index in local for v in f.verts}
delta = Vector(offset)
for v in moved:
v.co += delta
bm.to_mesh(candidate.data)
bm.free()
face_slots = [remap.get(poly.material_index, 0)
for poly in candidate.data.polygons]
candidate.data.materials.clear()
for m in kept:
candidate.data.materials.append(m)
for poly, slot in zip(candidate.data.polygons, face_slots):
poly.material_index = slot
donor = candidate
break
for extra in added:
if extra is not donor:
bpy.data.objects.remove(extra, do_unlink=True)
if donor is None:
print(" head donor not found, Worker head left bare")
return
donor.parent = None
donor.matrix_world = Matrix.Identity(4)
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
donor.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
# The join can leave `Eye.001` next to a zero-user `Eye`; the contract slot
# names must be exact because the runtime tints by name.
slot_names = {m.name for m in obj.data.materials if m is not None}
for material in obj.data.materials:
if material is None:
continue
base = _base_name(material.name)
if base != material.name and base not in slot_names:
slot_names.discard(material.name)
material.name = base
slot_names.add(base)
group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "
f"from {spec['source']}")
def _face_centroid(obj, poly):
co = [obj.data.vertices[v].co for v in poly.vertices]
n = max(len(co), 1)
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
sum(c.z for c in co) / n)
def _face_centroid(obj, poly):
co = [obj.data.vertices[v].co for v in poly.vertices]
n = max(len(co), 1)
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
sum(c.z for c in co) / n)
def _carve_quaternius_faces(obj, slot_map, by_name):
"""Delete the faces a rule marks as `carve`.
The modular Worker's `Eyebrows` material is one continuous bar across the
face, which reads as a unibrow, and no other upstream head has brows that fit
this head (the three heads are different meshes). Cutting the middle out of
the bar is the cheapest honest fix. This is the only deletion in the
pipeline: everything else is remapped, because the Worker's vest *is* its
torso and its hard hat *is* the crown of its skull.
"""
doomed = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
rule = slot_map.get(material.name) if material else None
if not isinstance(rule, dict) or "carve" not in rule:
continue
x, _y, z = _face_centroid(obj, poly)
if "z" in rule and z >= float(rule["z"]):
continue
if abs(x) < float(rule["carve"].get("abs_x_below", 0.0)):
doomed.append(poly.index)
if not doomed:
return
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bm.to_mesh(obj.data)
bm.free()
print(f" carved {len(doomed)} brow faces")
def _quaternius_slot_map(obj, slot_map):
"""Repoint the upstream materials onto the contract slots.
Nothing is deleted. The upstream Worker is modular: `Worker_Vest` *is* the
torso and `Worker_Yellow` above z 1.65 *is* the crown of the skull, so
dropping either leaves real holes in the character (that is what shipped in
the first 0.9.9 build). Instead every polygon is repointed at a slot, and a
rule can split one upstream material by height:
{"split": True, "z": 1.65, "above": "hair", "below": "keep"}
Several upstream materials can share a slot (trousers + patches), so the
polygons are repointed at one Blender material instead of relying on a
rename that Blender would de-duplicate to `pants.001`.
"""
materials = [m for m in obj.data.materials if m is not None]
by_name = {m.name: m for m in materials}
# Read the source state before touching the material list: clearing it resets
# every polygon to slot 0.
source_names = []
centroids = []
for poly in obj.data.polygons:
src = materials[poly.material_index] if poly.material_index < len(materials) else None
source_names.append(src.name if src else "")
co = [obj.data.vertices[v].co for v in poly.vertices]
centroids.append(sum(c.z for c in co) / max(len(co), 1))
targets = []
def target_index(key, source):
if key in index_of_slot:
return index_of_slot[key]
if key[0] == "keep":
mat = source
else:
# A copy carries the authored colour into the slot, and keeps the
# source material untouched for the other slots that share it.
mat = source.copy()
mat.name = key[1]
index_of_slot[key] = len(targets)
targets.append(mat)
return index_of_slot[key]
index_of_slot = {}
resolved = []
for name, z in zip(source_names, centroids):
rule = slot_map.get(name, "keep")
if isinstance(rule, dict) and rule.get("split"):
slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
key = ("keep", name) if slot == "keep" else ("slot", slot)
elif rule == "keep" or rule is None:
key = ("keep", name)
else:
key = ("slot", rule)
resolved.append(target_index(key, by_name[name]) if name in by_name else 0)
obj.data.materials.clear()
for m in targets:
obj.data.materials.append(m)
for poly, index in zip(obj.data.polygons, resolved):
poly.material_index = index
def _bake_quaternius_rest(arm, mesh):
"""Bake the T-pose into an arm-down rest pose.
This runs on the re-imported T-pose export, not on the raw upstream file.
The order matters: pose, read the posed matrices, apply the Armature
modifier, **reset the pose to identity**, write the rest matrices, re-add the
modifier. Skipping the reset double-applies the arm-down (mesh width ±0.53 m
instead of ±0.23 m); `bpy.ops.object.armature_apply()` in Blender 5.2
deforms the mesh without baking the rest and is not usable here.
"""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for side in ("L", "R"):
pose_bone = arm.pose.bones.get("UpperArm." + side)
if pose_bone is None:
continue
pose_bone.rotation_mode = "XYZ"
# Local X swings the arm in the frontal plane. This is only symmetric on
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
# the build always round-trips through `_quaternius_tpose_glb()` first.
# 83 instead of 90 degrees leaves the arm 7 degrees out from vertical: on
# the narrow `casual` base a perfectly vertical arm intersects the hip
# and torso (owner report: dress, jacket, crowd_a).
pose_bone.rotation_euler = (math.radians(-83.0), 0, 0)
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
for side in ("L", "R"):
wrist = arm.pose.bones.get("Wrist." + side)
if wrist is not None:
head = arm.matrix_world @ wrist.head
print(f" wrist.{side} at x={head.x:+.3f} y={head.y:+.3f} z={head.z:+.3f}")
fingers = ("Thumb", "Index", "Middle", "Ring", "Pinky")
posed = {}
for side in ("L", "R"):
for bone in arm.data.bones:
name = bone.name
if not name.endswith(side):
continue
if name.startswith(("UpperArm", "LowerArm", "Wrist")) or name.startswith(fingers):
posed[name] = arm.pose.bones[name].matrix.copy()
arm_modifier = next(m for m in mesh.modifiers if m.type == "ARMATURE")
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.modifier_apply(modifier=arm_modifier.name)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for pose_bone in arm.pose.bones:
pose_bone.matrix_basis.identity()
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
for name, matrix in posed.items():
arm.data.edit_bones[name].matrix = matrix
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = mesh
modifier = mesh.modifiers.new("Armature", "ARMATURE")
modifier.object = arm
bpy.context.view_layer.update()
evaluated = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get())
xs = [v.co.x for v in evaluated.data.vertices]
print(f" arm-down rest baked, deformed x {min(xs):.3f}..{max(xs):.3f}")
def _export_preserving_skin(objs, category, name):
"""Export a skinned GLB without touching the rest pose.
`export_apply=True` (what `export()` uses for the procedural library) bakes
the evaluated deformation a second time and writes wrong inverse bind
matrices, so Godot then renders the A-pose.
"""
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=False, 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
def _quaternius_work_dir():
path = ROOT / "tmp" / "npc-build"
path.mkdir(parents=True, exist_ok=True)
return path
def _quaternius_tpose_glb(base, overrides):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the
upstream file mirrors the bone rolls; this round trip normalises them. The
result depends only on the source and the slot overrides, so it is cached
for the run.
"""
# The cache key has to cover the slot overrides: two roles can share a source
# and still need different slot maps (the delivery vest keeps its hi-vis).
key = base
if overrides:
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb"
if path.is_file():
return path
source = QUATERNIUS_SOURCES[base]
source_path = QUATERNIUS_SRC / source["file"]
if not source_path.is_file():
raise FileNotFoundError(
f"{source_path} is missing; see {QUATERNIUS_SRC / 'README.md'}")
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(source_path))
# The importer brings an Icosphere helper along; it is not part of the body
# and has no UVs or weights.
for o in list(bpy.context.scene.objects):
if o.name.startswith("Icosphere"):
bpy.data.objects.remove(o, do_unlink=True)
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not meshes:
raise RuntimeError(f"{source_path} imported no skinned meshes")
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
# Join without welding: remove_doubles merges vertices across weight groups
# and the resulting skinning is wrong (see MEMORY.md).
for o in bpy.context.scene.objects:
o.select_set(False)
for o in meshes:
o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = "npc_base"
obj.data.name = "npc_base"
slots = dict(source["slots"])
slots.update(overrides)
if source.get("donor_head"):
_transplant_worker_head(obj)
_quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write
# an all-black map. Repack a real layout at a tight island margin (the
# default 0.01 wastes most of the square on a dense body), then scale it to
# fill the square so the 256-square maps keep a usable texel density.
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(66.0), island_margin=0.0005)
bpy.ops.uv.average_islands_scale()
bpy.ops.uv.pack_islands(margin=0.0015, rotate=True)
bpy.ops.object.mode_set(mode="OBJECT")
uv_layer = obj.data.uv_layers.active
loops = uv_layer.data
us = [d.uv[0] for d in loops]
vs = [d.uv[1] for d in loops]
span = max(max(us) - min(us), max(vs) - min(vs))
if span > 1e-6:
scale = 0.98 / span
low_u, low_v = min(us), min(vs)
for d in loops:
d.uv = ((d.uv[0] - low_u) * scale + 0.01,
(d.uv[1] - low_v) * scale + 0.01)
# Only the five contract slots are tinted at runtime; make sure a source that
# does not paint with one of them still declares it.
present = {m.name for m in obj.data.materials if m}
for slot in SLOTS:
if slot not in present:
obj.data.materials.append(bpy.data.materials.get(slot)
or material(slot))
for o in bpy.context.scene.objects:
o.select_set(False)
arm.select_set(True)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB", use_selection=True,
export_apply=False, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
print(f" T-pose base {path.relative_to(ROOT)}")
return path
def build_quaternius_character(name):
"""Bake one roster character from its CC0 Quaternius source."""
spec = CHARACTERS[name]
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}))
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
mesh.name = "npc_base"
mesh.data.name = "npc_base"
_bake_quaternius_rest(arm, mesh)
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. The chest points
# are pulled in to x 0.12 / Godot z 0.15 so the straps hug the slimmer
# Quaternius torso (ADR-0018) as well as the wider procedural one;
# the earlier x 0.145 / z 0.19 path floated ~5 cm proud of the
# Quaternius chest and read as two detached blue bars.
p.append(tube("Strap", [(0.075 * s, 0.13, 0.27), (0.095 * s, -0.02, 0.29),
(0.105 * s, -0.06, 0.25),
(0.12 * s, -0.15, 0.18), (0.125 * s, -0.15, -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))
# The hand attach point hangs off the wrist, so a full neck strap would
# float beside the head instead of resting on the neck. A short wrist
# loop keeps every hand item self-contained and follows the hand.
p.append(tube("WristStrap", [(-0.045, -0.02, 0.02), (-0.05, -0.035, 0.075),
(0.0, -0.045, 0.10), (0.05, -0.035, 0.075),
(0.045, -0.02, 0.02)], 0.005, M("plastic_black"),
seg=5, caps=False))
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] == "head":
# Head gear is authored around the head *centre*. The shipped roster is
# the Quaternius base (ADR-0018): its head is 0.20 m wide against the
# procedural library's 0.315 m, and its centre sits 0.07 m higher, so the
# head gear is scaled and lifted to match. The procedural fallback then
# wears slightly small hats — cosmetic, and recorded in
# docs/blender-assets.md.
centre = Vector((0.0, 0.0, HEAD_GEAR_LIFT))
obj.data.transform(
Matrix.Translation(centre)
@ Matrix.Diagonal((HEAD_GEAR_SCALE, HEAD_GEAR_SCALE,
HEAD_GEAR_SCALE, 1.0)))
elif 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, head="face", source="quaternius"):
reset_scene()
quaternius = (category == "npc" and source == "quaternius"
and "base" in CHARACTERS[name])
if category == "npc":
objs = (build_quaternius_character(name) if quaternius
else build_character(name, head=head))
elif category == "npc_gear":
objs = build_gear(name)
else:
objs = build_prop(name)
path = (_export_preserving_skin if quaternius else 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", [])]
required = QUATERNIUS_BONES if "UpperArm.L" in bones else BONES
for b in required:
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}
# ADR-0018: a Quaternius-derived character keeps the 62-joint upstream
# armature, so the required set is the joints `NpcAvatar` maps instead of the
# 13-core skeleton.
if "UpperArm.L" in bones:
needed = set(QUATERNIUS_BONES)
else:
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=24000):
"""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",
"base": spec.get("base", "procedural")})
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="")
if cmd == "build":
s.add_argument("--head", default="face", choices=("face", "proto", "mh"),
help="procedural character head source (default: face)")
s.add_argument("--source", default="quaternius",
choices=("quaternius", "procedural"),
help="character mesh source (default: the Quaternius roster)")
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=24000,
help="decimate target (default 24000)")
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):
if args.cmd == "build":
_build_one(cat, name, head=args.head, source=args.source)
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())