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

+2
View File
@@ -14,6 +14,8 @@ Semantic Versioning.
- Hands, head gear and carried items were re-fitted to the new characters: hand items sit in the
palm instead of floating at the wrist, the lanyard moved up to the sternum, back gear moved up to
shoulder-blade height, and the clipboard is carried by its top edge.
- NPCs no longer all share one hairstyle: the roster now uses short, buzz and long hair silhouettes
instead of a single shell in twelve colours.
- Eyes now have whites and pupils instead of flat dark rectangles.
- Every NPC now has a visible mouth; the Quaternius heads shipped without one, so the faces read as
brow-and-eye masks.
+5
View File
@@ -130,6 +130,11 @@ canonical implemented-features list in `docs/development-status.md`.
gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback
(`build --source procedural`), not in the shipped roster. Silhouette variety is the known
regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix.
- **Hair variety is built in-project (2026-11-06):** the upstream modular pack is a Google Drive
folder, so it cannot be fetched reproducibly; `QUATERNIUS_HAIR_PARTS` reshapes the one hair shell
each base ships (`short` identity, `buzz` squash, `long` stretch, `bald` removal) and the role's
existing `CHARACTERS[...]["hair"]` picks it. `bald` is restricted to bases whose skin closes the
skull. Outfit variety still needs the pack or new garments.
- **No hats except the firefighter helmet (2026-11-06, owner):** the iconography is not worth the
cost — a cap or hood on this head hides the face, and every hat needed a lift+scale hack to clear
the 0.228 m hair shell. `ACCESSORIES` maps no role to `cap`/`hood`, the guard/vest per-character
+10 -2
View File
@@ -766,8 +766,16 @@ regressing gear fit, budget or readability.
casual/coat/hoodie/crowd_c, casual tee for jacket/dress/crowd_a/crowd_b, worker for
vest/firefighter/guard/crowd_d. `npc_firefighter`, `npc_guard` and `npc_crowd_d` drop the
upstream hivis vest and hard hat. All twelve built, AO-baked and committed (`a6ccd86`).
- [ ] Add the modular part library (hair styles, outfits) so the roster regains the silhouette
variety the twelve procedural meshes had; three shared meshes is the current ceiling.
- [x] **Hair part library.** Every base ships one hair shell; `QUATERNIUS_HAIR_PARTS` reshapes it in
place (`short` identity, `buzz` squashed toward the hairline, `long` stretched from the crown),
and each role picks a style through the existing `CHARACTERS[...]["hair"]` field. The T-pose
cache is keyed on the style. The roster now reads as three silhouettes instead of one.
- [ ] **Outfit part library.** Unchanged ceiling: three outfit meshes for twelve roles. The upstream
modular pack is distributed as a Google Drive folder
(<https://drive.google.com/drive/folders/1USAAquX2JJWuA2m6zol0KUkFe3UkZ8zX>), which cannot be
fetched reproducibly by a script, so outfit variety needs either a manual intake of that pack
(with the CC0 licence + SHA-256 record) or new garments authored here. Decide which before
starting; a `bald` hair variant also needs a skull-covering crown on the `worker` base first.
- [ ] Bake per-slot albedo/normal/ORM maps and a per-slot atlas; the five slots currently share one
whole-body UV set, which caps how much the cloth/pants tints can differ.
+10 -10
View File
@@ -439,7 +439,7 @@
"min": [
-0.262,
-0.004,
-0.091
-0.092
]
},
"category": "npc",
@@ -470,7 +470,7 @@
"hair"
],
"triangles": 6242,
"vertices": 13100
"vertices": 13110
},
"npc_crowd_a": {
"base": "casual",
@@ -519,13 +519,13 @@
"bounds_godot": {
"max": [
0.22,
1.843,
1.789,
0.271
],
"min": [
-0.22,
-0.009,
-0.099
-0.094
]
},
"category": "npc",
@@ -554,7 +554,7 @@
"hair"
],
"triangles": 6436,
"vertices": 13459
"vertices": 13441
},
"npc_crowd_c": {
"base": "hoodie",
@@ -655,7 +655,7 @@
"min": [
-0.22,
-0.009,
-0.099
-0.105
]
},
"category": "npc",
@@ -683,7 +683,7 @@
"hair"
],
"triangles": 6436,
"vertices": 13459
"vertices": 13475
},
"npc_firefighter": {
"base": "worker",
@@ -866,13 +866,13 @@
"bounds_godot": {
"max": [
0.262,
1.866,
1.789,
0.287
],
"min": [
-0.262,
-0.004,
-0.087
-0.083
]
},
"category": "npc",
@@ -904,7 +904,7 @@
"hair"
],
"triangles": 6290,
"vertices": 13188
"vertices": 13186
},
"palm_tree": {
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+6
View File
@@ -176,6 +176,12 @@ colour texture. The current `_apply_outfit_materials()` sets
`albedo_color = WHITE` on any slot it does not recognise, and these keep their
colour through that. When integrating, tint only the five contract slots.
**Hair part library.** Each base ships one hair shell and the build reshapes it in place
(`QUATERNIUS_HAIR_PARTS`): `short` is identity, `buzz` squashes toward the hairline, `long` stretches
from the crown. The role picks the style through `CHARACTERS[...]["hair"]`, and the T-pose cache is
keyed on it. `bald` is only allowed on the `casual` and `hoodie` bases, where the skin closes the
skull; the modular Worker's head stops at the hairline, so removing its hair would open the crown.
**Face detail.** None of the three Quaternius heads ships a mouth or eye whites, so the build adds
both: `_add_quaternius_mouth()` places a lip block on the measured skin surface, and
`_add_quaternius_eye_whites()` renames the eye quads to `sclera` and adds a shrunken `pupil` copy in
+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")