From cda2d0ba3aad8153de7bdc42ed140641f6541d14 Mon Sep 17 00:00:00 2001 From: rozgo Date: Tue, 1 Jul 2025 23:17:29 -0600 Subject: [PATCH 01/23] spatial grid --- README.md | 17 + examples/README.md | 8 + examples/forest_fire.rs | 363 +++++++++++++++++++++ examples/pandemic_spatial.rs | 593 +++++++++++++++++++++++++++++++++++ rust-toolchain.toml | 2 + src/plugins/mod.rs | 3 + src/plugins/spatial_grid.rs | 364 +++++++++++++++++++++ src/plugins/time_series.rs | 2 +- src/prelude.rs | 6 +- src/simulation_builder.rs | 25 +- tests/mod.rs | 1 + tests/test_spatial_grid.rs | 269 ++++++++++++++++ 12 files changed, 1650 insertions(+), 3 deletions(-) create mode 100644 examples/forest_fire.rs create mode 100644 examples/pandemic_spatial.rs create mode 100644 rust-toolchain.toml create mode 100644 src/plugins/spatial_grid.rs create mode 100644 tests/test_spatial_grid.rs diff --git a/README.md b/README.md index 7a57848..ac0f09e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ In-depth knowledge of Bevy's internals is not required however, since we have ab use incerto::prelude::*; let simulation: Simulation = SimulationBuilder::new() + // add resources if needed + .insert_resource(...) // add one or more entity spawners .add_entity_spawner(...) .add_entity_spawner(...) @@ -175,6 +177,21 @@ Currently the following ways of fetching simulation results are supported. - Record and collect time series data of values sampled from components. Call `builder.record_time_series::()` on the builder to set up the recording, and then `simulation.get_time_series::()` to collect the results. +## Examples + +The crate includes several comprehensive examples demonstrating different simulation patterns: + +### Basic Simulations +- **[Counter](examples/counter.rs)** - Simple entity counting and state management +- **[Pandemic](examples/pandemic.rs)** - Basic disease spread simulation with random movement +- **[Step Counter](examples/step_counter.rs)** - Demonstrates the built-in step counter plugin +- **[Traders](examples/traders.rs)** - Time series collection with multiple trader agents + +### Spatial Simulations +- **[Forest Fire](examples/forest_fire.rs)** - Cellular automaton fire spread with spatial grid optimization +- **[Pandemic Spatial](examples/pandemic_spatial.rs)** - Advanced epidemic modeling with infection radius, social distancing, contact tracing, and quarantine zones + + ## Performance When it comes to experiments like Monte Carlo, performance is typically of paramount importance since it defines their limits in terms of scope, size, length and granularity. Hence why I made the decision build this crate on top of bevy. The ECS architecture on offer here is likely the most memory-efficient and parallelizable way one can build such simulations, while still maintaining some agency of high-level programming. diff --git a/examples/README.md b/examples/README.md index 59d5684..8b63e98 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,4 +10,12 @@ - The most complicated example. - Two different kinds of entities interacting with each other. - Data is sampled as a time series and plotted. +- **[forest_fire.rs](forest_fire.rs)** + - Spatial cellular automaton simulation. + - Demonstrates grid-based entities and neighborhood interactions. + - Shows probabilistic fire spread with time series analysis. +- **[pandemic_spatial.rs](pandemic_spatial.rs)** + - Advanced epidemic simulation with spatial features. + - Infection radius, social distancing, contact tracing, and quarantine zones. + - Demonstrates realistic epidemic modeling with spatial grid optimization. diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs new file mode 100644 index 0000000..e65fb0c --- /dev/null +++ b/examples/forest_fire.rs @@ -0,0 +1,363 @@ +//! # Monte Carlo simulation of forest fire spread. +//! +//! This example showcases a spatial cellular automaton simulation where fire spreads +//! through a forest based on probabilistic rules. The simulation demonstrates: +//! +//! * Spatial grid-based entities using the `SpatialGridPlugin` +//! * Entity state transitions (Healthy → Burning → Burned → Empty) +//! * Neighborhood interactions for fire spreading +//! * Time series collection of fire statistics +//! * Configurable fire spread parameters for Monte Carlo analysis +//! +//! Each cell in the forest can be in one of four states: +//! * **Healthy**: Can catch fire from burning neighbors +//! * **Burning**: Spreads fire to healthy neighbors, burns for a duration +//! * **Burned**: No longer spreads fire, can't burn again +//! * **Empty**: Vacant land that can regrow over time +//! +//! The simulation allows for studying fire spread patterns, firebreak effectiveness, +//! and forest management strategies under different conditions. + +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::cast_precision_loss)] + +use incerto::prelude::*; +use rand::prelude::*; + +// Simulation parameters +const SIMULATION_STEPS: usize = 500; +const GRID_WIDTH: i32 = 50; +const GRID_HEIGHT: i32 = 50; + +// Fire parameters +const INITIAL_FOREST_DENSITY: f64 = 0.7; // Probability a cell starts as forest +const FIRE_SPREAD_PROBABILITY: f64 = 0.6; // Probability fire spreads to neighbor +const BURN_DURATION: usize = 3; // Steps a cell burns before becoming burned +const REGROWTH_PROBABILITY: f64 = 0.001; // Probability empty cell becomes forest +const INITIAL_FIRE_COUNT: usize = 3; // Number of initial fire sources + +// Time series sampling +const SAMPLE_INTERVAL: usize = 1; + +/// Represents the state of a forest cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellState +{ + /// Healthy forest that can catch fire + Healthy, + /// Currently burning, will spread fire + Burning + { + remaining_burn_time: usize + }, + /// Already burned, cannot burn again + Burned, + /// Empty land that can regrow + Empty, +} + +/// Component representing a single cell in the forest grid. +#[derive(Component, Debug)] +pub struct ForestCell +{ + pub state: CellState, +} + +impl Default for ForestCell +{ + fn default() -> Self + { + Self { + state: CellState::Empty, + } + } +} + +/// Fire statistics collected during simulation. +#[derive(Debug, Clone, Copy)] +pub struct FireStats +{ + pub healthy_count: usize, + pub burning_count: usize, + pub burned_count: usize, + pub empty_count: usize, + pub total_cells: usize, +} + +impl FireStats +{ + #[must_use] + pub fn fire_activity(&self) -> f64 + { + self.burning_count as f64 / self.total_cells as f64 + } + + #[must_use] + pub fn forest_coverage(&self) -> f64 + { + (self.healthy_count + self.burning_count) as f64 / self.total_cells as f64 + } + + #[must_use] + pub fn burned_percentage(&self) -> f64 + { + self.burned_count as f64 / self.total_cells as f64 + } +} + +/// Implement sampling to collect fire statistics. +impl Sample for ForestCell +{ + fn sample(components: &[&Self]) -> FireStats + { + let mut healthy_count = 0; + let mut burning_count = 0; + let mut burned_count = 0; + let mut empty_count = 0; + + for cell in components + { + match cell.state + { + CellState::Healthy => healthy_count += 1, + CellState::Burning { .. } => burning_count += 1, + CellState::Burned => burned_count += 1, + CellState::Empty => empty_count += 1, + } + } + + FireStats { + healthy_count, + burning_count, + burned_count, + empty_count, + total_cells: components.len(), + } + } +} + +fn main() +{ + println!("šŸ”„ Starting Forest Fire Simulation"); + println!("Grid size: {GRID_WIDTH}x{GRID_HEIGHT}"); + println!( + "Initial forest density: {:.1}%", + INITIAL_FOREST_DENSITY * 100.0 + ); + println!( + "Fire spread probability: {:.1}%", + FIRE_SPREAD_PROBABILITY * 100.0 + ); + println!("Simulation steps: {SIMULATION_STEPS}"); + println!(); + + // Build the simulation + let bounds = GridBounds::new(0, GRID_WIDTH - 1, 0, GRID_HEIGHT - 1); + let mut simulation = SimulationBuilder::new() + // Add spatial grid support + .add_spatial_grid(bounds) + // Spawn the forest grid + .add_entity_spawner(spawn_forest_grid) + // Add fire spread system + .add_systems(fire_spread_system) + // Add burn progression system + .add_systems(burn_progression_system) + // Add regrowth system + .add_systems(regrowth_system) + // Record time series of fire statistics + .record_time_series::(SAMPLE_INTERVAL) + .expect("Failed to set up time series recording") + .build(); + + // Run the simulation + println!("Running simulation..."); + simulation.run(SIMULATION_STEPS); + + // Collect and display results + let final_stats = simulation + .sample::() + .expect("Failed to sample fire statistics"); + + println!("šŸ“Š Final Statistics:"); + println!( + " Healthy forest: {} cells ({:.1}%)", + final_stats.healthy_count, + final_stats.healthy_count as f64 / final_stats.total_cells as f64 * 100.0 + ); + println!( + " Currently burning: {} cells ({:.1}%)", + final_stats.burning_count, + final_stats.fire_activity() * 100.0 + ); + println!( + " Burned areas: {} cells ({:.1}%)", + final_stats.burned_count, + final_stats.burned_percentage() * 100.0 + ); + println!( + " Empty land: {} cells ({:.1}%)", + final_stats.empty_count, + final_stats.empty_count as f64 / final_stats.total_cells as f64 * 100.0 + ); + + // Display time series summary + let time_series = simulation + .get_time_series::() + .expect("Failed to get time series data"); + + println!("\nšŸ“ˆ Time Series Summary:"); + println!(" Data points collected: {}", time_series.len()); + + if let Some(peak_fire) = time_series + .iter() + .max_by(|a, b| a.burning_count.cmp(&b.burning_count)) + { + println!( + " Peak fire activity: {} burning cells ({:.1}%)", + peak_fire.burning_count, + peak_fire.fire_activity() * 100.0 + ); + } + + let total_burned = time_series.last().map_or(0, |stats| stats.burned_count); + println!( + " Total area burned: {} cells ({:.1}%)", + total_burned, + total_burned as f64 / final_stats.total_cells as f64 * 100.0 + ); +} + +/// Spawn the initial forest grid with random forest coverage. +fn spawn_forest_grid(spawner: &mut Spawner) +{ + let mut rng = rand::rng(); + + // Spawn all grid cells + for x in 0..GRID_WIDTH + { + for y in 0..GRID_HEIGHT + { + let position = GridPosition::new(x, y); + + // Determine initial state + let state = if rng.random_bool(INITIAL_FOREST_DENSITY) + { + CellState::Healthy + } + else + { + CellState::Empty + }; + + let cell = ForestCell { state }; + spawner.spawn((position, cell)); + } + } + + // Start some initial fires at random locations + let healthy_positions: Vec = (0..GRID_WIDTH) + .flat_map(|x| (0..GRID_HEIGHT).map(move |y| GridPosition::new(x, y))) + .collect(); + + // This is a simplified approach - in a real implementation you'd query existing entities + // For this example, we'll start fires by spawning burning cells at random positions + for _ in 0..INITIAL_FIRE_COUNT + { + if let Some(&pos) = healthy_positions.choose(&mut rng) + { + let burning_cell = ForestCell { + state: CellState::Burning { + remaining_burn_time: BURN_DURATION, + }, + }; + spawner.spawn((pos, burning_cell)); + } + } +} + +/// System that handles fire spreading to neighboring cells using `SpatialGrid`. +fn fire_spread_system( + spatial_grid: Res, + query_burning: Query<(Entity, &GridPosition), With>, + mut query_cells: Query<(&GridPosition, &mut ForestCell)>, +) +{ + let mut rng = rand::rng(); + let mut spread_positions = Vec::new(); + + // Find all burning cells + for (burning_entity, burning_pos) in &query_burning + { + // Check if this cell is actually burning + if let Ok((_, cell)) = query_cells.get(burning_entity) + && matches!(cell.state, CellState::Burning { .. }) + { + // Get orthogonal neighbors using SpatialGrid + let neighbors = spatial_grid.orthogonal_neighbors_of(burning_pos); + + for neighbor_entity in neighbors + { + if let Ok((neighbor_pos, neighbor_cell)) = query_cells.get(neighbor_entity) + { + // Check if neighbor is healthy and can catch fire + if matches!(neighbor_cell.state, CellState::Healthy) + { + // Fire spreads with probability + if rng.random_bool(FIRE_SPREAD_PROBABILITY) + { + spread_positions.push(*neighbor_pos); + } + } + } + } + } + } + + // Apply fire spread + for (position, mut cell) in &mut query_cells + { + if spread_positions.contains(position) + { + cell.state = CellState::Burning { + remaining_burn_time: BURN_DURATION, + }; + } + } +} + +/// System that progresses burning cells through their burn cycle. +fn burn_progression_system(mut query: Query<&mut ForestCell>) +{ + for mut cell in &mut query + { + if let CellState::Burning { + remaining_burn_time, + } = &mut cell.state + { + if *remaining_burn_time > 1 + { + *remaining_burn_time -= 1; + } + else + { + // Fire burns out, cell becomes burned + cell.state = CellState::Burned; + } + } + } +} + +/// System that handles forest regrowth on empty land. +fn regrowth_system(mut query: Query<&mut ForestCell>) +{ + let mut rng = rand::rng(); + + for mut cell in &mut query + { + if matches!(cell.state, CellState::Empty) && rng.random_bool(REGROWTH_PROBABILITY) + { + cell.state = CellState::Healthy; + } + } +} diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs new file mode 100644 index 0000000..112721b --- /dev/null +++ b/examples/pandemic_spatial.rs @@ -0,0 +1,593 @@ +//! # Enhanced Monte Carlo simulation of pandemic spread with spatial features. +//! +//! This example demonstrates advanced spatial epidemic modeling using the `SpatialGrid` plugin. +//! It showcases realistic pandemic dynamics including: +//! +//! * **Infection radius**: Disease spreads within a configurable distance, not just same-cell +//! * **Contact tracing**: Track and quarantine people who were near infected individuals +//! * **Social distancing**: People avoid crowded areas and maintain distance +//! * **Quarantine zones**: Restricted movement areas to contain outbreaks +//! * **Superspreader events**: Detection of high-transmission locations +//! * **Population density tracking**: Monitor crowding and movement patterns +//! +//! The simulation models a more realistic epidemic than simple grid-cell transmission, +//! allowing for analysis of various intervention strategies and their effectiveness. + +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::cast_precision_loss)] +#![allow(clippy::too_many_lines)] +#![allow(clippy::type_complexity)] + +use std::collections::HashSet; + +use incerto::prelude::*; +use rand::prelude::*; + +// Simulation parameters +const SIMULATION_STEPS: usize = 500; +const INITIAL_POPULATION: usize = 2000; +const GRID_SIZE: i32 = 40; + +// Disease parameters +const CHANCE_START_INFECTED: f64 = 0.02; +const INFECTION_RADIUS: u32 = 2; // Can infect within 2 cells distance +const CHANCE_INFECT_AT_DISTANCE_1: f64 = 0.15; // High chance at close distance +const CHANCE_INFECT_AT_DISTANCE_2: f64 = 0.05; // Lower chance at farther distance +const CHANCE_RECOVER: f64 = 0.03; +const CHANCE_DIE: f64 = 0.001; +const INCUBATION_PERIOD: usize = 5; // Steps before becoming infectious + +// Social distancing parameters +const SOCIAL_DISTANCING_ENABLED: bool = true; +const CROWDING_THRESHOLD: usize = 8; // Avoid areas with more than 8 people +const SOCIAL_DISTANCE_COMPLIANCE: f64 = 0.7; // 70% of people practice social distancing + +// Contact tracing parameters +const CONTACT_TRACING_ENABLED: bool = true; +const CONTACT_QUARANTINE_DURATION: usize = 14; + +// Quarantine zone (center area) +const QUARANTINE_ZONE_ENABLED: bool = true; +const QUARANTINE_CENTER_X: i32 = GRID_SIZE / 2; +const QUARANTINE_CENTER_Y: i32 = GRID_SIZE / 2; +const QUARANTINE_RADIUS: u32 = 8; + +// Time series sampling +const SAMPLE_INTERVAL: usize = 1; + +/// Disease states for people in the simulation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiseaseState +{ + Healthy, + Exposed + { + incubation_remaining: usize, + }, // Infected but not yet infectious + Infectious, + Recovered, +} + +/// Component representing a person in the simulation +#[derive(Component, Debug)] +pub struct Person +{ + pub disease_state: DiseaseState, + pub social_distancing: bool, // Whether this person practices social distancing +} + +/// Component for people under quarantine (contact tracing) +#[derive(Component, Debug)] +pub struct Quarantined +{ + pub remaining_duration: usize, +} + +/// Component to track contact history for contact tracing +#[derive(Component, Debug, Default)] +pub struct ContactHistory +{ + pub recent_contacts: HashSet, // Positions visited recently +} + +/// Pandemic statistics collected during simulation +#[derive(Debug, Clone, Copy)] +pub struct PandemicStats +{ + pub healthy_count: usize, + pub exposed_count: usize, + pub infectious_count: usize, + pub recovered_count: usize, + pub quarantined_count: usize, + pub dead_count: usize, + pub total_population: usize, + pub avg_population_density: f64, + pub superspreader_locations: usize, // Locations with >threshold infections +} + +impl Sample for Person +{ + fn sample(components: &[&Self]) -> PandemicStats + { + let mut healthy_count = 0; + let mut exposed_count = 0; + let mut infectious_count = 0; + let mut recovered_count = 0; + + for person in components + { + match person.disease_state + { + DiseaseState::Healthy => healthy_count += 1, + DiseaseState::Exposed { .. } => exposed_count += 1, + DiseaseState::Infectious => infectious_count += 1, + DiseaseState::Recovered => recovered_count += 1, + } + } + + PandemicStats { + healthy_count, + exposed_count, + infectious_count, + recovered_count, + quarantined_count: 0, // Will be updated by separate query + dead_count: INITIAL_POPULATION - components.len(), + total_population: components.len(), + avg_population_density: 0.0, // Will be calculated separately + superspreader_locations: 0, + } + } +} + +fn main() +{ + println!("🦠 Starting Enhanced Pandemic Simulation"); + println!("Population: {INITIAL_POPULATION}"); + println!("Grid size: {GRID_SIZE}x{GRID_SIZE}"); + println!("Infection radius: {INFECTION_RADIUS} cells"); + println!( + "Social distancing: {}", + if SOCIAL_DISTANCING_ENABLED + { + "ON" + } + else + { + "OFF" + } + ); + println!( + "Contact tracing: {}", + if CONTACT_TRACING_ENABLED { "ON" } else { "OFF" } + ); + println!( + "Quarantine zone: {}", + if QUARANTINE_ZONE_ENABLED { "ON" } else { "OFF" } + ); + println!(); + + let bounds = GridBounds::new(0, GRID_SIZE - 1, 0, GRID_SIZE - 1); + let mut simulation = SimulationBuilder::new() + // Add spatial grid support + .add_spatial_grid(bounds) + // Spawn initial population + .add_entity_spawner(spawn_population) + // Movement and social distancing + .add_systems(people_move_with_social_distancing) + // Disease progression and transmission + .add_systems(( + disease_incubation_progression, + spatial_disease_transmission, + disease_recovery_and_death, + )) + // Contact tracing and quarantine + .add_systems(( + update_contact_history, + process_contact_tracing, + update_quarantine_status, + )) + // Record pandemic statistics + .record_time_series::(SAMPLE_INTERVAL) + .expect("Failed to set up time series recording") + .build(); + + println!("Running simulation..."); + simulation.run(SIMULATION_STEPS); + + // Collect and display results + let final_stats = simulation + .sample::() + .expect("Failed to sample pandemic statistics"); + + println!("šŸ“Š Final Statistics:"); + println!( + " Population: {} ({}% survived)", + final_stats.total_population, + final_stats.total_population as f64 / INITIAL_POPULATION as f64 * 100.0 + ); + println!( + " Healthy: {} ({:.1}%)", + final_stats.healthy_count, + final_stats.healthy_count as f64 / final_stats.total_population as f64 * 100.0 + ); + println!( + " Exposed: {} ({:.1}%)", + final_stats.exposed_count, + final_stats.exposed_count as f64 / final_stats.total_population as f64 * 100.0 + ); + println!( + " Infectious: {} ({:.1}%)", + final_stats.infectious_count, + final_stats.infectious_count as f64 / final_stats.total_population as f64 * 100.0 + ); + println!( + " Recovered: {} ({:.1}%)", + final_stats.recovered_count, + final_stats.recovered_count as f64 / final_stats.total_population as f64 * 100.0 + ); + println!( + " Deaths: {} ({:.1}%)", + final_stats.dead_count, + final_stats.dead_count as f64 / INITIAL_POPULATION as f64 * 100.0 + ); + + // Display time series summary + let time_series = simulation + .get_time_series::() + .expect("Failed to get time series data"); + + println!("\nšŸ“ˆ Pandemic Timeline:"); + println!(" Data points collected: {}", time_series.len()); + + if let Some(peak_infections) = time_series + .iter() + .max_by_key(|stats| stats.infectious_count) + { + println!( + " Peak infectious: {} people ({:.1}%)", + peak_infections.infectious_count, + peak_infections.infectious_count as f64 / INITIAL_POPULATION as f64 * 100.0 + ); + } + + let total_recovered = time_series.last().map_or(0, |stats| stats.recovered_count); + let total_deaths = time_series.last().map_or(0, |stats| stats.dead_count); + println!( + " Total recovered: {} ({:.1}%)", + total_recovered, + total_recovered as f64 / INITIAL_POPULATION as f64 * 100.0 + ); + println!( + " Total deaths: {} ({:.1}%)", + total_deaths, + total_deaths as f64 / INITIAL_POPULATION as f64 * 100.0 + ); + + // Calculate attack rate (percentage who got infected) + let attack_rate = (total_recovered + total_deaths) as f64 / INITIAL_POPULATION as f64 * 100.0; + println!(" Attack rate: {attack_rate:.1}% (total who got infected)"); +} + +/// Spawn the initial population with random positions and infection states +fn spawn_population(spawner: &mut Spawner) +{ + let mut rng = rand::rng(); + + for _ in 0..INITIAL_POPULATION + { + // Random position on the grid + let position = GridPosition::new( + rng.random_range(0..GRID_SIZE), + rng.random_range(0..GRID_SIZE), + ); + + // Determine if person practices social distancing + let social_distancing = rng.random_bool(SOCIAL_DISTANCE_COMPLIANCE); + + // Initial disease state + let disease_state = if rng.random_bool(CHANCE_START_INFECTED) + { + DiseaseState::Exposed { + incubation_remaining: INCUBATION_PERIOD, + } + } + else + { + DiseaseState::Healthy + }; + + let person = Person { + disease_state, + social_distancing, + }; + + spawner.spawn((position, person, ContactHistory::default())); + } +} + +/// Enhanced movement system with social distancing behavior +fn people_move_with_social_distancing( + mut query: Query<(&mut GridPosition, &Person, Option<&Quarantined>)>, + spatial_grid: Res, +) +{ + let mut rng = rand::rng(); + + for (mut position, person, quarantined) in &mut query + { + // Quarantined people don't move + if quarantined.is_some() + { + continue; + } + + // 50% chance to try to move + if !rng.random_bool(0.5) + { + continue; + } + + // Get potential movement directions + let directions = [ + GridPosition::new(position.x, position.y - 1), // up + GridPosition::new(position.x - 1, position.y), // left + GridPosition::new(position.x + 1, position.y), // right + GridPosition::new(position.x, position.y + 1), // down + ]; + + let mut best_moves = Vec::new(); + let mut min_crowding = usize::MAX; + + for new_pos in directions + { + // Check bounds + if new_pos.x < 0 || new_pos.x >= GRID_SIZE || new_pos.y < 0 || new_pos.y >= GRID_SIZE + { + continue; + } + + // Check if in quarantine zone + if QUARANTINE_ZONE_ENABLED + { + let quarantine_center = GridPosition::new(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); + if new_pos.manhattan_distance(&quarantine_center) <= QUARANTINE_RADIUS + { + // Only enter quarantine zone if not practicing social distancing + if person.social_distancing + { + continue; + } + } + } + + // Count people at potential destination for social distancing + let people_at_destination = spatial_grid.entities_at(&new_pos).len(); + + if person.social_distancing && SOCIAL_DISTANCING_ENABLED + { + // Social distancing: prefer less crowded areas + if people_at_destination < min_crowding + { + min_crowding = people_at_destination; + best_moves.clear(); + best_moves.push(new_pos); + } + else if people_at_destination == min_crowding + { + best_moves.push(new_pos); + } + } + else + { + // No social distancing: any valid move is fine + best_moves.push(new_pos); + } + } + + // Move to a randomly selected best position + if !best_moves.is_empty() + { + let chosen_move = best_moves.choose(&mut rng).copied().unwrap(); + + // Only move if it's not too crowded (even for non-social-distancing people) + let people_at_destination = spatial_grid.entities_at(&chosen_move).len(); + if people_at_destination < CROWDING_THRESHOLD + { + *position = chosen_move; + } + } + } +} + +/// Progress disease through incubation period +fn disease_incubation_progression(mut query: Query<&mut Person>) +{ + for mut person in &mut query + { + if let DiseaseState::Exposed { + incubation_remaining, + } = &mut person.disease_state + { + if *incubation_remaining > 1 + { + *incubation_remaining -= 1; + } + else + { + // Become infectious + person.disease_state = DiseaseState::Infectious; + } + } + } +} + +/// Advanced spatial disease transmission system using infection radius +fn spatial_disease_transmission( + spatial_grid: Res, + query_infectious: Query<(Entity, &GridPosition), (With, Without)>, + mut query_susceptible: Query<(Entity, &GridPosition, &mut Person), Without>, +) +{ + let mut rng = rand::rng(); + let mut new_exposures = Vec::new(); + + for (infectious_entity, infectious_pos) in &query_infectious + { + // Check if this person is actually infectious + if let Ok((_, _, person)) = query_susceptible.get(infectious_entity) + && matches!(person.disease_state, DiseaseState::Infectious) + { + // Get all people within infection radius + let nearby_entities = + spatial_grid.entities_within_distance(infectious_pos, INFECTION_RADIUS); + + for &nearby_entity in &nearby_entities + { + if nearby_entity == infectious_entity + { + continue; // Don't infect self + } + + if let Ok((entity, susceptible_pos, person)) = query_susceptible.get(nearby_entity) + { + // Only infect healthy people + if matches!(person.disease_state, DiseaseState::Healthy) + { + // Calculate infection probability based on distance + let distance = infectious_pos.manhattan_distance(susceptible_pos); + let infection_chance = match distance + { + 0 | 1 => CHANCE_INFECT_AT_DISTANCE_1, // Same cell or adjacent + 2 => CHANCE_INFECT_AT_DISTANCE_2, // 2 cells away + _ => 0.0, // Too far + }; + + if rng.random_bool(infection_chance) + { + new_exposures.push(entity); + } + } + } + } + } + } + + // Apply new exposures + for entity in new_exposures + { + if let Ok((_, _, mut person)) = query_susceptible.get_mut(entity) + { + person.disease_state = DiseaseState::Exposed { + incubation_remaining: INCUBATION_PERIOD, + }; + } + } +} + +/// Handle disease recovery and death +fn disease_recovery_and_death(mut commands: Commands, mut query: Query<(Entity, &mut Person)>) +{ + let mut rng = rand::rng(); + + for (entity, mut person) in &mut query + { + if matches!(person.disease_state, DiseaseState::Infectious) + { + if rng.random_bool(CHANCE_DIE) + { + // Person dies + commands.entity(entity).despawn(); + } + else if rng.random_bool(CHANCE_RECOVER) + { + // Person recovers and gains immunity + person.disease_state = DiseaseState::Recovered; + } + } + } +} + +/// Update contact history for contact tracing +fn update_contact_history(mut query: Query<(&GridPosition, &mut ContactHistory)>) +{ + for (position, mut contact_history) in &mut query + { + // Add current position to recent contacts + contact_history.recent_contacts.insert(*position); + + // Limit history size (keep last 14 positions) + if contact_history.recent_contacts.len() > 14 + { + // In a real implementation, you'd track timestamps and remove old ones + // For simplicity, we'll just clear periodically + if contact_history.recent_contacts.len() > 20 + { + contact_history.recent_contacts.clear(); + } + } + } +} + +/// Process contact tracing when someone becomes infectious +fn process_contact_tracing( + mut commands: Commands, + spatial_grid: Res, + query_newly_infectious: Query< + (Entity, &GridPosition, &ContactHistory), + (With, Without), + >, + query_potential_contacts: Query, Without)>, +) +{ + if !CONTACT_TRACING_ENABLED + { + return; + } + + for (infectious_entity, _infectious_pos, contact_history) in &query_newly_infectious + { + // Check if this person just became infectious (simplified check) + // In a real implementation, you'd track state changes + + // Quarantine people who were in recent contact locations + for &contact_location in &contact_history.recent_contacts + { + let people_at_location = spatial_grid.entities_at(&contact_location); + + for &potential_contact in people_at_location + { + if potential_contact == infectious_entity + { + continue; + } + + // Quarantine this person if they're not already quarantined + if query_potential_contacts.get(potential_contact).is_ok() + { + // Use try_insert to handle entities that may have been despawned + commands.entity(potential_contact).try_insert(Quarantined { + remaining_duration: CONTACT_QUARANTINE_DURATION, + }); + } + } + } + } +} + +/// Update quarantine status +fn update_quarantine_status(mut commands: Commands, mut query: Query<(Entity, &mut Quarantined)>) +{ + for (entity, mut quarantined) in &mut query + { + if quarantined.remaining_duration > 1 + { + quarantined.remaining_duration -= 1; + } + else + { + // End quarantine + commands.entity(entity).remove::(); + } + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..39b94b0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" \ No newline at end of file diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index e7353ce..ce109ef 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -3,3 +3,6 @@ pub use step_counter::StepCounterPlugin; mod time_series; pub use time_series::{TimeSeries, TimeSeriesPlugin}; + +mod spatial_grid; +pub use spatial_grid::{GridBounds, GridPosition, SpatialGrid, SpatialGridPlugin}; diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs new file mode 100644 index 0000000..bbc7de2 --- /dev/null +++ b/src/plugins/spatial_grid.rs @@ -0,0 +1,364 @@ +use std::collections::HashMap; + +use bevy::prelude::*; + +/// Component representing a position in the spatial grid. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GridPosition +{ + pub x: i32, + pub y: i32, +} + +impl GridPosition +{ + #[must_use] + pub const fn new(x: i32, y: i32) -> Self + { + Self { x, y } + } + + /// Get the 8 neighboring positions (Moore neighborhood). + #[must_use] + pub const fn neighbors(&self) -> [Self; 8] + { + [ + Self::new(self.x - 1, self.y - 1), // top-left + Self::new(self.x, self.y - 1), // top + Self::new(self.x + 1, self.y - 1), // top-right + Self::new(self.x - 1, self.y), // left + Self::new(self.x + 1, self.y), // right + Self::new(self.x - 1, self.y + 1), // bottom-left + Self::new(self.x, self.y + 1), // bottom + Self::new(self.x + 1, self.y + 1), // bottom-right + ] + } + + /// Get the 4 orthogonal neighboring positions (Von Neumann neighborhood). + #[must_use] + pub const fn orthogonal_neighbors(&self) -> [Self; 4] + { + [ + Self::new(self.x, self.y - 1), // top + Self::new(self.x - 1, self.y), // left + Self::new(self.x + 1, self.y), // right + Self::new(self.x, self.y + 1), // bottom + ] + } + + /// Calculate Manhattan distance to another position. + #[must_use] + #[allow(clippy::cast_sign_loss)] + pub const fn manhattan_distance(&self, other: &Self) -> u32 + { + ((self.x - other.x).abs() + (self.y - other.y).abs()) as u32 + } + + /// Calculate Euclidean distance squared to another position. + #[must_use] + #[allow(clippy::cast_sign_loss)] + pub const fn distance_squared(&self, other: &Self) -> u32 + { + let dx = self.x - other.x; + let dy = self.y - other.y; + (dx * dx + dy * dy) as u32 + } +} + +/// Resource that maintains a spatial index for efficient neighbor queries. +#[derive(Resource, Default)] +pub struct SpatialGrid +{ + /// Maps grid positions to entities at those positions. + position_to_entities: HashMap>, + /// Maps entities to their grid positions for fast lookups. + entity_to_position: HashMap, + /// Grid bounds for validation and iteration. + bounds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GridBounds +{ + pub min_x: i32, + pub max_x: i32, + pub min_y: i32, + pub max_y: i32, +} + +impl GridBounds +{ + #[must_use] + pub const fn new(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self + { + Self { + min_x, + max_x, + min_y, + max_y, + } + } + + #[must_use] + pub const fn contains(&self, pos: &GridPosition) -> bool + { + pos.x >= self.min_x && pos.x <= self.max_x && pos.y >= self.min_y && pos.y <= self.max_y + } + + #[must_use] + #[allow(clippy::cast_sign_loss)] + pub const fn width(&self) -> u32 + { + (self.max_x - self.min_x + 1) as u32 + } + + #[must_use] + #[allow(clippy::cast_sign_loss)] + pub const fn height(&self) -> u32 + { + (self.max_y - self.min_y + 1) as u32 + } + + #[must_use] + pub const fn total_cells(&self) -> u32 + { + self.width() * self.height() + } +} + +impl SpatialGrid +{ + #[must_use] + pub fn new() -> Self + { + Self::default() + } + + #[must_use] + pub fn with_bounds(bounds: GridBounds) -> Self + { + Self { + position_to_entities: HashMap::new(), + entity_to_position: HashMap::new(), + bounds: Some(bounds), + } + } + + pub const fn set_bounds(&mut self, bounds: GridBounds) + { + self.bounds = Some(bounds); + } + + #[must_use] + pub const fn bounds(&self) -> Option + { + self.bounds + } + + /// Add an entity at a specific grid position. + pub fn insert(&mut self, entity: Entity, position: GridPosition) + { + // Remove entity from old position if it exists + if let Some(old_pos) = self.entity_to_position.get(&entity) + && let Some(entities) = self.position_to_entities.get_mut(old_pos) + { + entities.retain(|&e| e != entity); + if entities.is_empty() + { + self.position_to_entities.remove(old_pos); + } + } + + // Insert at new position + self.position_to_entities + .entry(position) + .or_default() + .push(entity); + self.entity_to_position.insert(entity, position); + } + + /// Remove an entity from the spatial index. + pub fn remove(&mut self, entity: Entity) + { + if let Some(position) = self.entity_to_position.remove(&entity) + && let Some(entities) = self.position_to_entities.get_mut(&position) + { + entities.retain(|&e| e != entity); + if entities.is_empty() + { + self.position_to_entities.remove(&position); + } + } + } + + /// Get all entities at a specific position. + pub fn entities_at(&self, position: &GridPosition) -> &[Entity] + { + self.position_to_entities + .get(position) + .map_or(&[], Vec::as_slice) + } + + /// Get the position of an entity. + #[must_use] + pub fn position_of(&self, entity: Entity) -> Option + { + self.entity_to_position.get(&entity).copied() + } + + /// Get all entities in the 8-connected neighborhood of a position. + #[must_use] + pub fn neighbors_of(&self, position: &GridPosition) -> Vec + { + let mut neighbors = Vec::new(); + for neighbor_pos in position.neighbors() + { + if let Some(bounds) = self.bounds + && !bounds.contains(&neighbor_pos) + { + continue; + } + neighbors.extend_from_slice(self.entities_at(&neighbor_pos)); + } + neighbors + } + + /// Get all entities in the 4-connected orthogonal neighborhood of a position. + #[must_use] + pub fn orthogonal_neighbors_of(&self, position: &GridPosition) -> Vec + { + let mut neighbors = Vec::new(); + for neighbor_pos in position.orthogonal_neighbors() + { + if let Some(bounds) = self.bounds + && !bounds.contains(&neighbor_pos) + { + continue; + } + neighbors.extend_from_slice(self.entities_at(&neighbor_pos)); + } + neighbors + } + + /// Get all entities within a Manhattan distance of a position. + #[must_use] + #[allow(clippy::cast_possible_wrap)] + pub fn entities_within_distance(&self, center: &GridPosition, distance: u32) -> Vec + { + let mut entities = Vec::new(); + let distance_i32 = distance as i32; + + for x in (center.x - distance_i32)..=(center.x + distance_i32) + { + for y in (center.y - distance_i32)..=(center.y + distance_i32) + { + let pos = GridPosition::new(x, y); + if pos.manhattan_distance(center) <= distance + { + if let Some(bounds) = self.bounds + && !bounds.contains(&pos) + { + continue; + } + entities.extend_from_slice(self.entities_at(&pos)); + } + } + } + entities + } + + /// Clear all entities from the spatial index. + pub fn clear(&mut self) + { + self.position_to_entities.clear(); + self.entity_to_position.clear(); + } + + /// Get all occupied positions in the grid. + #[must_use] + pub fn occupied_positions(&self) -> Vec + { + self.position_to_entities.keys().copied().collect() + } + + /// Get total number of entities in the grid. + #[must_use] + pub fn entity_count(&self) -> usize + { + self.entity_to_position.len() + } +} + +/// Plugin that maintains a spatial index for entities with `GridPosition` components. +pub struct SpatialGridPlugin +{ + bounds: Option, +} + +impl Default for SpatialGridPlugin +{ + fn default() -> Self + { + Self::new() + } +} + +impl SpatialGridPlugin +{ + pub const fn new() -> Self + { + Self { bounds: None } + } + + pub const fn with_bounds(bounds: GridBounds) -> Self + { + Self { + bounds: Some(bounds), + } + } + + pub fn init(app: &mut App, bounds: Option) + { + let spatial_grid = bounds.map_or_else(SpatialGrid::new, SpatialGrid::with_bounds); + app.insert_resource(spatial_grid); + } +} + +impl Plugin for SpatialGridPlugin +{ + fn build(&self, app: &mut App) + { + Self::init(app, self.bounds); + + // System to maintain the spatial index + app.add_systems( + PreUpdate, + (spatial_grid_update_system, spatial_grid_cleanup_system).chain(), + ); + } +} + +/// System that updates the spatial grid when entities with `GridPosition` are added or moved. +#[allow(clippy::type_complexity)] +pub fn spatial_grid_update_system( + mut spatial_grid: ResMut, + query: Query<(Entity, &GridPosition), Or<(Added, Changed)>>, +) +{ + for (entity, position) in &query + { + spatial_grid.insert(entity, *position); + } +} + +/// System that removes entities from the spatial grid when they no longer have `GridPosition`. +pub fn spatial_grid_cleanup_system( + mut spatial_grid: ResMut, + mut removed: RemovedComponents, +) +{ + for entity in removed.read() + { + spatial_grid.remove(entity); + } +} diff --git a/src/plugins/time_series.rs b/src/plugins/time_series.rs index 6b2b441..468b7f5 100644 --- a/src/plugins/time_series.rs +++ b/src/plugins/time_series.rs @@ -62,7 +62,7 @@ where ) { // only get new samples once every 'sample_interval' steps - if step_counter.is_multiple_of(time_series.sample_interval) + if (**step_counter).is_multiple_of(time_series.sample_interval) { let component_values = query.iter().collect::>(); diff --git a/src/prelude.rs b/src/prelude.rs index 3207220..a9d6c44 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -4,6 +4,10 @@ pub use bevy::prelude::{ }; pub use super::{ - error::*, simulation::Simulation, simulation_builder::SimulationBuilder, spawner::Spawner, + error::*, + plugins::{GridBounds, GridPosition, SpatialGrid}, + simulation::Simulation, + simulation_builder::SimulationBuilder, + spawner::Spawner, traits::*, }; diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index c67205c..e9a20a5 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -6,7 +6,7 @@ use bevy::{ use crate::{ Sample, SimulationBuildError, - plugins::{StepCounterPlugin, TimeSeries, TimeSeriesPlugin}, + plugins::{GridBounds, SpatialGridPlugin, StepCounterPlugin, TimeSeries, TimeSeriesPlugin}, simulation::Simulation, spawner::Spawner, }; @@ -76,6 +76,29 @@ impl SimulationBuilder self } + /// Add spatial grid support to the simulation. + /// + /// This enables efficient spatial queries for entities with `GridPosition` components. + /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. + /// + /// # Example + /// ```rust + /// use incerto::prelude::*; + /// + /// let bounds = GridBounds::new(0, 99, 0, 99); + /// let simulation = SimulationBuilder::new() + /// .add_spatial_grid(bounds) + /// .build(); + /// ``` + #[must_use] + pub fn add_spatial_grid(mut self, bounds: GridBounds) -> Self + { + self.sim + .app + .add_plugins(SpatialGridPlugin::with_bounds(bounds)); + self + } + /// Add an entity spawner function to the simulation. /// /// In the beginning of ever simulation, each of the spawner functions added here diff --git a/tests/mod.rs b/tests/mod.rs index 5fbf105..b9cf797 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,2 +1,3 @@ mod test_builder; mod test_counter; +mod test_spatial_grid; diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs new file mode 100644 index 0000000..58387da --- /dev/null +++ b/tests/test_spatial_grid.rs @@ -0,0 +1,269 @@ +#![allow(clippy::expect_used)] +#![allow(clippy::uninlined_format_args)] +#![allow(clippy::cast_possible_truncation)] + +use incerto::prelude::*; + +#[derive(Component)] +struct TestEntity(i32); + +#[test] +fn test_grid_position_neighbors() +{ + let pos = GridPosition::new(1, 1); + + let neighbors = pos.neighbors(); + assert_eq!(neighbors.len(), 8); + + // Check all 8 neighbors are present + let expected_neighbors = [ + GridPosition::new(0, 0), + GridPosition::new(1, 0), + GridPosition::new(2, 0), + GridPosition::new(0, 1), + GridPosition::new(2, 1), + GridPosition::new(0, 2), + GridPosition::new(1, 2), + GridPosition::new(2, 2), + ]; + + for expected in expected_neighbors + { + assert!( + neighbors.contains(&expected), + "Missing neighbor: {:?}", + expected + ); + } +} + +#[test] +fn test_grid_position_orthogonal_neighbors() +{ + let pos = GridPosition::new(1, 1); + + let neighbors = pos.orthogonal_neighbors(); + assert_eq!(neighbors.len(), 4); + + let expected_neighbors = [ + GridPosition::new(1, 0), // top + GridPosition::new(0, 1), // left + GridPosition::new(2, 1), // right + GridPosition::new(1, 2), // bottom + ]; + + for expected in expected_neighbors + { + assert!( + neighbors.contains(&expected), + "Missing orthogonal neighbor: {:?}", + expected + ); + } +} + +#[test] +fn test_grid_position_distances() +{ + let pos1 = GridPosition::new(0, 0); + let pos2 = GridPosition::new(3, 4); + + assert_eq!(pos1.manhattan_distance(&pos2), 7); + assert_eq!(pos1.distance_squared(&pos2), 25); + + let pos3 = GridPosition::new(1, 1); + assert_eq!(pos1.manhattan_distance(&pos3), 2); + assert_eq!(pos1.distance_squared(&pos3), 2); +} + +#[test] +fn test_grid_bounds() +{ + let bounds = GridBounds::new(0, 9, 0, 9); + + assert_eq!(bounds.width(), 10); + assert_eq!(bounds.height(), 10); + assert_eq!(bounds.total_cells(), 100); + + assert!(bounds.contains(&GridPosition::new(0, 0))); + assert!(bounds.contains(&GridPosition::new(9, 9))); + assert!(bounds.contains(&GridPosition::new(5, 5))); + + assert!(!bounds.contains(&GridPosition::new(-1, 0))); + assert!(!bounds.contains(&GridPosition::new(0, -1))); + assert!(!bounds.contains(&GridPosition::new(10, 5))); + assert!(!bounds.contains(&GridPosition::new(5, 10))); +} + +#[test] +fn test_spatial_grid_basic_operations() +{ + let mut grid = SpatialGrid::new(); + let entity1 = Entity::from_raw(1); + let entity2 = Entity::from_raw(2); + let pos1 = GridPosition::new(0, 0); + let pos2 = GridPosition::new(1, 1); + + // Test insertion + grid.insert(entity1, pos1); + grid.insert(entity2, pos2); + + assert_eq!(grid.entity_count(), 2); + assert_eq!(grid.position_of(entity1), Some(pos1)); + assert_eq!(grid.position_of(entity2), Some(pos2)); + + // Test entities at position + let entities_at_pos1 = grid.entities_at(&pos1); + assert_eq!(entities_at_pos1.len(), 1); + assert_eq!(entities_at_pos1[0], entity1); + + // Test removal + grid.remove(entity1); + assert_eq!(grid.entity_count(), 1); + assert_eq!(grid.position_of(entity1), None); + assert!(grid.entities_at(&pos1).is_empty()); +} + +#[test] +fn test_spatial_grid_with_bounds() +{ + let bounds = GridBounds::new(0, 2, 0, 2); + let mut grid = SpatialGrid::with_bounds(bounds); + + assert_eq!(grid.bounds(), Some(bounds)); + + let entity = Entity::from_raw(1); + let center = GridPosition::new(1, 1); + grid.insert(entity, center); + + // Test neighbor queries respect bounds + let neighbors = grid.neighbors_of(¢er); + assert_eq!(neighbors.len(), 0); // No entities at neighbor positions + + // Add entities at neighbor positions + for (i, neighbor_pos) in center.neighbors().iter().enumerate() + { + if bounds.contains(neighbor_pos) + { + let neighbor_entity = Entity::from_raw(i as u32 + 10); + grid.insert(neighbor_entity, *neighbor_pos); + } + } + + let neighbors = grid.neighbors_of(¢er); + assert_eq!(neighbors.len(), 8); // All 8 neighbors are within bounds for center position +} + +#[test] +fn test_spatial_grid_multiple_entities_per_position() +{ + let mut grid = SpatialGrid::new(); + let entity1 = Entity::from_raw(1); + let entity2 = Entity::from_raw(2); + let entity3 = Entity::from_raw(3); + let pos = GridPosition::new(0, 0); + + // Insert multiple entities at same position + grid.insert(entity1, pos); + grid.insert(entity2, pos); + grid.insert(entity3, pos); + + let entities = grid.entities_at(&pos); + assert_eq!(entities.len(), 3); + assert!(entities.contains(&entity1)); + assert!(entities.contains(&entity2)); + assert!(entities.contains(&entity3)); + + // Remove one entity + grid.remove(entity2); + let entities = grid.entities_at(&pos); + assert_eq!(entities.len(), 2); + assert!(!entities.contains(&entity2)); +} + +#[test] +fn test_spatial_grid_entity_movement() +{ + let mut grid = SpatialGrid::new(); + let entity = Entity::from_raw(1); + let pos1 = GridPosition::new(0, 0); + let pos2 = GridPosition::new(1, 1); + + // Insert entity at first position + grid.insert(entity, pos1); + assert_eq!(grid.entities_at(&pos1).len(), 1); + assert!(grid.entities_at(&pos2).is_empty()); + + // Move entity to second position + grid.insert(entity, pos2); + assert!(grid.entities_at(&pos1).is_empty()); + assert_eq!(grid.entities_at(&pos2).len(), 1); + assert_eq!(grid.position_of(entity), Some(pos2)); +} + +#[test] +fn test_spatial_grid_distance_queries() +{ + let bounds = GridBounds::new(0, 4, 0, 4); + let mut grid = SpatialGrid::with_bounds(bounds); + let center = GridPosition::new(2, 2); + + // Add entities in a cross pattern + let positions = [ + GridPosition::new(2, 2), // center + GridPosition::new(2, 1), // top + GridPosition::new(1, 2), // left + GridPosition::new(3, 2), // right + GridPosition::new(2, 3), // bottom + GridPosition::new(0, 0), // corner + ]; + + for (i, &pos) in positions.iter().enumerate() + { + let entity = Entity::from_raw(i as u32 + 1); + grid.insert(entity, pos); + } + + // Test distance queries + let entities_distance_0 = grid.entities_within_distance(¢er, 0); + assert_eq!(entities_distance_0.len(), 1); // Only center entity + + let entities_distance_1 = grid.entities_within_distance(¢er, 1); + assert_eq!(entities_distance_1.len(), 5); // Center + 4 orthogonal neighbors + + let entities_distance_2 = grid.entities_within_distance(¢er, 2); + assert_eq!(entities_distance_2.len(), 5); // Same as distance 1 in this setup + + let entities_distance_4 = grid.entities_within_distance(¢er, 4); + assert_eq!(entities_distance_4.len(), 6); // All entities including corner +} + +#[test] +fn test_spatial_grid_plugin_integration() +{ + let _bounds = GridBounds::new(0, 2, 0, 2); + + let builder = SimulationBuilder::new() + .add_entity_spawner(|spawner| { + // Spawn entities with grid positions + spawner.spawn((GridPosition::new(0, 0), TestEntity(1))); + spawner.spawn((GridPosition::new(1, 1), TestEntity(2))); + spawner.spawn((GridPosition::new(2, 2), TestEntity(3))); + }) + .add_systems(|query: Query<(&GridPosition, &TestEntity)>| { + // Verify entities have grid positions and test data + for (position, test_entity) in &query + { + // Verify test entity data + assert!(test_entity.0 > 0); + assert!(position.x >= 0 && position.x <= 2); + assert!(position.y >= 0 && position.y <= 2); + } + }); + + let mut simulation = builder.build(); + simulation.run(1); + + // Test completed without panics, which means the spatial grid plugin + // is working correctly with the simulation systems +} From 140073d0faf6e0293c48b8565ff0b953a2feb769 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 00:00:19 -0600 Subject: [PATCH 02/23] bevy types, clippy --- src/plugins/spatial_grid.rs | 149 +++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 54 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index bbc7de2..8348c1b 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -3,19 +3,16 @@ use std::collections::HashMap; use bevy::prelude::*; /// Component representing a position in the spatial grid. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct GridPosition -{ - pub x: i32, - pub y: i32, -} +/// Built on top of Bevy's `IVec2` for compatibility with the Bevy ecosystem. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Deref, DerefMut)] +pub struct GridPosition(IVec2); impl GridPosition { #[must_use] pub const fn new(x: i32, y: i32) -> Self { - Self { x, y } + Self(IVec2::new(x, y)) } /// Get the 8 neighboring positions (Moore neighborhood). @@ -23,14 +20,14 @@ impl GridPosition pub const fn neighbors(&self) -> [Self; 8] { [ - Self::new(self.x - 1, self.y - 1), // top-left - Self::new(self.x, self.y - 1), // top - Self::new(self.x + 1, self.y - 1), // top-right - Self::new(self.x - 1, self.y), // left - Self::new(self.x + 1, self.y), // right - Self::new(self.x - 1, self.y + 1), // bottom-left - Self::new(self.x, self.y + 1), // bottom - Self::new(self.x + 1, self.y + 1), // bottom-right + Self(IVec2::new(self.0.x - 1, self.0.y - 1)), // top-left + Self(IVec2::new(self.0.x, self.0.y - 1)), // top + Self(IVec2::new(self.0.x + 1, self.0.y - 1)), // top-right + Self(IVec2::new(self.0.x - 1, self.0.y)), // left + Self(IVec2::new(self.0.x + 1, self.0.y)), // right + Self(IVec2::new(self.0.x - 1, self.0.y + 1)), // bottom-left + Self(IVec2::new(self.0.x, self.0.y + 1)), // bottom + Self(IVec2::new(self.0.x + 1, self.0.y + 1)), // bottom-right ] } @@ -39,29 +36,29 @@ impl GridPosition pub const fn orthogonal_neighbors(&self) -> [Self; 4] { [ - Self::new(self.x, self.y - 1), // top - Self::new(self.x - 1, self.y), // left - Self::new(self.x + 1, self.y), // right - Self::new(self.x, self.y + 1), // bottom + Self(IVec2::new(self.0.x, self.0.y - 1)), // top + Self(IVec2::new(self.0.x - 1, self.0.y)), // left + Self(IVec2::new(self.0.x + 1, self.0.y)), // right + Self(IVec2::new(self.0.x, self.0.y + 1)), // bottom ] } /// Calculate Manhattan distance to another position. #[must_use] - #[allow(clippy::cast_sign_loss)] pub const fn manhattan_distance(&self, other: &Self) -> u32 { - ((self.x - other.x).abs() + (self.y - other.y).abs()) as u32 + let dx = (self.0.x - other.0.x).unsigned_abs(); + let dy = (self.0.y - other.0.y).unsigned_abs(); + dx + dy } /// Calculate Euclidean distance squared to another position. #[must_use] - #[allow(clippy::cast_sign_loss)] pub const fn distance_squared(&self, other: &Self) -> u32 { - let dx = self.x - other.x; - let dy = self.y - other.y; - (dx * dx + dy * dy) as u32 + let dx = (self.0.x - other.0.x).unsigned_abs(); + let dy = (self.0.y - other.0.y).unsigned_abs(); + dx * dx + dy * dy } } @@ -77,53 +74,93 @@ pub struct SpatialGrid bounds: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GridBounds -{ - pub min_x: i32, - pub max_x: i32, - pub min_y: i32, - pub max_y: i32, -} +/// Grid bounds representing the valid area for grid positions. +/// +/// Built on top of Bevy's `IRect` for compatibility, but maintains grid-specific semantics +/// where width/height represent cell counts rather than geometric dimensions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deref, DerefMut)] +pub struct GridBounds(IRect); impl GridBounds { #[must_use] - pub const fn new(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self + pub fn new(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self { - Self { - min_x, - max_x, - min_y, - max_y, - } + Self(IRect::new(min_x, min_y, max_x, max_y)) } #[must_use] - pub const fn contains(&self, pos: &GridPosition) -> bool + pub fn contains(&self, pos: &GridPosition) -> bool { - pos.x >= self.min_x && pos.x <= self.max_x && pos.y >= self.min_y && pos.y <= self.max_y + self.0.contains(pos.0) } + /// Get the width in grid cells (number of columns). #[must_use] - #[allow(clippy::cast_sign_loss)] - pub const fn width(&self) -> u32 + #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds + pub fn width(&self) -> u32 { - (self.max_x - self.min_x + 1) as u32 + (self.0.width() + 1) as u32 } + /// Get the height in grid cells (number of rows). #[must_use] - #[allow(clippy::cast_sign_loss)] - pub const fn height(&self) -> u32 + #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds + pub fn height(&self) -> u32 { - (self.max_y - self.min_y + 1) as u32 + (self.0.height() + 1) as u32 } + /// Get the total number of grid cells. #[must_use] - pub const fn total_cells(&self) -> u32 + pub fn total_cells(&self) -> u32 { self.width() * self.height() } + + /// Get the minimum x coordinate. + #[must_use] + pub const fn min_x(&self) -> i32 + { + self.0.min.x + } + + /// Get the maximum x coordinate. + #[must_use] + pub const fn max_x(&self) -> i32 + { + self.0.max.x + } + + /// Get the minimum y coordinate. + #[must_use] + pub const fn min_y(&self) -> i32 + { + self.0.min.y + } + + /// Get the maximum y coordinate. + #[must_use] + pub const fn max_y(&self) -> i32 + { + self.0.max.y + } +} + +impl From for GridBounds +{ + fn from(rect: IRect) -> Self + { + Self(rect) + } +} + +impl From for IRect +{ + fn from(bounds: GridBounds) -> Self + { + bounds.0 + } } impl SpatialGrid @@ -338,12 +375,16 @@ impl Plugin for SpatialGridPlugin } } +/// Query for entities with `GridPosition` components that have been added or changed. +type GridPositionQuery<'world, 'state> = Query< + 'world, + 'state, + (Entity, &'static GridPosition), + Or<(Added, Changed)>, +>; + /// System that updates the spatial grid when entities with `GridPosition` are added or moved. -#[allow(clippy::type_complexity)] -pub fn spatial_grid_update_system( - mut spatial_grid: ResMut, - query: Query<(Entity, &GridPosition), Or<(Added, Changed)>>, -) +pub fn spatial_grid_update_system(mut spatial_grid: ResMut, query: GridPositionQuery) { for (entity, position) in &query { From 66d5364e69b1d199838cf5a4ed65f7b418348419 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 00:31:15 -0600 Subject: [PATCH 03/23] bevy hashmap --- src/plugins/spatial_grid.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 8348c1b..1aeb586 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -1,6 +1,4 @@ -use std::collections::HashMap; - -use bevy::prelude::*; +use bevy::{ecs::entity::EntityHashMap, platform::collections::HashMap, prelude::*}; /// Component representing a position in the spatial grid. /// Built on top of Bevy's `IVec2` for compatibility with the Bevy ecosystem. @@ -68,8 +66,8 @@ pub struct SpatialGrid { /// Maps grid positions to entities at those positions. position_to_entities: HashMap>, - /// Maps entities to their grid positions for fast lookups. - entity_to_position: HashMap, + /// Maps entities to their grid positions for fast lookups (optimized for Entity keys). + entity_to_position: EntityHashMap, /// Grid bounds for validation and iteration. bounds: Option, } @@ -176,7 +174,7 @@ impl SpatialGrid { Self { position_to_entities: HashMap::new(), - entity_to_position: HashMap::new(), + entity_to_position: EntityHashMap::default(), bounds: Some(bounds), } } From d55efeffb80b665dee1794d799fda8585ea3ad3d Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 10:14:17 -0600 Subject: [PATCH 04/23] Address several PR feedback. Lower hanging fruit first. --- README.md | 2 - examples/README.md | 1 - examples/forest_fire.rs | 23 ++-- examples/pandemic_spatial.rs | 95 ++++++++------- rust-toolchain.toml | 2 +- src/plugins/spatial_grid.rs | 226 ++++++++++++++++++----------------- tests/test_spatial_grid.rs | 159 ++---------------------- 7 files changed, 188 insertions(+), 320 deletions(-) diff --git a/README.md b/README.md index ac0f09e..1fb9d53 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,6 @@ In-depth knowledge of Bevy's internals is not required however, since we have ab use incerto::prelude::*; let simulation: Simulation = SimulationBuilder::new() - // add resources if needed - .insert_resource(...) // add one or more entity spawners .add_entity_spawner(...) .add_entity_spawner(...) diff --git a/examples/README.md b/examples/README.md index 8b63e98..f2ecf7a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,6 @@ - Shows how entities can interact with each other. - Samples the simulation by counting entities. - **[traders.rs](traders.rs)** - - The most complicated example. - Two different kinds of entities interacting with each other. - Data is sampled as a time series and plotted. - **[forest_fire.rs](forest_fire.rs)** diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs index e65fb0c..c268ca5 100644 --- a/examples/forest_fire.rs +++ b/examples/forest_fire.rs @@ -22,6 +22,8 @@ #![allow(clippy::expect_used)] #![allow(clippy::cast_precision_loss)] +use std::collections::HashSet; + use incerto::prelude::*; use rand::prelude::*; @@ -41,7 +43,7 @@ const INITIAL_FIRE_COUNT: usize = 3; // Number of initial fire sources const SAMPLE_INTERVAL: usize = 1; /// Represents the state of a forest cell. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CellState { /// Healthy forest that can catch fire @@ -54,26 +56,17 @@ pub enum CellState /// Already burned, cannot burn again Burned, /// Empty land that can regrow + #[default] Empty, } /// Component representing a single cell in the forest grid. -#[derive(Component, Debug)] +#[derive(Component, Debug, Default)] pub struct ForestCell { pub state: CellState, } -impl Default for ForestCell -{ - fn default() -> Self - { - Self { - state: CellState::Empty, - } - } -} - /// Fire statistics collected during simulation. #[derive(Debug, Clone, Copy)] pub struct FireStats @@ -111,6 +104,8 @@ impl Sample for ForestCell { fn sample(components: &[&Self]) -> FireStats { + assert!(!components.is_empty()); + let mut healthy_count = 0; let mut burning_count = 0; let mut burned_count = 0; @@ -284,7 +279,7 @@ fn fire_spread_system( ) { let mut rng = rand::rng(); - let mut spread_positions = Vec::new(); + let mut spread_positions = HashSet::new(); // Find all burning cells for (burning_entity, burning_pos) in &query_burning @@ -306,7 +301,7 @@ fn fire_spread_system( // Fire spreads with probability if rng.random_bool(FIRE_SPREAD_PROBABILITY) { - spread_positions.push(*neighbor_pos); + spread_positions.insert(*neighbor_pos); } } } diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index 112721b..5f9d3ee 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -99,17 +99,16 @@ pub struct PandemicStats pub exposed_count: usize, pub infectious_count: usize, pub recovered_count: usize, - pub quarantined_count: usize, pub dead_count: usize, pub total_population: usize, - pub avg_population_density: f64, - pub superspreader_locations: usize, // Locations with >threshold infections } impl Sample for Person { fn sample(components: &[&Self]) -> PandemicStats { + assert!(!components.is_empty()); + let mut healthy_count = 0; let mut exposed_count = 0; let mut infectious_count = 0; @@ -131,11 +130,8 @@ impl Sample for Person exposed_count, infectious_count, recovered_count, - quarantined_count: 0, // Will be updated by separate query dead_count: INITIAL_POPULATION - components.len(), total_population: components.len(), - avg_population_density: 0.0, // Will be calculated separately - superspreader_locations: 0, } } } @@ -351,7 +347,7 @@ fn people_move_with_social_distancing( if QUARANTINE_ZONE_ENABLED { let quarantine_center = GridPosition::new(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); - if new_pos.manhattan_distance(&quarantine_center) <= QUARANTINE_RADIUS + if (*new_pos - *quarantine_center).abs().element_sum() as u32 <= QUARANTINE_RADIUS { // Only enter quarantine zone if not practicing social distancing if person.social_distancing @@ -362,7 +358,7 @@ fn people_move_with_social_distancing( } // Count people at potential destination for social distancing - let people_at_destination = spatial_grid.entities_at(&new_pos).len(); + let people_at_destination = spatial_grid.entities_at(&new_pos).count(); if person.social_distancing && SOCIAL_DISTANCING_ENABLED { @@ -391,7 +387,7 @@ fn people_move_with_social_distancing( let chosen_move = best_moves.choose(&mut rng).copied().unwrap(); // Only move if it's not too crowded (even for non-social-distancing people) - let people_at_destination = spatial_grid.entities_at(&chosen_move).len(); + let people_at_destination = spatial_grid.entities_at(&chosen_move).count(); if people_at_destination < CROWDING_THRESHOLD { *position = chosen_move; @@ -425,48 +421,57 @@ fn disease_incubation_progression(mut query: Query<&mut Person>) /// Advanced spatial disease transmission system using infection radius fn spatial_disease_transmission( spatial_grid: Res, - query_infectious: Query<(Entity, &GridPosition), (With, Without)>, - mut query_susceptible: Query<(Entity, &GridPosition, &mut Person), Without>, + mut query: Query<(Entity, &GridPosition, &mut Person), Without>, ) { let mut rng = rand::rng(); let mut new_exposures = Vec::new(); - for (infectious_entity, infectious_pos) in &query_infectious + // Collect infectious people first to avoid borrowing conflicts + let infectious_people: Vec<(Entity, GridPosition)> = query + .iter() + .filter_map(|(entity, pos, person)| { + if matches!(person.disease_state, DiseaseState::Infectious) + { + Some((entity, *pos)) + } + else + { + None + } + }) + .collect(); + + for (infectious_entity, infectious_pos) in infectious_people { - // Check if this person is actually infectious - if let Ok((_, _, person)) = query_susceptible.get(infectious_entity) - && matches!(person.disease_state, DiseaseState::Infectious) + // Get all people within infection radius + let nearby_entities = + spatial_grid.entities_within_distance(&infectious_pos, INFECTION_RADIUS); + + for nearby_entity in nearby_entities { - // Get all people within infection radius - let nearby_entities = - spatial_grid.entities_within_distance(infectious_pos, INFECTION_RADIUS); + if nearby_entity == infectious_entity + { + continue; // Don't infect self + } - for &nearby_entity in &nearby_entities + if let Ok((entity, susceptible_pos, person)) = query.get(nearby_entity) { - if nearby_entity == infectious_entity + // Only infect healthy people + if matches!(person.disease_state, DiseaseState::Healthy) { - continue; // Don't infect self - } + // Calculate infection probability based on distance + let distance = (*infectious_pos - **susceptible_pos).abs().element_sum() as u32; + let infection_chance = match distance + { + 0 | 1 => CHANCE_INFECT_AT_DISTANCE_1, // Same cell or adjacent + 2 => CHANCE_INFECT_AT_DISTANCE_2, // 2 cells away + _ => 0.0, // Too far + }; - if let Ok((entity, susceptible_pos, person)) = query_susceptible.get(nearby_entity) - { - // Only infect healthy people - if matches!(person.disease_state, DiseaseState::Healthy) + if rng.random_bool(infection_chance) { - // Calculate infection probability based on distance - let distance = infectious_pos.manhattan_distance(susceptible_pos); - let infection_chance = match distance - { - 0 | 1 => CHANCE_INFECT_AT_DISTANCE_1, // Same cell or adjacent - 2 => CHANCE_INFECT_AT_DISTANCE_2, // 2 cells away - _ => 0.0, // Too far - }; - - if rng.random_bool(infection_chance) - { - new_exposures.push(entity); - } + new_exposures.push(entity); } } } @@ -476,12 +481,10 @@ fn spatial_disease_transmission( // Apply new exposures for entity in new_exposures { - if let Ok((_, _, mut person)) = query_susceptible.get_mut(entity) - { - person.disease_state = DiseaseState::Exposed { - incubation_remaining: INCUBATION_PERIOD, - }; - } + let (_, _, mut person) = query.get_mut(entity).expect("Entity should exist"); + person.disease_state = DiseaseState::Exposed { + incubation_remaining: INCUBATION_PERIOD, + }; } } @@ -555,7 +558,7 @@ fn process_contact_tracing( { let people_at_location = spatial_grid.entities_at(&contact_location); - for &potential_contact in people_at_location + for potential_contact in people_at_location { if potential_contact == infectious_entity { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 39b94b0..5d56faf 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly" \ No newline at end of file +channel = "nightly" diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 1aeb586..e267d81 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -1,62 +1,77 @@ -use bevy::{ecs::entity::EntityHashMap, platform::collections::HashMap, prelude::*}; +use bevy::{ + ecs::entity::EntityHashMap, + platform::collections::{HashMap, HashSet}, + prelude::*, +}; /// Component representing a position in the spatial grid. /// Built on top of Bevy's `IVec2` for compatibility with the Bevy ecosystem. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Deref, DerefMut)] -pub struct GridPosition(IVec2); +pub struct GridPosition(pub IVec2); impl GridPosition { + // Direction constants for cleaner neighbor calculations + const NORTH: IVec2 = IVec2::new(0, -1); + const SOUTH: IVec2 = IVec2::new(0, 1); + const EAST: IVec2 = IVec2::new(1, 0); + const WEST: IVec2 = IVec2::new(-1, 0); + const NORTH_EAST: IVec2 = IVec2::new(1, -1); + const NORTH_WEST: IVec2 = IVec2::new(-1, -1); + const SOUTH_EAST: IVec2 = IVec2::new(1, 1); + const SOUTH_WEST: IVec2 = IVec2::new(-1, 1); + + /// Create a new grid position from x, y coordinates. #[must_use] pub const fn new(x: i32, y: i32) -> Self { Self(IVec2::new(x, y)) } - /// Get the 8 neighboring positions (Moore neighborhood). + /// Get the x coordinate. #[must_use] - pub const fn neighbors(&self) -> [Self; 8] + pub const fn x(&self) -> i32 { - [ - Self(IVec2::new(self.0.x - 1, self.0.y - 1)), // top-left - Self(IVec2::new(self.0.x, self.0.y - 1)), // top - Self(IVec2::new(self.0.x + 1, self.0.y - 1)), // top-right - Self(IVec2::new(self.0.x - 1, self.0.y)), // left - Self(IVec2::new(self.0.x + 1, self.0.y)), // right - Self(IVec2::new(self.0.x - 1, self.0.y + 1)), // bottom-left - Self(IVec2::new(self.0.x, self.0.y + 1)), // bottom - Self(IVec2::new(self.0.x + 1, self.0.y + 1)), // bottom-right - ] + self.0.x } - /// Get the 4 orthogonal neighboring positions (Von Neumann neighborhood). + /// Get the y coordinate. #[must_use] - pub const fn orthogonal_neighbors(&self) -> [Self; 4] + pub const fn y(&self) -> i32 { - [ - Self(IVec2::new(self.0.x, self.0.y - 1)), // top - Self(IVec2::new(self.0.x - 1, self.0.y)), // left - Self(IVec2::new(self.0.x + 1, self.0.y)), // right - Self(IVec2::new(self.0.x, self.0.y + 1)), // bottom - ] + self.0.y } - /// Calculate Manhattan distance to another position. + /// Get all neighboring positions (Moore neighborhood). #[must_use] - pub const fn manhattan_distance(&self, other: &Self) -> u32 - { - let dx = (self.0.x - other.0.x).unsigned_abs(); - let dy = (self.0.y - other.0.y).unsigned_abs(); - dx + dy - } - - /// Calculate Euclidean distance squared to another position. + pub fn neighbors(&self) -> impl Iterator + { + const DIRECTIONS: [IVec2; 8] = [ + GridPosition::NORTH_WEST, + GridPosition::NORTH, + GridPosition::NORTH_EAST, + GridPosition::WEST, + GridPosition::EAST, + GridPosition::SOUTH_WEST, + GridPosition::SOUTH, + GridPosition::SOUTH_EAST, + ]; + let base = self.0; + DIRECTIONS.into_iter().map(move |dir| Self(base + dir)) + } + + /// Get orthogonal neighboring positions (Von Neumann neighborhood). #[must_use] - pub const fn distance_squared(&self, other: &Self) -> u32 + pub fn neighbors_orthogonal(&self) -> impl Iterator { - let dx = (self.0.x - other.0.x).unsigned_abs(); - let dy = (self.0.y - other.0.y).unsigned_abs(); - dx * dx + dy * dy + const DIRECTIONS: [IVec2; 4] = [ + GridPosition::NORTH, + GridPosition::WEST, + GridPosition::EAST, + GridPosition::SOUTH, + ]; + let base = self.0; + DIRECTIONS.into_iter().map(move |dir| Self(base + dir)) } } @@ -65,7 +80,7 @@ impl GridPosition pub struct SpatialGrid { /// Maps grid positions to entities at those positions. - position_to_entities: HashMap>, + position_to_entities: HashMap>, /// Maps entities to their grid positions for fast lookups (optimized for Entity keys). entity_to_position: EntityHashMap, /// Grid bounds for validation and iteration. @@ -173,9 +188,8 @@ impl SpatialGrid pub fn with_bounds(bounds: GridBounds) -> Self { Self { - position_to_entities: HashMap::new(), - entity_to_position: EntityHashMap::default(), bounds: Some(bounds), + ..default() } } @@ -191,47 +205,47 @@ impl SpatialGrid } /// Add an entity at a specific grid position. - pub fn insert(&mut self, entity: Entity, position: GridPosition) + fn insert(&mut self, entity: Entity, position: GridPosition) { // Remove entity from old position if it exists - if let Some(old_pos) = self.entity_to_position.get(&entity) - && let Some(entities) = self.position_to_entities.get_mut(old_pos) - { - entities.retain(|&e| e != entity); - if entities.is_empty() - { - self.position_to_entities.remove(old_pos); - } - } + self.remove(entity); // Insert at new position self.position_to_entities .entry(position) .or_default() - .push(entity); + .insert(entity); self.entity_to_position.insert(entity, position); } /// Remove an entity from the spatial index. - pub fn remove(&mut self, entity: Entity) + /// + /// Returns the position where the entity was located, if it was found. + fn remove(&mut self, entity: Entity) -> Option { if let Some(position) = self.entity_to_position.remove(&entity) && let Some(entities) = self.position_to_entities.get_mut(&position) { - entities.retain(|&e| e != entity); + entities.remove(&entity); if entities.is_empty() { self.position_to_entities.remove(&position); } + Some(position) + } + else + { + None } } /// Get all entities at a specific position. - pub fn entities_at(&self, position: &GridPosition) -> &[Entity] + pub fn entities_at(&self, position: &GridPosition) -> impl Iterator + '_ { self.position_to_entities .get(position) - .map_or(&[], Vec::as_slice) + .into_iter() + .flat_map(|set| set.iter().copied()) } /// Get the position of an entity. @@ -243,36 +257,44 @@ impl SpatialGrid /// Get all entities in the 8-connected neighborhood of a position. #[must_use] - pub fn neighbors_of(&self, position: &GridPosition) -> Vec - { - let mut neighbors = Vec::new(); - for neighbor_pos in position.neighbors() - { - if let Some(bounds) = self.bounds - && !bounds.contains(&neighbor_pos) - { - continue; - } - neighbors.extend_from_slice(self.entities_at(&neighbor_pos)); - } - neighbors + pub fn neighbors_of<'a>( + &'a self, + position: &'a GridPosition, + ) -> impl Iterator + 'a + { + position + .neighbors() + .filter(move |neighbor_pos| { + self.bounds + .map_or(true, |bounds| bounds.contains(neighbor_pos)) + }) + .flat_map(move |neighbor_pos| { + self.position_to_entities + .get(&neighbor_pos) + .into_iter() + .flat_map(|set| set.iter().copied()) + }) } /// Get all entities in the 4-connected orthogonal neighborhood of a position. #[must_use] - pub fn orthogonal_neighbors_of(&self, position: &GridPosition) -> Vec - { - let mut neighbors = Vec::new(); - for neighbor_pos in position.orthogonal_neighbors() - { - if let Some(bounds) = self.bounds - && !bounds.contains(&neighbor_pos) - { - continue; - } - neighbors.extend_from_slice(self.entities_at(&neighbor_pos)); - } - neighbors + pub fn orthogonal_neighbors_of<'a>( + &'a self, + position: &'a GridPosition, + ) -> impl Iterator + 'a + { + position + .neighbors_orthogonal() + .filter(move |neighbor_pos| { + self.bounds + .map_or(true, |bounds| bounds.contains(neighbor_pos)) + }) + .flat_map(move |neighbor_pos| { + self.position_to_entities + .get(&neighbor_pos) + .into_iter() + .flat_map(|set| set.iter().copied()) + }) } /// Get all entities within a Manhattan distance of a position. @@ -280,26 +302,18 @@ impl SpatialGrid #[allow(clippy::cast_possible_wrap)] pub fn entities_within_distance(&self, center: &GridPosition, distance: u32) -> Vec { - let mut entities = Vec::new(); let distance_i32 = distance as i32; + let center_pos = *center; - for x in (center.x - distance_i32)..=(center.x + distance_i32) - { - for y in (center.y - distance_i32)..=(center.y + distance_i32) - { - let pos = GridPosition::new(x, y); - if pos.manhattan_distance(center) <= distance - { - if let Some(bounds) = self.bounds - && !bounds.contains(&pos) - { - continue; - } - entities.extend_from_slice(self.entities_at(&pos)); - } - } - } - entities + (center.x - distance_i32..=center.x + distance_i32) + .flat_map(move |x| { + (center.y - distance_i32..=center.y + distance_i32) + .map(move |y| GridPosition::new(x, y)) + }) + .filter(move |pos| (pos.0 - center_pos.0).abs().element_sum() as u32 <= distance) + .filter(move |pos| self.bounds.map_or(true, |bounds| bounds.contains(&pos))) + .flat_map(move |pos| self.entities_at(&pos).collect::>()) + .collect() } /// Clear all entities from the spatial index. @@ -309,16 +323,18 @@ impl SpatialGrid self.entity_to_position.clear(); } - /// Get all occupied positions in the grid. + /// Check if a position is empty (has no entities). #[must_use] - pub fn occupied_positions(&self) -> Vec + pub fn is_empty(&self, position: &GridPosition) -> bool { - self.position_to_entities.keys().copied().collect() + self.position_to_entities + .get(position) + .map_or(true, |set| set.is_empty()) } /// Get total number of entities in the grid. #[must_use] - pub fn entity_count(&self) -> usize + pub fn num_entities(&self) -> usize { self.entity_to_position.len() } @@ -374,12 +390,8 @@ impl Plugin for SpatialGridPlugin } /// Query for entities with `GridPosition` components that have been added or changed. -type GridPositionQuery<'world, 'state> = Query< - 'world, - 'state, - (Entity, &'static GridPosition), - Or<(Added, Changed)>, ->; +type GridPositionQuery<'world, 'state> = + Query<'world, 'state, (Entity, &'static GridPosition), Changed>; /// System that updates the spatial grid when entities with `GridPosition` are added or moved. pub fn spatial_grid_update_system(mut spatial_grid: ResMut, query: GridPositionQuery) diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 58387da..50d43c3 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -12,7 +12,7 @@ fn test_grid_position_neighbors() { let pos = GridPosition::new(1, 1); - let neighbors = pos.neighbors(); + let neighbors: Vec = pos.neighbors().collect(); assert_eq!(neighbors.len(), 8); // Check all 8 neighbors are present @@ -42,7 +42,7 @@ fn test_grid_position_orthogonal_neighbors() { let pos = GridPosition::new(1, 1); - let neighbors = pos.orthogonal_neighbors(); + let neighbors: Vec = pos.neighbors_orthogonal().collect(); assert_eq!(neighbors.len(), 4); let expected_neighbors = [ @@ -68,12 +68,16 @@ fn test_grid_position_distances() let pos1 = GridPosition::new(0, 0); let pos2 = GridPosition::new(3, 4); - assert_eq!(pos1.manhattan_distance(&pos2), 7); - assert_eq!(pos1.distance_squared(&pos2), 25); + // Test Manhattan distance using IVec2 operations + assert_eq!((*pos1 - *pos2).abs().element_sum(), 7); + // Test Euclidean distance squared using IVec2 operations + let diff = *pos1 - *pos2; + assert_eq!(diff.x * diff.x + diff.y * diff.y, 25); let pos3 = GridPosition::new(1, 1); - assert_eq!(pos1.manhattan_distance(&pos3), 2); - assert_eq!(pos1.distance_squared(&pos3), 2); + assert_eq!((*pos1 - *pos3).abs().element_sum(), 2); + let diff3 = *pos1 - *pos3; + assert_eq!(diff3.x * diff3.x + diff3.y * diff3.y, 2); } #[test] @@ -95,149 +99,6 @@ fn test_grid_bounds() assert!(!bounds.contains(&GridPosition::new(5, 10))); } -#[test] -fn test_spatial_grid_basic_operations() -{ - let mut grid = SpatialGrid::new(); - let entity1 = Entity::from_raw(1); - let entity2 = Entity::from_raw(2); - let pos1 = GridPosition::new(0, 0); - let pos2 = GridPosition::new(1, 1); - - // Test insertion - grid.insert(entity1, pos1); - grid.insert(entity2, pos2); - - assert_eq!(grid.entity_count(), 2); - assert_eq!(grid.position_of(entity1), Some(pos1)); - assert_eq!(grid.position_of(entity2), Some(pos2)); - - // Test entities at position - let entities_at_pos1 = grid.entities_at(&pos1); - assert_eq!(entities_at_pos1.len(), 1); - assert_eq!(entities_at_pos1[0], entity1); - - // Test removal - grid.remove(entity1); - assert_eq!(grid.entity_count(), 1); - assert_eq!(grid.position_of(entity1), None); - assert!(grid.entities_at(&pos1).is_empty()); -} - -#[test] -fn test_spatial_grid_with_bounds() -{ - let bounds = GridBounds::new(0, 2, 0, 2); - let mut grid = SpatialGrid::with_bounds(bounds); - - assert_eq!(grid.bounds(), Some(bounds)); - - let entity = Entity::from_raw(1); - let center = GridPosition::new(1, 1); - grid.insert(entity, center); - - // Test neighbor queries respect bounds - let neighbors = grid.neighbors_of(¢er); - assert_eq!(neighbors.len(), 0); // No entities at neighbor positions - - // Add entities at neighbor positions - for (i, neighbor_pos) in center.neighbors().iter().enumerate() - { - if bounds.contains(neighbor_pos) - { - let neighbor_entity = Entity::from_raw(i as u32 + 10); - grid.insert(neighbor_entity, *neighbor_pos); - } - } - - let neighbors = grid.neighbors_of(¢er); - assert_eq!(neighbors.len(), 8); // All 8 neighbors are within bounds for center position -} - -#[test] -fn test_spatial_grid_multiple_entities_per_position() -{ - let mut grid = SpatialGrid::new(); - let entity1 = Entity::from_raw(1); - let entity2 = Entity::from_raw(2); - let entity3 = Entity::from_raw(3); - let pos = GridPosition::new(0, 0); - - // Insert multiple entities at same position - grid.insert(entity1, pos); - grid.insert(entity2, pos); - grid.insert(entity3, pos); - - let entities = grid.entities_at(&pos); - assert_eq!(entities.len(), 3); - assert!(entities.contains(&entity1)); - assert!(entities.contains(&entity2)); - assert!(entities.contains(&entity3)); - - // Remove one entity - grid.remove(entity2); - let entities = grid.entities_at(&pos); - assert_eq!(entities.len(), 2); - assert!(!entities.contains(&entity2)); -} - -#[test] -fn test_spatial_grid_entity_movement() -{ - let mut grid = SpatialGrid::new(); - let entity = Entity::from_raw(1); - let pos1 = GridPosition::new(0, 0); - let pos2 = GridPosition::new(1, 1); - - // Insert entity at first position - grid.insert(entity, pos1); - assert_eq!(grid.entities_at(&pos1).len(), 1); - assert!(grid.entities_at(&pos2).is_empty()); - - // Move entity to second position - grid.insert(entity, pos2); - assert!(grid.entities_at(&pos1).is_empty()); - assert_eq!(grid.entities_at(&pos2).len(), 1); - assert_eq!(grid.position_of(entity), Some(pos2)); -} - -#[test] -fn test_spatial_grid_distance_queries() -{ - let bounds = GridBounds::new(0, 4, 0, 4); - let mut grid = SpatialGrid::with_bounds(bounds); - let center = GridPosition::new(2, 2); - - // Add entities in a cross pattern - let positions = [ - GridPosition::new(2, 2), // center - GridPosition::new(2, 1), // top - GridPosition::new(1, 2), // left - GridPosition::new(3, 2), // right - GridPosition::new(2, 3), // bottom - GridPosition::new(0, 0), // corner - ]; - - for (i, &pos) in positions.iter().enumerate() - { - let entity = Entity::from_raw(i as u32 + 1); - grid.insert(entity, pos); - } - - // Test distance queries - let entities_distance_0 = grid.entities_within_distance(¢er, 0); - assert_eq!(entities_distance_0.len(), 1); // Only center entity - - let entities_distance_1 = grid.entities_within_distance(¢er, 1); - assert_eq!(entities_distance_1.len(), 5); // Center + 4 orthogonal neighbors - - let entities_distance_2 = grid.entities_within_distance(¢er, 2); - assert_eq!(entities_distance_2.len(), 5); // Same as distance 1 in this setup - - let entities_distance_4 = grid.entities_within_distance(¢er, 4); - assert_eq!(entities_distance_4.len(), 6); // All entities including corner -} - #[test] fn test_spatial_grid_plugin_integration() { From 931caef455d490a7c1d7687ba5faeb4b535cb771 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 16:35:40 -0600 Subject: [PATCH 05/23] 2D and 3D spatial grid --- examples/air_traffic_3d.rs | 250 ++++++++++++++ examples/forest_fire.rs | 20 +- examples/pandemic_spatial.rs | 33 +- src/lib.rs | 2 +- src/plugins/mod.rs | 5 +- src/plugins/spatial_grid.rs | 632 ++++++++++++++++++++++++++++------- src/plugins/time_series.rs | 1 + src/prelude.rs | 2 +- src/simulation_builder.rs | 38 ++- tests/test_spatial_grid.rs | 225 ++++++++++--- 10 files changed, 1014 insertions(+), 194 deletions(-) create mode 100644 examples/air_traffic_3d.rs diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs new file mode 100644 index 0000000..50e2ee5 --- /dev/null +++ b/examples/air_traffic_3d.rs @@ -0,0 +1,250 @@ +//! # Simple 3D Air Traffic Control Simulation +//! +//! This example demonstrates 3D spatial grid functionality with aircraft moving +//! through different altitude levels in a simple airspace. + +use bevy::prelude::*; +use incerto::{ + plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, + prelude::*, +}; +use rand::prelude::*; + +// Simulation parameters +const SIMULATION_STEPS: usize = 50; +const AIRSPACE_SIZE: i32 = 20; // 20x20x10 airspace +const AIRSPACE_HEIGHT: i32 = 10; +const NUM_AIRCRAFT: usize = 15; + +/// Aircraft component with basic properties +#[derive(Component, Debug)] +pub struct Aircraft +{ + pub id: u32, + pub aircraft_type: AircraftType, + pub target_altitude: i32, + pub speed: i32, // cells per step +} + +#[derive(Debug, Clone, Copy)] +pub enum AircraftType +{ + Commercial, + PrivateJet, + Cargo, +} + +impl AircraftType +{ + fn symbol(&self) -> &'static str + { + match self + { + AircraftType::Commercial => "āœˆļø", + AircraftType::PrivateJet => "šŸ›©ļø", + AircraftType::Cargo => "šŸ›«", + } + } +} + +/// Sample trait for counting aircraft +impl Sample for Aircraft +{ + fn sample(components: &[&Self]) -> usize + { + components.len() + } +} + +/// Spawn aircraft at random positions and altitudes +fn spawn_aircraft(spawner: &mut Spawner) +{ + let mut rng = rand::rng(); + + for i in 0..NUM_AIRCRAFT + { + let x = rng.random_range(0..AIRSPACE_SIZE); + let y = rng.random_range(0..AIRSPACE_SIZE); + let z = rng.random_range(0..AIRSPACE_HEIGHT); + + let aircraft_type = match rng.random_range(0..3) + { + 0 => AircraftType::Commercial, + 1 => AircraftType::PrivateJet, + _ => AircraftType::Cargo, + }; + + let target_altitude = rng.random_range(0..AIRSPACE_HEIGHT); + + let aircraft = Aircraft { + id: i as u32, + aircraft_type, + target_altitude, + speed: 1, + }; + + let position = GridPosition3D::new_3d(x, y, z); + spawner.spawn((aircraft, position)); + } +} + +/// Move aircraft towards their target altitudes and in random horizontal directions +fn move_aircraft(mut query: Query<(&mut GridPosition3D, &Aircraft)>) +{ + let mut rng = rand::rng(); + + for (mut position, aircraft) in &mut query + { + // Move towards target altitude + if position.z() < aircraft.target_altitude + { + *position = GridPosition3D::new_3d(position.x(), position.y(), position.z() + 1); + } + else if position.z() > aircraft.target_altitude + { + *position = GridPosition3D::new_3d(position.x(), position.y(), position.z() - 1); + } + + // Random horizontal movement + if rng.random_bool(0.7) + { + let dx = rng.random_range(-1..=1); + let dy = rng.random_range(-1..=1); + + let new_x = (position.x() + dx).clamp(0, AIRSPACE_SIZE - 1); + let new_y = (position.y() + dy).clamp(0, AIRSPACE_SIZE - 1); + + *position = GridPosition3D::new_3d(new_x, new_y, position.z()); + } + } +} + +/// Check for aircraft conflicts (too close in 3D space) +fn check_conflicts( + spatial_grid: Res, + query: Query<(Entity, &GridPosition3D, &Aircraft)>, +) +{ + let mut conflicts = 0; + + for (entity, position, aircraft) in &query + { + // Check for nearby aircraft (within 1 cell in any direction) + let nearby_aircraft = spatial_grid.neighbors_of(position); + + for nearby_entity in nearby_aircraft + { + if nearby_entity != entity + { + if let Ok((_, nearby_pos, nearby_aircraft)) = query.get(nearby_entity) + { + let distance = position.manhattan_distance(nearby_pos); + if distance <= 1 + { + conflicts += 1; + println!( + "āš ļø CONFLICT: Aircraft {} {} and {} {} too close at distance {}", + aircraft.id, + aircraft.aircraft_type.symbol(), + nearby_aircraft.id, + nearby_aircraft.aircraft_type.symbol(), + distance + ); + } + } + } + } + } + + if conflicts > 0 + { + println!(" Total conflicts detected: {}", conflicts / 2); // Divide by 2 since each conflict is counted twice + } +} + +/// Display airspace status +fn display_airspace(query: Query<(&GridPosition3D, &Aircraft)>) +{ + println!("\nšŸ“” Airspace Status:"); + + // Count aircraft by altitude + let mut altitude_counts = vec![0; AIRSPACE_HEIGHT as usize]; + let mut aircraft_positions = Vec::new(); + + for (position, aircraft) in &query + { + altitude_counts[position.z() as usize] += 1; + aircraft_positions.push((position, aircraft)); + } + + for (altitude, count) in altitude_counts.iter().enumerate() + { + if *count > 0 + { + print!(" FL{:02}: {} aircraft ", altitude, count); + + // Show aircraft at this altitude + for (pos, aircraft) in &aircraft_positions + { + if pos.z() == altitude as i32 + { + print!("{} ", aircraft.aircraft_type.symbol()); + } + } + println!(); + } + } + + println!( + " Airspace: {}x{}x{} cells", + AIRSPACE_SIZE, AIRSPACE_SIZE, AIRSPACE_HEIGHT + ); +} + +fn main() +{ + println!("āœˆļø 3D Air Traffic Control Simulation"); + println!( + "Airspace: {}x{}x{} cells", + AIRSPACE_SIZE, AIRSPACE_SIZE, AIRSPACE_HEIGHT + ); + println!("Aircraft: {}", NUM_AIRCRAFT); + println!("Duration: {} steps\n", SIMULATION_STEPS); + + // Create 3D airspace bounds + let bounds = GridBounds3D::new_3d( + 0, + AIRSPACE_SIZE - 1, + 0, + AIRSPACE_SIZE - 1, + 0, + AIRSPACE_HEIGHT - 1, + ); + + let mut simulation = SimulationBuilder::new() + .add_spatial_grid_3d(bounds) + .add_entity_spawner(spawn_aircraft) + .add_systems((move_aircraft, check_conflicts, display_airspace)) + .build(); + + // Run simulation + for step in 1..=SIMULATION_STEPS + { + println!("šŸ• Step {}/{}", step, SIMULATION_STEPS); + simulation.run(1); + + if step % 10 == 0 + { + let aircraft_count = simulation.sample::().unwrap(); + println!(" šŸ“Š Total aircraft tracked: {}", aircraft_count); + } + + // Add small delay for readability + std::thread::sleep(std::time::Duration::from_millis(200)); + } + + println!("\nāœ… Air Traffic Control simulation completed!"); + + let final_count = simulation.sample::().unwrap(); + println!("šŸ“ˆ Final aircraft count: {}", final_count); +} diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs index c268ca5..be93eb6 100644 --- a/examples/forest_fire.rs +++ b/examples/forest_fire.rs @@ -3,7 +3,7 @@ //! This example showcases a spatial cellular automaton simulation where fire spreads //! through a forest based on probabilistic rules. The simulation demonstrates: //! -//! * Spatial grid-based entities using the `SpatialGridPlugin` +//! * Spatial grid-based entities using the `SpatialGrid2DPlugin` //! * Entity state transitions (Healthy → Burning → Burned → Empty) //! * Neighborhood interactions for fire spreading //! * Time series collection of fire statistics @@ -148,7 +148,7 @@ fn main() println!(); // Build the simulation - let bounds = GridBounds::new(0, GRID_WIDTH - 1, 0, GRID_HEIGHT - 1); + let bounds = GridBounds2D::new_2d(0, GRID_WIDTH - 1, 0, GRID_HEIGHT - 1); let mut simulation = SimulationBuilder::new() // Add spatial grid support .add_spatial_grid(bounds) @@ -233,7 +233,7 @@ fn spawn_forest_grid(spawner: &mut Spawner) { for y in 0..GRID_HEIGHT { - let position = GridPosition::new(x, y); + let position = GridPosition2D::new_2d(x, y); // Determine initial state let state = if rng.random_bool(INITIAL_FOREST_DENSITY) @@ -251,8 +251,8 @@ fn spawn_forest_grid(spawner: &mut Spawner) } // Start some initial fires at random locations - let healthy_positions: Vec = (0..GRID_WIDTH) - .flat_map(|x| (0..GRID_HEIGHT).map(move |y| GridPosition::new(x, y))) + let healthy_positions: Vec = (0..GRID_WIDTH) + .flat_map(|x| (0..GRID_HEIGHT).map(move |y| GridPosition2D::new_2d(x, y))) .collect(); // This is a simplified approach - in a real implementation you'd query existing entities @@ -271,11 +271,11 @@ fn spawn_forest_grid(spawner: &mut Spawner) } } -/// System that handles fire spreading to neighboring cells using `SpatialGrid`. +/// System that handles fire spreading to neighboring cells using `SpatialGrid2D`. fn fire_spread_system( - spatial_grid: Res, - query_burning: Query<(Entity, &GridPosition), With>, - mut query_cells: Query<(&GridPosition, &mut ForestCell)>, + spatial_grid: Res, + query_burning: Query<(Entity, &GridPosition2D), With>, + mut query_cells: Query<(&GridPosition2D, &mut ForestCell)>, ) { let mut rng = rand::rng(); @@ -288,7 +288,7 @@ fn fire_spread_system( if let Ok((_, cell)) = query_cells.get(burning_entity) && matches!(cell.state, CellState::Burning { .. }) { - // Get orthogonal neighbors using SpatialGrid + // Get orthogonal neighbors using SpatialGrid2D let neighbors = spatial_grid.orthogonal_neighbors_of(burning_pos); for neighbor_entity in neighbors diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index 5f9d3ee..2d8ac0e 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -88,7 +88,7 @@ pub struct Quarantined #[derive(Component, Debug, Default)] pub struct ContactHistory { - pub recent_contacts: HashSet, // Positions visited recently + pub recent_contacts: HashSet, // Positions visited recently } /// Pandemic statistics collected during simulation @@ -163,7 +163,7 @@ fn main() ); println!(); - let bounds = GridBounds::new(0, GRID_SIZE - 1, 0, GRID_SIZE - 1); + let bounds = GridBounds2D::new_2d(0, GRID_SIZE - 1, 0, GRID_SIZE - 1); let mut simulation = SimulationBuilder::new() // Add spatial grid support .add_spatial_grid(bounds) @@ -273,7 +273,7 @@ fn spawn_population(spawner: &mut Spawner) for _ in 0..INITIAL_POPULATION { // Random position on the grid - let position = GridPosition::new( + let position = GridPosition2D::new_2d( rng.random_range(0..GRID_SIZE), rng.random_range(0..GRID_SIZE), ); @@ -304,8 +304,8 @@ fn spawn_population(spawner: &mut Spawner) /// Enhanced movement system with social distancing behavior fn people_move_with_social_distancing( - mut query: Query<(&mut GridPosition, &Person, Option<&Quarantined>)>, - spatial_grid: Res, + mut query: Query<(&mut GridPosition2D, &Person, Option<&Quarantined>)>, + spatial_grid: Res, ) { let mut rng = rand::rng(); @@ -326,10 +326,10 @@ fn people_move_with_social_distancing( // Get potential movement directions let directions = [ - GridPosition::new(position.x, position.y - 1), // up - GridPosition::new(position.x - 1, position.y), // left - GridPosition::new(position.x + 1, position.y), // right - GridPosition::new(position.x, position.y + 1), // down + GridPosition2D::new_2d(position.x(), position.y() - 1), // up + GridPosition2D::new_2d(position.x() - 1, position.y()), // left + GridPosition2D::new_2d(position.x() + 1, position.y()), // right + GridPosition2D::new_2d(position.x(), position.y() + 1), // down ]; let mut best_moves = Vec::new(); @@ -346,7 +346,8 @@ fn people_move_with_social_distancing( // Check if in quarantine zone if QUARANTINE_ZONE_ENABLED { - let quarantine_center = GridPosition::new(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); + let quarantine_center = + GridPosition2D::new_2d(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); if (*new_pos - *quarantine_center).abs().element_sum() as u32 <= QUARANTINE_RADIUS { // Only enter quarantine zone if not practicing social distancing @@ -420,15 +421,15 @@ fn disease_incubation_progression(mut query: Query<&mut Person>) /// Advanced spatial disease transmission system using infection radius fn spatial_disease_transmission( - spatial_grid: Res, - mut query: Query<(Entity, &GridPosition, &mut Person), Without>, + spatial_grid: Res, + mut query: Query<(Entity, &GridPosition2D, &mut Person), Without>, ) { let mut rng = rand::rng(); let mut new_exposures = Vec::new(); // Collect infectious people first to avoid borrowing conflicts - let infectious_people: Vec<(Entity, GridPosition)> = query + let infectious_people: Vec<(Entity, GridPosition2D)> = query .iter() .filter_map(|(entity, pos, person)| { if matches!(person.disease_state, DiseaseState::Infectious) @@ -512,7 +513,7 @@ fn disease_recovery_and_death(mut commands: Commands, mut query: Query<(Entity, } /// Update contact history for contact tracing -fn update_contact_history(mut query: Query<(&GridPosition, &mut ContactHistory)>) +fn update_contact_history(mut query: Query<(&GridPosition2D, &mut ContactHistory)>) { for (position, mut contact_history) in &mut query { @@ -535,9 +536,9 @@ fn update_contact_history(mut query: Query<(&GridPosition, &mut ContactHistory)> /// Process contact tracing when someone becomes infectious fn process_contact_tracing( mut commands: Commands, - spatial_grid: Res, + spatial_grid: Res, query_newly_infectious: Query< - (Entity, &GridPosition, &ContactHistory), + (Entity, &GridPosition2D, &ContactHistory), (With, Without), >, query_potential_contacts: Query, Without)>, diff --git a/src/lib.rs b/src/lib.rs index 3b80c6d..393a6d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ pub mod prelude; mod error; -mod plugins; +pub mod plugins; mod simulation; mod simulation_builder; mod spawner; diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index ce109ef..01f54de 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -5,4 +5,7 @@ mod time_series; pub use time_series::{TimeSeries, TimeSeriesPlugin}; mod spatial_grid; -pub use spatial_grid::{GridBounds, GridPosition, SpatialGrid, SpatialGridPlugin}; +pub use spatial_grid::{ + GridBounds2D, GridBounds3D, GridCoordinate, GridPosition2D, GridPosition3D, SpatialGrid2D, + SpatialGrid3D, SpatialGridPlugin2D, SpatialGridPlugin3D, +}; diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index e267d81..4cce359 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -4,108 +4,386 @@ use bevy::{ prelude::*, }; -/// Component representing a position in the spatial grid. -/// Built on top of Bevy's `IVec2` for compatibility with the Bevy ecosystem. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Deref, DerefMut)] -pub struct GridPosition(pub IVec2); +// Direction constants for 2D grid movement +const NORTH: IVec2 = IVec2::new(0, -1); +const SOUTH: IVec2 = IVec2::new(0, 1); +const EAST: IVec2 = IVec2::new(1, 0); +const WEST: IVec2 = IVec2::new(-1, 0); +const NORTH_EAST: IVec2 = IVec2::new(1, -1); +const NORTH_WEST: IVec2 = IVec2::new(-1, -1); +const SOUTH_EAST: IVec2 = IVec2::new(1, 1); +const SOUTH_WEST: IVec2 = IVec2::new(-1, 1); + +// Direction constants for 3D grid movement (orthogonal only) +const UP: IVec3 = IVec3::new(0, 0, 1); +const DOWN: IVec3 = IVec3::new(0, 0, -1); +const NORTH_3D: IVec3 = IVec3::new(0, -1, 0); +const SOUTH_3D: IVec3 = IVec3::new(0, 1, 0); +const EAST_3D: IVec3 = IVec3::new(1, 0, 0); +const WEST_3D: IVec3 = IVec3::new(-1, 0, 0); + +/// Sealed trait for grid coordinate types that can be used with the spatial grid system. +/// +/// This trait abstracts over 2D and 3D coordinate types, allowing the same spatial grid +/// implementation to work with both `IVec2` and `IVec3` coordinates. +pub trait GridCoordinate: + Copy + + Clone + + PartialEq + + Eq + + std::hash::Hash + + std::fmt::Debug + + Send + + Sync + + 'static + + private::Sealed +{ + /// The bounds type for this coordinate system (e.g., `IRect` for 2D, custom bounds for 3D). + type Bounds: Copy + Clone + PartialEq + Eq + std::fmt::Debug + Send + Sync + 'static; + + /// Create a new coordinate from individual components. + /// For 2D: new(x, y), for 3D: new(x, y, z) + fn new(x: i32, y: i32, z: i32) -> Self; + + /// Get the x coordinate. + fn x(self) -> i32; + + /// Get the y coordinate. + fn y(self) -> i32; + + /// Get the z coordinate (returns 0 for 2D coordinates). + fn z(self) -> i32; + + /// Calculate Manhattan distance between two coordinates. + fn manhattan_distance(self, other: Self) -> u32; + + /// Get all neighboring coordinates (Moore neighborhood). + fn neighbors(self) -> Box>; + + /// Get orthogonal neighboring coordinates (Von Neumann neighborhood). + fn neighbors_orthogonal(self) -> Box>; + + /// Create bounds from min/max coordinates. + fn create_bounds(min: Self, max: Self) -> Self::Bounds; -impl GridPosition + /// Check if this coordinate is within the given bounds. + fn within_bounds(self, bounds: &Self::Bounds) -> bool; + + /// Generate all coordinates within a Manhattan distance of this coordinate. + fn coordinates_within_distance(self, distance: u32) -> Box>; +} + +/// Private module to enforce the sealed trait pattern. +mod private { - // Direction constants for cleaner neighbor calculations - const NORTH: IVec2 = IVec2::new(0, -1); - const SOUTH: IVec2 = IVec2::new(0, 1); - const EAST: IVec2 = IVec2::new(1, 0); - const WEST: IVec2 = IVec2::new(-1, 0); - const NORTH_EAST: IVec2 = IVec2::new(1, -1); - const NORTH_WEST: IVec2 = IVec2::new(-1, -1); - const SOUTH_EAST: IVec2 = IVec2::new(1, 1); - const SOUTH_WEST: IVec2 = IVec2::new(-1, 1); + pub trait Sealed {} + impl Sealed for bevy::prelude::IVec2 {} + impl Sealed for bevy::prelude::IVec3 {} +} - /// Create a new grid position from x, y coordinates. - #[must_use] - pub const fn new(x: i32, y: i32) -> Self +/// 2D bounds type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bounds2D(pub IRect); + +/// 3D bounds type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bounds3D +{ + pub min: IVec3, + pub max: IVec3, +} + +impl GridCoordinate for IVec2 +{ + type Bounds = Bounds2D; + + fn new(x: i32, y: i32, _z: i32) -> Self { - Self(IVec2::new(x, y)) + Self::new(x, y) } + fn x(self) -> i32 + { + self.x + } + + fn y(self) -> i32 + { + self.y + } + + fn z(self) -> i32 + { + 0 + } + + fn manhattan_distance(self, other: Self) -> u32 + { + #[allow(clippy::cast_sign_loss)] + { + (self - other).abs().element_sum() as u32 + } + } + + fn neighbors(self) -> Box> + { + const DIRECTIONS: [IVec2; 8] = [ + NORTH_WEST, NORTH, NORTH_EAST, WEST, EAST, SOUTH_WEST, SOUTH, SOUTH_EAST, + ]; + Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) + } + + fn neighbors_orthogonal(self) -> Box> + { + const DIRECTIONS: [IVec2; 4] = [NORTH, WEST, EAST, SOUTH]; + Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) + } + + fn create_bounds(min: Self, max: Self) -> Self::Bounds + { + Bounds2D(IRect::new(min.x, min.y, max.x, max.y)) + } + + fn within_bounds(self, bounds: &Self::Bounds) -> bool + { + bounds.0.contains(self) + } + + fn coordinates_within_distance(self, distance: u32) -> Box> + { + #[allow(clippy::cast_possible_wrap)] + let distance_i32 = distance as i32; + Box::new( + (self.x - distance_i32..=self.x + distance_i32) + .flat_map(move |x| { + (self.y - distance_i32..=self.y + distance_i32).map(move |y| Self::new(x, y)) + }) + .filter(move |pos| self.manhattan_distance(*pos) <= distance), + ) + } +} + +impl GridCoordinate for IVec3 +{ + type Bounds = Bounds3D; + + fn new(x: i32, y: i32, z: i32) -> Self + { + Self::new(x, y, z) + } + + fn x(self) -> i32 + { + self.x + } + + fn y(self) -> i32 + { + self.y + } + + fn z(self) -> i32 + { + self.z + } + + fn manhattan_distance(self, other: Self) -> u32 + { + #[allow(clippy::cast_sign_loss)] + { + (self - other).abs().element_sum() as u32 + } + } + + fn neighbors(self) -> Box> + { + // 26 neighbors in 3D (3x3x3 cube minus center) + Box::new((-1..=1).flat_map(move |dx| { + (-1..=1).flat_map(move |dy| { + (-1..=1).filter_map(move |dz| { + if dx == 0 && dy == 0 && dz == 0 + { + None // Skip center + } + else + { + Some(self + Self::new(dx, dy, dz)) + } + }) + }) + })) + } + + fn neighbors_orthogonal(self) -> Box> + { + // 6 orthogonal neighbors in 3D + const DIRECTIONS: [IVec3; 6] = [WEST_3D, EAST_3D, NORTH_3D, SOUTH_3D, DOWN, UP]; + Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) + } + + fn create_bounds(min: Self, max: Self) -> Self::Bounds + { + Bounds3D { min, max } + } + + fn within_bounds(self, bounds: &Self::Bounds) -> bool + { + self.x >= bounds.min.x + && self.x <= bounds.max.x + && self.y >= bounds.min.y + && self.y <= bounds.max.y + && self.z >= bounds.min.z + && self.z <= bounds.max.z + } + + fn coordinates_within_distance(self, distance: u32) -> Box> + { + #[allow(clippy::cast_possible_wrap)] + let distance_i32 = distance as i32; + Box::new( + (self.x - distance_i32..=self.x + distance_i32) + .flat_map(move |x| { + (self.y - distance_i32..=self.y + distance_i32).flat_map(move |y| { + (self.z - distance_i32..=self.z + distance_i32) + .map(move |z| Self::new(x, y, z)) + }) + }) + .filter(move |pos| self.manhattan_distance(*pos) <= distance), + ) + } +} + +/// Component representing a position in the spatial grid. +/// Generic over coordinate types that implement the `GridCoordinate` trait. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Deref, DerefMut)] +pub struct GridPosition(pub T); + +impl GridPosition +{ /// Get the x coordinate. #[must_use] - pub const fn x(&self) -> i32 + pub fn x(&self) -> i32 { - self.0.x + self.0.x() } /// Get the y coordinate. #[must_use] - pub const fn y(&self) -> i32 + pub fn y(&self) -> i32 { - self.0.y + self.0.y() } - /// Get all neighboring positions (Moore neighborhood). + /// Get the z coordinate. #[must_use] + pub fn z(&self) -> i32 + { + self.0.z() + } + + /// Get all neighboring positions (Moore neighborhood). pub fn neighbors(&self) -> impl Iterator { - const DIRECTIONS: [IVec2; 8] = [ - GridPosition::NORTH_WEST, - GridPosition::NORTH, - GridPosition::NORTH_EAST, - GridPosition::WEST, - GridPosition::EAST, - GridPosition::SOUTH_WEST, - GridPosition::SOUTH, - GridPosition::SOUTH_EAST, - ]; - let base = self.0; - DIRECTIONS.into_iter().map(move |dir| Self(base + dir)) + let neighbors = self.0.neighbors(); + // Convert Box to a concrete type by collecting and iterating + let neighbors_vec: Vec = neighbors.collect(); + neighbors_vec.into_iter().map(Self) } /// Get orthogonal neighboring positions (Von Neumann neighborhood). - #[must_use] pub fn neighbors_orthogonal(&self) -> impl Iterator { - const DIRECTIONS: [IVec2; 4] = [ - GridPosition::NORTH, - GridPosition::WEST, - GridPosition::EAST, - GridPosition::SOUTH, - ]; - let base = self.0; - DIRECTIONS.into_iter().map(move |dir| Self(base + dir)) + let neighbors = self.0.neighbors_orthogonal(); + // Convert Box to a concrete type by collecting and iterating + let neighbors_vec: Vec = neighbors.collect(); + neighbors_vec.into_iter().map(Self) + } + + /// Calculate Manhattan distance to another position. + #[must_use] + pub fn manhattan_distance(&self, other: &Self) -> u32 + { + self.0.manhattan_distance(other.0) + } +} + +// Convenience methods for 2D positions +impl GridPosition +{ + /// Create a new 2D grid position from x, y coordinates. + #[must_use] + pub const fn new_2d(x: i32, y: i32) -> Self + { + Self(IVec2::new(x, y)) + } +} + +// Convenience methods for 3D positions +impl GridPosition +{ + /// Create a new 3D grid position from x, y, z coordinates. + #[must_use] + pub const fn new_3d(x: i32, y: i32, z: i32) -> Self + { + Self(IVec3::new(x, y, z)) } } /// Resource that maintains a spatial index for efficient neighbor queries. -#[derive(Resource, Default)] -pub struct SpatialGrid +/// Generic over coordinate types that implement the `GridCoordinate` trait. +#[derive(Resource)] +pub struct SpatialGrid { /// Maps grid positions to entities at those positions. - position_to_entities: HashMap>, + position_to_entities: HashMap, HashSet>, /// Maps entities to their grid positions for fast lookups (optimized for Entity keys). - entity_to_position: EntityHashMap, + entity_to_position: EntityHashMap>, /// Grid bounds for validation and iteration. - bounds: Option, + bounds: Option>, +} + +impl Default for SpatialGrid +{ + fn default() -> Self + { + Self { + position_to_entities: HashMap::default(), + entity_to_position: EntityHashMap::default(), + bounds: None, + } + } } /// Grid bounds representing the valid area for grid positions. -/// -/// Built on top of Bevy's `IRect` for compatibility, but maintains grid-specific semantics -/// where width/height represent cell counts rather than geometric dimensions. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deref, DerefMut)] -pub struct GridBounds(IRect); +/// Generic over coordinate types that implement the `GridCoordinate` trait. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GridBounds(pub T::Bounds); -impl GridBounds +impl GridBounds { + /// Create new bounds from min and max coordinates. #[must_use] - pub fn new(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self + pub fn new(min: GridPosition, max: GridPosition) -> Self { - Self(IRect::new(min_x, min_y, max_x, max_y)) + Self(T::create_bounds(min.0, max.0)) } + /// Check if a position is within these bounds. #[must_use] - pub fn contains(&self, pos: &GridPosition) -> bool + pub fn contains(&self, pos: &GridPosition) -> bool { - self.0.contains(pos.0) + pos.0.within_bounds(&self.0) + } +} + +// Specific implementations for 2D bounds +impl GridBounds +{ + /// Create 2D bounds from coordinate values. + #[must_use] + pub fn new_2d(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self + { + Self(Bounds2D(IRect::new(min_x, min_y, max_x, max_y))) } /// Get the width in grid cells (number of columns). @@ -113,7 +391,7 @@ impl GridBounds #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds pub fn width(&self) -> u32 { - (self.0.width() + 1) as u32 + (self.0.0.width() + 1) as u32 } /// Get the height in grid cells (number of rows). @@ -121,16 +399,98 @@ impl GridBounds #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds pub fn height(&self) -> u32 { - (self.0.height() + 1) as u32 + (self.0.0.height() + 1) as u32 } /// Get the total number of grid cells. #[must_use] + #[allow(clippy::missing_const_for_fn)] pub fn total_cells(&self) -> u32 { self.width() * self.height() } + /// Get the minimum x coordinate. + #[must_use] + pub const fn min_x(&self) -> i32 + { + self.0.0.min.x + } + + /// Get the maximum x coordinate. + #[must_use] + pub const fn max_x(&self) -> i32 + { + self.0.0.max.x + } + + /// Get the minimum y coordinate. + #[must_use] + pub const fn min_y(&self) -> i32 + { + self.0.0.min.y + } + + /// Get the maximum y coordinate. + #[must_use] + pub const fn max_y(&self) -> i32 + { + self.0.0.max.y + } +} + +// Specific implementations for 3D bounds +impl GridBounds +{ + /// Create 3D bounds from coordinate values. + #[must_use] + pub const fn new_3d( + min_x: i32, + max_x: i32, + min_y: i32, + max_y: i32, + min_z: i32, + max_z: i32, + ) -> Self + { + Self(Bounds3D { + min: IVec3::new(min_x, min_y, min_z), + max: IVec3::new(max_x, max_y, max_z), + }) + } + + /// Get the width in grid cells (number of columns). + #[must_use] + #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds + pub const fn width(&self) -> u32 + { + (self.0.max.x - self.0.min.x + 1) as u32 + } + + /// Get the height in grid cells (number of rows). + #[must_use] + #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds + pub const fn height(&self) -> u32 + { + (self.0.max.y - self.0.min.y + 1) as u32 + } + + /// Get the depth in grid cells (number of layers). + #[must_use] + #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds + pub const fn depth(&self) -> u32 + { + (self.0.max.z - self.0.min.z + 1) as u32 + } + + /// Get the total number of grid cells. + #[must_use] + #[allow(clippy::missing_const_for_fn)] + pub fn total_cells(&self) -> u32 + { + self.width() * self.height() * self.depth() + } + /// Get the minimum x coordinate. #[must_use] pub const fn min_x(&self) -> i32 @@ -158,25 +518,39 @@ impl GridBounds { self.0.max.y } + + /// Get the minimum z coordinate. + #[must_use] + pub const fn min_z(&self) -> i32 + { + self.0.min.z + } + + /// Get the maximum z coordinate. + #[must_use] + pub const fn max_z(&self) -> i32 + { + self.0.max.z + } } -impl From for GridBounds +impl From for GridBounds { fn from(rect: IRect) -> Self { - Self(rect) + Self(Bounds2D(rect)) } } -impl From for IRect +impl From> for IRect { - fn from(bounds: GridBounds) -> Self + fn from(bounds: GridBounds) -> Self { - bounds.0 + bounds.0.0 } } -impl SpatialGrid +impl SpatialGrid { #[must_use] pub fn new() -> Self @@ -185,27 +559,27 @@ impl SpatialGrid } #[must_use] - pub fn with_bounds(bounds: GridBounds) -> Self + pub fn with_bounds(bounds: GridBounds) -> Self { Self { bounds: Some(bounds), - ..default() + ..Self::default() } } - pub const fn set_bounds(&mut self, bounds: GridBounds) + pub const fn set_bounds(&mut self, bounds: GridBounds) { self.bounds = Some(bounds); } #[must_use] - pub const fn bounds(&self) -> Option + pub const fn bounds(&self) -> Option> { self.bounds } /// Add an entity at a specific grid position. - fn insert(&mut self, entity: Entity, position: GridPosition) + pub(crate) fn insert(&mut self, entity: Entity, position: GridPosition) { // Remove entity from old position if it exists self.remove(entity); @@ -221,7 +595,7 @@ impl SpatialGrid /// Remove an entity from the spatial index. /// /// Returns the position where the entity was located, if it was found. - fn remove(&mut self, entity: Entity) -> Option + pub(crate) fn remove(&mut self, entity: Entity) -> Option> { if let Some(position) = self.entity_to_position.remove(&entity) && let Some(entities) = self.position_to_entities.get_mut(&position) @@ -240,7 +614,7 @@ impl SpatialGrid } /// Get all entities at a specific position. - pub fn entities_at(&self, position: &GridPosition) -> impl Iterator + '_ + pub fn entities_at(&self, position: &GridPosition) -> impl Iterator + '_ { self.position_to_entities .get(position) @@ -250,23 +624,22 @@ impl SpatialGrid /// Get the position of an entity. #[must_use] - pub fn position_of(&self, entity: Entity) -> Option + pub fn position_of(&self, entity: Entity) -> Option> { self.entity_to_position.get(&entity).copied() } - /// Get all entities in the 8-connected neighborhood of a position. - #[must_use] + /// Get all entities in the neighborhood of a position (Moore neighborhood). pub fn neighbors_of<'a>( &'a self, - position: &'a GridPosition, + position: &'a GridPosition, ) -> impl Iterator + 'a { position .neighbors() .filter(move |neighbor_pos| { self.bounds - .map_or(true, |bounds| bounds.contains(neighbor_pos)) + .is_none_or(|bounds| bounds.contains(neighbor_pos)) }) .flat_map(move |neighbor_pos| { self.position_to_entities @@ -276,18 +649,17 @@ impl SpatialGrid }) } - /// Get all entities in the 4-connected orthogonal neighborhood of a position. - #[must_use] + /// Get all entities in the orthogonal neighborhood of a position (Von Neumann neighborhood). pub fn orthogonal_neighbors_of<'a>( &'a self, - position: &'a GridPosition, + position: &'a GridPosition, ) -> impl Iterator + 'a { position .neighbors_orthogonal() .filter(move |neighbor_pos| { self.bounds - .map_or(true, |bounds| bounds.contains(neighbor_pos)) + .is_none_or(|bounds| bounds.contains(neighbor_pos)) }) .flat_map(move |neighbor_pos| { self.position_to_entities @@ -299,20 +671,14 @@ impl SpatialGrid /// Get all entities within a Manhattan distance of a position. #[must_use] - #[allow(clippy::cast_possible_wrap)] - pub fn entities_within_distance(&self, center: &GridPosition, distance: u32) -> Vec - { - let distance_i32 = distance as i32; - let center_pos = *center; - - (center.x - distance_i32..=center.x + distance_i32) - .flat_map(move |x| { - (center.y - distance_i32..=center.y + distance_i32) - .map(move |y| GridPosition::new(x, y)) - }) - .filter(move |pos| (pos.0 - center_pos.0).abs().element_sum() as u32 <= distance) - .filter(move |pos| self.bounds.map_or(true, |bounds| bounds.contains(&pos))) - .flat_map(move |pos| self.entities_at(&pos).collect::>()) + pub fn entities_within_distance(&self, center: &GridPosition, distance: u32) -> Vec + { + center + .0 + .coordinates_within_distance(distance) + .map(GridPosition) + .filter(|pos| self.bounds.is_none_or(|bounds| bounds.contains(pos))) + .flat_map(|pos| self.entities_at(&pos).collect::>()) .collect() } @@ -325,11 +691,11 @@ impl SpatialGrid /// Check if a position is empty (has no entities). #[must_use] - pub fn is_empty(&self, position: &GridPosition) -> bool + pub fn is_empty(&self, position: &GridPosition) -> bool { self.position_to_entities .get(position) - .map_or(true, |set| set.is_empty()) + .is_none_or(HashSet::is_empty) } /// Get total number of entities in the grid. @@ -341,12 +707,14 @@ impl SpatialGrid } /// Plugin that maintains a spatial index for entities with `GridPosition` components. -pub struct SpatialGridPlugin +/// Generic over coordinate types that implement the `GridCoordinate` trait. +pub struct SpatialGridPlugin { - bounds: Option, + bounds: Option>, + _phantom: std::marker::PhantomData, } -impl Default for SpatialGridPlugin +impl Default for SpatialGridPlugin { fn default() -> Self { @@ -354,28 +722,32 @@ impl Default for SpatialGridPlugin } } -impl SpatialGridPlugin +impl SpatialGridPlugin { pub const fn new() -> Self { - Self { bounds: None } + Self { + bounds: None, + _phantom: std::marker::PhantomData, + } } - pub const fn with_bounds(bounds: GridBounds) -> Self + pub const fn with_bounds(bounds: GridBounds) -> Self { Self { bounds: Some(bounds), + _phantom: std::marker::PhantomData, } } - pub fn init(app: &mut App, bounds: Option) + pub fn init(app: &mut App, bounds: Option>) { let spatial_grid = bounds.map_or_else(SpatialGrid::new, SpatialGrid::with_bounds); app.insert_resource(spatial_grid); } } -impl Plugin for SpatialGridPlugin +impl Plugin for SpatialGridPlugin { fn build(&self, app: &mut App) { @@ -384,17 +756,24 @@ impl Plugin for SpatialGridPlugin // System to maintain the spatial index app.add_systems( PreUpdate, - (spatial_grid_update_system, spatial_grid_cleanup_system).chain(), + ( + spatial_grid_update_system::, + spatial_grid_cleanup_system::, + ) + .chain(), ); } } /// Query for entities with `GridPosition` components that have been added or changed. -type GridPositionQuery<'world, 'state> = - Query<'world, 'state, (Entity, &'static GridPosition), Changed>; +type GridPositionQuery<'world, 'state, T> = + Query<'world, 'state, (Entity, &'static GridPosition), Changed>>; /// System that updates the spatial grid when entities with `GridPosition` are added or moved. -pub fn spatial_grid_update_system(mut spatial_grid: ResMut, query: GridPositionQuery) +pub fn spatial_grid_update_system( + mut spatial_grid: ResMut>, + query: GridPositionQuery, +) { for (entity, position) in &query { @@ -403,9 +782,9 @@ pub fn spatial_grid_update_system(mut spatial_grid: ResMut, query: } /// System that removes entities from the spatial grid when they no longer have `GridPosition`. -pub fn spatial_grid_cleanup_system( - mut spatial_grid: ResMut, - mut removed: RemovedComponents, +pub fn spatial_grid_cleanup_system( + mut spatial_grid: ResMut>, + mut removed: RemovedComponents>, ) { for entity in removed.read() @@ -413,3 +792,28 @@ pub fn spatial_grid_cleanup_system( spatial_grid.remove(entity); } } + +// Type aliases for convenience +/// 2D spatial grid using `IVec2` coordinates. +pub type SpatialGrid2D = SpatialGrid; + +/// 3D spatial grid using `IVec3` coordinates. +pub type SpatialGrid3D = SpatialGrid; + +/// 2D grid position using `IVec2` coordinates. +pub type GridPosition2D = GridPosition; + +/// 3D grid position using `IVec3` coordinates. +pub type GridPosition3D = GridPosition; + +/// 2D grid bounds using `IRect`. +pub type GridBounds2D = GridBounds; + +/// 3D grid bounds using custom `Bounds3D`. +pub type GridBounds3D = GridBounds; + +/// 2D spatial grid plugin. +pub type SpatialGridPlugin2D = SpatialGridPlugin; + +/// 3D spatial grid plugin. +pub type SpatialGridPlugin3D = SpatialGridPlugin; diff --git a/src/plugins/time_series.rs b/src/plugins/time_series.rs index 468b7f5..b12c9c2 100644 --- a/src/plugins/time_series.rs +++ b/src/plugins/time_series.rs @@ -32,6 +32,7 @@ where O: Send + Sync + 'static, F: QueryFilter + Send + Sync + 'static, { + #[must_use] pub const fn new(sample_interval: usize) -> Self { Self { diff --git a/src/prelude.rs b/src/prelude.rs index a9d6c44..b3f9ad7 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -5,7 +5,7 @@ pub use bevy::prelude::{ pub use super::{ error::*, - plugins::{GridBounds, GridPosition, SpatialGrid}, + plugins::{GridBounds2D, GridPosition2D, SpatialGrid2D}, simulation::Simulation, simulation_builder::SimulationBuilder, spawner::Spawner, diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index e9a20a5..a98d7ee 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -6,7 +6,10 @@ use bevy::{ use crate::{ Sample, SimulationBuildError, - plugins::{GridBounds, SpatialGridPlugin, StepCounterPlugin, TimeSeries, TimeSeriesPlugin}, + plugins::{ + GridBounds2D, GridBounds3D, SpatialGridPlugin2D, SpatialGridPlugin3D, StepCounterPlugin, + TimeSeries, TimeSeriesPlugin, + }, simulation::Simulation, spawner::Spawner, }; @@ -76,26 +79,49 @@ impl SimulationBuilder self } - /// Add spatial grid support to the simulation. + /// Add 2D spatial grid support to the simulation. /// - /// This enables efficient spatial queries for entities with `GridPosition` components. + /// This enables efficient spatial queries for entities with `GridPosition2D` components. /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. /// /// # Example /// ```rust /// use incerto::prelude::*; /// - /// let bounds = GridBounds::new(0, 99, 0, 99); + /// let bounds = GridBounds2D::new_2d(0, 99, 0, 99); /// let simulation = SimulationBuilder::new() /// .add_spatial_grid(bounds) /// .build(); /// ``` #[must_use] - pub fn add_spatial_grid(mut self, bounds: GridBounds) -> Self + pub fn add_spatial_grid(mut self, bounds: GridBounds2D) -> Self { self.sim .app - .add_plugins(SpatialGridPlugin::with_bounds(bounds)); + .add_plugins(SpatialGridPlugin2D::with_bounds(bounds)); + self + } + + /// Add 3D spatial grid support to the simulation. + /// + /// This enables efficient spatial queries for entities with `GridPosition3D` components. + /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. + /// + /// # Example + /// ```rust + /// use incerto::{SimulationBuilder, plugins::GridBounds3D}; + /// + /// let bounds = GridBounds3D::new_3d(0, 99, 0, 99, 0, 99); + /// let simulation = SimulationBuilder::new() + /// .add_spatial_grid_3d(bounds) + /// .build(); + /// ``` + #[must_use] + pub fn add_spatial_grid_3d(mut self, bounds: GridBounds3D) -> Self + { + self.sim + .app + .add_plugins(SpatialGridPlugin3D::with_bounds(bounds)); self } diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 50d43c3..de8167b 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -2,7 +2,10 @@ #![allow(clippy::uninlined_format_args)] #![allow(clippy::cast_possible_truncation)] -use incerto::prelude::*; +use incerto::{ + plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, + prelude::*, +}; #[derive(Component)] struct TestEntity(i32); @@ -10,21 +13,21 @@ struct TestEntity(i32); #[test] fn test_grid_position_neighbors() { - let pos = GridPosition::new(1, 1); + let pos = GridPosition2D::new_2d(1, 1); - let neighbors: Vec = pos.neighbors().collect(); + let neighbors: Vec = pos.neighbors().collect(); assert_eq!(neighbors.len(), 8); // Check all 8 neighbors are present let expected_neighbors = [ - GridPosition::new(0, 0), - GridPosition::new(1, 0), - GridPosition::new(2, 0), - GridPosition::new(0, 1), - GridPosition::new(2, 1), - GridPosition::new(0, 2), - GridPosition::new(1, 2), - GridPosition::new(2, 2), + GridPosition2D::new_2d(0, 0), + GridPosition2D::new_2d(1, 0), + GridPosition2D::new_2d(2, 0), + GridPosition2D::new_2d(0, 1), + GridPosition2D::new_2d(2, 1), + GridPosition2D::new_2d(0, 2), + GridPosition2D::new_2d(1, 2), + GridPosition2D::new_2d(2, 2), ]; for expected in expected_neighbors @@ -40,16 +43,16 @@ fn test_grid_position_neighbors() #[test] fn test_grid_position_orthogonal_neighbors() { - let pos = GridPosition::new(1, 1); + let pos = GridPosition2D::new_2d(1, 1); - let neighbors: Vec = pos.neighbors_orthogonal().collect(); + let neighbors: Vec = pos.neighbors_orthogonal().collect(); assert_eq!(neighbors.len(), 4); let expected_neighbors = [ - GridPosition::new(1, 0), // top - GridPosition::new(0, 1), // left - GridPosition::new(2, 1), // right - GridPosition::new(1, 2), // bottom + GridPosition2D::new_2d(1, 0), // top + GridPosition2D::new_2d(0, 1), // left + GridPosition2D::new_2d(2, 1), // right + GridPosition2D::new_2d(1, 2), // bottom ]; for expected in expected_neighbors @@ -65,60 +68,55 @@ fn test_grid_position_orthogonal_neighbors() #[test] fn test_grid_position_distances() { - let pos1 = GridPosition::new(0, 0); - let pos2 = GridPosition::new(3, 4); - - // Test Manhattan distance using IVec2 operations - assert_eq!((*pos1 - *pos2).abs().element_sum(), 7); - // Test Euclidean distance squared using IVec2 operations - let diff = *pos1 - *pos2; - assert_eq!(diff.x * diff.x + diff.y * diff.y, 25); - - let pos3 = GridPosition::new(1, 1); - assert_eq!((*pos1 - *pos3).abs().element_sum(), 2); - let diff3 = *pos1 - *pos3; - assert_eq!(diff3.x * diff3.x + diff3.y * diff3.y, 2); + let pos1 = GridPosition2D::new_2d(0, 0); + let pos2 = GridPosition2D::new_2d(3, 4); + + // Test Manhattan distance using the trait method + assert_eq!(pos1.manhattan_distance(&pos2), 7); + + let pos3 = GridPosition2D::new_2d(1, 1); + assert_eq!(pos1.manhattan_distance(&pos3), 2); } #[test] fn test_grid_bounds() { - let bounds = GridBounds::new(0, 9, 0, 9); + let bounds = GridBounds2D::new_2d(0, 9, 0, 9); assert_eq!(bounds.width(), 10); assert_eq!(bounds.height(), 10); assert_eq!(bounds.total_cells(), 100); - assert!(bounds.contains(&GridPosition::new(0, 0))); - assert!(bounds.contains(&GridPosition::new(9, 9))); - assert!(bounds.contains(&GridPosition::new(5, 5))); + assert!(bounds.contains(&GridPosition2D::new_2d(0, 0))); + assert!(bounds.contains(&GridPosition2D::new_2d(9, 9))); + assert!(bounds.contains(&GridPosition2D::new_2d(5, 5))); - assert!(!bounds.contains(&GridPosition::new(-1, 0))); - assert!(!bounds.contains(&GridPosition::new(0, -1))); - assert!(!bounds.contains(&GridPosition::new(10, 5))); - assert!(!bounds.contains(&GridPosition::new(5, 10))); + assert!(!bounds.contains(&GridPosition2D::new_2d(-1, 0))); + assert!(!bounds.contains(&GridPosition2D::new_2d(0, -1))); + assert!(!bounds.contains(&GridPosition2D::new_2d(10, 5))); + assert!(!bounds.contains(&GridPosition2D::new_2d(5, 10))); } #[test] fn test_spatial_grid_plugin_integration() { - let _bounds = GridBounds::new(0, 2, 0, 2); + let _bounds = GridBounds2D::new_2d(0, 2, 0, 2); let builder = SimulationBuilder::new() .add_entity_spawner(|spawner| { // Spawn entities with grid positions - spawner.spawn((GridPosition::new(0, 0), TestEntity(1))); - spawner.spawn((GridPosition::new(1, 1), TestEntity(2))); - spawner.spawn((GridPosition::new(2, 2), TestEntity(3))); + spawner.spawn((GridPosition2D::new_2d(0, 0), TestEntity(1))); + spawner.spawn((GridPosition2D::new_2d(1, 1), TestEntity(2))); + spawner.spawn((GridPosition2D::new_2d(2, 2), TestEntity(3))); }) - .add_systems(|query: Query<(&GridPosition, &TestEntity)>| { + .add_systems(|query: Query<(&GridPosition2D, &TestEntity)>| { // Verify entities have grid positions and test data for (position, test_entity) in &query { // Verify test entity data assert!(test_entity.0 > 0); - assert!(position.x >= 0 && position.x <= 2); - assert!(position.y >= 0 && position.y <= 2); + assert!(position.x() >= 0 && position.x() <= 2); + assert!(position.y() >= 0 && position.y() <= 2); } }); @@ -128,3 +126,140 @@ fn test_spatial_grid_plugin_integration() // Test completed without panics, which means the spatial grid plugin // is working correctly with the simulation systems } + +#[test] +fn test_3d_grid_position_neighbors() +{ + let pos = GridPosition3D::new_3d(1, 1, 1); + + let neighbors: Vec = pos.neighbors().collect(); + assert_eq!(neighbors.len(), 26); // 3x3x3 cube minus center = 26 neighbors + + // Check that center position is not included + assert!(!neighbors.contains(&pos)); + + // Check some specific 3D neighbors + assert!(neighbors.contains(&GridPosition3D::new_3d(0, 0, 0))); // corner + assert!(neighbors.contains(&GridPosition3D::new_3d(2, 2, 2))); // opposite corner + assert!(neighbors.contains(&GridPosition3D::new_3d(1, 1, 0))); // directly below + assert!(neighbors.contains(&GridPosition3D::new_3d(1, 1, 2))); // directly above +} + +#[test] +fn test_3d_grid_position_orthogonal_neighbors() +{ + let pos = GridPosition3D::new_3d(1, 1, 1); + + let neighbors: Vec = pos.neighbors_orthogonal().collect(); + assert_eq!(neighbors.len(), 6); // 6 orthogonal directions in 3D + + let expected_neighbors = [ + GridPosition3D::new_3d(0, 1, 1), // -x + GridPosition3D::new_3d(2, 1, 1), // +x + GridPosition3D::new_3d(1, 0, 1), // -y + GridPosition3D::new_3d(1, 2, 1), // +y + GridPosition3D::new_3d(1, 1, 0), // -z + GridPosition3D::new_3d(1, 1, 2), // +z + ]; + + for expected in expected_neighbors + { + assert!( + neighbors.contains(&expected), + "Missing 3D orthogonal neighbor: {:?}", + expected + ); + } +} + +#[test] +fn test_3d_grid_position_distances() +{ + let pos1 = GridPosition3D::new_3d(0, 0, 0); + let pos2 = GridPosition3D::new_3d(3, 4, 5); + + // Test 3D Manhattan distance + assert_eq!(pos1.manhattan_distance(&pos2), 12); // 3 + 4 + 5 = 12 + + let pos3 = GridPosition3D::new_3d(1, 1, 1); + assert_eq!(pos1.manhattan_distance(&pos3), 3); // 1 + 1 + 1 = 3 +} + +#[test] +fn test_3d_grid_bounds() +{ + let bounds = GridBounds3D::new_3d(0, 9, 0, 9, 0, 9); + + assert_eq!(bounds.width(), 10); + assert_eq!(bounds.height(), 10); + assert_eq!(bounds.depth(), 10); + assert_eq!(bounds.total_cells(), 1000); + + assert!(bounds.contains(&GridPosition3D::new_3d(0, 0, 0))); + assert!(bounds.contains(&GridPosition3D::new_3d(9, 9, 9))); + assert!(bounds.contains(&GridPosition3D::new_3d(5, 5, 5))); + + assert!(!bounds.contains(&GridPosition3D::new_3d(-1, 0, 0))); + assert!(!bounds.contains(&GridPosition3D::new_3d(0, -1, 0))); + assert!(!bounds.contains(&GridPosition3D::new_3d(0, 0, -1))); + assert!(!bounds.contains(&GridPosition3D::new_3d(10, 5, 5))); + assert!(!bounds.contains(&GridPosition3D::new_3d(5, 10, 5))); + assert!(!bounds.contains(&GridPosition3D::new_3d(5, 5, 10))); +} + +#[test] +fn test_3d_spatial_grid_integration() +{ + #[derive(Component)] + struct TestEntity3D(i32); + + impl Sample for TestEntity3D + { + fn sample(components: &[&Self]) -> usize + { + components.len() + } + } + + let bounds = GridBounds3D::new_3d(0, 4, 0, 4, 0, 4); + + let builder = SimulationBuilder::new() + .add_spatial_grid_3d(bounds) + .add_entity_spawner(|spawner| { + // Spawn entities at different 3D positions + spawner.spawn((GridPosition3D::new_3d(0, 0, 0), TestEntity3D(1))); + spawner.spawn((GridPosition3D::new_3d(2, 2, 2), TestEntity3D(2))); + spawner.spawn((GridPosition3D::new_3d(4, 4, 4), TestEntity3D(3))); + spawner.spawn((GridPosition3D::new_3d(1, 2, 3), TestEntity3D(4))); + }) + .add_systems( + |spatial_grid: Res, + query: Query<(Entity, &GridPosition3D, &TestEntity3D)>| { + // Test 3D spatial queries + let center_pos = GridPosition3D::new_3d(2, 2, 2); + + // Find entities within distance 2 in 3D space + let nearby_entities = spatial_grid.entities_within_distance(¢er_pos, 2); + assert!( + !nearby_entities.is_empty(), + "Should find nearby entities in 3D" + ); + + // Verify all positions are valid 3D coordinates + for (_, position, test_entity) in &query + { + assert!(test_entity.0 > 0); + assert!(position.x() >= 0 && position.x() <= 4); + assert!(position.y() >= 0 && position.y() <= 4); + assert!(position.z() >= 0 && position.z() <= 4); + } + }, + ); + + let mut simulation = builder.build(); + simulation.run(1); + + // Verify all entities were created + let entity_count = simulation.sample::().unwrap(); + assert_eq!(entity_count, 4); +} From 15cb9387743f2c90e074e366362a71607c19528a Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 20:14:56 -0600 Subject: [PATCH 06/23] remove distance functions --- examples/pandemic_spatial.rs | 21 ++++++++++++++++++--- src/plugins/spatial_grid.rs | 13 ------------- tests/test_spatial_grid.rs | 27 +++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index 2d8ac0e..f44f703 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -445,9 +445,24 @@ fn spatial_disease_transmission( for (infectious_entity, infectious_pos) in infectious_people { - // Get all people within infection radius - let nearby_entities = - spatial_grid.entities_within_distance(&infectious_pos, INFECTION_RADIUS); + // Get all people within infection radius using iterative approach + let mut nearby_entities = Vec::new(); + let infectious_coord = *infectious_pos; + + // Check all positions within Manhattan distance of INFECTION_RADIUS + for dx in -(INFECTION_RADIUS as i32)..=(INFECTION_RADIUS as i32) + { + for dy in -(INFECTION_RADIUS as i32)..=(INFECTION_RADIUS as i32) + { + let manhattan_distance = dx.abs() + dy.abs(); + if manhattan_distance <= INFECTION_RADIUS as i32 + { + let check_pos = + GridPosition2D::new_2d(infectious_coord.x + dx, infectious_coord.y + dy); + nearby_entities.extend(spatial_grid.entities_at(&check_pos)); + } + } + } for nearby_entity in nearby_entities { diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 4cce359..d301540 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -669,19 +669,6 @@ impl SpatialGrid }) } - /// Get all entities within a Manhattan distance of a position. - #[must_use] - pub fn entities_within_distance(&self, center: &GridPosition, distance: u32) -> Vec - { - center - .0 - .coordinates_within_distance(distance) - .map(GridPosition) - .filter(|pos| self.bounds.is_none_or(|bounds| bounds.contains(pos))) - .flat_map(|pos| self.entities_at(&pos).collect::>()) - .collect() - } - /// Clear all entities from the spatial index. pub fn clear(&mut self) { diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index de8167b..2c0b1cc 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -238,8 +238,31 @@ fn test_3d_spatial_grid_integration() // Test 3D spatial queries let center_pos = GridPosition3D::new_3d(2, 2, 2); - // Find entities within distance 2 in 3D space - let nearby_entities = spatial_grid.entities_within_distance(¢er_pos, 2); + // Find entities within distance 2 in 3D space using neighbor-based approach + let mut nearby_entities = Vec::new(); + let center_coord = center_pos.0; + + // Check all positions within Manhattan distance of 2 + for dx in -2i32..=2i32 + { + for dy in -2i32..=2i32 + { + for dz in -2i32..=2i32 + { + let manhattan_distance = dx.abs() + dy.abs() + dz.abs(); + if manhattan_distance <= 2 + { + let check_pos = GridPosition3D::new_3d( + center_coord.x + dx, + center_coord.y + dy, + center_coord.z + dz, + ); + nearby_entities.extend(spatial_grid.entities_at(&check_pos)); + } + } + } + } + assert!( !nearby_entities.is_empty(), "Should find nearby entities in 3D" From 9562b4ef47f88ec1bfea958eb7a36ed81e66780e Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 20:20:12 -0600 Subject: [PATCH 07/23] spatial construction --- src/plugins/spatial_grid.rs | 47 ++++++------------------------------- src/simulation_builder.rs | 4 ++-- 2 files changed, 9 insertions(+), 42 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index d301540..0870733 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -342,18 +342,6 @@ pub struct SpatialGrid bounds: Option>, } -impl Default for SpatialGrid -{ - fn default() -> Self - { - Self { - position_to_entities: HashMap::default(), - entity_to_position: EntityHashMap::default(), - bounds: None, - } - } -} - /// Grid bounds representing the valid area for grid positions. /// Generic over coordinate types that implement the `GridCoordinate` trait. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -553,17 +541,12 @@ impl From> for IRect impl SpatialGrid { #[must_use] - pub fn new() -> Self - { - Self::default() - } - - #[must_use] - pub fn with_bounds(bounds: GridBounds) -> Self + pub fn new(bounds: Option>) -> Self { Self { - bounds: Some(bounds), - ..Self::default() + position_to_entities: HashMap::default(), + entity_to_position: EntityHashMap::default(), + bounds, } } @@ -701,35 +684,19 @@ pub struct SpatialGridPlugin _phantom: std::marker::PhantomData, } -impl Default for SpatialGridPlugin -{ - fn default() -> Self - { - Self::new() - } -} - impl SpatialGridPlugin { - pub const fn new() -> Self - { - Self { - bounds: None, - _phantom: std::marker::PhantomData, - } - } - - pub const fn with_bounds(bounds: GridBounds) -> Self + pub const fn new(bounds: Option>) -> Self { Self { - bounds: Some(bounds), + bounds, _phantom: std::marker::PhantomData, } } pub fn init(app: &mut App, bounds: Option>) { - let spatial_grid = bounds.map_or_else(SpatialGrid::new, SpatialGrid::with_bounds); + let spatial_grid = SpatialGrid::new(bounds); app.insert_resource(spatial_grid); } } diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index a98d7ee..0eceef4 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -98,7 +98,7 @@ impl SimulationBuilder { self.sim .app - .add_plugins(SpatialGridPlugin2D::with_bounds(bounds)); + .add_plugins(SpatialGridPlugin2D::new(Some(bounds))); self } @@ -121,7 +121,7 @@ impl SimulationBuilder { self.sim .app - .add_plugins(SpatialGridPlugin3D::with_bounds(bounds)); + .add_plugins(SpatialGridPlugin3D::new(Some(bounds))); self } From b4ec1d8ebb8d430d1472832a0911ff92df87995c Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 20:30:13 -0600 Subject: [PATCH 08/23] generic spatial grid builder --- examples/air_traffic_3d.rs | 2 +- src/plugins/mod.rs | 5 +++-- src/simulation_builder.rs | 46 +++++++++++++------------------------- tests/test_spatial_grid.rs | 2 +- 4 files changed, 21 insertions(+), 34 deletions(-) diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs index 50e2ee5..eb32134 100644 --- a/examples/air_traffic_3d.rs +++ b/examples/air_traffic_3d.rs @@ -222,7 +222,7 @@ fn main() ); let mut simulation = SimulationBuilder::new() - .add_spatial_grid_3d(bounds) + .add_spatial_grid(bounds) .add_entity_spawner(spawn_aircraft) .add_systems((move_aircraft, check_conflicts, display_airspace)) .build(); diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 01f54de..7ed6e5e 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -6,6 +6,7 @@ pub use time_series::{TimeSeries, TimeSeriesPlugin}; mod spatial_grid; pub use spatial_grid::{ - GridBounds2D, GridBounds3D, GridCoordinate, GridPosition2D, GridPosition3D, SpatialGrid2D, - SpatialGrid3D, SpatialGridPlugin2D, SpatialGridPlugin3D, + GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, GridPosition2D, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridPlugin, + SpatialGridPlugin2D, SpatialGridPlugin3D, }; diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index 0eceef4..897f001 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -7,8 +7,8 @@ use bevy::{ use crate::{ Sample, SimulationBuildError, plugins::{ - GridBounds2D, GridBounds3D, SpatialGridPlugin2D, SpatialGridPlugin3D, StepCounterPlugin, - TimeSeries, TimeSeriesPlugin, + GridBounds, GridCoordinate, SpatialGridPlugin, StepCounterPlugin, TimeSeries, + TimeSeriesPlugin, }, simulation::Simulation, spawner::Spawner, @@ -79,49 +79,35 @@ impl SimulationBuilder self } - /// Add 2D spatial grid support to the simulation. + /// Add spatial grid support to the simulation. /// - /// This enables efficient spatial queries for entities with `GridPosition2D` components. + /// This enables efficient spatial queries for entities with grid position components. /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. + /// Works with both 2D and 3D coordinate systems. /// /// # Example /// ```rust /// use incerto::prelude::*; + /// use incerto::plugins::GridBounds3D; /// - /// let bounds = GridBounds2D::new_2d(0, 99, 0, 99); - /// let simulation = SimulationBuilder::new() - /// .add_spatial_grid(bounds) + /// // 2D spatial grid + /// let bounds_2d = GridBounds2D::new_2d(0, 99, 0, 99); + /// let simulation_2d = SimulationBuilder::new() + /// .add_spatial_grid(bounds_2d) /// .build(); - /// ``` - #[must_use] - pub fn add_spatial_grid(mut self, bounds: GridBounds2D) -> Self - { - self.sim - .app - .add_plugins(SpatialGridPlugin2D::new(Some(bounds))); - self - } - - /// Add 3D spatial grid support to the simulation. - /// - /// This enables efficient spatial queries for entities with `GridPosition3D` components. - /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. - /// - /// # Example - /// ```rust - /// use incerto::{SimulationBuilder, plugins::GridBounds3D}; /// - /// let bounds = GridBounds3D::new_3d(0, 99, 0, 99, 0, 99); - /// let simulation = SimulationBuilder::new() - /// .add_spatial_grid_3d(bounds) + /// // 3D spatial grid + /// let bounds_3d = GridBounds3D::new_3d(0, 99, 0, 99, 0, 99); + /// let simulation_3d = SimulationBuilder::new() + /// .add_spatial_grid(bounds_3d) /// .build(); /// ``` #[must_use] - pub fn add_spatial_grid_3d(mut self, bounds: GridBounds3D) -> Self + pub fn add_spatial_grid(mut self, bounds: GridBounds) -> Self { self.sim .app - .add_plugins(SpatialGridPlugin3D::new(Some(bounds))); + .add_plugins(SpatialGridPlugin::::new(Some(bounds))); self } diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 2c0b1cc..0c947fd 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -224,7 +224,7 @@ fn test_3d_spatial_grid_integration() let bounds = GridBounds3D::new_3d(0, 4, 0, 4, 0, 4); let builder = SimulationBuilder::new() - .add_spatial_grid_3d(bounds) + .add_spatial_grid(bounds) .add_entity_spawner(|spawner| { // Spawn entities at different 3D positions spawner.spawn((GridPosition3D::new_3d(0, 0, 0), TestEntity3D(1))); From 9899a17a6e3677dfcfcfa827aeb3f347575bdc4e Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 20:35:47 -0600 Subject: [PATCH 09/23] spatial grid reset --- src/plugins/spatial_grid.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 0870733..dc2e61a 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -4,6 +4,8 @@ use bevy::{ prelude::*, }; +use crate::plugins::step_counter::StepCounter; + // Direction constants for 2D grid movement const NORTH: IVec2 = IVec2::new(0, -1); const SOUTH: IVec2 = IVec2::new(0, 1); @@ -711,6 +713,7 @@ impl Plugin for SpatialGridPlugin app.add_systems( PreUpdate, ( + spatial_grid_reset_system::, spatial_grid_update_system::, spatial_grid_cleanup_system::, ) @@ -719,6 +722,20 @@ impl Plugin for SpatialGridPlugin } } +/// System that resets the spatial grid at the beginning of each simulation. +pub fn spatial_grid_reset_system( + mut spatial_grid: ResMut>, + step_counter: Res, +) +{ + // Reset the spatial grid whenever the step counter is 0 + // This should occur on the first step of every simulation + if **step_counter == 0 + { + spatial_grid.clear(); + } +} + /// Query for entities with `GridPosition` components that have been added or changed. type GridPositionQuery<'world, 'state, T> = Query<'world, 'state, (Entity, &'static GridPosition), Changed>>; From fb39c4efc2f4c616fdffb7b0592dc9201cf5dc93 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 20:45:58 -0600 Subject: [PATCH 10/23] remove more distance funcs --- src/plugins/spatial_grid.rs | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index dc2e61a..333eee9 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -70,9 +70,6 @@ pub trait GridCoordinate: /// Check if this coordinate is within the given bounds. fn within_bounds(self, bounds: &Self::Bounds) -> bool; - - /// Generate all coordinates within a Manhattan distance of this coordinate. - fn coordinates_within_distance(self, distance: u32) -> Box>; } /// Private module to enforce the sealed trait pattern. @@ -150,19 +147,6 @@ impl GridCoordinate for IVec2 { bounds.0.contains(self) } - - fn coordinates_within_distance(self, distance: u32) -> Box> - { - #[allow(clippy::cast_possible_wrap)] - let distance_i32 = distance as i32; - Box::new( - (self.x - distance_i32..=self.x + distance_i32) - .flat_map(move |x| { - (self.y - distance_i32..=self.y + distance_i32).map(move |y| Self::new(x, y)) - }) - .filter(move |pos| self.manhattan_distance(*pos) <= distance), - ) - } } impl GridCoordinate for IVec3 @@ -237,22 +221,6 @@ impl GridCoordinate for IVec3 && self.z >= bounds.min.z && self.z <= bounds.max.z } - - fn coordinates_within_distance(self, distance: u32) -> Box> - { - #[allow(clippy::cast_possible_wrap)] - let distance_i32 = distance as i32; - Box::new( - (self.x - distance_i32..=self.x + distance_i32) - .flat_map(move |x| { - (self.y - distance_i32..=self.y + distance_i32).flat_map(move |y| { - (self.z - distance_i32..=self.z + distance_i32) - .map(move |z| Self::new(x, y, z)) - }) - }) - .filter(move |pos| self.manhattan_distance(*pos) <= distance), - ) - } } /// Component representing a position in the spatial grid. From 120b865ecdc8dd4bf94eb96d0fae7d11166eb82d Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 21:26:57 -0600 Subject: [PATCH 11/23] generic spatial grid over component --- examples/air_traffic_3d.rs | 12 +++-- examples/forest_fire.rs | 22 ++++++--- examples/pandemic_spatial.rs | 32 ++++++++++--- src/plugins/mod.rs | 6 +-- src/plugins/spatial_grid.rs | 87 ++++++++++++++++++++++-------------- src/simulation_builder.rs | 44 +++++++++--------- tests/test_spatial_grid.rs | 57 +++++++++++++++++++++-- 7 files changed, 187 insertions(+), 73 deletions(-) diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs index eb32134..fd3f767 100644 --- a/examples/air_traffic_3d.rs +++ b/examples/air_traffic_3d.rs @@ -5,7 +5,7 @@ use bevy::prelude::*; use incerto::{ - plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, + plugins::{GridBounds3D, GridPosition3D, SpatialGrid, SpatialGridEntity}, prelude::*, }; use rand::prelude::*; @@ -121,10 +121,16 @@ fn move_aircraft(mut query: Query<(&mut GridPosition3D, &Aircraft)>) /// Check for aircraft conflicts (too close in 3D space) fn check_conflicts( - spatial_grid: Res, + spatial_grids: Query<&SpatialGrid, With>, query: Query<(Entity, &GridPosition3D, &Aircraft)>, ) { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; + let mut conflicts = 0; for (entity, position, aircraft) in &query @@ -222,7 +228,7 @@ fn main() ); let mut simulation = SimulationBuilder::new() - .add_spatial_grid(bounds) + .add_spatial_grid::(bounds) .add_entity_spawner(spawn_aircraft) .add_systems((move_aircraft, check_conflicts, display_airspace)) .build(); diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs index be93eb6..5f825dd 100644 --- a/examples/forest_fire.rs +++ b/examples/forest_fire.rs @@ -3,7 +3,7 @@ //! This example showcases a spatial cellular automaton simulation where fire spreads //! through a forest based on probabilistic rules. The simulation demonstrates: //! -//! * Spatial grid-based entities using the `SpatialGrid2DPlugin` +//! * Spatial grid-based entities using the `SpatialGridPlugin` //! * Entity state transitions (Healthy → Burning → Burned → Empty) //! * Neighborhood interactions for fire spreading //! * Time series collection of fire statistics @@ -24,7 +24,11 @@ use std::collections::HashSet; -use incerto::prelude::*; +use bevy::prelude::IVec2; +use incerto::{ + plugins::{SpatialGrid, SpatialGridEntity}, + prelude::*, +}; use rand::prelude::*; // Simulation parameters @@ -151,7 +155,7 @@ fn main() let bounds = GridBounds2D::new_2d(0, GRID_WIDTH - 1, 0, GRID_HEIGHT - 1); let mut simulation = SimulationBuilder::new() // Add spatial grid support - .add_spatial_grid(bounds) + .add_spatial_grid::(bounds) // Spawn the forest grid .add_entity_spawner(spawn_forest_grid) // Add fire spread system @@ -271,13 +275,19 @@ fn spawn_forest_grid(spawner: &mut Spawner) } } -/// System that handles fire spreading to neighboring cells using `SpatialGrid2D`. +/// System that handles fire spreading to neighboring cells using the spatial grid. fn fire_spread_system( - spatial_grid: Res, + spatial_grids: Query<&SpatialGrid, With>, query_burning: Query<(Entity, &GridPosition2D), With>, mut query_cells: Query<(&GridPosition2D, &mut ForestCell)>, ) { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; + let mut rng = rand::rng(); let mut spread_positions = HashSet::new(); @@ -288,7 +298,7 @@ fn fire_spread_system( if let Ok((_, cell)) = query_cells.get(burning_entity) && matches!(cell.state, CellState::Burning { .. }) { - // Get orthogonal neighbors using SpatialGrid2D + // Get orthogonal neighbors using the spatial grid let neighbors = spatial_grid.orthogonal_neighbors_of(burning_pos); for neighbor_entity in neighbors diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index f44f703..26ea8d8 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -21,7 +21,11 @@ use std::collections::HashSet; -use incerto::prelude::*; +use bevy::prelude::IVec2; +use incerto::{ + plugins::{SpatialGrid, SpatialGridEntity}, + prelude::*, +}; use rand::prelude::*; // Simulation parameters @@ -166,7 +170,7 @@ fn main() let bounds = GridBounds2D::new_2d(0, GRID_SIZE - 1, 0, GRID_SIZE - 1); let mut simulation = SimulationBuilder::new() // Add spatial grid support - .add_spatial_grid(bounds) + .add_spatial_grid::(bounds) // Spawn initial population .add_entity_spawner(spawn_population) // Movement and social distancing @@ -305,9 +309,15 @@ fn spawn_population(spawner: &mut Spawner) /// Enhanced movement system with social distancing behavior fn people_move_with_social_distancing( mut query: Query<(&mut GridPosition2D, &Person, Option<&Quarantined>)>, - spatial_grid: Res, + spatial_grids: Query<&SpatialGrid, With>, ) { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; + let mut rng = rand::rng(); for (mut position, person, quarantined) in &mut query @@ -421,10 +431,16 @@ fn disease_incubation_progression(mut query: Query<&mut Person>) /// Advanced spatial disease transmission system using infection radius fn spatial_disease_transmission( - spatial_grid: Res, + spatial_grids: Query<&SpatialGrid, With>, mut query: Query<(Entity, &GridPosition2D, &mut Person), Without>, ) { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; + let mut rng = rand::rng(); let mut new_exposures = Vec::new(); @@ -551,7 +567,7 @@ fn update_contact_history(mut query: Query<(&GridPosition2D, &mut ContactHistory /// Process contact tracing when someone becomes infectious fn process_contact_tracing( mut commands: Commands, - spatial_grid: Res, + spatial_grids: Query<&SpatialGrid, With>, query_newly_infectious: Query< (Entity, &GridPosition2D, &ContactHistory), (With, Without), @@ -559,6 +575,12 @@ fn process_contact_tracing( query_potential_contacts: Query, Without)>, ) { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; + if !CONTACT_TRACING_ENABLED { return; diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 7ed6e5e..a7db798 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -6,7 +6,7 @@ pub use time_series::{TimeSeries, TimeSeriesPlugin}; mod spatial_grid; pub use spatial_grid::{ - GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, GridPosition2D, - GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridPlugin, - SpatialGridPlugin2D, SpatialGridPlugin3D, + DefaultSpatialComponent, GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, + GridPosition2D, GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridEntity, + SpatialGridPlugin, SpatialGridPlugin2D, SpatialGridPlugin3D, }; diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 333eee9..4152332 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -6,6 +6,14 @@ use bevy::{ use crate::plugins::step_counter::StepCounter; +/// Default marker component for spatial grids that don't specify a component filter. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DefaultSpatialComponent; + +/// Marker component to identify spatial grid entities. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct SpatialGridEntity; + // Direction constants for 2D grid movement const NORTH: IVec2 = IVec2::new(0, -1); const SOUTH: IVec2 = IVec2::new(0, 1); @@ -299,10 +307,10 @@ impl GridPosition } } -/// Resource that maintains a spatial index for efficient neighbor queries. -/// Generic over coordinate types that implement the `GridCoordinate` trait. -#[derive(Resource)] -pub struct SpatialGrid +/// Component that maintains a spatial index for efficient neighbor queries. +/// Generic over coordinate types that implement the `GridCoordinate` trait and component types. +#[derive(Component)] +pub struct SpatialGrid { /// Maps grid positions to entities at those positions. position_to_entities: HashMap, HashSet>, @@ -310,6 +318,8 @@ pub struct SpatialGrid entity_to_position: EntityHashMap>, /// Grid bounds for validation and iteration. bounds: Option>, + /// Phantom data to maintain type association with component C. + _phantom: std::marker::PhantomData, } /// Grid bounds representing the valid area for grid positions. @@ -508,7 +518,7 @@ impl From> for IRect } } -impl SpatialGrid +impl SpatialGrid { #[must_use] pub fn new(bounds: Option>) -> Self @@ -517,6 +527,7 @@ impl SpatialGrid position_to_entities: HashMap::default(), entity_to_position: EntityHashMap::default(), bounds, + _phantom: std::marker::PhantomData, } } @@ -647,14 +658,14 @@ impl SpatialGrid } /// Plugin that maintains a spatial index for entities with `GridPosition` components. -/// Generic over coordinate types that implement the `GridCoordinate` trait. -pub struct SpatialGridPlugin +/// Generic over coordinate types that implement the `GridCoordinate` trait and component types. +pub struct SpatialGridPlugin { bounds: Option>, - _phantom: std::marker::PhantomData, + _phantom: std::marker::PhantomData<(T, C)>, } -impl SpatialGridPlugin +impl SpatialGridPlugin { pub const fn new(bounds: Option>) -> Self { @@ -666,12 +677,13 @@ impl SpatialGridPlugin pub fn init(app: &mut App, bounds: Option>) { - let spatial_grid = SpatialGrid::new(bounds); - app.insert_resource(spatial_grid); + // Spawn the spatial grid entity directly + let spatial_grid = SpatialGrid::::new(bounds); + app.world_mut().spawn((spatial_grid, SpatialGridEntity)); } } -impl Plugin for SpatialGridPlugin +impl Plugin for SpatialGridPlugin { fn build(&self, app: &mut App) { @@ -681,9 +693,9 @@ impl Plugin for SpatialGridPlugin app.add_systems( PreUpdate, ( - spatial_grid_reset_system::, - spatial_grid_update_system::, - spatial_grid_cleanup_system::, + spatial_grid_reset_system::, + spatial_grid_update_system::, + spatial_grid_cleanup_system::, ) .chain(), ); @@ -691,8 +703,8 @@ impl Plugin for SpatialGridPlugin } /// System that resets the spatial grid at the beginning of each simulation. -pub fn spatial_grid_reset_system( - mut spatial_grid: ResMut>, +pub fn spatial_grid_reset_system( + mut spatial_grids: Query<&mut SpatialGrid, With>, step_counter: Res, ) { @@ -700,44 +712,53 @@ pub fn spatial_grid_reset_system( // This should occur on the first step of every simulation if **step_counter == 0 { - spatial_grid.clear(); + for mut spatial_grid in &mut spatial_grids + { + spatial_grid.clear(); + } } } /// Query for entities with `GridPosition` components that have been added or changed. -type GridPositionQuery<'world, 'state, T> = - Query<'world, 'state, (Entity, &'static GridPosition), Changed>>; +type GridPositionQuery<'world, 'state, T, C> = + Query<'world, 'state, (Entity, &'static GridPosition), (Changed>, With)>; /// System that updates the spatial grid when entities with `GridPosition` are added or moved. -pub fn spatial_grid_update_system( - mut spatial_grid: ResMut>, - query: GridPositionQuery, +pub fn spatial_grid_update_system( + mut spatial_grids: Query<&mut SpatialGrid, With>, + query: GridPositionQuery, ) { - for (entity, position) in &query + if let Ok(mut spatial_grid) = spatial_grids.single_mut() { - spatial_grid.insert(entity, *position); + for (entity, position) in &query + { + spatial_grid.insert(entity, *position); + } } } /// System that removes entities from the spatial grid when they no longer have `GridPosition`. -pub fn spatial_grid_cleanup_system( - mut spatial_grid: ResMut>, +pub fn spatial_grid_cleanup_system( + mut spatial_grids: Query<&mut SpatialGrid, With>, mut removed: RemovedComponents>, ) { - for entity in removed.read() + if let Ok(mut spatial_grid) = spatial_grids.single_mut() { - spatial_grid.remove(entity); + for entity in removed.read() + { + spatial_grid.remove(entity); + } } } // Type aliases for convenience /// 2D spatial grid using `IVec2` coordinates. -pub type SpatialGrid2D = SpatialGrid; +pub type SpatialGrid2D = SpatialGrid; /// 3D spatial grid using `IVec3` coordinates. -pub type SpatialGrid3D = SpatialGrid; +pub type SpatialGrid3D = SpatialGrid; /// 2D grid position using `IVec2` coordinates. pub type GridPosition2D = GridPosition; @@ -752,7 +773,7 @@ pub type GridBounds2D = GridBounds; pub type GridBounds3D = GridBounds; /// 2D spatial grid plugin. -pub type SpatialGridPlugin2D = SpatialGridPlugin; +pub type SpatialGridPlugin2D = SpatialGridPlugin; /// 3D spatial grid plugin. -pub type SpatialGridPlugin3D = SpatialGridPlugin; +pub type SpatialGridPlugin3D = SpatialGridPlugin; diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index 897f001..b725354 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -79,35 +79,39 @@ impl SimulationBuilder self } - /// Add spatial grid support to the simulation. + /// Add a spatial grid for a specific component type to the simulation. /// - /// This enables efficient spatial queries for entities with grid position components. - /// The spatial grid provides O(1) neighbor lookups and distance-based entity searches. - /// Works with both 2D and 3D coordinate systems. /// - /// # Example - /// ```rust - /// use incerto::prelude::*; - /// use incerto::plugins::GridBounds3D; + /// This creates a spatial index for entities that have both `GridPosition` and the specified component `C`. + /// Multiple spatial grids can coexist for different component types. + /// The spatial grid will be spawned as an entity during simulation startup. /// - /// // 2D spatial grid - /// let bounds_2d = GridBounds2D::new_2d(0, 99, 0, 99); - /// let simulation_2d = SimulationBuilder::new() - /// .add_spatial_grid(bounds_2d) - /// .build(); - /// - /// // 3D spatial grid - /// let bounds_3d = GridBounds3D::new_3d(0, 99, 0, 99, 0, 99); - /// let simulation_3d = SimulationBuilder::new() - /// .add_spatial_grid(bounds_3d) + /// Example: + /// ``` + /// # use bevy::prelude::IVec2; + /// # use incerto::prelude::*; + /// # use incerto::plugins::{GridBounds2D}; + /// #[derive(Component)] + /// struct Person; + /// + /// #[derive(Component)] + /// struct Vehicle; + /// + /// let bounds = GridBounds2D::new_2d(0, 99, 0, 99); + /// let simulation = SimulationBuilder::new() + /// .add_spatial_grid::(bounds) + /// .add_spatial_grid::(bounds) /// .build(); /// ``` #[must_use] - pub fn add_spatial_grid(mut self, bounds: GridBounds) -> Self + pub fn add_spatial_grid( + mut self, + bounds: GridBounds, + ) -> Self { self.sim .app - .add_plugins(SpatialGridPlugin::::new(Some(bounds))); + .add_plugins(SpatialGridPlugin::::new(Some(bounds))); self } diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 0c947fd..030d72a 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -2,8 +2,11 @@ #![allow(clippy::uninlined_format_args)] #![allow(clippy::cast_possible_truncation)] +use bevy::prelude::{IVec2, IVec3}; use incerto::{ - plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, + plugins::{ + GridBounds2D, GridBounds3D, GridPosition2D, GridPosition3D, SpatialGrid, SpatialGridEntity, + }, prelude::*, }; @@ -224,7 +227,7 @@ fn test_3d_spatial_grid_integration() let bounds = GridBounds3D::new_3d(0, 4, 0, 4, 0, 4); let builder = SimulationBuilder::new() - .add_spatial_grid(bounds) + .add_spatial_grid::(bounds) .add_entity_spawner(|spawner| { // Spawn entities at different 3D positions spawner.spawn((GridPosition3D::new_3d(0, 0, 0), TestEntity3D(1))); @@ -233,8 +236,13 @@ fn test_3d_spatial_grid_integration() spawner.spawn((GridPosition3D::new_3d(1, 2, 3), TestEntity3D(4))); }) .add_systems( - |spatial_grid: Res, + |spatial_grids: Query<&SpatialGrid, With>, query: Query<(Entity, &GridPosition3D, &TestEntity3D)>| { + let Ok(spatial_grid) = spatial_grids.single() + else + { + return; // Skip if spatial grid not found + }; // Test 3D spatial queries let center_pos = GridPosition3D::new_3d(2, 2, 2); @@ -286,3 +294,46 @@ fn test_3d_spatial_grid_integration() let entity_count = simulation.sample::().unwrap(); assert_eq!(entity_count, 4); } + +#[test] +fn test_spatial_grid_reset_functionality() +{ + #[derive(Component)] + #[allow(dead_code)] + struct TestResetEntity(i32); + + impl Sample for TestResetEntity + { + fn sample(components: &[&Self]) -> usize + { + components.len() + } + } + + let bounds = GridBounds2D::new_2d(0, 4, 0, 4); + + let mut simulation = SimulationBuilder::new() + .add_spatial_grid::(bounds) + .add_entity_spawner(|spawner| { + // Spawn entities at different positions + spawner.spawn((GridPosition2D::new_2d(0, 0), TestResetEntity(1))); + spawner.spawn((GridPosition2D::new_2d(2, 2), TestResetEntity(2))); + spawner.spawn((GridPosition2D::new_2d(4, 4), TestResetEntity(3))); + }) + .build(); + + // Run first simulation + simulation.run(2); + + // Verify entities are tracked + let entity_count = simulation.sample::().unwrap(); + assert_eq!(entity_count, 3); + + // Reset simulation (this should trigger spatial grid reset on step 0) + simulation.reset(); + simulation.run(1); + + // Verify entities are still tracked after reset + let entity_count_after_reset = simulation.sample::().unwrap(); + assert_eq!(entity_count_after_reset, 3); +} From 8b3f8ed10f484f10f7a08d92d063acf180ce8a06 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 21:42:36 -0600 Subject: [PATCH 12/23] generics spatial grid cleanup --- src/plugins/mod.rs | 4 ++-- src/plugins/spatial_grid.rs | 16 ++++++---------- src/prelude.rs | 2 +- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index a7db798..7578b61 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -6,7 +6,7 @@ pub use time_series::{TimeSeries, TimeSeriesPlugin}; mod spatial_grid; pub use spatial_grid::{ - DefaultSpatialComponent, GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, - GridPosition2D, GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridEntity, + GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, GridPosition2D, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridEntity, SpatialGridPlugin, SpatialGridPlugin2D, SpatialGridPlugin3D, }; diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 4152332..dc13b9d 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -6,10 +6,6 @@ use bevy::{ use crate::plugins::step_counter::StepCounter; -/// Default marker component for spatial grids that don't specify a component filter. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct DefaultSpatialComponent; - /// Marker component to identify spatial grid entities. #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct SpatialGridEntity; @@ -310,7 +306,7 @@ impl GridPosition /// Component that maintains a spatial index for efficient neighbor queries. /// Generic over coordinate types that implement the `GridCoordinate` trait and component types. #[derive(Component)] -pub struct SpatialGrid +pub struct SpatialGrid { /// Maps grid positions to entities at those positions. position_to_entities: HashMap, HashSet>, @@ -659,7 +655,7 @@ impl SpatialGrid /// Plugin that maintains a spatial index for entities with `GridPosition` components. /// Generic over coordinate types that implement the `GridCoordinate` trait and component types. -pub struct SpatialGridPlugin +pub struct SpatialGridPlugin { bounds: Option>, _phantom: std::marker::PhantomData<(T, C)>, @@ -755,10 +751,10 @@ pub fn spatial_grid_cleanup_system( // Type aliases for convenience /// 2D spatial grid using `IVec2` coordinates. -pub type SpatialGrid2D = SpatialGrid; +pub type SpatialGrid2D = SpatialGrid; /// 3D spatial grid using `IVec3` coordinates. -pub type SpatialGrid3D = SpatialGrid; +pub type SpatialGrid3D = SpatialGrid; /// 2D grid position using `IVec2` coordinates. pub type GridPosition2D = GridPosition; @@ -773,7 +769,7 @@ pub type GridBounds2D = GridBounds; pub type GridBounds3D = GridBounds; /// 2D spatial grid plugin. -pub type SpatialGridPlugin2D = SpatialGridPlugin; +pub type SpatialGridPlugin2D = SpatialGridPlugin; /// 3D spatial grid plugin. -pub type SpatialGridPlugin3D = SpatialGridPlugin; +pub type SpatialGridPlugin3D = SpatialGridPlugin; diff --git a/src/prelude.rs b/src/prelude.rs index b3f9ad7..8401918 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -5,7 +5,7 @@ pub use bevy::prelude::{ pub use super::{ error::*, - plugins::{GridBounds2D, GridPosition2D, SpatialGrid2D}, + plugins::{GridBounds2D, GridPosition2D}, simulation::Simulation, simulation_builder::SimulationBuilder, spawner::Spawner, From d93255a5642bd5b984e17cbfdaa27fb30ad63600 Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 22:12:22 -0600 Subject: [PATCH 13/23] spatial grid reset restore entity grid --- src/plugins/spatial_grid.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index dc13b9d..44a1945 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -702,15 +702,27 @@ impl Plugin for SpatialGridPlugin pub fn spatial_grid_reset_system( mut spatial_grids: Query<&mut SpatialGrid, With>, step_counter: Res, + mut commands: Commands, ) { // Reset the spatial grid whenever the step counter is 0 // This should occur on the first step of every simulation if **step_counter == 0 { - for mut spatial_grid in &mut spatial_grids + if spatial_grids.is_empty() { - spatial_grid.clear(); + // If no spatial grid entity exists, create one + // This handles the case where reset() cleared all entities + // TODO: Feels hacky + let spatial_grid = SpatialGrid::::new(None); + commands.spawn((spatial_grid, SpatialGridEntity)); + } + else + { + for mut spatial_grid in &mut spatial_grids + { + spatial_grid.clear(); + } } } } From e037accc4bc48cdfb3ec3868240c238791af39ae Mon Sep 17 00:00:00 2001 From: rozgo Date: Wed, 2 Jul 2025 22:20:30 -0600 Subject: [PATCH 14/23] multi grid example --- examples/ecosystem_multi_grid.rs | 652 +++++++++++++++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 examples/ecosystem_multi_grid.rs diff --git a/examples/ecosystem_multi_grid.rs b/examples/ecosystem_multi_grid.rs new file mode 100644 index 0000000..a80baa2 --- /dev/null +++ b/examples/ecosystem_multi_grid.rs @@ -0,0 +1,652 @@ +//! # Multi-Grid Ecosystem Simulation +//! +//! This example demonstrates the power of multiple spatial grids by simulating +//! a complex ecosystem with three different entity types, each with their own +//! spatial grid for optimized interactions: +//! +//! * **Prey (Rabbits)** - Use `SpatialGrid` for flocking behavior +//! * **Predators (Wolves)** - Use `SpatialGrid` for pack hunting +//! * **Vegetation (Grass)** - Use `SpatialGrid` for growth patterns +//! +//! ## Key Features: +//! * **Multi-scale interactions** - Different interaction ranges for different behaviors +//! * **Cross-grid queries** - Predators hunt prey, prey avoid predators, all consume vegetation +//! * **Emergent patterns** - Herds, pack formation, resource patches emerge naturally +//! * **Performance optimization** - Each entity type has its own spatial index + +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::cast_precision_loss)] +#![allow(clippy::too_many_lines)] + +use std::collections::HashMap; + +use bevy::prelude::{IVec2, ParamSet}; +use incerto::{ + plugins::{GridBounds2D, GridPosition2D, SpatialGrid, SpatialGridEntity}, + prelude::*, +}; +use rand::prelude::*; + +// Simulation parameters +const SIMULATION_STEPS: usize = 200; +const WORLD_SIZE: i32 = 30; +const INITIAL_PREY: usize = 50; +const INITIAL_PREDATORS: usize = 8; +const INITIAL_VEGETATION: usize = 120; + +/// Prey animals (rabbits) that flock together and graze +#[derive(Component, Debug)] +pub struct Prey +{ + pub energy: u32, + pub age: u32, + pub fear_level: f32, // Affects movement when predators are near +} + +/// Predator animals (wolves) that hunt in packs +#[derive(Component, Debug)] +pub struct Predator +{ + pub energy: u32, + pub age: u32, + pub hunt_cooldown: u32, // Ticks until can hunt again +} + +/// Vegetation (grass) that grows and gets consumed +#[derive(Component, Debug)] +pub struct Vegetation +{ + pub growth_level: u32, // 0-100, higher = more nutritious + pub regrowth_timer: u32, // Ticks until next growth +} + +/// Bundle for spawning prey entities +#[derive(Bundle)] +pub struct PreyBundle +{ + pub position: GridPosition2D, + pub prey: Prey, +} + +/// Bundle for spawning predator entities +#[derive(Bundle)] +pub struct PredatorBundle +{ + pub position: GridPosition2D, + pub predator: Predator, +} + +/// Bundle for spawning vegetation entities +#[derive(Bundle)] +pub struct VegetationBundle +{ + pub position: GridPosition2D, + pub vegetation: Vegetation, +} + +// Sample implementations for statistics +impl Sample for Prey +{ + fn sample(components: &[&Self]) -> usize + { + components.len() + } +} + +impl Sample for Predator +{ + fn sample(components: &[&Self]) -> usize + { + components.len() + } +} + +impl Sample for Vegetation +{ + fn sample(components: &[&Self]) -> usize + { + components.len() + } +} + +/// Spawn initial ecosystem entities +fn spawn_ecosystem(spawner: &mut Spawner) +{ + let mut rng = rand::rng(); + + // Spawn prey (rabbits) in small groups + for _ in 0..INITIAL_PREY + { + let x = rng.random_range(0..WORLD_SIZE); + let y = rng.random_range(0..WORLD_SIZE); + + spawner.spawn(PreyBundle { + position: GridPosition2D::new_2d(x, y), + prey: Prey { + energy: rng.random_range(50..100), + age: rng.random_range(0..50), + fear_level: 0.0, + }, + }); + } + + // Spawn predators (wolves) scattered + for _ in 0..INITIAL_PREDATORS + { + let x = rng.random_range(0..WORLD_SIZE); + let y = rng.random_range(0..WORLD_SIZE); + + spawner.spawn(PredatorBundle { + position: GridPosition2D::new_2d(x, y), + predator: Predator { + energy: rng.random_range(80..120), + age: rng.random_range(0..30), + hunt_cooldown: 0, + }, + }); + } + + // Spawn vegetation (grass) patches + for _ in 0..INITIAL_VEGETATION + { + let x = rng.random_range(0..WORLD_SIZE); + let y = rng.random_range(0..WORLD_SIZE); + + spawner.spawn(VegetationBundle { + position: GridPosition2D::new_2d(x, y), + vegetation: Vegetation { + growth_level: rng.random_range(20..80), + regrowth_timer: 0, + }, + }); + } +} + +/// Prey flocking and movement system - uses only prey spatial grid +fn prey_behavior( + prey_grid: Query<&SpatialGrid, With>, + predator_grid: Query<&SpatialGrid, With>, + mut prey_query: Query<(&mut GridPosition2D, &mut Prey)>, +) +{ + let Ok(prey_spatial) = prey_grid.single() + else + { + return; + }; + let Ok(predator_spatial) = predator_grid.single() + else + { + return; + }; + + let mut rng = rand::rng(); + + for (mut position, mut prey) in &mut prey_query + { + let current_pos = *position; + + // Reset fear + prey.fear_level *= 0.9; // Decay fear over time + + // Check for nearby predators (CROSS-GRID INTERACTION!) + let nearby_predators: Vec<_> = predator_spatial.neighbors_of(¤t_pos).collect(); + if !nearby_predators.is_empty() + { + prey.fear_level = (prey.fear_level + 50.0).min(100.0); + } + + // Flocking behavior - stay near other prey + let nearby_prey: Vec<_> = prey_spatial.neighbors_of(¤t_pos).collect(); + + let mut target_x = current_pos.x() as f32; + let mut target_y = current_pos.y() as f32; + + // If afraid, move away from predators + if prey.fear_level > 10.0 && !nearby_predators.is_empty() + { + target_x += rng.random_range(-2.0..2.0); + target_y += rng.random_range(-2.0..2.0); + } + else if nearby_prey.len() < 2 + { + // If isolated, move randomly to find group + target_x += rng.random_range(-1.0..1.0); + target_y += rng.random_range(-1.0..1.0); + } + else if nearby_prey.len() > 5 + { + // If overcrowded, spread out slightly + target_x += rng.random_range(-0.5..0.5); + target_y += rng.random_range(-0.5..0.5); + } + + // Clamp to world bounds + let new_x = (target_x as i32).clamp(0, WORLD_SIZE - 1); + let new_y = (target_y as i32).clamp(0, WORLD_SIZE - 1); + + // Update position directly to trigger Changed + if new_x != current_pos.x() || new_y != current_pos.y() + { + *position = GridPosition2D::new_2d(new_x, new_y); + } + + // Consume energy + prey.energy = prey.energy.saturating_sub(1); + prey.age += 1; + } +} + +/// Predator hunting system - uses both predator and prey spatial grids +fn predator_hunting( + predator_grid: Query<&SpatialGrid, With>, + prey_grid: Query<&SpatialGrid, With>, + mut query_set: ParamSet<( + Query<(&mut GridPosition2D, &mut Predator)>, + Query<(Entity, &GridPosition2D), With>, + )>, + mut commands: Commands, +) +{ + let Ok(predator_spatial) = predator_grid.single() + else + { + return; + }; + let Ok(prey_spatial) = prey_grid.single() + else + { + return; + }; + + let mut rng = rand::rng(); + let mut hunts = Vec::new(); + + for (mut position, mut predator) in &mut query_set.p0() + { + let current_pos = *position; + + // Reduce hunt cooldown + predator.hunt_cooldown = predator.hunt_cooldown.saturating_sub(1); + + // Look for prey in neighboring cells (CROSS-GRID INTERACTION!) + let nearby_prey: Vec<_> = prey_spatial.neighbors_of(¤t_pos).collect(); + + let mut target_x = current_pos.x() as f32; + let mut target_y = current_pos.y() as f32; + + if !nearby_prey.is_empty() && predator.hunt_cooldown == 0 && predator.energy > 30 + { + // Hunt nearby prey + if let Some(prey_entity) = nearby_prey.choose(&mut rng) + { + hunts.push(*prey_entity); + predator.energy += 40; // Gain energy from successful hunt + predator.hunt_cooldown = 10; // Cooldown before next hunt + } + } + else + { + // Move toward areas with more prey + let mut best_prey_count = 0; + let mut best_direction = (0.0, 0.0); + + // Check surrounding areas for prey density + for dx in -2..=2 + { + for dy in -2..=2 + { + if dx == 0 && dy == 0 + { + continue; + } + + let check_x = (current_pos.x() + dx).clamp(0, WORLD_SIZE - 1); + let check_y = (current_pos.y() + dy).clamp(0, WORLD_SIZE - 1); + let check_pos = GridPosition2D::new_2d(check_x, check_y); + + let prey_count = prey_spatial.entities_at(&check_pos).count(); + if prey_count > best_prey_count + { + best_prey_count = prey_count; + best_direction = (dx as f32, dy as f32); + } + } + } + + if best_prey_count > 0 + { + target_x += best_direction.0 * 0.5; + target_y += best_direction.1 * 0.5; + } + else + { + // Random movement when no prey detected + target_x += rng.random_range(-1.0..1.0); + target_y += rng.random_range(-1.0..1.0); + } + } + + // Avoid other predators (pack behavior - maintain some distance) + let nearby_predators: Vec<_> = predator_spatial.neighbors_of(¤t_pos).collect(); + if nearby_predators.len() > 2 + { + target_x += rng.random_range(-0.5..0.5); + target_y += rng.random_range(-0.5..0.5); + } + + // Clamp to world bounds + let new_x = (target_x as i32).clamp(0, WORLD_SIZE - 1); + let new_y = (target_y as i32).clamp(0, WORLD_SIZE - 1); + + // Update position directly to trigger Changed + if new_x != current_pos.x() || new_y != current_pos.y() + { + *position = GridPosition2D::new_2d(new_x, new_y); + } + + // Consume energy (hunting is expensive) + predator.energy = predator.energy.saturating_sub(2); + predator.age += 1; + } + + // Execute hunts (remove caught prey) + for prey_entity in hunts + { + commands.entity(prey_entity).despawn(); + } +} + +/// Vegetation growth and grazing system - uses vegetation spatial grid +fn vegetation_dynamics( + vegetation_grid: Query<&SpatialGrid, With>, + prey_grid: Query<&SpatialGrid, With>, + mut vegetation_query: Query<(Entity, &GridPosition2D, &mut Vegetation)>, + mut commands: Commands, +) +{ + let Ok(vegetation_spatial) = vegetation_grid.single() + else + { + return; + }; + let Ok(prey_spatial) = prey_grid.single() + else + { + return; + }; + + let mut rng = rand::rng(); + let mut consumed_vegetation = Vec::new(); + + // Vegetation growth and consumption + for (veg_entity, position, mut vegetation) in &mut vegetation_query + { + // Check for grazing prey (CROSS-GRID INTERACTION!) + let grazing_prey: Vec<_> = prey_spatial.entities_at(position).collect(); + + if !grazing_prey.is_empty() && vegetation.growth_level > 10 + { + // Vegetation gets consumed + let consumption = rng.random_range(10..30).min(vegetation.growth_level); + vegetation.growth_level = vegetation.growth_level.saturating_sub(consumption); + + // Note: Prey feeding happens in a separate system to avoid query conflicts + + if vegetation.growth_level == 0 + { + consumed_vegetation.push(veg_entity); + } + } + else + { + // Natural growth + vegetation.regrowth_timer = vegetation.regrowth_timer.saturating_sub(1); + if vegetation.regrowth_timer == 0 + { + vegetation.growth_level = (vegetation.growth_level + 1).min(100); + vegetation.regrowth_timer = rng.random_range(5..15); + } + + // Spread to nearby empty areas occasionally + if vegetation.growth_level > 50 && rng.random_bool(0.05) + { + let spread_x = position.x() + rng.random_range(-1..=1); + let spread_y = position.y() + rng.random_range(-1..=1); + + if spread_x >= 0 && spread_x < WORLD_SIZE && spread_y >= 0 && spread_y < WORLD_SIZE + { + let spread_pos = GridPosition2D::new_2d(spread_x, spread_y); + + // Check if area is empty of vegetation + let existing_veg = vegetation_spatial.entities_at(&spread_pos).count(); + if existing_veg == 0 + { + commands.spawn(VegetationBundle { + position: spread_pos, + vegetation: Vegetation { + growth_level: 30, + regrowth_timer: rng.random_range(10..20), + }, + }); + } + } + } + } + } + + // Remove fully consumed vegetation + for entity in consumed_vegetation + { + commands.entity(entity).despawn(); + } +} + +/// Prey feeding system - separate from vegetation to avoid query conflicts +fn prey_feeding( + vegetation_grid: Query<&SpatialGrid, With>, + mut prey_query: Query<(&GridPosition2D, &mut Prey)>, + vegetation_query: Query<&Vegetation>, +) +{ + let Ok(vegetation_spatial) = vegetation_grid.single() + else + { + return; + }; + + for (prey_pos, mut prey) in &mut prey_query + { + // Check for vegetation at prey location and feed from the first available + for veg_entity in vegetation_spatial.entities_at(prey_pos) + { + if let Ok(vegetation) = vegetation_query.get(veg_entity) + { + if vegetation.growth_level > 10 + { + let nutrition = (vegetation.growth_level / 4).min(20); + prey.energy = (prey.energy + nutrition).min(100); + break; + } + } + } + } +} + +/// Natural lifecycle - entities die of old age or starvation +fn lifecycle_system( + prey_query: Query<(Entity, &Prey)>, + predator_query: Query<(Entity, &Predator)>, + mut commands: Commands, +) +{ + let mut rng = rand::rng(); + + // Prey lifecycle + for (entity, prey) in &prey_query + { + if prey.energy == 0 || (prey.age > 100 && rng.random_bool(0.1)) + { + commands.entity(entity).despawn(); + } + } + + // Predator lifecycle + for (entity, predator) in &predator_query + { + if predator.energy == 0 || (predator.age > 150 && rng.random_bool(0.05)) + { + commands.entity(entity).despawn(); + } + } +} + +/// Display ecosystem statistics and spatial patterns +fn display_ecosystem_stats( + prey_query: Query<(&GridPosition2D, &Prey)>, + predator_query: Query<(&GridPosition2D, &Predator)>, + vegetation_query: Query<(&GridPosition2D, &Vegetation)>, +) +{ + let prey_count = prey_query.iter().count(); + let predator_count = predator_query.iter().count(); + let vegetation_count = vegetation_query.iter().count(); + + let avg_prey_energy: f32 = if prey_count > 0 + { + prey_query.iter().map(|(_, p)| p.energy as f32).sum::() / prey_count as f32 + } + else + { + 0.0 + }; + + let avg_predator_energy: f32 = if predator_count > 0 + { + predator_query + .iter() + .map(|(_, p)| p.energy as f32) + .sum::() + / predator_count as f32 + } + else + { + 0.0 + }; + + let avg_vegetation_growth: f32 = if vegetation_count > 0 + { + vegetation_query + .iter() + .map(|(_, v)| v.growth_level as f32) + .sum::() + / vegetation_count as f32 + } + else + { + 0.0 + }; + + // Analyze spatial distribution + let mut prey_density = HashMap::new(); + for (pos, _) in &prey_query + { + let region = (pos.x() / 5, pos.y() / 5); // 5x5 regions + *prey_density.entry(region).or_insert(0) += 1; + } + + let max_prey_density = prey_density.values().max().copied().unwrap_or(0); + + println!("\nšŸŒ Ecosystem Status:"); + println!( + " 🐰 Prey: {} (avg energy: {:.1})", + prey_count, avg_prey_energy + ); + println!( + " 🐺 Predators: {} (avg energy: {:.1})", + predator_count, avg_predator_energy + ); + println!( + " 🌱 Vegetation: {} (avg growth: {:.1}%)", + vegetation_count, avg_vegetation_growth + ); + println!(" šŸ“Š Max prey density in region: {}", max_prey_density); + + if prey_count == 0 + { + println!(" āš ļø All prey extinct!"); + } + if predator_count == 0 + { + println!(" āš ļø All predators extinct!"); + } +} + +fn main() +{ + println!("šŸŒ Multi-Grid Ecosystem Simulation"); + println!("Demonstrating multiple spatial grids working together:"); + println!(" • Prey Grid: Flocking and grazing behavior"); + println!(" • Predator Grid: Pack hunting coordination"); + println!(" • Vegetation Grid: Growth and resource management"); + println!("World size: {}x{}", WORLD_SIZE, WORLD_SIZE); + println!("Duration: {} steps\n", SIMULATION_STEPS); + + let bounds = GridBounds2D::new_2d(0, WORLD_SIZE - 1, 0, WORLD_SIZE - 1); + + let mut simulation = SimulationBuilder::new() + // Create separate spatial grids for each entity type + .add_spatial_grid::(bounds) + .add_spatial_grid::(bounds) + .add_spatial_grid::(bounds) + .add_entity_spawner(spawn_ecosystem) + .add_systems(( + prey_behavior, + predator_hunting.after(prey_behavior), + vegetation_dynamics.after(predator_hunting), + prey_feeding.after(vegetation_dynamics), + lifecycle_system.after(prey_feeding), + display_ecosystem_stats.after(lifecycle_system), + )) + .build(); + + // Run ecosystem simulation + for step in 1..=SIMULATION_STEPS + { + println!("šŸ• Step {}/{}", step, SIMULATION_STEPS); + simulation.run(1); + + if step % 25 == 0 + { + let prey_count = simulation.sample::().unwrap(); + let predator_count = simulation.sample::().unwrap(); + let vegetation_count = simulation.sample::().unwrap(); + + println!( + " šŸ“ˆ Population trends: 🐰{} 🐺{} 🌱{}", + prey_count, predator_count, vegetation_count + ); + + if prey_count == 0 && predator_count == 0 + { + println!("\nšŸ’€ Ecosystem collapse! All animals extinct."); + break; + } + } + + // Add delay for readability + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + println!("\nāœ… Ecosystem simulation completed!"); + + let final_prey = simulation.sample::().unwrap(); + let final_predators = simulation.sample::().unwrap(); + let final_vegetation = simulation.sample::().unwrap(); + + println!("šŸ“Š Final populations:"); + println!(" 🐰 Prey: {}", final_prey); + println!(" 🐺 Predators: {}", final_predators); + println!(" 🌱 Vegetation: {}", final_vegetation); +} From fdbaf83ca534a7058b18cdb63fd6bdc458fae0ff Mon Sep 17 00:00:00 2001 From: haath Date: Fri, 4 Jul 2025 17:50:21 +0200 Subject: [PATCH 15/23] Refactor spatial grid plugin --- src/plugins/mod.rs | 5 +- src/plugins/spatial_grid.rs | 544 +++++++----------------------------- src/simulation_builder.rs | 4 +- tests/test_spatial_grid.rs | 68 ++--- 4 files changed, 135 insertions(+), 486 deletions(-) diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 7578b61..74421dd 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -6,7 +6,6 @@ pub use time_series::{TimeSeries, TimeSeriesPlugin}; mod spatial_grid; pub use spatial_grid::{ - GridBounds, GridBounds2D, GridBounds3D, GridCoordinate, GridPosition, GridPosition2D, - GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridEntity, - SpatialGridPlugin, SpatialGridPlugin2D, SpatialGridPlugin3D, + GridBounds, GridBounds2D, GridBounds3D, GridCoordinates, GridPosition, GridPosition2D, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, SpatialGridPlugin, }; diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 44a1945..71e78f8 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -1,3 +1,5 @@ +use std::hash::Hash; + use bevy::{ ecs::entity::EntityHashMap, platform::collections::{HashMap, HashSet}, @@ -6,10 +8,6 @@ use bevy::{ use crate::plugins::step_counter::StepCounter; -/// Marker component to identify spatial grid entities. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct SpatialGridEntity; - // Direction constants for 2D grid movement const NORTH: IVec2 = IVec2::new(0, -1); const SOUTH: IVec2 = IVec2::new(0, 1); @@ -28,167 +26,54 @@ const SOUTH_3D: IVec3 = IVec3::new(0, 1, 0); const EAST_3D: IVec3 = IVec3::new(1, 0, 0); const WEST_3D: IVec3 = IVec3::new(-1, 0, 0); -/// Sealed trait for grid coordinate types that can be used with the spatial grid system. -/// -/// This trait abstracts over 2D and 3D coordinate types, allowing the same spatial grid -/// implementation to work with both `IVec2` and `IVec3` coordinates. -pub trait GridCoordinate: - Copy - + Clone - + PartialEq - + Eq - + std::hash::Hash - + std::fmt::Debug - + Send - + Sync - + 'static - + private::Sealed +/// A sealed trait for coordinates on a grid. +/// Will be implemented for [`IVec2`] and [`IVec3`]. +pub trait GridCoordinates: + private::Sealed + Clone + Copy + Hash + PartialEq + Eq + Send + Sync + 'static { - /// The bounds type for this coordinate system (e.g., `IRect` for 2D, custom bounds for 3D). - type Bounds: Copy + Clone + PartialEq + Eq + std::fmt::Debug + Send + Sync + 'static; - - /// Create a new coordinate from individual components. - /// For 2D: new(x, y), for 3D: new(x, y, z) - fn new(x: i32, y: i32, z: i32) -> Self; - - /// Get the x coordinate. - fn x(self) -> i32; - - /// Get the y coordinate. - fn y(self) -> i32; - - /// Get the z coordinate (returns 0 for 2D coordinates). - fn z(self) -> i32; - - /// Calculate Manhattan distance between two coordinates. - fn manhattan_distance(self, other: Self) -> u32; + fn neighbors(&self) -> impl Iterator; - /// Get all neighboring coordinates (Moore neighborhood). - fn neighbors(self) -> Box>; + fn neighbors_orthogonal(&self) -> impl Iterator; - /// Get orthogonal neighboring coordinates (Von Neumann neighborhood). - fn neighbors_orthogonal(self) -> Box>; - - /// Create bounds from min/max coordinates. - fn create_bounds(min: Self, max: Self) -> Self::Bounds; - - /// Check if this coordinate is within the given bounds. - fn within_bounds(self, bounds: &Self::Bounds) -> bool; + fn in_bounds(&self, bounds: &GridBounds) -> bool; } -/// Private module to enforce the sealed trait pattern. -mod private -{ - pub trait Sealed {} - impl Sealed for bevy::prelude::IVec2 {} - impl Sealed for bevy::prelude::IVec3 {} -} - -/// 2D bounds type. +/// Describes the bounds of a grid. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Bounds2D(pub IRect); - -/// 3D bounds type. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Bounds3D +pub struct GridBounds { - pub min: IVec3, - pub max: IVec3, + pub min: T, + pub max: T, } -impl GridCoordinate for IVec2 +impl GridCoordinates for IVec2 { - type Bounds = Bounds2D; - - fn new(x: i32, y: i32, _z: i32) -> Self - { - Self::new(x, y) - } - - fn x(self) -> i32 - { - self.x - } - - fn y(self) -> i32 - { - self.y - } - - fn z(self) -> i32 - { - 0 - } - - fn manhattan_distance(self, other: Self) -> u32 - { - #[allow(clippy::cast_sign_loss)] - { - (self - other).abs().element_sum() as u32 - } - } - - fn neighbors(self) -> Box> + fn neighbors(&self) -> impl Iterator { const DIRECTIONS: [IVec2; 8] = [ NORTH_WEST, NORTH, NORTH_EAST, WEST, EAST, SOUTH_WEST, SOUTH, SOUTH_EAST, ]; - Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) + DIRECTIONS.into_iter().map(move |dir| self + dir) } - fn neighbors_orthogonal(self) -> Box> + fn neighbors_orthogonal(&self) -> impl Iterator { const DIRECTIONS: [IVec2; 4] = [NORTH, WEST, EAST, SOUTH]; - Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) + DIRECTIONS.into_iter().map(move |dir| self + dir) } - fn create_bounds(min: Self, max: Self) -> Self::Bounds + fn in_bounds(&self, bounds: &GridBounds) -> bool { - Bounds2D(IRect::new(min.x, min.y, max.x, max.y)) - } - - fn within_bounds(self, bounds: &Self::Bounds) -> bool - { - bounds.0.contains(self) + bounds.contains(self) } } -impl GridCoordinate for IVec3 +impl GridCoordinates for IVec3 { - type Bounds = Bounds3D; - - fn new(x: i32, y: i32, z: i32) -> Self - { - Self::new(x, y, z) - } - - fn x(self) -> i32 - { - self.x - } - - fn y(self) -> i32 - { - self.y - } - - fn z(self) -> i32 - { - self.z - } - - fn manhattan_distance(self, other: Self) -> u32 - { - #[allow(clippy::cast_sign_loss)] - { - (self - other).abs().element_sum() as u32 - } - } - - fn neighbors(self) -> Box> + fn neighbors(&self) -> impl Iterator { // 26 neighbors in 3D (3x3x3 cube minus center) - Box::new((-1..=1).flat_map(move |dx| { + (-1..=1).flat_map(move |dx| { (-1..=1).flat_map(move |dy| { (-1..=1).filter_map(move |dz| { if dx == 0 && dy == 0 && dz == 0 @@ -201,94 +86,45 @@ impl GridCoordinate for IVec3 } }) }) - })) + }) } - fn neighbors_orthogonal(self) -> Box> + fn neighbors_orthogonal(&self) -> impl Iterator { // 6 orthogonal neighbors in 3D const DIRECTIONS: [IVec3; 6] = [WEST_3D, EAST_3D, NORTH_3D, SOUTH_3D, DOWN, UP]; - Box::new(DIRECTIONS.into_iter().map(move |dir| self + dir)) - } - - fn create_bounds(min: Self, max: Self) -> Self::Bounds - { - Bounds3D { min, max } + DIRECTIONS.into_iter().map(move |dir| self + dir) } - fn within_bounds(self, bounds: &Self::Bounds) -> bool + fn in_bounds(&self, bounds: &GridBounds) -> bool { - self.x >= bounds.min.x - && self.x <= bounds.max.x - && self.y >= bounds.min.y - && self.y <= bounds.max.y - && self.z >= bounds.min.z - && self.z <= bounds.max.z + bounds.contains(self) } } /// Component representing a position in the spatial grid. /// Generic over coordinate types that implement the `GridCoordinate` trait. -#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Deref, DerefMut)] -pub struct GridPosition(pub T); +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GridPosition(pub T); -impl GridPosition +// Convenience methods for 2D positions +impl GridPosition { - /// Get the x coordinate. - #[must_use] - pub fn x(&self) -> i32 - { - self.0.x() - } - - /// Get the y coordinate. - #[must_use] - pub fn y(&self) -> i32 - { - self.0.y() - } - - /// Get the z coordinate. + /// Create a new 2D grid position from x, y coordinates. #[must_use] - pub fn z(&self) -> i32 + pub const fn new(x: i32, y: i32) -> Self { - self.0.z() + Self(IVec2::new(x, y)) } - /// Get all neighboring positions (Moore neighborhood). pub fn neighbors(&self) -> impl Iterator { - let neighbors = self.0.neighbors(); - // Convert Box to a concrete type by collecting and iterating - let neighbors_vec: Vec = neighbors.collect(); - neighbors_vec.into_iter().map(Self) + self.0.neighbors().map(Self) } - /// Get orthogonal neighboring positions (Von Neumann neighborhood). pub fn neighbors_orthogonal(&self) -> impl Iterator { - let neighbors = self.0.neighbors_orthogonal(); - // Convert Box to a concrete type by collecting and iterating - let neighbors_vec: Vec = neighbors.collect(); - neighbors_vec.into_iter().map(Self) - } - - /// Calculate Manhattan distance to another position. - #[must_use] - pub fn manhattan_distance(&self, other: &Self) -> u32 - { - self.0.manhattan_distance(other.0) - } -} - -// Convenience methods for 2D positions -impl GridPosition -{ - /// Create a new 2D grid position from x, y coordinates. - #[must_use] - pub const fn new_2d(x: i32, y: i32) -> Self - { - Self(IVec2::new(x, y)) + self.0.neighbors_orthogonal().map(Self) } } @@ -297,16 +133,26 @@ impl GridPosition { /// Create a new 3D grid position from x, y, z coordinates. #[must_use] - pub const fn new_3d(x: i32, y: i32, z: i32) -> Self + pub const fn new(x: i32, y: i32, z: i32) -> Self { Self(IVec3::new(x, y, z)) } + + pub fn neighbors(&self) -> impl Iterator + { + self.0.neighbors().map(Self) + } + + pub fn neighbors_orthogonal(&self) -> impl Iterator + { + self.0.neighbors_orthogonal().map(Self) + } } /// Component that maintains a spatial index for efficient neighbor queries. /// Generic over coordinate types that implement the `GridCoordinate` trait and component types. -#[derive(Component)] -pub struct SpatialGrid +#[derive(Resource)] +pub struct SpatialGrid { /// Maps grid positions to entities at those positions. position_to_entities: HashMap, HashSet>, @@ -318,203 +164,29 @@ pub struct SpatialGrid _phantom: std::marker::PhantomData, } -/// Grid bounds representing the valid area for grid positions. -/// Generic over coordinate types that implement the `GridCoordinate` trait. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GridBounds(pub T::Bounds); - -impl GridBounds -{ - /// Create new bounds from min and max coordinates. - #[must_use] - pub fn new(min: GridPosition, max: GridPosition) -> Self - { - Self(T::create_bounds(min.0, max.0)) - } - - /// Check if a position is within these bounds. - #[must_use] - pub fn contains(&self, pos: &GridPosition) -> bool - { - pos.0.within_bounds(&self.0) - } -} - -// Specific implementations for 2D bounds +/// Specific implementations for 2D bounds impl GridBounds { - /// Create 2D bounds from coordinate values. - #[must_use] - pub fn new_2d(min_x: i32, max_x: i32, min_y: i32, max_y: i32) -> Self - { - Self(Bounds2D(IRect::new(min_x, min_y, max_x, max_y))) - } - - /// Get the width in grid cells (number of columns). - #[must_use] - #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds - pub fn width(&self) -> u32 - { - (self.0.0.width() + 1) as u32 - } - - /// Get the height in grid cells (number of rows). - #[must_use] - #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds - pub fn height(&self) -> u32 - { - (self.0.0.height() + 1) as u32 - } - - /// Get the total number of grid cells. - #[must_use] - #[allow(clippy::missing_const_for_fn)] - pub fn total_cells(&self) -> u32 - { - self.width() * self.height() - } - - /// Get the minimum x coordinate. - #[must_use] - pub const fn min_x(&self) -> i32 - { - self.0.0.min.x - } - - /// Get the maximum x coordinate. - #[must_use] - pub const fn max_x(&self) -> i32 - { - self.0.0.max.x - } - - /// Get the minimum y coordinate. - #[must_use] - pub const fn min_y(&self) -> i32 - { - self.0.0.min.y - } - - /// Get the maximum y coordinate. + /// Check if a position is within these bounds. #[must_use] - pub const fn max_y(&self) -> i32 + pub fn contains(&self, pos: &IVec2) -> bool { - self.0.0.max.y + todo!() } } -// Specific implementations for 3D bounds +/// Specific implementations for 3D bounds impl GridBounds { - /// Create 3D bounds from coordinate values. - #[must_use] - pub const fn new_3d( - min_x: i32, - max_x: i32, - min_y: i32, - max_y: i32, - min_z: i32, - max_z: i32, - ) -> Self - { - Self(Bounds3D { - min: IVec3::new(min_x, min_y, min_z), - max: IVec3::new(max_x, max_y, max_z), - }) - } - - /// Get the width in grid cells (number of columns). - #[must_use] - #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds - pub const fn width(&self) -> u32 - { - (self.0.max.x - self.0.min.x + 1) as u32 - } - - /// Get the height in grid cells (number of rows). - #[must_use] - #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds - pub const fn height(&self) -> u32 - { - (self.0.max.y - self.0.min.y + 1) as u32 - } - - /// Get the depth in grid cells (number of layers). - #[must_use] - #[allow(clippy::cast_sign_loss)] // Safe: assumes well-formed bounds - pub const fn depth(&self) -> u32 - { - (self.0.max.z - self.0.min.z + 1) as u32 - } - - /// Get the total number of grid cells. - #[must_use] - #[allow(clippy::missing_const_for_fn)] - pub fn total_cells(&self) -> u32 - { - self.width() * self.height() * self.depth() - } - - /// Get the minimum x coordinate. - #[must_use] - pub const fn min_x(&self) -> i32 - { - self.0.min.x - } - - /// Get the maximum x coordinate. - #[must_use] - pub const fn max_x(&self) -> i32 - { - self.0.max.x - } - - /// Get the minimum y coordinate. - #[must_use] - pub const fn min_y(&self) -> i32 - { - self.0.min.y - } - - /// Get the maximum y coordinate. - #[must_use] - pub const fn max_y(&self) -> i32 - { - self.0.max.y - } - - /// Get the minimum z coordinate. - #[must_use] - pub const fn min_z(&self) -> i32 - { - self.0.min.z - } - - /// Get the maximum z coordinate. + /// Check if a position is within these bounds. #[must_use] - pub const fn max_z(&self) -> i32 + pub fn contains(&self, pos: &IVec3) -> bool { - self.0.max.z + todo!() } } -impl From for GridBounds -{ - fn from(rect: IRect) -> Self - { - Self(Bounds2D(rect)) - } -} - -impl From> for IRect -{ - fn from(bounds: GridBounds) -> Self - { - bounds.0.0 - } -} - -impl SpatialGrid +impl SpatialGrid { #[must_use] pub fn new(bounds: Option>) -> Self @@ -527,11 +199,6 @@ impl SpatialGrid } } - pub const fn set_bounds(&mut self, bounds: GridBounds) - { - self.bounds = Some(bounds); - } - #[must_use] pub const fn bounds(&self) -> Option> { @@ -590,18 +257,17 @@ impl SpatialGrid } /// Get all entities in the neighborhood of a position (Moore neighborhood). - pub fn neighbors_of<'a>( - &'a self, - position: &'a GridPosition, - ) -> impl Iterator + 'a + pub fn neighbors_of(&self, position: &GridPosition) -> impl Iterator { position + .0 .neighbors() - .filter(move |neighbor_pos| { + .filter(|neighbor_pos| { self.bounds - .is_none_or(|bounds| bounds.contains(neighbor_pos)) + .is_none_or(|bounds| neighbor_pos.in_bounds(&bounds)) }) - .flat_map(move |neighbor_pos| { + .map(|p| GridPosition(p)) + .flat_map(|neighbor_pos| { self.position_to_entities .get(&neighbor_pos) .into_iter() @@ -610,18 +276,20 @@ impl SpatialGrid } /// Get all entities in the orthogonal neighborhood of a position (Von Neumann neighborhood). - pub fn orthogonal_neighbors_of<'a>( - &'a self, - position: &'a GridPosition, - ) -> impl Iterator + 'a + pub fn orthogonal_neighbors_of( + &self, + position: &GridPosition, + ) -> impl Iterator { position + .0 .neighbors_orthogonal() - .filter(move |neighbor_pos| { + .filter(|neighbor_pos| { self.bounds - .is_none_or(|bounds| bounds.contains(neighbor_pos)) + .is_none_or(|bounds| neighbor_pos.in_bounds(&bounds)) }) - .flat_map(move |neighbor_pos| { + .map(|p| GridPosition(p)) + .flat_map(|neighbor_pos| { self.position_to_entities .get(&neighbor_pos) .into_iter() @@ -655,13 +323,13 @@ impl SpatialGrid /// Plugin that maintains a spatial index for entities with `GridPosition` components. /// Generic over coordinate types that implement the `GridCoordinate` trait and component types. -pub struct SpatialGridPlugin +pub struct SpatialGridPlugin { bounds: Option>, _phantom: std::marker::PhantomData<(T, C)>, } -impl SpatialGridPlugin +impl SpatialGridPlugin { pub const fn new(bounds: Option>) -> Self { @@ -675,11 +343,11 @@ impl SpatialGridPlugin { // Spawn the spatial grid entity directly let spatial_grid = SpatialGrid::::new(bounds); - app.world_mut().spawn((spatial_grid, SpatialGridEntity)); + app.world_mut().insert_resource(spatial_grid); } } -impl Plugin for SpatialGridPlugin +impl Plugin for SpatialGridPlugin { fn build(&self, app: &mut App) { @@ -699,31 +367,16 @@ impl Plugin for SpatialGridPlugin } /// System that resets the spatial grid at the beginning of each simulation. -pub fn spatial_grid_reset_system( - mut spatial_grids: Query<&mut SpatialGrid, With>, +pub fn spatial_grid_reset_system( + mut spatial_grid: ResMut>, step_counter: Res, - mut commands: Commands, ) { // Reset the spatial grid whenever the step counter is 0 // This should occur on the first step of every simulation if **step_counter == 0 { - if spatial_grids.is_empty() - { - // If no spatial grid entity exists, create one - // This handles the case where reset() cleared all entities - // TODO: Feels hacky - let spatial_grid = SpatialGrid::::new(None); - commands.spawn((spatial_grid, SpatialGridEntity)); - } - else - { - for mut spatial_grid in &mut spatial_grids - { - spatial_grid.clear(); - } - } + spatial_grid.clear(); } } @@ -732,32 +385,27 @@ type GridPositionQuery<'world, 'state, T, C> = Query<'world, 'state, (Entity, &'static GridPosition), (Changed>, With)>; /// System that updates the spatial grid when entities with `GridPosition` are added or moved. -pub fn spatial_grid_update_system( - mut spatial_grids: Query<&mut SpatialGrid, With>, +pub fn spatial_grid_update_system( + mut spatial_grid: ResMut>, query: GridPositionQuery, ) { - if let Ok(mut spatial_grid) = spatial_grids.single_mut() + for (entity, position) in &query { - for (entity, position) in &query - { - spatial_grid.insert(entity, *position); - } + spatial_grid.remove(entity); + spatial_grid.insert(entity, *position); } } /// System that removes entities from the spatial grid when they no longer have `GridPosition`. -pub fn spatial_grid_cleanup_system( - mut spatial_grids: Query<&mut SpatialGrid, With>, +pub fn spatial_grid_cleanup_system( + mut spatial_grid: ResMut>, mut removed: RemovedComponents>, ) { - if let Ok(mut spatial_grid) = spatial_grids.single_mut() + for entity in removed.read() { - for entity in removed.read() - { - spatial_grid.remove(entity); - } + spatial_grid.remove(entity); } } @@ -780,8 +428,10 @@ pub type GridBounds2D = GridBounds; /// 3D grid bounds using custom `Bounds3D`. pub type GridBounds3D = GridBounds; -/// 2D spatial grid plugin. -pub type SpatialGridPlugin2D = SpatialGridPlugin; - -/// 3D spatial grid plugin. -pub type SpatialGridPlugin3D = SpatialGridPlugin; +/// Private module to enforce the sealed trait pattern. +mod private +{ + pub trait Sealed {} + impl Sealed for bevy::prelude::IVec2 {} + impl Sealed for bevy::prelude::IVec3 {} +} diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index b725354..b83cb89 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -7,7 +7,7 @@ use bevy::{ use crate::{ Sample, SimulationBuildError, plugins::{ - GridBounds, GridCoordinate, SpatialGridPlugin, StepCounterPlugin, TimeSeries, + GridBounds, GridCoordinates, SpatialGridPlugin, StepCounterPlugin, TimeSeries, TimeSeriesPlugin, }, simulation::Simulation, @@ -104,7 +104,7 @@ impl SimulationBuilder /// .build(); /// ``` #[must_use] - pub fn add_spatial_grid( + pub fn add_spatial_grid( mut self, bounds: GridBounds, ) -> Self diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 030d72a..0a1ed2d 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -16,21 +16,21 @@ struct TestEntity(i32); #[test] fn test_grid_position_neighbors() { - let pos = GridPosition2D::new_2d(1, 1); + let pos = GridPosition2D::new(1, 1); let neighbors: Vec = pos.neighbors().collect(); assert_eq!(neighbors.len(), 8); // Check all 8 neighbors are present let expected_neighbors = [ - GridPosition2D::new_2d(0, 0), - GridPosition2D::new_2d(1, 0), - GridPosition2D::new_2d(2, 0), - GridPosition2D::new_2d(0, 1), - GridPosition2D::new_2d(2, 1), - GridPosition2D::new_2d(0, 2), - GridPosition2D::new_2d(1, 2), - GridPosition2D::new_2d(2, 2), + GridPosition2D::new(0, 0), + GridPosition2D::new(1, 0), + GridPosition2D::new(2, 0), + GridPosition2D::new(0, 1), + GridPosition2D::new(2, 1), + GridPosition2D::new(0, 2), + GridPosition2D::new(1, 2), + GridPosition2D::new(2, 2), ]; for expected in expected_neighbors @@ -46,16 +46,16 @@ fn test_grid_position_neighbors() #[test] fn test_grid_position_orthogonal_neighbors() { - let pos = GridPosition2D::new_2d(1, 1); + let pos = GridPosition2D::new(1, 1); let neighbors: Vec = pos.neighbors_orthogonal().collect(); assert_eq!(neighbors.len(), 4); let expected_neighbors = [ - GridPosition2D::new_2d(1, 0), // top - GridPosition2D::new_2d(0, 1), // left - GridPosition2D::new_2d(2, 1), // right - GridPosition2D::new_2d(1, 2), // bottom + GridPosition2D::new(1, 0), // top + GridPosition2D::new(0, 1), // left + GridPosition2D::new(2, 1), // right + GridPosition2D::new(1, 2), // bottom ]; for expected in expected_neighbors @@ -71,46 +71,46 @@ fn test_grid_position_orthogonal_neighbors() #[test] fn test_grid_position_distances() { - let pos1 = GridPosition2D::new_2d(0, 0); - let pos2 = GridPosition2D::new_2d(3, 4); + let pos1 = GridPosition2D::new(0, 0); + let pos2 = GridPosition2D::new(3, 4); // Test Manhattan distance using the trait method assert_eq!(pos1.manhattan_distance(&pos2), 7); - let pos3 = GridPosition2D::new_2d(1, 1); + let pos3 = GridPosition2D::new(1, 1); assert_eq!(pos1.manhattan_distance(&pos3), 2); } #[test] fn test_grid_bounds() { - let bounds = GridBounds2D::new_2d(0, 9, 0, 9); + let bounds = GridBounds2D::new(0, 9, 0, 9); assert_eq!(bounds.width(), 10); assert_eq!(bounds.height(), 10); assert_eq!(bounds.total_cells(), 100); - assert!(bounds.contains(&GridPosition2D::new_2d(0, 0))); - assert!(bounds.contains(&GridPosition2D::new_2d(9, 9))); - assert!(bounds.contains(&GridPosition2D::new_2d(5, 5))); + assert!(bounds.contains(&GridPosition2D::new(0, 0))); + assert!(bounds.contains(&GridPosition2D::new(9, 9))); + assert!(bounds.contains(&GridPosition2D::new(5, 5))); - assert!(!bounds.contains(&GridPosition2D::new_2d(-1, 0))); - assert!(!bounds.contains(&GridPosition2D::new_2d(0, -1))); - assert!(!bounds.contains(&GridPosition2D::new_2d(10, 5))); - assert!(!bounds.contains(&GridPosition2D::new_2d(5, 10))); + assert!(!bounds.contains(&GridPosition2D::new(-1, 0))); + assert!(!bounds.contains(&GridPosition2D::new(0, -1))); + assert!(!bounds.contains(&GridPosition2D::new(10, 5))); + assert!(!bounds.contains(&GridPosition2D::new(5, 10))); } #[test] fn test_spatial_grid_plugin_integration() { - let _bounds = GridBounds2D::new_2d(0, 2, 0, 2); + let _bounds = GridBounds2D::new(0, 2, 0, 2); let builder = SimulationBuilder::new() .add_entity_spawner(|spawner| { // Spawn entities with grid positions - spawner.spawn((GridPosition2D::new_2d(0, 0), TestEntity(1))); - spawner.spawn((GridPosition2D::new_2d(1, 1), TestEntity(2))); - spawner.spawn((GridPosition2D::new_2d(2, 2), TestEntity(3))); + spawner.spawn((GridPosition2D::new(0, 0), TestEntity(1))); + spawner.spawn((GridPosition2D::new(1, 1), TestEntity(2))); + spawner.spawn((GridPosition2D::new(2, 2), TestEntity(3))); }) .add_systems(|query: Query<(&GridPosition2D, &TestEntity)>| { // Verify entities have grid positions and test data @@ -133,7 +133,7 @@ fn test_spatial_grid_plugin_integration() #[test] fn test_3d_grid_position_neighbors() { - let pos = GridPosition3D::new_3d(1, 1, 1); + let pos = GridPosition3D::new(1, 1, 1); let neighbors: Vec = pos.neighbors().collect(); assert_eq!(neighbors.len(), 26); // 3x3x3 cube minus center = 26 neighbors @@ -142,10 +142,10 @@ fn test_3d_grid_position_neighbors() assert!(!neighbors.contains(&pos)); // Check some specific 3D neighbors - assert!(neighbors.contains(&GridPosition3D::new_3d(0, 0, 0))); // corner - assert!(neighbors.contains(&GridPosition3D::new_3d(2, 2, 2))); // opposite corner - assert!(neighbors.contains(&GridPosition3D::new_3d(1, 1, 0))); // directly below - assert!(neighbors.contains(&GridPosition3D::new_3d(1, 1, 2))); // directly above + assert!(neighbors.contains(&GridPosition3D::new(0, 0, 0))); // corner + assert!(neighbors.contains(&GridPosition3D::new(2, 2, 2))); // opposite corner + assert!(neighbors.contains(&GridPosition3D::new(1, 1, 0))); // directly below + assert!(neighbors.contains(&GridPosition3D::new(1, 1, 2))); // directly above } #[test] From 309b17ae3623cd8c6e687c2a413be6f6584120a2 Mon Sep 17 00:00:00 2001 From: haath Date: Fri, 4 Jul 2025 18:03:05 +0200 Subject: [PATCH 16/23] Implement bounds.contains --- examples/air_traffic_3d.rs | 40 ++++++++++++------------------ src/plugins/spatial_grid.rs | 49 +++++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs index fd3f767..f58145e 100644 --- a/examples/air_traffic_3d.rs +++ b/examples/air_traffic_3d.rs @@ -5,7 +5,7 @@ use bevy::prelude::*; use incerto::{ - plugins::{GridBounds3D, GridPosition3D, SpatialGrid, SpatialGridEntity}, + plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, prelude::*, }; use rand::prelude::*; @@ -36,13 +36,13 @@ pub enum AircraftType impl AircraftType { - fn symbol(&self) -> &'static str + const fn symbol(self) -> &'static str { match self { - AircraftType::Commercial => "āœˆļø", - AircraftType::PrivateJet => "šŸ›©ļø", - AircraftType::Cargo => "šŸ›«", + Self::Commercial => "āœˆļø", + Self::PrivateJet => "šŸ›©ļø", + Self::Cargo => "šŸ›«", } } } @@ -83,7 +83,7 @@ fn spawn_aircraft(spawner: &mut Spawner) speed: 1, }; - let position = GridPosition3D::new_3d(x, y, z); + let position = GridPosition3D::new(x, y, z); spawner.spawn((aircraft, position)); } } @@ -98,11 +98,11 @@ fn move_aircraft(mut query: Query<(&mut GridPosition3D, &Aircraft)>) // Move towards target altitude if position.z() < aircraft.target_altitude { - *position = GridPosition3D::new_3d(position.x(), position.y(), position.z() + 1); + *position = GridPosition3D::new(position.x(), position.y(), position.z() + 1); } else if position.z() > aircraft.target_altitude { - *position = GridPosition3D::new_3d(position.x(), position.y(), position.z() - 1); + *position = GridPosition3D::new(position.x(), position.y(), position.z() - 1); } // Random horizontal movement @@ -114,23 +114,17 @@ fn move_aircraft(mut query: Query<(&mut GridPosition3D, &Aircraft)>) let new_x = (position.x() + dx).clamp(0, AIRSPACE_SIZE - 1); let new_y = (position.y() + dy).clamp(0, AIRSPACE_SIZE - 1); - *position = GridPosition3D::new_3d(new_x, new_y, position.z()); + *position = GridPosition3D::new(new_x, new_y, position.z()); } } } /// Check for aircraft conflicts (too close in 3D space) fn check_conflicts( - spatial_grids: Query<&SpatialGrid, With>, + spatial_grid: Res>, query: Query<(Entity, &GridPosition3D, &Aircraft)>, ) { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; - let mut conflicts = 0; for (entity, position, aircraft) in &query @@ -144,7 +138,7 @@ fn check_conflicts( { if let Ok((_, nearby_pos, nearby_aircraft)) = query.get(nearby_entity) { - let distance = position.manhattan_distance(nearby_pos); + let distance = (position.0 - nearby_pos.0).length_squared(); if distance <= 1 { conflicts += 1; @@ -218,14 +212,10 @@ fn main() println!("Duration: {} steps\n", SIMULATION_STEPS); // Create 3D airspace bounds - let bounds = GridBounds3D::new_3d( - 0, - AIRSPACE_SIZE - 1, - 0, - AIRSPACE_SIZE - 1, - 0, - AIRSPACE_HEIGHT - 1, - ); + let bounds = GridBounds3D { + min: IVec3::ZERO, + max: IVec3::new(AIRSPACE_SIZE, AIRSPACE_SIZE, AIRSPACE_HEIGHT), + }; let mut simulation = SimulationBuilder::new() .add_spatial_grid::(bounds) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 71e78f8..0092f58 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -126,6 +126,18 @@ impl GridPosition { self.0.neighbors_orthogonal().map(Self) } + + #[must_use] + pub const fn x(&self) -> i32 + { + self.0.x + } + + #[must_use] + pub const fn y(&self) -> i32 + { + self.0.y + } } // Convenience methods for 3D positions @@ -147,6 +159,24 @@ impl GridPosition { self.0.neighbors_orthogonal().map(Self) } + + #[must_use] + pub const fn x(&self) -> i32 + { + self.0.x + } + + #[must_use] + pub const fn y(&self) -> i32 + { + self.0.y + } + + #[must_use] + pub const fn z(&self) -> i32 + { + self.0.z + } } /// Component that maintains a spatial index for efficient neighbor queries. @@ -168,10 +198,16 @@ pub struct SpatialGrid impl GridBounds { /// Check if a position is within these bounds. + /// + /// # Panics + /// + /// If [`Self::min`] is larger than [`Self::max`] along any axis. #[must_use] pub fn contains(&self, pos: &IVec2) -> bool { - todo!() + assert!(self.min.x <= self.max.x); + assert!(self.min.y <= self.max.y); + (pos.x >= self.min.x && pos.x <= self.max.x) && (pos.y >= self.min.y && pos.y <= self.max.y) } } @@ -179,10 +215,19 @@ impl GridBounds impl GridBounds { /// Check if a position is within these bounds. + /// + /// # Panics + /// + /// If [`Self::min`] is larger than [`Self::max`] along any axis. #[must_use] pub fn contains(&self, pos: &IVec3) -> bool { - todo!() + assert!(self.min.x <= self.max.x); + assert!(self.min.y <= self.max.y); + assert!(self.min.z <= self.max.z); + (pos.x >= self.min.x && pos.x <= self.max.x) + && (pos.y >= self.min.y && pos.y <= self.max.y) + && (pos.z >= self.min.z && pos.z <= self.max.z) } } From aa08d986d9eea775b80c7e1bba14c478f7ae3973 Mon Sep 17 00:00:00 2001 From: haath Date: Fri, 4 Jul 2025 18:14:38 +0200 Subject: [PATCH 17/23] Privatize plugin systems --- src/plugins/spatial_grid.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 0092f58..507fc3d 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -412,7 +412,7 @@ impl Plugin for SpatialGridPlugin } /// System that resets the spatial grid at the beginning of each simulation. -pub fn spatial_grid_reset_system( +fn spatial_grid_reset_system( mut spatial_grid: ResMut>, step_counter: Res, ) @@ -430,7 +430,7 @@ type GridPositionQuery<'world, 'state, T, C> = Query<'world, 'state, (Entity, &'static GridPosition), (Changed>, With)>; /// System that updates the spatial grid when entities with `GridPosition` are added or moved. -pub fn spatial_grid_update_system( +fn spatial_grid_update_system( mut spatial_grid: ResMut>, query: GridPositionQuery, ) @@ -443,7 +443,7 @@ pub fn spatial_grid_update_system( } /// System that removes entities from the spatial grid when they no longer have `GridPosition`. -pub fn spatial_grid_cleanup_system( +fn spatial_grid_cleanup_system( mut spatial_grid: ResMut>, mut removed: RemovedComponents>, ) From 8fe1a67c9d4df8663e8f849a6512e69b177f1dac Mon Sep 17 00:00:00 2001 From: rozgo Date: Fri, 4 Jul 2025 14:52:23 -0600 Subject: [PATCH 18/23] update examples and tests to match refactored spatial grid --- examples/air_traffic_3d.rs | 73 ++-- examples/ecosystem_multi_grid.rs | 652 ------------------------------- examples/forest_fire.rs | 19 +- examples/pandemic_spatial.rs | 66 ++-- src/simulation_builder.rs | 5 +- tests/test_spatial_grid.rs | 177 ++++----- 6 files changed, 151 insertions(+), 841 deletions(-) delete mode 100644 examples/ecosystem_multi_grid.rs diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs index f58145e..b4656bb 100644 --- a/examples/air_traffic_3d.rs +++ b/examples/air_traffic_3d.rs @@ -3,6 +3,10 @@ //! This example demonstrates 3D spatial grid functionality with aircraft moving //! through different altitude levels in a simple airspace. +#![allow(clippy::cast_possible_wrap)] +#![allow(clippy::cast_sign_loss)] +#![allow(clippy::expect_used)] + use bevy::prelude::*; use incerto::{ plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, @@ -20,7 +24,7 @@ const NUM_AIRCRAFT: usize = 15; #[derive(Component, Debug)] pub struct Aircraft { - pub id: u32, + pub id: usize, pub aircraft_type: AircraftType, pub target_altitude: i32, pub speed: i32, // cells per step @@ -77,7 +81,7 @@ fn spawn_aircraft(spawner: &mut Spawner) let target_altitude = rng.random_range(0..AIRSPACE_HEIGHT); let aircraft = Aircraft { - id: i as u32, + id: i, aircraft_type, target_altitude, speed: 1, @@ -135,22 +139,20 @@ fn check_conflicts( for nearby_entity in nearby_aircraft { if nearby_entity != entity + && let Ok((_, nearby_pos, nearby_aircraft)) = query.get(nearby_entity) { - if let Ok((_, nearby_pos, nearby_aircraft)) = query.get(nearby_entity) + let distance = (position.0 - nearby_pos.0).length_squared(); + if distance <= 1 { - let distance = (position.0 - nearby_pos.0).length_squared(); - if distance <= 1 - { - conflicts += 1; - println!( - "āš ļø CONFLICT: Aircraft {} {} and {} {} too close at distance {}", - aircraft.id, - aircraft.aircraft_type.symbol(), - nearby_aircraft.id, - nearby_aircraft.aircraft_type.symbol(), - distance - ); - } + conflicts += 1; + println!( + "āš ļø CONFLICT: Aircraft {} {} and {} {} too close at distance {}", + aircraft.id, + aircraft.aircraft_type.symbol(), + nearby_aircraft.id, + nearby_aircraft.aircraft_type.symbol(), + distance + ); } } } @@ -177,16 +179,17 @@ fn display_airspace(query: Query<(&GridPosition3D, &Aircraft)>) aircraft_positions.push((position, aircraft)); } - for (altitude, count) in altitude_counts.iter().enumerate() + for altitude in 0..AIRSPACE_HEIGHT { - if *count > 0 + let count = altitude_counts[altitude as usize]; + if count > 0 { - print!(" FL{:02}: {} aircraft ", altitude, count); + print!(" FL{altitude:02}: {count} aircraft "); // Show aircraft at this altitude for (pos, aircraft) in &aircraft_positions { - if pos.z() == altitude as i32 + if pos.z() == altitude { print!("{} ", aircraft.aircraft_type.symbol()); } @@ -195,21 +198,15 @@ fn display_airspace(query: Query<(&GridPosition3D, &Aircraft)>) } } - println!( - " Airspace: {}x{}x{} cells", - AIRSPACE_SIZE, AIRSPACE_SIZE, AIRSPACE_HEIGHT - ); + println!(" Airspace: {AIRSPACE_SIZE}x{AIRSPACE_SIZE}x{AIRSPACE_HEIGHT} cells"); } fn main() { println!("āœˆļø 3D Air Traffic Control Simulation"); - println!( - "Airspace: {}x{}x{} cells", - AIRSPACE_SIZE, AIRSPACE_SIZE, AIRSPACE_HEIGHT - ); - println!("Aircraft: {}", NUM_AIRCRAFT); - println!("Duration: {} steps\n", SIMULATION_STEPS); + println!("Airspace: {AIRSPACE_SIZE}x{AIRSPACE_SIZE}x{AIRSPACE_HEIGHT} cells"); + println!("Aircraft: {NUM_AIRCRAFT}"); + println!("Duration: {SIMULATION_STEPS} steps\n"); // Create 3D airspace bounds let bounds = GridBounds3D { @@ -226,13 +223,15 @@ fn main() // Run simulation for step in 1..=SIMULATION_STEPS { - println!("šŸ• Step {}/{}", step, SIMULATION_STEPS); + println!("šŸ• Step {step}/{SIMULATION_STEPS}"); simulation.run(1); - if step % 10 == 0 + if step.is_multiple_of(10) { - let aircraft_count = simulation.sample::().unwrap(); - println!(" šŸ“Š Total aircraft tracked: {}", aircraft_count); + let aircraft_count = simulation + .sample::() + .expect("Failed to sample aircraft count"); + println!(" šŸ“Š Total aircraft tracked: {aircraft_count}"); } // Add small delay for readability @@ -241,6 +240,8 @@ fn main() println!("\nāœ… Air Traffic Control simulation completed!"); - let final_count = simulation.sample::().unwrap(); - println!("šŸ“ˆ Final aircraft count: {}", final_count); + let final_count = simulation + .sample::() + .expect("Failed to sample final aircraft count"); + println!("šŸ“ˆ Final aircraft count: {final_count}"); } diff --git a/examples/ecosystem_multi_grid.rs b/examples/ecosystem_multi_grid.rs deleted file mode 100644 index a80baa2..0000000 --- a/examples/ecosystem_multi_grid.rs +++ /dev/null @@ -1,652 +0,0 @@ -//! # Multi-Grid Ecosystem Simulation -//! -//! This example demonstrates the power of multiple spatial grids by simulating -//! a complex ecosystem with three different entity types, each with their own -//! spatial grid for optimized interactions: -//! -//! * **Prey (Rabbits)** - Use `SpatialGrid` for flocking behavior -//! * **Predators (Wolves)** - Use `SpatialGrid` for pack hunting -//! * **Vegetation (Grass)** - Use `SpatialGrid` for growth patterns -//! -//! ## Key Features: -//! * **Multi-scale interactions** - Different interaction ranges for different behaviors -//! * **Cross-grid queries** - Predators hunt prey, prey avoid predators, all consume vegetation -//! * **Emergent patterns** - Herds, pack formation, resource patches emerge naturally -//! * **Performance optimization** - Each entity type has its own spatial index - -#![allow(clippy::unwrap_used)] -#![allow(clippy::expect_used)] -#![allow(clippy::cast_precision_loss)] -#![allow(clippy::too_many_lines)] - -use std::collections::HashMap; - -use bevy::prelude::{IVec2, ParamSet}; -use incerto::{ - plugins::{GridBounds2D, GridPosition2D, SpatialGrid, SpatialGridEntity}, - prelude::*, -}; -use rand::prelude::*; - -// Simulation parameters -const SIMULATION_STEPS: usize = 200; -const WORLD_SIZE: i32 = 30; -const INITIAL_PREY: usize = 50; -const INITIAL_PREDATORS: usize = 8; -const INITIAL_VEGETATION: usize = 120; - -/// Prey animals (rabbits) that flock together and graze -#[derive(Component, Debug)] -pub struct Prey -{ - pub energy: u32, - pub age: u32, - pub fear_level: f32, // Affects movement when predators are near -} - -/// Predator animals (wolves) that hunt in packs -#[derive(Component, Debug)] -pub struct Predator -{ - pub energy: u32, - pub age: u32, - pub hunt_cooldown: u32, // Ticks until can hunt again -} - -/// Vegetation (grass) that grows and gets consumed -#[derive(Component, Debug)] -pub struct Vegetation -{ - pub growth_level: u32, // 0-100, higher = more nutritious - pub regrowth_timer: u32, // Ticks until next growth -} - -/// Bundle for spawning prey entities -#[derive(Bundle)] -pub struct PreyBundle -{ - pub position: GridPosition2D, - pub prey: Prey, -} - -/// Bundle for spawning predator entities -#[derive(Bundle)] -pub struct PredatorBundle -{ - pub position: GridPosition2D, - pub predator: Predator, -} - -/// Bundle for spawning vegetation entities -#[derive(Bundle)] -pub struct VegetationBundle -{ - pub position: GridPosition2D, - pub vegetation: Vegetation, -} - -// Sample implementations for statistics -impl Sample for Prey -{ - fn sample(components: &[&Self]) -> usize - { - components.len() - } -} - -impl Sample for Predator -{ - fn sample(components: &[&Self]) -> usize - { - components.len() - } -} - -impl Sample for Vegetation -{ - fn sample(components: &[&Self]) -> usize - { - components.len() - } -} - -/// Spawn initial ecosystem entities -fn spawn_ecosystem(spawner: &mut Spawner) -{ - let mut rng = rand::rng(); - - // Spawn prey (rabbits) in small groups - for _ in 0..INITIAL_PREY - { - let x = rng.random_range(0..WORLD_SIZE); - let y = rng.random_range(0..WORLD_SIZE); - - spawner.spawn(PreyBundle { - position: GridPosition2D::new_2d(x, y), - prey: Prey { - energy: rng.random_range(50..100), - age: rng.random_range(0..50), - fear_level: 0.0, - }, - }); - } - - // Spawn predators (wolves) scattered - for _ in 0..INITIAL_PREDATORS - { - let x = rng.random_range(0..WORLD_SIZE); - let y = rng.random_range(0..WORLD_SIZE); - - spawner.spawn(PredatorBundle { - position: GridPosition2D::new_2d(x, y), - predator: Predator { - energy: rng.random_range(80..120), - age: rng.random_range(0..30), - hunt_cooldown: 0, - }, - }); - } - - // Spawn vegetation (grass) patches - for _ in 0..INITIAL_VEGETATION - { - let x = rng.random_range(0..WORLD_SIZE); - let y = rng.random_range(0..WORLD_SIZE); - - spawner.spawn(VegetationBundle { - position: GridPosition2D::new_2d(x, y), - vegetation: Vegetation { - growth_level: rng.random_range(20..80), - regrowth_timer: 0, - }, - }); - } -} - -/// Prey flocking and movement system - uses only prey spatial grid -fn prey_behavior( - prey_grid: Query<&SpatialGrid, With>, - predator_grid: Query<&SpatialGrid, With>, - mut prey_query: Query<(&mut GridPosition2D, &mut Prey)>, -) -{ - let Ok(prey_spatial) = prey_grid.single() - else - { - return; - }; - let Ok(predator_spatial) = predator_grid.single() - else - { - return; - }; - - let mut rng = rand::rng(); - - for (mut position, mut prey) in &mut prey_query - { - let current_pos = *position; - - // Reset fear - prey.fear_level *= 0.9; // Decay fear over time - - // Check for nearby predators (CROSS-GRID INTERACTION!) - let nearby_predators: Vec<_> = predator_spatial.neighbors_of(¤t_pos).collect(); - if !nearby_predators.is_empty() - { - prey.fear_level = (prey.fear_level + 50.0).min(100.0); - } - - // Flocking behavior - stay near other prey - let nearby_prey: Vec<_> = prey_spatial.neighbors_of(¤t_pos).collect(); - - let mut target_x = current_pos.x() as f32; - let mut target_y = current_pos.y() as f32; - - // If afraid, move away from predators - if prey.fear_level > 10.0 && !nearby_predators.is_empty() - { - target_x += rng.random_range(-2.0..2.0); - target_y += rng.random_range(-2.0..2.0); - } - else if nearby_prey.len() < 2 - { - // If isolated, move randomly to find group - target_x += rng.random_range(-1.0..1.0); - target_y += rng.random_range(-1.0..1.0); - } - else if nearby_prey.len() > 5 - { - // If overcrowded, spread out slightly - target_x += rng.random_range(-0.5..0.5); - target_y += rng.random_range(-0.5..0.5); - } - - // Clamp to world bounds - let new_x = (target_x as i32).clamp(0, WORLD_SIZE - 1); - let new_y = (target_y as i32).clamp(0, WORLD_SIZE - 1); - - // Update position directly to trigger Changed - if new_x != current_pos.x() || new_y != current_pos.y() - { - *position = GridPosition2D::new_2d(new_x, new_y); - } - - // Consume energy - prey.energy = prey.energy.saturating_sub(1); - prey.age += 1; - } -} - -/// Predator hunting system - uses both predator and prey spatial grids -fn predator_hunting( - predator_grid: Query<&SpatialGrid, With>, - prey_grid: Query<&SpatialGrid, With>, - mut query_set: ParamSet<( - Query<(&mut GridPosition2D, &mut Predator)>, - Query<(Entity, &GridPosition2D), With>, - )>, - mut commands: Commands, -) -{ - let Ok(predator_spatial) = predator_grid.single() - else - { - return; - }; - let Ok(prey_spatial) = prey_grid.single() - else - { - return; - }; - - let mut rng = rand::rng(); - let mut hunts = Vec::new(); - - for (mut position, mut predator) in &mut query_set.p0() - { - let current_pos = *position; - - // Reduce hunt cooldown - predator.hunt_cooldown = predator.hunt_cooldown.saturating_sub(1); - - // Look for prey in neighboring cells (CROSS-GRID INTERACTION!) - let nearby_prey: Vec<_> = prey_spatial.neighbors_of(¤t_pos).collect(); - - let mut target_x = current_pos.x() as f32; - let mut target_y = current_pos.y() as f32; - - if !nearby_prey.is_empty() && predator.hunt_cooldown == 0 && predator.energy > 30 - { - // Hunt nearby prey - if let Some(prey_entity) = nearby_prey.choose(&mut rng) - { - hunts.push(*prey_entity); - predator.energy += 40; // Gain energy from successful hunt - predator.hunt_cooldown = 10; // Cooldown before next hunt - } - } - else - { - // Move toward areas with more prey - let mut best_prey_count = 0; - let mut best_direction = (0.0, 0.0); - - // Check surrounding areas for prey density - for dx in -2..=2 - { - for dy in -2..=2 - { - if dx == 0 && dy == 0 - { - continue; - } - - let check_x = (current_pos.x() + dx).clamp(0, WORLD_SIZE - 1); - let check_y = (current_pos.y() + dy).clamp(0, WORLD_SIZE - 1); - let check_pos = GridPosition2D::new_2d(check_x, check_y); - - let prey_count = prey_spatial.entities_at(&check_pos).count(); - if prey_count > best_prey_count - { - best_prey_count = prey_count; - best_direction = (dx as f32, dy as f32); - } - } - } - - if best_prey_count > 0 - { - target_x += best_direction.0 * 0.5; - target_y += best_direction.1 * 0.5; - } - else - { - // Random movement when no prey detected - target_x += rng.random_range(-1.0..1.0); - target_y += rng.random_range(-1.0..1.0); - } - } - - // Avoid other predators (pack behavior - maintain some distance) - let nearby_predators: Vec<_> = predator_spatial.neighbors_of(¤t_pos).collect(); - if nearby_predators.len() > 2 - { - target_x += rng.random_range(-0.5..0.5); - target_y += rng.random_range(-0.5..0.5); - } - - // Clamp to world bounds - let new_x = (target_x as i32).clamp(0, WORLD_SIZE - 1); - let new_y = (target_y as i32).clamp(0, WORLD_SIZE - 1); - - // Update position directly to trigger Changed - if new_x != current_pos.x() || new_y != current_pos.y() - { - *position = GridPosition2D::new_2d(new_x, new_y); - } - - // Consume energy (hunting is expensive) - predator.energy = predator.energy.saturating_sub(2); - predator.age += 1; - } - - // Execute hunts (remove caught prey) - for prey_entity in hunts - { - commands.entity(prey_entity).despawn(); - } -} - -/// Vegetation growth and grazing system - uses vegetation spatial grid -fn vegetation_dynamics( - vegetation_grid: Query<&SpatialGrid, With>, - prey_grid: Query<&SpatialGrid, With>, - mut vegetation_query: Query<(Entity, &GridPosition2D, &mut Vegetation)>, - mut commands: Commands, -) -{ - let Ok(vegetation_spatial) = vegetation_grid.single() - else - { - return; - }; - let Ok(prey_spatial) = prey_grid.single() - else - { - return; - }; - - let mut rng = rand::rng(); - let mut consumed_vegetation = Vec::new(); - - // Vegetation growth and consumption - for (veg_entity, position, mut vegetation) in &mut vegetation_query - { - // Check for grazing prey (CROSS-GRID INTERACTION!) - let grazing_prey: Vec<_> = prey_spatial.entities_at(position).collect(); - - if !grazing_prey.is_empty() && vegetation.growth_level > 10 - { - // Vegetation gets consumed - let consumption = rng.random_range(10..30).min(vegetation.growth_level); - vegetation.growth_level = vegetation.growth_level.saturating_sub(consumption); - - // Note: Prey feeding happens in a separate system to avoid query conflicts - - if vegetation.growth_level == 0 - { - consumed_vegetation.push(veg_entity); - } - } - else - { - // Natural growth - vegetation.regrowth_timer = vegetation.regrowth_timer.saturating_sub(1); - if vegetation.regrowth_timer == 0 - { - vegetation.growth_level = (vegetation.growth_level + 1).min(100); - vegetation.regrowth_timer = rng.random_range(5..15); - } - - // Spread to nearby empty areas occasionally - if vegetation.growth_level > 50 && rng.random_bool(0.05) - { - let spread_x = position.x() + rng.random_range(-1..=1); - let spread_y = position.y() + rng.random_range(-1..=1); - - if spread_x >= 0 && spread_x < WORLD_SIZE && spread_y >= 0 && spread_y < WORLD_SIZE - { - let spread_pos = GridPosition2D::new_2d(spread_x, spread_y); - - // Check if area is empty of vegetation - let existing_veg = vegetation_spatial.entities_at(&spread_pos).count(); - if existing_veg == 0 - { - commands.spawn(VegetationBundle { - position: spread_pos, - vegetation: Vegetation { - growth_level: 30, - regrowth_timer: rng.random_range(10..20), - }, - }); - } - } - } - } - } - - // Remove fully consumed vegetation - for entity in consumed_vegetation - { - commands.entity(entity).despawn(); - } -} - -/// Prey feeding system - separate from vegetation to avoid query conflicts -fn prey_feeding( - vegetation_grid: Query<&SpatialGrid, With>, - mut prey_query: Query<(&GridPosition2D, &mut Prey)>, - vegetation_query: Query<&Vegetation>, -) -{ - let Ok(vegetation_spatial) = vegetation_grid.single() - else - { - return; - }; - - for (prey_pos, mut prey) in &mut prey_query - { - // Check for vegetation at prey location and feed from the first available - for veg_entity in vegetation_spatial.entities_at(prey_pos) - { - if let Ok(vegetation) = vegetation_query.get(veg_entity) - { - if vegetation.growth_level > 10 - { - let nutrition = (vegetation.growth_level / 4).min(20); - prey.energy = (prey.energy + nutrition).min(100); - break; - } - } - } - } -} - -/// Natural lifecycle - entities die of old age or starvation -fn lifecycle_system( - prey_query: Query<(Entity, &Prey)>, - predator_query: Query<(Entity, &Predator)>, - mut commands: Commands, -) -{ - let mut rng = rand::rng(); - - // Prey lifecycle - for (entity, prey) in &prey_query - { - if prey.energy == 0 || (prey.age > 100 && rng.random_bool(0.1)) - { - commands.entity(entity).despawn(); - } - } - - // Predator lifecycle - for (entity, predator) in &predator_query - { - if predator.energy == 0 || (predator.age > 150 && rng.random_bool(0.05)) - { - commands.entity(entity).despawn(); - } - } -} - -/// Display ecosystem statistics and spatial patterns -fn display_ecosystem_stats( - prey_query: Query<(&GridPosition2D, &Prey)>, - predator_query: Query<(&GridPosition2D, &Predator)>, - vegetation_query: Query<(&GridPosition2D, &Vegetation)>, -) -{ - let prey_count = prey_query.iter().count(); - let predator_count = predator_query.iter().count(); - let vegetation_count = vegetation_query.iter().count(); - - let avg_prey_energy: f32 = if prey_count > 0 - { - prey_query.iter().map(|(_, p)| p.energy as f32).sum::() / prey_count as f32 - } - else - { - 0.0 - }; - - let avg_predator_energy: f32 = if predator_count > 0 - { - predator_query - .iter() - .map(|(_, p)| p.energy as f32) - .sum::() - / predator_count as f32 - } - else - { - 0.0 - }; - - let avg_vegetation_growth: f32 = if vegetation_count > 0 - { - vegetation_query - .iter() - .map(|(_, v)| v.growth_level as f32) - .sum::() - / vegetation_count as f32 - } - else - { - 0.0 - }; - - // Analyze spatial distribution - let mut prey_density = HashMap::new(); - for (pos, _) in &prey_query - { - let region = (pos.x() / 5, pos.y() / 5); // 5x5 regions - *prey_density.entry(region).or_insert(0) += 1; - } - - let max_prey_density = prey_density.values().max().copied().unwrap_or(0); - - println!("\nšŸŒ Ecosystem Status:"); - println!( - " 🐰 Prey: {} (avg energy: {:.1})", - prey_count, avg_prey_energy - ); - println!( - " 🐺 Predators: {} (avg energy: {:.1})", - predator_count, avg_predator_energy - ); - println!( - " 🌱 Vegetation: {} (avg growth: {:.1}%)", - vegetation_count, avg_vegetation_growth - ); - println!(" šŸ“Š Max prey density in region: {}", max_prey_density); - - if prey_count == 0 - { - println!(" āš ļø All prey extinct!"); - } - if predator_count == 0 - { - println!(" āš ļø All predators extinct!"); - } -} - -fn main() -{ - println!("šŸŒ Multi-Grid Ecosystem Simulation"); - println!("Demonstrating multiple spatial grids working together:"); - println!(" • Prey Grid: Flocking and grazing behavior"); - println!(" • Predator Grid: Pack hunting coordination"); - println!(" • Vegetation Grid: Growth and resource management"); - println!("World size: {}x{}", WORLD_SIZE, WORLD_SIZE); - println!("Duration: {} steps\n", SIMULATION_STEPS); - - let bounds = GridBounds2D::new_2d(0, WORLD_SIZE - 1, 0, WORLD_SIZE - 1); - - let mut simulation = SimulationBuilder::new() - // Create separate spatial grids for each entity type - .add_spatial_grid::(bounds) - .add_spatial_grid::(bounds) - .add_spatial_grid::(bounds) - .add_entity_spawner(spawn_ecosystem) - .add_systems(( - prey_behavior, - predator_hunting.after(prey_behavior), - vegetation_dynamics.after(predator_hunting), - prey_feeding.after(vegetation_dynamics), - lifecycle_system.after(prey_feeding), - display_ecosystem_stats.after(lifecycle_system), - )) - .build(); - - // Run ecosystem simulation - for step in 1..=SIMULATION_STEPS - { - println!("šŸ• Step {}/{}", step, SIMULATION_STEPS); - simulation.run(1); - - if step % 25 == 0 - { - let prey_count = simulation.sample::().unwrap(); - let predator_count = simulation.sample::().unwrap(); - let vegetation_count = simulation.sample::().unwrap(); - - println!( - " šŸ“ˆ Population trends: 🐰{} 🐺{} 🌱{}", - prey_count, predator_count, vegetation_count - ); - - if prey_count == 0 && predator_count == 0 - { - println!("\nšŸ’€ Ecosystem collapse! All animals extinct."); - break; - } - } - - // Add delay for readability - std::thread::sleep(std::time::Duration::from_millis(50)); - } - - println!("\nāœ… Ecosystem simulation completed!"); - - let final_prey = simulation.sample::().unwrap(); - let final_predators = simulation.sample::().unwrap(); - let final_vegetation = simulation.sample::().unwrap(); - - println!("šŸ“Š Final populations:"); - println!(" 🐰 Prey: {}", final_prey); - println!(" 🐺 Predators: {}", final_predators); - println!(" 🌱 Vegetation: {}", final_vegetation); -} diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs index 5f825dd..f64a81d 100644 --- a/examples/forest_fire.rs +++ b/examples/forest_fire.rs @@ -26,7 +26,7 @@ use std::collections::HashSet; use bevy::prelude::IVec2; use incerto::{ - plugins::{SpatialGrid, SpatialGridEntity}, + plugins::{GridBounds2D, GridPosition2D, SpatialGrid}, prelude::*, }; use rand::prelude::*; @@ -152,7 +152,10 @@ fn main() println!(); // Build the simulation - let bounds = GridBounds2D::new_2d(0, GRID_WIDTH - 1, 0, GRID_HEIGHT - 1); + let bounds = GridBounds2D { + min: IVec2::new(0, 0), + max: IVec2::new(GRID_WIDTH - 1, GRID_HEIGHT - 1), + }; let mut simulation = SimulationBuilder::new() // Add spatial grid support .add_spatial_grid::(bounds) @@ -237,7 +240,7 @@ fn spawn_forest_grid(spawner: &mut Spawner) { for y in 0..GRID_HEIGHT { - let position = GridPosition2D::new_2d(x, y); + let position = GridPosition2D::new(x, y); // Determine initial state let state = if rng.random_bool(INITIAL_FOREST_DENSITY) @@ -256,7 +259,7 @@ fn spawn_forest_grid(spawner: &mut Spawner) // Start some initial fires at random locations let healthy_positions: Vec = (0..GRID_WIDTH) - .flat_map(|x| (0..GRID_HEIGHT).map(move |y| GridPosition2D::new_2d(x, y))) + .flat_map(|x| (0..GRID_HEIGHT).map(move |y| GridPosition2D::new(x, y))) .collect(); // This is a simplified approach - in a real implementation you'd query existing entities @@ -277,17 +280,11 @@ fn spawn_forest_grid(spawner: &mut Spawner) /// System that handles fire spreading to neighboring cells using the spatial grid. fn fire_spread_system( - spatial_grids: Query<&SpatialGrid, With>, + spatial_grid: Res>, query_burning: Query<(Entity, &GridPosition2D), With>, mut query_cells: Query<(&GridPosition2D, &mut ForestCell)>, ) { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; - let mut rng = rand::rng(); let mut spread_positions = HashSet::new(); diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index 26ea8d8..a8e0e10 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -23,7 +23,7 @@ use std::collections::HashSet; use bevy::prelude::IVec2; use incerto::{ - plugins::{SpatialGrid, SpatialGridEntity}, + plugins::{GridBounds2D, GridPosition2D, SpatialGrid}, prelude::*, }; use rand::prelude::*; @@ -35,7 +35,7 @@ const GRID_SIZE: i32 = 40; // Disease parameters const CHANCE_START_INFECTED: f64 = 0.02; -const INFECTION_RADIUS: u32 = 2; // Can infect within 2 cells distance +const INFECTION_RADIUS: i32 = 2; // Can infect within 2 cells distance const CHANCE_INFECT_AT_DISTANCE_1: f64 = 0.15; // High chance at close distance const CHANCE_INFECT_AT_DISTANCE_2: f64 = 0.05; // Lower chance at farther distance const CHANCE_RECOVER: f64 = 0.03; @@ -55,7 +55,7 @@ const CONTACT_QUARANTINE_DURATION: usize = 14; const QUARANTINE_ZONE_ENABLED: bool = true; const QUARANTINE_CENTER_X: i32 = GRID_SIZE / 2; const QUARANTINE_CENTER_Y: i32 = GRID_SIZE / 2; -const QUARANTINE_RADIUS: u32 = 8; +const QUARANTINE_RADIUS: i32 = 8; // Time series sampling const SAMPLE_INTERVAL: usize = 1; @@ -167,7 +167,10 @@ fn main() ); println!(); - let bounds = GridBounds2D::new_2d(0, GRID_SIZE - 1, 0, GRID_SIZE - 1); + let bounds = GridBounds2D { + min: IVec2::new(0, 0), + max: IVec2::new(GRID_SIZE - 1, GRID_SIZE - 1), + }; let mut simulation = SimulationBuilder::new() // Add spatial grid support .add_spatial_grid::(bounds) @@ -277,7 +280,7 @@ fn spawn_population(spawner: &mut Spawner) for _ in 0..INITIAL_POPULATION { // Random position on the grid - let position = GridPosition2D::new_2d( + let position = GridPosition2D::new( rng.random_range(0..GRID_SIZE), rng.random_range(0..GRID_SIZE), ); @@ -309,15 +312,9 @@ fn spawn_population(spawner: &mut Spawner) /// Enhanced movement system with social distancing behavior fn people_move_with_social_distancing( mut query: Query<(&mut GridPosition2D, &Person, Option<&Quarantined>)>, - spatial_grids: Query<&SpatialGrid, With>, + spatial_grid: Res>, ) { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; - let mut rng = rand::rng(); for (mut position, person, quarantined) in &mut query @@ -336,10 +333,10 @@ fn people_move_with_social_distancing( // Get potential movement directions let directions = [ - GridPosition2D::new_2d(position.x(), position.y() - 1), // up - GridPosition2D::new_2d(position.x() - 1, position.y()), // left - GridPosition2D::new_2d(position.x() + 1, position.y()), // right - GridPosition2D::new_2d(position.x(), position.y() + 1), // down + GridPosition2D::new(position.x(), position.y() - 1), // up + GridPosition2D::new(position.x() - 1, position.y()), // left + GridPosition2D::new(position.x() + 1, position.y()), // right + GridPosition2D::new(position.x(), position.y() + 1), // down ]; let mut best_moves = Vec::new(); @@ -348,7 +345,10 @@ fn people_move_with_social_distancing( for new_pos in directions { // Check bounds - if new_pos.x < 0 || new_pos.x >= GRID_SIZE || new_pos.y < 0 || new_pos.y >= GRID_SIZE + if new_pos.x() < 0 + || new_pos.x() >= GRID_SIZE + || new_pos.y() < 0 + || new_pos.y() >= GRID_SIZE { continue; } @@ -357,8 +357,8 @@ fn people_move_with_social_distancing( if QUARANTINE_ZONE_ENABLED { let quarantine_center = - GridPosition2D::new_2d(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); - if (*new_pos - *quarantine_center).abs().element_sum() as u32 <= QUARANTINE_RADIUS + GridPosition2D::new(QUARANTINE_CENTER_X, QUARANTINE_CENTER_Y); + if (new_pos.0 - quarantine_center.0).abs().element_sum() <= QUARANTINE_RADIUS { // Only enter quarantine zone if not practicing social distancing if person.social_distancing @@ -431,16 +431,10 @@ fn disease_incubation_progression(mut query: Query<&mut Person>) /// Advanced spatial disease transmission system using infection radius fn spatial_disease_transmission( - spatial_grids: Query<&SpatialGrid, With>, + spatial_grid: Res>, mut query: Query<(Entity, &GridPosition2D, &mut Person), Without>, ) { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; - let mut rng = rand::rng(); let mut new_exposures = Vec::new(); @@ -463,18 +457,18 @@ fn spatial_disease_transmission( { // Get all people within infection radius using iterative approach let mut nearby_entities = Vec::new(); - let infectious_coord = *infectious_pos; + let infectious_coord = infectious_pos.0; // Check all positions within Manhattan distance of INFECTION_RADIUS - for dx in -(INFECTION_RADIUS as i32)..=(INFECTION_RADIUS as i32) + for dx in -INFECTION_RADIUS..=INFECTION_RADIUS { - for dy in -(INFECTION_RADIUS as i32)..=(INFECTION_RADIUS as i32) + for dy in -INFECTION_RADIUS..=INFECTION_RADIUS { let manhattan_distance = dx.abs() + dy.abs(); - if manhattan_distance <= INFECTION_RADIUS as i32 + if manhattan_distance <= INFECTION_RADIUS { let check_pos = - GridPosition2D::new_2d(infectious_coord.x + dx, infectious_coord.y + dy); + GridPosition2D::new(infectious_coord.x + dx, infectious_coord.y + dy); nearby_entities.extend(spatial_grid.entities_at(&check_pos)); } } @@ -493,7 +487,7 @@ fn spatial_disease_transmission( if matches!(person.disease_state, DiseaseState::Healthy) { // Calculate infection probability based on distance - let distance = (*infectious_pos - **susceptible_pos).abs().element_sum() as u32; + let distance = (infectious_pos.0 - susceptible_pos.0).abs().element_sum(); let infection_chance = match distance { 0 | 1 => CHANCE_INFECT_AT_DISTANCE_1, // Same cell or adjacent @@ -567,7 +561,7 @@ fn update_contact_history(mut query: Query<(&GridPosition2D, &mut ContactHistory /// Process contact tracing when someone becomes infectious fn process_contact_tracing( mut commands: Commands, - spatial_grids: Query<&SpatialGrid, With>, + spatial_grid: Res>, query_newly_infectious: Query< (Entity, &GridPosition2D, &ContactHistory), (With, Without), @@ -575,12 +569,6 @@ fn process_contact_tracing( query_potential_contacts: Query, Without)>, ) { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; - if !CONTACT_TRACING_ENABLED { return; diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index b83cb89..620cad7 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -97,7 +97,10 @@ impl SimulationBuilder /// #[derive(Component)] /// struct Vehicle; /// - /// let bounds = GridBounds2D::new_2d(0, 99, 0, 99); + /// let bounds = GridBounds2D { + /// min: IVec2::new(0, 0), + /// max: IVec2::new(99, 99), + /// }; /// let simulation = SimulationBuilder::new() /// .add_spatial_grid::(bounds) /// .add_spatial_grid::(bounds) diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 0a1ed2d..76b1647 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -4,15 +4,10 @@ use bevy::prelude::{IVec2, IVec3}; use incerto::{ - plugins::{ - GridBounds2D, GridBounds3D, GridPosition2D, GridPosition3D, SpatialGrid, SpatialGridEntity, - }, + plugins::{GridBounds2D, GridBounds3D, GridPosition2D, GridPosition3D, SpatialGrid}, prelude::*, }; -#[derive(Component)] -struct TestEntity(i32); - #[test] fn test_grid_position_neighbors() { @@ -74,60 +69,31 @@ fn test_grid_position_distances() let pos1 = GridPosition2D::new(0, 0); let pos2 = GridPosition2D::new(3, 4); - // Test Manhattan distance using the trait method - assert_eq!(pos1.manhattan_distance(&pos2), 7); + // Test Manhattan distance + let diff = pos2.0 - pos1.0; + assert_eq!(diff.abs().element_sum(), 7); let pos3 = GridPosition2D::new(1, 1); - assert_eq!(pos1.manhattan_distance(&pos3), 2); + let diff2 = pos3.0 - pos1.0; + assert_eq!(diff2.abs().element_sum(), 2); } #[test] fn test_grid_bounds() { - let bounds = GridBounds2D::new(0, 9, 0, 9); - - assert_eq!(bounds.width(), 10); - assert_eq!(bounds.height(), 10); - assert_eq!(bounds.total_cells(), 100); - - assert!(bounds.contains(&GridPosition2D::new(0, 0))); - assert!(bounds.contains(&GridPosition2D::new(9, 9))); - assert!(bounds.contains(&GridPosition2D::new(5, 5))); - - assert!(!bounds.contains(&GridPosition2D::new(-1, 0))); - assert!(!bounds.contains(&GridPosition2D::new(0, -1))); - assert!(!bounds.contains(&GridPosition2D::new(10, 5))); - assert!(!bounds.contains(&GridPosition2D::new(5, 10))); -} - -#[test] -fn test_spatial_grid_plugin_integration() -{ - let _bounds = GridBounds2D::new(0, 2, 0, 2); - - let builder = SimulationBuilder::new() - .add_entity_spawner(|spawner| { - // Spawn entities with grid positions - spawner.spawn((GridPosition2D::new(0, 0), TestEntity(1))); - spawner.spawn((GridPosition2D::new(1, 1), TestEntity(2))); - spawner.spawn((GridPosition2D::new(2, 2), TestEntity(3))); - }) - .add_systems(|query: Query<(&GridPosition2D, &TestEntity)>| { - // Verify entities have grid positions and test data - for (position, test_entity) in &query - { - // Verify test entity data - assert!(test_entity.0 > 0); - assert!(position.x() >= 0 && position.x() <= 2); - assert!(position.y() >= 0 && position.y() <= 2); - } - }); - - let mut simulation = builder.build(); - simulation.run(1); - - // Test completed without panics, which means the spatial grid plugin - // is working correctly with the simulation systems + let bounds = GridBounds2D { + min: IVec2::new(0, 0), + max: IVec2::new(9, 9), + }; + + assert!(bounds.contains(&GridPosition2D::new(0, 0).0)); + assert!(bounds.contains(&GridPosition2D::new(9, 9).0)); + assert!(bounds.contains(&GridPosition2D::new(5, 5).0)); + + assert!(!bounds.contains(&GridPosition2D::new(-1, 0).0)); + assert!(!bounds.contains(&GridPosition2D::new(0, -1).0)); + assert!(!bounds.contains(&GridPosition2D::new(10, 5).0)); + assert!(!bounds.contains(&GridPosition2D::new(5, 10).0)); } #[test] @@ -151,18 +117,18 @@ fn test_3d_grid_position_neighbors() #[test] fn test_3d_grid_position_orthogonal_neighbors() { - let pos = GridPosition3D::new_3d(1, 1, 1); + let pos = GridPosition3D::new(1, 1, 1); let neighbors: Vec = pos.neighbors_orthogonal().collect(); assert_eq!(neighbors.len(), 6); // 6 orthogonal directions in 3D let expected_neighbors = [ - GridPosition3D::new_3d(0, 1, 1), // -x - GridPosition3D::new_3d(2, 1, 1), // +x - GridPosition3D::new_3d(1, 0, 1), // -y - GridPosition3D::new_3d(1, 2, 1), // +y - GridPosition3D::new_3d(1, 1, 0), // -z - GridPosition3D::new_3d(1, 1, 2), // +z + GridPosition3D::new(0, 1, 1), // -x + GridPosition3D::new(2, 1, 1), // +x + GridPosition3D::new(1, 0, 1), // -y + GridPosition3D::new(1, 2, 1), // +y + GridPosition3D::new(1, 1, 0), // -z + GridPosition3D::new(1, 1, 2), // +z ]; for expected in expected_neighbors @@ -178,36 +144,36 @@ fn test_3d_grid_position_orthogonal_neighbors() #[test] fn test_3d_grid_position_distances() { - let pos1 = GridPosition3D::new_3d(0, 0, 0); - let pos2 = GridPosition3D::new_3d(3, 4, 5); + let pos1 = GridPosition3D::new(0, 0, 0); + let pos2 = GridPosition3D::new(3, 4, 5); // Test 3D Manhattan distance - assert_eq!(pos1.manhattan_distance(&pos2), 12); // 3 + 4 + 5 = 12 + let diff = pos2.0 - pos1.0; + assert_eq!(diff.abs().element_sum(), 12); // 3 + 4 + 5 = 12 - let pos3 = GridPosition3D::new_3d(1, 1, 1); - assert_eq!(pos1.manhattan_distance(&pos3), 3); // 1 + 1 + 1 = 3 + let pos3 = GridPosition3D::new(1, 1, 1); + let diff2 = pos3.0 - pos1.0; + assert_eq!(diff2.abs().element_sum(), 3); // 1 + 1 + 1 = 3 } #[test] fn test_3d_grid_bounds() { - let bounds = GridBounds3D::new_3d(0, 9, 0, 9, 0, 9); - - assert_eq!(bounds.width(), 10); - assert_eq!(bounds.height(), 10); - assert_eq!(bounds.depth(), 10); - assert_eq!(bounds.total_cells(), 1000); - - assert!(bounds.contains(&GridPosition3D::new_3d(0, 0, 0))); - assert!(bounds.contains(&GridPosition3D::new_3d(9, 9, 9))); - assert!(bounds.contains(&GridPosition3D::new_3d(5, 5, 5))); - - assert!(!bounds.contains(&GridPosition3D::new_3d(-1, 0, 0))); - assert!(!bounds.contains(&GridPosition3D::new_3d(0, -1, 0))); - assert!(!bounds.contains(&GridPosition3D::new_3d(0, 0, -1))); - assert!(!bounds.contains(&GridPosition3D::new_3d(10, 5, 5))); - assert!(!bounds.contains(&GridPosition3D::new_3d(5, 10, 5))); - assert!(!bounds.contains(&GridPosition3D::new_3d(5, 5, 10))); + let bounds = GridBounds3D { + min: IVec3::new(0, 0, 0), + max: IVec3::new(9, 9, 9), + }; + + assert!(bounds.contains(&GridPosition3D::new(0, 0, 0).0)); + assert!(bounds.contains(&GridPosition3D::new(9, 9, 9).0)); + assert!(bounds.contains(&GridPosition3D::new(5, 5, 5).0)); + + assert!(!bounds.contains(&GridPosition3D::new(-1, 0, 0).0)); + assert!(!bounds.contains(&GridPosition3D::new(0, -1, 0).0)); + assert!(!bounds.contains(&GridPosition3D::new(0, 0, -1).0)); + assert!(!bounds.contains(&GridPosition3D::new(10, 5, 5).0)); + assert!(!bounds.contains(&GridPosition3D::new(5, 10, 5).0)); + assert!(!bounds.contains(&GridPosition3D::new(5, 5, 10).0)); } #[test] @@ -224,27 +190,25 @@ fn test_3d_spatial_grid_integration() } } - let bounds = GridBounds3D::new_3d(0, 4, 0, 4, 0, 4); + let bounds = GridBounds3D { + min: IVec3::new(0, 0, 0), + max: IVec3::new(4, 4, 4), + }; let builder = SimulationBuilder::new() .add_spatial_grid::(bounds) .add_entity_spawner(|spawner| { // Spawn entities at different 3D positions - spawner.spawn((GridPosition3D::new_3d(0, 0, 0), TestEntity3D(1))); - spawner.spawn((GridPosition3D::new_3d(2, 2, 2), TestEntity3D(2))); - spawner.spawn((GridPosition3D::new_3d(4, 4, 4), TestEntity3D(3))); - spawner.spawn((GridPosition3D::new_3d(1, 2, 3), TestEntity3D(4))); + spawner.spawn((GridPosition3D::new(0, 0, 0), TestEntity3D(1))); + spawner.spawn((GridPosition3D::new(2, 2, 2), TestEntity3D(2))); + spawner.spawn((GridPosition3D::new(4, 4, 4), TestEntity3D(3))); + spawner.spawn((GridPosition3D::new(1, 2, 3), TestEntity3D(4))); }) .add_systems( - |spatial_grids: Query<&SpatialGrid, With>, + |spatial_grid: Res>, query: Query<(Entity, &GridPosition3D, &TestEntity3D)>| { - let Ok(spatial_grid) = spatial_grids.single() - else - { - return; // Skip if spatial grid not found - }; // Test 3D spatial queries - let center_pos = GridPosition3D::new_3d(2, 2, 2); + let center_pos = GridPosition3D::new(2, 2, 2); // Find entities within distance 2 in 3D space using neighbor-based approach let mut nearby_entities = Vec::new(); @@ -260,7 +224,7 @@ fn test_3d_spatial_grid_integration() let manhattan_distance = dx.abs() + dy.abs() + dz.abs(); if manhattan_distance <= 2 { - let check_pos = GridPosition3D::new_3d( + let check_pos = GridPosition3D::new( center_coord.x + dx, center_coord.y + dy, center_coord.z + dz, @@ -291,7 +255,9 @@ fn test_3d_spatial_grid_integration() simulation.run(1); // Verify all entities were created - let entity_count = simulation.sample::().unwrap(); + let entity_count = simulation + .sample::() + .expect("Failed to sample TestEntity3D count"); assert_eq!(entity_count, 4); } @@ -310,15 +276,18 @@ fn test_spatial_grid_reset_functionality() } } - let bounds = GridBounds2D::new_2d(0, 4, 0, 4); + let bounds = GridBounds2D { + min: IVec2::new(0, 0), + max: IVec2::new(4, 4), + }; let mut simulation = SimulationBuilder::new() .add_spatial_grid::(bounds) .add_entity_spawner(|spawner| { // Spawn entities at different positions - spawner.spawn((GridPosition2D::new_2d(0, 0), TestResetEntity(1))); - spawner.spawn((GridPosition2D::new_2d(2, 2), TestResetEntity(2))); - spawner.spawn((GridPosition2D::new_2d(4, 4), TestResetEntity(3))); + spawner.spawn((GridPosition2D::new(0, 0), TestResetEntity(1))); + spawner.spawn((GridPosition2D::new(2, 2), TestResetEntity(2))); + spawner.spawn((GridPosition2D::new(4, 4), TestResetEntity(3))); }) .build(); @@ -326,7 +295,9 @@ fn test_spatial_grid_reset_functionality() simulation.run(2); // Verify entities are tracked - let entity_count = simulation.sample::().unwrap(); + let entity_count = simulation + .sample::() + .expect("Failed to sample TestResetEntity count"); assert_eq!(entity_count, 3); // Reset simulation (this should trigger spatial grid reset on step 0) @@ -334,6 +305,8 @@ fn test_spatial_grid_reset_functionality() simulation.run(1); // Verify entities are still tracked after reset - let entity_count_after_reset = simulation.sample::().unwrap(); + let entity_count_after_reset = simulation + .sample::() + .expect("Failed to sample TestResetEntity count after reset"); assert_eq!(entity_count_after_reset, 3); } From cf4552ad8ce294e4716c3d10e34cf1876069c053 Mon Sep 17 00:00:00 2001 From: rozgo Date: Sun, 6 Jul 2025 11:47:54 -0600 Subject: [PATCH 19/23] makes mod plugins private --- examples/air_traffic_3d.rs | 5 +---- examples/forest_fire.rs | 5 +---- examples/pandemic_spatial.rs | 5 +---- src/lib.rs | 6 +++++- src/prelude.rs | 3 ++- src/simulation_builder.rs | 1 - tests/test_spatial_grid.rs | 5 +---- 7 files changed, 11 insertions(+), 19 deletions(-) diff --git a/examples/air_traffic_3d.rs b/examples/air_traffic_3d.rs index b4656bb..0a6f8cf 100644 --- a/examples/air_traffic_3d.rs +++ b/examples/air_traffic_3d.rs @@ -8,10 +8,7 @@ #![allow(clippy::expect_used)] use bevy::prelude::*; -use incerto::{ - plugins::{GridBounds3D, GridPosition3D, SpatialGrid3D}, - prelude::*, -}; +use incerto::prelude::*; use rand::prelude::*; // Simulation parameters diff --git a/examples/forest_fire.rs b/examples/forest_fire.rs index f64a81d..2a2d0c3 100644 --- a/examples/forest_fire.rs +++ b/examples/forest_fire.rs @@ -25,10 +25,7 @@ use std::collections::HashSet; use bevy::prelude::IVec2; -use incerto::{ - plugins::{GridBounds2D, GridPosition2D, SpatialGrid}, - prelude::*, -}; +use incerto::prelude::*; use rand::prelude::*; // Simulation parameters diff --git a/examples/pandemic_spatial.rs b/examples/pandemic_spatial.rs index a8e0e10..f8f76da 100644 --- a/examples/pandemic_spatial.rs +++ b/examples/pandemic_spatial.rs @@ -22,10 +22,7 @@ use std::collections::HashSet; use bevy::prelude::IVec2; -use incerto::{ - plugins::{GridBounds2D, GridPosition2D, SpatialGrid}, - prelude::*, -}; +use incerto::prelude::*; use rand::prelude::*; // Simulation parameters diff --git a/src/lib.rs b/src/lib.rs index 393a6d3..d92f9fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,13 +16,17 @@ pub mod prelude; mod error; -pub mod plugins; +mod plugins; mod simulation; mod simulation_builder; mod spawner; mod traits; pub use error::*; +pub use plugins::{ + GridBounds, GridBounds2D, GridBounds3D, GridCoordinates, GridPosition, GridPosition2D, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, TimeSeries, +}; pub use simulation::Simulation; pub use simulation_builder::SimulationBuilder; pub use spawner::Spawner; diff --git a/src/prelude.rs b/src/prelude.rs index 8401918..e86f23d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -4,8 +4,9 @@ pub use bevy::prelude::{ }; pub use super::{ + GridBounds, GridBounds2D, GridBounds3D, GridCoordinates, GridPosition, GridPosition2D, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, TimeSeries, error::*, - plugins::{GridBounds2D, GridPosition2D}, simulation::Simulation, simulation_builder::SimulationBuilder, spawner::Spawner, diff --git a/src/simulation_builder.rs b/src/simulation_builder.rs index 620cad7..3fec802 100644 --- a/src/simulation_builder.rs +++ b/src/simulation_builder.rs @@ -90,7 +90,6 @@ impl SimulationBuilder /// ``` /// # use bevy::prelude::IVec2; /// # use incerto::prelude::*; - /// # use incerto::plugins::{GridBounds2D}; /// #[derive(Component)] /// struct Person; /// diff --git a/tests/test_spatial_grid.rs b/tests/test_spatial_grid.rs index 76b1647..7fbfab7 100644 --- a/tests/test_spatial_grid.rs +++ b/tests/test_spatial_grid.rs @@ -3,10 +3,7 @@ #![allow(clippy::cast_possible_truncation)] use bevy::prelude::{IVec2, IVec3}; -use incerto::{ - plugins::{GridBounds2D, GridBounds3D, GridPosition2D, GridPosition3D, SpatialGrid}, - prelude::*, -}; +use incerto::prelude::*; #[test] fn test_grid_position_neighbors() From 61449f9dbb65c864e605d5cad974b71460da465d Mon Sep 17 00:00:00 2001 From: rozgo Date: Sun, 6 Jul 2025 12:21:12 -0600 Subject: [PATCH 20/23] redundant remove --- src/plugins/spatial_grid.rs | 1 - src/prelude.rs | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 507fc3d..4b66ab6 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -437,7 +437,6 @@ fn spatial_grid_update_system( { for (entity, position) in &query { - spatial_grid.remove(entity); spatial_grid.insert(entity, *position); } } diff --git a/src/prelude.rs b/src/prelude.rs index e86f23d..235b3f2 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -5,10 +5,6 @@ pub use bevy::prelude::{ pub use super::{ GridBounds, GridBounds2D, GridBounds3D, GridCoordinates, GridPosition, GridPosition2D, - GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, TimeSeries, - error::*, - simulation::Simulation, - simulation_builder::SimulationBuilder, - spawner::Spawner, - traits::*, + GridPosition3D, SpatialGrid, SpatialGrid2D, SpatialGrid3D, TimeSeries, error::*, + simulation::Simulation, simulation_builder::SimulationBuilder, spawner::Spawner, traits::*, }; From 69b5c99278ef960883daf3807f100e1e5136517f Mon Sep 17 00:00:00 2001 From: rozgo Date: Sun, 6 Jul 2025 12:25:08 -0600 Subject: [PATCH 21/23] handle remove error --- src/plugins/spatial_grid.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index 4b66ab6..e490753 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -269,20 +269,20 @@ impl SpatialGrid /// Returns the position where the entity was located, if it was found. pub(crate) fn remove(&mut self, entity: Entity) -> Option> { - if let Some(position) = self.entity_to_position.remove(&entity) - && let Some(entities) = self.position_to_entities.get_mut(&position) - { - entities.remove(&entity); - if entities.is_empty() - { - self.position_to_entities.remove(&position); - } - Some(position) - } + let position = self.entity_to_position.remove(&entity)?; + + let Some(entities_at_position) = self.position_to_entities.get_mut(&position) else { - None + panic!("entity found in one hashmap but not the other?"); + }; + + entities_at_position.remove(&entity); + if entities_at_position.is_empty() + { + self.position_to_entities.remove(&position); } + Some(position) } /// Get all entities at a specific position. From df7abd0dadde3df942400a8ccb6bbd847ee319a6 Mon Sep 17 00:00:00 2001 From: rozgo Date: Sun, 6 Jul 2025 12:29:58 -0600 Subject: [PATCH 22/23] privatize api consistently --- src/plugins/spatial_grid.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/spatial_grid.rs b/src/plugins/spatial_grid.rs index e490753..c381a65 100644 --- a/src/plugins/spatial_grid.rs +++ b/src/plugins/spatial_grid.rs @@ -251,7 +251,7 @@ impl SpatialGrid } /// Add an entity at a specific grid position. - pub(crate) fn insert(&mut self, entity: Entity, position: GridPosition) + fn insert(&mut self, entity: Entity, position: GridPosition) { // Remove entity from old position if it exists self.remove(entity); @@ -267,7 +267,7 @@ impl SpatialGrid /// Remove an entity from the spatial index. /// /// Returns the position where the entity was located, if it was found. - pub(crate) fn remove(&mut self, entity: Entity) -> Option> + fn remove(&mut self, entity: Entity) -> Option> { let position = self.entity_to_position.remove(&entity)?; @@ -343,7 +343,7 @@ impl SpatialGrid } /// Clear all entities from the spatial index. - pub fn clear(&mut self) + fn clear(&mut self) { self.position_to_entities.clear(); self.entity_to_position.clear(); From 67b1d1a82faff0f2fdd293df6cc582d6e9d886e9 Mon Sep 17 00:00:00 2001 From: rozgo Date: Sun, 6 Jul 2025 12:33:51 -0600 Subject: [PATCH 23/23] deref fix --- src/plugins/time_series.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/time_series.rs b/src/plugins/time_series.rs index b12c9c2..0c6e24b 100644 --- a/src/plugins/time_series.rs +++ b/src/plugins/time_series.rs @@ -63,7 +63,7 @@ where ) { // only get new samples once every 'sample_interval' steps - if (**step_counter).is_multiple_of(time_series.sample_interval) + if step_counter.is_multiple_of(time_series.sample_interval) { let component_values = query.iter().collect::>();