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) { 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 { 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::>() }))]; 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::>()); }; 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::>(), "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()); }