mod bbox;
mod index;
mod iter;
mod node;
pub use bbox::{BoundingBoxN, SpatialEntryN};
pub use index::SpatialIndexN;
pub use iter::SpatialIterN;
const DEFAULT_MAX_ENTRIES: usize = 9;
const DEFAULT_MIN_ENTRIES: usize = 4;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SplitStrategy {
Linear = 0,
#[default]
RStar = 1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpatialConfig {
max_entries: usize,
min_entries: usize,
split_strategy: SplitStrategy,
}
impl SpatialConfig {
pub const DEFAULT: Self = Self {
max_entries: DEFAULT_MAX_ENTRIES,
min_entries: DEFAULT_MIN_ENTRIES,
split_strategy: SplitStrategy::RStar,
};
pub const fn new(max_entries: usize) -> Result<Self, SpatialError> {
if max_entries < 4 {
return Err(SpatialError::InvalidConfig);
}
Ok(Self {
max_entries,
min_entries: max_entries / 2,
split_strategy: SplitStrategy::RStar,
})
}
pub const fn with_strategy(
max_entries: usize,
strategy: SplitStrategy,
) -> Result<Self, SpatialError> {
if max_entries < 4 {
return Err(SpatialError::InvalidConfig);
}
Ok(Self {
max_entries,
min_entries: max_entries / 2,
split_strategy: strategy,
})
}
#[must_use]
pub const fn max_entries(self) -> usize {
self.max_entries
}
#[must_use]
pub const fn min_entries(self) -> usize {
self.min_entries
}
#[must_use]
pub const fn split_strategy(self) -> SplitStrategy {
self.split_strategy
}
}
impl Default for SpatialConfig {
fn default() -> Self {
Self::DEFAULT
}
}
pub type BoundingBox = BoundingBoxN<2>;
pub type BoundingBox3D = BoundingBoxN<3>;
pub type SpatialEntry<T> = SpatialEntryN<2, T>;
pub type SpatialEntry3D<T> = SpatialEntryN<3, T>;
pub type SpatialIndex<T> = SpatialIndexN<2, T>;
pub type SpatialIndex3D<T> = SpatialIndexN<3, T>;
pub type SpatialIter<'a, T> = SpatialIterN<'a, 2, T>;
#[allow(dead_code)]
pub type SpatialIter3D<'a, T> = SpatialIterN<'a, 3, T>;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum SpatialError {
#[error("invalid bounding box: width and height must be non-negative")]
InvalidBounds,
#[error("entry not found in spatial index")]
NotFound,
#[error("invalid radius: must be non-negative and finite")]
InvalidRadius,
#[error("invalid 3D bounding box: width, height, and depth must be non-negative")]
InvalidBounds3D,
#[error("invalid config: max_entries must be at least 4")]
InvalidConfig,
}
#[cfg(test)]
mod tests {
use super::node::NodeN;
use super::*;
use bitcode;
#[test]
fn test_node_bounds_empty_leaf() {
let node: NodeN<2, u32> = NodeN::Leaf {
entries: Vec::new(),
};
assert!(node.bounds().is_none());
}
#[test]
fn test_node_bounds_empty_internal() {
let node: NodeN<2, u32> = NodeN::Internal {
children: Vec::new(),
};
assert!(node.bounds().is_none());
}
#[test]
fn test_internal_node_len() {
let mut index = SpatialIndex::new();
for i in 0..50u32 {
index.insert(SpatialEntry {
bounds: BoundingBox::new(i as f32, 0.0, 1.0, 1.0).unwrap(),
data: i,
});
}
assert_eq!(index.len(), 50);
assert_eq!(index.root.len(), 50);
}
#[test]
fn test_node_3d_bounds_empty_leaf() {
let node: NodeN<3, u32> = NodeN::Leaf {
entries: Vec::new(),
};
assert!(node.bounds().is_none());
}
#[test]
fn test_node_3d_bounds_empty_internal() {
let node: NodeN<3, u32> = NodeN::Internal {
children: Vec::new(),
};
assert!(node.bounds().is_none());
}
#[test]
fn test_spatial_index_3d_internal_node_len() {
let mut index = SpatialIndex3D::new();
for i in 0..50u32 {
index.insert(SpatialEntry3D {
bounds: BoundingBox3D::new(i as f32, 0.0, 0.0, 1.0, 1.0, 1.0).unwrap(),
data: i,
});
}
assert_eq!(index.len(), 50);
assert_eq!(index.root.len(), 50);
}
#[test]
fn test_bulk_load_tree_is_internal_for_large_input() {
let entries: Vec<SpatialEntry<u32>> = (0..50)
.map(|i| SpatialEntry {
bounds: BoundingBox::new(i as f32, 0.0, 1.0, 1.0).unwrap(),
data: i,
})
.collect();
let index = SpatialIndex::bulk_load(entries);
assert_eq!(index.len(), 50);
assert_eq!(index.root.len(), 50);
assert!(
matches!(index.root, NodeN::Internal { .. }),
"50 entries should produce an Internal root"
);
}
#[test]
fn test_remove_condense_leaf_underflow() {
let mut index = SpatialIndex::new();
for i in 0..20u32 {
index.insert(SpatialEntry {
bounds: BoundingBox::new(i as f32 * 2.0, 0.0, 1.0, 1.0).unwrap(),
data: i,
});
}
assert_eq!(index.len(), 20);
for i in 0..10u32 {
let bb = BoundingBox::new(i as f32 * 2.0, 0.0, 1.0, 1.0).unwrap();
index.remove(bb, |e| e.data == i).unwrap();
}
assert_eq!(index.len(), 10);
assert_eq!(index.root.len(), 10);
let big_region = BoundingBox::new(-1.0, -1.0, 100.0, 100.0).unwrap();
let results = index.query_region(big_region);
assert_eq!(results.len(), 10);
let mut found: Vec<u32> = results.iter().map(|e| e.data).collect();
found.sort_unstable();
assert_eq!(found, (10..20).collect::<Vec<u32>>());
}
#[test]
fn test_remove_condense_root_shrink() {
let mut index = SpatialIndex::new();
for i in 0..15u32 {
index.insert(SpatialEntry {
bounds: BoundingBox::new(i as f32, 0.0, 1.0, 1.0).unwrap(),
data: i,
});
}
assert!(matches!(index.root, NodeN::Internal { .. }));
for i in 0..12u32 {
let bb = BoundingBox::new(i as f32, 0.0, 1.0, 1.0).unwrap();
index.remove(bb, |e| e.data == i).unwrap();
}
assert_eq!(index.len(), 3);
assert_eq!(index.root.len(), 3);
assert!(
matches!(index.root, NodeN::Leaf { .. }),
"root should be a Leaf after heavy deletion"
);
}
#[test]
fn test_remove_region_filters_leaf_matches() {
let mut index = SpatialIndex::new();
index.insert(SpatialEntry {
bounds: BoundingBox::new(100.0, 100.0, 1.0, 1.0).unwrap(),
data: "dup",
});
index.insert(SpatialEntry {
bounds: BoundingBox::new(0.0, 0.0, 1.0, 1.0).unwrap(),
data: "dup",
});
let region = BoundingBox::new(0.0, 0.0, 1.0, 1.0).unwrap();
index.remove(region, |e| e.data == "dup").unwrap();
assert_eq!(index.len(), 1);
let remaining = index.iter().next().unwrap();
assert!(
(remaining.bounds.x() - 100.0).abs() < f32::EPSILON,
"wrong entry removed: expected x=100, got x={}",
remaining.bounds.x()
);
}
#[test]
fn test_bbox_2d_serde_rejects_negative_extent() {
let raw: [f32; 4] = [0.0, 0.0, -1.0, 1.0];
let bytes = bitcode::serialize(&raw).unwrap();
let result: Result<BoundingBox, _> = bitcode::deserialize(&bytes);
assert!(
result.is_err(),
"2D bbox with negative width must be rejected"
);
}
}