tests/HumanoidRetargeter.Tests/Target/UserDieFbxTargetTests.cs

Unit tests and diagnostics for importing and retargeting a user-supplied FBX rig (DieExported.fbx) and related clips. Tests import FBX and BVH/FBX source animations, resolve bone mappings, construct a TargetRig, run retarget conversions, and assert solved frames, pelvis/hand motion and mapping notes; additional diagnostic tests print mapping and bone metrics.

File Access
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Target;
using Xunit;
using Xunit.Abstractions;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Target;

/// <summary>User repro (2026-07-04, local-only): DieExported.fbx picked as a custom FBX
/// target — skinned character, inch units (UnitScaleFactor 2.54), Y-up, textures folder
/// NEXT TO the source folder. Must import, be recognized (or at least accepted
/// best-effort), and convert cleanly onto its own skeleton.</summary>
public class UserDieFbxTargetTests
{
    private static readonly string FbxPath =
        TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");

    private readonly ITestOutputHelper _out;

    public UserDieFbxTargetTests(ITestOutputHelper output) => _out = output;

    [Fact]
    public void DieExported_ImportsAndResolvesAsTarget()
    {
        if (!File.Exists(FbxPath))
            return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
        _out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis}");
        foreach (var bone in scene.Skeleton.Bones.Take(24))
            _out.WriteLine($"  {bone.Name}");

        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);
        _out.WriteLine($"profile={map.ProfileName} source={map.Source} conf={map.Confidence:0.00} "
            + $"needsUser={report.NeedsUserDecision} roles={map.RoleToBone.Count}");
        foreach (var (role, index) in map.RoleToBone.OrderBy(kv => kv.Key.ToString()))
            _out.WriteLine($"  {role} -> {scene.Skeleton[index].Name}");

        // The structural minimum TargetPickers accepts best-effort maps on.
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.Hips), "no hips");
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegL), "no upper leg L");
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegR), "no upper leg R");

        // Converting a citizen clip onto it must solve.
        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig,
            VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
            DefaultRootBone = scene.Skeleton[0].Name,
        };
        var bvh = TestUtil.RepoFile("dev", "corpus", "bvh", "cmu_01_01.bvh");
        if (!File.Exists(bvh))
            return;
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[]
            {
                new HumanoidRetargeter.Core.RetargetRequest
                {
                    SourceData = File.ReadAllBytes(bvh),
                    SourceFileName = "cmu_01_01.bvh",
                },
            },
            target,
            new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        foreach (var clip in batch.Clips.Where(c => !c.Success))
            _out.WriteLine($"FAIL {clip.ClipName}: {clip.Error}");
        Assert.True(batch.Clips.All(c => c.Success));
        Assert.Contains("cmu_01_01", batch.StandaloneVmdl);
    }

    /// <summary>User request: "apply the Neutral throw ball animation to that model, see
    /// if the animation plays correctly" (and "try the surprised.fbx animation as well,
    /// which is mixamo"). Solves each clip onto DieExported and requires real motion: the
    /// hands must travel, the character must stay upright (pelvis height near rest, no
    /// axis flip), and the pelvis must not sink.</summary>
    [Theory]
    [InlineData("todo/Neutral_throw_ball_001__A057.bvh", 0.5f)]
    [InlineData("mixamo/Surprised.fbx", 0.15f)] // startled recoil - hands travel less than a throw
    public void DieExported_UserClips_SolveWithRealMotion(string corpusRelative, float travelFactor)
    {
        var throwPath = TestUtil.RepoFile(
            ("dev/corpus/" + corpusRelative).Split('/'));
        if (!File.Exists(FbxPath) || !File.Exists(throwPath))
            return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
        var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig,
            VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
            DefaultRootBone = scene.Skeleton[0].Name,
        };

        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[]
            {
                new HumanoidRetargeter.Core.RetargetRequest
                {
                    SourceData = File.ReadAllBytes(throwPath),
                    SourceFileName = Path.GetFileName(throwPath),
                    FootPlantCleanup = true,
                },
            },
            target,
            new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });

        var clip = Assert.Single(batch.Clips);
        _out.WriteLine($"clip={clip.ClipName} success={clip.Success} err={clip.Error} "
            + $"frames={clip.SolvedFrames?.Count}");
        Assert.True(clip.Success, clip.Error);
        Assert.True(clip.SolvedFrames!.Count > 30, "suspiciously short solve");

        var skeleton = rig.Skeleton;
        var hips = rig.BoneForRole(BoneRole.Hips)!.Value;
        var handR = rig.BoneForRole(BoneRole.HandR)!.Value;

        var restPelvisY = skeleton.RestWorld[hips].Pos.Y;
        var minPelvis = float.MaxValue;
        var maxPelvis = float.MinValue;
        var handMin = new System.Numerics.Vector3(float.MaxValue);
        var handMax = new System.Numerics.Vector3(float.MinValue);
        foreach (var frame in clip.SolvedFrames)
        {
            var world = new HumanoidRetargeter.Core.Skeleton.Pose(frame).ToWorld(skeleton);
            minPelvis = MathF.Min(minPelvis, world[hips].Pos.Y);
            maxPelvis = MathF.Max(maxPelvis, world[hips].Pos.Y);
            handMin = System.Numerics.Vector3.Min(handMin, world[handR].Pos);
            handMax = System.Numerics.Vector3.Max(handMax, world[handR].Pos);
        }
        var handTravel = (handMax - handMin).Length();
        _out.WriteLine($"restPelvisY={restPelvisY:0.#} pelvisY=[{minPelvis:0.#}..{maxPelvis:0.#}] "
            + $"handTravel={handTravel:0.#}cm (rig units)");

        // Upright: pelvis stays within a sane band around its rest height (a lying/flipped
        // or buried character violates this immediately).
        Assert.InRange(minPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
        Assert.InRange(maxPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
        // The clip must move the hand substantially (relative to character size).
        Assert.True(handTravel > restPelvisY * travelFactor,
            $"hand barely moves ({handTravel:0.#} vs pelvis height {restPelvisY:0.#})");
    }
}

public class UserDieFbxHandDiagnostics
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserDieFbxHandDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Surprised_OnDieExported_HandTransferAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
        var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
        if (!File.Exists(fbx) || !File.Exists(clipPath)) return;

        var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        var (tgtMap, tgtReport) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
        var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name,
        };
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[] { new HumanoidRetargeter.Core.RetargetRequest {
                SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
            target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        var clip = batch.Clips.Single();
        Xunit.Assert.True(clip.Success, clip.Error);

        foreach (var note in clip.Mapping!.Notes.Where(n => n.Contains("and") || n.Contains("roll")))
            _out.WriteLine("NOTE: " + note);

        // Local-rotation swing per bone at the surprise peak vs source counterparts.
        var srcScene = HumanoidRetargeter.Core.Retargeter.ImportSource(File.ReadAllBytes(clipPath), "Surprised.fbx");
        var (srcMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(srcScene.Skeleton);
        var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
        var srcF = Math.Min(60, srcScene.Clips[0].Frames.Count - 1);
        float Angle(System.Numerics.Quaternion a, System.Numerics.Quaternion b)
        {
            var d = MathF.Min(MathF.Abs(System.Numerics.Quaternion.Dot(
                System.Numerics.Quaternion.Normalize(a), System.Numerics.Quaternion.Normalize(b))), 1f);
            return 2f * MathF.Acos(d) * 180f / MathF.PI;
        }
        foreach (var role in new[] {
            HumanoidRetargeter.Core.Mapping.BoneRole.LowerArmR, HumanoidRetargeter.Core.Mapping.BoneRole.HandR,
            HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleProxR,
            HumanoidRetargeter.Core.Mapping.BoneRole.ThumbProxR,
            HumanoidRetargeter.Core.Mapping.BoneRole.HandL, HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxL })
        {
            var t = rig.BoneForRole(role);
            srcMap.RoleToBone.TryGetValue(role, out var s);
            if (t is null) { _out.WriteLine($"{role}: unmapped on target"); continue; }
            var tgtSwing = Angle(clip.SolvedFrames[f][t.Value].Rot, rig.Skeleton[t.Value].RestLocal.Rot);
            var srcSwing = srcMap.RoleToBone.ContainsKey(role)
                ? Angle(srcScene.Clips[0].Frames[srcF][s].Rot, srcScene.Skeleton[s].RestLocal.Rot)
                : -1f;
            _out.WriteLine($"{role}: target swing {tgtSwing:0.#} deg  source swing {srcSwing:0.#} deg");
        }
    }
}

public class UserDieFbxFingerLengths
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserDieFbxFingerLengths(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Surprised_OnDieExported_FingerLengthAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
        var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
        if (!File.Exists(fbx) || !File.Exists(clipPath)) return;

        var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        var (tgtMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
        foreach (var b in tgtScene.Skeleton.Bones)
            if (b.Name.Contains("Finger") || b.Name.Contains("Hand") || b.Name.Contains("Forearm"))
                _out.WriteLine($"bone: {b.Name} mapped={tgtMap.RoleToBone.ContainsValue(b.Index)}");

        var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        { Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name };
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[] { new HumanoidRetargeter.Core.RetargetRequest {
                SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
            target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        var clip = batch.Clips.Single();
        var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
        var world = new HumanoidRetargeter.Core.Skeleton.Pose(clip.SolvedFrames[f]).ToWorld(rig.Skeleton);
        var rest = rig.Skeleton.RestWorld;
        var hand = rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.HandR)!.Value;
        foreach (var role in new[] {
            HumanoidRetargeter.Core.Mapping.BoneRole.IndexDistR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleDistR,
            HumanoidRetargeter.Core.Mapping.BoneRole.ThumbDistR, HumanoidRetargeter.Core.Mapping.BoneRole.PinkyDistR })
        {
            if (rig.BoneForRole(role) is not { } tip) { _out.WriteLine($"{role}: unmapped"); continue; }
            var restD = (rest[tip].Pos - rest[hand].Pos).Length();
            var frameD = (world[tip].Pos - world[hand].Pos).Length();
            _out.WriteLine($"{role}: rest {restD:0.##}  frame {frameD:0.##}  ratio {frameD / restD:0.###}");
        }
    }
}

public class UserSimpsonRigDiagnostics
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserSimpsonRigDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Simpson_ImportAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "simpson", "source", "C8V7NUC53TF8TTF3BXDMVGQIQ.fbx");
        if (!File.Exists(fbx)) return;
        var scene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        _out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis} clips={scene.Clips.Count}");
        var (map, report) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(scene.Skeleton);
        _out.WriteLine($"profile={map.ProfileName} conf={map.Confidence:0.00} roles={map.RoleToBone.Count} needsUser={report.NeedsUserDecision}");
        // Character extents + suspicious rest locals
        var rest = scene.Skeleton.RestWorld;
        float maxY = float.MinValue, minY = float.MaxValue;
        foreach (var w in rest) { maxY = MathF.Max(maxY, w.Pos.Y); minY = MathF.Min(minY, w.Pos.Y); }
        _out.WriteLine($"rest height span Y: {minY:0.#}..{maxY:0.#} cm");
        foreach (var role in new[] { HumanoidRetargeter.Core.Mapping.BoneRole.Hips, HumanoidRetargeter.Core.Mapping.BoneRole.Head,
            HumanoidRetargeter.Core.Mapping.BoneRole.HandR, HumanoidRetargeter.Core.Mapping.BoneRole.FootL })
        {
            if (map.RoleToBone.TryGetValue(role, out var i))
                _out.WriteLine($"{role} -> {scene.Skeleton[i].Name} world {rest[i].Pos}");
            else _out.WriteLine($"{role}: UNMAPPED");
        }
        foreach (var b in scene.Skeleton.Bones.Take(12))
            _out.WriteLine($"  [{b.Index}] {b.Name} parent={b.ParentIndex} localPos={b.RestLocal.Pos}");
    }
}