HumanoidRetargeter/Cleanup/EffectorIkPass.cs

IK cleanup pass utilities. Defines a LimbChain struct and static EffectorIk class that runs a two-bone IK solve per-frame, converts frames to world space, applies solver world rotation deltas, and folds them back into per-bone local rotations while preserving end-effector world orientation.

Native Interop
using System;
using System.Collections.Generic;
using System.Numerics;
using HumanoidRetargeter.Maths;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;

namespace HumanoidRetargeter.Cleanup;

using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>
/// A three-joint limb chain identified by skeleton bone indices:
/// hip/knee/ankle for a leg, shoulder/elbow/wrist for an arm.
/// <see cref="Lower"/> is expected to be a child of <see cref="Upper"/> and
/// <see cref="End"/> a child of <see cref="Lower"/> (the usual humanoid layout);
/// locals are re-derived against the actual parents, so the solve stays exact
/// for direct parent-child chains.
/// </summary>
public sealed class LimbChain
{
    /// <summary>Upper joint bone index (hip / shoulder).</summary>
    public required int Upper { get; init; }

    /// <summary>Mid joint bone index (knee / elbow).</summary>
    public required int Lower { get; init; }

    /// <summary>End effector bone index (ankle / wrist).</summary>
    public required int End { get; init; }
}

/// <summary>
/// End-effector IK cleanup pass: per frame, runs analytic <see cref="TwoBoneIk"/> on a limb
/// chain and folds the resulting world-space deltas back into the frame's LOCAL rotations.
/// Operates on raw frame lists (one local <see cref="XForm"/> per bone) + a skeleton +
/// explicit bone indices, so it is solver-agnostic and testable with synthetic data.
/// </summary>
/// <remarks>
/// Correction model (rotations only — rest-local translations are preserved):
/// <list type="bullet">
/// <item><see cref="LimbChain.Upper"/> and <see cref="LimbChain.Lower"/> world rotations are
/// premultiplied by the solver's deltas, then re-derived as locals against their parents'
/// world rotations. Because local translations are untouched, the FK positions land exactly
/// on the solver's <c>b' = a + dU*(b−a)</c>, <c>c' = b' + dL*(c−b)</c> convention.</item>
/// <item><see cref="LimbChain.End"/>'s local rotation is counter-rotated by the inverse of
/// the Lower world delta so the effector's WORLD rotation is unchanged — the IK swing must
/// not disturb foot/hand orientation. Descendants (toes, fingers) follow naturally via FK.</item>
/// </list>
/// </remarks>
public static class EffectorIk
{
    /// <summary>
    /// Pulls the chain's end effector onto <paramref name="goalsWorld"/> (one world-space goal
    /// per frame), modifying the frames' local rotations in place.
    /// </summary>
    /// <param name="frames">Per-frame local transforms (skeleton bone order); modified in place.</param>
    /// <param name="skeleton">Bone hierarchy the frames are expressed against.</param>
    /// <param name="chain">Limb chain bone indices.</param>
    /// <param name="goalsWorld">World-space effector goal per frame; must match <paramref name="frames"/> in count.</param>
    /// <param name="stableBendAxis">Hinge axis used when the chain is near-collinear (see <see cref="TwoBoneIk.Solve"/>).</param>
    /// <param name="soften">Reach-softening fraction forwarded to <see cref="TwoBoneIk.Solve"/>.</param>
    public static void ApplyGoals(
        List<XForm[]> frames,
        SkeletonModel skeleton,
        LimbChain chain,
        IReadOnlyList<Vector3> goalsWorld,
        Vector3 stableBendAxis,
        float soften = 0.02f)
    {
        ArgumentNullException.ThrowIfNull(frames);
        ArgumentNullException.ThrowIfNull(skeleton);
        ArgumentNullException.ThrowIfNull(chain);
        ArgumentNullException.ThrowIfNull(goalsWorld);
        if (goalsWorld.Count != frames.Count)
            throw new ArgumentException(
                $"Goal count ({goalsWorld.Count}) must match frame count ({frames.Count}).", nameof(goalsWorld));

        // One FK scratch buffer shared across all frames (perf: this pass used to allocate
        // four world arrays per frame).
        var world = new XForm[skeleton.Count];
        for (int i = 0; i < frames.Count; i++)
        {
            var locals = frames[i];
            FkUtil.ToWorld(locals, skeleton, world);
            var result = TwoBoneIk.Solve(
                world[chain.Upper].Pos, world[chain.Lower].Pos, world[chain.End].Pos,
                goalsWorld[i], soften, stableBendAxis);
            ApplyWorldDeltas(locals, skeleton, chain.Upper, chain.Lower, chain.End,
                result.UpperWorldDelta, result.LowerWorldDelta, world);
        }
    }

    /// <summary>
    /// Folds a pair of world-space rotation deltas (per the <see cref="TwoBoneIk.Result"/>
    /// convention) into a frame's local rotations: Upper and Lower world rotations get the
    /// deltas premultiplied and are re-derived as locals; End's local is compensated so its
    /// world rotation stays exactly what it was before the call. Local translations untouched.
    /// A parentless Lower or End bone breaks the chain assumption (its local IS its world;
    /// there is no parent frame to re-derive against), so the correction is skipped entirely.
    /// </summary>
    /// <param name="worldScratch">Caller-owned FK scratch buffer (≥ skeleton bone count);
    /// overwritten by this call.</param>
    internal static void ApplyWorldDeltas(
        XForm[] locals, SkeletonModel skeleton, int upper, int lower, int end,
        Quaternion upperDelta, Quaternion lowerDelta, XForm[] worldScratch)
    {
        // Guard: lower/end without a parent would index world[-1] below — treat the whole
        // correction as a no-op rather than corrupting part of the chain.
        if (skeleton[lower].ParentIndex < 0 || skeleton[end].ParentIndex < 0)
            return;

        var world = worldScratch;
        FkUtil.ToWorld(locals, skeleton, world);
        var lowerWorldRot0 = world[lower].Rot;
        var endWorldRot0 = world[end].Rot;

        // Upper: premultiply world rotation, re-derive local against (unchanged) parent.
        var upperParent = skeleton[upper].ParentIndex;
        var upperParentRot = upperParent < 0 ? Quaternion.Identity : world[upperParent].Rot;
        var upperWorldRot1 = MathQ.Normalize(upperDelta * world[upper].Rot);
        locals[upper] = new XForm(
            locals[upper].Pos,
            MathQ.Normalize(Quaternion.Conjugate(upperParentRot) * upperWorldRot1));

        // Lower: same, but its parent's world rotation just changed — ancestor-chain walk
        // over the updated locals (bit-identical to a full FK re-run, without allocating).
        var lowerParentRot = FkUtil.BoneWorld(locals, skeleton, skeleton[lower].ParentIndex).Rot;
        var lowerWorldRot1 = MathQ.Normalize(lowerDelta * lowerWorldRot0);
        locals[lower] = new XForm(
            locals[lower].Pos,
            MathQ.Normalize(Quaternion.Conjugate(lowerParentRot) * lowerWorldRot1));

        // End: counter-rotate so its WORLD rotation is unchanged (effector orientation must
        // not be disturbed by the IK swing). For end.parent == lower this is exactly the
        // inverse of the Lower world delta.
        var endParentRot = FkUtil.BoneWorld(locals, skeleton, skeleton[end].ParentIndex).Rot;
        locals[end] = new XForm(
            locals[end].Pos,
            MathQ.Normalize(Quaternion.Conjugate(endParentRot) * endWorldRot0));
    }
}