Skip to main content

symtropy_physics/
body.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
4use nalgebra::SVector;
5use symtropy_math::{Bivector, Point, Shape, Sphere, Transform};
6
7/// Opaque handle to a rigid body in the physics world.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub struct BodyHandle(pub usize);
10
11/// Stable network identifier for a body, used for cross-machine replay determinism.
12///
13/// Unlike `BodyHandle` (which depends on insertion order), `NetId` is assigned by
14/// the application and survives serialization/deserialization.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct NetId(pub u64);
17
18/// Whether a body is affected by forces and collisions.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum BodyType {
21    /// Affected by forces and collisions.
22    Dynamic,
23    /// Not affected by forces, but participates in collisions (infinite mass).
24    Static,
25    /// Moved by user code, pushes dynamic bodies but isn't affected by them.
26    Kinematic,
27}
28
29/// A rigid body in D-dimensional space.
30pub struct RigidBody<const D: usize> {
31    pub handle: BodyHandle,
32    /// Stable network identifier for cross-machine replay. `None` for local-only bodies.
33    pub net_id: Option<NetId>,
34    pub body_type: BodyType,
35    pub transform: Transform<D>,
36    pub linear_velocity: SVector<f64, D>,
37    pub angular_velocity: Bivector<D>,
38    pub mass: f64,
39    pub inv_mass: f64,
40    /// Principal moments of inertia along each axis [I_x, I_y, ...].
41    /// For spheres: all equal (isotropic). For asymmetric bodies: different per axis.
42    /// Angular acceleration in bivector plane (i,j) uses avg(I_i, I_j).
43    pub inertia: SVector<f64, D>,
44    /// Inverse principal moments (1/I per axis). Zero for static bodies.
45    pub inv_inertia: SVector<f64, D>,
46    pub restitution: f64,
47    pub friction: f64,
48    /// Collider shape. Boxed because Shape<D> is a trait object.
49    pub collider: Box<dyn Shape<D>>,
50    /// Accumulated force this frame (cleared each step).
51    pub force_accumulator: SVector<f64, D>,
52    /// Accumulated torque this frame (as bivector, cleared each step).
53    pub torque_accumulator: Bivector<D>,
54    /// Linear damping factor (0.0 = no damping, 1.0 = full damping).
55    pub linear_damping: f64,
56    /// Angular damping factor.
57    pub angular_damping: f64,
58    /// Whether this body is sleeping (deactivated due to low velocity).
59    pub sleeping: bool,
60    /// Ticks since velocity dropped below sleep threshold.
61    pub sleep_counter: u32,
62    /// Collision group membership (bitmask). Body belongs to these groups.
63    /// Default: `0xFFFF_FFFF` (all groups).
64    pub collision_group: u32,
65    /// Collision filter mask. Body collides with bodies whose `collision_group`
66    /// overlaps this mask. Default: `0xFFFF_FFFF` (collides with everything).
67    pub collision_mask: u32,
68    /// Whether this body is a sensor/trigger (detects overlaps but doesn't resolve collisions).
69    pub is_sensor: bool,
70}
71
72impl<const D: usize> RigidBody<D> {
73    /// Create a dynamic body with a sphere collider.
74    pub fn dynamic_sphere(handle: BodyHandle, position: Point<D>, radius: f64, mass: f64) -> Self {
75        Self {
76            handle,
77            net_id: None,
78            body_type: BodyType::Dynamic,
79            transform: Transform::from_translation(position),
80            linear_velocity: SVector::zeros(),
81            angular_velocity: Bivector::zero(),
82            mass,
83            inv_mass: 1.0 / mass,
84            inertia: SVector::from_element(0.4 * mass * radius * radius), // 2/5 * m * r² (sphere, isotropic)
85            inv_inertia: SVector::from_element(1.0 / (0.4 * mass * radius * radius)),
86            restitution: 0.5,
87            friction: 0.3,
88            collider: Box::new(Sphere::new(Point::origin(), radius)),
89            force_accumulator: SVector::zeros(),
90            torque_accumulator: Bivector::zero(),
91            linear_damping: 0.01,
92            angular_damping: 0.05,
93            sleeping: false,
94            sleep_counter: 0,
95            collision_group: 0xFFFF_FFFF,
96            collision_mask: 0xFFFF_FFFF,
97            is_sensor: false,
98        }
99    }
100
101    /// Create a static body (infinite mass, zero velocity).
102    pub fn static_body(handle: BodyHandle, position: Point<D>, collider: Box<dyn Shape<D>>) -> Self {
103        Self {
104            handle,
105            net_id: None,
106            body_type: BodyType::Static,
107            transform: Transform::from_translation(position),
108            linear_velocity: SVector::zeros(),
109            angular_velocity: Bivector::zero(),
110            mass: f64::INFINITY,
111            inv_mass: 0.0,
112            inertia: SVector::from_element(f64::INFINITY),
113            inv_inertia: SVector::zeros(),
114            restitution: 0.5,
115            friction: 0.5,
116            collider,
117            force_accumulator: SVector::zeros(),
118            torque_accumulator: Bivector::zero(),
119            linear_damping: 0.0,
120            angular_damping: 0.0,
121            sleeping: false,
122            sleep_counter: 0,
123            collision_group: 0xFFFF_FFFF,
124            collision_mask: 0xFFFF_FFFF,
125            is_sensor: false,
126        }
127    }
128
129    /// Create a dynamic body with a custom collider (isotropic inertia).
130    pub fn dynamic(
131        handle: BodyHandle,
132        position: Point<D>,
133        mass: f64,
134        inertia: f64,
135        collider: Box<dyn Shape<D>>,
136    ) -> Self {
137        Self {
138            handle,
139            net_id: None,
140            body_type: BodyType::Dynamic,
141            transform: Transform::from_translation(position),
142            linear_velocity: SVector::zeros(),
143            angular_velocity: Bivector::zero(),
144            mass,
145            inv_mass: 1.0 / mass,
146            inertia: SVector::from_element(inertia),
147            inv_inertia: SVector::from_element(1.0 / inertia),
148            restitution: 0.5,
149            friction: 0.3,
150            collider,
151            force_accumulator: SVector::zeros(),
152            torque_accumulator: Bivector::zero(),
153            linear_damping: 0.01,
154            angular_damping: 0.05,
155            sleeping: false,
156            sleep_counter: 0,
157            collision_group: 0xFFFF_FFFF,
158            collision_mask: 0xFFFF_FFFF,
159            is_sensor: false,
160        }
161    }
162
163    /// Apply a force at the center of mass.
164    #[inline]
165    pub fn apply_force(&mut self, force: SVector<f64, D>) {
166        self.force_accumulator += force;
167    }
168
169    /// Apply a torque (as a bivector).
170    #[inline]
171    pub fn apply_torque(&mut self, torque: Bivector<D>) {
172        self.torque_accumulator = self.torque_accumulator.add(&torque);
173    }
174
175    /// Clear accumulated forces and torques.
176    #[inline]
177    pub fn clear_accumulators(&mut self) {
178        self.force_accumulator = SVector::zeros();
179        self.torque_accumulator = Bivector::zero();
180    }
181
182    /// Position of the body's center of mass.
183    #[inline]
184    pub fn position(&self) -> &Point<D> {
185        &self.transform.translation
186    }
187
188    /// Whether this body can be moved by forces.
189    #[inline]
190    pub fn is_dynamic(&self) -> bool {
191        self.body_type == BodyType::Dynamic
192    }
193
194    /// Wake this body from sleep.
195    #[inline]
196    pub fn wake(&mut self) {
197        self.sleeping = false;
198        self.sleep_counter = 0;
199    }
200
201    /// Check if this body should go to sleep (low velocity for many ticks).
202    /// Returns true if the body just fell asleep.
203    pub fn try_sleep(&mut self, threshold: f64, ticks_required: u32) -> bool {
204        if !self.is_dynamic() || self.sleeping {
205            return false;
206        }
207        let speed = self.linear_velocity.norm() + self.angular_velocity.norm();
208        if speed < threshold {
209            self.sleep_counter += 1;
210            if self.sleep_counter >= ticks_required {
211                self.sleeping = true;
212                self.linear_velocity = SVector::zeros();
213                self.angular_velocity = Bivector::zero();
214                return true;
215            }
216        } else {
217            self.sleep_counter = 0;
218        }
219        false
220    }
221
222    /// Kinetic energy: 0.5 * m * v² + 0.5 * I_avg * ω²
223    /// Uses mean principal moment for angular KE (exact for isotropic bodies).
224    pub fn kinetic_energy(&self) -> f64 {
225        let linear = 0.5 * self.mass * self.linear_velocity.norm_squared();
226        let i_avg = self.inertia.sum() / D as f64;
227        let angular = 0.5 * i_avg * self.angular_velocity.norm_squared();
228        linear + angular
229    }
230
231    /// Support point in world space (transforms collider support by body pose).
232    pub fn world_support(&self, direction: &SVector<f64, D>) -> SVector<f64, D> {
233        // Transform direction to local space
234        let local_dir = self.transform.rotation.reverse().rotate_vector(direction);
235        // Get local support
236        let local_support = self.collider.support(&local_dir);
237        // Transform back to world space
238        self.transform.transform_point(&Point(local_support)).0
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn dynamic_sphere_creation() {
248        let body = RigidBody::<3>::dynamic_sphere(
249            BodyHandle(0),
250            Point::new([1.0, 2.0, 3.0]),
251            1.0,
252            10.0,
253        );
254        assert!(body.is_dynamic());
255        assert!((body.mass - 10.0).abs() < 1e-12);
256        assert!((body.inv_mass - 0.1).abs() < 1e-12);
257    }
258
259    #[test]
260    fn static_body_infinite_mass() {
261        let body = RigidBody::<3>::static_body(
262            BodyHandle(0),
263            Point::origin(),
264            Box::new(Sphere::<3>::unit()),
265        );
266        assert!(!body.is_dynamic());
267        assert_eq!(body.inv_mass, 0.0);
268    }
269
270    #[test]
271    fn force_accumulation() {
272        let mut body = RigidBody::<3>::dynamic_sphere(
273            BodyHandle(0),
274            Point::origin(),
275            1.0,
276            1.0,
277        );
278        body.apply_force(SVector::from([1.0, 0.0, 0.0]));
279        body.apply_force(SVector::from([0.0, 2.0, 0.0]));
280        assert!((body.force_accumulator[0] - 1.0).abs() < 1e-12);
281        assert!((body.force_accumulator[1] - 2.0).abs() < 1e-12);
282        body.clear_accumulators();
283        assert!(body.force_accumulator.norm() < 1e-12);
284    }
285
286    #[test]
287    fn kinetic_energy_at_rest_is_zero() {
288        let body = RigidBody::<4>::dynamic_sphere(
289            BodyHandle(0),
290            Point::origin(),
291            1.0,
292            1.0,
293        );
294        assert!(body.kinetic_energy() < 1e-12);
295    }
296
297    #[test]
298    fn world_support_translated() {
299        let body = RigidBody::<3>::dynamic_sphere(
300            BodyHandle(0),
301            Point::new([10.0, 0.0, 0.0]),
302            1.0,
303            1.0,
304        );
305        let dir = SVector::from([1.0, 0.0, 0.0]);
306        let sp = body.world_support(&dir);
307        // Sphere of radius 1 centered at (10,0,0), support in +x = (11,0,0)
308        assert!((sp[0] - 11.0).abs() < 1e-10);
309    }
310}