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
+190 -9
View File
@@ -28,14 +28,27 @@ extends Node3D
## for the game to run correctly.
##
## CONTROLS
## left drag orbit wheel zoom
## middle drag pan F frame the hands
## R reset knobs S save C copy JSON to clipboard
## drag a MARKER move that anchor left drag orbit
## middle drag pan wheel zoom
## F frame the hands
## R reset knobs S save C copy JSON to clipboard
##
## The three coloured markers are the points being solved for — red is the
## trigger grip, green the support hand on the handguard, blue the buttstock. If
## a hand is not on its marker the IK could not reach, which is a different
## problem from the marker being in the wrong place.
## The three coloured markers ARE the anchors — red is the trigger grip, green
## the support hand on the handguard, blue the buttstock — and they can be
## dragged. The marker under the mouse swells; drag it and the hand follows.
##
## Dragging an anchor is not the same as any slider above it. The sliders move
## the GUN: `grip_offset` slides the weapon around inside the fist, and
## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither
## can take a hand OFF that axis, which is what a handguard below the bore, an
## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or
## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways
## stays sideways as the weapon pitches; the buttstock marker writes the shoulder
## pocket for whichever pose is showing.
##
## If a hand is not ON its marker, the IK could not reach — a different problem
## from the marker being in the wrong place, and dragging the marker further will
## not fix it.
const POSES := [
["Low ready", "ground", 0.0, 0.0],
@@ -162,7 +175,8 @@ func _build_world() -> void:
# Depth-tested on purpose. Drawn through the body they look like they are
# floating in front of the chest when they are in fact behind an arm,
# which is exactly the wrong impression for judging whether a hand is on
# its target.
# its target. `_process` lifts this for the marker under the mouse, so
# the one you are about to grab is the one you can see.
mat.no_depth_test = false
m.material_override = mat
add_child(m)
@@ -269,6 +283,155 @@ func _process(_delta: float) -> void:
for i in _markers.size():
_markers[i].global_position = to_world * pts[i]
_markers[i].visible = pts[i] != Vector3.ZERO
# The one being dragged, or the one the mouse is over, swells AND
# draws through the body.
#
# Depth testing is right for the other two: drawn through the mesh
# they look like they are floating in front of the chest when they
# are in fact behind an arm, which is the wrong impression for
# judging whether a hand reached its target. But it is wrong for the
# one being grabbed — at any useful framing the hands occlude all
# three markers, and a handle you cannot see is a handle you cannot
# find. `_marker_under` never cared about occlusion; this makes that
# visible rather than a secret.
var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker)
_markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0)
var mat := _markers[i].material_override as StandardMaterial3D
if mat:
mat.no_depth_test = hot
# ── dragging the anchors ──────────────────────────────────────────────────────
#
# The markers are the anchor POINTS the hands are solved onto. Until now they
# could only be moved through the sliders, and two of the three could only move
# along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own
# axis, so the trigger and support hands slid up and down the weapon and nowhere
# else. What the sliders DID move freely was the gun itself, under anchors that
# stayed put.
#
# So: grab a marker and drag it. Each one writes to the knob that expresses that
# motion, in the frame that knob is written in — which is why the pose layer now
# publishes the gun's basis. A drag left has to mean "left across the weapon"
# whether the muzzle is down at low ready or level at ADS.
## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along
## basis; "skeleton" is the model's, which is where the shoulder pocket lives.
const MARKER_KNOB := [
["grip_shift", "gun"],
["fore_shift", "gun"],
["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose
]
## How close, in pixels, the mouse has to be to a marker to take hold of it.
const GRAB_RADIUS := 26.0
var _drag_marker := -1
var _hover_marker := -1
## The plane a drag is measured on: fixed at grab time so it does not drift
## toward the camera as the marker follows the mouse.
var _drag_plane_at := Vector3.ZERO
var _drag_last := Vector3.ZERO
## The stock anchor is a blend of two knobs; a drag edits whichever one this
## pose is actually showing. Dragging at low ready must not silently rewrite the
## aiming pocket.
func _stock_knob() -> String:
return "pocket_ads" if POSES[_pose][3] > 0.5 else "pocket_hip"
func _knob_for(marker: int) -> String:
var key: String = MARKER_KNOB[marker][0]
return _stock_knob() if key == "pocket" else key
func _marker_under(mouse: Vector2) -> int:
var best := -1
var best_d := GRAB_RADIUS
for i in _markers.size():
if not _markers[i].visible:
continue
var p: Vector3 = _markers[i].global_position
# Behind the camera projects to a nonsense point that can still land
# within the grab radius.
if _cam.is_position_behind(p):
continue
var d := _cam.unproject_position(p).distance_to(mouse)
if d < best_d:
best_d = d
best = i
return best
## Where the mouse ray meets the drag plane — through the grab point, facing the
## camera.
func _plane_hit(mouse: Vector2) -> Vector3:
var origin := _cam.project_ray_origin(mouse)
var dir := _cam.project_ray_normal(mouse)
var n := -_cam.global_transform.basis.z
var denom := dir.dot(n)
if absf(denom) < 1e-5:
return _drag_last
return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom)
func _begin_drag(marker: int, mouse: Vector2) -> void:
_drag_marker = marker
_drag_plane_at = _markers[marker].global_position
_drag_last = _plane_hit(mouse)
_status.text = "Dragging the %s anchor (%s)" % [
["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)]
func _drag_to(mouse: Vector2) -> void:
if _model == null or _model._pose_mod == null or _model.skeleton == null:
return
var now := _plane_hit(mouse)
var world_delta := now - _drag_last
_drag_last = now
# World -> skeleton, because that is the space the pose layer works in.
var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta
if MARKER_KNOB[_drag_marker][1] == "gun":
# ...and on into the gun's frame for the two hand anchors. Orthonormal,
# so the inverse is the transpose, but say what is meant.
delta = _model._pose_mod.dbg_gun_basis.inverse() * delta
var key := _knob_for(_drag_marker)
var spec := _spec_for("hold", key)
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
if not (cur is Vector3):
cur = Vector3.ZERO
var lo: float = spec[2]
var hi: float = spec[3]
_set_knob("hold", key, Vector3(
clampf(cur.x + delta.x, lo, hi),
clampf(cur.y + delta.y, lo, hi),
clampf(cur.z + delta.z, lo, hi)))
func _spec_for(group: String, key: String) -> Array:
for spec in _specs(group):
if spec[0] == key:
return spec
return ["", "", -1.0, 1.0, true, Vector3.ZERO]
## Write a knob from somewhere other than its own slider, and keep the slider in
## step. Without the write-back the sliders would silently disagree with the
## model the moment anything was dragged, and Save would store the sliders.
func _set_knob(group: String, key: String, value) -> void:
_knobs[group][key] = value
var entry = _sliders[group].get(key)
if entry != null:
if entry.spec[4]:
entry.x.set_value_no_signal(value.x)
entry.y.set_value_no_signal(value.y)
entry.z.set_value_no_signal(value.z)
else:
entry.x.set_value_no_signal(float(value))
_refresh_label(group, key)
_push(group)
# ── knobs ─────────────────────────────────────────────────────────────────────
@@ -406,7 +569,7 @@ func _build_ui() -> void:
_status = Label.new()
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.custom_minimum_size = Vector2(400, 40)
_status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands"
_status.text = "DRAG A MARKER to move that anchor · left drag orbit · wheel zoom · middle drag pan · F frame hands"
box.add_child(_status)
# Reflect whatever the shot arguments or the defaults selected, or the
# dropdown says one thing while the scene shows another.
@@ -581,6 +744,14 @@ func _update_camera() -> void:
func _unhandled_input(e: InputEvent) -> void:
if e is InputEventMouseMotion:
# An anchor drag OWNS the left button — otherwise grabbing a marker
# would also orbit the camera, and the anchor would appear to move
# because the view did.
if _drag_marker >= 0:
_drag_to(e.position)
return
_hover_marker = _marker_under(e.position) \
if e.button_mask == 0 else _hover_marker
if e.button_mask & MOUSE_BUTTON_MASK_LEFT:
_yaw -= e.relative.x * 0.006
_pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4)
@@ -589,6 +760,16 @@ func _unhandled_input(e: InputEvent) -> void:
var b := _cam.global_transform.basis
_pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015
_update_camera()
elif e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT:
if e.pressed:
var hit := _marker_under(e.position)
if hit >= 0:
_begin_drag(hit, e.position)
elif _drag_marker >= 0:
_status.text = "%s is now %s — Save to keep it" % [
_knob_for(_drag_marker),
_knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)]
_drag_marker = -1
elif e is InputEventMouseButton and e.pressed:
if e.button_index == MOUSE_BUTTON_WHEEL_UP:
_dist = maxf(_dist * 0.9, 0.15)