
OmArm One Series
Part 1: Build, statics and payload · Part 1.5: Drive it with a game controller · Part 2: Digital twin (this post) · Part 3: MoveIt 2 on real hardware (coming soon) · Part 4: Computer vision pick and place (coming soon) · Part 5: AI: grasping by name, by voice (coming soon)You need a finished OmArm One from Part 1, or any arm you have CAD for. No firmware is flashed in this part. The arm stays switched off except for the two measurements in section 8 and the checks in sections 11 and 13; everything else happens on a PC.
Video: the Part 2 walkthrough is coming to the OmArTronics YouTube channel; this paragraph is replaced by the player when it is live.
Part 1 gave the arm a body and a web app. Part 1.5 gave it a game controller. Both of those drive the arm by telling a joint to go to an angle, and both leave you doing the hardest part in your head: working out where the gripper will actually end up. Six joint angles are not a position in space. If you want the arm to reach a point on the table, you have to solve that backwards, and no amount of slider dragging will do it for you.
That is what a digital twin is for. It is a model of the robot, precise enough that software can answer questions about it: where is the gripper right now, will this motion hit the table, what joint angles put the tool at this point. In ROS 2 that model is a file format called URDF, and once it exists you get RViz to look at the robot, Gazebo to drop it into a physics world, and in Part 3 MoveIt to plan motions for it.
The catch, and the reason this post exists, is that a model exported from CAD is not automatically a model of your robot. It is a model of the CAD assembly. Those two differ in ways that look small on screen and are large in practice: a joint that turns the wrong way, a zero that sits 45 degrees off, a speed limit nineteen times faster than the servo can move. This post shows the export, then shows every single change I had to make afterwards and why, with the arithmetic, so you can redo it for your own arm rather than copy mine.

At a glance
What you’ll build: A ROS 2 package that describes OmArm One well enough for software to reason about it. You export the model from CAD, audit what came out, correct the joint directions and zeros so the model matches the machine on your bench, make it physically sane for a simulator, and end with the arm standing in RViz and running in Gazebo. One argument switches the same description between simulation and real hardware. If you only want the arm on screen tonight, take the finished package instead: the Route A block in section 4, then straight to section 11.
What you’ll learn: What a URDF actually is and the one transform formula behind every joint. How to prepare a CAD assembly so the export is usable. How to read an exported model critically instead of trusting it. The difference between a joint’s zero, its direction and its scale, why one measurement can never separate them, and how to bake a measured offset into the geometry correctly, one number when the axis is a coordinate axis, a composed rotation when it is tilted. Why a model that satisfies RViz can still fall over in Gazebo.
Time required: Ten minutes if you run the finished package. An evening if you export your own model from CAD, and two to four hours on top of that if you are also preparing the assembly and measuring joints.
Total cost: 0 €. Everything in this part runs on a PC.
Difficulty: Intermediate. No robotics maths required beyond one matrix identity, which is written out, and a library call for the tilted-axis case. Some terminal comfort helps.
Safety: Most of this part is a PC job. Three places need the powered arm: the two calibration measurements in section 8, the direction check in section 11, and the silhouette comparison in sections 11 and 13. Move one joint at a time, keep the workspace clear, and use the web app or the game controller from Part 1.5 to drive it, nothing here talks to the servos.
What you need: Ubuntu 24.04 with ROS 2 Jazzy, and CAD for an arm. The OmArm One model ships with the package, or use your own. A finished arm from Part 1 for the measurements and checks named under Safety.
Files you’ll need: فإن omarm_description package and the audit script, in the Part 2 download, which also carries this guide as a PDF.
1. What a digital twin is, and what it is not
A digital twin here means one specific thing: a description of the robot’s geometry, joint structure and mass, accurate enough that a planner can reason about it. It is not a rendering, and it does not replace the firmware: the arm’s motion engine still runs on the ESP32 exactly as it did in Part 1.
What the twin buys you comes in three steps, and this post covers the first two.
You can see the robot’s state. RViz draws the model at whatever joint angles you feed it. Feed it the arm’s real angles and you are looking at a live picture of a machine that has no screen. Feed it slider values and you have a way to check the model itself.
You can simulate it. Gazebo takes the same model, adds gravity, contact and a physics loop, and lets you run a motion into a table without owning a new table afterwards. This is where the mass and inertia values earn their place.
You can plan with it. MoveIt takes the model and answers the inverse question: which joint angles put the gripper there, and what path gets it there without a collision. That is Part 3, and it explains why this part is so fussy about correctness. A planner trusts the model absolutely. If your model says the arm is 10 mm shorter than it is, the planner will confidently plan the gripper into the table and every layer of software will report success.
The honest limit. The twin knows where you told the arm to go, never where it is. OmArm One has no encoders. Part 1 explains why that is a deliberate cost decision and what the firmware does about it. It means the twin can be perfectly correct and still show a pose the arm is not in, if something slipped. Chapter 12 of the shipped documentation calls this out; so does the confirm gesture from Part 1.5.
2. ROS 2, the short version
If you already work with ROS 2, skip to section 3. If Part 1 and 1.5 were your only robotics so far, this section is the vocabulary you need for the rest of the post. It is deliberately short: only the pieces this part actually uses.

A node is a program that does one job. robot_state_publisher is a node. RViz is a node. In Part 3, MoveIt’s planner is a node. They are separate processes, they can be started and stopped independently, and they can run on different machines.
Topics are named channels between nodes. A node publishes messages on a topic; any number of nodes subscribe. /joint_states carries the current joint angles. Nobody addresses anybody: the publisher does not know who listens. That is why you can swap a slider GUI for a real robot driver without changing anything downstream: the topic is the interface, and this post relies on that more than once.
TF answers one question: where is that, relative to this. A robot has many coordinate frames, one per link, plus the world. TF continuously publishes the transforms between them, so any node can ask “where is gripper_base_1 in world coordinates” and get an answer. robot_state_publisher is the node that produces the arm’s transforms: it reads your URDF, subscribes to /joint_states, and publishes a TF frame for every link. That single sentence is most of what happens when you run the RViz launch file in section 11.
A package is a folder with a package.xml. It groups files that belong together and tells the build system what they depend on. omarm_description is a package: the model, the meshes, the launch files. A workspace is a folder with a src/ directory holding packages, which you build with colcon build and then activate with source install/setup.bash. Forget that second command and the package is invisible, however plainly it sits in the folder.
A launch file starts several nodes at once, with their configuration. Nobody starts a robot by opening five terminals. display.launch.py in this package starts three nodes and hands the model to the one that needs it.
That is enough to follow everything below.
3. URDF: links, joints, and the one formula that matters
URDF stands for Unified Robot Description Format. It is XML, and it describes a robot as a tree of rigid bodies connected by joints. A tree, not a graph: apart from the single root, every link is the child of exactly one joint, and there are no loops.

A link is one rigid body, and it carries up to three descriptions of the same object:
<link name="link_1_1">
<inertial> <!-- mass and inertia: physics only, Gazebo reads this, RViz ignores it -->
<mass value="0.3149105967000781"/> <!-- the exporter writes full float precision; harmless, and left as it came -->
<inertia ixx="0.001311" iyy="0.001223" izz="0.000194" ixy="1e-06" iyz="0.000215" ixz="1e-06"/>
</inertial>
<visual> <!-- what you SEE -->
<origin xyz="0.03063 0.0 -0.09157" rpy="0 0 0"/>
<geometry><mesh filename="file://.../link_1_1.stl" scale="0.001 0.001 0.001"/></geometry>
</visual>
<collision> <!-- what collision checking uses -->
<origin xyz="0.03063 0.0 -0.09157" rpy="0 0 0"/>
<geometry><mesh filename="file://.../link_1_1.stl" scale="0.001 0.001 0.001"/></geometry>
</collision>
</link>
Two things in there need explaining. The scale="0.001 0.001 0.001" is because the STL is in millimetres and ROS works in metres. Get that wrong and you have a thousand-fold robot. The <origin> inside <visual> exists because the mesh carries its own coordinate origin, inherited from the CAD assembly where every part sits at its assembled position, while the link frame sits at the joint axis. The visual origin is the offset that maps one onto the other, which is why every mesh below the base has an origin like 0.03063 0.0 -0.09157, while base_link, sitting on the assembly origin, has 0 0 0.
One consequence follows from that, and it saved me an hour once: a correct transform tree does not prove a correct picture. If a visual origin is wrong, the link is in the right place and its mesh is drawn somewhere else. When a gripper part appeared to float next to the base in this project, that distinction is what ruled out a mesh problem in about a minute.
A joint says how the child moves relative to the parent:
<joint name="Revolute 2" type="revolute"> <origin xyz="-0.03063 0.0 0.049" rpy="-1.356194163 0 0"/> <parent link="base_link_upper_1"/> <child link="link_1_1"/> <axis xyz="1.0 -0.0 -0.0"/> <limit upper="3.141593" lower="0.0" effort="3.5" velocity="5.2"/> </joint>
| العنصر | Meaning |
|---|---|
origin xyz |
translation from the parent frame to the joint frame, in metres |
origin rpy |
rotation of the joint frame: roll, pitch, yaw, in radians |
axis |
the direction the joint rotates about, expressed in the joint frame |
limit lower / upper |
how far it may turn, in radians |
limit velocity |
maximum speed, in radians per second |
type |
revolute turns and has limits, fixed is glued, continuous turns forever |
And here is the one piece of mathematics in this post. The transform a joint produces is:
T_joint = T_origin · R_axis(q)
The fixed part first, then the rotation by the joint angle q about the axis. Section 8 uses exactly this formula to bake a measured calibration into the geometry, and it is also why the “just subtract from roll” shortcut you will find on forums is wrong for five of this robot’s six arm joints.

xacro, because raw URDF does not scale
Writing that XML by hand for twelve links is miserable, and there is no way to say “and if this is a simulation, use a different driver”. Xacro is a preprocessor that adds includes, variables, macros and conditionals to URDF, then expands to plain URDF before anything else sees it.
This package uses four files, and the main one includes the other three:
urdf/omarm.xacro links, joints, and the includes below urdf/materials.xacro colour definitions urdf/omarm.ros2control which driver moves the joints urdf/omarm.gazebo surface properties and the Gazebo plugin
You can always see what xacro actually produced, and you should, because that expanded file is what every other program reads:
xacro src/omarm_description/urdf/omarm.xacro > /tmp/robot.urdf
4. Setting up the PC
Everything below runs on Ubuntu 24.04 with ROS 2 Jazzy, which is the pairing this workspace was built and tested on. ROS 2’s own installation instructions change often enough that reproducing them here would age badly. Follow the official guide for your distribution, then add the pieces this part needs:
sudo apt install \ ros-jazzy-rviz2 \ ros-jazzy-robot-state-publisher \ ros-jazzy-xacro \ ros-jazzy-joint-state-publisher-gui \ ros-jazzy-ros2-control ros-jazzy-ros2-controllers \ ros-jazzy-ros-gz \ ros-jazzy-gz-ros2-control \ ros-jazzy-tf2-tools
xacro expands the model, joint_state_publisher_gui gives you the sliders in section 11, ros2_control و ros2_controllers provide the controller framework, ros_gz is the Gazebo integration, gz_ros2_control is the bridge that lets the same controllers drive a simulated robot, and tf2_tools draws the transform tree in section 13.
A workspace is a folder with a src/ inside it:
mkdir -p ~/omarm_one_ws/src
Everything else waits until there is a package to put in src/: either the one from the Part 2 download, or the one you export in section 6. Once it is there:
cd ~/omarm_one_ws colcon build source install/setup.bash
colcon build compiles and installs every package under src/. source install/setup.bash is what makes them findable, and it has to be repeated in every new terminal. Nearly every “package not found” for a package that is obviously sitting right there is a missing source.
Two habits save time later. Build one package while you are iterating on it:
colcon build --packages-select omarm_description
And after changing anything in urdf/, config/ or meshes/, rebuild before launching. Those files are installed into install/, and the launch files read the installed copy, not the one you just edited. Editing a URDF and seeing no change is almost always this.
Two ways from here
There are two routes through the rest of this post, and they meet again in the same terminal.

Route A, the finished package. Take omarm_one_ws from the Part 2 download, build it, launch it. Ten minutes and you are looking at the arm in RViz. Jump to section 11. The model in there is already matched to the machine on my bench, directions, zeros and speed limits, which is exactly the work sections 8 and 9 describe. Read them when you want to know لماذا it runs, or when your own arm is not this one.
Route B, your own CAD. That is sections 5 to 10 in order, then sections 11 to 13, and it is what you want if the arm on your desk is not an OmArm One. The export is the easy part; reading it critically is the work.
# Route A. The download unpacks to a folder OmArm-One-Digital-Twin with the # workspace omarm_one_ws inside it. Put the workspace in your home folder: cp -r ~/Downloads/OmArm-One-Digital-Twin/omarm_one_ws ~/ cd ~/omarm_one_ws colcon build --packages-select omarm_description source install/setup.bash ros2 launch omarm_description display.launch.py
5. Preparing the CAD in Fusion 360
The exporter is not magic. It reads the structure you built in Fusion, so almost everything about the quality of the export is decided before you run it.

One component per link. A URDF link is one rigid body, so every part that moves independently has to be its own component in Fusion. Parts that never move relative to each other, a bracket and the screws in it, belong in the same component, because splitting them only adds links that can never do anything.
Joints, in the order of the chain. Build the joints from the base outwards, each one connecting a component to the one before it. The exporter turns the Fusion joint tree directly into the URDF tree, so if the chain is wrong here it is wrong everywhere afterwards. Use revolute joints for the six arm axes and for the gripper gears; use rigid joints where parts are fastened together.

<limit lower> و <limit upper> in the URDF.Set the motion limits. In the joint dialog, enable motion limits and enter the range the joint can physically reach. For OmArm One that is 0 to 180 degrees on every arm joint, because a standard hobby servo has 180 degrees of travel, and the horn is bolted directly to it. These become the lower و upper values in the URDF, and in Part 3 MoveIt refuses to plan outside them.
Set the material. The exporter reads mass and inertia from the Fusion physical material. Set PLA on the printed parts before exporting, or you get the mass of whatever the default material is, and Gazebo will behave accordingly. Part 1 measured the finished arm at 1283 g, which is the number the model should be reproducing, provided the servos, bearings and fasteners inside each component carry their own masses in Fusion, not just the printed shells. The audit script prints the sum, so you can check it in one line rather than trusting it.
Check the origin. The assembly origin becomes the robot’s base frame. Put it where the arm meets the table, Z pointing up, and you save yourself a correction later.
What none of this gives you. The Fusion joint zero is wherever the components sit at the moment you run the exporter, it reads the current transforms, not the pose the joint was created in. The real arm’s zero is wherever the servo horn was when you bolted it on. Those two are not related in any way, and no amount of CAD care will align them. That gap is section 8.
6. Generating the ROS 2 package
I used fusion360-urdf-ros2 (MIT). It produces a complete ROS 2 package rather than a bare file: meshes, xacro files, launch files, package.xml, setup.py. Several exporters like it exist and they differ in detail; the important thing is what comes out, and the audit in the next section works regardless of which one you used.
Getting the exporter into Fusion

Download it from GitHub: the green Code button, then Download ZIP, then unpack it somewhere you will find again. What you get is a folder called fusion360-urdf-ros2-main, and one level down inside it a folder called Fusion_URDF_Exporter_ROS2. That inner folder is the one Fusion wants. It is the one holding Fusion_URDF_Exporter_ROS2.py and the matching .manifest, and Fusion looks for those two directly in whatever folder you hand it.

InstallURDFExporter.bat in the file list is the Windows shortcut described below.It registers as a script, not as an add-in; its manifest says "type": "script". That decides where it ends up. In Fusion, press Shift + S (the menu route is Utilities → ADD-INS) to open Scripts and Add-Ins. Click the green +, choose Script or add-in from device, and select Fusion_URDF_Exporter_ROS2. It then shows up under My Scripts and stays there between sessions.

On Windows there is a shortcut. The repository ships InstallURDFExporter.bat, which copies the same folder into %APPDATA%\Autodesk\Autodesk Fusion 360\API\Scripts\ for you; restart Fusion and the script is already there. On macOS you do it through the green +.

Two things about the run itself. It asks whether you are on Gazebo Harmonic or Gazebo Classic, on Jazzy the answer is Harmonic, and the choice changes the launch files it writes, not the model. And it restructures your design document: every component is copied to a new one ending in _1, and the originals are renamed old_component (1), old_component (2) and so on. That is where the _1 in link_1_1 و gripper_base_1 comes from, and it is the reason to close Fusion afterwards without saving.
What it produced:
omarm_description/ ├── urdf/ │ ├── omarm.xacro 341 lines: 12 links, 11 joints │ ├── materials.xacro colour definitions │ ├── omarm.gazebo surface properties per link │ └── omarm.ros2control an empty <robot> element ├── meshes/ 12 STL files, one per link ├── launch/ │ ├── display.launch.py RViz with sliders │ └── gazebo.launch.py spawn into Gazebo ├── config/ RViz configurations, bridge config ├── package.xml └── setup.py
The meshes are the real geometry and they are correct, in the finished model they are byte-for-byte the same files the exporter wrote. None of the corrections in this post touches the 3D data. Every fix is a number or an attribute in a text file: the xacro for the model, one YAML file for the controllers, and two lines of Python in the Gazebo launch file. That also means you can redo all of it in a text editor, and that regenerating from CAD will wipe the xacro half of it.
7. Reading the export critically
Before changing anything, find out what you actually have. Expand the xacro and audit it. The script below is the one that ships with this part; it only checks things that have genuinely caught a problem in this project.
cd ~/omarm_one_ws colcon build --packages-select omarm_description && source install/setup.bash xacro src/omarm_description/urdf/omarm.xacro > /tmp/robot.urdf python3 tools/check_description.py /tmp/robot.urdf
The build and source are not optional here. The xacro files reference each other and every mesh through $(find omarm_description), which xacro can only resolve for a package that is installed and on the path. It also means the expansion pulls the installed copies of materials.xacro, omarm.ros2control و omarm.gazebo, so after editing any of them, rebuild before you audit, or you will be auditing the previous version.
check_description.py sits in tools/ at the root of the workspace, next to src/, not inside the package. If you came the Route B way, copy tools/ from the download into your workspace root first. It is a single file with no dependencies beyond the Python standard library.


Run against the raw export it prints a full joint table; abridged to the lines that matter here:
links : 12
joints : 11
root link(s) : ['base_link']
[... joint table, 11 rows ...]
zero principal inertia: ['gripper_gear_left_1.iyy', 'gripper_gear_right_1.iyy',
'gripper_finger_left_1.iyy', 'gripper_finger_right_1.iyy']
mimic joints : none
gazebo plugin(s) : none
17 problem(s):
- Revolute 1: velocity 100.0 rad/s = 5730 deg/s - an exporter default, not a servo
- Revolute 1: effort 100 Nm - an exporter default, not a servo torque
[... the same pair for Revolute 2, 3, 4, 5, 6, 8 and 9; there is no Revolute 7 ...]
- zero inertia makes the physics engine misbehave: [...]
Eight velocity complaints, eight effort complaints and one inertia complaint make the seventeen.
Three kinds of problem, plus one line in the table the script prints without flagging, mimic joints: none. Four findings, and all four are fixed in section 9.
Every movable joint says velocity="100". The three fixed joints carry no <limit> at all. 100 is 100 radians per second, 5730 degrees per second, roughly nineteen times what the servo can do. It is not a harmless default: MoveIt uses this as an upper bound when it converts a planned path into a timed trajectory, so it will happily produce a motion the arm cannot physically follow.
Every movable joint also says effort="100". 100 newton metres. The DS3240-180 delivers 36 kg·cm at 5 V, which is 3.5 N·m, and the SG90 about 0.18. It is the same default as the velocity, from the same place, and it matters in the same way: a planner that believes it will hand you a trajectory the servo cannot hold.
Four inertia values are exactly zero. The gripper parts are small enough that Fusion rounded iyy to zero at the precision the exporter writes. A rigid body with zero inertia about an axis is physically meaningless, and physics engines react to it in creative ways.
There is no mimic joint. The gripper’s two fingers are driven by one servo through a gear pair. In the export they are two independent joints, so nothing links them. Read the axis column before you write one: the export gives Revolute 8 the axis 0 -0.173648 0.984808 و Revolute 9 exactly the negative of it. The axes already point opposite ways, so a multiplier of 1 makes the gears counter-rotate, which is what a meshing pair does. If your export has them parallel instead, you need -1.
And two things no script can check, because they need the real robot standing next to you:
The joint directions. Four of the six arm joints turn the opposite way from the real arm.
The joint zeros. Two joints are calibrated 45 and 55 degrees away from where the CAD thinks their zero is.
Those six items are the model-correctness job, and the two sections take them the other way round. Section 8 does the two that need the bench, because the rest is wasted effort on a model that turns the wrong way. Section 9 then works through seven changes the physics engine and the control stack need, four of which are on the list above.
8. Making the model turn the way the arm turns
You need RViz up for this section: the launch command is at the top of section 11. Run it now and come back.
This is the part that cannot be automated, because it needs a human looking at a robot.
There are three separate claims hiding in the phrase “the model matches the arm”, and mixing them up is the source of most calibration pain.

Zero. When the model says 0 degrees, the real joint is at its own zero.
Direction. When the model’s angle increases, the real joint turns the same way.
Scale. Ten degrees in the model is ten degrees on the arm.
For a directly driven servo joint there is no gearbox to change the scale, but the firmware’s pulse-to-angle map can: a servo told that 500 to 2500 µs is 180° often travels a little more or less. Two measurements far apart catch that; one never will. In practice you are fixing direction and zero, and checking that scale is not hiding behind them.
One measurement cannot separate zero from direction. Drive a joint to model 90° and measure 110° on the arm, and you know something is wrong, but not whether it is a +20° offset or a mirrored direction with some other offset. Always take two measurements, at least 60 degrees apart.
Direction: flip the axis in the model
Direction is fixed in the URDF by negating the joint’s axis vector. On this arm four joints needed it:
| مشترك | exported axis | corrected axis |
|---|---|---|
| Revolute 3 | 1.0 0.0 -0.0 |
-1.0 0.0 0.0 |
| Revolute 4 | -0.0 0.984808 0.173648 |
0.0 -0.984808 -0.173648 |
| Revolute 5 | 1.0 0.0 -0.0 |
-1.0 0.0 0.0 |
| Revolute 6 | -0.0 0.984808 0.173648 |
0.0 -0.984808 -0.173648 |
Two of those axes are not aligned with any coordinate axis at all. 0 -0.984808 -0.173648 is a direction tilted about 10 degrees out of the Y axis in the YZ plane, which is what you get when a joint in the CAD is mounted at an angle. Remember that vector; it comes back in a moment and it is why the popular shortcut fails.
Four joints also needed their frame rotated, which is a different thing from flipping the axis: the axis says which way the joint turns, rpy says how the joint frame itself is oriented before it turns.
| مشترك | exported rpy | corrected rpy | what it is made of |
|---|---|---|---|
| Revolute 1 | 0 0 0 |
0 0 -1.570796 |
base frame yawed −90° |
| Revolute 2 | 0 0 0 |
-1.356194163 0 0 |
orientation fix −0.570796 و the measured 45° |
| Revolute 3 | 0 0 0 |
1.359931089 0 0 |
orientation fix +0.4 و the measured 55° |
| Revolute 5 | 0 0 0 |
+1.570796 0 0 |
wrist frame rolled +90° |
That table hides a two-stage process, and the stages are found in completely different ways. Stage one is orientation: with the axes flipped, you set each joint frame so that it sits the way the real joint sits and turns in the plane it should, the shape is right, the numbering is not yet. Those are the round-ish numbers: -1.570796, +1.570796و -0.570796 و 0.4 sitting inside joints 2 and 3. You find them by looking at the model next to the machine and reasoning about which way the frame has to sit; they are properties of how this CAD was built, so yours will differ. Stage two is the measured zero, and that is arithmetic on top of stage one. The rest of this section is stage two.
A note if you read the shipped source. The comment on Revolute 2 in
omarm.xacrocarries the same arithmetic and says where each term comes from: the-0.570796is the stage-one orientation fix, not a CAD value. The raw export hasrpy="0 0 0"on that joint, open the pre-correctionomarm.xacroand look; the audit script prints axes, not origins.
Zero: two places to put the correction, and only one good one
Once directions are right, measure. Set every slider in RViz to 0, then drive the real joint until the arm stands in the same pose as the picture, and read the servo command that got you there. Be precise about which way the mismatch runs, because the sign of everything that follows depends on it.
On this arm, joint 2 matched the model’s zero pose at servo 45°, and joint 3 at servo 55°. The uncorrected model’s numbers ran that far behind the servo. Write it down as one equation before touching anything, because everything below follows from it:
model_old = servo - 45° (joint 2, measured; joint 3 is the same with 55)
You can correct that in two places.

Option A: an offset in the driver. إن omarm.ros2control file can carry a servo_offset_deg parameter, and the hardware driver converts between URDF radians and servo degrees. Simple, and simulation keeps the pure CAD model. But you are then living with model = servo - 45 forever: this joint reads −45 to 135 while every other joint reads 0 to 180. The joint limits have to move with it, and simulation still shows the wrong pose, because Gazebo never goes through that driver.
Option B: rotate the joint’s origin in the URDF. The correction lives in the geometry. The URDF angle then equals the servo angle, the range stays a clean 0 to 180 like every other joint, and it fixes simulation too, because simulation reads the same URDF.
This project uses Option B. The reasoning is simple: the URDF should describe the robot you actually own. If the real joint’s zero is 45 degrees away from the CAD zero, the model is wrong, and correcting the model fixes hardware and simulation at once.
The arithmetic, and the trap in it
Remember T = T_origin · R_axis(q). You want the model at servo angle s to draw what it currently draws at s - d, where d is the measured offset, +45° for joint 2, +55° for joint 3, the amount the old numbers lag the servo. So you need:
R_new · R_axis(s) = R_old · R_axis(s − d)
Rotations about the same axis commute and simply add, which collapses the whole thing to:
R_new = R_old · R_axis(−d)
That is exact, and it is لا the same as subtracting d from roll. Here is where the tilted axes bite.

| مشترك | axis | correct operation |
|---|---|---|
| Revolute 2 | +X |
roll − d |
| Revolute 3 | -X |
roll + d, the sign flips |
| Revolute 5 | -X |
roll + d |
| Revolute 1 | +Z |
yaw − d, not roll |
| Revolute 4 | 0 -0.985 -0.174 |
no single rpy component exists |
| Revolute 6 | 0 -0.985 -0.174 |
no single rpy component exists |
Applying the joint-2 recipe to joint 3 would have grown its error from 55° to 110° rather than removing it. Joints 4 and 6 cannot be corrected by editing one number at all: their axis is tilted 10° out of Y in the YZ plane, so the correction spreads across all three rpy values.
Worked through for the two joints that were measured:
Joint 2, axis +Xو rpy is a pure X rotation, so the two add directly:
rpy_roll = -0.570796 (orientation fix) - 0.785398163 (45°) = -1.356194163
Joint 3, axis -X, so the offset is added:
rpy_roll = 0.4 (orientation fix) + 0.959931089 (55°) = 1.359931089
Note the nine decimal places. Rounding to six gives about 10⁻⁵ degrees of error, which is physically irrelevant but pollutes any verification you do afterwards; the extra digits cost nothing.
The identity above still tells you what to do, it just stops being arithmetic you can do in your head. R_new = R_old · R_axis(-d), where R_axis is a rotation of -d about the joint’s own axis vector, Rodrigues’ formula gives it from the axis and the angle. Build R_old from the stage-one rpy in URDF’s convention, R = Rz(yaw)·Ry(pitch)·Rx(roll), which is scipy’s extrinsic 'xyz'; multiply; read the new roll, pitch and yaw back out of the product. A few lines of scipy.spatial.transform.Rotation (sudo apt install python3-scipy if you do not have it):
from scipy.spatial.transform import Rotation as Rot
import numpy as np
axis = np.array([0.0, -0.984808, -0.173648]) # the joint's axis after the direction fix
d = np.deg2rad(12.0) # the measured offset, in radians
R_old = Rot.from_euler('xyz', [0.0, 0.0, 0.0]) # the rpy after stage one
R_new = R_old * Rot.from_rotvec(-d * axis)
print(R_new.as_euler('xyz')) # the three numbers for the file
Run that on joint 2 (axis 1 0 0, d = 45°, stage-one rpy -0.570796 0 0) and it prints [-1.35619416 0. 0.]: the number worked out by hand above. That is the check that the general form is the same rule, not a different one.
I never had to use it on OmArm One: joints 4 and 6 came out of the CAD with their zero already on the machine, so there was nothing to correct. It is here so you are not stuck if yours are not.
Do not apply the correction twice. Once the 45° lives in joint 2’s
rpyفإنservo_offset_degfor that joint must be gone. Leaving both in place counts it twice, and the arm ends up 45° off in the other direction. The shippedomarm.ros2controlcarries a comment on exactly that joint saying so.The offset moves numbers, never shapes. Whichever option you pick, the correction changes which number is displayed. The shape RViz draws at a given number comes from the meshes and the joint origins. If after correcting the numbers agree but the picture still does not look like the arm, your CAD zero pose itself is wrong, and no offset will fix that. Compare the silhouette, not the value.
9. What Gazebo needs that RViz does not
RViz is forgiving. It draws whatever the model says and never asks whether it makes physical sense. Gazebo runs a physics loop, and a physics loop rejects things RViz never questions. Three of the seven changes below, the mimic joint, the speed limits and the torque limits, are not strictly Gazebo’s doing; robot_state_publisher needs the first, MoveIt the other two. They sit here because this is the pass where you stop having a picture and start having a model that other software can act on.
The robot needs to be attached to the world. A URDF describes a robot floating in nothing. RViz is happy with that; Gazebo drops it. The fix is a link called world and a fixed joint tying the base to it:
<link name="world"/> <joint name="world_to_base" type="fixed"> <parent link="world"/> <child link="base_link"/> <origin xyz="0 0 0" rpy="0 0 0"/> </joint>
That also changes what the root of the tree is, the audit script now reports world بدلاً من base_link, and it means the spawn height in the launch file should be 0, not the 32 cm the exported launch file used. An arm spawned 32 cm up and then bolted to the world stays 32 cm up.
Inertia must not be zero. The four gripper parts came out with iyy="0.0", for the reason in section 7.
The fix here was to fill iyy in with the link’s own transverse value rather than to recompute it:
<!-- gripper_finger_right_1: iyy was 0.0 --> <inertia ixx="3e-06" iyy="3e-06" izz="3e-06" ixy="-0.0" iyz="-0.0" ixz="0.0"/>
Be honest with yourself about what that is: a placeholder that makes the body physically valid, not a measured property, and it is an upper bound, not an estimate. The term that rounded to zero is by definition the smallest of the three at the exporter’s precision: iyy came out below 5·10⁻⁷ while ixx و izz survived, at 1·10⁻⁶ on the two gears and 3·10⁻⁶ on the two fingers. Substituting the transverse value therefore overstates it. On parts weighing five and ten grams at the end of a 1.28 kg arm that changes nothing you can see. On a heavier link it would, and there you go back to the CAD and export the tensor properly.
The two fingers must be coupled. Mechanically the gripper is one servo driving a gear pair. In URDF that is a mimic joint:
<joint name="Revolute 9" type="revolute"> ... <mimic joint="Revolute 8" multiplier="1" offset="0"/> </joint>
Revolute 9 has no motor and is deliberately لا listed in ros2_control. robot_state_publisher and MoveIt compute its angle from Revolute 8. In Gazebo that means the right gear’s physics joint is free, the mimic is honoured by TF and the planner, not by the simulator, which is acceptable for a finger nothing pushes on, and something to revisit the day the gripper has to hold a load in simulation. Command it as well and you have two controllers fighting over one mechanism.
Something has to actually drive the joints. A URDF describes a robot; it does not move one. In simulation that job goes to gz_ros2_control, which runs inside Gazebo, reads the <ros2_control> block and offers the joints to the normal ROS 2 controller framework. It is loaded by a plugin block in omarm.gazebo, wrapped so that it is only emitted for simulation:
<xacro:if value="$(arg use_sim)">
<gazebo>
<plugin filename="gz_ros2_control-system"
name="gz_ros2_control::GazeboSimROS2ControlPlugin">
<ros>
<remapping>/controller_manager/robot_description:=/robot_description</remapping>
</ros>
<parameters>$(find omarm_description)/config/ros2_controllers.yaml</parameters>
</plugin>
</gazebo>
</xacro:if>
إن <parameters> line points at the controller configuration, which lists the controllers and which joints each one owns:
controller_manager:
ros__parameters:
update_rate: 100 # Hz
omarm_arm_controller:
type: joint_trajectory_controller/JointTrajectoryController
omarm_gripper_controller:
type: joint_trajectory_controller/JointTrajectoryController
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
omarm_arm_controller:
ros__parameters:
joints: ["Revolute 1", "Revolute 2", "Revolute 3",
"Revolute 4", "Revolute 5", "Revolute 6"]
command_interfaces: [position]
state_interfaces: [position, velocity]
omarm_gripper_controller:
ros__parameters:
joints: ["Revolute 8"]
command_interfaces: [position]
state_interfaces: [position, velocity]
Revolute 9 is missing from that list, and that is deliberate: it is the mimic joint and has no motor to command. And update_rate: 100 is the rate of the controller loop, not of Gazebo’s physics step, which runs faster. The two are not independent: gz_ros2_control ticks the controller manager from inside Gazebo’s own update, and simply skips iterations until the 100 Hz period is due.
In the Part 2 download this file sits in omarm_description/config/ros2_controllers.yaml, so the description package is self-contained and runs with nothing from Part 3 installed. Part 3’s MoveIt package brings its own copy; when both are on the path, the one named in omarm.gazebo is the one that gets loaded.
The speed limits must be honest. velocity="100" became velocity="5.2", which is 298 °/s. The DS3240-180 datasheet gives 0.20 s per 60° at 5 V, so 300 °/s unloaded, and 5.2 rad/s sits just under it. (The sheet also lists 0.17 s per 60° at 6.8 V, about 353 °/s. Between the two rows 6 V interpolates to roughly 0.183 s, about 328 °/s. Part 1 works in the 5 V case throughout because that is the pessimistic floor; pick the row that matches your supply and stay conservative.) The gripper’s SG90 got 8.0 rad/s, which is 458 °/s. Its datasheet says 0.1 s per 60° at 4.8 V, so about 600 °/s, the small servo genuinely is the fastest thing on the arm, and 458 °/s is deliberately under the sheet rather than on it.
The torque limits had to come down with them. effort="100" became effort="3.5" on the six arm joints and effort="0.18" on the gripper. 36 kg·cm at 5 V is 3.5 N·m; the SG90’s 1.8 kg·cm is 0.18. Nothing in Part 2 enforces this, RViz ignores it and the position controllers in Gazebo drive the joint directly, so you will not see a difference today. It is there for Part 3, where a planner reads it, and because a file that says a hobby servo delivers 100 N·m is simply wrong.
The gripper limit got 1.6 rad instead of π/2. 1.6 rad is 91.67°, so the limit now sits 1.7° beyond the 90° the mechanism uses. That margin is deliberate. A limit written as 1.570796 invites a failure where something computes π/2 as 1.5707963267948966, compares it against the six decimals in the file, finds the value fractionally out of bounds and refuses to plan, while every layer reports success and the arm quietly does nothing. Park a joint on an exact limit and you will meet this. The arm joints keep 3.141593, which is safe for the opposite reason: rounded up, it sits a hair above π rather than below it.
10. One model, two back ends
The same robot has to be driven by two completely different things: in simulation by Gazebo’s physics, on the bench by the ESP32 over USB. I want one description for both, because two URDFs would be out of sync inside a week. So the model takes an argument:

<xacro:arg name="use_sim" default="false" />
<ros2_control name="OmArmHardware" type="system">
<hardware>
<xacro:if value="$(arg use_sim)">
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</xacro:if>
<xacro:unless value="$(arg use_sim)">
<plugin>omarm_hardware/OmArmSystem</plugin>
<param name="device">auto</param>
<param name="baudrate">115200</param>
...
</xacro:unless>
</hardware>
<!-- everything below here is identical for both -->
<joint name="Revolute 1">
<command_interface name="position"/>
<state_interface name="position"><param name="initial_value">1.5708</param></state_interface>
<state_interface name="velocity"/>
</joint>
...
Same joint names, same interfaces, same limits. What differs is the hardware back end and, with use_sim:=true, an added Gazebo plugin block, so the diff is longer than one line, because the hardware branch also carries eleven driver parameters that simulation does not need. You can see it for yourself:
xacro src/omarm_description/urdf/omarm.xacro > /tmp/robot_hw.urdf xacro src/omarm_description/urdf/omarm.xacro use_sim:=true > /tmp/robot_sim.urdf diff /tmp/robot_hw.urdf /tmp/robot_sim.urdf
Two things have to be true for that switch to work, and during this project’s development they were true one at a time.
The launch file has to actually pass the mapping. The exported one did not:
# exported
robot_description_config = xacro.process_file(robot_description_file)
# corrected
robot_description_config = xacro.process_file(
robot_description_file,
mappings={'use_sim': 'true'})
And the xacro has to declare the argument, before the includes that read it, because xacro processes the file top to bottom and omarm.ros2control is included, not inlined:
<xacro:arg name="use_sim" default="false" /> <xacro:include filename="$(find omarm_description)/urdf/materials.xacro" /> <xacro:include filename="$(find omarm_description)/urdf/omarm.ros2control" /> <xacro:include filename="$(find omarm_description)/urdf/omarm.gazebo" />
One half of this fails loudly, the other silently. Know which is which.
Loud: if the argument is not declared before the includes that read it, expansion aborts with
Undefined substitution argument use_simand a non-zero exit, but only on the run that passes no mapping, which is the RViz path. Passinguse_sim:=truewrites the value into the substitution table directly, so that run keeps working and hides the problem. You find it the moment you launch RViz.Silent: if the launch file forgets
mappings={'use_sim': 'true'}, nothing complains at all. The declared defaultfalseapplies, Gazebo comes up having loaded the hardware driver, and it tries to open a serial port that is not there. That is the one that cost this project time: the intent sat in a comment while the line that implements it was missing. A comment is not a mechanism, and thediffabove is how you check.
11. Looking at it: RViz
With the model corrected, this is the fastest feedback loop you have.
cd ~/omarm_one_ws colcon build --packages-select omarm_description source install/setup.bash ros2 launch omarm_description display.launch.py


robot_state_publisher turns them into transforms, RViz draws the meshes at those transforms. The coloured arrows are the TF display, one triad per link. No hardware anywhere.Three nodes start. The joint state publisher GUI gives you a slider per driven joint and publishes them on /joint_states, seven here, not twelve: the four fixed joints cannot move, and Revolute 9 is computed from Revolute 8 by the mimic tag rather than commanded. robot_state_publisher reads the URDF, subscribes to /joint_states, and publishes a TF frame for every link. RViz draws the meshes at those frames.
That chain reappears unchanged in Part 3: only the source of /joint_states changes, from a slider window to the real arm.
Check the Fixed Frame first: it has to name a frame that is actually being published. The shipped display.rviz uses base_link; world works just as well, because the two are joined by a fixed joint. What does not work is RViz’s own default, map, which nothing in this package publishes, that is the case where you get a red error and an empty 3D view. And if the shipped config does not load, add the RobotModel display yourself and set its Description Topic to /robot_description.
Then check the model, in this order:
Does the shape look like the arm at zero? Set every slider to 0 and compare with the real arm standing in its zero pose. This is the silhouette test, and it catches the errors no offset will fix.
Does each joint turn the right way? Move one slider at a time in the positive direction and check the real arm turns the same way. This is the direction claim from section 8.
Do the numbers match? Drive the real arm to 90° on one joint and set the slider to 90°. Same picture, same number.

12. Dropping it into physics: Gazebo
ros2 launch omarm_description gazebo.launch.py

<urdf-string>, because the model was spawned from the /robot_description topic rather than from a file on disk. This is where mass and inertia stop being decoration; RViz ignored them completely.This launch file starts Gazebo with an empty world, starts robot_state_publisher و ros_gz parameter bridge, then waits five seconds and spawns the robot from the /robot_description topic. The wait matters: spawning into a simulator that has not finished starting fails in a way that looks exactly like a broken model.
Because the URDF was processed with use_sim:=trueفإن gz_ros2_control plugin comes up inside Gazebo and starts a controller manager for the joints. The controllers themselves still have to be activated, in a second terminal sourced the same way (the first one is busy running Gazebo):
cd ~/omarm_one_ws && source install/setup.bash ros2 control list_controllers ros2 control load_controller --set-state active joint_state_broadcaster ros2 control load_controller --set-state active omarm_arm_controller ros2 control load_controller --set-state active omarm_gripper_controller
Then you can command a trajectory directly, without a planner:
ros2 topic pub --once /omarm_arm_controller/joint_trajectory \
trajectory_msgs/msg/JointTrajectory \
'{joint_names: ["Revolute 1","Revolute 2","Revolute 3","Revolute 4","Revolute 5","Revolute 6"],
points: [{positions: [1.57, 1.2, 1.57, 1.57, 1.57, 1.57], time_from_start: {sec: 3}}]}'
Note the quotes around every joint name. The names carry a space, Revolute 1 rather than Revolute_1, because that is what the CAD export produced. Strictly, both layers cope without them: a YAML plain scalar may contain spaces, so [Revolute 1, Revolute 2] parses to exactly the same strings as the quoted form, and the whole payload above sits inside single quotes, so the shell never sees the space either. Quote them anyway. The moment a name picks up a comma or a bracket, YAML’s flow syntax breaks on it; the moment someone reformats the command without the outer single quotes, the shell splits Revolute 1 into two words and the controller is asked for a joint called Revolute. It is a one-character habit that removes a class of failure.
Renaming them would be cleaner. It would also touch the URDF, the controller configuration, and in Part 3 the semantic description and the driver as well. Living with the names is a deliberate decision.
13. Verifying the twin
One check runs on the file, the other on the bench.
Mechanical, on the file. The audit script from section 7, run against both expansions:
cd ~/omarm_one_ws colcon build --packages-select omarm_description && source install/setup.bash xacro src/omarm_description/urdf/omarm.xacro > /tmp/robot.urdf python3 tools/check_description.py /tmp/robot.urdf xacro src/omarm_description/urdf/omarm.xacro use_sim:=true > /tmp/robot_sim.urdf python3 tools/check_description.py /tmp/robot_sim.urdf
Both must end in all checks passed. It exits non-zero otherwise, so it belongs in whatever passes for your CI. Here is the before and after in one table:
| Check | Raw export | After post-processing |
|---|---|---|
| links / joints | 12 / 11 | 13 / 12 |
| root link | base_link |
world |
| arm joint velocity | 100 rad/s = 5730 °/s | 5.2 rad/s = 298 °/s |
| gripper joint velocity | 100 rad/s = 5730 °/s | 8.0 rad/s = 458 °/s |
| zero inertia values | 4 | 0 |
| mimic joints | none | Revolute 9 mimics Revolute 8 |
joints under ros2_control |
none | 7 |
Gazebo plugin with use_sim:=true |
none | gz_ros2_control-system |
| joint effort limit | 100 N·m | 3.5 N·m arm, 0.18 N·m gripper |
| total mass | 1284.4 g | 1284.4 g, against 1283 g measured |
| audit result | 17 problems | all checks passed |
By eye, against the machine. Drive the arm to three or four clearly different poses and compare the silhouette each time. One pose only ever proves the zero at that pose. If the silhouette matches at one pose and not at others, you have a direction or scale problem, so go back and measure two points at least 60° apart. If it never matches although the numbers agree, the CAD zero pose itself is wrong, section 8.
You can also inspect the transform tree directly. The quickest look is inside RViz: expand the TF display and it lists every frame it is receiving, each with its own status.

world among them, all reporting Transform OK. If a link is missing from this list, robot_state_publisher is not publishing its frame, most often because nobody is publishing that joint’s angle on /joint_states (a moving joint’s transform only appears once an angle arrives), otherwise because the joint that should carry it is not in the URDF. It is not the mesh.For the tree structure rather than the list, parent, child, and the publishing rate on every edge, write it out as a PDF:
ros2 run tf2_tools view_frames
That drops a frames_<timestamp>.pdf in the current directory. It is the better artefact to attach to a bug report; the RViz list is the better one for a five-second check.
14. Troubleshooting
| Symptom | السبب | إصلاح |
|---|---|---|
Package 'omarm_description' not found |
the workspace was built but not sourced | source install/setup.bash in every new terminal |
| RViz shows nothing, red error about a frame | Fixed Frame names a frame nobody publishes, usually RViz’s default map |
set Fixed Frame to base_link or world |
| Meshes missing, links drawn as small axes | the STL paths do not resolve, or the package was built before the meshes were added | rebuild; check setup.py installs meshes/ |
| Robot is a thousand times too big | mesh scale missing | every mesh needs scale="0.001 0.001 0.001" for millimetre STLs |
| A link is in the right place, mesh is elsewhere | wrong <origin> inside <visual> |
compare with the CAD assembly coordinates; the TF tree being correct does not prove this |
| Arm falls through the floor in Gazebo | no world link and fixed joint |
section 9 |
| Arm collapses under gravity | no controllers active, so nothing holds the joints | ros2 control load_controller --set-state active ... |
Simulated joints never move, log mentions /dev/ttyUSB0 |
the model was expanded with the hardware back end | check use_sim is declared in omarm.xacro و passed by the launch file |
| Controller reports an unknown joint | the name in the config does not match the URDF character for character, a lost space, an underscore, a stray bracket | copy the name straight out of the audit script’s joint table |
| Right finger stays still, left one moves | the mimic joint is missing or Revolute 9 is commanded |
one mimic tag, and keep Revolute 9 out of ros2_control |
Joint ... from the starting state is outside bounds |
the joint sits exactly on its limit, and the stored limit has fewer decimals than the computed value | leave a degree or two of margin at the end you park on |
| A joint moves the wrong way | axis direction | negate the axis vector, not the sign of your commands |
| Model and arm agree at one pose, not at others | direction or scale, not zero | re-measure with two points at least 60° apart |
15. FAQ
What is a URDF, in one sentence? An XML file describing a robot as a tree of rigid bodies connected by joints, with the geometry, mass and limits each of them has.
Do I need the real robot to follow this? Not for most of it, RViz and Gazebo run entirely on a PC. The powered arm comes in for section 8 and for the two checks after it. Skip those and you still get a working twin of the CAD assembly; you just have no evidence it matches a particular machine.
Can I export a URDF from SolidWorks or Onshape instead? Yes, both have exporters. What comes out differs in detail, but the audit in section 7 and the corrections in sections 8 and 9 apply the same way.
Why does the model use meshes for collision as well as for visuals? Accuracy, at the cost of computation. Simplified collision shapes are faster and are the usual production choice; for a desktop arm with twelve small parts the saving does not justify a second set of files.
Why not just rename the joints to remove the spaces? Because of the blast radius, section 12 has the list.
Does the twin know where the arm really is? No. OmArm One has no encoders, so the twin shows the commanded pose. Part 1 explains why, and the firmware refuses to move at all after a power loss until a human confirms the pose.
Why is Revolute 7 missing? It is Rigid 7, a fixed joint between the tool link and the gripper base. The exporter numbers all joints in one sequence regardless of type.
Will regenerating from CAD keep my corrections? No. The corrections live in the files the exporter writes, the xacro and the launch file, and a fresh export overwrites those. Keep the corrected package in version control and diff a new export against it instead of replacing it.
16. What comes next
The arm now has a model that matches it: right shape, right directions, right zeros, right limits, in a package that runs in Gazebo and is already wired, with one argument, for the hardware driver Part 3 adds. What it does not have yet is anyone using that model to decide anything.
That is Part 3. MoveIt 2 takes this description and answers the inverse question: which joint angles put the gripper at a point in space, and what path gets it there without hitting the arm itself or the table. The ros2_control layer sketched in section 10 gets its second back end, the ESP32 driver, and a trajectory planned against the simulation runs on the real arm without changing a line. The groundwork is already here: use_sim:=false is the branch Part 3 fills in.
If you build a twin of your own arm, I would like to see the before-and-after audit. And if the exporter you used produces something this post does not cover, tell me which one, the checklist in section 7 grows with every report.
Resources
- The arm itself on GitHub, CAD analysis, electronics and firmware: github.com/OmArTronics/OmArm-One
- The same workspace plus this guide as a 48-page PDF: Part 2 download
- The arm itself, STLs, firmware and both build guides: OmArm One complete package
- Part 1: the build · Part 1.5: the game controller
- The same workflow on the smaller arm: OmArm Zero URDF tutorial