iced_lenia/
lib.rs

1use iced::{
2    Color, Element, Event, Length, Point, Rectangle, Subscription, Task, Theme,
3    mouse::{Cursor, Interaction},
4    widget::canvas::{self, Canvas, Frame, Geometry, Path},
5};
6use ndarray::{Array1, Array2};
7use ndarray_rand::{RandomExt, rand_distr::Normal};
8use rayon::prelude::*;
9use std::f32::consts::PI;
10
11pub const WIDTH: f32 = 1500.0;
12pub const HEIGHT: f32 = 1500.0;
13const D: usize = 3; // 3D positions (we ignore z for drawing)
14const N: usize = 1000;
15const DT: f32 = 0.1;
16
17// Type definitions remain the same
18#[derive(Debug, Clone)]
19pub enum Message {
20    Tick,
21    Zoom(f32), // positive delta zooms in; negative zooms out
22    NoOp,
23}
24
25/// The main struct, including the particle positions and their energies.
26pub struct ParticleLenia {
27    particles: Array2<f32>,
28    energies: Vec<f32>,
29    cache: canvas::Cache,
30    // Lenia parameters
31    r: f32,
32    w: f32,
33    m: f32,
34    s: f32,
35    c_rep: f32,
36    kernel_sum: f32,
37    zoom: f32,
38}
39
40impl ParticleLenia {
41    /// Create a new instance with default parameters
42    pub fn new() -> Self {
43        // Lenia parameters; these may be tuned further.
44        let r = 2.0;
45        let w = 0.64;
46        let m = 0.72;
47        let s = 0.26;
48        let c_rep = 1.0;
49        let kernel_sum = compute_kernel_sum(D, r, w);
50
51        // Initialize particles randomly.
52        let particles = Array2::random((N, D), Normal::new(0.0, 1.0).unwrap());
53        let energies = vec![0.0; N];
54
55        Self {
56            particles,
57            energies,
58            cache: canvas::Cache::new(),
59            r,
60            w,
61            m,
62            s,
63            c_rep,
64            kernel_sum,
65            zoom: 0.33,
66        }
67    }
68
69    /// Advance one simulation time step.
70    fn step(&mut self) {
71        let x_prev = self.particles.clone();
72        // Copy scalar parameters so the parallel closure doesn't capture &self.
73        let kernel_sum = self.kernel_sum;
74        let r = self.r;
75        let w = self.w;
76        let m = self.m;
77        let s = self.s;
78        let c_rep = self.c_rep;
79
80        // Update particle positions in parallel.
81        let updates: Vec<Array1<f32>> = (0..N)
82            .into_par_iter()
83            .map(|i| {
84                let xi = x_prev.row(i).to_owned();
85                let grad = numerical_gradient(&x_prev, &xi, kernel_sum, r, w, m, s, c_rep, 1e-4);
86                xi - grad * DT
87            })
88            .collect();
89
90        let new_positions = Array2::from_shape_fn((N, D), |(i, j)| updates[i][j]);
91        self.particles.assign(&new_positions);
92
93        // Compute energies for each particle in parallel.
94        let new_energies: Vec<f32> = (0..N)
95            .into_par_iter()
96            .map(|i| {
97                let xi = new_positions.row(i).to_owned();
98                energy(&new_positions, &xi, kernel_sum, r, w, m, s, c_rep)
99            })
100            .collect();
101
102        self.energies = new_energies;
103    }
104}
105
106// Standalone functions for application builder
107pub fn update(state: &mut ParticleLenia, message: Message) -> Task<Message> {
108    match message {
109        Message::Tick => {
110            state.step();
111            state.cache.clear();
112        }
113        Message::Zoom(delta) => {
114            // Adjust zoom factor (change multiplier as needed)
115            state.zoom += 0.01 * delta;
116            state.zoom = state.zoom.clamp(0.1, 10.0);
117            state.cache.clear();
118        }
119        Message::NoOp => {}
120    }
121
122    Task::none()
123}
124
125pub fn view(state: &ParticleLenia) -> Element<Message> {
126    Canvas::new(state)
127        .width(Length::Fill)
128        .height(Length::Fill)
129        .into()
130}
131
132pub fn subscription(_state: &ParticleLenia) -> Subscription<Message> {
133    Subscription::batch(vec![
134        iced::time::every(std::time::Duration::from_millis(16)).map(|_| Message::Tick),
135        iced_futures::event::listen().map(|event| {
136            if let Event::Mouse(iced::mouse::Event::WheelScrolled { delta }) = event {
137                // Use the vertical component of the scroll to adjust zoom.
138                let zoom_delta = match delta {
139                    iced::mouse::ScrollDelta::Lines { y, .. } => y,
140                    iced::mouse::ScrollDelta::Pixels { y, .. } => y,
141                };
142                Message::Zoom(zoom_delta)
143            } else {
144                Message::NoOp
145            }
146        }),
147    ])
148}
149
150// Main function using the builder pattern
151pub fn main() -> iced::Result {
152    iced::application("Particle Lenia Simulation", update, view)
153        .subscription(subscription)
154        .run_with(|| {
155            // Initialize the application state and return it with an empty task
156            let state = ParticleLenia::new();
157            (state, Task::none())
158        })
159}
160
161// The canvas::Program implementation remains the same
162impl<Message> canvas::Program<Message, Theme> for ParticleLenia {
163    type State = ();
164
165    fn draw(
166        &self,
167        _state: &Self::State,
168        renderer: &iced::Renderer,
169        _theme: &Theme,
170        bounds: Rectangle,
171        _cursor: Cursor,
172    ) -> Vec<Geometry> {
173        // Compute min and max energy values to normalize our color mapping.
174        let min_energy = self.energies.iter().cloned().fold(f32::INFINITY, f32::min);
175        let max_energy = self
176            .energies
177            .iter()
178            .cloned()
179            .fold(f32::NEG_INFINITY, f32::max);
180
181        let geometry = self
182            .cache
183            .draw(renderer, bounds.size(), |frame: &mut Frame| {
184                // Draw background.
185                frame.fill_rectangle(Point::ORIGIN, frame.size(), Color::BLACK);
186
187                // Define a camera distance (or focal length)
188                let camera_distance = 400.0;
189
190                // Render each particle.
191                for (i, particle) in self.particles.rows().into_iter().enumerate() {
192                    // Get the 3D point.
193                    let point = [particle[0], particle[1], particle[2]];
194
195                    // Project the 3D point to 2D.
196                    let (proj_x, proj_y) = project_point(&point, camera_distance);
197
198                    // Transform the projected coordinates to canvas space.
199                    let x = (bounds.width / 2.0) + proj_x * (bounds.width / 4.0) * self.zoom;
200                    let y = (bounds.height / 2.0) + proj_y * (bounds.height / 4.0) * self.zoom;
201
202                    // Normalize energy for color mapping.
203                    let energy_value = self.energies[i];
204                    let factor = if max_energy - min_energy > 0.0 {
205                        (energy_value - min_energy) / (max_energy - min_energy)
206                    } else {
207                        0.5
208                    };
209
210                    // Map factor to a color
211                    let circle_color = Color::from_rgb(1.0 - factor, factor, 0.5);
212
213                    // Draw the particle as a circle.
214                    let circle = Path::circle(Point::new(x, y), 10.0);
215                    frame.fill(&circle, circle_color);
216                }
217            });
218
219        vec![geometry]
220    }
221
222    fn mouse_interaction(
223        &self,
224        _state: &Self::State,
225        _bounds: Rectangle,
226        _cursor: Cursor,
227    ) -> Interaction {
228        Interaction::default()
229    }
230}
231
232// Helper functions remain the same
233/// Projects a 3D point to 2D using a simple perspective projection.
234fn project_point(point: &[f32; 3], camera_distance: f32) -> (f32, f32) {
235    // Calculate a scaling factor based on the z-value.
236    let factor = camera_distance / (camera_distance - point[2]);
237    let x_proj = point[0] * factor;
238    let y_proj = point[1] * factor;
239    (x_proj, y_proj)
240}
241
242/// The typical Lenia "bell" function.
243fn bell(x: f32, m: f32, s: f32) -> f32 {
244    (-((x - m) / s).powi(2)).exp()
245}
246
247/// A simple repulsion function.
248fn repulse(x: f32) -> f32 {
249    (1.0 - x).max(0.0).powi(2)
250}
251
252/// Numerically integrates bell(r, w) * surface_area_factor * r^(D-1)
253/// over a radius interval, matching the Python reference normalization.
254fn compute_kernel_sum(d: usize, r: f32, w: f32) -> f32 {
255    let lower = (r - 4.0 * w).max(0.0);
256    let upper = r + 4.0 * w;
257    let steps = 51;
258    let delta = (upper - lower) / (steps - 1) as f32;
259
260    let dimension_factor = match d {
261        2 => 2.0 * PI, // for 2D (circumference)
262        3 => 4.0 * PI, // for 3D (surface area)
263        _ => panic!("compute_kernel_sum: only d=2 or d=3 is implemented."),
264    };
265
266    let mut sum = 0.0;
267    let mut last_val = None;
268
269    for i in 0..steps {
270        let dist = lower + (i as f32) * delta;
271        let val = bell(dist, r, w) * dimension_factor * dist.powi((d - 1) as i32);
272
273        if let Some(prev) = last_val {
274            sum += 0.5 * (val + prev) * delta; // trapezoidal integration
275        }
276
277        last_val = Some(val);
278    }
279
280    sum
281}
282
283/// Compute the energy for one particle at position `x_i` against all others in `X`.
284fn energy(
285    #[allow(non_snake_case)] X: &Array2<f32>,
286    x_i: &Array1<f32>,
287    kernel_sum: f32,
288    r: f32,
289    w: f32,
290    m: f32,
291    s: f32,
292    c_rep: f32,
293) -> f32 {
294    let distances = (X - x_i)
295        .mapv(|v| v.powi(2))
296        .sum_axis(ndarray::Axis(1))
297        .mapv(f32::sqrt)
298        .mapv(|val| val.max(1e-10));
299
300    let u = distances.mapv(|d| bell(d, r, w)).sum() / kernel_sum;
301    let g = bell(u, m, s);
302    let r_ener = distances.mapv(repulse).sum() * c_rep / 2.0;
303
304    r_ener - g
305}
306
307/// Compute a numerical gradient for `x_i` using finite differences.
308fn numerical_gradient(
309    #[allow(non_snake_case)] X: &Array2<f32>,
310    xi: &Array1<f32>,
311    kernel_sum: f32,
312    r: f32,
313    w: f32,
314    m: f32,
315    s: f32,
316    c_rep: f32,
317    h: f32,
318) -> Array1<f32> {
319    let mut grad = Array1::zeros(D);
320
321    for dim in 0..D {
322        let mut x_plus = xi.clone();
323        x_plus[dim] += h;
324        let f_plus = energy(X, &x_plus, kernel_sum, r, w, m, s, c_rep);
325
326        let mut x_minus = xi.clone();
327        x_minus[dim] -= h;
328        let f_minus = energy(X, &x_minus, kernel_sum, r, w, m, s, c_rep);
329
330        grad[dim] = (f_plus - f_minus) / (2.0 * h);
331    }
332
333    grad
334}