Files
OneCAD-Draft/src/app/settings.rs
T
Felix RiestraandClaude Sonnet 5 d675244fb8 Merge branch 'main' into feature/parametric-constraint-system
Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).

Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:

- src/app/automation.rs, src/app/command_driver.rs: combined both
  sides — kept this branch's sketch-constraint/named-parameter loading
  on "new"/"open", adopted main's Scene::clear()/material_base_dir and
  its newer rebuild_derived_caches() over the older bump_geometry().
  Scene::clear() (src/scene/entity.rs) now also resets
  sketch_constraints/named_parameters, so every caller gets this for
  free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
  outright (commit 973af87d, authored by Hakan directly) — a large
  MINSERT is no longer purged as "corrupt" at load, only its rendering
  is bounded, preserving the source data. This branch's own earlier
  design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
  guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
  copy was an explicit verbatim snapshot from an earlier upstream PR
  (commit 4c264b8c) with zero constraint-system-specific content, and
  main has since evolved it considerably further.

Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").

Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-09 20:44:38 +02:00

421 lines
16 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.
//! 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};
/// 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",
}
}
}
/// 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::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) that 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::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 => 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)
}
/// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn.
pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100;
/// 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 dyn_input: bool,
pub polar: bool,
pub polar_increment_deg: f32,
pub zoom_wheel_reversed: bool,
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,
/// 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,
/// 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,
/// 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]>,
/// Model-space lineweight preview scale as a percentage.
pub lineweight_display_scale: i32,
/// Whole-interface scale, as a percentage — every panel, dialog, the
/// ribbon, and their text, not just one widget's font size. Wired
/// straight into iced's own per-window `scale_factor` hook
/// (`OpenCADStudio::run`), so a change takes effect immediately with no
/// per-panel plumbing needed. Relies on the struct-level `#[serde(default)]`
/// (via `impl Default` below) to backfill this field for a config file
/// saved before this setting existed, same as every other field here.
pub ui_scale: i32,
/// Isometric drafting changes the grid and crosshair to the active axis pair.
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,
/// App version whose donation prompt has been displayed.
pub donation_prompt_version: 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,
/// 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, saving a drawing also writes its sketch constraints as
/// AutoCAD's own native object graph (`scene::dwg_native_constraints`),
/// not just the app's own XRecord format. Off by default — opt-in, since
/// it adds file weight every save whether or not AutoCAD/BricsCAD
/// interop is needed. See `write_dwg_native_constraints` on the app
/// struct for the full contract (a drawing that already has this native
/// graph keeps it synced regardless of this setting).
#[serde(default)]
pub write_dwg_native_constraints: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
/// 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,
/// Persisted viewport background colours (0–255 RGB); `None` = app default
/// (dark grey model / off-white paper). Applied to every drawing tab on
/// launch and to tabs opened later, so a chosen background survives restarts
/// (#188).
pub bg_color: Option<[u8; 3]>,
pub paper_bg_color: Option<[u8; 3]>,
/// 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,
/// 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>,
}
fn default_clipromptlines() -> i32 {
3
}
fn default_commandline_fade_ms() -> i32 {
3000
}
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 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 {
dyn_input: true,
polar: false,
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
cursor_size: 5,
pick_box: 3,
double_click_block_refedit: false,
double_click_block_attedit: true,
grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT,
cursor_type: CursorType::Crosshair,
crosshair_color: None,
lineweight_display_scale: 100,
ui_scale: 100,
isometric_drafting: false,
iso_plane: IsoPlane::Left,
snap_angle_deg: 0.0,
otrack: false,
default_assoc_prompted: false,
donation_prompt_version: 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
// off (suppress bit 16384).
osmode: 575 | OSMODE_SUPPRESS,
texteditmode: false,
quick_dimension_snap_priority: 0,
dimension_continue_mode: 1,
textfill: true,
backup_on_save: true,
file_assoc_enabled: true,
write_dwg_native_constraints: false,
savetime_min: 10,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true,
pick_drag_rect: false,
quick_properties: false,
bg_color: None,
paper_bg_color: None,
language: crate::i18n::Language::default(),
cliprompt_lines: 3,
commandline_fade_ms: 3000,
block_mru: Vec::new(),
block_freq: std::collections::HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
}