Files
VR-Mess/scripts/BodyProfile.cs
T
FoxyzandClaude Opus 5.5 81a9b1fca2 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>
2026-10-02 05:47:20 +03:00

133 lines
5.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}