OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 7897b78

Blender's keyframe types and real bezier handles
"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?" Catmull-Rom, which every keyframe got by default. Its tangent is (p2 - p0) / 2, taken from the neighbours' VALUES alone, so it never flattens: two keys set to the same value still get a slope, and the segment between them has to leave the held value and come back. Measured at 4.8% off a held 1.0. The same tangent ignores the time axis entirely, so uneven key spacing overshoots as well. Blender does not have this problem because its default is Bezier with AUTO CLAMPED handles, which zeroes the tangent at a local extremum - which two equal keys in a row always are. Adds KeyframeType, carrying Blender's thirteen presets, as a keyframe-level dropdown in the panel's own menu order. HandleType and 2D handle coordinates replace the LeftTangent/RightTangent fields that had sat commented out since before the fork, and the handles draw and drag in the curve editor. Handles for the derived types are recomputed on every read rather than cached: Keyframe is a plain mutable object in a plain List, so a moved key raises no notification and a cache would go stale the first time one was dragged. New keyframes are stamped Bezier + AutoClamped at the ONE creation site, Dopesheet.AddOrUpdateChannel, rather than by changing Keyframe's own default. That is the whole compatibility story in one line: a keyframe loaded from an existing file has no Type property, deserializes to KeyframeType.Legacy = 0, and evaluates through the old path unchanged, wobble included. Legacy is offered in the dropdown for exactly that reason, and it means "use InterpolationMode" - it is NOT a synonym for Catmull-Rom, which is why it is not labelled as one. KeyframeType had to be a new FIELD rather than new members on InterpolationMode. IfsSerializer uses Newtonsoft with no StringEnumConverter, so enums are written as bare integers; a new InterpolationMode member would write an integer the upstream Windows app cannot read, and its Channel.EvaluateAt ends in a throw. Unknown properties Newtonsoft ignores. Setting Type also projects InterpolationMode onto the nearest legacy equivalent, so a file written here still renders - approximately, not exactly - upstream. ShouldSerialize keeps a legacy keyframe byte-identical across a round-trip and limits a new one to three added fields; verified through IfsSerializer itself, not just JsonConvert. Two defects found while pinning the boundary conditions: - Penner's exponential is 2^-10 at t=0, not 0, and the usual special case at exactly zero leaves the curve discontinuous a hair into the segment. It is normalized to start where its keyframe does. - Circular's ease-in is vertical at its end, so it is legitimately 1.4e-4 short a millionth of a second before the next key. The endpoint property is asserted to 0.5% of the range rather than to an exact match, because tightening it would be asserting something untrue about a circle. 54 tests: every preset in every easing direction starts and ends on its own keyframes (the property EasingDirection.Out violated for years by the full range), none produces a non-finite value, vector handles give a straight line, auto-clamped does not overshoot, over-long handles do not fold the curve, and the dropdown is reachable from a selected keyframe and reaches the evaluator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7897b78dad62b267535d0077f3bc52fb33eefe14
by plankatron <fleeky@gmail.com> · 2026-09-05T18:48:54.000Z

Changed files (11) +1395 -27

AvaloniaShell.Tests/KeyframeEditorUiTests.cs +139 -0
……@@ -0,0 +1,139 @@
1using System;
2using System.Linq;
3
4using Avalonia;
5using Avalonia.Controls;
6using Avalonia.Headless.XUnit;
7using Avalonia.VisualTree;
8
9using IFSEngine.Animation;
10using IFSEngine.Model;
11
12using Xunit;
13
14namespace 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>
25public 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}
AvaloniaShell.Tests/KeyframeTypeTests.cs +348 -0
……@@ -0,0 +1,348 @@
1using System;
2using System.Collections.Generic;
3using System.Linq;
4
5using IFSEngine.Animation;
6
7using Newtonsoft.Json;
8
9using Xunit;
10
11namespace 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>
35public 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}
AvaloniaShell/AnimationPanel.cs +178 -5
……@@ -1,4 +1,4 @@
1using System;
1using System;
22using System.Collections.Generic;
33using System.Globalization;
44using System.Linq;
……@@ -55,6 +55,27 @@ public sealed class AnimationPanel : Grid
5555 private readonly NumericUpDown _length = new() { Minimum = 0.1m, Maximum = 3600, Increment = 1, FormatString = "0.##", Width = 90 };
5656 private readonly NumericUpDown _fps = new() { Minimum = 1, Maximum = 240, Increment = 1, FormatString = "0", Width = 74 };
5757
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
5879 private readonly ComboBox _interpolation = new() { MinWidth = 130 };
5980 private readonly ComboBox _easing = new() { MinWidth = 110 };
6081 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
336357 Children.Add(middle);
337358
338359 // ---- keyframe + audio editors ----
360 _keyType.ItemsSource = KeyTypes;
361 _handles.ItemsSource = HandleTypes;
339362 // Two InterpolationMode enums exist since the palette merge: this is the keyframe's.
340363 _interpolation.ItemsSource = Enum.GetValues<IFSEngine.Animation.InterpolationMode>();
341364 _easing.ItemsSource = Enum.GetValues<EasingDirection>();
342365
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
343405 _interpolation.SelectionChanged += (_, _) => EditKeyframe(k =>
344406 {
345407 if (_interpolation.SelectedItem is IFSEngine.Animation.InterpolationMode m)
……@@ -373,9 +435,20 @@ public sealed class AnimationPanel : Grid
373435 // shown through a nullVisibilityConverter on EditedChannel). Keeping them permanently
374436 // across the full width was the single biggest reason this tab felt cluttered: it put
375437 // 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 })
377441 c.HorizontalAlignment = HorizontalAlignment.Stretch;
378442
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
379452 _keyframeEditor = new StackPanel
380453 {
381454 Margin = new Thickness(4),
……@@ -385,9 +458,13 @@ public sealed class AnimationPanel : Grid
385458 {
386459 Caption("KEYFRAME"),
387460 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,
391468 deleteKey,
392469 },
393470 };
……@@ -869,6 +946,96 @@ public sealed class AnimationPanel : Grid
869946 Changed?.Invoke();
870947 }
871948
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
8721039 private void RefreshKeyframeFields()
8731040 {
8741041 Keyframe k = _sheet.SelectedKeyframe;
……@@ -882,10 +1049,16 @@ public sealed class AnimationPanel : Grid
8821049 _keyframeEditor.IsVisible = has;
8831050 if (!has)
8841051 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);
8851057 _interpolation.SelectedItem = k.InterpolationMode;
8861058 _easing.SelectedItem = k.EasingDirection;
8871059 _easingPower.Value = ToDecimal(k.EasingPower, 0.1m, 10m, 1m);
8881060 _keyValue.Value = ToDecimal(k.Value, KeyValueLimit * -1, KeyValueLimit, 0m);
1061 ShowRowsFor(k);
8891062 }
8901063 finally { _syncing = false; }
8911064 }
AvaloniaShell/CurveCanvas.cs +208 -5
……@@ -1,4 +1,4 @@
1using System;
1using System;
22using System.Collections.Generic;
33using System.Globalization;
44using System.Linq;
……@@ -35,10 +35,16 @@ namespace IFSRenderer.AvaloniaShell;
3535/// Re-deriving any of that here would let the picture drift from what actually renders.
3636/// </para>
3737/// <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.
4248/// </para>
4349/// </remarks>
4450internal sealed class CurveCanvas : Control
……@@ -46,6 +52,7 @@ internal sealed class CurveCanvas : Control
4652 private const double VerticalPad = 14;
4753 private const double KeyRadius = 4.5;
4854 private const double HitSlack = 4;
55 private const double HandleRadius = 3.5;
4956 private const double MinValueSpan = 1e-3;
5057
5158 /// <summary>Curve samples per render. Caps the cost on CSV-imported channels.</summary>
……@@ -62,6 +69,8 @@ internal sealed class CurveCanvas : Control
6269 private bool _autoFit = true;
6370
6471 private Keyframe _dragKey;
72 private Keyframe _handleKey;
73 private bool _draggingLeftHandle;
6574 private Point _dragOrigin;
6675 private bool _dragAxisLocked;
6776 private bool _dragLockToValue;
……@@ -101,6 +110,10 @@ internal sealed class CurveCanvas : Control
101110 private static readonly IBrush KeyFill = new SolidColorBrush(Color.Parse("#B0B0B0"));
102111 private static readonly IBrush KeyFillSelected = new SolidColorBrush(Colors.DarkOrange);
103112 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"));
104117 private static readonly IPen PlayheadPen = new Pen(Brushes.White, 1.5);
105118 private static readonly IPen PlayheadHaloPen =
106119 new Pen(new SolidColorBrush(Color.Parse("#88000000")), 4);
……@@ -280,6 +293,7 @@ internal sealed class CurveCanvas : Control
280293
281294 DrawValueGrid(ctx);
282295 DrawCurve(ctx, channel);
296 DrawHandles(ctx, channel);
283297 DrawKeyframes(ctx, channel);
284298
285299 double px = TimeToX(CurrentTime);
……@@ -368,6 +382,36 @@ internal sealed class CurveCanvas : Control
368382 ctx.DrawGeometry(null, ActiveSegmentPen, geometry);
369383 }
370384
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
371415 private void DrawKeyframes(DrawingContext ctx, Channel channel)
372416 {
373417 foreach (Keyframe k in channel.Keyframes)
……@@ -402,6 +446,34 @@ internal sealed class CurveCanvas : Control
402446 return hit;
403447 }
404448
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
405477 protected override void OnPointerPressed(PointerPressedEventArgs e)
406478 {
407479 base.OnPointerPressed(e);
……@@ -423,6 +495,18 @@ internal sealed class CurveCanvas : Control
423495 return;
424496 }
425497
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
426510 Keyframe key = HitKeyframe(p);
427511
428512 if (key is null && Math.Abs(TimeToX(CurrentTime) - p.X) <= PlayheadGrab)
……@@ -491,6 +575,12 @@ internal sealed class CurveCanvas : Control
491575 return;
492576 }
493577
578 if (_handleKey is not null)
579 {
580 DragHandleTo(p);
581 return;
582 }
583
494584 if (_dragKey is null)
495585 return;
496586
……@@ -506,6 +596,8 @@ internal sealed class CurveCanvas : Control
506596 if (_dragKey is null)
507597 return;
508598
599 double beforeT = _dragKey.t, beforeValue = _dragKey.Value;
600
509601 bool constrain = modifiers.HasFlag(KeyModifiers.Shift);
510602 if (constrain && !_dragAxisLocked)
511603 {
……@@ -532,7 +624,88 @@ internal sealed class CurveCanvas : Control
532624 if (!(_dragAxisLocked && !_dragLockToValue))
533625 _dragKey.Value = YToValue(p.Y);
534626
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
535673 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 }
536709 }
537710
538711 protected override void OnPointerReleased(PointerReleasedEventArgs e)
……@@ -541,9 +714,20 @@ internal sealed class CurveCanvas : Control
541714 _scrubbing = false;
542715 _panning = false;
543716 e.Pointer.Capture(null);
717 EndHandleDrag();
544718 EndDrag();
545719 }
546720
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
547731 /// <summary>Ends a keyframe drag, releasing the axis back to auto-fit.</summary>
548732 internal void EndDrag()
549733 {
……@@ -631,9 +815,28 @@ internal sealed class CurveCanvas : Control
631815
632816 if (inserted is not null && left is not null && !ReferenceEquals(inserted, left))
633817 {
818 inserted.Type = left.Type;
634819 inserted.InterpolationMode = left.InterpolationMode;
635820 inserted.EasingDirection = left.EasingDirection;
636821 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;
637840 }
638841
639842 SelectedKeyframe = inserted;
IFSEngine/Animation/BezierHandles.cs +182 -0
……@@ -0,0 +1,182 @@
1using System;
2using System.Collections.Generic;
3
4namespace IFSEngine.Animation;
5
6/// <summary>A handle position, in the channel's own (time, value) space.</summary>
7public 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>
30public 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}
IFSEngine/Animation/Channel.cs +58 -4
……@@ -38,7 +38,12 @@ public class Channel
3838
3939 var tNorm = (t - previousKeyframe.t) / (nextKeyframe.t - previousKeyframe.t);
4040
41 if (previousKeyframe.Type != KeyframeType.Legacy)
42 return Drive(EvaluatePreset(keyframes, previousKeyframe, nextKeyframe, tNorm), t);
43
4144 //easing
45 // Legacy only. A preset carries its own shape, and for the easing presets the
46 // direction is applied inside Easing.Apply instead.
4247 // Every case must satisfy f(0)=0 and f(1)=1, or the segment does not start at the
4348 // previous keyframe and end at the next one. `Out` was `1 - t^p`, which is 1 at
4449 // 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
7075 else throw new NotImplementedException();
7176 }
7277
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)
74116 {
75 eval = AudioChannelDriver.Apply(eval, t);
76 }
117 case KeyframeType.Constant:
118 return previous.Value;
77119
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 }
79133 }
80134
81135 private static double InOutPowerEasing(double t, double pow1, double pow2)
IFSEngine/Animation/Dopesheet.cs +28 -10
……@@ -23,21 +23,39 @@ public class Dopesheet
2323 }
2424 else
2525 {
26 channel.Keyframes.Add(new Keyframe
27 {
28 t = t,
29 Value = value
30 });
26 channel.Keyframes.Add(NewKeyframe(t, value));
3127 }
3228 }
3329 else
3430 {
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));
4032 }
4133 }
4234
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
4361}
IFSEngine/Animation/Easing.cs +111 -0
……@@ -0,0 +1,111 @@
1using System;
2
3namespace 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>
15internal 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}
IFSEngine/Animation/HandleType.cs +26 -0
……@@ -0,0 +1,26 @@
1namespace IFSEngine.Animation;
2
3/// <summary>
4/// How a bezier keyframe's handle is derived, matching Blender's handle types.
5/// </summary>
6public 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}
IFSEngine/Animation/Keyframe.cs +71 -3
……@@ -1,4 +1,4 @@
1namespace IFSEngine.Animation;
1namespace IFSEngine.Animation;
22
33public class Keyframe
44{
……@@ -7,6 +7,74 @@ public class Keyframe
77 public EasingDirection EasingDirection { get; set; } = EasingDirection.InOut;
88 public double t { get; set; }
99 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;
1280}
IFSEngine/Animation/KeyframeType.cs +46 -0
……@@ -0,0 +1,46 @@
1namespace 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>
23public 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}