Skip to main content

rustsim_spaces/
continuous.rs

1//! 2D continuous space with spatial-hashing neighbor queries.
2//!
3//! [`ContinuousSpace2D`] spans `[0, extent_x) x [0, extent_y)` and uses a
4//! uniform grid of cells (side length = `spacing`) to accelerate neighbor
5//! lookups to O(k) where k is the number of agents in the neighborhood.
6//!
7//! Supports periodic (toroidal) and non-periodic (bounded) boundaries.
8//!
9//! Mirrors Julia Agents.jl `ContinuousSpace{2}`.
10
11use rand::Rng;
12use rustsim_core::{
13    interaction::{PositionedAgent, SpaceInteraction},
14    space::Space,
15    types::AgentId,
16};
17use std::collections::HashMap;
18use thiserror::Error;
19
20/// 2D position in continuous space.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct ContinuousPos {
23    /// X coordinate.
24    pub x: f64,
25    /// Y coordinate.
26    pub y: f64,
27}
28
29impl ContinuousPos {
30    /// Create a new position.
31    pub fn new(x: f64, y: f64) -> Self {
32        Self { x, y }
33    }
34
35    /// Euclidean distance to another position (ignoring periodic boundaries).
36    pub fn distance_to(&self, other: &Self) -> f64 {
37        let dx = self.x - other.x;
38        let dy = self.y - other.y;
39        (dx * dx + dy * dy).sqrt()
40    }
41
42    /// The origin `(0, 0)`.
43    pub fn zero() -> Self {
44        Self { x: 0.0, y: 0.0 }
45    }
46
47    /// Vector length (magnitude).
48    pub fn length(&self) -> f64 {
49        (self.x * self.x + self.y * self.y).sqrt()
50    }
51
52    /// Unit vector in the same direction, or zero if the length is negligible.
53    pub fn normalized(&self) -> Self {
54        let len = self.length();
55        if len < 1e-12 {
56            Self::zero()
57        } else {
58            Self::new(self.x / len, self.y / len)
59        }
60    }
61}
62
63impl std::ops::Add for ContinuousPos {
64    type Output = Self;
65    fn add(self, rhs: Self) -> Self {
66        Self::new(self.x + rhs.x, self.y + rhs.y)
67    }
68}
69
70impl std::ops::Sub for ContinuousPos {
71    type Output = Self;
72    fn sub(self, rhs: Self) -> Self {
73        Self::new(self.x - rhs.x, self.y - rhs.y)
74    }
75}
76
77impl std::ops::Mul<f64> for ContinuousPos {
78    type Output = Self;
79    fn mul(self, rhs: f64) -> Self {
80        Self::new(self.x * rhs, self.y * rhs)
81    }
82}
83
84/// Errors returned by continuous space configuration validation.
85#[derive(Debug, Clone, Copy, PartialEq, Error)]
86pub enum ContinuousSpaceConfigError {
87    /// Space extents must be strictly positive.
88    #[error("space extents must be positive")]
89    InvalidExtent,
90    /// Spatial-hash spacing must be strictly positive.
91    #[error("spacing must be positive")]
92    InvalidSpacing,
93}
94
95/// Errors returned by continuous space operations.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
97pub enum ContinuousSpaceError {
98    /// The position is outside the space extent (and the space is not periodic).
99    #[error("position is out of bounds")]
100    OutOfBounds,
101}
102
103/// 2D continuous space with spatial hashing.
104///
105/// Agents are tracked by position and indexed into a uniform grid for fast
106/// neighbor queries. The grid cell size (`spacing`) determines the tradeoff
107/// between query accuracy and `move_agent_pos` overhead.
108///
109/// # Boundary modes
110///
111/// - `periodic = true` - toroidal wrapping.
112/// - `periodic = false` - bounded; out-of-bounds positions return [`ContinuousSpaceError::OutOfBounds`].
113#[derive(Debug, Clone)]
114pub struct ContinuousSpace2D {
115    extent_x: f64,
116    extent_y: f64,
117    periodic: bool,
118    spacing: f64,
119    grid_w: usize,
120    grid_h: usize,
121    cells: Vec<Vec<AgentId>>,
122    agent_positions: HashMap<AgentId, ContinuousPos>,
123}
124
125impl ContinuousSpace2D {
126    /// Create a new 2D continuous space.
127    ///
128    /// # Arguments
129    ///
130    /// - `extent_x`, `extent_y` - space dimensions.
131    /// - `periodic` - whether boundaries wrap.
132    /// - `spacing` - cell side length for the spatial hash grid.
133    pub fn new(
134        extent_x: f64,
135        extent_y: f64,
136        periodic: bool,
137        spacing: f64,
138    ) -> Result<Self, ContinuousSpaceConfigError> {
139        if extent_x <= 0.0 || extent_y <= 0.0 {
140            return Err(ContinuousSpaceConfigError::InvalidExtent);
141        }
142        if spacing <= 0.0 {
143            return Err(ContinuousSpaceConfigError::InvalidSpacing);
144        }
145        let grid_w = (extent_x / spacing).ceil() as usize;
146        let grid_h = (extent_y / spacing).ceil() as usize;
147        let cells = vec![Vec::new(); grid_w * grid_h];
148        Ok(Self {
149            extent_x,
150            extent_y,
151            periodic,
152            spacing,
153            grid_w,
154            grid_h,
155            cells,
156            agent_positions: HashMap::new(),
157        })
158    }
159
160    /// Space dimensions as `(extent_x, extent_y)`.
161    pub fn extent(&self) -> (f64, f64) {
162        (self.extent_x, self.extent_y)
163    }
164
165    /// Whether the space wraps around (toroidal boundaries).
166    pub fn periodic(&self) -> bool {
167        self.periodic
168    }
169
170    /// Cell side length for the spatial hash grid.
171    pub fn spacing(&self) -> f64 {
172        self.spacing
173    }
174
175    /// Look up the current position of an agent.
176    pub fn agent_position(&self, id: AgentId) -> Option<&ContinuousPos> {
177        self.agent_positions.get(&id)
178    }
179
180    fn normalize_pos(&self, pos: ContinuousPos) -> Option<ContinuousPos> {
181        if self.periodic {
182            let x = ((pos.x % self.extent_x) + self.extent_x) % self.extent_x;
183            let y = ((pos.y % self.extent_y) + self.extent_y) % self.extent_y;
184            Some(ContinuousPos::new(x, y))
185        } else if pos.x >= 0.0 && pos.x < self.extent_x && pos.y >= 0.0 && pos.y < self.extent_y {
186            Some(pos)
187        } else {
188            None
189        }
190    }
191
192    fn pos_to_cell(&self, pos: &ContinuousPos) -> (usize, usize) {
193        let cx = ((pos.x / self.spacing).floor() as usize).min(self.grid_w - 1);
194        let cy = ((pos.y / self.spacing).floor() as usize).min(self.grid_h - 1);
195        (cx, cy)
196    }
197
198    fn cell_index(&self, cx: usize, cy: usize) -> usize {
199        cy * self.grid_w + cx
200    }
201
202    fn add_to_grid(&mut self, id: AgentId, pos: &ContinuousPos) {
203        let (cx, cy) = self.pos_to_cell(pos);
204        let idx = self.cell_index(cx, cy);
205        self.cells[idx].push(id);
206    }
207
208    fn remove_from_grid(&mut self, id: AgentId, pos: &ContinuousPos) {
209        let (cx, cy) = self.pos_to_cell(pos);
210        let idx = self.cell_index(cx, cy);
211        if let Some(i) = self.cells[idx].iter().position(|v| *v == id) {
212            self.cells[idx].swap_remove(i);
213        }
214    }
215
216    /// Move an agent to a new position, updating the spatial hash.
217    ///
218    /// Returns the (possibly normalized) final position, or an error if
219    /// the position is out of bounds in a non-periodic space.
220    pub fn move_agent_pos(
221        &mut self,
222        id: AgentId,
223        new_pos: ContinuousPos,
224    ) -> Result<ContinuousPos, ContinuousSpaceError> {
225        let new_pos = self
226            .normalize_pos(new_pos)
227            .ok_or(ContinuousSpaceError::OutOfBounds)?;
228
229        if let Some(old_pos) = self.agent_positions.get(&id).copied() {
230            let old_cell = self.pos_to_cell(&old_pos);
231            let new_cell = self.pos_to_cell(&new_pos);
232            if old_cell != new_cell {
233                self.remove_from_grid(id, &old_pos);
234                self.add_to_grid(id, &new_pos);
235            }
236            self.agent_positions.insert(id, new_pos);
237        }
238        Ok(new_pos)
239    }
240
241    fn euclidean_distance_wrapped(&self, a: &ContinuousPos, b: &ContinuousPos) -> f64 {
242        let mut dx = (a.x - b.x).abs();
243        let mut dy = (a.y - b.y).abs();
244        if self.periodic {
245            if dx > self.extent_x / 2.0 {
246                dx = self.extent_x - dx;
247            }
248            if dy > self.extent_y / 2.0 {
249                dy = self.extent_y - dy;
250            }
251        }
252        (dx * dx + dy * dy).sqrt()
253    }
254
255    /// Find all agent IDs within Euclidean distance `radius` of `pos`.
256    ///
257    /// Respects periodic boundaries when enabled.
258    pub fn nearby_ids_euclidean(&self, pos: &ContinuousPos, radius: f64) -> Vec<AgentId> {
259        let grid_r = ((radius / self.spacing).ceil() as i32) + 1;
260        let (focal_cx, focal_cy) = self.pos_to_cell(pos);
261        let mut ids = Vec::new();
262
263        for dy in -grid_r..=grid_r {
264            for dx in -grid_r..=grid_r {
265                let nx = focal_cx as i32 + dx;
266                let ny = focal_cy as i32 + dy;
267
268                let cell = if self.periodic {
269                    let px = ((nx % self.grid_w as i32) + self.grid_w as i32) % self.grid_w as i32;
270                    let py = ((ny % self.grid_h as i32) + self.grid_h as i32) % self.grid_h as i32;
271                    Some((px as usize, py as usize))
272                } else if nx >= 0
273                    && ny >= 0
274                    && (nx as usize) < self.grid_w
275                    && (ny as usize) < self.grid_h
276                {
277                    Some((nx as usize, ny as usize))
278                } else {
279                    None
280                };
281
282                if let Some((cx, cy)) = cell {
283                    let idx = self.cell_index(cx, cy);
284                    for &id in &self.cells[idx] {
285                        if let Some(agent_pos) = self.agent_positions.get(&id) {
286                            if self.euclidean_distance_wrapped(pos, agent_pos) <= radius {
287                                ids.push(id);
288                            }
289                        }
290                    }
291                }
292            }
293        }
294        ids
295    }
296}
297
298impl Space for ContinuousSpace2D {}
299
300impl<A> SpaceInteraction<A> for ContinuousSpace2D
301where
302    A: PositionedAgent<Position = ContinuousPos>,
303{
304    type Error = ContinuousSpaceError;
305
306    fn random_position<R: rand::RngCore>(&self, rng: &mut R) -> A::Position {
307        ContinuousPos::new(
308            rng.gen_range(0.0..self.extent_x),
309            rng.gen_range(0.0..self.extent_y),
310        )
311    }
312
313    fn add_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
314        let pos = self
315            .normalize_pos(*agent.position())
316            .ok_or(ContinuousSpaceError::OutOfBounds)?;
317        let id = agent.id();
318        self.agent_positions.insert(id, pos);
319        self.add_to_grid(id, &pos);
320        Ok(())
321    }
322
323    fn remove_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
324        let id = agent.id();
325        if let Some(pos) = self.agent_positions.remove(&id) {
326            self.remove_from_grid(id, &pos);
327        }
328        Ok(())
329    }
330
331    fn nearby_ids(&self, position: &A::Position, radius: usize) -> Vec<AgentId> {
332        self.nearby_ids_euclidean(position, radius as f64)
333    }
334}