mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
@@ -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": "导航栏"
|
||||
}
|
||||
}
|
||||
@@ -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, ¶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() };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user