How to Write a URDF Model for a Mobile Robot: Step-by-Step with Leo Rover as an Example
Adrian Krzemiński,

TL;DR: A URDF (Unified Robot Description Format) file tells ROS 2 what your robot looks like, how its parts connect, and where each frame sits. This URDF mobile robot tutorial walks you through building a working xacro model for the Leo Rover: defining links and joints, attaching meshes, adding wheels with a differential-like 4-wheel skid-steer layout, publishing the TF tree with robot_state_publisher, and verifying everything in RViz. You will end with a reproducible setup, a clean TF tree, and links to the official Fictionlab repository so you can fork and extend it.
Why a URDF Mobile Robot Tutorial Matters for ROS 2 Development
Without an accurate URDF, your ROS 2 stack cannot reason about geometry. Sensor data lands in the wrong frame, navigation plans hit phantom obstacles, and simulation breaks. For any mobile robot – whether you are prototyping a field platform, integrating a manipulator, or running SLAM – the URDF is the geometric source of truth shared by RViz, Gazebo, MoveIt, Nav2, and tf2.
The Leo Rover is a useful example because its mechanics are simple enough to fit in one article, but realistic enough to expose the patterns you will reuse on larger robots: a chassis link, four wheels with continuous joints, a sensor mount, and a couple of fixed transforms for cameras or IMUs. Fictionlab maintains the official description package on GitHub, so you can cross-check every snippet here against production code.
What URDF and Xacro Actually Are
URDF is an XML format describing a robot as a tree of links (rigid bodies) connected by joints (kinematic relationships). Each link can carry visual geometry, collision geometry, and inertial properties. Each joint defines a parent, a child, a type (fixed, revolute, continuous, prismatic), an origin, and an axis.
Plain URDF gets verbose fast. Xacro is a macro language that adds variables, math, includes, and reusable macros on top of URDF. In practice, you almost always write .urdf.xacro files and let the launch system expand them at runtime.
How to Set Up the Workspace
Before writing any XML, prepare a clean ROS 2 package. The instructions below assume ROS 2 Humble on Ubuntu 22.04 or ROS 2 Jazzy on Ubuntu 24.04.
- Create a workspace and a description package:
mkdir -p ~/leo_ws/src && cd ~/leo_ws/src
ros2 pkg create --build-type ament_cmake my_leo_description
- Inside the package, add three folders:
urdf/,meshes/, andlaunch/. - Install the runtime dependencies you will need:
sudo apt install ros-$ROS_DISTRO-xacro ros-$ROS_DISTRO-robot-state-publisher ros-$ROS_DISTRO-joint-state-publisher-gui ros-$ROS_DISTRO-rviz2 ros-$ROS_DISTRO-tf2-tools liburdfdom-tools
If you use the mesh filenames shown below, copy matching mesh files into meshes/ from your CAD export or from the official description package. Also install your package data by adding this to CMakeLists.txt before ament_package():
install(DIRECTORY urdf meshes launch DESTINATION share/${PROJECT_NAME})
How to Define the Base Link and Chassis
Every mobile robot URDF starts with a base_link. Convention places it near the robot’s body reference point, with X forward, Y left, Z up (REP-103). For the Leo Rover, the whole rover is roughly 0.45 x 0.43 x 0.25 m and weighs about 6.5 kg according to Fictionlab’s published specs; the simplified values below are for a tutorial model.
Create urdf/leo.urdf.xacro:
<?xml version="1.0"?>
<robot name="leo" xmlns:xacro="http://www.ros.org/wiki/xacro">
<xacro:property name="wheel_radius" value="0.0625"/>
<xacro:property name="wheel_separation_x" value="0.30"/>
<xacro:property name="wheel_separation_y" value="0.36"/>
<link name="base_footprint"/>
<link name="base_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry><mesh filename="package://my_leo_description/meshes/Chassis.dae"/></geometry>
</visual>
<collision>
<geometry><box size="0.425 0.385 0.18"/></geometry>
</collision>
<inertial>
<mass value="4.5"/>
<inertia ixx="0.08" ixy="0" ixz="0" iyy="0.10" iyz="0" izz="0.13"/>
</inertial>
</link>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/><child link="base_link"/>
<origin xyz="0 0 ${wheel_radius}" rpy="0 0 0"/>
</joint>
The base_footprint link is a flat 2D projection used by Nav2. base_link is lifted by the wheel radius so wheels touch z=0.
How to Add Wheels with a Xacro Macro
The Leo Rover has four independently driven wheels. Instead of copy-pasting four identical blocks, define one xacro macro and instantiate it four times.
<xacro:macro name="wheel" params="prefix x y">
<link name="${prefix}_wheel_link">
<visual><geometry><mesh filename="package://my_leo_description/meshes/Wheel.dae"/></geometry></visual>
<collision><origin rpy="1.5708 0 0"/><geometry><cylinder radius="${wheel_radius}" length="0.07"/></geometry></collision>
<inertial><mass value="0.4"/><inertia ixx="0.0008" ixy="0" ixz="0" iyy="0.0008" iyz="0" izz="0.0012"/></inertial>
</link>
<joint name="${prefix}_wheel_joint" type="continuous">
<parent link="base_link"/><child link="${prefix}_wheel_link"/>
<origin xyz="${x} ${y} 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
</xacro:macro>
<xacro:wheel prefix="fl" x="${wheel_separation_x/2}" y="${wheel_separation_y/2}"/>
<xacro:wheel prefix="fr" x="${wheel_separation_x/2}" y="-${wheel_separation_y/2}"/>
<xacro:wheel prefix="rl" x="-${wheel_separation_x/2}" y="${wheel_separation_y/2}"/>
<xacro:wheel prefix="rr" x="-${wheel_separation_x/2}" y="-${wheel_separation_y/2}"/>
The wheel joints are continuous because they spin without limits. The axis is Y, matching REP-103 with X forward.
How to Mount Sensors with Fixed Joints
Most sensors attach with a fixed joint at a known offset. The Leo Rover ships with a forward-facing camera. Add it before the closing </robot> tag like this:
<link name="camera_link"/>
<joint name="camera_mount" type="fixed">
<parent link="base_link"/><child link="camera_link"/>
<origin xyz="0.12 0 0.15" rpy="0 0.2 0"/>
</joint>
<link name="camera_optical_frame"/>
<joint name="camera_optical" type="fixed">
<parent link="camera_link"/><child link="camera_optical_frame"/>
<origin xyz="0 0 0" rpy="-1.5708 0 -1.5708"/>
</joint>
</robot>
The extra optical frame follows REP-103: camera drivers publish images in a frame with Z forward, X right, Y down. Mixing these conventions is the most common URDF bug in vision pipelines.
How to Launch and Verify in RViz
Create launch/display.launch.py:
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.substitutions import Command
import os
from ament_index_python.packages import get_package_share_directory
def generate_launch_description():
pkg = get_package_share_directory('my_leo_description')
urdf = os.path.join(pkg, 'urdf', 'leo.urdf.xacro')
robot_desc = Command(['xacro ', urdf])
return LaunchDescription([
Node(package='robot_state_publisher', executable='robot_state_publisher',
parameters=[{'robot_description': robot_desc}]),
Node(package='joint_state_publisher_gui', executable='joint_state_publisher_gui'),
Node(package='rviz2', executable='rviz2'),
])
Build and launch:
cd ~/leo_ws && colcon build && source install/setup.bash
ros2 launch my_leo_description display.launch.py
In RViz, set the Fixed Frame to base_footprint, add a RobotModel display pointing to /robot_description, and add a TF display. You should see the chassis, four wheels, and the camera frame. Use the joint_state_publisher_gui sliders to spin the wheels and confirm joint axes are correct.
How to Inspect the TF Tree
A correct TF tree is the real test. Generate a diagram with:
ros2 run tf2_tools view_frames
You should see base_footprint -> base_link, then base_link branching into four wheel links and the camera chain. There must be exactly one root in the robot model and no disconnected frames. If you see two trees, you have a typo in a parent/child name.
For runtime debugging, use:
ros2 run tf2_ros tf2_echo base_link fl_wheel_linkto verify a specific transform.ros2 topic echo /tf_staticto confirm fixed joints are published once.xacro urdf/leo.urdf.xacro > /tmp/leo.urdf && check_urdf /tmp/leo.urdfto validate XML and joint consistency.
How to Extend the Model for Simulation and Real Hardware
The minimal URDF above renders in RViz and is enough for tf-based pipelines. For Gazebo or ros2_control, you add <gazebo> blocks for plugins or sensors and a <ros2_control> section declaring hardware interfaces for each wheel joint. Fictionlab’s official leo_description package shows the full pattern, including realistic inertia tensors and the rocker-style suspension geometry used on field deployments such as those described in research applications and space analog missions.
You can browse and fork the production model at github.com/LeoRover/leo_common-ros2. Compare your file against leo_description/urdf/ to see how Fictionlab’s team handles meshes, ros2_control tags, and Gazebo integration.
Frequently Asked Questions
What is the difference between URDF and SDF?
URDF is the ROS-native format and works across the ROS 2 ecosystem. SDF is Gazebo’s native format and supports richer simulation features such as worlds, lights, and more detailed physics configuration. For ROS 2 robots, write URDF/xacro and let Gazebo convert it; only switch to SDF if you need features URDF cannot express cleanly.
Why use xacro instead of plain URDF?
Xacro adds macros, properties, and includes. On a four-wheel robot, one xacro macro replaces four duplicated link/joint blocks, so changing wheel radius means editing one number instead of four.
Do I need accurate inertia values?
For RViz visualization, no. For Gazebo simulation or any physics-based controller, yes. Inaccurate inertia causes unstable simulation and wrong dynamic behavior. Compute tensors from CAD or approximate with primitive shapes using standard formulas.
What frame should be the root of my TF tree?
For mobile robots, base_footprint (or base_link if you do not use footprint) is usually the root of the robot’s own tree. At runtime, localization nodes like AMCL or slam_toolbox typically publish map -> odom, while odometry publishes odom -> base_footprint or odom -> base_link.
Why does RViz show “No transform from [x] to [base_link]”?
Either robot_state_publisher is not running, the /joint_states topic is missing (for non-fixed joints), or a link name in a joint does not match the link definition. Run ros2 run tf2_tools view_frames to find the gap.
Can I auto-generate URDF from CAD?
Yes. SolidWorks has the ROS-Industrial URDF exporter; Fusion 360 and Onshape have community plugins. They produce working URDF but you should clean up joint names, axis directions, and inertia values before committing.
How large can a URDF realistically get?
Production robots routinely have 50-200 links. Xacro keeps this manageable. The bottleneck is usually mesh complexity in RViz, not URDF parsing; decimate visual meshes and use primitive shapes for collision geometry.
Next step: clone the official leo_common-ros2 repository, run the launch files on your machine, and adapt the description package to your own sensor payload. The repository includes the full URDF, ros2_control configuration, and Gazebo integration you can use as a reference for your next mobile robot project, while the Leo Rover documentation is a useful reference for the hardware platform.