Add the hair part library and give the roster three silhouettes
This commit is contained in:
1 parent
a0dbf75806
commit
7a46963e3f
14 files changed
+101
-20
No files matched your search
+68
-8
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user