mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 06:05:08 +00:00
In Outline view an image was still hit anywhere inside its box and a gradient mesh inside its fill, though Outline draws only the image's frame and the mesh lines. A path behind them could not be picked there. Now an image is hit on its frame (unless Show Images in Outline Mode draws its pixels) and a mesh on its edge and mesh lines. Preview is unchanged.
1649 lines
91 KiB
Rust
1649 lines
91 KiB
Rust
use super::*;
|
||
use crate::{Appearance, Node};
|
||
use std::sync::Arc;
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_geom::shapes;
|
||
|
||
#[test]
|
||
fn hits_topmost() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
d.insert(Some(l), 9, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
let b = d.alloc_id();
|
||
d.insert(Some(l), 9, Node::path(b, shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default_art())).unwrap();
|
||
let h = hit_test(&d, Point::new(30.0, 30.0), HitOptions::default()).unwrap();
|
||
assert_eq!(h.leaf, b);
|
||
assert_eq!(h.kind, HitKind::Fill);
|
||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||
assert_eq!(h.leaf, a);
|
||
assert!(hit_test(&d, Point::new(150.0, 150.0), HitOptions::default()).is_none());
|
||
// Outline mode: interior misses.
|
||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions { outline: true, ..Default::default() }).is_none());
|
||
assert!(hit_test(&d, Point::new(0.5, 10.0), HitOptions { outline: true, ..Default::default() }).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn unfilled_interior_misses() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
d.insert(
|
||
Some(l),
|
||
9,
|
||
Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
|
||
)
|
||
.unwrap();
|
||
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
|
||
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn long_plain_stroke_hits_its_middle_and_curved_end() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let mut sp = vectorcraft_geom::SubPath::polyline(&(0..=4097).map(|i| Point::new(i as f64 * 10.0, 0.0)).collect::<Vec<_>>(), false);
|
||
sp.anchors[4096].h_out = Point::new(40960.0, 100.0);
|
||
sp.anchors[4097].h_in = Point::new(40970.0, 100.0);
|
||
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(40970.0, 100.0)));
|
||
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(0.0, 100.0)));
|
||
let path = vectorcraft_geom::PathData::single(sp);
|
||
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
d.insert(Some(l), 0, Node::path(id, path, look)).unwrap();
|
||
let outline = HitOptions { outline: true, ..Default::default() };
|
||
for point in [Point::new(20485.0, 0.0), Point::new(40965.0, 75.0)] {
|
||
assert_eq!(hit_test(&d, point, HitOptions::default()).map(|h| h.kind), Some(HitKind::Stroke));
|
||
assert_eq!(hit_test(&d, point, outline).map(|h| h.kind), Some(HitKind::Outline));
|
||
}
|
||
assert!(hit_test(&d, Point::new(20485.0, 50.0), HitOptions::default()).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn coincident_anchors_remain_hittable_as_an_outline() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let id = d.alloc_id();
|
||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[Point::new(50.0, 50.0); 2], false));
|
||
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||
d.insert(d.layers.first().map(|l| l.id), 0, Node::path(id, path, look)).unwrap();
|
||
let at = Point::new(50.0, 50.0);
|
||
assert_eq!(hit_test(&d, at, HitOptions::default()).map(|h| h.kind), Some(HitKind::Outline));
|
||
assert_eq!(hit_test(&d, at, HitOptions { outline: true, ..Default::default() }).map(|h| h.kind), Some(HitKind::Outline));
|
||
}
|
||
|
||
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
|
||
/// outline it doesn't touch.
|
||
#[test]
|
||
fn a_marquee_inside_a_background_leaves_the_background() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (back, small) = (d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::path(back, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
|
||
d.insert(Some(l), 1, Node::path(small, shapes::rectangle(Rect::new(50.0, 50.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||
assert_eq!(marquee(&d, Rect::new(40.0, 40.0, 70.0, 70.0), None, false), vec![small]);
|
||
// Reaching the background's edge takes it too.
|
||
assert_eq!(marquee(&d, Rect::new(-5.0, 40.0, 70.0, 70.0), None, false), vec![back, small]);
|
||
}
|
||
|
||
#[test]
|
||
fn group_top_object() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
let g = d.alloc_id();
|
||
let p = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||
d.insert(Some(l), 0, Node::group(g, vec![Arc::new(p)])).unwrap();
|
||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||
assert_eq!(h.leaf, a);
|
||
assert_eq!(h.top_object(None), g);
|
||
assert_eq!(h.top_object(Some(g)), a);
|
||
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, false), vec![g]);
|
||
assert_eq!(marquee(&d, Rect::new(-5.0, -5.0, 5.0, 5.0), None, true), vec![a]);
|
||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||
}
|
||
|
||
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
|
||
/// the art, in that order.
|
||
fn half_clipped(d: &mut Document) -> (Node, Node) {
|
||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||
(mask, art)
|
||
}
|
||
|
||
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
|
||
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
|
||
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
|
||
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
|
||
/// does.
|
||
#[test]
|
||
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let (g, art_id) = (d.alloc_id(), art.id);
|
||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
|
||
let opt = HitOptions::default();
|
||
assert!(hit_test(&d, hidden, opt).is_none());
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
|
||
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
|
||
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
|
||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
|
||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
|
||
let other = d.add_layer(Some("Other"));
|
||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
|
||
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
|
||
let mut d = d.clone();
|
||
change(d.node_mut(art_id).unwrap());
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
|
||
}
|
||
// A layer's clipping mask.
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let art_id = art.id;
|
||
d.insert(Some(l), 0, mask).unwrap();
|
||
d.insert(Some(l), 1, art).unwrap();
|
||
d.node_mut(l).unwrap().set_clips(true);
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||
}
|
||
|
||
/// A selected group's own art is what a click would hit in it: not a hidden or locked child, nor
|
||
/// art its own clipping mask hides, all under the pointer where an outer mask hides them.
|
||
#[test]
|
||
fn a_selected_groups_hidden_art_counts_only_where_a_click_would_hit_it() {
|
||
// An outer clip group masking to the left half (0..100); the selected group inside it holds
|
||
// `child` (100..200 × 0..100, under the pointer at (150, 50)) and `shown` (inside the mask).
|
||
let build = |change: &dyn Fn(&mut Node, &mut Node)| {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let outer_mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||
let (gid, cid) = (d.alloc_id(), d.alloc_id());
|
||
let mut group = Node::group(gid, vec![Arc::new(shown)]);
|
||
change(&mut group, &mut child);
|
||
if let NodeKind::Group { children, .. } = &mut group.kind {
|
||
children.push(Arc::new(child));
|
||
}
|
||
let mut clip = Node::group(cid, vec![Arc::new(outer_mask), Arc::new(group)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
(d, gid)
|
||
};
|
||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||
let (d, g) = build(&|_, _| {});
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), Some(g));
|
||
let (d, g) = build(&|_, child| child.visible = false);
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a hidden child");
|
||
let (d, g) = build(&|_, child| child.locked = true);
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a locked child");
|
||
// The selected group is a clip group too, whose own mask (its first child, 10..40) leaves
|
||
// `child` out.
|
||
let (d, g) = build(&|group, _| group.set_clips(true));
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "its own mask");
|
||
}
|
||
|
||
/// A selected guide path where a mask hides it counts only while guides can be clicked: not
|
||
/// while they are locked or hidden (`skip_guides`), as a click on it then hits nothing.
|
||
#[test]
|
||
fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, mut art) = half_clipped(&mut d);
|
||
if let NodeKind::Path { guide, .. } = &mut art.kind {
|
||
*guide = true;
|
||
}
|
||
let (g, guide_id) = (d.alloc_id(), art.id);
|
||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[guide_id]), Some(guide_id));
|
||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
|
||
}
|
||
|
||
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
|
||
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
|
||
/// lies in them.
|
||
#[test]
|
||
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
|
||
// original shapes (its first child), under (150, 50).
|
||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||
let path_id = path.id;
|
||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
|
||
sources.name = Some(crate::shaper::SOURCES.into());
|
||
let sources_id = sources.id;
|
||
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||
shaper.extras_mut().shaper = Some(Box::default());
|
||
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
|
||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
|
||
}
|
||
|
||
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
|
||
/// counts on its outline, not inside its fill.
|
||
#[test]
|
||
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let (g, art_id) = (d.alloc_id(), art.id);
|
||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
|
||
*preview = false;
|
||
}
|
||
let opt = HitOptions::default();
|
||
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
|
||
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
|
||
}
|
||
|
||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||
/// Group Selection's leaves. In isolation mode (#3748) the isolated group's own mask does not
|
||
/// limit the marquee, as it does not limit clicks: with the group or the art isolated, the
|
||
/// marquee takes the hidden art.
|
||
#[test]
|
||
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
|
||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||
// This marquee crosses only the hidden art's top and right edges.
|
||
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
|
||
assert!(marquee(&d, hidden, None, false).is_empty());
|
||
assert!(marquee(&d, hidden, None, true).is_empty());
|
||
assert_eq!(marquee(&d, hidden, Some(g), false), vec![art_id]);
|
||
assert_eq!(marquee(&d, hidden, Some(g), true), vec![art_id]);
|
||
assert_eq!(marquee(&d, hidden, Some(art_id), false), vec![art_id]);
|
||
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
|
||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
|
||
// This marquee crosses the shown half's top edge and goes on into the hidden half.
|
||
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
|
||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||
assert!(marquee(&d, shown, None, true).contains(&art_id));
|
||
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
|
||
}
|
||
|
||
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
|
||
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
|
||
/// straight line, and a marquee across that line takes the art it reaches inside.
|
||
#[test]
|
||
fn a_marquee_must_meet_the_clipping_path_itself() {
|
||
// A clip group of a mask through `pts` (closed or open) over the art `art`.
|
||
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let mut mask = vectorcraft_geom::BezPath::new();
|
||
mask.move_to(pts[0]);
|
||
for &p in &pts[1..] {
|
||
mask.line_to(p);
|
||
}
|
||
if closed {
|
||
mask.close_path();
|
||
}
|
||
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
|
||
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
|
||
let g = d.alloc_id();
|
||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||
clip.set_clips(true);
|
||
d.insert(Some(l), 0, clip).unwrap();
|
||
(d, g)
|
||
};
|
||
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
|
||
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
|
||
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
|
||
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
|
||
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
|
||
}
|
||
|
||
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
|
||
/// mask shows them, as in a clip group.
|
||
#[test]
|
||
fn a_marquee_leaves_art_a_layer_clips_away() {
|
||
for sublayer in [false, true] {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let beside = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||
let masked = d.alloc_id();
|
||
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
|
||
layer.set_clips(true);
|
||
d.insert(sublayer.then_some(l), 9, layer).unwrap();
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let (mask_id, art_id) = (mask.id, art.id);
|
||
d.insert(Some(masked), 0, mask).unwrap();
|
||
d.insert(Some(masked), 1, art).unwrap();
|
||
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
|
||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
|
||
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
|
||
}
|
||
}
|
||
|
||
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
|
||
/// the original shape of a face the group erased it selects nothing, with Group Selection's
|
||
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
|
||
/// (Construction mode) it takes the original shape it reaches.
|
||
#[test]
|
||
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
|
||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||
let b_id = b.id;
|
||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||
sources.name = Some(crate::shaper::SOURCES.into());
|
||
sources.opacity = 0.0;
|
||
let sources_id = sources.id;
|
||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||
let face_id = face.id;
|
||
let g = d.alloc_id();
|
||
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
|
||
group.extras_mut().shaper = Some(Box::default());
|
||
d.insert(Some(l), 0, group).unwrap();
|
||
// This marquee crosses only the right edge of the second square, whose face is erased.
|
||
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
|
||
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
|
||
assert!(marquee(&d, erased, None, false).is_empty());
|
||
assert!(marquee(&d, erased, None, true).is_empty());
|
||
assert!(marquee(&d, erased, Some(g), false).is_empty());
|
||
// This marquee crosses the shown face's left edge.
|
||
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
|
||
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
|
||
// Construction mode: the original shapes are the objects.
|
||
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
|
||
}
|
||
|
||
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
|
||
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
|
||
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
|
||
/// Selection take, or in isolation mode, it selects the member.
|
||
#[test]
|
||
fn a_marquee_meets_an_intertwine_group_by_its_members() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
|
||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
|
||
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
|
||
let (a_id, g) = (a.id, d.alloc_id());
|
||
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
|
||
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
|
||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
|
||
assert!(marquee(&d, gap, None, false).is_empty());
|
||
assert!(marquee(&d, gap, None, true).is_empty());
|
||
assert!(marquee(&d, gap, Some(g), false).is_empty());
|
||
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
|
||
// This marquee crosses the first square's left edge.
|
||
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||
assert_eq!(marquee(&d, edge, None, false), vec![g]);
|
||
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
|
||
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
|
||
}
|
||
|
||
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
|
||
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
|
||
#[test]
|
||
fn a_marquee_meets_a_repeat_by_its_copies() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||
// Copies at 0..20, 40..60 and 80..100.
|
||
let kind = crate::pattern::RepeatKind::Grid {
|
||
h_spacing: 20.0,
|
||
v_spacing: 20.0,
|
||
rows: 1,
|
||
cols: 3,
|
||
grid_type: crate::pattern::TileType::Grid,
|
||
flip_rows: false,
|
||
flip_cols: false,
|
||
};
|
||
let r = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
|
||
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
|
||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
|
||
assert!(marquee(&d, gap, None, false).is_empty());
|
||
assert!(marquee(&d, gap, None, true).is_empty());
|
||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
|
||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
|
||
}
|
||
|
||
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
|
||
/// too: inside the box, away from every key object's and the content's outline, it takes them.
|
||
#[test]
|
||
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
|
||
let mut d = Document::new(300.0, 300.0);
|
||
let l = d.layers[0].id;
|
||
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
|
||
let blend = d.alloc_id();
|
||
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
|
||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
|
||
let envelope = d.alloc_id();
|
||
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
|
||
let e = Node::new(
|
||
envelope,
|
||
NodeKind::Envelope {
|
||
content,
|
||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||
fidelity: 100.0,
|
||
editing: false,
|
||
options: Default::default(),
|
||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||
},
|
||
);
|
||
d.insert(Some(l), 1, e).unwrap();
|
||
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
|
||
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
|
||
assert_eq!(marquee(&d, r, None, false), vec![id]);
|
||
assert_eq!(marquee(&d, r, None, true), vec![id]);
|
||
}
|
||
}
|
||
|
||
/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
|
||
/// whose own reach holds the point, not every step of the evaluated blend.
|
||
#[test]
|
||
fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
|
||
let mut d = Document::new(1000.0, 1000.0);
|
||
let l = d.layers[0].id;
|
||
// 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
|
||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||
let blend = d.alloc_id();
|
||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||
let count = |p: Point| {
|
||
LEAVES_TESTED.with(|c| c.set(0));
|
||
let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
|
||
(h, LEAVES_TESTED.with(std::cell::Cell::get))
|
||
};
|
||
// On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
|
||
let (h, tested) = count(Point::new(450.0, 291.0));
|
||
assert_eq!(h, Some(blend));
|
||
assert!(tested < 20, "{tested} objects tested");
|
||
// In the corner of the reach, outside every stroke: nothing hits.
|
||
let (h, tested) = count(Point::new(6.0, 291.0));
|
||
assert_eq!(h, None);
|
||
assert!(tested < 20, "{tested} objects tested");
|
||
}
|
||
|
||
/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
|
||
/// members.
|
||
#[test]
|
||
fn evaluated_art_hits_an_intertwine_group_by_its_members() {
|
||
let d = Document::new(200.0, 200.0);
|
||
let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
|
||
let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
|
||
let symbols = d.symbol_boxes();
|
||
assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
|
||
assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
|
||
}
|
||
|
||
/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
|
||
/// or a symbol of the document changes.
|
||
#[test]
|
||
fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
|
||
let mut d = Document::new(1000.0, 1000.0);
|
||
let l = d.layers[0].id;
|
||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||
let blend = d.alloc_id();
|
||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||
let before = evaluated();
|
||
for x in [448.0, 450.0, 452.0] {
|
||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||
}
|
||
assert_eq!(evaluated(), before + 1);
|
||
// 100 steps 8.7 pt apart: a step still reaches (450, 291).
|
||
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
|
||
spec.spacing = crate::live::BlendSpacing::Steps(100);
|
||
}
|
||
assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||
assert_eq!(evaluated(), before + 2);
|
||
let art = circle(d.alloc_id(), 0.0);
|
||
d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
|
||
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
|
||
assert_eq!(evaluated(), before + 3);
|
||
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
|
||
assert_eq!(evaluated(), before + 3);
|
||
}
|
||
|
||
/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
|
||
/// evaluates it again.
|
||
#[test]
|
||
fn evaluated_art_past_the_kept_size_is_not_kept() {
|
||
let mut d = Document::new(1000.0, 1000.0);
|
||
let l = d.layers[0].id;
|
||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||
// A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
|
||
let circle = |id, x: f64| {
|
||
let points: Vec<Point> = (0..2000)
|
||
.map(|i| {
|
||
let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
|
||
Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
|
||
})
|
||
.collect();
|
||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||
Arc::new(Node::path(id, path, stroked.clone()))
|
||
};
|
||
let blend = d.alloc_id();
|
||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||
let before = evaluated();
|
||
for x in [448.0, 450.0] {
|
||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||
}
|
||
assert_eq!(evaluated(), before + 2);
|
||
}
|
||
|
||
/// While the document's symbols hold a placed document, whose art changes when its file is read,
|
||
/// each click in a blend's stroke band evaluates the blend again.
|
||
#[test]
|
||
fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
|
||
let mut d = Document::new(1000.0, 1000.0);
|
||
let l = d.layers[0].id;
|
||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||
let blend = d.alloc_id();
|
||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||
let placed = crate::PlacedDocument {
|
||
link: crate::LinkInfo::new("/art/logo.vectorcraft"),
|
||
key: "logo".into(),
|
||
bounding: false,
|
||
page_box: crate::PageBox::Crop,
|
||
width: 80.0,
|
||
height: 40.0,
|
||
xf: vectorcraft_geom::Affine::IDENTITY,
|
||
placement: Default::default(),
|
||
};
|
||
let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
|
||
d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
|
||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||
let before = evaluated();
|
||
for x in [448.0, 450.0] {
|
||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||
}
|
||
assert_eq!(evaluated(), before + 2);
|
||
}
|
||
|
||
/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
|
||
/// ([`LiveInputs`]) are as they were.
|
||
#[test]
|
||
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
|
||
let d = Document::new(100.0, 100.0);
|
||
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
let e = kept_evaluation(&d, &n);
|
||
assert!(e.is_of(&d, &n, 7));
|
||
assert!(!e.is_of(&d, &n, 8));
|
||
}
|
||
|
||
/// An evaluation of `n` in `d`, with inputs 7, holding no symbols, patterns or art.
|
||
fn kept_evaluation(d: &Document, n: &Node) -> EvaluatedLive {
|
||
EvaluatedLive {
|
||
node: n.clone(),
|
||
symbols: vec![],
|
||
patterns: vec![],
|
||
swatches: d.swatches.clone(),
|
||
swatch_groups: d.swatch_groups.clone(),
|
||
inputs: 7,
|
||
art: std::rc::Rc::new(vec![]),
|
||
size: 0,
|
||
}
|
||
}
|
||
|
||
/// A kept evaluation is stale once a swatch changes, in a color group too: a painted live effect's
|
||
/// pieces name swatches.
|
||
#[test]
|
||
fn a_kept_evaluation_is_stale_after_a_swatch_changes() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||
let e = kept_evaluation(&d, &n);
|
||
assert!(e.is_of(&d, &n, 7));
|
||
d.swatches.first_mut().unwrap().name = "Renamed".into();
|
||
assert!(!e.is_of(&d, &n, 7));
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let e = kept_evaluation(&d, &n);
|
||
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Added".into(), swatches: vec![] });
|
||
assert!(!e.is_of(&d, &n, 7));
|
||
}
|
||
|
||
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
|
||
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
|
||
/// clipping path and the art.
|
||
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||
*clipping = true;
|
||
}
|
||
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
|
||
if layer {
|
||
d.insert(Some(l), 0, path).unwrap();
|
||
d.insert(Some(l), 1, picture).unwrap();
|
||
d.node_mut(l).unwrap().set_clips(true);
|
||
(d, l, clip, art)
|
||
} else {
|
||
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
|
||
(d, g, clip, art)
|
||
}
|
||
}
|
||
|
||
const SELECTING: HitOptions =
|
||
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
|
||
|
||
/// For the selection tools a clip group's clipping path is hit on its outline, within the
|
||
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
|
||
#[test]
|
||
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
|
||
let (d, g, clip, art) = masked_square(false);
|
||
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
|
||
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
|
||
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
|
||
}
|
||
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
|
||
assert_eq!((h.leaf, h.top_object(None)), (art, g));
|
||
// Beyond the tolerance outside it, the clipped art is not there to hit.
|
||
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
|
||
}
|
||
|
||
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
|
||
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
|
||
/// nothing there.
|
||
#[test]
|
||
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
|
||
let (d, _, clip, art) = masked_square(false);
|
||
let p = Point::new(138.5, 90.0);
|
||
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
|
||
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
|
||
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
|
||
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
|
||
// A hidden or locked clipping path is passed over.
|
||
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
|
||
let mut d = d.clone();
|
||
change(d.node_mut(clip).unwrap());
|
||
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
|
||
}
|
||
}
|
||
|
||
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
|
||
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
|
||
/// well inside, the art alone (the mask paints nothing there).
|
||
#[test]
|
||
fn select_behind_takes_a_layer_masks_outline_first() {
|
||
let (d, _, clip, art) = masked_square(true);
|
||
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
|
||
let p = Point::new(x, 90.0);
|
||
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
|
||
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
|
||
}
|
||
// Without clip_outlines, as before.
|
||
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
|
||
}
|
||
|
||
/// #3748: in isolation mode the selection tools hit the art that the isolated clip group (or
|
||
/// the isolated layer with a clipping mask) hides outside its mask, and the art when it is the
|
||
/// single object isolated; the mask's outline is still hit first. Outside isolation mode, and
|
||
/// for the tools that act on what is drawn, the hidden art is not hit. The Unlock submenu
|
||
/// lists the hidden art when it is locked.
|
||
#[test]
|
||
fn isolation_reaches_the_art_a_clipping_mask_hides() {
|
||
let hidden = Point::new(170.0, 90.0);
|
||
for layer in [false, true] {
|
||
let (mut d, g, clip, art) = masked_square(layer);
|
||
let isolated = HitOptions { scope: Some(g), ..SELECTING };
|
||
assert!(hit_test(&d, hidden, SELECTING).is_none(), "layer {layer}: not isolated");
|
||
let h = hit_test(&d, hidden, isolated);
|
||
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(g)))), Some((art, art)), "layer {layer}");
|
||
assert_eq!(objects_at(&d, hidden, SELECTING, Some(g)), vec![art], "layer {layer}");
|
||
assert_eq!(hit_test(&d, Point::new(140.0, 90.0), isolated).map(|h| h.leaf), Some(clip), "layer {layer}: the outline first");
|
||
let one = HitOptions { scope: Some(art), ..SELECTING };
|
||
assert_eq!(hit_test(&d, hidden, one).map(|h| h.leaf), Some(art), "layer {layer}: the art isolated");
|
||
assert!(hit_test(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }).is_none(), "layer {layer}: drawn art only");
|
||
assert!(hit_test_visible(&d, hidden, isolated).is_none(), "layer {layer}: sampling");
|
||
d.node_mut(art).unwrap().locked = true;
|
||
assert_eq!(locked_objects_at(&d, hidden, HitOptions { scope: Some(g), ..HitOptions::default() }), vec![art], "layer {layer}");
|
||
assert!(locked_objects_at(&d, hidden, HitOptions::default()).is_empty(), "layer {layer}: not isolated");
|
||
}
|
||
}
|
||
|
||
/// #3748, nested clip groups: with the outer clip group isolated, a click or a marquee takes
|
||
/// the art its own mask hides, while the mask of a clip group inside it still limits that
|
||
/// group's art. With the inner clip group isolated, or its art, the inner mask does not.
|
||
#[test]
|
||
fn isolation_reaches_hidden_art_only_for_the_isolated_clip_group() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (outer, outer_clip, outer_art, inner, inner_clip, inner_art) =
|
||
(d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
let mask = |id, r| {
|
||
let mut path = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||
*clipping = true;
|
||
}
|
||
Arc::new(path)
|
||
};
|
||
let art = |id, r| Arc::new(Node::path(id, shapes::rectangle(r), Appearance::default_art()));
|
||
let inner_group = Node::new(
|
||
inner,
|
||
NodeKind::Group {
|
||
children: vec![mask(inner_clip, Rect::new(60.0, 60.0, 120.0, 120.0)), art(inner_art, Rect::new(40.0, 40.0, 160.0, 160.0))],
|
||
clip: true,
|
||
},
|
||
);
|
||
let children =
|
||
vec![mask(outer_clip, Rect::new(20.0, 20.0, 180.0, 180.0)), art(outer_art, Rect::new(190.0, 20.0, 260.0, 180.0)), Arc::new(inner_group)];
|
||
d.insert(Some(l), 0, Node::new(outer, NodeKind::Group { children, clip: true })).unwrap();
|
||
// `outer_hidden` is outside the outer mask, on the outer group's own art; `inner_hidden` is
|
||
// inside the outer mask and outside the inner one, on the inner group's art.
|
||
let (outer_hidden, inner_hidden, shown) = (Point::new(220.0, 100.0), Point::new(150.0, 100.0), Point::new(90.0, 90.0));
|
||
let leaf = |p, scope| hit_test(&d, p, HitOptions { scope, ..SELECTING }).map(|h| h.leaf);
|
||
assert_eq!((leaf(outer_hidden, None), leaf(inner_hidden, None)), (None, None), "not isolated");
|
||
assert_eq!(leaf(outer_hidden, Some(outer)), Some(outer_art), "outer isolated: its own hidden art");
|
||
assert_eq!(leaf(inner_hidden, Some(outer)), None, "outer isolated: the inner mask still clips");
|
||
let h = hit_test(&d, shown, HitOptions { scope: Some(outer), ..SELECTING });
|
||
assert_eq!(h.map(|h| (h.leaf, h.top_object(Some(outer)))), Some((inner_art, inner)), "outer isolated: shown art");
|
||
assert_eq!(leaf(inner_hidden, Some(inner)), Some(inner_art), "inner isolated");
|
||
assert_eq!(leaf(outer_hidden, Some(inner)), None, "inner isolated: the outer group's art");
|
||
assert_eq!(leaf(inner_hidden, Some(inner_art)), Some(inner_art), "the inner art isolated");
|
||
// These marquees cross the right edge of the outer art (outside the outer mask) and of the
|
||
// inner art (inside the outer mask, outside the inner one).
|
||
let (outer_edge, inner_edge) = (Rect::new(255.0, 95.0, 265.0, 105.0), Rect::new(155.0, 95.0, 165.0, 105.0));
|
||
assert_eq!(marquee(&d, outer_edge, Some(outer), false), vec![outer_art]);
|
||
assert!(marquee(&d, inner_edge, Some(outer), false).is_empty());
|
||
assert!(marquee(&d, inner_edge, Some(outer), true).is_empty());
|
||
assert_eq!(marquee(&d, inner_edge, Some(inner), false), vec![inner_art]);
|
||
assert!(marquee(&d, inner_edge, None, false).is_empty());
|
||
assert!(marquee(&d, outer_edge, None, false).is_empty());
|
||
}
|
||
|
||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||
/// sublayer; hidden, locked and template sublayers block both.
|
||
#[test]
|
||
fn sublayers_are_not_objects() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let sub = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
|
||
let inner = d.alloc_id();
|
||
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
|
||
let a = d.alloc_id();
|
||
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
let g = d.alloc_id();
|
||
let b = d.alloc_id();
|
||
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
|
||
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
|
||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
|
||
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
|
||
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
|
||
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
|
||
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
|
||
assert_eq!(d.layer_containing(a), Some(inner));
|
||
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
|
||
assert_eq!(d.selectable_art(), vec![a, g]);
|
||
for f in [
|
||
|n: &mut Node| n.visible = false,
|
||
|n: &mut Node| n.locked = true,
|
||
|n: &mut Node| {
|
||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||
*template = true;
|
||
}
|
||
},
|
||
] {
|
||
let mut d2 = d.clone();
|
||
f(d2.node_mut(inner).unwrap());
|
||
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||
assert_eq!(marquee(&d2, all, None, false), vec![g]);
|
||
assert_eq!(d2.selectable_art(), vec![g]);
|
||
}
|
||
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
|
||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
|
||
*preview = false;
|
||
}
|
||
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
|
||
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
|
||
}
|
||
|
||
/// Object Selection by Path Only: a filled path or compound path hits on its outline only.
|
||
#[test]
|
||
fn path_only_leaves_out_fills() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (a, c) = (d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default());
|
||
let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero });
|
||
compound.appearance = Appearance::default_art().into();
|
||
d.insert(Some(l), 9, compound).unwrap();
|
||
let path_only = HitOptions { path_only: true, ..Default::default() };
|
||
for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] {
|
||
assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits");
|
||
assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit");
|
||
assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does");
|
||
}
|
||
}
|
||
|
||
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
|
||
/// a path under it can be picked through it.
|
||
#[test]
|
||
fn path_only_picks_paths_through_images() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (path, image) = (d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
|
||
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
|
||
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None };
|
||
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
|
||
let path_only = HitOptions { path_only: true, ..Default::default() };
|
||
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
|
||
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
|
||
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
|
||
assert_eq!(hit(inside, HitOptions::default()), Some(image));
|
||
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
|
||
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
|
||
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
|
||
}
|
||
|
||
/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines,
|
||
/// so a click inside them, on the outline of a path behind, picks that path, and a click on the
|
||
/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before,
|
||
/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does.
|
||
#[test]
|
||
fn outline_mode_picks_paths_through_images_and_meshes() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0));
|
||
let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None };
|
||
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
|
||
d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap();
|
||
let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0));
|
||
let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap();
|
||
d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap();
|
||
let outline = HitOptions { outline: true, ..Default::default() };
|
||
let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf);
|
||
// The image: the path's edge and the inside, then the frame.
|
||
let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0));
|
||
assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path");
|
||
assert_eq!(hit(&d, inside, HitOptions::default()), Some(image));
|
||
assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image");
|
||
assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image");
|
||
assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame");
|
||
// The mesh: the path's edge and the inside, then its edge and a mesh line inside it.
|
||
let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0));
|
||
assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path");
|
||
assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh));
|
||
assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh");
|
||
assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines");
|
||
assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge");
|
||
assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line");
|
||
// Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again.
|
||
d.setup.outline_images = true;
|
||
assert_eq!(hit(&d, path_edge, outline), Some(image));
|
||
}
|
||
|
||
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
|
||
/// frame and type on a path on its path, no longer anywhere in its bounds.
|
||
#[test]
|
||
fn type_path_only_picks_type_on_its_path() {
|
||
use crate::{CharStyle, TextKind, TextObject};
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let l = d.layers[0].id;
|
||
let mut add = |t: TextObject| {
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), 9, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
|
||
id
|
||
};
|
||
// Point type at (10, 100), 20 pt: its glyphs rise above the baseline, its layout is 60 wide.
|
||
let mut point = TextObject::point(Point::new(10.0, 100.0), "Hello", CharStyle { size: 20.0, ..CharStyle::default() });
|
||
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
|
||
let point = add(point);
|
||
let mut area = TextObject::point(Point::new(200.0, 200.0), "Area", CharStyle::default());
|
||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
|
||
let area = add(area);
|
||
let mut on_path = TextObject::point(Point::new(10.0, 300.0), "Path", CharStyle::default());
|
||
on_path.kind = TextKind::OnPath { path: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)), start: 0.0, end: None };
|
||
let on_path = add(on_path);
|
||
let path_only = HitOptions { type_path_only: true, ..Default::default() };
|
||
let hit = |p: Point, opt: HitOptions| hit_test(&d, p, opt).map(|h| (h.leaf, h.kind));
|
||
// Off: anywhere in the bounds.
|
||
assert_eq!(hit(Point::new(40.0, 90.0), HitOptions::default()), Some((point, HitKind::Bounds)));
|
||
assert_eq!(hit(Point::new(250.0, 225.0), HitOptions::default()), Some((area, HitKind::Bounds)));
|
||
assert_eq!(hit(Point::new(60.0, 320.0), HitOptions::default()), Some((on_path, HitKind::Bounds)));
|
||
// On: the glyphs and the inside of the frame or path no longer pick the type.
|
||
assert_eq!(hit(Point::new(40.0, 90.0), path_only), None, "point type: among its glyphs");
|
||
assert_eq!(hit(Point::new(250.0, 225.0), path_only), None, "area type: inside its frame");
|
||
assert_eq!(hit(Point::new(60.0, 320.0), path_only), None, "type on a path: inside its path");
|
||
// The baseline (within the tolerance, across the layout's width), frame and path do.
|
||
assert_eq!(hit(Point::new(40.0, 101.5), path_only), Some((point, HitKind::Outline)), "point type: its baseline");
|
||
assert_eq!(hit(Point::new(75.0, 100.0), path_only), None, "point type: past the end of its layout");
|
||
assert_eq!(hit(Point::new(250.0, 200.5), path_only), Some((area, HitKind::Outline)), "area type: its frame");
|
||
assert_eq!(hit(Point::new(60.0, 339.5), path_only), Some((on_path, HitKind::Outline)), "type on a path: its path");
|
||
}
|
||
|
||
/// Type Object Selection by Path Only with the layout's baselines: every line's for point type,
|
||
/// area type's besides its frame, a vertical column's centre line, and type in perspective
|
||
/// where it is drawn.
|
||
#[test]
|
||
fn type_path_only_picks_every_baseline() {
|
||
use crate::{CharStyle, PerspectiveAttachment, TextKind, TextObject};
|
||
use vectorcraft_geom::{Affine, Homography};
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let l = d.layers[0].id;
|
||
let mut add = |t: TextObject, perspective: Option<Affine>| {
|
||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||
n.extras_mut().perspective = perspective.map(|a| {
|
||
Box::new(PerspectiveAttachment { projection: Some(Homography::from_affine(a).to_array()), ..PerspectiveAttachment::new("left", 0.0) })
|
||
});
|
||
let id = n.id;
|
||
d.insert(Some(l), 9, n).unwrap();
|
||
id
|
||
};
|
||
let style = CharStyle { size: 20.0, ..CharStyle::default() };
|
||
// Two lines of point type at (10, 100), 24 pt apart.
|
||
let mut point = TextObject::point(
|
||
Point::new(10.0, 100.0),
|
||
"Hello
|
||
world",
|
||
style.clone(),
|
||
);
|
||
point.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 28.0));
|
||
point.cached_baselines = vec![(Point::ZERO, Point::new(60.0, 0.0)), (Point::new(0.0, 24.0), Point::new(50.0, 24.0))];
|
||
let point = add(point, None);
|
||
// Area type: a line inside its frame.
|
||
let mut area = TextObject::point(Point::new(200.0, 50.0), "Area", style.clone());
|
||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
|
||
area.cached_baselines = vec![(Point::new(0.0, 16.0), Point::new(40.0, 16.0))];
|
||
let area = add(area, None);
|
||
// Vertical point type at (300, 200) with no baselines cached: down its first column.
|
||
let mut vertical = TextObject::point(Point::new(300.0, 200.0), "Tate", style.clone());
|
||
vertical.vertical = true;
|
||
vertical.cached_bounds = Some(Rect::new(-10.0, 0.0, 10.0, 80.0));
|
||
let vertical = add(vertical, None);
|
||
// Point type drawn 100 to the right of where it is laid out.
|
||
let mut moved = TextObject::point(Point::new(10.0, 350.0), "Moved", style);
|
||
moved.cached_bounds = Some(Rect::new(0.0, -16.0, 60.0, 4.0));
|
||
let moved = add(moved, Some(Affine::translate((100.0, 0.0))));
|
||
let path_only = HitOptions { type_path_only: true, ..Default::default() };
|
||
let hit = |p: Point| hit_test(&d, p, path_only).map(|h| h.leaf);
|
||
assert_eq!(hit(Point::new(40.0, 100.0)), Some(point), "the first line's baseline");
|
||
assert_eq!(hit(Point::new(40.0, 125.0)), Some(point), "the second line's");
|
||
assert_eq!(hit(Point::new(40.0, 112.0)), None, "between the lines");
|
||
assert_eq!(hit(Point::new(65.0, 124.0)), None, "past the end of the shorter second line");
|
||
assert_eq!(hit(Point::new(220.0, 66.0)), Some(area), "area type: a line's baseline");
|
||
assert_eq!(hit(Point::new(220.0, 80.0)), None, "area type: inside the frame, off the baselines");
|
||
assert_eq!(hit(Point::new(299.0, 260.0)), Some(vertical), "vertical type: down the column");
|
||
assert_eq!(hit(Point::new(306.0, 260.0)), None, "vertical type: beside it");
|
||
assert_eq!(hit(Point::new(140.0, 351.0)), Some(moved), "in perspective: where it is drawn");
|
||
assert_eq!(hit(Point::new(40.0, 351.0)), None, "in perspective: not where it is laid out");
|
||
}
|
||
|
||
/// `objects_at`: every object under the point, topmost first; a group counts once.
|
||
#[test]
|
||
fn objects_at_lists_the_stack() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||
let a = rect(&mut d, 0.0);
|
||
let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0));
|
||
let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id());
|
||
d.insert(Some(l), 0, a).unwrap();
|
||
d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap();
|
||
let far = rect(&mut d, 120.0);
|
||
d.insert(Some(l), 9, far).unwrap();
|
||
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
|
||
assert_eq!(at(30.0, None), vec![g, a_id]);
|
||
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
|
||
// Isolated, the art around the group is locked: clicks pass through it (#833).
|
||
assert!(at(5.0, Some(g)).is_empty());
|
||
assert!(at(130.0, Some(g)).is_empty());
|
||
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
|
||
assert_eq!(at(5.0, None), vec![a_id]);
|
||
assert!(at(90.0, None).is_empty());
|
||
}
|
||
|
||
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
|
||
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
|
||
/// the per-object walk.
|
||
#[test]
|
||
fn objects_at_large_overlapping_stack() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let layer = d.layers[0].id;
|
||
let mut ids = Vec::new();
|
||
for _ in 0..256 {
|
||
let id = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
ids.push(id);
|
||
}
|
||
ids.reverse();
|
||
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
|
||
}
|
||
|
||
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
|
||
/// invisibility, and path/outline-only settings.
|
||
#[test]
|
||
fn objects_at_one_pass_matches_repeated_hits() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let layer = d.layers[0].id;
|
||
let add = |d: &mut Document, parent, bounds| {
|
||
let id = d.alloc_id();
|
||
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
|
||
id
|
||
};
|
||
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||
let sub = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
|
||
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||
let group = d.alloc_id();
|
||
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
|
||
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
|
||
let clip = d.alloc_id();
|
||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
|
||
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
|
||
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
|
||
clipping.set_clips(true);
|
||
d.insert(Some(layer), usize::MAX, clipping).unwrap();
|
||
for (point, opt) in [
|
||
(Point::new(25.0, 25.0), HitOptions::default()),
|
||
(Point::new(3.0, 25.0), HitOptions::default()),
|
||
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
|
||
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
|
||
(Point::new(100.0, 100.0), HitOptions::default()),
|
||
] {
|
||
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
|
||
}
|
||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
|
||
*preview = false;
|
||
}
|
||
let p = Point::new(25.0, 25.0);
|
||
let opt = HitOptions::default();
|
||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||
d.node_mut(group).unwrap().locked = true;
|
||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||
}
|
||
|
||
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
|
||
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
|
||
/// in the same order as the hit test repeated over what is left.
|
||
#[test]
|
||
fn objects_at_matches_repeated_hits_on_random_documents() {
|
||
let mut seed = 0x9e37_79b9_u32;
|
||
let mut next = move |n: u32| {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
seed % n
|
||
};
|
||
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
|
||
for _ in 0..2 + next(4) {
|
||
let id = d.alloc_id();
|
||
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
|
||
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
|
||
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
|
||
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
|
||
4 | 5 => Node::group(id, Vec::new()),
|
||
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
|
||
7 => {
|
||
let mut g = Node::group(id, Vec::new());
|
||
g.set_clips(true);
|
||
g
|
||
}
|
||
_ => Node::path(id, shapes::rectangle(rect), appearance),
|
||
};
|
||
n.visible = next(8) != 0;
|
||
n.locked = next(8) == 0;
|
||
if let NodeKind::Layer { preview, .. } = &mut n.kind {
|
||
*preview = next(3) != 0;
|
||
}
|
||
let container = n.is_container();
|
||
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
|
||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||
if container {
|
||
fill(d, id, depth + 1, next);
|
||
}
|
||
}
|
||
}
|
||
for round in 0..200 {
|
||
let mut d = Document::new(120.0, 120.0);
|
||
let layer = d.layers[0].id;
|
||
fill(&mut d, layer, 0, &mut next);
|
||
// A clipping mask on the layer, or on a sublayer, now and then.
|
||
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
|
||
for id in std::iter::once(layer).chain(sublayers) {
|
||
if next(3) == 0 {
|
||
d.node_mut(id).unwrap().set_clips(true);
|
||
}
|
||
}
|
||
for _ in 0..12 {
|
||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
|
||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
|
||
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
|
||
/// one instance and 100 pt lower by an instance in a group.
|
||
#[test]
|
||
fn objects_at_finds_symbol_instances_by_their_art() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
|
||
d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
|
||
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||
let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
|
||
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
|
||
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
|
||
let opt = HitOptions::default();
|
||
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
|
||
let p = Point::new(x, y);
|
||
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
|
||
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
|
||
}
|
||
}
|
||
|
||
/// Edit Contents can expose descendants of a top-level group: preserve the original
|
||
/// selection order by falling back to the general hit traversal.
|
||
#[test]
|
||
fn objects_at_edit_contents_uses_general_order() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let layer = d.layers[0].id;
|
||
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
|
||
let (a, b) = (d.alloc_id(), d.alloc_id());
|
||
let e = Node::new(
|
||
d.alloc_id(),
|
||
NodeKind::Envelope {
|
||
content: vec![inner(a), inner(b)],
|
||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||
fidelity: 100.0,
|
||
editing: true,
|
||
options: Default::default(),
|
||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||
},
|
||
);
|
||
d.insert(Some(layer), usize::MAX, e).unwrap();
|
||
let p = Point::new(25.0, 25.0);
|
||
let opt = HitOptions::default();
|
||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
|
||
}
|
||
|
||
#[test]
|
||
fn locked_and_hidden_skip() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
d.node_mut(a).unwrap().locked = true;
|
||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||
d.node_mut(a).unwrap().locked = false;
|
||
d.node_mut(l).unwrap().visible = false;
|
||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||
}
|
||
|
||
/// Insert `n` locked at the top of `parent`.
|
||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||
let id = n.id;
|
||
n.locked = true;
|
||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||
id
|
||
}
|
||
|
||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||
#[test]
|
||
fn locked_objects_at_lists_the_locked_stack() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let rect = |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||
inner.locked = true;
|
||
let inner_id = inner.id;
|
||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||
g.locked = true;
|
||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||
let open_group = d.alloc_id();
|
||
let mut child = rect(&mut d, 25.0);
|
||
child.locked = true;
|
||
let child_id = child.id;
|
||
let free = rect(&mut d, 25.0);
|
||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||
assert_eq!(at(&d, 5.0, None), [a]);
|
||
assert!(at(&d, 150.0, None).is_empty());
|
||
// Isolated, only the group's own objects are looked at.
|
||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||
d.node_mut(b).unwrap().visible = false;
|
||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||
d.node_mut(l).unwrap().locked = true;
|
||
assert!(at(&d, 30.0, None).is_empty());
|
||
d.node_mut(l).unwrap().locked = false;
|
||
d.node_mut(l).unwrap().visible = false;
|
||
assert!(at(&d, 30.0, None).is_empty());
|
||
}
|
||
|
||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||
#[test]
|
||
fn locked_objects_at_stops_at_its_limit() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let l = d.layers[0].id;
|
||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||
.map(|_| {
|
||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||
insert_locked(&mut d, l, n)
|
||
})
|
||
.collect();
|
||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||
}
|
||
|
||
#[test]
|
||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||
let mut d = Document::new(200.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let a = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||
let p = Point::new(10.0, 10.0);
|
||
for lock in [a, l] {
|
||
d.node_mut(lock).unwrap().locked = true;
|
||
assert!(hit_test(&d, p, HitOptions::default()).is_none());
|
||
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).unwrap().leaf, a);
|
||
assert!(d.node(lock).unwrap().locked);
|
||
d.node_mut(lock).unwrap().locked = false;
|
||
}
|
||
d.node_mut(a).unwrap().visible = false;
|
||
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
|
||
d.node_mut(a).unwrap().visible = true;
|
||
d.node_mut(l).unwrap().visible = false;
|
||
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
|
||
}
|
||
|
||
/// Deterministic xorshift in 0..1.
|
||
struct Rng(u64);
|
||
impl Rng {
|
||
fn next(&mut self) -> f64 {
|
||
self.0 ^= self.0 << 13;
|
||
self.0 ^= self.0 >> 7;
|
||
self.0 ^= self.0 << 17;
|
||
(self.0 % 1_000_000) as f64 / 1_000_000.0
|
||
}
|
||
}
|
||
|
||
/// A closed blob of `n` smooth anchors round `c` (radius about `r`), turning either way, with
|
||
/// handles long enough that its control points reach past its curves.
|
||
fn blob(rng: &mut Rng, c: Point, r: f64, n: usize, clockwise: bool) -> vectorcraft_geom::SubPath {
|
||
use vectorcraft_geom::{Anchor, Vec2};
|
||
let anchors = (0..n)
|
||
.map(|i| {
|
||
let k = if clockwise { i as f64 } else { (n - i) as f64 };
|
||
let a = k / n as f64 * std::f64::consts::TAU;
|
||
let rr = r * (0.6 + 0.8 * rng.next());
|
||
let p = c + Vec2::new(a.cos(), a.sin()) * rr;
|
||
let t = Vec2::new(-a.sin(), a.cos()) * (if clockwise { 1.0 } else { -1.0 }) * rr * (0.2 + 1.3 * rng.next());
|
||
Anchor::smooth(p, p + t)
|
||
})
|
||
.collect();
|
||
vectorcraft_geom::SubPath::new(anchors, true)
|
||
}
|
||
|
||
/// A document with one compound path of `members` blobs (some of them holes), its id and the
|
||
/// members' paths.
|
||
fn compound_doc(members: usize, rule: vectorcraft_geom::FillRule, appearance: Appearance) -> (Document, NodeId, Vec<vectorcraft_geom::PathData>) {
|
||
let mut d = Document::new(800.0, 800.0);
|
||
let l = d.layers[0].id;
|
||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
|
||
let mut paths = vec![];
|
||
let mut children = vec![];
|
||
for i in 0..members {
|
||
let c = Point::new(20.0 + rng.next() * 600.0, 20.0 + rng.next() * 600.0);
|
||
let r = 3.0 + rng.next() * 9.0;
|
||
let pd = vectorcraft_geom::PathData::single(blob(&mut rng, c, r, 3 + i % 4, i % 3 != 0));
|
||
children.push(Arc::new(Node::path(d.alloc_id(), pd.clone(), Appearance::default())));
|
||
paths.push(pd);
|
||
}
|
||
let id = d.alloc_id();
|
||
let mut compound = Node::new(id, NodeKind::Compound { children, rule });
|
||
compound.appearance = appearance.into();
|
||
d.insert(Some(l), 0, compound).unwrap();
|
||
(d, id, paths)
|
||
}
|
||
|
||
/// What a click on compound path `n` gave before compound paths were culled by their control
|
||
/// points and their members near the click: the whole path assembled (`full`), culled by its
|
||
/// reach measured along the curves.
|
||
fn compound_hit_before(n: &Node, full: &vectorcraft_geom::BezPath, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||
let NodeKind::Compound { rule, .. } = &n.kind else { return None };
|
||
if !n.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||
return None;
|
||
}
|
||
if !opt.outline && n.appearance.stroke_hit(full, *rule, p, opt.tol) {
|
||
return Some(HitKind::Stroke);
|
||
}
|
||
if stroke_contains(full, 0.0, opt.tol, p) {
|
||
return Some(HitKind::Outline);
|
||
}
|
||
(!opt.outline && !opt.path_only && fill_contains(full, *rule, p)).then_some(HitKind::Fill)
|
||
}
|
||
|
||
/// The points a test clicks for `paths`: random ones over them and past them, and, for some
|
||
/// members, their anchors, points just over the tolerance from them, and the corners and edge
|
||
/// midpoints of their control and curve boxes, on them and grown by the tolerance.
|
||
fn probe_points(paths: &[vectorcraft_geom::PathData], tol: f64, random: usize, every: usize) -> Vec<Point> {
|
||
let all = paths.iter().filter_map(vectorcraft_geom::PathData::control_bounds).reduce(|a, b| a.union(b)).unwrap().inflate(30.0, 30.0);
|
||
let mut rng = Rng(7);
|
||
let mut pts: Vec<Point> = (0..random).map(|_| Point::new(all.x0 + rng.next() * all.width(), all.y0 + rng.next() * all.height())).collect();
|
||
for pd in paths.iter().step_by(every) {
|
||
for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) {
|
||
pts.extend([a.p, Point::new(a.p.x + tol, a.p.y), Point::new(a.p.x, a.p.y - tol), Point::new(a.p.x + tol * 1.0001, a.p.y)]);
|
||
}
|
||
for b in [pd.control_bounds().unwrap(), pd.bounds().unwrap()] {
|
||
for b in [b, b.inflate(tol, tol)] {
|
||
let (cx, cy) = (b.center().x, b.center().y);
|
||
pts.extend(
|
||
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1), (cx, b.y0), (cx, b.y1), (b.x0, cy), (b.x1, cy)]
|
||
.map(|(x, y)| Point::new(x, y)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
pts
|
||
}
|
||
|
||
/// A compound path of thousands of members hits exactly as it did when it was assembled
|
||
/// whole for every click: at random points, on and next to anchors, and on the edges of the
|
||
/// members' boxes (control points' and curves', with and without the tolerance), for both fill
|
||
/// rules, in outline mode and with Object Selection by Path Only.
|
||
#[test]
|
||
fn culled_compound_hits_as_the_whole_path() {
|
||
use vectorcraft_geom::FillRule;
|
||
for (rule, opt) in [
|
||
(FillRule::NonZero, HitOptions::default()),
|
||
(FillRule::EvenOdd, HitOptions { tol: 0.5, ..Default::default() }),
|
||
(FillRule::NonZero, HitOptions { outline: true, tol: 1.0, ..Default::default() }),
|
||
(FillRule::EvenOdd, HitOptions { path_only: true, ..Default::default() }),
|
||
] {
|
||
let (d, id, paths) = compound_doc(2000, rule, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
let n = d.node(id).unwrap();
|
||
let full = n.stroke_path().unwrap();
|
||
// The whole path's box grown by the tolerance. Before, a point on its far edges (right
|
||
// and bottom: `Rect::contains` leaves them out) never hit, even exactly `tol` from the
|
||
// outline; now it hits there as on the near edges. Those points are clicked too, and
|
||
// checked against the distance alone.
|
||
let far = vectorcraft_geom::Shape::bounding_box(&full).inflate(opt.tol, opt.tol);
|
||
let mut pts = probe_points(&paths, opt.tol, 200, 120);
|
||
let rightmost = paths.iter().filter_map(vectorcraft_geom::PathData::bounds).max_by(|a, b| a.x1.total_cmp(&b.x1)).unwrap();
|
||
pts.push(Point::new(far.x1, rightmost.center().y));
|
||
let (mut hits, mut kinds) = (0, HashSet::new());
|
||
for p in pts {
|
||
let before = compound_hit_before(n, &full, p, opt);
|
||
let now = hit_test(&d, p, opt);
|
||
if p.x == far.x1 || p.y == far.y1 {
|
||
let outline = vectorcraft_geom::hit::distance_to_outline(&full, p) <= opt.tol;
|
||
assert_eq!(now.is_some(), outline || before.is_some(), "{rule:?} {opt:?} on the far edge at {p:?}");
|
||
continue;
|
||
}
|
||
assert_eq!(now.as_ref().map(|h| (h.leaf, h.kind)), before.map(|k| (id, k)), "{rule:?} {opt:?} at {p:?}");
|
||
hits += usize::from(before.is_some());
|
||
kinds.extend(before.map(|k| format!("{k:?}")));
|
||
}
|
||
// The points reach both hits and misses, and the outline as well as the fill.
|
||
assert!(hits > 100, "{rule:?} {opt:?}: {hits} hits");
|
||
assert!(kinds.contains("Outline"), "{rule:?} {opt:?}: {kinds:?}");
|
||
}
|
||
}
|
||
|
||
/// A wide stroke paints far past a path's control points: clicks on it there still hit, for a
|
||
/// path and for a compound path (whose strokes are measured along all of it), exactly as when
|
||
/// they were culled by their reach measured along the curves.
|
||
#[test]
|
||
fn wide_strokes_hit_past_the_control_points() {
|
||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let l = d.layers[0].id;
|
||
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 40.0);
|
||
// A zigzag 10 pt high whose 40 pt stroke reaches 20 pt (more at its mitred turns) past it.
|
||
let zig = PathData::single(SubPath::new(
|
||
[(100.0, 100.0), (110.0, 110.0), (120.0, 100.0), (130.0, 110.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(),
|
||
false,
|
||
));
|
||
let path = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::path(path, zig.clone(), stroke.clone())).unwrap();
|
||
// A compound path of two small rings with the same stroke, further along.
|
||
let mut rng = Rng(3);
|
||
let members: Vec<Arc<Node>> = [Point::new(250.0, 250.0), Point::new(262.0, 250.0)]
|
||
.into_iter()
|
||
.map(|c| Arc::new(Node::path(d.alloc_id(), PathData::single(blob(&mut rng, c, 4.0, 4, true)), Appearance::default())))
|
||
.collect();
|
||
let compound = d.alloc_id();
|
||
let mut node = Node::new(compound, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::NonZero });
|
||
node.appearance = stroke.into();
|
||
d.insert(Some(l), 9, node).unwrap();
|
||
let symbols = d.symbol_boxes();
|
||
let opt = HitOptions::default();
|
||
for id in [path, compound] {
|
||
let n = d.node(id).unwrap();
|
||
let full = n.stroke_path().unwrap();
|
||
let control = match &n.kind {
|
||
NodeKind::Path { path, .. } => path.control_bounds().unwrap(),
|
||
_ => n.children().unwrap().iter().filter_map(|m| m.path_data()?.control_bounds()).reduce(|a, b| a.union(b)).unwrap(),
|
||
};
|
||
let (mut outside, mut checked) = (0, 0);
|
||
let area = control.inflate(40.0, 40.0);
|
||
for i in 0..=80 {
|
||
for j in 0..=80 {
|
||
let p = Point::new(area.x0 + area.width() * f64::from(i) / 80.0, area.y0 + area.height() * f64::from(j) / 80.0);
|
||
let before = match &n.kind {
|
||
NodeKind::Path { .. } => n
|
||
.reach_bounds_with(&symbols)
|
||
.is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||
.then(|| hit_leaf(n, p, opt, &symbols))
|
||
.flatten(),
|
||
_ => compound_hit_before(n, &full, p, opt),
|
||
};
|
||
let now = hit_test(&d, p, opt).filter(|h| h.leaf == id).map(|h| h.kind);
|
||
assert_eq!(now, before, "{id:?} at {p:?}");
|
||
checked += 1;
|
||
outside += usize::from(before == Some(HitKind::Stroke) && !control.contains(p));
|
||
}
|
||
}
|
||
assert!(outside > 50, "{id:?}: {outside} of {checked} points hit the stroke outside the control points' box");
|
||
}
|
||
}
|
||
|
||
/// The box a click is culled by holds the object's reach for paths and compound paths (it is
|
||
/// measured by their control points, which hold their curves, and grown by the same stroke reach),
|
||
/// so culling by it never turns away a click the reach let through. (Up to rounding: where a curve's
|
||
/// extreme is an anchor, the two boxes measure the same point in different ways.)
|
||
#[test]
|
||
fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
|
||
const E: f64 = 1e-9;
|
||
let holds =
|
||
|outer: Rect, inner: Rect| outer.x0 - E <= inner.x0 && outer.y0 - E <= inner.y0 && inner.x1 <= outer.x1 + E && inner.y1 <= outer.y1 + E;
|
||
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 12.0);
|
||
let (mut d, id, _) = compound_doc(200, vectorcraft_geom::FillRule::NonZero, stroke.clone());
|
||
let path_id = d.alloc_id();
|
||
let symbols = d.symbol_boxes();
|
||
let n = d.node(id).unwrap();
|
||
let mut checked = 0;
|
||
for node in std::iter::once(n).chain(n.children().unwrap().iter().map(|m| &**m)) {
|
||
let (cull, reach) = (cull_reach(node, &symbols).unwrap(), node.reach_bounds_with(&symbols).unwrap());
|
||
assert!(holds(cull, reach), "{:?}: {cull:?} does not hold {reach:?}", node.id);
|
||
checked += 1;
|
||
}
|
||
// A stroked plain path, its handles reaching past its curves.
|
||
let mut rng = Rng(11);
|
||
let path = Node::path(path_id, vectorcraft_geom::PathData::single(blob(&mut rng, Point::new(50.0, 50.0), 20.0, 5, true)), stroke);
|
||
let (cull, reach) = (cull_reach(&path, &symbols).unwrap(), path.reach_bounds_with(&symbols).unwrap());
|
||
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
|
||
assert_eq!(checked, 201);
|
||
}
|
||
|
||
/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does:
|
||
/// Direct Selection picks an object only where the mask shows it, and outlines it there.
|
||
#[test]
|
||
fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let (mask, art) = half_clipped(&mut d);
|
||
let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2));
|
||
if let NodeKind::Layer { children, .. } = &mut layer.kind {
|
||
*children = vec![Arc::new(mask), Arc::new(art)];
|
||
}
|
||
layer.set_clips(true);
|
||
let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]);
|
||
d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) });
|
||
let inst = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||
let n = d.node(inst).unwrap();
|
||
let opt = HitOptions::default();
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art");
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it");
|
||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap());
|
||
assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}");
|
||
// Its outline inside the group around the layer is cut too.
|
||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap());
|
||
assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}");
|
||
}
|
||
|
||
/// In a dynamic symbol's art that placing through its 9-slice guides could make more than
|
||
/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the
|
||
/// canvas draws nothing of it. At the art's natural size the same art is picked.
|
||
#[test]
|
||
fn symbol_art_past_the_object_limit_picks_nothing() {
|
||
use vectorcraft_color::{Gradient, GradientPaint};
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let l = d.layers[0].id;
|
||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||
mask.linked = false;
|
||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||
// Drawn in six cells, each with its own copy of the mask's 3,240,000 squares.
|
||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||
big.extras_mut().mask = Some(Box::new(mask));
|
||
let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art());
|
||
let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]);
|
||
d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) });
|
||
d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default()));
|
||
let scaled = d.alloc_id();
|
||
let natural = d.alloc_id();
|
||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||
d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap();
|
||
d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||
assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit");
|
||
let opt = HitOptions::default();
|
||
let n = d.node(scaled).unwrap();
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square");
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path");
|
||
assert!(symbol_part_outline(&d, n, &[0]).is_none());
|
||
assert!(symbol_part_outline(&d, n, &[1]).is_none());
|
||
let n = d.node(natural).unwrap();
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0]));
|
||
assert!(symbol_part_outline(&d, n, &[0]).is_some());
|
||
}
|
||
|
||
/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it
|
||
/// included, so an object in it is picked and outlined there even when placing that object through
|
||
/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects.
|
||
#[test]
|
||
fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() {
|
||
use vectorcraft_color::{Gradient, GradientPaint};
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let l = d.layers[0].id;
|
||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||
mask.linked = false;
|
||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||
big.extras_mut().mask = Some(Box::new(mask));
|
||
let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]);
|
||
group.set_clips(true);
|
||
let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||
let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]);
|
||
d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) });
|
||
d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default()));
|
||
let inst = d.alloc_id();
|
||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap();
|
||
let m = d.symbol_slice_map("Corner", xf).unwrap();
|
||
assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit");
|
||
assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn");
|
||
let n = d.node(inst).unwrap();
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1]));
|
||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap());
|
||
assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}");
|
||
}
|
||
|
||
/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes,
|
||
/// which the canvas does not draw: a click over an original shape whose face the group erased
|
||
/// picks nothing, and a click over the face it shows picks the face.
|
||
#[test]
|
||
fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||
sources.name = Some(crate::shaper::SOURCES.into());
|
||
sources.opacity = 0.0;
|
||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||
group.extras_mut().shaper = Some(Box::default());
|
||
let root = Node::group(d.alloc_id(), vec![Arc::new(group)]);
|
||
d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) });
|
||
let inst = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||
let n = d.node(inst).unwrap();
|
||
let opt = HitOptions::default();
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape");
|
||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face");
|
||
}
|