Navigation: choose the orbit centre: view centre, origin or point under the cursor (#1377)

Preferences ▸ Navigation ▸ Orbit around now offers View centre (the default, as before), Origin and Point under the cursor. A saved orbit_cursor preference migrates to the cursor option, and the control channel still accepts the old key.

Thanks to @kuraneko1 for this.

Co-authored-by: kuraneko1 <98832587+kuraneko1@users.noreply.github.com>
This commit is contained in:
_ryu15_ authored and GitHub committed 2026-10-15 08:57:20 -04:00
1 parent 4b3b48388c
commit ad30a2350e
6 files changed
+161 -40

No files matched your search

+103 -25
View File
@@ -236,6 +236,19 @@ impl NavigatorStyle {
}
}
/// What an orbit turns around (Preferences: "Orbit around").
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OrbitAround {
/// The world origin: the view turns about it and keeps it still on screen.
Origin,
/// The view's centre: what Pan moves and Fit frames (the default, as it always was).
#[default]
ViewCentre,
/// The model point under the cursor when the drag began.
Cursor,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Prefs {
@@ -249,7 +262,11 @@ pub struct Prefs {
pub radial_context_menu: bool,
/// The wheel zooms out when turned forward.
pub zoom_reverse: bool,
/// Orbit around the point under the cursor (else around the view's centre).
/// What an orbit turns around.
pub orbit_around: OrbitAround,
/// The cursor preference as releases before the origin option saved it (`true` = the point
/// under the cursor). [`migrate`] folds it into `orbit_around` when saved preferences are
/// read, so an existing choice survives the upgrade; this version only writes `false`.
pub orbit_cursor: bool,
/// Whether a connected 3D mouse moves the view.
pub space_mouse_enabled: bool,
@@ -346,6 +363,7 @@ impl Default for Prefs {
navigator_style: NavigatorStyle::default(),
radial_context_menu: false,
zoom_reverse: false,
orbit_around: OrbitAround::default(),
orbit_cursor: false,
space_mouse_enabled: true,
space_mouse_speed: 1.0,
@@ -482,6 +500,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.zoom_to_cursor = d.zoom_to_cursor;
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.trackpad_pan = d.trackpad_pan;
p.orbit_around = d.orbit_around;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
@@ -585,25 +604,36 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
if app.preferences.zoom_reverse { -1.0 } else { 1.0 }
}
/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around
/// the model point that was under the cursor when the drag began (kept still on screen). A drag
/// that begins over empty space turns around the centre of the visible geometry instead, so the
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world
/// origin, or the model point that was under the cursor when the drag began. The origin and the
/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around
/// the centre of the visible geometry instead, so the model never swings around a far-off point.
/// The cursor pivot is chosen once, when the drag begins.
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
if !app.preferences.orbit_cursor {
let pivot = match app.preferences.orbit_around {
OrbitAround::ViewCentre => None,
OrbitAround::Origin => Some(Vec3::ZERO),
OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
Some(p) => p,
None => {
let proj = crate::viewport::projection(app, rect);
let at = press.unwrap_or(rect.center());
let p = pivot_under(app, &proj, at);
app.viewport.orbit_pivot = Some(p);
p
}
}),
};
let Some(pivot) = pivot else {
app.cam.orbit(dx, dy);
return;
}
let pivot = match app.viewport.orbit_pivot {
Some(p) => p,
None => {
let proj = crate::viewport::projection(app, rect);
let at = press.unwrap_or(rect.center());
let p = pivot_under(app, &proj, at);
app.viewport.orbit_pivot = Some(p);
p
}
};
orbit_about(app, rect, dx, dy, pivot);
}
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
/// correction puts it back where it was.
fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
let before = crate::viewport::projection(app, rect).to_screen(pivot);
app.cam.orbit(dx, dy);
let after = crate::viewport::projection(app, rect).to_screen(pivot);
@@ -1050,9 +1080,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.end_row();
ui.label(crate::i18n::tr_label("Orbit around"));
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, crate::i18n::tr_label("View centre"));
ui.radio_value(&mut app.preferences.orbit_cursor, true, crate::i18n::tr_label("Point under the cursor"));
// Wrapped: three choices, and German or Russian labels are long.
ui.horizontal_wrapped(|ui| {
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Origin, crate::i18n::tr_label("Origin"));
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::ViewCentre, crate::i18n::tr_label("View centre"));
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Cursor, crate::i18n::tr_label("Point under the cursor"));
});
ui.end_row();
ui.label(crate::i18n::tr_label("Middle double-click"));
@@ -1312,12 +1344,22 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
Ok(out)
}
/// Fold a setting an older release kept in its own field into the one this version reads.
/// `orbit_cursor` (`true` = the point under the cursor) became `orbit_around`.
pub fn migrate(mut p: Prefs) -> Prefs {
if p.orbit_cursor {
p.orbit_around = OrbitAround::Cursor;
p.orbit_cursor = false;
}
p
}
/// Saved preferences, keeping every one that still reads. A value this version doesn't
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
/// its default on its own instead of discarding all of them.
pub fn lenient(saved: &serde_json::Value) -> Prefs {
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
return p;
return migrate(p);
}
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
@@ -1331,7 +1373,7 @@ pub fn lenient(saved: &serde_json::Value) -> Prefs {
}
}
}
serde_json::from_value(cur).unwrap_or_default()
serde_json::from_value(cur).map(migrate).unwrap_or_default()
}
/// Set preferences from JSON (control channel); returns them all.
@@ -1351,7 +1393,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
{
return Err("`color_vision` must be off, protan, deutan or tritan".into());
}
let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
let new: Prefs = migrate(serde_json::from_value(cur).map_err(|e| e.to_string())?);
if !UNITS.contains(&new.default_units.as_str()) {
return Err(format!("units must be one of {}", UNITS.join(", ")));
}
@@ -1832,11 +1874,47 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Orbiting about the world origin keeps the origin where it was drawn, even when Pan has
/// moved it off to the side.
#[test]
fn orbiting_around_the_origin_keeps_it_still() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
assert_eq!(app.preferences.orbit_around, OrbitAround::ViewCentre, "the view centre stays the default");
app.preferences.orbit_around = OrbitAround::Origin;
app.cam.pan(60.0, -25.0, 600.0);
let was = (app.cam.yaw, app.cam.pitch);
let before = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
orbit(&mut app, rect, 40.0, 25.0, None);
let after = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
assert_ne!((app.cam.yaw, app.cam.pitch), was, "the view must turn");
}
/// Preferences saved before the origin option had `orbit_cursor`: that choice survives, and
/// the control channel still accepts the old key.
#[test]
fn saved_cursor_preference_migrates_to_the_origin_option() {
assert_eq!(lenient(&json!({"orbit_cursor": true})).orbit_around, OrbitAround::Cursor);
assert_eq!(lenient(&json!({"orbit_cursor": false})).orbit_around, OrbitAround::ViewCentre);
assert_eq!(lenient(&json!({})).orbit_around, OrbitAround::ViewCentre);
assert_eq!(lenient(&json!({"orbit_around": "origin"})).orbit_around, OrbitAround::Origin);
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"orbit_around": "origin"})).unwrap();
assert_eq!(app.preferences.orbit_around, OrbitAround::Origin);
set(&mut app, &json!({"orbit_cursor": true})).unwrap();
assert_eq!(app.preferences.orbit_around, OrbitAround::Cursor, "the old key still moves the choice");
assert!(!app.preferences.orbit_cursor, "and is folded away");
}
#[test]
fn orbiting_around_the_cursor_keeps_that_point_still() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
let at = egui::pos2(500.0, 250.0);
@@ -1852,7 +1930,7 @@ mod tests {
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
app.fit_view();
let corner = egui::pos2(10.0, 10.0);
@@ -1865,7 +1943,7 @@ mod tests {
assert_eq!(app.viewport.orbit_pivot, Some(centre));
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.orbit_cursor = true;
app.preferences.orbit_around = OrbitAround::Cursor;
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));