From fbcd4852c11c73eb2af973f620187db1bc6d9446 Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Mon, 26 Oct 2026 17:46:15 +0000 Subject: [PATCH] Navigation bar: the view commands (#6585) (#6586) * claim: #6585 * Navigation bar: the view commands (#6585) --- .../locales/pending/navbar-view-commands.json | 32 ++++ crates/engine/src/cmd/view_navigation.rs | 123 +++++++++++++ crates/ui-egui/src/control.rs | 10 +- crates/ui-egui/src/dialogs_sheet_tests.rs | 2 +- crates/ui-egui/src/lib.rs | 79 ++++++-- crates/ui-egui/src/prefs.rs | 4 + crates/ui-egui/src/render_workspace_tests.rs | 15 +- crates/ui-egui/src/view_home_tests.rs | 168 ++++++++++++++++++ crates/ui-egui/src/viewport.rs | 57 ++++-- crates/ui-egui/src/viewport_layout.rs | 16 +- crates/ui-egui/src/viewport_layout_tests.rs | 19 +- crates/ui-egui/src/workspace.rs | 17 +- docs/roadmap-detail.md | 2 + 13 files changed, 491 insertions(+), 53 deletions(-) create mode 100644 assets/locales/pending/navbar-view-commands.json diff --git a/assets/locales/pending/navbar-view-commands.json b/assets/locales/pending/navbar-view-commands.json new file mode 100644 index 0000000000..a9b562e646 --- /dev/null +++ b/assets/locales/pending/navbar-view-commands.json @@ -0,0 +1,32 @@ +{ + "en": { + "cmd.view_fit": "Fit", + "cmd.view_home": "Home view", + "cmd.view_tool": "Navigation tool", + "cmd.view_spin": "Continuous orbit", + "cmd.view_visual_style": "Visual style", + "cmd.view_grid": "Grid", + "cmd.view_ground_shadow": "Ground shadow", + "cmd.view_navigation_bar": "Navigation bar" + }, + "ja": { + "cmd.view_fit": "フィット", + "cmd.view_home": "ホームビュー", + "cmd.view_tool": "ナビゲーションツール", + "cmd.view_spin": "連続オービット", + "cmd.view_visual_style": "表示スタイル", + "cmd.view_grid": "グリッド", + "cmd.view_ground_shadow": "地面の影", + "cmd.view_navigation_bar": "ナビゲーションバー" + }, + "zh": { + "cmd.view_fit": "适合视图", + "cmd.view_home": "主视图", + "cmd.view_tool": "导航工具", + "cmd.view_spin": "连续环绕", + "cmd.view_visual_style": "视觉样式", + "cmd.view_grid": "网格", + "cmd.view_ground_shadow": "地面阴影", + "cmd.view_navigation_bar": "导航栏" + } +} diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index 867007c92c..c4b0cc969a 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -7,6 +7,22 @@ use crate::params::{bad, num, req_vec2, req_vec3, vec3}; use crate::{Result, Sel, Session}; pub static COMMANDS: &[CommandSpec] = &[ +CommandSpec::new("view.fit", "Fit", fit) + .icon("fit").key("F6").noundo().doc_only().params("no parameters → frame the active canvas or selection; display only"), +CommandSpec::new("view.home", "Home view", home) + .icon("home").noundo().doc_only().params("no parameters → home orientation framing the active canvas; display only"), +CommandSpec::new("view.tool", "Navigation tool", tool) + .icon("orbit").noundo().doc_only().params("tool: orbit|pan|zoom|none → tool; latch a camera tool, none releases it"), +CommandSpec::new("view.spin", "Continuous orbit", spin) + .icon("spin").noundo().doc_only().params("on?: bool (omitted: toggle) → on?; continuous orbit until the next click or key"), +CommandSpec::new("view.visual_style", "Visual style", visual_style) + .icon("style").noundo().doc_only().params("style: shaded_with_edges|shaded|wireframe|shaded_with_hidden_edges → style, index (0..3 in that order)"), +CommandSpec::new("view.grid", "Grid", grid) + .icon("style").noundo().doc_only().params("on?: bool (omitted: toggle) → on?; show the viewport grid"), +CommandSpec::new("view.ground_shadow", "Ground shadow", ground_shadow) + .icon("style").noundo().doc_only().params("on?: bool (omitted: toggle) → on?; show the ground shadow"), +CommandSpec::new("view.navigation_bar", "Navigation bar", navigation_bar) + .icon("pan").noundo().doc_only().params("visible?: bool (omitted: toggle) → visible?; show the navigation bar (window setting)"), CommandSpec::new("view.layout", "Workspace layout", layout) .icon("settings").noundo().doc_only().params("mode: floating|compact → validated workspace layout; display only, never opens a dialog"), CommandSpec::new("view.history_layout", "History layout", history_layout) @@ -123,6 +139,71 @@ fn normal_to(s: &mut Session, p: &Value) -> Result { Ok(json!({"camera": to})) } +// UI-owned settings follow the layout command: validate here, apply in the UI adapter. +fn view_params(p: &Value, cmd: &str, allowed: &[&str]) -> Result<()> { + let object = p.as_object().ok_or_else(|| bad(cmd, "parameters must be an object"))?; + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(bad(cmd, format!("unknown parameter: {key}"))); + } + Ok(()) +} + +fn fit(_s: &mut Session, p: &Value) -> Result { + view_params(p, "view.fit", &[])?; + Ok(json!({})) +} + +fn home(_s: &mut Session, p: &Value) -> Result { + view_params(p, "view.home", &[])?; + Ok(json!({})) +} + +fn tool(_s: &mut Session, p: &Value) -> Result { + let cmd = "view.tool"; + view_params(p, cmd, &["tool"])?; + let tool = p + .get("tool") + .and_then(Value::as_str) + .filter(|v| matches!(*v, "orbit" | "pan" | "zoom" | "none")) + .ok_or_else(|| bad(cmd, "tool must be orbit, pan, zoom or none"))?; + Ok(json!({"tool": tool})) +} + +fn visual_style(_s: &mut Session, p: &Value) -> Result { + let cmd = "view.visual_style"; + view_params(p, cmd, &["style"])?; + let style = p.get("style").and_then(Value::as_str); + let index = match style { + Some("shaded_with_edges") => 0, + Some("shaded") => 1, + Some("wireframe") => 2, + Some("shaded_with_hidden_edges") => 3, + _ => return Err(bad(cmd, "style must be shaded_with_edges, shaded, wireframe or shaded_with_hidden_edges")), + }; + Ok(json!({"style": style, "index": index})) +} + +fn view_toggle(p: &Value, cmd: &str, key: &str) -> Result { + view_params(p, cmd, &[key])?; + match p.get(key) { + Some(value) => value.as_bool().map(|on| json!({key: on})).ok_or_else(|| bad(cmd, format!("{key} must be a boolean"))), + None => Ok(json!({})), + } +} + +fn spin(_s: &mut Session, p: &Value) -> Result { + view_toggle(p, "view.spin", "on") +} +fn grid(_s: &mut Session, p: &Value) -> Result { + view_toggle(p, "view.grid", "on") +} +fn ground_shadow(_s: &mut Session, p: &Value) -> Result { + view_toggle(p, "view.ground_shadow", "on") +} +fn navigation_bar(_s: &mut Session, p: &Value) -> Result { + view_toggle(p, "view.navigation_bar", "visible") +} + fn layout(_s: &mut Session, p: &Value) -> Result { let mode = p .get("mode") @@ -291,6 +372,48 @@ mod tests { use solidcraft_geom::Vec3; use solidcraft_render::UpAxis; + #[test] + fn view_bar_commands_validate_json_without_editing_design_or_history() { + let mut s = Session::default(); + let before = s.doc.to_json(); + for id in ["view.fit", "view.home"] { + assert_eq!(s.execute(id, &json!({})).unwrap(), json!({})); + assert_eq!(s.execute(id, &Value::Null).unwrap(), json!({})); + assert!(s.execute(id, &json!({"unexpected": true})).is_err()); + } + for tool in ["orbit", "pan", "zoom", "none"] { + assert_eq!(s.execute("view.tool", &json!({"tool":tool})).unwrap(), json!({"tool":tool})); + } + for (index, style) in ["shaded_with_edges", "shaded", "wireframe", "shaded_with_hidden_edges"].into_iter().enumerate() { + assert_eq!(s.execute("view.visual_style", &json!({"style":style})).unwrap(), json!({"style":style,"index":index})); + } + for (id, key) in [("view.tool", "tool"), ("view.visual_style", "style")] { + assert!(s.execute(id, &json!({})).is_err()); + assert!(s.execute(id, &Value::Null).is_err()); + for value in [json!("unknown"), json!(""), json!(0), json!(true), Value::Null, json!([]), json!({})] { + assert!(s.execute(id, &json!({key:value})).is_err(), "{id}: {value}"); + } + } + for (id, key) in [("view.spin", "on"), ("view.grid", "on"), ("view.ground_shadow", "on"), ("view.navigation_bar", "visible")] { + assert_eq!(s.execute(id, &json!({})).unwrap(), json!({})); + assert_eq!(s.execute(id, &Value::Null).unwrap(), json!({})); + for on in [false, true] { + assert_eq!(s.execute(id, &json!({key:on})).unwrap(), json!({key:on})); + } + for value in [Value::Null, json!(0), json!("true"), json!([]), json!({})] { + assert!(s.execute(id, &json!({key:value})).is_err(), "{id}: {value}"); + } + } + for id in ["view.fit", "view.home", "view.tool", "view.spin", "view.visual_style", "view.grid", "view.ground_shadow", "view.navigation_bar"] { + for params in [json!(true), json!(1), json!("orbit"), json!([]), json!({"unexpected":true})] { + assert!(s.execute(id, ¶ms).is_err(), "{id}: {params}"); + } + } + assert_eq!(s.doc.to_json(), before); + assert!(s.undo.is_empty()); + assert!(s.camera.is_none()); + } + #[test] fn look_at_planes_uses_the_first_supported_selection_and_flips_on_repeat() { let original = Camera { yaw: 0.3, pitch: 0.4, roll: 0.7, fov: 0.6, distance: 123.0, ..Default::default() }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 84d5f95421..424f22480e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -226,7 +226,15 @@ fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result if r.is_ok() && !matches!( id, - "view.layout" + "view.fit" + | "view.home" + | "view.tool" + | "view.spin" + | "view.visual_style" + | "view.grid" + | "view.ground_shadow" + | "view.navigation_bar" + | "view.layout" | "view.navigate" | "view.ambient_occlusion" | "view.perspective" diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 5ad69880d9..a3c1c4260d 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -1088,7 +1088,7 @@ fn fit_and_home_frame_the_flat_pattern() { app.flat_view.cam.pan(120.0, -80.0, 600.0); app.flat_view.cam.zoom_at(0.3, None); assert_ne!(app.flat_view.cam, fitted); - app.animate_view(view); + app.run(if view == "fit" { "view.fit" } else { "view.home" }, json!({})).unwrap(); frame(&mut app, &ctx, &mut t, none, vec![]); assert!( app.flat_view.cam.target.dist(fitted.target) < 1e-9 && (app.flat_view.cam.distance - fitted.distance).abs() < 1e-9, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a5392ad9e..dc07d49b92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -307,6 +307,7 @@ pub struct UiState { pub show_browser: bool, pub show_timeline: bool, pub show_grid: bool, + pub show_navigation_bar: bool, pub perspective: bool, /// Perspective while orbiting, orthographic when the camera faces a standard axis view /// (#2682). Off by default; when on it decides the projection instead of `perspective`. @@ -341,6 +342,7 @@ impl Default for UiState { show_browser: true, show_timeline: true, show_grid: true, + show_navigation_bar: true, perspective: false, auto_ortho_faces: false, palette_open: false, @@ -799,6 +801,7 @@ impl SolveApp { json!({ "dark": self.ui.dark, "grid": self.ui.show_grid, + "navigation_bar": self.ui.show_navigation_bar, "perspective": self.ui.perspective, "auto_ortho_faces": self.ui.auto_ortho_faces, "pick_bodies": self.ui.pick_bodies, @@ -835,6 +838,9 @@ impl SolveApp { if let Some(g) = flag("grid") { self.ui.show_grid = g; } + if let Some(visible) = flag("navigation_bar") { + self.ui.show_navigation_bar = visible; + } if let Some(p) = flag("perspective") { self.ui.perspective = p; } @@ -915,6 +921,32 @@ impl SolveApp { /// Host effects shared by directly executed and already evaluated commands. pub(crate) fn command_completed(&mut self, id: &str, r: Result) -> Result { if let Ok(v) = &r { + match id { + "view.fit" | "view.home" => self.frame_view(id == "view.home"), + "view.tool" => { + self.viewport.nav = match v.get("tool").and_then(Value::as_str) { + Some("orbit") => Some(viewport::NavMode::Orbit), + Some("pan") => Some(viewport::NavMode::Pan), + Some("zoom") => Some(viewport::NavMode::Zoom), + _ => None, + }; + } + "view.spin" => { + self.cancel_view_animation(); + self.viewport.spin = v.get("on").and_then(Value::as_bool).unwrap_or(!self.viewport.spin); + } + "view.visual_style" => { + if let Some(index) = v.get("index").and_then(Value::as_u64).and_then(|n| u8::try_from(n).ok()) { + self.ui.visual_style = index; + } + } + "view.grid" => self.ui.show_grid = v.get("on").and_then(Value::as_bool).unwrap_or(!self.ui.show_grid), + "view.ground_shadow" => self.ui.ground_shadow = v.get("on").and_then(Value::as_bool).unwrap_or(!self.ui.ground_shadow), + "view.navigation_bar" => { + self.ui.show_navigation_bar = v.get("visible").and_then(Value::as_bool).unwrap_or(!self.ui.show_navigation_bar) + } + _ => {} + } if id == "view.layout" { let layout = if v.get("mode").and_then(Value::as_str) == Some("compact") { shell::LayoutMode::Docked } else { shell::LayoutMode::Floating }; @@ -1127,6 +1159,10 @@ impl SolveApp { return; } // A display toggle leaves the current modelling tool and camera in place. + if matches!(id, "view.fit" | "view.home" | "view.spin" | "view.grid" | "view.ground_shadow" | "view.navigation_bar") { + let _ = self.run(id, json!({})); + return; + } if id == "view.layout" { let mode = if self.ui.layout == shell::LayoutMode::Floating { "compact" } else { "floating" }; let _ = self.run(id, json!({"mode":mode})); @@ -1726,17 +1762,29 @@ impl SolveApp { /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { - if render_workspace::result_active(self) && matches!(v, "fit" | "home") { + if matches!(v, "fit" | "home") { + let _ = self.run(if v == "fit" { "view.fit" } else { "view.home" }, json!({})); + } else if let Some(view) = StandardView::parse(v) { + self.pending_fit = None; + let mut to = self.cam; + to.set_view(view); + self.animate_to(to); + } + } + + /// Apply view.fit / view.home to the active canvas, retaining each canvas's framing policy. + fn frame_view(&mut self, home: bool) { + if render_workspace::result_active(self) { render_workspace::fit_result(self); return; } // The flat pattern is a 2D view: Fit and Home frame it, and the Design camera stays put. - if dialogs_sheet::flat_active(self) && matches!(v, "fit" | "home") { + if dialogs_sheet::flat_active(self) { self.flat_view.fitted = false; return; } - if render_workspace::active(self) && matches!(v, "fit" | "home") { - if v == "home" { + if render_workspace::active(self) { + if home { self.cam.set_view(StandardView::Iso); } self.fit_view(); @@ -1744,27 +1792,22 @@ impl SolveApp { } self.pending_fit = None; let mut to = self.cam; - match v { + if home { + to.set_view(StandardView::Iso); + self.pending_fit = Some((to, viewport::view_bounds(self), true)); + to = self.fitted(to); + } else { // With something selected, Fit frames the selection. - "fit" => match viewport::selection_bounds(self) { - Some(b) => { - self.pending_fit = Some((to, b, true)); - to = self.fitted_bounds(to, &b); + match viewport::selection_bounds(self) { + Some(bounds) => { + self.pending_fit = Some((to, bounds, true)); + to = self.fitted_bounds(to, &bounds); } None => { self.pending_fit = Some((to, viewport::view_bounds(self), true)); to = self.fitted(to); } - }, - "home" => { - to.set_view(StandardView::Iso); - self.pending_fit = Some((to, viewport::view_bounds(self), true)); - to = self.fitted(to); } - v => match StandardView::parse(v) { - Some(sv) => to.set_view(sv), - None => return, - }, } let request = self.pending_fit.take(); self.animate_to(to); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 0bb647dd55..3af5ecf1df 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -755,6 +755,7 @@ struct Snapshot { dark: bool, layout: crate::shell::LayoutMode, grid: bool, + navigation_bar: bool, shadow: bool, perspective: bool, style: u8, @@ -772,6 +773,7 @@ impl Snapshot { dark: app.ui.dark, layout: app.ui.layout, grid: app.ui.show_grid, + navigation_bar: app.ui.show_navigation_bar, shadow: app.ui.ground_shadow, perspective: app.ui.perspective, style: app.ui.visual_style, @@ -795,6 +797,7 @@ impl Snapshot { app.ui.dark = self.dark; app.ui.layout = self.layout; app.ui.show_grid = self.grid; + app.ui.show_navigation_bar = self.navigation_bar; app.ui.ground_shadow = self.shadow; app.ui.perspective = self.perspective; app.ui.visual_style = self.style; @@ -973,6 +976,7 @@ fn defaults(app: &mut SolveApp, page: usize) { PAGE_INTERFACE => { app.ui.dark = true; app.ui.layout = app.preferences.layout; + app.ui.show_navigation_bar = true; } PAGE_AI => app.ui.agent_cursor = Default::default(), PAGE_NAVIGATION | PAGE_DESIGN => {} diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 9890c62fa9..a7fbecc805 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -465,9 +465,13 @@ fn saved_camera_survives_frames_and_product_fit_ignores_selection() { let bounds = solidcraft_engine::product_scene::extract(&h.app.session).unwrap().bounds; let mut expected = camera; expected.fit(&bounds); - h.app.animate_view("fit"); + h.call("engine.execute", json!({"command":"view.fit","params":{}})); h.frames(3); assert_eq!(h.app.cam, expected); + expected.set_view(solidcraft_engine::render::StandardView::Iso); + expected.fit(&bounds); + h.call("engine.execute", json!({"command":"view.home","params":{}})); + assert_eq!(h.app.cam, expected); enter(&mut h.app, false).unwrap(); assert_eq!(h.app.cam, design); } @@ -1073,9 +1077,12 @@ fn result_navigation_uses_shared_pan_zoom_and_leaves_product_camera_unchanged() assert!(crate::navigation::view_key(&mut h.app, &ctx, Key::ArrowRight, Modifiers::CTRL, false)); assert_ne!(h.app.render_workspace.view.result_camera.target, before_key); assert!(!crate::navigation::view_key(&mut h.app, &ctx, Key::ArrowRight, Modifiers::NONE, false)); - h.app.animate_view("home"); - nav_frame(&mut h.app, &ctx, true, &mut time, Modifiers::NONE, vec![]); - assert_eq!(h.app.render_workspace.view.result_camera, fitted); + for command in ["view.fit", "view.home"] { + h.app.render_workspace.view.result_camera.target.x += 20.0; + h.app.run(command, json!({})).unwrap(); + nav_frame(&mut h.app, &ctx, true, &mut time, Modifiers::NONE, vec![]); + assert_eq!(h.app.render_workspace.view.result_camera, fitted); + } assert_eq!(h.app.cam, preview); assert!(h.app.ui.perspective); assert_eq!(serde_json::to_value(&h.app.render_workspace.view.draft).unwrap(), draft); diff --git a/crates/ui-egui/src/view_home_tests.rs b/crates/ui-egui/src/view_home_tests.rs index 4b694de5d8..685d592176 100644 --- a/crates/ui-egui/src/view_home_tests.rs +++ b/crates/ui-egui/src/view_home_tests.rs @@ -142,3 +142,171 @@ fn middle_double_click_preference_fit_or_off() { h.settle(); assert!(same_cam(&h.app.cam, &moved), "Off does nothing"); } + +#[test] +fn view_bar_json_commands_apply_ui_state_without_dialogs_or_undo() { + use crate::viewport::NavMode; + let mut h = Harness::new(); + moved_block(&mut h); + let design = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let camera = h.app.cam; + for (tool, mode) in [("orbit", Some(NavMode::Orbit)), ("pan", Some(NavMode::Pan)), ("zoom", Some(NavMode::Zoom)), ("none", None)] { + let r = h.call("engine.execute", json!({"command":"view.tool","params":{"tool":tool}})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.viewport.nav, mode); + } + for (index, style) in ["shaded_with_edges", "shaded", "wireframe", "shaded_with_hidden_edges"].into_iter().enumerate() { + let r = h.call("engine.execute", json!({"command":"view.visual_style","params":{"style":style}})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(usize::from(h.app.ui.visual_style), index); + } + for id in ["view.spin", "view.grid", "view.ground_shadow", "view.navigation_bar"] { + let key = if id == "view.navigation_bar" { "visible" } else { "on" }; + let state = |app: &crate::SolveApp| match id { + "view.spin" => app.viewport.spin, + "view.grid" => app.ui.show_grid, + "view.ground_shadow" => app.ui.ground_shadow, + _ => app.ui.show_navigation_bar, + }; + for on in [false, true] { + h.app.run(id, json!({key:on})).unwrap(); + assert_eq!(state(&h.app), on); + h.app.run(id, json!({key:on})).unwrap(); + assert_eq!(state(&h.app), on, "explicit is idempotent"); + h.app.run(id, json!({})).unwrap(); + assert_eq!(state(&h.app), !on); + } + let before = state(&h.app); + for value in [json!(null), json!("false"), json!(0)] { + assert!(h.app.run(id, json!({key:value})).is_err()); + assert_eq!(state(&h.app), before); + } + } + h.app.run("view.spin", json!({"on":false})).unwrap(); + assert_eq!(h.app.cam, camera, "display commands must not trigger the control adapter's first-body fit"); + for (id, params) in [("view.tool", json!({"tool":"bad"})), ("view.visual_style", json!({"style":2}))] { + let state = (h.app.viewport.nav, h.app.ui.visual_style); + assert!(h.app.run(id, params).is_err()); + assert_eq!((h.app.viewport.nav, h.app.ui.visual_style), state); + } + assert!(h.app.dialog.is_none() && h.app.tool.is_none()); + assert_eq!(h.app.session.doc.to_json(), design); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn navigation_bar_visibility_defaults_and_round_trips() { + let mut h = Harness::new(); + moved_block(&mut h); + assert!(h.app.ui.show_navigation_bar); + assert!(crate::scenario::rect_of("nav:outer").is_some()); + h.call("engine.execute", json!({"command":"view.navigation_bar","params":{"visible":false}})); + assert!(!h.app.ui.show_navigation_bar); + assert!(!h.app.viewport.layout.as_ref().unwrap().navigation.is_positive()); + assert!(crate::scenario::rect_of("nav:outer").is_none()); + let mut restored = crate::SolveApp::new(Default::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert!(!restored.ui.show_navigation_bar); + let mut old = crate::SolveApp::new(Default::default(), Default::default()); + old.load_prefs("{\"grid\":false}"); + assert!(old.ui.show_navigation_bar); + h.app.start("view.navigation_bar"); + h.settle(); + assert!(h.app.ui.show_navigation_bar); + assert!(crate::scenario::rect_of("nav:outer").is_some()); +} + +#[test] +fn navigation_bar_buttons_run_registered_commands() { + let mut h = Harness::new(); + moved_block(&mut h); + let undo = h.app.session.undo.len(); + let mut orbit_on = false; + for (index, id) in [ + (0, "view.tool"), + (0, "view.tool"), + (2, "view.tool"), + (3, "view.tool"), + (4, "view.fit"), + (6, "view.home"), + (8, "view.visual_style"), + (1, "view.spin"), + ] { + let bar = crate::scenario::rect_of("nav:outer").unwrap(); + let at = bar.left_top() + egui::vec2(18.0 + index as f32 * 30.0, 15.0); + let log = h.app.session.log.len(); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + let label = solidcraft_engine::find_command(id).unwrap().label; + let echo = crate::i18n::Locale::for_language(&h.app.preferences.language).format("ui.command_done", &[("command", label)]); + assert!(h.app.session.log.iter().skip(log).any(|line| line == &echo), "{id} was not run"); + if index == 0 { + orbit_on = !orbit_on; + assert_eq!(h.app.viewport.nav, if orbit_on { Some(crate::viewport::NavMode::Orbit) } else { None }); + } + } + assert_eq!(h.app.ui.visual_style, 1); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn fit_and_home_json_commands_preserve_design_framing() { + let mut h = Harness::new(); + moved_block(&mut h); + let design = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + for command in ["view.fit", "view.home"] { + h.app.cam.set_view(StandardView::Front); + h.app.cam.pan(300.0, 200.0, 500.0); + let direction = (h.app.cam.yaw, h.app.cam.pitch); + let r = h.call("engine.execute", json!({"command":command,"params":{}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.cam.target.dist(Vec3::new(505.0, 5.0, 2.5)) < 5.0); + assert!(h.app.cam.distance < 200.0); + if command == "view.fit" { + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), direction); + } else { + assert!(same_cam(&h.app.cam, &home(&h))); + } + } + assert_eq!(h.app.session.doc.to_json(), design); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn fit_json_frames_selection_while_home_frames_everything() { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Near"})).unwrap(); + h.app.run("solid.box", json!({"corner":[500,0,0],"length":10,"width":10,"height":10,"body_name":"Far"})).unwrap(); + h.settle(); + h.app.run("select.set", json!({"items":[{"type":"body","name":"Near"}]})).unwrap(); + let design = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let r = h.call("engine.execute", json!({"command":"view.fit","params":{}})); + assert_eq!(r["ok"], true, "{r}"); + let selected = h.app.cam; + assert!(selected.target.dist(Vec3::new(5.0, 5.0, 5.0)) < 5.0); + let r = h.call("engine.execute", json!({"command":"view.home","params":{}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.cam.distance > selected.distance * 10.0); + assert!(same_cam(&h.app.cam, &home(&h))); + assert_eq!(h.app.session.doc.to_json(), design); + assert_eq!(h.app.session.undo.len(), undo); +} + +#[test] +fn navigation_bar_display_menu_runs_grid_and_shadow_commands() { + let mut h = Harness::new(); + moved_block(&mut h); + h.step(&json!({"click":{"handle":"nav:view"}})).unwrap(); + for (label, id) in [("Grid", "view.grid"), ("Ground shadow", "view.ground_shadow")] { + let before = if id == "view.grid" { h.app.ui.show_grid } else { h.app.ui.ground_shadow }; + let log = h.app.session.log.len(); + h.step(&json!({"widget":label})).unwrap(); + let after = if id == "view.grid" { h.app.ui.show_grid } else { h.app.ui.ground_shadow }; + assert_eq!(after, !before); + let echo = crate::i18n::Locale::for_language(&h.app.preferences.language).format("ui.command_done", &[("command", label)]); + assert!(h.app.session.log.iter().skip(log).any(|line| line == &echo), "{id} was not run"); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 945a284d31..99068efd19 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -4874,8 +4874,15 @@ pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { // OK and Cancel in the view's corner (#6709) take the top right; the axis view goes below. let corner = crate::view_confirm::size(app); let content = if app.ui.layout == crate::shell::LayoutMode::Docked { rect } else { content }; - app.viewport.layout = - Some(crate::viewport_layout::Layout::with_content(rect, content, &docks, scale, app.preferences.navigator_style, corner)); + app.viewport.layout = Some(crate::viewport_layout::Layout::with_content( + rect, + content, + &docks, + scale, + app.preferences.navigator_style, + corner, + app.ui.show_navigation_bar, + )); } } @@ -5058,7 +5065,7 @@ pub fn look_at_selection(app: &mut SolveApp) { } fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { - if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) { + if !app.ui.show_navigation_bar || app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) { return; } let t = Tokens::get(); @@ -5125,19 +5132,33 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } icons::paint_icon(&painter, br.shrink(4.0), icon, f32::from(t.icon.a()) / 255.0); if resp.on_hover_text(*tip).clicked() { - let toggle = |m: NavMode, cur: Option| if cur == Some(m) { None } else { Some(m) }; match *icon { - "orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav), - "pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav), - "zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav), - "fit" => app.animate_view("fit"), - "spin" => { - app.cancel_view_animation(); - app.viewport.spin = !app.viewport.spin; + "orbit" | "pan" | "zoom" => { + let selected = matches!( + (*icon, app.viewport.nav), + ("orbit", Some(NavMode::Orbit)) | ("pan", Some(NavMode::Pan)) | ("zoom", Some(NavMode::Zoom)) + ); + let _ = app.run("view.tool", json!({"tool": if selected { "none" } else { *icon }})); + } + "fit" => { + let _ = app.run("view.fit", json!({})); + } + "spin" => { + let _ = app.run("view.spin", json!({})); + } + "home" => { + let _ = app.run("view.home", json!({})); } - "home" => app.animate_view("home"), "lookat" => look_at_selection(app), - "style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4, + "style" => { + let style = match app.ui.visual_style { + 0 => "shaded", + 1 => "wireframe", + 2 => "shaded_with_hidden_edges", + _ => "shaded_with_edges", + }; + let _ = app.run("view.visual_style", json!({"style": style})); + } "perspective" => app.ui.perspective = !app.ui.perspective, _ => menu_open = !menu_open, } @@ -5170,8 +5191,14 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { ui.label(app.tr("ui.graphics_preset")); crate::prefs::graphics_preset_selector(app, ui); ui.separator(); - crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Grid"), &mut app.ui.show_grid); - crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Ground shadow"), &mut app.ui.ground_shadow); + let mut grid = app.ui.show_grid; + if crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Grid"), &mut grid).changed() { + let _ = app.run("view.grid", json!({"on": grid})); + } + let mut shadow = app.ui.ground_shadow; + if crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Ground shadow"), &mut shadow).changed() { + let _ = app.run("view.ground_shadow", json!({"on": shadow})); + } let auto_ortho = crate::ui_kit::checkbox(ui, crate::i18n::tr_label("Perspective while orbiting, ortho on faces"), &mut app.ui.auto_ortho_faces); crate::scenario::publish_handle("view:auto_ortho_faces", auto_ortho.rect.center()); diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index 73455694e5..e2d3ef62de 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -52,13 +52,21 @@ pub(crate) fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { impl Layout { /// The whole viewport as content, without the OK and Cancel pair. #[cfg(test)] - pub fn new(viewport: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle) -> Self { - Self::with_content(viewport, viewport, docks, scale, style, egui::Vec2::ZERO) + pub fn new(viewport: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle, show_navigation_bar: bool) -> Self { + Self::with_content(viewport, viewport, docks, scale, style, egui::Vec2::ZERO, show_navigation_bar) } /// `corner` is the size of the OK and Cancel pair (#6709; zero while it is not shown): it is /// held in the top right corner of the content, and the axis view moves down below it. - pub fn with_content(viewport: Rect, content: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle, corner: egui::Vec2) -> Self { + pub fn with_content( + viewport: Rect, + content: Rect, + docks: &[Rect], + scale: f32, + style: crate::prefs::NavigatorStyle, + corner: egui::Vec2, + show_navigation_bar: bool, + ) -> Self { let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; let mut obstacles: Vec<_> = docks.iter().take(14).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect(); // Navigation is anchored to the viewport, never to transient dialog space. @@ -84,7 +92,7 @@ impl Layout { let nav_space = content; let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0); let nav_height = (10.0 / columns).ceil() * 30.0; - let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 { + let navigation = if show_navigation_bar && nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 { Rect::from_center_size(pos2(nav_space.center().x, nav_space.bottom() - nav_height * 0.5 - 7.0), vec2(columns * 30.0 + 10.0, nav_height)) } else { Rect::NOTHING diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index 7aff63fc6b..7e0e7b71d7 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -9,7 +9,7 @@ fn thin_rotated_bounds_fit_using_the_original_full_projection() { for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] { let vp = Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(width, height - 30.0)); let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max); - let layout = Layout::new(vp, &[dock], scale, crate::prefs::NavigatorStyle::Axes); + let layout = Layout::new(vp, &[dock], scale, crate::prefs::NavigatorStyle::Axes, true); assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}"); assert!(!layout.drawable.intersect(dock).is_positive()); @@ -97,12 +97,12 @@ fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_vie #[test] fn hostile_or_empty_fit_keeps_the_existing_camera_valid() { let cam = Camera::default(); - let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes); + let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes, true); assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none()); let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) }; assert!(fit_bounds(cam, &bad, &layout, true).is_none()); let mut app = crate::SolveApp::new(Default::default(), Default::default()); - let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes); + let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0, crate::prefs::NavigatorStyle::Axes, true); app.viewport.layout = Some(tiny); let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) }; assert_eq!(app.fitted_bounds(cam, &bounds), cam); @@ -253,7 +253,7 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() { fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() { for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] { let vp = Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(width, height - 30.0)); - let baseline = Layout::new(vp, &[], scale, crate::prefs::NavigatorStyle::Axes); + let baseline = Layout::new(vp, &[], scale, crate::prefs::NavigatorStyle::Axes, true); assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive()); assert_eq!(baseline.axis_view.right_top(), vp.right_top()); assert_eq!(baseline.navigation.center().x, vp.center().x); @@ -265,7 +265,7 @@ fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() { vec![vp], ]; for docks in cases { - let measured = Layout::new(vp, &docks, scale, crate::prefs::NavigatorStyle::Axes); + let measured = Layout::new(vp, &docks, scale, crate::prefs::NavigatorStyle::Axes, true); assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}"); assert_eq!(measured.navigation, baseline.navigation, "{docks:?}"); assert!(vp.contains_rect(measured.axis_view)); @@ -319,8 +319,8 @@ fn the_corner_pair_takes_the_top_right_and_the_view_navigator_moves_below_it() { for (width, height) in [(1400.0, 900.0), (700.0, 450.0), (310.0, 240.0), (160.0, 160.0), (90.0, 50.0)] { let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height)); let content = Rect::from_min_max(pos2(vp.left() + width * 0.25, vp.top()), vp.max); - let plain = Layout::with_content(vp, content, &[], 1.0, style, egui::Vec2::ZERO); - let held = Layout::with_content(vp, content, &[], 1.0, style, corner); + let plain = Layout::with_content(vp, content, &[], 1.0, style, egui::Vec2::ZERO, true); + let held = Layout::with_content(vp, content, &[], 1.0, style, corner, true); assert!(!plain.confirm.is_positive(), "{plain:?}"); assert_eq!(held.navigation, plain.navigation, "the navigation bar never moves"); assert!(!held.axis_view.intersect(held.navigation).is_positive(), "{held:?}"); @@ -346,6 +346,7 @@ fn the_corner_pair_takes_the_top_right_and_the_view_navigator_moves_below_it() { 1.0, crate::prefs::NavigatorStyle::Axes, corner, + true, ); assert!(roomy.confirm.is_positive() && roomy.axis_view.is_positive(), "{roomy:?}"); } @@ -389,8 +390,8 @@ fn tiny_viewports_never_overlap_navigation_and_axis_view() { for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] { for height in [30.0, 80.0, 130.0, 160.0, 240.0] { let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height)); - let clear = Layout::new(vp, &[], 1.0, crate::prefs::NavigatorStyle::Axes); - let covered = Layout::new(vp, &[vp], 1.0, crate::prefs::NavigatorStyle::Axes); + let clear = Layout::new(vp, &[], 1.0, crate::prefs::NavigatorStyle::Axes, true); + let covered = Layout::new(vp, &[vp], 1.0, crate::prefs::NavigatorStyle::Axes, true); assert_eq!(clear.axis_view, covered.axis_view); assert_eq!(clear.navigation, covered.navigation); assert!(!clear.axis_view.intersect(clear.navigation).is_positive()); diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index d58abdd35d..c4e39c25d2 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -194,7 +194,22 @@ pub const SECTIONS: &[Section] = &[ tab: "VIEW", panel: "VIEWS", promoted: 2, - ids: &["view.apply", "view.save", "view.rename", "view.delete", "view.list", "view.navigate"], + ids: &[ + "view.apply", + "view.save", + "view.rename", + "view.delete", + "view.list", + "view.navigate", + "view.fit", + "view.home", + "view.tool", + "view.spin", + "view.visual_style", + "view.grid", + "view.ground_shadow", + "view.navigation_bar", + ], }, Section { tab: "VIEW", panel: "SELECT", promoted: 0, ids: &[] }, Section { diff --git a/docs/roadmap-detail.md b/docs/roadmap-detail.md index bab5e7ec01..786de1f14e 100644 --- a/docs/roadmap-detail.md +++ b/docs/roadmap-detail.md @@ -385,6 +385,8 @@ completed drawings publish atomically. The browser still exports synchronously. to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views. +- Navigation bar overhaul (#6585): the viewport's Fit, Home, camera tools, Spin, visual style, Grid, Ground + shadow and bar visibility are registered `view.*` commands, and Fit/Home follow the active canvas. ## Known limitations