diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6c83218a..a1df2e70 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -134,13 +134,30 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } - // Move: the triad at the pivot; dragging it sets the distances and angles. + // Move: the triad at the pivot; dragging it sets the distances and angles. Its value box + // sits beside it and reads the move's main value, so a drag shows its number and typing + // one sets it. The triad is the manipulator here, so the box comes without the drag arrow + // every other dialog's value gets (`dir` is None below). if matches!(d.kind, Kind::Move { .. }) { if let Some(t) = crate::dialogs_move::triad(app, &d) && let Some(g) = crate::gizmo::show(ui, painter, proj, egui::Id::new("sc_move_triad"), &t, snap_step(app.cam.half_height())) { crate::dialogs_move::drag(app, &mut d, g); } + // Read the triad again after the drag, so the box follows it in the same frame. Move + // types with no triad (Rotate, Point to Point, Point to Position) put the box on the + // picked bodies instead. + let triad = crate::dialogs_move::triad(app, &d); + let at = triad.map(|t| t.center).or_else(|| anchor(app, &d).map(|(base, _)| base)); + let focus = std::mem::take(&mut d.focus); + if let Some(bs) = at.and_then(|base| proj.to_screen(base)) + && let Some((label, _, value)) = d.primary() + { + // Clear of the triad: its arrows reach LEN_PX and its rings RING of that, so a box + // beside the centre would cover the handles the user is reaching for. + let off = if triad.is_some() { vec2(crate::gizmo::LEN_PX as f32 + 16.0, -12.0) } else { vec2(16.0, -30.0) }; + value_box(ui, label, value, bs + off, focus); + } app.dialog = Some(d); return; } @@ -311,8 +328,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, box_at = ts + vec2(14.0, -12.0); } } + value_box(ui, label, value, box_at, focus); + if *value != before + && let Kind::Extrude { all, .. } = &mut d.kind + { + *all = false; + } +} + +/// The command's main value in the viewport, next to the geometry: the keyboard goes here while +/// the window is open, so a number typed straight after a pick sets the value. `at` is its +/// top-left corner. +fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, focus: bool) { + let t = Tokens::get(); let id = egui::Id::new("sc_canvas_value"); - crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5)); @@ -328,11 +358,6 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); }); }); - if *value != before - && let Kind::Extrude { all, .. } = &mut d.kind - { - *all = false; - } } /// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 35479eb4..496b0f49 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -679,7 +679,13 @@ impl Dialog { Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), - Kind::Move { x, .. } => ("X", ValueKind::Length, x), + // A move's main value follows its type: a distance along X for the ones that + // translate, the angle for Rotate. Point to Point is two picks with nothing to type. + Kind::Move { mode, x, angle, .. } => match *mode { + crate::dialogs_move::ROTATE => ("Angle", ValueKind::Angle, angle), + crate::dialogs_move::POINT_TO_POINT => return None, + _ => ("X", ValueKind::Length, x), + }, Kind::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing), Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset), diff --git a/crates/ui-egui/src/dialogs_move.rs b/crates/ui-egui/src/dialogs_move.rs index 4cc12aee..a7698f22 100644 --- a/crates/ui-egui/src/dialogs_move.rs +++ b/crates/ui-egui/src/dialogs_move.rs @@ -13,11 +13,11 @@ use crate::gizmo::{self, Change, Drag}; use crate::selection::{AXES, BODIES, EDGES, FACES, SelInput, VERTICES}; pub const TYPES: [&str; 5] = ["Free Move", "Translate", "Rotate", "Point to Point", "Point to Position"]; -const FREE: usize = 0; -const TRANSLATE: usize = 1; -const ROTATE: usize = 2; -const POINT_TO_POINT: usize = 3; -const POINT_TO_POSITION: usize = 4; +pub const FREE: usize = 0; +pub const TRANSLATE: usize = 1; +pub const ROTATE: usize = 2; +pub const POINT_TO_POINT: usize = 3; +pub const POINT_TO_POSITION: usize = 4; const POINTS: u16 = VERTICES | FACES | EDGES; /// A fresh Move dialog of a type (all values zero). @@ -318,3 +318,7 @@ pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], a d.extra.insert("copy".into(), json!(true)); } } + +#[cfg(test)] +#[path = "dialogs_move_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_move_tests.rs b/crates/ui-egui/src/dialogs_move_tests.rs new file mode 100644 index 00000000..eb52a7c1 --- /dev/null +++ b/crates/ui-egui/src/dialogs_move_tests.rs @@ -0,0 +1,71 @@ +//! The Move window's main value: the one the canvas box shows and the keyboard types into. +//! It follows the move type, because each type uses a different value. +use serde_json::json; +use solidcraft_engine::Session; + +use super::*; +use crate::Services; +use crate::dialogs::Dialog; + +fn move_dialog(mode: usize) -> Dialog { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap(); + let mut app = SolveApp::new(s, Services::default()); + app.start("solid.move"); + let mut d = app.dialog.clone().unwrap(); + d.kind = kind(mode); + d.inputs = inputs(mode); + if let Some(inp) = d.inputs.first_mut() { + inp.items = vec![Sel::Body { name: "Body1".into() }]; + } + d +} + +#[test] +fn the_main_value_follows_the_move_type() { + for mode in [FREE, TRANSLATE, POINT_TO_POSITION] { + let mut d = move_dialog(mode); + let got = d.primary().map(|p| (p.0, p.1)); + assert_eq!(got, Some(("X", ValueKind::Length)), "{} types a distance", TYPES[mode]); + } + + let mut d = move_dialog(ROTATE); + let got = d.primary().map(|p| (p.0, p.1)); + assert_eq!(got, Some(("Angle", ValueKind::Angle)), "Rotate types an angle, not an X distance"); + + let mut d = move_dialog(POINT_TO_POINT); + assert!(d.primary().is_none(), "Point to Point is two picks with nothing to type"); +} + +#[test] +fn the_main_value_writes_through_to_the_command() { + let mut d = move_dialog(ROTATE); + if let Some((_, _, value)) = d.primary() { + *value = "90 deg".into(); + } + let Kind::Move { angle, .. } = &d.kind else { panic!("not a move") }; + assert_eq!(angle, "90 deg", "the box edits the angle itself, not a copy of it"); + + let mut d = move_dialog(TRANSLATE); + if let Some((_, _, value)) = d.primary() { + *value = "30 mm".into(); + } + let Kind::Move { x, y, z, .. } = &d.kind else { panic!("not a move") }; + assert_eq!((x.as_str(), y.as_str(), z.as_str()), ("30 mm", "0 mm", "0 mm"), "the box edits X alone"); +} + +/// Free Move and Translate carry the triad. The other three have no manipulator yet, so their +/// value box is the only way to drive them: it must still have somewhere to sit. +#[test] +fn every_move_type_has_somewhere_to_put_its_value_box() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap(); + let app = SolveApp::new(s, Services::default()); + for mode in [FREE, TRANSLATE, ROTATE, POINT_TO_POINT, POINT_TO_POSITION] { + let d = move_dialog(mode); + let has_triad = triad(&app, &d).is_some(); + assert_eq!(has_triad, matches!(mode, FREE | TRANSLATE), "{} triad", TYPES[mode]); + let at = triad(&app, &d).map(|t| t.center).or_else(|| crate::canvas::anchor(&app, &d).map(|(base, _)| base)); + assert!(at.is_some(), "{} has nowhere to put its value box", TYPES[mode]); + } +} diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs index d5706bfe..3ec9c6c2 100644 --- a/crates/ui-egui/src/gizmo.rs +++ b/crates/ui-egui/src/gizmo.rs @@ -68,7 +68,7 @@ struct State { const DIRS: [Vec3; 3] = [Vec3::X, Vec3::Y, Vec3::Z]; /// Arrow length on screen (px). -const LEN_PX: f64 = 110.0; +pub const LEN_PX: f64 = 110.0; thread_local! { /// The last triad drawn: centre and arrow length in mm (for scenarios and tests). diff --git a/crates/ui-egui/tests/scenarios/move_canvas_value.json b/crates/ui-egui/tests/scenarios/move_canvas_value.json new file mode 100644 index 00000000..2aa82873 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/move_canvas_value.json @@ -0,0 +1,17 @@ +[ + {"note": "The Move window's value box on the canvas. Picking a body gives it the keyboard, so a typed number sets the move without hunting for a field first. Which value the box reads follows the move type, covered by dialogs_move_tests."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}}, + {"call": "ui.view", "params": {"view": "iso"}}, + + {"note": "Free Move: pick the body, type 30, press Enter. Nothing is clicked in the window at all."}, + {"start": "solid.move"}, + {"click": {"world": [10, 10, 20]}}, + {"expect": {"dialog": "Move", "bodies": 1}}, + {"text": "30"}, + {"expect": {"dialog": "x: \"30\""}}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "bodies": 1}}, + {"expect": {"bbox": {"body": "Body1", "min": [30, 0, 0], "max": [50, 20, 20]}}}, + + {"shot": "move_canvas_value"} +]