Files
vectorcraft/crates/tools/src/builder/tests.rs
T

1067 lines
53 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::testutil::*;
use vectorcraft_doc::{Appearance, Selection};
use vectorcraft_geom::shapes;
fn two_rects() -> (Document, NodeId, NodeId) {
two_rects_at(0.0)
}
/// Squares A (`x`..`x`+100) and B (`x`+50..`x`+150), 100 pt tall, overlapping by half.
fn two_rects_at(x: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let a = d.alloc_id();
d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default_art())).unwrap();
let b = d.alloc_id();
d.insert(Some(l), 1, Node::path(b, shapes::rectangle(Rect::new(x + 50.0, 0.0, x + 150.0, 100.0)), Appearance::default_art())).unwrap();
(d, a, b)
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn shape_builder_hover_highlights_region() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)).is_empty());
let ov = t.overlays(&cx);
assert_eq!(ov.len(), 2);
let Overlay::Path { path, .. } = &ov[1] else { panic!() };
let bb = path.bounding_box();
assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}");
t.pointer(&cx, &ev(PointerKind::Move, 400.0, 400.0));
assert!(t.overlays(&cx).is_empty());
}
#[test]
fn shape_builder_drag_emits_merge() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([b, a]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 50.0));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 50.0));
assert_eq!(t.touched.regions.len(), 2);
assert!(t.busy());
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(id, "shapeBuilder.merge");
assert_eq!(v["erase"], false);
assert_eq!(v["ids"], json!([a.0, b.0]), "sorted back to front");
assert!(v["points"].as_array().unwrap().len() >= 2);
}
#[test]
fn shape_builder_rebuilds_its_map_for_a_new_revision_at_the_same_address() {
let (mut d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let mut t = ShapeBuilderTool::default();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0));
// A later revision of the document, with the squares 300 pt to the right, at the same address.
d = two_rects_at(300.0).0;
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Down, 325.0, 50.0));
t.pointer(&cx2, &ev(PointerKind::Drag, 375.0, 50.0));
let acts = t.pointer(&cx2, &ev(PointerKind::Up, 375.0, 50.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
}
#[test]
fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() {
let (d, s) = corner_rects();
let p = paint();
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0));
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
// Alt hovering over an edge uses the same map.
t.pointer(&cx1, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt));
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
assert_eq!(t.cache.builds, 1, "a hover then an Alt hover");
t.pointer(&cx1, &ev(PointerKind::Down, 25.0, 25.0));
t.pointer(&cx1, &ev(PointerKind::Drag, 75.0, 75.0));
let acts = t.pointer(&cx1, &ev(PointerKind::Up, 75.0, 75.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
// The merge left the document as it was (it failed, say): the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing");
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
}
/// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each.
fn square_row(n: usize) -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for k in 0..n {
let x = k as f64 * 10.0;
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
/// Polls `t`, as the desktop app does each frame, until it stops waiting.
fn poll_until_ready(t: &mut dyn Tool, cx: &ToolContext) {
let start = std::time::Instant::now();
while t.waiting() {
assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting");
std::thread::sleep(std::time::Duration::from_millis(2));
t.poll(cx, false);
}
}
#[test]
fn shape_builder_hover_builds_a_large_map_on_another_thread() {
let (d, s) = square_row(40);
let p = paint();
let mut t = ShapeBuilderTool::default();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let hover = ev(PointerKind::Move, 15.0, 10.0);
t.pointer(&cx1, &hover);
assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built");
assert!(t.waiting() && t.hover.is_none() && t.overlays(&cx1).is_empty(), "{:?}", t.hover);
poll_until_ready(&mut t, &cx1);
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline");
// A new revision while the map of the one before it is being built: only the newer one's
// map is used.
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) };
t.pointer(&cx2, &hover);
t.pointer(&cx3, &hover);
poll_until_ready(&mut t, &cx3);
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's");
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3)));
// A host without frames waits for the map.
let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) };
t.pointer(&cx4, &hover);
assert!(t.waiting() && t.hover.is_none());
t.poll(&cx4, true);
assert!(!t.waiting());
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (4, 4));
// A press while the map is being built waits for it, and the drag merges.
let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) };
t.pointer(&cx5, &hover);
t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0));
t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0));
let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
assert_eq!((t.cache.builds, t.cache.background), (5, 5));
assert!(!t.waiting());
// Changing an option forgets a map being built with the old one.
let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) };
t.pointer(&cx6, &hover);
t.set_option("gapDetection", &json!(true));
assert!(!t.waiting());
}
#[test]
fn shape_builder_hover_builds_a_small_map_at_once() {
let (d, s) = square_row(3);
let p = paint();
let mut t = ShapeBuilderTool::default();
let cx = cx(&d, &s, &p);
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (1, 0));
}
#[test]
fn shape_builder_alt_drag_erases_and_empty_drag_does_nothing() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0).with_mods(alt));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 125.0, 50.0).with_mods(alt));
let Action::Exec(_, v) = &acts[0] else { panic!() };
assert_eq!(v["erase"], true);
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)).is_empty());
}
/// Squares A (0..100) and B (50..150 both ways), overlapping at a corner, selected.
fn corner_rects() -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for (k, r) in [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 50.0, 150.0, 150.0)].into_iter().enumerate() {
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(r), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
fn colors(ov: &[Overlay]) -> Vec<[u8; 3]> {
ov.iter().map(|o| if let Overlay::Path { color, .. } = o { *color } else { panic!("{o:?}") }).collect()
}
#[test]
fn shape_builder_alt_switches_to_erase_mode_while_held() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
let at = Point::new(75.0, 75.0);
// Merge mode: a grey mesh and the plus pointer.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// Alt held: what would go highlights red, and the pointer shows a minus.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y).with_mods(alt));
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 2]);
assert_eq!(t.cursor(&cx, at, alt), Cursor::ShapeBuilderErase);
// Released: merge mode again.
t.pointer(&cx, &ev(PointerKind::Move, at.x, at.y));
assert_eq!(colors(&t.overlays(&cx)), [HATCH; 2]);
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilder);
// A drag keeps the mode it started in.
t.pointer(&cx, &ev(PointerKind::Down, at.x, at.y).with_mods(alt));
assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::ShapeBuilderErase);
let acts = t.pointer(&cx, &ev(PointerKind::Up, at.x, at.y));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(v["erase"], true);
}
#[test]
fn shape_builder_alt_on_an_edge_highlights_and_deletes_it() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
// A's side inside B, between the points where B's outline crosses it.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0).with_mods(alt));
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
let ov = t.overlays(&cx);
let [Overlay::Path { path, color: HIGHLIGHT_RED, width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(50.0, 50.0, 100.0, 100.0));
// Without Alt it's the region under the pointer.
t.pointer(&cx, &ev(PointerKind::Move, 101.0, 75.0));
assert!(matches!(t.hover, Some(Hit::Region(_))));
t.pointer(&cx, &ev(PointerKind::Down, 101.0, 75.0).with_mods(alt));
assert_eq!(t.touched.edges.len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Up, 101.0, 75.0).with_mods(alt));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
}
#[test]
fn shape_builder_erase_drag_into_regions_shows_the_regions_not_the_edges_it_crosses() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0).with_mods(alt));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(alt));
assert_eq!(t.touched.regions.len(), 2);
assert!(!t.touched.edges.is_empty(), "the drag crossed B's outline");
assert!(t.cache.map.erased_edges(&t.touched).is_empty(), "which goes with the regions");
// Two regions (mesh + outline each) and the drag's trail, all red.
assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]);
}
/// Rect A (0..100) and rect B (98..198), both 100 tall, selected: they overlap in a 2 pt strip.
fn sliver_rects() -> (Document, Selection) {
rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(98.0, 0.0, 198.0, 100.0)])
}
/// The rectangles `rs`, back to front, selected.
fn rects(rs: &[Rect]) -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for (k, r) in rs.iter().enumerate() {
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(*r), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
fn map_of(d: &Document, s: &Selection) -> BuilderMap {
BuilderMap::new(d, &sorted_roots(d, &s.objects), true, true).0
}
#[test]
fn shape_builder_erase_drag_across_an_overlap_narrower_than_twice_the_tolerance_touches_it() {
let (d, s) = sliver_rects();
let map = map_of(&d, &s);
let strip = map.region_at(Point::new(99.0, 50.0)).unwrap();
// Every point of the strip is within 4 pt of its edges.
let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(150.0, 50.0)], Some(4.0));
assert_eq!(t.regions.len(), 3, "{t:?}");
assert!(t.regions.contains(&strip), "{t:?}");
assert!(map.erased_edges(&t).is_empty(), "the strip's edges go with it");
// A drag at a shallow angle goes about 10 pt through the strip and touches it too.
let t = map.builder_hits_along(&[Point::new(90.0, 10.0), Point::new(106.0, 90.0)], Some(4.0));
assert_eq!(t.regions.len(), 3, "{t:?}");
// So does a drag that starts in the strip, and one that goes into it and back out.
let t = map.builder_hits_along(&[Point::new(99.0, 50.0), Point::new(150.0, 50.0)], Some(4.0));
assert!(t.regions.contains(&strip), "{t:?}");
let t = map.builder_hits_along(&[Point::new(50.0, 50.0), Point::new(99.0, 50.0), Point::new(50.0, 51.0)], Some(4.0));
assert!(t.regions.contains(&strip), "{t:?}");
// Merging is unchanged, and so is the plain hit test other tools use.
assert_eq!(map.builder_hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Region(strip)));
assert!(matches!(map.hit(Point::new(99.0, 50.0), Some(4.0)), Some(Hit::Edge(_))));
assert_eq!(map.builder_hit(Point::new(99.0, 50.0), None), Some(Hit::Region(strip)));
}
#[test]
fn shape_builder_erase_drag_across_a_thin_bar_touches_it_from_either_side() {
// A 1.5 pt bar inside a square, and the same bar on its own.
let bar = Rect::new(49.0, 10.0, 50.5, 90.0);
for rs in [vec![Rect::new(0.0, 0.0, 100.0, 100.0), bar], vec![bar]] {
let (d, s) = rects(&rs);
let map = map_of(&d, &s);
let inner = map.region_at(Point::new(49.75, 50.0)).unwrap();
let t = map.builder_hits_along(&[Point::new(20.0, 50.0), Point::new(80.0, 50.0)], Some(4.0));
assert!(t.regions.contains(&inner), "{rs:?}: {t:?}");
assert_eq!(t.regions.len(), rs.len(), "{rs:?}: {t:?}");
// 0.1 pt from an end, the nearest outline is the end, and the bar is still 1.5 pt
// across from its sides: a click there and a drag across the bar there touch it.
assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}");
assert_eq!(map.builder_hit(Point::new(49.75, 89.9), Some(4.0)), Some(Hit::Region(inner)), "{rs:?}");
let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0));
assert!(t.regions.contains(&inner), "{rs:?}: {t:?}");
}
// Near a corner of the square, both of its edges there are close, and the square is wide
// across from each: the edge is touched.
let (d, s) = rects(&[Rect::new(0.0, 0.0, 100.0, 100.0), bar]);
let map = map_of(&d, &s);
assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_))));
// So it is when the square's top edge has a point 2 pt from the corner: the end of the
// segment past that point is not where the square is narrow.
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [(0.0, 0.0), (2.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let path = PathData::new(vec![SubPath::polyline(&pts, true)]);
d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.add(id);
let map = map_of(&d, &s);
let square = map.region_at(Point::new(50.0, 50.0)).unwrap();
assert_eq!(map.regions[square].1.segments().count(), 5, "the point on the top edge stays");
assert!(matches!(map.builder_hit(Point::new(1.0, 1.0), Some(4.0)), Some(Hit::Edge(_))));
// A bar whose sides each have a point level with a click 0.1 pt from its end: the click
// and a drag across the bar there still touch the bar.
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let pts = [(49.0, 10.0), (50.5, 10.0), (50.5, 10.1), (50.5, 90.0), (49.0, 90.0), (49.0, 10.1)].map(|(x, y)| Point::new(x, y));
let path = PathData::new(vec![SubPath::polyline(&pts, true)]);
d.insert(Some(l), 0, Node::path(id, path, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.add(id);
let map = map_of(&d, &s);
let split = map.region_at(Point::new(49.75, 50.0)).unwrap();
assert_eq!(map.regions[split].1.segments().count(), 6, "the points on the sides stay");
assert_eq!(map.builder_hit(Point::new(49.75, 10.1), Some(4.0)), Some(Hit::Region(split)));
let t = map.builder_hits_along(&[Point::new(20.0, 10.1), Point::new(80.0, 10.1)], Some(4.0));
assert!(t.regions.contains(&split), "{t:?}");
}
#[test]
fn shape_builder_erase_drag_along_an_edge_does_not_touch_the_regions_beside_it() {
let (d, s) = grid();
let map = map_of(&d, &s);
// 1 pt above the edges between the top row and the middle one, through all three squares.
let t = map.builder_hits_along(&[Point::new(50.0, 99.0), Point::new(250.0, 99.0)], Some(4.0));
assert!(t.regions.is_empty(), "{t:?}");
assert!(!t.edges.is_empty());
// Crossing the edge 2 pt before a junction clips the square below without touching it.
let pts = [Point::new(50.0, 99.0), Point::new(97.0, 99.0), Point::new(99.0, 101.0), Point::new(150.0, 101.0)];
let t = map.builder_hits_along(&pts, Some(4.0));
let clipped = map.region_at(Point::new(99.0, 101.0)).unwrap();
assert!(!t.regions.contains(&clipped), "{t:?}");
}
#[test]
fn shape_builder_alt_hover_over_a_narrow_overlap_highlights_it() {
let (d, s) = sliver_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Move, 99.0, 50.0).with_mods(alt));
let strip = t.cache.map.region_at(Point::new(99.0, 50.0));
assert_eq!(t.hover, strip.map(Hit::Region));
// Beside a wide region, the edge.
t.pointer(&cx, &ev(PointerKind::Move, 50.0, 99.0).with_mods(alt));
assert!(matches!(t.hover, Some(Hit::Edge(_))), "{:?}", t.hover);
}
#[test]
fn shape_builder_alt_drag_highlights_an_overlap_it_crosses_over_two_moves() {
let (d, s) = sliver_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(alt));
// One move ends inside the strip, and the next one leaves it.
t.pointer(&cx, &ev(PointerKind::Drag, 99.0, 50.0).with_mods(alt));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 50.0).with_mods(alt));
assert_eq!(t.touched.regions.len(), 3, "{:?}", t.touched);
assert!(t.cache.map.erased_edges(&t.touched).is_empty());
let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 50.0).with_mods(alt));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(4.0)));
}
/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected.
fn grid() -> (Document, Selection) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let mut s = Selection::default();
for k in 0..9 {
let (x, y) = ((k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0);
let id = d.alloc_id();
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 100.0, y + 100.0)), Appearance::default_art())).unwrap();
s.add(id);
}
(d, s)
}
/// The paths of an overlay list (a shaded region draws two: its mesh and its outline), and its
/// marquees.
fn paths_and_marquees(ov: &[Overlay]) -> (usize, Vec<Rect>) {
let paths = ov.iter().filter(|o| matches!(o, Overlay::Path { .. })).count();
(paths, ov.iter().filter_map(|o| if let Overlay::Marquee(r) = o { Some(*r) } else { None }).collect())
}
#[test]
fn shape_builder_shift_drag_highlights_and_merges_every_region_in_its_marquee() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let shift = Mods { shift: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0).with_mods(shift));
// Shift may be let go once the drag is on.
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0));
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
assert_eq!(t.touched.regions.len(), 9, "{:?}", t.touched);
assert_eq!(t.touched.regions[0], t.cache.map.region_at(Point::new(250.0, 250.0)).unwrap(), "where the drag started comes first");
assert_eq!(paths_and_marquees(&t.overlays(&cx)), (18, vec![Rect::new(50.0, 50.0, 250.0, 250.0)]), "nine regions, no trail");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["rect"], &v["erase"]), ("shapeBuilder.merge", &json!([250.0, 250.0, -200.0, -200.0]), &json!(false)));
assert!(v.get("points").is_none(), "{v}");
assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee goes with the drag");
}
#[test]
fn shape_builder_alt_shift_drag_marks_the_regions_in_its_marquee_for_deleting() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
// A narrow marquee down the middle column.
t.pointer(&cx, &ev(PointerKind::Down, 140.0, 50.0).with_mods(alt_shift));
t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 250.0).with_mods(alt_shift));
assert_eq!(t.touched.regions.len(), 3);
let ov = t.overlays(&cx);
assert!(ov.iter().all(|o| matches!(o, Overlay::Marquee(_) | Overlay::Path { color: HIGHLIGHT_RED, .. })), "{ov:?}");
assert_eq!(paths_and_marquees(&ov).0, 6, "three regions");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 250.0).with_mods(alt_shift));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((&v["rect"], &v["erase"]), (&json!([140.0, 50.0, 20.0, 200.0]), &json!(true)));
}
/// Without Shift a drag still touches what the pointer passes over; a Shift-click that does not
/// move is a click.
#[test]
fn shape_builder_drag_without_shift_and_shift_click_send_points() {
let (d, s) = grid();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0));
t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0));
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
assert_eq!(t.touched.regions.len(), 3, "the diagonal");
let (paths, marquees) = paths_and_marquees(&t.overlays(&cx));
assert_eq!((paths, marquees.len()), (7, 0), "three regions and the trail");
let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[250.0, 250.0], [150.0, 150.0], [50.0, 50.0]]));
assert!(v.get("rect").is_none(), "{v}");
let shift = Mods { shift: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(t.touched.regions.len(), 1);
let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0).with_mods(shift));
let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!((&v["points"], v.get("rect")), (&json!([[150.0, 150.0]]), None));
}
#[test]
fn marquee_regions_touch_crossed_outlines_held_anchors_and_the_region_around_it() {
let (d, s) = grid();
let (map, _) = BuilderMap::new(&d, &sorted_roots(&d, &s.objects), false, true);
let at = |x: f64, y: f64| map.region_at(Point::new(x, y)).unwrap();
// Wholly inside the middle square, then crossing into the one to its right.
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(180.0, 180.0)), [at(150.0, 150.0)]);
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(220.0, 180.0)), [at(150.0, 150.0), at(250.0, 150.0)]);
// Ending on a shared side does not reach the square past it.
assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(200.0, 180.0)), [at(150.0, 150.0)]);
// Around the whole grid, from outside it: every square, with no region under either corner.
assert_eq!(map.regions_in_marquee(Point::new(-10.0, -10.0), Point::new(310.0, 310.0)).len(), 9);
assert!(map.regions_in_marquee(Point::new(400.0, 400.0), Point::new(450.0, 450.0)).is_empty());
// Where the drag started first, then where it ends, whichever way it runs.
for (a, b) in [((50.0, 50.0), (250.0, 250.0)), ((250.0, 250.0), (50.0, 50.0)), ((250.0, 50.0), (50.0, 250.0))] {
let got = map.regions_in_marquee(a.into(), b.into());
assert_eq!((got.len(), got[0], got[1]), (9, at(a.0, a.1), at(b.0, b.1)), "{a:?} to {b:?}");
}
}
/// With In Merge Mode, Clicking Stroke Splits the Path on, a Shift-drag that starts on a stroke
/// sends its marquee and no split, however little it moves; a Shift-click there splits the
/// stroke as a click does.
#[test]
fn shape_builder_shift_drag_on_a_stroke_merges_and_a_shift_click_splits() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.set_option("clickStrokeSplitsPath", &json!(true));
let shift = Mods { shift: true, ..Default::default() };
// 1 pt outside A's left side, a move of less than 3 pixels, and one across the art.
for (to, rect) in [((0.5, 26.0), json!([-1.0, 25.0, 1.5, 1.0])), ((75.0, 75.0), json!([-1.0, 25.0, 76.0, 50.0]))] {
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift));
let acts = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1).with_mods(shift));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["rect"], v.get("points"), v.get("splitStroke")), (&rect, None, None), "{v}");
}
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0).with_mods(shift));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["splitStroke"], &v["points"], v.get("rect")), (&json!(true), &json!([[-1.0, 25.0]]), None), "{v}");
}
/// While a Shift-drag that started over a stroke a click would split draws its marquee, the
/// pointer shows the Shape Builder, not the scissors, and the stroke is not highlighted.
#[test]
fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
t.set_option("clickStrokeSplitsPath", &json!(true));
let shift = Mods { shift: true, ..Default::default() };
let highlighted = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Path { color: STROKE_HIGHLIGHT, width: 3.0, .. }));
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), shift), Cursor::Scissors);
assert!(highlighted(&t.overlays(&cx)));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(shift));
let ov = t.overlays(&cx);
assert!(ov.iter().any(|o| matches!(o, Overlay::Marquee(_))) && !highlighted(&ov), "{ov:?}");
assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder);
}
#[test]
fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() {
let (d, s) = corner_rects();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeBuilderTool::default();
let o = t.options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f")));
// With the option off, the pointer 1 pt outside A's left side highlights nothing.
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::ShapeBuilder);
t.set_option("clickStrokeSplitsPath", &json!(true));
t.set_option("highlightColor", &json!("#00ff00"));
// A value that is not a color or not a switch is ignored.
t.set_option("highlightColor", &json!("nope"));
t.set_option("highlightStroke", &json!("off"));
let o = t.options();
assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#00ff00")));
// On, the tool draws A's left side from corner to corner in the highlight color and shows the scissors.
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0));
let ov = t.overlays(&cx);
let [Overlay::Path { path, color: [0, 0xff, 0], width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 0.0, 100.0));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::Scissors);
// With Alt the tool is in erase mode, which deletes edges and does not split them.
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0).with_mods(alt));
assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), alt), Cursor::ShapeBuilderErase);
assert!(t.stroke.is_none());
// A click splits; the command gets the point, the switch and the tolerance.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0));
let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((id.as_str(), &v["splitStroke"], &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(false), &json!(4.0)));
// A release up to 3 pixels from the press is still a click, which splits where the press was.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, -1.5, 26.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 0.5, 27.0));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!((&v["splitStroke"], &v["points"]), (&json!(true), &json!([[-1.0, 25.0]])));
// A release farther away is a drag, which splits nothing.
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, -1.0, 30.0)).is_empty());
// So is a Straight Line drag that goes away and ends back near the press.
t.set_option("selection", &json!("straightLine"));
t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 25.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, -0.5, 25.5));
assert!(!acts.iter().any(|a| matches!(a, Action::Exec(_, v) if v.get("splitStroke").is_some())), "{acts:?}");
t.set_option("selection", &json!("freeform"));
// A drag merges as before and sends no switch.
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0));
t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0));
let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 75.0));
let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") };
assert!(v.get("splitStroke").is_none(), "{v}");
}
#[test]
fn hatch_stays_inside_region() {
let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath();
let h = hatch(&bp, 4.0);
let bb = h.bounding_box();
assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 40.0 + 1e-6 && bb.y1 <= 20.0 + 1e-6);
assert!(h.elements().len() > 10);
}
/// An empty hidden sources group for a Live Paint group made in a test.
fn empty_sources(d: &mut Document) -> Arc<Node> {
let mut g = Node::group(d.alloc_id(), vec![]);
g.name = Some(SOURCES_NAME.into());
g.visible = false;
Arc::new(g)
}
fn live_group_doc() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let f1 = d.alloc_id();
let f2 = d.alloc_id();
let e = d.alloc_id();
let mut face1 = Node::path(f1, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default());
face1.name = Some(FACE_NAME.into());
let mut face2 = Node::path(f2, shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 50.0)), Appearance::default());
face2.name = Some(FACE_NAME.into());
let mut edge = Node::path(e, shapes::line(Point::new(50.0, 0.0), Point::new(50.0, 50.0)), Appearance::default());
edge.name = Some(EDGE_NAME.into());
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![empty_sources(&mut d), Arc::new(face1), Arc::new(face2), Arc::new(edge)]);
g.name = Some(LIVE_PAINT_NAME.into());
d.insert(Some(l), 0, g).unwrap();
(d, gid, f2, e)
}
/// Adds a Live Paint group of one face `r` with its four sides as edges (left, top, right,
/// bottom) in front of the layer's art; returns the group and the edges.
fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) {
let l = d.layers[0].id;
let mut face = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default());
face.name = Some(FACE_NAME.into());
let (a, b, c, e) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
let mut kids = vec![empty_sources(d), Arc::new(face)];
let mut ids = [NodeId(0); 4];
for (k, (from, to)) in [(e, a), (a, b), (b, c), (c, e)].into_iter().enumerate() {
ids[k] = d.alloc_id();
let mut edge = Node::path(ids[k], shapes::line(from, to), Appearance::default());
edge.name = Some(EDGE_NAME.into());
kids.push(Arc::new(edge));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, kids);
g.name = Some(LIVE_PAINT_NAME.into());
let at = d.layers[0].children().map_or(0, |c| c.len());
d.insert(Some(l), at, g).unwrap();
(gid, ids)
}
#[test]
fn bucket_click_fills_face_and_shift_click_strokes_edge() {
let (d, gid, f2, _e) = live_group_doc();
assert_eq!(live_paint_at(&d, Point::new(75.0, 25.0)), Some(gid));
assert_eq!(face_at(d.node(gid).unwrap(), Point::new(75.0, 25.0)).unwrap().id, f2);
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
let ov = t.overlays(&cx);
assert!(matches!(&ov[0], Overlay::Path { color: HIGHLIGHT_RED, .. }));
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["group"], gid.0);
let shift = Mods { shift: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(shift));
let Action::Exec(id, _) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.strokeEdge");
}
#[test]
fn bucket_on_plain_selection_makes_live_paint() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintBucketTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0));
let Action::Exec(id, v) = &acts[0] else { panic!() };
assert_eq!(id, "livePaint.fill");
assert_eq!(v["ids"], json!([a.0, b.0]));
}
#[test]
fn bucket_rebuilds_its_map_for_a_new_revision_at_the_same_address() {
let (mut d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let mut t = LivePaintBucketTool::default();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0));
// A later revision of the document, with the squares 300 pt to the right, at the same address.
d = two_rects_at(300.0).0;
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
let acts = t.pointer(&cx2, &ev(PointerKind::Down, 375.0, 50.0));
assert_eq!(acts, vec![Action::Exec("livePaint.fill".into(), json!({ "ids": [a.0, b.0], "point": [375.0, 50.0] }))]);
}
#[test]
fn bucket_keeps_its_map_while_the_document_is_unchanged() {
let (d, a, b) = two_rects();
let mut s = Selection::default();
s.set([a, b]);
let p = paint();
let mut t = LivePaintBucketTool::default();
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 50.0));
let acts = t.pointer(&cx1, &ev(PointerKind::Down, 75.0, 50.0));
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "livePaint.fill"), "{acts:?}");
// The fill left the document as it was: the map still fits it.
t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing");
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0));
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
}
#[test]
fn bucket_hover_builds_a_large_map_on_another_thread() {
let (d, s) = square_row(40);
let p = paint();
let mut t = LivePaintBucketTool::default();
let cx = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
let hover = ev(PointerKind::Move, 15.0, 10.0);
t.pointer(&cx, &hover);
assert_eq!(t.cache.builds, 0, "the first hover returns before the map is built");
assert!(t.waiting() && t.hover == LpHover::None, "{:?}", t.hover);
poll_until_ready(&mut t, &cx);
assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
assert_eq!(t.overlays(&cx).len(), 1);
}
#[test]
fn bucket_takes_the_map_it_waits_for_while_hovering_a_live_paint_group() {
let (mut d, s) = square_row(40);
live_square(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
let p = paint();
let mut t = LivePaintBucketTool::default();
let cx = cx(&d, &s, &p);
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert!(t.waiting());
t.pointer(&cx, &ev(PointerKind::Move, 150.0, 150.0));
assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover);
poll_until_ready(&mut t, &cx);
assert!(matches!(t.hover, LpHover::Part(_)), "{:?}", t.hover);
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
}
#[test]
fn live_paint_selection_hover_builds_no_map_of_the_selection() {
let (d, s) = square_row(3);
let p = paint();
let mut t = LivePaintSelectionTool::default();
let cx = cx(&d, &s, &p);
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
assert_eq!(t.hover, LpHover::None);
assert_eq!(t.cache.builds, 0);
assert!(!t.waiting());
}
#[test]
fn live_paint_selection_selects_faces() {
let (d, _gid, f2, e) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]);
let alt = Mods { alt: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn live_paint_selection_options_choose_what_a_click_selects() {
let (d, _gid, f2, e) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let none = vec![Action::Exec("select.none".into(), json!({}))];
let mut t = LivePaintSelectionTool::default();
assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0}));
// Select Fills off: a click inside a face selects nothing, and a click by an edge selects it.
t.set_option("selectFills", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none);
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e]));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e]));
// Select Strokes off: Alt-click by an edge selects the face under the pointer.
t.set_option("selectFills", &json!(true));
t.set_option("selectStrokes", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2]));
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt));
let ov = t.overlays(&cx);
let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(50.0, 0.0, 100.0, 50.0), "the face, not the edge");
// Both off: nothing is selected or highlighted.
t.set_option("selectFills", &json!(false));
for m in [Mods::default(), alt] {
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none);
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m));
assert!(t.overlays(&cx).is_empty());
}
let shift = Mods { shift: true, ..Default::default() };
assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty());
}
#[test]
fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() {
let mut d = Document::new(500.0, 500.0);
let (_, [_, _, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let mut t = LivePaintSelectionTool::default();
// Alt held, and Select Fills off without Alt: 2 pt right of the square, the pointer
// highlights the square's right side, and a click selects that side (Shift-click adds it).
for (fills, m) in [(true, alt), (false, Mods::default())] {
t.set_option("selectFills", &json!(fills));
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 50.0).with_mods(m));
let ov = t.overlays(&cx);
let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0));
let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m }));
assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]);
}
// Farther than 4 pixels from every edge: no highlight, and a click selects nothing.
t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
#[test]
fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() {
let mut d = Document::new(500.0, 500.0);
let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0));
let s = Selection::default();
let p = paint();
let alt = Mods { alt: true, ..Default::default() };
let shift = Mods { shift: true, ..Default::default() };
// 1.5 pt from the back group's right side and 2.5 pt from the front group's left side.
let (x, y) = (101.5, 50.0);
let edge_x = |ov: &[Overlay]| match ov {
[] => None,
[Overlay::Path { path, .. }] => Some(path.bounding_box().x0),
_ => panic!("{ov:?}"),
};
// Live Paint Selection (Alt) and Live Paint Bucket (Shift) highlight and pick the same edge.
let check = |d: &Document, want: Option<(NodeId, NodeId, f64)>| {
let cx = cx(d, &s, &p);
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(alt));
assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2));
let picked = match want {
Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })),
None => Action::Exec("select.none".into(), json!({})),
};
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]);
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift));
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2));
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 })));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(shift)), stroked.into_iter().collect::<Vec<_>>());
};
check(&d, Some((back, back_right, 100.0)));
// A hidden group's edges are not highlighted or picked.
d.node_mut(back).unwrap().visible = false;
check(&d, Some((front, front_left, 104.0)));
d.node_mut(front).unwrap().visible = false;
check(&d, None);
}
#[test]
fn live_paint_tools_skip_hidden_edges() {
let mut d = Document::new(500.0, 500.0);
let (_, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0));
// The back group's right side is hidden on its own; both groups stay visible.
d.node_mut(back_right).unwrap().visible = false;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let shift = Mods { shift: true, ..Default::default() };
let edge_x = |ov: &[Overlay]| match ov {
[] => None,
[Overlay::Path { path, .. }] => Some(path.bounding_box().x0),
_ => panic!("{ov:?}"),
};
// Outside the faces, 1.5 pt from the hidden side and 2.5 pt from the front group's left
// side, both tools highlight and pick the visible side. Inside the back face, 1.5 pt from
// the hidden side, no edge is in reach, so neither tool highlights or picks one.
for (x, want) in [(101.5, Some((front, front_left, 104.0))), (98.5, None)] {
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(alt));
assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2), "x {x}");
let picked = match want {
Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })),
None => Action::Exec("select.none".into(), json!({})),
};
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}");
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift));
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}");
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 })));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(shift)), stroked.into_iter().collect::<Vec<_>>(), "x {x}");
}
}
#[test]
fn live_paint_tools_skip_hidden_faces() {
let mut d = Document::new(500.0, 500.0);
let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0));
let face = |g: NodeId| faces(d.node(g).unwrap())[0].id;
let (back_face, hidden) = (face(back), face(front));
// The front group's face is hidden on its own; both groups stay visible.
d.node_mut(hidden).unwrap().visible = false;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Default::default() };
let part = |ov: &[Overlay]| match ov {
[] => None,
[Overlay::Path { path, .. }] => Some(path.bounding_box()),
_ => panic!("{ov:?}"),
};
// Where the faces overlap, both tools take the back group's face under the hidden one.
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]);
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]);
// Over the hidden face alone, neither tool highlights or picks a face.
bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), None);
assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty());
sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), None);
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
// Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side
// as it does outside every face.
sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0)));
let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt));
assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]);
}
#[test]
fn live_paint_selection_options_set_the_highlight() {
let (d, ..) = live_group_doc();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
let highlight = |t: &mut LivePaintSelectionTool| {
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
match t.overlays(&cx).as_slice() {
[Overlay::Path { color, width, .. }] => (*color, *width),
ov => panic!("{ov:?}"),
}
};
assert_eq!(highlight(&mut t), (HIGHLIGHT_RED, 4.0));
t.set_option("color", &json!("#00FF00"));
t.set_option("width", &json!(9));
assert_eq!(highlight(&mut t), ([0, 0xff, 0], 9.0));
// A layer color's name; a value that is not a color, or a width that is not a number, is ignored.
t.set_option("color", &json!("light blue"));
t.set_option("width", &json!("wide"));
assert_eq!(highlight(&mut t), ([0x4f, 0x80, 0xff], 9.0));
t.set_option("color", &json!("nope"));
assert_eq!(t.options()["color"], "#4f80ff");
// The width stays within its range.
t.set_option("width", &json!(100));
assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.1));
t.set_option("width", &json!(0));
assert_eq!(t.options()["width"], json!(LP_SELECT_WIDTH_RANGE.0));
}
#[test]
fn create_builds_the_three_tools() {
for id in ["shapeBuilder", "livePaintBucket", "livePaintSelection"] {
assert_eq!(create(id).unwrap().id(), id);
}
assert!(create("pen").is_none());
}