diff --git a/assets/icons/ui/source/glyphs.rs b/assets/icons/ui/source/glyphs.rs index 828e8e34..c2b5eba7 100644 --- a/assets/icons/ui/source/glyphs.rs +++ b/assets/icons/ui/source/glyphs.rs @@ -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)); }; diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 25df0e9d..f82c9c5d 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -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 = @@ -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 = (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 = (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 = (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::>(), SHEET_LINE); + pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::>(), 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); } diff --git a/assets/icons/ui/source/markers.rs b/assets/icons/ui/source/markers.rs index 862b2996..1cf8ec84 100644 --- a/assets/icons/ui/source/markers.rs +++ b/assets/icons/ui/source/markers.rs @@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2}; pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option, 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, 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, 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)); diff --git a/assets/icons/ui/source/navigation.rs b/assets/icons/ui/source/navigation.rs index fcef90a7..234b7e24 100644 --- a/assets/icons/ui/source/navigation.rs +++ b/assets/icons/ui/source/navigation.rs @@ -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)); diff --git a/assets/icons/ui/source/pointer.rs b/assets/icons/ui/source/pointer.rs index 1dbb8baf..b637f753 100644 --- a/assets/icons/ui/source/pointer.rs +++ b/assets/icons/ui/source/pointer.rs @@ -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 = [(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); } diff --git a/assets/locales/de.json b/assets/locales/de.json index a683ae0d..bda21b00 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index 2f2e6471..81df1f95 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 4d19b5c4..2f5f8973 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "同心", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index f8d148ef..9ef6ccaf 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "브라우저에서 찾기", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index d5c55210..087d06ee 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "Концентричность", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 5cb7ddb6..4a8d7e74 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "同心", diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index e1d7d591..97c3c479 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -10,6 +10,10 @@ struct U { acc_u: vec4, // accessibility map axes (xyz, offset) acc_v: vec4, eye: vec4, // 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, 8>, + ana_f: vec4, // x = 1: stripe the negative draft and blocked faces }; @group(1) @binding(2) var acc_t: texture_2d; @@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4 { // 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(0.85, 0.22, 0.20); - if (facing && clear) { c = vec3(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(c * lit, 1.0)); } if (u.ana.x > 3.5) { @@ -171,18 +175,25 @@ fn analysis(i: TOut, k: f32) -> vec4 { } 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(0.95, 0.80, 0.15); - if (a >= u.ana.y) { c = vec3(0.20, 0.75, 0.30); } - if (a <= -u.ana.y) { c = vec3(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(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(0.10, 0.80, 0.25), vec3(0.90, 0.15, 0.10), t * 2.0 - 1.0), - mix(vec3(0.15, 0.35, 0.95), vec3(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(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) -> vec3 { + 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 { if (clipped(i.wp)) { discard; } diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index 580c75aa..310a07da 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -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 } } diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs index daf27398..676ab5e0 100644 --- a/crates/ui-egui/src/agent_cursor.rs +++ b/crates/ui-egui/src/agent_cursor.rs @@ -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); } diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs new file mode 100644 index 00000000..a2d892e4 --- /dev/null +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -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 { + 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(") { + let tail = &rest[i..]; + let close = tail.find(')').unwrap_or(tail.len() - 1); + literals.push(&tail[..=close]); + rest = &tail[close..]; + } + assert_eq!( + literals, + [ + "vec3(0.0, 0.0, 1.0)", + "vec3(1.0, 0.0, 0.0)", + "vec3(c)", + "vec3(0.32, 0.34, 0.38)", + "vec3(0.80, 0.86, 0.95)", + "vec3(0.6)", + "vec3(0.9)", + "vec3(0.55, 0.56, 0.60)", + "vec3(0.20, 0.20, 0.22)", + "vec3(0.35)", + "vec3(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"); +} diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs new file mode 100644 index 00000000..5d5449f4 --- /dev/null +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -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::() / 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 { + 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 { + let text = format!("{t:#?}"); + let mut out = BTreeSet::new(); + let mut parent: Option = 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 { + 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::() - 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 { + 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::>(); + 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 { 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::(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 = 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::()) + } + 2 => json!((0..next() % 40).map(|_| char::from_u32((next() % 0x10FFFF) as u32).unwrap_or('x')).collect::()), + 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::>()), + _ => 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"})); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7f5f5f6d..2a180663 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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") { diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 3be668a5..76eab174 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -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 = ps.iter().filter_map(|p| proj.to_screen(*p)).collect(); if sp.len() == 3 { let base = em.vertices.len() as u32; diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 144113bd..86f8e04d 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -799,13 +799,11 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option { 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) diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index 3ec9c6c2..47691692 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -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)))); } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 85ed6736..1c3c74ef 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -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, /// 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 { 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, @@ -1044,6 +1052,8 @@ pub(crate) fn render_offscreen( eye: [f32; 4], size: u32, tess: Option<&str>, + analysis: [f32; 8], + colors: [f32; 36], ) -> Option> { 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; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ded9cf4c..2a5bab03 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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"] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b18f1ab..7421b0ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index b8ab3713..738b76f4 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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 = 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::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: D) -> Result { + 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; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 903b222b..8c5eb952 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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 }; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 77057d62..86e833ca 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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 { 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 = (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)] diff --git a/crates/ui-egui/tests/scenarios/colour_vision_setting.json b/crates/ui-egui/tests/scenarios/colour_vision_setting.json new file mode 100644 index 00000000..0cb44845 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/colour_vision_setting.json @@ -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"}}} +] diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 92e80cd1..0b9de9d0 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a3f955f0..387a6516 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 |