Swap the full 12-character roster onto the CC0 Quaternius bases
No files matched your search
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 29 KiB |
@@ -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*
|
||||
(<https://quaternius.com/packs/ultimatemodularcharacters.html>), 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||