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