Skip to main content

symtropy_physics/
integrator.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
4//! Semi-implicit Euler integrator for ND rigid body dynamics.
5//!
6//! Angular velocity is a bivector (element of so(n)), integrated via the
7//! rotation matrix exponential. For small timesteps, the Rodrigues formula
8//! approximation is sufficient and avoids matrix exponential computation.
9
10use crate::body::RigidBody;
11use nalgebra::SVector;
12use symtropy_math::{Bivector, Point, Rotor};
13
14/// Integrate a single rigid body forward by dt seconds.
15///
16/// Semi-implicit Euler: update velocity first, then position.
17/// This is unconditionally stable for Hamiltonian systems (symplectic).
18pub fn integrate<const D: usize>(body: &mut RigidBody<D>, gravity: &SVector<f64, D>, dt: f64) {
19    if !body.is_dynamic() {
20        body.clear_accumulators();
21        return;
22    }
23
24    // --- NaN/Inf guards on inputs ---
25    // Sanitize force/torque accumulators: replace non-finite values with zero
26    // to prevent NaN propagation from bad external forces.
27    if !body.force_accumulator.iter().all(|v| v.is_finite()) {
28        body.force_accumulator = SVector::zeros();
29    }
30    if !body.torque_accumulator.is_finite() {
31        body.torque_accumulator = Bivector::zero();
32    }
33
34    // --- Linear dynamics ---
35
36    // Acceleration = F/m + gravity
37    let accel = body.force_accumulator * body.inv_mass + gravity;
38
39    // Semi-implicit: velocity first
40    body.linear_velocity += accel * dt;
41
42    // LTC-based damping: v(t+dt) = v(t) × exp(-dt/τ) where τ = 1/damping.
43    // Frame-rate independent, never reverses velocity (exp > 0), exact for any dt.
44    if body.linear_damping > 1e-15 {
45        let tau = 1.0 / body.linear_damping;
46        body.linear_velocity *= (-dt / tau).exp();
47    }
48
49    // Clamp velocity to prevent explosion
50    let speed = body.linear_velocity.norm();
51    if speed > 1000.0 {
52        body.linear_velocity *= 1000.0 / speed;
53    }
54
55    // Update position
56    let displacement = body.linear_velocity * dt;
57    body.transform.translation = Point(body.transform.translation.0 + displacement);
58
59    // --- Angular dynamics ---
60
61    // Angular acceleration = torque / inertia (scalar approximation)
62    // Per-axis inertia: use mean of principal moments for each bivector plane.
63    // For spheres (isotropic), this is exact. For asymmetric bodies, it's the
64    // average inertia of the two axes spanning each rotation plane.
65    let inv_i_avg = body.inv_inertia.sum() / D as f64;
66    let ang_accel = body.torque_accumulator.scale(inv_i_avg);
67
68    // Update angular velocity
69    body.angular_velocity = body.angular_velocity.add(&ang_accel.scale(dt));
70
71    // LTC angular damping (same closed-form)
72    if body.angular_damping > 1e-15 {
73        let ang_tau = 1.0 / body.angular_damping;
74        body.angular_velocity = body.angular_velocity.scale((-dt / ang_tau).exp());
75    }
76
77    // Clamp angular velocity
78    let ang_speed = body.angular_velocity.norm();
79    if ang_speed > 100.0 {
80        body.angular_velocity = body.angular_velocity.scale(100.0 / ang_speed);
81    }
82
83    // Integrate rotation: small angle approximation via Rodrigues
84    if ang_speed > 1e-12 {
85        let delta_angle = ang_speed * dt;
86        let delta_rotation = Rotor::from_plane_angle(&body.angular_velocity, delta_angle);
87        body.transform.rotation = delta_rotation.compose(&body.transform.rotation);
88    }
89
90    // --- Post-integration NaN/Inf assertions ---
91    // If any state became non-finite despite clamping, zero the offending values
92    // rather than propagating corruption through the simulation.
93    if !body.linear_velocity.iter().all(|v| v.is_finite()) {
94        debug_assert!(false, "NaN/Inf detected in linear_velocity after integration");
95        body.linear_velocity = SVector::zeros();
96    }
97    if !body.angular_velocity.is_finite() {
98        debug_assert!(false, "NaN/Inf detected in angular_velocity after integration");
99        body.angular_velocity = Bivector::zero();
100    }
101    if !body.transform.translation.0.iter().all(|v| v.is_finite()) {
102        debug_assert!(false, "NaN/Inf detected in position after integration");
103        // Don't zero position — that would teleport. Just freeze velocity.
104        body.linear_velocity = SVector::zeros();
105    }
106
107    // Clear accumulators for next frame
108    body.clear_accumulators();
109}
110
111/// Apply a position correction to separate overlapping bodies.
112pub fn separate<const D: usize>(
113    body: &mut RigidBody<D>,
114    correction: &SVector<f64, D>,
115) {
116    if body.is_dynamic() {
117        body.transform.translation = Point(body.transform.translation.0 + correction);
118    }
119}
120
121/// Apply an impulse to a body at its center of mass.
122pub fn apply_impulse<const D: usize>(
123    body: &mut RigidBody<D>,
124    impulse: &SVector<f64, D>,
125) {
126    if body.is_dynamic() {
127        body.linear_velocity += impulse * body.inv_mass;
128    }
129}
130
131/// Apply an angular impulse (as a bivector) to a body.
132pub fn apply_angular_impulse<const D: usize>(
133    body: &mut RigidBody<D>,
134    impulse: &Bivector<D>,
135) {
136    if body.is_dynamic() {
137        let inv_i_avg = body.inv_inertia.sum() / D as f64;
138        body.angular_velocity = body.angular_velocity.add(&impulse.scale(inv_i_avg));
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::body::BodyHandle;
146
147    #[test]
148    fn free_fall_3d() {
149        let mut body = RigidBody::<3>::dynamic_sphere(
150            BodyHandle(0),
151            Point::origin(),
152            1.0,
153            1.0,
154        );
155        let gravity = SVector::from([0.0, -9.81, 0.0]);
156        let dt = 0.01;
157
158        // Simulate 1 second of free fall (100 steps)
159        for _ in 0..100 {
160            integrate(&mut body, &gravity, dt);
161        }
162
163        // After 1s: y ≈ -0.5 * 9.81 * 1² ≈ -4.905 (with damping, less)
164        let y = body.transform.translation.coord(1);
165        assert!(y < -3.0, "y = {y}, expected < -3.0");
166        assert!(y > -6.0, "y = {y}, expected > -6.0 (damping bounds)");
167    }
168
169    #[test]
170    fn stationary_without_forces() {
171        let mut body = RigidBody::<3>::dynamic_sphere(
172            BodyHandle(0),
173            Point::new([5.0, 5.0, 5.0]),
174            1.0,
175            1.0,
176        );
177        let zero_gravity = SVector::zeros();
178        let dt = 0.01;
179
180        for _ in 0..100 {
181            integrate(&mut body, &zero_gravity, dt);
182        }
183
184        // Should barely move (damping causes slight drift toward zero vel)
185        assert!((body.transform.translation.coord(0) - 5.0).abs() < 0.1);
186    }
187
188    #[test]
189    fn applied_force_accelerates() {
190        let mut body = RigidBody::<3>::dynamic_sphere(
191            BodyHandle(0),
192            Point::origin(),
193            1.0,
194            2.0, // 2 kg
195        );
196        let zero_gravity = SVector::zeros();
197        let dt = 0.01;
198
199        // Apply 10N in x direction for 1 second
200        for _ in 0..100 {
201            body.apply_force(SVector::from([10.0, 0.0, 0.0]));
202            integrate(&mut body, &zero_gravity, dt);
203        }
204
205        // v ≈ F/m * t = 10/2 * 1 = 5 m/s (minus damping)
206        let vx = body.linear_velocity[0];
207        assert!(vx > 3.0, "vx = {vx}, expected > 3.0");
208        assert!(vx < 6.0, "vx = {vx}, expected < 6.0");
209    }
210
211    #[test]
212    fn angular_velocity_rotates_body() {
213        let mut body = RigidBody::<3>::dynamic_sphere(
214            BodyHandle(0),
215            Point::origin(),
216            1.0,
217            1.0,
218        );
219        // Set angular velocity in xy plane
220        body.angular_velocity = Bivector::<3>::unit_plane(0, 1).scale(1.0);
221
222        let zero_gravity = SVector::zeros();
223        let dt = 0.01;
224
225        for _ in 0..100 {
226            integrate(&mut body, &zero_gravity, dt);
227        }
228
229        // Body should have rotated — rotation matrix should not be identity
230        let mat = body.transform.rotation.to_matrix();
231        let trace = mat.trace();
232        // Identity has trace = 3, rotated will have trace < 3
233        assert!(trace < 2.99, "trace = {trace}, body didn't rotate");
234    }
235
236    #[test]
237    fn impulse_changes_velocity() {
238        let mut body = RigidBody::<4>::dynamic_sphere(
239            BodyHandle(0),
240            Point::origin(),
241            1.0,
242            5.0, // 5 kg
243        );
244        apply_impulse(&mut body, &SVector::from([50.0, 0.0, 0.0, 0.0]));
245        // Δv = impulse / mass = 50/5 = 10
246        assert!((body.linear_velocity[0] - 10.0).abs() < 1e-10);
247    }
248
249    #[test]
250    fn energy_conservation_no_damping() {
251        let mut body = RigidBody::<3>::dynamic_sphere(
252            BodyHandle(0),
253            Point::new([0.0, 100.0, 0.0]),
254            1.0,
255            1.0,
256        );
257        body.linear_damping = 0.0;
258        body.angular_damping = 0.0;
259
260        let gravity = SVector::from([0.0, -9.81, 0.0]);
261        let dt = 0.001; // Small timestep for accuracy
262
263        let initial_energy = body.kinetic_energy() + body.mass * 9.81 * body.transform.translation.coord(1);
264
265        for _ in 0..1000 {
266            integrate(&mut body, &gravity, dt);
267        }
268
269        let final_energy = body.kinetic_energy() + body.mass * 9.81 * body.transform.translation.coord(1);
270        let drift = ((final_energy - initial_energy) / initial_energy).abs();
271        assert!(drift < 0.01, "energy drift {drift} > 1%");
272    }
273
274    #[test]
275    fn static_body_not_affected() {
276        use symtropy_math::Sphere;
277        let mut body = RigidBody::<3>::static_body(
278            BodyHandle(0),
279            Point::new([5.0, 5.0, 5.0]),
280            Box::new(Sphere::unit()),
281        );
282        body.apply_force(SVector::from([1000.0, 0.0, 0.0]));
283        let gravity = SVector::from([0.0, -100.0, 0.0]);
284        integrate(&mut body, &gravity, 1.0);
285        assert!((body.transform.translation.coord(0) - 5.0).abs() < 1e-12);
286    }
287
288    #[test]
289    fn nan_force_is_sanitized() {
290        let mut body = RigidBody::<3>::dynamic_sphere(
291            BodyHandle(0),
292            Point::new([1.0, 2.0, 3.0]),
293            1.0,
294            1.0,
295        );
296        // Apply a NaN force — should be zeroed, not propagated
297        body.apply_force(SVector::from([f64::NAN, 0.0, 0.0]));
298        let gravity = SVector::zeros();
299        integrate(&mut body, &gravity, 0.01);
300
301        // Position and velocity must remain finite
302        assert!(body.linear_velocity.iter().all(|v| v.is_finite()),
303            "NaN force propagated to velocity");
304        assert!(body.transform.translation.0.iter().all(|v| v.is_finite()),
305            "NaN force propagated to position");
306    }
307
308    #[test]
309    fn inf_force_is_sanitized() {
310        let mut body = RigidBody::<3>::dynamic_sphere(
311            BodyHandle(0),
312            Point::origin(),
313            1.0,
314            1.0,
315        );
316        body.apply_force(SVector::from([f64::INFINITY, 0.0, 0.0]));
317        let gravity = SVector::zeros();
318        integrate(&mut body, &gravity, 0.01);
319
320        assert!(body.linear_velocity.iter().all(|v| v.is_finite()),
321            "Inf force propagated to velocity");
322    }
323}