diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 8d537547..7d85c374 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -648,6 +648,20 @@ fn validate_and_clamp_resets_non_finite_toolbar_scale() { assert_eq!(config.ui.toolbar.scale, 1.0); } +#[test] +fn validate_and_clamp_resets_non_finite_hit_test_tolerance() { + let default = Config::default().drawing.hit_test_tolerance; + + for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut config = Config::default(); + config.drawing.hit_test_tolerance = invalid; + + config.validate_and_clamp(); + + assert_eq!(config.drawing.hit_test_tolerance, default); + } +} + #[test] fn validate_and_clamp_resets_non_finite_spotlight_settings() { let defaults = Config::default().spotlight; diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index 2e1e8265..85e7b4d2 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; /// Maximum quick colors rendered by dense palette UIs. pub const QUICK_COLOR_RENDER_LIMIT: usize = 24; +/// Default tolerance used when selecting or targeting drawn shapes. +pub(crate) const DEFAULT_HIT_TEST_TOLERANCE: f64 = 6.0; + /// Drawing-related settings. /// /// Controls the default appearance of drawing tools when the overlay first opens. @@ -898,7 +901,7 @@ fn default_text_background() -> bool { } fn default_hit_test_tolerance() -> f64 { - 6.0 + DEFAULT_HIT_TEST_TOLERANCE } fn default_hit_test_threshold() -> usize { diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 31e816f5..47b83867 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -34,6 +34,7 @@ pub use capture::{ }; pub use click_highlight::ClickHighlightConfig; pub use context_menu::ContextMenuUiConfig; +pub(crate) use drawing::DEFAULT_HIT_TEST_TOLERANCE; pub use drawing::{ DragButtonConfig, DrawingConfig, MouseDragToolsConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite, diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index 71ae0e0a..fd80ce5f 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -1,4 +1,5 @@ use super::Config; +use crate::config::types::DEFAULT_HIT_TEST_TOLERANCE; use crate::draw::shape::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; @@ -68,7 +69,13 @@ impl Config { .clamp(REGULAR_POLYGON_MIN_SIDES, REGULAR_POLYGON_MAX_SIDES); } - if !(1.0..=20.0).contains(&self.drawing.hit_test_tolerance) { + if !self.drawing.hit_test_tolerance.is_finite() { + log::warn!( + "Invalid non-finite hit_test_tolerance; resetting to default {:.1}", + DEFAULT_HIT_TEST_TOLERANCE + ); + self.drawing.hit_test_tolerance = DEFAULT_HIT_TEST_TOLERANCE; + } else if !(1.0..=20.0).contains(&self.drawing.hit_test_tolerance) { log::warn!( "Invalid hit_test_tolerance {:.1}, clamping to 1.0-20.0 range", self.drawing.hit_test_tolerance diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index 63718d90..1ffbb669 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -10,8 +10,17 @@ use crate::draw::shape::{arrow_label_layout, step_marker_outline_thickness, step use crate::draw::{DrawnShape, Shape}; use crate::util::Rect; +const MAX_HIT_TEST_TOLERANCE: f64 = i32::MAX as f64; + +/// Validates a tolerance before it reaches floating-point geometry or integer inflation. +pub(crate) fn validated_tolerance(tolerance: f64) -> Option { + (tolerance.is_finite() && (0.0..=MAX_HIT_TEST_TOLERANCE).contains(&tolerance)) + .then_some(tolerance) +} + /// Computes a tolerance-aware bounding rectangle for the shape. pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { + let tolerance = validated_tolerance(tolerance)?; let base = shape.bounding_box()?; if matches!(shape.shape, Shape::EraserStroke { .. }) { return None; @@ -25,6 +34,10 @@ pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { /// Returns `true` if the point intersects the provided shape within tolerance. pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { + let Some(tolerance) = validated_tolerance(tolerance) else { + return false; + }; + match &shape.shape { Shape::Freehand { points, thick, .. } => { shapes::freehand_hit(points, point, *thick, tolerance) @@ -147,6 +160,10 @@ pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { /// Stroke erasing intentionally keeps using `hit_test`, while direct point /// targeting includes filled interiors for closed fill-capable shapes. pub fn hit_test_for_point_targeting(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { + let Some(tolerance) = validated_tolerance(tolerance) else { + return false; + }; + if hit_test(shape, point, tolerance) { return true; } diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs index 7a6db9ce..19422518 100644 --- a/src/input/hit_test/tests.rs +++ b/src/input/hit_test/tests.rs @@ -55,6 +55,29 @@ fn compute_hit_bounds_ignores_eraser_strokes() { ); } +#[test] +fn invalid_tolerances_fail_closed() { + let drawn = DrawnShape::with_metadata( + 3, + Shape::Line { + x1: 0, + y1: 0, + x2: 20, + y2: 0, + color: BLACK, + thick: 2.0, + }, + 0, + false, + ); + + for tolerance in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { + assert!(compute_hit_bounds(&drawn, tolerance).is_none()); + assert!(!hit_test(&drawn, (10, 0), tolerance)); + assert!(!hit_test_for_point_targeting(&drawn, (10, 0), tolerance)); + } +} + #[test] fn rect_hit_handles_degenerate_dimensions() { let rect = DrawnShape::with_metadata( diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index eb4e869d..5607ba08 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -1,25 +1,11 @@ +mod grid; + use super::base::InputState; -use crate::draw::{Frame, ShapeId}; +use crate::draw::ShapeId; use crate::input::hit_test; -use crate::util::Rect; use std::collections::{HashMap, HashSet}; -pub(super) const SPATIAL_GRID_CELL_SIZE: i32 = 64; - -/// Spatial grid for efficient hit-testing using ShapeId instead of indices. -/// -/// This allows incremental updates when shapes are added, removed, or modified -/// without needing to rebuild the entire grid. -#[derive(Debug, Clone)] -pub(super) struct SpatialGrid { - pub(super) cell_size: i32, - /// Maps cell coordinates to the ShapeIds contained in that cell. - pub(super) cells: HashMap<(i32, i32), Vec>, - /// Reverse mapping from ShapeId to the cells it occupies for efficient removal. - pub(super) shape_cells: HashMap>, - /// Number of shapes when the grid was built (for validation). - pub(super) shape_count: usize, -} +pub(super) use self::grid::SpatialGrid; impl InputState { /// Returns all shapes intersecting any of the provided points within tolerance. @@ -38,6 +24,10 @@ impl InputState { points: &[(i32, i32)], tolerance: f64, ) -> Vec { + let Some(tolerance) = hit_test::validated_tolerance(tolerance) else { + return Vec::new(); + }; + if points.is_empty() { return Vec::new(); } @@ -130,7 +120,9 @@ impl InputState { /// Updates the hit-test tolerance (in pixels). pub fn set_hit_test_tolerance(&mut self, tolerance: f64) { - self.hit_test_tolerance = tolerance.max(1.0); + self.hit_test_tolerance = hit_test::validated_tolerance(tolerance) + .map(|tolerance| tolerance.max(1.0)) + .unwrap_or(1.0); self.invalidate_hit_cache(); } @@ -156,14 +148,14 @@ impl InputState { let needs_rebuild = match &self.spatial_index { None => true, Some(grid) => { - let drift = (grid.shape_count as i64 - len as i64).unsigned_abs() as usize; + let drift = (grid.shape_count() as i64 - len as i64).unsigned_abs() as usize; drift > len / 5 + 1 } }; if needs_rebuild { let frame = self.boards.active_frame(); - self.spatial_index = SpatialGrid::build(frame, SPATIAL_GRID_CELL_SIZE); + self.spatial_index = SpatialGrid::build(frame); } } @@ -274,106 +266,3 @@ impl InputState { self.hit_test_indices((0..len).rev(), x, y, tolerance) } } - -impl SpatialGrid { - fn build(frame: &Frame, cell_size: i32) -> Option { - let cell_size = cell_size.max(1); - if frame.shapes.is_empty() { - return None; - } - - let mut cells: HashMap<(i32, i32), Vec> = HashMap::new(); - let mut shape_cells: HashMap> = HashMap::new(); - - for drawn in &frame.shapes { - let Some(bounds) = drawn.bounding_box() else { - continue; - }; - - let cell_keys = Self::compute_cell_keys(bounds, cell_size); - for &key in &cell_keys { - cells.entry(key).or_default().push(drawn.id); - } - shape_cells.insert(drawn.id, cell_keys); - } - - if cells.is_empty() { - return None; - } - - Some(Self { - cell_size, - cells, - shape_cells, - shape_count: frame.shapes.len(), - }) - } - - /// Computes the cell keys that a bounding box occupies. - fn compute_cell_keys(bounds: Rect, cell_size: i32) -> Vec<(i32, i32)> { - let min_cell_x = bounds.x.div_euclid(cell_size); - let max_cell_x = (bounds.x + bounds.width - 1).div_euclid(cell_size); - let min_cell_y = bounds.y.div_euclid(cell_size); - let max_cell_y = (bounds.y + bounds.height - 1).div_euclid(cell_size); - - let mut keys = Vec::with_capacity( - ((max_cell_x - min_cell_x + 1) * (max_cell_y - min_cell_y + 1)) as usize, - ); - for cx in min_cell_x..=max_cell_x { - for cy in min_cell_y..=max_cell_y { - keys.push((cx, cy)); - } - } - keys - } - - /// Removes a shape from all cells it occupies. - fn remove_shape(&mut self, id: ShapeId) { - if let Some(cell_keys) = self.shape_cells.remove(&id) { - for key in cell_keys { - if let Some(ids) = self.cells.get_mut(&key) { - ids.retain(|&existing_id| existing_id != id); - // Clean up empty cells - if ids.is_empty() { - self.cells.remove(&key); - } - } - } - } - } - - /// Adds a shape with known bounds to the grid. - fn add_shape_with_bounds(&mut self, id: ShapeId, bounds: Rect) { - let cell_keys = Self::compute_cell_keys(bounds, self.cell_size); - for &key in &cell_keys { - self.cells.entry(key).or_default().push(id); - } - self.shape_cells.insert(id, cell_keys); - } - - /// Queries for all ShapeIds in cells near the given point with tolerance-aware radius. - /// - /// The search radius is expanded based on tolerance to ensure shapes that could - /// be hit within the tolerance distance are not missed. - fn query_with_tolerance(&self, point: (i32, i32), tolerance: f64) -> Vec { - let cell_x = point.0.div_euclid(self.cell_size); - let cell_y = point.1.div_euclid(self.cell_size); - - // Expand search radius based on tolerance: ceil(tolerance / cell_size) + 1 - // The +1 ensures we always check at least the 3x3 neighborhood - let extra_cells = (tolerance / self.cell_size as f64).ceil() as i32; - let radius = 1 + extra_cells; - - let mut unique = HashSet::new(); - for dx in -radius..=radius { - for dy in -radius..=radius { - let key = (cell_x + dx, cell_y + dy); - if let Some(ids) = self.cells.get(&key) { - unique.extend(ids.iter().copied()); - } - } - } - - unique.into_iter().collect() - } -} diff --git a/src/input/state/core/index/grid.rs b/src/input/state/core/index/grid.rs new file mode 100644 index 00000000..8371c996 --- /dev/null +++ b/src/input/state/core/index/grid.rs @@ -0,0 +1,243 @@ +use crate::draw::{Frame, ShapeId}; +use crate::util::Rect; +use std::collections::{HashMap, HashSet}; + +const SPATIAL_GRID_CELL_SIZE: i32 = 64; +const MAX_SPATIAL_CELLS_PER_SHAPE: usize = 4_096; +// The largest centered odd square within this budget is 63 x 63 cells. +const MAX_SPATIAL_QUERY_CELLS: u64 = 4_096; +// Allows roughly 26 indexed cells per shape at the default 10,000-shape limit +// while placing a hard ceiling on duplicated `(shape, cell)` entries. +const MAX_SPATIAL_GRID_MEMBERSHIPS: usize = 262_144; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CellRange { + min_x: i32, + max_x: i32, + min_y: i32, + max_y: i32, + count: usize, +} + +impl CellRange { + fn keys(self) -> impl Iterator { + (self.min_x..=self.max_x).flat_map(move |x| (self.min_y..=self.max_y).map(move |y| (x, y))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CellCoverage { + Bounded(CellRange), + Global, +} + +/// Spatial grid for efficient hit-testing using ShapeId instead of indices. +/// +/// This allows incremental updates when shapes are added, removed, or modified +/// without needing to rebuild the entire grid. +#[derive(Debug, Clone)] +pub(in crate::input::state::core) struct SpatialGrid { + cell_size: i32, + /// Maps cell coordinates to the ShapeIds contained in that cell. + cells: HashMap<(i32, i32), Vec>, + /// Reverse mapping from ShapeId to the cells it occupies for efficient removal. + shape_cells: HashMap>, + /// Shapes kept as global candidates because their coverage is unsafe or + /// oversized, or because the aggregate membership budget is exhausted. + /// + /// These remain candidates for every query, bounding per-shape index work + /// and total grid memory without introducing hit-test false negatives. + global_shapes: HashSet, + /// Number of `(shape, cell)` memberships currently stored in both maps. + indexed_memberships: usize, + /// Maximum number of memberships retained by this grid. + max_indexed_memberships: usize, + /// Number of shapes when the grid was built (for validation). + shape_count: usize, +} + +impl SpatialGrid { + pub(super) fn build(frame: &Frame) -> Option { + Self::build_with_membership_limit( + frame, + SPATIAL_GRID_CELL_SIZE, + MAX_SPATIAL_GRID_MEMBERSHIPS, + ) + } + + fn build_with_membership_limit( + frame: &Frame, + cell_size: i32, + max_indexed_memberships: usize, + ) -> Option { + let cell_size = cell_size.max(1); + if frame.shapes.is_empty() { + return None; + } + + let mut grid = Self { + cell_size, + cells: HashMap::new(), + shape_cells: HashMap::new(), + global_shapes: HashSet::new(), + indexed_memberships: 0, + max_indexed_memberships, + shape_count: frame.shapes.len(), + }; + + for drawn in &frame.shapes { + let Some(bounds) = drawn.bounding_box() else { + continue; + }; + grid.add_shape_with_bounds(drawn.id, bounds); + } + + if grid.cells.is_empty() && grid.global_shapes.is_empty() { + return None; + } + + Some(grid) + } + + pub(super) fn shape_count(&self) -> usize { + self.shape_count + } + + /// Computes bounded cell coverage without materializing its keys. + fn compute_cell_coverage(bounds: Rect, cell_size: i32) -> CellCoverage { + if !bounds.is_valid() { + return CellCoverage::Global; + } + + let cell_size = i64::from(cell_size.max(1)); + let min_cell_x = i64::from(bounds.x).div_euclid(cell_size); + let max_cell_x = (i64::from(bounds.x) + i64::from(bounds.width) - 1).div_euclid(cell_size); + let min_cell_y = i64::from(bounds.y).div_euclid(cell_size); + let max_cell_y = (i64::from(bounds.y) + i64::from(bounds.height) - 1).div_euclid(cell_size); + + let columns = (max_cell_x - min_cell_x + 1) as u64; + let rows = (max_cell_y - min_cell_y + 1) as u64; + let Some(cell_count) = columns.checked_mul(rows) else { + return CellCoverage::Global; + }; + if cell_count > MAX_SPATIAL_CELLS_PER_SHAPE as u64 { + return CellCoverage::Global; + } + + let (Ok(min_cell_x), Ok(max_cell_x), Ok(min_cell_y), Ok(max_cell_y)) = ( + i32::try_from(min_cell_x), + i32::try_from(max_cell_x), + i32::try_from(min_cell_y), + i32::try_from(max_cell_y), + ) else { + return CellCoverage::Global; + }; + + CellCoverage::Bounded(CellRange { + min_x: min_cell_x, + max_x: max_cell_x, + min_y: min_cell_y, + max_y: max_cell_y, + count: cell_count as usize, + }) + } + + /// Removes a shape from all cells it occupies. + pub(super) fn remove_shape(&mut self, id: ShapeId) { + self.global_shapes.remove(&id); + if let Some(cell_keys) = self.shape_cells.remove(&id) { + self.indexed_memberships = self + .indexed_memberships + .checked_sub(cell_keys.len()) + .expect("spatial index membership accounting underflow"); + for key in cell_keys { + if let Some(ids) = self.cells.get_mut(&key) { + ids.retain(|&existing_id| existing_id != id); + if ids.is_empty() { + self.cells.remove(&key); + } + } + } + } + } + + /// Adds a shape with known bounds to the grid. + pub(super) fn add_shape_with_bounds(&mut self, id: ShapeId, bounds: Rect) { + match Self::compute_cell_coverage(bounds, self.cell_size) { + CellCoverage::Bounded(cell_range) + if cell_range.count + <= self + .max_indexed_memberships + .saturating_sub(self.indexed_memberships) => + { + self.indexed_memberships += cell_range.count; + let mut cell_keys = Vec::with_capacity(cell_range.count); + for key in cell_range.keys() { + self.cells.entry(key).or_default().push(id); + cell_keys.push(key); + } + debug_assert_eq!(cell_keys.len(), cell_range.count); + self.shape_cells.insert(id, cell_keys); + } + CellCoverage::Bounded(_) | CellCoverage::Global => { + self.global_shapes.insert(id); + } + } + } + + /// Queries for all ShapeIds in cells near the given point with tolerance-aware radius. + /// + /// The search radius is expanded based on tolerance to ensure shapes that could + /// be hit within the tolerance distance are not missed. + pub(super) fn query_with_tolerance(&self, point: (i32, i32), tolerance: f64) -> Vec { + let Some(radius) = Self::query_radius_within_budget(tolerance, self.cell_size) else { + return self.all_candidates(); + }; + let cell_x = i64::from(point.0.div_euclid(self.cell_size)); + let cell_y = i64::from(point.1.div_euclid(self.cell_size)); + + let mut unique = HashSet::with_capacity(self.global_shapes.len()); + unique.extend(self.global_shapes.iter().copied()); + for dx in -radius..=radius { + let Ok(key_x) = i32::try_from(cell_x + dx) else { + continue; + }; + for dy in -radius..=radius { + let Ok(key_y) = i32::try_from(cell_y + dy) else { + continue; + }; + if let Some(ids) = self.cells.get(&(key_x, key_y)) { + unique.extend(ids.iter().copied()); + } + } + } + + unique.into_iter().collect() + } + + fn query_radius_within_budget(tolerance: f64, cell_size: i32) -> Option { + if !tolerance.is_finite() || tolerance < 0.0 { + return None; + } + + let extra_cells = (tolerance / f64::from(cell_size.max(1))).ceil(); + if extra_cells > (i64::MAX - 1) as f64 { + return None; + } + let radius = (extra_cells as i64).checked_add(1)?; + let diameter = radius.checked_mul(2)?.checked_add(1)?; + let diameter = u64::try_from(diameter).ok()?; + let query_cells = diameter.checked_mul(diameter)?; + (query_cells <= MAX_SPATIAL_QUERY_CELLS).then_some(radius) + } + + fn all_candidates(&self) -> Vec { + let mut candidates = Vec::with_capacity(self.shape_cells.len() + self.global_shapes.len()); + candidates.extend(self.shape_cells.keys().copied()); + candidates.extend(self.global_shapes.iter().copied()); + candidates + } +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/index/grid/tests.rs b/src/input/state/core/index/grid/tests.rs new file mode 100644 index 00000000..079b13aa --- /dev/null +++ b/src/input/state/core/index/grid/tests.rs @@ -0,0 +1,301 @@ +use super::*; +use crate::draw::{Color, Shape}; + +fn filled_rect(x: i32, y: i32, width: i32, height: i32) -> Shape { + Shape::Rect { + x, + y, + w: width, + h: height, + fill: true, + color: Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 1.0, + } +} + +fn assert_membership_accounting(grid: &SpatialGrid) { + let reverse_memberships = grid.shape_cells.values().map(Vec::len).sum::(); + let forward_memberships = grid.cells.values().map(Vec::len).sum::(); + assert_eq!(grid.indexed_memberships, reverse_memberships); + assert_eq!(grid.indexed_memberships, forward_memberships); + assert!(grid.indexed_memberships <= grid.max_indexed_memberships); + for global_id in &grid.global_shapes { + assert!(!grid.shape_cells.contains_key(global_id)); + assert!(grid.cells.values().all(|ids| !ids.contains(global_id))); + } +} + +fn mixed_candidate_grid() -> (SpatialGrid, [ShapeId; 3]) { + let mut frame = Frame::new(); + let first = frame.add_shape(filled_rect(10, 10, 10, 10)); + let second = frame.add_shape(filled_rect(74, 10, 10, 10)); + let third = frame.add_shape(filled_rect(138, 10, 10, 10)); + let grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) + .expect("spatial grid"); + (grid, [first, second, third]) +} + +fn candidate_set(candidates: Vec) -> HashSet { + candidates.into_iter().collect() +} + +#[test] +fn oversized_shape_is_queried_without_per_cell_index_entries() { + let mut frame = Frame::new(); + let shape_id = frame.add_shape(filled_rect(0, 0, 100_000, 100_000)); + + let grid = SpatialGrid::build(&frame).expect("spatial grid"); + + assert!(grid.cells.is_empty()); + assert!(grid.shape_cells.is_empty()); + assert_eq!(grid.global_shapes, HashSet::from([shape_id])); + assert!( + grid.query_with_tolerance((50_000, 50_000), 1.0) + .contains(&shape_id) + ); +} + +#[test] +fn cell_coverage_uses_wide_arithmetic_near_coordinate_limits() { + let bounds = Rect::new(i32::MAX - 31, i32::MAX - 31, 64, 64).expect("valid bounds"); + + assert_eq!( + SpatialGrid::compute_cell_coverage(bounds, 1), + CellCoverage::Global + ); +} + +#[test] +fn invalid_bounds_remain_conservative_global_candidates() { + let bounds = Rect { + x: 10, + y: 20, + width: 0, + height: 30, + }; + + assert_eq!( + SpatialGrid::compute_cell_coverage(bounds, SPATIAL_GRID_CELL_SIZE), + CellCoverage::Global + ); +} + +#[test] +fn bounded_coverage_reports_count_before_key_materialization() { + let bounds = Rect::new(0, 0, 128, 128).expect("valid bounds"); + let CellCoverage::Bounded(cell_range) = + SpatialGrid::compute_cell_coverage(bounds, SPATIAL_GRID_CELL_SIZE) + else { + panic!("expected bounded cell coverage"); + }; + + assert_eq!(cell_range.count, 4); + assert_eq!( + cell_range.keys().collect::>(), + vec![(0, 0), (0, 1), (1, 0), (1, 1)] + ); +} + +#[test] +fn bounded_coverage_handles_negative_exact_cell_edges() { + let bounds = Rect::new(-64, -64, 128, 64).expect("valid bounds"); + let CellCoverage::Bounded(cell_range) = + SpatialGrid::compute_cell_coverage(bounds, SPATIAL_GRID_CELL_SIZE) + else { + panic!("expected bounded cell coverage"); + }; + + assert_eq!(cell_range.count, 2); + assert_eq!( + cell_range.keys().collect::>(), + vec![(-1, -1), (0, -1)] + ); +} + +#[test] +fn cell_coverage_enforces_per_shape_limit_boundary() { + let at_limit = Rect::new(0, 0, MAX_SPATIAL_CELLS_PER_SHAPE as i32, 1).expect("valid bounds"); + let over_limit = + Rect::new(0, 0, MAX_SPATIAL_CELLS_PER_SHAPE as i32 + 1, 1).expect("valid bounds"); + + let CellCoverage::Bounded(cell_range) = SpatialGrid::compute_cell_coverage(at_limit, 1) else { + panic!("coverage at the limit should remain bounded"); + }; + assert_eq!(cell_range.count, MAX_SPATIAL_CELLS_PER_SHAPE); + assert_eq!( + SpatialGrid::compute_cell_coverage(over_limit, 1), + CellCoverage::Global + ); +} + +#[test] +fn oversized_shape_can_move_back_into_regular_cells() { + let mut frame = Frame::new(); + let shape_id = frame.add_shape(filled_rect(0, 0, 100_000, 100_000)); + let mut grid = SpatialGrid::build(&frame).expect("spatial grid"); + + grid.remove_shape(shape_id); + grid.add_shape_with_bounds(shape_id, Rect::new(128, 128, 32, 32).expect("valid bounds")); + + assert!(!grid.global_shapes.contains(&shape_id)); + assert!(!grid.shape_cells[&shape_id].is_empty()); + assert!( + grid.query_with_tolerance((144, 144), 1.0) + .contains(&shape_id) + ); +} + +#[test] +fn aggregate_membership_budget_routes_excess_shapes_to_global_candidates() { + let mut frame = Frame::new(); + let first = frame.add_shape(filled_rect(10, 10, 10, 10)); + let second = frame.add_shape(filled_rect(74, 10, 10, 10)); + let third = frame.add_shape(filled_rect(138, 10, 10, 10)); + + let grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) + .expect("spatial grid"); + + assert_eq!(grid.indexed_memberships, 2); + assert_membership_accounting(&grid); + assert!(grid.shape_cells.contains_key(&first)); + assert!(grid.shape_cells.contains_key(&second)); + assert!(!grid.shape_cells.contains_key(&third)); + assert_eq!(grid.global_shapes, HashSet::from([third])); + + let candidates = grid.query_with_tolerance((15, 15), 1.0); + assert!(candidates.contains(&first)); + assert!(candidates.contains(&third)); +} + +#[test] +fn aggregate_rejection_keeps_membership_storage_available_for_later_small_shape() { + let mut frame = Frame::new(); + let first = frame.add_shape(filled_rect(10, 10, 10, 10)); + let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 1) + .expect("spatial grid"); + let rejected = u64::MAX; + let later_small = u64::MAX - 1; + + grid.add_shape_with_bounds( + rejected, + Rect::new(0, 0, SPATIAL_GRID_CELL_SIZE * 4_096, 1).expect("valid bounds"), + ); + assert_eq!(grid.indexed_memberships, 1); + assert!(!grid.shape_cells.contains_key(&rejected)); + assert!(grid.global_shapes.contains(&rejected)); + + grid.remove_shape(first); + grid.add_shape_with_bounds(later_small, Rect::new(0, 0, 1, 1).expect("valid bounds")); + + assert_eq!(grid.indexed_memberships, 1); + assert!(grid.shape_cells.contains_key(&later_small)); + assert_membership_accounting(&grid); +} + +#[test] +fn removing_indexed_shape_releases_aggregate_membership_budget() { + let mut frame = Frame::new(); + let first = frame.add_shape(filled_rect(10, 10, 10, 10)); + let _second = frame.add_shape(filled_rect(74, 10, 10, 10)); + let third = frame.add_shape(filled_rect(138, 10, 10, 10)); + let third_bounds = frame + .shape(third) + .and_then(|shape| shape.bounding_box()) + .expect("third shape bounds"); + let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) + .expect("spatial grid"); + + grid.remove_shape(first); + grid.remove_shape(third); + grid.add_shape_with_bounds(third, third_bounds); + + assert_eq!(grid.indexed_memberships, 2); + assert_membership_accounting(&grid); + assert!(!grid.global_shapes.contains(&third)); + assert!(grid.shape_cells.contains_key(&third)); +} + +#[test] +fn reindexing_shape_beyond_remaining_budget_clears_old_cells_and_stays_queryable() { + let mut frame = Frame::new(); + let first = frame.add_shape(filled_rect(10, 10, 10, 10)); + let _second = frame.add_shape(filled_rect(74, 10, 10, 10)); + let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) + .expect("spatial grid"); + + grid.remove_shape(first); + grid.add_shape_with_bounds(first, Rect::new(0, 0, 128, 32).expect("valid bounds")); + + assert_eq!(grid.indexed_memberships, 1); + assert_membership_accounting(&grid); + assert!(!grid.cells.contains_key(&(0, 0))); + assert!(!grid.shape_cells.contains_key(&first)); + assert!(grid.global_shapes.contains(&first)); + assert!(grid.query_with_tolerance((32, 16), 1.0).contains(&first)); +} + +#[test] +fn invalid_query_tolerances_return_all_candidates() { + let (grid, shape_ids) = mixed_candidate_grid(); + let expected = HashSet::from(shape_ids); + + for tolerance in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { + assert_eq!( + candidate_set(grid.query_with_tolerance((6_400, 6_400), tolerance)), + expected, + "unexpected candidates for tolerance {tolerance:?}" + ); + } +} + +#[test] +fn query_cell_budget_falls_back_before_radius_can_explode() { + let (grid, [first, second, global]) = mixed_candidate_grid(); + let point = (6_400, 6_400); + let within_budget = 30.0 * f64::from(SPATIAL_GRID_CELL_SIZE); + let over_budget = 31.0 * f64::from(SPATIAL_GRID_CELL_SIZE); + + assert_eq!( + candidate_set(grid.query_with_tolerance(point, within_budget)), + HashSet::from([global]) + ); + assert_eq!( + candidate_set(grid.query_with_tolerance(point, over_budget)), + HashSet::from([first, second, global]) + ); +} + +#[test] +fn query_at_coordinate_limits_skips_out_of_range_neighbor_cells() { + let min_id = 1; + let max_id = 2; + let grid = SpatialGrid { + cell_size: 1, + cells: HashMap::from([ + ((i32::MIN, i32::MIN), vec![min_id]), + ((i32::MAX, i32::MAX), vec![max_id]), + ]), + shape_cells: HashMap::from([ + (min_id, vec![(i32::MIN, i32::MIN)]), + (max_id, vec![(i32::MAX, i32::MAX)]), + ]), + global_shapes: HashSet::new(), + indexed_memberships: 2, + max_indexed_memberships: 2, + shape_count: 2, + }; + + assert_eq!( + candidate_set(grid.query_with_tolerance((i32::MIN, i32::MIN), 0.0)), + HashSet::from([min_id]) + ); + assert_eq!( + candidate_set(grid.query_with_tolerance((i32::MAX, i32::MAX), 0.0)), + HashSet::from([max_id]) + ); +} diff --git a/src/input/state/tests/hit_testing.rs b/src/input/state/tests/hit_testing.rs new file mode 100644 index 00000000..2c83cdef --- /dev/null +++ b/src/input/state/tests/hit_testing.rs @@ -0,0 +1,45 @@ +use super::*; + +fn add_test_line(state: &mut InputState) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Line { + x1: 0, + y1: 0, + x2: 20, + y2: 0, + color: Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }) +} + +#[test] +fn explicit_hit_testing_rejects_invalid_tolerances() { + let mut state = create_test_input_state(); + add_test_line(&mut state); + + for tolerance in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { + assert!( + state + .hit_test_all_for_points(&[(10, 0)], tolerance) + .is_empty(), + "invalid tolerance {tolerance:?} must fail closed" + ); + } +} + +#[test] +fn stored_hit_test_tolerance_is_always_valid() { + let mut state = create_test_input_state(); + + for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { + state.set_hit_test_tolerance(invalid); + assert_eq!(state.hit_test_tolerance, 1.0); + } + + state.set_hit_test_tolerance(25.0); + assert_eq!(state.hit_test_tolerance, 25.0); +} diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs index 1e109ea4..b0f3cca7 100644 --- a/src/input/state/tests/mod.rs +++ b/src/input/state/tests/mod.rs @@ -19,6 +19,7 @@ mod delete_restore; mod drawing; mod erase; mod focus_mode; +mod hit_testing; mod input_hud; mod light_mode; mod menus;