Files
security-game/tools/blender_assets.py
T
zwitschi 5bab6754c0
Full build / Import + headless tests (push) Successful in 53s
Full build / Full desktop build + optional release (push) Skipped
Rebuild the fire hydrant silhouette (flange, bonnet, outlets)
2026-11-03 21:56:28 +01:00

2542 lines
120 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"]
# The eight library variants may use more geometry than the frozen npc_base
# fallback (6000 tris / 4000 verts). Raised to 8000/8000 on 2026-11-02 so the
# remaining face, hand-grip and clothing work is not forced to replace detail.
# The glTF vertex count is after UV-seam splits; each avatar is still one
# skinned mesh, and `smoke_npc_budget.gd` must confirm the 24-NPC frame time.
CHAR_MAX_TRIS = 8000
CHAR_MAX_VERTS = 8000
GEAR_MAX_TRIS = 1500
PROP_MAX_TRIS = 4000
try:
import bpy # type: ignore
import bmesh # type: ignore
from mathutils import Matrix, Vector # type: ignore
except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
bpy = None
# ---------------------------------------------------------------------------
# Catalogue (pure data, readable without Blender)
# ---------------------------------------------------------------------------
# Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can
# pick one per avatar later. `tint` lists the slots the runtime should tint;
# every other material keeps its authored colour.
CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long",
"gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim",
"gear": ["shoulder_bag"],
"roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"],
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]},
}
# Gear origin = the attach point; offsets are in the attach node's local space.
GEAR_ATTACH = {
"head": "Rig/Head marker (y = 1.62 m, head centre)",
"hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin",
"back": "Rig root, origin at the spine centre (0, 1.10, 0)",
"chest": "Rig root, origin at the sternum (0, 1.30, 0)",
}
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
FIT_HEAD_MATERIALS = ("skin",)
FIT_CLOTHING_MATERIAL = "cloth"
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
# Side bags include a strap that crosses the torso, so the full gear bbox cannot
# tell where the bag body starts. Measure the body-only y band for this fit.
FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)}
GEAR = {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head",
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
"lanyard": "chest",
"phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand",
"camera": "hand", "protest_sign": "hand",
"flashlight": "hand", "wand": "hand", "radio": "hand",
}
# Props: the first six replace assets/models/generated/<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),
"bark": ((92, 70, 52), 0.95, 0.0, 0.0),
"palm_bark": ((128, 104, 78), 0.95, 0.0, 0.0),
"glass": ((90, 120, 140), 0.08, 0.2, 0.0),
"dark_glass": ((22, 26, 32), 0.06, 0.3, 0.0),
"paper": ((228, 224, 210), 0.9, 0.0, 0.0),
"chalkboard": ((32, 38, 36), 0.9, 0.0, 0.0),
"chalk": ((232, 232, 226), 0.9, 0.0, 0.0),
"plastic_black": ((26, 27, 30), 0.5, 0.0, 0.0),
"plastic_grey": ((110, 114, 120), 0.5, 0.0, 0.0),
"plastic_white": ((222, 222, 218), 0.45, 0.0, 0.0),
"cone_orange": ((236, 96, 24), 0.55, 0.0, 0.0),
"rubber": ((22, 22, 24), 0.9, 0.0, 0.0),
"velvet_red": ((128, 20, 30), 0.9, 0.0, 0.0),
"awning_red": ((150, 34, 38), 0.8, 0.0, 0.0),
"door_paint": ((38, 40, 44), 0.5, 0.4, 0.0),
"car_paint": ((18, 20, 24), 0.2, 0.6, 0.0),
"bulb": ((255, 226, 160), 0.3, 0.0, 6.0),
"headlight": ((240, 240, 255), 0.2, 0.0, 3.0),
"lock_led": ((255, 40, 30), 0.3, 0.0, 4.0),
"sign_face": ((255, 236, 196), 0.4, 0.0, 2.5),
"hivis": ((214, 228, 40), 0.7, 0.0, 0.0),
"leather": ((54, 38, 28), 0.6, 0.0, 0.0),
"denim_bag": ((60, 72, 96), 0.8, 0.0, 0.0),
"pack_nylon": ((40, 64, 92), 0.7, 0.0, 0.0),
"lens": ((20, 22, 26), 0.05, 0.5, 0.0),
"screen": ((26, 40, 70), 0.1, 0.0, 0.6),
"cardboard": ((186, 150, 104), 0.9, 0.0, 0.0),
"ribbon_blue": ((40, 80, 170), 0.7, 0.0, 0.0),
"turnout": ((36, 40, 46), 0.85, 0.0, 0.0),
"helmet": ((24, 26, 30), 0.3, 0.1, 0.0),
"undershirt": ((60, 62, 68), 0.9, 0.0, 0.0),
"uniform": ((30, 34, 46), 0.85, 0.0, 0.0),
"patch": ((190, 150, 60), 0.6, 0.2, 0.0),
"eye_white": ((236, 233, 226), 0.45, 0.0, 0.0),
}
_MATS = {}
def _solid_image(name, rgb):
"""4x4 sRGB texture so a runtime albedo_color=WHITE tint keeps the colour."""
img = bpy.data.images.new(f"{name}_albedo", 4, 4, alpha=False)
px = [rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0] * 16
img.pixels = px
img.pack()
return img
def material(name, textured_solid=False):
"""Principled material from PALETTE (or an npc slot texture set)."""
key = (name, textured_solid)
if key in _MATS:
return _MATS[key]
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
bsdf = nodes.get("Principled BSDF")
if name in SLOTS:
# Same neutral maps as npc_base, so Materials.npc_slot() tints alike.
albedo = nodes.new("ShaderNodeTexImage")
albedo.image = bpy.data.images.load(str(NPC_TEX_DIR / f"{name}_albedo.png"),
check_existing=True)
albedo.image.colorspace_settings.name = "sRGB"
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = bpy.data.images.load(
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
normal_tex.image.colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92,
"accessory": 0.5, "hair": 0.7}[name]
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = 0.0
else:
rgb, rough, metal, emit = PALETTE[name]
lin = tuple(_srgb_to_linear(c / 255.0) for c in rgb) + (1.0,)
bsdf.inputs["Base Color"].default_value = lin
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metal
if textured_solid:
tex = nodes.new("ShaderNodeTexImage")
tex.image = _solid_image(name, rgb)
tex.interpolation = "Closest"
links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if emit > 0.0:
bsdf.inputs["Emission Color"].default_value = lin
bsdf.inputs["Emission Strength"].default_value = emit
_MATS[key] = mat
return mat
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
_MATS.clear()
def _link(name, bm):
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
obj = bpy.data.objects.new(name, me)
bpy.context.scene.collection.objects.link(obj)
return obj
def _xf(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)):
from mathutils import Euler
m = Matrix.Translation(Vector(loc)) @ Euler(rot, "XYZ").to_matrix().to_4x4()
s = Matrix.Diagonal(Vector((scale[0], scale[1], scale[2], 1.0)))
return m @ s
def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1):
obj.data.materials.append(mat)
if bevel > 0.0:
mod = obj.modifiers.new("Bevel", "BEVEL")
mod.width = bevel
mod.segments = bevel_segments
mod.limit_method = "ANGLE"
mod.angle_limit = math.radians(40)
_apply_all(obj)
_clean(obj)
if smooth:
obj.data.shade_smooth()
obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle))
else:
obj.data.shade_flat()
return obj
def _clean(obj):
"""Drop zero-area faces (thin bevels, lathe poles) before export."""
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
def _apply_all(obj):
bpy.context.view_layer.objects.active = obj
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
for mod in list(obj.modifiers):
bpy.ops.object.modifier_apply(modifier=mod.name)
def box(name, size, loc, mat, rot=(0, 0, 0), bevel=0.0, segs=1):
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0, matrix=_xf(loc, rot, size))
return _finish(_link(name, bm), mat, smooth=bevel > 0, bevel=bevel,
bevel_segments=segs)
def cyl(name, r1, r2, depth, loc, mat, seg=16, rot=(0, 0, 0), smooth=True,
scale=(1, 1, 1), caps=True, bevel=0.0):
bm = bmesh.new()
bmesh.ops.create_cone(bm, cap_ends=caps, cap_tris=False, segments=seg,
radius1=r1, radius2=r2, depth=depth,
matrix=_xf(loc, rot, scale))
return _finish(_link(name, bm), mat, smooth=smooth, bevel=bevel)
def sphere(name, r, loc, mat, seg=16, rings=10, scale=(1, 1, 1), rot=(0, 0, 0),
cut_below=None, smooth=True):
"""UV sphere; `cut_below` drops vertices under a local z (0..1 of radius)."""
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=r)
if cut_below is not None:
doomed = [v for v in bm.verts if v.co.z < cut_below * r - 1e-6]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bmesh.ops.transform(bm, matrix=_xf(loc, rot, scale), verts=bm.verts)
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=60)
def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6,
scale=(1, 1, 1)):
bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor,
major_segments=seg, minor_segments=minor_seg)
obj = bpy.context.object
obj.name = name
obj.data.transform(_xf(loc, rot, scale))
return _finish(obj, mat, smooth=True, sharp_angle=70)
def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0,
face_front=False, caps=True):
"""Revolve [(radius, z), ...] around Z (bottom to top), capped.
`face_front` rotates the ring half a step so a flat face (not a corner)
points at the front (-Y); used for octagonal street furniture.
"""
phase = (math.pi / seg + math.pi / 2) if face_front else 0.0
bm = bmesh.new()
rings = []
for r, z in profile:
ring = []
for i in range(seg):
a = math.tau * i / seg + phase
ring.append(bm.verts.new((loc[0] + r * math.cos(a),
loc[1] + r * math.sin(a), loc[2] + z)))
rings.append(ring)
for a, b in zip(rings, rings[1:]):
for i in range(seg):
j = (i + 1) % seg
bm.faces.new((a[i], a[j], b[j], b[i]))
if caps and profile[0][0] > 1e-4:
bm.faces.new(list(reversed(rings[0])))
if caps and profile[-1][0] > 1e-4:
bm.faces.new(rings[-1])
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=sharp)
def tube(name, points, radius, mat, seg=8, caps=True, width_scale=1.0,
thickness_scale=1.0):
"""Bent tube through 3D points (handrails, umbrella handles, frames).
`width_scale`/`thickness_scale` turn the round cross-section into a flat
strap for shoulder harnesses; the default keeps the old round tube.
"""
bm = bmesh.new()
rings = []
pts = [Vector(p) for p in points]
for i, p in enumerate(pts):
if i == 0:
d = (pts[1] - p).normalized()
elif i == len(pts) - 1:
d = (p - pts[i - 1]).normalized()
else:
d = ((pts[i + 1] - p).normalized() + (p - pts[i - 1]).normalized()).normalized()
up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0))
side = d.cross(up).normalized()
up2 = side.cross(d).normalized()
ring = []
for k in range(seg):
a = math.tau * k / seg
ring.append(bm.verts.new(p + radius * (
math.cos(a) * side * width_scale
+ math.sin(a) * up2 * thickness_scale)))
rings.append(ring)
for a, b in zip(rings, rings[1:]):
for k in range(seg):
j = (k + 1) % seg
bm.faces.new((a[k], a[j], b[j], b[k]))
if caps:
bm.faces.new(list(reversed(rings[0])))
bm.faces.new(rings[-1])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def join(objs, name):
for o in bpy.context.selected_objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
if len(objs) > 1:
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
obj.data.name = name
return obj
def uv_unwrap(obj, angle=60.0):
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
def export(objs, category, name):
path = asset_path(category, name)
path.parent.mkdir(parents=True, exist_ok=True)
for o in bpy.context.selected_objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB", use_selection=True,
export_apply=True, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
SRC_DIR.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
compress=True)
return path
# ---------------------------------------------------------------------------
# Characters
# ---------------------------------------------------------------------------
# Bone layout copied from tools/npc_pipeline.py so NpcAvatar's proxies line up.
def _bone_layout():
b = {"Hips": ((0, 0, 0.62), (0, 0, 0.82), None),
"Spine": ((0, 0, 0.82), (0, 0, 1.40), "Hips"),
"Neck": ((0, 0, 1.40), (0, 0, 1.52), "Spine"),
"Head": ((0, 0, 1.52), (0, 0, 1.82), "Neck")}
for s, suf in ((-1.0, "L"), (1.0, "R")):
x = 0.27 * s
hx = 0.12 * s
b[f"Shoulder{suf}"] = ((x, 0, 1.38), (x, 0, 1.06), "Spine")
b[f"Elbow{suf}"] = ((x, 0, 1.06), (x, 0, 0.78), f"Shoulder{suf}")
b[f"Hand{suf}"] = ((x, 0, 0.78), (x, 0, 0.68), f"Elbow{suf}")
b[f"Hip{suf}"] = ((hx, 0, 0.62), (hx, 0, 0.30), "Hips")
b[f"Knee{suf}"] = ((hx, 0, 0.30), (hx, 0, 0.0), f"Hip{suf}")
return b
BUILDS = { # torso width / depth multipliers, limb thickness
"average": (1.0, 1.0, 1.0),
"slim": (0.9, 0.92, 0.88),
"broad": (1.12, 1.08, 1.1),
}
def _skin_body(spec):
"""Connected torso + limbs from a Skin modifier graph, subdivided once."""
w, d, t = BUILDS[spec.get("build", "average")]
layer = spec["layer"]
bulk = 1.0 + {"coat": 0.06, "jacket": 0.05, "hood": 0.06, "responder": 0.16,
"guard": 0.03, "vest": 0.02}.get(layer, 0.0)
sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22,
"guard": 0.05}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
nodes = [ # (pos, radius_x, radius_y)
((0, 0, 0.80), 0.168 * w, 0.116 * d), # 0 pelvis
((0, 0, 0.96), 0.146 * w * bulk, 0.103 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.168 * w * bulk, 0.114 * d * bulk), # 2 chest
((0, 0.0, 1.355), 0.170 * w * bulk, 0.108 * d * bulk), # 3 upper chest
# A slightly fuller neck base and a lower shoulder root give the
# head/neck/shoulder line a trapezius slope instead of a thin post on
# a hanger. The head and collar still sit above this band.
((0, 0, 1.43), 0.074, 0.072), # 4 neck base
((0, -0.005, 1.53), 0.060, 0.060), # 5 neck top
]
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
for s in (-1.0, 1.0):
base = len(nodes)
nodes += [
((0.20 * s * w, 0, 1.37), 0.080 * t * sleeve, 0.074 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.23), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
((0.275 * s * w, -0.01, 0.80), 0.034 * t, 0.030 * t), # wrist
]
edges += [(3, base), (base, base + 1), (base + 1, base + 2), (base + 2, base + 3)]
hb = len(nodes)
nodes += [
((0.095 * s, 0, 0.72), 0.095 * t * leg, 0.10 * t * leg), # hip joint
((0.115 * s, 0, 0.52), 0.080 * t * leg, 0.085 * t * leg), # thigh
((0.12 * s, 0.0, 0.31), 0.058 * t * leg, 0.062 * t * leg), # knee
((0.12 * s, 0.01, 0.18), 0.055 * t * leg, 0.058 * t * leg), # calf
((0.12 * s, 0, 0.08), 0.040 * leg, 0.042 * leg), # ankle
]
edges += [(0, hb), (hb, hb + 1), (hb + 1, hb + 2), (hb + 2, hb + 3), (hb + 3, hb + 4)]
me = bpy.data.meshes.new("body")
me.from_pydata([n[0] for n in nodes], edges, [])
obj = bpy.data.objects.new("body", me)
bpy.context.scene.collection.objects.link(obj)
skin = obj.modifiers.new("Skin", "SKIN")
skin.branch_smoothing = 0.6
for i, (_, rx, ry) in enumerate(nodes):
me.skin_vertices[0].data[i].radius = (rx, ry)
me.skin_vertices[0].data[0].use_root = True
sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.26
dec.use_symmetry = True
dec.symmetry_axis = "X"
_apply_all(obj)
# Clean edge loops where materials change (waist, cuffs, short sleeves, neck).
bm = bmesh.new()
bm.from_mesh(obj.data)
for z in (0.86, 1.19, 1.43):
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1))
# Cuts next to existing vertices leave slivers; weld them.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4)
bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
# Front of the chest/backside slightly shaped: push chest forward a touch.
for v in obj.data.vertices:
if 1.05 < v.co.z < 1.38 and abs(v.co.x) < 0.17 and v.co.y < 0:
v.co.y -= 0.012 * (1.0 - abs(v.co.x) / 0.17)
if 0.72 < v.co.z < 0.9 and abs(v.co.x) < 0.17 and v.co.y > 0:
v.co.y += 0.015
obj.data.shade_smooth()
return obj
def _assign_body_slots(obj, spec, mats):
"""Pick a slot per face from its centroid: skin / cloth / pants."""
layer = spec["layer"]
names = [m.name for m in mats]
obj.data.materials.clear()
for m in mats:
obj.data.materials.append(m)
idx = {n: i for i, n in enumerate(names)}
short_sleeve = layer in ("none", "vest", "dress")
# Main surfaces always use the tintable contract slots (dress legs = tights).
top_slot = "cloth"
leg_slot = "pants"
waist = 0.86
for poly in obj.data.polygons:
c = poly.center
arm = abs(c.x) > 0.205 and c.z > 0.78
if c.z > 1.43 and not arm:
slot = "skin"
elif arm:
if c.z < 0.86 or (short_sleeve and c.z < 1.19):
slot = "skin"
else:
slot = top_slot
elif c.z < waist:
slot = leg_slot
if layer == "dress" and c.z > 0.40:
slot = "cloth"
else:
slot = top_slot
poly.material_index = idx[slot]
def _seg_dist(p, a, b):
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-9)))
return (p - (a + ab * t)).length
def _auto_weights(obj, layout, rigid=None):
"""Distance-to-bone weights limited by body region (no heat-solve flakes)."""
segs = {n: (Vector(h), Vector(t)) for n, (h, t, _) in layout.items()}
groups = {n: obj.vertex_groups.new(name=n) for n in layout}
for v in obj.data.vertices:
p = v.co
side = "L" if p.x < 0 else "R"
if rigid:
bone = rigid(p)
if bone:
groups[bone].add([v.index], 1.0, "REPLACE")
continue
if abs(p.x) > 0.19 and p.z > 0.74 and p.z < 1.42:
allowed = ["Spine", f"Shoulder{side}", f"Elbow{side}", f"Hand{side}"]
if abs(p.x) > 0.23:
allowed.remove("Spine")
elif p.z < 0.80:
allowed = ["Hips", f"Hip{side}", f"Knee{side}"]
if p.z < 0.66:
allowed.remove("Hips")
elif p.z > 1.40:
allowed = ["Spine", "Neck", "Head"]
else:
allowed = ["Hips", "Spine", "Neck"]
scored = sorted((_seg_dist(p, *segs[b]), b) for b in allowed)[:2]
ws = [(1.0 / (dd + 0.02) ** 4, b) for dd, b in scored]
total = sum(w for w, _ in ws)
for w, b in ws:
if w / total > 0.02:
groups[b].add([v.index], w / total, "REPLACE")
def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645),
scale=(0.95, 1.04, 1.1)):
"""Head-hugging cap: cut at the brow in front and lower at the back."""
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=radius)
bmesh.ops.transform(bm, matrix=_xf(center, (0, 0, 0), scale), verts=bm.verts)
doomed = []
for v in bm.verts:
t = max(0.0, min(1.0, 0.5 - (v.co.y - center[1]) / (radius * 1.2)))
cut = back_z + (front_z - back_z) * t
if v.co.z < cut:
doomed.append(v)
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70)
def _hair(style, mat_hair, broad=False):
parts = []
if style == "buzz":
parts.append(_hair_cap("Hair", mat_hair, 1.705, 1.58, radius=0.158))
elif style in ("short", "long", "bun"):
parts.append(_hair_cap("Hair", mat_hair, 1.715, 1.56))
parts.append(sphere("Fringe", 0.08, (0.025, -0.115, 1.755), mat_hair, seg=10,
rings=6, scale=(1.35, 0.5, 0.42), rot=(0.35, 0, -0.18)))
if style == "long":
parts.append(sphere("HairBack", 0.15, (0, 0.06, 1.52), mat_hair, seg=14, rings=8,
scale=(1.0, 0.5, 1.35)))
for s in (-1.0, 1.0):
parts.append(sphere("Lock", 0.06, (0.125 * s, -0.01, 1.57), mat_hair, seg=8,
rings=6, scale=(0.6, 0.9, 1.9)))
if style == "bun":
parts.append(sphere("Bun", 0.065, (0, 0.13, 1.76), mat_hair, seg=12, rings=8))
return parts
def _hood(name, mat, head_center):
"""Hood shell around the head with an oval face opening and a solid rim."""
cx, cy, cz = head_center
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19)
bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0),
(0.97, 1.05, 1.06)), verts=bm.verts)
doomed = [v for v in bm.verts
if v.co.z < cz - 0.17
or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2
+ ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
# Snap the stair-stepped opening onto the ellipse for a clean edge.
for v in bm.verts:
if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16:
dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165
k = math.hypot(dx, dz)
if k > 1e-6:
v.co.x = cx + dx / k * 0.125
v.co.z = cz - 0.01 + dz / k * 0.165
# Thicken into a shell so the open edge reads as fabric, not a paper cut.
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def _shape_head_mesh(head):
"""Sculpt a chin plate and cheekbones into the head sphere itself.
Separate jaw/cheek spheres leave visible intersection rings in the baked AO,
which read as a beard shadow. Moving the head's own front vertices keeps the
surface continuous while still giving the key light something to catch.
"""
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)
# Chin/jaw 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
# Cheekbone: a shallow, wide lift under each eye.
cheek = (max(0.0, 1.0 - abs(z - 1.610) / 0.040)
* max(0.0, 1.0 - abs(ax - 0.072) / 0.050))
v.co.y -= 0.006 * cheek
bm.normal_update()
bm.to_mesh(head.data)
bm.free()
def _face(mats, spec):
"""Head with readable eyes, brow ridge, nose, lips and ears.
At conversation distance the eye region carries the read, so each eye gets a
white sclera, a dark iris and an upper lid, and the face gets sculpted
cheekbones and a nose bridge instead of a single cone (realism pass detail
work).
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
scale=(0.92, 1.0, 1.12))
_shape_head_mesh(head)
parts = [head]
# nose: bridge and ball (nostrils were cut to stay inside the vertex budget)
parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8,
rot=(math.radians(-72), 0, 0), smooth=True))
parts.append(sphere("NoseTip", 0.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.011, (0.015 * 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 skin 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=8, rings=5,
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)))
# One thin upper lid; no lower lid, which made the eyes read as
# half-closed slits at conversation distance.
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(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair,
rot=(0, 0.14 * s, 0.0)))
# lips: upper and lower instead of a single bar
# One rounded mouth slot instead of two flat boxes. Two dark lip blobs read
# as a moustache at portrait distance; a single shallow oval keeps the mouth
# line without adding a second dark mass.
parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5,
scale=(1.0, 0.28, 0.12)))
if spec.get("beard"):
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
scale=(1.0, 0.9, 0.75), cut_below=-0.2))
return parts
def _hands_and_shoes(mats, spec):
skin = mats["skin"]
shoe = mats["accessory"]
parts = []
w = BUILDS[spec.get("build", "average")][0]
for s in (-1.0, 1.0):
x = 0.275 * s * w
# Wider and shallower than the old ball: a palm, not a wrist knob.
parts.append(sphere("Palm", 0.046, (x, -0.012, 0.742), skin, seg=10, rings=6,
scale=(0.80, 0.62, 0.88)))
# Four bent, tapering fingers with rounded tips. The two segments keep
# the hand readable at conversation distance without adding a full
# finger skeleton; the outer fingers are slightly shorter.
for index, offset in enumerate((-0.024, -0.008, 0.008, 0.024)):
fx = x + offset * s
tip_z = (0.692, 0.681, 0.684, 0.694)[index]
radius = 0.0115 if index in (1, 2) else 0.0105
parts.append(tube("Finger", [(fx, -0.018, 0.758),
(fx, -0.030, 0.726),
(fx, -0.042, tip_z)],
radius, skin, seg=6, caps=False))
parts.append(sphere("Fingertip", radius, (fx, -0.042, tip_z), skin,
seg=6, rings=4))
parts.append(tube("Thumb", [(x - 0.010 * s, -0.018, 0.758),
(x - 0.028 * s, -0.040, 0.740),
(x - 0.034 * s, -0.055, 0.718)],
0.012, skin, seg=6, caps=False))
parts.append(sphere("ThumbTip", 0.012, (x - 0.034 * s, -0.055, 0.718), skin,
seg=6, rings=4))
parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe,
bevel=0.028, segs=1))
# Round the toe and heel so the low-poly shoe reads as footwear rather
# than a box; both stay above the rubber sole contact plane.
parts.append(sphere("ShoeToe", 0.045, (0.12 * s, -0.155, 0.045), shoe,
seg=8, rings=5, scale=(1.05, 0.80, 0.55)))
parts.append(sphere("ShoeHeel", 0.034, (0.12 * s, 0.070, 0.045), shoe,
seg=8, rings=5, scale=(1.0, 0.55, 0.55)))
parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01),
material("rubber", textured_solid=True)))
return parts
def _clothing(spec, mats):
layer = spec["layer"]
cloth = mats["cloth"]
acc = mats["accessory"]
parts = []
if layer in ("coat", "dress", "responder"):
bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer]
flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer]
mat = cloth
top = 0.17 if layer != "responder" else 0.20
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1),
(top + 0.03, 0.80), (top, 0.93)], mat, seg=16,
caps=False))
# Double-sided shell: an inner copy with flipped normals, so the open
# hem does not vanish under Godot's back-face culling.
bm = bmesh.new()
bm.from_mesh(parts[-1].data)
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012)
bm.to_mesh(parts[-1].data)
bm.free()
for o in parts[-1:]:
for v in o.data.vertices: # flatten front/back
v.co.y *= 0.8
if layer in ("coat", "jacket", "guard", "responder"):
mat = cloth
parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat,
rot=(math.radians(8), 0, 0), seg=16))
# sleeve cuffs at the wrists so the arm does not end in bare skin
for s in (-1.0, 1.0):
parts.append(torus("Cuff", 0.045, 0.012, (0.275 * s, -0.008, 0.80), mat,
seg=12, minor_seg=4))
parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc))
if layer == "vest":
hv = mats["hivis"]
refl = mats["reflective"]
parts.append(lathe("Vest", [(0.162, 0.86), (0.18, 0.98), (0.19, 1.18),
(0.19, 1.30), (0.15, 1.40)], hv, seg=20, smooth=True))
for v in parts[-1].data.vertices:
v.co.y *= 0.70
for z in (0.98, 1.08):
band = torus("Band", 0.188, 0.012, (0, 0, z), refl, seg=24, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.70
parts.append(band)
for s in (-1.0, 1.0):
parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl,
rot=(0, 0.12 * s, 0)))
if layer == "hood":
parts.append(_hood("Hood", cloth, (0, 0, 1.635)))
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth))
for s in (-1.0, 1.0):
parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38),
acc, seg=5))
if layer == "responder":
refl = mats["reflective"]
hv = mats["hivis"]
for z, r in ((0.60, 0.252), (1.16, 0.205)):
band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.8 if z < 1.0 else 0.66
parts.append(band)
for s in (-1.0, 1.0):
parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl,
rot=(0, 0, 0), seg=14, minor_seg=4))
parts.append(box("Boot", (0.12, 0.27, 0.22), (0.12 * s, -0.04, 0.11),
mats["rubber"], bevel=0.03, segs=2))
parts.append(sphere("BootToe", 0.055, (0.12 * s, -0.155, 0.075),
mats["rubber"], seg=8, rings=5,
scale=(1.0, 0.80, 0.55)))
parts.append(sphere("BootHeel", 0.045, (0.12 * s, 0.080, 0.055),
mats["rubber"], seg=8, rings=5,
scale=(1.0, 0.55, 0.50)))
if layer == "guard":
belt = torus("Belt", 0.168, 0.022, (0, 0, 0.86), mats["leather"], seg=24,
minor_seg=4, scale=(1.0, 0.72, 1.4))
parts.append(belt)
parts.append(box("Buckle", (0.05, 0.012, 0.035), (0, -0.125, 0.86), mats["steel"]))
parts.append(box("Badge", (0.045, 0.01, 0.055), (0.085, -0.128, 1.27), mats["patch"],
bevel=0.006))
for s in (-1.0, 1.0):
parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28),
mats["patch"], bevel=0.005))
parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84),
mats["leather"]))
parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30),
mats["plastic_black"], bevel=0.008))
return parts
def build_character(name):
spec = dict(CHARACTERS[name])
if name == "npc_hoodie":
spec["beard"] = True
mats = {slot: material(slot) for slot in SLOTS}
for extra in ("hivis", "reflective", "leather",
"steel", "patch", "plastic_black", "rubber", "helmet"):
mats[extra] = material(extra, textured_solid=True)
layout = _bone_layout()
body = _skin_body(spec)
body_mats = [mats["skin"], mats["cloth"], mats["pants"]]
_assign_body_slots(body, spec, body_mats)
_auto_weights(body, layout)
head_parts = _face(mats, spec)
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
head_parts += _hair(hair_style, mats["hair"])
head = join(head_parts, "head")
_auto_weights(head, layout, rigid=lambda p: "Head")
hs = _hands_and_shoes(mats, spec)
if spec["layer"] == "responder": # boots replace the shoes
hs = [o for o in hs if not o.name.startswith(("Shoe", "Sole"))] or hs
limbs = join(hs, "limbs")
def limb_bone(p):
side = "L" if p.x < 0 else "R"
return f"Hand{side}" if p.z > 0.5 else f"Knee{side}"
_auto_weights(limbs, layout, rigid=limb_bone)
rest = [body, head, limbs]
clothes = _clothing(spec, mats)
if clothes:
cl = join(clothes, "clothing")
def cloth_bone(p):
if p.z > 1.42:
return "Head" if p.z > 1.55 else "Neck"
side = "L" if p.x < 0 else "R"
if abs(p.x) > 0.26 and p.z > 0.8:
return f"Elbow{side}"
if p.z < 0.25:
return f"Knee{side}"
return None
_auto_weights(cl, layout, rigid=cloth_bone)
rest.append(cl)
mesh = join(rest, "npc_base")
# The fuller neck/shoulder skin graph can leave a few collinear triangles
# after the decimate step. Remove them before UV/export; Godot warns about
# zero-area faces during LOD generation and `audit` rejects them.
bm = bmesh.new()
bm.from_mesh(mesh.data)
bmesh.ops.dissolve_degenerate(bm, dist=2e-4, edges=bm.edges)
# Skin-modifier branch cleanup can still leave a handful of zero-area
# faces; Godot's LOD generator treats them as malformed. Triangulate first
# because a degenerate quad can become a zero-area triangle on export, then
# drop the tiny faces explicitly.
bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY",
ngon_method="BEAUTY")
bad = [face for face in bm.faces if face.calc_area() < 1e-10]
if bad:
bmesh.ops.delete(bm, geom=bad, context="FACES_ONLY")
bm.to_mesh(mesh.data)
bm.free()
# Every contract slot must exist even if a variant does not paint with it.
present = {m.name for m in mesh.data.materials if m}
for slot in SLOTS:
if slot not in present:
mesh.data.materials.append(mats[slot])
# Fewer, larger islands keep the seam-split vertex count inside the budget.
uv_unwrap(mesh, angle=80.0)
arm_data = bpy.data.armatures.new("Armature")
arm = bpy.data.objects.new("Armature", arm_data)
bpy.context.scene.collection.objects.link(arm)
bpy.context.view_layer.objects.active = arm
for o in bpy.context.selected_objects:
o.select_set(False)
arm.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
eb = arm_data.edit_bones
for bone_name, (h, t, parent) in layout.items():
bone = eb.new(bone_name)
bone.head, bone.tail = h, t
if parent:
bone.parent = eb[parent]
bpy.ops.object.mode_set(mode="OBJECT")
mesh.parent = arm
mod = mesh.modifiers.new("Armature", "ARMATURE")
mod.object = arm
return [arm, mesh]
# ---------------------------------------------------------------------------
# Gear (static, attach-point space; see GEAR_ATTACH)
# ---------------------------------------------------------------------------
def build_gear(name):
M = lambda n: material(n) # noqa: E731
p = []
# Head gear: origin = head centre (Godot Rig/Head marker at y=1.62); the
# Blender model's head centre sits at z=1.635, so offsets are ~ +0.015.
if name == "cap":
p.append(sphere("Crown", 0.168, (0, 0.01, 0.05), M("plastic_black"), seg=20,
rings=12, scale=(1.0, 1.05, 0.85), cut_below=0.0))
p.append(cyl("Brim", 0.11, 0.11, 0.012, (0, -0.17, 0.055), M("plastic_black"),
seg=16, scale=(1.0, 0.8, 1.0), rot=(math.radians(-8), 0, 0)))
p.append(sphere("Button", 0.014, (0, 0.01, 0.195), M("plastic_black"), seg=8, rings=4))
elif name == "beanie":
p.append(sphere("Knit", 0.172, (0, 0.01, 0.04), M("plastic_grey"), seg=20, rings=12,
scale=(1.0, 1.04, 1.02), cut_below=-0.05))
p.append(torus("Cuff", 0.163, 0.03, (0, 0.01, 0.04), M("plastic_grey"), seg=20))
elif name == "hood":
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
elif name == "sunglasses":
# Keep the front frame narrow enough to sit inside the 0.315 m head
# silhouette. The arms/hinges set the overall width, so pull those in
# before shrinking the lenses; the lens plane stays in front of the eyes.
for s in (-1.0, 1.0):
p.append(cyl("Lens", 0.024, 0.024, 0.008, (0.04 * s, -0.152, 0.035), M("lens"),
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
p.append(box("Hinge", (0.035, 0.008, 0.01), (0.075 * s, -0.150, 0.045),
M("plastic_black")))
p.append(box("Arm", (0.006, 0.15, 0.008), (0.098 * s, -0.078, 0.042),
M("plastic_black")))
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
elif name == "firefighter_helmet":
p.append(sphere("Shell", 0.19, (0, 0.0, 0.03), M("helmet"), seg=24, rings=12,
scale=(1.0, 1.08, 0.95), cut_below=0.0))
p.append(lathe("Brim", [(0.0, 0.025), (0.20, 0.025), (0.26, 0.015),
(0.255, 0.035), (0.0, 0.04)], M("helmet"), seg=24))
for v in p[-1].data.vertices:
if v.co.y > 0:
v.co.y *= 1.35 # long rear brim
p.append(torus("Band", 0.188, 0.014, (0, 0, 0.09), M("hivis"), seg=24, minor_seg=4,
scale=(1.0, 1.08, 1.0)))
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
# The 2026-11-01 face pass added a brow ridge; lift the helmet 2 cm so
# the brim sits above the eyes instead of cutting into the brow.
for obj in p:
obj.data.transform(Matrix.Translation((0, 0, 0.02)))
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
elif name == "backpack":
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
bevel=0.04, segs=2))
p.append(box("Pocket", (0.22, 0.05, 0.16), (0, 0.29, -0.02), M("pack_nylon"),
bevel=0.02, segs=2))
p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel")))
for s in (-1.0, 1.0):
# Strap path: over the shoulder, then down the chest outside the
# torso front (Godot z≈0.21). The old chest/waist points sat at
# z≈0.13 inside the body, so only thin tube fragments read as two
# rigid black bars from the front.
p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.11 * s, -0.02, 0.29),
(0.12 * s, -0.06, 0.25),
(0.14 * s, -0.19, 0.18), (0.145 * s, -0.19, -0.08)],
0.016, M("denim_bag"), seg=8, width_scale=1.35,
thickness_scale=0.42))
elif name == "shoulder_bag":
# Keep the bag's inner face outside the widest skirt flare (dress = 0.26):
# 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line.
p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"),
bevel=0.025, segs=2))
p.append(tube("Strap", [(0.262, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
(0.262, 0.03, -0.12)], 0.009, M("leather"), seg=6))
elif name == "scba_tank":
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
cut_below=0.0))
p.append(cyl("Valve", 0.03, 0.03, 0.06, (0, 0.21, -0.29), M("brass"), seg=10))
p.append(box("Frame", (0.30, 0.04, 0.60), (0, 0.12, 0.0), M("plastic_black"),
bevel=0.015))
for s in (-1.0, 1.0):
p.append(tube("Strap", [(0.11 * s, 0.10, 0.28), (0.11 * s, -0.03, 0.31),
(0.12 * s, -0.14, 0.18), (0.13 * s, -0.13, -0.15)],
0.016, M("plastic_black"), seg=6))
# Chest: origin = sternum (Godot 0, 1.30, 0).
elif name == "lanyard":
p.append(tube("Cord", [(-0.06, 0.0, 0.16), (-0.07, -0.10, 0.10), (-0.04, -0.135, -0.05),
(0.0, -0.14, -0.14), (0.04, -0.135, -0.05), (0.07, -0.10, 0.10),
(0.06, 0.0, 0.16)], 0.005, M("ribbon_blue"), seg=5, caps=False))
p.append(box("Badge", (0.06, 0.006, 0.085), (0, -0.145, -0.19), M("plastic_white"),
bevel=0.004))
p.append(box("Photo", (0.024, 0.002, 0.03), (-0.012, -0.149, -0.175), M("ribbon_blue")))
# Hand items: origin = grip centre; items hang along -Z (Godot -Y).
elif name == "phone":
p.append(box("Body", (0.075, 0.012, 0.15), (0, -0.02, 0.02), M("plastic_black"),
bevel=0.008, segs=2))
p.append(box("Screen", (0.066, 0.002, 0.135), (0, -0.0265, 0.02), M("screen")))
elif name == "cup":
p.append(lathe("Cup", [(0.0, -0.06), (0.032, -0.06), (0.042, 0.06), (0.0, 0.06)],
M("paper"), seg=16))
p.append(lathe("Lid", [(0.0, 0.06), (0.046, 0.06), (0.044, 0.078), (0.0, 0.082)],
M("plastic_white"), seg=16))
p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16,
smooth=True))
elif name == "clipboard":
p.append(box("Board", (0.23, 0.008, 0.31), (0, -0.04, -0.06), M("dark_wood"),
bevel=0.004))
p.append(box("Paper", (0.20, 0.002, 0.26), (0, -0.045, -0.08), M("paper")))
p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, 0.085), M("steel"), bevel=0.004))
elif name == "umbrella":
p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14),
(0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8))
p.append(cyl("Shaft", 0.009, 0.009, 0.70, (0, 0, 0.29), M("black_metal"), seg=8))
canopy = lathe("Canopy", [(0.0, 0.66), (0.10, 0.65), (0.30, 0.58), (0.45, 0.48)],
M("plastic_black"), seg=8, sharp=20)
p.append(canopy)
elif name == "camera":
p.append(box("Body", (0.13, 0.07, 0.085), (0, -0.04, 0.04), M("plastic_black"),
bevel=0.01, segs=2))
p.append(cyl("Lens", 0.032, 0.03, 0.07, (0, -0.105, 0.035), M("plastic_black"),
seg=16, rot=(math.radians(90), 0, 0)))
p.append(cyl("Glass", 0.024, 0.024, 0.004, (0, -0.141, 0.035), M("lens"), seg=16,
rot=(math.radians(90), 0, 0)))
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
bevel=0.006))
p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42),
(0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006,
M("plastic_black"), seg=5))
elif name == "protest_sign":
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"),
bevel=0.003))
for i, z in enumerate((0.90, 0.80, 0.72)):
p.append(box("Letters", (0.46 - i * 0.08, 0.002, 0.04), (0, -0.032, z),
M("plastic_black")))
elif name == "flashlight":
p.append(cyl("Barrel", 0.017, 0.017, 0.20, (0, -0.02, 0.0), M("black_metal"), seg=14,
rot=(math.radians(90), 0, 0)))
p.append(cyl("Head", 0.027, 0.019, 0.05, (0, -0.135, 0.0), M("black_metal"), seg=14,
rot=(math.radians(-90), 0, 0)))
p.append(cyl("LensF", 0.023, 0.023, 0.004, (0, -0.161, 0.0), M("bulb"), seg=14,
rot=(math.radians(90), 0, 0)))
p.append(box("Switch", (0.01, 0.02, 0.008), (0, -0.06, 0.019), M("rubber")))
elif name == "wand":
p.append(cyl("Grip", 0.019, 0.017, 0.15, (0, 0, 0.0), M("plastic_black"), seg=12))
p.append(box("Paddle", (0.075, 0.025, 0.25), (0, 0, 0.20), M("plastic_grey"),
bevel=0.012, segs=2))
p.append(box("Coil", (0.055, 0.027, 0.18), (0, 0, 0.21), M("plastic_black"),
bevel=0.008))
p.append(sphere("Led", 0.007, (0, -0.016, 0.06), M("lock_led"), seg=8, rings=4))
elif name == "radio":
p.append(box("Body", (0.058, 0.032, 0.115), (0, 0, 0.0), M("plastic_black"),
bevel=0.007, segs=2))
p.append(cyl("Antenna", 0.007, 0.005, 0.09, (0.018, 0.0, 0.10), M("rubber"), seg=8))
p.append(cyl("Knob", 0.008, 0.008, 0.015, (-0.015, 0.0, 0.065), M("plastic_grey"),
seg=10))
p.append(box("Grille", (0.044, 0.002, 0.05), (0, -0.017, -0.02), M("dark_metal")))
p.append(sphere("Led", 0.004, (0.0, -0.016, 0.045), M("lock_led"), seg=6, rings=4))
else:
raise KeyError(name)
obj = join(p, name)
if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
obj.data.transform(Matrix.Translation((0, 0, -0.05)))
uv_unwrap(obj)
return [obj]
# ---------------------------------------------------------------------------
# Props (grounded, front = Blender -Y)
# ---------------------------------------------------------------------------
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
"""Dense foliage cluster: many small flat-shaded clumps, not one smooth blob.
Reads as leaves at game distance because the silhouette is broken up and the
facets catch the light, which is what the realism-pass foliage note asks for.
"""
parts = []
import random
rng = random.Random(seed)
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii,
smooth=False))
for i in range(lumps * 2): # inner clumps fill the volume
a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25)
ring = rng.uniform(0.25, 0.75)
r = rng.uniform(lump * 0.8, lump * 1.25)
off = (center[0] + math.cos(a) * radii[0] * ring,
center[1] + math.sin(a) * radii[1] * ring,
center[2] + rng.uniform(-0.2, 0.55) * radii[2])
parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9),
smooth=False))
for i in range(max(3, lumps // 2)): # outer clumps break the silhouette
a = math.tau * i / max(3, lumps // 2) + rng.uniform(-0.3, 0.3)
r = rng.uniform(lump * 0.6, lump * 0.85)
off = (center[0] + math.cos(a) * radii[0] * rng.uniform(0.85, 1.0),
center[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0),
center[2] + rng.uniform(-0.05, 0.4) * radii[2])
parts.append(sphere(name, 1.0, off, mat, seg=8, rings=6,
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8),
rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)),
smooth=False))
return parts
def build_prop(name):
M = lambda n: material(n) # noqa: E731
p = []
if name == "bollard": # matches generated: r 0.09, h 0.97
p.append(lathe("Bollard", [(0.105, 0.0), (0.105, 0.04), (0.085, 0.07), (0.08, 0.80),
(0.09, 0.84), (0.09, 0.90), (0.06, 0.96), (0.0, 0.975)],
M("black_metal"), seg=8, sharp=25))
p.append(lathe("Band", [(0.0815, 0.74), (0.0815, 0.79)], M("reflective"), seg=8,
smooth=False))
elif name == "bench": # 1.80 x 0.52 seat at 0.46, backrest at back (+Y)
for i, y in enumerate((-0.17, -0.055, 0.06, 0.175)):
p.append(box("Seat", (1.80, 0.10, 0.04), (0, y, 0.46), M("wood"), bevel=0.012))
for z in (0.62, 0.76):
p.append(box("Back", (1.80, 0.035, 0.11), (0, 0.235, z), M("wood"),
rot=(math.radians(-10), 0, 0), bevel=0.012))
for x in (-0.72, 0.72):
p.append(tube("Frame", [(x, -0.22, 0.0), (x, -0.20, 0.44), (x, 0.20, 0.44),
(x, 0.22, 0.0)], 0.022, M("black_metal"), seg=8))
p.append(tube("BackFrame", [(x, 0.20, 0.44), (x, 0.26, 0.84)], 0.02,
M("black_metal"), seg=8))
p.append(tube("Arm", [(x, -0.20, 0.44), (x, -0.20, 0.62), (x, 0.08, 0.64),
(x, 0.24, 0.62)], 0.016, M("black_metal"), seg=8))
elif name == "trash_bin": # octagonal, r ~0.28, h 0.9
p.append(lathe("Base", [(0.27, 0.0), (0.27, 0.08), (0.25, 0.10)], M("bin_green"),
seg=8, smooth=False, face_front=True))
p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8,
smooth=False, face_front=True))
p.append(lathe("Lid", [(0.0, 0.78), (0.285, 0.78), (0.285, 0.84), (0.24, 0.90),
(0.0, 0.92)], M("bin_green"), seg=8, smooth=False,
face_front=True))
p.append(box("Slot", (0.26, 0.03, 0.09), (0, -0.258, 0.70), M("plastic_black")))
apothem = 0.255 * math.cos(math.pi / 8)
for a in range(8): # inset panels, one per face
ang = math.tau * a / 8
p.append(box("Panel", (0.15, 0.01, 0.40),
(math.sin(ang) * apothem, -math.cos(ang) * apothem, 0.38),
M("dark_metal"), rot=(0, 0, ang)))
elif name == "planter_box": # 0.96 square, 0.54 high + shrub
p.append(box("Planter", (0.96, 0.96, 0.50), (0, 0, 0.25), M("stone_pot"),
bevel=0.025, segs=2))
p.append(box("Lip", (1.0, 1.0, 0.05), (0, 0, 0.515), M("stone_pot"), bevel=0.015))
p.append(box("Soil", (0.86, 0.86, 0.02), (0, 0, 0.535), M("soil")))
p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=5, seed=3)
elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92
p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01))
p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02))
p.append(box("Top", (0.44, 0.38, 0.08), (0, 0.0, 0.84), M("news_blue"),
rot=(math.radians(-12), 0, 0), bevel=0.015))
p.append(box("Window", (0.32, 0.01, 0.22), (0, -0.172, 0.62), M("glass")))
p.append(box("Paper", (0.26, 0.004, 0.14), (0, -0.168, 0.60), M("paper")))
p.append(box("Handle", (0.14, 0.03, 0.02), (0, -0.18, 0.47), M("steel"), bevel=0.006))
p.append(box("Coin", (0.06, 0.02, 0.08), (0.14, -0.175, 0.76), M("steel"), bevel=0.005))
elif name == "fire_hydrant": # r 0.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)
_finish(frond, M("palm_leaf"), smooth=True, sharp_angle=80)
p.append(frond)
elif name == "round_planter": # mockup pot with topiary, ~1.0 m
p.append(lathe("Pot", [(0.0, 0.0), (0.22, 0.0), (0.26, 0.05), (0.33, 0.50),
(0.36, 0.52), (0.36, 0.58), (0.31, 0.58), (0.0, 0.56)],
M("stone_pot"), seg=20, sharp=30))
p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=4, seed=5)
elif name == "a_frame_sign": # chalkboard sidewalk sign
for s in (-1.0, 1.0):
tilt = math.radians(14) * s
y = 0.13 * s
p.append(box("Frame", (0.62, 0.035, 0.98), (0, y, 0.48), M("dark_wood"),
rot=(tilt, 0, 0), bevel=0.008))
p.append(box("Board", (0.52, 0.04, 0.82), (0, y, 0.50), M("chalkboard"),
rot=(tilt, 0, 0)))
for i, z in enumerate((0.72, 0.60, 0.40)):
p.append(box("Chalk", (0.38 - 0.06 * i, 0.044, 0.05),
(0, y + 0.0005 * s, z), M("chalk"), rot=(tilt, 0, 0)))
p.append(cyl("Hinge", 0.012, 0.012, 0.62, (0, 0, 0.955), M("steel"), seg=8,
rot=(0, math.radians(90), 0)))
elif name == "wall_lantern": # wall-mounted, origin on wall at mount height
p.append(box("Plate", (0.14, 0.03, 0.22), (0, 0.0, 0.0), M("black_metal"), bevel=0.01))
p.append(tube("Arm", [(0, -0.01, 0.05), (0, -0.14, 0.10), (0, -0.22, 0.08)], 0.015,
M("black_metal"), seg=6))
p.append(lathe("Cap", [(0.0, 0.16), (0.05, 0.15), (0.11, 0.09), (0.11, 0.07)],
M("black_metal"), seg=8, loc=(0, -0.24, 0.0)))
p.append(lathe("Glass", [(0.08, -0.12), (0.10, 0.07)], M("bulb"), seg=8,
loc=(0, -0.24, 0.0), smooth=False))
p.append(lathe("Bottom", [(0.0, -0.17), (0.09, -0.15), (0.09, -0.12)],
M("black_metal"), seg=8, loc=(0, -0.24, 0.0)))
elif name == "traffic_cone":
p.append(box("Base", (0.38, 0.38, 0.04), (0, 0, 0.02), M("rubber"), bevel=0.02))
p.append(lathe("Cone", [(0.15, 0.04), (0.035, 0.68), (0.0, 0.69)], M("cone_orange"),
seg=16))
p.append(lathe("Stripe", [(0.118, 0.22), (0.096, 0.34)], M("reflective"), seg=16))
p.append(lathe("Stripe2", [(0.08, 0.42), (0.064, 0.50)], M("reflective"), seg=16))
elif name == "crowd_barrier": # steel bike-rack barricade, 2.2 m
p.append(tube("Frame", [(-1.1, 0, 0.12), (-1.1, 0, 1.05), (1.1, 0, 1.05), (1.1, 0, 0.12),
(-1.1, 0, 0.12)], 0.022, M("steel"), seg=8, caps=False))
for i in range(13):
x = -0.96 + i * 0.16
p.append(cyl("Bar", 0.01, 0.01, 0.90, (x, 0, 0.585), M("steel"), seg=6))
for x in (-1.0, 1.0):
p.append(tube("Foot", [(x, -0.30, 0.0), (x, 0.0, 0.14), (x, 0.30, 0.0)], 0.018,
M("steel"), seg=6))
elif name == "stanchion": # queue post with velvet rope stub
p.append(lathe("Base", [(0.0, 0.0), (0.17, 0.0), (0.17, 0.025), (0.04, 0.05)],
M("chrome"), seg=20))
p.append(cyl("Post", 0.025, 0.025, 0.90, (0, 0, 0.50), M("chrome"), seg=12))
p.append(sphere("Top", 0.04, (0, 0, 0.97), M("chrome"), seg=12, rings=8))
p.append(tube("Rope", [(0, 0.0, 0.92), (0.2, 0.0, 0.80), (0.45, 0.0, 0.74),
(0.70, 0.0, 0.80), (0.9, 0.0, 0.92)], 0.018,
M("velvet_red"), seg=8))
p.append(cyl("Hook", 0.022, 0.022, 0.05, (0.9, 0, 0.92), M("brass"), seg=8,
rot=(0, math.radians(90), 0)))
elif name == "handrail": # 2 m stair rail, posts at x=0 and x=2 (stairs run +X up)
p.append(tube("Rail", [(-0.1, 0, 0.92), (0.0, 0, 0.95), (2.0, 0, 1.75), (2.1, 0, 1.76)],
0.025, M("black_metal"), seg=10))
for x, z in ((0.0, 0.0), (1.0, 0.4), (2.0, 0.8)):
p.append(cyl("Post", 0.022, 0.022, 0.95 + 0.0, (x, 0, z + 0.475 + (x * 0.0)),
M("black_metal"), seg=8))
p.append(cyl("Flange", 0.05, 0.05, 0.015, (x, 0, z + 0.008), M("black_metal"), seg=10))
elif name == "security_podium": # check-in lectern for the door
p.append(box("Plinth", (0.62, 0.46, 0.06), (0, 0, 0.03), M("plastic_black"),
bevel=0.01))
p.append(box("Body", (0.56, 0.40, 1.0), (0, 0, 0.56), M("dark_wood"), bevel=0.015))
p.append(box("Desk", (0.64, 0.48, 0.04), (0, 0.02, 1.10), M("dark_wood"),
rot=(math.radians(-12), 0, 0), bevel=0.01))
p.append(box("Panel", (0.44, 0.01, 0.62), (0, -0.205, 0.55), M("black_metal")))
p.append(box("Badge", (0.18, 0.012, 0.18), (0, -0.212, 0.66), M("patch"), bevel=0.01))
p.append(box("Tablet", (0.26, 0.18, 0.012), (0, 0.0, 1.13), M("screen"),
rot=(math.radians(-12), 0, 0)))
elif name == "double_door": # 1.8 x 2.6 framed entrance doors
p.append(box("FrameL", (0.10, 0.20, 2.70), (-0.95, 0, 1.35), M("door_paint"), bevel=0.01))
p.append(box("FrameR", (0.10, 0.20, 2.70), (0.95, 0, 1.35), M("door_paint"), bevel=0.01))
p.append(box("Header", (2.0, 0.20, 0.12), (0, 0, 2.66), M("door_paint"), bevel=0.01))
for s in (-1.0, 1.0):
x = 0.45 * s
p.append(box("Leaf", (0.88, 0.06, 2.58), (x, 0.03, 1.30), M("door_paint"),
bevel=0.008))
p.append(box("Glass", (0.62, 0.065, 1.50), (x, 0.03, 1.55), M("dark_glass")))
p.append(box("Kick", (0.80, 0.07, 0.22), (x, 0.03, 0.14), M("steel")))
p.append(cyl("Pull", 0.014, 0.014, 0.50, (0.07 * s, -0.06, 1.15), M("chrome"),
seg=8))
p.append(box("Reader", (0.09, 0.03, 0.14), (1.12, -0.06, 1.25), M("plastic_black"),
bevel=0.008))
p.append(box("LockLed", (0.03, 0.01, 0.03), (1.12, -0.078, 1.29), M("lock_led")))
elif name == "marquee": # canopy sign for the venue, 6 m wide, origin at wall base
p.append(box("Canopy", (6.0, 1.6, 0.18), (0, -0.80, 0.0), M("black_metal"), bevel=0.02))
p.append(box("SignBox", (5.4, 0.35, 1.0), (0, -1.45, 0.60), M("black_metal"),
bevel=0.03))
p.append(box("SignFace", (4.8, 0.02, 0.62), (0, -1.63, 0.62), M("sign_face")))
for i in range(24):
x = -2.6 + i * (5.2 / 23)
for z in (0.17, 1.06):
p.append(sphere("Bulb", 0.035, (x, -1.635, z), M("bulb"), seg=8, rings=4))
for i in range(10):
x = -2.7 + i * 0.6
p.append(sphere("Under", 0.03, (x, -1.0, -0.10), M("bulb"), seg=6, rings=4))
for s in (-1.0, 1.0):
p.append(tube("Rod", [(2.6 * s, -1.5, 1.05), (2.6 * s, -0.4, 1.9)], 0.02,
M("black_metal"), seg=6))
elif name == "sedan": # ~4.6 m dark sedan, front toward -Y
p.append(box("Lower", (1.80, 4.60, 0.62), (0, 0, 0.52), M("car_paint"), bevel=0.12,
segs=3))
cabin = box("Cabin", (1.56, 2.30, 0.55), (0, 0.25, 1.05), M("car_paint"), bevel=0.10,
segs=2)
for v in cabin.data.vertices: # taper the greenhouse
if v.co.z > 1.1:
v.co.x *= 0.88
v.co.y = 0.25 + (v.co.y - 0.25) * 0.72
p.append(cabin)
glass = box("Glass", (1.58, 2.10, 0.36), (0, 0.25, 1.06), M("dark_glass"), bevel=0.06)
for v in glass.data.vertices:
if v.co.z > 1.1:
v.co.x *= 0.89
v.co.y = 0.25 + (v.co.y - 0.25) * 0.74
p.append(glass)
for x in (-0.80, 0.80):
for y in (-1.45, 1.45):
p.append(cyl("Tyre", 0.34, 0.34, 0.24, (x, y, 0.34), M("rubber"), seg=18,
rot=(0, math.radians(90), 0)))
p.append(cyl("Rim", 0.22, 0.22, 0.25, (x, y, 0.34), M("chrome"), seg=12,
rot=(0, math.radians(90), 0)))
for x in (-0.62, 0.62):
p.append(box("Headlight", (0.38, 0.04, 0.10), (x, -2.29, 0.66), M("headlight"),
bevel=0.02))
p.append(box("Tail", (0.40, 0.04, 0.10), (x, 2.29, 0.70), M("lock_led"),
bevel=0.02))
p.append(box("Grille", (0.70, 0.03, 0.14), (0, -2.30, 0.55), M("chrome"), bevel=0.02))
else:
raise KeyError(name)
obj = join(p, name)
uv_unwrap(obj)
return [obj]
# ---------------------------------------------------------------------------
# Build + render entry points (inside Blender)
# ---------------------------------------------------------------------------
def _build_one(category, name):
reset_scene()
if category == "npc":
objs = build_character(name)
elif category == "npc_gear":
objs = build_gear(name)
else:
objs = build_prop(name)
path = export(objs, category, name)
print(f"wrote {path.relative_to(ROOT)}")
return path
def _selected(only):
wanted = {n.strip() for n in only.split(",") if n.strip()} if only else None
return [(c, n) for c, n in catalogue() if wanted is None or n in wanted]
def _setup_render(objs, out_path, character=False):
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 48
scene.cycles.use_denoising = True
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.film_transparent = False
scene.view_settings.view_transform = "AgX"
world = bpy.data.worlds.new("World")
world.use_nodes = True
bg = world.node_tree.nodes.get("Background")
bg.inputs["Color"].default_value = (0.42, 0.44, 0.48, 1.0)
bg.inputs["Strength"].default_value = 0.7
scene.world = world
mins = Vector((1e9, 1e9, 1e9))
maxs = Vector((-1e9, -1e9, -1e9))
for o in objs:
if o.type != "MESH" or o.name.startswith("Icosphere") or o.hide_render:
continue
for corner in o.bound_box:
w = o.matrix_world @ Vector(corner)
mins = Vector(map(min, mins, w))
maxs = Vector(map(max, maxs, w))
center = (mins + maxs) / 2
size = max((maxs - mins).length, 0.05)
bpy.ops.mesh.primitive_plane_add(size=max(size * 6, 2.0), location=(0, 0, min(mins.z, 0)))
floor = bpy.context.object
floor.data.materials.append(material("concrete"))
sun_data = bpy.data.lights.new("Sun", "SUN")
sun_data.energy = 3.5
sun_data.angle = math.radians(8)
sun = bpy.data.objects.new("Sun", sun_data)
sun.rotation_euler = (math.radians(50), 0, math.radians(-35))
scene.collection.objects.link(sun)
fill_data = bpy.data.lights.new("Fill", "AREA")
fill_data.energy = 40.0 * size * size
fill_data.size = size * 2
fill = bpy.data.objects.new("Fill", fill_data)
fill.location = center + Vector((size * 1.2, -size * 1.4, size * 0.6))
fill.rotation_euler = (Vector((0, 0, 0)) - (fill.location - center)).to_track_quat(
"-Z", "Y").to_euler()
scene.collection.objects.link(fill)
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50
cam = bpy.data.objects.new("Cam", cam_data)
direction = Vector((0.55, -1.0, 0.32 if not character else 0.12)).normalized()
cam.location = center + direction * size * 1.45
cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
scene.collection.objects.link(cam)
scene.camera = cam
scene.render.filepath = str(out_path)
# Preview-only tints (sRGB) standing in for NpcAvatar.outfit_for(); the GLBs
# keep neutral slot textures so the runtime tint stays authoritative.
PREVIEW_TINTS = {
"npc_casual": {"cloth": (34, 40, 62), "pants": (150, 120, 84)},
"npc_jacket": {"cloth": (52, 70, 58), "pants": (40, 46, 60)},
"npc_coat": {"cloth": (150, 112, 74), "pants": (30, 32, 38)},
"npc_vest": {"cloth": (70, 74, 82), "pants": (44, 50, 64)},
"npc_dress": {"cloth": (150, 30, 44), "pants": (30, 28, 30)},
"npc_hoodie": {"cloth": (78, 80, 86), "pants": (34, 38, 48)},
"npc_firefighter": {"cloth": (40, 42, 46), "pants": (40, 42, 46)},
"npc_guard": {"cloth": (30, 34, 46), "pants": (30, 34, 46)},
}
DEFAULT_TINTS = {"skin": (214, 170, 136), "hair": (58, 40, 28), "accessory": (30, 30, 34)}
def _tint_preview(name):
tints = dict(DEFAULT_TINTS)
tints.update(PREVIEW_TINTS.get(name, {}))
for mat in bpy.data.materials:
slot = mat.name.split(".")[0]
if slot not in tints or not mat.node_tree:
continue
nodes = mat.node_tree.nodes
bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
if bsdf is None or not bsdf.inputs["Base Color"].links:
continue
src = bsdf.inputs["Base Color"].links[0].from_socket
mix = nodes.new("ShaderNodeMix")
mix.data_type = "RGBA"
mix.blend_type = "MULTIPLY"
mix.inputs["Factor"].default_value = 1.0
mat.node_tree.links.new(src, mix.inputs[6])
rgb = tints[slot]
# neutral textures are light; lift the tint so the product lands near it
mix.inputs[7].default_value = tuple(
min(1.0, _srgb_to_linear(c / 255.0) * 1.5) for c in rgb) + (1.0,)
mat.node_tree.links.new(mix.outputs[2], bsdf.inputs["Base Color"])
def _render_one(category, name):
path = asset_path(category, name)
if not path.is_file():
print(f"skip {name}: not built")
return
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(path))
objs = list(bpy.context.scene.objects)
if category == "npc":
_tint_preview(name)
# Dress the preview with the recommended gear at the documented attach
# points; this also checks the attach-point convention visually.
for gear in CHARACTERS[name].get("gear", []):
gpath = asset_path("npc_gear", gear)
if not gpath.is_file():
continue
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(gpath))
for o in set(bpy.context.scene.objects) - before:
if o.parent is None:
o.location = Vector(o.location) + Vector(ATTACH_BLENDER[GEAR[gear]])
objs = list(bpy.context.scene.objects)
if category == "npc_gear": # show gear at a readable scale on the floor
mins_z = min((o.matrix_world @ Vector(c)).z for o in objs if o.type == "MESH"
for c in o.bound_box)
for o in objs:
if o.parent is None:
o.location.z -= mins_z
bpy.context.view_layer.update()
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
out = PREVIEW_DIR / f"{name}.png"
_setup_render(objs, out, character=category == "npc")
bpy.ops.render.render(write_still=True)
print(f"rendered {out.relative_to(ROOT)}")
# ---------------------------------------------------------------------------
# Audit + contact sheets (stdlib / Pillow, outside Blender)
# ---------------------------------------------------------------------------
def _read_glb(path):
data = path.read_bytes()
magic, version, length = struct.unpack_from("<III", data, 0)
json_len, _ = struct.unpack_from("<II", data, 12)
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
bin_off = 20 + json_len
bin_len = struct.unpack_from("<I", data, bin_off)[0] if bin_off < len(data) else 0
binary = data[bin_off + 8:bin_off + 8 + bin_len]
return magic, version, length, len(data), gltf, binary
def _accessor(gltf, binary, index):
acc = gltf["accessors"][index]
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[acc["type"]]
fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(acc["componentType"])]
view = gltf["bufferViews"][acc["bufferView"]]
off = int(view.get("byteOffset", 0)) + int(acc.get("byteOffset", 0))
stride = int(view.get("byteStride", width * struct.calcsize(fmt)))
return [struct.unpack_from("<" + fmt * width, binary, off + i * stride)
for i in range(int(acc["count"]))]
def _degenerate(gltf, binary, pos_index, idx_index):
pos = _accessor(gltf, binary, pos_index)
idx = [i[0] for i in _accessor(gltf, binary, idx_index)]
count = 0
for k in range(0, len(idx) - 2, 3):
a, b, c = pos[idx[k]], pos[idx[k + 1]], pos[idx[k + 2]]
u = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
v = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
cx = u[1] * v[2] - u[2] * v[1]
cy = u[2] * v[0] - u[0] * v[2]
cz = u[0] * v[1] - u[1] * v[0]
if cx * cx + cy * cy + cz * cz < 1e-14:
count += 1
return count
def audit_asset(category, name):
path = asset_path(category, name)
res = {"name": name, "category": category, "ok": False, "issues": [],
"triangles": 0, "vertices": 0, "materials": [], "bounds": None}
if not path.is_file():
res["issues"].append("missing glb")
return res
try:
magic, version, length, size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError) as exc:
res["issues"].append(f"unreadable: {exc}")
return res
if magic != 0x46546C67 or version != 2 or length != size:
res["issues"].append("bad GLB container")
mins = [1e9] * 3
maxs = [-1e9] * 3
tris = verts = degenerate = 0
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
for req in ("POSITION", "NORMAL", "TEXCOORD_0"):
if req not in attrs:
res["issues"].append(f"primitive missing {req}")
if "POSITION" in attrs:
acc = gltf["accessors"][attrs["POSITION"]]
verts += int(acc["count"])
for k in range(3):
mins[k] = min(mins[k], acc["min"][k])
maxs[k] = max(maxs[k], acc["max"][k])
if "TEXCOORD_0" in attrs:
for u, v in _accessor(gltf, binary, attrs["TEXCOORD_0"]):
if not (-1e-4 <= u <= 1.0001 and -1e-4 <= v <= 1.0001):
res["issues"].append("UV outside [0, 1]")
break
if prim.get("indices") is not None:
tris += gltf["accessors"][prim["indices"]]["count"] // 3
if "POSITION" in attrs:
degenerate += _degenerate(gltf, binary, attrs["POSITION"],
prim["indices"])
res["triangles"], res["vertices"] = tris, verts
if degenerate:
# Godot warns "non-finite normal in LOD generation" for these.
res["issues"].append(f"{degenerate} zero-area triangles")
res["materials"] = [m.get("name", "") for m in gltf.get("materials", [])]
if verts:
res["bounds"] = {"min": [round(v, 3) for v in mins],
"max": [round(v, 3) for v in maxs]}
if category == "npc":
nodes = gltf.get("nodes", [])
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
for j in s.get("joints", [])]
for b in BONES:
if b not in bones:
res["issues"].append(f"missing bone {b}")
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
res["issues"].append("no mesh node named npc_base")
for slot in SLOTS:
if slot not in res["materials"]:
res["issues"].append(f"missing material slot {slot}")
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
if "JOINTS_0" not in prim.get("attributes", {}):
res["issues"].append("primitive not skinned")
if tris > CHAR_MAX_TRIS:
res["issues"].append(f"triangles {tris} > {CHAR_MAX_TRIS}")
if verts > CHAR_MAX_VERTS:
res["issues"].append(f"vertices {verts} > {CHAR_MAX_VERTS}")
if verts and abs(mins[1]) > 0.03:
res["issues"].append(f"feet not on the ground (min y {mins[1]:.3f})")
if verts and not (1.6 < maxs[1] < 2.1):
res["issues"].append(f"unexpected height {maxs[1]:.2f}")
elif category == "npc_gear":
if tris > GEAR_MAX_TRIS:
res["issues"].append(f"triangles {tris} > {GEAR_MAX_TRIS}")
else:
if tris > PROP_MAX_TRIS:
res["issues"].append(f"triangles {tris} > {PROP_MAX_TRIS}")
grounded = name not in ("wall_lantern", "marquee")
if grounded and verts and abs(mins[1]) > 0.03:
res["issues"].append(f"not on the ground (min y {mins[1]:.3f})")
if not (SRC_DIR / f"{name}.blend").is_file():
res["issues"].append("editable .blend source missing")
res["ok"] = not res["issues"]
return res
# ---------------------------------------------------------------------------
# Texel density (UV area vs world area, per material slot)
# ---------------------------------------------------------------------------
# The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat
# the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their
# effective density is four times the authored number.
TEXEL_RESOLUTION = 256
TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0}
def _tri_area3(a, b, c) -> float:
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
cx = uy * vz - uz * vy
cy = uz * vx - ux * vz
cz = ux * vy - uy * vx
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
def _tri_area2(a, b, c) -> float:
return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
def texel_density(name: str, path: Path | None = None) -> dict | None:
"""Per-slot texel density (px/m) for a character GLB.
texel density = resolution * sqrt(UV area / world surface area), so the
number is independent of how the island is packed. Effective density also
applies the runtime UV repeat for cloth/pants.
"""
path = path or asset_path("npc", name)
if not path.is_file():
return None
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return None
materials = [m.get("name", "") for m in gltf.get("materials", [])]
slots: dict[str, dict] = {}
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs
or prim.get("indices") is None):
continue
mat_index = prim.get("material")
slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
positions = _accessor(gltf, binary, attrs["POSITION"])
uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"])
indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])]
row = slots.setdefault(slot or "unassigned",
{"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0})
for k in range(0, len(indices) - 2, 3):
i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2]
if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs):
continue
row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2])
row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2])
row["triangles"] += 1
for slot, row in slots.items():
world = row["world_area_m2"]
uv = row["uv_area"]
authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0
row["td_px_per_m"] = round(authored, 1)
row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1)
row["world_area_m2"] = round(world, 3)
row["uv_area"] = round(uv, 4)
values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0]
return {
"name": name,
"resolution": TEXEL_RESOLUTION,
"slots": slots,
"min_td": min(values) if values else 0.0,
"max_td": max(values) if values else 0.0,
"total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3),
}
def texel_report() -> list[dict]:
return [row for row in (texel_density(name) for name in CHARACTERS) if row]
# ---------------------------------------------------------------------------
# Intake inspection (import a GLB the way Blender sees it)
# ---------------------------------------------------------------------------
# `audit` reads accessor-local bounds and ignores node transforms, so an
# external GLB can pass locally and sit metres away in the scene. `inspect`
# imports the file, applies node transforms and measures world space.
INSPECT_TRANSFORM_TOLERANCE = 0.005 # metres of local/world drift before reporting
INSPECT_CATEGORY_DIRS = {"npc": "npc", "npc_gear": "npc_gear", "props": "props",
"generated": "props", "blender": "external"}
def category_for_path(path) -> str:
"""Guess the contract family from a GLB path (npc, npc_gear, props, external)."""
p = Path(path)
stem = p.stem
parent = p.parent.name
if parent in INSPECT_CATEGORY_DIRS:
return INSPECT_CATEGORY_DIRS[parent]
if stem in CHARACTERS:
return "npc"
if stem in GEAR:
return "npc_gear"
if stem in PROPS:
return "props"
return "external"
def analyse_inspection(stats: dict) -> dict:
"""Pure contract analysis of `inspect_glb()` measurements (no Blender).
Hard contract failures land in `issues`; advisory findings in `warnings`.
The category gates the budget/rig checks; `external` assets only get the
generic checks so an unadapted download can be measured before intake.
"""
category = str(stats.get("category", "external"))
issues: list[str] = []
warnings: list[str] = []
tris = int(stats.get("triangles", 0))
world_verts = int(stats.get("split_vertices", 0))
meshes = list(stats.get("mesh_nodes", []))
bones = list(stats.get("bones", []))
materials = list(stats.get("materials", []))
bones_ok = {b for b in BONES if b in bones}
needed = set(BONES) | {"Hips", "Spine", "Neck"}
if not meshes:
issues.append("no mesh objects")
if tris <= 0:
issues.append("no triangles")
bounds = stats.get("world_bounds") or {}
lo, hi = bounds.get("min"), bounds.get("max")
if not lo or not hi:
issues.append("empty world bounds")
for value in (tris, world_verts, *(lo or []), *(hi or [])):
if isinstance(value, float) and not math.isfinite(value):
issues.append("non-finite measurement")
break
if category == "npc":
if "npc_base" not in meshes:
issues.append("no mesh node named npc_base")
for bone in sorted(needed - set(bones)):
issues.append(f"missing bone {bone}")
for slot in SLOTS:
if slot not in materials:
issues.append(f"missing material slot {slot}")
unskinned = list(stats.get("unskinned", []))
if not stats.get("skinned", False) or unskinned:
issues.append("mesh not skinned" + (f" ({', '.join(unskinned)})" if unskinned else ""))
if tris > CHAR_MAX_TRIS:
issues.append(f"triangles {tris} > {CHAR_MAX_TRIS}")
if world_verts > CHAR_MAX_VERTS:
issues.append(f"glTF vertices {world_verts} > {CHAR_MAX_VERTS}")
if lo and abs(float(lo[1])) > 0.03:
issues.append(f"feet not on the ground (min y {float(lo[1]):.3f})")
if hi and not (1.6 < float(hi[1]) < 2.1):
issues.append(f"unexpected height {float(hi[1]):.2f} m")
elif category == "npc_gear":
if tris > GEAR_MAX_TRIS:
issues.append(f"triangles {tris} > {GEAR_MAX_TRIS}")
elif category == "props":
if tris > PROP_MAX_TRIS:
issues.append(f"triangles {tris} > {PROP_MAX_TRIS}")
else:
warnings.append("external asset: no project budget or rig contract applied")
if int(stats.get("meshes_without_uv", 0)) > 0:
issues.append(f"{int(stats['meshes_without_uv'])} mesh(es) without UVs")
if int(stats.get("meshes_without_material", 0)) > 0:
warnings.append(f"{int(stats['meshes_without_material'])} mesh(es) without a material")
if int(stats.get("objects", 0)) > max(4, tris // 2000):
warnings.append(f"{stats['objects']} objects: merge before runtime use (draw calls)")
if float(stats.get("transform_delta", 0.0)) > INSPECT_TRANSFORM_TOLERANCE:
warnings.append(
"node transforms changed the pose by "
f"{float(stats['transform_delta']):.3f} m; apply transforms before export "
"(`audit` reads local bounds)")
if int(stats.get("animations", 0)) > 0:
warnings.append(f"{int(stats['animations'])} animation(s) imported; the runtime has "
"no clip for them")
if int(stats.get("images", 0)) == 0 and materials and category in ("npc", "external"):
# Library gear/props are intentionally solid-colour palette materials.
warnings.append("no images: materials are untextured")
return {
"ok": not issues,
"category": category,
"issues": issues,
"warnings": warnings,
"budget": {
"max_triangles": {"npc": CHAR_MAX_TRIS, "npc_gear": GEAR_MAX_TRIS,
"props": PROP_MAX_TRIS}.get(category),
"max_vertices": CHAR_MAX_VERTS if category == "npc" else None,
},
"bones_found": sorted(bones_ok),
"bones_missing": sorted(needed - set(bones)) if category == "npc" else [],
}
def inspect_glb(path, category=None) -> dict:
"""Import `path` into the current Blender session and measure it.
Node transforms are applied first, so bounds and bone rests match what the
scene would export rather than the accessor-local values `audit` reads.
"""
if bpy is None:
return {"ok": False, "error": "inspect must run inside Blender", "issues": [],
"warnings": [], "category": category or "external"}
file_path = Path(path).expanduser()
if not file_path.is_file():
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
"warnings": [], "category": category or "external"}
reset_scene()
# The glTF importer logs per-mesh INFO lines to stdout, which would corrupt
# `--json` output; keep the log in stats instead of printing it.
import_log = io.StringIO()
try:
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
bpy.ops.import_scene.gltf(filepath=str(file_path))
except RuntimeError as exc:
return {"ok": False, "error": f"import failed: {exc}", "issues": [],
"warnings": [], "category": category or "external"}
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
# The glTF importer adds a bone-display helper (an Icosphere used as
# pose_bone.custom_shape). It is not part of the asset, so never measure it.
helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones
if pb.custom_shape is not None}
meshes = [o for o in bpy.context.scene.objects
if o.type == "MESH" and o.name not in helpers]
local = _bounds_of(meshes, world=False)
world = _bounds_of(meshes, world=True)
delta = _bounds_delta(local, world)
applied = _apply_imported_transforms(meshes, armatures)
if applied:
world = _bounds_of(meshes, world=True)
bones = [b.name for arm in armatures for b in arm.data.bones]
bone_heads = {b.name: _to_godot(arm.matrix_world @ b.head_local, digits=3)
for arm in armatures for b in arm.data.bones}
uv_layers = sorted({uv.name for o in meshes for uv in o.data.uv_layers})
objects = [{
"name": o.name,
"triangles": _triangle_count(o),
"vertices": len(o.data.vertices),
"split_vertices": _split_vertex_count(o),
"uv_layers": [uv.name for uv in o.data.uv_layers],
"materials": [s.material.name for s in o.material_slots if s.material],
"vertex_groups": len(o.vertex_groups),
"skinned": any(m.type == "ARMATURE" for m in o.modifiers),
} for o in meshes]
stats = {
"file": str(file_path),
"category": category or category_for_path(file_path),
"objects": len(meshes),
"mesh_nodes": [o.name for o in meshes],
"armatures": [o.name for o in armatures],
"helpers": sorted(helpers),
"bones": bones,
"bone_heads": bone_heads,
"materials": sorted({m for row in objects for m in row["materials"]}),
"images": len(bpy.data.images),
"uv_layers": uv_layers,
"meshes_without_uv": sum(1 for row in objects if not row["uv_layers"]),
"meshes_without_material": sum(1 for row in objects if not row["materials"]),
"unskinned": [row["name"] for row in objects if not row["skinned"]],
"skinned": any(row["skinned"] for row in objects),
"animations": len(bpy.data.actions),
"triangles": sum(row["triangles"] for row in objects),
"vertices": sum(row["vertices"] for row in objects),
"split_vertices": sum(row["split_vertices"] for row in objects),
"local_bounds": local,
"world_bounds": world,
"transform_delta": delta,
"transforms_applied": applied,
"import_log": import_log.getvalue().strip().splitlines()[-3:],
"detail": objects,
}
result = analyse_inspection(stats)
result.update({"stats": stats})
return result
def _to_godot(co, digits: int = 4) -> list:
"""Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match
the contract bounds, HEAD_BAND and the audit's coordinate space."""
return [round(float(co[0]), digits), round(float(co[2]), digits),
round(-float(co[1]), digits)]
def _bounds_of(objects, world: bool) -> dict:
"""Bounds in Godot coordinates (Y up, +Z front) for the given meshes."""
lo = [1e18] * 3
hi = [-1e18] * 3
found = False
for obj in objects:
matrix = obj.matrix_world if world else None
for vert in obj.data.vertices:
co = (matrix @ vert.co) if matrix is not None else vert.co
found = True
for k in range(3):
lo[k] = min(lo[k], float(co[k]))
hi[k] = max(hi[k], float(co[k]))
if not found:
return {"min": None, "max": None}
# Blender is Z-up; Godot is Y-up with +Z forward: (x, y, z) -> (x, z, -y).
return {
"min": [round(lo[0], 4), round(lo[2], 4), round(-hi[1], 4)],
"max": [round(hi[0], 4), round(hi[2], 4), round(-lo[1], 4)],
}
def _bounds_delta(a: dict, b: dict) -> float:
if not a.get("min") or not b.get("min"):
return 0.0
return round(max(abs(x - y) for pair in ((a["min"], b["min"]), (a["max"], b["max"]))
for x, y in zip(*pair)), 4)
def _triangle_count(obj) -> int:
mesh = obj.data
if hasattr(mesh, "calc_loop_triangles"):
mesh.calc_loop_triangles()
return len(mesh.loop_triangles)
return sum(len(poly.vertices) - 2 for poly in mesh.polygons)
def _split_vertex_count(obj) -> int:
"""Approximate the glTF vertex count: unique (vertex, uv, normal) per loop."""
mesh = obj.data
uv_layer = mesh.uv_layers.active
seen = set()
for poly in mesh.polygons:
for loop_index in poly.loop_indices:
loop = mesh.loops[loop_index]
uv = tuple(round(float(c), 4) for c in (uv_layer.data[loop_index].uv if uv_layer
else (0.0, 0.0)))
normal = tuple(round(float(c), 3) for c in loop.normal)
seen.add((loop.vertex_index, uv, normal))
return len(seen)
def _apply_imported_transforms(meshes, armatures) -> bool:
"""Clear parents and apply object transforms so measurements match export."""
if not meshes and not armatures:
return False
try:
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes + armatures:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0] if meshes else armatures[0]
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return True
except (RuntimeError, AttributeError): # pragma: no cover - Blender only
return False
def print_inspection(result: dict) -> None:
stats = result.get("stats", {})
if result.get("error"):
print(f"INSPECT FAILED {stats.get('file', '')}\n - {result['error']}", file=sys.stderr)
return
world = stats.get("world_bounds") or {}
lo, hi = world.get("min"), world.get("max")
print(f"INSPECT {Path(stats['file']).name}")
print(f" category {result['category']}")
budget = result.get("budget", {})
print(f" triangles {stats.get('triangles', 0)}" +
(f" / {budget['max_triangles']}" if budget.get("max_triangles") else ""))
print(f" vertices {stats.get('vertices', 0)} raw, "
f"{stats.get('split_vertices', 0)} seam-split" +
(f" / {budget['max_vertices']}" if budget.get("max_vertices") else ""))
if lo and hi:
print(f" bounds (m) x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
if result["category"] == "npc":
print(f" height / feet {hi[1]:.3f} m / {lo[1]:+.3f} m")
print(f" transforms {'applied' if stats.get('transforms_applied') else 'NOT applied'}"
f" (local/world delta {stats.get('transform_delta', 0.0):.3f} m)")
print(f" objects {stats.get('objects', 0)} mesh(es) "
f"[{', '.join(stats.get('mesh_nodes', [])) or 'none'}], "
f"{len(stats.get('armatures', []))} armature(s)"
+ (f", helpers ignored [{', '.join(stats['helpers'])}]" if stats.get("helpers") else ""))
print(f" bones {len(stats.get('bones', []))}"
+ (f": {', '.join(stats.get('bones', []))}" if stats.get("bones") else ""))
print(f" materials {len(stats.get('materials', []))}"
+ (f": {', '.join(stats.get('materials', []))}" if stats.get("materials") else ""))
print(f" uv layers {len(stats.get('uv_layers', []))}"
+ (f": {', '.join(stats.get('uv_layers', []))}" if stats.get("uv_layers") else ""))
print(f" skinning {'yes' if stats.get('skinned') else 'no'}"
f" (unskinned: {', '.join(stats.get('unskinned', [])) or 'none'})")
print(f" animations {stats.get('animations', 0)}")
print(f" issues {'none' if result['ok'] else str(len(result['issues']))}")
for issue in result["issues"]:
print(" - " + issue)
if result["warnings"]:
print(" warnings")
for warning in result["warnings"]:
print(" ! " + warning)
def character_fit_reference(name):
"""Measure a character's head width and hip/skirt radius from its GLB.
Head width uses skin/hair vertices whose strongest joint is Head inside the
fixed head band. Clothing radius uses the cloth vertices in the hip/skirt
band. Both are reported in metres and rounded to millimetres.
"""
path = asset_path("npc", name)
if not path.is_file():
return None
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return None
nodes = gltf.get("nodes", [])
joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])]
materials = [m.get("name", "") for m in gltf.get("materials", [])]
head_x: list[float] = []
head_y: list[float] = []
cloth_x: list[float] = []
hip_x: list[float] = []
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
continue
mat_index = prim.get("material")
mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
positions = _accessor(gltf, binary, attrs["POSITION"])
joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"])
weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"])
for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows):
strongest = max(range(4), key=lambda i: weight_row[i])
bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else ""
if (mat_name in FIT_HEAD_MATERIALS and bone == "Head"
and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]):
head_x.append(pos[0])
head_y.append(pos[1])
if (mat_name == FIT_CLOTHING_MATERIAL
and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]):
cloth_x.append(pos[0])
if (mat_name == "pants"
and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]):
hip_x.append(pos[0])
skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0
hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0
clothing_radius = max(skirt_radius, hip_radius)
return {
"head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0,
"head_y_min": round(min(head_y), 3) if head_y else 0.0,
"head_y_max": round(max(head_y), 3) if head_y else 0.0,
"clothing_radius": round(clothing_radius, 3),
"clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip",
"head_band": HEAD_BAND,
}
def _gear_local_positions(name):
path = asset_path("npc_gear", name)
if not path.is_file():
return []
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return []
out = []
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if "POSITION" in attrs:
out.extend(_accessor(gltf, binary, attrs["POSITION"]))
return out
def _gear_fit(name, reference):
attach = GEAR.get(name)
if attach not in ("head", "back", "chest"):
return None
audit = audit_asset("npc_gear", name)
bounds = audit.get("bounds")
if not bounds:
return None
offset = ATTACH_GODOT[attach]
lo = [bounds["min"][i] + offset[i] for i in range(3)]
hi = [bounds["max"][i] + offset[i] for i in range(3)]
result = {
"name": name,
"attach": attach,
"min": [round(v, 3) for v in lo],
"max": [round(v, 3) for v in hi],
}
if attach == "head":
result.update({
"width": round(hi[0] - lo[0], 3),
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)
if reference["head_width"] else 0.0,
"inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005,
})
else:
# Compare the widest point (|x|) and the corner radial extent with the
# skirt flare. The radial value catches bags that sit diagonally out.
inner_x = min(abs(lo[0]), abs(hi[0]))
if name in FIT_BODY_BANDS:
band = FIT_BODY_BANDS[name]
body_x = [abs(p[0]) for p in _gear_local_positions(name)
if band[0] <= p[1] <= band[1]]
if body_x:
inner_x = min(body_x)
x_extent = max(abs(lo[0]), abs(hi[0]))
radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
result.update({
"inner_x": round(inner_x, 3),
"x_extent": round(x_extent, 3),
"radial": round(radial, 3),
# Positive clearance means the bag's inner face is outside the body.
"x_clearance": round(inner_x - reference["clothing_radius"], 3),
"radial_clearance": round(reference["clothing_radius"] - radial, 3),
})
return result
def fit_report():
"""Return per-character gear fit measurements for the `fit` command/tests."""
report = []
for character, spec in CHARACTERS.items():
reference = character_fit_reference(character)
if reference is None:
continue
rows = []
for gear_name in spec.get("gear", []):
row = _gear_fit(gear_name, reference)
if row:
rows.append(row)
report.append({"character": character, "reference": reference, "gear": rows})
return report
def write_manifest(results):
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
"assets": {}}
for r in results:
entry = {"path": str(asset_path(r["category"], r["name"]).relative_to(ROOT)),
"category": r["category"], "triangles": r["triangles"],
"vertices": r["vertices"], "materials": r["materials"],
"bounds_godot": r["bounds"]}
if r["category"] == "npc":
spec = CHARACTERS[r["name"]]
entry.update({"layer": spec["layer"], "roles": spec["roles"],
"tint_slots": SLOTS, "mesh_node": "npc_base"})
elif r["category"] == "npc_gear":
entry["attach"] = GEAR[r["name"]]
else:
entry.update({k: v for k, v in PROPS[r["name"]].items()})
out["assets"][r["name"]] = entry
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def contact_sheets():
from PIL import Image, ImageDraw
groups = {}
for cat, name in catalogue():
groups.setdefault(cat, []).append(name)
written = []
for cat, names in groups.items():
tiles = [(n, PREVIEW_DIR / f"{n}.png") for n in names
if (PREVIEW_DIR / f"{n}.png").is_file()]
if not tiles:
continue
cols = 4 if len(tiles) > 4 else len(tiles)
rows = (len(tiles) + cols - 1) // cols
cell = 320
sheet = Image.new("RGB", (cols * cell, rows * (cell + 24)), (24, 26, 30))
draw = ImageDraw.Draw(sheet)
for i, (n, path) in enumerate(tiles):
with Image.open(path) as im:
im = im.convert("RGB").resize((cell, cell), Image.Resampling.LANCZOS)
x, y = (i % cols) * cell, (i // cols) * (cell + 24)
sheet.paste(im, (x, y))
draw.text((x + 8, y + cell + 5), n, fill=(230, 230, 230))
# JPEG keeps the committed review sheets small; per-asset PNG renders
# stay local (gitignored) and are rebuilt with `render`.
out = PREVIEW_DIR / f"sheet_{cat}.jpg"
sheet.save(out, quality=86, optimize=True)
written.append(out)
print(f"wrote {out.relative_to(ROOT)}")
return written
def main(argv=None):
raw = list(argv if argv is not None else sys.argv[1:])
if "--" in raw:
raw = raw[raw.index("--") + 1:]
ap = argparse.ArgumentParser(description="Blender asset builder")
sub = ap.add_subparsers(dest="cmd", required=True)
for cmd in ("build", "render"):
s = sub.add_parser(cmd)
s.add_argument("--only", default="")
a = sub.add_parser("audit")
a.add_argument("--json", action="store_true")
fit = sub.add_parser("fit")
fit.add_argument("--json", action="store_true")
insp = sub.add_parser("inspect", help="import a GLB and report its contract state")
insp.add_argument("--file", action="append", required=True,
help="GLB to inspect (repeatable; absolute path, it runs in Blender)")
insp.add_argument("--category", default="",
choices=["", "npc", "npc_gear", "props", "external"],
help="override the path-derived contract category")
insp.add_argument("--json", action="store_true")
insp.add_argument("--out", default="",
help="with --json, write to this file (Blender prints a startup banner)")
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
tex.add_argument("--only", default="")
tex.add_argument("--json", action="store_true")
sub.add_parser("sheet")
sub.add_parser("list")
args = ap.parse_args(raw)
if args.cmd == "texels":
names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS)
report = [row for row in (texel_density(n) for n in names) if row]
if not report:
print("no built character assets; run build first", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2))
else:
for entry in report:
print(f"{entry['name']:16s} {entry['resolution']}px maps "
f"area={entry['total_world_area_m2']:.2f} m2 "
f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m")
for slot, row in sorted(entry["slots"].items()):
note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \
if slot in TEXEL_RUNTIME_UV_SCALE else ""
print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 "
f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} "
f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}")
return 0
if args.cmd == "inspect":
if bpy is None:
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- inspect "
"--file <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 in ("build", "render"):
if bpy is None:
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- {args.cmd}",
file=sys.stderr)
return 2
for cat, name in _selected(args.only):
(_build_one if args.cmd == "build" else _render_one)(cat, name)
if args.cmd == "build":
results = [audit_asset(c, n) for c, n in catalogue()]
write_manifest([r for r in results if r["vertices"]])
bad = [r for r in results if not r["ok"] and r["name"] in
{n for _, n in _selected(args.only)}]
for r in bad:
print("BAD", r["name"], r["issues"])
return 1 if bad else 0
return 0
if args.cmd == "list":
for cat, name in catalogue():
extra = GEAR.get(name, "") if cat == "npc_gear" else \
CHARACTERS.get(name, {}).get("layer", "") if cat == "npc" else \
PROPS[name].get("replaces", "new")
print(f"{cat:9s} {name:20s} {extra}")
return 0
if args.cmd == "sheet":
contact_sheets()
return 0
if args.cmd == "fit":
report = fit_report()
if not report:
print("no built character assets; run build first", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2))
else:
for entry in report:
ref = entry["reference"]
print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m "
f"clothing_radius={ref['clothing_radius']:.3f} m "
f"({ref['clothing_source']})")
for row in entry["gear"]:
lo, hi = row["min"], row["max"]
box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] "
f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
if row["attach"] == "head":
flag = "inside" if row["inside_band"] else "OUTSIDE"
print(f" {row['name']:20s} head {box} "
f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) "
f"band={flag}")
else:
clip = "CLIP" if row["x_clearance"] < 0 else "clear"
print(f" {row['name']:20s} {row['attach']:5s} {box} "
f"inner_x={row['inner_x']:.3f} "
f"clearance x={row['x_clearance']:+.3f} "
f"r={row['radial_clearance']:+.3f} {clip}")
return 0
results = [audit_asset(c, n) for c, n in catalogue()]
if args.json:
print(json.dumps(results, indent=2))
else:
for r in results:
print(f"{'OK ' if r['ok'] else 'BAD'} {r['category']:9s} {r['name']:20s} "
f"tris={r['triangles']:5d} verts={r['vertices']:5d}")
for issue in r["issues"]:
print(" -", issue)
return 0 if all(r["ok"] for r in results) else 1
if __name__ == "__main__":
sys.exit(main())