Add the hair part library and give the roster three silhouettes
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||