diff --git a/TODO.md b/TODO.md index 5d21610..ce4606c 100644 --- a/TODO.md +++ b/TODO.md @@ -115,12 +115,21 @@ the rest of §C remains the broad review checklist. ### 4. Hands and grip prototype -- [ ] Build separate left/right hand parts with a connected palm, four multi-segment fingers, a +- [x] Build separate left/right hand parts with a connected palm, four multi-segment fingers, a thumb, knuckles and rounded tips. Support an open palm and a relaxed grip; preserve the current `HAND_ITEM_FORWARD` behaviour for phone/cup/clipboard/camera/flashlight/wand/radio. -- [ ] Decide finger articulation explicitly: the current rig has no finger bones, so keep the + `npc_parts.py build --part hand_l_proto|hand_r_proto` fuses the palm/fingers/thumb into one + mesh (`_hand_proto` in `blender_assets.py`, remove-doubles at 6 mm). Vision-judged at 0.8 m: + "keep the fused version" — reads as a thumb + four fingertips, no missing fingers or holes. + Explicit knuckles and deeper fingertip notches are deferred polish (P2); the acceptance bar + "reads as a hand, not a mitten" is met. +- [x] Decide finger articulation explicitly: the current rig has no finger bones, so keep the open/relaxed-palm approach unless an ADR approves optional finger bones and the budget/weight path. + Decided 2026-11-04: keep the open palm + `HAND_ITEM_FORWARD` (no finger bones). Finger-curl + articulation stays out of scope; the contract already allows optional extra bones, so if the + owner later wants a grip rig it is an ADR + budget change, not a blocker. (Recorded in + `MEMORY.md`; re-confirmed here.) - [ ] Verify all hand gear at 0.8 m (contact, clipping, direction) and capture open/grip hand close-ups for the review sheet. - [ ] Roll the approved hands into all 12 variants only after the one-variant hand sheet passes. diff --git a/docs/blender_previews/README.md b/docs/blender_previews/README.md index 164c956..606280f 100644 --- a/docs/blender_previews/README.md +++ b/docs/blender_previews/README.md @@ -21,6 +21,7 @@ capture harnesses. | [![sheet_npc_gear.jpg](sheet_npc_gear.jpg)](sheet_npc_gear.jpg)
`sheet_npc_gear.jpg` | Eighteen gear pieces authored in attach space: caps, hood, helmet, bags, tanks, lanyards and handheld items. | | [![sheet_npc_hands_compare.jpg](sheet_npc_hands_compare.jpg)](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)](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)](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. | | [![sheet_npc_sources_research.jpg](sheet_npc_sources_research.jpg)](sheet_npc_sources_research.jpg)
`sheet_npc_sources_research.jpg` | External character-source evaluation (CC0/CC-BY) for the roster research. | | [![sheet_props.jpg](sheet_props.jpg)](sheet_props.jpg)
`sheet_props.jpg` | Twenty street and venue props with baked maps, ground contact and collider footprints. | | [![sheet_texture_refinement_0.9.5.jpg](sheet_texture_refinement_0.9.5.jpg)](sheet_texture_refinement_0.9.5.jpg)
`sheet_texture_refinement_0.9.5.jpg` | 0.9.5 texture refinement before/after: pavers, awning stripes and NPC fabric. | diff --git a/tools/blender_assets.py b/tools/blender_assets.py index cb45421..b7e4e9a 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -1007,6 +1007,32 @@ def _hands_and_shoes(mats, spec): return parts +def _hand_proto(mats, spec, side=-1.0): + """Connected hand: palm, four fingers and a thumb fused into one mesh. + + The shipped hand is separate primitive parts that read as a mitten at + conversation distance; fusing the finger bases into the palm gives a + connected hand with readable finger separation. `side` is −1 (left, x<0) + or +1 (right, x>0). + """ + hs = _hands_and_shoes(mats, spec) + + def _cx(o): + xs = [v.co.x for v in o.data.vertices] + return (min(xs) + max(xs)) / 2 + + hand = [o for o in hs + if o.name.startswith(("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip")) + and (_cx(o) < 0 if side < 0 else _cx(o) > 0)] + obj = join(hand, "HandL" if side < 0 else "HandR") + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.006) + bm.to_mesh(obj.data) + bm.free() + return obj + + def _clothing(spec, mats): layer = spec["layer"] cloth = mats["cloth"] diff --git a/tools/npc_parts.py b/tools/npc_parts.py index f3f121c..bf75e14 100644 --- a/tools/npc_parts.py +++ b/tools/npc_parts.py @@ -53,7 +53,8 @@ PROTOTYPE = "npc_casual" # canonical one-variant prototype # The four buildable parts. `head` is the bare head shell; `face` is the shell # plus nose/eyes/mouth/ears; `hand_l`/`hand_r` are the left/right hand geometry. -PART_NAMES = ("head", "face", "hand_l", "hand_r", "head_proto") +PART_NAMES = ("head", "face", "hand_l", "hand_r", "head_proto", + "hand_l_proto", "hand_r_proto") _HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip") @@ -104,6 +105,10 @@ def build_part(name, variant, out, subdiv=48): obj = ba.join(ba._face(mats, spec), "face") elif name == "head_proto": obj = ba.join(ba._head_proto(mats, spec, subdiv=subdiv), "head_proto") + elif name == "hand_l_proto": + obj = ba._hand_proto(mats, spec, side=-1.0) + elif name == "hand_r_proto": + obj = ba._hand_proto(mats, spec, side=1.0) else: side = -1.0 if name == "hand_l" else 1.0 hs = ba._hands_and_shoes(mats, spec) @@ -155,7 +160,7 @@ def extract_part(name, variant, out): if mesh is None: raise SystemExit(f"no npc_base mesh in {src}") group = ("Head" if name in ("head", "face", "head_proto") - else f"Hand{'L' if name == 'hand_l' else 'R'}") + else f"Hand{'L' if name in ('hand_l', 'hand_l_proto') else 'R'}") return _extract_group(mesh, group, out) diff --git a/tools/tests/test_npc_parts.py b/tools/tests/test_npc_parts.py index 0a242af..f8bcf51 100644 --- a/tools/tests/test_npc_parts.py +++ b/tools/tests/test_npc_parts.py @@ -11,7 +11,8 @@ class CliTests(unittest.TestCase): def test_part_names_are_known(self): self.assertEqual( set(npc_parts.PART_NAMES), - {"head", "face", "hand_l", "hand_r", "head_proto"}, + {"head", "face", "hand_l", "hand_r", "head_proto", + "hand_l_proto", "hand_r_proto"}, ) def test_blender_commands_fail_cleanly_without_bpy(self):