diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4bdde165..fbbb2359 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -31,7 +31,7 @@ //! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists) //! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears) //! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics -//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa, +//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_around, msaa, //! length_decimals, angle_decimals); returns them all //! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question //! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 3960fa22..21ab2a0a 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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) { - 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 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::(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 {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))); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9a060b2c..a09cf4aa 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2304,16 +2304,22 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some((mode, delta)) = motion { if delta != egui::Vec2::ZERO { let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]}); - if mode == NavMode::Orbit && app.preferences.orbit_cursor { - let pivot = match app.viewport.orbit_pivot { - Some(pivot) => pivot, - None => { - let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta); - app.viewport.orbit_pivot = Some(pivot); - pivot - } + if mode == NavMode::Orbit { + let pivot = match app.preferences.orbit_around { + crate::prefs::OrbitAround::ViewCentre => None, + crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO), + crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot { + Some(pivot) => pivot, + None => { + let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta); + app.viewport.orbit_pivot = Some(pivot); + pivot + } + }), }; - params["pivot"] = json!(pivot); + if let Some(pivot) = pivot { + params["pivot"] = json!(pivot); + } } camera_motion(app, rect, params); } @@ -3345,7 +3351,17 @@ fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option Some(Vec3::ZERO), + _ => app.viewport.orbit_pivot, + } + } else { + None + } + .and_then(|p| { app.cam = displayed; projection(app, rect).to_screen(p).map(|at| (p, at)) }); @@ -4429,7 +4445,7 @@ mod tests { let ctx = egui::Context::default(); let mut app = hover_app(false); app.preferences.nav = "touchpad".into(); - app.preferences.orbit_cursor = true; + app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor; let p = pos2(350.0, 300.0); let alt = egui::Modifiers { alt: true, ..Default::default() }; hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); @@ -5499,10 +5515,35 @@ mod tests { } } + /// The origin pivot holds through a Shift drag that snaps to an axis: the spring eases the + /// view round without letting the origin slide on screen. + #[test] + fn snapping_keeps_the_origin_still() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.orbit_around = crate::prefs::OrbitAround::Origin; + h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap(); + h.app.fit_view(); + let rect = h.app.viewport.rect.unwrap(); + let at = rect.center() + vec2(50.0, -20.0); + orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), true, true); + assert_eq!( + h.app.viewport.orbit_snap.as_ref().and_then(|s| s.pivot).map(|(p, _)| p), + Some(Vec3::ZERO), + "the origin is the pivot while snapping" + ); + let before = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap(); + for _ in 0..30 { + orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true); + step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); + let after = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap(); + assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}"); + } + } + #[test] fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() { let mut h = crate::scenario::Harness::new(); - h.app.preferences.orbit_cursor = true; + h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor; h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap(); h.app.fit_view(); let rect = h.app.viewport.rect.unwrap(); diff --git a/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json b/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json index 28149244..edf94e3a 100644 --- a/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json +++ b/crates/ui-egui/tests/scenarios/alias_navigation_bindings.json @@ -2,6 +2,8 @@ {"note": "Alias preset (#304): Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms (down: out, up: in). Other presets don't navigate with Shift+Alt+left."}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}}, {"call": "ui.prefs", "params": {"nav": "alias"}}, + {"note": "The orbit centre is its own preference, and the origin and cursor pivots move the camera's target: pin the view centre (the default) here so `panned` measures the binding, not the pivot."}, + {"call": "ui.prefs", "params": {"orbit_around": "view_centre"}}, {"expect": {"prefs": {"nav": "alias"}}}, {"note": "Orbit: like a right drag (from the drag threshold on), and Shift (part of the binding) doesn't snap."}, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e1f815d1..b700c340 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -132,7 +132,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | diff --git a/examples/demo/enclosure_ui_mcp.py b/examples/demo/enclosure_ui_mcp.py index 586d1038..370be2e9 100644 --- a/examples/demo/enclosure_ui_mcp.py +++ b/examples/demo/enclosure_ui_mcp.py @@ -738,7 +738,7 @@ def main(): mcp.send("notifications/initialized", notify=True) d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed, a.minimum_caption) # The pi story's camera moves orbit about the point under the pointer (Preferences > Navigation). - d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_cursor": a.story.startswith("pi"), "button": "apply"}) + d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_around": "cursor" if a.story.startswith("pi") else "view_centre", "button": "apply"}) d.ui("ui.resize", {"width": 1920, "height": 1080}) d.ui("ui.home", {"open": False}) d.ui("ui.documents", {"action": "new"})