diff --git a/Cargo.toml b/Cargo.toml index 5d99054..3b9b15e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "flockers_mpi", "forestfire", "forestfire_bayesian", + "n_bodies", "schelling", "sir_ga_exploration", "sugarscape", diff --git a/README.md b/README.md index 1314097..d3359ef 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,11 @@ This repository hosts working examples of agent-based simulations, based on the | [Flockers](flockers) | :heavy_check_mark: | drawing | | [ForestFire](forestfire) | :heavy_check_mark: | drawing | | [ForestFire_bayesian](forestfire_bayesian) | :heavy_check_mark: | :x: | +| [N Bodies](n_bodies) | :heavy_check_mark: | drawing | | [Schelling](schelling) | :heavy_check_mark: | drawing | -| [Sir_bayesian](sir_bayesian) | :heavy_check_mark: | :x: | -| [Sir_ga_exploration](sir_ga_exploration) | :heavy_check_mark: | :x: | -| [Sugarscape](sugarscape) | :heavy_check_mark: | drawing | +| [Sir_bayesian](sir_bayesian) | :heavy_check_mark: | :x: | +| [Sir_ga_exploration](sir_ga_exploration) | :heavy_check_mark: | :x: | +| [Sugarscape](sugarscape) | :heavy_check_mark: | drawing | | [Template](template) | :heavy_check_mark: | drawing | | [Virus on a Network](virusnetwork) | :heavy_check_mark: | drawing | | [Wolf Sheep Grass](wolfsheepgrass) | :heavy_check_mark: | drawing | diff --git a/n_bodies/.gitignore b/n_bodies/.gitignore new file mode 100644 index 0000000..551b4e4 --- /dev/null +++ b/n_bodies/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +.idea +/output \ No newline at end of file diff --git a/n_bodies/Cargo.toml b/n_bodies/Cargo.toml new file mode 100644 index 0000000..2b3725b --- /dev/null +++ b/n_bodies/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "n_bodies" +version = "0.1.0" +authors = [ + "Jean Claude Coppola " +] +edition = "2021" +license = "MIT" +readme = "README.md" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +krabmaga = "0.6.*" +bevy_egui = { version = "0.39", optional = true } + +[features] +visualization = ["krabmaga/visualization", "bevy_egui"] +visualization_wasm = ["krabmaga/visualization_wasm", "bevy_egui"] \ No newline at end of file diff --git a/n_bodies/Makefile.toml b/n_bodies/Makefile.toml new file mode 100644 index 0000000..5cf219a --- /dev/null +++ b/n_bodies/Makefile.toml @@ -0,0 +1,49 @@ +[env] +ENV_DIR = {source="${CARGO_MAKE_PROFILE}", default_value = "debug", mapping = {release = "release"}} +CARGO_TARGET_DIR = {value = "target", condition = {env_not_set = ["CARGO_TARGET_DIR"]}} +CARGO_WASM_PATH = "${CARGO_TARGET_DIR}/wasm32-unknown-unknown/${ENV_DIR}/${CARGO_MAKE_CRATE_NAME}.wasm" +CARGO_PROFILE = "dev" +TARGET_DIR = "target" +CARGO_MANIFEST_DIR = "${CARGO_MAKE_WORKING_DIRECTORY}" + +[env.release] +CARGO_RELEASE_ARGS = "--release" + +[tasks.show-env] +command = "env" + +[tasks.install-target-wasm32-unknown-unknown] +args = ["target", "install", "wasm32-unknown-unknown"] + +command = "rustup" +[tasks.basic-http-server] +install_crate = {crate_name = "basic-http-server", binary = "basic-http-server", test_arg="--help"} + +[tasks.wasm-bindgen-cli] +install_crate = {crate_name = "wasm-bindgen-cli", binary = "wasm-bindgen", test_arg="--help"} + +[tasks.cargo-build-web] +args = ["build", "--target", "wasm32-unknown-unknown", "--features", "visualization_wasm", "@@split(CARGO_RELEASE_ARGS, )"] +command = "cargo" +dependencies = ["install-target-wasm32-unknown-unknown"] + +[tasks.build-web] +args = ["--out-dir", "${TARGET_DIR}", "--out-name", "wasm", "--target", "web", "--no-typescript", "${CARGO_WASM_PATH}"] +command = "wasm-bindgen" +dependencies = ["cargo-build-web", "wasm-bindgen-cli"] + +[tasks.build-native] +args = ["build", "--features", "visualization", "@@split(CARGO_RELEASE_ARGS, )"] +command = "cargo" + +[tasks.run] +command = "${CARGO_TARGET_DIR}/${ENV_DIR}/${CARGO_MAKE_BINARY_EXECUTABLE_NAME}" +dependencies = ["build-native"] + +[tasks.serve] +command = "basic-http-server" +args = ["-x"] +dependencies = ["build-web", "basic-http-server"] + +[tasks.test] +disabled = true \ No newline at end of file diff --git a/n_bodies/README.md b/n_bodies/README.md new file mode 100644 index 0000000..5676e4f --- /dev/null +++ b/n_bodies/README.md @@ -0,0 +1,127 @@ +# N-Bodies + +A 2D gravitational N-body simulation written in Rust with the +[krABMaga](https://github.com/krABMaga/krABMaga) agent-based modeling framework +(Bevy-based visualization). +[N-Bodies](https://www.netlogoweb.org/launch#http://ccl.northwestern.edu/netlogo/models/models/Sample%20Models/Chemistry%20&%20Physics/Mechanics/Unverified/N-Bodies.nlogox) +model. + +![](nbodies1.gif) +![](nbodies2.gif) +![](nbodies3.gif) + +## Features + +- Every body is a krABMaga **agent** attracted by all the others (Newtonian gravity, O(N²)). +- **Trails**: the path of the first *N* bodies is drawn as fading polylines + (Bevy gizmos, no extra sprites), colored like the body they belong to. +- **Control window** (egui) to change the parameters live or regenerate the system. +- Four **initial configurations**: star with circular orbits, stable two-planet + system, random with zero momentum, symmetrical (zero-sum). +- Optional **keep centered** mode that pins the center of mass to the middle of the view. +- Negative `G` turns gravity into a repulsive force. + +## Requirements + +- Rust (stable) with `cargo` +- The system libraries Bevy needs on your platform + (see the [Bevy setup guide](https://bevy.org/learn/quick-start/getting-started/setup/)) + +## Run + +```bash +# Visualization (window with the simulation and the menus) +cargo run --release --features visualization + +# Headless (no window, runs 1000 steps) +cargo run --release +``` +Press **▶ Start** in the left panel to begin. + +`Cargo.toml` needs the egui dependency used by the menu: + +```toml +[dependencies] +krabmaga = "0.6" +bevy_egui = { version = "0.39", optional = true } + +[features] +visualization = ["krabmaga/visualization", "bevy_egui"] +visualization_wasm = ["krabmaga/visualization_wasm", "bevy_egui"] +``` + +Use the same `bevy_egui` version as krABMaga (check with +`cargo tree --features visualization -i bevy_egui`: only one version must appear). + +## How it works + +**Physics.** For each body *i* the acceleration is + +``` +a_i = Σ_j G · m_j · (r_j − r_i) / (|r_j − r_i|² + ε²)^(3/2) +``` + +where `ε` is the *softening* length, which avoids huge forces in close +encounters. Integration uses the semi-implicit (symplectic) Euler method: +`v ← v + a·dt`, then `x ← x + v·dt` with the new `v`. + +**Synchronous update.** All the physical data live in `NBodyState` +(single source of truth). During a step each `Body` agent reads the snapshot +`state.bodies` and writes its new data in `state.next`; in `after_step` the +state copies `next` into `bodies`. Every body therefore sees the same instant, +independently of the agents' execution order. The same hook recenters the +system (if enabled) and records the trail points. + +**Rendering.** Sprites read their position and size from the state every +frame. The planets are tinted with a per-body color (`body_color`), and the +trail of a body uses the same color with a fading alpha. + +## Menu + +The default krABMaga panel (left) provides Start / Pause / Stop and the steps +per second. **Stop** regenerates the system using the current parameters. +The floating **N-Bodies** window adds: + +| Parameter | Effect | Applied | +|---|---|---| +| Configuration | Initial layout (see below) | Setup | +| Number of bodies | Total number of bodies (2–300) | Setup | +| Central mass | Mass of the star (or mass scale in random setups) | Setup | +| Max planet mass | Upper bound of planet masses | Setup | +| Initial speed | Maximum initial speed in random setups | Setup | +| Bodies with trail | How many bodies get a trail | Setup | +| G | Gravitational constant (negative = repulsive) | Live | +| dt | Integration time step | Live | +| Softening | Smoothing length ε of the force | Live | +| Keep centered | Keep the center of mass in the middle of the view | Live | +| Show trails / Trail length | Toggle and length of the trails | Live | + +The window also shows how many bodies are currently **outside the view**. +After pressing **Setup** the simulation is paused: press ▶ Start again. + +### Configurations + +| Name | Description | +|---|---| +| Star + circular orbits | Heavy star at the center, planets on a spiral (golden angle) with circular-orbit speed | +| Two planets (stable) | Star and two planets on opposite sides, at different radii | +| Random (zero momentum) | Random positions, velocities and masses; net momentum removed so the system doesn't drift | +| Symmetrical (zero-sum) | Half of the bodies are random, the other half are their mirror image (opposite position and velocity) | + +## Tips + +- Bodies that "escape" are usually the effect of close encounters: increase + *Softening*, decrease *dt*, or lower *Max planet mass*. +- If nothing seems to move, check that the simulation is started and try a + larger `G` or `dt`. +- The simulation area is `980 × 720`: Bevy's default window is 1280 × 720 and + krABMaga reserves 300 px on the left for its panel, so this keeps the scale + 1:1. If you resize the window the scene is stretched accordingly. +- Sprites: `Emoji("sun")` and `Emoji("planet")` are loaded from + `assets/emojis/sun.png` and `assets/emojis/planet.png`. If you change the + image size, update `SPRITE_PX` in `visualization/body_vis.rs`. +- The default values (`G = 100`, `dt = 0.05`, softening `5`) are defined in + `Params::default()` in `model/state.rs`. + +## References +- http://ccl.northwestern.edu/netlogo/models/N-Bodies diff --git a/n_bodies/assets/emojis/planet.png b/n_bodies/assets/emojis/planet.png new file mode 100644 index 0000000..cf4e640 Binary files /dev/null and b/n_bodies/assets/emojis/planet.png differ diff --git a/n_bodies/assets/emojis/sun.png b/n_bodies/assets/emojis/sun.png new file mode 100644 index 0000000..ebefba6 Binary files /dev/null and b/n_bodies/assets/emojis/sun.png differ diff --git a/n_bodies/assets/fonts/FiraSans-Bold.ttf b/n_bodies/assets/fonts/FiraSans-Bold.ttf new file mode 100644 index 0000000..5c41e85 Binary files /dev/null and b/n_bodies/assets/fonts/FiraSans-Bold.ttf differ diff --git a/n_bodies/index.html b/n_bodies/index.html new file mode 100644 index 0000000..a99f749 --- /dev/null +++ b/n_bodies/index.html @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/n_bodies/nbodies1.gif b/n_bodies/nbodies1.gif new file mode 100644 index 0000000..d3c222b Binary files /dev/null and b/n_bodies/nbodies1.gif differ diff --git a/n_bodies/nbodies2.gif b/n_bodies/nbodies2.gif new file mode 100644 index 0000000..a3115fb Binary files /dev/null and b/n_bodies/nbodies2.gif differ diff --git a/n_bodies/nbodies3.gif b/n_bodies/nbodies3.gif new file mode 100644 index 0000000..9aa43ff Binary files /dev/null and b/n_bodies/nbodies3.gif differ diff --git a/n_bodies/src/main.rs b/n_bodies/src/main.rs new file mode 100644 index 0000000..bd51505 --- /dev/null +++ b/n_bodies/src/main.rs @@ -0,0 +1,55 @@ +mod model; +#[cfg(any(feature = "visualization", feature = "visualization_wasm"))] +mod visualization; + +// Simulation area. The default window of Bevy is 1280x720 and krABMaga +// reserves 300 px on the left for its panel, so (1280-300) x 720 gives a +// 1:1 scale (circles stay circles). +const DIM: (f32, f32) = (980.0, 720.0); + +#[cfg(not(any(feature = "visualization", feature = "visualization_wasm")))] +use krabmaga::*; + +#[cfg(not(any(feature = "visualization", feature = "visualization_wasm")))] +fn main() { + use crate::model::state::{NBodyState, SetupMode}; + + let mut state = NBodyState::new(DIM); // starts from Params::default() + + // Change whatever you want here, like the menu does in the window... + state.p.mode = SetupMode::Random; + state.p.number = 100; + state.p.g = 100.0; + state.p.dt = 0.05; + + // ...then regenerate the bodies: setup() reads the parameters above. + state.setup(); + + // simulate!(state, steps, repetitions) + simulate!(state, 1000, 1); +} + +#[cfg(any(feature = "visualization", feature = "visualization_wasm"))] +fn main() { + use bevy_egui::EguiPrimaryContextPass; + use krabmaga::bevy::prelude::{Color, Update}; + use krabmaga::visualization::visualization::Visualization; + + use crate::model::state::NBodyState; + use crate::visualization::{menu::menu_system, trails::draw_trails, vis_state::NBodyVis}; + + let state = NBodyState::new(DIM); + + // `setup` (instead of `start`) returns the Bevy App, so we can add our own systems. + let mut app = Visualization::default() + .with_window_dimensions(1280., 720.) + .with_simulation_dimensions(DIM.0, DIM.1) + .with_background_color(Color::srgb(0.02, 0.02, 0.08)) + .with_name("N-Bodies") + .setup::(NBodyVis, state); + + app.add_systems(Update, draw_trails) + .add_systems(EguiPrimaryContextPass, menu_system); + + app.run(); +} diff --git a/n_bodies/src/model/body.rs b/n_bodies/src/model/body.rs new file mode 100644 index 0000000..5f674d4 --- /dev/null +++ b/n_bodies/src/model/body.rs @@ -0,0 +1,67 @@ +use krabmaga::engine::agent::Agent; +use krabmaga::engine::location::Real2D; +use krabmaga::engine::state::State; + +use crate::model::state::NBodyState; + +//Physical Data of one body -> it lives in the state so that it can be modified and read by the menu and visualization +#[derive(Clone, Copy)] +pub struct BodyData { + pub mass: f32, + pub radius: f32, + pub pos: Real2D, + pub vel: Real2D, + pub is_star: bool, +} + +/// The agent only knows its index: it reads the snapshot `state.bodies` +/// (positions at the beginning of the step) and writes its new data into +/// `state.next`. `NBodyState::after_step` then publishes `next` -> `bodies`, +/// so every body sees the same instant (synchronous update). +#[derive(Clone, Copy)] +pub struct Body { + pub id: u32, +} + +impl Agent for Body { + fn step(&mut self, state: &mut dyn State) { + let s = state + .as_any_mut() + .downcast_mut::() + .expect("state is not NBodyState"); + + let i = self.id as usize; + let me = s.bodies[i]; + let eps2 = s.p.softening * s.p.softening; + + // Acceleration = sum_j G * m_j * (r_j - r_i) / (|r|^2 + eps^2)^(3/2) + // (my own mass cancels out: F = m*a) + let mut ax = 0.0f32; + let mut ay = 0.0f32; + + for (j, other) in s.bodies.iter().enumerate() { + if j == i { + continue; + } + let dx = other.pos.x - me.pos.x; + let dy = other.pos.y - me.pos.y; + let d2 = dx * dx + dy * dy + eps2; + if d2 < 1e-9 { + continue; + } + let k = s.p.g * other.mass / (d2 * d2.sqrt()); + ax += k * dx; + ay += k * dy; + } + + // Semi-implicit Euler: v first, then x with the NEW v. + let dt = s.p.dt; + let mut new = me; + new.vel.x += ax * dt; + new.vel.y += ay * dt; + new.pos.x += new.vel.x * dt; + new.pos.y += new.vel.y * dt; + + s.next[i] = new; + } +} diff --git a/n_bodies/src/model/mod.rs b/n_bodies/src/model/mod.rs new file mode 100644 index 0000000..cad0c32 --- /dev/null +++ b/n_bodies/src/model/mod.rs @@ -0,0 +1,2 @@ +pub mod body; +pub mod state; diff --git a/n_bodies/src/model/state.rs b/n_bodies/src/model/state.rs new file mode 100644 index 0000000..b7f15c8 --- /dev/null +++ b/n_bodies/src/model/state.rs @@ -0,0 +1,330 @@ +use std::any::Any; +use std::collections::VecDeque; +use std::f32::consts::TAU; + +use krabmaga::engine::location::Real2D; +use krabmaga::engine::schedule::Schedule; +use krabmaga::engine::state::State; +use krabmaga::rand; +use krabmaga::Rng; + +use crate::model::body::{Body, BodyData}; + +// to generate the angle and get the most irrational number +const GOLDEN_ANGLE: f32 = 2.399_963; + +const STAR_RADIUS: f32 = 8.0; +/// A trail point is recorded every TRAIL_EVERY steps. +const TRAIL_EVERY: u64 = 2; + +/// Initial configurations +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SetupMode { + SolarSystem, + TwoPlanets, + Random, + Symmetrical, +} + +impl SetupMode { + pub const ALL: [SetupMode; 4] = [ + SetupMode::SolarSystem, + SetupMode::TwoPlanets, + SetupMode::Random, + SetupMode::Symmetrical, + ]; + + pub fn label(&self) -> &'static str { + match self { + SetupMode::SolarSystem => "Star + circular orbits", + SetupMode::TwoPlanets => "Two planets (stable)", + SetupMode::Random => "Random (zero momentum)", + SetupMode::Symmetrical => "Symmetrical (zero-sum)", + } + } +} + +// Everything the menu can change. +// Fields marked "setup" are read only when the system is (re)generated, +// the others act immediately. +#[derive(Clone)] +pub struct Params { + // --- setup --- + pub mode: SetupMode, + pub number: usize, + pub star_mass: f32, + pub max_planet_mass: f32, + pub init_speed: f32, + pub tracked: usize, + // --- live --- + pub g: f32, + pub dt: f32, + pub softening: f32, + pub keep_centered: bool, + pub show_trails: bool, + pub trail_len: usize, +} + +impl Default for Params { + fn default() -> Self { + Params { + mode: SetupMode::SolarSystem, + number: 30, + star_mass: 1000.0, + max_planet_mass: 10.0, + init_speed: 20.0, + tracked: 8, + g: 100.0, + dt: 0.05, + softening: 5.0, + keep_centered: true, + show_trails: true, + trail_len: 200, + } + } +} + +pub struct NBodyState { + pub p: Params, + pub dim: (f32, f32), + // Snapshot read by every agent during a step. + pub bodies: Vec, + // Buffer written by the agents during a step. + pub next: Vec, + // One trail per tracked body: (x, y) points, oldest first. + pub trails: Vec>, + // Index of the body that owns trails[0] (1 when body 0 is the star). + pub trail_first: usize, + pub ticks: u64, +} + +impl NBodyState { + pub fn new(dim: (f32, f32)) -> Self { + let mut s = NBodyState { + p: Params::default(), + dim, + bodies: Vec::new(), + next: Vec::new(), + trails: Vec::new(), + trail_first: 0, + ticks: 0, + }; + s.setup(); + s + } + + fn planet_radius(mass: f32) -> f32 { + 2.0 + 0.6 * mass.sqrt() + } + + // (Re)generate all the bodies from the current `Params`. + pub fn setup(&mut self) { + let (w, h) = self.dim; + let (cx, cy) = (w / 2.0, h / 2.0); + let p = self.p.clone(); + let mut rng = rand::rng(); + let mut bodies: Vec = Vec::new(); + + let r_min = 40.0f32; + let r_max = (w.min(h) / 2.0 - 20.0).max(r_min + 10.0); + let eps2 = p.softening * p.softening; + + // Speed of a circular orbit of radius r around the (softened) star. + let circular_speed = |r: f32| -> f32 { + (p.g * p.star_mass * r * r / (r * r + eps2).powf(1.5)) + .max(0.0) + .sqrt() + }; + + match p.mode { + SetupMode::SolarSystem | SetupMode::TwoPlanets => { + bodies.push(BodyData { + mass: p.star_mass, + radius: STAR_RADIUS, + pos: Real2D { x: cx, y: cy }, + vel: Real2D { x: 0.0, y: 0.0 }, + is_star: true, + }); + + let two = p.mode == SetupMode::TwoPlanets; + let n_planets = if two { 2 } else { p.number.saturating_sub(1) }; + + for i in 0..n_planets { + let (r, a, m) = if two { + ( + r_max * (0.45 + 0.4 * i as f32), + std::f32::consts::PI * i as f32, + p.max_planet_mass * (1.0 - 0.5 * i as f32), + ) + } else { + let t = (i as f32 + 1.0) / (n_planets as f32 + 1.0); + ( + r_min + (r_max - r_min) * t.sqrt(), + (i as f32 + 1.0) * GOLDEN_ANGLE, + rng.random_range(1.0..p.max_planet_mass.max(1.5)), + ) + }; + let v = circular_speed(r); + bodies.push(BodyData { + mass: m, + radius: Self::planet_radius(m), + pos: Real2D { + x: cx + r * a.cos(), + y: cy + r * a.sin(), + }, + vel: Real2D { + x: -a.sin() * v, + y: a.cos() * v, + }, + is_star: false, + }); + } + } + + SetupMode::Random | SetupMode::Symmetrical => { + let sym = p.mode == SetupMode::Symmetrical; + let n_gen = if sym { + (p.number / 2).max(1) + } else { + p.number.max(2) + }; + + for _ in 0..n_gen { + let r = r_max * rng.random_range(0.0f32..1.0).sqrt(); + let a = rng.random_range(0.0f32..TAU); + let speed = p.init_speed * rng.random_range(0.0f32..1.0); + let sa = rng.random_range(0.0f32..TAU); + let m = rng.random_range(p.star_mass * 0.02..p.star_mass * 0.2); + bodies.push(BodyData { + mass: m, + radius: Self::planet_radius(m), + pos: Real2D { + x: cx + r * a.cos(), + y: cy + r * a.sin(), + }, + vel: Real2D { + x: speed * sa.cos(), + y: speed * sa.sin(), + }, + is_star: false, + }); + } + + if sym { + // Mirror every body through the centre: total momentum = 0 + // and the centre of mass stays in the middle. + let half = bodies.clone(); + for b in half { + bodies.push(BodyData { + pos: Real2D { + x: 2.0 * cx - b.pos.x, + y: 2.0 * cy - b.pos.y, + }, + vel: Real2D { + x: -b.vel.x, + y: -b.vel.y, + }, + ..b + }); + } + } else { + // Remove the net momentum so the whole system doesn't drift. + let m_tot: f32 = bodies.iter().map(|b| b.mass).sum(); + let px: f32 = bodies.iter().map(|b| b.mass * b.vel.x).sum::() / m_tot; + let py: f32 = bodies.iter().map(|b| b.mass * b.vel.y).sum::() / m_tot; + for b in bodies.iter_mut() { + b.vel.x -= px; + b.vel.y -= py; + } + } + } + } + + let first = if bodies.first().map_or(false, |b| b.is_star) { + 1 + } else { + 0 + }; + let tracked = p.tracked.min(bodies.len().saturating_sub(first)); + + self.trails = vec![VecDeque::new(); tracked]; + self.trail_first = first; + self.next = bodies.clone(); + self.bodies = bodies; + self.ticks = 0; + } + + // keep-centered: move everything so the centre of mass + // stays in the middle of the view. + fn recenter(&mut self) { + let m_tot: f32 = self.bodies.iter().map(|b| b.mass).sum(); + if m_tot.abs() < 1e-6 { + return; + } + let cx = self.bodies.iter().map(|b| b.mass * b.pos.x).sum::() / m_tot; + let cy = self.bodies.iter().map(|b| b.mass * b.pos.y).sum::() / m_tot; + let tx = self.dim.0 / 2.0 - cx; + let ty = self.dim.1 / 2.0 - cy; + for b in self.bodies.iter_mut() { + b.pos.x += tx; + b.pos.y += ty; + } + } + + fn record_trails(&mut self) { + let first = self.trail_first; + let max = self.p.trail_len; + for (k, trail) in self.trails.iter_mut().enumerate() { + let b = &self.bodies[first + k]; + trail.push_back((b.pos.x, b.pos.y)); + while trail.len() > max { + trail.pop_front(); + } + } + } +} + +impl State for NBodyState { + // It Registers an agent Body {id} for each body + fn init(&mut self, schedule: &mut Schedule) { + for id in 0..self.bodies.len() { + schedule.schedule_repeating(Box::new(Body { id: id as u32 }), 0.0, 0); + } + } + + fn update(&mut self, _step: u64) {} + + // Called once per step, after ALL agents have stepped. + // we copy `next` into `bodies` + fn after_step(&mut self, _schedule: &mut Schedule) { + self.bodies.copy_from_slice(&self.next); + if self.p.keep_centered { + self.recenter(); + } + self.ticks += 1; + if self.ticks % TRAIL_EVERY == 0 { + self.record_trails(); + } + } + + // Called by the "Stop" button of the default panel (and by my "Setup"). + fn reset(&mut self) { + self.setup(); + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn as_state(&self) -> &(dyn State + 'static) { + self + } + + fn as_state_mut(&mut self) -> &mut (dyn State + 'static) { + self + } +} diff --git a/n_bodies/src/visualization/body_vis.rs b/n_bodies/src/visualization/body_vis.rs new file mode 100644 index 0000000..f12ef08 --- /dev/null +++ b/n_bodies/src/visualization/body_vis.rs @@ -0,0 +1,63 @@ +use krabmaga::bevy::prelude::{Transform, Vec3, Visibility}; +use krabmaga::engine::agent::Agent; +use krabmaga::engine::state::State; +use krabmaga::visualization::agent_render::{AgentRender, SpriteType}; + +use crate::model::body::BodyData; +use crate::model::state::NBodyState; + +/// Side (in pixels) of assets/emojis/sun.png and planet.png. +const SPRITE_PX: f32 = 64.0; +const OFFSCREEN: f32 = -100_000.0; + +fn data(state: &Box<&dyn State>, id: u32) -> Option { + let s = state.as_any().downcast_ref::()?; + s.bodies.get(id as usize).copied() +} + +#[derive(Clone)] +pub struct BodyVis { + pub id: u32, +} + +impl AgentRender for BodyVis { + fn sprite(&self, _agent: &Box, state: &Box<&dyn State>) -> SpriteType { + let star = data(state, self.id).map_or(false, |d| d.is_star); + SpriteType::Emoji(String::from(if star { "sun" } else { "planet" })) + } + + fn location(&self, _agent: &Box, state: &Box<&dyn State>) -> (f32, f32, f32) { + data(state, self.id) + .map(|d| (d.pos.x, d.pos.y, if d.is_star { 1.0 } else { 0.0 })) + .unwrap_or((OFFSCREEN, OFFSCREEN, 0.0)) + } + + fn scale(&self, _agent: &Box, state: &Box<&dyn State>) -> (f32, f32) { + let k = data(state, self.id).map_or(0.0, |d| 2.0 * d.radius / SPRITE_PX); + (k, k) + } + + fn rotation(&self, _agent: &Box, _state: &Box<&dyn State>) -> f32 { + 0.0 + } + + fn get_id(&self) -> u32 { + self.id + } + + fn update( + &mut self, + _agent: &Box, + transform: &mut Transform, + state: &Box<&dyn State>, + visible: &mut Visibility, + ) { + if let Some(d) = data(state, self.id) { + let k = 2.0 * d.radius / SPRITE_PX; + transform.translation.x = d.pos.x; + transform.translation.y = d.pos.y; + transform.scale = Vec3::new(k, k, 1.0); + *visible = Visibility::Visible; + } + } +} diff --git a/n_bodies/src/visualization/colors.rs b/n_bodies/src/visualization/colors.rs new file mode 100644 index 0000000..a6473f4 --- /dev/null +++ b/n_bodies/src/visualization/colors.rs @@ -0,0 +1,6 @@ +use krabmaga::bevy::prelude::Color; + +// mol by 47 to have different colors especially for similar near ID +pub fn body_color(id: u32, alpha: f32) -> Color { + Color::hsla((id as f32 * 47.0) % 360.0, 0.85, 0.65, alpha) +} diff --git a/n_bodies/src/visualization/menu.rs b/n_bodies/src/visualization/menu.rs new file mode 100644 index 0000000..1608b4c --- /dev/null +++ b/n_bodies/src/visualization/menu.rs @@ -0,0 +1,111 @@ +use bevy_egui::{egui, EguiContexts}; +use krabmaga::bevy::prelude::{Commands, Entity, Query, Res, ResMut, With}; +use krabmaga::engine::schedule::Schedule; +use krabmaga::engine::state::State; +use krabmaga::visualization::asset_handle_factory::AssetHandleFactoryResource; +use krabmaga::visualization::simulation_descriptor::SimulationDescriptor; +use krabmaga::visualization::visualization_state::VisualizationState; +use krabmaga::visualization::wrappers::{ActiveSchedule, ActiveState, SimulationRenderEntity}; + +use crate::model::state::{NBodyState, SetupMode}; +use crate::visualization::vis_state::NBodyVis; + +/// Floating control window (the default krABMaga panel on the left keeps +/// Start / Pause / Stop and the "steps per second" slider). +pub fn menu_system( + mut egui_context: EguiContexts, + mut sim_data: ResMut, + active_schedule: Res, + active_state: Res>, + on_init: Res, + mut sprite_factory: AssetHandleFactoryResource, + query: Query>, + mut commands: Commands, +) { + let Ok(ctx) = egui_context.ctx_mut() else { + return; + }; + + let mut do_setup = false; + + { + let mut s = active_state.0.lock().expect("error on lock"); + let n = s.bodies.len(); + let (w, h) = s.dim; + let outside = s + .bodies + .iter() + .filter(|b| b.pos.x < 0.0 || b.pos.x > w || b.pos.y < 0.0 || b.pos.y > h) + .count(); + let p = &mut s.p; + + egui::Window::new("N-Bodies") + .default_pos(egui::pos2(320.0, 12.0)) + .show(ctx, |ui| { + ui.label(format!("Bodies: {n} outside the view: {outside}")); + ui.separator(); + + ui.strong("Initial conditions (apply with Setup)"); + egui::ComboBox::from_label("Configuration") + .selected_text(p.mode.label()) + .show_ui(ui, |ui| { + for m in SetupMode::ALL { + ui.selectable_value(&mut p.mode, m, m.label()); + } + }); + ui.add(egui::Slider::new(&mut p.number, 2..=300).text("Number of bodies")); + ui.add(egui::Slider::new(&mut p.star_mass, 10.0..=5000.0).text("Central mass")); + ui.add( + egui::Slider::new(&mut p.max_planet_mass, 1.0..=100.0).text("Max planet mass"), + ); + ui.add( + egui::Slider::new(&mut p.init_speed, 0.0..=60.0).text("Initial speed (random)"), + ); + ui.add(egui::Slider::new(&mut p.tracked, 0..=50).text("Bodies with trail")); + + if ui.button("⟲ Setup").clicked() { + do_setup = true; + } + ui.label("then press ▶ Start in the left panel"); + + ui.separator(); + ui.strong("Live parameters"); + ui.add( + egui::Slider::new(&mut p.g, -300.0..=500.0).text("G (negative = repulsive)"), + ); + ui.add( + egui::Slider::new(&mut p.dt, 0.005..=0.2) + .logarithmic(true) + .text("dt"), + ); + ui.add(egui::Slider::new(&mut p.softening, 0.0..=30.0).text("Softening")); + ui.checkbox(&mut p.keep_centered, "Keep centered (centre of mass)"); + ui.checkbox(&mut p.show_trails, "Show trails"); + ui.add(egui::Slider::new(&mut p.trail_len, 0..=1000).text("Trail length")); + }); + } + + if do_setup { + // Same sequence used by the "Stop" button of krABMaga's default panel. + sim_data.paused = true; + for entity in query.iter() { + commands.entity(entity).despawn(); + } + + let mut new_schedule = Schedule::new(); + { + let mut s = active_state.0.lock().expect("error on lock"); + s.reset(); + s.init(&mut new_schedule); + on_init.on_init( + &mut commands, + &mut sprite_factory, + &mut s, + &mut new_schedule, + &mut *sim_data, + ); + on_init.setup_graphics(&mut new_schedule, &mut commands, &mut s, sprite_factory); + } + *active_schedule.0.lock().expect("error on lock") = new_schedule; + } +} diff --git a/n_bodies/src/visualization/mod.rs b/n_bodies/src/visualization/mod.rs new file mode 100644 index 0000000..ae52f01 --- /dev/null +++ b/n_bodies/src/visualization/mod.rs @@ -0,0 +1,5 @@ +pub mod body_vis; +pub mod colors; +pub mod menu; +pub mod trails; +pub mod vis_state; diff --git a/n_bodies/src/visualization/trails.rs b/n_bodies/src/visualization/trails.rs new file mode 100644 index 0000000..b199649 --- /dev/null +++ b/n_bodies/src/visualization/trails.rs @@ -0,0 +1,33 @@ +use krabmaga::bevy::prelude::{Gizmos, Res, Vec2}; +use krabmaga::visualization::wrappers::ActiveState; + +use crate::model::state::NBodyState; +use crate::visualization::colors::body_color; + +// Bevy system (runs every frame): draws every trail as a polyline +// whose segments fade from transparent (oldest) to opaque (newest). +pub fn draw_trails(mut gizmos: Gizmos, state: Res>) { + let s = state.0.lock().expect("error on lock"); + if !s.p.show_trails { + return; + } + + for (k, trail) in s.trails.iter().enumerate() { + let n = trail.len(); + if n < 2 { + continue; + } + let id = (s.trail_first + k) as u32; + + let mut prev: Option = None; + for (i, &(x, y)) in trail.iter().enumerate() { + // +0.5: the framework draws sprites half a unit higher + let point = Vec2::new(x, y + 0.5); + if let Some(q) = prev { + let alpha = 0.05 + 0.85 * (i as f32 / n as f32); + gizmos.line_2d(q, point, body_color(id, alpha)); + } + prev = Some(point); + } + } +} diff --git a/n_bodies/src/visualization/vis_state.rs b/n_bodies/src/visualization/vis_state.rs new file mode 100644 index 0000000..c0bd8f9 --- /dev/null +++ b/n_bodies/src/visualization/vis_state.rs @@ -0,0 +1,88 @@ +use krabmaga::bevy::ecs as bevy_ecs; +use krabmaga::bevy::prelude::{Commands, Quat, Resource, Sprite, Transform, Vec3}; +use krabmaga::engine::agent::Agent; +use krabmaga::engine::schedule::Schedule; +use krabmaga::engine::state::State; +use krabmaga::visualization::agent_render::{AgentRender, AgentRenderComponent}; +use krabmaga::visualization::asset_handle_factory::AssetHandleFactoryResource; +use krabmaga::visualization::simulation_descriptor::SimulationDescriptor; +use krabmaga::visualization::visualization_state::VisualizationState; +use krabmaga::visualization::wrappers::SimulationRenderEntity; + +use crate::model::body::Body; +use crate::model::state::NBodyState; +use crate::visualization::body_vis::BodyVis; +use crate::visualization::colors::body_color; + +#[derive(Clone, Resource)] +pub struct NBodyVis; + +impl VisualizationState for NBodyVis { + fn on_init( + &self, + _commands: &mut Commands, + _factory: &mut AssetHandleFactoryResource, + _state: &mut NBodyState, + _schedule: &mut Schedule, + _sim: &mut SimulationDescriptor, + ) { + } + + fn get_agent_render( + &self, + agent: &Box, + _state: &NBodyState, + ) -> Option> { + let b = agent.downcast_ref::()?; + Some(Box::new(BodyVis { id: b.id })) + } + + fn get_agent( + &self, + agent_render: &dyn AgentRender, + state: &Box<&dyn State>, + ) -> Option> { + let s = state.as_any().downcast_ref::()?; + let id = agent_render.get_id(); + if (id as usize) < s.bodies.len() { + Some(Box::new(Body { id })) + } else { + None + } + } + + // Same as the default implementation of the trait, but every planet + // sprite is tinted with the colour of its trail. + fn setup_agent_graphics( + &self, + agent: &Box, + agent_render: Box, + mut sprite: Sprite, + commands: &mut Commands, + state: &Box<&dyn State>, + ) { + let id = agent_render.get_id(); + let is_star = state + .as_any() + .downcast_ref::() + .and_then(|s| s.bodies.get(id as usize).map(|b| b.is_star)) + .unwrap_or(false); + if !is_star { + sprite.color = body_color(id, 1.0); + } + + let (x, y, z) = agent_render.location(agent, state); + let (sx, sy) = agent_render.scale(agent, state); + let rotation = agent_render.rotation(agent, state); + + let mut transform = Transform::from_translation(Vec3::new(x, y + 0.5, z)); + transform.scale.x = sx; + transform.scale.y = sy; + transform.rotation = Quat::from_rotation_z(rotation); + + commands + .spawn((sprite, transform)) + .insert(AgentRenderComponent(agent_render)) + .insert(SimulationRenderEntity); + } +}