PTENES
MODULE 4.2 · HANDS-ON

🧱 Describe and simulate the robot

Before mapping and navigating, ROS needs to know what your robot looks like: where the wheels, LiDAR, and center are. You describe this in URDF/xacro, check it in RViz2, and get the robot moving in Gazebo, controlled with the keyboard. Everything runs on your PC, with no hardware needed.

6
Topics
Intermediate
Level
Practice
Type
PC only
Hardware required
0%
0 of 0 topics read · Section 1 of 6

Detailed content

1

🗂️ Workspace, packages, and colcon

In 4.1 you created ~/ros2_ws without giving it much thought. It's worth understanding what happens there. The workspace is a folder with src/ (your code) and, after colcon build, three more: build/ (a draft), install/ (what runs), and log/. When you run source install/setup.bash, your workspace becomes a layer on top of the ROS installation in /opt/ros.

🌱 New here?

Underlay is the lower layer (the ROS in /opt/ros/<distro>); overlay is the one above (your install/). If a package exists in both, the overlay takes precedence. colcon is the tool that builds all the packages in src/ in the right order. rosdep reads the package.xml and installs missing dependencies with apt.

Ubuntu 26.04 or 24.04 underlay: /opt/ros/<distro>rclpy, RViz2, ros_gz, xacro... overlay: ~/ros2_ws/installmeu_robo, meu_robo_description source secondsource first

How to read this map: the installed ROS is the foundation; your workspace builds on top of it. The cyan arrow shows the order to source: first /opt/ros (already in .bashrc), then the install/ from your workspace, in every new terminal.

Goal: create the package meu_robo_description, which will hold the robot description, the Gazebo world, and the launch file. It uses the ament_python type, like the one in 4.1.

terminal
# tools used by this module (desktop already includes RViz2 and robot_state_publisher)
sudo apt install -y ros-<distro>-ros-gz ros-<distro>-xacro ros-<distro>-tf2-tools \
  ros-<distro>-joint-state-publisher-gui ros-<distro>-teleop-twist-keyboard
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python --license MIT meu_robo_description
mkdir -p meu_robo_description/urdf meu_robo_description/launch meu_robo_description/worlds
# after creating the files for topics 2 and 5:
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -y
colcon build --symlink-install --packages-select meu_robo_description
source install/setup.bash
meu_robo_description/setup.py
import os
from glob import glob
from setuptools import setup

package_name = 'meu_robo_description'
share = os.path.join('share', package_name)

setup(
    name=package_name,
    version='0.1.0',
    packages=[package_name],
    data_files=[
        ('share/ament_index/resource_index/packages', ['resource/' + package_name]),
        (share, ['package.xml']),
        (os.path.join(share, 'urdf'), glob('urdf/*.xacro')),      # installs the description
        (os.path.join(share, 'launch'), glob('launch/*.launch.py')),
        (os.path.join(share, 'worlds'), glob('worlds/*.sdf')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='voce',
    maintainer_email='voce@exemplo.com',
    description='Robot course description and simulation',
    license='MIT',
)

How to check: in package.xml generated, add one line <exec_depend> for each package used: robot_state_publisher, xacro, ros_gz_sim, ros_gz_bridge, rviz2. After the build, ros2 pkg prefix meu_robo_description shows the path to install/, e ls install/meu_robo_description/share/meu_robo_description/urdf lists the robot file.

✓ Time-saving habits

  • ✓Always build from the workspace root.
  • ✓Use --symlink-install: Python and launch files take effect without rebuilding.
  • ✓Run rosdep install when downloading a third-party package.

✗ Common mistakes

  • ✗Run colcon build inside src/.
  • ✗Forget to source install/setup.bash in a new terminal.
  • ✗Create a new file (URDF, launch) and forget to register it in setup.py.

Why learn this

Half of the beginner questions on ROS forums come down to a workspace that hasn’t been sourced, a file that hasn’t been installed, or a missing dependency. Understanding the layers turns these errors into a one-minute fix.

Key concepts

Workspace
src, build, install, log
Overlay
your layer on top
rosdep
installs dependencies
data_files
what goes into share/
2

📐 URDF/xacro: describing the robot

O URDF (Unified Robot Description Format) describes the robot as rigid parts, called links, connected by joints (joints). Each link has three parts: visual (how it looks), collision (the shape used for collisions), and inertial (mass and inertia, which the simulator requires). xacro is a preprocessor that adds variables, calculations, and macros, so you can write a wheel once and use it twice.

base_link roda_esq · continuous roda_dir · continuous rodizio · fixed lidar_link · fixed x (forward)y (left)

How to read this map: the amber rectangle is the base_link, the parent part. The wheels (cyan) rotate freely: joint type continuous. The caster (dashed) and the LiDAR (amber circle) don’t move relative to the base: joint type fixed. By ROS convention, x points forward and y points left; z comes out of the screen.

Goal: describe a differential-drive robot measuring 25 × 18 cm, with wheels of 4 cm radius, a caster, and a LiDAR, including the Gazebo plugins (differential drive, joint state, and laser). Measure your robot and update the properties at the top of the file.

meu_robo_description/urdf/robo.urdf.xacro
<?xml version="1.0"?>
<robot name="robo" xmlns:xacro="http://www.ros.org/wiki/xacro">
  <!-- measurements in meters and kg: replace with your robot's values -->
  <xacro:property name="base_x" value="0.25"/> <xacro:property name="base_y" value="0.18"/>
  <xacro:property name="base_z" value="0.06"/> <xacro:property name="raio" value="0.04"/>
  <xacro:property name="larg_roda" value="0.025"/> <xacro:property name="sep" value="0.21"/>
  <material name="ambar"><color rgba="0.96 0.62 0.04 1"/></material>
  <material name="preto"><color rgba="0.1 0.1 0.1 1"/></material>

  <link name="base_link">
    <visual><geometry><box size="${base_x} ${base_y} ${base_z}"/></geometry><material name="ambar"/></visual>
    <collision><geometry><box size="${base_x} ${base_y} ${base_z}"/></geometry></collision>
    <inertial><mass value="1.0"/><inertia ixx="0.003" ixy="0" ixz="0" iyy="0.0055" iyz="0" izz="0.008"/></inertial>
  </link>

  <!-- macro: one wheel; side = esq or dir, y = lateral position -->
  <xacro:macro name="roda" params="lado y">
    <link name="roda_${lado}">
      <visual><geometry><cylinder radius="${raio}" length="${larg_roda}"/></geometry><material name="preto"/></visual>
      <collision><geometry><cylinder radius="${raio}" length="${larg_roda}"/></geometry></collision>
      <inertial><mass value="0.1"/><inertia ixx="4.5e-5" ixy="0" ixz="0" iyy="4.5e-5" iyz="0" izz="8e-5"/></inertial>
    </link>
    <joint name="junta_roda_${lado}" type="continuous">
      <parent link="base_link"/><child link="roda_${lado}"/>
      <origin xyz="0.05 ${y} 0" rpy="-1.5708 0 0"/><axis xyz="0 0 1"/>
    </joint>
  </xacro:macro>
  <xacro:roda lado="esq" y="${sep/2}"/>
  <xacro:roda lado="dir" y="${-sep/2}"/>

  <link name="rodizio">
    <visual><geometry><sphere radius="0.02"/></geometry><material name="preto"/></visual>
    <collision><geometry><sphere radius="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/><inertia ixx="8e-6" ixy="0" ixz="0" iyy="8e-6" iyz="0" izz="8e-6"/></inertial>
  </link>
  <joint name="junta_rodizio" type="fixed">
    <parent link="base_link"/><child link="rodizio"/><origin xyz="-0.09 0 -0.02"/>
  </joint>

  <link name="lidar_link">
    <visual><geometry><cylinder radius="0.035" length="0.04"/></geometry><material name="preto"/></visual>
    <collision><geometry><cylinder radius="0.035" length="0.04"/></geometry></collision>
    <inertial><mass value="0.1"/><inertia ixx="5e-5" ixy="0" ixz="0" iyy="5e-5" iyz="0" izz="6e-5"/></inertial>
  </link>
  <joint name="junta_lidar" type="fixed">
    <parent link="base_link"/><child link="lidar_link"/><origin xyz="0.05 0 0.05"/>
  </joint>

  <!-- Gazebo (Harmonic or Jetty): frictionless caster, drive, joints, and laser -->
  <gazebo reference="rodizio"><mu1>0.0</mu1><mu2>0.0</mu2></gazebo>
  <gazebo>
    <plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive">
      <left_joint>junta_roda_esq</left_joint><right_joint>junta_roda_dir</right_joint>
      <wheel_separation>${sep}</wheel_separation><wheel_radius>${raio}</wheel_radius>
      <topic>cmd_vel</topic><odom_topic>odom</odom_topic><tf_topic>tf</tf_topic>
      <frame_id>odom</frame_id><child_frame_id>base_link</child_frame_id>
      <odom_publish_frequency>30</odom_publish_frequency>
    </plugin>
    <plugin filename="gz-sim-joint-state-publisher-system" name="gz::sim::systems::JointStatePublisher">
      <topic>joint_states</topic>
    </plugin>
  </gazebo>
  <gazebo reference="lidar_link">
    <sensor name="lidar" type="gpu_lidar">
      <topic>scan</topic><gz_frame_id>lidar_link</gz_frame_id>
      <update_rate>10</update_rate><always_on>1</always_on><visualize>true</visualize>
      <lidar>
        <scan><horizontal><samples>360</samples><resolution>1</resolution>
          <min_angle>-3.14159</min_angle><max_angle>3.14159</max_angle></horizontal></scan>
        <range><min>0.12</min><max>12.0</max><resolution>0.01</resolution></range>
      </lidar>
    </sensor>
  </gazebo>
</robot>

How to check

  • ✓xacro robo.urdf.xacro > /tmp/robo.urdf produces no errors.
  • ✓check_urdf /tmp/robo.urdf prints the tree: base_link with 4 children (liburdfdom-tools package).
  • ✗"Undefined substitution argument"? Did you forget to declare a xacro:property.

Why learn this

One file serves all these purposes: RViz uses it to draw the robot, Gazebo simulates its physics, robot_state_publisher calculates the position of each part, and Nav2 uses the measurements to avoid collisions. If the distance between the wheels is wrong here, the odometry and map will be off later on.

Key concepts

Link
rigid part
Joint
fixed, continuous, revolute
Inertial
mass required by Gazebo
xacro:macro
write once, use twice
3

🌳 TF2: coordinate frames

The LiDAR says, "there’s a wall 1 m away, at a 30° angle," but 1 m from where? From the LiDAR itself. To place that wall on the map, ROS needs to link together: LiDAR → robot base → the point where the robot started → map. Each of these is a coordinate frame (frame), and the TF2 library stores their tree over time, so it can answer "where is A relative to B?" at any moment.

map odom base_link roda_esqroda_dirrodiziolidar_link SLAM or AMCL (4.4, 4.5)odometry (Gazebo or 4.3)robot_state_publisher

How to read this map: each box is a frame, each line a transform, and each transform has a single publisher (gray text). robot_state_publisher publishes the amber lines from the URDF. Odometry publishes odom → base_link. The dashed map → odom line only appears when SLAM starts: it corrects the accumulated odometry error.

🌱 New here?

odom is the point where the robot started: its position relative to this frame changes smoothly, but error accumulates (the wheel slips). map is the map’s fixed reference frame: it may jump slightly when SLAM corrects the position, but it doesn’t drift. This split follows REP 105, which every navigation package respects.

terminal (with the simulation from topic 5 running)
# where is the LiDAR relative to the base? (translation and rotation)
ros2 run tf2_ros tf2_echo base_link lidar_link
# generates frames_<data>.pdf with the entire tree
ros2 run tf2_tools view_frames

How to check: o tf2_echo prints "Translation: [0.050, 0.000, 0.050]", exactly the origin of the LiDAR joint in the URDF. The PDF shows odom → base_link → four children.

Why learn this

An incorrect TF is navigation’s most insidious defect: everything runs, but the laser appears rotated, the map duplicates walls, and the robot thinks it’s passing through the sofa. If you can read the tree, you can find the problem in minutes.

Key concepts

Frame
coordinate frame
map / odom
fixed / smooth with drift
base_link
the center of the robot
tf2_echo
looks up a connection
4

👁️ RViz2: view the robot and sensors

O RViz2 doesn’t simulate anything: it draws what the topics report. It’s a window into the robot’s mind. Before taking the model to Gazebo, check it in RViz with two nodes: the robot_state_publisher, which reads the URDF and publishes the TF for the parts, and the joint_state_publisher_gui, a window with sliders for turning the wheels by hand.

Step by step: the model in RViz2

1

Publish the description

Run the three commands in the block below, each in a separate terminal with the workspace activated.

2

Set the Fixed Frame

In the "Global Options" panel, change Fixed Frame to base_link (without a simulator, there is no odom).

3

Add the visualizations

Click "Add": RobotModel (under Description Topic, choose /robot_description) e TF. Save it with File → Save Config As to ~/ros2_ws/robo.rviz.

4

Move the wheels

Drag the sliders in the joint_state_publisher_gui window and watch the wheels turn in RViz.

terminal
cd ~/ros2_ws/src/meu_robo_description/urdf
# terminal 1: reads the URDF and publishes the TF for the parts
ros2 run robot_state_publisher robot_state_publisher --ros-args -p robot_description:="$(xacro robo.urdf.xacro)"
# terminal 2: sliders for the wheel joints
ros2 run joint_state_publisher_gui joint_state_publisher_gui
# terminal 3
rviz2

How to check: An amber box appears with two black wheels, the caster at the back, and the LiDAR on top. The TF axes show x (red) pointing forward. ros2 topic echo /joint_states changes when you drag a slider.

✓ What to check in RViz

  • ✓The front of the robot aligns with the red axis (x).
  • ✓The wheels touch the ground at the same height as the caster.
  • ✓The LiDAR is where it sits on the real robot.

✗ Signs of trouble

  • ✗"No transform from roda_esq": /joint_states is missing.
  • ✗Blank screen: Fixed Frame points to a frame that doesn’t exist.
  • ✗Wheels lying flat: the rpy for the joint is wrong.

Why learn this

Everything else in the course uses RViz: the laser in 4.4, the map and planned path in 4.5, and clicking "go there." Learning to set up and save a configuration now saves you from rebuilding it each time.

Key concepts

Fixed Frame
screen reference frame
RobotModel
draws the URDF
/joint_states
angle of each joint
.rviz
saved configuration
5

🌐 Gazebo (Harmonic/Jetty) and ros_gz

O Gazebo simulates physics, collisions, and sensors. The version matches the distro: Harmonic with Jazzy and Jetty with Lyrical; the package ros-<distro>-ros-gz already includes the right pair. Gazebo has its own message network; the ros_gz_bridge bridge, which translates topic by topic, connects the two. The old "Gazebo Classic" reached end of life in January 2025: tutorials using gazebo_ros and old .world files no longer apply.

Gazebo sala.sdf worldDiffDrivegpu_lidarJointStatephysics, clock ros_gzbridge ROS 2 teleoprobot_state_pub.RViz2SLAM, Nav2 (4.4+) /cmd_vel/odom /scan /tf/joint_states /clock

How to read this map: The amber arrow at the top is the command sent from ROS to the simulator (velocity). The cyan arrow at the bottom is what the simulator "measures" and sends back to ROS. Only what you list in the bridge passes through it: a topic missing from the bridge doesn’t exist on the other side.

Goal: a world with a floor, lighting, the sensor system (without it, the LiDAR won’t publish), and two boxes for the laser to detect.

meu_robo_description/worlds/sala.sdf
<?xml version="1.0"?>
<sdf version="1.9">
  <world name="sala">
    <plugin filename="gz-sim-physics-system" name="gz::sim::systems::Physics"/>
    <plugin filename="gz-sim-user-commands-system" name="gz::sim::systems::UserCommands"/>
    <plugin filename="gz-sim-scene-broadcaster-system" name="gz::sim::systems::SceneBroadcaster"/>
    <plugin filename="gz-sim-sensors-system" name="gz::sim::systems::Sensors">
      <render_engine>ogre2</render_engine>
    </plugin>
    <light type="directional" name="sol">
      <pose>0 0 10 0 0 0</pose><diffuse>0.9 0.9 0.9 1</diffuse><direction>-0.5 0.1 -0.9</direction>
    </light>
    <model name="chao"><static>true</static><link name="link">
      <collision name="c"><geometry><plane><normal>0 0 1</normal><size>20 20</size></plane></geometry></collision>
      <visual name="v"><geometry><plane><normal>0 0 1</normal><size>20 20</size></plane></geometry></visual>
    </link></model>
    <model name="caixa1"><static>true</static><pose>1.5 0 0.25 0 0 0</pose><link name="link">
      <collision name="c"><geometry><box><size>0.5 0.5 0.5</size></box></geometry></collision>
      <visual name="v"><geometry><box><size>0.5 0.5 0.5</size></box></geometry></visual>
    </link></model>
    <model name="caixa2"><static>true</static><pose>-1 1.2 0.25 0 0 0.5</pose><link name="link">
      <collision name="c"><geometry><box><size>1 0.3 0.5</size></box></geometry></collision>
      <visual name="v"><geometry><box><size>1 0.3 0.5</size></box></geometry></visual>
    </link></model>
  </world>
</sdf>

Goal: a launch file that opens Gazebo with the world, publishes the description, places the robot in the world, starts the bridge, and opens RViz2. In the bridge, ] means ROS → Gazebo and [ means Gazebo → ROS.

meu_robo_description/launch/sim.launch.py
import os
import xacro
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node


def generate_launch_description():
    pkg = get_package_share_directory('meu_robo_description')
    urdf = xacro.process_file(os.path.join(pkg, 'urdf', 'robo.urdf.xacro')).toxml()
    mundo = os.path.join(pkg, 'worlds', 'sala.sdf')
    sim = {'use_sim_time': True}   # all nodes use the Gazebo clock

    gazebo = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(os.path.join(
            get_package_share_directory('ros_gz_sim'), 'launch', 'gz_sim.launch.py')),
        launch_arguments={'gz_args': f'-r {mundo}'}.items())   # -r: starts running right away

    rsp = Node(package='robot_state_publisher', executable='robot_state_publisher',
               parameters=[{'robot_description': urdf}, sim])

    nascer = Node(package='ros_gz_sim', executable='create',
                  arguments=['-topic', 'robot_description', '-name', 'robo', '-z', '0.05'])

    ponte = Node(package='ros_gz_bridge', executable='parameter_bridge', parameters=[sim],
                 arguments=[
                     '/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist',
                     '/odom@nav_msgs/msg/Odometry[gz.msgs.Odometry',
                     '/scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan',
                     '/tf@tf2_msgs/msg/TFMessage[gz.msgs.Pose_V',
                     '/joint_states@sensor_msgs/msg/JointState[gz.msgs.Model',
                     '/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock',
                 ])

    rviz = Node(package='rviz2', executable='rviz2', parameters=[sim])

    return LaunchDescription([gazebo, rsp, nascer, ponte, rviz])
terminal
cd ~/ros2_ws
colcon build --symlink-install --packages-select meu_robo_description
source install/setup.bash
ros2 launch meu_robo_description sim.launch.py

How to check

  • ✓Gazebo opens with the amber robot in the center and two boxes; the laser appears as blue rays.
  • ✓In RViz, with Fixed Frame odom, add RobotModel and LaserScan (/scan): the laser points trace the edges of the boxes.
  • ✓ros2 topic hz /scan shows about 10 Hz.
  • ✗Does Gazebo close right after opening in a virtual machine? Run this first: export LIBGL_ALWAYS_SOFTWARE=1 (it’ll be slow, but it works).

Why learn this

With a different bridge, the same launch file becomes the one for the real robot in 4.6. Mapping and navigating in simulation first helps you tell whether a problem comes from the algorithm or the hardware, and lets you work through the whole course without a LiDAR.

Key concepts

Harmonic / Jetty
Gazebo version for each distro
parameter_bridge
translates topic by topic
use_sim_time
simulator clock
launch
starts everything at once
6

🎮 Drive the simulated robot with teleop

With the simulation running, you need something to publish to /cmd_vel. O teleop_twist_keyboard converts keystrokes into Twist messages. It’s the smoke test for the whole course: if the robot moves, the laser turns with it, and the odometry follows, the command → simulator → sensors → ROS chain is working end to end. In 4.3, this same command will move the real robot.

terminal (with sim.launch.py running)
# keep THIS terminal focused while driving
ros2 run teleop_twist_keyboard teleop_twist_keyboard
#   i = forward   , = reverse   j / l = turn   k = stop   q / z = increase / decrease speed
# in another terminal, check what’s happening
ros2 topic echo /cmd_vel
ros2 topic echo /odom --field pose.pose.position

How to check

  • ✓With i, the robot moves forward in Gazebo and RViz at the same time.
  • ✓O echo /cmd_vel terminal linear.x: 0.5; o x of the odometry increases.
  • ✓As it approaches a box, the LaserScan points in RViz stay fixed in the world (the robot is what moves).
  • ✗The robot moves backward with i? The wheel joint axis is reversed in the URDF.

💡 Twist or TwistStamped?

The teleop publishes Twist by default, which is what the bridge above expects. Newer packages, such as Nav2 in module 4.5, may publish TwistStamped (Twist with a timestamp). When you get there, adjust the type in the bridge or the Nav2 parameter so both sides match; different types on the same topic simply won't connect.

🌱 New here?

Notice that if you drive in a circle and return to the starting point, Gazebo's odometry reports exactly 0. On the real robot, it won't: the wheel slips and the error accumulates. That's the error that SLAM in module 4.4 corrects using the laser.

Why learn this

Manual driving is how you'll map the house in 4.4 and test the real robot in 4.3. And when something goes wrong with Nav2, switching back to teleop helps you tell “the robot isn't obeying” from “the planner isn't planning.”

Key concepts

teleop
keyboard → /cmd_vel
/odom
estimated position
/scan
laser readings
Smoke test
is the whole chain connected?

✅ Module summary

✓
Layered workspace: /opt/ros underneath, your install/ on top; colcon at the root, source in every terminal.
✓
URDF/xacro describes links and joints once for RViz, Gazebo, TF, and Nav2.
✓
TF2: map → odom → base_link → sensors, with a single publisher for each link.
✓
Gazebo Harmonic (Jazzy) or Jetty (Lyrical) connected to ROS through ros_gz_bridge, topic by topic.
✓
teleop_twist_keyboard closes the loop and serves as a smoke test.

To go further

Next module

4.3 · micro-ROS on the ESP32: the real robot joins ROS 2, follows the same /cmd_vel, and returns odometry.