Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageVersion Include="Polyfill" Version="10.11.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Numerics.Vectors" Version="4.5.0" />
<!-- Source generator packages -->
<PackageVersion Include="ktsu.CodeBlocker" Version="1.2.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
Expand Down
18 changes: 18 additions & 0 deletions Semantics.Color/AccessibilityLevel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Color;

/// <summary>WCAG 2.x contrast conformance levels, ordered so that higher is stricter.</summary>
public enum AccessibilityLevel
{
/// <summary>Does not meet the AA contrast threshold.</summary>
Fail = 0,

/// <summary>Meets the WCAG AA contrast threshold.</summary>
AA = 1,

/// <summary>Meets the WCAG AAA contrast threshold.</summary>
AAA = 2,
}
149 changes: 149 additions & 0 deletions Semantics.Color/Color.Conversions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Color;

using System;
using System.Numerics;

public readonly partial record struct Color
{
/// <summary>Creates a linear color from a gamma-encoded <see cref="Srgb"/>.</summary>
/// <param name="srgb">The sRGB color.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromSrgb(Srgb srgb, double a = 1.0) => srgb.ToLinear(a);

/// <summary>Creates a linear color from gamma-encoded sRGB channels.</summary>
/// <param name="r">sRGB red channel (0..1).</param>
/// <param name="g">sRGB green channel (0..1).</param>
/// <param name="b">sRGB blue channel (0..1).</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromSrgb(double r, double g, double b, double a = 1.0) => new Srgb(r, g, b).ToLinear(a);

/// <summary>Converts this linear color to gamma-encoded <see cref="Srgb"/>.</summary>
/// <returns>The sRGB equivalent (alpha dropped).</returns>
public Srgb ToSrgb() => Srgb.FromLinear(this);

/// <summary>Converts to a gamma-encoded sRGB <see cref="Vector4"/> (float) — the value ImGui expects.</summary>
/// <returns>A float vector of sRGB RGB plus alpha.</returns>
public Vector4 ToSrgbVector4()
{
Srgb s = ToSrgb();
return new Vector4((float)s.R, (float)s.G, (float)s.B, (float)A);
}

/// <summary>Converts to a gamma-encoded sRGB <see cref="Vector3"/> (float), dropping alpha.</summary>
/// <returns>A float vector of sRGB RGB.</returns>
public Vector3 ToSrgbVector3()
{
Srgb s = ToSrgb();
return new Vector3((float)s.R, (float)s.G, (float)s.B);
}

/// <summary>Creates a linear color from a hex string: <c>#RGB</c>, <c>#RRGGBB</c>, or <c>#RRGGBBAA</c> (leading '#' optional). Channels are interpreted as sRGB.</summary>
/// <param name="hex">The hex color string.</param>
/// <returns>The linear-RGB color.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hex"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="hex"/> is not a recognised hex length.</exception>
public static Color FromHex(string hex)
{
Ensure.NotNull(hex);

string h = hex.StartsWith('#') ? hex[1..] : hex;

if (h.Length == 3)
{
h = new string([h[0], h[0], h[1], h[1], h[2], h[2]]);
}

if (h.Length is not (6 or 8))
{
throw new ArgumentException("Hex color must be #RGB, #RRGGBB, or #RRGGBBAA.", nameof(hex));
}

byte r = ParseByte(h, 0);
byte g = ParseByte(h, 2);
byte b = ParseByte(h, 4);
byte a = h.Length == 8 ? ParseByte(h, 6) : (byte)255;
return FromBytes(r, g, b, a);
}

/// <summary>Converts to an uppercase hex string: <c>#RRGGBB</c>, or <c>#RRGGBBAA</c> when alpha is not fully opaque.</summary>
/// <returns>The hex string.</returns>
public string ToHex()
{
(byte r, byte g, byte b, byte a) = ToBytes();
return a == 255
? $"#{r:X2}{g:X2}{b:X2}"
: $"#{r:X2}{g:X2}{b:X2}{a:X2}";
}

/// <summary>Creates a linear color from 8-bit sRGB channels.</summary>
/// <param name="r">sRGB red byte.</param>
/// <param name="g">sRGB green byte.</param>
/// <param name="b">sRGB blue byte.</param>
/// <param name="a">Alpha byte (default 255).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromBytes(byte r, byte g, byte b, byte a = 255) =>
FromSrgb(r / 255.0, g / 255.0, b / 255.0, a / 255.0);

/// <summary>Converts to 8-bit sRGB channels plus an alpha byte.</summary>
/// <returns>The rounded sRGB byte tuple.</returns>
public (byte R, byte G, byte B, byte A) ToBytes()
{
Srgb s = ToSrgb();
return (ToByte(s.R), ToByte(s.G), ToByte(s.B), ToByte(A));
}

private static byte ParseByte(string hex, int index) =>
Convert.ToByte(hex.Substring(index, 2), 16);

/// <summary>Converts this linear color to <see cref="Oklab"/>.</summary>
/// <returns>The Oklab equivalent.</returns>
public Oklab ToOklab() => Oklab.FromColor(this);

/// <summary>Creates a linear color from an <see cref="Oklab"/> value.</summary>
/// <param name="oklab">The Oklab color.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromOklab(Oklab oklab, double a = 1.0) => oklab.ToColor(a);

/// <summary>Converts this linear color to <see cref="Oklch"/>.</summary>
/// <returns>The Oklch equivalent.</returns>
public Oklch ToOklch() => Oklab.FromColor(this).ToOklch();

/// <summary>Creates a linear color from an <see cref="Oklch"/> value.</summary>
/// <param name="oklch">The Oklch color.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromOklch(Oklch oklch, double a = 1.0) => oklch.ToOklab().ToColor(a);

/// <summary>Converts this linear color to <see cref="Hsl"/> (via sRGB).</summary>
/// <returns>The HSL equivalent.</returns>
public Hsl ToHsl() => Hsl.FromSrgb(ToSrgb());

/// <summary>Creates a linear color from an <see cref="Hsl"/> value (via sRGB).</summary>
/// <param name="hsl">The HSL color.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromHsl(Hsl hsl, double a = 1.0) => FromSrgb(hsl.ToSrgb(), a);

/// <summary>Converts this linear color to <see cref="Hsv"/> (via sRGB).</summary>
/// <returns>The HSV equivalent.</returns>
public Hsv ToHsv() => Hsv.FromSrgb(ToSrgb());

/// <summary>Creates a linear color from an <see cref="Hsv"/> value (via sRGB).</summary>
/// <param name="hsv">The HSV color.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>The linear-RGB color.</returns>
public static Color FromHsv(Hsv hsv, double a = 1.0) => FromSrgb(hsv.ToSrgb(), a);

private static byte ToByte(double channel)
{
double scaled = Math.Round(Clamp01(channel) * 255.0);
return (byte)scaled;
}
}
171 changes: 171 additions & 0 deletions Semantics.Color/Color.Operations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Color;

using System;
using System.Collections.Generic;

public readonly partial record struct Color
{
/// <summary>Gets the WCAG relative luminance of this color (computed on the linear channels).</summary>
public double RelativeLuminance => (0.2126 * R) + (0.7152 * G) + (0.0722 * B);

/// <summary>Computes the WCAG contrast ratio (1..21) between this color and another.</summary>
/// <param name="other">The other color.</param>
/// <returns>The contrast ratio, from 1 (identical luminance) to 21 (black vs white).</returns>
public double ContrastRatio(Color other)
{
double l1 = RelativeLuminance;
double l2 = other.RelativeLuminance;
double lighter = Math.Max(l1, l2);
double darker = Math.Min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}

/// <summary>Rates the contrast of this color against a background per WCAG.</summary>
/// <param name="background">The background color.</param>
/// <param name="largeText">True for large text (lower thresholds).</param>
/// <returns>The highest <see cref="AccessibilityLevel"/> the pair satisfies.</returns>
public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false)
{
double contrast = ContrastRatio(background);
if (contrast >= (largeText ? 4.5 : 7.0))
{
return AccessibilityLevel.AAA;
}

return contrast >= (largeText ? 3.0 : 4.5) ? AccessibilityLevel.AA : AccessibilityLevel.Fail;
}

/// <summary>
/// Adjusts this color's Oklab lightness (preserving hue and chroma) until it meets the requested
/// contrast level against a background. Returns this color unchanged if already sufficient or if
/// no adjustment can reach the target.
/// </summary>
/// <param name="background">The background color.</param>
/// <param name="target">The desired conformance level.</param>
/// <param name="largeText">True for large text (lower thresholds).</param>
/// <returns>An adjusted color, clamped to gamut.</returns>
public Color AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)
{
double required = target switch
{
AccessibilityLevel.AAA => largeText ? 4.5 : 7.0,
AccessibilityLevel.AA => largeText ? 3.0 : 4.5,
_ => 1.0,
};

if (ContrastRatio(background) >= required)
{
return this;
}

Oklab lab = ToOklab();
double alpha = A;
bool goLighter = background.RelativeLuminance < 0.5;
double lo = goLighter ? lab.L : 0.0;
double hi = goLighter ? 1.0 : lab.L;

// Contrast increases monotonically as L moves toward the chosen extreme; binary-search the
// smallest movement that meets the requirement.
for (int i = 0; i < 30; i++)
{
double mid = (lo + hi) / 2.0;
Color candidate = Candidate(lab, mid);
bool meets = candidate.ContrastRatio(background) >= required;
if (goLighter)
{
if (meets)
{
hi = mid;
}
else
{
lo = mid;
}
}
else if (meets)
{
lo = mid;
}
else
{
hi = mid;
}
}

Color result = Candidate(lab, goLighter ? hi : lo);
return result.ContrastRatio(background) >= required ? result : this;

Color Candidate(Oklab source, double lightness) =>
FromOklab(new Oklab(lightness, source.A, source.B), alpha).Clamp();
}

/// <summary>Computes the perceptual (Oklab Euclidean) distance to another color.</summary>
/// <param name="other">The other color.</param>
/// <returns>The Oklab distance.</returns>
public double DistanceTo(Color other)
{
Oklab a = ToOklab();
Oklab b = other.ToOklab();
double dl = a.L - b.L;
double da = a.A - b.A;
double db = a.B - b.B;
return Math.Sqrt((dl * dl) + (da * da) + (db * db));
}

/// <summary>Mixes this color with another in Oklab space (perceptually uniform).</summary>
/// <param name="other">The other color.</param>
/// <param name="t">The interpolation factor, 0 = this, 1 = other.</param>
/// <returns>The mixed color.</returns>
public Color MixOklab(Color other, double t)
{
Oklab a = ToOklab();
Oklab b = other.ToOklab();
double inv = 1.0 - t;
Oklab mixed = new(
(a.L * inv) + (b.L * t),
(a.A * inv) + (b.A * t),
(a.B * inv) + (b.B * t));
return FromOklab(mixed, (A * inv) + (other.A * t));
}

/// <summary>Linearly interpolates this color with another in linear-RGB space.</summary>
/// <param name="other">The other color.</param>
/// <param name="t">The interpolation factor, 0 = this, 1 = other.</param>
/// <returns>The interpolated color.</returns>
public Color Lerp(Color other, double t)
{
double inv = 1.0 - t;
return new Color(
(R * inv) + (other.R * t),
(G * inv) + (other.G * t),
(B * inv) + (other.B * t),
(A * inv) + (other.A * t));
}

/// <summary>Builds a perceptually-uniform (Oklab) gradient from this color to another.</summary>
/// <param name="to">The end color.</param>
/// <param name="steps">The number of colors to produce (at least 2).</param>
/// <returns>The gradient, inclusive of both endpoints.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="steps"/> is less than 2.</exception>
public IReadOnlyList<Color> Gradient(Color to, int steps)
{
if (steps < 2)
{
throw new ArgumentException("A gradient needs at least 2 steps.", nameof(steps));
}

Color[] result = new Color[steps];
result[0] = this;
result[steps - 1] = to;
for (int i = 1; i < steps - 1; i++)
{
result[i] = MixOklab(to, i / (double)(steps - 1));
}

return result;
}
}
46 changes: 46 additions & 0 deletions Semantics.Color/Color.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.

namespace ktsu.Semantics.Color;

using System.Numerics;

/// <summary>
/// A color stored as linear (not gamma-encoded) RGB plus straight alpha, each in the range 0..1.
/// This is the canonical color type; conversions to and from other color spaces live in the
/// <c>Color.Conversions</c> partial, and color-science operations in <c>Color.Operations</c>.
/// </summary>
/// <param name="R">Linear red channel.</param>
/// <param name="G">Linear green channel.</param>
/// <param name="B">Linear blue channel.</param>
/// <param name="A">Straight (non-premultiplied) alpha.</param>
public readonly partial record struct Color(double R, double G, double B, double A)
{
/// <summary>Creates a color from linear RGB channels, defaulting alpha to fully opaque.</summary>
/// <param name="r">Linear red channel.</param>
/// <param name="g">Linear green channel.</param>
/// <param name="b">Linear blue channel.</param>
/// <param name="a">Straight alpha (default 1.0).</param>
/// <returns>A linear-RGB color.</returns>
public static Color FromLinear(double r, double g, double b, double a = 1.0) => new(r, g, b, a);

/// <summary>Returns a copy of this color with a replaced alpha.</summary>
/// <param name="a">The new straight alpha.</param>
/// <returns>A color with the same RGB and the given alpha.</returns>
public Color WithAlpha(double a) => new(R, G, B, a);

/// <summary>Returns a copy with every channel clamped to the 0..1 range.</summary>
/// <returns>A gamut- and alpha-clamped color.</returns>
public Color Clamp() => new(Clamp01(R), Clamp01(G), Clamp01(B), Clamp01(A));

/// <summary>Converts to a linear-RGBA <see cref="Vector4"/> (float).</summary>
/// <returns>A float vector of the linear channels.</returns>
public Vector4 ToLinearVector4() => new((float)R, (float)G, (float)B, (float)A);

/// <summary>Converts to a linear-RGB <see cref="Vector3"/> (float), dropping alpha.</summary>
/// <returns>A float vector of the linear RGB channels.</returns>
public Vector3 ToLinearVector3() => new((float)R, (float)G, (float)B);

internal static double Clamp01(double value) => value < 0.0 ? 0.0 : value > 1.0 ? 1.0 : value;
}
Loading
Loading