Skip to main content

use_rigidbody/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Scalar rigid-body mechanics helpers.
5
6pub mod prelude;
7
8/// Mass and rotational inertia for a scalar rigid body.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct MassProperties {
11    pub mass: f64,
12    pub moment_of_inertia: f64,
13}
14
15impl MassProperties {
16    /// Creates mass properties when both values are finite and non-negative.
17    #[must_use]
18    pub fn new(mass: f64, moment_of_inertia: f64) -> Option<Self> {
19        if !is_nonnegative_finite(mass) || !is_nonnegative_finite(moment_of_inertia) {
20            return None;
21        }
22
23        Some(Self {
24            mass,
25            moment_of_inertia,
26        })
27    }
28
29    /// Creates mass properties for a point mass using `I = mr²`.
30    #[must_use]
31    pub fn point_mass(mass: f64, radius: f64) -> Option<Self> {
32        let moment_of_inertia = point_mass_moment_of_inertia(mass, radius)?;
33
34        Self::new(mass, moment_of_inertia)
35    }
36
37    /// Creates mass properties for a solid disk using `I = 0.5mr²`.
38    #[must_use]
39    pub fn solid_disk(mass: f64, radius: f64) -> Option<Self> {
40        let moment_of_inertia = solid_disk_moment_of_inertia(mass, radius)?;
41
42        Self::new(mass, moment_of_inertia)
43    }
44
45    /// Creates mass properties for a thin ring using `I = mr²`.
46    #[must_use]
47    pub fn thin_ring(mass: f64, radius: f64) -> Option<Self> {
48        let moment_of_inertia = thin_ring_moment_of_inertia(mass, radius)?;
49
50        Self::new(mass, moment_of_inertia)
51    }
52
53    /// Creates mass properties for a solid sphere using `I = (2 / 5)mr²`.
54    ///
55    /// # Examples
56    ///
57    /// ```rust
58    /// use use_rigidbody::MassProperties;
59    ///
60    /// let props = MassProperties::solid_sphere(5.0, 2.0).unwrap();
61    ///
62    /// assert_eq!(props.moment_of_inertia, 8.0);
63    /// ```
64    #[must_use]
65    pub fn solid_sphere(mass: f64, radius: f64) -> Option<Self> {
66        let moment_of_inertia = solid_sphere_moment_of_inertia(mass, radius)?;
67
68        Self::new(mass, moment_of_inertia)
69    }
70
71    /// Creates mass properties for a hollow sphere using `I = (2 / 3)mr²`.
72    #[must_use]
73    pub fn hollow_sphere(mass: f64, radius: f64) -> Option<Self> {
74        let moment_of_inertia = hollow_sphere_moment_of_inertia(mass, radius)?;
75
76        Self::new(mass, moment_of_inertia)
77    }
78
79    /// Creates mass properties for a rod about its center using `I = (1 / 12)mL²`.
80    #[must_use]
81    pub fn rod_about_center(mass: f64, length: f64) -> Option<Self> {
82        let moment_of_inertia = rod_moment_of_inertia_about_center(mass, length)?;
83
84        Self::new(mass, moment_of_inertia)
85    }
86
87    /// Creates mass properties for a rod about one end using `I = (1 / 3)mL²`.
88    #[must_use]
89    pub fn rod_about_end(mass: f64, length: f64) -> Option<Self> {
90        let moment_of_inertia = rod_moment_of_inertia_about_end(mass, length)?;
91
92        Self::new(mass, moment_of_inertia)
93    }
94
95    /// Applies the parallel-axis theorem using `I = I_cm + md²`.
96    #[must_use]
97    pub fn shifted_by_parallel_axis(self, distance: f64) -> Option<Self> {
98        let moment_of_inertia =
99            parallel_axis_moment_of_inertia(self.moment_of_inertia, self.mass, distance)?;
100
101        Self::new(self.mass, moment_of_inertia)
102    }
103}
104
105/// A one-dimensional rigid body with scalar translational and rotational state.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct RigidBody1D {
108    pub mass_properties: MassProperties,
109    pub position: f64,
110    pub velocity: f64,
111    pub angle: f64,
112    pub angular_velocity: f64,
113}
114
115impl RigidBody1D {
116    /// Creates a rigid body when the state values are finite.
117    #[must_use]
118    pub const fn new(
119        mass_properties: MassProperties,
120        position: f64,
121        velocity: f64,
122        angle: f64,
123        angular_velocity: f64,
124    ) -> Option<Self> {
125        if !position.is_finite()
126            || !velocity.is_finite()
127            || !angle.is_finite()
128            || !angular_velocity.is_finite()
129        {
130            return None;
131        }
132
133        Some(Self {
134            mass_properties,
135            position,
136            velocity,
137            angle,
138            angular_velocity,
139        })
140    }
141
142    /// Returns the body's mass.
143    #[must_use]
144    pub const fn mass(&self) -> f64 {
145        self.mass_properties.mass
146    }
147
148    /// Returns the body's moment of inertia.
149    #[must_use]
150    pub const fn moment_of_inertia(&self) -> f64 {
151        self.mass_properties.moment_of_inertia
152    }
153
154    /// Computes linear momentum using `p = mv`.
155    #[must_use]
156    pub fn linear_momentum(&self) -> Option<f64> {
157        linear_momentum(self.mass(), self.velocity)
158    }
159
160    /// Computes angular momentum using `L = Iω`.
161    #[must_use]
162    pub fn angular_momentum(&self) -> Option<f64> {
163        angular_momentum(self.moment_of_inertia(), self.angular_velocity)
164    }
165
166    /// Computes translational kinetic energy using `KE = 0.5mv²`.
167    #[must_use]
168    pub fn linear_kinetic_energy(&self) -> Option<f64> {
169        linear_kinetic_energy(self.mass(), self.velocity)
170    }
171
172    /// Computes rotational kinetic energy using `KE_rot = 0.5Iω²`.
173    #[must_use]
174    pub fn rotational_kinetic_energy(&self) -> Option<f64> {
175        rotational_kinetic_energy(self.moment_of_inertia(), self.angular_velocity)
176    }
177
178    /// Computes total kinetic energy as translational plus rotational energy.
179    #[must_use]
180    pub fn total_kinetic_energy(&self) -> Option<f64> {
181        total_kinetic_energy(
182            self.mass(),
183            self.velocity,
184            self.moment_of_inertia(),
185            self.angular_velocity,
186        )
187    }
188
189    /// Returns a copy with updated velocity after a linear impulse.
190    ///
191    /// # Examples
192    ///
193    /// ```rust
194    /// use use_rigidbody::{MassProperties, RigidBody1D};
195    ///
196    /// let props = MassProperties::new(2.0, 4.0).unwrap();
197    /// let body = RigidBody1D::new(props, 10.0, 3.0, 1.0, 5.0).unwrap();
198    ///
199    /// assert_eq!(body.with_impulse(4.0).unwrap().velocity, 5.0);
200    /// ```
201    #[must_use]
202    pub fn with_impulse(self, impulse: f64) -> Option<Self> {
203        let velocity = velocity_after_impulse(self.mass(), self.velocity, impulse)?;
204
205        Self::new(
206            self.mass_properties,
207            self.position,
208            velocity,
209            self.angle,
210            self.angular_velocity,
211        )
212    }
213
214    /// Returns a copy with updated angular velocity after an angular impulse.
215    #[must_use]
216    pub fn with_angular_impulse(self, angular_impulse: f64) -> Option<Self> {
217        let angular_velocity = angular_velocity_after_angular_impulse(
218            self.moment_of_inertia(),
219            self.angular_velocity,
220            angular_impulse,
221        )?;
222
223        Self::new(
224            self.mass_properties,
225            self.position,
226            self.velocity,
227            self.angle,
228            angular_velocity,
229        )
230    }
231
232    /// Advances position and angle kinematically using the current velocities.
233    ///
234    /// This helper updates:
235    ///
236    /// - `position += velocity * time`
237    /// - `angle += angular_velocity * time`
238    ///
239    /// # Examples
240    ///
241    /// ```rust
242    /// use use_rigidbody::{MassProperties, RigidBody1D};
243    ///
244    /// let props = MassProperties::new(2.0, 4.0).unwrap();
245    /// let body = RigidBody1D::new(props, 10.0, 3.0, 1.0, 5.0).unwrap();
246    /// let advanced = body.advanced_kinematically(2.0).unwrap();
247    ///
248    /// assert_eq!(advanced.position, 16.0);
249    /// assert_eq!(advanced.angle, 11.0);
250    /// ```
251    #[must_use]
252    pub fn advanced_kinematically(self, time: f64) -> Option<Self> {
253        if !is_nonnegative_finite(time) {
254            return None;
255        }
256
257        let position = finite_result(self.velocity.mul_add(time, self.position))?;
258        let angle = finite_result(self.angular_velocity.mul_add(time, self.angle))?;
259
260        Self::new(
261            self.mass_properties,
262            position,
263            self.velocity,
264            angle,
265            self.angular_velocity,
266        )
267    }
268}
269
270/// Computes the one-dimensional center of mass using `x_cm = Σ(m_i * x_i) / Σm_i`.
271///
272/// Returns `None` when the slices have different lengths, are empty, contain invalid inputs, or
273/// when the computed result is not finite.
274///
275/// # Examples
276///
277/// ```rust
278/// use use_rigidbody::center_of_mass_1d;
279///
280/// assert_eq!(center_of_mass_1d(&[1.0, 3.0], &[0.0, 10.0]), Some(7.5));
281/// ```
282#[must_use]
283pub fn center_of_mass_1d(masses: &[f64], positions: &[f64]) -> Option<f64> {
284    if masses.len() != positions.len() || masses.is_empty() {
285        return None;
286    }
287
288    let (weighted_position_sum, total_mass) = masses.iter().zip(positions).try_fold(
289        (0.0, 0.0),
290        |(weighted_sum, total_mass), (mass, position)| {
291            if !is_nonnegative_finite(*mass) || !position.is_finite() {
292                return None;
293            }
294
295            let weighted_sum = finite_result((*mass).mul_add(*position, weighted_sum))?;
296            let total_mass = finite_result(total_mass + *mass)?;
297
298            Some((weighted_sum, total_mass))
299        },
300    )?;
301
302    if total_mass <= 0.0 {
303        return None;
304    }
305
306    finite_result(weighted_position_sum / total_mass)
307}
308
309/// Computes the sum of non-negative masses.
310///
311/// Returns `Some(0.0)` for an empty slice and `None` when any mass or the result is invalid.
312#[must_use]
313pub fn combined_mass(masses: &[f64]) -> Option<f64> {
314    masses.iter().try_fold(0.0, |sum, mass| {
315        if !is_nonnegative_finite(*mass) {
316            return None;
317        }
318
319        finite_result(sum + *mass)
320    })
321}
322
323/// Computes reduced mass using `μ = (m1 * m2) / (m1 + m2)`.
324#[must_use]
325pub fn reduced_mass(mass_a: f64, mass_b: f64) -> Option<f64> {
326    if !is_nonnegative_finite(mass_a) || !is_nonnegative_finite(mass_b) {
327        return None;
328    }
329
330    let total_mass = finite_result(mass_a + mass_b)?;
331    if total_mass <= 0.0 {
332        return None;
333    }
334
335    finite_result((mass_a * mass_b) / total_mass)
336}
337
338/// Computes point-mass moment of inertia using `I = mr²`.
339#[must_use]
340pub fn point_mass_moment_of_inertia(mass: f64, radius: f64) -> Option<f64> {
341    scaled_square_measure(mass, radius, 1.0)
342}
343
344/// Computes solid-disk moment of inertia using `I = 0.5mr²`.
345///
346/// # Examples
347///
348/// ```rust
349/// use use_rigidbody::solid_disk_moment_of_inertia;
350///
351/// assert_eq!(solid_disk_moment_of_inertia(2.0, 3.0), Some(9.0));
352/// ```
353#[must_use]
354pub fn solid_disk_moment_of_inertia(mass: f64, radius: f64) -> Option<f64> {
355    scaled_square_measure(mass, radius, 0.5)
356}
357
358/// Computes thin-ring moment of inertia using `I = mr²`.
359#[must_use]
360pub fn thin_ring_moment_of_inertia(mass: f64, radius: f64) -> Option<f64> {
361    point_mass_moment_of_inertia(mass, radius)
362}
363
364/// Computes solid-sphere moment of inertia using `I = (2 / 5)mr²`.
365#[must_use]
366pub fn solid_sphere_moment_of_inertia(mass: f64, radius: f64) -> Option<f64> {
367    scaled_square_measure(mass, radius, 2.0 / 5.0)
368}
369
370/// Computes hollow-sphere moment of inertia using `I = (2 / 3)mr²`.
371#[must_use]
372pub fn hollow_sphere_moment_of_inertia(mass: f64, radius: f64) -> Option<f64> {
373    scaled_square_measure(mass, radius, 2.0 / 3.0)
374}
375
376/// Computes rod moment of inertia about its center using `I = (1 / 12)mL²`.
377#[must_use]
378pub fn rod_moment_of_inertia_about_center(mass: f64, length: f64) -> Option<f64> {
379    scaled_square_measure(mass, length, 1.0 / 12.0)
380}
381
382/// Computes rod moment of inertia about one end using `I = (1 / 3)mL²`.
383#[must_use]
384pub fn rod_moment_of_inertia_about_end(mass: f64, length: f64) -> Option<f64> {
385    scaled_square_measure(mass, length, 1.0 / 3.0)
386}
387
388/// Applies the parallel-axis theorem using `I = I_cm + md²`.
389///
390/// # Examples
391///
392/// ```rust
393/// use use_rigidbody::parallel_axis_moment_of_inertia;
394///
395/// assert_eq!(parallel_axis_moment_of_inertia(4.0, 2.0, 3.0), Some(22.0));
396/// ```
397#[must_use]
398pub fn parallel_axis_moment_of_inertia(
399    center_moment_of_inertia: f64,
400    mass: f64,
401    distance: f64,
402) -> Option<f64> {
403    if !is_nonnegative_finite(center_moment_of_inertia)
404        || !is_nonnegative_finite(mass)
405        || !is_nonnegative_finite(distance)
406    {
407        return None;
408    }
409
410    finite_result((mass * distance).mul_add(distance, center_moment_of_inertia))
411}
412
413/// Computes the center moment from a shifted moment using `I_cm = I - md²`.
414#[must_use]
415pub fn center_moment_from_parallel_axis(
416    shifted_moment_of_inertia: f64,
417    mass: f64,
418    distance: f64,
419) -> Option<f64> {
420    if !is_nonnegative_finite(shifted_moment_of_inertia)
421        || !is_nonnegative_finite(mass)
422        || !is_nonnegative_finite(distance)
423    {
424        return None;
425    }
426
427    nonnegative_finite_result((mass * distance).mul_add(-distance, shifted_moment_of_inertia))
428}
429
430/// Computes linear momentum using `p = mv`.
431#[must_use]
432pub fn linear_momentum(mass: f64, velocity: f64) -> Option<f64> {
433    if !is_nonnegative_finite(mass) || !velocity.is_finite() {
434        return None;
435    }
436
437    finite_result(mass * velocity)
438}
439
440/// Computes translational kinetic energy using `KE = 0.5mv²`.
441///
442/// # Examples
443///
444/// ```rust
445/// use use_rigidbody::linear_kinetic_energy;
446///
447/// assert_eq!(linear_kinetic_energy(2.0, 3.0), Some(9.0));
448/// ```
449#[must_use]
450pub fn linear_kinetic_energy(mass: f64, velocity: f64) -> Option<f64> {
451    if !is_nonnegative_finite(mass) || !velocity.is_finite() {
452        return None;
453    }
454
455    nonnegative_finite_result(0.5 * mass * velocity * velocity)
456}
457
458/// Computes final velocity after an impulse using `v_final = v_initial + J / m`.
459#[must_use]
460pub fn velocity_after_impulse(mass: f64, initial_velocity: f64, impulse: f64) -> Option<f64> {
461    if !is_positive_finite(mass) || !initial_velocity.is_finite() || !impulse.is_finite() {
462        return None;
463    }
464
465    finite_result(initial_velocity + impulse / mass)
466}
467
468/// Computes impulse from a velocity change using `J = m(v_final - v_initial)`.
469#[must_use]
470pub fn impulse_from_velocity_change(
471    mass: f64,
472    initial_velocity: f64,
473    final_velocity: f64,
474) -> Option<f64> {
475    if !is_nonnegative_finite(mass) || !initial_velocity.is_finite() || !final_velocity.is_finite()
476    {
477        return None;
478    }
479
480    finite_result(mass * (final_velocity - initial_velocity))
481}
482
483/// Computes angular momentum using `L = Iω`.
484#[must_use]
485pub fn angular_momentum(moment_of_inertia: f64, angular_velocity: f64) -> Option<f64> {
486    if !is_nonnegative_finite(moment_of_inertia) || !angular_velocity.is_finite() {
487        return None;
488    }
489
490    finite_result(moment_of_inertia * angular_velocity)
491}
492
493/// Computes rotational kinetic energy using `KE_rot = 0.5Iω²`.
494///
495/// # Examples
496///
497/// ```rust
498/// use use_rigidbody::rotational_kinetic_energy;
499///
500/// assert_eq!(rotational_kinetic_energy(4.0, 5.0), Some(50.0));
501/// ```
502#[must_use]
503pub fn rotational_kinetic_energy(moment_of_inertia: f64, angular_velocity: f64) -> Option<f64> {
504    if !is_nonnegative_finite(moment_of_inertia) || !angular_velocity.is_finite() {
505        return None;
506    }
507
508    nonnegative_finite_result(0.5 * moment_of_inertia * angular_velocity * angular_velocity)
509}
510
511/// Computes final angular velocity after an angular impulse using `ω_final = ω_initial + J / I`.
512#[must_use]
513pub fn angular_velocity_after_angular_impulse(
514    moment_of_inertia: f64,
515    initial_angular_velocity: f64,
516    angular_impulse: f64,
517) -> Option<f64> {
518    if !is_positive_finite(moment_of_inertia)
519        || !initial_angular_velocity.is_finite()
520        || !angular_impulse.is_finite()
521    {
522        return None;
523    }
524
525    finite_result(initial_angular_velocity + angular_impulse / moment_of_inertia)
526}
527
528/// Computes angular impulse from an angular velocity change using `J = I(ω_final - ω_initial)`.
529#[must_use]
530pub fn angular_impulse_from_angular_velocity_change(
531    moment_of_inertia: f64,
532    initial_angular_velocity: f64,
533    final_angular_velocity: f64,
534) -> Option<f64> {
535    if !is_nonnegative_finite(moment_of_inertia)
536        || !initial_angular_velocity.is_finite()
537        || !final_angular_velocity.is_finite()
538    {
539        return None;
540    }
541
542    finite_result(moment_of_inertia * (final_angular_velocity - initial_angular_velocity))
543}
544
545/// Computes total kinetic energy using `KE_total = 0.5mv² + 0.5Iω²`.
546///
547/// # Examples
548///
549/// ```rust
550/// use use_rigidbody::total_kinetic_energy;
551///
552/// assert_eq!(total_kinetic_energy(2.0, 3.0, 4.0, 5.0), Some(59.0));
553/// ```
554#[must_use]
555pub fn total_kinetic_energy(
556    mass: f64,
557    linear_velocity: f64,
558    moment_of_inertia: f64,
559    angular_velocity: f64,
560) -> Option<f64> {
561    let linear = linear_kinetic_energy(mass, linear_velocity)?;
562    let rotational = rotational_kinetic_energy(moment_of_inertia, angular_velocity)?;
563
564    nonnegative_finite_result(linear + rotational)
565}
566
567fn scaled_square_measure(primary: f64, measure: f64, factor: f64) -> Option<f64> {
568    if !is_nonnegative_finite(primary) || !is_nonnegative_finite(measure) {
569        return None;
570    }
571
572    nonnegative_finite_result(factor * primary * measure * measure)
573}
574
575fn is_nonnegative_finite(value: f64) -> bool {
576    value.is_finite() && value >= 0.0
577}
578
579fn is_positive_finite(value: f64) -> bool {
580    value.is_finite() && value > 0.0
581}
582
583fn finite_result(value: f64) -> Option<f64> {
584    value.is_finite().then_some(normalize_zero(value))
585}
586
587fn nonnegative_finite_result(value: f64) -> Option<f64> {
588    if !value.is_finite() || value < 0.0 {
589        return None;
590    }
591
592    Some(normalize_zero(value))
593}
594
595fn normalize_zero(value: f64) -> f64 {
596    if value == 0.0 { 0.0 } else { value }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::{
602        MassProperties, RigidBody1D, angular_impulse_from_angular_velocity_change,
603        angular_momentum, angular_velocity_after_angular_impulse, center_moment_from_parallel_axis,
604        center_of_mass_1d, combined_mass, hollow_sphere_moment_of_inertia,
605        impulse_from_velocity_change, linear_kinetic_energy, linear_momentum,
606        parallel_axis_moment_of_inertia, point_mass_moment_of_inertia, reduced_mass,
607        rod_moment_of_inertia_about_center, rod_moment_of_inertia_about_end,
608        rotational_kinetic_energy, solid_disk_moment_of_inertia, solid_sphere_moment_of_inertia,
609        thin_ring_moment_of_inertia, total_kinetic_energy, velocity_after_impulse,
610    };
611
612    const EPSILON: f64 = 1.0e-12;
613
614    fn assert_approx_eq(left: f64, right: f64) {
615        let tolerance = EPSILON * left.abs().max(right.abs()).max(1.0);
616        assert!(
617            (left - right).abs() <= tolerance,
618            "left={left}, right={right}, tolerance={tolerance}"
619        );
620    }
621
622    fn assert_some_approx_eq(value: Option<f64>, expected: f64) {
623        let Some(value) = value else {
624            panic!("expected Some({expected})");
625        };
626
627        assert_approx_eq(value, expected);
628    }
629
630    #[test]
631    fn center_of_mass_helpers_cover_common_cases() {
632        assert_some_approx_eq(center_of_mass_1d(&[1.0, 1.0], &[0.0, 10.0]), 5.0);
633        assert_some_approx_eq(center_of_mass_1d(&[1.0, 3.0], &[0.0, 10.0]), 7.5);
634        assert_eq!(center_of_mass_1d(&[], &[]), None);
635        assert_eq!(center_of_mass_1d(&[1.0], &[0.0, 1.0]), None);
636        assert_eq!(center_of_mass_1d(&[-1.0], &[0.0]), None);
637    }
638
639    #[test]
640    fn mass_helpers_cover_common_cases() {
641        assert_some_approx_eq(combined_mass(&[1.0, 2.0, 3.0]), 6.0);
642        assert_some_approx_eq(combined_mass(&[]), 0.0);
643        assert_eq!(combined_mass(&[1.0, -2.0]), None);
644
645        assert_some_approx_eq(reduced_mass(2.0, 2.0), 1.0);
646        assert_some_approx_eq(reduced_mass(2.0, 6.0), 1.5);
647        assert_eq!(reduced_mass(0.0, 0.0), None);
648    }
649
650    #[test]
651    fn moment_of_inertia_helpers_cover_common_shapes() {
652        assert_some_approx_eq(point_mass_moment_of_inertia(2.0, 3.0), 18.0);
653        assert_some_approx_eq(solid_disk_moment_of_inertia(2.0, 3.0), 9.0);
654        assert_some_approx_eq(thin_ring_moment_of_inertia(2.0, 3.0), 18.0);
655        assert_some_approx_eq(solid_sphere_moment_of_inertia(5.0, 2.0), 8.0);
656        assert_some_approx_eq(hollow_sphere_moment_of_inertia(3.0, 2.0), 8.0);
657        assert_some_approx_eq(rod_moment_of_inertia_about_center(12.0, 2.0), 4.0);
658        assert_some_approx_eq(rod_moment_of_inertia_about_end(3.0, 2.0), 4.0);
659    }
660
661    #[test]
662    fn parallel_axis_helpers_validate_inputs() {
663        assert_some_approx_eq(parallel_axis_moment_of_inertia(4.0, 2.0, 3.0), 22.0);
664        assert_eq!(parallel_axis_moment_of_inertia(-4.0, 2.0, 3.0), None);
665
666        assert_some_approx_eq(center_moment_from_parallel_axis(22.0, 2.0, 3.0), 4.0);
667        assert_eq!(center_moment_from_parallel_axis(4.0, 2.0, 3.0), None);
668    }
669
670    #[test]
671    fn linear_helpers_cover_common_values() {
672        assert_some_approx_eq(linear_momentum(2.0, 3.0), 6.0);
673        assert_some_approx_eq(linear_momentum(2.0, -3.0), -6.0);
674        assert_eq!(linear_momentum(-2.0, 3.0), None);
675
676        assert_some_approx_eq(linear_kinetic_energy(2.0, 3.0), 9.0);
677        assert_some_approx_eq(linear_kinetic_energy(2.0, -3.0), 9.0);
678
679        assert_some_approx_eq(velocity_after_impulse(2.0, 3.0, 4.0), 5.0);
680        assert_eq!(velocity_after_impulse(0.0, 3.0, 4.0), None);
681
682        assert_some_approx_eq(impulse_from_velocity_change(2.0, 3.0, 5.0), 4.0);
683    }
684
685    #[test]
686    fn rotational_helpers_cover_common_values() {
687        assert_some_approx_eq(angular_momentum(4.0, 5.0), 20.0);
688        assert_some_approx_eq(angular_momentum(4.0, -5.0), -20.0);
689        assert_eq!(angular_momentum(-4.0, 5.0), None);
690
691        assert_some_approx_eq(rotational_kinetic_energy(4.0, 5.0), 50.0);
692
693        assert_some_approx_eq(angular_velocity_after_angular_impulse(4.0, 5.0, 8.0), 7.0);
694        assert_eq!(angular_velocity_after_angular_impulse(0.0, 5.0, 8.0), None);
695
696        assert_some_approx_eq(
697            angular_impulse_from_angular_velocity_change(4.0, 5.0, 7.0),
698            8.0,
699        );
700    }
701
702    #[test]
703    fn total_energy_helper_combines_linear_and_rotational_energy() {
704        assert_some_approx_eq(total_kinetic_energy(2.0, 3.0, 4.0, 5.0), 59.0);
705    }
706
707    #[test]
708    fn mass_properties_validate_and_delegate_to_public_helpers() {
709        let props = MassProperties::new(2.0, 4.0).expect("expected valid mass properties");
710        assert_approx_eq(props.mass, 2.0);
711
712        assert_eq!(MassProperties::new(-2.0, 4.0), None);
713        assert_eq!(MassProperties::new(2.0, -4.0), None);
714
715        let sphere = MassProperties::solid_sphere(5.0, 2.0).expect("expected solid sphere");
716        assert_approx_eq(sphere.moment_of_inertia, 8.0);
717
718        let rod = MassProperties::rod_about_center(12.0, 2.0).expect("expected rod about center");
719        assert_approx_eq(rod.moment_of_inertia, 4.0);
720
721        let shifted = props
722            .shifted_by_parallel_axis(3.0)
723            .expect("expected shifted properties");
724        assert_approx_eq(shifted.moment_of_inertia, 22.0);
725    }
726
727    #[test]
728    fn rigid_body_methods_delegate_to_public_helpers() {
729        let props = MassProperties::new(2.0, 4.0).expect("expected valid mass properties");
730        let body = RigidBody1D::new(props, 10.0, 3.0, 1.0, 5.0).expect("expected valid body");
731
732        assert_approx_eq(body.mass(), 2.0);
733        assert_approx_eq(body.moment_of_inertia(), 4.0);
734        assert_some_approx_eq(body.linear_momentum(), 6.0);
735        assert_some_approx_eq(body.angular_momentum(), 20.0);
736        assert_some_approx_eq(body.linear_kinetic_energy(), 9.0);
737        assert_some_approx_eq(body.rotational_kinetic_energy(), 50.0);
738        assert_some_approx_eq(body.total_kinetic_energy(), 59.0);
739
740        let with_impulse = body.with_impulse(4.0).expect("expected updated velocity");
741        assert_approx_eq(with_impulse.velocity, 5.0);
742
743        let with_angular_impulse = body
744            .with_angular_impulse(8.0)
745            .expect("expected updated angular velocity");
746        assert_approx_eq(with_angular_impulse.angular_velocity, 7.0);
747
748        let advanced = body
749            .advanced_kinematically(2.0)
750            .expect("expected advanced body state");
751        assert_approx_eq(advanced.position, 16.0);
752        assert_approx_eq(advanced.angle, 11.0);
753
754        assert_eq!(RigidBody1D::new(props, f64::NAN, 3.0, 1.0, 5.0), None);
755        assert_eq!(body.advanced_kinematically(-1.0), None);
756    }
757}