diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj
index 9a766c559..d4caadce7 100644
--- a/src/Artemis.Core/Artemis.Core.csproj
+++ b/src/Artemis.Core/Artemis.Core.csproj
@@ -12,6 +12,7 @@
ArtemisRGB.Core
1
enable
+ true
diff --git a/src/Artemis.Core/Artemis.Core.csproj.DotSettings b/src/Artemis.Core/Artemis.Core.csproj.DotSettings
index 57c6a2f5f..6641be310 100644
--- a/src/Artemis.Core/Artemis.Core.csproj.DotSettings
+++ b/src/Artemis.Core/Artemis.Core.csproj.DotSettings
@@ -1,4 +1,6 @@
+ True
+ True
True
True
True
diff --git a/src/Artemis.Core/ColorScience/Quantization/ColorCube.cs b/src/Artemis.Core/ColorScience/Quantization/ColorCube.cs
new file mode 100644
index 000000000..0bfc2fe59
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Quantization/ColorCube.cs
@@ -0,0 +1,176 @@
+using SkiaSharp;
+using System;
+using System.Numerics;
+using System.Runtime.InteropServices;
+
+namespace Artemis.Core.ColorScience;
+
+internal readonly struct ColorRanges
+{
+ public readonly byte RedRange;
+ public readonly byte GreenRange;
+ public readonly byte BlueRange;
+
+ public ColorRanges(byte redRange, byte greenRange, byte blueRange)
+ {
+ this.RedRange = redRange;
+ this.GreenRange = greenRange;
+ this.BlueRange = blueRange;
+ }
+}
+
+internal class ColorCube
+{
+ private const int BYTES_PER_COLOR = 4;
+ private static readonly int ELEMENTS_PER_VECTOR = Vector.Count / BYTES_PER_COLOR;
+ private static readonly int BYTES_PER_VECTOR = ELEMENTS_PER_VECTOR * BYTES_PER_COLOR;
+
+ private readonly int _from;
+ private readonly int _length;
+ private SortTarget _currentOrder = SortTarget.None;
+
+ public ColorCube(in Span fullColorList, int from, int length, SortTarget preOrdered)
+ {
+ this._from = from;
+ this._length = length;
+
+ OrderColors(fullColorList.Slice(from, length), preOrdered);
+ }
+
+ private void OrderColors(in Span colors, SortTarget preOrdered)
+ {
+ if (colors.Length < 2) return;
+ ColorRanges colorRanges = GetColorRanges(colors);
+
+ if ((colorRanges.RedRange > colorRanges.GreenRange) && (colorRanges.RedRange > colorRanges.BlueRange))
+ {
+ if (preOrdered != SortTarget.Red)
+ QuantizerSort.SortRed(colors);
+
+ _currentOrder = SortTarget.Red;
+ }
+ else if (colorRanges.GreenRange > colorRanges.BlueRange)
+ {
+ if (preOrdered != SortTarget.Green)
+ QuantizerSort.SortGreen(colors);
+
+ _currentOrder = SortTarget.Green;
+ }
+ else
+ {
+ if (preOrdered != SortTarget.Blue)
+ QuantizerSort.SortBlue(colors);
+
+ _currentOrder = SortTarget.Blue;
+ }
+ }
+
+ private unsafe ColorRanges GetColorRanges(in ReadOnlySpan colors)
+ {
+ if (Vector.IsHardwareAccelerated && (colors.Length >= Vector.Count))
+ {
+ int chunks = colors.Length / ELEMENTS_PER_VECTOR;
+ int missingElements = colors.Length - (chunks * ELEMENTS_PER_VECTOR);
+
+ Vector max = Vector.Zero;
+ Vector min = new(byte.MaxValue);
+
+ ReadOnlySpan colorBytes = MemoryMarshal.AsBytes(colors);
+ fixed (byte* colorPtr = &MemoryMarshal.GetReference(colorBytes))
+ {
+ byte* current = colorPtr;
+ for (int i = 0; i < chunks; i++)
+ {
+ Vector currentVector = *(Vector*)current;
+
+ max = Vector.Max(max, currentVector);
+ min = Vector.Min(min, currentVector);
+
+ current += BYTES_PER_VECTOR;
+ }
+ }
+
+ byte redMin = byte.MaxValue;
+ byte redMax = byte.MinValue;
+ byte greenMin = byte.MaxValue;
+ byte greenMax = byte.MinValue;
+ byte blueMin = byte.MaxValue;
+ byte blueMax = byte.MinValue;
+
+ for (int i = 0; i < BYTES_PER_VECTOR; i += BYTES_PER_COLOR)
+ {
+ if (min[i + 2] < redMin) redMin = min[i + 2];
+ if (max[i + 2] > redMax) redMax = max[i + 2];
+ if (min[i + 1] < greenMin) greenMin = min[i + 1];
+ if (max[i + 1] > greenMax) greenMax = max[i + 1];
+ if (min[i] < blueMin) blueMin = min[i];
+ if (max[i] > blueMax) blueMax = max[i];
+ }
+
+ for (int i = 0; i < missingElements; i++)
+ {
+ SKColor color = colors[^(i + 1)];
+
+ if (color.Red < redMin) redMin = color.Red;
+ if (color.Red > redMax) redMax = color.Red;
+ if (color.Green < greenMin) greenMin = color.Green;
+ if (color.Green > greenMax) greenMax = color.Green;
+ if (color.Blue < blueMin) blueMin = color.Blue;
+ if (color.Blue > blueMax) blueMax = color.Blue;
+ }
+
+ return new ColorRanges((byte)(redMax - redMin), (byte)(greenMax - greenMin), (byte)(blueMax - blueMin));
+ }
+ else
+ {
+ byte redMin = byte.MaxValue;
+ byte redMax = byte.MinValue;
+ byte greenMin = byte.MaxValue;
+ byte greenMax = byte.MinValue;
+ byte blueMin = byte.MaxValue;
+ byte blueMax = byte.MinValue;
+
+ foreach (SKColor color in colors)
+ {
+ if (color.Red < redMin) redMin = color.Red;
+ if (color.Red > redMax) redMax = color.Red;
+ if (color.Green < greenMin) greenMin = color.Green;
+ if (color.Green > greenMax) greenMax = color.Green;
+ if (color.Blue < blueMin) blueMin = color.Blue;
+ if (color.Blue > blueMax) blueMax = color.Blue;
+ }
+
+ return new ColorRanges((byte)(redMax - redMin), (byte)(greenMax - greenMin), (byte)(blueMax - blueMin));
+ }
+ }
+
+
+ internal void Split(in Span fullColorList, out ColorCube a, out ColorCube b)
+ {
+ Span colors = fullColorList.Slice(_from, _length);
+
+ int median = colors.Length / 2;
+
+ a = new ColorCube(fullColorList, _from, median, _currentOrder);
+ b = new ColorCube(fullColorList, _from + median, colors.Length - median, _currentOrder);
+ }
+
+ internal SKColor GetAverageColor(in ReadOnlySpan fullColorList)
+ {
+ ReadOnlySpan colors = fullColorList.Slice(_from, _length);
+
+ int r = 0, g = 0, b = 0;
+ foreach (SKColor color in colors)
+ {
+ r += color.Red;
+ g += color.Green;
+ b += color.Blue;
+ }
+
+ return new SKColor(
+ (byte)(r / colors.Length),
+ (byte)(g / colors.Length),
+ (byte)(b / colors.Length)
+ );
+ }
+}
diff --git a/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs b/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs
new file mode 100644
index 000000000..1afaa4f3c
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs
@@ -0,0 +1,285 @@
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace Artemis.Core.ColorScience;
+
+///
+/// Helper class for color quantization.
+///
+public static class ColorQuantizer
+{
+ ///
+ /// Quantizes a span of colors into the desired amount of representative colors.
+ ///
+ /// The colors to quantize
+ /// How many colors to return. Must be a power of two.
+ /// colors.
+ public static SKColor[] Quantize(in Span colors, int amount)
+ {
+ if (!BitOperations.IsPow2(amount))
+ throw new ArgumentException("Must be power of two", nameof(amount));
+
+ int splits = BitOperations.Log2((uint)amount);
+ return QuantizeSplit(colors, splits);
+ }
+
+ ///
+ /// Quantizes a span of colors, splitting the average number of times.
+ ///
+ /// The colors to quantize
+ /// How many splits to execute. Each split doubles the number of colors returned.
+ /// Up to (2 ^ ) number of colors.
+ public static SKColor[] QuantizeSplit(in Span colors, int splits)
+ {
+ if (colors.Length < (1 << splits)) throw new ArgumentException($"The color array must at least contain ({(1 << splits)}) to perform {splits} splits.");
+
+ Span cubes = new ColorCube[1 << splits];
+ cubes[0] = new ColorCube(colors, 0, colors.Length, SortTarget.None);
+
+ int currentIndex = 0;
+ for (int i = 0; i < splits; i++)
+ {
+ int currentCubeCount = 1 << i;
+ Span currentCubes = cubes.Slice(0, currentCubeCount);
+ for (int j = 0; j < currentCubes.Length; j++)
+ {
+ currentCubes[j].Split(colors, out ColorCube a, out ColorCube b);
+ currentCubes[j] = a;
+ cubes[++currentIndex] = b;
+ }
+ }
+
+ SKColor[] result = new SKColor[cubes.Length];
+ for (int i = 0; i < cubes.Length; i++)
+ result[i] = cubes[i].GetAverageColor(colors);
+
+ return result;
+ }
+
+ ///
+ /// Finds colors with certain characteristics in a given .
+ ///
+ /// Vibrant variants are more saturated, while Muted colors are less.
+ ///
+ /// Light and Dark colors have higher and lower lightness values, respectively.
+ ///
+ /// The colors to find the variations in
+ /// Which type of color to find
+ ///
+ /// Ignore hard limits on whether a color is considered for each category. Result may be
+ /// if this is false
+ ///
+ /// The color found
+ public static SKColor FindColorVariation(IEnumerable colors, ColorType type, bool ignoreLimits = false)
+ {
+ float bestColorScore = 0;
+ SKColor bestColor = SKColor.Empty;
+
+ foreach (SKColor clr in colors)
+ {
+ float score = GetScore(clr, type, ignoreLimits);
+ if (score > bestColorScore)
+ {
+ bestColorScore = score;
+ bestColor = clr;
+ }
+ }
+
+ return bestColor;
+ }
+
+ ///
+ /// Finds all the color variations available and returns a struct containing them all.
+ ///
+ /// The colors to find the variations in
+ ///
+ /// Ignore hard limits on whether a color is considered for each category. Some colors may be
+ /// if this is false
+ ///
+ /// A swatch containing all color variations
+ public static ColorSwatch FindAllColorVariations(IEnumerable colors, bool ignoreLimits = false)
+ {
+ SKColor bestVibrantColor = SKColor.Empty;
+ SKColor bestLightVibrantColor = SKColor.Empty;
+ SKColor bestDarkVibrantColor = SKColor.Empty;
+ SKColor bestMutedColor = SKColor.Empty;
+ SKColor bestLightMutedColor = SKColor.Empty;
+ SKColor bestDarkMutedColor = SKColor.Empty;
+ float bestVibrantScore = float.MinValue;
+ float bestLightVibrantScore = float.MinValue;
+ float bestDarkVibrantScore = float.MinValue;
+ float bestMutedScore = float.MinValue;
+ float bestLightMutedScore = float.MinValue;
+ float bestDarkMutedScore = float.MinValue;
+
+ //ugly but at least we only loop through the enumerable once ¯\_(ツ)_/¯
+ foreach (SKColor color in colors)
+ {
+ static void SetIfBetterScore(ref float bestScore, ref SKColor bestColor, SKColor newColor, ColorType type, bool ignoreLimits)
+ {
+ float newScore = GetScore(newColor, type, ignoreLimits);
+ if (newScore > bestScore)
+ {
+ bestScore = newScore;
+ bestColor = newColor;
+ }
+ }
+
+ SetIfBetterScore(ref bestVibrantScore, ref bestVibrantColor, color, ColorType.Vibrant, ignoreLimits);
+ SetIfBetterScore(ref bestLightVibrantScore, ref bestLightVibrantColor, color, ColorType.LightVibrant, ignoreLimits);
+ SetIfBetterScore(ref bestDarkVibrantScore, ref bestDarkVibrantColor, color, ColorType.DarkVibrant, ignoreLimits);
+ SetIfBetterScore(ref bestMutedScore, ref bestMutedColor, color, ColorType.Muted, ignoreLimits);
+ SetIfBetterScore(ref bestLightMutedScore, ref bestLightMutedColor, color, ColorType.LightMuted, ignoreLimits);
+ SetIfBetterScore(ref bestDarkMutedScore, ref bestDarkMutedColor, color, ColorType.DarkMuted, ignoreLimits);
+ }
+
+ return new ColorSwatch
+ {
+ Vibrant = bestVibrantColor,
+ LightVibrant = bestLightVibrantColor,
+ DarkVibrant = bestDarkVibrantColor,
+ Muted = bestMutedColor,
+ LightMuted = bestLightMutedColor,
+ DarkMuted = bestDarkMutedColor,
+ };
+ }
+
+ ///
+ /// Gets a gradient from a given image.
+ ///
+ /// The image to process
+ public static ColorGradient GetGradientFromImage(SKBitmap bitmap)
+ {
+ SKColor[] colors = QuantizeSplit(bitmap.Pixels, 8);
+ ColorSwatch swatch = FindAllColorVariations(colors);
+ SKColor[] swatchArray = new SKColor[]
+ {
+ swatch.Muted,
+ swatch.Vibrant,
+ swatch.DarkMuted,
+ swatch.DarkVibrant,
+ swatch.LightMuted,
+ swatch.LightVibrant
+ };
+
+ ColorSorter.Sort(swatchArray, SKColors.Black);
+
+ ColorGradient gradient = new();
+
+ for (int i = 0; i < swatchArray.Length; i++)
+ gradient.Add(new ColorGradientStop(swatchArray[i], (float)i / (swatchArray.Length - 1)));
+
+ return gradient;
+ }
+
+ private static float GetScore(SKColor color, ColorType type, bool ignoreLimits = false)
+ {
+ static float InvertDiff(float value, float target)
+ {
+ return 1 - Math.Abs(value - target);
+ }
+
+ color.ToHsl(out float _, out float saturation, out float luma);
+ saturation /= 100f;
+ luma /= 100f;
+
+ if (!ignoreLimits && ((saturation <= GetMinSaturation(type)) || (saturation >= GetMaxSaturation(type)) || (luma <= GetMinLuma(type)) || (luma >= GetMaxLuma(type))))
+ {
+ //if either saturation or luma falls outside the min-max, return the
+ //lowest score possible unless we're ignoring these limits.
+ return float.MinValue;
+ }
+
+ float totalValue = (InvertDiff(saturation, GetTargetSaturation(type)) * WEIGHT_SATURATION) + (InvertDiff(luma, GetTargetLuma(type)) * WEIGHT_LUMA);
+
+ const float TOTAL_WEIGHT = WEIGHT_SATURATION + WEIGHT_LUMA;
+
+ return totalValue / TOTAL_WEIGHT;
+ }
+
+ #region Constants
+
+ private const float TARGET_DARK_LUMA = 0.26f;
+ private const float MAX_DARK_LUMA = 0.45f;
+ private const float MIN_LIGHT_LUMA = 0.55f;
+ private const float TARGET_LIGHT_LUMA = 0.74f;
+ private const float MIN_NORMAL_LUMA = 0.3f;
+ private const float TARGET_NORMAL_LUMA = 0.5f;
+ private const float MAX_NORMAL_LUMA = 0.7f;
+ private const float TARGET_MUTES_SATURATION = 0.3f;
+ private const float MAX_MUTES_SATURATION = 0.3f;
+ private const float TARGET_VIBRANT_SATURATION = 1.0f;
+ private const float MIN_VIBRANT_SATURATION = 0.35f;
+ private const float WEIGHT_SATURATION = 3f;
+ private const float WEIGHT_LUMA = 5f;
+
+ private static float GetTargetLuma(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => TARGET_NORMAL_LUMA,
+ ColorType.LightVibrant => TARGET_LIGHT_LUMA,
+ ColorType.DarkVibrant => TARGET_DARK_LUMA,
+ ColorType.Muted => TARGET_NORMAL_LUMA,
+ ColorType.LightMuted => TARGET_LIGHT_LUMA,
+ ColorType.DarkMuted => TARGET_DARK_LUMA,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ private static float GetMinLuma(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => MIN_NORMAL_LUMA,
+ ColorType.LightVibrant => MIN_LIGHT_LUMA,
+ ColorType.DarkVibrant => 0f,
+ ColorType.Muted => MIN_NORMAL_LUMA,
+ ColorType.LightMuted => MIN_LIGHT_LUMA,
+ ColorType.DarkMuted => 0,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ private static float GetMaxLuma(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => MAX_NORMAL_LUMA,
+ ColorType.LightVibrant => 1f,
+ ColorType.DarkVibrant => MAX_DARK_LUMA,
+ ColorType.Muted => MAX_NORMAL_LUMA,
+ ColorType.LightMuted => 1f,
+ ColorType.DarkMuted => MAX_DARK_LUMA,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ private static float GetTargetSaturation(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => TARGET_VIBRANT_SATURATION,
+ ColorType.LightVibrant => TARGET_VIBRANT_SATURATION,
+ ColorType.DarkVibrant => TARGET_VIBRANT_SATURATION,
+ ColorType.Muted => TARGET_MUTES_SATURATION,
+ ColorType.LightMuted => TARGET_MUTES_SATURATION,
+ ColorType.DarkMuted => TARGET_MUTES_SATURATION,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ private static float GetMinSaturation(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => MIN_VIBRANT_SATURATION,
+ ColorType.LightVibrant => MIN_VIBRANT_SATURATION,
+ ColorType.DarkVibrant => MIN_VIBRANT_SATURATION,
+ ColorType.Muted => 0,
+ ColorType.LightMuted => 0,
+ ColorType.DarkMuted => 0,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ private static float GetMaxSaturation(ColorType colorType) => colorType switch
+ {
+ ColorType.Vibrant => 1f,
+ ColorType.LightVibrant => 1f,
+ ColorType.DarkVibrant => 1f,
+ ColorType.Muted => MAX_MUTES_SATURATION,
+ ColorType.LightMuted => MAX_MUTES_SATURATION,
+ ColorType.DarkMuted => MAX_MUTES_SATURATION,
+ _ => throw new ArgumentException(nameof(colorType))
+ };
+
+ #endregion
+}
diff --git a/src/Artemis.Core/Services/ColorQuantizer/ColorSwatch.cs b/src/Artemis.Core/ColorScience/Quantization/ColorSwatch.cs
similarity index 92%
rename from src/Artemis.Core/Services/ColorQuantizer/ColorSwatch.cs
rename to src/Artemis.Core/ColorScience/Quantization/ColorSwatch.cs
index b1b4561a5..16dd769b3 100644
--- a/src/Artemis.Core/Services/ColorQuantizer/ColorSwatch.cs
+++ b/src/Artemis.Core/ColorScience/Quantization/ColorSwatch.cs
@@ -1,11 +1,11 @@
using SkiaSharp;
-namespace Artemis.Core.Services;
+namespace Artemis.Core.ColorScience;
///
/// Swatch containing the known useful color variations.
///
-public struct ColorSwatch
+public readonly record struct ColorSwatch
{
///
/// The component.
diff --git a/src/Artemis.Core/Services/ColorQuantizer/ColorType.cs b/src/Artemis.Core/ColorScience/Quantization/ColorType.cs
similarity index 94%
rename from src/Artemis.Core/Services/ColorQuantizer/ColorType.cs
rename to src/Artemis.Core/ColorScience/Quantization/ColorType.cs
index 81f28f99c..c3eada53f 100644
--- a/src/Artemis.Core/Services/ColorQuantizer/ColorType.cs
+++ b/src/Artemis.Core/ColorScience/Quantization/ColorType.cs
@@ -1,4 +1,4 @@
-namespace Artemis.Core.Services;
+namespace Artemis.Core.ColorScience;
///
/// The types of relevant colors in an image.
diff --git a/src/Artemis.Core/ColorScience/Quantization/QuantizerSort.cs b/src/Artemis.Core/ColorScience/Quantization/QuantizerSort.cs
new file mode 100644
index 000000000..3126a9b7c
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Quantization/QuantizerSort.cs
@@ -0,0 +1,103 @@
+using SkiaSharp;
+using System;
+using System.Buffers;
+
+namespace Artemis.Core.ColorScience;
+
+//HACK DarthAffe 17.11.2022: Sorting is a really hot path in the quantizer, therefore abstracting this into cleaner code (one method with parameter or something like that) sadly has a well measurable performance impact.
+internal static class QuantizerSort
+{
+ #region Methods
+
+ public static void SortRed(in Span span)
+ {
+ Span counts = stackalloc int[256];
+ foreach (SKColor t in span)
+ counts[t.Red]++;
+
+ SKColor[] bucketsArray = ArrayPool.Shared.Rent(span.Length);
+ Span buckets = bucketsArray.AsSpan().Slice(0, span.Length);
+ Span currentBucketIndex = stackalloc int[256];
+
+ int offset = 0;
+ for (int i = 0; i < counts.Length; i++)
+ {
+ currentBucketIndex[i] = offset;
+ offset += counts[i];
+ }
+
+ foreach (SKColor color in span)
+ {
+ int index = color.Red;
+ int bucketIndex = currentBucketIndex[index];
+ currentBucketIndex[index]++;
+ buckets[bucketIndex] = color;
+ }
+
+ buckets.CopyTo(span);
+
+ ArrayPool.Shared.Return(bucketsArray);
+ }
+
+ public static void SortGreen(in Span span)
+ {
+ Span counts = stackalloc int[256];
+ foreach (SKColor t in span)
+ counts[t.Green]++;
+
+ SKColor[] bucketsArray = ArrayPool.Shared.Rent(span.Length);
+ Span buckets = bucketsArray.AsSpan().Slice(0, span.Length);
+ Span currentBucketIndex = stackalloc int[256];
+
+ int offset = 0;
+ for (int i = 0; i < counts.Length; i++)
+ {
+ currentBucketIndex[i] = offset;
+ offset += counts[i];
+ }
+
+ foreach (SKColor color in span)
+ {
+ int index = color.Green;
+ int bucketIndex = currentBucketIndex[index];
+ currentBucketIndex[index]++;
+ buckets[bucketIndex] = color;
+ }
+
+ buckets.CopyTo(span);
+
+ ArrayPool.Shared.Return(bucketsArray);
+ }
+
+ public static void SortBlue(in Span span)
+ {
+ Span counts = stackalloc int[256];
+ foreach (SKColor t in span)
+ counts[t.Blue]++;
+
+ SKColor[] bucketsArray = ArrayPool.Shared.Rent(span.Length);
+ Span buckets = bucketsArray.AsSpan().Slice(0, span.Length);
+ Span currentBucketIndex = stackalloc int[256];
+
+ int offset = 0;
+ for (int i = 0; i < counts.Length; i++)
+ {
+ currentBucketIndex[i] = offset;
+ offset += counts[i];
+ }
+
+ foreach (SKColor color in span)
+ {
+ int index = color.Blue;
+ int bucketIndex = currentBucketIndex[index];
+ currentBucketIndex[index]++;
+ buckets[bucketIndex] = color;
+ }
+
+ buckets.CopyTo(span);
+
+ ArrayPool.Shared.Return(bucketsArray);
+ }
+
+ #endregion
+}
diff --git a/src/Artemis.Core/ColorScience/Quantization/SortTarget.cs b/src/Artemis.Core/ColorScience/Quantization/SortTarget.cs
new file mode 100644
index 000000000..72f58517d
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Quantization/SortTarget.cs
@@ -0,0 +1,6 @@
+namespace Artemis.Core.ColorScience;
+
+internal enum SortTarget
+{
+ None, Red, Green, Blue
+}
diff --git a/src/Artemis.Core/ColorScience/Sorting/Cie94.cs b/src/Artemis.Core/ColorScience/Sorting/Cie94.cs
new file mode 100644
index 000000000..ca6ea6644
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Sorting/Cie94.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Runtime.CompilerServices;
+
+namespace Artemis.Core.ColorScience;
+
+//HACK DarthAffe 17.11.2022: Due to the high amount of inlined code this is not supposed to be used outside the ColorSorter!
+internal static class Cie94
+{
+ const float KL = 1.0f;
+ const float K1 = 0.045f;
+ const float K2 = 0.015f;
+ const float SL = 1.0f;
+ const float KC = 1.0f;
+ const float KH = 1.0f;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static float ComputeDifference(in LabColor x, in LabColor y)
+ {
+ float deltaL = x.L - y.L;
+ float deltaA = x.A - y.A;
+ float deltaB = x.B - y.B;
+
+ float c1 = MathF.Sqrt(Pow2(x.A) + Pow2(x.B));
+ float c2 = MathF.Sqrt(Pow2(y.A) + Pow2(y.B));
+ float deltaC = c1 - c2;
+
+ float deltaH = Pow2(deltaA) + Pow2(deltaB) - Pow2(deltaC);
+ deltaH = deltaH < 0f ? 0f : MathF.Sqrt(deltaH);
+
+ float sc = 1.0f + K1 * c1;
+ float sh = 1.0f + K2 * c1;
+
+ float i = Pow2(deltaL / (KL * SL)) + Pow2(deltaC / (KC * sc)) + Pow2(deltaH / (KH * sh));
+
+ return i < 0f ? 0f : MathF.Sqrt(i);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static float Pow2(in float x) => x * x;
+}
diff --git a/src/Artemis.Core/ColorScience/Sorting/ColorSorter.cs b/src/Artemis.Core/ColorScience/Sorting/ColorSorter.cs
new file mode 100644
index 000000000..63f3f04d9
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Sorting/ColorSorter.cs
@@ -0,0 +1,83 @@
+using SkiaSharp;
+using System;
+using System.Buffers;
+
+namespace Artemis.Core.ColorScience
+{
+ ///
+ /// Methods for sorting colors.
+ ///
+ public static class ColorSorter
+ {
+ private const int STACK_ALLOC_LIMIT = 1024;
+ private readonly record struct SortColor(SKColor RGB, LabColor Lab);
+
+ private static void Sort(in Span colors, Span sortColors, LabColor referenceColor)
+ {
+ for (int i = 0; i < colors.Length; i++)
+ {
+ SKColor color = colors[i];
+ sortColors[i] = new SortColor(color, new LabColor(color));
+ }
+
+ for (int i = 0; i < colors.Length; i++)
+ {
+ float closestDistance = float.MaxValue;
+ int closestIndex = -1;
+ for (int j = 0; j < sortColors.Length; j++)
+ {
+ float distance = Cie94.ComputeDifference(sortColors[j].Lab, referenceColor);
+ if (distance == 0f)
+ {
+ closestIndex = j;
+ break;
+ }
+
+ if (distance < closestDistance)
+ {
+ closestIndex = j;
+ closestDistance = distance;
+ }
+ }
+
+ SortColor closestColor = sortColors[closestIndex];
+ colors[i] = closestColor.RGB;
+ referenceColor = closestColor.Lab;
+
+ sortColors[closestIndex] = sortColors[^1];
+ sortColors = sortColors[..^1];
+ }
+ }
+
+ ///
+ /// Sorts the given colors in place, starting by the closest to the reference color.
+ ///
+ /// The span of colors to sort.
+ /// The reference color to sort from.
+ public static void Sort(in Span colors, SKColor startColor = new())
+ {
+ LabColor referenceColor = new(startColor);
+
+ if (colors.Length < STACK_ALLOC_LIMIT)
+ {
+ Span sortColors = stackalloc SortColor[colors.Length];
+ Sort(colors, sortColors, referenceColor);
+ }
+ else
+ {
+ SortColor[] sortColorArray = ArrayPool.Shared.Rent(colors.Length);
+ Span sortColors = sortColorArray.AsSpan(0, colors.Length);
+ Sort(colors, sortColors, referenceColor);
+ ArrayPool.Shared.Return(sortColorArray);
+ }
+ }
+
+ ///
+ /// Gets the Cie94 difference between two colors.
+ ///
+ public static float GetColorDifference(in SKColor color1, in SKColor color2)
+ {
+ return Cie94.ComputeDifference(new LabColor(color1), new LabColor(color2));
+ }
+ }
+}
diff --git a/src/Artemis.Core/ColorScience/Sorting/LabColor.cs b/src/Artemis.Core/ColorScience/Sorting/LabColor.cs
new file mode 100644
index 000000000..e4e17dfde
--- /dev/null
+++ b/src/Artemis.Core/ColorScience/Sorting/LabColor.cs
@@ -0,0 +1,106 @@
+using RGB.NET.Core;
+using SkiaSharp;
+using System.Runtime.CompilerServices;
+using static System.MathF;
+
+namespace Artemis.Core.ColorScience;
+
+internal readonly struct LabColor
+{
+ #region Constants
+
+ private const float LAB_REFERENCE_X = 0.95047f;
+ private const float LAB_REFERENCE_Y = 1.0f;
+ private const float LAB_REFERENCE_Z = 1.08883f;
+
+ #endregion
+
+ #region Properties & Fields
+
+ public readonly float L;
+ public readonly float A;
+ public readonly float B;
+
+ #endregion
+
+ #region Constructors
+
+ public LabColor(SKColor color)
+ : this(color.Red.GetPercentageFromByteValue(),
+ color.Green.GetPercentageFromByteValue(),
+ color.Blue.GetPercentageFromByteValue())
+ { }
+
+ public LabColor(float r, float g, float b)
+ {
+ (L, A, B) = RgbToLab((r, g, b));
+ }
+
+ #endregion
+
+ #region Methods
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static (float x, float y, float z) RgbToXyz((float r, float g, float b) rgb)
+ {
+ (float r, float g, float b) = rgb;
+
+ if (r > 0.04045f)
+ r = Pow((r + 0.055f) / 1.055f, 2.4f);
+ else
+ r /= 12.92f;
+
+ if (g > 0.04045)
+ g = Pow((g + 0.055f) / 1.055f, 2.4f);
+ else
+ g /= 12.92f;
+
+ if (b > 0.04045)
+ b = Pow((b + 0.055f) / 1.055f, 2.4f);
+ else
+ b /= 12.92f;
+
+ float x = (r * 0.4124f) + (g * 0.3576f) + (b * 0.1805f);
+ float y = (r * 0.2126f) + (g * 0.7152f) + (b * 0.0722f);
+ float z = (r * 0.0193f) + (g * 0.1192f) + (b * 0.9505f);
+
+ return (x, y, z);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static (float l, float a, float b) XyzToLab((float x, float y, float z) xyz)
+ {
+ const float POWER = 1.0f / 3.0f;
+ const float OFFSET = 16.0f / 116.0f;
+
+ float x = xyz.x / LAB_REFERENCE_X;
+ float y = xyz.y / LAB_REFERENCE_Y;
+ float z = xyz.z / LAB_REFERENCE_Z;
+
+ if (x > 0.008856f)
+ x = Pow(x, POWER);
+ else
+ x = (7.787f * x) + OFFSET;
+
+ if (y > 0.008856f)
+ y = Pow(y, POWER);
+ else
+ y = (7.787f * y) + OFFSET;
+
+ if (z > 0.008856f)
+ z = Pow(z, POWER);
+ else
+ z = (7.787f * z) + OFFSET;
+
+ float l = (116f * y) - 16f;
+ float a = 500f * (x - y);
+ float b = 200f * (y - z);
+
+ return (l, a, b);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static (float l, float a, float b) RgbToLab((float r, float g, float b) rgb) => XyzToLab(RgbToXyz(rgb));
+
+ #endregion
+}
diff --git a/src/Artemis.Core/Services/ColorQuantizer/ColorCube.cs b/src/Artemis.Core/Services/ColorQuantizer/ColorCube.cs
deleted file mode 100644
index 2e5133f18..000000000
--- a/src/Artemis.Core/Services/ColorQuantizer/ColorCube.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using SkiaSharp;
-
-namespace Artemis.Core.Services;
-
-internal class ColorCube
-{
- private readonly List _colors;
-
- internal ColorCube(IEnumerable colors)
- {
- if (colors.Count() < 2)
- {
- _colors = colors.ToList();
- return;
- }
-
- int redRange = colors.Max(c => c.Red) - colors.Min(c => c.Red);
- int greenRange = colors.Max(c => c.Green) - colors.Min(c => c.Green);
- int blueRange = colors.Max(c => c.Blue) - colors.Min(c => c.Blue);
-
- if (redRange > greenRange && redRange > blueRange)
- _colors = colors.OrderBy(a => a.Red).ToList();
- else if (greenRange > blueRange)
- _colors = colors.OrderBy(a => a.Green).ToList();
- else
- _colors = colors.OrderBy(a => a.Blue).ToList();
- }
-
- internal bool TrySplit([NotNullWhen(true)] out ColorCube? a, [NotNullWhen(true)] out ColorCube? b)
- {
- if (_colors.Count < 2)
- {
- a = null;
- b = null;
- return false;
- }
-
- int median = _colors.Count / 2;
-
- a = new ColorCube(_colors.GetRange(0, median));
- b = new ColorCube(_colors.GetRange(median, _colors.Count - median));
-
- return true;
- }
-
- internal SKColor GetAverageColor()
- {
- int r = 0, g = 0, b = 0;
-
- for (int i = 0; i < _colors.Count; i++)
- {
- r += _colors[i].Red;
- g += _colors[i].Green;
- b += _colors[i].Blue;
- }
-
- return new SKColor(
- (byte) (r / _colors.Count),
- (byte) (g / _colors.Count),
- (byte) (b / _colors.Count)
- );
- }
-}
\ No newline at end of file
diff --git a/src/Artemis.Core/Services/ColorQuantizer/ColorQuantizerService.cs b/src/Artemis.Core/Services/ColorQuantizer/ColorQuantizerService.cs
deleted file mode 100644
index b1cfc22d8..000000000
--- a/src/Artemis.Core/Services/ColorQuantizer/ColorQuantizerService.cs
+++ /dev/null
@@ -1,227 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using SkiaSharp;
-
-namespace Artemis.Core.Services;
-
-///
-internal class ColorQuantizerService : IColorQuantizerService
-{
- private static float GetScore(SKColor color, ColorType type, bool ignoreLimits = false)
- {
- static float InvertDiff(float value, float target)
- {
- return 1 - Math.Abs(value - target);
- }
-
- color.ToHsl(out float _, out float saturation, out float luma);
- saturation /= 100f;
- luma /= 100f;
-
- if (!ignoreLimits &&
- (saturation <= GetMinSaturation(type) || saturation >= GetMaxSaturation(type)
- || luma <= GetMinLuma(type) || luma >= GetMaxLuma(type)))
- //if either saturation or luma falls outside the min-max, return the
- //lowest score possible unless we're ignoring these limits.
- return float.MinValue;
-
- float totalValue = InvertDiff(saturation, GetTargetSaturation(type)) * weightSaturation +
- InvertDiff(luma, GetTargetLuma(type)) * weightLuma;
-
- const float totalWeight = weightSaturation + weightLuma;
-
- return totalValue / totalWeight;
- }
-
- ///
- public SKColor[] Quantize(IEnumerable colors, int amount)
- {
- if ((amount & (amount - 1)) != 0)
- throw new ArgumentException("Must be power of two", nameof(amount));
-
- Queue cubes = new(amount);
- cubes.Enqueue(new ColorCube(colors));
-
- while (cubes.Count < amount)
- {
- ColorCube cube = cubes.Dequeue();
- if (cube.TrySplit(out ColorCube? a, out ColorCube? b))
- {
- cubes.Enqueue(a);
- cubes.Enqueue(b);
- }
- }
-
- return cubes.Select(c => c.GetAverageColor()).ToArray();
- }
-
- ///
- public SKColor FindColorVariation(IEnumerable colors, ColorType type, bool ignoreLimits = false)
- {
- float bestColorScore = 0;
- SKColor bestColor = SKColor.Empty;
-
- foreach (SKColor clr in colors)
- {
- float score = GetScore(clr, type, ignoreLimits);
- if (score > bestColorScore)
- {
- bestColorScore = score;
- bestColor = clr;
- }
- }
-
- return bestColor;
- }
-
- ///
- public ColorSwatch FindAllColorVariations(IEnumerable colors, bool ignoreLimits = false)
- {
- SKColor bestVibrantColor = SKColor.Empty;
- SKColor bestLightVibrantColor = SKColor.Empty;
- SKColor bestDarkVibrantColor = SKColor.Empty;
- SKColor bestMutedColor = SKColor.Empty;
- SKColor bestLightMutedColor = SKColor.Empty;
- SKColor bestDarkMutedColor = SKColor.Empty;
- float bestVibrantScore = 0;
- float bestLightVibrantScore = 0;
- float bestDarkVibrantScore = 0;
- float bestMutedScore = 0;
- float bestLightMutedScore = 0;
- float bestDarkMutedScore = 0;
-
- //ugly but at least we only loop through the enumerable once ¯\_(ツ)_/¯
- foreach (SKColor color in colors)
- {
- static void SetIfBetterScore(ref float bestScore, ref SKColor bestColor, SKColor newColor, ColorType type, bool ignoreLimits)
- {
- float newScore = GetScore(newColor, type, ignoreLimits);
- if (newScore > bestScore)
- {
- bestScore = newScore;
- bestColor = newColor;
- }
- }
-
- SetIfBetterScore(ref bestVibrantScore, ref bestVibrantColor, color, ColorType.Vibrant, ignoreLimits);
- SetIfBetterScore(ref bestLightVibrantScore, ref bestLightVibrantColor, color, ColorType.LightVibrant, ignoreLimits);
- SetIfBetterScore(ref bestDarkVibrantScore, ref bestDarkVibrantColor, color, ColorType.DarkVibrant, ignoreLimits);
- SetIfBetterScore(ref bestMutedScore, ref bestMutedColor, color, ColorType.Muted, ignoreLimits);
- SetIfBetterScore(ref bestLightMutedScore, ref bestLightMutedColor, color, ColorType.LightMuted, ignoreLimits);
- SetIfBetterScore(ref bestDarkMutedScore, ref bestDarkMutedColor, color, ColorType.DarkMuted, ignoreLimits);
- }
-
- return new ColorSwatch
- {
- Vibrant = bestVibrantColor,
- LightVibrant = bestLightVibrantColor,
- DarkVibrant = bestDarkVibrantColor,
- Muted = bestMutedColor,
- LightMuted = bestLightMutedColor,
- DarkMuted = bestDarkMutedColor
- };
- }
-
- #region Constants
-
- private const float targetDarkLuma = 0.26f;
- private const float maxDarkLuma = 0.45f;
- private const float minLightLuma = 0.55f;
- private const float targetLightLuma = 0.74f;
- private const float minNormalLuma = 0.3f;
- private const float targetNormalLuma = 0.5f;
- private const float maxNormalLuma = 0.7f;
- private const float targetMutesSaturation = 0.3f;
- private const float maxMutesSaturation = 0.3f;
- private const float targetVibrantSaturation = 1.0f;
- private const float minVibrantSaturation = 0.35f;
- private const float weightSaturation = 3f;
- private const float weightLuma = 5f;
-
- private static float GetTargetLuma(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => targetNormalLuma,
- ColorType.LightVibrant => targetLightLuma,
- ColorType.DarkVibrant => targetDarkLuma,
- ColorType.Muted => targetNormalLuma,
- ColorType.LightMuted => targetLightLuma,
- ColorType.DarkMuted => targetDarkLuma,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- private static float GetMinLuma(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => minNormalLuma,
- ColorType.LightVibrant => minLightLuma,
- ColorType.DarkVibrant => 0f,
- ColorType.Muted => minNormalLuma,
- ColorType.LightMuted => minLightLuma,
- ColorType.DarkMuted => 0,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- private static float GetMaxLuma(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => maxNormalLuma,
- ColorType.LightVibrant => 1f,
- ColorType.DarkVibrant => maxDarkLuma,
- ColorType.Muted => maxNormalLuma,
- ColorType.LightMuted => 1f,
- ColorType.DarkMuted => maxDarkLuma,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- private static float GetTargetSaturation(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => targetVibrantSaturation,
- ColorType.LightVibrant => targetVibrantSaturation,
- ColorType.DarkVibrant => targetVibrantSaturation,
- ColorType.Muted => targetMutesSaturation,
- ColorType.LightMuted => targetMutesSaturation,
- ColorType.DarkMuted => targetMutesSaturation,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- private static float GetMinSaturation(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => minVibrantSaturation,
- ColorType.LightVibrant => minVibrantSaturation,
- ColorType.DarkVibrant => minVibrantSaturation,
- ColorType.Muted => 0,
- ColorType.LightMuted => 0,
- ColorType.DarkMuted => 0,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- private static float GetMaxSaturation(ColorType colorType)
- {
- return colorType switch
- {
- ColorType.Vibrant => 1f,
- ColorType.LightVibrant => 1f,
- ColorType.DarkVibrant => 1f,
- ColorType.Muted => maxMutesSaturation,
- ColorType.LightMuted => maxMutesSaturation,
- ColorType.DarkMuted => maxMutesSaturation,
- _ => throw new ArgumentException(nameof(colorType))
- };
- }
-
- #endregion
-}
\ No newline at end of file
diff --git a/src/Artemis.Core/Services/ColorQuantizer/Interfaces/IColorQuantizerService.cs b/src/Artemis.Core/Services/ColorQuantizer/Interfaces/IColorQuantizerService.cs
deleted file mode 100644
index e363fe6d9..000000000
--- a/src/Artemis.Core/Services/ColorQuantizer/Interfaces/IColorQuantizerService.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System.Collections.Generic;
-using SkiaSharp;
-
-namespace Artemis.Core.Services;
-
-///
-/// A service providing a pallette of colors in a bitmap based on vibrant.js
-///
-public interface IColorQuantizerService : IArtemisService
-{
- ///
- /// Reduces an to a given amount of relevant colors. Based on the Median Cut
- /// algorithm
- ///
- /// The colors to quantize.
- /// The number of colors that should be calculated. Must be a power of two.
- /// The quantized colors.
- public SKColor[] Quantize(IEnumerable colors, int amount);
-
- ///
- /// Finds colors with certain characteristics in a given .
- ///
- /// Vibrant variants are more saturated, while Muted colors are less.
- ///
- /// Light and Dark colors have higher and lower lightness values, respectively.
- ///
- /// The colors to find the variations in
- /// Which type of color to find
- ///
- /// Ignore hard limits on whether a color is considered for each category. Result may be
- /// if this is false
- ///
- /// The color found
- public SKColor FindColorVariation(IEnumerable colors, ColorType type, bool ignoreLimits = false);
-
- ///
- /// Finds all the color variations available and returns a struct containing them all.
- ///
- /// The colors to find the variations in
- ///
- /// Ignore hard limits on whether a color is considered for each category. Some colors may be
- /// if this is false
- ///
- /// A swatch containing all color variations
- public ColorSwatch FindAllColorVariations(IEnumerable colors, bool ignoreLimits = false);
-}
\ No newline at end of file
diff --git a/src/Artemis.VisualScripting/Nodes/Color/GradientBuilderNode.cs b/src/Artemis.VisualScripting/Nodes/Color/GradientBuilderNode.cs
new file mode 100644
index 000000000..e4d380196
--- /dev/null
+++ b/src/Artemis.VisualScripting/Nodes/Color/GradientBuilderNode.cs
@@ -0,0 +1,55 @@
+using Artemis.Core;
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Artemis.VisualScripting.Nodes.Color
+{
+ [Node("Gradient Builder", "Generates a gradient based on some values", "Color", OutputType = typeof(ColorGradient), HelpUrl = "https://krazydad.com/tutorials/makecolors.php")]
+ public class GradientBuilderNode : Node
+ {
+ public OutputPin Output { get; }
+
+ public InputPin Frequency1 { get; }
+ public InputPin Frequency2 { get; }
+ public InputPin Frequency3 { get; }
+ public InputPin Phase1 { get; }
+ public InputPin Phase2 { get; }
+ public InputPin Phase3 { get; }
+ public InputPin Center { get; }
+ public InputPin Width { get; }
+ public InputPin Length { get; }
+
+ public GradientBuilderNode()
+ {
+ Output = CreateOutputPin();
+ Frequency1 = CreateInputPin("Frequency 1");
+ Frequency2 = CreateInputPin("Frequency 2");
+ Frequency3 = CreateInputPin("Frequency 3");
+ Phase1 = CreateInputPin("Phase 1");
+ Phase2 = CreateInputPin("Phase 2");
+ Phase3 = CreateInputPin("Phase 3");
+ Center = CreateInputPin("Center");
+ Width = CreateInputPin("Width");
+ Length = CreateInputPin("Length");
+ }
+
+ public override void Evaluate()
+ {
+ ColorGradient gradient = new ColorGradient();
+
+ for (int i = 0; i < Length.Value; i++)
+ {
+ Numeric r = Math.Sin(Frequency1.Value * i + Phase1.Value) * Width.Value + Center.Value;
+ Numeric g = Math.Sin(Frequency2.Value * i + Phase2.Value) * Width.Value + Center.Value;
+ Numeric b = Math.Sin(Frequency3.Value * i + Phase3.Value) * Width.Value + Center.Value;
+ gradient.Add(new ColorGradientStop(new SKColor((byte)r, (byte)g, (byte)b), i / Length.Value));
+ }
+
+ Output.Value = gradient;
+ }
+ }
+}
diff --git a/src/Artemis.VisualScripting/Nodes/Color/SortedGradient.cs b/src/Artemis.VisualScripting/Nodes/Color/SortedGradient.cs
new file mode 100644
index 000000000..3645d849f
--- /dev/null
+++ b/src/Artemis.VisualScripting/Nodes/Color/SortedGradient.cs
@@ -0,0 +1,45 @@
+using Artemis.Core;
+using Artemis.Core.ColorScience;
+using SkiaSharp;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Artemis.VisualScripting.Nodes.Color
+{
+ [Node("Sorted Gradient", "Generates a sorted gradient from the given colors", "Color", InputType = typeof(SKColor), OutputType = typeof(ColorGradient))]
+ public class SortedGradientNode : Node
+ {
+ public InputPinCollection Inputs { get; }
+ public OutputPin Output { get; }
+
+ public SortedGradientNode()
+ {
+ Inputs = CreateInputPinCollection();
+ Output = CreateOutputPin();
+ }
+
+ public override void Evaluate()
+ {
+ var colors = Inputs.Values.ToArray();
+
+ if (colors.Length == 0)
+ {
+ Output.Value = null;
+ return;
+ }
+
+ ColorSorter.Sort(colors, SKColors.Black);
+
+ var gradient = new ColorGradient();
+ for (int i = 0; i < colors.Length; i++)
+ {
+ gradient.Add(new(colors[i], (float)i / (colors.Length - 1)));
+ }
+
+ Output.Value = gradient;
+ }
+ }
+}