| … | … | @@ -0,0 +1,139 @@ |
| 1 | using System; | |
| 2 | using System.Linq; | |
| 3 | ||
| 4 | using Avalonia; | |
| 5 | using Avalonia.Controls; | |
| 6 | using Avalonia.Headless.XUnit; | |
| 7 | using Avalonia.VisualTree; | |
| 8 | ||
| 9 | using IFSEngine.Animation; | |
| 10 | using IFSEngine.Model; | |
| 11 | ||
| 12 | using Xunit; | |
| 13 | ||
| 14 | namespace IFSRenderer.AvaloniaShell.Tests; | |
| 15 | ||
| 16 | /// <summary> | |
| 17 | /// The keyframe type dropdown, as a user reaches it. | |
| 18 | /// </summary> | |
| 19 | /// <remarks> | |
| 20 | /// Half of what was reported - "I got no control over it" - was never a missing feature. The | |
| 21 | /// interpolation controls existed, hidden until a keyframe is selected, and on Windows they are | |
| 22 | /// behind a right-click on the dope diamond. So the test that matters is not that the model | |
| 23 | /// supports a type, but that SELECTING A KEYFRAME PUTS THE CONTROL ON SCREEN. | |
| 24 | /// </remarks> | |
| 25 | public class KeyframeEditorUiTests | |
| 26 | { | |
| 27 | private static (AnimationPanel Panel, Channel Channel, CurveCanvas Curve) Panel() | |
| 28 | { | |
| 29 | var fractal = new IFS(); | |
| 30 | foreach ((double t, double v) in new[] { (3.0, 0.0), (5.0, 1.0), (7.0, 1.0), (9.0, 2.0) }) | |
| 31 | fractal.Dopesheet.AddOrUpdateChannel("Focus", "Camera.FocusDistance", | |
| 32 | TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), v); | |
| 33 | ||
| 34 | var panel = new AnimationPanel(); | |
| 35 | var window = new Window { Width = 1000, Height = 600, Content = panel }; | |
| 36 | window.Show(); | |
| 37 | panel.SetFractal(fractal); | |
| 38 | window.Measure(new Size(1000, 600)); | |
| 39 | window.Arrange(new Rect(0, 0, 1000, 600)); | |
| 40 | ||
| 41 | CurveCanvas curve = panel.GetVisualDescendants().OfType<CurveCanvas>().Single(); | |
| 42 | return (panel, fractal.Dopesheet.Channels["Camera.FocusDistance"], curve); | |
| 43 | } | |
| 44 | ||
| 45 | private static ComboBox TypeCombo(AnimationPanel panel) => | |
| 46 | panel.GetVisualDescendants().OfType<ComboBox>() | |
| 47 | .Single(c => c.ItemsSource is not null | |
| 48 | && c.ItemsSource.Cast<object>().Any(i => i.ToString() == "Bezier")); | |
| 49 | ||
| 50 | [AvaloniaFact] | |
| 51 | public void TheKeyframeEditorIsHiddenUntilAKeyframeIsSelected() | |
| 52 | { | |
| 53 | (AnimationPanel panel, Channel channel, CurveCanvas curve) = Panel(); | |
| 54 | ComboBox combo = TypeCombo(panel); | |
| 55 | ||
| 56 | Assert.Null(curve.SelectedKeyframe); | |
| 57 | Assert.False(combo.IsEffectivelyVisible); | |
| 58 | ||
| 59 | curve.BeginDrag(channel.Keyframes[1], new Point(0, 0)); | |
| 60 | curve.EndDrag(); | |
| 61 | Relayout(panel); | |
| 62 | ||
| 63 | Assert.True(combo.IsEffectivelyVisible, | |
| 64 | "selecting a keyframe did not reveal the type dropdown"); | |
| 65 | } | |
| 66 | ||
| 67 | /// <summary>Visibility changes only take effect once the tree has been measured again.</summary> | |
| 68 | private static void Relayout(AnimationPanel panel) | |
| 69 | { | |
| 70 | Window window = panel.GetVisualRoot() as Window; | |
| 71 | window!.Measure(new Size(1000, 600)); | |
| 72 | window.Arrange(new Rect(0, 0, 1000, 600)); | |
| 73 | } | |
| 74 | ||
| 75 | [AvaloniaFact] | |
| 76 | public void TheDropdownOffersBlendersPresetsAndSetsTheSelectedKeyframesType() | |
| 77 | { | |
| 78 | (AnimationPanel panel, Channel channel, CurveCanvas curve) = Panel(); | |
| 79 | Keyframe key = channel.Keyframes[1]; | |
| 80 | ||
| 81 | curve.BeginDrag(key, new Point(0, 0)); | |
| 82 | curve.EndDrag(); | |
| 83 | ||
| 84 | ComboBox combo = TypeCombo(panel); | |
| 85 | string[] labels = combo.ItemsSource!.Cast<object>().Select(i => i.ToString()).ToArray(); | |
| 86 | ||
| 87 | // Blender's own menu, in its own order. | |
| 88 | Assert.Equal( | |
| 89 | [ | |
| 90 | "Constant", "Linear", "Bezier", | |
| 91 | "Sinusoidal", "Quadratic", "Cubic", "Quartic", "Quintic", "Exponential", "Circular", | |
| 92 | "Back", "Bounce", "Elastic", | |
| 93 | "Legacy", | |
| 94 | ], | |
| 95 | labels); | |
| 96 | ||
| 97 | // A keyed value starts on Blender's default, which is what stops the hold wobbling. | |
| 98 | Assert.Equal(KeyframeType.Bezier, key.Type); | |
| 99 | ||
| 100 | combo.SelectedIndex = Array.IndexOf(labels, "Constant"); | |
| 101 | ||
| 102 | Assert.Equal(KeyframeType.Constant, key.Type); | |
| 103 | // ...and the legacy mode went with it, so upstream still reads something sane. | |
| 104 | Assert.Equal(IFSEngine.Animation.InterpolationMode.Constant, key.InterpolationMode); | |
| 105 | } | |
| 106 | ||
| 107 | /// <summary> | |
| 108 | /// The dropdown reaches the evaluator, not just the model: picking a type changes what the | |
| 109 | /// channel is worth between its keyframes. | |
| 110 | /// </summary> | |
| 111 | /// <remarks> | |
| 112 | /// Measured on the 3s..5s RAMP rather than the 5s..7s hold, because a hold between two | |
| 113 | /// equal keys is 1.0 under Linear and under Bezier alike - it would not tell the two apart, | |
| 114 | /// which is the entire point of the fix. | |
| 115 | /// </remarks> | |
| 116 | [AvaloniaFact] | |
| 117 | public void ChangingTheTypeChangesWhatTheChannelEvaluatesTo() | |
| 118 | { | |
| 119 | (AnimationPanel panel, Channel channel, CurveCanvas curve) = Panel(); | |
| 120 | Keyframe ramp = channel.Keyframes[0]; // t=3, value 0, running up to t=5, value 1 | |
| 121 | ||
| 122 | curve.BeginDrag(ramp, new Point(0, 0)); | |
| 123 | curve.EndDrag(); | |
| 124 | ||
| 125 | ComboBox combo = TypeCombo(panel); | |
| 126 | string[] labels = combo.ItemsSource!.Cast<object>().Select(i => i.ToString()).ToArray(); | |
| 127 | ||
| 128 | combo.SelectedIndex = Array.IndexOf(labels, "Linear"); | |
| 129 | Assert.Equal(0.5, channel.EvaluateAt(4), 9); | |
| 130 | ||
| 131 | combo.SelectedIndex = Array.IndexOf(labels, "Constant"); | |
| 132 | Assert.Equal(0.0, channel.EvaluateAt(4), 9); | |
| 133 | ||
| 134 | // Legacy hands the segment back to InterpolationMode, whatever that now says. | |
| 135 | combo.SelectedIndex = Array.IndexOf(labels, "Legacy"); | |
| 136 | Assert.Equal(KeyframeType.Legacy, ramp.Type); | |
| 137 | Assert.Equal(0.0, channel.EvaluateAt(4), 9); | |
| 138 | } | |
| 139 | } |
| … | … | @@ -0,0 +1,348 @@ |
| 1 | using System; | |
| 2 | using System.Collections.Generic; | |
| 3 | using System.Linq; | |
| 4 | ||
| 5 | using IFSEngine.Animation; | |
| 6 | ||
| 7 | using Newtonsoft.Json; | |
| 8 | ||
| 9 | using Xunit; | |
| 10 | ||
| 11 | namespace IFSRenderer.AvaloniaShell.Tests; | |
| 12 | ||
| 13 | /// <summary> | |
| 14 | /// Blender-style keyframe types and bezier handles. | |
| 15 | /// </summary> | |
| 16 | /// <remarks> | |
| 17 | /// <para> | |
| 18 | /// Reported: "if you make value 0, to value 1 then for a few frames hold value 1, then go to | |
| 19 | /// value 2, it's impossible to make it stay at value 1 in those frames because it wobbles up | |
| 20 | /// and down [...] 5s and 7s, the keys are identical, if the values are identical, why does the | |
| 21 | /// focus distance change?" | |
| 22 | /// </para> | |
| 23 | /// <para> | |
| 24 | /// The answer was <see cref="InterpolationMode.CatmullRom"/>, which every keyframe gets by | |
| 25 | /// default: its tangent is <c>(p2 - p0) / 2</c>, taken from the neighbours' VALUES alone | |
| 26 | /// (Channel.cs:94). It never flattens, so two equal keys with unequal neighbours still get a | |
| 27 | /// slope and the segment has to leave the held value and come back. It also ignores the time | |
| 28 | /// axis, so uneven key spacing overshoots. | |
| 29 | /// </para> | |
| 30 | /// <para> | |
| 31 | /// Blender does not have this problem because its default is Bezier with AUTO CLAMPED handles, | |
| 32 | /// which zeroes the tangent at a local extremum. These tests pin that behaviour. | |
| 33 | /// </para> | |
| 34 | /// </remarks> | |
| 35 | public class KeyframeTypeTests | |
| 36 | { | |
| 37 | /// <summary> | |
| 38 | /// Builds a channel the way the app does - through the ◇ diamond - so the defaults under | |
| 39 | /// test are the ones a user actually gets, not ones the test chose. | |
| 40 | /// </summary> | |
| 41 | private static Channel Keyed(params (double T, double Value)[] keys) | |
| 42 | { | |
| 43 | var sheet = new Dopesheet { Length = TimeSpan.FromSeconds(12), Fps = 30 }; | |
| 44 | foreach ((double t, double v) in keys) | |
| 45 | sheet.AddOrUpdateChannel("Focus", "Camera.FocusDistance", | |
| 46 | TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)), v); | |
| 47 | return sheet.Channels["Camera.FocusDistance"]; | |
| 48 | } | |
| 49 | ||
| 50 | private static IEnumerable<double> Across(Channel c, double from, double to, int n = 400) => | |
| 51 | Enumerable.Range(0, n + 1).Select(i => c.EvaluateAt(from + (to - from) * i / n)); | |
| 52 | ||
| 53 | [Fact] | |
| 54 | public void AHoldBetweenTwoEqualKeysStaysFlat() | |
| 55 | { | |
| 56 | Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2)); | |
| 57 | ||
| 58 | double worst = Across(c, 5, 7).Max(v => Math.Abs(v - 1.0)); | |
| 59 | ||
| 60 | Assert.True(worst < 1e-9, | |
| 61 | $"held value drifted by {worst:P2} between its two identical keyframes"); | |
| 62 | } | |
| 63 | ||
| 64 | [Fact] | |
| 65 | public void TheHoldIsFlatWithoutFlatteningTheRampsAroundIt() | |
| 66 | { | |
| 67 | Channel c = Keyed((3, 0), (5, 1), (7, 1), (9, 2)); | |
| 68 | ||
| 69 | // The ramps still move, monotonically, all the way to their endpoints. | |
| 70 | Assert.True(c.EvaluateAt(4) > 0 && c.EvaluateAt(4) < 1); | |
| 71 | Assert.True(c.EvaluateAt(8) > 1 && c.EvaluateAt(8) < 2); | |
| 72 | Assert.Equal(0, c.EvaluateAt(3), 9); | |
| 73 | Assert.Equal(2, c.EvaluateAt(9), 9); | |
| 74 | } | |
| 75 | ||
| 76 | [Fact] | |
| 77 | public void ARampDoesNotOvershootItsEndpoints() | |
| 78 | { | |
| 79 | // Uneven spacing: the old tangent ignored dt entirely, so the tight pair overshot. | |
| 80 | Channel c = Keyed((0, 0), (1, 1), (9, 2)); | |
| 81 | ||
| 82 | double max = Across(c, 0, 9).Max(); | |
| 83 | Assert.True(max <= 2 + 1e-9, $"curve reached {max}, past its highest keyframe (2)"); | |
| 84 | } | |
| 85 | ||
| 86 | // ---------------------------------------------------------------- every preset | |
| 87 | ||
| 88 | public static TheoryData<KeyframeType> AllPresets() | |
| 89 | { | |
| 90 | var data = new TheoryData<KeyframeType>(); | |
| 91 | foreach (KeyframeType t in Enum.GetValues<KeyframeType>()) | |
| 92 | if (t != KeyframeType.Legacy) | |
| 93 | data.Add(t); | |
| 94 | return data; | |
| 95 | } | |
| 96 | ||
| 97 | /// <summary> | |
| 98 | /// Every preset but <see cref="KeyframeType.Constant"/>, which by definition does NOT | |
| 99 | /// approach the next keyframe's value - it holds its own until the next key is reached. | |
| 100 | /// </summary> | |
| 101 | public static TheoryData<KeyframeType> InterpolatingPresets() | |
| 102 | { | |
| 103 | var data = new TheoryData<KeyframeType>(); | |
| 104 | foreach (KeyframeType t in Enum.GetValues<KeyframeType>()) | |
| 105 | if (t is not (KeyframeType.Legacy or KeyframeType.Constant)) | |
| 106 | data.Add(t); | |
| 107 | return data; | |
| 108 | } | |
| 109 | ||
| 110 | /// <summary> | |
| 111 | /// The property <see cref="EasingDirection.Out"/> violated for years: a segment has to run | |
| 112 | /// from its own keyframe's value to the next one's. <c>1 - t^p</c> is 1 at t=0, so every | |
| 113 | /// segment played backwards, at every power, and nothing caught it. | |
| 114 | /// </summary> | |
| 115 | /// <remarks> | |
| 116 | /// The tolerance is 0.5% of the segment's 10-unit range rather than an exact match, because | |
| 117 | /// <see cref="KeyframeType.Circular"/>'s ease-in is vertical at its end: a millionth of a | |
| 118 | /// second from the last keyframe it is still legitimately 1.4e-4 short, and tightening this | |
| 119 | /// past that would be asserting something untrue about a circle. The bug this guards | |
| 120 | /// against missed by the FULL range, not by a rounding error. | |
| 121 | /// </remarks> | |
| 122 | [Theory] | |
| 123 | [MemberData(nameof(InterpolatingPresets))] | |
| 124 | public void EverySegmentRunsFromItsStartValueToItsEndValue(KeyframeType type) | |
| 125 | { | |
| 126 | foreach (EasingDirection direction in Enum.GetValues<EasingDirection>()) | |
| 127 | { | |
| 128 | Channel c = Typed(type, direction, (0, -5), (10, 5)); | |
| 129 | ||
| 130 | Assert.True(Math.Abs(c.EvaluateAt(0.000001) - -5) < 0.05, | |
| 131 | $"{type}/{direction} starts at {c.EvaluateAt(0.000001)}, not -5"); | |
| 132 | Assert.True(Math.Abs(c.EvaluateAt(9.999999) - 5) < 0.05, | |
| 133 | $"{type}/{direction} ends at {c.EvaluateAt(9.999999)}, not 5"); | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | /// <summary> | |
| 138 | /// <see cref="KeyframeType.Exponential"/> specifically: Penner's form is 2^-10 at t=0, so | |
| 139 | /// it starts a thousandth of the range ABOVE its own keyframe unless normalized. | |
| 140 | /// </summary> | |
| 141 | [Fact] | |
| 142 | public void ExponentialStartsExactlyAtItsKeyframe() | |
| 143 | { | |
| 144 | Channel c = Typed(KeyframeType.Exponential, EasingDirection.In, (0, 0), (10, 1)); | |
| 145 | ||
| 146 | // Un-normalized this is 2^-10 = 0.00098 immediately off the keyframe, a million times | |
| 147 | // the bound below; normalized it decays with the offset. | |
| 148 | Assert.True(c.EvaluateAt(1e-9) < 1e-9, $"exponential starts at {c.EvaluateAt(1e-9)}"); | |
| 149 | } | |
| 150 | ||
| 151 | [Theory] | |
| 152 | [MemberData(nameof(AllPresets))] | |
| 153 | public void NoPresetProducesANonFiniteValue(KeyframeType type) | |
| 154 | { | |
| 155 | foreach (EasingDirection direction in Enum.GetValues<EasingDirection>()) | |
| 156 | { | |
| 157 | Channel c = Typed(type, direction, (0, -5), (3, 12), (4, 12), (10, 0)); | |
| 158 | Assert.All(Across(c, -1, 11), v => Assert.True(double.IsFinite(v), $"{type}/{direction} produced {v}")); | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | [Fact] | |
| 163 | public void ConstantHoldsTheLeftKeyRightUpToTheNextOne() | |
| 164 | { | |
| 165 | Channel c = Typed(KeyframeType.Constant, EasingDirection.InOut, (0, 3), (10, 9)); | |
| 166 | ||
| 167 | Assert.All(Across(c, 0, 9.99), v => Assert.Equal(3, v, 9)); | |
| 168 | Assert.Equal(9, c.EvaluateAt(10), 9); | |
| 169 | } | |
| 170 | ||
| 171 | [Fact] | |
| 172 | public void LinearIsAStraightLine() | |
| 173 | { | |
| 174 | Channel c = Typed(KeyframeType.Linear, EasingDirection.InOut, (0, 0), (10, 10)); | |
| 175 | ||
| 176 | for (double t = 0; t <= 10; t += 0.5) | |
| 177 | Assert.Equal(t, c.EvaluateAt(t), 9); | |
| 178 | } | |
| 179 | ||
| 180 | /// <summary>Blender's Back and Elastic are meant to leave the range; the rest are not.</summary> | |
| 181 | [Theory] | |
| 182 | [InlineData(KeyframeType.Sinusoidal)] | |
| 183 | [InlineData(KeyframeType.Quadratic)] | |
| 184 | [InlineData(KeyframeType.Cubic)] | |
| 185 | [InlineData(KeyframeType.Exponential)] | |
| 186 | [InlineData(KeyframeType.Circular)] | |
| 187 | [InlineData(KeyframeType.Bounce)] | |
| 188 | public void AnEasingPresetStaysWithinItsEndpoints(KeyframeType type) | |
| 189 | { | |
| 190 | Channel c = Typed(type, EasingDirection.InOut, (0, 0), (10, 1)); | |
| 191 | ||
| 192 | Assert.All(Across(c, 0, 10), v => Assert.InRange(v, -1e-9, 1 + 1e-9)); | |
| 193 | } | |
| 194 | ||
| 195 | // ---------------------------------------------------------------- handles | |
| 196 | ||
| 197 | [Fact] | |
| 198 | public void VectorHandlesMakeASegmentStraight() | |
| 199 | { | |
| 200 | Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10)); | |
| 201 | foreach (Keyframe k in c.Keyframes) | |
| 202 | k.LeftHandleType = k.RightHandleType = HandleType.Vector; | |
| 203 | ||
| 204 | for (double t = 0; t <= 10; t += 0.5) | |
| 205 | Assert.Equal(t, c.EvaluateAt(t), 6); | |
| 206 | } | |
| 207 | ||
| 208 | /// <summary> | |
| 209 | /// Automatic smooths through the key and is ALLOWED to overshoot; auto-clamped is the one | |
| 210 | /// that does not. Both must still be time-aware, which the Catmull-Rom tangent was not. | |
| 211 | /// </summary> | |
| 212 | [Fact] | |
| 213 | public void AutoClampedDoesNotOvershootWhereAutomaticMay() | |
| 214 | { | |
| 215 | Channel clamped = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (1, 10), (9, 10.5)); | |
| 216 | foreach (Keyframe k in clamped.Keyframes) | |
| 217 | k.LeftHandleType = k.RightHandleType = HandleType.AutoClamped; | |
| 218 | ||
| 219 | Assert.All(Across(clamped, 0, 9), v => Assert.InRange(v, -1e-9, 10.5 + 1e-9)); | |
| 220 | } | |
| 221 | ||
| 222 | [Fact] | |
| 223 | public void DraggingAHandleChangesTheCurveItself() | |
| 224 | { | |
| 225 | Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10)); | |
| 226 | double before = c.EvaluateAt(3); | |
| 227 | ||
| 228 | Keyframe first = c.Keyframes[0]; | |
| 229 | first.RightHandleType = HandleType.Free; | |
| 230 | first.RightHandleT = 3; | |
| 231 | first.RightHandleValue = 8; | |
| 232 | ||
| 233 | Assert.NotEqual(before, c.EvaluateAt(3), 3); | |
| 234 | // ...and it still starts and ends where its keyframes say. | |
| 235 | Assert.Equal(0, c.EvaluateAt(0), 9); | |
| 236 | Assert.Equal(10, c.EvaluateAt(10), 9); | |
| 237 | } | |
| 238 | ||
| 239 | /// <summary> | |
| 240 | /// Handles that reach past the segment would fold the curve back and stop it being a | |
| 241 | /// function of time. They are shortened before the solve, so the result stays single-valued | |
| 242 | /// and monotonic in time. | |
| 243 | /// </summary> | |
| 244 | [Fact] | |
| 245 | public void OverlongHandlesDoNotFoldTheCurve() | |
| 246 | { | |
| 247 | Channel c = Typed(KeyframeType.Bezier, EasingDirection.InOut, (0, 0), (10, 10)); | |
| 248 | Keyframe a = c.Keyframes[0], b = c.Keyframes[1]; | |
| 249 | a.RightHandleType = b.LeftHandleType = HandleType.Free; | |
| 250 | a.RightHandleT = 40; | |
| 251 | a.RightHandleValue = 0; | |
| 252 | b.LeftHandleT = -30; | |
| 253 | b.LeftHandleValue = 10; | |
| 254 | ||
| 255 | List<double> vs = Across(c, 0, 10).ToList(); | |
| 256 | Assert.All(vs, v => Assert.True(double.IsFinite(v))); | |
| 257 | for (int i = 1; i < vs.Count; i++) | |
| 258 | Assert.True(vs[i] >= vs[i - 1] - 1e-6, $"curve went backwards at sample {i}"); | |
| 259 | } | |
| 260 | ||
| 261 | // ---------------------------------------------------------------- file format | |
| 262 | ||
| 263 | /// <summary> | |
| 264 | /// A keyframe from a file written before any of this existed has no Type property, so it | |
| 265 | /// deserializes to <see cref="KeyframeType.Legacy"/> and evaluates through the old path. | |
| 266 | /// Existing animations must not change shape because the app was updated. | |
| 267 | /// </summary> | |
| 268 | [Fact] | |
| 269 | public void AKeyframeWithoutATypeIsLegacyAndEvaluatesAsItAlwaysDid() | |
| 270 | { | |
| 271 | const string legacy = """ | |
| 272 | {"Name":"Brightness","Keyframes":[ | |
| 273 | {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":0.0,"Value":0.0}, | |
| 274 | {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":5.0,"Value":1.0}, | |
| 275 | {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":7.0,"Value":1.0}, | |
| 276 | {"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":9.0,"Value":2.0}], | |
| 277 | "AudioChannelDriver":null} | |
| 278 | """; | |
| 279 | ||
| 280 | Channel c = JsonConvert.DeserializeObject<Channel>(legacy); | |
| 281 | ||
| 282 | Assert.All(c.Keyframes, k => Assert.Equal(KeyframeType.Legacy, k.Type)); | |
| 283 | // Including the wobble - this is the OLD behaviour, preserved on purpose. | |
| 284 | Assert.True(Across(c, 5, 7).Max(v => Math.Abs(v - 1.0)) > 0.04); | |
| 285 | } | |
| 286 | ||
| 287 | /// <summary> | |
| 288 | /// A legacy keyframe must round-trip byte-identically, or every existing .ifsjson grows | |
| 289 | /// eleven fields the moment it is opened and saved. | |
| 290 | /// </summary> | |
| 291 | [Fact] | |
| 292 | public void ALegacyKeyframeGainsNoFieldsWhenWritten() | |
| 293 | { | |
| 294 | string json = JsonConvert.SerializeObject(new Keyframe { t = 1, Value = 2 }); | |
| 295 | ||
| 296 | Assert.Equal("""{"InterpolationMode":2,"EasingPower":1.0,"EasingDirection":2,"t":1.0,"Value":2.0}""", json); | |
| 297 | } | |
| 298 | ||
| 299 | /// <summary> | |
| 300 | /// The upstream Windows app knows nothing about <see cref="KeyframeType"/> and its | |
| 301 | /// evaluator ends in <c>throw new NotImplementedException()</c>, so every preset must still | |
| 302 | /// leave a legacy InterpolationMode it can read. | |
| 303 | /// </summary> | |
| 304 | [Theory] | |
| 305 | [MemberData(nameof(AllPresets))] | |
| 306 | public void EveryPresetLeavesAnInterpolationModeUpstreamCanRead(KeyframeType type) | |
| 307 | { | |
| 308 | var k = new Keyframe { t = 0, Value = 0, Type = type }; | |
| 309 | ||
| 310 | Assert.InRange((int)k.InterpolationMode, 0, 2); | |
| 311 | Assert.Contains($"\"InterpolationMode\":{(int)k.InterpolationMode}", | |
| 312 | JsonConvert.SerializeObject(k)); | |
| 313 | } | |
| 314 | ||
| 315 | [Fact] | |
| 316 | public void ABezierKeyframeWritesItsTypeAndHandles() | |
| 317 | { | |
| 318 | var k = new Keyframe | |
| 319 | { | |
| 320 | t = 1, Value = 2, | |
| 321 | Type = KeyframeType.Bezier, | |
| 322 | LeftHandleType = HandleType.Free, | |
| 323 | RightHandleType = HandleType.Free, | |
| 324 | LeftHandleT = 0.5, LeftHandleValue = 1.5, | |
| 325 | RightHandleT = 1.5, RightHandleValue = 2.5, | |
| 326 | }; | |
| 327 | ||
| 328 | Keyframe back = JsonConvert.DeserializeObject<Keyframe>(JsonConvert.SerializeObject(k)); | |
| 329 | ||
| 330 | Assert.Equal(KeyframeType.Bezier, back.Type); | |
| 331 | Assert.Equal(HandleType.Free, back.LeftHandleType); | |
| 332 | Assert.Equal(0.5, back.LeftHandleT, 9); | |
| 333 | Assert.Equal(2.5, back.RightHandleValue, 9); | |
| 334 | } | |
| 335 | ||
| 336 | /// <summary>Builds a channel whose keyframes all carry one preset.</summary> | |
| 337 | private static Channel Typed(KeyframeType type, EasingDirection direction, | |
| 338 | params (double T, double Value)[] keys) | |
| 339 | { | |
| 340 | Channel c = Keyed(keys); | |
| 341 | foreach (Keyframe k in c.Keyframes) | |
| 342 | { | |
| 343 | k.Type = type; | |
| 344 | k.EasingDirection = direction; | |
| 345 | } | |
| 346 | return c; | |
| 347 | } | |
| 348 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | using System; | |
| 1 | using System; | |
| 2 | 2 | using System.Collections.Generic; |
| 3 | 3 | using System.Globalization; |
| 4 | 4 | using System.Linq; |
| … | … | @@ -55,6 +55,27 @@ public sealed class AnimationPanel : Grid |
| 55 | 55 | private readonly NumericUpDown _length = new() { Minimum = 0.1m, Maximum = 3600, Increment = 1, FormatString = "0.##", Width = 90 }; |
| 56 | 56 | private readonly NumericUpDown _fps = new() { Minimum = 1, Maximum = 240, Increment = 1, FormatString = "0", Width = 74 }; |
| 57 | 57 | |
| 58 | private readonly ComboBox _keyType = new() { MinWidth = 130 }; | |
| 59 | private readonly ComboBox _handles = new() { MinWidth = 130 }; | |
| 60 | private readonly NumericUpDown _dynamicParam = new() | |
| 61 | { | |
| 62 | Minimum = 0, Maximum = 100, Increment = 0.05m, FormatString = "0.###", Width = 84, | |
| 63 | }; | |
| 64 | private readonly TextBlock _dynamicCaption = Caption("overshoot"); | |
| 65 | private readonly NumericUpDown _elasticPeriod = new() | |
| 66 | { | |
| 67 | Minimum = 0, Maximum = 10, Increment = 0.05m, FormatString = "0.###", Width = 84, | |
| 68 | }; | |
| 69 | ||
| 70 | // Rows that only apply to some keyframe types, hidden for the rest. Assigned as the | |
| 71 | // editor is built. | |
| 72 | private StackPanel _handleRow; | |
| 73 | private StackPanel _dynamicRow; | |
| 74 | private StackPanel _periodRow; | |
| 75 | private StackPanel _legacyRow; | |
| 76 | private StackPanel _easingRow; | |
| 77 | private StackPanel _powerRow; | |
| 78 | ||
| 58 | 79 | private readonly ComboBox _interpolation = new() { MinWidth = 130 }; |
| 59 | 80 | private readonly ComboBox _easing = new() { MinWidth = 110 }; |
| 60 | 81 | private readonly NumericUpDown _easingPower = new() { Minimum = 0.1m, Maximum = 10, Increment = 0.1m, FormatString = "0.##", Width = 84 }; |
| … | … | @@ -336,10 +357,51 @@ public sealed class AnimationPanel : Grid |
| 336 | 357 | Children.Add(middle); |
| 337 | 358 | |
| 338 | 359 | // ---- keyframe + audio editors ---- |
| 360 | _keyType.ItemsSource = KeyTypes; | |
| 361 | _handles.ItemsSource = HandleTypes; | |
| 339 | 362 | // Two InterpolationMode enums exist since the palette merge: this is the keyframe's. |
| 340 | 363 | _interpolation.ItemsSource = Enum.GetValues<IFSEngine.Animation.InterpolationMode>(); |
| 341 | 364 | _easing.ItemsSource = Enum.GetValues<EasingDirection>(); |
| 342 | 365 | |
| 366 | _keyType.SelectionChanged += (_, _) => EditKeyframe(k => | |
| 367 | { | |
| 368 | if (_keyType.SelectedItem is KeyTypeItem item) | |
| 369 | k.Type = item.Value; | |
| 370 | }); | |
| 371 | ||
| 372 | _handles.SelectionChanged += (_, _) => EditKeyframe(k => | |
| 373 | { | |
| 374 | if (_handles.SelectedItem is not HandleItem item) | |
| 375 | return; | |
| 376 | // Seed the stored coordinates from wherever the derived handles already are, so | |
| 377 | // choosing Free or Aligned keeps the curve that was on screen a moment ago rather | |
| 378 | // than collapsing both handles onto the origin. | |
| 379 | if (item.Value is HandleType.Free or HandleType.Aligned && CurrentChannel is Channel c) | |
| 380 | { | |
| 381 | (Handle left, Handle right) = c.ResolveHandles(k); | |
| 382 | k.LeftHandleT = left.T; | |
| 383 | k.LeftHandleValue = left.Value; | |
| 384 | k.RightHandleT = right.T; | |
| 385 | k.RightHandleValue = right.Value; | |
| 386 | } | |
| 387 | k.LeftHandleType = item.Value; | |
| 388 | k.RightHandleType = item.Value; | |
| 389 | }); | |
| 390 | ||
| 391 | _dynamicParam.ValueChanged += (_, _) => EditKeyframe(k => | |
| 392 | { | |
| 393 | double v = (double)(_dynamicParam.Value ?? 0); | |
| 394 | if (k.Type == KeyframeType.Back) | |
| 395 | k.BackOvershoot = v; | |
| 396 | else if (k.Type == KeyframeType.Elastic) | |
| 397 | k.ElasticAmplitude = v; | |
| 398 | }); | |
| 399 | ||
| 400 | // Zero means auto for both elastic parameters, which is why their minimum is 0 rather | |
| 401 | // than something positive - it is a real setting, not an empty box. | |
| 402 | _elasticPeriod.ValueChanged += (_, _) => | |
| 403 | EditKeyframe(k => k.ElasticPeriod = (double)(_elasticPeriod.Value ?? 0)); | |
| 404 | ||
| 343 | 405 | _interpolation.SelectionChanged += (_, _) => EditKeyframe(k => |
| 344 | 406 | { |
| 345 | 407 | if (_interpolation.SelectedItem is IFSEngine.Animation.InterpolationMode m) |
| … | … | @@ -373,9 +435,20 @@ public sealed class AnimationPanel : Grid |
| 373 | 435 | // shown through a nullVisibilityConverter on EditedChannel). Keeping them permanently |
| 374 | 436 | // across the full width was the single biggest reason this tab felt cluttered: it put |
| 375 | 437 | // roughly twenty controls on screen that are meaningless with nothing selected. |
| 376 | foreach (Control c in new Control[] { _keyValue, _interpolation, _easing, _easingPower }) | |
| 438 | foreach (Control c in new Control[] | |
| 439 | { _keyValue, _keyType, _handles, _dynamicParam, _elasticPeriod, | |
| 440 | _interpolation, _easing, _easingPower }) | |
| 377 | 441 | c.HorizontalAlignment = HorizontalAlignment.Stretch; |
| 378 | 442 | |
| 443 | // Built before the panel: a collection initializer takes members, not assignments, | |
| 444 | // so these cannot be captured inline in Children. | |
| 445 | _handleRow = Row(Caption("handles"), _handles); | |
| 446 | _dynamicRow = Row(_dynamicCaption, _dynamicParam); | |
| 447 | _periodRow = Row(Caption("period"), _elasticPeriod); | |
| 448 | _legacyRow = Row(Caption("interp"), _interpolation); | |
| 449 | _easingRow = Row(Caption("easing"), _easing); | |
| 450 | _powerRow = Row(Caption("power"), _easingPower); | |
| 451 | ||
| 379 | 452 | _keyframeEditor = new StackPanel |
| 380 | 453 | { |
| 381 | 454 | Margin = new Thickness(4), |
| … | … | @@ -385,9 +458,13 @@ public sealed class AnimationPanel : Grid |
| 385 | 458 | { |
| 386 | 459 | Caption("KEYFRAME"), |
| 387 | 460 | Row(Caption("value"), _keyValue), |
| 388 | Row(Caption("interp"), _interpolation), | |
| 389 | Row(Caption("easing"), _easing), | |
| 390 | Row(Caption("power"), _easingPower), | |
| 461 | Row(Caption("type"), _keyType), | |
| 462 | _handleRow, | |
| 463 | _dynamicRow, | |
| 464 | _periodRow, | |
| 465 | _legacyRow, | |
| 466 | _easingRow, | |
| 467 | _powerRow, | |
| 391 | 468 | deleteKey, |
| 392 | 469 | }, |
| 393 | 470 | }; |
| … | … | @@ -869,6 +946,96 @@ public sealed class AnimationPanel : Grid |
| 869 | 946 | Changed?.Invoke(); |
| 870 | 947 | } |
| 871 | 948 | |
| 949 | /// <summary> | |
| 950 | /// Blender's "Set Keyframe Interpolation" presets, in its own menu order: the three | |
| 951 | /// interpolation modes, then easing by strength, then the dynamic effects. | |
| 952 | /// </summary> | |
| 953 | /// <remarks> | |
| 954 | /// <see cref="KeyframeType.Legacy"/> is offered last and deliberately. Every keyframe in | |
| 955 | /// every file written before these existed is Legacy, and forcing those onto a preset the | |
| 956 | /// moment they were selected would silently re-shape animations that already render | |
| 957 | /// correctly. Picking it back is how you get the old evaluator, easing power and all. | |
| 958 | /// <para> | |
| 959 | /// It is labelled just "Legacy", not "Legacy (Catmull-Rom)": it means "evaluate through | |
| 960 | /// <see cref="Keyframe.InterpolationMode"/>", which may be Linear or Constant as easily as | |
| 961 | /// Catmull-Rom. Choosing it reveals the interp row, which says which. | |
| 962 | /// </para> | |
| 963 | /// </remarks> | |
| 964 | private static readonly KeyTypeItem[] KeyTypes = | |
| 965 | [ | |
| 966 | new(KeyframeType.Constant, "Constant"), | |
| 967 | new(KeyframeType.Linear, "Linear"), | |
| 968 | new(KeyframeType.Bezier, "Bezier"), | |
| 969 | new(KeyframeType.Sinusoidal, "Sinusoidal"), | |
| 970 | new(KeyframeType.Quadratic, "Quadratic"), | |
| 971 | new(KeyframeType.Cubic, "Cubic"), | |
| 972 | new(KeyframeType.Quartic, "Quartic"), | |
| 973 | new(KeyframeType.Quintic, "Quintic"), | |
| 974 | new(KeyframeType.Exponential, "Exponential"), | |
| 975 | new(KeyframeType.Circular, "Circular"), | |
| 976 | new(KeyframeType.Back, "Back"), | |
| 977 | new(KeyframeType.Bounce, "Bounce"), | |
| 978 | new(KeyframeType.Elastic, "Elastic"), | |
| 979 | new(KeyframeType.Legacy, "Legacy"), | |
| 980 | ]; | |
| 981 | ||
| 982 | private static readonly HandleItem[] HandleTypes = | |
| 983 | [ | |
| 984 | new(HandleType.Free, "Free"), | |
| 985 | new(HandleType.Aligned, "Aligned"), | |
| 986 | new(HandleType.Vector, "Vector"), | |
| 987 | new(HandleType.Automatic, "Automatic"), | |
| 988 | new(HandleType.AutoClamped, "Auto Clamped"), | |
| 989 | ]; | |
| 990 | ||
| 991 | /// <summary>ToString is what a ComboBox renders, so the label lives there.</summary> | |
| 992 | private sealed record KeyTypeItem(KeyframeType Value, string Label) | |
| 993 | { | |
| 994 | public override string ToString() => Label; | |
| 995 | } | |
| 996 | ||
| 997 | private sealed record HandleItem(HandleType Value, string Label) | |
| 998 | { | |
| 999 | public override string ToString() => Label; | |
| 1000 | } | |
| 1001 | ||
| 1002 | private Channel CurrentChannel => | |
| 1003 | _fractal is not null && _sheet.SelectedChannel is string path | |
| 1004 | && _fractal.Dopesheet.Channels.TryGetValue(path, out Channel c) | |
| 1005 | ? c | |
| 1006 | : null; | |
| 1007 | ||
| 1008 | /// <summary> | |
| 1009 | /// Reveals only the controls the selected keyframe's type actually uses. | |
| 1010 | /// </summary> | |
| 1011 | /// <remarks> | |
| 1012 | /// Handles belong to Bezier, the easing direction to the easing and dynamic presets, | |
| 1013 | /// and interpolation mode plus easing power to Legacy alone. Showing all of them at once | |
| 1014 | /// would put a dozen controls on screen of which most do nothing - the clutter that | |
| 1015 | /// "Uncrowd the animation tab" was about. | |
| 1016 | /// </remarks> | |
| 1017 | private void ShowRowsFor(Keyframe k) | |
| 1018 | { | |
| 1019 | bool legacy = k.Type == KeyframeType.Legacy; | |
| 1020 | bool eased = k.Type is not (KeyframeType.Legacy or KeyframeType.Constant | |
| 1021 | or KeyframeType.Linear or KeyframeType.Bezier); | |
| 1022 | ||
| 1023 | if (_handleRow is not null) | |
| 1024 | _handleRow.IsVisible = k.Type == KeyframeType.Bezier; | |
| 1025 | if (_legacyRow is not null) | |
| 1026 | _legacyRow.IsVisible = legacy; | |
| 1027 | if (_powerRow is not null) | |
| 1028 | _powerRow.IsVisible = legacy; | |
| 1029 | if (_easingRow is not null) | |
| 1030 | _easingRow.IsVisible = legacy || eased; | |
| 1031 | if (_dynamicRow is not null) | |
| 1032 | _dynamicRow.IsVisible = k.Type is KeyframeType.Back or KeyframeType.Elastic; | |
| 1033 | if (_periodRow is not null) | |
| 1034 | _periodRow.IsVisible = k.Type == KeyframeType.Elastic; | |
| 1035 | ||
| 1036 | _dynamicCaption.Text = k.Type == KeyframeType.Back ? "overshoot" : "amplitude"; | |
| 1037 | } | |
| 1038 | ||
| 872 | 1039 | private void RefreshKeyframeFields() |
| 873 | 1040 | { |
| 874 | 1041 | Keyframe k = _sheet.SelectedKeyframe; |
| … | … | @@ -882,10 +1049,16 @@ public sealed class AnimationPanel : Grid |
| 882 | 1049 | _keyframeEditor.IsVisible = has; |
| 883 | 1050 | if (!has) |
| 884 | 1051 | return; |
| 1052 | _keyType.SelectedItem = KeyTypes.FirstOrDefault(i => i.Value == k.Type); | |
| 1053 | _handles.SelectedItem = HandleTypes.FirstOrDefault(i => i.Value == k.RightHandleType); | |
| 1054 | _dynamicParam.Value = ToDecimal( | |
| 1055 | k.Type == KeyframeType.Back ? k.BackOvershoot : k.ElasticAmplitude, 0m, 100m, 0m); | |
| 1056 | _elasticPeriod.Value = ToDecimal(k.ElasticPeriod, 0m, 10m, 0m); | |
| 885 | 1057 | _interpolation.SelectedItem = k.InterpolationMode; |
| 886 | 1058 | _easing.SelectedItem = k.EasingDirection; |
| 887 | 1059 | _easingPower.Value = ToDecimal(k.EasingPower, 0.1m, 10m, 1m); |
| 888 | 1060 | _keyValue.Value = ToDecimal(k.Value, KeyValueLimit * -1, KeyValueLimit, 0m); |
| 1061 | ShowRowsFor(k); | |
| 889 | 1062 | } |
| 890 | 1063 | finally { _syncing = false; } |
| 891 | 1064 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | using System; | |
| 1 | using System; | |
| 2 | 2 | using System.Collections.Generic; |
| 3 | 3 | using System.Globalization; |
| 4 | 4 | using System.Linq; |
| … | … | @@ -35,10 +35,16 @@ namespace IFSRenderer.AvaloniaShell; |
| 35 | 35 | /// Re-deriving any of that here would let the picture drift from what actually renders. |
| 36 | 36 | /// </para> |
| 37 | 37 | /// <para> |
| 38 | /// Second, <see cref="Keyframe"/>'s tangent fields are commented out (Keyframe.cs:10-11). There | |
| 39 | /// are no bezier handles to drag, so this does not pretend to offer them: shaping a segment is | |
| 40 | /// done through interpolation mode and easing power, which is what the editor row beside the | |
| 41 | /// plot edits for whichever keyframe is selected here. | |
| 38 | /// Second, a <see cref="KeyframeType.Bezier"/> keyframe HAS handles, and they are drawn and | |
| 39 | /// dragged here. They are resolved through <see cref="Channel.ResolveHandles"/> rather than | |
| 40 | /// recomputed locally, for the same reason the curve is sampled rather than interpolated: the | |
| 41 | /// automatic handle types are derived from the neighbours, and a second implementation of that | |
| 42 | /// derivation would eventually disagree with the one that renders. | |
| 43 | /// </para> | |
| 44 | /// <para> | |
| 45 | /// Dragging a derived handle makes it <see cref="HandleType.Free"/>, as Blender does - the | |
| 46 | /// alternative is a handle that springs back the instant it is released, because its position | |
| 47 | /// is a function of its neighbours rather than of the pointer. | |
| 42 | 48 | /// </para> |
| 43 | 49 | /// </remarks> |
| 44 | 50 | internal sealed class CurveCanvas : Control |
| … | … | @@ -46,6 +52,7 @@ internal sealed class CurveCanvas : Control |
| 46 | 52 | private const double VerticalPad = 14; |
| 47 | 53 | private const double KeyRadius = 4.5; |
| 48 | 54 | private const double HitSlack = 4; |
| 55 | private const double HandleRadius = 3.5; | |
| 49 | 56 | private const double MinValueSpan = 1e-3; |
| 50 | 57 | |
| 51 | 58 | /// <summary>Curve samples per render. Caps the cost on CSV-imported channels.</summary> |
| … | … | @@ -62,6 +69,8 @@ internal sealed class CurveCanvas : Control |
| 62 | 69 | private bool _autoFit = true; |
| 63 | 70 | |
| 64 | 71 | private Keyframe _dragKey; |
| 72 | private Keyframe _handleKey; | |
| 73 | private bool _draggingLeftHandle; | |
| 65 | 74 | private Point _dragOrigin; |
| 66 | 75 | private bool _dragAxisLocked; |
| 67 | 76 | private bool _dragLockToValue; |
| … | … | @@ -101,6 +110,10 @@ internal sealed class CurveCanvas : Control |
| 101 | 110 | private static readonly IBrush KeyFill = new SolidColorBrush(Color.Parse("#B0B0B0")); |
| 102 | 111 | private static readonly IBrush KeyFillSelected = new SolidColorBrush(Colors.DarkOrange); |
| 103 | 112 | private static readonly IPen KeyStroke = new Pen(new SolidColorBrush(Color.Parse("#1b1b1b")), 1); |
| 113 | private static readonly IPen HandlePen = | |
| 114 | new Pen(new SolidColorBrush(Color.Parse("#ffb74d")), 1); | |
| 115 | private static readonly IBrush HandleFill = new SolidColorBrush(Color.Parse("#ffb74d")); | |
| 116 | private static readonly IBrush HandleFillDerived = new SolidColorBrush(Color.Parse("#7a6a4a")); | |
| 104 | 117 | private static readonly IPen PlayheadPen = new Pen(Brushes.White, 1.5); |
| 105 | 118 | private static readonly IPen PlayheadHaloPen = |
| 106 | 119 | new Pen(new SolidColorBrush(Color.Parse("#88000000")), 4); |
| … | … | @@ -280,6 +293,7 @@ internal sealed class CurveCanvas : Control |
| 280 | 293 | |
| 281 | 294 | DrawValueGrid(ctx); |
| 282 | 295 | DrawCurve(ctx, channel); |
| 296 | DrawHandles(ctx, channel); | |
| 283 | 297 | DrawKeyframes(ctx, channel); |
| 284 | 298 | |
| 285 | 299 | double px = TimeToX(CurrentTime); |
| … | … | @@ -368,6 +382,36 @@ internal sealed class CurveCanvas : Control |
| 368 | 382 | ctx.DrawGeometry(null, ActiveSegmentPen, geometry); |
| 369 | 383 | } |
| 370 | 384 | |
| 385 | /// <summary> | |
| 386 | /// Draws the selected bezier keyframe's two handles. | |
| 387 | /// </summary> | |
| 388 | /// <remarks> | |
| 389 | /// Only the selected one, as Blender does. Drawing every key's handles on a channel of any | |
| 390 | /// length turns the plot into a thicket, and the handles that matter are the ones attached | |
| 391 | /// to the key being shaped. A derived handle is drawn dimmer than a positioned one, so it | |
| 392 | /// reads as something the curve is computing rather than something you placed. | |
| 393 | /// </remarks> | |
| 394 | private void DrawHandles(DrawingContext ctx, Channel channel) | |
| 395 | { | |
| 396 | if (SelectedKeyframe is null || SelectedKeyframe.Type != KeyframeType.Bezier) | |
| 397 | return; | |
| 398 | ||
| 399 | (Handle left, Handle right) = channel.ResolveHandles(SelectedKeyframe); | |
| 400 | var centre = new Point(TimeToX(SelectedKeyframe.t), ValueToY(SelectedKeyframe.Value)); | |
| 401 | ||
| 402 | Draw(left, SelectedKeyframe.LeftHandleType); | |
| 403 | Draw(right, SelectedKeyframe.RightHandleType); | |
| 404 | ||
| 405 | void Draw(Handle h, HandleType type) | |
| 406 | { | |
| 407 | var at = new Point(TimeToX(h.T), ValueToY(h.Value)); | |
| 408 | ctx.DrawLine(HandlePen, centre, at); | |
| 409 | bool positioned = type is HandleType.Free or HandleType.Aligned; | |
| 410 | ctx.DrawEllipse(positioned ? HandleFill : HandleFillDerived, HandlePen, | |
| 411 | at, HandleRadius, HandleRadius); | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 371 | 415 | private void DrawKeyframes(DrawingContext ctx, Channel channel) |
| 372 | 416 | { |
| 373 | 417 | foreach (Keyframe k in channel.Keyframes) |
| … | … | @@ -402,6 +446,34 @@ internal sealed class CurveCanvas : Control |
| 402 | 446 | return hit; |
| 403 | 447 | } |
| 404 | 448 | |
| 449 | /// <summary> | |
| 450 | /// Whether the pointer is on one of the selected keyframe's handles, and which. | |
| 451 | /// </summary> | |
| 452 | private bool HitHandle(Point p, out bool leftSide) | |
| 453 | { | |
| 454 | leftSide = false; | |
| 455 | Channel channel = Current; | |
| 456 | if (channel is null || SelectedKeyframe is null | |
| 457 | || SelectedKeyframe.Type != KeyframeType.Bezier) | |
| 458 | return false; | |
| 459 | ||
| 460 | (Handle left, Handle right) = channel.ResolveHandles(SelectedKeyframe); | |
| 461 | ||
| 462 | double dl = Distance(left), dr = Distance(right); | |
| 463 | double reach = HandleRadius + HitSlack; | |
| 464 | if (dl > reach && dr > reach) | |
| 465 | return false; | |
| 466 | ||
| 467 | leftSide = dl <= dr; | |
| 468 | return true; | |
| 469 | ||
| 470 | double Distance(Handle h) | |
| 471 | { | |
| 472 | double dx = TimeToX(h.T) - p.X, dy = ValueToY(h.Value) - p.Y; | |
| 473 | return Math.Sqrt(dx * dx + dy * dy); | |
| 474 | } | |
| 475 | } | |
| 476 | ||
| 405 | 477 | protected override void OnPointerPressed(PointerPressedEventArgs e) |
| 406 | 478 | { |
| 407 | 479 | base.OnPointerPressed(e); |
| … | … | @@ -423,6 +495,18 @@ internal sealed class CurveCanvas : Control |
| 423 | 495 | return; |
| 424 | 496 | } |
| 425 | 497 | |
| 498 | // Handles are tested BEFORE keyframes: a handle can be dragged right up against its | |
| 499 | // own key, and the key is the larger target, so testing the other way round makes a | |
| 500 | // short handle impossible to grab. | |
| 501 | if (!props.IsRightButtonPressed && HitHandle(p, out bool leftSide)) | |
| 502 | { | |
| 503 | SnapshotRequested?.Invoke(); | |
| 504 | _handleKey = SelectedKeyframe; | |
| 505 | _draggingLeftHandle = leftSide; | |
| 506 | e.Pointer.Capture(this); | |
| 507 | return; | |
| 508 | } | |
| 509 | ||
| 426 | 510 | Keyframe key = HitKeyframe(p); |
| 427 | 511 | |
| 428 | 512 | if (key is null && Math.Abs(TimeToX(CurrentTime) - p.X) <= PlayheadGrab) |
| … | … | @@ -491,6 +575,12 @@ internal sealed class CurveCanvas : Control |
| 491 | 575 | return; |
| 492 | 576 | } |
| 493 | 577 | |
| 578 | if (_handleKey is not null) | |
| 579 | { | |
| 580 | DragHandleTo(p); | |
| 581 | return; | |
| 582 | } | |
| 583 | ||
| 494 | 584 | if (_dragKey is null) |
| 495 | 585 | return; |
| 496 | 586 | |
| … | … | @@ -506,6 +596,8 @@ internal sealed class CurveCanvas : Control |
| 506 | 596 | if (_dragKey is null) |
| 507 | 597 | return; |
| 508 | 598 | |
| 599 | double beforeT = _dragKey.t, beforeValue = _dragKey.Value; | |
| 600 | ||
| 509 | 601 | bool constrain = modifiers.HasFlag(KeyModifiers.Shift); |
| 510 | 602 | if (constrain && !_dragAxisLocked) |
| 511 | 603 | { |
| … | … | @@ -532,7 +624,88 @@ internal sealed class CurveCanvas : Control |
| 532 | 624 | if (!(_dragAxisLocked && !_dragLockToValue)) |
| 533 | 625 | _dragKey.Value = YToValue(p.Y); |
| 534 | 626 | |
| 627 | // Stored handles are absolute (time, value) coordinates, so a key that moves without | |
| 628 | // them would leave its own handles behind. | |
| 629 | _dragKey.LeftHandleT += _dragKey.t - beforeT; | |
| 630 | _dragKey.LeftHandleValue += _dragKey.Value - beforeValue; | |
| 631 | _dragKey.RightHandleT += _dragKey.t - beforeT; | |
| 632 | _dragKey.RightHandleValue += _dragKey.Value - beforeValue; | |
| 633 | ||
| 634 | InvalidateVisual(); | |
| 635 | } | |
| 636 | ||
| 637 | /// <summary> | |
| 638 | /// Moves one handle of the selected keyframe. | |
| 639 | /// </summary> | |
| 640 | /// <remarks> | |
| 641 | /// The handle is clamped to its own side of the key in TIME. Letting it cross would not | |
| 642 | /// break the evaluator - <c>BezierHandles.Correct</c> shortens over-long handles before | |
| 643 | /// solving - but it lets the user build a segment whose drawn shape does not match the | |
| 644 | /// corrected one that renders. | |
| 645 | /// </remarks> | |
| 646 | internal void DragHandleTo(Point p) | |
| 647 | { | |
| 648 | if (_handleKey is null) | |
| 649 | return; | |
| 650 | ||
| 651 | double t = XToTime(p.X); | |
| 652 | double value = YToValue(p.Y); | |
| 653 | ||
| 654 | if (_draggingLeftHandle) | |
| 655 | { | |
| 656 | t = Math.Min(t, _handleKey.t); | |
| 657 | _handleKey.LeftHandleT = t; | |
| 658 | _handleKey.LeftHandleValue = value; | |
| 659 | _handleKey.LeftHandleType = Positioned(_handleKey.LeftHandleType); | |
| 660 | if (_handleKey.LeftHandleType == HandleType.Aligned) | |
| 661 | Align(fromLeft: true); | |
| 662 | } | |
| 663 | else | |
| 664 | { | |
| 665 | t = Math.Max(t, _handleKey.t); | |
| 666 | _handleKey.RightHandleT = t; | |
| 667 | _handleKey.RightHandleValue = value; | |
| 668 | _handleKey.RightHandleType = Positioned(_handleKey.RightHandleType); | |
| 669 | if (_handleKey.RightHandleType == HandleType.Aligned) | |
| 670 | Align(fromLeft: false); | |
| 671 | } | |
| 672 | ||
| 535 | 673 | InvalidateVisual(); |
| 674 | ||
| 675 | // A derived handle is a function of its neighbours, so dragging one has to make it | |
| 676 | // the user's before the position can stick. | |
| 677 | static HandleType Positioned(HandleType type) => | |
| 678 | type == HandleType.Aligned ? HandleType.Aligned : HandleType.Free; | |
| 679 | } | |
| 680 | ||
| 681 | /// <summary>Reflects the opposite handle through the key, preserving its own length.</summary> | |
| 682 | private void Align(bool fromLeft) | |
| 683 | { | |
| 684 | double dt = (fromLeft ? _handleKey.LeftHandleT : _handleKey.RightHandleT) - _handleKey.t; | |
| 685 | double dv = (fromLeft ? _handleKey.LeftHandleValue : _handleKey.RightHandleValue) - _handleKey.Value; | |
| 686 | double length = Math.Sqrt(dt * dt + dv * dv); | |
| 687 | if (length <= 0) | |
| 688 | return; | |
| 689 | ||
| 690 | double otherT = (fromLeft ? _handleKey.RightHandleT : _handleKey.LeftHandleT) - _handleKey.t; | |
| 691 | double otherV = (fromLeft ? _handleKey.RightHandleValue : _handleKey.LeftHandleValue) - _handleKey.Value; | |
| 692 | double otherLength = Math.Sqrt(otherT * otherT + otherV * otherV); | |
| 693 | if (otherLength <= 0) | |
| 694 | otherLength = length; | |
| 695 | ||
| 696 | double scale = otherLength / length; | |
| 697 | if (fromLeft) | |
| 698 | { | |
| 699 | _handleKey.RightHandleT = _handleKey.t - dt * scale; | |
| 700 | _handleKey.RightHandleValue = _handleKey.Value - dv * scale; | |
| 701 | _handleKey.RightHandleType = HandleType.Aligned; | |
| 702 | } | |
| 703 | else | |
| 704 | { | |
| 705 | _handleKey.LeftHandleT = _handleKey.t - dt * scale; | |
| 706 | _handleKey.LeftHandleValue = _handleKey.Value - dv * scale; | |
| 707 | _handleKey.LeftHandleType = HandleType.Aligned; | |
| 708 | } | |
| 536 | 709 | } |
| 537 | 710 | |
| 538 | 711 | protected override void OnPointerReleased(PointerReleasedEventArgs e) |
| … | … | @@ -541,9 +714,20 @@ internal sealed class CurveCanvas : Control |
| 541 | 714 | _scrubbing = false; |
| 542 | 715 | _panning = false; |
| 543 | 716 | e.Pointer.Capture(null); |
| 717 | EndHandleDrag(); | |
| 544 | 718 | EndDrag(); |
| 545 | 719 | } |
| 546 | 720 | |
| 721 | /// <summary>Ends a handle drag, so the edit reaches the viewport.</summary> | |
| 722 | internal void EndHandleDrag() | |
| 723 | { | |
| 724 | if (_handleKey is null) | |
| 725 | return; | |
| 726 | _handleKey = null; | |
| 727 | InvalidateVisual(); | |
| 728 | Edited?.Invoke(); | |
| 729 | } | |
| 730 | ||
| 547 | 731 | /// <summary>Ends a keyframe drag, releasing the axis back to auto-fit.</summary> |
| 548 | 732 | internal void EndDrag() |
| 549 | 733 | { |
| … | … | @@ -631,9 +815,28 @@ internal sealed class CurveCanvas : Control |
| 631 | 815 | |
| 632 | 816 | if (inserted is not null && left is not null && !ReferenceEquals(inserted, left)) |
| 633 | 817 | { |
| 818 | inserted.Type = left.Type; | |
| 634 | 819 | inserted.InterpolationMode = left.InterpolationMode; |
| 635 | 820 | inserted.EasingDirection = left.EasingDirection; |
| 636 | 821 | inserted.EasingPower = left.EasingPower; |
| 822 | inserted.BackOvershoot = left.BackOvershoot; | |
| 823 | inserted.ElasticAmplitude = left.ElasticAmplitude; | |
| 824 | inserted.ElasticPeriod = left.ElasticPeriod; | |
| 825 | // The handle TYPES carry over, the coordinates do not: they are absolute, so the | |
| 826 | // left key's would put this key's handles somewhere else entirely. | |
| 827 | // | |
| 828 | // The derived positions are read BEFORE the types are assigned, and the order is | |
| 829 | // load-bearing: Resolve returns the STORED coordinates for Free and Aligned, so | |
| 830 | // seeding after the assignment would read the zeroes the key was created with and | |
| 831 | // collapse both handles onto the origin. A new keyframe is auto-clamped, so | |
| 832 | // reading first derives them properly. | |
| 833 | (Handle lh, Handle rh) = channel.ResolveHandles(inserted); | |
| 834 | inserted.LeftHandleT = lh.T; | |
| 835 | inserted.LeftHandleValue = lh.Value; | |
| 836 | inserted.RightHandleT = rh.T; | |
| 837 | inserted.RightHandleValue = rh.Value; | |
| 838 | inserted.LeftHandleType = left.LeftHandleType; | |
| 839 | inserted.RightHandleType = left.RightHandleType; | |
| 637 | 840 | } |
| 638 | 841 | |
| 639 | 842 | SelectedKeyframe = inserted; |
| … | … | @@ -0,0 +1,182 @@ |
| 1 | using System; | |
| 2 | using System.Collections.Generic; | |
| 3 | ||
| 4 | namespace IFSEngine.Animation; | |
| 5 | ||
| 6 | /// <summary>A handle position, in the channel's own (time, value) space.</summary> | |
| 7 | public readonly struct Handle(double t, double value) | |
| 8 | { | |
| 9 | public double T { get; } = t; | |
| 10 | public double Value { get; } = value; | |
| 11 | } | |
| 12 | ||
| 13 | /// <summary> | |
| 14 | /// Derives and evaluates the bezier handles of <see cref="KeyframeType.Bezier"/> keyframes. | |
| 15 | /// </summary> | |
| 16 | /// <remarks> | |
| 17 | /// <para> | |
| 18 | /// Handles for <see cref="HandleType.Automatic"/>, <see cref="HandleType.AutoClamped"/> and | |
| 19 | /// <see cref="HandleType.Vector"/> are DERIVED here on every read rather than cached on the | |
| 20 | /// keyframe. Nothing then has to remember to invalidate them: <see cref="Keyframe"/> is a plain | |
| 21 | /// mutable object in a plain <see cref="List{T}"/>, so a moved key raises no notification, and | |
| 22 | /// a cache would go stale the first time one was dragged. The work is O(1) per segment against | |
| 23 | /// an evaluator that already sorts the whole channel on every call. | |
| 24 | /// </para> | |
| 25 | /// <para> | |
| 26 | /// The stored coordinates are only authoritative for <see cref="HandleType.Free"/> and | |
| 27 | /// <see cref="HandleType.Aligned"/>, which are the two the user positions. | |
| 28 | /// </para> | |
| 29 | /// </remarks> | |
| 30 | public static class BezierHandles | |
| 31 | { | |
| 32 | /// <summary> | |
| 33 | /// The outgoing (right) handle of <paramref name="index"/> and the incoming (left) handle | |
| 34 | /// of the key after it - the two control points of that segment. | |
| 35 | /// </summary> | |
| 36 | public static (Handle Right, Handle Left) ForSegment(List<Keyframe> sorted, int index) | |
| 37 | { | |
| 38 | Handle right = Resolve(sorted, index, leftSide: false); | |
| 39 | Handle left = Resolve(sorted, index + 1, leftSide: true); | |
| 40 | return Correct(sorted[index], right, left, sorted[index + 1]); | |
| 41 | } | |
| 42 | ||
| 43 | /// <summary>One handle of one keyframe, derived or stored according to its handle type.</summary> | |
| 44 | public static Handle Resolve(List<Keyframe> sorted, int index, bool leftSide) | |
| 45 | { | |
| 46 | Keyframe k = sorted[index]; | |
| 47 | HandleType type = leftSide ? k.LeftHandleType : k.RightHandleType; | |
| 48 | ||
| 49 | if (type is HandleType.Free or HandleType.Aligned) | |
| 50 | return leftSide | |
| 51 | ? new Handle(k.LeftHandleT, k.LeftHandleValue) | |
| 52 | : new Handle(k.RightHandleT, k.RightHandleValue); | |
| 53 | ||
| 54 | Keyframe prev = index > 0 ? sorted[index - 1] : null; | |
| 55 | Keyframe next = index < sorted.Count - 1 ? sorted[index + 1] : null; | |
| 56 | ||
| 57 | // A third of the way to the neighbour is the length at which a cubic bezier with a | |
| 58 | // matching slope reproduces the straight line through both keys. | |
| 59 | double dtPrev = prev is not null ? (k.t - prev.t) / 3.0 : (next is not null ? (next.t - k.t) / 3.0 : 0); | |
| 60 | double dtNext = next is not null ? (next.t - k.t) / 3.0 : (prev is not null ? (k.t - prev.t) / 3.0 : 0); | |
| 61 | ||
| 62 | if (type == HandleType.Vector) | |
| 63 | { | |
| 64 | // Straight at the neighbour, which makes that side of the segment a line. | |
| 65 | double vPrev = prev is not null ? k.Value + (prev.Value - k.Value) / 3.0 : k.Value; | |
| 66 | double vNext = next is not null ? k.Value + (next.Value - k.Value) / 3.0 : k.Value; | |
| 67 | return leftSide | |
| 68 | ? new Handle(k.t - dtPrev, vPrev) | |
| 69 | : new Handle(k.t + dtNext, vNext); | |
| 70 | } | |
| 71 | ||
| 72 | double slope = Slope(prev, k, next, type == HandleType.AutoClamped); | |
| 73 | ||
| 74 | if (leftSide) | |
| 75 | { | |
| 76 | double v = k.Value - slope * dtPrev; | |
| 77 | if (type == HandleType.AutoClamped && prev is not null) | |
| 78 | v = Clamp(v, prev.Value, k.Value); | |
| 79 | return new Handle(k.t - dtPrev, v); | |
| 80 | } | |
| 81 | else | |
| 82 | { | |
| 83 | double v = k.Value + slope * dtNext; | |
| 84 | if (type == HandleType.AutoClamped && next is not null) | |
| 85 | v = Clamp(v, k.Value, next.Value); | |
| 86 | return new Handle(k.t + dtNext, v); | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | /// <summary> | |
| 91 | /// The tangent through a keyframe: a TIME-AWARE central difference, unlike | |
| 92 | /// <c>Channel</c>'s Catmull-Rom tangent, which is <c>(p2 - p0) / 2</c> from the neighbours' | |
| 93 | /// values alone and so both overshoots on uneven spacing and never flattens. | |
| 94 | /// </summary> | |
| 95 | /// <remarks> | |
| 96 | /// <paramref name="clamped"/> is the whole reason a hold works: where the key is a local | |
| 97 | /// extremum - both neighbours on the same side of it, which two equal keys in a row always | |
| 98 | /// are - the tangent is zero, so the segment leaves the value flat and stays there. | |
| 99 | /// </remarks> | |
| 100 | private static double Slope(Keyframe prev, Keyframe k, Keyframe next, bool clamped) | |
| 101 | { | |
| 102 | if (prev is null && next is null) | |
| 103 | return 0; | |
| 104 | if (prev is null) | |
| 105 | return Secant(k, next); | |
| 106 | if (next is null) | |
| 107 | return Secant(prev, k); | |
| 108 | ||
| 109 | if (clamped && IsLocalExtremum(prev.Value, k.Value, next.Value)) | |
| 110 | return 0; | |
| 111 | ||
| 112 | double span = next.t - prev.t; | |
| 113 | return span > 0 ? (next.Value - prev.Value) / span : 0; | |
| 114 | } | |
| 115 | ||
| 116 | private static double Secant(Keyframe a, Keyframe b) => | |
| 117 | b.t - a.t > 0 ? (b.Value - a.Value) / (b.t - a.t) : 0; | |
| 118 | ||
| 119 | /// <summary>True when both neighbours sit on the same side of the key - a peak, a trough, or a hold.</summary> | |
| 120 | private static bool IsLocalExtremum(double prev, double value, double next) => | |
| 121 | (prev <= value && next <= value) || (prev >= value && next >= value); | |
| 122 | ||
| 123 | private static double Clamp(double v, double a, double b) => | |
| 124 | Math.Clamp(v, Math.Min(a, b), Math.Max(a, b)); | |
| 125 | ||
| 126 | /// <summary> | |
| 127 | /// Shortens handles that reach past the segment in TIME, which would make the curve fold | |
| 128 | /// back on itself and stop being a function of time. Both handles scale by the same factor, | |
| 129 | /// so the shape is preserved. This is Blender's <c>correct_bezpart</c>. | |
| 130 | /// </summary> | |
| 131 | private static (Handle, Handle) Correct(Keyframe k1, Handle right, Handle left, Keyframe k2) | |
| 132 | { | |
| 133 | double h1t = right.T - k1.t, h1v = right.Value - k1.Value; | |
| 134 | double h2t = left.T - k2.t, h2v = left.Value - k2.Value; | |
| 135 | ||
| 136 | double reach = Math.Abs(h1t) + Math.Abs(h2t); | |
| 137 | double span = Math.Abs(k2.t - k1.t); | |
| 138 | if (reach <= span || reach <= 0) | |
| 139 | return (right, left); | |
| 140 | ||
| 141 | double fac = span / reach; | |
| 142 | return (new Handle(k1.t + h1t * fac, k1.Value + h1v * fac), | |
| 143 | new Handle(k2.t + h2t * fac, k2.Value + h2v * fac)); | |
| 144 | } | |
| 145 | ||
| 146 | /// <summary> | |
| 147 | /// The value of a cubic bezier segment at a given TIME. | |
| 148 | /// </summary> | |
| 149 | /// <remarks> | |
| 150 | /// A bezier is parameterised by u, not by time, so the time has to be inverted first. | |
| 151 | /// After <see cref="Correct"/> the x component is monotonic over the segment, which makes | |
| 152 | /// bisection both correct and unconditionally convergent - worth more here than the speed | |
| 153 | /// of a Newton step that can leave [0,1] on a near-vertical segment. | |
| 154 | /// </remarks> | |
| 155 | public static double EvaluateSegment(Keyframe k1, Handle right, Handle left, Keyframe k2, double t) | |
| 156 | { | |
| 157 | double span = k2.t - k1.t; | |
| 158 | if (span <= 0) | |
| 159 | return k2.Value; | |
| 160 | ||
| 161 | double lo = 0, hi = 1; | |
| 162 | for (int i = 0; i < 60; i++) | |
| 163 | { | |
| 164 | double mid = (lo + hi) / 2.0; | |
| 165 | if (Cubic(k1.t, right.T, left.T, k2.t, mid) < t) | |
| 166 | lo = mid; | |
| 167 | else | |
| 168 | hi = mid; | |
| 169 | } | |
| 170 | ||
| 171 | return Cubic(k1.Value, right.Value, left.Value, k2.Value, (lo + hi) / 2.0); | |
| 172 | } | |
| 173 | ||
| 174 | private static double Cubic(double p0, double p1, double p2, double p3, double u) | |
| 175 | { | |
| 176 | double n = 1.0 - u; | |
| 177 | return n * n * n * p0 | |
| 178 | + 3.0 * n * n * u * p1 | |
| 179 | + 3.0 * n * u * u * p2 | |
| 180 | + u * u * u * p3; | |
| 181 | } | |
| 182 | } |
| … | … | @@ -38,7 +38,12 @@ public class Channel |
| 38 | 38 | |
| 39 | 39 | var tNorm = (t - previousKeyframe.t) / (nextKeyframe.t - previousKeyframe.t); |
| 40 | 40 | |
| 41 | if (previousKeyframe.Type != KeyframeType.Legacy) | |
| 42 | return Drive(EvaluatePreset(keyframes, previousKeyframe, nextKeyframe, tNorm), t); | |
| 43 | ||
| 41 | 44 | //easing |
| 45 | // Legacy only. A preset carries its own shape, and for the easing presets the | |
| 46 | // direction is applied inside Easing.Apply instead. | |
| 42 | 47 | // Every case must satisfy f(0)=0 and f(1)=1, or the segment does not start at the |
| 43 | 48 | // previous keyframe and end at the next one. `Out` was `1 - t^p`, which is 1 at |
| 44 | 49 | // t=0 and 0 at t=1 - it ran the whole segment BACKWARDS, from the next keyframe's |
| … | … | @@ -70,12 +75,61 @@ public class Channel |
| 70 | 75 | else throw new NotImplementedException(); |
| 71 | 76 | } |
| 72 | 77 | |
| 73 | if (AudioChannelDriver is not null) | |
| 78 | return Drive(eval, t); | |
| 79 | } | |
| 80 | ||
| 81 | /// <summary> | |
| 82 | /// Where a keyframe's bezier handles actually sit, deriving the automatic ones. | |
| 83 | /// </summary> | |
| 84 | /// <remarks> | |
| 85 | /// The editor needs this to DRAW handles, and to seed the stored coordinates when a handle | |
| 86 | /// is switched to a user-positioned type - without which the curve would jump the moment | |
| 87 | /// "Free" was picked, because the stored coordinates start at the origin. | |
| 88 | /// </remarks> | |
| 89 | public (Handle Left, Handle Right) ResolveHandles(Keyframe key) | |
| 90 | { | |
| 91 | var sorted = Keyframes.OrderBy(k => k.t).ToList(); | |
| 92 | int index = sorted.IndexOf(key); | |
| 93 | if (index < 0) | |
| 94 | return (new Handle(key.t, key.Value), new Handle(key.t, key.Value)); | |
| 95 | ||
| 96 | return (BezierHandles.Resolve(sorted, index, leftSide: true), | |
| 97 | BezierHandles.Resolve(sorted, index, leftSide: false)); | |
| 98 | } | |
| 99 | ||
| 100 | /// <summary>Applies the channel's audio driver, if it has one, on top of an interpolated value.</summary> | |
| 101 | private double Drive(double eval, double t) => | |
| 102 | AudioChannelDriver is not null ? AudioChannelDriver.Apply(eval, t) : eval; | |
| 103 | ||
| 104 | /// <summary> | |
| 105 | /// Evaluates a <see cref="KeyframeType"/> preset over a segment, given its NORMALIZED | |
| 106 | /// parameter. | |
| 107 | /// </summary> | |
| 108 | /// <remarks> | |
| 109 | /// <paramref name="tNorm"/> is deliberately the un-eased parameter: the legacy | |
| 110 | /// <see cref="Keyframe.EasingPower"/> ramp is a different mechanism from these presets and | |
| 111 | /// composing the two would shape every segment twice. | |
| 112 | /// </remarks> | |
| 113 | private static double EvaluatePreset(List<Keyframe> keyframes, Keyframe previous, Keyframe next, double tNorm) | |
| 114 | { | |
| 115 | switch (previous.Type) | |
| 74 | 116 | { |
| 75 | eval = AudioChannelDriver.Apply(eval, t); | |
| 76 | } | |
| 117 | case KeyframeType.Constant: | |
| 118 | return previous.Value; | |
| 77 | 119 | |
| 78 | return eval; | |
| 120 | case KeyframeType.Linear: | |
| 121 | return (1.0 - tNorm) * previous.Value + tNorm * next.Value; | |
| 122 | ||
| 123 | case KeyframeType.Bezier: | |
| 124 | int index = keyframes.IndexOf(previous); | |
| 125 | (Handle right, Handle left) = BezierHandles.ForSegment(keyframes, index); | |
| 126 | double t = previous.t + tNorm * (next.t - previous.t); | |
| 127 | return BezierHandles.EvaluateSegment(previous, right, left, next, t); | |
| 128 | ||
| 129 | default: | |
| 130 | double eased = Easing.Apply(previous.Type, previous.EasingDirection, tNorm, previous); | |
| 131 | return (1.0 - eased) * previous.Value + eased * next.Value; | |
| 132 | } | |
| 79 | 133 | } |
| 80 | 134 | |
| 81 | 135 | private static double InOutPowerEasing(double t, double pow1, double pow2) |
| … | … | @@ -23,21 +23,39 @@ public class Dopesheet |
| 23 | 23 | } |
| 24 | 24 | else |
| 25 | 25 | { |
| 26 | channel.Keyframes.Add(new Keyframe | |
| 27 | { | |
| 28 | t = t, | |
| 29 | Value = value | |
| 30 | }); | |
| 26 | channel.Keyframes.Add(NewKeyframe(t, value)); | |
| 31 | 27 | } |
| 32 | 28 | } |
| 33 | 29 | else |
| 34 | 30 | { |
| 35 | Channels[path] = new Channel(name, new Keyframe | |
| 36 | { | |
| 37 | t = t, | |
| 38 | Value = value | |
| 39 | }); | |
| 31 | Channels[path] = new Channel(name, NewKeyframe(t, value)); | |
| 40 | 32 | } |
| 41 | 33 | } |
| 42 | 34 | |
| 35 | /// <summary> | |
| 36 | /// A keyframe as the ◇ diamond makes one. | |
| 37 | /// </summary> | |
| 38 | /// <remarks> | |
| 39 | /// <para> | |
| 40 | /// Bezier with auto-clamped handles, which is Blender's default and NOT the engine's: | |
| 41 | /// <see cref="Keyframe"/> still defaults to <see cref="InterpolationMode.CatmullRom"/>, so | |
| 42 | /// that keyframes loaded from existing files keep evaluating exactly as they did. | |
| 43 | /// </para> | |
| 44 | /// <para> | |
| 45 | /// The old default is what made a hold impossible. Catmull-Rom's tangent is | |
| 46 | /// <c>(p2 - p0) / 2</c>, from the neighbours' VALUES alone, so it never flattens: two keys | |
| 47 | /// set to the same value still got a slope, and the segment between them had to leave that | |
| 48 | /// value and come back - 4.8% off a held 1.0 in the reported case. An auto-clamped handle | |
| 49 | /// is zero at a local extremum, which is what two equal keys in a row are. | |
| 50 | /// </para> | |
| 51 | /// </remarks> | |
| 52 | private static Keyframe NewKeyframe(double t, double value) => new() | |
| 53 | { | |
| 54 | t = t, | |
| 55 | Value = value, | |
| 56 | Type = KeyframeType.Bezier, | |
| 57 | LeftHandleType = HandleType.AutoClamped, | |
| 58 | RightHandleType = HandleType.AutoClamped, | |
| 59 | }; | |
| 60 | ||
| 43 | 61 | } |
| … | … | @@ -0,0 +1,111 @@ |
| 1 | using System; | |
| 2 | ||
| 3 | namespace IFSEngine.Animation; | |
| 4 | ||
| 5 | /// <summary> | |
| 6 | /// The easing equations behind <see cref="KeyframeType"/>'s "Easing (by strength)" and | |
| 7 | /// "Dynamic Effects" groups - the Penner set Blender also uses. | |
| 8 | /// </summary> | |
| 9 | /// <remarks> | |
| 10 | /// Every function here maps [0,1] to a normalized progress with <c>f(0) = 0</c> and | |
| 11 | /// <c>f(1) = 1</c>. That property is not decoration: violating it is exactly the bug | |
| 12 | /// <see cref="EasingDirection.Out"/> shipped with (<c>1 - t^p</c> is 1 at t=0), which ran | |
| 13 | /// every segment backwards. It is asserted per type in the test suite. | |
| 14 | /// </remarks> | |
| 15 | internal static class Easing | |
| 16 | { | |
| 17 | /// <summary> | |
| 18 | /// Applies <paramref name="type"/> in <paramref name="direction"/> to a normalized | |
| 19 | /// segment parameter. | |
| 20 | /// </summary> | |
| 21 | public static double Apply(KeyframeType type, EasingDirection direction, double t, Keyframe key) | |
| 22 | { | |
| 23 | t = Math.Clamp(t, 0, 1); | |
| 24 | return direction switch | |
| 25 | { | |
| 26 | EasingDirection.In => In(type, t, key), | |
| 27 | EasingDirection.Out => 1.0 - In(type, 1.0 - t, key), | |
| 28 | // In over the first half, Out over the second, each at half scale. | |
| 29 | EasingDirection.InOut => t < 0.5 | |
| 30 | ? In(type, t * 2.0, key) / 2.0 | |
| 31 | : 1.0 - In(type, 2.0 - t * 2.0, key) / 2.0, | |
| 32 | _ => t, | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | private static double In(KeyframeType type, double t, Keyframe key) => type switch | |
| 37 | { | |
| 38 | KeyframeType.Sinusoidal => 1.0 - Math.Cos(t * Math.PI / 2.0), | |
| 39 | KeyframeType.Quadratic => t * t, | |
| 40 | KeyframeType.Cubic => t * t * t, | |
| 41 | KeyframeType.Quartic => t * t * t * t, | |
| 42 | KeyframeType.Quintic => t * t * t * t * t, | |
| 43 | KeyframeType.Exponential => ExponentialIn(t), | |
| 44 | KeyframeType.Circular => 1.0 - Math.Sqrt(Math.Max(1.0 - t * t, 0)), | |
| 45 | KeyframeType.Back => BackIn(t, key.BackOvershoot), | |
| 46 | KeyframeType.Bounce => 1.0 - BounceOut(1.0 - t), | |
| 47 | KeyframeType.Elastic => ElasticIn(t, key.ElasticAmplitude, key.ElasticPeriod), | |
| 48 | _ => t, | |
| 49 | }; | |
| 50 | ||
| 51 | /// <summary> | |
| 52 | /// The classic exponential ramp, normalized so that f(0) is exactly 0. | |
| 53 | /// </summary> | |
| 54 | /// <remarks> | |
| 55 | /// Penner's <c>2^(10(t-1))</c> is 0.0009765625 at t=0, not 0, and the usual implementations | |
| 56 | /// paper over it with a special case at exactly zero - which leaves the curve DISCONTINUOUS | |
| 57 | /// a hair's breadth into the segment. Subtracting that offset and rescaling makes it start | |
| 58 | /// where its keyframe does without changing the shape anywhere it matters. | |
| 59 | /// </remarks> | |
| 60 | private static double ExponentialIn(double t) | |
| 61 | { | |
| 62 | if (t <= 0) | |
| 63 | return 0; | |
| 64 | const double floor = 1.0 / 1024.0; // 2^-10, the value at t = 0 | |
| 65 | return (Math.Pow(2, 10 * (t - 1)) - floor) / (1.0 - floor); | |
| 66 | } | |
| 67 | ||
| 68 | /// <summary>Overshoots backwards before running forward. <paramref name="s"/> is the overshoot.</summary> | |
| 69 | private static double BackIn(double t, double s) => t * t * ((s + 1.0) * t - s); | |
| 70 | ||
| 71 | private static double BounceOut(double t) | |
| 72 | { | |
| 73 | const double n = 7.5625, d = 2.75; | |
| 74 | if (t < 1.0 / d) | |
| 75 | return n * t * t; | |
| 76 | if (t < 2.0 / d) | |
| 77 | { | |
| 78 | t -= 1.5 / d; | |
| 79 | return n * t * t + 0.75; | |
| 80 | } | |
| 81 | if (t < 2.5 / d) | |
| 82 | { | |
| 83 | t -= 2.25 / d; | |
| 84 | return n * t * t + 0.9375; | |
| 85 | } | |
| 86 | t -= 2.625 / d; | |
| 87 | return n * t * t + 0.984375; | |
| 88 | } | |
| 89 | ||
| 90 | /// <summary> | |
| 91 | /// A decaying oscillation. Zero <paramref name="amplitude"/> or <paramref name="period"/> | |
| 92 | /// mean "auto", which is Penner's default of a unit amplitude over a 0.3 period. | |
| 93 | /// </summary> | |
| 94 | private static double ElasticIn(double t, double amplitude, double period) | |
| 95 | { | |
| 96 | if (t <= 0) | |
| 97 | return 0; | |
| 98 | if (t >= 1) | |
| 99 | return 1; | |
| 100 | ||
| 101 | double p = period > 0 ? period : 0.3; | |
| 102 | double a = amplitude > 0 ? amplitude : 1.0; | |
| 103 | ||
| 104 | // The phase shift that puts f(1) = 1 for the chosen amplitude. | |
| 105 | double s = a < 1.0 ? p / 4.0 : p / (2.0 * Math.PI) * Math.Asin(Math.Clamp(1.0 / a, -1, 1)); | |
| 106 | if (a < 1.0) | |
| 107 | a = 1.0; | |
| 108 | ||
| 109 | return -(a * Math.Pow(2, 10 * (t - 1)) * Math.Sin((t - 1 - s) * 2.0 * Math.PI / p)); | |
| 110 | } | |
| 111 | } |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | namespace IFSEngine.Animation; | |
| 2 | ||
| 3 | /// <summary> | |
| 4 | /// How a bezier keyframe's handle is derived, matching Blender's handle types. | |
| 5 | /// </summary> | |
| 6 | public enum HandleType | |
| 7 | { | |
| 8 | /// <summary>Positioned by the user; the two handles are independent.</summary> | |
| 9 | Free = 0, | |
| 10 | ||
| 11 | /// <summary>Positioned by the user, but kept collinear with the opposite handle.</summary> | |
| 12 | Aligned, | |
| 13 | ||
| 14 | /// <summary>Points at the neighbouring keyframe, making that side of the segment straight.</summary> | |
| 15 | Vector, | |
| 16 | ||
| 17 | /// <summary>Derived from the neighbours, smoothing through the key.</summary> | |
| 18 | Automatic, | |
| 19 | ||
| 20 | /// <summary> | |
| 21 | /// <see cref="Automatic"/>, but flattened where the key is a local extremum and clamped so | |
| 22 | /// a segment cannot overshoot its endpoints. Blender's default, and the reason a hold | |
| 23 | /// between two equal keyframes stays flat there. | |
| 24 | /// </summary> | |
| 25 | AutoClamped, | |
| 26 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | namespace IFSEngine.Animation; | |
| 1 | namespace IFSEngine.Animation; | |
| 2 | 2 | |
| 3 | 3 | public class Keyframe |
| 4 | 4 | { |
| … | … | @@ -7,6 +7,74 @@ public class Keyframe |
| 7 | 7 | public EasingDirection EasingDirection { get; set; } = EasingDirection.InOut; |
| 8 | 8 | public double t { get; set; } |
| 9 | 9 | public double Value { get; set; } |
| 10 | //public double LeftTangent; | |
| 11 | //public double RightTangent; | |
| 10 | ||
| 11 | private KeyframeType _type = KeyframeType.Legacy; | |
| 12 | ||
| 13 | /// <summary> | |
| 14 | /// The Blender-style preset governing the segment to this keyframe's RIGHT. | |
| 15 | /// </summary> | |
| 16 | /// <remarks> | |
| 17 | /// Setting this also projects <see cref="InterpolationMode"/> onto the nearest legacy | |
| 18 | /// equivalent, so a file written here still evaluates - approximately, but without | |
| 19 | /// throwing - in the upstream Windows app, which knows nothing about this property. See | |
| 20 | /// <see cref="KeyframeType"/> for why a new enum value could not be used instead. | |
| 21 | /// </remarks> | |
| 22 | public KeyframeType Type | |
| 23 | { | |
| 24 | get => _type; | |
| 25 | set | |
| 26 | { | |
| 27 | _type = value; | |
| 28 | if (value != KeyframeType.Legacy) | |
| 29 | InterpolationMode = LegacyEquivalent(value); | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | public HandleType LeftHandleType { get; set; } = HandleType.AutoClamped; | |
| 34 | public HandleType RightHandleType { get; set; } = HandleType.AutoClamped; | |
| 35 | ||
| 36 | /// <summary>Incoming handle, in (time, value) space. Authoritative only for Free and Aligned.</summary> | |
| 37 | public double LeftHandleT { get; set; } | |
| 38 | public double LeftHandleValue { get; set; } | |
| 39 | ||
| 40 | /// <summary>Outgoing handle, in (time, value) space. Authoritative only for Free and Aligned.</summary> | |
| 41 | public double RightHandleT { get; set; } | |
| 42 | public double RightHandleValue { get; set; } | |
| 43 | ||
| 44 | /// <summary>How far <see cref="KeyframeType.Back"/> overshoots. Blender's default.</summary> | |
| 45 | public double BackOvershoot { get; set; } = 1.70158; | |
| 46 | ||
| 47 | /// <summary>Zero means auto.</summary> | |
| 48 | public double ElasticAmplitude { get; set; } | |
| 49 | public double ElasticPeriod { get; set; } | |
| 50 | ||
| 51 | /// <summary>The pre-existing mode that most nearly reproduces a preset.</summary> | |
| 52 | public static InterpolationMode LegacyEquivalent(KeyframeType type) => type switch | |
| 53 | { | |
| 54 | KeyframeType.Constant => InterpolationMode.Constant, | |
| 55 | KeyframeType.Linear => InterpolationMode.Linear, | |
| 56 | _ => InterpolationMode.CatmullRom, | |
| 57 | }; | |
| 58 | ||
| 59 | // ---- conditional serialization (Newtonsoft honours ShouldSerialize<Property>) ---- | |
| 60 | // | |
| 61 | // Without these, every keyframe ever written would gain eleven new fields, including the | |
| 62 | // overwhelming majority that are plain Legacy keys from existing files. Suppressing the | |
| 63 | // ones a keyframe does not use keeps a legacy .ifsjson byte-identical across a load/save | |
| 64 | // round-trip, which is the property the fork's compatibility claim rests on. | |
| 65 | ||
| 66 | public bool ShouldSerializeType() => Type != KeyframeType.Legacy; | |
| 67 | public bool ShouldSerializeLeftHandleType() => Type == KeyframeType.Bezier; | |
| 68 | public bool ShouldSerializeRightHandleType() => Type == KeyframeType.Bezier; | |
| 69 | public bool ShouldSerializeLeftHandleT() => IsUserPositioned(LeftHandleType); | |
| 70 | public bool ShouldSerializeLeftHandleValue() => IsUserPositioned(LeftHandleType); | |
| 71 | public bool ShouldSerializeRightHandleT() => IsUserPositioned(RightHandleType); | |
| 72 | public bool ShouldSerializeRightHandleValue() => IsUserPositioned(RightHandleType); | |
| 73 | public bool ShouldSerializeBackOvershoot() => Type == KeyframeType.Back; | |
| 74 | public bool ShouldSerializeElasticAmplitude() => Type == KeyframeType.Elastic; | |
| 75 | public bool ShouldSerializeElasticPeriod() => Type == KeyframeType.Elastic; | |
| 76 | ||
| 77 | /// <summary>Derived handles are recomputed on read, so only these two are worth storing.</summary> | |
| 78 | private bool IsUserPositioned(HandleType handle) => | |
| 79 | Type == KeyframeType.Bezier && handle is HandleType.Free or HandleType.Aligned; | |
| 12 | 80 | } |
| … | … | @@ -0,0 +1,46 @@ |
| 1 | namespace IFSEngine.Animation; | |
| 2 | ||
| 3 | /// <summary> | |
| 4 | /// Blender's keyframe interpolation presets, as offered by its Graph Editor's | |
| 5 | /// "Set Keyframe Interpolation" menu. | |
| 6 | /// </summary> | |
| 7 | /// <remarks> | |
| 8 | /// <para> | |
| 9 | /// This is ADDITIVE to <see cref="InterpolationMode"/>, not a replacement for it, and the | |
| 10 | /// distinction is a file-format one. <c>IfsSerializer</c> uses Newtonsoft with no | |
| 11 | /// <c>StringEnumConverter</c>, so enums are written as bare integers - a new member of | |
| 12 | /// <see cref="InterpolationMode"/> would write an integer the upstream Windows app cannot | |
| 13 | /// read, and <see cref="Channel.EvaluateAt"/> ends in <c>throw new NotImplementedException()</c>. | |
| 14 | /// Unknown PROPERTIES, on the other hand, Newtonsoft ignores by default, so a new field | |
| 15 | /// degrades where a new enum value crashes. | |
| 16 | /// </para> | |
| 17 | /// <para> | |
| 18 | /// Hence <see cref="Legacy"/> = 0: a keyframe from a file written before this existed | |
| 19 | /// deserializes to it and evaluates exactly as it always did, through | |
| 20 | /// <see cref="Keyframe.InterpolationMode"/> and <see cref="Keyframe.EasingPower"/>. | |
| 21 | /// </para> | |
| 22 | /// </remarks> | |
| 23 | public enum KeyframeType | |
| 24 | { | |
| 25 | /// <summary>Evaluate through <see cref="Keyframe.InterpolationMode"/>. The pre-existing behaviour.</summary> | |
| 26 | Legacy = 0, | |
| 27 | ||
| 28 | // Interpolation | |
| 29 | Constant, | |
| 30 | Linear, | |
| 31 | Bezier, | |
| 32 | ||
| 33 | // Easing (by strength) | |
| 34 | Sinusoidal, | |
| 35 | Quadratic, | |
| 36 | Cubic, | |
| 37 | Quartic, | |
| 38 | Quintic, | |
| 39 | Exponential, | |
| 40 | Circular, | |
| 41 | ||
| 42 | // Dynamic effects | |
| 43 | Back, | |
| 44 | Bounce, | |
| 45 | Elastic, | |
| 46 | } |