1use bevy::prelude::*;
16use nalgebra::SVector;
17use symtropy_physics::{body::BodyHandle, CollisionEvent, PhysicsCallback, PhysicsWorld};
18
19#[derive(Resource)]
26pub struct BevyPhysics<const D: usize> {
27 pub world: PhysicsWorld<D>,
29}
30
31impl<const D: usize> Default for BevyPhysics<D> {
32 fn default() -> Self {
33 Self {
34 world: PhysicsWorld::new(SVector::zeros()),
35 }
36 }
37}
38
39impl<const D: usize> BevyPhysics<D> {
40 pub fn with_gravity(gravity: [f64; D]) -> Self {
42 Self {
43 world: PhysicsWorld::new(SVector::from(gravity)),
44 }
45 }
46}
47
48#[derive(Component)]
55pub struct PhysicsBody {
56 pub handle: BodyHandle,
58 pub visual_radius: f32,
60}
61
62impl PhysicsBody {
63 pub fn new(handle: BodyHandle, visual_radius: f32) -> Self {
65 Self {
66 handle,
67 visual_radius,
68 }
69 }
70}
71
72#[derive(Resource, Default)]
80pub struct NoCouplingResource;
81
82impl<const D: usize> PhysicsCallback<D> for NoCouplingResource {
83 fn modulate_force(&self, _: BodyHandle, force: &SVector<f64, D>) -> SVector<f64, D> {
84 *force
85 }
86 fn modulate_impulse(&self, impulse: f64, _: &SVector<f64, D>) -> f64 {
87 impulse
88 }
89 fn friction_multiplier(&self, _: &SVector<f64, D>, _: BodyHandle) -> f64 {
90 1.0
91 }
92 fn on_collision(&mut self, _: &CollisionEvent<D>) {}
93 fn record_dissipation(&mut self, _: f64) {}
94 fn record_work(&mut self, _: BodyHandle, _: f64) {}
95 fn apply_trauma(&mut self, _: &CollisionEvent<D>) {}
96}
97
98pub fn step_physics<const D: usize, C: PhysicsCallback<D> + Resource>(
105 mut physics: ResMut<BevyPhysics<D>>,
106 mut cb: ResMut<C>,
107 time: Res<Time<Fixed>>,
108) {
109 physics
110 .world
111 .step_with_callback(time.delta_secs_f64(), &mut *cb);
112}
113
114pub fn sync_transforms<const D: usize>(
121 physics: Res<BevyPhysics<D>>,
122 mut query: Query<(&PhysicsBody, &mut Transform)>,
123) {
124 for (body_comp, mut transform) in &mut query {
125 if let Some(body) = physics.world.body(body_comp.handle) {
126 let pos = body.position();
127 if D >= 1 {
128 transform.translation.x = pos[0] as f32;
129 }
130 if D >= 2 {
131 transform.translation.y = pos[1] as f32;
132 }
133 if D >= 3 {
134 transform.translation.z = pos[2] as f32;
135 }
136 }
137 }
138}
139
140pub struct BevyPhysicsCorePlugin<const D: usize> {
147 pub gravity: SVector<f64, D>,
149}
150
151impl<const D: usize> Default for BevyPhysicsCorePlugin<D> {
152 fn default() -> Self {
153 Self {
154 gravity: SVector::zeros(),
155 }
156 }
157}
158
159impl<const D: usize> BevyPhysicsCorePlugin<D> {
160 pub fn with_gravity(gravity: [f64; D]) -> Self {
162 Self {
163 gravity: SVector::from(gravity),
164 }
165 }
166}
167
168pub struct BevyPhysicsPlugin<const D: usize> {
174 pub gravity: SVector<f64, D>,
176}
177
178impl<const D: usize> Default for BevyPhysicsPlugin<D> {
179 fn default() -> Self {
180 Self {
181 gravity: SVector::zeros(),
182 }
183 }
184}
185
186impl<const D: usize> BevyPhysicsPlugin<D> {
187 pub fn with_gravity(gravity: [f64; D]) -> Self {
189 Self {
190 gravity: SVector::from(gravity),
191 }
192 }
193}
194
195macro_rules! impl_core_plugin {
198 ($d:literal) => {
199 impl Plugin for BevyPhysicsCorePlugin<$d> {
200 fn build(&self, app: &mut App) {
201 app.insert_resource(BevyPhysics::<$d> {
202 world: PhysicsWorld::new(self.gravity),
203 });
204 app.add_systems(FixedUpdate, sync_transforms::<$d>);
205 }
206 }
207 };
208}
209
210macro_rules! impl_full_plugin {
211 ($d:literal) => {
212 impl Plugin for BevyPhysicsPlugin<$d> {
213 fn build(&self, app: &mut App) {
214 app.insert_resource(BevyPhysics::<$d> {
215 world: PhysicsWorld::new(self.gravity),
216 });
217 app.insert_resource(NoCouplingResource);
218 app.add_systems(
219 FixedUpdate,
220 (
221 step_physics::<$d, NoCouplingResource>,
222 sync_transforms::<$d>,
223 )
224 .chain(),
225 );
226 }
227 }
228 };
229}
230
231impl_core_plugin!(2);
232impl_core_plugin!(3);
233impl_core_plugin!(4);
234
235impl_full_plugin!(2);
236impl_full_plugin!(3);
237impl_full_plugin!(4);
238
239#[cfg(test)]
242mod tests {
243 use super::*;
244 use symtropy_math::Point;
245
246 #[test]
247 fn bevy_physics_default_has_zero_gravity_2d() {
248 let p = BevyPhysics::<2>::default();
249 let g = p.world.gravity;
250 assert_eq!(g[0], 0.0);
251 assert_eq!(g[1], 0.0);
252 }
253
254 #[test]
255 fn bevy_physics_with_gravity_stores_gravity() {
256 let p = BevyPhysics::<3>::with_gravity([0.0, -9.81, 0.0]);
257 let g = p.world.gravity;
258 assert!((g[1] - (-9.81)).abs() < 1e-9);
259 }
260
261 #[test]
262 fn physics_body_new_stores_handle_and_radius() {
263 let mut world = PhysicsWorld::<2>::new(SVector::zeros());
264 let h = world.add_sphere(Point::new([0.0, 0.0]), 1.0, 1.0);
265 let b = PhysicsBody::new(h, 0.5);
266 assert_eq!(b.handle, h);
267 assert!((b.visual_radius - 0.5).abs() < 1e-9);
268 }
269
270 #[test]
271 fn no_coupling_modulate_force_is_identity() {
272 let cb = NoCouplingResource;
273 let mut world = PhysicsWorld::<2>::new(SVector::zeros());
274 let h = world.add_sphere(Point::new([0.0, 0.0]), 1.0, 1.0);
275 let f_in = SVector::from([3.14, -2.71]);
276 let f_out = <NoCouplingResource as PhysicsCallback<2>>::modulate_force(&cb, h, &f_in);
277 assert_eq!(f_in, f_out);
278 }
279
280 #[test]
281 fn no_coupling_friction_multiplier_is_one() {
282 let cb = NoCouplingResource;
283 let mut world = PhysicsWorld::<3>::new(SVector::zeros());
284 let h = world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
285 let point = SVector::from([0.0, 0.0, 0.0]);
286 let mu = <NoCouplingResource as PhysicsCallback<3>>::friction_multiplier(&cb, &point, h);
287 assert!((mu - 1.0).abs() < 1e-9);
288 }
289
290 #[test]
291 fn core_plugin_2d_registers_resource() {
292 let mut app = App::new();
293 BevyPhysicsCorePlugin::<2>::default().build(&mut app);
294 assert!(app.world().contains_resource::<BevyPhysics<2>>());
295 }
296
297 #[test]
298 fn full_plugin_2d_registers_both_resources() {
299 let mut app = App::new();
300 BevyPhysicsPlugin::<2>::default().build(&mut app);
301 assert!(app.world().contains_resource::<BevyPhysics<2>>());
302 assert!(app.world().contains_resource::<NoCouplingResource>());
303 }
304
305 #[test]
306 fn full_plugin_3d_registers_both_resources() {
307 let mut app = App::new();
308 BevyPhysicsPlugin::<3>::default().build(&mut app);
309 assert!(app.world().contains_resource::<BevyPhysics<3>>());
310 assert!(app.world().contains_resource::<NoCouplingResource>());
311 }
312
313 #[test]
314 fn full_plugin_4d_registers_both_resources() {
315 let mut app = App::new();
316 BevyPhysicsPlugin::<4>::default().build(&mut app);
317 assert!(app.world().contains_resource::<BevyPhysics<4>>());
318 assert!(app.world().contains_resource::<NoCouplingResource>());
319 }
320
321 #[test]
322 fn plugin_with_gravity_resource_accessible() {
323 let mut app = App::new();
324 BevyPhysicsPlugin::<2>::with_gravity([0.0, -9.81]).build(&mut app);
325 let res = app.world().resource::<BevyPhysics<2>>();
326 let g = res.world.gravity;
327 assert!((g[1] - (-9.81)).abs() < 1e-9);
328 }
329
330 #[test]
331 fn manual_step_with_no_coupling_gravity_pulls_body_down() {
332 let mut p = BevyPhysics::<2>::with_gravity([0.0, -9.81]);
333 let h = p.world.add_sphere(Point::new([0.0, 10.0]), 1.0, 1.0);
334 let y0 = p.world.body(h).unwrap().position().coord(1);
335 let mut cb = NoCouplingResource;
336 for _ in 0..10 {
337 p.world.step_with_callback(1.0 / 60.0, &mut cb);
338 }
339 let y1 = p.world.body(h).unwrap().position().coord(1);
340 assert!(y1 < y0, "gravity should pull body down: y0={y0}, y1={y1}");
341 }
342
343 #[test]
344 fn can_add_multiple_bodies() {
345 let mut p = BevyPhysics::<3>::default();
346 let h1 = p.world.add_sphere(Point::new([0.0, 0.0, 0.0]), 1.0, 1.0);
347 let h2 = p.world.add_sphere(Point::new([5.0, 0.0, 0.0]), 1.0, 1.0);
348 assert!(p.world.body(h1).is_some());
349 assert!(p.world.body(h2).is_some());
350 }
351}