From 5f745cf7af5359d9e5ae4c791cfa8d1868ce02df Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:13:52 +0200 Subject: [PATCH 1/6] perf(input): bound per-shape spatial indexing --- src/input/state/core/index.rs | 99 ++++++++++++++++++++--------- src/input/state/core/index/tests.rs | 77 ++++++++++++++++++++++ 2 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 src/input/state/core/index/tests.rs diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index eb4e869df..273654290 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -5,6 +5,13 @@ use crate::util::Rect; use std::collections::{HashMap, HashSet}; pub(super) const SPATIAL_GRID_CELL_SIZE: i32 = 64; +const MAX_SPATIAL_CELLS_PER_SHAPE: usize = 4_096; + +#[derive(Debug, PartialEq, Eq)] +enum CellMembership { + Cells(Vec<(i32, i32)>), + Global, +} /// Spatial grid for efficient hit-testing using ShapeId instead of indices. /// @@ -17,6 +24,11 @@ pub(super) struct SpatialGrid { pub(super) cells: HashMap<(i32, i32), Vec>, /// Reverse mapping from ShapeId to the cells it occupies for efficient removal. pub(super) shape_cells: HashMap>, + /// Shapes spanning too many cells to index individually. + /// + /// These remain candidates for every query, bounding per-shape index work + /// and memory without introducing hit-test false negatives. + global_shapes: HashSet, /// Number of shapes when the grid was built (for validation). pub(super) shape_count: usize, } @@ -282,53 +294,70 @@ impl SpatialGrid { return None; } - let mut cells: HashMap<(i32, i32), Vec> = HashMap::new(); - let mut shape_cells: HashMap> = HashMap::new(); + let mut grid = Self { + cell_size, + cells: HashMap::new(), + shape_cells: HashMap::new(), + global_shapes: HashSet::new(), + shape_count: frame.shapes.len(), + }; 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); + grid.add_shape_with_bounds(drawn.id, bounds); } - if cells.is_empty() { + if grid.cells.is_empty() && grid.global_shapes.is_empty() { return None; } - Some(Self { - cell_size, - cells, - shape_cells, - shape_count: frame.shapes.len(), - }) + Some(grid) } - /// 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); + /// Computes bounded cell membership for a bounding box. + fn compute_cell_membership(bounds: Rect, cell_size: i32) -> CellMembership { + if !bounds.is_valid() { + return CellMembership::Global; + } - let mut keys = Vec::with_capacity( - ((max_cell_x - min_cell_x + 1) * (max_cell_y - min_cell_y + 1)) as usize, - ); + 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 CellMembership::Global; + }; + if cell_count > MAX_SPATIAL_CELLS_PER_SHAPE as u64 { + return CellMembership::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 CellMembership::Global; + }; + + let mut keys = Vec::with_capacity(cell_count 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 + CellMembership::Cells(keys) } /// Removes a shape from all cells it occupies. fn remove_shape(&mut self, id: ShapeId) { + self.global_shapes.remove(&id); if let Some(cell_keys) = self.shape_cells.remove(&id) { for key in cell_keys { if let Some(ids) = self.cells.get_mut(&key) { @@ -344,11 +373,17 @@ impl SpatialGrid { /// 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); + match Self::compute_cell_membership(bounds, self.cell_size) { + CellMembership::Cells(cell_keys) => { + for &key in &cell_keys { + self.cells.entry(key).or_default().push(id); + } + self.shape_cells.insert(id, cell_keys); + } + CellMembership::Global => { + self.global_shapes.insert(id); + } } - self.shape_cells.insert(id, cell_keys); } /// Queries for all ShapeIds in cells near the given point with tolerance-aware radius. @@ -364,7 +399,8 @@ impl SpatialGrid { let extra_cells = (tolerance / self.cell_size as f64).ceil() as i32; let radius = 1 + extra_cells; - let mut unique = HashSet::new(); + let mut unique = HashSet::with_capacity(self.global_shapes.len()); + unique.extend(self.global_shapes.iter().copied()); for dx in -radius..=radius { for dy in -radius..=radius { let key = (cell_x + dx, cell_y + dy); @@ -377,3 +413,6 @@ impl SpatialGrid { unique.into_iter().collect() } } + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/index/tests.rs b/src/input/state/core/index/tests.rs new file mode 100644 index 000000000..ebfd36409 --- /dev/null +++ b/src/input/state/core/index/tests.rs @@ -0,0 +1,77 @@ +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, + } +} + +#[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, SPATIAL_GRID_CELL_SIZE).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_membership_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_membership(bounds, 1), + CellMembership::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_membership(bounds, SPATIAL_GRID_CELL_SIZE), + CellMembership::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, SPATIAL_GRID_CELL_SIZE).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) + ); +} From bae45e1884ade935c1c5569fc3c03ac4d9053bd6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:21:09 +0200 Subject: [PATCH 2/6] perf(input): cap total spatial index memberships --- src/input/state/core/index.rs | 33 +++++++++++-- src/input/state/core/index/tests.rs | 76 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index 273654290..e5e13e60e 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -6,6 +6,9 @@ use std::collections::{HashMap, HashSet}; pub(super) const SPATIAL_GRID_CELL_SIZE: i32 = 64; const MAX_SPATIAL_CELLS_PER_SHAPE: usize = 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, PartialEq, Eq)] enum CellMembership { @@ -27,8 +30,12 @@ pub(super) struct SpatialGrid { /// Shapes spanning too many cells to index individually. /// /// These remain candidates for every query, bounding per-shape index work - /// and memory without introducing hit-test false negatives. + /// 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). pub(super) shape_count: usize, } @@ -289,6 +296,14 @@ impl InputState { impl SpatialGrid { fn build(frame: &Frame, cell_size: i32) -> Option { + Self::build_with_membership_limit(frame, 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; @@ -299,6 +314,8 @@ impl SpatialGrid { cells: HashMap::new(), shape_cells: HashMap::new(), global_shapes: HashSet::new(), + indexed_memberships: 0, + max_indexed_memberships, shape_count: frame.shapes.len(), }; @@ -359,6 +376,10 @@ impl SpatialGrid { 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); @@ -374,13 +395,19 @@ impl SpatialGrid { /// Adds a shape with known bounds to the grid. fn add_shape_with_bounds(&mut self, id: ShapeId, bounds: Rect) { match Self::compute_cell_membership(bounds, self.cell_size) { - CellMembership::Cells(cell_keys) => { + CellMembership::Cells(cell_keys) + if cell_keys.len() + <= self + .max_indexed_memberships + .saturating_sub(self.indexed_memberships) => + { + self.indexed_memberships += cell_keys.len(); for &key in &cell_keys { self.cells.entry(key).or_default().push(id); } self.shape_cells.insert(id, cell_keys); } - CellMembership::Global => { + CellMembership::Cells(_) | CellMembership::Global => { self.global_shapes.insert(id); } } diff --git a/src/input/state/core/index/tests.rs b/src/input/state/core/index/tests.rs index ebfd36409..500ddc98d 100644 --- a/src/input/state/core/index/tests.rs +++ b/src/input/state/core/index/tests.rs @@ -18,6 +18,18 @@ fn filled_rect(x: i32, y: i32, width: i32, height: i32) -> Shape { } } +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))); + } +} + #[test] fn oversized_shape_is_queried_without_per_cell_index_entries() { let mut frame = Frame::new(); @@ -75,3 +87,67 @@ fn oversized_shape_can_move_back_into_regular_cells() { .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 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)); +} From 82481597168a9b221fc302114e98fc1767ededf7 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:26:05 +0200 Subject: [PATCH 3/6] perf(input): defer spatial cell materialization --- src/input/state/core/index.rs | 65 ++++++++++++++-------- src/input/state/core/index/tests.rs | 83 +++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index e5e13e60e..eb7d0c89d 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -10,9 +10,24 @@ const MAX_SPATIAL_CELLS_PER_SHAPE: usize = 4_096; // while placing a hard ceiling on duplicated `(shape, cell)` entries. const MAX_SPATIAL_GRID_MEMBERSHIPS: usize = 262_144; -#[derive(Debug, PartialEq, Eq)] -enum CellMembership { - Cells(Vec<(i32, i32)>), +#[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, } @@ -27,7 +42,8 @@ pub(super) struct SpatialGrid { pub(super) cells: HashMap<(i32, i32), Vec>, /// Reverse mapping from ShapeId to the cells it occupies for efficient removal. pub(super) shape_cells: HashMap>, - /// Shapes spanning too many cells to index individually. + /// 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. @@ -333,10 +349,10 @@ impl SpatialGrid { Some(grid) } - /// Computes bounded cell membership for a bounding box. - fn compute_cell_membership(bounds: Rect, cell_size: i32) -> CellMembership { + /// Computes bounded cell coverage without materializing its keys. + fn compute_cell_coverage(bounds: Rect, cell_size: i32) -> CellCoverage { if !bounds.is_valid() { - return CellMembership::Global; + return CellCoverage::Global; } let cell_size = i64::from(cell_size.max(1)); @@ -348,10 +364,10 @@ impl SpatialGrid { 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 CellMembership::Global; + return CellCoverage::Global; }; if cell_count > MAX_SPATIAL_CELLS_PER_SHAPE as u64 { - return CellMembership::Global; + return CellCoverage::Global; } let (Ok(min_cell_x), Ok(max_cell_x), Ok(min_cell_y), Ok(max_cell_y)) = ( @@ -360,16 +376,16 @@ impl SpatialGrid { i32::try_from(min_cell_y), i32::try_from(max_cell_y), ) else { - return CellMembership::Global; + return CellCoverage::Global; }; - let mut keys = Vec::with_capacity(cell_count as usize); - for cx in min_cell_x..=max_cell_x { - for cy in min_cell_y..=max_cell_y { - keys.push((cx, cy)); - } - } - CellMembership::Cells(keys) + 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. @@ -394,20 +410,23 @@ impl SpatialGrid { /// Adds a shape with known bounds to the grid. fn add_shape_with_bounds(&mut self, id: ShapeId, bounds: Rect) { - match Self::compute_cell_membership(bounds, self.cell_size) { - CellMembership::Cells(cell_keys) - if cell_keys.len() + 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_keys.len(); - for &key in &cell_keys { + 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); } - CellMembership::Cells(_) | CellMembership::Global => { + CellCoverage::Bounded(_) | CellCoverage::Global => { self.global_shapes.insert(id); } } diff --git a/src/input/state/core/index/tests.rs b/src/input/state/core/index/tests.rs index 500ddc98d..2e65a772c 100644 --- a/src/input/state/core/index/tests.rs +++ b/src/input/state/core/index/tests.rs @@ -47,12 +47,12 @@ fn oversized_shape_is_queried_without_per_cell_index_entries() { } #[test] -fn cell_membership_uses_wide_arithmetic_near_coordinate_limits() { +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_membership(bounds, 1), - CellMembership::Global + SpatialGrid::compute_cell_coverage(bounds, 1), + CellCoverage::Global ); } @@ -66,8 +66,56 @@ fn invalid_bounds_remain_conservative_global_candidates() { }; assert_eq!( - SpatialGrid::compute_cell_membership(bounds, SPATIAL_GRID_CELL_SIZE), - CellMembership::Global + 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 ); } @@ -110,6 +158,31 @@ fn aggregate_membership_budget_routes_excess_shapes_to_global_candidates() { 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(); From 4b04205d8f7dc1b39bcb5b672ff69edffe014ddf Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:51:03 +0200 Subject: [PATCH 4/6] refactor(input): extract spatial grid module --- src/input/state/core/index.rs | 214 +---------------- src/input/state/core/index/grid.rs | 215 ++++++++++++++++++ .../state/core/index/{ => grid}/tests.rs | 4 +- 3 files changed, 223 insertions(+), 210 deletions(-) create mode 100644 src/input/state/core/index/grid.rs rename src/input/state/core/index/{ => grid}/tests.rs (97%) diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index eb7d0c89d..7ec2f8c7c 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -1,60 +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; -const MAX_SPATIAL_CELLS_PER_SHAPE: usize = 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(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>, - /// 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). - pub(super) shape_count: usize, -} +pub(super) use self::grid::SpatialGrid; impl InputState { /// Returns all shapes intersecting any of the provided points within tolerance. @@ -191,14 +142,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); } } @@ -309,156 +260,3 @@ impl InputState { self.hit_test_indices((0..len).rev(), x, y, tolerance) } } - -impl SpatialGrid { - fn build(frame: &Frame, cell_size: i32) -> Option { - Self::build_with_membership_limit(frame, 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) - } - - /// 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. - 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); - // 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) { - 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. - 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::with_capacity(self.global_shapes.len()); - unique.extend(self.global_shapes.iter().copied()); - 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() - } -} - -#[cfg(test)] -mod tests; diff --git a/src/input/state/core/index/grid.rs b/src/input/state/core/index/grid.rs new file mode 100644 index 000000000..a361b8b7f --- /dev/null +++ b/src/input/state/core/index/grid.rs @@ -0,0 +1,215 @@ +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; +// 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 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::with_capacity(self.global_shapes.len()); + unique.extend(self.global_shapes.iter().copied()); + 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() + } +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/index/tests.rs b/src/input/state/core/index/grid/tests.rs similarity index 97% rename from src/input/state/core/index/tests.rs rename to src/input/state/core/index/grid/tests.rs index 2e65a772c..0aff7e56d 100644 --- a/src/input/state/core/index/tests.rs +++ b/src/input/state/core/index/grid/tests.rs @@ -35,7 +35,7 @@ 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, SPATIAL_GRID_CELL_SIZE).expect("spatial grid"); + let grid = SpatialGrid::build(&frame).expect("spatial grid"); assert!(grid.cells.is_empty()); assert!(grid.shape_cells.is_empty()); @@ -123,7 +123,7 @@ fn cell_coverage_enforces_per_shape_limit_boundary() { 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, SPATIAL_GRID_CELL_SIZE).expect("spatial grid"); + 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")); From 84406bfce2c46d13229c39627ca7a1c337bcc306 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:55:15 +0200 Subject: [PATCH 5/6] fix(input): bound spatial query work --- src/input/state/core/index/grid.rs | 46 ++++++++++++--- src/input/state/core/index/grid/tests.rs | 75 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/input/state/core/index/grid.rs b/src/input/state/core/index/grid.rs index a361b8b7f..8371c9965 100644 --- a/src/input/state/core/index/grid.rs +++ b/src/input/state/core/index/grid.rs @@ -4,6 +4,8 @@ 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; @@ -188,20 +190,23 @@ impl SpatialGrid { /// 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 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 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 key = (cell_x + dx, cell_y + dy); - if let Some(ids) = self.cells.get(&key) { + 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()); } } @@ -209,6 +214,29 @@ impl SpatialGrid { 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)] diff --git a/src/input/state/core/index/grid/tests.rs b/src/input/state/core/index/grid/tests.rs index 0aff7e56d..079b13aa9 100644 --- a/src/input/state/core/index/grid/tests.rs +++ b/src/input/state/core/index/grid/tests.rs @@ -30,6 +30,20 @@ fn assert_membership_accounting(grid: &SpatialGrid) { } } +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(); @@ -224,3 +238,64 @@ fn reindexing_shape_beyond_remaining_budget_clears_old_cells_and_stays_queryable 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]) + ); +} From b87cdf45779c27ffa6a850c086680a1d579b4558 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 24 Aug 2026 07:59:57 +0200 Subject: [PATCH 6/6] fix(input): reject invalid hit tolerances --- src/config/tests/validate.rs | 14 +++++++++ src/config/types/drawing.rs | 5 +++- src/config/types/mod.rs | 1 + src/config/validate/drawing.rs | 9 +++++- src/input/hit_test/mod.rs | 17 +++++++++++ src/input/hit_test/tests.rs | 23 ++++++++++++++ src/input/state/core/index.rs | 8 ++++- src/input/state/tests/hit_testing.rs | 45 ++++++++++++++++++++++++++++ src/input/state/tests/mod.rs | 1 + 9 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/input/state/tests/hit_testing.rs diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 8d5375478..7d85c3748 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 2e1e82656..85e7b4d25 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 31e816f5b..47b83867c 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 71ae0e0a6..fd80ce5fa 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 63718d909..1ffbb6690 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 7a6db9ceb..194225180 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 7ec2f8c7c..5607ba086 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -24,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(); } @@ -116,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(); } diff --git a/src/input/state/tests/hit_testing.rs b/src/input/state/tests/hit_testing.rs new file mode 100644 index 000000000..2c83cdef9 --- /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 1e109ea42..b0f3cca78 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;