1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4pub mod prelude;
8
9fn finite(value: f64) -> Option<f64> {
10 if value.is_finite() {
11 Some(if value == 0.0 { 0.0 } else { value })
12 } else {
13 None
14 }
15}
16
17fn finite_pair(x: f64, y: f64) -> Option<(f64, f64)> {
18 Some((finite(x)?, finite(y)?))
19}
20
21fn all_finite(values: &[f64]) -> bool {
22 values.iter().all(|value| value.is_finite())
23}
24
25fn nonnegative_finite(value: f64) -> bool {
26 value.is_finite() && value >= 0.0
27}
28
29fn positive_finite(value: f64) -> bool {
30 value.is_finite() && value > 0.0
31}
32
33#[must_use]
46pub fn net_force_1d(forces: &[f64]) -> Option<f64> {
47 if !all_finite(forces) {
48 return None;
49 }
50
51 finite(forces.iter().copied().sum())
52}
53
54#[must_use]
66pub fn net_force_2d(forces: &[(f64, f64)]) -> Option<(f64, f64)> {
67 let (sum_x, sum_y) =
68 forces
69 .iter()
70 .try_fold((0.0, 0.0), |(sum_x, sum_y), (force_x, force_y)| {
71 if !force_x.is_finite() || !force_y.is_finite() {
72 return None;
73 }
74
75 Some((sum_x + force_x, sum_y + force_y))
76 })?;
77
78 finite_pair(sum_x, sum_y)
79}
80
81#[must_use]
85pub fn force_magnitude(force_x: f64, force_y: f64) -> Option<f64> {
86 if !force_x.is_finite() || !force_y.is_finite() {
87 return None;
88 }
89
90 finite(force_x.hypot(force_y))
91}
92
93#[must_use]
97pub fn force_angle_radians(force_x: f64, force_y: f64) -> Option<f64> {
98 if !force_x.is_finite() || !force_y.is_finite() {
99 return None;
100 }
101
102 finite(force_y.atan2(force_x))
103}
104
105#[must_use]
110pub fn is_translational_equilibrium_1d(forces: &[f64], tolerance: f64) -> Option<bool> {
111 if !nonnegative_finite(tolerance) {
112 return None;
113 }
114
115 Some(net_force_1d(forces)?.abs() <= tolerance)
116}
117
118#[must_use]
123pub fn is_translational_equilibrium_2d(forces: &[(f64, f64)], tolerance: f64) -> Option<bool> {
124 if !nonnegative_finite(tolerance) {
125 return None;
126 }
127
128 let (net_x, net_y) = net_force_2d(forces)?;
129
130 Some(net_x.abs() <= tolerance && net_y.abs() <= tolerance)
131}
132
133#[must_use]
146pub fn moment_2d(position_x: f64, position_y: f64, force_x: f64, force_y: f64) -> Option<f64> {
147 if !position_x.is_finite()
148 || !position_y.is_finite()
149 || !force_x.is_finite()
150 || !force_y.is_finite()
151 {
152 return None;
153 }
154
155 finite(position_x.mul_add(force_y, -(position_y * force_x)))
156}
157
158#[must_use]
162pub fn moment_from_force_and_arm(force: f64, moment_arm: f64) -> Option<f64> {
163 if !force.is_finite() || !moment_arm.is_finite() {
164 return None;
165 }
166
167 finite(force * moment_arm)
168}
169
170#[must_use]
172pub fn net_moment(moments: &[f64]) -> Option<f64> {
173 if !all_finite(moments) {
174 return None;
175 }
176
177 finite(moments.iter().copied().sum())
178}
179
180#[must_use]
184pub fn net_moment_2d(force_positions: &[(f64, f64, f64, f64)]) -> Option<f64> {
185 let total = force_positions.iter().try_fold(
186 0.0,
187 |total, (position_x, position_y, force_x, force_y)| {
188 Some(total + moment_2d(*position_x, *position_y, *force_x, *force_y)?)
189 },
190 )?;
191
192 finite(total)
193}
194
195#[must_use]
197pub fn is_rotational_equilibrium(moments: &[f64], tolerance: f64) -> Option<bool> {
198 if !nonnegative_finite(tolerance) {
199 return None;
200 }
201
202 Some(net_moment(moments)?.abs() <= tolerance)
203}
204
205#[must_use]
218pub fn is_static_equilibrium_2d(
219 forces: &[(f64, f64)],
220 moments: &[f64],
221 tolerance: f64,
222) -> Option<bool> {
223 Some(
224 is_translational_equilibrium_2d(forces, tolerance)?
225 && is_rotational_equilibrium(moments, tolerance)?,
226 )
227}
228
229#[must_use]
241pub fn maximum_static_friction(coefficient_static_friction: f64, normal_force: f64) -> Option<f64> {
242 if !nonnegative_finite(coefficient_static_friction) || !nonnegative_finite(normal_force) {
243 return None;
244 }
245
246 finite(coefficient_static_friction * normal_force)
247}
248
249#[must_use]
251pub fn required_static_friction(applied_horizontal_force: f64) -> Option<f64> {
252 if !applied_horizontal_force.is_finite() {
253 return None;
254 }
255
256 finite(applied_horizontal_force.abs())
257}
258
259#[must_use]
261pub fn can_static_friction_hold(
262 applied_horizontal_force: f64,
263 coefficient_static_friction: f64,
264 normal_force: f64,
265) -> Option<bool> {
266 Some(
267 required_static_friction(applied_horizontal_force)?
268 <= maximum_static_friction(coefficient_static_friction, normal_force)?,
269 )
270}
271
272#[must_use]
276pub fn normal_force_horizontal_surface(mass: f64, gravitational_acceleration: f64) -> Option<f64> {
277 if !nonnegative_finite(mass) || !nonnegative_finite(gravitational_acceleration) {
278 return None;
279 }
280
281 finite(mass * gravitational_acceleration)
282}
283
284#[must_use]
300pub fn normal_force_incline(
301 mass: f64,
302 gravitational_acceleration: f64,
303 angle_radians: f64,
304) -> Option<f64> {
305 if !nonnegative_finite(mass)
306 || !nonnegative_finite(gravitational_acceleration)
307 || !angle_radians.is_finite()
308 {
309 return None;
310 }
311
312 let normal_force = mass * gravitational_acceleration * angle_radians.cos();
313
314 if normal_force < 0.0 {
315 return None;
316 }
317
318 finite(normal_force)
319}
320
321#[must_use]
325pub fn downslope_force_incline(
326 mass: f64,
327 gravitational_acceleration: f64,
328 angle_radians: f64,
329) -> Option<f64> {
330 if !nonnegative_finite(mass)
331 || !nonnegative_finite(gravitational_acceleration)
332 || !angle_radians.is_finite()
333 {
334 return None;
335 }
336
337 finite(mass * gravitational_acceleration * angle_radians.sin())
338}
339
340#[must_use]
344pub fn minimum_static_friction_coefficient_for_incline(angle_radians: f64) -> Option<f64> {
345 if !nonnegative_finite(angle_radians) {
346 return None;
347 }
348
349 let coefficient = finite(angle_radians.tan())?;
350
351 (coefficient >= 0.0).then_some(coefficient)
352}
353
354#[must_use]
371pub fn simply_supported_point_load_reactions(
372 span: f64,
373 load: f64,
374 load_position_from_left: f64,
375) -> Option<(f64, f64)> {
376 if !positive_finite(span)
377 || !nonnegative_finite(load)
378 || !load_position_from_left.is_finite()
379 || !(0.0..=span).contains(&load_position_from_left)
380 {
381 return None;
382 }
383
384 let left_reaction = load * (span - load_position_from_left) / span;
385 let right_reaction = load * load_position_from_left / span;
386
387 finite_pair(left_reaction, right_reaction)
388}
389
390#[must_use]
392pub fn simply_supported_uniform_load_reactions(
393 span: f64,
394 load_per_length: f64,
395) -> Option<(f64, f64)> {
396 if !positive_finite(span) || !nonnegative_finite(load_per_length) {
397 return None;
398 }
399
400 let total_load = load_per_length * span;
401 let reaction = finite(total_load / 2.0)?;
402
403 Some((reaction, reaction))
404}
405
406#[derive(Debug, Clone, Copy, PartialEq)]
408pub struct CantileverReaction {
409 pub vertical_reaction: f64,
411 pub fixed_end_moment: f64,
413}
414
415#[must_use]
431pub fn cantilever_end_point_load_reaction(span: f64, load: f64) -> Option<CantileverReaction> {
432 if !positive_finite(span) || !nonnegative_finite(load) {
433 return None;
434 }
435
436 Some(CantileverReaction {
437 vertical_reaction: finite(load)?,
438 fixed_end_moment: finite(load * span)?,
439 })
440}
441
442#[derive(Debug, Clone, Copy, PartialEq)]
444pub struct Force2D {
445 pub x: f64,
447 pub y: f64,
449}
450
451impl Force2D {
452 #[must_use]
454 pub fn new(x: f64, y: f64) -> Option<Self> {
455 Some(Self {
456 x: finite(x)?,
457 y: finite(y)?,
458 })
459 }
460
461 #[must_use]
471 pub fn magnitude(&self) -> Option<f64> {
472 force_magnitude(self.x, self.y)
473 }
474
475 #[must_use]
477 pub fn angle_radians(&self) -> Option<f64> {
478 force_angle_radians(self.x, self.y)
479 }
480}
481
482#[derive(Debug, Clone, Copy, PartialEq)]
484pub struct PointForce2D {
485 pub position_x: f64,
487 pub position_y: f64,
489 pub force_x: f64,
491 pub force_y: f64,
493}
494
495impl PointForce2D {
496 #[must_use]
498 pub fn new(position_x: f64, position_y: f64, force_x: f64, force_y: f64) -> Option<Self> {
499 Some(Self {
500 position_x: finite(position_x)?,
501 position_y: finite(position_y)?,
502 force_x: finite(force_x)?,
503 force_y: finite(force_y)?,
504 })
505 }
506
507 #[must_use]
509 pub fn moment_about_origin(&self) -> Option<f64> {
510 moment_2d(self.position_x, self.position_y, self.force_x, self.force_y)
511 }
512}
513
514#[derive(Debug, Clone, PartialEq)]
516pub struct StaticSystem2D {
517 pub forces: Vec<Force2D>,
519 pub moments: Vec<f64>,
521}
522
523impl StaticSystem2D {
524 #[must_use]
526 pub fn new(forces: Vec<Force2D>, moments: Vec<f64>) -> Option<Self> {
527 if !forces
528 .iter()
529 .all(|force| force.x.is_finite() && force.y.is_finite())
530 || !all_finite(&moments)
531 {
532 return None;
533 }
534
535 Some(Self { forces, moments })
536 }
537
538 fn force_components(&self) -> Vec<(f64, f64)> {
539 self.forces.iter().map(|force| (force.x, force.y)).collect()
540 }
541
542 #[must_use]
544 pub fn net_force(&self) -> Option<(f64, f64)> {
545 net_force_2d(&self.force_components())
546 }
547
548 #[must_use]
550 pub fn net_moment(&self) -> Option<f64> {
551 net_moment(&self.moments)
552 }
553
554 #[must_use]
574 pub fn is_equilibrium(&self, tolerance: f64) -> Option<bool> {
575 is_static_equilibrium_2d(&self.force_components(), &self.moments, tolerance)
576 }
577}
578
579#[cfg(test)]
580#[allow(clippy::float_cmp)]
581mod tests {
582 use core::f64;
583 use core::f64::consts::FRAC_PI_4;
584
585 use super::{
586 CantileverReaction, Force2D, PointForce2D, StaticSystem2D, can_static_friction_hold,
587 cantilever_end_point_load_reaction, downslope_force_incline, force_angle_radians,
588 force_magnitude, is_rotational_equilibrium, is_static_equilibrium_2d,
589 is_translational_equilibrium_1d, is_translational_equilibrium_2d, maximum_static_friction,
590 minimum_static_friction_coefficient_for_incline, moment_2d, moment_from_force_and_arm,
591 net_force_1d, net_force_2d, net_moment, net_moment_2d, normal_force_horizontal_surface,
592 normal_force_incline, required_static_friction, simply_supported_point_load_reactions,
593 simply_supported_uniform_load_reactions,
594 };
595
596 fn approx_eq(left: f64, right: f64, tolerance: f64) {
597 assert!(
598 (left - right).abs() <= tolerance,
599 "expected {left} to be within {tolerance} of {right}"
600 );
601 }
602
603 #[test]
604 fn force_helpers_cover_required_cases() {
605 assert_eq!(net_force_1d(&[10.0, -4.0, -6.0]), Some(0.0));
606 assert_eq!(net_force_1d(&[]), Some(0.0));
607 assert_eq!(net_force_1d(&[10.0, f64::NAN]), None);
608
609 assert_eq!(net_force_2d(&[(1.0, 2.0), (-1.0, -2.0)]), Some((0.0, 0.0)));
610 assert_eq!(net_force_2d(&[]), Some((0.0, 0.0)));
611
612 assert_eq!(force_magnitude(3.0, 4.0), Some(5.0));
613 assert_eq!(force_angle_radians(1.0, 0.0), Some(0.0));
614 }
615
616 #[test]
617 fn translational_equilibrium_helpers_cover_required_cases() {
618 assert_eq!(
619 is_translational_equilibrium_1d(&[10.0, -10.0], 0.0),
620 Some(true)
621 );
622 assert_eq!(
623 is_translational_equilibrium_1d(&[10.0, -9.0], 0.0),
624 Some(false)
625 );
626 assert_eq!(is_translational_equilibrium_1d(&[10.0, -10.0], -1.0), None);
627
628 assert_eq!(
629 is_translational_equilibrium_2d(&[(1.0, 2.0), (-1.0, -2.0)], 0.0),
630 Some(true)
631 );
632 }
633
634 #[test]
635 fn moment_helpers_cover_required_cases() {
636 assert_eq!(moment_2d(2.0, 0.0, 0.0, 10.0), Some(20.0));
637 assert_eq!(moment_2d(0.0, 2.0, 10.0, 0.0), Some(-20.0));
638
639 assert_eq!(moment_from_force_and_arm(10.0, 2.0), Some(20.0));
640 assert_eq!(moment_from_force_and_arm(-10.0, 2.0), Some(-20.0));
641
642 assert_eq!(net_moment(&[10.0, -4.0, -6.0]), Some(0.0));
643 assert_eq!(net_moment(&[]), Some(0.0));
644
645 assert_eq!(
646 net_moment_2d(&[(2.0, 0.0, 0.0, 10.0), (0.0, 2.0, 10.0, 0.0)]),
647 Some(0.0)
648 );
649 }
650
651 #[test]
652 fn rotational_equilibrium_helpers_cover_required_cases() {
653 assert_eq!(is_rotational_equilibrium(&[10.0, -10.0], 0.0), Some(true));
654 assert_eq!(is_rotational_equilibrium(&[10.0, -9.0], 0.0), Some(false));
655 assert_eq!(
656 is_static_equilibrium_2d(&[(1.0, 2.0), (-1.0, -2.0)], &[10.0, -10.0], 0.0),
657 Some(true)
658 );
659 }
660
661 #[test]
662 fn static_friction_helpers_cover_required_cases() {
663 assert_eq!(maximum_static_friction(0.5, 100.0), Some(50.0));
664 assert_eq!(maximum_static_friction(-0.5, 100.0), None);
665
666 assert_eq!(required_static_friction(-20.0), Some(20.0));
667 assert_eq!(can_static_friction_hold(20.0, 0.5, 100.0), Some(true));
668 assert_eq!(can_static_friction_hold(60.0, 0.5, 100.0), Some(false));
669
670 let Some(normal_force) = normal_force_horizontal_surface(10.0, 9.80665) else {
671 panic!("valid horizontal surface inputs should produce a normal force");
672 };
673 approx_eq(normal_force, 98.0665, 1.0e-12);
674 assert_eq!(normal_force_horizontal_surface(-10.0, 9.80665), None);
675 }
676
677 #[test]
678 fn incline_helpers_cover_required_cases() {
679 let Some(normal_force) = normal_force_incline(10.0, 9.80665, 0.0) else {
680 panic!("valid incline inputs should produce a normal force");
681 };
682 approx_eq(normal_force, 98.0665, 1.0e-12);
683
684 let Some(downslope_force) = downslope_force_incline(10.0, 9.80665, 0.0) else {
685 panic!("valid incline inputs should produce a downslope force");
686 };
687 approx_eq(downslope_force, 0.0, 1.0e-12);
688
689 let Some(coefficient) = minimum_static_friction_coefficient_for_incline(FRAC_PI_4) else {
690 panic!("forty-five degrees should produce a finite coefficient");
691 };
692 approx_eq(coefficient, 1.0, 1.0e-12);
693 assert_eq!(minimum_static_friction_coefficient_for_incline(-1.0), None);
694 }
695
696 #[test]
697 fn support_reaction_helpers_cover_required_cases() {
698 assert_eq!(
699 simply_supported_point_load_reactions(10.0, 100.0, 5.0),
700 Some((50.0, 50.0))
701 );
702 assert_eq!(
703 simply_supported_point_load_reactions(10.0, 100.0, 0.0),
704 Some((100.0, 0.0))
705 );
706 assert_eq!(
707 simply_supported_point_load_reactions(10.0, 100.0, 10.0),
708 Some((0.0, 100.0))
709 );
710 assert_eq!(
711 simply_supported_point_load_reactions(10.0, 100.0, 11.0),
712 None
713 );
714 assert_eq!(simply_supported_point_load_reactions(0.0, 100.0, 0.0), None);
715
716 assert_eq!(
717 simply_supported_uniform_load_reactions(10.0, 20.0),
718 Some((100.0, 100.0))
719 );
720 assert_eq!(simply_supported_uniform_load_reactions(10.0, -20.0), None);
721
722 assert_eq!(
723 cantilever_end_point_load_reaction(10.0, 100.0),
724 Some(CantileverReaction {
725 vertical_reaction: 100.0,
726 fixed_end_moment: 1000.0,
727 })
728 );
729 assert_eq!(cantilever_end_point_load_reaction(0.0, 100.0), None);
730 }
731
732 #[test]
733 fn structs_cover_required_cases() {
734 let Some(force) = Force2D::new(3.0, 4.0) else {
735 panic!("valid force should construct");
736 };
737 assert_eq!(force.magnitude(), Some(5.0));
738 assert_eq!(Force2D::new(f64::NAN, 4.0), None);
739
740 let Some(point_force) = PointForce2D::new(2.0, 0.0, 0.0, 10.0) else {
741 panic!("valid point force should construct");
742 };
743 assert_eq!(point_force.moment_about_origin(), Some(20.0));
744 assert_eq!(PointForce2D::new(f64::NAN, 0.0, 0.0, 10.0), None);
745 }
746
747 #[test]
748 fn static_system_covers_required_cases() {
749 let Some(force_a) = Force2D::new(1.0, 2.0) else {
750 panic!("valid force should construct");
751 };
752 let Some(force_b) = Force2D::new(-1.0, -2.0) else {
753 panic!("valid force should construct");
754 };
755 let Some(system) = StaticSystem2D::new(vec![force_a, force_b], vec![10.0, -10.0]) else {
756 panic!("valid system should construct");
757 };
758
759 assert_eq!(system.net_force(), Some((0.0, 0.0)));
760 assert_eq!(system.net_moment(), Some(0.0));
761 assert_eq!(system.is_equilibrium(0.0), Some(true));
762
763 let Some(valid_force) = Force2D::new(1.0, 2.0) else {
764 panic!("valid force should construct");
765 };
766 assert_eq!(StaticSystem2D::new(vec![valid_force], vec![f64::NAN]), None);
767 }
768}