Add the hair part library and give the roster three silhouettes
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 18:48:38 +01:00
1 parent a0dbf75806
commit 7a46963e3f
14 files changed
+101 -20

No files matched your search

+68 -8
View File
@@ -107,7 +107,7 @@ CHARACTERS = {
"slots": {"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "keep"}},
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
"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",
@@ -116,18 +116,18 @@ CHARACTERS = {
"base": "worker",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"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": "bun", "build": "slim", "base": "casual",
"npc_crowd_a": {"layer": "none", "hair": "short", "build": "slim", "base": "casual",
"gear": ["sunglasses"], "roles": ["tourist", "guest"]},
"npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "base": "casual",
"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": "short", "base": "hoodie",
"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",
@@ -1738,6 +1738,64 @@ def _add_quaternius_eye_whites(obj):
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(".")
@@ -2100,7 +2158,7 @@ def _quaternius_work_dir():
return path
def _quaternius_tpose_glb(base, overrides):
def _quaternius_tpose_glb(base, overrides, hair_style="short"):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the
@@ -2110,7 +2168,7 @@ def _quaternius_tpose_glb(base, overrides):
"""
# 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
key = base + "-" + hair_style
if overrides:
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb"
@@ -2152,6 +2210,7 @@ def _quaternius_tpose_glb(base, overrides):
_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
@@ -2204,7 +2263,8 @@ def _quaternius_tpose_glb(base, overrides):
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", {}))
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}),
str(spec.get("hair", "short")))
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")