OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / KeyframeTypeTests.cs

349 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Linq;

using IFSEngine.Animation;

using Newtonsoft.Json;

using Xunit;

namespace IFSRenderer.AvaloniaShell.Tests;

/// <summary>
/// Blender-style keyframe types and bezier handles.
/// </summary>
/// <remarks>
/// <para>
/// Reported: "if you make value 0, to value 1 then for a few frames hold value 1, then go to
/// value 2, it's impossible to make it stay at value 1 in those frames because it wobbles up
/// and down [...] 5s and 7s, the keys are identical, if the values are identical, why does the
/// focus distance change?"
/// </para>
/// <para>
/// The answer was <see cref="InterpolationMode.CatmullRom"/>, which every keyframe gets by
/// default: its tangent is <c>(p2 - p0) / 2</c>, taken from the neighbours' VALUES alone
/// (Channel.cs:94). It never flattens, so two equal keys with unequal neighbours still get a
/// slope and the segment has to leave the held value and come back. It also ignores the time
/// axis, so uneven key spacing overshoots.
/// </para>
/// <para>
/// Blender does not have this problem because its default is Bezier with AUTO CLAMPED handles,
/// which zeroes the tangent at a local extremum. These tests pin that behaviour.
/// </para>
/// </remarks>
public class KeyframeTypeTests
{
    /// <summary>
    /// Builds a channel the way the app does - through the ◇ diamond - so the defaults under
    /// test are the ones a user actually gets, not ones the test chose.
    /// </summary>
    private static Channel Keyed(params (double T, double Value)[] keys)
    {
        var sheet = new Dopesheet { Length = TimeSpan.FromSeconds(12), Fps = 30 };
        foreach ((double t, double v) in keys)
            sheet.AddOrUpdateChannel("Focus", "Camera.FocusDistance",
                TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), v);
        return sheet.Channels["Camera.FocusDistance"];
    }

    private static IEnumerable<double> Across(Channel c, double from, double to, int n = 400) =>
        Enumerable.Range(0, n + 1).Select(i => c.EvaluateAt(from + (to - from) * i / n));

    [Fact]
    public void AHoldBetweenTwoEqualKeysStaysFlat()
    {
        Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2));

        double worst = Across(c, 5, 7).Max(v => Math.Abs(v - 1.0));

        Assert.True(worst < 1e-9,
            $"held value drifted by {worst:P2} between its two identical keyframes");
    }

    [Fact]
    public void TheHoldIsFlatWithoutFlatteningTheRampsAroundIt()
    {
        Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2));

        // The ramps still move, monotonically, all the way to their endpoints.
        Assert.True(c.EvaluateAt(4) > 0 && c.EvaluateAt(4) < 1);
        Assert.True(c.EvaluateAt(8) > 1 && c.EvaluateAt(8) < 2);
        Assert.Equal(0, c.EvaluateAt(3), 9);
        Assert.Equal(2, c.EvaluateAt(9), 9);
    }

    [Fact]
    public void ARampDoesNotOvershootItsEndpoints()
    {
        // Uneven spacing: the old tangent ignored dt entirely, so the tight pair overshot.
        Channel c = Keyed((0, 0), (1, 1), (9, 2));

        double max = Across(c, 0, 9).Max();
        Assert.True(max <= 2 + 1e-9, $"curve reached {max}, past its highest keyframe (2)");
    }

    // ---------------------------------------------------------------- every preset

    public static TheoryData<KeyframeType> AllPresets()
    {
        var data = new TheoryData<KeyframeType>();
        foreach (KeyframeType t in Enum.GetValues<KeyframeType>())
            if (t != KeyframeType.Legacy)
                data.Add(t);
        return data;
    }

    /// <summary>
    /// Every preset but <see cref="KeyframeType.Constant"/>, which by definition does NOT
    /// approach the next keyframe's value - it holds its own until the next key is reached.
    /// </summary>
    public static TheoryData<KeyframeType> InterpolatingPresets()
    {
        var data = new TheoryData<KeyframeType>();
        foreach (KeyframeType t in Enum.GetValues<KeyframeType>())
            if (t is not (KeyframeType.Legacy or KeyframeType.Constant))
                data.Add(t);
        return data;
    }

    /// <summary>
    /// The property <see cref="EasingDirection.Out"/> violated for years: a segment has to run
    /// from its own keyframe's value to the next one's. <c>1 - t^p</c> is 1 at t=0, so every
    /// segment played backwards, at every power, and nothing caught it.
    /// </summary>
    /// <remarks>
    /// The tolerance is 0.5% of the segment's 10-unit range rather than an exact match, because
    /// <see cref="KeyframeType.Circular"/>'s ease-in is vertical at its end: a millionth of a
    /// second from the last keyframe it is still legitimately 1.4e-4 short, and tightening this
    /// past that would be asserting something untrue about a circle. The bug this guards
    /// against missed by the FULL range, not by a rounding error.
    /// </remarks>
    [Theory]
    [MemberData(nameof(InterpolatingPresets))]
    public void EverySegmentRunsFromItsStartValueToItsEndValue(KeyframeType type)
    {
        foreach (EasingDirection direction in Enum.GetValues<EasingDirection>())
        {
            Channel c = Typed(type, direction, (0, -5), (10, 5));

            Assert.True(Math.Abs(c.EvaluateAt(0.000001) - -5) < 0.05,
                $"{type}/{direction} starts at {c.EvaluateAt(0.000001)}, not -5");
            Assert.True(Math.Abs(c.EvaluateAt(9.999999) - 5) < 0.05,
                $"{type}/{direction} ends at {c.EvaluateAt(9.999999)}, not 5");
        }
    }

    /// <summary>
    /// <see cref="KeyframeType.Exponential"/> specifically: Penner's form is 2^-10 at t=0, so
    /// it starts a thousandth of the range ABOVE its own keyframe unless normalized.
    /// </summary>
    [Fact]
    public void ExponentialStartsExactlyAtItsKeyframe()
    {
        Channel c = Typed(KeyframeType.Exponential, EasingDirection.In, (0, 0), (10, 1));

        // Un-normalized this is 2^-10 = 0.00098 immediately off the keyframe, a million times
        // the bound below; normalized it decays with the offset.
        Assert.True(c.EvaluateAt(1e-9) < 1e-9, $"exponential starts at {c.EvaluateAt(1e-9)}");
    }

    [Theory]
    [MemberData(nameof(AllPresets))]
    public void NoPresetProducesANonFiniteValue(KeyframeType type)
    {
        foreach (EasingDirection direction in Enum.GetValues<EasingDirection>())
        {
            Channel c = Typed(type, direction, (0, -5), (3, 12), (4, 12), (10, 0));
            Assert.All(Across(c, -1, 11), v => Assert.True(double.IsFinite(v), $"{type}/{direction} produced {v}"));
        }
    }

    [Fact]
    public void ConstantHoldsTheLeftKeyRightUpToTheNextOne()
    {
        Channel c = Typed(KeyframeType.Constant, EasingDirection.InOut, (0, 3), (10, 9));

        Assert.All(Across(c, 0, 9.99), v => Assert.Equal(3, v, 9));
        Assert.Equal(9, c.EvaluateAt(10), 9);
    }

    [Fact]
    public void LinearIsAStraightLine()
    {
        Channel c = Typed(KeyframeType.Linear, EasingDirection.InOut, (0, 0), (10, 10));

        for (double t = 0; t <= 10; t += 0.5)
            Assert.Equal(t, c.EvaluateAt(t), 9);
    }

    /// <summary>Blender's Back and Elastic are meant to leave the range; the rest are not.</summary>
    [Theory]
    [InlineData(KeyframeType.Sinusoidal)]
    [InlineData(KeyframeType.Quadratic)]
    [InlineData(KeyframeType.Cubic)]
    [InlineData(KeyframeType.Exponential)]
    [InlineData(KeyframeType.Circular)]
    [InlineData(KeyframeType.Bounce)]
    public void AnEasingPresetStaysWithinItsEndpoints(KeyframeType type)
    {
        Channel c = Typed(type, EasingDirection.InOut, (0, 0), (10, 1));

        Assert.All(Across(c, 0, 10), v => Assert.InRange(v, -1e-9, 1 + 1e-9));
    }

    // ---------------------------------------------------------------- handles

    [Fact]
    public void VectorHandlesMakeASegmentStraight()
    {
        Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
        foreach (Keyframe k in c.Keyframes)
            k.LeftHandleType = k.RightHandleType = HandleType.Vector;

        for (double t = 0; t <= 10; t += 0.5)
            Assert.Equal(t, c.EvaluateAt(t), 6);
    }

    /// <summary>
    /// Automatic smooths through the key and is ALLOWED to overshoot; auto-clamped is the one
    /// that does not. Both must still be time-aware, which the Catmull-Rom tangent was not.
    /// </summary>
    [Fact]
    public void AutoClampedDoesNotOvershootWhereAutomaticMay()
    {
        Channel clamped = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (1, 10), (9, 10.5));
        foreach (Keyframe k in clamped.Keyframes)
            k.LeftHandleType = k.RightHandleType = HandleType.AutoClamped;

        Assert.All(Across(clamped, 0, 9), v => Assert.InRange(v, -1e-9, 10.5 + 1e-9));
    }

    [Fact]
    public void DraggingAHandleChangesTheCurveItself()
    {
        Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
        double before = c.EvaluateAt(3);

        Keyframe first = c.Keyframes[0];
        first.RightHandleType = HandleType.Free;
        first.RightHandleT = 3;
        first.RightHandleValue = 8;

        Assert.NotEqual(before, c.EvaluateAt(3), 3);
        // ...and it still starts and ends where its keyframes say.
        Assert.Equal(0, c.EvaluateAt(0), 9);
        Assert.Equal(10, c.EvaluateAt(10), 9);
    }

    /// <summary>
    /// Handles that reach past the segment would fold the curve back and stop it being a
    /// function of time. They are shortened before the solve, so the result stays single-valued
    /// and monotonic in time.
    /// </summary>
    [Fact]
    public void OverlongHandlesDoNotFoldTheCurve()
    {
        Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10));
        Keyframe a = c.Keyframes[0], b = c.Keyframes[1];
        a.RightHandleType = b.LeftHandleType = HandleType.Free;
        a.RightHandleT = 40;
        a.RightHandleValue = 0;
        b.LeftHandleT = -30;
        b.LeftHandleValue = 10;

        List<double> vs = Across(c, 0, 10).ToList();
        Assert.All(vs, v => Assert.True(double.IsFinite(v)));
        for (int i = 1; i < vs.Count; i++)
            Assert.True(vs[i] >= vs[i - 1] - 1e-6, $"curve went backwards at sample {i}");
    }

    // ---------------------------------------------------------------- file format

    /// <summary>
    /// A keyframe from a file written before any of this existed has no Type property, so it
    /// deserializes to <see cref="KeyframeType.Legacy"/> and evaluates through the old path.
    /// Existing animations must not change shape because the app was updated.
    /// </summary>
    [Fact]
    public void AKeyframeWithoutATypeIsLegacyAndEvaluatesAsItAlwaysDid()
    {
        const string legacy = """
        {"Name":"Brightness","Keyframes":[
          {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":0.0,"Value":0.0},
          {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":5.0,"Value":1.0},
          {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":7.0,"Value":1.0},
          {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":9.0,"Value":2.0}],
         "AudioChannelDriver":null}
        """;

        Channel c = JsonConvert.DeserializeObject<Channel>(legacy);

        Assert.All(c.Keyframes, k => Assert.Equal(KeyframeType.Legacy, k.Type));
        // Including the wobble - this is the OLD behaviour, preserved on purpose.
        Assert.True(Across(c, 5, 7).Max(v => Math.Abs(v - 1.0)) > 0.04);
    }

    /// <summary>
    /// A legacy keyframe must round-trip byte-identically, or every existing .ifsjson grows
    /// eleven fields the moment it is opened and saved.
    /// </summary>
    [Fact]
    public void ALegacyKeyframeGainsNoFieldsWhenWritten()
    {
        string json = JsonConvert.SerializeObject(new Keyframe { t = 1, Value = 2 });

        Assert.Equal("""{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":1.0,"Value":2.0}""", json);
    }

    /// <summary>
    /// The upstream Windows app knows nothing about <see cref="KeyframeType"/> and its
    /// evaluator ends in <c>throw new NotImplementedException()</c>, so every preset must still
    /// leave a legacy InterpolationMode it can read.
    /// </summary>
    [Theory]
    [MemberData(nameof(AllPresets))]
    public void EveryPresetLeavesAnInterpolationModeUpstreamCanRead(KeyframeType type)
    {
        var k = new Keyframe { t = 0, Value = 0, Type = type };

        Assert.InRange((int)k.InterpolationMode, 0, 2);
        Assert.Contains($"\"InterpolationMode\":{(int)k.InterpolationMode}",
            JsonConvert.SerializeObject(k));
    }

    [Fact]
    public void ABezierKeyframeWritesItsTypeAndHandles()
    {
        var k = new Keyframe
        {
            t = 1, Value = 2,
            Type = KeyframeType.Bezier,
            LeftHandleType = HandleType.Free,
            RightHandleType = HandleType.Free,
            LeftHandleT = 0.5, LeftHandleValue = 1.5,
            RightHandleT = 1.5, RightHandleValue = 2.5,
        };

        Keyframe back = JsonConvert.DeserializeObject<Keyframe>(JsonConvert.SerializeObject(k));

        Assert.Equal(KeyframeType.Bezier, back.Type);
        Assert.Equal(HandleType.Free, back.LeftHandleType);
        Assert.Equal(0.5, back.LeftHandleT, 9);
        Assert.Equal(2.5, back.RightHandleValue, 9);
    }

    /// <summary>Builds a channel whose keyframes all carry one preset.</summary>
    private static Channel Typed(KeyframeType type, EasingDirection direction,
        params (double T, double Value)[] keys)
    {
        Channel c = Keyed(keys);
        foreach (Keyframe k in c.Keyframes)
        {
            k.Type = type;
            k.EasingDirection = direction;
        }
        return c;
    }
}