diff --git a/TODO.md b/TODO.md
index 1a3fdc3..4854dfe 100644
--- a/TODO.md
+++ b/TODO.md
@@ -965,7 +965,17 @@ contract.
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.
+- [x] New [`src/test/capture_npc_gear.gd`](src/test/capture_npc_gear.gd) captures every gear piece
+ **worn by a real avatar** through the runtime attach path, one frame per item, and
+ `docs/blender_previews/sheet_npc_gear_on_character.jpg` collects them. It caught two things
+ the asset renders could not: `sunglasses` were hidden inside the hair shell, and the outfit's
+ own gear double-attached over the tested piece (the capture now clears it first).
+- [x] Owner feedback on grip height applied: every hand item drops into the palm
+ (`HAND_ITEM_DROP`, camera 0.085 m, flashlight 0.065, cup 0.050, umbrella 0.045, clipboard and
+ board 0.035, phone/radio/wand 0.030), the carried role props drop 0.06 m, and the lanyard
+ attach moved from y 1.30 to 1.38 for the taller Quaternius sternum.
+- [x] `docs/npc-visual-review.md` row 4 now points at the gear-on-character sheet and the
+ measurement table. Only the owner verdict is outstanding.
**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
diff --git a/docs/blender_previews/README.md b/docs/blender_previews/README.md
index 4d25bd3..5284da7 100644
--- a/docs/blender_previews/README.md
+++ b/docs/blender_previews/README.md
@@ -24,6 +24,7 @@ capture harnesses.
| [](sheet_npc_roster_crowd_0.9.9.jpg)
`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` | Four approved crowd-variety looks queued for the 12-character roster. |
| [](sheet_npc_gear.jpg)
`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_gear_on_character.jpg)
`sheet_npc_gear_on_character.jpg` | Every gear piece worn by a real avatar through the runtime attach path: helmet, sunglasses, lanyard, bags, tank and the nine hand items. |
| [](sheet_npc_hands_compare.jpg)
`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` | 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` | Head overhaul sign-off: current `_face` vs the s48 connected-shell prototype at 1.85 m front/three-quarter/profile. |
diff --git a/docs/blender_previews/sheet_npc_gear_on_character.jpg b/docs/blender_previews/sheet_npc_gear_on_character.jpg
new file mode 100644
index 0000000..91a6789
Binary files /dev/null and b/docs/blender_previews/sheet_npc_gear_on_character.jpg differ
diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md
index 6e3ab33..dd21866 100644
--- a/docs/npc-visual-review.md
+++ b/docs/npc-visual-review.md
@@ -75,14 +75,14 @@ Tick a verdict per row. A rejected row becomes a concrete `TODO.md` task, not an
| # | Question | Verdict |
| --- | --- | --- |
-| 1 | Do the twelve characters read as distinct people at 8 m? | ☐ yes ☐ no |
-| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | ☐ yes ☐ no |
-| 3 | Do the faces hold up at portrait distance — brows, eyes, mouth, hair? | ☐ yes ☐ no |
-| 4 | Do the hands and the hand-held items (camera, clipboard, board, baton, parcel) read correctly? | ☐ yes ☐ no |
-| 5 | Is the firefighter helmet the only head gear, and does it sit correctly? | ☐ yes ☐ no |
-| 6 | Are the role outfits believable for a Hollywood-boulevard shift? | ☐ yes ☐ no |
-| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | ☐ yes ☐ no |
-| 8 | Do the seated and walking poses hold up? | ☐ yes ☐ no |
+| 1 | Do the twelve characters read as distinct people at 8 m? | x yes ☐ no |
+| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | x yes ☐ no |
+| 3 | Do the faces hold up at portrait distance — brows, eyes, mouth, hair? | x yes ☐ no |
+| 4 | Do the hands and the hand-held items (camera, clipboard, board, baton, parcel) read correctly? | ☐ yes x no |
+| 5 | Is the firefighter helmet the only head gear, and does it sit correctly? | x yes ☐ no |
+| 6 | Are the role outfits believable for a Hollywood-boulevard shift? | x yes ☐ no |
+| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | x yes ☐ no |
+| 8 | Do the seated and walking poses hold up? | x yes ☐ no |
**Signoff:** ☐ approved as shipped ☐ approved with the follow-ups below ☐ rejected
@@ -98,13 +98,17 @@ Recorded as tasks in `TODO.md` under **Owner review findings**:
grips the top edge. The delivery parcel and the confused-visitor tray became carried props in
the hands, and the `cap`/`hood` gear were retired with the hats, leaving a 15-piece set. The
per-item table is in [`blender-assets.md`](blender-assets.md#hand-item-fit-on-the-quaternius-rig).
- **Row 4 is the one still needing an owner verdict.**
-2. **Arms still graze the torso on the `worker` base** — the 7° rest tilt cleared the `casual` base
+ After the owner's grip-height feedback every hand item drops into the palm (`HAND_ITEM_DROP`),
+ the carried role props drop 0.06 m and the lanyard attach rose from y 1.30 to 1.38. Each piece
+ is now also captured **worn by a real avatar**
+ ([`sheet_npc_gear_on_character.jpg`](blender_previews/sheet_npc_gear_on_character.jpg)) — check
+ row 4 against that sheet. **Row 4 is the one still needing an owner verdict.**
+3. **Arms still graze the torso on the `worker` base** — the 7° rest tilt cleared the `casual` base
but not this one.
-3. **The `npc_vest` clipboard is oversized** on the shorter Quaternius arm.
-4. **Roster variety is three meshes for twelve roles.** Tint and gear carry the difference; the
+4. **The `npc_vest` clipboard is oversized** on the shorter Quaternius arm.
+5. **Roster variety is three meshes for twelve roles.** Tint and gear carry the difference; the
modular part library (hair, outfits) is the fix.
-5. `npc_dress` and `npc_hoodie` have no coat or dress geometry — they are the `casual`/`hoodie`
+6. `npc_dress` and `npc_hoodie` have no coat or dress geometry — they are the `casual`/`hoodie`
bases with a matching tint.
## Measured facts
diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd
index f9c3a07..f2d8a16 100644
--- a/src/game/npc_avatar.gd
+++ b/src/game/npc_avatar.gd
@@ -180,12 +180,32 @@ const BLENDER_GEAR_DIR := "res://assets/models/blender/npc_gear/"
const GEAR_OFFSETS := {
"lanyard": Vector3(0, 0, 0.04),
"firefighter_helmet": Vector3(0, 0.03, 0),
+ # The Quaternius hair comes down over the forehead, so the lens plane has to sit
+ # in front of it or the glasses disappear inside the fringe.
+ "sunglasses": Vector3(0, -0.01, 0.035),
}
-## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front
-## of that origin, so without this offset the first phone/flashlight versions
-## were partly buried inside the hand. Keep the grip axis and move the whole
-## item forward into the open palm.
+## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front of
+## that origin, so without this offset the first phone/flashlight versions were
+## partly buried inside the hand. Keep the grip axis and move the whole item
+## forward into the open palm.
const HAND_ITEM_FORWARD := Vector3(0, 0, 0.04)
+## The grip is the wrist *joint*, but a carried object rests in the palm, which is
+## below it. The local -Y of the hand proxy follows the hanging arm, so these drops
+## put the grip in the palm and follow the swing instead of floating at the wrist.
+## Owner report (2026-11-06): the camera, clipboard, board, flashlight and radio
+## all sat too high. Items not listed use `HAND_DROP_DEFAULT`.
+const HAND_ITEM_DROP := {
+ "camera": 0.085,
+ "flashlight": 0.065,
+ "cup": 0.050,
+ "umbrella": 0.045,
+ "clipboard": 0.035,
+ "protest_sign": 0.035,
+ "phone": 0.030,
+ "radio": 0.030,
+ "wand": 0.030,
+}
+const HAND_DROP_DEFAULT := 0.040
const GEAR_ATTACH := {
"sunglasses": "head",
"firefighter_helmet": "head",
@@ -801,7 +821,9 @@ func _attach_gear(gear_name: String) -> bool:
if _rig == null:
node.free()
return false
- node.position = Vector3(0, 1.30, 0)
+ # Sternum on the Quaternius rig; 1.30 was the procedural chest and put
+ # the lanyard badge on the belly (owner report).
+ node.position = Vector3(0, 1.38, 0)
_rig.add_child(node)
_:
node.free()
@@ -810,6 +832,7 @@ func _attach_gear(gear_name: String) -> bool:
node.position += GEAR_OFFSETS[gear_name]
if str(GEAR_ATTACH[gear_name]) == "hand":
node.position += HAND_ITEM_FORWARD
+ node.position.y -= float(HAND_ITEM_DROP.get(gear_name, HAND_DROP_DEFAULT))
_detail_nodes.append(node)
var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png")
for child in node.find_children("*", "MeshInstance3D", true, false):
@@ -988,7 +1011,7 @@ func _add_props(npc_type: int) -> void:
## 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,
## the same convention the Blender hand gear uses (`HAND_ITEM_FORWARD`).
-const HAND_PROP_OFFSET := Vector3(0, -0.02, 0.05)
+const HAND_PROP_OFFSET := Vector3(0, -0.06, 0.05)
## Hand proxy for a side (0 = the avatar's left, 1 = its right), falling back to
## the rig when the fallback body has no proxies.
diff --git a/src/test/capture_npc_gear.gd b/src/test/capture_npc_gear.gd
new file mode 100644
index 0000000..46d7a56
--- /dev/null
+++ b/src/test/capture_npc_gear.gd
@@ -0,0 +1,177 @@
+extends SceneTree
+## Gear-on-character capture: one frame per gear piece, worn by a real avatar.
+##
+## The gear library is authored in attach-point space, so the only way to judge a
+## piece is to see it on the rig it ships with. Each shot stages `npc_casual`,
+## attaches exactly one gear item through the runtime path (`_attach_gear`, which
+## applies the same offsets the game uses) and frames the region that gear lives in.
+##
+## Usage (windowed):
+## godot --path . --script res://src/test/capture_npc_gear.gd -- \
+## --out=res://tmp/npc-gear
+##
+## Then compose the sheet:
+## python3 tools/npc_baseline.py sheet --dir tmp/npc-gear --out tmp/npc-gear/sheet.jpg
+
+const Rules := preload("res://src/core/rules.gd")
+const NpcAvatar := preload("res://src/game/npc_avatar.gd")
+const NpcProfile := preload("res://src/core/npc_profile.gd")
+
+## Gear order matches `NpcAvatar.GEAR_ATTACH`; the crop frames where the piece sits.
+const SHOTS := [
+ {"gear": "firefighter_helmet", "view": "head"},
+ {"gear": "sunglasses", "view": "head"},
+ {"gear": "lanyard", "view": "chest"},
+ {"gear": "backpack", "view": "back"},
+ {"gear": "shoulder_bag", "view": "back"},
+ {"gear": "scba_tank", "view": "back"},
+ {"gear": "camera", "view": "hand"},
+ {"gear": "clipboard", "view": "hand"},
+ {"gear": "phone", "view": "hand"},
+ {"gear": "cup", "view": "hand"},
+ {"gear": "umbrella", "view": "hand"},
+ {"gear": "protest_sign", "view": "hand"},
+ {"gear": "flashlight", "view": "hand"},
+ {"gear": "wand", "view": "hand"},
+ {"gear": "radio", "view": "hand"},
+]
+const VIEWS := {
+ "head": {"yaw": 18.0, "distance": 0.95, "height": 1.42, "target_y": 1.72, "fov": 34.0},
+ "chest": {"yaw": 18.0, "distance": 1.10, "height": 1.45, "target_y": 1.32, "fov": 34.0},
+ "back": {"yaw": 158.0, "distance": 1.80, "height": 1.50, "target_y": 1.25, "fov": 36.0},
+ "hand": {"yaw": 22.0, "distance": 1.25, "height": 1.35, "target_y": 0.95, "fov": 34.0},
+}
+const SETTLE_FRAMES := 3
+const MAX_FRAMES := 3000
+
+var out_dir := "user://screenshots/npc_gear"
+var _stage: Node3D
+var _camera: Camera3D
+var _avatar: NpcAvatar
+var _shot := 0
+var _frames := 0
+
+
+func _initialize() -> void:
+ for arg in OS.get_cmdline_user_args():
+ if str(arg).begins_with("--out="):
+ out_dir = str(arg).substr(6)
+
+
+func _process(_delta: float) -> bool:
+ _frames += 1
+ if _frames > MAX_FRAMES:
+ push_error("capture_npc_gear timed out")
+ quit(1)
+ return true
+ if _stage == null:
+ _build_stage()
+ _stage_shot()
+ return false
+ if _frames < SETTLE_FRAMES:
+ return false
+ _shoot()
+ _shot += 1
+ if _shot >= SHOTS.size():
+ print("capture_npc_gear done -> %s" % out_dir)
+ quit(0)
+ return true
+ _stage_shot()
+ return false
+
+
+func _build_stage() -> void:
+ _stage = Node3D.new()
+ _stage.name = "NpcGearStage"
+ root.add_child(_stage)
+
+ var env := Environment.new()
+ env.background_mode = Environment.BG_COLOR
+ env.background_color = Color(0.56, 0.59, 0.63)
+ env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
+ env.ambient_light_color = Color(0.72, 0.75, 0.80)
+ env.ambient_light_energy = 0.55
+ env.tonemap_mode = Environment.TONE_MAPPER_ACES
+ var world_env := WorldEnvironment.new()
+ world_env.environment = env
+ _stage.add_child(world_env)
+
+ var sun := DirectionalLight3D.new()
+ sun.rotation_degrees = Vector3(-38.0, -32.0, 0.0)
+ sun.light_energy = 1.35
+ sun.light_color = Color(1.0, 0.97, 0.92)
+ sun.shadow_enabled = true
+ _stage.add_child(sun)
+ var fill := DirectionalLight3D.new()
+ fill.rotation_degrees = Vector3(-12.0, 145.0, 0.0)
+ fill.light_energy = 0.45
+ fill.light_color = Color(0.75, 0.82, 1.0)
+ _stage.add_child(fill)
+
+ var ground := MeshInstance3D.new()
+ var plane := PlaneMesh.new()
+ plane.size = Vector2(24.0, 24.0)
+ ground.mesh = plane
+ var mat := StandardMaterial3D.new()
+ mat.albedo_color = Color(0.30, 0.32, 0.35)
+ mat.roughness = 0.92
+ ground.material_override = mat
+ _stage.add_child(ground)
+
+ _camera = Camera3D.new()
+ _camera.current = true
+ _stage.add_child(_camera)
+
+
+func _stage_shot() -> void:
+ if _avatar != null and is_instance_valid(_avatar):
+ _avatar.queue_free()
+ _avatar = null
+ var shot: Dictionary = SHOTS[_shot]
+ var profile := NpcProfile.new()
+ profile.id = "gear_" + str(shot["gear"])
+ profile.display_name = str(shot["gear"]).to_upper()
+ profile.npc_type = Rules.NpcType.TOURIST
+ var avatar := NpcAvatar.new()
+ avatar.name = "GearSubject"
+ # The hoodie base has short sleeves and no gear of its own beyond the outfit's,
+ # so the attach point of the tested piece is unobstructed.
+ avatar.character_override = "npc_hoodie"
+ avatar.setup(profile, "roam", 4242, Vector3.ZERO, Vector3(0, 0, 2),
+ Vector3(0, 0.5, 1.2), str(profile.id))
+ _stage.add_child(avatar)
+ avatar.global_position = Vector3.ZERO
+ avatar.rotation.y = 0.0
+ avatar.set_physics_process(false)
+ avatar.set_process(false)
+ # Clear the gear the outfit attached for itself, or the shot shows two items in
+ # the same hand and the tested piece is unreadable.
+ for node in avatar.find_children("Gear_*", "Node3D", true, false):
+ (node as Node3D).queue_free()
+ # Attach the tested piece through the real runtime path, so the offsets under
+ # test are exactly the ones the game applies.
+ var gear := str(shot["gear"])
+ if not avatar._attach_gear(gear):
+ push_warning("gear %s did not attach" % gear)
+ avatar._animate(1.0 / 60.0)
+ _avatar = avatar
+ _frame_view(VIEWS[str(shot["view"])])
+
+
+func _frame_view(view: Dictionary) -> void:
+ var yaw := deg_to_rad(float(view["yaw"]))
+ var distance := float(view["distance"])
+ var target := Vector3(0.0, float(view["target_y"]), 0.0)
+ _camera.position = target + Vector3(sin(yaw) * distance,
+ float(view["height"]) - target.y, cos(yaw) * distance)
+ _camera.fov = float(view["fov"])
+ _camera.look_at(target, Vector3.UP)
+
+
+func _shoot() -> void:
+ var dir := ProjectSettings.globalize_path(out_dir)
+ DirAccess.make_dir_recursive_absolute(dir)
+ var path := dir.path_join("%02d-%s.png" % [_shot, SHOTS[_shot]["gear"]])
+ var img: Image = root.get_texture().get_image()
+ var err := img.save_png(path)
+ print("%s -> err=%d" % [path.get_file(), err])