HumanoidMocap/Core/Motion/FingerContactCorrection.cs

Applies bounded hinge corrections to retargeted finger chains (proximal, middle, distal) so distal phalanx endpoints better match contact targets. It builds chains per finger, computes hinge axes from palm geometry, samples contact goals per frame, runs iterative per-joint hinge adjustments with FK updates, and blends corrections by goal weight and limits.

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using HumanoidMocap.Core.Mapping;
using HumanoidMocap.Core.Cleanup;
using HumanoidMocap.Core.Maths;
using HumanoidMocap.Core.Skeleton;
using HumanoidMocap.Core.Solve;
using HumanoidMocap.Core.Target;

namespace HumanoidMocap.Core.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));
            }
        }
    }
}