Give every Quaternius head a visible mouth and queue the prop and gear fit pass
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 18:02:55 +01:00
1 parent 30d145079a
commit 9fd48243ad
32 files changed
+146 -36

No files matched your search

+2
View File
@@ -11,6 +11,8 @@ Semantic Versioning.
- Removed every hat and hood from the NPC roster: the firefighter helmet is the only head gear - Removed every hat and hood from the NPC roster: the firefighter helmet is the only head gear
left, so no character hides its face behind a cap. The street performer no longer wears the left, so no character hides its face behind a cap. The street performer no longer wears the
cone hat and carries the baton and board in the hands instead. cone hat and carries the baton and board in the hands instead.
- Every NPC now has a visible mouth; the Quaternius heads shipped without one, so the faces read as
brow-and-eye masks.
- Fixed the modular Worker characters: the guard, delivery, firefighter and crowd roles had holes - Fixed the modular Worker characters: the guard, delivery, firefighter and crowd roles had holes
in the torso and an open crown, a continuous unibrow and no visible eyes. They now have a closed in the torso and an open crown, a continuous unibrow and no visible eyes. They now have a closed
torso, hair, two eyebrows and two eyes. torso, hair, two eyebrows and two eyes.
+31
View File
@@ -930,6 +930,37 @@ gear) and 5 (head gear on the skull).
- [ ] Refresh `CHARACTERS`, the manifest roles/tint data, `CREDITS.md` and the review doc rows as - [ ] Refresh `CHARACTERS`, the manifest roles/tint data, `CREDITS.md` and the review doc rows as
each family lands. each family lands.
### Prop and gear fit pass (new NPC attach points)
Goal: every gear piece and role prop is measured against, and re-authored for, the Quaternius
skeleton. The 0.9.9 roster swap moved the hand from y 0.78 to the wrist at y 1.017, the head centre
from 1.62 to 1.69 and the shoulder width from 0.27 to 0.15, so anything authored for the procedural
body is now the wrong size or in the wrong place. Nothing in this workstream changes the runtime
contract.
- [ ] Extend `tools/blender_assets.py fit` to measure **hand** and **chest** gear (it currently
reports head, back and chest only, and skips `hand` outright). Report each item's grip
offset from the wrist, its overall size and whether it clears the thigh at rest.
- [ ] Record a measurement table for the nine hand items (`phone`, `cup`, `clipboard`, `umbrella`,
`camera`, `protest_sign`, `flashlight`, `wand`, `radio`) against the Quaternius wrist
(Godot y 1.017, palm 4 cm forward) and pick a grip scale per item.
- [ ] Re-author the oversized hand items: `clipboard` (0.23 x 0.31 m) and `umbrella` (0.70 m
shaft) are the two the captures flag; rebuild, re-bake AO and re-check in the capture gate.
- [ ] Re-fit the role props in `NpcAvatar._add_props()` and `_add_hand_item()` — delivery box,
confused-visitor tray, influencer photo prop, tourist camera — against the same wrist
anchors, and delete the now-unused primitive cap/hood fallbacks, if any remain.
- [ ] Retire the gear GLBs the roster no longer uses (`cap`, `hood`, `beanie`) or mark them
fallback-only, and update `CREDITS.md`, the manifest and `docs/blender-assets.md`.
- [ ] Re-measure `fit` for `backpack`, `shoulder_bag`, `scba_tank` and `lanyard` on the slimmer
Quaternius torso and re-fit the straps where they no longer touch the body.
- [ ] Rebuild `docs/blender_previews/sheet_npc_gear.jpg` and add a gear-on-character sheet to the
review page, then re-run the 24-NPC budget smoke.
- [ ] Update `docs/npc-visual-review.md` row 4 (hands and hand-held gear) once the pass lands.
**Exit:** every gear piece and role prop has a measured fit against the Quaternius attach points,
the hand-held items are within +/-10 % of their authored real-world size, and the review page row 4
is signed off.
### Gates and rollout ### Gates and rollout
- [ ] Re-bake AO per rebuilt GLB and refresh the stale `.godot` import cache (scratch-copy - [ ] Re-bake AO per rebuilt GLB and refresh the stale `.godot` import cache (scratch-copy
+48 -36
View File
@@ -450,7 +450,8 @@
"Eyebrows", "Eyebrows",
"Eye", "Eye",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_casual.glb", "path": "assets/models/blender/npc/npc_casual.glb",
@@ -467,8 +468,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6206, "triangles": 6218,
"vertices": 12988 "vertices": 13012
}, },
"npc_coat": { "npc_coat": {
"base": "hoodie", "base": "hoodie",
@@ -493,7 +494,8 @@
"Eyebrows", "Eyebrows",
"Eye", "Eye",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_coat.glb", "path": "assets/models/blender/npc/npc_coat.glb",
@@ -509,8 +511,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6206, "triangles": 6218,
"vertices": 12988 "vertices": 13012
}, },
"npc_crowd_a": { "npc_crowd_a": {
"base": "casual", "base": "casual",
@@ -535,7 +537,8 @@
"Hair_Brown", "Hair_Brown",
"Brown", "Brown",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_a.glb", "path": "assets/models/blender/npc/npc_crowd_a.glb",
@@ -550,8 +553,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6424, "triangles": 6436,
"vertices": 13439 "vertices": 13459
}, },
"npc_crowd_b": { "npc_crowd_b": {
"base": "casual", "base": "casual",
@@ -576,7 +579,8 @@
"Hair_Brown", "Hair_Brown",
"Brown", "Brown",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_b.glb", "path": "assets/models/blender/npc/npc_crowd_b.glb",
@@ -591,8 +595,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6424, "triangles": 6436,
"vertices": 13439 "vertices": 13459
}, },
"npc_crowd_c": { "npc_crowd_c": {
"base": "hoodie", "base": "hoodie",
@@ -617,7 +621,8 @@
"Eyebrows", "Eyebrows",
"Eye", "Eye",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_c.glb", "path": "assets/models/blender/npc/npc_crowd_c.glb",
@@ -632,8 +637,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6206, "triangles": 6218,
"vertices": 12988 "vertices": 13012
}, },
"npc_crowd_d": { "npc_crowd_d": {
"base": "worker", "base": "worker",
@@ -660,7 +665,8 @@
"pants", "pants",
"Brows", "Brows",
"Eye.001", "Eye.001",
"hair" "hair",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb", "path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -675,8 +681,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6254, "triangles": 6266,
"vertices": 13198 "vertices": 13222
}, },
"npc_dress": { "npc_dress": {
"base": "casual", "base": "casual",
@@ -701,7 +707,8 @@
"Hair_Brown", "Hair_Brown",
"Brown", "Brown",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_dress.glb", "path": "assets/models/blender/npc/npc_dress.glb",
@@ -715,8 +722,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6424, "triangles": 6436,
"vertices": 13439 "vertices": 13459
}, },
"npc_firefighter": { "npc_firefighter": {
"base": "worker", "base": "worker",
@@ -743,7 +750,8 @@
"pants", "pants",
"Brows", "Brows",
"Eye.001", "Eye.001",
"hair" "hair",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb", "path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -757,8 +765,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6254, "triangles": 6266,
"vertices": 13198 "vertices": 13222
}, },
"npc_guard": { "npc_guard": {
"base": "worker", "base": "worker",
@@ -785,7 +793,8 @@
"pants", "pants",
"Brows", "Brows",
"Eye.001", "Eye.001",
"hair" "hair",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb", "path": "assets/models/blender/npc/npc_guard.glb",
@@ -800,8 +809,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6254, "triangles": 6266,
"vertices": 13198 "vertices": 13222
}, },
"npc_hoodie": { "npc_hoodie": {
"base": "hoodie", "base": "hoodie",
@@ -826,7 +835,8 @@
"Eyebrows", "Eyebrows",
"Eye", "Eye",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_hoodie.glb", "path": "assets/models/blender/npc/npc_hoodie.glb",
@@ -842,8 +852,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6206, "triangles": 6218,
"vertices": 12988 "vertices": 13012
}, },
"npc_jacket": { "npc_jacket": {
"base": "casual", "base": "casual",
@@ -868,7 +878,8 @@
"Hair_Brown", "Hair_Brown",
"Brown", "Brown",
"hair", "hair",
"pants" "pants",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_jacket.glb", "path": "assets/models/blender/npc/npc_jacket.glb",
@@ -884,8 +895,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6424, "triangles": 6436,
"vertices": 13439 "vertices": 13459
}, },
"npc_vest": { "npc_vest": {
"base": "worker", "base": "worker",
@@ -913,7 +924,8 @@
"pants", "pants",
"Brows", "Brows",
"Eye.001", "Eye.001",
"hair" "hair",
"mouth"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb", "path": "assets/models/blender/npc/npc_vest.glb",
@@ -927,8 +939,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6254, "triangles": 6266,
"vertices": 13198 "vertices": 13222
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 215 KiB

+5
View File
@@ -22,6 +22,7 @@ all build defects, not art. This revision covers what was fixed and what is stil
| arms intersecting the body on `npc_dress`, `npc_jacket`, `npc_crowd_a`, `npc_crowd_d` | the baked rest aimed the upper arm straight down, which fails on the narrow bases | reduced — the arm rests 7° out from vertical (mesh width 0.180 → 0.220 on `casual`, 0.228 → 0.262 on `hoodie`) | | arms intersecting the body on `npc_dress`, `npc_jacket`, `npc_crowd_a`, `npc_crowd_d` | the baked rest aimed the upper arm straight down, which fails on the narrow bases | reduced — the arm rests 7° out from vertical (mesh width 0.180 → 0.220 on `casual`, 0.228 → 0.262 on `hoodie`) |
| head gear did not sit on the skull | gear is authored around the procedural head centre (1.62); the Quaternius head centre is 1.69 | **resolved by removal** — see below | | head gear did not sit on the skull | gear is authored around the procedural head centre (1.62); the Quaternius head centre is 1.69 | **resolved by removal** — see below |
| performer cone swallowed the head | a 0.44 m cone authored for the old body | removed — the performer carries a baton and board in the hands | | performer cone swallowed the head | a 0.44 m cone authored for the old body | removed — the performer carries a baton and board in the hands |
| faces had **no mouth at all** | none of the three upstream heads carries a mouth material | fixed — a small lip block is placed on the measured skin surface for every base (dark maroon, not tinted) |
## Hats and head items ## Hats and head items
@@ -91,6 +92,10 @@ Recorded as tasks in `TODO.md` under **Owner review findings**:
1. **Eyes have no whites.** Every base uses single-colour quads, so eyes read as dark rectangles at 1. **Eyes have no whites.** Every base uses single-colour quads, so eyes read as dark rectangles at
portrait distance. A light sclera plus pupil on the `Eye` slot would finish the face. portrait distance. A light sclera plus pupil on the `Eye` slot would finish the face.
2. **Hands and hand-held gear are still authored for the old body.** The wrist moved from y 0.78 to
1.017 and the arm got shorter, so the clipboard and the umbrella are oversized. A dedicated
prop-and-gear fit pass is queued in [`../TODO.md`](../TODO.md) under **Prop and gear fit pass
(new NPC attach points)** — it starts by teaching `fit` to measure hand gear at all.
2. **Arms still graze the torso on the `worker` base** — the 7° rest tilt cleared the `casual` base 2. **Arms still graze the torso on the `worker` base** — the 7° rest tilt cleared the `casual` base
but not this one. but not this one.
3. **The `npc_vest` clipboard is oversized** on the shorter Quaternius arm. 3. **The `npc_vest` clipboard is oversized** on the shorter Quaternius arm.
+60
View File
@@ -1587,6 +1587,65 @@ def _purge_unused_materials(obj):
bpy.data.materials.remove(material) bpy.data.materials.remove(material)
def _add_quaternius_mouth(obj):
"""Give every Quaternius head a readable mouth.
None of the three upstream heads carries a mouth material: the face is skin
only, so at gameplay distance the characters read as brow-and-eye masks. A
small flat lip block is placed on the measured skin surface, which is why it
works for all three bases without per-source tuning.
"""
# This runs before the slot map, so the material is still the upstream `Skin`.
skin = next((i for i, m in enumerate(obj.data.materials)
if m is not None and _base_name(m.name).lower() == "skin"), None)
if skin is None:
return
band = []
for poly in obj.data.polygons:
if poly.material_index != skin:
continue
for index in poly.vertices:
co = obj.data.vertices[index].co
if 1.600 <= co.z <= 1.632 and abs(co.x) <= 0.035:
band.append(co.y)
if not band:
return
front = min(band) # Blender -Y is the face; the lip sits on the surface
height = 1.616
material = bpy.data.materials.new("mouth")
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
# Blender node colours are linear; 0.16 linear reads as mauve once
# Tonemapped in Godot, so the lip is set well below the skin value.
bsdf.inputs["Base Color"].default_value = (0.055, 0.018, 0.02, 1.0)
bsdf.inputs["Roughness"].default_value = 0.6
else:
print(" mouth: no Principled node, colour left at default")
material.diffuse_color = (0.055, 0.018, 0.02, 1.0)
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
for vert in bm.verts:
vert.co.x *= 0.058
vert.co.y *= 0.012
vert.co.z *= 0.011
vert.co += Vector((0.0, front + 0.003, height))
mouth = bpy.data.objects.new("Mouth", bpy.data.meshes.new("Mouth"))
bm.to_mesh(mouth.data)
bm.free()
mouth.data.materials.append(material)
bpy.context.scene.collection.objects.link(mouth)
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
mouth.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
group = obj.vertex_groups.get("Head") or obj.vertex_groups.new(name="Head")
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
print(f" mouth placed at y {front + 0.003:+.3f} z {height:.3f}")
def _base_name(name): def _base_name(name):
"""Strip Blender's `.001` duplicate suffix.""" """Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".") head, sep, tail = name.rpartition(".")
@@ -1998,6 +2057,7 @@ def _quaternius_tpose_glb(base, overrides):
slots.update(overrides) slots.update(overrides)
if source.get("donor_head"): if source.get("donor_head"):
_transplant_worker_head(obj) _transplant_worker_head(obj)
_add_quaternius_mouth(obj)
_quaternius_slot_map(obj, slots) _quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write # Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write