Skip to main content

symtropy_physics/
integrator.rs

1// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
2// SPDX-License-Identifier: Apache-2.0 OR MIT
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!(
95            false,
96            "NaN/Inf detected in linear_velocity after integration"
97        );
98        body.linear_velocity = SVector::zeros();
99    }
100    if !body.angular_velocity.is_finite() {
101        debug_assert!(
102            false,
103            "NaN/Inf detected in angular_velocity after integration"
104        );
105        body.angular_velocity = Bivector::zero();
106    }
107    if !body.transform.translation.0.iter().all(|v| v.is_finite()) {
108        debug_assert!(false, "NaN/Inf detected in position after integration");
109        // Don't zero position — that would teleport. Just freeze velocity.
110        body.linear_velocity = SVector::zeros();
111    }
112
113    // Clear accumulators for next frame
114    body.clear_accumulators();
115}
116
117/// Apply a position correction to separate overlapping bodies.
118pub fn separate<const D: usize>(body: &mut RigidBody<D>, correction: &SVector<f64, D>) {
119    if body.is_dynamic() {
120        body.transform.translation = Point(body.transform.translation.0 + correction);
121    }
122}
123
124/// Apply an impulse to a body at its center of mass.
125pub fn apply_impulse<const D: usize>(body: &mut RigidBody<D>, impulse: &SVector<f64, D>) {
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>(body: &mut RigidBody<D>, impulse: &Bivector<D>) {
133    if body.is_dynamic() {
134        let inv_i_avg = body.inv_inertia.sum() / D as f64;
135        body.angular_velocity = body.angular_velocity.add(&impulse.scale(inv_i_avg));
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::body::BodyHandle;
143
144    #[test]
145    fn free_fall_3d() {
146        let mut body = RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::origin(), 1.0, 1.0);
147        let gravity = SVector::from([0.0, -9.81, 0.0]);
148        let dt = 0.01;
149
150        // Simulate 1 second of free fall (100 steps)
151        for _ in 0..100 {
152            integrate(&mut body, &gravity, dt);
153        }
154
155        // After 1s: y ≈ -0.5 * 9.81 * 1² ≈ -4.905 (with damping, less)
156        let y = body.transform.translation.coord(1);
157        assert!(y < -3.0, "y = {y}, expected < -3.0");
158        assert!(y > -6.0, "y = {y}, expected > -6.0 (damping bounds)");
159    }
160
161    #[test]
162    fn stationary_without_forces() {
163        let mut body =
164            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([5.0, 5.0, 5.0]), 1.0, 1.0);
165        let zero_gravity = SVector::zeros();
166        let dt = 0.01;
167
168        for _ in 0..100 {
169            integrate(&mut body, &zero_gravity, dt);
170        }
171
172        // Should barely move (damping causes slight drift toward zero vel)
173        assert!((body.transform.translation.coord(0) - 5.0).abs() < 0.1);
174    }
175
176    #[test]
177    fn applied_force_accelerates() {
178        let mut body = RigidBody::<3>::dynamic_sphere(
179            BodyHandle(0),
180            Point::origin(),
181            1.0,
182            2.0, // 2 kg
183        );
184        let zero_gravity = SVector::zeros();
185        let dt = 0.01;
186
187        // Apply 10N in x direction for 1 second
188        for _ in 0..100 {
189            body.apply_force(SVector::from([10.0, 0.0, 0.0]));
190            integrate(&mut body, &zero_gravity, dt);
191        }
192
193        // v ≈ F/m * t = 10/2 * 1 = 5 m/s (minus damping)
194        let vx = body.linear_velocity[0];
195        assert!(vx > 3.0, "vx = {vx}, expected > 3.0");
196        assert!(vx < 6.0, "vx = {vx}, expected < 6.0");
197    }
198
199    #[test]
200    fn angular_velocity_rotates_body() {
201        let mut body = RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::origin(), 1.0, 1.0);
202        // Set angular velocity in xy plane
203        body.angular_velocity = Bivector::<3>::unit_plane(0, 1).scale(1.0);
204
205        let zero_gravity = SVector::zeros();
206        let dt = 0.01;
207
208        for _ in 0..100 {
209            integrate(&mut body, &zero_gravity, dt);
210        }
211
212        // Body should have rotated — rotation matrix should not be identity
213        let mat = body.transform.rotation.to_matrix();
214        let trace = mat.trace();
215        // Identity has trace = 3, rotated will have trace < 3
216        assert!(trace < 2.99, "trace = {trace}, body didn't rotate");
217    }
218
219    #[test]
220    fn impulse_changes_velocity() {
221        let mut body = RigidBody::<4>::dynamic_sphere(
222            BodyHandle(0),
223            Point::origin(),
224            1.0,
225            5.0, // 5 kg
226        );
227        apply_impulse(&mut body, &SVector::from([50.0, 0.0, 0.0, 0.0]));
228        // Δv = impulse / mass = 50/5 = 10
229        assert!((body.linear_velocity[0] - 10.0).abs() < 1e-10);
230    }
231
232    #[test]
233    fn energy_conservation_no_damping() {
234        let mut body =
235            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([0.0, 100.0, 0.0]), 1.0, 1.0);
236        body.linear_damping = 0.0;
237        body.angular_damping = 0.0;
238
239        let gravity = SVector::from([0.0, -9.81, 0.0]);
240        let dt = 0.001; // Small timestep for accuracy
241
242        let initial_energy =
243            body.kinetic_energy() + body.mass * 9.81 * body.transform.translation.coord(1);
244
245        for _ in 0..1000 {
246            integrate(&mut body, &gravity, dt);
247        }
248
249        let final_energy =
250            body.kinetic_energy() + body.mass * 9.81 * body.transform.translation.coord(1);
251        let drift = ((final_energy - initial_energy) / initial_energy).abs();
252        assert!(drift < 0.01, "energy drift {drift} > 1%");
253    }
254
255    #[test]
256    fn static_body_not_affected() {
257        use symtropy_math::Sphere;
258        let mut body = RigidBody::<3>::static_body(
259            BodyHandle(0),
260            Point::new([5.0, 5.0, 5.0]),
261            Box::new(Sphere::unit()),
262        );
263        body.apply_force(SVector::from([1000.0, 0.0, 0.0]));
264        let gravity = SVector::from([0.0, -100.0, 0.0]);
265        integrate(&mut body, &gravity, 1.0);
266        assert!((body.transform.translation.coord(0) - 5.0).abs() < 1e-12);
267    }
268
269    #[test]
270    fn nan_force_is_sanitized() {
271        let mut body =
272            RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::new([1.0, 2.0, 3.0]), 1.0, 1.0);
273        // Apply a NaN force — should be zeroed, not propagated
274        body.apply_force(SVector::from([f64::NAN, 0.0, 0.0]));
275        let gravity = SVector::zeros();
276        integrate(&mut body, &gravity, 0.01);
277
278        // Position and velocity must remain finite
279        assert!(
280            body.linear_velocity.iter().all(|v| v.is_finite()),
281            "NaN force propagated to velocity"
282        );
283        assert!(
284            body.transform.translation.0.iter().all(|v| v.is_finite()),
285            "NaN force propagated to position"
286        );
287    }
288
289    #[test]
290    fn inf_force_is_sanitized() {
291        let mut body = RigidBody::<3>::dynamic_sphere(BodyHandle(0), Point::origin(), 1.0, 1.0);
292        body.apply_force(SVector::from([f64::INFINITY, 0.0, 0.0]));
293        let gravity = SVector::zeros();
294        integrate(&mut body, &gravity, 0.01);
295
296        assert!(
297            body.linear_velocity.iter().all(|v| v.is_finite()),
298            "Inf force propagated to velocity"
299        );
300    }
301}