diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj index 9a766c559..0c62553d4 100644 --- a/src/Artemis.Core/Artemis.Core.csproj +++ b/src/Artemis.Core/Artemis.Core.csproj @@ -44,9 +44,9 @@ - - - + + + 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..5b7b353a0 --- /dev/null +++ b/src/Artemis.Core/ColorScience/Quantization/ColorCube.cs @@ -0,0 +1,167 @@ +using SkiaSharp; +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +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 readonly struct 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 readonly SortTarget _currentOrder = SortTarget.None; + + public ColorCube(in Span fullColorList, int from, int length, SortTarget preOrdered) + { + this._from = from; + this._length = length; + + if (length < 2) return; + + Span colors = fullColorList.Slice(from, length); + 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; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ColorRanges GetColorRanges(in ReadOnlySpan colors) + { + if (Vector.IsHardwareAccelerated && (colors.Length >= Vector.Count)) + { + int chunks = colors.Length / ELEMENTS_PER_VECTOR; + int vectorElements = (chunks * ELEMENTS_PER_VECTOR); + int missingElements = colors.Length - vectorElements; + + Vector max = Vector.Zero; + Vector min = new(byte.MaxValue); + foreach (Vector currentVector in MemoryMarshal.Cast>(colors[..vectorElements])) + { + max = Vector.Max(max, currentVector); + min = Vector.Min(min, currentVector); + } + + 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)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + 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); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + 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..46b593ece --- /dev/null +++ b/src/Artemis.Core/ColorScience/Quantization/QuantizerSort.cs @@ -0,0 +1,121 @@ +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 colors) + { + Span counts = stackalloc int[256]; + foreach (SKColor t in colors) + counts[t.Red]++; + + SKColor[] bucketsArray = ArrayPool.Shared.Rent(colors.Length); + + try + { + Span buckets = bucketsArray.AsSpan().Slice(0, colors.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 colors) + { + int index = color.Red; + int bucketIndex = currentBucketIndex[index]; + currentBucketIndex[index]++; + buckets[bucketIndex] = color; + } + + buckets.CopyTo(colors); + } + finally + { + ArrayPool.Shared.Return(bucketsArray); + } + } + + public static void SortGreen(in Span colors) + { + Span counts = stackalloc int[256]; + foreach (SKColor t in colors) + counts[t.Green]++; + + SKColor[] bucketsArray = ArrayPool.Shared.Rent(colors.Length); + + try + { + Span buckets = bucketsArray.AsSpan().Slice(0, colors.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 colors) + { + int index = color.Green; + int bucketIndex = currentBucketIndex[index]; + currentBucketIndex[index]++; + buckets[bucketIndex] = color; + } + + buckets.CopyTo(colors); + } + finally + { + ArrayPool.Shared.Return(bucketsArray); + } + } + + public static void SortBlue(in Span colors) + { + Span counts = stackalloc int[256]; + foreach (SKColor t in colors) + counts[t.Blue]++; + + SKColor[] bucketsArray = ArrayPool.Shared.Rent(colors.Length); + + try + { + Span buckets = bucketsArray.AsSpan().Slice(0, colors.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 colors) + { + int index = color.Blue; + int bucketIndex = currentBucketIndex[index]; + currentBucketIndex[index]++; + buckets[bucketIndex] = color; + } + + buckets.CopyTo(colors); + } + finally + { + 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..0ff7c5d6f --- /dev/null +++ b/src/Artemis.Core/ColorScience/Sorting/ColorSorter.cs @@ -0,0 +1,89 @@ +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); + try + { + Span sortColors = sortColorArray.AsSpan(0, colors.Length); + Sort(colors, sortColors, referenceColor); + } + finally + { + 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/Constants.cs b/src/Artemis.Core/Constants.cs index f465f118e..cfb3b0551 100644 --- a/src/Artemis.Core/Constants.cs +++ b/src/Artemis.Core/Constants.cs @@ -62,8 +62,8 @@ public static class Constants /// /// The current API version for plugins /// - public static readonly int PluginApiVersion = int.Parse(CoreAssembly.GetCustomAttributes() - .First(a => a.Key == "PluginApiVersion").Value); + public static readonly int PluginApiVersion = int.Parse(CoreAssembly.GetCustomAttributes().First(a => a.Key == "PluginApiVersion").Value ?? + throw new InvalidOperationException("Cannot find PluginApiVersion metadata in assembly")); /// /// The plugin info used by core components of Artemis diff --git a/src/Artemis.Core/JsonConverters/ForgivingVersionConverter.cs b/src/Artemis.Core/JsonConverters/ForgivingVersionConverter.cs index 8cfe43151..8a325f7e0 100644 --- a/src/Artemis.Core/JsonConverters/ForgivingVersionConverter.cs +++ b/src/Artemis.Core/JsonConverters/ForgivingVersionConverter.cs @@ -10,9 +10,9 @@ namespace Artemis.Core.JsonConverters /// internal class ForgivingVersionConverter : VersionConverter { - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { - object obj = base.ReadJson(reader, objectType, existingValue, serializer); + object? obj = base.ReadJson(reader, objectType, existingValue, serializer); if (obj is not Version v) return obj; diff --git a/src/Artemis.Core/Models/Profile/Folder.cs b/src/Artemis.Core/Models/Profile/Folder.cs index 363a5759b..ec3496955 100644 --- a/src/Artemis.Core/Models/Profile/Folder.cs +++ b/src/Artemis.Core/Models/Profile/Folder.cs @@ -99,19 +99,26 @@ public sealed class Folder : RenderProfileElement return; } - UpdateDisplayCondition(); - UpdateTimeline(deltaTime); + try + { + UpdateDisplayCondition(); + UpdateTimeline(deltaTime); - if (ShouldBeEnabled) - Enable(); - else if (Timeline.IsFinished) - Disable(); + if (ShouldBeEnabled) + Enable(); + else if (Timeline.IsFinished) + Disable(); - foreach (BaseLayerEffect baseLayerEffect in LayerEffects.Where(e => !e.Suspended)) - baseLayerEffect.InternalUpdate(Timeline); + foreach (BaseLayerEffect baseLayerEffect in LayerEffects.Where(e => !e.Suspended)) + baseLayerEffect.InternalUpdate(Timeline); - foreach (ProfileElement child in Children) - child.Update(deltaTime); + foreach (ProfileElement child in Children) + child.Update(deltaTime); + } + finally + { + Timeline.ClearDelta(); + } } /// @@ -224,8 +231,6 @@ public sealed class Folder : RenderProfileElement canvas.Restore(); layerPaint.DisposeSelfAndProperties(); } - - Timeline.ClearDelta(); } #endregion @@ -233,8 +238,10 @@ public sealed class Folder : RenderProfileElement /// public override void Enable() { - // No checks here, effects will do their own checks to ensure they never enable twice - // Also not enabling children, they'll enable themselves during their own Update + if (Enabled) + return; + + // Not enabling children, they'll enable themselves during their own Update foreach (BaseLayerEffect baseLayerEffect in LayerEffects) baseLayerEffect.InternalEnable(); @@ -244,7 +251,9 @@ public sealed class Folder : RenderProfileElement /// public override void Disable() { - // No checks here, effects will do their own checks to ensure they never disable twice + if (!Enabled) + return; + foreach (BaseLayerEffect baseLayerEffect in LayerEffects) baseLayerEffect.InternalDisable(); diff --git a/src/Artemis.Core/Models/Profile/Layer.cs b/src/Artemis.Core/Models/Profile/Layer.cs index beca25a7d..a92aabc35 100644 --- a/src/Artemis.Core/Models/Profile/Layer.cs +++ b/src/Artemis.Core/Models/Profile/Layer.cs @@ -40,7 +40,7 @@ public sealed class Layer : RenderProfileElement Suspended = false; Leds = new ReadOnlyCollection(_leds); Adapter = new LayerAdapter(this); - + Initialize(); } @@ -60,7 +60,7 @@ public sealed class Layer : RenderProfileElement Parent = parent; Leds = new ReadOnlyCollection(_leds); Adapter = new LayerAdapter(this); - + Load(); Initialize(); if (loadNodeScript) @@ -370,41 +370,48 @@ public sealed class Layer : RenderProfileElement return; } - UpdateDisplayCondition(); - UpdateTimeline(deltaTime); - - if (ShouldBeEnabled) - Enable(); - else if (Suspended || (Timeline.IsFinished && !_renderCopies.Any())) - Disable(); - - if (Timeline.Delta == TimeSpan.Zero) - return; - - General.Update(Timeline); - Transform.Update(Timeline); - LayerBrush?.InternalUpdate(Timeline); - - foreach (BaseLayerEffect baseLayerEffect in LayerEffects) + try { - if (!baseLayerEffect.Suspended) - baseLayerEffect.InternalUpdate(Timeline); + UpdateDisplayCondition(); + UpdateTimeline(deltaTime); + + if (ShouldBeEnabled) + Enable(); + else if (Suspended || (Timeline.IsFinished && !_renderCopies.Any())) + Disable(); + + if (!Enabled || Timeline.Delta == TimeSpan.Zero) + return; + + General.Update(Timeline); + Transform.Update(Timeline); + LayerBrush?.InternalUpdate(Timeline); + + foreach (BaseLayerEffect baseLayerEffect in LayerEffects) + { + if (!baseLayerEffect.Suspended) + baseLayerEffect.InternalUpdate(Timeline); + } + + // Remove render copies that finished their timeline and update the rest + for (int index = 0; index < _renderCopies.Count; index++) + { + Layer child = _renderCopies[index]; + if (!child.Timeline.IsFinished) + { + child.Update(deltaTime); + } + else + { + _renderCopies.Remove(child); + child.Dispose(); + index--; + } + } } - - // Remove render copies that finished their timeline and update the rest - for (int index = 0; index < _renderCopies.Count; index++) + finally { - Layer child = _renderCopies[index]; - if (!child.Timeline.IsFinished) - { - child.Update(deltaTime); - } - else - { - _renderCopies.Remove(child); - child.Dispose(); - index--; - } + Timeline.ClearDelta(); } } @@ -485,8 +492,6 @@ public sealed class Layer : RenderProfileElement { layerPaint.DisposeSelfAndProperties(); } - - Timeline.ClearDelta(); } private void RenderCopies(SKCanvas canvas, SKPointI basePosition) @@ -498,7 +503,9 @@ public sealed class Layer : RenderProfileElement /// public override void Enable() { - // No checks here, the brush and effects will do their own checks to ensure they never enable twice + if (Enabled) + return; + bool tryOrBreak = TryOrBreak(() => LayerBrush?.InternalEnable(), "Failed to enable layer brush"); if (!tryOrBreak) return; @@ -517,7 +524,9 @@ public sealed class Layer : RenderProfileElement /// public override void Disable() { - // No checks here, the brush and effects will do their own checks to ensure they never disable twice + if (!Enabled) + return; + LayerBrush?.InternalDisable(); foreach (BaseLayerEffect baseLayerEffect in LayerEffects) baseLayerEffect.InternalDisable(); @@ -830,11 +839,10 @@ public sealed class Layer : RenderProfileElement General.ShapeType.IsHidden = LayerBrush != null && !LayerBrush.SupportsTransformation; General.BlendMode.IsHidden = LayerBrush != null && !LayerBrush.SupportsTransformation; Transform.IsHidden = LayerBrush != null && !LayerBrush.SupportsTransformation; - if (LayerBrush != null) + if (LayerBrush != null && Enabled) { - if (!LayerBrush.Enabled) - LayerBrush.InternalEnable(); - LayerBrush?.Update(0); + LayerBrush.InternalEnable(); + LayerBrush.Update(0); } OnLayerBrushUpdated(); diff --git a/src/Artemis.Core/Models/Profile/Profile.cs b/src/Artemis.Core/Models/Profile/Profile.cs index 5534e686d..c511f9ec9 100644 --- a/src/Artemis.Core/Models/Profile/Profile.cs +++ b/src/Artemis.Core/Models/Profile/Profile.cs @@ -23,7 +23,9 @@ public sealed class Profile : ProfileElement { _scripts = new ObservableCollection(); _scriptConfigurations = new ObservableCollection(); - + + Opacity = 0d; + ShouldDisplay = true; Configuration = configuration; Profile = this; ProfileEntity = profileEntity; @@ -81,6 +83,10 @@ public sealed class Profile : ProfileElement internal List Exceptions { get; } + internal bool ShouldDisplay { get; set; } + + internal double Opacity { get; private set; } + /// public override void Update(double deltaTime) { @@ -97,6 +103,13 @@ public sealed class Profile : ProfileElement foreach (ProfileScript profileScript in Scripts) profileScript.OnProfileUpdated(deltaTime); + + const double OPACITY_PER_SECOND = 1; + + if (ShouldDisplay && Opacity < 1) + Opacity = Math.Clamp(Opacity + OPACITY_PER_SECOND * deltaTime, 0d, 1d); + if (!ShouldDisplay && Opacity > 0) + Opacity = Math.Clamp(Opacity - OPACITY_PER_SECOND * deltaTime, 0d, 1d); } } @@ -110,10 +123,26 @@ public sealed class Profile : ProfileElement foreach (ProfileScript profileScript in Scripts) profileScript.OnProfileRendering(canvas, canvas.LocalClipBounds); + + SKPaint? opacityPaint = null; + bool applyOpacityLayer = Configuration.FadeInAndOut && Opacity < 1; + + if (applyOpacityLayer) + { + opacityPaint = new SKPaint(); + opacityPaint.Color = new SKColor(0, 0, 0, (byte)(255d * Easings.CubicEaseInOut(Opacity))); + canvas.SaveLayer(opacityPaint); + } foreach (ProfileElement profileElement in Children) profileElement.Render(canvas, basePosition, editorFocus); + if (applyOpacityLayer) + { + canvas.Restore(); + opacityPaint?.Dispose(); + } + foreach (ProfileScript profileScript in Scripts) profileScript.OnProfileRendered(canvas, canvas.LocalClipBounds); diff --git a/src/Artemis.Core/Models/Profile/RenderProfileElement.cs b/src/Artemis.Core/Models/Profile/RenderProfileElement.cs index 02b75ce9b..9a87edc67 100644 --- a/src/Artemis.Core/Models/Profile/RenderProfileElement.cs +++ b/src/Artemis.Core/Models/Profile/RenderProfileElement.cs @@ -223,6 +223,14 @@ public abstract class RenderProfileElement : ProfileElement // Make sure the layer effect is tied to this element layerEffect.ProfileElement = this; + + // If the element is enabled, enable the effect before adding it + if (Enabled) + { + layerEffect.InternalEnable(); + layerEffect.Update(0); + } + _layerEffects.Add(layerEffect); // Update the order on the effects @@ -246,6 +254,9 @@ public abstract class RenderProfileElement : ProfileElement // Remove the effect from the layer _layerEffects.Remove(layerEffect); + // Disable the effect after removing it + layerEffect.InternalDisable(); + // Update the order on the remaining effects OrderEffects(); OnLayerEffectsUpdated(); diff --git a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs index 3390a2863..b349acfd1 100644 --- a/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs +++ b/src/Artemis.Core/Models/ProfileConfiguration/ProfileConfiguration.cs @@ -21,6 +21,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable private bool _isBeingEdited; private bool _isMissingModule; private bool _isSuspended; + private bool _fadeInAndOut; private Module? _module; private string _name; @@ -160,6 +161,15 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable internal set => SetAndNotify(ref _profile, value); } + /// + /// Gets or sets a boolean indicating whether this profile should fade in and out when enabling or disabling + /// + public bool FadeInAndOut + { + get => _fadeInAndOut; + set => SetAndNotify(ref _fadeInAndOut, value); + } + /// /// Gets or sets the module this profile uses /// @@ -272,6 +282,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable IsSuspended = Entity.IsSuspended; ActivationBehaviour = (ActivationBehaviour) Entity.ActivationBehaviour; HotkeyMode = (ProfileConfigurationHotkeyMode) Entity.HotkeyMode; + FadeInAndOut = Entity.FadeInAndOut; Order = Entity.Order; Icon.Load(); @@ -294,6 +305,7 @@ public class ProfileConfiguration : BreakableModel, IStorageModel, IDisposable Entity.ActivationBehaviour = (int) ActivationBehaviour; Entity.HotkeyMode = (int) HotkeyMode; Entity.ProfileCategoryId = Category.Entity.Id; + Entity.FadeInAndOut = FadeInAndOut; Entity.Order = Order; Icon.Save(); diff --git a/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs b/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs index 1baca1d95..025732df5 100644 --- a/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs +++ b/src/Artemis.Core/Models/Surface/Layout/ArtemisLedLayout.cs @@ -63,7 +63,7 @@ public class ArtemisLedLayout // Prefer a matching layout or else a default layout (that has no name) LayoutCustomLedDataLogicalLayout logicalLayout = LayoutCustomLedData.LogicalLayouts - .OrderBy(l => l.Name == artemisDevice.LogicalLayout) + .OrderByDescending(l => l.Name == artemisDevice.LogicalLayout) .ThenBy(l => l.Name == null) .First(); diff --git a/src/Artemis.Core/Plugins/PluginFeatureInfo.cs b/src/Artemis.Core/Plugins/PluginFeatureInfo.cs index 32d5775ec..b1dfbdc7b 100644 --- a/src/Artemis.Core/Plugins/PluginFeatureInfo.cs +++ b/src/Artemis.Core/Plugins/PluginFeatureInfo.cs @@ -18,7 +18,6 @@ namespace Artemis.Core; public class PluginFeatureInfo : CorePropertyChanged, IPrerequisitesSubject { private string? _description; - private string? _icon; private PluginFeature? _instance; private Exception? _loadException; private string _name = null!; 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.Core/Services/Input/InputProvider.cs b/src/Artemis.Core/Services/Input/InputProvider.cs index c51a25d63..8db4dec76 100644 --- a/src/Artemis.Core/Services/Input/InputProvider.cs +++ b/src/Artemis.Core/Services/Input/InputProvider.cs @@ -8,7 +8,10 @@ namespace Artemis.Core.Services; /// public abstract class InputProvider : IDisposable { - public InputProvider() + /// + /// Creates a new instance of the class. + /// + protected InputProvider() { ProviderName = GetType().FullName ?? throw new InvalidOperationException("Input provider must have a type with a name"); } diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index d5adc7d87..4b86f3d55 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; @@ -213,8 +213,17 @@ internal class ProfileService : IProfileService // Make sure the profile is active or inactive according to the parameters above if (shouldBeActive && profileConfiguration.Profile == null && profileConfiguration.BrokenState != "Failed to activate profile") profileConfiguration.TryOrBreak(() => ActivateProfile(profileConfiguration), "Failed to activate profile"); + if (shouldBeActive && profileConfiguration.Profile != null && !profileConfiguration.Profile.ShouldDisplay) + profileConfiguration.Profile.ShouldDisplay = true; else if (!shouldBeActive && profileConfiguration.Profile != null) - DeactivateProfile(profileConfiguration); + { + if (!profileConfiguration.FadeInAndOut) + DeactivateProfile(profileConfiguration); + else if (!profileConfiguration.Profile.ShouldDisplay && profileConfiguration.Profile.Opacity <= 0) + DeactivateProfile(profileConfiguration); + else if (profileConfiguration.Profile.Opacity > 0) + RequestDeactivation(profileConfiguration); + } profileConfiguration.Profile?.Update(deltaTime); } @@ -254,7 +263,8 @@ internal class ProfileService : IProfileService { ProfileConfiguration profileConfiguration = profileCategory.ProfileConfigurations[j]; // Ensure all criteria are met before rendering - if (!profileConfiguration.IsSuspended && !profileConfiguration.IsMissingModule && profileConfiguration.ActivationConditionMet) + bool fadingOut = profileConfiguration.Profile?.ShouldDisplay == false && profileConfiguration.Profile?.Opacity > 0; + if (!profileConfiguration.IsSuspended && !profileConfiguration.IsMissingModule && (profileConfiguration.ActivationConditionMet || fadingOut)) profileConfiguration.Profile?.Render(canvas, SKPointI.Empty, null); } catch (Exception e) @@ -316,7 +326,10 @@ internal class ProfileService : IProfileService public Profile ActivateProfile(ProfileConfiguration profileConfiguration) { if (profileConfiguration.Profile != null) + { + profileConfiguration.Profile.ShouldDisplay = true; return profileConfiguration.Profile; + } ProfileEntity profileEntity; try @@ -361,6 +374,16 @@ internal class ProfileService : IProfileService OnProfileDeactivated(new ProfileConfigurationEventArgs(profileConfiguration)); } + public void RequestDeactivation(ProfileConfiguration profileConfiguration) + { + if (profileConfiguration.IsBeingEdited) + throw new ArtemisCoreException("Cannot disable a profile that is being edited, that's rude"); + if (profileConfiguration.Profile == null) + return; + + profileConfiguration.Profile.ShouldDisplay = false; + } + public void DeleteProfile(ProfileConfiguration profileConfiguration) { DeactivateProfile(profileConfiguration); diff --git a/src/Artemis.Core/VisualScripting/Interfaces/INode.cs b/src/Artemis.Core/VisualScripting/Interfaces/INode.cs index dce2df631..3bc2521fb 100644 --- a/src/Artemis.Core/VisualScripting/Interfaces/INode.cs +++ b/src/Artemis.Core/VisualScripting/Interfaces/INode.cs @@ -97,7 +97,7 @@ public interface INode : INotifyPropertyChanged, IBreakableModel void TryEvaluate(); /// - /// Resets the node causing all pins to re-evaluate the next time is called + /// Resets the node causing all pins to re-evaluate the next time is called /// void Reset(); } \ No newline at end of file diff --git a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs index 377d78ce7..e91ddcc96 100644 --- a/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs +++ b/src/Artemis.Storage/Entities/Profile/ProfileConfigurationEntity.cs @@ -25,4 +25,6 @@ public class ProfileConfigurationEntity public Guid ProfileCategoryId { get; set; } public Guid ProfileId { get; set; } + + public bool FadeInAndOut { get; set; } } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj index 66fe3e2c7..09c2d5479 100644 --- a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj +++ b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs index c3fd4fc6e..1632f25a7 100644 --- a/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs +++ b/src/Artemis.UI.Shared/Controls/DeviceVisualizer.cs @@ -317,7 +317,7 @@ public class DeviceVisualizer : Control Dispatcher.UIThread.Post(InvalidateMeasure); } - catch (Exception e) + catch (Exception) { // ignored } diff --git a/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs b/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs index a1df2555c..029a6dc18 100644 --- a/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs +++ b/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs @@ -18,7 +18,7 @@ namespace Artemis.UI.Shared.DataModelVisualization.Shared; /// public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposable { - private const int MaxDepth = 4; + private const int MAX_DEPTH = 4; private ObservableCollection _children; private DataModel? _dataModel; private bool _isMatchingFilteredTypes; @@ -47,6 +47,9 @@ public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposa PropertyDescription = DataModelPath?.GetPropertyDescription() ?? DataModel?.DataModelDescription; } + /// + /// Copies the path of the data model to the clipboard. + /// public ReactiveCommand CopyPath { get; } /// @@ -337,7 +340,7 @@ public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposa { if (DataModel == null) throw new ArtemisSharedUIException("Cannot create a data model visualization child VM for a parent without a data model"); - if (depth > MaxDepth) + if (depth > MAX_DEPTH) return null; DataModelPath dataModelPath = new(DataModel, path); diff --git a/src/Artemis.UI.Shared/ReactiveCoreWindow.cs b/src/Artemis.UI.Shared/ReactiveCoreWindow.cs index 60fd8f353..295f4e6bb 100644 --- a/src/Artemis.UI.Shared/ReactiveCoreWindow.cs +++ b/src/Artemis.UI.Shared/ReactiveCoreWindow.cs @@ -19,6 +19,9 @@ namespace Artemis.UI.Shared; /// ViewModel type. public class ReactiveCoreWindow : CoreWindow, IViewFor where TViewModel : class { + /// + /// The ViewModel. + /// public static readonly StyledProperty ViewModelProperty = AvaloniaProperty .Register, TViewModel?>(nameof(ViewModel)); diff --git a/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateColorGradient.cs b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateColorGradient.cs index 5970b652b..58f91f4bd 100644 --- a/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateColorGradient.cs +++ b/src/Artemis.UI.Shared/Services/ProfileEditor/Commands/UpdateColorGradient.cs @@ -5,7 +5,7 @@ using Artemis.Core; namespace Artemis.UI.Shared.Services.ProfileEditor.Commands; /// -/// Represents a profile editor command that can be used to update a layer property of type . +/// Represents a profile editor command that can be used to update a color gradient. /// public class UpdateColorGradient : IProfileEditorCommand { diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index c568a677c..04e578c41 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -29,8 +29,8 @@ - - + + diff --git a/src/Artemis.UI/DefaultTypes/PropertyInput/FloatRangePropertyInputView.axaml b/src/Artemis.UI/DefaultTypes/PropertyInput/FloatRangePropertyInputView.axaml index 07d3faeab..ad358c205 100644 --- a/src/Artemis.UI/DefaultTypes/PropertyInput/FloatRangePropertyInputView.axaml +++ b/src/Artemis.UI/DefaultTypes/PropertyInput/FloatRangePropertyInputView.axaml @@ -13,8 +13,8 @@ Value="{CompiledBinding Start}" Prefix="{CompiledBinding Prefix}" Suffix="{CompiledBinding Affix}" - Minimum="{CompiledBinding End}" - Maximum="{CompiledBinding Max}" + Minimum="{CompiledBinding Min}" + Maximum="{CompiledBinding End}" LargeChange="{Binding LayerProperty.PropertyDescription.InputStepSize}" SimpleNumberFormat="F3" VerticalAlignment="Center" diff --git a/src/Artemis.UI/DefaultTypes/PropertyInput/IntRangePropertyInputView.axaml b/src/Artemis.UI/DefaultTypes/PropertyInput/IntRangePropertyInputView.axaml index 512006830..96ded4c7c 100644 --- a/src/Artemis.UI/DefaultTypes/PropertyInput/IntRangePropertyInputView.axaml +++ b/src/Artemis.UI/DefaultTypes/PropertyInput/IntRangePropertyInputView.axaml @@ -13,8 +13,8 @@ Value="{CompiledBinding Start}" Prefix="{CompiledBinding Prefix}" Suffix="{CompiledBinding Affix}" - Minimum="{CompiledBinding End}" - Maximum="{CompiledBinding Max}" + Minimum="{CompiledBinding Min}" + Maximum="{CompiledBinding End}" LargeChange="{Binding LayerProperty.PropertyDescription.InputStepSize}" SimpleNumberFormat="F3" VerticalAlignment="Center" diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index 10481f25c..b9175e9ed 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -92,7 +92,7 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi private void CurrentMainWindowOnClosing(object? sender, EventArgs e) { - WindowSizeSetting.Save(); + WindowSizeSetting?.Save(); _lifeTime.MainWindow = null; SidebarViewModel = null; Router.NavigateAndReset.Execute(new EmptyViewModel(this, "blank")).Subscribe(); diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml index fed01edac..a127cdc14 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditView.axaml @@ -127,6 +127,9 @@ + + Fade when enabling and disabling + diff --git a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs index ed55d14c3..eaaf00480 100644 --- a/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/Dialogs/ProfileConfigurationEditViewModel.cs @@ -30,6 +30,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase? _materialIcons; @@ -57,6 +58,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase RaiseAndSetIfChanged(ref _disableHotkey, value); } + public bool FadeInAndOut + { + get => _fadeInAndOut; + set => RaiseAndSetIfChanged(ref _fadeInAndOut, value); + } + public ObservableCollection Modules { get; } public ProfileModuleViewModel? SelectedModule @@ -131,7 +139,6 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase OpenConditionEditor { get; } public ReactiveCommand BrowseBitmapFile { get; } public ReactiveCommand Confirm { get; } - public ReactiveCommand Import { get; } public ReactiveCommand Delete { get; } public ReactiveCommand Cancel { get; } @@ -155,6 +162,7 @@ public class ProfileConfigurationEditViewModel : DialogViewModelBase 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..eea7329fe --- /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() + { + SKColor[] colors = Inputs.Values.ToArray(); + + if (colors.Length == 0) + { + Output.Value = null; + return; + } + + ColorSorter.Sort(colors, SKColors.Black); + + ColorGradient gradient = new(); + for (int i = 0; i < colors.Length; i++) + { + gradient.Add(new ColorGradientStop(colors[i], (float)i / (colors.Length - 1))); + } + + Output.Value = gradient; + } + } +} diff --git a/src/Artemis.VisualScripting/Nodes/External/LayerPropertyNode.cs b/src/Artemis.VisualScripting/Nodes/External/LayerPropertyNode.cs index 72fff998a..5a2199c05 100644 --- a/src/Artemis.VisualScripting/Nodes/External/LayerPropertyNode.cs +++ b/src/Artemis.VisualScripting/Nodes/External/LayerPropertyNode.cs @@ -117,7 +117,7 @@ public class LayerPropertyNode : Node - private void CreateOrAddOutputPin(Type valueType, string displayName) + private new void CreateOrAddOutputPin(Type valueType, string displayName) { // Grab the first pin from the bucket that isn't on the node yet OutputPin? pin = _pinBucket.FirstOrDefault(p => !Pins.Contains(p)); diff --git a/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs index 90f4f83eb..3631e5934 100644 --- a/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs +++ b/src/Artemis.VisualScripting/Nodes/List/ListOperatorPredicateNode.cs @@ -9,7 +9,7 @@ namespace Artemis.VisualScripting.Nodes.List; public class ListOperatorPredicateNode : Node, IDisposable { private readonly object _scriptLock = new(); - private ListOperatorPredicateStartNode _startNode; + private readonly ListOperatorPredicateStartNode _startNode; public ListOperatorPredicateNode() { @@ -65,7 +65,7 @@ public class ListOperatorPredicateNode : Node