From c111b5f2fbb8036f110e6e5b91f7555af31211d0 Mon Sep 17 00:00:00 2001 From: Dahak Builds Date: Sat, 31 Oct 2026 22:00:16 +0000 Subject: [PATCH] ViewCube: option to invert directional rotation arrows (#10254) (#10286) * claim: #10254 * ViewCube: an option to invert the directional rotation arrows (#10254) A new Navigation preference, "Invert the view cube rotation arrows" (invert_navigator_arrows, off by default): with it on, an arrow around the cube orbits the camera the way the arrow points, as in Fusion and Inventor, instead of rotating the cube toward the side the arrow sits on. Left and right, and up and down, trade places, and each tooltip names the action actually performed. The curved roll arrows are not inverted: they name the rotation they perform, not a direction. --------- Co-authored-by: Dahak Builds --- .../pending/invert-navigator-arrows.json | 26 +++++++ crates/ui-egui/src/navigation_cube.rs | 73 +++++++++++++++++++ crates/ui-egui/src/prefs.rs | 20 +++++ crates/ui-egui/src/prefs/navigation.rs | 3 + 4 files changed, 122 insertions(+) create mode 100644 assets/locales/pending/invert-navigator-arrows.json diff --git a/assets/locales/pending/invert-navigator-arrows.json b/assets/locales/pending/invert-navigator-arrows.json new file mode 100644 index 0000000000..c1830b3088 --- /dev/null +++ b/assets/locales/pending/invert-navigator-arrows.json @@ -0,0 +1,26 @@ +{ + "en": { + "prefs.invert_navigator_arrows": "Invert the view cube rotation arrows", + "prefs.invert_navigator_arrows_note": "The arrows around the cube orbit the camera the way they point, as in Fusion and Inventor: the arrow on the left points right and swings the camera right. Off: an arrow rotates the cube toward the side it sits on, so the left arrow swings the camera left. The curved roll arrows are not affected." + }, + "ja": { + "prefs.invert_navigator_arrows": "ビューキューブの回転矢印を反転", + "prefs.invert_navigator_arrows_note": "キューブの周りの矢印が、矢印の指す方向にカメラを回します(Fusion や Inventor と同じ動き):左の矢印は右を指し、カメラを右に振ります。オフ: 矢印は、そのある面の方へキューブを回すため、左の矢印はカメラを左に振ります。カーブしたロール矢印は影響を受けません。" + }, + "zh": { + "prefs.invert_navigator_arrows": "反转视图立方体的旋转箭头", + "prefs.invert_navigator_arrows_note": "立方体周围的箭头按箭头所指的方向转动相机,与 Fusion 和 Inventor 相同:左侧箭头指向右,相机向右转动。关闭:箭头把立方体转向它所在的那一面,因此左侧箭头使相机向左转动。弯曲的翻滚箭头不受影响。" + }, + "ar": { + "prefs.invert_navigator_arrows": "عكس أسهم تدوير مكعب العرض", + "prefs.invert_navigator_arrows_note": "تدور الأسهم حول المكعب بالكاميرا حسب اتجاه السهم، كما في Fusion وInventor: السهم على اليسار يشير يميناً ويحرّك الكاميرا يميناً. عند الإيقاف: يدور السهم بالمكعب نحو الجهة التي يقع عليها، فيحرّك سهم اليسار الكاميرا يساراً. لا يتأثر سهما التدوير المنحنيان." + }, + "he": { + "prefs.invert_navigator_arrows": "היפוך חצי סיבוב קוביית התצוגה", + "prefs.invert_navigator_arrows_note": "החצים סביב הקובייה מחוויים את המצלמה לכיוון שהחץ מצביע, כמו ב-Fusion ו-Inventor: החץ שמשמאל מצביע ימימה ומניע את המצלמה ימימה. כבוי: החץ מסובב את הקובייה אל הצד שבו הוא יושב, כך שחץ שמאל מניע את המצלמה שמאלה. חצי הגלילה המעוגלים אינם מושפעים." + }, + "vi": { + "prefs.invert_navigator_arrows": "Đảo ngược mũi tên xoay của khối điều hướng", + "prefs.invert_navigator_arrows_note": "Các mũi tên quanh khối xoay camera theo hướng mũi tên chỉ, như trong Fusion và Inventor: mũi tên bên trái chỉ sang phải và đưa camera sang phải. Khi tắt: mũi tên xoay khối về phía cạnh chứa nó, nên mũi tên bên trái đưa camera sang trái. Hai mũi tên cuộn cong không bị ảnh hưởng." + } +} diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs index cb3fc4e214..26ab2e0488 100644 --- a/crates/ui-egui/src/navigation_cube.rs +++ b/crates/ui-egui/src/navigation_cube.rs @@ -147,6 +147,21 @@ fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, to hover } +/// Preferences › Navigation: "Invert the view cube rotation arrows" (#10254). With it on, an +/// arrow orbits the camera the way the arrow points (camera-centric, as in Fusion and Inventor) +/// instead of rotating the cube toward the side the arrow sits on, so left and right, and up and +/// down, trade places. The tooltip names the action actually performed. The curved roll arrows +/// are not inverted: they name the rotation they perform, not a direction. +fn inverted_arrow(action: &str) -> Option<&'static str> { + match action { + "left" => Some("right"), + "right" => Some("left"), + "up" => Some("down"), + "down" => Some("up"), + _ => None, + } +} + pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let outer = axis_rect(app, rect); if !outer.is_positive() { @@ -205,6 +220,18 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { ("up", vec2(0.0, -distance), vec2(0.0, 1.0), "Orbit up 90°"), ("down", vec2(0.0, distance), vec2(0.0, -1.0), "Orbit down 90°"), ] { + let (action, tooltip) = match app.preferences.invert_navigator_arrows.then(|| inverted_arrow(action)).flatten() { + Some(swapped) => ( + swapped, + match swapped { + "right" => "Orbit right 90°", + "left" => "Orbit left 90°", + "down" => "Orbit down 90°", + _ => "Orbit up 90°", + }, + ), + None => (action, tooltip), + }; let at = c + offset * scale; let hovered = control(app, ui, Rect::from_center_size(at, vec2(button_size, button_size) * scale), action, tooltip); if active { @@ -571,6 +598,52 @@ mod tests { assert_eq!(h.app.session.undo.len(), undo, "a view change edits nothing"); } + /// #10254: with Preferences › Navigation "Invert the view cube rotation arrows" on, the + /// arrow on the left orbits the camera the way it points (right), and the other arrows swap + /// the same way; the curved roll arrows keep their action. + #[test] + fn inverted_arrows_orbit_the_way_they_point() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube", "look_at_anim":false})); + assert!(!h.app.preferences.invert_navigator_arrows, "off by default"); + let turn = |h: &mut crate::scenario::Harness, action: &str| -> Camera { + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + serde_json::from_value(h.app.session.execute("view.navigate", &json!({"camera": h.app.cam, "action": action})).unwrap()["camera"].clone()) + .unwrap() + }; + let click = |h: &mut crate::scenario::Harness, handle: &str| { + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + let p = crate::scenario::handle_at(&format!("navigation_cube:control:{handle}")).unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + h.app.cam + }; + let on_the_left = |handle: &str| { + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + crate::scenario::handle_at(&format!("navigation_cube:control:{handle}")).unwrap().x < outer.center().x + }; + // Off (the default): the arrow on the left runs the "left" orbit, as before. + assert!(on_the_left("left"), "off: the left action sits on the cube's left side"); + assert_eq!(click(&mut h, "left"), turn(&mut h, "left")); + assert_eq!(click(&mut h, "up"), turn(&mut h, "up")); + h.call("ui.prefs", json!({"invert_navigator_arrows":true})); + h.frames(2); + // On: the arrow on the left now runs the "right" orbit, and the sides trade places. + assert!(on_the_left("right"), "on: the arrow on the left runs the right orbit"); + assert_eq!(click(&mut h, "right"), turn(&mut h, "right")); + assert_eq!(click(&mut h, "left"), turn(&mut h, "left")); + assert_eq!(click(&mut h, "down"), turn(&mut h, "down")); + // The curved roll arrows are not inverted. + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + let camera = h.app.cam; + let p = crate::scenario::handle_at("navigation_cube:control:clockwise").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!((h.app.cam.roll - camera.roll - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "clockwise still rolls clockwise"); + } + #[test] fn cube_navigation_eases_through_intermediate_views_and_can_be_interrupted() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 404e85c665..8d03cdf386 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1265,6 +1265,10 @@ preference_settings! { /// whole model, as Home does (#10091). Off by default: the view turns in place, keeping its /// target and zoom. navigator_fit: bool = false, PAGE_NAVIGATION; + /// The view cube's directional arrows orbit the camera the way the arrow points, as in + /// Fusion and Inventor (`invert_navigator_arrows`, #10254). Off by default: an arrow rotates + /// the cube toward the side it sits on, so the arrow on the left swings the camera left. + invert_navigator_arrows: bool = false, PAGE_NAVIGATION; /// How much of the view the model fills when Home, Fit, an opened design or a navigator /// view change frames it (0.1 to 1; #10091): 1, the default, fills the room between the /// panels, the compass and the navigation bar, as before. @@ -4071,6 +4075,22 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str) #[cfg(test)] mod tests { + /// #10254: the invert toggle is off by default, persists, and the Navigation page's + /// Defaults resets it. + #[test] + fn invert_navigator_arrows_defaults_off_persists_and_resets() { + assert!(!Prefs::default().invert_navigator_arrows); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + assert!(!app.preferences.invert_navigator_arrows); + app.preferences.invert_navigator_arrows = true; + assert_eq!(serde_json::to_value(&app.preferences).unwrap()["invert_navigator_arrows"], true); + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&app.prefs()); + assert!(restored.preferences.invert_navigator_arrows); + super::reset_preferences(&mut app.preferences, super::PAGE_NAVIGATION); + assert!(!app.preferences.invert_navigator_arrows); + } + #[test] fn trackpad_zoom_modifier_roundtrips_and_validates_control_values() { assert_eq!(Prefs::default().trackpad_zoom_modifier, TrackpadZoomModifier::Off); diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 7438cb6d00..6f87e29bca 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -200,6 +200,9 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let (label, note) = (app.tr("prefs.navigator_fit").to_owned(), app.tr("prefs.navigator_fit_note").to_owned()); let fit = ui_kit::checkbox(ui, &label, &mut app.preferences.navigator_fit).on_hover_text(note); crate::scenario::publish_target("prefs:navigator_fit", fit.rect); + let (label, note) = (app.tr("prefs.invert_navigator_arrows").to_owned(), app.tr("prefs.invert_navigator_arrows_note").to_owned()); + let invert = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_navigator_arrows).on_hover_text(note); + crate::scenario::publish_target("prefs:invert_navigator_arrows", invert.rect); ui_kit::row(ui, app.tr_label("Middle double-click"), |ui| { let current = MiddleDoubleClick::of(app); ui_kit::settings_control_width(ui, t.form_label_width * 2.0 + t.space_24 * 2.0 + t.space_12, |ui| {