//! Hit testing and polyline simplification tests. use notescraft_annotate::{Annotations, ColorRef, Item, Point, StrokeTool, simplify}; /// A line item helper. fn line(from: (f32, f32), to: (f32, f32)) -> Item { Item::Line { color: ColorRef::Theme(0), width: 2.0, from: Point::new(from.0, from.1), to: Point::new(to.0, to.1), arrow: false } } #[test] fn hit_prefers_the_topmost_and_respects_tolerance() { let mut notes = Annotations::new(400, 300); notes.items.push(line((0.0, 0.0), (100.0, 0.0))); notes.items.push(line((0.0, 0.0), (100.0, 0.0))); // Both segments coincide; the eraser takes the last (topmost) one. assert_eq!(notes.hit(Point::new(50.0, 1.0), 2.0), Some(1)); assert_eq!(notes.hit(Point::new(50.0, 2.0), 2.0), Some(1)); // Just outside the tolerance for this y: no hit at all. assert_eq!(notes.hit(Point::new(50.0, 2.0), 1.0), None); assert_eq!(notes.hit(Point::new(50.0, 3.0), 2.0), None); // After the topmost one is erased, the bottom one is reachable again. let removed = notes.remove(1); assert_eq!(removed, Some(line((0.0, 0.0), (100.0, 0.0)))); assert_eq!(notes.hit(Point::new(50.0, 1.0), 2.0), Some(0)); assert_eq!(notes.remove(9), None); assert!(notes.remove(0).is_some_and(|item| item == line((0.0, 0.0), (100.0, 0.0)))); } #[test] fn hit_on_every_item_kind() { let items = [ Item::Stroke { tool: StrokeTool::Pen, color: ColorRef::Theme(0), width: 1.0, points: vec![Point::new(10.0, 10.0), Point::new(30.0, 30.0)] }, Item::Stroke { tool: StrokeTool::Highlighter, color: ColorRef::Theme(1), width: 1.0, points: vec![Point::new(50.0, 50.0)] }, Item::Line { color: ColorRef::Theme(0), width: 1.0, from: Point::new(100.0, 10.0), to: Point::new(100.0, 100.0), arrow: true }, Item::Rect { color: ColorRef::Theme(0), width: 1.0, min: Point::new(200.0, 10.0), max: Point::new(250.0, 60.0), filled: false }, Item::Ellipse { color: ColorRef::Theme(0), width: 1.0, min: Point::new(270.0, 10.0), max: Point::new(320.0, 60.0), filled: false }, Item::Ellipse { color: ColorRef::Theme(0), width: 1.0, min: Point::new(340.0, 60.0), max: Point::new(390.0, 10.0), filled: true }, Item::Text { color: ColorRef::Theme(0), size: 20.0, at: Point::new(10.0, 100.0), text: "abcd".to_string() }, ]; let mut notes = Annotations::new(400, 300); notes.items.extend(items); // diagonal stroke: on the path, and one point stroke near its only point assert_eq!(notes.hit(Point::new(20.0, 20.0), 1.0), Some(0)); assert_eq!(notes.hit(Point::new(50.0, 46.0), 5.0), Some(1)); assert_eq!(notes.hit(Point::new(50.0, 40.0), 5.0), None); // vertical line assert_eq!(notes.hit(Point::new(101.0, 50.0), 2.0), Some(2)); assert_eq!(notes.hit(Point::new(110.0, 50.0), 2.0), None); // rect (and its corners) assert_eq!(notes.hit(Point::new(220.0, 30.0), 0.5), Some(3)); // ellipse via its box assert_eq!(notes.hit(Point::new(300.0, 30.0), 0.5), Some(4)); // ellipse with crossed corners normalises to the same box assert_eq!(notes.hit(Point::new(350.0, 30.0), 0.5), Some(5)); // just outside the rect assert_eq!(notes.hit(Point::new(260.0, 30.0), 1.0), None); // text label: inside the approximated box (0.6 * 20 * 4 wide, 20 tall) assert_eq!(notes.hit(Point::new(30.0, 110.0), 2.0), Some(6)); assert_eq!(notes.hit(Point::new(80.0, 110.0), 2.0), None); // nothing hit far away assert_eq!(notes.hit(Point::new(399.0, 299.0), 2.0), None); } #[test] fn bounds_union_every_item() { let mut notes = Annotations::new(400, 300); assert_eq!(notes.bounds(), None); notes.items.push(line((10.0, 20.0), (110.0, 120.0))); let area = notes.bounds().unwrap(); assert_eq!(area.min, Point::new(10.0, 20.0)); assert_eq!(area.max, Point::new(110.0, 120.0)); notes.items.push(Item::Stroke { tool: StrokeTool::Pen, color: ColorRef::Theme(0), width: 1.0, points: vec![Point::new(-5.0, 5.0), Point::new(200.0, 60.0)], }); let joined = notes.bounds().unwrap(); assert_eq!(joined.min, Point::new(-5.0, 5.0)); assert_eq!(joined.max, Point::new(200.0, 120.0)); // An empty stroke bounds nothing but does not break the union. let mut empty = Annotations::new(400, 300); empty.items.push(Item::Stroke { tool: StrokeTool::Pen, color: ColorRef::Theme(0), width: 1.0, points: Vec::new() }); assert_eq!(empty.bounds(), None); } #[test] fn simplify_never_grows_and_keeps_endpoints() { // The deterministic LCG from `crates/sync/tests/merge_props.rs`. struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); self.0 >> 33 } fn below(&mut self, n: u64) -> u64 { self.next() % n.max(1) } fn unit(&mut self) -> f32 { let sample = u16::try_from(self.next() % 4096).unwrap_or(0); f32::from(sample) / 4096.0 } } let mut rng = Rng(0x5EED_5107); for case in 0..300_u64 { let _ = case; let count = 1 + usize::from(u16::try_from(rng.below(300)).unwrap_or(1)); let mut points = Vec::with_capacity(count); for index in 0..count { let wander = if index % 2 == 0 { 60.0 } else { -60.0 }; points.push(Point::new( f32::from(u16::try_from(index).unwrap_or(0)) * 2.0 + rng.unit() * 5.0, f32::from(u16::try_from(index).unwrap_or(0)) * 2.0 + wander + rng.unit() * 5.0, )); } let tolerance = rng.unit() * 50.0; let trimmed = simplify(&points, tolerance); assert!(trimmed.len() <= points.len(), "case {case} grew"); assert_eq!(trimmed.first(), points.first()); assert_eq!(trimmed.last(), points.last()); } } #[test] fn simplify_is_iterative_on_a_100000_point_line() { let count = 100_000; let mut points = Vec::with_capacity(count); for index in 0..count { let wobble = if index % 2 == 0 { 0.5 } else { -0.5 }; let along = f32::from(u16::try_from(index % 1000).unwrap_or(0)) + f32::from(u16::try_from(index / 1000).unwrap_or(0)); points.push(Point::new(along, along * 0.7 + wobble)); } let trimmed = simplify(&points, 10.0); // A slightly wobbly straight line collapses to its endpoints. assert_eq!(trimmed.len(), 2); assert_eq!(trimmed[0], points[0]); assert_eq!(trimmed[1], points[count - 1]); // Tolerance below the wobble keeps far-off kinks. let trimmed = simplify(&points, 0.1); assert!(trimmed.len() >= 3); }