tests/HumanoidRetargeter.Tests/Target/UserSimpsonSkeletonDump.cs

Unit test helper that loads a local FBX file for the "simpson" user rig, prints the skeleton hierarchy with world rest positions, resolves mapping roles, runs a rest normalizer and reports rotational and positional differences for spine/neck/head chain.

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

namespace HumanoidRetargeter.Tests.Target;

/// <summary>Temporary instrumentation (2026-07-04): the simpson rig maps Head to a bone
/// whose rest sits 90cm below-behind the neck. Dump the full hierarchy + bind vs normalized
/// rest so the real skull bone and the mis-assignment are visible.</summary>
public class UserSimpsonSkeletonDump
{
    private readonly ITestOutputHelper _out;
    public UserSimpsonSkeletonDump(ITestOutputHelper o) => _out = o;

    [Fact]
    public void DumpHierarchyAndMapping()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "simpson", "source", "C8V7NUC53TF8TTF3BXDMVGQIQ.fbx");
        if (!File.Exists(fbx)) return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(fbx));
        var (map, report) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(scene.Skeleton);
        _out.WriteLine($"bones={scene.Skeleton.Count} profile={map.ProfileName} conf={map.Confidence:0.00}");

        var roleOf = map.RoleToBone.ToDictionary(kv => kv.Value, kv => kv.Key.ToString());
        var rest = scene.Skeleton.RestWorld;
        for (var i = 0; i < scene.Skeleton.Count; 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.#}"
                + (roleOf.TryGetValue(i, out var role) ? $"  <== {role}" : ""));
        }

        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var (norm, normReport) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
        foreach (var note in normReport.Notes)
            _out.WriteLine($"NORM NOTE: {note}");
        // Bind vs normalized world rot difference on the neck/head chain.
        foreach (var role in new[] { BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2,
            BoneRole.Neck, BoneRole.Head })
        {
            if (rig.BoneForRole(role) is not { } bi) { _out.WriteLine($"{role}: unmapped"); continue; }
            var d = 2f * MathF.Acos(MathF.Min(1f, MathF.Abs(System.Numerics.Quaternion.Dot(
                System.Numerics.Quaternion.Normalize(rest[bi].Rot),
                System.Numerics.Quaternion.Normalize(norm.WorldRest[bi].Rot))))) * 180f / MathF.PI;
            _out.WriteLine($"{role} '{rig.Skeleton[bi].Name}': bind→norm world-rot delta {d:0.#} deg "
                + $"bindPos={rest[bi].Pos} normPos={norm.WorldRest[bi].Pos}");
        }
    }
}