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.
This commit is contained in:
CamWight authored and GitHub committed 2026-10-14 17:35:56 -04:00
1 parent 289b521a65
commit f6a47a49d7
30 files changed
+2077 -166

No files matched your search

+2 -2
View File
@@ -100,7 +100,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
let face = |col: Color32, pts: &[(f32, f32)]| {
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
};
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
let [light, mid, dark, edge_col] = crate::theme::Tokens::get().logo;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
@@ -111,7 +111,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
// White edge lines.
let edge = Stroke::new(10.0 * s, Color32::WHITE);
let edge = Stroke::new(10.0 * s, edge_col);
let line = |pts: &[(f32, f32)]| {
p.add(egui::Shape::line(m(pts), edge));
};
+68 -73
View File
@@ -3,13 +3,6 @@
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
/// round strip), and the iso lines and comb teeth drawn on sheets.
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
/// `n + 1` points along the cubic Bézier curve with control points `c`.
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
(0..=n)
@@ -127,6 +120,12 @@ fn line_type_dot_radius(r: Rect) -> f32 {
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons);
}
/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`).
pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) {
let ic = *ic;
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
let a = pen.accent;
match name {
@@ -164,7 +163,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"sphere" => {
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint);
}
"torus" => {
let pts: Vec<Pos2> =
@@ -172,7 +171,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
let pts: Vec<Pos2> =
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
}
"fillet" => {
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
@@ -214,7 +213,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a)));
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
}
@@ -231,7 +230,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"shell" => {
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a);
}
"draft" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
@@ -478,7 +477,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"c_coincident" => {
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
pen.circle(12.0, 12.0, 2.6, ic.highlight, a);
}
"c_tangent" => {
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
@@ -525,7 +524,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
}
"new" => {
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink);
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
}
"open" => {
@@ -533,7 +532,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"save" => {
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink);
}
"export" => {
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
@@ -572,7 +571,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"zoom" => {
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
pen.circle(10.0, 10.0, 6.0, ic.highlight, ink);
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
}
"perspective" => {
@@ -590,13 +589,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
}
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink),
"origin" => {
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]);
pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]);
pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]);
}
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink),
"axis" => {
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
pen.circle(4.0, 20.0, 2.0, ink, ink);
@@ -825,8 +824,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"accessibility" => {
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT);
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
pen.arrow((4.0, 2.0), (4.0, 13.0), a);
pen.arrow((20.0, 2.0), (20.0, 13.0), a);
@@ -834,10 +833,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"environment_map" => {
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight);
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"zebra" | "draft_analysis" | "curvature_map" => {
@@ -845,27 +844,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
match name {
"zebra" => {
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
for k in 0..3 {
let y = 6.5 + k as f32 * 4.5;
let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
pen.poly(
&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
Color32::from_gray(30),
Color32::TRANSPARENT,
);
pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT);
}
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"draft_analysis" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT);
pen.arrow((12.0, 16.0), (12.0, 1.5), a);
}
_ => {
p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT);
}
}
}
@@ -983,13 +978,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"patch" => {
// A wire loop with a surface spanned across it.
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink);
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
}
"stitch" => {
// Two surface patches sewn along their shared edge.
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink);
for y in [6.0, 9.0, 12.0] {
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
}
@@ -999,7 +994,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
let d = f.map(|(x, y)| (x, y + 6.0));
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet);
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
@@ -1042,7 +1037,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) pulled up into a sheet.
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
pen.curve(top, ink);
@@ -1053,13 +1048,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) turned about the axis into a hollow band.
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet);
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
pen.poly(&front, SHEET, Color32::TRANSPARENT);
pen.poly(&front, ic.sheet, Color32::TRANSPARENT);
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
opening.pop();
pen.poly(&opening, SHEET_LIGHT, ink);
pen.poly(&opening, ic.sheet_light, ink);
pen.line(&front, ink);
pen.curve(left, ink);
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
@@ -1070,7 +1065,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A straight profile (accent) carried along a curved path: a ribbon.
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet);
pen.curve(near, ink);
pen.curve(far, ink);
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
@@ -1080,7 +1075,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Two open sections (accent) joined by a sheet.
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
pen.curve(top, a);
@@ -1090,7 +1085,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet);
pen.curve(back, ink);
pen.curve(front, ink);
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
@@ -1104,7 +1099,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"ruled" => {
// A strip hanging off a block's edge (accent), ruled by straight lines.
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink);
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
@@ -1140,13 +1135,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
})
.collect();
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
pen.band(lo, hi, SHEET);
pen.band(lo, hi, ic.sheet);
}
for j in [n / 3, 2 * n / 3] {
if let Some(r) = rows.get(j) {
pen.line(r, SHEET_LINE);
pen.line(r, ic.sheet_line);
}
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), ic.sheet_line);
}
for c in [left, top, right, bottom] {
pen.curve(c, a);
@@ -1160,29 +1155,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// leaves both edges tangent.
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.curve(far, a);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink);
pen.curve(near, a);
}
"untrim" => {
// A trimmed sheet whose hole (dashed, accent) is filled back in.
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink);
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT);
pen.dashed(&hole, a);
}
"unstitch" => {
// Two sheets pulled apart from their shared edge.
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink);
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
}
"reverse_normal" => {
// A sheet with its old normal (faded) and the flipped one (accent).
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink);
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
}
@@ -1190,10 +1185,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wall sheet and a floor sheet joined by a round strip (accent edges).
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line);
pen.curve(far, a);
pen.curve(near, a);
}
@@ -1209,11 +1204,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let h = 0.8 + 4.2 * i as f32 / 10.0;
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
if i % 2 == 0 {
pen.line(&[(*x, *y), tip], SHEET_LINE);
pen.line(&[(*x, *y), tip], ic.sheet_line);
}
tips.push(tip);
}
pen.line(&tips, SHEET_LINE);
pen.line(&tips, ic.sheet_line);
pen.curve(c, a);
pen.dot(12.0, 13.0, a);
}
@@ -1256,7 +1251,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"interference" => {
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
}
// Sheet metal: thin plates (fill), the made or changed part in the accent.
@@ -1283,7 +1278,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"fold" => {
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold);
pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
pen.arrow((18.0, 16.0), (17.0, 12.5), a);
}
@@ -1358,13 +1353,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Appearance: a shaded ball with three paint dabs.
"appearance" => {
pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight);
pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink);
pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink);
pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink);
}
"warning" => {
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink);
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
pen.dot(12.0, 17.0, ink);
}
+6 -5
View File
@@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2};
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
let ic = Tokens::get().icons;
let halo = Stroke::new(4.5, ic.marker_halo);
let white = Stroke::new(1.5, ic.highlight);
if let Some(tip) = tip {
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
@@ -30,7 +31,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(ic.marker_halo, Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match kind {
SnapKind::FaceCenter => {
@@ -38,8 +39,8 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
SnapKind::CircleCenter => {
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, ic.highlight));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, ic.highlight));
}
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
+3 -4
View File
@@ -151,13 +151,12 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
pub(crate) const AXIS_RADIUS: f32 = 11.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
let col = if d.x != 0.0 {
solidcraft_engine::view::colors::AXIS_X
t.triad[0]
} else if d.y != 0.0 {
solidcraft_engine::view::colors::AXIS_Y
t.triad[1]
} else {
solidcraft_engine::view::colors::AXIS_Z
t.triad[2]
};
let col = Color32::from_rgb(col.0, col.1, col.2);
let positive = !name.starts_with('-');
let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col };
painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
+10 -6
View File
@@ -1,14 +1,18 @@
//! Original agent pointer and label. Animation stays with the UI controller.
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, AGENT);
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
}
+16
View File
@@ -72,6 +72,21 @@
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
"ui.collinear": "Kollinear",
"ui.colour_vision": "Farbsehen",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "Bei schwachem oder fehlendem Grünsinn",
"ui.colour_vision_failed": "Fehler",
"ui.colour_vision_fixed": "Fixiert",
"ui.colour_vision_mode": "Modus",
"ui.colour_vision_note": "Ändert die Farben, die eine Bedeutung tragen: Skizzenstatus, Achsen, Biegungen, Analysen und Symbole. Wenn Sie die Farbfilter von Windows verwenden, lassen Sie dies auf „Aus“.",
"ui.colour_vision_off": "Aus",
"ui.colour_vision_off_tip": "Die eigenen Farben des Designs",
"ui.colour_vision_projected": "Projiziert",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "Bei schwachem oder fehlendem Rotsinn",
"ui.colour_vision_sample": "Beispiel",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "Bei schwachem oder fehlendem Blausinn",
"ui.combine": "Kombinieren",
"ui.coming_later": "bald verfügbar",
"ui.concentric": "Konzentrisch",
@@ -668,6 +683,7 @@
"ui.heading_constraints": "RANDBEDINGUNGEN",
"ui.heading_utilities": "WERKZEUGE",
"BROWSER": "BROWSER",
"Bends": "Biegungen",
"Drive Joint": "Gelenk antreiben",
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Find in Browser": "Im Browser suchen",
+15
View File
@@ -350,6 +350,21 @@
"ui.coincident": "Coincident",
"ui.collapse": "Collapse",
"ui.collinear": "Collinear",
"ui.colour_vision": "Colour vision",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "For a weak or missing green sense",
"ui.colour_vision_failed": "Failed",
"ui.colour_vision_fixed": "Fixed",
"ui.colour_vision_mode": "Mode",
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
"ui.colour_vision_off": "Off",
"ui.colour_vision_off_tip": "The theme's own colours",
"ui.colour_vision_projected": "Projected",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "For a weak or missing red sense",
"ui.colour_vision_sample": "Sample",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "For a weak or missing blue sense",
"ui.combine": "Combine",
"ui.coming_later": "coming later",
"ui.concentric": "Concentric",
+15
View File
@@ -350,6 +350,21 @@
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
"ui.collinear": "同一直線上",
"ui.colour_vision": "色覚",
"ui.colour_vision_deutan": "2型色覚(D型)",
"ui.colour_vision_deutan_tip": "緑を感じにくい、または感じない場合に",
"ui.colour_vision_failed": "エラー",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "モード",
"ui.colour_vision_note": "意味を持つ色を変更します(スケッチの状態、軸、曲げ、解析、アイコン)。Windows のカラー フィルターを使う場合は「オフ」のままにしてください。",
"ui.colour_vision_off": "オフ",
"ui.colour_vision_off_tip": "テーマ本来の色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "1型色覚(P型)",
"ui.colour_vision_protan_tip": "赤を感じにくい、または感じない場合に",
"ui.colour_vision_sample": "サンプル",
"ui.colour_vision_tritan": "3型色覚(T型)",
"ui.colour_vision_tritan_tip": "青を感じにくい、または感じない場合に",
"ui.combine": "結合",
"ui.coming_later": "準備中",
"ui.concentric": "同心",
+16
View File
@@ -72,6 +72,21 @@
"ui.coincident": "일치",
"ui.collapse": "접기",
"ui.collinear": "동일 선상",
"ui.colour_vision": "색각",
"ui.colour_vision_deutan": "녹색각이상 (Deutan)",
"ui.colour_vision_deutan_tip": "녹색 감각이 약하거나 없는 경우",
"ui.colour_vision_failed": "실패",
"ui.colour_vision_fixed": "고정",
"ui.colour_vision_mode": "모드",
"ui.colour_vision_note": "의미를 나타내는 색을 바꿉니다: 스케치 상태, 축, 벤딩, 분석, 아이콘. Windows 색 필터를 사용한다면 '끄기'로 두세요.",
"ui.colour_vision_off": "끄기",
"ui.colour_vision_off_tip": "테마 고유의 색상",
"ui.colour_vision_projected": "투영",
"ui.colour_vision_protan": "적색각이상 (Protan)",
"ui.colour_vision_protan_tip": "빨간색 감각이 약하거나 없는 경우",
"ui.colour_vision_sample": "예시",
"ui.colour_vision_tritan": "청색각이상 (Tritan)",
"ui.colour_vision_tritan_tip": "파란색 감각이 약하거나 없는 경우",
"ui.combine": "결합",
"ui.coming_later": "추후 지원 예정",
"ui.concentric": "동심",
@@ -668,6 +683,7 @@
"ui.heading_constraints": "구속 조건",
"ui.heading_utilities": "유틸리티",
"BROWSER": "브라우저",
"Bends": "벤딩",
"Drive Joint": "조인트 구동",
"Edit Profile Sketch": "윤곽 스케치 편집",
"Find in Browser": "브라우저에서 찾기",
+15
View File
@@ -342,6 +342,21 @@
"ui.coincident": "Совпадение",
"ui.collapse": "Свернуть",
"ui.collinear": "Коллинеарность",
"ui.colour_vision": "Цветовое зрение",
"ui.colour_vision_deutan": "Дейтан",
"ui.colour_vision_deutan_tip": "При слабом или отсутствующем восприятии зелёного",
"ui.colour_vision_failed": "Ошибка",
"ui.colour_vision_fixed": "Фиксировано",
"ui.colour_vision_mode": "Режим",
"ui.colour_vision_note": "Меняет цвета, которые несут смысл: статус эскиза, оси, гибы, анализы и значки. Если вы пользуетесь цветовыми фильтрами Windows, оставьте «Выключено».",
"ui.colour_vision_off": "Выключено",
"ui.colour_vision_off_tip": "Собственные цвета темы",
"ui.colour_vision_projected": "Проекция",
"ui.colour_vision_protan": "Протан",
"ui.colour_vision_protan_tip": "При слабом или отсутствующем восприятии красного",
"ui.colour_vision_sample": "Пример",
"ui.colour_vision_tritan": "Тритан",
"ui.colour_vision_tritan_tip": "При слабом или отсутствующем восприятии синего",
"ui.combine": "Объединение",
"ui.coming_later": "появится позже",
"ui.concentric": "Концентричность",
+15
View File
@@ -349,6 +349,21 @@
"ui.coincident": "重合",
"ui.collapse": "折叠",
"ui.collinear": "共线",
"ui.colour_vision": "色觉",
"ui.colour_vision_deutan": "绿色觉异常(Deutan)",
"ui.colour_vision_deutan_tip": "绿色感知较弱或缺失时使用",
"ui.colour_vision_failed": "失败",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "模式",
"ui.colour_vision_note": "更改表示含义的颜色:草图状态、轴、折弯、分析和图标。如果使用 Windows 颜色滤镜,请保持“关闭”。",
"ui.colour_vision_off": "关闭",
"ui.colour_vision_off_tip": "主题本身的颜色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "红色觉异常(Protan)",
"ui.colour_vision_protan_tip": "红色感知较弱或缺失时使用",
"ui.colour_vision_sample": "示例",
"ui.colour_vision_tritan": "蓝色觉异常(Tritan)",
"ui.colour_vision_tritan_tip": "蓝色感知较弱或缺失时使用",
"ui.combine": "组合",
"ui.coming_later": "即将推出",
"ui.concentric": "同心",
+18 -7
View File
@@ -10,6 +10,10 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
};
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
// Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
let clear = dot(i.wp, d) >= top - slack;
var c = vec3<f32>(0.85, 0.22, 0.20);
if (facing && clear) { c = vec3<f32>(0.20, 0.75, 0.30); }
var c = u.ana_c[4].rgb;
if (facing && clear) { c = u.ana_c[3].rgb; } else { c = stripe(i, c); }
return out_color(vec4<f32>(c * lit, 1.0));
}
if (u.ana.x > 3.5) {
@@ -171,18 +175,25 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
}
if (u.ana.x < 2.5) {
let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
var c = vec3<f32>(0.95, 0.80, 0.15);
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
var c = u.ana_c[1].rgb;
if (a >= u.ana.y) { c = u.ana_c[0].rgb; }
if (a <= -u.ana.y) { c = stripe(i, u.ana_c[2].rgb); }
return out_color(vec4<f32>(c * lit, 1.0));
}
// Curvature from how fast the normal turns across the pixel.
let t = clamp(k * u.ana.y, 0.0, 1.0);
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
let c = select(mix(u.ana_c[6].rgb, u.ana_c[7].rgb, t * 2.0 - 1.0), mix(u.ana_c[5].rgb, u.ana_c[6].rgb, t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
}
/// A "bad" analysis face (negative draft, blocked): with the stripes on (colour vision modes), the
/// section cap's 45 degree hatch over it, so colour is not the only cue.
fn stripe(i: TOut, c: vec3<f32>) -> vec3<f32> {
if (u.ana_f.x < 0.5) { return c; }
let h = fract((i.pos.x + i.pos.y) / 9.0);
return select(c, c * 0.6, h < 0.22);
}
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
+97 -1
View File
@@ -1,5 +1,5 @@
//! Original light and dark palette values. Compiled by the UI theme boundary.
use crate::theme::Tokens;
use crate::theme::{AnalysisColors, IconColors, Tokens};
use egui::Color32;
impl Tokens {
pub const fn light() -> Tokens {
@@ -83,6 +83,48 @@ impl Tokens {
field: Color32::WHITE,
box_window: Color32::from_rgb(38, 120, 218),
box_crossing: Color32::from_rgb(40, 160, 80),
// Equal to `view::colors` (the engine's tags), so mapping a tag to its token
// changes nothing.
sketch_projected: Color32::from_rgb(150, 60, 190),
sketch_lost: Color32::from_rgb(200, 60, 90),
sketch_construction: Color32::from_rgb(230, 130, 40),
sketch_centerline: Color32::from_rgb(200, 110, 60),
triad: [Color32::from_rgb(220, 60, 60), Color32::from_rgb(60, 170, 60), Color32::from_rgb(60, 100, 230)],
gizmo: [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)],
bend_up: Color32::from_rgb(30, 140, 50),
bend_down: Color32::from_rgb(200, 90, 20),
interference: Color32::from_rgb(235, 45, 40),
analysis: AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
hatch: false,
},
icons: IconColors {
sheet: Color32::from_rgb(170, 200, 240),
sheet_2: Color32::from_rgb(150, 185, 235),
sheet_light: Color32::from_rgb(214, 229, 248),
sheet_line: Color32::from_rgb(127, 166, 218),
highlight: Color32::WHITE,
glint: Color32::from_rgba_premultiplied(160, 160, 160, 160),
folder: Color32::from_rgb(236, 200, 110),
plane: Color32::from_rgb(244, 214, 150),
fold: Color32::from_rgb(130, 140, 160),
stripe: Color32::from_gray(30),
sky: Color32::from_rgb(190, 205, 230),
floor: Color32::from_rgb(60, 62, 70),
paint: [Color32::from_rgb(220, 70, 60), Color32::from_rgb(70, 150, 230), Color32::from_rgb(240, 200, 60)],
axes: [Color32::from_rgb(210, 60, 60), Color32::from_rgb(60, 160, 60), Color32::from_rgb(60, 100, 220)],
access: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(220, 70, 60)],
draft: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(235, 200, 50)],
curvature: [Color32::from_rgb(60, 110, 230), Color32::from_rgb(70, 190, 90), Color32::from_rgb(225, 60, 45)],
interference: Color32::from_rgb(225, 70, 60),
warning: Color32::from_rgb(250, 200, 60),
agent: Color32::from_rgb(168, 85, 247),
marker_halo: Color32::from_black_alpha(150),
},
app_bar_icon: [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)],
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
}
}
pub const fn dark() -> Tokens {
@@ -135,6 +177,60 @@ impl Tokens {
t.field = Color32::from_rgb(30, 34, 41);
t.box_window = Color32::from_rgb(70, 150, 255);
t.box_crossing = Color32::from_rgb(80, 200, 120);
t.bend_up = Color32::from_rgb(110, 200, 120);
t.bend_down = Color32::from_rgb(240, 150, 80);
t
}
/// Colour vision, red-green (Protan and Deutan share it, #778): values of the theme's colours
/// that a weak red or green sense still tells apart. Each was measured against normal vision,
/// protan and deutan, with two simulations of each loss (`colour_vision_tests`).
pub(super) const fn red_green(self, dark: bool) -> Tokens {
let mut t = self;
let positive = [0.20, 0.45, 0.90];
let negative = [0.85, 0.40, 0.05];
t.triad[0] = Color32::from_rgb(200, 40, 40);
t.triad[1] = Color32::from_rgb(120, 210, 80);
t.gizmo[1] = Color32::from_rgb(140, 220, 90);
t.preview_sketch = Color32::from_rgb(80, 185, 145);
t.analysis.draft = [positive, [0.95, 0.90, 0.60], negative];
t.analysis.access = [positive, negative];
t.analysis.hatch = true;
// The icons that show the same meanings follow; on the dark toolbar the blue is a little
// stronger, to stay clear of the icon fill.
let good = if dark { Color32::from_rgb(35, 115, 245) } else { Color32::from_rgb(51, 115, 230) };
t.icons.axes[0] = t.triad[0];
t.icons.axes[1] = t.triad[1];
t.icons.access = [good, Color32::from_rgb(217, 102, 13)];
t.icons.draft = [good, Color32::from_rgb(242, 230, 153)];
if dark {
t.bend_up = Color32::from_rgb(110, 165, 255);
t.bend_down = Color32::from_rgb(240, 180, 70);
t.sketch_accent = Color32::from_rgb(150, 225, 195);
} else {
t.sketch_projected = Color32::from_rgb(205, 120, 215);
t.sketch_locked = Color32::from_rgb(60, 150, 120);
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
t.bend_up = Color32::from_rgb(30, 100, 210);
t.bend_down = Color32::from_rgb(200, 120, 0);
t.sketch_accent = Color32::from_rgb(60, 150, 105);
}
t
}
/// Colour vision, blue-yellow (Tritan, #778), measured against normal vision and tritan.
pub(super) const fn blue_yellow(self, dark: bool) -> Tokens {
let mut t = self;
t.analysis.hatch = true;
t.preview_sketch = Color32::from_rgb(125, 180, 105);
if dark {
t.sketch_locked = Color32::from_rgb(140, 225, 110);
t.box_crossing = Color32::from_rgb(240, 110, 170);
t.glyph_text = Color32::from_rgb(240, 180, 165);
} else {
t.sketch_locked = Color32::from_rgb(20, 165, 70);
t.box_crossing = Color32::from_rgb(200, 60, 120);
}
t
}
}
+2 -2
View File
@@ -334,7 +334,7 @@ fn finish(app: &mut SolveApp, ctx: &egui::Context) {
}
/// The pointer's colour: a violet no part of the UI uses.
use crate::icons::pointer::AGENT;
use crate::icons::pointer::agent;
/// Draw the pointer, its "Agent" tag and the click ripples over everything.
pub fn paint(app: &SolveApp, ctx: &egui::Context) {
@@ -345,7 +345,7 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("sc_agent_cursor")));
for (at, t0) in &app.agent.ripples {
let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32;
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k)));
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, agent().gamma_multiply(1.0 - k)));
}
crate::icons::pointer::paint(&painter, p);
}
+303
View File
@@ -0,0 +1,303 @@
//! 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");
}
+861
View File
@@ -0,0 +1,861 @@
//! Colour vision modes (#778), measured. Colours that appear together must stay apart (CIEDE2000
//! 10 or more) for normal vision and for the loss each mode is for, with two simulations of that
//! loss: Machado, Oliveira and Fernandes 2009 (severity 1) and Brettel, Viénot and Mollon 1997.
//! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published
//! with their paper, Brettel on white and its own anchors.
//!
//! When a token edit breaks a mode here, give that token a value in the mode's table
//! (`assets/styles/ui/palettes.rs`) that passes.
use std::collections::BTreeSet;
use egui::Color32;
use serde_json::json;
use crate::theme::{ColorVision, Tokens};
type Rgb8 = [u8; 3];
type V3 = [f64; 3];
type M3 = [[f64; 3]; 3];
/// The smallest difference a pair may have (CIEDE2000).
const FLOOR: f64 = 10.0;
/// The shading the analysis colours get in the shader (`lit`: 0.55 turned away, 1 facing the eye).
const LIT: [f64; 4] = [0.55, 0.7, 0.85, 1.0];
const MODES: [ColorVision; 3] = [ColorVision::Protan, ColorVision::Deutan, ColorVision::Tritan];
#[derive(Clone, Copy, Debug, PartialEq)]
enum Eye {
Normal,
Protan,
Deutan,
Tritan,
}
#[derive(Clone, Copy, Debug)]
enum Instrument {
Machado,
Brettel,
}
/// The loss a mode is for.
fn eye(mode: ColorVision) -> Eye {
match mode {
ColorVision::Off => Eye::Normal,
ColorVision::Protan => Eye::Protan,
ColorVision::Deutan => Eye::Deutan,
ColorVision::Tritan => Eye::Tritan,
}
}
// ---- the instruments ----
fn to_lin(c: f64) -> f64 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
fn to_srgb(c: f64) -> f64 {
if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}
fn mul(m: &M3, v: V3) -> V3 {
[0, 1, 2].map(|r| m[r][0] * v[0] + m[r][1] * v[1] + m[r][2] * v[2])
}
fn mat(a: &M3, b: &M3) -> M3 {
[0, 1, 2].map(|r| [0, 1, 2].map(|c| (0..3).map(|k| a[r][k] * b[k][c]).sum()))
}
fn inv(m: &M3) -> M3 {
let [[a, b, c], [d, e, f], [g, h, i]] = *m;
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
[
[(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det],
[(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det],
[(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det],
]
}
fn cross(u: V3, v: V3) -> V3 {
[u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
}
fn dot(u: V3, v: V3) -> f64 {
u[0] * v[0] + u[1] * v[1] + u[2] * v[2]
}
/// Machado, Oliveira, Fernandes 2009, severity 1.0, on linear sRGB (the authors' Table 1).
fn machado(eye: Eye) -> M3 {
match eye {
Eye::Protan => [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
Eye::Deutan => [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]],
Eye::Tritan => [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]],
Eye::Normal => [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
}
}
/// Linear sRGB to CIE XYZ (D65), IEC 61966-2-1.
const XYZ_FROM_LIN: M3 = [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]];
/// XYZ to cone responses (LMS), Smith and Pokorny 1975.
const LMS_FROM_XYZ: M3 = [[0.15514, 0.54312, -0.03286], [-0.15514, 0.45684, 0.03286], [0.0, 0.0, 0.00801]];
/// CIE 1931 2-degree colour matching functions (CIE 018:2019) at Brettel's anchors.
const CIE_475: V3 = [0.1421, 0.1126, 1.0419];
const CIE_485: V3 = [0.05795001, 0.1693, 0.6162];
const CIE_575: V3 = [0.8425, 0.9154, 0.0018];
const CIE_660: V3 = [0.1649, 0.061, 0.0];
/// The missing cone (0 L, 1 M, 2 S) and the two anchor wavelengths of its half-planes.
fn anchors(eye: Eye) -> (usize, V3, V3) {
match eye {
Eye::Protan => (0, CIE_475, CIE_575),
Eye::Deutan => (1, CIE_475, CIE_575),
Eye::Normal | Eye::Tritan => (2, CIE_485, CIE_660),
}
}
/// Brettel, Viénot, Mollon 1997: the missing cone's response is put back on one of two half-planes
/// through white and an anchor colour, chosen by the side of the white axis the colour is on.
fn brettel(eye: Eye, lin: V3) -> V3 {
let lms = mat(&LMS_FROM_XYZ, &XYZ_FROM_LIN);
let (c, w1, w2) = anchors(eye);
let white = mul(&lms, [1.0; 3]);
let (a1, a2) = (mul(&LMS_FROM_XYZ, w1), mul(&LMS_FROM_XYZ, w2));
let mut axis = [0.0; 3];
axis[c] = 1.0;
let sep = cross(white, axis);
let mut q = mul(&lms, lin);
let n = if dot(q, sep) * dot(a1, sep) >= 0.0 { cross(white, a1) } else { cross(white, a2) };
q[c] = -(0..3).filter(|k| *k != c).map(|k| n[k] * q[k]).sum::<f64>() / n[c];
mul(&inv(&lms), q)
}
/// What an eye sees of an sRGB colour, as sRGB 0..1.
fn simulate(c: Rgb8, eye: Eye, how: Instrument) -> V3 {
let srgb = c.map(|v| f64::from(v) / 255.0);
if eye == Eye::Normal {
return srgb;
}
let lin = srgb.map(to_lin);
let out = match how {
Instrument::Machado => mul(&machado(eye), lin),
Instrument::Brettel => brettel(eye, lin),
};
out.map(|v| to_srgb(v.clamp(0.0, 1.0)))
}
/// CIELAB (D65) of an sRGB colour.
fn lab(srgb: V3) -> V3 {
let [x, y, z] = mul(&XYZ_FROM_LIN, srgb.map(to_lin));
let f = |t: f64| if t > (6.0f64 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f64 / 29.0).powi(2)) + 4.0 / 29.0 };
let (fx, fy, fz) = (f(x / 0.95047), f(y), f(z / 1.08883));
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
/// CIEDE2000 (Sharma, Wu, Dalal 2005).
fn de2000(l1: V3, l2: V3) -> f64 {
let ([lightness1, a1, b1], [lightness2, a2, b2]) = (l1, l2);
let (c1, c2) = (a1.hypot(b1), a2.hypot(b2));
let cb = (c1 + c2) / 2.0;
let g = 0.5 * (1.0 - (cb.powi(7) / (cb.powi(7) + 25f64.powi(7))).sqrt());
let (a1p, a2p) = ((1.0 + g) * a1, (1.0 + g) * a2);
let (c1p, c2p) = (a1p.hypot(b1), a2p.hypot(b2));
let hue = |b: f64, a: f64, c: f64| if c == 0.0 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
let (h1p, h2p) = (hue(b1, a1p, c1p), hue(b2, a2p, c2p));
let dlp = lightness2 - lightness1;
let dcp = c2p - c1p;
let dhp = if c1p * c2p == 0.0 {
0.0
} else if (h2p - h1p).abs() <= 180.0 {
h2p - h1p
} else if h2p - h1p > 180.0 {
h2p - h1p - 360.0
} else {
h2p - h1p + 360.0
};
let dhp_big = 2.0 * (c1p * c2p).sqrt() * (dhp / 2.0).to_radians().sin();
let lbp = (lightness1 + lightness2) / 2.0;
let cbp = (c1p + c2p) / 2.0;
let hbp = if c1p * c2p == 0.0 {
h1p + h2p
} else if (h1p - h2p).abs() <= 180.0 {
(h1p + h2p) / 2.0
} else if h1p + h2p < 360.0 {
(h1p + h2p + 360.0) / 2.0
} else {
(h1p + h2p - 360.0) / 2.0
};
let cos = |deg: f64| deg.to_radians().cos();
let t = 1.0 - 0.17 * cos(hbp - 30.0) + 0.24 * cos(2.0 * hbp) + 0.32 * cos(3.0 * hbp + 6.0) - 0.20 * cos(4.0 * hbp - 63.0);
let dtheta = 30.0 * (-((hbp - 275.0) / 25.0).powi(2)).exp();
let rc = 2.0 * (cbp.powi(7) / (cbp.powi(7) + 25f64.powi(7))).sqrt();
let sl = 1.0 + 0.015 * (lbp - 50.0).powi(2) / (20.0 + (lbp - 50.0).powi(2)).sqrt();
let sc = 1.0 + 0.045 * cbp;
let sh = 1.0 + 0.015 * cbp * t;
let rt = -(2.0 * dtheta).to_radians().sin() * rc;
((dlp / sl).powi(2) + (dcp / sc).powi(2) + (dhp_big / sh).powi(2) + rt * (dcp / sc) * (dhp_big / sh)).sqrt()
}
fn diff(a: Rgb8, b: Rgb8, eye: Eye, how: Instrument) -> f64 {
de2000(lab(simulate(a, eye, how)), lab(simulate(b, eye, how)))
}
fn normal(a: Rgb8, b: Rgb8) -> f64 {
diff(a, b, Eye::Normal, Instrument::Machado)
}
/// The smallest difference for normal vision and for the mode's loss (both instruments).
fn worst(mode: ColorVision, a: Rgb8, b: Rgb8) -> f64 {
let e = eye(mode);
normal(a, b).min(diff(a, b, e, Instrument::Machado)).min(diff(a, b, e, Instrument::Brettel))
}
// ---- the colours that appear together ----
fn rgb(c: Color32) -> Rgb8 {
[c.r(), c.g(), c.b()]
}
/// A translucent colour over an opaque ground, in sRGB (close to egui's blend).
fn over(c: Color32, ground: Color32) -> Rgb8 {
let [r, g, b, a] = c.to_srgba_unmultiplied();
let k = f64::from(a) / 255.0;
let mix = |f: u8, g: u8| (f64::from(f) * k + f64::from(g) * (1.0 - k)).round() as u8;
[mix(r, ground.r()), mix(g, ground.g()), mix(b, ground.b())]
}
/// An analysis colour as the shader shades it.
fn lit(c: [f32; 3], k: f64) -> Rgb8 {
c.map(|x| ((f64::from(x) * 255.0).round() * k).min(255.0).round() as u8)
}
struct Group {
name: &'static str,
/// Colours that must differ from each other.
members: Vec<(&'static str, Rgb8)>,
/// What they are drawn on.
grounds: Vec<(&'static str, Rgb8)>,
}
fn group(name: &'static str, members: &[(&'static str, Color32)], grounds: &[(&'static str, Color32)]) -> Group {
for (n, c) in members.iter().chain(grounds) {
assert_eq!(c.a(), 255, "{name}: {n} is translucent; put it over its ground first");
}
Group { name, members: members.iter().map(|(n, c)| (*n, rgb(*c))).collect(), grounds: grounds.iter().map(|(n, c)| (*n, rgb(*c))).collect() }
}
/// Every group of meaning-carrying colours that appear together (and the icons' colours, drawn on
/// the toolbar with their caller's ink, fill and accent). Each colour is named by its `Tokens`
/// field, so `every_token_is_measured_or_carries_no_meaning` knows which tokens are measured.
fn groups(t: &Tokens) -> Vec<Group> {
let lift = |c: Color32| {
Color32::from_rgb(c.r().saturating_add(t.hover_face_lift), c.g().saturating_add(t.hover_face_lift), c.b().saturating_add(t.hover_face_lift))
};
let ic = &t.icons;
let a = &t.analysis;
let mut out = vec![
group(
"sketch lines",
&[
("sketch", t.sketch),
("sketch_fixed", t.sketch_fixed),
("sketch_locked", t.sketch_locked),
("sketch_projected", t.sketch_projected),
("sketch_lost", t.sketch_lost),
("sketch_construction", t.sketch_construction),
("sketch_centerline", t.sketch_centerline),
("preview_sketch", t.preview_sketch),
],
&[("viewport_top", t.viewport_top), ("viewport_bottom", t.viewport_bottom)],
),
group(
"sketch points",
&[("sketch_point", t.sketch_point), ("sketch_point_free", t.sketch_point_free), ("sketch_locked", t.sketch_locked)],
&[("viewport_top", t.viewport_top)],
),
group(
"timeline",
&[("timeline_item", t.timeline_item), ("timeline_item_error", t.timeline_item_error), ("hover", t.hover), ("accent_soft", t.accent_soft)],
&[],
),
group("UI text", &[("error", t.error), ("warning", t.warning), ("text", t.text)], &[("panel", t.panel)]),
group("origin axes", &[("axis_x", t.axis_x), ("axis_y", t.axis_y), ("axis_z", t.axis_z)], &[("viewport_top", t.viewport_top)]),
group("view triad", &[("triad[0]", t.triad[0]), ("triad[1]", t.triad[1]), ("triad[2]", t.triad[2])], &[("viewport_top", t.viewport_top)]),
group("gizmo", &[("gizmo[0]", t.gizmo[0]), ("gizmo[1]", t.gizmo[1]), ("gizmo[2]", t.gizmo[2])], &[("viewport_top", t.viewport_top)]),
group("bend lines", &[("bend_up", t.bend_up), ("bend_down", t.bend_down)], &[("viewport_top", t.viewport_top)]),
group("box select", &[("box_window", t.box_window), ("box_crossing", t.box_crossing)], &[("viewport_top", t.viewport_top)]),
group(
"selection",
&[
("body", t.body),
("sel_face", t.sel_face),
("body_selected", t.body_selected),
("hover_face_lift", lift(t.body)),
("preview_add", t.preview_add),
],
&[],
),
group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]),
group("interference", &[("interference", t.interference)], &[("body", t.body)]),
// The scale's three stops, unlit. Shaded (a stop at one angle to the light, the next at
// another), middle and tight merge for a red-green loss (3.3 protan, 5.2 deutan) and flat
// and middle for tritan (4.6). That is the scale's own fault, in Off too: #975.
group(
"curvature map",
&[
("analysis.curvature[0]", crate::theme::srgb(a.curvature[0])),
("analysis.curvature[1]", crate::theme::srgb(a.curvature[1])),
("analysis.curvature[2]", crate::theme::srgb(a.curvature[2])),
],
&[],
),
group(
"ribbon accents",
&[("glyph_text", t.glyph_text), ("sketch_accent", t.sketch_accent), ("construction_geom", t.construction_geom)],
&[("toolbar", t.toolbar)],
),
group(
"origin icon",
&[("icons.axes[0]", ic.axes[0]), ("icons.axes[1]", ic.axes[1]), ("icons.axes[2]", ic.axes[2])],
&[("toolbar", t.toolbar)],
),
group(
"accessibility icon",
&[("icons.access[0]", ic.access[0]), ("icons.access[1]", ic.access[1]), ("icon_fill", t.icon_fill), ("icon", t.icon)],
&[],
),
group("draft icon", &[("icons.draft[0]", ic.draft[0]), ("icons.draft[1]", ic.draft[1]), ("icon", t.icon)], &[]),
group(
"curvature icon",
&[("icons.curvature[0]", ic.curvature[0]), ("icons.curvature[1]", ic.curvature[1]), ("icons.curvature[2]", ic.curvature[2])],
&[],
),
group(
"appearance icon",
&[("icons.paint[0]", ic.paint[0]), ("icons.paint[1]", ic.paint[1]), ("icons.paint[2]", ic.paint[2]), ("icon_fill", t.icon_fill)],
&[],
),
group("environment icon", &[("icons.sky", ic.sky), ("icons.floor", ic.floor), ("icons.highlight", ic.highlight)], &[]),
group("warning icon", &[("icons.warning", ic.warning), ("icon", t.icon)], &[("toolbar", t.toolbar)]),
group("interference icon", &[("icons.interference", ic.interference), ("icon", t.icon)], &[("toolbar", t.toolbar)]),
group("folder icons", &[("icons.folder", ic.folder), ("icons.plane", ic.plane), ("icons.fold", ic.fold), ("icon", t.icon)], &[]),
group("sheet icons", &[("icons.sheet", ic.sheet), ("icons.sheet_2", ic.sheet_2), ("icon", t.icon)], &[]),
group(
"app bar buttons",
&[("app_bar_icon[0]", t.app_bar_icon[0]), ("app_bar_icon[1]", t.app_bar_icon[1]), ("app_bar_icon[2]", t.app_bar_icon[2])],
&[("app_bar", t.app_bar)],
),
group("logo", &[("logo[0]", t.logo[0]), ("logo[1]", t.logo[1]), ("logo[2]", t.logo[2])], &[("app_bar", t.app_bar)]),
];
// The removed part of a cut preview is translucent over the body.
if let Some(sel) = out.iter_mut().find(|g| g.name == "selection") {
sel.members.push(("preview_cut", over(t.preview_cut, t.body)));
}
out
}
/// Measured by the shaded check in `every_mode_keeps_colours_apart_for_its_eye`.
const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"];
/// Tokens no colour vision mode needs to keep apart, and why. A new token fails
/// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here.
const NO_MEANING: &[(&str, &str)] = &[
// The window's chrome: grounds, text and borders (measured as grounds where meaning sits on them).
("panel_header", "chrome"),
("border", "chrome"),
("text_dim", "secondary text"),
("accent", "the one interface accent"),
("timeline", "the timeline's ground"),
("dialog_bg", "chrome"),
("dialog_border", "chrome"),
("chip", "a label's ground"),
("heading_bg", "chrome"),
("subheading_bg", "chrome"),
("overlay", "a chip's ground over the view"),
("app_bar_text", "text"),
("app_bar_hover", "a hovered tab, translucent white"),
("app_bar_tab_edge", "an inactive tab's outline, translucent white"),
("timeline_marker", "the rollback marker, alone"),
("field", "a field's ground"),
// Highlights: shades of the accent blue over what they mark, by design close to each other.
("sel_edge", "selection highlight"),
("sel_edge_rim", "selection highlight"),
("hover_edge", "hover highlight"),
("hover_edge_halo", "hover highlight"),
("sel_profile", "selection highlight"),
("sel_profile_edge", "selection highlight"),
("hover_profile", "hover highlight"),
("hover_profile_edge", "hover highlight"),
("sel_vertex", "selection highlight"),
("rubber_band", "the shape being drawn"),
("manipulator", "a drag handle, alone"),
// Planes, points, the grid and the model's edges: one neutral each, told apart by shape.
("origin_plane", "a plane, told apart by its place"),
("origin_plane_edge", "a plane's edge"),
("origin_plane_hover", "hover highlight"),
("origin_point", "the origin, alone"),
("construction_plane", "a plane"),
("body_edge", "the model's edges"),
("grid", "the grid"),
("grid_major", "the grid"),
("profile_fill", "a closed profile's tint"),
("dimension", "dimension text"),
("dimension_driven", "dimension text, dimmed"),
("glyph_bg", "a constraint glyph's ground"),
("glyph_edge", "a constraint glyph's edge"),
// The navigation cube: labelled faces.
("cube_face", "labelled"),
("cube_border", "labelled"),
("cube_text", "labelled"),
("cube_hover", "hover highlight"),
// Not a colour, or the end of a mark that is measured elsewhere.
("analysis.hatch", "a flag: stripes on the failing faces"),
("logo[3]", "the mark's white edge"),
// Icon colours that are a shade of a measured one, or one mark alone.
("icons.sheet_light", "a lighter shade of a sheet"),
("icons.sheet_line", "lines on a sheet"),
("icons.glint", "a highlight"),
("icons.stripe", "zebra stripes, grey"),
("icons.agent", "the agent's pointer, alone"),
("icons.marker_halo", "a dark halo under a marker"),
];
/// Every token field, by path (`sketch`, `analysis.draft`, `logo[3]`…): the arrays of distinct
/// meanings by element where only some elements are measured.
fn token_fields(t: &Tokens) -> BTreeSet<String> {
let text = format!("{t:#?}");
let mut out = BTreeSet::new();
let mut parent: Option<String> = None;
for line in text.lines() {
let indent = line.len() - line.trim_start().len();
let Some((name, rest)) = line.trim_start().split_once(": ") else { continue };
match indent {
4 if rest.ends_with('{') => parent = Some(name.to_string()),
4 => {
parent = None;
out.insert(name.to_string());
}
8 => {
if let Some(p) = &parent {
out.insert(format!("{p}.{name}"));
}
}
_ => {}
}
}
out
}
/// A group member's token field (`triad[0]` is part of `triad`).
fn field_of(name: &str) -> &str {
name.split('[').next().unwrap_or(name)
}
#[test]
fn every_token_is_measured_or_carries_no_meaning() {
for t in [Tokens::light(), Tokens::dark()] {
let measured: BTreeSet<&str> =
groups(&t).iter().flat_map(|g| g.members.iter().chain(&g.grounds).map(|(n, _)| field_of(n))).chain(SHADED).collect();
// An element of an array (`logo[3]`) is listed for the record; its array is measured.
let listed: BTreeSet<&str> = NO_MEANING.iter().map(|(n, _)| field_of(n)).collect();
let fields = token_fields(&t);
let unplaced: Vec<&String> = fields.iter().filter(|f| !measured.contains(f.as_str()) && !listed.contains(f.as_str())).collect();
assert!(unplaced.is_empty(), "tokens neither measured (`groups`) nor listed in `NO_MEANING`: {unplaced:?}");
let stale: Vec<&&str> = measured.iter().chain(&listed).filter(|f| !fields.contains(**f)).collect();
assert!(stale.is_empty(), "names that are not token fields: {stale:?}");
let both: Vec<&str> = NO_MEANING.iter().map(|(n, _)| *n).filter(|n| !n.contains('[') && measured.contains(n)).collect();
assert!(both.is_empty(), "measured and also listed as carrying no meaning: {both:?}");
}
}
/// Every pair in the groups: (group, name a, a, name b, b).
fn pairs(t: &Tokens) -> Vec<(String, Rgb8, Rgb8)> {
let mut out = Vec::new();
for g in groups(t) {
for (i, (na, a)) in g.members.iter().enumerate() {
for (nb, b) in g.members.iter().skip(i + 1).chain(&g.grounds) {
out.push((format!("{}: {na} vs {nb}", g.name), *a, *b));
}
}
}
out
}
/// Pairs under the floor that the tables leave as they are (decided for #778). Each is close to
/// the floor for normal vision too, and construction and centerline lines also differ by their
/// dash patterns.
fn left_as_they_are(mode: ColorVision, dark: bool) -> BTreeSet<String> {
let mut v = vec!["sketch lines: sketch_construction vs sketch_centerline"];
if mode == ColorVision::Tritan {
v.push("sketch lines: sketch_lost vs sketch_centerline");
v.push(if dark { "timeline: hover vs accent_soft" } else { "timeline: timeline_item vs accent_soft" });
v.push("selection: sel_face vs preview_add");
}
v.into_iter().map(String::from).collect()
}
// ---- the tests ----
/// Sharma, Wu, Dalal, "The CIEDE2000 Color-Difference Formula: Implementation Notes,
/// Supplementary Test Data, and Mathematical Observations", Color Research and Application 30
/// (2005), Table 1: L*, a*, b* of each pair, then the difference.
const SHARMA: [[f64; 7]; 34] = [
[50.0000, 2.6772, -79.7751, 50.0000, 0.0000, -82.7485, 2.0425],
[50.0000, 3.1571, -77.2803, 50.0000, 0.0000, -82.7485, 2.8615],
[50.0000, 2.8361, -74.0200, 50.0000, 0.0000, -82.7485, 3.4412],
[50.0000, -1.3802, -84.2814, 50.0000, 0.0000, -82.7485, 1.0000],
[50.0000, -1.1848, -84.8006, 50.0000, 0.0000, -82.7485, 1.0000],
[50.0000, -0.9009, -85.5211, 50.0000, 0.0000, -82.7485, 1.0000],
[50.0000, 0.0000, 0.0000, 50.0000, -1.0000, 2.0000, 2.3669],
[50.0000, -1.0000, 2.0000, 50.0000, 0.0000, 0.0000, 2.3669],
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0009, 7.1792],
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0010, 7.1792],
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0011, 7.2195],
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0012, 7.2195],
[50.0000, -0.0010, 2.4900, 50.0000, 0.0009, -2.4900, 4.8045],
[50.0000, -0.0010, 2.4900, 50.0000, 0.0010, -2.4900, 4.8045],
[50.0000, -0.0010, 2.4900, 50.0000, 0.0011, -2.4900, 4.7461],
[50.0000, 2.5000, 0.0000, 50.0000, 0.0000, -2.5000, 4.3065],
[50.0000, 2.5000, 0.0000, 73.0000, 25.0000, -18.0000, 27.1492],
[50.0000, 2.5000, 0.0000, 61.0000, -5.0000, 29.0000, 22.8977],
[50.0000, 2.5000, 0.0000, 56.0000, -27.0000, -3.0000, 31.9030],
[50.0000, 2.5000, 0.0000, 58.0000, 24.0000, 15.0000, 19.4535],
[50.0000, 2.5000, 0.0000, 50.0000, 3.1736, 0.5854, 1.0000],
[50.0000, 2.5000, 0.0000, 50.0000, 3.2972, 0.0000, 1.0000],
[50.0000, 2.5000, 0.0000, 50.0000, 1.8634, 0.5757, 1.0000],
[50.0000, 2.5000, 0.0000, 50.0000, 3.2592, 0.3350, 1.0000],
[60.2574, -34.0099, 36.2677, 60.4626, -34.1751, 39.4387, 1.2644],
[63.0109, -31.0961, -5.8663, 62.8187, -29.7946, -4.0864, 1.2630],
[61.2901, 3.7196, -5.3901, 61.4292, 2.2480, -4.9620, 1.8731],
[35.0831, -44.1164, 3.7933, 35.0232, -40.0716, 1.5901, 1.8645],
[22.7233, 20.0904, -46.6940, 23.0331, 14.9730, -42.5619, 2.0373],
[36.4612, 47.8580, 18.3852, 36.2715, 50.5065, 21.2231, 1.4146],
[90.8027, -2.0831, 1.4410, 91.1528, -1.6435, 0.0447, 1.4441],
[90.9257, -0.5406, -0.9208, 88.6381, -0.8985, -0.7239, 1.5381],
[6.7747, -0.2908, -2.4247, 5.8714, -0.0985, -2.2286, 0.6377],
[2.0776, 0.0795, -1.1350, 0.9033, -0.0636, -0.5514, 0.9082],
];
#[test]
fn ciede2000_reproduces_sharmas_test_pairs() {
for (i, p) in SHARMA.iter().enumerate() {
let got = de2000([p[0], p[1], p[2]], [p[3], p[4], p[5]]);
assert!((got - p[6]).abs() < 1e-4, "pair {}: {got}, published {}", i + 1, p[6]);
// The formula is symmetric.
assert!((de2000([p[3], p[4], p[5]], [p[0], p[1], p[2]]) - got).abs() < 1e-9, "pair {}", i + 1);
}
}
#[test]
fn the_simulations_keep_white_and_brettels_anchors() {
let lms = mat(&LMS_FROM_XYZ, &XYZ_FROM_LIN);
for e in [Eye::Protan, Eye::Deutan, Eye::Tritan] {
let w = brettel(e, [1.0; 3]);
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-9), "{e:?}: white -> {w:?}");
let (_, a1, a2) = anchors(e);
for a in [a1, a2] {
let lin = mul(&inv(&lms), mul(&LMS_FROM_XYZ, a));
let back = brettel(e, lin);
assert!(lin.iter().zip(back).all(|(x, y)| (x - y).abs() < 1e-9), "{e:?}: anchor {lin:?} -> {back:?}");
}
let m = machado(e);
assert!(m.iter().all(|row| (row.iter().sum::<f64>() - 1.0).abs() < 1e-5), "{e:?}: Machado keeps white");
}
// Known losses: pure red and green merge for a red-green loss, blue and green for a blue one.
let (red, green, blue) = ([220, 60, 60], [60, 170, 60], [60, 100, 230]);
assert!(normal(red, green) > 60.0);
for how in [Instrument::Machado, Instrument::Brettel] {
assert!(diff(red, green, Eye::Deutan, how) < 10.0, "{how:?}");
assert!(diff(blue, green, Eye::Tritan, how) < diff(blue, green, Eye::Normal, how), "{how:?}");
}
}
#[test]
fn off_gives_the_theme_unchanged() {
assert_eq!(Tokens::themed(false, ColorVision::Off), Tokens::light());
assert_eq!(Tokens::themed(true, ColorVision::Off), Tokens::dark());
for dark in [false, true] {
assert!(!Tokens::themed(dark, ColorVision::Off).analysis.hatch);
// Protan and Deutan share the red-green table.
assert_eq!(Tokens::themed(dark, ColorVision::Protan), Tokens::themed(dark, ColorVision::Deutan));
}
}
/// The token fields whose `Debug` text differs (`triad`, `analysis.draft`…), so a field cannot
/// change in a mode by accident.
fn changed_fields(a: &Tokens, b: &Tokens) -> BTreeSet<String> {
let (la, lb) = (format!("{a:#?}"), format!("{b:#?}"));
let (la, lb): (Vec<&str>, Vec<&str>) = (la.lines().collect(), lb.lines().collect());
assert_eq!(la.len(), lb.len());
let mut out = BTreeSet::new();
for i in (0..la.len()).filter(|i| la[*i] != lb[*i]) {
// The named fields above this line, innermost first, up to a field of `Tokens`.
let mut path = Vec::new();
let mut indent = usize::MAX;
for line in la[..=i].iter().rev() {
let here = line.len() - line.trim_start().len();
if here < indent
&& let Some((name, _)) = line.trim_start().split_once(':')
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
path.push(name.to_string());
indent = here;
}
if indent <= 4 {
break;
}
}
path.reverse();
out.insert(path.join("."));
}
out
}
#[test]
fn each_mode_changes_only_its_table() {
let set = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<BTreeSet<_>>();
let red_green_both = [
"triad",
"gizmo",
"analysis.draft",
"analysis.access",
"analysis.hatch",
"icons.axes",
"icons.access",
"icons.draft",
"bend_up",
"bend_down",
"sketch_accent",
"preview_sketch",
];
let mut red_green_light = red_green_both.to_vec();
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]);
for (dark, mode, want) in [
(false, ColorVision::Protan, set(&red_green_light)),
(true, ColorVision::Protan, set(&red_green_both)),
(false, ColorVision::Deutan, set(&red_green_light)),
(true, ColorVision::Deutan, set(&red_green_both)),
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])),
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])),
] {
let base = Tokens::themed(dark, ColorVision::Off);
assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}");
}
}
#[test]
fn every_mode_keeps_colours_apart_for_its_eye() {
let mut report = Vec::new();
for dark in [false, true] {
let off = Tokens::themed(dark, ColorVision::Off);
for mode in MODES {
let on = Tokens::themed(dark, mode);
let mut left = BTreeSet::new();
for ((name, a0, b0), (_, a1, b1)) in pairs(&off).into_iter().zip(pairs(&on)) {
// Close for everybody by design (hover tints, a shade of one hue), and not changed
// by the mode: not a colour vision fault.
if normal(a0, b0) < FLOOR && (a0, b0) == (a1, b1) {
continue;
}
let w = worst(mode, a1, b1);
if w < FLOOR {
report.push(format!("{mode:?} dark={dark} {name}: {w:.1} (before {:.1})", worst(mode, a0, b0)));
left.insert(name);
}
}
assert_eq!(left, left_as_they_are(mode, dark), "{mode:?}, dark {dark}: {report:#?}");
// Draft and Accessibility, for faces at any angle to the light against each other.
let a = &on.analysis;
let shaded = [(a.draft[0], a.draft[1]), (a.draft[0], a.draft[2]), (a.draft[1], a.draft[2]), (a.access[0], a.access[1])];
for (p, q) in shaded {
for x in LIT {
for y in LIT {
let w = worst(mode, lit(p, x), lit(q, y));
assert!(w >= FLOOR, "{mode:?}, dark {dark}: {p:?} lit {x} vs {q:?} lit {y}: {w:.1}");
}
}
}
}
}
}
#[test]
fn the_shader_gets_the_modes_colours_and_its_stripes() {
for dark in [false, true] {
let off = crate::gpu::analysis_colors(&Tokens::themed(dark, ColorVision::Off).analysis);
assert_eq!(off[32], 0.0, "no stripes in Off");
for mode in MODES {
let t = Tokens::themed(dark, mode);
let v = crate::gpu::analysis_colors(&t.analysis);
assert_eq!(v[32], 1.0, "{mode:?}: stripes on the bad faces");
assert_eq!(v[..3], t.analysis.draft[0], "{mode:?}");
assert_eq!(v[16..19], t.analysis.access[1], "{mode:?}");
}
}
}
// ---- the preference ----
/// Every message the Colour vision section shows (the heading, the mode names and their
/// tooltips, the sample's names and the note) is in every catalogue, translated: in Japanese,
/// Korean, Russian and Chinese none is the English text. (German keeps "Protan", "Deutan" and
/// "Tritan".)
#[test]
fn the_sections_messages_are_in_every_catalog() {
let load = |text: &str| -> serde_json::Map<String, serde_json::Value> { serde_json::from_str(text).unwrap() };
let english = load(include_str!("../../../assets/locales/en.json"));
let ids: Vec<&str> = ColorVision::ALL
.into_iter()
.flat_map(|m| [m.label_id(), m.help_id()])
.chain(crate::prefs::COLOUR_VISION_SAMPLE)
.chain(["ui.colour_vision", "ui.colour_vision_mode", "ui.colour_vision_sample", "ui.colour_vision_note"])
.collect();
for (lang, text) in [
("de", include_str!("../../../assets/locales/de.json")),
("ja", include_str!("../../../assets/locales/ja.json")),
("ko", include_str!("../../../assets/locales/ko.json")),
("ru", include_str!("../../../assets/locales/ru.json")),
("zh", include_str!("../../../assets/locales/zh.json")),
] {
let catalog = load(text);
for id in &ids {
let (Some(en), Some(here)) = (english.get(*id), catalog.get(*id)) else {
panic!("{lang}: missing message {id} (English: {})", english.contains_key(*id));
};
assert!(lang == "de" || here != en, "{lang}: {id} is the English text {en}");
}
}
}
fn app() -> crate::SolveApp {
crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default())
}
#[test]
fn saved_preferences_load_the_mode_and_a_bad_one_loads_as_off() {
use crate::prefs::Prefs;
// Saved before the mode existed.
let old: Prefs = serde_json::from_value(json!({"theme": "light"})).unwrap();
assert_eq!((old.color_vision, old.theme.as_str()), (ColorVision::Off, "light"));
for mode in ColorVision::ALL {
let p = Prefs { color_vision: mode, ..Prefs::default() };
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["color_vision"], mode.id());
assert_eq!(serde_json::from_value::<Prefs>(v).unwrap(), p);
}
// A value this version doesn't know (a later SolidCraft's mode, a hand edit) is Off, and the
// rest of the preferences still load.
for bad in [json!("achromat"), json!("PROTAN"), json!(""), json!(3), json!(null), json!(true), json!({"protan": 1}), json!(["tritan"])] {
let p: Prefs = serde_json::from_value(json!({"theme": "light", "color_vision": bad})).unwrap();
assert_eq!((p.color_vision, p.theme.as_str()), (ColorVision::Off, "light"), "{bad}");
}
}
#[test]
fn ui_prefs_sets_each_mode_and_refuses_anything_else() {
let mut app = app();
for mode in ColorVision::ALL {
let out = crate::prefs::set(&mut app, &json!({"color_vision": mode.id()})).unwrap();
assert_eq!(out["color_vision"], mode.id());
assert_eq!(app.preferences.color_vision, mode);
}
let long = "p".repeat(1 << 20);
for bad in [
json!("purple"),
json!("Protan"),
json!(" protan"),
json!(""),
json!(long),
json!(2),
json!(-1.5),
json!(null),
json!(false),
json!([]),
json!({}),
] {
let before = app.preferences.clone();
let err = crate::prefs::set(&mut app, &json!({"color_vision": bad, "theme": "light"})).unwrap_err();
assert!(err.contains("off, protan, deutan or tritan"), "{err}");
assert_eq!(app.preferences, before, "a refused value changes nothing");
}
}
/// Random values (fixed seed): strings near the four ids and far from them, numbers, nesting.
/// `ui.prefs` takes exactly the four ids and refuses the rest without a change; saved
/// preferences load every value, unknown ones as Off. Nothing panics.
#[test]
fn random_values_never_panic_and_only_the_four_ids_are_taken() {
fn value(next: &mut impl FnMut() -> u64, depth: u32) -> serde_json::Value {
let ids = ["off", "protan", "deutan", "tritan"];
match next() % if depth > 3 { 6 } else { 8 } {
0 => json!(ids[(next() % 4) as usize]),
1 => {
// A near miss: one id with a character changed, added or taken away.
let mut s: Vec<char> = ids[(next() % 4) as usize].chars().collect();
let i = (next() % s.len() as u64) as usize;
match next() % 3 {
0 => s[i] = char::from_u32((next() % 0x2FFF) as u32).unwrap_or('?'),
1 => s.insert(i, ['\0', ' ', 'É', '\u{301}', 'ـ'][(next() % 5) as usize]),
_ => {
s.remove(i);
}
}
json!(s.into_iter().collect::<String>())
}
2 => json!((0..next() % 40).map(|_| char::from_u32((next() % 0x10FFFF) as u32).unwrap_or('x')).collect::<String>()),
3 => json!(next() as i64),
4 => json!(Some(f64::from_bits(next())).filter(|x| x.is_finite()).unwrap_or(0.5)),
5 => [json!(null), json!(true), json!(false)][(next() % 3) as usize].clone(),
6 => json!((0..next() % 4).map(|_| value(next, depth + 1)).collect::<Vec<_>>()),
_ => json!({"color_vision": value(next, depth + 1)}),
}
}
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let mut app = app();
let mut taken = 0;
for _ in 0..4000 {
let v = value(&mut next, 0);
let id = v.as_str().and_then(ColorVision::from_id);
let before = app.preferences.clone();
match crate::prefs::set(&mut app, &json!({"color_vision": v})) {
Ok(_) => {
taken += 1;
assert_eq!(Some(app.preferences.color_vision), id, "{v}");
}
Err(e) => {
assert!(id.is_none() && e.contains("off, protan, deutan or tritan"), "{v}: {e}");
assert_eq!(app.preferences, before, "{v}");
}
}
let saved: crate::prefs::Prefs = serde_json::from_value(json!({"theme": "light", "color_vision": v})).unwrap();
assert_eq!((saved.color_vision, saved.theme.as_str()), (id.unwrap_or_default(), "light"), "{v}");
}
assert!(taken > 200, "the four ids came up {taken} times");
}
#[test]
fn cancel_puts_the_mode_back_ok_keeps_it_and_restore_defaults_resets_graphics_only() {
let mut app = app();
let control = |app: &mut crate::SolveApp, p: serde_json::Value| crate::prefs::control(app, &p).unwrap();
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"color_vision": "protan"}));
assert_eq!(app.preferences.color_vision, ColorVision::Protan);
control(&mut app, json!({"button": "cancel"}));
assert_eq!(app.preferences.color_vision, ColorVision::Off);
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"color_vision": "tritan", "button": "ok"}));
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
// Restore Defaults on another page leaves it.
control(&mut app, json!({"open": "General"}));
control(&mut app, json!({"button": "defaults"}));
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
control(&mut app, json!({"open": "Graphics"}));
control(&mut app, json!({"button": "defaults"}));
assert_eq!(app.preferences.color_vision, ColorVision::Off);
control(&mut app, json!({"button": "ok"}));
}
+2 -2
View File
@@ -630,14 +630,14 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"ui.render" => {
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize;
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize;
// In the window's theme, and cut by the section the viewport shows (an open Section
// In the window's theme and colour vision mode, and cut by the section the viewport shows (an open Section
// Analysis dialog's preview, a sketch's Slice), not only the committed one.
let scene = solidcraft_engine::view::scene_with_palette(
&app.session,
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark),
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
);
let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") };
match s("path") {
+3 -1
View File
@@ -866,7 +866,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
continue;
}
let k = 0.55 + 0.45 * facing;
let fill = Color32::from_rgba_unmultiplied((235.0 * k) as u8, (45.0 * k) as u8, (40.0 * k) as u8, 210);
let shade = |c: u8| (f64::from(c) * k) as u8;
let ic = t.interference;
let fill = Color32::from_rgba_unmultiplied(shade(ic.r()), shade(ic.g()), shade(ic.b()), 210);
let sp: Vec<Pos2> = ps.iter().filter_map(|p| proj.to_screen(*p)).collect();
if sp.len() == 3 {
let base = em.vertices.len() as u32;
+4 -6
View File
@@ -799,13 +799,11 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
Some(if flip { -n } else { n })
}
/// Bend line colours: up and down (legible on either theme).
/// Bend line colours: up and down (theme tokens, legible on either theme and in every colour
/// vision mode).
fn bend_colors() -> (Color32, Color32) {
if crate::theme::is_dark() {
(Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80))
} else {
(Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20))
}
let t = crate::theme::Tokens::get();
(t.bend_up, t.bend_down)
}
/// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours)
+1 -9
View File
@@ -114,14 +114,6 @@ fn ring_basis(i: usize) -> (Vec3, Vec3) {
}
}
fn axis_color(i: usize) -> Color32 {
match i {
0 => Color32::from_rgb(225, 70, 60),
1 => Color32::from_rgb(80, 190, 80),
_ => Color32::from_rgb(70, 130, 240),
}
}
/// Distance from `p` to the screen segment a–b.
fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
@@ -263,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
let active = state.map(|s| s.handle).or(hovered);
let hot = |h: Handle| active == Some(h);
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { tk.gizmo[i.min(2)] };
for (i, pts) in &sh.rings {
painter.add(Shape::line(pts.clone(), Stroke::new(if hot(Handle::Ring(*i)) { 3.0 } else { 1.8 }, col(Handle::Ring(*i), *i))));
}
+32 -7
View File
@@ -13,7 +13,9 @@ use egui_wgpu::wgpu::util::DeviceExt;
pub const TRI_SIZE: usize = 28;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
const UNIFORM_SIZE: u64 = 208;
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their
/// flags (`uniform_bytes`).
const UNIFORM_SIZE: u64 = 352;
/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face
/// (the plan's face numbering; faces past the end take the last colour).
@@ -199,6 +201,8 @@ pub struct ViewportCallback {
/// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment,
/// 5 accessibility; parameter; scale; size) and the pull / access direction.
pub analysis: [f32; 8],
/// The analysis colours and the stripe flag ([`analysis_colors`]).
pub analysis_colors: [f32; 36],
/// Accessibility: the depth map seen from the access direction.
pub access: Option<AccessMap>,
/// Draw silhouette outlines around the model (visual styles with edges).
@@ -1010,11 +1014,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
}
}
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
/// `size`. `None` without a GPU adapter. With `tess` (the tessellation shader), planned bodies
/// are evaluated on the GPU where the device builds that pipeline, as [`install`] sets it up.
/// `highlight` is drawn as the viewport's highlight scene (hover and selection).
/// A device of its own for an offscreen render or check (`None` without a GPU adapter).
#[cfg(all(test, not(target_arch = "wasm32")))]
fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
@@ -1035,7 +1035,15 @@ pub(crate) fn offscreen_tessellates(shader: &str) -> Option<bool> {
Some(tessellation(&adapter, &device, &queue, shader).is_some())
}
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
/// `size`. `None` without a GPU adapter. With `tess` (the tessellation shader), planned bodies
/// are evaluated on the GPU where the device builds that pipeline, as [`install`] sets it up.
/// `highlight` is drawn as the viewport's highlight scene (hover and selection). `analysis` is the
/// surface analysis uniform (`viewport::analysis_uniform`; zeros for none) and `colors` its
/// colours (`analysis_colors`).
#[cfg(all(test, not(target_arch = "wasm32")))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_offscreen(
scene: GpuScene,
highlight: Option<GpuScene>,
@@ -1044,6 +1052,8 @@ pub(crate) fn render_offscreen(
eye: [f32; 4],
size: u32,
tess: Option<&str>,
analysis: [f32; 8],
colors: [f32; 36],
) -> Option<Vec<u8>> {
let (adapter, device, queue) = offscreen_device()?;
let (format, depth) = (wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Depth24PlusStencil8);
@@ -1067,7 +1077,8 @@ pub(crate) fn render_offscreen(
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
analysis: [0.0; 8],
analysis,
analysis_colors: colors,
access: None,
outline: false,
eye,
@@ -1152,9 +1163,23 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec
None => v.extend([0.0; 8]),
}
v.extend(cb.eye);
v.extend(cb.analysis_colors);
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable,
/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad
/// faces).
pub fn analysis_colors(c: &crate::theme::AnalysisColors) -> [f32; 36] {
let mut v = [0.0; 36];
let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]);
for (slot, x) in v.iter_mut().zip(colors) {
*slot = x;
}
v[32] = if c.hatch { 1.0 } else { 0.0 };
v
}
#[cfg(test)]
mod tests {
use egui_wgpu::wgpu::naga;
+2
View File
@@ -2,6 +2,8 @@
#[path = "../../../assets/icons/ui/source/icons.rs"]
mod source;
pub use source::paint;
#[cfg(test)]
pub(crate) use source::paint_with;
#[path = "../../../assets/icons/ui/source/glyphs.rs"]
pub(crate) mod glyphs;
#[path = "../../../assets/icons/ui/source/markers.rs"]
+6 -1
View File
@@ -12,6 +12,10 @@ pub mod access;
pub mod agent_cursor;
pub mod browser;
pub mod canvas;
#[cfg(test)]
mod colour_tokens_tests;
#[cfg(test)]
mod colour_vision_tests;
pub mod context_menu;
pub mod control;
pub mod crash_ui;
@@ -1022,8 +1026,9 @@ impl SolveApp {
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
if !self.styled || theme::is_dark() != self.ui.dark {
if !self.styled || theme::is_dark() != self.ui.dark || theme::vision() != self.preferences.color_vision {
theme::set_dark(self.ui.dark);
theme::set_vision(self.preferences.color_vision);
theme::apply(ctx);
self.styled = true;
}
+106 -4
View File
@@ -3,10 +3,10 @@
//! designs, autosave, display precision, timeline layout, the agent cursor), Navigation (mouse presets, zoom
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
//! runs, except anti-aliasing, which needs a restart.
//! (visual style, grid, shadow, perspective, anti-aliasing, colour vision), Sketch and Selection.
//! Changes apply at once; Cancel puts back what was there when the dialog opened, OK keeps them,
//! Apply keeps them and stays open, and Restore Defaults resets the page shown. Everything is
//! kept between runs, except anti-aliasing, which needs a restart.
use egui::{Modifiers, Pos2, RichText};
use serde::{Deserialize, Serialize};
@@ -246,6 +246,8 @@ pub struct Prefs {
pub offer_stand_up: bool,
/// What a middle-button double-click in the view does: `home`, `fit` or `off`.
pub middle_double_click: String,
/// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`.
pub color_vision: crate::theme::ColorVision,
/// Check for updates at start and once a day (docs/updates.md).
pub check_updates: bool,
/// When the last update check ran, and the versions skipped or put off (`update::Memo`).
@@ -308,6 +310,7 @@ impl Default for Prefs {
up_axis: "z".into(),
offer_stand_up: true,
middle_double_click: "home".into(),
color_vision: crate::theme::ColorVision::Off,
check_updates: true,
update: crate::update::Memo::default(),
}
@@ -431,6 +434,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
PAGE_GRAPHICS => {
let msaa = p.msaa != d.msaa;
p.msaa = d.msaa;
p.color_vision = d.color_vision;
app.ui.visual_style = 0;
app.ui.show_grid = true;
app.ui.ground_shadow = true;
@@ -1055,6 +1059,28 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
_ => "CPU (software)".to_string(),
});
ui.end_row();
// Colour vision (#778): its own section at the end of the page.
ui.label("");
ui.end_row();
ui.label(RichText::new(app.tr("ui.colour_vision")).strong());
ui.label("");
ui.end_row();
ui.label(app.tr("ui.colour_vision_mode"));
let mode = app.preferences.color_vision;
egui::ComboBox::from_id_salt("pref_color_vision").selected_text(app.tr(mode.label_id())).show_ui(ui, |ui| {
for m in crate::theme::ColorVision::ALL {
ui.selectable_value(&mut app.preferences.color_vision, m, crate::i18n::tr(m.label_id())).on_hover_text(crate::i18n::tr(m.help_id()));
}
});
if app.preferences.color_vision != mode {
// At once, like the theme: the next frame draws in the new colours.
ui.ctx().request_repaint();
}
ui.end_row();
ui.label(app.tr("ui.colour_vision_sample"));
colour_vision_sample(ui, t);
ui.end_row();
note(ui, app.tr("ui.colour_vision_note"));
}
5 => {
ui.label(crate::i18n::tr_label("Project face edges into new sketches"));
@@ -1072,6 +1098,76 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
});
}
/// The names under the Colour vision sample (message ids).
pub(crate) const COLOUR_VISION_SAMPLE: [&str; 6] =
["ui.colour_vision_fixed", "ui.colour_vision_projected", "ui.axes", "Bends", "ui.draft", "ui.colour_vision_failed"];
/// The Colour vision sample (#778): fixed and projected sketch lines, the X and Y axes, bends up
/// and down, Draft Analysis and a failed feature, each drawn as it is where it appears, in the
/// colours on now. One fixed-size area, so the grid row is as tall as what it draws.
fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) {
const W: f32 = 72.0;
const LINE: f32 = 4.0;
let (area, _) = ui.allocate_exact_size(egui::vec2(W * COLOUR_VISION_SAMPLE.len() as f32, 50.0), egui::Sense::hover());
let p = ui.painter_at(area);
let line = |c| egui::Stroke::new(LINE, c);
for (i, name) in COLOUR_VISION_SAMPLE.iter().enumerate() {
let cell = egui::Rect::from_min_size(area.min + egui::vec2(W * i as f32, 0.0), egui::vec2(W - 8.0, 32.0));
let (a, b) = (cell.left_center() + egui::vec2(8.0, 0.0), cell.right_center() - egui::vec2(8.0, 0.0));
match i {
0 | 1 => {
p.rect_filled(cell, 3.0, t.viewport_top);
p.line_segment([a, b], line(if i == 0 { t.sketch_locked } else { t.sketch_projected }));
}
2 => {
// X along and Y up from one corner, as the view triad draws them.
p.rect_filled(cell, 3.0, t.viewport_top);
let o = cell.left_bottom() + egui::vec2(12.0, -7.0);
p.line_segment([o, egui::pos2(cell.right() - 10.0, o.y)], line(t.triad[0]));
p.line_segment([o, egui::pos2(o.x, cell.top() + 6.0)], line(t.triad[1]));
}
3 => {
// Dashed, as the flat pattern draws them.
p.rect_filled(cell, 3.0, t.viewport_top);
for (dy, c) in [(-7.0, t.bend_up), (7.0, t.bend_down)] {
p.extend(egui::Shape::dashed_line(&[a + egui::vec2(0.0, dy), b + egui::vec2(0.0, dy)], line(c), 8.0, 5.0));
}
}
4 => {
// The three bands of Draft Analysis; in a mode the negative one is striped.
let w = cell.width() / 3.0;
for (k, c) in t.analysis.draft.iter().enumerate() {
let band = egui::Rect::from_min_size(cell.min + egui::vec2(w * k as f32, 0.0), egui::vec2(w, cell.height()));
p.rect_filled(band, 0.0, crate::theme::srgb(*c));
if k == 2 && t.analysis.hatch {
let stripes = p.with_clip_rect(band);
let dark = crate::theme::srgb(c.map(|x| x * 0.6));
let mut x = band.left() - band.height();
while x < band.right() {
stripes.line_segment(
[egui::pos2(x, band.bottom()), egui::pos2(x + band.height(), band.top())],
egui::Stroke::new(2.0, dark),
);
x += 9.0;
}
}
}
}
_ => {
// A failed feature on the timeline.
p.rect(cell.shrink2(egui::vec2(10.0, 3.0)), 3.0, t.timeline_item_error, egui::Stroke::new(1.0, t.error), egui::StrokeKind::Inside);
}
}
p.text(
cell.center_bottom() + egui::vec2(0.0, 3.0),
egui::Align2::CENTER_TOP,
crate::i18n::tr(name),
egui::FontId::proportional(11.0),
t.text_dim,
);
}
}
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
/// any other keys set preferences. Returns them all, the open page and the offer.
@@ -1142,6 +1238,12 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
o.insert(k.clone(), v.clone());
}
}
// Saved preferences read an unknown mode as Off; asked for here, it is an error.
if let Some(v) = p.get("color_vision")
&& v.as_str().and_then(crate::theme::ColorVision::from_id).is_none()
{
return Err("`color_vision` must be off, protan, deutan or tritan".into());
}
let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
if !UNITS.contains(&new.default_units.as_str()) {
return Err(format!("units must be one of {}", UNITS.join(", ")));
+184 -5
View File
@@ -1,21 +1,163 @@
//! Colours and widget style: a dark theme (the default, a blue-grey in the spirit of desktop CAD
//! tools, viewport #2e3440) and a light one. Every colour the UI uses comes from [`Tokens`].
//! tools, viewport #2e3440) and a light one, each with colour vision modes. Every colour the UI
//! uses comes from [`Tokens`].
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "../../../assets/styles/ui/palettes.rs"]
mod palettes;
use egui::{Color32, Stroke};
use serde::{Deserialize, Serialize};
static DARK: AtomicBool = AtomicBool::new(true);
thread_local! {
/// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
/// with one global, a test in a mode changed the colours under another one
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
/// it: the preview thread is handed its colours (`preview::Colors`) and thumbnails are drawn by
/// the engine in the model's colours, so a new thread starts Off and stays Off.
static VISION: Cell<ColorVision> = const { Cell::new(ColorVision::Off) };
}
/// Is the dark theme on?
pub fn is_dark() -> bool {
DARK.load(Ordering::Relaxed)
}
#[derive(Clone, Copy, Debug)]
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
/// a weak or missing red (protan), green (deutan) or blue (tritan) sense. Not a filter: Off gives
/// back the theme's colours unchanged. Protan and Deutan share one red-green table, measured
/// clear for both (`colour_vision_tests`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ColorVision {
#[default]
Off,
Protan,
Deutan,
Tritan,
}
impl ColorVision {
pub const ALL: [ColorVision; 4] = [ColorVision::Off, ColorVision::Protan, ColorVision::Deutan, ColorVision::Tritan];
/// The saved value (`ui.prefs` `color_vision`).
pub fn id(self) -> &'static str {
match self {
ColorVision::Off => "off",
ColorVision::Protan => "protan",
ColorVision::Deutan => "deutan",
ColorVision::Tritan => "tritan",
}
}
pub fn from_id(id: &str) -> Option<ColorVision> {
ColorVision::ALL.into_iter().find(|m| m.id() == id)
}
/// Its name in the Preferences (a message id).
pub fn label_id(self) -> &'static str {
match self {
ColorVision::Off => "ui.colour_vision_off",
ColorVision::Protan => "ui.colour_vision_protan",
ColorVision::Deutan => "ui.colour_vision_deutan",
ColorVision::Tritan => "ui.colour_vision_tritan",
}
}
/// Who it is for, in one line (its tooltip; a message id).
pub fn help_id(self) -> &'static str {
match self {
ColorVision::Off => "ui.colour_vision_off_tip",
ColorVision::Protan => "ui.colour_vision_protan_tip",
ColorVision::Deutan => "ui.colour_vision_deutan_tip",
ColorVision::Tritan => "ui.colour_vision_tritan_tip",
}
}
}
/// From saved preferences: a value this version doesn't know (a later version's mode, a hand
/// edit) is Off rather than an error, which would lose every other saved preference with it.
/// `ui.prefs` refuses such a value before it gets here (`prefs::set`).
impl<'de> Deserialize<'de> for ColorVision {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().and_then(ColorVision::from_id).unwrap_or_default())
}
}
/// The colour vision mode on now (on this thread).
pub fn vision() -> ColorVision {
VISION.with(Cell::get)
}
/// Switch the colour vision mode (the visuals follow on the next [`apply`]).
pub fn set_vision(mode: ColorVision) {
VISION.with(|v| v.set(mode));
}
/// An sRGB colour given as 0..1 (the analysis colours), for drawing in the UI.
pub fn srgb(c: [f32; 3]) -> Color32 {
let byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(byte(c[0]), byte(c[1]), byte(c[2]))
}
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read
/// the same as when they were literals in the WGSL).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
/// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its
/// caller). Every icon colour is a token so a colour vision mode can reach it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IconColors {
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet, iso lines and teeth.
pub sheet: Color32,
pub sheet_2: Color32,
pub sheet_light: Color32,
pub sheet_line: Color32,
/// Highlights (and the white ink of markers and the agent pointer), and the sphere's glint.
pub highlight: Color32,
pub glint: Color32,
pub folder: Color32,
pub plane: Color32,
pub fold: Color32,
/// Zebra stripes, and the environment map's sky and floor.
pub stripe: Color32,
pub sky: Color32,
pub floor: Color32,
/// Appearance: red, blue and yellow paint dabs.
pub paint: [Color32; 3],
/// Origin: X, Y and Z arrows.
pub axes: [Color32; 3],
/// Accessibility: reachable, blocked. Draft: positive, between. Curvature: flat, middle,
/// tight.
pub access: [Color32; 2],
pub draft: [Color32; 2],
pub curvature: [Color32; 3],
pub interference: Color32,
pub warning: Color32,
/// The pointer an AI agent moves.
pub agent: Color32,
/// The dark halo under viewport markers (snap points).
pub marker_halo: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub app_bar: Color32,
pub toolbar: Color32,
@@ -135,12 +277,49 @@ pub struct Tokens {
// ---- box selection ----
pub box_window: Color32,
pub box_crossing: Color32,
// ---- colours that came from the engine or were drawn in place ----
/// Sketch curves the engine tags (`view::colors`): projected, lost link, construction,
/// centerline.
pub sketch_projected: Color32,
pub sketch_lost: Color32,
pub sketch_construction: Color32,
pub sketch_centerline: Color32,
/// The view triad's X, Y and Z (and the CPU scene's origin axes).
pub triad: [Color32; 3],
/// The move gizmo's X, Y and Z.
pub gizmo: [Color32; 3],
/// Sheet metal flat pattern: bend up, bend down.
pub bend_up: Color32,
pub bend_down: Color32,
/// Interference: the clash fill, before its shading.
pub interference: Color32,
pub analysis: AnalysisColors,
pub icons: IconColors,
/// The application bar's buttons: ink, fill and accent of their icons.
pub app_bar_icon: [Color32; 3],
/// The SolidCraft mark on the application bar: light, mid and dark faces, and its edge.
pub logo: [Color32; 4],
}
impl Tokens {
/// The tokens of the current theme.
/// The tokens of the current theme and colour vision mode.
pub fn get() -> Tokens {
if is_dark() { Tokens::dark() } else { Tokens::light() }
Tokens::themed(is_dark(), vision())
}
/// The tokens of a theme and colour vision mode.
pub fn themed(dark: bool, mode: ColorVision) -> Tokens {
(if dark { Tokens::dark() } else { Tokens::light() }).with_vision(dark, mode)
}
/// These tokens with a colour vision mode's values on top (the tables in `palettes.rs`); Off
/// gives them back unchanged.
pub fn with_vision(self, dark: bool, mode: ColorVision) -> Tokens {
match mode {
ColorVision::Off => self,
ColorVision::Protan | ColorVision::Deutan => self.red_green(dark),
ColorVision::Tritan => self.blue_yellow(dark),
}
}
/// Fade idle cube colours into the viewport while retaining opaque facets.
@@ -170,7 +349,7 @@ pub fn set_dark(dark: bool) {
}
fn visuals(dark: bool) -> egui::Visuals {
let t = if dark { Tokens::dark() } else { Tokens::light() };
let t = Tokens::themed(dark, vision());
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
+1 -8
View File
@@ -187,14 +187,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
icons::paint(
ui.painter(),
br.shrink(5.0),
icon,
dim(Color32::from_rgb(225, 230, 238)),
dim(Color32::from_rgb(120, 140, 170)),
dim(Color32::from_rgb(120, 190, 255)),
);
icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2]));
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
+244 -21
View File
@@ -279,6 +279,7 @@ fn scene_key(app: &SolveApp) -> u64 {
app.preview.replaced.hash(&mut h);
app.tree.redefine.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
crate::theme::vision().hash(&mut h);
app.session.active_sketch.hash(&mut h);
h.finish()
}
@@ -296,7 +297,7 @@ pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) {
(scene_key(app), sketch_scene_key(app, proj))
}
/// A sketch curve colour from the engine, in the current theme.
/// A sketch curve colour from the engine, in the current theme and colour vision mode.
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
if c == colors::SKETCH {
c4(t.sketch)
@@ -304,6 +305,14 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
c4(t.sketch_fixed)
} else if c == colors::SKETCH_LOCKED {
c4(t.sketch_locked)
} else if c == colors::SKETCH_PROJECTED {
c4(t.sketch_projected)
} else if c == colors::SKETCH_LOST {
c4(t.sketch_lost)
} else if c == colors::SKETCH_CONSTRUCTION {
c4(t.sketch_construction)
} else if c == colors::SKETCH_CENTERLINE {
c4(t.sketch_centerline)
} else {
rgba(c)
}
@@ -1554,6 +1563,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
app.ui.visual_style.hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
crate::theme::is_dark().hash(&mut h);
crate::theme::vision().hash(&mut h);
app.preview.replaced.hash(&mut h);
app.origin_visible().hash(&mut h);
app.session.visibility.hidden_origin.hash(&mut h);
@@ -2263,6 +2273,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
size_px: [rect.width() * ppp, rect.height() * ppp],
images: crate::ref_images::gpu_images(app),
analysis: analysis_uniform(app),
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
access: crate::access::map(app),
outline: matches!(app.ui.visual_style, 0 | 3),
eye: {
@@ -2981,10 +2992,11 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);
app.ui.dark.hash(&mut h);
app.preferences.color_vision.hash(&mut h);
let key = h.finish();
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
let palette = scene_palette(app.ui.dark);
let palette = scene_palette(app.ui.dark, app.preferences.color_vision);
let scene = solidcraft_engine::view::scene_with_palette(
&app.session,
&proj.cam,
@@ -3005,11 +3017,11 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or
/// the light theme, from the tokens the window draws with, so the picture matches the window. The
/// light theme's are the engine's own colours, so a light render is unchanged. The sketch states
/// and axes the window draws in the engine's colours stay so.
pub(crate) fn scene_palette(dark: bool) -> solidcraft_engine::view::Palette {
let t = if dark { Tokens::dark() } else { Tokens::light() };
/// the light theme and a colour vision mode, from the tokens the window draws with, so the picture
/// matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
/// render is unchanged.
pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette {
let t = Tokens::themed(dark, mode);
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
solidcraft_engine::view::Palette {
background: (rgb(t.viewport_top), rgb(t.viewport_bottom)),
@@ -3022,8 +3034,12 @@ pub(crate) fn scene_palette(dark: bool) -> solidcraft_engine::view::Palette {
sketch_fixed: rgb(t.sketch_fixed),
sketch_done: rgb(t.sketch_fixed),
sketch_locked: rgb(t.sketch_locked),
sketch_projected: rgb(t.sketch_projected),
sketch_lost: rgb(t.sketch_lost),
sketch_construction: rgb(t.sketch_construction),
sketch_centerline: rgb(t.sketch_centerline),
axes: t.triad.map(rgb),
cap: rgb(t.section_cap),
..solidcraft_engine::view::Palette::DEFAULT
}
}
@@ -3467,7 +3483,21 @@ mod tests {
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
let (planned, shader) = (scene.planned.len(), tess.then_some(solidcraft_gpu::SHADER));
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader))
(
planned,
scene.tris.len(),
crate::gpu::render_offscreen(
scene,
None,
proj.vp.to_f32(),
proj.cam.back().to_f32(),
eye,
SIZE,
shader,
[0.0; 8],
crate::gpu::analysis_colors(&Tokens::get().analysis),
),
)
};
let (planned, gpu_tris, gpu) = render(true);
let (none, cpu_tris, cpu) = render(false);
@@ -3516,7 +3546,17 @@ mod tests {
let proj = projection(&app, rect);
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
let Some(img) = crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, Some(REJECTED)) else {
let Some(img) = crate::gpu::render_offscreen(
scene,
None,
proj.vp.to_f32(),
proj.cam.back().to_f32(),
eye,
SIZE,
Some(REJECTED),
[0.0; 8],
crate::gpu::analysis_colors(&Tokens::get().analysis),
) else {
return;
};
let n = (SIZE * SIZE) as usize;
@@ -3552,7 +3592,17 @@ mod tests {
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
let shader = tess.then_some(solidcraft_gpu::SHADER);
crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader)
crate::gpu::render_offscreen(
scene,
Some(highlight),
proj.vp.to_f32(),
proj.cam.back().to_f32(),
eye,
SIZE,
shader,
[0.0; 8],
crate::gpu::analysis_colors(&Tokens::get().analysis),
)
};
let lit = |plain: &[u8], hovered: &[u8]| -> Vec<bool> { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() };
let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else {
@@ -3628,24 +3678,27 @@ mod tests {
crate::sketch_palette::set_mode(&mut h.app, 0);
}
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974).
/// The light theme's tokens are the engine's own colours, so a light picture is unchanged; in
/// the dark theme each part takes the token the window draws it with. Sketch states follow
/// what the window does with them (`sketch_color`), so the two can't drift apart.
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974)
/// and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
/// light picture in Off is unchanged; otherwise each part takes the token the window draws it
/// with. Sketch states follow what the window does with them (`sketch_color`) and the axes the
/// mode's triad, so the two can't drift apart.
#[test]
fn cpu_pictures_are_drawn_in_the_windows_theme() {
use crate::theme::ColorVision;
use solidcraft_engine::view::Palette;
assert_eq!(scene_palette(false), Palette::DEFAULT, "the light theme is the engine's colours");
assert_eq!(scene_palette(false, ColorVision::Off), Palette::DEFAULT, "the light theme is the engine's colours");
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
let (p, t) = (scene_palette(true), Tokens::dark());
let (p, t) = (scene_palette(true, ColorVision::Off), Tokens::dark());
assert_eq!(p.background, (rgb(t.viewport_top), rgb(t.viewport_bottom)));
assert_eq!(
[p.grid, p.grid_major, p.edge, p.body, p.body_selected, p.cap],
[t.grid, t.grid_major, t.body_edge, t.body, t.body_selected, t.section_cap].map(rgb)
);
assert_ne!(p.background, Palette::DEFAULT.background);
for dark in [false, true] {
let (p, t) = (scene_palette(dark), if dark { Tokens::dark() } else { Tokens::light() });
for (dark, mode) in [false, true].into_iter().flat_map(|d| ColorVision::ALL.map(|m| (d, m))) {
let (p, t) = (scene_palette(dark, mode), Tokens::themed(dark, mode));
assert_eq!(p.axes, t.triad.map(rgb), "dark {dark}, {mode:?}: the axes");
for (tag, cpu) in [
(colors::SKETCH, p.sketch),
(colors::SKETCH_FIXED, p.sketch_fixed),
@@ -3657,12 +3710,13 @@ mod tests {
(colors::SKETCH_CENTERLINE, p.sketch_centerline),
] {
let [r, g, b, _] = sketch_color(&t, tag);
assert_eq!(cpu, Rgb(r, g, b), "dark {dark}: the window draws {tag:?} in another colour");
assert_eq!(cpu, Rgb(r, g, b), "dark {dark}, {mode:?}: the window draws {tag:?} in another colour");
}
}
}
/// The CPU viewport follows a theme change at once: the theme is part of its picture's key.
/// The CPU viewport follows a theme or colour vision mode change at once: both are part of its
/// picture's key.
#[test]
fn cpu_viewport_redraws_in_the_new_theme() {
let mut h = crate::scenario::Harness::new();
@@ -3696,6 +3750,10 @@ mod tests {
let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}");
}
// And a colour vision mode change, on its own.
h.app.preferences.color_vision = crate::theme::ColorVision::Deutan;
assert!(top_row(&mut h).is_some(), "a new mode kept the old picture");
assert!(top_row(&mut h).is_none(), "no change, no new picture");
}
#[test]
@@ -5012,6 +5070,171 @@ mod tests {
app.session.visibility.hidden_origin.push(name);
assert!(construction_axis_segments(&app).is_empty());
}
/// Draft Analysis on the GPU takes its colours from the tokens: the sample design through the
/// viewport's real pipeline, offscreen (skipped without a GPU). In Off the faces that pass are
/// green; in Protan they are the red-green table's blue, and the negative faces are striped.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn draft_analysis_on_the_gpu_draws_the_modes_colours_and_stripes() {
use crate::theme::ColorVision;
let mut s = solidcraft_engine::Session::default();
s.run_script(&solidcraft_engine::sample::script()).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.viewport.no_pixels = true;
app.fit_view();
app.run("inspect.draft", json!({"pull": [-1, -1, 0]})).unwrap();
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
const SIZE: u32 = 256;
let proj = projection(&app, Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)));
let e = proj.cam.eye().to_f32();
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
let render = |c: &crate::theme::AnalysisColors| {
crate::gpu::render_offscreen(
build_scene(&app),
None,
proj.vp.to_f32(),
proj.cam.back().to_f32(),
eye,
SIZE,
None,
analysis_uniform(&app),
crate::gpu::analysis_colors(c),
)
};
let off = Tokens::themed(false, ColorVision::Off).analysis;
let protan = Tokens::themed(false, ColorVision::Protan).analysis;
let flat = crate::theme::AnalysisColors { hatch: false, ..protan };
let (Some(a), Some(b), Some(c)) = (render(&off), render(&protan), render(&flat)) else {
eprintln!("skipping: no GPU adapter");
return;
};
// Pixels of a colour under any light: the same hue, so the same ratios between channels.
let like = |img: &[u8], c: [f32; 3]| {
img.as_chunks::<4>()
.0
.iter()
.filter(|p| {
let m = c.iter().copied().fold(0.0, f32::max);
let k = f32::from(p[0].max(p[1]).max(p[2])) / 255.0 / m;
k > 0.3 && (0..3).all(|i| (f32::from(p[i]) / 255.0 - c[i] * k).abs() < 0.04)
})
.count()
};
let n = (SIZE * SIZE) as usize;
assert!(like(&a, off.draft[0]) > n / 50 && like(&a, protan.draft[0]) == 0, "Off: green passing faces");
assert!(like(&b, protan.draft[0]) > n / 50 && like(&b, off.draft[0]) == 0, "Protan: blue passing faces");
// The stripes: with them on, some negative pixels are darker, and nothing else changes.
let negative = |p: &[u8]| like(p, protan.draft[2]) == 1;
let changed: Vec<usize> = (0..n).filter(|&i| b[4 * i..4 * i + 3] != c[4 * i..4 * i + 3]).collect();
let neg = (0..n).filter(|&i| negative(&c[4 * i..4 * i + 4])).count();
assert!(!changed.is_empty() && changed.len() * 2 < neg, "{} striped of {neg} negative pixels", changed.len());
assert!(changed.iter().all(|&i| negative(&c[4 * i..4 * i + 4])), "only negative faces are striped");
assert!(changed.iter().all(|&i| b[4 * i..4 * i + 3].iter().zip(&c[4 * i..4 * i + 3]).all(|(x, y)| x <= y)), "stripes are darker");
}
/// The CPU viewport draws in the colour vision mode: a fixed sketch line in the light theme
/// takes Deutan's value, and Off's is gone.
#[test]
fn cpu_viewport_draws_in_the_colour_vision_mode() {
use crate::theme::ColorVision;
let mut h = crate::scenario::Harness::new();
h.app.ui.dark = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
let mut cam = solidcraft_engine::view::home_camera(&h.app.session);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
h.app.viewport.no_pixels = false;
let count = |h: &mut crate::scenario::Harness, c: Color32| {
h.app.viewport.cpu = None;
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
});
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
let n = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map_or(0, |upload| {
let egui::ImageData::Color(image) = &upload.image;
image.pixels.iter().filter(|p| [p.r(), p.g(), p.b()].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
});
out.textures_delta.clear();
n
};
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
h.app.preferences.color_vision = ColorVision::Deutan;
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
}
/// A mode's table reaches the CPU picture: the sketch states and axes it changes, and nothing
/// else (the light theme in Off is the engine's own colours).
#[test]
fn cpu_picture_follows_the_colour_vision_mode() {
use crate::theme::ColorVision;
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
let off = scene_palette(false, ColorVision::Off);
let rg = scene_palette(false, ColorVision::Protan);
let t = Tokens::themed(false, ColorVision::Protan);
assert_eq!((rg.sketch_projected, rg.sketch_locked), (rgb(t.sketch_projected), rgb(t.sketch_locked)));
assert_ne!(rg.sketch_projected, off.sketch_projected, "the red-green table changes projected lines in light");
assert_eq!((rg.axes[0], rg.axes[1], rg.axes[2]), (rgb(t.triad[0]), rgb(t.triad[1]), off.axes[2]));
assert_eq!((rg.sketch, rg.background, rg.grid, rg.body), (off.sketch, off.background, off.grid, off.body));
let by = scene_palette(false, ColorVision::Tritan);
assert_eq!(by.sketch_locked, rgb(Tokens::themed(false, ColorVision::Tritan).sketch_locked));
assert_ne!(by.sketch_locked, off.sketch_locked);
assert_eq!(by.sketch_projected, off.sketch_projected);
}
/// The mode is on from the next frame (theme, widgets, caches, `ui.render`), and Cancel puts
/// it back.
#[test]
fn colour_vision_mode_reaches_the_frame_and_the_view_caches() {
use crate::theme::ColorVision;
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
let keys = |app: &SolveApp| (scene_key(app), highlight_key(app));
h.call("ui.prefs", json!({"open": "Graphics"}));
assert!(h.widget("Colour vision").is_some() && h.widget("Mode").is_some());
let before = keys(&h.app);
h.call("ui.prefs", json!({"color_vision": "deutan"}));
h.frame();
assert_eq!(crate::theme::vision(), ColorVision::Deutan);
assert_eq!(Tokens::get(), Tokens::themed(crate::theme::is_dark(), ColorVision::Deutan));
let after = keys(&h.app);
assert!(before.0 != after.0 && before.1 != after.1, "both caches are keyed on the mode");
h.call("ui.prefs", json!({"button": "cancel"}));
h.frame();
assert_eq!((h.app.preferences.color_vision, crate::theme::vision()), (ColorVision::Off, ColorVision::Off));
assert_eq!(keys(&h.app), before);
// `ui.render` (the CPU picture) shows the mode too: a fixed sketch line in Deutan's value
// (in the light theme, where the red-green table changes it; the render follows the theme).
h.call("ui.prefs", json!({"theme": "light"}));
h.frame();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
h.call("ui.view", json!({"view": "top"}));
let path = std::env::temp_dir().join(format!("sc_colour_vision_render_{}.png", std::process::id()));
let near = |h: &mut crate::scenario::Harness, c: Color32| {
let r = h.call("ui.render", json!({"path": path.to_string_lossy(), "width": 400, "height": 300}));
assert_eq!(r["ok"], true, "{r}");
let img = image::open(&path).unwrap().to_rgb8();
img.pixels().filter(|p| p.0.iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
};
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
let (a, b) = (near(&mut h, off), near(&mut h, deutan));
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
h.call("ui.prefs", json!({"color_vision": "deutan"}));
h.frame();
let (a, b) = (near(&mut h, off), near(&mut h, deutan));
h.call("ui.prefs", json!({"color_vision": "off"}));
h.frame();
let _ = std::fs::remove_file(&path);
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
}
}
#[cfg(test)]
@@ -0,0 +1,25 @@
[
{"note": "#778: Preferences › Graphics › Colour vision. A mode applies at once, Cancel puts it back, OK keeps it, Restore Defaults on Graphics resets it, and the window keeps its size."},
{"click": {"handle": "titlebar:preferences"}},
{"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}},
{"widget": "Graphics"},
{"expect": {"shown": "Colour vision", "prefs": {"dialog": "Graphics"}, "same_width": "prefs:window"}},
{"widget": "Off"},
{"widget": "Deutan"},
{"expect": {"prefs": {"color_vision": "deutan"}, "same_width": "prefs:window"}},
{"widget": "Cancel"},
{"expect": {"prefs": {"dialog": null, "color_vision": "off"}}},
{"key": "Comma", "cmd": true},
{"widget": "Graphics"},
{"widget": "Off"},
{"widget": "Tritan"},
{"widget": "OK"},
{"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}},
{"key": "Comma", "cmd": true},
{"widget": "Graphics"},
{"expect": {"shown": "Tritan", "same_width": "prefs:window"}},
{"widget": "Restore Defaults"},
{"expect": {"prefs": {"color_vision": "off"}}},
{"widget": "OK"},
{"expect": {"prefs": {"dialog": null, "color_vision": "off"}}}
]
+4 -1
View File
@@ -51,7 +51,10 @@
to its top.
Preferences (File › Preferences) hold the theme, the units of new designs, the mouse preset, the
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
visual style and autosave. Graphics › Colour vision gives the colours that carry a meaning (sketch
states, axes, bends, analyses, icons) values for a weak red, green or blue sense: Protan, Deutan or
Tritan. In a mode, the faces Draft and Accessibility Analysis mark as failing are striped too.
Help › Keyboard Shortcuts lists every key, and File › Keyboard
Shortcuts changes them.
Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or
+1 -1
View File
@@ -127,7 +127,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `ko`, `ru`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `ko`, `ru`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |