From 9fe1d3c619545b2d2378f612f8e3a74184e0f8ad Mon Sep 17 00:00:00 2001 From: Christopher Whitley <103014489+AristurtleDev@users.noreply.github.com> Date: Thu, 22 May 2025 12:41:38 -0400 Subject: [PATCH] Refactor `HslColor` and Color Utilities (#989) * Refactor HslColor: Improve implementation, documentation, and API - Added explicit memory layout to prepare for particle update. - Restructured fields as private with readonly property accessors. Struct is meant to be immutable, but the CopyTo method makes it mutable, so this is a trade off - Added nullability annotations and explicit argument validations. - Added Deconstruct method to match MonoGame terminology and marked Destruct as obsolete. - Marked the implicit string conversion as obsolete. Users should use the Parse method as it's more explicity as to what's happening. - Restructure the FromRgb method for better readability and future maintenance. * Deprecate ColorExtensions.ToHsl method in favor of HslColor.FromRgb The ColorExtensions.ToHsl and HslColor.FromRgb methods provide the same functionality but with different implementations. HslColor.FromRgb is more accurate and handles edge cases correctly, so it should be preferred. * Mark ColorExtensions.FromHex as obsolete This just calls ColorHelper.FromHex, which is where the call site should be anyway as this isn't an extension method of Color. * Move FromAbgr to ColorHelper. This is not an extension method. Mark other as obsolete. * Add a const value for machine epsilon * Move ToRgb as a static method of HslColor struct - ColorExtensions.ToRgb has been marked obsolete * Mark ColorExtensions.ToRgb as obsolete * Move ToHex from ColorHelper to ColorExtensions and make it an extension method. Marked ColorHelper.ToHex as obsolete * Mark ColorHelper.FromHsl as obsolete * Clean up namespaces * Add FromHex overload that uses ReadOnlySpan which increases performance and reduces memory allocations * Update documentation of FromAbgr since it was moved to ColorHelper * Fix tag in documentation * Per coding guidelines, fields at top of class declaration * Per coding guidelines, prefix static fields with `s_` * Document the FromName method * Adhere to coding guidelines - If statement, even single line, uses brackets - Inline `Color` definition * Documentat ColorHelper class * Remove extra whitespace line * Clean up unused namespaces * Correct test case * Move to root namespace * Rename to HslColorTests * Add tests for ColorExtensions.ToHex * Use facts not theorys * Add tests for ColorHelper.FromHex --- source/MonoGame.Extended/ColorExtensions.cs | 23 +- source/MonoGame.Extended/ColorHelper.cs | 210 +++++++-- source/MonoGame.Extended/HslColor.cs | 432 ++++++++++++------ source/MonoGame.Extended/MathExtended.cs | 6 + .../Particles/ParticleReleaseParameters.cs | 2 +- .../Json/Utf8JsonReaderExtensions.cs | 2 +- .../ColorExtensionsTests.cs | 69 +++ .../ColorHelperTests.cs | 104 +++++ .../ColourTests.cs => HslColorTests.cs} | 10 +- 9 files changed, 664 insertions(+), 194 deletions(-) create mode 100644 tests/MonoGame.Extended.Tests/ColorExtensionsTests.cs create mode 100644 tests/MonoGame.Extended.Tests/ColorHelperTests.cs rename tests/MonoGame.Extended.Tests/{Particles/ColourTests.cs => HslColorTests.cs} (93%) diff --git a/source/MonoGame.Extended/ColorExtensions.cs b/source/MonoGame.Extended/ColorExtensions.cs index 78884407..446029b5 100644 --- a/source/MonoGame.Extended/ColorExtensions.cs +++ b/source/MonoGame.Extended/ColorExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using Microsoft.Xna.Framework; namespace MonoGame.Extended @@ -9,11 +8,28 @@ namespace MonoGame.Extended /// public static class ColorExtensions { + /// + /// Converts a to its hexadecimal string representation in RGBA format. + /// + /// The to convert. + /// A hexadecimal string representation of the color in the format #RRGGBBAA. + public static string ToHex(this Color color) + { + string rx = $"{color.R:x2}"; + string gx = $"{color.G:x2}"; + string bx = $"{color.B:x2}"; + string ax = $"{color.A:x2}"; + + return $"#{rx}{gx}{bx}{ax}"; + } + + [Obsolete("Use ColorHelper.FromHex instead. This will be removed in the next major SemVer release.")] public static Color FromHex(string value) { return ColorHelper.FromHex(value); } + [Obsolete("Use HslColor.ToRgb instead. This will be removed in the next major SemVer update")] public static Color ToRgb(this HslColor c) { var h = c.H; @@ -33,6 +49,7 @@ namespace MonoGame.Extended ComponentFromHue(min, max, h - 1f/3f)); } + [Obsolete("This will be removed in the next major SemVer release.")] private static float ComponentFromHue(float m1, float m2, float h) { h = (h + 1f)%1f; @@ -44,7 +61,8 @@ namespace MonoGame.Extended return m1 + (m2 - m1)*(2f/3f - h)*6f; return m1; } - + + [Obsolete("Use HslColor.FromRgb instead. This method will be removed in the next SemVer release")] public static HslColor ToHsl(this Color c) { var r = c.R/255f; @@ -89,6 +107,7 @@ namespace MonoGame.Extended /// /// The packed color value in ABGR format /// The value created + [Obsolete("Use ColorHelper.FromAbgr instead. This will be removed in the next major SemVer release.")] public static Color FromAbgr(uint abgr) { uint rgba = (abgr & 0x000000FF) << 24 | // Alpha diff --git a/source/MonoGame.Extended/ColorHelper.cs b/source/MonoGame.Extended/ColorHelper.cs index b6e8ecd6..f2e3ae35 100644 --- a/source/MonoGame.Extended/ColorHelper.cs +++ b/source/MonoGame.Extended/ColorHelper.cs @@ -1,53 +1,99 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Reflection; using Microsoft.Xna.Framework; namespace MonoGame.Extended { + /// + /// Provides utility methods for working with values. + /// public static class ColorHelper { - //http://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion - public static Color FromHsl(float hue, float saturation, float lightness) - { - var hsl = new Vector4(hue, saturation, lightness, 1); - var color = new Vector4(0, 0, 0, hsl.W); - - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (hsl.Y == 0.0f) - color.X = color.Y = color.Z = hsl.Z; - else - { - var q = hsl.Z < 0.5f ? hsl.Z*(1.0f + hsl.Y) : hsl.Z + hsl.Y - hsl.Z*hsl.Y; - var p = 2.0f*hsl.Z - q; - - color.X = HueToRgb(p, q, hsl.X + 1.0f/3.0f); - color.Y = HueToRgb(p, q, hsl.X); - color.Z = HueToRgb(p, q, hsl.X - 1.0f/3.0f); - } - - return new Color(color); - } - - private static float HueToRgb(float p, float q, float t) - { - if (t < 0.0f) t += 1.0f; - if (t > 1.0f) t -= 1.0f; - if (t < 1.0f/6.0f) return p + (q - p)*6.0f*t; - if (t < 1.0f/2.0f) return q; - if (t < 2.0f/3.0f) return p + (q - p)*(2.0f/3.0f - t)*6.0f; - return p; - } + private static readonly Dictionary s_colorsByName = typeof(Color) + .GetRuntimeProperties() + .Where(p => p.PropertyType == typeof(Color)) + .ToDictionary(p => p.Name, p => (Color)p.GetValue(null), StringComparer.OrdinalIgnoreCase); + /// + /// Converts a hexadecimal color string to a value. + /// + /// + /// The hexadecimal color string to convert. Supports multiple formats: + /// + /// 3 characters: RGB shorthand (e.g., "F0A" becomes "FF00AA") + /// 4 characters: RGBA shorthand (e.g., "F0A8" becomes "FF00AA88") + /// 6 characters: Full RGB format (e.g., "FF00AA") + /// 8 characters: Full RGBA format (e.g., "FF00AA88") + /// + /// Optional '#' prefix is automatically handled and removed. + /// + /// + /// A value representing the parsed hexadecimal color, or + /// if the input is or an empty string. + /// + /// + /// The length of is not 3, 4, 6, or 8 (excluding a '#' prefix) + /// + /// + /// contains invalid hexadecimal characters. + /// + /// + /// represents a value too large for a 32-bit unsigned integer. + /// public static Color FromHex(string value) { if (string.IsNullOrEmpty(value)) + { return Color.Transparent; + } + + return FromHex(value.AsSpan()); + } + + /// + /// Converts a hexadecimal color span to a value. + /// + /// + /// This overload provides better performance than the string version by avoiding string allocations + /// when removing the '#' prefix and during parsing operations. Particularly beneficial when + /// processing large numbers of hex colors or when called frequently. + /// + /// + /// A read-only span of characters representing the hexadecimal color. Supports multiple formats: + /// + /// 3 characters: RGB shorthand (e.g., "F0A" becomes "FF00AA") + /// 4 characters: RGBA shorthand (e.g., "F0A8" becomes "FF00AA88") + /// 6 characters: Full RGB format (e.g., "FF00AA") + /// 8 characters: Full RGBA format (e.g., "FF00AA88") + /// + /// Optional '#' prefix is automatically handled and removed. + /// + /// + /// A value representing the parsed hexadecimal color, or + /// if the input is is empty. + /// + /// + /// The length of is not 3, 4, 6, or 8 (excluding a '#' prefix) + /// + /// + /// contains invalid hexadecimal characters. + /// + /// + /// represents a value too large for a 32-bit unsigned integer. + /// + public static Color FromHex(ReadOnlySpan value) + { + if (value.IsEmpty) + { + return Color.Transparent; + } if (value[0] == '#') - value = value.Substring(1); + { + value = value.Slice(1); + } int r, g, b, a; uint hexInt = uint.Parse(value, System.Globalization.NumberStyles.HexNumber); @@ -88,6 +134,90 @@ namespace MonoGame.Extended return new Color(r, g, b, a); } + /// + /// Creates a value from the specified name of a predefined color. + /// Gets a value from a p + /// + /// The name of the predefined color. + /// + /// The value this method creates. + /// + /// is not a valid color. + public static Color FromName(string name) + { + if (s_colorsByName.TryGetValue(name, out Color color)) + { + return color; + } + + throw new InvalidOperationException($"{name} is not a valid color"); + } + + /// + /// Returns a new value based on a packed value in the ABGR format. + /// + /// + /// This is useful for when you have HTML hex style values such as #123456 and want to use it in hex format for + /// the parameter. Since Color's standard format is RGBA, you would have to do new Color(0xFF563212) since R + /// is the LSB. With this method, you can write it the same way it is written in HTML hex by doing + /// ColorHelper.FromAbgr(0x123456FF); + /// + /// The packed color value in ABGR format + /// The value created + public static Color FromAbgr(uint abgr) + { + uint rgba = (abgr & 0x000000FF) << 24 | // Alpha + (abgr & 0x0000FF00) << 8 | // Blue + (abgr & 0x00FF0000) >> 8 | // Green + (abgr & 0xFF000000) >> 24; // Red + + Color result; + +#if FNA + result = default; + result.PackedValue = rgba; +#else + result = new Color(rgba); +#endif + + return result; + } + + [Obsolete("Use HslColor.ToRgb instead. This will be removed in the next major SemVer release.")] + //http://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion + public static Color FromHsl(float hue, float saturation, float lightness) + { + var hsl = new Vector4(hue, saturation, lightness, 1); + var color = new Vector4(0, 0, 0, hsl.W); + + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (hsl.Y == 0.0f) + color.X = color.Y = color.Z = hsl.Z; + else + { + var q = hsl.Z < 0.5f ? hsl.Z * (1.0f + hsl.Y) : hsl.Z + hsl.Y - hsl.Z * hsl.Y; + var p = 2.0f * hsl.Z - q; + + color.X = HueToRgb(p, q, hsl.X + 1.0f / 3.0f); + color.Y = HueToRgb(p, q, hsl.X); + color.Z = HueToRgb(p, q, hsl.X - 1.0f / 3.0f); + } + + return new Color(color); + } + + [Obsolete("This will be removed in the next major SemVer release")] + private static float HueToRgb(float p, float q, float t) + { + if (t < 0.0f) t += 1.0f; + if (t > 1.0f) t -= 1.0f; + if (t < 1.0f / 6.0f) return p + (q - p) * 6.0f * t; + if (t < 1.0f / 2.0f) return q; + if (t < 2.0f / 3.0f) return p + (q - p) * (2.0f / 3.0f - t) * 6.0f; + return p; + } + + [Obsolete("Use ColorExtensions.ToHex instead. This will be removed in the next major SemVer release.")] public static string ToHex(Color color) { var rx = $"{color.R:x2}"; @@ -96,20 +226,6 @@ namespace MonoGame.Extended var ax = $"{color.A:x2}"; return $"#{rx}{gx}{bx}{ax}"; } - - private static readonly Dictionary _colorsByName = typeof(Color) - .GetRuntimeProperties() - .Where(p => p.PropertyType == typeof(Color)) - .ToDictionary(p => p.Name, p => (Color) p.GetValue(null), StringComparer.OrdinalIgnoreCase); - public static Color FromName(string name) - { - Color color; - - if(_colorsByName.TryGetValue(name, out color)) - return color; - - throw new InvalidOperationException($"{name} is not a valid color"); - } } } diff --git a/source/MonoGame.Extended/HslColor.cs b/source/MonoGame.Extended/HslColor.cs index 628073d2..e067d758 100644 --- a/source/MonoGame.Extended/HslColor.cs +++ b/source/MonoGame.Extended/HslColor.cs @@ -1,63 +1,104 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.InteropServices; using Microsoft.Xna.Framework; namespace MonoGame.Extended { /// - /// An immutable data structure representing a 24bit color composed of separate hue, saturation and lightness channels. + /// Represents a color in the HSL (Hue, Saturation, Lightness) color space. /// - //[Serializable] + /// + /// + /// Hue (H) represents the color, ranging from 0 to 360 degrees on the color wheel. + /// Saturation (S) represents the intensity of the color, ranging from 0.0 (gray) to 1.0 (full color). + /// Lightness (L) represents the brightness, ranging from 0.0 (black) to 1.0 (white). + /// + /// + [StructLayout(LayoutKind.Sequential)] public struct HslColor : IEquatable, IComparable { - /// - /// Gets the value of the hue channel in degrees. - /// - public readonly float H; + + private float _h; + private float _s; + private float _l; /// - /// Gets the value of the saturation channel. + /// The hue component value (in degrees) of the color ranging from 0.0 to 360.0. /// - public readonly float S; + public readonly float H => _h; /// - /// Gets the value of the lightness channel. + /// The saturation component value of the color ranging from 0.0 to 1.0. /// - public readonly float L; + public readonly float S => _s; + /// + /// The lightness component value of the color ranging from 0.0 to 1.0. + /// + public readonly float L => _l; + + /// + /// Normalizes a hue value to be within the range [0, 360). + /// Handles negative values by wrapping them around. + /// + /// The hue value to normalize. + /// The normalized hue value. private static float NormalizeHue(float h) { - if (h < 0) return h + 360*((int) (h/360) + 1); - return h%360; + if (h < 0) return h + 360 * ((int)(h / 360) + 1); + return h % 360; } /// - /// Initializes a new instance of the structure. + /// Initializes a new instance of the struct with the specified hue, saturation, + /// and lightness component values. /// - /// The value of the hue channel. - /// The value of the saturation channel. - /// The value of the lightness channel. - public HslColor(float h, float s, float l) : this() + /// The hue component value (in degrees) from 0.0 to 360.0. + /// The saturation component value from 0.0 to 1.0. + /// The lightness component value from 0.0 to 1.0. + public HslColor(float h, float s, float l) { - // normalize the hue - H = NormalizeHue(h); - S = MathHelper.Clamp(s, 0f, 1f); - L = MathHelper.Clamp(l, 0f, 1f); + _h = Math.Clamp(h, 0.0f, 360.0f); + _s = Math.Clamp(s, 0.0f, 1.0f); + _l = Math.Clamp(l, 0.0f, 1.0f); } /// - /// Copies the individual channels of the color to the specified memory location. + /// Copies the values of this struct to a new instance. /// - /// The memory location to copy the axis to. - public void CopyTo(out HslColor destination) + /// When this method returns, contains a copy of this . + [Obsolete("Use CopyToRef instead. This will be removed in the next major SemVer release.")] + public readonly void CopyTo(out HslColor destination) { destination = new HslColor(H, S, L); } /// - /// Destructures the color, exposing the individual channels. + /// Copies the value of this struct to an existing destination. /// - public void Destructure(out float h, out float s, out float l) + /// A reference to the destination struct where values will be copied to. + /// + /// This method directly modifies the internal components of the destination struct for improved performance. + /// Unlike typical operations on immutable structs, this method does not create a new instance but alters + /// the existing one in-place. It should be used only in scenarios where performance is critical. + /// + public readonly void CopyToRef(ref HslColor destination) + { + destination._h = _h; + destination._s = _s; + destination._l = _l; + } + + /// + /// Deconstructs this into its hue, saturation, and lightness component values. + /// + /// When this method returns, contains the hue component value of this . + /// When this method returns, contains the saturation component value of this . + /// When this method returns, contains the lightness component value of this . + [Obsolete("Will be removed in next major SemVer release. Use Deconstruct instead.")] + public readonly void Destructure(out float h, out float s, out float l) { h = H; s = S; @@ -65,113 +106,119 @@ namespace MonoGame.Extended } /// - /// Exposes the individual channels of the color to the specified matching function. + /// Deconstructs this into its hue, saturation, and lightness component values. /// - /// The function which matches the individual channels of the color. - /// - /// Thrown if the value passed to the parameter is null. - /// - public void Match(Action callback) + /// When this method returns, contains the hue component value of this . + /// When this method returns, contains the saturation component value of this . + /// When this method returns, contains the lightness component value of this . + public readonly void Deconstruct(out float h, out float s, out float l) { - if (callback == null) - throw new ArgumentNullException(nameof(callback)); + h = H; + s = S; + l = L; + } + /// + /// Executes a callback with the components of this . + /// + /// The callback to execute. + /// + /// Thrown when is . + /// + public readonly void Match(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); callback(H, S, L); } /// - /// Exposes the individual channels of the color to the specified mapping function and returns the - /// result; + /// Maps the components of this to a new value using the specified mapping function. /// - /// The type being mapped to. - /// - /// A function which maps the color channels to an instance of . - /// + /// The type of the result of the mapping function. + /// The mapping function to apply to the components of this . /// - /// The result of the function when passed the individual X and Y components. + /// The result of applying the mapping function to the components of this . /// - /// - /// Thrown if the value passed to the parameter is null. + /// + /// Thrown when is . /// - public T Map(Func map) + public readonly T Map(Func map) { - if (map == null) - throw new ArgumentNullException(nameof(map)); - + ArgumentNullException.ThrowIfNull(map); return map(H, S, L); } - public static HslColor operator +(HslColor a, HslColor b) - { - return new HslColor(a.H + b.H, a.S + b.S, a.L + b.L); - } - + /// + /// Implicitly converts a string to an . + /// + /// The string to convert. + /// The represented by the string. + [Obsolete("Use HslColor.Parse instead to make string parsing explicit and improve code readability. This method will be removed in the next major SemVer release.")] public static implicit operator HslColor(string value) { return Parse(value); } - public int CompareTo(HslColor other) + /// + /// + /// This comparison uses a weighted approach that establishes a hierarchy of importance among the HSL components: + /// + /// + /// Hue is the primary sorting factor (weighted by 100) + /// Saturation is the secondary sorting factor (weighted by 10) + /// Lightness is the tertiary sorting factor (weight of 1) + /// + /// + /// This weighting ensures that differences in hue will dominate the comparison result, followed by + /// saturation, and then lightness, creating a sorting order that aligns with the perceptual importance + /// of each component in the HSL color space. + /// + public readonly int CompareTo(HslColor other) { - // ReSharper disable ImpureMethodCallOnReadonlyValueField - return H.CompareTo(other.H)*100 + S.CompareTo(other.S)*10 + L.CompareTo(L); - // ReSharper restore ImpureMethodCallOnReadonlyValueField + + return _h.CompareTo(other._h) * 100 + + _s.CompareTo(other._s) * 10 + + _l.CompareTo(other._l); } - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public override bool Equals(object obj) + /// + public override bool Equals([NotNullWhen(true)] object obj) { - if (obj is HslColor) - return Equals((HslColor) obj); - - return base.Equals(obj); + return obj is HslColor other && Equals(other); } - /// - /// Determines whether the specified is equal to this instance. - /// - /// The to compare with this instance. - /// - /// true if the specified is equal to this instance; otherwise, false. - /// - public bool Equals(HslColor value) + /// + public readonly bool Equals(HslColor value) { - // ReSharper disable ImpureMethodCallOnReadonlyValueField - return H.Equals(value.H) && S.Equals(value.S) && L.Equals(value.L); - // ReSharper restore ImpureMethodCallOnReadonlyValueField + return H.Equals(value.H) && + L.Equals(value.L) && + S.Equals(value.S); } - /// - /// Returns a hash code for this instance. - /// - /// - /// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table. - /// - public override int GetHashCode() + /// + public override readonly int GetHashCode() { return H.GetHashCode() ^ S.GetHashCode() ^ L.GetHashCode(); } - /// - /// Returns a that represents this instance. - /// - /// - /// A that represents this instance. - /// - public override string ToString() + /// + public override readonly string ToString() { return string.Format(CultureInfo.InvariantCulture, "H:{0:N1}° S:{1:N1} L:{2:N1}", - H, 100*S, 100*L); + H, 100 * S, 100 * L); } + /// + /// Parses a string into an . + /// + /// The string to parse. + /// The represented by the string. + /// + /// The input string should be in the format "hue,saturation,lightness", where hue is in degrees + /// (optionally followed by the '°' symbol), and saturation and lightness are decimal values. + /// public static HslColor Parse(string s) { var hsl = s.Split(','); @@ -182,13 +229,14 @@ namespace MonoGame.Extended return new HslColor(hue, sat, lig); } + /// - /// Implements the operator ==. + /// Determines whether two values are equal. /// - /// The lvalue. - /// The rvalue. + /// The first to compare. + /// The second to compare. /// - /// true if the lvalue is equal to the rvalue; otherwise, false. + /// if the values are equal; otherwise, . /// public static bool operator ==(HslColor x, HslColor y) { @@ -196,74 +244,182 @@ namespace MonoGame.Extended } /// - /// Implements the operator !=. + /// Determines whether two values are not equal. /// - /// The lvalue. - /// The rvalue. + /// The first to compare. + /// The second to compare. /// - /// true if the lvalue is not equal to the rvalue; otherwise, false. + /// if the values are not equal; otherwise, . /// public static bool operator !=(HslColor x, HslColor y) { return !x.Equals(y); } + /// + /// Adds two values together. + /// + /// The first to add. + /// The second to add. + /// + /// A new value where the hue, saturation, and light component values are the sum of + /// the components of the two input colors. + /// + public static HslColor operator +(HslColor a, HslColor b) + { + return new HslColor( + a._h + b._h, + a._s + b._s, + a._l + b._l + ); + } + + /// + /// Subtracts one value from another. + /// + /// The to subtract from. + /// The to subtract. + /// + /// A new value where the hue, saturation, and light component values are the difference + /// of the components of the two input colors. + /// public static HslColor operator -(HslColor a, HslColor b) { - return new HslColor(a.H - b.H, a.S - b.S, a.L - b.L); + return new HslColor( + a._h - b._h, + a._s - b._s, + a._l - b._l + ); } + /// + /// Linearly interpolates between two values. + /// + /// The first . + /// The second . + /// The interpolation factor. A value of 0 returns , a value of 1 returns . + /// The interpolated . public static HslColor Lerp(HslColor c1, HslColor c2, float t) { - // loop around if c2.H < c1.H + // loop around if c2.H < c1.HF var h2 = c2.H >= c1.H ? c2.H : c2.H + 360; return new HslColor( - c1.H + t*(h2 - c1.H), - c1.S + t*(c2.S - c1.S), - c1.L + t*(c2.L - c1.L)); + c1.H + t * (h2 - c1.H), + c1.S + t * (c2.S - c1.S), + c1.L + t * (c2.L - c1.L)); } + /// + /// Convers a value to a value. + /// + /// The value to convert. + /// + /// A value representing the RGB equivalent of the specified + /// value. + /// + public static Color ToRgb(HslColor hsl) + { + float h = hsl._h; + float s = hsl._s; + float l = hsl._l; + + if (s < MathExtended.MachineEpsilon) + { + return new Color(l, l, l); + } + + if (l <= MathExtended.MachineEpsilon) + { + return Color.Black; + } + + h /= 360.0f; + + float max = l < 0.5f ? + l * (1 + s) : + l + s - l * s; + + float min = 2.0f * l - max; + + float r = RgbFromHue(min, max, h + 0.3333333f); + float g = RgbFromHue(min, max, h); + float b = RgbFromHue(min, max, h - 0.3333333f); + + return new Color(r, g, b); + } + + private static float RgbFromHue(float min, float max, float hue) + { + hue = (hue + 1.0f) % 1.0f; + + if (hue * 6.0f < 1.0f) + { + return min + (max - min) * 6.0f * hue; + } + + if (hue * 2.0f < 1.0f) + { + return max; + } + + if (hue * 3.0f < 2.0f) + { + return min + (max - min) * (2.0f / 3.0f - hue) * 6.0f; + } + + return min; + } + + /// + /// Converts an RGB color to an HSL color. + /// + /// The RGB color to convert. + /// The equivalent HSL color. public static HslColor FromRgb(Color color) { - // derived from http://www.geekymonkey.com/Programming/CSharp/RGB2HSL_HSL2RGB.htm - var r = color.R / 255f; - var g = color.G / 255f; - var b = color.B / 255f; - var h = 0f; // default to black - var s = 0f; - var l = 0f; - var v = Math.Max(r, g); - v = Math.Max(v, b); + float r = color.R / 255f; + float g = color.G / 255f; + float b = color.B / 255f; - var m = Math.Min(r, g); - m = Math.Min(m, b); - l = (m + v) / 2.0f; + float max = MathF.Max(r, MathF.Max(g, b)); + float min = MathF.Min(r, MathF.Min(g, b)); + float delta = max - min; - if (l <= 0.0) + float h = 0.0f; + float s = 0.0f; + float l = (max + min) * 0.5f; + + if (MathF.Abs(delta) < float.Epsilon) + { return new HslColor(h, s, l); + } - var vm = v - m; - s = vm; + if (MathF.Abs(r - max) < float.Epsilon) + { + h = (g - b) / delta; + } + else if (MathF.Abs(g - max) < float.Epsilon) + { + h = (b - r) / delta + 2.0f; + } + else if (MathF.Abs(b - max) < float.Epsilon) + { + h = (r - g) / delta + 4.0f; + } - if (s > 0.0) - s /= l <= 0.5f ? v + m : 2.0f - v - m; - else - return new HslColor(h, s, l); + h *= 60.0f; - var r2 = (v - r) / vm; - var g2 = (v - g) / vm; - var b2 = (v - b) / vm; - - if (Math.Abs(r - v) < float.Epsilon) - h = Math.Abs(g - m) < float.Epsilon ? 5.0f + b2 : 1.0f - g2; - else if (Math.Abs(g - v) < float.Epsilon) - h = Math.Abs(b - m) < float.Epsilon ? 1.0f + r2 : 3.0f - b2; - else - h = Math.Abs(r - m) < float.Epsilon ? 3.0f + g2 : 5.0f - r2; - - h *= 60; h = NormalizeHue(h); + if (l <= 0.5f) + { + s = delta / (max + min); + } + else + { + s = delta / (2.0f - max - min); + } + return new HslColor(h, s, l); } } diff --git a/source/MonoGame.Extended/MathExtended.cs b/source/MonoGame.Extended/MathExtended.cs index 26f3d08b..0c718538 100644 --- a/source/MonoGame.Extended/MathExtended.cs +++ b/source/MonoGame.Extended/MathExtended.cs @@ -4,6 +4,12 @@ namespace MonoGame.Extended; public static class MathExtended { + /// + /// Represents the smallest positive value that can be added to 1.0 to produce a distinguishable result. + /// This value is approximately 1.19209290e-7 and is useful for floating-point comparisons. + /// + public const float MachineEpsilon = 1.19209290e-7f; + /// /// Calculates a new with the component-wise minimum values from two given /// values. diff --git a/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs b/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs index e19fbc26..195cbdd3 100644 --- a/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs +++ b/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs @@ -9,7 +9,7 @@ namespace MonoGame.Extended.Particles { Quantity = 1; Speed = new Range(-1f, 1f); - Color = new Range(Microsoft.Xna.Framework.Color.White.ToHsl(), Microsoft.Xna.Framework.Color.White.ToHsl()); + Color = new Range(HslColor.FromRgb(Microsoft.Xna.Framework.Color.Wheat), HslColor.FromRgb(Microsoft.Xna.Framework.Color.White)); Opacity = new Range(0f, 1f); Scale = new Range(1f, 1f); Rotation = new Range(-MathHelper.Pi, MathHelper.Pi); diff --git a/source/MonoGame.Extended/Serialization/Json/Utf8JsonReaderExtensions.cs b/source/MonoGame.Extended/Serialization/Json/Utf8JsonReaderExtensions.cs index 18f939a9..a580baa4 100644 --- a/source/MonoGame.Extended/Serialization/Json/Utf8JsonReaderExtensions.cs +++ b/source/MonoGame.Extended/Serialization/Json/Utf8JsonReaderExtensions.cs @@ -14,7 +14,7 @@ public static class Utf8JsonReaderExtensions { {typeof(int), s => int.Parse(s, CultureInfo.InvariantCulture.NumberFormat)}, {typeof(float), s => float.Parse(s, CultureInfo.InvariantCulture.NumberFormat)}, - {typeof(HslColor), s => ColorExtensions.FromHex(s).ToHsl() } + {typeof(HslColor), s => HslColor.FromRgb(ColorExtensions.FromHex(s))} }; /// diff --git a/tests/MonoGame.Extended.Tests/ColorExtensionsTests.cs b/tests/MonoGame.Extended.Tests/ColorExtensionsTests.cs new file mode 100644 index 00000000..c5e0058a --- /dev/null +++ b/tests/MonoGame.Extended.Tests/ColorExtensionsTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Tests; + +public class ColorExtensionsTests +{ + public class ToHex + { + [Fact] + public void Color_Transparent_ReturnsCorrectHex() + { + Color transparent = new Color(0, 0, 0, 0); + string hex = transparent.ToHex(); + Assert.Equal("#00000000", hex); + } + + [Fact] + public void Color_White_ReturnsCorrectHex() + { + Color white = new Color(255, 255, 255); + string hex = white.ToHex(); + Assert.Equal("#ffffffff", hex); + } + + [Fact] + public void Color_Black_ReturnsCorrectHex() + { + Color black = new Color(0, 0, 0); + string hex = black.ToHex(); + Assert.Equal("#000000ff", hex); + } + + [Fact] + public void Color_Red_ReturnsCorrectHex() + { + Color red = new Color(255, 0, 0); + string hex = red.ToHex(); + Assert.Equal("#ff0000ff", hex); + } + + [Fact] + public void Color_Green_ReturnsCorrectHex() + { + Color green = new Color(0, 255, 0); + string hex = green.ToHex(); + Assert.Equal("#00ff00ff", hex); + } + + [Fact] + public void Color_Blue_ReturnsCorrectHex() + { + Color blue = new Color(0, 0, 255); + string hex = blue.ToHex(); + Assert.Equal("#0000ffff", hex); + } + + [Fact] + public void Color_ReturnsCorrectHex() + { + Color color = new Color(170, 187, 204, 128); + string hex = color.ToHex(); + Assert.Equal("#aabbcc80", hex); + } + } +} diff --git a/tests/MonoGame.Extended.Tests/ColorHelperTests.cs b/tests/MonoGame.Extended.Tests/ColorHelperTests.cs new file mode 100644 index 00000000..e6d48cf2 --- /dev/null +++ b/tests/MonoGame.Extended.Tests/ColorHelperTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Tests; + +public class ColorHelperTests +{ + public class FromHex + { + [Theory] + [InlineData("#00000000", 0, 0, 0, 0)] + [InlineData("#FFFFFFFF", 255, 255, 255, 255)] + [InlineData("#AABBCCFF", 170, 187, 204, 255)] + public void LengthEight_WithPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("#000000", 0, 0, 0, 255)] + [InlineData("#FFFFFF", 255, 255, 255, 255)] + [InlineData("#AABBCC", 170, 187, 204, 255)] + public void LengthSix_WithPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("#000", 0, 0, 0, 255)] + [InlineData("#FFF", 255, 255, 255, 255)] + [InlineData("#ABC", 170, 187, 204, 255)] + public void LengthThree_WithPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("00000000", 0, 0, 0, 0)] + [InlineData("FFFFFFFF", 255, 255, 255, 255)] + [InlineData("AABBCCFF", 170, 187, 204, 255)] + public void LengthEight_WithoutPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("000000", 0, 0, 0, 255)] + [InlineData("FFFFFF", 255, 255, 255, 255)] + [InlineData("AABBCC", 170, 187, 204, 255)] + public void LengthSix_WithoutPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("000", 0, 0, 0, 255)] + [InlineData("FFF", 255, 255, 255, 255)] + [InlineData("ABC", 170, 187, 204, 255)] + public void LengthThree_WithoutPrefix_ReturnsCorrectColor(string hex, int r, int g, int b, int a) + { + Color expected = new Color(r, g, b, a); + Color actual = ColorHelper.FromHex(hex); + Assert.Equal(expected, actual); + } + + [Fact] + public void Empty_ReturnsTransparent() + { + Color expected = Color.Transparent; + Color actual = ColorHelper.FromHex(string.Empty); + Assert.Equal(expected, actual); + } + + [Fact] + public void InvalidLength_ThrowsArgumentException() + { + string lengthOne = "0"; + string lengthTwo = "00"; + string lengthFive = "00000"; + string lengthSeven = "0000000"; + string lengthNine = "000000000"; + + Assert.Throws(() => ColorHelper.FromHex(lengthOne)); + Assert.Throws(() => ColorHelper.FromHex(lengthTwo)); + Assert.Throws(() => ColorHelper.FromHex(lengthFive)); + Assert.Throws(() => ColorHelper.FromHex(lengthSeven)); + Assert.Throws(() => ColorHelper.FromHex(lengthNine)); + } + } +} diff --git a/tests/MonoGame.Extended.Tests/Particles/ColourTests.cs b/tests/MonoGame.Extended.Tests/HslColorTests.cs similarity index 93% rename from tests/MonoGame.Extended.Tests/Particles/ColourTests.cs rename to tests/MonoGame.Extended.Tests/HslColorTests.cs index 49b5e039..ce72652e 100644 --- a/tests/MonoGame.Extended.Tests/Particles/ColourTests.cs +++ b/tests/MonoGame.Extended.Tests/HslColorTests.cs @@ -2,9 +2,9 @@ using Microsoft.Xna.Framework; using Xunit; -namespace MonoGame.Extended.Tests.Particles +namespace MonoGame.Extended.Tests { - public class ColourTests + public class HslColorTests { public class Constructor { @@ -44,7 +44,7 @@ namespace MonoGame.Extended.Tests.Particles { var x = new HslColor(360f, 1f, 1f); - Object y = new HslColor(360f, 1f, 1f); + object y = new HslColor(360f, 1f, 1f); Assert.Equal(x, y); } @@ -53,7 +53,7 @@ namespace MonoGame.Extended.Tests.Particles { var x = new HslColor(360f, 1f, 1f); - Object y = new HslColor(0f, 1f, 0f); + object y = new HslColor(0f, 1f, 0f); Assert.False(x.Equals(y)); } @@ -89,7 +89,7 @@ namespace MonoGame.Extended.Tests.Particles public class ToStringMethod { [Theory] - [InlineData(360f, 1f, 1f, "H:0.0° S:100.0 L:100.0")] + [InlineData(360f, 1f, 1f, "H:360.0° S:100.0 L:100.0")] [InlineData(180f, 0.5f, 0.5f, "H:180.0° S:50.0 L:50.0")] [InlineData(0f, 0f, 0f, "H:0.0° S:0.0 L:0.0")] public void ReturnsCorrectValue(float h, float s, float l, string expected)