tests/PosedLocomotion/PosedSkeletonFixture.cs
using System.Globalization;
using System.Text;

namespace HumanoidRetargeter.Tests;

/// <summary>
/// Synthetic mixamo-named BVH walk with EXACTLY known foot-plant phases, shared by the
/// footstep-event and mirrored-variant facade tests.
/// </summary>
/// <remarks>
/// 60 frames at 30 fps, hips fixed at height 95 cm (plus a ±1.5 cm lateral sway — slow
/// enough to stay under the plant-detection speed threshold, but enough to make "pelvis
/// lateral offset negates" assertions non-trivial). The legs step IN PLACE by rotating the
/// hip joints about the lateral axis (a 30° sin-arc swing lifts the ankle ~10.7 cm, far
/// beyond the 6 cm hysteresis exit; between swings the leg holds its rest pose, so the foot
/// is world-stationary = planted):
/// <list type="bullet">
/// <item>LEFT leg: swings frames 0–14, planted 15–59 → exactly one touchdown at frame 15.</item>
/// <item>RIGHT leg: planted 0–29 (already down at clip start — no touchdown), swings 30–44,
/// planted 45–59 → exactly one touchdown at frame 45.</item>
/// </list>
/// </remarks>
internal static class PosedSkeletonFixture
{
    /// <summary>Total frames (30 fps).</summary>
    public const int Frames = 60;

    /// <summary>The left foot's only touchdown frame.</summary>
    public const int LeftTouchdownFrame = 15;

    /// <summary>The right foot's only touchdown frame (its frame-0 plant has no touchdown).</summary>
    public const int RightTouchdownFrame = 45;

    /// <summary>Builds the BVH text (mixamorig names → the shipped mixamo preset detects it).</summary>
    public static string SyntheticWalkBvh()
    {
        var sb = new StringBuilder();
        sb.AppendLine("HIERARCHY");
        sb.AppendLine("ROOT mixamorig:Hips");
        sb.AppendLine("{");
        sb.AppendLine("  OFFSET 0 0 0");
        sb.AppendLine("  CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation");

        void Joint(string name, float x, float y, float z, Action? children = null,
            (float X, float Y, float Z)? end = null)
        {
            sb.AppendLine($"  JOINT mixamorig:{name}");
            sb.AppendLine("  {");
            sb.AppendLine(string.Create(CultureInfo.InvariantCulture, $"    OFFSET {x} {y} {z}"));
            sb.AppendLine("    CHANNELS 3 Zrotation Xrotation Yrotation");
            children?.Invoke();
            if (end is { } e)
            {
                sb.AppendLine("    End Site");
                sb.AppendLine("    {");
                sb.AppendLine(string.Create(CultureInfo.InvariantCulture, $"      OFFSET {e.X} {e.Y} {e.Z}"));
                sb.AppendLine("    }");
            }
            sb.AppendLine("  }");
        }

        // Joint order (matters for the MOTION channel layout below):
        //  1 Spine  2 Spine1  3 Spine2  4 Neck  5 Head
        //  6 LeftShoulder  7 LeftArm  8 LeftForeArm  9 LeftHand
        // 10 RightShoulder 11 RightArm 12 RightForeArm 13 RightHand
        // 14 LeftUpLeg 15 LeftLeg 16 LeftFoot 17 LeftToeBase
        // 18 RightUpLeg 19 RightLeg 20 RightFoot 21 RightToeBase
        Joint("Spine", 0, 10, 0, () =>
        Joint("Spine1", 0, 12, 0, () =>
        Joint("Spine2", 0, 12, 0, () =>
        {
            Joint("Neck", 0, 14, 0, () =>
                Joint("Head", 0, 6, 0, end: (0, 12, 0)));
            Joint("LeftShoulder", 6, 8, 0, () =>
            Joint("LeftArm", 12, 0, 0, () =>
            Joint("LeftForeArm", 26, 0, 0, () =>
            Joint("LeftHand", 25, 0, 0, end: (10, 0, 0)))));
            Joint("RightShoulder", -6, 8, 0, () =>
            Joint("RightArm", -12, 0, 0, () =>
            Joint("RightForeArm", -26, 0, 0, () =>
            Joint("RightHand", -25, 0, 0, end: (-10, 0, 0)))));
        })));
        Joint("LeftUpLeg", 9, -5, 0, () =>
        Joint("LeftLeg", 0, -40, 0, () =>
        Joint("LeftFoot", 0, -40, 0, () =>
        Joint("LeftToeBase", 0, -8, 12, end: (0, 0, 8)))));
        Joint("RightUpLeg", -9, -5, 0, () =>
        Joint("RightLeg", 0, -40, 0, () =>
        Joint("RightFoot", 0, -40, 0, () =>
        Joint("RightToeBase", 0, -8, 12, end: (0, 0, 8)))));

        sb.AppendLine("}");
        sb.AppendLine("MOTION");
        sb.AppendLine($"Frames: {Frames}");
        sb.AppendLine("Frame Time: 0.0333333");

        // 30° sin-arc swing inside [start, end]; 0 (rest = planted) outside.
        static float Swing(int frame, int start, int end)
            => frame >= start && frame <= end
                ? 30f * MathF.Sin(MathF.PI * (frame - start) / (end - start))
                : 0f;

        for (var f = 0; f < Frames; f++)
        {
            var sway = 1.5f * MathF.Sin(2f * MathF.PI * f / Frames); // < 5 cm/s: never unplants
            var left = Swing(f, 0, LeftTouchdownFrame - 1);          // swings 0–14, plants at 15
            var right = Swing(f, 30, RightTouchdownFrame - 1);       // planted 0–29, swings 30–44

            var values = new float[69]; // 6 root + 21 joints x 3 (Zrot Xrot Yrot)
            values[0] = sway;            // hips Xposition
            values[1] = 95f;             // hips Yposition
            values[6 + 13 * 3 + 1] = left;  // joint 14 (LeftUpLeg) Xrotation
            values[6 + 17 * 3 + 1] = right; // joint 18 (RightUpLeg) Xrotation

            sb.AppendLine(string.Join(' ',
                values.Select(v => v.ToString("0.######", CultureInfo.InvariantCulture))));
        }
        return sb.ToString();
    }
}