diff --git a/CHANGELOG.md b/CHANGELOG.md index 053af7d..df8147e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/MEMORY.md b/MEMORY.md index df38f1e..42e9f74 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 diff --git a/TODO.md b/TODO.md index 993821c..fa9b099 100644 --- a/TODO.md +++ b/TODO.md @@ -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 + (), 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. diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 1509a37..c13b54d 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -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": { diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb index 707033a..33566d0 100644 Binary files a/assets/models/blender/npc/npc_coat.glb and b/assets/models/blender/npc/npc_coat.glb differ diff --git a/assets/models/blender/npc/npc_coat_ao.png b/assets/models/blender/npc/npc_coat_ao.png index 9c79099..abf295a 100644 Binary files a/assets/models/blender/npc/npc_coat_ao.png and b/assets/models/blender/npc/npc_coat_ao.png differ diff --git a/assets/models/blender/npc/npc_crowd_b.glb b/assets/models/blender/npc/npc_crowd_b.glb index f466e02..3beb2f2 100644 Binary files a/assets/models/blender/npc/npc_crowd_b.glb and b/assets/models/blender/npc/npc_crowd_b.glb differ diff --git a/assets/models/blender/npc/npc_crowd_b_ao.png b/assets/models/blender/npc/npc_crowd_b_ao.png index 82a54b5..48d0cc5 100644 Binary files a/assets/models/blender/npc/npc_crowd_b_ao.png and b/assets/models/blender/npc/npc_crowd_b_ao.png differ diff --git a/assets/models/blender/npc/npc_dress.glb b/assets/models/blender/npc/npc_dress.glb index f466e02..d179dc3 100644 Binary files a/assets/models/blender/npc/npc_dress.glb and b/assets/models/blender/npc/npc_dress.glb differ diff --git a/assets/models/blender/npc/npc_dress_ao.png b/assets/models/blender/npc/npc_dress_ao.png index 82a54b5..660644d 100644 Binary files a/assets/models/blender/npc/npc_dress_ao.png and b/assets/models/blender/npc/npc_dress_ao.png differ diff --git a/assets/models/blender/npc/npc_vest.glb b/assets/models/blender/npc/npc_vest.glb index aee966a..0dee08c 100644 Binary files a/assets/models/blender/npc/npc_vest.glb and b/assets/models/blender/npc/npc_vest.glb differ diff --git a/assets/models/blender/npc/npc_vest_ao.png b/assets/models/blender/npc/npc_vest_ao.png index 806a0b9..81e94b0 100644 Binary files a/assets/models/blender/npc/npc_vest_ao.png and b/assets/models/blender/npc/npc_vest_ao.png differ diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 7782f11..5af220b 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -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 diff --git a/tools/blender_assets.py b/tools/blender_assets.py index bbfd8f5..6dae5a8 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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")