forked from 1b-agent/opencadstudio-upstream
Name clashes, DGNIMPORTMODE and prefixing as the reference names them; V7 cell copies, unshared cells and mirrored placements as blocks; justified text and text nodes placed at their justification points with the reference's MTEXT contents; B-spline curves as splines and 3D arcs and ellipses in their own planes; every V8 level as a layer; design models only; reference attachments translated, ignored or attached as underlays. The colour index palette comes from the codec fix it depends on. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1252 lines
46 KiB
Rust
1252 lines
46 KiB
Rust
//! 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<AutoConstraintKind>,
|
||
pub enabled: Vec<AutoConstraintKind>,
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TXT2MTXT (Combine Text) settings, the profile values
|
||
/// TXT2MTXTCOMBINEMTEXT, TXT2MTXTTEXTORDER, TXT2MTXTWORDWRAP and
|
||
/// TXT2MTXTLINESPACING.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct Txt2MtxtSettings {
|
||
/// Combine everything into one MTEXT (else each TEXT becomes its own).
|
||
pub combine: bool,
|
||
/// Keep the selection order (else sort top-down).
|
||
pub select_order: bool,
|
||
/// Run the lines on separated by spaces in a computed width (else one
|
||
/// paragraph per object).
|
||
pub word_wrap: bool,
|
||
/// Force uniform line spacing (always on while word-wrap is).
|
||
pub uniform_spacing: bool,
|
||
}
|
||
|
||
impl Default for Txt2MtxtSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
combine: true,
|
||
select_order: false,
|
||
word_wrap: true,
|
||
uniform_spacing: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Which mouse gestures pan and orbit the model view.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum NavigationStyle {
|
||
/// Middle button pans; Shift + middle button orbits.
|
||
#[default]
|
||
Cad,
|
||
/// Middle button orbits; Shift + middle button pans.
|
||
Blender,
|
||
/// Middle button pans; middle and left buttons together orbit.
|
||
Catia,
|
||
}
|
||
|
||
impl NavigationStyle {
|
||
pub const ALL: [NavigationStyle; 3] = [Self::Cad, Self::Blender, Self::Catia];
|
||
}
|
||
|
||
impl std::fmt::Display for NavigationStyle {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(&match self {
|
||
Self::Cad => crate::t!("CAD: Shift + middle button orbits"),
|
||
Self::Blender => crate::t!("Blender: middle button orbits, Shift pans"),
|
||
Self::Catia => crate::t!("CATIA: middle + left button orbits"),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 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<i32> {
|
||
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<Item = &'a SnapType>,
|
||
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<SnapType>, 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<crate::ui::window::options::OptionsTab, D::Error>
|
||
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,
|
||
pub navigation_style: NavigationStyle,
|
||
/// UCSDETECT: a command's first point on a planar solid face sets the
|
||
/// UCS to that face until the command ends.
|
||
pub dynamic_ucs: 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,
|
||
/// Block Definition dialog: base point "Specify On-screen".
|
||
pub block_base_on_screen: bool,
|
||
/// Block Definition dialog: objects "Specify On-screen".
|
||
pub block_objects_on_screen: bool,
|
||
/// Block Definition dialog / -BLOCK object conversion (0 Retain,
|
||
/// 1 Convert, 2 Delete).
|
||
pub block_object_mode: u8,
|
||
/// Block Definition dialog: "Allow exploding".
|
||
pub block_allow_exploding: bool,
|
||
/// Block Definition dialog: open the new block in the block editor.
|
||
pub block_open_in_editor: bool,
|
||
/// Write Block dialog / -WBLOCK object conversion (0 Retain, 1 Convert,
|
||
/// 2 Delete).
|
||
pub wblock_object_mode: u8,
|
||
/// 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,
|
||
/// TRIMEXTENDMODE: TRIM and EXTEND start in Quick mode (else Standard).
|
||
pub trim_extend_quick: bool,
|
||
/// Fonts and plot styles kept in a chosen folder (`None`: the default).
|
||
pub fonts_folder: Option<String>,
|
||
pub plot_styles_folder: Option<String>,
|
||
/// 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,
|
||
/// PDFSHXTEXT's recognition settings (fonts, threshold, PDFSHXLAYER,
|
||
/// PDFSHXBESTFONT, PDFSHXTEXTSETTINGS).
|
||
pub pdf_shx_text: crate::modules::insert::pdf_shx_text::ShxTextSettings,
|
||
/// DGNIMPORTMODE and the DGN Import window's last choices.
|
||
pub dgn_import: crate::modules::insert::dgn_import::DgnImportSettings,
|
||
/// 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<String>,
|
||
/// Linked plugin source repositories (`owner/repo`) the marketplace installs
|
||
/// from.
|
||
pub plugin_repos: Vec<String>,
|
||
/// 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
|
||
/// `<name>.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,
|
||
/// TXT2MTXT settings (Text to MText Settings dialog).
|
||
#[serde(default)]
|
||
pub txt2mtxt: Txt2MtxtSettings,
|
||
/// 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,
|
||
/// Draw the coloured X/Y axis lines through the grid origin.
|
||
#[serde(default = "default_true")]
|
||
pub grid_axes: 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<String>,
|
||
/// Insertion frequency per block name (uppercase key → count).
|
||
#[serde(default)]
|
||
pub block_freq: std::collections::HashMap<String, u32>,
|
||
/// Blocks palette: the recent list (newest first, BLOCKMRULIST long).
|
||
#[serde(default)]
|
||
pub block_recent: Vec<PaletteBlockRef>,
|
||
/// Blocks palette: favorites, kept locally.
|
||
#[serde(default)]
|
||
pub block_favorites: Vec<PaletteBlockRef>,
|
||
/// Blocks palette: library folders or drawings, last used first.
|
||
#[serde(default)]
|
||
pub block_libraries: Vec<String>,
|
||
/// 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<i32, D::Error>
|
||
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<i32, D::Error>
|
||
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,
|
||
navigation_style: NavigationStyle::Cad,
|
||
dynamic_ucs: true,
|
||
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,
|
||
block_base_on_screen: false,
|
||
block_objects_on_screen: false,
|
||
block_object_mode: 1,
|
||
block_allow_exploding: true,
|
||
block_open_in_editor: false,
|
||
wblock_object_mode: 0,
|
||
field_display: true,
|
||
xref_fade: 50,
|
||
file_dia: true,
|
||
trim_extend_quick: true,
|
||
fonts_folder: None,
|
||
plot_styles_folder: None,
|
||
attach_filter: 7,
|
||
surface_associativity: true,
|
||
ucs_ortho: true,
|
||
ucs_view: true,
|
||
layout_create_viewport: true,
|
||
pdf_shx_text: Default::default(),
|
||
dgn_import: Default::default(),
|
||
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(),
|
||
txt2mtxt: Txt2MtxtSettings::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,
|
||
grid_axes: 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<SnapType> = 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"
|
||
);
|
||
}
|
||
}
|