From 1a5e9613628d3fb04aa22f9bbbaba8807462ad5d Mon Sep 17 00:00:00 2001 From: Diogo Soares Rodrigues Date: Tue, 13 Oct 2026 12:45:15 -0300 Subject: [PATCH] feat(ui): sketch tool shortcuts open a sketch on the selected plane (#737) Outside a sketch, a sketch tool's shortcut opens Create Sketch on the selected plane or planar face (or asks for one), then starts that tool in the new sketch. --- crates/ui-egui/src/dialogs.rs | 13 ++- crates/ui-egui/src/keymap.rs | 29 +++++++ crates/ui-egui/src/sketch_shortcut_tests.rs | 93 +++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 12 +-- 4 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/sketch_shortcut_tests.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 496b0f49..009dc830 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -294,6 +294,8 @@ pub struct Dialog { pub extra: serde_json::Map, /// What the inputs were when OK was refused: `error` stays only while they still are. pub refused: Option, + /// Create Sketch opened by a sketch tool's shortcut: that tool starts once the sketch is open. + pub then_start: Option<&'static str>, } /// The commands a dialog's inputs make (or why they make none), its picks, and the design's @@ -350,6 +352,7 @@ impl Dialog { collapsed: false, extra: serde_json::Map::new(), refused: None, + then_start: None, } } @@ -2216,12 +2219,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { cancel = true; } // A plane picked for a new sketch starts it right away. + let mut then_start = None; if matches!(d.kind, Kind::Sketch) && d.error.is_none() && let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned() { match start_sketch(app, &sel) { - Ok(()) => cancel = true, + Ok(()) => { + cancel = true; + then_start = d.then_start; + } Err(error) => d.refuse(app, error), } } @@ -2247,6 +2254,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // Editing done: put the timeline marker back where it was. let _ = roll_marker(app, marker); } + // The shortcut that asked for this sketch starts its tool in it. + if let Some(id) = then_start { + app.start(id); + } } /// Close the dialog without applying it (Esc, Cancel). An edit puts the timeline marker back. diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 69f0f10e..8e5f043f 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -77,6 +77,31 @@ pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> { commands_for(app, key).into_iter().find(|id| solidcraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled)) } +/// Run a shortcut key: its enabled command; outside a sketch, a sketch tool's key opens Create +/// Sketch (on the selected plane or planar face, else on the plane picked next) and the tool +/// starts in the new sketch. +pub fn press(app: &mut SolveApp, key: &str) { + if let Some(id) = command_for(app, key) { + app.start(id); + } else if let Some(id) = sketch_tool_for(app, key) { + app.start("sketch.create"); + if let Some(d) = app.dialog.as_mut().filter(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)) { + d.then_start = Some(id); + } + } +} + +/// Outside a sketch: the SKETCH-tab command bound to `key` that a new sketch would start. Not +/// Normal/Construction (a mode of the drawing in progress) nor the Finish Sketch panel. +fn sketch_tool_for(app: &SolveApp, key: &str) -> Option<&'static str> { + if app.session.active_sketch.is_some() { + return None; + } + commands_for(app, key).into_iter().find(|id| { + solidcraft_engine::find_command(id).is_some_and(|c| c.tab == "SKETCH" && c.panel != "FINISH SKETCH" && c.id != "sketch.construction") + }) +} + /// Bind `key` to a command (`""` clears it). Refused for reserved keys, and when other commands /// use the key unless `replace` (they lose it). pub fn bind(app: &mut SolveApp, id: &str, key: &str, replace: bool) -> Result<(), String> { @@ -298,3 +323,7 @@ mod tests { assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K"); } } + +#[cfg(test)] +#[path = "sketch_shortcut_tests.rs"] +mod sketch_shortcut_tests; diff --git a/crates/ui-egui/src/sketch_shortcut_tests.rs b/crates/ui-egui/src/sketch_shortcut_tests.rs new file mode 100644 index 00000000..5bd8403a --- /dev/null +++ b/crates/ui-egui/src/sketch_shortcut_tests.rs @@ -0,0 +1,93 @@ +use crate::scenario::Harness; +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::doc::{FeatureKind, PlaneRef}; + +fn tool(h: &Harness) -> Option<&str> { + h.app.tool.as_ref().map(|t| t.cmd) +} + +fn normal(h: &Harness) -> solidcraft_engine::geom::Vec3 { + let active = h.app.session.active_sketch; + h.app.session.world_state().sketches.iter().find(|s| Some(s.feature) == active).map(|s| s.plane.normal()).unwrap_or_default() +} + +fn press(h: &mut Harness, key: &str) { + h.step(&json!({"key": key})).unwrap(); + h.settle(); +} + +#[test] +fn sketch_keys_outside_a_sketch_sketch_on_the_selected_plane_and_start_their_tool() { + for (key, cmd) in + [("C", "sketch.circle.center"), ("L", "sketch.line"), ("R", "sketch.rectangle.two_point"), ("D", "sketch.dimension"), ("P", "sketch.project")] + { + let mut h = Harness::new(); + h.app.session.selection = vec![Sel::Plane { name: "XZ".into() }]; + press(&mut h, key); + assert!(h.app.session.active_sketch.is_some(), "{key}: no sketch opened"); + assert_eq!(h.app.session.world_state().sketches.len(), 1, "{key}"); + assert!(normal(&h).y.abs() > 0.999, "{key}: not on XZ: {:?}", normal(&h)); + assert_eq!(tool(&h), Some(cmd), "{key}"); + assert!(h.app.dialog.is_none(), "{key}: dialog left open"); + } +} + +#[test] +fn a_sketch_key_sketches_on_the_selected_planar_face() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.ui.pick_bodies = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"click":{"world":[3,4,10]}})).unwrap(); + assert!(matches!(h.app.session.selection.as_slice(), [Sel::Face { body, .. }] if body == "BaseBox"), "{:?}", h.app.session.selection); + press(&mut h, "C"); + let sketch = h.app.session.active_sketch.expect("sketch opened on the face"); + let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else { + panic!("the sketch must sit on the picked face"); + }; + assert_eq!(anchor.target.body, "BaseBox"); + assert_eq!(tool(&h), Some("sketch.circle.center")); +} + +#[test] +fn a_sketch_key_with_nothing_selected_asks_for_the_plane_then_starts_the_tool() { + let mut h = Harness::new(); + press(&mut h, "C"); + assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(crate::dialogs::Kind::Sketch))); + assert!(h.app.session.active_sketch.is_none()); + assert!(tool(&h).is_none()); + h.step(&json!({"click":{"plane":"XY"}})).unwrap(); + h.settle(); + assert!(normal(&h).z.abs() > 0.999, "{:?}", normal(&h)); + assert_eq!(tool(&h), Some("sketch.circle.center")); + assert!(h.app.dialog.is_none()); +} + +#[test] +fn escape_drops_the_tool_waiting_for_a_sketch() { + let mut h = Harness::new(); + press(&mut h, "C"); + press(&mut h, "Escape"); + assert!(h.app.dialog.is_none()); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"plane":"XY"}})).unwrap(); + h.settle(); + assert!(h.app.session.active_sketch.is_some()); + assert!(tool(&h).is_none(), "a cancelled tool came back: {:?}", tool(&h)); +} + +#[test] +fn construction_key_outside_a_sketch_opens_nothing_and_keys_in_a_sketch_keep_it() { + let mut h = Harness::new(); + press(&mut h, "X"); + assert!(h.app.dialog.is_none()); + assert!(h.app.session.active_sketch.is_none()); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + press(&mut h, "C"); + assert_eq!(h.app.session.world_state().sketches.len(), 1); + assert_eq!(tool(&h), Some("sketch.circle.center")); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0f8a75b9..20682897 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1173,11 +1173,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { Key::Z => drop(app.run("edit.undo", json!({}))), Key::Y => drop(app.run("edit.redo", json!({}))), Key::S => save(app), - _ => { - if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) { - app.start(id); - } - } + _ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)), } continue; } @@ -1214,11 +1210,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } Key::F6 => app.animate_view("fit"), Key::Delete | Key::Backspace => crate::viewport::delete_selection(app), - _ => { - if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) { - app.start(id); - } - } + _ => crate::keymap::press(app, &crate::keymap::key_name(k, mods)), } } }