pamoja_kit/motion.rs
1//! Shared planar motion types: a body twist and a world pose.
2
3use core::f32::consts::PI;
4
5/// A planar body velocity: the command a wheeled robot is driven with.
6///
7/// The frame follows the robotics convention (ROS REP-103): x points forward, y points to
8/// the robot's left, and a positive `omega` turns counter-clockwise (toward the left).
9/// Nonholonomic drives (differential, Ackermann, skid-steer) cannot move sideways and ignore
10/// `vy`; holonomic drives (mecanum, omni) use it.
11///
12/// # Examples
13///
14/// ```
15/// use pamoja_kit::Twist;
16///
17/// let forward = Twist::planar(1.0, 0.0); // 1 m/s ahead, no turn
18/// assert_eq!(forward.vy, 0.0);
19/// assert_eq!(Twist::zero(), Twist::new(0.0, 0.0, 0.0));
20/// ```
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct Twist {
23 /// Forward speed along the x axis.
24 pub vx: f32,
25 /// Leftward speed along the y axis; zero for drives that cannot strafe.
26 pub vy: f32,
27 /// Yaw rate about the z axis, positive counter-clockwise.
28 pub omega: f32,
29}
30
31impl Twist {
32 /// Creates a twist from its three components.
33 ///
34 /// # Arguments
35 ///
36 /// * `vx` - forward speed.
37 /// * `vy` - leftward speed.
38 /// * `omega` - yaw rate, positive counter-clockwise.
39 ///
40 /// # Returns
41 ///
42 /// The twist.
43 pub fn new(vx: f32, vy: f32, omega: f32) -> Self {
44 Self { vx, vy, omega }
45 }
46
47 /// Creates a planar twist with no sideways motion (`vy = 0`).
48 ///
49 /// # Arguments
50 ///
51 /// * `vx` - forward speed.
52 /// * `omega` - yaw rate, positive counter-clockwise.
53 ///
54 /// # Returns
55 ///
56 /// The twist with `vy` zero.
57 pub fn planar(vx: f32, omega: f32) -> Self {
58 Self { vx, vy: 0.0, omega }
59 }
60
61 /// Returns the zero twist: stopped.
62 ///
63 /// # Returns
64 ///
65 /// A twist whose every component is zero.
66 pub fn zero() -> Self {
67 Self {
68 vx: 0.0,
69 vy: 0.0,
70 omega: 0.0,
71 }
72 }
73}
74
75/// A planar pose in the world frame: position and heading.
76///
77/// # Examples
78///
79/// ```
80/// use pamoja_kit::Pose;
81///
82/// let start = Pose::origin();
83/// assert_eq!((start.x, start.y, start.theta), (0.0, 0.0, 0.0));
84/// ```
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct Pose {
87 /// Position along the world x axis, in metres.
88 pub x: f32,
89 /// Position along the world y axis, in metres.
90 pub y: f32,
91 /// Heading from the world x axis, in radians, in `(-pi, pi]`, positive counter-clockwise.
92 pub theta: f32,
93}
94
95impl Pose {
96 /// Creates a pose; the heading is wrapped into `(-pi, pi]`.
97 ///
98 /// # Arguments
99 ///
100 /// * `x` - position along the world x axis.
101 /// * `y` - position along the world y axis.
102 /// * `theta` - heading in radians, wrapped to `(-pi, pi]`.
103 ///
104 /// # Returns
105 ///
106 /// The pose.
107 pub fn new(x: f32, y: f32, theta: f32) -> Self {
108 Self {
109 x,
110 y,
111 theta: wrap_pi(theta),
112 }
113 }
114
115 /// Returns the origin pose: at `(0, 0)` facing along the x axis.
116 ///
117 /// # Returns
118 ///
119 /// The origin pose.
120 pub fn origin() -> Self {
121 Self {
122 x: 0.0,
123 y: 0.0,
124 theta: 0.0,
125 }
126 }
127}
128
129// Wraps an angle in radians into the half-open interval `(-pi, pi]`.
130pub(crate) fn wrap_pi(angle: f32) -> f32 {
131 let two_pi = 2.0 * PI;
132 let mut a = angle % two_pi;
133 if a > PI {
134 a -= two_pi;
135 } else if a <= -PI {
136 a += two_pi;
137 }
138 a
139}
140
141// `f32::abs` lives in `std`, so this `no_std` crate takes the magnitude by hand.
142pub(crate) fn magnitude(value: f32) -> f32 {
143 if value < 0.0 {
144 -value
145 } else {
146 value
147 }
148}
149
150// `f32::clamp` lives in `std`; clamp by hand. Callers pass `low <= high`.
151pub(crate) fn clamp(value: f32, low: f32, high: f32) -> f32 {
152 if value < low {
153 low
154 } else if value > high {
155 high
156 } else {
157 value
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn wrap_pi_folds_into_the_canonical_interval() {
167 // Interior values pass through; a value past 2pi folds to its coterminal angle.
168 assert!(wrap_pi(0.0).abs() < 1e-6);
169 assert!((wrap_pi(1.0) - 1.0).abs() < 1e-6);
170 assert!((wrap_pi(2.0 * PI + 0.3) - 0.3).abs() < 1e-5);
171 // Whatever the input, the result lands within (-pi, pi].
172 for k in -10..=10 {
173 let w = wrap_pi(k as f32 * 1.3);
174 assert!(w > -PI - 1e-4 && w <= PI + 1e-4);
175 }
176 }
177
178 #[test]
179 fn clamp_and_magnitude_behave() {
180 assert_eq!(clamp(5.0, 0.0, 1.0), 1.0);
181 assert_eq!(clamp(-5.0, 0.0, 1.0), 0.0);
182 assert_eq!(magnitude(-3.0), 3.0);
183 }
184}