Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/config/tests/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/config/types/drawing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/config/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/config/validate/drawing.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/input/hit_test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64> {
(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<Rect> {
let tolerance = validated_tolerance(tolerance)?;
let base = shape.bounding_box()?;
if matches!(shape.shape, Shape::EraserStroke { .. }) {
return None;
Expand All @@ -25,6 +34,10 @@ pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option<Rect> {

/// 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)
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 23 additions & 0 deletions src/input/hit_test/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
137 changes: 13 additions & 124 deletions src/input/state/core/index.rs
Original file line number Diff line number Diff line change
@@ -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<ShapeId>>,
/// Reverse mapping from ShapeId to the cells it occupies for efficient removal.
pub(super) shape_cells: HashMap<ShapeId, Vec<(i32, i32)>>,
/// 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.
Expand All @@ -38,6 +24,10 @@ impl InputState {
points: &[(i32, i32)],
tolerance: f64,
) -> Vec<ShapeId> {
let Some(tolerance) = hit_test::validated_tolerance(tolerance) else {
return Vec::new();
};

if points.is_empty() {
return Vec::new();
}
Expand Down Expand Up @@ -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();
}

Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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<Self> {
let cell_size = cell_size.max(1);
if frame.shapes.is_empty() {
return None;
}

let mut cells: HashMap<(i32, i32), Vec<ShapeId>> = HashMap::new();
let mut shape_cells: HashMap<ShapeId, Vec<(i32, i32)>> = 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<ShapeId> {
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()
}
}
Loading
Loading