- BodyProfile (Winter anthropometry, T-pose: arm = (span - shoulder width)/2 - 1.5 cm) and Prefs (gitignored prefs.json next to the executable / project folder in editor builds) - Calibration node: loads prefs, else head-locked prompt + jitter-proof TPoseDetector; body systems wait for BodyProfile.Current; Recalibrate() in group "calibration"; recal rebuilds arms - ArmSolver: PhysicsArms' elbow solve extracted (behaviour-identical) + min reach |U-F| fix - TrueBody: derived real-body pose (neck, chest yaw + crouch lean at neck pivot, lagging pelvis, moving shoulder girdle with scapulohumeral shrug, arms) + debug skeleton - PhysicsArms/PhysicsTorso/BodyRig now profile-driven (arm length no longer from head height) - TrueBodySpike PASS x3; all spikes PASS x3 (arm spike thresholds re-based for correct 0.57 m arms; CollapseSpike uses a fixed standing profile) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
6.8 KiB
C#
193 lines
6.8 KiB
C#
using Godot;
|
|
|
|
// Detects a held T-pose from head + wrist positions in XR-origin space (floor at y = 0) and
|
|
// measures it. Pure logic so headless spikes can feed it synthetic poses.
|
|
public sealed class TPoseDetector
|
|
{
|
|
public const float HoldTime = 1.0f;
|
|
public const float StillRadius = 0.03f; // every point must stay within this of where the hold began (jitter-proof)
|
|
private const float LevelTolerance = 0.08f;
|
|
private const float SymmetryTolerance = 0.10f;
|
|
private const float MaxLineVsForward = 0.35f;
|
|
private const float MinSpan = 1.0f;
|
|
private const float MaxSpan = 2.4f;
|
|
private const float MinDropBelowEye = 0.05f;
|
|
private const float MaxDropBelowEye = 0.5f;
|
|
|
|
private Vector3 _anchorHead, _anchorLeft, _anchorRight;
|
|
private float _held;
|
|
private float _sumEye, _sumSpan, _sumWristY;
|
|
private int _samples;
|
|
|
|
public string Problem { get; private set; } = "";
|
|
public float Progress => Mathf.Clamp(_held / HoldTime, 0f, 1f);
|
|
|
|
public void Reset()
|
|
{
|
|
_held = 0f;
|
|
_sumEye = _sumSpan = _sumWristY = 0f;
|
|
_samples = 0;
|
|
Problem = "";
|
|
}
|
|
|
|
// Returns a profile once the pose has been valid and still for HoldTime.
|
|
public BodyProfile Feed(Vector3 head, Vector3 headForward, Vector3 wristL, Vector3 wristR, float dt)
|
|
{
|
|
Problem = Validate(head, headForward, wristL, wristR);
|
|
bool still = true;
|
|
if (Problem.Length == 0 && _samples > 0)
|
|
still = head.DistanceTo(_anchorHead) < StillRadius && wristL.DistanceTo(_anchorLeft) < StillRadius
|
|
&& wristR.DistanceTo(_anchorRight) < StillRadius;
|
|
if (Problem.Length == 0 && !still)
|
|
Problem = "hold still";
|
|
if (Problem.Length > 0)
|
|
{
|
|
_held = 0f;
|
|
_sumEye = _sumSpan = _sumWristY = 0f;
|
|
_samples = 0;
|
|
return null;
|
|
}
|
|
|
|
if (_samples == 0)
|
|
{
|
|
_anchorHead = head;
|
|
_anchorLeft = wristL;
|
|
_anchorRight = wristR;
|
|
}
|
|
_held += dt;
|
|
_sumEye += head.Y;
|
|
_sumSpan += wristL.DistanceTo(wristR);
|
|
_sumWristY += (wristL.Y + wristR.Y) * 0.5f;
|
|
_samples++;
|
|
if (_held < HoldTime)
|
|
return null;
|
|
|
|
BodyProfile profile = BodyProfile.FromTPose(_sumEye / _samples, _sumSpan / _samples, _sumWristY / _samples);
|
|
if (!profile.IsPlausible())
|
|
{
|
|
Problem = "measurement implausible — try again";
|
|
Reset();
|
|
return null;
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
private static string Validate(Vector3 head, Vector3 headForward, Vector3 wristL, Vector3 wristR)
|
|
{
|
|
if (Mathf.Abs(wristL.Y - wristR.Y) > LevelTolerance)
|
|
return "level your arms";
|
|
float drop = head.Y - (wristL.Y + wristR.Y) * 0.5f;
|
|
if (drop < MinDropBelowEye || drop > MaxDropBelowEye)
|
|
return "arms out at shoulder height";
|
|
float span = wristL.DistanceTo(wristR);
|
|
if (span < MinSpan || span > MaxSpan)
|
|
return "stretch both arms fully out";
|
|
Vector2 headXZ = new(head.X, head.Z);
|
|
float dl = headXZ.DistanceTo(new Vector2(wristL.X, wristL.Z));
|
|
float dr = headXZ.DistanceTo(new Vector2(wristR.X, wristR.Z));
|
|
if (Mathf.Abs(dl - dr) > SymmetryTolerance)
|
|
return "arms out evenly to both sides";
|
|
Vector3 line = wristR - wristL;
|
|
line.Y = 0f;
|
|
Vector3 fwd = new(headForward.X, 0f, headForward.Z);
|
|
if (line.LengthSquared() > 1e-6f && fwd.LengthSquared() > 1e-6f
|
|
&& Mathf.Abs(line.Normalized().Dot(fwd.Normalized())) > MaxLineVsForward)
|
|
return "face forward, arms to the sides";
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// One-time body calibration gate. On launch: load prefs.json; if there is no valid profile the
|
|
// body systems wait (they check BodyProfile.Current) and a prompt asks for a T-pose. Recalibrate()
|
|
// (menu button; group "calibration") repeats it at any time.
|
|
public partial class Calibration : Node
|
|
{
|
|
public const float WristOffset = 0.06f; // wrist sits behind the aim-pose origin along its +Z
|
|
|
|
private XRCamera3D _camera;
|
|
private XRController3D _left;
|
|
private XRController3D _right;
|
|
private Label3D _prompt;
|
|
private readonly TPoseDetector _detector = new();
|
|
private bool _active;
|
|
private float _doneTimer;
|
|
|
|
public override void _Ready()
|
|
{
|
|
AddToGroup("calibration");
|
|
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
|
|
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
|
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
|
_prompt = new Label3D
|
|
{
|
|
FontSize = 48,
|
|
PixelSize = 0.0012f,
|
|
OutlineSize = 10,
|
|
NoDepthTest = true,
|
|
Visible = false,
|
|
Position = new Vector3(0f, -0.05f, -1.1f),
|
|
};
|
|
_camera.AddChild(_prompt);
|
|
|
|
BodyProfile saved = Prefs.LoadBody();
|
|
if (saved != null)
|
|
{
|
|
BodyProfile.Set(saved);
|
|
GD.Print($"Calibration: loaded {Prefs.FilePath}: {saved}");
|
|
}
|
|
else
|
|
{
|
|
GD.Print($"Calibration: no valid profile at {Prefs.FilePath} — T-pose calibration required");
|
|
Begin();
|
|
}
|
|
}
|
|
|
|
public void Recalibrate()
|
|
{
|
|
GD.Print("Calibration: recalibration requested");
|
|
Begin();
|
|
}
|
|
|
|
private void Begin()
|
|
{
|
|
_active = true;
|
|
_doneTimer = 0f;
|
|
_detector.Reset();
|
|
_prompt.Visible = true;
|
|
}
|
|
|
|
public override void _PhysicsProcess(double delta)
|
|
{
|
|
float dt = (float)delta;
|
|
if (_doneTimer > 0f)
|
|
{
|
|
_doneTimer -= dt;
|
|
if (_doneTimer <= 0f)
|
|
_prompt.Visible = false;
|
|
}
|
|
if (!_active)
|
|
return;
|
|
|
|
// Origin-local: the floor is y = 0 and world locomotion doesn't matter.
|
|
Vector3 head = _camera.Position;
|
|
Vector3 forward = _camera.Transform.Basis * Vector3.Forward;
|
|
Vector3 wristL = _left.Transform * new Vector3(0f, 0f, WristOffset);
|
|
Vector3 wristR = _right.Transform * new Vector3(0f, 0f, WristOffset);
|
|
BodyProfile result = _detector.Feed(head, forward, wristL, wristR, dt);
|
|
|
|
if (result == null)
|
|
{
|
|
string hint = _detector.Problem.Length > 0 ? _detector.Problem : $"hold… {Mathf.RoundToInt(_detector.Progress * 100f)}%";
|
|
_prompt.Text = $"Body calibration\nStand straight, arms out to the sides (T-pose)\n{hint}";
|
|
return;
|
|
}
|
|
|
|
_active = false;
|
|
BodyProfile.Set(result);
|
|
bool saved = Prefs.SaveBody(result);
|
|
_prompt.Text = $"Calibrated ✓\narm {result.ArmLength * 100f:F1} cm · height {result.Stature * 100f:F0} cm";
|
|
_doneTimer = 2.5f;
|
|
GD.Print($"Calibration: T-pose measured: {result}; saved {(saved ? Prefs.FilePath : "FAILED")}");
|
|
}
|
|
}
|