Files
vectorcraft/crates/doc/src/hit/tests.rs
T
HassanKhan a87d996e1d Outline view: a click on a path's outline selects it through an image or a gradient mesh in front (#6437)
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.
2026-11-11 16:38:59 +05:00

1649 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}