Detailed content
🗂️ 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.
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.
# 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
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
📐 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.
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.
<?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
🌳 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.
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.
# 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
👁️ 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
Publish the description
Run the three commands in the block below, each in a separate terminal with the workspace activated.
Set the Fixed Frame
In the "Global Options" panel, change Fixed Frame to base_link (without a simulator, there is no odom).
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.
Move the wheels
Drag the sliders in the joint_state_publisher_gui window and watch the wheels turn in RViz.
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
🌐 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.
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.
<?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.
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])
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
🎮 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.
# 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
✅ Module summary
To go further
- URDF tutorials (docs.ros.org) — links, joints, and xacro step by step
- About TF2 — concepts and tools
- Gazebo documentation — choose Harmonic or Jetty at the top
- Gazebo with ROS (ros_gz) — which pair to install with each distro
- REP 105 — the map, odom, and base_link convention
Next module
4.3 · micro-ROS on the ESP32: the real robot joins ROS 2, follows the same /cmd_vel, and returns odometry.