From dae13d57f0c22383fda0ec49e751c2452bb0e37f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 11:42:59 +0700 Subject: [PATCH] Fix #1602: drawn points snap to where paths and guides intersect A point placed by the Pen, the shape tools and the other drawing tools, and a dragged anchor, lands where two paths cross, where a path crosses a ruler guide and where two ruler guides cross, with the Smart Guides label "intersect". --- crates/tools/src/guides.rs | 91 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 3e7aa338..45145609 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -440,6 +440,9 @@ impl Targets { if let Some((q, k)) = self.nearest_point(p, tol) { return (q, self.label(q, k.label()).into_iter().collect()); } + if let Some(q) = self.crossing(p, tol) { + return (q, self.label(q, "intersect").into_iter().collect()); + } if let Some(q) = self.on_segment(p, tol) { return (q, self.label(q, Kind::Edge.label()).into_iter().collect()); } @@ -545,6 +548,43 @@ impl Targets { .map(|(c, n)| c.eval(n.t)) } + /// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing + /// nearest `p` within `tol` ("intersect", #1602). + fn crossing(&self, p: Point, tol: f64) -> Option { + // A ruler guide running past, as a short line through the reach of `p`. + let ruler = |r: &Ruler| { + let (pos, ..) = r.line_at(p, tol)?; + let (across, along) = if r.vertical { (p.x, p.y) } else { (p.y, p.x) }; + let (a, b) = (along - tol, along + tol); + ((pos - across).abs() <= tol) + .then(|| PathSeg::Line(if r.vertical { Line::new((pos, a), (pos, b)) } else { Line::new((a, pos), (b, pos)) })) + }; + let near: Vec = self + .segments + .iter() + .filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol) + .map(|(_, c)| *c) + .chain(self.rulers.iter().filter_map(ruler)) + // shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter + .take(32) + .collect(); + let mut best: Option = None; + for (i, a) in near.iter().enumerate() { + for b in near.iter().skip(i + 1) { + let hits: Vec = match (a, b) { + (PathSeg::Line(l), c) | (c, PathSeg::Line(l)) => c.intersect_line(*l).iter().map(|x| l.eval(x.line_t)).collect(), + _ => meeting(a, b, p).into_iter().collect(), + }; + for q in hits { + if q.distance(p) <= tol && best.is_none_or(|b| q.distance(p) < b.distance(p)) { + best = Some(q); + } + } + } + } + best + } + /// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line /// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there. pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec) { @@ -828,6 +868,20 @@ impl Targets { } } +/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back +/// settles where they meet (none when they don't nearby). +fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option { + let mut q = p; + for _ in 0..32 { + let on_a = a.eval(a.nearest(q, 1e-9).t); + q = b.eval(b.nearest(on_a, 1e-9).t); + if on_a.distance(q) <= 1e-6 { + return Some(q); + } + } + None +} + /// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { snap_with(cx, p, || Targets::collect(cx.doc, exclude, None)) @@ -1380,6 +1434,43 @@ mod tests { assert_eq!(q, Point::new(340.0, 340.0)); } + /// #1602: a drawn point lands where two paths cross, where a path crosses a ruler guide and + /// where two ruler guides cross, labelled "intersect"; an anchor nearby still wins. + #[test] + fn drawn_points_land_on_intersections() { + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::shapes; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + // A slanted line through the rect's top side (y = 100) at x = 150, and a circle across its + // right side (x = 200). + for path in [shapes::line(Point::new(145.0, 90.0), Point::new(165.0, 130.0)), shapes::ellipse(Rect::new(175.0, 130.0, 215.0, 170.0))] { + let id = d.alloc_id(); + d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); + } + d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + let (s, p) = (Selection::default(), paint()); + let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; + let t = Targets::for_drawing(&d, &[], None).styled(&c).with_rulers(&c); + let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0); + assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!(labels(&ov), ["intersect"]); + // The circle (curves, centre (195, 150), radius 20) crosses the rect's right side. + let (q, ov) = t.snap_from(Point::new(201.5, 131.5), None, 4.0); + assert!((q - Point::new(200.0, 150.0 - 375f64.sqrt())).hypot() < 0.02, "{q:?}"); + assert_eq!(labels(&ov), ["intersect"]); + // A ruler guide across a path, and two ruler guides. + let (q, ov) = t.snap_from(Point::new(101.0, 151.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(100.0, 150.0), vec!["intersect"])); + let (q, ov) = t.snap_from(Point::new(402.0, 298.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(400.0, 300.0), vec!["intersect"])); + // An anchor in reach wins, and a path alone is still "path". + assert_eq!(labels(&t.snap_from(Point::new(146.0, 91.0), None, 4.0).1), ["anchor"]); + assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]); + } + /// The angle sets of Preferences › Smart Guides › Construction Guides. #[test] fn construction_angle_sets() {