Files
OneCAD/crates/ui-egui/src/colour_tokens_tests.rs
T
CamWight f6a47a49d7 Colour vision modes: Protan, Deutan and Tritan in Preferences › Graphics (#778) (#1007)
Colour vision modes (Protan, Deutan, Tritan) in Preferences > Graphics, with palettes, shader and icon colours adapted per mode, translations and tests.
2026-10-14 17:35:56 -04:00

304 lines
13 KiB
Rust

//! Every colour that carries a meaning comes from `theme::Tokens` (#778): the engine's sketch and
//! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that
//! the tokens equal what was drawn before, and that a new colour cannot skip them.
use egui::{Color32, Pos2, Rect, vec2};
use solidcraft_engine::render::Rgb;
use solidcraft_engine::view::colors;
use crate::theme::{IconColors, Tokens};
fn rgb(c: Rgb) -> Color32 {
Color32::from_rgb(c.0, c.1, c.2)
}
/// The new tokens hold exactly the values that were drawn in place before they were tokens, so
/// with no colour vision mode on, nothing on screen changes.
#[test]
fn moved_colours_keep_the_values_they_were_drawn_with() {
for t in [Tokens::light(), Tokens::dark()] {
assert_eq!(t.sketch_projected, rgb(colors::SKETCH_PROJECTED));
assert_eq!(t.sketch_lost, rgb(colors::SKETCH_LOST));
assert_eq!(t.sketch_construction, rgb(colors::SKETCH_CONSTRUCTION));
assert_eq!(t.sketch_centerline, rgb(colors::SKETCH_CENTERLINE));
assert_eq!(t.triad, [rgb(colors::AXIS_X), rgb(colors::AXIS_Y), rgb(colors::AXIS_Z)]);
assert_eq!(t.gizmo, [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)]);
assert_eq!(t.interference, Color32::from_rgb(235, 45, 40));
// The shader's former literals, bit for bit.
assert_eq!(t.analysis.draft, [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]]);
assert_eq!(t.analysis.access, [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]]);
assert_eq!(t.analysis.curvature, [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]]);
assert!(!t.analysis.hatch);
assert_eq!(t.app_bar_icon, [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)]);
assert_eq!(t.logo, [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE]);
assert_eq!(t.icons.glint, Color32::from_white_alpha(160));
assert_eq!(t.icons.marker_halo, Color32::from_black_alpha(150));
assert_eq!(t.icons.agent, Color32::from_rgb(168, 85, 247));
}
let (l, d) = (Tokens::light(), Tokens::dark());
assert_eq!((l.bend_up, l.bend_down), (Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20)));
assert_eq!((d.bend_up, d.bend_down), (Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80)));
}
/// The analysis colours reach the shader in the order its `ana_c` array reads them.
#[test]
fn analysis_colours_reach_the_shader_in_its_order() {
let a = Tokens::light().analysis;
let v = crate::gpu::analysis_colors(&a);
let at = |i: usize| [v[4 * i], v[4 * i + 1], v[4 * i + 2]];
assert_eq!([at(0), at(1), at(2)], a.draft);
assert_eq!([at(3), at(4)], a.access);
assert_eq!([at(5), at(6), at(7)], a.curvature);
assert!((0..8).all(|i| v[4 * i + 3] == 1.0));
assert_eq!(v[32], 0.0, "no stripes without a colour vision mode");
let mut striped = a;
striped.hatch = true;
assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0);
}
/// Every name a `match` arm in the icon source draws.
fn icon_names() -> Vec<&'static str> {
let src = include_str!("../../../assets/icons/ui/source/icons.rs");
let body = src.split("#[cfg(test)]").next().unwrap_or(src);
let mut names = Vec::new();
for line in body.lines() {
let line = line.trim_start();
if !line.starts_with('"') {
continue;
}
let Some((pattern, _)) = line.split_once("=>") else { continue };
let pattern = pattern.split(" if ").next().unwrap_or(pattern);
for part in pattern.split('|') {
let part = part.trim();
if part.len() > 2 && part.starts_with('"') && part.ends_with('"') {
names.push(&part[1..part.len() - 1]);
}
}
}
names.sort_unstable();
names.dedup();
names
}
/// Marker icon colours: every field its own colour, from `seed`.
fn marker_icons(seed: u8) -> IconColors {
let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i);
IconColors {
sheet: c(1),
sheet_2: c(2),
sheet_light: c(3),
sheet_line: c(4),
highlight: c(5),
glint: c(6),
folder: c(7),
plane: c(8),
fold: c(9),
stripe: c(10),
sky: c(11),
floor: c(12),
paint: [c(13), c(14), c(15)],
axes: [c(16), c(17), c(18)],
access: [c(19), c(20)],
draft: [c(21), c(22)],
curvature: [c(23), c(24), c(25)],
interference: c(26),
warning: c(27),
agent: c(28),
marker_halo: c(29),
}
}
/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order).
fn drawn_colours(name: &str, seed: u8) -> Vec<String> {
let ctx = egui::Context::default();
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
let icons = marker_icons(seed);
let out = ctx.run_ui(input, |ui| {
let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
let ink = Color32::from_rgb(seed, 101, 1);
let fill = Color32::from_rgb(seed, 102, 2);
let accent = Color32::from_rgb(seed, 103, 3);
crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons);
});
let text = format!("{:?}", out.shapes);
let b = text.as_bytes();
let mut colours = Vec::new();
for i in 0..b.len() {
let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit);
if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) {
colours.push(text[i..i + 12].to_string());
}
}
colours
}
/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the
/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made
/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the
/// same both times was written into the icon, and a mode could not reach it.
#[test]
fn every_icon_draws_only_colours_a_mode_can_reach() {
let names = icon_names();
assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len());
let mut fixed = Vec::new();
for name in &names {
let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200));
assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape");
for (x, y) in a.iter().zip(&b) {
if x == y && !x.ends_with("_00") {
fixed.push(format!("{name}: {x}"));
}
}
}
fixed.dedup();
assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}");
}
/// The most colour literals each UI file may hold, and why. None of them carries a meaning that a
/// colour vision mode must change. A file with more fails here: make the new colour a token in
/// `theme::Tokens` (and `assets/styles/ui/palettes.rs`) instead, or, if it carries no meaning,
/// raise the file's count here and give the reason. Fewer is always fine. The icon sources may
/// hold none at all.
const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("navigation.rs", 1, "the navigation cube's face shading, made from a token"),
("canvas.rs", 3, "white rings around manipulator handles"),
("context_menu.rs", 1, "the radial menu's shadow"),
("dialogs_appearance.rs", 1, "a design appearance: the colour is the fact"),
("dialogs_library.rs", 1, "a library appearance's swatch: the colour is the fact"),
("dialogs_assembly.rs", 1, "the interference fill, shaded from its token"),
("dialogs_motion.rs", 1, "a white outline"),
("documents.rs", 4, "white hover overlays on document tabs"),
("frame.rs", 1, "the window shadow"),
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
("home.rs", 4, "white ink on accent buttons, and thumbnail tints"),
("ref_images.rs", 1, "a reference image's opacity tint"),
("sketch_palette.rs", 1, "white text on the accent button"),
("sketch_text.rs", 1, "white text on the selection chip"),
("titlebar.rs", 4, "the window close button's red hover (the system's convention), white hovers and ink"),
("toolbar.rs", 3, "white hover overlays and the version text"),
("update.rs", 1, "text laid out only to measure its width, never drawn"),
];
const COLOUR_LITERALS: &[&str] = &[
"Color32::from_rgb(",
"Color32::from_rgba_unmultiplied(",
"Color32::from_rgba_unmultiplied_const(",
"Color32::from_rgba_premultiplied(",
"Color32::from_gray(",
"Color32::from_black_alpha(",
"Color32::from_white_alpha(",
"Color32::from_hex(",
"Color32::WHITE",
"Color32::BLACK",
"Color32::RED",
"Color32::GREEN",
"Color32::BLUE",
"Color32::YELLOW",
"Color32::GOLD",
"Color32::ORANGE",
"Color32::BROWN",
"Color32::KHAKI",
"Color32::GRAY",
"Color32::LIGHT_GRAY",
"Color32::DARK_GRAY",
"Color32::DARK_RED",
"Color32::LIGHT_RED",
"Color32::DARK_BLUE",
"Color32::LIGHT_BLUE",
"Color32::DARK_GREEN",
"Color32::LIGHT_GREEN",
"Color32::LIGHT_YELLOW",
];
#[test]
fn colours_outside_the_theme_are_only_the_listed_neutral_ones() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let dirs = [root.join("src"), root.join("../../assets/icons/ui/source")];
let mut found = Vec::new();
for dir in &dirs {
let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string();
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "theme.rs" {
continue;
}
let src = std::fs::read_to_string(&path).unwrap_or_default();
let code = src.split("#[cfg(test)]").next().unwrap_or(&src);
let n: usize = COLOUR_LITERALS.iter().map(|p| code.matches(p).count()).sum();
let allowed = ALLOWED_LITERALS.iter().find(|(f, _, _)| *f == name).map_or(0, |(_, n, _)| *n);
if n > allowed {
found.push(format!("{name}: {n} colour literal(s), at most {allowed} allowed"));
}
}
}
assert!(found.is_empty(), "colour literals outside theme::Tokens: {found:?}");
}
/// The analysis shader takes its colours from the uniform (and so from the tokens). Only the
/// zebra and environment map neutrals and direction vectors stay as literals.
#[test]
fn analysis_shader_colours_come_from_the_uniform() {
let src = include_str!("../../../assets/shaders/viewport.wgsl");
let start = src.find("fn analysis(").unwrap_or(0);
let end = src.find("fn stripe(").unwrap_or(src.len());
let body = &src[start..end];
let mut literals = Vec::new();
let mut rest = body;
while let Some(i) = rest.find("vec3<f32>(") {
let tail = &rest[i..];
let close = tail.find(')').unwrap_or(tail.len() - 1);
literals.push(&tail[..=close]);
rest = &tail[close..];
}
assert_eq!(
literals,
[
"vec3<f32>(0.0, 0.0, 1.0)",
"vec3<f32>(1.0, 0.0, 0.0)",
"vec3<f32>(c)",
"vec3<f32>(0.32, 0.34, 0.38)",
"vec3<f32>(0.80, 0.86, 0.95)",
"vec3<f32>(0.6)",
"vec3<f32>(0.9)",
"vec3<f32>(0.55, 0.56, 0.60)",
"vec3<f32>(0.20, 0.20, 0.22)",
"vec3<f32>(0.35)",
"vec3<f32>(1.0)",
],
"a new colour in the analysis shader must come from `ana_c` (theme::AnalysisColors)"
);
assert_eq!(body.matches("u.ana_c[").count(), 9, "every analysis colour reads `ana_c`");
}
/// Every colour the engine tags a scene line with is mapped to a token by the viewport, or carries
/// no meaning a colour vision mode must change. A new engine tag fails here until it is placed.
#[test]
fn every_engine_colour_tag_is_placed() {
const MAPPED: &[&str] = &[
"SKETCH",
"SKETCH_DONE",
"SKETCH_FIXED",
"SKETCH_LOCKED",
"SKETCH_PROJECTED",
"SKETCH_LOST",
"SKETCH_CONSTRUCTION",
"SKETCH_CENTERLINE",
"AXIS_X",
"AXIS_Y",
"AXIS_Z",
];
// Bodies, edges, profiles, the grid and the background: the GPU viewport draws these from
// their own tokens; the engine's values are only the headless snapshot's.
const NEUTRAL: &[&str] = &["BODY", "BODY_SELECTED", "EDGE", "PROFILE", "GRID", "GRID_MAJOR", "BG_TOP", "BG_BOTTOM", "SECTION_CAP"];
let src = include_str!("../../engine/src/view.rs");
let start = src.find("pub mod colors {").unwrap_or(0);
let block = &src[start..];
let block = &block[..block.find("\n}").unwrap_or(block.len())];
let mut names: Vec<&str> = block.lines().filter_map(|l| l.trim().strip_prefix("pub const ")).filter_map(|l| l.split(':').next()).collect();
names.sort_unstable();
let mut placed: Vec<&str> = MAPPED.iter().chain(NEUTRAL).copied().collect();
placed.sort_unstable();
assert_eq!(names, placed, "an engine colour tag must be mapped to a token in `viewport::sketch_color` or listed as neutral");
}