Files
security-game/tools/blender_assets.py
T
zwitschi d513e0b82c Stage the Blender character/gear/prop library
Commit the finished generator output from the parallel Blender pass:
8 skinned NPC variants, 18 gear pieces, and 20 props under
assets/models/blender/, editable sources (gdignored), manifest, review sheets,
and the integration notes in docs/blender-assets.md.

- audit: all 46 assets clean (characters 3,788-4,640 tris, under 6000/4000)
- tests: Python manifest/audit contract and Godot import/bone/slot/ground checks
- docs: README library section, architecture/plan references, TODO Phase 3.5
  status, MEMORY integration rules
- .gitignore: per-asset preview PNGs, extracted GLB textures, blend1 backups

Nothing in the game loads the library yet; integration follows." && git log --oneline -1
2026-11-01 14:33:30 +01:00

1726 lines
80 KiB
Python

#!/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
# outside Blender (stdlib + Pillow)
python3 tools/blender_assets.py audit # GLB contract checks
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 json
import math
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "assets" / "models" / "blender"
SRC_DIR = ROOT / "assets" / "models" / "blender_src"
PREVIEW_DIR = ROOT / "docs" / "blender_previews"
NPC_TEX_DIR = ROOT / "assets" / "textures" / "npc"
MANIFEST = OUT_DIR / "manifest.json"
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
"ElbowL", "ElbowR", "HandL", "HandR"]
CHAR_MAX_TRIS = 6000
CHAR_MAX_VERTS = 4000
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)}
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),
}
_MATS = {}
def _solid_image(name, rgb):
"""4x4 sRGB texture so a runtime albedo_color=WHITE tint keeps the colour."""
img = bpy.data.images.new(f"{name}_albedo", 4, 4, alpha=False)
px = [rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0] * 16
img.pixels = px
img.pack()
return img
def material(name, textured_solid=False):
"""Principled material from PALETTE (or an npc slot texture set)."""
key = (name, textured_solid)
if key in _MATS:
return _MATS[key]
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
bsdf = nodes.get("Principled BSDF")
if name in SLOTS:
# Same neutral maps as npc_base, so Materials.npc_slot() tints alike.
albedo = nodes.new("ShaderNodeTexImage")
albedo.image = bpy.data.images.load(str(NPC_TEX_DIR / f"{name}_albedo.png"),
check_existing=True)
albedo.image.colorspace_settings.name = "sRGB"
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = bpy.data.images.load(
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
normal_tex.image.colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92,
"accessory": 0.5, "hair": 0.7}[name]
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = 0.0
else:
rgb, rough, metal, emit = PALETTE[name]
lin = tuple(_srgb_to_linear(c / 255.0) for c in rgb) + (1.0,)
bsdf.inputs["Base Color"].default_value = lin
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metal
if textured_solid:
tex = nodes.new("ShaderNodeTexImage")
tex.image = _solid_image(name, rgb)
tex.interpolation = "Closest"
links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if emit > 0.0:
bsdf.inputs["Emission Color"].default_value = lin
bsdf.inputs["Emission Strength"].default_value = emit
_MATS[key] = mat
return mat
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
_MATS.clear()
def _link(name, bm):
me = bpy.data.meshes.new(name)
bm.to_mesh(me)
bm.free()
obj = bpy.data.objects.new(name, me)
bpy.context.scene.collection.objects.link(obj)
return obj
def _xf(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)):
from mathutils import Euler
m = Matrix.Translation(Vector(loc)) @ Euler(rot, "XYZ").to_matrix().to_4x4()
s = Matrix.Diagonal(Vector((scale[0], scale[1], scale[2], 1.0)))
return m @ s
def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1):
obj.data.materials.append(mat)
if bevel > 0.0:
mod = obj.modifiers.new("Bevel", "BEVEL")
mod.width = bevel
mod.segments = bevel_segments
mod.limit_method = "ANGLE"
mod.angle_limit = math.radians(40)
_apply_all(obj)
_clean(obj)
if smooth:
obj.data.shade_smooth()
obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle))
else:
obj.data.shade_flat()
return obj
def _clean(obj):
"""Drop zero-area faces (thin bevels, lathe poles) before export."""
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
def _apply_all(obj):
bpy.context.view_layer.objects.active = obj
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
for mod in list(obj.modifiers):
bpy.ops.object.modifier_apply(modifier=mod.name)
def box(name, size, loc, mat, rot=(0, 0, 0), bevel=0.0, segs=1):
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0, matrix=_xf(loc, rot, size))
return _finish(_link(name, bm), mat, smooth=bevel > 0, bevel=bevel,
bevel_segments=segs)
def cyl(name, r1, r2, depth, loc, mat, seg=16, rot=(0, 0, 0), smooth=True,
scale=(1, 1, 1), caps=True, bevel=0.0):
bm = bmesh.new()
bmesh.ops.create_cone(bm, cap_ends=caps, cap_tris=False, segments=seg,
radius1=r1, radius2=r2, depth=depth,
matrix=_xf(loc, rot, scale))
return _finish(_link(name, bm), mat, smooth=smooth, bevel=bevel)
def sphere(name, r, loc, mat, seg=16, rings=10, scale=(1, 1, 1), rot=(0, 0, 0),
cut_below=None, smooth=True):
"""UV sphere; `cut_below` drops vertices under a local z (0..1 of radius)."""
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=r)
if cut_below is not None:
doomed = [v for v in bm.verts if v.co.z < cut_below * r - 1e-6]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
bmesh.ops.transform(bm, matrix=_xf(loc, rot, scale), verts=bm.verts)
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=60)
def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6,
scale=(1, 1, 1)):
bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor,
major_segments=seg, minor_segments=minor_seg)
obj = bpy.context.object
obj.name = name
obj.data.transform(_xf(loc, rot, scale))
return _finish(obj, mat, smooth=True, sharp_angle=70)
def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0,
face_front=False, caps=True):
"""Revolve [(radius, z), ...] around Z (bottom to top), capped.
`face_front` rotates the ring half a step so a flat face (not a corner)
points at the front (-Y); used for octagonal street furniture.
"""
phase = (math.pi / seg + math.pi / 2) if face_front else 0.0
bm = bmesh.new()
rings = []
for r, z in profile:
ring = []
for i in range(seg):
a = math.tau * i / seg + phase
ring.append(bm.verts.new((loc[0] + r * math.cos(a),
loc[1] + r * math.sin(a), loc[2] + z)))
rings.append(ring)
for a, b in zip(rings, rings[1:]):
for i in range(seg):
j = (i + 1) % seg
bm.faces.new((a[i], a[j], b[j], b[i]))
if caps and profile[0][0] > 1e-4:
bm.faces.new(list(reversed(rings[0])))
if caps and profile[-1][0] > 1e-4:
bm.faces.new(rings[-1])
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=sharp)
def tube(name, points, radius, mat, seg=8, caps=True):
"""Bent tube through 3D points (handrails, umbrella handles, frames)."""
bm = bmesh.new()
rings = []
pts = [Vector(p) for p in points]
for i, p in enumerate(pts):
if i == 0:
d = (pts[1] - p).normalized()
elif i == len(pts) - 1:
d = (p - pts[i - 1]).normalized()
else:
d = ((pts[i + 1] - p).normalized() + (p - pts[i - 1]).normalized()).normalized()
up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0))
side = d.cross(up).normalized()
up2 = side.cross(d).normalized()
ring = []
for k in range(seg):
a = math.tau * k / seg
ring.append(bm.verts.new(p + radius * (math.cos(a) * side + math.sin(a) * up2)))
rings.append(ring)
for a, b in zip(rings, rings[1:]):
for k in range(seg):
j = (k + 1) % seg
bm.faces.new((a[k], a[j], b[j], b[k]))
if caps:
bm.faces.new(list(reversed(rings[0])))
bm.faces.new(rings[-1])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def join(objs, name):
for o in bpy.context.selected_objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
if len(objs) > 1:
bpy.ops.object.join()
obj = bpy.context.object
obj.name = name
obj.data.name = name
return obj
def uv_unwrap(obj, angle=60.0):
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
def export(objs, category, name):
path = asset_path(category, name)
path.parent.mkdir(parents=True, exist_ok=True)
for o in bpy.context.selected_objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB", use_selection=True,
export_apply=True, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
SRC_DIR.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
compress=True)
return path
# ---------------------------------------------------------------------------
# Characters
# ---------------------------------------------------------------------------
# Bone layout copied from tools/npc_pipeline.py so NpcAvatar's proxies line up.
def _bone_layout():
b = {"Hips": ((0, 0, 0.62), (0, 0, 0.82), None),
"Spine": ((0, 0, 0.82), (0, 0, 1.40), "Hips"),
"Neck": ((0, 0, 1.40), (0, 0, 1.52), "Spine"),
"Head": ((0, 0, 1.52), (0, 0, 1.82), "Neck")}
for s, suf in ((-1.0, "L"), (1.0, "R")):
x = 0.27 * s
hx = 0.12 * s
b[f"Shoulder{suf}"] = ((x, 0, 1.38), (x, 0, 1.06), "Spine")
b[f"Elbow{suf}"] = ((x, 0, 1.06), (x, 0, 0.78), f"Shoulder{suf}")
b[f"Hand{suf}"] = ((x, 0, 0.78), (x, 0, 0.68), f"Elbow{suf}")
b[f"Hip{suf}"] = ((hx, 0, 0.62), (hx, 0, 0.30), "Hips")
b[f"Knee{suf}"] = ((hx, 0, 0.30), (hx, 0, 0.0), f"Hip{suf}")
return b
BUILDS = { # torso width / depth multipliers, limb thickness
"average": (1.0, 1.0, 1.0),
"slim": (0.9, 0.92, 0.88),
"broad": (1.12, 1.08, 1.1),
}
def _skin_body(spec):
"""Connected torso + limbs from a Skin modifier graph, subdivided once."""
w, d, t = BUILDS[spec.get("build", "average")]
layer = spec["layer"]
bulk = 1.0 + {"coat": 0.06, "jacket": 0.05, "hood": 0.06, "responder": 0.16,
"guard": 0.03, "vest": 0.02}.get(layer, 0.0)
sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22,
"guard": 0.05}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
nodes = [ # (pos, radius_x, radius_y)
((0, 0, 0.80), 0.165 * w, 0.115 * d), # 0 pelvis
((0, 0, 0.96), 0.150 * w * bulk, 0.105 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.170 * w * bulk, 0.115 * d * bulk), # 2 chest
((0, 0.0, 1.355), 0.172 * w * bulk, 0.108 * d * bulk), # 3 upper chest
((0, 0, 1.44), 0.058, 0.058), # 4 neck base
((0, -0.005, 1.53), 0.052, 0.052), # 5 neck top
]
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
for s in (-1.0, 1.0):
base = len(nodes)
nodes += [
((0.20 * s * w, 0, 1.39), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.24), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
((0.275 * s * w, -0.01, 0.80), 0.034 * t, 0.030 * t), # wrist
]
edges += [(3, base), (base, base + 1), (base + 1, base + 2), (base + 2, base + 3)]
hb = len(nodes)
nodes += [
((0.095 * s, 0, 0.72), 0.095 * t * leg, 0.10 * t * leg), # hip joint
((0.115 * s, 0, 0.52), 0.080 * t * leg, 0.085 * t * leg), # thigh
((0.12 * s, 0.0, 0.31), 0.058 * t * leg, 0.062 * t * leg), # knee
((0.12 * s, 0.01, 0.18), 0.055 * t * leg, 0.058 * t * leg), # calf
((0.12 * s, 0, 0.08), 0.040 * leg, 0.042 * leg), # ankle
]
edges += [(0, hb), (hb, hb + 1), (hb + 1, hb + 2), (hb + 2, hb + 3), (hb + 3, hb + 4)]
me = bpy.data.meshes.new("body")
me.from_pydata([n[0] for n in nodes], edges, [])
obj = bpy.data.objects.new("body", me)
bpy.context.scene.collection.objects.link(obj)
skin = obj.modifiers.new("Skin", "SKIN")
skin.branch_smoothing = 0.6
for i, (_, rx, ry) in enumerate(nodes):
me.skin_vertices[0].data[i].radius = (rx, ry)
me.skin_vertices[0].data[0].use_root = True
sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.36
dec.use_symmetry = True
dec.symmetry_axis = "X"
_apply_all(obj)
# Clean edge loops where materials change (waist, cuffs, short sleeves, neck).
bm = bmesh.new()
bm.from_mesh(obj.data)
for z in (0.86, 1.19, 1.43):
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1))
# Cuts next to existing vertices leave slivers; weld them.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4)
bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
# Front of the chest/backside slightly shaped: push chest forward a touch.
for v in obj.data.vertices:
if 1.05 < v.co.z < 1.38 and abs(v.co.x) < 0.17 and v.co.y < 0:
v.co.y -= 0.012 * (1.0 - abs(v.co.x) / 0.17)
if 0.72 < v.co.z < 0.9 and abs(v.co.x) < 0.17 and v.co.y > 0:
v.co.y += 0.015
obj.data.shade_smooth()
return obj
def _assign_body_slots(obj, spec, mats):
"""Pick a slot per face from its centroid: skin / cloth / pants."""
layer = spec["layer"]
names = [m.name for m in mats]
obj.data.materials.clear()
for m in mats:
obj.data.materials.append(m)
idx = {n: i for i, n in enumerate(names)}
short_sleeve = layer in ("none", "vest", "dress")
# Main surfaces always use the tintable contract slots (dress legs = tights).
top_slot = "cloth"
leg_slot = "pants"
waist = 0.86
for poly in obj.data.polygons:
c = poly.center
arm = abs(c.x) > 0.205 and c.z > 0.78
if c.z > 1.43 and not arm:
slot = "skin"
elif arm:
if c.z < 0.86 or (short_sleeve and c.z < 1.19):
slot = "skin"
else:
slot = top_slot
elif c.z < waist:
slot = leg_slot
if layer == "dress" and c.z > 0.40:
slot = "cloth"
else:
slot = top_slot
poly.material_index = idx[slot]
def _seg_dist(p, a, b):
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-9)))
return (p - (a + ab * t)).length
def _auto_weights(obj, layout, rigid=None):
"""Distance-to-bone weights limited by body region (no heat-solve flakes)."""
segs = {n: (Vector(h), Vector(t)) for n, (h, t, _) in layout.items()}
groups = {n: obj.vertex_groups.new(name=n) for n in layout}
for v in obj.data.vertices:
p = v.co
side = "L" if p.x < 0 else "R"
if rigid:
bone = rigid(p)
if bone:
groups[bone].add([v.index], 1.0, "REPLACE")
continue
if abs(p.x) > 0.19 and p.z > 0.74 and p.z < 1.42:
allowed = ["Spine", f"Shoulder{side}", f"Elbow{side}", f"Hand{side}"]
if abs(p.x) > 0.23:
allowed.remove("Spine")
elif p.z < 0.80:
allowed = ["Hips", f"Hip{side}", f"Knee{side}"]
if p.z < 0.66:
allowed.remove("Hips")
elif p.z > 1.40:
allowed = ["Spine", "Neck", "Head"]
else:
allowed = ["Hips", "Spine", "Neck"]
scored = sorted((_seg_dist(p, *segs[b]), b) for b in allowed)[:2]
ws = [(1.0 / (dd + 0.02) ** 4, b) for dd, b in scored]
total = sum(w for w, _ in ws)
for w, b in ws:
if w / total > 0.02:
groups[b].add([v.index], w / total, "REPLACE")
def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645),
scale=(0.95, 1.04, 1.1)):
"""Head-hugging cap: cut at the brow in front and lower at the back."""
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=radius)
bmesh.ops.transform(bm, matrix=_xf(center, (0, 0, 0), scale), verts=bm.verts)
doomed = []
for v in bm.verts:
t = max(0.0, min(1.0, 0.5 - (v.co.y - center[1]) / (radius * 1.2)))
cut = back_z + (front_z - back_z) * t
if v.co.z < cut:
doomed.append(v)
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70)
def _hair(style, mat_hair, broad=False):
parts = []
if style == "buzz":
parts.append(_hair_cap("Hair", mat_hair, 1.705, 1.58, radius=0.158))
elif style in ("short", "long", "bun"):
parts.append(_hair_cap("Hair", mat_hair, 1.715, 1.56))
parts.append(sphere("Fringe", 0.08, (0.025, -0.115, 1.755), mat_hair, seg=10,
rings=6, scale=(1.35, 0.5, 0.42), rot=(0.35, 0, -0.18)))
if style == "long":
parts.append(sphere("HairBack", 0.15, (0, 0.06, 1.52), mat_hair, seg=14, rings=8,
scale=(1.0, 0.5, 1.35)))
for s in (-1.0, 1.0):
parts.append(sphere("Lock", 0.06, (0.125 * s, -0.01, 1.57), mat_hair, seg=8,
rings=6, scale=(0.6, 0.9, 1.9)))
if style == "bun":
parts.append(sphere("Bun", 0.065, (0, 0.13, 1.76), mat_hair, seg=12, rings=8))
return parts
def _hood(name, mat, head_center):
"""Hood shell around the head with an oval face opening and a solid rim."""
cx, cy, cz = head_center
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19)
bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0),
(0.97, 1.05, 1.06)), verts=bm.verts)
doomed = [v for v in bm.verts
if v.co.z < cz - 0.17
or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2
+ ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
# Snap the stair-stepped opening onto the ellipse for a clean edge.
for v in bm.verts:
if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16:
dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165
k = math.hypot(dx, dz)
if k > 1e-6:
v.co.x = cx + dx / k * 0.125
v.co.z = cz - 0.01 + dz / k * 0.165
# Thicken into a shell so the open edge reads as fabric, not a paper cut.
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def _face(mats, spec):
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
scale=(0.92, 1.0, 1.12))]
# jaw / chin softens the egg into a face
parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.56), skin, seg=12, rings=8,
scale=(1.0, 1.0, 0.8)))
parts.append(cyl("Nose", 0.022, 0.006, 0.05, (0, -0.158, 1.632), skin, seg=6,
rot=(math.radians(-80), 0, 0), smooth=True))
for s in (-1.0, 1.0):
parts.append(sphere("Ear", 0.035, (0.142 * s, 0.01, 1.63), skin, seg=8, rings=5,
scale=(0.45, 1.0, 1.35)))
parts.append(sphere("Eye", 0.017, (0.05 * s, -0.142, 1.665), acc, seg=8, rings=5,
scale=(1.2, 0.6, 0.9)))
parts.append(box("Brow", (0.05, 0.012, 0.011), (0.05 * s, -0.146, 1.70), hair,
rot=(0, 0.12 * s, 0.0)))
parts.append(box("Mouth", (0.06, 0.012, 0.01), (0, -0.142, 1.585), acc))
if spec.get("beard"):
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
scale=(1.0, 0.9, 0.75), cut_below=-0.2))
return parts
def _hands_and_shoes(mats, spec):
skin = mats["skin"]
shoe = mats["accessory"]
parts = []
w = BUILDS[spec.get("build", "average")][0]
for s in (-1.0, 1.0):
x = 0.275 * s * w
parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6,
scale=(0.55, 0.95, 1.25)))
parts.append(sphere("Thumb", 0.018, (x - 0.01 * s, -0.045, 0.75), skin, seg=8,
rings=6, scale=(1.0, 1.0, 1.8), rot=(0.4, 0, 0)))
parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe,
bevel=0.028, segs=1))
parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01),
material("rubber", textured_solid=True)))
return parts
def _clothing(spec, mats):
layer = spec["layer"]
cloth = mats["cloth"]
acc = mats["accessory"]
parts = []
if layer in ("coat", "dress", "responder"):
bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer]
flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer]
mat = cloth
top = 0.17 if layer != "responder" else 0.20
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1),
(top + 0.03, 0.80), (top, 0.93)], mat, seg=16,
caps=False))
# Double-sided shell: an inner copy with flipped normals, so the open
# hem does not vanish under Godot's back-face culling.
bm = bmesh.new()
bm.from_mesh(parts[-1].data)
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012)
bm.to_mesh(parts[-1].data)
bm.free()
for o in parts[-1:]:
for v in o.data.vertices: # flatten front/back
v.co.y *= 0.8
if layer in ("coat", "jacket", "guard", "responder"):
mat = cloth
parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat,
rot=(math.radians(8), 0, 0), seg=16))
parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc))
if layer == "vest":
hv = mats["hivis"]
refl = mats["reflective"]
parts.append(lathe("Vest", [(0.162, 0.86), (0.18, 0.98), (0.19, 1.18),
(0.19, 1.30), (0.15, 1.40)], hv, seg=20, smooth=True))
for v in parts[-1].data.vertices:
v.co.y *= 0.70
for z in (0.98, 1.08):
band = torus("Band", 0.188, 0.012, (0, 0, z), refl, seg=24, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.70
parts.append(band)
for s in (-1.0, 1.0):
parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl,
rot=(0, 0.12 * s, 0)))
if layer == "hood":
parts.append(_hood("Hood", cloth, (0, 0, 1.635)))
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth))
for s in (-1.0, 1.0):
parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38),
acc, seg=5))
if layer == "responder":
refl = mats["reflective"]
hv = mats["hivis"]
for z, r in ((0.60, 0.252), (1.16, 0.205)):
band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.8 if z < 1.0 else 0.66
parts.append(band)
for s in (-1.0, 1.0):
parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl,
rot=(0, 0, 0), seg=14, minor_seg=4))
parts.append(box("Boot", (0.12, 0.27, 0.22), (0.12 * s, -0.04, 0.11),
mats["rubber"], bevel=0.03, segs=2))
if layer == "guard":
belt = torus("Belt", 0.168, 0.022, (0, 0, 0.86), mats["leather"], seg=24,
minor_seg=4, scale=(1.0, 0.72, 1.4))
parts.append(belt)
parts.append(box("Buckle", (0.05, 0.012, 0.035), (0, -0.125, 0.86), mats["steel"]))
parts.append(box("Badge", (0.045, 0.01, 0.055), (0.085, -0.128, 1.27), mats["patch"],
bevel=0.006))
for s in (-1.0, 1.0):
parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28),
mats["patch"], bevel=0.005))
parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84),
mats["leather"]))
parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30),
mats["plastic_black"], bevel=0.008))
return parts
def build_character(name):
spec = dict(CHARACTERS[name])
if name == "npc_hoodie":
spec["beard"] = True
mats = {slot: material(slot) for slot in SLOTS}
for extra in ("hivis", "reflective", "leather",
"steel", "patch", "plastic_black", "rubber", "helmet"):
mats[extra] = material(extra, textured_solid=True)
layout = _bone_layout()
body = _skin_body(spec)
body_mats = [mats["skin"], mats["cloth"], mats["pants"]]
_assign_body_slots(body, spec, body_mats)
_auto_weights(body, layout)
head_parts = _face(mats, spec)
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
head_parts += _hair(hair_style, mats["hair"])
head = join(head_parts, "head")
_auto_weights(head, layout, rigid=lambda p: "Head")
hs = _hands_and_shoes(mats, spec)
if spec["layer"] == "responder": # boots replace the shoes
hs = [o for o in hs if not o.name.startswith(("Shoe", "Sole"))] or hs
limbs = join(hs, "limbs")
def limb_bone(p):
side = "L" if p.x < 0 else "R"
return f"Hand{side}" if p.z > 0.5 else f"Knee{side}"
_auto_weights(limbs, layout, rigid=limb_bone)
rest = [body, head, limbs]
clothes = _clothing(spec, mats)
if clothes:
cl = join(clothes, "clothing")
def cloth_bone(p):
if p.z > 1.42:
return "Head" if p.z > 1.55 else "Neck"
side = "L" if p.x < 0 else "R"
if abs(p.x) > 0.26 and p.z > 0.8:
return f"Elbow{side}"
if p.z < 0.25:
return f"Knee{side}"
return None
_auto_weights(cl, layout, rigid=cloth_bone)
rest.append(cl)
mesh = join(rest, "npc_base")
# Every contract slot must exist even if a variant does not paint with it.
present = {m.name for m in mesh.data.materials if m}
for slot in SLOTS:
if slot not in present:
mesh.data.materials.append(mats[slot])
# Fewer, larger islands keep the seam-split vertex count inside the budget.
uv_unwrap(mesh, angle=80.0)
arm_data = bpy.data.armatures.new("Armature")
arm = bpy.data.objects.new("Armature", arm_data)
bpy.context.scene.collection.objects.link(arm)
bpy.context.view_layer.objects.active = arm
for o in bpy.context.selected_objects:
o.select_set(False)
arm.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
eb = arm_data.edit_bones
for bone_name, (h, t, parent) in layout.items():
bone = eb.new(bone_name)
bone.head, bone.tail = h, t
if parent:
bone.parent = eb[parent]
bpy.ops.object.mode_set(mode="OBJECT")
mesh.parent = arm
mod = mesh.modifiers.new("Armature", "ARMATURE")
mod.object = arm
return [arm, mesh]
# ---------------------------------------------------------------------------
# Gear (static, attach-point space; see GEAR_ATTACH)
# ---------------------------------------------------------------------------
def build_gear(name):
M = lambda n: material(n) # noqa: E731
p = []
# Head gear: origin = head centre (Godot Rig/Head marker at y=1.62); the
# Blender model's head centre sits at z=1.635, so offsets are ~ +0.015.
if name == "cap":
p.append(sphere("Crown", 0.168, (0, 0.01, 0.05), M("plastic_black"), seg=20,
rings=12, scale=(1.0, 1.05, 0.85), cut_below=0.0))
p.append(cyl("Brim", 0.11, 0.11, 0.012, (0, -0.17, 0.055), M("plastic_black"),
seg=16, scale=(1.0, 0.8, 1.0), rot=(math.radians(-8), 0, 0)))
p.append(sphere("Button", 0.014, (0, 0.01, 0.195), M("plastic_black"), seg=8, rings=4))
elif name == "beanie":
p.append(sphere("Knit", 0.172, (0, 0.01, 0.04), M("plastic_grey"), seg=20, rings=12,
scale=(1.0, 1.04, 1.02), cut_below=-0.05))
p.append(torus("Cuff", 0.163, 0.03, (0, 0.01, 0.04), M("plastic_grey"), seg=20))
elif name == "hood":
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
elif name == "sunglasses":
for s in (-1.0, 1.0):
p.append(cyl("Lens", 0.03, 0.03, 0.008, (0.05 * 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.06, 0.008, 0.01), (0.11 * s, -0.150, 0.045),
M("plastic_black")))
p.append(box("Arm", (0.006, 0.15, 0.008), (0.138 * s, -0.078, 0.042),
M("plastic_black")))
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
elif name == "firefighter_helmet":
p.append(sphere("Shell", 0.19, (0, 0.0, 0.03), M("helmet"), seg=24, rings=12,
scale=(1.0, 1.08, 0.95), cut_below=0.0))
p.append(lathe("Brim", [(0.0, 0.025), (0.20, 0.025), (0.26, 0.015),
(0.255, 0.035), (0.0, 0.04)], M("helmet"), seg=24))
for v in p[-1].data.vertices:
if v.co.y > 0:
v.co.y *= 1.35 # long rear brim
p.append(torus("Band", 0.188, 0.014, (0, 0, 0.09), M("hivis"), seg=24, minor_seg=4,
scale=(1.0, 1.08, 1.0)))
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
elif name == "backpack":
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
bevel=0.04, segs=2))
p.append(box("Pocket", (0.22, 0.05, 0.16), (0, 0.29, -0.02), M("pack_nylon"),
bevel=0.02, segs=2))
p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel")))
for s in (-1.0, 1.0):
p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.10 * s, -0.02, 0.29),
(0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)],
0.012, M("plastic_black"), seg=6))
elif name == "shoulder_bag":
p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"),
bevel=0.025, segs=2))
p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
(0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6))
elif name == "scba_tank":
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
cut_below=0.0))
p.append(cyl("Valve", 0.03, 0.03, 0.06, (0, 0.21, -0.29), M("brass"), seg=10))
p.append(box("Frame", (0.30, 0.04, 0.60), (0, 0.12, 0.0), M("plastic_black"),
bevel=0.015))
for s in (-1.0, 1.0):
p.append(tube("Strap", [(0.11 * s, 0.10, 0.28), (0.11 * s, -0.03, 0.31),
(0.12 * s, -0.14, 0.18), (0.13 * s, -0.13, -0.15)],
0.016, M("plastic_black"), seg=6))
# Chest: origin = sternum (Godot 0, 1.30, 0).
elif name == "lanyard":
p.append(tube("Cord", [(-0.06, 0.0, 0.16), (-0.07, -0.10, 0.10), (-0.04, -0.135, -0.05),
(0.0, -0.14, -0.14), (0.04, -0.135, -0.05), (0.07, -0.10, 0.10),
(0.06, 0.0, 0.16)], 0.005, M("ribbon_blue"), seg=5, caps=False))
p.append(box("Badge", (0.06, 0.006, 0.085), (0, -0.145, -0.19), M("plastic_white"),
bevel=0.004))
p.append(box("Photo", (0.024, 0.002, 0.03), (-0.012, -0.149, -0.175), M("ribbon_blue")))
# Hand items: origin = grip centre; items hang along -Z (Godot -Y).
elif name == "phone":
p.append(box("Body", (0.075, 0.012, 0.15), (0, -0.02, 0.02), M("plastic_black"),
bevel=0.008, segs=2))
p.append(box("Screen", (0.066, 0.002, 0.135), (0, -0.0265, 0.02), M("screen")))
elif name == "cup":
p.append(lathe("Cup", [(0.0, -0.06), (0.032, -0.06), (0.042, 0.06), (0.0, 0.06)],
M("paper"), seg=16))
p.append(lathe("Lid", [(0.0, 0.06), (0.046, 0.06), (0.044, 0.078), (0.0, 0.082)],
M("plastic_white"), seg=16))
p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16,
smooth=True))
elif name == "clipboard":
p.append(box("Board", (0.23, 0.008, 0.31), (0, -0.04, -0.06), M("dark_wood"),
bevel=0.004))
p.append(box("Paper", (0.20, 0.002, 0.26), (0, -0.045, -0.08), M("paper")))
p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, 0.085), M("steel"), bevel=0.004))
elif name == "umbrella":
p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14),
(0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8))
p.append(cyl("Shaft", 0.009, 0.009, 0.70, (0, 0, 0.29), M("black_metal"), seg=8))
canopy = lathe("Canopy", [(0.0, 0.66), (0.10, 0.65), (0.30, 0.58), (0.45, 0.48)],
M("plastic_black"), seg=8, sharp=20)
p.append(canopy)
elif name == "camera":
p.append(box("Body", (0.13, 0.07, 0.085), (0, -0.04, 0.04), M("plastic_black"),
bevel=0.01, segs=2))
p.append(cyl("Lens", 0.032, 0.03, 0.07, (0, -0.105, 0.035), M("plastic_black"),
seg=16, rot=(math.radians(90), 0, 0)))
p.append(cyl("Glass", 0.024, 0.024, 0.004, (0, -0.141, 0.035), M("lens"), seg=16,
rot=(math.radians(90), 0, 0)))
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
bevel=0.006))
p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42),
(0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006,
M("plastic_black"), seg=5))
elif name == "protest_sign":
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"),
bevel=0.003))
for i, z in enumerate((0.90, 0.80, 0.72)):
p.append(box("Letters", (0.46 - i * 0.08, 0.002, 0.04), (0, -0.032, z),
M("plastic_black")))
elif name == "flashlight":
p.append(cyl("Barrel", 0.017, 0.017, 0.20, (0, -0.02, 0.0), M("black_metal"), seg=14,
rot=(math.radians(90), 0, 0)))
p.append(cyl("Head", 0.027, 0.019, 0.05, (0, -0.135, 0.0), M("black_metal"), seg=14,
rot=(math.radians(-90), 0, 0)))
p.append(cyl("LensF", 0.023, 0.023, 0.004, (0, -0.161, 0.0), M("bulb"), seg=14,
rot=(math.radians(90), 0, 0)))
p.append(box("Switch", (0.01, 0.02, 0.008), (0, -0.06, 0.019), M("rubber")))
elif name == "wand":
p.append(cyl("Grip", 0.019, 0.017, 0.15, (0, 0, 0.0), M("plastic_black"), seg=12))
p.append(box("Paddle", (0.075, 0.025, 0.25), (0, 0, 0.20), M("plastic_grey"),
bevel=0.012, segs=2))
p.append(box("Coil", (0.055, 0.027, 0.18), (0, 0, 0.21), M("plastic_black"),
bevel=0.008))
p.append(sphere("Led", 0.007, (0, -0.016, 0.06), M("lock_led"), seg=8, rings=4))
elif name == "radio":
p.append(box("Body", (0.058, 0.032, 0.115), (0, 0, 0.0), M("plastic_black"),
bevel=0.007, segs=2))
p.append(cyl("Antenna", 0.007, 0.005, 0.09, (0.018, 0.0, 0.10), M("rubber"), seg=8))
p.append(cyl("Knob", 0.008, 0.008, 0.015, (-0.015, 0.0, 0.065), M("plastic_grey"),
seg=10))
p.append(box("Grille", (0.044, 0.002, 0.05), (0, -0.017, -0.02), M("dark_metal")))
p.append(sphere("Led", 0.004, (0.0, -0.016, 0.045), M("lock_led"), seg=6, rings=4))
else:
raise KeyError(name)
obj = join(p, name)
if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
obj.data.transform(Matrix.Translation((0, 0, -0.05)))
uv_unwrap(obj)
return [obj]
# ---------------------------------------------------------------------------
# Props (grounded, front = Blender -Y)
# ---------------------------------------------------------------------------
def _shrub(name, center, radii, mat, lumps=7, seed=1):
"""Lumpy foliage cluster from overlapping low-poly spheres."""
parts = []
import random
rng = random.Random(seed)
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii))
for i in range(lumps):
a = math.tau * i / lumps + rng.uniform(-0.3, 0.3)
r = rng.uniform(0.45, 0.6)
off = (center[0] + math.cos(a) * radii[0] * 0.6,
center[1] + math.sin(a) * radii[1] * 0.6,
center[2] + rng.uniform(-0.1, 0.35) * 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)))
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=8, seed=3)
elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92
p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01))
p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02))
p.append(box("Top", (0.44, 0.38, 0.08), (0, 0.0, 0.84), M("news_blue"),
rot=(math.radians(-12), 0, 0), bevel=0.015))
p.append(box("Window", (0.32, 0.01, 0.22), (0, -0.172, 0.62), M("glass")))
p.append(box("Paper", (0.26, 0.004, 0.14), (0, -0.168, 0.60), M("paper")))
p.append(box("Handle", (0.14, 0.03, 0.02), (0, -0.18, 0.47), M("steel"), bevel=0.006))
p.append(box("Coin", (0.06, 0.02, 0.08), (0.14, -0.175, 0.76), M("steel"), bevel=0.005))
elif name == "fire_hydrant": # r 0.15-0.19, h ~0.74
p.append(lathe("Hydrant", [(0.19, 0.0), (0.19, 0.06), (0.15, 0.09), (0.14, 0.52),
(0.17, 0.55), (0.17, 0.60), (0.12, 0.66), (0.05, 0.72),
(0.05, 0.75), (0.0, 0.76)], M("hydrant_red"), seg=12,
sharp=30))
for s in (-1.0, 1.0):
p.append(cyl("Nozzle", 0.05, 0.05, 0.10, (0.17 * s, 0, 0.40), M("hydrant_red"),
seg=10, rot=(0, math.radians(90), 0)))
p.append(cyl("Cap", 0.055, 0.055, 0.03, (0.225 * s, 0, 0.40), M("chrome"), seg=6,
rot=(0, math.radians(90), 0), smooth=False))
p.append(cyl("FrontCap", 0.065, 0.065, 0.05, (0, -0.16, 0.40), M("chrome"), seg=6,
rot=(math.radians(90), 0, 0), smooth=False))
for a in range(6):
ang = math.tau * a / 6
p.append(cyl("Bolt", 0.012, 0.012, 0.02, (math.cos(ang) * 0.16,
math.sin(ang) * 0.16, 0.07),
M("chrome"), seg=6, smooth=False))
elif name == "street_lamp": # 5 m, arm toward -X, bulb ~4.85 m
p.append(lathe("Base", [(0.20, 0.0), (0.20, 0.08), (0.15, 0.14), (0.13, 0.60),
(0.10, 0.66), (0.08, 0.70)], M("black_metal"), seg=8, sharp=25))
p.append(lathe("Pole", [(0.08, 0.70), (0.055, 4.70), (0.07, 4.76), (0.07, 4.82),
(0.0, 4.84)], M("black_metal"), seg=8, sharp=25))
p.append(tube("Arm", [(0.0, 0, 4.55), (-0.3, 0, 4.85), (-0.7, 0, 4.95), (-0.85, 0, 4.92)],
0.035, M("black_metal"), seg=8))
p.append(lathe("Hood", [(0.0, 4.98), (0.06, 4.97), (0.24, 4.85), (0.25, 4.82)],
M("black_metal"), seg=12, loc=(-0.85, 0, 0)))
p.append(lathe("BulbGlass", [(0.0, 4.82), (0.17, 4.82), (0.10, 4.74), (0.0, 4.72)],
M("bulb"), seg=12, loc=(-0.85, 0, 0)))
p.append(torus("Ring", 0.07, 0.015, (0, 0, 3.0), M("black_metal"), seg=12, minor_seg=4))
elif name == "street_tree": # trunk + layered crown, ~3.6 m
p.append(lathe("Trunk", [(0.15, 0.0), (0.11, 0.25), (0.09, 1.4), (0.07, 2.0)],
M("bark"), seg=8))
p.append(tube("Branch", [(0, 0, 1.6), (0.35, 0.1, 2.2)], 0.04, M("bark"), seg=6))
p.append(tube("Branch", [(0, 0, 1.7), (-0.3, -0.2, 2.3)], 0.04, M("bark"), seg=6))
p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=9, seed=7)
p += _shrub("CrownTop", (0.05, 0.05, 3.15), (0.6, 0.6, 0.5), M("foliage_dark"),
lumps=5, 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=7, 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
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")
sub.add_parser("sheet")
sub.add_parser("list")
args = ap.parse_args(raw)
if args.cmd in ("build", "render"):
if bpy is None:
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- {args.cmd}",
file=sys.stderr)
return 2
for cat, name in _selected(args.only):
(_build_one if args.cmd == "build" else _render_one)(cat, name)
if args.cmd == "build":
results = [audit_asset(c, n) for c, n in catalogue()]
write_manifest([r for r in results if r["vertices"]])
bad = [r for r in results if not r["ok"] and r["name"] in
{n for _, n in _selected(args.only)}]
for r in bad:
print("BAD", r["name"], r["issues"])
return 1 if bad else 0
return 0
if args.cmd == "list":
for cat, name in catalogue():
extra = GEAR.get(name, "") if cat == "npc_gear" else \
CHARACTERS.get(name, {}).get("layer", "") if cat == "npc" else \
PROPS[name].get("replaces", "new")
print(f"{cat:9s} {name:20s} {extra}")
return 0
if args.cmd == "sheet":
contact_sheets()
return 0
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())