Swap the full 12-character roster onto the CC0 Quaternius bases
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 13:56:30 +01:00
1 parent 1ce173e9d1
commit a6ccd86f85
39 files changed
+583 -173

No files matched your search

+159 -153
View File
@@ -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",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
@@ -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.
Binary file not shown.
@@ -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.
+7 -2
View File
@@ -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.
+355 -15
View File
@@ -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":
+8 -3
View File
@@ -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)