tests/HumanoidRetargeter.Tests/Solve/FingerSolverTests.cs

Unit tests for the finger-solving portion of the HumanoidRetargeter solver. Exercises round-trip fidelity, redistribution of curl when source/target phalanx counts differ, synthetic fist behavior, twist drift limits, and opt-out or missing-finger behavior against an s&box rig and sample skeletons.

File Access
using System.Numerics;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Mapping;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Solve;

public class FingerSolverTests
{
    private const float RadToDeg = GeometricSolverTests.RadToDeg;

    private static readonly string[] Fingers = { "Thumb", "Index", "Middle", "Ring", "Pinky" };
    private static readonly string[] Segments = { "Meta", "Prox", "Mid", "Dist" };

    private static IEnumerable<BoneRole> AllFingerRoles()
    {
        foreach (var side in new[] { "L", "R" })
        {
            foreach (var finger in Fingers)
            {
                foreach (var segment in Segments)
                    yield return Enum.Parse<BoneRole>(finger + segment + side);
            }
        }
    }

    // ================================================================ D1. round-trip fingers

    [Fact]
    public void CitizenRoundTrip_FingerWorldRotations_Within1Degree()
    {
        var scene = GeometricSolverTests.Citizen();
        var rig = GeometricSolverTests.RigOverSkeleton(scene.Skeleton, "citizen_roundtrip");
        var srcMap = GeometricSolverTests.RoleMapByName(scene.Skeleton, rig);
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var mapped = AllFingerRoles()
            .Where(r => srcMap.RoleToBone.ContainsKey(r) && rig.BoneForRole(r) is not null)
            .Select(r => (Src: srcMap.RoleToBone[r], Tgt: rig.BoneForRole(r)!.Value, Role: r))
            .ToList();
        Assert.True(mapped.Count >= 20, $"only {mapped.Count} finger bones mapped in the round-trip");

        var max = 0f;
        var worst = "";
        for (var f = 0; f < clip.FrameCount; f++)
        {
            var srcWorld = GeometricSolverTests.Fk(scene.Skeleton, clip.Frames[f]);
            var outWorld = GeometricSolverTests.Fk(rig.Skeleton, solved.Frames[f]);
            foreach (var (s, t, role) in mapped)
            {
                var deg = GeometricSolverTests.DegBetween(srcWorld[s].Rot, outWorld[t].Rot);
                if (deg > max)
                {
                    max = deg;
                    worst = $"{role} @ frame {f}";
                }
            }
        }
        Assert.True(max <= 1.0f, $"finger round-trip error {max:F3} deg (worst: {worst})");
    }

    // ================================================================ D2. synthetic fist (redistribution path)

    [Fact]
    public void SyntheticFist_CurlsTargetFingers_WithoutTwistDrift()
    {
        // Source: Zombie Crawl rest with every finger phalanx curled +60 deg about ITS
        // canonical hinge (hierarchically, prox -> mid -> dist, hinge transported with the
        // accumulated rotation). Mixamo fingers are Prox/Mid/Dist; the s&box target adds a
        // metacarpal, so this exercises the FingerSolver redistribution path.
        var skeleton = MappingFixtures.LoadZombieCrawl();
        var detected = ProfileDetector.Detect(skeleton);
        Assert.NotNull(detected);
        var srcMap = detected!.Value.Result;

        var canon = CanonicalFrames.Build(skeleton, srcMap); // bind rest (animation space)
        var world = skeleton.RestWorld.ToArray();
        foreach (var side in new[] { "L", "R" })
        {
            foreach (var finger in Fingers)
            {
                foreach (var segment in new[] { "Prox", "Mid", "Dist" })
                {
                    var role = Enum.Parse<BoneRole>(finger + segment + side);
                    if (!srcMap.RoleToBone.TryGetValue(role, out var bone) || !canon.Has(role))
                        continue;

                    var restHinge = Vector3.Transform(Vector3.UnitY, canon.WorldFrameOf(role));
                    var deltaSoFar = MathQ.Normalize(
                        world[bone].Rot * Quaternion.Conjugate(skeleton.RestWorld[bone].Rot));
                    var hinge = Vector3.Transform(restHinge, deltaSoFar);
                    GeometricSolverTests.RotateWorldSubtree(
                        skeleton, world, bone,
                        Quaternion.CreateFromAxisAngle(hinge, 60f / RadToDeg), world[bone].Pos);
                }
            }
        }
        // Frame 0 = source rest (the drift baseline), frame 1 = the fist.
        var scene = GeometricSolverTests.SceneFromWorldFrame(skeleton, world, "fist");
        var restLocals = new XForm[skeleton.Count];
        for (var i = 0; i < skeleton.Count; i++)
            restLocals[i] = skeleton[i].RestLocal;
        scene.Clips[0].Frames.Insert(0, restLocals);

        var rig = GeometricSolverTests.SboxRig();
        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var tgtRest = rig.Skeleton.RestWorld.ToArray();
        var tgtBase = GeometricSolverTests.Fk(rig.Skeleton, solved.Frames[0]);
        var tgtPose = GeometricSolverTests.Fk(rig.Skeleton, solved.Frames[1]);

        // Fingertip-to-palm-center distance must shrink by >= 35% on both hands
        // (averaged over the four non-thumb fingers). The fingertip is the distal head
        // extrapolated by 0.8x the mid->dist rest segment (carried in distal bone space, so
        // the distal phalanx's own curl moves it).
        foreach (var side in new[] { "L", "R" })
        {
            Vector3 PalmCenter(IReadOnlyList<XForm> w)
            {
                var hand = w[rig.BoneForRole(GeometricSolverTests.R("Hand", side))!.Value].Pos;
                var knuckles = Vector3.Zero;
                var count = 0;
                foreach (var finger in Fingers)
                {
                    if (rig.BoneForRole(GeometricSolverTests.R(finger + "Prox", side)) is int b)
                    {
                        knuckles += w[b].Pos;
                        count++;
                    }
                }
                return (hand + knuckles / count) * 0.5f;
            }

            var restPalm = PalmCenter(tgtRest);
            var posePalm = PalmCenter(tgtPose);
            var shrinkSum = 0f;
            var fingerCount = 0;
            foreach (var finger in new[] { "Index", "Middle", "Ring", "Pinky" })
            {
                var mid = rig.BoneForRole(GeometricSolverTests.R(finger + "Mid", side))!.Value;
                var dist = rig.BoneForRole(GeometricSolverTests.R(finger + "Dist", side))!.Value;
                var segment = tgtRest[dist].Pos - tgtRest[mid].Pos;
                var tipLocal = tgtRest[dist].Inverse().TransformPoint(tgtRest[dist].Pos + 0.8f * segment);

                var restLen = (tgtRest[dist].TransformPoint(tipLocal) - restPalm).Length();
                var poseLen = (tgtPose[dist].TransformPoint(tipLocal) - posePalm).Length();
                shrinkSum += 1f - poseLen / restLen;
                fingerCount++;
            }
            var shrink = shrinkSum / fingerCount;
            Assert.True(shrink >= 0.35f,
                $"hand {side}: fingertips only moved {shrink:P0} closer to the palm");
        }

        // Twist drift: curling must not introduce axial twist. Measured as the change of the
        // finger-axis (canonical X) twist of each phalanx's local rotation (relative to its
        // chain predecessor) between the rest-baseline frame and the fist frame — the static
        // cross-rig roll re-pose (e.g. the thumb's canonical roll differs ~12 deg between
        // Mixamo and s&box anatomy) is a constant and must not count as drift.
        var tgtMap = rig.ToMappingResult();
        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, tgtMap);
        var tgtCanon = CanonicalFrames.Build(rig.Skeleton, tgtMap, tgtNorm.WorldRest);

        float TwistOf(IReadOnlyList<XForm> pose, BoneRole prevRole, BoneRole role)
        {
            Quaternion DeltaOf(BoneRole r)
            {
                var bone = rig.BoneForRole(r)!.Value;
                return MathQ.Normalize(pose[bone].Rot * Quaternion.Conjugate(tgtNorm.WorldRest[bone].Rot));
            }

            var local = MathQ.Normalize(Quaternion.Conjugate(DeltaOf(prevRole)) * DeltaOf(role));
            var c = tgtCanon.WorldFrameOf(role);
            var canonLocal = MathQ.Normalize(Quaternion.Conjugate(c) * local * c);
            MathQ.SwingTwist(canonLocal, Vector3.UnitX, out _, out var twist);
            var s = twist.X; // axis = UnitX
            var angle = 2f * MathF.Atan2(s, twist.W);
            if (angle > MathF.PI)
                angle -= 2f * MathF.PI;
            else if (angle < -MathF.PI)
                angle += 2f * MathF.PI;
            return angle * RadToDeg;
        }

        foreach (var side in new[] { "L", "R" })
        {
            foreach (var finger in Fingers)
            {
                var prevRole = GeometricSolverTests.R("Hand", side);
                foreach (var segment in new[] { "Prox", "Mid", "Dist" })
                {
                    var role = GeometricSolverTests.R(finger + segment, side);
                    if (rig.BoneForRole(role) is null || !tgtCanon.Has(role)
                        || !srcMap.RoleToBone.ContainsKey(role))
                    {
                        continue;
                    }

                    var drift = MathF.Abs(TwistOf(tgtPose, prevRole, role) - TwistOf(tgtBase, prevRole, role));
                    Assert.True(drift < 8f, $"{role}: twist drift {drift:F2} deg");
                    prevRole = role;
                }
            }
        }
    }

    // ================================================================ D3. proportional redistribution (count mismatch)

    [Fact]
    public void SyntheticFist_TwoPhalanxSource_RedistributesCurlOverThreeTargetPhalanges()
    {
        // Strip the distal phalanges from the source mapping: a 2-phalanx source finger onto
        // the 3-phalanx target exercises the proportional total-curl redistribution.
        var skeleton = MappingFixtures.LoadZombieCrawl();
        var detected = ProfileDetector.Detect(skeleton);
        Assert.NotNull(detected);
        var full = detected!.Value.Result;
        var srcMap = new MappingResult(full.ProfileName, full.Source) { Confidence = full.Confidence };
        foreach (var (role, bone) in full.RoleToBone)
        {
            if (!role.ToString().Contains("Dist"))
                srcMap.RoleToBone[role] = bone;
        }

        // Curl the remaining prox+mid phalanges +60 deg about their canonical hinges.
        var canon = CanonicalFrames.Build(skeleton, srcMap);
        var world = skeleton.RestWorld.ToArray();
        foreach (var side in new[] { "L", "R" })
        {
            foreach (var finger in Fingers)
            {
                foreach (var segment in new[] { "Prox", "Mid" })
                {
                    var role = GeometricSolverTests.R(finger + segment, side);
                    if (!srcMap.RoleToBone.TryGetValue(role, out var bone) || !canon.Has(role))
                        continue;
                    var restHinge = Vector3.Transform(Vector3.UnitY, canon.WorldFrameOf(role));
                    var deltaSoFar = MathQ.Normalize(
                        world[bone].Rot * Quaternion.Conjugate(skeleton.RestWorld[bone].Rot));
                    GeometricSolverTests.RotateWorldSubtree(
                        skeleton, world, bone,
                        Quaternion.CreateFromAxisAngle(Vector3.Transform(restHinge, deltaSoFar), 60f / RadToDeg),
                        world[bone].Pos);
                }
            }
        }
        var scene = GeometricSolverTests.SceneFromWorldFrame(skeleton, world, "fist2");

        var rig = GeometricSolverTests.SboxRig();
        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        // The 120 deg of chain curl must spread over all THREE target phalanges: every
        // phalanx receives a meaningful share (local rotation well away from rest), and the
        // fingertips close toward the palm.
        var tgtRest = rig.Skeleton.RestWorld.ToArray();
        var tgtPose = GeometricSolverTests.Fk(rig.Skeleton, solved.Frames[0]);
        foreach (var side in new[] { "L", "R" })
        {
            foreach (var finger in new[] { "Index", "Middle", "Ring", "Pinky" })
            {
                foreach (var segment in new[] { "Prox", "Mid", "Dist" })
                {
                    var bone = rig.BoneForRole(GeometricSolverTests.R(finger + segment, side))!.Value;
                    var localDeg = GeometricSolverTests.DegBetween(
                        solved.Frames[0][bone].Rot, rig.Skeleton[bone].RestLocal.Rot);
                    Assert.True(localDeg > 10f,
                        $"{finger}{segment}{side}: only {localDeg:F1} deg of local curl received");
                }

                var hand = tgtPose[rig.BoneForRole(GeometricSolverTests.R("Hand", side))!.Value].Pos;
                var handRest = tgtRest[rig.BoneForRole(GeometricSolverTests.R("Hand", side))!.Value].Pos;
                var dist = rig.BoneForRole(GeometricSolverTests.R(finger + "Dist", side))!.Value;
                var restLen = (tgtRest[dist].Pos - handRest).Length();
                var poseLen = (tgtPose[dist].Pos - hand).Length();
                Assert.True(poseLen < restLen * 0.85f,
                    $"{finger}{side}: distal did not close toward the hand ({poseLen:F2} vs rest {restLen:F2} cm)");
            }
        }
    }

    // ================================================================ missing fingers + opt-out

    [Fact]
    public void SourceWithoutFingerRoles_LeavesTargetFingersAtRest()
    {
        var scene = GeometricSolverTests.Zombie();
        var full = GeometricSolverTests.DetectMap(scene, "mixamo");
        var rig = GeometricSolverTests.SboxRig();

        // Strip every finger role from the source mapping (a fingerless mocap rig).
        var srcMap = new MappingResult(full.ProfileName, full.Source) { Confidence = full.Confidence };
        var fingerRoles = AllFingerRoles().ToHashSet();
        foreach (var (role, bone) in full.RoleToBone)
        {
            if (!fingerRoles.Contains(role))
                srcMap.RoleToBone[role] = bone;
        }

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var fingerBones = AllFingerRoles()
            .Select(rig.BoneForRole)
            .Where(b => b is not null)
            .Select(b => b!.Value)
            .ToList();
        Assert.True(fingerBones.Count >= 30);

        foreach (var frame in solved.Frames)
        {
            foreach (var bone in fingerBones)
                Assert.Equal(rig.Skeleton[bone].RestLocal, frame[bone]);
        }
    }

    [Fact]
    public void TransferFingersFalse_LeavesTargetFingersAtRest()
    {
        var scene = GeometricSolverTests.Zombie();
        var srcMap = GeometricSolverTests.DetectMap(scene, "mixamo");
        var rig = GeometricSolverTests.SboxRig();

        var solved = new GeometricSolver().Solve(
            scene, srcMap, rig, new SolveOptions { TransferFingers = false });

        foreach (var frame in solved.Frames)
        {
            foreach (var role in AllFingerRoles())
            {
                if (rig.BoneForRole(role) is int bone)
                    Assert.Equal(rig.Skeleton[bone].RestLocal, frame[bone]);
            }
        }
    }
}