Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
408 lines
18 KiB
Rust
408 lines
18 KiB
Rust
//! 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,
|
||
}
|
||
|
||
/// A new, empty design takes the preferred units (not an undo step, not a change to save).
|
||
pub fn apply_new_design(app: &mut SolveApp) {
|
||
if app.session.doc.features.is_empty() && app.session.doc.units != app.preferences.default_units {
|
||
let saved = !app.session.is_dirty();
|
||
if app.session.execute("document.units", &json!({ "units": app.preferences.default_units })).is_ok() {
|
||
app.session.undo.pop();
|
||
if saved {
|
||
app.session.mark_saved();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
|
||
/// Units per millimetre and the unit's name.
|
||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||
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"),
|
||
}
|
||
}
|
||
|
||
/// A length in millimetres in `units`, with `decimals` (for display).
|
||
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
|
||
let (k, u) = unit_scale(units);
|
||
format!("{:.*} {u}", usize::from(decimals.min(8)), mm * k)
|
||
}
|
||
|
||
/// A length, area (`power` 2) or volume (3) given in millimetres, shown in the design's units
|
||
/// with the length precision.
|
||
pub fn show_mm(app: &SolveApp, v: f64, power: i32) -> String {
|
||
let (k, u) = unit_scale(&app.session.doc.units);
|
||
let sup = match power {
|
||
2 => "²",
|
||
3 => "³",
|
||
_ => "",
|
||
};
|
||
format!("{:.*} {u}{sup}", usize::from(app.preferences.length_decimals.min(8)), v * k.powi(power))
|
||
}
|
||
|
||
/// Numbers in millimetres, in the design's units without the unit name.
|
||
pub fn show_mm_bare(app: &SolveApp, v: f64) -> String {
|
||
let (k, _) = unit_scale(&app.session.doc.units);
|
||
format!("{:.*}", usize::from(app.preferences.length_decimals.min(8)), v * 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("Autosave every");
|
||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||
set_autosave(app);
|
||
}
|
||
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:?}");
|
||
}
|
||
}
|