Code/HumanoidRetargeter/Cleanup/TwoBoneIk.cs

Analytic two-bone inverse kinematics solver operating on world-space positions that returns two world-space rotation deltas for the upper and lower joints. It computes hinge axis, handles near-collinear fallback, applies soft reach clamping, and produces quaternions to move the end effector to a target.

using System;
using System.Numerics;
using HumanoidRetargeter.Maths;

namespace HumanoidRetargeter.Cleanup;

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

/// <summary>
/// Analytic two-bone IK (Holden's formulation) with soft reach clamping.
/// Operates purely on world-space positions and returns world-space rotation deltas,
/// keeping it independent of any particular skeleton convention.
/// </summary>
public static class TwoBoneIk
{
    /// <summary>
    /// World-space rotation deltas produced by <see cref="Solve"/>.
    /// Application convention (used by tests and callers):
    ///   b' = a + UpperWorldDelta * (b - a)
    ///   c' = b' + LowerWorldDelta * (c - b)
    /// i.e. <see cref="UpperWorldDelta"/> premultiplies the upper joint's world rotation and
    /// <see cref="LowerWorldDelta"/> premultiplies the lower joint's world rotation.
    /// </summary>
    public readonly struct Result
    {
        public Quaternion UpperWorldDelta { get; init; }
        public Quaternion LowerWorldDelta { get; init; }
    }

    /// <summary>
    /// Solves the chain a→b→c to bring the end effector c onto <paramref name="target"/>.
    /// </summary>
    /// <param name="a">Upper joint world position (hip/shoulder).</param>
    /// <param name="b">Mid joint world position (knee/elbow).</param>
    /// <param name="c">End effector world position (ankle/wrist).</param>
    /// <param name="target">Desired world position for c.</param>
    /// <param name="soften">
    /// Fraction of full extension where soft clamping begins (0 disables). Within the soft
    /// zone the reach approaches full extension asymptotically, removing the "snap" at lockout.
    /// </param>
    /// <param name="stableBendAxis">
    /// Bend axis to use when the chain is near-collinear (its natural bend plane is undefined).
    /// </param>
    public static Result Solve(
        Vector3 a, Vector3 b, Vector3 c, Vector3 target,
        float soften = 0.02f, Vector3? stableBendAxis = null)
    {
        const float Eps = 1e-5f;

        float lab = Vector3.Distance(a, b);
        float lcb = Vector3.Distance(b, c);
        float maxExt = lab + lcb;

        // Degenerate zero-length chain: nothing can rotate to reach anywhere — report an
        // identity result instead of letting the reach clamp below throw (min > max).
        if (maxExt < 1e-4f)
            return new Result { UpperWorldDelta = Quaternion.Identity, LowerWorldDelta = Quaternion.Identity };

        var at = target - a;
        float dist = at.Length();
        if (dist < Eps)
            return new Result { UpperWorldDelta = Quaternion.Identity, LowerWorldDelta = Quaternion.Identity };

        float lat = SoftClamp(dist, maxExt, soften);
        lat = Math.Clamp(lat, Eps, maxExt - Eps * maxExt);

        var ab = b - a;
        var ac = c - a;
        var bc = c - b;

        // Bend axis: the chain's natural bend plane normal, or the caller-provided stable axis
        // when the chain is straight enough that the cross product degenerates.
        var axisRaw = Vector3.Cross(ac, ab);
        Vector3 axis0;
        if (axisRaw.Length() < Eps * lab * lcb || axisRaw.Length() < 1e-8f)
        {
            var fallback = stableBendAxis ?? MathQ.Perpendicular(Vector3.Normalize(ac));
            // Project to be orthogonal to ac so the hinge is well-defined.
            var proj = fallback - Vector3.Dot(fallback, Vector3.Normalize(ac)) * Vector3.Normalize(ac);
            axis0 = proj.Length() > 1e-8f ? Vector3.Normalize(proj) : MathQ.Perpendicular(Vector3.Normalize(ac));
        }
        else
        {
            axis0 = Vector3.Normalize(axisRaw);
        }

        float lac = ac.Length();
        if (lac < Eps)
            lac = Eps;

        // Current interior angles (atan2-based — stays accurate near straight chains
        // where acos of the dot product is ill-conditioned).
        float acAb0 = MathQ.AngleBetween(ac, ab);
        float baBc0 = MathQ.AngleBetween(-ab, bc);

        // Desired interior angles from the law of cosines for chain length lat.
        float acAb1 = MathF.Acos(Math.Clamp((lcb * lcb - lab * lab - lat * lat) / (-2f * lab * lat), -1f, 1f));
        float baBc1 = MathF.Acos(Math.Clamp((lat * lat - lab * lab - lcb * lcb) / (-2f * lab * lcb), -1f, 1f));

        var r0 = Quaternion.CreateFromAxisAngle(axis0, acAb1 - acAb0);
        var r1 = Quaternion.CreateFromAxisAngle(axis0, baBc1 - baBc0);

        // Swing the (now correctly extended) chain so the effector direction hits the target.
        var r2 = MathQ.FromTo(ac, at);

        var upper = MathQ.Normalize(r2 * r0);
        var lower = MathQ.Normalize(r2 * r0 * r1);
        return new Result { UpperWorldDelta = upper, LowerWorldDelta = lower };
    }

    /// <summary>
    /// Monotonic soft clamp: identity below the soft boundary, then asymptotic approach to
    /// <paramref name="max"/> (never reaching it) — ozz-style reach softening.
    /// </summary>
    private static float SoftClamp(float d, float max, float soften)
    {
        soften = Math.Clamp(soften, 0f, 0.5f);
        float softStart = max * (1f - soften);
        if (soften <= 0f)
            return Math.Min(d, max);
        if (d <= softStart)
            return d;
        float range = max - softStart;
        return softStart + range * (1f - MathF.Exp(-(d - softStart) / range));
    }

}