diff --git a/Resources/Locale/en-US/color-naming.ftl b/Resources/Locale/en-US/color-naming.ftl new file mode 100644 index 000000000..8f8305234 --- /dev/null +++ b/Resources/Locale/en-US/color-naming.ftl @@ -0,0 +1,32 @@ +color-hue-chroma-lightness = {$lightness} {$chroma} {$hue} +color-hue-chroma = {$chroma} {$hue} +color-hue-lightness = {$lightness} {$hue} +color-very-dark = very dark +color-dark = dark +color-light = light +color-very-light = very light +color-mixed-hue = {$a} {$b} +color-pale = pale +color-gray-adjective = gray +color-strong = strong +color-pink = pink +color-red = red +color-orange = orange +color-yellow = yellow +color-green = green +color-cyan = cyan +color-blue = blue +color-purple = purple +color-brown = brown +color-white = white +color-gray = gray +color-black = black + +color-pink-color-red = pinkish red +color-red-color-orange = reddish orange +color-orange-color-yellow = orangeish yellow +color-yellow-color-green = yellowish green +color-green-color-cyan = greenish cyan +color-cyan-color-blue = cyanish blue +color-blue-color-purple = blueish purple +color-purple-color-pink = purpleish pink diff --git a/Robust.Client/UserInterface/Controls/ColorSelectorSliders.cs b/Robust.Client/UserInterface/Controls/ColorSelectorSliders.cs index a4fccde9d..e3c2f49b1 100644 --- a/Robust.Client/UserInterface/Controls/ColorSelectorSliders.cs +++ b/Robust.Client/UserInterface/Controls/ColorSelectorSliders.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using Robust.Shared.ColorNaming; +using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Maths; @@ -8,6 +10,8 @@ namespace Robust.Client.UserInterface.Controls; // condensed version of the original ColorSlider set public sealed class ColorSelectorSliders : Control { + [Dependency] private readonly ILocalizationManager _localization = default!; + public Color Color { get => _currentColor; @@ -83,6 +87,7 @@ public sealed class ColorSelectorSliders : Control private Label _middleSliderLabel = new(); private Label _bottomSliderLabel = new(); private Label _alphaSliderLabel = new(); + private Label _colorDescriptionLabel = new(); private OptionButton _typeSelector; private List _types = new(); @@ -93,6 +98,8 @@ public sealed class ColorSelectorSliders : Control public ColorSelectorSliders() { + IoCManager.InjectDependencies(this); + _topColorSlider = new ColorableSlider { HorizontalExpand = true, @@ -188,6 +195,8 @@ public sealed class ColorSelectorSliders : Control _typeSelector.Select(args.Id); }; + _colorDescriptionLabel.Text = ColorNaming.Describe(_currentColor, _localization); + // TODO: Maybe some engine widgets could be laid out in XAML? var rootBox = new BoxContainer @@ -200,6 +209,7 @@ public sealed class ColorSelectorSliders : Control rootBox.AddChild(headerBox); headerBox.AddChild(_typeSelector); + headerBox.AddChild(_colorDescriptionLabel); var bodyBox = new BoxContainer() { @@ -305,6 +315,7 @@ public sealed class ColorSelectorSliders : Control _alphaSlider.Value = Color.A; _alphaInputBox.Value = (int)(Color.A * 100.0f); + _colorDescriptionLabel.Text = ColorNaming.Describe(Color, _localization); _updating = false; } diff --git a/Robust.Shared.Maths/Color.cs b/Robust.Shared.Maths/Color.cs index 138043028..3513dd2c2 100644 --- a/Robust.Shared.Maths/Color.cs +++ b/Robust.Shared.Maths/Color.cs @@ -591,6 +591,125 @@ namespace Robust.Shared.Maths return new Vector4(hue, saturation, max, rgb.A); } + #region Oklab/Oklch + /* + + The code in this region is based off of https://bottosson.github.io/posts/oklab/, available under public domain or the MIT license. + + Copyright (c) 2020 Björn Ottosson + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + of the Software, and to permit persons to whom the Software is furnished to do + so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + + /// + /// Converts linear sRGB color values to Oklab color values. + /// + /// + /// Returns the converted color value. + /// The X element is L (lightness), the Y element is a (green-red), the Z element is b (blue-yellow), and the W element is Alpha + /// (a copy of the input's Alpha value). + /// L and Alpha have a range of 0 to 1, while a and b are unbounded, but roughly -0.5 to 0.5 + /// + /// Linear sRGB color value to convert. to convert an sRGB color into linear sRGB. + public static Vector4 ToLab(Color srgb) + { + // convert from srgb to linear lms + + var l = 0.4122214708f * srgb.R + 0.5363325363f * srgb.G + 0.0514459929f * srgb.B; + var m = 0.2119034982f * srgb.R + 0.6806995451f * srgb.G + 0.1073969566f * srgb.B; + var s = 0.0883024619f * srgb.R + 0.2817188376f * srgb.G + 0.6299787005f * srgb.B; + + // convert from linear lms to non-linear lms + + var l_ = MathF.Cbrt(l); + var m_ = MathF.Cbrt(m); + var s_ = MathF.Cbrt(s); + + // convert from non-linear lms to lab + + return new Vector4( + 0.2104542553f * l_ + 0.7936177850f * m_ - 0.0040720468f * s_, + 1.9779984951f * l_ - 2.4285922050f * m_ + 0.4505937099f * s_, + 0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_, + srgb.A + ); + } + + /// + /// Converts Oklab color values to linear sRGB color values. + /// + /// + /// Returns the converted color value. to convert an sRGB color into linear sRGB. + /// + /// Oklab color value to convert. + public static Color FromLab(Vector4 oklab) + { + var l_ = oklab.X + 0.3963377774f * oklab.Y + 0.2158037573f * oklab.Z; + var m_ = oklab.X - 0.1055613458f * oklab.Y - 0.0638541728f * oklab.Z; + var s_ = oklab.X - 0.0894841775f * oklab.Y - 1.2914855480f * oklab.Z; + + // convert from non-linear lms to linear lms + + var l = l_ * l_ * l_; + var m = m_ * m_ * m_; + var s = s_ * s_ * s_; + + // convert from linear lms to linear srgb + + var r = +4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s; + var g = -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s; + var b = -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s; + + return new Color(r, g, b, oklab.W); + } + + /// + /// Converts cartesian Oklab color values to polar Oklch color values. + /// + /// + /// Returns the converted color value. + /// + /// Oklab color value to convert. + public static Vector4 ToLch(Vector4 oklab) + { + var c = MathF.Sqrt(oklab.Y * oklab.Y + oklab.Z * oklab.Z); + var h = MathF.Atan2(oklab.Z, oklab.Y); + if (h < 0) + h += 2 * MathF.PI; + + return new Vector4(oklab.X, c, h, oklab.W); + } + + /// + /// Converts polar Oklch color values to cartesian Oklab color values. + /// + /// + /// Returns the converted color value. + /// + /// Oklch color value to convert. + public static Vector4 FromLch(Vector4 oklch) + { + var a = oklch.Y * MathF.Cos(oklch.Z); + var b = oklch.Y * MathF.Sin(oklch.Z); + + return new Vector4(oklch.X, a, b, oklch.W); + } + #endregion + /// /// Converts XYZ color values to RGB color values. /// diff --git a/Robust.Shared/ColorNaming/ColorNaming.cs b/Robust.Shared/ColorNaming/ColorNaming.cs new file mode 100644 index 000000000..27da31a7e --- /dev/null +++ b/Robust.Shared/ColorNaming/ColorNaming.cs @@ -0,0 +1,157 @@ +using System; +using Robust.Shared.Localization; +using Robust.Shared.Maths; + +namespace Robust.Shared.ColorNaming; + +// color naming algorithim is inspired by https://react-spectrum.adobe.com/blog/accessible-color-descriptions.html + +public static class ColorNaming +{ + private static readonly (float Hue, string Loc)[] HueNames = + { + (float.DegreesToRadians(0f), "color-pink"), + (float.DegreesToRadians(15f), "color-red"), + (float.DegreesToRadians(45f), "color-orange"), + (float.DegreesToRadians(90f), "color-yellow"), + (float.DegreesToRadians(135f), "color-green"), + (float.DegreesToRadians(180f), "color-cyan"), + (float.DegreesToRadians(240f), "color-blue"), + (float.DegreesToRadians(285f), "color-purple"), + (float.DegreesToRadians(330f), "color-pink"), + }; + private static readonly (float Hue, string Loc) HueFallback = (float.DegreesToRadians(360f), "color-pink"); + + private const float BrownLightnessThreshold = 0.675f; + private static readonly LocId OrangeString = "color-orange"; + private static readonly LocId BrownString = "color-brown"; + + private const float VeryDarkLightnessThreshold = 0.25f; + private const float DarkLightnessThreshold = 0.5f; + private const float NeutralLightnessThreshold = 0.7f; + private const float LightLightnessThreshold = 0.85f; + + private static readonly LocId VeryDarkString = "color-very-dark"; + private static readonly LocId DarkString = "color-dark"; + private static readonly LocId LightString = "color-light"; + private static readonly LocId VeryLightString = "color-very-light"; + + private static readonly LocId MixedHueString = "color-mixed-hue"; + private static readonly LocId LightLowChromaString = "color-pale"; + private static readonly LocId DarkLowChromaString = "color-gray-adjective"; + private static readonly LocId HighChromaString = "color-strong"; + + private static readonly LocId WhiteString = "color-white"; + private static readonly LocId GrayString = "color-gray"; + private static readonly LocId BlackString = "color-black"; + + private const float LowChromaThreshold = 0.07f; + private const float HighChromaThreshold = 0.16f; + private const float LightLowChromaThreshold = 0.6f; + + private const float WhiteLightnessThreshold = 0.99f; + private const float BlackLightnessThreshold = 0.01f; + private const float GrayChromaThreshold = 0.01f; + + private static (string Loc, float AdjustedLightness) DescribeHue(Vector4 oklch, ILocalizationManager localization) + { + var (lightness, _, hue, _) = oklch; + + for (var i = 0; i < HueNames.Length; i++) + { + var prevData = HueNames[i]; + var nextData = i+1 < HueNames.Length ? HueNames[i+1] : HueFallback; + + if (prevData.Hue >= hue || hue > nextData.Hue) + continue; + + var loc = prevData.Loc; + var adjustedLightness = lightness; + + if (prevData.Loc == OrangeString && lightness <= BrownLightnessThreshold) + loc = BrownString; + else if (prevData.Loc == OrangeString) + adjustedLightness = lightness - BrownLightnessThreshold + DarkLightnessThreshold; + + if (hue >= (prevData.Hue + nextData.Hue)/2f && prevData.Loc != nextData.Loc) + { + if (localization.TryGetString($"{loc}-{nextData.Loc}", out var hueName)) + return (hueName!, adjustedLightness); + else + return (localization.GetString(MixedHueString, ("a", localization.GetString(loc)), ("b", localization.GetString(nextData.Loc))), adjustedLightness); + } + + return (localization.GetString(loc), adjustedLightness); + } + + throw new ArgumentOutOfRangeException("oklch", $"colour ({oklch}) hue {hue} is outside of expected bounds"); + } + + private static string? DescribeChroma(Vector4 oklch, ILocalizationManager localization) + { + var (lightness, chroma, _, _) = oklch; + + if (chroma <= LowChromaThreshold) + { + if (lightness >= LightLowChromaThreshold) + return localization.GetString(LightLowChromaString); + else + return localization.GetString(DarkLowChromaString); + } + else if (chroma >= HighChromaThreshold) + { + return localization.GetString(HighChromaString); + } + + return null; + } + + private static string? DescribeLightness(Vector4 oklch, ILocalizationManager localization) + { + return oklch.X switch + { + < VeryDarkLightnessThreshold => localization.GetString(VeryDarkString), + < DarkLightnessThreshold => localization.GetString(DarkString), + < NeutralLightnessThreshold => null, + < LightLightnessThreshold => localization.GetString(LightString), + _ => localization.GetString(VeryLightString) + }; + } + + /// + /// Textually describes a color + /// + /// + /// Returns a localized textual description of the provided color + /// + /// A Color that is assumed to be in SRGB (the default for most cases) + public static string Describe(Color srgb, ILocalizationManager localization) + { + var oklch = Color.ToLch(Color.ToLab(Color.FromSrgb(srgb))); + + if (oklch.X >= WhiteLightnessThreshold) + return localization.GetString(WhiteString); + + if (oklch.X <= BlackLightnessThreshold) + return localization.GetString(BlackString); + + var (hueDescription, adjustedLightness) = DescribeHue(oklch, localization); + oklch.X = adjustedLightness; + var chromaDescription = DescribeChroma(oklch, localization); + var lightnessDescription = DescribeLightness(oklch, localization); + + if (oklch.Y <= GrayChromaThreshold) + { + hueDescription = localization.GetString(GrayString); + chromaDescription = null; + } + + return (hueDescription, chromaDescription, lightnessDescription) switch + { + ({ } hue, { } chroma, { } lightness) => localization.GetString("color-hue-chroma-lightness", ("hue", hue), ("chroma", chroma), ("lightness", lightness)), + ({ } hue, { } chroma, null) => localization.GetString("color-hue-chroma", ("hue", hue), ("chroma", chroma)), + ({ } hue, null, { } lightness) => localization.GetString("color-hue-lightness", ("hue", hue), ("lightness", lightness)), + ({ } hue, null, null) => hue, + }; + } +}