mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
894 lines
49 KiB
Rust
894 lines
49 KiB
Rust
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<Point> = 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, None)));
|
||
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)));
|
||
}
|
||
|
||
/// Art inside a hidden group or layer is no target, even while its own visibility is on: not
|
||
/// for drawing, the Knife's straight cut, a move or a transform tool's reference point on the
|
||
/// shown group around it.
|
||
#[test]
|
||
fn art_in_hidden_groups_and_layers_is_no_target() {
|
||
use std::sync::Arc;
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
let (mut d, _) = doc_with_rect();
|
||
let l = d.layers[0].id;
|
||
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)), Appearance::default_art());
|
||
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(square)]);
|
||
inner.visible = false;
|
||
let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
|
||
let g = outer.id;
|
||
d.insert(Some(l), 1, outer).unwrap();
|
||
let corner = Point::new(300.0, 300.0);
|
||
let gives = |t: &Targets, at: Point| {
|
||
t.points.iter().any(|(p, _)| *p == at)
|
||
|| t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == at)
|
||
|| t.segments.iter().any(|(_, s)| s.start() == at || s.end() == at)
|
||
};
|
||
assert!(!gives(&Targets::collect(&d, &[], None), corner));
|
||
assert!(!gives(&Targets::for_drawing(&d, &[], None, None), corner));
|
||
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), corner));
|
||
assert!(!gives(&Targets::points_near(&d, &[g], Rect::new(296.0, 296.0, 304.0, 304.0)), corner));
|
||
// The shown rectangle on the same layer still is one, until the layer is hidden.
|
||
let shown = Point::new(100.0, 100.0);
|
||
assert!(gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
|
||
d.node_mut(l).unwrap().visible = false;
|
||
assert!(!gives(&Targets::collect(&d, &[], None), shown));
|
||
assert!(!gives(&Targets::for_drawing(&d, &[], None, None).anchors_and_paths(), shown));
|
||
}
|
||
|
||
/// #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, None).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, 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"]);
|
||
}
|
||
|
||
#[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, None) };
|
||
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, None)).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, None)).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, None);
|
||
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, None) };
|
||
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, None), 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<f64> = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect();
|
||
let mut all: Vec<f64> = 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:?}");
|
||
}
|
||
|
||
/// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about
|
||
/// where it starts, and the type.
|
||
fn glyph_doc(text: &str, angle: f64) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) {
|
||
glyph_doc_with(text, angle, vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() })
|
||
}
|
||
|
||
/// [`glyph_doc`] with the type in `style`.
|
||
fn glyph_doc_with(text: &str, angle: f64, style: vectorcraft_doc::CharStyle) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) {
|
||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let at = Point::new(100.0, 300.0);
|
||
let mut t = vectorcraft_doc::TextObject::point(at, text, style);
|
||
t.xf = Affine::rotate_about(angle, at) * t.xf;
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||
(d, t)
|
||
}
|
||
|
||
/// Snap to Glyph: a Pen click near an anchor point of a glyph's outline lands on it, and not with
|
||
/// Snap to Glyph off.
|
||
#[test]
|
||
fn pen_click_near_a_glyph_anchor_lands_on_it() {
|
||
use crate::{Action, PointerEvent, PointerKind, Tool};
|
||
let (d, t) = glyph_doc("H", 0.0);
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
|
||
// An anchor inside the glyph's bounds (a corner where the crossbar meets a stem), which no
|
||
// glyph bounds line passes through.
|
||
let bb = g.outline.bounding_box();
|
||
let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0;
|
||
let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph");
|
||
let (s, p) = (Selection::default(), paint());
|
||
let click = anchor + Vec2::new(1.5, -1.0);
|
||
let first_anchor = |glyphs: bool, anchors: bool| {
|
||
let c = ToolContext { snap_to_glyph: glyphs, glyph_snap: GlyphSnap { anchors, ..GlyphSnap::default() }, ..cx(&d, &s, &p) };
|
||
let mut pen = crate::pen::PenTool::default();
|
||
let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y));
|
||
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
|
||
Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap())
|
||
};
|
||
assert!(first_anchor(true, true).distance(anchor) < 1e-9, "{:?} vs {anchor:?}", first_anchor(true, true));
|
||
assert!(first_anchor(false, true).distance(anchor) > 1e-3);
|
||
// The Character panel's Snap to Glyph › Anchor Point off: no glyph anchors (#4232).
|
||
assert!(first_anchor(true, false).distance(anchor) > 1e-3);
|
||
// Snap to Glyph works with Smart Guides on: Snap to Point alone does not land on glyph anchors.
|
||
let c = ToolContext { snap_to_glyph: true, smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) };
|
||
let mut pen = crate::pen::PenTool::default();
|
||
let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y));
|
||
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
|
||
let q = Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap());
|
||
assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off");
|
||
}
|
||
|
||
/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only.
|
||
#[test]
|
||
fn drawn_points_line_up_with_the_x_height_of_a_single_line() {
|
||
let (d, t) = glyph_doc("H", 0.0);
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let g = &lay.glyphs[0];
|
||
let face = FontDb::global().face_by_id(g.font_id).unwrap();
|
||
let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y;
|
||
let p = Point::new(400.0, x_height + 2.0);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!((q.y - x_height).abs() < 1e-9, "{q:?}, x-height {x_height}");
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0);
|
||
assert_eq!(q.y, p.y);
|
||
let (d, _) = glyph_doc("H\nH", 0.0);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert_eq!(q.y, p.y, "a paragraph has no x-height guide");
|
||
// Baseline Shift raises the x-height with the glyphs.
|
||
let (d, _) = glyph_doc_with("H", 0.0, vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, ..Default::default() });
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0);
|
||
assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0);
|
||
// Character Rotation turns the glyph, not its baseline shift.
|
||
let style = vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, rotation: 30.0, ..Default::default() };
|
||
let (d, _) = glyph_doc_with("H", 0.0, style);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0);
|
||
assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0);
|
||
// Glyph Scaling widens a rotated glyph without raising its x-height.
|
||
let mut t =
|
||
vectorcraft_doc::TextObject::point(Point::ZERO, "HH HH", vectorcraft_doc::CharStyle { size: 60.0, rotation: 90.0, ..Default::default() });
|
||
let frame = kurbo::Shape::to_path(&kurbo::Rect::new(50.0, 100.0, 450.0, 400.0), 0.1);
|
||
t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&frame) };
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.justify = vectorcraft_doc::Justify::JustifyAll;
|
||
t.para.justification.glyph_scaling = [100.0, 100.0, 200.0];
|
||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
assert_eq!(lay.lines.len(), 1);
|
||
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
|
||
assert!(g.scale > 1.2, "Glyph Scaling widens the glyph: {}", g.scale);
|
||
let expected = (t.xf * g.origin).y - (300.0 - x_height);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(Point::new(30.0, expected + 2.0), 4.0);
|
||
assert!((q.y - expected).abs() < 1e-9, "{q:?}, x-height {expected}");
|
||
}
|
||
|
||
/// Snap to Glyph: the x-height guide of type skewed along a slanted path runs through the glyph's
|
||
/// x-height.
|
||
#[test]
|
||
fn the_x_height_guide_of_skewed_path_type_runs_through_the_glyphs_x_height() {
|
||
let mut path = kurbo::BezPath::new();
|
||
path.move_to((100.0, 400.0));
|
||
path.line_to((300.0, 54.0));
|
||
let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "H", vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() });
|
||
t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||
t.xf = Affine::IDENTITY;
|
||
t.path_effect = vectorcraft_doc::PathEffect::Skew;
|
||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
|
||
let face = FontDb::global().face_by_id(g.font_id).unwrap();
|
||
let top = t.xf * g.xf * Point::new(0.0, -face.x_height());
|
||
let ahead = Vec2::new(g.angle.cos(), g.angle.sin());
|
||
let normal = Vec2::new(ahead.y, -ahead.x);
|
||
// Beside the guide's line, past the glyph's end along it.
|
||
let p = top + ahead * 80.0 + normal * 1.5;
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!((q - top).cross(ahead).abs() < 1e-6, "{q:?} is off the x-height line through {top:?}");
|
||
}
|
||
|
||
/// Snap to Glyph › Proximity Guides: the edges of each character's advance width are guides.
|
||
#[test]
|
||
fn drawn_points_line_up_with_the_edge_of_a_characters_width() {
|
||
let (d, t) = glyph_doc("HH", 0.0);
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let edge = (t.xf * lay.glyphs[1].origin).x;
|
||
let p = Point::new(edge + 0.3, 450.0);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}");
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0);
|
||
assert_eq!(q.x, p.x);
|
||
// Character Rotation turns the glyphs, not their widths along the baseline.
|
||
let (d, t) = glyph_doc_with("HH", 0.0, vectorcraft_doc::CharStyle { size: 60.0, rotation: 30.0, ..Default::default() });
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let edge = (t.xf * lay.glyphs[1].origin).x;
|
||
let p = Point::new(edge + 0.3, 450.0);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}");
|
||
}
|
||
|
||
/// Snap to Glyph › Angular Guides: in a rotated glyph a straight upright stroke is a guide at its
|
||
/// angle, which a drawn point lands on beyond the glyph.
|
||
#[test]
|
||
fn a_rotated_glyph_has_angular_guides_along_its_strokes() {
|
||
let angle = 20f64.to_radians();
|
||
let (d, t) = glyph_doc("H", angle);
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
|
||
let stem = g
|
||
.outline
|
||
.segments()
|
||
.find_map(|s| match s {
|
||
PathSeg::Line(l) if (l.p1.x - l.p0.x).abs() < 1e-6 && (l.p1.y - l.p0.y).abs() > 10.0 => Some(l),
|
||
_ => None,
|
||
})
|
||
.expect("an upright stroke");
|
||
let (a, b) = (t.xf * stem.p0, t.xf * stem.p1);
|
||
let dir = (b - a) / (b - a).hypot();
|
||
// Past the far end of the stroke, a little beside its line.
|
||
let far = if (a - t.xf * Point::new(0.0, 0.0)).hypot() > (b - t.xf * Point::new(0.0, 0.0)).hypot() { a } else { b };
|
||
let out = if far == a { -dir } else { dir };
|
||
let p = far + out * 30.0 + Vec2::new(-dir.y, dir.x) * 1.5;
|
||
let on_line = |q: Point| (q - a).cross(dir).abs();
|
||
let (q, ov) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!(on_line(q) < 1e-9, "{q:?} is {} off the stroke's line", on_line(q));
|
||
assert!(!lines(&ov).is_empty());
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, None).snap_point(p, 4.0);
|
||
assert!(on_line(q) > 1e-3, "no angular guide with Snap to Glyph off");
|
||
}
|
||
|
||
/// The Character panel's Snap to Glyph options (#4232): each one off takes away its own guides and
|
||
/// leaves the others.
|
||
#[test]
|
||
fn each_snap_to_glyph_option_takes_away_only_its_own_guides() {
|
||
// The glyph guides: lines across the page (ys), down it (xs), at an angle, and anchor points.
|
||
let count = |d: &vectorcraft_doc::Document, o: GlyphSnap| {
|
||
let t = Targets::for_drawing(d, &[], None, Some(o));
|
||
let glyph = |v: &[(f64, Point, Kind)]| v.iter().filter(|e| e.2 == Kind::Glyph).count();
|
||
[glyph(&t.ys), glyph(&t.xs), t.angled.len(), t.points.iter().filter(|e| e.1 == Kind::Anchor).count()]
|
||
};
|
||
let all = GlyphSnap::default();
|
||
let (d, t) = glyph_doc("H", 0.0);
|
||
let on = count(&d, all);
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let g = &lay.glyphs[0];
|
||
let anchors = g.outline.segments().count();
|
||
assert!(anchors > 0);
|
||
// One line, one glyph: a baseline, an x-height, two width edges, four corners and its anchors.
|
||
for (off, less) in [
|
||
(GlyphSnap { baseline: false, ..all }, [1, 0, 0, 0]),
|
||
(GlyphSnap { x_height: false, ..all }, [1, 0, 0, 0]),
|
||
(GlyphSnap { bounds: false, ..all }, [4, 4, 0, 0]),
|
||
(GlyphSnap { proximity: false, ..all }, [0, 2, 0, 0]),
|
||
(GlyphSnap { anchors: false, ..all }, [0, 0, 0, anchors]),
|
||
(GlyphSnap { angular: false, ..all }, [0, 0, 0, 0]),
|
||
] {
|
||
let got = count(&d, off);
|
||
assert_eq!(std::array::from_fn::<usize, 4, _>(|i| on[i] - got[i]), less, "{off:?}: {got:?} of {on:?}");
|
||
}
|
||
// A point near the x-height lands on it only with x-height on.
|
||
let face = FontDb::global().face_by_id(g.font_id).unwrap();
|
||
let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y;
|
||
let p = Point::new(400.0, x_height + 2.0);
|
||
assert!((Targets::for_drawing(&d, &[], None, Some(all)).snap_point(p, 4.0).0.y - x_height).abs() < 1e-9);
|
||
assert_eq!(Targets::for_drawing(&d, &[], None, Some(GlyphSnap { x_height: false, ..all })).snap_point(p, 4.0).0.y, p.y);
|
||
// Rotated type: its baseline, x-height and width edges and its strokes run at an angle, all
|
||
// Angular Guides; its glyph bounds still run across and down the page.
|
||
let (d, _) = glyph_doc("H", 20f64.to_radians());
|
||
let (on, off) = (count(&d, all), count(&d, GlyphSnap { angular: false, ..all }));
|
||
assert!(on[2] > 0, "{on:?}");
|
||
assert_eq!(off, [on[0], on[1], 0, on[3]]);
|
||
}
|
||
|
||
/// Snap to Glyph: the baseline of type turned slightly runs at its angle, and a point near it
|
||
/// lands on it even close to where it starts, far nearer the horizontal through that point.
|
||
#[test]
|
||
fn a_slightly_rotated_baseline_pulls_points_near_its_start() {
|
||
let (d, t) = glyph_doc("H", 2f64.to_radians());
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let m = t.xf * lay.line_xf;
|
||
let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline);
|
||
let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO);
|
||
let dir = dir / dir.hypot();
|
||
let p = start - dir * 10.0 + Vec2::new(-dir.y, dir.x) * 1.0;
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_point(p, 4.0);
|
||
assert!((q - start).cross(dir).abs() < 1e-9, "{q:?} is off the baseline");
|
||
}
|
||
|
||
/// Snap to Glyph: a segment Shift keeps level slides along to where it crosses the baseline of
|
||
/// rotated type.
|
||
#[test]
|
||
fn a_shift_constrained_point_slides_onto_a_rotated_baseline() {
|
||
let (d, t) = glyph_doc("H", 20f64.to_radians());
|
||
let lay = layout_text(FontDb::global(), &t);
|
||
let m = t.xf * lay.line_xf;
|
||
let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline);
|
||
let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO);
|
||
let dir = dir / dir.hypot();
|
||
let crossing = start - dir * 40.0;
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let from = Leave::segment(&c, crossing - Vec2::new(60.0, 0.0), true);
|
||
let (q, _) = Targets::for_drawing(&d, &[], None, Some(GlyphSnap::default())).snap_from(crossing + Vec2::new(1.5, 0.3), Some(&from), 4.0);
|
||
assert!(q.distance(crossing) < 1e-9, "{q:?} vs {crossing:?}");
|
||
}
|