OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / IFSEngine / Animation / BezierHandles.cs

183 lines · csharp ref:
using System;
using System.Collections.Generic;

namespace IFSEngine.Animation;

/// <summary>A handle position, in the channel's own (time, value) space.</summary>
public readonly struct Handle(double t, double value)
{
    public double T { get; } = t;
    public double Value { get; } = value;
}

/// <summary>
/// Derives and evaluates the bezier handles of <see cref="KeyframeType.Bezier"/> keyframes.
/// </summary>
/// <remarks>
/// <para>
/// Handles for <see cref="HandleType.Automatic"/>, <see cref="HandleType.AutoClamped"/> and
/// <see cref="HandleType.Vector"/> are DERIVED here on every read rather than cached on the
/// keyframe. Nothing then has to remember to invalidate them: <see cref="Keyframe"/> is a plain
/// mutable object in a plain <see cref="List{T}"/>, so a moved key raises no notification, and
/// a cache would go stale the first time one was dragged. The work is O(1) per segment against
/// an evaluator that already sorts the whole channel on every call.
/// </para>
/// <para>
/// The stored coordinates are only authoritative for <see cref="HandleType.Free"/> and
/// <see cref="HandleType.Aligned"/>, which are the two the user positions.
/// </para>
/// </remarks>
public static class BezierHandles
{
    /// <summary>
    /// The outgoing (right) handle of <paramref name="index"/> and the incoming (left) handle
    /// of the key after it - the two control points of that segment.
    /// </summary>
    public static (Handle Right, Handle Left) ForSegment(List<Keyframe> sorted, int index)
    {
        Handle right = Resolve(sorted, index, leftSide: false);
        Handle left = Resolve(sorted, index + 1, leftSide: true);
        return Correct(sorted[index], right, left, sorted[index + 1]);
    }

    /// <summary>One handle of one keyframe, derived or stored according to its handle type.</summary>
    public static Handle Resolve(List<Keyframe> sorted, int index, bool leftSide)
    {
        Keyframe k = sorted[index];
        HandleType type = leftSide ? k.LeftHandleType : k.RightHandleType;

        if (type is HandleType.Free or HandleType.Aligned)
            return leftSide
                ? new Handle(k.LeftHandleT, k.LeftHandleValue)
                : new Handle(k.RightHandleT, k.RightHandleValue);

        Keyframe prev = index > 0 ? sorted[index - 1] : null;
        Keyframe next = index < sorted.Count - 1 ? sorted[index + 1] : null;

        // A third of the way to the neighbour is the length at which a cubic bezier with a
        // matching slope reproduces the straight line through both keys.
        double dtPrev = prev is not null ? (k.t - prev.t) / 3.0 : (next is not null ? (next.t - k.t) / 3.0 : 0);
        double dtNext = next is not null ? (next.t - k.t) / 3.0 : (prev is not null ? (k.t - prev.t) / 3.0 : 0);

        if (type == HandleType.Vector)
        {
            // Straight at the neighbour, which makes that side of the segment a line.
            double vPrev = prev is not null ? k.Value + (prev.Value - k.Value) / 3.0 : k.Value;
            double vNext = next is not null ? k.Value + (next.Value - k.Value) / 3.0 : k.Value;
            return leftSide
                ? new Handle(k.t - dtPrev, vPrev)
                : new Handle(k.t + dtNext, vNext);
        }

        double slope = Slope(prev, k, next, type == HandleType.AutoClamped);

        if (leftSide)
        {
            double v = k.Value - slope * dtPrev;
            if (type == HandleType.AutoClamped && prev is not null)
                v = Clamp(v, prev.Value, k.Value);
            return new Handle(k.t - dtPrev, v);
        }
        else
        {
            double v = k.Value + slope * dtNext;
            if (type == HandleType.AutoClamped && next is not null)
                v = Clamp(v, k.Value, next.Value);
            return new Handle(k.t + dtNext, v);
        }
    }

    /// <summary>
    /// The tangent through a keyframe: a TIME-AWARE central difference, unlike
    /// <c>Channel</c>'s Catmull-Rom tangent, which is <c>(p2 - p0) / 2</c> from the neighbours'
    /// values alone and so both overshoots on uneven spacing and never flattens.
    /// </summary>
    /// <remarks>
    /// <paramref name="clamped"/> is the whole reason a hold works: where the key is a local
    /// extremum - both neighbours on the same side of it, which two equal keys in a row always
    /// are - the tangent is zero, so the segment leaves the value flat and stays there.
    /// </remarks>
    private static double Slope(Keyframe prev, Keyframe k, Keyframe next, bool clamped)
    {
        if (prev is null && next is null)
            return 0;
        if (prev is null)
            return Secant(k, next);
        if (next is null)
            return Secant(prev, k);

        if (clamped && IsLocalExtremum(prev.Value, k.Value, next.Value))
            return 0;

        double span = next.t - prev.t;
        return span > 0 ? (next.Value - prev.Value) / span : 0;
    }

    private static double Secant(Keyframe a, Keyframe b) =>
        b.t - a.t > 0 ? (b.Value - a.Value) / (b.t - a.t) : 0;

    /// <summary>True when both neighbours sit on the same side of the key - a peak, a trough, or a hold.</summary>
    private static bool IsLocalExtremum(double prev, double value, double next) =>
        (prev <= value && next <= value) || (prev >= value && next >= value);

    private static double Clamp(double v, double a, double b) =>
        Math.Clamp(v, Math.Min(a, b), Math.Max(a, b));

    /// <summary>
    /// Shortens handles that reach past the segment in TIME, which would make the curve fold
    /// back on itself and stop being a function of time. Both handles scale by the same factor,
    /// so the shape is preserved. This is Blender's <c>correct_bezpart</c>.
    /// </summary>
    private static (Handle, Handle) Correct(Keyframe k1, Handle right, Handle left, Keyframe k2)
    {
        double h1t = right.T - k1.t, h1v = right.Value - k1.Value;
        double h2t = left.T - k2.t, h2v = left.Value - k2.Value;

        double reach = Math.Abs(h1t) + Math.Abs(h2t);
        double span = Math.Abs(k2.t - k1.t);
        if (reach <= span || reach <= 0)
            return (right, left);

        double fac = span / reach;
        return (new Handle(k1.t + h1t * fac, k1.Value + h1v * fac),
                new Handle(k2.t + h2t * fac, k2.Value + h2v * fac));
    }

    /// <summary>
    /// The value of a cubic bezier segment at a given TIME.
    /// </summary>
    /// <remarks>
    /// A bezier is parameterised by u, not by time, so the time has to be inverted first.
    /// After <see cref="Correct"/> the x component is monotonic over the segment, which makes
    /// bisection both correct and unconditionally convergent - worth more here than the speed
    /// of a Newton step that can leave [0,1] on a near-vertical segment.
    /// </remarks>
    public static double EvaluateSegment(Keyframe k1, Handle right, Handle left, Keyframe k2, double t)
    {
        double span = k2.t - k1.t;
        if (span <= 0)
            return k2.Value;

        double lo = 0, hi = 1;
        for (int i = 0; i < 60; i++)
        {
            double mid = (lo + hi) / 2.0;
            if (Cubic(k1.t, right.T, left.T, k2.t, mid) < t)
                lo = mid;
            else
                hi = mid;
        }

        return Cubic(k1.Value, right.Value, left.Value, k2.Value, (lo + hi) / 2.0);
    }

    private static double Cubic(double p0, double p1, double p2, double p3, double u)
    {
        double n = 1.0 - u;
        return n * n * n * p0
             + 3.0 * n * n * u * p1
             + 3.0 * n * u * u * p2
             + u * u * u * p3;
    }
}