//! Inline dimensions while drawing in a sketch. Once a shape's first point is down, value //! boxes (line length and angle, rectangle width and height, circle diameter…) sit next to the //! segment they measure and follow the cursor. Typing goes straight into the first box and //! locks that value; Tab moves to the next box; Enter places the shape with the typed values, //! which become dimension constraints. Values are expressions ("25", "1 in", "d1 * 2"). use egui::{Pos2, vec2}; use serde_json::{Value, json}; use solvecraft_engine::doc::expr::Kind as ValueKind; use solvecraft_engine::geom::Vec2; use crate::SolveApp; use crate::theme::Tokens; use crate::tools::Tool; use crate::viewport::Proj; /// One value box. #[derive(Clone, Debug)] pub struct DimBox { pub label: &'static str, pub kind: ValueKind, /// The expression shown (the live value while not locked). pub value: String, /// Typed by the user: the value no longer follows the cursor. pub locked: bool, } impl DimBox { fn new(label: &'static str, kind: ValueKind) -> DimBox { DimBox { label, kind, value: String::new(), locked: false } } } /// The boxes a tool shows after `n` points. pub fn boxes_for(cmd: &str, n: usize) -> Vec { use ValueKind::*; let b = DimBox::new; match (cmd, n) { ("sketch.line", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)], ("sketch.rectangle.two_point" | "sketch.rectangle.center", 1) => vec![b("Width", Length), b("Height", Length)], ("sketch.circle.center", 1) => vec![b("Diameter", Length)], ("sketch.arc.center_point", 1) => vec![b("Radius", Length)], ("sketch.arc.center_point", 2) => vec![b("Sweep", Angle)], ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)], ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => vec![b("Length", Length)], ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => vec![b("Width", Length)], _ => Vec::new(), } } /// A locked box's value (mm or radians). fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option { let b = t.dims.iter().chain(&t.dim_history).find(|b| b.label == label && b.locked)?; app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite()) } fn dir_or_x(v: Vec2) -> Vec2 { v.normalized().unwrap_or(Vec2::X) } fn sign(x: f64) -> f64 { if x < 0.0 { -1.0 } else { 1.0 } } /// The next point the tool would place for the cursor at `h`, honouring locked values. pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { let Some(a) = t.pts.last().map(|p| p.0) else { return h }; let lk = |l: &str| locked(app, t, l); match (t.cmd, t.pts.len()) { ("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => { let v = h - a; let len = lk("Length").unwrap_or(v.len()); let ang = lk("Angle").unwrap_or_else(|| snap_axis(v.angle())); a + Vec2::from_angle(ang) * len } ("sketch.rectangle.two_point", 1) => { let d = h - a; Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * sign(d.y))) } ("sketch.rectangle.center", 1) => { let d = h - a; Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * 0.5 * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * 0.5 * sign(d.y))) } ("sketch.circle.center", 1) => a + dir_or_x(h - a) * lk("Diameter").map_or((h - a).len(), |d| d * 0.5), ("sketch.arc.center_point", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), ("sketch.arc.center_point", 2) => { let c = t.pts.first().map(|p| p.0).unwrap_or(a); let r = (a - c).len(); let a0 = (a - c).angle(); let sweep = lk("Sweep").unwrap_or_else(|| ((h - c).angle() - a0).rem_euclid(std::f64::consts::TAU)); c + Vec2::from_angle(a0 + sweep) * r } ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => match lk("Width") { Some(w) => { let c0 = t.pts.first().map(|p| p.0).unwrap_or(a); let dir = dir_or_x(a - c0); let n = dir.perp(); let side = sign(n.dot(h - c0)); c0 + (a - c0) * 0.5 + n * (w * 0.5 * side) } None => h, }, _ => h, } } /// 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). /// `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()) { ("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => { let v = e - a; 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)] } ("sketch.rectangle.two_point", 1) => { 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)), ] } ("sketch.rectangle.center", 1) => { let d = e - a; 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))] } ("sketch.circle.center", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], ("sketch.arc.center_point", 1) | ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => { vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)] } ("sketch.arc.center_point", 2) => { let a0 = (a - first).angle(); vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)] } ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => { let dir = dir_or_x(a - first); vec![(2.0 * dir.cross(e - first).abs(), e)] } _ => Vec::new(), } } fn fmt(kind: ValueKind, v: f64) -> String { match kind { ValueKind::Angle => format!("{:.1} deg", v.to_degrees()), ValueKind::Unitless => format!("{}", v.round()), ValueKind::Length => format!("{v:.2}"), } } /// Make the tool's boxes match its stage (new boxes when a point was placed). pub fn sync(t: &mut Tool) { if t.dims_stage != t.pts.len() { if t.dims_stage == 1 && t.pts.len() == 2 && matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall") { t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned()); } else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" { t.dim_history.clear(); } t.dims = boxes_for(t.cmd, t.pts.len()); t.dims_stage = t.pts.len(); t.dims_focus = !t.dims.is_empty(); } } /// Clear a shape's input immediately, including when another first point arrives before redraw. pub fn reset(t: &mut Tool) { t.dims.clear(); t.dim_history.clear(); t.dims_stage = usize::MAX; t.dims_focus = false; } pub fn has_locked(t: &Tool, label: &str) -> bool { t.dims.iter().chain(&t.dim_history).any(|dimension| dimension.locked && dimension.label == label) } /// Original expressions, rather than sampled numbers, become the shape's permanent dimensions. pub fn dimensions(t: &Tool) -> Value { let values = t .dim_history .iter() .chain(&t.dims) .filter(|dimension| dimension.locked && dimension.label != "Sides") .map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value))) .collect::>(); Value::Object(values) } /// Invalid values stay in the current field, so users can correct them before another stage. pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> { for dimension in t.dim_history.iter().chain(&t.dims).filter(|dimension| dimension.locked) { if dimension.value.len() > 4096 { return Err(format!("{} expression is too long", dimension.label)); } let value = app.session.doc.eval(&dimension.value, dimension.kind).map_err(|error| format!("{}: {error}", dimension.label))?; if !value.is_finite() || value.abs() > 1e9 { return Err(format!("{} must be finite and within the model's size limits", dimension.label)); } match dimension.label { "Angle" => {} "Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {} "Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()), "Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {} "Sides" => return Err("Sides must be an integer from 3 to 200".into()), _ if value > 1e-9 => {} _ => return Err(format!("{} must be positive", dimension.label)), } } if t.cmd == "sketch.slot.overall" && let (Some(length), Some(width)) = (locked(app, t, "Length"), locked(app, t, "Width")) && length <= width { return Err("Overall slot Length must exceed Width".into()); } Ok(()) } /// A locked box value by label (for the polygon side count). pub fn locked_value(app: &SolveApp, t: &Tool, label: &str) -> Option { locked(app, t, label) } /// Draw the boxes of the active tool next to what they measure. Enter places the point. 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 && !app.esc_handled && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.esc_handled = true; crate::tools::finish(app); return; } let Some(mut t) = app.tool.take() else { return }; sync(&mut t); let place = if t.dims.is_empty() { None } else { boxes(app, ui, proj, &mut t) }; app.tool = Some(t); if let Some(p) = place { crate::tools::place(app, (p, None)); } } fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option { let tk = Tokens::get(); let sid = app.session.active_sketch?; let st = app.session.world_state(); let plane = st.sketch(sid)?.plane; let h = t.hover.as_ref().map(|x| x.0)?; let e = effective(app, t, h); 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); // Egui processes ordinary Tab navigation before these floating text edits are drawn. // Remember the box that owned the keyboard and restore a focus within this tool after // drawing, so the last dimension wraps to the first rather than reaching the panels. let tab = ui.input(|input| { input.raw.events.iter().find_map(|event| match event { egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => { Some(modifiers.shift) } _ => None, }) }); let source = tab.and_then(|_| { ui.ctx().memory(|memory| { (0..t.dims.len()) .find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i)))) .or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i))))) }) }); if source.is_some() { // Also cancel navigation on the first focused frame, before the text edit's focus // filter has been installed (e.g. immediately repeated keyboard input). ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None)); } let mut completed = false; let mut any_focused = false; for (i, b) in t.dims.iter_mut().enumerate() { let Some((v, at)) = lv.get(i).copied() else { continue }; if !b.locked { b.value = fmt(b.kind, v); } let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue }; let id = egui::Id::new(("sc_dim", i)); let has_focus = ui.ctx().memory(|m| m.has_focus(id)); // While a box follows the cursor its whole text is selected, so typing replaces it. if (has_focus || (focus && i == 0)) && !b.locked { let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); let n = b.value.chars().count(); s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); s.store(ui.ctx(), id); } let pos: Pos2 = sp + vec2(-34.0, -11.0); crate::frame::area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { let fill = if b.locked { tk.accent_soft } else { tk.panel }; egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { let r = ui // The drawing tool handles Enter. Keep the text edit focused when a // rejected value needs correction, instead of surrendering the keyboard. .add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).return_key(None).event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() })) .on_hover_text(b.label); // A completion gets first use of forward Tab. Shift+Tab always moves back; // the box reached by egui's generic traversal must not complete its text. let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value); completed |= changed && source == Some(i); if r.changed() || changed { b.locked = true; } if focus && i == 0 { r.request_focus(); } any_focused |= r.has_focus() || r.lost_focus(); }); }); } if let Some(source) = source && !completed { let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() }; let id = egui::Id::new(("sc_dim", next)); if let Some(dimension) = t.dims.get(next) { let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); state.cursor.set_char_range(Some(egui::text::CCursorRange::two( egui::text::CCursor::new(0), egui::text::CCursor::new(dimension.value.chars().count()), ))); state.store(ui.ctx(), id); } ui.ctx().memory_mut(|memory| memory.request_focus(id)); } let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let nothing = ui.ctx().memory(|m| m.focused().is_none()); (enter && (any_focused || nothing)).then_some(e) } #[cfg(test)] #[path = "sketch_dims_tests.rs"] mod tests; #[cfg(test)] #[path = "sketch_typed_intent_tests.rs"] mod typed_intent_tests;