Skip to main content

symtropy_bevy_core/
lib.rs

1// Copyright (c) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3//! # symtropy-bevy-core
4//!
5//! Permissively-licensed Bevy plugin for [`symtropy-physics`] N-dimensional physics.
6//! Zero AGPL dependencies.
7//!
8//! Use [`BevyPhysicsPlugin`] for drop-in physics with no coupling, or
9//! [`BevyPhysicsCorePlugin`] + [`step_physics`] with your own
10//! [`PhysicsCallback`] resource to couple any per-body metric to physics.
11//!
12//! For Φ (integrated information) coupling, see the AGPL sibling crate
13//! `symtropy-bevy` — same API, adds `ConsciousnessField` integration.
14
15use bevy::prelude::*;
16use nalgebra::SVector;
17use symtropy_physics::{body::BodyHandle, CollisionEvent, PhysicsCallback, PhysicsWorld};
18
19// --- Resource wrapping the physics world ---
20
21/// Bevy resource wrapping an N-dimensional [`PhysicsWorld`].
22///
23/// Access this in your systems to add bodies, step simulation manually,
24/// or query state.
25#[derive(Resource)]
26pub struct BevyPhysics<const D: usize> {
27    /// The N-dimensional rigid body world.
28    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    /// Create with custom gravity.
41    pub fn with_gravity(gravity: [f64; D]) -> Self {
42        Self {
43            world: PhysicsWorld::new(SVector::from(gravity)),
44        }
45    }
46}
47
48// --- Linking component ---
49
50/// Bevy component linking an entity to a physics body.
51///
52/// Attach to a Bevy entity with a `Transform`; `sync_transforms` writes the
53/// physics body's position into it each `FixedUpdate`.
54#[derive(Component)]
55pub struct PhysicsBody {
56    /// Handle to the body in the physics world.
57    pub handle: BodyHandle,
58    /// Visual radius for debug rendering.
59    pub visual_radius: f32,
60}
61
62impl PhysicsBody {
63    /// Create a new component for a given body handle and visual radius.
64    pub fn new(handle: BodyHandle, visual_radius: f32) -> Self {
65        Self {
66            handle,
67            visual_radius,
68        }
69    }
70}
71
72// --- Default no-coupling callback (resource flavor) ---
73
74/// Bevy `Resource` form of the identity "no-coupling" callback.
75///
76/// Forces, impulses, and friction pass through unchanged. Use when you want
77/// N-dimensional physics without any per-body state coupling. Implements
78/// [`PhysicsCallback<D>`] for all `D`.
79#[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
98// --- Systems ---
99
100/// Step the physics world with a generic callback `Resource`.
101///
102/// Bring your own `C: PhysicsCallback<D> + Resource` to couple a custom metric
103/// to physics, or use [`NoCouplingResource`] for uncoupled physics-only behavior.
104pub 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
114/// Sync physics body positions to Bevy `Transform`s.
115///
116/// 2D: writes `(x, y)` to `translation.x`/`.y`.
117/// 3D: writes `(x, y, z)` to `translation`.
118/// 4D: writes `(x, y, z)` to `translation` (w dropped — use `symtropy-render-bridge`
119/// for cross-section projection).
120pub 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
140// --- Plugins ---
141
142/// Minimal Bevy plugin: registers [`BevyPhysics<D>`] + [`sync_transforms`] only.
143///
144/// Use this when you're supplying your own step system with a custom
145/// [`PhysicsCallback`] resource.
146pub struct BevyPhysicsCorePlugin<const D: usize> {
147    /// Initial gravity.
148    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    /// Create with the given gravity vector.
161    pub fn with_gravity(gravity: [f64; D]) -> Self {
162        Self {
163            gravity: SVector::from(gravity),
164        }
165    }
166}
167
168/// Full Bevy plugin: registers [`BevyPhysics<D>`] + [`NoCouplingResource`] + a
169/// default [`step_physics`] system + [`sync_transforms`].
170///
171/// Drop in for N-dimensional physics with no coupling. For custom couplings,
172/// use [`BevyPhysicsCorePlugin`] and register your own step system.
173pub struct BevyPhysicsPlugin<const D: usize> {
174    /// Initial gravity.
175    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    /// Create with the given gravity vector.
188    pub fn with_gravity(gravity: [f64; D]) -> Self {
189        Self {
190            gravity: SVector::from(gravity),
191        }
192    }
193}
194
195// Per-dim Plugin impls — Bevy's Plugin trait isn't const-generic.
196
197macro_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// --- Tests ---
240
241#[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}