The abstraction that turns every motion planning problem into a point navigating through a manifold — from topology to SE(3).
Imagine you need to move a grand piano from one room to another through a narrow hallway. In the real world, you have to think about the piano’s exact shape, how to tilt it, how much clearance you need at each angle. Every possible position and orientation of the piano — every way it could be arranged in space — is a separate thing you might need to consider. This is motion planning in the workspace, and it is a geometric nightmare.
Here is the insight that unlocks all of Chapter 4. The piano has three degrees of freedom in 2D: its x position, its y position, and its orientation angle θ. Every possible arrangement of the piano is completely described by exactly three numbers — the triple (x, y, θ). Instead of thinking about the piano as a shape sweeping through 2D space, we can think about the triple (x, y, θ) as a single point moving through a 3-dimensional space. That three-dimensional space is the configuration space, or C-space.
Walls in the physical room become regions of C-space called Cobs — configurations where the piano would overlap an obstacle. Everything else is Cfree = C ∖ Cobs. The piano-moving problem becomes: find a continuous path for a point through Cfree. All the planning algorithms from Chapter 2 — graph search, RRTs, PRMs — can then operate on this abstract space without caring at all about the piano’s shape.
The canvas below makes this concrete. The left panel shows a 2D room with a rectangular piano (dark orange). Drag it around. The right panel shows the corresponding point in (x, y) C-space — colored by the current angle θ. The gray region in the right panel is Cobs: configurations that collide with the wall. Watch the point enter the gray region when the piano touches a wall.
Drag the sliders to move and rotate the piano. Left: workspace view. Right: C-space view with Cobs shaded. The colored dot is the current configuration.
The piano in 2D has 3 DOF, so its C-space is 3-dimensional. A degree of freedom (DOF) is an independent way the robot can move. A point robot sliding on a 2D plane has 2 DOF: it can move left-right and up-down. Its C-space is R2. A rod that can also spin has 3 DOF: x, y, θ. Its C-space is R2 × S1. A 6-DOF robotic arm — 6 revolute joints — has a 6-dimensional C-space.
This is why C-space is such a powerful abstraction. No matter how geometrically complex the robot is in the workspace, it is always a single point in C-space. A 6-DOF industrial arm with links, joints, and end-effectors becomes a point in R6 (or a 6-dimensional manifold, as we’ll see). The planning algorithms don’t need to know anything about the arm’s shape — they just navigate a 6D space.
Here is where beginners trip up. C-space has dimension equal to the number of DOF, but the topology is not always Rn. The translation DOF of a robot moving in an unbounded plane give a factor of R2 — that part really is flat and infinite. But a revolute joint contributes a factor of S1 (a circle), not R. A 2-link arm has C-space T2 = S1×S1, not R2. Confusing these leads to planning algorithms that miss valid paths, declare reachable configurations unreachable, or compute incorrect path lengths.
Chapters 1–3 of this lesson build the topological vocabulary needed to distinguish these cases: topological spaces, homeomorphisms, identification spaces, and the fundamental group. Chapter 4 applies that vocabulary to SE(2) and SE(3), the C-spaces of rigid bodies in 2D and 3D. From Chapter 6 onward, we handle kinematic chains, where multiple joints chain together multiplicatively.
| Robot type | DOF | C-space | Topology |
|---|---|---|---|
| 2D point robot | 2 | R2 | Flat plane |
| 2D rigid body (piano) | 3 | R2×S1 = SE(2) | Cylinder × line |
| 2-link revolute arm | 2 | T2 = S1×S1 | Torus |
| 3D rigid body | 6 | R3×SO(3) = SE(3) | R3×RP3 |
| 6-DOF industrial arm | 6 | T6 = (S1)6 | 6-torus |
One of the hardest things about C-space is that humans cannot visualize spaces with more than 3 dimensions. A 6-DOF robot arm has a 6-dimensional C-space, and Cobs is a 6-dimensional region. There is no way to look at it directly. This is not a failure of imagination — it is a fundamental limitation, and it is why developing rigorous mathematical understanding of C-space topology matters. The math lets us reason about spaces we cannot see.
Practical approaches to high-dimensional C-space: (1) Projection: fix four joint values and vary two, plotting the 2D slice of Cobs. This reveals local structure but misses global connectivity. (2) Sampling: scatter random configurations and check collision; build a statistical picture of Cfree’s density. (3) Roadmaps: build a sparse graph of collision-free configurations and use it as a proxy for Cfree’s connectivity. This is the PRM algorithm of Chapter 5. (4) Topology: use theoretical results about the manifold structure (what we’re building in this chapter) to understand the global properties without needing to visualize them.
Knowing the C-space structure is not enough. We also need to know which configurations are in collision (the C-space obstacle region, Cobs). Formally: a configuration q is in Cobs if the robot at configuration q intersects at least one obstacle. That is:
where A(q) is the set of points occupied by the robot at configuration q (the robot’s “swept volume” in the workspace), and O is the union of all obstacles. Cfree = C ∖ Cobs.
The direct computation of Cobs is expensive. For a polygon robot and polygon obstacles, Cobs can be computed exactly using the Minkowski sum (Chapter 9 of this lesson). But in higher-dimensional C-spaces, exact Cobs computation is intractable. This is why sampling-based planners (Chapter 5) avoid computing Cobs explicitly — they just check whether a sampled configuration q is in Cobs by checking A(q) ∩ O using a fast collision detection library.
Let’s make C-space concrete with actual numbers. Say the piano is a rectangle 2.0 m long × 1.0 m wide. The hallway is 1.5 m wide. The piano’s configuration is (x, y, θ) where x, y are the center coordinates and θ is the rotation angle.
At θ = 0° (piano aligned with the hallway), the piano’s footprint is 2.0 m × 1.0 m. It fits in the 1.5 m hallway in the 1.0 m dimension but not the 2.0 m dimension. At θ = 90°, the footprint is 1.0 m × 2.0 m — the 1.0 m side fits, but now it’s 2.0 m in the hallway’s width direction. At θ = 45°, the bounding box is (2.0 sin 45° + 1.0 cos 45°) = √2(2+1)/√2 = √(22+12) × cos(45°−arctan(1/2)) — roughly 2.2 m at the diagonal.
In C-space: the x-y slice at each fixed θ shows a different “forbidden zone” shape (the piano’s footprint inflated by the Minkowski sum of the obstacle). As θ sweeps from 0 to 2π, the Cobs cross-section morphs continuously. The full Cobs is a 3D region in (x, y, θ)-space. Finding a path from (xstart, ystart, 0) to (xgoal, ygoal, 0) through Cfree corresponds exactly to finding the sequence of positions and rotations that lets the piano navigate the hallway without collision.
python import numpy as np def piano_corners(cx: float, cy: float, theta: float, length: float = 2.0, width: float = 1.0) -> np.ndarray: """Four corners of the piano rectangle at configuration (cx, cy, theta). Returns (4, 2) array of corner positions in world frame. """ # Corners in body frame (center at origin) half_l, half_w = length / 2, width / 2 corners_body = np.array([ [ half_l, half_w], [ half_l, -half_w], [-half_l, -half_w], [-half_l, half_w], ]) # shape (4, 2) # Rotation matrix for angle theta c, s = np.cos(theta), np.sin(theta) R = np.array([[c, -s], [s, c]]) # (2, 2) # Transform to world frame: rotate then translate corners_world = (R @ corners_body.T).T + np.array([cx, cy]) return corners_world # shape (4, 2) def check_collision(cx, cy, theta, obstacles: list) -> bool: """Check if piano at (cx, cy, theta) intersects any obstacle polygon.""" from shapely.geometry import Polygon piano = Polygon(piano_corners(cx, cy, theta)) for obs in obstacles: if piano.intersects(Polygon(obs)): return True # in C_obs return False # in C_free # Sample C-space to find C_free hallway_walls = [ [(-10, -10), (-10, 0.0), (10, 0.0), (10, -10)], # bottom wall [(-10, 1.5), (-10, 10), (10, 10), (10, 1.5)], # top wall ] # For each (x, theta) slice, check collision xs = np.linspace(-5, 5, 100) thetas = np.linspace(0, 2*np.pi, 72) c_free = np.zeros((100, 72), dtype=bool) for i, x in enumerate(xs): for j, theta in enumerate(thetas): # y fixed at 0.75 (center of hallway) c_free[i, j] = not check_collision(x, 0.75, theta, hallway_walls) # c_free[i,j] = True means config (xs[i], 0.75, thetas[j]) is collision-free
Here is a quiet trap that breaks motion planners if you ignore it. A revolute joint can rotate through angle θ. You might represent θ as a real number, say θ ∈ [0, 2π). But wait — is θ = 0° the same configuration as θ = 360°? Yes, of course. They’re the same physical orientation. But on the number line, 0 and 2π are different points, 6.28 apart. If your planner treats θ as a number in [0, 2π), it thinks the path from θ = 0.01 to θ = 2π − 0.01 must traverse the entire interval — a 6.27-radian journey — when there’s a 0.02-radian shortcut right across the boundary.
The problem is that the number line is the wrong topological space for angles. The right space is the circle, written S1. On a circle, the two “endpoints” 0 and 2π are the same point, and the short path across that seam is perfectly valid. This is not a technicality — it changes which paths exist, which are shortest, and whether a planner finds them at all.
A topological space is a set X together with a collection of “open sets” satisfying three axioms: the empty set and all of X are open; any union of open sets is open; any finite intersection of open sets is open. This seems abstract, but the open sets encode the neighborhood structure — they say which points are “close to” which other points, without needing a metric.
In R, the open sets are unions of open intervals (a, b). In contrast, [0, 1] is a closed set (it contains its boundary points 0 and 1). The interval [0, 1) is neither open nor closed — it contains the boundary point 0 but not 1. This distinction matters for C-space because joint limits create boundaries, and planners must handle them correctly.
Not all topological spaces are equal for motion planning purposes. Three key properties determine what kinds of planners will work:
1. Hausdorff property: any two distinct points can be separated by disjoint open sets. Manifolds are Hausdorff. This ensures that two different robot configurations are genuinely distinct and cannot be confused by the topology. Without the Hausdorff property, a sequence of configurations could converge to two different limits simultaneously — a nightmare for any convergence-based planner.
2. Second countability: there is a countable basis for the topology (a countable collection of open sets such that every open set is a union of basis sets). This ensures that C-space is not “too large” for measure-theoretic arguments. All the manifolds in this chapter — Sn, Tn, SO(3), SE(3) — are second countable. This property underpins probabilistic completeness arguments for sampling-based planners: the space can be approximated arbitrarily well by a finite sample.
3. Paracompactness: every open cover has a locally finite refinement. This is a technical condition that allows for the existence of smooth partitions of unity, which are used to patch together local constructions (like local coordinate charts) into global ones. All manifolds are paracompact. For planning, this matters for gradient-based methods: the ability to do calculus globally on C-space requires paracompactness.
In topology, a function f: X → Y is continuous if the preimage of every open set in Y is open in X. This generalizes the calculus definition (“close inputs give close outputs”) to spaces where “close” is defined by open sets rather than by a metric. For robot motion planning, this means: a motion is a continuous path if small changes in time give small changes in configuration. In C-space, this is exactly the requirement that the path γ: [0,1] → Cfree is a continuous function.
The path γ must be continuous: there should be no teleportation. When γ stays within Cfree — every point on the path is collision-free — we have a valid motion plan. The fundamental planning question is: does such a γ exist, connecting qinitial to qgoal? This is the path existence problem, and its answer depends entirely on the topology of Cfree.
| Space | Notation | Description | Example DOF it models |
|---|---|---|---|
| Real line | R | Unbounded, 1D, no boundary | Prismatic joint (infinite range) |
| Bounded interval | [a, b] | Bounded, 1D, has two boundary points | Prismatic joint with hard limits |
| Circle | S1 | Compact, 1D, no boundary, wraps around | Revolute joint (full rotation) |
| 2-Sphere | S2 | Compact, 2D, no boundary | Spherical joint (2D direction) |
The crucial fact about S1: it is compact (bounded and closed) but has no boundary. You can walk along a circle forever without reaching an edge. A number line has neither property. A closed interval [a, b] is compact but DOES have two boundary points. S1 is the unique 1-manifold that is compact and boundary-free.
A space is compact if every open cover has a finite subcover — the topological generalization of “closed and bounded.” For motion planning, compactness has two practical consequences. First, compact C-spaces have a finite diameter: there is a maximum possible path length between any two configurations. This means planners can reason about worst-case exploration distances. Second, on a non-compact space like Rn, a path can escape to infinity; on a compact space like Tn or Sn, it cannot.
S1 is compact (it is closed and bounded as a subset of R2). R is not compact (it is unbounded). T2 is compact (product of compact spaces is compact). SE(2) = R2×S1 is not compact (the R2 factor is unbounded). In practice, we always restrict to a bounded workspace, making the effective C-space compact — but the underlying topology determines whether the boundaries are “hard walls” (joint limits, manifolds-with-boundary) or “wrap-arounds” (freely rotating joints, compact manifolds).
On a manifold, we need a distance function to measure how far apart two configurations are. On Rn, Euclidean distance works. On S1, the correct distance between angles θ1 and θ2 is the length of the shorter arc:
This is the geodesic distance on S1: the length of the shortest path between the two points staying on the circle. For T2 = S1×S1, the natural distance is the product metric: d((θ1, φ1), (θ2, φ2)) = √(dS1(θ1, θ2)2 + dS1(φ1, φ2)2). For planners like RRT that need nearest-neighbor queries, using the right distance function is critical for finding nearby configurations across identification boundaries.
Drag the slider. On R (top), the point hits a wall at the endpoints. On S1 (bottom), the point wraps around — 0° and 360° are the same place. The red dot shows both representations of the same angle.
A homeomorphism is a continuous bijection with a continuous inverse. If such a map exists between X and Y, we say X and Y are homeomorphic (written X ≅ Y) — they are topologically identical. The open interval (0, 1) ≅ R via f(x) = tan(π(x − ½)). The circle S1 ≅ any closed curve without self-intersections. The classic punchline: a donut is homeomorphic to a coffee cup — both have exactly one hole. You can continuously deform one into the other.
For C-space, homeomorphism tells us when two representations are equivalent. If we parameterize a revolute joint as [0, 2π) versus as S1, these are NOT homeomorphic — [0, 2π) has a boundary point (the gap at θ = 0/2π), while S1 does not. A planner using [0, 2π) will refuse to plan across that gap. A planner using S1 handles it naturally.
The spaces we care about in robotics are all manifolds: topological spaces that locally look like Rn. Zoom in close enough to any point on S1, and it looks like a line segment. Zoom in on any point on S2, and it looks like a flat patch of R2. The global topology can be complicated (sphere, torus, projective space), but locally everything is flat. This local flatness is what lets us do calculus on manifolds — and it is why motion planners can work with infinitesimally small steps even in curved C-spaces.
A manifold has a dimension equal to the dimension of the local Euclidean neighborhood. S1 is a 1-manifold (locally like R). S2 is a 2-manifold (locally like R2). SE(3) is a 6-manifold. A key fact: a manifold cannot have boundaries unless we explicitly declare them. S1 and T2 are closed manifolds (compact, no boundary). [0, 1] is a manifold with boundary. Revolute joints with mechanical stops are modeled as [a, b] (manifold with boundary); unconstrained revolute joints are S1 (closed manifold).
A topological space X is connected if it cannot be partitioned into two disjoint, non-empty open sets. Intuitively: you can travel between any two points. Cfree may or may not be connected. If Cfree is disconnected, there is no continuous path from qinitial to qgoal if they are in different connected components — the planning problem is infeasible.
A stronger notion is path-connectedness: X is path-connected if for any two points p, q ∈ X, there exists a continuous path γ: [0,1] → X with γ(0)=p and γ(1)=q. For “nice” spaces (manifolds, which all our C-spaces are), connectedness and path-connectedness are equivalent. So for robotics, we can use them interchangeably.
A planner like BFS or RRT implicitly tests path-connectedness: if the planner fails to find a path after exhaustive search, it is reporting that qinitial and qgoal are in different connected components of Cfree. Proving that no path exists (rather than merely failing to find one) requires completeness guarantees — a major theme in Chapter 5.
At any point q on a smooth manifold, the tangent space TqM is the set of all possible velocity vectors at q — the directions in which a path through q can be moving at that instant. For Rn, the tangent space at every point is just Rn itself (all directions are possible). For S1, the tangent space at each point is a 1D line tangent to the circle at that point.
Why do tangent spaces matter for planning? A path through C-space is a smooth map γ: [0,1] → C. Its derivative ˙γ(t) is a tangent vector in Tγ(t)C. For a robot with nonholonomic constraints, only certain tangent directions are admissible — the robot cannot move in arbitrary directions in C-space. Planning must stay within the admissible tangent cone at each point. For holonomic systems, all tangent directions are admissible, and any smooth path through Cfree is a valid plan.
For SE(2), the tangent space at any configuration (x, y, θ) is 3-dimensional: instantaneous rates of change (˙x, ˙y, ˙θ). For a differential-drive robot, the nonholonomic constraint says ˙x sin θ − ˙y cos θ = 0 — this restricts the admissible tangent vectors to a 2-dimensional subspace at each configuration (forward/backward and turning). The admissible subspace varies continuously as θ changes, creating the curved structure of nonholonomic motion.
How do we construct spaces like S1? One elegant method is identification (also called “gluing”). Start with a simple, familiar space — like an interval or a square — and declare that certain points are “the same.” The resulting quotient space can have a completely different topology, even though it was built from something simple.
Example: take the unit interval [0, 1] and declare 0 ∼ 1 (zero and one are identified). Geometrically, you’re bending the interval into a loop and gluing the endpoints together. The result is S1. The identification creates the “wrap-around” topology that makes S1 different from [0, 1].
| Identification | Result | Orientable? | C-space role |
|---|---|---|---|
| None | R2 (plane) | Yes | 2D translation |
| Left ≅ right: (0,y)∼(1,y) | Cylinder R×S1 | Yes | One translation + one rotation |
| Left ≅ right, flipped: (0,y)∼(1,1−y) | Möbius band | No | — (non-orientable, rare in practice) |
| Both pairs: (0,y)∼(1,y) AND (x,0)∼(x,1) | Torus T2 = S1×S1 | Yes | 2-link revolute arm! |
| Both pairs, one flip | Klein bottle | No | — |
| Both pairs, both flipped | RP2 (real projective plane) | No | Related to SO(3) |
LaValle uses a wonderful analogy from his actual text: the flat torus T2 looks exactly like the universe in the classic Asteroids arcade game. When your spaceship flies off the right edge of the screen, it reappears on the left. When it flies off the top, it reappears at the bottom. The screen is a square with opposite edges identified — which is exactly the definition of T2 = S1×S1.
A 2-link robot arm, where each joint can rotate fully 360°, has two angles θ1 and θ2. Each angle lives on S1. The pair (θ1, θ2) lives on T2 = S1×S1. This is NOT just R2. If you plan on R2 instead of T2, you will miss paths that cross the θ = 0/2π boundary for either joint — topologically valid shortcuts that are physically equivalent to small rotations.
There are two ways to think about T2. The embedded torus is the donut surface in R3: {(x,y,z) : (√(x2+y2)−R)2 + z2 = r2}. The flat torus is the square [0,1]×[0,1] with opposite edges identified. These are homeomorphic (same topology), but they are NOT isometric (they have different metrics).
The flat torus is what we actually want for C-space. On the flat torus, distances are computed with the product metric on S1×S1: d((θ1,φ1),(θ2,φ2)) = √(dcirc(θ1,θ2)2 + dcirc(φ1,φ2)2). On the embedded torus, the inner circle (closer to the central axis) has a shorter circumference than the outer circle — the metric is not uniform. Using the embedded torus metric would incorrectly penalize configurations where the robot is near certain joint angles just because of how the torus happens to sit in 3D space, which has no physical meaning.
Each mode shows a different identification on the unit square. A path that crosses an identified edge (shown in color) reappears on the opposite edge. Toggle modes to see how gluing creates different topologies.
If you implement a path planner for a 2-link arm and you store the configuration as two floating-point angles in [0, 2π), then when θ1 is at 0.05 rad and the planner wants to decrease it to 2π − 0.05 rad (a tiny clockwise step), the planner sees a jump of nearly 2π — a giant step in its distance function. The correct representation stores (θ1, θ2) on T2 with a distance metric that knows the boundary is identified. The distance between 0.05 and 2π − 0.05 is 0.1 rad, not 6.18 rad.
Most robot C-spaces are product manifolds: you take the C-space factors for each independent DOF and multiply them together. The product X×Y is just the set of all pairs (x, y) with x ∈ X and y ∈ Y, with the product topology (open sets are products of open sets). This is exactly how R3 = R×R×R works, but now applied to non-Euclidean factors.
A 3-link arm where link 1 can fully rotate (S1), link 2 has a joint limit (interval [−π/2, π/2]), and link 3 can fully rotate (S1) has C-space S1×[−π/2, π/2]×S1. This is a 3-manifold with boundary (the middle factor has endpoints). Path planners on this space must handle the boundary correctly: configurations at θ2 = ±π/2 are physically reachable but the joint cannot move further.
Let’s work through a concrete planning scenario to feel the difference. A 2-link arm is at configuration qI = (0.1, 0.1) (both joints near 0 radians). The goal is qG = (6.1, 6.1) (both joints near 2π radians — barely past a full revolution, physically almost identical to qI).
On R2: the Euclidean distance from qI to qG is √((6.0)2 + (6.0)2) ≈ 8.49. The planner plans a long sweeping path across most of the configuration space, rotating both joints nearly 360°.
On T2: the toroidal distance is computed as min(|Δθ1|, 2π−|Δθ1|) for each joint separately. For both joints, |Δθ| = 6.0, and 2π−6.0 ≈ 0.28. So the toroidal distance is √((0.28)2 + (0.28)2) ≈ 0.40. The planner finds the short path that crosses the seam — a tiny movement of about 0.28 radians per joint — instead of the 360° detour.
The ratio is 8.49 / 0.40 ≈ 21x. A planner using the wrong topology finds a path 21 times longer than necessary in this case. In surgical robot applications with cable-driven joints, this difference directly translates to excess cable wear, slower movements, and potentially reaching joint limits unnecessarily.
Another way to build topological spaces is as CW complexes: start with a set of 0-cells (points), attach 1-cells (line segments, glued at endpoints), attach 2-cells (disks, glued at boundaries), and so on. S1 as a CW complex: one 0-cell (a point) and one 1-cell (an arc, with both endpoints identified to the 0-cell). T2 as a CW complex: one 0-cell, two 1-cells (the two circle factors, one in each direction), one 2-cell (the square, with boundary identified to the 1-skeleton).
This cell decomposition is useful for planning because it provides a finite combinatorial structure that approximates the continuous manifold. Cell decomposition planners (Chapter 6 of LaValle) work by decomposing Cfree into cells and building a roadmap on the cell adjacency graph. The topology of the cells and how they fit together determines whether the planner correctly handles the manifold structure.
Joint limits impose boundaries on C-space. A revolute joint limited to [−π, π] has C-space [−π, π] — a manifold-with-boundary. The boundary consists of the two endpoints {−π, π}. At a boundary configuration, the joint cannot move further in the constrained direction: the path must stay in the interior or return from the boundary.
Planners that sample C-space must handle boundaries. A configuration sampled uniformly from [a, b]×S1 might land exactly at x = a or x = b — a valid configuration, but where one direction of motion is blocked. The planner must not try to extend the tree “through” the boundary. In practice, sampled configurations are checked against joint limits, and steering functions clamp to the boundary when they would overshoot.
For completeness, let’s briefly visit the non-orientable identifications from the table. The Klein bottle is constructed from a square by identifying the left-right edges normally ((0,y)∼(1,y)) but identifying the top-bottom edges with a flip ((x,0)∼(1−x,1)). The result is a closed, non-orientable surface that cannot be embedded in R3 without self-intersection. It has no C-space role in standard robotics but appears in topology textbooks as a canonical example.
The real projective plane RP2 identifies both pairs of opposite edges with flips. It is also non-orientable, also cannot be embedded in R3, and π1(RP2) = Z2. Its 3D analog RP3 = S3/∼ (antipodal identification on S3) IS the topology of SO(3), as we discuss in Chapter 5. Non-orientable spaces are rare in robot C-spaces (all the common ones — SE(2), SE(3), Tn — are orientable), but the identification technique that generates them is the same one that generates all the spaces we do use.
Here is a common implementation mistake and its fix. In an RRT for a 2-link arm, we need to find the nearest existing node to a randomly sampled configuration. If we use Euclidean distance on raw angles, we get wrong nearest-neighbors near the wrap-around boundary.
python import numpy as np # WRONG: Euclidean distance ignores wrap-around def bad_distance(q1: np.ndarray, q2: np.ndarray) -> float: return np.linalg.norm(q1 - q2) # ||(θ₁,θ₂) - (φ₁,φ₂)||₂ # BUG: distance(0.1, 6.1) = 6.0, should be ~0.28 # CORRECT: Toroidal distance (product metric on T²) def circle_dist(a: float, b: float) -> float: """Geodesic distance between two angles on S¹.""" diff = abs(a - b) % (2 * np.pi) return min(diff, 2 * np.pi - diff) def torus_distance(q1: np.ndarray, q2: np.ndarray) -> float: """Correct geodesic distance on T^k for k revolute joints.""" return np.sqrt(sum(circle_dist(q1[i], q2[i])**2 for i in range(len(q1)))) # Example: q1=(0.1, 0.1), q2=(6.1, 6.1) (both just past 0, physically close) q1 = np.array([0.1, 0.1]) q2 = np.array([6.1, 6.1]) d_bad = bad_distance(q1, q2) # ≈ 8.49 — wildly wrong d_correct = torus_distance(q1, q2) # ≈ 0.40 — correct # 21x difference in nearest-neighbor priority for RRT!
Two spaces can look similar — both are 2D, both are smooth — but have fundamentally different loop structures. A disk has no holes; a washer has one. You can shrink any loop on a disk to a point. On a washer, a loop that goes around the hole cannot be shrunk without cutting it. How do we make this precise? The answer is the fundamental group π1(X).
Fix a basepoint p ∈ X. A loop based at p is a continuous map γ: [0,1] → X with γ(0) = γ(1) = p. Two loops are homotopic if one can be continuously deformed into the other while keeping the basepoint fixed. Homotopy is an equivalence relation — it partitions the set of all loops into classes. The fundamental group π1(X) is the set of these homotopy classes, with group operation “traverse one loop then the other.”
Fix a basepoint p ∈ X. The group operation on homotopy classes [γ1] and [γ2] is concatenation: first traverse γ1 at double speed, then γ2 at double speed — both completing in the unit time interval [0,1]. The inverse of [γ] is the reverse path γ−1(t) = γ(1−t). The identity is the constant path at p.
Two key facts: (1) π1 is well-defined (the group axioms hold up to homotopy), and (2) for path-connected spaces, changing the basepoint gives an isomorphic group (so the choice of p doesn’t matter for the algebraic structure, only for concrete element labeling). For all the spaces we care about, we can pick any convenient basepoint.
R2 (or any Rn): Simply connected. Every loop can be continuously shrunk to the basepoint through the interior of Rn. π1(Rn) = {e} — the trivial group with one element.
S1 (the circle): A loop that winds once around S1 cannot be shrunk — there is no 2D interior to move through. Winding once counterclockwise and winding once clockwise are different classes. Winding twice counterclockwise is yet another class. In fact, each integer winding number gives a distinct homotopy class, and combining two loops adds their winding numbers. π1(S1) = Z (the integers under addition).
T2 = S1×S1 (the torus): Two independent circles, two independent winding numbers. π1(T2) = Z×Z = Z2. A loop that winds 3 times in the θ1 direction and −2 times in the θ2 direction belongs to the class (3, −2) ∈ Z2.
RP2 (real projective plane, relevant for SO(3)): Strange behavior. Winding twice is homotopic to winding zero times! π1(RP2) = Z2 = {0, 1} (the integers mod 2). The same phenomenon occurs in RP3, which is homeomorphic to SO(3).
| Space | π1 | Interpretation |
|---|---|---|
| Rn | {e} (trivial) | No holes, every loop contractible |
| S1 | Z | Winding number — infinitely many loop classes |
| S2 | {e} | Simply connected (no 1D holes in the 2-sphere) |
| T2 | Z2 | Two independent winding numbers |
| RP2 | Z2 | Double cover: winding twice = trivial |
| SO(3) ≅ RP3 | Z2 | 720° rotation contractible; 360° is not |
If π1(Cfree) is non-trivial, there are multiple homotopy classes of paths between two configurations. A planner that finds one path may have found the unique class — or it may have found the expensive class while missing a shorter one. On a cylinder R×S1 with an obstacle spanning the cylinder, the only path from qI to qG may go “over the seam” — crossing the S1 identification boundary. A planner that treats the S1 direction as a number line will declare the path impossible.
The plate trick (also called the Dirac belt trick) is the physical manifestation of π1(SO(3)) = Z2. Hold a plate flat on your palm and rotate it 360° around a vertical axis: your arm is twisted. Continue to 720°: your arm untwists to its original position. A 360° rotation of a rigid body is not contractible to no rotation, but a 720° rotation IS. This is a topological fact about RP3, and it has real consequences for cable-routing and tether management in robot systems.
When Cfree is simply connected (π1(Cfree) = {e}), every two paths with the same endpoints are homotopic. This means there is essentially one homotopy class of paths between any two configurations. A planner can find “the” path without worrying about missing a topologically distinct alternative. This is the common case for many practical robot systems: a robot arm in free space with no obstacles looping around any joint has simply connected Cfree.
But simply connected does NOT mean easy to plan in. A simply connected space can have very narrow passages, very high dimension, very complex geometry. It just means the topology is not adding extra difficulty beyond the geometry. The planning algorithms of Chapters 5 and 6 are designed to work in both simply connected and non-simply-connected Cfree; the topology affects whether they are complete and what guarantees they can provide.
If Cfree has a non-trivial fundamental group, there are multiple homotopy classes of paths from qI to qG. This means: there are qualitatively different routes that cannot be continuously deformed into each other. One route might go “above” an obstacle and one “below,” where the obstacle is a hole in Cfree.
Standard planners (RRT, PRM) are single-homotopy-class planners. They find one path and stop. If the first path they find is in the expensive homotopy class, they miss the shorter path in a different class. Homotopy-aware planners — an active research area — explicitly track which class each candidate path belongs to, ensuring they explore all classes before declaring optimality.
LaValle’s Figure 4.8 gives the canonical example: a cylinder R×S1 with a wall-like obstacle that extends all the way across the S1 direction at a fixed height. The only path from bottom to top must cross the S1 identification boundary. π1 = Z tells us there are infinitely many homotopy classes — each number of times the path wraps around S1 gives a different class. The planner must understand this topology, or it will miss the path.
The fundamental group π1 counts distinct classes of loops. Higher homotopy groups count distinct classes of higher-dimensional maps. π2(X) counts equivalence classes of maps S2 → X (2-sphere mappings). For S2 itself: π2(S2) = Z, meaning 2-sphere maps into S2 have a winding number, just as loop maps into S1 do.
For robotics: π2(SO(3)) = 0 (trivial), but π3(SO(3)) = Z. This means that while all sphere-shaped configurations are contractible in SO(3), there are non-trivial 3-sphere families of rotations. This appears in the context of Hopf fibrations and has connections to spin representations in quantum mechanics — fascinating mathematics, but beyond the needs of most motion planners.
The key practical lesson: for the motion planning problems in LaValle’s book, we need π1 (loop structure, path connectivity) and the connectedness of Cfree. Higher homotopy groups appear in more advanced topics like topological complexity of motion planning (Farber’s TC invariant) and configuration spaces of many-robot systems.
For product spaces X×Y, the fundamental group is the product: π1(X×Y) = π1(X) × π1(Y). This is why π1(T2) = π1(S1×S1) = Z×Z. For more complex constructions — spaces formed by gluing two spaces along a common subspace — the Seifert-van Kampen theorem gives a way to compute π1 from the pieces.
The practical upshot: once you know the C-space decomposition into product factors, you can compute π1(C) by multiplying the π1s of each factor. A k-link arm with all revolute joints has C-space Tk = (S1)k, and π1(Tk) = Zk. The planner needs to be aware of k independent “winding number” choices for the path — 2k qualitatively distinct path classes to potentially explore.
| Robot | C-space | π1 | Distinct path classes between any qI, qG |
|---|---|---|---|
| Point in Rn | Rn | {e} | 1 (unique homotopy class) |
| 1-link revolute arm | S1 | Z | ∞ (one per winding number) |
| 2-link revolute arm | T2 | Z2 | ∞ (pairs of winding numbers) |
| 3D rigid body | SE(3) | Z2 | 2 (short and long rotation path) |
| 6-DOF arm | T6 | Z6 | ∞ (6 independent winding numbers) |
Each loop winds some integer number of times around the circle. Try to shrink winding-1 to winding-0 — you can’t without crossing the origin (which is not in the space). The winding number is a topological invariant: it doesn’t change under continuous deformations.
Time to get concrete. What exactly is the C-space for a rigid body that can move freely in a 2D plane? We need to build the mathematical structure, not just name it. The answer comes from understanding rigid motions as a matrix Lie group.
A rigid motion in 2D preserves distances and orientations. It consists of a rotation and a translation. The rotation part is a 2×2 matrix R(θ); the translation part is a 2D vector (x, y). LaValle derives the structure bottom-up, starting from the most general invertible matrices.
Start with GL(2): the set of all 2×2 invertible real matrices. This has 4 free entries and forms a 4-dimensional manifold (a Lie group). That’s too general — GL(2) includes stretching, shearing, and reflection, not just rotation.
Impose the constraint AAT = I (orthogonality: columns are orthonormal). For A = [[a, b], [c, d]], this gives three scalar equations: a2+b2=1 (first column unit), c2+d2=1 (second column unit), ac+bd=0 (columns orthogonal). Three constraints on four free parameters — by the implicit function theorem, the solution set is generically a 4−3=1 dimensional manifold. The result is O(2): the orthogonal group.
O(2) has two connected components, separated by the determinant. When det(A) = +1, the matrix is a pure rotation. When det(A) = −1, the matrix is a reflection. These two sets are topologically disconnected: you cannot continuously deform a rotation into a reflection while staying in O(2). Impose det(A) = 1 to isolate the rotations: the result is SO(2), the special orthogonal group.
The explicit parameterization: for any θ, the matrix R(θ) = [[cos θ, −sin θ], [sin θ, cos θ]] satisfies R(θ)R(θ)T = I and det(R(θ)) = cos2θ + sin2θ = 1. Conversely, any element of SO(2) has this form for some θ. The map θ → R(θ) is a bijection S1 → SO(2) that is continuous and has a continuous inverse — a homeomorphism. We have proven SO(2) ≅ S1.
A 2D rigid body can both rotate (by θ ∈ S1) and translate (by (x,y) ∈ R2). The Special Euclidean group SE(2) combines these: C = R2 × S1. Its elements are represented as 3×3 homogeneous transformation matrices:
| cos θ | −sin θ | x |
| sin θ | cos θ | y |
| 0 | 0 | 1 |
Why homogeneous coordinates? Because composing rigid motions becomes matrix multiplication: T1 · T2 applies T2 first, then T1. A point p = (px, py)T in the robot’s body frame transforms to T · (px, py, 1)T in the world frame. This is clean, composable, and directly implementable.
SE(2) has dimension 3: two translation DOF (x, y ∈ R) and one rotation DOF (θ ∈ S1). The topology is R2×S1: a cylinder (R×S1) extended into a second translational direction. It is not compact — translations can go to infinity. Its fundamental group is π1(R2×S1) = π1(S1) = Z, because π1(R2) is trivial.
Let’s trace through a concrete SE(2) computation. A robot starts at the origin facing right: T1 = se2(0, 0, 0). It drives forward 3 meters (in the +x direction of its body frame): this is a translation of (3, 0) in the robot’s body frame. Then it turns left 90° (counterclockwise). Then it drives forward 2 meters again.
Step 1: Tforward3 = se2(3, 0, 0). After applying this to T1: Tafter_forward = T1 · Tforward3 = se2(3, 0, 0). Robot is now at (3, 0) facing right.
Step 2: Tturn_left = se2(0, 0, π/2). After turning: Tafter_turn = Tafter_forward · Tturn_left = se2(3, 0, π/2). Robot is at (3, 0) facing up.
Step 3: Tforward2 = se2(2, 0, 0) (2 meters forward in current body frame direction). Apply in robot’s current frame: Tfinal = Tafter_turn · Tforward2. Since the robot faces π/2 (up), driving “forward” in body frame (+x body direction) moves it in the world’s +y direction: Tfinal = se2(3, 2, π/2). Robot ends at (3, 2) facing up.
The key: we always compose transforms as Tworld→new_pose = Tworld→old_pose · Told_pose→new_pose. The relative transform is always expressed in the robot’s current frame. This left-multiplication by the old pose correctly handles the rotation, which is why the 2m forward step went to (3,2) and not (5,0).
There is an even cleaner way to think about SO(2). Instead of a 2×2 matrix with the constraint AAT = I, det(A) = 1, we can use a unit complex number: z = eiθ = cos θ + i sin θ, with |z| = 1. Multiplication of two unit complex numbers corresponds to adding their angles: eiα · eiβ = ei(α+β). This is rotation composition. The constraint |z| = 1 is one scalar equation on two reals — confining z to S1, which is exactly SO(2).
The same idea extends to 3D: SO(3) uses unit quaternions (unit elements of the 4D quaternion algebra), which we explore in Chapter 5. And in both cases, the algebraic structure (complex numbers, quaternions) exactly captures the rotation group structure, making these parameterizations natural rather than arbitrary.
SE(2) is not just a manifold — it is a Lie group: a group that is also a smooth manifold, where the group operations (multiplication and inversion) are smooth maps. The Lie group structure means SE(2) has a natural notion of “tangent space at the identity,” called the Lie algebra se(2). For motion planning, this matters because it gives us a principled way to do interpolation (moving from one configuration to another along a geodesic) and to define velocities (elements of the Lie algebra are instantaneous velocities).
The Lie algebra se(2) consists of 3×3 matrices of the form [[0, −ω, vx], [ω, 0, vy], [0, 0, 0]], where ω is the angular velocity and (vx, vy) is the linear velocity. The exponential map exp: se(2) → SE(2) converts instantaneous velocities into finite transformations. This is the foundation for screw theory and exponential coordinates, which appear throughout modern robot kinematics.
A differential-drive robot (two powered wheels on a common axle) has three configuration variables (x, y, θ) but only two independently controllable motors — it cannot move sideways. This means the robot’s instantaneous velocity must satisfy the nonholonomic constraint: the velocity must be tangent to the direction the robot faces. Formally, ˙x sin θ − ˙y cos θ = 0 (no lateral motion).
This constraint limits which paths through SE(2) the robot can actually follow. Not all paths through Cfree are physically realizable — only those whose tangent vectors satisfy the constraint at every point. This is the key distinction between holonomic systems (any path through Cfree is realizable) and nonholonomic systems (only constraint-satisfying paths are realizable). The full C-space is still SE(2), but the reachable paths form a subset constrained by the robot’s dynamics.
For group-aware planners, we need the inverse of a SE(2) transform. If T = se2(x, y, θ) is the pose of the robot in the world, then T−1 is the pose of the world in the robot’s frame. The formula:
In matrix form, since T = [[R, t], [0, 1]], we have T−1 = [[RT, −RTt], [0, 1]]. For SE(2), RT = R(−θ) = [[cos θ, sin θ], [−sin θ, cos θ]], and −RTt = (−x cos θ − y sin θ, x sin θ − y cos θ).
For interpolating between two SE(2) configurations q0 = (x0, y0, θ0) and q1 = (x1, y1, θ1), the natural interpolation is linear for translation and shortest-arc for rotation:
where the third component uses the signed shortest arc from θ0 to θ1 (positive if counterclockwise, negative if clockwise). This gives a straight-line interpolation in (x,y) space with simultaneous shortest-arc rotation — the natural “straight line” in SE(2) for a holonomic robot.
python import numpy as np def se2_matrix(x: float, y: float, theta: float) -> np.ndarray: """SE(2) homogeneous transformation matrix. Args: x, y: translation (meters) theta: rotation (radians) Returns: 3x3 homogeneous matrix T in SE(2) """ c, s = np.cos(theta), np.sin(theta) return np.array([ [c, -s, x], [s, c, y], [0, 0, 1] ], dtype=np.float64) def compose_se2(T1: np.ndarray, T2: np.ndarray) -> np.ndarray: """Compose two SE(2) transforms: apply T2 first, then T1.""" return T1 @ T2 # 3x3 matrix multiplication def apply_se2(T: np.ndarray, point: np.ndarray) -> np.ndarray: """Apply SE(2) transform to a 2D point.""" p_hom = np.array([point[0], point[1], 1.0]) result = T @ p_hom return result[:2] # drop homogeneous coordinate # Example: robot at (3, 2) rotated 45 degrees T = se2_matrix(x=3.0, y=2.0, theta=np.pi/4) # A point at (1, 0) in robot frame p_body = np.array([1.0, 0.0]) p_world = apply_se2(T, p_body) # p_world ≈ [3.707, 2.707] — 45° rotation + translation # Composition: first move 1m in x, then rotate 90° T1 = se2_matrix(0, 0, np.pi/2) # 90° rotation T2 = se2_matrix(1, 0, 0) # 1m translation in x T_composed = compose_se2(T1, T2) # rotate AFTER translate # Result: translation 1m in x, THEN 90° — origin at (0,1) facing left
Left: a triangular robot in 2D workspace. Right: its position as a point in SE(2) C-space (x,y plane; color encodes θ). Adjust the sliders to move and rotate the robot.
A rigid body in 3D space has 6 DOF: three translations (x, y, z ∈ R3) and three rotations. The C-space is SE(3) = R3×SO(3). Translations are easy — R3 is R3. The challenge is SO(3): the group of 3D rotations.
The naive approach — three Euler angles (roll, pitch, yaw) — seems like it should work. Three angles, three DOF, done. But it fails in the most inconvenient way possible, exactly at the worst configurations. Understanding why forces us to find a better parameterization: quaternions.
We can run the same derivation as SO(2), but now in 3D. Start with GL(3): all 3×3 invertible matrices, a 9-dimensional manifold. Impose AAT = I: six constraints (three unit-column constraints, three orthogonality constraints — the upper triangle of AAT = I is 6 equations). This leaves a 9−6 = 3-dimensional manifold: O(3). Impose det(A) = 1: one more constraint cutting O(3) in half, giving SO(3), a 3-dimensional manifold.
The three dimensions of SO(3) correspond to three independent ways to rotate a rigid body in 3D (around each of three axes, for example). What is the topology of SO(3)? The key claim from LaValle Section 4.2.2: SO(3) ≅ RP3. Every unit quaternion h ∈ S3 maps to a rotation, and h and −h give the same rotation, so SO(3) = S3 / (h ∼ −h) = RP3. This identification is what makes SO(3) topologically non-trivial — and it is the source of gimbal lock.
Represent a 3D rotation as three successive rotations: yaw (around Z), then pitch (around Y), then roll (around X). This works generically, but at pitch = ±90°, the roll and yaw axes align. You lose one degree of freedom. No matter how you change roll or yaw independently, they both produce the same effect — rotation around the same axis. This is gimbal lock.
Gimbal lock is not just a numerical issue — it’s a topological fact. You cannot globally parameterize SO(3) with three angles without singularities. This follows from the Hairy Ball Theorem (you cannot comb a 3-sphere flat). Any chart on SO(3) using three real-valued angles must have at least one singular point. Euler angles have infinitely many (the entire set of ±90° pitch configurations).
The topological obstruction to gimbal-lock-free Euler angles is fundamental, not a matter of choosing a better angle convention. The Hairy Ball Theorem states that there is no continuous, non-vanishing tangent vector field on S2. A related result (which we won’t prove here) states that SO(3) cannot be covered by a single coordinate chart without singularities. Any attempt to describe all of SO(3) using three continuous angles must have degenerate points — configurations where the mapping from angles to rotations is not locally invertible.
Different Euler angle conventions (ZYX, ZYZ, XYZ, and 9 others) all have singularities, just at different configurations. ZYX (yaw-pitch-roll) locks at pitch ±90°. ZYZ (aerospace convention) locks at zero pitch. There is no singularity-free 3-angle representation of SO(3). The minimum-overhead solution is unit quaternions: 4 floats with one quadratic constraint, no singularities, direct geodesic interpolation.
A quaternion h = a + bi + cj + dk is a 4D number with three imaginary units i, j, k satisfying: i2 = j2 = k2 = ijk = −1. The key identities are ij = k, jk = i, ki = j (and their negatives when reversed). A unit quaternion satisfies a2 + b2 + c2 + d2 = 1, which means it lives on the 3-sphere S3 ⊂ R4.
Quaternion multiplication is associative but not commutative: in general, h1 · h2 ≠ h2 · h1. This non-commutativity directly reflects the non-commutativity of 3D rotations: rotating 90° around X then 90° around Y gives a different result from rotating 90° around Y then 90° around X. The quaternion algebra exactly captures this.
The conjugate of h = a + bi + cj + dk is h* = a − bi − cj − dk. For unit quaternions, h−1 = h* (inverse = conjugate). This is analogous to how rotation matrices satisfy R−1 = RT. The product hh* = a2+b2+c2+d2 = 1 for unit quaternions, confirming the norm is preserved.
To rotate a 3D vector v using quaternion h, embed v as a pure quaternion p = 0 + vxi + vyj + vzk, then compute h · p · h*. The result is another pure quaternion whose imaginary part is the rotated vector. This formula is equivalent to applying the rotation matrix R(h), but works directly in quaternion space without expanding to a 3×3 matrix.
Unit quaternions parameterize 3D rotations via the axis-angle formula. For a rotation of angle θ around unit axis v = (v1, v2, v3):
The corresponding 3×3 rotation matrix (LaValle eq. 4.20) is:
| 2(a2+b2)−1 | 2(bc−ad) | 2(bd+ac) |
| 2(bc+ad) | 2(a2+c2)−1 | 2(cd−ab) |
| 2(bd−ac) | 2(cd+ab) | 2(a2+d2)−1 |
Here is the crucial topological fact (LaValle Figure 4.10): h and −h give the same rotation. The quaternion (−a, −b, −c, −d) describes the rotation of angle (2π−θ) around axis −v, which is physically identical to rotating θ around v. Every rotation corresponds to exactly two antipodal points on S3: {h, −h}.
So SO(3) is not S3 — it is S3 with antipodal points identified: S3/~ = RP3. The unit quaternion sphere is a double cover of SO(3). This identification has the fundamental group consequence we saw in Chapter 3: π1(RP3) = Z2, explaining the plate trick.
Let’s verify the double-cover concretely. For the rotation h = cos(θ/2) + sin(θ/2) · v:
Given two orientations represented as unit quaternions h0 and h1, the shortest rotation path between them is the geodesic on S3. This geodesic is parameterized by spherical linear interpolation (SLERP):
Or equivalently, if Ω = arccos(h0 · h1) is the angle between them on S3:
SLERP produces constant angular velocity along the shortest rotation path. For motion planning, it provides a natural steering function: given two configurations qa = (ta, ha) and qb = (tb, hb) in SE(3), we can interpolate translations linearly and rotations via SLERP to get a valid path through C-space (assuming no obstacles). This is used directly in RRT-Connect and similar bidirectional planners operating in SE(3).
The C-space of a 3D rigid body is SE(3) = R3×SO(3). Since SO(3) ≅ RP3, we have SE(3) ≅ R3×RP3. This is a 6-dimensional manifold. Its elements are represented as 4×4 homogeneous transformation matrices:
| —— R (3×3) —— | x | ||
| y | |||
| z | |||
| 0 | 0 | 0 | 1 |
where R is the 3×3 rotation matrix from the quaternion formula above, and (x, y, z) is the translation. Composition of rigid motions is again matrix multiplication. This is the standard format used by every robotic library: ROS uses SE(3) transforms, robotics simulators use SE(3) transforms, computer vision uses SE(3) transforms.
A robot operating in 3D space maintains a transform tree: a directed graph of SE(3) transforms linking coordinate frames. Each node is a frame (world, robot base, each link, each camera). Each edge is a 4×4 T matrix. To compute the pose of the end-effector in world coordinates, you multiply the transforms along the path from world to end-effector: Tworld→ee = Tworld→base · Tbase→link1 · … · Tlink(n-1)→ee.
This chain of SE(3) multiplications is called forward kinematics — the topic of Chapter 6. For now, the key point is that each joint contributes one SE(3) transform parameterized by the joint’s configuration variable (angle for revolute, displacement for prismatic). The C-space of the entire chain is the product of the C-space factors for each joint.
lookupTransform("world", "end_effector"), tf2 multiplies SE(3) transforms along the kinematic chain. The choice of quaternion over rotation matrix (9 floats, harder to normalize) is deliberate: quaternions are more compact, normalize cheaply (divide by norm), and SLERP interpolates smoothly.
SE(3) is not compact (translations can go to infinity). For planning a drone or autonomous vehicle, we operate in a bounded workspace — a compact subset of SE(3) — and the planner works within that subset. For a robot arm fixed to a base, the translations are constrained by the arm’s reach, effectively compactifying the reachable C-space. In practice, planners always work with bounded C-spaces, obtained by taking the physically meaningful subset of the full mathematical C-space.
The sampling distribution for sampling-based planners must respect the topology. For the translation part (R3), uniform sampling in a bounding box works fine. For the rotation part (SO(3) ≅ RP3), uniform sampling in SO(3) via uniform sampling on S3 (and then identifying antipodal points) gives the Haar measure — the unique rotation-invariant distribution on SO(3). To sample uniformly on S3, generate four independent Gaussians (a, b, c, d) ∼ N(0,1) and normalize: h = (a,b,c,d)/|(a,b,c,d)|. This is the standard method used in RRT and PRM implementations for SE(3) planning.
python import numpy as np def quat_multiply(h1: np.ndarray, h2: np.ndarray) -> np.ndarray: """Quaternion multiplication h1 * h2 (LaValle eq. 4.19). h = [a, b, c, d] where h = a + bi + cj + dk """ a1, b1, c1, d1 = h1 a2, b2, c2, d2 = h2 return np.array([ a1*a2 - b1*b2 - c1*c2 - d1*d2, # a component a1*b2 + b1*a2 + c1*d2 - d1*c2, # b (i) component a1*c2 - b1*d2 + c1*a2 + d1*b2, # c (j) component a1*d2 + b1*c2 - c1*b2 + d1*a2, # d (k) component ]) def axis_angle_to_quat(axis: np.ndarray, angle: float) -> np.ndarray: """Convert axis-angle to unit quaternion (LaValle eq. 4.21).""" axis = axis / np.linalg.norm(axis) # ensure unit axis return np.array([ np.cos(angle / 2), # a = cos(θ/2) axis[0] * np.sin(angle / 2), # b = v₁ sin(θ/2) axis[1] * np.sin(angle / 2), # c = v₂ sin(θ/2) axis[2] * np.sin(angle / 2), # d = v₃ sin(θ/2) ]) def quat_to_rotmat(h: np.ndarray) -> np.ndarray: """Quaternion to 3x3 rotation matrix (LaValle eq. 4.20).""" a, b, c, d = h return np.array([ [2*(a**2+b**2)-1, 2*(b*c-a*d), 2*(b*d+a*c) ], [2*(b*c+a*d), 2*(a**2+c**2)-1, 2*(c*d-a*b) ], [2*(b*d-a*c), 2*(c*d+a*b), 2*(a**2+d**2)-1], ]) # Example: 90° rotation around Z axis h_z90 = axis_angle_to_quat(np.array([0, 0, 1]), np.pi/2) # h_z90 ≈ [0.707, 0, 0, 0.707] = cos(45°) + sin(45°)k # Verify: h and -h give the same rotation matrix R1 = quat_to_rotmat(h_z90) R2 = quat_to_rotmat(-h_z90) # np.allclose(R1, R2) → True ← the double-cover in action # Compose two rotations: 90° around Z, then 90° around X h_x90 = axis_angle_to_quat(np.array([1, 0, 0]), np.pi/2) h_composed = quat_multiply(h_x90, h_z90) # Z first, then X R_composed = quat_to_rotmat(h_composed) def se3_matrix(t: np.ndarray, h: np.ndarray) -> np.ndarray: """SE(3) 4x4 homogeneous matrix from translation t and quaternion h.""" T = np.eye(4) T[:3, :3] = quat_to_rotmat(h) T[:3, 3] = t return T
Set an axis (vx, vy, vz) and angle θ. The quaternion components update live. Notice that θ = 0° and θ = 360° both give h = (1, 0, 0, 0), while θ = 180° gives h = (0, vx, vy, vz). Note that h and −h give the same rotation — the double cover.
| Representation | DOF stored | Constraints | Singularities? | Best use |
|---|---|---|---|---|
| Euler angles (3) | 3 floats | None | Yes — gimbal lock | Human input only |
| Rotation matrix (9) | 9 floats | AAT=I, det=1 (6 constraints) | No | Computation, composing transforms |
| Axis-angle (3) | 3 floats | None | Yes — at θ=0 | Small rotations, Lie algebra |
| Unit quaternion (4) | 4 floats | |h|=1 (1 constraint) | No | Planning, interpolation (SLERP) |
For motion planning, unit quaternions are almost always the right choice. They are singularity-free, overdetermined by only one constraint (easy to enforce by normalizing), support SLERP for geodesic interpolation, and have efficient multiplication formulas. Rotation matrices are excellent for applying transforms to points (matrix-vector multiply is fast). Euler angles should almost never appear inside a planner — only at the user interface for human-readable input/output.
Planners like RRT need a distance function d(q1, q2) to choose which node in the tree to extend toward. On SE(3), the natural choice combines translation distance with rotation distance:
where dSO(3)(R1, R2) = arccos((trace(R1TR2)−1)/2) is the geodesic distance on SO(3), and α, β are weighting constants that depend on the problem scale (meters vs radians). Setting α and β correctly matters: if α >> β, the planner ignores rotations; if β >> α, it ignores translations. There is no universal correct weighting — it depends on the task.
In quaternion form, the geodesic distance is simpler to compute: dSO(3)(h1, h2) = 2 arccos(|h1 · h2|), where the absolute value handles the double-cover (h and −h have zero distance to each other). The factor of 2 converts from S3 arc length to SO(3) arc length. This formula is the one to use in any planner operating in SO(3) or SE(3).
Sampling-based planners need to sample configurations uniformly at random from C-space. For Rn, sampling uniformly in a bounding box is trivial. For SO(3), “uniform” means the Haar measure: the unique probability measure on SO(3) invariant under left and right multiplication by any fixed rotation. Sampling Euler angles uniformly from [0, 2π) does NOT give uniform rotations — you over-sample near the poles (gimbal-lock-adjacent regions).
The correct method: generate (a, b, c, d) with each component independently drawn from N(0,1), then normalize h = (a,b,c,d)/|(a,b,c,d)|. The resulting unit quaternions are uniform on S3 by the spherical symmetry of the Gaussian distribution. Each rotation appears twice (h and −h), but this is harmless: just use h as sampled.
python import numpy as np def sample_so3_uniform() -> np.ndarray: """Sample uniformly random rotation as unit quaternion [a,b,c,d]. Haar measure on SO(3): invariant under rotations. Method: normalize 4 independent Gaussians. """ h = np.random.randn(4) # 4 independent N(0,1) h /= np.linalg.norm(h) # project onto S³ if h[0] < 0: h = -h # canonical: a >= 0 return h def so3_geodesic_dist(h1: np.ndarray, h2: np.ndarray) -> float: """Geodesic distance between two SO(3) elements (as unit quaternions).""" dot = abs(np.dot(h1, h2)) # |h1·h2|, handles double-cover dot = np.clip(dot, 0.0, 1.0) return 2.0 * np.arccos(dot) # angle in [0, pi] def se3_distance(t1, h1, t2, h2, alpha=1.0, beta=1.0) -> float: """Weighted SE(3) configuration distance.""" dt = np.linalg.norm(t1 - t2) dr = so3_geodesic_dist(h1, h2) return np.sqrt(alpha * dt**2 + beta * dr**2) # Example: two SE(3) configurations t1 = np.array([1.0, 0.0, 0.5]) h1 = axis_angle_to_quat(np.array([0, 0, 1]), 0) # no rotation t2 = np.array([2.0, 1.0, 0.5]) h2 = axis_angle_to_quat(np.array([0, 0, 1]), np.pi/2) # 90° around Z d = se3_distance(t1, h1, t2, h2, alpha=1.0, beta=1.0) # d ≈ sqrt(1² + 1² + 0² + (π/2)²) ≈ sqrt(2 + 2.47) ≈ 2.11 # (1m translation in x, 1m in y, 90° rotation)
Here is the complete reference for everything covered in Chapters 0–5 of this lesson. This table is the core “cheat sheet” for designing motion planners for different robot types:
| DOF type | C-space factor | Manifold | π1 | Compact? | Correct distance |
|---|---|---|---|---|---|
| Unbounded prismatic | R | 1D line | {e} | No | |x1−x2| |
| Limited prismatic [a,b] | [a, b] | 1D interval w/ boundary | {e} | Yes | |x1−x2| |
| Full revolute | S1 | Circle | Z | Yes | min(|Δθ|, 2π−|Δθ|) |
| 2D translation | R2 | Plane | {e} | No | √(Δx2+Δy2) |
| 2D rigid body (SE(2)) | R2×S1 | 3D manifold | Z | No | Weighted Euclidean+circle |
| 3D rotation (SO(3)) | RP3 | 3D manifold | Z2 | Yes | 2 arccos(|h1·h2|) |
| 3D rigid body (SE(3)) | R3×RP3 | 6D manifold | Z2 | No | Weighted Euclidean+SO(3) |
| k-link revolute arm | Tk = (S1)k | k-torus | Zk | Yes | Product of circle distances |
When implementing SE(3) planning, you will use a quaternion library. Key features to verify: (1) Unit normalization: the library should normalize after every multiplication to prevent numerical drift off S3. (2) Double-cover handling: distance and interpolation functions should compare h with both h2 and −h2, using the shorter arc. (3) SLERP: the library should implement spherical linear interpolation correctly, including the special case when the two quaternions are nearly identical (use linear interpolation when |h1·h2| > 0.9995 to avoid division by sin(Ω) ≈ 0). (4) Consistent convention: the library should document whether quaternions are stored as (w, x, y, z) or (x, y, z, w) — a common source of bugs when mixing libraries.
Common Python options: scipy.spatial.transform.Rotation (the standard, wraps quaternions, supports SLERP, handles all edge cases), pyquaternion (lightweight, explicit), numpy-quaternion (fast, C extension). ROS uses its own convention (x, y, z, w in geometry_msgs/Quaternion). PyBullet uses (x, y, z, w). Eigen uses (w, x, y, z) internally. Always check the convention before integrating a library.
A robot arm is not a single rigid body. It is a kinematic chain: a sequence of rigid links connected by joints. Each joint contributes one or more degrees of freedom, and those DOF multiply together to form the full C-space. Understanding how the pieces combine — topologically, not just numerically — is the difference between a planner that works and one that misses valid paths.
The rule is simple and powerful. If joint 1 has C-space factor C1 and joint 2 has factor C2, then the two-joint arm has C = C1 × C2. Each joint contributes its own topological piece independently. The C-space of the whole chain is the Cartesian product of the pieces.
Two revolute joints, each with full 360° rotation. Joint 1 contributes S1. Joint 2 contributes S1. Therefore:
T2 is the 2-torus: a donut shape. The C-space of this arm is not a flat square — it is a square with opposite edges identified, which is topologically a torus. Paths that cross the θ1=0/2π boundary or the θ2=0/2π boundary are perfectly valid; they wrap around the torus.
Given joint angles (θ1, θ2), the forward kinematics maps them to the end-effector position in the workspace. For a planar 2-link arm with link lengths L1 and L2:
The elbow joint position is (L1cosθ1, L1sinθ1). The hand tip is that plus the second link rotated by the total angle θ1+θ2. One point in C-space (a pair of angles) maps to one point in the workspace. But one point in workspace can have two preimages in C-space — elbow-up and elbow-down. This is why planning in C-space is more natural: there is no ambiguity.
The canvas below shows both views simultaneously. LEFT: the physical arm in 2D workspace with a circular obstacle. RIGHT: C-space as a square (toroidal identification shown by arrows on the edges). The orange dot is the current configuration (θ1, θ2). The dark region in the right panel is Cobs — precomputed on a 60×60 grid by checking collision at each (θ1,θ2). Click anywhere in the right (C-space) panel to jump to that configuration and watch the arm move. Notice how the dot can exit one edge and appear on the opposite edge — the torus in action.
Sliders control joint angles. Click the C-space panel (right) to teleport. Dark region = Cobs. Dot wraps at edges — opposite edges are the same point on the torus.
python import numpy as np def fk_2link(theta1: float, theta2: float, L1: float = 1.0, L2: float = 0.8) -> tuple: """Forward kinematics for a planar 2-link arm. Returns (elbow_x, elbow_y, hand_x, hand_y). """ ex = L1 * np.cos(theta1) ey = L1 * np.sin(theta1) hx = ex + L2 * np.cos(theta1 + theta2) hy = ey + L2 * np.sin(theta1 + theta2) return ex, ey, hx, hy def seg_dist(p, a, b) -> float: """Distance from point p to segment [a, b].""" ab = b - a t = np.clip(np.dot(p - a, ab) / (np.dot(ab, ab) + 1e-12), 0, 1) return np.linalg.norm(p - (a + t * ab)) def arm_collides(t1, t2, obs_c, obs_r, L1=1.0, L2=0.8) -> bool: ex, ey, hx, hy = fk_2link(t1, t2, L1, L2) orig = np.array([0.0, 0.0]) elbow = np.array([ex, ey]) hand = np.array([hx, hy]) obs = np.array(obs_c) return (seg_dist(obs, orig, elbow) < obs_r or seg_dist(obs, elbow, hand) < obs_r) # Precompute C_obs on a 60×60 grid over [0, 2π) × [0, 2π) N = 60 ts = np.linspace(0, 2*np.pi, N, endpoint=False) cobs = np.array([[arm_collides(t1, t2, [1.2, 0.4], 0.35) for t2 in ts] for t1 in ts]) # cobs.shape = (60, 60); True = collision at (ts[i], ts[j])
You now know what C-space is. The next question is: which configurations are forbidden? The answer is the C-space obstacle region, Cobs. This is the set of all configurations where the robot touches or overlaps at least one obstacle in the workspace.
Here A(q) is the set of workspace points occupied by the robot at configuration q. O is the union of all workspace obstacles. Cfree = C ∖ Cobs is the open set where motion is legal. The planning problem is: find a continuous path through Cfree.
For a kinematic chain with links A1, …, Am, collisions come from two sources: each link might hit a workspace obstacle, and non-adjacent links might hit each other. Let P be the set of collision pairs — pairs [i, j] of links that are not permanently attached and could potentially intersect:
Adjacent links share a joint and are always in contact at the joint point; they are NOT in P. Self-collision only becomes significant for 3+ link chains. For the 2-link arm, collision pairs are simply (link1, obstacle) and (link2, obstacle).
LaValle’s Formulation 4.1 is the canonical statement of the motion planning problem in C-space:
The “correctly report” clause is critical. A planner that always says “no path found” trivially satisfies the first condition but fails the second. This is the completeness requirement. The Piano Mover’s Problem is PSPACE-hard (Reif, 1979): as DOF grows, worst-case complexity explodes exponentially. No polynomial-time algorithm for the general case exists, which motivates sampling-based approximations.
Left: workspace with robot (orange square) and obstacle (gray rectangle). Right: C-space showing Cobs (dark). Dot turns red when in collision.
For a robot that only translates (no rotation), there is an elegant closed-form formula for Cobs. It uses the Minkowski sum. Master this in 1D first, then 2D.
Geometrically: place a copy of A at every point in B (or equivalently, place a copy of B at every point in A) and union all the translates. The result is the set of all points reachable by adding one element from each set.
Robot A = [−1, 2] (occupies 3 units; reference point at 0, extends left 1 unit and right 2 units). Obstacle O = [0, 4]. We need Cobs = O ⊕ (−A).
Step 1: negate the robot. −A = [−2, 1]. Step 2: Minkowski sum. O ⊕ (−A) = {o + r | o ∈ [0,4], r ∈ [−2,1]} = [0−2, 4+1] = [−2, 5].
For convex polygons in 2D, the Minkowski sum boundary is computed by merging the edge sequences of O and −A, sorted by the angle of their outward normals. If O has m edges and A has n edges, Cobs = O ⊕ (−A) has at most m+n edges and can be computed in O(m+n) time (after sorting).
Two types of contact contribute to the boundary: EV contact (an edge of A touches a vertex of O) produces an edge from A in the boundary. VE contact (a vertex of A touches an edge of O) produces an edge from O in the boundary. The merge procedure walks both polygons simultaneously, always advancing along the edge with the smaller outward normal angle.
Left: workspace with square robot (orange) and square obstacle (gray). Right: Cobs shown in orange. Grow the robot and watch Cobs expand. When the robot is a point, Cobs = the obstacle itself.
python import numpy as np def minkowski_convex(P: np.ndarray, Q: np.ndarray) -> np.ndarray: """Minkowski sum of two convex CCW polygons. Merge edge sequences sorted by outward-normal angle. O(n+m) once polygons are bottom-aligned. """ # Start at bottom-most vertex of each polygon i0 = int(np.argmin(P[:, 1])) j0 = int(np.argmin(Q[:, 1])) n, m = len(P), len(Q) result, i, j = [], i0, j0 for _ in range(n + m): result.append(P[i % n] + Q[j % m]) ep = P[(i+1)%n] - P[i%n] # current edge of P eq = Q[(j+1)%m] - Q[j%m] # current edge of Q cross = ep[0]*eq[1] - ep[1]*eq[0] if cross > 0: i += 1 # advance P elif cross < 0: j += 1 # advance Q else: i += 1; j += 1 # parallel edges return np.array(result) # Square robot half-side s, square obstacle half-side w: # C_obs is a square with half-side (s + w) # — Minkowski sum of two squares = larger square with summed side lengths s, w = 0.2, 0.5 # C_obs side length = 2*(s+w) = 2*(0.2+0.5) = 1.4
The Minkowski sum is elegant for translation-only robots. But real robots rotate, and real robots have many joints. The moment you add rotation, explicit Cobs computation becomes intractable. This is not an engineering inconvenience — it is a fundamental barrier, and it is WHY sampling-based methods exist.
For a robot that translates AND rotates in 2D, C = R2×S1. At each fixed angle θ, the 2D cross-section of Cobs is O ⊕ (−Aθ), where Aθ is the robot rotated by θ. Because −Aθ changes shape as θ varies, the Minkowski sum is different at every angle. Cobs is a 3D region in (x, y, θ)-space with a continuously morphing cross-section that cannot be computed efficiently for non-convex shapes.
For an n-DOF revolute arm, Cobs lives in Tn. For n=6, that is a subset of a 6-dimensional torus. You cannot visualize it, store it, or compute its boundary. Even a coarse grid with 10 values per dimension needs 106 cells. At 100 per dimension: 1012 cells, each requiring a collision check. Completely infeasible.
The canvas shows a triangular robot rotating in front of a rectangular obstacle. The left panel shows the robot at the current angle θ. The right panel shows the 2D cross-section of Cobs at that θ — the Minkowski sum O ⊕ (−Aθ) computed on a grid. Slide θ and watch the Cobs cross-section morph. This is why you cannot just store Cobs once — it is different for every θ.
Left: triangular robot at angle θ. Right: 2D Cobs cross-section at this θ (dark = forbidden). Drag θ to watch Cobs morph.
Every motion planning system ever built for a real robot follows the same pipeline. You define the robot’s geometry, identify the C-space topology, build a collision checker, hand everything to a planner, and get back a path. The C-space abstraction is the seam that separates geometry from search — and that separation is what makes the whole thing work at scale.
| Approach | Thinks in | Advantages | Disadvantages |
|---|---|---|---|
| Workspace planning | Robot geometry, link shapes, obstacle polygons | Intuitive, visually obvious | Algorithm must know robot shape; not generalizable |
| C-space planning | A point in a manifold | Any graph search works; robot-independent; unified theory | High-dimensional; Cobs is implicit and complex |
Each box is a computation module. Arrows show data flow. Note: the collision checker is the only module that knows about geometry. The planner and the path representations are robot-independent.
You’ve built the full C-space vocabulary across 12 chapters. Here is the complete reference — every manifold, every distance formula, every topology — in one place. Use this as your cheat sheet when designing motion planners for new robot types.
| Robot | C-space | Dim | Notes |
|---|---|---|---|
| Point in R2 | R2 | 2 | Trivial; Euclidean distance |
| Rigid body in R2 | SE(2) = R2×S1 | 3 | Translation + rotation; 3×3 matrix form |
| Rigid body in R3 | SE(3) = R3×RP3 | 6 | Quaternion parameterization avoids gimbal lock |
| n revolute joints (full range) | Tn = (S1)n | n | n-torus; wrap-around at each joint boundary |
| n revolute joints (limited) | [a1,b1]×…×[an,bn] | n | Each joint a closed interval; no wrap-around |
| n prismatic joints | Rn | n | Linear motion; standard Euclidean distance |
| Mixed arm (k full revolute + m limited) | Tk×[…]m | k+m | Product topology; each factor handled separately |
C-space obstacle:
Minkowski sum (translation only):
Geodesic on S1:
SE(2) matrix:
SO(3) geodesic (quaternions):
High dimensionality. A 7-DOF arm lives in a 7D manifold. Sampling-based planners work, but narrow passages in Cfree become exponentially harder to find as dimension grows. This is the active research frontier: narrow-passage sampling, bridge testing, workspace guidance.
Kinodynamic constraints. The C-space framework assumes the robot can stop anywhere and move in any direction. Real robots have velocity limits and momentum. Kinodynamic planning extends C-space to state space (q, q˙) and is covered in LaValle Chapter 14.
Uncertainty. Perfect configuration knowledge is assumed here. In practice, sensors are noisy and maps are imperfect. Belief-space planning (POMDPs) replaces the configuration with a probability distribution and is covered in Chapter 11.