Skip to main content

simular/scenarios/
bouncing_balls.rs

1//! Bouncing balls physics simulation.
2//!
3//! 2D elastic collisions with gravity, wall boundaries, and velocity-dependent
4//! color mapping. Produces `RenderCommand` output for SVG/keyframe export.
5
6use crate::orbit::render::{Color, RenderCommand};
7
8/// A single ball in the simulation.
9#[derive(Debug, Clone)]
10pub struct Ball {
11    pub x: f64,
12    pub y: f64,
13    pub vx: f64,
14    pub vy: f64,
15    pub radius: f64,
16    pub mass: f64,
17    /// Trail of recent positions.
18    pub trail: Vec<(f64, f64)>,
19}
20
21/// Simulation configuration.
22#[derive(Debug, Clone)]
23pub struct BouncingBallsConfig {
24    /// Canvas width in pixels.
25    pub width: f64,
26    /// Canvas height in pixels.
27    pub height: f64,
28    /// Gravitational acceleration (pixels/s^2, positive = downward).
29    pub gravity: f64,
30    /// Coefficient of restitution (1.0 = perfectly elastic).
31    pub restitution: f64,
32    /// Maximum trail length per ball.
33    pub trail_length: usize,
34    /// Number of balls.
35    pub ball_count: usize,
36}
37
38impl Default for BouncingBallsConfig {
39    fn default() -> Self {
40        Self {
41            width: 1920.0,
42            height: 1080.0,
43            gravity: 400.0,
44            restitution: 0.92,
45            trail_length: 30,
46            ball_count: 8,
47        }
48    }
49}
50
51/// Complete simulation state.
52#[derive(Debug, Clone)]
53pub struct BouncingBallsState {
54    pub balls: Vec<Ball>,
55    pub config: BouncingBallsConfig,
56}
57
58impl BouncingBallsState {
59    /// Create initial state from a seed.
60    #[must_use]
61    pub fn new(config: BouncingBallsConfig, seed: u64) -> Self {
62        let mut balls = Vec::with_capacity(config.ball_count);
63        // Simple deterministic PRNG (xorshift64)
64        let mut rng = seed.wrapping_add(1);
65
66        for _ in 0..config.ball_count {
67            rng = xorshift64(rng);
68            let radius = 20.0 + (rng % 30) as f64;
69            rng = xorshift64(rng);
70            let x = radius + (rng % (config.width as u64 - 2 * radius as u64)) as f64;
71            rng = xorshift64(rng);
72            let y = radius + (rng % ((config.height as u64) / 2)) as f64;
73            rng = xorshift64(rng);
74            let vx = (rng % 400) as f64 - 200.0;
75            rng = xorshift64(rng);
76            let vy = (rng % 300) as f64 - 150.0;
77
78            balls.push(Ball {
79                x,
80                y,
81                vx,
82                vy,
83                radius,
84                mass: radius * radius, // mass proportional to area
85                trail: Vec::with_capacity(config.trail_length),
86            });
87        }
88
89        Self { balls, config }
90    }
91
92    /// Advance physics by `dt` seconds.
93    pub fn step(&mut self, dt: f64) {
94        let gravity = self.config.gravity;
95        let restitution = self.config.restitution;
96        let w = self.config.width;
97        let h = self.config.height;
98        let trail_len = self.config.trail_length;
99
100        // Update velocities and positions
101        for ball in &mut self.balls {
102            ball.vy += gravity * dt;
103            ball.x += ball.vx * dt;
104            ball.y += ball.vy * dt;
105
106            // Wall collisions
107            if ball.x - ball.radius < 0.0 {
108                ball.x = ball.radius;
109                ball.vx = -ball.vx * restitution;
110            }
111            if ball.x + ball.radius > w {
112                ball.x = w - ball.radius;
113                ball.vx = -ball.vx * restitution;
114            }
115            if ball.y - ball.radius < 0.0 {
116                ball.y = ball.radius;
117                ball.vy = -ball.vy * restitution;
118            }
119            if ball.y + ball.radius > h {
120                ball.y = h - ball.radius;
121                ball.vy = -ball.vy * restitution;
122            }
123
124            // Record trail
125            ball.trail.push((ball.x, ball.y));
126            if ball.trail.len() > trail_len {
127                ball.trail.remove(0);
128            }
129        }
130
131        // Ball-ball elastic collisions
132        let n = self.balls.len();
133        for i in 0..n {
134            for j in (i + 1)..n {
135                resolve_collision(&mut self.balls, i, j, restitution);
136            }
137        }
138    }
139
140    /// Generate render commands for the current state.
141    #[must_use]
142    pub fn render(&self) -> Vec<RenderCommand> {
143        let mut commands = Vec::with_capacity(self.balls.len() * 3 + 2);
144
145        // Identity camera: screen coordinates pass through unchanged
146        commands.push(RenderCommand::SetCamera {
147            center_x: self.config.width / 2.0,
148            center_y: self.config.height / 2.0,
149            zoom: 1.0,
150        });
151
152        // Dark background
153        commands.push(RenderCommand::Clear {
154            color: Color::rgb(18, 18, 24),
155        });
156
157        // Floor line
158        commands.push(RenderCommand::DrawLine {
159            x1: 0.0,
160            y1: self.config.height - 2.0,
161            x2: self.config.width,
162            y2: self.config.height - 2.0,
163            color: Color::rgb(60, 60, 80),
164        });
165
166        // Trails
167        for ball in &self.balls {
168            if ball.trail.len() >= 2 {
169                commands.push(RenderCommand::DrawOrbitPath {
170                    points: ball.trail.clone(),
171                    color: Color::new(255, 255, 255, 40),
172                });
173            }
174        }
175
176        // Balls with velocity-mapped color
177        for ball in &self.balls {
178            let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
179            let color = velocity_to_color(speed);
180
181            commands.push(RenderCommand::DrawCircle {
182                x: ball.x,
183                y: ball.y,
184                radius: ball.radius,
185                color,
186                filled: true,
187            });
188        }
189
190        // Speed labels
191        for ball in &self.balls {
192            let speed = (ball.vx * ball.vx + ball.vy * ball.vy).sqrt();
193            commands.push(RenderCommand::DrawText {
194                x: ball.x,
195                y: ball.y - ball.radius - 8.0,
196                text: format!("{speed:.0}"),
197                color: Color::rgb(180, 180, 200),
198            });
199        }
200
201        commands
202    }
203}
204
205/// Xorshift64 PRNG — deterministic, no dependencies.
206fn xorshift64(mut state: u64) -> u64 {
207    state ^= state << 13;
208    state ^= state >> 7;
209    state ^= state << 17;
210    state
211}
212
213/// Map speed (0-600 px/s) to a blue→cyan→green→yellow→red gradient.
214fn velocity_to_color(speed: f64) -> Color {
215    let t = (speed / 600.0).clamp(0.0, 1.0);
216    if t < 0.25 {
217        // Blue → Cyan
218        let s = t / 0.25;
219        Color::rgb(30, (100.0 + 155.0 * s) as u8, 255)
220    } else if t < 0.5 {
221        // Cyan → Green
222        let s = (t - 0.25) / 0.25;
223        Color::rgb(30, 255, (255.0 - 155.0 * s) as u8)
224    } else if t < 0.75 {
225        // Green → Yellow
226        let s = (t - 0.5) / 0.25;
227        Color::rgb((30.0 + 225.0 * s) as u8, 255, (100.0 - 70.0 * s) as u8)
228    } else {
229        // Yellow → Red
230        let s = (t - 0.75) / 0.25;
231        Color::rgb(255, (255.0 - 155.0 * s) as u8, 30)
232    }
233}
234
235/// Resolve elastic collision between two balls.
236fn resolve_collision(balls: &mut [Ball], i: usize, j: usize, restitution: f64) {
237    let dx = balls[j].x - balls[i].x;
238    let dy = balls[j].y - balls[i].y;
239    let dist_sq = dx * dx + dy * dy;
240    let min_dist = balls[i].radius + balls[j].radius;
241
242    if dist_sq >= min_dist * min_dist || dist_sq < 1e-10 {
243        return;
244    }
245
246    let dist = dist_sq.sqrt();
247    let nx = dx / dist;
248    let ny = dy / dist;
249
250    // Relative velocity along collision normal
251    let dvx = balls[i].vx - balls[j].vx;
252    let dvy = balls[i].vy - balls[j].vy;
253    let dvn = dvx * nx + dvy * ny;
254
255    // Don't resolve if separating
256    if dvn < 0.0 {
257        return;
258    }
259
260    let m1 = balls[i].mass;
261    let m2 = balls[j].mass;
262    let impulse = (1.0 + restitution) * dvn / (m1 + m2);
263
264    balls[i].vx -= impulse * m2 * nx;
265    balls[i].vy -= impulse * m2 * ny;
266    balls[j].vx += impulse * m1 * nx;
267    balls[j].vy += impulse * m1 * ny;
268
269    // Separate overlapping balls
270    let overlap = min_dist - dist;
271    let sep = overlap / 2.0 + 0.5;
272    balls[i].x -= sep * nx;
273    balls[i].y -= sep * ny;
274    balls[j].x += sep * nx;
275    balls[j].y += sep * ny;
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_default_config() {
284        let cfg = BouncingBallsConfig::default();
285        assert_eq!(cfg.width, 1920.0);
286        assert_eq!(cfg.height, 1080.0);
287        assert_eq!(cfg.ball_count, 8);
288    }
289
290    #[test]
291    fn test_new_state_creates_correct_ball_count() {
292        let cfg = BouncingBallsConfig {
293            ball_count: 5,
294            ..Default::default()
295        };
296        let state = BouncingBallsState::new(cfg, 42);
297        assert_eq!(state.balls.len(), 5);
298    }
299
300    #[test]
301    fn test_balls_within_bounds() {
302        let cfg = BouncingBallsConfig::default();
303        let state = BouncingBallsState::new(cfg, 42);
304        for ball in &state.balls {
305            assert!(ball.x >= ball.radius);
306            assert!(ball.x <= 1920.0 - ball.radius);
307            assert!(ball.y >= ball.radius);
308        }
309    }
310
311    #[test]
312    fn test_deterministic_seed() {
313        let cfg = BouncingBallsConfig::default();
314        let s1 = BouncingBallsState::new(cfg.clone(), 42);
315        let s2 = BouncingBallsState::new(cfg, 42);
316        for (a, b) in s1.balls.iter().zip(s2.balls.iter()) {
317            assert_eq!(a.x, b.x);
318            assert_eq!(a.y, b.y);
319            assert_eq!(a.vx, b.vx);
320            assert_eq!(a.vy, b.vy);
321        }
322    }
323
324    #[test]
325    fn test_different_seeds_differ() {
326        let cfg = BouncingBallsConfig::default();
327        let s1 = BouncingBallsState::new(cfg.clone(), 42);
328        let s2 = BouncingBallsState::new(cfg, 99);
329        let differs = s1
330            .balls
331            .iter()
332            .zip(s2.balls.iter())
333            .any(|(a, b)| a.x != b.x);
334        assert!(differs);
335    }
336
337    #[test]
338    fn test_step_applies_gravity() {
339        let cfg = BouncingBallsConfig {
340            ball_count: 1,
341            gravity: 100.0,
342            ..Default::default()
343        };
344        let mut state = BouncingBallsState::new(cfg, 42);
345        let vy_before = state.balls[0].vy;
346        state.step(0.1);
347        // Gravity should increase downward velocity
348        assert!(state.balls[0].vy > vy_before);
349    }
350
351    #[test]
352    fn test_wall_collision_keeps_in_bounds() {
353        let cfg = BouncingBallsConfig {
354            ball_count: 1,
355            ..Default::default()
356        };
357        let mut state = BouncingBallsState::new(cfg, 42);
358        // Run many steps
359        for _ in 0..1000 {
360            state.step(1.0 / 60.0);
361        }
362        let ball = &state.balls[0];
363        assert!(ball.x >= ball.radius);
364        assert!(ball.x <= 1920.0);
365        assert!(ball.y >= ball.radius);
366        assert!(ball.y <= 1080.0);
367    }
368
369    #[test]
370    fn test_trail_grows() {
371        let cfg = BouncingBallsConfig {
372            ball_count: 1,
373            trail_length: 10,
374            ..Default::default()
375        };
376        let mut state = BouncingBallsState::new(cfg, 42);
377        assert!(state.balls[0].trail.is_empty());
378        for _ in 0..5 {
379            state.step(1.0 / 60.0);
380        }
381        assert_eq!(state.balls[0].trail.len(), 5);
382    }
383
384    #[test]
385    fn test_trail_caps_at_max_length() {
386        let cfg = BouncingBallsConfig {
387            ball_count: 1,
388            trail_length: 5,
389            ..Default::default()
390        };
391        let mut state = BouncingBallsState::new(cfg, 42);
392        for _ in 0..20 {
393            state.step(1.0 / 60.0);
394        }
395        assert_eq!(state.balls[0].trail.len(), 5);
396    }
397
398    #[test]
399    fn test_render_produces_commands() {
400        let cfg = BouncingBallsConfig {
401            ball_count: 3,
402            ..Default::default()
403        };
404        let state = BouncingBallsState::new(cfg, 42);
405        let commands = state.render();
406        // Clear + floor line + 3 balls + 3 labels = 8 minimum
407        assert!(commands.len() >= 8);
408    }
409
410    #[test]
411    fn test_render_starts_with_camera_then_clear() {
412        let cfg = BouncingBallsConfig {
413            ball_count: 1,
414            ..Default::default()
415        };
416        let state = BouncingBallsState::new(cfg, 42);
417        let commands = state.render();
418        assert!(matches!(commands[0], RenderCommand::SetCamera { .. }));
419        assert!(matches!(commands[1], RenderCommand::Clear { .. }));
420    }
421
422    #[test]
423    fn test_velocity_color_gradient() {
424        let slow = velocity_to_color(0.0);
425        let fast = velocity_to_color(600.0);
426        // Slow = blue-ish, fast = red-ish
427        assert!(slow.b > slow.r);
428        assert!(fast.r > fast.b);
429    }
430
431    #[test]
432    fn test_velocity_color_clamps() {
433        let over = velocity_to_color(9999.0);
434        let under = velocity_to_color(-10.0);
435        // Should not panic, should clamp
436        assert_eq!(over.r, 255);
437        assert!(under.b == 255);
438    }
439
440    #[test]
441    fn test_elastic_collision_conserves_momentum() {
442        let cfg = BouncingBallsConfig {
443            ball_count: 2,
444            gravity: 0.0,
445            restitution: 1.0,
446            ..Default::default()
447        };
448        let mut state = BouncingBallsState::new(cfg, 42);
449        // Set up head-on collision
450        state.balls[0].x = 500.0;
451        state.balls[0].y = 540.0;
452        state.balls[0].vx = 100.0;
453        state.balls[0].vy = 0.0;
454        state.balls[1].x = 560.0;
455        state.balls[1].y = 540.0;
456        state.balls[1].vx = -100.0;
457        state.balls[1].vy = 0.0;
458
459        let m1 = state.balls[0].mass;
460        let m2 = state.balls[1].mass;
461        let px_before = m1 * state.balls[0].vx + m2 * state.balls[1].vx;
462        let py_before = m1 * state.balls[0].vy + m2 * state.balls[1].vy;
463
464        state.step(1.0 / 60.0);
465
466        let px_after = m1 * state.balls[0].vx + m2 * state.balls[1].vx;
467        let py_after = m1 * state.balls[0].vy + m2 * state.balls[1].vy;
468
469        assert!((px_before - px_after).abs() < 1.0);
470        assert!((py_before - py_after).abs() < 1.0);
471    }
472
473    #[test]
474    fn test_xorshift_deterministic() {
475        assert_eq!(xorshift64(42), xorshift64(42));
476        assert_ne!(xorshift64(42), xorshift64(43));
477    }
478
479    #[test]
480    fn test_render_after_many_steps() {
481        let cfg = BouncingBallsConfig::default();
482        let mut state = BouncingBallsState::new(cfg, 42);
483        for _ in 0..600 {
484            state.step(1.0 / 60.0);
485        }
486        let commands = state.render();
487        // Should still produce valid commands after 10s of simulation
488        assert!(!commands.is_empty());
489        // All balls should still be in bounds
490        for ball in &state.balls {
491            assert!(ball.x >= 0.0 && ball.x <= 1920.0);
492            assert!(ball.y >= 0.0 && ball.y <= 1080.0);
493        }
494    }
495}