rustsim-spaces 0.0.1

Space implementations (grid, continuous, graph, hybrid) for rustsim
Documentation
//! 2D grid spaces with integer coordinates.
//!
//! Two variants are provided:
//!
//! - [`Grid2D`] - multi-occupancy: each cell can hold any number of agents.
//! - [`Grid2DSingle`] - single-occupancy: each cell holds at most one agent.
//!
//! Both support periodic (toroidal) and non-periodic (bounded) boundaries.
//! Neighbor queries use Chebyshev distance (8-connected / square neighborhood).
//!
//! Mirrors Julia Agents.jl `GridSpace` and `GridSpaceSingle`.

use rand::Rng;
use rustsim_core::{
    interaction::{PositionedAgent, SpaceInteraction},
    space::Space,
    types::AgentId,
};
use thiserror::Error;

/// Position on a 2D grid: `(x, y)` with `0 ? x < width`, `0 ? y < height`.
pub type GridPos2 = (usize, usize);

/// Errors returned by grid space operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum GridError {
    /// The position is outside the grid bounds (and the grid is not periodic).
    #[error("position ({}, {}) is out of bounds", .0.0, .0.1)]
    OutOfBounds(GridPos2),
    /// The cell is already occupied (single-occupancy grid only).
    #[error("position ({}, {}) is already occupied", .0.0, .0.1)]
    Occupied(GridPos2),
}

/// Multi-occupancy 2D grid space.
///
/// Each cell can hold an arbitrary number of agents. Backed by a flat
/// `Vec<Vec<AgentId>>` of size `width x height`.
///
/// # Boundary modes
///
/// - `periodic = true` - toroidal wrapping (positions are taken modulo grid size).
/// - `periodic = false` - bounded (out-of-bounds positions return an error).
#[derive(Debug, Clone)]
pub struct Grid2D {
    width: usize,
    height: usize,
    periodic: bool,
    cells: Vec<Vec<AgentId>>,
}

impl Grid2D {
    /// Create a new multi-occupancy grid.
    ///
    /// # Panics
    ///
    /// Panics if `width` or `height` is zero.
    pub fn new(width: usize, height: usize, periodic: bool) -> Self {
        assert!(width > 0 && height > 0, "grid dimensions must be positive");
        let cells = vec![Vec::new(); width * height];
        Self {
            width,
            height,
            periodic,
            cells,
        }
    }

    /// Grid dimensions as `(width, height)`.
    pub fn dimensions(&self) -> (usize, usize) {
        (self.width, self.height)
    }

    /// Whether the grid wraps around (toroidal boundaries).
    pub fn periodic(&self) -> bool {
        self.periodic
    }

    /// Check whether a position is within the grid bounds.
    pub fn is_in_bounds(&self, pos: GridPos2) -> bool {
        pos.0 < self.width && pos.1 < self.height
    }

    /// Check whether a cell has no agents.
    pub fn is_empty(&self, pos: GridPos2) -> bool {
        match self.normalize_pos(pos) {
            Some(pos) => self.cells[self.index(pos)].is_empty(),
            None => true,
        }
    }

    /// Return the agent IDs at a given position, or `None` if out of bounds.
    pub fn ids_in_position(&self, pos: GridPos2) -> Option<&[AgentId]> {
        let pos = self.normalize_pos(pos)?;
        Some(&self.cells[self.index(pos)])
    }

    /// Register an agent at a position.
    pub fn add_agent(&mut self, pos: GridPos2, id: AgentId) -> Result<(), GridError> {
        let pos = self.normalize_pos(pos).ok_or(GridError::OutOfBounds(pos))?;
        let index = self.index(pos);
        self.cells[index].push(id);
        Ok(())
    }

    /// Deregister an agent from a position. Returns `Ok(true)` if found and removed.
    pub fn remove_agent(&mut self, pos: GridPos2, id: AgentId) -> Result<bool, GridError> {
        let pos = self.normalize_pos(pos).ok_or(GridError::OutOfBounds(pos))?;
        let index = self.index(pos);
        let cell = &mut self.cells[index];
        if let Some(index) = cell.iter().position(|value| *value == id) {
            cell.swap_remove(index);
            Ok(true)
        } else {
            Ok(false)
        }
    }

    fn normalize_pos(&self, pos: GridPos2) -> Option<GridPos2> {
        if self.periodic {
            Some((pos.0 % self.width, pos.1 % self.height))
        } else if self.is_in_bounds(pos) {
            Some(pos)
        } else {
            None
        }
    }

    fn index(&self, pos: GridPos2) -> usize {
        pos.1 * self.width + pos.0
    }
}

impl Space for Grid2D {}

impl<A> SpaceInteraction<A> for Grid2D
where
    A: PositionedAgent<Position = GridPos2>,
{
    type Error = GridError;

    fn random_position<R: rand::RngCore>(&self, rng: &mut R) -> A::Position {
        let x = rng.gen_range(0..self.width);
        let y = rng.gen_range(0..self.height);
        (x, y)
    }

    fn add_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
        self.add_agent(*agent.position(), agent.id())
    }

    fn remove_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
        self.remove_agent(*agent.position(), agent.id()).map(|_| ())
    }

    fn nearby_ids(&self, position: &A::Position, radius: usize) -> Vec<AgentId> {
        let mut ids = Vec::new();
        let radius = radius as i32;
        let (cx, cy) = (position.0 as i32, position.1 as i32);
        for dy in -radius..=radius {
            for dx in -radius..=radius {
                let x = cx + dx;
                let y = cy + dy;
                let pos = if self.periodic {
                    let px = ((x % self.width as i32) + self.width as i32) % self.width as i32;
                    let py = ((y % self.height as i32) + self.height as i32) % self.height as i32;
                    Some((px as usize, py as usize))
                } else if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
                    Some((x as usize, y as usize))
                } else {
                    None
                };

                if let Some(pos) = pos {
                    ids.extend(self.cells[self.index(pos)].iter().copied());
                }
            }
        }
        ids
    }
}

/// Single-occupancy 2D grid space.
///
/// Each cell holds at most one agent. Attempting to add an agent to an
/// occupied cell returns [`GridError::Occupied`].
///
/// Backed by a flat `Vec<Option<AgentId>>` of size `width x height`.
#[derive(Debug, Clone)]
pub struct Grid2DSingle {
    width: usize,
    height: usize,
    periodic: bool,
    cells: Vec<Option<AgentId>>,
}

impl Grid2DSingle {
    /// Create a new single-occupancy grid.
    ///
    /// # Panics
    ///
    /// Panics if `width` or `height` is zero.
    pub fn new(width: usize, height: usize, periodic: bool) -> Self {
        assert!(width > 0 && height > 0, "grid dimensions must be positive");
        let cells = vec![None; width * height];
        Self {
            width,
            height,
            periodic,
            cells,
        }
    }

    /// Grid dimensions as `(width, height)`.
    pub fn dimensions(&self) -> (usize, usize) {
        (self.width, self.height)
    }

    /// Whether the grid wraps around (toroidal boundaries).
    pub fn periodic(&self) -> bool {
        self.periodic
    }

    /// Check whether a position is within the grid bounds.
    pub fn is_in_bounds(&self, pos: GridPos2) -> bool {
        pos.0 < self.width && pos.1 < self.height
    }

    /// Check whether a cell is unoccupied.
    pub fn is_empty(&self, pos: GridPos2) -> bool {
        match self.normalize_pos(pos) {
            Some(pos) => self.cells[self.index(pos)].is_none(),
            None => true,
        }
    }

    /// Return the agent ID at a given position, or `None` if empty or out of bounds.
    pub fn id_in_position(&self, pos: GridPos2) -> Option<AgentId> {
        let pos = self.normalize_pos(pos)?;
        self.cells[self.index(pos)]
    }

    /// Register an agent at a position. Returns [`GridError::Occupied`] if the cell is taken.
    pub fn add_agent(&mut self, pos: GridPos2, id: AgentId) -> Result<(), GridError> {
        let pos = self.normalize_pos(pos).ok_or(GridError::OutOfBounds(pos))?;
        let index = self.index(pos);
        let cell = &mut self.cells[index];
        if cell.is_some() {
            return Err(GridError::Occupied(pos));
        }
        *cell = Some(id);
        Ok(())
    }

    /// Deregister an agent from a position. Returns `Ok(true)` if found and removed.
    pub fn remove_agent(&mut self, pos: GridPos2, id: AgentId) -> Result<bool, GridError> {
        let pos = self.normalize_pos(pos).ok_or(GridError::OutOfBounds(pos))?;
        let index = self.index(pos);
        let cell = &mut self.cells[index];
        if cell == &Some(id) {
            *cell = None;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    fn normalize_pos(&self, pos: GridPos2) -> Option<GridPos2> {
        if self.periodic {
            Some((pos.0 % self.width, pos.1 % self.height))
        } else if self.is_in_bounds(pos) {
            Some(pos)
        } else {
            None
        }
    }

    fn index(&self, pos: GridPos2) -> usize {
        pos.1 * self.width + pos.0
    }
}

impl Space for Grid2DSingle {}

impl<A> SpaceInteraction<A> for Grid2DSingle
where
    A: PositionedAgent<Position = GridPos2>,
{
    type Error = GridError;

    fn random_position<R: rand::RngCore>(&self, rng: &mut R) -> A::Position {
        let x = rng.gen_range(0..self.width);
        let y = rng.gen_range(0..self.height);
        (x, y)
    }

    fn add_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
        self.add_agent(*agent.position(), agent.id())
    }

    fn remove_agent(&mut self, agent: &A) -> Result<(), Self::Error> {
        self.remove_agent(*agent.position(), agent.id()).map(|_| ())
    }

    fn nearby_ids(&self, position: &A::Position, radius: usize) -> Vec<AgentId> {
        let mut ids = Vec::new();
        let radius = radius as i32;
        let (cx, cy) = (position.0 as i32, position.1 as i32);
        for dy in -radius..=radius {
            for dx in -radius..=radius {
                let x = cx + dx;
                let y = cy + dy;
                let pos = if self.periodic {
                    let px = ((x % self.width as i32) + self.width as i32) % self.width as i32;
                    let py = ((y % self.height as i32) + self.height as i32) % self.height as i32;
                    Some((px as usize, py as usize))
                } else if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
                    Some((x as usize, y as usize))
                } else {
                    None
                };

                if let Some(pos) = pos {
                    if let Some(id) = self.cells[self.index(pos)] {
                        ids.push(id);
                    }
                }
            }
        }
        ids
    }
}