diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6a5ea31c6..5aae19817 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -31,6 +31,56 @@ const UCS_ICON_PICK_PX: f32 = 10.0; /// Pixel half-width for clicking an icon arm (line segment). const UCS_ICON_ARM_PX: f32 = 6.0; +/// Snapper coordinates are local to the active pane, while the overlay +/// consumes canvas coordinates. Apply the same translation to the visible +/// snap glyph and both Extension guide origins. +fn snap_pixels_to_canvas(hit: &mut crate::snap::SnapResult, pane: iced::Rectangle) { + let translate = |point: &mut Point| { + point.x += pane.x; + point.y += pane.y; + }; + translate(&mut hit.screen); + if let Some(base) = hit.extension_base.as_mut() { + translate(base); + } + if let Some(base) = hit.extension_base2.as_mut() { + translate(base); + } +} + +#[cfg(test)] +mod extension_canvas_tests { + use super::*; + + #[test] + fn translates_extension_anchors_with_the_snap_marker() { + let mut hit = crate::snap::SnapResult { + world: glam::DVec3::ZERO, + screen: Point::new(150.0, 75.0), + snap_type: crate::snap::SnapType::Extension, + tangent_obj: None, + extension_base: Some(Point::new(100.0, 150.0)), + extension_base2: Some(Point::new(75.0, 200.0)), + extension_origin: None, + extension_dir: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, + }; + let pane = iced::Rectangle { + x: 32.0, + y: 56.0, + width: 600.0, + height: 400.0, + }; + snap_pixels_to_canvas(&mut hit, pane); + assert_eq!(hit.screen, Point::new(182.0, 131.0)); + assert_eq!(hit.extension_base, Some(Point::new(132.0, 206.0))); + assert_eq!(hit.extension_base2, Some(Point::new(107.0, 256.0))); + } +} + fn pt_pt_d2(a: Point, b: Point) -> f32 { (a.x - b.x).powi(2) + (a.y - b.y).powi(2) } @@ -1855,8 +1905,7 @@ impl OpenCADStudio { snap_hit.map(|s| s.world).unwrap_or(raw) }; if let Some(s) = self.tabs[i].snap_result.as_mut() { - s.screen.x += tile_b.x; - s.screen.y += tile_b.y; + snap_pixels_to_canvas(s, tile_b); } if axis_lock.is_none() @@ -2900,11 +2949,11 @@ impl OpenCADStudio { } } - // Snap glyph is positioned in canvas space; shift the - // tile-local snap screen point back to the full canvas. + // Move Extension's acquired endpoints along with the snap glyph. + // Previously only s.screen moved to canvas pixels, leaving the + // dashed Extension line offset from the actual endpoint. if let Some(s) = self.tabs[i].snap_result.as_mut() { - s.screen.x += tile_b.x; - s.screen.y += tile_b.y; + snap_pixels_to_canvas(s, tile_b); } // Give the command the current UCS + Ctrl state before it @@ -3438,18 +3487,7 @@ impl OpenCADStudio { 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() { - // Snap marker and its extension anchors are pane-local; lift them to - // absolute canvas px. - s.screen.x += tile_b.x; - s.screen.y += tile_b.y; - if let Some(base) = s.extension_base.as_mut() { - base.x += tile_b.x; - base.y += tile_b.y; - } - if let Some(base) = s.extension_base2.as_mut() { - base.x += tile_b.x; - base.y += tile_b.y; - } + snap_pixels_to_canvas(s, tile_b); } use codec::types::Vector3; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 580ab4ab0..17cbd2cdb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -914,6 +914,60 @@ bg={bg_ms:.1}ms n={view_count}" ob.y + (1.0 - ndc.y) * 0.5 * ob.height, ) }; + // Project a *ray* using a screen-space baseline long enough to + // survive f32 pixel rounding. Projecting base + one world unit can + // produce a subpixel delta at low zoom: normalizing that delta in + // the overlay magnifies the rounding error into a visibly wandering + // guide, even when the acquired world-space ray is fixed. + let ost_project_ray = |origin: glam::DVec3, + dir: glam::DVec3, + view_rot: glam::Mat4, + eye: glam::DVec3, + ob: iced::Rectangle| + -> Option<(iced::Point, iced::Point)> { + let b = ost_project(origin, view_rot, eye, ob); + if !b.x.is_finite() || !b.y.is_finite() { + return None; + } + let unit = dir.try_normalize()?; + let mut distance = 1.0_f64; + for _ in 0..24 { + let tip = ost_project(origin + unit * distance, view_rot, eye, ob); + let length = (tip.x - b.x).hypot(tip.y - b.y); + if length.is_finite() && length >= 64.0 { + return Some((b, tip)); + } + distance *= 2.0; + } + None + }; + // Draw the Extension start at the actual acquired *world* point, + // projected through the same camera/rect as the entity and guides. + // The snap engine's screen-space base is only pane-local and can + // differ from the final canvas coordinates used by the overlay. + // Keep that older value as a fallback for snaps without origin. + let snap_ext_base = tab + .snap_result + .filter(|hit| hit.snap_type == crate::snap::SnapType::Extension) + .and_then(|hit| hit.extension_origin) + .and_then(|origin| { + let (rot, eye, rect) = otrack_proj?; + let pixel = ost_project(origin, rot, eye, rect); + (pixel.x.is_finite() && pixel.y.is_finite()).then_some(pixel) + }) + .or(snap_ext_base); + // OSNAP Extension is also active with F11 / OTRACK turned off. + // Give its marker the actual acquired geometric direction instead + // of reconstructing a new ray from the moving snap cursor. + let snap_ext_dir: Option = tab + .snap_result + .filter(|hit| hit.snap_type == crate::snap::SnapType::Extension) + .and_then(|hit| hit.extension_origin.zip(hit.extension_dir)) + .and_then(|(origin, dir)| { + let (rot, eye, rect) = otrack_proj?; + let (base, tip) = ost_project_ray(origin, dir, rot, eye, rect)?; + Some(iced::Point::new(tip.x - base.x, tip.y - base.y)) + }); let ost_points: Vec = if let Some((view_rot, eye, ob)) = otrack_proj { self.snapper @@ -935,17 +989,16 @@ bg={bg_ms:.1}ms n={view_count}" // both are drawn — one guide alone hides what the point is (#1313). let otrack_lines: Vec<(iced::Point, iced::Point)> = match otrack_proj { Some((view_rot, eye, ob)) => { - let a = ost_project(tab.last_cursor_world, view_rot, eye, ob); self.otrack_active .into_iter() .chain(self.otrack_cross) - .filter_map(|(base, _dir)| { - let b = ost_project(base, view_rot, eye, ob); - (b.x.is_finite() - && b.y.is_finite() - && a.x.is_finite() - && a.y.is_finite()) - .then_some((b, a)) + .filter_map(|(base, dir)| { + // Draw the acquired geometrical ray, not the line + // from its origin to the moving cursor. The latter + // rotates slightly as the pointer moves away from + // the exact ray, making the Extension guide appear + // to slide across its acquired endpoint. + ost_project_ray(base, dir, view_rot, eye, ob) }) .collect() } @@ -1227,6 +1280,7 @@ bg={bg_ms:.1}ms n={view_count}" snap_info, snap_ext_base, snap_ext_base2, + snap_ext_dir, grips, control_polygon, grip_clip, diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 826708911..f209223ba 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -783,6 +783,62 @@ fn clip_seg(p0: Point, p1: Point, bounds: iced::Rectangle) -> Option<(Point, Poi )) } +/// Build a snap-guide end on the acquired screen ray, not on the noisy +/// cursor-to-base line. Returns the normalized ray and its forward tip. +fn extension_guide_tip( + base: Point, + snapped: Point, + acquired_direction: Option, +) -> Option<(Point, Point)> { + let delta = acquired_direction.unwrap_or(Point::new( + snapped.x - base.x, + snapped.y - base.y, + )); + let len = delta.x.hypot(delta.y); + if !len.is_finite() || len <= 1e-3 { + return None; + } + let u = Point::new(delta.x / len, delta.y / len); + let along = (snapped.x - base.x) * u.x + (snapped.y - base.y) * u.y; + let tip = Point::new( + base.x + u.x * (along + 18.0), + base.y + u.y * (along + 18.0), + ); + Some((u, tip)) +} + +#[cfg(test)] +mod extension_guide_tests { + use super::*; + + #[test] + fn acquired_extension_guide_stays_on_its_origin_when_cursor_wanders() { + let base = Point::new(100.0, 200.0); + for snap in [ + Point::new(140.0, 202.0), + Point::new(175.0, 193.0), + Point::new(240.0, 215.0), + ] { + let (unit, tip) = + extension_guide_tip(base, snap, Some(Point::new(300.0, 0.0))).unwrap(); + assert!((unit.y).abs() < 1e-5); + assert!((tip.y - base.y).abs() < 1e-5); + assert!((tip.x - (snap.x + 18.0)).abs() < 1e-5); + } + } + + #[test] + fn extension_guide_fallback_keeps_original_behavior_without_acquired_ray() { + let base = Point::new(100.0, 200.0); + let snap = Point::new(130.0, 240.0); + let (u, tip) = extension_guide_tip(base, snap, None).unwrap(); + assert!((u.x - 0.6).abs() < 1e-5); + assert!((u.y - 0.8).abs() < 1e-5); + assert!((tip.x - 140.8).abs() < 1e-4); + assert!((tip.y - 254.4).abs() < 1e-4); + } +} + pub fn compute_grid_step(distance: f32, fov_y: f32, bounds: iced::Rectangle) -> f32 { compute_grid_steps(1.0, 1.0, distance, fov_y, bounds, true).0 } @@ -954,6 +1010,7 @@ pub(crate) fn selection_overlay<'a>( snap: Option<(Point, SnapType)>, snap_ext_base: Option, snap_ext_base2: Option, + snap_ext_dir: Option, grips: Vec, control_polygon: Option<(Vec, bool)>, grip_clip: Option, @@ -989,6 +1046,7 @@ pub(crate) fn selection_overlay<'a>( snap, snap_ext_base, snap_ext_base2, + snap_ext_dir, grips, control_polygon, grip_clip, @@ -1040,6 +1098,7 @@ struct SelectionCanvas { /// Second extension-guide base, present only for an extended intersection so /// both crossing extensions stay drawn when the crossing is caught. (#247, #259) snap_ext_base2: Option, + snap_ext_dir: Option, grips: Vec, /// Selected spline control polygon and closed state. control_polygon: Option<(Vec, bool)>, @@ -1704,6 +1763,60 @@ impl canvas::Program for SelectionCanvas { style: canvas::Style::Solid(marker), ..Default::default() }; + // Construction guides are canvas-space geometry, NOT snap glyphs. + // They must be drawn before the SNAPMARKERSIZE transform below. + // Scaling a guide around the moving snap position makes a fixed + // acquired endpoint visibly slide as the cursor moves. + let extension_ray = self.snap_ext_base.and_then(|base| { + let acquired = self.snap_ext_dir.or_else(|| { + self.otrack_lines.iter().find_map(|&(origin, tip)| { + let offset = (origin.x - base.x).hypot(origin.y - base.y); + (offset <= 2.0) + .then_some(Point::new(tip.x - origin.x, tip.y - origin.y)) + }) + }); + extension_guide_tip(base, sp, acquired) + .map(|(unit, tip)| (base, unit, tip)) + }); + let guide_dash = canvas::Stroke { + line_dash: canvas::LineDash { + segments: &[4.0, 4.0], + offset: 0, + }, + ..canvas::Stroke::default().with_color(marker).with_width(1.0) + }; + match snap_type { + SnapType::Extension => { + if let Some((base, _, tip)) = extension_ray { + if let Some((a, b)) = clip_seg(base, tip, bounds) { + frame.stroke(&canvas::Path::line(a, b), guide_dash.clone()); + } + } + } + SnapType::Intersection => { + // Extended-intersection guide origins must also remain + // fixed when SNAPMARKERSIZE changes. + for base in [self.snap_ext_base, self.snap_ext_base2] + .into_iter() + .flatten() + { + let dx = sp.x - base.x; + let dy = sp.y - base.y; + let len = dx.hypot(dy); + if len > 1e-3 { + let tip = Point::new( + sp.x + dx / len * 18.0, + sp.y + dy / len * 18.0, + ); + if let Some((a, b)) = clip_seg(base, tip, bounds) { + frame.stroke(&canvas::Path::line(a, b), guide_dash.clone()); + } + } + } + } + _ => {} + } + // SNAPMARKERSIZE scales the glyphs drawn for the default 5 px. let scale = self.crosshair.snap_size.clamp(1.0, 20.0) / 5.0; frame.with_save(|frame| { @@ -1801,35 +1914,8 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&path, stroke); } SnapType::Intersection => { - // An extended intersection sets one or two extension bases; - // draw a dashed guide from each endpoint through the crossing - // so both contributing extension paths stay visible. A real - // on-segment crossing carries no bases, so nothing draws here. - // (#247, #259) - for base in [self.snap_ext_base, self.snap_ext_base2] - .into_iter() - .flatten() - { - let dx = sp.x - base.x; - let dy = sp.y - base.y; - let len = (dx * dx + dy * dy).sqrt(); - if len > 1e-3 { - let dash = canvas::Stroke { - line_dash: canvas::LineDash { - segments: &[4.0, 4.0], - offset: 0, - }, - ..canvas::Stroke::default().with_color(marker).with_width(1.0) - }; - let tip = - Point::new(sp.x + dx / len * 18.0, sp.y + dy / len * 18.0); - // `base` is a projected entity endpoint: clip before - // stroking (see `clip_seg`). - if let Some((a, b)) = clip_seg(base, tip, bounds) { - frame.stroke(&canvas::Path::line(a, b), dash); - } - } - } + // The guide is drawn unscaled above; only the X glyph + // belongs inside the SNAPMARKERSIZE transform. let r = 5.0_f32; let p1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r)); @@ -1917,36 +2003,12 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&path, stroke); } SnapType::Extension => { - // Unit direction from the endpoint the snap extends from - // toward the snap point, so the guide line and the three - // dots follow the actual extension path (#238). - let dir = self.snap_ext_base.and_then(|base| { - let dx = sp.x - base.x; - let dy = sp.y - base.y; - let len = (dx * dx + dy * dy).sqrt(); - (len > 1e-3).then(|| (base, Point::new(dx / len, dy / len))) - }); - // Dashed guide line from that endpoint, through the snap - // point and a little beyond, so the extension path is - // visible as the cursor tracks along it. - if let Some((base, u)) = dir { - let dash = canvas::Stroke { - line_dash: canvas::LineDash { - segments: &[4.0, 4.0], - offset: 0, - }, - ..canvas::Stroke::default().with_color(marker).with_width(1.0) - }; - let tip = Point::new(sp.x + u.x * 18.0, sp.y + u.y * 18.0); - // `base` is a projected entity endpoint: clip before - // stroking (see `clip_seg`). - if let Some((a, b)) = clip_seg(base, tip, bounds) { - frame.stroke(&canvas::Path::line(a, b), dash); - } - } - // Three dots at the snap point, strung along the extension - // direction (horizontal fallback when the base is unknown). - let u = dir.map(|(_, u)| u).unwrap_or(Point::new(1.0, 0.0)); + // Only the three-dot marker is scaled. The actual + // guide and diagnostic dot are drawn in canvas space. + let dir = extension_ray; + // Three dots stay aligned with the *same* acquired ray + // used by the dashed guide (rather than cursor jitter). + let u = dir.map(|(_, u, _)| u).unwrap_or(Point::new(1.0, 0.0)); let r = 1.4_f32; for k in [-7.0_f32, 0.0, 7.0] { let dot =