SE(2) Motion Planning
This tutorial walks through motion planning in \(\mathrm{SE}(2)\) for three increasingly constrained robot types: a holonomic circular robot, a differential-drive robot with a rectangular footprint, and a car-like vehicle that parks between obstacles. Along the way we will also go through the geodex collision checking module, polygon footprint validation, SDF-based clearance metrics, and the full OMPL planning and smoothing pipeline. By the end you will know how to set up an \(\mathrm{SE}(2)\) planner on an occupancy grid or among rectangle obstacles, and how to tune the clearance metric so planned paths maintain safe distance from walls.
Note
This tutorial assumes you have installed geodex with OMPL support (see OMPL installation guide) and are familiar with the basic geodex operations covered in geodex Basics. All the code will be in C++ only since the OMPL adapter layer does not have Python bindings support yet.
Poses and Footprints
A pose in \(\mathrm{SE}(2) = \mathbb{R}^2 \rtimes \mathrm{SO}(2)\) is a triple
\((x, y, \theta)\) that encodes a planar position and orientation. In geodex the
geodex::SE2 class represents this Lie group, with poses stored as Eigen::Vector3d:
#include <geodex/geodex.hpp>
#include <geodex/collision/collision.hpp>
geodex::SE2<> se2;
Eigen::Vector3d pose{5.0, 3.0, M_PI / 4.0}; // (x, y, theta)
A real robot is not a point. Its physical shape, the footprint, is a set of body-frame points that move rigidly with the pose. Footprint shape is purely geometric and independent of the kinematic model: a differential-drive or car-like robot can be approximated by a disc, and a holonomic robot can just as easily carry a rectangular or polygonal outline. A disc reduces collision to a single point query against an inflated distance field, while rectangles and convex polygons capture elongated or asymmetric shapes at the cost of a heading-dependent test. The figure below shows the three robots at a pose with body-frame axes \(x\) (red, forward) and \(y\) (green, left).
In code, a circular footprint is captured by its radius alone; the collision
checking module uses this radius to inflate the obstacle distance field.
Non-circular shapes use geodex::collision::PolygonFootprint, which stores body-frame
perimeter samples of a convex polygon.
Both the rectangle factory and the general constructor take a samples_per_edge
argument that sets how many uniformly-spaced points are placed along each edge:
// Circular footprint: a single radius. Collision reduces to a point query
// against an obstacle distance field inflated by this value.
constexpr double robot_radius = 0.3;
// Rectangular footprint via the factory.
auto rect_fp = geodex::collision::PolygonFootprint::rectangle(
/*half_length=*/0.35, /*half_width=*/0.25, /*samples_per_edge=*/6);
// Arbitrary convex polygon from ordered body-frame vertices (counter-clockwise).
std::vector<Eigen::Vector2d> verts = {
{-0.35, -0.30}, // rear-right
{ 0.35, -0.20}, // front-right
{ 0.35, 0.20}, // front-left
{-0.35, 0.30}, // rear-left
};
geodex::collision::PolygonFootprint poly_fp(verts, /*samples_per_edge=*/4);
More samples give tighter collision detection at a small runtime cost; four to six per edge works well for most robot footprints at centimeter-scale grid resolutions.
Left-Invariant Metrics on SE(2)
The choice of Riemannian metric determines what “shortest path” means.
geodex::SE2LeftInvariantMetric defines a diagonal inner product on
\(\mathrm{SE}(2)\):
where \(w_x, w_y, w_\theta > 0\) are positive weights. By adjusting these weights, we can create different motion preferences and kinematic models:
\(w_x\) |
\(w_y\) |
\(w_\theta\) |
Behavior |
Kinematic model |
|---|---|---|---|---|
1.0 |
1.0 |
0.5 |
Isotropic translation, cheap rotation |
Holonomic |
1.0 |
10.0 |
1.0 |
Penalizes lateral sliding |
Differential drive |
1.0 |
20.0 |
2.25 |
With effective turning radius 1.5 m |
Car-like |
// Holonomic: isotropic translation.
geodex::SE2LeftInvariantMetric metric_holo{1.0, 1.0, 0.5};
// Differential drive: moderate lateral penalty.
geodex::SE2LeftInvariantMetric metric_diff{1.0, 10.0, 1.0};
// Car-like: effective turning radius of 1.5 m, lateral penalty 20.
auto metric_car = geodex::SE2LeftInvariantMetric::car_like(
/*turning_radius=*/1.5, /*lateral_penalty=*/20.0);
The convenience factory car_like(turning_radius, lateral_penalty) sets
\(w_\theta = \text{turning\_radius}^2\) and \(w_y = \text{lateral\_penalty}\),
so that the geodesic turning radius is approximately
\(r_\text{eff} \approx \sqrt{w_\theta / w_x}\) and a large lateral_penalty
strongly discourages sideslip. This is a soft constraint: the metric penalizes
tight turns but does not forbid them.
Preparing the Environment
Occupancy Maps and Distance Grids
Many indoor environments are described by an occupancy grid: a raster image where dark
pixels represent walls and light pixels represent free space. Before we can plan, we
convert this image into a distance grid, a precomputed Euclidean distance transform
that stores the signed distance to the nearest obstacle at every cell. The C++ class
geodex::collision::DistanceGrid loads and queries this grid with bilinear
interpolation.
The Willow Garage map (1222 x 966 pixels at 0.05 m/pixel) is large enough that a small robot becomes hard to see. For this tutorial we crop it into a smaller section of approximately 18 x 12 m, which provides narrow hallways and corners that make collision checking and clearance metrics visually interesting:
A smaller section cropped from the Willow Garage map. Left: the occupancy grid. Right: the Euclidean distance transform over the free space, with walls shown in black; lighter colors indicate higher clearance.
In C++, loading the distance grid is a single call:
geodex::collision::DistanceGrid grid;
grid.load("willow_corridor_dist.txt");
// World dimensions in meters.
double world_w = grid.width() * grid.resolution();
double world_h = grid.height() * grid.resolution();
Synthetic Parking Lot
For the car-like parking scenario we define obstacles directly in code as oriented
rectangles (geodex::collision::RectObstacle). Four parked cars line the
curb with a gap in the middle for the ego vehicle:
using RectObstacle = geodex::collision::RectObstacle;
constexpr double car_hl = 2.25, car_hw = 0.9; // 4.5 x 1.8 m
std::vector<RectObstacle> obstacles = {
{5.0, 1.35, 0.0, car_hl, car_hw}, // parked car 1
{10.0, 1.35, 0.0, car_hl, car_hw}, // parked car 2
{21.0, 1.35, 0.0, car_hl, car_hw}, // parked car 3
{26.0, 1.35, 0.0, car_hl, car_hw}, // parked car 4
{15.0, -0.05, 0.0, 15.0, 0.05}, // curb
{15.0, 10.05, 0.0, 15.0, 0.05}, // sidewalk
};
The parallel parking scenario. Parked vehicles (salmon) create a gap between cars 2 and 3. The ego vehicle approaches from the right (green) and must park in the gap (orange star).
Holonomic Circular Robot
The simplest case is a holonomic robot with a circular footprint of radius \(r\).
It can translate in any direction and rotate freely, so we use the isotropic metric
SE2LeftInvariantMetric{1.0, 1.0, 1.0}.
Collision Checking for a Disc
For a circular robot, collision checking reduces to a point query: the robot is
collision-free if and only if the signed distance at its center exceeds the robot
radius plus a small safety buffer. A bilinear-interpolated
DistanceGrid::distance_at query is enough:
constexpr double robot_radius = 0.3;
constexpr double safety_margin = 0.10; // extra buffer beyond the robot radius
auto is_valid = [&grid, robot_radius](const Eigen::Vector3d& q) {
return grid.distance_at(q[0], q[1]) > robot_radius + safety_margin;
};
For the clearance metric later in this tutorial we will also want a continuous
signed distance field that already accounts for the robot radius. That is what
geodex::collision::GridSDF and geodex::collision::InflatedSDF
provide: GridSDF wraps the distance grid as a callable, and InflatedSDF
subtracts the robot radius so its output is positive exactly when the disc fits.
Inflating the obstacle by the robot radius \(r\). The original boundary (solid) becomes the inflated boundary (dashed). A circular robot whose center lies outside the dashed line is guaranteed to be collision-free.
Setting Up the Planner
The OMPL pipeline begins with wrapping our geodex manifold as an OMPL state space.
geodex::integration::ompl::GeodexStateSpace handles this automatically:
it delegates distance, interpolation, and sampling to the underlying manifold. The
validity checker and optimization objective complete the setup:
namespace ob = ompl::base;
namespace og = ompl::geometric;
// 1. Create manifold and state space.
geodex::SE2LeftInvariantMetric metric{1.0, 1.0, 1.0};
geodex::SE2<> manifold{metric, geodex::SE2ExponentialMap{},
Eigen::Vector3d(0, 0, -M_PI),
Eigen::Vector3d(world_w, world_h, M_PI)};
ob::RealVectorBounds bounds(3);
bounds.setLow(0, 0.0); bounds.setHigh(0, world_w);
bounds.setLow(1, 0.0); bounds.setHigh(1, world_h);
bounds.setLow(2, -M_PI); bounds.setHigh(2, M_PI);
auto space = std::make_shared<GeodexStateSpace<decltype(manifold)>>(manifold, bounds);
space->setCollisionResolution(grid.resolution());
// 2. SimpleSetup with validity checker.
og::SimpleSetup ss(space);
ss.setStateValidityChecker(
make_validity_checker<decltype(manifold)>(
ss.getSpaceInformation(), is_valid));
// 3. Start and goal.
using SS = geodex::integration::ompl::GeodexStateSpace<decltype(manifold)>;
ob::ScopedState<SS> start(space), goal(space);
start->values[0] = 2.0; start->values[1] = 5.0; start->values[2] = 0.0;
goal->values[0] = 12.0; goal->values[1] = 6.0; goal->values[2] = -M_PI/2;
ss.setStartAndGoalStates(start, goal, 0.5);
// 4. Optimization objective.
Eigen::Vector3d goal_coords{12.0, 6.0, -M_PI/2};
auto objective = std::make_shared<
GeodexOptimizationObjective<decltype(manifold)>>(
ss.getSpaceInformation(), goal_coords);
ss.setOptimizationObjective(objective);
// 5. Plan with Informed RRT*.
ss.setPlanner(std::make_shared<og::InformedRRTstar>(ss.getSpaceInformation()));
ss.solve(0.5);
Path Smoothing
The raw RRT* solution is piecewise-geodesic with unnecessary detours. The
smooth_path function refines it in two phases: random shortcutting removes
redundant waypoints, then L-BFGS energy minimization pulls the remaining path
toward the metric geodesic while respecting collision constraints:
// Extract waypoints from the OMPL solution path.
auto& solution = ss.getSolutionPath();
std::vector<Eigen::Vector3d> waypoints;
for (const auto* state : solution.getStates()) {
const auto* s = state->as<SS::StateType>();
waypoints.push_back({s->values[0], s->values[1], s->values[2]});
}
auto result = geodex::algorithm::smooth_path(manifold, is_valid, waypoints);
Planning result under the isotropic metric. The RRT* tree (gray), raw path (dashed red), and smoothed path (solid blue) are shown with circular footprints drawn at intervals along the smoothed trajectory.
The circular robot following the smoothed path through the corridor.
Reproducing the figures:
cmake -B build -DBUILD_OMPL_EXAMPLES=ON -Dompl_DIR=/path/to/ompl
cmake --build build --target se2_tutorial
DIST=examples/ompl/willow_corridor_dist.txt
./build/examples/ompl/se2_tutorial $DIST \
--scenario=holonomic -o se2_tutorial_holonomic.json --time=0.5 --seed=1
python scripts/visualize_se2_tutorial.py \
se2_tutorial_holonomic.json \
-o docs/tutorials/figs/se2-planning/holonomic_result.svg \
--map examples/ompl/willow_corridor.png
python scripts/animate_se2_tutorial.py \
se2_tutorial_holonomic.json \
-o docs/tutorials/figs/se2-planning/holonomic_sweep.gif \
--map examples/ompl/willow_corridor.png
Differential-Drive Robot
A differential-drive robot has two independently driven wheels. It can move forward, backward, and rotate in place, but it cannot slide sideways without rotating first. This kinematic constraint is captured by an anisotropic metric that penalizes lateral motion, independently of footprint shape. We also switch to a rectangular footprint for this scenario to illustrate orientation-dependent collision checking, though the same metric applies just as well to a circular chassis.
Polygon Footprint Collision Checking
For a non-circular robot the inflated SDF trick no longer works: whether the robot
collides or not now depends on both its position and its heading. The geodex collision module
provides geodex::collision::PolygonFootprint and
geodex::collision::FootprintGridChecker for this purpose.
PolygonFootprint precomputes a set of body-frame perimeter samples around the
robot outline. At query time, FootprintGridChecker transforms all samples to world
coordinates (using a single sincos call shared across all points), batch-queries the
distance grid with SIMD-accelerated bilinear interpolation, and returns the minimum
signed distance across all samples. If this minimum is positive, the entire footprint is
collision-free:
auto footprint = geodex::collision::PolygonFootprint::rectangle(
/*half_length=*/0.35, /*half_width=*/0.25, /*samples_per_edge=*/6);
geodex::collision::FootprintGridChecker checker{&grid, footprint, /*safety_margin=*/0.10};
auto is_valid = [&checker](const auto& q) {
return checker.is_valid(q);
};
Left: body-frame perimeter samples around the rectangular footprint. Right: the samples transformed to world frame and checked against the distance grid. Orange dotted lines show the distance query from each sample to the nearest wall.
Anisotropic Metric for Differential Drive
A differential-drive robot prefers forward and backward motion over lateral sliding. We express this by setting \(w_y > w_x\) in the left-invariant metric, which makes lateral motion “expensive” in the Riemannian sense. The planner then naturally produces paths with forward-facing arcs and in-place rotations at turns rather than diagonal sliding motions:
geodex::SE2LeftInvariantMetric metric{1.0, 10.0, 1.0};
geodex::SE2<> manifold{metric, geodex::SE2ExponentialMap{},
Eigen::Vector3d(0, 0, -M_PI),
Eigen::Vector3d(world_w, world_h, M_PI)};
The planner setup is identical to the holonomic case, substituting this manifold and the footprint-based validity checker for the inflated SDF one.
A differential-drive robot navigating the corridor. The anisotropic metric produces paths with clear forward-facing motion and rotation at waypoints.
A differential-drive robot under (anisotropic) left-invariant Riemannian metric.
Reproducing the figures:
./build/examples/ompl/se2_tutorial $DIST \
--scenario=diff_drive -o se2_tutorial_diff_drive.json --time=0.5 --seed=1
python scripts/visualize_se2_tutorial.py \
se2_tutorial_diff_drive.json \
-o docs/tutorials/figs/se2-planning/diff_drive_result.svg \
--map examples/ompl/willow_corridor.png
python scripts/animate_se2_tutorial.py \
se2_tutorial_diff_drive.json \
-o docs/tutorials/figs/se2-planning/diff_drive_sweep.gif \
--map examples/ompl/willow_corridor.png
Clearance-Aware Planning
The paths produced so far are geometrically shortest under the chosen metric, but they may brush close to walls. A real robot needs clearance: it should prefer routes through the middle of a corridor even if they are slightly longer. The geodex conformal clearance metric achieves this by warping the Riemannian geometry near obstacles.
The Conformal Clearance Metric
geodex::SDFConformalMetric wraps any base metric and conformally scales it
by a factor that grows near obstacles:
where \(\mathrm{sdf}(q)\) is the signed distance from the robot to the nearest obstacle (positive in free space), \(\kappa\) controls the strength of the repulsion, and \(\beta\) controls the falloff rate. The inner product becomes
so that paths near obstacles become “longer” in the metric sense. The planner, which minimizes path cost, naturally routes through regions where \(c(q) \approx 1\) (high clearance).
The conformal factor \(c(q)\) overlaid on the corridor map with \(\kappa = 1.5\) and \(\beta = 1.5\). Warm colors near walls indicate high metric cost; cool regions in the corridor center have \(c(q) \approx 1\).
To apply the clearance metric we create a
geodex::ConfigurationSpace that overlays the clearance-weighted metric on
the base \(\mathrm{SE}(2)\) topology. The base manifold provides the exponential
and logarithmic maps, while the custom metric provides all geometry (inner product,
norm, distance):
// Base metric and SDF (inflated by robot radius for a circular robot).
geodex::SE2LeftInvariantMetric base_metric{1.0, 1.0, 1.0};
geodex::collision::InflatedSDF inflated_sdf{
geodex::collision::GridSDF{&grid}, 0.3};
// Conformal clearance metric: kappa=1.5, beta=1.5.
geodex::SDFConformalMetric clearance_metric{
base_metric, inflated_sdf, 1.5, 1.5};
// Compose: SE(2) topology + clearance geometry.
geodex::SE2<> se2{base_metric, geodex::SE2ExponentialMap{},
Eigen::Vector3d(0, 0, -M_PI),
Eigen::Vector3d(world_w, world_h, M_PI)};
geodex::ConfigurationSpace cspace{se2, clearance_metric};
The cspace object satisfies the RiemannianManifold concept and can be passed
to GeodexStateSpace, smooth_path, and any other geodex algorithm.
With vs Without Clearance
Holonomic robot in the corridor. Left: baseline isotropic metric (the path cuts close to walls). Right: clearance metric with \(\kappa=1.5, \beta=1.5\) (the path maintains safe distance from walls throughout).
Clearance for the Differential-Drive Robot
An appealing property of FootprintGridChecker is that its operator() returns a
continuous signed distance (the minimum grid distance across all perimeter samples minus
the safety margin), not just a binary collision result. This makes it directly usable as
the SDF input to SDFConformalMetric. The same object serves double duty: binary
validity checking for the planner, and continuous clearance field for the metric:
auto footprint = geodex::collision::PolygonFootprint::rectangle(0.35, 0.25, 6);
geodex::collision::FootprintGridChecker checker{&grid, footprint, 0.05};
// Binary validity for the planner.
auto is_valid = [&checker](const auto& q) { return checker.is_valid(q); };
// Continuous SDF for the clearance metric (same object).
geodex::SE2LeftInvariantMetric base_metric{1.0, 10.0, 1.0};
geodex::SDFConformalMetric clearance_metric{base_metric, checker, 1.5, 1.5};
geodex::SE2<> se2{base_metric, geodex::SE2ExponentialMap{},
Eigen::Vector3d(0, 0, -M_PI),
Eigen::Vector3d(world_w, world_h, M_PI)};
geodex::ConfigurationSpace cspace{se2, clearance_metric};
A Differential-drive robot under the clearance metric. The combined effect of anisotropic weights (preferring forward motion) and conformal scaling (avoiding walls) produces paths that track the corridor center with smooth forward-facing arcs.
A Differential-drive robot following the smoothed path with the clearance metric active.
Reproducing the figures:
./build/examples/ompl/se2_tutorial $DIST \
--scenario=holo_clearance -o se2_tutorial_holo_clearance.json --time=0.5 --seed=1
./build/examples/ompl/se2_tutorial $DIST \
--scenario=diff_clearance -o se2_tutorial_diff_clearance.json --time=0.5 --seed=1
FIGS=docs/tutorials/figs/se2-planning
MAP=examples/ompl/willow_corridor.png
python scripts/visualize_se2_tutorial.py \
se2_tutorial_holo_clearance.json \
-o $FIGS/conformal_factor.svg --map $MAP --mode conformal
python scripts/visualize_se2_tutorial.py \
se2_tutorial_holonomic.json se2_tutorial_holo_clearance.json \
-o $FIGS/clearance_comparison.svg --map $MAP --mode comparison
python scripts/visualize_se2_tutorial.py \
se2_tutorial_diff_clearance.json \
-o $FIGS/diff_clearance_result.svg --map $MAP
python scripts/animate_se2_tutorial.py \
se2_tutorial_diff_clearance.json \
-o $FIGS/diff_clearance_sweep.gif --map $MAP
Car-Like Robot in a Parking Lot
The most constrained robot type is a car-like vehicle with an effective minimum turning radius and no lateral motion. We use the synthetic parking lot defined earlier and plan a parallel parking maneuver.
The Car-Like Metric
The car_like(r, lp) factory creates metric weights that produce geodesics with an
effective turning radius of approximately \(r\). A moderate lateral penalty
(\(w_y = 20\)) strongly discourages sideslip while keeping the metric landscape
tractable for an RRT* tree, so the planner produces smooth arcs rather than
diagonal motions:
auto metric = geodex::SE2LeftInvariantMetric::car_like(1.5, 20.0);
// Weights: wx=1.0, wy=20.0, w_theta=2.25 (= 1.5^2)
Note that this is a soft constraint. Near the start and goal, the optimizer may produce short segments of in-place rotation where a real Ackermann vehicle would need a multi-point turn.
Rectangle Obstacles and RectSmoothSDF
The parking scenario uses geodex::collision::RectSmoothSDF to compute a
smooth signed distance field over the oriented rectangle obstacles. Like
CircleSmoothSDF, it uses log-sum-exp to produce a differentiable approximation to
the min-distance, but with SIMD-accelerated rectangle SDF evaluation internally:
geodex::collision::RectSmoothSDF sdf{obstacles, 20.0, car_hw};
// beta=20 (smoothness), inflation=car_hw (half-width of ego vehicle)
The inflation parameter performs Minkowski expansion: it subtracts the ego vehicle’s
half-width from all obstacle distances inside the SIMD loop, effectively growing
obstacles by the robot’s lateral extent. This gives the clearance metric an
inflation-aware SDF without requiring a separate wrapper.
For the binary validity check we use the exact separating-axis-theorem (SAT) test
geodex::collision::rects_overlap(), which checks whether the ego vehicle’s
oriented bounding box overlaps any obstacle:
auto is_valid = [&](const auto& q) {
RectObstacle ego{q[0], q[1], q[2], car_hl, car_hw};
for (const auto& obs : obstacles) {
if (geodex::collision::rects_overlap(ego, obs)) return false;
}
return true;
};
Full Parking Pipeline
Putting it all together: the car-like metric is wrapped with SDFConformalMetric for
clearance-aware planning, and the composite
ConfigurationSpace is passed to the OMPL planner. Higher \(\kappa\) values
(we use 8.0 here) keep the vehicle further from parked cars during the approach:
auto base_metric = geodex::SE2LeftInvariantMetric::car_like(1.5, 20.0);
geodex::collision::RectSmoothSDF sdf{obstacles, 20.0, car_hw};
geodex::SDFConformalMetric clearance_metric{base_metric, sdf, 8.0, 3.0};
geodex::SE2<geodex::SE2LeftInvariantMetric, geodex::SE2ExponentialMap> se2{
geodex::SE2LeftInvariantMetric::car_like(1.5, 20.0),
geodex::SE2ExponentialMap{},
Eigen::Vector3d(0, 0, -M_PI),
Eigen::Vector3d(30.0, 12.0, M_PI)};
geodex::ConfigurationSpace cspace{se2, clearance_metric};
// Smoothing with tighter interpolation for car-like metric.
geodex::algorithm::PathSmoothingSettings car_smooth;
car_smooth.interp.step_size = 0.02;
car_smooth.interp.max_steps = 2000;
car_smooth.collision_resolution = 0.1;
Parallel parking with the car-like metric. The vehicle approaches from the right and executes a reverse maneuver to park between vehicles. Rectangular footprints at intervals show the pose progression.
Animated footprint sweep along the parking path.
Reproducing the figures:
./build/examples/ompl/se2_tutorial dummy \
--scenario=parking -o se2_tutorial_parking.json --time=0.5 --seed=1
python scripts/visualize_se2_tutorial.py \
se2_tutorial_parking.json \
-o docs/tutorials/figs/se2-planning/parking_result.svg
python scripts/visualize_se2_tutorial.py \
se2_tutorial_parking.json \
-o docs/tutorials/figs/se2-planning/parking_lot.svg --mode env
python scripts/animate_se2_tutorial.py \
se2_tutorial_parking.json \
-o docs/tutorials/figs/se2-planning/parking_footprints.gif --fps 15
Summary
This tutorial covered the full SE(2) planning pipeline in geodex with three robot types, progressively adding complexity at each stage:
Metric |
Robot |
Collision |
Clearance SDF |
Env |
|---|---|---|---|---|
Isotropic (1, 1, 1) |
Holonomic (circle) |
|
|
Willow corridor |
Anisotropic (1, 10, 1) |
Diff-drive (rect) |
|
|
Willow corridor |
|
Car-like (rect) |
SAT |
|
Parking lot |
See also
geodex Basics for an introduction to manifolds, metrics, and geodesics
Minimum-Energy Planning on Configuration Manifolds for planning with configuration-dependent metrics
The API Reference for full documentation of the collision module and OMPL adapters