diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs index d9226f7ab0..ee1a129ca1 100644 --- a/render-wasm/src/state/ui.rs +++ b/render-wasm/src/state/ui.rs @@ -22,12 +22,57 @@ impl GuidePool { } } - // TODO: handle the unwraps here self.horizontal .sort_by(|a, b| a.position().total_cmp(&b.position())); self.vertical .sort_by(|a, b| a.position().total_cmp(&b.position())); } + + pub fn find_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> { + if zoom <= 0.0 || tolerance < 0.0 { + return None; + } + + let world_tolerance = tolerance / zoom; + let vertical = Self::find_closest_in_axis(&self.vertical, x, world_tolerance); + let horizontal = Self::find_closest_in_axis(&self.horizontal, y, world_tolerance); + + match (vertical, horizontal) { + (Some(v), Some(h)) => { + let v_dist = (v.position() - x).abs(); + let h_dist = (h.position() - y).abs(); + if v_dist <= h_dist { + Some(v) + } else { + Some(h) + } + } + (v, h) => v.or(h), + } + } + + fn find_closest_in_axis(guides: &[Guide], coord: f32, world_tolerance: f32) -> Option<&Guide> { + if guides.is_empty() { + return None; + } + + let idx = guides.partition_point(|guide| guide.position() < coord); + let mut closest: Option<&Guide> = None; + let mut closest_dist = world_tolerance; + + for candidate_idx in [idx.wrapping_sub(1), idx] { + if candidate_idx < guides.len() { + let guide = &guides[candidate_idx]; + let dist = (guide.position() - coord).abs(); + if dist <= world_tolerance && dist <= closest_dist { + closest_dist = dist; + closest = Some(guide); + } + } + } + + closest + } } pub struct UIState { @@ -52,7 +97,78 @@ impl UIState { } #[allow(dead_code)] - fn find_guide_at(&self, _x: f32, _y: f32, _zoom: f32) -> Option<&Guide> { - unimplemented!("TODO: Implement guide finding"); + fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> { + self.guides.find_at(x, y, zoom, tolerance) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::Color; + + fn vertical_guide(position: f32, index: usize) -> Guide { + Guide::new(GuideKind::Vertical(position), Color::BLACK, Some(index)) + } + + fn horizontal_guide(position: f32, index: usize) -> Guide { + Guide::new(GuideKind::Horizontal(position), Color::BLACK, Some(index)) + } + + fn pool_with(guides: Vec) -> GuidePool { + let mut pool = GuidePool::new(); + pool.set(guides); + pool + } + + #[test] + fn find_at_returns_none_when_no_guides() { + let pool = GuidePool::new(); + assert!(pool.find_at(100.0, 100.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_finds_vertical_guide_within_tolerance() { + let pool = pool_with(vec![vertical_guide(100.0, 0)]); + let guide = pool.find_at(102.0, 50.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 0); + assert_eq!(guide.kind, GuideKind::Vertical(100.0)); + } + + #[test] + fn find_at_misses_vertical_guide_outside_tolerance() { + let pool = pool_with(vec![vertical_guide(100.0, 0)]); + assert!(pool.find_at(110.0, 50.0, 1.0, 8.0).is_none()); + } + + #[test] + fn find_at_finds_horizontal_guide_within_tolerance() { + let pool = pool_with(vec![horizontal_guide(200.0, 1)]); + let guide = pool.find_at(50.0, 203.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 1); + assert_eq!(guide.kind, GuideKind::Horizontal(200.0)); + } + + #[test] + fn find_at_picks_closest_vertical_guide() { + let pool = pool_with(vec![vertical_guide(100.0, 0), vertical_guide(105.0, 1)]); + let guide = pool.find_at(103.0, 0.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 1); + } + + #[test] + fn find_at_prefers_closer_guide_at_intersection() { + let pool = pool_with(vec![vertical_guide(102.0, 0), horizontal_guide(100.0, 1)]); + let guide = pool.find_at(100.0, 100.0, 1.0, 8.0).unwrap(); + assert_eq!(guide.index, 1); + assert_eq!(guide.kind, GuideKind::Horizontal(100.0)); + } + + #[test] + fn find_at_scales_tolerance_with_zoom() { + let pool = pool_with(vec![vertical_guide(100.0, 0)]); + + assert!(pool.find_at(104.0, 0.0, 2.0, 8.0).is_some()); + assert!(pool.find_at(105.0, 0.0, 2.0, 8.0).is_none()); } }