diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 5b5ca9a49..1a7aaf2e5 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -250,6 +250,41 @@ pub(super) fn polar_constrain_near( } } +/// Hard axis lock (#312): the locked ray's direction — the nearest polar +/// increment (or ortho axis) from `base` toward `cursor`, in the active UCS +/// plane. `None` when the cursor sits on the base point. +pub(super) fn axis_lock_capture( + cursor: glam::DVec3, + base: glam::DVec3, + polar: bool, + step_deg: f32, + xf: &UcsXform, +) -> Option { + let p = xf.to_ucs(cursor); + let b = xf.to_ucs(base); + let dx = p.x - b.x; + let dy = p.y - b.y; + 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 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()) +} + +/// Project `pt` onto the locked ray through `base` — the hard lock applies to +/// EVERYTHING, including an osnap hit, so a snap far off-axis contributes only +/// its along-axis component (#312). +pub(super) fn axis_lock_apply( + pt: glam::DVec3, + base: glam::DVec3, + dir: glam::DVec3, +) -> glam::DVec3 { + base + dir * (pt - base).dot(dir) +} + // ── Clipboard / selection helpers ────────────────────────────────────────── /// Copy/paste anchor: the lower-left corner of the bounding box that encloses diff --git a/src/app/mod.rs b/src/app/mod.rs index 670a3bbb0..a400019b4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -559,6 +559,11 @@ pub(super) struct OpenCADStudio { /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas /// point it opened at, or `None` when closed (#337). snap_override_popup: Option, + /// Hard axis lock (#312): the WCS direction captured when Shift went down + /// during a rubber-band point pick. While `Some`, every candidate point — + /// osnap hits included — projects onto this ray from `last_point` (or the + /// grip origin). Cleared when Shift releases. + axis_lock_dir: Option, /// Inline rename state: (original_name, current_edit_value). layout_rename_state: Option<(String, String)>, /// Timestamp of the previous viewport left-click release (for double-click detection). @@ -2358,6 +2363,7 @@ impl OpenCADStudio { text_inline: None, layout_context_menu: None, snap_override_popup: None, + axis_lock_dir: None, layout_rename_state: None, last_vp_click_time: None, last_vp_click_pos: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1c0cac2a6..297f96cec 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2243,6 +2243,11 @@ impl OpenCADStudio { let ctrl_changed = self.ctrl_down != ctrl; self.shift_down = shift; self.ctrl_down = ctrl; + // Releasing Shift drops the hard axis lock immediately (#312) + // — without this a lock could linger until the next move. + if !shift { + self.axis_lock_dir = None; + } // A live command may key its preview off Ctrl (arc-direction // flip). Rebuild it at the current cursor so the flip shows // without waiting for the next mouse move. diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5a62e38a0..1da7b5161 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4,7 +4,8 @@ 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, + axis_lock_apply, axis_lock_capture, ortho_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}; @@ -767,7 +768,26 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { s.screen.y += tile_b.y; } - if snap_hit.is_none() { + // Hard axis lock (#312) works for grip drags too: Shift + // pins the drag to the nearest polar/ortho ray from the + // grip's origin, osnap hits included. + if self.shift_down { + if self.axis_lock_dir.is_none() { + let ucs_xf = self.tabs[i].ucs_xform(); + self.axis_lock_dir = axis_lock_capture( + raw, + grip.origin_world, + self.polar_mode, + self.polar_increment_deg, + &ucs_xf, + ); + } + } else { + self.axis_lock_dir = None; + } + if let Some(dir) = self.axis_lock_dir { + snapped = axis_lock_apply(snapped, grip.origin_world, dir); + } else if snap_hit.is_none() { let base = grip.origin_world; let ucs_xf = self.tabs[i].ucs_xform(); if self.ortho_mode { @@ -1025,6 +1045,42 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .snap_result .map(|s| s.world) .unwrap_or(cursor_world); + // Hard axis lock (#312): Shift during a rubber-band + // pick captures the nearest polar/ortho direction + // and pins the pick to that ray — even an osnap hit + // contributes only its along-axis component. + // Only while an active command is asking for a + // POINT — Shift has other meanings during entity + // picks (TRIM/EXTEND swap) and selection. + let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|c| { + !c.needs_entity_pick() + && !c.needs_tangent_pick() + && !c.is_selection_gathering() + }); + if let Some(base) = self.last_point { + if self.shift_down && wants_point && !is_window_corner { + if self.axis_lock_dir.is_none() { + let ucs_xf = self.tabs[i].ucs_xform(); + self.axis_lock_dir = axis_lock_capture( + cursor_world, + base, + self.polar_mode, + self.polar_increment_deg, + &ucs_xf, + ); + } + } else if !self.shift_down { + self.axis_lock_dir = None; + } + } else if !self.shift_down { + self.axis_lock_dir = None; + } + if let (Some(dir), Some(base)) = + (self.axis_lock_dir, self.last_point) + { + pt = axis_lock_apply(pt, base, dir); + pt + } else { // Object snap wins over ortho/polar: a snapped point // is taken as-is so it isn't pulled onto the ortho // axis. Grid snap is positional, not an object snap, @@ -1052,6 +1108,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } } pt + } }; // Clamp to world XY only when no UCS is active; with a // UCS the point already lies on the UCS XY plane. @@ -1966,9 +2023,20 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { if self.tabs[i].active_ucs.is_none() { pt.z = 0.0; } + let mut world_pt_locked = false; + // Hard axis lock (#312): the held Shift lock projects + // the committed point — osnap hits included — onto the + // locked ray, matching the preview exactly. + if let (Some(dir), Some(base)) = (self.axis_lock_dir, self.last_point) { + pt = axis_lock_apply(pt, base, dir); + if self.tabs[i].active_ucs.is_none() { + pt.z = 0.0; + } + world_pt_locked = true; + } // OTRACK alignment wins over ortho/polar; otherwise apply // ortho/polar relative to the last point. - let otrack = if snap_hit.is_none() { + let otrack = if !world_pt_locked && snap_hit.is_none() { let step = if self.polar_mode && !is_window_corner { Some(self.polar_increment_deg) } else { @@ -1985,7 +2053,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { if self.tabs[i].active_ucs.is_none() { pt.z = 0.0; } - } else if !is_window_corner + } else if !world_pt_locked + && !is_window_corner && !snap_hit .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) {