Editor/WeaponImporter/Core/Setup/ActionContacts.cs
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Grip;
using WeaponImporter.Core.Hands;
using WeaponImporter.Core.Ik;
using WeaponImporter.Core.Maths;

namespace WeaponImporter.Core.Setup;

using Vector3 = System.Numerics.Vector3;

/// <summary>A character action sampled over its normalized time (see CharacterPoser.SampleTrack).</summary>
public sealed record ActionTrack(AnimationRole Role, float Seconds, IReadOnlyList<(float Time, CharacterPose Pose)> Samples);

/// <summary>Result of planning the support hand over the actions.</summary>
public sealed class ActionContactPlan
{
    /// <summary>Support hand on the magazine (weapon space), when the weapon has one.</summary>
    public HandPose? Magazine { get; set; }
    public GripRegion? MagazineRegion { get; set; }
    public Dictionary<AnimationRole, ContactPlanner.Plan> Plans { get; } = new();
}

/// <summary>
/// Plans the support hand over the character's actions once the grip is solved: where the
/// animated hand goes relative to the weapon (it follows the hold bone), where it should touch
/// this weapon instead (the magazine during reloads), and the contact keys that get it there with
/// the smallest change to the animation. Pure maths; the samples come from the editor.
/// </summary>
public static class ActionContacts
{
    public static ActionContactPlan Plan(WeaponAnalysis weapon, WeaponSetup setup, CharacterRig character, GripSolution grip, IReadOnlyList<ActionTrack> tracks, GripOptions? options = null, CancellationToken cancel = default)
    {
        var plan = new ActionContactPlan();
        if (grip.Left is null || character.Left is not { } leftRig || !setup.UseSupportHand)
            return plan;
        var region = GripRegions.Magazine(weapon);
        plan.MagazineRegion = region;

        // Animated support wrist in weapon space, per sample.
        List<ContactPlanner.Sample> Samples(ActionTrack track) => track.Samples
            .Select(s => new ContactPlanner.Sample(s.Time, XForm.ToLocal(GripSolver.WeaponWorld(s.Pose, character, grip.WeaponInHold), s.Pose.World[leftRig.Hand])))
            .ToList();

        var reloads = tracks.Where(t => ActionTiming.ReloadRoles.Contains(t.Role)).ToList();
        if (!setup.ReloadTouchesMagazine)
        {
            // Default: the character reloads as animated; the hand only lets go and comes back.
            foreach (var track in reloads)
                plan.Plans[track.Role] = ContactPlanner.PlanRelease(Samples(track), track.Seconds);
            return plan;
        }
        if (region?.Surface is not null && reloads.Count > 0)
        {
            // Fit the magazine grab closest to where the animation brings the hand.
            var first = Samples(reloads[0]);
            var nearest = first.OrderBy(s => Vector3.Distance(s.Wrist.Pos, region.Surface.Contact)).First();
            plan.Magazine = GripSolver.SolveOnRegion(weapon, region, character, grip, nearest.Wrist, options, cancel);
        }

        foreach (var track in reloads)
        {
            cancel.ThrowIfCancellationRequested();
            if (plan.Magazine is null)
                break;
            plan.Plans[track.Role] = ContactPlanner.PlanReload(Samples(track), grip.Left.Wrist, plan.Magazine.Wrist, track.Seconds, weapon.WeaponBounds);
        }
        return plan;
    }

    /// <summary>Writes the planned tracks into the setup, leaving tracks the user edited alone.</summary>
    public static void Apply(WeaponSetup setup, ActionContactPlan plan)
    {
        foreach (var (role, p) in plan.Plans)
        {
            if (setup.Contacts.TryGetValue(role, out var existing) && existing.Keys.Count > 0 && !existing.Generated)
                continue;
            setup.Contacts[role] = new ContactTrack { Keys = p.Keys, Generated = true, Note = p.Note };
        }
    }
}