Colour vision modes (Protan, Deutan, Tritan) in Preferences > Graphics, with palettes, shader and icon colours adapted per mode, translations and tests.
This commit is contained in:
1 parent
289b521a65
commit
f6a47a49d7
30 files changed
+2077
-166
No files matched your search
@@ -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));
|
||||
};
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
|
||||
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
|
||||
/// round strip), and the iso lines and comb teeth drawn on sheets.
|
||||
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
|
||||
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
|
||||
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
|
||||
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
|
||||
|
||||
/// `n + 1` points along the cubic Bézier curve with control points `c`.
|
||||
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
|
||||
(0..=n)
|
||||
@@ -127,6 +120,12 @@ fn line_type_dot_radius(r: Rect) -> f32 {
|
||||
|
||||
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
|
||||
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
|
||||
paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons);
|
||||
}
|
||||
|
||||
/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`).
|
||||
pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) {
|
||||
let ic = *ic;
|
||||
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
|
||||
let a = pen.accent;
|
||||
match name {
|
||||
@@ -164,7 +163,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"sphere" => {
|
||||
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
|
||||
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
|
||||
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
|
||||
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint);
|
||||
}
|
||||
"torus" => {
|
||||
let pts: Vec<Pos2> =
|
||||
@@ -172,7 +171,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
|
||||
let pts: Vec<Pos2> =
|
||||
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
|
||||
}
|
||||
"fillet" => {
|
||||
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
|
||||
@@ -214,7 +213,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
|
||||
let pts: Vec<Pos2> =
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a)));
|
||||
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
|
||||
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
|
||||
}
|
||||
@@ -231,7 +230,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"shell" => {
|
||||
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
|
||||
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
|
||||
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a);
|
||||
}
|
||||
"draft" => {
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
|
||||
@@ -478,7 +477,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"c_coincident" => {
|
||||
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
|
||||
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
|
||||
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
|
||||
pen.circle(12.0, 12.0, 2.6, ic.highlight, a);
|
||||
}
|
||||
"c_tangent" => {
|
||||
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
|
||||
@@ -525,7 +524,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
|
||||
}
|
||||
"new" => {
|
||||
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
|
||||
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink);
|
||||
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
|
||||
}
|
||||
"open" => {
|
||||
@@ -533,7 +532,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"save" => {
|
||||
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
|
||||
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
|
||||
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink);
|
||||
}
|
||||
"export" => {
|
||||
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
|
||||
@@ -572,7 +571,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
|
||||
pen.circle(10.0, 10.0, 6.0, ic.highlight, ink);
|
||||
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
|
||||
}
|
||||
"perspective" => {
|
||||
@@ -590,13 +589,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
|
||||
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
|
||||
}
|
||||
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
|
||||
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink),
|
||||
"origin" => {
|
||||
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
|
||||
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
|
||||
pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]);
|
||||
pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]);
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]);
|
||||
}
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink),
|
||||
"axis" => {
|
||||
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
|
||||
pen.circle(4.0, 20.0, 2.0, ink, ink);
|
||||
@@ -825,8 +824,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
}
|
||||
"accessibility" => {
|
||||
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
|
||||
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
|
||||
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink);
|
||||
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT);
|
||||
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
|
||||
pen.arrow((4.0, 2.0), (4.0, 13.0), a);
|
||||
pen.arrow((20.0, 2.0), (20.0, 13.0), a);
|
||||
@@ -834,10 +833,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"environment_map" => {
|
||||
let pts: Vec<Pos2> =
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
|
||||
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
|
||||
pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
|
||||
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
|
||||
p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink)));
|
||||
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT);
|
||||
pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight);
|
||||
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight);
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
}
|
||||
"zebra" | "draft_analysis" | "curvature_map" => {
|
||||
@@ -845,27 +844,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
|
||||
match name {
|
||||
"zebra" => {
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
|
||||
for k in 0..3 {
|
||||
let y = 6.5 + k as f32 * 4.5;
|
||||
let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
|
||||
pen.poly(
|
||||
&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
|
||||
Color32::from_gray(30),
|
||||
Color32::TRANSPARENT,
|
||||
);
|
||||
pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT);
|
||||
}
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
}
|
||||
"draft_analysis" => {
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
|
||||
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink);
|
||||
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT);
|
||||
pen.arrow((12.0, 16.0), (12.0, 1.5), a);
|
||||
}
|
||||
_ => {
|
||||
p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
|
||||
pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
|
||||
pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
|
||||
p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink)));
|
||||
pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT);
|
||||
pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -983,13 +978,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"patch" => {
|
||||
// A wire loop with a surface spanned across it.
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink);
|
||||
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
|
||||
}
|
||||
"stitch" => {
|
||||
// Two surface patches sewn along their shared edge.
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink);
|
||||
for y in [6.0, 9.0, 12.0] {
|
||||
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
|
||||
}
|
||||
@@ -999,7 +994,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
|
||||
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
|
||||
let d = f.map(|(x, y)| (x, y + 6.0));
|
||||
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
|
||||
pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet);
|
||||
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
|
||||
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
|
||||
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
|
||||
@@ -1042,7 +1037,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// An open profile (accent) pulled up into a sheet.
|
||||
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
|
||||
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
|
||||
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
|
||||
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
|
||||
pen.curve(top, ink);
|
||||
@@ -1053,13 +1048,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// An open profile (accent) turned about the axis into a hollow band.
|
||||
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
|
||||
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
|
||||
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
|
||||
pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet);
|
||||
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
|
||||
pen.poly(&front, SHEET, Color32::TRANSPARENT);
|
||||
pen.poly(&front, ic.sheet, Color32::TRANSPARENT);
|
||||
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
|
||||
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
|
||||
opening.pop();
|
||||
pen.poly(&opening, SHEET_LIGHT, ink);
|
||||
pen.poly(&opening, ic.sheet_light, ink);
|
||||
pen.line(&front, ink);
|
||||
pen.curve(left, ink);
|
||||
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
|
||||
@@ -1070,7 +1065,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A straight profile (accent) carried along a curved path: a ribbon.
|
||||
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
|
||||
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet);
|
||||
pen.curve(near, ink);
|
||||
pen.curve(far, ink);
|
||||
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
|
||||
@@ -1080,7 +1075,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// Two open sections (accent) joined by a sheet.
|
||||
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
|
||||
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
|
||||
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
|
||||
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
|
||||
pen.curve(top, a);
|
||||
@@ -1090,7 +1085,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
|
||||
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
|
||||
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
|
||||
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
|
||||
pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet);
|
||||
pen.curve(back, ink);
|
||||
pen.curve(front, ink);
|
||||
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
|
||||
@@ -1104,7 +1099,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"ruled" => {
|
||||
// A strip hanging off a block's edge (accent), ruled by straight lines.
|
||||
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
|
||||
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
|
||||
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink);
|
||||
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
|
||||
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
|
||||
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
|
||||
@@ -1140,13 +1135,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
})
|
||||
.collect();
|
||||
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
|
||||
pen.band(lo, hi, SHEET);
|
||||
pen.band(lo, hi, ic.sheet);
|
||||
}
|
||||
for j in [n / 3, 2 * n / 3] {
|
||||
if let Some(r) = rows.get(j) {
|
||||
pen.line(r, SHEET_LINE);
|
||||
pen.line(r, ic.sheet_line);
|
||||
}
|
||||
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
|
||||
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), ic.sheet_line);
|
||||
}
|
||||
for c in [left, top, right, bottom] {
|
||||
pen.curve(c, a);
|
||||
@@ -1160,29 +1155,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// leaves both edges tangent.
|
||||
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
|
||||
pen.curve(far, a);
|
||||
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
|
||||
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
"untrim" => {
|
||||
// A trimmed sheet whose hole (dashed, accent) is filled back in.
|
||||
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
|
||||
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink);
|
||||
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
|
||||
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
|
||||
pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT);
|
||||
pen.dashed(&hole, a);
|
||||
}
|
||||
"unstitch" => {
|
||||
// Two sheets pulled apart from their shared edge.
|
||||
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
|
||||
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink);
|
||||
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink);
|
||||
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
|
||||
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
|
||||
}
|
||||
"reverse_normal" => {
|
||||
// A sheet with its old normal (faded) and the flipped one (accent).
|
||||
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
|
||||
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink);
|
||||
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
|
||||
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
|
||||
}
|
||||
@@ -1190,10 +1185,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A wall sheet and a floor sheet joined by a round strip (accent edges).
|
||||
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
|
||||
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
|
||||
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink);
|
||||
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
|
||||
pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line);
|
||||
pen.curve(far, a);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
@@ -1209,11 +1204,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
let h = 0.8 + 4.2 * i as f32 / 10.0;
|
||||
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
|
||||
if i % 2 == 0 {
|
||||
pen.line(&[(*x, *y), tip], SHEET_LINE);
|
||||
pen.line(&[(*x, *y), tip], ic.sheet_line);
|
||||
}
|
||||
tips.push(tip);
|
||||
}
|
||||
pen.line(&tips, SHEET_LINE);
|
||||
pen.line(&tips, ic.sheet_line);
|
||||
pen.curve(c, a);
|
||||
pen.dot(12.0, 13.0, a);
|
||||
}
|
||||
@@ -1256,7 +1251,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"interference" => {
|
||||
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
|
||||
pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
|
||||
}
|
||||
// Sheet metal: thin plates (fill), the made or changed part in the accent.
|
||||
@@ -1283,7 +1278,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"fold" => {
|
||||
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
|
||||
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
|
||||
pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
|
||||
pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold);
|
||||
pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
|
||||
pen.arrow((18.0, 16.0), (17.0, 12.5), a);
|
||||
}
|
||||
@@ -1358,13 +1353,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// Appearance: a shaded ball with three paint dabs.
|
||||
"appearance" => {
|
||||
pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
|
||||
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
|
||||
pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
|
||||
pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
|
||||
pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
|
||||
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight);
|
||||
pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink);
|
||||
pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink);
|
||||
pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink);
|
||||
}
|
||||
"warning" => {
|
||||
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
|
||||
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink);
|
||||
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
|
||||
pen.dot(12.0, 17.0, ink);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2};
|
||||
|
||||
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
|
||||
let white = Stroke::new(1.5, Color32::WHITE);
|
||||
let ic = Tokens::get().icons;
|
||||
let halo = Stroke::new(4.5, ic.marker_halo);
|
||||
let white = Stroke::new(1.5, ic.highlight);
|
||||
if let Some(tip) = tip {
|
||||
let d = (tip - c).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
@@ -30,7 +31,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
}
|
||||
}
|
||||
};
|
||||
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
|
||||
painter.add(shape(ic.marker_halo, Stroke::NONE, 4.0));
|
||||
painter.add(shape(col, Stroke::NONE, 0.0));
|
||||
match kind {
|
||||
SnapKind::FaceCenter => {
|
||||
@@ -38,8 +39,8 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
}
|
||||
SnapKind::CircleCenter => {
|
||||
painter.circle_stroke(c, 7.0, white);
|
||||
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, ic.highlight));
|
||||
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, ic.highlight));
|
||||
}
|
||||
SnapKind::EdgeMid | SnapKind::Vertex => {
|
||||
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Original agent pointer and label. Animation stays with the UI controller.
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
|
||||
pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
|
||||
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
|
||||
pub(crate) fn agent() -> Color32 {
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = crate::theme::Tokens::get().icons;
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
|
||||
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, AGENT);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
|
||||
painter.rect_filled(r, 4.0, ic.agent);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "同心",
|
||||
|
||||
@@ -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": "브라우저에서 찾기",
|
||||
|
||||
@@ -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": "Концентричность",
|
||||
|
||||
@@ -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": "同心",
|
||||
|
||||
@@ -10,6 +10,10 @@ struct U {
|
||||
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
|
||||
acc_v: vec4<f32>,
|
||||
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
|
||||
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
|
||||
// blocked; curvature flat, middle, tight
|
||||
ana_c: array<vec4<f32>, 8>,
|
||||
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
|
||||
};
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
|
||||
// Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
|
||||
let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
|
||||
let clear = dot(i.wp, d) >= top - slack;
|
||||
var c = vec3<f32>(0.85, 0.22, 0.20);
|
||||
if (facing && clear) { c = vec3<f32>(0.20, 0.75, 0.30); }
|
||||
var c = u.ana_c[4].rgb;
|
||||
if (facing && clear) { c = u.ana_c[3].rgb; } else { c = stripe(i, c); }
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
if (u.ana.x > 3.5) {
|
||||
@@ -171,18 +175,25 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
|
||||
}
|
||||
if (u.ana.x < 2.5) {
|
||||
let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
|
||||
var c = vec3<f32>(0.95, 0.80, 0.15);
|
||||
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
|
||||
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
|
||||
var c = u.ana_c[1].rgb;
|
||||
if (a >= u.ana.y) { c = u.ana_c[0].rgb; }
|
||||
if (a <= -u.ana.y) { c = stripe(i, u.ana_c[2].rgb); }
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
// Curvature from how fast the normal turns across the pixel.
|
||||
let t = clamp(k * u.ana.y, 0.0, 1.0);
|
||||
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
|
||||
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
|
||||
let c = select(mix(u.ana_c[6].rgb, u.ana_c[7].rgb, t * 2.0 - 1.0), mix(u.ana_c[5].rgb, u.ana_c[6].rgb, t * 2.0), t < 0.5);
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
|
||||
/// A "bad" analysis face (negative draft, blocked): with the stripes on (colour vision modes), the
|
||||
/// section cap's 45 degree hatch over it, so colour is not the only cue.
|
||||
fn stripe(i: TOut, c: vec3<f32>) -> vec3<f32> {
|
||||
if (u.ana_f.x < 0.5) { return c; }
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
return select(c, c * 0.6, h < 0.22);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Every colour that carries a meaning comes from `theme::Tokens` (#778): the engine's sketch and
|
||||
//! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that
|
||||
//! the tokens equal what was drawn before, and that a new colour cannot skip them.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, vec2};
|
||||
use solidcraft_engine::render::Rgb;
|
||||
use solidcraft_engine::view::colors;
|
||||
|
||||
use crate::theme::{IconColors, Tokens};
|
||||
|
||||
fn rgb(c: Rgb) -> Color32 {
|
||||
Color32::from_rgb(c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// The new tokens hold exactly the values that were drawn in place before they were tokens, so
|
||||
/// with no colour vision mode on, nothing on screen changes.
|
||||
#[test]
|
||||
fn moved_colours_keep_the_values_they_were_drawn_with() {
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
assert_eq!(t.sketch_projected, rgb(colors::SKETCH_PROJECTED));
|
||||
assert_eq!(t.sketch_lost, rgb(colors::SKETCH_LOST));
|
||||
assert_eq!(t.sketch_construction, rgb(colors::SKETCH_CONSTRUCTION));
|
||||
assert_eq!(t.sketch_centerline, rgb(colors::SKETCH_CENTERLINE));
|
||||
assert_eq!(t.triad, [rgb(colors::AXIS_X), rgb(colors::AXIS_Y), rgb(colors::AXIS_Z)]);
|
||||
assert_eq!(t.gizmo, [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)]);
|
||||
assert_eq!(t.interference, Color32::from_rgb(235, 45, 40));
|
||||
// The shader's former literals, bit for bit.
|
||||
assert_eq!(t.analysis.draft, [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]]);
|
||||
assert_eq!(t.analysis.access, [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]]);
|
||||
assert_eq!(t.analysis.curvature, [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]]);
|
||||
assert!(!t.analysis.hatch);
|
||||
assert_eq!(t.app_bar_icon, [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)]);
|
||||
assert_eq!(t.logo, [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE]);
|
||||
assert_eq!(t.icons.glint, Color32::from_white_alpha(160));
|
||||
assert_eq!(t.icons.marker_halo, Color32::from_black_alpha(150));
|
||||
assert_eq!(t.icons.agent, Color32::from_rgb(168, 85, 247));
|
||||
}
|
||||
let (l, d) = (Tokens::light(), Tokens::dark());
|
||||
assert_eq!((l.bend_up, l.bend_down), (Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20)));
|
||||
assert_eq!((d.bend_up, d.bend_down), (Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80)));
|
||||
}
|
||||
|
||||
/// The analysis colours reach the shader in the order its `ana_c` array reads them.
|
||||
#[test]
|
||||
fn analysis_colours_reach_the_shader_in_its_order() {
|
||||
let a = Tokens::light().analysis;
|
||||
let v = crate::gpu::analysis_colors(&a);
|
||||
let at = |i: usize| [v[4 * i], v[4 * i + 1], v[4 * i + 2]];
|
||||
assert_eq!([at(0), at(1), at(2)], a.draft);
|
||||
assert_eq!([at(3), at(4)], a.access);
|
||||
assert_eq!([at(5), at(6), at(7)], a.curvature);
|
||||
assert!((0..8).all(|i| v[4 * i + 3] == 1.0));
|
||||
assert_eq!(v[32], 0.0, "no stripes without a colour vision mode");
|
||||
let mut striped = a;
|
||||
striped.hatch = true;
|
||||
assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0);
|
||||
}
|
||||
|
||||
/// Every name a `match` arm in the icon source draws.
|
||||
fn icon_names() -> Vec<&'static str> {
|
||||
let src = include_str!("../../../assets/icons/ui/source/icons.rs");
|
||||
let body = src.split("#[cfg(test)]").next().unwrap_or(src);
|
||||
let mut names = Vec::new();
|
||||
for line in body.lines() {
|
||||
let line = line.trim_start();
|
||||
if !line.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
let Some((pattern, _)) = line.split_once("=>") else { continue };
|
||||
let pattern = pattern.split(" if ").next().unwrap_or(pattern);
|
||||
for part in pattern.split('|') {
|
||||
let part = part.trim();
|
||||
if part.len() > 2 && part.starts_with('"') && part.ends_with('"') {
|
||||
names.push(&part[1..part.len() - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Marker icon colours: every field its own colour, from `seed`.
|
||||
fn marker_icons(seed: u8) -> IconColors {
|
||||
let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i);
|
||||
IconColors {
|
||||
sheet: c(1),
|
||||
sheet_2: c(2),
|
||||
sheet_light: c(3),
|
||||
sheet_line: c(4),
|
||||
highlight: c(5),
|
||||
glint: c(6),
|
||||
folder: c(7),
|
||||
plane: c(8),
|
||||
fold: c(9),
|
||||
stripe: c(10),
|
||||
sky: c(11),
|
||||
floor: c(12),
|
||||
paint: [c(13), c(14), c(15)],
|
||||
axes: [c(16), c(17), c(18)],
|
||||
access: [c(19), c(20)],
|
||||
draft: [c(21), c(22)],
|
||||
curvature: [c(23), c(24), c(25)],
|
||||
interference: c(26),
|
||||
warning: c(27),
|
||||
agent: c(28),
|
||||
marker_halo: c(29),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order).
|
||||
fn drawn_colours(name: &str, seed: u8) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
|
||||
let icons = marker_icons(seed);
|
||||
let out = ctx.run_ui(input, |ui| {
|
||||
let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
|
||||
let ink = Color32::from_rgb(seed, 101, 1);
|
||||
let fill = Color32::from_rgb(seed, 102, 2);
|
||||
let accent = Color32::from_rgb(seed, 103, 3);
|
||||
crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons);
|
||||
});
|
||||
let text = format!("{:?}", out.shapes);
|
||||
let b = text.as_bytes();
|
||||
let mut colours = Vec::new();
|
||||
for i in 0..b.len() {
|
||||
let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit);
|
||||
if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) {
|
||||
colours.push(text[i..i + 12].to_string());
|
||||
}
|
||||
}
|
||||
colours
|
||||
}
|
||||
|
||||
/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the
|
||||
/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made
|
||||
/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the
|
||||
/// same both times was written into the icon, and a mode could not reach it.
|
||||
#[test]
|
||||
fn every_icon_draws_only_colours_a_mode_can_reach() {
|
||||
let names = icon_names();
|
||||
assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len());
|
||||
let mut fixed = Vec::new();
|
||||
for name in &names {
|
||||
let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200));
|
||||
assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape");
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
if x == y && !x.ends_with("_00") {
|
||||
fixed.push(format!("{name}: {x}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
fixed.dedup();
|
||||
assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}");
|
||||
}
|
||||
|
||||
/// The most colour literals each UI file may hold, and why. None of them carries a meaning that a
|
||||
/// colour vision mode must change. A file with more fails here: make the new colour a token in
|
||||
/// `theme::Tokens` (and `assets/styles/ui/palettes.rs`) instead, or, if it carries no meaning,
|
||||
/// raise the file's count here and give the reason. Fewer is always fine. The icon sources may
|
||||
/// hold none at all.
|
||||
const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
|
||||
("navigation.rs", 1, "the navigation cube's face shading, made from a token"),
|
||||
("canvas.rs", 3, "white rings around manipulator handles"),
|
||||
("context_menu.rs", 1, "the radial menu's shadow"),
|
||||
("dialogs_appearance.rs", 1, "a design appearance: the colour is the fact"),
|
||||
("dialogs_library.rs", 1, "a library appearance's swatch: the colour is the fact"),
|
||||
("dialogs_assembly.rs", 1, "the interference fill, shaded from its token"),
|
||||
("dialogs_motion.rs", 1, "a white outline"),
|
||||
("documents.rs", 4, "white hover overlays on document tabs"),
|
||||
("frame.rs", 1, "the window shadow"),
|
||||
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
|
||||
("home.rs", 4, "white ink on accent buttons, and thumbnail tints"),
|
||||
("ref_images.rs", 1, "a reference image's opacity tint"),
|
||||
("sketch_palette.rs", 1, "white text on the accent button"),
|
||||
("sketch_text.rs", 1, "white text on the selection chip"),
|
||||
("titlebar.rs", 4, "the window close button's red hover (the system's convention), white hovers and ink"),
|
||||
("toolbar.rs", 3, "white hover overlays and the version text"),
|
||||
("update.rs", 1, "text laid out only to measure its width, never drawn"),
|
||||
];
|
||||
|
||||
const COLOUR_LITERALS: &[&str] = &[
|
||||
"Color32::from_rgb(",
|
||||
"Color32::from_rgba_unmultiplied(",
|
||||
"Color32::from_rgba_unmultiplied_const(",
|
||||
"Color32::from_rgba_premultiplied(",
|
||||
"Color32::from_gray(",
|
||||
"Color32::from_black_alpha(",
|
||||
"Color32::from_white_alpha(",
|
||||
"Color32::from_hex(",
|
||||
"Color32::WHITE",
|
||||
"Color32::BLACK",
|
||||
"Color32::RED",
|
||||
"Color32::GREEN",
|
||||
"Color32::BLUE",
|
||||
"Color32::YELLOW",
|
||||
"Color32::GOLD",
|
||||
"Color32::ORANGE",
|
||||
"Color32::BROWN",
|
||||
"Color32::KHAKI",
|
||||
"Color32::GRAY",
|
||||
"Color32::LIGHT_GRAY",
|
||||
"Color32::DARK_GRAY",
|
||||
"Color32::DARK_RED",
|
||||
"Color32::LIGHT_RED",
|
||||
"Color32::DARK_BLUE",
|
||||
"Color32::LIGHT_BLUE",
|
||||
"Color32::DARK_GREEN",
|
||||
"Color32::LIGHT_GREEN",
|
||||
"Color32::LIGHT_YELLOW",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn colours_outside_the_theme_are_only_the_listed_neutral_ones() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dirs = [root.join("src"), root.join("../../assets/icons/ui/source")];
|
||||
let mut found = Vec::new();
|
||||
for dir in &dirs {
|
||||
let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string();
|
||||
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "theme.rs" {
|
||||
continue;
|
||||
}
|
||||
let src = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let code = src.split("#[cfg(test)]").next().unwrap_or(&src);
|
||||
let n: usize = COLOUR_LITERALS.iter().map(|p| code.matches(p).count()).sum();
|
||||
let allowed = ALLOWED_LITERALS.iter().find(|(f, _, _)| *f == name).map_or(0, |(_, n, _)| *n);
|
||||
if n > allowed {
|
||||
found.push(format!("{name}: {n} colour literal(s), at most {allowed} allowed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found.is_empty(), "colour literals outside theme::Tokens: {found:?}");
|
||||
}
|
||||
|
||||
/// The analysis shader takes its colours from the uniform (and so from the tokens). Only the
|
||||
/// zebra and environment map neutrals and direction vectors stay as literals.
|
||||
#[test]
|
||||
fn analysis_shader_colours_come_from_the_uniform() {
|
||||
let src = include_str!("../../../assets/shaders/viewport.wgsl");
|
||||
let start = src.find("fn analysis(").unwrap_or(0);
|
||||
let end = src.find("fn stripe(").unwrap_or(src.len());
|
||||
let body = &src[start..end];
|
||||
let mut literals = Vec::new();
|
||||
let mut rest = body;
|
||||
while let Some(i) = rest.find("vec3<f32>(") {
|
||||
let tail = &rest[i..];
|
||||
let close = tail.find(')').unwrap_or(tail.len() - 1);
|
||||
literals.push(&tail[..=close]);
|
||||
rest = &tail[close..];
|
||||
}
|
||||
assert_eq!(
|
||||
literals,
|
||||
[
|
||||
"vec3<f32>(0.0, 0.0, 1.0)",
|
||||
"vec3<f32>(1.0, 0.0, 0.0)",
|
||||
"vec3<f32>(c)",
|
||||
"vec3<f32>(0.32, 0.34, 0.38)",
|
||||
"vec3<f32>(0.80, 0.86, 0.95)",
|
||||
"vec3<f32>(0.6)",
|
||||
"vec3<f32>(0.9)",
|
||||
"vec3<f32>(0.55, 0.56, 0.60)",
|
||||
"vec3<f32>(0.20, 0.20, 0.22)",
|
||||
"vec3<f32>(0.35)",
|
||||
"vec3<f32>(1.0)",
|
||||
],
|
||||
"a new colour in the analysis shader must come from `ana_c` (theme::AnalysisColors)"
|
||||
);
|
||||
assert_eq!(body.matches("u.ana_c[").count(), 9, "every analysis colour reads `ana_c`");
|
||||
}
|
||||
|
||||
/// Every colour the engine tags a scene line with is mapped to a token by the viewport, or carries
|
||||
/// no meaning a colour vision mode must change. A new engine tag fails here until it is placed.
|
||||
#[test]
|
||||
fn every_engine_colour_tag_is_placed() {
|
||||
const MAPPED: &[&str] = &[
|
||||
"SKETCH",
|
||||
"SKETCH_DONE",
|
||||
"SKETCH_FIXED",
|
||||
"SKETCH_LOCKED",
|
||||
"SKETCH_PROJECTED",
|
||||
"SKETCH_LOST",
|
||||
"SKETCH_CONSTRUCTION",
|
||||
"SKETCH_CENTERLINE",
|
||||
"AXIS_X",
|
||||
"AXIS_Y",
|
||||
"AXIS_Z",
|
||||
];
|
||||
// Bodies, edges, profiles, the grid and the background: the GPU viewport draws these from
|
||||
// their own tokens; the engine's values are only the headless snapshot's.
|
||||
const NEUTRAL: &[&str] = &["BODY", "BODY_SELECTED", "EDGE", "PROFILE", "GRID", "GRID_MAJOR", "BG_TOP", "BG_BOTTOM", "SECTION_CAP"];
|
||||
let src = include_str!("../../engine/src/view.rs");
|
||||
let start = src.find("pub mod colors {").unwrap_or(0);
|
||||
let block = &src[start..];
|
||||
let block = &block[..block.find("\n}").unwrap_or(block.len())];
|
||||
let mut names: Vec<&str> = block.lines().filter_map(|l| l.trim().strip_prefix("pub const ")).filter_map(|l| l.split(':').next()).collect();
|
||||
names.sort_unstable();
|
||||
let mut placed: Vec<&str> = MAPPED.iter().chain(NEUTRAL).copied().collect();
|
||||
placed.sort_unstable();
|
||||
assert_eq!(names, placed, "an engine colour tag must be mapped to a token in `viewport::sketch_color` or listed as neutral");
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
//! Colour vision modes (#778), measured. Colours that appear together must stay apart (CIEDE2000
|
||||
//! 10 or more) for normal vision and for the loss each mode is for, with two simulations of that
|
||||
//! loss: Machado, Oliveira and Fernandes 2009 (severity 1) and Brettel, Viénot and Mollon 1997.
|
||||
//! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published
|
||||
//! with their paper, Brettel on white and its own anchors.
|
||||
//!
|
||||
//! When a token edit breaks a mode here, give that token a value in the mode's table
|
||||
//! (`assets/styles/ui/palettes.rs`) that passes.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use egui::Color32;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::{ColorVision, Tokens};
|
||||
|
||||
type Rgb8 = [u8; 3];
|
||||
type V3 = [f64; 3];
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
/// The smallest difference a pair may have (CIEDE2000).
|
||||
const FLOOR: f64 = 10.0;
|
||||
/// The shading the analysis colours get in the shader (`lit`: 0.55 turned away, 1 facing the eye).
|
||||
const LIT: [f64; 4] = [0.55, 0.7, 0.85, 1.0];
|
||||
const MODES: [ColorVision; 3] = [ColorVision::Protan, ColorVision::Deutan, ColorVision::Tritan];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Eye {
|
||||
Normal,
|
||||
Protan,
|
||||
Deutan,
|
||||
Tritan,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Instrument {
|
||||
Machado,
|
||||
Brettel,
|
||||
}
|
||||
|
||||
/// The loss a mode is for.
|
||||
fn eye(mode: ColorVision) -> Eye {
|
||||
match mode {
|
||||
ColorVision::Off => Eye::Normal,
|
||||
ColorVision::Protan => Eye::Protan,
|
||||
ColorVision::Deutan => Eye::Deutan,
|
||||
ColorVision::Tritan => Eye::Tritan,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the instruments ----
|
||||
|
||||
fn to_lin(c: f64) -> f64 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn to_srgb(c: f64) -> f64 {
|
||||
if c <= 0.0031308 { 12.92 * c } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
fn mul(m: &M3, v: V3) -> V3 {
|
||||
[0, 1, 2].map(|r| m[r][0] * v[0] + m[r][1] * v[1] + m[r][2] * v[2])
|
||||
}
|
||||
|
||||
fn mat(a: &M3, b: &M3) -> M3 {
|
||||
[0, 1, 2].map(|r| [0, 1, 2].map(|c| (0..3).map(|k| a[r][k] * b[k][c]).sum()))
|
||||
}
|
||||
|
||||
fn inv(m: &M3) -> M3 {
|
||||
let [[a, b, c], [d, e, f], [g, h, i]] = *m;
|
||||
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||
[
|
||||
[(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det],
|
||||
[(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det],
|
||||
[(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det],
|
||||
]
|
||||
}
|
||||
|
||||
fn cross(u: V3, v: V3) -> V3 {
|
||||
[u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
|
||||
}
|
||||
|
||||
fn dot(u: V3, v: V3) -> f64 {
|
||||
u[0] * v[0] + u[1] * v[1] + u[2] * v[2]
|
||||
}
|
||||
|
||||
/// Machado, Oliveira, Fernandes 2009, severity 1.0, on linear sRGB (the authors' Table 1).
|
||||
fn machado(eye: Eye) -> M3 {
|
||||
match eye {
|
||||
Eye::Protan => [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
|
||||
Eye::Deutan => [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]],
|
||||
Eye::Tritan => [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.303900]],
|
||||
Eye::Normal => [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear sRGB to CIE XYZ (D65), IEC 61966-2-1.
|
||||
const XYZ_FROM_LIN: M3 = [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]];
|
||||
/// XYZ to cone responses (LMS), Smith and Pokorny 1975.
|
||||
const LMS_FROM_XYZ: M3 = [[0.15514, 0.54312, -0.03286], [-0.15514, 0.45684, 0.03286], [0.0, 0.0, 0.00801]];
|
||||
/// CIE 1931 2-degree colour matching functions (CIE 018:2019) at Brettel's anchors.
|
||||
const CIE_475: V3 = [0.1421, 0.1126, 1.0419];
|
||||
const CIE_485: V3 = [0.05795001, 0.1693, 0.6162];
|
||||
const CIE_575: V3 = [0.8425, 0.9154, 0.0018];
|
||||
const CIE_660: V3 = [0.1649, 0.061, 0.0];
|
||||
|
||||
/// The missing cone (0 L, 1 M, 2 S) and the two anchor wavelengths of its half-planes.
|
||||
fn anchors(eye: Eye) -> (usize, V3, V3) {
|
||||
match eye {
|
||||
Eye::Protan => (0, CIE_475, CIE_575),
|
||||
Eye::Deutan => (1, CIE_475, CIE_575),
|
||||
Eye::Normal | Eye::Tritan => (2, CIE_485, CIE_660),
|
||||
}
|
||||
}
|
||||
|
||||
/// Brettel, Viénot, Mollon 1997: the missing cone's response is put back on one of two half-planes
|
||||
/// through white and an anchor colour, chosen by the side of the white axis the colour is on.
|
||||
fn brettel(eye: Eye, lin: V3) -> V3 {
|
||||
let lms = mat(&LMS_FROM_XYZ, &XYZ_FROM_LIN);
|
||||
let (c, w1, w2) = anchors(eye);
|
||||
let white = mul(&lms, [1.0; 3]);
|
||||
let (a1, a2) = (mul(&LMS_FROM_XYZ, w1), mul(&LMS_FROM_XYZ, w2));
|
||||
let mut axis = [0.0; 3];
|
||||
axis[c] = 1.0;
|
||||
let sep = cross(white, axis);
|
||||
let mut q = mul(&lms, lin);
|
||||
let n = if dot(q, sep) * dot(a1, sep) >= 0.0 { cross(white, a1) } else { cross(white, a2) };
|
||||
q[c] = -(0..3).filter(|k| *k != c).map(|k| n[k] * q[k]).sum::<f64>() / n[c];
|
||||
mul(&inv(&lms), q)
|
||||
}
|
||||
|
||||
/// What an eye sees of an sRGB colour, as sRGB 0..1.
|
||||
fn simulate(c: Rgb8, eye: Eye, how: Instrument) -> V3 {
|
||||
let srgb = c.map(|v| f64::from(v) / 255.0);
|
||||
if eye == Eye::Normal {
|
||||
return srgb;
|
||||
}
|
||||
let lin = srgb.map(to_lin);
|
||||
let out = match how {
|
||||
Instrument::Machado => mul(&machado(eye), lin),
|
||||
Instrument::Brettel => brettel(eye, lin),
|
||||
};
|
||||
out.map(|v| to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// CIELAB (D65) of an sRGB colour.
|
||||
fn lab(srgb: V3) -> V3 {
|
||||
let [x, y, z] = mul(&XYZ_FROM_LIN, srgb.map(to_lin));
|
||||
let f = |t: f64| if t > (6.0f64 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f64 / 29.0).powi(2)) + 4.0 / 29.0 };
|
||||
let (fx, fy, fz) = (f(x / 0.95047), f(y), f(z / 1.08883));
|
||||
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
|
||||
}
|
||||
|
||||
/// CIEDE2000 (Sharma, Wu, Dalal 2005).
|
||||
fn de2000(l1: V3, l2: V3) -> f64 {
|
||||
let ([lightness1, a1, b1], [lightness2, a2, b2]) = (l1, l2);
|
||||
let (c1, c2) = (a1.hypot(b1), a2.hypot(b2));
|
||||
let cb = (c1 + c2) / 2.0;
|
||||
let g = 0.5 * (1.0 - (cb.powi(7) / (cb.powi(7) + 25f64.powi(7))).sqrt());
|
||||
let (a1p, a2p) = ((1.0 + g) * a1, (1.0 + g) * a2);
|
||||
let (c1p, c2p) = (a1p.hypot(b1), a2p.hypot(b2));
|
||||
let hue = |b: f64, a: f64, c: f64| if c == 0.0 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
|
||||
let (h1p, h2p) = (hue(b1, a1p, c1p), hue(b2, a2p, c2p));
|
||||
let dlp = lightness2 - lightness1;
|
||||
let dcp = c2p - c1p;
|
||||
let dhp = if c1p * c2p == 0.0 {
|
||||
0.0
|
||||
} else if (h2p - h1p).abs() <= 180.0 {
|
||||
h2p - h1p
|
||||
} else if h2p - h1p > 180.0 {
|
||||
h2p - h1p - 360.0
|
||||
} else {
|
||||
h2p - h1p + 360.0
|
||||
};
|
||||
let dhp_big = 2.0 * (c1p * c2p).sqrt() * (dhp / 2.0).to_radians().sin();
|
||||
let lbp = (lightness1 + lightness2) / 2.0;
|
||||
let cbp = (c1p + c2p) / 2.0;
|
||||
let hbp = if c1p * c2p == 0.0 {
|
||||
h1p + h2p
|
||||
} else if (h1p - h2p).abs() <= 180.0 {
|
||||
(h1p + h2p) / 2.0
|
||||
} else if h1p + h2p < 360.0 {
|
||||
(h1p + h2p + 360.0) / 2.0
|
||||
} else {
|
||||
(h1p + h2p - 360.0) / 2.0
|
||||
};
|
||||
let cos = |deg: f64| deg.to_radians().cos();
|
||||
let t = 1.0 - 0.17 * cos(hbp - 30.0) + 0.24 * cos(2.0 * hbp) + 0.32 * cos(3.0 * hbp + 6.0) - 0.20 * cos(4.0 * hbp - 63.0);
|
||||
let dtheta = 30.0 * (-((hbp - 275.0) / 25.0).powi(2)).exp();
|
||||
let rc = 2.0 * (cbp.powi(7) / (cbp.powi(7) + 25f64.powi(7))).sqrt();
|
||||
let sl = 1.0 + 0.015 * (lbp - 50.0).powi(2) / (20.0 + (lbp - 50.0).powi(2)).sqrt();
|
||||
let sc = 1.0 + 0.045 * cbp;
|
||||
let sh = 1.0 + 0.015 * cbp * t;
|
||||
let rt = -(2.0 * dtheta).to_radians().sin() * rc;
|
||||
((dlp / sl).powi(2) + (dcp / sc).powi(2) + (dhp_big / sh).powi(2) + rt * (dcp / sc) * (dhp_big / sh)).sqrt()
|
||||
}
|
||||
|
||||
fn diff(a: Rgb8, b: Rgb8, eye: Eye, how: Instrument) -> f64 {
|
||||
de2000(lab(simulate(a, eye, how)), lab(simulate(b, eye, how)))
|
||||
}
|
||||
|
||||
fn normal(a: Rgb8, b: Rgb8) -> f64 {
|
||||
diff(a, b, Eye::Normal, Instrument::Machado)
|
||||
}
|
||||
|
||||
/// The smallest difference for normal vision and for the mode's loss (both instruments).
|
||||
fn worst(mode: ColorVision, a: Rgb8, b: Rgb8) -> f64 {
|
||||
let e = eye(mode);
|
||||
normal(a, b).min(diff(a, b, e, Instrument::Machado)).min(diff(a, b, e, Instrument::Brettel))
|
||||
}
|
||||
|
||||
// ---- the colours that appear together ----
|
||||
|
||||
fn rgb(c: Color32) -> Rgb8 {
|
||||
[c.r(), c.g(), c.b()]
|
||||
}
|
||||
|
||||
/// A translucent colour over an opaque ground, in sRGB (close to egui's blend).
|
||||
fn over(c: Color32, ground: Color32) -> Rgb8 {
|
||||
let [r, g, b, a] = c.to_srgba_unmultiplied();
|
||||
let k = f64::from(a) / 255.0;
|
||||
let mix = |f: u8, g: u8| (f64::from(f) * k + f64::from(g) * (1.0 - k)).round() as u8;
|
||||
[mix(r, ground.r()), mix(g, ground.g()), mix(b, ground.b())]
|
||||
}
|
||||
|
||||
/// An analysis colour as the shader shades it.
|
||||
fn lit(c: [f32; 3], k: f64) -> Rgb8 {
|
||||
c.map(|x| ((f64::from(x) * 255.0).round() * k).min(255.0).round() as u8)
|
||||
}
|
||||
|
||||
struct Group {
|
||||
name: &'static str,
|
||||
/// Colours that must differ from each other.
|
||||
members: Vec<(&'static str, Rgb8)>,
|
||||
/// What they are drawn on.
|
||||
grounds: Vec<(&'static str, Rgb8)>,
|
||||
}
|
||||
|
||||
fn group(name: &'static str, members: &[(&'static str, Color32)], grounds: &[(&'static str, Color32)]) -> Group {
|
||||
for (n, c) in members.iter().chain(grounds) {
|
||||
assert_eq!(c.a(), 255, "{name}: {n} is translucent; put it over its ground first");
|
||||
}
|
||||
Group { name, members: members.iter().map(|(n, c)| (*n, rgb(*c))).collect(), grounds: grounds.iter().map(|(n, c)| (*n, rgb(*c))).collect() }
|
||||
}
|
||||
|
||||
/// Every group of meaning-carrying colours that appear together (and the icons' colours, drawn on
|
||||
/// the toolbar with their caller's ink, fill and accent). Each colour is named by its `Tokens`
|
||||
/// field, so `every_token_is_measured_or_carries_no_meaning` knows which tokens are measured.
|
||||
fn groups(t: &Tokens) -> Vec<Group> {
|
||||
let lift = |c: Color32| {
|
||||
Color32::from_rgb(c.r().saturating_add(t.hover_face_lift), c.g().saturating_add(t.hover_face_lift), c.b().saturating_add(t.hover_face_lift))
|
||||
};
|
||||
let ic = &t.icons;
|
||||
let a = &t.analysis;
|
||||
let mut out = vec![
|
||||
group(
|
||||
"sketch lines",
|
||||
&[
|
||||
("sketch", t.sketch),
|
||||
("sketch_fixed", t.sketch_fixed),
|
||||
("sketch_locked", t.sketch_locked),
|
||||
("sketch_projected", t.sketch_projected),
|
||||
("sketch_lost", t.sketch_lost),
|
||||
("sketch_construction", t.sketch_construction),
|
||||
("sketch_centerline", t.sketch_centerline),
|
||||
("preview_sketch", t.preview_sketch),
|
||||
],
|
||||
&[("viewport_top", t.viewport_top), ("viewport_bottom", t.viewport_bottom)],
|
||||
),
|
||||
group(
|
||||
"sketch points",
|
||||
&[("sketch_point", t.sketch_point), ("sketch_point_free", t.sketch_point_free), ("sketch_locked", t.sketch_locked)],
|
||||
&[("viewport_top", t.viewport_top)],
|
||||
),
|
||||
group(
|
||||
"timeline",
|
||||
&[("timeline_item", t.timeline_item), ("timeline_item_error", t.timeline_item_error), ("hover", t.hover), ("accent_soft", t.accent_soft)],
|
||||
&[],
|
||||
),
|
||||
group("UI text", &[("error", t.error), ("warning", t.warning), ("text", t.text)], &[("panel", t.panel)]),
|
||||
group("origin axes", &[("axis_x", t.axis_x), ("axis_y", t.axis_y), ("axis_z", t.axis_z)], &[("viewport_top", t.viewport_top)]),
|
||||
group("view triad", &[("triad[0]", t.triad[0]), ("triad[1]", t.triad[1]), ("triad[2]", t.triad[2])], &[("viewport_top", t.viewport_top)]),
|
||||
group("gizmo", &[("gizmo[0]", t.gizmo[0]), ("gizmo[1]", t.gizmo[1]), ("gizmo[2]", t.gizmo[2])], &[("viewport_top", t.viewport_top)]),
|
||||
group("bend lines", &[("bend_up", t.bend_up), ("bend_down", t.bend_down)], &[("viewport_top", t.viewport_top)]),
|
||||
group("box select", &[("box_window", t.box_window), ("box_crossing", t.box_crossing)], &[("viewport_top", t.viewport_top)]),
|
||||
group(
|
||||
"selection",
|
||||
&[
|
||||
("body", t.body),
|
||||
("sel_face", t.sel_face),
|
||||
("body_selected", t.body_selected),
|
||||
("hover_face_lift", lift(t.body)),
|
||||
("preview_add", t.preview_add),
|
||||
],
|
||||
&[],
|
||||
),
|
||||
group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]),
|
||||
group("interference", &[("interference", t.interference)], &[("body", t.body)]),
|
||||
// The scale's three stops, unlit. Shaded (a stop at one angle to the light, the next at
|
||||
// another), middle and tight merge for a red-green loss (3.3 protan, 5.2 deutan) and flat
|
||||
// and middle for tritan (4.6). That is the scale's own fault, in Off too: #975.
|
||||
group(
|
||||
"curvature map",
|
||||
&[
|
||||
("analysis.curvature[0]", crate::theme::srgb(a.curvature[0])),
|
||||
("analysis.curvature[1]", crate::theme::srgb(a.curvature[1])),
|
||||
("analysis.curvature[2]", crate::theme::srgb(a.curvature[2])),
|
||||
],
|
||||
&[],
|
||||
),
|
||||
group(
|
||||
"ribbon accents",
|
||||
&[("glyph_text", t.glyph_text), ("sketch_accent", t.sketch_accent), ("construction_geom", t.construction_geom)],
|
||||
&[("toolbar", t.toolbar)],
|
||||
),
|
||||
group(
|
||||
"origin icon",
|
||||
&[("icons.axes[0]", ic.axes[0]), ("icons.axes[1]", ic.axes[1]), ("icons.axes[2]", ic.axes[2])],
|
||||
&[("toolbar", t.toolbar)],
|
||||
),
|
||||
group(
|
||||
"accessibility icon",
|
||||
&[("icons.access[0]", ic.access[0]), ("icons.access[1]", ic.access[1]), ("icon_fill", t.icon_fill), ("icon", t.icon)],
|
||||
&[],
|
||||
),
|
||||
group("draft icon", &[("icons.draft[0]", ic.draft[0]), ("icons.draft[1]", ic.draft[1]), ("icon", t.icon)], &[]),
|
||||
group(
|
||||
"curvature icon",
|
||||
&[("icons.curvature[0]", ic.curvature[0]), ("icons.curvature[1]", ic.curvature[1]), ("icons.curvature[2]", ic.curvature[2])],
|
||||
&[],
|
||||
),
|
||||
group(
|
||||
"appearance icon",
|
||||
&[("icons.paint[0]", ic.paint[0]), ("icons.paint[1]", ic.paint[1]), ("icons.paint[2]", ic.paint[2]), ("icon_fill", t.icon_fill)],
|
||||
&[],
|
||||
),
|
||||
group("environment icon", &[("icons.sky", ic.sky), ("icons.floor", ic.floor), ("icons.highlight", ic.highlight)], &[]),
|
||||
group("warning icon", &[("icons.warning", ic.warning), ("icon", t.icon)], &[("toolbar", t.toolbar)]),
|
||||
group("interference icon", &[("icons.interference", ic.interference), ("icon", t.icon)], &[("toolbar", t.toolbar)]),
|
||||
group("folder icons", &[("icons.folder", ic.folder), ("icons.plane", ic.plane), ("icons.fold", ic.fold), ("icon", t.icon)], &[]),
|
||||
group("sheet icons", &[("icons.sheet", ic.sheet), ("icons.sheet_2", ic.sheet_2), ("icon", t.icon)], &[]),
|
||||
group(
|
||||
"app bar buttons",
|
||||
&[("app_bar_icon[0]", t.app_bar_icon[0]), ("app_bar_icon[1]", t.app_bar_icon[1]), ("app_bar_icon[2]", t.app_bar_icon[2])],
|
||||
&[("app_bar", t.app_bar)],
|
||||
),
|
||||
group("logo", &[("logo[0]", t.logo[0]), ("logo[1]", t.logo[1]), ("logo[2]", t.logo[2])], &[("app_bar", t.app_bar)]),
|
||||
];
|
||||
// The removed part of a cut preview is translucent over the body.
|
||||
if let Some(sel) = out.iter_mut().find(|g| g.name == "selection") {
|
||||
sel.members.push(("preview_cut", over(t.preview_cut, t.body)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Measured by the shaded check in `every_mode_keeps_colours_apart_for_its_eye`.
|
||||
const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"];
|
||||
|
||||
/// Tokens no colour vision mode needs to keep apart, and why. A new token fails
|
||||
/// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here.
|
||||
const NO_MEANING: &[(&str, &str)] = &[
|
||||
// The window's chrome: grounds, text and borders (measured as grounds where meaning sits on them).
|
||||
("panel_header", "chrome"),
|
||||
("border", "chrome"),
|
||||
("text_dim", "secondary text"),
|
||||
("accent", "the one interface accent"),
|
||||
("timeline", "the timeline's ground"),
|
||||
("dialog_bg", "chrome"),
|
||||
("dialog_border", "chrome"),
|
||||
("chip", "a label's ground"),
|
||||
("heading_bg", "chrome"),
|
||||
("subheading_bg", "chrome"),
|
||||
("overlay", "a chip's ground over the view"),
|
||||
("app_bar_text", "text"),
|
||||
("app_bar_hover", "a hovered tab, translucent white"),
|
||||
("app_bar_tab_edge", "an inactive tab's outline, translucent white"),
|
||||
("timeline_marker", "the rollback marker, alone"),
|
||||
("field", "a field's ground"),
|
||||
// Highlights: shades of the accent blue over what they mark, by design close to each other.
|
||||
("sel_edge", "selection highlight"),
|
||||
("sel_edge_rim", "selection highlight"),
|
||||
("hover_edge", "hover highlight"),
|
||||
("hover_edge_halo", "hover highlight"),
|
||||
("sel_profile", "selection highlight"),
|
||||
("sel_profile_edge", "selection highlight"),
|
||||
("hover_profile", "hover highlight"),
|
||||
("hover_profile_edge", "hover highlight"),
|
||||
("sel_vertex", "selection highlight"),
|
||||
("rubber_band", "the shape being drawn"),
|
||||
("manipulator", "a drag handle, alone"),
|
||||
// Planes, points, the grid and the model's edges: one neutral each, told apart by shape.
|
||||
("origin_plane", "a plane, told apart by its place"),
|
||||
("origin_plane_edge", "a plane's edge"),
|
||||
("origin_plane_hover", "hover highlight"),
|
||||
("origin_point", "the origin, alone"),
|
||||
("construction_plane", "a plane"),
|
||||
("body_edge", "the model's edges"),
|
||||
("grid", "the grid"),
|
||||
("grid_major", "the grid"),
|
||||
("profile_fill", "a closed profile's tint"),
|
||||
("dimension", "dimension text"),
|
||||
("dimension_driven", "dimension text, dimmed"),
|
||||
("glyph_bg", "a constraint glyph's ground"),
|
||||
("glyph_edge", "a constraint glyph's edge"),
|
||||
// The navigation cube: labelled faces.
|
||||
("cube_face", "labelled"),
|
||||
("cube_border", "labelled"),
|
||||
("cube_text", "labelled"),
|
||||
("cube_hover", "hover highlight"),
|
||||
// Not a colour, or the end of a mark that is measured elsewhere.
|
||||
("analysis.hatch", "a flag: stripes on the failing faces"),
|
||||
("logo[3]", "the mark's white edge"),
|
||||
// Icon colours that are a shade of a measured one, or one mark alone.
|
||||
("icons.sheet_light", "a lighter shade of a sheet"),
|
||||
("icons.sheet_line", "lines on a sheet"),
|
||||
("icons.glint", "a highlight"),
|
||||
("icons.stripe", "zebra stripes, grey"),
|
||||
("icons.agent", "the agent's pointer, alone"),
|
||||
("icons.marker_halo", "a dark halo under a marker"),
|
||||
];
|
||||
|
||||
/// Every token field, by path (`sketch`, `analysis.draft`, `logo[3]`…): the arrays of distinct
|
||||
/// meanings by element where only some elements are measured.
|
||||
fn token_fields(t: &Tokens) -> BTreeSet<String> {
|
||||
let text = format!("{t:#?}");
|
||||
let mut out = BTreeSet::new();
|
||||
let mut parent: Option<String> = None;
|
||||
for line in text.lines() {
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
let Some((name, rest)) = line.trim_start().split_once(": ") else { continue };
|
||||
match indent {
|
||||
4 if rest.ends_with('{') => parent = Some(name.to_string()),
|
||||
4 => {
|
||||
parent = None;
|
||||
out.insert(name.to_string());
|
||||
}
|
||||
8 => {
|
||||
if let Some(p) = &parent {
|
||||
out.insert(format!("{p}.{name}"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A group member's token field (`triad[0]` is part of `triad`).
|
||||
fn field_of(name: &str) -> &str {
|
||||
name.split('[').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token_is_measured_or_carries_no_meaning() {
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
let measured: BTreeSet<&str> =
|
||||
groups(&t).iter().flat_map(|g| g.members.iter().chain(&g.grounds).map(|(n, _)| field_of(n))).chain(SHADED).collect();
|
||||
// An element of an array (`logo[3]`) is listed for the record; its array is measured.
|
||||
let listed: BTreeSet<&str> = NO_MEANING.iter().map(|(n, _)| field_of(n)).collect();
|
||||
let fields = token_fields(&t);
|
||||
let unplaced: Vec<&String> = fields.iter().filter(|f| !measured.contains(f.as_str()) && !listed.contains(f.as_str())).collect();
|
||||
assert!(unplaced.is_empty(), "tokens neither measured (`groups`) nor listed in `NO_MEANING`: {unplaced:?}");
|
||||
let stale: Vec<&&str> = measured.iter().chain(&listed).filter(|f| !fields.contains(**f)).collect();
|
||||
assert!(stale.is_empty(), "names that are not token fields: {stale:?}");
|
||||
let both: Vec<&str> = NO_MEANING.iter().map(|(n, _)| *n).filter(|n| !n.contains('[') && measured.contains(n)).collect();
|
||||
assert!(both.is_empty(), "measured and also listed as carrying no meaning: {both:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pair in the groups: (group, name a, a, name b, b).
|
||||
fn pairs(t: &Tokens) -> Vec<(String, Rgb8, Rgb8)> {
|
||||
let mut out = Vec::new();
|
||||
for g in groups(t) {
|
||||
for (i, (na, a)) in g.members.iter().enumerate() {
|
||||
for (nb, b) in g.members.iter().skip(i + 1).chain(&g.grounds) {
|
||||
out.push((format!("{}: {na} vs {nb}", g.name), *a, *b));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pairs under the floor that the tables leave as they are (decided for #778). Each is close to
|
||||
/// the floor for normal vision too, and construction and centerline lines also differ by their
|
||||
/// dash patterns.
|
||||
fn left_as_they_are(mode: ColorVision, dark: bool) -> BTreeSet<String> {
|
||||
let mut v = vec!["sketch lines: sketch_construction vs sketch_centerline"];
|
||||
if mode == ColorVision::Tritan {
|
||||
v.push("sketch lines: sketch_lost vs sketch_centerline");
|
||||
v.push(if dark { "timeline: hover vs accent_soft" } else { "timeline: timeline_item vs accent_soft" });
|
||||
v.push("selection: sel_face vs preview_add");
|
||||
}
|
||||
v.into_iter().map(String::from).collect()
|
||||
}
|
||||
|
||||
// ---- the tests ----
|
||||
|
||||
/// Sharma, Wu, Dalal, "The CIEDE2000 Color-Difference Formula: Implementation Notes,
|
||||
/// Supplementary Test Data, and Mathematical Observations", Color Research and Application 30
|
||||
/// (2005), Table 1: L*, a*, b* of each pair, then the difference.
|
||||
const SHARMA: [[f64; 7]; 34] = [
|
||||
[50.0000, 2.6772, -79.7751, 50.0000, 0.0000, -82.7485, 2.0425],
|
||||
[50.0000, 3.1571, -77.2803, 50.0000, 0.0000, -82.7485, 2.8615],
|
||||
[50.0000, 2.8361, -74.0200, 50.0000, 0.0000, -82.7485, 3.4412],
|
||||
[50.0000, -1.3802, -84.2814, 50.0000, 0.0000, -82.7485, 1.0000],
|
||||
[50.0000, -1.1848, -84.8006, 50.0000, 0.0000, -82.7485, 1.0000],
|
||||
[50.0000, -0.9009, -85.5211, 50.0000, 0.0000, -82.7485, 1.0000],
|
||||
[50.0000, 0.0000, 0.0000, 50.0000, -1.0000, 2.0000, 2.3669],
|
||||
[50.0000, -1.0000, 2.0000, 50.0000, 0.0000, 0.0000, 2.3669],
|
||||
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0009, 7.1792],
|
||||
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0010, 7.1792],
|
||||
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0011, 7.2195],
|
||||
[50.0000, 2.4900, -0.0010, 50.0000, -2.4900, 0.0012, 7.2195],
|
||||
[50.0000, -0.0010, 2.4900, 50.0000, 0.0009, -2.4900, 4.8045],
|
||||
[50.0000, -0.0010, 2.4900, 50.0000, 0.0010, -2.4900, 4.8045],
|
||||
[50.0000, -0.0010, 2.4900, 50.0000, 0.0011, -2.4900, 4.7461],
|
||||
[50.0000, 2.5000, 0.0000, 50.0000, 0.0000, -2.5000, 4.3065],
|
||||
[50.0000, 2.5000, 0.0000, 73.0000, 25.0000, -18.0000, 27.1492],
|
||||
[50.0000, 2.5000, 0.0000, 61.0000, -5.0000, 29.0000, 22.8977],
|
||||
[50.0000, 2.5000, 0.0000, 56.0000, -27.0000, -3.0000, 31.9030],
|
||||
[50.0000, 2.5000, 0.0000, 58.0000, 24.0000, 15.0000, 19.4535],
|
||||
[50.0000, 2.5000, 0.0000, 50.0000, 3.1736, 0.5854, 1.0000],
|
||||
[50.0000, 2.5000, 0.0000, 50.0000, 3.2972, 0.0000, 1.0000],
|
||||
[50.0000, 2.5000, 0.0000, 50.0000, 1.8634, 0.5757, 1.0000],
|
||||
[50.0000, 2.5000, 0.0000, 50.0000, 3.2592, 0.3350, 1.0000],
|
||||
[60.2574, -34.0099, 36.2677, 60.4626, -34.1751, 39.4387, 1.2644],
|
||||
[63.0109, -31.0961, -5.8663, 62.8187, -29.7946, -4.0864, 1.2630],
|
||||
[61.2901, 3.7196, -5.3901, 61.4292, 2.2480, -4.9620, 1.8731],
|
||||
[35.0831, -44.1164, 3.7933, 35.0232, -40.0716, 1.5901, 1.8645],
|
||||
[22.7233, 20.0904, -46.6940, 23.0331, 14.9730, -42.5619, 2.0373],
|
||||
[36.4612, 47.8580, 18.3852, 36.2715, 50.5065, 21.2231, 1.4146],
|
||||
[90.8027, -2.0831, 1.4410, 91.1528, -1.6435, 0.0447, 1.4441],
|
||||
[90.9257, -0.5406, -0.9208, 88.6381, -0.8985, -0.7239, 1.5381],
|
||||
[6.7747, -0.2908, -2.4247, 5.8714, -0.0985, -2.2286, 0.6377],
|
||||
[2.0776, 0.0795, -1.1350, 0.9033, -0.0636, -0.5514, 0.9082],
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn ciede2000_reproduces_sharmas_test_pairs() {
|
||||
for (i, p) in SHARMA.iter().enumerate() {
|
||||
let got = de2000([p[0], p[1], p[2]], [p[3], p[4], p[5]]);
|
||||
assert!((got - p[6]).abs() < 1e-4, "pair {}: {got}, published {}", i + 1, p[6]);
|
||||
// The formula is symmetric.
|
||||
assert!((de2000([p[3], p[4], p[5]], [p[0], p[1], p[2]]) - got).abs() < 1e-9, "pair {}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_simulations_keep_white_and_brettels_anchors() {
|
||||
let lms = mat(&LMS_FROM_XYZ, &XYZ_FROM_LIN);
|
||||
for e in [Eye::Protan, Eye::Deutan, Eye::Tritan] {
|
||||
let w = brettel(e, [1.0; 3]);
|
||||
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-9), "{e:?}: white -> {w:?}");
|
||||
let (_, a1, a2) = anchors(e);
|
||||
for a in [a1, a2] {
|
||||
let lin = mul(&inv(&lms), mul(&LMS_FROM_XYZ, a));
|
||||
let back = brettel(e, lin);
|
||||
assert!(lin.iter().zip(back).all(|(x, y)| (x - y).abs() < 1e-9), "{e:?}: anchor {lin:?} -> {back:?}");
|
||||
}
|
||||
let m = machado(e);
|
||||
assert!(m.iter().all(|row| (row.iter().sum::<f64>() - 1.0).abs() < 1e-5), "{e:?}: Machado keeps white");
|
||||
}
|
||||
// Known losses: pure red and green merge for a red-green loss, blue and green for a blue one.
|
||||
let (red, green, blue) = ([220, 60, 60], [60, 170, 60], [60, 100, 230]);
|
||||
assert!(normal(red, green) > 60.0);
|
||||
for how in [Instrument::Machado, Instrument::Brettel] {
|
||||
assert!(diff(red, green, Eye::Deutan, how) < 10.0, "{how:?}");
|
||||
assert!(diff(blue, green, Eye::Tritan, how) < diff(blue, green, Eye::Normal, how), "{how:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_gives_the_theme_unchanged() {
|
||||
assert_eq!(Tokens::themed(false, ColorVision::Off), Tokens::light());
|
||||
assert_eq!(Tokens::themed(true, ColorVision::Off), Tokens::dark());
|
||||
for dark in [false, true] {
|
||||
assert!(!Tokens::themed(dark, ColorVision::Off).analysis.hatch);
|
||||
// Protan and Deutan share the red-green table.
|
||||
assert_eq!(Tokens::themed(dark, ColorVision::Protan), Tokens::themed(dark, ColorVision::Deutan));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token fields whose `Debug` text differs (`triad`, `analysis.draft`…), so a field cannot
|
||||
/// change in a mode by accident.
|
||||
fn changed_fields(a: &Tokens, b: &Tokens) -> BTreeSet<String> {
|
||||
let (la, lb) = (format!("{a:#?}"), format!("{b:#?}"));
|
||||
let (la, lb): (Vec<&str>, Vec<&str>) = (la.lines().collect(), lb.lines().collect());
|
||||
assert_eq!(la.len(), lb.len());
|
||||
let mut out = BTreeSet::new();
|
||||
for i in (0..la.len()).filter(|i| la[*i] != lb[*i]) {
|
||||
// The named fields above this line, innermost first, up to a field of `Tokens`.
|
||||
let mut path = Vec::new();
|
||||
let mut indent = usize::MAX;
|
||||
for line in la[..=i].iter().rev() {
|
||||
let here = line.len() - line.trim_start().len();
|
||||
if here < indent
|
||||
&& let Some((name, _)) = line.trim_start().split_once(':')
|
||||
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||||
{
|
||||
path.push(name.to_string());
|
||||
indent = here;
|
||||
}
|
||||
if indent <= 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
path.reverse();
|
||||
out.insert(path.join("."));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mode_changes_only_its_table() {
|
||||
let set = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<BTreeSet<_>>();
|
||||
let red_green_both = [
|
||||
"triad",
|
||||
"gizmo",
|
||||
"analysis.draft",
|
||||
"analysis.access",
|
||||
"analysis.hatch",
|
||||
"icons.axes",
|
||||
"icons.access",
|
||||
"icons.draft",
|
||||
"bend_up",
|
||||
"bend_down",
|
||||
"sketch_accent",
|
||||
"preview_sketch",
|
||||
];
|
||||
let mut red_green_light = red_green_both.to_vec();
|
||||
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]);
|
||||
for (dark, mode, want) in [
|
||||
(false, ColorVision::Protan, set(&red_green_light)),
|
||||
(true, ColorVision::Protan, set(&red_green_both)),
|
||||
(false, ColorVision::Deutan, set(&red_green_light)),
|
||||
(true, ColorVision::Deutan, set(&red_green_both)),
|
||||
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])),
|
||||
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])),
|
||||
] {
|
||||
let base = Tokens::themed(dark, ColorVision::Off);
|
||||
assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_keeps_colours_apart_for_its_eye() {
|
||||
let mut report = Vec::new();
|
||||
for dark in [false, true] {
|
||||
let off = Tokens::themed(dark, ColorVision::Off);
|
||||
for mode in MODES {
|
||||
let on = Tokens::themed(dark, mode);
|
||||
let mut left = BTreeSet::new();
|
||||
for ((name, a0, b0), (_, a1, b1)) in pairs(&off).into_iter().zip(pairs(&on)) {
|
||||
// Close for everybody by design (hover tints, a shade of one hue), and not changed
|
||||
// by the mode: not a colour vision fault.
|
||||
if normal(a0, b0) < FLOOR && (a0, b0) == (a1, b1) {
|
||||
continue;
|
||||
}
|
||||
let w = worst(mode, a1, b1);
|
||||
if w < FLOOR {
|
||||
report.push(format!("{mode:?} dark={dark} {name}: {w:.1} (before {:.1})", worst(mode, a0, b0)));
|
||||
left.insert(name);
|
||||
}
|
||||
}
|
||||
assert_eq!(left, left_as_they_are(mode, dark), "{mode:?}, dark {dark}: {report:#?}");
|
||||
// Draft and Accessibility, for faces at any angle to the light against each other.
|
||||
let a = &on.analysis;
|
||||
let shaded = [(a.draft[0], a.draft[1]), (a.draft[0], a.draft[2]), (a.draft[1], a.draft[2]), (a.access[0], a.access[1])];
|
||||
for (p, q) in shaded {
|
||||
for x in LIT {
|
||||
for y in LIT {
|
||||
let w = worst(mode, lit(p, x), lit(q, y));
|
||||
assert!(w >= FLOOR, "{mode:?}, dark {dark}: {p:?} lit {x} vs {q:?} lit {y}: {w:.1}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_gets_the_modes_colours_and_its_stripes() {
|
||||
for dark in [false, true] {
|
||||
let off = crate::gpu::analysis_colors(&Tokens::themed(dark, ColorVision::Off).analysis);
|
||||
assert_eq!(off[32], 0.0, "no stripes in Off");
|
||||
for mode in MODES {
|
||||
let t = Tokens::themed(dark, mode);
|
||||
let v = crate::gpu::analysis_colors(&t.analysis);
|
||||
assert_eq!(v[32], 1.0, "{mode:?}: stripes on the bad faces");
|
||||
assert_eq!(v[..3], t.analysis.draft[0], "{mode:?}");
|
||||
assert_eq!(v[16..19], t.analysis.access[1], "{mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the preference ----
|
||||
|
||||
/// Every message the Colour vision section shows (the heading, the mode names and their
|
||||
/// tooltips, the sample's names and the note) is in every catalogue, translated: in Japanese,
|
||||
/// Korean, Russian and Chinese none is the English text. (German keeps "Protan", "Deutan" and
|
||||
/// "Tritan".)
|
||||
#[test]
|
||||
fn the_sections_messages_are_in_every_catalog() {
|
||||
let load = |text: &str| -> serde_json::Map<String, serde_json::Value> { serde_json::from_str(text).unwrap() };
|
||||
let english = load(include_str!("../../../assets/locales/en.json"));
|
||||
let ids: Vec<&str> = ColorVision::ALL
|
||||
.into_iter()
|
||||
.flat_map(|m| [m.label_id(), m.help_id()])
|
||||
.chain(crate::prefs::COLOUR_VISION_SAMPLE)
|
||||
.chain(["ui.colour_vision", "ui.colour_vision_mode", "ui.colour_vision_sample", "ui.colour_vision_note"])
|
||||
.collect();
|
||||
for (lang, text) in [
|
||||
("de", include_str!("../../../assets/locales/de.json")),
|
||||
("ja", include_str!("../../../assets/locales/ja.json")),
|
||||
("ko", include_str!("../../../assets/locales/ko.json")),
|
||||
("ru", include_str!("../../../assets/locales/ru.json")),
|
||||
("zh", include_str!("../../../assets/locales/zh.json")),
|
||||
] {
|
||||
let catalog = load(text);
|
||||
for id in &ids {
|
||||
let (Some(en), Some(here)) = (english.get(*id), catalog.get(*id)) else {
|
||||
panic!("{lang}: missing message {id} (English: {})", english.contains_key(*id));
|
||||
};
|
||||
assert!(lang == "de" || here != en, "{lang}: {id} is the English text {en}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> crate::SolveApp {
|
||||
crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_preferences_load_the_mode_and_a_bad_one_loads_as_off() {
|
||||
use crate::prefs::Prefs;
|
||||
// Saved before the mode existed.
|
||||
let old: Prefs = serde_json::from_value(json!({"theme": "light"})).unwrap();
|
||||
assert_eq!((old.color_vision, old.theme.as_str()), (ColorVision::Off, "light"));
|
||||
for mode in ColorVision::ALL {
|
||||
let p = Prefs { color_vision: mode, ..Prefs::default() };
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["color_vision"], mode.id());
|
||||
assert_eq!(serde_json::from_value::<Prefs>(v).unwrap(), p);
|
||||
}
|
||||
// A value this version doesn't know (a later SolidCraft's mode, a hand edit) is Off, and the
|
||||
// rest of the preferences still load.
|
||||
for bad in [json!("achromat"), json!("PROTAN"), json!(""), json!(3), json!(null), json!(true), json!({"protan": 1}), json!(["tritan"])] {
|
||||
let p: Prefs = serde_json::from_value(json!({"theme": "light", "color_vision": bad})).unwrap();
|
||||
assert_eq!((p.color_vision, p.theme.as_str()), (ColorVision::Off, "light"), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_prefs_sets_each_mode_and_refuses_anything_else() {
|
||||
let mut app = app();
|
||||
for mode in ColorVision::ALL {
|
||||
let out = crate::prefs::set(&mut app, &json!({"color_vision": mode.id()})).unwrap();
|
||||
assert_eq!(out["color_vision"], mode.id());
|
||||
assert_eq!(app.preferences.color_vision, mode);
|
||||
}
|
||||
let long = "p".repeat(1 << 20);
|
||||
for bad in [
|
||||
json!("purple"),
|
||||
json!("Protan"),
|
||||
json!(" protan"),
|
||||
json!(""),
|
||||
json!(long),
|
||||
json!(2),
|
||||
json!(-1.5),
|
||||
json!(null),
|
||||
json!(false),
|
||||
json!([]),
|
||||
json!({}),
|
||||
] {
|
||||
let before = app.preferences.clone();
|
||||
let err = crate::prefs::set(&mut app, &json!({"color_vision": bad, "theme": "light"})).unwrap_err();
|
||||
assert!(err.contains("off, protan, deutan or tritan"), "{err}");
|
||||
assert_eq!(app.preferences, before, "a refused value changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Random values (fixed seed): strings near the four ids and far from them, numbers, nesting.
|
||||
/// `ui.prefs` takes exactly the four ids and refuses the rest without a change; saved
|
||||
/// preferences load every value, unknown ones as Off. Nothing panics.
|
||||
#[test]
|
||||
fn random_values_never_panic_and_only_the_four_ids_are_taken() {
|
||||
fn value(next: &mut impl FnMut() -> u64, depth: u32) -> serde_json::Value {
|
||||
let ids = ["off", "protan", "deutan", "tritan"];
|
||||
match next() % if depth > 3 { 6 } else { 8 } {
|
||||
0 => json!(ids[(next() % 4) as usize]),
|
||||
1 => {
|
||||
// A near miss: one id with a character changed, added or taken away.
|
||||
let mut s: Vec<char> = ids[(next() % 4) as usize].chars().collect();
|
||||
let i = (next() % s.len() as u64) as usize;
|
||||
match next() % 3 {
|
||||
0 => s[i] = char::from_u32((next() % 0x2FFF) as u32).unwrap_or('?'),
|
||||
1 => s.insert(i, ['\0', ' ', 'É', '\u{301}', 'ـ'][(next() % 5) as usize]),
|
||||
_ => {
|
||||
s.remove(i);
|
||||
}
|
||||
}
|
||||
json!(s.into_iter().collect::<String>())
|
||||
}
|
||||
2 => json!((0..next() % 40).map(|_| char::from_u32((next() % 0x10FFFF) as u32).unwrap_or('x')).collect::<String>()),
|
||||
3 => json!(next() as i64),
|
||||
4 => json!(Some(f64::from_bits(next())).filter(|x| x.is_finite()).unwrap_or(0.5)),
|
||||
5 => [json!(null), json!(true), json!(false)][(next() % 3) as usize].clone(),
|
||||
6 => json!((0..next() % 4).map(|_| value(next, depth + 1)).collect::<Vec<_>>()),
|
||||
_ => json!({"color_vision": value(next, depth + 1)}),
|
||||
}
|
||||
}
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let mut app = app();
|
||||
let mut taken = 0;
|
||||
for _ in 0..4000 {
|
||||
let v = value(&mut next, 0);
|
||||
let id = v.as_str().and_then(ColorVision::from_id);
|
||||
let before = app.preferences.clone();
|
||||
match crate::prefs::set(&mut app, &json!({"color_vision": v})) {
|
||||
Ok(_) => {
|
||||
taken += 1;
|
||||
assert_eq!(Some(app.preferences.color_vision), id, "{v}");
|
||||
}
|
||||
Err(e) => {
|
||||
assert!(id.is_none() && e.contains("off, protan, deutan or tritan"), "{v}: {e}");
|
||||
assert_eq!(app.preferences, before, "{v}");
|
||||
}
|
||||
}
|
||||
let saved: crate::prefs::Prefs = serde_json::from_value(json!({"theme": "light", "color_vision": v})).unwrap();
|
||||
assert_eq!((saved.color_vision, saved.theme.as_str()), (id.unwrap_or_default(), "light"), "{v}");
|
||||
}
|
||||
assert!(taken > 200, "the four ids came up {taken} times");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_puts_the_mode_back_ok_keeps_it_and_restore_defaults_resets_graphics_only() {
|
||||
let mut app = app();
|
||||
let control = |app: &mut crate::SolveApp, p: serde_json::Value| crate::prefs::control(app, &p).unwrap();
|
||||
control(&mut app, json!({"open": "Graphics"}));
|
||||
control(&mut app, json!({"color_vision": "protan"}));
|
||||
assert_eq!(app.preferences.color_vision, ColorVision::Protan);
|
||||
control(&mut app, json!({"button": "cancel"}));
|
||||
assert_eq!(app.preferences.color_vision, ColorVision::Off);
|
||||
control(&mut app, json!({"open": "Graphics"}));
|
||||
control(&mut app, json!({"color_vision": "tritan", "button": "ok"}));
|
||||
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
|
||||
// Restore Defaults on another page leaves it.
|
||||
control(&mut app, json!({"open": "General"}));
|
||||
control(&mut app, json!({"button": "defaults"}));
|
||||
assert_eq!(app.preferences.color_vision, ColorVision::Tritan);
|
||||
control(&mut app, json!({"open": "Graphics"}));
|
||||
control(&mut app, json!({"button": "defaults"}));
|
||||
assert_eq!(app.preferences.color_vision, ColorVision::Off);
|
||||
control(&mut app, json!({"button": "ok"}));
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -866,7 +866,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
||||
continue;
|
||||
}
|
||||
let k = 0.55 + 0.45 * facing;
|
||||
let fill = Color32::from_rgba_unmultiplied((235.0 * k) as u8, (45.0 * k) as u8, (40.0 * k) as u8, 210);
|
||||
let shade = |c: u8| (f64::from(c) * k) as u8;
|
||||
let ic = t.interference;
|
||||
let fill = Color32::from_rgba_unmultiplied(shade(ic.r()), shade(ic.g()), shade(ic.b()), 210);
|
||||
let sp: Vec<Pos2> = ps.iter().filter_map(|p| proj.to_screen(*p)).collect();
|
||||
if sp.len() == 3 {
|
||||
let base = em.vertices.len() as u32;
|
||||
|
||||
@@ -799,13 +799,11 @@ pub fn arrow(app: &SolveApp, d: &Dialog) -> Option<Vec3> {
|
||||
Some(if flip { -n } else { n })
|
||||
}
|
||||
|
||||
/// Bend line colours: up and down (legible on either theme).
|
||||
/// Bend line colours: up and down (theme tokens, legible on either theme and in every colour
|
||||
/// vision mode).
|
||||
fn bend_colors() -> (Color32, Color32) {
|
||||
if crate::theme::is_dark() {
|
||||
(Color32::from_rgb(110, 200, 120), Color32::from_rgb(240, 150, 80))
|
||||
} else {
|
||||
(Color32::from_rgb(30, 140, 50), Color32::from_rgb(200, 90, 20))
|
||||
}
|
||||
let t = crate::theme::Tokens::get();
|
||||
(t.bend_up, t.bend_down)
|
||||
}
|
||||
|
||||
/// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours)
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use egui_wgpu::wgpu::util::DeviceExt;
|
||||
pub const TRI_SIZE: usize = 28;
|
||||
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
|
||||
pub const LINE_SIZE: usize = 32;
|
||||
const UNIFORM_SIZE: u64 = 208;
|
||||
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their
|
||||
/// flags (`uniform_bytes`).
|
||||
const UNIFORM_SIZE: u64 = 352;
|
||||
|
||||
/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face
|
||||
/// (the plan's face numbering; faces past the end take the last colour).
|
||||
@@ -199,6 +201,8 @@ pub struct ViewportCallback {
|
||||
/// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment,
|
||||
/// 5 accessibility; parameter; scale; size) and the pull / access direction.
|
||||
pub analysis: [f32; 8],
|
||||
/// The analysis colours and the stripe flag ([`analysis_colors`]).
|
||||
pub analysis_colors: [f32; 36],
|
||||
/// Accessibility: the depth map seen from the access direction.
|
||||
pub access: Option<AccessMap>,
|
||||
/// Draw silhouette outlines around the model (visual styles with edges).
|
||||
@@ -1010,11 +1014,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
|
||||
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
|
||||
/// `size`. `None` without a GPU adapter. With `tess` (the tessellation shader), planned bodies
|
||||
/// are evaluated on the GPU where the device builds that pipeline, as [`install`] sets it up.
|
||||
/// `highlight` is drawn as the viewport's highlight scene (hover and selection).
|
||||
/// A device of its own for an offscreen render or check (`None` without a GPU adapter).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
@@ -1035,7 +1035,15 @@ pub(crate) fn offscreen_tessellates(shader: &str) -> Option<bool> {
|
||||
Some(tessellation(&adapter, &device, &queue, shader).is_some())
|
||||
}
|
||||
|
||||
/// Render `scene` offscreen on a device of its own, through the viewport's real resources and
|
||||
/// frame callback (`prepare`, then `paint`), and read the pixels back: straight RGBA, `size` ×
|
||||
/// `size`. `None` without a GPU adapter. With `tess` (the tessellation shader), planned bodies
|
||||
/// are evaluated on the GPU where the device builds that pipeline, as [`install`] sets it up.
|
||||
/// `highlight` is drawn as the viewport's highlight scene (hover and selection). `analysis` is the
|
||||
/// surface analysis uniform (`viewport::analysis_uniform`; zeros for none) and `colors` its
|
||||
/// colours (`analysis_colors`).
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn render_offscreen(
|
||||
scene: GpuScene,
|
||||
highlight: Option<GpuScene>,
|
||||
@@ -1044,6 +1052,8 @@ pub(crate) fn render_offscreen(
|
||||
eye: [f32; 4],
|
||||
size: u32,
|
||||
tess: Option<&str>,
|
||||
analysis: [f32; 8],
|
||||
colors: [f32; 36],
|
||||
) -> Option<Vec<u8>> {
|
||||
let (adapter, device, queue) = offscreen_device()?;
|
||||
let (format, depth) = (wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Depth24PlusStencil8);
|
||||
@@ -1067,7 +1077,8 @@ pub(crate) fn render_offscreen(
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis,
|
||||
analysis_colors: colors,
|
||||
access: None,
|
||||
outline: false,
|
||||
eye,
|
||||
@@ -1152,9 +1163,23 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec
|
||||
None => v.extend([0.0; 8]),
|
||||
}
|
||||
v.extend(cb.eye);
|
||||
v.extend(cb.analysis_colors);
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable,
|
||||
/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad
|
||||
/// faces).
|
||||
pub fn analysis_colors(c: &crate::theme::AnalysisColors) -> [f32; 36] {
|
||||
let mut v = [0.0; 36];
|
||||
let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
for (slot, x) in v.iter_mut().zip(colors) {
|
||||
*slot = x;
|
||||
}
|
||||
v[32] = if c.hatch { 1.0 } else { 0.0 };
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui_wgpu::wgpu::naga;
|
||||
|
||||
@@ -2,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"]
|
||||
|
||||
@@ -12,6 +12,10 @@ pub mod access;
|
||||
pub mod agent_cursor;
|
||||
pub mod browser;
|
||||
pub mod canvas;
|
||||
#[cfg(test)]
|
||||
mod colour_tokens_tests;
|
||||
#[cfg(test)]
|
||||
mod colour_vision_tests;
|
||||
pub mod context_menu;
|
||||
pub mod control;
|
||||
pub mod crash_ui;
|
||||
@@ -1022,8 +1026,9 @@ impl SolveApp {
|
||||
file_load::tick(self, ctx);
|
||||
self.esc_handled = false;
|
||||
prefs::apply(self, ctx);
|
||||
if !self.styled || theme::is_dark() != self.ui.dark {
|
||||
if !self.styled || theme::is_dark() != self.ui.dark || theme::vision() != self.preferences.color_vision {
|
||||
theme::set_dark(self.ui.dark);
|
||||
theme::set_vision(self.preferences.color_vision);
|
||||
theme::apply(ctx);
|
||||
self.styled = true;
|
||||
}
|
||||
|
||||
+106
-4
@@ -3,10 +3,10 @@
|
||||
//! designs, autosave, display precision, timeline layout, the agent cursor), Navigation (mouse presets, zoom
|
||||
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
|
||||
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
|
||||
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
|
||||
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
|
||||
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
|
||||
//! runs, except anti-aliasing, which needs a restart.
|
||||
//! (visual style, grid, shadow, perspective, anti-aliasing, colour vision), Sketch and Selection.
|
||||
//! Changes apply at once; Cancel puts back what was there when the dialog opened, OK keeps them,
|
||||
//! Apply keeps them and stays open, and Restore Defaults resets the page shown. Everything is
|
||||
//! kept between runs, except anti-aliasing, which needs a restart.
|
||||
|
||||
use egui::{Modifiers, Pos2, RichText};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -246,6 +246,8 @@ pub struct Prefs {
|
||||
pub offer_stand_up: bool,
|
||||
/// What a middle-button double-click in the view does: `home`, `fit` or `off`.
|
||||
pub middle_double_click: String,
|
||||
/// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`.
|
||||
pub color_vision: crate::theme::ColorVision,
|
||||
/// Check for updates at start and once a day (docs/updates.md).
|
||||
pub check_updates: bool,
|
||||
/// When the last update check ran, and the versions skipped or put off (`update::Memo`).
|
||||
@@ -308,6 +310,7 @@ impl Default for Prefs {
|
||||
up_axis: "z".into(),
|
||||
offer_stand_up: true,
|
||||
middle_double_click: "home".into(),
|
||||
color_vision: crate::theme::ColorVision::Off,
|
||||
check_updates: true,
|
||||
update: crate::update::Memo::default(),
|
||||
}
|
||||
@@ -431,6 +434,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
PAGE_GRAPHICS => {
|
||||
let msaa = p.msaa != d.msaa;
|
||||
p.msaa = d.msaa;
|
||||
p.color_vision = d.color_vision;
|
||||
app.ui.visual_style = 0;
|
||||
app.ui.show_grid = true;
|
||||
app.ui.ground_shadow = true;
|
||||
@@ -1055,6 +1059,28 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
_ => "CPU (software)".to_string(),
|
||||
});
|
||||
ui.end_row();
|
||||
// Colour vision (#778): its own section at the end of the page.
|
||||
ui.label("");
|
||||
ui.end_row();
|
||||
ui.label(RichText::new(app.tr("ui.colour_vision")).strong());
|
||||
ui.label("");
|
||||
ui.end_row();
|
||||
ui.label(app.tr("ui.colour_vision_mode"));
|
||||
let mode = app.preferences.color_vision;
|
||||
egui::ComboBox::from_id_salt("pref_color_vision").selected_text(app.tr(mode.label_id())).show_ui(ui, |ui| {
|
||||
for m in crate::theme::ColorVision::ALL {
|
||||
ui.selectable_value(&mut app.preferences.color_vision, m, crate::i18n::tr(m.label_id())).on_hover_text(crate::i18n::tr(m.help_id()));
|
||||
}
|
||||
});
|
||||
if app.preferences.color_vision != mode {
|
||||
// At once, like the theme: the next frame draws in the new colours.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(app.tr("ui.colour_vision_sample"));
|
||||
colour_vision_sample(ui, t);
|
||||
ui.end_row();
|
||||
note(ui, app.tr("ui.colour_vision_note"));
|
||||
}
|
||||
5 => {
|
||||
ui.label(crate::i18n::tr_label("Project face edges into new sketches"));
|
||||
@@ -1072,6 +1098,76 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The names under the Colour vision sample (message ids).
|
||||
pub(crate) const COLOUR_VISION_SAMPLE: [&str; 6] =
|
||||
["ui.colour_vision_fixed", "ui.colour_vision_projected", "ui.axes", "Bends", "ui.draft", "ui.colour_vision_failed"];
|
||||
|
||||
/// The Colour vision sample (#778): fixed and projected sketch lines, the X and Y axes, bends up
|
||||
/// and down, Draft Analysis and a failed feature, each drawn as it is where it appears, in the
|
||||
/// colours on now. One fixed-size area, so the grid row is as tall as what it draws.
|
||||
fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) {
|
||||
const W: f32 = 72.0;
|
||||
const LINE: f32 = 4.0;
|
||||
let (area, _) = ui.allocate_exact_size(egui::vec2(W * COLOUR_VISION_SAMPLE.len() as f32, 50.0), egui::Sense::hover());
|
||||
let p = ui.painter_at(area);
|
||||
let line = |c| egui::Stroke::new(LINE, c);
|
||||
for (i, name) in COLOUR_VISION_SAMPLE.iter().enumerate() {
|
||||
let cell = egui::Rect::from_min_size(area.min + egui::vec2(W * i as f32, 0.0), egui::vec2(W - 8.0, 32.0));
|
||||
let (a, b) = (cell.left_center() + egui::vec2(8.0, 0.0), cell.right_center() - egui::vec2(8.0, 0.0));
|
||||
match i {
|
||||
0 | 1 => {
|
||||
p.rect_filled(cell, 3.0, t.viewport_top);
|
||||
p.line_segment([a, b], line(if i == 0 { t.sketch_locked } else { t.sketch_projected }));
|
||||
}
|
||||
2 => {
|
||||
// X along and Y up from one corner, as the view triad draws them.
|
||||
p.rect_filled(cell, 3.0, t.viewport_top);
|
||||
let o = cell.left_bottom() + egui::vec2(12.0, -7.0);
|
||||
p.line_segment([o, egui::pos2(cell.right() - 10.0, o.y)], line(t.triad[0]));
|
||||
p.line_segment([o, egui::pos2(o.x, cell.top() + 6.0)], line(t.triad[1]));
|
||||
}
|
||||
3 => {
|
||||
// Dashed, as the flat pattern draws them.
|
||||
p.rect_filled(cell, 3.0, t.viewport_top);
|
||||
for (dy, c) in [(-7.0, t.bend_up), (7.0, t.bend_down)] {
|
||||
p.extend(egui::Shape::dashed_line(&[a + egui::vec2(0.0, dy), b + egui::vec2(0.0, dy)], line(c), 8.0, 5.0));
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
// The three bands of Draft Analysis; in a mode the negative one is striped.
|
||||
let w = cell.width() / 3.0;
|
||||
for (k, c) in t.analysis.draft.iter().enumerate() {
|
||||
let band = egui::Rect::from_min_size(cell.min + egui::vec2(w * k as f32, 0.0), egui::vec2(w, cell.height()));
|
||||
p.rect_filled(band, 0.0, crate::theme::srgb(*c));
|
||||
if k == 2 && t.analysis.hatch {
|
||||
let stripes = p.with_clip_rect(band);
|
||||
let dark = crate::theme::srgb(c.map(|x| x * 0.6));
|
||||
let mut x = band.left() - band.height();
|
||||
while x < band.right() {
|
||||
stripes.line_segment(
|
||||
[egui::pos2(x, band.bottom()), egui::pos2(x + band.height(), band.top())],
|
||||
egui::Stroke::new(2.0, dark),
|
||||
);
|
||||
x += 9.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// A failed feature on the timeline.
|
||||
p.rect(cell.shrink2(egui::vec2(10.0, 3.0)), 3.0, t.timeline_item_error, egui::Stroke::new(1.0, t.error), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
p.text(
|
||||
cell.center_bottom() + egui::vec2(0.0, 3.0),
|
||||
egui::Align2::CENTER_TOP,
|
||||
crate::i18n::tr(name),
|
||||
egui::FontId::proportional(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
|
||||
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
|
||||
/// any other keys set preferences. Returns them all, the open page and the offer.
|
||||
@@ -1142,6 +1238,12 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
o.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// Saved preferences read an unknown mode as Off; asked for here, it is an error.
|
||||
if let Some(v) = p.get("color_vision")
|
||||
&& v.as_str().and_then(crate::theme::ColorVision::from_id).is_none()
|
||||
{
|
||||
return Err("`color_vision` must be off, protan, deutan or tritan".into());
|
||||
}
|
||||
let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
|
||||
if !UNITS.contains(&new.default_units.as_str()) {
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
|
||||
+184
-5
@@ -1,21 +1,163 @@
|
||||
//! Colours and widget style: a dark theme (the default, a blue-grey in the spirit of desktop CAD
|
||||
//! tools, viewport #2e3440) and a light one. Every colour the UI uses comes from [`Tokens`].
|
||||
//! tools, viewport #2e3440) and a light one, each with colour vision modes. Every colour the UI
|
||||
//! uses comes from [`Tokens`].
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[path = "../../../assets/styles/ui/palettes.rs"]
|
||||
mod palettes;
|
||||
|
||||
use egui::{Color32, Stroke};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
static DARK: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
thread_local! {
|
||||
/// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app
|
||||
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
|
||||
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
|
||||
/// with one global, a test in a mode changed the colours under another one
|
||||
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
|
||||
/// it: the preview thread is handed its colours (`preview::Colors`) and thumbnails are drawn by
|
||||
/// the engine in the model's colours, so a new thread starts Off and stays Off.
|
||||
static VISION: Cell<ColorVision> = const { Cell::new(ColorVision::Off) };
|
||||
}
|
||||
|
||||
/// Is the dark theme on?
|
||||
pub fn is_dark() -> bool {
|
||||
DARK.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
|
||||
/// a weak or missing red (protan), green (deutan) or blue (tritan) sense. Not a filter: Off gives
|
||||
/// back the theme's colours unchanged. Protan and Deutan share one red-green table, measured
|
||||
/// clear for both (`colour_vision_tests`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColorVision {
|
||||
#[default]
|
||||
Off,
|
||||
Protan,
|
||||
Deutan,
|
||||
Tritan,
|
||||
}
|
||||
|
||||
impl ColorVision {
|
||||
pub const ALL: [ColorVision; 4] = [ColorVision::Off, ColorVision::Protan, ColorVision::Deutan, ColorVision::Tritan];
|
||||
|
||||
/// The saved value (`ui.prefs` `color_vision`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ColorVision::Off => "off",
|
||||
ColorVision::Protan => "protan",
|
||||
ColorVision::Deutan => "deutan",
|
||||
ColorVision::Tritan => "tritan",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<ColorVision> {
|
||||
ColorVision::ALL.into_iter().find(|m| m.id() == id)
|
||||
}
|
||||
|
||||
/// Its name in the Preferences (a message id).
|
||||
pub fn label_id(self) -> &'static str {
|
||||
match self {
|
||||
ColorVision::Off => "ui.colour_vision_off",
|
||||
ColorVision::Protan => "ui.colour_vision_protan",
|
||||
ColorVision::Deutan => "ui.colour_vision_deutan",
|
||||
ColorVision::Tritan => "ui.colour_vision_tritan",
|
||||
}
|
||||
}
|
||||
|
||||
/// Who it is for, in one line (its tooltip; a message id).
|
||||
pub fn help_id(self) -> &'static str {
|
||||
match self {
|
||||
ColorVision::Off => "ui.colour_vision_off_tip",
|
||||
ColorVision::Protan => "ui.colour_vision_protan_tip",
|
||||
ColorVision::Deutan => "ui.colour_vision_deutan_tip",
|
||||
ColorVision::Tritan => "ui.colour_vision_tritan_tip",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// From saved preferences: a value this version doesn't know (a later version's mode, a hand
|
||||
/// edit) is Off rather than an error, which would lose every other saved preference with it.
|
||||
/// `ui.prefs` refuses such a value before it gets here (`prefs::set`).
|
||||
impl<'de> Deserialize<'de> for ColorVision {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_str().and_then(ColorVision::from_id).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour vision mode on now (on this thread).
|
||||
pub fn vision() -> ColorVision {
|
||||
VISION.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Switch the colour vision mode (the visuals follow on the next [`apply`]).
|
||||
pub fn set_vision(mode: ColorVision) {
|
||||
VISION.with(|v| v.set(mode));
|
||||
}
|
||||
|
||||
/// An sRGB colour given as 0..1 (the analysis colours), for drawing in the UI.
|
||||
pub fn srgb(c: [f32; 3]) -> Color32 {
|
||||
let byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgb(byte(c[0]), byte(c[1]), byte(c[2]))
|
||||
}
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read
|
||||
/// the same as when they were literals in the WGSL).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
|
||||
/// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its
|
||||
/// caller). Every icon colour is a token so a colour vision mode can reach it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct IconColors {
|
||||
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet, iso lines and teeth.
|
||||
pub sheet: Color32,
|
||||
pub sheet_2: Color32,
|
||||
pub sheet_light: Color32,
|
||||
pub sheet_line: Color32,
|
||||
/// Highlights (and the white ink of markers and the agent pointer), and the sphere's glint.
|
||||
pub highlight: Color32,
|
||||
pub glint: Color32,
|
||||
pub folder: Color32,
|
||||
pub plane: Color32,
|
||||
pub fold: Color32,
|
||||
/// Zebra stripes, and the environment map's sky and floor.
|
||||
pub stripe: Color32,
|
||||
pub sky: Color32,
|
||||
pub floor: Color32,
|
||||
/// Appearance: red, blue and yellow paint dabs.
|
||||
pub paint: [Color32; 3],
|
||||
/// Origin: X, Y and Z arrows.
|
||||
pub axes: [Color32; 3],
|
||||
/// Accessibility: reachable, blocked. Draft: positive, between. Curvature: flat, middle,
|
||||
/// tight.
|
||||
pub access: [Color32; 2],
|
||||
pub draft: [Color32; 2],
|
||||
pub curvature: [Color32; 3],
|
||||
pub interference: Color32,
|
||||
pub warning: Color32,
|
||||
/// The pointer an AI agent moves.
|
||||
pub agent: Color32,
|
||||
/// The dark halo under viewport markers (snap points).
|
||||
pub marker_halo: Color32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub app_bar: Color32,
|
||||
pub toolbar: Color32,
|
||||
@@ -135,12 +277,49 @@ pub struct Tokens {
|
||||
// ---- box selection ----
|
||||
pub box_window: Color32,
|
||||
pub box_crossing: Color32,
|
||||
// ---- colours that came from the engine or were drawn in place ----
|
||||
/// Sketch curves the engine tags (`view::colors`): projected, lost link, construction,
|
||||
/// centerline.
|
||||
pub sketch_projected: Color32,
|
||||
pub sketch_lost: Color32,
|
||||
pub sketch_construction: Color32,
|
||||
pub sketch_centerline: Color32,
|
||||
/// The view triad's X, Y and Z (and the CPU scene's origin axes).
|
||||
pub triad: [Color32; 3],
|
||||
/// The move gizmo's X, Y and Z.
|
||||
pub gizmo: [Color32; 3],
|
||||
/// Sheet metal flat pattern: bend up, bend down.
|
||||
pub bend_up: Color32,
|
||||
pub bend_down: Color32,
|
||||
/// Interference: the clash fill, before its shading.
|
||||
pub interference: Color32,
|
||||
pub analysis: AnalysisColors,
|
||||
pub icons: IconColors,
|
||||
/// The application bar's buttons: ink, fill and accent of their icons.
|
||||
pub app_bar_icon: [Color32; 3],
|
||||
/// The SolidCraft mark on the application bar: light, mid and dark faces, and its edge.
|
||||
pub logo: [Color32; 4],
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
/// The tokens of the current theme.
|
||||
/// The tokens of the current theme and colour vision mode.
|
||||
pub fn get() -> Tokens {
|
||||
if is_dark() { Tokens::dark() } else { Tokens::light() }
|
||||
Tokens::themed(is_dark(), vision())
|
||||
}
|
||||
|
||||
/// The tokens of a theme and colour vision mode.
|
||||
pub fn themed(dark: bool, mode: ColorVision) -> Tokens {
|
||||
(if dark { Tokens::dark() } else { Tokens::light() }).with_vision(dark, mode)
|
||||
}
|
||||
|
||||
/// These tokens with a colour vision mode's values on top (the tables in `palettes.rs`); Off
|
||||
/// gives them back unchanged.
|
||||
pub fn with_vision(self, dark: bool, mode: ColorVision) -> Tokens {
|
||||
match mode {
|
||||
ColorVision::Off => self,
|
||||
ColorVision::Protan | ColorVision::Deutan => self.red_green(dark),
|
||||
ColorVision::Tritan => self.blue_yellow(dark),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade idle cube colours into the viewport while retaining opaque facets.
|
||||
@@ -170,7 +349,7 @@ pub fn set_dark(dark: bool) {
|
||||
}
|
||||
|
||||
fn visuals(dark: bool) -> egui::Visuals {
|
||||
let t = if dark { Tokens::dark() } else { Tokens::light() };
|
||||
let t = Tokens::themed(dark, vision());
|
||||
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
|
||||
v.panel_fill = t.panel;
|
||||
v.window_fill = t.panel;
|
||||
|
||||
@@ -187,14 +187,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
br.shrink(5.0),
|
||||
icon,
|
||||
dim(Color32::from_rgb(225, 230, 238)),
|
||||
dim(Color32::from_rgb(120, 140, 170)),
|
||||
dim(Color32::from_rgb(120, 190, 255)),
|
||||
);
|
||||
icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2]));
|
||||
let clicked = resp.on_hover_text(tip).clicked();
|
||||
enabled && clicked
|
||||
};
|
||||
|
||||
+244
-21
@@ -279,6 +279,7 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.tree.redefine.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
crate::theme::vision().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
@@ -296,7 +297,7 @@ pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) {
|
||||
(scene_key(app), sketch_scene_key(app, proj))
|
||||
}
|
||||
|
||||
/// A sketch curve colour from the engine, in the current theme.
|
||||
/// A sketch curve colour from the engine, in the current theme and colour vision mode.
|
||||
fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
|
||||
if c == colors::SKETCH {
|
||||
c4(t.sketch)
|
||||
@@ -304,6 +305,14 @@ fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] {
|
||||
c4(t.sketch_fixed)
|
||||
} else if c == colors::SKETCH_LOCKED {
|
||||
c4(t.sketch_locked)
|
||||
} else if c == colors::SKETCH_PROJECTED {
|
||||
c4(t.sketch_projected)
|
||||
} else if c == colors::SKETCH_LOST {
|
||||
c4(t.sketch_lost)
|
||||
} else if c == colors::SKETCH_CONSTRUCTION {
|
||||
c4(t.sketch_construction)
|
||||
} else if c == colors::SKETCH_CENTERLINE {
|
||||
c4(t.sketch_centerline)
|
||||
} else {
|
||||
rgba(c)
|
||||
}
|
||||
@@ -1554,6 +1563,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
||||
app.ui.visual_style.hash(&mut h);
|
||||
app.ui.pick_bodies.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
crate::theme::vision().hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.origin_visible().hash(&mut h);
|
||||
app.session.visibility.hidden_origin.hash(&mut h);
|
||||
@@ -2263,6 +2273,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
size_px: [rect.width() * ppp, rect.height() * ppp],
|
||||
images: crate::ref_images::gpu_images(app),
|
||||
analysis: analysis_uniform(app),
|
||||
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
|
||||
access: crate::access::map(app),
|
||||
outline: matches!(app.ui.visual_style, 0 | 3),
|
||||
eye: {
|
||||
@@ -2981,10 +2992,11 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
}
|
||||
(rect.width() as u32, rect.height() as u32).hash(&mut h);
|
||||
app.ui.dark.hash(&mut h);
|
||||
app.preferences.color_vision.hash(&mut h);
|
||||
let key = h.finish();
|
||||
if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576);
|
||||
let palette = scene_palette(app.ui.dark);
|
||||
let palette = scene_palette(app.ui.dark, app.preferences.color_vision);
|
||||
let scene = solidcraft_engine::view::scene_with_palette(
|
||||
&app.session,
|
||||
&proj.cam,
|
||||
@@ -3005,11 +3017,11 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
}
|
||||
|
||||
/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or
|
||||
/// the light theme, from the tokens the window draws with, so the picture matches the window. The
|
||||
/// light theme's are the engine's own colours, so a light render is unchanged. The sketch states
|
||||
/// and axes the window draws in the engine's colours stay so.
|
||||
pub(crate) fn scene_palette(dark: bool) -> solidcraft_engine::view::Palette {
|
||||
let t = if dark { Tokens::dark() } else { Tokens::light() };
|
||||
/// the light theme and a colour vision mode, from the tokens the window draws with, so the picture
|
||||
/// matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
|
||||
/// render is unchanged.
|
||||
pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette {
|
||||
let t = Tokens::themed(dark, mode);
|
||||
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
|
||||
solidcraft_engine::view::Palette {
|
||||
background: (rgb(t.viewport_top), rgb(t.viewport_bottom)),
|
||||
@@ -3022,8 +3034,12 @@ pub(crate) fn scene_palette(dark: bool) -> solidcraft_engine::view::Palette {
|
||||
sketch_fixed: rgb(t.sketch_fixed),
|
||||
sketch_done: rgb(t.sketch_fixed),
|
||||
sketch_locked: rgb(t.sketch_locked),
|
||||
sketch_projected: rgb(t.sketch_projected),
|
||||
sketch_lost: rgb(t.sketch_lost),
|
||||
sketch_construction: rgb(t.sketch_construction),
|
||||
sketch_centerline: rgb(t.sketch_centerline),
|
||||
axes: t.triad.map(rgb),
|
||||
cap: rgb(t.section_cap),
|
||||
..solidcraft_engine::view::Palette::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3467,7 +3483,21 @@ mod tests {
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let (planned, shader) = (scene.planned.len(), tess.then_some(solidcraft_gpu::SHADER));
|
||||
(planned, scene.tris.len(), crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader))
|
||||
(
|
||||
planned,
|
||||
scene.tris.len(),
|
||||
crate::gpu::render_offscreen(
|
||||
scene,
|
||||
None,
|
||||
proj.vp.to_f32(),
|
||||
proj.cam.back().to_f32(),
|
||||
eye,
|
||||
SIZE,
|
||||
shader,
|
||||
[0.0; 8],
|
||||
crate::gpu::analysis_colors(&Tokens::get().analysis),
|
||||
),
|
||||
)
|
||||
};
|
||||
let (planned, gpu_tris, gpu) = render(true);
|
||||
let (none, cpu_tris, cpu) = render(false);
|
||||
@@ -3516,7 +3546,17 @@ mod tests {
|
||||
let proj = projection(&app, rect);
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let Some(img) = crate::gpu::render_offscreen(scene, None, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, Some(REJECTED)) else {
|
||||
let Some(img) = crate::gpu::render_offscreen(
|
||||
scene,
|
||||
None,
|
||||
proj.vp.to_f32(),
|
||||
proj.cam.back().to_f32(),
|
||||
eye,
|
||||
SIZE,
|
||||
Some(REJECTED),
|
||||
[0.0; 8],
|
||||
crate::gpu::analysis_colors(&Tokens::get().analysis),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let n = (SIZE * SIZE) as usize;
|
||||
@@ -3552,7 +3592,17 @@ mod tests {
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let shader = tess.then_some(solidcraft_gpu::SHADER);
|
||||
crate::gpu::render_offscreen(scene, Some(highlight), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, shader)
|
||||
crate::gpu::render_offscreen(
|
||||
scene,
|
||||
Some(highlight),
|
||||
proj.vp.to_f32(),
|
||||
proj.cam.back().to_f32(),
|
||||
eye,
|
||||
SIZE,
|
||||
shader,
|
||||
[0.0; 8],
|
||||
crate::gpu::analysis_colors(&Tokens::get().analysis),
|
||||
)
|
||||
};
|
||||
let lit = |plain: &[u8], hovered: &[u8]| -> Vec<bool> { plain.chunks(4).zip(hovered.chunks(4)).map(|(a, b)| a != b).collect() };
|
||||
let (Some(gpu_plain), Some(cpu_plain)) = (render(true, None), render(false, None)) else {
|
||||
@@ -3628,24 +3678,27 @@ mod tests {
|
||||
crate::sketch_palette::set_mode(&mut h.app, 0);
|
||||
}
|
||||
|
||||
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974).
|
||||
/// The light theme's tokens are the engine's own colours, so a light picture is unchanged; in
|
||||
/// the dark theme each part takes the token the window draws it with. Sketch states follow
|
||||
/// what the window does with them (`sketch_color`), so the two can't drift apart.
|
||||
/// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974)
|
||||
/// and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a
|
||||
/// light picture in Off is unchanged; otherwise each part takes the token the window draws it
|
||||
/// with. Sketch states follow what the window does with them (`sketch_color`) and the axes the
|
||||
/// mode's triad, so the two can't drift apart.
|
||||
#[test]
|
||||
fn cpu_pictures_are_drawn_in_the_windows_theme() {
|
||||
use crate::theme::ColorVision;
|
||||
use solidcraft_engine::view::Palette;
|
||||
assert_eq!(scene_palette(false), Palette::DEFAULT, "the light theme is the engine's colours");
|
||||
assert_eq!(scene_palette(false, ColorVision::Off), Palette::DEFAULT, "the light theme is the engine's colours");
|
||||
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
|
||||
let (p, t) = (scene_palette(true), Tokens::dark());
|
||||
let (p, t) = (scene_palette(true, ColorVision::Off), Tokens::dark());
|
||||
assert_eq!(p.background, (rgb(t.viewport_top), rgb(t.viewport_bottom)));
|
||||
assert_eq!(
|
||||
[p.grid, p.grid_major, p.edge, p.body, p.body_selected, p.cap],
|
||||
[t.grid, t.grid_major, t.body_edge, t.body, t.body_selected, t.section_cap].map(rgb)
|
||||
);
|
||||
assert_ne!(p.background, Palette::DEFAULT.background);
|
||||
for dark in [false, true] {
|
||||
let (p, t) = (scene_palette(dark), if dark { Tokens::dark() } else { Tokens::light() });
|
||||
for (dark, mode) in [false, true].into_iter().flat_map(|d| ColorVision::ALL.map(|m| (d, m))) {
|
||||
let (p, t) = (scene_palette(dark, mode), Tokens::themed(dark, mode));
|
||||
assert_eq!(p.axes, t.triad.map(rgb), "dark {dark}, {mode:?}: the axes");
|
||||
for (tag, cpu) in [
|
||||
(colors::SKETCH, p.sketch),
|
||||
(colors::SKETCH_FIXED, p.sketch_fixed),
|
||||
@@ -3657,12 +3710,13 @@ mod tests {
|
||||
(colors::SKETCH_CENTERLINE, p.sketch_centerline),
|
||||
] {
|
||||
let [r, g, b, _] = sketch_color(&t, tag);
|
||||
assert_eq!(cpu, Rgb(r, g, b), "dark {dark}: the window draws {tag:?} in another colour");
|
||||
assert_eq!(cpu, Rgb(r, g, b), "dark {dark}, {mode:?}: the window draws {tag:?} in another colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU viewport follows a theme change at once: the theme is part of its picture's key.
|
||||
/// The CPU viewport follows a theme or colour vision mode change at once: both are part of its
|
||||
/// picture's key.
|
||||
#[test]
|
||||
fn cpu_viewport_redraws_in_the_new_theme() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -3696,6 +3750,10 @@ mod tests {
|
||||
let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
|
||||
assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}");
|
||||
}
|
||||
// And a colour vision mode change, on its own.
|
||||
h.app.preferences.color_vision = crate::theme::ColorVision::Deutan;
|
||||
assert!(top_row(&mut h).is_some(), "a new mode kept the old picture");
|
||||
assert!(top_row(&mut h).is_none(), "no change, no new picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5012,6 +5070,171 @@ mod tests {
|
||||
app.session.visibility.hidden_origin.push(name);
|
||||
assert!(construction_axis_segments(&app).is_empty());
|
||||
}
|
||||
|
||||
/// Draft Analysis on the GPU takes its colours from the tokens: the sample design through the
|
||||
/// viewport's real pipeline, offscreen (skipped without a GPU). In Off the faces that pass are
|
||||
/// green; in Protan they are the red-green table's blue, and the negative faces are striped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn draft_analysis_on_the_gpu_draws_the_modes_colours_and_stripes() {
|
||||
use crate::theme::ColorVision;
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.run_script(&solidcraft_engine::sample::script()).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.viewport.no_pixels = true;
|
||||
app.fit_view();
|
||||
app.run("inspect.draft", json!({"pull": [-1, -1, 0]})).unwrap();
|
||||
app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
|
||||
const SIZE: u32 = 256;
|
||||
let proj = projection(&app, Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)));
|
||||
let e = proj.cam.eye().to_f32();
|
||||
let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }];
|
||||
let render = |c: &crate::theme::AnalysisColors| {
|
||||
crate::gpu::render_offscreen(
|
||||
build_scene(&app),
|
||||
None,
|
||||
proj.vp.to_f32(),
|
||||
proj.cam.back().to_f32(),
|
||||
eye,
|
||||
SIZE,
|
||||
None,
|
||||
analysis_uniform(&app),
|
||||
crate::gpu::analysis_colors(c),
|
||||
)
|
||||
};
|
||||
let off = Tokens::themed(false, ColorVision::Off).analysis;
|
||||
let protan = Tokens::themed(false, ColorVision::Protan).analysis;
|
||||
let flat = crate::theme::AnalysisColors { hatch: false, ..protan };
|
||||
let (Some(a), Some(b), Some(c)) = (render(&off), render(&protan), render(&flat)) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
// Pixels of a colour under any light: the same hue, so the same ratios between channels.
|
||||
let like = |img: &[u8], c: [f32; 3]| {
|
||||
img.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let m = c.iter().copied().fold(0.0, f32::max);
|
||||
let k = f32::from(p[0].max(p[1]).max(p[2])) / 255.0 / m;
|
||||
k > 0.3 && (0..3).all(|i| (f32::from(p[i]) / 255.0 - c[i] * k).abs() < 0.04)
|
||||
})
|
||||
.count()
|
||||
};
|
||||
let n = (SIZE * SIZE) as usize;
|
||||
assert!(like(&a, off.draft[0]) > n / 50 && like(&a, protan.draft[0]) == 0, "Off: green passing faces");
|
||||
assert!(like(&b, protan.draft[0]) > n / 50 && like(&b, off.draft[0]) == 0, "Protan: blue passing faces");
|
||||
// The stripes: with them on, some negative pixels are darker, and nothing else changes.
|
||||
let negative = |p: &[u8]| like(p, protan.draft[2]) == 1;
|
||||
let changed: Vec<usize> = (0..n).filter(|&i| b[4 * i..4 * i + 3] != c[4 * i..4 * i + 3]).collect();
|
||||
let neg = (0..n).filter(|&i| negative(&c[4 * i..4 * i + 4])).count();
|
||||
assert!(!changed.is_empty() && changed.len() * 2 < neg, "{} striped of {neg} negative pixels", changed.len());
|
||||
assert!(changed.iter().all(|&i| negative(&c[4 * i..4 * i + 4])), "only negative faces are striped");
|
||||
assert!(changed.iter().all(|&i| b[4 * i..4 * i + 3].iter().zip(&c[4 * i..4 * i + 3]).all(|(x, y)| x <= y)), "stripes are darker");
|
||||
}
|
||||
|
||||
/// The CPU viewport draws in the colour vision mode: a fixed sketch line in the light theme
|
||||
/// takes Deutan's value, and Off's is gone.
|
||||
#[test]
|
||||
fn cpu_viewport_draws_in_the_colour_vision_mode() {
|
||||
use crate::theme::ColorVision;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.dark = false;
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0));
|
||||
let mut cam = solidcraft_engine::view::home_camera(&h.app.session);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
h.app.viewport.no_pixels = false;
|
||||
let count = |h: &mut crate::scenario::Harness, c: Color32| {
|
||||
h.app.viewport.cpu = None;
|
||||
let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||||
cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj);
|
||||
});
|
||||
let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id());
|
||||
let n = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map_or(0, |upload| {
|
||||
let egui::ImageData::Color(image) = &upload.image;
|
||||
image.pixels.iter().filter(|p| [p.r(), p.g(), p.b()].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
n
|
||||
};
|
||||
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
|
||||
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
|
||||
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
|
||||
h.app.preferences.color_vision = ColorVision::Deutan;
|
||||
let (a, b) = (count(&mut h, off), count(&mut h, deutan));
|
||||
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
|
||||
}
|
||||
|
||||
/// A mode's table reaches the CPU picture: the sketch states and axes it changes, and nothing
|
||||
/// else (the light theme in Off is the engine's own colours).
|
||||
#[test]
|
||||
fn cpu_picture_follows_the_colour_vision_mode() {
|
||||
use crate::theme::ColorVision;
|
||||
let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b());
|
||||
let off = scene_palette(false, ColorVision::Off);
|
||||
let rg = scene_palette(false, ColorVision::Protan);
|
||||
let t = Tokens::themed(false, ColorVision::Protan);
|
||||
assert_eq!((rg.sketch_projected, rg.sketch_locked), (rgb(t.sketch_projected), rgb(t.sketch_locked)));
|
||||
assert_ne!(rg.sketch_projected, off.sketch_projected, "the red-green table changes projected lines in light");
|
||||
assert_eq!((rg.axes[0], rg.axes[1], rg.axes[2]), (rgb(t.triad[0]), rgb(t.triad[1]), off.axes[2]));
|
||||
assert_eq!((rg.sketch, rg.background, rg.grid, rg.body), (off.sketch, off.background, off.grid, off.body));
|
||||
let by = scene_palette(false, ColorVision::Tritan);
|
||||
assert_eq!(by.sketch_locked, rgb(Tokens::themed(false, ColorVision::Tritan).sketch_locked));
|
||||
assert_ne!(by.sketch_locked, off.sketch_locked);
|
||||
assert_eq!(by.sketch_projected, off.sketch_projected);
|
||||
}
|
||||
|
||||
/// The mode is on from the next frame (theme, widgets, caches, `ui.render`), and Cancel puts
|
||||
/// it back.
|
||||
#[test]
|
||||
fn colour_vision_mode_reaches_the_frame_and_the_view_caches() {
|
||||
use crate::theme::ColorVision;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
let keys = |app: &SolveApp| (scene_key(app), highlight_key(app));
|
||||
h.call("ui.prefs", json!({"open": "Graphics"}));
|
||||
assert!(h.widget("Colour vision").is_some() && h.widget("Mode").is_some());
|
||||
let before = keys(&h.app);
|
||||
h.call("ui.prefs", json!({"color_vision": "deutan"}));
|
||||
h.frame();
|
||||
assert_eq!(crate::theme::vision(), ColorVision::Deutan);
|
||||
assert_eq!(Tokens::get(), Tokens::themed(crate::theme::is_dark(), ColorVision::Deutan));
|
||||
let after = keys(&h.app);
|
||||
assert!(before.0 != after.0 && before.1 != after.1, "both caches are keyed on the mode");
|
||||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||
h.frame();
|
||||
assert_eq!((h.app.preferences.color_vision, crate::theme::vision()), (ColorVision::Off, ColorVision::Off));
|
||||
assert_eq!(keys(&h.app), before);
|
||||
// `ui.render` (the CPU picture) shows the mode too: a fixed sketch line in Deutan's value
|
||||
// (in the light theme, where the red-green table changes it; the render follows the theme).
|
||||
h.call("ui.prefs", json!({"theme": "light"}));
|
||||
h.frame();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap();
|
||||
h.call("ui.view", json!({"view": "top"}));
|
||||
let path = std::env::temp_dir().join(format!("sc_colour_vision_render_{}.png", std::process::id()));
|
||||
let near = |h: &mut crate::scenario::Harness, c: Color32| {
|
||||
let r = h.call("ui.render", json!({"path": path.to_string_lossy(), "width": 400, "height": 300}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let img = image::open(&path).unwrap().to_rgb8();
|
||||
img.pixels().filter(|p| p.0.iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count()
|
||||
};
|
||||
let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked);
|
||||
let (a, b) = (near(&mut h, off), near(&mut h, deutan));
|
||||
assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour");
|
||||
h.call("ui.prefs", json!({"color_vision": "deutan"}));
|
||||
h.frame();
|
||||
let (a, b) = (near(&mut h, off), near(&mut h, deutan));
|
||||
h.call("ui.prefs", json!({"color_vision": "off"}));
|
||||
h.frame();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{"note": "#778: Preferences › Graphics › Colour vision. A mode applies at once, Cancel puts it back, OK keeps it, Restore Defaults on Graphics resets it, and the window keeps its size."},
|
||||
{"click": {"handle": "titlebar:preferences"}},
|
||||
{"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}},
|
||||
{"widget": "Graphics"},
|
||||
{"expect": {"shown": "Colour vision", "prefs": {"dialog": "Graphics"}, "same_width": "prefs:window"}},
|
||||
{"widget": "Off"},
|
||||
{"widget": "Deutan"},
|
||||
{"expect": {"prefs": {"color_vision": "deutan"}, "same_width": "prefs:window"}},
|
||||
{"widget": "Cancel"},
|
||||
{"expect": {"prefs": {"dialog": null, "color_vision": "off"}}},
|
||||
{"key": "Comma", "cmd": true},
|
||||
{"widget": "Graphics"},
|
||||
{"widget": "Off"},
|
||||
{"widget": "Tritan"},
|
||||
{"widget": "OK"},
|
||||
{"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}},
|
||||
{"key": "Comma", "cmd": true},
|
||||
{"widget": "Graphics"},
|
||||
{"expect": {"shown": "Tritan", "same_width": "prefs:window"}},
|
||||
{"widget": "Restore Defaults"},
|
||||
{"expect": {"prefs": {"color_vision": "off"}}},
|
||||
{"widget": "OK"},
|
||||
{"expect": {"prefs": {"dialog": null, "color_vision": "off"}}}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user