1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4pub mod prelude;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct MassProperties {
11 pub mass: f64,
12 pub moment_of_inertia: f64,
13}
14
15impl MassProperties {
16 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[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 #[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 #[must_use]
144 pub const fn mass(&self) -> f64 {
145 self.mass_properties.mass
146 }
147
148 #[must_use]
150 pub const fn moment_of_inertia(&self) -> f64 {
151 self.mass_properties.moment_of_inertia
152 }
153
154 #[must_use]
156 pub fn linear_momentum(&self) -> Option<f64> {
157 linear_momentum(self.mass(), self.velocity)
158 }
159
160 #[must_use]
162 pub fn angular_momentum(&self) -> Option<f64> {
163 angular_momentum(self.moment_of_inertia(), self.angular_velocity)
164 }
165
166 #[must_use]
168 pub fn linear_kinetic_energy(&self) -> Option<f64> {
169 linear_kinetic_energy(self.mass(), self.velocity)
170 }
171
172 #[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 #[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 #[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 #[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 #[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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}