diff --git a/CLAUDE.md b/CLAUDE.md index 87f4da2..186a2f1 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 828bc6c..28041e1 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 0000000..62af331 --- /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 0000000..b6d22eb --- /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 0000000..fc11cf9 --- /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 28f8524..cbb2884 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 0000000..2b14dfd --- /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)); + } +}