3993 lines
186 KiB
Python
3993 lines
186 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
|
||
# inspect an exported or external GLB exactly as Blender imports it
|
||
flatpak run org.blender.Blender --background \
|
||
--python "$PWD/tools/blender_assets.py" -- inspect --file some.glb
|
||
# outside Blender (stdlib + Pillow)
|
||
python3 tools/blender_assets.py audit # GLB contract checks
|
||
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
|
||
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
|
||
python3 tools/blender_assets.py list
|
||
|
||
The flatpak sandbox cwd is /app/blender and it cannot read /tmp, so every path
|
||
is absolute and derived from this file's location.
|
||
|
||
Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up):
|
||
* the front of every asset faces Blender -Y, which becomes Godot +Z (the
|
||
NPC face direction used by `NpcAvatar.yaw_towards`);
|
||
* grounded assets have their origin on the floor (min y ~= 0);
|
||
* characters reuse the npc_base contract: mesh node "npc_base", bones
|
||
Head/HipL/R/KneeL/R/ShoulderL/R/ElbowL/R/HandL/R (+ Hips/Spine/Neck),
|
||
material slots skin/cloth/pants/accessory/hair, same joint positions;
|
||
* gear is authored in attach-point space (see GEAR_ATTACH).
|
||
"""
|
||
|
||
import argparse
|
||
import contextlib
|
||
import io
|
||
import json
|
||
import math
|
||
import struct
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
OUT_DIR = ROOT / "assets" / "models" / "blender"
|
||
SRC_DIR = ROOT / "assets" / "models" / "blender_src"
|
||
PREVIEW_DIR = ROOT / "docs" / "blender_previews"
|
||
NPC_TEX_DIR = ROOT / "assets" / "textures" / "npc"
|
||
MANIFEST = OUT_DIR / "manifest.json"
|
||
|
||
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
|
||
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
|
||
"ElbowL", "ElbowR", "HandL", "HandR"]
|
||
# ADR-0018: a Quaternius-derived character keeps the upstream 62-joint armature
|
||
# instead of the 13-core skeleton, so these are the joints `NpcAvatar` maps onto
|
||
# the animation proxies and the ones the audit requires.
|
||
QUATERNIUS_BONES = ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", "LowerLeg.R",
|
||
"UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
|
||
"Wrist.L", "Wrist.R"]
|
||
# Character cap raised to 24000/24000 on 2026-11-05 for the MPFB body, the
|
||
# accepted MakeHuman head and a seamless neck/shoulder junction. The required
|
||
# 13 core bones stay, but optional extra bones (fingers, face, toes) are
|
||
# allowed; they must not move the core rest positions. The glTF vertex count is
|
||
# after UV-seam splits; each avatar is still one skinned mesh, and the 24-NPC
|
||
# smoke test must confirm frame time before shipping.
|
||
CHAR_MAX_TRIS = 24000
|
||
CHAR_MAX_VERTS = 24000
|
||
GEAR_MAX_TRIS = 1500
|
||
PROP_MAX_TRIS = 4000
|
||
|
||
try:
|
||
import bpy # type: ignore
|
||
import bmesh # type: ignore
|
||
from mathutils import Matrix, Vector # type: ignore
|
||
except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
|
||
bpy = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Catalogue (pure data, readable without Blender)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can
|
||
# pick one per avatar later. `tint` lists the slots the runtime should tint;
|
||
# every other material keeps its authored colour.
|
||
# `base` selects the CC0 Quaternius source the character is baked from
|
||
# (ADR-0017/ADR-0018, owner chose the full roster swap); `slots` overrides the
|
||
# source's material -> tint-slot map for that role (nothing is deleted: the
|
||
# modular Worker's vest is its torso and its hard hat is its crown).
|
||
# Rebuild one procedurally with `build --source procedural --head mh`.
|
||
CHARACTERS = {
|
||
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
|
||
"base": "hoodie",
|
||
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
|
||
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
|
||
"base": "casual",
|
||
"roles": ["authorized_staff", "protester", "guest"]},
|
||
"npc_coat": {"layer": "coat", "hair": "short", "base": "suit",
|
||
"gear": ["sunglasses", "shoulder_bag", "phone"],
|
||
"roles": ["influencer", "trespasser", "violent_threat"]},
|
||
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["clipboard"],
|
||
"base": "worker",
|
||
# Delivery keeps the hi-vis stripe bands as authored; every
|
||
# other worker role tints them with the shirt.
|
||
"slots": {"Worker_Yellow": {"split": True, "z": 1.65,
|
||
"above": "hair", "below": "keep"}},
|
||
"roles": ["delivery"]},
|
||
"npc_dress": {"layer": "dress", "hair": "long", "build": "slim", "base": "casual",
|
||
"gear": ["shoulder_bag"],
|
||
"roles": ["performer"]},
|
||
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
|
||
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
|
||
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
|
||
"base": "worker",
|
||
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
|
||
"roles": ["responder"]},
|
||
"npc_guard": {"layer": "guard", "hair": "short", "build": "broad",
|
||
"base": "worker",
|
||
"gear": ["wand"],
|
||
"roles": ["player_guard", "venue_security"]},
|
||
# Crowd-variety prototypes (character development): the three Quaternius
|
||
# sources are spread across the crowd pools so a crowd never shows one mesh.
|
||
"npc_crowd_a": {"layer": "none", "hair": "short", "build": "slim", "base": "casual",
|
||
"gear": ["sunglasses"], "roles": ["tourist", "guest"]},
|
||
"npc_crowd_b": {"layer": "none", "hair": "buzz", "build": "broad", "base": "casual",
|
||
"beard": True, "gear": ["shoulder_bag"],
|
||
"roles": ["confused_visitor", "guest"]},
|
||
"npc_crowd_c": {"layer": "coat", "hair": "long", "base": "hoodie",
|
||
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
|
||
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
|
||
"base": "worker",
|
||
"gear": ["cup"], "roles": ["tourist", "guest"]},
|
||
}
|
||
|
||
# Gear origin = the attach point; offsets are in the attach node's local space.
|
||
GEAR_ATTACH = {
|
||
"head": "Rig/Head marker (y = 1.62 m, head centre)",
|
||
"hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin",
|
||
"back": "Rig root, origin at the spine centre (0, 1.10, 0)",
|
||
"chest": "Rig root, origin at the sternum (0, 1.30, 0)",
|
||
}
|
||
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
|
||
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
||
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
|
||
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
|
||
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
|
||
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
|
||
# The runtime hangs hand gear off the Quaternius wrist proxy, not off the authored
|
||
# `hand` attach point: the wrist rests at Godot y 1.017 with the hip line at 0.95.
|
||
# `fit` uses these to say whether a hand item clears the thigh when the arm hangs.
|
||
HAND_REST_Y = 1.017
|
||
HIP_LINE = 0.95
|
||
# Real-world size of each hand item in metres (longest edge), so `fit` can flag a
|
||
# prop that drifted off-scale instead of only "it is big".
|
||
# Longest edge as authored today; `fit` flags drift past 10 %, so a re-authored
|
||
# item has to update its row instead of silently growing.
|
||
HAND_ITEM_AUTHORED_SIZE = {
|
||
"phone": 0.150, "cup": 0.142, "clipboard": 0.305, "umbrella": 0.900,
|
||
"camera": 0.138, "protest_sign": 1.230, "flashlight": 0.243,
|
||
"wand": 0.400, "radio": 0.203,
|
||
}
|
||
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
|
||
# Head gear is authored at the procedural library's head size; the shipped
|
||
# Quaternius roster has a 0.20 m head whose centre sits higher (see
|
||
# `build_gear`). 0.64 = 0.201 / 0.315.
|
||
HEAD_GEAR_SCALE = 0.64
|
||
# The gear origin is the Head *bone* (Godot y 1.587 on the Quaternius rig), but the
|
||
# gear is authored around the head *centre* (1.62 in the procedural library). The
|
||
# Quaternius head centre sits at 1.69, so head gear needs +0.103 or the cap brim
|
||
# lands on the brow and reads as a black visor across the eyes.
|
||
HEAD_GEAR_LIFT = 0.103
|
||
FIT_HEAD_MATERIALS = ("skin",)
|
||
FIT_CLOTHING_MATERIAL = "cloth"
|
||
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
|
||
FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
|
||
# Side bags include a strap that crosses the torso, so the full gear bbox cannot
|
||
# tell where the bag body starts. Measure the body-only y band for this fit.
|
||
FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)}
|
||
# Head gear is the firefighter helmet only (owner, 2026-11-06). `cap` and `hood`
|
||
# were retired with the hat removal; `build_gear()` keeps their branch code so a
|
||
# future design can restore them, but they are not assets any more.
|
||
GEAR = {
|
||
"sunglasses": "head",
|
||
"firefighter_helmet": "head",
|
||
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
|
||
"lanyard": "chest",
|
||
"phone": "hand", "cup": "hand", "clipboard": "hand", "umbrella": "hand",
|
||
"camera": "hand", "protest_sign": "hand",
|
||
"flashlight": "hand", "wand": "hand", "radio": "hand",
|
||
}
|
||
|
||
# Props: the first six replace assets/models/generated/<name>.glb with the same
|
||
# footprint; the rest are new street/venue pieces.
|
||
PROPS = {
|
||
"bollard": {"replaces": "generated/bollard.glb"},
|
||
"bench": {"replaces": "generated/bench.glb"},
|
||
"trash_bin": {"replaces": "generated/trash_bin.glb"},
|
||
"planter_box": {"replaces": "generated/planter_box.glb"},
|
||
"newspaper_box": {"replaces": "generated/newspaper_box.glb"},
|
||
"fire_hydrant": {"replaces": "generated/fire_hydrant.glb"},
|
||
"street_lamp": {"replaces": "WorldBuilder lamp boxes",
|
||
"emissive": ["bulb"]},
|
||
"street_tree": {"replaces": "WorldBuilder cone trees"},
|
||
"palm_tree": {},
|
||
"round_planter": {},
|
||
"a_frame_sign": {},
|
||
"wall_lantern": {"emissive": ["bulb"]},
|
||
"traffic_cone": {"replaces": "prop accessory 'cone'"},
|
||
"crowd_barrier": {},
|
||
"stanchion": {},
|
||
"handrail": {},
|
||
"security_podium": {},
|
||
"double_door": {"emissive": ["lock_led"]},
|
||
"marquee": {"emissive": ["bulb", "sign_face"]},
|
||
"sedan": {"emissive": ["headlight"]},
|
||
}
|
||
|
||
|
||
def asset_path(category: str, name: str) -> Path:
|
||
return OUT_DIR / category / f"{name}.glb"
|
||
|
||
|
||
def catalogue():
|
||
rows = [("npc", n) for n in CHARACTERS]
|
||
rows += [("npc_gear", n) for n in GEAR]
|
||
rows += [("props", n) for n in PROPS]
|
||
return rows
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Blender helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _srgb_to_linear(c: float) -> float:
|
||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||
|
||
|
||
# name: (sRGB 0-255, roughness, metallic, emission strength)
|
||
PALETTE = {
|
||
"black_metal": ((34, 35, 38), 0.45, 0.8, 0.0),
|
||
"dark_metal": ((52, 55, 60), 0.4, 0.85, 0.0),
|
||
"steel": ((170, 172, 176), 0.3, 1.0, 0.0),
|
||
"chrome": ((215, 217, 220), 0.15, 1.0, 0.0),
|
||
"brass": ((181, 146, 72), 0.3, 1.0, 0.0),
|
||
"reflective": ((226, 222, 196), 0.25, 0.3, 0.0),
|
||
"bin_green": ((44, 70, 54), 0.55, 0.3, 0.0),
|
||
"news_blue": ((40, 88, 150), 0.5, 0.3, 0.0),
|
||
"hydrant_red": ((176, 46, 36), 0.5, 0.2, 0.0),
|
||
"wood": ((150, 92, 54), 0.7, 0.0, 0.0),
|
||
"dark_wood": ((84, 54, 34), 0.7, 0.0, 0.0),
|
||
"concrete": ((176, 172, 162), 0.9, 0.0, 0.0),
|
||
"terracotta": ((170, 100, 70), 0.85, 0.0, 0.0),
|
||
"stone_pot": ((196, 186, 168), 0.85, 0.0, 0.0),
|
||
"soil": ((58, 42, 30), 1.0, 0.0, 0.0),
|
||
"foliage": ((54, 104, 46), 0.85, 0.0, 0.0),
|
||
"foliage_dark": ((36, 78, 36), 0.85, 0.0, 0.0),
|
||
"palm_leaf": ((70, 112, 50), 0.8, 0.0, 0.0),
|
||
"palm_leaf_dark": ((52, 88, 40), 0.8, 0.0, 0.0),
|
||
"bark": ((92, 70, 52), 0.95, 0.0, 0.0),
|
||
"palm_bark": ((128, 104, 78), 0.95, 0.0, 0.0),
|
||
"glass": ((90, 120, 140), 0.08, 0.2, 0.0),
|
||
"dark_glass": ((22, 26, 32), 0.06, 0.3, 0.0),
|
||
"paper": ((228, 224, 210), 0.9, 0.0, 0.0),
|
||
"chalkboard": ((32, 38, 36), 0.9, 0.0, 0.0),
|
||
"chalk": ((232, 232, 226), 0.9, 0.0, 0.0),
|
||
"plastic_black": ((26, 27, 30), 0.5, 0.0, 0.0),
|
||
"plastic_grey": ((110, 114, 120), 0.5, 0.0, 0.0),
|
||
"plastic_white": ((222, 222, 218), 0.45, 0.0, 0.0),
|
||
"cone_orange": ((236, 96, 24), 0.55, 0.0, 0.0),
|
||
"rubber": ((22, 22, 24), 0.9, 0.0, 0.0),
|
||
"velvet_red": ((128, 20, 30), 0.9, 0.0, 0.0),
|
||
"awning_red": ((150, 34, 38), 0.8, 0.0, 0.0),
|
||
"door_paint": ((38, 40, 44), 0.5, 0.4, 0.0),
|
||
"car_paint": ((18, 20, 24), 0.2, 0.6, 0.0),
|
||
"bulb": ((255, 226, 160), 0.3, 0.0, 6.0),
|
||
"headlight": ((240, 240, 255), 0.2, 0.0, 3.0),
|
||
"lock_led": ((255, 40, 30), 0.3, 0.0, 4.0),
|
||
"sign_face": ((255, 236, 196), 0.4, 0.0, 2.5),
|
||
"hivis": ((214, 228, 40), 0.7, 0.0, 0.0),
|
||
"leather": ((54, 38, 28), 0.6, 0.0, 0.0),
|
||
"denim_bag": ((60, 72, 96), 0.8, 0.0, 0.0),
|
||
"pack_nylon": ((40, 64, 92), 0.7, 0.0, 0.0),
|
||
"lens": ((20, 22, 26), 0.05, 0.5, 0.0),
|
||
"screen": ((26, 40, 70), 0.1, 0.0, 0.6),
|
||
"cardboard": ((186, 150, 104), 0.9, 0.0, 0.0),
|
||
"ribbon_blue": ((40, 80, 170), 0.7, 0.0, 0.0),
|
||
"turnout": ((36, 40, 46), 0.85, 0.0, 0.0),
|
||
"helmet": ((24, 26, 30), 0.3, 0.1, 0.0),
|
||
"undershirt": ((60, 62, 68), 0.9, 0.0, 0.0),
|
||
"uniform": ((30, 34, 46), 0.85, 0.0, 0.0),
|
||
"patch": ((190, 150, 60), 0.6, 0.2, 0.0),
|
||
"eye_white": ((236, 233, 226), 0.45, 0.0, 0.0),
|
||
}
|
||
|
||
_MATS = {}
|
||
|
||
|
||
def _solid_image(name, rgb):
|
||
"""4x4 sRGB texture so a runtime albedo_color=WHITE tint keeps the colour."""
|
||
img = bpy.data.images.new(f"{name}_albedo", 4, 4, alpha=False)
|
||
px = [rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0] * 16
|
||
img.pixels = px
|
||
img.pack()
|
||
return img
|
||
|
||
|
||
def material(name, textured_solid=False):
|
||
"""Principled material from PALETTE (or an npc slot texture set)."""
|
||
key = (name, textured_solid)
|
||
if key in _MATS:
|
||
return _MATS[key]
|
||
mat = bpy.data.materials.new(name)
|
||
mat.use_nodes = True
|
||
nodes = mat.node_tree.nodes
|
||
links = mat.node_tree.links
|
||
bsdf = nodes.get("Principled BSDF")
|
||
if name in SLOTS:
|
||
# Same neutral maps as npc_base, so Materials.npc_slot() tints alike.
|
||
albedo = nodes.new("ShaderNodeTexImage")
|
||
albedo.image = bpy.data.images.load(str(NPC_TEX_DIR / f"{name}_albedo.png"),
|
||
check_existing=True)
|
||
albedo.image.colorspace_settings.name = "sRGB"
|
||
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
|
||
# The hair normal map was authored against the old cap UVs. Reusing it
|
||
# on the skull-conforming MakeHuman cap produces large dark tangent
|
||
# patches, so hair keeps its albedo but takes flat shading normals.
|
||
if name != "hair":
|
||
normal_tex = nodes.new("ShaderNodeTexImage")
|
||
normal_tex.image = bpy.data.images.load(
|
||
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
|
||
normal_tex.image.colorspace_settings.name = "Non-Color"
|
||
nmap = nodes.new("ShaderNodeNormalMap")
|
||
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
|
||
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
|
||
rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92,
|
||
"accessory": 0.5, "hair": 0.7}[name]
|
||
bsdf.inputs["Roughness"].default_value = rough
|
||
bsdf.inputs["Metallic"].default_value = 0.0
|
||
else:
|
||
rgb, rough, metal, emit = PALETTE[name]
|
||
lin = tuple(_srgb_to_linear(c / 255.0) for c in rgb) + (1.0,)
|
||
bsdf.inputs["Base Color"].default_value = lin
|
||
bsdf.inputs["Roughness"].default_value = rough
|
||
bsdf.inputs["Metallic"].default_value = metal
|
||
if textured_solid:
|
||
tex = nodes.new("ShaderNodeTexImage")
|
||
tex.image = _solid_image(name, rgb)
|
||
tex.interpolation = "Closest"
|
||
links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||
if emit > 0.0:
|
||
bsdf.inputs["Emission Color"].default_value = lin
|
||
bsdf.inputs["Emission Strength"].default_value = emit
|
||
_MATS[key] = mat
|
||
return mat
|
||
|
||
|
||
def reset_scene():
|
||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
_MATS.clear()
|
||
|
||
|
||
def _link(name, bm):
|
||
me = bpy.data.meshes.new(name)
|
||
bm.to_mesh(me)
|
||
bm.free()
|
||
obj = bpy.data.objects.new(name, me)
|
||
bpy.context.scene.collection.objects.link(obj)
|
||
return obj
|
||
|
||
|
||
def _xf(loc=(0, 0, 0), rot=(0, 0, 0), scale=(1, 1, 1)):
|
||
from mathutils import Euler
|
||
m = Matrix.Translation(Vector(loc)) @ Euler(rot, "XYZ").to_matrix().to_4x4()
|
||
s = Matrix.Diagonal(Vector((scale[0], scale[1], scale[2], 1.0)))
|
||
return m @ s
|
||
|
||
|
||
def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1):
|
||
obj.data.materials.append(mat)
|
||
if bevel > 0.0:
|
||
mod = obj.modifiers.new("Bevel", "BEVEL")
|
||
mod.width = bevel
|
||
mod.segments = bevel_segments
|
||
mod.limit_method = "ANGLE"
|
||
mod.angle_limit = math.radians(40)
|
||
_apply_all(obj)
|
||
_clean(obj)
|
||
if smooth:
|
||
obj.data.shade_smooth()
|
||
obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle))
|
||
else:
|
||
obj.data.shade_flat()
|
||
return obj
|
||
|
||
|
||
def _clean(obj):
|
||
"""Drop zero-area faces (thin bevels, lathe poles) before export."""
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges)
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
|
||
|
||
def _apply_all(obj):
|
||
bpy.context.view_layer.objects.active = obj
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
for mod in list(obj.modifiers):
|
||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||
|
||
|
||
def box(name, size, loc, mat, rot=(0, 0, 0), bevel=0.0, segs=1):
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_cube(bm, size=1.0, matrix=_xf(loc, rot, size))
|
||
return _finish(_link(name, bm), mat, smooth=bevel > 0, bevel=bevel,
|
||
bevel_segments=segs)
|
||
|
||
|
||
def cyl(name, r1, r2, depth, loc, mat, seg=16, rot=(0, 0, 0), smooth=True,
|
||
scale=(1, 1, 1), caps=True, bevel=0.0):
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_cone(bm, cap_ends=caps, cap_tris=False, segments=seg,
|
||
radius1=r1, radius2=r2, depth=depth,
|
||
matrix=_xf(loc, rot, scale))
|
||
return _finish(_link(name, bm), mat, smooth=smooth, bevel=bevel)
|
||
|
||
|
||
def sphere(name, r, loc, mat, seg=16, rings=10, scale=(1, 1, 1), rot=(0, 0, 0),
|
||
cut_below=None, smooth=True):
|
||
"""UV sphere; `cut_below` drops vertices under a local z (0..1 of radius)."""
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=rings, radius=r)
|
||
if cut_below is not None:
|
||
doomed = [v for v in bm.verts if v.co.z < cut_below * r - 1e-6]
|
||
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
|
||
bmesh.ops.transform(bm, matrix=_xf(loc, rot, scale), verts=bm.verts)
|
||
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=60)
|
||
|
||
|
||
def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6,
|
||
scale=(1, 1, 1)):
|
||
bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor,
|
||
major_segments=seg, minor_segments=minor_seg)
|
||
obj = bpy.context.object
|
||
obj.name = name
|
||
obj.data.transform(_xf(loc, rot, scale))
|
||
return _finish(obj, mat, smooth=True, sharp_angle=70)
|
||
|
||
|
||
def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0,
|
||
face_front=False, caps=True):
|
||
"""Revolve [(radius, z), ...] around Z (bottom to top), capped.
|
||
|
||
`face_front` rotates the ring half a step so a flat face (not a corner)
|
||
points at the front (-Y); used for octagonal street furniture.
|
||
"""
|
||
phase = (math.pi / seg + math.pi / 2) if face_front else 0.0
|
||
bm = bmesh.new()
|
||
rings = []
|
||
for r, z in profile:
|
||
ring = []
|
||
for i in range(seg):
|
||
a = math.tau * i / seg + phase
|
||
ring.append(bm.verts.new((loc[0] + r * math.cos(a),
|
||
loc[1] + r * math.sin(a), loc[2] + z)))
|
||
rings.append(ring)
|
||
for a, b in zip(rings, rings[1:]):
|
||
for i in range(seg):
|
||
j = (i + 1) % seg
|
||
bm.faces.new((a[i], a[j], b[j], b[i]))
|
||
if caps and profile[0][0] > 1e-4:
|
||
bm.faces.new(list(reversed(rings[0])))
|
||
if caps and profile[-1][0] > 1e-4:
|
||
bm.faces.new(rings[-1])
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||
return _finish(_link(name, bm), mat, smooth=smooth, sharp_angle=sharp)
|
||
|
||
|
||
def tube(name, points, radius, mat, seg=8, caps=True, width_scale=1.0,
|
||
thickness_scale=1.0):
|
||
"""Bent tube through 3D points (handrails, umbrella handles, frames).
|
||
|
||
`width_scale`/`thickness_scale` turn the round cross-section into a flat
|
||
strap for shoulder harnesses; the default keeps the old round tube.
|
||
"""
|
||
bm = bmesh.new()
|
||
rings = []
|
||
pts = [Vector(p) for p in points]
|
||
for i, p in enumerate(pts):
|
||
if i == 0:
|
||
d = (pts[1] - p).normalized()
|
||
elif i == len(pts) - 1:
|
||
d = (p - pts[i - 1]).normalized()
|
||
else:
|
||
d = ((pts[i + 1] - p).normalized() + (p - pts[i - 1]).normalized()).normalized()
|
||
up = Vector((0, 0, 1)) if abs(d.z) < 0.9 else Vector((1, 0, 0))
|
||
side = d.cross(up).normalized()
|
||
up2 = side.cross(d).normalized()
|
||
ring = []
|
||
for k in range(seg):
|
||
a = math.tau * k / seg
|
||
ring.append(bm.verts.new(p + radius * (
|
||
math.cos(a) * side * width_scale
|
||
+ math.sin(a) * up2 * thickness_scale)))
|
||
rings.append(ring)
|
||
for a, b in zip(rings, rings[1:]):
|
||
for k in range(seg):
|
||
j = (k + 1) % seg
|
||
bm.faces.new((a[k], a[j], b[j], b[k]))
|
||
if caps:
|
||
bm.faces.new(list(reversed(rings[0])))
|
||
bm.faces.new(rings[-1])
|
||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
|
||
|
||
|
||
def join(objs, name):
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
for o in objs:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = objs[0]
|
||
if len(objs) > 1:
|
||
bpy.ops.object.join()
|
||
obj = bpy.context.object
|
||
obj.name = name
|
||
obj.data.name = name
|
||
return obj
|
||
|
||
|
||
def uv_unwrap(obj, angle=60.0):
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode="EDIT")
|
||
bpy.ops.mesh.select_all(action="SELECT")
|
||
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01)
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
|
||
|
||
def export(objs, category, name):
|
||
path = asset_path(category, name)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
for o in objs:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = objs[0]
|
||
bpy.ops.export_scene.gltf(
|
||
filepath=str(path), export_format="GLB", use_selection=True,
|
||
export_apply=True, export_animations=False, export_skins=True,
|
||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||
SRC_DIR.mkdir(parents=True, exist_ok=True)
|
||
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
|
||
compress=True)
|
||
return path
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Characters
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Bone layout copied from tools/npc_pipeline.py so NpcAvatar's proxies line up.
|
||
def _bone_layout():
|
||
b = {"Hips": ((0, 0, 0.62), (0, 0, 0.82), None),
|
||
"Spine": ((0, 0, 0.82), (0, 0, 1.40), "Hips"),
|
||
"Neck": ((0, 0, 1.40), (0, 0, 1.52), "Spine"),
|
||
"Head": ((0, 0, 1.52), (0, 0, 1.82), "Neck")}
|
||
for s, suf in ((-1.0, "L"), (1.0, "R")):
|
||
x = 0.27 * s
|
||
hx = 0.12 * s
|
||
b[f"Shoulder{suf}"] = ((x, 0, 1.38), (x, 0, 1.06), "Spine")
|
||
b[f"Elbow{suf}"] = ((x, 0, 1.06), (x, 0, 0.78), f"Shoulder{suf}")
|
||
b[f"Hand{suf}"] = ((x, 0, 0.78), (x, 0, 0.68), f"Elbow{suf}")
|
||
b[f"Hip{suf}"] = ((hx, 0, 0.62), (hx, 0, 0.30), "Hips")
|
||
b[f"Knee{suf}"] = ((hx, 0, 0.30), (hx, 0, 0.0), f"Hip{suf}")
|
||
return b
|
||
|
||
|
||
BUILDS = { # torso width / depth multipliers, limb thickness
|
||
"average": (1.0, 1.0, 1.0),
|
||
"slim": (0.9, 0.92, 0.88),
|
||
"broad": (1.12, 1.08, 1.1),
|
||
}
|
||
|
||
|
||
def _skin_body(spec):
|
||
"""Connected torso + limbs from a Skin modifier graph, subdivided once."""
|
||
w, d, t = BUILDS[spec.get("build", "average")]
|
||
layer = spec["layer"]
|
||
bulk = 1.0 + {"coat": 0.06, "jacket": 0.05, "hood": 0.06, "responder": 0.16,
|
||
"guard": 0.03, "vest": 0.02}.get(layer, 0.0)
|
||
sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22,
|
||
"guard": 0.05}.get(layer, 0.0)
|
||
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
|
||
nodes = [ # (pos, radius_x, radius_y)
|
||
((0, 0, 0.80), 0.158 * w, 0.112 * d), # 0 pelvis
|
||
((0, 0, 0.96), 0.142 * w * bulk, 0.101 * d * bulk), # 1 waist
|
||
((0, 0.0, 1.18), 0.155 * w * bulk, 0.110 * d * bulk), # 2 chest
|
||
((0, 0.0, 1.335), 0.158 * w * bulk, 0.105 * d * bulk), # 3 upper chest
|
||
# Narrow neck and a lower, sloped shoulder root. The old wide neck base
|
||
# and horizontal shoulder shelf made the accepted head read as small.
|
||
((0, 0, 1.42), 0.060, 0.060), # 4 neck base
|
||
((0, -0.005, 1.53), 0.054, 0.054), # 5 neck top
|
||
]
|
||
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
|
||
for s in (-1.0, 1.0):
|
||
base = len(nodes)
|
||
nodes += [
|
||
((0.19 * s * w, 0, 1.345), 0.070 * t * sleeve, 0.068 * t * sleeve), # shoulder
|
||
((0.27 * s * w, 0, 1.22), 0.054 * t * sleeve, 0.055 * t * sleeve), # upper arm
|
||
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
|
||
((0.275 * s * w, -0.01, 0.78), 0.034 * t, 0.030 * t), # wrist
|
||
]
|
||
edges += [(3, base), (base, base + 1), (base + 1, base + 2), (base + 2, base + 3)]
|
||
hb = len(nodes)
|
||
nodes += [
|
||
((0.095 * s, 0, 0.72), 0.095 * t * leg, 0.10 * t * leg), # hip joint
|
||
((0.115 * s, 0, 0.52), 0.080 * t * leg, 0.085 * t * leg), # thigh
|
||
((0.12 * s, 0.0, 0.31), 0.058 * t * leg, 0.062 * t * leg), # knee
|
||
((0.12 * s, 0.01, 0.18), 0.055 * t * leg, 0.058 * t * leg), # calf
|
||
((0.12 * s, 0, 0.08), 0.040 * leg, 0.042 * leg), # ankle
|
||
]
|
||
edges += [(0, hb), (hb, hb + 1), (hb + 1, hb + 2), (hb + 2, hb + 3), (hb + 3, hb + 4)]
|
||
me = bpy.data.meshes.new("body")
|
||
me.from_pydata([n[0] for n in nodes], edges, [])
|
||
obj = bpy.data.objects.new("body", me)
|
||
bpy.context.scene.collection.objects.link(obj)
|
||
skin = obj.modifiers.new("Skin", "SKIN")
|
||
skin.branch_smoothing = 0.6
|
||
for i, (_, rx, ry) in enumerate(nodes):
|
||
me.skin_vertices[0].data[i].radius = (rx, ry)
|
||
me.skin_vertices[0].data[0].use_root = True
|
||
sub = obj.modifiers.new("Sub", "SUBSURF")
|
||
sub.levels = 2
|
||
dec = obj.modifiers.new("Decimate", "DECIMATE")
|
||
dec.ratio = 0.21
|
||
dec.use_symmetry = True
|
||
dec.symmetry_axis = "X"
|
||
_apply_all(obj)
|
||
# Clean edge loops where materials change (waist, cuffs, short sleeves, neck).
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
for z in (0.86, 1.19, 1.43):
|
||
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
|
||
bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1))
|
||
# Cuts next to existing vertices leave slivers; weld them.
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4)
|
||
bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges)
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
# Front of the chest/backside slightly shaped: push chest forward a touch.
|
||
for v in obj.data.vertices:
|
||
if 1.05 < v.co.z < 1.38 and abs(v.co.x) < 0.17 and v.co.y < 0:
|
||
v.co.y -= 0.012 * (1.0 - abs(v.co.x) / 0.17)
|
||
if 0.72 < v.co.z < 0.9 and abs(v.co.x) < 0.17 and v.co.y > 0:
|
||
v.co.y += 0.015
|
||
obj.data.shade_smooth()
|
||
return obj
|
||
|
||
|
||
def _assign_body_slots(obj, spec, mats):
|
||
"""Pick a slot per face from its centroid: skin / cloth / pants."""
|
||
layer = spec["layer"]
|
||
names = [m.name for m in mats]
|
||
obj.data.materials.clear()
|
||
for m in mats:
|
||
obj.data.materials.append(m)
|
||
idx = {n: i for i, n in enumerate(names)}
|
||
short_sleeve = layer in ("none", "vest", "dress")
|
||
# Main surfaces always use the tintable contract slots (dress legs = tights).
|
||
top_slot = "cloth"
|
||
leg_slot = "pants"
|
||
waist = 0.86
|
||
for poly in obj.data.polygons:
|
||
c = poly.center
|
||
arm = abs(c.x) > 0.205 and c.z > 0.78
|
||
if c.z > 1.43 and not arm:
|
||
slot = "skin"
|
||
elif arm:
|
||
if c.z < 0.86 or (short_sleeve and c.z < 1.19):
|
||
slot = "skin"
|
||
else:
|
||
slot = top_slot
|
||
elif c.z < waist:
|
||
slot = leg_slot
|
||
if layer == "dress" and c.z > 0.40:
|
||
slot = "cloth"
|
||
else:
|
||
slot = top_slot
|
||
poly.material_index = idx[slot]
|
||
|
||
|
||
def _seg_dist(p, a, b):
|
||
ab = b - a
|
||
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-9)))
|
||
return (p - (a + ab * t)).length
|
||
|
||
|
||
def _auto_weights(obj, layout, rigid=None):
|
||
"""Distance-to-bone weights limited by body region (no heat-solve flakes)."""
|
||
segs = {n: (Vector(h), Vector(t)) for n, (h, t, _) in layout.items()}
|
||
groups = {n: obj.vertex_groups.new(name=n) for n in layout}
|
||
for v in obj.data.vertices:
|
||
p = v.co
|
||
side = "L" if p.x < 0 else "R"
|
||
if rigid:
|
||
bone = rigid(p)
|
||
if bone:
|
||
groups[bone].add([v.index], 1.0, "REPLACE")
|
||
continue
|
||
if abs(p.x) > 0.19 and p.z > 0.74 and p.z < 1.42:
|
||
if p.z < 0.82:
|
||
# Wrist: track the Hand bone fully so the forearm-end moves with
|
||
# the rigid hand (the old Hand+Elbow blend left a visible gap at
|
||
# the wrist when the hand rotated).
|
||
allowed = [f"Hand{side}"]
|
||
else:
|
||
allowed = ["Spine", f"Shoulder{side}", f"Elbow{side}", f"Hand{side}"]
|
||
if abs(p.x) > 0.23:
|
||
allowed.remove("Spine")
|
||
elif p.z < 0.80:
|
||
# Legs: keep the shin rigid to the knee and the thigh rigid to the
|
||
# hip, with a short blend only at the knee joint, so deep flexion
|
||
# (seated) no longer collapses the thigh or pinches the knee.
|
||
if p.z < 0.28:
|
||
allowed = [f"Knee{side}"]
|
||
elif p.z < 0.34:
|
||
allowed = [f"Hip{side}", f"Knee{side}"]
|
||
elif p.z < 0.62:
|
||
allowed = [f"Hip{side}"]
|
||
else:
|
||
allowed = ["Hips", f"Hip{side}"]
|
||
elif p.z > 1.40:
|
||
allowed = ["Spine", "Neck", "Head"]
|
||
else:
|
||
allowed = ["Hips", "Spine", "Neck"]
|
||
scored = sorted((_seg_dist(p, *segs[b]), b) for b in allowed)[:2]
|
||
ws = [(1.0 / (dd + 0.02) ** 4, b) for dd, b in scored]
|
||
total = sum(w for w, _ in ws)
|
||
for w, b in ws:
|
||
if w / total > 0.02:
|
||
groups[b].add([v.index], w / total, "REPLACE")
|
||
|
||
|
||
def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645),
|
||
scale=(0.95, 1.04, 1.1)):
|
||
"""Head-hugging cap: cut at the brow in front and lower at the back."""
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=radius)
|
||
bmesh.ops.transform(bm, matrix=_xf(center, (0, 0, 0), scale), verts=bm.verts)
|
||
doomed = []
|
||
for v in bm.verts:
|
||
t = max(0.0, min(1.0, 0.5 - (v.co.y - center[1]) / (radius * 1.2)))
|
||
cut = back_z + (front_z - back_z) * t
|
||
if v.co.z < cut:
|
||
doomed.append(v)
|
||
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
|
||
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70)
|
||
|
||
|
||
def _hair_curve(style):
|
||
"""Hairline endpoints and skull offset for a hair style."""
|
||
if style == "buzz":
|
||
return 1.734, 1.608, 1.035
|
||
return 1.714, 1.588, 1.055
|
||
|
||
|
||
def _trim_hidden_scalp(head, style):
|
||
"""Delete the scalp that sits under the hair cap to free triangle budget.
|
||
|
||
The cap is a scaled copy of the skull, so any head face whose scaled
|
||
vertices all fall outside the hairline sphere is fully covered. Keeping
|
||
boundary-touching faces leaves a safety band under the hair rim; the open
|
||
hole is never visible and the accepted face geometry is untouched.
|
||
"""
|
||
front_z, back_z, scale = _hair_curve(style)
|
||
center = Vector((0.0, 0.0, 1.665))
|
||
cutter_z = (front_z + back_z) * 0.5 - 0.396
|
||
bm = bmesh.new()
|
||
bm.from_mesh(head.data)
|
||
|
||
def covered(p):
|
||
q = center + (p - center) * scale
|
||
return (q.x * q.x + (q.y + 0.41) ** 2
|
||
+ (q.z - cutter_z) ** 2) > 0.56 ** 2
|
||
|
||
doomed = [f for f in bm.faces if all(covered(v.co) for v in f.verts)]
|
||
if doomed:
|
||
bmesh.ops.delete(bm, geom=doomed, context="FACES")
|
||
loose = [v for v in bm.verts if not v.link_faces]
|
||
if loose:
|
||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||
bm.to_mesh(head.data)
|
||
bm.free()
|
||
|
||
|
||
def _hair_from_head(source, style, mat_hair):
|
||
"""Build the hair cap as a solid shell derived from the skull.
|
||
|
||
A separate hair ellipsoid cannot follow the broad MakeHuman crown, and an
|
||
open, face-deleted copy produces a sawtooth hairline. Scaling the closed
|
||
head shell outwards and boolean-cutting the face with a large front sphere
|
||
gives a watertight cap whose curved hairline is smooth from every angle.
|
||
"""
|
||
front_z, back_z, scale = _hair_curve(style)
|
||
cap = source.copy()
|
||
cap.data = source.data.copy()
|
||
cap.name = "HairCap"
|
||
bpy.context.scene.collection.objects.link(cap)
|
||
center = Vector((0.0, 0.0, 1.665))
|
||
for v in cap.data.vertices:
|
||
v.co = center + (v.co - center) * scale
|
||
# The cutter is a big sphere in front of the face: its upper surface rises
|
||
# toward the brow and falls toward the nape, which is the hairline curve.
|
||
mid_z = (front_z + back_z) * 0.5
|
||
cutter_z = mid_z - 0.396
|
||
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=20, radius=0.56,
|
||
location=(0.0, -0.41, cutter_z))
|
||
cutter = bpy.context.object
|
||
bpy.context.view_layer.objects.active = cap
|
||
mod = cap.modifiers.new("Hairline", "BOOLEAN")
|
||
mod.operation = "DIFFERENCE"
|
||
mod.solver = "EXACT"
|
||
mod.object = cutter
|
||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||
bpy.data.objects.remove(cutter, do_unlink=True)
|
||
# The boolean result is still head-density. Hair needs silhouette, not
|
||
# pores, and the character budget has to stay under 16k triangles.
|
||
dec = cap.modifiers.new("HairReduce", "DECIMATE")
|
||
dec.ratio = 0.34
|
||
dec.use_collapse_triangulate = True
|
||
bpy.ops.object.modifier_apply(modifier=dec.name)
|
||
bm = bmesh.new()
|
||
bm.from_mesh(cap.data)
|
||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||
bm.to_mesh(cap.data)
|
||
bm.free()
|
||
cap.data.materials.clear()
|
||
cap.data.materials.append(mat_hair)
|
||
cap.data.shade_smooth()
|
||
cap.data.set_sharp_from_angle(angle=math.radians(55))
|
||
|
||
parts = [cap]
|
||
if style == "long":
|
||
parts.append(sphere("HairBack", 0.095, (0, 0.060, 1.560), mat_hair,
|
||
seg=14, rings=9, scale=(0.98, 0.55, 1.55)))
|
||
for s in (-1.0, 1.0):
|
||
parts.append(sphere("Lock", 0.048, (0.089 * s, -0.012, 1.585),
|
||
mat_hair, seg=8, rings=6,
|
||
scale=(0.55, 0.85, 2.05)))
|
||
if style == "bun":
|
||
parts.append(sphere("Bun", 0.060, (0, 0.100, 1.775), mat_hair,
|
||
seg=12, rings=8, scale=(1.0, 0.9, 0.95)))
|
||
return parts
|
||
|
||
def _hair(style, mat_hair, broad=False):
|
||
parts = []
|
||
if style == "buzz":
|
||
parts.append(_hair_cap("Hair", mat_hair, 1.705, 1.58, radius=0.158))
|
||
elif style in ("short", "long", "bun"):
|
||
parts.append(_hair_cap("Hair", mat_hair, 1.715, 1.56))
|
||
parts.append(sphere("Fringe", 0.08, (0.025, -0.115, 1.755), mat_hair, seg=10,
|
||
rings=6, scale=(1.35, 0.5, 0.42), rot=(0.35, 0, -0.18)))
|
||
if style == "long":
|
||
parts.append(sphere("HairBack", 0.15, (0, 0.06, 1.52), mat_hair, seg=14, rings=8,
|
||
scale=(1.0, 0.5, 1.35)))
|
||
for s in (-1.0, 1.0):
|
||
parts.append(sphere("Lock", 0.06, (0.125 * s, -0.01, 1.57), mat_hair, seg=8,
|
||
rings=6, scale=(0.6, 0.9, 1.9)))
|
||
if style == "bun":
|
||
parts.append(sphere("Bun", 0.065, (0, 0.13, 1.76), mat_hair, seg=12, rings=8))
|
||
return parts
|
||
|
||
|
||
def _hood(name, mat, head_center):
|
||
"""Hood shell around the head with an oval face opening and a solid rim."""
|
||
cx, cy, cz = head_center
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19)
|
||
bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0),
|
||
(0.97, 1.05, 1.06)), verts=bm.verts)
|
||
doomed = [v for v in bm.verts
|
||
if v.co.z < cz - 0.17
|
||
or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2
|
||
+ ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)]
|
||
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
|
||
# Snap the stair-stepped opening onto the ellipse for a clean edge.
|
||
for v in bm.verts:
|
||
if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16:
|
||
dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165
|
||
k = math.hypot(dx, dz)
|
||
if k > 1e-6:
|
||
v.co.x = cx + dx / k * 0.125
|
||
v.co.z = cz - 0.01 + dz / k * 0.165
|
||
# Thicken into a shell so the open edge reads as fabric, not a paper cut.
|
||
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018)
|
||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
|
||
|
||
|
||
def _shape_head_mesh(head):
|
||
"""Sculpt a brow ridge, cheekbones and a jaw/chin plate into the head sphere.
|
||
|
||
Separate jaw/cheek spheres leave visible intersection rings in the baked AO,
|
||
which read as a beard shadow. Only forward protrusions are sculpted; recessed
|
||
creases (nasolabial folds, temple hollows, a chin cleft) bake as dark AO
|
||
patches on this low-poly head, so they are left to shading. The face front is
|
||
-Y (Blender) / +Z (Godot); z is up in this builder.
|
||
"""
|
||
bm = bmesh.new()
|
||
bm.from_mesh(head.data)
|
||
for v in bm.verts:
|
||
x, y, z = v.co
|
||
if y >= 0.0:
|
||
continue
|
||
ax = abs(x)
|
||
# Brow ridge: a shallow forward roll above each eye socket.
|
||
brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.030)
|
||
* max(0.0, 1.0 - abs(ax - 0.050) / 0.040))
|
||
v.co.y -= 0.008 * brow
|
||
# Cheekbone: a shallow, wide lift under each eye.
|
||
cheek = (max(0.0, 1.0 - abs(z - 1.612) / 0.040)
|
||
* max(0.0, 1.0 - abs(ax - 0.070) / 0.050))
|
||
v.co.y -= 0.005 * cheek
|
||
# Jaw/chin plate: forward and slightly wider below the mouth.
|
||
jaw = (max(0.0, 1.0 - abs(z - 1.565) / 0.055)
|
||
* max(0.0, 1.0 - ax / 0.075))
|
||
v.co.y -= 0.012 * jaw
|
||
v.co.x *= 1.0 + 0.06 * jaw
|
||
bm.normal_update()
|
||
bm.to_mesh(head.data)
|
||
bm.free()
|
||
|
||
|
||
def _face(mats, spec):
|
||
"""Head with realistic face planes: recessed sockets with both lids, a
|
||
sculpted brow/cheek/jaw, a bridged nose with ala and columella, and separated
|
||
upper/lower lips.
|
||
|
||
At conversation distance the eye region carries the read, so each eye gets a
|
||
white sclera, a dark iris, an upper and a lower lid, and the face gets
|
||
sculpted brow/cheek/jaw planes instead of a bare sphere (realism pass).
|
||
"""
|
||
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
|
||
white = material("eye_white", textured_solid=True)
|
||
head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=24, rings=16,
|
||
scale=(0.92, 1.0, 1.12))
|
||
_shape_head_mesh(head)
|
||
parts = [head]
|
||
# Nose: bridge and ball; the ala widen the base into a small triangle.
|
||
parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8,
|
||
rot=(math.radians(-72), 0, 0), smooth=True))
|
||
parts.append(sphere("NoseTip", 0.022, (0, -0.162, 1.615), skin, seg=10, rings=6,
|
||
scale=(1.0, 0.85, 0.80)))
|
||
for s in (-1.0, 1.0):
|
||
# Ala widen the nose base into a small triangle rather than a ball.
|
||
parts.append(sphere("NoseWing", 0.012, (0.016 * s, -0.160, 1.606), skin,
|
||
seg=6, rings=4, scale=(1.0, 0.65, 0.75)))
|
||
# Ear with a lobe and a darker concha; the old disc had no depth cue.
|
||
parts.append(sphere("Ear", 0.036, (0.143 * s, 0.008, 1.63), skin, seg=8, rings=5,
|
||
scale=(0.42, 1.0, 1.35)))
|
||
parts.append(sphere("EarLobe", 0.018, (0.142 * s, -0.005, 1.605), skin, seg=8,
|
||
rings=5, scale=(0.45, 1.0, 0.9)))
|
||
parts.append(sphere("EarInner", 0.013, (0.149 * s, -0.003, 1.633), acc,
|
||
seg=6, rings=4, scale=(0.28, 0.65, 0.90)))
|
||
# Eye: recess the sclera into the socket, then wrap it with both lids.
|
||
# The old white ball sat ~1 cm proud of the head; this keeps the iris
|
||
# readable without the doll-like bulge.
|
||
ex, ey, ez = 0.052 * s, -0.134, 1.664
|
||
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, rings=6,
|
||
scale=(1.25, 0.62, 0.85)))
|
||
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, rings=5,
|
||
scale=(1.0, 0.55, 1.0)))
|
||
parts.append(sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), skin,
|
||
seg=8, rings=4, scale=(1.20, 0.32, 0.24),
|
||
rot=(math.radians(-10), 0, 0)))
|
||
# A thin lower lid keeps the eye from reading as a floating white oval.
|
||
parts.append(sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), skin,
|
||
seg=8, rings=4, scale=(1.10, 0.22, 0.18),
|
||
rot=(math.radians(6), 0, 0)))
|
||
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair,
|
||
rot=(0, 0.14 * s, 0.0)))
|
||
# Mouth: one rounded dark slot (the old clean read) plus a fuller lower lip
|
||
# in skin so the mouth line gains a subtle volume cue without a moustache read.
|
||
parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5,
|
||
scale=(1.0, 0.28, 0.12)))
|
||
parts.append(sphere("LowerLip", 0.012, (0, -0.156, 1.578), skin, seg=8, rings=5,
|
||
scale=(1.2, 0.55, 0.30)))
|
||
if spec.get("beard"):
|
||
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
|
||
scale=(1.0, 0.9, 0.75), cut_below=-0.2))
|
||
return parts
|
||
|
||
|
||
def _head_proto(mats, spec, subdiv=48):
|
||
"""Connected head shell with sculpted sockets, nose, lips, ears and brow/cheek/jaw.
|
||
|
||
The model-overhaul prototype: the skin features are displaced out of one
|
||
head mesh instead of floating spheres, so the face is a single connected
|
||
surface with loop-based eye sockets and protruding nose/lip/ear volumes. The
|
||
eyeballs (sclera/iris/lids) stay separate — they are not skin. Blender Z-up,
|
||
face front −Y. `subdiv` is the UV-sphere u-segments (24/48/64 variants); s48
|
||
is the chosen default after the 1.5 m/1.85 m comparison.
|
||
"""
|
||
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
|
||
white = material("eye_white", textured_solid=True)
|
||
rings = max(14, subdiv // 2 + 2)
|
||
head = sphere("HeadShell", 0.155, (0, 0, 1.635), skin, seg=subdiv, rings=rings,
|
||
scale=(0.88, 0.95, 1.18))
|
||
_shape_head_mesh(head) # brow ridge + cheekbone + jaw/chin planes
|
||
bm = bmesh.new()
|
||
bm.from_mesh(head.data)
|
||
|
||
# Pass 1: skull shape — flatten the face plane and taper the jaw/chin.
|
||
for v in bm.verts:
|
||
x, y, z = v.co
|
||
if y < -0.03 and 1.42 < z < 1.80:
|
||
v.co.y = -0.13 + (y + 0.13) * 0.05 # flatten the forward bulge
|
||
if y < -0.03 and z > 1.70:
|
||
v.co.y += (z - 1.70) * 0.45 # forehead slopes back to the crown
|
||
if z < 1.58:
|
||
taper = max(0.0, 1.0 - (1.58 - z) / 0.12)
|
||
v.co.x *= 1.0 - 0.16 * taper # narrow the lower face
|
||
|
||
# Pass 2: features on the flattened face.
|
||
for v in bm.verts:
|
||
x, y, z = v.co
|
||
ax = abs(x)
|
||
# --- brow ledge: a clear ridge where the forehead meets the nose ---
|
||
brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.024)
|
||
* max(0.0, 1.0 - abs(ax - 0.050) / 0.045))
|
||
if brow > 0:
|
||
v.co.y -= 0.028 * brow
|
||
# --- eye sockets: recess the floor, leave a raised orbital rim ---
|
||
for s in (-1.0, 1.0):
|
||
r = math.hypot(x - 0.052 * s, z - 1.664)
|
||
if r < 0.036:
|
||
v.co.y += 0.018 * (1.0 - r / 0.036)
|
||
elif r < 0.046:
|
||
v.co.y -= 0.010 * (1.0 - (r - 0.036) / 0.010)
|
||
# --- nose: a wide bridge/tip with ala wings ---
|
||
if y < 0 and ax < 0.080:
|
||
band = max(0.0, 1.0 - abs(z - 1.632) / 0.055)
|
||
if band > 0:
|
||
tip = max(0.0, 1.0 - abs(z - 1.615) / 0.034)
|
||
v.co.y -= (0.062 * tip + 0.020 * max(0.0, band - tip)) \
|
||
* (1.0 - ax / 0.080)
|
||
for s in (-1.0, 1.0):
|
||
ar = math.hypot((x - 0.026 * s) / 0.015, (z - 1.604) / 0.014)
|
||
if ar < 1.0:
|
||
v.co.y -= 0.024 * (1.0 - ar)
|
||
# --- chin wedge + jaw step ---
|
||
if y < 0 and ax < 0.044:
|
||
chin = max(0.0, 1.0 - abs(z - 1.548) / 0.024)
|
||
if chin > 0:
|
||
v.co.y -= 0.034 * chin
|
||
step = max(0.0, 1.0 - abs(z - 1.516) / 0.016)
|
||
if step > 0:
|
||
v.co.y += 0.026 * step
|
||
# --- mouth: recessed line + smooth protruding upper/lower lip block ---
|
||
if y < 0 and ax < 0.036:
|
||
if abs(z - 1.583) < 0.014:
|
||
v.co.y += 0.006
|
||
if abs(z - 1.590) < 0.012:
|
||
v.co.y -= 0.016
|
||
if abs(z - 1.577) < 0.014:
|
||
v.co.y -= 0.020
|
||
# --- ears: raised C-shape helix + concha recess + a lobe ---
|
||
for v in bm.verts:
|
||
x, y, z = v.co
|
||
r = math.hypot((z - 1.62) / 0.062, (y - 0.002) / 0.048)
|
||
near = abs(x) - 0.138
|
||
sign = 1.0 if x >= 0 else -1.0
|
||
if -0.02 < near < 0.07 and r < 1.0:
|
||
if r < 0.36:
|
||
v.co.x -= 0.016 * (1.0 - r / 0.36) * sign
|
||
else:
|
||
v.co.x += 0.048 * (1.0 - r) * sign
|
||
lr = math.hypot((z - 1.585) / 0.018, (y + 0.012) / 0.017)
|
||
if -0.02 < near < 0.06 and lr < 1.0:
|
||
v.co.x += 0.034 * (1.0 - lr) * sign
|
||
bm.normal_update()
|
||
bm.to_mesh(head.data)
|
||
bm.free()
|
||
|
||
# Eyeballs stay separate for readability; lids wrap the socket rim.
|
||
parts = [head]
|
||
for s in (-1.0, 1.0):
|
||
ex, ey, ez = 0.052 * s, -0.127, 1.664
|
||
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10,
|
||
rings=6, scale=(1.25, 0.62, 0.85)))
|
||
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8,
|
||
rings=5, scale=(1.0, 0.55, 1.0)))
|
||
parts.append(sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014),
|
||
skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24),
|
||
rot=(math.radians(-10), 0, 0)))
|
||
parts.append(sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012),
|
||
skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18),
|
||
rot=(math.radians(6), 0, 0)))
|
||
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.137, 1.699),
|
||
hair, rot=(0, 0.14 * s, 0.0)))
|
||
return parts
|
||
|
||
|
||
def _makehuman_head(mats, spec):
|
||
"""MakeHuman head shape plus the project's eye/mouth/brow pieces.
|
||
|
||
The AI-judged head rework (2026-11-04) picked the MakeHuman head over the
|
||
displacement shell: it keeps the nose/chin/jaw landmarks. Newer part builds
|
||
carry their own embedded eye/iris/pupil/mouth geometry, so those materials
|
||
are preserved; the older shell-only asset still gets the project's
|
||
sclera/iris/lid/brow pieces added at the MakeHuman landmarks. Blender Z-up,
|
||
face front −Y.
|
||
"""
|
||
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
|
||
white = material("eye_white", textured_solid=True)
|
||
src = SRC_DIR / "makehuman_head.glb"
|
||
before = {o.name for o in bpy.context.scene.objects}
|
||
bpy.ops.import_scene.gltf(filepath=str(src))
|
||
# Drop the imported armature / glTF neutral bone; build_character re-weights
|
||
# the head to the contract rig.
|
||
for a in list(bpy.context.scene.objects):
|
||
if a.type == "ARMATURE" and a.name not in before:
|
||
bpy.data.objects.remove(a, do_unlink=True)
|
||
# Pick the newly imported mesh, not the already-built body.
|
||
head = next((o for o in bpy.context.scene.objects
|
||
if o.type == "MESH" and o.name not in before
|
||
and o.data.materials), None)
|
||
if head is None:
|
||
raise SystemExit(f"no head mesh in {src}")
|
||
head.name = "HeadShell"
|
||
# Newer MakeHuman part builds carry their own eye/mouth/brow materials and
|
||
# geometry. Use those instead of adding the project's primitive face pieces
|
||
# a second time; only the old shell-only asset falls through to that path.
|
||
embedded = {m.name.split(".")[0] for m in head.data.materials if m}
|
||
if "eye_white" in embedded:
|
||
# Preserve the embedded iris/pupil/mouth materials; only map the
|
||
# project contract slots onto the imported head.
|
||
remap = {"skin": skin, "eye_white": white, "hair": hair}
|
||
for index, mat in enumerate(list(head.data.materials)):
|
||
if mat:
|
||
target = remap.get(mat.name.split(".")[0])
|
||
if target is not None:
|
||
head.data.materials[index] = target
|
||
# Bridge the 1.48-1.53 m neck gap between the MakeHuman cut and the
|
||
# procedurally built torso. The plug sits inside both shells, so only
|
||
# the visible band reads as neck and no seam ring reaches the surface.
|
||
plug = cyl("NeckPlug", 0.062, 0.052, 0.095, (0, 0.002, 1.500), skin,
|
||
seg=12, scale=(1.10, 1.0, 1.0))
|
||
hair_style = "buzz" if spec["layer"] == "hood" else spec["hair"]
|
||
hair_parts = _hair_from_head(head, hair_style, hair)
|
||
_trim_hidden_scalp(head, hair_style)
|
||
return [head, plug] + hair_parts
|
||
|
||
# The old decimated MakeHuman "hair" scalp slot reads as shaggy shards;
|
||
# collapse the head to one skin slot so the join does not collide with the
|
||
# body's cloth/pants slots (the project `_hair` cap provides hair later).
|
||
head.data.materials.clear()
|
||
head.data.materials.append(skin)
|
||
# Clean the ragged neck cut left by the Head-group extraction: bisect below
|
||
# the jaw and cap the hole so the head ends on a clean solid underside.
|
||
bm = bmesh.new()
|
||
bm.from_mesh(head.data)
|
||
bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||
plane_co=(0, 0, 1.50), plane_no=(0, 0, -1),
|
||
clear_outer=True)
|
||
boundary = [e for e in bm.edges if e.is_boundary]
|
||
if boundary:
|
||
bmesh.ops.holes_fill(bm, edges=boundary)
|
||
bm.to_mesh(head.data)
|
||
bm.free()
|
||
|
||
# Lids and brows weld into the head shell so the face reads as one surface;
|
||
# the sclera/iris stay separate eyeballs.
|
||
shell_parts = [head]
|
||
for s in (-1.0, 1.0):
|
||
ex, ez = 0.052 * s, 1.664
|
||
shell_parts.append(sphere("UpperLid", 0.020, (ex, -0.135, ez + 0.014),
|
||
skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24),
|
||
rot=(math.radians(-10), 0, 0)))
|
||
shell_parts.append(sphere("LowerLid", 0.017, (ex, -0.135, ez - 0.012),
|
||
skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18),
|
||
rot=(math.radians(6), 0, 0)))
|
||
shell_parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.165, 1.705),
|
||
hair, rot=(0, 0.14 * s, 0.0)))
|
||
shell = join(shell_parts, "HeadShell")
|
||
bm = bmesh.new()
|
||
bm.from_mesh(shell.data)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.008)
|
||
bm.to_mesh(shell.data)
|
||
bm.free()
|
||
|
||
parts = [shell]
|
||
for s in (-1.0, 1.0):
|
||
# The MakeHuman socket is a deep cavity (floor ≈ −0.105); keep the
|
||
# sclera recessed so it never breaks the face silhouette.
|
||
ex, ey, ez = 0.052 * s, -0.125, 1.664
|
||
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10,
|
||
rings=6, scale=(1.25, 0.62, 0.85)))
|
||
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8,
|
||
rings=5, scale=(1.0, 0.55, 1.0)))
|
||
# A subtle mouth line on top of the MakeHuman lips for readability.
|
||
parts.append(sphere("Mouth", 0.028, (0, -0.165, 1.58), acc, seg=8, rings=5,
|
||
scale=(1.0, 0.28, 0.12)))
|
||
# A skull-conforming buzz cap sized for the smaller MakeHuman cranium.
|
||
parts.append(_hair_cap("Hair", hair, 1.70, 1.56, radius=0.115,
|
||
center=(0, 0.008, 1.66), scale=(0.95, 1.04, 1.05)))
|
||
return parts
|
||
|
||
|
||
def _hands_and_shoes(mats, spec):
|
||
skin = mats["skin"]
|
||
shoe = mats["accessory"]
|
||
parts = []
|
||
w = BUILDS[spec.get("build", "average")][0]
|
||
for s in (-1.0, 1.0):
|
||
x = 0.275 * s * w
|
||
# Wider and shallower than the old ball: a palm, not a wrist knob.
|
||
parts.append(sphere("Palm", 0.046, (x, -0.012, 0.762), skin, seg=10, rings=6,
|
||
scale=(0.80, 0.62, 0.88)))
|
||
# Four bent, tapering fingers with rounded tips. The two segments keep
|
||
# the hand readable at conversation distance without adding a full
|
||
# finger skeleton; the outer fingers are slightly shorter.
|
||
for index, offset in enumerate((-0.024, -0.008, 0.008, 0.024)):
|
||
fx = x + offset * s
|
||
tip_z = (0.712, 0.701, 0.704, 0.714)[index]
|
||
radius = 0.0115 if index in (1, 2) else 0.0105
|
||
parts.append(tube("Finger", [(fx, -0.018, 0.778),
|
||
(fx, -0.030, 0.746),
|
||
(fx, -0.042, tip_z)],
|
||
radius, skin, seg=6, caps=False))
|
||
parts.append(sphere("Fingertip", radius, (fx, -0.042, tip_z), skin,
|
||
seg=6, rings=4))
|
||
parts.append(tube("Thumb", [(x - 0.010 * s, -0.018, 0.778),
|
||
(x - 0.028 * s, -0.040, 0.760),
|
||
(x - 0.034 * s, -0.055, 0.738)],
|
||
0.012, skin, seg=6, caps=False))
|
||
parts.append(sphere("ThumbTip", 0.012, (x - 0.034 * s, -0.055, 0.738), skin,
|
||
seg=6, rings=4))
|
||
parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe,
|
||
bevel=0.028, segs=1))
|
||
# Round the toe and heel so the low-poly shoe reads as footwear rather
|
||
# than a box; both stay above the rubber sole contact plane.
|
||
parts.append(sphere("ShoeToe", 0.045, (0.12 * s, -0.155, 0.045), shoe,
|
||
seg=8, rings=5, scale=(1.05, 0.80, 0.55)))
|
||
parts.append(sphere("ShoeHeel", 0.034, (0.12 * s, 0.070, 0.045), shoe,
|
||
seg=8, rings=5, scale=(1.0, 0.55, 0.55)))
|
||
parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01),
|
||
material("rubber", textured_solid=True)))
|
||
return parts
|
||
|
||
|
||
def _hand_proto(mats, spec, side=-1.0):
|
||
"""Connected hand: palm, four fingers and a thumb fused into one mesh.
|
||
|
||
The shipped hand is separate primitive parts that read as a mitten at
|
||
conversation distance; fusing the finger bases into the palm gives a
|
||
connected hand with readable finger separation. `side` is −1 (left, x<0)
|
||
or +1 (right, x>0).
|
||
"""
|
||
hs = _hands_and_shoes(mats, spec)
|
||
|
||
def _cx(o):
|
||
xs = [v.co.x for v in o.data.vertices]
|
||
return (min(xs) + max(xs)) / 2
|
||
|
||
hand = [o for o in hs
|
||
if o.name.startswith(("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip"))
|
||
and (_cx(o) < 0 if side < 0 else _cx(o) > 0)]
|
||
obj = join(hand, "HandL" if side < 0 else "HandR")
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.006)
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
return obj
|
||
|
||
|
||
def _clothing(spec, mats):
|
||
layer = spec["layer"]
|
||
cloth = mats["cloth"]
|
||
acc = mats["accessory"]
|
||
parts = []
|
||
if layer in ("coat", "dress", "responder"):
|
||
bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer]
|
||
flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer]
|
||
mat = cloth
|
||
top = 0.17 if layer != "responder" else 0.20
|
||
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1),
|
||
(top + 0.03, 0.80), (top, 0.93)], mat, seg=16,
|
||
caps=False))
|
||
# Double-sided shell: an inner copy with flipped normals, so the open
|
||
# hem does not vanish under Godot's back-face culling.
|
||
bm = bmesh.new()
|
||
bm.from_mesh(parts[-1].data)
|
||
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012)
|
||
bm.to_mesh(parts[-1].data)
|
||
bm.free()
|
||
for o in parts[-1:]:
|
||
for v in o.data.vertices: # flatten front/back
|
||
v.co.y *= 0.8
|
||
if layer in ("coat", "jacket", "guard", "responder"):
|
||
mat = cloth
|
||
parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat,
|
||
rot=(math.radians(8), 0, 0), seg=16))
|
||
# sleeve cuffs at the wrists so the arm does not end in bare skin
|
||
for s in (-1.0, 1.0):
|
||
parts.append(torus("Cuff", 0.045, 0.012, (0.275 * s, -0.008, 0.80), mat,
|
||
seg=12, minor_seg=4))
|
||
parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc))
|
||
if layer == "vest":
|
||
hv = mats["hivis"]
|
||
refl = mats["reflective"]
|
||
parts.append(lathe("Vest", [(0.162, 0.86), (0.18, 0.98), (0.19, 1.18),
|
||
(0.19, 1.30), (0.15, 1.40)], hv, seg=20, smooth=True))
|
||
for v in parts[-1].data.vertices:
|
||
v.co.y *= 0.70
|
||
for z in (0.98, 1.08):
|
||
band = torus("Band", 0.188, 0.012, (0, 0, z), refl, seg=24, minor_seg=4)
|
||
for v in band.data.vertices:
|
||
v.co.y *= 0.70
|
||
parts.append(band)
|
||
for s in (-1.0, 1.0):
|
||
parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl,
|
||
rot=(0, 0.12 * s, 0)))
|
||
if layer == "hood":
|
||
parts.append(_hood("Hood", cloth, (0, 0, 1.635)))
|
||
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth))
|
||
for s in (-1.0, 1.0):
|
||
parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38),
|
||
acc, seg=5))
|
||
if layer == "responder":
|
||
refl = mats["reflective"]
|
||
hv = mats["hivis"]
|
||
for z, r in ((0.60, 0.252), (1.16, 0.205)):
|
||
band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4)
|
||
for v in band.data.vertices:
|
||
v.co.y *= 0.8 if z < 1.0 else 0.66
|
||
parts.append(band)
|
||
for s in (-1.0, 1.0):
|
||
parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl,
|
||
rot=(0, 0, 0), seg=14, minor_seg=4))
|
||
parts.append(box("Boot", (0.12, 0.27, 0.22), (0.12 * s, -0.04, 0.11),
|
||
mats["rubber"], bevel=0.03, segs=2))
|
||
parts.append(sphere("BootToe", 0.055, (0.12 * s, -0.155, 0.075),
|
||
mats["rubber"], seg=8, rings=5,
|
||
scale=(1.0, 0.80, 0.55)))
|
||
parts.append(sphere("BootHeel", 0.045, (0.12 * s, 0.080, 0.055),
|
||
mats["rubber"], seg=8, rings=5,
|
||
scale=(1.0, 0.55, 0.50)))
|
||
if layer == "guard":
|
||
belt = torus("Belt", 0.168, 0.022, (0, 0, 0.86), mats["leather"], seg=24,
|
||
minor_seg=4, scale=(1.0, 0.72, 1.4))
|
||
parts.append(belt)
|
||
parts.append(box("Buckle", (0.05, 0.012, 0.035), (0, -0.125, 0.86), mats["steel"]))
|
||
parts.append(box("Badge", (0.045, 0.01, 0.055), (0.085, -0.128, 1.27), mats["patch"],
|
||
bevel=0.006))
|
||
for s in (-1.0, 1.0):
|
||
parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28),
|
||
mats["patch"], bevel=0.005))
|
||
parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84),
|
||
mats["leather"]))
|
||
parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30),
|
||
mats["plastic_black"], bevel=0.008))
|
||
return parts
|
||
|
||
|
||
def build_character(name, head="face"):
|
||
"""Build one character. `head` picks the head source: "face" (current
|
||
primitive face), "proto" (connected-shell displacement prototype) or
|
||
"mh" (decimated MakeHuman head + project eyes/brows/mouth)."""
|
||
spec = dict(CHARACTERS[name])
|
||
if name == "npc_hoodie":
|
||
spec["beard"] = True
|
||
mats = {slot: material(slot) for slot in SLOTS}
|
||
for extra in ("hivis", "reflective", "leather",
|
||
"steel", "patch", "plastic_black", "rubber", "helmet"):
|
||
mats[extra] = material(extra, textured_solid=True)
|
||
layout = _bone_layout()
|
||
|
||
body = _skin_body(spec)
|
||
body_mats = [mats["skin"], mats["cloth"], mats["pants"]]
|
||
_assign_body_slots(body, spec, body_mats)
|
||
_auto_weights(body, layout)
|
||
|
||
if head == "mh":
|
||
head_parts = _makehuman_head(mats, spec)
|
||
elif head == "proto":
|
||
head_parts = _head_proto(mats, spec)
|
||
else:
|
||
head_parts = _face(mats, spec)
|
||
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
|
||
if head != "mh":
|
||
head_parts += _hair(hair_style, mats["hair"])
|
||
head = join(head_parts, "head")
|
||
_auto_weights(head, layout, rigid=lambda p: "Head")
|
||
|
||
hs = _hands_and_shoes(mats, spec)
|
||
if spec["layer"] == "responder": # boots replace the shoes
|
||
hs = [o for o in hs if not o.name.startswith(("Shoe", "Sole"))] or hs
|
||
limbs = join(hs, "limbs")
|
||
|
||
def limb_bone(p):
|
||
side = "L" if p.x < 0 else "R"
|
||
return f"Hand{side}" if p.z > 0.5 else f"Knee{side}"
|
||
_auto_weights(limbs, layout, rigid=limb_bone)
|
||
|
||
rest = [body, head, limbs]
|
||
clothes = _clothing(spec, mats)
|
||
if clothes:
|
||
cl = join(clothes, "clothing")
|
||
|
||
def cloth_bone(p):
|
||
if p.z > 1.42:
|
||
return "Head" if p.z > 1.55 else "Neck"
|
||
side = "L" if p.x < 0 else "R"
|
||
if abs(p.x) > 0.26 and p.z > 0.8:
|
||
# Sleeve: the cuff follows the hand, the upper sleeve the elbow,
|
||
# so the sleeve does not separate from the hand at the wrist.
|
||
return f"Hand{side}" if p.z < 1.06 else f"Elbow{side}"
|
||
if p.z < 0.25:
|
||
return f"Knee{side}"
|
||
return None
|
||
_auto_weights(cl, layout, rigid=cloth_bone)
|
||
rest.append(cl)
|
||
|
||
mesh = join(rest, "npc_base")
|
||
# The fuller neck/shoulder skin graph can leave a few collinear triangles
|
||
# after the decimate step. Remove them before UV/export; Godot warns about
|
||
# zero-area faces during LOD generation and `audit` rejects them.
|
||
bm = bmesh.new()
|
||
bm.from_mesh(mesh.data)
|
||
bmesh.ops.dissolve_degenerate(bm, dist=2e-4, edges=bm.edges)
|
||
# Skin-modifier branch cleanup can still leave a handful of zero-area
|
||
# faces; Godot's LOD generator treats them as malformed. Triangulate first
|
||
# because a degenerate quad can become a zero-area triangle on export, then
|
||
# drop the tiny faces explicitly.
|
||
bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY",
|
||
ngon_method="BEAUTY")
|
||
bad = [face for face in bm.faces if face.calc_area() < 1e-10]
|
||
if bad:
|
||
bmesh.ops.delete(bm, geom=bad, context="FACES_ONLY")
|
||
bm.to_mesh(mesh.data)
|
||
bm.free()
|
||
# Every contract slot must exist even if a variant does not paint with it.
|
||
present = {m.name for m in mesh.data.materials if m}
|
||
for slot in SLOTS:
|
||
if slot not in present:
|
||
mesh.data.materials.append(mats[slot])
|
||
# Fewer, larger islands keep the seam-split vertex count inside the budget.
|
||
uv_unwrap(mesh, angle=80.0)
|
||
|
||
arm_data = bpy.data.armatures.new("Armature")
|
||
arm = bpy.data.objects.new("Armature", arm_data)
|
||
bpy.context.scene.collection.objects.link(arm)
|
||
bpy.context.view_layer.objects.active = arm
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
arm.select_set(True)
|
||
bpy.ops.object.mode_set(mode="EDIT")
|
||
eb = arm_data.edit_bones
|
||
for bone_name, (h, t, parent) in layout.items():
|
||
bone = eb.new(bone_name)
|
||
bone.head, bone.tail = h, t
|
||
if parent:
|
||
bone.parent = eb[parent]
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
mesh.parent = arm
|
||
mod = mesh.modifiers.new("Armature", "ARMATURE")
|
||
mod.object = arm
|
||
return [arm, mesh]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Quaternius roster (ADR-0017 / ADR-0018)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# The CC0 sources live under `blender_src/quaternius/` (gitignored for Godot via
|
||
# the parent `.gdignore`) and are build inputs, not runtime assets. Every source
|
||
# is 4 skinned meshes on the same 62-joint armature; the build joins them
|
||
# **without welding** (a weld corrupts the skin weights), renames the upstream
|
||
# materials onto the five tint slots, unwraps the degenerate source UVs and bakes
|
||
# the arm-down rest. See docs/npc-model-sourcing.md for the measurements.
|
||
|
||
QUATERNIUS_SRC = SRC_DIR / "quaternius"
|
||
|
||
# Material -> contract slot per source. `"keep"` leaves the authored colour
|
||
# (face, hair detail and hivis fabrics); anything not listed is kept too, so a
|
||
# new upstream material shows up as an extra fixed-colour slot instead of
|
||
# silently disappearing. The maps were derived from the per-material height bands
|
||
# measured in docs/npc-model-sourcing.md.
|
||
QUATERNIUS_SOURCES = {
|
||
"casual": {
|
||
"file": "Casual.glb",
|
||
"slots": {"Skin": "skin", "White": "cloth", "Orange": "pants",
|
||
"Grey": "accessory", "Hair_Blond": "hair",
|
||
"Brown": "keep", "Hair_Brown": "keep"},
|
||
"notes": "white tee + orange trousers + grey shoes; blond hair",
|
||
},
|
||
"hoodie": {
|
||
"file": "Casual_Hoodie.glb",
|
||
"slots": {"Skin": "skin", "Purple": "cloth", "LightBlue": "pants",
|
||
"Hair": "hair", "White": "accessory",
|
||
"Eye": "keep", "Eyebrows": "keep"},
|
||
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
|
||
},
|
||
"suit": {
|
||
"file": "Suit.glb",
|
||
"slots": {"Skin": "skin", "Suit": "cloth", "Suit_legs": "pants",
|
||
"Black": "accessory",
|
||
"Hair": "hair",
|
||
"Tie": "keep", "White": "keep", "Eye": "keep", "Eyebrows": "keep"},
|
||
"notes": "formal suit: jacket+trousers one fabric, white shirt and tie as fixed detail",
|
||
},
|
||
"worker": {
|
||
"file": "Worker.glb",
|
||
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
|
||
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
|
||
# The modular Worker has no torso under the vest and no crown
|
||
# under the hard hat, so both must stay in the mesh: the vest
|
||
# becomes the role's shirt and only the hat/crown part of the
|
||
# yellow material stays, tinted as hair. The stripes below the
|
||
# collar follow the shirt.
|
||
"Worker_Vest": "cloth",
|
||
"Worker_Yellow": {"split": True, "z": 1.65,
|
||
"above": "hair", "below": "cloth"},
|
||
# The bar, the oversized eyes and the hard hat are replaced by
|
||
# `_transplant_worker_head()`; those materials are gone by here.
|
||
"Eyebrows": "keep",
|
||
"Brows": "keep",
|
||
"Eye": "keep", "Moustache": "keep"},
|
||
"donor_head": True,
|
||
"notes": "work shirt (vest geometry) + trousers + hard hat/crown",
|
||
},
|
||
}
|
||
|
||
|
||
## The modular Worker head has three defects the roster cannot live with, and no
|
||
## other upstream head has parts that fit it directly, because the three heads are
|
||
## different meshes at different detail levels. `casual_hoodie` is the closest
|
||
## sibling, so its brows, eyes and hair are transplanted onto the Worker head:
|
||
##
|
||
## - `Eyebrows` is one continuous bar across the face (reads as a unibrow);
|
||
## - `Eye` is two oversized quads that nearly touch (the same dark mass);
|
||
## - `Worker_Yellow` above the collar is a hard hat that doubles as the skull
|
||
## crown, so a guard or responder looks like it is wearing a helmet to bed.
|
||
##
|
||
## This is the only place the build removes geometry. Everything else is
|
||
## remapped, because the Worker's vest *is* its torso and its hat *is* its crown.
|
||
WORKER_HEAD_DONOR = {
|
||
"source": "Casual_Hoodie.glb",
|
||
# donor material -> contract slot name.
|
||
"parts": {"Eyebrows": "Brows", "Eye": "Eye", "Hair": "hair"},
|
||
# The three upstream heads carry the same-sized eye quads (x +-0.057), but the
|
||
# Worker's face is flatter, so unchecked they sit proud of the skin and merge
|
||
# into one dark bar -- the "unibrow" the owner saw. Set them back into the
|
||
# socket so the skin crops their edges, and lift the brows clear of them.
|
||
# Brows lift clear of the eye line; the eyes are pushed 4 mm forward so they
|
||
# clear the Worker's flatter face instead of sinking into it.
|
||
"offset": {"Brows": (0.0, 0.0, 0.020), "Eye": (0.0, -0.004, 0.0)},
|
||
# Our material -> the height below which its faces go away. `Eyebrows` is the
|
||
# bar *and* the forehead shell, so only the bar is cut: removing the whole
|
||
# material opened the skull and the interior rendered as a black band across
|
||
# the brows. `Eye` goes entirely (its quads read as one dark mass).
|
||
# The Worker's own eye quads sit inside the head and never show (the owner
|
||
# reported the Worker roles as eyeless), so they are replaced by the hoodie's
|
||
# pair, which is the same geometry the other eight characters use.
|
||
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
|
||
"bone": "Head",
|
||
}
|
||
|
||
|
||
def _purge_unused_materials(obj):
|
||
"""Drop material slots no face uses, so a donor can take the freed name.
|
||
|
||
`IDMaterials.pop()` repoints the polygons that used the slot, which corrupted
|
||
the mesh the first time this was tried; rebuilding the list from the per-face
|
||
names is explicit and leaves no dangling index.
|
||
"""
|
||
names = []
|
||
for poly in obj.data.polygons:
|
||
material = obj.data.materials[poly.material_index]
|
||
names.append(material.name if material else "")
|
||
kept, index_of = [], {}
|
||
for name in names:
|
||
if name in index_of:
|
||
continue
|
||
source = bpy.data.materials.get(name)
|
||
if source is None:
|
||
continue
|
||
index_of[name] = len(kept)
|
||
kept.append(source)
|
||
if len(kept) == len(obj.data.materials):
|
||
return
|
||
obj.data.materials.clear()
|
||
for material in kept:
|
||
obj.data.materials.append(material)
|
||
for poly, name in zip(obj.data.polygons, names):
|
||
poly.material_index = index_of.get(name, 0)
|
||
# A removed slot leaves its datablock behind with zero users, which still owns
|
||
# the name and forces the donor's material to `Eye.001`.
|
||
for material in [m for m in bpy.data.materials if m.users == 0]:
|
||
bpy.data.materials.remove(material)
|
||
|
||
|
||
## Outfit/hair parts cut from the modular pack (see `tools/quaternius_parts.py`).
|
||
## A part is one skinned mesh on the 62-joint rig; the builder joins it onto the base
|
||
## body, which already owns the skeleton, so the part's weights are kept as-is.
|
||
QUATERNIUS_PARTS_DIR = SRC_DIR / "quaternius" / "parts"
|
||
|
||
|
||
def _join_quaternius_parts(obj, part_names):
|
||
"""Join the named part files onto the body. No-ops until the pack is intaken."""
|
||
joined = []
|
||
for name in part_names:
|
||
path = QUATERNIUS_PARTS_DIR / f"{name}.glb"
|
||
if not path.is_file():
|
||
print(f" part '{name}' not found in {QUATERNIUS_PARTS_DIR.relative_to(ROOT)}; skipped")
|
||
continue
|
||
before = set(o.name for o in bpy.context.scene.objects)
|
||
bpy.ops.import_scene.gltf(filepath=str(path))
|
||
added = [o for o in bpy.context.scene.objects if o.name not in before]
|
||
meshes = [o for o in added if o.type == "MESH"]
|
||
if not meshes:
|
||
print(f" part '{name}' imported no mesh; skipped")
|
||
continue
|
||
# The part's own armature is redundant: the base body already has the skeleton,
|
||
# and joining two armatures would duplicate every bone.
|
||
for obj in added:
|
||
if obj.type == "ARMATURE":
|
||
bpy.data.objects.remove(obj, do_unlink=True)
|
||
elif obj.type == "MESH" and obj not in meshes:
|
||
bpy.data.objects.remove(obj, do_unlink=True)
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
for mesh in meshes:
|
||
if mesh.name in bpy.data.objects:
|
||
mesh.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.join()
|
||
joined.append(name)
|
||
if joined:
|
||
print(f" joined parts: {', '.join(joined)}")
|
||
|
||
|
||
def _add_quaternius_mouth(obj):
|
||
"""Give every Quaternius head a readable mouth.
|
||
|
||
None of the three upstream heads carries a mouth material: the face is skin
|
||
only, so at gameplay distance the characters read as brow-and-eye masks. A
|
||
small flat lip block is placed on the measured skin surface, which is why it
|
||
works for all three bases without per-source tuning.
|
||
"""
|
||
# This runs before the slot map, so the material is still the upstream `Skin`.
|
||
skin = next((i for i, m in enumerate(obj.data.materials)
|
||
if m is not None and _base_name(m.name).lower() == "skin"), None)
|
||
if skin is None:
|
||
return
|
||
band = []
|
||
for poly in obj.data.polygons:
|
||
if poly.material_index != skin:
|
||
continue
|
||
for index in poly.vertices:
|
||
co = obj.data.vertices[index].co
|
||
if 1.600 <= co.z <= 1.632 and abs(co.x) <= 0.035:
|
||
band.append(co.y)
|
||
if not band:
|
||
return
|
||
front = min(band) # Blender -Y is the face; the lip sits on the surface
|
||
height = 1.616
|
||
material = bpy.data.materials.new("mouth")
|
||
material.use_nodes = True
|
||
bsdf = material.node_tree.nodes.get("Principled BSDF")
|
||
if bsdf is not None:
|
||
# Blender node colours are linear; 0.16 linear reads as mauve once
|
||
# Tonemapped in Godot, so the lip is set well below the skin value.
|
||
bsdf.inputs["Base Color"].default_value = (0.055, 0.018, 0.02, 1.0)
|
||
bsdf.inputs["Roughness"].default_value = 0.6
|
||
else:
|
||
print(" mouth: no Principled node, colour left at default")
|
||
material.diffuse_color = (0.055, 0.018, 0.02, 1.0)
|
||
bm = bmesh.new()
|
||
bmesh.ops.create_cube(bm, size=1.0)
|
||
for vert in bm.verts:
|
||
vert.co.x *= 0.058
|
||
vert.co.y *= 0.012
|
||
vert.co.z *= 0.011
|
||
vert.co += Vector((0.0, front + 0.003, height))
|
||
mouth = bpy.data.objects.new("Mouth", bpy.data.meshes.new("Mouth"))
|
||
bm.to_mesh(mouth.data)
|
||
bm.free()
|
||
mouth.data.materials.append(material)
|
||
bpy.context.scene.collection.objects.link(mouth)
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
mouth.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.join()
|
||
group = obj.vertex_groups.get("Head") or obj.vertex_groups.new(name="Head")
|
||
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
|
||
print(f" mouth placed at y {front + 0.003:+.3f} z {height:.3f}")
|
||
|
||
|
||
def _add_quaternius_eye_whites(obj):
|
||
"""Give the eye quads a sclera and a pupil.
|
||
|
||
Every base ships one dark quad per eye, so the face reads as two dark
|
||
rectangles at portrait distance. The quads are rewired instead of textured: the
|
||
existing faces become the sclera (light), and a shrunken copy in front of each
|
||
becomes the pupil (dark). Two draw calls per head, no new UV work.
|
||
"""
|
||
eye = next((i for i, m in enumerate(obj.data.materials)
|
||
if m is not None and _base_name(m.name).lower() == "eye"), None)
|
||
if eye is None:
|
||
return
|
||
sclera = obj.data.materials[eye]
|
||
sclera.name = "sclera"
|
||
sclera.use_nodes = True
|
||
bsdf = sclera.node_tree.nodes.get("Principled BSDF")
|
||
if bsdf is not None:
|
||
# Linear values: 0.62 reads as an off-white sclera, not a glowing one.
|
||
bsdf.inputs["Base Color"].default_value = (0.62, 0.60, 0.55, 1.0)
|
||
bsdf.inputs["Roughness"].default_value = 0.45
|
||
sclera.use_backface_culling = False
|
||
pupil = bpy.data.materials.new("pupil")
|
||
pupil.use_nodes = True
|
||
pupil_bsdf = pupil.node_tree.nodes.get("Principled BSDF")
|
||
if pupil_bsdf is not None:
|
||
pupil_bsdf.inputs["Base Color"].default_value = (0.012, 0.010, 0.014, 1.0)
|
||
pupil_bsdf.inputs["Roughness"].default_value = 0.35
|
||
# A single-sided pupil can vanish depending on the exported winding; the extra
|
||
# draw cost on two 0.02 m quads is irrelevant.
|
||
pupil.use_backface_culling = False
|
||
|
||
# Collect the eye faces before adding anything, then inset a pupil per face.
|
||
faces = [p for p in obj.data.polygons if p.material_index == eye]
|
||
if not faces:
|
||
return
|
||
obj.data.materials.append(pupil)
|
||
pupil_index = len(obj.data.materials) - 1
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bm.faces.ensure_lookup_table()
|
||
# Hold face *references*: adding vertices invalidates the index tables.
|
||
targets = [bm.faces[i] for i in range(len(bm.faces))
|
||
if bm.faces[i].material_index == eye]
|
||
# Shrink toward the *eye* centre, not each triangle's own centre: the quad is two
|
||
# triangles, and per-face insets produced two offset wedges instead of a pupil.
|
||
eye_centre = {}
|
||
for face in targets:
|
||
side = 1 if face.calc_center_median().x >= 0.0 else -1
|
||
eye_centre.setdefault(side, []).append(face.calc_center_median())
|
||
side_centre = {side: sum(points, Vector()) / len(points)
|
||
for side, points in eye_centre.items()}
|
||
new_faces = []
|
||
for face in targets:
|
||
centre = side_centre[1 if face.calc_center_median().x >= 0.0 else -1]
|
||
# Push the pupil in front of the sclera so it cannot z-fight, then shrink it
|
||
# about its own centre. Blender -Y is the face, so "in front" is -Y.
|
||
# Push the pupil out along the sclera's own normal: offsetting in world -Y
|
||
# only worked while the eye plane was axis-aligned, and this one is tilted.
|
||
nudge = face.normal.normalized() * 0.0032
|
||
verts = []
|
||
for vert in face.verts:
|
||
offset = vert.co - centre
|
||
verts.append(bm.verts.new(centre
|
||
+ Vector((offset.x * 0.74,
|
||
offset.y * 0.74,
|
||
offset.z * 0.70))
|
||
+ nudge))
|
||
new_face = bm.faces.new(verts)
|
||
new_face.material_index = pupil_index
|
||
new_faces.append(new_face)
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
print(f" eye whites: {len(faces)} sclera quads, {len(new_faces)} pupil quads")
|
||
|
||
|
||
## Hair part library (owner, 2026-11-06): the roster should not read as one hairstyle
|
||
## in twelve colours. Each base ships one hair shell; these variants reshape it in
|
||
## place, so a role picks a silhouette without a second source file. `anchor` is the
|
||
## end of the shell that stays put: squashing toward the hairline gives a buzz cut,
|
||
## stretching from the crown downwards gives long hair.
|
||
QUATERNIUS_HAIR_PARTS = {
|
||
"short": {"scale": (1.00, 1.00, 1.00), "anchor": "bottom"},
|
||
"buzz": {"scale": (0.95, 0.95, 0.68), "anchor": "bottom"},
|
||
"long": {"scale": (1.04, 1.06, 1.34), "anchor": "top"},
|
||
"bald": {"remove": True},
|
||
}
|
||
# `bald` only works where the skin closes the skull: the modular Worker's head stops
|
||
# at the hairline, so removing its hair would open the crown.
|
||
HAIR_BALD_SAFE_BASES = ("casual", "hoodie")
|
||
|
||
|
||
def _apply_hair_part(obj, style, base):
|
||
"""Reshape (or remove) the hair shell for one role."""
|
||
part = QUATERNIUS_HAIR_PARTS.get(style)
|
||
if part is None:
|
||
return
|
||
hair = next((i for i, m in enumerate(obj.data.materials)
|
||
if m is not None and _base_name(m.name) == "hair"), None)
|
||
if hair is None:
|
||
return
|
||
if part.get("remove"):
|
||
if base not in HAIR_BALD_SAFE_BASES:
|
||
print(f" hair: '{style}' skipped on the {base} base (would open the crown)")
|
||
return
|
||
doomed = [p.index for p in obj.data.polygons if p.material_index == hair]
|
||
if not doomed:
|
||
return
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bm.faces.ensure_lookup_table()
|
||
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
|
||
loose = [v for v in bm.verts if not v.link_faces]
|
||
if loose:
|
||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
print(f" hair: {style} ({len(doomed)} faces removed)")
|
||
return
|
||
|
||
verts = {v for poly in obj.data.polygons if poly.material_index == hair
|
||
for v in (obj.data.vertices[i] for i in poly.vertices)}
|
||
if not verts:
|
||
return
|
||
zs = [v.co.z for v in verts]
|
||
anchor_z = min(zs) if part["anchor"] == "bottom" else max(zs)
|
||
sx, sy, sz = part["scale"]
|
||
for v in verts:
|
||
v.co.x *= sx
|
||
v.co.y *= sy
|
||
v.co.z = anchor_z + (v.co.z - anchor_z) * sz
|
||
print(f" hair: {style} scale {sx}/{sy}/{sz} about {part['anchor']}")
|
||
|
||
|
||
def _base_name(name):
|
||
"""Strip Blender's `.001` duplicate suffix."""
|
||
head, sep, tail = name.rpartition(".")
|
||
return head if sep and tail.isdigit() else name
|
||
|
||
|
||
def _transplant_worker_head(obj):
|
||
"""Replace the Worker brow bar, eyes and hard hat with the hoodie's head gear."""
|
||
spec = WORKER_HEAD_DONOR
|
||
# Resolve the targets before anything is deleted or imported: the material
|
||
# objects are stable through the join, names are not.
|
||
targets = {}
|
||
for name in spec["remove"]:
|
||
material = next((m for m in obj.data.materials if m and m.name == name), None)
|
||
if material is not None:
|
||
targets[material.name] = material
|
||
doomed = []
|
||
for poly in obj.data.polygons:
|
||
material = obj.data.materials[poly.material_index]
|
||
if material is None or material.name not in targets:
|
||
continue
|
||
limit = spec["remove"].get(material.name)
|
||
if limit is not None and _face_centroid(obj, poly)[2] <= float(limit):
|
||
continue
|
||
doomed.append(poly.index)
|
||
if doomed:
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bm.faces.ensure_lookup_table()
|
||
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
|
||
loose = [v for v in bm.verts if not v.link_faces]
|
||
if loose:
|
||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
print(f" removed {len(doomed)} Worker head faces")
|
||
_purge_unused_materials(obj)
|
||
|
||
donor_path = QUATERNIUS_SRC / spec["source"]
|
||
if not donor_path.is_file():
|
||
print(f" head donor {donor_path.name} missing, Worker head left bare")
|
||
return
|
||
before = set(o.name for o in bpy.context.scene.objects)
|
||
bpy.ops.import_scene.gltf(filepath=str(donor_path))
|
||
added = [o for o in bpy.context.scene.objects if o.name not in before]
|
||
donor = None
|
||
for candidate in added:
|
||
if candidate.type != "MESH":
|
||
continue
|
||
wanted = [i for i, m in enumerate(candidate.data.materials)
|
||
if m is not None and _base_name(m.name) in spec["parts"]]
|
||
if not wanted:
|
||
continue
|
||
bm = bmesh.new()
|
||
bm.from_mesh(candidate.data)
|
||
bm.faces.ensure_lookup_table()
|
||
drop = [f for f in bm.faces if f.material_index not in wanted]
|
||
if drop:
|
||
bmesh.ops.delete(bm, geom=drop, context="FACES")
|
||
loose = [v for v in bm.verts if not v.link_faces]
|
||
if loose:
|
||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||
if not bm.faces:
|
||
bm.free()
|
||
continue
|
||
bm.to_mesh(candidate.data)
|
||
bm.free()
|
||
# Collect the kept materials, then remap the faces: clearing the material
|
||
# list resets every polygon to slot 0.
|
||
kept = []
|
||
remap = {}
|
||
for index, m in enumerate(candidate.data.materials):
|
||
if m is None:
|
||
continue
|
||
slot = spec["parts"].get(_base_name(m.name))
|
||
if slot is None:
|
||
continue
|
||
m.name = slot
|
||
remap[index] = len(kept)
|
||
kept.append(m)
|
||
for slot_name, rule in spec.get("narrow", {}).items():
|
||
local = [i for i, m in enumerate(candidate.data.materials)
|
||
if m is not None and _base_name(m.name) == slot_name]
|
||
if not local:
|
||
continue
|
||
bm = bmesh.new()
|
||
bm.from_mesh(candidate.data)
|
||
bm.faces.ensure_lookup_table()
|
||
moved = {v for f in bm.faces if f.material_index in local for v in f.verts}
|
||
centre = float(rule["centre"])
|
||
factor = float(rule["scale"])
|
||
for v in moved:
|
||
box = centre if v.co.x >= 0.0 else -centre
|
||
v.co.x = box + (v.co.x - box) * factor
|
||
bm.to_mesh(candidate.data)
|
||
bm.free()
|
||
|
||
for slot_name, offset in spec.get("offset", {}).items():
|
||
local = [i for i, m in enumerate(candidate.data.materials)
|
||
if m is not None and _base_name(m.name) == slot_name]
|
||
if not local:
|
||
continue
|
||
bm = bmesh.new()
|
||
bm.from_mesh(candidate.data)
|
||
bm.faces.ensure_lookup_table()
|
||
moved = {v for f in bm.faces if f.material_index in local for v in f.verts}
|
||
delta = Vector(offset)
|
||
for v in moved:
|
||
v.co += delta
|
||
bm.to_mesh(candidate.data)
|
||
bm.free()
|
||
face_slots = [remap.get(poly.material_index, 0)
|
||
for poly in candidate.data.polygons]
|
||
candidate.data.materials.clear()
|
||
for m in kept:
|
||
candidate.data.materials.append(m)
|
||
for poly, slot in zip(candidate.data.polygons, face_slots):
|
||
poly.material_index = slot
|
||
donor = candidate
|
||
break
|
||
for extra in added:
|
||
if extra is not donor:
|
||
bpy.data.objects.remove(extra, do_unlink=True)
|
||
if donor is None:
|
||
print(" head donor not found, Worker head left bare")
|
||
return
|
||
donor.parent = None
|
||
donor.matrix_world = Matrix.Identity(4)
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
donor.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.join()
|
||
# The join can leave `Eye.001` next to a zero-user `Eye`; the contract slot
|
||
# names must be exact because the runtime tints by name.
|
||
slot_names = {m.name for m in obj.data.materials if m is not None}
|
||
for material in obj.data.materials:
|
||
if material is None:
|
||
continue
|
||
base = _base_name(material.name)
|
||
if base != material.name and base not in slot_names:
|
||
slot_names.discard(material.name)
|
||
material.name = base
|
||
slot_names.add(base)
|
||
group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
|
||
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
|
||
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "
|
||
f"from {spec['source']}")
|
||
|
||
|
||
def _face_centroid(obj, poly):
|
||
co = [obj.data.vertices[v].co for v in poly.vertices]
|
||
n = max(len(co), 1)
|
||
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
|
||
sum(c.z for c in co) / n)
|
||
|
||
|
||
def _face_centroid(obj, poly):
|
||
co = [obj.data.vertices[v].co for v in poly.vertices]
|
||
n = max(len(co), 1)
|
||
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
|
||
sum(c.z for c in co) / n)
|
||
|
||
|
||
def _carve_quaternius_faces(obj, slot_map, by_name):
|
||
"""Delete the faces a rule marks as `carve`.
|
||
|
||
The modular Worker's `Eyebrows` material is one continuous bar across the
|
||
face, which reads as a unibrow, and no other upstream head has brows that fit
|
||
this head (the three heads are different meshes). Cutting the middle out of
|
||
the bar is the cheapest honest fix. This is the only deletion in the
|
||
pipeline: everything else is remapped, because the Worker's vest *is* its
|
||
torso and its hard hat *is* the crown of its skull.
|
||
"""
|
||
doomed = []
|
||
for poly in obj.data.polygons:
|
||
material = obj.data.materials[poly.material_index]
|
||
rule = slot_map.get(material.name) if material else None
|
||
if not isinstance(rule, dict) or "carve" not in rule:
|
||
continue
|
||
x, _y, z = _face_centroid(obj, poly)
|
||
if "z" in rule and z >= float(rule["z"]):
|
||
continue
|
||
if abs(x) < float(rule["carve"].get("abs_x_below", 0.0)):
|
||
doomed.append(poly.index)
|
||
if not doomed:
|
||
return
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
bm.faces.ensure_lookup_table()
|
||
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
|
||
loose = [v for v in bm.verts if not v.link_faces]
|
||
if loose:
|
||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||
bm.to_mesh(obj.data)
|
||
bm.free()
|
||
print(f" carved {len(doomed)} brow faces")
|
||
|
||
|
||
def _quaternius_slot_map(obj, slot_map):
|
||
"""Repoint the upstream materials onto the contract slots.
|
||
|
||
Nothing is deleted. The upstream Worker is modular: `Worker_Vest` *is* the
|
||
torso and `Worker_Yellow` above z 1.65 *is* the crown of the skull, so
|
||
dropping either leaves real holes in the character (that is what shipped in
|
||
the first 0.9.9 build). Instead every polygon is repointed at a slot, and a
|
||
rule can split one upstream material by height:
|
||
|
||
{"split": True, "z": 1.65, "above": "hair", "below": "keep"}
|
||
|
||
Several upstream materials can share a slot (trousers + patches), so the
|
||
polygons are repointed at one Blender material instead of relying on a
|
||
rename that Blender would de-duplicate to `pants.001`.
|
||
"""
|
||
materials = [m for m in obj.data.materials if m is not None]
|
||
by_name = {m.name: m for m in materials}
|
||
# Read the source state before touching the material list: clearing it resets
|
||
# every polygon to slot 0.
|
||
source_names = []
|
||
centroids = []
|
||
for poly in obj.data.polygons:
|
||
src = materials[poly.material_index] if poly.material_index < len(materials) else None
|
||
source_names.append(src.name if src else "")
|
||
co = [obj.data.vertices[v].co for v in poly.vertices]
|
||
centroids.append(sum(c.z for c in co) / max(len(co), 1))
|
||
|
||
targets = []
|
||
|
||
def target_index(key, source):
|
||
if key in index_of_slot:
|
||
return index_of_slot[key]
|
||
if key[0] == "keep":
|
||
mat = source
|
||
else:
|
||
# A copy carries the authored colour into the slot, and keeps the
|
||
# source material untouched for the other slots that share it.
|
||
mat = source.copy()
|
||
mat.name = key[1]
|
||
index_of_slot[key] = len(targets)
|
||
targets.append(mat)
|
||
return index_of_slot[key]
|
||
|
||
index_of_slot = {}
|
||
resolved = []
|
||
for name, z in zip(source_names, centroids):
|
||
# Imported parts carry `.001` duplicates of the base material; fall back to the
|
||
# base name so those still reach their slot instead of staying untinted.
|
||
rule = slot_map.get(name)
|
||
if rule is None:
|
||
rule = slot_map.get(_base_name(name), "keep")
|
||
if isinstance(rule, dict) and rule.get("split"):
|
||
slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
|
||
key = ("keep", name) if slot == "keep" else ("slot", slot)
|
||
elif rule == "keep" or rule is None:
|
||
key = ("keep", name)
|
||
else:
|
||
key = ("slot", rule)
|
||
resolved.append(target_index(key, by_name[name]) if name in by_name else 0)
|
||
|
||
obj.data.materials.clear()
|
||
for m in targets:
|
||
obj.data.materials.append(m)
|
||
for poly, index in zip(obj.data.polygons, resolved):
|
||
poly.material_index = index
|
||
|
||
|
||
def _bake_quaternius_rest(arm, mesh):
|
||
"""Bake the T-pose into an arm-down rest pose.
|
||
|
||
This runs on the re-imported T-pose export, not on the raw upstream file.
|
||
The order matters: pose, read the posed matrices, apply the Armature
|
||
modifier, **reset the pose to identity**, write the rest matrices, re-add the
|
||
modifier. Skipping the reset double-applies the arm-down (mesh width ±0.53 m
|
||
instead of ±0.23 m); `bpy.ops.object.armature_apply()` in Blender 5.2
|
||
deforms the mesh without baking the rest and is not usable here.
|
||
"""
|
||
bpy.context.view_layer.objects.active = arm
|
||
bpy.ops.object.mode_set(mode="POSE")
|
||
for side in ("L", "R"):
|
||
pose_bone = arm.pose.bones.get("UpperArm." + side)
|
||
if pose_bone is None:
|
||
continue
|
||
pose_bone.rotation_mode = "XYZ"
|
||
# Local X swings the arm in the frontal plane. This is only symmetric on
|
||
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
|
||
# the build always round-trips through `_quaternius_tpose_glb()` first.
|
||
# 83 instead of 90 degrees leaves the arm 7 degrees out from vertical: on
|
||
# the narrow `casual` base a perfectly vertical arm intersects the hip
|
||
# and torso (owner report: dress, jacket, crowd_a).
|
||
pose_bone.rotation_euler = (math.radians(-83.0), 0, 0)
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
bpy.context.view_layer.update()
|
||
for side in ("L", "R"):
|
||
wrist = arm.pose.bones.get("Wrist." + side)
|
||
if wrist is not None:
|
||
head = arm.matrix_world @ wrist.head
|
||
print(f" wrist.{side} at x={head.x:+.3f} y={head.y:+.3f} z={head.z:+.3f}")
|
||
|
||
fingers = ("Thumb", "Index", "Middle", "Ring", "Pinky")
|
||
posed = {}
|
||
for side in ("L", "R"):
|
||
for bone in arm.data.bones:
|
||
name = bone.name
|
||
if not name.endswith(side):
|
||
continue
|
||
if name.startswith(("UpperArm", "LowerArm", "Wrist")) or name.startswith(fingers):
|
||
posed[name] = arm.pose.bones[name].matrix.copy()
|
||
|
||
arm_modifier = next(m for m in mesh.modifiers if m.type == "ARMATURE")
|
||
bpy.context.view_layer.objects.active = mesh
|
||
bpy.ops.object.modifier_apply(modifier=arm_modifier.name)
|
||
|
||
bpy.context.view_layer.objects.active = arm
|
||
bpy.ops.object.mode_set(mode="POSE")
|
||
for pose_bone in arm.pose.bones:
|
||
pose_bone.matrix_basis.identity()
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
bpy.context.view_layer.update()
|
||
|
||
bpy.context.view_layer.objects.active = arm
|
||
bpy.ops.object.mode_set(mode="EDIT")
|
||
for name, matrix in posed.items():
|
||
arm.data.edit_bones[name].matrix = matrix
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
bpy.context.view_layer.update()
|
||
|
||
bpy.context.view_layer.objects.active = mesh
|
||
modifier = mesh.modifiers.new("Armature", "ARMATURE")
|
||
modifier.object = arm
|
||
bpy.context.view_layer.update()
|
||
evaluated = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||
xs = [v.co.x for v in evaluated.data.vertices]
|
||
print(f" arm-down rest baked, deformed x {min(xs):.3f}..{max(xs):.3f}")
|
||
|
||
|
||
def _export_preserving_skin(objs, category, name):
|
||
"""Export a skinned GLB without touching the rest pose.
|
||
|
||
`export_apply=True` (what `export()` uses for the procedural library) bakes
|
||
the evaluated deformation a second time and writes wrong inverse bind
|
||
matrices, so Godot then renders the A-pose.
|
||
"""
|
||
path = asset_path(category, name)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
for o in objs:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = objs[0]
|
||
bpy.ops.export_scene.gltf(
|
||
filepath=str(path), export_format="GLB", use_selection=True,
|
||
export_apply=False, export_animations=False, export_skins=True,
|
||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||
SRC_DIR.mkdir(parents=True, exist_ok=True)
|
||
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
|
||
compress=True)
|
||
return path
|
||
|
||
|
||
def _quaternius_work_dir():
|
||
path = ROOT / "tmp" / "npc-build"
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
def _quaternius_tpose_glb(base, overrides, hair_style="short", parts=()):
|
||
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
|
||
|
||
The arm-down bake only behaves symmetrically on our own export, because the
|
||
upstream file mirrors the bone rolls; this round trip normalises them. The
|
||
result depends only on the source and the slot overrides, so it is cached
|
||
for the run.
|
||
"""
|
||
# The cache key has to cover the slot overrides: two roles can share a source
|
||
# and still need different slot maps (the delivery vest keeps its hi-vis).
|
||
key = base + "-" + hair_style
|
||
if parts:
|
||
key += "-" + "-".join(parts)
|
||
if overrides:
|
||
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
|
||
path = _quaternius_work_dir() / f"tpose_{key}.glb"
|
||
if path.is_file():
|
||
return path
|
||
source = QUATERNIUS_SOURCES[base]
|
||
source_path = QUATERNIUS_SRC / source["file"]
|
||
if not source_path.is_file():
|
||
raise FileNotFoundError(
|
||
f"{source_path} is missing; see {QUATERNIUS_SRC / 'README.md'}")
|
||
reset_scene()
|
||
bpy.ops.import_scene.gltf(filepath=str(source_path))
|
||
# The importer brings an Icosphere helper along; it is not part of the body
|
||
# and has no UVs or weights.
|
||
for o in list(bpy.context.scene.objects):
|
||
if o.name.startswith("Icosphere"):
|
||
bpy.data.objects.remove(o, do_unlink=True)
|
||
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
|
||
if not meshes:
|
||
raise RuntimeError(f"{source_path} imported no skinned meshes")
|
||
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
|
||
|
||
# Join without welding: remove_doubles merges vertices across weight groups
|
||
# and the resulting skinning is wrong (see MEMORY.md).
|
||
for o in bpy.context.scene.objects:
|
||
o.select_set(False)
|
||
for o in meshes:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = meshes[0]
|
||
bpy.ops.object.join()
|
||
obj = bpy.context.object
|
||
obj.name = "npc_base"
|
||
obj.data.name = "npc_base"
|
||
|
||
slots = dict(source["slots"])
|
||
slots.update(overrides)
|
||
_join_quaternius_parts(obj, parts)
|
||
if source.get("donor_head"):
|
||
_transplant_worker_head(obj)
|
||
_add_quaternius_mouth(obj)
|
||
_add_quaternius_eye_whites(obj)
|
||
_quaternius_slot_map(obj, slots)
|
||
_apply_hair_part(obj, hair_style, base)
|
||
|
||
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write
|
||
# an all-black map. Repack a real layout at a tight island margin (the
|
||
# default 0.01 wastes most of the square on a dense body), then scale it to
|
||
# fill the square so the 256-square maps keep a usable texel density.
|
||
for o in bpy.context.selected_objects:
|
||
o.select_set(False)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode="EDIT")
|
||
bpy.ops.mesh.select_all(action="SELECT")
|
||
bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.0005)
|
||
bpy.ops.uv.average_islands_scale()
|
||
bpy.ops.uv.pack_islands(margin=0.0015, rotate=True)
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
uv_layer = obj.data.uv_layers.active
|
||
loops = uv_layer.data
|
||
us = [d.uv[0] for d in loops]
|
||
vs = [d.uv[1] for d in loops]
|
||
span = max(max(us) - min(us), max(vs) - min(vs))
|
||
if span > 1e-6:
|
||
scale = 0.98 / span
|
||
low_u, low_v = min(us), min(vs)
|
||
for d in loops:
|
||
d.uv = ((d.uv[0] - low_u) * scale + 0.01,
|
||
(d.uv[1] - low_v) * scale + 0.01)
|
||
|
||
# Only the five contract slots are tinted at runtime; make sure a source that
|
||
# does not paint with one of them still declares it.
|
||
present = {m.name for m in obj.data.materials if m}
|
||
for slot in SLOTS:
|
||
if slot not in present:
|
||
obj.data.materials.append(bpy.data.materials.get(slot)
|
||
or material(slot))
|
||
|
||
for o in bpy.context.scene.objects:
|
||
o.select_set(False)
|
||
arm.select_set(True)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
bpy.ops.export_scene.gltf(
|
||
filepath=str(path), export_format="GLB", use_selection=True,
|
||
export_apply=False, export_animations=False, export_skins=True,
|
||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||
print(f" T-pose base {path.relative_to(ROOT)}")
|
||
return path
|
||
|
||
|
||
def build_quaternius_character(name):
|
||
"""Bake one roster character from its CC0 Quaternius source."""
|
||
spec = CHARACTERS[name]
|
||
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}),
|
||
str(spec.get("hair", "short")),
|
||
tuple(spec.get("parts", ())))
|
||
reset_scene()
|
||
bpy.ops.import_scene.gltf(filepath=str(tpose))
|
||
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
|
||
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
|
||
mesh.name = "npc_base"
|
||
mesh.data.name = "npc_base"
|
||
_bake_quaternius_rest(arm, mesh)
|
||
return [arm, mesh]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gear (static, attach-point space; see GEAR_ATTACH)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def build_gear(name):
|
||
M = lambda n: material(n) # noqa: E731
|
||
p = []
|
||
# Head gear: origin = head centre (Godot Rig/Head marker at y=1.62); the
|
||
# Blender model's head centre sits at z=1.635, so offsets are ~ +0.015.
|
||
if name == "cap":
|
||
p.append(sphere("Crown", 0.168, (0, 0.01, 0.05), M("plastic_black"), seg=20,
|
||
rings=12, scale=(1.0, 1.05, 0.85), cut_below=0.0))
|
||
p.append(cyl("Brim", 0.11, 0.11, 0.012, (0, -0.17, 0.055), M("plastic_black"),
|
||
seg=16, scale=(1.0, 0.8, 1.0), rot=(math.radians(-8), 0, 0)))
|
||
p.append(sphere("Button", 0.014, (0, 0.01, 0.195), M("plastic_black"), seg=8, rings=4))
|
||
elif name == "hood":
|
||
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
|
||
elif name == "sunglasses":
|
||
# Keep the front frame narrow enough to sit inside the 0.315 m head
|
||
# silhouette. The arms/hinges set the overall width, so pull those in
|
||
# before shrinking the lenses; the lens plane stays in front of the eyes.
|
||
for s in (-1.0, 1.0):
|
||
p.append(cyl("Lens", 0.024, 0.024, 0.008, (0.04 * s, -0.152, 0.035), M("lens"),
|
||
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
|
||
p.append(box("Hinge", (0.035, 0.008, 0.01), (0.075 * s, -0.150, 0.045),
|
||
M("plastic_black")))
|
||
p.append(box("Arm", (0.006, 0.15, 0.008), (0.098 * s, -0.078, 0.042),
|
||
M("plastic_black")))
|
||
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
|
||
elif name == "firefighter_helmet":
|
||
p.append(sphere("Shell", 0.19, (0, 0.0, 0.03), M("helmet"), seg=24, rings=12,
|
||
scale=(1.0, 1.08, 0.95), cut_below=0.0))
|
||
p.append(lathe("Brim", [(0.0, 0.025), (0.20, 0.025), (0.26, 0.015),
|
||
(0.255, 0.035), (0.0, 0.04)], M("helmet"), seg=24))
|
||
for v in p[-1].data.vertices:
|
||
if v.co.y > 0:
|
||
v.co.y *= 1.35 # long rear brim
|
||
p.append(torus("Band", 0.188, 0.014, (0, 0, 0.09), M("hivis"), seg=24, minor_seg=4,
|
||
scale=(1.0, 1.08, 1.0)))
|
||
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
|
||
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
|
||
# The 2026-11-01 face pass added a brow ridge; lift the helmet 2 cm so
|
||
# the brim sits above the eyes instead of cutting into the brow.
|
||
for obj in p:
|
||
obj.data.transform(Matrix.Translation((0, 0, 0.02)))
|
||
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
|
||
elif name == "backpack":
|
||
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
|
||
bevel=0.04, segs=2))
|
||
p.append(box("Pocket", (0.22, 0.05, 0.16), (0, 0.29, -0.02), M("pack_nylon"),
|
||
bevel=0.02, segs=2))
|
||
p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel")))
|
||
for s in (-1.0, 1.0):
|
||
# Strap path: over the shoulder, then down the chest. The chest points
|
||
# are pulled in to x 0.12 / Godot z 0.15 so the straps hug the slimmer
|
||
# Quaternius torso (ADR-0018) as well as the wider procedural one;
|
||
# the earlier x 0.145 / z 0.19 path floated ~5 cm proud of the
|
||
# Quaternius chest and read as two detached blue bars.
|
||
p.append(tube("Strap", [(0.075 * s, 0.13, 0.27), (0.095 * s, -0.02, 0.29),
|
||
(0.105 * s, -0.06, 0.25),
|
||
(0.12 * s, -0.15, 0.18), (0.125 * s, -0.15, -0.08)],
|
||
0.016, M("denim_bag"), seg=8, width_scale=1.35,
|
||
thickness_scale=0.42))
|
||
elif name == "shoulder_bag":
|
||
# Keep the bag's inner face outside the widest skirt flare (dress = 0.26):
|
||
# 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line.
|
||
p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"),
|
||
bevel=0.025, segs=2))
|
||
p.append(tube("Strap", [(0.262, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
|
||
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
|
||
(0.262, 0.03, -0.12)], 0.009, M("leather"), seg=6))
|
||
elif name == "scba_tank":
|
||
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
|
||
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
|
||
cut_below=0.0))
|
||
p.append(cyl("Valve", 0.03, 0.03, 0.06, (0, 0.21, -0.29), M("brass"), seg=10))
|
||
p.append(box("Frame", (0.30, 0.04, 0.60), (0, 0.12, 0.0), M("plastic_black"),
|
||
bevel=0.015))
|
||
for s in (-1.0, 1.0):
|
||
p.append(tube("Strap", [(0.11 * s, 0.10, 0.28), (0.11 * s, -0.03, 0.31),
|
||
(0.12 * s, -0.14, 0.18), (0.13 * s, -0.13, -0.15)],
|
||
0.016, M("plastic_black"), seg=6))
|
||
# Chest: origin = sternum (Godot 0, 1.30, 0).
|
||
elif name == "lanyard":
|
||
p.append(tube("Cord", [(-0.06, 0.0, 0.16), (-0.07, -0.10, 0.10), (-0.04, -0.135, -0.05),
|
||
(0.0, -0.14, -0.14), (0.04, -0.135, -0.05), (0.07, -0.10, 0.10),
|
||
(0.06, 0.0, 0.16)], 0.005, M("ribbon_blue"), seg=5, caps=False))
|
||
p.append(box("Badge", (0.06, 0.006, 0.085), (0, -0.145, -0.19), M("plastic_white"),
|
||
bevel=0.004))
|
||
p.append(box("Photo", (0.024, 0.002, 0.03), (-0.012, -0.149, -0.175), M("ribbon_blue")))
|
||
# Hand items: origin = grip centre; items hang along -Z (Godot -Y).
|
||
elif name == "phone":
|
||
p.append(box("Body", (0.075, 0.012, 0.15), (0, -0.02, 0.02), M("plastic_black"),
|
||
bevel=0.008, segs=2))
|
||
p.append(box("Screen", (0.066, 0.002, 0.135), (0, -0.0265, 0.02), M("screen")))
|
||
elif name == "cup":
|
||
p.append(lathe("Cup", [(0.0, -0.06), (0.032, -0.06), (0.042, 0.06), (0.0, 0.06)],
|
||
M("paper"), seg=16))
|
||
p.append(lathe("Lid", [(0.0, 0.06), (0.046, 0.06), (0.044, 0.078), (0.0, 0.082)],
|
||
M("plastic_white"), seg=16))
|
||
p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16,
|
||
smooth=True))
|
||
elif name == "clipboard":
|
||
# Grip the *top* edge, as a hand actually carries a clipboard. The old
|
||
# geometry put the grip in the middle of the board, so it hung 26 cm below
|
||
# the wrist and swung through the thigh line; from the top edge it reads
|
||
# as held, and the board keeps its A4 size (0.22 x 0.30 m).
|
||
p.append(box("Board", (0.22, 0.008, 0.30), (0, -0.04, -0.175), M("dark_wood"),
|
||
bevel=0.004))
|
||
p.append(box("Paper", (0.19, 0.002, 0.25), (0, -0.045, -0.195), M("paper")))
|
||
p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, -0.035), M("steel"), bevel=0.004))
|
||
elif name == "umbrella":
|
||
p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14),
|
||
(0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8))
|
||
p.append(cyl("Shaft", 0.009, 0.009, 0.70, (0, 0, 0.29), M("black_metal"), seg=8))
|
||
canopy = lathe("Canopy", [(0.0, 0.66), (0.10, 0.65), (0.30, 0.58), (0.45, 0.48)],
|
||
M("plastic_black"), seg=8, sharp=20)
|
||
p.append(canopy)
|
||
elif name == "camera":
|
||
p.append(box("Body", (0.13, 0.07, 0.085), (0, -0.04, 0.04), M("plastic_black"),
|
||
bevel=0.01, segs=2))
|
||
p.append(cyl("Lens", 0.032, 0.03, 0.07, (0, -0.105, 0.035), M("plastic_black"),
|
||
seg=16, rot=(math.radians(90), 0, 0)))
|
||
p.append(cyl("Glass", 0.024, 0.024, 0.004, (0, -0.141, 0.035), M("lens"), seg=16,
|
||
rot=(math.radians(90), 0, 0)))
|
||
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
|
||
bevel=0.006))
|
||
# The hand attach point hangs off the wrist, so a full neck strap would
|
||
# float beside the head instead of resting on the neck. A short wrist
|
||
# loop keeps every hand item self-contained and follows the hand.
|
||
p.append(tube("WristStrap", [(-0.045, -0.02, 0.02), (-0.05, -0.035, 0.075),
|
||
(0.0, -0.045, 0.10), (0.05, -0.035, 0.075),
|
||
(0.045, -0.02, 0.02)], 0.005, M("plastic_black"),
|
||
seg=5, caps=False))
|
||
elif name == "protest_sign":
|
||
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
|
||
p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"),
|
||
bevel=0.003))
|
||
for i, z in enumerate((0.90, 0.80, 0.72)):
|
||
p.append(box("Letters", (0.46 - i * 0.08, 0.002, 0.04), (0, -0.032, z),
|
||
M("plastic_black")))
|
||
elif name == "flashlight":
|
||
p.append(cyl("Barrel", 0.017, 0.017, 0.20, (0, -0.02, 0.0), M("black_metal"), seg=14,
|
||
rot=(math.radians(90), 0, 0)))
|
||
p.append(cyl("Head", 0.027, 0.019, 0.05, (0, -0.135, 0.0), M("black_metal"), seg=14,
|
||
rot=(math.radians(-90), 0, 0)))
|
||
p.append(cyl("LensF", 0.023, 0.023, 0.004, (0, -0.161, 0.0), M("bulb"), seg=14,
|
||
rot=(math.radians(90), 0, 0)))
|
||
p.append(box("Switch", (0.01, 0.02, 0.008), (0, -0.06, 0.019), M("rubber")))
|
||
elif name == "wand":
|
||
p.append(cyl("Grip", 0.019, 0.017, 0.15, (0, 0, 0.0), M("plastic_black"), seg=12))
|
||
p.append(box("Paddle", (0.075, 0.025, 0.25), (0, 0, 0.20), M("plastic_grey"),
|
||
bevel=0.012, segs=2))
|
||
p.append(box("Coil", (0.055, 0.027, 0.18), (0, 0, 0.21), M("plastic_black"),
|
||
bevel=0.008))
|
||
p.append(sphere("Led", 0.007, (0, -0.016, 0.06), M("lock_led"), seg=8, rings=4))
|
||
elif name == "radio":
|
||
p.append(box("Body", (0.058, 0.032, 0.115), (0, 0, 0.0), M("plastic_black"),
|
||
bevel=0.007, segs=2))
|
||
p.append(cyl("Antenna", 0.007, 0.005, 0.09, (0.018, 0.0, 0.10), M("rubber"), seg=8))
|
||
p.append(cyl("Knob", 0.008, 0.008, 0.015, (-0.015, 0.0, 0.065), M("plastic_grey"),
|
||
seg=10))
|
||
p.append(box("Grille", (0.044, 0.002, 0.05), (0, -0.017, -0.02), M("dark_metal")))
|
||
p.append(sphere("Led", 0.004, (0.0, -0.016, 0.045), M("lock_led"), seg=6, rings=4))
|
||
else:
|
||
raise KeyError(name)
|
||
obj = join(p, name)
|
||
if GEAR[name] == "head":
|
||
# Head gear is authored around the head *centre*. The shipped roster is
|
||
# the Quaternius base (ADR-0018): its head is 0.20 m wide against the
|
||
# procedural library's 0.315 m, and its centre sits 0.07 m higher, so the
|
||
# head gear is scaled and lifted to match. The procedural fallback then
|
||
# wears slightly small hats — cosmetic, and recorded in
|
||
# docs/blender-assets.md.
|
||
centre = Vector((0.0, 0.0, HEAD_GEAR_LIFT))
|
||
obj.data.transform(
|
||
Matrix.Translation(centre)
|
||
@ Matrix.Diagonal((HEAD_GEAR_SCALE, HEAD_GEAR_SCALE,
|
||
HEAD_GEAR_SCALE, 1.0)))
|
||
elif GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
|
||
obj.data.transform(Matrix.Translation((0, 0, -0.05)))
|
||
uv_unwrap(obj)
|
||
return [obj]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Props (grounded, front = Blender -Y)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _shrub(name, center, radii, mat, lumps=7, seed=1, lump=0.42):
|
||
"""Dense foliage cluster: many small flat-shaded clumps in two greens.
|
||
|
||
Alternating light/dark clumps break the single-colour read that made the
|
||
old shrub look like a rock; the facets still catch the light at game
|
||
distance (the realism-pass foliage note).
|
||
"""
|
||
parts = []
|
||
import random
|
||
rng = random.Random(seed)
|
||
mat_light = material("foliage")
|
||
mat_dark = material("foliage_dark")
|
||
parts.append(sphere(name, 1.0, center, mat, seg=10, rings=7, scale=radii,
|
||
smooth=False))
|
||
for i in range(lumps * 2): # inner clumps fill the volume
|
||
a = math.tau * i / (lumps * 2) + rng.uniform(-0.25, 0.25)
|
||
ring = rng.uniform(0.25, 0.75)
|
||
r = rng.uniform(lump * 0.8, lump * 1.25)
|
||
off = (center[0] + math.cos(a) * radii[0] * ring,
|
||
center[1] + math.sin(a) * radii[1] * ring,
|
||
center[2] + rng.uniform(-0.2, 0.55) * radii[2])
|
||
parts.append(sphere(name, 1.0, off, mat_light if i % 2 == 0 else mat_dark,
|
||
seg=8, rings=6,
|
||
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.9),
|
||
smooth=False))
|
||
for i in range(max(3, lumps // 2)): # outer clumps break the silhouette
|
||
a = math.tau * i / max(3, lumps // 2) + rng.uniform(-0.3, 0.3)
|
||
r = rng.uniform(lump * 0.6, lump * 0.85)
|
||
off = (center[0] + math.cos(a) * radii[0] * rng.uniform(0.85, 1.0),
|
||
center[1] + math.sin(a) * radii[1] * rng.uniform(0.85, 1.0),
|
||
center[2] + rng.uniform(-0.05, 0.4) * radii[2])
|
||
parts.append(sphere(name, 1.0, off, mat_dark if i % 2 == 0 else mat_light,
|
||
seg=8, rings=6,
|
||
scale=(radii[0] * r, radii[1] * r, radii[2] * r * 0.8),
|
||
rot=(rng.uniform(0, 1), rng.uniform(0, 1), rng.uniform(0, 3)),
|
||
smooth=False))
|
||
return parts
|
||
|
||
|
||
def build_prop(name):
|
||
M = lambda n: material(n) # noqa: E731
|
||
p = []
|
||
if name == "bollard": # matches generated: r 0.09, h 0.97; flared base collar
|
||
p.append(lathe("Bollard", [(0.125, 0.0), (0.125, 0.05), (0.095, 0.09), (0.08, 0.80),
|
||
(0.09, 0.84), (0.09, 0.90), (0.06, 0.96), (0.0, 0.975)],
|
||
M("black_metal"), seg=8, sharp=25))
|
||
p.append(lathe("Band", [(0.0815, 0.74), (0.0815, 0.79)], M("reflective"), seg=8,
|
||
smooth=False))
|
||
elif name == "bench": # 1.80 x 0.52 seat at 0.46, backrest at back (+Y)
|
||
for i, y in enumerate((-0.17, -0.055, 0.06, 0.175)):
|
||
p.append(box("Seat", (1.80, 0.10, 0.04), (0, y, 0.46), M("wood"), bevel=0.012))
|
||
for z in (0.62, 0.76):
|
||
p.append(box("Back", (1.80, 0.035, 0.11), (0, 0.235, z), M("wood"),
|
||
rot=(math.radians(-10), 0, 0), bevel=0.012))
|
||
for x in (-0.72, 0.72):
|
||
p.append(tube("Frame", [(x, -0.22, 0.0), (x, -0.20, 0.44), (x, 0.20, 0.44),
|
||
(x, 0.22, 0.0)], 0.022, M("black_metal"), seg=8))
|
||
p.append(tube("BackFrame", [(x, 0.20, 0.44), (x, 0.26, 0.84)], 0.02,
|
||
M("black_metal"), seg=8))
|
||
p.append(tube("Arm", [(x, -0.20, 0.44), (x, -0.20, 0.62), (x, 0.08, 0.64),
|
||
(x, 0.24, 0.62)], 0.016, M("black_metal"), seg=8))
|
||
elif name == "trash_bin": # octagonal, r ~0.28, h 0.9
|
||
p.append(lathe("Base", [(0.27, 0.0), (0.27, 0.08), (0.25, 0.10)], M("bin_green"),
|
||
seg=8, smooth=False, face_front=True))
|
||
p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8,
|
||
smooth=False, face_front=True))
|
||
p.append(lathe("Lid", [(0.0, 0.78), (0.285, 0.78), (0.285, 0.84), (0.24, 0.90),
|
||
(0.0, 0.92)], M("bin_green"), seg=8, smooth=False,
|
||
face_front=True))
|
||
p.append(box("Slot", (0.26, 0.03, 0.09), (0, -0.258, 0.70), M("plastic_black")))
|
||
apothem = 0.255 * math.cos(math.pi / 8)
|
||
for a in range(8): # inset panels, one per face
|
||
ang = math.tau * a / 8
|
||
p.append(box("Panel", (0.15, 0.01, 0.40),
|
||
(math.sin(ang) * apothem, -math.cos(ang) * apothem, 0.38),
|
||
M("dark_metal"), rot=(0, 0, ang)))
|
||
elif name == "planter_box": # 0.96 square, 0.54 high + shrub
|
||
p.append(box("Planter", (0.96, 0.96, 0.50), (0, 0, 0.25), M("stone_pot"),
|
||
bevel=0.025, segs=2))
|
||
p.append(box("Lip", (1.0, 1.0, 0.05), (0, 0, 0.515), M("stone_pot"), bevel=0.015))
|
||
p.append(box("Soil", (0.86, 0.86, 0.02), (0, 0, 0.535), M("soil")))
|
||
p += _shrub("Shrub", (0, 0, 0.78), (0.42, 0.42, 0.30), M("foliage"), lumps=5, seed=3)
|
||
elif name == "newspaper_box": # 0.44 x 0.38, h ~0.92
|
||
p.append(box("Stand", (0.40, 0.34, 0.10), (0, 0, 0.05), M("news_blue"), bevel=0.01))
|
||
p.append(box("Body", (0.42, 0.34, 0.70), (0, 0, 0.45), M("news_blue"), bevel=0.02))
|
||
p.append(box("Top", (0.44, 0.38, 0.08), (0, 0.0, 0.84), M("news_blue"),
|
||
rot=(math.radians(-12), 0, 0), bevel=0.015))
|
||
# Recessed door frame: a dark inset so the glass reads as a window cut
|
||
# into the door rather than a flat decal on the surface.
|
||
p.append(box("DoorFrame", (0.36, 0.02, 0.26), (0, -0.152, 0.60), M("dark_metal"),
|
||
bevel=0.004))
|
||
p.append(box("Window", (0.30, 0.005, 0.20), (0, -0.157, 0.60), M("glass")))
|
||
p.append(box("Paper", (0.24, 0.004, 0.14), (0, -0.154, 0.60), M("paper")))
|
||
# Door seam above the handle so the door reads as a hinged panel.
|
||
p.append(box("DoorSeam", (0.36, 0.003, 0.002), (0, -0.16, 0.72), M("dark_metal")))
|
||
p.append(box("Handle", (0.14, 0.03, 0.02), (0, -0.18, 0.47), M("steel"), bevel=0.006))
|
||
p.append(box("Coin", (0.06, 0.02, 0.08), (0.14, -0.175, 0.76), M("steel"), bevel=0.005))
|
||
elif name == "fire_hydrant": # r 0.19 body, h ~0.76 + flange, bonnet and outlets
|
||
# Bolted base flange so the ground seam reads as a bolted fitting.
|
||
p.append(lathe("Flange", [(0.235, 0.0), (0.235, 0.03), (0.19, 0.06)],
|
||
M("hydrant_red"), seg=12, sharp=30))
|
||
p.append(lathe("Hydrant", [(0.19, 0.06), (0.15, 0.09), (0.14, 0.52),
|
||
(0.17, 0.55), (0.17, 0.60), (0.12, 0.66), (0.05, 0.72),
|
||
(0.05, 0.75), (0.0, 0.76)], M("hydrant_red"), seg=12,
|
||
sharp=30))
|
||
# Bonnet cap nut on top so the dome reads as a valve, not a bottle.
|
||
p.append(sphere("BonnetNut", 0.035, (0, 0, 0.78), M("chrome"), seg=8, rings=5))
|
||
for s in (-1.0, 1.0):
|
||
# Larger side outlets so they register in profile.
|
||
p.append(cyl("Nozzle", 0.062, 0.055, 0.12, (0.17 * s, 0, 0.40), M("hydrant_red"),
|
||
seg=10, rot=(0, math.radians(90), 0)))
|
||
p.append(cyl("Cap", 0.068, 0.068, 0.04, (0.245 * s, 0, 0.40), M("chrome"), seg=6,
|
||
rot=(0, math.radians(90), 0), smooth=False))
|
||
# Front outlet (faces -Y = the street) with a proper chrome cap.
|
||
p.append(cyl("FrontNozzle", 0.072, 0.06, 0.06, (0, -0.155, 0.40), M("hydrant_red"),
|
||
seg=10, rot=(math.radians(90), 0, 0)))
|
||
p.append(cyl("FrontCap", 0.082, 0.082, 0.04, (0, -0.215, 0.40), M("chrome"), seg=6,
|
||
rot=(math.radians(90), 0, 0), smooth=False))
|
||
for a in range(6):
|
||
ang = math.tau * a / 6
|
||
p.append(cyl("Bolt", 0.012, 0.012, 0.02, (math.cos(ang) * 0.16,
|
||
math.sin(ang) * 0.16, 0.07),
|
||
M("chrome"), seg=6, smooth=False))
|
||
elif name == "street_lamp": # 5 m, arm toward -X, bulb ~4.85 m
|
||
p.append(lathe("Base", [(0.20, 0.0), (0.20, 0.08), (0.15, 0.14), (0.13, 0.60),
|
||
(0.10, 0.66), (0.08, 0.70)], M("black_metal"), seg=8, sharp=25))
|
||
p.append(lathe("Pole", [(0.08, 0.70), (0.055, 4.70), (0.07, 4.76), (0.07, 4.82),
|
||
(0.0, 4.84)], M("black_metal"), seg=8, sharp=25))
|
||
p.append(tube("Arm", [(0.0, 0, 4.55), (-0.3, 0, 4.85), (-0.7, 0, 4.95), (-0.85, 0, 4.92)],
|
||
0.035, M("black_metal"), seg=8))
|
||
p.append(lathe("Hood", [(0.0, 4.98), (0.06, 4.97), (0.24, 4.85), (0.25, 4.82)],
|
||
M("black_metal"), seg=12, loc=(-0.85, 0, 0)))
|
||
p.append(lathe("BulbGlass", [(0.0, 4.82), (0.17, 4.82), (0.10, 4.74), (0.0, 4.72)],
|
||
M("bulb"), seg=12, loc=(-0.85, 0, 0)))
|
||
p.append(torus("Ring", 0.07, 0.015, (0, 0, 3.0), M("black_metal"), seg=12, minor_seg=4))
|
||
elif name == "street_tree": # trunk + layered crown, ~3.6 m
|
||
p.append(lathe("Trunk", [(0.15, 0.0), (0.11, 0.25), (0.09, 1.4), (0.07, 2.0)],
|
||
M("bark"), seg=8))
|
||
p.append(tube("Branch", [(0, 0, 1.6), (0.35, 0.1, 2.2)], 0.04, M("bark"), seg=6))
|
||
p.append(tube("Branch", [(0, 0, 1.7), (-0.3, -0.2, 2.3)], 0.04, M("bark"), seg=6))
|
||
p += _shrub("Crown", (0, 0, 2.55), (0.95, 0.95, 0.85), M("foliage"), lumps=7, seed=7)
|
||
p += _shrub("CrownTop", (0.05, 0.05, 3.1), (0.62, 0.62, 0.52), M("foliage_dark"),
|
||
lumps=4, seed=11)
|
||
p.append(box("Grate", (1.0, 1.0, 0.02), (0, 0, 0.01), M("dark_metal")))
|
||
elif name == "palm_tree": # ~7 m Hollywood palm
|
||
prof = []
|
||
for i in range(12):
|
||
z = i * 0.6
|
||
prof.append((0.17 - i * 0.006 + (0.012 if i % 2 else 0.0), z))
|
||
trunk = lathe("Trunk", prof, M("palm_bark"), seg=8)
|
||
for v in trunk.data.vertices: # gentle lean
|
||
v.co.x += 0.04 * (v.co.z / 6.6) ** 2 * 6.6
|
||
p.append(trunk)
|
||
top = (0.29, 0.0, 6.6)
|
||
p.append(sphere("Crown", 0.24, top, M("palm_bark"), seg=10, rings=6))
|
||
for i in range(14):
|
||
a = math.tau * i / 14
|
||
droop = 0.6 + 0.35 * (i % 3) / 2
|
||
tip = (top[0] + math.cos(a) * 2.0, top[1] + math.sin(a) * 2.0,
|
||
top[2] - droop * 1.1)
|
||
mid = (top[0] + math.cos(a) * 1.0, top[1] + math.sin(a) * 1.0, top[2] + 0.35)
|
||
bm = bmesh.new()
|
||
pts = [Vector(top), Vector(mid), Vector(tip)]
|
||
side = Vector((-math.sin(a), math.cos(a), 0))
|
||
widths = [0.06, 0.45, 0.0]
|
||
vs = []
|
||
for pt, wd in zip(pts, widths):
|
||
vs.append((bm.verts.new(pt - side * wd + Vector((0, 0, -0.06))),
|
||
bm.verts.new(pt + Vector((0, 0, 0.04))),
|
||
bm.verts.new(pt + side * wd + Vector((0, 0, -0.06)))))
|
||
for (a0, a1, a2), (b0, b1, b2) in zip(vs, vs[1:]):
|
||
bm.faces.new((a0, a1, b1, b0))
|
||
bm.faces.new((a1, a2, b2, b1))
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
|
||
frond = _link("Frond", bm)
|
||
# Alternate two greens so the crown is not one flat colour.
|
||
_finish(frond, M("palm_leaf" if i % 2 == 0 else "palm_leaf_dark"),
|
||
smooth=True, sharp_angle=80)
|
||
p.append(frond)
|
||
elif name == "round_planter": # mockup pot with topiary, ~1.0 m
|
||
p.append(lathe("Pot", [(0.0, 0.0), (0.22, 0.0), (0.26, 0.05), (0.33, 0.50),
|
||
(0.36, 0.52), (0.36, 0.58), (0.31, 0.58), (0.0, 0.56)],
|
||
M("stone_pot"), seg=20, sharp=30))
|
||
p += _shrub("Topiary", (0, 0, 0.82), (0.34, 0.34, 0.30), M("foliage"), lumps=4, seed=5)
|
||
elif name == "a_frame_sign": # chalkboard sidewalk sign
|
||
for s in (-1.0, 1.0):
|
||
tilt = math.radians(14) * s
|
||
y = 0.13 * s
|
||
p.append(box("Frame", (0.62, 0.035, 0.98), (0, y, 0.48), M("dark_wood"),
|
||
rot=(tilt, 0, 0), bevel=0.008))
|
||
p.append(box("Board", (0.52, 0.04, 0.82), (0, y, 0.50), M("chalkboard"),
|
||
rot=(tilt, 0, 0)))
|
||
for i, z in enumerate((0.72, 0.60, 0.40)):
|
||
p.append(box("Chalk", (0.38 - 0.06 * i, 0.044, 0.05),
|
||
(0, y + 0.0005 * s, z), M("chalk"), rot=(tilt, 0, 0)))
|
||
p.append(cyl("Hinge", 0.012, 0.012, 0.62, (0, 0, 0.955), M("steel"), seg=8,
|
||
rot=(0, math.radians(90), 0)))
|
||
elif name == "wall_lantern": # wall-mounted, origin on wall at mount height
|
||
p.append(box("Plate", (0.14, 0.03, 0.22), (0, 0.0, 0.0), M("black_metal"), bevel=0.01))
|
||
p.append(tube("Arm", [(0, -0.01, 0.05), (0, -0.14, 0.10), (0, -0.22, 0.08)], 0.015,
|
||
M("black_metal"), seg=6))
|
||
p.append(lathe("Cap", [(0.0, 0.16), (0.05, 0.15), (0.11, 0.09), (0.11, 0.07)],
|
||
M("black_metal"), seg=8, loc=(0, -0.24, 0.0)))
|
||
p.append(lathe("Glass", [(0.08, -0.12), (0.10, 0.07)], M("bulb"), seg=8,
|
||
loc=(0, -0.24, 0.0), smooth=False))
|
||
p.append(lathe("Bottom", [(0.0, -0.17), (0.09, -0.15), (0.09, -0.12)],
|
||
M("black_metal"), seg=8, loc=(0, -0.24, 0.0)))
|
||
elif name == "traffic_cone":
|
||
p.append(box("Base", (0.38, 0.38, 0.04), (0, 0, 0.02), M("rubber"), bevel=0.02))
|
||
p.append(lathe("Cone", [(0.15, 0.04), (0.035, 0.68), (0.0, 0.69)], M("cone_orange"),
|
||
seg=16))
|
||
p.append(lathe("Stripe", [(0.118, 0.22), (0.096, 0.34)], M("reflective"), seg=16))
|
||
p.append(lathe("Stripe2", [(0.08, 0.42), (0.064, 0.50)], M("reflective"), seg=16))
|
||
elif name == "crowd_barrier": # steel bike-rack barricade, 2.2 m
|
||
p.append(tube("Frame", [(-1.1, 0, 0.12), (-1.1, 0, 1.05), (1.1, 0, 1.05), (1.1, 0, 0.12),
|
||
(-1.1, 0, 0.12)], 0.022, M("steel"), seg=8, caps=False))
|
||
for i in range(13):
|
||
x = -0.96 + i * 0.16
|
||
p.append(cyl("Bar", 0.01, 0.01, 0.90, (x, 0, 0.585), M("steel"), seg=6))
|
||
for x in (-1.0, 1.0):
|
||
p.append(tube("Foot", [(x, -0.30, 0.0), (x, 0.0, 0.14), (x, 0.30, 0.0)], 0.018,
|
||
M("steel"), seg=6))
|
||
elif name == "stanchion": # queue post with velvet rope stub
|
||
p.append(lathe("Base", [(0.0, 0.0), (0.17, 0.0), (0.17, 0.025), (0.04, 0.05)],
|
||
M("chrome"), seg=20))
|
||
p.append(cyl("Post", 0.025, 0.025, 0.90, (0, 0, 0.50), M("chrome"), seg=12))
|
||
p.append(sphere("Top", 0.04, (0, 0, 0.97), M("chrome"), seg=12, rings=8))
|
||
p.append(tube("Rope", [(0, 0.0, 0.92), (0.2, 0.0, 0.80), (0.45, 0.0, 0.74),
|
||
(0.70, 0.0, 0.80), (0.9, 0.0, 0.92)], 0.018,
|
||
M("velvet_red"), seg=8))
|
||
p.append(cyl("Hook", 0.022, 0.022, 0.05, (0.9, 0, 0.92), M("brass"), seg=8,
|
||
rot=(0, math.radians(90), 0)))
|
||
elif name == "handrail": # 2 m stair rail, posts at x=0 and x=2 (stairs run +X up)
|
||
p.append(tube("Rail", [(-0.1, 0, 0.92), (0.0, 0, 0.95), (2.0, 0, 1.75), (2.1, 0, 1.76)],
|
||
0.025, M("black_metal"), seg=10))
|
||
for x, z in ((0.0, 0.0), (1.0, 0.4), (2.0, 0.8)):
|
||
p.append(cyl("Post", 0.022, 0.022, 0.95 + 0.0, (x, 0, z + 0.475 + (x * 0.0)),
|
||
M("black_metal"), seg=8))
|
||
p.append(cyl("Flange", 0.05, 0.05, 0.015, (x, 0, z + 0.008), M("black_metal"), seg=10))
|
||
elif name == "security_podium": # check-in lectern for the door
|
||
p.append(box("Plinth", (0.62, 0.46, 0.06), (0, 0, 0.03), M("plastic_black"),
|
||
bevel=0.01))
|
||
p.append(box("Body", (0.56, 0.40, 1.0), (0, 0, 0.56), M("dark_wood"), bevel=0.015))
|
||
p.append(box("Desk", (0.64, 0.48, 0.04), (0, 0.02, 1.10), M("dark_wood"),
|
||
rot=(math.radians(-12), 0, 0), bevel=0.01))
|
||
p.append(box("Panel", (0.44, 0.01, 0.62), (0, -0.205, 0.55), M("black_metal")))
|
||
p.append(box("Badge", (0.18, 0.012, 0.18), (0, -0.212, 0.66), M("patch"), bevel=0.01))
|
||
p.append(box("Tablet", (0.26, 0.18, 0.012), (0, 0.0, 1.13), M("screen"),
|
||
rot=(math.radians(-12), 0, 0)))
|
||
elif name == "double_door": # 1.8 x 2.6 framed entrance doors
|
||
p.append(box("FrameL", (0.10, 0.20, 2.70), (-0.95, 0, 1.35), M("door_paint"), bevel=0.01))
|
||
p.append(box("FrameR", (0.10, 0.20, 2.70), (0.95, 0, 1.35), M("door_paint"), bevel=0.01))
|
||
p.append(box("Header", (2.0, 0.20, 0.12), (0, 0, 2.66), M("door_paint"), bevel=0.01))
|
||
for s in (-1.0, 1.0):
|
||
x = 0.45 * s
|
||
p.append(box("Leaf", (0.88, 0.06, 2.58), (x, 0.03, 1.30), M("door_paint"),
|
||
bevel=0.008))
|
||
p.append(box("Glass", (0.62, 0.065, 1.50), (x, 0.03, 1.55), M("dark_glass")))
|
||
p.append(box("Kick", (0.80, 0.07, 0.22), (x, 0.03, 0.14), M("steel")))
|
||
p.append(cyl("Pull", 0.014, 0.014, 0.50, (0.07 * s, -0.06, 1.15), M("chrome"),
|
||
seg=8))
|
||
p.append(box("Reader", (0.09, 0.03, 0.14), (1.12, -0.06, 1.25), M("plastic_black"),
|
||
bevel=0.008))
|
||
p.append(box("LockLed", (0.03, 0.01, 0.03), (1.12, -0.078, 1.29), M("lock_led")))
|
||
elif name == "marquee": # canopy sign for the venue, 6 m wide, origin at wall base
|
||
p.append(box("Canopy", (6.0, 1.6, 0.18), (0, -0.80, 0.0), M("black_metal"), bevel=0.02))
|
||
p.append(box("SignBox", (5.4, 0.35, 1.0), (0, -1.45, 0.60), M("black_metal"),
|
||
bevel=0.03))
|
||
p.append(box("SignFace", (4.8, 0.02, 0.62), (0, -1.63, 0.62), M("sign_face")))
|
||
for i in range(24):
|
||
x = -2.6 + i * (5.2 / 23)
|
||
for z in (0.17, 1.06):
|
||
p.append(sphere("Bulb", 0.035, (x, -1.635, z), M("bulb"), seg=8, rings=4))
|
||
for i in range(10):
|
||
x = -2.7 + i * 0.6
|
||
p.append(sphere("Under", 0.03, (x, -1.0, -0.10), M("bulb"), seg=6, rings=4))
|
||
for s in (-1.0, 1.0):
|
||
p.append(tube("Rod", [(2.6 * s, -1.5, 1.05), (2.6 * s, -0.4, 1.9)], 0.02,
|
||
M("black_metal"), seg=6))
|
||
elif name == "sedan": # ~4.6 m dark sedan, front toward -Y
|
||
p.append(box("Lower", (1.80, 4.60, 0.62), (0, 0, 0.52), M("car_paint"), bevel=0.12,
|
||
segs=3))
|
||
cabin = box("Cabin", (1.56, 2.30, 0.55), (0, 0.25, 1.05), M("car_paint"), bevel=0.10,
|
||
segs=2)
|
||
for v in cabin.data.vertices: # taper the greenhouse
|
||
if v.co.z > 1.1:
|
||
v.co.x *= 0.88
|
||
v.co.y = 0.25 + (v.co.y - 0.25) * 0.72
|
||
p.append(cabin)
|
||
glass = box("Glass", (1.58, 2.10, 0.36), (0, 0.25, 1.06), M("dark_glass"), bevel=0.06)
|
||
for v in glass.data.vertices:
|
||
if v.co.z > 1.1:
|
||
v.co.x *= 0.89
|
||
v.co.y = 0.25 + (v.co.y - 0.25) * 0.74
|
||
p.append(glass)
|
||
for x in (-0.80, 0.80):
|
||
for y in (-1.45, 1.45):
|
||
p.append(cyl("Tyre", 0.34, 0.34, 0.24, (x, y, 0.34), M("rubber"), seg=18,
|
||
rot=(0, math.radians(90), 0)))
|
||
p.append(cyl("Rim", 0.22, 0.22, 0.25, (x, y, 0.34), M("chrome"), seg=12,
|
||
rot=(0, math.radians(90), 0)))
|
||
for x in (-0.62, 0.62):
|
||
p.append(box("Headlight", (0.38, 0.04, 0.10), (x, -2.29, 0.66), M("headlight"),
|
||
bevel=0.02))
|
||
p.append(box("Tail", (0.40, 0.04, 0.10), (x, 2.29, 0.70), M("lock_led"),
|
||
bevel=0.02))
|
||
p.append(box("Grille", (0.70, 0.03, 0.14), (0, -2.30, 0.55), M("chrome"), bevel=0.02))
|
||
else:
|
||
raise KeyError(name)
|
||
obj = join(p, name)
|
||
uv_unwrap(obj)
|
||
return [obj]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Build + render entry points (inside Blender)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_one(category, name, head="face", source="quaternius"):
|
||
reset_scene()
|
||
quaternius = (category == "npc" and source == "quaternius"
|
||
and "base" in CHARACTERS[name])
|
||
if category == "npc":
|
||
objs = (build_quaternius_character(name) if quaternius
|
||
else build_character(name, head=head))
|
||
elif category == "npc_gear":
|
||
objs = build_gear(name)
|
||
else:
|
||
objs = build_prop(name)
|
||
path = (_export_preserving_skin if quaternius else export)(objs, category, name)
|
||
print(f"wrote {path.relative_to(ROOT)}")
|
||
return path
|
||
|
||
|
||
def _selected(only):
|
||
wanted = {n.strip() for n in only.split(",") if n.strip()} if only else None
|
||
return [(c, n) for c, n in catalogue() if wanted is None or n in wanted]
|
||
|
||
|
||
def _setup_render(objs, out_path, character=False):
|
||
scene = bpy.context.scene
|
||
scene.render.engine = "CYCLES"
|
||
scene.cycles.device = "CPU"
|
||
scene.cycles.samples = 48
|
||
scene.cycles.use_denoising = True
|
||
scene.render.resolution_x = 640
|
||
scene.render.resolution_y = 640
|
||
scene.render.film_transparent = False
|
||
scene.view_settings.view_transform = "AgX"
|
||
world = bpy.data.worlds.new("World")
|
||
world.use_nodes = True
|
||
bg = world.node_tree.nodes.get("Background")
|
||
bg.inputs["Color"].default_value = (0.42, 0.44, 0.48, 1.0)
|
||
bg.inputs["Strength"].default_value = 0.7
|
||
scene.world = world
|
||
|
||
mins = Vector((1e9, 1e9, 1e9))
|
||
maxs = Vector((-1e9, -1e9, -1e9))
|
||
for o in objs:
|
||
if o.type != "MESH" or o.name.startswith("Icosphere") or o.hide_render:
|
||
continue
|
||
for corner in o.bound_box:
|
||
w = o.matrix_world @ Vector(corner)
|
||
mins = Vector(map(min, mins, w))
|
||
maxs = Vector(map(max, maxs, w))
|
||
center = (mins + maxs) / 2
|
||
size = max((maxs - mins).length, 0.05)
|
||
|
||
bpy.ops.mesh.primitive_plane_add(size=max(size * 6, 2.0), location=(0, 0, min(mins.z, 0)))
|
||
floor = bpy.context.object
|
||
floor.data.materials.append(material("concrete"))
|
||
|
||
sun_data = bpy.data.lights.new("Sun", "SUN")
|
||
sun_data.energy = 3.5
|
||
sun_data.angle = math.radians(8)
|
||
sun = bpy.data.objects.new("Sun", sun_data)
|
||
sun.rotation_euler = (math.radians(50), 0, math.radians(-35))
|
||
scene.collection.objects.link(sun)
|
||
fill_data = bpy.data.lights.new("Fill", "AREA")
|
||
fill_data.energy = 40.0 * size * size
|
||
fill_data.size = size * 2
|
||
fill = bpy.data.objects.new("Fill", fill_data)
|
||
fill.location = center + Vector((size * 1.2, -size * 1.4, size * 0.6))
|
||
fill.rotation_euler = (Vector((0, 0, 0)) - (fill.location - center)).to_track_quat(
|
||
"-Z", "Y").to_euler()
|
||
scene.collection.objects.link(fill)
|
||
|
||
cam_data = bpy.data.cameras.new("Cam")
|
||
cam_data.lens = 50
|
||
cam = bpy.data.objects.new("Cam", cam_data)
|
||
direction = Vector((0.55, -1.0, 0.32 if not character else 0.12)).normalized()
|
||
cam.location = center + direction * size * 1.45
|
||
cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
|
||
scene.collection.objects.link(cam)
|
||
scene.camera = cam
|
||
scene.render.filepath = str(out_path)
|
||
|
||
|
||
# Preview-only tints (sRGB) standing in for NpcAvatar.outfit_for(); the GLBs
|
||
# keep neutral slot textures so the runtime tint stays authoritative.
|
||
PREVIEW_TINTS = {
|
||
"npc_casual": {"cloth": (34, 40, 62), "pants": (150, 120, 84)},
|
||
"npc_jacket": {"cloth": (52, 70, 58), "pants": (40, 46, 60)},
|
||
"npc_coat": {"cloth": (150, 112, 74), "pants": (30, 32, 38)},
|
||
"npc_vest": {"cloth": (70, 74, 82), "pants": (44, 50, 64)},
|
||
"npc_dress": {"cloth": (150, 30, 44), "pants": (30, 28, 30)},
|
||
"npc_hoodie": {"cloth": (78, 80, 86), "pants": (34, 38, 48)},
|
||
"npc_firefighter": {"cloth": (40, 42, 46), "pants": (40, 42, 46)},
|
||
"npc_guard": {"cloth": (30, 34, 46), "pants": (30, 34, 46)},
|
||
}
|
||
DEFAULT_TINTS = {"skin": (214, 170, 136), "hair": (58, 40, 28), "accessory": (30, 30, 34)}
|
||
|
||
|
||
def _tint_preview(name):
|
||
tints = dict(DEFAULT_TINTS)
|
||
tints.update(PREVIEW_TINTS.get(name, {}))
|
||
for mat in bpy.data.materials:
|
||
slot = mat.name.split(".")[0]
|
||
if slot not in tints or not mat.node_tree:
|
||
continue
|
||
nodes = mat.node_tree.nodes
|
||
bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
|
||
if bsdf is None or not bsdf.inputs["Base Color"].links:
|
||
continue
|
||
src = bsdf.inputs["Base Color"].links[0].from_socket
|
||
mix = nodes.new("ShaderNodeMix")
|
||
mix.data_type = "RGBA"
|
||
mix.blend_type = "MULTIPLY"
|
||
mix.inputs["Factor"].default_value = 1.0
|
||
mat.node_tree.links.new(src, mix.inputs[6])
|
||
rgb = tints[slot]
|
||
# neutral textures are light; lift the tint so the product lands near it
|
||
mix.inputs[7].default_value = tuple(
|
||
min(1.0, _srgb_to_linear(c / 255.0) * 1.5) for c in rgb) + (1.0,)
|
||
mat.node_tree.links.new(mix.outputs[2], bsdf.inputs["Base Color"])
|
||
|
||
|
||
def _render_one(category, name):
|
||
path = asset_path(category, name)
|
||
if not path.is_file():
|
||
print(f"skip {name}: not built")
|
||
return
|
||
reset_scene()
|
||
bpy.ops.import_scene.gltf(filepath=str(path))
|
||
objs = list(bpy.context.scene.objects)
|
||
if category == "npc":
|
||
_tint_preview(name)
|
||
# Dress the preview with the recommended gear at the documented attach
|
||
# points; this also checks the attach-point convention visually.
|
||
for gear in CHARACTERS[name].get("gear", []):
|
||
gpath = asset_path("npc_gear", gear)
|
||
if not gpath.is_file():
|
||
continue
|
||
before = set(bpy.context.scene.objects)
|
||
bpy.ops.import_scene.gltf(filepath=str(gpath))
|
||
for o in set(bpy.context.scene.objects) - before:
|
||
if o.parent is None:
|
||
o.location = Vector(o.location) + Vector(ATTACH_BLENDER[GEAR[gear]])
|
||
objs = list(bpy.context.scene.objects)
|
||
if category == "npc_gear": # show gear at a readable scale on the floor
|
||
mins_z = min((o.matrix_world @ Vector(c)).z for o in objs if o.type == "MESH"
|
||
for c in o.bound_box)
|
||
for o in objs:
|
||
if o.parent is None:
|
||
o.location.z -= mins_z
|
||
bpy.context.view_layer.update()
|
||
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||
out = PREVIEW_DIR / f"{name}.png"
|
||
_setup_render(objs, out, character=category == "npc")
|
||
bpy.ops.render.render(write_still=True)
|
||
print(f"rendered {out.relative_to(ROOT)}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Audit + contact sheets (stdlib / Pillow, outside Blender)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _read_glb(path):
|
||
data = path.read_bytes()
|
||
magic, version, length = struct.unpack_from("<III", data, 0)
|
||
json_len, _ = struct.unpack_from("<II", data, 12)
|
||
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
|
||
bin_off = 20 + json_len
|
||
bin_len = struct.unpack_from("<I", data, bin_off)[0] if bin_off < len(data) else 0
|
||
binary = data[bin_off + 8:bin_off + 8 + bin_len]
|
||
return magic, version, length, len(data), gltf, binary
|
||
|
||
|
||
def _accessor(gltf, binary, index):
|
||
acc = gltf["accessors"][index]
|
||
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[acc["type"]]
|
||
fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(acc["componentType"])]
|
||
view = gltf["bufferViews"][acc["bufferView"]]
|
||
off = int(view.get("byteOffset", 0)) + int(acc.get("byteOffset", 0))
|
||
stride = int(view.get("byteStride", width * struct.calcsize(fmt)))
|
||
return [struct.unpack_from("<" + fmt * width, binary, off + i * stride)
|
||
for i in range(int(acc["count"]))]
|
||
|
||
|
||
def _degenerate(gltf, binary, pos_index, idx_index):
|
||
pos = _accessor(gltf, binary, pos_index)
|
||
idx = [i[0] for i in _accessor(gltf, binary, idx_index)]
|
||
count = 0
|
||
for k in range(0, len(idx) - 2, 3):
|
||
a, b, c = pos[idx[k]], pos[idx[k + 1]], pos[idx[k + 2]]
|
||
u = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||
v = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
|
||
cx = u[1] * v[2] - u[2] * v[1]
|
||
cy = u[2] * v[0] - u[0] * v[2]
|
||
cz = u[0] * v[1] - u[1] * v[0]
|
||
if cx * cx + cy * cy + cz * cz < 1e-14:
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def audit_asset(category, name):
|
||
path = asset_path(category, name)
|
||
res = {"name": name, "category": category, "ok": False, "issues": [],
|
||
"triangles": 0, "vertices": 0, "materials": [], "bounds": None}
|
||
if not path.is_file():
|
||
res["issues"].append("missing glb")
|
||
return res
|
||
try:
|
||
magic, version, length, size, gltf, binary = _read_glb(path)
|
||
except (struct.error, ValueError, KeyError) as exc:
|
||
res["issues"].append(f"unreadable: {exc}")
|
||
return res
|
||
if magic != 0x46546C67 or version != 2 or length != size:
|
||
res["issues"].append("bad GLB container")
|
||
mins = [1e9] * 3
|
||
maxs = [-1e9] * 3
|
||
tris = verts = degenerate = 0
|
||
for mesh in gltf.get("meshes", []):
|
||
for prim in mesh.get("primitives", []):
|
||
attrs = prim.get("attributes", {})
|
||
for req in ("POSITION", "NORMAL", "TEXCOORD_0"):
|
||
if req not in attrs:
|
||
res["issues"].append(f"primitive missing {req}")
|
||
if "POSITION" in attrs:
|
||
acc = gltf["accessors"][attrs["POSITION"]]
|
||
verts += int(acc["count"])
|
||
for k in range(3):
|
||
mins[k] = min(mins[k], acc["min"][k])
|
||
maxs[k] = max(maxs[k], acc["max"][k])
|
||
if "TEXCOORD_0" in attrs:
|
||
for u, v in _accessor(gltf, binary, attrs["TEXCOORD_0"]):
|
||
if not (-1e-4 <= u <= 1.0001 and -1e-4 <= v <= 1.0001):
|
||
res["issues"].append("UV outside [0, 1]")
|
||
break
|
||
if prim.get("indices") is not None:
|
||
tris += gltf["accessors"][prim["indices"]]["count"] // 3
|
||
if "POSITION" in attrs:
|
||
degenerate += _degenerate(gltf, binary, attrs["POSITION"],
|
||
prim["indices"])
|
||
res["triangles"], res["vertices"] = tris, verts
|
||
if degenerate:
|
||
# Godot warns "non-finite normal in LOD generation" for these.
|
||
res["issues"].append(f"{degenerate} zero-area triangles")
|
||
res["materials"] = [m.get("name", "") for m in gltf.get("materials", [])]
|
||
if verts:
|
||
res["bounds"] = {"min": [round(v, 3) for v in mins],
|
||
"max": [round(v, 3) for v in maxs]}
|
||
if category == "npc":
|
||
nodes = gltf.get("nodes", [])
|
||
bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
|
||
for j in s.get("joints", [])]
|
||
required = QUATERNIUS_BONES if "UpperArm.L" in bones else BONES
|
||
for b in required:
|
||
if b not in bones:
|
||
res["issues"].append(f"missing bone {b}")
|
||
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
|
||
res["issues"].append("no mesh node named npc_base")
|
||
for slot in SLOTS:
|
||
if slot not in res["materials"]:
|
||
res["issues"].append(f"missing material slot {slot}")
|
||
for mesh in gltf.get("meshes", []):
|
||
for prim in mesh.get("primitives", []):
|
||
if "JOINTS_0" not in prim.get("attributes", {}):
|
||
res["issues"].append("primitive not skinned")
|
||
if tris > CHAR_MAX_TRIS:
|
||
res["issues"].append(f"triangles {tris} > {CHAR_MAX_TRIS}")
|
||
if verts > CHAR_MAX_VERTS:
|
||
res["issues"].append(f"vertices {verts} > {CHAR_MAX_VERTS}")
|
||
if verts and abs(mins[1]) > 0.03:
|
||
res["issues"].append(f"feet not on the ground (min y {mins[1]:.3f})")
|
||
if verts and not (1.6 < maxs[1] < 2.1):
|
||
res["issues"].append(f"unexpected height {maxs[1]:.2f}")
|
||
elif category == "npc_gear":
|
||
if tris > GEAR_MAX_TRIS:
|
||
res["issues"].append(f"triangles {tris} > {GEAR_MAX_TRIS}")
|
||
else:
|
||
if tris > PROP_MAX_TRIS:
|
||
res["issues"].append(f"triangles {tris} > {PROP_MAX_TRIS}")
|
||
grounded = name not in ("wall_lantern", "marquee")
|
||
if grounded and verts and abs(mins[1]) > 0.03:
|
||
res["issues"].append(f"not on the ground (min y {mins[1]:.3f})")
|
||
if not (SRC_DIR / f"{name}.blend").is_file():
|
||
res["issues"].append("editable .blend source missing")
|
||
res["ok"] = not res["issues"]
|
||
return res
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Texel density (UV area vs world area, per material slot)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat
|
||
# the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their
|
||
# effective density is four times the authored number.
|
||
TEXEL_RESOLUTION = 256
|
||
TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0}
|
||
|
||
|
||
def _tri_area3(a, b, c) -> float:
|
||
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
|
||
cx = uy * vz - uz * vy
|
||
cy = uz * vx - ux * vz
|
||
cz = ux * vy - uy * vx
|
||
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
|
||
|
||
|
||
def _tri_area2(a, b, c) -> float:
|
||
return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
|
||
|
||
|
||
def texel_density(name: str, path: Path | None = None) -> dict | None:
|
||
"""Per-slot texel density (px/m) for a character GLB.
|
||
|
||
texel density = resolution * sqrt(UV area / world surface area), so the
|
||
number is independent of how the island is packed. Effective density also
|
||
applies the runtime UV repeat for cloth/pants.
|
||
"""
|
||
path = path or asset_path("npc", name)
|
||
if not path.is_file():
|
||
return None
|
||
try:
|
||
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||
except (struct.error, ValueError, KeyError):
|
||
return None
|
||
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||
slots: dict[str, dict] = {}
|
||
for mesh in gltf.get("meshes", []):
|
||
for prim in mesh.get("primitives", []):
|
||
attrs = prim.get("attributes", {})
|
||
if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs
|
||
or prim.get("indices") is None):
|
||
continue
|
||
mat_index = prim.get("material")
|
||
slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||
uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"])
|
||
indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])]
|
||
row = slots.setdefault(slot or "unassigned",
|
||
{"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0})
|
||
for k in range(0, len(indices) - 2, 3):
|
||
i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2]
|
||
if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs):
|
||
continue
|
||
row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2])
|
||
row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2])
|
||
row["triangles"] += 1
|
||
for slot, row in slots.items():
|
||
world = row["world_area_m2"]
|
||
uv = row["uv_area"]
|
||
authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0
|
||
row["td_px_per_m"] = round(authored, 1)
|
||
row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1)
|
||
row["world_area_m2"] = round(world, 3)
|
||
row["uv_area"] = round(uv, 4)
|
||
values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0]
|
||
return {
|
||
"name": name,
|
||
"resolution": TEXEL_RESOLUTION,
|
||
"slots": slots,
|
||
"min_td": min(values) if values else 0.0,
|
||
"max_td": max(values) if values else 0.0,
|
||
"total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3),
|
||
}
|
||
|
||
|
||
def texel_report() -> list[dict]:
|
||
return [row for row in (texel_density(name) for name in CHARACTERS) if row]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Intake inspection (import a GLB the way Blender sees it)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# `audit` reads accessor-local bounds and ignores node transforms, so an
|
||
# external GLB can pass locally and sit metres away in the scene. `inspect`
|
||
# imports the file, applies node transforms and measures world space.
|
||
INSPECT_TRANSFORM_TOLERANCE = 0.005 # metres of local/world drift before reporting
|
||
INSPECT_CATEGORY_DIRS = {"npc": "npc", "npc_gear": "npc_gear", "props": "props",
|
||
"generated": "props", "blender": "external"}
|
||
|
||
|
||
def category_for_path(path) -> str:
|
||
"""Guess the contract family from a GLB path (npc, npc_gear, props, external)."""
|
||
p = Path(path)
|
||
stem = p.stem
|
||
parent = p.parent.name
|
||
if parent in INSPECT_CATEGORY_DIRS:
|
||
return INSPECT_CATEGORY_DIRS[parent]
|
||
if stem in CHARACTERS:
|
||
return "npc"
|
||
if stem in GEAR:
|
||
return "npc_gear"
|
||
if stem in PROPS:
|
||
return "props"
|
||
return "external"
|
||
|
||
|
||
def analyse_inspection(stats: dict) -> dict:
|
||
"""Pure contract analysis of `inspect_glb()` measurements (no Blender).
|
||
|
||
Hard contract failures land in `issues`; advisory findings in `warnings`.
|
||
The category gates the budget/rig checks; `external` assets only get the
|
||
generic checks so an unadapted download can be measured before intake.
|
||
"""
|
||
category = str(stats.get("category", "external"))
|
||
issues: list[str] = []
|
||
warnings: list[str] = []
|
||
tris = int(stats.get("triangles", 0))
|
||
world_verts = int(stats.get("split_vertices", 0))
|
||
meshes = list(stats.get("mesh_nodes", []))
|
||
bones = list(stats.get("bones", []))
|
||
materials = list(stats.get("materials", []))
|
||
bones_ok = {b for b in BONES if b in bones}
|
||
# ADR-0018: a Quaternius-derived character keeps the 62-joint upstream
|
||
# armature, so the required set is the joints `NpcAvatar` maps instead of the
|
||
# 13-core skeleton.
|
||
if "UpperArm.L" in bones:
|
||
needed = set(QUATERNIUS_BONES)
|
||
else:
|
||
needed = set(BONES) | {"Hips", "Spine", "Neck"}
|
||
|
||
if not meshes:
|
||
issues.append("no mesh objects")
|
||
if tris <= 0:
|
||
issues.append("no triangles")
|
||
bounds = stats.get("world_bounds") or {}
|
||
lo, hi = bounds.get("min"), bounds.get("max")
|
||
if not lo or not hi:
|
||
issues.append("empty world bounds")
|
||
for value in (tris, world_verts, *(lo or []), *(hi or [])):
|
||
if isinstance(value, float) and not math.isfinite(value):
|
||
issues.append("non-finite measurement")
|
||
break
|
||
|
||
if category == "npc":
|
||
if "npc_base" not in meshes:
|
||
issues.append("no mesh node named npc_base")
|
||
for bone in sorted(needed - set(bones)):
|
||
issues.append(f"missing bone {bone}")
|
||
for slot in SLOTS:
|
||
if slot not in materials:
|
||
issues.append(f"missing material slot {slot}")
|
||
unskinned = list(stats.get("unskinned", []))
|
||
if not stats.get("skinned", False) or unskinned:
|
||
issues.append("mesh not skinned" + (f" ({', '.join(unskinned)})" if unskinned else ""))
|
||
if tris > CHAR_MAX_TRIS:
|
||
issues.append(f"triangles {tris} > {CHAR_MAX_TRIS}")
|
||
if world_verts > CHAR_MAX_VERTS:
|
||
issues.append(f"glTF vertices {world_verts} > {CHAR_MAX_VERTS}")
|
||
if lo and abs(float(lo[1])) > 0.03:
|
||
issues.append(f"feet not on the ground (min y {float(lo[1]):.3f})")
|
||
if hi and not (1.6 < float(hi[1]) < 2.1):
|
||
issues.append(f"unexpected height {float(hi[1]):.2f} m")
|
||
elif category == "npc_gear":
|
||
if tris > GEAR_MAX_TRIS:
|
||
issues.append(f"triangles {tris} > {GEAR_MAX_TRIS}")
|
||
elif category == "props":
|
||
if tris > PROP_MAX_TRIS:
|
||
issues.append(f"triangles {tris} > {PROP_MAX_TRIS}")
|
||
else:
|
||
warnings.append("external asset: no project budget or rig contract applied")
|
||
|
||
if int(stats.get("meshes_without_uv", 0)) > 0:
|
||
issues.append(f"{int(stats['meshes_without_uv'])} mesh(es) without UVs")
|
||
if int(stats.get("meshes_without_material", 0)) > 0:
|
||
warnings.append(f"{int(stats['meshes_without_material'])} mesh(es) without a material")
|
||
if int(stats.get("objects", 0)) > max(4, tris // 2000):
|
||
warnings.append(f"{stats['objects']} objects: merge before runtime use (draw calls)")
|
||
if float(stats.get("transform_delta", 0.0)) > INSPECT_TRANSFORM_TOLERANCE:
|
||
warnings.append(
|
||
"node transforms changed the pose by "
|
||
f"{float(stats['transform_delta']):.3f} m; apply transforms before export "
|
||
"(`audit` reads local bounds)")
|
||
if int(stats.get("animations", 0)) > 0:
|
||
warnings.append(f"{int(stats['animations'])} animation(s) imported; the runtime has "
|
||
"no clip for them")
|
||
if int(stats.get("images", 0)) == 0 and materials and category in ("npc", "external"):
|
||
# Library gear/props are intentionally solid-colour palette materials.
|
||
warnings.append("no images: materials are untextured")
|
||
|
||
return {
|
||
"ok": not issues,
|
||
"category": category,
|
||
"issues": issues,
|
||
"warnings": warnings,
|
||
"budget": {
|
||
"max_triangles": {"npc": CHAR_MAX_TRIS, "npc_gear": GEAR_MAX_TRIS,
|
||
"props": PROP_MAX_TRIS}.get(category),
|
||
"max_vertices": CHAR_MAX_VERTS if category == "npc" else None,
|
||
},
|
||
"bones_found": sorted(bones_ok),
|
||
"bones_missing": sorted(needed - set(bones)) if category == "npc" else [],
|
||
}
|
||
|
||
|
||
def inspect_glb(path, category=None) -> dict:
|
||
"""Import `path` into the current Blender session and measure it.
|
||
|
||
Node transforms are applied first, so bounds and bone rests match what the
|
||
scene would export rather than the accessor-local values `audit` reads.
|
||
"""
|
||
if bpy is None:
|
||
return {"ok": False, "error": "inspect must run inside Blender", "issues": [],
|
||
"warnings": [], "category": category or "external"}
|
||
file_path = Path(path).expanduser()
|
||
if not file_path.is_file():
|
||
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
|
||
"warnings": [], "category": category or "external"}
|
||
reset_scene()
|
||
# The glTF importer logs per-mesh INFO lines to stdout, which would corrupt
|
||
# `--json` output; keep the log in stats instead of printing it.
|
||
import_log = io.StringIO()
|
||
try:
|
||
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
|
||
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
||
except RuntimeError as exc:
|
||
return {"ok": False, "error": f"import failed: {exc}", "issues": [],
|
||
"warnings": [], "category": category or "external"}
|
||
|
||
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
|
||
# The glTF importer adds a bone-display helper (an Icosphere used as
|
||
# pose_bone.custom_shape). It is not part of the asset, so never measure it.
|
||
helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones
|
||
if pb.custom_shape is not None}
|
||
meshes = [o for o in bpy.context.scene.objects
|
||
if o.type == "MESH" and o.name not in helpers]
|
||
local = _bounds_of(meshes, world=False)
|
||
world = _bounds_of(meshes, world=True)
|
||
delta = _bounds_delta(local, world)
|
||
applied = _apply_imported_transforms(meshes, armatures)
|
||
if applied:
|
||
world = _bounds_of(meshes, world=True)
|
||
|
||
bones = [b.name for arm in armatures for b in arm.data.bones]
|
||
bone_heads = {b.name: _to_godot(arm.matrix_world @ b.head_local, digits=3)
|
||
for arm in armatures for b in arm.data.bones}
|
||
uv_layers = sorted({uv.name for o in meshes for uv in o.data.uv_layers})
|
||
objects = [{
|
||
"name": o.name,
|
||
"triangles": _triangle_count(o),
|
||
"vertices": len(o.data.vertices),
|
||
"split_vertices": _split_vertex_count(o),
|
||
"uv_layers": [uv.name for uv in o.data.uv_layers],
|
||
"materials": [s.material.name for s in o.material_slots if s.material],
|
||
"vertex_groups": len(o.vertex_groups),
|
||
"skinned": any(m.type == "ARMATURE" for m in o.modifiers),
|
||
} for o in meshes]
|
||
stats = {
|
||
"file": str(file_path),
|
||
"category": category or category_for_path(file_path),
|
||
"objects": len(meshes),
|
||
"mesh_nodes": [o.name for o in meshes],
|
||
"armatures": [o.name for o in armatures],
|
||
"helpers": sorted(helpers),
|
||
"bones": bones,
|
||
"bone_heads": bone_heads,
|
||
"materials": sorted({m for row in objects for m in row["materials"]}),
|
||
"images": len(bpy.data.images),
|
||
"uv_layers": uv_layers,
|
||
"meshes_without_uv": sum(1 for row in objects if not row["uv_layers"]),
|
||
"meshes_without_material": sum(1 for row in objects if not row["materials"]),
|
||
"unskinned": [row["name"] for row in objects if not row["skinned"]],
|
||
"skinned": any(row["skinned"] for row in objects),
|
||
"animations": len(bpy.data.actions),
|
||
"triangles": sum(row["triangles"] for row in objects),
|
||
"vertices": sum(row["vertices"] for row in objects),
|
||
"split_vertices": sum(row["split_vertices"] for row in objects),
|
||
"local_bounds": local,
|
||
"world_bounds": world,
|
||
"transform_delta": delta,
|
||
"transforms_applied": applied,
|
||
"import_log": import_log.getvalue().strip().splitlines()[-3:],
|
||
"detail": objects,
|
||
}
|
||
result = analyse_inspection(stats)
|
||
result.update({"stats": stats})
|
||
return result
|
||
|
||
|
||
def adapt_glb(path, out_path, target_tris=24000):
|
||
"""Import an external GLB and adapt it toward the library contract.
|
||
|
||
Applies node transforms, grounds the feet (lowest vertex at y = 0), welds
|
||
duplicated seam vertices and decimates to `target_tris`, then exports the
|
||
result to `out_path` as a GLB. The five tint slots and the required 13 core
|
||
bones (optional extra bones are allowed) are deliberately NOT auto-mapped —
|
||
that is a manual retopology/rig step. Returns before/after measurements for
|
||
the intake report.
|
||
"""
|
||
if bpy is None:
|
||
return {"ok": False, "error": "adapt must run inside Blender"}
|
||
file_path = Path(path).expanduser()
|
||
if not file_path.is_file():
|
||
return {"ok": False, "error": f"missing file {file_path}"}
|
||
reset_scene()
|
||
import_log = io.StringIO()
|
||
try:
|
||
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
|
||
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
||
except RuntimeError as exc:
|
||
return {"ok": False, "error": f"import failed: {exc}"}
|
||
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
|
||
helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones
|
||
if pb.custom_shape is not None}
|
||
meshes = [o for o in bpy.context.scene.objects
|
||
if o.type == "MESH" and o.name not in helpers]
|
||
if not meshes:
|
||
return {"ok": False, "error": "no meshes in the file"}
|
||
_apply_imported_transforms(meshes, armatures)
|
||
before = sum(_triangle_count(o) for o in meshes)
|
||
|
||
# Ground: translate so the lowest vertex sits at y = 0.
|
||
min_y = min((o.matrix_world @ v.co).y for o in meshes for v in o.data.vertices)
|
||
for o in meshes:
|
||
o.location.y -= min_y
|
||
|
||
# Weld seam-split duplicates left by the glTF importer.
|
||
for o in meshes:
|
||
bm = bmesh.new()
|
||
bm.from_mesh(o.data)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
|
||
bm.to_mesh(o.data)
|
||
bm.free()
|
||
|
||
# Decimate to the target budget (modifier applied so the count is real).
|
||
total = sum(_triangle_count(o) for o in meshes)
|
||
if total > target_tris:
|
||
ratio = max(0.05, target_tris / total)
|
||
for o in meshes:
|
||
o.modifiers.new("AdaptDecimate", "DECIMATE").ratio = ratio
|
||
_apply_all(o)
|
||
after = sum(_triangle_count(o) for o in meshes)
|
||
|
||
bpy.ops.object.select_all(action="DESELECT")
|
||
for o in meshes:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = meshes[0]
|
||
out = Path(out_path).expanduser()
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
bpy.ops.export_scene.gltf(
|
||
filepath=str(out), export_format="GLB", use_selection=True,
|
||
export_apply=True, export_animations=False, export_skins=True,
|
||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||
return {"ok": True, "before_triangles": before, "after_triangles": after,
|
||
"out": str(out), "bones": [b.name for a in armatures for b in a.data.bones],
|
||
"meshes": [o.name for o in meshes]}
|
||
|
||
|
||
def _to_godot(co, digits: int = 4) -> list:
|
||
"""Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match
|
||
the contract bounds, HEAD_BAND and the audit's coordinate space."""
|
||
return [round(float(co[0]), digits), round(float(co[2]), digits),
|
||
round(-float(co[1]), digits)]
|
||
|
||
|
||
def _bounds_of(objects, world: bool) -> dict:
|
||
"""Bounds in Godot coordinates (Y up, +Z front) for the given meshes."""
|
||
lo = [1e18] * 3
|
||
hi = [-1e18] * 3
|
||
found = False
|
||
for obj in objects:
|
||
matrix = obj.matrix_world if world else None
|
||
for vert in obj.data.vertices:
|
||
co = (matrix @ vert.co) if matrix is not None else vert.co
|
||
found = True
|
||
for k in range(3):
|
||
lo[k] = min(lo[k], float(co[k]))
|
||
hi[k] = max(hi[k], float(co[k]))
|
||
if not found:
|
||
return {"min": None, "max": None}
|
||
# Blender is Z-up; Godot is Y-up with +Z forward: (x, y, z) -> (x, z, -y).
|
||
return {
|
||
"min": [round(lo[0], 4), round(lo[2], 4), round(-hi[1], 4)],
|
||
"max": [round(hi[0], 4), round(hi[2], 4), round(-lo[1], 4)],
|
||
}
|
||
|
||
|
||
def _bounds_delta(a: dict, b: dict) -> float:
|
||
if not a.get("min") or not b.get("min"):
|
||
return 0.0
|
||
return round(max(abs(x - y) for pair in ((a["min"], b["min"]), (a["max"], b["max"]))
|
||
for x, y in zip(*pair)), 4)
|
||
|
||
|
||
def _triangle_count(obj) -> int:
|
||
mesh = obj.data
|
||
if hasattr(mesh, "calc_loop_triangles"):
|
||
mesh.calc_loop_triangles()
|
||
return len(mesh.loop_triangles)
|
||
return sum(len(poly.vertices) - 2 for poly in mesh.polygons)
|
||
|
||
|
||
def _split_vertex_count(obj) -> int:
|
||
"""Approximate the glTF vertex count: unique (vertex, uv, normal) per loop."""
|
||
mesh = obj.data
|
||
uv_layer = mesh.uv_layers.active
|
||
seen = set()
|
||
for poly in mesh.polygons:
|
||
for loop_index in poly.loop_indices:
|
||
loop = mesh.loops[loop_index]
|
||
uv = tuple(round(float(c), 4) for c in (uv_layer.data[loop_index].uv if uv_layer
|
||
else (0.0, 0.0)))
|
||
normal = tuple(round(float(c), 3) for c in loop.normal)
|
||
seen.add((loop.vertex_index, uv, normal))
|
||
return len(seen)
|
||
|
||
|
||
def _apply_imported_transforms(meshes, armatures) -> bool:
|
||
"""Clear parents and apply object transforms so measurements match export."""
|
||
if not meshes and not armatures:
|
||
return False
|
||
try:
|
||
bpy.ops.object.select_all(action="DESELECT")
|
||
for obj in meshes + armatures:
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = meshes[0] if meshes else armatures[0]
|
||
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
|
||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||
return True
|
||
except (RuntimeError, AttributeError): # pragma: no cover - Blender only
|
||
return False
|
||
|
||
|
||
def print_inspection(result: dict) -> None:
|
||
stats = result.get("stats", {})
|
||
if result.get("error"):
|
||
print(f"INSPECT FAILED {stats.get('file', '')}\n - {result['error']}", file=sys.stderr)
|
||
return
|
||
world = stats.get("world_bounds") or {}
|
||
lo, hi = world.get("min"), world.get("max")
|
||
print(f"INSPECT {Path(stats['file']).name}")
|
||
print(f" category {result['category']}")
|
||
budget = result.get("budget", {})
|
||
print(f" triangles {stats.get('triangles', 0)}" +
|
||
(f" / {budget['max_triangles']}" if budget.get("max_triangles") else ""))
|
||
print(f" vertices {stats.get('vertices', 0)} raw, "
|
||
f"{stats.get('split_vertices', 0)} seam-split" +
|
||
(f" / {budget['max_vertices']}" if budget.get("max_vertices") else ""))
|
||
if lo and hi:
|
||
print(f" bounds (m) x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
|
||
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
|
||
if result["category"] == "npc":
|
||
print(f" height / feet {hi[1]:.3f} m / {lo[1]:+.3f} m")
|
||
print(f" transforms {'applied' if stats.get('transforms_applied') else 'NOT applied'}"
|
||
f" (local/world delta {stats.get('transform_delta', 0.0):.3f} m)")
|
||
print(f" objects {stats.get('objects', 0)} mesh(es) "
|
||
f"[{', '.join(stats.get('mesh_nodes', [])) or 'none'}], "
|
||
f"{len(stats.get('armatures', []))} armature(s)"
|
||
+ (f", helpers ignored [{', '.join(stats['helpers'])}]" if stats.get("helpers") else ""))
|
||
print(f" bones {len(stats.get('bones', []))}"
|
||
+ (f": {', '.join(stats.get('bones', []))}" if stats.get("bones") else ""))
|
||
print(f" materials {len(stats.get('materials', []))}"
|
||
+ (f": {', '.join(stats.get('materials', []))}" if stats.get("materials") else ""))
|
||
print(f" uv layers {len(stats.get('uv_layers', []))}"
|
||
+ (f": {', '.join(stats.get('uv_layers', []))}" if stats.get("uv_layers") else ""))
|
||
print(f" skinning {'yes' if stats.get('skinned') else 'no'}"
|
||
f" (unskinned: {', '.join(stats.get('unskinned', [])) or 'none'})")
|
||
print(f" animations {stats.get('animations', 0)}")
|
||
print(f" issues {'none' if result['ok'] else str(len(result['issues']))}")
|
||
for issue in result["issues"]:
|
||
print(" - " + issue)
|
||
if result["warnings"]:
|
||
print(" warnings")
|
||
for warning in result["warnings"]:
|
||
print(" ! " + warning)
|
||
|
||
|
||
def character_fit_reference(name):
|
||
"""Measure a character's head width and hip/skirt radius from its GLB.
|
||
|
||
Head width uses skin/hair vertices whose strongest joint is Head inside the
|
||
fixed head band. Clothing radius uses the cloth vertices in the hip/skirt
|
||
band. Both are reported in metres and rounded to millimetres.
|
||
"""
|
||
path = asset_path("npc", name)
|
||
if not path.is_file():
|
||
return None
|
||
try:
|
||
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||
except (struct.error, ValueError, KeyError):
|
||
return None
|
||
nodes = gltf.get("nodes", [])
|
||
joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])]
|
||
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||
head_x: list[float] = []
|
||
head_y: list[float] = []
|
||
cloth_x: list[float] = []
|
||
hip_x: list[float] = []
|
||
for mesh in gltf.get("meshes", []):
|
||
for prim in mesh.get("primitives", []):
|
||
attrs = prim.get("attributes", {})
|
||
if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
|
||
continue
|
||
mat_index = prim.get("material")
|
||
mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||
joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"])
|
||
weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"])
|
||
for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows):
|
||
strongest = max(range(4), key=lambda i: weight_row[i])
|
||
bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else ""
|
||
if (mat_name in FIT_HEAD_MATERIALS and bone == "Head"
|
||
and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]):
|
||
head_x.append(pos[0])
|
||
head_y.append(pos[1])
|
||
if (mat_name == FIT_CLOTHING_MATERIAL
|
||
and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]):
|
||
cloth_x.append(pos[0])
|
||
if (mat_name == "pants"
|
||
and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]):
|
||
hip_x.append(pos[0])
|
||
skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0
|
||
hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0
|
||
clothing_radius = max(skirt_radius, hip_radius)
|
||
return {
|
||
"head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0,
|
||
"head_y_min": round(min(head_y), 3) if head_y else 0.0,
|
||
"head_y_max": round(max(head_y), 3) if head_y else 0.0,
|
||
"clothing_radius": round(clothing_radius, 3),
|
||
"clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip",
|
||
"head_band": HEAD_BAND,
|
||
}
|
||
|
||
|
||
def _gear_local_positions(name):
|
||
path = asset_path("npc_gear", name)
|
||
if not path.is_file():
|
||
return []
|
||
try:
|
||
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||
except (struct.error, ValueError, KeyError):
|
||
return []
|
||
out = []
|
||
for mesh in gltf.get("meshes", []):
|
||
for prim in mesh.get("primitives", []):
|
||
attrs = prim.get("attributes", {})
|
||
if "POSITION" in attrs:
|
||
out.extend(_accessor(gltf, binary, attrs["POSITION"]))
|
||
return out
|
||
|
||
|
||
def _gear_fit(name, reference):
|
||
attach = GEAR.get(name)
|
||
if attach not in ("head", "back", "chest", "hand"):
|
||
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 == "hand":
|
||
# The GLB origin is the grip, so the audit bounds are grip-relative and the
|
||
# item hangs along Godot -Y. `lo` already has the authored attach offset
|
||
# added, so subtract it before converting to a rest position.
|
||
grip = bounds["min"]
|
||
drop = round(-float(grip[1]), 3)
|
||
real = HAND_ITEM_AUTHORED_SIZE.get(name, 0.0)
|
||
longest = round(max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), 3)
|
||
result.update({
|
||
"width": round(hi[0] - lo[0], 3),
|
||
"height": round(hi[1] - lo[1], 3),
|
||
"depth": round(hi[2] - lo[2], 3),
|
||
"grip_drop": drop,
|
||
"rest_lowest_y": round(HAND_REST_Y + float(grip[1]), 3),
|
||
"clears_hip": (HAND_REST_Y + float(grip[1])) >= HIP_LINE,
|
||
"longest": longest,
|
||
"authored_size": real,
|
||
"size_ratio": round(longest / real, 3) if real else 0.0,
|
||
# Same object, bigger head: on the 0.20 m Quaternius head a 0.315 m
|
||
# clipboard is 1.57 head-widths where it used to be 1.0. Report the
|
||
# proportion so "it reads oversized" is a number, not a feeling.
|
||
"size_vs_head": round(longest / reference["head_width"], 2)
|
||
if reference.get("head_width") else 0.0,
|
||
})
|
||
elif attach == "head":
|
||
result.update({
|
||
"width": round(hi[0] - lo[0], 3),
|
||
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)
|
||
if reference["head_width"] else 0.0,
|
||
"inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005,
|
||
})
|
||
else:
|
||
# Compare the widest point (|x|) and the corner radial extent with the
|
||
# skirt flare. The radial value catches bags that sit diagonally out.
|
||
inner_x = min(abs(lo[0]), abs(hi[0]))
|
||
if name in FIT_BODY_BANDS:
|
||
band = FIT_BODY_BANDS[name]
|
||
body_x = [abs(p[0]) for p in _gear_local_positions(name)
|
||
if band[0] <= p[1] <= band[1]]
|
||
if body_x:
|
||
inner_x = min(body_x)
|
||
x_extent = max(abs(lo[0]), abs(hi[0]))
|
||
radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
|
||
result.update({
|
||
"inner_x": round(inner_x, 3),
|
||
"x_extent": round(x_extent, 3),
|
||
"radial": round(radial, 3),
|
||
# Positive clearance means the bag's inner face is outside the body.
|
||
"x_clearance": round(inner_x - reference["clothing_radius"], 3),
|
||
"radial_clearance": round(reference["clothing_radius"] - radial, 3),
|
||
})
|
||
return result
|
||
|
||
|
||
def fit_report():
|
||
"""Return per-character gear fit measurements for the `fit` command/tests."""
|
||
report = []
|
||
for character, spec in CHARACTERS.items():
|
||
reference = character_fit_reference(character)
|
||
if reference is None:
|
||
continue
|
||
rows = []
|
||
for gear_name in spec.get("gear", []):
|
||
row = _gear_fit(gear_name, reference)
|
||
if row:
|
||
rows.append(row)
|
||
report.append({"character": character, "reference": reference, "gear": rows})
|
||
return report
|
||
|
||
|
||
def write_manifest(results):
|
||
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
|
||
"assets": {}}
|
||
for r in results:
|
||
entry = {"path": str(asset_path(r["category"], r["name"]).relative_to(ROOT)),
|
||
"category": r["category"], "triangles": r["triangles"],
|
||
"vertices": r["vertices"], "materials": r["materials"],
|
||
"bounds_godot": r["bounds"]}
|
||
if r["category"] == "npc":
|
||
spec = CHARACTERS[r["name"]]
|
||
entry.update({"layer": spec["layer"], "roles": spec["roles"],
|
||
"tint_slots": SLOTS, "mesh_node": "npc_base",
|
||
"base": spec.get("base", "procedural")})
|
||
elif r["category"] == "npc_gear":
|
||
entry["attach"] = GEAR[r["name"]]
|
||
else:
|
||
entry.update({k: v for k, v in PROPS[r["name"]].items()})
|
||
out["assets"][r["name"]] = entry
|
||
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
||
MANIFEST.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||
|
||
|
||
def contact_sheets():
|
||
from PIL import Image, ImageDraw
|
||
groups = {}
|
||
for cat, name in catalogue():
|
||
groups.setdefault(cat, []).append(name)
|
||
written = []
|
||
for cat, names in groups.items():
|
||
tiles = [(n, PREVIEW_DIR / f"{n}.png") for n in names
|
||
if (PREVIEW_DIR / f"{n}.png").is_file()]
|
||
if not tiles:
|
||
continue
|
||
cols = 4 if len(tiles) > 4 else len(tiles)
|
||
rows = (len(tiles) + cols - 1) // cols
|
||
cell = 320
|
||
sheet = Image.new("RGB", (cols * cell, rows * (cell + 24)), (24, 26, 30))
|
||
draw = ImageDraw.Draw(sheet)
|
||
for i, (n, path) in enumerate(tiles):
|
||
with Image.open(path) as im:
|
||
im = im.convert("RGB").resize((cell, cell), Image.Resampling.LANCZOS)
|
||
x, y = (i % cols) * cell, (i // cols) * (cell + 24)
|
||
sheet.paste(im, (x, y))
|
||
draw.text((x + 8, y + cell + 5), n, fill=(230, 230, 230))
|
||
# JPEG keeps the committed review sheets small; per-asset PNG renders
|
||
# stay local (gitignored) and are rebuilt with `render`.
|
||
out = PREVIEW_DIR / f"sheet_{cat}.jpg"
|
||
sheet.save(out, quality=86, optimize=True)
|
||
written.append(out)
|
||
print(f"wrote {out.relative_to(ROOT)}")
|
||
return written
|
||
|
||
|
||
def main(argv=None):
|
||
raw = list(argv if argv is not None else sys.argv[1:])
|
||
if "--" in raw:
|
||
raw = raw[raw.index("--") + 1:]
|
||
ap = argparse.ArgumentParser(description="Blender asset builder")
|
||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||
for cmd in ("build", "render"):
|
||
s = sub.add_parser(cmd)
|
||
s.add_argument("--only", default="")
|
||
if cmd == "build":
|
||
s.add_argument("--head", default="face", choices=("face", "proto", "mh"),
|
||
help="procedural character head source (default: face)")
|
||
s.add_argument("--source", default="quaternius",
|
||
choices=("quaternius", "procedural"),
|
||
help="character mesh source (default: the Quaternius roster)")
|
||
a = sub.add_parser("audit")
|
||
a.add_argument("--json", action="store_true")
|
||
fit = sub.add_parser("fit")
|
||
fit.add_argument("--json", action="store_true")
|
||
insp = sub.add_parser("inspect", help="import a GLB and report its contract state")
|
||
insp.add_argument("--file", action="append", required=True,
|
||
help="GLB to inspect (repeatable; absolute path, it runs in Blender)")
|
||
insp.add_argument("--category", default="",
|
||
choices=["", "npc", "npc_gear", "props", "external"],
|
||
help="override the path-derived contract category")
|
||
insp.add_argument("--json", action="store_true")
|
||
insp.add_argument("--out", default="",
|
||
help="with --json, write to this file (Blender prints a startup banner)")
|
||
ada = sub.add_parser("adapt", help="import + ground + weld + decimate an external GLB")
|
||
ada.add_argument("--file", required=True,
|
||
help="external GLB to adapt (absolute path; runs in Blender)")
|
||
ada.add_argument("--out", required=True, help="where to write the adapted GLB")
|
||
ada.add_argument("--target-tris", type=int, default=24000,
|
||
help="decimate target (default 24000)")
|
||
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
|
||
tex.add_argument("--only", default="")
|
||
tex.add_argument("--json", action="store_true")
|
||
sub.add_parser("sheet")
|
||
sub.add_parser("list")
|
||
args = ap.parse_args(raw)
|
||
|
||
if args.cmd == "texels":
|
||
names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS)
|
||
report = [row for row in (texel_density(n) for n in names) if row]
|
||
if not report:
|
||
print("no built character assets; run build first", file=sys.stderr)
|
||
return 2
|
||
if args.json:
|
||
print(json.dumps(report, indent=2))
|
||
else:
|
||
for entry in report:
|
||
print(f"{entry['name']:16s} {entry['resolution']}px maps "
|
||
f"area={entry['total_world_area_m2']:.2f} m2 "
|
||
f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m")
|
||
for slot, row in sorted(entry["slots"].items()):
|
||
note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \
|
||
if slot in TEXEL_RUNTIME_UV_SCALE else ""
|
||
print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 "
|
||
f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} "
|
||
f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}")
|
||
return 0
|
||
if args.cmd == "inspect":
|
||
if bpy is None:
|
||
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
|
||
f"--background --python {Path(__file__).resolve()} -- inspect "
|
||
"--file <path.glb>", file=sys.stderr)
|
||
return 2
|
||
results = [inspect_glb(f, args.category or None) for f in args.file]
|
||
if args.json:
|
||
payload = json.dumps(results, indent=2)
|
||
if args.out:
|
||
Path(args.out).write_text(payload + "\n", encoding="utf-8")
|
||
print(f"wrote {args.out}")
|
||
else:
|
||
# Blender prints its version banner before this script runs, so
|
||
# scripts should prefer --out for machine-readable output.
|
||
print(payload)
|
||
else:
|
||
for result in results:
|
||
print_inspection(result)
|
||
ok = all(r.get("ok", False) for r in results)
|
||
return 0 if ok else 1
|
||
|
||
if args.cmd == "adapt":
|
||
if bpy is None:
|
||
print("adapt must run inside Blender:\n flatpak run org.blender.Blender "
|
||
f"--background --python {Path(__file__).resolve()} -- adapt "
|
||
"--file <in.glb> --out <out.glb>", file=sys.stderr)
|
||
return 2
|
||
result = adapt_glb(args.file, args.out, args.target_tris)
|
||
if not result.get("ok"):
|
||
print(f"ADAPT FAILED {result.get('error', 'unknown error')}", file=sys.stderr)
|
||
return 1
|
||
print(f"adapted {Path(result['out']).name}: "
|
||
f"{result['before_triangles']} -> {result['after_triangles']} tris"
|
||
f" (bones={len(result['bones'])}, meshes={len(result['meshes'])})")
|
||
return 0
|
||
|
||
if args.cmd in ("build", "render"):
|
||
if bpy is None:
|
||
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "
|
||
f"--background --python {Path(__file__).resolve()} -- {args.cmd}",
|
||
file=sys.stderr)
|
||
return 2
|
||
for cat, name in _selected(args.only):
|
||
if args.cmd == "build":
|
||
_build_one(cat, name, head=args.head, source=args.source)
|
||
else:
|
||
_render_one(cat, name)
|
||
if args.cmd == "build":
|
||
results = [audit_asset(c, n) for c, n in catalogue()]
|
||
write_manifest([r for r in results if r["vertices"]])
|
||
bad = [r for r in results if not r["ok"] and r["name"] in
|
||
{n for _, n in _selected(args.only)}]
|
||
for r in bad:
|
||
print("BAD", r["name"], r["issues"])
|
||
return 1 if bad else 0
|
||
return 0
|
||
if args.cmd == "list":
|
||
for cat, name in catalogue():
|
||
extra = GEAR.get(name, "") if cat == "npc_gear" else \
|
||
CHARACTERS.get(name, {}).get("layer", "") if cat == "npc" else \
|
||
PROPS[name].get("replaces", "new")
|
||
print(f"{cat:9s} {name:20s} {extra}")
|
||
return 0
|
||
if args.cmd == "sheet":
|
||
contact_sheets()
|
||
return 0
|
||
if args.cmd == "fit":
|
||
report = fit_report()
|
||
if not report:
|
||
print("no built character assets; run build first", file=sys.stderr)
|
||
return 2
|
||
if args.json:
|
||
print(json.dumps(report, indent=2))
|
||
else:
|
||
for entry in report:
|
||
ref = entry["reference"]
|
||
print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m "
|
||
f"clothing_radius={ref['clothing_radius']:.3f} m "
|
||
f"({ref['clothing_source']})")
|
||
for row in entry["gear"]:
|
||
lo, hi = row["min"], row["max"]
|
||
box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
|
||
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] "
|
||
f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
|
||
if row["attach"] == "head":
|
||
flag = "inside" if row["inside_band"] else "OUTSIDE"
|
||
print(f" {row['name']:20s} head {box} "
|
||
f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) "
|
||
f"band={flag}")
|
||
else:
|
||
clip = "CLIP" if row["x_clearance"] < 0 else "clear"
|
||
print(f" {row['name']:20s} {row['attach']:5s} {box} "
|
||
f"inner_x={row['inner_x']:.3f} "
|
||
f"clearance x={row['x_clearance']:+.3f} "
|
||
f"r={row['radial_clearance']:+.3f} {clip}")
|
||
return 0
|
||
results = [audit_asset(c, n) for c, n in catalogue()]
|
||
if args.json:
|
||
print(json.dumps(results, indent=2))
|
||
else:
|
||
for r in results:
|
||
print(f"{'OK ' if r['ok'] else 'BAD'} {r['category']:9s} {r['name']:20s} "
|
||
f"tris={r['triangles']:5d} verts={r['vertices']:5d}")
|
||
for issue in r["issues"]:
|
||
print(" -", issue)
|
||
return 0 if all(r["ok"] for r in results) else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|