tests/HumanoidRetargeter.Tests/Target/UserCatgirlDiagnostics.cs

Unit test that imports a specific FBX user rig (Catgirl_2), runs mapping and retarget solving against a BVH clip, and emits diagnostics about skeleton hierarchy, mapping, per-bone stretch, swing, and a targeted check that a skirt bone remains correctly parented to the hips.

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

namespace HumanoidRetargeter.Tests.Target;

/// <summary>User repro (2026-07-04, local-only): Catgirl_2.fbx "completely messed up /
/// stretched weird" with Neutral_throw, and "one part of the body doesn't move at all".
/// Import/map/solve audit with stretch + immobility metrics.</summary>
public class UserCatgirlDiagnostics
{
    private readonly ITestOutputHelper _out;
    public UserCatgirlDiagnostics(ITestOutputHelper o) => _out = o;

    [Fact]
    public void Catgirl_ImportMapSolveAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "catgirl", "source", "Catgirl_2.fbx");
        var clipPath = TestUtil.RepoFile("dev", "corpus", "todo", "Neutral_throw_ball_001__A057.bvh");
        if (!File.Exists(fbx) || !File.Exists(clipPath)) return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(fbx));
        _out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis} clips={scene.Clips.Count}");
        var rest = scene.Skeleton.RestWorld;
        for (var i = 0; i < Math.Min(scene.Skeleton.Count, 80); i++)
        {
            var b = scene.Skeleton[i];
            var depth = 0;
            for (var p = b.ParentIndex; p >= 0; p = scene.Skeleton[p].ParentIndex) depth++;
            _out.WriteLine($"{new string(' ', depth * 2)}[{i}] {b.Name} world={rest[i].Pos.X:0.#},{rest[i].Pos.Y:0.#},{rest[i].Pos.Z:0.#}");
        }

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

        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        { Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = scene.Skeleton[0].Name };
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[] { new HumanoidRetargeter.Core.RetargetRequest {
                SourceData = File.ReadAllBytes(clipPath),
                SourceFileName = Path.GetFileName(clipPath), FootPlantCleanup = true } },
            target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        var clip = batch.Clips.Single();
        _out.WriteLine($"solve success={clip.Success} err={clip.Error} frames={clip.SolvedFrames?.Count}");
        if (!clip.Success) return;

        // Stretch: per-bone hips-relative distance vs rest across frames (FK world) and
        // immobility: which mapped roles never move.
        var skeleton = rig.Skeleton;
        var hips = rig.BoneForRole(BoneRole.Hips)!.Value;
        var restWorld = skeleton.RestWorld;
        var maxStretch = new float[skeleton.Count];
        var maxSwing = new float[skeleton.Count];
        foreach (var frame in clip.SolvedFrames!)
        {
            var world = new HumanoidRetargeter.Core.Skeleton.Pose(frame).ToWorld(skeleton);
            for (var i = 0; i < skeleton.Count; i++)
            {
                if (skeleton[i].ParentIndex is int p and >= 0)
                {
                    var restLen = (restWorld[i].Pos - restWorld[p].Pos).Length();
                    var frameLen = (world[i].Pos - world[p].Pos).Length();
                    if (restLen > 0.5f)
                        maxStretch[i] = MathF.Max(maxStretch[i], MathF.Abs(frameLen - restLen) / restLen);
                }
                var swing = 2f * MathF.Acos(MathF.Min(1f, MathF.Abs(System.Numerics.Quaternion.Dot(
                    System.Numerics.Quaternion.Normalize(frame[i].Rot),
                    System.Numerics.Quaternion.Normalize(skeleton[i].RestLocal.Rot))))) * 180f / MathF.PI;
                maxSwing[i] = MathF.Max(maxSwing[i], swing);
            }
        }
        _out.WriteLine("worst bone-length stretch (should be ~0 - rotations preserve length):");
        foreach (var i in Enumerable.Range(0, skeleton.Count).OrderByDescending(i => maxStretch[i]).Take(8))
            _out.WriteLine($"  {skeleton[i].Name,-30} stretch {maxStretch[i]:P0}  swing {maxSwing[i]:0.#}");
        _out.WriteLine("mapped roles that never move (>3deg):");
        foreach (var (role, index) in map.RoleToBone.OrderBy(kv => kv.Key.ToString()))
            if (maxSwing[index] < 3f && role != BoneRole.Hips)
                _out.WriteLine($"  {role} '{skeleton[index].Name}' maxSwing {maxSwing[index]:0.#}");

        // The kilt: 48 skirt-physics bones parented to the SCENE ROOT, outside the body
        // chain. Unmapped bones hold rest locals, so the whole skirt froze at the bind
        // spot while the body animated away ("one part doesn't move at all", the mesh
        // between skirt and legs "stretched weird"). They must ride the hips rigidly:
        // hips-relative offset stays the rest offset on every frame.
        var kiltTop = Enumerable.Range(0, skeleton.Count).Single(i => skeleton[i].Name == "c_kilt_01_01.l");
        var restOffset = HumanoidRetargeter.Core.Maths.XForm.ToLocal(restWorld[hips], restWorld[kiltTop]).Pos;
        var worstDrift = 0f;
        foreach (var frame in clip.SolvedFrames)
        {
            var world = new HumanoidRetargeter.Core.Skeleton.Pose(frame).ToWorld(skeleton);
            var offset = HumanoidRetargeter.Core.Maths.XForm.ToLocal(world[hips], world[kiltTop]).Pos;
            worstDrift = MathF.Max(worstDrift, (offset - restOffset).Length());
        }
        _out.WriteLine($"kilt hips-frame drift: worst {worstDrift:0.##} cm");
        Assert.True(worstDrift < 2f,
            $"kilt bone drifts {worstDrift:0.#} cm from the hips - the skirt is frozen in place");
    }
}