Body step 1: one-time T-pose calibration (prefs.json, forced gate) + True Body solver + shared ArmSolver

- 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>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 05:47:20 +03:00
1 parent 3a990c6dbe
commit 81a9b1fca2
15 files changed
+994 -106

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
using System;
using System.IO;
using System.Text.Json;
using Godot;
// The player's measured body (True Body metrics), from the one-time T-pose calibration.
// All heights are above the tracking floor; lengths in metres. Ratios: Winter, Biomechanics and
// Motor Control of Human Movement, Fig 4.1 (Drillis & Contini) — see research/nimso-project-tx.md §3.
public sealed class BodyProfile
{
public const float EyeToStature = 0.936f;
public const float ShoulderWidthRatio = 0.259f; // biacromial, joint to joint
public const float ShoulderHeightRatio = 0.818f;
public const float UpperArmRatio = 0.186f;
public const float ForearmRatio = 0.146f;
public const float ArmSafetyMargin = 0.015f; // store arms slightly short: the real elbow must never lock first
public int Version { get; set; } = 1;
public float EyeHeight { get; set; }
public float Stature { get; set; }
public float ShoulderHalfWidth { get; set; }
public float ShoulderDrop { get; set; } // eye height − shoulder-joint height
public float UpperArm { get; set; }
public float Forearm { get; set; }
public float MeasuredSpan { get; set; } // wrist-to-wrist in the T-pose (0 = estimated, not measured)
public string Date { get; set; } = "";
public float ArmLength => UpperArm + Forearm;
// Current profile for the session; null until calibrated (main scene) or estimated (headless spikes).
public static BodyProfile Current { get; set; }
public static event Action<BodyProfile> Changed;
public static void Set(BodyProfile profile)
{
Current = profile;
Changed?.Invoke(profile);
}
// Anthropometric estimate from eye height alone — used by headless spikes and as a fallback.
public static BodyProfile Estimate(float eyeHeight)
{
float stature = eyeHeight / EyeToStature;
float arm = (UpperArmRatio + ForearmRatio) * stature - ArmSafetyMargin;
return FromMeasurements(eyeHeight, stature, ShoulderWidthRatio * stature * 0.5f,
eyeHeight - ShoulderHeightRatio * stature, arm, 0f);
}
// From a T-pose: wrist span = shoulder width + 2 arms. Shoulder width comes from the stature
// ratio — a few cm of error there only moves where the arm starts, not the total reach.
public static BodyProfile FromTPose(float eyeHeight, float wristSpan, float wristHeight)
{
float stature = eyeHeight / EyeToStature;
float halfWidth = ShoulderWidthRatio * stature * 0.5f;
float arm = (wristSpan - 2f * halfWidth) * 0.5f - ArmSafetyMargin;
return FromMeasurements(eyeHeight, stature, halfWidth, eyeHeight - wristHeight, arm, wristSpan);
}
private static BodyProfile FromMeasurements(float eye, float stature, float halfWidth, float drop, float arm, float span)
{
float split = UpperArmRatio / (UpperArmRatio + ForearmRatio);
return new BodyProfile
{
EyeHeight = eye,
Stature = stature,
ShoulderHalfWidth = halfWidth,
ShoulderDrop = drop,
UpperArm = arm * split,
Forearm = arm * (1f - split),
MeasuredSpan = span,
Date = DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
};
}
public bool IsPlausible() =>
EyeHeight is > 1.0f and < 2.3f && ArmLength is > 0.35f and < 0.9f
&& ShoulderHalfWidth is > 0.12f and < 0.32f && ShoulderDrop is > 0.05f and < 0.5f;
public override string ToString() =>
$"eye {EyeHeight:F3} m, stature {Stature:F2} m, arm {ArmLength:F3} m (upper {UpperArm:F3} / fore {Forearm:F3}), " +
$"shoulder half-width {ShoulderHalfWidth:F3} m, shoulder drop {ShoulderDrop:F3} m";
}
// prefs.json next to the executable (exported builds) or in the project folder (editor builds,
// incl. headless runs) — portable: copy the file to carry your calibration. Gitignored.
public static class Prefs
{
private sealed class Document
{
public BodyProfile Body { get; set; }
}
private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
public static string FilePath =>
OS.HasFeature("editor")
? ProjectSettings.GlobalizePath("res://prefs.json")
: Path.Combine(Path.GetDirectoryName(OS.GetExecutablePath()) ?? ".", "prefs.json");
public static BodyProfile LoadBody(string path = null)
{
path ??= FilePath;
try
{
if (!File.Exists(path))
return null;
Document doc = JsonSerializer.Deserialize<Document>(File.ReadAllText(path), Options);
return doc?.Body != null && doc.Body.IsPlausible() ? doc.Body : null;
}
catch (Exception e)
{
GD.PushWarning($"Prefs: could not read {path}: {e.Message}");
return null;
}
}
public static bool SaveBody(BodyProfile body, string path = null)
{
path ??= FilePath;
try
{
File.WriteAllText(path, JsonSerializer.Serialize(new Document { Body = body }, Options));
return true;
}
catch (Exception e)
{
GD.PushWarning($"Prefs: could not write {path}: {e.Message}");
return false;
}
}
}