Preferences window (File › Preferences): theme, units of new designs, recovery copies every N minutes (to a Recovery folder beside the preferences), mouse presets (Fusion, SolidWorks, Inventor), reverse zoom, orbit around the point under the cursor, grid, perspective, visual style, display precision, anti-aliasing (on restart), sketch and selection options; all kept between runs; ui.prefs control method
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
bfa70f005b
commit
c99e7e41ec
7 files changed
+420
-9
No files matched your search
@@ -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<std::path::PathBuf> {
|
||||
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::<serde_json::Value>(&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<u16> = 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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
||||
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
|
||||
pub graphics_path: Option<String>,
|
||||
}
|
||||
|
||||
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::<prefs::Prefs>(p.clone()).ok()) {
|
||||
self.preferences = p;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NavMode> {
|
||||
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<Pos2>) {
|
||||
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<serde_json::Value, String> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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…", "") {
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct ViewportState {
|
||||
pub hover_feature: Option<u64>,
|
||||
/// Bodies whose browser row is under the cursor: highlighted the same way.
|
||||
pub hover_bodies: Vec<String>,
|
||||
/// The model point an orbit turns around (Preferences: orbit around the cursor).
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
@@ -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();
|
||||
|
||||
@@ -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` | — | |
|
||||
|
||||
Reference in new issue
Block a user