View keys: arrows turn the view, Ctrl+arrows pan, Ctrl+8 looks at the selection (#1541) (#1585)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-16 00:27:12 -04:00
1 parent 8f5073ff14
commit ac3a25fb5b
5 files changed
+151 -6

No files matched your search

+49 -6
View File
@@ -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<Value> {
let cmd = "view.navigate";
@@ -41,12 +41,23 @@ fn navigate(_s: &mut Session, p: &Value) -> Result<Value> {
}
} 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<Value>| -> Result<Camera> {
let mut params = json!({"camera": cam, "action": action});
if let Some(d) = degrees {
params["degrees"] = d;
}
let result = s.execute("view.navigate", &params)?;
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();
+14
View File
@@ -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)]
+82
View File
@@ -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>(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()));
}
}
+3
View File
@@ -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),
+3
View File
@@ -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