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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona
| `Semantics.Strings.Identifiers` | Concrete identifier string types (`Uuid`, `Ulid`, `Iban`, `Isbn`, `CreditCardNumber`, `JwtToken`) built on the `Semantics.Strings` framework. |
| `Semantics.Paths` | Polymorphic file system path types (`IPath`, `IFilePath`, `IDirectoryPath`, …). |
| `Semantics.Music` | Immutable musical value types (`Pitch`, `Interval`, `Scale`, `Chord`, `Key`, `Duration`, `TimeSignature`) plus an analysis aggregate layer (`Progression`, `Section`, `Arrangement`, `Form`) computing roman numerals, cadences, key inference, chromatic identification, and named forms. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. |
| `Semantics.Color` | Physically-grounded color types. Canonical linear-RGB `Color` hub plus color-space satellites (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`); every type converts to and from every other, routed through the nearest shared hub (`Srgb` within the sRGB family, `Oklab` within the perceptual family, linear `Color` across families) so no conversion takes a redundant gamma round-trip. Also WCAG accessibility tooling, HSL/perceptual adjustment operations (lighten/saturate/hue/invert), and `NamedColors`. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. |
| `Semantics.Color` | Physically-grounded color types. Canonical linear-RGB `Color` hub plus color-space satellites (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`); every type converts to and from every other, routed through the nearest shared hub (`Srgb` within the sRGB family, `Oklab` within the perceptual family, linear `Color` across families) so no conversion takes a redundant gamma round-trip. Also WCAG accessibility tooling, color vision deficiency simulation (`ColorVision.Matrix(deficiency, severity)` and `Color.Simulate`, the Machado et al. 2009 tables at 0.1 severity steps, interpolated linearly between them and applied to the linear channels), HSL/perceptual adjustment operations (lighten/saturate/hue/invert), and `NamedColors`. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. |
| `Semantics.Quantities` | Hand-written runtime types (`IPhysicalQuantity<TSelf, T>`, `PhysicalQuantityCore`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. Every generated quantity is a `readonly record struct`. |
| `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. Only the physics-specific half lives here — `Models/`, `Metadata/`, `Generators/`, and the bindings in `SemanticsGenerator`/`SemanticsDiagnostics`/`Emit`. The C# syntax templates come from `ktsu.CodeBlocker.Templates`; the metadata-driven generator base, metadata loading and the diagnostic catalogue come from `ktsu.SourceGeneratorToolkit` (#181, #192). |
| `Semantics.Quantities.{Double,Float,Decimal,Precise}` | Props-only satellite packages. Each ships a `build` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass<double>`. `build` rather than `buildTransitive` on purpose: the aliases are project-wide global usings keyed on the bare type name, so two of these packages reaching one project define every alias twice and the compile fails with one `CS1537` per quantity. `build` binds the aliases in the project that declares the reference and nowhere else, which is what "one alias package per project" means; a project downstream of that one references the package it wants for itself. `Precise` binds to `ktsu.PreciseNumber.PreciseNumber` and is the one whose storage type comes from a package rather than being a C# keyword, so it carries a `PackageReference` the others do not; the core `Semantics.Quantities` still has no PreciseNumber dependency. |
Expand Down
14 changes: 14 additions & 0 deletions Semantics.Color/Color.Operations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,18 @@ public IReadOnlyList<Color> Gradient(Color to, int steps)
/// <summary>Returns the per-channel inverse (photographic negative), computed in gamma-encoded sRGB and preserving alpha.</summary>
/// <returns>The inverted color.</returns>
public Color Invert() => FromSrgb(ToSrgb().Invert(), A);

/// <summary>
/// Simulates how this color looks to a viewer with a color vision deficiency, by applying
/// <see cref="ColorVision.Matrix"/> to the linear channels and clamping each to 0..1. Alpha is kept.
/// </summary>
/// <param name="deficiency">The kind of deficiency.</param>
/// <param name="severity">The severity, from 0 (normal vision) to 1 (dichromacy).</param>
/// <returns>The color as the viewer perceives it.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="severity"/> is outside 0..1 or not a number, or <paramref name="deficiency"/>
/// is not a defined value.
/// </exception>
public Color Simulate(ColorVisionDeficiency deficiency, double severity) =>
ColorVision.Matrix(deficiency, severity).Apply(this);
}
114 changes: 114 additions & 0 deletions Semantics.Color/ColorVision.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Color;

using System;

/// <summary>
/// Simulates color vision deficiencies with the physiologically based model of Machado, Oliveira
/// and Fernandes (2009), "A Physiologically-based Model for Simulation of Color Vision Deficiency",
/// IEEE TVCG 15(6), doi:10.1109/TVCG.2009.113.
/// </summary>
/// <remarks>
/// The matrices are the authors' published tables for severities 0.0 to 1.0 in steps of 0.1, as
/// printed to six decimal places. A severity between two steps interpolates linearly between them,
/// as the authors suggest. The matrices act on linear RGB, which is what <see cref="Color"/> stores.
/// </remarks>
public static class ColorVision
{
/// <summary>The spacing between the published severities.</summary>
public const double SeverityStep = 0.1;

// Protan, severity 0.0 to 1.0.
private static readonly ColorVisionMatrix[] ProtanTable =
[
new(1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000),
new(0.856167, 0.182038, -0.038205, 0.029342, 0.955115, 0.015544, -0.002880, -0.001563, 1.004443),
new(0.734766, 0.334872, -0.069637, 0.051840, 0.919198, 0.028963, -0.004928, -0.004209, 1.009137),
new(0.630323, 0.465641, -0.095964, 0.069181, 0.890046, 0.040773, -0.006308, -0.007724, 1.014032),
new(0.539009, 0.579343, -0.118352, 0.082546, 0.866121, 0.051332, -0.007136, -0.011959, 1.019095),
new(0.458064, 0.679578, -0.137642, 0.092785, 0.846313, 0.060902, -0.007494, -0.016807, 1.024301),
new(0.385450, 0.769005, -0.154455, 0.100526, 0.829802, 0.069673, -0.007442, -0.022190, 1.029632),
new(0.319627, 0.849633, -0.169261, 0.106241, 0.815969, 0.077790, -0.007025, -0.028051, 1.035076),
new(0.259411, 0.923008, -0.182420, 0.110296, 0.804340, 0.085364, -0.006276, -0.034346, 1.040622),
new(0.203876, 0.990338, -0.194214, 0.112975, 0.794542, 0.092483, -0.005222, -0.041043, 1.046265),
new(0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998),
];

// Deutan, severity 0.0 to 1.0.
private static readonly ColorVisionMatrix[] DeutanTable =
[
new(1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000),
new(0.866435, 0.177704, -0.044139, 0.049567, 0.939063, 0.011370, -0.003453, 0.007233, 0.996220),
new(0.760729, 0.319078, -0.079807, 0.090568, 0.889315, 0.020117, -0.006027, 0.013325, 0.992702),
new(0.675425, 0.433850, -0.109275, 0.125303, 0.847755, 0.026942, -0.007950, 0.018572, 0.989378),
new(0.605511, 0.528560, -0.134071, 0.155318, 0.812366, 0.032316, -0.009376, 0.023176, 0.986200),
new(0.547494, 0.607765, -0.155259, 0.181692, 0.781742, 0.036566, -0.010410, 0.027275, 0.983136),
new(0.498864, 0.674741, -0.173604, 0.205199, 0.754872, 0.039929, -0.011131, 0.030969, 0.980162),
new(0.457771, 0.731899, -0.189670, 0.226409, 0.731012, 0.042579, -0.011595, 0.034333, 0.977261),
new(0.422823, 0.781057, -0.203881, 0.245752, 0.709602, 0.044646, -0.011843, 0.037423, 0.974421),
new(0.392952, 0.823610, -0.216562, 0.263559, 0.690210, 0.046232, -0.011910, 0.040281, 0.971630),
new(0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.011820, 0.042940, 0.968881),
];

// Tritan, severity 0.0 to 1.0.
private static readonly ColorVisionMatrix[] TritanTable =
[
new(1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000),
new(0.926670, 0.092514, -0.019184, 0.021191, 0.964503, 0.014306, 0.008437, 0.054813, 0.936750),
new(0.895720, 0.133330, -0.029050, 0.029997, 0.945400, 0.024603, 0.013027, 0.104707, 0.882266),
new(0.905871, 0.127791, -0.033662, 0.026856, 0.941251, 0.031893, 0.013410, 0.148296, 0.838294),
new(0.948035, 0.089490, -0.037526, 0.014364, 0.946792, 0.038844, 0.010853, 0.193991, 0.795156),
new(1.017277, 0.027029, -0.044306, -0.006113, 0.958479, 0.047634, 0.006379, 0.248708, 0.744913),
new(1.104996, -0.046633, -0.058363, -0.032137, 0.971635, 0.060503, 0.001336, 0.317922, 0.680742),
new(1.193214, -0.109812, -0.083402, -0.058496, 0.979410, 0.079086, -0.002346, 0.403492, 0.598854),
new(1.257728, -0.139648, -0.118081, -0.078003, 0.975409, 0.102594, -0.003316, 0.501214, 0.502102),
new(1.278864, -0.125333, -0.153531, -0.084748, 0.957674, 0.127074, -0.000989, 0.601151, 0.399838),
new(1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, 0.691367, 0.303900),
];

/// <summary>
/// Gets the simulation matrix for a deficiency at a severity, interpolating linearly between the
/// two nearest published severities.
/// </summary>
/// <param name="deficiency">The kind of deficiency.</param>
/// <param name="severity">The severity, from 0 (normal vision) to 1 (dichromacy).</param>
/// <returns>The matrix taking linear RGB to the linear RGB the viewer perceives.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="severity"/> is outside 0..1 or not a number, or <paramref name="deficiency"/>
/// is not a defined value.
/// </exception>
public static ColorVisionMatrix Matrix(ColorVisionDeficiency deficiency, double severity)
{
ColorVisionMatrix[] table = deficiency switch
{
ColorVisionDeficiency.Protan => ProtanTable,
ColorVisionDeficiency.Deutan => DeutanTable,
ColorVisionDeficiency.Tritan => TritanTable,
_ => throw new ArgumentOutOfRangeException(nameof(deficiency), deficiency, "Unknown color vision deficiency."),
};

if (double.IsNaN(severity) || severity < 0.0 || severity > 1.0)
{
throw new ArgumentOutOfRangeException(nameof(severity), severity, "Severity must be between 0 and 1.");
}

double position = severity / SeverityStep;
int low = Math.Min((int)Math.Floor(position), table.Length - 2);
double fraction = position - low;

// Snap to a published step when the division lands within rounding of one, so 0.3 and 1.0
// return the published table exactly rather than an interpolation a few ulps away from it.
if (Math.Abs(fraction) < 1e-9)
{
return table[low];
}

if (Math.Abs(fraction - 1.0) < 1e-9)
{
return table[low + 1];
}

return ColorVisionMatrix.Lerp(table[low], table[low + 1], fraction);
}
}
19 changes: 19 additions & 0 deletions Semantics.Color/ColorVisionDeficiency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Color;

/// <summary>
/// The three kinds of color vision deficiency simulated by <see cref="ColorVision"/>, named for the
/// cone type that is anomalous (at partial severity) or missing (at severity 1).
/// </summary>
public enum ColorVisionDeficiency
{
/// <summary>Anomalous or missing long-wavelength (red) cones: protanomaly, and protanopia at severity 1.</summary>
Protan = 0,

/// <summary>Anomalous or missing medium-wavelength (green) cones: deuteranomaly, and deuteranopia at severity 1.</summary>
Deutan = 1,

/// <summary>Anomalous or missing short-wavelength (blue) cones: tritanomaly, and tritanopia at severity 1.</summary>
Tritan = 2,
}
50 changes: 50 additions & 0 deletions Semantics.Color/ColorVisionMatrix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Color;

/// <summary>
/// A 3x3 matrix taking linear RGB to the linear RGB a viewer with a color vision deficiency
/// perceives, as returned by <see cref="ColorVision.Matrix"/>. Rows produce red, green and blue.
/// </summary>
/// <param name="M11">Red output from red input.</param>
/// <param name="M12">Red output from green input.</param>
/// <param name="M13">Red output from blue input.</param>
/// <param name="M21">Green output from red input.</param>
/// <param name="M22">Green output from green input.</param>
/// <param name="M23">Green output from blue input.</param>
/// <param name="M31">Blue output from red input.</param>
/// <param name="M32">Blue output from green input.</param>
/// <param name="M33">Blue output from blue input.</param>
public readonly record struct ColorVisionMatrix(
double M11, double M12, double M13,
double M21, double M22, double M23,
double M31, double M32, double M33)
{
/// <summary>Gets the identity matrix, which is normal color vision.</summary>
public static ColorVisionMatrix Identity { get; } = new(1, 0, 0, 0, 1, 0, 0, 0, 1);

/// <summary>
/// Applies this matrix to a color's linear channels, clamping each result to 0..1. Alpha is kept.
/// </summary>
/// <param name="color">The color to transform.</param>
/// <returns>The color as the simulated viewer sees it.</returns>
public Color Apply(Color color) => new(
Color.Clamp01((M11 * color.R) + (M12 * color.G) + (M13 * color.B)),
Color.Clamp01((M21 * color.R) + (M22 * color.G) + (M23 * color.B)),
Color.Clamp01((M31 * color.R) + (M32 * color.G) + (M33 * color.B)),
color.A);

/// <summary>Interpolates linearly, entry by entry, between two matrices.</summary>
/// <param name="from">The matrix at <paramref name="t"/> = 0.</param>
/// <param name="to">The matrix at <paramref name="t"/> = 1.</param>
/// <param name="t">The interpolation fraction.</param>
/// <returns>The interpolated matrix.</returns>
internal static ColorVisionMatrix Lerp(ColorVisionMatrix from, ColorVisionMatrix to, double t)
{
double s = 1.0 - t;
return new(
(s * from.M11) + (t * to.M11), (s * from.M12) + (t * to.M12), (s * from.M13) + (t * to.M13),
(s * from.M21) + (t * to.M21), (s * from.M22) + (t * to.M22), (s * from.M23) + (t * to.M23),
(s * from.M31) + (t * to.M31), (s * from.M32) + (t * to.M32), (s * from.M33) + (t * to.M33));
}
}
25 changes: 25 additions & 0 deletions Semantics.Color/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ On top of that foundation the package adds perceptual operations in the Oklab co
- **Color spaces**: sRGB (`Srgb`), HSL (`Hsl`), HSV (`Hsv`), Oklab (`Oklab`), and Oklch (`Oklch`). Every color type (including `Color`) converts directly to and from every other through `From*` / `To*` methods. Each hop is routed through the nearest shared hub, so a conversion crosses the gamma boundary at most once and never takes a redundant gamma round-trip.
- **Interop**: hex parse and format (`#RGB`, `#RRGGBB`, `#RRGGBBAA`), 8-bit byte tuples, and linear or sRGB `Vector3` / `Vector4` output (the sRGB vectors are what ImGui expects).
- **WCAG accessibility**: relative luminance, contrast ratio (1..21), conformance rating against a background, and `AdjustForContrast` which binary-searches Oklab lightness to hit a target while preserving hue and chroma.
- **Color vision simulation**: `Simulate(deficiency, severity)` shows a color as someone with protan, deutan, or tritan color vision sees it, from the Machado, Oliveira, and Fernandes (2009) matrices at any severity from 0 to 1.
- **Perceptual operations**: Oklab distance (`DistanceTo`), perceptually uniform mixing (`MixOklab`), and Oklab gradients (`Gradient`), alongside plain linear `Lerp`.
- **Adjustments**: lighten/darken, saturate/desaturate, hue offset, grayscale, and invert. HSL-based on `Color`, `Hsl`, and `Srgb`; perceptually-uniform (lightness/chroma) variants on `Oklch`.
- **Named colors**: a CSS/X11 subset with case-insensitive lookup.
Expand Down Expand Up @@ -70,6 +71,26 @@ if (level < AccessibilityLevel.AA)
Console.WriteLine(text.ToHex());
```

### Simulating color vision deficiencies

```csharp
using ktsu.Semantics.Color;

Color red = Color.FromHex("#D62728");
Color green = Color.FromHex("#2CA02C");

// Protanopia (no working red cones): severity 1.0 is the dichromacy, lower is protanomaly
Color redSeen = red.Simulate(ColorVisionDeficiency.Protan, 1.0);
Color greenSeen = green.Simulate(ColorVisionDeficiency.Protan, 1.0);
double apart = redSeen.DistanceTo(greenSeen); // much smaller than red.DistanceTo(green)

// The matrix itself, to apply to many pixels without re-interpolating
ColorVisionMatrix mild = ColorVision.Matrix(ColorVisionDeficiency.Deutan, 0.4);
Color seen = mild.Apply(green);
```

The matrices are the published tables of Machado, Oliveira, and Fernandes (2009), "A Physiologically-based Model for Simulation of Color Vision Deficiency", for severities 0.0 to 1.0 in steps of 0.1. A severity between two steps interpolates linearly between them. They act on linear RGB, which is what `Color` stores, and the result is clamped to 0..1 with alpha kept. Severity 0 is the identity, and a severity outside 0..1 throws `ArgumentOutOfRangeException`.

### Perceptually uniform gradients

```csharp
Expand Down Expand Up @@ -167,6 +188,7 @@ The canonical color: linear RGBA, each channel `double` in 0..1. A `readonly rec
| `MixOklab(other, t)` | `Color` | Perceptually uniform mix (`t = 0` returns this, `t = 1` returns other). |
| `Lerp(other, t)` | `Color` | Linear-RGB interpolation. |
| `Gradient(to, steps)` | `IReadOnlyList<Color>` | Oklab gradient, inclusive of endpoints (`steps >= 2`). |
| `Simulate(deficiency, severity)` | `Color` | The color as seen with a color vision deficiency (Machado et al. 2009), severity 0..1. |

#### Adjustments

Expand All @@ -189,6 +211,9 @@ Convenience adjustments on `Color` operate in HSL and preserve alpha; for percep
| `Oklab` | Perceptual color space (Ottosson 2020). | `FromColor` / `ToColor`; polar via `ToOklch` / `FromOklch` |
| `Oklch` | Polar form of Oklab. | `ToOklab` / `FromOklab` |
| `AccessibilityLevel` | enum: `Fail = 0`, `AA = 1`, `AAA = 2`. | — |
| `ColorVisionDeficiency` | enum: `Protan`, `Deutan`, `Tritan`. | — |
| `ColorVision` | `Matrix(deficiency, severity)`: the Machado et al. (2009) simulation matrix, interpolated between the published 0.1 steps. | — |
| `ColorVisionMatrix` | A 3x3 linear-RGB matrix (`M11`..`M33`) with `Apply(Color)`, which clamps to 0..1 and keeps alpha; `Identity`. | — |
| `NamedColors` | Common colors (`Black`, `White`, `Red`, `Orange`, `Transparent`, ...), plus `All` and `TryGet(name, out color)` with case-insensitive keys. | — |

The satellite spaces carry their own adjustments: `Hsl` and `Srgb` have the full HSL set (saturation/lightness/hue/grayscale; `Srgb` routes these through HSL and also adds `Invert()`), while `Oklch` exposes perceptually-uniform `WithLightness`/`LightenBy`/`DarkenBy`, chroma ops (`WithChroma`/`MultiplyChroma`/`SaturateBy`/`DesaturateBy`/`ToGrayscale`), and `OffsetHue`. `Color`'s adjustment methods forward to `Hsl`.
Expand Down
Loading
Loading