Merge pull request #1853 from gianlucafiore/fix/extension-guide-visual-anchor

fix(osnap): keep Extension guides anchored when moving the cursor
This commit is contained in:
gianlucafiore authored and GitHub committed 2026-10-16 16:16:29 -03:00
commit 590ca17f94
3 files changed
+239 -85

No files matched your search

+56 -18
View File
@@ -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;
+62 -8
View File
@@ -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<iced::Point> = 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<crate::ui::overlay::OstTrackPoint> =
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,
+121 -59
View File
@@ -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<Point>,
) -> 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<Point>,
snap_ext_base2: Option<Point>,
snap_ext_dir: Option<Point>,
grips: Vec<GripMarker>,
control_polygon: Option<(Vec<Point>, bool)>,
grip_clip: Option<iced::Rectangle>,
@@ -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<Point>,
snap_ext_dir: Option<Point>,
grips: Vec<GripMarker>,
/// Selected spline control polygon and closed state.
control_polygon: Option<(Vec<Point>, bool)>,
@@ -1704,6 +1763,60 @@ impl canvas::Program<Message> 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<Message> 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<Message> 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 =