diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 56066ab7e..e05f7dceb 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -1618,622 +1618,4 @@ pub fn distance_guides(cx: &ToolContext, from: Rect, to: Rect, artboard: bool) - } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - - #[test] - fn artboard_bleed_is_a_target() { - let (mut d, _) = doc_with_rect(); - let t = Targets::collect(&d, &[], None); - assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near"); - d.setup.bleed = [10.0; 4]; - let t = Targets::collect(&d, &[], None); - assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0); - } - - #[test] - fn snaps_to_anchor_and_alignment() { - let (d, _) = doc_with_rect(); - let t = Targets::collect(&d, &[], None); - let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!(p, Point::new(100.0, 100.0)); - assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor")); - let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(p.y, 200.0); - } - - #[test] - fn rect_snap_aligns_edges() { - let (d, id) = doc_with_rect(); - let t = Targets::collect(&d, &[id], None); - // Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge. - let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); - assert_eq!(dv.x, -2.0); - assert!(!ov.is_empty()); - } - - #[test] - fn scale_snap_lands_the_moved_edges_on_targets() { - use crate::bbox::scale_for_drag; - let (d, _) = doc_with_rect(); - // The document's rect is 100..200: a box beside it is resized against it. - let t = Targets::collect(&d, &[], None); - let r = Rect::new(250.0, 100.0, 300.0, 150.0); - let snap = |h: Handle, p: Point, shift: bool, alt: bool| { - let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); - (a.transform_rect_bbox(r), ov) - }; - // The bottom edge lands on the neighbour's bottom: same height. - let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(); - let align = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); - assert_eq!(lines(&ov), 1); - assert!(align(&ov), "{ov:?}"); - // A corner snaps each axis on its own: y to the neighbour, x stays free. - let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); - assert_eq!(lines(&ov), 1); - // Proportional: the snapped axis carries the other one. - let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); - // From the centre the dragged edge still lands on the target. - let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); - assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); - // Out of reach, and the fixed edge's own target: untouched. - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); - assert!(ov.is_empty()); - let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); - assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); - assert!(ov.is_empty()); - } - - /// A resize handle lands on another object's anchor point (#482), not only in line with its - /// edges and centre, and says so; a side handle stops where its way passes the anchor. - #[test] - fn scale_snap_lands_the_handle_on_anchors() { - use crate::bbox::scale_for_drag; - use vectorcraft_doc::{Appearance, Node}; - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - // A triangle whose apex (320, 400) lines up with none of the edges or centres on x. - let tri = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], - true, - )); - d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap(); - let t = Targets::collect(&d, &[], None); - let snap = |r: Rect, h: Handle, p: Point, shift: bool| { - let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, false), shift, false, 4.0); - (a.transform_rect_bbox(r), ov) - }; - let anchor = |ov: &[Overlay]| matches!(ov, [Overlay::Label { text, p, .. }] if text == "anchor" && *p == Point::new(320.0, 400.0)); - // A free corner takes the anchor's x and y. - let (nr, ov) = snap(Rect::new(250.0, 350.0, 300.0, 390.0), Handle::BottomRight, Point::new(322.0, 398.0), false); - assert_eq!(nr, Rect::new(250.0, 350.0, 320.0, 400.0)); - assert!(anchor(&ov), "{ov:?}"); - // The right side, at the apex's height: its x lands on the anchor. - let (nr, ov) = snap(Rect::new(250.0, 380.0, 300.0, 420.0), Handle::Right, Point::new(318.0, 401.0), false); - assert_eq!(nr, Rect::new(250.0, 380.0, 320.0, 420.0)); - assert!(anchor(&ov), "{ov:?}"); - // A proportional corner slides along its diagonal onto it. - let (nr, ov) = snap(Rect::new(240.0, 320.0, 300.0, 380.0), Handle::BottomRight, Point::new(318.0, 398.0), true); - assert_eq!(nr, Rect::new(240.0, 320.0, 320.0, 400.0)); - assert!(anchor(&ov), "{ov:?}"); - } - - /// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as - /// two other objects of its row are apart, or halfway between its two neighbours, and the - /// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged. - #[test] - fn spacing_guides_space_a_moved_selection_evenly() { - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - // A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440. - for r in [ - Rect::new(20.0, 300.0, 60.0, 340.0), - Rect::new(80.0, 300.0, 120.0, 340.0), - Rect::new(400.0, 20.0, 440.0, 50.0), - Rect::new(400.0, 80.0, 440.0, 110.0), - ] { - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap(); - } - let (s, p) = (Selection::default(), paint()); - let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); - let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); - // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). - let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); - assert_eq!(dv, Vec2::new(-1.0, 0.0)); - assert_eq!(lines(&ov), 6, "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}"); - // Halfway between B and a box at 220–260: 50 either side. - let mut wide = d.clone(); - let id = wide.alloc_id(); - wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) - .unwrap(); - let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); - assert_eq!(dv, Vec2::new(2.0, 0.0)); - assert!(lines(&ov) >= 6, "{ov:?}"); - // A column: 28 under D, as C and D are 30 apart. - let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0); - assert_eq!(dv, Vec2::new(0.0, 2.0)); - assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}"); - // Above C, 32 over it: 30. - let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0); - assert_eq!(dv, Vec2::new(0.0, 2.0)); - // Off: nowhere to go, and no marks. - let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; - let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); - assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); - } - - /// Smart Guides › Spacing Guides (#394): resizing by a side handle snaps the moving edge to the - /// same gap as the neighbours in its row, with the gaps marked. - #[test] - fn spacing_guides_resize_snaps_the_moving_edge_to_equal_gaps() { - use crate::bbox::scale_for_drag; - use vectorcraft_doc::{Appearance, Node}; - let mut d = Document::new(500.0, 500.0); - let l = d.layers[0].id; - for r in [Rect::new(20.0, 300.0, 60.0, 340.0), Rect::new(80.0, 300.0, 120.0, 340.0)] { - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - } - let r = Rect::new(141.0, 305.0, 181.0, 345.0); - let id = d.alloc_id(); - d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); - let s = Selection { objects: vec![id], ..Default::default() }; - let t = Targets::for_scale(&cx(&d, &s, &paint())); - let (a, ov) = t.snap_scale( - &OrientedBox::aligned(r), - Handle::Left, - scale_for_drag(r, Handle::Left, Point::new(139.0, 325.0), false, false), - false, - false, - 4.0, - ); - assert_eq!(a.transform_rect_bbox(r).x0, 140.0, "20 pt from the second square"); - let magenta = |o: &Overlay| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA); - assert!(ov.iter().any(magenta), "{ov:?}"); - } - - /// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and - /// Anchor/Path Labels off, change what shows and never where a point lands. - #[test] - fn display_options_colour_and_hide_the_guides_but_keep_the_snap() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let green = [0x00, 0xff, 0x00]; - let c = ToolContext { smart_guide_color: green, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!(q, Point::new(100.0, 100.0)); - assert!(matches!(&ov[0], Overlay::Label { text, color, .. } if text == "anchor" && *color == green), "{ov:?}"); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}"); - assert!( - ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) - ); - // Alignment Guides off: the point still lines up and says so, without the line. - let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(!ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "{ov:?}"); - assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}"); - let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); - assert_eq!((dv.x, ov.len()), (-2.0, 0)); - // A handle already in line moves nowhere, yet is still "align". - let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0); - assert_eq!(q, Point::new(400.0, 200.0)); - assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}"); - // Anchor/Path Labels off: on the anchor, without saying so. - let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) }; - let t = Targets::collect(&d, &[], None).styled(&c); - let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); - assert_eq!((q, ov.len()), (Point::new(100.0, 100.0), 0)); - let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); - assert_eq!(q.y, 200.0); - assert!(ov.iter().all(|o| matches!(o, Overlay::Line { .. })) && !ov.is_empty(), "{ov:?}"); - assert_eq!(t.snap_guide(false, 199.0, 4.0), (200.0, vec![])); - } - - /// Dragging an anchor of a path: its other anchors and the segments not touching it stay - /// targets, but neither it nor the path's bounds (which move) are. - #[test] - fn anchor_drag_targets_leave_out_what_moves() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, [(0, 0)].into_iter().collect()); - let t = Targets::for_anchor_drag(&d, &s); - let anchors: Vec = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect(); - assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x"); - assert_eq!(t.segments.len(), 2); - assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves"); - 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"); - } - - /// A Shaper Group's original shapes, which are not shown, are no targets: not their - /// anchors, edges or segments, not their bounds as an object a moved face spaces itself evenly - /// with, and not the anchors a transform tool's reference point snaps to on the selected group. - /// Selected, or under the pointer in Construction mode, an original shape still gives its anchors. - #[test] - fn shaper_sources_are_no_targets() { - let (d, g, sources, face) = doc_with_shaper_group(); - let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; - // The top right corner of the second source square, which no shown art has. - let corner = Point::new(150.0, 300.0); - let gives_corner = |t: &Targets| { - t.points.iter().any(|(p, _)| *p == corner) - || t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == corner) - || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) - }; - assert!(!gives_corner(&Targets::collect(&d, &[], None))); - assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); - assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); - assert!(!gives_corner(&Targets::for_handle(&d, face))); - assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); - let near = Rect::new(146.0, 296.0, 154.0, 304.0); - assert!(!gives_corner(&Targets::points_near(&d, &[g], near))); - assert!(gives_corner(&Targets::points_near(&d, &[b], near))); - } - - /// #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![])); - } - - /// A segment nearly level (0.3°) is still straightened by dragging its end into line with - /// the other one; along its own direction, farther from level, it keeps to its extension. - #[test] - fn a_nearly_level_segment_still_lines_up_with_its_other_end() { - use vectorcraft_doc::{Appearance, Node}; - 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, vectorcraft_geom::shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 301.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)); - let (q, ov) = t.snap_point(Point::new(300.0, 300.0), 4.0); - assert_eq!((q, labels(&ov)), (Point::new(300.0, 300.0), vec!["align"])); - let (q, ov) = t.snap_point(Point::new(400.0, 301.5), 4.0); - assert!((q - Point::new(400.0, 301.5)).hypot() < 1e-9, "{q:?}"); - assert_eq!(lines(&ov), vec![(Point::new(100.0, 300.0), q)]); - } - - fn labels(ov: &[Overlay]) -> Vec<&str> { - ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect() - } - - fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> { - ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect() - } - - /// A drawn point (#506): on another object's anchors, in line with them too (not only with its - /// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction - /// guide through the point it leaves from it lands on the guide, where a target's line crosses - /// it if one does nearby. - #[test] - fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() { - let (d, _) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let t = Targets::for_drawing(&d, &[], None, false).styled(&c); - // The rect is 100..200: its corner, a point on its side, and in line with an anchor. - let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); - let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"])); - let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0); - assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"])); - // Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200. - let shift = Leave::segment(&c, Point::new(300.0, 300.0), true); - let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0); - assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}"); - assert_eq!(labels(&ov), ["align"]); - assert_eq!(lines(&ov).len(), 1, "{ov:?}"); - // Nothing in reach: on its angle, as far as the pointer. - let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0); - assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0)); - // Construction guides through (300, 320): onto the 135° one… - let from = Leave::segment(&c, Point::new(300.0, 320.0), false); - let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0); - assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}"); - assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]); - // …where x = 200 crosses it. - let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0); - assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}"); - assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"])); - // Construction Guides off (or Smart Guides off): in line, not on a guide. - let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) }; - let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0); - assert_eq!(q, Point::new(352.0, 373.0)); - // A box's corner keeps to a diagonal with Shift, the farther axis giving its size. - let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0); - 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, false).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"]); - } - - #[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() { - assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]); - assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]); - assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]); - assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8); - assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES)); - } - - /// The sorted targets find by bisection just what a scan of all of them finds, whatever - /// gathered them. - #[test] - fn bisection_finds_what_a_scan_finds() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - for i in 0..60 { - let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64); - let tri = vectorcraft_geom::PathData::single(SubPath::polyline( - &[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)], - true, - )); - let n = d.alloc_id(); - d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap(); - } - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, [(0, 1)].into_iter().collect()); - for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { - for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { - let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); - assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); - for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] { - let mut near: Vec = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect(); - let mut all: Vec = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect(); - near.sort_by(f64::total_cmp); - all.sort_by(f64::total_cmp); - assert_eq!(near, all); - } - } - } - } - - /// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal - /// and vertical alike, also while it slides along a construction guide or Shift's angle. - #[test] - fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { - let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut snap = DrawSnap::default(); - assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide"); - assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide"); - // On the 45° construction guide from (210, 210), where the horizontal guide crosses it. - let from = Leave::segment(&c, Point::new(210.0, 210.0), false); - let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from)); - assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}"); - // Snap to Point off: Smart Guides alone don't use the ruler guides. - let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; - assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0)); - } - - /// A drawing tool's snapper reuses its targets while hovering over the same document state, and - /// gathers them again when it changes, and on every press. - #[test] - fn draw_snap_gathers_once_per_document_state() { - let (d, _) = doc_with_rect(); - let empty = vectorcraft_doc::Document::new(500.0, 500.0); - let (s, p) = (Selection::default(), paint()); - let at = Point::new(202.0, 198.0); - let mut snap = DrawSnap::default(); - let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); - assert_eq!(labels(snap.guides()), ["anchor"]); - // The same revision: the targets are the ones gathered (the document isn't read again). - let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); - // A new revision, or a press, reads it. - let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) }; - assert_eq!(snap.hover(&c, at, &[], None), at); - assert!(snap.guides().is_empty()); - let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; - assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0)); - // The drag keeps what the press gathered, though the document now shows its preview. - let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) }; - assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0)); - snap.clear(); - assert!(snap.guides().is_empty()); - // Snap to Grid wins over Smart Guides, Shift still keeping the angle. - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let from = Leave::segment(&c, Point::ZERO, true); - let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); - assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); - } -} +mod tests; diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs new file mode 100644 index 000000000..9ed34c4fc --- /dev/null +++ b/crates/tools/src/guides/tests.rs @@ -0,0 +1,617 @@ +use super::*; +use crate::testutil::*; + +#[test] +fn artboard_bleed_is_a_target() { + let (mut d, _) = doc_with_rect(); + let t = Targets::collect(&d, &[], None); + assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near"); + d.setup.bleed = [10.0; 4]; + let t = Targets::collect(&d, &[], None); + assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0); +} + +#[test] +fn snaps_to_anchor_and_alignment() { + let (d, _) = doc_with_rect(); + let t = Targets::collect(&d, &[], None); + let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!(p, Point::new(100.0, 100.0)); + assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor")); + let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(p.y, 200.0); +} + +#[test] +fn rect_snap_aligns_edges() { + let (d, id) = doc_with_rect(); + let t = Targets::collect(&d, &[id], None); + // Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge. + let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); + assert_eq!(dv.x, -2.0); + assert!(!ov.is_empty()); +} + +#[test] +fn scale_snap_lands_the_moved_edges_on_targets() { + use crate::bbox::scale_for_drag; + let (d, _) = doc_with_rect(); + // The document's rect is 100..200: a box beside it is resized against it. + let t = Targets::collect(&d, &[], None); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + let snap = |h: Handle, p: Point, shift: bool, alt: bool| { + let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); + (a.transform_rect_bbox(r), ov) + }; + // The bottom edge lands on the neighbour's bottom: same height. + let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { .. })).count(); + let align = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); + assert_eq!(lines(&ov), 1); + assert!(align(&ov), "{ov:?}"); + // A corner snaps each axis on its own: y to the neighbour, x stays free. + let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); + assert_eq!(lines(&ov), 1); + // Proportional: the snapped axis carries the other one. + let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); + // From the centre the dragged edge still lands on the target. + let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); + assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); + // Out of reach, and the fixed edge's own target: untouched. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); + assert!(ov.is_empty()); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); + assert!(ov.is_empty()); +} + +/// A resize handle lands on another object's anchor point (#482), not only in line with its +/// edges and centre, and says so; a side handle stops where its way passes the anchor. +#[test] +fn scale_snap_lands_the_handle_on_anchors() { + use crate::bbox::scale_for_drag; + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + // A triangle whose apex (320, 400) lines up with none of the edges or centres on x. + let tri = vectorcraft_geom::PathData::single(SubPath::polyline( + &[Point::new(300.0, 300.0), Point::new(380.0, 300.0), Point::new(320.0, 400.0)], + true, + )); + d.insert(Some(l), 1, Node::path(id, tri, Appearance::default_art())).unwrap(); + let t = Targets::collect(&d, &[], None); + let snap = |r: Rect, h: Handle, p: Point, shift: bool| { + let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, false), shift, false, 4.0); + (a.transform_rect_bbox(r), ov) + }; + let anchor = |ov: &[Overlay]| matches!(ov, [Overlay::Label { text, p, .. }] if text == "anchor" && *p == Point::new(320.0, 400.0)); + // A free corner takes the anchor's x and y. + let (nr, ov) = snap(Rect::new(250.0, 350.0, 300.0, 390.0), Handle::BottomRight, Point::new(322.0, 398.0), false); + assert_eq!(nr, Rect::new(250.0, 350.0, 320.0, 400.0)); + assert!(anchor(&ov), "{ov:?}"); + // The right side, at the apex's height: its x lands on the anchor. + let (nr, ov) = snap(Rect::new(250.0, 380.0, 300.0, 420.0), Handle::Right, Point::new(318.0, 401.0), false); + assert_eq!(nr, Rect::new(250.0, 380.0, 320.0, 420.0)); + assert!(anchor(&ov), "{ov:?}"); + // A proportional corner slides along its diagonal onto it. + let (nr, ov) = snap(Rect::new(240.0, 320.0, 300.0, 380.0), Handle::BottomRight, Point::new(318.0, 398.0), true); + assert_eq!(nr, Rect::new(240.0, 320.0, 320.0, 400.0)); + assert!(anchor(&ov), "{ov:?}"); +} + +/// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as +/// two other objects of its row are apart, or halfway between its two neighbours, and the +/// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged. +#[test] +fn spacing_guides_space_a_moved_selection_evenly() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + // A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440. + for r in [ + Rect::new(20.0, 300.0, 60.0, 340.0), + Rect::new(80.0, 300.0, 120.0, 340.0), + Rect::new(400.0, 20.0, 440.0, 50.0), + Rect::new(400.0, 80.0, 440.0, 110.0), + ] { + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap(); + } + let (s, p) = (Selection::default(), paint()); + let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); + let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); + // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). + let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + assert_eq!(dv, Vec2::new(-1.0, 0.0)); + assert_eq!(lines(&ov), 6, "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}"); + // Halfway between B and a box at 220–260: 50 either side. + let mut wide = d.clone(); + let id = wide.alloc_id(); + wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) + .unwrap(); + let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); + assert_eq!(dv, Vec2::new(2.0, 0.0)); + assert!(lines(&ov) >= 6, "{ov:?}"); + // A column: 28 under D, as C and D are 30 apart. + let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0); + assert_eq!(dv, Vec2::new(0.0, 2.0)); + assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}"); + // Above C, 32 over it: 30. + let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0); + assert_eq!(dv, Vec2::new(0.0, 2.0)); + // Off: nowhere to go, and no marks. + let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; + let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); +} + +/// Smart Guides › Spacing Guides (#394): resizing by a side handle snaps the moving edge to the +/// same gap as the neighbours in its row, with the gaps marked. +#[test] +fn spacing_guides_resize_snaps_the_moving_edge_to_equal_gaps() { + use crate::bbox::scale_for_drag; + use vectorcraft_doc::{Appearance, Node}; + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + for r in [Rect::new(20.0, 300.0, 60.0, 340.0), Rect::new(80.0, 300.0, 120.0, 340.0)] { + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + } + let r = Rect::new(141.0, 305.0, 181.0, 345.0); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let s = Selection { objects: vec![id], ..Default::default() }; + let t = Targets::for_scale(&cx(&d, &s, &paint())); + let (a, ov) = t.snap_scale( + &OrientedBox::aligned(r), + Handle::Left, + scale_for_drag(r, Handle::Left, Point::new(139.0, 325.0), false, false), + false, + false, + 4.0, + ); + assert_eq!(a.transform_rect_bbox(r).x0, 140.0, "20 pt from the second square"); + let magenta = |o: &Overlay| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA); + assert!(ov.iter().any(magenta), "{ov:?}"); +} + +/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and +/// Anchor/Path Labels off, change what shows and never where a point lands. +#[test] +fn display_options_colour_and_hide_the_guides_but_keep_the_snap() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let green = [0x00, 0xff, 0x00]; + let c = ToolContext { smart_guide_color: green, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!(q, Point::new(100.0, 100.0)); + assert!(matches!(&ov[0], Overlay::Label { text, color, .. } if text == "anchor" && *color == green), "{ov:?}"); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(ov.iter().all(|o| matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color == green)), "{ov:?}"); + assert!( + ov.iter().any(|o| matches!(o, Overlay::Line { .. })) && ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")) + ); + // Alignment Guides off: the point still lines up and says so, without the line. + let c = ToolContext { alignment_guides: false, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(!ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "{ov:?}"); + assert!(ov.iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")), "{ov:?}"); + let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); + assert_eq!((dv.x, ov.len()), (-2.0, 0)); + // A handle already in line moves nowhere, yet is still "align". + let (q, ov) = t.snap_point_with(Point::new(400.0, 200.0), &[], 4.0); + assert_eq!(q, Point::new(400.0, 200.0)); + assert!(matches!(&ov[..], [Overlay::Label { text, .. }] if text == "align"), "{ov:?}"); + // Anchor/Path Labels off: on the anchor, without saying so. + let c = ToolContext { anchor_path_labels: false, ..cx(&d, &s, &p) }; + let t = Targets::collect(&d, &[], None).styled(&c); + let (q, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!((q, ov.len()), (Point::new(100.0, 100.0), 0)); + let (q, ov) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(q.y, 200.0); + assert!(ov.iter().all(|o| matches!(o, Overlay::Line { .. })) && !ov.is_empty(), "{ov:?}"); + assert_eq!(t.snap_guide(false, 199.0, 4.0), (200.0, vec![])); +} + +/// Dragging an anchor of a path: its other anchors and the segments not touching it stay +/// targets, but neither it nor the path's bounds (which move) are. +#[test] +fn anchor_drag_targets_leave_out_what_moves() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, [(0, 0)].into_iter().collect()); + let t = Targets::for_anchor_drag(&d, &s); + let anchors: Vec = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect(); + assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x"); + assert_eq!(t.segments.len(), 2); + assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves"); + 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"); +} + +/// A Shaper Group's original shapes, which are not shown, are no targets: not their +/// anchors, edges or segments, not their bounds as an object a moved face spaces itself evenly +/// with, and not the anchors a transform tool's reference point snaps to on the selected group. +/// Selected, or under the pointer in Construction mode, an original shape still gives its anchors. +#[test] +fn shaper_sources_are_no_targets() { + let (d, g, sources, face) = doc_with_shaper_group(); + let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id; + // The top right corner of the second source square, which no shown art has. + let corner = Point::new(150.0, 300.0); + let gives_corner = |t: &Targets| { + t.points.iter().any(|(p, _)| *p == corner) + || t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == corner) + || t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner) + }; + assert!(!gives_corner(&Targets::collect(&d, &[], None))); + assert!(!gives_corner(&Targets::for_drawing(&d, &[], None, false))); + assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default()))); + assert!(!gives_corner(&Targets::for_handle(&d, face))); + assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds"); + let near = Rect::new(146.0, 296.0, 154.0, 304.0); + assert!(!gives_corner(&Targets::points_near(&d, &[g], near))); + assert!(gives_corner(&Targets::points_near(&d, &[b], near))); +} + +/// #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![])); +} + +/// A segment nearly level (0.3°) is still straightened by dragging its end into line with +/// the other one; along its own direction, farther from level, it keeps to its extension. +#[test] +fn a_nearly_level_segment_still_lines_up_with_its_other_end() { + use vectorcraft_doc::{Appearance, Node}; + 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, vectorcraft_geom::shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 301.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)); + let (q, ov) = t.snap_point(Point::new(300.0, 300.0), 4.0); + assert_eq!((q, labels(&ov)), (Point::new(300.0, 300.0), vec!["align"])); + let (q, ov) = t.snap_point(Point::new(400.0, 301.5), 4.0); + assert!((q - Point::new(400.0, 301.5)).hypot() < 1e-9, "{q:?}"); + assert_eq!(lines(&ov), vec![(Point::new(100.0, 300.0), q)]); +} + +fn labels(ov: &[Overlay]) -> Vec<&str> { + ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect() +} + +fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> { + ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect() +} + +/// A drawn point (#506): on another object's anchors, in line with them too (not only with its +/// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction +/// guide through the point it leaves from it lands on the guide, where a target's line crosses +/// it if one does nearby. +#[test] +fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let t = Targets::for_drawing(&d, &[], None, false).styled(&c); + // The rect is 100..200: its corner, a point on its side, and in line with an anchor. + let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"])); + let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"])); + let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0); + assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"])); + // Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200. + let shift = Leave::segment(&c, Point::new(300.0, 300.0), true); + let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0); + assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!(labels(&ov), ["align"]); + assert_eq!(lines(&ov).len(), 1, "{ov:?}"); + // Nothing in reach: on its angle, as far as the pointer. + let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0); + assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0)); + // Construction guides through (300, 320): onto the 135° one… + let from = Leave::segment(&c, Point::new(300.0, 320.0), false); + let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0); + assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}"); + assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]); + // …where x = 200 crosses it. + let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0); + assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}"); + assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"])); + // Construction Guides off (or Smart Guides off): in line, not on a guide. + let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) }; + let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0); + assert_eq!(q, Point::new(352.0, 373.0)); + // A box's corner keeps to a diagonal with Shift, the farther axis giving its size. + let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0); + 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, false).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"]); +} + +#[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() { + assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]); + assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]); + assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]); + assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8); + assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES)); +} + +/// The sorted targets find by bisection just what a scan of all of them finds, whatever +/// gathered them. +#[test] +fn bisection_finds_what_a_scan_finds() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + for i in 0..60 { + let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64); + let tri = vectorcraft_geom::PathData::single(SubPath::polyline( + &[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)], + true, + )); + let n = d.alloc_id(); + d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap(); + } + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, [(0, 1)].into_iter().collect()); + for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None, false), Targets::for_anchor_drag(&d, &s)] { + for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) { + let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q))); + assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}"); + for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] { + let mut near: Vec = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect(); + let mut all: Vec = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect(); + near.sort_by(f64::total_cmp); + all.sort_by(f64::total_cmp); + assert_eq!(near, all); + } + } + } +} + +/// #812: with Smart Guides on, ruler guides pull a drawn point too (Snap to Point), horizontal +/// and vertical alike, also while it slides along a construction guide or Shift's angle. +#[test] +fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { + let (mut d, _) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); + d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut snap = DrawSnap::default(); + assert_eq!(snap.press(&c, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 313.0), "the horizontal guide"); + assert_eq!(snap.press(&c, Point::new(329.0, 260.0), &[], None), Point::new(331.0, 260.0), "the vertical guide"); + // On the 45° construction guide from (210, 210), where the horizontal guide crosses it. + let from = Leave::segment(&c, Point::new(210.0, 210.0), false); + let q = snap.press(&c, Point::new(312.0, 311.0), &[], Some(&from)); + assert!((q - Point::new(313.0, 313.0)).hypot() < 1e-9, "{q:?}"); + // Snap to Point off: Smart Guides alone don't use the ruler guides. + let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; + assert_eq!(snap.press(&off, Point::new(260.0, 315.0), &[], None), Point::new(260.0, 315.0)); +} + +/// A drawing tool's snapper reuses its targets while hovering over the same document state, and +/// gathers them again when it changes, and on every press. +#[test] +fn draw_snap_gathers_once_per_document_state() { + let (d, _) = doc_with_rect(); + let empty = vectorcraft_doc::Document::new(500.0, 500.0); + let (s, p) = (Selection::default(), paint()); + let at = Point::new(202.0, 198.0); + let mut snap = DrawSnap::default(); + let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); + assert_eq!(labels(snap.guides()), ["anchor"]); + // The same revision: the targets are the ones gathered (the document isn't read again). + let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0)); + // A new revision, or a press, reads it. + let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) }; + assert_eq!(snap.hover(&c, at, &[], None), at); + assert!(snap.guides().is_empty()); + let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) }; + assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0)); + // The drag keeps what the press gathered, though the document now shows its preview. + let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) }; + assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0)); + snap.clear(); + assert!(snap.guides().is_empty()); + // Snap to Grid wins over Smart Guides, Shift still keeping the angle. + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let from = Leave::segment(&c, Point::ZERO, true); + let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); + assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); +}