Blog

Writing a URDF for Your Own Chassis: From CAD to a Robot ROS 2 Understands

Tom Cosgrove,

Reading Room: The Fictionlab Blog

TL;DR: This URDF tutorial for a custom robot walks you through turning a CAD assembly into a working ROS 2 description: exporting meshes, defining links and joints, computing inertias, adding collisions, and verifying the model in RViz and Gazebo. You will use xacro for reusable macros, robot_state_publisher for TF, and ros2_control hooks for simulation. A public template repository is referenced so you can fork and adapt it to your own chassis.

Why a correct URDF matters before you touch ROS 2 navigation

A URDF (Unified Robot Description Format) is the kinematic and visual contract between your hardware and the rest of the ROS 2 stack. Tools such as robot_state_publisher, RViz, MoveIt, and Gazebo use it directly, while navigation and SLAM systems rely on the TF tree and sensor frames derived from it. If your URDF for a custom robot has a wrong joint axis, a shifted sensor frame, or a fabricated inertia tensor, downstream problems appear as drifting odometry, tipping in simulation, or a laser scan that stubbornly points at the floor.

This guide assumes you have a CAD assembly of a differential-drive chassis (four wheels, two driven, a caster or skid geometry similar to the Leo Rover) and a working ROS 2 Humble or Jazzy installation. The steps generalize to tracked platforms and 6-wheel skid-steer designs used in field robotics.

What a URDF actually contains

A URDF file is XML that describes a tree of links connected by joints. Every link can carry three blocks:

  • visual – meshes or primitives used by RViz and Gazebo for rendering.
  • collision – simplified geometry for physics and planning.
  • inertial – mass, center of mass, and 3×3 inertia tensor.

Joints define how one link moves relative to its parent. The common types you will use on a mobile robot are fixed (rigid mounts, sensor brackets), continuous (wheels), and revolute (steering, arms). For anything beyond a two-link demo, wrap the file in xacro macros so you can parameterize wheel radius, track width, and sensor offsets.

How to prepare meshes in CAD before you write any XML

Your CAD tool (SolidWorks, Fusion 360, FreeCAD, Onshape) produces assemblies with nested coordinate systems that rarely match ROS conventions. Fix this at export time, not in URDF offsets.

  1. Decide the base_link origin. A common convention for wheeled robots is the center between the drive axles at axle height, with X forward, Y left, and Z up (REP-103). Place base_footprint directly below it on the ground plane if you use that frame.
  2. For each moving part (left wheel, right wheel, steering knuckle), create a separate body with its origin on the rotation axis. Export each body as its own mesh file.
  3. Export visuals as .dae or .stl in meters. If your CAD works in millimeters, scale on export. A mesh that looks 1000x too large in RViz is the classic symptom.
  4. Generate a simplified collision mesh per link. Convex hulls or primitive boxes and cylinders are dramatically faster in Gazebo than raw triangle soup. Aim for under approximately 500 triangles per collision shape.
  5. Read out mass, center of mass, and the inertia tensor for each body from your CAD’s mass properties tool. Record them in kilograms, meters, and kg·m². These values are the single most common source of unstable simulation.

How to structure the xacro files for a custom robot URDF

A maintainable layout separates concerns. A typical package looks like this:

  • urdf/robot.urdf.xacro – top-level file that includes the rest.
  • urdf/chassis.xacro – base_link, body visuals, IMU mount.
  • urdf/wheel.xacro – a macro instantiated four times.
  • urdf/sensors.xacro – camera, lidar, depth sensor frames.
  • urdf/gazebo.xacro – Gazebo-specific tags and ros2_control plugin.
  • meshes/ – .dae for visuals, .stl for collisions.
  • launch/display.launch.py – RViz preview.
  • launch/gazebo.launch.py – simulation.

A minimal wheel macro

The wheel is the most reused element, so a macro pays off immediately. This example defines a continuous joint about Y, a cylinder collision, and a mesh visual:

<xacro:macro name="wheel" params="prefix reflect">
  <link name="${prefix}_wheel_link">
    <visual>
      <origin xyz="0 0 0" rpy="1.5708 0 0"/>
      <geometry><mesh filename="package://my_robot/meshes/wheel.dae"/></geometry>
    </visual>
    <collision>
      <origin xyz="0 0 0" rpy="1.5708 0 0"/>
      <geometry><cylinder radius="0.0625" length="0.06"/></geometry>
    </collision>
    <inertial>
      <mass value="0.5"/>
      <origin xyz="0 0 0"/>
      <inertia ixx="0.00064" iyy="0.00098" izz="0.00064" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="${prefix}_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="${prefix}_wheel_link"/>
    <origin xyz="0.15 ${reflect*0.18} 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
  </joint>
</xacro:macro>

Instantiate it with <xacro:wheel prefix="front_left" reflect="1"/> and mirror for the right side using reflect="-1".

How to compute inertia tensors you can trust

Guessing inertia is the fastest way to a robot that wobbles in Gazebo. Three practical routes exist:

  • Read from CAD. Every major CAD system reports the tensor at the center of mass in the part’s local frame. Use those values only after ensuring that the CAD frame matches the URDF link frame; otherwise rotate the tensor into the link frame and set the URDF inertial origin to the center of mass. Make sure the CAD material density matches reality; a hollow ABS shell modeled as solid aluminum is a common error.
  • Analytical primitives. For cylinders and boxes the formulas are trivial. A solid cylinder of mass m, radius r, length h has Ixx = Iyy = (1/12)·m·(3r² + h²), Izz = (1/2)·m·r², where the cylinder axis is Z.
  • Measured. For final validation, weigh the assembled robot and compare with the sum of link masses in your URDF. A discrepancy above roughly 10% means you missed cables, batteries, or fasteners.

How to verify the URDF in RViz

Before any physics simulation, check the kinematics visually. A dedicated launch file loads the model and starts joint_state_publisher_gui so you can drag joint sliders:

ros2 launch my_robot display.launch.py

In RViz, add the RobotModel and TF displays. Verify that:

  • All links appear in the correct place and orientation.
  • Wheel joints rotate about the correct axis when you move the sliders.
  • Sensor frames (camera_link, laser_link, imu_link) match the physical mounting, including any 45° or 90° rotations from the sensor’s own datasheet.
  • base_link to base_footprint transform places the footprint exactly on the ground plane.

Run check_urdf and urdf_to_graphviz on the expanded file (produced by xacro robot.urdf.xacro > robot.urdf) to catch typos and confirm the tree structure.

How to make the URDF simulation-ready in Gazebo

Gazebo Classic and the current Gazebo simulator use different integration mechanisms. In either case, configure wheel friction, add a ros2_control block, and load the appropriate simulator hardware plugin; configure the differential-drive controller separately in a controller YAML file. Gazebo Classic reached end of life in January 2025, so new ROS 2 Jazzy projects should normally target the current Gazebo simulator with gz_ros2_control.

The following is a Gazebo Classic fragment for a wheel:

<gazebo reference="front_left_wheel_link">
  <mu1>1.0</mu1>
  <mu2>1.0</mu2>
  <kp>1000000.0</kp>
  <kd>100.0</kd>
</gazebo>

For Gazebo Classic, launch with ros2 launch my_robot gazebo.launch.py, spawn the entity via ros2 run gazebo_ros spawn_entity.py, and publish a test twist:

ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.2}, angular: {z: 0.0}}"

With the current Gazebo simulator, spawning and plugin configuration use the ros_gz and gz_ros2_control integration packages instead. The robot should drive straight. If it curves, your wheel joint origins are asymmetric or one wheel’s axis is flipped. If it sinks through the ground, the collision mesh is missing or scaled wrong. If it flips on acceleration, the inertia tensor or center-of-mass Z is off.

Template repository and reference platform

The Leo Rover repositories on GitHub contain a production URDF for a 4×4 skid-steer field robot and provide a working reference when you build your own. For teams doing academic work, related setups are documented under research applications.

Common mistakes when writing a URDF for a custom robot

Reviewing dozens of student and integrator repositories, the same defects appear repeatedly:

  • Meshes exported in millimeters, producing a 1000x oversized robot.
  • Wheel axis set to 0 0 1 instead of 0 1 0, so wheels spin like turntables.
  • Inertia tensors copied from tutorials (all 0.001) regardless of the actual link, causing Gazebo instability.
  • Collision meshes identical to visual meshes, tanking simulation frame rate.
  • Sensor frames placed at the mount hole instead of the sensor’s optical center, biasing every downstream measurement.
  • Missing base_footprint when the rest of your TF configuration expects it. Nav2 itself commonly defaults to base_link.

Frequently asked questions

Do you need URDF or SDF for ROS 2?

ROS 2 tooling such as robot_state_publisher, RViz, and MoveIt consumes URDF. Gazebo uses SDF internally and can import or convert URDF, although simulator-specific features are often best expressed in SDF or through the appropriate Gazebo integration packages. For a mobile robot pipeline, write URDF (via xacro) and add the simulator-specific configuration required by your chosen Gazebo version.

What is the difference between URDF and xacro?

URDF is the raw XML format ROS parses. Xacro is a macro preprocessor that expands to URDF. You write xacro for parameters, loops, and includes, then let the launch system expand it at runtime.

How accurate do inertia values need to be?

For kinematic tools (RViz, TF, Nav2) inertia is ignored. For Gazebo physics, values within roughly 20% of the true tensor are usually enough for stable simulation. Zero or wildly wrong values cause explosions and drift.

Can you skip collision meshes?

Only if you never plan to simulate contact or run self-collision checks. Nav2 costmap footprints are configured independently of URDF collision geometry, but collision geometry remains important for physics simulation and motion-planning applications. Simplified primitives are almost always the right answer.

How do you handle sensors like a lidar or depth camera?

Add a fixed joint from base_link to each sensor’s physical frame using the exact XYZ and RPY from your CAD. For cameras, add the optical frame as a child fixed frame following REP-103: Z forward, X right, Y down. Lidar frames normally follow the standard body-frame convention: X forward, Y left, Z up. Sensor drivers publish data in these frames.

What about tracked or articulated robots?

Tracks are often approximated as large wheels with high friction; detailed track physics requires a dedicated contact model or plugin and is not represented by URDF alone. Articulated joints (arms, pan-tilt) use revolute joints with limits and are driven by ros2_control position or velocity interfaces.

Where should you put the robot origin?

For wheeled ground robots, a common choice is to place base_link at the center of the drive axles and base_footprint directly below on the ground plane. This is compatible with common navigation and SLAM configurations, provided the corresponding frame names are configured consistently.

Move from a working URDF to a working robot

A verified URDF is the foundation for everything else in your ROS 2 stack: odometry, SLAM, navigation, manipulation, and sim-to-real transfer. If you would rather start from a proven chassis and description instead of building both hardware and URDF from scratch, Fictionlab develops customized platforms based on Leo Rover and Raph Rover, delivered with production-grade ROS 2 descriptions. Explore the process at Fictionlab custom robotics.


Read more