diff --git a/TODO.md b/TODO.md
index c691330..2a54301 100644
--- a/TODO.md
+++ b/TODO.md
@@ -870,6 +870,32 @@ regressing gear fit, budget or readability.
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.
+
+### Owner review findings (2026-11-06)
+
+Review page: [`docs/npc-visual-review.md`](docs/npc-visual-review.md). The owner signed the roster
+off **with follow-ups** and rejected checklist rows 3 (faces at portrait), 4 (hands and hand-held
+gear) and 5 (head gear on the skull).
+
+- [x] Worker torso holes on `npc_guard`: the build deleted `Worker_Vest`, which on the modular
+ Worker *is* the torso. Fixed by mapping it to `cloth` instead of deleting it.
+- [x] Worker crown hole on `npc_firefighter`/`npc_crowd_d`: the deleted `Worker_Yellow` hat was
+ the crown of the skull. Fixed by keeping it and splitting the material at z 1.65 (crown →
+ `hair`, vest stripes → `cloth`).
+- [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar is cut out and
+ the hoodie's two brows are transplanted onto the Worker head (`DONOR_BROWS`).
+- [-] Arm/torso intersection on `npc_dress`/`npc_jacket`/`npc_crowd_a`/`npc_crowd_d`: the baked
+ rest now aims the upper arm 7° out from vertical. Verified clear on the `casual` base; a
+ light clip remains on `worker`, so this needs either a wider angle or a per-base shoulder
+ offset.
+- [ ] Worker crown still reads as a hard hat: the shape is upstream geometry, so it needs the
+ modular hair/cap library (or a carved brim) instead of a tint.
+- [ ] Eyes and brows still read as one heavy dark mass at portrait distance (checklist row 3).
+- [ ] Head gear still does not sit on the skull convincingly (row 5): the firefighter helmet brim
+ is 1.55× the head width and the cap sits high.
+- [ ] Hand-held gear is still authored for the old hand (row 4): the `npc_vest` clipboard and the
+ `npc_dress` performer sign are oversized, and the sign floats beside the body because
+ `_add_props()` places it at a fixed rig offset instead of on the hand proxy.
- [-] Build one asset family per commit, AO-baked and re-imported; never run a no-op full-library
rebuild. Use the concept + vision-judge loop for head/outfit decisions and record model,
prompt and spend. (Roster family landed in one commit because the three sources share one
diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json
index 5e97cd9..be9ed90 100644
--- a/assets/models/blender/manifest.json
+++ b/assets/models/blender/manifest.json
@@ -431,12 +431,12 @@
"base": "hoodie",
"bounds_godot": {
"max": [
- 0.228,
+ 0.262,
1.866,
0.287
],
"min": [
- -0.228,
+ -0.262,
-0.004,
-0.091
]
@@ -474,12 +474,12 @@
"base": "hoodie",
"bounds_godot": {
"max": [
- 0.228,
+ 0.262,
1.866,
0.287
],
"min": [
- -0.228,
+ -0.262,
-0.004,
-0.091
]
@@ -516,12 +516,12 @@
"base": "casual",
"bounds_godot": {
"max": [
- 0.18,
+ 0.22,
1.843,
0.271
],
"min": [
- -0.18,
+ -0.22,
-0.009,
-0.099
]
@@ -551,18 +551,18 @@
"hair"
],
"triangles": 6424,
- "vertices": 13440
+ "vertices": 13439
},
"npc_crowd_b": {
"base": "casual",
"bounds_godot": {
"max": [
- 0.18,
+ 0.22,
1.843,
0.271
],
"min": [
- -0.18,
+ -0.22,
-0.009,
-0.099
]
@@ -592,18 +592,18 @@
"hair"
],
"triangles": 6424,
- "vertices": 13440
+ "vertices": 13439
},
"npc_crowd_c": {
"base": "hoodie",
"bounds_godot": {
"max": [
- 0.228,
+ 0.262,
1.866,
0.287
],
"min": [
- -0.228,
+ -0.262,
-0.004,
-0.091
]
@@ -639,14 +639,14 @@
"base": "worker",
"bounds_godot": {
"max": [
- 0.217,
- 1.826,
+ 0.262,
+ 1.863,
0.287
],
"min": [
- -0.217,
+ -0.262,
-0.004,
- -0.065
+ -0.103
]
},
"category": "npc",
@@ -658,7 +658,8 @@
"hair",
"Moustache",
"Eye",
- "pants"
+ "pants",
+ "Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -673,19 +674,19 @@
"accessory",
"hair"
],
- "triangles": 4458,
- "vertices": 9370
+ "triangles": 5258,
+ "vertices": 10980
},
"npc_dress": {
"base": "casual",
"bounds_godot": {
"max": [
- 0.18,
+ 0.22,
1.843,
0.271
],
"min": [
- -0.18,
+ -0.22,
-0.009,
-0.099
]
@@ -714,20 +715,20 @@
"hair"
],
"triangles": 6424,
- "vertices": 13440
+ "vertices": 13439
},
"npc_firefighter": {
"base": "worker",
"bounds_godot": {
"max": [
- 0.217,
- 1.826,
+ 0.262,
+ 1.863,
0.287
],
"min": [
- -0.217,
+ -0.262,
-0.004,
- -0.065
+ -0.103
]
},
"category": "npc",
@@ -739,7 +740,8 @@
"hair",
"Moustache",
"Eye",
- "pants"
+ "pants",
+ "Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -753,21 +755,21 @@
"accessory",
"hair"
],
- "triangles": 4458,
- "vertices": 9370
+ "triangles": 5258,
+ "vertices": 10980
},
"npc_guard": {
"base": "worker",
"bounds_godot": {
"max": [
- 0.217,
- 1.826,
+ 0.262,
+ 1.863,
0.287
],
"min": [
- -0.217,
+ -0.262,
-0.004,
- -0.065
+ -0.103
]
},
"category": "npc",
@@ -779,7 +781,8 @@
"hair",
"Moustache",
"Eye",
- "pants"
+ "pants",
+ "Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb",
@@ -794,19 +797,19 @@
"accessory",
"hair"
],
- "triangles": 4458,
- "vertices": 9370
+ "triangles": 5258,
+ "vertices": 10980
},
"npc_hoodie": {
"base": "hoodie",
"bounds_godot": {
"max": [
- 0.228,
+ 0.262,
1.866,
0.287
],
"min": [
- -0.228,
+ -0.262,
-0.004,
-0.091
]
@@ -843,12 +846,12 @@
"base": "casual",
"bounds_godot": {
"max": [
- 0.18,
+ 0.22,
1.843,
0.271
],
"min": [
- -0.18,
+ -0.22,
-0.009,
-0.099
]
@@ -879,18 +882,18 @@
"hair"
],
"triangles": 6424,
- "vertices": 13440
+ "vertices": 13439
},
"npc_vest": {
"base": "worker",
"bounds_godot": {
"max": [
- 0.217,
+ 0.262,
1.863,
0.287
],
"min": [
- -0.217,
+ -0.262,
-0.004,
-0.103
]
@@ -900,13 +903,13 @@
"materials": [
"skin",
"Worker_Yellow",
- "Worker_Vest",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
- "pants"
+ "pants",
+ "Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb",
@@ -920,8 +923,8 @@
"accessory",
"hair"
],
- "triangles": 5240,
- "vertices": 10936
+ "triangles": 5258,
+ "vertices": 10980
},
"palm_tree": {
"bounds_godot": {
diff --git a/assets/models/blender/npc/npc_casual.glb b/assets/models/blender/npc/npc_casual.glb
index ac93d49..6cce457 100644
Binary files a/assets/models/blender/npc/npc_casual.glb and b/assets/models/blender/npc/npc_casual.glb differ
diff --git a/assets/models/blender/npc/npc_casual_ao.png b/assets/models/blender/npc/npc_casual_ao.png
index 0cf2ffb..27dc18e 100644
Binary files a/assets/models/blender/npc/npc_casual_ao.png and b/assets/models/blender/npc/npc_casual_ao.png differ
diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb
index ac93d49..6cce457 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 0cf2ffb..27dc18e 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_a.glb b/assets/models/blender/npc/npc_crowd_a.glb
index 7bc425a..41811dc 100644
Binary files a/assets/models/blender/npc/npc_crowd_a.glb and b/assets/models/blender/npc/npc_crowd_a.glb differ
diff --git a/assets/models/blender/npc/npc_crowd_a_ao.png b/assets/models/blender/npc/npc_crowd_a_ao.png
index 643e6e1..45cebb6 100644
Binary files a/assets/models/blender/npc/npc_crowd_a_ao.png and b/assets/models/blender/npc/npc_crowd_a_ao.png differ
diff --git a/assets/models/blender/npc/npc_crowd_b.glb b/assets/models/blender/npc/npc_crowd_b.glb
index 7bc425a..41811dc 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 643e6e1..45cebb6 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_crowd_c.glb b/assets/models/blender/npc/npc_crowd_c.glb
index ac93d49..6cce457 100644
Binary files a/assets/models/blender/npc/npc_crowd_c.glb and b/assets/models/blender/npc/npc_crowd_c.glb differ
diff --git a/assets/models/blender/npc/npc_crowd_c_ao.png b/assets/models/blender/npc/npc_crowd_c_ao.png
index 0cf2ffb..27dc18e 100644
Binary files a/assets/models/blender/npc/npc_crowd_c_ao.png and b/assets/models/blender/npc/npc_crowd_c_ao.png differ
diff --git a/assets/models/blender/npc/npc_crowd_d.glb b/assets/models/blender/npc/npc_crowd_d.glb
index 42041df..5e68461 100644
Binary files a/assets/models/blender/npc/npc_crowd_d.glb and b/assets/models/blender/npc/npc_crowd_d.glb differ
diff --git a/assets/models/blender/npc/npc_crowd_d_ao.png b/assets/models/blender/npc/npc_crowd_d_ao.png
index 82b18b3..22e4cbc 100644
Binary files a/assets/models/blender/npc/npc_crowd_d_ao.png and b/assets/models/blender/npc/npc_crowd_d_ao.png differ
diff --git a/assets/models/blender/npc/npc_dress.glb b/assets/models/blender/npc/npc_dress.glb
index 7bc425a..41811dc 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 643e6e1..45cebb6 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_firefighter.glb b/assets/models/blender/npc/npc_firefighter.glb
index 42041df..5e68461 100644
Binary files a/assets/models/blender/npc/npc_firefighter.glb and b/assets/models/blender/npc/npc_firefighter.glb differ
diff --git a/assets/models/blender/npc/npc_firefighter_ao.png b/assets/models/blender/npc/npc_firefighter_ao.png
index 82b18b3..22e4cbc 100644
Binary files a/assets/models/blender/npc/npc_firefighter_ao.png and b/assets/models/blender/npc/npc_firefighter_ao.png differ
diff --git a/assets/models/blender/npc/npc_guard.glb b/assets/models/blender/npc/npc_guard.glb
index 42041df..85b492c 100644
Binary files a/assets/models/blender/npc/npc_guard.glb and b/assets/models/blender/npc/npc_guard.glb differ
diff --git a/assets/models/blender/npc/npc_guard_ao.png b/assets/models/blender/npc/npc_guard_ao.png
index 82b18b3..db0129e 100644
Binary files a/assets/models/blender/npc/npc_guard_ao.png and b/assets/models/blender/npc/npc_guard_ao.png differ
diff --git a/assets/models/blender/npc/npc_hoodie.glb b/assets/models/blender/npc/npc_hoodie.glb
index ac93d49..6cce457 100644
Binary files a/assets/models/blender/npc/npc_hoodie.glb and b/assets/models/blender/npc/npc_hoodie.glb differ
diff --git a/assets/models/blender/npc/npc_hoodie_ao.png b/assets/models/blender/npc/npc_hoodie_ao.png
index 0cf2ffb..27dc18e 100644
Binary files a/assets/models/blender/npc/npc_hoodie_ao.png and b/assets/models/blender/npc/npc_hoodie_ao.png differ
diff --git a/assets/models/blender/npc/npc_jacket.glb b/assets/models/blender/npc/npc_jacket.glb
index 7bc425a..41811dc 100644
Binary files a/assets/models/blender/npc/npc_jacket.glb and b/assets/models/blender/npc/npc_jacket.glb differ
diff --git a/assets/models/blender/npc/npc_jacket_ao.png b/assets/models/blender/npc/npc_jacket_ao.png
index 643e6e1..45cebb6 100644
Binary files a/assets/models/blender/npc/npc_jacket_ao.png and b/assets/models/blender/npc/npc_jacket_ao.png differ
diff --git a/assets/models/blender/npc/npc_vest.glb b/assets/models/blender/npc/npc_vest.glb
index 60b10fe..37358ea 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 aefc63f..22e4cbc 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_previews/README.md b/docs/blender_previews/README.md
index 319a162..eee1fa8 100644
--- a/docs/blender_previews/README.md
+++ b/docs/blender_previews/README.md
@@ -18,6 +18,10 @@ capture harnesses.
| [](sheet_npc_baseline_compare-tex256.jpg)
`sheet_npc_baseline_compare-tex256.jpg` | 128 -> 256 px NPC slot maps on the same camera. |
| [](sheet_npc_baseline_compare-texuv.jpg)
`sheet_npc_baseline_compare-texuv.jpg` | 4x fabric weave with the baked per-character AO removed. |
| [](sheet_npc_baseline_compare-torso.jpg)
`sheet_npc_baseline_compare-torso.jpg` | Torso/hip taper without changing the skeleton or build multipliers. |
+| [](sheet_npc_roster_0.9.9.jpg)
`sheet_npc_roster_0.9.9.jpg` | 0.9.9 shipped roster: all twelve roles on the CC0 Quaternius bases, full body. |
+| [](sheet_npc_roster_views_0.9.9.jpg)
`sheet_npc_roster_views_0.9.9.jpg` | 0.9.9 roster: front and profile per role, for silhouette and gear placement. |
+| [](sheet_npc_roster_poses_0.9.9.jpg)
`sheet_npc_roster_poses_0.9.9.jpg` | 0.9.9 roster: idle, seated and walk-contact poses on six representative roles. |
+| [](sheet_npc_roster_crowd_0.9.9.jpg)
`sheet_npc_roster_crowd_0.9.9.jpg` | 0.9.9 roster at the 8 m crowd distance a player actually sees. |
| [](sheet_npc_crowd_prototypes.jpg)
`sheet_npc_crowd_prototypes.jpg` | Four approved crowd-variety looks queued for the 12-character roster. |
| [](sheet_npc_gear.jpg)
`sheet_npc_gear.jpg` | Eighteen gear pieces authored in attach space: caps, hood, helmet, bags, tanks, lanyards and handheld items. |
| [](sheet_npc_hands_compare.jpg)
`sheet_npc_hands_compare.jpg` | Hand pass: wider palms, segmented fingers and a separate thumb. |
diff --git a/docs/blender_previews/sheet_npc_roster_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_0.9.9.jpg
index 1e51f21..2e420bb 100644
Binary files a/docs/blender_previews/sheet_npc_roster_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_0.9.9.jpg differ
diff --git a/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg
index a25bc40..139bcd1 100644
Binary files a/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg differ
diff --git a/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg
index 816a014..9d1f16f 100644
Binary files a/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg differ
diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md
index 2d5e657..92d1bc4 100644
--- a/docs/npc-visual-review.md
+++ b/docs/npc-visual-review.md
@@ -45,19 +45,44 @@ straight into `TODO.md`.
| # | Question | Verdict |
| --- | --- | --- |
-| 1 | Do the twelve characters read as distinct people at 8 m? | ☐ yes ☐ no |
-| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | ☐ yes ☐ no |
-| 3 | Do the faces hold up at portrait distance? | ☐ yes ☐ no |
-| 4 | Do the hands and the hand-held gear (camera, cup, clipboard, sign) read correctly? | ☐ yes ☐ no |
-| 5 | Does the head gear (cap, hood, sunglasses, firefighter helmet) sit on the skull? | ☐ yes ☐ no |
-| 6 | Are the role outfits believable for a Hollywood-boulevard shift (tourist, delivery, guard, responder)? | ☐ yes ☐ no |
-| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | ☐ yes ☐ no |
-| 8 | Do the seated and walking poses hold up? | ☐ yes ☐ no |
+| 1 | Do the twelve characters read as distinct people at 8 m? | [x] yes [ ] no |
+| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | [x] yes [ ] no |
+| 3 | Do the faces hold up at portrait distance? | [ ] yes [x] no |
+| 4 | Do the hands and the hand-held gear (camera, cup, clipboard, sign) read correctly? | [ ] yes [x] no |
+| 5 | Does the head gear (cap, hood, sunglasses, firefighter helmet) sit on the skull? | [ ] yes [x] no |
+| 6 | Are the role outfits believable for a Hollywood-boulevard shift (tourist, delivery, guard, responder)? | [x] yes [ ] no |
+| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | [x] yes [ ] no |
+| 8 | Do the seated and walking poses hold up? | [x] yes [ ] no |
-**Signoff:** ☐ approved as shipped ☐ approved with the follow-ups below ☐ rejected
+**Signoff:** [ ] approved as shipped [x] approved with the follow-ups below [ ] rejected
## Issues visible in the captures
+The first review pass found three body defects. All three came from the build pipeline,
+not the art, and all three are addressed in the second pass (2026-11-06):
+
+| Finding | Cause | Status |
+| --- | --- | --- |
+| `npc_guard` has holes in the upper body | the build **deleted** the `Worker_Vest` material, and on the modular Worker the vest *is* the torso | **fixed** — the vest is now the `cloth` slot, tinted per role; nothing is deleted |
+| `npc_firefighter`, `npc_crowd_d` have a broken shoulder/upper-body area | same cause, plus the deleted `Worker_Yellow` hat was the crown of the skull, leaving the head open | **fixed** — the hat/crown stays in the mesh and only the part above z 1.65 takes the `hair` tint |
+| `npc_guard`, `npc_vest`, `npc_crowd_d` have one big unibrow | the Worker's `Eyebrows` material is one continuous bar, and no other upstream head has brows that fit this mesh | **replaced** — the bar is cut out and the hoodie's two brows are transplanted onto the Worker head, lifted above the eye quads |
+| `npc_dress`, `npc_jacket`, `npc_crowd_a`, `npc_crowd_d` arms intersect the body | the baked rest aimed the upper arm straight down, which works on the wide bases but not the narrow ones | **reduced** — the arm now rests 7° out from vertical (mesh width 0.180 → 0.220 on `casual`, 0.228 → 0.262 on `hoodie`); a light clip remains on the `worker` bases |
+
+Re-verified after the fix with the sheets below and the vision judge: no holes or missing geometry
+on any of the three Worker-based roles.
+
+**Still open** — checklist rows 3, 4 and 5 plus the list below: the Worker crown still reads as a
+hard hat because that is upstream geometry, the eye/brow mass still reads heavy at portrait
+distance, and the arms still graze the torso on the `worker` base.
+
+Found by user:
+
+1. npc_firefighter, npc_guard and npc_crowd_d have errors in the body:
+ - npc_guard has "holes" in upper body
+ - npc_firefighter and npc_crowd_d have buggy shoulder and upper body area
+2. npc_guard, npc_vest and npc_crowd_d have one large unibrow: use other character's eyebrow geometry instead.
+3. npc_dress, npc_jacket, npc_crowd_a and npc_crowd_d arms are too close and intersecting the body
+
Found while building this page; none blocks the swap, all are queued in `TODO.md`:
1. **`npc_dress` wears the performer cone hat plus the protest sign** because the performer props
diff --git a/tools/blender_assets.py b/tools/blender_assets.py
index 2c13a40..d06bb0c 100644
--- a/tools/blender_assets.py
+++ b/tools/blender_assets.py
@@ -86,8 +86,9 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
# pick one per avatar later. `tint` lists the slots the runtime should tint;
# every other material keeps its authored colour.
# `base` selects the CC0 Quaternius source the character is baked from
-# (ADR-0017/ADR-0018, owner chose the full roster swap); `drop` removes upstream
-# materials that do not belong to the role (the Worker's hivis vest and hard hat).
+# (ADR-0017/ADR-0018, owner chose the full roster swap); `slots` overrides the
+# source's material -> tint-slot map for that role (nothing is deleted: the
+# modular Worker's vest is its torso and its hard hat is its crown).
# Rebuild one procedurally with `build --source procedural --head mh`.
CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
@@ -101,6 +102,10 @@ CHARACTERS = {
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"base": "worker",
+ # Delivery keeps the hi-vis stripe bands as authored; every
+ # other worker role tints them with the shirt.
+ "slots": {"Worker_Yellow": {"split": True, "z": 1.65,
+ "above": "hair", "below": "keep"}},
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["shoulder_bag"],
@@ -108,11 +113,11 @@ CHARACTERS = {
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
- "base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
+ "base": "worker",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
- "base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
+ "base": "worker",
"gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): the three Quaternius
@@ -125,7 +130,7 @@ CHARACTERS = {
"npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
- "base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
+ "base": "worker",
"gear": ["cup"], "roles": ["tourist", "guest"]},
}
@@ -1491,72 +1496,234 @@ QUATERNIUS_SOURCES = {
"file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
- # The Worker's head is bare apart from a hard hat, so the
- # brows carry the hair tint and the slot keeps a surface.
+ # The modular Worker has no torso under the vest and no crown
+ # under the hard hat, so both must stay in the mesh: the vest
+ # becomes the role's shirt and only the hat/crown part of the
+ # yellow material stays, tinted as hair. The stripes below the
+ # collar follow the shirt.
+ "Worker_Vest": "cloth",
+ "Worker_Yellow": {"split": True, "z": 1.65,
+ "above": "hair", "below": "cloth"},
+ # The bar is deleted in `_transplant_brows()` and replaced by
+ # the hoodie's two brows; what remains of this material is the
+ # dark shell over the crown, which follows the hair.
"Eyebrows": "hair",
- "Eye": "keep", "Moustache": "keep",
- "Worker_Vest": "keep", "Worker_Yellow": "keep"},
- "notes": "work shirt + trousers + hivis vest + hard hat",
+ "Brows": "keep",
+ "Eye": "keep", "Moustache": "keep"},
+ "donor_brows": True,
+ "notes": "work shirt (vest geometry) + trousers + hard hat/crown",
},
}
-def _quaternius_slot_map(obj, slot_map, drop):
- """Rename the upstream materials onto the contract slots.
+## The modular Worker's `Eyebrows` material is one continuous bar across the
+## face (the owner reads it as a unibrow) and no other upstream head has brows
+## that fit this head, because the three heads are different meshes. `casual` is
+## the closest sibling, so its two brows are transplanted onto the Worker head
+## and the Worker bar is deleted. Everything else in the pipeline is remapped,
+## never deleted: the Worker's vest *is* its torso and its hard hat *is* the
+## crown of its skull.
+DONOR_BROWS = {"source": "Casual_Hoodie.glb", "material": "Eyebrows",
+ "slot_name": "Brows", "bone": "Head",
+ # The two heads are different meshes: the Worker's eye quads top
+ # out at y 1.709, so the hoodie's brows (1.702-1.717) landed on the
+ # eyes and read as one dark mass. Lift them above the eyes and sink
+ # them into the brow ridge. Blender z = Godot y.
+ "offset": (0.0, 0.0, 0.018)}
+
+
+def _base_name(name):
+ """Strip Blender's `.001` duplicate suffix."""
+ head, sep, tail = name.rpartition(".")
+ return head if sep and tail.isdigit() else name
+
+
+def _transplant_brows(obj):
+ """Replace the Worker eyebrow bar with the two brows from the hoodie head."""
+ spec = DONOR_BROWS
+ # Cut our own bar: every `Eyebrows` face below the crown shell.
+ doomed = []
+ for poly in obj.data.polygons:
+ material = obj.data.materials[poly.material_index]
+ if material is None or material.name != spec["material"]:
+ continue
+ if _face_centroid(obj, poly)[2] < 1.70:
+ doomed.append(poly.index)
+ if doomed:
+ 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")
+ bm.to_mesh(obj.data)
+ bm.free()
+
+ donor_path = QUATERNIUS_SRC / spec["source"]
+ if not donor_path.is_file():
+ print(f" brows: {donor_path.name} missing, keeping the bar")
+ return
+ before = set(o.name for o in bpy.context.scene.objects)
+ bpy.ops.import_scene.gltf(filepath=str(donor_path))
+ added = [o for o in bpy.context.scene.objects if o.name not in before]
+ donor = None
+ for candidate in added:
+ if candidate.type != "MESH":
+ continue
+ # A second import of the same pack reuses the material names, so Blender
+ # suffixes them (`Eyebrows.001`); match the base name.
+ keep = [i for i, m in enumerate(candidate.data.materials)
+ if m is not None and _base_name(m.name) == spec["material"]]
+ if not keep:
+ continue
+ bm = bmesh.new()
+ bm.from_mesh(candidate.data)
+ bm.faces.ensure_lookup_table()
+ drop = [f for f in bm.faces if f.material_index not in keep]
+ if drop:
+ bmesh.ops.delete(bm, geom=drop, context="FACES")
+ loose = [v for v in bm.verts if not v.link_faces]
+ if loose:
+ bmesh.ops.delete(bm, geom=loose, context="VERTS")
+ if not bm.faces:
+ bm.free()
+ continue
+ bm.to_mesh(candidate.data)
+ bm.free()
+ for m in candidate.data.materials:
+ if m is not None and _base_name(m.name) == spec["material"]:
+ m.name = spec["slot_name"]
+ donor = candidate
+ break
+ for extra in added:
+ if extra is not donor:
+ bpy.data.objects.remove(extra, do_unlink=True)
+ if donor is None:
+ print(" brows: donor head not found, keeping the bar")
+ return
+ donor.parent = None
+ donor.matrix_world = Matrix.Identity(4)
+ donor.data.transform(Matrix.Translation(spec["offset"]))
+ group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
+ for o in bpy.context.selected_objects:
+ o.select_set(False)
+ obj.select_set(True)
+ donor.select_set(True)
+ bpy.context.view_layer.objects.active = obj
+ bpy.ops.object.join()
+ # The donor's vertices carry no weights; the brows ride the head bone.
+ group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
+ print(f" transplanted {spec['slot_name']} from {spec['source']}")
+
+
+def _face_centroid(obj, poly):
+ co = [obj.data.vertices[v].co for v in poly.vertices]
+ n = max(len(co), 1)
+ return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
+ sum(c.z for c in co) / n)
+
+
+def _face_centroid(obj, poly):
+ co = [obj.data.vertices[v].co for v in poly.vertices]
+ n = max(len(co), 1)
+ return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
+ sum(c.z for c in co) / n)
+
+
+def _carve_quaternius_faces(obj, slot_map, by_name):
+ """Delete the faces a rule marks as `carve`.
+
+ The modular Worker's `Eyebrows` material is one continuous bar across the
+ face, which reads as a unibrow, and no other upstream head has brows that fit
+ this head (the three heads are different meshes). Cutting the middle out of
+ the bar is the cheapest honest fix. This is the only deletion in the
+ pipeline: everything else is remapped, because the Worker's vest *is* its
+ torso and its hard hat *is* the crown of its skull.
+ """
+ doomed = []
+ for poly in obj.data.polygons:
+ material = obj.data.materials[poly.material_index]
+ rule = slot_map.get(material.name) if material else None
+ if not isinstance(rule, dict) or "carve" not in rule:
+ continue
+ x, _y, z = _face_centroid(obj, poly)
+ if "z" in rule and z >= float(rule["z"]):
+ continue
+ if abs(x) < float(rule["carve"].get("abs_x_below", 0.0)):
+ doomed.append(poly.index)
+ 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" carved {len(doomed)} brow faces")
+
+
+def _quaternius_slot_map(obj, slot_map):
+ """Repoint the upstream materials onto the contract slots.
+
+ Nothing is deleted. The upstream Worker is modular: `Worker_Vest` *is* the
+ torso and `Worker_Yellow` above z 1.65 *is* the crown of the skull, so
+ dropping either leaves real holes in the character (that is what shipped in
+ the first 0.9.9 build). Instead every polygon is repointed at a slot, and a
+ rule can split one upstream material by height:
+
+ {"split": True, "z": 1.65, "above": "hair", "below": "keep"}
Several upstream materials can share a slot (trousers + patches), so the
polygons are repointed at one Blender material instead of relying on a
rename that Blender would de-duplicate to `pants.001`.
"""
materials = [m for m in obj.data.materials if m is not None]
- dropped = {i for i, m in enumerate(materials) if m.name in drop}
- kept = []
- target = {}
- remap = {}
- for index, m in enumerate(materials):
- slot = slot_map.get(m.name, "keep")
- if slot == "keep":
- if m.name not in target:
- target[m.name] = len(kept)
- kept.append(m)
- remap[index] = target[m.name]
- continue
- if slot in target:
- remap[index] = target[slot]
- continue
- m.name = slot
- target[slot] = len(kept)
- kept.append(m)
- remap[index] = target[slot]
- # Faces that only exist for a dropped material go away with it.
- if dropped:
- doomed = [p.index for p in obj.data.polygons
- if p.material_index in dropped]
- if doomed:
- 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")
- bm.to_mesh(obj.data)
- bm.free()
- # Loose vertices left behind by the helmet/vest would export as an
- # unreferenced blob, so drop them before the UV pass.
- bm = bmesh.new()
- bm.from_mesh(obj.data)
- 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()
- # `materials.clear()` resets every polygon to slot 0, so record the source
- # indices first or the whole mesh collapses onto the first slot.
- source_index = [poly.material_index for poly in obj.data.polygons]
+ by_name = {m.name: m for m in materials}
+ # Read the source state before touching the material list: clearing it resets
+ # every polygon to slot 0.
+ source_names = []
+ centroids = []
+ for poly in obj.data.polygons:
+ src = materials[poly.material_index] if poly.material_index < len(materials) else None
+ source_names.append(src.name if src else "")
+ co = [obj.data.vertices[v].co for v in poly.vertices]
+ centroids.append(sum(c.z for c in co) / max(len(co), 1))
+
+ targets = []
+
+ def target_index(key, source):
+ if key in index_of_slot:
+ return index_of_slot[key]
+ if key[0] == "keep":
+ mat = source
+ else:
+ # A copy carries the authored colour into the slot, and keeps the
+ # source material untouched for the other slots that share it.
+ mat = source.copy()
+ mat.name = key[1]
+ index_of_slot[key] = len(targets)
+ targets.append(mat)
+ return index_of_slot[key]
+
+ index_of_slot = {}
+ resolved = []
+ for name, z in zip(source_names, centroids):
+ rule = slot_map.get(name, "keep")
+ if isinstance(rule, dict) and rule.get("split"):
+ slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
+ key = ("keep", name) if slot == "keep" else ("slot", slot)
+ elif rule == "keep" or rule is None:
+ key = ("keep", name)
+ else:
+ key = ("slot", rule)
+ resolved.append(target_index(key, by_name[name]) if name in by_name else 0)
+
obj.data.materials.clear()
- for m in kept:
+ for m in targets:
obj.data.materials.append(m)
- for poly, index in zip(obj.data.polygons, source_index):
- poly.material_index = remap.get(index, 0)
+ for poly, index in zip(obj.data.polygons, resolved):
+ poly.material_index = index
def _bake_quaternius_rest(arm, mesh):
@@ -1579,7 +1746,10 @@ def _bake_quaternius_rest(arm, mesh):
# Local X swings the arm in the frontal plane. This is only symmetric on
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
# the build always round-trips through `_quaternius_tpose_glb()` first.
- pose_bone.rotation_euler = (-math.pi / 2, 0, 0)
+ # 83 instead of 90 degrees leaves the arm 7 degrees out from vertical: on
+ # the narrow `casual` base a perfectly vertical arm intersects the hip
+ # and torso (owner report: dress, jacket, crowd_a).
+ pose_bone.rotation_euler = (math.radians(-83.0), 0, 0)
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
for side in ("L", "R"):
@@ -1655,15 +1825,19 @@ def _quaternius_work_dir():
return path
-def _quaternius_tpose_glb(base, drop):
+def _quaternius_tpose_glb(base, overrides):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the
upstream file mirrors the bone rolls; this round trip normalises them. The
- result depends only on the source and the dropped materials, so it is cached
+ result depends only on the source and the slot overrides, so it is cached
for the run.
"""
- key = base + ("-" + "-".join(sorted(drop)) if drop else "")
+ # 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
+ if overrides:
+ key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb"
if path.is_file():
return path
@@ -1696,7 +1870,11 @@ def _quaternius_tpose_glb(base, drop):
obj.name = "npc_base"
obj.data.name = "npc_base"
- _quaternius_slot_map(obj, source["slots"], drop)
+ slots = dict(source["slots"])
+ slots.update(overrides)
+ if source.get("donor_brows"):
+ _transplant_brows(obj)
+ _quaternius_slot_map(obj, slots)
# 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
@@ -1749,7 +1927,7 @@ def _quaternius_tpose_glb(base, drop):
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("drop", []))
+ tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}))
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")