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}