From c99e7e41ecd7544535315fadbefd067c4fea1039 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 11:06:06 +0000 Subject: [PATCH] =?UTF-8?q?Preferences=20window=20(File=20=E2=80=BA=20Pref?= =?UTF-8?q?erences):=20theme,=20units=20of=20new=20designs,=20recovery=20c?= =?UTF-8?q?opies=20every=20N=20minutes=20(to=20a=20Recovery=20folder=20bes?= =?UTF-8?q?ide=20the=20preferences),=20mouse=20presets=20(Fusion,=20SolidW?= =?UTF-8?q?orks,=20Inventor),=20reverse=20zoom,=20orbit=20around=20the=20p?= =?UTF-8?q?oint=20under=20the=20cursor,=20grid,=20perspective,=20visual=20?= =?UTF-8?q?style,=20display=20precision,=20anti-aliasing=20(on=20restart),?= =?UTF-8?q?=20sketch=20and=20selection=20options;=20all=20kept=20between?= =?UTF-8?q?=20runs;=20ui.prefs=20control=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft/src/main.rs | 23 +- crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/lib.rs | 13 ++ crates/ui-egui/src/prefs.rs | 369 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 2 +- crates/ui-egui/src/viewport.rs | 18 +- docs/control-protocol.md | 1 + 7 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/prefs.rs diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 8a7f8973..0354712d 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -58,9 +58,27 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str, exts: &[&str]| { rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string()) })), + graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()), } } +/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the +/// window opens). +fn graphics_path() -> Option { + eframe::storage_dir("SolveCraft").map(|d| d.join("graphics.json")) +} + +/// Anti-aliasing samples from Preferences (4 when unset). +fn msaa() -> u16 { + graphics_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64)) + .and_then(|m| u16::try_from(m).ok()) + .filter(|m| [1, 2, 4, 8].contains(m)) + .unwrap_or(MSAA) +} + /// Windows and Linux: no OS title bar; the application bar is the title bar /// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); @@ -69,6 +87,7 @@ const DEPTH_BITS: u8 = 24; const MSAA: u16 = 4; fn main() -> eframe::Result { + let samples = msaa(); let mut control_port: Option = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut sample = false; @@ -100,7 +119,7 @@ fn main() -> eframe::Result { .with_title_shown(false) .with_app_id("ai.storyteller.solvecraft"), depth_buffer: DEPTH_BITS, - multisampling: MSAA, + multisampling: samples, ..Default::default() }; eframe::run_native( @@ -114,7 +133,7 @@ fn main() -> eframe::Result { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, u32::from(MSAA)); + app.set_wgpu(rs, DEPTH_BITS, u32::from(samples)); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1c208131..529fa895 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -24,6 +24,8 @@ //! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists) //! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears) //! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics +//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa, +//! length_decimals, angle_decimals); returns them all //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -376,6 +378,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})), } } + "ui.prefs" => wrap(crate::prefs::set(app, p)), "ui.help" => match s("item") { Some(i @ ("about" | "shortcuts" | "report")) => { crate::help::run(app, ctx, i); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7093c52..f56ea553 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod keymap; mod menu_tests; pub mod palette; pub mod params_dialog; +pub mod prefs; #[cfg(test)] mod preselect_tests; pub mod preview; @@ -129,6 +130,8 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + /// Where the host reads the anti-aliasing preference at start (a small JSON file). + pub graphics_path: Option, } pub struct SolveApp { @@ -161,6 +164,9 @@ pub struct SolveApp { pub keymap: keymap::Keymap, /// The Help menu and the About window. pub help: help::HelpState, + /// Preferences kept between runs, and their window. + pub preferences: prefs::Prefs, + pub prefs_window: prefs::PrefsWindow, pub preview: preview::PreviewState, /// The last command started interactively (id, label), for Repeat. pub last_command: Option<(String, String)>, @@ -215,6 +221,8 @@ impl SolveApp { sbox: shortcut_box::ShortcutBox::default(), keymap: keymap::Keymap::default(), help: help::HelpState::default(), + preferences: prefs::Prefs::default(), + prefs_window: prefs::PrefsWindow::default(), preview: preview::PreviewState::default(), last_command: None, esc_handled: false, @@ -262,6 +270,7 @@ impl SolveApp { "shortcut_box": self.sbox.prefs(), "shortcuts": self.keymap.prefs(), "autosave_minutes": self.autosave_minutes, + "preferences": self.preferences, }) .to_string() } @@ -311,6 +320,9 @@ impl SolveApp { if let Some(s) = v.get("shortcuts") { self.keymap.load(s); } + if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::(p.clone()).ok()) { + self.preferences = p; + } } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -698,6 +710,7 @@ impl SolveApp { } shortcut_box::show(self, ui.ctx()); keymap::show(self, ui.ctx()); + prefs::show(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs new file mode 100644 index 00000000..e13cd688 --- /dev/null +++ b/crates/ui-egui/src/prefs.rs @@ -0,0 +1,369 @@ +//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new +//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display +//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything +//! applies at once and is kept between runs, except anti-aliasing, which needs a restart. + +use egui::{Modifiers, Pos2, RichText}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::viewport::NavMode; + +pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; +/// Mouse mappings: (id, label). +pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")]; +pub const MSAA: [u32; 4] = [1, 2, 4, 8]; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Prefs { + /// Units of new designs. + pub default_units: String, + /// Mouse mapping: `fusion`, `solidworks` or `inventor`. + pub nav: String, + /// The wheel zooms out when turned forward. + pub zoom_reverse: bool, + /// Orbit around the point under the cursor (else around the view's centre). + pub orbit_cursor: bool, + /// Anti-aliasing samples (applies on restart). + pub msaa: u32, + /// Decimals shown for lengths and angles. + pub length_decimals: u8, + pub angle_decimals: u8, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + default_units: "mm".into(), + nav: "fusion".into(), + zoom_reverse: false, + orbit_cursor: false, + msaa: 4, + length_decimals: 2, + angle_decimals: 1, + } + } +} + +/// The preferences window. +#[derive(Default)] +pub struct PrefsWindow { + pub open: bool, + section: usize, +} + +/// The autosave interval follows `app.autosave_minutes` (0: off). +pub fn set_autosave(app: &mut SolveApp) { + let m = app.autosave_minutes; + if let Some(a) = app.autosave.as_mut() { + a.interval = if m > 0.0 { std::time::Duration::from_secs_f64(m * 60.0) } else { std::time::Duration::from_secs(u64::MAX / 4) }; + } +} + +/// Which navigation a mouse drag does under the preset. +pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option { + if secondary && !middle { + return Some(NavMode::Orbit); + } + if !middle { + return None; + } + let ctrl = mods.ctrl || mods.command; + match app.preferences.nav.as_str() { + // Middle orbits, Ctrl+middle pans, Shift+middle zooms. + "solidworks" => Some(if ctrl { + NavMode::Pan + } else if mods.shift { + NavMode::Zoom + } else { + NavMode::Orbit + }), + // Middle pans, Shift+middle orbits, Ctrl+middle zooms. + "inventor" => Some(if mods.shift { + NavMode::Orbit + } else if ctrl { + NavMode::Zoom + } else { + NavMode::Pan + }), + // Fusion: middle pans, Shift+middle orbits. + _ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }), + } +} + +/// +1, or −1 when the wheel zooms the other way. +pub fn zoom_sign(app: &SolveApp) -> f64 { + if app.preferences.zoom_reverse { -1.0 } else { 1.0 } +} + +/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around +/// the model point that was under the cursor when the drag began (kept still on screen). +pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option) { + if !app.preferences.orbit_cursor { + app.cam.orbit(dx, dy); + return; + } + let pivot = match app.viewport.orbit_pivot { + Some(p) => p, + None => { + let proj = crate::viewport::projection(app, rect); + let at = press.unwrap_or(rect.center()); + let p = pivot_under(app, &proj, at); + app.viewport.orbit_pivot = Some(p); + p + } + }; + let before = crate::viewport::projection(app, rect).to_screen(pivot); + app.cam.orbit(dx, dy); + let after = crate::viewport::projection(app, rect).to_screen(pivot); + if let (Some(a), Some(b)) = (before, after) { + app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0))); + } +} + +/// The model point under a screen position, or the point at the view's depth. +fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { + use crate::viewport::Hit; + for h in crate::viewport::pick(app, proj, at) { + match h { + Hit::Face { point, .. } | Hit::Vertex { point, .. } => return point, + Hit::Edge { mid, .. } => return mid, + _ => {} + } + } + let (o, d) = proj.ray(at); + let n = app.cam.back(); + let den = d.dot(n); + if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } +} + +/// A length in millimetres in `units`, with `decimals` (for display). +pub fn format_length(mm: f64, units: &str, decimals: u8) -> String { + let (k, u) = match units { + "cm" => (0.1, "cm"), + "m" => (0.001, "m"), + "in" => (1.0 / 25.4, "in"), + "ft" => (1.0 / 304.8, "ft"), + _ => (1.0, "mm"), + }; + format!("{:.*} {u}", usize::from(decimals.min(8)), mm * k) +} + +/// Write the anti-aliasing setting where the host reads it at start (the window's samples can't +/// change while it is open). +fn save_graphics(app: &SolveApp) { + if let Some(path) = &app.services.graphics_path { + let _ = std::fs::write(path, json!({ "msaa": app.preferences.msaa }).to_string()); + } +} + +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if !app.prefs_window.open { + return; + } + let t = Tokens::get(); + let mut open = true; + let sections = ["General", "Navigation", "Display", "Sketch", "Selection"]; + let before = app.preferences.clone(); + egui::Window::new("Preferences") + .id(egui::Id::new("sc_prefs")) + .open(&mut open) + .default_pos(egui::pos2(300.0, 120.0)) + .default_size([560.0, 360.0]) + .show(ctx, |ui| { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(120.0); + for (i, s) in sections.iter().enumerate() { + if ui.selectable_label(app.prefs_window.section == i, *s).clicked() { + app.prefs_window.section = i; + } + } + }); + ui.separator(); + ui.vertical(|ui| { + egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section { + 0 => { + ui.label("Theme"); + ui.horizontal(|ui| { + ui.radio_value(&mut app.ui.dark, true, "Dark"); + ui.radio_value(&mut app.ui.dark, false, "Light"); + }); + ui.end_row(); + ui.label("Units of new designs"); + egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| { + for u in UNITS { + ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u); + } + }); + ui.end_row(); + ui.label("Recovery copy every"); + ui.add(egui::DragValue::new(&mut app.preferences.autosave_minutes).range(0..=120).suffix(" min")); + ui.end_row(); + ui.label(""); + ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim)); + ui.end_row(); + } + 1 => { + ui.label("Mouse"); + egui::ComboBox::from_id_salt("pref_nav") + .selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1)) + .show_ui(ui, |ui| { + for (id, label) in NAV_PRESETS { + if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() { + app.preferences.zoom_reverse = id == "solidworks"; + } + } + }); + ui.end_row(); + ui.label(""); + let how = match app.preferences.nav.as_str() { + "solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms", + "inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms", + _ => "Middle drag pans, Shift+middle orbits", + }; + ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim)); + ui.end_row(); + ui.label("Reverse zoom direction"); + ui.checkbox(&mut app.preferences.zoom_reverse, ""); + ui.end_row(); + ui.label("Orbit around"); + ui.horizontal(|ui| { + ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre"); + ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor"); + }); + ui.end_row(); + } + 2 => { + ui.label("Grid"); + ui.checkbox(&mut app.ui.show_grid, ""); + ui.end_row(); + ui.label("Perspective view"); + ui.checkbox(&mut app.ui.perspective, ""); + ui.end_row(); + ui.label("Visual style"); + let styles = ["Shaded with edges", "Shaded", "Wireframe"]; + egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(2))]).show_ui( + ui, + |ui| { + for (i, s) in styles.iter().enumerate() { + ui.selectable_value(&mut app.ui.visual_style, i as u8, *s); + } + }, + ); + ui.end_row(); + ui.label("Length decimals"); + ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6)); + ui.end_row(); + ui.label("Angle decimals"); + ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6)); + ui.end_row(); + ui.label("Anti-aliasing"); + egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| { + for m in MSAA { + ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")); + } + }); + ui.end_row(); + ui.label(""); + ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim)); + ui.end_row(); + } + 3 => { + ui.label("Project face edges into new sketches"); + let mut auto = app.session.auto_project; + if ui.checkbox(&mut auto, "").changed() { + let _ = app.run("sketch.auto_project", json!({ "value": auto })); + } + ui.end_row(); + } + _ => { + ui.label("Click selects whole bodies"); + ui.checkbox(&mut app.ui.pick_bodies, ""); + ui.end_row(); + } + }); + }); + }); + }); + if app.preferences.msaa != before.msaa { + save_graphics(app); + } + if !open { + app.prefs_window.open = false; + } +} + +/// Set preferences from JSON (control channel); returns them all. +pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result { + let mut cur = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?; + if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) { + for (k, v) in src { + if !o.contains_key(k) { + return Err(format!("unknown preference `{k}`")); + } + o.insert(k.clone(), v.clone()); + } + } + let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?; + if !UNITS.contains(&new.default_units.as_str()) { + return Err(format!("units must be one of {}", UNITS.join(", "))); + } + if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) { + return Err("`nav` must be fusion, solidworks or inventor and `msaa` 1, 2, 4 or 8".into()); + } + let msaa_changed = new.msaa != app.preferences.msaa; + app.preferences = new; + if msaa_changed { + save_graphics(app); + } + serde_json::to_value(&app.preferences).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_map_the_mouse_and_prefs_round_trip() { + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + let shift = Modifiers { shift: true, ..Default::default() }; + let ctrl = Modifiers { ctrl: true, ..Default::default() }; + let none = Modifiers::default(); + assert!(matches!(nav_mode(&app, true, false, none), Some(NavMode::Pan))); + assert!(matches!(nav_mode(&app, true, false, shift), Some(NavMode::Orbit))); + set(&mut app, &json!({"nav": "solidworks"})).unwrap(); + assert!(matches!(nav_mode(&app, true, false, none), Some(NavMode::Orbit))); + assert!(matches!(nav_mode(&app, true, false, ctrl), Some(NavMode::Pan))); + set(&mut app, &json!({"nav": "inventor", "zoom_reverse": true})).unwrap(); + assert!(matches!(nav_mode(&app, true, false, ctrl), Some(NavMode::Zoom))); + assert_eq!(zoom_sign(&app), -1.0); + assert!(matches!(nav_mode(&app, false, true, none), Some(NavMode::Orbit)), "right drag orbits everywhere"); + assert!(set(&mut app, &json!({"nav": "blender"})).is_err()); + assert!(set(&mut app, &json!({"default_units": "furlong"})).is_err()); + assert!(set(&mut app, &json!({"nope": 1})).is_err()); + // Kept between runs. + let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + other.load_prefs(&app.prefs()); + assert_eq!(other.preferences, app.preferences); + assert_eq!(format_length(25.4, "in", 3), "1.000 in"); + } + + #[test] + fn orbiting_around_the_cursor_keeps_that_point_still() { + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + app.run("PrimitiveBox", json!({"length": 40, "width": 40, "height": 40})).unwrap(); + app.preferences.orbit_cursor = true; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + app.fit_view(); + let at = egui::pos2(500.0, 250.0); + let pivot = pivot_under(&app, &crate::viewport::projection(&app, rect), at); + orbit(&mut app, rect, 40.0, 25.0, Some(at)); + let s = crate::viewport::projection(&app, rect).to_screen(pivot).unwrap(); + assert!(s.distance(at) < 2.0, "{s:?}"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index f1e1701a..e48a8daf 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -185,7 +185,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { } ui.separator(); if item(ui, "Preferences…", "") { - app.dialog = Some(crate::dialogs::Dialog::preferences()); + app.prefs_window.open = true; close = true; } if item(ui, "Keyboard Shortcuts…", "") { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 8f85ebfd..f7f7fdd5 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -45,6 +45,8 @@ pub struct ViewportState { pub hover_feature: Option, /// Bodies whose browser row is under the cursor: highlighted the same way. pub hover_bodies: Vec, + /// The model point an orbit turns around (Preferences: orbit around the cursor). + pub orbit_pivot: Option, pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, @@ -1057,7 +1059,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // ---- navigation ---- let h = rect.height().max(1.0) as f64; if inside && scroll != 0.0 { - let f = (-scroll as f64 * 0.0015).exp(); + let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); let anchor = hover.map(|p| { let proj = projection(app, rect); let (o, d) = proj.ray(p); @@ -1071,10 +1073,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let dragging = resp.dragged() || (inside && (middle || secondary_down)); if dragging && delta != egui::Vec2::ZERO { let (dx, dy) = (delta.x as f64, delta.y as f64); - let mode = if middle && (mods.shift) || (secondary_down && !middle) { - Some(NavMode::Orbit) - } else if middle { - Some(NavMode::Pan) + let mode = if middle || secondary_down { + crate::prefs::nav_mode(app, middle, secondary_down, mods) } else if primary_down { app.viewport.nav } else { @@ -1084,12 +1084,18 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.cancel_view_animation(); } match mode { - Some(NavMode::Orbit) => app.cam.orbit(dx, dy), + Some(NavMode::Orbit) => { + let press = ui.input(|i| i.pointer.press_origin()); + crate::prefs::orbit(app, rect, dx, dy, press); + } Some(NavMode::Pan) => app.cam.pan(dx, dy, h), Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None), None => {} } } + if !dragging { + app.viewport.orbit_pivot = None; + } if !app.cam.is_valid() { app.cam = Camera::default(); app.fit_view(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0e3cbb3d..4c62cd5f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -48,6 +48,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | | `ui.resize` | `width`, `height` | | | `app.quit` | — | |