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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 12:09:31 +00:00
1 parent bfa70f005b
commit c99e7e41ec
7 files changed
+420 -9

No files matched your search

+21 -2
View File
@@ -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());
+3
View File
@@ -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);
+13
View File
@@ -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;
}
+369
View File
@@ -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:?}");
}
}
+1 -1
View File
@@ -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…", "") {
+12 -6
View File
@@ -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();
+1
View File
@@ -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` | — | |