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.
This commit is contained in:
Diogo Soares Rodrigues authored and GitHub committed 2026-10-13 11:45:15 -04:00
1 parent ff609bc320
commit 1a5e961362
4 files changed
+136 -11

No files matched your search

+12 -1
View File
@@ -294,6 +294,8 @@ pub struct Dialog {
pub extra: serde_json::Map<String, Value>,
/// What the inputs were when OK was refused: `error` stays only while they still are.
pub refused: Option<Refused>,
/// 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.
+29
View File
@@ -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;
@@ -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"));
}
+2 -10
View File
@@ -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)),
}
}
}