From 3aab588b8b242a1b5562a3908f428f794ed97710 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:02:12 -0400 Subject: [PATCH] Navigation: orbit about the point under the cursor, zoom toward it, presets in the display menu An orbit turns about the model point under the press (the pick ray's first hit on the plot, the mesh or the design), or the centre of what is drawn when it starts on empty space, keeps that point still on screen and marks it while orbiting. The wheel and a pinch zoom toward the point under the cursor; the wheel's delta is read as it arrives, so a notch zooms in full in orthographic and perspective views alike (#273). Mouse presets and reverse zoom come from SolidCraft (prefs.rs NavPreset, NAV_PRESETS; MIT OR Apache-2.0): FieldCraft's own (left drag per the navigation bar) stays the default, Ctrl + left drag from empty space orbits in every preset. They are chosen in the display menu, settable with ui.set and kept in prefs.json beside recent.json (the browser build keeps them for the page's run); an older prefs.json still loads. Fixes #262, fixes #273. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- ATTRIBUTION.md | 1 + apps/fieldcraft-web/src/web.rs | 2 + apps/fieldcraft/src/main.rs | 1 + crates/ui-egui/src/files.rs | 64 +++++++++ crates/ui-egui/src/lib.rs | 83 +++++++++++- crates/ui-egui/src/navigation.rs | 225 +++++++++++++++++++++++++++++++ crates/ui-egui/src/viewport.rs | 118 +++++++++++++--- docs/control-protocol.md | 6 +- 8 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/navigation.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5def8f0..683a40d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -173,6 +173,7 @@ is committed and listed here with its exports. | crates/geometry/src/tagged_mesh.rs (package and part reading, transforms, units, caps) | SolidCraft `crates/io/src/threemf.rs` | MIT OR Apache-2.0 | | crates/render/src/camera.rs, crates/render/src/raster.rs | SolidCraft `crates/render/src/camera.rs`, `raster.rs` | MIT OR Apache-2.0 | | crates/ui-egui/src/gpu.rs, crates/ui-egui/src/theme.rs (tokens and their values), crates/ui-egui/src/control.rs, crates/ui-egui/src/lib.rs (input injection, screenshots), crates/ui-egui/src/toolbar.rs (workspace switcher, tabs, panels with drop-downs, fitting), crates/ui-egui/src/appbar.rs (application bar; the mark is drawn from assets/app-icon/icon-small.svg's coordinates), crates/ui-egui/src/titlebar.rs (caption buttons, drag and resize zones; SolidCraft took it from VectorCraft), crates/ui-egui/src/frame.rs (windows, heading bands), crates/ui-egui/src/browser.rs (row drawing), crates/ui-egui/src/palette.rs (command line and search), crates/ui-egui/src/timeline.rs (timeline strip, transport), crates/ui-egui/src/viewport.rs (navigation bar, orientation gizmo), crates/ui-egui/src/files.rs (background opening, file pickers as host services) | SolidCraft `crates/ui-egui/src/` (titlebar.rs also VectorCraft `crates/ui-egui/src/titlebar.rs`) | MIT OR Apache-2.0 | +| crates/ui-egui/src/navigation.rs (navigation presets and bindings), crates/ui-egui/src/viewport.rs (orbit about the point under the cursor, or the drawn centre from empty space; Ctrl + left drag orbits) | SolidCraft `crates/ui-egui/src/prefs.rs` (`NavPreset`, `NavBinding`, `NAV_PRESETS`, `orbit`) | MIT OR Apache-2.0 | | crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 | | crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | | crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 | diff --git a/apps/fieldcraft-web/src/web.rs b/apps/fieldcraft-web/src/web.rs index 4e816a2..dab0f37 100644 --- a/apps/fieldcraft-web/src/web.rs +++ b/apps/fieldcraft-web/src/web.rs @@ -188,6 +188,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))), // The recent list holds the page's files, which go with the page. recent_path: None, + // Preferences last for the page's run. + prefs_path: None, } } diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 74e2f60..9d1d8af 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -95,6 +95,7 @@ fn services() -> Services { })), // Beside the recovery folder in the per-user data folder. recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))), + prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))), } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 071ae8a..61e7d4e 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -42,6 +42,8 @@ pub struct Services { pub pick_save: Option, /// The recent-files list's file (JSON); `None` keeps the list for this run only. pub recent_path: Option, + /// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only. + pub prefs_path: Option, } const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] }; @@ -164,6 +166,50 @@ impl Recent { } } +/// Largest preferences file read. +const MAX_PREFS_BYTES: u64 = 64 * 1024; + +/// What the window remembers for the user between runs (`prefs.json` beside `recent.json`). +/// Keys it no longer knows (`show_setup`, from before the setup card went) are ignored. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Prefs { + /// The mouse navigation preset ([`crate::navigation::NAV_PRESETS`] id). + pub nav_preset: String, + /// The wheel zooms the other way. + pub reverse_zoom: bool, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { nav_preset: crate::navigation::DEFAULT_PRESET.into(), reverse_zoom: false } + } +} + +impl Prefs { + /// The preferences kept in `file` (an unreadable or malformed file gives the defaults). + pub fn load(file: Option<&Path>) -> Prefs { + file.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES)) + .and_then(|f| std::fs::read(f).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() + } + + /// Write them to `file`; a failure is logged (the preferences stay for this run). + pub fn save(&self, file: Option<&Path>) { + let Some(file) = file else { return }; + let write = || -> std::io::Result<()> { + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(file, serde_json::to_vec_pretty(self).unwrap_or_default()) + }; + if let Err(e) = write() { + log::warn!("preferences {}: {e}", file.display()); + } + } +} + /// A command running in the background on a copy of the session: a file opening, a solve, a /// mesher started from its form. pub struct Loading { @@ -1026,6 +1072,24 @@ mod tests { dir } + /// The navigation preset and the wheel's direction are remembered in the preferences file; + /// an older file with keys no longer used still loads, a malformed one gives the defaults. + #[test] + fn navigation_preferences_are_remembered() { + let file = temp("prefs").join("prefs.json"); + std::fs::write(&file, br#"{"show_setup": false}"#).unwrap(); + assert_eq!(Prefs::load(Some(&file)), Prefs::default()); + let services = || Services { prefs_path: Some(file.clone()), ..Default::default() }; + let mut app = ModelApp::new(Session::default()).with_services(services()); + app.ui.nav_preset = "solidworks".into(); + app.ui.reverse_zoom = true; + app.save_prefs(); + let again = ModelApp::new(Session::default()).with_services(services()); + assert_eq!((again.ui.nav_preset.as_str(), again.ui.reverse_zoom), ("solidworks", true)); + std::fs::write(&file, b"{nope").unwrap(); + assert_eq!(Prefs::load(Some(&file)), Prefs::default()); + } + /// A picker that answers `path` and records the filters it was offered. fn answering(path: Option, offered: Rc>>>) -> PickOpen { Box::new(move |f: &[Filter]| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 67707b0..aab5088 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ pub mod frame; pub mod framing; pub mod gpu; pub mod icons; +pub mod navigation; pub mod palette; pub mod reply; pub mod results; @@ -55,7 +56,7 @@ use std::sync::mpsc::{Receiver, Sender}; use fieldcraft_engine::catalogue::{self, Tab, Workspace}; use fieldcraft_engine::{EngineError, Session}; use fieldcraft_render::glyphs::Glyphs; -use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView}; +use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView, Vec3}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -87,6 +88,10 @@ pub struct UiState { pub show_tree: bool, /// The journal timeline (bottom). pub show_timeline: bool, + /// The mouse navigation preset ([`navigation::NAV_PRESETS`] id; remembered per user). + pub nav_preset: String, + /// The wheel zooms the other way (remembered per user). + pub reverse_zoom: bool, /// The workspace shown, and its toolbar tab. pub workspace: Workspace, pub tab: Tab, @@ -110,6 +115,8 @@ impl Default for UiState { dark: true, show_tree: true, show_timeline: true, + nav_preset: navigation::DEFAULT_PRESET.into(), + reverse_zoom: false, workspace: Workspace::default(), tab: Tab::default(), palette: false, @@ -254,6 +261,10 @@ pub struct Viewport { /// The overlays drawn over the viewport last frame (legend, selection bar, gizmo, navigation /// bar): a fit leaves them free ([`framing::free_part`]). pub overlays: Vec, + /// What the current drag does to the camera, chosen when it starts. + pub drag: Option, + /// The point the current orbit turns about (drawn as a marker while orbiting). + pub orbit_pivot: Option, } /// A probed node: its values as lines of text, for the step, mode and frame they were read from. @@ -352,6 +363,8 @@ pub struct ModelApp { /// Recently opened and saved files. pub recent: files::Recent, /// The user's preferences as last saved. + prefs: files::Prefs, + /// The user's preferences as last saved. /// The setup progress the browser marks, and what it was worked out for. setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. @@ -431,6 +444,7 @@ impl ModelApp { quit_requested: false, services: files::Services::default(), recent: files::Recent::default(), + prefs: files::Prefs::default(), setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), @@ -463,6 +477,9 @@ impl ModelApp { /// Use the host's services (file pickers, the recent-files file) and load the recent list. pub fn with_services(mut self, services: files::Services) -> Self { self.recent = files::Recent::load(services.recent_path.clone()); + self.prefs = files::Prefs::load(services.prefs_path.as_deref()); + self.ui.nav_preset = self.prefs.nav_preset.clone(); + self.ui.reverse_zoom = self.prefs.reverse_zoom; self.services = services; self } @@ -950,6 +967,32 @@ impl ModelApp { Some(fieldcraft_render::pick::Projector::new(&self.cam, f64::from(rect.width()), f64::from(rect.height()), radius)) } + /// The nearest drawn point under window point `at`: the pick ray's first hit on the results + /// plot when one shows, else on the shown elements' faces or the design's surfaces. + pub fn surface_point(&mut self, at: egui::Pos2) -> Option { + let rect = self.viewport.rect.filter(|r| r.contains(at))?; + let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height())); + let t = if let Some(plot) = self.plot() { + plot.display.hit_element_face(o, d).map(|h| h.t) + } else { + let (mesh, geometry) = (self.ui.show_mesh, self.ui.show_geometry); + let display = self.display(); + let faces = mesh.then(|| display.hit_element_face(o, d)).flatten().map(|h| h.t); + let surface = geometry.then(|| display.hit_surface(o, d)).flatten().map(|h| h.t); + faces.into_iter().chain(surface).min_by(f64::total_cmp) + }?; + let p = o + d * t; + p.is_finite().then_some(p) + } + + /// What an orbit that starts on empty space turns about: the centre of what is drawn (the + /// target when nothing is). + pub fn drawn_centre(&mut self) -> Vec3 { + self.scene(); + let b = self.drawn_bounds(); + if b.is_empty() || !b.center().is_finite() { self.cam.target } else { b.center() } + } + /// What a click at window point `at` would pick with the current filter. pub fn pick_at(&mut self, at: egui::Pos2) -> Option { let rect = self.viewport.rect.filter(|r| r.contains(at))?; @@ -1325,6 +1368,7 @@ impl ModelApp { if self.custom_titlebar { titlebar::resize_zones(ui); } + self.save_prefs(); } /// The setup progress ([`fieldcraft_engine::progress`]) the browser marks, worked out again @@ -1338,6 +1382,14 @@ impl ModelApp { self.setup.1.clone() } + /// Save the preferences the window changed (a View menu toggle, `ui.set`). + pub(crate) fn save_prefs(&mut self) { + if self.ui.nav_preset != self.prefs.nav_preset || self.ui.reverse_zoom != self.prefs.reverse_zoom { + self.prefs = files::Prefs { nav_preset: self.ui.nav_preset.clone(), reverse_zoom: self.ui.reverse_zoom }; + self.prefs.save(self.services.prefs_path.as_deref()); + } + } + /// Remember where a region of the window was drawn (`ui.inspect`'s `layout`). pub fn place(&mut self, region: &'static str, rect: egui::Rect) { self.layout.insert(region, rect); @@ -2103,4 +2155,33 @@ mod tests { assert_eq!(app.status, Some(Status { text: "Graphics: Out of Memory".into(), error: true })); assert!(app.viewport.gpu_errors.lock().unwrap().is_none(), "each error is shown once"); } + + /// An orbit pivots on the model point under the press (the pick ray's first hit, on the + /// face toward the eye) and keeps it still on screen; from empty space it pivots on the + /// centre of what is drawn (#262). + #[test] + fn orbit_pivots_on_the_point_under_the_cursor_or_the_drawn_centre() { + let corners = [[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10]]; + let mut app = ModelApp::new(Session::default()); + app.run("mesh.block", json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [2, 2, 2]})).unwrap(); + app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0))); + app.set_view(StandardView::Right); + app.fit_view(); + // The right view looks along −X: the face under the cursor is x = 10. + let on = Vec3::new(10.0, 7.0, 3.0); + let at = viewport::to_screen(&app, on).unwrap(); + let pivot = app.surface_point(at).unwrap(); + assert!(pivot.dist(on) < 1e-6, "{pivot:?}"); + app.cam.orbit_about(pivot, 60.0, -30.0); + let after = viewport::to_screen(&app, pivot).unwrap(); + assert!((after - at).length() < 1e-3, "{at:?} -> {after:?}"); + // Empty space: the centre of the drawn bounds. + let pivot = app.drawn_centre(); + assert!(pivot.dist(Vec3::new(5.0, 5.0, 5.0)) < 1e-6, "{pivot:?}"); + assert!(app.surface_point(egui::pos2(105.0, 55.0)).is_none()); + // Nothing drawn: the target. + let mut empty = ModelApp::new(Session::default()); + empty.cam.target = Vec3::new(1.0, 2.0, 3.0); + assert_eq!(empty.drawn_centre(), Vec3::new(1.0, 2.0, 3.0)); + } } diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs new file mode 100644 index 0000000..2637a9b --- /dev/null +++ b/crates/ui-egui/src/navigation.rs @@ -0,0 +1,225 @@ +//! Mouse navigation presets: which mouse button, with which keys held, orbits, pans or zooms. +//! +//! The presets after FieldCraft's own follow SolidCraft's `crates/ui-egui/src/prefs.rs` +//! (`NavPreset`, `NavBinding`, `NAV_PRESETS`; MIT OR Apache-2.0), see ATTRIBUTION.md. In every +//! preset a left drag the preset does not bind does what the navigation bar's mode says (orbit +//! by default), and a Ctrl (Cmd) + left drag that starts on empty space orbits, unless the +//! preset binds Ctrl + left itself (SolidCraft #471). + +use crate::NavMode; + +/// A mouse button that can drag the camera. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NavButton { + Left, + Middle, + Right, +} + +/// Modifier keys a binding needs held (others may be held too). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct NavKeys { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, +} + +impl NavKeys { + const NONE: NavKeys = NavKeys { shift: false, ctrl: false, alt: false }; + const SHIFT: NavKeys = NavKeys { shift: true, ctrl: false, alt: false }; + const CTRL: NavKeys = NavKeys { shift: false, ctrl: true, alt: false }; + const SHIFT_ALT: NavKeys = NavKeys { shift: true, ctrl: false, alt: true }; + + /// All of these keys are held (Ctrl is Cmd on macOS). + fn held(self, m: egui::Modifiers) -> bool { + (!self.shift || m.shift) && (!self.ctrl || m.ctrl || m.command) && (!self.alt || m.alt) + } +} + +/// One camera binding: dragging `button` with `keys` held does `mode`. +#[derive(Clone, Copy, Debug)] +pub struct NavBinding { + pub button: NavButton, + pub keys: NavKeys, + pub mode: NavMode, +} + +const fn bind(button: NavButton, keys: NavKeys, mode: NavMode) -> NavBinding { + NavBinding { button, keys, mode } +} + +/// A mouse mapping: its bindings, most specific first (the first one whose button is dragged +/// with all its keys held wins). +pub struct NavPreset { + pub id: &'static str, + pub label: &'static str, + /// What the mouse does, for the settings menu. + pub help: &'static str, + /// Choosing the preset turns the wheel the other way. + pub reverse_zoom: bool, + pub bindings: &'static [NavBinding], +} + +impl NavPreset { + /// What dragging `button` with `mods` held does, if the preset binds it. + pub fn mode(&self, button: NavButton, mods: egui::Modifiers) -> Option { + self.bindings.iter().find(|b| b.button == button && b.keys.held(mods)).map(|b| b.mode) + } +} + +use NavButton::{Left, Middle, Right}; +use NavMode::{Orbit, Pan, Zoom}; + +/// The id of the default preset. +pub const DEFAULT_PRESET: &str = "fieldcraft"; + +/// The mouse mappings. The first is the default. +pub const NAV_PRESETS: [NavPreset; 7] = [ + NavPreset { + id: DEFAULT_PRESET, + label: "FieldCraft", + help: "Left drag does the navigation bar's mode (orbit, pan or zoom); right, middle or Shift+left drag pans", + reverse_zoom: false, + bindings: &[bind(Left, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Pan), bind(Middle, NavKeys::NONE, Pan)], + }, + NavPreset { + id: "fusion", + label: "Fusion", + help: "Middle drag pans, Shift+middle orbits, right drag orbits", + reverse_zoom: false, + bindings: &[bind(Middle, NavKeys::SHIFT, Orbit), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], + }, + NavPreset { + id: "solidworks", + label: "SolidWorks", + help: "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", + reverse_zoom: true, + bindings: &[ + bind(Middle, NavKeys::CTRL, Pan), + bind(Middle, NavKeys::SHIFT, Zoom), + bind(Middle, NavKeys::NONE, Orbit), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "inventor", + label: "Inventor", + help: "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", + reverse_zoom: false, + bindings: &[ + bind(Middle, NavKeys::SHIFT, Orbit), + bind(Middle, NavKeys::CTRL, Zoom), + bind(Middle, NavKeys::NONE, Pan), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "alias", + label: "Alias", + help: "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits", + reverse_zoom: false, + bindings: &[ + bind(Left, NavKeys::SHIFT_ALT, Orbit), + bind(Middle, NavKeys::SHIFT_ALT, Pan), + bind(Right, NavKeys::SHIFT_ALT, Zoom), + bind(Middle, NavKeys::CTRL, Zoom), + bind(Middle, NavKeys::NONE, Pan), + bind(Right, NavKeys::NONE, Orbit), + ], + }, + NavPreset { + id: "tinkercad", + label: "Tinkercad", + help: "Right drag orbits, middle or Shift+right drag pans", + reverse_zoom: false, + bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, Pan)], + }, + NavPreset { + id: "openscad", + label: "OpenSCAD", + help: "Left drag orbits; right or middle drag pans, Shift+right or Shift+middle drag zooms", + reverse_zoom: false, + bindings: &[ + bind(Left, NavKeys::NONE, Orbit), + bind(Right, NavKeys::SHIFT, Zoom), + bind(Middle, NavKeys::SHIFT, Zoom), + bind(Right, NavKeys::NONE, Pan), + bind(Middle, NavKeys::NONE, Pan), + ], + }, +]; + +/// The preset with this id (the default for an unknown one). +pub fn preset(id: &str) -> &'static NavPreset { + NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) +} + +/// What a drag of `button` with `mods` held does under `preset`: the preset's binding; else, for +/// the left button, an orbit when only Ctrl (Cmd) is held and the drag starts off the model, +/// nothing in box selection (the drag draws the box), and the navigation bar's mode `bar` +/// otherwise. `None`: the drag does not move the camera. +pub fn drag_mode(preset: &NavPreset, button: NavButton, mods: egui::Modifiers, bar: NavMode, boxing: bool, on_model: bool) -> Option { + if let Some(m) = preset.mode(button, mods) { + return Some(m); + } + if button != Left { + return None; + } + let ctrl_only = NavKeys::CTRL.held(mods) && !mods.shift && !mods.alt; + if ctrl_only && !on_model { + Some(Orbit) + } else if boxing { + None + } else { + Some(bar) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::Modifiers; + + const SHIFT: Modifiers = Modifiers::SHIFT; + const CTRL: Modifiers = Modifiers::CTRL; + const NONE: Modifiers = Modifiers::NONE; + + /// Each preset maps its buttons and keys as its help says. + #[test] + fn presets_bind_as_listed() { + let shift_alt = Modifiers { shift: true, alt: true, ..NONE }; + let cases: [(&str, &[(NavButton, Modifiers, Option)]); 7] = [ + ("fieldcraft", &[(Left, SHIFT, Some(Pan)), (Right, NONE, Some(Pan)), (Middle, NONE, Some(Pan)), (Left, NONE, None)]), + ("fusion", &[(Middle, NONE, Some(Pan)), (Middle, SHIFT, Some(Orbit)), (Right, NONE, Some(Orbit)), (Left, NONE, None)]), + ("solidworks", &[(Middle, NONE, Some(Orbit)), (Middle, CTRL, Some(Pan)), (Middle, SHIFT, Some(Zoom)), (Right, NONE, Some(Orbit))]), + ("inventor", &[(Middle, NONE, Some(Pan)), (Middle, SHIFT, Some(Orbit)), (Middle, CTRL, Some(Zoom)), (Right, NONE, Some(Orbit))]), + ("alias", &[(Left, shift_alt, Some(Orbit)), (Middle, shift_alt, Some(Pan)), (Right, shift_alt, Some(Zoom)), (Middle, NONE, Some(Pan))]), + ("tinkercad", &[(Right, NONE, Some(Orbit)), (Right, SHIFT, Some(Pan)), (Middle, NONE, Some(Pan)), (Left, NONE, None)]), + ("openscad", &[(Left, NONE, Some(Orbit)), (Right, NONE, Some(Pan)), (Right, SHIFT, Some(Zoom)), (Middle, SHIFT, Some(Zoom))]), + ]; + for (id, binds) in cases { + let p = preset(id); + assert_eq!(p.id, id); + for &(button, mods, want) in binds { + assert_eq!(p.mode(button, mods), want, "{id}: {button:?} {mods:?}"); + } + } + assert_eq!(preset("no such preset").id, DEFAULT_PRESET); + assert!(preset("solidworks").reverse_zoom); + } + + /// An unbound left drag does the bar's mode; Ctrl+left off the model orbits in every preset + /// (in box selection too); box selection keeps the plain left drag for the box. + #[test] + fn left_drag_follows_the_bar_and_ctrl_orbits_from_empty_space() { + for p in &NAV_PRESETS { + assert_eq!(drag_mode(p, Left, CTRL, Pan, true, false), Some(Orbit), "{}", p.id); + assert_eq!(drag_mode(p, Left, CTRL, Pan, false, true), Some(p.mode(Left, CTRL).unwrap_or(Pan)), "{}", p.id); + } + let fc = preset(DEFAULT_PRESET); + assert_eq!(drag_mode(fc, Left, NONE, Zoom, false, true), Some(Zoom)); + assert_eq!(drag_mode(fc, Left, NONE, Orbit, true, false), None); + assert_eq!(drag_mode(fc, Left, SHIFT, Orbit, false, false), Some(Pan)); + assert_eq!(drag_mode(preset("fusion"), Right, CTRL, Orbit, false, false), Some(Orbit)); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e8b8635..43b240e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1,9 +1,12 @@ //! The 3D viewport: the model drawn on the GPU (or on the CPU without one), camera navigation, //! the navigation bar (bottom centre) and the orientation gizmo (top right), as in SolidCraft. //! -//! Navigation: left drag orbits (or pans or zooms: the navigation bar's modes), right or middle -//! drag (or Shift + left drag) pans, the wheel or a pinch zooms at the cursor, a double click or F -//! frames the model. The gizmo's axis tips turn the view to look along them. The bar and the gizmo +//! Navigation ([`crate::navigation`] presets, chosen in the display menu): by default left drag +//! orbits (or pans or zooms: the navigation bar's modes), right or middle drag (or Shift + left +//! drag) pans, Ctrl + left drag from empty space orbits. An orbit turns about the model point +//! under the press (marked while orbiting), or the centre of what is drawn from empty space. The +//! wheel or a pinch zooms toward the model point under the cursor; a double click or F frames +//! the model. The gizmo's axis tips turn the view to look along them. The bar and the gizmo //! follow SolidCraft's `viewport.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md. //! //! Selection ([`crate::selection`]): what a click would pick is highlighted under the pointer; a @@ -17,6 +20,7 @@ use fieldcraft_render::{StandardView, Vec3}; use crate::gpu::{GpuScene, ViewportCallback}; use crate::icons::{self, Look}; +use crate::navigation::{self, NavButton}; use crate::selection::{Filter, Mode}; use crate::theme::Tokens; use crate::{ModelApp, NavMode, Style}; @@ -34,7 +38,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { let response = ui.allocate_rect(rect, Sense::click_and_drag()); let boxing = app.pick.box_mode && app.pick.filter.boxes(); navigate(app, ui, &response, rect, boxing); - if boxing { + if boxing && app.viewport.drag.is_none() { box_drag(app, ui, &response); } if response.clicked() @@ -57,6 +61,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { } sampled_note(app, &painter, rect, &t); hover_label(app, &painter, &t); + pivot_marker(app, &painter, &t); if let Some((a, b)) = app.pick.dragging { let r = Rect::from_two_pos(a, b); painter.rect(r, 0.0, t.pick_selected.gamma_multiply(0.12), Stroke::new(1.0, t.pick_selected), egui::StrokeKind::Inside); @@ -252,33 +257,98 @@ fn selection_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) app.place("selection_bar", shown.response.rect); } +/// Camera navigation. A drag's effect is chosen when it starts ([`navigation::drag_mode`]: the +/// preset's bindings, the bar's mode for the left button); an orbit turns about the model point +/// under the press, or the centre of what is drawn when it starts on empty space, and keeps that +/// point still on screen. The wheel (raw, so a notch zooms in full on the frame it arrives) and a +/// pinch zoom toward the model point under the cursor, or the point at the target's depth. fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect, boxing: bool) { let (w, h) = (f64::from(rect.width()), f64::from(rect.height())); + let (mods, press) = ui.input(|i| (i.modifiers, i.pointer.press_origin())); + for (pressed, button) in [ + (egui::PointerButton::Primary, NavButton::Left), + (egui::PointerButton::Middle, NavButton::Middle), + (egui::PointerButton::Secondary, NavButton::Right), + ] { + if response.drag_started_by(pressed) { + let at = press.or(response.interact_pointer_pos()); + let preset = navigation::preset(&app.ui.nav_preset); + // The pick is needed only for an orbit's pivot and an unbound Ctrl + left drag. + let picked = preset.mode(button, mods).is_none() && button == NavButton::Left && (mods.ctrl || mods.command); + let hit = if picked { at.and_then(|a| app.surface_point(a)) } else { None }; + app.viewport.drag = navigation::drag_mode(preset, button, mods, app.nav, boxing, hit.is_some()); + app.viewport.orbit_pivot = None; + if app.viewport.drag == Some(NavMode::Orbit) { + let hit = if picked { hit } else { at.and_then(|a| app.surface_point(a)) }; + app.viewport.orbit_pivot = Some(hit.unwrap_or_else(|| app.drawn_centre())); + } + } + } let d = response.drag_delta(); let (dx, dy) = (f64::from(d.x), f64::from(d.y)); - let shift = ui.input(|i| i.modifiers.shift); - // In box selection a left drag draws the box. - let left = response.dragged_by(egui::PointerButton::Primary) && !boxing; - if left && !shift && app.nav == NavMode::Orbit { - app.cam.orbit(dx, dy); - } else if left && !shift && app.nav == NavMode::Zoom { - app.cam.zoom_at((dy * ZOOM_PER_POINT * 4.0).exp(), None); - } else if response.dragged_by(egui::PointerButton::Secondary) || response.dragged_by(egui::PointerButton::Middle) || left { - app.cam.pan(dx, dy, h); + if response.dragged() && (dx != 0.0 || dy != 0.0) { + match app.viewport.drag { + Some(NavMode::Orbit) => match app.viewport.orbit_pivot { + Some(p) => app.cam.orbit_about(p, dx, dy), + None => app.cam.orbit(dx, dy), + }, + Some(NavMode::Pan) => app.cam.pan(dx, dy, h), + Some(NavMode::Zoom) => app.cam.zoom_at((dy * ZOOM_PER_POINT * 4.0 * zoom_sign(app)).exp(), None), + None => {} + } + } + if !response.dragged() { + app.viewport.drag = None; + app.viewport.orbit_pivot = None; } if response.double_clicked() || (response.hovered() && ui.input(|i| i.key_pressed(egui::Key::F) && i.modifiers.is_none())) { app.fit_view(); } if response.hovered() { - let (scroll, pinch, at) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta(), i.pointer.hover_pos())); - let factor = (-f64::from(scroll) * ZOOM_PER_POINT).exp() / f64::from(pinch).max(1e-3); + let options = ui.ctx().options(|o| o.input_options); + let (scroll, pinch, at) = ui.input(|i| (wheel_points(i, &options), i.zoom_delta(), i.pointer.hover_pos())); + let factor = (-f64::from(scroll) * ZOOM_PER_POINT * zoom_sign(app)).exp() / f64::from(pinch).max(1e-3); if (factor - 1.0).abs() > 1e-9 { - let anchor = at.and_then(|p| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h)); + let anchor = at.and_then(|p| { + app.surface_point(p).or_else(|| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h)) + }); app.cam.zoom_at(factor, anchor); } } } +/// This frame's wheel and trackpad scrolling, vertical, in points, as it arrived: egui's smoothed +/// delta spreads a notch over the next frames and loses its tail when no more frames come (a +/// hidden window). Lines count as egui counts them; scrolling with the zoom modifier is egui's +/// zoom ([`egui::InputState::zoom_delta`]) and left out. +fn wheel_points(i: &egui::InputState, options: &egui::InputOptions) -> f32 { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::MouseWheel { unit, delta, modifiers, .. } if !modifiers.matches_any(options.zoom_modifier) => Some(match unit { + egui::MouseWheelUnit::Point => delta.y, + egui::MouseWheelUnit::Line => delta.y * options.line_scroll_speed, + egui::MouseWheelUnit::Page => delta.y * i.viewport_rect().height(), + }), + _ => None, + }) + .filter(|d| d.is_finite()) + .sum() +} + +/// −1 when the wheel is reversed (the preference), else 1. +fn zoom_sign(app: &ModelApp) -> f64 { + if app.ui.reverse_zoom { -1.0 } else { 1.0 } +} + +/// The orbit's pivot, while orbiting: a small ring with a dot. +fn pivot_marker(app: &ModelApp, painter: &egui::Painter, t: &Tokens) { + if let Some(at) = app.viewport.orbit_pivot.and_then(|p| to_screen(app, p)) { + painter.circle_filled(at, 2.5, t.accent); + painter.circle_stroke(at, 6.0, Stroke::new(1.5, t.accent)); + } +} + /// A vertical gradient behind the model. fn gradient(painter: &egui::Painter, rect: Rect, top: Color32, bottom: Color32) { let mut mesh = egui::Mesh::default(); @@ -560,6 +630,22 @@ fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { ui.separator(); ui.checkbox(&mut app.ui.show_tree, "Browser"); ui.checkbox(&mut app.ui.show_timeline, "Timeline"); + ui.separator(); + crate::frame::menu_heading(ui, &t, "NAVIGATION"); + let current = navigation::preset(&app.ui.nav_preset); + egui::ComboBox::from_id_salt("fc_nav_preset") + .selected_text(current.label) + .show_ui(ui, |ui| { + for p in &navigation::NAV_PRESETS { + if ui.selectable_label(p.id == current.id, p.label).on_hover_text(p.help).clicked() { + app.ui.nav_preset = p.id.into(); + app.ui.reverse_zoom = p.reverse_zoom; + } + } + }) + .response + .on_hover_text(current.help); + ui.checkbox(&mut app.ui.reverse_zoom, "Reverse zoom").on_hover_text("The wheel zooms the other way"); }); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d2bb3a0..6bc6eac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,7 +42,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | Method | Params | Notes | |---|---|---| | `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/`), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | -| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | +| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape and the setup's symbols included) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | @@ -54,8 +54,8 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `submit?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | | `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | | `ui.move` | `x`, `y` | | -| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits (with box selection on, it selects the visible nodes, element faces or elements in the box), a right or middle drag (or Shift + left) pans | -| `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport the drag does what the navigation preset (`ui.set {nav_preset}`) binds; by default a left drag orbits about the model point under the press, or the centre of what is drawn from empty space (with box selection on, it selects the visible nodes, element faces or elements in the box; Ctrl + left from empty space still orbits), a right or middle drag (or Shift + left) pans | +| `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms toward the model point under `x`, `y` (the point at the target's depth over empty space), by the same factor per line in orthographic and perspective views; `ui.set {reverse_zoom}` turns it the other way | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model; `Escape` clears the selection (and then cancels an open form) | | `ui.text` | `text` | typed text (into the focused field) | | `ui.drop` | `paths`: 1–16 file paths | drops the files on the window: the next frame opens them as a drop from the file manager does, and as a file picked in the Open dialog does (a `.fieldcraft` model, a `.solidcraft` design, a `.3mf` tagged mesh; `ui.inspect`'s `busy` while they open). The native picker cannot be driven over the channel; this is the same route |