diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 821d25262..678ae2144 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -339,10 +339,8 @@ pub(super) fn polar_constrain( )) } -/// Polar constraint that only engages when the cursor is within `tol_px` -/// screen pixels of the nearest polar ray; otherwise the cursor is left free -/// so POLAR behaves as if off when pointing away from every angle (issue #70). -pub(super) fn polar_constrain_near( +/// Return the polar-constrained point while its ray is engaged. +pub(super) fn polar_constrain_if_near( pt: glam::DVec3, base: glam::DVec3, step_deg: f32, @@ -351,7 +349,7 @@ pub(super) fn polar_constrain_near( bounds: iced::Rectangle, tol_px: f32, xf: &UcsXform, -) -> glam::DVec3 { +) -> Option { let snapped = polar_constrain(pt, base, step_deg, xf); let to_screen = |w: glam::DVec3| { let ndc = view_rot.project_point3((w - eye).as_vec3()); @@ -362,11 +360,22 @@ pub(super) fn polar_constrain_near( }; let (cx, cy) = to_screen(pt); let (sx, sy) = to_screen(snapped); - if ((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px { - snapped - } else { - pt - } + (((cx - sx).powi(2) + (cy - sy).powi(2)).sqrt() <= tol_px).then_some(snapped) +} + +/// Constrain near an engaged polar ray; otherwise keep the cursor free. +pub(super) fn polar_constrain_near( + pt: glam::DVec3, + base: glam::DVec3, + step_deg: f32, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + tol_px: f32, + xf: &UcsXform, +) -> glam::DVec3 { + polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf) + .unwrap_or(pt) } /// Hard axis lock (#312): the locked ray's direction — the nearest polar diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 61acc136e..b42fc2cf5 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4,8 +4,9 @@ use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - 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, + axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord, + polar_constrain_if_near, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, + CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; @@ -117,6 +118,45 @@ fn over_ucs_icon(p: Point, h: &crate::ui::overlay::UcsIconHit) -> bool { } impl OpenCADStudio { + fn active_construction_ray( + &self, + tab: usize, + cursor: glam::DVec3, + base: glam::DVec3, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + ) -> Option<(glam::DVec3, glam::DVec3)> { + let ucs = self.tabs[tab].ucs_xform(); + let mut target = if self.ortho_mode { + drafting_constrain( + cursor, + base, + &ucs, + self.isometric_drafting, + self.iso_plane, + self.snap_angle_deg, + ) + } else if self.polar_mode { + polar_constrain_if_near( + cursor, + base, + self.polar_increment_deg, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + &ucs, + )? + } else { + return None; + }; + if self.tabs[tab].active_ucs.is_none() { + target.z = base.z; + } + Some((base, target)) + } + /// Write PDSIZE from the dialog buffer with the current relative/absolute /// sign. A relative size is stored negative; absolute positive. Switching to /// absolute with an empty/zero size seeds a positive value from the current @@ -1034,60 +1074,10 @@ 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()); - self.snapper.from_point = Some(grip.origin_world.as_vec3()); - - // Keep the updated drafting basis from main so grid/snap follows the - // current drafting axes, including isometric drafting and SNAPANG. let (go, gr) = self.drafting_grid_basis(i); - let base = grip.origin_world; - let ucs_xf = self.tabs[i].ucs_xform(); - - let mut construction_cursor = if self.ortho_mode { - Some(drafting_constrain( - raw, - base, - &ucs_xf, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - )) - } else if self.polar_mode { - let pt = polar_constrain_near( - raw, - base, - self.polar_increment_deg, - view_rot, - eye, - bounds, - self.snapper.osnap_radius_px, - &ucs_xf, - ); - - // POLAR being enabled is not enough: the cursor must actually - // be engaged on one of the polar tracking rays. - let delta = pt - base; - let step = (self.polar_increment_deg as f64).to_radians(); - let angle = delta.y.atan2(delta.x); - - let polar_engaged = step > 1e-9 - && (((angle / step).round() * step - angle).abs() < 1e-3) - && (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9; - - if polar_engaged { - Some(pt) - } else { - None - } - } else { - None - }; - - if let Some(pt) = construction_cursor.as_mut() { - if self.tabs[i].active_ucs.is_none() { - pt.z = base.z; - } - } + let construction_ray = + self.active_construction_ray(i, raw, base, view_rot, eye, bounds); // `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 = self.snapper.snap( @@ -1099,7 +1089,7 @@ impl OpenCADStudio { bounds, go, gr, - construction_cursor, + construction_ray, ); self.tabs[i].snap_result = snap_hit; @@ -1521,60 +1511,18 @@ impl OpenCADStudio { // the downcast point here. self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); - // Build the temporary construction direction before OSNAP. - // Intersection can then test the active rubber-band against - // existing geometry (#704). - let construction_cursor = if is_window_corner { + let construction_ray = if is_window_corner { None } else { self.last_point.and_then(|base| { - let ucs_xf = self.tabs[i].ucs_xform(); - - let mut pt = if self.ortho_mode { - Some(drafting_constrain( - snap_cursor, - base, - &ucs_xf, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - )) - } else if self.polar_mode { - let pt = polar_constrain_near( - snap_cursor, - base, - self.polar_increment_deg, - view_rot, - eye, - bounds, - self.snapper.osnap_radius_px, - &ucs_xf, - ); - - // POLAR being enabled is not enough: the cursor must actually - // be engaged on one of the polar tracking rays. - let delta = pt - base; - let step = (self.polar_increment_deg as f64).to_radians(); - let angle = delta.y.atan2(delta.x); - - let polar_engaged = step > 1e-9 - && (((angle / step).round() * step - angle).abs() < 1e-3) - && (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9; - - if polar_engaged { - Some(pt) - } else { - None - } - } else { - None - }?; - - if self.tabs[i].active_ucs.is_none() { - pt.z = base.z; - } - - Some(pt) + self.active_construction_ray( + i, + snap_cursor, + base, + view_rot, + eye, + bounds, + ) }) }; @@ -1587,7 +1535,7 @@ impl OpenCADStudio { bounds, go, gr, - construction_cursor, + construction_ray, ) }; @@ -2906,57 +2854,18 @@ impl OpenCADStudio { let (go, gr) = self.drafting_grid_basis(i); self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); - let construction_cursor = if is_window_corner { + let construction_ray = if is_window_corner { None } else { self.last_point.and_then(|base| { - let ucs_xf = self.tabs[i].ucs_xform(); - - let mut pt = if self.ortho_mode { - Some(drafting_constrain( + self.active_construction_ray( + i, snap_cursor, base, - &ucs_xf, - self.isometric_drafting, - self.iso_plane, - self.snap_angle_deg, - )) - } else if self.polar_mode { - let pt = polar_constrain_near( - snap_cursor, - base, - self.polar_increment_deg, - view_rot, - eye, - bounds, - self.snapper.osnap_radius_px, - &ucs_xf, - ); - - // POLAR being enabled is not enough: the cursor must actually - // be engaged on one of the polar tracking rays. - let delta = pt - base; - let step = (self.polar_increment_deg as f64).to_radians(); - let angle = delta.y.atan2(delta.x); - - let polar_engaged = step > 1e-9 - && (((angle / step).round() * step - angle).abs() < 1e-3) - && (delta.x * delta.x + delta.y * delta.y).sqrt() > 1e-9; - - if polar_engaged { - Some(pt) - } else { - None - } - } else { - None - }?; - - if self.tabs[i].active_ucs.is_none() { - pt.z = base.z; - } - - Some(pt) + view_rot, + eye, + bounds, + ) }) }; @@ -2969,7 +2878,7 @@ impl OpenCADStudio { bounds, go, gr, - construction_cursor, + construction_ray, ) }; // Snap runs in model space; the result is already model. diff --git a/src/snap.rs b/src/snap.rs index 9450b56e6..816e8de48 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -865,7 +865,7 @@ impl Snapper { // rotated or isometric grid the user sees. grid_origin: Vec3, grid_axes: (Vec3, Vec3, Vec3), - construction_cursor: Option, + construction_ray: Option<(glam::DVec3, glam::DVec3)>, ) -> Option { // Object-snap selection is priority-then-distance, NOT nearest-wins. // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry @@ -1217,42 +1217,45 @@ impl Snapper { } } - // ── Intersection with active construction segment (#704) ────────── - // - // The existing Intersection pass below only compares document - // entities with each other. Also compare the rubber-band currently - // being drawn against existing geometry. - if self.is_on(SnapType::Intersection) { - if let (Some(from), Some(to)) = (self.from_point, construction_cursor) { - let from = from.as_dvec3(); - if (to - from).length_squared() > 1e-18 { - // Active/deferred Intersection uses the actual nearby wires directly. - // The interaction candidate set may contain wires while its optional - // segment index is empty. - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; + let mut try_ray_intersections = |origin: glam::DVec3, through: glam::DVec3| { + if (through - origin).length_squared() <= 1e-18 { + return; + } + if let Some(segments) = &local_segments { + for segment in segments { + if let Some(point) = + ray_segment_intersect_3d(origin, through, segment.a, segment.b) + { + if (point - origin).length_squared() > 1e-18 { + try_pt(point, SnapType::Intersection); } - - for index in 0..wire.points.len().saturating_sub(1) { - let b0 = wp_f64(wire, index); - let b1 = wp_f64(wire, index + 1); - - if let Some(pt) = ray_segment_intersect_3d(from, to, b0, b1) { - if (pt - from).length_squared() > 1e-18 { - try_pt(pt, SnapType::Intersection); - } + } + } + } else { + for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + for index in 0..wire.points.len().saturating_sub(1) { + if let Some(point) = ray_segment_intersect_3d( + origin, + through, + wp_f64(wire, index), + wp_f64(wire, index + 1), + ) { + if (point - origin).length_squared() > 1e-18 { + try_pt(point, SnapType::Intersection); } } } } } + }; + + if self.is_on(SnapType::Intersection) { + if let Some((origin, through)) = construction_ray { + try_ray_intersections(origin, through); + } } - // ── Intersection with active perpendicular OTRACK ray (#704) ────── - // - // A perpendicular tracking ray is not part of the command's ordinary - // ORTHO/POLAR construction cursor. When the cursor is actually tracking - // one of those rays, allow Intersection to stop it on nearby geometry. + + // Check engaged perpendicular tracking rays against nearby geometry. if self.is_on(SnapType::Intersection) && self.otrack_enabled && self.is_on(SnapType::Perpendicular) @@ -1263,50 +1266,23 @@ impl Snapper { }; for &dir in perp_dirs { - // Project the cursor onto this perpendicular tracking line. let rel = cursor_world - origin; let t = rel.x * dir.x + rel.y * dir.y; - let aligned = glam::DVec3::new( origin.x + dir.x * t, origin.y + dir.y * t, origin.z, ); - - // Only consider the ray when the cursor is genuinely tracking it. - // This prevents Intersection from behaving like Nearest. let aligned_screen = world_to_screen(aligned, view_rot, eye, bounds); if dist2(aligned_screen, cursor_screen) > radius2 { continue; } - - // Perpendicular tracking is an infinite line, but once the cursor - // chooses a side we only want intersections forward on that side. let dir_to_cursor = if t >= 0.0 { dir } else { -dir }; - let through = origin + dir_to_cursor; - - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; - } - - for index in 0..wire.points.len().saturating_sub(1) { - let b0 = wp_f64(wire, index); - let b1 = wp_f64(wire, index + 1); - - if let Some(pt) = - ray_segment_intersect_3d(origin, through, b0, b1) - { - // Do not re-offer the tracking origin itself. - if (pt - origin).length_squared() > 1e-18 { - try_pt(pt, SnapType::Intersection); - } - } - } - } + try_ray_intersections(origin, origin + dir_to_cursor); } } } + drop(try_ray_intersections); // ── Intersection — segment-segment intersections (pairwise, gated) ── if self.is_on(SnapType::Intersection) @@ -2103,13 +2079,7 @@ fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option= 0`: only forward along the construction ray. - // `s` must remain inside the existing finite segment. if t < 0.0 || s < 0.0 || s > 1.0 { return None; }