diff --git a/Project-Aurora/Project-Aurora/EffectsEngine/EffectLayer.cs b/Project-Aurora/Project-Aurora/EffectsEngine/EffectLayer.cs index 25fcc9fc8..d233c0bba 100755 --- a/Project-Aurora/Project-Aurora/EffectsEngine/EffectLayer.cs +++ b/Project-Aurora/Project-Aurora/EffectsEngine/EffectLayer.cs @@ -441,12 +441,20 @@ public EffectLayer Set(Devices.DeviceKeys[] keys, Color color) /// KeySequence to specify what regions of the bitmap need to be changed /// Color to be used /// Itself - public EffectLayer Set(KeySequence sequence, Color color) + public EffectLayer Set(KeySequence sequence, Color color) => Set(sequence, new SolidBrush(color)); + + /// + /// Sets a specific KeySequence on the bitmap with a specified brush. + /// + /// KeySequence to specify what regions of the bitmap need to be changed + /// Brush to be used + /// Itself + public EffectLayer Set(KeySequence sequence, Brush brush) { if (sequence.type == KeySequenceType.Sequence) { foreach (var key in sequence.keys) - Set(key, color); + SetOneKey(key, brush); } else { @@ -468,7 +476,7 @@ public EffectLayer Set(KeySequence sequence, Color color) myMatrix.RotateAt(sequence.freeform.Angle, rotatePoint, MatrixOrder.Append); g.Transform = myMatrix; - g.FillRectangle(new SolidBrush(color), rect); + g.FillRectangle(brush, rect); } } @@ -562,13 +570,24 @@ public EffectLayer DrawTransformed(KeySequence sequence, Action render /// DeviceKey to be set /// Color to be used /// Itself - private EffectLayer SetOneKey(Devices.DeviceKeys key, Color color) + private EffectLayer SetOneKey(Devices.DeviceKeys key, Color color) => SetOneKey(key, new SolidBrush(color)); + + /// + /// Sets one DeviceKeys key with a specific brush on the bitmap + /// + /// DeviceKey to be set + /// Brush to be used + /// Itself + private EffectLayer SetOneKey(Devices.DeviceKeys key, Brush brush) { BitmapRectangle keymaping = Effects.GetBitmappingFromDeviceKey(key); if (key == Devices.DeviceKeys.Peripheral) { - peripheral = color; + if (brush is SolidBrush solidBrush) + peripheral = solidBrush.Color; + // TODO Add support for this ^ to other brush types + using (Graphics g = Graphics.FromImage(colormap)) { foreach (Devices.DeviceKeys peri_key in possible_peripheral_keys) @@ -576,7 +595,7 @@ private EffectLayer SetOneKey(Devices.DeviceKeys key, Color color) BitmapRectangle peri_keymaping = Effects.GetBitmappingFromDeviceKey(peri_key); if (peri_keymaping.IsValid) - g.FillRectangle(new SolidBrush(color), peri_keymaping.Rectangle); + g.FillRectangle(brush, peri_keymaping.Rectangle); } needsRender = true; @@ -588,13 +607,13 @@ private EffectLayer SetOneKey(Devices.DeviceKeys key, Color color) keymaping.Left < 0 || keymaping.Right > Effects.canvas_width) { Global.logger.Warn("Coudln't set key color " + key.ToString()); - return this; ; + return this; } else { using (Graphics g = Graphics.FromImage(colormap)) { - g.FillRectangle(new SolidBrush(color), keymaping.Rectangle); + g.FillRectangle(brush, keymaping.Rectangle); needsRender = true; } } diff --git a/Project-Aurora/Project-Aurora/EffectsEngine/SegmentedRadialBrushFactory.cs b/Project-Aurora/Project-Aurora/EffectsEngine/SegmentedRadialBrushFactory.cs new file mode 100644 index 000000000..1bb71e973 --- /dev/null +++ b/Project-Aurora/Project-Aurora/EffectsEngine/SegmentedRadialBrushFactory.cs @@ -0,0 +1,165 @@ +using Aurora.Utils; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; + +namespace Aurora.EffectsEngine { + + /// + /// A factory that can create a segmented radial brush. + /// + /// + /// I originally tried creating this effect using the , however I cannot find a way of removing the central colour. This means that the + /// colours gradually fade to another colour in the centre. Since the points on the path would need to be equidistant from the centre to preserve the angle and gradients, + /// it means that some of the brush is cut off and the colours appear washed out. All round, not ideal for this use case, so that is the reason I have created this instead. + /// + public class SegmentedRadialBrushFactory : ICloneable { + + // The resolution of the base texture size. + private const int textureSize = 200; + private static readonly Rectangle renderArea = new Rectangle(0, 0, textureSize, textureSize); + private static readonly SolidBrush fallback = new SolidBrush(Color.Transparent); + + private ColorStopCollection colors; + private int segmentCount = 24; + private TextureBrush baseBrush; + + public SegmentedRadialBrushFactory(ColorStopCollection colors) { + this.colors = colors; + CreateBaseTextureBrush(); + } + + /// + /// Gets or sets the colors and their orders in use by the brush. + /// + public ColorStopCollection Colors { + get => colors; + set { + // If the colors are equal, don't do anything + if (colors.StopsEqual(value)) + return; + + // If they are not equal, create a new texture brush + colors = value; + CreateBaseTextureBrush(); + } + } + + /// + /// How many segments should be created for this brush. Larger values appear smoother by may run more slowly. + /// + public int SegmentCount { + get => segmentCount; + set { + if (segmentCount <= 0) + throw new ArgumentOutOfRangeException(nameof(SegmentCount), "Segment count must not be lower than 1."); + if (segmentCount != value) { + segmentCount = value; + CreateBaseTextureBrush(); + } + } + } + + /// + /// Creates a new base brush from the current properties. + /// + private void CreateBaseTextureBrush() { + var angle = 360f / segmentCount; + var segmentOffset = 1f / segmentCount; // how much each segment moves the offset forwards on the gradient + + // Get a list of all stops in the stop collection. + // We use this to optimise the interpolation of the colors. + // If we were to use ColorStopCollection.GetColorAt, it may end up running numerous for loops over the same stops, but given + // the special requirements here, we can eliminate that and use less for loops and make the ones we do use slightly more optimal. + var stops = colors.ToList(); + var currentOffset = segmentOffset / 2; + var stopIdx = 0; + + // If there isn't a stop at offsets 0 and 1, create them. This makes it easier during the loop since we don't have to check if we're left/right of the first/last stops. + if (stops[0].Key != 0) + stops.Insert(0, new KeyValuePair(0f, stops[0].Value)); + if (stops[stops.Count - 1].Key != 1) + stops.Add(new KeyValuePair(1f, stops[stops.Count - 1].Value)); + + // Create and draw texture + var texture = new Bitmap(textureSize, textureSize); + using (var gfx = Graphics.FromImage(texture)) { + for (var i = 0; i < segmentCount; i++) { + + // Move the stop index forwards if required. + // - It needs to more fowards until the the stop at that index is to the left of the current offset and the point at that index+1 is to the right. + // - If it is exactly on a stop, make that matched stop at that index. + while (stops[stopIdx + 1].Key < currentOffset) + stopIdx++; + + // Now that stopIdx is in the right place, we can figure out which color we need. + var color = stops[stopIdx].Key == currentOffset + ? stops[stopIdx].Value // if exactly on a stop, don't need to interpolate it + : ColorUtils.BlendColors( // otherwise, we need to calculate the blend between the two stops + stops[stopIdx].Value, + stops[stopIdx + 1].Value, + (currentOffset - stops[stopIdx].Key) / (stops[stopIdx + 1].Key - stops[stopIdx].Key) + ); + + // Draw this segment + gfx.FillPie(new SolidBrush(color), renderArea, i * angle, angle); + + // Bump the offset + currentOffset += segmentOffset; + } + } + + // Create the texture brush from our custom bitmap texture + baseBrush = new TextureBrush(texture); + } + + /// + /// Gets the brush that will be centered on and sized for the specified region. + /// + /// The region which defines where the brush will be drawn and where the brush will be centered. + /// The angle which the brush will be rendered at. + /// If true, the scale transformation will have the same value in x as it does in y. If false, the scale in each dimension may be different. + /// When true, the sizes/areas of each color may appear different (due to being cut off), however when false, they appear more consistent. + /// If the brush is animated, true will make the speeed appear constant whereas false will cause the rotation to appear slower on the shorter side. + public Brush GetBrush(RectangleF region, float angle = 0, bool keepAspectRatio = true) { + // Check if the region has a 0 size. If so, just return a blank brush instead (the matrix becomes invalid with 0 size scaling). + if (region.Width == 0 || region.Height == 0) return fallback; + + var brush = (TextureBrush)baseBrush.Clone(); // Clone the brush so we don't alter the transformation of it in other places accidently + var mtx = new Matrix(); + + // Translate it so that the center of the texture (where all the colors meet) is at 0,0 + mtx.Translate(-textureSize / 2, -textureSize / 2, MatrixOrder.Append); + + // Then, rotate it to the target angle + mtx.Rotate(angle, MatrixOrder.Append); + + // Scale it so that it'll still completely cover the textureSize area. + // 1.45 is a rough approximation of SQRT(2) [it's actually 1.414 but we want to allow a bit of space incase of artifacts at the edges] + mtx.Scale(1.45f, 1.45f, MatrixOrder.Append); + + // Next we need to scale the texture so that it'll cover the area defined by the region + float sx = region.Width / textureSize, sy = region.Height / textureSize; + // If the aspect ratio is locked, we want to scale both dimensions up to the biggest required scale + if (keepAspectRatio) + sx = sy = Math.Max(sx, sy); + mtx.Scale(sx, sy, MatrixOrder.Append); + + // Finally, we need to translate the texture so that it is in the center of the region + // (At this point, the center of the texture where the colors meet is still at 0,0) + mtx.Translate(region.Left + (region.Width / 2), region.Top + (region.Height / 2), MatrixOrder.Append); + + // Apply the transformation and return the texture brush + brush.Transform = mtx; + return brush; + } + + /// + /// Creates a clone of this factory. + /// + public object Clone() => new SegmentedRadialBrushFactory(new ColorStopCollection(colors)) { SegmentCount = SegmentCount }; + } +} diff --git a/Project-Aurora/Project-Aurora/Profiles/LightingStateManager.cs b/Project-Aurora/Project-Aurora/Profiles/LightingStateManager.cs index 3b1589d0a..3bddc6ecc 100755 --- a/Project-Aurora/Project-Aurora/Profiles/LightingStateManager.cs +++ b/Project-Aurora/Project-Aurora/Profiles/LightingStateManager.cs @@ -186,7 +186,8 @@ public bool Initialize() new LayerHandlerEntry("Toolbar", "Toolbar Layer", typeof(ToolbarLayerHandler)), new LayerHandlerEntry("BinaryCounter", "Binary Counter Layer", typeof(BinaryCounterLayerHandler)), new LayerHandlerEntry("Particle", "Particle Layer", typeof(SimpleParticleLayerHandler)), - new LayerHandlerEntry("InteractiveParticle", "Interactive Particle Layer", typeof(InteractiveParticleLayerHandler)) + new LayerHandlerEntry("InteractiveParticle", "Interactive Particle Layer", typeof(InteractiveParticleLayerHandler)), + new LayerHandlerEntry("Radial", "Radial Layer", typeof(RadialLayerHandler)) }, true); RegisterLayerHandler(new LayerHandlerEntry("WrapperLights", "Wrapper Lighting Layer", typeof(WrapperLightsLayerHandler)), false); diff --git a/Project-Aurora/Project-Aurora/Project-Aurora.csproj b/Project-Aurora/Project-Aurora/Project-Aurora.csproj index 2c3db0a09..2e9803326 100644 --- a/Project-Aurora/Project-Aurora/Project-Aurora.csproj +++ b/Project-Aurora/Project-Aurora/Project-Aurora.csproj @@ -417,6 +417,7 @@ + Control_Discord.xaml @@ -782,6 +783,10 @@ + + Control_RadialLayer.xaml + + Control_RazerLayer.xaml @@ -2296,6 +2301,10 @@ MSBuild:Compile Designer + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/Project-Aurora/Project-Aurora/Settings/Layers/Control_ParticleLayer.xaml.cs b/Project-Aurora/Project-Aurora/Settings/Layers/Control_ParticleLayer.xaml.cs index cc3cd574b..f7694fb4b 100644 --- a/Project-Aurora/Project-Aurora/Settings/Layers/Control_ParticleLayer.xaml.cs +++ b/Project-Aurora/Project-Aurora/Settings/Layers/Control_ParticleLayer.xaml.cs @@ -33,7 +33,7 @@ private void ApplyGradientToEditor() { private void GradientEditor_BrushChanged(object sender, ColorBox.BrushChangedEventArgs e) { // Set the particle's color stops from the media brush. We cannot pass the media brush directly as it causes issues with UI threading - handler.Properties._ParticleColorStops = e.Brush.ToColorStopCollection(); + handler.Properties._ParticleColorStops = ColorStopCollection.FromMediaBrush(e.Brush); } private void ApplyButton_Click(object sender, RoutedEventArgs e) { diff --git a/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml b/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml new file mode 100644 index 000000000..923b3de4d --- /dev/null +++ b/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml @@ -0,0 +1,27 @@ + + + + + diff --git a/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml.cs b/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml.cs new file mode 100644 index 000000000..ff5b6c0a4 --- /dev/null +++ b/Project-Aurora/Project-Aurora/Settings/Layers/Control_RadialLayer.xaml.cs @@ -0,0 +1,24 @@ +using Aurora.Utils; +using System.Windows.Controls; + +namespace Aurora.Settings.Layers { + + public partial class Control_RadialLayer : UserControl { + + private readonly RadialLayerHandler handler; + + public Control_RadialLayer(RadialLayerHandler context) { + DataContext = handler = context; + InitializeComponent(); + } + + private void UserControl_Loaded(object sender, System.Windows.RoutedEventArgs e) { + GradientPicker.Brush = handler.Properties.Brush.Colors.ToMediaBrush(); + Loaded -= UserControl_Loaded; + } + + private void GradientPicker_BrushChanged(object sender, ColorBox.BrushChangedEventArgs e) { + handler.Properties.Brush.Colors = ColorStopCollection.FromMediaBrush(GradientPicker.Brush); + } + } +} diff --git a/Project-Aurora/Project-Aurora/Settings/Layers/RadialLayerHandler.cs b/Project-Aurora/Project-Aurora/Settings/Layers/RadialLayerHandler.cs new file mode 100644 index 000000000..79bd79cb6 --- /dev/null +++ b/Project-Aurora/Project-Aurora/Settings/Layers/RadialLayerHandler.cs @@ -0,0 +1,63 @@ +using Aurora.EffectsEngine; +using Aurora.Profiles; +using Aurora.Settings.Overrides; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Controls; + +namespace Aurora.Settings.Layers { + + public class RadialLayerProperties : LayerHandlerProperties { + + private static readonly SegmentedRadialBrushFactory defaultFactory = new SegmentedRadialBrushFactory(new Utils.ColorStopCollection(new[] { Color.Red, Color.Orange, Color.Yellow, Color.Lime, Color.Cyan, Color.Blue, Color.Purple, Color.Red })); + + public SegmentedRadialBrushFactory _Brush { get; set; } + [JsonIgnore] public SegmentedRadialBrushFactory Brush => Logic._Brush ?? _Brush ?? defaultFactory; + + // Number of degrees per second the brush rotates at. + [LogicOverridable("Animation Speed")] public int? _AnimationSpeed { get; set; } + [JsonIgnore] public int AnimationSpeed => Logic._AnimationSpeed ?? _AnimationSpeed ?? 60; + + public RadialLayerProperties() : base() { } + public RadialLayerProperties(bool empty = false) : base(empty) { } + + public override void Default() { + base.Default(); + _Sequence = new KeySequence(Effects.WholeCanvasFreeForm); + _Brush = (SegmentedRadialBrushFactory)defaultFactory.Clone(); + _AnimationSpeed = 60; + } + } + + public class RadialLayerHandler : LayerHandler { + + private Stopwatch sw = new Stopwatch(); + private float angle; + + public RadialLayerHandler() { + _ID = "Radial"; + } + + protected override UserControl CreateControl() => new Control_RadialLayer(this); + + public override EffectLayer Render(IGameState gamestate) { + // Calculate delta time + var dt = sw.Elapsed.TotalSeconds; + sw.Restart(); + + // Update angle + angle = (angle + (float)(dt * Properties.AnimationSpeed)) % 360; + + var area = Properties.Sequence.GetAffectedRegion(); + var brush = Properties.Brush.GetBrush(area, angle, true); + return new EffectLayer().Set(Properties.Sequence, brush); + } + } +} diff --git a/Project-Aurora/Project-Aurora/Settings/Layers/SimpleParticleLayerHandler.cs b/Project-Aurora/Project-Aurora/Settings/Layers/SimpleParticleLayerHandler.cs index d47b94915..a6a24a757 100644 --- a/Project-Aurora/Project-Aurora/Settings/Layers/SimpleParticleLayerHandler.cs +++ b/Project-Aurora/Project-Aurora/Settings/Layers/SimpleParticleLayerHandler.cs @@ -119,13 +119,13 @@ public class SimpleParticleLayerProperties : ParticleLayerPropertiesBase< // The color gradient stops for the particle. Note this is sorted by offset when set using _ParticleColorStops. Not using a linear brush here because: // 1) there are multithreading issues when trying to access a Media brush's gradient collection since it belongs to the UI thread // 2) We don't actually need the gradient as a brush since we're not drawing particles as gradients, only a solid color based on their lifetime, so we only need to access the color stops - private List<(Color color, float offset)> particleColorStops; - public List<(Color color, float offset)> _ParticleColorStops { get => particleColorStops; set => SetAndNotify(ref particleColorStops, value.OrderBy(s => s.offset).ToList()); } - [JsonIgnore] public List<(Color color, float offset)> ParticleColorStops => Logic._ParticleColorStops ?? _ParticleColorStops ?? defaultParticleColor; + private ColorStopCollection particleColorStops; + public ColorStopCollection _ParticleColorStops { get => particleColorStops; set => SetAndNotify(ref particleColorStops, value); } + [JsonIgnore] public ColorStopCollection ParticleColorStops => Logic._ParticleColorStops ?? _ParticleColorStops ?? defaultParticleColor; - private static readonly List<(Color, float)> defaultParticleColor = new List<(Color, float)> { - (Color.White, 0f), - (Color.FromArgb(0, Color.White), 1f) + private static readonly ColorStopCollection defaultParticleColor = new ColorStopCollection { + {0f, Color.White }, + {1f, Color.FromArgb(0, Color.White) } }; public SimpleParticleLayerProperties() : base() { } @@ -144,33 +144,10 @@ public override void Default() { _AccelerationY = .5f; _DragX = 0; _DragY = 0; - _MinSize = 6; - _MaxSize = 6; + _MinSize = 6; _MaxSize = 6; _DeltaSize = 0; _Sequence = new KeySequence(Effects.WholeCanvasFreeForm); } - - /// - /// Returns the color at the specified offset for the current . - /// - /// A value between 0 and 1. At 0, the left-most color is returned, at 1, the rightmost is. - /// - public Color ColorAt(float offset) { - // Check if any GradientStops are exactly at the requested offset. If so, return that - var exact = particleColorStops.Where(s => s.offset == offset); - if (exact.Count() == 1) return exact.Single().color; - - // Check if the requested offset is outside of bounds of the offset range. If so, return the nearest offset - if (offset <= particleColorStops.First().offset) return particleColorStops.First().color; - if (offset >= particleColorStops.Last().offset) return particleColorStops.Last().color; - - // Find the two stops either side of the requsted offset - var left = particleColorStops.Last(s => s.offset < offset); - var right = particleColorStops.First(s => s.offset > offset); - - // Return the blended color that is the correct ratio between left and right - return ColorUtils.BlendColors(left.color, right.color, (offset - left.offset) / (right.offset - left.offset)); - } } @@ -252,7 +229,7 @@ public SimpleParticle(SimpleParticleLayerProperties properties) { public void Render(Graphics gfx, SimpleParticleLayerProperties properties, IGameState gameState) { var s2 = Size / 2; - gfx.FillEllipse(new SolidBrush(properties.ColorAt((float)(Lifetime / MaxLifetime))), new RectangleF(PositionX - s2, PositionY - s2, Size, Size)); + gfx.FillEllipse(new SolidBrush(properties.ParticleColorStops.GetColorAt((float)(Lifetime / MaxLifetime))), new RectangleF(PositionX - s2, PositionY - s2, Size, Size)); } /// @@ -292,10 +269,10 @@ public static class ParticleLayerPresets { new Dictionary> { { "Fire", p => { p._SpawnLocation = ParticleSpawnLocations.BottomEdge; - p._ParticleColorStops = new List<(Color color, float offset)> { - (Color.Yellow, 0f), - (Color.FromArgb(128, Color.Red), 0.6f), - (Color.FromArgb(0, Color.Black), 1f) + p._ParticleColorStops = new ColorStopCollection { + { 0f, Color.Yellow }, + { 0.6f, Color.FromArgb(128, Color.Red) }, + { 1f, Color.FromArgb(0, Color.Black) } }; p._MinSpawnTime = p._MaxSpawnTime = .05f; p._MinSpawnAmount = 4; p._MaxSpawnAmount = 6; @@ -304,15 +281,14 @@ public static class ParticleLayerPresets { p._MinInitialVelocityY = -1.3f; p._MaxInitialVelocityY = -0.8f; p._AccelerationX = 0; p._AccelerationY = 0.5f; - p._MinSize = 6; - p._MaxSize = 6; - p._DeltaSize = 0; + p._MinSize = 8; p._MaxSize = 12; + p._DeltaSize = -4; } }, { "Matrix", p => { p._SpawnLocation = ParticleSpawnLocations.TopEdge; - p._ParticleColorStops = new List<(Color color, float offset)> { - (Color.FromArgb(0,255,0), 0f), - (Color.FromArgb(0,255,0), 1f) + p._ParticleColorStops = new ColorStopCollection { + { 0f, Color.FromArgb(0,255,0) }, + { 1f, Color.FromArgb(0,255,0) } }; p._MinSpawnTime = .1f; p._MaxSpawnTime = .2f; p._MinSpawnAmount = 1; p._MaxSpawnAmount = 2; @@ -321,15 +297,14 @@ public static class ParticleLayerPresets { p._MinInitialVelocityY = p._MaxInitialVelocityY = 3; p._AccelerationX = 0; p._AccelerationY = 0; - p._MinSize = 6; - p._MaxSize = 6; + p._MinSize = 6; p._MaxSize = 6; p._DeltaSize = 0; } }, { "Rain", p => { p._SpawnLocation = ParticleSpawnLocations.TopEdge; - p._ParticleColorStops = new List<(Color color, float offset)> { - (Color.Cyan, 0f), - (Color.Cyan, 1f) + p._ParticleColorStops = new ColorStopCollection { + { 0f, Color.Cyan }, + { 1f, Color.Cyan } }; p._MinSpawnTime = .1f; p._MaxSpawnTime = .2f; p._MinSpawnAmount = 1; p._MaxSpawnAmount = 2; diff --git a/Project-Aurora/Project-Aurora/Utils/BrushUtils.cs b/Project-Aurora/Project-Aurora/Utils/BrushUtils.cs index 1f5b618a6..64e02f290 100644 --- a/Project-Aurora/Project-Aurora/Utils/BrushUtils.cs +++ b/Project-Aurora/Project-Aurora/Utils/BrushUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -248,34 +249,144 @@ public static M.Brush DrawingBrushToMediaBrush(D.Brush in_brush) return new M.SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 255, 0, 0)); //Return error color } } + } + + /// + /// A collection which stores and interpolates a collection of colors that can represent a gradient. + /// + /// + /// I've made this as it's own class rather than using one of the built-in collections as there can sometimes be UI multi-thead issues if trying + /// to access a gradient stop collection that is being used by a gradient editor in the UI. + /// + public class ColorStopCollection : IEnumerable> { + + private readonly SortedList stops = new SortedList(); /// - /// Creates a from the given media brush. + /// Creates an empty ColorStopCollection. /// - public static ColorStopCollecion ToColorStopCollection(this M.Brush brush) { - ColorStopCollecion csc = null; - if (brush is M.GradientBrush gb) - csc = gb.GradientStops.Select(gs => (gs.Color.ToDrawingColor(), (float)gs.Offset)).ToList(); - else if (brush is M.SolidColorBrush sb) - csc = new ColorStopCollecion { (sb.Color.ToDrawingColor(), 0f) }; - csc?.Sort((a, b) => Comparer.Default.Compare(a.offset, b.offset)); - return csc; + public ColorStopCollection() { } + + /// + /// Creates a ColorStopCollection from the given float-color key-value-pairs. + /// + public ColorStopCollection(IEnumerable> stops) { + foreach (var stop in stops) + SetColorAt(stop.Key, stop.Value); + } + + /// + /// Creates a ColorStopCollection from the given colors, which are automatically evenly placed, with the first being at offset 0 and the last at offset 1. + /// + public ColorStopCollection(IEnumerable colors) { + var count = colors.Count(); + if (count > 0) { + float offset = 0, d = count > 2 ? 1f / (count - 1f) : 0f; + foreach (var color in colors) { + SetColorAt(offset, color); + offset += d; + } + } + } + + /// + /// The number of stops in this stop collection. + /// + public int StopCount => stops.Count; + + /// + /// Gets or sets the color at the specified offset. + /// When setting a value, a new color stop will be created at the given offset if one does not already exist. + /// + /// + /// + public D.Color this[float offset] { + get => GetColorAt(offset); + set => SetColorAt(offset, value); } /// - /// Converts a into a media brush (either - /// or a depending on the amount of stops in the collection). + /// Gets the color at the specific offset. + /// If this point is not at a stop, it's value is interpolated. /// - public static M.Brush ToMediaBrush(this ColorStopCollecion stops) { + public D.Color GetColorAt(float offset) { + // If there are no stops, return a transparent color if (stops.Count == 0) - return M.Brushes.Transparent; + return D.Color.Transparent; + + offset = Math.Max(Math.Min(offset, 1), 0); + + // First, check if the target offset is at a stop. If so, return the value of that stop. + if (stops.ContainsKey(offset)) + return stops[offset]; + + // Next, check to see if the target offset is before the first stop or after the last, if so, return that stop. + if (offset < stops.First().Key) + return stops.First().Value; + if (offset > stops.Last().Key) + return stops.Last().Value; + + // At this point, offset is determined to be between two stops, so find which two and then interpolate them. + for (var i = 1; i < stops.Count; i++) { + if (offset > stops.Keys[i - 1] && offset < stops.Keys[i]) + return ColorUtils.BlendColors( + stops.Values[i - 1], + stops.Values[i], + (offset - stops.Keys[i - 1]) / (stops.Keys[i] - stops.Keys[i - 1]) + ); + } + + // Logically, should never get here. + throw new InvalidOperationException("No idea what happened."); + } + + /// + /// Sets the color at the specified offset to the given value. + /// If an offset does not exist at this point, one will be created. + /// + public void SetColorAt(float offset, D.Color color) { + if (offset < 0 || offset > 1) + throw new ArgumentOutOfRangeException(nameof(offset), $"Gradient stop at offset {offset} is out of range. Value must be between 0 and 1 (inclusive)."); + stops[offset] = color; + } + + /// + /// Creates a new media brush from this stop collection. + /// + public M.LinearGradientBrush ToMediaBrush() { + M.GradientStopCollection gsc; + if (stops.Count == 0) + gsc = new M.GradientStopCollection(new[] { new M.GradientStop(M.Colors.Transparent, 0), new M.GradientStop(M.Colors.Transparent, 1) }); else if (stops.Count == 1) - return new M.SolidColorBrush(stops[0].color.ToMediaColor()); + gsc = new M.GradientStopCollection(new[] { new M.GradientStop(stops.Values[0].ToMediaColor(), 0), new M.GradientStop(stops.Values[0].ToMediaColor(), 1) }); else - return new M.LinearGradientBrush(new M.GradientStopCollection( - stops.Select(s => new M.GradientStop(s.color.ToMediaColor(), s.offset)) - )); + gsc = new M.GradientStopCollection(stops.Select(s => new M.GradientStop(s.Value.ToMediaColor(), s.Key))); + return new M.LinearGradientBrush(gsc); } + + /// + /// Creates a new stop collection from the given media brush. + /// + public static ColorStopCollection FromMediaBrush(M.Brush brush) { + if (brush is M.GradientBrush gb) + return new ColorStopCollection(gb.GradientStops.GroupBy(gs => gs.Offset).ToDictionary(gs => (float)gs.First().Offset, gs => gs.First().Color.ToDrawingColor())); + else if (brush is M.SolidColorBrush sb) + return new ColorStopCollection { { 0f, sb.Color.ToDrawingColor() } }; + throw new InvalidOperationException($"Brush of type '{brush.GetType().Name} could not be converted to a ColorStopCollection."); + } + + /// + /// Determines if this color stop collection contains the same stops as another collection. + /// + public bool StopsEqual(ColorStopCollection other) => Enumerable.SequenceEqual(stops, other.stops); + + #region IEnumerable + /// Alias for to allow for list constructor syntax. + public void Add(float offset, D.Color color) => SetColorAt(offset, color); + + public IEnumerator> GetEnumerator() => ((IEnumerable>)stops).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable>)stops).GetEnumerator(); + #endregion } ///