HumanoidRetargeter/Target/SmartPortRig.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Solve;
using HumanoidRetargeter.Cleanup;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;
using NVector3 = System.Numerics.Vector3;

namespace HumanoidRetargeter.Target;

/// <summary>Fits a source animation rig around an unchanged target skin skeleton.</summary>
public sealed class SmartPortRig
{
    public SkeletonModel Source { get; }
    public SkeletonModel Target { get; }
    public IReadOnlyDictionary<string, string> BoneNames => names;
    public float MotionScale { get; }
    readonly Dictionary<string, string> names = new(StringComparer.Ordinal);
    readonly RuntimePoseRetargeter solver;
    readonly int[] sourceIndices;
    readonly int[] sourceParents;
    readonly HashSet<int> roleBones;
    readonly XForm[] reference;
    readonly int[] limbEnds;
    readonly HashSet<int> copiedHelpers;
    readonly HashSet<int> handSockets = new();
    readonly Dictionary<int, (int TargetHand, int Upper, int Lower, NVector3 SourcePalm, NVector3 TargetPalm)> palms = new();
    readonly List<(int SourceGoal, int SourceEnd, int TargetGoal, int TargetEnd, bool CrossHand)> ikGoals = new();

    public SmartPortRig(SkeletonModel source, SkeletonModel target, IReadOnlyDictionary<string, string>? ikTargets = null,
        IReadOnlyCollection<string>? attachmentBones = null)
    {
        Source = source;
        foreach (var bone in source.Bones.Concat(target.Bones))
            if (!float.IsFinite(bone.RestLocal.Pos.LengthSquared()) || !float.IsFinite(bone.RestLocal.Rot.LengthSquared())
                || bone.RestLocal.Rot.LengthSquared() < .0001f) throw new ArgumentException("Invalid bind transform: " + bone.Name);
        var sourceMap = Map(source);
        var targetMap = Map(target);
        var required = new[] { BoneRole.Hips, BoneRole.Head, BoneRole.UpperArmL, BoneRole.UpperArmR,
            BoneRole.LowerArmL, BoneRole.LowerArmR, BoneRole.HandL, BoneRole.HandR,
            BoneRole.UpperLegL, BoneRole.UpperLegR, BoneRole.LowerLegL, BoneRole.LowerLegR, BoneRole.FootL, BoneRole.FootR };
        foreach (var role in required)
            if (!sourceMap.RoleToBone.ContainsKey(role) || !targetMap.RoleToBone.ContainsKey(role))
                throw new ArgumentException($"Smart Port could not match humanoid role {role} on both models.");
        limbEnds = new[] { BoneRole.LowerArmL, BoneRole.LowerArmR, BoneRole.HandL, BoneRole.HandR,
            BoneRole.LowerLegL, BoneRole.LowerLegR, BoneRole.FootL, BoneRole.FootR }.Select(r => sourceMap.RoleToBone[r]).ToArray();
        var lookup = target.Bones.ToDictionary(b => b.Name, b => b.Name, StringComparer.OrdinalIgnoreCase);
        foreach (var bone in source.Bones)
            if (lookup.TryGetValue(bone.Name, out var match)) names.Add(bone.Name, match);
        foreach (var (role, index) in sourceMap.RoleToBone)
            if (targetMap.RoleToBone.TryGetValue(role, out var other)) names[source[index].Name] = target[other].Name;
        if (names.Values.Distinct(StringComparer.Ordinal).Count() != names.Count)
            throw new ArgumentException("Smart Port bone mapping is ambiguous.");

        float LegLength(SkeletonModel rig, MappingResult map) => new[] { BoneRole.LowerLegL, BoneRole.LowerLegR, BoneRole.FootL, BoneRole.FootR }
            .Sum(role => rig[map.RoleToBone[role]].RestLocal.Pos.Length());
        MotionScale = LegLength(target, targetMap) / LegLength(source, sourceMap);
        if (!float.IsFinite(MotionScale) || MotionScale <= 0) throw new ArgumentException("Invalid humanoid proportions.");
        var definitions = target.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : target[b.ParentIndex].Name, b.RestLocal)).ToList();
        // Graph-only IK, weapon and extra spine bones are retained, never substituted for skin bones.
        foreach (var bone in source.Bones)
        {
            if (names.ContainsKey(bone.Name)) continue;
            if (lookup.ContainsKey(bone.Name)) throw new ArgumentException("Conflicting graph bone: " + bone.Name);
            names.Add(bone.Name, bone.Name);
            definitions.Add(new BoneDefinition(bone.Name, bone.ParentIndex < 0 ? null : names[source[bone.ParentIndex].Name],
                new XForm(bone.RestLocal.Pos * MotionScale, bone.RestLocal.Rot)));
        }
        Target = SkeletonModel.Create(definitions);
        copiedHelpers = Target.Bones.Where(b => target.IndexOf(b.Name) < 0).Select(b => b.Index).ToHashSet();
        var mapped = new MappingResult(targetMap.ProfileName, targetMap.Source);
        foreach (var (role, index) in targetMap.RoleToBone) mapped.RoleToBone.Add(role, Target.IndexOf(target[index].Name));
        roleBones = mapped.RoleToBone.Values.ToHashSet();
        solver = new RuntimePoseRetargeter(source, sourceMap, TargetRig.FromSkeleton(Target, mapped));
        sourceIndices = Enumerable.Repeat(-1, Target.Count).ToArray();
        foreach (var bone in source.Bones) sourceIndices[Target.IndexOf(names[bone.Name])] = bone.Index;
        if (attachmentBones is not null)
        foreach (var side in new[] { "L", "R" })
        {
            var handRole = Enum.Parse<BoneRole>("Hand" + side);
            var sh = sourceMap.RoleToBone[handRole];
            var th = mapped.RoleToBone[handRole];
            int SocketRoot(int index)
            {
                while (index >= 0)
                {
                    // An existing fitted socket (or intermediate helper) is authoritative.
                    if (!copiedHelpers.Contains(Target.IndexOf(names[source[index].Name]))) return -1;
                    if (source[index].ParentIndex == sh) return index;
                    index = source[index].ParentIndex;
                }
                return -1;
            }
            var sockets = attachmentBones.Select(source.IndexOf).Select(SocketRoot).Where(i => i >= 0)
                .Select(i => Target.IndexOf(names[source[i].Name])).ToArray();
            if (sockets.Length == 0) continue;
            var knuckles = new[] { "IndexProx", "MiddleProx", "RingProx" }.Select(n => Enum.Parse<BoneRole>(n + side))
                .Where(r => sourceMap.RoleToBone.ContainsKey(r) && mapped.RoleToBone.ContainsKey(r)).ToArray();
            if (knuckles.Length < 2) continue; // Insufficient hand geometry: keep the existing transfer.
            var sp = knuckles.Select(r => source.RestWorld[sourceMap.RoleToBone[r]].Pos)
                .Aggregate(NVector3.Zero, (a, b) => a + b) / knuckles.Length;
            var tp = knuckles.Select(r => Target.RestWorld[mapped.RoleToBone[r]].Pos)
                .Aggregate(NVector3.Zero, (a, b) => a + b) / knuckles.Length;
            palms.Add(sh, (th, mapped.RoleToBone[Enum.Parse<BoneRole>("UpperArm" + side)],
                mapped.RoleToBone[Enum.Parse<BoneRole>("LowerArm" + side)],
                source.RestWorld[sh].Inverse().TransformPoint(sp), Target.RestWorld[th].Inverse().TransformPoint(tp)));
            handSockets.UnionWith(sockets);
        }
        sourceParents = sourceIndices.Select(i => i < 0 ? -1 : source[i].ParentIndex).ToArray();
        foreach (var bone in Target.Bones)
        {
            if (bone.ParentIndex < 0 || sourceIndices[bone.ParentIndex] < 0) continue;
            var parent = sourceIndices[bone.ParentIndex];
            for (var ancestor = sourceParents[bone.Index]; ancestor >= 0; ancestor = source[ancestor].ParentIndex)
                if (ancestor == parent) { sourceParents[bone.Index] = parent; break; }
        }
        if (ikTargets is not null)
            foreach (var (goal, end) in ikTargets)
            {
                var sourceGoal = source.IndexOf(goal);
                var sourceEnd = source.IndexOf(end);
                if (sourceGoal < 0 || sourceEnd < 0) throw new ArgumentException("IK goal or effector is missing from the source skeleton.");
                var targetGoal = Target.IndexOf(names[goal]);
                if (roleBones.Contains(targetGoal)) continue; // An actual skin joint can also be used as an IK target.
                var hands = new[] { sourceMap.RoleToBone[BoneRole.HandL], sourceMap.RoleToBone[BoneRole.HandR] };
                var parent = Source[sourceGoal].ParentIndex;
                ikGoals.Add((sourceGoal, sourceEnd, targetGoal, Target.IndexOf(names[end]),
                    copiedHelpers.Contains(targetGoal) && parent != sourceEnd && hands.Contains(parent) && hands.Contains(sourceEnd)));
            }
        reference = TransferAbsolute(source.Bones.Select(b => b.RestLocal).ToArray(), false);
    }

    /// <summary>Modern compiled additive sequences may omit the legacy delta flag.
    /// Their limb translations are offsets near zero, not complete anatomical lengths.</summary>
    public bool IsDeltaPose(XForm[] pose)
    {
        if (pose.Length != Source.Count) throw new ArgumentException("Wrong source bone count.");
        var offsets = limbEnds.Count(i => Source[i].RestLocal.Pos.LengthSquared() > .0001f
            && pose[i].Pos.LengthSquared() < Source[i].RestLocal.Pos.LengthSquared() * .0625f);
        return offsets >= limbEnds.Length * 3 / 4;
    }

    static MappingResult Map(SkeletonModel skeleton)
    {
        var map = Retargeter.ResolveMapping(skeleton).Map;
        // A known rig family can still have extra/renamed joints (for example head_0).
        foreach (var (role, bone) in AutoMapper.Map(skeleton).RoleToBone)
            if (!map.RoleToBone.ContainsKey(role) && !map.RoleToBone.ContainsValue(bone)) map.RoleToBone.Add(role, bone);
        return map;
    }

    /// <summary>Retarget absolute poses and additive deltas separately; a zero delta stays zero.</summary>
    public XForm[] Transfer(XForm[] pose, bool delta = false)
    {
        if (pose.Length != Source.Count) throw new ArgumentException("Wrong source bone count.");
        if (!delta) return TransferAbsolute(pose);
        var absolute = new XForm[pose.Length];
        for (var i = 0; i < pose.Length; i++)
            absolute[i] = new XForm(Source[i].RestLocal.Pos + pose[i].Pos,
                Quaternion.Normalize(Source[i].RestLocal.Rot * pose[i].Rot));
        // Additive goal channels act on an already fitted grip. Re-solving them
        // against the bind pose invents goal translations during arm-only recoil.
        var result = TransferAbsolute(absolute, false);
        for (var i = 0; i < result.Length; i++)
            result[i] = new XForm(result[i].Pos - reference[i].Pos,
                Quaternion.Normalize(Quaternion.Conjugate(reference[i].Rot) * result[i].Rot));
        return result;
    }

    XForm[] TransferAbsolute(XForm[] pose, bool fitGoals = true)
    {
        var result = new XForm[Target.Count];
        solver.Retarget(pose, result);
        for (var i = 0; i < result.Length; i++)
        {
            if (roleBones.Contains(i)) continue;
            var s = sourceIndices[i];
            if (s < 0) { result[i] = Target[i].RestLocal; continue; }
            var sp = sourceParents[i];
            var tp = Target[i].ParentIndex;
            var local = pose[s];
            var rest = Source[s].RestLocal;
            // A matching helper can have fewer ancestors on the target. Fold in the
            // skipped source drivers (e.g. an animated weapon pivot above a grip).
            for (var ancestor = Source[s].ParentIndex; ancestor != sp; ancestor = Source[ancestor].ParentIndex)
            {
                local = XForm.Compose(pose[ancestor], local);
                rest = XForm.Compose(Source[ancestor].RestLocal, rest);
            }
            var basis = sp < 0 || tp < 0 ? Quaternion.Identity
                : Quaternion.Normalize(Quaternion.Conjugate(Target.RestWorld[tp].Rot) * Source.RestWorld[sp].Rot);
            var delta = Quaternion.Normalize(local.Rot * Quaternion.Conjugate(rest.Rot));
            result[i] = new XForm(Target[i].RestLocal.Pos + NVector3.Transform(local.Pos - rest.Pos, basis) * MotionScale,
                Quaternion.Normalize(basis * delta * Quaternion.Conjugate(basis) * Target[i].RestLocal.Rot));
        }
        if (fitGoals && (ikGoals.Count > 0 || copiedHelpers.Count > 0))
        {
            var sourceWorld = World(Source, pose);
            FitGripReach(result, sourceWorld);
            var targetWorld = new XForm[Target.Count];
            foreach (var bone in Target.Bones)
            {
                var parent = bone.ParentIndex;
                if (copiedHelpers.Contains(bone.Index))
                {
                    // Newly copied graph frames have no fitted skin bind to preserve.
                    // Keep their animated model-space axes and offset from the mapped
                    // parent; source local axes can be unrelated to a custom hand's axes.
                    var s = sourceIndices[bone.Index];
                    var frame = sourceWorld[s];
                    frame.Pos = parent < 0 ? frame.Pos * MotionScale : targetWorld[parent].Pos
                        + (frame.Pos - sourceWorld[Source[s].ParentIndex].Pos) * MotionScale;
                    if (handSockets.Contains(bone.Index)) frame.Pos += PalmShift(Source[s].ParentIndex, sourceWorld, targetWorld);
                    result[bone.Index] = parent < 0 ? frame : XForm.ToLocal(targetWorld[parent], frame);
                }
                targetWorld[bone.Index] = parent < 0 ? result[bone.Index] : XForm.Compose(targetWorld[parent], result[bone.Index]);
            }
            foreach (var (sourceGoal, sourceEnd, targetGoal, targetEnd, crossHand) in ikGoals)
            {
                // Goals are effector frames, not skin joints: retaining their target bind
                // offsets can lock a hand in mid-air when the rigs have different rest poses.
                var offset = XForm.ToLocal(sourceWorld[sourceEnd], sourceWorld[sourceGoal]);
                offset.Pos *= MotionScale;
                var goal = XForm.Compose(targetWorld[targetEnd], offset);
                // A newly copied support-hand goal is anchored to the other hand.
                // Keep that grip separation; fitting it back to the free arm loses contact.
                if (crossHand) goal.Pos = targetWorld[targetGoal].Pos
                    + PalmShift(Source[sourceGoal].ParentIndex, sourceWorld, targetWorld) - PalmShift(sourceEnd, sourceWorld, targetWorld);
                var parent = Target[targetGoal].ParentIndex;
                result[targetGoal] = parent < 0 ? goal : XForm.ToLocal(targetWorld[parent], goal);
            }
        }
        return result;
    }

    static XForm[] World(SkeletonModel skeleton, XForm[] pose)
    {
        var world = new XForm[skeleton.Count];
        foreach (var bone in skeleton.Bones)
            world[bone.Index] = bone.ParentIndex < 0 ? pose[bone.Index] : XForm.Compose(world[bone.ParentIndex], pose[bone.Index]);
        return world;
    }

    // Preserve the source socket's relation to the knuckles, not just to the wrist.
    // Only newly copied attachment bones are fitted; existing target sockets stay authoritative.
    NVector3 PalmShift(int sourceHand, XForm[] sourceWorld, XForm[] targetWorld)
        => palms.TryGetValue(sourceHand, out var palm)
            ? targetWorld[palm.TargetHand].TransformVector(palm.TargetPalm)
                - sourceWorld[sourceHand].TransformVector(palm.SourcePalm) * MotionScale
            : NVector3.Zero;

    void FitGripReach(XForm[] pose, XForm[] sourceWorld)
    {
        if (palms.Count != 2) return;
        var contact = ikGoals.FirstOrDefault(g => g.CrossHand
            && NVector3.Distance(sourceWorld[g.SourceGoal].Pos, sourceWorld[g.SourceEnd].Pos) < .01f);
        if (!contact.CrossHand) return;
        var anchor = Source[contact.SourceGoal].ParentIndex;
        var hands = new[] { palms[anchor], palms[contact.SourceEnd] };
        var world = World(Target, pose);
        var anchored = world[hands[0].TargetHand].Pos;
        var goals = new[] { anchored, anchored + (sourceWorld[contact.SourceGoal].Pos - sourceWorld[anchor].Pos) * MotionScale
            + PalmShift(anchor, sourceWorld, world) - PalmShift(contact.SourceEnd, sourceWorld, world) };
        var radii = hands.Select(h => (NVector3.Distance(world[h.Upper].Pos, world[h.Lower].Pos)
            + NVector3.Distance(world[h.Lower].Pos, world[h.TargetHand].Pos)) * .98f).ToArray();
        if (NVector3.Distance(goals[1], world[hands[1].Upper].Pos) <= radii[1]) return;
        // Keep the source support arm's bend reserve for graph blends and recoil.
        // Merely fitting an almost straight arm leaves no room for locomotion layers.
        var sourceUpper = sourceIndices[hands[1].Upper];
        var sourceLower = sourceIndices[hands[1].Lower];
        var sourceReach = NVector3.Distance(sourceWorld[sourceUpper].Pos, sourceWorld[sourceLower].Pos)
            + NVector3.Distance(sourceWorld[sourceLower].Pos, sourceWorld[contact.SourceEnd].Pos);
        if (sourceReach < .0001f) return;
        var extension = NVector3.Distance(sourceWorld[sourceUpper].Pos, sourceWorld[contact.SourceEnd].Pos) / sourceReach;
        radii[1] *= MathF.Min(1, extension / .98f);
        // Move the shared grip into both arms' reach. Leave a small elbow margin,
        // like the existing IK solver, and never stretch or change local translations.
        var shift = NVector3.Zero;
        for (var step = 0; step < 8; step++)
        for (var i = 0; i < hands.Length; i++)
        {
            var offset = goals[i] + shift - world[hands[i].Upper].Pos;
            var distance = offset.Length();
            if (distance > radii[i]) shift -= offset * (1 - radii[i] / distance);
        }
        // Some source spans cannot fit this body at all. Do not distort its arms.
        if (hands.Where((h, i) => NVector3.Distance(goals[i] + shift, world[h.Upper].Pos) > radii[i] + .001f).Any()) return;
        for (var i = 0; i < hands.Length; i++)
        {
            var h = hands[i];
            world = World(Target, pose);
            var ik = TwoBoneIk.Solve(world[h.Upper].Pos, world[h.Lower].Pos, world[h.TargetHand].Pos, goals[i] + shift, soften: 0);
            EffectorIk.ApplyWorldDeltas(pose, Target, h.Upper, h.Lower, h.TargetHand,
                ik.UpperWorldDelta, ik.LowerWorldDelta, world);
        }
    }
}