diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index dc9d44dff..61acc136e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1034,12 +1034,73 @@ 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; + } + } // `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(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); + let snap_hit = self.snapper.snap( + raw, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + construction_cursor, + ); self.tabs[i].snap_result = snap_hit; @@ -1459,6 +1520,64 @@ impl OpenCADStudio { // base only matters for typed-input precision, so hand it // 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 { + 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.snapper.snap( snap_cursor, p, @@ -1468,6 +1587,7 @@ impl OpenCADStudio { bounds, go, gr, + construction_cursor, ) }; @@ -2123,7 +2243,7 @@ impl OpenCADStudio { let (go, gr) = self.drafting_grid_basis(i); let snap_hit = self .snapper - .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr); + .snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr, None); let world = snap_hit.map(|s| s.world).unwrap_or(raw); self.tabs[i].snap_result = snap_hit; if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2785,6 +2905,61 @@ impl OpenCADStudio { } else { 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 { + 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.snapper.snap( snap_cursor, p, @@ -2794,6 +2969,7 @@ impl OpenCADStudio { bounds, go, gr, + construction_cursor, ) }; // Snap runs in model space; the result is already model. diff --git a/src/snap.rs b/src/snap.rs index 82d2e54e4..9450b56e6 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -848,6 +848,7 @@ impl Snapper { bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), + None, ) } @@ -864,6 +865,7 @@ impl Snapper { // rotated or isometric grid the user sees. grid_origin: Vec3, grid_axes: (Vec3, Vec3, Vec3), + construction_cursor: Option, ) -> Option { // Object-snap selection is priority-then-distance, NOT nearest-wins. // "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry @@ -1215,6 +1217,97 @@ 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; + } + + 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); + } + } + } + } + } + } + } + // ── 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. + if self.is_on(SnapType::Intersection) + && self.otrack_enabled + && self.is_on(SnapType::Perpendicular) + { + for (gi, &origin) in self.tracking_points.iter().enumerate() { + let Some(perp_dirs) = self.tracking_perp_dirs.get(gi) else { + continue; + }; + + 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); + } + } + } + } + } + } + } + // ── Intersection — segment-segment intersections (pairwise, gated) ── if self.is_on(SnapType::Intersection) && (local_segments.is_some() || allow_unindexed_pairwise) @@ -2010,6 +2103,56 @@ fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option Option { + let d1x = ray_through.x - ray_origin.x; + let d1y = ray_through.y - ray_origin.y; + let d2x = b1.x - b0.x; + let d2y = b1.y - b0.y; + + let cross = d1x * d2y - d1y * d2x; + if cross.abs() < 1e-9 { + return None; + } + + let ex = b0.x - ray_origin.x; + let ey = b0.y - ray_origin.y; + + let t = (ex * d2y - ey * d2x) / cross; + let s = (ex * d1y - ey * d1x) / cross; + + // `t >= 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; + } + + let za = ray_origin.z + t * (ray_through.z - ray_origin.z); + let zb = b0.z + s * (b1.z - b0.z); + let tol = 1e-6_f64.max(1e-9 * za.abs().max(zb.abs())); + + if (za - zb).abs() > tol { + return None; + } + + Some(glam::DVec3::new( + ray_origin.x + t * d1x, + ray_origin.y + t * d1y, + 0.5 * (za + zb), + )) +} + /// XY-plane segment-segment intersection. Returns `None` if parallel or outside. /// True 3D intersection of two segments: the point where their plan (XY) /// projections cross **and** both segments are at the same height there. Returns