Fix the worker torso/crown holes, transplant proper brows, widen the rest-arm clearance
No files matched your search
@@ -870,6 +870,32 @@ regressing gear fit, budget or readability.
|
|||||||
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
|
- [ ] 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.
|
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
|
- [-] 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,
|
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
|
prompt and spend. (Roster family landed in one commit because the three sources share one
|
||||||
|
|||||||
@@ -431,12 +431,12 @@
|
|||||||
"base": "hoodie",
|
"base": "hoodie",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.228,
|
0.262,
|
||||||
1.866,
|
1.866,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.228,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.091
|
-0.091
|
||||||
]
|
]
|
||||||
@@ -474,12 +474,12 @@
|
|||||||
"base": "hoodie",
|
"base": "hoodie",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.228,
|
0.262,
|
||||||
1.866,
|
1.866,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.228,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.091
|
-0.091
|
||||||
]
|
]
|
||||||
@@ -516,12 +516,12 @@
|
|||||||
"base": "casual",
|
"base": "casual",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.18,
|
0.22,
|
||||||
1.843,
|
1.843,
|
||||||
0.271
|
0.271
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.18,
|
-0.22,
|
||||||
-0.009,
|
-0.009,
|
||||||
-0.099
|
-0.099
|
||||||
]
|
]
|
||||||
@@ -551,18 +551,18 @@
|
|||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 6424,
|
"triangles": 6424,
|
||||||
"vertices": 13440
|
"vertices": 13439
|
||||||
},
|
},
|
||||||
"npc_crowd_b": {
|
"npc_crowd_b": {
|
||||||
"base": "casual",
|
"base": "casual",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.18,
|
0.22,
|
||||||
1.843,
|
1.843,
|
||||||
0.271
|
0.271
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.18,
|
-0.22,
|
||||||
-0.009,
|
-0.009,
|
||||||
-0.099
|
-0.099
|
||||||
]
|
]
|
||||||
@@ -592,18 +592,18 @@
|
|||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 6424,
|
"triangles": 6424,
|
||||||
"vertices": 13440
|
"vertices": 13439
|
||||||
},
|
},
|
||||||
"npc_crowd_c": {
|
"npc_crowd_c": {
|
||||||
"base": "hoodie",
|
"base": "hoodie",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.228,
|
0.262,
|
||||||
1.866,
|
1.866,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.228,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.091
|
-0.091
|
||||||
]
|
]
|
||||||
@@ -639,14 +639,14 @@
|
|||||||
"base": "worker",
|
"base": "worker",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.217,
|
0.262,
|
||||||
1.826,
|
1.863,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.217,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.065
|
-0.103
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "npc",
|
"category": "npc",
|
||||||
@@ -658,7 +658,8 @@
|
|||||||
"hair",
|
"hair",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
"Eye",
|
||||||
"pants"
|
"pants",
|
||||||
|
"Brows"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
"path": "assets/models/blender/npc/npc_crowd_d.glb",
|
"path": "assets/models/blender/npc/npc_crowd_d.glb",
|
||||||
@@ -673,19 +674,19 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 4458,
|
"triangles": 5258,
|
||||||
"vertices": 9370
|
"vertices": 10980
|
||||||
},
|
},
|
||||||
"npc_dress": {
|
"npc_dress": {
|
||||||
"base": "casual",
|
"base": "casual",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.18,
|
0.22,
|
||||||
1.843,
|
1.843,
|
||||||
0.271
|
0.271
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.18,
|
-0.22,
|
||||||
-0.009,
|
-0.009,
|
||||||
-0.099
|
-0.099
|
||||||
]
|
]
|
||||||
@@ -714,20 +715,20 @@
|
|||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 6424,
|
"triangles": 6424,
|
||||||
"vertices": 13440
|
"vertices": 13439
|
||||||
},
|
},
|
||||||
"npc_firefighter": {
|
"npc_firefighter": {
|
||||||
"base": "worker",
|
"base": "worker",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.217,
|
0.262,
|
||||||
1.826,
|
1.863,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.217,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.065
|
-0.103
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "npc",
|
"category": "npc",
|
||||||
@@ -739,7 +740,8 @@
|
|||||||
"hair",
|
"hair",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
"Eye",
|
||||||
"pants"
|
"pants",
|
||||||
|
"Brows"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
"path": "assets/models/blender/npc/npc_firefighter.glb",
|
"path": "assets/models/blender/npc/npc_firefighter.glb",
|
||||||
@@ -753,21 +755,21 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 4458,
|
"triangles": 5258,
|
||||||
"vertices": 9370
|
"vertices": 10980
|
||||||
},
|
},
|
||||||
"npc_guard": {
|
"npc_guard": {
|
||||||
"base": "worker",
|
"base": "worker",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.217,
|
0.262,
|
||||||
1.826,
|
1.863,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.217,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.065
|
-0.103
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"category": "npc",
|
"category": "npc",
|
||||||
@@ -779,7 +781,8 @@
|
|||||||
"hair",
|
"hair",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
"Eye",
|
||||||
"pants"
|
"pants",
|
||||||
|
"Brows"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
"path": "assets/models/blender/npc/npc_guard.glb",
|
"path": "assets/models/blender/npc/npc_guard.glb",
|
||||||
@@ -794,19 +797,19 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 4458,
|
"triangles": 5258,
|
||||||
"vertices": 9370
|
"vertices": 10980
|
||||||
},
|
},
|
||||||
"npc_hoodie": {
|
"npc_hoodie": {
|
||||||
"base": "hoodie",
|
"base": "hoodie",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.228,
|
0.262,
|
||||||
1.866,
|
1.866,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.228,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.091
|
-0.091
|
||||||
]
|
]
|
||||||
@@ -843,12 +846,12 @@
|
|||||||
"base": "casual",
|
"base": "casual",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.18,
|
0.22,
|
||||||
1.843,
|
1.843,
|
||||||
0.271
|
0.271
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.18,
|
-0.22,
|
||||||
-0.009,
|
-0.009,
|
||||||
-0.099
|
-0.099
|
||||||
]
|
]
|
||||||
@@ -879,18 +882,18 @@
|
|||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 6424,
|
"triangles": 6424,
|
||||||
"vertices": 13440
|
"vertices": 13439
|
||||||
},
|
},
|
||||||
"npc_vest": {
|
"npc_vest": {
|
||||||
"base": "worker",
|
"base": "worker",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.217,
|
0.262,
|
||||||
1.863,
|
1.863,
|
||||||
0.287
|
0.287
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.217,
|
-0.262,
|
||||||
-0.004,
|
-0.004,
|
||||||
-0.103
|
-0.103
|
||||||
]
|
]
|
||||||
@@ -900,13 +903,13 @@
|
|||||||
"materials": [
|
"materials": [
|
||||||
"skin",
|
"skin",
|
||||||
"Worker_Yellow",
|
"Worker_Yellow",
|
||||||
"Worker_Vest",
|
|
||||||
"cloth",
|
"cloth",
|
||||||
"accessory",
|
"accessory",
|
||||||
"hair",
|
"hair",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
"Eye",
|
||||||
"pants"
|
"pants",
|
||||||
|
"Brows"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
"path": "assets/models/blender/npc/npc_vest.glb",
|
"path": "assets/models/blender/npc/npc_vest.glb",
|
||||||
@@ -920,8 +923,8 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 5240,
|
"triangles": 5258,
|
||||||
"vertices": 10936
|
"vertices": 10980
|
||||||
},
|
},
|
||||||
"palm_tree": {
|
"palm_tree": {
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 70 KiB |
@@ -18,6 +18,10 @@ capture harnesses.
|
|||||||
| [](sheet_npc_baseline_compare-tex256.jpg) <br> `sheet_npc_baseline_compare-tex256.jpg` | 128 -> 256 px NPC slot maps on the same camera. |
|
| [](sheet_npc_baseline_compare-tex256.jpg) <br> `sheet_npc_baseline_compare-tex256.jpg` | 128 -> 256 px NPC slot maps on the same camera. |
|
||||||
| [](sheet_npc_baseline_compare-texuv.jpg) <br> `sheet_npc_baseline_compare-texuv.jpg` | 4x fabric weave with the baked per-character AO removed. |
|
| [](sheet_npc_baseline_compare-texuv.jpg) <br> `sheet_npc_baseline_compare-texuv.jpg` | 4x fabric weave with the baked per-character AO removed. |
|
||||||
| [](sheet_npc_baseline_compare-torso.jpg) <br> `sheet_npc_baseline_compare-torso.jpg` | Torso/hip taper without changing the skeleton or build multipliers. |
|
| [](sheet_npc_baseline_compare-torso.jpg) <br> `sheet_npc_baseline_compare-torso.jpg` | Torso/hip taper without changing the skeleton or build multipliers. |
|
||||||
|
| [](sheet_npc_roster_0.9.9.jpg) <br> `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) <br> `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) <br> `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) <br> `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) <br> `sheet_npc_crowd_prototypes.jpg` | Four approved crowd-variety looks queued for the 12-character roster. |
|
| [](sheet_npc_crowd_prototypes.jpg) <br> `sheet_npc_crowd_prototypes.jpg` | Four approved crowd-variety looks queued for the 12-character roster. |
|
||||||
| [](sheet_npc_gear.jpg) <br> `sheet_npc_gear.jpg` | Eighteen gear pieces authored in attach space: caps, hood, helmet, bags, tanks, lanyards and handheld items. |
|
| [](sheet_npc_gear.jpg) <br> `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) <br> `sheet_npc_hands_compare.jpg` | Hand pass: wider palms, segmented fingers and a separate thumb. |
|
| [](sheet_npc_hands_compare.jpg) <br> `sheet_npc_hands_compare.jpg` | Hand pass: wider palms, segmented fingers and a separate thumb. |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 211 KiB |
@@ -45,19 +45,44 @@ straight into `TODO.md`.
|
|||||||
|
|
||||||
| # | Question | Verdict |
|
| # | Question | Verdict |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | Do the twelve characters read as distinct people at 8 m? | ☐ 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)? | ☐ 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 ☐ 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 ☐ 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 ☐ 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)? | ☐ yes ☐ 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? | ☐ 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? | ☐ 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
|
## 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`:
|
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
|
1. **`npc_dress` wears the performer cone hat plus the protest sign** because the performer props
|
||||||
|
|||||||
@@ -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;
|
# pick one per avatar later. `tint` lists the slots the runtime should tint;
|
||||||
# every other material keeps its authored colour.
|
# every other material keeps its authored colour.
|
||||||
# `base` selects the CC0 Quaternius source the character is baked from
|
# `base` selects the CC0 Quaternius source the character is baked from
|
||||||
# (ADR-0017/ADR-0018, owner chose the full roster swap); `drop` removes upstream
|
# (ADR-0017/ADR-0018, owner chose the full roster swap); `slots` overrides the
|
||||||
# materials that do not belong to the role (the Worker's hivis vest and hard hat).
|
# 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`.
|
# Rebuild one procedurally with `build --source procedural --head mh`.
|
||||||
CHARACTERS = {
|
CHARACTERS = {
|
||||||
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
|
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
|
||||||
@@ -101,6 +102,10 @@ CHARACTERS = {
|
|||||||
"roles": ["influencer", "trespasser", "violent_threat"]},
|
"roles": ["influencer", "trespasser", "violent_threat"]},
|
||||||
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
|
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
|
||||||
"base": "worker",
|
"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"]},
|
"roles": ["delivery"]},
|
||||||
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
|
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
|
||||||
"gear": ["shoulder_bag"],
|
"gear": ["shoulder_bag"],
|
||||||
@@ -108,11 +113,11 @@ CHARACTERS = {
|
|||||||
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
|
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
|
||||||
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
|
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
|
||||||
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
|
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
|
||||||
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
|
"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": "buzz", "build": "broad",
|
||||||
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
|
"base": "worker",
|
||||||
"gear": ["cap", "wand"],
|
"gear": ["cap", "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
|
||||||
@@ -125,7 +130,7 @@ CHARACTERS = {
|
|||||||
"npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
|
"npc_crowd_c": {"layer": "coat", "hair": "short", "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", "drop": ["Worker_Vest", "Worker_Yellow"],
|
"base": "worker",
|
||||||
"gear": ["cup"], "roles": ["tourist", "guest"]},
|
"gear": ["cup"], "roles": ["tourist", "guest"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1491,72 +1496,234 @@ QUATERNIUS_SOURCES = {
|
|||||||
"file": "Worker.glb",
|
"file": "Worker.glb",
|
||||||
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
|
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
|
||||||
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
|
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
|
||||||
# The Worker's head is bare apart from a hard hat, so the
|
# The modular Worker has no torso under the vest and no crown
|
||||||
# brows carry the hair tint and the slot keeps a surface.
|
# 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",
|
"Eyebrows": "hair",
|
||||||
"Eye": "keep", "Moustache": "keep",
|
"Brows": "keep",
|
||||||
"Worker_Vest": "keep", "Worker_Yellow": "keep"},
|
"Eye": "keep", "Moustache": "keep"},
|
||||||
"notes": "work shirt + trousers + hivis vest + hard hat",
|
"donor_brows": True,
|
||||||
|
"notes": "work shirt (vest geometry) + trousers + hard hat/crown",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quaternius_slot_map(obj, slot_map, drop):
|
## The modular Worker's `Eyebrows` material is one continuous bar across the
|
||||||
"""Rename the upstream materials onto the contract slots.
|
## 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
|
Several upstream materials can share a slot (trousers + patches), so the
|
||||||
polygons are repointed at one Blender material instead of relying on a
|
polygons are repointed at one Blender material instead of relying on a
|
||||||
rename that Blender would de-duplicate to `pants.001`.
|
rename that Blender would de-duplicate to `pants.001`.
|
||||||
"""
|
"""
|
||||||
materials = [m for m in obj.data.materials if m is not None]
|
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}
|
by_name = {m.name: m for m in materials}
|
||||||
kept = []
|
# Read the source state before touching the material list: clearing it resets
|
||||||
target = {}
|
# every polygon to slot 0.
|
||||||
remap = {}
|
source_names = []
|
||||||
for index, m in enumerate(materials):
|
centroids = []
|
||||||
slot = slot_map.get(m.name, "keep")
|
for poly in obj.data.polygons:
|
||||||
if slot == "keep":
|
src = materials[poly.material_index] if poly.material_index < len(materials) else None
|
||||||
if m.name not in target:
|
source_names.append(src.name if src else "")
|
||||||
target[m.name] = len(kept)
|
co = [obj.data.vertices[v].co for v in poly.vertices]
|
||||||
kept.append(m)
|
centroids.append(sum(c.z for c in co) / max(len(co), 1))
|
||||||
remap[index] = target[m.name]
|
|
||||||
continue
|
targets = []
|
||||||
if slot in target:
|
|
||||||
remap[index] = target[slot]
|
def target_index(key, source):
|
||||||
continue
|
if key in index_of_slot:
|
||||||
m.name = slot
|
return index_of_slot[key]
|
||||||
target[slot] = len(kept)
|
if key[0] == "keep":
|
||||||
kept.append(m)
|
mat = source
|
||||||
remap[index] = target[slot]
|
else:
|
||||||
# Faces that only exist for a dropped material go away with it.
|
# A copy carries the authored colour into the slot, and keeps the
|
||||||
if dropped:
|
# source material untouched for the other slots that share it.
|
||||||
doomed = [p.index for p in obj.data.polygons
|
mat = source.copy()
|
||||||
if p.material_index in dropped]
|
mat.name = key[1]
|
||||||
if doomed:
|
index_of_slot[key] = len(targets)
|
||||||
bm = bmesh.new()
|
targets.append(mat)
|
||||||
bm.from_mesh(obj.data)
|
return index_of_slot[key]
|
||||||
bm.faces.ensure_lookup_table()
|
|
||||||
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed],
|
index_of_slot = {}
|
||||||
context="FACES")
|
resolved = []
|
||||||
bm.to_mesh(obj.data)
|
for name, z in zip(source_names, centroids):
|
||||||
bm.free()
|
rule = slot_map.get(name, "keep")
|
||||||
# Loose vertices left behind by the helmet/vest would export as an
|
if isinstance(rule, dict) and rule.get("split"):
|
||||||
# unreferenced blob, so drop them before the UV pass.
|
slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
|
||||||
bm = bmesh.new()
|
key = ("keep", name) if slot == "keep" else ("slot", slot)
|
||||||
bm.from_mesh(obj.data)
|
elif rule == "keep" or rule is None:
|
||||||
loose = [v for v in bm.verts if not v.link_faces]
|
key = ("keep", name)
|
||||||
if loose:
|
else:
|
||||||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
key = ("slot", rule)
|
||||||
bm.to_mesh(obj.data)
|
resolved.append(target_index(key, by_name[name]) if name in by_name else 0)
|
||||||
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]
|
|
||||||
obj.data.materials.clear()
|
obj.data.materials.clear()
|
||||||
for m in kept:
|
for m in targets:
|
||||||
obj.data.materials.append(m)
|
obj.data.materials.append(m)
|
||||||
for poly, index in zip(obj.data.polygons, source_index):
|
for poly, index in zip(obj.data.polygons, resolved):
|
||||||
poly.material_index = remap.get(index, 0)
|
poly.material_index = index
|
||||||
|
|
||||||
|
|
||||||
def _bake_quaternius_rest(arm, mesh):
|
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
|
# 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
|
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
|
||||||
# the build always round-trips through `_quaternius_tpose_glb()` first.
|
# 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.ops.object.mode_set(mode="OBJECT")
|
||||||
bpy.context.view_layer.update()
|
bpy.context.view_layer.update()
|
||||||
for side in ("L", "R"):
|
for side in ("L", "R"):
|
||||||
@@ -1655,15 +1825,19 @@ def _quaternius_work_dir():
|
|||||||
return path
|
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.
|
"""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
|
||||||
upstream file mirrors the bone rolls; this round trip normalises them. 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.
|
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"
|
path = _quaternius_work_dir() / f"tpose_{key}.glb"
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return path
|
return path
|
||||||
@@ -1696,7 +1870,11 @@ def _quaternius_tpose_glb(base, drop):
|
|||||||
obj.name = "npc_base"
|
obj.name = "npc_base"
|
||||||
obj.data.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
|
# 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
|
||||||
@@ -1749,7 +1927,7 @@ def _quaternius_tpose_glb(base, drop):
|
|||||||
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("drop", []))
|
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}))
|
||||||
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")
|
||||||
|
|||||||