diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 9303281a4..f6f23ef93 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -224,6 +224,43 @@ impl OpenCADStudio { "SNAP" => { return Some(Task::done(Message::ToggleGridSnap)); } + // ISOPLANE — cycle the isometric drafting axis pair (F5). + "ISOPLANE" => { + return Some(Task::done(Message::CycleIsoPlane)); + } + cmd if cmd.starts_with("ISOPLANE ") => { + let plane = match cmd.trim_start_matches("ISOPLANE").trim() { + "LEFT" | "L" => Some(crate::app::settings::IsoPlane::Left), + "TOP" | "T" => Some(crate::app::settings::IsoPlane::Top), + "RIGHT" | "R" => Some(crate::app::settings::IsoPlane::Right), + _ => None, + }; + if let Some(plane) = plane { + return Some(Task::done(Message::SetIsoPlane(plane))); + } + self.command_line + .push_error(crate::t!("ISOPLANE: expected Left, Top, or Right.").as_ref()); + } + // ISODRAFT — enable or disable isometric drafting. + "ISODRAFT" => { + return Some(Task::done(Message::ToggleIsometricDrafting)); + } + cmd if cmd.starts_with("ISODRAFT ") => { + let requested = match cmd.trim_start_matches("ISODRAFT").trim() { + "1" | "ON" => Some(true), + "0" | "OFF" => Some(false), + _ => None, + }; + match requested { + Some(value) if value != self.isometric_drafting => { + return Some(Task::done(Message::ToggleIsometricDrafting)); + } + Some(_) => {} + None => self + .command_line + .push_error(crate::t!("ISODRAFT: expected On or Off.").as_ref()), + } + } // POLAR — toggle polar tracking. "POLAR" => { return Some(Task::done(Message::TogglePolar)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 8d3e76b12..6843a5cda 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -301,6 +301,8 @@ inventory::submit!(crate::command::CommandRegistration { "CUI", "DSETTINGS", "GRID", + "ISODRAFT", + "ISOPLANE", "OSNAP", "POLAR", "QUICKPROPERTIES", @@ -448,6 +450,10 @@ inventory::submit!(crate::command::CommandRegistration { "MIRRTEXT", "ZOOMWHEEL", "ZOOMFACTOR", + "CURSORSIZE", + "PICKBOX", + "CURSORTYPE", + "SNAPANG", "ATTREQ", "ATTDIA", "DIMASSOC", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 6bc5bfff6..e18432c25 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -868,6 +868,10 @@ impl OpenCADStudio { "MIRRTEXT" | "ZOOMWHEEL" | "ZOOMFACTOR" + | "CURSORSIZE" + | "PICKBOX" + | "CURSORTYPE" + | "SNAPANG" | "TEXTFILL" | "ATTREQ" | "ATTDIA" @@ -938,7 +942,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { // Parse a boolean given as 0/1 or ON/OFF. @@ -1061,6 +1065,56 @@ impl OpenCADStudio { Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) } }, + "CURSORSIZE" => match &value { + Some(v) => match v.parse::() { + Ok(size) if (1..=100).contains(&size) => { + self.cursor_size = size; + Ok((format!("CURSORSIZE = {size}"), true)) + } + _ => Err("SETVAR: integer from 1 to 100 required.".into()), + }, + None => Ok((format!("CURSORSIZE = {}", self.cursor_size), false)), + }, + "PICKBOX" => match &value { + Some(v) => match v.parse::() { + Ok(size) if (0..=50).contains(&size) => { + self.pick_box = size; + Ok((format!("PICKBOX = {size}"), true)) + } + _ => Err("SETVAR: integer from 0 to 50 required.".into()), + }, + None => Ok((format!("PICKBOX = {}", self.pick_box), false)), + }, + "CURSORTYPE" => match &value { + Some(v) => match v.as_str() { + "0" => { + self.cursor_type = crate::app::settings::CursorType::Crosshair; + Ok(("CURSORTYPE = 0".to_string(), true)) + } + "1" => { + self.cursor_type = crate::app::settings::CursorType::Pointer; + Ok(("CURSORTYPE = 1".to_string(), true)) + } + _ => Err("SETVAR: 0 or 1 required.".into()), + }, + None => Ok(( + format!( + "CURSORTYPE = {}", + i32::from(self.cursor_type == crate::app::settings::CursorType::Pointer) + ), + false, + )), + }, + "SNAPANG" => match &value { + Some(v) => match v.parse::() { + Ok(angle) if angle.is_finite() => { + self.snap_angle_deg = angle.rem_euclid(360.0); + Ok((format!("SNAPANG = {}", self.snap_angle_deg), true)) + } + _ => Err("SETVAR: finite numeric value required.".into()), + }, + None => Ok((format!("SNAPANG = {}", self.snap_angle_deg), false)), + }, // Global (not stored in the drawing): fill vs. hollow // TrueType text. The active tab re-tessellates below. "TEXTFILL" => match &value { @@ -1612,7 +1666,15 @@ impl OpenCADStudio { match outcome { Ok((msg, changed)) => { if changed { - if matches!(name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR") { + if matches!( + name.as_str(), + "ZOOMWHEEL" + | "ZOOMFACTOR" + | "CURSORSIZE" + | "PICKBOX" + | "CURSORTYPE" + | "SNAPANG" + ) { self.persist_settings_if_changed(); } else { self.tabs[i].dirty = true; diff --git a/src/app/config.rs b/src/app/config.rs index 7e5791800..2e4d5e796 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -209,11 +209,11 @@ fn rgb_to_color(rgb: [u8; 3]) -> iced::Color { iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]) } -fn rgb_to_hex(rgb: [u8; 3]) -> String { +pub(crate) fn rgb_to_hex(rgb: [u8; 3]) -> String { format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) } -fn parse_hex(value: &str) -> Option<[u8; 3]> { +pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> { let value = value.trim().strip_prefix('#').unwrap_or(value.trim()); if value.len() != 6 { return None; diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 0c010b5f9..821d25262 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -260,19 +260,56 @@ pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs { // ── Drawing constraint helpers ───────────────────────────────────────────── -/// Constrain `pt` to the nearest 90° direction from `base`, in the active UCS -/// plane — ortho follows the user's coordinate system, not world axes. `xf` is -/// identity for plain WCS, so the world-XY behaviour is unchanged there. -pub(super) fn ortho_constrain(pt: glam::DVec3, base: glam::DVec3, xf: &UcsXform) -> glam::DVec3 { +/// The two live drafting directions in degrees inside the active UCS plane. +pub(super) fn drafting_angles( + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> [f64; 2] { + let base = if isometric { + iso_plane.angles() + } else { + [0.0, 90.0] + }; + base.map(|angle| angle + snap_angle_deg as f64) +} + +/// Convert the live drafting directions into world-space axes. +pub(super) fn drafting_axes( + x: glam::DVec3, + y: glam::DVec3, + z: glam::DVec3, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> (glam::DVec3, glam::DVec3, glam::DVec3) { + let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg); + let direction = |degrees: f64| { + let radians = degrees.to_radians(); + (x * radians.cos() + y * radians.sin()).normalize_or(x) + }; + (direction(a), direction(b), z.normalize_or(x.cross(y))) +} + +/// Constrain `pt` to the nearest live drafting direction from `base`. +pub(super) fn drafting_constrain( + pt: glam::DVec3, + base: glam::DVec3, + xf: &UcsXform, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, +) -> glam::DVec3 { let p = xf.to_ucs(pt); let b = xf.to_ucs(base); - let dx = (p.x - b.x).abs(); - let dy = (p.y - b.y).abs(); - let c = if dx >= dy { - glam::DVec3::new(p.x, b.y, p.z) - } else { - glam::DVec3::new(b.x, p.y, p.z) - }; + let delta = glam::DVec2::new(p.x - b.x, p.y - b.y); + let [a, c] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| { + let radians = degrees.to_radians(); + glam::DVec2::new(radians.cos(), radians.sin()) + }); + let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { a } else { c }; + let projected = direction * delta.dot(direction); + let c = glam::DVec3::new(b.x + projected.x, b.y + projected.y, p.z); xf.to_wcs(c) } @@ -341,6 +378,9 @@ pub(super) fn axis_lock_capture( polar: bool, step_deg: f32, xf: &UcsXform, + isometric: bool, + iso_plane: super::settings::IsoPlane, + snap_angle_deg: f32, ) -> Option { let p = xf.to_ucs(cursor); let b = xf.to_ucs(base); @@ -349,8 +389,20 @@ pub(super) fn axis_lock_capture( if dx.hypot(dy) < 1e-9 { return None; } - let step = (if polar { step_deg as f64 } else { 90.0 }).to_radians(); - let ang = (dy.atan2(dx) / step).round() * step; + let ang = if polar { + let base = (snap_angle_deg as f64).to_radians(); + let step = (step_deg as f64).to_radians(); + ((dy.atan2(dx) - base) / step).round() * step + base + } else { + let delta = glam::DVec2::new(dx, dy); + let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| { + let radians = degrees.to_radians(); + glam::DVec2::new(radians.cos(), radians.sin()) + }); + let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { a } else { b }; + let direction = if delta.dot(direction) < 0.0 { -direction } else { direction }; + direction.y.atan2(direction.x) + }; let dir_ucs = glam::DVec3::new(ang.cos(), ang.sin(), 0.0); let dir = xf.to_wcs(b + dir_ucs) - xf.to_wcs(b); (dir.length_squared() > 1e-12).then(|| dir.normalize()) diff --git a/src/app/mod.rs b/src/app/mod.rs index b9d3f17a6..bec19b348 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,7 +18,7 @@ mod mtext_editor; pub mod plugin_host; mod properties; mod recent; -mod settings; +pub(crate) mod settings; mod shortcuts; mod style_ops; mod text_inline; @@ -448,10 +448,27 @@ pub(super) struct OpenCADStudio { zoom_wheel_reversed: bool, /// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR). zoom_factor: i32, + /// Crosshair reach as a viewport percentage (CURSORSIZE, 1..=100). + cursor_size: i32, + /// Selection-box half-size and click aperture in pixels (PICKBOX, 0..=50). + pick_box: i32, + /// Drawing viewport cursor style (CURSORTYPE). + cursor_type: settings::CursorType, + /// Explicit crosshair colour; `None` retains automatic contrast. + crosshair_color: Option<[u8; 3]>, + /// Editable Options buffer for the crosshair colour. + crosshair_color_input: String, + /// Isometric drafting state and active axis pair. + isometric_drafting: bool, + iso_plane: settings::IsoPlane, + /// Drafting-grid/crosshair rotation in degrees (SNAPANG). + snap_angle_deg: f32, /// Show grid lines in the viewport (F7). show_grid: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, + /// Currently visible page in the application Options dialog. + options_tab: crate::ui::window::options::OptionsTab, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, /// When true (default), saving over an existing file first writes a `.bak` @@ -1502,6 +1519,7 @@ pub enum ModalKind { LayerStateManager, LayerTranslator, DrawingUnits, + DraftingSettings, LayerStateEditor, Plot, PrintAll, @@ -1768,6 +1786,16 @@ pub enum Message { SaveDialogPathPicked(Option), /// Open the application-wide Options dialog. OptionsOpen, + /// Switch the visible page in Options. + OptionsTabChanged(crate::ui::window::options::OptionsTab), + /// Set CURSORSIZE from the Display-page slider. + CursorSizeChanged(i32), + /// Set PICKBOX from the Selection-page slider. + PickBoxChanged(i32), + /// Set CURSORTYPE from Options. + CursorTypeChanged(settings::CursorType), + /// Edit the optional crosshair RGB value; blank restores automatic contrast. + CrosshairColorChanged(String), /// Set the default type/version used when first saving a new drawing. DefaultSaveFormatChanged(String), /// Select one of Iced's built-in themes or the editable Custom theme. @@ -2094,6 +2122,14 @@ pub enum Message { ToggleSnapEnabled, /// Toggle grid-snap on/off — F9 / SNAP status-bar button. ToggleGridSnap, + /// Enable or disable isometric drafting. + ToggleIsometricDrafting, + /// Select one isometric drafting axis pair. + SetIsoPlane(settings::IsoPlane), + /// Advance Left → Top → Right, enabling isometric drafting if necessary. + CycleIsoPlane, + /// Reset SNAPANG and return the active drafting coordinate system to World. + ResetDraftingRotation, /// Toggle the ViewCube 3D gizmo visibility (NAVVCUBE). ToggleViewCube, /// Toggle the Properties panel visibility (PROPERTIES). @@ -3036,8 +3072,17 @@ impl OpenCADStudio { polar_increment_deg: 45.0, zoom_wheel_reversed: false, zoom_factor: 60, + cursor_size: 5, + pick_box: 3, + cursor_type: settings::CursorType::Crosshair, + crosshair_color: None, + crosshair_color_input: String::new(), + isometric_drafting: false, + iso_plane: settings::IsoPlane::Left, + snap_angle_deg: 0.0, show_grid: false, dyn_input: true, + options_tab: crate::ui::window::options::OptionsTab::General, texteditmode: false, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/settings.rs b/src/app/settings.rs index 727eaae1d..25cf6f8bb 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,63 @@ 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`]). @@ -99,6 +156,19 @@ pub struct UserSettings { pub polar_increment_deg: f32, pub zoom_wheel_reversed: bool, pub zoom_factor: i32, + /// CURSORSIZE: crosshair arm reach as a percentage of the viewport. + pub cursor_size: i32, + /// PICKBOX: visible selection-box half-size and click aperture in pixels. + pub pick_box: 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]>, + /// 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- @@ -170,6 +240,13 @@ impl Default for UserSettings { polar_increment_deg: 45.0, zoom_wheel_reversed: false, zoom_factor: 60, + cursor_size: 5, + pick_box: 3, + cursor_type: CursorType::Crosshair, + crosshair_color: None, + isometric_drafting: false, + iso_plane: IsoPlane::Left, + snap_angle_deg: 0.0, otrack: false, default_assoc_prompted: false, disabled_plugins: Vec::new(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index a5c9e8861..6c888fa40 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), + ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), ("F9".to_string(), "SNAP"), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index f854b612f..f735c6d82 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index a3119277d..c72f0f7d8 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -5,7 +5,7 @@ use super::util::*; use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1d88daabd..72a44921b 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5d7ed8591..fa58d2fcf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; @@ -267,6 +267,13 @@ impl OpenCADStudio { polar_increment_deg: self.polar_increment_deg, zoom_wheel_reversed: self.zoom_wheel_reversed, zoom_factor: self.zoom_factor, + cursor_size: self.cursor_size, + pick_box: self.pick_box, + cursor_type: self.cursor_type, + crosshair_color: self.crosshair_color, + isometric_drafting: self.isometric_drafting, + iso_plane: self.iso_plane, + snap_angle_deg: self.snap_angle_deg, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, disabled_plugins: { @@ -303,6 +310,21 @@ impl OpenCADStudio { self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; self.zoom_factor = s.zoom_factor.clamp(3, 100); + self.cursor_size = s.cursor_size.clamp(1, 100); + self.pick_box = s.pick_box.clamp(0, 50); + self.cursor_type = s.cursor_type; + self.crosshair_color = s.crosshair_color; + self.crosshair_color_input = s + .crosshair_color + .map(crate::app::config::rgb_to_hex) + .unwrap_or_default(); + self.isometric_drafting = s.isometric_drafting; + self.iso_plane = s.iso_plane; + self.snap_angle_deg = if s.snap_angle_deg.is_finite() { + s.snap_angle_deg.rem_euclid(360.0) + } else { + 0.0 + }; // Ortho + running OSNAP are per-drawing (adopted from the header on // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; @@ -588,6 +610,9 @@ impl OpenCADStudio { self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); + self.shortcut_bindings + .entry("F5".to_string()) + .or_insert_with(|| "ISOPLANE".to_string()); } /// Write the config only when it changed since the last write, so a toggle diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9fed4ee80..62e382f2e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -149,6 +149,9 @@ impl OpenCADStudio { if self.active_modal == Some(ScaleManager) { self.scale_stage_discard(); } + if self.active_modal == Some(DraftingSettings) { + self.snap_popup_open = false; + } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(FileInUse) { self.pending_save_failure = None; @@ -3014,6 +3017,43 @@ impl OpenCADStudio { self.sync_vport_display(self.active_tab); Task::none() } + Message::ToggleIsometricDrafting => { + self.isometric_drafting = !self.isometric_drafting; + self.persist_settings_if_changed(); + Task::none() + } + Message::SetIsoPlane(plane) => { + self.isometric_drafting = true; + self.iso_plane = plane; + self.persist_settings_if_changed(); + Task::none() + } + Message::CycleIsoPlane => { + if self.isometric_drafting { + self.iso_plane = self.iso_plane.next(); + } else { + self.isometric_drafting = true; + } + self.command_line.push_output(crate::tf!( + "Isometric plane: {}.", + self.iso_plane.label() + ).as_ref()); + self.persist_settings_if_changed(); + Task::none() + } + Message::ResetDraftingRotation => { + self.snap_angle_deg = 0.0; + let i = self.active_tab; + if self.tabs[i].active_ucs.is_some() { + self.tabs[i].active_ucs = None; + self.commit_active_ucs_change(i, "UCS"); + self.tabs[i].scene.camera_generation += 1; + } + self.command_line + .push_output(crate::t!("Drafting rotation reset to World at 0°.").as_ref()); + self.persist_settings_if_changed(); + Task::none() + } Message::ToggleGrid => { self.show_grid ^= true; self.sync_vport_display(self.active_tab); @@ -3654,11 +3694,20 @@ impl OpenCADStudio { Task::none() } Message::ToggleSnapPopup => { - self.snap_popup_open ^= true; + if self.active_modal == Some(super::ModalKind::DraftingSettings) { + self.close_active_modal(); + self.snap_popup_open = false; + } else { + self.active_modal = Some(super::ModalKind::DraftingSettings); + self.snap_popup_open = true; + } Task::none() } Message::CloseSnapPopup => { self.snap_popup_open = false; + if self.active_modal == Some(super::ModalKind::DraftingSettings) { + self.close_active_modal(); + } Task::none() } Message::SnapSelectAll => { @@ -5149,6 +5198,41 @@ impl OpenCADStudio { Task::none() } + Message::OptionsTabChanged(tab) => { + self.options_tab = tab; + Task::none() + } + + Message::CursorSizeChanged(value) => { + self.cursor_size = value.clamp(1, 100); + self.persist_settings_if_changed(); + Task::none() + } + + Message::PickBoxChanged(value) => { + self.pick_box = value.clamp(0, 50); + self.persist_settings_if_changed(); + Task::none() + } + + Message::CursorTypeChanged(value) => { + self.cursor_type = value; + self.persist_settings_if_changed(); + Task::none() + } + + Message::CrosshairColorChanged(value) => { + self.crosshair_color_input = value.clone(); + if value.trim().is_empty() { + self.crosshair_color = None; + self.persist_settings_if_changed(); + } else if let Some(rgb) = crate::app::config::parse_hex(&value) { + self.crosshair_color = Some(rgb); + self.persist_settings_if_changed(); + } + Task::none() + } + Message::DefaultSaveFormatChanged(format) => { self.default_save_format = crate::io::canonical_save_format(&format).to_string(); diff --git a/src/app/update/style.rs b/src/app/update/style.rs index bff4cd52d..0274b140e 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 14bd53b7c..c567cc622 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4,7 +4,7 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - axis_lock_apply, axis_lock_capture, ortho_constrain, parse_coord, polar_constrain_near, + axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; @@ -171,6 +171,26 @@ impl OpenCADStudio { .set_active_tile_grid_snap(grid_on, snap_on); } + /// Grid origin plus the rotated/isometric axes used by both drawing and snap. + pub(in crate::app) fn drafting_grid_basis( + &self, + i: usize, + ) -> (glam::Vec3, (glam::Vec3, glam::Vec3, glam::Vec3)) { + let (origin, rotation) = self.tabs[i].ucs_grid_basis(); + let x = rotation.transform_vector3(glam::Vec3::X).as_dvec3(); + let y = rotation.transform_vector3(glam::Vec3::Y).as_dvec3(); + let z = rotation.transform_vector3(glam::Vec3::Z).as_dvec3(); + let (x, y, z) = drafting_axes( + x, + y, + z, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + (origin, (x.as_vec3(), y.as_vec3(), z.as_vec3())) + } + /// Adopt the active viewport's grid *display* into the live toggle. Called /// on load and whenever the active tab or viewport changes, so the grid /// drawing follows the active viewport. @@ -1014,7 +1034,7 @@ impl OpenCADStudio { // snapping must drop its foot from this point, including when a // hot-grip set is moved by the same drag vector. self.snapper.from_point = Some(grip.origin_world.as_vec3()); - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); // `raw` is already model space (viewport camera or paper→model), // and the wires are model space, so the snap result is model. let snap_hit = @@ -1044,7 +1064,7 @@ impl OpenCADStudio { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( raw, @@ -1054,8 +1074,8 @@ impl OpenCADStudio { polar_step, Some(grip.origin_world), self.ortho_mode, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -1086,6 +1106,9 @@ impl OpenCADStudio { self.polar_mode, self.polar_increment_deg, &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, ); } } else { @@ -1099,7 +1122,14 @@ impl OpenCADStudio { let base = grip.origin_world; let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - snapped = ortho_constrain(snapped, base, &ucs_xf); + snapped = drafting_constrain( + snapped, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { snapped = polar_constrain_near( snapped, @@ -1423,7 +1453,7 @@ impl OpenCADStudio { bounds, ) } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); // The snapper is a screen-space (f32) engine; the f64 // base only matters for typed-input precision, so hand it // the downcast point here. @@ -1472,7 +1502,7 @@ impl OpenCADStudio { } else { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( cursor_world, view_rot, @@ -1481,8 +1511,8 @@ impl OpenCADStudio { step, self.last_point, self.ortho_mode && !is_window_corner, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -1545,6 +1575,9 @@ impl OpenCADStudio { self.polar_mode, self.polar_increment_deg, &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, ); } } else if !self.shift_down { @@ -1568,7 +1601,14 @@ impl OpenCADStudio { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - pt = ortho_constrain(pt, base, &ucs_xf); + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { pt = polar_constrain_near( pt, @@ -1738,6 +1778,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -2077,7 +2118,7 @@ impl OpenCADStudio { self.snapper.grid_spacing = grid_spacing; // No rubber-band origin (perp/extension feet don't apply to a free drag). self.snapper.from_point = None; - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); let snap_hit = self .snapper .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); @@ -2740,7 +2781,7 @@ impl OpenCADStudio { bounds, ) } else { - let (go, gr) = self.tabs[i].ucs_grid_basis(); + let (go, gr) = self.drafting_grid_basis(i); self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); self.snapper.snap( snap_cursor, @@ -2779,7 +2820,7 @@ impl OpenCADStudio { } else { None }; - let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes(); + let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i); self.snapper.otrack_snap( raw, view_rot, @@ -2788,8 +2829,8 @@ impl OpenCADStudio { step, self.last_point, self.ortho_mode && !is_window_corner, - ucs_x, - ucs_y, + ucs_x.as_dvec3(), + ucs_y.as_dvec3(), ) } else { None @@ -2808,7 +2849,14 @@ impl OpenCADStudio { if let Some(base) = self.last_point { let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { - pt = ortho_constrain(pt, base, &ucs_xf); + pt = drafting_constrain( + pt, + base, + &ucs_xf, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); } else if self.polar_mode { pt = polar_constrain_near( pt, @@ -2893,7 +2941,7 @@ impl OpenCADStudio { view_rot2, eye2, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let include_fills = self.tabs[i] .active_cmd @@ -2914,6 +2962,7 @@ impl OpenCADStudio { eye2, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3442,7 +3491,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3462,6 +3511,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) @@ -3483,6 +3533,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3824,7 +3875,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_handles = self.tabs[i] .scene @@ -3839,6 +3890,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -3943,7 +3995,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); scene::pick::hit_test::click_hit( p, @@ -3952,6 +4004,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(|s| Scene::handle_from_wire_name(s)) .and_then(|h| { @@ -4357,7 +4410,7 @@ impl OpenCADStudio { view_rot, eye, bounds, - scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, + self.pick_box.max(1) as f32 * 2.0, ); let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0; let candidate_count = hover_candidates.len(); @@ -4377,6 +4430,7 @@ impl OpenCADStudio { eye, bounds, self.tabs[i].scene.document.header.lineweight_display, + self.pick_box.max(1) as f32, ) .and_then(Scene::handle_from_wire_name); let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index c0ec9d441..d104c0027 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -335,6 +335,14 @@ impl OpenCADStudio { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); + let (ux, uy, uz) = super::helpers::drafting_axes( + ux, + uy, + uz, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) }; let grid: Vec = tab @@ -342,7 +350,7 @@ impl OpenCADStudio { .grid_views(vw, vh) .into_iter() .map(|(bounds, cam, handle)| { - let (origin, axes): (glam::DVec3, _) = if is_paper { + let (origin, mut axes): (glam::DVec3, _) = if is_paper { match tab.ucs_from_viewport(handle) { Some(u) => { let (o, ux, uy, uz) = @@ -357,6 +365,17 @@ impl OpenCADStudio { } else { model_basis }; + if is_paper { + let (ux, uy, uz) = super::helpers::drafting_axes( + axes.0.as_dvec3(), + axes.1.as_dvec3(), + axes.2.as_dvec3(), + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ); + axes = (ux.as_vec3(), uy.as_vec3(), uz.as_vec3()); + } crate::ui::overlay::GridParams { view_rot: cam.view_proj_rte(bounds), eye: cam.eye(), @@ -684,6 +703,15 @@ impl OpenCADStudio { self.ribbon.open_dropdown.is_some(), hover_locked, crosshair_background(tab, is_paper), + crate::ui::overlay::CrosshairOptions { + size_percent: self.cursor_size, + pick_box: self.pick_box, + cursor_type: self.cursor_type, + color: self.crosshair_color, + isometric: self.isometric_drafting, + iso_plane: self.iso_plane, + snap_angle_deg: self.snap_angle_deg, + }, ) }; @@ -1693,6 +1721,8 @@ impl OpenCADStudio { self.polar_increment_deg, self.dyn_input, self.snapper.otrack_enabled, + self.isometric_drafting, + self.iso_plane, layout_names.clone(), block_tabs, layout_names.into_iter().skip(1).collect(), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2b1c1ffb9..409c23ebc 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -21,6 +21,7 @@ impl OpenCADStudio { Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), + Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"), Some(K::Plot) => crate::tr!("modal-plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -96,6 +97,31 @@ impl OpenCADStudio { &self.ui_theme, &self.theme_color_inputs, self.language, + self.options_tab, + self.cursor_size, + self.pick_box, + self.cursor_type, + self.crosshair_color, + &self.crosshair_color_input, + flow, + ) + }, + ), + super::super::ModalKind::DraftingSettings => sized_flow( + ex, + 520, + 560, + |flow| { + crate::ui::window::drafting_settings::view_window( + &self.snapper, + self.show_grid, + self.snapper.grid_snap(), + self.ortho_mode, + self.polar_mode, + self.snapper.otrack_enabled, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, flow, ) }, diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index b7f5329e3..464247abc 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -15,13 +15,10 @@ use crate::scene::model::mesh_model::MeshModel; use crate::scene::model::wire_model::WireModel; use crate::scene::pick::interaction_index::WireSource; -/// Pixel radius used for single-click wire detection. -pub const CLICK_THRESHOLD_PX: f32 = 8.0; - /// Pick radius for one wire, in screen pixels. /// /// A wire renders as a band `line_weight_px` wide, so testing every wire at the -/// bare [`CLICK_THRESHOLD_PX`] would leave the outer part of a heavy line +/// configured base radius would leave the outer part of a heavy line /// unselectable — the cursor would sit on solid ink and miss. Widening to the /// rendered half-width keeps "looks like I'm on it" and "picks it" the same /// thing at any zoom: both quantities are screen-space, so the relation holds @@ -35,13 +32,13 @@ pub const CLICK_THRESHOLD_PX: f32 = 8.0; /// renders 7.97 px half-width), so this only bites for out-of-range weights — /// and it keeps the two sides from silently drifting apart if the display boost /// in `view::render::lineweight_to_px` ever changes. -pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool) -> f32 { +pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool, base_radius_px: f32) -> f32 { let half_width = if lw_display { wire.line_weight_px * 0.5 } else { 0.5 }; - CLICK_THRESHOLD_PX.max(half_width) + base_radius_px.max(1.0).max(half_width) } /// Is `aabb` — a wire's world-space XY box — further than `tol` pixels from @@ -199,6 +196,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( eye: glam::DVec3, bounds: Rectangle, lw_display: bool, + base_radius_px: f32, ) -> Option<&'a str> { // A click outside the pane rectangle (e.g. on the paper around a floating // viewport) must not reach geometry scissored out of the viewport. @@ -243,7 +241,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( bounds, ); let d = dist_point_to_segment(cursor, p0, p1); - if d < pick_tolerance_px(wire, lw_display) && d < best_dist { + if d < pick_tolerance_px(wire, lw_display, base_radius_px) && d < best_dist { best_dist = d; best = Some(&wire.name); } @@ -251,7 +249,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } else { // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires.iter() { - let tol = pick_tolerance_px(wire, lw_display); + let tol = pick_tolerance_px(wire, lw_display, base_radius_px); // Cheap AABB pre-reject (flat view only; never for the unbounded // sentinel used by previews / greeked text). if z_flat @@ -446,6 +444,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( eye: glam::DVec3, bounds: Rectangle, lw_display: bool, + base_radius_px: f32, ) -> Vec<&'a str> { if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height { return Vec::new(); @@ -474,7 +473,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( bounds, ); let d = dist_point_to_segment(cursor, p0, p1); - if d < pick_tolerance_px(wire, lw_display) { + if d < pick_tolerance_px(wire, lw_display, base_radius_px) { best_by_wire .entry(segment.wire) .and_modify(|best| *best = best.min(d)) @@ -488,7 +487,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( })); } else { for wire in wires.iter() { - let tol = pick_tolerance_px(wire, lw_display); + let tol = pick_tolerance_px(wire, lw_display, base_radius_px); let mut prev: Option = None; let mut best_for_wire = tol; let mut hit = false; @@ -543,7 +542,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(triangle.wire); } } @@ -562,7 +561,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -597,7 +596,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(triangle.wire); } } @@ -616,7 +615,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( ) .is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -641,7 +640,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( continue; }; if text_quad_hit_area(cursor, vertices, view_rot, eye, bounds).is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); matched.insert(glyph.wire); } } @@ -651,7 +650,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( continue; } if text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds).is_some() { - hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + hits.push((base_radius_px.max(1.0), wire.name.as_str())); } } } @@ -2038,16 +2037,16 @@ mod aabb_reject_tests { let eye = glam::DVec3::ZERO; assert_eq!( - click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true), + click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true, 8.0), Some("5") ); assert_eq!( - click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true), + click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true, 8.0), None ); // The far wire must be rejected without hiding the near one. assert_eq!( - click_hit(cursor, &[far, near], vp, eye, bounds, true), + click_hit(cursor, &[far, near], vp, eye, bounds, true, 8.0), Some("5") ); } diff --git a/src/snap.rs b/src/snap.rs index 6766219ed..add3ee424 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -11,7 +11,7 @@ use iced::{Point, Rectangle}; use crate::command::TangentObject; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; -use crate::ui::overlay::CROSSHAIR_ARM; +const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; // ── Snap type ───────────────────────────────────────────────────────────── @@ -174,7 +174,7 @@ impl Default for Snapper { enabled, grid_snap_on: false, grid_spacing: 1.0, - osnap_radius_px: CROSSHAIR_ARM * 0.25, + osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX, otrack_enabled: false, tracking_points: Vec::new(), tracking_dirs: Vec::new(), @@ -830,7 +830,7 @@ impl Snapper { eye, bounds, Vec3::ZERO, - Mat4::IDENTITY, + (Vec3::X, Vec3::Y, Vec3::Z), ) } @@ -843,10 +843,10 @@ impl Snapper { view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, - // Grid origin (render/wire space) and UCS→world rotation, so grid snap - // lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid. + // Grid origin and live drafting axes, so grid snap lands on the same + // rotated or isometric grid the user sees. grid_origin: Vec3, - grid_rot: Mat4, + grid_axes: (Vec3, Vec3, Vec3), ) -> Option { // Object-snap selection is priority-then-distance, NOT nearest-wins. // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry @@ -881,13 +881,27 @@ impl Snapper { let s = self.grid_spacing as f64; if s.abs() > 1e-9 { // Round in the UCS grid frame, then map back to world. - let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3(); - let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3(); - let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3(); + let (ax, ay, az) = grid_axes; + let ax = ax.normalize_or(Vec3::X).as_dvec3(); + let ay = ay.normalize_or(Vec3::Y).as_dvec3(); + let az = az.normalize_or(Vec3::Z).as_dvec3(); let origin = grid_origin.as_dvec3(); let rel = cursor_world - origin; - let ux = (rel.dot(ax) / s).round() * s; - let uy = (rel.dot(ay) / s).round() * s; + // The isometric pairs are oblique, so dot products alone are + // not coordinates. Invert their 2×2 Gram matrix before rounding. + let aa = ax.dot(ax); + let ab = ax.dot(ay); + let bb = ay.dot(ay); + let det = aa * bb - ab * ab; + let (ux, uy) = if det.abs() > 1e-9 { + let ra = rel.dot(ax); + let rb = rel.dot(ay); + ((ra * bb - rb * ab) / det, (rb * aa - ra * ab) / det) + } 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 gp = origin + ax * ux + ay * uy + az * uz; let screen = world_to_screen(gp, view_rot, eye, bounds); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 5995b322d..a92324536 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -6,15 +6,23 @@ use iced::widget::canvas; use iced::{Color, Element, Length, Point, Size, Theme}; use crate::app::Message; +use crate::app::settings::{CursorType, IsoPlane}; use crate::scene::model::object::GripShape; use crate::scene::SelectionState; -/// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2). -pub const CROSSHAIR_SQ: f32 = 7.5; -/// Arm length of the crosshair from center — used as the snap aperture radius. -pub const CROSSHAIR_ARM: f32 = 60.0; use crate::snap::SnapType; +#[derive(Clone, Copy)] +pub struct CrosshairOptions { + pub size_percent: i32, + pub pick_box: i32, + pub cursor_type: CursorType, + pub color: Option<[u8; 3]>, + pub isometric: bool, + pub iso_plane: IsoPlane, + pub snap_angle_deg: f32, +} + // ── Grip marker data ────────────────────────────────────────────────────── /// Describes one grip to be drawn in the viewport overlay. @@ -223,6 +231,7 @@ pub fn selection_overlay<'a>( suppressed: bool, hover_locked: bool, crosshair_bg: [f32; 4], + crosshair: CrosshairOptions, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -243,6 +252,7 @@ pub fn selection_overlay<'a>( suppressed, hover_locked, crosshair_bg, + crosshair, }) .width(Length::Fill) .height(Length::Fill) @@ -297,6 +307,7 @@ struct SelectionCanvas { /// Background of the active drawing space. Crosshair contrast follows this /// rather than the UI theme, which may be light over a dark model viewport. crosshair_bg: [f32; 4], + crosshair: CrosshairOptions, } fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) { @@ -432,7 +443,7 @@ impl canvas::Program for SelectionCanvas { // cursor entirely. `Interaction::None` would let the stack fall // through to a sibling — `Hidden` is the explicit "no cursor" // signal that actually suppresses the OS arrow. - if cursor.is_over(bounds) { + if cursor.is_over(bounds) && self.crosshair.cursor_type == CursorType::Crosshair { mouse::Interaction::Hidden } else { mouse::Interaction::default() @@ -924,11 +935,28 @@ impl canvas::Program for SelectionCanvas { // top of it would double up the visual feedback. let over_divider = self.divider_under(cursor, bounds); // PAN mode replaces the crosshair with a hand cursor. - if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed { + if !over_viewcube + && !over_divider + && !self.pan_mode + && !self.suppressed + && self.crosshair.cursor_type == CursorType::Crosshair + { if let Some(cp) = self.selection.last_move_pos { - let [r, g, b, a] = crate::scene::view::render::adapt_to_bg( - [1.0, 1.0, 1.0, 0.90], - self.crosshair_bg, + let [r, g, b, a] = self.crosshair.color.map_or_else( + || { + crate::scene::view::render::adapt_to_bg( + [1.0, 1.0, 1.0, 0.90], + self.crosshair_bg, + ) + }, + |[r, g, b]| { + [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + 0.90, + ] + }, ); let color = Color { r, g, b, a }; let stroke = canvas::Stroke { @@ -936,38 +964,38 @@ impl canvas::Program for SelectionCanvas { style: canvas::Style::Solid(color), ..Default::default() }; - let sq = CROSSHAIR_SQ; // square half-size → 15×15 - let arm = CROSSHAIR_ARM; // crosshair arm length from center - - // Horizontal arms (start at square edge, end at arm length) - let h_left = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x - sq, cp.y)); - b.line_to(Point::new(cp.x - arm, cp.y)); - }); - let h_right = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x + sq, cp.y)); - b.line_to(Point::new(cp.x + arm, cp.y)); - }); - // Vertical arms - let v_top = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x, cp.y - sq)); - b.line_to(Point::new(cp.x, cp.y - arm)); - }); - let v_bot = canvas::Path::new(|b| { - b.move_to(Point::new(cp.x, cp.y + sq)); - b.line_to(Point::new(cp.x, cp.y + arm)); - }); - // Center square - let square = canvas::Path::rectangle( - Point::new(cp.x - sq, cp.y - sq), - Size::new(sq * 2.0, sq * 2.0), - ); - - frame.stroke(&h_left, stroke.clone()); - frame.stroke(&h_right, stroke.clone()); - frame.stroke(&v_top, stroke.clone()); - frame.stroke(&v_bot, stroke.clone()); - frame.stroke(&square, stroke); + let sq = self.crosshair.pick_box.clamp(0, 50) as f32; + let arm = bounds.width.hypot(bounds.height) + * self.crosshair.size_percent.clamp(1, 100) as f32 + / 100.0; + let base_angles: [f64; 2] = if self.crosshair.isometric { + self.crosshair.iso_plane.angles() + } else { + [0.0, 90.0] + }; + for angle in base_angles { + let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians(); + let dir = Point::new(rad.cos() as f32, -rad.sin() as f32); + let gap = if sq > 0.0 { + sq / dir.x.abs().max(dir.y.abs()).max(1e-6) + } else { + 0.0 + }; + let arms = canvas::Path::new(|path| { + path.move_to(Point::new(cp.x + dir.x * gap, cp.y + dir.y * gap)); + path.line_to(Point::new(cp.x + dir.x * arm, cp.y + dir.y * arm)); + path.move_to(Point::new(cp.x - dir.x * gap, cp.y - dir.y * gap)); + path.line_to(Point::new(cp.x - dir.x * arm, cp.y - dir.y * arm)); + }); + frame.stroke(&arms, stroke.clone()); + } + if sq > 0.0 { + let square = canvas::Path::rectangle( + Point::new(cp.x - sq, cp.y - sq), + Size::new(sq * 2.0, sq * 2.0), + ); + frame.stroke(&square, stroke); + } // Locked-layer badge: a small padlock beside the crosshair when // the hovered object sits on a locked layer (issue: locked diff --git a/src/ui/popup/snap_popup.rs b/src/ui/popup/snap_popup.rs index bc6b674c6..c5a36c852 100644 --- a/src/ui/popup/snap_popup.rs +++ b/src/ui/popup/snap_popup.rs @@ -1,14 +1,19 @@ //! OpenCADStudio-style OSNAP status menu. -use iced::widget::{button, container, row, text}; +use iced::widget::{button, checkbox, column, container, row, text}; use iced::{Background, Element, Fill, Length, Theme}; use crate::app::Message; +use crate::app::settings::IsoPlane; use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES}; use crate::ui::statusbar::status_menu::Entry; use crate::t; -pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec> { +pub fn menu_entries<'a>( + snapper: &'a Snapper, + isometric: bool, + iso_plane: IsoPlane, +) -> Vec> { let all_on = snapper.all_on(); let none_on = snapper.none_on(); @@ -30,7 +35,49 @@ pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec> { .width(Fill) .padding([0, 4]); - let mut entries = vec![Entry::stay(header), Entry::stay(divider)]; + let mut iso_planes = row![].spacing(3); + for plane in IsoPlane::ALL { + iso_planes = iso_planes.push( + button(text(t!(plane.label())).size(10)) + .on_press(Message::SetIsoPlane(plane)) + .style(if isometric && iso_plane == plane { + button::primary + } else { + button::secondary + }) + .padding([3, 8]), + ); + } + let drafting = column![ + row![ + checkbox(isometric) + .on_toggle(|_| Message::ToggleIsometricDrafting) + .size(14), + text(t!("Isometric drafting")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + iso_planes, + text(t!("F5 cycles the active plane.")).size(10), + ] + .spacing(5) + .padding([5, 8]); + let drafting_divider = container(iced::widget::Space::new().height(1)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weak.color, + )), + ..Default::default() + }) + .width(Fill) + .padding([0, 4]); + + let mut entries = vec![ + Entry::stay(header), + Entry::stay(divider), + Entry::stay(drafting), + Entry::stay(drafting_divider), + ]; for &(snap_type, _glyph, label) in ALL_SNAP_MODES { entries.push(Entry::stay(snap_row( snap_type, diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 6a3a9630b..7736d3028 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -71,6 +71,8 @@ impl StatusBar { polar_increment_deg: f32, dyn_input: bool, otrack: bool, + isometric_drafting: bool, + iso_plane: crate::app::settings::IsoPlane, layouts: Vec, block_tabs: Vec, reorderable_layouts: Vec, @@ -297,7 +299,11 @@ impl StatusBar { osnap_active, snapper.snap_enabled, tooltip_hidden, - crate::ui::popup::snap_popup::menu_entries(snapper), + crate::ui::popup::snap_popup::menu_entries( + snapper, + isometric_drafting, + iso_plane, + ), ) .into(), ); diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs new file mode 100644 index 000000000..714225ec9 --- /dev/null +++ b/src/ui/window/drafting_settings.rs @@ -0,0 +1,135 @@ +use crate::app::settings::IsoPlane; +use crate::app::Message; +use crate::snap::{Snapper, ALL_SNAP_MODES}; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + snapper: &'a Snapper, + grid: bool, + grid_snap: bool, + ortho: bool, + polar: bool, + otrack: bool, + isometric: bool, + iso_plane: IsoPlane, + snap_angle_deg: f32, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let toggle = |value: bool, label: std::borrow::Cow<'a, str>, message: Message| { + row![ + checkbox(value).on_toggle(move |_| message.clone()).size(15), + text(label).size(12), + ] + .spacing(7) + .align_y(iced::Center) + }; + + let drafting_modes = column![ + text(crate::t!("Drafting modes")).size(15), + Space::new().height(8), + toggle(grid, crate::t!("Grid display"), Message::ToggleGrid), + toggle(grid_snap, crate::t!("Grid snap"), Message::ToggleGridSnap), + toggle(ortho, crate::t!("Ortho"), Message::ToggleOrtho), + toggle(polar, crate::t!("Polar tracking"), Message::TogglePolar), + toggle(otrack, crate::t!("Object snap tracking"), Message::ToggleOTrack), + ] + .spacing(6); + + let mut planes = row![].spacing(5); + for plane in IsoPlane::ALL { + planes = planes.push( + button(text(crate::t!(plane.label())).size(11)) + .on_press(Message::SetIsoPlane(plane)) + .style(if isometric && iso_plane == plane { + button::primary + } else { + button::secondary + }) + .padding([5, 12]), + ); + } + let isometric_controls = column![ + text(crate::t!("Isometric drafting")).size(15), + Space::new().height(8), + toggle( + isometric, + crate::t!("Enable isometric drafting"), + Message::ToggleIsometricDrafting, + ), + planes, + text(crate::t!("F5 cycles Left, Top, and Right.")).size(11), + Space::new().height(6), + row![ + text(crate::t!("Rotation: %{angle}°", angle = snap_angle_deg)).size(11), + button(text(crate::t!("Reset rotation")).size(10)) + .on_press(Message::ResetDraftingRotation) + .style(button::secondary) + .padding([4, 10]), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(7); + + let mut snap_modes = column![ + text(crate::t!("Object snap modes")).size(15), + Space::new().height(8), + toggle( + snapper.snap_enabled, + crate::t!("Enable object snap"), + Message::ToggleSnapEnabled, + ), + row![ + button(text(crate::t!("Select All")).size(10)) + .on_press(Message::SnapSelectAll) + .style(button::secondary) + .padding([4, 10]), + button(text(crate::t!("Clear All")).size(10)) + .on_press(Message::SnapClearAll) + .style(button::secondary) + .padding([4, 10]), + ] + .spacing(6), + ] + .spacing(5); + for &(snap_type, _, label) in ALL_SNAP_MODES { + snap_modes = snap_modes.push( + row![ + checkbox(snapper.is_on(snap_type)) + .on_toggle(move |_| Message::ToggleSnap(snap_type)) + .size(14), + text(crate::t!(label)).size(11), + ] + .spacing(7) + .align_y(iced::Center), + ); + } + + let close = button(text(crate::tr!("action-close")).size(12)) + .on_press(Message::CloseModal) + .padding([6, 18]) + .style(button::secondary); + let content = column![ + drafting_modes, + Space::new().height(20), + isometric_controls, + Space::new().height(20), + snap_modes, + ] + .width(sizing.width); + let body = column![ + scrollable(content).spacing(8).height(sizing.height), + Space::new().height(12), + row![Space::new().width(Fill), close], + ] + .width(sizing.width) + .height(sizing.height); + + container(body) + .style(container::rounded_box) + .padding([16, 18]) + .width(sizing.width) + .height(sizing.height) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 4e8cd139d..e1ae0b229 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -3,6 +3,7 @@ pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; +pub mod drafting_settings; pub mod layer_translator; pub mod plot; pub mod print_all; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index cb27fd43c..02eabff93 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1,11 +1,20 @@ use crate::app::config::UiThemeConfig; +use crate::app::settings::CursorType; use crate::app::Message; use iced::widget::{ - button, column, container, row, scrollable, text, text_input, Space, + button, column, container, row, scrollable, slider, text, text_input, Space, }; -use iced::{Background, Border, Element, Theme}; +use iced::{Background, Border, Element, Fill, Theme}; use std::fmt; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OptionsTab { + #[default] + General, + Display, + Selection, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct Labelled { value: T, @@ -24,6 +33,12 @@ pub fn view_window<'a>( ui_theme: &'a UiThemeConfig, theme_color_inputs: &'a [String; 6], language: crate::i18n::Language, + active_tab: OptionsTab, + cursor_size: i32, + pick_box: i32, + cursor_type: CursorType, + crosshair_color: Option<[u8; 3]>, + crosshair_color_input: &'a str, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let selected_format = crate::io::SAVE_FORMAT_OPTIONS @@ -62,6 +77,18 @@ pub fn view_window<'a>( .find(|choice| choice.value == language) .cloned(); + let cursor_options = CursorType::ALL + .into_iter() + .map(|value: CursorType| Labelled { + label: crate::t!(value.label()).into_owned(), + value, + }) + .collect::>(); + let selected_cursor = cursor_options + .iter() + .find(|choice| choice.value == cursor_type) + .cloned(); + let palette = ui_theme.palette.to_iced(); let colors = [ (crate::tr!("options-color-background"), palette.background), @@ -104,7 +131,7 @@ pub fn view_window<'a>( .padding([6, 18]) .style(button::secondary); - let content = column![ + let general = column![ text(crate::tr!("options-language-section")).size(15), Space::new().height(10), row![ @@ -154,7 +181,29 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), - Space::new().height(22), + ] + .spacing(0) + .width(sizing.width); + + let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]); + let crosshair_swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(iced::Color::from_rgb8( + crosshair_rgb[0], + crosshair_rgb[1], + crosshair_rgb[2], + ))), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + + let display = column![ text(crate::tr!("options-theme-section")).size(15), Space::new().height(10), row![ @@ -175,11 +224,96 @@ pub fn view_window<'a>( .width(sizing.width), Space::new().height(12), color_controls, + Space::new().height(24), + text(crate::t!("Crosshair")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Crosshair size")).size(12).width(140), + slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged) + .step(1) + .width(Fill), + text(format!("{}%", cursor_size.clamp(1, 100))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Cursor type")).size(12).width(140), + iced::widget::pick_list( + selected_cursor, + cursor_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::CursorTypeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Crosshair color")).size(12).width(140), + crosshair_swatch, + text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input) + .on_input(Message::CrosshairColorChanged) + .width(150), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!("Leave the color blank to keep automatic viewport contrast.")) + .size(11) + .width(sizing.width), ] .spacing(0) .width(sizing.width); + let selection = column![ + text(crate::t!("Selection")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Pick box size")).size(12).width(140), + slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged) + .step(1) + .width(Fill), + text(pick_box.clamp(0, 50).to_string()).size(11).width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(8), + text(crate::t!( + "Controls both the visible selection box and the click aperture." + )) + .size(11) + .width(sizing.width), + ] + .spacing(0) + .width(sizing.width); + + let content: Element<'a, Message> = match active_tab { + OptionsTab::General => general.into(), + OptionsTab::Display => display.into(), + OptionsTab::Selection => selection.into(), + }; + + let tab_button = |label, tab| { + let selected = active_tab == tab; + button(text(label).size(12)) + .on_press(Message::OptionsTabChanged(tab)) + .padding([6, 14]) + .style(if selected { button::primary } else { button::secondary }) + }; + let tabs = row![ + tab_button(crate::t!("General"), OptionsTab::General), + tab_button(crate::t!("Display"), OptionsTab::Display), + tab_button(crate::t!("Selection"), OptionsTab::Selection), + ] + .spacing(6); + let body = column![ + tabs, + Space::new().height(12), // Keep the scrollbar in its own lane instead of floating over the // controls at the trailing edge of the Options content. scrollable(content).spacing(8).height(sizing.height),