extends SceneTree ## Two things about the per-pose hold. ## ## 1. THE SLIDERS ON SCREEN BELONG TO THE POSE ON SCREEN. Half the hold's knobs ## mean something different at low ready than down the sights, and showing ## both sets at once meant every slider was for one of two poses with nothing ## saying which — and `pitch` does not exist down the sights at all, because ## there the muzzle follows the camera. A control that does nothing is worse ## than a missing one. ## ## 2. THE WRISTS ROTATE. They were one scalar each, a twist about the barrel, ## which is the only axis a hand wrapping a cylinder is free in ONCE the arc ## onto the barrel is solved — true of the support hand, never true of the ## trigger hand, and in neither case a way to cock a wrist forward or break it ## inward. Now three axes, in the gun's frame. This asserts each axis moves ## the hand it names, and that the two poses hold separate values. ## ## godot --path . -s res://debug/hold_pose_check.gd const LAB := "res://debug/rig_lab.tscn" ## Big enough to read past the pose layer's smoothing, small enough that the IK ## does not give up and drop the hold. const TWIST := 0.35 var _fails := 0 var _probe: PoseProbe = null ## Snapshot the pose from INSIDE the modifier pass. ## ## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so ## reading `get_bone_pose_rotation` from a SceneTree script recomputes the ## globals from the ANIMATION alone — the shooter hold is simply not in what you ## measure. The first version of this check did that and reported every wrist ## axis as moving the hand by 0.0 degrees, which is the same answer it would ## give if the wrists had never been implemented. ## ## The same trap, and the same fix, as `cloth_clip_check` and `travel_dir_check`. class PoseProbe extends SkeletonModifier3D: var pose: Array = [] func _process_modification() -> void: var skel := get_skeleton() if skel == null: return pose.resize(skel.get_bone_count()) for i in skel.get_bone_count(): pose[i] = skel.get_bone_global_pose(i) func _init() -> void: await process_frame var lab: Node = load(LAB).instantiate() root.add_child(lab) for _i in 200: await process_frame if lab._model == null or lab._model._pose_mod == null: _expect(false, "the lab built a character with a pose layer") _done() return _check_scoping(lab) await _check_wrists(lab) _done() # ── 1. slider scoping ──────────────────────────────────────────────────────── func _check_scoping(lab: Node) -> void: var shared := {} for spec in WeaponHoldTuning.SHARED_KNOBS: shared[spec[0]] = true for i in lab.POSES.size(): lab._pose = i lab._rebuild_knobs() var pose: String = lab._hold_pose() var other: String = "ads" if pose == "hip" else "hip" var keys: Array = lab._sliders["hold"].keys() var strays: PackedStringArray = [] for k in keys: if shared.has(k): continue if not String(k).ends_with("_" + pose): strays.append(k) _expect(strays.is_empty(), "'%s' shows only %s knobs%s" % [lab.POSES[i][0], pose, "" if strays.is_empty() else " — strays: " + ", ".join(strays)]) # Specifically: the OTHER pose's stock pocket must not be on screen. It # is the knob most likely to be edited by accident, because both poses # have one and they look identical in a list. _expect(not lab._sliders["hold"].has("pocket_" + other), "'%s' does not show the %s stock pocket" % [lab.POSES[i][0], other]) # ...and the heading says which hold is being edited. var head: String = lab._heading_for("hold") _expect(head.to_lower().contains( WeaponHoldTuning.POSE_NAMES[pose]), "'%s' heading names the hold: %s" % [lab.POSES[i][0], head]) # `pitch` exists at low ready and NOWHERE else. lab._pose = 0 lab._rebuild_knobs() _expect(lab._sliders["hold"].has("pitch_hip"), "low ready offers a muzzle pitch") lab._pose = 1 lab._rebuild_knobs() _expect(not lab._sliders["hold"].has("pitch_ads"), "aiming offers no muzzle pitch — down the sights it follows the camera") # ── 2. the wrists ──────────────────────────────────────────────────────────── func _check_wrists(lab: Node) -> void: var model = lab._model var pm = model._pose_mod var skel: Skeleton3D = model.skeleton var hands := {"wrist_r": "hand.R", "wrist_l": "hand.L"} # AFTER the hold, so what it sees is what the hold produced. _probe = PoseProbe.new() _probe.name = "WristProbe" skel.add_child(_probe) for _i in 10: await process_frame for pose in ["hip", "ads"]: lab._pose = 0 if pose == "hip" else 1 lab._rebuild_knobs() for stem in hands: var bone: int = _role_bone(model, hands[stem]) if bone < 0: _expect(false, "'%s' resolves to a bone" % hands[stem]) continue for axis in 3: var v := Vector3.ZERO v[axis] = TWIST model.set_hold_tuning({}) await _settle(lab, pose) var before: Quaternion = _probe_rot(bone) model.set_hold_tuning({"%s_%s" % [stem, pose]: v}) await _settle(lab, pose) var after: Quaternion = _probe_rot(bone) var moved := rad_to_deg(before.angle_to(after)) # Not compared to an exact angle: the wrist is applied as a # global-space target and blended in by the hold weight, so the # LOCAL rotation that lands on the bone is not the knob. That it # moved, and moved substantially, is the claim. _expect(moved > 3.0, "%s %s axis %d turned the hand %.1f deg" % [stem, pose, axis, moved]) model.set_hold_tuning({}) func _settle(lab: Node, pose: String) -> void: lab._model.update_state("ground", 0.0, false) lab._model.set_locomotion(0.0, 0.0, 1.0 if pose == "ads" else 0.0) for _i in 60: await process_frame func _probe_rot(bone: int) -> Quaternion: if _probe == null or bone >= _probe.pose.size(): return Quaternion.IDENTITY var t: Transform3D = _probe.pose[bone] return t.basis.get_rotation_quaternion() func _role_bone(model, role: String) -> int: var name: String = model._rig_info.get("roles", {}).get(role, "") return model.skeleton.find_bone(name) if name != "" else -1 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=== HOLD POSES ===\nFailures: %d" % _fails) quit(1 if _fails > 0 else 0)