Core - Fix profile rendering and timeline bugs found by the tests (#909)

* Core - Keep the name of renamed layer effects when loading

BaseLayerEffect.Load checked HasBeenRenamed before reading it from the entity, so renamed effects always fell back to the descriptor's display name.

* Core - Apply layer effect filters once on layers

Layer.DelegateRendering composited the layer with the pre-processed paint and also handed that paint to the brush, so color and image filters of effects applied to the brush output and again when compositing. The brush now gets a copy of the paint without those filters, like folders which apply them once.

* Core - Disable folders that get suspended while enabled

Layer.Update disables a suspended layer but Folder.Update only disabled a folder once its timeline finished, so a folder suspended outside of the editor kept rendering its children.

* Core - Update device LEDs when rotating or scaling a device

The Rotation and Scale setters only updated the RGB.NET device, the Artemis LED rectangles and the device path kept their old positions and DeviceUpdated wasn't raised until X or Y was set.

* Core - Correct the docs of the Layer and Folder constructors

They claimed the new element adds itself to the child collection of the parent, it doesn't and callers rely on adding it themselves.

* Core - Apply layer opacity once for brushes that draw with the paint

Brushes drawing with a shader or image keep the alpha of the paint they are given, so the layer opacity and opacity effects applied to the brush output and again when compositing, 50% opacity rendered at 25%. The brush now gets an opaque paint, per-LED brushes already overrode the color. The solid color test brush now draws with a shader like the real solid brush so the opacity tests cover this.

* Tests - Describe expected faded colors by their opacity

Adds an AtOpacity helper so assertions read as red at 50% over black instead of a hand-computed 127, and uses it for layer opacity, opacity effects, folder fades, the profile fade and semi-transparent drawing.

* Core - Wrap timelines around by how far they went past the main segment

Repeating timelines wrapped around by the frame time rather than by how
far they went past the end of the main segment, so wrapping could be off
by up to a frame. A timeline that was already past the end, like the
finished timeline new layers start with, still starts the main segment
over.

* Tests - Mention the defaults the timeline wrap-around tests depend on
This commit is contained in:
RobertBeekman authored and GitHub committed 2026-10-20 13:30:04 +02:00
1 parent 504845c7d2
commit a25d0f0c9c
18 files changed
+174 -67

No files matched your search

@@ -239,14 +239,16 @@ public class EventConditionTests : RenderTestBase
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle);
DataModel.Event.Trigger();
// Render defaults to a 0.1s frame, so the timeline starts playing and ends up at 0.1s
using RenderFrame on = Render(layer.Profile);
// AddLayer gives the layer a 1 second main segment and no start or end segment. 0.1s + 1.5s = 1.6s goes 0.6s past
// the end, and while toggled on the timeline wraps around rather than finishing
using RenderFrame stillOn = Render(layer.Profile, 1.5);
condition.IsMet.ShouldBeTrue();
ShouldBeOn(on);
ShouldBeOn(stillOn);
// Wrapped around the main segment rather than finishing
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.5));
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.6));
}
[Fact]
@@ -0,0 +1,32 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure;
/// <summary>
/// Helpers to describe expected colors by what produced them rather than by their channel values.
/// </summary>
public static class ExpectedColors
{
/// <summary>
/// Returns the color an LED shows when <paramref name="color" /> is drawn at the given opacity over an opaque
/// background. The canvas is cleared to black before each frame, so that is the default background.
/// </summary>
/// <param name="color">The color being drawn</param>
/// <param name="opacity">The opacity in percent, like the opacity properties of layers and effects</param>
/// <param name="over">The opaque color underneath, defaults to black</param>
public static SKColor AtOpacity(this SKColor color, float opacity, SKColor? over = null)
{
SKColor background = over ?? SKColors.Black;
float alpha = opacity / 100f;
return new SKColor(
Blend(color.Red, background.Red, alpha),
Blend(color.Green, background.Green, alpha),
Blend(color.Blue, background.Blue, alpha)
);
}
private static byte Blend(byte top, byte bottom, float alpha)
{
return (byte) MathF.Round(top * alpha + bottom * (1 - alpha));
}
}
@@ -19,8 +19,10 @@ public class SolidColorTestBrush : LayerBrush<SolidColorTestBrushProperties>
public override void Render(SKCanvas canvas, SKRect bounds, SKPaint paint)
{
// Like the real brushes this overrides the paint color, the layer applies its opacity when compositing
paint.Color = Properties.Color.CurrentValue;
// Like the real solid brush this draws with a shader, which keeps the alpha of the paint it is given
using SKShader shader = SKShader.CreateColor(Properties.Color.CurrentValue);
paint.Shader = shader;
canvas.DrawRect(bounds, paint);
paint.Shader = null;
}
}
@@ -122,7 +122,7 @@ public sealed class RenderHarness : IRenderer, IDisposable
/// <returns>The layer and its brush, the brush exposes the brush properties</returns>
public (Layer Layer, TBrush Brush) AddLayer<TBrush>(Folder parent, IEnumerable<ArtemisLed> leds, string name = "Test layer", int? order = null) where TBrush : BaseLayerBrush
{
// Note: despite its documentation the Layer constructor does not add the layer to the parent
// The Layer constructor does not add the layer to the parent
Layer layer = new(parent, name);
parent.AddChild(layer, order);
layer.AddLeds(leds);
@@ -177,7 +177,8 @@ public class CompositingTests : RenderTestBase
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 127));
// Blue plus red is magenta, the folder fades that as a whole
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Magenta.AtOpacity(50));
}
#endregion
@@ -38,7 +38,7 @@ public class FolderEffectTests : RenderTestBase
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, opacity);
after.ShouldOnlyDifferFrom(before, busy.Leds, c => Scale(c, 127));
after.ShouldOnlyDifferFrom(before, busy.Leds, c => c.AtOpacity(50));
}
[Fact]
@@ -79,7 +79,7 @@ public class FolderEffectTests : RenderTestBase
// The nested children get both, the rest of the folder only gets the swap
ArtemisLed[] outerOnly = busy.Leds.Except(busy.NestedLeds).ToArray();
foreach (ArtemisLed led in busy.NestedLeds)
after.LedColors[led].ShouldBeColor(Scale(Swap(before.LedColors[led]), 127), 3);
after.LedColors[led].ShouldBeColor(Swap(before.LedColors[led]).AtOpacity(50), 3);
after.ShouldOnlyDifferFrom(before, outerOnly, Swap, busy.NestedLeds);
}
@@ -109,7 +109,7 @@ public class FolderEffectTests : RenderTestBase
using RenderFrame frame = Render(profile);
// The children are flattened before the folder is faded, the bottom layer does not shine through
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(0, 127, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Lime.AtOpacity(50));
}
[Fact]
@@ -125,7 +125,7 @@ public class FolderEffectTests : RenderTestBase
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(128, 127, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Lime.AtOpacity(50, over: SKColors.Red));
}
[Fact]
@@ -201,11 +201,6 @@ public class FolderEffectTests : RenderTestBase
return new SKColor(color.Blue, color.Green, color.Red, color.Alpha);
}
private static SKColor Scale(SKColor color, byte factor)
{
return new SKColor((byte) (color.Red * factor / 255), (byte) (color.Green * factor / 255), (byte) (color.Blue * factor / 255));
}
private static SKColor Multiply(SKColor color, SKColor with)
{
return new SKColor((byte) (color.Red * with.Red / 255), (byte) (color.Green * with.Green / 255), (byte) (color.Blue * with.Blue / 255));
@@ -18,7 +18,7 @@ public class LayerEffectTests : RenderTestBase
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
[Fact]
@@ -32,11 +32,11 @@ public class LayerEffectTests : RenderTestBase
using RenderFrame frame = Render(profile);
// 50% of 50%
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(63, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(25));
}
[Fact]
public void PreProcessColorFilter_IsAppliedTwiceOnLayers()
public void PreProcessColorFilter_IsAppliedOnceOnLayers()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
@@ -44,11 +44,21 @@ public class LayerEffectTests : RenderTestBase
using RenderFrame frame = Render(profile);
// Pinned as-is, likely unintended: Layer.DelegateRendering composites with the pre-processed paint and also hands
// that same paint to the brush, so for any brush drawing with that paint (regular and per-LED alike) the filter
// applies to the brush output and again when compositing.
// Swapping red and blue twice cancels out, a hue shift effect would shift twice. Folders apply it once.
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
// Swapping red and blue twice would cancel out
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void PreProcessColorFilter_IsAppliedOnceOnPerLedBrushes()
{
Profile profile = Harness.CreateProfile();
(Layer layer, PerLedTestBrush brush) = Harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
brush.ColorSelector = (_, _) => SKColors.Red;
Harness.AddEffect<ChannelSwapTestEffect>(layer);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
@@ -137,8 +147,8 @@ public class LayerEffectTests : RenderTestBase
using RenderTimeline timeline = RenderTimeline(profile, 0.25, 0.75);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(191, 0, 0));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(63, 0, 0));
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(75));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(25));
}
[Fact]
@@ -17,7 +17,7 @@ public class LayerTransformTests : RenderTestBase
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
[Fact]
@@ -30,7 +30,22 @@ public class LayerTransformTests : RenderTestBase
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
[Fact]
public void Opacity_AppliesOnceToGradientBrushes()
{
Profile profile = Harness.CreateProfile();
(Layer layer, GradientTestBrush brush) = Harness.AddLayer<GradientTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
brush.Properties.Left.SetCurrentValue(SKColors.Red);
brush.Properties.Right.SetCurrentValue(SKColors.Red);
layer.Transform.Opacity.SetCurrentValue(50);
using RenderFrame frame = Render(profile);
// The gradient shader keeps the alpha of the paint, the brush must not get the layer opacity on top of compositing
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
[Fact]
@@ -124,7 +124,7 @@ public class ProfileSerializationTests : RenderTestBase
}
[Fact]
public void RoundTrip_LosesTheNameOfRenamedEffects()
public void RoundTrip_KeepsTheNameOfRenamedEffects()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
@@ -134,10 +134,22 @@ public class ProfileSerializationTests : RenderTestBase
Profile reloaded = Harness.SaveAndReload(profile);
// Pinned as-is, a bug: BaseLayerEffect.Load checks HasBeenRenamed before loading it from the entity, so it is
// always false at that point and the name falls back to the display name of the descriptor
BaseLayerEffect reloadedTint = reloaded.GetAllLayers().Single().LayerEffects.Single();
reloadedTint.HasBeenRenamed.ShouldBeTrue();
reloadedTint.Name.ShouldBe("Renamed tint");
}
[Fact]
public void RoundTrip_UsesTheDisplayNameOfEffectsThatWereNotRenamed()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
Harness.AddEffect<TintTestEffect>(layer);
Profile reloaded = Harness.SaveAndReload(profile);
BaseLayerEffect reloadedTint = reloaded.GetAllLayers().Single().LayerEffects.Single();
reloadedTint.HasBeenRenamed.ShouldBeFalse();
reloadedTint.Name.ShouldBe("Tint");
}
@@ -147,22 +147,21 @@ public class ProfileStructureTests : RenderTestBase
}
[Fact]
public void SuspendingARenderingFolder_KeepsRenderingItsChildren()
public void SuspendingARenderingFolder_StopsRenderingItsChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
using RenderFrame before = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
busy.Folder.Suspended = true;
Harness.Render(profile, TimeSpan.FromSeconds(10)).Dispose();
using RenderFrame after = Render(profile);
// Pinned as-is, an inconsistency: Layer.Update disables a suspended layer, Folder.Update only disables a folder
// once its timeline finishes, which never happens with the default always-on condition. The editor hides this by
// disabling suspended elements itself, so it only affects code suspending folders outside of the editor
busy.Folder.Enabled.ShouldBeTrue();
foreach ((ArtemisLed led, SKColor color) in before.LedColors)
after.LedColors[led].ShouldBeColor(color, 2, led.RgbLed.Id.ToString());
busy.Folder.Enabled.ShouldBeFalse();
foreach (ArtemisLed led in busy.Leds)
{
before.LedColors[led].ShouldNotBe(SKColors.Black, led.RgbLed.Id.ToString());
after.LedColors[led].ShouldBeColor(SKColors.Black, 2, led.RgbLed.Id.ToString());
}
}
[Fact]
@@ -218,7 +217,7 @@ public class ProfileStructureTests : RenderTestBase
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 1, 1.5);
// The fade uses a cubic ease-in-out, which is at exactly half at the halfway point
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[2].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
@@ -92,7 +92,7 @@ public class TimelineTests : RenderTestBase
using RenderFrame frame = Render(profile, 0.5);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
#endregion
@@ -117,19 +117,20 @@ public class TimelineTests : RenderTestBase
}
[Fact]
public void AlwaysOn_WrapsAroundUsingTheFrameDeltaRatherThanTheOvershoot()
public void AlwaysOn_WrapsAroundByTheOvershoot()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (2, SKColors.Blue));
// Going from 1.5s to 2.5s overshoots the 2s main segment by 0.5s, the timeline lands on 1s instead of 0.5s.
// Pinned as-is: with real frame times (~33ms) the difference is at most a single frame.
// Going from 1.5s to 2.5s overshoots the 2s main segment by 0.5s. A new timeline has no start segment, so the
// main segment starts at 0s and the timeline lands on 0.5s
using RenderTimeline timeline = RenderTimeline(profile, 1.5, 2.5);
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.5));
// 0.5s is a quarter of the way from red to blue
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(191, 0, 64));
}
[Fact]
@@ -95,28 +95,40 @@ public class SurfaceTests : RenderTestBase
}
[Fact]
public void RotatingADevice_DoesNotUpdateItsLedsUntilItIsMoved()
public void RotatingADevice_UpdatesItsLeds()
{
ArtemisLed a = Led(LedId.Keyboard_A);
SKRect beforeRotation = a.AbsoluteRectangle;
int updates = 0;
Keyboard.DeviceUpdated += (_, _) => updates++;
Keyboard.Rotation = 90;
// Pinned as-is: the Rotation and Scale setters only update the RGB.NET device. The Artemis LEDs, and with them the
// texture, keep their old positions until X or Y is set. The device properties dialog gets away with this because
// it reloads the device layout after saving
a.AbsoluteRectangle.ShouldBe(beforeRotation);
Math.Abs(a.RgbLed.AbsoluteBoundary.Location.X - beforeRotation.Left).ShouldBeGreaterThan(1);
Keyboard.X = Keyboard.X;
a.AbsoluteRectangle.ShouldNotBe(beforeRotation);
a.AbsoluteRectangle.Left.ShouldBe(a.RgbLed.AbsoluteBoundary.Location.X, 1);
a.AbsoluteRectangle.Top.ShouldBe(a.RgbLed.AbsoluteBoundary.Location.Y, 1);
updates.ShouldBe(1);
}
[Fact]
public void ScalingADevice_UpdatesItsLeds()
{
ArtemisLed a = Led(LedId.Keyboard_A);
SKRect beforeScaling = a.AbsoluteRectangle;
int updates = 0;
Keyboard.DeviceUpdated += (_, _) => updates++;
Keyboard.Scale = 2;
// Rounded outwards to the render scale, so slightly wider than twice the size
a.AbsoluteRectangle.Width.ShouldBeInRange(beforeScaling.Width * 2, beforeScaling.Width * 2 + 4);
updates.ShouldBe(1);
}
[Fact]
public void RotatedDevice_SamplesFromItsRotatedLedPositions()
{
Keyboard.Rotation = 90;
Keyboard.X = Keyboard.X;
SKRect rotatedA = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, rotatedA, SKColors.Red));
@@ -130,7 +142,6 @@ public class SurfaceTests : RenderTestBase
public void ScaledDevice_SamplesFromItsScaledLedPositions()
{
Keyboard.Scale = 2;
Keyboard.X = Keyboard.X;
SKRect scaledA = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, scaledA, SKColors.Red));
@@ -63,10 +63,10 @@ public class TextureSamplingTests : RenderTestBase
[Fact]
public void SemiTransparentDrawing_IsBlendedOntoBlack()
{
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, new SKColor(255, 0, 0, 128)));
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red.WithAlpha(128)));
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(128, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red.AtOpacity(50));
}
[Fact]
+6 -3
View File
@@ -16,8 +16,11 @@ public sealed class Folder : RenderProfileElement
private bool _isExpanded;
/// <summary>
/// Creates a new instance of the <see cref="Folder" /> class and adds itself to the child collection of the provided
/// <paramref name="parent" />
/// Creates a new instance of the <see cref="Folder" /> class with the provided <paramref name="parent" />.
/// <para>
/// The folder is not added to the child collection of the parent, use <see cref="ProfileElement.AddChild" /> to
/// add it.
/// </para>
/// </summary>
/// <param name="parent">The parent of the folder</param>
/// <param name="name">The name of the folder</param>
@@ -106,7 +109,7 @@ public sealed class Folder : RenderProfileElement
if (ShouldBeEnabled)
Enable();
else if (Timeline.IsFinished)
else if (Suspended || Timeline.IsFinished)
Disable();
foreach (BaseLayerEffect baseLayerEffect in LayerEffects.Where(e => !e.Suspended))
+13 -3
View File
@@ -28,8 +28,11 @@ public sealed class Layer : RenderProfileElement
private List<LedEntity> _missingLeds = [];
/// <summary>
/// Creates a new instance of the <see cref="Layer" /> class and adds itself to the child collection of the provided
/// <paramref name="parent" />
/// Creates a new instance of the <see cref="Layer" /> class with the provided <paramref name="parent" />.
/// <para>
/// The layer is not added to the child collection of the parent, use <see cref="ProfileElement.AddChild" /> to
/// add it.
/// </para>
/// </summary>
/// <param name="parent">The parent of the layer</param>
/// <param name="name">The name of the layer</param>
@@ -663,7 +666,14 @@ public sealed class Layer : RenderProfileElement
// Restore the blend mode before doing the actual render
layerPaint.BlendMode = SKBlendMode.SrcOver;
LayerBrush.InternalRender(canvas, bounds, layerPaint);
// The opacity and the filters of the effects are applied when compositing the layer, the brush gets an opaque
// paint without filters so they don't apply to the brush output a second time
using SKPaint brushPaint = layerPaint.Clone();
brushPaint.Color = brushPaint.Color.WithAlpha(255);
brushPaint.ColorFilter = null;
brushPaint.ImageFilter = null;
LayerBrush.InternalRender(canvas, bounds, brushPaint);
foreach (BaseLayerEffect baseLayerEffect in LayerEffects)
{
+9 -3
View File
@@ -243,6 +243,7 @@ public class Timeline : CorePropertyChanged, IStorageModel
if (IsOverridden)
throw new ArtemisCoreException("Can't update an overridden timeline, call ClearOverride first.");
TimeSpan previousPosition = Position;
Delta += delta;
Position += delta;
@@ -251,10 +252,15 @@ public class Timeline : CorePropertyChanged, IStorageModel
// If the main segment has no length, simply stick to the start of the segment
if (MainSegmentLength == TimeSpan.Zero)
{
Position = MainSegmentStartPosition;
// Ensure wrapping back around retains the delta time
else
Position = MainSegmentStartPosition + TimeSpan.FromMilliseconds(delta.TotalMilliseconds % MainSegmentLength.TotalMilliseconds);
return;
}
// Wrap around by as much as the timeline went past the end of the main segment. If it was already past the end
// (for example a timeline that had finished), start the main segment over instead
TimeSpan overshoot = previousPosition <= MainSegmentEndPosition ? Position - MainSegmentEndPosition : delta;
Position = MainSegmentStartPosition + TimeSpan.FromTicks(overshoot.Ticks % MainSegmentLength.Ticks);
}
}
@@ -200,7 +200,11 @@ public class ArtemisDevice : CorePropertyChanged
{
DeviceEntity.Rotation = value;
if (RgbDevice.Surface != null)
{
RgbDevice.Rotation = DeviceEntity.Rotation;
CalculateRenderProperties();
}
OnPropertyChanged(nameof(Rotation));
}
}
@@ -215,7 +219,11 @@ public class ArtemisDevice : CorePropertyChanged
{
DeviceEntity.Scale = value;
if (RgbDevice.Surface != null)
{
RgbDevice.Scale = DeviceEntity.Scale;
CalculateRenderProperties();
}
OnPropertyChanged(nameof(Scale));
}
}
@@ -217,8 +217,8 @@ public abstract class BaseLayerEffect : BreakableModel, IDisposable, IStorageMod
/// <inheritdoc />
public void Load()
{
Name = HasBeenRenamed ? LayerEffectEntity.Name : Descriptor.DisplayName;
HasBeenRenamed = LayerEffectEntity.HasBeenRenamed;
Name = HasBeenRenamed ? LayerEffectEntity.Name : Descriptor.DisplayName;
Order = LayerEffectEntity.Order;
}