feat(snap): Shift hard axis lock during point picks (#312)

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-22 16:34:47 +03:00
commit 51f07f1208
4 changed files with 119 additions and 4 deletions

View file

@ -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<glam::DVec3> {
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

View file

@ -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<iced::Point>,
/// 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<glam::DVec3>,
/// 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,

View file

@ -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.

View file

@ -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<Message> {
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<Message> {
.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<Message> {
}
}
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<Message> {
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<Message> {
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)
{