diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index a9edbca..5e97cd9 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -121,14 +121,14 @@ "attach": "head", "bounds_godot": { "max": [ - 0.168, - 0.209, - 0.258 + 0.108, + 0.204, + 0.165 ], "min": [ - -0.168, - 0.037, - -0.186 + -0.108, + 0.094, + -0.119 ] }, "category": "npc_gear", @@ -264,14 +264,14 @@ "attach": "head", "bounds_godot": { "max": [ - 0.26, - 0.245, - 0.26 + 0.166, + 0.227, + 0.166 ], "min": [ - -0.26, - 0.035, - -0.351 + -0.166, + 0.092, + -0.225 ] }, "category": "npc_gear", @@ -333,14 +333,14 @@ "attach": "head", "bounds_godot": { "max": [ - 0.184, - 0.236, - 0.123 + 0.118, + 0.221, + 0.079 ], "min": [ - -0.184, - -0.146, - -0.225 + -0.118, + -0.024, + -0.144 ] }, "category": "npc_gear", @@ -428,6 +428,7 @@ "vertices": 648 }, "npc_casual": { + "base": "hoodie", "bounds_godot": { "max": [ 0.228, @@ -470,16 +471,17 @@ "vertices": 12988 }, "npc_coat": { + "base": "hoodie", "bounds_godot": { "max": [ - 0.332, - 1.824, - 0.189 + 0.228, + 1.866, + 0.287 ], "min": [ - -0.332, - 0, - -0.18 + -0.228, + -0.004, + -0.091 ] }, "category": "npc", @@ -487,11 +489,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Eyebrows", + "Eye", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_coat.glb", @@ -507,20 +509,21 @@ "accessory", "hair" ], - "triangles": 6046, - "vertices": 5645 + "triangles": 6206, + "vertices": 12988 }, "npc_crowd_a": { + "base": "casual", "bounds_godot": { "max": [ - 0.288, - 1.825, - 0.189 + 0.18, + 1.843, + 0.271 ], "min": [ - -0.288, - 0, - -0.195 + -0.18, + -0.009, + -0.099 ] }, "category": "npc", @@ -528,11 +531,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Hair_Brown", + "Brown", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_crowd_a.glb", @@ -547,20 +550,21 @@ "accessory", "hair" ], - "triangles": 5284, - "vertices": 4908 + "triangles": 6424, + "vertices": 13440 }, "npc_crowd_b": { + "base": "casual", "bounds_godot": { "max": [ - 0.358, - 1.809, - 0.189 + 0.18, + 1.843, + 0.271 ], "min": [ - -0.358, - 0, - -0.155 + -0.18, + -0.009, + -0.099 ] }, "category": "npc", @@ -568,11 +572,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Hair_Brown", + "Brown", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_crowd_b.glb", @@ -587,20 +591,21 @@ "accessory", "hair" ], - "triangles": 4812, - "vertices": 4550 + "triangles": 6424, + "vertices": 13440 }, "npc_crowd_c": { + "base": "hoodie", "bounds_godot": { "max": [ - 0.332, - 1.824, - 0.189 + 0.228, + 1.866, + 0.287 ], "min": [ - -0.332, - 0, - -0.18 + -0.228, + -0.004, + -0.091 ] }, "category": "npc", @@ -608,11 +613,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Eyebrows", + "Eye", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_crowd_c.glb", @@ -627,20 +632,21 @@ "accessory", "hair" ], - "triangles": 5690, - "vertices": 5510 + "triangles": 6206, + "vertices": 12988 }, "npc_crowd_d": { + "base": "worker", "bounds_godot": { "max": [ - 0.293, - 1.856, - 0.189 + 0.217, + 1.826, + 0.287 ], "min": [ - -0.293, - 0, - -0.225 + -0.217, + -0.004, + -0.065 ] }, "category": "npc", @@ -648,11 +654,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", "hair", - "rubber" + "Moustache", + "Eye", + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_crowd_d.glb", @@ -667,20 +673,21 @@ "accessory", "hair" ], - "triangles": 5810, - "vertices": 5444 + "triangles": 4458, + "vertices": 9370 }, "npc_dress": { + "base": "casual", "bounds_godot": { "max": [ - 0.288, - 1.825, - 0.208 + 0.18, + 1.843, + 0.271 ], "min": [ - -0.288, - 0, - -0.208 + -0.18, + -0.009, + -0.099 ] }, "category": "npc", @@ -688,11 +695,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Hair_Brown", + "Brown", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_dress.glb", @@ -706,20 +713,21 @@ "accessory", "hair" ], - "triangles": 5540, - "vertices": 5073 + "triangles": 6424, + "vertices": 13440 }, "npc_firefighter": { + "base": "worker", "bounds_godot": { "max": [ - 0.37, - 1.819, - 0.213 + 0.217, + 1.826, + 0.287 ], "min": [ - -0.37, - 0, - -0.213 + -0.217, + -0.004, + -0.065 ] }, "category": "npc", @@ -727,13 +735,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", "hair", - "hivis", - "reflective", - "rubber" + "Moustache", + "Eye", + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_firefighter.glb", @@ -747,20 +753,21 @@ "accessory", "hair" ], - "triangles": 5888, - "vertices": 5785 + "triangles": 4458, + "vertices": 9370 }, "npc_guard": { + "base": "worker", "bounds_godot": { "max": [ - 0.361, - 1.819, - 0.189 + 0.217, + 1.826, + 0.287 ], "min": [ - -0.361, - 0, - -0.172 + -0.217, + -0.004, + -0.065 ] }, "category": "npc", @@ -768,15 +775,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", "hair", - "rubber", - "leather", - "steel", - "patch", - "plastic_black" + "Moustache", + "Eye", + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_guard.glb", @@ -791,20 +794,21 @@ "accessory", "hair" ], - "triangles": 5670, - "vertices": 5671 + "triangles": 4458, + "vertices": 9370 }, "npc_hoodie": { + "base": "hoodie", "bounds_godot": { "max": [ - 0.326, - 1.856, - 0.189 + 0.228, + 1.866, + 0.287 ], "min": [ - -0.326, - 0, - -0.225 + -0.228, + -0.004, + -0.091 ] }, "category": "npc", @@ -812,11 +816,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Eyebrows", + "Eye", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_hoodie.glb", @@ -832,20 +836,21 @@ "accessory", "hair" ], - "triangles": 5864, - "vertices": 5325 + "triangles": 6206, + "vertices": 12988 }, "npc_jacket": { + "base": "casual", "bounds_godot": { "max": [ - 0.332, - 1.824, - 0.189 + 0.18, + 1.843, + 0.271 ], "min": [ - -0.332, - 0, - -0.178 + -0.18, + -0.009, + -0.099 ] }, "category": "npc", @@ -853,11 +858,11 @@ "materials": [ "skin", "cloth", - "pants", "accessory", - "eye_white", + "Hair_Brown", + "Brown", "hair", - "rubber" + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_jacket.glb", @@ -873,34 +878,35 @@ "accessory", "hair" ], - "triangles": 5432, - "vertices": 5225 + "triangles": 6424, + "vertices": 13440 }, "npc_vest": { + "base": "worker", "bounds_godot": { "max": [ - 0.321, - 1.819, - 0.189 + 0.217, + 1.863, + 0.287 ], "min": [ - -0.321, - 0, - -0.172 + -0.217, + -0.004, + -0.103 ] }, "category": "npc", "layer": "vest", "materials": [ "skin", + "Worker_Yellow", + "Worker_Vest", "cloth", - "pants", "accessory", - "eye_white", "hair", - "rubber", - "hivis", - "reflective" + "Moustache", + "Eye", + "pants" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_vest.glb", @@ -914,8 +920,8 @@ "accessory", "hair" ], - "triangles": 5532, - "vertices": 5195 + "triangles": 5240, + "vertices": 10936 }, "palm_tree": { "bounds_godot": { @@ -1239,14 +1245,14 @@ "attach": "head", "bounds_godot": { "max": [ - 0.101, - 0.059, - 0.158 + 0.065, + 0.108, + 0.101 ], "min": [ - -0.101, - 0.011, - 0.003 + -0.065, + 0.077, + 0.002 ] }, "category": "npc_gear", diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb index d27e65b..ac93d49 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 9beb4d5..0cf2ffb 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 e8c26f4..7bc425a 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 30cabad..643e6e1 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 fde6cf4..7bc425a 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 88f3fc0..643e6e1 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 52e9d7c..ac93d49 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 57cf8bf..0cf2ffb 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 413fde9..42041df 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 3c1715f..82b18b3 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 2cb4300..7bc425a 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 1806c34..643e6e1 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 e6e8f27..42041df 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 e1482c6..82b18b3 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 5d48ff4..42041df 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 a7ae64f..82b18b3 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 e1357ad..ac93d49 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 e8d672e..0cf2ffb 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 8a896d2..7bc425a 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 33c7801..643e6e1 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 a65609f..60b10fe 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 b16e38a..aefc63f 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/assets/models/blender/npc_gear/cap.glb b/assets/models/blender/npc_gear/cap.glb index df1ddae..f52f3d2 100644 Binary files a/assets/models/blender/npc_gear/cap.glb and b/assets/models/blender/npc_gear/cap.glb differ diff --git a/assets/models/blender/npc_gear/cap_ao.png b/assets/models/blender/npc_gear/cap_ao.png index acce34e..75bd4f5 100644 Binary files a/assets/models/blender/npc_gear/cap_ao.png and b/assets/models/blender/npc_gear/cap_ao.png differ diff --git a/assets/models/blender/npc_gear/firefighter_helmet.glb b/assets/models/blender/npc_gear/firefighter_helmet.glb index b7477a0..db0ba0e 100644 Binary files a/assets/models/blender/npc_gear/firefighter_helmet.glb and b/assets/models/blender/npc_gear/firefighter_helmet.glb differ diff --git a/assets/models/blender/npc_gear/firefighter_helmet_ao.png b/assets/models/blender/npc_gear/firefighter_helmet_ao.png index cf982e8..b0bfc9a 100644 Binary files a/assets/models/blender/npc_gear/firefighter_helmet_ao.png and b/assets/models/blender/npc_gear/firefighter_helmet_ao.png differ diff --git a/assets/models/blender/npc_gear/hood.glb b/assets/models/blender/npc_gear/hood.glb index a98f71e..9eefab3 100644 Binary files a/assets/models/blender/npc_gear/hood.glb and b/assets/models/blender/npc_gear/hood.glb differ diff --git a/assets/models/blender/npc_gear/hood_ao.png b/assets/models/blender/npc_gear/hood_ao.png index 76d8d59..8610b44 100644 Binary files a/assets/models/blender/npc_gear/hood_ao.png and b/assets/models/blender/npc_gear/hood_ao.png differ diff --git a/assets/models/blender/npc_gear/sunglasses.glb b/assets/models/blender/npc_gear/sunglasses.glb index 77c92c8..cd276d1 100644 Binary files a/assets/models/blender/npc_gear/sunglasses.glb and b/assets/models/blender/npc_gear/sunglasses.glb differ diff --git a/assets/models/blender/npc_gear/sunglasses_ao.png b/assets/models/blender/npc_gear/sunglasses_ao.png index 73d10a2..baba00a 100644 Binary files a/assets/models/blender/npc_gear/sunglasses_ao.png and b/assets/models/blender/npc_gear/sunglasses_ao.png differ diff --git a/assets/models/blender_src/quaternius/Casual.glb b/assets/models/blender_src/quaternius/Casual.glb new file mode 100644 index 0000000..a67f449 Binary files /dev/null and b/assets/models/blender_src/quaternius/Casual.glb differ diff --git a/assets/models/blender_src/quaternius/Casual_Hoodie.glb b/assets/models/blender_src/quaternius/Casual_Hoodie.glb new file mode 100644 index 0000000..bdae92b Binary files /dev/null and b/assets/models/blender_src/quaternius/Casual_Hoodie.glb differ diff --git a/assets/models/blender_src/quaternius/README.md b/assets/models/blender_src/quaternius/README.md new file mode 100644 index 0000000..fb9ee0e --- /dev/null +++ b/assets/models/blender_src/quaternius/README.md @@ -0,0 +1,33 @@ +# Quaternius build sources (CC0 1.0) + +These are the **build inputs** for the shipped NPC roster, not runtime assets. +`blender_src/.gdignore` keeps Godot from importing this directory. + +Source: Quaternius *Ultimate Modular Characters* and *Ultimate Modular Women* +(), licence CC0 1.0, +attribution recorded in the repository `CREDITS.md`. + +| File | SHA-256 | Bones | Meshes | +| --- | --- | --- | --- | +| `Casual.glb` | `bc2d84fba249ebaa0629cb9d133ad37234196e4b8004aa205a6397abed04e05c` | 62 | 4 skinned + Icosphere helper | +| `Casual_Hoodie.glb` | `f5b722199f7628b24550989af4230f65d5230727df9c326fd2b3e360effe5249` | 62 | 4 skinned + Icosphere helper | +| `Worker.glb` | `8d0f454336c2effc5c08f63a177af3a82d1a7af025ca9588b40e4c90c4703534` | 62 | 4 skinned + Icosphere helper | + +Each file also carries 24 animation clips; the build exports **no** animation data, so +the shipped GLBs contain the mesh, the 62-joint skin and the material slots only. + +Reproducing the shipped characters: + + flatpak run org.blender.Blender --background \ + --python "$PWD/tools/blender_assets.py" -- build --only npc + +Upstream quirks the build compensates for (see `docs/npc-model-sourcing.md`): + +1. every mesh ships a **degenerate UV map** (all loops at `0,0`) with flat-colour + materials, so the build Smart-UV-unwraps and repacks before export; +2. the meshes must be joined **without welding** — `remove_doubles` corrupts the + skin weights; +3. the rest pose is a T-pose, so the arm-down rest is baked by + `_bake_quaternius_rest()`; +4. the upstream material names differ per file, so each source carries a slot map + down to the project's five tint slots. diff --git a/assets/models/blender_src/quaternius/Worker.glb b/assets/models/blender_src/quaternius/Worker.glb new file mode 100644 index 0000000..3979f27 Binary files /dev/null and b/assets/models/blender_src/quaternius/Worker.glb differ diff --git a/docs/adr/0018-quaternius-stage2-runtime-rig.md b/docs/adr/0018-quaternius-stage2-runtime-rig.md index 67b25a6..6bbcbf9 100644 --- a/docs/adr/0018-quaternius-stage2-runtime-rig.md +++ b/docs/adr/0018-quaternius-stage2-runtime-rig.md @@ -73,3 +73,24 @@ full source rig. Resolved: option 2 (62-joint runtime) is the owner's choice. The first implementation step is the `NpcAvatar` bone-map + gear-attach re-derivation, then the slot/AO bake and the 12-variant rollout. + +## Follow-up decision: full roster swap + +- **Date:** 2026-11-05 (owner, at the `0.9.9` face/silhouette gate) + +The owner chose **option (a) full swap** from +[`docs/screens/npc-face-gate/`](../../screens/npc-face-gate/README.md): every roster character +moves onto the Quaternius base, and the procedural generator plus the MakeHuman head retire to +the tested fallback rather than staying in the shipped roster. + +Scope consequences: + +- The shipped roster keeps its twelve public character names and `CHARACTER_POOLS` mapping; the + geometry behind them comes from the three CC0 full-body sources already researched + (`Casual`, `Casual_Hoodie`, `Worker`), aliased per role. +- Variety now comes from the five tint slots, the gear library and the per-role outfit rules + instead of twelve distinct meshes. Roster silhouettes are therefore *less* varied than the + procedural library until the modular part library (hair, outfits) is added — tracked as a + `0.9.10` task, not hidden. +- The procedural library, `_build_body()` and the MakeHuman head stay in the tree and tested as + the instant fallback; no MPFB or Stage-1 proportion-warp work reopens. diff --git a/docs/screens/npc-face-gate/README.md b/docs/screens/npc-face-gate/README.md index 1670fba..477b0bf 100644 --- a/docs/screens/npc-face-gate/README.md +++ b/docs/screens/npc-face-gate/README.md @@ -57,5 +57,10 @@ hands and gear that the review still flags as weak. ## Recorded decision -Status: **awaiting owner sign-off.** Until then `npc_casual` ships as the pilot base and the other -eleven characters stay procedural. +Status: **option 1 (full swap) accepted by the owner, 2026-11-05.** Every roster character moves +onto the Quaternius base; the procedural library and the MakeHuman head become the tested +fallback. See the follow-up decision section in +[ADR-0018](../../adr/0018-quaternius-stage2-runtime-rig.md#follow-up-decision-full-roster-swap). + +Roster silhouettes are less varied than the procedural library until the modular part library +(hair, outfits) is added in the `0.9.10` roster workstream — recorded rather than hidden. diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 28edd94..2c13a40 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -85,37 +85,47 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender # Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can # 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). +# Rebuild one procedurally with `build --source procedural --head mh`. CHARACTERS = { "npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"], + "base": "hoodie", "roles": ["tourist", "confused_visitor", "intoxicated", "guest"]}, "npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"], + "base": "casual", "roles": ["authorized_staff", "protester", "guest"]}, - "npc_coat": {"layer": "coat", "hair": "long", + "npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie", "gear": ["sunglasses", "shoulder_bag", "phone"], "roles": ["influencer", "trespasser", "violent_threat"]}, "npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"], + "base": "worker", "roles": ["delivery"]}, - "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", + "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual", "gear": ["shoulder_bag"], "roles": ["performer"]}, - "npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], + "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"], "gear": ["firefighter_helmet", "scba_tank", "flashlight"], "roles": ["responder"]}, "npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", + "base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"], "gear": ["cap", "wand"], "roles": ["player_guard", "venue_security"]}, - # Crowd-variety prototypes (character development): vary build, head - # silhouette, hair/beard and outer layer without labelling behaviour. - "npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", + # Crowd-variety prototypes (character development): the three Quaternius + # sources are spread across the crowd pools so a crowd never shows one mesh. + "npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "base": "casual", "gear": ["sunglasses"], "roles": ["tourist", "guest"]}, - "npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", + "npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "base": "casual", "beard": True, "gear": ["shoulder_bag"], "roles": ["confused_visitor", "guest"]}, - "npc_crowd_c": {"layer": "coat", "hair": "short", + "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"], "gear": ["cup"], "roles": ["tourist", "guest"]}, } @@ -133,6 +143,11 @@ ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78), ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02), "back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)} HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres +# Head gear is authored at the procedural library's head size; the shipped +# Quaternius roster has a 0.20 m head whose centre sits higher (see +# `build_gear`). 0.64 = 0.201 / 0.315. +HEAD_GEAR_SCALE = 0.64 +HEAD_GEAR_LIFT = 0.07 FIT_HEAD_MATERIALS = ("skin",) FIT_CLOTHING_MATERIAL = "cloth" FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare @@ -1439,6 +1454,312 @@ def build_character(name, head="face"): return [arm, mesh] +# --------------------------------------------------------------------------- +# Quaternius roster (ADR-0017 / ADR-0018) +# --------------------------------------------------------------------------- +# +# The CC0 sources live under `blender_src/quaternius/` (gitignored for Godot via +# the parent `.gdignore`) and are build inputs, not runtime assets. Every source +# is 4 skinned meshes on the same 62-joint armature; the build joins them +# **without welding** (a weld corrupts the skin weights), renames the upstream +# materials onto the five tint slots, unwraps the degenerate source UVs and bakes +# the arm-down rest. See docs/npc-model-sourcing.md for the measurements. + +QUATERNIUS_SRC = SRC_DIR / "quaternius" + +# Material -> contract slot per source. `"keep"` leaves the authored colour +# (face, hair detail and hivis fabrics); anything not listed is kept too, so a +# new upstream material shows up as an extra fixed-colour slot instead of +# silently disappearing. The maps were derived from the per-material height bands +# measured in docs/npc-model-sourcing.md. +QUATERNIUS_SOURCES = { + "casual": { + "file": "Casual.glb", + "slots": {"Skin": "skin", "White": "cloth", "Orange": "pants", + "Grey": "accessory", "Hair_Blond": "hair", + "Brown": "keep", "Hair_Brown": "keep"}, + "notes": "white tee + orange trousers + grey shoes; blond hair", + }, + "hoodie": { + "file": "Casual_Hoodie.glb", + "slots": {"Skin": "skin", "Purple": "cloth", "LightBlue": "pants", + "Hair": "hair", "White": "accessory", + "Eye": "keep", "Eyebrows": "keep"}, + "notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)", + }, + "worker": { + "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. + "Eyebrows": "hair", + "Eye": "keep", "Moustache": "keep", + "Worker_Vest": "keep", "Worker_Yellow": "keep"}, + "notes": "work shirt + trousers + hivis vest + hard hat", + }, +} + + +def _quaternius_slot_map(obj, slot_map, drop): + """Rename the upstream materials onto the contract slots. + + 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] + obj.data.materials.clear() + for m in kept: + obj.data.materials.append(m) + for poly, index in zip(obj.data.polygons, source_index): + poly.material_index = remap.get(index, 0) + + +def _bake_quaternius_rest(arm, mesh): + """Bake the T-pose into an arm-down rest pose. + + This runs on the re-imported T-pose export, not on the raw upstream file. + The order matters: pose, read the posed matrices, apply the Armature + modifier, **reset the pose to identity**, write the rest matrices, re-add the + modifier. Skipping the reset double-applies the arm-down (mesh width ±0.53 m + instead of ±0.23 m); `bpy.ops.object.armature_apply()` in Blender 5.2 + deforms the mesh without baking the rest and is not usable here. + """ + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="POSE") + for side in ("L", "R"): + pose_bone = arm.pose.bones.get("UpperArm." + side) + if pose_bone is None: + continue + pose_bone.rotation_mode = "XYZ" + # 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) + bpy.ops.object.mode_set(mode="OBJECT") + bpy.context.view_layer.update() + for side in ("L", "R"): + wrist = arm.pose.bones.get("Wrist." + side) + if wrist is not None: + head = arm.matrix_world @ wrist.head + print(f" wrist.{side} at x={head.x:+.3f} y={head.y:+.3f} z={head.z:+.3f}") + + fingers = ("Thumb", "Index", "Middle", "Ring", "Pinky") + posed = {} + for side in ("L", "R"): + for bone in arm.data.bones: + name = bone.name + if not name.endswith(side): + continue + if name.startswith(("UpperArm", "LowerArm", "Wrist")) or name.startswith(fingers): + posed[name] = arm.pose.bones[name].matrix.copy() + + arm_modifier = next(m for m in mesh.modifiers if m.type == "ARMATURE") + bpy.context.view_layer.objects.active = mesh + bpy.ops.object.modifier_apply(modifier=arm_modifier.name) + + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="POSE") + for pose_bone in arm.pose.bones: + pose_bone.matrix_basis.identity() + bpy.ops.object.mode_set(mode="OBJECT") + bpy.context.view_layer.update() + + bpy.context.view_layer.objects.active = arm + bpy.ops.object.mode_set(mode="EDIT") + for name, matrix in posed.items(): + arm.data.edit_bones[name].matrix = matrix + bpy.ops.object.mode_set(mode="OBJECT") + bpy.context.view_layer.update() + + bpy.context.view_layer.objects.active = mesh + modifier = mesh.modifiers.new("Armature", "ARMATURE") + modifier.object = arm + bpy.context.view_layer.update() + evaluated = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get()) + xs = [v.co.x for v in evaluated.data.vertices] + print(f" arm-down rest baked, deformed x {min(xs):.3f}..{max(xs):.3f}") + + +def _export_preserving_skin(objs, category, name): + """Export a skinned GLB without touching the rest pose. + + `export_apply=True` (what `export()` uses for the procedural library) bakes + the evaluated deformation a second time and writes wrong inverse bind + matrices, so Godot then renders the A-pose. + """ + path = asset_path(category, name) + path.parent.mkdir(parents=True, exist_ok=True) + for o in bpy.context.selected_objects: + o.select_set(False) + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + bpy.ops.export_scene.gltf( + filepath=str(path), export_format="GLB", use_selection=True, + export_apply=False, export_animations=False, export_skins=True, + export_yup=True, export_materials="EXPORT", export_image_format="AUTO") + SRC_DIR.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"), + compress=True) + return path + + +def _quaternius_work_dir(): + path = ROOT / "tmp" / "npc-build" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _quaternius_tpose_glb(base, drop): + """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 + for the run. + """ + key = base + ("-" + "-".join(sorted(drop)) if drop else "") + path = _quaternius_work_dir() / f"tpose_{key}.glb" + if path.is_file(): + return path + source = QUATERNIUS_SOURCES[base] + source_path = QUATERNIUS_SRC / source["file"] + if not source_path.is_file(): + raise FileNotFoundError( + f"{source_path} is missing; see {QUATERNIUS_SRC / 'README.md'}") + reset_scene() + bpy.ops.import_scene.gltf(filepath=str(source_path)) + # The importer brings an Icosphere helper along; it is not part of the body + # and has no UVs or weights. + for o in list(bpy.context.scene.objects): + if o.name.startswith("Icosphere"): + bpy.data.objects.remove(o, do_unlink=True) + meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"] + if not meshes: + raise RuntimeError(f"{source_path} imported no skinned meshes") + arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE") + + # Join without welding: remove_doubles merges vertices across weight groups + # and the resulting skinning is wrong (see MEMORY.md). + for o in bpy.context.scene.objects: + o.select_set(False) + for o in meshes: + o.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + bpy.ops.object.join() + obj = bpy.context.object + obj.name = "npc_base" + obj.data.name = "npc_base" + + _quaternius_slot_map(obj, source["slots"], drop) + + # 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 + # default 0.01 wastes most of the square on a dense body), then scale it to + # fill the square so the 256-square maps keep a usable texel density. + for o in bpy.context.selected_objects: + o.select_set(False) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.0005) + bpy.ops.uv.average_islands_scale() + bpy.ops.uv.pack_islands(margin=0.0015, rotate=True) + bpy.ops.object.mode_set(mode="OBJECT") + uv_layer = obj.data.uv_layers.active + loops = uv_layer.data + us = [d.uv[0] for d in loops] + vs = [d.uv[1] for d in loops] + span = max(max(us) - min(us), max(vs) - min(vs)) + if span > 1e-6: + scale = 0.98 / span + low_u, low_v = min(us), min(vs) + for d in loops: + d.uv = ((d.uv[0] - low_u) * scale + 0.01, + (d.uv[1] - low_v) * scale + 0.01) + + # Only the five contract slots are tinted at runtime; make sure a source that + # does not paint with one of them still declares it. + present = {m.name for m in obj.data.materials if m} + for slot in SLOTS: + if slot not in present: + obj.data.materials.append(bpy.data.materials.get(slot) + or material(slot)) + + for o in bpy.context.scene.objects: + o.select_set(False) + arm.select_set(True) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + path.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=str(path), export_format="GLB", use_selection=True, + export_apply=False, export_animations=False, export_skins=True, + export_yup=True, export_materials="EXPORT", export_image_format="AUTO") + print(f" T-pose base {path.relative_to(ROOT)}") + return path + + +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", [])) + reset_scene() + bpy.ops.import_scene.gltf(filepath=str(tpose)) + arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE") + mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH") + mesh.name = "npc_base" + mesh.data.name = "npc_base" + _bake_quaternius_rest(arm, mesh) + return [arm, mesh] + + # --------------------------------------------------------------------------- # Gear (static, attach-point space; see GEAR_ATTACH) # --------------------------------------------------------------------------- @@ -1602,7 +1923,19 @@ def build_gear(name): else: raise KeyError(name) obj = join(p, name) - if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower + if GEAR[name] == "head": + # Head gear is authored around the head *centre*. The shipped roster is + # the Quaternius base (ADR-0018): its head is 0.20 m wide against the + # procedural library's 0.315 m, and its centre sits 0.07 m higher, so the + # head gear is scaled and lifted to match. The procedural fallback then + # wears slightly small hats — cosmetic, and recorded in + # docs/blender-assets.md. + centre = Vector((0.0, 0.0, HEAD_GEAR_LIFT)) + obj.data.transform( + Matrix.Translation(centre) + @ Matrix.Diagonal((HEAD_GEAR_SCALE, HEAD_GEAR_SCALE, + HEAD_GEAR_SCALE, 1.0))) + elif GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower obj.data.transform(Matrix.Translation((0, 0, -0.05))) uv_unwrap(obj) return [obj] @@ -1930,15 +2263,18 @@ def build_prop(name): # Build + render entry points (inside Blender) # --------------------------------------------------------------------------- -def _build_one(category, name, head="face"): +def _build_one(category, name, head="face", source="quaternius"): reset_scene() + quaternius = (category == "npc" and source == "quaternius" + and "base" in CHARACTERS[name]) if category == "npc": - objs = build_character(name, head=head) + objs = (build_quaternius_character(name) if quaternius + else build_character(name, head=head)) elif category == "npc_gear": objs = build_gear(name) else: objs = build_prop(name) - path = export(objs, category, name) + path = (_export_preserving_skin if quaternius else export)(objs, category, name) print(f"wrote {path.relative_to(ROOT)}") return path @@ -2840,7 +3176,8 @@ def write_manifest(results): if r["category"] == "npc": spec = CHARACTERS[r["name"]] entry.update({"layer": spec["layer"], "roles": spec["roles"], - "tint_slots": SLOTS, "mesh_node": "npc_base"}) + "tint_slots": SLOTS, "mesh_node": "npc_base", + "base": spec.get("base", "procedural")}) elif r["category"] == "npc_gear": entry["attach"] = GEAR[r["name"]] else: @@ -2892,7 +3229,10 @@ def main(argv=None): s.add_argument("--only", default="") if cmd == "build": s.add_argument("--head", default="face", choices=("face", "proto", "mh"), - help="character head source (default: face)") + help="procedural character head source (default: face)") + s.add_argument("--source", default="quaternius", + choices=("quaternius", "procedural"), + help="character mesh source (default: the Quaternius roster)") a = sub.add_parser("audit") a.add_argument("--json", action="store_true") fit = sub.add_parser("fit") @@ -2984,7 +3324,7 @@ def main(argv=None): return 2 for cat, name in _selected(args.only): if args.cmd == "build": - _build_one(cat, name, head=args.head) + _build_one(cat, name, head=args.head, source=args.source) else: _render_one(cat, name) if args.cmd == "build": diff --git a/tools/tests/test_blender_assets.py b/tools/tests/test_blender_assets.py index 5d9b9d2..6d0886c 100644 --- a/tools/tests/test_blender_assets.py +++ b/tools/tests/test_blender_assets.py @@ -56,12 +56,17 @@ class BuiltAssetTests(unittest.TestCase): report = {entry["character"]: entry for entry in ba.fit_report()} self.assertIn("npc_coat", report) coat = report["npc_coat"] - self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2) - self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3) + # Reference dimensions come from the shipped mesh, so the roster swap + # (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is + # authored for the shipped roster, so the ratio checks below are what + # must stay true; the absolute numbers are a drift guard. + self.assertAlmostEqual(coat["reference"]["head_width"], 0.201, places=2) + self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.19, places=3) sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") self.assertEqual(sunglasses["attach"], "head") self.assertLessEqual(sunglasses["width"], 0.70 * coat["reference"]["head_width"]) + self.assertAlmostEqual(sunglasses["width_ratio"], 0.65, places=2) bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") self.assertEqual(bag["attach"], "back") self.assertGreaterEqual(bag["x_extent"], 0.2) @@ -73,7 +78,7 @@ class BuiltAssetTests(unittest.TestCase): self.assertEqual(len(row["max"]), 3) self.assertIn("npc_dress", report) dress = report["npc_dress"] - self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3) + self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.18, places=3) dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag") self.assertGreater(dress_bag["x_clearance"], 0)