- 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>
205 lines
12 KiB
C#
205 lines
12 KiB
C#
using System.Collections.Generic;
|
||
using System.IO;
|
||
using Godot;
|
||
|
||
// Headless regression for body step 1: T-pose calibration (TPoseDetector), prefs.json persistence,
|
||
// and the TrueBodySolver pose. No physics — runs synchronously and quits.
|
||
// godot-mono --headless --path . scenes/truebody_spike.tscn
|
||
public partial class TrueBodySpike : Node
|
||
{
|
||
private const float Dt = 1f / 90f;
|
||
private const float Stature = 1.80f;
|
||
private static float Eye => BodyProfile.EyeToStature * Stature;
|
||
private static float ShoulderY => BodyProfile.ShoulderHeightRatio * Stature;
|
||
private static float HalfWidth => BodyProfile.ShoulderWidthRatio * Stature * 0.5f;
|
||
private static float RealArm => (BodyProfile.UpperArmRatio + BodyProfile.ForearmRatio) * Stature;
|
||
|
||
private readonly List<(string Name, bool Ok)> _results = new();
|
||
private readonly RandomNumberGenerator _rng = new() { Seed = 7 };
|
||
|
||
public override void _Ready()
|
||
{
|
||
BodyProfile measured = TestDetector();
|
||
TestPersistence(measured);
|
||
TestSolver(measured ?? BodyProfile.Estimate(Eye));
|
||
bool all = true;
|
||
foreach (var (name, ok) in _results)
|
||
{
|
||
GD.Print($"TRUEBODY: {name} → {(ok ? "PASS" : "FAIL")}");
|
||
all &= ok;
|
||
}
|
||
GD.Print($"TRUEBODY: overall {(all ? "PASS — calibration + True Body functional" : "CHECK — inspect failures above")}");
|
||
GetTree().Quit();
|
||
}
|
||
|
||
private void Record(string name, bool ok) => _results.Add((name, ok));
|
||
|
||
private Vector3 Noise(float amp) => new(_rng.RandfRange(-amp, amp), _rng.RandfRange(-amp, amp), _rng.RandfRange(-amp, amp));
|
||
|
||
// ---- calibration ----
|
||
private BodyProfile RunPose(float seconds, System.Func<float, (Vector3 head, Vector3 fwd, Vector3 l, Vector3 r)> pose, TPoseDetector d)
|
||
{
|
||
for (float t = 0f; t < seconds; t += Dt)
|
||
{
|
||
var (head, fwd, l, r) = pose(t);
|
||
BodyProfile p = d.Feed(head, fwd, l, r, Dt);
|
||
if (p != null)
|
||
return p;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private (Vector3, Vector3, Vector3, Vector3) TPose(float t, float leftDrop = 0f, float wobble = 0f, bool armsForward = false, float spanScale = 1f)
|
||
{
|
||
float reach = (HalfWidth + RealArm) * spanScale;
|
||
Vector3 head = new Vector3(0f, Eye, 0f) + Noise(0.0005f);
|
||
Vector3 l = armsForward ? new Vector3(-HalfWidth, ShoulderY, -RealArm) : new Vector3(-reach, ShoulderY - leftDrop, 0f);
|
||
Vector3 r = armsForward ? new Vector3(HalfWidth, ShoulderY, -RealArm) : new Vector3(reach, ShoulderY, 0f);
|
||
Vector3 w = new(0f, 0.05f * Mathf.Sin(t * Mathf.Tau * 2f) * wobble, 0f);
|
||
return (head, Vector3.Forward, l + w + Noise(0.0005f), r - w + Noise(0.0005f));
|
||
}
|
||
|
||
private BodyProfile TestDetector()
|
||
{
|
||
var d = new TPoseDetector();
|
||
BodyProfile p = RunPose(1.6f, t => TPose(t), d);
|
||
float expectArm = RealArm - BodyProfile.ArmSafetyMargin;
|
||
bool ok = p != null && Mathf.Abs(p.EyeHeight - Eye) < 0.003f && Mathf.Abs(p.ArmLength - expectArm) < 0.005f
|
||
&& Mathf.Abs(p.ShoulderDrop - (Eye - ShoulderY)) < 0.005f && Mathf.Abs(p.Stature - Stature) < 0.005f;
|
||
Record($"T-pose measured: {(p != null ? p.ToString() : "none")} (expected arm {expectArm:F3}, drop {Eye - ShoulderY:F3})", ok);
|
||
|
||
d.Reset();
|
||
Record($"uneven arms rejected ({(RunPose(3f, t => TPose(t, leftDrop: 0.15f), d) == null ? d.Problem : "accepted")})",
|
||
RunPose(0.01f, t => TPose(t, leftDrop: 0.15f), d) == null && d.Problem == "level your arms");
|
||
d.Reset();
|
||
Record($"moving arms rejected ({(RunPose(3f, t => TPose(t, wobble: 1f), d) == null ? d.Problem : "accepted")})",
|
||
RunPose(0.5f, t => TPose(t, wobble: 1f), d) == null);
|
||
d.Reset();
|
||
Record("arms-forward pose rejected", RunPose(3f, t => TPose(t, armsForward: true), d) == null);
|
||
d.Reset();
|
||
Record("implausible span rejected", RunPose(3f, t => TPose(t, spanScale: 1.6f), d) == null);
|
||
return p;
|
||
}
|
||
|
||
private void TestPersistence(BodyProfile p)
|
||
{
|
||
if (p == null)
|
||
{
|
||
Record("persistence (no profile to save)", false);
|
||
return;
|
||
}
|
||
string path = Path.Combine(OS.GetUserDataDir(), "truebody_spike_prefs.json");
|
||
bool saved = Prefs.SaveBody(p, path);
|
||
BodyProfile back = Prefs.LoadBody(path);
|
||
bool same = back != null && Mathf.IsEqualApprox(back.ArmLength, p.ArmLength) && Mathf.IsEqualApprox(back.EyeHeight, p.EyeHeight)
|
||
&& Mathf.IsEqualApprox(back.ShoulderHalfWidth, p.ShoulderHalfWidth) && Mathf.IsEqualApprox(back.ShoulderDrop, p.ShoulderDrop);
|
||
Record($"prefs round-trip ({path})", saved && same);
|
||
File.WriteAllText(path, "{ not json");
|
||
Record("corrupt prefs rejected", Prefs.LoadBody(path) == null);
|
||
File.Delete(path);
|
||
}
|
||
|
||
// ---- solver ----
|
||
private static Transform3D Ctrl(Vector3 wrist, Basis basis) => new(basis, wrist - basis * new Vector3(0f, 0f, TrueBodySolver.WristOffset));
|
||
|
||
private void Settle(TrueBodySolver s, BodyProfile p, Transform3D head, Transform3D l, Transform3D r, float seconds = 1.5f)
|
||
{
|
||
for (float t = 0f; t < seconds; t += Dt)
|
||
s.Solve(p, head, l, r, 0f, Dt);
|
||
}
|
||
|
||
private void TestSolver(BodyProfile p)
|
||
{
|
||
var s = new TrueBodySolver();
|
||
Transform3D head = new(Basis.Identity, new Vector3(0f, p.EyeHeight, 0f));
|
||
Basis down = new(Vector3.Right, -Mathf.Pi / 2f); // controller pointing at the floor (hands hanging)
|
||
float shoulderY = p.EyeHeight - p.ShoulderDrop;
|
||
|
||
// Neutral: hands hanging at the sides.
|
||
Vector3 hangL = new(-p.ShoulderHalfWidth, shoulderY - p.ArmLength * 0.95f, 0.06f);
|
||
Vector3 hangR = new(p.ShoulderHalfWidth, shoulderY - p.ArmLength * 0.95f, 0.06f);
|
||
Settle(s, p, head, Ctrl(hangL, down), Ctrl(hangR, down));
|
||
Vector3 expectR = new(p.ShoulderHalfWidth, shoulderY, TrueBodySolver.ShouldersBehindEyes);
|
||
float shoulderErr = Mathf.Max(s.Shoulder[1].DistanceTo(expectR), s.Shoulder[0].DistanceTo(new Vector3(-expectR.X, expectR.Y, expectR.Z)));
|
||
float pelvisExpectY = shoulderY - TrueBodySolver.TrunkRatio * p.Stature;
|
||
Record($"neutral: shoulder joints within {shoulderErr * 100f:F1} cm of anthropometric (limit 2), pelvis y {s.Pelvis.Y:F3} vs {pelvisExpectY:F3}",
|
||
shoulderErr < 0.02f && Mathf.Abs(s.Pelvis.Y - pelvisExpectY) < 0.03f);
|
||
Vector3 neutralShoulderR = s.Shoulder[1];
|
||
|
||
// Reach straight forward 10 cm beyond the arm: the shoulder protracts, within its limit.
|
||
Basis fwd = Basis.Identity;
|
||
Vector3 farR = new(p.ShoulderHalfWidth, shoulderY, TrueBodySolver.ShouldersBehindEyes - p.ArmLength - 0.10f);
|
||
Settle(s, p, head, Ctrl(hangL, down), Ctrl(farR, fwd));
|
||
float protraction = neutralShoulderR.Z - s.Shoulder[1].Z;
|
||
float wristErr = s.Wrist[1].DistanceTo(farR);
|
||
Record($"far reach: shoulder protracts {protraction * 100f:F1} cm (4–8.5), wrist short of target by {wristErr * 100f:F1} cm (< 5)",
|
||
protraction > 0.04f && protraction < TrueBodySolver.GirdleForward + 0.005f && wristErr < 0.05f);
|
||
|
||
// Arm straight overhead: shrug.
|
||
Vector3 upR = new(p.ShoulderHalfWidth, shoulderY + p.ArmLength * 0.97f, TrueBodySolver.ShouldersBehindEyes);
|
||
Settle(s, p, head, Ctrl(hangL, down), Ctrl(upR, new Basis(Vector3.Right, Mathf.Pi / 2f)));
|
||
float shrug = s.Shoulder[1].Y - neutralShoulderR.Y;
|
||
Record($"overhead: shoulder rises {shrug * 100f:F1} cm (3–7.5)", shrug > 0.03f && shrug < TrueBodySolver.GirdleUp + 0.005f);
|
||
|
||
// Look down 60° while standing: chest barely follows, shoulders stay put.
|
||
// A real head pivots at the neck: rotate about the neck pivot, the eyes travel.
|
||
Basis pitch = new(Vector3.Right, -Mathf.DegToRad(60f));
|
||
Vector3 pivot = head.Origin + TrueBodySolver.EyeToNeckPivot;
|
||
Transform3D lookDown = new(pitch, pivot - pitch * TrueBodySolver.EyeToNeckPivot);
|
||
Settle(s, p, lookDown, Ctrl(hangL, down), Ctrl(hangR, down));
|
||
float chestPitch = Mathf.RadToDeg(s.ChestBasis.Y.AngleTo(Vector3.Up));
|
||
Record($"look down 60°: chest pitch {chestPitch:F1}° (< 20), shoulder height change {(s.Shoulder[1].Y - neutralShoulderR.Y) * 100f:F1} cm (|·| < 6)",
|
||
chestPitch < 20f && Mathf.Abs(s.Shoulder[1].Y - neutralShoulderR.Y) < 0.06f);
|
||
|
||
// Deep crouch: eyes 0.6 m lower → chest leans, hips kick back behind the upper chest.
|
||
Transform3D crouch = new(Basis.Identity, new Vector3(0f, p.EyeHeight - 0.6f, 0f));
|
||
Settle(s, p, crouch, Ctrl(hangL + Vector3.Down * 0.6f, down), Ctrl(hangR + Vector3.Down * 0.6f, down));
|
||
chestPitch = Mathf.RadToDeg(s.ChestBasis.Y.AngleTo(Vector3.Up));
|
||
float hipBack = s.Pelvis.Z - s.UpperChest.Z;
|
||
Record($"crouch: factor {s.Crouch:F2} (> 0.5), chest pitch {chestPitch:F1}° (> 20), hips {hipBack * 100f:F0} cm behind upper chest (> 10)",
|
||
s.Crouch > 0.5f && chestPitch > 20f && hipBack > 0.10f);
|
||
|
||
// Pelvis lag: head snaps 0.3 m sideways → pelvis trails, then settles.
|
||
Settle(s, p, head, Ctrl(hangL, down), Ctrl(hangR, down));
|
||
Transform3D side = new(Basis.Identity, head.Origin + new Vector3(0.3f, 0f, 0f));
|
||
Vector3 before = s.Pelvis;
|
||
s.Solve(p, side, Ctrl(hangL + Vector3.Right * 0.3f, down), Ctrl(hangR + Vector3.Right * 0.3f, down), 0f, Dt);
|
||
float firstStep = s.Pelvis.DistanceTo(before);
|
||
Settle(s, p, side, Ctrl(hangL + Vector3.Right * 0.3f, down), Ctrl(hangR + Vector3.Right * 0.3f, down), 1f);
|
||
float settled = Mathf.Abs(s.Pelvis.X - (s.UpperChest.X));
|
||
Record($"pelvis lag: first tick moves {firstStep * 100f:F1} cm of 30 (< 10), settled offset {settled * 100f:F1} cm (< 1)", firstStep < 0.10f && settled < 0.01f);
|
||
|
||
// Random motion: finite, exact segment lengths, no elbow jumps.
|
||
bool finite = true;
|
||
float maxLenErr = 0f, maxElbowStep = 0f;
|
||
Vector3 lastElbow = s.Elbow[1];
|
||
for (int i = 0; i < 900; i++)
|
||
{
|
||
float t = i * Dt;
|
||
Vector3 hp = new(0.2f * Mathf.Sin(t * 0.7f), p.EyeHeight - 0.3f * (0.5f + 0.5f * Mathf.Sin(t * 0.4f)), 0.15f * Mathf.Sin(t * 0.5f));
|
||
Basis hb = new Basis(Vector3.Up, 0.8f * Mathf.Sin(t * 0.3f)) * new Basis(Vector3.Right, 0.4f * Mathf.Sin(t * 0.9f));
|
||
// Hands roam the anatomically reachable space in front of the body (a wrist can't come
|
||
// within ~15 cm of its own shoulder joint; targets that do make the folded arm whip round).
|
||
Vector3 lw = hp + new Vector3(-0.3f + 0.2f * Mathf.Sin(t * 1.3f), -0.35f + 0.35f * Mathf.Sin(t * 1.1f), -0.4f + 0.15f * Mathf.Cos(t * 0.8f));
|
||
Vector3 rw = hp + new Vector3(0.3f + 0.2f * Mathf.Cos(t * 1.7f), -0.35f + 0.35f * Mathf.Cos(t * 0.9f), -0.4f + 0.15f * Mathf.Sin(t * 1.2f));
|
||
Basis lb = new Basis(new Vector3(0.3f, 1f, 0.2f).Normalized(), t * 2f);
|
||
Basis rb = new Basis(new Vector3(-0.2f, 0.4f, 1f).Normalized(), t * 1.5f);
|
||
s.Solve(p, new Transform3D(hb, hp), Ctrl(lw, lb), Ctrl(rw, rb), 0f, Dt);
|
||
for (int a = 0; a < 2; a++)
|
||
{
|
||
finite &= s.Shoulder[a].IsFinite() && s.Elbow[a].IsFinite() && s.Wrist[a].IsFinite() && s.Pelvis.IsFinite();
|
||
maxLenErr = Mathf.Max(maxLenErr, Mathf.Abs(s.Shoulder[a].DistanceTo(s.Elbow[a]) - p.UpperArm));
|
||
maxLenErr = Mathf.Max(maxLenErr, Mathf.Abs(s.Elbow[a].DistanceTo(s.Wrist[a]) - p.Forearm));
|
||
}
|
||
if (i > 0)
|
||
{
|
||
float stepNow = s.Elbow[1].DistanceTo(lastElbow);
|
||
maxElbowStep = Mathf.Max(maxElbowStep, stepNow);
|
||
}
|
||
lastElbow = s.Elbow[1];
|
||
}
|
||
Record($"random motion: finite {finite}, segment length error max {maxLenErr * 1000f:F2} mm (< 1), elbow step max {maxElbowStep * 100f:F1} cm/tick (< 6)",
|
||
finite && maxLenErr < 0.001f && maxElbowStep < 0.06f);
|
||
}
|
||
}
|