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
+140
View File
@@ -0,0 +1,140 @@
extends SceneTree
## Do the hand anchors move where they are told, in the frame they are told in?
##
## `grip_shift` and `fore_shift` exist because the two hand anchors could only
## ever slide along the barrel: `gun_stock` and `gun_fore` are distances along
## the weapon's own axis, so the trigger and support hands travelled up and down
## the gun and nowhere else. What could move freely was the GUN, under anchors
## that stayed put.
##
## Two things have to hold, and only the first is obvious:
##
## 1. the anchor moves by the amount asked for;
## 2. it moves in the GUN's frame, not the skeleton's. A sideways nudge has to
## stay sideways relative to the weapon whether the muzzle is pitched down
## at low ready or level at ADS — otherwise the same number means two
## different places in the two poses, and a skeleton-space implementation
## passes check 1 happily.
##
## MEASURED IN THE GUN'S FRAME, and it has to be. The hold BREATHES — there is a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
## same world position twice, and the first version of this check compared
## absolute positions and reported a 3.5 mm error that was just 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.
##
## godot --headless --path . -s res://debug/anchor_shift_check.gd
## All three axes, deliberately asymmetric, so an axis swap or a sign flip
## cannot pass.
const SHIFT := Vector3(0.05, -0.03, 0.02)
const TOLERANCE := 0.0015
## Long enough for the ADS blend and the hold's take-up to settle. The gun-frame
## measurement is invariant to both, but a half-blended pose is a bad place to
## be reading anything.
const SETTLE := 40
var _fails := 0
func _init() -> void:
await process_frame
await process_frame
var weapon := _first_weapon()
var data = JSON.parse_string(FileAccess.get_file_as_string(
"res://assets/characters/skins/skins.json"))
for entry in data["skins"]:
await _check(entry["id"], entry.get("model", ""), weapon)
print("\n=== ANCHOR SHIFTS ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
func _first_weapon() -> String:
var db = root.get_node("LoadoutManager").weapon_db
var ids: Array = db.keys()
ids.sort()
for id in ids:
var s: String = db[id].get("script", "")
if s != "" and ResourceLoader.exists(s):
return s
return ""
## The two anchors in the gun's own across/up/along frame:
## grip, relative to the buttstock == (0, 0, gun_stock) + grip_shift
## fore, relative to the grip == (0, 0, fore_dist) + fore_shift
func _local(pm) -> Array:
var inv: Basis = pm.dbg_gun_basis.inverse()
var grip: Vector3 = pm.dbg_grip
var fore: Vector3 = pm.dbg_fore
var stock: Vector3 = pm.dbg_stock
return [inv * (grip - stock), inv * (fore - grip)]
func _check(id: String, path: String, weapon: String) -> void:
if path == "" or not ResourceLoader.exists(path):
return
var model := SkinnedPlayerModel.new()
model.model_path = path
model.skin_id = id
root.add_child(model)
for _i in 4:
await process_frame
model.set_weapon(weapon)
for _i in 6:
await process_frame
# Both poses, because the gun's pitch differs between them and that is the
# whole point of expressing the shift in the gun's frame.
for pose in [["low ready", 0.0], ["ADS", 1.0]]:
var pm = model._pose_mod
if pm == null:
_expect(false, "'%s' has a pose layer" % id)
break
model.set_hold_tuning({})
model.update_state("ground", 0.0, false)
model.set_locomotion(0.0, 0.0, pose[1])
for _i in SETTLE:
await process_frame
var base: Array = _local(pm)
# Each anchor on its own. `fore` hangs off `grip`, so shifting the grip
# legitimately carries the support hand with it — moving where the
# trigger hand holds a rifle moves the whole rifle, handguard included.
# Testing them together would just measure that, and the first version
# of this check did, and reported the sum as a 2x error.
for which in [["grip_shift", 0, "trigger"], ["fore_shift", 1, "support"]]:
model.set_hold_tuning({which[0]: SHIFT})
for _i in 6:
await process_frame
var now: Array = _local(pm)
var moved: Vector3 = now[which[1]] - base[which[1]]
# x and y are across the barrel — the freedom that did not exist
# before. z is along it, and for the support hand the reach solver
# owns that, so it is not ours to predict.
var across := Vector2(moved.x, moved.y)
var want := Vector2(SHIFT.x, SHIFT.y)
_expect(across.distance_to(want) <= TOLERANCE,
"'%s' %s: %s anchor moved %.0f, %.0f mm across the barrel (wanted %.0f, %.0f)"
% [id, pose[0], which[2], across.x * 1000.0, across.y * 1000.0,
want.x * 1000.0, want.y * 1000.0])
model.set_hold_tuning({})
for _i in 6:
await process_frame
var back: Array = _local(pm)
var residue: Vector3 = back[which[1]] - base[which[1]]
_expect(Vector2(residue.x, residue.y).length() <= TOLERANCE,
"'%s' %s: clearing %s restores the derived anchor"
% [id, pose[0], which[0]])
model.queue_free()
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1