HumanoidMocap/Motion/FingerContactCorrection.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using HumanoidMocap.Mapping;
using HumanoidMocap.Cleanup;
using HumanoidMocap.Maths;
using HumanoidMocap.Skeleton;
using HumanoidMocap.Solve;
using HumanoidMocap.Target;

namespace HumanoidMocap.Motion;
using Vector3=System.Numerics.Vector3;

/// <summary>Bounded hinge corrections on an already retargeted hand. Objects and wrists
/// are authoritative; only the three selected phalanx rotations can change.</summary>
public static class FingerContactCorrection
{
    sealed record Chain(BoneRole Role,int[] Bones,Vector3[] Axes);
    sealed record Goal(Vector3 Point,Vector3 Target,float Weight,float Limit);
    public static bool IsDistal(BoneRole role)=>role is BoneRole.ThumbDistL or BoneRole.IndexDistL or BoneRole.MiddleDistL
        or BoneRole.RingDistL or BoneRole.PinkyDistL or BoneRole.ThumbDistR or BoneRole.IndexDistR or BoneRole.MiddleDistR or BoneRole.RingDistR or BoneRole.PinkyDistR;
    public static Vector3 TargetAt(ContactInterval contact,FingerContactTarget target,double time)=>MotionDocument.V(target.LocalTarget)
        +(contact.Sliding?ContactSolver.LocalTarget(contact,time)-MotionDocument.V(target.SlideOrigin??contact.LocalTarget):Vector3.Zero);

    public static Vector3? BoneEndpoint(TargetRig rig,BoneRole role,TargetUpAxis axis)
    {
        if(!IsDistal(role)||rig.BoneForRole(role) is not int b||rig.TailWorldOf(b) is not Vector3 tail)return null;
        var local=XForm.Compose(rig.Skeleton.RestWorld[b].Inverse(),new(tail,Quaternion.Identity)).Pos;
        return local/(axis==TargetUpAxis.ZUpEngine?39.3700787f:100f);
    }

    public static void Apply(List<XForm[]> frames,TargetRig rig,TargetUpAxis axis,TargetCorrectionSettings settings,PropContactMotion contacts,float fps)
    {
        if(string.IsNullOrEmpty(settings.ContactTargetKey))return;
        var bindings=contacts.Contacts.Where(c=>c.Review==ContactReview.Confirmed)
            .SelectMany(c=>c.FingerTargets.Where(t=>t.TargetKey==settings.ContactTargetKey).Select(t=>(Contact:c,Target:t))).ToArray();
        if(bindings.Length==0)return;
        if((long)bindings.Length*frames.Count>1_000_000)throw new ArgumentException("Too many finger contact intervals for this clip. Use a shorter range or fewer intervals.");
        var map=new MappingResult("Target contact anatomy",MappingSource.Authored);
        foreach(var bone in rig.Skeleton.Bones)if(rig.RoleOf(bone.Index) is {} role)map.RoleToBone[role]=bone.Index;
        var rest=rig.Skeleton.RestWorld;var chains=new List<Chain>();
        foreach(var role in bindings.Select(b=>b.Target.Role).Distinct().OrderBy(r=>r))
        {
            var name=role.ToString();var side=name.EndsWith("L",StringComparison.Ordinal)?"L":"R";var stem=name[..^5];
            var roles=new[]{stem+"Prox"+side,stem+"Mid"+side,stem+"Dist"+side}.Select(Enum.Parse<BoneRole>).ToArray();
            var bones=roles.Select(r=>rig.BoneForRole(r)??-1).ToArray();
            if(bones.Any(b=>b<0)||rig.Skeleton[bones[1]].ParentIndex!=bones[0]||rig.Skeleton[bones[2]].ParentIndex!=bones[1])
                throw new ArgumentException("Finger contact needs a directly connected proximal/middle/distal chain: "+role);
            var dorsal=HandGeometry.Dorsal(map,rest,side=="L")??throw new ArgumentException("Finger contact needs a nondegenerate target palm.");
            var axes=new Vector3[3];
            for(var j=0;j<3;j++)
            {
                var direction=j<2?rest[bones[j+1]].Pos-rest[bones[j]].Pos:rest[bones[2]].Pos-rest[bones[1]].Pos;
                var hinge=Vector3.Cross(dorsal,direction);
                if(hinge.LengthSquared()<1e-10f)throw new ArgumentException("Degenerate finger contact hinge: "+roles[j]);
                axes[j]=Vector3.Transform(Vector3.Normalize(hinge),Quaternion.Inverse(rest[bones[j]].Rot));
            }
            chains.Add(new(role,bones,axes));
        }
        // Each active chain uses at most 19 whole-skeleton FK evaluations per frame.
        if((long)frames.Count*chains.Count*rig.Skeleton.Count*19>50_000_000)
            throw new ArgumentException("Finger contact correction exceeds its work budget. Use a shorter range or fewer constrained fingers.");
        var placement=CapturePlacement.ForTarget(axis,settings);var world=new XForm[rig.Skeleton.Count];
        var fade=new ContactSettings();
        for(var f=0;f<frames.Count;f++)
        {
            var time=Math.Min(contacts.StartTime+f/(double)fps,contacts.EndTime);var frame=frames[f];
            foreach(var chain in chains)
            {
                var goals=new List<Goal>();
                foreach(var binding in bindings.Where(b=>b.Target.Role==chain.Role).OrderBy(b=>b.Contact.Object,StringComparer.Ordinal).ThenBy(b=>b.Contact.ObjectBone,StringComparer.Ordinal).ThenBy(b=>b.Contact.Start))
                {
                    var weight=ContactSolver.Weight(binding.Contact,time,fade);if(weight<=0)continue;
                    if(!contacts.TrySample(binding.Contact.Object,time,out var props,out var available))continue;
                    var prop=contacts.Objects.Single(p=>p.Id==binding.Contact.Object);
                    var bone=string.IsNullOrEmpty(binding.Contact.ObjectBone)?0:prop.Bones.FindIndex(b=>b.Name==binding.Contact.ObjectBone);
                    if(bone<0||!available[bone])continue;
                    var t=binding.Target;
                    goals.Add(new(MotionDocument.V(t.LocalPoint)*placement.Units,
                        placement.Transform(XForm.Compose(props[bone],new(TargetAt(binding.Contact,t,time),Quaternion.Identity))).Pos,
                        weight,t.MaximumDegrees*MathF.PI/180));
                }
                if(goals.Count==0)continue;
                var original=chain.Bones.Select(b=>frame[b].Rot).ToArray();var angles=new float[3];
                var limit=goals.Min(g=>g.Limit);var activation=Math.Min(1,goals.Sum(g=>g.Weight));
                FkUtil.ToWorld(frame,rig.Skeleton,world);
                for(var iteration=0;iteration<6;iteration++)for(var j=2;j>=0;j--)
                {
                    var bone=chain.Bones[j];var pivot=world[bone].Pos;var hinge=Vector3.Transform(chain.Axes[j],world[bone].Rot);
                    float cross=0,dot=0;
                    foreach(var goal in goals)
                    {
                        var point=XForm.Compose(world[chain.Bones[2]],new(goal.Point,Quaternion.Identity)).Pos-pivot;
                        var target=goal.Target-pivot;point-=hinge*Vector3.Dot(point,hinge);target-=hinge*Vector3.Dot(target,hinge);
                        cross+=Vector3.Dot(hinge,Vector3.Cross(point,target))*goal.Weight;dot+=Vector3.Dot(point,target)*goal.Weight;
                    }
                    if(!float.IsFinite(cross)||!float.IsFinite(dot))throw new ArgumentException("Finger contact values exceed the numeric range. Check point and object coordinates in metres.");
                    if(Math.Abs(cross)+Math.Abs(dot)<1e-10f)continue;
                    angles[j]=Math.Clamp(angles[j]+MathF.Atan2(cross,dot),-limit,limit);
                    frame[bone].Rot=Quaternion.Normalize(original[j]*Quaternion.CreateFromAxisAngle(chain.Axes[j],angles[j]));
                    FkUtil.ToWorld(frame,rig.Skeleton,world);
                }
                for(var j=0;j<3;j++)frame[chain.Bones[j]].Rot=Quaternion.Normalize(Quaternion.Slerp(original[j],frame[chain.Bones[j]].Rot,activation));
            }
        }
    }
}