From 10bf10c438bf3784c113c827420b567cd990c958 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:02:31 +0000 Subject: [PATCH] [minor] Simulate color vision deficiencies at any severity in Semantics.Color Adds ColorVisionDeficiency (Protan, Deutan, Tritan), ColorVision.Matrix with the Machado, Oliveira and Fernandes (2009) tables at severities 0.0 to 1.0 in 0.1 steps, interpolated linearly between them, ColorVisionMatrix with Apply, and Color.Simulate, which applies the matrix to the linear channels with clamping. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011NhxrNWomwz1U7X5GMMT1J --- CLAUDE.md | 2 +- Semantics.Color/Color.Operations.cs | 14 ++ Semantics.Color/ColorVision.cs | 114 +++++++++++++++++ Semantics.Color/ColorVisionDeficiency.cs | 19 +++ Semantics.Color/ColorVisionMatrix.cs | 50 ++++++++ Semantics.Color/README.md | 25 ++++ Semantics.Test/Colors/ColorVisionTests.cs | 149 ++++++++++++++++++++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 Semantics.Color/ColorVision.cs create mode 100644 Semantics.Color/ColorVisionDeficiency.cs create mode 100644 Semantics.Color/ColorVisionMatrix.cs create mode 100644 Semantics.Test/Colors/ColorVisionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 87f4da21..186a2f19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, `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`. `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. | diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs index 828bc6c3..28041e17 100644 --- a/Semantics.Color/Color.Operations.cs +++ b/Semantics.Color/Color.Operations.cs @@ -241,4 +241,18 @@ public IReadOnlyList Gradient(Color to, int steps) /// Returns the per-channel inverse (photographic negative), computed in gamma-encoded sRGB and preserving alpha. /// The inverted color. public Color Invert() => FromSrgb(ToSrgb().Invert(), A); + + /// + /// Simulates how this color looks to a viewer with a color vision deficiency, by applying + /// to the linear channels and clamping each to 0..1. Alpha is kept. + /// + /// The kind of deficiency. + /// The severity, from 0 (normal vision) to 1 (dichromacy). + /// The color as the viewer perceives it. + /// + /// is outside 0..1 or not a number, or + /// is not a defined value. + /// + public Color Simulate(ColorVisionDeficiency deficiency, double severity) => + ColorVision.Matrix(deficiency, severity).Apply(this); } diff --git a/Semantics.Color/ColorVision.cs b/Semantics.Color/ColorVision.cs new file mode 100644 index 00000000..62af3318 --- /dev/null +++ b/Semantics.Color/ColorVision.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Color; + +using System; + +/// +/// 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. +/// +/// +/// 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 stores. +/// +public static class ColorVision +{ + /// The spacing between the published severities. + 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), + ]; + + /// + /// Gets the simulation matrix for a deficiency at a severity, interpolating linearly between the + /// two nearest published severities. + /// + /// The kind of deficiency. + /// The severity, from 0 (normal vision) to 1 (dichromacy). + /// The matrix taking linear RGB to the linear RGB the viewer perceives. + /// + /// is outside 0..1 or not a number, or + /// is not a defined value. + /// + 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); + } +} diff --git a/Semantics.Color/ColorVisionDeficiency.cs b/Semantics.Color/ColorVisionDeficiency.cs new file mode 100644 index 00000000..b6d22eb9 --- /dev/null +++ b/Semantics.Color/ColorVisionDeficiency.cs @@ -0,0 +1,19 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Color; + +/// +/// The three kinds of color vision deficiency simulated by , named for the +/// cone type that is anomalous (at partial severity) or missing (at severity 1). +/// +public enum ColorVisionDeficiency +{ + /// Anomalous or missing long-wavelength (red) cones: protanomaly, and protanopia at severity 1. + Protan = 0, + + /// Anomalous or missing medium-wavelength (green) cones: deuteranomaly, and deuteranopia at severity 1. + Deutan = 1, + + /// Anomalous or missing short-wavelength (blue) cones: tritanomaly, and tritanopia at severity 1. + Tritan = 2, +} diff --git a/Semantics.Color/ColorVisionMatrix.cs b/Semantics.Color/ColorVisionMatrix.cs new file mode 100644 index 00000000..fc11cf95 --- /dev/null +++ b/Semantics.Color/ColorVisionMatrix.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Color; + +/// +/// A 3x3 matrix taking linear RGB to the linear RGB a viewer with a color vision deficiency +/// perceives, as returned by . Rows produce red, green and blue. +/// +/// Red output from red input. +/// Red output from green input. +/// Red output from blue input. +/// Green output from red input. +/// Green output from green input. +/// Green output from blue input. +/// Blue output from red input. +/// Blue output from green input. +/// Blue output from blue input. +public readonly record struct ColorVisionMatrix( + double M11, double M12, double M13, + double M21, double M22, double M23, + double M31, double M32, double M33) +{ + /// Gets the identity matrix, which is normal color vision. + public static ColorVisionMatrix Identity { get; } = new(1, 0, 0, 0, 1, 0, 0, 0, 1); + + /// + /// Applies this matrix to a color's linear channels, clamping each result to 0..1. Alpha is kept. + /// + /// The color to transform. + /// The color as the simulated viewer sees it. + 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); + + /// Interpolates linearly, entry by entry, between two matrices. + /// The matrix at = 0. + /// The matrix at = 1. + /// The interpolation fraction. + /// The interpolated matrix. + 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)); + } +} diff --git a/Semantics.Color/README.md b/Semantics.Color/README.md index 28f8524f..cbb28842 100644 --- a/Semantics.Color/README.md +++ b/Semantics.Color/README.md @@ -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. @@ -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 @@ -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` | 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 @@ -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`. diff --git a/Semantics.Test/Colors/ColorVisionTests.cs b/Semantics.Test/Colors/ColorVisionTests.cs new file mode 100644 index 00000000..2b14dfd4 --- /dev/null +++ b/Semantics.Test/Colors/ColorVisionTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Colors; + +using System; +using ktsu.Semantics.Color; + +[TestClass] +public class ColorVisionTests +{ + private static readonly ColorVisionDeficiency[] Deficiencies = + [ColorVisionDeficiency.Protan, ColorVisionDeficiency.Deutan, ColorVisionDeficiency.Tritan]; + + [TestMethod] + public void SeverityZero_IsIdentity() + { + foreach (ColorVisionDeficiency deficiency in Deficiencies) + { + AssertMatrix(ColorVisionMatrix.Identity, ColorVision.Matrix(deficiency, 0.0), 1e-12); + } + } + + [TestMethod] + public void SeverityOne_IsThePublishedDichromacyMatrix() + { + // Machado, Oliveira and Fernandes (2009), supplementary tables, severity 1.0. + AssertMatrix( + new(0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998), + ColorVision.Matrix(ColorVisionDeficiency.Protan, 1.0), + 1e-6); + AssertMatrix( + new(0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.011820, 0.042940, 0.968881), + ColorVision.Matrix(ColorVisionDeficiency.Deutan, 1.0), + 1e-6); + AssertMatrix( + new(1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, 0.691367, 0.303900), + ColorVision.Matrix(ColorVisionDeficiency.Tritan, 1.0), + 1e-6); + } + + [TestMethod] + public void APublishedStep_IsReturnedWithoutInterpolationError() + { + // 0.3 / 0.1 is 2.9999999999999996 in binary floating point; the step must still be exact. + AssertMatrix( + new(0.630323, 0.465641, -0.095964, 0.069181, 0.890046, 0.040773, -0.006308, -0.007724, 1.014032), + ColorVision.Matrix(ColorVisionDeficiency.Protan, 0.3), + 1e-12); + } + + [TestMethod] + public void ASeverityBetweenSteps_IsTheLinearInterpolation() + { + foreach (ColorVisionDeficiency deficiency in Deficiencies) + { + ColorVisionMatrix low = ColorVision.Matrix(deficiency, 0.5); + ColorVisionMatrix high = ColorVision.Matrix(deficiency, 0.6); + ColorVisionMatrix mid = ColorVision.Matrix(deficiency, 0.55); + AssertMatrix( + new( + (low.M11 + high.M11) / 2, (low.M12 + high.M12) / 2, (low.M13 + high.M13) / 2, + (low.M21 + high.M21) / 2, (low.M22 + high.M22) / 2, (low.M23 + high.M23) / 2, + (low.M31 + high.M31) / 2, (low.M32 + high.M32) / 2, (low.M33 + high.M33) / 2), + mid, + 1e-9); + } + } + + [TestMethod] + public void EveryRowOfEveryMatrix_SumsToOne() + { + // Each matrix keeps neutral greys neutral, so every row sums to one within the tables' rounding. + foreach (ColorVisionDeficiency deficiency in Deficiencies) + { + for (int step = 0; step <= 20; step++) + { + ColorVisionMatrix m = ColorVision.Matrix(deficiency, step / 20.0); + string where = $"{deficiency} at {step / 20.0}"; + Assert.AreEqual(1.0, m.M11 + m.M12 + m.M13, 1e-3, where); + Assert.AreEqual(1.0, m.M21 + m.M22 + m.M23, 1e-3, where); + Assert.AreEqual(1.0, m.M31 + m.M32 + m.M33, 1e-3, where); + } + } + } + + [TestMethod] + public void Simulate_AppliesTheMatrixToLinearChannelsAndKeepsAlpha() + { + Color color = Color.FromLinear(0.2, 0.4, 0.6, 0.5); + Color simulated = color.Simulate(ColorVisionDeficiency.Deutan, 1.0); + Assert.AreEqual((0.367322 * 0.2) + (0.860646 * 0.4) + (-0.227968 * 0.6), simulated.R, 1e-9); + Assert.AreEqual((0.280085 * 0.2) + (0.672501 * 0.4) + (0.047413 * 0.6), simulated.G, 1e-9); + Assert.AreEqual((-0.011820 * 0.2) + (0.042940 * 0.4) + (0.968881 * 0.6), simulated.B, 1e-9); + Assert.AreEqual(0.5, simulated.A); + } + + [TestMethod] + public void Simulate_ClampsToTheUnitRange() + { + // Pure green under protanopia: the red row weights green by 1.052583, so red is clamped to one. + Color green = Color.FromLinear(0.0, 1.0, 0.0); + Color protan = green.Simulate(ColorVisionDeficiency.Protan, 1.0); + Assert.AreEqual(1.0, protan.R); + Assert.AreEqual(0.786281, protan.G, 1e-9); + + // Pure blue under tritanopia: the red row weights blue by -0.178779, so red is clamped to zero. + Color blue = Color.FromLinear(0.0, 0.0, 1.0); + Color tritan = blue.Simulate(ColorVisionDeficiency.Tritan, 1.0); + Assert.AreEqual(0.0, tritan.R); + } + + [TestMethod] + public void Simulate_LeavesGreysGrey() + { + Color grey = Color.FromLinear(0.5, 0.5, 0.5); + foreach (ColorVisionDeficiency deficiency in Deficiencies) + { + Color simulated = grey.Simulate(deficiency, 0.7); + Assert.AreEqual(0.5, simulated.R, 1e-3, deficiency.ToString()); + Assert.AreEqual(0.5, simulated.G, 1e-3, deficiency.ToString()); + Assert.AreEqual(0.5, simulated.B, 1e-3, deficiency.ToString()); + } + } + + [TestMethod] + public void SeverityOutsideTheUnitRange_Throws() + { + Assert.ThrowsExactly(() => ColorVision.Matrix(ColorVisionDeficiency.Protan, -0.01)); + Assert.ThrowsExactly(() => ColorVision.Matrix(ColorVisionDeficiency.Protan, 1.01)); + Assert.ThrowsExactly(() => ColorVision.Matrix(ColorVisionDeficiency.Protan, double.NaN)); + } + + [TestMethod] + public void AnUndefinedDeficiency_Throws() => + Assert.ThrowsExactly(() => ColorVision.Matrix((ColorVisionDeficiency)3, 0.5)); + + private static void AssertMatrix(ColorVisionMatrix expected, ColorVisionMatrix actual, double tolerance) + { + Assert.AreEqual(expected.M11, actual.M11, tolerance, nameof(actual.M11)); + Assert.AreEqual(expected.M12, actual.M12, tolerance, nameof(actual.M12)); + Assert.AreEqual(expected.M13, actual.M13, tolerance, nameof(actual.M13)); + Assert.AreEqual(expected.M21, actual.M21, tolerance, nameof(actual.M21)); + Assert.AreEqual(expected.M22, actual.M22, tolerance, nameof(actual.M22)); + Assert.AreEqual(expected.M23, actual.M23, tolerance, nameof(actual.M23)); + Assert.AreEqual(expected.M31, actual.M31, tolerance, nameof(actual.M31)); + Assert.AreEqual(expected.M32, actual.M32, tolerance, nameof(actual.M32)); + Assert.AreEqual(expected.M33, actual.M33, tolerance, nameof(actual.M33)); + } +}