Snapping: tangent and perpendicular snapping, View > Snapping (#2530)

Squashed from #2537 onto main.
This commit is contained in:
bflatastic
2026-10-23 03:22:54 +07:00
parent ffeb37f481
commit 8bf7ffe39d
38 changed files with 431 additions and 21 deletions
+3 -3
View File
@@ -376,8 +376,8 @@ impl CurvatureTool {
/// and the segment to it: the path being drawn, or the selected one whose end is current.
fn leave(&self, cx: &ToolContext) -> Option<(NodeId, Leave)> {
match self.active(cx) {
Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false))),
None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false))),
Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false).without_contact())),
None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false).without_contact())),
}
}
@@ -420,7 +420,7 @@ impl CurvatureTool {
return Some(out);
}
if let Some(e) = end {
let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false)));
let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false).without_contact()));
let how = self.how(&e);
self.placed = Some(Placed { id: e.id, si: e.si, start: e.start, anchors: e.anchors + 1, corner: ev.mods.alt });
let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name(), "corner": ev.mods.alt});
+216 -6
View File
@@ -13,8 +13,8 @@ use std::sync::Arc;
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, Shape, SubPath, Vec2, constrain_angle_from};
use vectorcraft_geom::kurbo::{ParamCurveDeriv, ParamCurveNearest};
use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathSeg, Point, Rect, Shape, SubPath, Vec2, constrain_angle_from};
use vectorcraft_text::{FontDb, layout as layout_text};
use crate::bbox::Handle;
@@ -89,6 +89,10 @@ pub struct Targets {
/// it ends, as the end that stays put and the unit direction towards the dragged one
/// ([`Self::for_anchor_drag`]).
extensions: Vec<(Point, Vec2)>,
/// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it
/// grazes a curve, or meets a segment square ([`Self::touching`]).
tangent: bool,
perpendicular: bool,
/// How the guides these targets produce look.
style: GuideStyle,
}
@@ -560,6 +564,11 @@ impl Targets {
if let Some(q) = self.crossing(p, tol) {
return (q, self.label(q, "intersect").into_iter().collect());
}
if let Some(f) = from.filter(|f| f.contact)
&& let Some((q, perpendicular, dir)) = self.touching(f.at, p, tol)
{
return (q, self.touch_guides(f.at, q, perpendicular, dir, tol));
}
if let Some(q) = self.on_segment(p, tol) {
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
@@ -602,6 +611,52 @@ impl Targets {
(out, ov)
}
/// Where a line from `at` through a point near `p` touches a target segment: square to it
/// (`perpendicular`, the foot of the perpendicular from `at`) or just grazing a curve
/// (`tangent`), within `tol` of `p`. The point, whether it is the perpendicular one, and the
/// segment's direction there. Targets gather segments for drawn points and dragged anchors only.
fn touching(&self, at: Point, p: Point, tol: f64) -> Option<(Point, bool, Vec2)> {
if !(self.tangent || self.perpendicular) || ![at.x, at.y, p.x, p.y, tol].iter().all(|v| v.is_finite()) {
return None;
}
let mut best: Option<(f64, Point, bool, Vec2)> = None;
// Every segment in reach is tried: their order is the document's, not the distance.
for (_, seg) in self.segments.iter().filter(|(b, _)| b.inflate(tol, tol).contains(p)) {
let curved = !matches!(seg, PathSeg::Line(_));
for (perpendicular, wanted) in [(true, self.perpendicular && !curved), (false, self.tangent && curved)] {
if !wanted {
continue;
}
for t in touch_params(seg, at, perpendicular) {
let (q, d) = (seg.eval(t), seg_direction(seg, t));
let dist = q.distance(p);
if dist <= tol && q.distance(at) > tol && best.is_none_or(|b| dist < b.0) {
best = Some((dist, q, perpendicular, d));
}
}
}
}
best.map(|(_, q, perpendicular, d)| (q, perpendicular, d))
}
/// The guides of a point landed where the line from `at` touches a segment at `q` running
/// along `dir`: the line itself, a square corner mark for the perpendicular, and the label.
fn touch_guides(&self, at: Point, q: Point, perpendicular: bool, dir: Vec2, tol: f64) -> Vec<Overlay> {
let mut out: Vec<Overlay> = self.line(at, q).into_iter().collect();
let (u, v) = (at - q, dir);
if perpendicular && self.style.lines && u.hypot2() > 1e-18 && v.hypot2() > 1e-18 {
let (u, v) = (u / u.hypot(), v / v.hypot());
let s = tol * 1.5;
let mut mark = BezPath::new();
mark.move_to(q + u * s);
mark.line_to(q + u * s + v * s);
mark.line_to(q + v * s);
out.push(Overlay::Path { path: mark, color: self.style.color, width: 1.0, dashed: false });
}
out.extend(self.label(q, if perpendicular { "perpendicular" } else { "tangent" }));
out
}
/// 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. Where `p` is nearer
/// the horizontal or the vertical through that end there is none: lining up with it ("align")
@@ -1123,6 +1178,53 @@ fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option<Point> {
None
}
/// The direction of `seg` at `t` (not normalised; zero in a cusp).
fn seg_direction(seg: &PathSeg, t: f64) -> Vec2 {
match seg {
PathSeg::Line(l) => l.p1 - l.p0,
PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(),
}
}
/// The parameters in (0, 1) where the line from `at` to `seg` is square to it (`perpendicular`)
/// or grazes it, found by sampling the curve for a change of sign and bisecting.
fn touch_params(seg: &PathSeg, at: Point, perpendicular: bool) -> Vec<f64> {
const STEPS: usize = 24;
let f = |t: f64| {
let (v, d) = (seg.eval(t) - at, seg_direction(seg, t));
if perpendicular { v.dot(d) } else { v.cross(d) }
};
let mut out = vec![];
let mut prev = (0.0, f(0.0));
for i in 1..=STEPS {
let t = i as f64 / STEPS as f64;
let cur = (t, f(t));
if prev.1.is_finite() && cur.1.is_finite() && prev.1 * cur.1 < 0.0 {
let (mut lo, mut hi) = (prev, cur);
for _ in 0..40 {
let mid = 0.5 * (lo.0 + hi.0);
let m = (mid, f(mid));
if lo.1 * m.1 <= 0.0 {
hi = m;
} else {
lo = m;
}
}
let t = 0.5 * (lo.0 + hi.0);
// A jump through a cusp changes sign without crossing zero: the line must really
// run along (or square to) the curve there.
let (v, d) = (seg.eval(t) - at, seg_direction(seg, t));
let scale = v.hypot() * d.hypot();
if scale > 1e-12 && f(t).abs() <= 1e-3 * scale {
out.push(t);
}
}
prev = cur;
}
out
}
/// 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<Overlay>) {
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
@@ -1148,7 +1250,8 @@ impl PointSnap {
None
} else if cx.smart_guides {
// Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812).
let t = targets().styled(cx);
let mut t = targets().styled(cx);
(t.tangent, t.perpendicular) = (cx.snap_tangent, cx.snap_perpendicular);
Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance))
} else if cx.snap_to_point {
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
@@ -1247,6 +1350,8 @@ pub struct Leave {
shift: Option<Keep>,
/// The angles of the construction guides through `at` (none with them off).
angles: &'static [f64],
/// A drawn segment: it may land square to a segment or grazing a curve (a box does not).
contact: bool,
}
impl Leave {
@@ -1257,14 +1362,21 @@ impl Leave {
at,
shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }),
angles: if cx.smart_guides { cx.construction_angles } else { &[] },
contact: true,
}
}
/// A curved source cannot make a line-to-line perpendicular or line-to-curve tangent contact.
pub fn without_contact(mut self) -> Self {
self.contact = false;
self
}
/// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, and
/// Artboard, which passes no Shift): Shift keeps the dragged corner on a diagonal, the box a
/// square.
pub fn diagonal(at: Point, shift: bool) -> Self {
Self { at, shift: shift.then_some(Keep::Square), angles: &[] }
Self { at, shift: shift.then_some(Keep::Square), angles: &[], contact: false }
}
/// Where Shift puts `p`: none without it.
@@ -1324,7 +1436,7 @@ struct SnapKey {
revision: (u64, u64),
exclude: Vec<NodeId>,
screen: Option<ScreenFrame>,
view: [bool; 5],
view: [bool; 7],
snap_to_glyph: bool,
style: GuideStyle,
tolerances: [f64; 2],
@@ -1336,7 +1448,7 @@ impl SnapKey {
revision: cx.revision,
exclude: exclude.to_vec(),
screen: cx.screen,
view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides],
view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular],
snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides,
style: GuideStyle::of(cx),
tolerances: [cx.snapping_tolerance, cx.snap_tolerance],
@@ -1868,6 +1980,104 @@ mod tests {
assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]);
}
#[test]
fn perpendicular_snapping_rejects_a_curved_target() {
let (mut d, _) = doc_with_rect();
let layer = d.layers[0].id;
let id = d.alloc_id();
d.insert(
Some(layer),
1,
vectorcraft_doc::Node::path(
id,
vectorcraft_geom::shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)),
vectorcraft_doc::Appearance::default_art(),
),
)
.unwrap();
let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) };
assert!(targets.touching(Point::new(400.0, 500.0), Point::new(450.0, 500.0), 4.0).is_none());
assert!(targets.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), 4.0).is_some());
}
/// A line drawn from an anchor lands square to a segment (the foot of its perpendicular) and
/// just grazing a curve, with a guide and a label; the options switch each off, and the
/// result is the same each time and survives non-finite input.
#[test]
fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() {
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 1, Node::path(id, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let snap = |c: &ToolContext, from: Point, to: Point| {
let leave = Leave::segment(c, from, false);
PointSnap::new(c, || Targets::for_drawing(&d, &[], None, false)).snap_from(c, to, Some(&leave))
};
// From (300, 160) the rect's right side (x = 200) is square at (200, 160).
let (q, ov) = snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0));
assert!((q - Point::new(200.0, 160.0)).hypot() < 1e-6, "{q:?}");
assert_eq!(labels(&ov), ["perpendicular"]);
assert_eq!(lines(&ov), vec![(Point::new(300.0, 160.0), q)]);
assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "the square corner mark: {ov:?}");
assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)).0, q, "deterministic");
// Off, the point only lands on the path, where the pointer is.
let curved = Leave::segment(&c, Point::new(300.0, 160.0), false).without_contact();
let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, false)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved));
assert!(!labels(&ov).contains(&"perpendicular"));
let off = ToolContext { snap_perpendicular: false, ..cx(&d, &s, &p) };
let (q, ov) = snap(&off, Point::new(300.0, 160.0), Point::new(202.0, 163.0));
assert_eq!((q, labels(&ov)), (Point::new(200.0, 163.0), vec!["path"]));
// The circle (centre (500, 500), radius 50) seen from (400, 500): the tangent points are 60°
// round from the way back, at (475, 456.7) and (475, 543.3).
let (q, ov) = snap(&c, Point::new(400.0, 500.0), Point::new(477.0, 459.0));
assert_eq!(labels(&ov), ["tangent"]);
assert!((q - Point::new(475.0, 500.0 - 1875f64.sqrt())).hypot() < 0.1, "{q:?}");
assert!(((q - Point::new(400.0, 500.0)).dot(q - Point::new(500.0, 500.0))).abs() < 20.0, "{q:?}");
let none = ToolContext { snap_tangent: false, ..cx(&d, &s, &p) };
assert!(!labels(&snap(&none, Point::new(400.0, 500.0), Point::new(477.0, 459.0)).1).contains(&"tangent"));
// Out of reach, or with nothing finite to go on: left alone, no panic.
assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(250.0, 175.0)).0, Point::new(250.0, 175.0));
let t = Targets::for_drawing(&d, &[], None, false);
let t = Targets { tangent: true, perpendicular: true, ..t };
for bad in [f64::NAN, f64::INFINITY] {
assert!(t.touching(Point::new(bad, 1.0), Point::new(200.0, 160.0), 4.0).is_none());
assert!(t.touching(Point::new(300.0, 160.0), Point::new(bad, 1.0), 4.0).is_none());
assert!(t.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), bad).is_none());
}
}
/// A box dragged from a corner (Rectangle, Ellipse, ...) never snaps square to a segment, and
/// a busy spot does not hide the contact: every segment in reach is tried.
#[test]
fn contact_snaps_only_for_segments_and_in_busy_spots() {
use vectorcraft_geom::kurbo::Line;
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let snap = |t: &Targets, leave: &Leave| t.snap_from(Point::new(202.0, 163.0), Some(leave), 4.0);
let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) };
let (_, ov) = snap(&t, &Leave::segment(&c, Point::new(300.0, 160.0), false));
assert_eq!(labels(&ov), ["perpendicular"]);
let (_, ov) = snap(&t, &Leave::diagonal(Point::new(300.0, 160.0), false));
assert!(!labels(&ov).contains(&"perpendicular"), "{ov:?}");
// 70 short segments near the pointer, none square to the leave point, then the rect's side.
let mut busy = t;
let mut segments: Vec<(Rect, PathSeg)> = (0..70)
.map(|i| {
let y = 160.0 + f64::from(i) * 0.01;
(Rect::new(199.0, y, 205.0, y + 0.001), PathSeg::Line(Line::new((199.0, y), (205.0, y + 0.001))))
})
.collect();
segments.extend(busy.segments.iter().cloned());
busy.segments = segments;
let (_, ov) = snap(&busy, &Leave::segment(&c, Point::new(300.0, 160.0), false));
assert_eq!(labels(&ov), ["perpendicular"]);
}
/// The angle sets of Preferences › Smart Guides › Construction Guides.
#[test]
fn construction_angle_sets() {
+7
View File
@@ -252,6 +252,11 @@ pub struct ToolContext<'a> {
/// points (a transform's reference point) land on anchors and ruler guides within
/// [`Self::snap_tolerance`].
pub snap_to_point: bool,
/// View → Snapping → Snap to Tangent: with Smart Guides on, a line drawn
/// from an anchor lands where it touches a nearby curve.
pub snap_tangent: bool,
/// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square.
pub snap_perpendicular: bool,
/// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides).
pub snap_to_glyph: bool,
/// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners.
@@ -1003,6 +1008,8 @@ pub(crate) mod testutil {
show_bbox: true,
snap_to_pixel: false,
snap_to_point: true,
snap_tangent: true,
snap_perpendicular: true,
snap_to_glyph: false,
corner_widgets: true,
fill_active: true,
+4 -1
View File
@@ -190,7 +190,10 @@ impl Tool for PenTool {
}
// The path being drawn moves its bounds as it grows: only its anchors and segments pull.
let drawn = active.map(|a| a.0);
let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift));
let from = active.map(|(_, _, last, handle)| {
let leave = Leave::segment(cx, last, ev.mods.shift);
if handle == last { leave } else { leave.without_contact() }
});
let p = match ev.kind {
PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()),
PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()),