Grip poses: per-hand primary grips, mirror mode, preview toggles (issue #42) #45

Merged
merith-tk merged 5 commits from muse-lantern/da-game:grip-poses-per-hand into main 2026-11-14 02:52:54 +00:00
Member

Grip poses: per-hand primary grips, mirror mode, preview toggles (issue #42)

Slice 1 of the grip-pose plan (dead-air-grip-pose-plan.md): primary grips, one per hand. The base pose (model.grip_offset / grip_rotate) stays the right/default pose; grip_poses entries override per side.

The mirror rule, as implemented

  • Lateral axis is item-local X (ItemSpec.GRIP_LATERAL_AXIS = Vector3(1, 0, 0)).
  • mirror_grip_pose(pose): position.x negated (y, z kept); rotation conjugated M·R·M with M = diag(-1, 1, 1), via Basis math. Net effect: pitch kept, yaw and roll negated. Asserted on the basis (the Euler-triple assertion was dropped: get_euler() can return an equivalent representation of the same basis).
  • Centeredness: is_grip_centered(bounds_center) = absf(bounds_center.x) <= 0.005 (5 mm epsilon on the bounds centre's lateral component).
  • Resolution (ItemSpec.primary_grip_pose): right = base pose (default + authored-right entry + grip_offset/grip_rotate); left with an authored entry = its own composed pose; left without one + centered object = mirror of the fully composed right pose; left without one + non-centered = right pose as-is (mirrored=false).
  • Scale rule: poses are authored in model space; set_content_scale(s) scales bounds and all GrabPoint positions by the relative factor (rotation unaffected); content_scale recorded on the item.

Runtime

  • Two primary points: GrabPrimaryR / GrabPrimaryL (mode PRIMARY, hand_side set, da_mirrored / da_authored metas). Grabbable.best_point() takes a hand side, prefers PRIMARY points for that side, falls back to any PRIMARY (shared points keep working); grab() derives the side from the holder (HandFollower.hand_id, else grip node name). HandInteractor passes its side.
  • An authored grip_poses entry replaces the default (default only as a .get() fallback), then offset/rotate compose on top (item_spec.gd:147-148). So entries store marker - offset/rotate, not a delta from the default pose. Anyone authoring entries by hand: the entry is the pose relative to the default origin.

Editor

  • Markers grip_right / grip_left (schema grip, replacing gun_grip — the rename rides here; vetoable before merge).
  • Side toggle left/both/right; render-mode toggle full/ghost/marker; mirror mode (right is master — edits apply to the right, left always shows mirror(right), live-snapped).
  • Warn dialog on enabling mirror mode with a custom left pose (Overwrite vs Keep left pose).
  • Dock shows per-side state (custom/mirrored/as-is/base) + status dots. A custom left is one grip_poses entry ({name, hands: ["left"], position, rotation_deg}), written only when the left differs from its derived pose, deleted when it matches again. JSON field names untouched.
  • Not headless-testable: the dialog's actual popup/click path and the dock in the real editor GUI — needs an in-editor check (same standing caveat as the earlier editor work).

Verification (VM, Godot 4.6.3 pin)

  • Double --import after .godot wipe: 0 ERROR both passes. tools/check.py: 6/6.
  • Full battery (independent re-run by Lantern): 33 pass, 3 fail, 1,693 ok lines (36 suites; terrain_check has no file on branch). Key suites: grip_pose_check PASS 40 ok; item_editor_check PASS 282 ok; item_editor_manage PASS 110 ok; mag_interaction PASS 69 ok; sandbox_world PASS 86 ok.
  • The 3 failures — volume_container (4 checks), spawn (1 check), mag_filo (1 check) — were confirmed byte-identical on pure base 8d466cf. All are PR #44's stale-expectation fallout, not this branch's.
  • SCRIPT ERRORs: exactly the known set — level_loader.gd:47 add_child-on-null (firearm, mag_filo, grip_pose) + firearm_check.gd:182 is_trigger_down. Nothing else.
  • sandbox_world_check was fixed on this branch: the bounds-scaling made its gizmo check fail — it compared the mesh's intrinsic AABB against the now-correctly-scaled bounds. The rendered gizmo was always correct (the gizmo node carries the content scale); the suite was scale-blind. It now compares rendered sizes (mesh AABB x gizmo node scale vs bounds + 0.02 x content_scale); passes on branch and base.

Overlap with PR #44

item_editor_check and mag_interaction_check on this branch overlap PR #44's diff on the same lines (both read expectations from the item dict now; this branch additionally updates the mag check to the two-primary-point world). Whichever of #44 / this PR lands second needs a rebase and a reconciliation of those two files — the slice-1 versions supersede #44's for the lines they touch.

Follow-ups (not in this PR)

  • Bridge e2e not run. The grip path doesn't break the bridge's call shape (best_point defaults the side to "right"), but the bridge's best_point(grip.global_position, true) passes no side — a left-hand bridge grab currently takes the right point instead of the mirrored left one. Wiring the side through the bridge is a small follow-up; the in-headset feel of the per-hand poses is unverifiable headlessly either way.
  • Slices 2-5 (secondary grips; multi-grip + event-driven extensions + built-in rack/bolt sliding; rack eject pose; slide slam on fire + slide lock on last round) are planned only, per the grip-pose plan.

Head: muse-lantern:grip-poses-per-hand (242f438) — 3 commits, 16 files, +1402/-231, base main 8d466cf.

# Grip poses: per-hand primary grips, mirror mode, preview toggles (issue #42) Slice 1 of the grip-pose plan (`dead-air-grip-pose-plan.md`): primary grips, one per hand. The base pose (`model.grip_offset` / `grip_rotate`) stays the right/default pose; `grip_poses` entries override per side. ## The mirror rule, as implemented - Lateral axis is item-local X (`ItemSpec.GRIP_LATERAL_AXIS = Vector3(1, 0, 0)`). - `mirror_grip_pose(pose)`: position.x negated (y, z kept); rotation conjugated `M·R·M` with M = diag(-1, 1, 1), via Basis math. Net effect: pitch kept, yaw and roll negated. Asserted on the basis (the Euler-triple assertion was dropped: `get_euler()` can return an equivalent representation of the same basis). - Centeredness: `is_grip_centered(bounds_center)` = `absf(bounds_center.x) <= 0.005` (5 mm epsilon on the bounds centre's lateral component). - Resolution (`ItemSpec.primary_grip_pose`): right = base pose (default + authored-right entry + `grip_offset`/`grip_rotate`); left with an authored entry = its own composed pose; left without one + centered object = mirror of the fully composed right pose; left without one + non-centered = right pose as-is (`mirrored=false`). - Scale rule: poses are authored in model space; `set_content_scale(s)` scales `bounds` and all GrabPoint positions by the relative factor (rotation unaffected); `content_scale` recorded on the item. ## Runtime - Two primary points: `GrabPrimaryR` / `GrabPrimaryL` (mode PRIMARY, `hand_side` set, `da_mirrored` / `da_authored` metas). `Grabbable.best_point()` takes a hand side, prefers PRIMARY points for that side, falls back to any PRIMARY (shared points keep working); `grab()` derives the side from the holder (`HandFollower.hand_id`, else grip node name). `HandInteractor` passes its side. - An authored `grip_poses` entry **replaces** the default (default only as a `.get()` fallback), then offset/rotate compose on top (`item_spec.gd:147-148`). So entries store `marker - offset/rotate`, **not** a delta from the default pose. Anyone authoring entries by hand: the entry is the pose relative to the default origin. ## Editor - Markers `grip_right` / `grip_left` (schema `grip`, replacing `gun_grip` — the rename rides here; vetoable before merge). - Side toggle left/both/right; render-mode toggle full/ghost/marker; mirror mode (right is master — edits apply to the right, left always shows `mirror(right)`, live-snapped). - Warn dialog on enabling mirror mode with a custom left pose (Overwrite vs Keep left pose). - Dock shows per-side state (custom/mirrored/as-is/base) + status dots. A custom left is one `grip_poses` entry (`{name, hands: ["left"], position, rotation_deg}`), written only when the left differs from its derived pose, deleted when it matches again. JSON field names untouched. - Not headless-testable: the dialog's actual popup/click path and the dock in the real editor GUI — needs an in-editor check (same standing caveat as the earlier editor work). ## Verification (VM, Godot 4.6.3 pin) - Double `--import` after `.godot` wipe: 0 ERROR both passes. `tools/check.py`: 6/6. - Full battery (independent re-run by Lantern): 33 pass, 3 fail, 1,693 ok lines (36 suites; `terrain_check` has no file on branch). Key suites: `grip_pose_check` PASS 40 ok; `item_editor_check` PASS 282 ok; `item_editor_manage` PASS 110 ok; `mag_interaction` PASS 69 ok; `sandbox_world` PASS 86 ok. - The 3 failures — `volume_container` (4 checks), `spawn` (1 check), `mag_filo` (1 check) — were confirmed byte-identical on pure base `8d466cf`. All are PR #44's stale-expectation fallout, not this branch's. - SCRIPT ERRORs: exactly the known set — `level_loader.gd:47` add_child-on-null (firearm, mag_filo, grip_pose) + `firearm_check.gd:182` `is_trigger_down`. Nothing else. - `sandbox_world_check` was fixed on this branch: the bounds-scaling made its gizmo check fail — it compared the mesh's intrinsic AABB against the now-correctly-scaled bounds. The rendered gizmo was always correct (the gizmo node carries the content scale); the suite was scale-blind. It now compares rendered sizes (mesh AABB x gizmo node scale vs bounds + 0.02 x content_scale); passes on branch and base. ## Overlap with PR #44 `item_editor_check` and `mag_interaction_check` on this branch overlap PR #44's diff on the same lines (both read expectations from the item dict now; this branch additionally updates the mag check to the two-primary-point world). Whichever of #44 / this PR lands second needs a rebase and a reconciliation of those two files — the slice-1 versions supersede #44's for the lines they touch. ## Follow-ups (not in this PR) - Bridge e2e not run. The grip path doesn't break the bridge's call shape (`best_point` defaults the side to `"right"`), but the bridge's `best_point(grip.global_position, true)` passes no side — a left-hand bridge grab currently takes the right point instead of the mirrored left one. Wiring the side through the bridge is a small follow-up; the in-headset feel of the per-hand poses is unverifiable headlessly either way. - Slices 2-5 (secondary grips; multi-grip + event-driven extensions + built-in rack/bolt sliding; rack eject pose; slide slam on fire + slide lock on last round) are planned only, per the grip-pose plan. Head: `muse-lantern:grip-poses-per-hand` (242f438) — 3 commits, 16 files, +1402/-231, base main `8d466cf`.
Runtime half of grip-pose slice 1. The grab point no longer hardcodes
the right-hand pose for both hands:

- ItemSpec: mirror_grip_pose() (lateral position negated, rotation
  conjugated M*R*M), is_grip_centered() (5 mm epsilon on the bounds
  centre lateral component), primary_grip_pose() resolving a side to
  {position, rotation_deg, mirrored, authored}. Right is the base
  pose (default + grip_poses entry + grip_offset/grip_rotate); left
  with an authored entry takes it, left without one mirrors the
  right pose for centered objects and takes the right pose as-is
  otherwise.
- WorldItem: two primary GrabPoints, GrabPrimaryR / GrabPrimaryL,
  each with hand_side and da_mirrored/da_authored metas.
  set_content_scale now also scales bounds and the GrabPoint
  positions (poses are authored in model space).
- Grabbable.best_point() takes a hand side and prefers PRIMARY
  points for that side, falling back to any PRIMARY (shared points
  keep working); grab() derives the side from the holder node
  (HandFollower hand_id, else grip node name).
- HandInteractor passes its hand side to best_point.

Tests: new grip_pose_check (mirror rule, pistol case, authored
left, non-centered fallback, scale, side selection); mag_filo and
mag_interaction adapted to the two points; mag_interaction now
reads its rotation expectation from the definition (a mag with a
grip_rotate composes it on the default, a mag without one keeps
the default). grip_pose_check is listed in CI.
Editor half of grip-pose slice 1 (runtime half is 220511e).

- item_schema: the gun_grip marker becomes grip (a grip_right /
  grip_left pair in the stage). New dict-level grip helpers that
  mirror ItemSpec.primary_grip_pose: resolved_grip_pose(),
  grip_side_state() (custom / mirrored / as-is / base),
  grip_pose_entry() / grip_pose_set_entry() /
  grip_pose_erase_side(), grip_entry_from_marker(), and the pure
  mirror_mode_would_clobber() decision.
- item_stage: two grip markers (right = base pose incl. an authored
  right entry, left = authored / mirrored / as-is). Collect writes
  model.grip_offset / grip_rotate from the right marker and a
  grip_poses entry (hands [left]) only when the left pose differs
  from its derived pose; moving the left marker back to the derived
  pose deletes the entry. Mirror mode (right is master): the left
  marker follows mirror(right), live in _process and on enable
  (which drops the left entry). Preview hands are per-side now:
  set_grip_preview(enabled, sides, render_mode) with sides
  left/both/right and render modes full/ghost/marker (marker = no
  hand mesh, only the axes triad); each hand tracks its own marker.
- dock: Sides + Render dropdowns in the Grip preview section, a
  Mirror mode toggle, and a warn dialog (Overwrite vs Keep left
  pose) when enabling mirror mode would clobber a custom left pose.
  The marker list shows grip_right / grip_left rows with per-side
  state and the existing status dot.
- tests/item_editor_check: rename + expectations read from the
  staged item dict (fixes the two stale 8d466cf failures: pistol
  grip_offset z 0.05347, mag grip_rotate 24.33 -> 114.33). New
  coverage: two markers + mirror rule via the editor path,
  custom-left collect/reload/remove, mirror mode decision + stage
  path, preview sides/render modes, dock per-side state.
  282 ok (was 219 + 2 stale FAILs).

Entry semantics follow the runtime code, not the prose formula in
the task: an entry REPLACES the default (composed = entry +
offset/rotate; ItemSpec.primary_grip_pose, proven by
grip_pose_check: entry rot (80,10,0) spawns at (80,10,0) with a
(90,0,0) default). Storing marker - default - offset would have
landed the hand a default-pose off in the game (the first reload
check caught it at rot 8 vs 80); entries store marker -
offset/rotate, and the right collect subtracts the base the marker
replaces (default, or an authored right entry).
sandbox_world_check: compare rendered gizmo sizes (issue #42)
Some checks failed
tests / test (pull_request) Failing after 1m42s
242f438413
The grip-pose runtime scales WorldItem.bounds by the content scale,
so the gizmo check now compares the RENDERED sizes: the mesh AABB
times the gizmo node scale against bounds + the 0.02 margin in mesh
space (0.02 * content_scale in world space). Previously both sides
were unscaled -- the mesh intrinsic AABB against the stale full-size
bounds -- so the check passed while measuring the wrong thing; the
rendered gizmo was always correct (the gizmo node carries the
content scale). The sphere branch scales the radius the same way.
Test: fold PR #44 grip-pose expectation fixes into slice 1 (issue #42)
All checks were successful
tests / test (pull_request) Successful in 2m23s
c352da8a76
spawn_check.gd: took #44 wholesale (authored Body hull wins; fallback probe).
volume_container_check.gd: took #44 wholesale (hand-stays-back pushes -x; offset read from def).
mag_filo_check.gd: reconciled - kept #45s GrabPrimaryR lookup; added #44s bounds-centre + grip_offset cube expectation and the no-offset synthetic probe.
item_editor_check.gd: kept #45s version - #44s dict-derived pistol/mag grip expectations are present (pistol lines 168-172, mag lines 707-711), marker renamed gun_grip -> grip_right.
mag_interaction_check.gd: kept #45s version - #44s runtime-composed default+grip_rotate check is present (lines 205-206) and the absent-rotation probe is present (lines 243-265), adapted to two PRIMARY points.
muse-lantern force-pushed grip-poses-per-hand from c352da8a76
All checks were successful
tests / test (pull_request) Successful in 2m23s
to 15dc67a4c9
All checks were successful
tests / test (pull_request) Successful in 2m35s
2026-11-13 15:54:36 +00:00
Compare
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine!45
No description provided.