From c79c3f1b24b5c4c566639fa7753e0daf79e59125 Mon Sep 17 00:00:00 2001 From: Oriah Ulrich Date: Sat, 4 May 2024 19:04:42 -0600 Subject: [PATCH] add boids example from yew: https://yew.rs/docs/getting-started/examples --- client/Cargo.toml | 14 ++- client/index.scss | 2 +- client/src/boid.rs | 208 +++++++++++++++++++++++++++++++++++++++ client/src/main.rs | 181 +++++++++++++++++++++++++++++----- client/src/math.rs | 172 ++++++++++++++++++++++++++++++++ client/src/settings.rs | 60 +++++++++++ client/src/simulation.rs | 110 +++++++++++++++++++++ client/src/slider.rs | 92 +++++++++++++++++ 8 files changed, 815 insertions(+), 24 deletions(-) create mode 100644 client/src/boid.rs create mode 100644 client/src/math.rs create mode 100644 client/src/settings.rs create mode 100644 client/src/simulation.rs create mode 100644 client/src/slider.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 7e1f332..edd9d43 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -6,4 +6,16 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -yew = { features = ["csr"] } \ No newline at end of file +anyhow = "1.0" +imp = "0.1.0" +rand = "0.8.5" +serde = { version = "1.0", features = ["derive"] } +getrandom = { version = "0.2", features = ["js"] } +yew = { features = ["csr"] } +gloo = "0.10" + +[dependencies.web-sys] +version = "0.3" +features = [ + "HtmlInputElement", +] \ No newline at end of file diff --git a/client/index.scss b/client/index.scss index 2446530..ea48d49 100644 --- a/client/index.scss +++ b/client/index.scss @@ -9,7 +9,7 @@ body { display: flex; justify-content: center; - background: linear-gradient(to bottom right, #444444, #009a5b); + background: linear-gradient(to bottom right, #be99eb, #9c8eed); font-size: 1.5rem; } diff --git a/client/src/boid.rs b/client/src/boid.rs new file mode 100644 index 0000000..88050bc --- /dev/null +++ b/client/src/boid.rs @@ -0,0 +1,208 @@ +use std::fmt::Write; +use std::iter; + +use rand::Rng; +use yew::{html, Html}; + +use crate::math::{self, Mean, Vector2D, WeightedMean}; +use crate::settings::Settings; +use crate::simulation::SIZE; + +#[derive(Clone, Debug, PartialEq)] +pub struct Boid { + position: Vector2D, + velocity: Vector2D, + radius: f64, + hue: f64, +} + +impl Boid { + pub fn new_random(settings: &Settings) -> Self { + let mut rng = rand::thread_rng(); + + let max_radius = settings.min_distance / 2.0; + let min_radius = max_radius / 6.0; + // by using the third power large boids become rarer + let radius = min_radius + rng.gen::().powi(3) * (max_radius - min_radius); + + Self { + position: Vector2D::new(rng.gen::() * SIZE.x, rng.gen::() * SIZE.y), + velocity: Vector2D::from_polar(rng.gen::() * math::TAU, settings.max_speed), + radius, + hue: rng.gen::() * math::TAU, + } + } + + fn coherence(&self, boids: VisibleBoidIter, factor: f64) -> Vector2D { + Vector2D::weighted_mean( + boids.map(|other| (other.boid.position, other.boid.radius * other.boid.radius)), + ) + .map(|mean| (mean - self.position) * factor) + .unwrap_or_default() + } + + fn separation(&self, boids: VisibleBoidIter, settings: &Settings) -> Vector2D { + let accel = boids + .filter_map(|other| { + if other.distance > settings.min_distance { + None + } else { + Some(-other.offset) + } + }) + .sum::(); + accel * settings.separation_factor + } + + fn alignment(&self, boids: VisibleBoidIter, factor: f64) -> Vector2D { + Vector2D::mean(boids.map(|other| other.boid.velocity)) + .map(|mean| (mean - self.velocity) * factor) + .unwrap_or_default() + } + + fn adapt_color(&mut self, boids: VisibleBoidIter, factor: f64) { + let mean = f64::mean(boids.filter_map(|other| { + if other.boid.radius > self.radius { + Some(math::smallest_angle_between(self.hue, other.boid.hue)) + } else { + None + } + })); + if let Some(avg_hue_offset) = mean { + self.hue += avg_hue_offset * factor; + } + } + + fn keep_in_bounds(&mut self, settings: &Settings) { + let min = SIZE * settings.border_margin; + let max = SIZE - min; + + let mut v = Vector2D::default(); + + let turn_speed = self.velocity.magnitude() * settings.turn_speed_ratio; + let pos = self.position; + if pos.x < min.x { + v.x += turn_speed; + } + if pos.x > max.x { + v.x -= turn_speed + } + + if pos.y < min.y { + v.y += turn_speed; + } + if pos.y > max.y { + v.y -= turn_speed; + } + + self.velocity += v; + } + + fn update_velocity(&mut self, settings: &Settings, boids: VisibleBoidIter) { + let v = self.velocity + + self.coherence(boids.clone(), settings.cohesion_factor) + + self.separation(boids.clone(), settings) + + self.alignment(boids, settings.alignment_factor); + self.velocity = v.clamp_magnitude(settings.max_speed); + } + + fn update(&mut self, settings: &Settings, boids: VisibleBoidIter) { + self.adapt_color(boids.clone(), settings.color_adapt_factor); + self.update_velocity(settings, boids); + self.keep_in_bounds(settings); + self.position += self.velocity; + } + + pub fn update_all(settings: &Settings, boids: &mut [Self]) { + for i in 0..boids.len() { + let (before, after) = boids.split_at_mut(i); + let (boid, after) = after.split_first_mut().unwrap(); + let visible_boids = + VisibleBoidIter::new(before, after, boid.position, settings.visible_range); + + boid.update(settings, visible_boids); + } + } + + pub fn render(&self) -> Html { + let color = format!("hsl({:.3}rad, 100%, 50%)", self.hue); + + let mut points = String::new(); + for offset in iter_shape_points(self.radius, self.velocity.angle()) { + let Vector2D { x, y } = self.position + offset; + + // Write to string will never fail. + let _ = write!(points, "{x:.2},{y:.2} "); + } + + html! { } + } +} + +fn iter_shape_points(radius: f64, rotation: f64) -> impl Iterator { + const SHAPE: [(f64, f64); 3] = [ + (0. * math::FRAC_TAU_3, 2.0), + (1. * math::FRAC_TAU_3, 1.0), + (2. * math::FRAC_TAU_3, 1.0), + ]; + SHAPE + .iter() + .copied() + .map(move |(angle, radius_mul)| Vector2D::from_polar(angle + rotation, radius_mul * radius)) +} + +#[derive(Debug)] +struct VisibleBoid<'a> { + boid: &'a Boid, + offset: Vector2D, + distance: f64, +} + +#[derive(Clone, Debug)] +struct VisibleBoidIter<'boid> { + // Pay no mind to this mess of a type. + // It's just `before` and `after` joined together. + it: iter::Chain, std::slice::Iter<'boid, Boid>>, + position: Vector2D, + visible_range: f64, +} +impl<'boid> VisibleBoidIter<'boid> { + fn new( + before: &'boid [Boid], + after: &'boid [Boid], + position: Vector2D, + visible_range: f64, + ) -> Self { + Self { + it: before.iter().chain(after), + position, + visible_range, + } + } +} +impl<'boid> Iterator for VisibleBoidIter<'boid> { + type Item = VisibleBoid<'boid>; + + fn next(&mut self) -> Option { + let Self { + ref mut it, + position, + visible_range, + } = *self; + + it.find_map(move |other| { + let offset = other.position - position; + let distance = offset.magnitude(); + + if distance > visible_range { + None + } else { + Some(VisibleBoid { + boid: other, + offset, + distance, + }) + } + }) + } +} diff --git a/client/src/main.rs b/client/src/main.rs index b200a76..bb7e940 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,27 +1,164 @@ -// Yew can run with trunk serve -use yew::prelude::*; - -#[function_component] -fn App() -> Html { - let counter = use_state(|| 0); - let onclick = { - let counter = counter.clone(); - move |_| { - let value = *counter + 1; - counter.set(value); +use settings::Settings; +use simulation::Simulation; +use slider::Slider; +use yew::html::Scope; +use yew::{html, Component, Context, Html}; + +mod boid; +mod math; +mod settings; +mod simulation; +mod slider; + +pub enum Msg { + ChangeSettings(Settings), + ResetSettings, + RestartSimulation, + TogglePause, +} + +pub struct App { + settings: Settings, + generation: usize, + paused: bool, +} +impl Component for App { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + Self { + settings: Settings::load(), + generation: 0, + paused: false, } - }; - - html! { -
- -

{ "Hello World!" }

- { "from Yew with " } -
- -

{ *counter }

+ } + + fn update(&mut self, _ctx: &Context, msg: Msg) -> bool { + match msg { + Msg::ChangeSettings(settings) => { + self.settings = settings; + self.settings.store(); + true + } + Msg::ResetSettings => { + self.settings = Settings::default(); + Settings::remove(); + true + } + Msg::RestartSimulation => { + self.generation = self.generation.wrapping_add(1); + true + } + Msg::TogglePause => { + self.paused = !self.paused; + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let Self { + ref settings, + generation, + paused, + .. + } = *self; + + html! { + <> +

{ "Boids" }

+ + { self.view_panel(ctx.link()) } + + } + } +} +impl App { + fn view_panel(&self, link: &Scope) -> Html { + let pause_text = if self.paused { "Resume" } else { "Pause" }; + html! { +
+ { self.view_settings(link) } +
+ + + +
-
+ } + } + + fn view_settings(&self, link: &Scope) -> Html { + let Self { settings, .. } = self; + + // This helper macro creates a callback which applies the new value to the current settings + // and sends `Msg::ChangeSettings`. Thanks to this, we don't need to have + // "ChangeBoids", "ChangeCohesion", etc. messages, but it comes at the cost of + // cloning the `Settings` struct each time. + macro_rules! settings_callback { + ($link:expr, $settings:ident; $key:ident as $ty:ty) => {{ + let settings = $settings.clone(); + $link.callback(move |value| { + let mut settings = settings.clone(); + settings.$key = value as $ty; + Msg::ChangeSettings(settings) + }) + }}; + ($link:expr, $settings:ident; $key:ident) => { + settings_callback!($link, $settings; $key as f64) + } + } + + html! { +
+ + + + + + + + + +
+ } } } diff --git a/client/src/math.rs b/client/src/math.rs new file mode 100644 index 0000000..e7a11f5 --- /dev/null +++ b/client/src/math.rs @@ -0,0 +1,172 @@ +use std::f64::consts::{FRAC_PI_3, PI}; +use std::iter::Sum; +use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +// at the time of writing the TAU constant is still unstable +pub const TAU: f64 = 2.0 * PI; +pub const FRAC_TAU_3: f64 = 2.0 * FRAC_PI_3; + +/// Get the smaller signed angle from `source` to `target`. +/// The result is in the range `[-PI, PI)`. +pub fn smallest_angle_between(source: f64, target: f64) -> f64 { + let d = target - source; + (d + PI).rem_euclid(TAU) - PI +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Vector2D { + pub x: f64, + pub y: f64, +} +impl Vector2D { + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + pub fn from_polar(angle: f64, radius: f64) -> Self { + let (sin, cos) = angle.sin_cos(); + Self::new(radius * cos, radius * sin) + } + + pub fn magnitude_squared(self) -> f64 { + self.x * self.x + self.y * self.y + } + + pub fn magnitude(self) -> f64 { + self.magnitude_squared().sqrt() + } + + pub fn clamp_magnitude(self, max: f64) -> Self { + let mag = self.magnitude(); + if mag > max { + self / mag * max + } else { + self + } + } + + /// Positive angles measured counter-clockwise from positive x axis. + pub fn angle(self) -> f64 { + self.y.atan2(self.x) + } +} + +impl Neg for Vector2D { + type Output = Self; + + fn neg(self) -> Self::Output { + Self::new(-self.x, -self.y) + } +} + +impl AddAssign for Vector2D { + fn add_assign(&mut self, other: Self) { + self.x += other.x; + self.y += other.y; + } +} +impl Add for Vector2D { + type Output = Self; + + fn add(mut self, rhs: Self) -> Self::Output { + self += rhs; + self + } +} + +impl SubAssign for Vector2D { + fn sub_assign(&mut self, other: Self) { + self.x -= other.x; + self.y -= other.y; + } +} +impl Sub for Vector2D { + type Output = Self; + + fn sub(mut self, rhs: Self) -> Self::Output { + self -= rhs; + self + } +} + +impl MulAssign for Vector2D { + fn mul_assign(&mut self, scalar: f64) { + self.x *= scalar; + self.y *= scalar; + } +} +impl Mul for Vector2D { + type Output = Self; + + fn mul(mut self, rhs: f64) -> Self::Output { + self *= rhs; + self + } +} + +impl DivAssign for Vector2D { + fn div_assign(&mut self, scalar: f64) { + self.x /= scalar; + self.y /= scalar; + } +} +impl Div for Vector2D { + type Output = Self; + + fn div(mut self, rhs: f64) -> Self::Output { + self /= rhs; + self + } +} + +impl Sum for Vector2D { + fn sum>(iter: I) -> Self { + iter.fold(Self::default(), |sum, v| sum + v) + } +} + +pub trait WeightedMean: Sized { + fn weighted_mean(it: impl Iterator) -> Option; +} + +impl WeightedMean for T +where + T: AddAssign + Mul + Div + Copy + Default, +{ + fn weighted_mean(it: impl Iterator) -> Option { + let (sum, total_weight) = it.fold( + (T::default(), 0.0), + |(mut sum, total_weight), (value, weight)| { + sum += value * weight; + (sum, total_weight + weight) + }, + ); + if total_weight.is_normal() { + Some(sum / total_weight) + } else { + None + } + } +} + +pub trait Mean: Sized { + fn mean(it: impl Iterator) -> Option; +} + +impl Mean for T +where + T: AddAssign + Sub + Div + Copy + Default, +{ + fn mean(it: impl Iterator) -> Option { + let (avg, count) = it.fold((T::default(), 0.0), |(mut avg, mut count), value| { + count += 1.0; + avg += (value - avg) / count; + (avg, count) + }); + if count.is_normal() { + Some(avg) + } else { + None + } + } +} diff --git a/client/src/settings.rs b/client/src/settings.rs new file mode 100644 index 0000000..f676f5e --- /dev/null +++ b/client/src/settings.rs @@ -0,0 +1,60 @@ +use gloo::storage::{LocalStorage, Storage}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct Settings { + /// amount of boids + pub boids: usize, + // time between each simulation tick + pub tick_interval_ms: u64, + /// view distance of a boid + pub visible_range: f64, + /// distance boids try to keep between each other + pub min_distance: f64, + /// max speed + pub max_speed: f64, + /// force multiplier for pulling boids together + pub cohesion_factor: f64, + /// force multiplier for separating boids + pub separation_factor: f64, + /// force multiplier for matching velocity of other boids + pub alignment_factor: f64, + /// controls turn speed to avoid leaving boundary + pub turn_speed_ratio: f64, + /// percentage of the size to the boundary at which a boid starts turning away + pub border_margin: f64, + /// factor for adapting the average color of the swarm + pub color_adapt_factor: f64, +} +impl Settings { + const KEY: &'static str = "yew.boids.settings"; + + pub fn load() -> Self { + LocalStorage::get(Self::KEY).unwrap_or_default() + } + + pub fn remove() { + LocalStorage::delete(Self::KEY); + } + + pub fn store(&self) { + let _ = LocalStorage::set(Self::KEY, self); + } +} +impl Default for Settings { + fn default() -> Self { + Self { + boids: 300, + tick_interval_ms: 50, + visible_range: 80.0, + min_distance: 15.0, + max_speed: 20.0, + alignment_factor: 0.15, + cohesion_factor: 0.05, + separation_factor: 0.6, + turn_speed_ratio: 0.25, + border_margin: 0.1, + color_adapt_factor: 0.05, + } + } +} diff --git a/client/src/simulation.rs b/client/src/simulation.rs new file mode 100644 index 0000000..ff47a28 --- /dev/null +++ b/client/src/simulation.rs @@ -0,0 +1,110 @@ +use gloo::timers::callback::Interval; +use yew::{html, Component, Context, Html, Properties}; + +use crate::boid::Boid; +use crate::math::Vector2D; +use crate::settings::Settings; + +pub const SIZE: Vector2D = Vector2D::new(1600.0, 1000.0); + +#[derive(Debug)] +pub enum Msg { + Tick, +} + +#[derive(Clone, Debug, PartialEq, Properties)] +pub struct Props { + pub settings: Settings, + #[prop_or_default] + pub generation: usize, + #[prop_or_default] + pub paused: bool, +} + +#[derive(Debug)] +pub struct Simulation { + boids: Vec, + interval: Interval, + generation: usize, +} +impl Component for Simulation { + type Message = Msg; + type Properties = Props; + + fn create(ctx: &Context) -> Self { + let settings = ctx.props().settings.clone(); + let boids = (0..settings.boids) + .map(|_| Boid::new_random(&settings)) + .collect(); + + let interval = { + let link = ctx.link().clone(); + Interval::new(settings.tick_interval_ms as u32, move || { + link.send_message(Msg::Tick) + }) + }; + + let generation = ctx.props().generation; + Self { + boids, + interval, + generation, + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::Tick => { + let Props { + ref settings, + paused, + .. + } = *ctx.props(); + + if paused { + false + } else { + Boid::update_all(settings, &mut self.boids); + true + } + } + } + } + + fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { + let props = ctx.props(); + let should_reset = + old_props.settings != props.settings || self.generation != props.generation; + self.generation = props.generation; + if should_reset { + self.boids.clear(); + + let settings = &props.settings; + self.boids + .resize_with(settings.boids, || Boid::new_random(settings)); + + // as soon as the previous task is dropped it is cancelled. + // We don't need to worry about manually stopping it. + self.interval = { + let link = ctx.link().clone(); + Interval::new(settings.tick_interval_ms as u32, move || { + link.send_message(Msg::Tick) + }) + }; + + true + } else { + false + } + } + + fn view(&self, _ctx: &Context) -> Html { + let view_box = format!("0 0 {} {}", SIZE.x, SIZE.y); + + html! { + + { for self.boids.iter().map(Boid::render) } + + } + } +} diff --git a/client/src/slider.rs b/client/src/slider.rs new file mode 100644 index 0000000..7e1e6ca --- /dev/null +++ b/client/src/slider.rs @@ -0,0 +1,92 @@ +use std::cell::Cell; + +use web_sys::HtmlInputElement; +use yew::events::InputEvent; +use yew::{html, Callback, Component, Context, Html, Properties, TargetCast}; + +thread_local! { + static SLIDER_ID: Cell = Cell::default(); +} +fn next_slider_id() -> usize { + SLIDER_ID.with(|cell| cell.replace(cell.get() + 1)) +} + +#[derive(Clone, Debug, PartialEq, Properties)] +pub struct Props { + pub label: &'static str, + pub value: f64, + pub onchange: Callback, + #[prop_or_default] + pub precision: Option, + #[prop_or_default] + pub percentage: bool, + #[prop_or_default] + pub min: f64, + pub max: f64, + #[prop_or_default] + pub step: Option, +} + +pub struct Slider { + id: usize, +} +impl Component for Slider { + type Message = (); + type Properties = Props; + + fn create(_ctx: &Context) -> Self { + Self { + id: next_slider_id(), + } + } + + fn update(&mut self, _ctx: &Context, _msg: Self::Message) -> bool { + unimplemented!() + } + + fn view(&self, ctx: &Context) -> Html { + let Props { + label, + value, + ref onchange, + precision, + percentage, + min, + max, + step, + } = *ctx.props(); + + let precision = precision.unwrap_or_else(|| usize::from(percentage)); + + let display_value = if percentage { + format!("{:.p$}%", 100.0 * value, p = precision) + } else { + format!("{value:.precision$}") + }; + + let id = format!("slider-{}", self.id); + let step = step.unwrap_or_else(|| { + let p = if percentage { precision + 2 } else { precision }; + 10f64.powi(-(p as i32)) + }); + + let oninput = onchange.reform(|e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + input.value_as_number() + }); + + html! { +
+ + + { display_value } +
+ } + } +}