feat(ucs): ortho/polar constrain in the active UCS plane

Ortho and polar drawing constraints snapped to world X/Y; under an active
UCS they now snap to the UCS axes, routed through the shared UcsXform
(identity = unchanged world-XY behaviour). Applied at all three input
sites (grip drag, command point, command preview). Typed coordinates
already flow through the converter via ucs_to_wcs / ucs_rotate_vec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-22 10:00:53 +03:00
commit 746a2c97be
2 changed files with 41 additions and 21 deletions

View file

@ -169,21 +169,34 @@ pub(super) fn grid_plane_from_camera(pitch: f32, yaw: f32) -> GridPlane {
// ── Drawing constraint helpers ───────────────────────────────────────────── // ── Drawing constraint helpers ─────────────────────────────────────────────
/// Constrain `pt` to the nearest 90° direction from `base` (XY plane, Z-up). /// Constrain `pt` to the nearest 90° direction from `base`, in the active UCS
pub(super) fn ortho_constrain(pt: glam::Vec3, base: glam::Vec3) -> glam::Vec3 { /// plane — ortho follows the user's coordinate system, not world axes. `xf` is
let dx = (pt.x - base.x).abs(); /// identity for plain WCS, so the world-XY behaviour is unchanged there.
let dy = (pt.y - base.y).abs(); pub(super) fn ortho_constrain(pt: glam::Vec3, base: glam::Vec3, xf: &UcsXform) -> glam::Vec3 {
if dx >= dy { let p = xf.to_ucs(pt);
glam::Vec3::new(pt.x, base.y, pt.z) 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::Vec3::new(p.x, b.y, p.z)
} else { } else {
glam::Vec3::new(base.x, pt.y, pt.z) glam::Vec3::new(b.x, p.y, p.z)
} };
xf.to_wcs(c)
} }
/// Constrain `pt` to the nearest polar angle multiple from `base` (XY plane, Z-up). /// Constrain `pt` to the nearest polar angle multiple from `base`, measured in
pub(super) fn polar_constrain(pt: glam::Vec3, base: glam::Vec3, step_deg: f32) -> glam::Vec3 { /// the active UCS plane (identity `xf` = world XY, Z-up).
let dx = pt.x - base.x; pub(super) fn polar_constrain(
let dy = pt.y - base.y; pt: glam::Vec3,
base: glam::Vec3,
step_deg: f32,
xf: &UcsXform,
) -> glam::Vec3 {
let p = xf.to_ucs(pt);
let b = xf.to_ucs(base);
let dx = p.x - b.x;
let dy = p.y - b.y;
let dist = (dx * dx + dy * dy).sqrt(); let dist = (dx * dx + dy * dy).sqrt();
if dist < 1e-6 { if dist < 1e-6 {
return pt; return pt;
@ -191,11 +204,11 @@ pub(super) fn polar_constrain(pt: glam::Vec3, base: glam::Vec3, step_deg: f32) -
let step = step_deg.to_radians(); let step = step_deg.to_radians();
let angle = dy.atan2(dx); let angle = dy.atan2(dx);
let snapped = (angle / step).round() * step; let snapped = (angle / step).round() * step;
glam::Vec3::new( xf.to_wcs(glam::Vec3::new(
base.x + dist * snapped.cos(), b.x + dist * snapped.cos(),
base.y + dist * snapped.sin(), b.y + dist * snapped.sin(),
pt.z, p.z,
) ))
} }
/// Polar constraint that only engages when the cursor is within `tol_px` /// Polar constraint that only engages when the cursor is within `tol_px`
@ -208,8 +221,9 @@ pub(super) fn polar_constrain_near(
view_proj: glam::Mat4, view_proj: glam::Mat4,
bounds: iced::Rectangle, bounds: iced::Rectangle,
tol_px: f32, tol_px: f32,
xf: &UcsXform,
) -> glam::Vec3 { ) -> glam::Vec3 {
let snapped = polar_constrain(pt, base, step_deg); let snapped = polar_constrain(pt, base, step_deg, xf);
let to_screen = |w: glam::Vec3| { let to_screen = |w: glam::Vec3| {
let ndc = view_proj.project_point3(w); let ndc = view_proj.project_point3(w);
( (

View file

@ -2653,8 +2653,9 @@ impl OpenCADStudio {
if snap_hit.is_none() { if snap_hit.is_none() {
let base = grip.origin_world; let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode { if self.ortho_mode {
snapped = ortho_constrain(snapped, base); snapped = ortho_constrain(snapped, base, &ucs_xf);
} else if self.polar_mode { } else if self.polar_mode {
snapped = polar_constrain_near( snapped = polar_constrain_near(
snapped, snapped,
@ -2663,6 +2664,7 @@ impl OpenCADStudio {
vp_mat, vp_mat,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf,
); );
} }
} }
@ -2871,8 +2873,9 @@ impl OpenCADStudio {
.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid); .is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid);
if !osnap_locked { if !osnap_locked {
if let Some(base) = self.last_point { if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode { if self.ortho_mode {
pt = ortho_constrain(pt, base); pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode { } else if self.polar_mode {
pt = polar_constrain_near( pt = polar_constrain_near(
pt, pt,
@ -2881,6 +2884,7 @@ impl OpenCADStudio {
view_proj, view_proj,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf,
); );
} }
} }
@ -3477,8 +3481,9 @@ impl OpenCADStudio {
// Object snap wins over ortho/polar — a snapped point // Object snap wins over ortho/polar — a snapped point
// commits as-is. Grid snap still combines. (#132) // commits as-is. Grid snap still combines. (#132)
if let Some(base) = self.last_point { if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode { if self.ortho_mode {
pt = ortho_constrain(pt, base); pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode { } else if self.polar_mode {
pt = polar_constrain_near( pt = polar_constrain_near(
pt, pt,
@ -3487,6 +3492,7 @@ impl OpenCADStudio {
vp_mat, vp_mat,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
&ucs_xf,
); );
} }
} }