diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 0a6a58a6a..4b8f85d13 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -3,10 +3,19 @@ use crate::ui::window::drafting_settings::{DraftingSettingsState, DraftingSettin impl DraftingSettingsState { pub(crate) fn from_app(app: &OpenCADStudio) -> Self { + let snap_x = app.snapper.snap_spacing_x; + let snap_y = app.snapper.snap_spacing_y; + // The dialog has no stored "equal" flag, so infer it: equal when + // X and Y match. This keeps the checkbox checked for the common + // case (including fresh defaults of 10/10). + let snap_equal = (snap_x - snap_y).abs() < 1e-6; Self { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: app.snapper.grid_snap(), grid_on: app.show_grid, + snap_x_input: crate::ui::window::drafting_settings::format_snap_spacing(snap_x), + snap_y_input: crate::ui::window::drafting_settings::format_snap_spacing(snap_y), + snap_equal, isometric: app.isometric_drafting, iso_plane: app.iso_plane, snap_angle_deg: app.snap_angle_deg, @@ -33,24 +42,42 @@ impl OpenCADStudio { } } - pub(super) fn apply_drafting_settings(&mut self) { - if let Some(state) = &self.drafting_settings_state { - self.show_grid = state.grid_on; - self.snapper.grid_snap_on = state.snap_on; - self.isometric_drafting = state.isometric; - self.iso_plane = state.iso_plane; - self.snap_angle_deg = state.snap_angle_deg; - self.polar_mode = state.polar_on; - self.ortho_mode = state.ortho_on; - self.polar_increment_deg = state.polar_increment_deg; - self.snapper.snap_enabled = state.osnap_on; - self.snapper.otrack_enabled = state.otrack_on; - self.snapper.enabled = state.snap_modes.clone(); - self.dyn_input = state.dyn_input_on; - self.quick_properties = state.quick_props_on; - self.selection_cycling = state.selection_cycling_on; - self.sync_vport_display(self.active_tab); + pub(super) fn apply_drafting_settings(&mut self) -> bool { + let Some(state) = &self.drafting_settings_state.clone() else { + return true; + }; + let x = crate::ui::window::drafting_settings::parse_snap_spacing(&state.snap_x_input); + let y = crate::ui::window::drafting_settings::parse_snap_spacing(&state.snap_y_input); + let (Some(sx), Some(sy)) = (x, y) else { + self.command_line + .push_error(crate::t!("Snap X and Y spacings must be positive numbers.").as_ref()); + return false; + }; + // When locked, Y follows X so the two can never diverge. + let sy = if state.snap_equal { sx } else { sy }; + if let Some(live) = &mut self.drafting_settings_state { + if live.snap_equal { + live.snap_y_input = live.snap_x_input.clone(); + } } + self.show_grid = state.grid_on; + self.snapper.grid_snap_on = state.snap_on; + self.snapper.snap_spacing_x = sx; + self.snapper.snap_spacing_y = sy; + self.isometric_drafting = state.isometric; + self.iso_plane = state.iso_plane; + self.snap_angle_deg = state.snap_angle_deg; + self.polar_mode = state.polar_on; + self.ortho_mode = state.ortho_on; + self.polar_increment_deg = state.polar_increment_deg; + self.snapper.snap_enabled = state.osnap_on; + self.snapper.otrack_enabled = state.otrack_on; + self.snapper.enabled = state.snap_modes.clone(); + self.dyn_input = state.dyn_input_on; + self.quick_properties = state.quick_props_on; + self.selection_cycling = state.selection_cycling_on; + self.sync_vport_display(self.active_tab); + true } } @@ -64,6 +91,9 @@ mod tests { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: false, grid_on: true, + snap_x_input: "10".to_string(), + snap_y_input: "10".to_string(), + snap_equal: true, isometric: false, iso_plane: crate::app::settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -96,6 +126,9 @@ mod tests { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: false, grid_on: true, + snap_x_input: "10".to_string(), + snap_y_input: "10".to_string(), + snap_equal: true, isometric: false, iso_plane: crate::app::settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -166,5 +199,29 @@ mod tests { let mut modded = base.clone(); modded.selection_cycling_on = true; assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_x_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_y_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_equal = false; + assert!(modded.is_dirty(&base)); + } + + #[test] + fn test_parse_snap_spacing() { + use crate::ui::window::drafting_settings::{format_snap_spacing, parse_snap_spacing}; + assert_eq!(parse_snap_spacing("10"), Some(10.0)); + assert_eq!(parse_snap_spacing(" 2.5 "), Some(2.5)); + assert_eq!(parse_snap_spacing("0"), None); + assert_eq!(parse_snap_spacing("-1"), None); + assert_eq!(parse_snap_spacing("abc"), None); + assert_eq!(parse_snap_spacing(""), None); + assert_eq!(format_snap_spacing(10.0), "10"); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 7581f9956..a46a17382 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2741,6 +2741,9 @@ pub enum Message { DraftingSettingsTabChanged(crate::ui::window::drafting_settings::DraftingSettingsTab), DraftingSettingsToggleGrid, DraftingSettingsToggleSnap, + DraftingSettingsSnapXChanged(String), + DraftingSettingsSnapYChanged(String), + DraftingSettingsToggleEqualSnap, DraftingSettingsToggleIsometric, DraftingSettingsSetIsoPlane(crate::app::settings::IsoPlane), DraftingSettingsResetRotation, diff --git a/src/app/settings.rs b/src/app/settings.rs index b19d6daab..9f8ac2078 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -434,6 +434,13 @@ pub struct UserSettings { deserialize_with = "deserialize_commandline_fade_ms" )] pub commandline_fade_ms: i32, + /// 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, /// Most-recently-inserted block names, most recent first, capped to 20. /// Used to rank INSERT suggestions without touching the drawing file. #[serde(default)] @@ -455,6 +462,20 @@ fn default_commandline_fade_ms() -> i32 { 3000 } +/// Default SNAPUNIT spacing shown in the Drafting Settings dialog. +fn default_snap_spacing() -> f32 { + 10.0 +} + +/// 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>, @@ -552,6 +573,8 @@ impl Default for UserSettings { language: crate::i18n::Language::default(), cliprompt_lines: 3, commandline_fade_ms: 3000, + snap_spacing_x: 10.0, + snap_spacing_y: 10.0, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 3454f0e59..6d20f0430 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -443,6 +443,8 @@ impl OpenCADStudio { commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms( self.commandline_fade_ms, ), + snap_spacing_x: self.snapper.snap_spacing_x, + snap_spacing_y: self.snapper.snap_spacing_y, block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), } @@ -540,6 +542,10 @@ impl OpenCADStudio { crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms); self.command_line .set_commandline_fade_ms(self.commandline_fade_ms.clamp(0, 60000) as u32); + self.snapper.snap_spacing_x = + crate::app::settings::sanitize_snap_spacing(s.snap_spacing_x); + self.snapper.snap_spacing_y = + crate::app::settings::sanitize_snap_spacing(s.snap_spacing_y); // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) self.block_mru = s.block_mru.iter().take(20).cloned().collect(); self.block_freq = s diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 185fbddac..bfff47d56 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4484,6 +4484,33 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsSnapXChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_x_input = value.clone(); + if state.snap_equal { + state.snap_y_input = value; + } + } + Task::none() + } + Message::DraftingSettingsSnapYChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_y_input = value.clone(); + if state.snap_equal { + state.snap_x_input = value; + } + } + Task::none() + } + Message::DraftingSettingsToggleEqualSnap => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_equal = !state.snap_equal; + if state.snap_equal { + state.snap_y_input = state.snap_x_input.clone(); + } + } + Task::none() + } Message::DraftingSettingsToggleIsometric => { if let Some(state) = &mut self.drafting_settings_state { state.isometric = !state.isometric; @@ -4574,17 +4601,19 @@ impl OpenCADStudio { Task::none() } Message::DraftingSettingsApply => { - self.apply_drafting_settings(); - self.drafting_settings_saved = self.drafting_settings_state.clone(); - self.persist_settings_if_changed(); + if self.apply_drafting_settings() { + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.persist_settings_if_changed(); + } Task::none() } Message::DraftingSettingsOk => { - self.apply_drafting_settings(); - self.drafting_settings_saved = self.drafting_settings_state.clone(); - self.drafting_settings_close_confirm = false; - self.persist_settings_if_changed(); - self.close_active_modal(); + if self.apply_drafting_settings() { + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.drafting_settings_close_confirm = false; + self.persist_settings_if_changed(); + self.close_active_modal(); + } Task::none() } Message::DraftingSettingsClose => { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2971a4ab7..ada2773e0 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1928,7 +1928,9 @@ impl OpenCADStudio { ) } }; - // Sync grid-snap spacing to the adaptive spacing of the visible grid. + // Sync the adaptive visible-grid step. It drives display and + // snap-aperture tolerances — grid *snap* itself locks to the + // fixed SNAPUNIT X/Y spacing from the Drafting Settings dialog. self.snapper.grid_spacing = grid_spacing; // Cursor and wires are model-space; the snap result is model. let snap_cursor = cursor_world; diff --git a/src/snap.rs b/src/snap.rs index fa837110b..5d3b31518 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -149,6 +149,11 @@ pub struct Snapper { pub grid_snap_on: bool, /// World-space grid spacing. pub grid_spacing: f32, + /// User-configurable snap spacing (SNAPUNIT X/Y). Used for grid-snap + /// positions; kept separate from the adaptive `grid_spacing`, which + /// tracks the visible grid step and drives tolerances. + pub snap_spacing_x: f32, + pub snap_spacing_y: f32, /// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and /// extension so the catch distance is the same everywhere. pub osnap_radius_px: f32, @@ -216,6 +221,8 @@ impl Default for Snapper { enabled, grid_snap_on: false, grid_spacing: 1.0, + snap_spacing_x: 10.0, + snap_spacing_y: 10.0, osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX, otrack_enabled: false, tracking_points: Vec::new(), @@ -1012,6 +1019,8 @@ impl Snapper { }, grid_snap_on: false, grid_spacing: self.grid_spacing, + snap_spacing_x: self.snap_spacing_x, + snap_spacing_y: self.snap_spacing_y, osnap_radius_px: self.osnap_radius_px, otrack_enabled: false, tracking_points: Vec::new(), @@ -1084,9 +1093,16 @@ impl Snapper { // Object snaps therefore NEVER catch grid points; only when grid snap is // on can a grid corner be picked. It is evaluated first and at the // lowest priority, so any object snap inside the aperture overrides it. + // Unlike object snap, grid snap LOCKS: like AutoCAD SNAP, every point + // rounds to the grid — it is not gated by the aperture. (Gating it + // would break as soon as the fixed SNAPUNIT spacing differs from the + // adaptive visible-grid step on screen.) if self.grid_snap_on { - let s = self.grid_spacing as f64; - if s.abs() > 1e-9 { + let sx = self.snap_spacing_x as f64; + let sy = self.snap_spacing_y as f64; + // Z has no independent spacing in the dialog; follow X. + let sz = sx; + if sx.abs() > 1e-9 && sy.abs() > 1e-9 && sz.abs() > 1e-9 { // Round in the UCS grid frame, then map back to world. let (ax, ay, az) = grid_axes; let ax = ax.normalize_or(Vec3::X).as_dvec3(); @@ -1107,13 +1123,16 @@ impl Snapper { } else { (rel.dot(ax), rel.dot(ay)) }; - let ux = (ux / s).round() * s; - let uy = (uy / s).round() * s; - let uz = (rel.dot(az) / s).round() * s; + let ux = (ux / sx).round() * sx; + let uy = (uy / sy).round() * sy; + let uz = (rel.dot(az) / sz).round() * sz; let gp = origin + ax * ux + ay * uy + az * uz; let screen = world_to_screen(gp, view_rot, eye, bounds); let d2 = dist2(screen, cursor_screen); - if d2 < radius2 && in_bounds(screen) { + // Lock, don't magnet: any in-pane grid point stands, so the + // cursor jumps grid-to-grid even when the SNAPUNIT spacing + // is far from the adaptive visible-grid step. + if in_bounds(screen) { best = Some(SnapResult { world: gp, screen, @@ -3815,4 +3834,32 @@ mod ext_tests { assert_eq!(res.world, DVec3::ZERO); } + #[test] + fn grid_snap_locks_beyond_aperture_with_independent_xy() { + let mut s = Snapper::default(); + s.grid_snap_on = true; + s.snap_enabled = false; // grid only + s.snap_spacing_x = 1.0; + s.snap_spacing_y = 0.5; + s.osnap_radius_px = 15.0; + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(0.0, 0.0, 500.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + let wires: Vec = Vec::new(); + + // 500 px per world unit: the cursor sits ~160 px from the nearest + // grid corner — far outside the 15 px aperture — yet SNAP must still + // lock like AutoCAD instead of magnetizing only when near a point. + let cursor_world = DVec3::new(0.32, 0.47, 500.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); + let res = s.snap( + cursor_world, cursor_screen, &wires, + view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("grid snap must lock even outside the aperture"); + assert_eq!(res.snap_type, SnapType::Grid); + assert!((res.world.x - 0.0).abs() < 1e-9, "x rounds to 0: {:?}", res.world); + assert!((res.world.y - 0.5).abs() < 1e-9, "y rounds to 0.5: {:?}", res.world); + } + } diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index c0541d274..ae3ffb2fa 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -1,7 +1,7 @@ use crate::app::settings::IsoPlane; use crate::app::Message; use crate::snap::{SnapType, ALL_SNAP_MODES}; -use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Fill, Length, Theme}; use std::borrow::Cow; @@ -28,6 +28,9 @@ pub struct DraftingSettingsState { // Snap and Grid pub snap_on: bool, pub grid_on: bool, + pub snap_x_input: String, + pub snap_y_input: String, + pub snap_equal: bool, pub isometric: bool, pub iso_plane: IsoPlane, pub snap_angle_deg: f32, @@ -55,6 +58,9 @@ impl DraftingSettingsState { pub fn is_dirty(&self, saved: &Self) -> bool { self.snap_on != saved.snap_on || self.grid_on != saved.grid_on + || self.snap_x_input != saved.snap_x_input + || self.snap_y_input != saved.snap_y_input + || self.snap_equal != saved.snap_equal || self.isometric != saved.isometric || self.iso_plane != saved.iso_plane || (self.snap_angle_deg - saved.snap_angle_deg).abs() > 0.001 @@ -71,6 +77,25 @@ impl DraftingSettingsState { } } +/// Format a snap spacing value for the dialog's text inputs. +pub fn format_snap_spacing(v: f32) -> String { + if (v - v.round()).abs() < 1e-4 { + format!("{}", v.round() as i32) + } else { + format!("{v}") + } +} + +/// Parse a snap spacing input. Spacings must be positive and finite. +pub fn parse_snap_spacing(s: &str) -> Option { + let v: f32 = s.trim().parse().ok()?; + if v.is_finite() && v > 0.0 && v <= 1e9 { + Some(v) + } else { + None + } +} + /// Helper for grouped sub-panels with a light border and header title. fn group<'a>(title: impl Into, body: impl Into>) -> Element<'a, Message> { container( @@ -187,18 +212,28 @@ pub fn view_window<'a>( column![ row![ text(crate::t!("Snap X spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.snap_x_input) + .on_input(Message::DraftingSettingsSnapXChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ text(crate::t!("Snap Y spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.snap_y_input) + .on_input(Message::DraftingSettingsSnapYChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ - checkbox(true).size(14), + checkbox(state.snap_equal) + .on_toggle(|_| Message::DraftingSettingsToggleEqualSnap) + .size(14), text(crate::t!("Equal X and Y spacing")).size(11), ] .spacing(6)