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)](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)](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)](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)](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)](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)](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)](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)](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)](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)](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")