HumanoidMocap/Motion/ContactSolver.cs
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidMocap.Maths;

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

public readonly record struct ContactSample(double Time,Vector3 Hand, XForm Object,
    Vector3 ClosestSurfacePoint, float FingerClosure, bool Observed,Vector3? Wrist=null);

public sealed class ContactSettings
{
    public float EnterDistance { get; set; } = .025f;
    public float ExitDistance { get; set; } = .05f;
    public float MaximumRelativeSpeed { get; set; } = .15f;
    public double MinimumPersistence { get; set; } = .12;
    public double ReleasePersistence { get; set; } = .08;
    public double BlendSeconds { get; set; } = .08;
    public double MaximumSampleGap { get; set; } = .1;
}

/// <summary>Suggestions require an explicit object track and actual surface samples.
/// The object is authoritative; solving never changes its transform in response to a hand.</summary>
public static class ContactSolver
{
    public static List<ContactInterval> Suggest(IReadOnlyList<ContactSample> samples,string hand,string prop,ContactSettings s)
    {
        if(!float.IsFinite(s.EnterDistance+s.ExitDistance+s.MaximumRelativeSpeed)||!double.IsFinite(s.MinimumPersistence+s.ReleasePersistence+s.MaximumSampleGap)
            ||s.EnterDistance<=0 || s.ExitDistance<=s.EnterDistance || s.MaximumRelativeSpeed<=0||s.MinimumPersistence<0 || s.ReleasePersistence<0||s.MaximumSampleGap<=0)
            throw new ArgumentException("Invalid contact hysteresis settings.");
        var result=new List<ContactInterval>();int candidate=-1,active=-1,release=-1;Vector3 local=default;
        for(var i=0;i<samples.Count;i++)
        {
            var x=samples[i];
            if(i>0 && x.Time<=samples[i-1].Time)throw new ArgumentException("Contact times must increase.");
            // Loss of observations is not evidence of a continuing grip. Never bridge
            // an occlusion or unsampled interval using temporal hysteresis.
            if(!x.Observed||(i>0&&x.Time-samples[i-1].Time>s.MaximumSampleGap))
            {
                if(active>=0)Add(Math.Max(active,release>=0?release-1:i-1));
                active=candidate=release=-1;
                if(!x.Observed)continue;
            }
            var inv=x.Object.Inverse();var p=XForm.Compose(inv,new XForm(x.Hand,Quaternion.Identity)).Pos;
            var speed=i==0||!samples[i-1].Observed||x.Time-samples[i-1].Time>s.MaximumSampleGap?float.PositiveInfinity:(p-XForm.Compose(samples[i-1].Object.Inverse(),new XForm(samples[i-1].Hand,Quaternion.Identity)).Pos).Length()/(float)(x.Time-samples[i-1].Time);
            var distance=Vector3.Distance(x.Hand,x.ClosestSurfacePoint);
            bool enter=x.Observed && distance<s.EnterDistance && speed<s.MaximumRelativeSpeed && x.FingerClosure>.25f;
            bool stay=x.Observed && distance<s.ExitDistance && speed<s.MaximumRelativeSpeed*2&&x.FingerClosure>.15f;
            if(active<0)
            {
                if(!enter){candidate=-1;continue;}
                if(candidate<0)candidate=i;
                if(x.Time-samples[candidate].Time>=s.MinimumPersistence)
                {
                    active=candidate;local=XForm.Compose(samples[active].Object.Inverse(),new XForm(samples[active].Wrist??samples[active].ClosestSurfacePoint,Quaternion.Identity)).Pos;
                }
            }
            else if(stay)release=-1;
            else
            {
                if(release<0)release=i;
                if(x.Time-samples[release].Time>=s.ReleasePersistence)
                {
                    Add(Math.Max(active,release-1));active=candidate=release=-1;
                }
            }
        }
        if(active>=0)Add(release>=0?Math.Max(active,release-1):samples.Count-1);
        return result;
        void Add(int end) => result.Add(new ContactInterval { Bone=hand,Object=prop,Start=samples[active].Time,
            End=samples[end].Time,LocalTarget=MotionDocument.A(local),Review=ContactReview.Suggested,
            Reason="Proximity, relative speed, finger closure and persistence; requires review. Not a calibrated probability." });
    }

    public static Vector3 Apply(Vector3 freeHand,XForm authoritativeObject,ContactInterval contact,double time,ContactSettings settings,
        Vector3? movingLocalTarget=null)
    {
        if(contact.Review!=ContactReview.Confirmed || time<contact.Start || time>contact.End)return freeHand;
        var local=contact.Sliding && movingLocalTarget is { } moving?moving:LocalTarget(contact,time);
        var target=XForm.Compose(authoritativeObject,new XForm(local,Quaternion.Identity)).Pos;
        return Vector3.Lerp(freeHand,target,Weight(contact,time,settings));
    }

    public static float Weight(ContactInterval contact,double time,ContactSettings settings)
    {
        if(contact.Review!=ContactReview.Confirmed||time<contact.Start||time>contact.End)return 0;
        var fade=Math.Max(settings.BlendSeconds,1e-5);
        var t=(float)Math.Clamp(Math.Min((time-contact.Start)/fade,(contact.End-time)/fade),0,1);
        return t*t*(3-2*t);
    }

    public static Vector3 LocalTarget(ContactInterval contact,double time)
    {
        if(!contact.Sliding)return MotionDocument.V(contact.LocalTarget);
        var keys=contact.TargetKeys;
        if(keys.Count<2)throw new ArgumentException("Sliding contacts need object-local target keys.");
        if(time<=keys[0].Time)return MotionDocument.V(keys[0].Position);
        for(var i=1;i<keys.Count;i++)if(time<=keys[i].Time)
            return Vector3.Lerp(MotionDocument.V(keys[i-1].Position),MotionDocument.V(keys[i].Position),
                (float)((time-keys[i-1].Time)/(keys[i].Time-keys[i-1].Time)));
        return MotionDocument.V(keys[^1].Position);
    }
}