Skip to main content

pamoja_kit/
arm.rs

1//! Serial-arm (manipulator) kinematics: where the hand is, and how to place it.
2//!
3//! A robot arm is a chain of joints, and two questions recur: given the joint angles, where is the
4//! tool (forward kinematics), and given a target, what joint angles put the tool there (inverse
5//! kinematics). [`forward_kinematics`] answers the first for any serial arm described in the
6//! standard Denavit-Hartenberg convention, in full 3D. The second has no closed form for a general
7//! arm, so this provides the classic solvable case, the planar [`TwoLinkArm`], with both its
8//! elbow-up and elbow-down solutions; numeric inverse kinematics for longer chains can build on the
9//! same forward model later.
10
11use crate::motion::{clamp, magnitude};
12use libm::{acosf, atan2f, cosf, sinf, sqrtf};
13
14/// A 4x4 homogeneous transform: a rotation and a translation in one matrix.
15///
16/// Stored row-major, this is the building block of forward kinematics: each joint contributes one
17/// transform, and chaining them places the tool relative to the base.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct Transform {
20    /// The sixteen elements in row-major order (row 0 first).
21    pub m: [f32; 16],
22}
23
24impl Transform {
25    /// Returns the identity transform: no rotation, no translation.
26    ///
27    /// # Returns
28    ///
29    /// The identity.
30    pub fn identity() -> Self {
31        let mut m = [0.0; 16];
32        m[0] = 1.0;
33        m[5] = 1.0;
34        m[10] = 1.0;
35        m[15] = 1.0;
36        Self { m }
37    }
38
39    /// Returns the product `self * other`, the transform that applies `other` then `self`.
40    ///
41    /// # Arguments
42    ///
43    /// * `other` - the transform applied first (the one further down the chain).
44    ///
45    /// # Returns
46    ///
47    /// The composed transform.
48    pub fn multiply(&self, other: &Transform) -> Transform {
49        let mut m = [0.0f32; 16];
50        for row in 0..4 {
51            for col in 0..4 {
52                let mut sum = 0.0;
53                for k in 0..4 {
54                    sum += self.m[row * 4 + k] * other.m[k * 4 + col];
55                }
56                m[row * 4 + col] = sum;
57            }
58        }
59        Transform { m }
60    }
61
62    /// Returns the translation part: the position this transform places the origin at.
63    ///
64    /// # Returns
65    ///
66    /// `(x, y, z)`, the last column of the matrix.
67    pub fn position(&self) -> (f32, f32, f32) {
68        (self.m[3], self.m[7], self.m[11])
69    }
70}
71
72/// The four Denavit-Hartenberg parameters describing one joint-to-joint step of a serial arm.
73///
74/// The DH convention pins each link with four numbers, so an arm is just a list of these. For a
75/// revolute joint the joint variable is `theta`; for a prismatic joint it is `d`.
76#[derive(Clone, Copy, Debug, PartialEq)]
77pub struct DhParameters {
78    /// Link length: distance along the common normal, in metres.
79    pub a: f32,
80    /// Link twist: angle about the common normal, in radians.
81    pub alpha: f32,
82    /// Link offset: distance along the previous z axis, in metres.
83    pub d: f32,
84    /// Joint angle: rotation about the previous z axis, in radians.
85    pub theta: f32,
86}
87
88impl DhParameters {
89    /// Returns the homogeneous [`Transform`] for this DH step.
90    ///
91    /// # Returns
92    ///
93    /// The standard DH transform built from `(a, alpha, d, theta)`.
94    pub fn transform(&self) -> Transform {
95        let (ct, st) = (cosf(self.theta), sinf(self.theta));
96        let (ca, sa) = (cosf(self.alpha), sinf(self.alpha));
97        Transform {
98            m: [
99                ct,
100                -st * ca,
101                st * sa,
102                self.a * ct,
103                st,
104                ct * ca,
105                -ct * sa,
106                self.a * st,
107                0.0,
108                sa,
109                ca,
110                self.d,
111                0.0,
112                0.0,
113                0.0,
114                1.0,
115            ],
116        }
117    }
118}
119
120/// Returns the transform from the base to the tool for a serial arm of DH joints.
121///
122/// # Arguments
123///
124/// * `joints` - the arm's joints, base first, each as [`DhParameters`].
125///
126/// # Returns
127///
128/// The composed base-to-tool [`Transform`]; the identity for an empty arm. Take
129/// [`Transform::position`] for the tool point.
130///
131/// # Examples
132///
133/// ```
134/// use pamoja_kit::{forward_kinematics, DhParameters};
135///
136/// // A two-link planar arm written in DH form: links of 1.0, both joints at 0, flat along x.
137/// let arm = [
138///     DhParameters { a: 1.0, alpha: 0.0, d: 0.0, theta: 0.0 },
139///     DhParameters { a: 1.0, alpha: 0.0, d: 0.0, theta: 0.0 },
140/// ];
141/// let (x, y, _z) = forward_kinematics(&arm).position();
142/// assert!((x - 2.0).abs() < 1e-5 && y.abs() < 1e-5); // reaches straight out to x = 2
143/// ```
144pub fn forward_kinematics(joints: &[DhParameters]) -> Transform {
145    let mut transform = Transform::identity();
146    for joint in joints {
147        transform = transform.multiply(&joint.transform());
148    }
149    transform
150}
151
152/// Which way a two-link arm's elbow bends; both reach the same point.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum Elbow {
155    /// The elbow bends so the second joint angle is positive (counter-clockwise).
156    Up,
157    /// The elbow bends so the second joint angle is negative (clockwise).
158    Down,
159}
160
161/// A planar two-link arm: the textbook arm with a closed-form inverse.
162///
163/// Two links of fixed length in a plane, with a shoulder and an elbow joint. [`tip`](TwoLinkArm::tip)
164/// is forward kinematics; [`joints_for`](TwoLinkArm::joints_for) is the analytic inverse, returning
165/// the shoulder and elbow angles that place the hand at a target, for the chosen [`Elbow`] branch.
166///
167/// # Examples
168///
169/// ```
170/// use pamoja_kit::{Elbow, TwoLinkArm};
171///
172/// let arm = TwoLinkArm::new(1.0, 1.0);
173/// // Place the hand, then recover the joint angles for it.
174/// let (x, y) = arm.tip(0.5, 0.7);
175/// let (q1, q2) = arm.joints_for(x, y, Elbow::Up).unwrap();
176/// assert!((q1 - 0.5).abs() < 1e-4 && (q2 - 0.7).abs() < 1e-4);
177///
178/// // A target beyond the arm's reach has no solution.
179/// assert!(arm.joints_for(5.0, 0.0, Elbow::Up).is_none());
180/// ```
181#[derive(Clone, Copy, Debug)]
182pub struct TwoLinkArm {
183    l1: f32,
184    l2: f32,
185}
186
187impl TwoLinkArm {
188    /// Creates an arm from its two link lengths.
189    ///
190    /// # Arguments
191    ///
192    /// * `l1` - the first (shoulder) link length; its magnitude is used.
193    /// * `l2` - the second (elbow) link length; its magnitude is used.
194    ///
195    /// # Returns
196    ///
197    /// The arm.
198    pub fn new(l1: f32, l2: f32) -> Self {
199        Self {
200            l1: magnitude(l1),
201            l2: magnitude(l2),
202        }
203    }
204
205    /// Returns the closest and farthest distances the hand can reach from the shoulder.
206    ///
207    /// # Returns
208    ///
209    /// `(min, max)`, where `min` is `|l1 - l2|` and `max` is `l1 + l2`.
210    pub fn reach(&self) -> (f32, f32) {
211        (magnitude(self.l1 - self.l2), self.l1 + self.l2)
212    }
213
214    /// Returns the hand position for given joint angles (forward kinematics).
215    ///
216    /// # Arguments
217    ///
218    /// * `shoulder` - the first joint angle, in radians from the x axis.
219    /// * `elbow` - the second joint angle, in radians relative to the first link.
220    ///
221    /// # Returns
222    ///
223    /// The hand `(x, y)`.
224    pub fn tip(&self, shoulder: f32, elbow: f32) -> (f32, f32) {
225        let x = self.l1 * cosf(shoulder) + self.l2 * cosf(shoulder + elbow);
226        let y = self.l1 * sinf(shoulder) + self.l2 * sinf(shoulder + elbow);
227        (x, y)
228    }
229
230    /// Returns the joint angles that place the hand at a target (inverse kinematics).
231    ///
232    /// # Arguments
233    ///
234    /// * `x` - the target x coordinate.
235    /// * `y` - the target y coordinate.
236    /// * `elbow` - which [`Elbow`] branch to solve for.
237    ///
238    /// # Returns
239    ///
240    /// `Some((shoulder, elbow))` for a reachable target, or `None` if the target lies outside the
241    /// arm's reach.
242    pub fn joints_for(&self, x: f32, y: f32, elbow: Elbow) -> Option<(f32, f32)> {
243        let distance_squared = x * x + y * y;
244        let distance = sqrtf(distance_squared);
245        let (min, max) = self.reach();
246        // A tiny tolerance keeps a target exactly on the boundary solvable despite rounding.
247        let tolerance = 1e-4;
248        if distance > max + tolerance || distance < min - tolerance {
249            return None;
250        }
251
252        let denominator = 2.0 * self.l1 * self.l2;
253        if denominator == 0.0 {
254            return None;
255        }
256        // Clamp guards the boundary case where rounding pushes the cosine just past +/-1.
257        let cos_elbow = clamp(
258            (distance_squared - self.l1 * self.l1 - self.l2 * self.l2) / denominator,
259            -1.0,
260            1.0,
261        );
262        let elbow_magnitude = acosf(cos_elbow);
263        let elbow_angle = match elbow {
264            Elbow::Up => elbow_magnitude,
265            Elbow::Down => -elbow_magnitude,
266        };
267        let shoulder = atan2f(y, x)
268            - atan2f(
269                self.l2 * sinf(elbow_angle),
270                self.l1 + self.l2 * cosf(elbow_angle),
271            );
272        Some((shoulder, elbow_angle))
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use core::f32::consts::FRAC_PI_2;
280
281    #[test]
282    fn dh_forward_kinematics_matches_the_planar_arm() {
283        // The same two-link arm, once in DH form and once by the planar formula, must agree.
284        let (q1, q2) = (0.6_f32, -0.4_f32);
285        let dh = [
286            DhParameters {
287                a: 1.5,
288                alpha: 0.0,
289                d: 0.0,
290                theta: q1,
291            },
292            DhParameters {
293                a: 1.0,
294                alpha: 0.0,
295                d: 0.0,
296                theta: q2,
297            },
298        ];
299        let (x, y, z) = forward_kinematics(&dh).position();
300        let arm = TwoLinkArm::new(1.5, 1.0);
301        let (px, py) = arm.tip(q1, q2);
302        assert!((x - px).abs() < 1e-5);
303        assert!((y - py).abs() < 1e-5);
304        assert!(z.abs() < 1e-5);
305    }
306
307    #[test]
308    fn identity_is_the_multiplicative_unit() {
309        let t = DhParameters {
310            a: 0.7,
311            alpha: 0.3,
312            d: 0.2,
313            theta: 1.1,
314        }
315        .transform();
316        let i = Transform::identity();
317        assert_eq!(i.multiply(&t), t);
318        assert_eq!(t.multiply(&i), t);
319    }
320
321    #[test]
322    fn a_z_offset_lifts_the_tool_out_of_the_plane() {
323        // A pure link offset along the base z axis puts the tool one unit straight up.
324        let dh = [DhParameters {
325            a: 0.0,
326            alpha: 0.0,
327            d: 1.0,
328            theta: 0.0,
329        }];
330        let (x, y, z) = forward_kinematics(&dh).position();
331        assert!(x.abs() < 1e-5 && y.abs() < 1e-5 && (z - 1.0).abs() < 1e-5);
332    }
333
334    #[test]
335    fn a_twist_then_offset_swings_into_the_y_axis() {
336        // A 90-degree twist about x turns a z offset into a -y displacement, still in the xy plane.
337        let dh = [
338            DhParameters {
339                a: 0.0,
340                alpha: FRAC_PI_2,
341                d: 0.0,
342                theta: 0.0,
343            },
344            DhParameters {
345                a: 0.0,
346                alpha: 0.0,
347                d: 1.0,
348                theta: 0.0,
349            },
350        ];
351        let (x, y, z) = forward_kinematics(&dh).position();
352        assert!(x.abs() < 1e-5 && (y + 1.0).abs() < 1e-5 && z.abs() < 1e-5);
353    }
354
355    #[test]
356    fn two_link_inverse_round_trips_both_elbows() {
357        let arm = TwoLinkArm::new(1.0, 1.2);
358        for &(q1, q2) in &[(0.5, 0.7), (0.2, -0.9), (-0.6, 1.1)] {
359            let (x, y) = arm.tip(q1, q2);
360            let elbow = if q2 >= 0.0 { Elbow::Up } else { Elbow::Down };
361            let (s, e) = arm.joints_for(x, y, elbow).unwrap();
362            let (rx, ry) = arm.tip(s, e);
363            // The recovered angles must reproduce the same hand position.
364            assert!((rx - x).abs() < 1e-4 && (ry - y).abs() < 1e-4);
365        }
366    }
367
368    #[test]
369    fn unreachable_targets_have_no_solution() {
370        let arm = TwoLinkArm::new(1.0, 1.0);
371        assert!(arm.joints_for(5.0, 0.0, Elbow::Up).is_none()); // too far
372        assert!(arm.joints_for(0.0, 0.0, Elbow::Up).is_some()); // folded back: reachable (min = 0)
373    }
374
375    #[test]
376    fn reach_is_the_link_sum_and_difference() {
377        let arm = TwoLinkArm::new(2.0, 0.5);
378        assert_eq!(arm.reach(), (1.5, 2.5));
379    }
380}