Files
OneCAD/crates/ui-egui/src/prefs.rs
T
Raymond Thurmanandraythurman2386 af2f09fac2 A new empty design takes Units of new designs (#1283)
The empty design shown at launch, New on a design that is still empty, file.new and the blank design left when the last tab closes all take the Units of new designs preference. Opened files and the sample part keep their own units. Fixes #1205.

Co-authored-by: raythurman2386 <raythurman2386@users.noreply.github.com>
2026-10-15 01:20:02 -04:00

1822 lines
86 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, timeline layout, 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, colour vision), 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";
pub const TOUCHPAD_NAV: &str = "touchpad";
/// 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; 7] = [
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),
],
},
NavPreset {
id: TOUCHPAD_NAV,
label: "Touchpad",
help: "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
reverse_zoom: false,
bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)],
},
];
impl NavPreset {
/// Button-free motion has exact modifiers, unlike the additive drag bindings.
pub fn motion_mode(&self, mods: Modifiers) -> Option<NavMode> {
if self.id != TOUCHPAD_NAV || mods.ctrl || mods.command || mods.mac_cmd {
return None;
}
match (mods.shift, mods.alt) {
(true, false) => Some(Pan),
(false, true) => Some(Orbit),
_ => None,
}
}
fn translated_label(&self) -> &str {
if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) }
}
}
impl Prefs {
/// Touchpad scroll zooms without overwriting the user's preference for other presets.
pub fn trackpad_pans(&self) -> bool {
self.nav != TOUCHPAD_NAV && self.trackpad_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 {
/// Also what a navigator this version doesn't know reads as (`lenient`).
#[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,
/// Put the feature history above the parts browser in the left sidebar instead of along the
/// bottom of the viewport.
pub timeline_left: bool,
/// Height of the feature-history section when it shares the left sidebar with the browser.
pub timeline_left_height: f32,
/// Whether the browser below the left feature history is collapsed.
pub timeline_left_browser_collapsed: bool,
/// 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,
/// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`.
pub color_vision: crate::theme::ColorVision,
/// Check for updates at start and once a day (docs/updates.md).
pub check_updates: bool,
/// When the last update check ran, and the versions skipped or put off (`update::Memo`).
pub update: crate::update::Memo,
}
/// 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,
timeline_left: false,
timeline_left_height: 360.0,
timeline_left_browser_collapsed: false,
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(),
color_vision: crate::theme::ColorVision::Off,
check_updates: true,
update: crate::update::Memo::default(),
}
}
}
/// 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;
// Update bookkeeping isn't a setting: a check that finished while the dialog was open stays.
let update = std::mem::take(&mut app.preferences.update);
app.preferences = self.prefs;
app.preferences.update = update;
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.timeline_left = d.timeline_left;
p.timeline_left_height = d.timeline_left_height;
p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
p.angle_decimals = d.angle_decimals;
p.check_updates = d.check_updates;
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;
p.color_vision = d.color_vision;
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).
/// Callers: a launch with no file, New on a design that is already empty, `file.new`, and the
/// blank design left when the last tab closes. A design that already has features is unchanged.
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.
pub(crate) 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 {
// Other settings in the file (the graphics `backend`) stay as they are.
let mut graphics = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| json!({}));
graphics["msaa"] = json!(app.preferences.msaa);
let _ = std::fs::write(path, graphics.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.feature_history"));
let timeline_bottom = app.tr("ui.timeline_bottom").to_string();
let timeline_left = app.tr("ui.timeline_left_sidebar").to_string();
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.timeline_left, false, timeline_bottom);
ui.radio_value(&mut app.preferences.timeline_left, true, timeline_left);
});
ui.end_row();
note(ui, app.tr("ui.timeline_left_note"));
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, crate::i18n::tr_label("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, crate::i18n::tr_label("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, crate::i18n::tr_label("Follow the camera (Look At sketches)")));
ui.end_row();
crate::update::prefs_rows(app, ui, t);
}
1 => {
ui.label(app.tr_label("Pan, zoom, orbit"));
let current_preset_label = nav_preset(&app.preferences.nav).translated_label();
egui::ComboBox::from_id_salt("pref_nav").selected_text(current_preset_label).show_ui(ui, |ui| {
for p in &NAV_PRESETS {
if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.translated_label()).clicked() {
app.preferences.zoom_reverse = p.reverse_zoom;
}
}
});
ui.end_row();
if app.preferences.nav == TOUCHPAD_NAV {
note(ui, crate::i18n::tr("nav.touchpad.help"));
} else {
let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" };
let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; the wheel zooms", nav_preset(&app.preferences.nav).help);
note(ui, crate::i18n::tr_label(&raw_help));
}
ui.label(crate::i18n::tr_label("Context menu"));
ui.checkbox(&mut app.preferences.radial_context_menu, crate::i18n::tr_label("Radial context menu"));
ui.end_row();
ui.label(crate::i18n::tr_label("View navigator"));
let picker =
egui::ComboBox::from_id_salt("pref_navigator_style").selected_text(crate::i18n::tr_label(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, crate::i18n::tr_label(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(crate::i18n::tr_label("Reverse zoom direction"));
ui.checkbox(&mut app.preferences.zoom_reverse, "");
ui.end_row();
ui.label(crate::i18n::tr_label("Zoom to the cursor"));
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
.on_hover_text(crate::i18n::tr_label("The wheel zooms toward the point under the cursor (off: toward the view's centre)"));
ui.end_row();
ui.label(crate::i18n::tr_label("Smooth wheel zoom"));
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
.on_hover_text(crate::i18n::tr_label("Turn off to apply each wheel event immediately, without a smoothing tail"));
ui.end_row();
ui.label(crate::i18n::tr_label("Two-finger scroll pans"));
let touchpad = app.preferences.nav == TOUCHPAD_NAV;
ui.add_enabled(!touchpad, egui::Checkbox::new(&mut app.preferences.trackpad_pan, "")).on_hover_text(if touchpad {
crate::i18n::tr("nav.touchpad.scroll")
} else {
crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms")
});
ui.end_row();
ui.label(crate::i18n::tr_label("Orbit around"));
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, crate::i18n::tr_label("View centre"));
ui.radio_value(&mut app.preferences.orbit_cursor, true, crate::i18n::tr_label("Point under the cursor"));
});
ui.end_row();
ui.label(crate::i18n::tr_label("Middle double-click"));
let current = MiddleDoubleClick::of(app);
egui::ComboBox::from_id_salt("pref_middle_double_click").selected_text(crate::i18n::tr_label(current.label())).show_ui(ui, |ui| {
for (m, id, label) in MiddleDoubleClick::ALL {
if ui.selectable_label(current == m, crate::i18n::tr_label(label)).clicked() {
app.preferences.middle_double_click = id.into();
}
}
})
.response
.on_hover_text(crate::i18n::tr_label("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(crate::i18n::tr_label("Animate view changes"));
ui.checkbox(&mut app.preferences.look_at_anim, crate::i18n::tr_label("Axis view, Look At, Home and Fit"));
ui.end_row();
ui.label(crate::i18n::tr_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(crate::i18n::tr_label("3D mouse (Windows)"));
ui.checkbox(&mut app.preferences.space_mouse_enabled, crate::i18n::tr_label("Navigate with SpaceNavigator / SpaceMouse"));
ui.end_row();
ui.label(crate::i18n::tr_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, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."));
}
PAGE_DESIGN => {
ui.label(crate::i18n::tr_label("Default modelling orientation"));
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), crate::i18n::tr_label("Z up"));
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), crate::i18n::tr_label("Y up"));
});
ui.end_row();
note(ui, crate::i18n::tr_label("Which axis points up in the views (axis view, Home, Look At). Designs are unchanged."));
ui.label(crate::i18n::tr_label("Offer to stand up Y-up files"));
ui.checkbox(&mut app.preferences.offer_stand_up, "")
.on_hover_text(crate::i18n::tr_label("After a STEP or IGES file opens with Z up, offer a quarter turn about X"));
ui.end_row();
}
4 => {
ui.label(crate::i18n::tr_label("Visual style"));
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
let selected_label = crate::i18n::tr_label(styles[usize::from(app.ui.visual_style.min(3))]);
egui::ComboBox::from_id_salt("pref_style").selected_text(selected_label).show_ui(ui, |ui| {
for (i, s) in styles.iter().enumerate() {
ui.selectable_value(&mut app.ui.visual_style, i as u8, crate::i18n::tr_label(s));
}
});
ui.end_row();
ui.label(crate::i18n::tr_label("Grid"));
ui.checkbox(&mut app.ui.show_grid, "");
ui.end_row();
ui.label(crate::i18n::tr_label("Ground shadow"));
ui.checkbox(&mut app.ui.ground_shadow, "");
ui.end_row();
ui.label(crate::i18n::tr_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(crate::i18n::tr_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();
// Colour vision (#778): its own section at the end of the page.
ui.label("");
ui.end_row();
ui.label(RichText::new(app.tr("ui.colour_vision")).strong());
ui.label("");
ui.end_row();
ui.label(app.tr("ui.colour_vision_mode"));
let mode = app.preferences.color_vision;
egui::ComboBox::from_id_salt("pref_color_vision").selected_text(app.tr(mode.label_id())).show_ui(ui, |ui| {
for m in crate::theme::ColorVision::ALL {
ui.selectable_value(&mut app.preferences.color_vision, m, crate::i18n::tr(m.label_id())).on_hover_text(crate::i18n::tr(m.help_id()));
}
});
if app.preferences.color_vision != mode {
// At once, like the theme: the next frame draws in the new colours.
ui.ctx().request_repaint();
}
ui.end_row();
ui.label(app.tr("ui.colour_vision_sample"));
colour_vision_sample(ui, t);
ui.end_row();
note(ui, app.tr("ui.colour_vision_note"));
}
5 => {
ui.label(crate::i18n::tr_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(crate::i18n::tr_label("Click selects whole bodies"));
ui.checkbox(&mut app.ui.pick_bodies, "");
ui.end_row();
}
});
}
/// The names under the Colour vision sample (message ids).
pub(crate) const COLOUR_VISION_SAMPLE: [&str; 6] =
["ui.colour_vision_fixed", "ui.colour_vision_projected", "ui.axes", "Bends", "ui.draft", "ui.colour_vision_failed"];
/// The Colour vision sample (#778): fixed and projected sketch lines, the X and Y axes, bends up
/// and down, Draft Analysis and a failed feature, each drawn as it is where it appears, in the
/// colours on now. One fixed-size area, so the grid row is as tall as what it draws.
fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) {
const W: f32 = 72.0;
const LINE: f32 = 4.0;
let (area, _) = ui.allocate_exact_size(egui::vec2(W * COLOUR_VISION_SAMPLE.len() as f32, 50.0), egui::Sense::hover());
let p = ui.painter_at(area);
let line = |c| egui::Stroke::new(LINE, c);
for (i, name) in COLOUR_VISION_SAMPLE.iter().enumerate() {
let cell = egui::Rect::from_min_size(area.min + egui::vec2(W * i as f32, 0.0), egui::vec2(W - 8.0, 32.0));
let (a, b) = (cell.left_center() + egui::vec2(8.0, 0.0), cell.right_center() - egui::vec2(8.0, 0.0));
match i {
0 | 1 => {
p.rect_filled(cell, 3.0, t.viewport_top);
p.line_segment([a, b], line(if i == 0 { t.sketch_locked } else { t.sketch_projected }));
}
2 => {
// X along and Y up from one corner, as the view triad draws them.
p.rect_filled(cell, 3.0, t.viewport_top);
let o = cell.left_bottom() + egui::vec2(12.0, -7.0);
p.line_segment([o, egui::pos2(cell.right() - 10.0, o.y)], line(t.triad[0]));
p.line_segment([o, egui::pos2(o.x, cell.top() + 6.0)], line(t.triad[1]));
}
3 => {
// Dashed, as the flat pattern draws them.
p.rect_filled(cell, 3.0, t.viewport_top);
for (dy, c) in [(-7.0, t.bend_up), (7.0, t.bend_down)] {
p.extend(egui::Shape::dashed_line(&[a + egui::vec2(0.0, dy), b + egui::vec2(0.0, dy)], line(c), 8.0, 5.0));
}
}
4 => {
// The three bands of Draft Analysis; in a mode the negative one is striped.
let w = cell.width() / 3.0;
for (k, c) in t.analysis.draft.iter().enumerate() {
let band = egui::Rect::from_min_size(cell.min + egui::vec2(w * k as f32, 0.0), egui::vec2(w, cell.height()));
p.rect_filled(band, 0.0, crate::theme::srgb(*c));
if k == 2 && t.analysis.hatch {
let stripes = p.with_clip_rect(band);
let dark = crate::theme::srgb(c.map(|x| x * 0.6));
let mut x = band.left() - band.height();
while x < band.right() {
stripes.line_segment(
[egui::pos2(x, band.bottom()), egui::pos2(x + band.height(), band.top())],
egui::Stroke::new(2.0, dark),
);
x += 9.0;
}
}
}
}
_ => {
// A failed feature on the timeline.
p.rect(cell.shrink2(egui::vec2(10.0, 3.0)), 3.0, t.timeline_item_error, egui::Stroke::new(1.0, t.error), egui::StrokeKind::Inside);
}
}
p.text(
cell.center_bottom() + egui::vec2(0.0, 3.0),
egui::Align2::CENTER_TOP,
crate::i18n::tr(name),
egui::FontId::proportional(11.0),
t.text_dim,
);
}
}
/// 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)
}
/// Saved preferences, keeping every one that still reads. A value this version doesn't
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
/// its default on its own instead of discarding all of them.
pub fn lenient(saved: &serde_json::Value) -> Prefs {
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
return p;
}
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
for (k, v) in src.iter().take(1000) {
// Keys this version doesn't have are ignored anyway.
let Some(old) = o.get(k).cloned() else { continue };
o.insert(k.clone(), v.clone());
if serde_json::from_value::<Prefs>(serde_json::Value::Object(o.clone())).is_err() {
log::warn!("preferences: `{k}` is not understood by this version; using its default");
o.insert(k.clone(), old);
}
}
}
serde_json::from_value(cur).unwrap_or_default()
}
/// 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());
}
}
// Saved preferences read an unknown mode as Off; asked for here, it is an error.
if let Some(v) = p.get("color_vision")
&& v.as_str().and_then(crate::theme::ColorVision::from_id).is_none()
{
return Err("`color_vision` must be off, protan, deutan or tritan".into());
}
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(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")));
}
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 a_value_this_version_does_not_understand_keeps_the_other_preferences() {
use serde_json::json;
// A navigator, a field of the wrong type and a key from another release.
let saved = json!({"nav": "solidworks", "navigator_style": "hologram", "ui_scale": "big", "zoom_reverse": true, "from_the_future": 3});
let p = super::lenient(&saved);
assert_eq!(p.nav, "solidworks");
assert!(p.zoom_reverse);
assert_eq!(p.navigator_style, super::NavigatorStyle::Axes);
assert_eq!(p.ui_scale, super::Prefs::default().ui_scale);
assert_eq!(super::lenient(&json!("garbage")), super::Prefs::default());
// Whole-app load: the other saved state is kept too, and nothing panics.
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.load_prefs(&json!({"grid": false, "preferences": saved}).to_string());
assert!(!app.ui.show_grid && app.preferences.nav == "solidworks" && app.preferences.zoom_reverse);
for junk in ["", "{", "[]", "null", "{\"preferences\": 7, \"recent\": {}, \"shortcuts\": 1, \"toolbar\": \"x\"}"] {
crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()).load_prefs(junk);
}
}
#[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;
std::fs::write(&path, r#"{"msaa":8,"backend":"vulkan"}"#).unwrap();
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!(saved["backend"], "vulkan", "the backend preference is kept");
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 touchpad_preset_persists_and_obeys_preference_transactions() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad"})).unwrap();
assert_eq!(app.preferences.nav, "touchpad");
assert!(app.preferences.trackpad_pan, "choosing Touchpad must not overwrite the saved scroll preference");
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
control(&mut app, &json!({"open":"Navigation", "nav":"touchpad", "button":"apply"})).unwrap();
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.nav, "touchpad");
control(&mut app, &json!({"button":"defaults"})).unwrap();
assert_eq!(app.preferences.nav, "fusion");
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert_eq!(app.preferences.nav, "touchpad");
}
#[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": "xx"})).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", "timeline_left": true})).unwrap();
app.preferences.timeline_left_height = 275.0;
app.preferences.timeline_left_browser_collapsed = true;
assert_eq!(app.preferences.language, "ja");
assert!(app.preferences.timeline_left);
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!(other.preferences.timeline_left);
assert_eq!(other.preferences.timeline_left_height, 275.0);
assert!(other.preferences.timeline_left_browser_collapsed);
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);
}
}