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");
}
}
}
@@ -1,5 +1,5 @@
[
{"note": "Construction mode like Fusion: with nothing selected, X (or the palette's Construction linetype) makes new curves construction until switched back; with a selection X converts it."},
{"note": "Construction mode like Fusion: with nothing selected, X (or the palette's Construction linetype) makes new curves construction until switched back; with a selection X converts it. The palette's linetype buttons still carry their old glyphs ('--', '- -', '-.-') as an invisible accessible label and hit target. A drawn icon (icons.rs) now paints over each, in the construction orange for construction and centerline. The widgets below are still reached by the glyph."},
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"expect": {"dialog": null}},
@@ -0,0 +1,20 @@
[
{
"note": "The Sketch Palette's Normal/Construction/Centerline selector (not the toolbar's separate CREATE panel icons) now paints a real drawn icon over each of its three old text glyphs ('--', '- -', '-.-'), in the construction orange for construction and centerline. The glyphs stay as the invisible accessible label and hit target, unchanged. The widgets below are still reached by the glyph, exactly as in s35_construction_mode.json. This exercises the Centerline button's mode switch through this widget. No scenario previously did that: with nothing selected, a click sets the drawing mode until it is switched back, the same way s35 exercises Construction's."
},
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"expect": {"dialog": null}},
{"widget": "-·-"},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [30, 20]}},
{"key": "Escape"},
{"expect": {"query": {"command": "sketch.inspect", "path": ["profiles"], "value": []}}},
{"widget": "—"},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 40]}},
{"click": {"sketch": [30, 60]}},
{"key": "Escape"},
{"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 0, "area_mm2"], "value": 600}}}
]