From 51f07f12086c1c8f8b265e8c2fe3d49b44ebd9d1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 22 Jul 2026 16:34:47 +0300 Subject: [PATCH] feat(snap): Shift hard axis lock during point picks (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Shift while a command asks for a point captures the nearest polar increment (or ortho axis, UCS-aware) from the rubber-band base toward the cursor and pins the pick to that ray. Every candidate — osnap hits included — projects onto the locked direction, so a snap far off-axis contributes only its along-axis component; the click commits exactly what the preview shows. The direction stays fixed while Shift is held and drops the moment it releases. Grip drags get the same lock from the grip origin. Guards: engages only when the active command wants a point — never during entity picks (Shift there is the TRIM/EXTEND swap), tangent picks, selection gathering or window-corner picks. Closes #312 Co-Authored-By: Claude Fable 5 --- src/app/helpers.rs | 35 +++++++++++++++++ src/app/mod.rs | 6 +++ src/app/update/mod.rs | 5 +++ src/app/update/viewport.rs | 77 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 4 deletions(-) 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) {