diff --git a/ROADMAP.md b/ROADMAP.md index 01c0079a..a91229c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,6 +63,9 @@ headline counts in-scope tabs only and lists the deferred ones separately. ## Dimensioning and error reporting +- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool + waits for a distinct point without a degenerate-shape error or history entry. Typed input + and programmatic commands retain their validation. - Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints. Type a value and press Enter to commit one undo step; rejected values remain editable and cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette diff --git a/crates/ui-egui/src/sketch_neutral_click_tests.rs b/crates/ui-egui/src/sketch_neutral_click_tests.rs new file mode 100644 index 00000000..ede9b88d --- /dev/null +++ b/crates/ui-egui/src/sketch_neutral_click_tests.rs @@ -0,0 +1,44 @@ +use crate::scenario::Harness; +use serde_json::json; + +#[test] +fn repeated_first_point_is_neutral_and_drawing_can_continue() { + for (command, rest) in [ + ("sketch.rectangle.two_point", vec![[10, 5, 0]]), + ("sketch.rectangle.center", vec![[10, 5, 0]]), + ("sketch.rectangle.three_point", vec![[10, 0, 0], [10, 5, 0]]), + ("sketch.circle.center", vec![[5, 0, 0]]), + ("sketch.circle.three_point", vec![[10, 0, 0], [5, 5, 0]]), + ("sketch.line", vec![[10, 5, 0]]), + ("sketch.polygon.inscribed", vec![[10, 5, 0]]), + ("sketch.slot.center_to_center", vec![[10, 0, 0], [10, 3, 0]]), + ] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.cam.distance = 50.0; + h.step(&json!({"start":command})).unwrap(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let p = h.call("ui.at", json!({"sketch":[0,0]}))["result"].clone(); + h.call("ui.click", json!({"x":p[0],"y":p[1],"double":true})); + assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status); + assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1, "{command}"); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + for point in rest { + h.step(&json!({"move":{"world":point}})).unwrap(); + if command == "sketch.slot.center_to_center" { + // The live width editor is placed at the cursor; Enter accepts it. + h.step(&json!({"key":"Enter"})).unwrap(); + } else { + h.step(&json!({"click":{"world":point}})).unwrap(); + } + } + assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status); + let state = h.app.session.world_state(); + assert!(!state.sketches[0].sketch.curves.is_empty(), "{command}: {:?}; {:?}", h.app.tool, h.app.status); + assert_eq!(h.app.session.undo.len(), undo + 1, "{command}"); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before, "{command}"); + } +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 012c38ba..6165ef6e 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -12,6 +12,10 @@ use crate::viewport::{Hit, Proj, pick, sketch_point_at}; #[path = "sketch_display_preview_tests.rs"] mod display_preview_tests; +#[cfg(test)] +#[path = "sketch_neutral_click_tests.rs"] +mod neutral_click_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { /// Click points; `n` points complete the shape (0 = chain until finished). @@ -144,12 +148,18 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { return; } match tool.kind { - Kind::Draw(_) => { + Kind::Draw(n) => { if let Some(mut p) = sketch_point_at(app, proj, pos) { // Typed values win over the cursor; lines snap to horizontal/vertical. if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) { p = (crate::sketch_dims::effective(app, &tool, p.0), None); } + // A repeated initial point supplies no extent. Keep waiting rather than + // attempting a degenerate shape; explicit typed values still validate. + if n != 1 && tool.pts.len() == 1 && tool.pts.first().is_some_and(|first| first.0 == p.0) && !tool.dims.iter().any(|b| b.locked) { + app.tool = Some(tool); + return; + } app.tool = Some(tool); place(app, p); return;