Navigation bar: the view commands (#6585) (#6586)

* claim: #6585

* Navigation bar: the view commands (#6585)
This commit is contained in:
Nathan Dane
2026-10-26 17:46:15 +00:00
committed by GitHub
parent 97cce94a1c
commit fbcd4852c1
13 changed files with 491 additions and 53 deletions
@@ -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": "导航栏"
}
}
+123
View File
@@ -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<Value> {
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<Value> {
view_params(p, "view.fit", &[])?;
Ok(json!({}))
}
fn home(_s: &mut Session, p: &Value) -> Result<Value> {
view_params(p, "view.home", &[])?;
Ok(json!({}))
}
fn tool(_s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
view_toggle(p, "view.spin", "on")
}
fn grid(_s: &mut Session, p: &Value) -> Result<Value> {
view_toggle(p, "view.grid", "on")
}
fn ground_shadow(_s: &mut Session, p: &Value) -> Result<Value> {
view_toggle(p, "view.ground_shadow", "on")
}
fn navigation_bar(_s: &mut Session, p: &Value) -> Result<Value> {
view_toggle(p, "view.navigation_bar", "visible")
}
fn layout(_s: &mut Session, p: &Value) -> Result<Value> {
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, &params).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() };
+9 -1
View File
@@ -226,7 +226,15 @@ fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String>
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"
+1 -1
View File
@@ -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,
+61 -18
View File
@@ -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<Value, String>) -> Result<Value, String> {
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);
+4
View File
@@ -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 => {}
+11 -4
View File
@@ -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);
+168
View File
@@ -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");
}
}
+42 -15
View File
@@ -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<NavMode>| 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());
+12 -4
View File
@@ -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
+10 -9
View File
@@ -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());
+16 -1
View File
@@ -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 {
+2
View File
@@ -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