Files
OneCAD/crates/ui-egui/src/prefs.rs
T
bherbruckandClaude Opus 5.5 7249623855 Home and Fit frame what is shown now; middle double-click goes Home (#701)
Home, Fit, the orbit pivot and the scene's depth range counted sketches the view doesn't draw (ones used by an extrude or revolve, ones in hidden components), so after a body moved away from its sketch Home framed the empty spot it came from. They now use the world-space bounds of what is shown now (view::visible_bounds / view::sketch_drawn, viewport::view_bounds). The pick-box cache holds its world state instead of its address. A middle-button double-click goes Home in every preset (Preferences › Navigation: Home, Fit or Off).

Fixes #689

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:33:55 -04:00

1566 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
//! them and stays open, and Restore Defaults resets the page shown. Everything 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 solidcraft_engine::geom::Vec3;
/// The key the apps keep the preferences under in eframe's storage.
pub const STORAGE_KEY: &str = "solidcraft.prefs";
/// The key earlier releases used. Compatibility shim (docs/compatibility.md): read when the
/// current key is missing, never written.
pub const LEGACY_STORAGE_KEY: &str = "solvecraft.prefs";
/// The stored preferences, from the current key or else the legacy one.
pub fn stored(get: impl Fn(&str) -> Option<String>) -> Option<String> {
get(STORAGE_KEY).or_else(|| get(LEGACY_STORAGE_KEY))
}
use crate::SolveApp;
use crate::theme::Tokens;
use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
pub const OPENSCAD_NAV: &str = "openscad";
/// 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: 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 Preferences page.
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 anything.
pub fn mode(&self, button: NavButton, mods: 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 mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 6] = [
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_NAV,
label: "OpenSCAD",
help: "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; 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 nav_preset(id: &str) -> &'static NavPreset {
NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0])
}
/// The dialog's pages, in order.
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
pub const PAGE_SHORTCUTS: usize = 2;
pub const PAGE_DESIGN: usize = 3;
pub const PAGE_GRAPHICS: usize = 4;
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
/// Original view navigators, independent of the mouse navigation preset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavigatorStyle {
#[default]
Axes,
Cube,
}
impl NavigatorStyle {
pub const ALL: [Self; 2] = [Self::Axes, Self::Cube];
pub fn label(self) -> &'static str {
match self {
Self::Axes => "Signed axes",
Self::Cube => "Cube",
}
}
}
#[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,
/// View orientation widget: original signed axes (default) or compact cube.
pub navigator_style: NavigatorStyle,
/// Show the radial ring and enable hold/flick gestures for viewport context menus.
pub radial_context_menu: bool,
/// 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,
/// Whether a connected 3D mouse moves the view.
pub space_mouse_enabled: bool,
/// Speed multiplier for 3D mouse navigation.
pub space_mouse_speed: f32,
/// Anti-aliasing samples (applies on restart).
pub msaa: u32,
/// Decimals shown for lengths and angles.
pub length_decimals: u8,
pub angle_decimals: u8,
/// `dark`, `light` or `system` (follow the operating system).
pub theme: String,
/// Interface language (a registered locale ID).
pub language: String,
/// Interface scale (1 = 100 %).
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool,
/// Spread discrete wheel input across frames (off: apply each event immediately).
pub smooth_wheel_zoom: bool,
/// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms.
pub trackpad_pan: bool,
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
pub look_at_anim: bool,
pub look_at_speed: f32,
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
pub up_axis: String,
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
pub offer_stand_up: bool,
/// What a middle-button double-click in the view does: `home`, `fit` or `off`.
pub middle_double_click: String,
}
/// What a middle-button double-click in the view does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MiddleDoubleClick {
/// Go Home (the iso view, framing what is shown).
Home,
/// Frame what is shown, keeping the orientation.
Fit,
Off,
}
impl MiddleDoubleClick {
pub const ALL: [(MiddleDoubleClick, &'static str, &'static str); 3] =
[(MiddleDoubleClick::Home, "home", "Home"), (MiddleDoubleClick::Fit, "fit", "Fit"), (MiddleDoubleClick::Off, "off", "Off")];
/// The preference's setting (Home for an unknown one).
pub fn of(app: &SolveApp) -> MiddleDoubleClick {
Self::parse(&app.preferences.middle_double_click).unwrap_or(MiddleDoubleClick::Home)
}
pub fn parse(id: &str) -> Option<MiddleDoubleClick> {
Self::ALL.iter().find(|(_, i, _)| *i == id).map(|(m, _, _)| *m)
}
pub fn label(self) -> &'static str {
Self::ALL.iter().find(|(m, _, _)| *m == self).map_or("Home", |(_, _, l)| l)
}
}
impl Default for Prefs {
fn default() -> Self {
Prefs {
default_units: "mm".into(),
nav: "fusion".into(),
navigator_style: NavigatorStyle::default(),
radial_context_menu: false,
zoom_reverse: false,
orbit_cursor: false,
space_mouse_enabled: true,
space_mouse_speed: 1.0,
msaa: 4,
length_decimals: 2,
angle_decimals: 1,
theme: "dark".into(),
language: "en".into(),
ui_scale: 1.0,
zoom_to_cursor: true,
smooth_wheel_zoom: true,
trackpad_pan: true,
look_at_anim: true,
look_at_speed: 1.0,
up_axis: "z".into(),
offer_stand_up: true,
middle_double_click: "home".into(),
}
}
}
/// The preferences window.
#[derive(Default)]
pub struct PrefsWindow {
pub open: bool,
/// The page shown (index into [`PAGES`]).
pub section: usize,
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
snapshot: Option<Snapshot>,
/// The up axis preference the view follows now (applied when it changes, and at startup).
applied_up: Option<String>,
}
/// Open the dialog at a page.
pub fn open_at(app: &mut SolveApp, page: usize) {
app.prefs_window.open = true;
app.prefs_window.section = page.min(PAGES.len() - 1);
}
/// Every setting the dialog changes, for Cancel and Restore Defaults.
#[derive(Clone)]
struct Snapshot {
prefs: Prefs,
dark: bool,
grid: bool,
shadow: bool,
perspective: bool,
style: u8,
pick_bodies: bool,
agent: crate::agent_cursor::Settings,
autosave: f64,
auto_project: bool,
keys: std::collections::BTreeMap<String, String>,
}
impl Snapshot {
fn take(app: &SolveApp) -> Snapshot {
Snapshot {
prefs: app.preferences.clone(),
dark: app.ui.dark,
grid: app.ui.show_grid,
shadow: app.ui.ground_shadow,
perspective: app.ui.perspective,
style: app.ui.visual_style,
pick_bodies: app.ui.pick_bodies,
agent: app.ui.agent_cursor,
autosave: app.autosave_minutes,
auto_project: app.session.auto_project,
keys: app.keymap.custom.clone(),
}
}
fn restore(self, app: &mut SolveApp) {
let msaa = app.preferences.msaa;
app.preferences = self.prefs;
app.ui.dark = self.dark;
app.ui.show_grid = self.grid;
app.ui.ground_shadow = self.shadow;
app.ui.perspective = self.perspective;
app.ui.visual_style = self.style;
app.ui.pick_bodies = self.pick_bodies;
app.ui.agent_cursor = self.agent;
app.autosave_minutes = self.autosave;
set_autosave(app);
app.session.auto_project = self.auto_project;
app.keymap.custom = self.keys;
if app.preferences.msaa != msaa {
save_graphics(app);
}
}
}
/// Restore Defaults for one page.
fn defaults(app: &mut SolveApp, page: usize) {
let d = Prefs::default();
let p = &mut app.preferences;
match page {
0 => {
p.theme = d.theme;
p.language = d.language;
p.ui_scale = d.ui_scale;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
p.angle_decimals = d.angle_decimals;
app.ui.dark = true;
app.ui.agent_cursor = Default::default();
app.autosave_minutes = 5.0;
set_autosave(app);
}
1 => {
p.nav = d.nav;
p.navigator_style = d.navigator_style;
p.radial_context_menu = d.radial_context_menu;
p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor;
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.trackpad_pan = d.trackpad_pan;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
p.look_at_anim = d.look_at_anim;
p.look_at_speed = d.look_at_speed;
p.middle_double_click = d.middle_double_click;
}
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
PAGE_DESIGN => {
p.up_axis = d.up_axis;
p.offer_stand_up = d.offer_stand_up;
}
PAGE_GRAPHICS => {
let msaa = p.msaa != d.msaa;
p.msaa = d.msaa;
app.ui.visual_style = 0;
app.ui.show_grid = true;
app.ui.ground_shadow = true;
app.ui.perspective = false;
if msaa {
save_graphics(app);
}
}
5 => app.session.auto_project = true,
_ => app.ui.pick_bodies = false,
}
}
/// Settings that follow the preferences every frame: the theme when it follows the system, and
/// the interface scale.
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
// The view's up axis follows the preference (set in the dialog, by ui.prefs or loaded).
if app.prefs_window.applied_up.as_deref() != Some(app.preferences.up_axis.as_str()) {
up_axis_changed(app);
}
if app.preferences.theme == "system"
&& let Some(t) = ctx.system_theme()
{
app.ui.dark = t == egui::Theme::Dark;
}
// egui's end-of-frame shortcuts only change its context; the saved preference would
// overwrite that change next frame. Consume the desktop shortcuts into the preference.
if ctx.options(|o| o.zoom_with_keyboard) && !crate::keymap::capturing(app) {
use egui::gui_zoom::kb_shortcuts::{ZOOM_IN, ZOOM_IN_SECONDARY, ZOOM_OUT, ZOOM_RESET};
if ctx.input_mut(|i| i.consume_shortcut(&ZOOM_RESET)) {
app.preferences.ui_scale = 1.0;
} else {
let zoom_in = ctx.input_mut(|i| i.consume_shortcut(&ZOOM_IN) || i.consume_shortcut(&ZOOM_IN_SECONDARY));
let zoom_out = ctx.input_mut(|i| i.consume_shortcut(&ZOOM_OUT));
if zoom_in != zoom_out {
app.preferences.ui_scale = (app.preferences.ui_scale + if zoom_in { 0.1 } else { -0.1 }).clamp(0.5, 2.5);
}
}
}
// Scaling while the pointer is held moves the slider under the pointer, feeding a
// different value back into it. Commit the layout change after the drag ends.
let z = app.preferences.ui_scale.clamp(0.5, 2.5);
if z.is_finite() && !ctx.input(|i| i.pointer.any_down()) && (ctx.zoom_factor() - z).abs() > 1e-3 {
ctx.set_zoom_factor(z);
}
}
/// 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 dragging `button` with `mods` held does under the preset.
pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option<NavMode> {
nav_preset(&app.preferences.nav).mode(button, mods)
}
/// +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). A drag
/// that begins over empty space turns around the centre of the visible geometry instead, so the
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
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. Over empty space: the centre of the visible bodies
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
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 b = crate::viewport::view_bounds(app);
if b.is_empty() { app.cam.target } else { b.center() }
}
/// 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"),
}
}
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
let base = usize::from(decimals.min(8));
if units != "in" {
return base;
}
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
base.max(if needs_four { 4 } else { 3 })
}
pub fn length_number(mm: f64, units: &str, decimals: u8) -> String {
let value = mm * unit_scale(units).0;
format!("{:.*}", length_precision(value, units, decimals), value)
}
/// Edit a millimetre value through a control expressed in the current design units.
/// Keep the stored value untouched unless the user actually changes the field.
pub fn length_drag<'a>(value: &'a mut f64, units: &str, decimals: u8) -> egui::DragValue<'a> {
let (scale, unit) = unit_scale(units);
egui::DragValue::new(value)
.speed(0.5)
.suffix(format!(" {unit}"))
.custom_formatter(move |mm, _| length_number(mm, unit, decimals))
.custom_parser(move |text| parse_length_mm(text, scale))
}
/// A length typed into a field, in millimetres: a bare number is in the design's units (`scale`
/// units per millimetre); an explicit unit ("10 mm", "0.5 in", "2 cm + 3 mm") is honoured.
pub fn parse_length_mm(text: &str, scale: f64) -> Option<f64> {
use solidcraft_engine::doc::expr;
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let defaults = expr::Defaults { len: 1.0 / scale, ..expr::Defaults::default() };
let no_params = |n: &str| Err(solidcraft_engine::doc::DocError::Expr(format!("unknown parameter `{n}`")));
let value = expr::eval_with_defaults(text.trim(), &no_params, defaults).ok()?.to_kind_in(expr::Kind::Length, defaults).ok()?;
value.is_finite().then_some(value)
}
/// A length in millimetres in `units`, with `decimals` (for display).
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
let (_, u) = unit_scale(units);
format!("{} {u}", length_number(mm, units, decimals))
}
/// 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 => "³",
_ => "",
};
let value = v * k.powi(power);
format!("{:.*} {u}{sup}", length_precision(value, &app.session.doc.units, app.preferences.length_decimals), value)
}
/// Numbers in millimetres, in the design's units without the unit name.
pub fn show_mm_bare(app: &SolveApp, v: f64) -> String {
length_number(v, &app.session.doc.units, app.preferences.length_decimals)
}
/// 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());
}
}
/// At start, with the graphics device known: the saved anti-aliasing (`requested` samples, as
/// the host read it) is more than the device supports. The window would not have opened with it,
/// so the renderer fell back; put the setting back to its default (or the largest supported
/// count below it) and tell the user what changed.
pub fn check_msaa(app: &mut SolveApp, requested: u32) {
let supported = &app.msaa_supported;
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
return;
}
let default = Prefs::default().msaa;
let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1);
let supported = supported.clone();
app.preferences.msaa = reset;
save_graphics(app);
app.settings_reset.push(SettingReset::Msaa { requested, reset, supported });
}
/// A setting put back at start because it would keep SolidCraft from starting.
#[derive(Clone, Debug, PartialEq)]
pub enum SettingReset {
/// Anti-aliasing samples the graphics device can't render with.
Msaa { requested: u32, reset: u32, supported: Vec<u32> },
}
impl SettingReset {
/// The line the notice shows, in the current interface language.
pub fn message(&self) -> String {
match self {
SettingReset::Msaa { requested, reset, supported } => {
let setting = format!("{} › {}", crate::i18n::tr("ui.graphics"), crate::i18n::tr("ui.anti_aliasing"));
let supported = supported.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join("×, ") + "×";
crate::i18n::format(
"settings.msaa_reset",
&[("setting", &setting), ("requested", &requested.to_string()), ("reset", &reset.to_string()), ("supported", &supported)],
)
}
}
}
}
/// Settings put back at start (`SolveApp::settings_reset`), until dismissed.
pub fn show_reset_notice(app: &mut SolveApp, ctx: &egui::Context) {
if app.settings_reset.is_empty() {
return;
}
let mut ok = false;
let mut prefs = false;
crate::frame::window(ctx, app.tr("settings.reset_title"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_settings_reset"))
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label(app.tr("settings.reset_intro"));
ui.add_space(4.0);
for reset in &app.settings_reset {
ui.label(format!("• {}", reset.message()));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ok = ui.button(app.tr("ui.heading_ok")).clicked();
prefs = ui.button(app.tr("ui.open_preferences")).clicked();
});
});
if ok || prefs {
app.settings_reset.clear();
}
if prefs {
open_at(app, PAGE_GRAPHICS);
}
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
if app.keymap.open {
app.keymap.open = false;
open_at(app, PAGE_SHORTCUTS);
}
if !app.prefs_window.open {
app.prefs_window.snapshot = None;
return;
}
if app.prefs_window.snapshot.is_none() {
app.prefs_window.snapshot = Some(Snapshot::take(app));
}
let t = Tokens::get();
let mut open = true;
let mut button: Option<&str> = None;
let before = app.preferences.clone();
let shown = crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide)
.id(egui::Id::new("sc_prefs"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
// One fixed size for every page (#290): the page is drawn into a clipped child of
// that size and only that size is allocated, so no page (or translation) can widen
// the window; descriptions wrap and tall pages scroll inside it.
ui.set_max_width(PREFS_W);
let (body, _) = ui.allocate_exact_size(egui::vec2(PREFS_W, PREFS_H), egui::Sense::hover());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::left_to_right(egui::Align::Min)));
child.shrink_clip_rect(body);
let ui_body = &mut child;
ui_body.vertical(|ui| {
ui.set_width(PREFS_NAV_W);
for (i, s) in PAGES.iter().enumerate() {
if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() {
app.prefs_window.section = i;
app.keymap.read_only = false;
}
}
});
ui_body.separator();
ui_body.vertical(|ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0));
ui.add_space(6.0);
if app.prefs_window.section == PAGE_SHORTCUTS {
// The shortcuts table scrolls by itself.
page(app, ui, &t);
} else {
egui::ScrollArea::vertical().id_salt("sc_prefs_page").auto_shrink([false, false]).show(ui, |ui| page(app, ui, &t));
}
});
ui.separator();
// Restore Defaults at the left; OK, Cancel and Apply flush right, as in every dialog.
ui.horizontal(|ui| {
ui.set_width(PREFS_W);
if ui.button(app.tr("ui.restore_defaults")).on_hover_text(app.tr("ui.reset_the_settings_on_this_page")).clicked() {
button = Some("defaults");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Right to left: Apply is the rightmost.
for (key, id) in [("ui.apply", "apply"), ("ui.cancel", "cancel"), ("ui.heading_ok", "ok")] {
let r = ui.button(app.tr(key));
crate::scenario::publish_rect(&format!("prefs:{id}"), r.rect);
if r.clicked() {
button = Some(id);
}
}
});
crate::scenario::publish_rect("prefs:footer", ui.max_rect());
});
});
if let Some(w) = &shown {
crate::scenario::publish_rect("prefs:window", w.response.rect);
}
if app.preferences.msaa != before.msaa {
save_graphics(app);
}
if app.preferences.up_axis != before.up_axis {
up_axis_changed(app);
}
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app);
match button {
Some("defaults") => defaults(app, app.prefs_window.section),
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
_ if !open || esc => close(app, !open || esc),
_ => {}
}
}
/// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×).
fn close(app: &mut SolveApp, revert: bool) {
if let Some(s) = app.prefs_window.snapshot.take()
&& revert
{
let up = app.preferences.up_axis.clone();
s.restore(app);
if app.preferences.up_axis != up {
up_axis_changed(app);
}
}
app.prefs_window.open = false;
app.keymap.read_only = false;
crate::keymap::stop_capture(app);
}
/// The default orientation changed: the view turns to match.
fn up_axis_changed(app: &mut SolveApp) {
use solidcraft_engine::render::UpAxis;
app.prefs_window.applied_up = Some(app.preferences.up_axis.clone());
let up = if app.preferences.up_axis.eq_ignore_ascii_case("y") { UpAxis::Y } else { UpAxis::Z };
if app.ui.up_axis != up || app.cam.up != up {
app.set_up_axis(up);
}
}
/// The Preferences window's content size: the same on every page.
const PREFS_W: f32 = 744.0;
const PREFS_H: f32 = 400.0;
/// The page list at its left.
const PREFS_NAV_W: f32 = 128.0;
/// One page of the dialog.
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let note = |ui: &mut egui::Ui, s: &str| {
ui.label("");
// Descriptions wrap within the page instead of widening it.
ui.add(egui::Label::new(RichText::new(s).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
};
if app.prefs_window.section == PAGE_SHORTCUTS {
crate::keymap::page(app, ui);
return;
}
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
0 => {
ui.label(app.tr("ui.language"));
egui::ComboBox::from_id_salt("pref_language").selected_text(crate::i18n::Locale::for_language(&app.preferences.language).name()).show_ui(
ui,
|ui| {
for locale in crate::i18n::LOCALES {
let (id, label) = (locale.id, locale.name);
ui.selectable_value(&mut app.preferences.language, id.to_string(), label);
}
},
);
ui.end_row();
ui.label(app.tr("ui.theme"));
ui.horizontal(|ui| {
for (id, label) in THEMES {
if ui.radio(app.preferences.theme == id, app.tr_label(label)).clicked() {
app.preferences.theme = id.to_string();
if id != "system" {
app.ui.dark = id == "dark";
}
}
}
});
ui.end_row();
ui.label(app.tr("ui.ui_scale"));
let scale = ui.add(
egui::Slider::new(&mut app.preferences.ui_scale, 0.5..=2.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)),
);
crate::scenario::publish_rect("prefs:scale", scale.rect);
ui.end_row();
ui.label(app.tr("ui.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(app.tr("ui.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();
note(ui, "0 turns it off");
ui.label(app.tr("ui.length_decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
ui.end_row();
ui.label(app.tr("ui.angle_decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
ui.end_row();
ui.label(app.tr("ui.agent_cursor"));
let speed_label = app.tr("ui.agent_cursor_speed");
let s = &mut app.ui.agent_cursor;
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
ui.end_row();
ui.label(speed_label);
ui.add_enabled_ui(s.show, |ui| {
crate::agent_cursor::speed_slider(ui, &mut s.speed);
});
ui.end_row();
ui.label("");
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)"));
ui.end_row();
}
1 => {
ui.label("Pan, zoom, orbit");
egui::ComboBox::from_id_salt("pref_nav").selected_text(nav_preset(&app.preferences.nav).label).show_ui(ui, |ui| {
for p in &NAV_PRESETS {
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.label).clicked() {
app.preferences.zoom_reverse = p.reverse_zoom;
}
}
});
ui.end_row();
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
note(ui, &format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help));
ui.label("Context menu");
ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu");
ui.end_row();
ui.label("View navigator");
let picker =
egui::ComboBox::from_id_salt("pref_navigator_style").selected_text(app.preferences.navigator_style.label()).show_ui(ui, |ui| {
for style in NavigatorStyle::ALL {
let response = ui.selectable_value(&mut app.preferences.navigator_style, style, style.label());
crate::scenario::publish_rect(&format!("prefs:navigator:{}", style.label()), response.rect);
crate::scenario::publish_handle(&format!("prefs:navigator:{}", style.label()), response.rect.center());
}
});
crate::scenario::publish_rect("prefs:navigator", picker.response.rect);
crate::scenario::publish_handle("prefs:navigator", picker.response.rect.center());
ui.end_row();
ui.label("Reverse zoom direction");
ui.checkbox(&mut app.preferences.zoom_reverse, "");
ui.end_row();
ui.label("Zoom to the cursor");
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
ui.end_row();
ui.label("Smooth wheel zoom");
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
.on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail");
ui.end_row();
ui.label("Two-finger scroll pans");
ui.checkbox(&mut app.preferences.trackpad_pan, "").on_hover_text(
"Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms",
);
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();
ui.label("Middle double-click");
let current = MiddleDoubleClick::of(app);
egui::ComboBox::from_id_salt("pref_middle_double_click").selected_text(current.label()).show_ui(ui, |ui| {
for (m, id, label) in MiddleDoubleClick::ALL {
if ui.selectable_label(current == m, label).clicked() {
app.preferences.middle_double_click = id.into();
}
}
})
.response
.on_hover_text("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing");
ui.end_row();
ui.label("Animate view changes");
ui.checkbox(&mut app.preferences.look_at_anim, "Axis view, Look At, Home and Fit");
ui.end_row();
ui.label("Animation speed");
ui.add_enabled(
app.preferences.look_at_anim,
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
ui.label("3D mouse (Windows)");
ui.checkbox(&mut app.preferences.space_mouse_enabled, "Navigate with SpaceNavigator / SpaceMouse");
ui.end_row();
ui.label("3D mouse speed");
ui.add_enabled(
app.preferences.space_mouse_enabled,
egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
note(ui, "Move and rotate the view together; sketches use pan and zoom only.");
}
PAGE_DESIGN => {
ui.label("Default modelling orientation");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up");
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
});
ui.end_row();
note(ui, "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.");
ui.label("Offer to stand up Y-up files");
ui.checkbox(&mut app.preferences.offer_stand_up, "")
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
ui.end_row();
}
4 => {
ui.label("Visual style");
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).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("Grid");
ui.checkbox(&mut app.ui.show_grid, "");
ui.end_row();
ui.label("Ground shadow");
ui.checkbox(&mut app.ui.ground_shadow, "");
ui.end_row();
ui.label("Perspective view");
ui.checkbox(&mut app.ui.perspective, "");
ui.end_row();
ui.label(app.tr("ui.anti_aliasing"));
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
for m in MSAA {
// Counts the graphics device can't do would keep the window from opening.
let ok = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
ui.add_enabled_ui(ok, |ui| ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")))
.inner
.on_disabled_hover_text(app.tr("settings.msaa_unsupported"));
}
});
ui.end_row();
note(ui, app.tr("settings.msaa_applies_on_restart"));
ui.label("Renderer");
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
(Some(_), Some(g)) => format!("GPU: {g}"),
(Some(_), None) => "GPU".to_string(),
_ => "CPU (software)".to_string(),
});
ui.end_row();
}
5 => {
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();
}
});
}
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
/// any other keys set preferences. Returns them all, the open page and the offer.
pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
let mut rest = p.as_object().cloned().unwrap_or_default();
if let Some(page) = rest.remove("open") {
let name = page.as_str().unwrap_or("General");
let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?;
open_at(app, i);
if app.prefs_window.snapshot.is_none() {
app.prefs_window.snapshot = Some(Snapshot::take(app));
}
}
if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) {
crate::stand_up::stand_up(app)?;
}
let button = rest.remove("button");
let mut out = set(app, &serde_json::Value::Object(rest))?;
match button.as_ref().and_then(|b| b.as_str()) {
Some("ok") => close(app, false),
Some("cancel") => close(app, true),
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
Some("defaults") => defaults(app, app.prefs_window.section),
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
None => {}
}
if button.is_some() {
out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
}
if let Some(o) = out.as_object_mut() {
o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default())));
o.insert("stand_up_offer".into(), json!(app.stand_up));
o.insert("dark".into(), json!(app.ui.dark));
}
Ok(out)
}
/// 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(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err("`nav` must be fusion, solidworks, inventor, alias, tinkercad or openscad and `msaa` 1, 2, 4 or 8".into());
}
if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) {
return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported));
}
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
}
if !crate::i18n::is_supported(&new.language) {
return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::<Vec<_>>().join(", ")));
}
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
}
if MiddleDoubleClick::parse(&new.middle_double_click).is_none() {
return Err("`middle_double_click` must be home, fit or off".into());
}
if new.theme != app.preferences.theme && new.theme != "system" {
app.ui.dark = new.theme == "dark";
}
let up_changed = new.up_axis != app.preferences.up_axis;
let msaa_changed = new.msaa != app.preferences.msaa;
app.preferences = new;
if msaa_changed {
save_graphics(app);
}
if up_changed {
up_axis_changed(app);
}
serde_json::to_value(&app.preferences).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
#[test]
fn stored_prefs_fall_back_to_the_legacy_key() {
let only_old = |k: &str| (k == super::LEGACY_STORAGE_KEY).then(|| "old".to_string());
assert_eq!(super::stored(only_old).as_deref(), Some("old"));
let both = |k: &str| Some(if k == super::STORAGE_KEY { "new" } else { "old" }.to_string());
assert_eq!(super::stored(both).as_deref(), Some("new"));
assert_eq!(super::stored(|_| None), None);
}
use super::*;
#[test]
fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() {
let dir = std::env::temp_dir().join(format!("sc_msaa_reset_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("graphics.json");
let services = crate::Services { graphics_path: Some(path.to_string_lossy().to_string()), ..Default::default() };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
// WebGPU's guarantee for the surface format: 8x saved, the device can only do 1x and 4x.
app.msaa_supported = vec![1, 4];
app.preferences.msaa = 8;
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default();
assert_eq!(saved["msaa"], 4);
assert_eq!(app.settings_reset, [SettingReset::Msaa { requested: 8, reset: 4, supported: vec![1, 4] }]);
assert_eq!(
app.settings_reset[0].message(),
"Graphics › Anti-aliasing: 8× isn't supported by this graphics device; it is now 4× (supported: 1×, 4×)."
);
{
let _de = crate::i18n::scope("de");
assert_eq!(
app.settings_reset[0].message(),
"Grafik › Kantenglättung: 8× wird von diesem Grafikgerät nicht unterstützt; jetzt 4× (unterstützt: 1×, 4×)."
);
}
// Below the default when the device can't do that either.
app.settings_reset.clear();
app.msaa_supported = vec![1, 2];
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 2);
// Supported, off, or no GPU: nothing changes (OpenGL's 1x policy isn't a reset either).
for (supported, requested) in [(vec![1, 2, 4, 8], 8), (vec![1, 4], 1), (vec![], 8), (vec![1, 4], 4)] {
app.settings_reset.clear();
app.preferences.msaa = requested;
app.msaa_supported = supported;
check_msaa(&mut app, requested);
assert!(app.settings_reset.is_empty());
assert_eq!(app.preferences.msaa, requested);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn control_channel_rejects_msaa_the_device_cannot_do_and_bad_saved_msaa_loads_as_default() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.msaa_supported = vec![1, 4];
assert!(set(&mut app, &serde_json::json!({ "msaa": 8 })).is_err());
assert!(set(&mut app, &serde_json::json!({ "msaa": 1 })).is_ok());
app.load_prefs(&serde_json::json!({ "preferences": { "msaa": 16 } }).to_string());
assert_eq!(app.preferences.msaa, Prefs::default().msaa);
}
#[test]
fn length_fields_read_bare_numbers_in_design_units_and_honour_explicit_units() {
let inch = unit_scale("in").0;
let close = |a: Option<f64>, b: f64| a.is_some_and(|a| (a - b).abs() < 1e-9);
assert!(close(parse_length_mm("2", inch), 50.8));
assert!(close(parse_length_mm(" 10 mm ", inch), 10.0));
assert!(close(parse_length_mm("0.5 in", 1.0), 12.7));
assert!(close(parse_length_mm("1 in + 5 mm", unit_scale("cm").0), 30.4));
assert!(close(parse_length_mm("3", unit_scale("cm").0), 30.0));
assert!(close(parse_length_mm("3", 1.0), 3.0));
assert_eq!(parse_length_mm("10 deg", inch), None);
assert_eq!(parse_length_mm("abc", inch), None);
assert_eq!(parse_length_mm("", inch), None);
assert_eq!(parse_length_mm("1e400", 1.0), None);
}
#[test]
fn navigator_picker_preserves_axes_and_follows_preferences_transactions() {
let old: Prefs = serde_json::from_value(json!({"nav":"solidworks"})).unwrap();
assert_eq!(old.navigator_style, NavigatorStyle::Axes);
let mut h = crate::scenario::Harness::new();
assert!(crate::scenario::handle_at("axis_view:z").is_some());
assert!(crate::scenario::handle_at("navigation_cube:front").is_none());
let camera = h.app.cam;
let design = h.app.session.doc.to_json();
let nav = h.app.preferences.nav.clone();
h.call("ui.prefs", json!({"open":"Navigation"}));
let picker = crate::scenario::rect_of("prefs:navigator").unwrap().center();
h.call("ui.click", json!({"x":picker.x,"y":picker.y}));
let cube = crate::scenario::rect_of("prefs:navigator:Cube").unwrap().center();
assert!(crate::scenario::rect_of("prefs:navigator:Signed axes").is_some());
h.call("ui.click", json!({"x":cube.x,"y":cube.y}));
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube);
assert!(crate::scenario::handle_at("navigation_cube:front").is_some());
assert_eq!(h.app.preferences.nav, nav);
assert_eq!(h.app.cam, camera);
assert_eq!(h.app.session.doc.to_json(), design);
let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
restored.load_prefs(&h.app.prefs());
assert_eq!(restored.preferences.navigator_style, NavigatorStyle::Cube);
let before = h.app.preferences.clone();
assert!(control(&mut h.app, &json!({"navigator_style":"unknown", "nav":"solidworks"})).is_err());
assert_eq!(h.app.preferences, before, "invalid settings must fail atomically");
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes);
assert!(crate::scenario::handle_at("navigation_cube:front").is_none());
h.call("ui.prefs", json!({"open":"Navigation","navigator_style":"cube"}));
h.call("ui.prefs", json!({"button":"apply"}));
h.call("ui.prefs", json!({"button":"defaults"}));
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes);
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube);
h.call("ui.prefs", json!({"navigator_style":"axes", "look_at_anim":false}));
h.call("ui.view", json!({"view":"front"}));
let axis = crate::scenario::handle_at("axis_view:-y").unwrap();
h.call("ui.click", json!({"x":axis.x,"y":axis.y}));
assert!(h.app.cam.back().dist(Vec3::Y) < 1e-6, "original axes retain click-again reversal");
assert_eq!(h.app.session.doc.to_json(), design);
}
#[test]
fn radial_context_menu_defaults_off_and_follows_preferences_transactions() {
assert!(!Prefs::default().radial_context_menu);
let old: Prefs = serde_json::from_value(json!({"nav":"solidworks","zoom_reverse":true})).unwrap();
assert!(!old.radial_context_menu && old.zoom_reverse && old.nav == "solidworks");
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"open":"Navigation"}));
h.step(&json!({"widget":"Radial context menu"})).unwrap();
assert!(h.app.preferences.radial_context_menu, "the actual checkbox enables radial menus");
let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
restored.load_prefs(&h.app.prefs());
assert!(restored.preferences.radial_context_menu, "enabled preference survives save/load");
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(!h.app.preferences.radial_context_menu, "Cancel restores the default-off setting");
h.call("ui.prefs", json!({"open":"Navigation","radial_context_menu":true}));
h.call("ui.prefs", json!({"button":"apply"}));
h.call("ui.prefs", json!({"button":"defaults"}));
assert!(!h.app.preferences.radial_context_menu, "Navigation defaults disable the ring");
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
}
#[test]
fn wheel_smoothing_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().smooth_wheel_zoom);
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"apply"})).unwrap();
control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert!(!other.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test]
fn openscad_preset_maps_navigation_and_persists() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
set(&mut app, &json!({"nav": OPENSCAD_NAV})).unwrap();
for button in [NavButton::Middle, NavButton::Right] {
assert_eq!(nav_mode(&app, button, Modifiers::default()), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, button, Modifiers { shift: true, ..Default::default() }), Some(NavMode::Zoom));
}
assert_eq!(nav_mode(&app, NavButton::Left, Modifiers::default()), Some(NavMode::Orbit));
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
}
#[test]
fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().trackpad_pan);
assert!(Prefs::default().trackpad_pan);
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(app.preferences.trackpad_pan);
control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap();
control(&mut app, &json!({"button":"apply"})).unwrap();
assert!(!app.preferences.trackpad_pan);
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert!(!other.preferences.trackpad_pan);
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
assert!(app.preferences.trackpad_pan);
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(!app.preferences.trackpad_pan);
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solidcraft_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, NavButton::Middle, none), Some(NavMode::Pan)));
assert!(matches!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Orbit)));
set(&mut app, &json!({"nav": "solidworks"})).unwrap();
assert!(matches!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit)));
assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Pan)));
set(&mut app, &json!({"nav": "inventor", "zoom_reverse": true})).unwrap();
assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom)));
assert_eq!(zoom_sign(&app), -1.0);
assert!(matches!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit)), "right drag orbits everywhere");
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "left drag selects");
let shift_alt = Modifiers { shift: true, alt: true, ..Default::default() };
assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), None, "only Alias navigates with the left button");
set(&mut app, &json!({"nav": "alias"})).unwrap();
assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), Some(NavMode::Orbit));
assert_eq!(nav_mode(&app, NavButton::Middle, shift_alt), Some(NavMode::Pan));
assert_eq!(nav_mode(&app, NavButton::Right, shift_alt), Some(NavMode::Zoom));
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "a plain left drag still selects");
assert_eq!(nav_mode(&app, NavButton::Left, shift), None);
assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit));
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(solidcraft_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");
}
/// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply
/// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page.
#[test]
fn preferences_dialog_cancel_apply_and_defaults() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open": "general"})).unwrap();
assert!(app.prefs_window.open && app.prefs_window.section == 0);
control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap();
assert!(!app.ui.dark, "light applies at once");
app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into());
let v = control(&mut app, &json!({"button": "cancel"})).unwrap();
assert_eq!(v["dialog"], serde_json::Value::Null);
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
control(&mut app, &json!({"button": "apply"})).unwrap();
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
control(&mut app, &json!({"button": "cancel"})).unwrap();
assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply");
let to = {
let mut c = app.cam;
c.set_view(solidcraft_engine::render::StandardView::Top);
c
};
app.animate_to(to);
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
control(&mut app, &json!({"open": "Navigation"})).unwrap();
control(&mut app, &json!({"button": "defaults"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
assert!(app.preferences.look_at_anim);
control(&mut app, &json!({"button": "ok"})).unwrap();
assert!(!app.prefs_window.open);
// File and Help › Keyboard Shortcuts open the same dialog at its page.
app.keymap.open = true;
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
assert!(set(&mut app, &json!({"language": "fr"})).is_err());
}
#[test]
fn language_preference_switches_and_persists() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
assert_eq!(app.preferences.language, "en");
assert_eq!(app.tr("ui.preferences"), "Preferences");
assert_eq!(app.tr("ui.extrude"), "Extrude");
// Switch language to Japanese
set(&mut app, &json!({"language": "ja"})).unwrap();
assert_eq!(app.preferences.language, "ja");
assert_eq!(app.tr("ui.preferences"), "環境設定");
assert_eq!(app.tr("ui.extrude"), "押し出し");
// Preferences round-trip
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.language, "ja");
assert_eq!(other.tr("ui.preferences"), "環境設定");
}
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
#[test]
fn a_y_up_step_file_can_be_stood_up() {
let dir = std::env::temp_dir().join(format!("solidcraft-standup-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
let mut src = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap();
src.run("file.export", json!({"path": path})).unwrap();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.open_path(&path);
let offered = app.stand_up.clone().unwrap_or_default();
assert_eq!(offered.len(), 1, "the file's body is offered");
let size = |app: &SolveApp| solidcraft_engine::view::bounds(&app.session);
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6);
control(&mut app, &json!({"stand_up": true})).unwrap();
let b = size(&app);
assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}");
assert!(app.stand_up.is_none());
app.run("edit.undo", json!({})).unwrap();
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step");
// Not offered when turned off, or with Y up as the default orientation.
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.offer_stand_up = false;
app.open_path(&path);
assert!(app.stand_up.is_none());
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.up_axis = "y".into();
app.open_path(&path);
assert!(app.stand_up.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn orbiting_around_the_cursor_keeps_that_point_still() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", 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:?}");
}
/// A drag that starts over empty space turns around the visible geometry's centre, not a
/// point far off to the side; with nothing visible it turns around the view's centre.
#[test]
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.run("solid.box", 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 corner = egui::pos2(10.0, 10.0);
let proj = crate::viewport::projection(&app, rect);
let hits = crate::viewport::pick(&app, &proj, corner);
use crate::viewport::Hit;
assert!(!hits.iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })), "the corner is empty space");
let centre = crate::viewport::view_bounds(&app).center();
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(centre));
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
app.preferences.orbit_cursor = true;
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
}
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
#[test]
fn up_axis_preference_turns_the_view() {
use solidcraft_engine::render::UpAxis;
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let ctx = egui::Context::default();
apply(&mut app, &ctx);
assert_eq!(app.cam.up, UpAxis::Z);
app.preferences.up_axis = "y".into();
apply(&mut app, &ctx);
assert_eq!((app.ui.up_axis, app.cam.up), (UpAxis::Y, UpAxis::Y));
// Home looks at the model with Y up: the eye is above the XZ ground.
assert!(app.cam.back().y > 0.3, "{:?}", app.cam.back());
app.preferences.up_axis = "z".into();
apply(&mut app, &ctx);
assert_eq!(app.cam.up, UpAxis::Z);
}
}
#[cfg(test)]
mod ui_scale_regression_tests {
use super::*;
use egui::{Event, Modifiers, PointerButton, pos2};
#[test]
fn desktop_zoom_shortcuts_update_and_persist_the_scale_preference() {
let mut h = crate::scenario::Harness::new();
h.call("ui.key", json!({"key":"Minus","cmd":true}));
assert!((h.app.preferences.ui_scale - 0.9).abs() < 1e-4);
assert!((h.ctx.zoom_factor() - 0.9).abs() < 1e-4);
h.frames(12);
assert!((h.ctx.zoom_factor() - 0.9).abs() < 1e-4, "must not flash back to the old preference");
h.step(&json!({"restart":"close"})).unwrap();
assert!((h.app.preferences.ui_scale - 0.9).abs() < 1e-4);
h.call("ui.key", json!({"key":"Equals","cmd":true}));
assert!((h.ctx.zoom_factor() - 1.0).abs() < 1e-4);
h.call("ui.key", json!({"key":"Plus","cmd":true}));
assert!((h.ctx.zoom_factor() - 1.1).abs() < 1e-4);
h.call("ui.key", json!({"key":"0","cmd":true}));
assert!((h.ctx.zoom_factor() - 1.0).abs() < 1e-4);
h.call("ui.prefs", json!({"ui_scale":0.5}));
h.call("ui.key", json!({"key":"Minus","cmd":true}));
assert_eq!(h.app.preferences.ui_scale, 0.5);
h.call("ui.prefs", json!({"ui_scale":2.5}));
h.call("ui.key", json!({"key":"Plus","cmd":true}));
assert_eq!(h.app.preferences.ui_scale, 2.5);
}
#[test]
fn dragging_the_scale_slider_keeps_its_window_still_until_release() {
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"open":"General"}));
let window = crate::scenario::rect_of("prefs:window").unwrap();
let slider = crate::scenario::rect_of("prefs:scale").unwrap();
let a = pos2(slider.left() + 20.0, slider.center().y);
let b = pos2(slider.left() + 90.0, slider.center().y);
h.app.synthetic.push(Event::PointerMoved(a));
h.app.synthetic.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.frames(3);
h.app.synthetic.push(Event::PointerMoved(b));
h.frames(4);
assert_ne!(h.app.preferences.ui_scale, 1.0, "the slider changes its value during the drag");
assert_eq!(h.ctx.zoom_factor(), 1.0, "layout must remain stable until release");
assert_eq!(crate::scenario::rect_of("prefs:window").unwrap(), window);
h.app.synthetic.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.frames(5);
assert!((h.ctx.zoom_factor() - h.app.preferences.ui_scale).abs() < 1e-4);
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.ui_scale, 1.0, "Cancel restores the original scale");
assert_eq!(h.ctx.zoom_factor(), 1.0);
}
}