Until now, ARCHO existed only in our minds and in a few simple nodes. From this chapter onward, we give it a physical body for the first time. But before opening any editor, let's sidestep a common beginner mistake: rushing to write code. A professional robotics engineer, before writing a single line of code, asks a simple question: "What parts do I have, and how do these parts connect to each other?"
ARCHO is a Differential Drive robot: two main wheels, each independently controlled, and one caster wheel for balance. If the speeds of the two wheels are equal, the robot goes straight; if one is faster, it turns; and if one goes forward while the other goes backward, the robot rotates almost about its own axis โ exactly what's needed to maneuver through the narrow aisles of a warehouse.
The complete ARCHO project is made up of eight layers, each built on top of the previous one:
Each layer must be tested on its own before being connected to the next layer. If one day ARCHO doesn't move in Nav2, the problem could be a wrong URDF, a wrong wheel direction, an incomplete TF, incorrect odometry, a wrong LiDAR, or a poorly tuned controller. If you build everything at once, finding the cause is nearly impossible; if you move forward step by step and verify each stage, the problem is always exactly identifiable. That's why this chapter and the following ones each end with a defined checkpoint.
In Chapter 2, a single package was enough for learning purposes. But a real robot project is usually split across several packages โ exactly the way a factory is divided into several specialized departments, rather than one big, crowded hall.
archo_ws/
โโโ src/
โโโ archo_description/ # Robot body: URDF/Xacro, meshes, RViz
โโโ archo_gazebo/ # Simulation world, worlds
โโโ archo_bringup/ # Main launch that starts everything
โโโ archo_slam/ # SLAM settings and maps
โโโ archo_navigation/ # Nav2 settings
| Benefit of splitting into multiple packages | Explanation |
|---|---|
| Easier maintenance | Each package has only one responsibility |
| Easier debugging | The problem is immediately confined to a specific area |
| Reusability | For example, archo_description can also be used in a different project |
| Industry alignment | Almost all open-source and industrial ROS 2 projects follow this same pattern |
In this chapter, we only work with archo_description โ the package responsible for answering the question: "What does the robot look like, what parts does it have, and where is each part relative to the others?"
cd ~/archo_ws/src
ros2 pkg create archo_description --build-type ament_cmake
cd archo_description
mkdir -p urdf meshes launch rviz config
Packages that mainly contain URDF, Xacro, launch, and config files โ not executable Python code โ
are usually built with ament_cmake. This build model is better suited to installing
and managing non-executable files. Packages that contain Python nodes (like
archo_bringup in the previous chapter) are usually ament_python.
URDF (Unified Robot Description Format) is the standard format for describing a robot's physical structure in ROS: what parts it has, where the joints are, what each part's visual appearance and physical collision shape are, and what its mass and moment of inertia are. Xacro is an added layer on top of URDF that allows defining variables, calculations, macros, and splitting a file into multiple parts โ because writing raw URDF for a robot with dozens of parts quickly becomes unmanageable.
Xacro โ URDF โ robot_state_publisher โ TF + RobotModel
Let's create the main file:
touch ~/archo_ws/src/archo_description/urdf/archo.urdf.xacro
<?xml version="1.0"?>
<robot
xmlns:xacro="http://www.ros.org/wiki/xacro"
name="archo">
</robot>
The line xmlns:xacro="..." enables Xacro features such as
<xacro:property> and <xacro:macro>. Every link, joint, and
macro must be placed between this same opening and closing <robot> tag.
The first frame we create is not the robot's actual body โ it's a hypothetical frame on the ground surface:
<link name="base_footprint"/>
The center of the body is usually above the ground โ for example, if the body height is 0.18
meters, its center is about 0.09 meters above the ground. But Navigation2 likes to have a frame
exactly on the ground, with no roll or pitch. That's why we create two frames:
base_footprint on the ground, and base_link, which is the body's main
physical frame and the one to which all other parts (wheels, LiDAR, IMU) are attached.
In ROS, it's always: X = forward, Y = robot's left, Z = up. You must keep this convention in mind from the very start โ if you get the directions wrong, the robot might drive backward, the LiDAR might read reversed, or rotations might become inconsistent with TF.
First, instead of hardcoded numbers, we define the body dimensions as properties โ exactly the same parameter philosophy we learned in Chapter 1, this time in the world of URDF:
<xacro:property name="base_length" value="0.50"/>
<xacro:property name="base_width" value="0.36"/>
<xacro:property name="base_height" value="0.18"/>
<xacro:property name="base_mass" value="12.0"/>
URDF uses SI units. Writing <box size="50 36 18"/> means a body 50 by 36 by 18
meters โ a building, not a robot! The correct value is 0.50 0.36 0.18.
Now let's build base_link itself with a custom color:
<material name="archo_blue">
<color rgba="0.12 0.35 0.70 1.0"/>
</material>
<link name="base_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<box size="${base_length} ${base_width} ${base_height}"/>
</geometry>
<material name="archo_blue"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry>
<box size="${base_length} ${base_width} ${base_height}"/>
</geometry>
</collision>
<inertial>
<origin xyz="0 0 0" rpy="0 0 0"/>
<mass value="${base_mass}"/>
<inertia
ixx="${base_mass * (base_width*base_width + base_height*base_height) / 12.0}"
ixy="0.0" ixz="0.0"
iyy="${base_mass * (base_length*base_length + base_height*base_height) / 12.0}"
iyz="0.0"
izz="${base_mass * (base_length*base_length + base_width*base_width) / 12.0}"/>
</inertial>
</link>
| Part | What question it answers |
|---|---|
visual | What should it look like in RViz/Gazebo? |
collision | What shape should the physics engine use for collisions? |
inertial | What are the mass, center of mass, and resistance to rotation? |
The visual appearance of the body can be a complex, beautiful mesh from a CAD
program, but collision is usually a simple shape like a box. If the physics engine
had to compute collisions on a mesh with millions of faces, the simulation would become severely
slow. This is exactly the trade-off between visual fidelity and computational performance that
we'll return to in the next chapter when working with Gazebo.
Finally, we connect the two frames with a fixed joint:
<joint name="base_footprint_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<origin xyz="0 0 ${base_height / 2.0}" rpy="0 0 0"/>
</joint>
The value ${base_height / 2.0} is not arbitrary: since the body's frame is at the
center of the box, for the bottom of the body to sit exactly on the ground, its center must be
half the height above base_footprint.
If you write this origin as xyz="0 0 0", the center of the body ends up
exactly on the ground, and as a result half the body sinks into the ground โ the most common
mistake the first time you write this joint.
Write out the full file above, then change the body dimensions to 0.55 ร 0.40 ร 0.20
meters. What should the new origin value in the joint be?
ARCHO's wheels are built as cylinders. But there's a subtle point: in URDF, a cylinder's long axis is by default along Z, whereas the wheel needs to rotate about the Y axis. So we rotate the geometry 90 degrees about X.
<xacro:property name="pi" value="3.141592653589793"/>
<xacro:property name="wheel_radius" value="0.09"/>
<xacro:property name="wheel_width" value="0.04"/>
<xacro:property name="wheel_mass" value="0.8"/>
<xacro:property name="wheel_y_offset" value="0.20"/>
Instead of writing a repetitive block for the left wheel and again for the right wheel, we create a macro โ exactly the same "don't repeat yourself" principle we follow in programming:
<xacro:macro name="drive_wheel" params="prefix y_position">
<link name="${prefix}_wheel_link">
<visual>
<origin xyz="0 0 0" rpy="${pi/2.0} 0 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
<material name="wheel_black"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="${pi/2.0} 0 0"/>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_width}"/>
</geometry>
</collision>
<inertial>
<mass value="${wheel_mass}"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
<inertia
ixx="${wheel_mass * (3*wheel_radius*wheel_radius + wheel_width*wheel_width) / 12.0}"
ixy="0" ixz="0"
iyy="${wheel_mass * wheel_radius * wheel_radius / 2.0}"
iyz="0"
izz="${wheel_mass * (3*wheel_radius*wheel_radius + wheel_width*wheel_width) / 12.0}"/>
</inertial>
</link>
<joint name="${prefix}_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="${prefix}_wheel_link"/>
<origin xyz="0 ${y_position} 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
</xacro:macro>
<xacro:drive_wheel prefix="left" y_position="${wheel_y_offset}"/>
<xacro:drive_wheel prefix="right" y_position="${-wheel_y_offset}"/>
| Question | Answer |
|---|---|
Why type="continuous" and not revolute? | revolute has an angle limit (e.g. ยฑ90ยฐ); the wheel needs to be able to rotate infinitely |
Why axis xyz="0 1 0"? | It means the wheel rotates about the Y axis; if it's wrong, the wheel spins like a coin or the robot doesn't move |
| Why a macro instead of copy-paste? | A single change (e.g. wheel radius) is applied in one place and both wheels update automatically |
Suppose you want to build a larger version of ARCHO with a wheel radius of 0.12 meters and a wheel separation of 0.30 meters. By changing only the properties (not the macro itself), how would this be done?
Two drive wheels alone cannot keep the body balanced; an auxiliary caster wheel is needed. For simplicity, we build it as a sphere โ a sphere has less lateral friction and makes the simulation more stable:
<link name="caster_link">
<visual><geometry><sphere radius="0.04"/></geometry></visual>
<collision><geometry><sphere radius="0.04"/></geometry></collision>
<inertial>
<mass value="0.15"/>
<inertia ixx="0.000096" ixy="0" ixz="0" iyy="0.000096" iyz="0" izz="0.000096"/>
</inertial>
</link>
<joint name="caster_joint" type="fixed">
<parent link="base_link"/>
<child link="caster_link"/>
<origin xyz="-0.18 0 -0.05" rpy="0 0 0"/>
</joint>
Now the LiDAR and IMU โ the same two sensors whose glossary entries we saw in Chapter 1 โ get physical frames here for the first time:
<link name="laser_link">
<visual><geometry><cylinder radius="0.045" length="0.04"/></geometry></visual>
<collision><geometry><cylinder radius="0.045" length="0.04"/></geometry></collision>
<inertial>
<mass value="0.2"/>
<inertia ixx="0.0002" ixy="0" ixz="0" iyy="0.0002" iyz="0" izz="0.0002"/>
</inertial>
</link>
<joint name="laser_joint" type="fixed">
<parent link="base_link"/>
<child link="laser_link"/>
<origin xyz="0.12 0 0.16" rpy="0 0 0"/>
</joint>
<link name="imu_link">
<visual><geometry><box size="0.04 0.03 0.015"/></geometry></visual>
</link>
<joint name="imu_joint" type="fixed">
<parent link="base_link"/>
<child link="imu_link"/>
<origin xyz="0 0 0.11" rpy="0 0 0"/>
</joint>
When the IMU is far from the robot's center of rotation, the accelerations caused by rotation also enter its measurements, making the data noisier. That's why the IMU is usually mounted as close as possible to the geometric/mass center of the body.
Our TF tree structure now looks like this:
This is exactly the same TF tree we'll discuss in full detail in Chapter 5.
A Xacro file is only useful once it's converted to pure URDF and validated. First, source the workspace:
To see the full structure diagram:
cd /tmp && urdf_to_graphiz archo.urdf && xdg-open archo.pdf
| Common error | Symptom | Solution |
|---|---|---|
| Unclosed XML tag | mismatched tag | Check the number of opening and closing tags |
Undefined property (e.g. base_lenght) | Xacro error during conversion | Compare the property's spelling with its definition |
| Zero mass | Gazebo becomes unstable in the next chapter | mass must always be positive |
| Wrong units (centimeters instead of meters) | Giant or microscopic robot | URDF always works in meters |
Finally, set up the files to be installed during build โ add this section to CMakeLists.txt:
install(
DIRECTORY urdf meshes launch rviz config
DESTINATION share/${PROJECT_NAME}
)
cd ~/archo_ws
colcon build --symlink-install --packages-select archo_description
source install/setup.bash
ros2 pkg prefix archo_description
xacro archo.urdf.xacro -o /tmp/archo.urdf runs without errors.check_urdf outputs the message Successfully Parsed XML.ros2 pkg prefix archo_description returns the path of the installed package.For the first time, ARCHO went from being a name on paper to a valid geometric model: a body with correct mass and inertia, two drive wheels connected with continuous-type joints, a caster wheel, and defined mounting locations for the LiDAR and IMU. The robot still doesn't move at all and doesn't exist in any virtual world yet โ that's exactly what we'll do in the next chapter with Gazebo.
ARCHO project now has a valid, validated archo.urdf.xacro file that will be used as the robot's physical foundation in all subsequent chapters โ Gazebo, ros2_control, TF2, SLAM, and Nav2.
In Chapter 5, we'll take a close look at the TF tree we just built with URDF: how ROS 2 tracks the position of each robot part relative to the others โ and relative to the world.
axis xyz="0 1 0" on the wheel joints.