Skip to main content

rustsim_spaces/
continuous3d.rs

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