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 - 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 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. 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. - 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 - Every NPC now has a visible mouth; the Quaternius heads shipped without one, so the faces read as
brow-and-eye masks. 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 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 (`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. 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 - **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 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 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 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 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`). 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 - [x] **Hair part library.** Every base ships one hair shell; `QUATERNIUS_HAIR_PARTS` reshapes it in
variety the twelve procedural meshes had; three shared meshes is the current ceiling. 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 - [ ] 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. whole-body UV set, which caps how much the cloth/pants tints can differ.
+10 -10
View File
@@ -439,7 +439,7 @@
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.091 -0.092
] ]
}, },
"category": "npc", "category": "npc",
@@ -470,7 +470,7 @@
"hair" "hair"
], ],
"triangles": 6242, "triangles": 6242,
"vertices": 13100 "vertices": 13110
}, },
"npc_crowd_a": { "npc_crowd_a": {
"base": "casual", "base": "casual",
@@ -519,13 +519,13 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.22, 0.22,
1.843, 1.789,
0.271 0.271
], ],
"min": [ "min": [
-0.22, -0.22,
-0.009, -0.009,
-0.099 -0.094
] ]
}, },
"category": "npc", "category": "npc",
@@ -554,7 +554,7 @@
"hair" "hair"
], ],
"triangles": 6436, "triangles": 6436,
"vertices": 13459 "vertices": 13441
}, },
"npc_crowd_c": { "npc_crowd_c": {
"base": "hoodie", "base": "hoodie",
@@ -655,7 +655,7 @@
"min": [ "min": [
-0.22, -0.22,
-0.009, -0.009,
-0.099 -0.105
] ]
}, },
"category": "npc", "category": "npc",
@@ -683,7 +683,7 @@
"hair" "hair"
], ],
"triangles": 6436, "triangles": 6436,
"vertices": 13459 "vertices": 13475
}, },
"npc_firefighter": { "npc_firefighter": {
"base": "worker", "base": "worker",
@@ -866,13 +866,13 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.262, 0.262,
1.866, 1.789,
0.287 0.287
], ],
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.087 -0.083
] ]
}, },
"category": "npc", "category": "npc",
@@ -904,7 +904,7 @@
"hair" "hair"
], ],
"triangles": 6290, "triangles": 6290,
"vertices": 13188 "vertices": 13186
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "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 `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. 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 **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 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 `_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, "slots": {"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "keep"}}, "above": "hair", "below": "keep"}},
"roles": ["delivery"]}, "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"], "gear": ["shoulder_bag"],
"roles": ["performer"]}, "roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie", "npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
@@ -116,18 +116,18 @@ CHARACTERS = {
"base": "worker", "base": "worker",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"], "gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]}, "roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", "npc_guard": {"layer": "guard", "hair": "short", "build": "broad",
"base": "worker", "base": "worker",
"gear": ["wand"], "gear": ["wand"],
"roles": ["player_guard", "venue_security"]}, "roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): the three Quaternius # Crowd-variety prototypes (character development): the three Quaternius
# sources are spread across the crowd pools so a crowd never shows one mesh. # 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"]}, "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"], "beard": True, "gear": ["shoulder_bag"],
"roles": ["confused_visitor", "guest"]}, "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"]}, "gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim", "npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
"base": "worker", "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") 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): def _base_name(name):
"""Strip Blender's `.001` duplicate suffix.""" """Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".") head, sep, tail = name.rpartition(".")
@@ -2100,7 +2158,7 @@ def _quaternius_work_dir():
return path 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. """Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the 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 # 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). # and still need different slot maps (the delivery vest keeps its hi-vis).
key = base key = base + "-" + hair_style
if overrides: if overrides:
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items())) key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb" path = _quaternius_work_dir() / f"tpose_{key}.glb"
@@ -2152,6 +2210,7 @@ def _quaternius_tpose_glb(base, overrides):
_add_quaternius_mouth(obj) _add_quaternius_mouth(obj)
_add_quaternius_eye_whites(obj) _add_quaternius_eye_whites(obj)
_quaternius_slot_map(obj, slots) _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 # 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 # 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): def build_quaternius_character(name):
"""Bake one roster character from its CC0 Quaternius source.""" """Bake one roster character from its CC0 Quaternius source."""
spec = CHARACTERS[name] 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() reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose)) bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE") arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")