feat(rig lab): drag the anchors themselves, not just the gun under them

The lab could move the GUN and not the anchor points the hands are solved onto.
`grip_offset` slides the weapon around inside the fist; `gun_fore` and
`gun_stock` are distances ALONG the barrel, so the trigger and support hands
could travel up and down the weapon's own axis and nowhere else. Nothing could
take a hand off that axis, which is what a handguard below the bore, an angled
foregrip, or a pistol whose grip is nowhere near its barrel line all need.

Two things fix that.

`grip_shift` and `fore_shift` give the two hand anchors real three-dimensional
freedom, expressed in the GUN's own across/up/along frame so a sideways nudge
stays sideways as the weapon pitches between low ready and ADS. Zero is exactly
the old behaviour. Their z overlaps the along-axis distances, which is redundant
and deliberate: keeping those separate is what lets the reach solver slide the
support hand back down the handguard without undoing a considered sideways
offset.

And the markers are now draggable. They already showed the anchors; now they
are handles. The one under the mouse swells and draws through the body — depth
testing is right for judging whether a hand reached its target and wrong for a
handle, because at any useful framing the hands occlude all three.

Verified three ways, and each one had to be rebuilt once:

  anchor_shift_check first compared absolute positions and reported a 3.5 mm
  error that was the character BREATHING — there is a sin() on the muzzle pitch,
  so no anchor is ever in the same place twice. Measuring each anchor relative
  to the one it hangs off, rotated into the current gun basis, cancels the
  breathing, the ADS blend and the recoil exactly. 48 checks, six characters,
  both poses.

  anchor_drag_check asserts the drag writes the knob the MOUSE asked for,
  derived independently from the camera: 0.00-0.01 mm on all three. It does not
  assert the marker lands under the cursor, because it does not — the anchors
  hang off the shoulder and the arm chasing them moves the shoulder, so a drag
  settles at 0.77x-1.13x. Small enough to ignore interactively.

  That feedback first read as 1.5x-1.8x, because the cases were compounding on
  each other, and waiting LONGER for the pose to settle made it worse rather
  than better — which is the opposite of how a settling error behaves and is
  what gave it away.

The buttstock case also failed for a while on a bug entirely in the test: it
read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The
lab has a note about that trap in `_reset`. It is just as easy to walk into from
a test, and now has one there too.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-28 01:05:37 -04:00
co-authored by Claude Opus 5
parent 700d0925d7
commit b1c8bab714
9 changed files with 566 additions and 11 deletions
@@ -136,6 +136,31 @@ Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
to isolate it — that is how the classifier gets checked: click `hair` and
anything else still standing was misclassified.
**Drag the coloured markers.** They ARE the anchor points the hands are solved
onto — red the trigger grip, green the support hand, blue the buttstock — and
the one under the mouse swells and draws through the body so it can be grabbed
where the hands would otherwise hide it.
Dragging an anchor is not the same as any slider:
| | moves |
|---|---|
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
| dragging a marker | the anchor itself, in three dimensions |
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
barrel, so the two hand anchors could slide up and down the gun and nowhere
else — no use for a handguard below the bore, an angled foregrip, or a pistol
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
for whichever pose is showing.
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
when it looks right — and measured, not assumed.
## Read before you touch anything
Load the reference that matches what you are doing. They are short and each one
@@ -44,6 +44,8 @@ Two related traps:
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
| `roster_capture.gd` | one photo of every character, from the picker | — |
| `ui_capture.gd` | one photo of every menu screen | — |
@@ -78,6 +80,14 @@ Three of these exist because the obvious check passes on a broken system.
nobody consumed. It measures in the attachment's frame, not the world's:
the attachment tracks a bone on an animating skeleton, so a world-space delta
is mostly the idle animation.
- `anchor_shift_check` and `anchor_drag_check` both measure in the GUN's frame
rather than the world's, and have to. The hold BREATHES — a
`sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
same world position twice, and comparing absolute positions reported a 3.5 mm
error that was the character inhaling. Taking each anchor relative to the one
it hangs off and rotating into the current gun basis cancels the breathing,
the ADS blend and the recoil kick exactly, because all three move the basis
and the anchor together.
- `surface_class_check` FAILS on a surface that falls through to the heuristic
instead of resolving from the table. A model whose names stopped matching still
renders — the fallback catches it — and quietly loses its per-class art