Implement the reviewed UI icon system and durable review workflow (#853)

Thanks @Nathan-Dane.
This commit is contained in:
Nathan Dane authored and GitHub committed 2026-10-17 04:07:16 -04:00
1 parent c599238653
commit 64966bab88
374 files changed
+8187 -2221

No files matched your search

+1 -1
View File
@@ -1363,7 +1363,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
key: f.id.to_string(),
row_key: format!("p:{}", f.id),
label: f.name.clone(),
icon: "plane",
icon: crate::timeline::icon_of(&f.kind),
visible: !app.session.visibility.hidden_origin.contains(&f.name),
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
color: None,
+7 -99
View File
@@ -2,11 +2,11 @@
//! 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 egui::Color32;
use solidcraft_engine::render::Rgb;
use solidcraft_engine::view::colors;
use crate::theme::{IconColors, Tokens};
use crate::theme::Tokens;
fn rgb(c: Rgb) -> Color32 {
Color32::from_rgb(c.0, c.1, c.2)
@@ -56,103 +56,10 @@ fn analysis_colours_reach_the_shader_in_its_order() {
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:?}");
}
// Icons no longer take their colours from the caller or from `IconColors`: the SVG masters name
// palette roles (`assets/icons/ui/design/palette.toml`, light and dark, plus accents) that
// `icon_svg` resolves. `icon_svg`'s own tests hold that every master parses and that only the
// palette's colours exist; the colour vision modes do not reach them yet.
/// 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
@@ -171,6 +78,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("documents.rs", 4, "white hover overlays on document tabs"),
("frame.rs", 1, "the window shadow"),
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
("icons.rs", 1, "the white tint that carries an icon image's opacity"),
("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"),
+6 -5
View File
@@ -4,8 +4,9 @@
//! 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.
//! When a token edit breaks a mode here, adjust its measured variant. Shared icon accents live
//! in `assets/icons/ui/design/palette.toml`; `assets/styles/ui/palettes.rs` links those generated
//! values to UI tokens and owns the remaining UI-only colour-vision adjustments.
use std::collections::BTreeSet;
@@ -627,14 +628,14 @@ fn each_mode_changes_only_its_table() {
"preview_sketch",
];
let mut red_green_light = red_green_both.to_vec();
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]);
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]);
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"])),
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "preview_sketch", "analysis.hatch"])),
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "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}");
+2 -2
View File
@@ -342,8 +342,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
Target::Workspace => {
let rendering = crate::render_workspace::active(app);
let mut v = vec![
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "camera")
act("ui.workspace.design", "Design", "ws_design").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
act("ui.workspace.render", "Render", "ws_render")
.with(json!({"workspace":"render"}))
.key(if rendering { "current" } else { "" })
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
+6 -5
View File
@@ -108,11 +108,12 @@ impl SolveApp {
let _ = tx.send(());
})
.ok()?;
match rx.recv_timeout(wait) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
}
_ => None,
// Zero explicitly requests off-thread application, even if a fast worker
// finishes before this thread is scheduled again. Apply on the next frame.
if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) {
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
} else {
None
}
}
+894
View File
@@ -0,0 +1,894 @@
//! The UI icon renderer: the SVG masters in `assets/icons/ui/design/svg`, drawn at the pixel size
//! they are shown.
//!
//! The masters are a small, linted SVG subset (`tools/lint_icons.py`): absolute `M L C Q A Z`
//! paths, circles, groups with a clip or opacity, masks and clip paths. They are embedded at build
//! time together with `palette.toml` (every colour role in light and dark, and the accents) and
//! `aliases.toml` (see `build.rs`), parsed once, and rasterised with tiny-skia into a texture per
//! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a
//! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*`
//! roles. GAP instead erases preceding artwork to transparency; it never paints a background.
//! Changing a colour is an edit to `palette.toml` only.
//!
//! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the
//! placeholder, and `every_master_parses` holds that none do.
use std::collections::HashMap;
use std::sync::OnceLock;
use tiny_skia as sk;
include!(concat!(env!("OUT_DIR"), "/icon_data.rs"));
/// The id drawn for every icon that has no master yet.
pub const PLACEHOLDER: &str = "placeholder";
/// How a shape is painted.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Paint {
None,
/// Erase earlier artwork; never paint a theme background into the texture.
Knockout,
/// `currentColor`: the `line` role.
Line,
/// A palette role (index into `ROLE_NAMES`).
Role(usize),
/// A fixed colour (mask artwork).
Fixed([u8; 3]),
}
#[derive(Debug)]
struct Node {
path: sk::Path,
fill: Paint,
rule: sk::FillRule,
stroke: Paint,
width: f32,
dash: Vec<f32>,
opacity: f32,
mask: Option<usize>,
}
#[derive(Debug)]
enum Item {
Node(Node),
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
}
/// A parsed master.
#[derive(Debug)]
pub struct Doc {
items: Vec<Item>,
masks: Vec<Vec<Node>>,
clips: Vec<Vec<Node>>,
}
// ---------------------------------------------------------------------------------------------
// Parsing
struct Tag<'a> {
name: &'a str,
attrs: Vec<(&'a str, &'a str)>,
close: bool,
selfclose: bool,
}
impl Tag<'_> {
fn get(&self, k: &str) -> Option<&str> {
self.attrs.iter().find(|(n, _)| *n == k).map(|(_, v)| *v)
}
fn num(&self, k: &str) -> Result<f32, String> {
self.get(k).ok_or_else(|| format!("missing {k}"))?.trim().parse::<f32>().map_err(|e| format!("{k}: {e}"))
}
}
fn tags(src: &str) -> Result<Vec<Tag<'_>>, String> {
let mut out = Vec::new();
let mut rest = src;
while let Some(i) = rest.find('<') {
let after = rest.get(i + 1..).ok_or("truncated")?;
let j = after.find('>').ok_or("unterminated tag")?;
let body = after.get(..j).ok_or("truncated")?;
rest = after.get(j + 1..).ok_or("truncated")?;
if body.starts_with('?') || body.starts_with('!') {
continue;
}
let (close, body) = match body.strip_prefix('/') {
Some(b) => (true, b),
None => (false, body),
};
let (selfclose, body) = match body.strip_suffix('/') {
Some(b) => (true, b),
None => (false, body),
};
let body = body.trim();
let name_end = body.find(char::is_whitespace).unwrap_or(body.len());
let name = body.get(..name_end).ok_or("bad tag")?;
let mut attrs = Vec::new();
let mut a = body.get(name_end..).unwrap_or("");
while let Some(eq) = a.find('=') {
let key = a.get(..eq).ok_or("bad attribute")?.trim();
let v = a.get(eq + 1..).ok_or("bad attribute")?.trim_start();
let q = v.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute")?;
let v = v.get(1..).ok_or("bad attribute")?;
let end = v.find(q).ok_or("unterminated attribute")?;
attrs.push((key, v.get(..end).ok_or("bad attribute")?));
a = v.get(end + 1..).unwrap_or("");
}
out.push(Tag { name, attrs, close, selfclose });
}
Ok(out)
}
/// An SVG elliptical arc as cubic Béziers appended to `pb`.
#[allow(clippy::too_many_arguments)]
fn arc(pb: &mut sk::PathBuilder, x1: f32, y1: f32, rx: f32, ry: f32, rot_deg: f32, large: bool, sweep: bool, x2: f32, y2: f32) {
let (mut rx, mut ry) = (rx.abs(), ry.abs());
if rx < 1e-6 || ry < 1e-6 || ((x1 - x2).abs() < 1e-6 && (y1 - y2).abs() < 1e-6) {
pb.line_to(x2, y2);
return;
}
let phi = rot_deg.to_radians();
let (sp, cp) = phi.sin_cos();
let (dx, dy) = ((x1 - x2) / 2.0, (y1 - y2) / 2.0);
let (x1p, y1p) = (cp * dx + sp * dy, -sp * dx + cp * dy);
let lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
if lam > 1.0 {
let s = lam.sqrt();
rx *= s;
ry *= s;
}
let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
let sign = if large == sweep { -1.0 } else { 1.0 };
let coef = sign * (num / den).max(0.0).sqrt();
let (cxp, cyp) = (coef * rx * y1p / ry, -coef * ry * x1p / rx);
let (cx, cy) = (cp * cxp - sp * cyp + (x1 + x2) / 2.0, sp * cxp + cp * cyp + (y1 + y2) / 2.0);
let ang = |ux: f32, uy: f32, vx: f32, vy: f32| (ux * vy - uy * vx).atan2(ux * vx + uy * vy);
let (ux, uy) = ((x1p - cxp) / rx, (y1p - cyp) / ry);
let (vx, vy) = ((-x1p - cxp) / rx, (-y1p - cyp) / ry);
let theta = ang(1.0, 0.0, ux, uy);
let mut dtheta = ang(ux, uy, vx, vy);
if !sweep && dtheta > 0.0 {
dtheta -= std::f32::consts::TAU;
} else if sweep && dtheta < 0.0 {
dtheta += std::f32::consts::TAU;
}
let n = ((dtheta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
let delta = dtheta / n as f32;
let t = 4.0 / 3.0 * (delta / 4.0).tan();
let map = |x: f32, y: f32| (cx + rx * cp * x - ry * sp * y, cy + rx * sp * x + ry * cp * y);
for i in 0..n {
let a1 = theta + delta * i as f32;
let a2 = a1 + delta;
let (s1, c1) = a1.sin_cos();
let (s2, c2) = a2.sin_cos();
let (p1x, p1y) = map(c1 - t * s1, s1 + t * c1);
let (p2x, p2y) = map(c2 + t * s2, s2 - t * c2);
let (ex, ey) = map(c2, s2);
pb.cubic_to(p1x, p1y, p2x, p2y, ex, ey);
}
}
fn parse_path(d: &str) -> Result<sk::Path, String> {
let mut toks: Vec<&str> = Vec::new();
let b = d.as_bytes();
let mut i = 0;
while i < b.len() {
let c = *b.get(i).ok_or("path")?;
if c.is_ascii_alphabetic() {
toks.push(d.get(i..=i).ok_or("path")?);
i += 1;
} else if c == b'-' || c == b'.' || c.is_ascii_digit() {
let s = i;
i += 1;
while let Some(&n) = b.get(i) {
if n.is_ascii_digit()
|| n == b'.'
|| ((n == b'-' || n == b'+') && matches!(b.get(i - 1), Some(b'e' | b'E')))
|| n == b'e'
|| n == b'E'
{
i += 1;
} else {
break;
}
}
toks.push(d.get(s..i).ok_or("path")?);
} else {
i += 1;
}
}
let mut pb = sk::PathBuilder::new();
let (mut k, mut cmd) = (0usize, ' ');
let (mut x, mut y) = (0.0f32, 0.0f32);
let mut open = false;
let num = |k: &mut usize| -> Result<f32, String> {
let t = toks.get(*k).ok_or("path: missing number")?;
*k += 1;
t.parse::<f32>().map_err(|e| format!("path: {e}"))
};
while k < toks.len() {
let t = *toks.get(k).ok_or("path")?;
if t.len() == 1 && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) {
cmd = t.chars().next().ok_or("path")?;
k += 1;
}
match cmd {
'M' => {
(x, y) = (num(&mut k)?, num(&mut k)?);
pb.move_to(x, y);
open = true;
cmd = 'L';
}
'L' => {
(x, y) = (num(&mut k)?, num(&mut k)?);
pb.line_to(x, y);
}
'C' => {
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
pb.cubic_to(v[0], v[1], v[2], v[3], v[4], v[5]);
(x, y) = (v[4], v[5]);
}
'Q' => {
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
pb.quad_to(v[0], v[1], v[2], v[3]);
(x, y) = (v[2], v[3]);
}
'A' => {
let (rx, ry, rot) = (num(&mut k)?, num(&mut k)?, num(&mut k)?);
let (fa, fs) = (num(&mut k)? != 0.0, num(&mut k)? != 0.0);
let (ex, ey) = (num(&mut k)?, num(&mut k)?);
arc(&mut pb, x, y, rx, ry, rot, fa, fs, ex, ey);
(x, y) = (ex, ey);
}
'Z' => {
if open {
pb.close();
}
}
other => return Err(format!("path command {other} is not supported")),
}
}
pb.finish().ok_or_else(|| "empty path".to_string())
}
fn parse_paint(v: Option<&str>, in_mask: bool) -> Result<Paint, String> {
let Some(v) = v else { return Ok(Paint::None) };
match v {
"none" => Ok(Paint::None),
"currentColor" => Ok(Paint::Line),
"white" if in_mask => Ok(Paint::Fixed([255, 255, 255])),
"black" if in_mask => Ok(Paint::Fixed([0, 0, 0])),
_ => {
let lower = v.to_ascii_lowercase();
ROLE_SOURCE_HEX
.iter()
.position(|h| *h == lower)
.map(|role| if ROLE_NAMES.get(role) == Some(&"gap") { Paint::Knockout } else { Paint::Role(role) })
.ok_or_else(|| format!("colour {v} is not a palette colour"))
}
}
}
fn parse_dash(v: Option<&str>) -> Vec<f32> {
let mut d: Vec<f32> = v.map(|s| s.split([' ', ',']).filter_map(|n| n.parse().ok()).collect()).unwrap_or_default();
if d.len() % 2 == 1 {
d.extend(d.clone());
}
d
}
fn url_ref(v: &str) -> Option<&str> {
v.strip_prefix("url(#")?.strip_suffix(')')
}
enum Frame {
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
Mask(usize),
Clip(usize),
Defs,
}
impl Doc {
pub fn parse(src: &str) -> Result<Doc, String> {
let mut doc = Doc { items: Vec::new(), masks: Vec::new(), clips: Vec::new() };
let (mut mask_ids, mut clip_ids): (HashMap<String, usize>, HashMap<String, usize>) = (HashMap::new(), HashMap::new());
let mut stack: Vec<Frame> = Vec::new();
for t in tags(src)? {
match (t.name, t.close) {
("svg" | "title", _) => {}
("defs", false) => stack.push(Frame::Defs),
("mask", false) => {
doc.masks.push(Vec::new());
mask_ids.insert(t.get("id").ok_or("mask without id")?.to_string(), doc.masks.len() - 1);
stack.push(Frame::Mask(doc.masks.len() - 1));
}
("clipPath", false) => {
doc.clips.push(Vec::new());
clip_ids.insert(t.get("id").ok_or("clipPath without id")?.to_string(), doc.clips.len() - 1);
stack.push(Frame::Clip(doc.clips.len() - 1));
}
("g", false) => {
let clip = match t.get("clip-path").and_then(url_ref) {
Some(id) => Some(*clip_ids.get(id).ok_or("unknown clip")?),
None => None,
};
let opacity = t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0);
stack.push(Frame::Group { clip, opacity, items: Vec::new() });
}
("defs" | "mask" | "clipPath" | "g", true) => match stack.pop() {
Some(Frame::Group { clip, opacity, items }) => {
let item = Item::Group { clip, opacity, items };
match stack.last_mut() {
Some(Frame::Group { items, .. }) => items.push(item),
_ => doc.items.push(item),
}
}
Some(_) => {}
None => return Err("unbalanced tags".into()),
},
("path" | "circle", _) => {
let in_mask = matches!(stack.last(), Some(Frame::Mask(_)));
let path = if t.name == "path" {
parse_path(t.get("d").ok_or("path without d")?)?
} else {
sk::PathBuilder::from_circle(t.num("cx")?, t.num("cy")?, t.num("r")?).ok_or("bad circle")?
};
let mask = match t.get("mask").and_then(url_ref) {
Some(id) => Some(*mask_ids.get(id).ok_or("unknown mask")?),
None => None,
};
let dash = parse_dash(t.get("stroke-dasharray"));
let mut stroke = parse_paint(t.get("stroke"), in_mask)?;
let visible_stroke = match stroke {
Paint::Line => true,
Paint::Role(role) => !matches!(ROLE_NAMES.get(role).copied(), Some("gap" | "active-1" | "active-2" | "active-3")),
_ => false,
};
// Non-blue dotted/dashed references share the guide colour.
// Blue source paints retain their semantic accent; keep
// background knockouts and literal mask paints unchanged.
if !dash.is_empty() && visible_stroke && !in_mask {
stroke = Paint::Role(ROLE_NAMES.iter().position(|name| *name == "guide").ok_or("missing guide colour")?);
}
let node = Node {
path,
fill: if t.get("fill").is_none() && in_mask { Paint::None } else { parse_paint(t.get("fill").or(Some("none")), in_mask)? },
rule: if t.get("fill-rule") == Some("evenodd") { sk::FillRule::EvenOdd } else { sk::FillRule::Winding },
stroke,
width: t.get("stroke-width").and_then(|w| w.parse().ok()).unwrap_or(1.0),
dash,
opacity: t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0),
mask,
};
match stack.last_mut() {
Some(Frame::Mask(i)) => doc.masks.get_mut(*i).ok_or("mask")?.push(node),
Some(Frame::Clip(i)) => doc.clips.get_mut(*i).ok_or("clip")?.push(node),
Some(Frame::Group { items, .. }) => items.push(Item::Node(node)),
_ => doc.items.push(Item::Node(node)),
}
}
(other, _) => return Err(format!("element <{other}> is not supported")),
}
let _ = t.selfclose;
}
if !stack.is_empty() {
return Err("unbalanced tags".into());
}
Ok(doc)
}
}
// ---------------------------------------------------------------------------------------------
// Rendering
/// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for
/// the base palette).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Look {
pub dark: bool,
pub accent: Option<usize>,
pub vision: crate::theme::ColorVision,
}
impl Look {
fn rgb(self, role: usize) -> [u8; 3] {
let theme = usize::from(self.dark);
if ROLE_NAMES.get(role) == Some(&"guide") {
return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]);
}
let vision = match self.vision {
crate::theme::ColorVision::Off => "",
crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green",
crate::theme::ColorVision::Tritan => "blue-yellow",
};
if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) {
return if self.dark { *d } else { *l };
}
if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) {
return if self.dark { *d } else { *l };
}
PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255])
}
fn resolve(self, p: Paint) -> Option<[u8; 3]> {
match p {
Paint::None | Paint::Knockout => None,
Paint::Fixed(c) => Some(c),
Paint::Role(r) => Some(self.rgb(r)),
Paint::Line => ROLE_NAMES.iter().position(|n| *n == "line").map(|r| self.rgb(r)),
}
}
}
fn paint_of(c: [u8; 3], alpha: f32) -> sk::Paint<'static> {
let mut p = sk::Paint::default();
p.set_color_rgba8(c[0], c[1], c[2], (alpha.clamp(0.0, 1.0) * 255.0).round() as u8);
p.anti_alias = true;
p
}
fn shape_paint(paint: Paint, look: Look, alpha: f32) -> Option<sk::Paint<'static>> {
if paint == Paint::Knockout {
let mut paint = paint_of([0, 0, 0], alpha);
paint.blend_mode = sk::BlendMode::DestinationOut;
Some(paint)
} else {
look.resolve(paint).map(|colour| paint_of(colour, alpha))
}
}
fn draw_node(pm: &mut sk::Pixmap, n: &Node, look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
let own;
let mask = if let Some(m) = n.mask {
own = doc.masks.get(m).and_then(|nodes| {
let mut sub = sk::Pixmap::new(pm.width(), pm.height())?;
for node in nodes {
draw_node(&mut sub, node, look, tf, doc, None, 1.0);
}
let mut own = sk::Mask::from_pixmap(sub.as_ref(), sk::MaskType::Luminance);
if let Some(parent) = clip {
for (value, parent) in own.data_mut().iter_mut().zip(parent.data()) {
*value = ((u16::from(*value) * u16::from(*parent) + 127) / 255) as u8;
}
}
Some(own)
});
own.as_ref()
} else {
clip
};
let alpha = n.opacity * opacity;
if let Some(paint) = shape_paint(n.fill, look, alpha) {
pm.fill_path(&n.path, &paint, n.rule, tf, mask);
}
if let Some(paint) = shape_paint(n.stroke, look, alpha) {
let stroke = sk::Stroke {
width: n.width,
line_cap: sk::LineCap::Round,
line_join: sk::LineJoin::Round,
dash: if n.dash.is_empty() { None } else { sk::StrokeDash::new(n.dash.clone(), 0.0) },
..sk::Stroke::default()
};
pm.stroke_path(&n.path, &paint, &stroke, tf, mask);
}
}
fn draw_items(pm: &mut sk::Pixmap, items: &[Item], look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
for item in items {
match item {
Item::Node(n) => draw_node(pm, n, look, tf, doc, clip, opacity),
Item::Group { clip: c, opacity: o, items } => {
let mask = c.and_then(|c| doc.clips.get(c)).and_then(|nodes| {
let mut m = sk::Mask::new(pm.width(), pm.height())?;
for n in nodes {
m.fill_path(&n.path, n.rule, true, tf);
}
Some(m)
});
draw_items(pm, items, look, tf, doc, mask.as_ref().or(clip), opacity * o);
}
}
}
}
/// Rasterise `doc` into a square `px` × `px` premultiplied RGBA image.
pub fn render(doc: &Doc, look: Look, px: u32) -> Option<sk::Pixmap> {
let mut pm = sk::Pixmap::new(px, px)?;
let s = px as f32 / 64.0;
draw_items(&mut pm, &doc.items, look, sk::Transform::from_scale(s, s), doc, None, 1.0);
Some(pm)
}
// ---------------------------------------------------------------------------------------------
// The icon table
struct Entry {
doc: Doc,
}
fn table() -> &'static (HashMap<&'static str, Entry>, HashMap<&'static str, Entry>) {
static T: OnceLock<(HashMap<&'static str, Entry>, HashMap<&'static str, Entry>)> = OnceLock::new();
T.get_or_init(|| {
let (mut big, mut small) = (HashMap::new(), HashMap::new());
for (id, fam, src) in SVGS {
match Doc::parse(src) {
Ok(doc) => {
let map = if *fam == "small" { &mut small } else { &mut big };
// Model masters precede flat glyphs: keep the reviewed command artwork.
map.entry(*id).or_insert(Entry { doc });
}
Err(e) => log::warn!("icon {fam}/{id}: {e}"),
}
}
(big, small)
})
}
/// A resolved icon: the master to draw and the accent it is drawn in.
#[derive(Clone, Copy, Debug)]
pub struct Resolved {
pub master: &'static str,
pub accent: Option<usize>,
pub placeholder: bool,
}
fn accent_index(name: &str) -> Option<usize> {
if name.is_empty() { None } else { ACCENT_NAMES.iter().position(|a| *a == name) }
}
/// A semantic default belongs to the requested id, never to the caller's current workspace.
fn default_accent(name: &str) -> Option<usize> {
ICON_ACCENTS.iter().find(|(id, _)| *id == name).and_then(|(_, accent)| accent_index(accent))
}
/// Primary semantic stroke, also used by toolbar affordances. The palette owns its value.
pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 {
let look = Look { dark, accent: resolve(name).accent, vision };
role_colour("active-3", look)
}
pub(crate) fn role_colour(name: &str, look: Look) -> egui::Color32 {
if name == "gap" {
return egui::Color32::TRANSPARENT;
}
let role = ROLE_NAMES.iter().position(|n| *n == name);
let [r, g, b] = role.map(|role| look.rgb(role)).unwrap_or([255, 0, 255]);
crate::theme::icon_color([r, g, b])
}
/// The master and accent for an app icon id: an alias (with its accent), a master of that name,
/// or the placeholder.
pub fn resolve(name: &str) -> Resolved {
let (big, _) = table();
let placeholder = Resolved { master: PLACEHOLDER, accent: None, placeholder: true };
if let Some((_, master, accent)) = ALIASES.iter().find(|(a, _, _)| *a == name) {
return match big.get_key_value(master) {
Some((k, _)) => Resolved { master: k, accent: accent_index(accent).or_else(|| default_accent(name)), placeholder: false },
None => placeholder,
};
}
match big.get_key_value(name) {
Some((k, _)) if name != PLACEHOLDER => Resolved { master: k, accent: default_accent(name), placeholder: false },
_ => placeholder,
}
}
/// The master to draw at `px` pixels: the small-slot variant where one exists.
pub fn doc_for(master: &str, px: u32) -> Option<&'static Doc> {
let (big, small) = table();
if let Some(e) = small.get(master).filter(|_| px <= 24) {
return Some(&e.doc);
}
big.get(master).or_else(|| big.get(PLACEHOLDER)).map(|e| &e.doc)
}
/// Every master id (not the small variants), for tests and tooling.
pub fn master_ids() -> Vec<&'static str> {
let mut v: Vec<_> = table().0.keys().copied().collect();
v.sort_unstable();
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_master_parses() {
for (id, fam, src) in SVGS {
if let Err(e) = Doc::parse(src) {
panic!("{fam}/{id}: {e}");
}
}
assert!(table().0.contains_key(PLACEHOLDER), "the placeholder master exists");
}
#[test]
fn aliases_point_at_masters_and_known_accents() {
for (alias, master, accent) in ALIASES {
assert!(table().0.contains_key(master), "{alias} -> {master}");
assert!(accent.is_empty() || ACCENT_NAMES.contains(accent), "{alias}: accent {accent}");
assert!(!resolve(alias).placeholder);
}
}
#[test]
fn non_blue_dashed_strokes_use_guides_but_preserve_accents_and_knockouts() {
let svg = "<svg><path d='M8 20L56 20' stroke='#8e9faf' stroke-width='4.25' stroke-dasharray='3 5'/><path d='M8 40L56 40' stroke='#ffffff' stroke-width='6' stroke-dasharray='3 5'/><path d='M8 52L56 52' stroke='#236ead' stroke-width='4.25' stroke-dasharray='3 5'/></svg>";
let doc = Doc::parse(svg).unwrap();
let guide = ROLE_NAMES.iter().position(|name| *name == "guide").unwrap();
let active = ROLE_NAMES.iter().position(|name| *name == "active-3").unwrap();
let Item::Node(line) = &doc.items[0] else { panic!("expected line") };
let Item::Node(knockout) = &doc.items[1] else { panic!("expected knockout") };
let Item::Node(blue) = &doc.items[2] else { panic!("expected blue line") };
assert_eq!(line.stroke, Paint::Role(guide));
assert_eq!(knockout.stroke, Paint::Knockout);
assert_eq!(blue.stroke, Paint::Role(active));
for dark in [false, true] {
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]);
let pm = render(&doc, look, 64).unwrap();
assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide));
assert_eq!([pm.pixel(9, 52).unwrap().red(), pm.pixel(9, 52).unwrap().green(), pm.pixel(9, 52).unwrap().blue()], look.rgb(active));
}
}
}
#[test]
fn knockout_fills_and_strokes_erase_earlier_layers_and_allow_later_foreground() {
let doc = Doc::parse("<svg><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><circle cx='20' cy='20' r='10' fill='#ffffff'/><circle cx='20' cy='20' r='3' fill='#236ead'/><path d='M8 44L56 44' stroke='#ffffff' stroke-width='6'/><circle cx='44' cy='20' r='6' fill='#ffffff' opacity='0.5'/></svg>").unwrap();
for dark in [false, true] {
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
let pm = render(&doc, look, 64).unwrap();
assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background");
assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background");
assert_eq!(pm.pixel(20, 20).unwrap().alpha(), 255, "foreground is drawn after erasure");
assert_eq!(pm.pixel(2, 2).unwrap().alpha(), 255, "unaffected base stays opaque");
assert!((126..=128).contains(&pm.pixel(44, 20).unwrap().alpha()), "partial opacity erases partially");
}
}
}
#[test]
fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() {
let doc = Doc::parse("<svg><defs><clipPath id='left'><path d='M0 0L32 0L32 64L0 64Z'/></clipPath><mask id='all'><path d='M0 0L64 0L64 64L0 64Z' fill='white'/></mask></defs><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><g clip-path='url(#left)'><circle cx='32' cy='32' r='16' fill='#ffffff' mask='url(#all)'/></g></svg>").unwrap();
let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0);
assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip");
}
#[test]
fn accepted_badges_and_environment_sun_have_transparent_holes() {
for dark in [false, true] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT);
for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] {
let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap();
assert_eq!(pm.pixel(x, y).unwrap().alpha(), 0, "{id}: GAP must not be a painted theme colour");
}
}
}
#[test]
fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() {
for dark in [false, true] {
for px in [16, 24, 64] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] {
let doc = doc_for(id, px).unwrap();
let pm = render(doc, look, px).unwrap();
assert_eq!(pm.pixel(px - 2, px - 2).unwrap().alpha(), 0, "{id} escapes its native clip");
assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents");
}
}
let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer");
}
}
#[test]
fn command_meanings_choose_the_same_accent_on_every_surface() {
let accent = |name| resolve(name).accent.map(|i| ACCENT_NAMES[i]);
for c in solidcraft_engine::command_specs() {
let want = if c.id.starts_with("surface.") {
Some("surface")
} else if c.panel == "CONSTRAINTS" {
Some("constraint")
} else if matches!(c.id, "sketch.construction" | "sketch.centerline") {
Some("construction")
} else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") {
Some("sketch")
} else if c.id == "file.save_mesh" {
Some("mesh")
} else {
continue;
};
assert_eq!(accent(c.icon), want, "{} ({})", c.id, c.icon);
}
for id in ["move", "scale", "copy", "delete", "export", "ground", "axis_two_planes"] {
assert_eq!(accent(id), None, "shared utility / modelling icon {id}");
}
for (alias, master) in [("sketch_move", "move"), ("sketch_scale", "scale"), ("surface_fair", "spline_cv"), ("mesh_export", "export")] {
assert_eq!(resolve(alias).master, resolve(master).master, "reuse accepted shapes");
}
}
#[test]
fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() {
for dark in [false, true] {
for vision in crate::theme::ColorVision::ALL {
let tokens = crate::theme::Tokens::themed(dark, vision);
assert_eq!(primary("line", dark, vision), tokens.sketch_accent);
assert_eq!(primary("c_parallel", dark, vision), tokens.glyph_text);
assert_eq!(primary("centerline", dark, vision), tokens.construction_geom);
assert_eq!(primary("construction", dark, vision), tokens.construction_geom);
}
}
}
#[test]
fn semantic_ramps_preserve_silhouettes_and_passive_faces_in_all_themes() {
use crate::theme::ColorVision;
for id in ["line", "c_parallel", "construction", "centerline", "sketch_rect_pattern", "surface_extrude", "mesh_export"] {
let resolved = resolve(id);
for dark in [false, true] {
for vision in ColorVision::ALL {
let look = Look { dark, accent: resolved.accent, vision };
let base = Look { accent: None, ..look };
for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] {
let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap();
assert_eq!(look.rgb(role), base.rgb(role), "{id} changes passive paint");
}
for px in [16, 24, 64] {
let doc = doc_for(resolved.master, px).unwrap();
let changed = render(doc, look, px).unwrap();
let original = render(doc, base, px).unwrap();
assert!(changed.pixels().iter().zip(original.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{id}: silhouette at {px}px");
}
}
}
}
}
#[test]
fn command_artwork_resolves_and_construct_methods_are_distinct() {
let specs = solidcraft_engine::command_specs();
for command in &specs {
assert!(!resolve(command.icon).placeholder, "{}: {}", command.id, command.icon);
}
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
assert_eq!(construction.len(), 19);
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
}
#[test]
fn helper_controls_resolve_without_command_registry_entries() {
for name in ["agent_chat", "normal", "infinite", "pattern_flip", "pattern_center"] {
assert!(!resolve(name).placeholder, "UI helper: {name}");
}
}
#[test]
fn sketch_construction_modes_have_distinct_icons() {
let families: &[&[&str]] = &[
&["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"],
&["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"],
&["sketch.slot.arc_three_point", "sketch.slot.arc_center"],
&["sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"],
];
for commands in families {
let icons: std::collections::HashSet<_> = commands.iter().map(|id| solidcraft_engine::find_command(id).unwrap().icon).collect();
assert_eq!(icons.len(), commands.len(), "Sketch modes share an icon: {commands:?}");
for icon in icons {
assert!(!resolve(icon).placeholder, "{icon}");
}
}
}
#[test]
fn contact_actions_have_distinct_icons_from_interference_analysis() {
let mut icons = std::collections::HashSet::new();
let interference = solidcraft_engine::find_command("inspect.interference").unwrap().icon;
for id in ["contact.enable_sets", "contact.enable_all", "contact.disable_all", "contact.create"] {
let icon = solidcraft_engine::find_command(id).unwrap().icon;
assert_ne!(icon, interference, "{id} is a contact control, not an interference result");
assert!(icons.insert(icon), "contact controls share an icon: {id}");
assert!(!resolve(icon).placeholder, "{id}: {icon}");
}
}
#[test]
fn reviewed_model_folder_is_also_the_folder_drawn_in_the_app() {
let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2;
let expected = Doc::parse(source).unwrap();
for dark in [false, true] {
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap();
let reviewed = render(&expected, look, 64).unwrap();
assert_eq!(actual.data(), reviewed.data());
}
}
#[test]
fn unknown_names_draw_the_placeholder() {
assert!(resolve("no_such_icon").placeholder);
assert!(resolve("").placeholder);
assert!(!resolve("extrude").placeholder);
}
fn coverage(pm: &sk::Pixmap) -> usize {
pm.pixels().iter().filter(|p| p.alpha() > 0).count()
}
#[test]
fn every_master_draws_something_at_every_size_in_both_themes() {
for id in master_ids() {
for dark in [false, true] {
for px in [16, 20, 26, 64] {
let Some(doc) = doc_for(id, px) else { panic!("{id}") };
let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px)
.unwrap_or_else(|| panic!("{id} at {px}"));
assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank");
}
}
}
}
fn rgba(pm: &sk::Pixmap) -> Vec<u8> {
pm.data().to_vec()
}
#[test]
fn dark_theme_and_accents_change_colour_not_shape() {
let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude"));
let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
assert_ne!(rgba(&light), rgba(&dark));
let r = resolve("surface_extrude");
assert_eq!(r.master, "extrude");
let surface =
render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
assert!(r.accent.is_some());
assert_ne!(rgba(&light), rgba(&surface));
// Same silhouette: the alpha channel is identical.
let alpha = |pm: &sk::Pixmap| pm.pixels().iter().map(|p| p.alpha()).collect::<Vec<_>>();
assert_eq!(alpha(&light), alpha(&surface));
}
#[test]
fn hostile_artwork_is_an_error_not_a_panic() {
for bad in [
"",
"<",
"<svg",
"<svg><path d=\"M 1\"/></svg>",
"<svg><path d=\"X\"/></svg>",
"<svg><g></svg>",
"<svg><rect/></svg>",
"<svg><path d=\"M 0 0 L 1 1\" fill=\"#123456\"/></svg>",
"<svg><path d='M0 0 L 1 1' mask='url(#x)'/></svg>",
] {
let _ = Doc::parse(bad);
}
assert!(Doc::parse("<svg><path d=\"M 1\"/></svg>").is_err());
assert!(Doc::parse("<svg><g></svg>").is_err());
}
#[test]
fn palette_roles_are_distinct_and_complete() {
assert_eq!(ROLE_NAMES.len(), ROLE_SOURCE_HEX.len());
let mut seen = std::collections::HashSet::new();
assert!(
ROLE_NAMES.iter().zip(ROLE_SOURCE_HEX).filter(|(n, _)| **n != "line").all(|(_, h)| seen.insert(*h)),
"two roles share a source identifier"
);
assert!(ROLE_NAMES.contains(&"line"));
assert_eq!(PALETTE.len(), 2);
assert!(PALETTE.iter().all(|p| p.len() == ROLE_NAMES.len()));
}
}
+106 -6
View File
@@ -1,9 +1,16 @@
//! Stable UI rendering boundary for the collected artwork. Compiled only at L5.
#[path = "../../../assets/icons/ui/source/icons.rs"]
mod source;
pub use source::paint;
#[cfg(test)]
pub(crate) use source::paint_with;
//! Stable UI rendering boundary for the icon artwork. Compiled only at L5.
//!
//! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`,
//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the
//! accents), never from the caller. An icon with no master yet draws the placeholder.
//! The glyph, marker, navigation and pointer sources below are not command icons.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{Color32, Painter, Rect, TextureHandle, TextureOptions, pos2};
use crate::icon_svg::{self, Look};
#[path = "../../../assets/icons/ui/source/glyphs.rs"]
pub(crate) mod glyphs;
#[path = "../../../assets/icons/ui/source/markers.rs"]
@@ -12,3 +19,96 @@ pub(crate) mod markers;
pub(crate) mod navigation;
#[path = "../../../assets/icons/ui/source/pointer.rs"]
pub(crate) mod pointer;
/// Textures already rasterised: one per (master, look, pixel size).
#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<(&'static str, Look, u32), TextureHandle>>>);
/// Draw the icon `name` in `r`. The ink, fill and accent colours the callers pass are no longer
/// used: the palette decides. Only the ink's opacity is kept, so a dimmed (disabled) button dims
/// its icon.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, _fill: Color32, _accent: Color32) {
paint_icon(p, r, name, f32::from(ink.a()) / 255.0);
}
/// [`paint`] with an explicit opacity (0 to 1).
pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) {
paint_on(p, r, name, None, opacity);
}
/// Draw the icon `name` for a surface that is always dark (the application bar), whatever the
/// theme: `dark: Some(true)`. `None` follows the theme.
pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option<bool>, opacity: f32) {
let ctx = p.ctx();
let ppp = ctx.pixels_per_point().max(0.5);
let side = r.width().min(r.height());
let px = (side * ppp).round().clamp(8.0, 512.0) as u32;
let resolved = icon_svg::resolve(name);
let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() };
let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone());
let Ok(mut map) = cache.0.lock() else { return };
let key = (resolved.master, look, px);
if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) {
let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return };
let Some(pm) = icon_svg::render(doc, look, px) else { return };
let image = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data());
slot.insert(ctx.load_texture(format!("icon:{}:{px}", resolved.master), image, TextureOptions::LINEAR));
}
let Some(tex) = map.get(&key) else { return };
// Snap the image to whole physical pixels so it stays crisp.
let size = px as f32 / ppp;
let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp);
let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8);
p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::{self, ColorVision};
#[test]
fn actual_uploaded_icon_pixels_follow_semantics_and_vision_cache_changes() {
let ctx = egui::Context::default();
let previous = theme::vision();
for dark in [false, true] {
for vision in [ColorVision::Off, ColorVision::Protan, ColorVision::Tritan] {
theme::set_vision(vision);
for icon in ["line", "c_parallel", "construction", "centerline", "surface_extrude", "mesh_export", "contact_new"] {
let expected = if icon == "construction" {
icon_svg::role_colour("guide", Look { dark, accent: None, vision })
} else {
icon_svg::primary(icon, dark, vision)
};
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
paint_on(ui.painter(), r, icon, Some(dark), 1.0);
});
assert!(
output.textures_delta.set.iter().any(|(_, deltas)| deltas.iter().any(|delta| match &delta.image {
egui::ImageData::Color(image) => image.pixels.contains(&expected),
})),
"{icon}, {dark}, {vision:?}: actual texture misses its semantic stroke or reused a stale cache entry"
);
if icon == "contact_new" {
let image = output
.textures_delta
.set
.values()
.flatten()
.find_map(|delta| match &delta.image {
egui::ImageData::Color(image) if image.size == [64, 64] => Some(image),
_ => None,
})
.unwrap();
assert_eq!(image.pixels.get(48 * 64 + 48), Some(&Color32::TRANSPARENT), "uploaded plus must stay transparent");
}
// This headless test inspects uploads instead of sending them
// to a GPU; acknowledge the deltas before they are dropped.
output.textures_delta.clear();
}
}
}
theme::set_vision(previous);
}
}
+1
View File
@@ -60,6 +60,7 @@ pub mod i18n;
mod i18n_menu_tests;
#[cfg(test)]
mod i18n_ui_tests;
pub mod icon_svg;
pub mod icons;
pub mod inference;
pub mod install;
+2 -1
View File
@@ -283,7 +283,8 @@ fn moved(x: &str, y: &str, angle: &str) -> Mod {
fn the_command_now_has_a_toolbar_placement_and_opens_a_window() {
let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered");
assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY"));
assert_eq!(spec.icon, "move");
assert_eq!(spec.icon, "sketch_move");
assert_eq!(crate::icon_svg::resolve(spec.icon).master, "move", "reuse the finished Move drawing");
let mut h = rectangle();
let before = curve_ids(&h).len();
+29 -6
View File
@@ -3,7 +3,6 @@
//! uses comes from [`Tokens`].
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "../../../assets/styles/ui/palettes.rs"]
mod palettes;
@@ -11,10 +10,11 @@ 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
/// Theme state belongs to the UI thread drawing the frame. Concurrent app/test renderers
/// must not change one another's palette while a frame or icon texture is being built.
static DARK: Cell<bool> = const { Cell::new(true) };
/// The colour vision mode, per thread. 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
@@ -26,7 +26,12 @@ thread_local! {
/// Is the dark theme on?
pub fn is_dark() -> bool {
DARK.load(Ordering::Relaxed)
DARK.get()
}
/// The common conversion boundary for compiled icon palette data.
pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
@@ -333,7 +338,7 @@ pub fn material_color(name: &str) -> Option<Color32> {
/// Switch themes (the visuals follow on the next [`apply`]).
pub fn set_dark(dark: bool) {
DARK.store(dark, Ordering::Relaxed);
DARK.set(dark);
}
fn visuals(dark: bool) -> egui::Visuals {
@@ -373,6 +378,24 @@ pub fn apply(ctx: &egui::Context) {
#[cfg(test)]
mod tests {
#[test]
fn concurrent_ui_renderers_keep_their_own_theme_and_vision() {
let barrier = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
for (dark, vision) in [(false, super::ColorVision::Off), (true, super::ColorVision::Protan)] {
let barrier = &barrier;
scope.spawn(move || {
super::set_dark(dark);
super::set_vision(vision);
barrier.wait();
assert_eq!(super::is_dark(), dark);
assert_eq!(super::vision(), vision);
assert_eq!(super::Tokens::get().sketch_accent, super::Tokens::themed(dark, vision).sketch_accent);
});
}
});
}
/// Title and section bands stand out from a dialog, the title more than its sections.
#[test]
fn heading_bands_are_darker_than_the_dialog_in_order() {
+149 -8
View File
@@ -3,7 +3,10 @@
pub(crate) use crate::icons::glyphs::transport_glyph;
use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2};
use serde_json::json;
use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::doc::{
FeatureKind, PlaneRef,
construct::{AxisDef, PointDef},
};
use crate::theme::Tokens;
use crate::{SolveApp, icons};
@@ -13,14 +16,18 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
FeatureKind::Unstitch { .. } => "unstitch",
FeatureKind::ReverseNormal { .. } => "reverse_normal",
FeatureKind::Unstitch { .. } => "surface_unstitch",
FeatureKind::ReverseNormal { .. } => "surface_reverse_normal",
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::SurfaceOffset { .. } => "surface_offset",
FeatureKind::SurfaceRuled { .. } => "ruled",
FeatureKind::SurfaceUntrim { .. } => "untrim",
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
FeatureKind::SurfaceBlend { .. } => "surface_blend",
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
FeatureKind::SurfaceMatch { .. } => "continuity",
FeatureKind::SurfaceFair { .. } => "surface_fair",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",
@@ -30,11 +37,25 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Torus { .. } => "torus",
FeatureKind::Combine { .. } => "combine",
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect",
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Path { .. }, .. } => "pattern_path",
FeatureKind::Pattern { .. } => "pattern_circ",
FeatureKind::Mirror { .. } => "mirror",
FeatureKind::ConstructionPlane { .. } => "plane",
FeatureKind::ConstructionAxis { .. } => "axis",
FeatureKind::ConstructionPoint { .. } => "point",
FeatureKind::ConstructionPlane { plane } => construction_plane_icon(plane),
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::ThroughFace { .. } => "axis_through_cylinder",
AxisDef::NormalToFace { .. } => "axis_normal_to_face",
AxisDef::TwoPlanes { .. } => "axis_two_planes",
AxisDef::TwoPoints { .. } => "axis_two_points",
AxisDef::Edge { .. } => "axis_edge",
},
FeatureKind::ConstructionPoint { def } => match def {
PointDef::At { .. } => "point_vertex",
PointDef::TwoEdges { .. } => "point_two_edges",
PointDef::ThreePlanes { .. } => "point_three_planes",
PointDef::Center { .. } => "point_center",
PointDef::EdgeAndPlane { .. } => "point_edge_plane",
PointDef::AlongPath { .. } => "point_along_path",
},
FeatureKind::Hole { .. } => "hole",
FeatureKind::Loft { .. } => "loft",
FeatureKind::Sweep { .. } => "sweep",
@@ -43,10 +64,43 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::Split { .. } => "split",
FeatureKind::Move { .. } => "move",
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import",
FeatureKind::BodyTransfer { .. } => "move",
FeatureKind::Thread { .. } => "thread",
FeatureKind::SplitFace { .. } => "split_face",
FeatureKind::Scale { .. } => "scale",
FeatureKind::OffsetFace { .. } => "offset_face",
FeatureKind::DeleteFace { .. } => "delete_face",
FeatureKind::Remove { .. } => "delete",
FeatureKind::BoundingSolid { .. } => "bounding_solid",
FeatureKind::Pipe { .. } => "pipe",
FeatureKind::Emboss { .. } => "emboss",
FeatureKind::ReplaceFace { .. } => "replace_face",
FeatureKind::Align { .. } => "align",
FeatureKind::Coil { .. } => "coil",
FeatureKind::SheetPerforate { .. } => "perforate",
FeatureKind::BoundaryFill { .. } => "boundary_fill",
FeatureKind::Patch { .. } => "surface_patch",
FeatureKind::Stitch { .. } => "surface_stitch",
FeatureKind::Thicken { .. } => "surface_thicken",
FeatureKind::SurfaceExtend { .. } => "surface_extend",
FeatureKind::SurfaceTrim { .. } => "surface_trim",
other => crate::workspace::feature_icon(other).unwrap_or("feature"),
}
}
fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
match plane {
PlaneRef::AtAngle { .. } => "plane_angle",
PlaneRef::Midplane { .. } => "plane_midplane",
PlaneRef::TwoEdges { .. } => "plane_two_edges",
PlaneRef::ThreePoints { .. } => "plane_three_points",
PlaneRef::Tangent { .. } => "plane_tangent",
PlaneRef::AlongPath { .. } => "plane_along_path",
PlaneRef::Perpendicular { .. } => "plane_perpendicular",
_ => "plane",
}
}
#[derive(Clone, Debug, PartialEq)]
enum Drag {
Marker,
@@ -439,6 +493,93 @@ mod tests {
use crate::scenario::Harness;
#[test]
fn construction_history_matches_the_tool_that_created_it() {
use solidcraft_engine::doc::{
FeatureKind, PlaneRef,
construct::{AxisDef, GeoRef, PointDef},
};
use solidcraft_engine::geom::Vec3;
let point = || GeoRef::Point { p: Vec3::ZERO };
let reference = || Box::new(point());
let mut cases = Vec::new();
for (id, def) in [
("through_cylinder", AxisDef::ThroughFace { face: point() }),
("normal_to_face", AxisDef::NormalToFace { face: point(), point: point() }),
("two_planes", AxisDef::TwoPlanes { a: point(), b: point() }),
("two_points", AxisDef::TwoPoints { a: point(), b: point() }),
("edge", AxisDef::Edge { edge: point() }),
] {
cases.push((format!("construct.axis.{id}"), FeatureKind::ConstructionAxis { def }));
}
for (id, def) in [
("vertex", PointDef::At { point: point() }),
("two_edges", PointDef::TwoEdges { a: point(), b: point() }),
("three_planes", PointDef::ThreePlanes { a: point(), b: point(), c: point() }),
("center", PointDef::Center { of: point() }),
("edge_plane", PointDef::EdgeAndPlane { edge: point(), plane: point() }),
("along_path", PointDef::AlongPath { path: point(), t: "0.5".into() }),
] {
cases.push((format!("construct.point.{id}"), FeatureKind::ConstructionPoint { def }));
}
for (id, plane) in [
("midplane", PlaneRef::Midplane { a: reference(), b: reference() }),
("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }),
("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }),
("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }),
("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }),
("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }),
] {
cases.push((format!("construct.plane.{id}"), FeatureKind::ConstructionPlane { plane }));
}
for (id, feature) in cases {
let command = solidcraft_engine::find_command(&id).unwrap();
let icon = super::icon_of(&feature);
assert_eq!(icon, command.icon, "{id}: history and toolbar disagree");
assert!(!crate::icon_svg::resolve(icon).placeholder, "{id}");
}
}
#[test]
fn new_surface_and_web_history_icons_match_their_commands() {
let cases = [
("surface.ruled", json!({"type":"surface_ruled", "body":"A", "edges":[], "distance":"5"})),
("surface.untrim", json!({"type":"surface_untrim", "face":{"body":"A", "point":[0,0,0]}})),
("surface.unstitch", json!({"type":"unstitch", "body":"A"})),
("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})),
("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})),
("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})),
("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})),
("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})),
("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})),
("solid.web", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2", "web":true})),
];
for (id, definition) in cases {
let feature: solidcraft_engine::doc::FeatureKind = serde_json::from_value(definition).unwrap();
let command = solidcraft_engine::find_command(id).unwrap();
assert_eq!(super::icon_of(&feature), command.icon, "{id}: history and toolbar disagree");
}
}
#[test]
fn a_path_pattern_keeps_its_path_icon_in_history() {
use solidcraft_engine::doc::{FeatureKind, PatternKind};
let feature = FeatureKind::Pattern {
features: Vec::new(),
bodies: Vec::new(),
pattern: PatternKind::Path {
path_sketch: solidcraft_engine::doc::SketchId(0),
path: Vec::new(),
count: "2".into(),
spacing: "10".into(),
extent: false,
flip: false,
orient: false,
},
};
assert_eq!(super::icon_of(&feature), solidcraft_engine::find_command("solid.pattern.path").unwrap().icon);
}
fn two_boxes(left: bool) -> Harness {
let mut h = Harness::new();
h.app.preferences.timeline_left = left;
+12 -32
View File
@@ -1,5 +1,6 @@
//! A failed feature is marked in the bottom timeline and the Feature History by a shape, not only
//! its colour (#972): the error mark (a disc with a cross) where a warning's mark goes.
//! its colour (#972): the error mark where a warning's mark goes (now an icon image, so these
//! tests look for the 10-point image at the mark's place).
use egui::{Pos2, Rect, Shape, vec2};
use serde_json::json;
@@ -27,20 +28,23 @@ fn features(app: &SolveApp) -> Vec<(String, bool)> {
app.session.doc.features.iter().map(|f| (f.name.clone(), app.session.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect()
}
/// The discs filled in the error colour (either theme's) that one frame of `draw` paints.
/// The centres of the 10 to 12-point icon images one frame of `draw` paints: the failed-feature mark
/// (the sample has no warnings, so every such image is an error mark).
fn error_discs(app: &mut SolveApp, draw: fn(&mut SolveApp, &mut egui::Ui)) -> Vec<Pos2> {
crate::scenario::clear_handles();
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0));
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
let errors = [crate::theme::Tokens::light().error, crate::theme::Tokens::dark().error];
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
// Icons upload textures; dropping their deltas unapplied would panic.
out.textures_delta.clear();
let mut found = Vec::new();
for clipped in &out.shapes {
each(&clipped.shape, &mut |s| {
if let Shape::Circle(c) = s
&& errors.contains(&c.fill)
{
found.push(c.center);
if let Shape::Mesh(m) = s {
let b = m.calc_bounds();
if matches!(m.texture_id, egui::TextureId::Managed(_)) && (b.width() - 11.0).abs() < 1.5 && (b.height() - 11.0).abs() < 1.5 {
found.push(b.center());
}
}
});
}
@@ -81,27 +85,3 @@ fn a_failed_feature_is_marked_in_the_feature_history() {
let got = marked(&mut app, crate::timeline_left::left_timeline, |item, d| (d.y - item.y).abs() < 1.5 && d.x > item.x);
assert_eq!(got, want, "an error mark on each failed feature and on no other");
}
/// The mark is its own drawing: a disc with a cross in the caller's colours, not the fallback
/// square, and a different shape from the warning's triangle.
#[test]
fn the_error_mark_is_a_disc_with_a_cross() {
let ctx = egui::Context::default();
let (ink, fill) = (egui::Color32::from_rgb(1, 2, 3), egui::Color32::from_rgb(4, 5, 6));
let draw = |name: &'static str| {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::icons::paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)), name, ink, fill, fill);
});
let mut shapes = Vec::new();
for c in &out.shapes {
each(&c.shape, &mut |s| shapes.push(s.clone()));
}
shapes
};
let error = draw("error");
let discs = error.iter().filter(|s| matches!(s, Shape::Circle(c) if c.fill == fill && c.stroke.color == ink)).count();
let strokes = error.iter().filter(|s| matches!(s, Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink))).count();
assert_eq!((discs, strokes), (1, 2), "one disc and the two strokes of a cross: {error:?}");
assert_ne!(format!("{error:?}"), format!("{:?}", draw("warning")));
assert_ne!(format!("{error:?}"), format!("{:?}", draw("no such icon")), "drawn, not the fallback square");
}
+22 -113
View File
@@ -10,39 +10,10 @@ use crate::{SolveApp, icons};
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
/// `sketch_accent`, and every other tab keeps the plain `accent`.
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
if panel == "CONSTRAINTS" {
t.glyph_text
} else if tab == "SKETCH" {
t.sketch_accent
} else {
t.accent
}
}
/// The accent stroke colour a command's icon keeps on every surface that draws it, where the
/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the
/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the
/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while
/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint`
/// draws both of these icon names in the accent its caller hands it, never in a colour of its own.
/// The caller is the only place the rule can live. Every other command has no fixed colour. It
/// keeps the accent of whichever surface draws it.
fn fixed_accent(t: Tokens, id: &str) -> Option<Color32> {
match id {
"sketch.construction" | "sketch.centerline" => Some(t.construction_geom),
_ => None,
}
}
/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own
/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that.
/// Every other command takes its `panel_accent`.
fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 {
fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel))
/// Legacy paint arguments and non-SVG affordances follow the same semantic palette as the icon.
/// The requested icon id determines its meaning on ribbons, menus and drag previews alike.
fn command_accent(icon: &str) -> Color32 {
crate::icon_svg::primary(icon, crate::theme::is_dark(), crate::theme::vision())
}
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
@@ -281,8 +252,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
if enabled && resp.hovered() {
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(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2]));
icons::paint_on(ui.painter(), br.shrink(5.0), icon, Some(true), if enabled { 1.0 } else { 0.35 });
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
@@ -351,7 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar);
icons::paint_on(ui.painter(), icon, "bug_report", Some(true), 1.0);
if report_label {
ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text);
}
@@ -364,7 +334,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
if gresp.hovered() {
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
}
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
icons::paint_on(ui.painter(), gear.shrink(4.0), "settings", Some(true), 1.0);
crate::scenario::publish_handle("titlebar:preferences", gear.center());
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
@@ -597,7 +567,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
let workspace_icon = if crate::render_workspace::active(app) { "ws_render" } else { "ws_design" };
icons::paint(
&painter,
Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)),
workspace_icon,
t.icon,
t.icon_fill,
t.accent,
);
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
@@ -719,7 +697,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.rect_filled(br, 4.0, t.hover);
}
if let Some(c) = shown.first().or(cmds.first()) {
let acc = command_accent(t, &tab, panel, c.id);
let acc = command_accent(c.icon);
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
}
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
@@ -769,14 +747,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if resp.hovered() && info.enabled {
painter.rect_filled(br, 4.0, t.hover);
}
let (ink, fill, acc) = if info.enabled {
(t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id))
} else {
(t.border, t.panel_header, t.border)
};
let (ink, fill, acc) =
if info.enabled { (t.icon, t.icon_fill, command_accent(selected.icon)) } else { (t.border, t.panel_header, t.border) };
let icon = match selected.id {
"sketch.finish" => "finish",
"config.table" => "configuration_table",
_ => selected.icon,
};
let icon_rect =
@@ -1105,11 +1079,9 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
p.rect_filled(rect, 3.0, t.hover);
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
}
// Every other row keeps the plain accent it always has. A constraint command's row switches
// to the constraint red, to match its button on the ribbon. A command with a `fixed_accent`
// keeps that colour ahead of either.
// The icon keeps its semantic accent in a drop-down as well as on the ribbon.
let (ink, fill, acc, text) = if info.enabled {
let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent });
let acc = command_accent(c.icon);
(t.icon, t.icon_fill, acc, t.text)
} else {
(t.border, t.panel_header, t.border, t.text_dim)
@@ -1243,7 +1215,7 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) {
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag")));
let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0));
p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside);
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent));
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, command_accent(c.icon));
if removing {
let s = Stroke::new(2.0, t.error);
let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0));
@@ -1788,69 +1760,6 @@ mod panel_drop_tests {
}
}
#[cfg(test)]
mod icon_accent_tests {
use super::*;
/// `icons.rs` pins one half of the construction icon contract. `construction` and
/// `centerline` take their colour from whatever accent their caller hands over. That half
/// stays green when the caller hands over the wrong colour. That is exactly what the toolbar
/// did. This pins the other half: the colour the toolbar actually passes for those two
/// commands, on every surface the toolbar draws them on.
#[test]
fn the_toolbar_passes_construction_orange_for_both_line_type_commands() {
for t in [Tokens::light(), Tokens::dark()] {
// The regression itself: the SKETCH tab's accent is green. These two icons drew in
// it. Construction orange has to be a colour no panel accent can supply.
assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent");
assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent");
assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red");
for id in ["sketch.construction", "sketch.centerline"] {
assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent");
// Its ribbon button, a collapsed panel's single button, and a drop-down row.
assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon");
assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon");
assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row");
}
}
}
/// Those two and no others. Every other command's icon keeps the accent of the panel it sits
/// on, so the constraint red and the sketch green both survive the exception above.
#[test]
fn no_other_command_overrides_the_accent_of_its_own_panel() {
let t = Tokens::light();
let specs = solidcraft_engine::command_specs();
let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect();
fixed.sort_unstable();
assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent");
for c in &specs {
if fixed.contains(&c.id) {
continue;
}
let want = panel_accent(t, c.tab, c.panel);
assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id);
}
// The fix did not flatten every icon to one colour. A sketch tool is still green, a
// constraint still red, a solid tool still blue. A missing id fails this as well.
let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id));
assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent));
assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text));
assert_eq!(accent_of("solid.extrude"), Some(t.accent));
}
/// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is
/// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws
/// from the caller's accent.
#[test]
fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() {
for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] {
let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon));
assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel");
}
}
}
#[cfg(test)]
mod dropdown_toggle_tests {
use crate::scenario::{Harness, rect_of};
+2 -1
View File
@@ -152,8 +152,9 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> {
FeatureKind::SheetConvert { .. } => "convert_sheet",
FeatureKind::Boss { .. } => "boss",
FeatureKind::Lip { .. } => "lip",
FeatureKind::SnapFit { .. } => "snap",
FeatureKind::SnapFit { .. } => "snap_fit",
FeatureKind::Rest { .. } => "rest",
FeatureKind::Rib { web: true, .. } => "web",
FeatureKind::Rib { .. } => "rib",
_ => return None,
})