tests/HumanoidRetargeter.Tests/BvhCorpusQualityGateTests.cs

Unit test class that runs local-only regression checks over .bvh files in a dev corpus. It converts each BVH to solved animation clips and enforces two quality gates: no large rotation jump at frame 0 compared to typical inter-frame deltas, and the solved clip starts near the target origin with feet reaching the ground.

File Access
using System.Numerics;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests;

/// <summary>
/// Local-only regression gates over EVERY .bvh in the dev corpus (<c>dev/corpus/**</c>, not
/// committed — the whole suite is silently green when the corpus is absent), pinning the two
/// user-reported BVH quality bugs so they can never come back for ANY corpus file:
/// <list type="bullet">
/// <item><b>No rest-pose flash at frame 0</b> (47_01.bvh: "when i move the preview slider to
/// 0 then there's a t-pose") — the solved clip's frame 0→1 discontinuity must be in line with
/// the clip's own inter-frame deltas, not a calibration-frame cut (measured 166° on the
/// repro before the importer's trim, ≤ 8° typical).</item>
/// <item><b>No skywalking / off-grid start</b> (Armchair1.bvh: "the model is in the air …
/// outside of the modeldoc grid") — the solved pelvis must START over the target origin and
/// the feet must reach the target's ground somewhere in the clip (measured 92 cm of hover at
/// (289, 68) horizontal before the placement normalization).</item>
/// </list>
/// </summary>
public class BvhCorpusQualityGateTests
{
    private const float Rad2Deg = 180f / MathF.PI;

    public static TheoryData<string> CorpusBvhFiles()
    {
        var data = new TheoryData<string>();
        string root;
        try
        {
            root = TestUtil.RepoFile("dev", "corpus");
        }
        catch (InvalidOperationException)
        {
            data.Add(""); // theory data must be non-empty; the test skips "" silently
            return data;
        }
        if (!Directory.Exists(root))
        {
            data.Add("");
            return data;
        }
        foreach (var path in Directory.EnumerateFiles(root, "*.bvh", SearchOption.AllDirectories))
            data.Add(Path.GetRelativePath(root, path));
        return data;
    }

    [Theory] // skipped (silently green) when the local corpus is not present
    [MemberData(nameof(CorpusBvhFiles))]
    public void SolvedClip_HasNoRestFlashAtFrame0_AndStartsGroundedOverOrigin(string relativePath)
    {
        if (string.IsNullOrEmpty(relativePath))
            return;
        var path = Path.Combine(TestUtil.RepoFile("dev", "corpus"), relativePath);
        if (!File.Exists(path))
            return;

        var bytes = File.ReadAllBytes(path);
        var target = RetargetTargetSpec.SboxDefault(File.ReadAllText(
            TestUtil.RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json")));
        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = bytes,
            SourceFileName = Path.GetFileName(path),
        }, target);
        var clip = result.Clips.FirstOrDefault(c => c.Success);
        Assert.True(clip is not null,
            $"{relativePath}: conversion failed — {string.Join("; ", result.Errors)}");
        var frames = clip!.SolvedFrames!;
        if (frames.Count < 3)
            return; // too short to judge continuity

        var rig = target.Rig;
        var skeleton = rig.Skeleton;

        // ---- gate 1: frame 0 is not a rest/T-pose outlier against frame 1 ----------------
        // The frame 0→1 max-bone rotation delta must be comparable to the clip's own typical
        // inter-frame delta (4× median of the following pairs) with an absolute floor for
        // slow clips. A trimmed-away calibration frame measured 166°; real openings ≤ 14°.
        float MaxRotDelta(XForm[] a, XForm[] b)
        {
            var max = 0f;
            for (var i = 0; i < skeleton.Count; i++)
                max = MathF.Max(max, MathQ.AngleBetween(a[i].Rot, b[i].Rot) * Rad2Deg);
            return max;
        }
        var interior = new List<float>();
        for (var f = 1; f < Math.Min(frames.Count - 1, 31); f++)
            interior.Add(MaxRotDelta(frames[f], frames[f + 1]));
        interior.Sort();
        var typical = interior[interior.Count / 2];
        var opening = MaxRotDelta(frames[0], frames[1]);
        Assert.True(opening <= MathF.Max(25f, 4f * typical),
            $"{relativePath}: frame 0→1 rotation jump {opening:F1}° vs typical {typical:F1}° — "
            + "frame 0 is a rest/calibration-pose outlier");

        // ---- gate 2: starts over the origin, feet reach the ground -----------------------
        var pelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
        var restPelvis = skeleton.RestWorld[pelvis].Pos;
        var world0 = new Pose(frames[0]).ToWorld(skeleton);
        var d = world0[pelvis].Pos - restPelvis;
        var horizontal = MathF.Sqrt(d.X * d.X + d.Z * d.Z); // target space is Y-up cm
        Assert.True(horizontal <= 30f,
            $"{relativePath}: solved pelvis starts {horizontal:F0} cm off the target origin");

        var ankleL = rig.BoneForRole(BoneRole.FootL)!.Value;
        var ankleR = rig.BoneForRole(BoneRole.FootR)!.Value;
        var restAnkleY = MathF.Min(
            skeleton.RestWorld[ankleL].Pos.Y, skeleton.RestWorld[ankleR].Pos.Y);
        var minAnkleY = float.MaxValue;
        foreach (var frame in frames)
        {
            var world = new Pose(frame).ToWorld(skeleton);
            minAnkleY = MathF.Min(minAnkleY,
                MathF.Min(world[ankleL].Pos.Y, world[ankleR].Pos.Y));
        }
        Assert.True(MathF.Abs(minAnkleY - restAnkleY) <= 25f,
            $"{relativePath}: lowest solved ankle {minAnkleY:F1} cm vs rest ankle "
            + $"{restAnkleY:F1} cm — clip floats or sinks");
    }
}