Files
FoxyzandClaude Opus 5.5 2aff2b61da Body step 2.1: climbing, mantling, crawling, legs, playground, 144 Hz
- Step rule + lower-body collider: no walking onto tables, steps <= 0.35 m
- Structural hand strength: two-hand mantle/pull-up works, one hand can't
- Arms aim from the physical shoulder horizontally, keep True Body pose vertically
- Crawl pose, crawl carry, 35 deg slope limit, ground feed-forward ignores
  play-space corrections (fixes a runaway feedback loop)
- Grab static world (climbing), procedural visual legs
- Playground sandbox; menu strength slider, spectator (old drag) and turn toggles
- Physics 144 Hz with interpolation; legacy chase-body spikes pin 90 Hz
- RagdollSpike: 27 phases incl. mantle, pull-up, crawl, ledge drop, ramps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:56:08 +03:00

491 lines
23 KiB
C#

using Godot;
public partial class GrabSpike : Node3D
{
private const float HandMass = 0.4f;
private const float HandMaxForce = 90f;
private const float HandMaxSpeed = 2.5f;
private const float ReachSpeed = 1f;
private const float HandMaxSpin = 120f;
private const float HandRadius = 0.05f;
private const float MoveDistance = 2f;
private const float MoveDuration = 2f;
private const float DragRampTime = 1f;
private const float LiftHeight = 0.3f;
private const float MaxSpeed = 20f;
private const float ReachTimeout = 3f;
private const float RigidWeldBias = 0f;
private const float RigidRotLimitDeg = 2f;
private const float RigidSepLimit = 0.01f;
private const float SpinRotLimitDeg = 3.5f;
private const float TakeoverRotLimitDeg = 5f;
private const float SpinLimit = 40f;
private const float PullDistance = 0.3f;
private const float SpinAngleDeg = 360f;
private const float SpinDuration = 2.5f;
private const float SpinHoldDuration = 0.5f;
private const float TugDuration = 1.5f;
private const float SettleDuration = 0.5f;
private const float FollowDuration = 1f;
private const float TugPull = 0.5f;
private const float FollowSway = 0.03f;
private const float TwoHandDuration = TugDuration + FollowDuration;
private const float TakeoverDuration = 1.5f;
private enum Phase { Reach, Drag, Spin, Hand2Reach, TwoHanded, TwoSettle, TwoFollow, Takeover }
private RigidBody3D _prop;
private RigidBody3D _hand;
private RigidBody3D _hand2;
private Node3D _target;
private Node3D _target2;
private ConeTwistJoint3D _joint;
private ConeTwistJoint3D _joint2;
private Vector3 _grabPoint;
private Vector3 _grab2Point;
private Vector3 _propAnchor;
private Vector3 _handAnchor;
private Vector3 _propAnchor2;
private Vector3 _handAnchor2;
private Quaternion _relRotation1;
private Quaternion _relRotation2;
private Quaternion _spinFrom;
private Quaternion _spinTo;
private Quaternion _cmd1 = Quaternion.Identity;
private Vector3 _takeoverBase;
private Phase _phase = Phase.Reach;
private float _phaseStart;
private float _elapsed;
private bool _dragRotOk;
private bool _dragSepOk;
private bool _dragSpeedOk;
private bool _spinRotOk;
private bool _spinSepOk;
private bool _spinSpeedOk;
private bool _twoHandedOk;
private bool _takeoverRotOk;
private bool _takeoverSepOk;
private bool _takeoverSpeedOk;
private float _maxDragRot;
private float _maxDragSep;
private float _maxDragPropSpeed;
private float _maxDragHandSpeed;
private float _maxSpinRot;
private float _maxSpinSep;
private float _maxSpinPropSpeed;
private float _maxSpinHandSpeed;
private float _maxTwoAnchor1;
private float _maxTugAnchor2;
private float _maxFollowAnchor2;
private float _maxTwoPropSpeed;
private float _maxTwoHandSpeed;
private float _maxTwoHand2Speed;
private float _maxTwoSpin;
private float _maxTakeoverRot;
private float _maxTakeoverSep;
private float _maxTakeoverPropSpeed;
private float _maxTakeoverHandSpeed;
private int _lastLogStep;
public override void _Ready()
{
// Legacy chase bodies (PhysicsHands/Torso/Arms) were tuned at 90 Hz; the project runs 144 Hz
// since body step 2, so this regression pins their design rate.
Engine.PhysicsTicksPerSecond = 90;
StaticBody3D floor = new() { Position = new Vector3(0, -0.5f, 0) };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } });
AddChild(floor);
_prop = new RigidBody3D { Position = new Vector3(0, 0.15f, 0), Rotation = new Vector3(0, Mathf.Pi / 2f, 0), Mass = 1f };
_prop.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = Vector3.One * 0.3f } });
AddChild(_prop);
_hand = new RigidBody3D
{
Position = new Vector3(0, 0.9f, 0),
Mass = HandMass,
GravityScale = 0f,
ContinuousCd = true,
ContactMonitor = true,
MaxContactsReported = 4,
};
_hand.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = HandRadius } });
_hand.BodyEntered += OnHand1Touched;
AddChild(_hand);
_hand2 = new RigidBody3D
{
Position = new Vector3(0, 0.15f, -1f),
Mass = HandMass,
GravityScale = 0f,
ContinuousCd = true,
ContactMonitor = true,
MaxContactsReported = 4,
};
_hand2.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = HandRadius } });
_hand2.BodyEntered += OnHand2Touched;
AddChild(_hand2);
_target = new Node3D { Position = _prop.Position };
AddChild(_target);
_target2 = new Node3D { Position = _hand2.Position };
AddChild(_target2);
GD.Print("GRAB: grab spike scene ready");
}
private void OnHand1Touched(Node body)
{
if (body != _prop || _phase != Phase.Reach)
return;
_grabPoint = _hand.GlobalPosition;
_relRotation1 = _hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_propAnchor = _prop.GlobalTransform.AffineInverse() * _grabPoint;
_handAnchor = _hand.GlobalTransform.AffineInverse() * _grabPoint;
_joint = MakeWeld(_hand, _prop, _grabPoint, RigidWeldBias);
_maxDragRot = 0f;
_maxDragSep = 0f;
_maxDragPropSpeed = 0f;
_maxDragHandSpeed = 0f;
_phase = Phase.Drag;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: rigid weld grab at t={_elapsed:F2} s, pivot {_grabPoint}, server joint type = {PhysicsServer3D.JointGetType(_joint.GetRid())} (expect {PhysicsServer3D.JointType.ConeTwist})");
}
private void OnHand2Touched(Node body)
{
if (body != _prop || _phase != Phase.Hand2Reach)
return;
_grab2Point = _hand2.GlobalPosition;
_relRotation2 = _hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_propAnchor2 = _prop.GlobalTransform.AffineInverse() * _grab2Point;
_handAnchor2 = _hand2.GlobalTransform.AffineInverse() * _grab2Point;
_joint2 = MakeWeld(_hand2, _prop, _grab2Point, RigidWeldBias);
_maxTwoAnchor1 = 0f;
_maxTugAnchor2 = 0f;
_maxFollowAnchor2 = 0f;
_maxTwoPropSpeed = 0f;
_maxTwoHandSpeed = 0f;
_maxTwoHand2Speed = 0f;
_maxTwoSpin = 0f;
_phase = Phase.TwoHanded;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: second hand rigid weld at t={_elapsed:F2} s, pivot {_grab2Point}");
}
public override void _PhysicsProcess(double delta)
{
float dt = (float)delta;
_elapsed += dt;
float phaseT = _elapsed - _phaseStart;
switch (_phase)
{
case Phase.Reach:
_target.GlobalPosition = _prop.GlobalPosition;
_target2.GlobalPosition = _hand2.GlobalPosition;
if (phaseT > ReachTimeout)
Fail("hand never touched the prop");
break;
case Phase.Drag:
{
_target.GlobalPosition = _grabPoint + new Vector3(DragOffset(phaseT), LiftHeight * Mathf.Min(phaseT / DragRampTime, 1f), 0f);
_maxDragPropSpeed = Mathf.Max(_maxDragPropSpeed, _prop.LinearVelocity.Length());
_maxDragHandSpeed = Mathf.Max(_maxDragHandSpeed, _hand.LinearVelocity.Length());
TrackRigidWeld(ref _maxDragRot, ref _maxDragSep);
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep && phaseT < MoveDuration)
{
_lastLogStep = step;
GD.Print($"GRAB: drag t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev1()):F2} deg sep={Sep1():F4} m propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= MoveDuration)
FinishDrag();
break;
}
case Phase.Spin:
{
_cmd1 = _spinFrom * new Quaternion(Vector3.Up,
Mathf.DegToRad(SpinAngleDeg) * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / SpinDuration, 1f)));
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
_maxSpinPropSpeed = Mathf.Max(_maxSpinPropSpeed, _prop.LinearVelocity.Length());
_maxSpinHandSpeed = Mathf.Max(_maxSpinHandSpeed, _hand.LinearVelocity.Length());
TrackRigidWeld(ref _maxSpinRot, ref _maxSpinSep);
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: fast spin t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev1()):F2} deg sep={Sep1():F4} m handW={_hand.AngularVelocity.Length():F1} rad/s");
}
if (phaseT >= SpinDuration + SpinHoldDuration)
FinishSpin();
break;
}
case Phase.Hand2Reach:
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalPosition;
if (phaseT > ReachTimeout)
Fail("second hand never touched the prop");
break;
case Phase.TwoHanded:
{
float pull = TugPull * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / 1f, 1f));
float zSway = 0.35f * Mathf.Sin(2f * phaseT) * (1f - Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / TugDuration, 1f)));
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + pull, LiftHeight, zSway);
_target2.GlobalPosition = _grab2Point + new Vector3(-pull, 0f, -zSway);
_maxTwoAnchor1 = Mathf.Max(_maxTwoAnchor1, Sep1());
_maxTugAnchor2 = Mathf.Max(_maxTugAnchor2, Sep2());
_maxTwoPropSpeed = Mathf.Max(_maxTwoPropSpeed, _prop.LinearVelocity.Length());
_maxTwoHandSpeed = Mathf.Max(_maxTwoHandSpeed, _hand.LinearVelocity.Length());
_maxTwoHand2Speed = Mathf.Max(_maxTwoHand2Speed, _hand2.LinearVelocity.Length());
_maxTwoSpin = Mathf.Max(_maxTwoSpin, _prop.AngularVelocity.Length());
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: two-handed tug t={phaseT:F2} anchor1={Sep1():F3} anchor2={Sep2():F3} spin={_prop.AngularVelocity.Length():F1} propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= TugDuration)
{
_phase = Phase.TwoSettle;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print("GRAB: tug done, settling before the follow assert");
}
break;
}
case Phase.TwoSettle:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + TugPull, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalTransform * _propAnchor2;
if (phaseT >= SettleDuration)
{
_phase = Phase.TwoFollow;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: settled at anchor2={Sep2():F3} m, second hand now follows with a small out-of-phase sway");
}
break;
}
case Phase.TwoFollow:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + TugPull, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalTransform * _propAnchor2 + new Vector3(0f, 0f, -FollowSway * Mathf.Sin(2f * Mathf.Pi * 1.5f * phaseT));
_maxTwoAnchor1 = Mathf.Max(_maxTwoAnchor1, Sep1());
_maxFollowAnchor2 = Mathf.Max(_maxFollowAnchor2, Sep2());
_maxTwoPropSpeed = Mathf.Max(_maxTwoPropSpeed, _prop.LinearVelocity.Length());
_maxTwoHandSpeed = Mathf.Max(_maxTwoHandSpeed, _hand.LinearVelocity.Length());
_maxTwoHand2Speed = Mathf.Max(_maxTwoHand2Speed, _hand2.LinearVelocity.Length());
_maxTwoSpin = Mathf.Max(_maxTwoSpin, _prop.AngularVelocity.Length());
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: two-handed follow t={phaseT:F2} anchor1={Sep1():F3} anchor2={Sep2():F3} propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= FollowDuration)
FinishTwoHanded();
break;
}
case Phase.Takeover:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
float move = PullDistance * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / TakeoverDuration, 1f));
_target2.GlobalPosition = _takeoverBase + new Vector3(-move, 0f, 0f);
_maxTakeoverPropSpeed = Mathf.Max(_maxTakeoverPropSpeed, _prop.LinearVelocity.Length());
_maxTakeoverHandSpeed = Mathf.Max(_maxTakeoverHandSpeed, _hand2.LinearVelocity.Length());
TrackTakeoverWeld();
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: takeover t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev2()):F2} deg sep={Sep2():F4} m propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= TakeoverDuration)
Finish();
break;
}
}
Chase(_hand, _target, dt, _phase == Phase.Reach || _phase == Phase.Hand2Reach ? ReachSpeed : HandMaxSpeed);
Chase(_hand2, _target2, dt, _phase == Phase.Reach || _phase == Phase.Hand2Reach ? ReachSpeed : HandMaxSpeed);
SteerHand(_hand, _cmd1, dt);
SteerHand(_hand2, Quaternion.Identity, dt);
}
private ConeTwistJoint3D MakeWeld(RigidBody3D hand, RigidBody3D prop, Vector3 pivot, float bias)
{
ConeTwistJoint3D weld = new()
{
ExcludeNodesFromCollision = true,
Position = pivot,
NodeA = hand.GetPath(),
NodeB = prop.GetPath(),
SwingSpan = 0f,
TwistSpan = 0f,
Bias = bias,
};
AddChild(weld);
return weld;
}
private void TrackRigidWeld(ref float maxRot, ref float maxSep)
{
maxRot = Mathf.Max(maxRot, RotDev1());
maxSep = Mathf.Max(maxSep, Sep1());
}
private void TrackTakeoverWeld()
{
Quaternion relNow = _hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_maxTakeoverRot = Mathf.Max(_maxTakeoverRot, DevAngle(relNow, _relRotation2));
_maxTakeoverSep = Mathf.Max(_maxTakeoverSep, Sep2());
}
private float RotDev1() => DevAngle(
_hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion(),
_relRotation1);
private float RotDev2() => DevAngle(
_hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion(),
_relRotation2);
private float Sep1() =>
(_prop.GlobalTransform * _propAnchor).DistanceTo(_hand.GlobalTransform * _handAnchor);
private float Sep2() =>
(_prop.GlobalTransform * _propAnchor2).DistanceTo(_hand2.GlobalTransform * _handAnchor2);
private static float DevAngle(Quaternion relNow, Quaternion rel0)
{
Quaternion dev = relNow * rel0.Inverse();
if (dev.W < 0f)
dev = new Quaternion(-dev.X, -dev.Y, -dev.Z, -dev.W);
// GetAngle() NaNs when w exceeds 1 by a float epsilon; clamp like WeldSpike does
if (float.IsNaN(dev.W))
return float.NaN;
return 2f * Mathf.Acos(Mathf.Clamp(Mathf.Abs(dev.W), 0f, 1f));
}
private static float DragOffset(float t)
{
float cruiseSpeed = MoveDistance / (MoveDuration - DragRampTime);
if (t <= DragRampTime)
return 0.5f * cruiseSpeed / DragRampTime * t * t;
if (t <= MoveDuration - DragRampTime)
return 0.5f * cruiseSpeed * DragRampTime + cruiseSpeed * (t - DragRampTime);
float remaining = MoveDuration - t;
return MoveDistance - 0.5f * cruiseSpeed / DragRampTime * remaining * remaining;
}
private static void SteerHand(RigidBody3D hand, Quaternion cmd, float dt)
{
Quaternion q = cmd * hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse();
Vector3 spin = q.GetAxis() * (q.GetAngle() / dt);
if (spin.Length() > HandMaxSpin)
spin = spin.Normalized() * HandMaxSpin;
hand.AngularVelocity = spin;
}
private static void Chase(RigidBody3D body, Node3D target, float dt, float maxSpeed)
{
Vector3 to = target.GlobalPosition - body.GlobalPosition;
Vector3 desired = to / dt;
if (desired.Length() > maxSpeed)
desired = desired.Normalized() * maxSpeed;
Vector3 dv = desired - body.LinearVelocity;
Vector3 force = dv / dt * HandMass;
if (force.Length() > HandMaxForce)
force = force.Normalized() * HandMaxForce;
body.LinearVelocity += force / HandMass * dt;
}
private void FinishDrag()
{
_dragRotOk = _maxDragRot < Mathf.DegToRad(RigidRotLimitDeg) && !float.IsNaN(_maxDragRot);
_dragSepOk = _maxDragSep < RigidSepLimit;
_dragSpeedOk = _maxDragPropSpeed < MaxSpeed && _maxDragHandSpeed < MaxSpeed;
GD.Print($"GRAB: drag rigid — rot dev max {Mathf.RadToDeg(_maxDragRot):F2} deg (limit {RigidRotLimitDeg}), sep max {_maxDragSep:F4} m (limit {RigidSepLimit}) → {(_dragRotOk && _dragSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: drag speeds — prop max {_maxDragPropSpeed:F2} m/s, hand max {_maxDragHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_dragSpeedOk ? "PASS" : "FAIL")}");
_spinFrom = _hand.GlobalTransform.Basis.GetRotationQuaternion();
_spinTo = _spinFrom * new Quaternion(Vector3.Up, Mathf.DegToRad(SpinAngleDeg));
_maxSpinRot = 0f;
_maxSpinSep = 0f;
_maxSpinPropSpeed = 0f;
_maxSpinHandSpeed = 0f;
_phase = Phase.Spin;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: fast spin starting — {SpinAngleDeg:F0} deg in {SpinDuration:0.#} s around world up"); }
private void FinishSpin()
{
_spinRotOk = _maxSpinRot < Mathf.DegToRad(SpinRotLimitDeg) && !float.IsNaN(_maxSpinRot);
_spinSepOk = _maxSpinSep < RigidSepLimit;
_spinSpeedOk = _maxSpinPropSpeed < MaxSpeed && _maxSpinHandSpeed < MaxSpeed;
GD.Print($"GRAB: fast spin rigid — rot dev max {Mathf.RadToDeg(_maxSpinRot):F2} deg (limit {SpinRotLimitDeg}), sep max {_maxSpinSep:F4} m (limit {RigidSepLimit}) → {(_spinRotOk && _spinSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: fast spin speeds — prop max {_maxSpinPropSpeed:F2} m/s, hand max {_maxSpinHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_spinSpeedOk ? "PASS" : "FAIL")}");
_hand.AngularVelocity = Vector3.Zero;
_hand2.GlobalTransform = new Transform3D(Basis.Identity, _prop.GlobalPosition + new Vector3(0.45f, 0f, 0f));
_hand2.LinearVelocity = Vector3.Zero;
_hand2.AngularVelocity = Vector3.Zero;
_phase = Phase.Hand2Reach;
_phaseStart = _elapsed;
_lastLogStep = 0;
}
private void FinishTwoHanded()
{
bool primaryRigidOk = _maxTwoAnchor1 < RigidSepLimit;
bool tugRigidOk = _maxTugAnchor2 < RigidSepLimit;
bool followOk = _maxFollowAnchor2 < RigidSepLimit;
bool spinOk = _maxTwoSpin < SpinLimit;
bool speedOk = _maxTwoPropSpeed < MaxSpeed && _maxTwoHandSpeed < MaxSpeed && _maxTwoHand2Speed < MaxSpeed;
GD.Print($"GRAB: two-handed primary rigid — anchor1 max {_maxTwoAnchor1:F4} m (limit {RigidSepLimit}) → {(primaryRigidOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed tug rigid — anchor2 max {_maxTugAnchor2:F4} m (limit {RigidSepLimit}) → {(tugRigidOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed follow — anchor2 max {_maxFollowAnchor2:F4} m (limit {RigidSepLimit}) → {(followOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed spin — prop max {_maxTwoSpin:F1} rad/s (limit {SpinLimit}) → {(spinOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed speed — prop max {_maxTwoPropSpeed:F2} m/s, hands max {Mathf.Max(_maxTwoHandSpeed, _maxTwoHand2Speed):F2} m/s (limit {MaxSpeed}) → {(speedOk ? "PASS" : "FAIL")}");
_twoHandedOk = primaryRigidOk && tugRigidOk && followOk && spinOk && speedOk;
_joint.QueueFree();
_joint = null;
_takeoverBase = _target2.GlobalPosition;
_maxTakeoverRot = 0f;
_maxTakeoverSep = 0f;
_maxTakeoverPropSpeed = 0f;
_maxTakeoverHandSpeed = 0f;
_phase = Phase.Takeover;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: primary released, surviving rigid weld untouched (pivot {_grab2Point})");
}
private void Finish()
{
_takeoverRotOk = _maxTakeoverRot < Mathf.DegToRad(TakeoverRotLimitDeg) && !float.IsNaN(_maxTakeoverRot);
_takeoverSepOk = _maxTakeoverSep < RigidSepLimit;
_takeoverSpeedOk = _maxTakeoverPropSpeed < MaxSpeed && _maxTakeoverHandSpeed < MaxSpeed;
GD.Print($"GRAB: takeover rigid — rot dev max {Mathf.RadToDeg(_maxTakeoverRot):F2} deg (limit {TakeoverRotLimitDeg}), sep max {_maxTakeoverSep:F4} m (limit {RigidSepLimit}) → {(_takeoverRotOk && _takeoverSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: takeover speeds — prop max {_maxTakeoverPropSpeed:F2} m/s, hand2 max {_maxTakeoverHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_takeoverSpeedOk ? "PASS" : "FAIL")}");
bool overall = _dragRotOk && _dragSepOk && _dragSpeedOk &&
_spinRotOk && _spinSepOk && _spinSpeedOk &&
_twoHandedOk &&
_takeoverRotOk && _takeoverSepOk && _takeoverSpeedOk;
GD.Print($"GRAB: overall {(overall ? "PASS — solver-owned rigid weld grab (v5.3) functional on Box3D" : "CHECK — behavior differs, inspect above")}");
GetTree().Quit();
}
private void Fail(string reason)
{
GD.Print($"GRAB: overall CHECK — {reason}");
GetTree().Quit();
}
}