Target/HandTargetFit.cs
#nullable enable
using System.Numerics;
using HumanoidHandRetargeter.Mapping;
using Skel = HumanoidHandRetargeter.Skeleton.Skeleton;

namespace HumanoidHandRetargeter.Target;
using Vector3 = System.Numerics.Vector3;

/// <summary>Conservative FPS import corrections measured in compiled model centimeters.</summary>
public sealed record HandTargetFit(float Scale, bool TurnAround)
{
    public static HandTargetFit Analyze(Skel rig, HandMappingResult mapping)
    {
        // Digit uncertainty must not prevent fitting a well-defined arm chain.
        // Validate the arm evidence independently, including side uniqueness.
        bool Valid(int i)=>i>=0&&i<rig.Count;
        var arms=mapping.Hands.Where(h=>mapping.Hands.Count(other=>other.Side==h.Side)==1
            &&Enum.IsDefined(typeof(HandSide),h.Side)
            &&h.UpperArm is int upper&&h.Forearm is int lower&&Valid(upper)&&Valid(lower)&&Valid(h.Wrist)
            &&upper!=lower&&lower!=h.Wrist&&rig.DescendsFrom(lower,upper)&&rig.DescendsFrom(h.Wrist,lower)).ToArray();
        var scale=1f;
        if(arms.Length>0)
        {
            var lengths=arms.Select(h=>Vector3.Distance(rig.RestWorld[h.UpperArm!.Value].Pos,rig.RestWorld[h.Forearm!.Value].Pos)
                +Vector3.Distance(rig.RestWorld[h.Forearm!.Value].Pos,rig.RestWorld[h.Wrist].Pos)).ToArray();
            var length=lengths.Average();
            // Leave ordinary proportions intact. Test common metric and inch unit
            // conversions rather than assuming every export error is a power of ten.
            // Require agreement between both sides before changing the entire mesh.
            if(float.IsFinite(length)&&length>1e-6f&&lengths.Max()/lengths.Min()<1.5f&&(length<25||length>85))
            {
                var factors=Enumerable.Range(-4,9).SelectMany(power=>new[]{1f,2.54f,1f/2.54f}.Select(unit=>unit*MathF.Pow(10,power)));
                var candidate=factors.OrderBy(f=>MathF.Abs(MathF.Log(length*f/55))).First();
                if(length*candidate>=35&&length*candidate<=85)scale=candidate;
            }
        }
        var left=arms.FirstOrDefault(h=>h.Side==HandSide.Left);
        var right=arms.FirstOrDefault(h=>h.Side==HandSide.Right);
        var turn=false;
        if(left is not null&&right is not null)
        {
            var lateral=rig.RestWorld[left.UpperArm!.Value].Pos-rig.RestWorld[right.UpperArm!.Value].Pos;
            // s&box uses +Y on the character's left. A reversed lateral axis puts
            // each shoulder behind the opposite weapon hand. Two mirrors are a
            // proper 180-degree turn, preserving handedness and skin winding.
            turn=lateral.LengthSquared()>1e-8f&&lateral.Y/lateral.Length()<-.9f;
        }
        return new(scale,turn);
    }

    public string Apply(string vmdl)
    {
        var document=Kv3.Parse(vmdl);
        var root=(KvObject)((KvObject)document.Root).GetOrNull("rootNode")!;
        void Visit(KvObject node)
        {
            if(node.GetString("_class")=="ModelModifier_ScaleAndMirror")
            {
                // New imports use the common cm-to-inches modifier. Apply the
                // correction to meshes, bind pose and every embedded take together.
                node["scale"]=new KvDouble(Scale/2.54);
                node["mirror_x"]=new KvBool(TurnAround);
                node["mirror_y"]=new KvBool(TurnAround);
            }
            if(node.GetOrNull("children") is KvArray children)
                foreach(var child in children.Items.OfType<KvObject>())Visit(child);
        }
        Visit(root);
        return Kv3.Serialize(document);
    }
}