diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 54a94f05..a884ee0a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1179,7 +1179,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; } - if esc { + if esc && !app.esc_handled { + app.esc_handled = true; cancel = true; } // A plane picked for a new sketch starts it right away. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0e7172b..16c6e4ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,6 +134,8 @@ pub struct SolveApp { pub preview: preview::PreviewState, /// The last command started interactively (id, label), for Repeat. pub last_command: Option<(String, String)>, + /// Esc was already acted on this frame (by the shortcuts), so later handlers skip it. + pub esc_handled: bool, pub status: Option<(String, f64, bool)>, pub quit_requested: bool, /// The application bar is the window's title bar (Windows, Linux: no OS decorations). @@ -170,6 +172,7 @@ impl SolveApp { tree: browser::TreeState::default(), preview: preview::PreviewState::default(), last_command: None, + esc_handled: false, status: None, quit_requested: false, custom_titlebar: false, @@ -508,6 +511,7 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + self.esc_handled = false; if !self.styled || theme::is_dark() != self.ui.dark { theme::set_dark(self.ui.dark); theme::apply(ctx); diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 9c7e6f49..1f70040d 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -70,7 +70,7 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { ("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => { let v = h - a; let len = lk("Length").unwrap_or(v.len()); - let ang = lk("Angle").unwrap_or_else(|| v.angle()); + let ang = lk("Angle").unwrap_or_else(|| snap_axis(v.angle())); a + Vec2::from_angle(ang) * len } ("ShapeRectangleTwoPoint", 1) => { @@ -105,23 +105,56 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { } } +/// Angles within a few degrees of horizontal or vertical snap to it (the line is drawn +/// horizontal or vertical, and the command infers the constraint). +pub fn snap_axis(ang: f64) -> f64 { + let q = std::f64::consts::FRAC_PI_2; + let k = (ang / q).round(); + if (ang - k * q).abs() < 2.5f64.to_radians() { k * q } else { ang } +} + +/// Is the segment exactly horizontal or vertical (an inferred constraint)? +pub fn axis_aligned(a: Vec2, b: Vec2) -> Option { + let d = b - a; + if d.len() < 1e-9 { + return None; + } + if d.y.abs() < 1e-9 * d.len() { + Some(true) + } else if d.x.abs() < 1e-9 * d.len() { + Some(false) + } else { + None + } +} + /// The value each box shows while it follows the cursor, and where it sits (sketch coordinates). -fn live(t: &Tool, e: Vec2) -> Vec<(f64, Vec2)> { +/// `px` is the size of a screen pixel in sketch units: boxes sit a little off the segment they +/// measure so they don't cover it. +fn live(t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let Some(a) = t.pts.last().map(|p| p.0) else { return Vec::new() }; let first = t.pts.first().map(|p| p.0).unwrap_or(a); + let off = 20.0 * px; match (t.cmd, t.pts.len()) { ("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => { let v = e - a; - vec![(v.len(), (a + e) * 0.5), (v.angle(), a + dir_or_x(v) * (v.len() * 0.2).min(12.0))] + let d = dir_or_x(v); + let n = d.perp(); + vec![(v.len(), (a + e) * 0.5 + n * off), (v.angle(), a + d * (v.len() * 0.3).min(60.0 * px) - n * off)] } ("ShapeRectangleTwoPoint", 1) => { - vec![((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y)), ((e.y - a.y).abs(), Vec2::new(e.x, (a.y + e.y) * 0.5))] + let (sx, sy) = (sign(e.x - a.x), sign(e.y - a.y)); + vec![ + ((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y - sy * off)), + ((e.y - a.y).abs(), Vec2::new(e.x + sx * 2.0 * off, (a.y + e.y) * 0.5)), + ] } ("ShapeRectangleCenter", 1) => { let d = e - a; - vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x, a.y))] + let (sx, sy) = (sign(d.x), sign(d.y)); + vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y - sy * off)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x + sx * 2.0 * off, a.y))] } - ("CircleCenterRadius", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5)], + ("CircleCenterRadius", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], ("ArcCenterTwoPoint", 1) | ("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => { vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)] } @@ -183,7 +216,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, proj: &Proj) { // Esc in a box ends the shape (the box had the keyboard, so the shortcut didn't see it). let in_box = app.tool.as_ref().is_some_and(|t| (0..t.dims.len()).any(|i| ui.ctx().memory(|m| m.had_focus_last_frame(egui::Id::new(("sc_dim", i)))))); - if in_box && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if in_box && !app.esc_handled && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.esc_handled = true; crate::tools::finish(app); return; } @@ -203,7 +237,8 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let plane = st.sketch(sid)?.plane; let h = t.hover.as_ref().map(|x| x.0)?; let e = effective(app, t, h); - let lv = live(t, e); + let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0)); + let lv = live(t, e, px); let focus = std::mem::take(&mut t.dims_focus); let mut any_focused = false; for (i, b) in t.dims.iter_mut().enumerate() { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4a56cae9..c89997ec 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -406,6 +406,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } match k { Key::Escape => { + app.esc_handled = true; if app.tool.is_some() { crate::tools::finish(app); } else if app.dialog.is_some() { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 88f3ed57..92324380 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -111,8 +111,8 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { match tool.kind { Kind::Draw(_) => { if let Some(mut p) = sketch_point_at(app, proj, pos) { - // Typed values win over the cursor. - if tool.dims.iter().any(|b| b.locked) { + // Typed values win over the cursor; lines snap to horizontal/vertical. + if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "DrawPolyline" && p.1.is_none()) { p = (crate::sketch_dims::effective(app, &tool, p.0), None); } app.tool = Some(tool); @@ -304,7 +304,22 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { painter.add(Shape::line(v, stroke)); }; match (t.cmd, pts.as_slice()) { - ("DrawPolyline", [.., last]) => line(*last, h), + ("DrawPolyline", [.., last]) => { + line(*last, h); + // Horizontal/vertical inference marker. + if let Some(horizontal) = crate::sketch_dims::axis_aligned(*last, h) + && let Some(m) = to((*last + h) * 0.5) + { + let g = if horizontal { egui::vec2(0.0, -12.0) } else { egui::vec2(12.0, 0.0) }; + painter.text( + m + g, + egui::Align2::CENTER_CENTER, + if horizontal { "H" } else { "V" }, + egui::FontId::proportional(10.0), + tk.sketch_accent, + ); + } + } ("ShapeRectangleTwoPoint", [a]) => { let (b, c) = (Vec2::new(h.x, a.y), Vec2::new(a.x, h.y)); line(*a, b);