diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 44899b3d7..d5d665e75 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -1680,1072 +1680,4 @@ pub fn create(id: &str) -> Option> { } #[cfg(test)] -mod tests { - 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()); - } -} +mod tests; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs new file mode 100644 index 000000000..2a0f5b1a7 --- /dev/null +++ b/crates/tools/src/builder/tests.rs @@ -0,0 +1,1067 @@ +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()); +}