diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index ad5f3a4f..258c0b14 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -10,7 +10,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navig .icon("orbit") .noundo() .doc_only() - .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit; or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?")]; + .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?")]; fn navigate(_s: &mut Session, p: &Value) -> Result { let cmd = "view.navigate"; @@ -41,12 +41,23 @@ fn navigate(_s: &mut Session, p: &Value) -> Result { } } else { use std::f64::consts::FRAC_PI_2; - let (r, u, _) = cam.basis(); + let (r, u, b) = cam.basis(); + // Left, right, up and down turn the view a quarter turn (the navigation cube's arrows), + // or `degrees` (the arrow keys' 15° steps, #1541). + let (sin, cos) = match p.get("degrees") { + None => (1.0, 0.0), + Some(d) => d + .as_f64() + .filter(|d| d.is_finite() && *d > 0.0 && *d <= 180.0) + .map(|d| if d == 90.0 { (1.0, 0.0) } else { d.to_radians().sin_cos() }) + .ok_or_else(|| bad(cmd, "degrees must be a number above 0 and at most 180"))?, + }; + let turn = |toward: solidcraft_geom::Vec3| cam.looking_from(b * cos + toward * sin); match p.get("action").and_then(Value::as_str) { - Some("left") => cam = cam.looking_from(-r), - Some("right") => cam = cam.looking_from(r), - Some("up") => cam = cam.looking_from(u), - Some("down") => cam = cam.looking_from(-u), + Some("left") => cam = turn(-r), + Some("right") => cam = turn(r), + Some("up") => cam = turn(u), + Some("down") => cam = turn(-u), Some("clockwise") => cam.roll = (cam.roll + FRAC_PI_2).rem_euclid(std::f64::consts::TAU), Some("counterclockwise") => cam.roll = (cam.roll - FRAC_PI_2).rem_euclid(std::f64::consts::TAU), Some("home") => { @@ -131,6 +142,38 @@ mod tests { use solidcraft_geom::Vec3; use solidcraft_render::UpAxis; + /// #1541: left, right, up and down turn a quarter turn, or `degrees` (the arrow keys' 15°). + #[test] + fn turns_take_degrees_and_default_to_a_quarter_turn() { + let mut s = Session::default(); + let cam = Camera { yaw: 0.0, pitch: 0.0, distance: 100.0, ..Default::default() }; + let mut turn = |action: &str, degrees: Option| -> Result { + let mut params = json!({"camera": cam, "action": action}); + if let Some(d) = degrees { + params["degrees"] = d; + } + let result = s.execute("view.navigate", ¶ms)?; + Ok(serde_json::from_value(result["camera"].clone()).unwrap()) + }; + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + // A quarter turn as before, given or not. + let quarter = turn("left", None).unwrap(); + assert_eq!(turn("left", Some(json!(90))).unwrap(), quarter); + assert!(close((quarter.yaw - cam.yaw).abs(), std::f64::consts::FRAC_PI_2)); + // 15° each way, about the up axis or over the top. + for (action, yaw, pitch) in [("left", -15.0, 0.0), ("right", 15.0, 0.0), ("up", 0.0, 15.0), ("down", 0.0, -15.0)] { + let to = turn(action, Some(json!(15))).unwrap(); + assert!(close(to.yaw.abs(), f64::to_radians(yaw).abs()), "{action}: yaw {}", to.yaw); + assert!(close(to.pitch, f64::to_radians(pitch)), "{action}: pitch {}", to.pitch); + assert_eq!((to.target, to.distance), (cam.target, cam.distance)); + } + // Left and right turn opposite ways. + assert!(close(turn("left", Some(json!(15))).unwrap().yaw, -turn("right", Some(json!(15))).unwrap().yaw)); + for bad in [json!(0), json!(-15), json!(181), json!(f64::NAN.to_string()), json!("15"), json!(null)] { + assert!(turn("left", Some(bad.clone())).is_err(), "{bad}"); + } + } + #[test] fn continuous_navigation_moves_the_camera_without_editing_the_design() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 11dc230e..abb38103 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -31,6 +31,20 @@ pub const RESERVED: &[&str] = &[ "Ctrl+C", "Ctrl+X", "Ctrl+V", + // View keys (`navigation::view_key`). + "Left", + "Right", + "Up", + "Down", + "Shift+Left", + "Shift+Right", + "Shift+Up", + "Shift+Down", + "Ctrl+Left", + "Ctrl+Right", + "Ctrl+Up", + "Ctrl+Down", + "Ctrl+8", ]; #[derive(Default)] diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 866a89c4..7f401cf9 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -181,6 +181,45 @@ pub(crate) fn modifier_motion( }) } +/// View keys (#1541): the arrows turn the view 15° (with Shift a quarter turn, as the navigation +/// cube's arrows), Ctrl+arrows pan it a tenth of the view, and Ctrl+8 looks straight at the +/// selected face, plane or sketch. Not while a menu, the command box or a focused widget has the +/// arrows. Returns whether the key was used. +pub(crate) fn view_key(app: &mut SolveApp, ctx: &egui::Context, key: egui::Key, mods: egui::Modifiers) -> bool { + use egui::Key; + let menu_open = egui::Popup::is_any_open(ctx) + || app.viewport.context_menu.is_some() + || app.ui.palette_open + || ctx.memory(|m| m.focused().is_some()) + || ctx.data(|d| d.get_temp::(egui::Id::new("sc_panel_open")).is_some()); + let ctrl = mods.command || mods.ctrl; + if mods.alt || menu_open { + return false; + } + if key == Key::Num8 && ctrl && !mods.shift { + crate::viewport::look_at_selection(app); + return true; + } + let (action, dx, dy) = match key { + Key::ArrowLeft => ("left", -1.0, 0.0), + Key::ArrowRight => ("right", 1.0, 0.0), + Key::ArrowUp => ("up", 0.0, -1.0), + Key::ArrowDown => ("down", 0.0, 1.0), + _ => return false, + }; + if ctrl { + // The model moves with the arrow, as it does under a pan drag. + if let Some(rect) = app.viewport.rect { + let step = rect.height() * 0.1; + camera_motion(app, rect, json!({"motion": "pan", "delta": [dx * step, dy * step]})); + } + } else { + let degrees = if mods.shift { 90.0 } else { 15.0 }; + let _ = app.run("view.navigate", json!({"camera": app.cam, "action": action, "degrees": degrees})); + } + true +} + /// Continuous camera changes use the same JSON command as programmatic callers. pub(crate) fn camera_motion(app: &mut SolveApp, rect: Rect, mut params: serde_json::Value) { let stored_fov = app.cam.fov; @@ -835,3 +874,46 @@ mod tests { assert_eq!(app.cam, still); } } + +#[cfg(test)] +mod view_key_tests { + use crate::scenario::Harness; + use serde_json::json; + + fn turned(h: &mut Harness, key: serde_json::Value) -> (f64, f64) { + let before = h.app.cam; + h.step(&key).unwrap(); + h.frames(2); + (h.app.cam.yaw - before.yaw, h.app.cam.pitch - before.pitch) + } + + /// #1541: the arrows turn the view 15°, Shift+arrows a quarter turn, Ctrl+arrows pan, and none + /// of them is a shortcut a command can take. + #[test] + fn arrow_keys_turn_and_pan_the_view() { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "front"}})).unwrap(); + h.frames(3); + let close = |a: f64, b: f64| (a.abs() - b.to_radians()).abs() < 1e-6; + let (yaw, pitch) = turned(&mut h, json!({"key": "ArrowLeft"})); + assert!(close(yaw, 15.0) && close(pitch, 0.0), "Left turns 15°: {yaw} {pitch}"); + let (yaw, pitch) = turned(&mut h, json!({"key": "ArrowUp"})); + assert!(close(yaw, 0.0) && close(pitch, 15.0), "Up turns 15° over: {yaw} {pitch}"); + let (yaw, _) = turned(&mut h, json!({"key": "ArrowRight", "shift": true})); + assert!(close(yaw, 90.0), "Shift+Right is a quarter turn: {yaw}"); + let (before, angles) = (h.app.cam.target, (h.app.cam.yaw, h.app.cam.pitch)); + h.step(&json!({"key": "ArrowRight", "cmd": true})).unwrap(); + h.frames(2); + assert_ne!(h.app.cam.target, before, "Ctrl+Right pans"); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), angles, "and does not turn"); + for k in [egui::Key::ArrowLeft, egui::Key::ArrowRight, egui::Key::ArrowUp, egui::Key::ArrowDown] { + for mods in [egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND] { + let name = crate::keymap::key_name(k, mods); + assert!(crate::keymap::RESERVED.contains(&name.as_str()), "{name} must be reserved"); + } + } + assert!(crate::keymap::RESERVED.contains(&crate::keymap::key_name(egui::Key::Num8, egui::Modifiers::COMMAND).as_str())); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 8407fc22..ffc53d8c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1305,6 +1305,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } for k in keys { use egui::Key; + if shown && crate::navigation::view_key(app, ctx, k, mods) { + continue; + } if mods.command { match k { Key::N => crate::documents::new_design(app), diff --git a/docs/book/src/guide/shortcuts.md b/docs/book/src/guide/shortcuts.md index babc08fd..9b937313 100644 --- a/docs/book/src/guide/shortcuts.md +++ b/docs/book/src/guide/shortcuts.md @@ -24,6 +24,9 @@ Keys handled by the window itself: | V | show or hide the selected bodies, sketches and planes | | F2 | rename the selected browser or timeline item | | F6 | fit the view to the model | +| ← → ↑ ↓ | turn the view 15° (with Shift, a quarter turn) | +| Ctrl+← → ↑ ↓ | pan the view | +| Ctrl+8 | look straight at the selected face, plane or sketch | | Esc | cancel the running command, or clear the selection | While a value box has the keyboard (a dimension being added, a dialog field), keys type into