Start the prop and gear fit pass: hand-gear measurement, top-edge clipboard, carried role props, 15-piece gear set
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 18:11:20 +01:00
1 parent cba00facf5
commit 219588bf8a
17 files changed
+127 -73

No files matched your search

+24 -11
View File
@@ -938,23 +938,36 @@ from 1.62 to 1.69 and the shoulder width from 0.27 to 0.15, so anything authored
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.
- [x] Extend `tools/blender_assets.py fit` to measure **hand** gear (it skipped `hand` outright).
It now reports grip drop, the lowest point at rest against the Quaternius wrist (y 1.017), the
hip-line flag (0.95), the authored size and `size_vs_head` — the proportion number that makes
"reads oversized" measurable.
- [x] Record the nine hand-item measurements (table in `docs/blender-assets.md`). Only the
`clipboard` was actually mis-authored: its grip sat in the *middle* of the board, so it hung
through the thigh line. It now grips the top edge like a carried clipboard (0.22 x 0.30 m).
- [ ] 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.
- [x] Re-author the `clipboard` (top-edge grip, 0.22 x 0.30 m) and rebuild + re-bake AO. The
`umbrella` (0.90 m) measures on its real-world size and keeps its grip, so it stays.
- [x] Re-fit the role props in `NpcAvatar._add_props()`: the delivery parcel (0.34 m slab across
the torso) is now a 0.26 m carried box in the hand, and the confused visitor carries a folded
map instead of a tray hovering at the hip.
- [ ] 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.
- [x] Retire the gear GLBs the roster no longer uses: `cap` and `hood` are gone from `GEAR`, the
manifest and the asset tree (`beanie` was already retired). The builder branches stay for a
future design, guarded by a comment.
- [x] `docs/blender-assets.md` documents the 15-piece matrix and the hand-item fit table;
`architecture.md`, `asset-tooling.md` and the preview index were updated with the new counts.
`CREDITS.md` still cites the upstream pack, which is unchanged.
- [x] Re-measure `fit` for the back and chest gear on the slimmer torso: `backpack` inner x 0.150
against a 0.190 clothing radius (straps cross the body, as intended), `shoulder_bag` x
clearance +0.072, `scba_tank` inner x 0.150, `lanyard` radial clearance +0.012 on the chest.
The backpack straps were re-fitted for this torso in `fb3e75a`; nothing further needed.
- [x] Rebuilt `docs/blender_previews/sheet_npc_gear.jpg` for the 15-piece set (all renders
regenerated) and re-ran the 24-NPC budget smoke (175,264 tris, 0.87 ms).
- [ ] 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,
+4 -48
View File
@@ -117,39 +117,17 @@
"triangles": 312,
"vertices": 422
},
"cap": {
"attach": "head",
"bounds_godot": {
"max": [
0.108,
0.237,
0.165
],
"min": [
-0.108,
0.127,
-0.119
]
},
"category": "npc_gear",
"materials": [
"plastic_black"
],
"path": "assets/models/blender/npc_gear/cap.glb",
"triangles": 328,
"vertices": 285
},
"clipboard": {
"attach": "hand",
"bounds_godot": {
"max": [
0.115,
0.05,
0.11,
-0.07,
0.053
],
"min": [
-0.115,
-0.265,
-0.11,
-0.375,
0.036
]
},
@@ -329,28 +307,6 @@
"triangles": 268,
"vertices": 400
},
"hood": {
"attach": "head",
"bounds_godot": {
"max": [
0.118,
0.254,
0.079
],
"min": [
-0.118,
0.009,
-0.144
]
},
"category": "npc_gear",
"materials": [
"plastic_grey"
],
"path": "assets/models/blender/npc_gear/hood.glb",
"triangles": 804,
"vertices": 649
},
"lanyard": {
"attach": "chest",
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+1 -1
View File
@@ -218,7 +218,7 @@ developer tools only — runtime, tests and CI consume committed GLBs and maps.
| Tool | Role | Committed artifact | Verification gate |
| --- | --- | --- | --- |
| `tools/npc_pipeline.py` | Builds the skinned base avatar, five-slot material maps, palette and AI-fabric preparation (`prepare`/`palette`) | `npc_base.glb`, `assets/textures/npc/*_{albedo,normal,orm}.png`, `palette.json` | `audit_glb` against the 24,000-triangle / 24,000-vertex base contract (13 core bones required; extras allowed) |
| `tools/blender_assets.py` | Builds/renders the library: 8 characters, 18 gear pieces, 20 props, plus `manifest.json`; stdlib `audit`/`fit`/`sheet`/`list` | `assets/models/blender/{npc,npc_gear,props}/*.glb` | Character cap 24,000 tris / 24,000 verts, 1,500 per gear, 4,000 per prop; grounding, UV, degenerate-triangle, attach-fit and manifest coverage checks |
| `tools/blender_assets.py` | Builds/renders the library: 12 characters, 15 gear pieces, 20 props, plus `manifest.json`; stdlib `audit`/`fit`/`sheet`/`list` | `assets/models/blender/{npc,npc_gear,props}/*.glb` | Character cap 24,000 tris / 24,000 verts, 1,500 per gear, 4,000 per prop; grounding, UV, degenerate-triangle, attach-fit and manifest coverage checks |
| `tools/bake_ao.py` | Bakes per-asset AO maps | `<asset>_ao.png` (256²) | Wired through `Materials.apply_ao()`/`SurfaceMaterials`; cloth/pants opt out (see below) |
| `tools/npc_proportions.py` | Measures exported GLB rest pose (height, head, shoulders, hips, torso, limbs, feet) against the stylized guide | report / JSON in `docs/npc-visual-review.md#proportion-audit` | All eight variants must stay inside the guide after a body change |
| `tools/npc_baseline.py` | Assembles neutral-baseline sheets and before/after comparisons | `docs/blender_previews/sheet_npc_baseline*.jpg` | Human review against `docs/npc-visual-review.md` |
+1 -1
View File
@@ -118,7 +118,7 @@ blotches; skin, hair and fixed gear keep AO. `palette` writes
`tools/blender_assets.py` models a larger library procedurally in Blender for the
v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest,
dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones,
joint positions and tint slots); **18 gear pieces** authored at head/hand/back/chest
joint positions and tint slots); **15 gear pieces** authored at head/hand/back/chest
attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup,
clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props**
(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp,
+35
View File
@@ -192,6 +192,41 @@ the role's `TYPE_COLORS`.
Gear is authored in attach-point space; the exact contract and runtime fit offsets are in
[`architecture.md#character-and-gear-asset-contract`](architecture.md#character-and-gear-asset-contract).
The shipped matrix is **fifteen pieces** since the hat removal (owner, 2026-11-06): `cap` and
`hood` are retired, and `firefighter_helmet` is the only head item.
| Attach | Pieces |
| --- | --- |
| head | `firefighter_helmet`, `sunglasses` |
| back | `backpack`, `shoulder_bag`, `scba_tank` |
| chest | `lanyard` |
| hand | `phone`, `cup`, `clipboard`, `umbrella`, `camera`, `protest_sign`, `flashlight`, `wand`, `radio` |
### Hand-item fit on the Quaternius rig
The hand attach point moved from Godot y 0.78 (procedural wrist) to **y 1.017** (Quaternius
wrist), so `fit` measures hand gear against the new wrist instead of the authored attach offset.
Run `blender_assets.py fit`; per item it reports the grip drop, the lowest point at rest, whether
that clears the hip line (0.95) and `size_vs_head` — the proportion that makes "reads oversized"
measurable on the 0.20 m head.
| Item | Drop (m) | Lowest at rest | Clears hip | Longest | × head |
| --- | --- | --- | --- | --- | --- |
| `camera` | 0.053 | 0.964 | yes | 0.138 | 0.69 |
| `phone` | 0.105 | 0.912 | no | 0.150 | 0.75 |
| `cup` | 0.110 | 0.907 | no | 0.142 | 0.71 |
| `radio` | 0.108 | 0.909 | no | 0.203 | 1.01 |
| `flashlight` | 0.077 | 0.940 | no | 0.243 | 1.14 |
| `wand` | 0.125 | 0.892 | no | 0.400 | 1.87 |
| `umbrella` | 0.201 | 0.816 | no | 0.900 | 4.48 |
| `clipboard` | 0.375 | 0.642 | no | 0.305 | 1.63 |
| `protest_sign` | 0.250 | 0.767 | no | 1.230 | 6.12 |
Falling below the hip line is normal for a carried object; the number that matters is whether the
item was *authored* for the new grip. Only the `clipboard` was: its grip sat in the middle of the
board, so it swung through the thigh. It now grips the top edge (0.22 × 0.30 m) like a carried
clipboard, and `HAND_ITEM_AUTHORED_SIZE` in the tool flags any future drift past 10 %.
## Props
| Prop | Replaces / role | Notes |
+1 -1
View File
@@ -23,7 +23,7 @@ capture harnesses.
| [![sheet_npc_roster_poses_0.9.9.jpg](sheet_npc_roster_poses_0.9.9.jpg)](sheet_npc_roster_poses_0.9.9.jpg) <br> `sheet_npc_roster_poses_0.9.9.jpg` | 0.9.9 roster: idle, seated and walk-contact poses on six representative roles. |
| [![sheet_npc_roster_crowd_0.9.9.jpg](sheet_npc_roster_crowd_0.9.9.jpg)](sheet_npc_roster_crowd_0.9.9.jpg) <br> `sheet_npc_roster_crowd_0.9.9.jpg` | 0.9.9 roster at the 8 m crowd distance a player actually sees. |
| [![sheet_npc_crowd_prototypes.jpg](sheet_npc_crowd_prototypes.jpg)](sheet_npc_crowd_prototypes.jpg) <br> `sheet_npc_crowd_prototypes.jpg` | Four approved crowd-variety looks queued for the 12-character roster. |
| [![sheet_npc_gear.jpg](sheet_npc_gear.jpg)](sheet_npc_gear.jpg) <br> `sheet_npc_gear.jpg` | Eighteen gear pieces authored in attach space: caps, hood, helmet, bags, tanks, lanyards and handheld items. |
| [![sheet_npc_gear.jpg](sheet_npc_gear.jpg)](sheet_npc_gear.jpg) <br> `sheet_npc_gear.jpg` | Fifteen gear pieces authored in attach space: helmet, sunglasses, bags, tank, lanyard and the nine hand items. Caps and hoods are retired (owner, 2026-11-06). |
| [![sheet_npc_hands_compare.jpg](sheet_npc_hands_compare.jpg)](sheet_npc_hands_compare.jpg) <br> `sheet_npc_hands_compare.jpg` | Hand pass: wider palms, segmented fingers and a separate thumb. |
| [![sheet_npc_hands_grip_compare.jpg](sheet_npc_hands_grip_compare.jpg)](sheet_npc_hands_grip_compare.jpg) <br> `sheet_npc_hands_grip_compare.jpg` | Grip pass: the forward hand-item offset that puts props in the palm. |
| [![sheet_head_proto_before_after.jpg](sheet_head_proto_before_after.jpg)](sheet_head_proto_before_after.jpg) <br> `sheet_head_proto_before_after.jpg` | Head overhaul sign-off: current `_face` vs the s48 connected-shell prototype at 1.85 m front/three-quarter/profile. |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+8 -3
View File
@@ -187,7 +187,7 @@ const GEAR_OFFSETS := {
## item forward into the open palm.
const HAND_ITEM_FORWARD := Vector3(0, 0, 0.04)
const GEAR_ATTACH := {
"cap": "head", "hood": "head", "sunglasses": "head",
"sunglasses": "head",
"firefighter_helmet": "head",
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
"lanyard": "chest",
@@ -952,7 +952,10 @@ func _add_props(npc_type: int) -> void:
_photo_flash = _add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
_photo_flash.visible = false
Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
# A carried parcel: it hangs off the hand so it follows the arm, and it
# is small enough that the torso and the role tint still read.
_add_box_prop(_hand_for(1), Vector3(0.26, 0.19, 0.24),
HAND_PROP_OFFSET + Vector3(0, -0.09, 0.07), Color(0.55, 0.4, 0.25))
Rules.NpcType.PERFORMER:
# No cone on the head: the performer plays the sidewalk with a board in
# one hand and a baton in the other, so the face stays readable.
@@ -978,7 +981,9 @@ func _add_props(npc_type: int) -> void:
_photo_prop.visible = false
_photo_prop_base_y = _photo_prop.position.y
Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
# A fold-out map carried in one hand rather than a tray floating at the hip.
_add_box_prop(_hand_for(0), Vector3(0.22, 0.03, 0.16),
HAND_PROP_OFFSET + Vector3(0, -0.02, 0.06), Color(0.9, 0.88, 0.8))
## Carried role props ride the hand proxy so they follow the arm. The offset is
## in the hand's local space: the grip sits ~4 cm in front of the wrist origin,
+53 -8
View File
@@ -100,7 +100,7 @@ CHARACTERS = {
"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"],
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["clipboard"],
"base": "worker",
# Delivery keeps the hi-vis stripe bands as authored; every
# other worker role tints them with the shirt.
@@ -118,7 +118,7 @@ CHARACTERS = {
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"base": "worker",
"gear": ["cap", "wand"],
"gear": ["wand"],
"roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): the three Quaternius
# sources are spread across the crowd pools so a crowd never shows one mesh.
@@ -147,6 +147,20 @@ ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
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)}
# The runtime hangs hand gear off the Quaternius wrist proxy, not off the authored
# `hand` attach point: the wrist rests at Godot y 1.017 with the hip line at 0.95.
# `fit` uses these to say whether a hand item clears the thigh when the arm hangs.
HAND_REST_Y = 1.017
HIP_LINE = 0.95
# Real-world size of each hand item in metres (longest edge), so `fit` can flag a
# prop that drifted off-scale instead of only "it is big".
# Longest edge as authored today; `fit` flags drift past 10 %, so a re-authored
# item has to update its row instead of silently growing.
HAND_ITEM_AUTHORED_SIZE = {
"phone": 0.150, "cup": 0.142, "clipboard": 0.305, "umbrella": 0.900,
"camera": 0.138, "protest_sign": 1.230, "flashlight": 0.243,
"wand": 0.400, "radio": 0.203,
}
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
@@ -164,8 +178,11 @@ FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
# Side bags include a strap that crosses the torso, so the full gear bbox cannot
# tell where the bag body starts. Measure the body-only y band for this fit.
FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)}
# Head gear is the firefighter helmet only (owner, 2026-11-06). `cap` and `hood`
# were retired with the hat removal; `build_gear()` keeps their branch code so a
# future design can restore them, but they are not assets any more.
GEAR = {
"cap": "head", "hood": "head", "sunglasses": "head",
"sunglasses": "head",
"firefighter_helmet": "head",
"backpack": "back", "shoulder_bag": "back", "scba_tank": "back",
"lanyard": "chest",
@@ -2225,10 +2242,14 @@ def build_gear(name):
p.append(lathe("Sleeve", [(0.039, -0.01), (0.044, 0.035)], M("cardboard"), seg=16,
smooth=True))
elif name == "clipboard":
p.append(box("Board", (0.23, 0.008, 0.31), (0, -0.04, -0.06), M("dark_wood"),
# Grip the *top* edge, as a hand actually carries a clipboard. The old
# geometry put the grip in the middle of the board, so it hung 26 cm below
# the wrist and swung through the thigh line; from the top edge it reads
# as held, and the board keeps its A4 size (0.22 x 0.30 m).
p.append(box("Board", (0.22, 0.008, 0.30), (0, -0.04, -0.175), M("dark_wood"),
bevel=0.004))
p.append(box("Paper", (0.20, 0.002, 0.26), (0, -0.045, -0.08), M("paper")))
p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, 0.085), M("steel"), bevel=0.004))
p.append(box("Paper", (0.19, 0.002, 0.25), (0, -0.045, -0.195), M("paper")))
p.append(box("Clip", (0.08, 0.014, 0.03), (0, -0.046, -0.035), M("steel"), bevel=0.004))
elif name == "umbrella":
p.append(tube("Handle", [(0.0, 0.0, -0.06), (0.0, 0.0, -0.10), (0.0, -0.04, -0.14),
(0.0, -0.08, -0.10)], 0.011, M("dark_wood"), seg=8))
@@ -3466,7 +3487,7 @@ def _gear_local_positions(name):
def _gear_fit(name, reference):
attach = GEAR.get(name)
if attach not in ("head", "back", "chest"):
if attach not in ("head", "back", "chest", "hand"):
return None
audit = audit_asset("npc_gear", name)
bounds = audit.get("bounds")
@@ -3481,7 +3502,31 @@ def _gear_fit(name, reference):
"min": [round(v, 3) for v in lo],
"max": [round(v, 3) for v in hi],
}
if attach == "head":
if attach == "hand":
# The GLB origin is the grip, so the audit bounds are grip-relative and the
# item hangs along Godot -Y. `lo` already has the authored attach offset
# added, so subtract it before converting to a rest position.
grip = bounds["min"]
drop = round(-float(grip[1]), 3)
real = HAND_ITEM_AUTHORED_SIZE.get(name, 0.0)
longest = round(max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), 3)
result.update({
"width": round(hi[0] - lo[0], 3),
"height": round(hi[1] - lo[1], 3),
"depth": round(hi[2] - lo[2], 3),
"grip_drop": drop,
"rest_lowest_y": round(HAND_REST_Y + float(grip[1]), 3),
"clears_hip": (HAND_REST_Y + float(grip[1])) >= HIP_LINE,
"longest": longest,
"authored_size": real,
"size_ratio": round(longest / real, 3) if real else 0.0,
# Same object, bigger head: on the 0.20 m Quaternius head a 0.315 m
# clipboard is 1.57 head-widths where it used to be 1.0. Report the
# proportion so "it reads oversized" is a number, not a feeling.
"size_vs_head": round(longest / reference["head_width"], 2)
if reference.get("head_width") else 0.0,
})
elif attach == "head":
result.update({
"width": round(hi[0] - lo[0], 3),
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)