forked from 1b-agent/fieldcraft-upstream
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
f074e10442
commit
3aab588b8b
8 files changed
+480
-20
No files matched your search
@@ -42,6 +42,8 @@ pub struct Services {
|
||||
pub pick_save: Option<PickSave>,
|
||||
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
|
||||
pub recent_path: Option<PathBuf>,
|
||||
/// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only.
|
||||
pub prefs_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<String>, offered: Rc<RefCell<Vec<Vec<Filter>>>>) -> PickOpen {
|
||||
Box::new(move |f: &[Filter]| {
|
||||
|
||||
@@ -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<egui::Rect>,
|
||||
/// What the current drag does to the camera, chosen when it starts.
|
||||
pub drag: Option<NavMode>,
|
||||
/// The point the current orbit turns about (drawn as a marker while orbiting).
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// 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<Vec<fieldcraft_engine::progress::StageState>>),
|
||||
/// 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<Vec3> {
|
||||
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<selection::Item> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<NavMode> {
|
||||
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<NavMode> {
|
||||
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<NavMode>)]); 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));
|
||||
}
|
||||
}
|
||||
+102
-16
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user