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 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(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; } } }