Files
OneCAD/crates/ui-egui/src/icon_svg.rs
T

895 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()));
}
}