Files
Papay-Shooter/debug/anchor_drag_check.gd
T
Nicholas ButzkeandClaude Opus 5 b1c8bab714 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]>
2026-07-28 01:05:37 -04:00

166 lines
6.5 KiB
GDScript

extends SceneTree
## Does dragging an anchor marker in the rig lab move that anchor where the
## mouse went?
##
## The maths behind a viewport drag has four frames in it — screen, world,
## skeleton, gun — and every one is a chance to transpose an inverse or lose a
## handedness. All of those mistakes still MOVE the marker, so "the number
## changed" proves nothing. What is asserted here is that the number changed by
## the RIGHT AMOUNT, in the frame that knob is written in, derived independently
## from the camera.
##
## Deliberately NOT asserted: that the marker lands exactly under the mouse. It
## does not, and the reason is a real property of the hold rather than a bug in
## the drag — see THE FEEDBACK below. The screen-space check kept here is a
## direction-and-order-of-magnitude one, which is the band the transform
## mistakes above actually live in: a swapped axis or a lost handedness sends
## the marker the wrong way entirely.
##
## THE FEEDBACK. Every anchor hangs off the shoulder — `stock_pos = shoulder +
## pocket`, and the grip and fore anchors are measured out from there. The
## shoulder is driven by the arm, and the arm is chasing the anchor. So moving
## an anchor moves the shoulder, which moves the anchor again. Measured from a
## clean slate it settles at 0.77x-1.13x of the drag depending on which anchor,
## which is small enough to be invisible interactively — you stop dragging when
## it looks right.
##
## It is worth the paragraph because of how it first showed up. Without the
## `_reset` between cases below, each drag started on top of the last one still
## working its way through the arm, and the ratio read 1.5x-1.8x; 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 the compounding
## away.
##
## godot --path . -s res://debug/anchor_drag_check.gd
const LAB := "res://debug/rig_lab.tscn"
const DRAG := Vector2(60, 0)
## Metres. What the knob is checked to — this part is exact maths, so it can be.
const KNOB_TOLERANCE := 0.0005
## The screen-space check is direction and order of magnitude only. See above.
const MIN_TRAVEL := 0.5
const MAX_TRAVEL := 2.2
var _fails := 0
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
# The pose layer chases its targets exponentially at a rate times DELTA, and
# a headless run is uncapped, so each frame advances the blend by almost
# nothing and the hold takes hundreds of frames to stop moving on its own.
for _i in 300:
await process_frame
if lab._model == null or not lab._model.loaded or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
_expect(true, "the lab built a character with a pose layer")
# Frame the hands, so a pixel is a small distance in the world — a drag
# measured at arm's length is mostly noise.
lab._pivot = lab._model.skeleton.global_transform * lab._model._pose_mod.dbg_grip
lab._dist = 0.6
lab._update_camera()
for _i in 20:
await process_frame
for case in [[0, "trigger hand", "grip_shift"], [1, "support hand", "fore_shift"],
[2, "buttstock", "pocket_hip"]]:
await _drag_case(lab, case[0], case[1], case[2])
_done()
func _drag_case(lab: Node, marker: int, label: String, key: String) -> void:
# From a clean slate each time, or the second case measures the first's
# shift still working its way through the arm.
lab._reset("hold")
for _i in 120:
await process_frame
var m: Node3D = lab._markers[marker]
if not m.visible:
_expect(false, "the %s marker is visible" % label)
return
var before: Vector2 = lab._cam.unproject_position(m.global_position)
_expect(lab._marker_under(before) == marker,
"the %s marker is grabbable where it is drawn" % label)
# What the drag SHOULD write, worked out from the camera here rather than
# from the lab's own code, so the two have to agree independently.
var want: Vector3 = _expected(lab, marker, m.global_position, before, before + DRAG)
var was: Vector3 = _knob(lab, key)
lab._begin_drag(marker, before)
# In steps, as a real drag arrives — a single jump would hide an error that
# accumulates per motion event.
for step in 6:
lab._drag_to(before + DRAG * (float(step + 1) / 6.0))
await process_frame
lab._drag_marker = -1
var wrote: Vector3 = _knob(lab, key) - was
var err: float = (wrote - want).length()
_expect(err <= KNOB_TOLERANCE,
"the %s drag wrote %s into '%s' (wanted %s, off by %.2f mm)"
% [label, _mm(wrote), key, _mm(want), err * 1000.0])
# ...and the marker really did go that way on screen.
for _i in 150:
await process_frame
var now: Vector2 = lab._cam.unproject_position(m.global_position)
var moved: Vector2 = now - before
var along: float = moved.dot(DRAG.normalized()) / DRAG.length()
_expect(along >= MIN_TRAVEL and along <= MAX_TRAVEL,
"the %s marker followed the drag (%.2fx of it; the shoulder feedback puts this over 1)"
% [label, along])
## The knob delta a drag from `a` to `b` ought to produce, in that knob's frame.
func _expected(lab: Node, marker: int, at: Vector3, a: Vector2, b: Vector2) -> Vector3:
var world := _plane(lab._cam, at, b) - _plane(lab._cam, at, a)
var v: Vector3 = lab._model.skeleton.global_transform.basis.inverse() * world
if lab.MARKER_KNOB[marker][1] == "gun":
v = lab._model._pose_mod.dbg_gun_basis.inverse() * v
return v
func _plane(cam: Camera3D, at: Vector3, mouse: Vector2) -> Vector3:
var origin := cam.project_ray_origin(mouse)
var dir := cam.project_ray_normal(mouse)
var n := -cam.global_transform.basis.z
return origin + dir * (((at - origin).dot(n)) / dir.dot(n))
## An absent knob reads as its DEFAULT, not as zero.
##
## Those are the same thing for `grip_shift` and `fore_shift` and not for
## `pocket_hip`, whose default is (30, -70, 60) mm. Reading it as zero made a
## perfectly correct 60 px drag look like a 97 mm error — the difference was
## exactly the default. The lab has a note about this trap in `_reset`; it is
## just as easy to walk into from a test.
func _knob(lab: Node, key: String) -> Vector3:
var v = lab._knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
return v if v is Vector3 else Vector3.ZERO
func _mm(v: Vector3) -> String:
return "(%.0f, %.0f, %.0f) mm" % [v.x * 1000.0, v.y * 1000.0, v.z * 1000.0]
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1
func _done() -> void:
print("\n=== ANCHOR DRAG ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)