//! Persisted user preferences — DYN, POLAR (+ increment), OTRACK, and assorted //! app-level flags (backup, autosave, plugin lists, viewport background). These //! are UI choices, not drawing data, so they live in the consolidated per-user //! config ([`crate::app::config`], the "settings" section) and survive across //! sessions. Drawing-scoped state (Ortho `$ORTHOMODE`, running OSNAP `$OSMODE`, //! lineweight display `$LWDISPLAY`, …) belongs to the file, not here. //! //! Also home to the `$OSMODE` bit conversions ([`osmode_from_snaps`] / //! [`snaps_from_osmode`]) that bridge the running-snap set and the drawing header. use crate::snap::SnapType; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum AutoConstraintKind { Coincident, Collinear, Parallel, Perpendicular, Tangent, Concentric, Horizontal, Vertical, Equal, } impl AutoConstraintKind { pub const ALL: [Self; 9] = [ Self::Coincident, Self::Collinear, Self::Parallel, Self::Perpendicular, Self::Tangent, Self::Concentric, Self::Horizontal, Self::Vertical, Self::Equal, ]; pub const fn label(self) -> &'static str { match self { Self::Coincident => "Coincident", Self::Collinear => "Collinear", Self::Parallel => "Parallel", Self::Perpendicular => "Perpendicular", Self::Tangent => "Tangent", Self::Concentric => "Concentric", Self::Horizontal => "Horizontal", Self::Vertical => "Vertical", Self::Equal => "Equal", } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct AutoConstrainSettings { pub priority: Vec, pub enabled: Vec, pub tangent_must_share_point: bool, pub perpendicular_must_intersect: bool, pub distance_tolerance: f64, pub angle_tolerance_deg: f64, } impl Default for AutoConstrainSettings { fn default() -> Self { Self { priority: AutoConstraintKind::ALL.to_vec(), // Keep Equal available without creating redundant relations // between equal-length segments by default. enabled: AutoConstraintKind::ALL .into_iter() .filter(|kind| *kind != AutoConstraintKind::Equal) .collect(), tangent_must_share_point: true, perpendicular_must_intersect: true, distance_tolerance: 0.05, angle_tolerance_deg: 1.0, } } } impl AutoConstrainSettings { pub fn sanitize(&mut self) { let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len()); for kind in self.priority.iter().copied().chain(AutoConstraintKind::ALL) { if !priority.contains(&kind) { priority.push(kind); } } self.priority = priority; self.enabled .retain(|kind| AutoConstraintKind::ALL.contains(kind)); self.enabled.sort_by_key(|kind| { self.priority .iter() .position(|candidate| candidate == kind) .unwrap_or(usize::MAX) }); self.enabled.dedup(); if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 { self.distance_tolerance = 0.05; } if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 { self.angle_tolerance_deg = 1.0; } } } /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { #[default] Crosshair, Pointer, } impl CursorType { pub const ALL: [Self; 2] = [Self::Crosshair, Self::Pointer]; pub fn label(self) -> &'static str { match self { Self::Crosshair => "Crosshair", Self::Pointer => "Desktop pointer", } } } /// What a right-click in the drawing area does (SHORTCUTMENU in commercial solutions / /// "Right-click Customization"). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RightClickMode { /// The default of commercial solutions: a right-click always opens the shortcut menu (Enter / /// Cancel / the command's options while a command runs; Repeat / edit /// tools when idle). #[default] ShortcutMenu, /// The "time-sensitive right-click" of commercial solutions: a quick click is Enter (or /// repeats the last command when idle); holding the button longer than /// `right_click_hold_ms` opens the shortcut menu. TimeSensitive, /// Original Open CAD Studio behaviour: while a command runs the first /// right-click is Enter and a second consecutive one opens the menu; /// when idle a right-click opens the menu. EnterFirst, } impl RightClickMode { pub const ALL: [Self; 3] = [Self::ShortcutMenu, Self::TimeSensitive, Self::EnterFirst]; pub fn label(self) -> &'static str { match self { Self::ShortcutMenu => "Shortcut menu", Self::TimeSensitive => "Time-sensitive (quick click = Enter)", Self::EnterFirst => "Enter first, second click = menu", } } } /// SHORTCUTMENUDURATION bounds: below 100 ms every click reads as a hold, /// above 1000 ms the menu becomes unreachable in practice. pub fn clamp_right_click_hold_ms(v: i32) -> i32 { v.clamp(100, 1000) } /// ZOOMFACTOR bounds. /// /// `ZOOM_FACTOR_SYSVAR_MAX` is where the Options slider stops: the range the /// variable has elsewhere. Both the Options field and `SETVAR ZOOMFACTOR` /// reach `ZOOM_FACTOR_MAX` for anyone who wants a faster wheel; the value /// lives only in this application's own configuration file, so it is nobody /// else's to read. pub const ZOOM_FACTOR_MIN: i32 = 3; pub const ZOOM_FACTOR_SYSVAR_MAX: i32 = 100; pub const ZOOM_FACTOR_MAX: i32 = 500; /// Hold a ZOOMFACTOR inside the range the application can store, wherever it /// arrives from — the slider, the Options field, or a configuration file /// written by hand or by an older build. /// Interface scale in percent, kept to the range the Options slider offers. pub fn clamp_ui_scale(percent: u16) -> u16 { percent.clamp(75, 200) } pub fn clamp_zoom_factor(v: i32) -> i32 { v.clamp(ZOOM_FACTOR_MIN, ZOOM_FACTOR_MAX) } /// The `Camera::zoom` step one wheel notch means at `factor`. The default 60 /// is one whole step, which is what makes 60 the setting that leaves the /// camera's own `ZOOM_STEP` untouched. pub fn zoom_notch_steps(factor: i32) -> f32 { factor as f32 / 60.0 } /// How much nearer one wheel notch brings the view at `factor`, as a /// percentage — the number the Options window shows, so that what the slider /// and the field are setting is readable instead of implied. Derived from the /// camera's own law, never from a second copy of it. pub fn zoom_notch_percent(factor: i32) -> f32 { let ratio = crate::scene::view::camera::Camera::ZOOM_STEP .powf(zoom_notch_steps(clamp_zoom_factor(factor))); (1.0 - ratio) * 100.0 } /// Active pair of axes while isometric drafting is enabled. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum IsoPlane { #[default] Left, Top, Right, } impl IsoPlane { pub const ALL: [Self; 3] = [Self::Left, Self::Top, Self::Right]; pub fn next(self) -> Self { match self { Self::Left => Self::Top, Self::Top => Self::Right, Self::Right => Self::Left, } } pub fn label(self) -> &'static str { match self { Self::Left => "Left", Self::Top => "Top", Self::Right => "Right", } } /// The two drafting directions, in degrees in the active UCS plane. pub fn angles(self) -> [f64; 2] { match self { Self::Left => [90.0, 150.0], Self::Top => [30.0, 150.0], Self::Right => [30.0, 90.0], } } } /// Canonical order of the user-toggleable object-snap modes. Drives the /// deterministic order when decoding the `$OSMODE` bitmask (see /// [`snaps_from_osmode`]). const SNAP_ORDER: &[SnapType] = &[ SnapType::Endpoint, SnapType::Midpoint, SnapType::Center, SnapType::GeometricCenter, SnapType::Node, SnapType::Quadrant, SnapType::Intersection, SnapType::Extension, SnapType::Insertion, SnapType::Perpendicular, SnapType::Tangent, SnapType::Nearest, SnapType::ApparentIntersection, SnapType::Parallel, // Grid snap (SNAPMODE) is a per-drawing view setting stored on the VPort, // not a global OSNAP preference, so it is deliberately excluded from the // persisted set. (#121) ]; /// `$OSMODE` bit for each running object-snap mode. /// `None` for OCS-only snaps (Grid, ObjectPick) and the 3D solid snaps /// (Vertex, EdgeMidpoint, FaceCenter, Knot, FacePerpendicular, NearestFace), /// which live in the separate 3D set and have no standard bit. fn snap_bit(s: SnapType) -> Option { Some(match s { SnapType::Endpoint => 1, SnapType::Midpoint => 2, SnapType::Center => 4, SnapType::Node => 8, SnapType::GeometricCenter => 1024, SnapType::Quadrant => 16, SnapType::Intersection => 32, SnapType::Insertion => 64, SnapType::Perpendicular => 128, SnapType::Tangent => 256, SnapType::Nearest => 512, SnapType::ApparentIntersection => 2048, SnapType::Extension => 4096, SnapType::Parallel => 8192, SnapType::Grid | SnapType::ObjectPick | SnapType::Vertex | SnapType::EdgeMidpoint | SnapType::FaceCenter | SnapType::Knot | SnapType::FacePerpendicular | SnapType::NearestFace => return None, }) } /// Bit 14 of `$OSMODE` — object snap turned off (master suppress). const OSMODE_SUPPRESS: i32 = 16384; /// Encode the running-snap set + master toggle into an `$OSMODE` bitmask for the /// drawing header. OCS-only snaps (Grid, ObjectPick) have no bit and are /// dropped. A cleared master toggle sets the suppress bit. pub(crate) fn osmode_from_snaps<'a>( enabled: impl IntoIterator, snap_enabled: bool, ) -> i32 { let mut bits = 0; for t in enabled { if let Some(b) = snap_bit(*t) { bits |= b; } } if !snap_enabled { bits |= OSMODE_SUPPRESS; } bits } /// Decode an `$OSMODE` bitmask into `(running-snap set, master enabled)`. Only /// the standard mappable modes are produced; the suppress bit maps (inverted) to /// the master toggle. pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { let modes = SNAP_ORDER .iter() .copied() .filter(|t| snap_bit(*t).is_some_and(|b| osmode & b != 0)) .collect(); (modes, osmode & OSMODE_SUPPRESS == 0) } fn deserialize_options_tab<'de, D>( deserializer: D, ) -> Result where D: serde::Deserializer<'de>, { use crate::ui::window::options::OptionsTab; use serde::Deserialize; let name = String::deserialize(deserializer).unwrap_or_default(); Ok(match name.as_str() { "files" => OptionsTab::Files, "open-and-save" => OptionsTab::OpenAndSave, "display" => OptionsTab::Display, "drafting" => OptionsTab::Drafting, "modeling" => OptionsTab::Modeling, "selection" => OptionsTab::Selection, "user-preferences" => OptionsTab::UserPreferences, "graphics" => OptionsTab::Graphics, _ => OptionsTab::General, }) } /// Render a drafting angle without a trailing `.0`, so `22.5` but `30`. /// /// The polar pop-up formats its presets the same way; both are showing the /// same kind of number to the same person. pub fn format_snap_angle(deg: f32) -> String { if (deg - deg.round()).abs() < 1e-4 { format!("{}", deg.round() as i32) } else { format!("{deg}") } } /// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn. pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100; /// GRIPVERTLIMIT default: cap on TOTAL selection grips across all selected /// objects. `grip_object_limit` gates object count, but one dense polyline /// can emit ~2 grips/vertex past it — this caps the vertex blowup. Mid-segment /// grips are dropped first; vertex grips are kept. /// /// `pub` so the `cargo bench` harness (external crate) measures the real /// constant alongside [`crate::app::apply_grip_budget`] as `ui_grip_budget`. pub const MAX_SELECTED_GRIPS: usize = 4096; /// The "settings" section of the consolidated config ([`crate::app::config`]). /// Field defaults mirror the app's in-code defaults so a missing key restores /// the value the app boots with. #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UserSettings { pub spacemouse: crate::input::spacemouse::Preferences, pub dyn_input: bool, pub dyn_mode: i16, pub polar: bool, pub polar_increment_deg: f32, pub zoom_wheel_reversed: bool, /// Scrolling pans and Ctrl+scroll (a touchpad pinch) zooms, for a /// touchpad that reports two-finger drags as wheel scrolls. pub touchpad_pan: bool, /// Interface scale in percent, on top of the display's own scale. pub ui_scale_percent: u16, pub zoom_factor: i32, /// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms. pub cursor_size: i32, /// PICKBOX: normalized visible-box and click-aperture size. pub pick_box: i32, /// HPASSOC: new hatches follow their boundary objects. pub hatch_associative: bool, /// MENUBAR: the classic pull-down menus show above the ribbon. pub show_menu_bar: bool, /// RIBBONCLOSE hid the ribbon; RIBBON brings it back. pub ribbon_closed: bool, /// When true, double-clicking a block reference starts REFEDIT instead of BEDIT. pub double_click_block_refedit: bool, /// When true, double-clicking a block with attributes opens ATTEDIT. pub double_click_block_attedit: bool, /// What a right-click in the drawing area does (SHORTCUTMENU). pub right_click_mode: RightClickMode, /// Hold duration that turns a time-sensitive right-click into the menu, /// in milliseconds (SHORTCUTMENUDURATION, 100..=1000). pub right_click_hold_ms: i32, /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, /// IMAGEFRAME of the profile: used by new drawings and by drawings that /// carry no image variables of their own. pub image_frame: i16, /// The Attribute Definition dialog's "Specify on-screen" choice. pub attdef_on_screen: bool, /// FIELDDISPLAY: fields show on a gray background. pub field_display: bool, /// XDWGFADECTL: how much referenced drawings fade (-90..=90; negative is off). pub xref_fade: i32, /// FILEDIA: file dialogs of commands that have a command-line form. pub file_dia: bool, /// The file type ATTACH's picker was last left on (1-based). pub attach_filter: u32, /// SURFACEASSOCIATIVITY: new surfaces stay associative to their sources. pub surface_associativity: bool, /// UCSORTHO: an orthographic view preset also sets its orthographic UCS. pub ucs_ortho: bool, /// UCSVIEW: a saved named view keeps the current UCS. pub ucs_view: bool, /// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a /// viewport. pub layout_create_viewport: bool, /// Nested-copy symbol handling: false inserts, true binds. pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. #[serde(default, deserialize_with = "deserialize_options_tab")] pub options_tab: crate::ui::window::options::OptionsTab, /// Show the navigation cube (NAVVCUBE). pub show_viewcube: bool, /// Show the UCS icon (UCSICON). pub show_ucs_icon: bool, /// Draw the UCS icon at the origin rather than in the corner /// (UCSICON ORigin / NOorigin). pub ucs_icon_at_origin: bool, /// Selection cycling: a click where objects overlap opens a picker. pub selection_cycling: bool, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, /// Explicit crosshair RGB. `None` keeps automatic background contrast. pub crosshair_color: Option<[u8; 3]>, /// Explicit object-snap marker RGB. `None` keeps the automatic colour. pub snap_marker_color: Option<[u8; 3]>, /// Explicit command-line text RGB. `None` keeps the theme's. pub command_text_color: Option<[u8; 3]>, /// Model-space lineweight preview scale as a percentage. pub lineweight_display_scale: i32, /// Isometric drafting changes the grid and crosshair to the active axis /// pair. Session-only: a persisted On turned every drawing's crosshair /// into the isoplane pair (one vertical, one diagonal line) on every /// launch, with no visible control to switch it back off. Turn it on for /// a session with ISODRAFT; ISOPLANE keeps its persisted value. #[serde(skip)] pub isometric_drafting: bool, pub iso_plane: IsoPlane, /// SNAPANG in degrees, applied in the active UCS plane. pub snap_angle_deg: f32, pub otrack: bool, // Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing — // stored in the document header, not here (they used to be persisted app- // globally, which duplicated the file's own state). /// Whether the one-time "make Open CAD Studio the default for .dwg/.dxf?" /// prompt has already been shown. Set once the user answers (either way), /// so we never nag again on subsequent launches. pub default_assoc_prompted: bool, /// Offer to download missing `.shx` fonts from the community repository /// when a drawing opens (see `crate::io::font_repo`). #[serde(default = "default_check_missing_fonts")] pub check_missing_fonts: bool, /// Custom font source base URL (empty = the OpenCADStudio community /// repository). Each missing font is fetched as `{base}/{file_name}`, /// so an intranet folder or a private GitHub raw folder both work. #[serde(default)] pub font_source_url: String, /// Drawing new tabs start from (QNEW template); empty = a blank drawing. #[serde(default)] pub qnew_template: String, /// App version whose donation prompt has been displayed. pub donation_prompt_version: String, /// The graphics verdict (`GpuStatus::identity()`) whose warning popup the /// user chose not to see again. Empty = always show. Keyed by verdict so /// silencing "software rendering on llvmpipe" does not silence a later, /// different failure. pub gpu_warning_silenced: String, /// Ids of plugins the user turned off in the Plugin Manager. Disabled /// plugins keep their manifest listed but drop their ribbon tab and command /// dispatch. pub disabled_plugins: Vec, /// Linked plugin source repositories (`owner/repo`) the marketplace installs /// from. pub plugin_repos: Vec, /// Command-line literal-space mode: when on, Space stays in the input /// instead of submitting (as if every line started with `>`), until the /// user toggles it back off. pub literal_spaces: bool, /// Height of the expanded command-history editor in logical pixels. pub command_history_height: f32, /// Running object-snap set + master toggle as an `$OSMODE`-style bitmask. /// App-level, not per-drawing: modern DWG (R2000+) has no file slot for /// OSMODE (it moved to the registry), so the set follows the user. A /// legacy R13/R14 or DXF file carrying a nonzero `$OSMODE` still overrides /// it on open (see `adopt_header_sysvars`). pub osmode: i32, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. #[serde(default)] pub quick_dimension_snap_priority: u8, /// DIMCONTINUEMODE: 1 inherits the base dimension's layer/style; 0 uses /// the current creation layer/style. Registry-style, app-level preference. #[serde(default = "default_dimension_continue_mode")] pub dimension_continue_mode: i16, /// DELOBJ: source-geometry deletion policy (0–3). Registry-style, /// app-level preference; first-run default is 1. #[serde(default = "default_delete_objects")] pub delete_objects: i16, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling /// `.bak` so a faulty or accidental save can be recovered (#205). pub backup_on_save: bool, /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, /// When true (default), a sketch constraint's viewport pill shows its /// glyph plus a driven value or named-parameter name. When false, every /// pill shows just the bare glyph, so the value/name text doesn't cover /// canvas detail on a dense sketch. #[serde(default = "default_show_constraint_values")] pub show_constraint_values: bool, /// Inference types, priority, intersection rules, and tolerances used by /// the Auto Constrain command. #[serde(default)] pub auto_constrain: AutoConstrainSettings, /// Keep existing geometry size while solving after a constraint edit. #[serde(default = "default_constraint_solve_mode")] pub constraint_solve_mode: bool, /// Apply eligible geometric constraints while creating geometry. #[serde(default)] pub constraint_infer: bool, /// Constraint bar display bit mask: 1 after applying, 2 on selection. #[serde(default = "default_constraint_bar_display")] pub constraint_bar_display: i16, /// Geometric constraint type bit mask (1..2048, combined; default all). #[serde(default = "default_constraint_bar_mode")] pub constraint_bar_mode: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, /// SCRIPTCOMMANDS: whether a script (the Python plugin) may run OCS commands /// through the host. On by default; a user turns it off with the /// `SCRIPTCOMMANDS 0` command, and a script cannot change it. pub script_commands: bool, /// File type and version used when a new/unsaved drawing is first saved. /// Existing drawings keep their own type and version. pub default_save_format: String, /// PICKADD (#226): `true` (default) = a plain click ADDS to the selection /// (Shift removes); `false` = OS-style — a click REPLACES the selection /// and Shift+click toggles membership. pub pick_add: bool, /// PICKDRAG (#226): `false` (default) = press-drag draws the freeform /// lasso; `true` = press-drag draws a rectangle marquee instead. pub pick_drag_rect: bool, /// Show the floating Quick Properties panel when objects are selected. pub quick_properties: bool, /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, /// CLIPROMPTLINES: how many temporary prompt lines for a single command /// are displayed above the command window (0–50, Registry, default 3). #[serde( default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines" )] pub cliprompt_lines: i32, /// COMMANDLINEFADETIME: how long command-line overlay history lines stay /// visible, in milliseconds (0–60000, default 3000). 0 skips transient lines. #[serde( default = "default_commandline_fade_ms", deserialize_with = "deserialize_commandline_fade_ms" )] pub commandline_fade_ms: i32, /// COMMANDLINEOPACITY: command-line panel opacity in percent (20–100). pub command_line_opacity: u8, /// SNAPUNIT X/Y spacing used by grid snap. 10 matches the Drafting /// Settings dialog defaults; older configs without these keys fall /// back via `default_snap_spacing`. #[serde(default = "default_snap_spacing")] pub snap_spacing_x: f32, #[serde(default = "default_snap_spacing")] pub snap_spacing_y: f32, /// GRIDUNIT X/Y display spacing (grid resizing). Falls back to 10. #[serde(default = "default_snap_spacing")] pub grid_spacing_x: f32, #[serde(default = "default_snap_spacing")] pub grid_spacing_y: f32, /// GRIDMAJOR: every Nth grid line is a brighter major line. #[serde(default = "default_grid_major")] pub grid_major_every: u32, /// Adaptive grid scaling (default on). #[serde(default = "default_true")] pub grid_adaptive: bool, /// Display grid beyond LIMITS (default on, matches dialog). #[serde(default = "default_true")] pub grid_beyond_limits: bool, /// Most-recently-inserted block names, most recent first, capped to 20. /// Used to rank INSERT suggestions without touching the drawing file. #[serde(default)] pub block_mru: Vec, /// Insertion frequency per block name (uppercase key → count). #[serde(default)] pub block_freq: std::collections::HashMap, /// Blocks palette: the recent list (newest first, BLOCKMRULIST long). #[serde(default)] pub block_recent: Vec, /// Blocks palette: favorites, kept locally. #[serde(default)] pub block_favorites: Vec, /// Blocks palette: library folders or drawings, last used first. #[serde(default)] pub block_libraries: Vec, /// Blocks palette view: 0 extra large, 1 large, 2 medium, 3 small icons, /// 4 details, 5 list. #[serde(default = "default_block_view")] pub block_palette_view: u8, /// Blocks palette insertion options. #[serde(default)] pub block_insert: BlockInsertOptions, /// BLOCKMRULIST (0–100, default 50). #[serde(default = "default_block_mru_list")] pub block_mru_list: u8, /// BLOCKREDEFINEMODE (0–2, default 1). #[serde(default = "default_one_u8")] pub block_redefine_mode: u8, /// BLOCKNAVIGATE (default "."). #[serde(default = "default_block_navigate")] pub block_navigate: String, /// The Edit Reference dialog's settings. #[serde(default)] pub refedit: RefEditOptions, /// XFADECTL: how far objects outside the REFEDIT working set fade (0–90, default 70). #[serde(default = "default_xfadectl")] pub xfadectl: u8, /// COUNTCOLOR (default 3). #[serde(default = "default_count_color")] pub count_color: i16, /// COUNTERRORCOLOR (default 1). #[serde(default = "default_count_error_color")] pub count_error_color: i16, /// COUNTSERVICE (default on). #[serde(default = "default_true")] pub count_service: bool, /// Count - Invalid Area: 0 ask, 1 undo, 2 count all of model space. #[serde(default)] pub count_invalid_area: u8, /// SSMAUTOOPEN, SSLOCATE, SSMPOLLTIME, SSMSHEETSTATUS. #[serde(default)] pub sheet_set: crate::ui::window::sheet_set::SheetSetSettings, } /// A block the Blocks palette lists from outside the current drawing (a /// recent or favorite entry): its name, the drawing it came from (empty for /// an unsaved drawing), when it was last used (Unix seconds), and whether it /// stands for a whole drawing inserted as a block. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PaletteBlockRef { pub name: String, #[serde(default)] pub source: String, #[serde(default)] pub time: u64, #[serde(default)] pub drawing: bool, } /// The Edit Reference dialog's settings, remembered between sessions: give /// an external reference's clashing named objects unique names, check out /// attribute definitions, lock what is outside the working set, pick the /// working set's objects in the drawing. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RefEditOptions { pub unique_names: bool, pub attributes: bool, pub lock: bool, pub nested: bool, } impl Default for RefEditOptions { fn default() -> Self { Self { unique_names: true, attributes: false, lock: true, nested: false, } } } /// The Blocks palette's insertion options. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BlockInsertOptions { pub insertion_point: bool, pub scale: bool, pub uniform: bool, pub x: f64, pub y: f64, pub z: f64, pub rotation: bool, pub angle: f64, pub auto_placement: bool, pub repeat: bool, pub explode: bool, } impl Default for BlockInsertOptions { fn default() -> Self { Self { insertion_point: true, scale: false, uniform: false, x: 1.0, y: 1.0, z: 1.0, rotation: false, angle: 0.0, auto_placement: true, repeat: false, explode: false, } } } fn default_block_view() -> u8 { 1 } fn default_xfadectl() -> u8 { 70 } fn default_block_mru_list() -> u8 { 50 } fn default_one_u8() -> u8 { 1 } fn default_count_color() -> i16 { 3 } fn default_count_error_color() -> i16 { 1 } fn default_block_navigate() -> String { ".".to_string() } fn default_clipromptlines() -> i32 { 3 } fn default_delete_objects() -> i16 { 1 } fn default_commandline_fade_ms() -> i32 { 3000 } /// Default SNAPUNIT spacing shown in the Drafting Settings dialog. fn default_snap_spacing() -> f32 { 10.0 } fn default_grid_major() -> u32 { 5 } fn default_true() -> bool { true } /// Clamp a major-line interval to the range the dialog accepts. pub fn sanitize_grid_major(v: u32) -> u32 { v.clamp(2, 100) } /// Clamp a snap spacing to the positive range the dialog accepts. pub fn sanitize_snap_spacing(v: f32) -> f32 { if v.is_finite() && v > 0.0 && v <= 1e9 { v } else { 10.0 } } fn deserialize_commandline_fade_ms<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, { let v = i32::deserialize(de).unwrap_or(3000); Ok(v.clamp(0, 60000)) } pub fn clamp_commandline_fade_ms(v: i32) -> i32 { v.clamp(0, 60000) } fn default_dimension_continue_mode() -> i16 { 1 } fn default_check_missing_fonts() -> bool { true } fn default_show_constraint_values() -> bool { true } fn default_constraint_solve_mode() -> bool { true } fn default_constraint_bar_display() -> i16 { 3 } fn default_constraint_bar_mode() -> i16 { 4095 } fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, { let v = i32::deserialize(de).unwrap_or(3); Ok(v.clamp(0, 50)) } pub fn clamp_clipromptlines(v: i32) -> i32 { v.clamp(0, 50) } impl Default for UserSettings { fn default() -> Self { Self { spacemouse: crate::input::spacemouse::Preferences::default(), dyn_input: true, dyn_mode: 3, polar: false, polar_increment_deg: 45.0, zoom_wheel_reversed: false, touchpad_pan: false, ui_scale_percent: 100, zoom_factor: 60, cursor_size: 5, pick_box: 3, hatch_associative: true, show_menu_bar: false, ribbon_closed: false, options_tab: crate::ui::window::options::OptionsTab::General, show_viewcube: true, show_ucs_icon: true, ucs_icon_at_origin: true, selection_cycling: false, double_click_block_refedit: false, double_click_block_attedit: true, right_click_mode: RightClickMode::ShortcutMenu, right_click_hold_ms: 250, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, image_frame: 1, attdef_on_screen: true, field_display: true, xref_fade: 50, file_dia: true, attach_filter: 7, surface_associativity: true, ucs_ortho: true, ucs_view: true, layout_create_viewport: true, ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, snap_marker_color: None, command_text_color: None, lineweight_display_scale: 100, isometric_drafting: false, iso_plane: IsoPlane::Left, snap_angle_deg: 0.0, otrack: false, default_assoc_prompted: false, check_missing_fonts: true, font_source_url: String::new(), qnew_template: String::new(), donation_prompt_version: String::new(), gpu_warning_silenced: String::new(), disabled_plugins: Vec::new(), plugin_repos: Vec::new(), literal_spaces: false, command_history_height: crate::ui::command_line::HISTORY_HEIGHT_DEFAULT, // Snapper::default(): END|MID|CEN|NODE|QUAD|INT|NEA (575), master // on. Object snap is a drafting aid users expect to be live from // the first click; the suppress bit (16384) is left for the user // to set via the status-bar pill or OSNAP. osmode: 575, texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, delete_objects: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, auto_constrain: AutoConstrainSettings::default(), constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, constraint_bar_mode: 4095, savetime_min: 10, script_commands: true, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, pick_drag_rect: false, quick_properties: false, language: crate::i18n::Language::default(), cliprompt_lines: 3, commandline_fade_ms: 3000, command_line_opacity: 80, snap_spacing_x: 10.0, snap_spacing_y: 10.0, grid_spacing_x: 10.0, grid_spacing_y: 10.0, grid_major_every: 5, grid_adaptive: true, grid_beyond_limits: true, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), block_recent: Vec::new(), block_favorites: Vec::new(), block_libraries: Vec::new(), block_palette_view: 1, block_insert: BlockInsertOptions::default(), block_mru_list: 50, block_redefine_mode: 1, block_navigate: ".".to_string(), refedit: RefEditOptions::default(), xfadectl: 70, count_color: 3, count_error_color: 1, count_service: true, count_invalid_area: 0, sheet_set: Default::default(), } } } #[cfg(test)] mod tests { use super::*; /// The Options read-out has to say what a notch really does, or it is /// worse than no read-out. These are the percentages of the law in /// `Camera::zoom`, which is where the number comes from: the default a /// tenth of the distance, the top of the system variable's range a /// sixth, and the top of the application's range a little over half. #[test] fn the_zoom_read_out_says_what_a_notch_does() { for (factor, percent) in [(3, 0.53_f32), (60, 10.0), (100, 16.1), (500, 58.4)] { let shown = zoom_notch_percent(factor); assert!( (shown - percent).abs() < 0.05, "ZOOMFACTOR {factor} reads {shown}%, not {percent}%" ); } // A value from outside the range reads as the one that will be used. assert_eq!(zoom_notch_percent(900), zoom_notch_percent(ZOOM_FACTOR_MAX)); assert_eq!(zoom_notch_percent(0), zoom_notch_percent(ZOOM_FACTOR_MIN)); } /// The default is one whole step, which is the whole reason the camera's /// base is the per-notch ratio and not something scaled. #[test] fn the_default_zoom_factor_is_one_step() { assert_eq!(zoom_notch_steps(60), 1.0); assert_eq!(UserSettings::default().zoom_factor, 60); } /// Object snap ships live. The modes were always pre-selected; only the /// master switch was off, so a new user got a configured snap set that /// did nothing until they found the status-bar pill. /// /// This is asserted rather than checked by eye because the default is /// only observable in a fresh profile: the app persists settings on /// change, so an existing settings.json keeps whatever osmode it already /// holds and never reveals what a new install would do. #[test] fn snapping_is_enabled_in_a_fresh_profile() { let settings = UserSettings::default(); assert_eq!( settings.osmode & OSMODE_SUPPRESS, 0, "the suppress bit is set, so snapping ships off" ); let (modes, master_on) = snaps_from_osmode(settings.osmode); assert!(master_on, "decoding the default must report snapping on"); for expected in [ SnapType::Endpoint, SnapType::Midpoint, SnapType::Center, SnapType::Intersection, ] { assert!( modes.contains(&expected), "{expected:?} is not in the default set" ); } // The Snapper and the persisted default have to agree, or the running // state and the saved state disagree the moment anything is written. assert_eq!(crate::snap::Snapper::default().snap_enabled, master_on); } #[test] fn osmode_encodes_bits_and_suppress() { // Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on. let on = [ SnapType::Endpoint, SnapType::Midpoint, SnapType::Intersection, ]; assert_eq!(osmode_from_snaps(on.iter(), true), 35); // Master off sets the suppress bit (16384). assert_eq!(osmode_from_snaps(on.iter(), false), 35 | 16384); // OCS-only snaps carry no bit and are dropped. assert_eq!( osmode_from_snaps([SnapType::Grid, SnapType::ObjectPick].iter(), true), 0 ); } #[test] fn osmode_decodes_bits_and_suppress() { let (modes, enabled) = snaps_from_osmode(35); let set: std::collections::HashSet<_> = modes.into_iter().collect(); assert!(enabled); assert_eq!(set.len(), 3); assert!(set.contains(&SnapType::Endpoint)); assert!(set.contains(&SnapType::Midpoint)); assert!(set.contains(&SnapType::Intersection)); // Suppress bit → master off; the mode bits still decode. let (_m, en) = snaps_from_osmode(35 | 16384); assert!(!en); } #[test] fn osmode_round_trips_every_mappable_mode() { // All 13 running snaps + master on encode and decode back to the same set. let all: Vec = SNAP_ORDER.to_vec(); let bits = osmode_from_snaps(all.iter(), true); let (back, enabled) = snaps_from_osmode(bits); assert!(enabled); let a: std::collections::HashSet<_> = all.into_iter().collect(); let b: std::collections::HashSet<_> = back.into_iter().collect(); assert_eq!(a, b); } #[test] fn a_page_name_that_no_longer_exists_does_not_cost_the_other_settings() { let json = r#"{ "settings": { "options_tab": "drawing", "pick_add": false, "savetime_min": 42 } }"#; let cfg: crate::app::config::AppConfig = serde_json::from_str(json).expect("an unknown page name must not fail the parse"); assert_eq!( cfg.settings.options_tab, crate::ui::window::options::OptionsTab::General, ); assert!(!cfg.settings.pick_add, "the rest of the file must survive"); assert_eq!(cfg.settings.savetime_min, 42); } #[test] fn script_commands_default_on_and_survive_a_round_trip() { let missing: crate::app::config::AppConfig = serde_json::from_str(r#"{"settings": {"pick_add": false}}"#).unwrap(); assert!( missing.settings.script_commands, "an old config keeps scripts allowed" ); let off: crate::app::config::AppConfig = serde_json::from_str(r#"{"settings": {"script_commands": false}}"#).unwrap(); assert!(!off.settings.script_commands); let text = serde_json::to_string(&off).unwrap(); let back: crate::app::config::AppConfig = serde_json::from_str(&text).unwrap(); assert!(!back.settings.script_commands); } #[test] fn isometric_drafting_never_persists_or_loads() { // A persisted On used to bring the isoplane crosshair (vertical + // diagonal arms) back on every launch for every drawing. The flag is // session-only now: it is dropped when saving and ignored when a // settings file still carries it. let mut settings = UserSettings::default(); settings.isometric_drafting = true; let json = serde_json::to_string(&settings).expect("serialize settings"); assert!( !json.contains("isometric_drafting"), "the saved settings must not carry the session-only flag: {json}" ); let with_flag = r#"{ "isometric_drafting": true, "iso_plane": "Left" }"#; let loaded: UserSettings = serde_json::from_str(with_flag).expect("an old settings file must still parse"); assert!( !loaded.isometric_drafting, "a persisted On must not turn isometric drafting back on" ); } }