A Feature Options section in the Sketch Palette showing the running tool's modes (#750)

Adds a Feature Options section to the Sketch Palette with the running tool's modes as icon buttons, so you can switch between e.g. circle or rectangle variants mid-tool.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 11:28:37 -04:00
1 parent 2bb24d1d2a
commit 4112189551
5 files changed
+212 -7

No files matched your search

+1
View File
@@ -101,6 +101,7 @@
"ui.extend": "Verlängern",
"ui.extent_type": "Ausdehnungstyp",
"ui.extrude": "Extrudieren",
"ui.feature_options": "Funktionsoptionen",
"ui.features": "Funktionen",
"ui.file": "Datei",
"ui.fillet": "Vollrundung",
+1
View File
@@ -101,6 +101,7 @@
"ui.extend": "Extend",
"ui.extent_type": "Extent Type",
"ui.extrude": "Extrude",
"ui.feature_options": "Feature Options",
"ui.features": "Features",
"ui.file": "File",
"ui.fillet": "Fillet",
+1
View File
@@ -101,6 +101,7 @@
"ui.extend": "延長",
"ui.extent_type": "範囲タイプ",
"ui.extrude": "押し出し",
"ui.feature_options": "フィーチャーオプション",
"ui.features": "フィーチャー",
"ui.file": "ファイル",
"ui.fillet": "フィレット",
+1
View File
@@ -101,6 +101,7 @@
"ui.extend": "연장",
"ui.extent_type": "범위 유형",
"ui.extrude": "돌출",
"ui.feature_options": "피처 옵션",
"ui.features": "피처",
"ui.file": "파일",
"ui.fillet": "필렛",
+208 -7
View File
@@ -1,12 +1,13 @@
//! The Sketch Palette: a panel docked at the right while a sketch is being edited, like a
//! command dialog. Options: the line type of the selected curves (normal, construction,
//! centerline), Look At, and the display toggles kept with the sketch (grid, snap, slice,
//! profiles, points, dimensions, constraints, projected geometry, 3D sketch); the sketch's
//! degrees of freedom; Finish Sketch. Toggles run `sketch.options`, line types
//! `sketch.construction` / `sketch.centerline`.
//! command dialog. Feature Options: the modes of the creation tool that is running, as icon
//! buttons. Options: the line type of the selected curves (normal, construction, centerline),
//! Look At, and the display toggles kept with the sketch (grid, snap, slice, profiles, points,
//! dimensions, constraints, projected geometry, 3D sketch); the sketch's degrees of freedom;
//! Finish Sketch. Toggles run `sketch.options`, line types `sketch.construction` /
//! `sketch.centerline`.
//!
//! The line type is also a drawing mode, as in Fusion: with nothing selected, Construction (or
//! X) makes what is drawn next construction geometry until it is switched back.
//! The line type is also a drawing mode: with nothing selected, Construction (or X) makes what
//! is drawn next construction geometry until it is switched back.
use std::cell::Cell;
@@ -23,6 +24,7 @@ thread_local! {
static MODE: Cell<(u8, Option<u64>)> = const { Cell::new((0, None)) };
static FOLDED: Cell<bool> = const { Cell::new(false) };
static OPTIONS_FOLDED: Cell<bool> = const { Cell::new(false) };
static FEATURE_FOLDED: Cell<bool> = const { Cell::new(false) };
}
/// Palette width (logical px).
@@ -227,6 +229,68 @@ fn disclosure_triangle(p: &egui::Painter, c: egui::Pos2, open: bool, col: Color3
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
}
/// The creation family of the tool that is running, and the exact variant it is running, when
/// that family has more than one mode to switch between.
///
/// Family membership comes from the declared table in `sketch_families`, never from the shape of
/// the id. `sketch.line` and `sketch.line.midpoint` are one family although only one of them
/// carries a variant suffix, and `sketch.ellipse`, `sketch.conic` and `sketch.text` carry no
/// family at all. The table also fixes the order the modes are shown in, which a derived rule
/// cannot express.
///
/// A tool outside a family has nothing to switch to, so the section stays hidden rather than
/// showing a single button that does nothing. The palette's vertical space is already budgeted
/// down to the footer, so an inert row would cost the Options list real height.
fn active_feature(app: &SolveApp) -> Option<(&'static crate::sketch_families::Family, &'static str)> {
let id = app.tool.as_ref()?.cmd;
let family = crate::sketch_families::family(id)?;
(family.commands.len() > 1).then_some((family, id))
}
/// One row of the Feature Options section: the family's name, then its modes as icon buttons
/// right-aligned in the same column every other palette row ends at. Five buttons do not fit one
/// palette-wide row, so the group wraps onto as many rows as it needs.
fn feature_row(app: &SolveApp, ui: &mut egui::Ui, width: f32, family: &crate::sketch_families::Family, current: &str) -> Option<&'static str> {
let t = Tokens::get();
let mut start = None;
ui.horizontal(|ui| {
ui.label(app.tr_label(family.label));
let btn_w = 28.0;
let btn_h = 22.0;
let sp = ui.spacing().item_spacing.x;
let room = (width - ui.min_rect().width() - sp).max(btn_w);
let fits = ((room + sp) / (btn_w + sp)).floor().clamp(1.0, family.commands.len() as f32) as usize;
let per_row = fits.max(1);
let group_w = per_row as f32 * btn_w + per_row.saturating_sub(1) as f32 * sp;
crate::frame::right_aligned(ui, width, group_w, |ui| {
ui.vertical(|ui| {
for chunk in family.commands.chunks(per_row) {
ui.horizontal(|ui| {
for spec in chunk.iter().filter_map(|id| solidcraft_engine::find_command(id)) {
let on = spec.id == current;
let enabled = spec.info(&app.session).enabled;
let resp = ui.add_enabled(enabled, egui::Button::new("").selected(on).min_size(vec2(btn_w, btn_h)));
let side = (resp.rect.width().min(resp.rect.height()) - 4.0).max(12.0);
let icon_rect = egui::Rect::from_center_size(resp.rect.center(), vec2(side, side));
let (ink, fill, acc) = if enabled { (t.icon, t.icon_fill, t.accent) } else { (t.border, t.panel_header, t.border) };
crate::icons::paint(ui.painter(), icon_rect, spec.icon, ink, fill, acc);
crate::scenario::publish_handle(&format!("sketch_palette:feature:{}", spec.id), resp.rect.center());
// The button draws an icon and no text of its own, so give a screen
// reader the command's own name and whether it is the mode in use.
let label = app.tr_label(spec.label).to_string();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, on, &label));
if resp.on_hover_text(app.tr_label(spec.label)).clicked() && enabled {
start = Some(spec.id);
}
}
});
}
});
});
});
start
}
/// Draw the palette (while sketching and no command dialog is open).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
app.viewport.palette_rect = None;
@@ -266,6 +330,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut linetype: Option<u8> = None;
let mut look = false;
let mut finish = false;
let mut start: Option<&'static str> = None;
let feature = active_feature(app);
let has_curves = !selected_curves(app).is_empty();
let selected_types = selected_linetypes(app);
let margin_bottom = f32::from(frame.inner_margin.bottom);
@@ -293,6 +359,25 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if FOLDED.with(Cell::get) {
return;
}
// Feature Options sits above Options: the modes of the creation tool that is running.
if let Some((family, current)) = feature {
let ffold = FEATURE_FOLDED.with(Cell::get);
crate::frame::heading(ui, Level::Section, width, |ui| {
let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click());
disclosure_triangle(ui.painter(), tri_rect.center(), !ffold, t.text);
crate::scenario::publish_handle("sketch_palette:feature_fold", tri_rect.center());
let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.feature_options")).size(12.0)).sense(egui::Sense::click()));
if tri_resp.clicked() || label_resp.clicked() {
FEATURE_FOLDED.with(|f| f.set(!ffold));
}
});
if !ffold {
let section = ui.vertical(|ui| feature_row(app, ui, width, family, current));
start = section.inner;
crate::scenario::publish_rect("sketch_palette:feature_options", section.response.rect);
}
ui.add_space(2.0);
}
let ofold = OPTIONS_FOLDED.with(Cell::get);
crate::frame::heading(ui, Level::Section, width, |ui| {
let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click());
@@ -416,6 +501,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
set_mode(app, k);
}
}
if let Some(id) = start {
// The same path a toolbar click takes. It drops the half-drawn shape and starts the
// chosen mode from its first point, so a partly placed 3-point rectangle does not
// carry its stale points into a 2-point one.
app.start(id);
}
if look {
crate::dialogs::look_at_sketch(app);
}
@@ -590,6 +681,116 @@ mod tests {
assert!(slice_on(&mut h), "clicking the \"Slice\" label must toggle it on");
}
/// The modes the Feature Options row draws pressed, in family order.
fn pressed_modes(h: &crate::scenario::Harness, family: &crate::sketch_families::Family) -> Vec<&'static str> {
let tree = h.output.accesskit_update.as_ref().unwrap();
family
.commands
.iter()
.copied()
.filter(|id| {
let label = solidcraft_engine::find_command(id).unwrap().label;
tree.nodes.iter().any(|(_, n)| n.label() == Some(label) && n.toggled() == Some(egui::accesskit::Toggled::True))
})
.collect()
}
#[test]
fn feature_options_show_every_mode_of_the_running_tool_and_switch_between_them() {
FOLDED.with(|v| v.set(false));
FEATURE_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.start("sketch.circle.center");
h.settle();
let circle = crate::sketch_families::family("sketch.circle.center").unwrap();
assert_eq!(circle.commands.len(), 5, "the Circle family is the five circle modes");
for id in circle.commands {
assert!(crate::scenario::handle_at(&format!("sketch_palette:feature:{id}")).is_some(), "{id} needs a mode button");
}
assert!(h.widget("Circle").is_some(), "the row names the family");
assert_eq!(pressed_modes(&h, circle), ["sketch.circle.center"]);
let at = crate::scenario::handle_at("sketch_palette:feature:sketch.circle.three_tangent").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.circle.three_tangent"));
assert_eq!(pressed_modes(&h, circle), ["sketch.circle.three_tangent"]);
// Another family replaces the row with its own modes.
h.app.start("sketch.rectangle.two_point");
h.settle();
let rect = crate::sketch_families::family("sketch.rectangle.two_point").unwrap();
assert_eq!(rect.commands.len(), 3);
assert_eq!(pressed_modes(&h, rect), ["sketch.rectangle.two_point"]);
assert!(crate::scenario::handle_at("sketch_palette:feature:sketch.circle.center").is_none(), "only the running family is shown");
}
#[test]
fn switching_mode_mid_draw_restarts_the_tool_and_leaves_the_design_alone() {
FOLDED.with(|v| v.set(false));
FEATURE_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.start("sketch.rectangle.three_point");
h.step(&json!({"click":{"sketch":[0,0]}})).unwrap();
h.settle();
assert_eq!(h.app.tool.as_ref().map(|t| t.pts.len()), Some(1), "one corner is placed");
let baseline = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let at = crate::scenario::handle_at("sketch_palette:feature:sketch.rectangle.two_point").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
let tool = h.app.tool.as_ref().unwrap();
assert_eq!(tool.cmd, "sketch.rectangle.two_point");
assert!(tool.pts.is_empty(), "the half-drawn rectangle is dropped, not carried into the new mode");
assert_eq!(h.app.session.doc.to_json(), baseline, "switching modes never changes the design");
assert_eq!(h.app.session.undo.len(), undo, "switching modes adds no undo step");
}
#[test]
fn feature_options_stay_hidden_without_a_tool_that_has_other_modes() {
FOLDED.with(|v| v.set(false));
FEATURE_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
assert!(crate::scenario::rect_of("sketch_palette:feature_options").is_none(), "no creation tool, no section");
// Ellipse has no siblings to switch to, so a section would hold one inert button.
h.app.start("sketch.ellipse");
h.settle();
assert!(crate::sketch_families::family("sketch.ellipse").is_none());
assert!(crate::scenario::rect_of("sketch_palette:feature_options").is_none(), "a tool outside a family shows no section");
h.app.start("sketch.arc.three_point");
h.settle();
assert!(crate::scenario::rect_of("sketch_palette:feature_options").is_some(), "a family with siblings shows the section");
// Finishing the tool takes the section away again.
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
assert!(h.app.tool.is_none());
assert!(crate::scenario::rect_of("sketch_palette:feature_options").is_none());
}
#[test]
fn feature_options_disclosure_triangle_folds_and_unfolds_the_section() {
FOLDED.with(|v| v.set(false));
FEATURE_FOLDED.with(|v| v.set(false));
let mut h = crate::scenario::Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.start("sketch.slot.center_to_center");
h.settle();
let button = "sketch_palette:feature:sketch.slot.arc_center";
assert!(crate::scenario::handle_at(button).is_some());
let at = crate::scenario::handle_at("sketch_palette:feature_fold").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(crate::scenario::handle_at(button).is_none(), "the triangle must fold the section");
assert!(h.widget("Sketch Grid").is_some(), "folding Feature Options leaves Options open");
let at = crate::scenario::handle_at("sketch_palette:feature_fold").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(crate::scenario::handle_at(button).is_some(), "the triangle must unfold it again");
FEATURE_FOLDED.with(|v| v.set(false));
}
#[test]
fn dof_readout() {
assert_eq!(dof_text(0, true), "Fully constrained");