physics_2d/
lib.rs

1//! Interactive original 2D physics sandbox. No imported textures, fonts, or scene assets.
2use ferravex_core::{FixedClock, FrameStats};
3use ferravex_physics::d2::{self, PhysicsWorld, RigidBodyHandle};
4use ferravex_render::{prelude::*, ui};
5
6const MAX_BODIES: usize = 240;
7const TICK_HZ: f64 = 120.0;
8
9#[derive(Clone, Copy)]
10enum Shape {
11    Ball(f32),
12    Box(Vec2),
13}
14
15struct Body {
16    handle: RigidBodyHandle,
17    shape: Shape,
18    color: Color,
19    previous_position: Vec2,
20    previous_angle: f32,
21}
22
23struct Sandbox {
24    physics: PhysicsWorld,
25    bodies: Vec<Body>,
26    platforms: Vec<(Vec2, Vec2)>,
27}
28
29impl Sandbox {
30    fn new() -> Self {
31        let mut world = Self {
32            physics: PhysicsWorld::new([0.0, -9.81]),
33            bodies: Vec::new(),
34            platforms: vec![],
35        };
36        for (position, half) in [
37            (vec2(0.0, -0.5), vec2(12.0, 0.5)),
38            (vec2(-12.25, 6.0), vec2(0.25, 6.5)),
39            (vec2(12.25, 6.0), vec2(0.25, 6.5)),
40            (vec2(-8.0, 0.8), vec2(2.0, 0.8)),
41            (vec2(8.0, 0.8), vec2(2.0, 0.8)),
42        ] {
43            world
44                .physics
45                .add_box(position.to_array(), half.to_array(), false);
46            world.platforms.push((position, half));
47        }
48        for row in 0..6 {
49            for column in 0..(6 - row) {
50                world.spawn(
51                    vec2(
52                        -4.0 + column as f32 * 1.06 + row as f32 * 0.53,
53                        0.51 + row as f32 * 1.03,
54                    ),
55                    false,
56                );
57            }
58        }
59        for index in 0..9 {
60            world.spawn(
61                vec2(
62                    5.8 + (index % 3) as f32 * 1.05,
63                    5.0 + (index / 3) as f32 * 1.1,
64                ),
65                true,
66            );
67        }
68        world
69    }
70
71    fn spawn(&mut self, position: Vec2, ball: bool) {
72        if self.bodies.len() >= MAX_BODIES {
73            return;
74        }
75        let (handle, shape, color) = if ball {
76            (
77                self.physics.add_ball(position.to_array(), 0.44, true),
78                Shape::Ball(0.44),
79                ui::CYAN,
80            )
81        } else {
82            (
83                self.physics
84                    .add_box(position.to_array(), [0.48, 0.48], true),
85                Shape::Box(vec2(0.48, 0.48)),
86                ui::AMBER,
87            )
88        };
89        let collider = self.physics.bodies[handle].colliders()[0];
90        self.physics.colliders[collider].set_restitution(if ball { 0.42 } else { 0.08 });
91        self.physics.bodies[handle].enable_ccd(true);
92        self.bodies.push(Body {
93            handle,
94            shape,
95            color,
96            previous_position: position,
97            previous_angle: 0.0,
98        });
99    }
100
101    fn step(&mut self, dt: f32) {
102        for body in &mut self.bodies {
103            let rigid_body = &self.physics.bodies[body.handle];
104            body.previous_position = Vec2::from_array(rigid_body.translation().to_array());
105            let axis = *rigid_body.rotation() * d2::Vector::X;
106            body.previous_angle = axis.y.atan2(axis.x);
107        }
108        self.physics.step(dt);
109    }
110}
111
112fn window_conf() -> Conf {
113    ferravex_render::window_conf("Ferravex / 2D physics laboratory")
114}
115
116ferravex_render::game!(window_conf, game);
117async fn game() {
118    let mut demo = ferravex_render::demo::Demo::new();
119    let mut sandbox = Sandbox::new();
120    let mut clock = FixedClock::new(TICK_HZ, 8);
121    let mut stats = FrameStats::<90>::default();
122    let mut paused = false;
123    let mut ball = true;
124    loop {
125        ui::warm_up_font();
126        stats.record(get_frame_time());
127        if is_key_pressed(KeyCode::Space) {
128            paused = !paused;
129        }
130        if is_key_pressed(KeyCode::Key1) {
131            ball = true;
132        }
133        if is_key_pressed(KeyCode::Key2) {
134            ball = false;
135        }
136        if is_key_pressed(KeyCode::R) {
137            sandbox = Sandbox::new();
138            clock.reset();
139        }
140
141        let footer_y = screen_height() - 98.0;
142        let area_height = (footer_y - 143.0).max(100.0);
143        let scale = ((screen_width() - 58.0) / 26.0)
144            .min(area_height / 14.0)
145            .max(1.0);
146        let origin = vec2(screen_width() * 0.5, footer_y - 15.0 - scale);
147        let to_screen = |point: Vec2| origin + vec2(point.x, -point.y) * scale;
148        let pointer = Vec2::from(mouse_position());
149        let pointer_in_scene = pointer.y > 136.0 && pointer.y < footer_y - 8.0;
150        let spawn_position = vec2(
151            (pointer.x - origin.x) / scale,
152            (origin.y - pointer.y) / scale,
153        );
154        if pointer_in_scene
155            && (is_mouse_button_pressed(MouseButton::Left)
156                || is_mouse_button_pressed(MouseButton::Right))
157        {
158            sandbox.spawn(
159                vec2(
160                    spawn_position.x.clamp(-11.2, 11.2),
161                    spawn_position.y.max(0.6),
162                ),
163                ball && !is_mouse_button_pressed(MouseButton::Right),
164            );
165        }
166        let frame = clock.advance(if paused { 0.0 } else { get_frame_time() as f64 });
167        for _ in 0..frame.steps {
168            sandbox.step(frame.dt);
169        }
170        let alpha = if paused { 1.0 } else { frame.alpha };
171
172        clear_background(ui::BACKGROUND);
173        for x in -12..=12 {
174            let start = to_screen(vec2(x as f32, 0.0));
175            let end = to_screen(vec2(x as f32, 13.0));
176            draw_line(
177                start.x,
178                start.y,
179                end.x,
180                end.y,
181                1.0,
182                Color::from_rgba(25, 38, 53, 255),
183            );
184        }
185        for y in 0..=13 {
186            let start = to_screen(vec2(-12.0, y as f32));
187            let end = to_screen(vec2(12.0, y as f32));
188            draw_line(
189                start.x,
190                start.y,
191                end.x,
192                end.y,
193                1.0,
194                Color::from_rgba(25, 38, 53, 255),
195            );
196        }
197        for (position, half) in &sandbox.platforms {
198            let top_left = to_screen(*position + vec2(-half.x, half.y));
199            draw_rectangle(
200                top_left.x,
201                top_left.y,
202                half.x * scale * 2.0,
203                half.y * scale * 2.0,
204                Color::from_rgba(43, 59, 73, 255),
205            );
206            draw_line(
207                top_left.x,
208                top_left.y,
209                top_left.x + half.x * scale * 2.0,
210                top_left.y,
211                2.0,
212                ui::MUTED,
213            );
214        }
215        for body in &sandbox.bodies {
216            let rigid_body = &sandbox.physics.bodies[body.handle];
217            let position = body
218                .previous_position
219                .lerp(Vec2::from_array(rigid_body.translation().to_array()), alpha);
220            let axis = *rigid_body.rotation() * d2::Vector::X;
221            let angle_now = axis.y.atan2(axis.x);
222            let difference = (angle_now - body.previous_angle + std::f32::consts::PI)
223                .rem_euclid(std::f32::consts::TAU)
224                - std::f32::consts::PI;
225            let angle = body.previous_angle + difference * alpha;
226            let center = to_screen(position);
227            let rotation = Vec2::from_angle(angle);
228            match body.shape {
229                Shape::Ball(radius) => {
230                    draw_circle(center.x, center.y, radius * scale, body.color);
231                    draw_circle_lines(
232                        center.x,
233                        center.y,
234                        radius * scale,
235                        1.5,
236                        Color::from_rgba(191, 239, 230, 255),
237                    );
238                    let end = to_screen(position + rotation * radius * 0.72);
239                    draw_line(center.x, center.y, end.x, end.y, 2.0, ui::BACKGROUND);
240                }
241                Shape::Box(half) => {
242                    let corners = [
243                        vec2(-half.x, -half.y),
244                        vec2(half.x, -half.y),
245                        vec2(half.x, half.y),
246                        vec2(-half.x, half.y),
247                    ]
248                    .map(|p| to_screen(position + rotation.rotate(p)));
249                    draw_triangle(corners[0], corners[1], corners[2], body.color);
250                    draw_triangle(corners[0], corners[2], corners[3], body.color);
251                    for index in 0..4 {
252                        let a = corners[index];
253                        let b = corners[(index + 1) % 4];
254                        draw_line(
255                            a.x,
256                            a.y,
257                            b.x,
258                            b.y,
259                            1.5,
260                            Color::from_rgba(250, 212, 147, 255),
261                        );
262                    }
263                    draw_line(
264                        corners[0].x,
265                        corners[0].y,
266                        corners[2].x,
267                        corners[2].y,
268                        1.0,
269                        Color::from_rgba(189, 126, 57, 255),
270                    );
271                }
272            }
273        }
274        if pointer_in_scene && sandbox.bodies.len() < MAX_BODIES {
275            if ball {
276                draw_circle_lines(pointer.x, pointer.y, 0.44 * scale, 1.0, ui::CYAN);
277            } else {
278                draw_rectangle_lines(
279                    pointer.x - 0.48 * scale,
280                    pointer.y - 0.48 * scale,
281                    0.96 * scale,
282                    0.96 * scale,
283                    1.0,
284                    ui::AMBER,
285                );
286            }
287        }
288        ui::header(
289            "2D RIGID BODIES",
290            "Gravity, in two dimensions.",
291            if screen_width() > 660.0 {
292                "Click or tap to drop a body  /  Right click: crate  /  Space: pause  /  R: reset"
293            } else {
294                "Tap to drop. Choose a shape below."
295            },
296        );
297        ui::panel(Rect::new(20.0, footer_y, screen_width() - 40.0, 78.0));
298        let button_width = ((screen_width() - 92.0) / 4.0).clamp(38.0, 84.0);
299        let button_rect = |index: f32| {
300            Rect::new(
301                34.0 + index * (button_width + 8.0),
302                footer_y + 20.0,
303                button_width,
304                36.0,
305            )
306        };
307        if ui::button(button_rect(0.0), "1  BALL", ball) {
308            ball = true;
309        }
310        if ui::button(button_rect(1.0), "2  CRATE", !ball) {
311            ball = false;
312        }
313        if ui::button(
314            button_rect(2.0),
315            if paused { "RESUME" } else { "PAUSE" },
316            paused,
317        ) {
318            paused = !paused;
319        }
320        if ui::button(button_rect(3.0), "RESET", false) {
321            sandbox = Sandbox::new();
322            clock.reset();
323        }
324        if screen_width() > 730.0 {
325            let awake = sandbox
326                .bodies
327                .iter()
328                .filter(|b| !sandbox.physics.bodies[b.handle].is_sleeping())
329                .count();
330            ui::stat(
331                430.0,
332                footer_y + 25.0,
333                "DYNAMIC BODIES",
334                &format!("{} / {MAX_BODIES}", sandbox.bodies.len()),
335            );
336            ui::stat(602.0, footer_y + 25.0, "AWAKE", &awake.to_string());
337        }
338        if screen_width() > 920.0 {
339            ui::stat(
340                744.0,
341                footer_y + 25.0,
342                "FRAME AVERAGE",
343                &format!("{:.1} ms", stats.mean_ms()),
344            );
345        }
346        if screen_width() > 1090.0 {
347            ui::stat(934.0, footer_y + 25.0, "FIXED SIMULATION", "120 Hz");
348        }
349        if demo.finish_frame() {
350            break;
351        }
352        next_frame().await;
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn recovered_sandbox_settles_with_finite_rotating_bodies() {
362        let mut sandbox = Sandbox::new();
363        let mut rotation_seen = false;
364        for _ in 0..720 {
365            sandbox.step(1.0 / 120.0);
366            for body in &sandbox.bodies {
367                let rigid = &sandbox.physics.bodies[body.handle];
368                assert!(rigid.translation().is_finite());
369                assert!(rigid.linvel().is_finite());
370                rotation_seen |= rigid.rotation().angle().abs() > 0.05;
371            }
372        }
373        assert!(rotation_seen, "contacts should rotate the falling bodies");
374        assert!(sandbox.bodies.iter().all(|body| {
375            let p = sandbox.physics.bodies[body.handle].translation();
376            p.y > 0.3 && p.x.abs() < 12.0
377        }));
378    }
379
380    #[test]
381    fn spawning_is_bounded_and_rebuild_replaces_the_world() {
382        let mut sandbox = Sandbox::new();
383        for i in 0..MAX_BODIES + 10 {
384            sandbox.spawn(vec2((i % 10) as f32 - 5.0, 12.0 + (i / 10) as f32), true);
385        }
386        assert_eq!(sandbox.bodies.len(), MAX_BODIES);
387        assert_eq!(
388            sandbox.physics.bodies.len(),
389            MAX_BODIES + sandbox.platforms.len()
390        );
391        sandbox = Sandbox::new();
392        assert_eq!(sandbox.bodies.len(), 30);
393        assert_eq!(sandbox.physics.bodies.len(), 35);
394    }
395}