Files
security-game/tools/blender_assets.py
T
zwitschi 7efb5024ec Extend the v0.6.0 plan with the Blender asset library
Document the larger asset set produced by the running Blender generator
(tools/blender_assets.py): 8 NPC character variants, 18 gear pieces, and 22
street/venue props with a manifest, editable sources, and preview sheets.

- TODO gains Phase 3.5 with the audit/budget reconciliation (npc_casual is
  currently 7,756 tris / 5,616 verts, above the 6,000/4,000 contract), character
  selection by role, gear attach points, prop replacement/placement, emissive
  wiring, budgets, and screenshot gates
- PLAN's Visual Quality Direction describes the library, runtime selection, and
  offline fallbacks
- docs/plan-graphics-v0.6.md adds the library contract/output table and
  integration steps, and points Phase 4 at the Blender props
- MEMORY records the manifest-as-source-of-truth rule and the generator caveat

Generated library files stay untracked until the running build finishes and
passes the audit; the generator itself is committed so the plan is reproducible.
2026-11-01 13:39:03 +01:00

1601 lines
75 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
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 # contact sheets from previews
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",
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
"npc_jacket": {"layer": "jacket", "hair": "short",
"roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long",
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz",
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim",
"roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short",
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"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 (grip centre, item extends along -Y = down the forearm)",
"back": "Rig root, origin at the spine centre (0, 1.10, 0)",
"chest": "Rig root, origin at the sternum (0, 1.30, 0)",
}
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)
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 _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):
"""Revolve [(radius, z), ...] around Z (bottom to top), capped."""
bm = bmesh.new()
rings = []
for r, z in profile:
ring = []
for i in range(seg):
a = math.tau * i / seg
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 profile[0][0] > 1e-4:
bm.faces.new(list(reversed(rings[0])))
if 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):
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(60.0), 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.2, "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.33), 0.175 * w * bulk, 0.110 * 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.37), 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.5
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))
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")
top_slot = {"vest": "undershirt", "responder": "turnout",
"guard": "cloth"}.get(layer, "cloth")
leg_slot = {"responder": "turnout", "dress": "skin"}.get(layer, "pants")
waist = 0.86 if layer != "dress" else 0.0
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 _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=16, rings=10,
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=12, rings=8,
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=2))
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.215, "dress": 0.26, "responder": 0.21}[layer]
mat = cloth if layer != "responder" else mats["turnout"]
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.92, bottom + 0.1),
(0.185, 0.80), (0.16, 0.93)], mat, seg=20))
for o in parts[-1:]:
for v in o.data.vertices: # flatten front/back
v.co.y *= 0.78
if layer in ("coat", "jacket", "guard", "responder"):
mat = cloth if layer != "responder" else mats["turnout"]
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(sphere("Hood", 0.20, (0, 0.03, 1.66), cloth, seg=20, rings=12,
scale=(0.95, 1.05, 1.05), cut_below=-0.55))
# open the face: drop front-facing verts of the hood
hood = parts[-1]
bm = bmesh.new()
bm.from_mesh(hood.data)
front = [v for v in bm.verts if v.co.y < -0.08 and v.co.z < 1.79 and v.co.z > 1.50]
bmesh.ops.delete(bm, geom=front, context="VERTS")
bm.to_mesh(hood.data)
bm.free()
parts.append(torus("HoodRim", 0.13, 0.022, (0, -0.075, 1.645), cloth,
rot=(math.radians(78), 0, 0), scale=(0.95, 1.0, 1.2)))
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth, bevel=0.01))
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 in (0.62, 1.00):
band = torus("Band", 0.20, 0.016, (0, 0, z), hv, seg=24, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.78
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"], bevel=0.01))
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 ("undershirt", "turnout", "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"], mats["undershirt"],
mats["turnout"]]
_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)
for o in rest:
print(f" part {o.name}: {len(o.data.polygons)} faces {len(o.data.vertices)} verts")
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])
uv_unwrap(mesh)
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(sphere("Hood", 0.20, (0, 0.03, 0.03), M("plastic_grey"), seg=20,
rings=12, cut_below=-0.55))
hood = p[-1]
bm = bmesh.new()
bm.from_mesh(hood.data)
bmesh.ops.delete(bm, geom=[v for v in bm.verts if v.co.y < -0.08 and -0.12 < v.co.z < 0.16],
context="VERTS")
bm.to_mesh(hood.data)
bm.free()
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("Arm", (0.006, 0.15, 0.008), (0.135 * s, -0.08, 0.04),
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)
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))
p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8,
smooth=False))
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))
p.append(box("Slot", (0.30, 0.03, 0.10), (0, -0.255, 0.70), M("plastic_black")))
for a in range(8): # recessed panels
ang = math.tau * (a + 0.5) / 8
p.append(box("Panel", (0.17, 0.012, 0.42),
(math.sin(ang) * 0.248, -math.cos(ang) * 0.248, 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(11):
a = math.tau * i / 11
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.05, 0.38, 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.12)))
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)
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_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
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 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 = 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
res["triangles"], res["vertices"] = tris, verts
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))
out = PREVIEW_DIR / f"sheet_{cat}.png"
sheet.save(out)
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())