The Geometry of Motion
Frames, rotations, SE(2), and the exponential map — why a pose is not a vector, and how ⊞ and ⊟ let every filter in this book pretend that it is.
Any rigid-body configuration can be achieved by starting from the fixed (home) reference frame and integrating a constant twist for a specified time.
In this chapter
Probabilistic robotics puts distributions on poses. A pose is not a vector, and almost every frustrating bug in a student SLAM system comes from forgetting it: the heading that averages to zero when it should average to 180°, the covariance ellipse drawn on a quantity that does not live in a plane, the trajectory that drifts because someone integrated a turn as if it were a straight line.
This chapter builds the substrate honestly. Frames and rotation matrices, homogeneous transforms and the cancellation rule, the exponential map from the rotation ODE, SE(2) worked to the last decimal — and then the move that the classical robotics texts do not make, because they never put a distribution on anything: the retraction operators and , which turn a curved manifold into a local vector space that a Kalman filter can work in without lying.
It also forges the book's most reused artifact. The SE2 type built here is imported by every
filter, every motion model, and every SLAM system from Chapter 4
to Chapter 26. Get it right once.
The average of 179° and −179°
Rusty has two heading estimates. One says 179°, the other says −179°. They disagree by two degrees. What is the average heading?
Add and halve: . The robot is now facing due east, ninety degrees off from where either estimate said, having averaged two nearly identical opinions into their exact opposite. Nothing was noisy, nothing was miscalibrated, and no floating-point rounding was involved. The arithmetic was simply applied to objects that do not support it.
The failure is not an edge case to be patched with an if statement. It is structural: headings
live on a circle, and a circle has no consistent notion of "add these two numbers." Every quantity
in this book that involves an orientation — a pose, a rotation, a relative measurement between two
poses — has the same property, and every operation a filter performs on such a quantity has to be
defined rather than assumed.
The repair visible in the purple needle is the entire chapter compressed into one line. Do not add the angles. Instead:
- Ask what rotation takes A to B. That is a difference, written , and on a circle it is the short way around: , not .
- Halve that rotation. Rotations can be scaled — they live in a genuine vector space, the tangent space.
- Apply the halved rotation to A. That is , and the result is .
Subtract on the manifold, work in the tangent space, come back to the manifold. Chapters 7, 9, 15, and 16 are all applications of that sentence to progressively harder problems.
Building intuition: frames, and the notation that types them
Before rotations there are frames. A frame is a choice of origin and orthonormal axes; a point in physical space has one position and as many coordinate triples as there are frames looking at it. Craig's notation makes the bookkeeping explicit by decorating every symbol with the frame it is expressed in:
| Symbol | Meaning |
|---|---|
| Coordinate frames. Rusty carries a body frame {B}; the world frame {W} is bolted to the map. | |
| The point p, expressed in the coordinates of {A}. The point does not change; the triple does. | |
| The transform "A from B": it maps ᴮp to ᴬp, and equivalently describes the pose of {B} as seen from {A}. | |
| Its rotation block. The columns are {B}'s axes written in {A}. | |
| Tangent (twist) coordinates for SE(2), translation first. Note the ordering — this book follows Solà, not Lynch–Park. | |
| Hat maps tangent coordinates to the 3×3 Lie-algebra matrix; vee undoes it. | |
| The adjoint of T: the 3×3 matrix that moves a twist from one frame to another. | |
| Retraction x·exp(δ^) and its local inverse log(x⁻¹y)^∨. Right/local convention, fixed book-wide. | |
| A unit quaternion; the double cover of SO(3). |
Read every transform name left to right as target-from-source. Then consumes something expressed in and produces something expressed in , and the rule for chaining transforms writes itself: adjacent indices must match, and they cancel.
Two features of that widget deserve to be stated as mathematics rather than left as observations.
Derivation: rotation matrices are stacked axes
DerivationThe columns of a rotation matrix are the frame's own axes
Step 1 — expand the point in 's basis. Write , which means as a geometric statement about arrows, independent of any frame.
Step 2 — express that statement in . Taking coordinates is linear, so
So the columns of are literally 's axes, written in 's coordinates. That is worth remembering when you are staring at four numbers in a debugger: the first column is where the body's nose points.
Step 3 — read off the constraints. The axes are unit length and mutually perpendicular, and the entry of is the dot product of columns and . Hence , so . Taking determinants gives ; a right-handed frame maps to a right-handed frame, which selects and excludes reflections.
The set of such matrices is the special orthogonal group
and is defined identically with matrices. "Special" is the ; "orthogonal" is . Both are groups under matrix multiplication: closed, associative, with identity and inverse .
A remark on reading a rotation two ways (Craig §2.2 vs §2.3). The same matrix can be read as a mapping — take coordinates in , return coordinates in — or as an operator — take a vector in and rotate it. The numbers are identical; the intent is not. Confusing the two is the single most common sign error in robotics code, and it is exactly what the frame-annotated types in the Practical section are designed to prevent.
Derivation: the cancellation rule
A homogeneous transform packages rotation and translation into one matrix that composes by multiplication:
DerivationComposition, inversion, and why the indices cancel
Step 1 — compose the mappings. Apply to a point expressed in , then apply to the result:
Associativity of matrix multiplication is what lets us drop the parentheses, and the bracketed product must therefore be .
Step 2 — read the index pattern. In the inner indices are both
and they cancel, leaving . The expression has inner indices
and ; it does not cancel, and it is meaningless. This is a type check performed with a
pencil, and the Practical section hands it to rustc.
Step 3 — invert by blocks. Guess and multiply:
Note what the inverse is not: it is not , and it is not . The translation must be rotated back into the other frame before it is negated, because it was measured in the frame you are leaving.
Transform equations. Because inverses are available, chains can be solved rather than only evaluated. If a calibration rig tells you and and you want the sensor extrinsics , write and left-multiply by the inverse: . That pattern — unknown = inverse of what you have, times what you want — is Craig's transform-equation technique, and it reappears in Chapter 16 as the definition of a loop-closure constraint.
A worked composition, checkable in your head. Put Rusty at and mount the lidar at — thirty centimetres forward on the robot's nose. Then
The lidar sits 30 cm north of the robot, not east, because "forward" is a body-frame word. A landmark the lidar reports at is therefore at in the map — a metre "ahead" of the sensor is a metre north in the world, because the whole chain was rotated. The widget above runs the same product with a yawed mount, so its numbers differ; the arithmetic does not.
The mathematics
Rotations in three dimensions, and the traps
is 's harder sibling. Three facts make it harder, and all three cost real robots real accuracy.
It is three-dimensional but embedded in nine numbers. A matrix has nine entries constrained by six equations ( is symmetric), leaving three degrees of freedom. Numerical drift pushes a matrix off that constraint surface, so long-running code must re-orthonormalize.
It is not commutative. in general. Rotate this page 90° about the vertical axis, then 90° about the horizontal one; reverse the order; the page ends up somewhere else. The Frame Composer above shows the same fact in the plane by applying one increment about the body frame and about the world frame.
Every minimal parameterization is singular somewhere. Three-parameter representations of — roll-pitch-yaw and its twenty-three siblings — all have configurations where two of the three axes align and one degree of freedom silently disappears.
Euler angles, in one box. There are twelve valid axis sequences (ZYX, ZYZ, …) and each can be interpreted about fixed or current axes, giving twenty-four conventions that all get called "roll, pitch, yaw". They agree at zero and disagree everywhere else. Every one of them has a gimbal-lock singularity. This book uses Euler angles for exactly one purpose — printing a heading for a human to read — and never for math, storage, or interpolation. When you must interoperate with a system that uses them, write down the convention in the type name, not in a comment.
Unit quaternions are the standard repair: four numbers, one constraint, no singularities.
DerivationUnit quaternions in one honest page
Step 1 — the rotation action. Identify a point with the pure quaternion . For a unit quaternion , the map (with the conjugate) sends pure quaternions to pure quaternions and preserves norms, so it is an isometry of fixing the origin. Writing and expanding the product recovers Rodrigues' formula for a rotation by about .
Step 2 — composition is multiplication. , so composing rotations is multiplying quaternions: sixteen multiplies instead of twenty-seven, and no orthogonality constraint to maintain — only , restored by a single division.
Step 3 — the double cover. , so and name the same
rotation. covers twice. Consequences that bite in practice: the "distance"
between two quaternions must be taken to the nearer of ; naively averaging a set of
quaternions can cancel them to zero; and interpolation must use slerp, which moves along the
great circle at constant angular rate, rather than a componentwise blend followed by
renormalization.
We derive quaternions here and then delegate them. nalgebra's UnitQuaternion and sophus's
are correct, tested, and fast; re-implementing them teaches nothing that the
two-dimensional case does not teach more clearly.
The exponential map
Here is the question that unifies everything. A rigid body moves with constant velocity for one unit of time. Where does it end up?
DerivationFrom the rotation ODE to the exponential map
Step 1 — differentiate the orthogonality constraint. holds for all . Differentiating,
So is skew-symmetric. Name it ; in the plane that is with , and in three dimensions it is the familiar cross-product matrix. The angular velocity is not an extra modelling assumption — it is forced by orthogonality.
Step 2 — solve the linear matrix ODE. with constant is solved by the matrix exponential, , exactly as the scalar equation is solved by .
Step 3 — collapse the series in 2-D. , so the series splits into the cosine and sine series:
The exponential of a skew matrix is a rotation matrix. In two dimensions everything commutes and this is exact and boring, which is precisely why is the right place to learn.
Step 4 — collapse the series in 3-D. With a unit axis, , so all powers reduce to and , and
which is Rodrigues' formula. The three numbers are the exponential coordinates of the rotation.
Now do the same for a full rigid motion. This is the derivation the rest of the book leans on.
DerivationSE(2) exp and log in closed form
Write the tangent vector as with the body-frame linear velocity. Its hat form is the matrix
Step 1 — write the motion as an ODE. A body moving with constant body-frame velocity satisfies . Splitting into blocks with :
The second equation is the whole story: the body-frame velocity has to be rotated into the world before it can be integrated, and the rotation is itself changing.
Step 2 — integrate. From Step 1 of the previous derivation, starting from . Substituting,
Step 3 — evaluate the integral entrywise. With and ,
Step 4 — check the straight-line limit. As , and , so and : pure translation. The formula degrades gracefully, which is exactly what a robot driving nearly straight needs.
Step 5 — invert. , so
using and . The logarithm is then
defined and smooth for — and only there, because at the rotation by and by are the same group element and has to pick one.
Step 6 — the numerical guard. Both and are at , so code needs a
branch. Below use the series
,
, and
. Above it, write as
rather than literally. At the a 60 Hz control loop produces,
, so subtracting two numbers that agree to six places
throws away six of the sixteen digits a f64 had — and it gets worse as shrinks, right
in the regime where a robot spends most of its life. The half-angle form never subtracts anything.
The geometric content of is worth saying in words, because it is the payoff. A constant twist does not drive a body along a straight line; it sweeps the body around a fixed point — the instantaneous center of rotation, at in body coordinates — at constant angular rate. This is the planar case of the Chasles–Mozzi theorem, the one Lynch and Park state as a screw: every rigid displacement is a rotation about some axis combined with a translation along it, and in the plane the translation vanishes, leaving a rotation about a point or a pure translation. is the bookkeeping that converts "how far I drove along the arc" into "where I ended up".
The adjoint
Twists, like points, are frame-dependent. The object that moves them between frames is the adjoint.
DerivationThe SE(2) adjoint by block computation
Step 1 — conjugate a one-parameter motion. For fixed , the curve passes through the identity at and is a one-parameter subgroup, so it equals for some tangent vector . Define by .
Step 2 — differentiate at . This gives the equivalent linear statement . Expand with :
Step 3 — use the two-dimensional accident. , and planar rotations commute, so . The rotation part of the twist is therefore unchanged — in the plane, angular velocity is frame-independent — and the linear part becomes . Collecting blocks with the translation-first ordering,
A note on authority. Sign and ordering conventions for adjoints are notorious; published
papers disagree with each other and with themselves. This book's authority is not anyone's memory
but the property test adjoint_conjugation in the Practical section, which asserts
on every random input proptest
can throw at it. If you change the tangent ordering, that test — not your memory — tells you what
the adjoint became.
The adjoint is how a covariance changes frames. If a body-frame uncertainty is , the same uncertainty in the world frame is — the manifold version of the familiar , and the reason Chapter 9 can compose odometry uncertainty at all.
⊞ and ⊟: a legitimate local vector space
Every estimator in this book wants to write : the Kalman update adds a correction to a mean, Gauss–Newton adds a step to an iterate, a particle filter adds noise to a sample. On that expression is undefined. The fix is to define it, once, with the two operators already used informally above:
Derivation⊞ and ⊟ satisfy the Hertzberg axioms
Axiom 1 — the zero increment does nothing. .
Axiom 2 — ⊟ finds the increment that ⊞ needs.
valid wherever and are mutual inverses, which by Step 5 of the derivation means for the relative rotation between and .
Axiom 3 — ⊟ recovers the increment.
again for . Together with smoothness of and , these are exactly Hertzberg et al.'s axioms for a manifold encapsulation: they say that near any , the map is a well-behaved chart, so an algorithm written in is doing honest calculus.
Which side? is the right (local) convention: the increment is applied in the body frame. is the left (global) convention, applied in the world frame. They differ by the adjoint, — which is what the two ghost frames in the Frame Composer are showing. This book fixes the right convention everywhere, for a physical reason: a robot's odometry, its wheel encoders, and its IMU all report body-frame quantities, so right increments are the ones the hardware actually produces. Chapter 7 turns the choice into an error-state EKF; the invariant-EKF literature makes the opposite choice deliberately and gets different, sometimes better, convergence guarantees.
Vectors are manifolds too, with and . That is not a technicality: it means one generic filter implementation covers a Euclidean state, a pose, and a pose-plus-bias state, with the compiler picking the right operators.
The algorithms
Geometry has no Thrun table names, so these are the book's Rust API, stated once and never re-derived.
- In
- τ = (v_x, v_y, ω)ᵀ ∈ ℝ³, tangent coordinates
- Out
- T ∈ SE(2)
- if then ,
- else ,
- endif
- // this is
- return
- In
- T = (R, t) ∈ SE(2)
- Out
- τ ∈ ℝ³, with |ω| < π
- // already wrapped to
- if then else endif
- // this is
- return
Everything else is one line on top of these: , , , and .
Uncertainty on a manifold
A short section now, because it is the bridge to Parts II and III; the full treatment is Chapter 9.
If a pose is not a vector, a distribution over poses is not the Gaussian on that Chapter 2 built. Writing is already wrong: it assigns different densities to the same rotation depending on which branch of you wrote down. The standard repair is a concentrated Gaussian: put an ordinary Gaussian in the tangent space, where it is at home, and push it onto the group through the retraction,
The mean lives on the manifold, the covariance lives in the tangent space at , and every operation a filter performs happens in that tangent space. What the construction produces in coordinates is not an ellipse.
Two consequences, both of which get proper treatment later. First, : the sample mean of the cloud is not the commanded endpoint, and the gap grows with and with distance travelled. Second, an ellipse fitted to the cloud claims mass in places the robot cannot be. A Gaussian filter tracking a pose is making a bet that the banana is thin enough to pass for an ellipse over one time step — which is usually true at 60 Hz and disastrously false over a ten-metre dead-reckoned stretch. Chapter 7 names that bet and prices it.
Implementation in Rust
The type is small enough to read in one sitting and central enough that it is worth reading
carefully. Two design decisions are load-bearing. The rotation is stored as a UnitComplex, not
as an f64 angle, so no arithmetic can ever produce an unwrapped heading. And the tangent type is
a fixed-size SVector<f64, 3>, so a dimension mismatch is a compile error rather than a panic.
use nalgebra::{Point2, SMatrix, SVector, UnitComplex, Vector2};
/// Tangent coordinates τ = (vₓ, v_y, ω)ᵀ — **translation first** (Solà order).
///
/// Lynch & Park write (ω, v). We do not, because the reader already holds the
/// pose tuple (x, y, θ) and because `factrs` and `sophus` order it this way.
pub type Tangent2 = SVector<f64, 3>;
/// Below this |ω| the closed forms are 0/0 and we switch to Taylor series.
const SMALL_ANGLE: f64 = 1e-8;
/// A planar rigid-body transform: the book's most-used type.
///
/// Storing the rotation as a `UnitComplex` rather than an angle is not a
/// micro-optimization — it makes an unwrapped heading *unrepresentable*.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SE2 {
pub rot: UnitComplex<f64>,
pub trans: Vector2<f64>,
}
impl SE2 {
pub fn identity() -> Self {
Self { rot: UnitComplex::identity(), trans: Vector2::zeros() }
}
pub fn new(x: f64, y: f64, theta: f64) -> Self {
Self { rot: UnitComplex::new(theta), trans: Vector2::new(x, y) }
}
/// exp: 𝔰𝔢(2) → SE(2). Derivation 4, with the guard from its Step 6.
pub fn exp(tau: &Tangent2) -> Self {
let (vx, vy, w) = (tau[0], tau[1], tau[2]);
let (a, b) = if w.abs() < SMALL_ANGLE {
(1.0 - w * w / 6.0, w / 2.0 - w * w * w / 24.0)
} else {
// (1 − cos ω) written as 2 sin²(ω/2): the literal form loses about
// eight digits to cancellation at the ω a 60 Hz loop produces.
let sh = (0.5 * w).sin();
(w.sin() / w, 2.0 * sh * sh / w)
};
Self {
rot: UnitComplex::new(w),
trans: Vector2::new(a * vx - b * vy, b * vx + a * vy),
}
}
/// log: SE(2) → 𝔰𝔢(2). Exact inverse of `exp` for |ω| < π.
pub fn log(&self) -> Tangent2 {
let w = self.rot.angle(); // UnitComplex::angle is already in (−π, π]
let h = 0.5 * w;
// c = (ω/2)·cot(ω/2), the cancellation-free form of ω sin ω / 2(1−cos ω).
let c = if w.abs() < SMALL_ANGLE { 1.0 - w * w / 12.0 } else { h / h.tan() };
let (x, y) = (self.trans.x, self.trans.y);
Tangent2::new(c * x + h * y, -h * x + c * y, w)
}
/// (Rᵀ, −Rᵀt). Note that the translation is rotated *before* it is negated.
pub fn inverse(&self) -> Self {
let r_inv = self.rot.inverse();
Self { rot: r_inv, trans: -(r_inv * self.trans) }
}
/// Ad_T, satisfying T·exp(τ)·T⁻¹ = exp(Ad_T τ). Derivation 6.
pub fn adjoint(&self) -> SMatrix<f64, 3, 3> {
let r = self.rot.to_rotation_matrix();
let m = r.matrix();
SMatrix::<f64, 3, 3>::new(
m[(0, 0)], m[(0, 1)], self.trans.y,
m[(1, 0)], m[(1, 1)], -self.trans.x,
0.0, 0.0, 1.0,
)
}
/// Map a point from this frame's coordinates into the parent frame.
pub fn act(&self, p: &Point2<f64>) -> Point2<f64> {
self.rot.transform_point(p) + self.trans
}
/// Display only. Never do arithmetic on the tuple — that is what ⊞ is for.
pub fn xytheta(&self) -> (f64, f64, f64) {
(self.trans.x, self.trans.y, self.rot.angle())
}
}
impl std::ops::Mul for SE2 {
type Output = SE2;
fn mul(self, rhs: SE2) -> SE2 {
SE2 { rot: self.rot * rhs.rot, trans: self.trans + self.rot * rhs.trans }
}
}The retraction goes in its own trait, because that is the interface every later estimator is
generic over. D is the tangent dimension, and it is a const generic so that the compiler knows
the size of every Jacobian in Chapter 7.
use nalgebra::SVector;
use crate::geom::se2::{Tangent2, SE2};
/// The interface the book's filters are written against.
///
/// `x ⊞ δ` moves `x` by a tangent-space increment; `y ⊟ x` recovers the
/// increment that would do it. Implementations must satisfy the Hertzberg
/// axioms, which `tests/geom.rs` checks by property test rather than by trust.
pub trait Manifold<const D: usize>: Copy {
fn boxplus(&self, delta: &SVector<f64, D>) -> Self;
fn boxminus(&self, other: &Self) -> SVector<f64, D>;
}
impl Manifold<3> for SE2 {
/// Right (local) convention: the increment is applied in the body frame.
fn boxplus(&self, delta: &Tangent2) -> Self {
*self * SE2::exp(delta)
}
fn boxminus(&self, other: &Self) -> Tangent2 {
(other.inverse() * *self).log()
}
}
/// Vectors are manifolds too — flat ones. This impl is why one generic EKF
/// serves both a Euclidean state and a pose without a line of special-casing.
impl<const N: usize> Manifold<N> for SVector<f64, N> {
fn boxplus(&self, delta: &SVector<f64, N>) -> Self { self + delta }
fn boxminus(&self, other: &Self) -> SVector<f64, N> { self - other }
}Craig's notation, enforced by rustc
Craig invented a type system in 1986 and wrote it in superscripts. Rust can check it. The markers
are zero-sized, PhantomData occupies nothing, and the whole thing compiles down to the same
machine code as bare SE2.
use core::marker::PhantomData;
use core::ops::Mul;
use crate::geom::se2::SE2;
pub trait Frame: 'static {}
pub struct World;
pub struct Body;
pub struct LidarF;
impl Frame for World {}
impl Frame for Body {}
impl Frame for LidarF {}
/// `Pose<A, B>` is Craig's ᴬ_BT: "A from B". Zero runtime cost, and the index
/// bookkeeping becomes the compiler's problem instead of yours.
#[derive(Clone, Copy, Debug)]
pub struct Pose<A: Frame, B: Frame>(pub SE2, PhantomData<fn() -> (A, B)>);
impl<A: Frame, B: Frame> Pose<A, B> {
pub fn new(t: SE2) -> Self { Self(t, PhantomData) }
pub fn inverse(self) -> Pose<B, A> { Pose::new(self.0.inverse()) }
}
/// The cancellation rule, as a trait impl: the inner frames must agree, and
/// they vanish from the output type.
impl<A: Frame, B: Frame, C: Frame> Mul<Pose<B, C>> for Pose<A, B> {
type Output = Pose<A, C>;
fn mul(self, rhs: Pose<B, C>) -> Pose<A, C> { Pose::new(self.0 * rhs.0) }
}Now the payoff. This is a deliberate compile error, and it is a feature of the chapter:
let world_from_body: Pose<World, Body> = Pose::new(SE2::new(2.0, 1.0, FRAC_PI_2));
let world_from_lidar: Pose<World, LidarF> = Pose::new(SE2::new(2.0, 1.3, FRAC_PI_2));
// Wrong: the inner indices are W and W. They do not cancel.
let oops = world_from_body * world_from_lidar;error[E0308]: mismatched types
--> demos/ch03-geometry/examples/frame_safety.rs:6:31
|
6 | let oops = world_from_body * world_from_lidar;
| ^^^^^^^^^^^^^^^^ expected `Pose<Body, _>`,
| found `Pose<World, LidarF>`What you meant was world_from_body.inverse() * world_from_lidar, whose type is
Pose<Body, LidarF> — the sensor extrinsics. The convention for the rest of the book: bare SE2
for state inside an estimator, where everything is in one frame by construction, and
Pose<_, _> at subsystem boundaries — extrinsics, map anchoring, anything crossing a module line.
Putting it together: the square dance
Here is the numeric example to check by hand. It is the smallest computation that exercises the whole chapter, and a test pins it.
Drive a quarter circle. Command : one metre of arc while turning ninety degrees. From Derivation 4,
because and , and . So
Sanity check the geometry: the turn radius is , the center of the arc is at , and rotating the start point about that center lands exactly on .
Go back. of that pose returns , so and — the two entries of are equal, and
Compare with Euler integration. The classical update , , applied in equal substeps gives
| endpoint | error vs. | |
|---|---|---|
| 1 | 0.7330 m | |
| 4 | 0.1772 m | |
| 16 | — | ≈ 0.044 m |
The error falls like , which is first-order convergence, which is what "Euler" means. The exponential is exact at , because it is not an approximation of the arc — it is the arc.
Close a square. Alternate exp((1,0,0)) and exp((0,0,π/2)) four times. Rusty returns to the
origin, and the closure error measured on the manifold,
, is at the level of floating-point
round-off — around , not . That is the difference between arithmetic that
respects the group and arithmetic that approximates it.
use approx::assert_relative_eq;
use nalgebra::Vector3;
use proptest::prelude::*;
use rand::{rngs::SmallRng, Rng, SeedableRng};
use std::f64::consts::{FRAC_PI_2, PI};
use pr_core::geom::{manifold::Manifold, se2::{Tangent2, SE2}};
/// The chapter's worked example, pinned to 1e-12.
#[test]
fn worked_example_ch03_quarter_arc() {
let tau = Tangent2::new(1.0, 0.0, FRAC_PI_2);
let t = SE2::exp(&tau);
let (x, y, theta) = t.xytheta();
assert_relative_eq!(x, 2.0 / PI, epsilon = 1e-12); // 0.636619772...
assert_relative_eq!(y, 2.0 / PI, epsilon = 1e-12);
assert_relative_eq!(theta, FRAC_PI_2, epsilon = 1e-12);
// …and log takes us straight back.
assert_relative_eq!(t.log(), tau, epsilon = 1e-12);
// One Euler step is off by 73 centimetres on a one-metre arc.
let euler = (1.0 - x).hypot(0.0 - y);
assert_relative_eq!(euler, 0.733028, epsilon = 1e-6);
}
/// Four sides and four right-angle turns must return exactly to the start.
#[test]
fn square_dance_closes() {
let forward = SE2::exp(&Tangent2::new(1.0, 0.0, 0.0));
let turn = SE2::exp(&Tangent2::new(0.0, 0.0, FRAC_PI_2));
let mut pose = SE2::identity();
for _ in 0..4 {
pose = pose * forward * turn;
}
// Measured on the manifold, not on the tuple: ‖final ⊟ identity‖.
let closure = pose.boxminus(&SE2::identity()).norm();
assert!(closure < 1e-14, "closure error {closure:e} — the group is not closing");
}
fn twist() -> impl Strategy<Value = Tangent2> {
// |ω| < π keeps us inside log's injectivity radius, per Derivation 4 Step 5.
(-5.0f64..5.0, -5.0f64..5.0, -3.14f64..3.14)
.prop_map(|(a, b, c)| Tangent2::new(a, b, c))
}
proptest! {
#[test]
fn exp_log_roundtrip(tau in twist()) {
prop_assert!((SE2::exp(&tau).log() - tau).norm() < 1e-12);
}
#[test]
fn group_axioms(a in twist(), b in twist(), c in twist()) {
let (ta, tb, tc) = (SE2::exp(&a), SE2::exp(&b), SE2::exp(&c));
let lhs = (ta * tb) * tc;
let rhs = ta * (tb * tc);
prop_assert!(lhs.boxminus(&rhs).norm() < 1e-12); // associativity
prop_assert!((ta * ta.inverse()).log().norm() < 1e-12); // inverse
}
/// The authority on the adjoint's sign convention. Not memory — this.
#[test]
fn adjoint_conjugation(t in twist(), tau in twist()) {
let big_t = SE2::exp(&t);
let lhs = big_t * SE2::exp(&tau) * big_t.inverse();
let rhs = SE2::exp(&(big_t.adjoint() * tau));
prop_assert!(lhs.boxminus(&rhs).norm() < 1e-10);
}
#[test]
fn boxplus_axioms(x in twist(), d in twist()) {
let big_x = SE2::exp(&x);
prop_assert!(big_x.boxplus(&Tangent2::zeros()).boxminus(&big_x).norm() < 1e-14);
let y = big_x.boxplus(&d);
prop_assert!((y.boxminus(&big_x) - d).norm() < 1e-12);
prop_assert!(big_x.boxplus(&y.boxminus(&big_x)).boxminus(&y).norm() < 1e-12);
}
}
/// log stays accurate right up to the injectivity boundary — the place where
/// a naive implementation quietly returns the wrong branch.
#[test]
fn log_is_stable_near_pi() {
let mut rng = SmallRng::seed_from_u64(0xC0FFEE); // never thread_rng() in this book
for _ in 0..1000 {
let theta = PI - rng.random_range(1e-9..1e-6);
let ours = SE2::new(0.3, -0.2, theta).log();
assert_relative_eq!(ours[2], theta, epsilon = 1e-12);
assert_relative_eq!(SE2::exp(&ours).log(), ours, epsilon = 1e-9);
}
}Every widget on this page runs the TypeScript port of the code above — web/lib/geom/se2.ts and
web/lib/geom/screw.ts — and both implementations are checked against this same quarter-arc
example. If the prose and the code ever disagree, the test settles it.
The three-dimensional case is delegated, not skipped. sophus provides and
with the same exp/log/adjoint surface, nalgebra's UnitQuaternion handles the
parameterization, and Chapter 18 uses both. The reason to hand-roll
and only is that every structure in the general case is visible here, in two
dimensions, where you can check the answer with a protractor.
Exercises
- Foundation exerciseDifficulty 2 of 3Derive V and its inverse
Evaluate entrywise to reproduce Derivation 4's closed form, then derive by inverting the matrix directly. Show that both tend to as , and compute the first two nonzero terms of the Taylor series of each entry — these are the coefficients the code branches to.
- Foundation exerciseDifficulty 2 of 3The adjoint is a homomorphism
Prove the adjoint formula of Derivation 6 by block computation, then show that and . Explain why the first identity says "changing frames twice is one change of frame", and why that is exactly what makes covariance propagation across a kinematic chain composable.
- Foundation exerciseDifficulty 3 of 3Right increments are body-frame quantities
Let with . Show that the world-frame displacement of the origin is , and hence to first order. Conclude why wheel odometry naturally produces right increments, and derive the conversion to the left convention.
- Conceptual exerciseDifficulty 2 of 3Predict, then verify with the Exp/Log Lens
Two poses differ by a pure rotation with no translation. Predict, before touching the widget: where does the geodesic interpolation put the robot at , and why does fail to pick a unique answer for this pair? Now drag the arrowhead in w3.2 to the origin so , and slide toward . The slider deliberately stops short of — say why, in terms of Derivation 4, Step 5, and describe what happens to the arc and to the ICR as you approach the limit from either side.
- Conceptual exerciseDifficulty 1 of 3Squeeze the banana
In w3.4, predict what happens to the cloud as with fixed, and what happens to the 2σ coverage readout. Verify. Then state, in one sentence, the condition under which the fitted ellipse is approximately honest — you have just described the bet the EKF makes in Chapter 7.
- Practical exerciseDifficulty 2 of 3Implement SE2 and pass the suite
Implement
SE2withexp,log,inverse,adjoint, andMul, plus theManifold<3>impl, so that every test incrates/pr-core/tests/geom.rsabove passes. Then deliberately break the small-angle guard by deleting the Taylor branch, and find the value of below whichexp_log_roundtripstarts failing. Report it, and name the subtraction that lost the digits. - Practical exerciseDifficulty 3 of 3SO3 under the same trait
Implement
SO3as a newtype overnalgebra::UnitQuaternion<f64>withexp,log, andManifold<3>. Handle with the numerically stable branch (the naive divides by , which vanishes at , and loses precision as from the other side). Property-test the round trip againstsophusat a thousand random rotations including the near- band, and plot the round-trip error against . - Practical exerciseDifficulty 2 of 3Make the frame bug uncompilable
Take a working piece of code that composes transforms with bare
SE2— the widget's lidar chain is a fine target — and port it toPose<A, B>. Introduce a realistic frame bug (compose the extrinsics in the wrong order) and show the compiler catching it. Then measure: does the newtype cost anything? Compare the generated assembly forPose<World, Body> * Pose<Body, LidarF>with plainSE2 * SE2.
References
- Lynch, K. M. and Park, F. C. (2017) Modern Robotics: Mechanics, Planning, and Control. Cambridge University Press. ISBN 978-1107156302.link to Modern Robotics: Mechanics, Planning, and Control (opens in a new tab)
Chapter 3 is this chapter's computational spine: planar rigid motions, angular velocity, exponential coordinates, twists, and screws. The epigraph is from its opening pages. Note the tangent ordering differs from ours — they write (ω, v).
- Craig, J. J. (2005) Introduction to Robotics: Mechanics and Control. Pearson, 3rd edition. ISBN 978-0201543612.link to Introduction to Robotics: Mechanics and Control (opens in a new tab)
The source of the leading super/subscript notation and the cancellation rule. Sections 2.2–2.7 are the clearest treatment of frames as a discipline anywhere; the Rust newtypes in this chapter are that discipline, mechanized.
- Spong, M. W., Hutchinson, S., and Vidyasagar, M. (2020) Robot Modeling and Control. Wiley, 2nd edition. ISBN 978-1119523994.link to Robot Modeling and Control (opens in a new tab)
Section 2.4 states the fixed-frame versus current-frame composition rule — pre- versus post-multiplication — which is exactly the two ghost frames in the Frame Composer, and the reason this book fixes the right convention.
- Hertzberg, C., Wagner, R., Frese, U., and Schröder, L. (2013) Integrating generic sensor fusion algorithms with sound state representations through encapsulation of manifolds. Information Fusion 14(1), 57–77.doi:10.1016/j.inffus.2011.08.003 (opens in a new tab)
Where ⊞ and ⊟ come from, with the axioms proved in Derivation 7. The paper's thesis is this chapter's thesis: encapsulate the manifold and every estimator downstream can be written as if the state were a vector.
- Solà, J., Deray, J., and Atchuthan, D. (2018) A micro Lie theory for state estimation in robotics. arXiv:1812.01537.link to A micro Lie theory for state estimation in robotics (opens in a new tab)
The modernization spine of this chapter: the ⊞/⊟ interface, the adjoint, and the Jacobian bookkeeping that Part II needs. This book follows its translation-first tangent ordering and its right-⊞ convention.
- Mangelson, J. G., Ghaffari, M., Vasudevan, R., and Eustice, R. M. (2020) Characterizing the Uncertainty of Jointly Distributed Poses in the Lie Algebra. IEEE Transactions on Robotics 36(5), 1371–1388.doi:10.1109/TRO.2020.2994457 (opens in a new tab)
The banana, taken seriously: what a concentrated Gaussian on SE(2)/SE(3) actually looks like, and why the independence assumption between poses in a graph is usually false. Chapter 9 builds on its compounding operations.
- Potokar, E. R., Beard, R. W., and Mangelson, J. G. (2024) An Introduction to the Invariant Extended Kalman Filter [Lecture Notes]. IEEE Control Systems 44(6).doi:10.1109/MCS.2024.3466488 (opens in a new tab)
The most readable recent account of why the choice between left and right ⊞ has consequences for convergence, not just for bookkeeping. Read it after Chapter 7.
- Ge, Y., van Goor, P., and Mahony, R. (2025) The Geometry of Extended Kalman Filters on Manifolds with Affine Connection. arXiv:2506.05728.link to The Geometry of Extended Kalman Filters on Manifolds with Affine Connection (opens in a new tab)
Where the field is now: the retraction of this chapter is one choice among many, and connection, parallel transport, and curvature all show up in the filter equations once you look. The frontier version of Chapter 7.
