Fix #2120: Smart Guides keep a dragged end of a straight segment on its extension at any angle

This commit is contained in:
Marco Arnulfo
2026-10-16 14:58:08 +07:00
committed by bflatastic
parent b9069e761e
commit 54a8d60dae
4 changed files with 117 additions and 8 deletions
+23
View File
@@ -1528,6 +1528,29 @@ mod tests {
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0}));
}
/// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction
/// (Line Extension) and the guide runs along it from the other end.
#[test]
fn dragged_end_of_a_slanted_line_stays_on_its_direction() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let p = paint();
let end = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &end, &p);
let mut t = DirectSelectionTool::new(false);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap());
assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}");
let guides: Vec<(Point, Point)> =
t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect();
assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}");
}
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
/// Selection tool does: B's left side lines up with A's right one.
#[test]
+88 -6
View File
@@ -82,6 +82,10 @@ pub struct Targets {
/// The bounds of the objects a moved selection spaces itself evenly with (Spacing Guides):
/// gathered only for moves ([`Self::for_move`]).
boxes: Vec<Rect>,
/// Line Extension: the lines a single dragged anchor keeps to, along the straight segments
/// it ends, as the end that stays put and the unit direction towards the dragged one
/// ([`Self::for_anchor_drag`]).
extensions: Vec<(Point, Vec2)>,
/// How the guides these targets produce look.
style: GuideStyle,
}
@@ -264,10 +268,14 @@ impl Targets {
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
/// the paths being reshaped (their bounds move along, so they are no targets).
/// the paths being reshaped (their bounds move along, so they are no targets). A single anchor
/// also keeps to the extensions of the straight segments it ends (#2120).
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments);
let (mut anchors, mut segments) = (BUDGET, BUDGET);
// With several anchors moving, the one under the pointer is not known here, and the
// others' extensions don't run through it.
let single = sel.objects.len() <= 1 && sel.anchors.values().map(|s| s.len()).sum::<usize>() == 1;
for (id, set) in &sel.anchors {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
@@ -277,11 +285,30 @@ impl Targets {
anchors -= 1;
}
t.push_segments(sp, moving, &mut segments);
if single {
t.push_extensions(sp, moving);
}
}
}
t.index()
}
/// The extensions of the straight segments of `sp` with one end among the anchors `moving`
/// accepts and the other staying put. Horizontal and vertical ones are left out: lining up
/// with the other end already keeps the point on them, and says "align".
fn push_extensions(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool) {
let n = sp.anchors.len();
for seg in (0..sp.segment_count()).filter(|&seg| moving(seg) != moving((seg + 1) % n) && sp.segment_is_line(seg)) {
let c = sp.segment(seg);
let (fixed, dragged) = if moving(seg) { (c.p3, c.p0) } else { (c.p0, c.p3) };
let d = dragged - fixed;
let len = d.hypot();
if len.is_finite() && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len {
self.extensions.push((fixed, d / len));
}
}
}
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
/// most `budget` of them (counted down).
fn push_segments(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool, budget: &mut usize) {
@@ -360,6 +387,7 @@ impl Targets {
self.xs.clear();
self.ys.clear();
self.segments.clear();
self.extensions.clear();
self
}
@@ -446,11 +474,11 @@ impl Targets {
if let Some(q) = self.on_segment(p, tol) {
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
if let Some(f) = from
&& let Some((dir, q)) = f.construction(p, tol)
{
let construction = |q: Point| Overlay::Line { a: f.at, b: q, color: self.style.color, dashed: false };
return match self.slide(f.at, dir, q, tol, false) {
// A construction guide through the point it leaves, else a dragged segment's extension:
// it lands on the guide, sliding into line with a target crossing it nearby.
if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) {
let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false };
return match self.slide(at, dir, q, tol, false) {
Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()),
None => (q, vec![construction(q)]),
};
@@ -485,6 +513,19 @@ impl Targets {
(out, ov)
}
/// The segment extension nearest `p`, within `tol` of it (and `p` farther than that from the
/// end it starts at): that end, its direction and where `p` lands on it.
fn extension(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> {
self.extensions
.iter()
.filter(|(at, _)| at.distance(p) > tol)
.map(|&(at, d)| (at, d, at + d * (p - at).dot(d)))
.map(|(at, d, q)| (at, d, q, q.distance(p)))
.filter(|(.., dist)| *dist <= tol)
.min_by(|a, b| a.3.total_cmp(&b.3))
.map(|(at, d, q, _)| (at, d, q))
}
/// The anchor or centre nearest `p`, within `tol`.
/// Is a target point (an anchor, a centre) within `tol` of `p`?
pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool {
@@ -1382,6 +1423,47 @@ mod tests {
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
}
/// #2120: the dragged end of a straight segment at any angle lands on the segment's own
/// extension (Line Extension), past its other end or short of it, the guide drawn from that
/// end; with two anchors moving there is none to keep to.
#[test]
fn dragged_end_of_a_slanted_line_keeps_to_its_extension() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
// From (300, 400) up to (380, 340): along (4, -3), about 37° up.
d.insert(
Some(l),
1,
Node::path(id, vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)), Appearance::default_art()),
)
.unwrap();
let (mut s, p) = (Selection::default(), paint());
s.set([id]);
s.anchors.insert(id, [(0, 1)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s).styled(&cx(&d, &s, &p));
// 150 along it (2.5 off), and 50 along it (shorter).
for (pointer, on) in [(Point::new(421.5, 312.0), Point::new(420.0, 310.0)), (Point::new(341.5, 372.0), Point::new(340.0, 370.0))] {
let (q, ov) = t.snap_point(pointer, 4.0);
assert!((q - on).hypot() < 1e-9, "{q:?}");
assert_eq!(lines(&ov), vec![(Point::new(300.0, 400.0), q)]);
}
// Two anchors moving, the middle one staying put: no telling which one the pointer drags,
// so neither keeps to the extension of its segment.
let id = d.alloc_id();
let bent = vectorcraft_geom::PathData::single(SubPath::polyline(
&[Point::new(250.0, 450.0), Point::new(300.0, 400.0), Point::new(380.0, 340.0)],
false,
));
d.insert(Some(l), 1, Node::path(id, bent, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, [(0, 0), (0, 2)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s);
assert_eq!(t.snap_point(Point::new(421.5, 312.0), 4.0), (Point::new(421.5, 312.0), vec![]));
}
fn labels(ov: &[Overlay]) -> Vec<&str> {
ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect()
}
+3 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long