tests/SmartPort/Parser/AttachmentInfluenceTests.cs

xUnit unit tests for SmartPort attachment influence handling. It builds test skeletons with different bone axis conventions, constructs KV3 attachment documents, runs alignment/transfer functions (SmartPortAttachments, SmartPortRig) and asserts rotations and origins are preserved or left unchanged for unmapped bones.

File Access
using System.Numerics;
using HumanoidRetargeter.EditorTools;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace SmartPort.Parser.Tests;

/// <summary>
/// Attachments that ignore their bones' rotation or blend several bones (the Citizen's forward_reference_modelspace,
/// the frame its body aim turns the spine toward) ported onto a skeleton whose bones have other axes.
/// </summary>
public class AttachmentInfluenceTests
{
    // The Citizen's bones and a Source 1 biped's point their axes differently (Citizen pelvis (-90, -90, 0),
    // ValveBiped pelvis (0, 90, 90)); joint positions are the same here.
    static readonly Quaternion CitizenAxes = Euler(-90, -90, 0);
    static readonly Quaternion BipedAxes = Euler(0, 90, 90);

    static Quaternion Euler(float pitch, float yaw, float roll) =>
        Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw * MathF.PI / 180)
        * Quaternion.CreateFromAxisAngle(Vector3.UnitY, pitch * MathF.PI / 180)
        * Quaternion.CreateFromAxisAngle(Vector3.UnitX, roll * MathF.PI / 180);

    static SkeletonModel Humanoid(Func<string, string> name, Quaternion axes)
    {
        var world = new Dictionary<string, Vector3>();
        var bones = new List<BoneDefinition>();
        void Bone(string bone, string? parent, Vector3 offset)
        {
            var at = (parent is null ? Vector3.Zero : world[parent]) + offset;
            world[bone] = at;
            // every bone has the same world axes, so only the root carries a rotation
            bones.Add(new(name(bone), parent is null ? null : name(parent),
                new XForm(parent is null ? at : Vector3.Transform(offset, Quaternion.Conjugate(axes)), parent is null ? axes : Quaternion.Identity)));
        }
        Bone("pelvis", null, new(0, 0, 40));
        Bone("spine_0", "pelvis", new(0, 0, 6));
        Bone("spine_1", "spine_0", new(0, 0, 6));
        Bone("spine_2", "spine_1", new(0, 0, 6));
        Bone("neck_0", "spine_2", new(0, 0, 3));
        Bone("head", "neck_0", new(0, 0, 5));
        foreach (var side in new[] { "L", "R" })
        {
            var sign = side == "L" ? 1 : -1;
            Bone("clavicle_" + side, "spine_2", new(0, sign * 2, 1));
            Bone("arm_upper_" + side, "clavicle_" + side, new(0, sign * 4, 0));
            Bone("arm_lower_" + side, "arm_upper_" + side, new(0, sign * 10, 0));
            Bone("hand_" + side, "arm_lower_" + side, new(0, sign * 9, 0));
            Bone("leg_upper_" + side, "pelvis", new(0, sign * 4, 0));
            Bone("leg_lower_" + side, "leg_upper_" + side, new(0, 0, -18));
            Bone("ankle_" + side, "leg_lower_" + side, new(0, 0, -18));
            Bone("ball_" + side, "ankle_" + side, new(5, 0, -3));
        }
        return SkeletonModel.Create(bones);
    }

    static string Biped(string citizen) => "ValveBiped_Bip01_" + citizen switch
    {
        "pelvis" => "Pelvis", "spine_0" => "Spine", "spine_1" => "Spine1", "spine_2" => "Spine2", "neck_0" => "Neck1", "head" => "Head1",
        var n when n.StartsWith("clavicle_") => n[^1] + "_Clavicle",
        var n when n.StartsWith("arm_upper_") => n[^1] + "_UpperArm",
        var n when n.StartsWith("arm_lower_") => n[^1] + "_Forearm",
        var n when n.StartsWith("hand_") => n[^1] + "_Hand",
        var n when n.StartsWith("leg_upper_") => n[^1] + "_Thigh",
        var n when n.StartsWith("leg_lower_") => n[^1] + "_Calf",
        var n when n.StartsWith("ankle_") => n[^1] + "_Foot",
        var n when n.StartsWith("ball_") => n[^1] + "_Toe0",
        var n => n,
    };

    static string Document(Func<string, string> name) => VmdlWriter.Kv3Header + $$"""
        { rootNode = { children = [{ _class = "AttachmentList" children = [
          { _class = "Attachment" name = "forward_reference_modelspace" parent_bone = "{{name("pelvis")}}" ignore_rotation = true
            relative_origin = [0, -1, 0] relative_angles = [0, 90, 90] weight = 0.5
            children = [{ _class = "AttachmentInfluence" parent_bone = "{{name("head")}}" relative_origin = [0, -1, 0] relative_angles = [0, 90, 90] weight = 0.5 }] },
          { _class = "Attachment" name = "middle_of_both_hands" parent_bone = "{{name("hand_R")}}" ignore_rotation = false
            relative_origin = [1, 2, 3] relative_angles = [10, 20, 30] weight = 0.5
            children = [{ _class = "AttachmentInfluence" parent_bone = "{{name("hand_L")}}" relative_origin = [1, 2, 3] relative_angles = [0, 0, 45] weight = 0.5 }] },
          { _class = "Attachment" name = "eyes" parent_bone = "{{name("head")}}" ignore_rotation = false
            relative_origin = [1, 2, 3] relative_angles = [0, 90, 90] weight = 1 }
        ] }] } }
        """;

    static KvObject Node(string text, string attachment, int influence = -1)
    {
        var list = (KvObject)((KvArray)((KvObject)((KvObject)Kv3.Parse(text).Root)["rootNode"])["children"]).Items[0];
        var node = ((KvArray)list["children"]).Items.OfType<KvObject>().Single(n => n.GetString("name") == attachment);
        return influence < 0 ? node : (KvObject)((KvArray)node["children"]).Items[influence];
    }

    static Quaternion Angles(KvObject node)
    {
        var values = ((KvArray)node["relative_angles"]).Items.Select(v => v is KvDouble d ? (float)d.Value : ((KvLong)v).Value).ToArray();
        return Euler(values[0], values[1], values[2]);
    }

    [Fact]
    public void FixedAndBlendedAttachmentsKeepTheSourceOrientationOnOtherBoneAxes()
    {
        var source = Humanoid(n => n, CitizenAxes);
        var target = Humanoid(Biped, BipedAxes);
        var rig = new SmartPortRig(source, target);
        var sourceText = Document(n => n);
        var targetText = Document(Biped); // as Smart Port copies them: the source's angles under the mapped bones
        var aligned = SmartPortAttachments.AlignInfluencesAndFixedAxes(SmartPortAttachments.Align(targetText, sourceText, rig), sourceText, rig);

        // an ignore_rotation attachment: each influence's bind frame times its angles is the source's
        foreach (var (influence, bone) in new[] { (-1, "pelvis"), (0, "head") })
        {
            var expected = source.RestWorld[source.IndexOf(bone)].Rot * Euler(0, 90, 90);
            var actual = rig.Target.RestWorld[rig.Target.IndexOf(Biped(bone))].Rot * Angles(Node(aligned, "forward_reference_modelspace", influence));
            Assert.True(MathQ.AngleBetween(expected, actual) < .001f, $"{bone}: {MathQ.AngleBetween(expected, actual)}");
            Assert.True(KvValue.DeepEquals(Node(targetText, "forward_reference_modelspace", influence)["relative_origin"],
                Node(aligned, "forward_reference_modelspace", influence)["relative_origin"]));
        }

        // a blended attachment that follows its bones: matches the source in the transferred rest pose, like Align's
        var pose = rig.Transfer(source.Bones.Select(b => b.RestLocal).ToArray());
        var world = new XForm[rig.Target.Count];
        foreach (var bone in rig.Target.Bones)
            world[bone.Index] = bone.ParentIndex < 0 ? pose[bone.Index] : XForm.Compose(world[bone.ParentIndex], pose[bone.Index]);
        foreach (var (influence, bone, angles) in new[] { (-1, "hand_R", Euler(10, 20, 30)), (0, "hand_L", Euler(0, 0, 45)) })
        {
            var expected = source.RestWorld[source.IndexOf(bone)].Rot * angles;
            var actual = world[rig.Target.IndexOf(Biped(bone))].Rot * Angles(Node(aligned, "middle_of_both_hands", influence));
            Assert.True(MathQ.AngleBetween(expected, actual) < .001f, $"{bone}: {MathQ.AngleBetween(expected, actual)}");
        }

        // single-bone attachments stay Align's
        Assert.True(KvValue.DeepEquals(Node(SmartPortAttachments.Align(targetText, sourceText, rig), "eyes"), Node(aligned, "eyes")));
    }

    [Fact]
    public void AttachmentsOnUnmappedBonesAreLeftAsTheyAre()
    {
        var source = Humanoid(n => n, CitizenAxes);
        var target = Humanoid(Biped, BipedAxes);
        var rig = new SmartPortRig(source, target);
        var sourceText = Document(n => n);
        var targetText = Document(n => n == "head" ? "some_other_bone" : Biped(n));
        var aligned = SmartPortAttachments.AlignInfluencesAndFixedAxes(targetText, sourceText, rig);
        Assert.True(KvValue.DeepEquals(Node(targetText, "forward_reference_modelspace", 0), Node(aligned, "forward_reference_modelspace", 0)));
        Assert.False(KvValue.DeepEquals(Node(targetText, "forward_reference_modelspace"), Node(aligned, "forward_reference_modelspace")));
    }
}