Three readable line type icons in the Sketch Palette, with construction orange moved to a caller contract (#747)

The Sketch Palette's line types are three drawn icons (solid, one gap, gap with a dot) instead of text glyphs, and the construction orange is now chosen by the caller (`fixed_accent` on the toolbar) rather than hardcoded in the icons.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 11:53:59 -04:00
1 parent aae70053ee
commit 4a989f6e2d
5 files changed
+308 -33

No files matched your search

+162 -20
View File
@@ -62,6 +62,20 @@ impl Pen<'_> {
}
}
/// The stroke width for the Normal/Construction/Centerline line-type icons. It is heavier than
/// the generic icon weight (`Pen::w`). It is sized from whichever side of `r` is smaller. A
/// short, wide button and a square toolbar slot both get a stroke their gaps can survive next
/// to.
fn line_type_stroke(r: Rect) -> f32 {
(r.width().min(r.height()) / 24.0 * 2.0).max(1.6)
}
/// The Centerline icon's dot radius. It is sized from `r`. The stroke width plays no part in it.
/// It stays a round mark even next to a heavy stroke.
fn line_type_dot_radius(r: Rect) -> f32 {
(r.width().min(r.height()) * 0.16).max(2.4)
}
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
@@ -345,28 +359,49 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.arrow((12.0, 11.0), (19.5, 11.0), a);
pen.line(&[(9.0, 15.0), (15.0, 15.0)], ink);
}
// Construction geometry has one fixed meaning everywhere in the app (the theme's
// construction orange), independent of the tab accent the caller passes in: a plain
// construction curve is an even dashed line, a centerline the ISO long-short rhythm.
"construction" | "centerline" => {
let orange = crate::theme::Tokens::get().construction_geom;
if name == "construction" {
for i in 0..4 {
let t = i as f32 * 4.5;
pen.line(&[(3.0 + t, 19.0 - t), (5.5 + t, 16.5 - t)], orange);
}
} else {
let (fx, fy) = (2.5, 20.5);
let (dx, dy) = (19.0, -18.0);
let seg = |t0: f32, t1: f32| {
pen.line(&[(fx + dx * t0, fy + dy * t0), (fx + dx * t1, fy + dy * t1)], orange);
};
seg(0.03, 0.32);
seg(0.40, 0.48);
seg(0.56, 0.85);
seg(0.93, 1.0);
// The Sketch Palette's line type selector (normal, construction, centerline) paints all
// three on the same diagonal, from (3, 19) to (19, 3). Only the dash pattern changes
// between them. Normal draws in `ink` and ignores `accent`. It is a plain line. It is
// never construction geometry. Its colour must stay fixed even if a caller passes the
// construction orange for it. Construction and centerline draw only in `accent` and
// ignore `ink`. The caller decides the colour. The palette and the toolbar both pass
// the theme's construction orange.
//
// These three are drawn small. The Sketch Palette draws them down to about 18 px
// square. At that size a repeating dash pattern loses its gaps. It reads as a solid
// line. Two patterns that only differ in dash phase read as the same texture too.
// Normal, Construction and Centerline instead get three different silhouettes. One is
// an unbroken line. One is a line broken by a single gap. One is a line broken by a
// single gap with a bold dot sitting in it. A viewer can name the shape on sight. No
// rhythm needs measuring. The stroke width and the dot radius both come from the rect
// actually passed in. That keeps the shapes legible in the palette, the toolbar and the
// command palette, at every size any of them uses.
"normal" => {
let w = line_type_stroke(r);
p.add(Shape::line(vec![pen.at(3.0, 19.0), pen.at(19.0, 3.0)], Stroke::new(w, ink)));
}
"construction" => {
// One gap, no dot. It is an unmistakably broken line. Nothing sits in the break.
let w = line_type_stroke(r);
let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
for (t0, t1) in [(0.0, 0.35), (0.65, 1.0)] {
p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
}
}
"centerline" => {
// The same gap as Construction, with a bold dot centred in it. The silhouette is
// line, dot, line. The dot's radius comes from the rect. It reads as a distinct
// round mark on its own, never as a thicker stretch of the stroke. The dashes stop
// well short of it on both sides. The dot never touches either stroke. It always
// reads as a separate shape.
let w = line_type_stroke(r);
let dot_r = line_type_dot_radius(r);
let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
for (t0, t1) in [(0.0, 0.26), (0.74, 1.0)] {
p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
}
p.circle_filled(at(0.5), dot_r, a);
}
"c_hv" => {
pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
@@ -1030,3 +1065,110 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A debug dump of everything `paint` draws for `name`, for comparing two colour choices
/// without depending on epaint's internal shape fields.
fn drawn(name: &str, ink: Color32, fill: Color32, accent: Color32) -> String {
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0));
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let out = ctx.run_ui(input, |ui| {
let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
paint(ui.painter(), r, name, ink, fill, accent);
});
format!("{:?}", out.shapes)
}
/// The Sketch Palette and the toolbar both rely on this contract. Construction and
/// centerline must track whatever accent colour the caller passes (both callers pass the
/// theme's construction orange) and must not change with `ink`.
#[test]
fn construction_and_centerline_icons_follow_the_callers_accent_only() {
for name in ["construction", "centerline"] {
let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour");
let other_ink = drawn(name, Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3));
assert_eq!(base, other_ink, "{name} icon has no ink-coloured parts: ink alone must not change it");
}
}
/// Normal is a plain line type. It is never construction geometry. Its icon must never pick
/// up the accent colour. It can never render construction orange even if a caller passes it
/// by mistake.
#[test]
fn normal_icon_never_uses_the_accent_colour() {
let one_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
let other_accent = drawn("normal", Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
assert_eq!(one_accent, other_accent, "the Normal icon must not change with the accent colour");
let other_ink = drawn("normal", Color32::from_rgb(9, 9, 9), Color32::WHITE, Color32::from_rgb(1, 2, 3));
assert_ne!(one_accent, other_ink, "the Normal icon must change with ink");
}
/// The dashes (as their first and last point) and the dots (as centre and radius) `paint`
/// actually drew for `name`, at `side` pixels square: the Sketch Palette draws these three
/// icons down to about 18 px.
fn dashes_and_dots(name: &str, side: f32) -> (Vec<(Pos2, Pos2)>, Vec<(Pos2, f32)>) {
let ctx = egui::Context::default();
let screen = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0));
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let r = Rect::from_min_size(Pos2::ZERO, vec2(side, side));
let out = ctx.run_ui(input, |ui| {
paint(ui.painter(), r, name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(232, 140, 48));
});
let dashes = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
Shape::Path(p) => Some((*p.points.first()?, *p.points.last()?)),
_ => None,
})
.collect();
let dots = out.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect();
(dashes, dots)
}
/// Pins the dash count and the gap. A future tidy-up might add dashes back in. It might
/// narrow the gap to look more faithful to the ISO convention. Either change would flatten
/// the icon back into a near-solid line at the size the Sketch Palette actually draws it,
/// about 18 px square. That is the exact defect this fix addressed.
#[test]
fn construction_and_centerline_read_as_two_dashes_with_a_gap_wider_than_the_stroke() {
for (name, want_dots) in [("construction", 0), ("centerline", 1)] {
for side in [18.0, 26.0] {
let (dashes, dots) = dashes_and_dots(name, side);
assert_eq!(dashes.len(), 2, "{name} at {side}px must be exactly two dashes");
assert_eq!(dots.len(), want_dots, "{name} at {side}px has the wrong number of dots");
let (_, gap_start) = dashes[0];
let (gap_end, _) = dashes[1];
let gap = gap_start.distance(gap_end);
let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side)));
assert!(
gap > stroke * 2.0,
"{name} at {side}px: the gap of {gap:.1}px must exceed the {stroke:.1}px stroke by a wide margin, or the dashes read as one solid line"
);
}
}
}
/// Pins the shape of the Centerline dot. It must be large enough to read as a round mark
/// beside the stroke. It must sit clear of both dashes. The silhouette must stay line, dot,
/// line.
#[test]
fn centerline_dot_is_a_bold_mark_clear_of_both_dashes() {
for side in [18.0, 26.0] {
let (dashes, dots) = dashes_and_dots("centerline", side);
let (center, radius) = dots[0];
let stroke = line_type_stroke(Rect::from_min_size(Pos2::ZERO, vec2(side, side)));
assert!(radius > stroke * 1.2, "centerline dot at {side}px: radius {radius:.1}px must exceed the {stroke:.1}px stroke by a wide margin");
for (a, b) in &dashes {
assert!(center.distance(*a) > radius, "centerline dot at {side}px must not touch a dash end");
assert!(center.distance(*b) > radius, "centerline dot at {side}px must not touch a dash end");
}
}
}
}
+29 -5
View File
@@ -400,22 +400,46 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(app.tr("ui.linetype"));
let btn_w = 30.0;
// Taller and wider than the old text-only buttons. Construction and
// Centerline need a round dot and a clear gap to read apart. 30 by
// 20 px left too little square room for either.
let btn_w = 36.0;
let group_w = 3.0 * btn_w + 2.0 * ui.spacing().item_spacing.x;
crate::frame::right_aligned(ui, width, group_w, |ui| {
ui.horizontal(|ui| {
// With a selection: its line type, and a click converts it;
// without: the mode new curves are drawn in.
// without: the mode new curves are drawn in. Each button
// still carries its old glyph as hidden (transparent) text.
// That keeps its hit target, size and accessible name
// exactly as before. A drawn icon (icons.rs) paints over it.
// Construction and Centerline always draw in the theme's
// construction orange. Normal's icon never changes with the
// accent colour. It stays neutral in every case.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
for (i, (label, icon, tip)) in
[("—", "normal", "Normal"), ("- -", "construction", "Construction"), ("-·-", "centerline", "Centerline")]
.iter()
.enumerate()
{
let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 };
let tr_tip = app.tr_label(tip);
let tip = if has_curves {
let hover = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(btn_w, 20.0))).on_hover_text(tip).clicked() {
let btn = egui::Button::new(RichText::new(*label).color(Color32::TRANSPARENT))
.selected(on)
.min_size(vec2(btn_w, 28.0));
let resp = ui.add(btn).on_hover_text(hover);
let accent = if *icon == "normal" { t.icon } else { t.construction_geom };
// A square icon area. The button's own rect is
// text-shaped, wider than tall. A flattened diagonal
// leaves less room for a dash pattern's gaps to read.
let side = (resp.rect.width().min(resp.rect.height()) - 3.0).max(12.0);
let icon_rect = egui::Rect::from_center_size(resp.rect.center(), vec2(side, side));
crate::icons::paint(ui.painter(), icon_rect, icon, t.icon, t.icon_fill, accent);
if resp.clicked() {
linetype = Some(i as u8);
}
}
+96 -7
View File
@@ -21,6 +21,28 @@ fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
}
}
/// 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))
}
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
pub fn panels(tab: &str) -> &'static [&'static str] {
if let Some(p) = crate::workspace::panels(tab) {
@@ -605,7 +627,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 = panel_accent(t, &tab, panel);
let acc = command_accent(t, &tab, panel, c.id);
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);
@@ -655,8 +677,11 @@ 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, panel_accent(t, &tab, panel)) } else { (t.border, t.panel_header, t.border) };
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 icon = match selected.id {
"sketch.finish" => "finish",
"config.table" => "configuration_table",
@@ -941,10 +966,11 @@ 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; only a constraint command's row
// switches to the constraint red, to match its button on the ribbon.
// 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.
let (ink, fill, acc, text) = if info.enabled {
let acc = if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent };
let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent });
(t.icon, t.icon_fill, acc, t.text)
} else {
(t.border, t.panel_header, t.border, t.text_dim)
@@ -1078,7 +1104,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, t.accent);
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent));
if removing {
let s = Stroke::new(2.0, t.error);
let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0));
@@ -1523,3 +1549,66 @@ mod panel_drop_tests {
h.step(&json!({"expect":{"handle_order":["button:solid.scale","button:solid.extrude"]}})).unwrap();
}
}
#[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");
}
}
}