Code/WeaponImporter/ArmSolver.cs
namespace WeaponImporter;

/// <summary>
/// Result of an analytic two-bone solve. Both rotations are deltas expressed in the space the
/// input joints were given in (character model space in practice): <see cref="UpperDelta"/>
/// rotates the whole arm about the shoulder, <see cref="LowerDelta"/> is applied afterwards and
/// rotates the forearm (and everything below it) about the corrected elbow.
/// </summary>
public struct ArmSolution
{
    /// <summary>Rotation about the shoulder applied to the upper arm and all its descendants.</summary>
    public Rotation UpperDelta;

    /// <summary>Rotation about the corrected elbow applied after <see cref="UpperDelta"/>.</summary>
    public Rotation LowerDelta;

    /// <summary>Corrected elbow position.</summary>
    public Vector3 Elbow;

    /// <summary>Corrected wrist position (equals the target unless it was out of reach).</summary>
    public Vector3 Wrist;

    /// <summary>Distance from the shoulder to the requested target.</summary>
    public float TargetDistance;

    /// <summary>Full reach of the chain (upper length + lower length).</summary>
    public float Reach;

    /// <summary>How far the wrist stays short of the target (0 when reached).</summary>
    public float Shortfall;
}

/// <summary>
/// Analytic two-bone IK that keeps the animation's own elbow bend: the elbow bends the way the
/// animated arm already bends, so the correction stays small and never flips mid-action.
/// </summary>
public static class ArmSolver
{
    /// <summary>Fraction of full reach the chain is allowed to extend to (avoids a locked elbow).</summary>
    public const float MaxExtension = 0.999f;

    /// <summary>
    /// Solves shoulder / elbow / wrist so the wrist reaches <paramref name="target"/>.
    /// All positions share one space; the returned deltas are in that same space.
    /// </summary>
    public static ArmSolution Solve( Vector3 shoulder, Vector3 elbow, Vector3 wrist, Vector3 target )
        => Solve( shoulder, elbow, wrist, target, null, 0f );

    /// <summary>
    /// As <see cref="Solve(Vector3, Vector3, Vector3, Vector3)"/>, with the elbow leaning toward
    /// <paramref name="elbowHint"/> (a point, same space) by <paramref name="hintWeight"/> (0..1):
    /// a baked elbow direction that keeps the bend steady when the animation's own elbow is
    /// ambiguous (a nearly straight arm).
    /// </summary>
    public static ArmSolution Solve( Vector3 shoulder, Vector3 elbow, Vector3 wrist, Vector3 target, Vector3? elbowHint, float hintWeight )
    {
        var result = new ArmSolution
        {
            UpperDelta = Rotation.Identity,
            LowerDelta = Rotation.Identity,
            Elbow = elbow,
            Wrist = wrist,
        };

        var upper = elbow - shoulder;
        var lower = wrist - elbow;
        float a = upper.Length;
        float b = lower.Length;
        result.Reach = a + b;

        var toTarget = target - shoulder;
        float distance = toTarget.Length;
        result.TargetDistance = distance;

        if ( a < 1e-4f || b < 1e-4f || distance < 1e-4f )
        {
            result.Shortfall = MathF.Max( 0f, distance - result.Reach );
            return result;
        }

        var dir = toTarget / distance;

        float maxReach = MaxExtension * (a + b);
        float minReach = MathF.Max( MathF.Abs( a - b ) * 1.001f + 1e-3f, 1e-3f );
        float d = Math.Clamp( distance, minReach, maxReach );
        result.Shortfall = MathF.Max( 0f, distance - maxReach );

        var pole = BendDirection( upper, lower, dir );
        if ( elbowHint is { } hint && hintWeight > 0f )
        {
            var toHint = hint - shoulder;
            toHint -= dir * Vector3.Dot( toHint, dir );
            // A hint across the animated bend would swing the elbow through the arm: ignore it.
            if ( toHint.LengthSquared > 1e-6f && Vector3.Dot( toHint.Normal, pole ) > -0.3f )
            {
                var blended = pole * (1f - hintWeight) + toHint.Normal * hintWeight;
                blended -= dir * Vector3.Dot( blended, dir );
                if ( blended.LengthSquared > 1e-6f )
                    pole = blended.Normal;
            }
        }

        // Law of cosines for the shoulder angle.
        float cosA = Math.Clamp( (a * a + d * d - b * b) / (2f * a * d), -1f, 1f );
        float sinA = MathF.Sqrt( MathF.Max( 0f, 1f - cosA * cosA ) );

        var newElbow = shoulder + dir * (a * cosA) + pole * (a * sinA);
        var reached = shoulder + dir * d;

        var q1 = FromTo( upper, newElbow - shoulder );
        var movedLower = q1 * lower;
        var q2 = FromTo( movedLower, reached - newElbow );

        result.UpperDelta = q1;
        result.LowerDelta = q2;
        result.Elbow = newElbow;
        result.Wrist = newElbow + q2 * movedLower;
        return result;
    }

    /// <summary>
    /// Unit direction, perpendicular to <paramref name="dir"/>, towards which the elbow bends.
    /// Taken from the animated elbow; falls back to the animated bend plane normal when the
    /// animated elbow lies on the shoulder-target line.
    /// </summary>
    public static Vector3 BendDirection( Vector3 upper, Vector3 lower, Vector3 dir )
    {
        // The animation's own bend: the elbow's offset from the shoulder->animated-wrist line.
        // Measured against the animated wrist (not the target) so it never flips when the target
        // swings past the elbow during an action.
        var reach = upper + lower;
        var animPole = reach.LengthSquared > 1e-8f ? upper - reach.Normal * Vector3.Dot( upper, reach.Normal ) : Vector3.Zero;
        var armLength = upper.Length + lower.Length;

        // Nearly straight arms have no reliable bend; ease toward a relaxed down-and-back elbow.
        var relaxed = Vector3.Down * 0.8f + Vector3.Backward * 0.2f;
        float straightness = armLength > 1e-4f ? Math.Clamp( animPole.Length / (armLength * 0.08f), 0f, 1f ) : 0f;
        var pole = (animPole.LengthSquared > 1e-10f ? animPole.Normal : relaxed) * straightness + relaxed.Normal * (1f - straightness);

        // Perpendicular to the reach direction.
        pole -= dir * Vector3.Dot( pole, dir );
        if ( pole.LengthSquared > 1e-8f )
            return pole.Normal;

        var fallback = Vector3.Down - dir * Vector3.Dot( Vector3.Down, dir );
        if ( fallback.LengthSquared < 1e-6f )
            fallback = Vector3.Forward - dir * Vector3.Dot( Vector3.Forward, dir );
        return fallback.Normal;
    }

    /// <summary>Shortest-arc rotation taking direction <paramref name="from"/> onto <paramref name="to"/>.</summary>
    public static Rotation FromTo( Vector3 from, Vector3 to )
    {
        if ( from.LengthSquared < 1e-12f || to.LengthSquared < 1e-12f )
            return Rotation.Identity;

        var f = from.Normal;
        var t = to.Normal;
        float dot = Vector3.Dot( f, t );

        if ( dot < -0.999999f )
        {
            var axis = Vector3.Cross( f, Vector3.Up );
            if ( axis.LengthSquared < 1e-6f )
                axis = Vector3.Cross( f, Vector3.Forward );
            axis = axis.Normal;
            return new Rotation( axis.x, axis.y, axis.z, 0f );
        }

        // Half-way quaternion: q = (cross, 1 + dot), normalised.
        var c = Vector3.Cross( f, t );
        float w = 1f + dot;
        float len = MathF.Sqrt( c.x * c.x + c.y * c.y + c.z * c.z + w * w );
        return new Rotation( c.x / len, c.y / len, c.z / len, w / len );
    }

    /// <summary>
    /// Twist part of <paramref name="rotation"/> about unit <paramref name="axis"/> (swing-twist
    /// split, rotation = swing * twist). Identity when the rotation has no twist about the axis.
    /// </summary>
    public static Rotation Twist( Rotation rotation, Vector3 axis )
    {
        float d = rotation.x * axis.x + rotation.y * axis.y + rotation.z * axis.z;
        float x = axis.x * d, y = axis.y * d, z = axis.z * d, w = rotation.w;
        float len = MathF.Sqrt( x * x + y * y + z * z + w * w );
        if ( len < 1e-6f )
            return Rotation.Identity;
        return new Rotation( x / len, y / len, z / len, w / len );
    }

    /// <summary>Weighted rotation between identity and <paramref name="delta"/> (shortest path).</summary>
    public static Rotation Weighted( Rotation delta, float weight )
    {
        if ( weight >= 0.9999f )
            return delta;
        if ( weight <= 0.0001f )
            return Rotation.Identity;
        return Slerp( Rotation.Identity, delta, weight );
    }

    /// <summary>Shortest-path spherical interpolation that does not depend on engine helpers.</summary>
    public static Rotation Slerp( Rotation a, Rotation b, float t )
    {
        float dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
        float bx = b.x, by = b.y, bz = b.z, bw = b.w;
        if ( dot < 0f )
        {
            dot = -dot;
            bx = -bx; by = -by; bz = -bz; bw = -bw;
        }

        float s0, s1;
        if ( dot > 0.9995f )
        {
            s0 = 1f - t;
            s1 = t;
        }
        else
        {
            float theta = MathF.Acos( Math.Clamp( dot, -1f, 1f ) );
            float sin = MathF.Sin( theta );
            s0 = MathF.Sin( (1f - t) * theta ) / sin;
            s1 = MathF.Sin( t * theta ) / sin;
        }

        float x = s0 * a.x + s1 * bx;
        float y = s0 * a.y + s1 * by;
        float z = s0 * a.z + s1 * bz;
        float w = s0 * a.w + s1 * bw;
        float len = MathF.Sqrt( x * x + y * y + z * z + w * w );
        if ( len < 1e-12f )
            return a;
        return new Rotation( x / len, y / len, z / len, w / len );
    }

    /// <summary>
    /// Frame-rate independent exponential smoothing factor. <paramref name="smoothing"/> 0 returns 1
    /// (no smoothing); 1 gives a 0.1 s half-life.
    /// </summary>
    public static float SmoothingFactor( float smoothing, float deltaTime )
    {
        if ( smoothing <= 0f )
            return 1f;
        if ( deltaTime <= 0f )
            return 0f;
        float halfLife = Math.Clamp( smoothing, 0f, 1f ) * 0.1f;
        return 1f - MathF.Pow( 2f, -deltaTime / halfLife );
    }
}