tests/HumanoidRetargeter.Tests/Solve/TransferModeContractTests.cs

Unit tests for the GeometricSolver transfer-mode behavior. They verify the solver's contracts for SolveOptions.TransferModes: how null, explicit, and empty transfer-mode maps affect foot, head and other role transfers across different source rig conditions.

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

namespace HumanoidRetargeter.Tests.Solve;

/// <summary>
/// The <see cref="SolveOptions.TransferModes"/> contract: null (default) =
/// <see cref="SolveOptions.DefaultTransferModes"/> plus the solver's fallback heuristics
/// (virtual-foot delta, posed-rest-head absolute gaze); a NON-NULL map is exact — the
/// caller's entries win, roles absent from the map are
/// <see cref="RoleTransferMode.AbsoluteDirection"/>, and every fallback heuristic
/// is disabled. An empty map is therefore the fully absolute legacy behavior (the editor's
/// checkbox-off path). Shares fixtures/helpers with <see cref="GeometricSolverTests"/>.
/// </summary>
public class TransferModeContractTests
{
    /// <summary>
    /// A toe-less source's foot direction is a virtual character-forward extension, so under
    /// the DEFAULT (null) modes the solver falls back to delta transfer for the feet. An
    /// explicit map containing Foot L/R = <see cref="RoleTransferMode.AbsoluteDirection"/> is
    /// a contract: the fallback must NOT override it — the feet solve absolute even though
    /// the source primary is virtual — while every other role matches the defaults run
    /// (the explicit map reproduces <see cref="SolveOptions.DefaultTransferModes"/> there).
    /// </summary>
    [Fact]
    public void ExplicitFootAbsolute_OnToelessSource_OverridesVirtualFootFallback()
    {
        var skeleton = GeometricSolverTests.Zombie().Skeleton;
        var rig = GeometricSolverTests.SboxRig();
        var scene = GeometricSolverTests.SceneFromWorldFrame(
            skeleton, skeleton.RestWorld.ToArray(), "rest_frame");

        var toeless = GeometricSolverTests.DetectMap(GeometricSolverTests.Zombie(), "mixamo");
        toeless.RoleToBone.Remove(BoneRole.ToeL);
        toeless.RoleToBone.Remove(BoneRole.ToeR);

        // Fixture sanity: the fallback's trigger condition really holds — virtual source
        // foot primary (no mapped toe), real target anatomy (toes mapped on the rig).
        var (_, srcCanon, _) = GeometricSolverTests.NormCanon(skeleton, toeless);
        var (_, tgtCanon, _) = GeometricSolverTests.NormCanon(rig.Skeleton, rig.ToMappingResult());
        Assert.True(srcCanon.HasVirtualPrimary(BoneRole.FootL), "source foot primary should be virtual");
        Assert.False(tgtCanon.HasVirtualPrimary(BoneRole.FootL), "target foot primary should be real");

        // Explicit map = the defaults plus the feet pinned ABSOLUTE.
        var explicitModes = SolveOptions.DefaultTransferModes
            .ToDictionary(kv => kv.Key, kv => kv.Value);
        explicitModes[BoneRole.FootL] = RoleTransferMode.AbsoluteDirection;
        explicitModes[BoneRole.FootR] = RoleTransferMode.AbsoluteDirection;

        var solver = new GeometricSolver();
        var fallbackRun = solver.Solve(scene, toeless, rig, new SolveOptions());
        var explicitRun = solver.Solve(scene, toeless, rig, new SolveOptions
        {
            TransferModes = explicitModes,
        });

        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
        var fallbackWorld = GeometricSolverTests.Fk(rig.Skeleton, fallbackRun.Frames[0]);
        var explicitWorld = GeometricSolverTests.Fk(rig.Skeleton, explicitRun.Frames[0]);

        var footBones = new HashSet<int>();
        foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
        {
            var bone = rig.BoneForRole(role)!.Value;
            footBones.Add(bone);
            var restRot = tgtNorm.WorldRest[bone].Rot;

            // Null modes: the heuristic applies — at source rest the target foot keeps its
            // own rest carriage (delta transfer).
            var fallbackDeg = GeometricSolverTests.DegBetween(fallbackWorld[bone].Rot, restRot);
            Assert.True(fallbackDeg <= 0.5f,
                $"{role}: null TransferModes should fall back to delta (target rest carriage), "
                + $"off {fallbackDeg:F2} deg");

            // Explicit AbsoluteDirection: no fallback — the foot adopts the source's virtual
            // forward direction, away from the target's real rest pitch.
            var explicitDeg = GeometricSolverTests.DegBetween(explicitWorld[bone].Rot, restRot);
            Assert.True(explicitDeg > 1.5f,
                $"{role}: explicit AbsoluteDirection must solve absolute (fallback overrode the "
                + $"contract? only {explicitDeg:F2} deg from target rest)");
        }

        // The two runs differ ONLY on the foot bones — everywhere else the explicit map
        // reproduces the defaults exactly (toes are unmapped → rest locals in both).
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            if (!footBones.Contains(i))
                Assert.Equal(fallbackRun.Frames[0][i], explicitRun.Frames[0][i]);
        }
    }

    /// <summary>
    /// A source whose REST head attitude is implausible as a neutral carriage (posed bind —
    /// here the zombie rest with its neck→head segment swung 30° further forward and 15°
    /// sideways, the Defenses.fbx fighting-guard class) makes the head's
    /// <see cref="RoleTransferMode.CharacterDeltaFromRest"/> default replay deltas from a
    /// posed reference: at source rest the output head would sit at the TARGET's neutral
    /// attitude while the source genuinely looks down — the reported "head looking up at an
    /// angle". Under the DEFAULT (null) modes the solver must fall back to ABSOLUTE gaze
    /// matching (output head adopts the source's posed direction); an explicit map
    /// reproducing the defaults is a contract and must NOT be overridden — the head replays
    /// the delta and keeps the target rest attitude.
    /// </summary>
    [Fact]
    public void PosedRestHeadSource_FallsBackToAbsoluteGaze_ExplicitMapWins()
    {
        var neutral = GeometricSolverTests.Zombie().Skeleton;
        var neutralMap = GeometricSolverTests.DetectMap(GeometricSolverTests.Zombie(), "mixamo");
        var rig = GeometricSolverTests.SboxRig();

        // Pose the rest head: swing the head joint about the neck so the neck→head segment
        // leans 30° further forward and 15° laterally (fwd ≈ 52°, lat ≈ 15° — both far
        // outside the measured neutral band of −3..27° fwd / ≤3° lat).
        var (_, neutralCanon, _) = GeometricSolverTests.NormCanon(neutral, neutralMap);
        var lat = Vector3.Normalize(Vector3.Cross(neutralCanon.CharacterUp, neutralCanon.CharacterForward));
        var swing = Quaternion.CreateFromAxisAngle(lat, 30f * MathF.PI / 180f) // up tips toward forward
                    * Quaternion.CreateFromAxisAngle(neutralCanon.CharacterForward, 15f * MathF.PI / 180f);
        var posed = PoseHead(neutral, neutralMap, swing);
        var posedMap = GeometricSolverTests.DetectMap(
            new SourceScene(posed, new[] { new Clip("none", 30f, false) }, 1f), "mixamo");
        var scene = GeometricSolverTests.SceneFromWorldFrame(
            posed, posed.RestWorld.ToArray(), "rest_frame");

        // Fixture sanity: both head primaries are real geometry (the fallback's precondition).
        var (_, srcCanon, _) = GeometricSolverTests.NormCanon(posed, posedMap);
        var (_, tgtCanon, _) = GeometricSolverTests.NormCanon(rig.Skeleton, rig.ToMappingResult());
        Assert.False(srcCanon.HasVirtualPrimary(BoneRole.Head), "source head primary should be real");
        Assert.False(tgtCanon.HasVirtualPrimary(BoneRole.Head), "target head primary should be real");

        var solver = new GeometricSolver();
        var fallbackRun = solver.Solve(scene, posedMap, rig, new SolveOptions());
        var explicitRun = solver.Solve(scene, posedMap, rig, new SolveOptions
        {
            TransferModes = SolveOptions.DefaultTransferModes.ToDictionary(kv => kv.Key, kv => kv.Value),
        });

        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
        var head = rig.BoneForRole(BoneRole.Head)!.Value;
        var restRot = tgtNorm.WorldRest[head].Rot;
        var fallbackWorld = GeometricSolverTests.Fk(rig.Skeleton, fallbackRun.Frames[0]);
        var explicitWorld = GeometricSolverTests.Fk(rig.Skeleton, explicitRun.Frames[0]);

        // Null modes: absolute gaze fallback — the output head adopts the source's posed
        // attitude, far from the target's neutral rest.
        var fallbackDeg = GeometricSolverTests.DegBetween(fallbackWorld[head].Rot, restRot);
        Assert.True(fallbackDeg > 10f,
            "null TransferModes on a posed-rest source should gaze-match absolutely "
            + $"(only {fallbackDeg:F2} deg from the target rest attitude)");

        // Explicit defaults map: the contract — CharacterDeltaFromRest replays the (identity)
        // delta, the head keeps the target's neutral rest attitude.
        var explicitDeg = GeometricSolverTests.DegBetween(explicitWorld[head].Rot, restRot);
        Assert.True(explicitDeg <= 0.5f,
            "explicit CharacterDeltaFromRest must not be overridden by the posed-rest fallback "
            + $"(off {explicitDeg:F2} deg from the target rest attitude)");

        // The NEUTRAL-rest zombie never triggers the fallback: defaults == explicit defaults.
        var neutralScene = GeometricSolverTests.SceneFromWorldFrame(
            neutral, neutral.RestWorld.ToArray(), "rest_frame");
        var neutralDefaults = solver.Solve(neutralScene, neutralMap, rig, new SolveOptions());
        var neutralHeadDeg = GeometricSolverTests.DegBetween(
            GeometricSolverTests.Fk(rig.Skeleton, neutralDefaults.Frames[0])[head].Rot, restRot);
        Assert.True(neutralHeadDeg <= 0.5f,
            $"neutral-rest source must keep the delta default (off {neutralHeadDeg:F2} deg)");
    }

    /// <summary>Rebuilds the skeleton with the head joint's rest swung about its parent
    /// (the neck) by <paramref name="swing"/> — descendant rest geometry rides along.</summary>
    private static Skel PoseHead(Skel skeleton, MappingResult map, Quaternion swing)
    {
        var headBone = map.RoleToBone[BoneRole.Head];
        var neckBone = skeleton[headBone].ParentIndex;
        Assert.True(neckBone >= 0, "fixture: head must have a parent");

        // World-space swing about the neck joint expressed on the head's rest LOCAL: rotate
        // the local offset/orientation through the parent's rest world rotation.
        var neckRot = skeleton.RestWorld[neckBone].Rot;
        var localSwing = MathQ.Normalize(
            Quaternion.Conjugate(neckRot) * swing * neckRot);

        var defs = new List<BoneDefinition>(skeleton.Count);
        foreach (var bone in skeleton.Bones)
        {
            var local = bone.RestLocal;
            if (bone.Index == headBone)
            {
                local = new XForm(
                    Vector3.Transform(local.Pos, localSwing),
                    MathQ.Normalize(localSwing * local.Rot));
            }
            defs.Add(new BoneDefinition(
                bone.Name, bone.ParentIndex < 0 ? null : skeleton[bone.ParentIndex].Name, local));
        }
        return Skel.Create(defs);
    }

    /// <summary>
    /// An EMPTY (non-null) map is the all-absolute legacy behavior (checkbox-off path): the
    /// solve runs, the delta-default roles (clavicles, neck) genuinely change versus the
    /// null-defaults run, and absolute roles stay bit-identical.
    /// </summary>
    [Fact]
    public void EmptyTransferModes_IsLegacyAllAbsolute_OnZombieClip()
    {
        var scene = GeometricSolverTests.Zombie();
        var srcMap = GeometricSolverTests.DetectMap(scene, "mixamo");
        var rig = GeometricSolverTests.SboxRig();

        var solver = new GeometricSolver();
        var defaults = solver.Solve(scene, srcMap, rig, new SolveOptions());
        var legacy = solver.Solve(scene, srcMap, rig, new SolveOptions
        {
            TransferModes = new Dictionary<BoneRole, RoleTransferMode>(), // empty = all absolute
        });

        Assert.True(legacy.FrameCount > 0);
        Assert.Equal(defaults.FrameCount, legacy.FrameCount);
        Assert.Equal(defaults.Fps, legacy.Fps);

        // The delta-default roles really switch to absolute: their output diverges from the
        // defaults run (each rig's clavicle line/neck base/ankle anatomy differs from the
        // source's, so absolute matching visibly drags them — the legacy "low shoulders" /
        // "bent feet" carriage).
        var sampleFrames = GeometricSolverTests.SampleFrames(defaults.FrameCount);
        foreach (var role in new[]
        {
            BoneRole.ClavicleL, BoneRole.ClavicleR, BoneRole.Neck, BoneRole.FootL, BoneRole.FootR,
        })
        {
            var bone = rig.BoneForRole(role)!.Value;
            var maxDeg = 0f;
            foreach (var f in sampleFrames)
            {
                maxDeg = MathF.Max(maxDeg, GeometricSolverTests.DegBetween(
                    defaults.Frames[f][bone].Rot, legacy.Frames[f][bone].Rot));
            }
            Assert.True(maxDeg > 2f,
                $"{role}: empty TransferModes should change the output vs the delta default "
                + $"(moved only {maxDeg:F2} deg — override not effective?)");
        }

        // Roles outside DefaultTransferModes were absolute already — their WORLD rotations
        // are unaffected by the map. (The head is mode-mapped since the head-carriage round:
        // its CharacterDeltaFromRest default vs legacy absolute differs only by the rigs'
        // small neutral head-anatomy divergence — too small for the >2° gate above, nonzero
        // for the identity gate here, so it belongs to neither list.)
        foreach (var f in sampleFrames)
        {
            var defaultsWorld = GeometricSolverTests.Fk(rig.Skeleton, defaults.Frames[f]);
            var legacyWorld = GeometricSolverTests.Fk(rig.Skeleton, legacy.Frames[f]);
            foreach (var role in new[]
            {
                BoneRole.LowerArmL, BoneRole.Spine0, BoneRole.UpperArmL, BoneRole.HandR, BoneRole.ToeL,
            })
            {
                var bone = rig.BoneForRole(role)!.Value;
                var deg = GeometricSolverTests.DegBetween(
                    defaultsWorld[bone].Rot, legacyWorld[bone].Rot);
                Assert.True(deg <= 1e-3f,
                    $"{role} @ frame {f}: absolute role should be identical across mode maps "
                    + $"(differs {deg:F4} deg)");
            }
        }
    }
}