Simulation (Gazebo)¶
The simulation lets the vision pipeline drive a physically accurate 3D hand model before the real hardware exists, useful for testing tracking, tuning, and the control pipeline without waiting on a 3D printer or wiring.
Stack: Gazebo Sim (Harmonic) + ROS2 Jazzy, run in Docker. This is a separate track from the
isaac-sim-setup.md runbook in the repo root. See Isaac Sim Notes for why that
exists and why it's unrelated to this hand project.
The vision pipeline's HUD (top left) driving the simulated hand in real time:
hand_tracker.py --no-serial --gazebo.

Setup¶
cd sim/docker
docker build -t robotics-gazebo .
./run.sh
run.sh mounts the whole sim/ directory to /sim in the container (not just worlds/, since
mesh files live under sim/models/), sets GZ_SIM_RESOURCE_PATH=/sim/models, and names the
container robotics_gazebo_sim, a stable name that other tooling (like the vision bridge) targets
with docker exec.
World files¶
sim/worlds/box_world.sdf: a minimal learning rig (pendulum, hinge, box) for getting a feel for Gazebo joints and physics before touching the hand model. Uses thedartsimphysics engine.sim/worlds/hand_world.sdf+sim/models/hand/: the actual hand model. Usesbullet-featherstone, notdartsim. See why below.
The hand model's provenance¶
sim/models/hand/hand.urdf (and its converted .sdf) came from the left-hand subtree
(i01.leftHand.* links/joints) of the whole-body URDF in
Sentience-Robotics/inmoov_ros_sim, not
written from scratch. It needed real adaptation before it was usable here, not just extraction:
- Rescaled ~10x. The source meshes/joint origins were sized as if the palm were ~88cm, measured
directly off the STL bounding boxes (~0.79×0.88×1.09 in source units). All mesh
scaleattributes and origin translations were multiplied by 0.1 to bring the palm to a real ~9-11cm hand size. Rotations were left untouched. - Collapsed to 1 actuated joint per finger, matching the real hardware's 1-servo-per-finger
tendon design (see Hardware & BOM). Each
finger's proximal joint (
index_link_joint,majeure_link_joint,pinky_link_joint,ringFinger_link_joint,thumb1_link_joint) is the driver; the other 2 joints per finger<mimic>it 1:1. The pinky/ring-finger abduction joints were converted totype="fixed", so no spread motion, since the real hardware doesn't have it either. - Added collision and inertial data. The source URDF had
<visual>meshes only, no<collision>or<inertial>at all, so nothing would have had contact physics or dynamics without adding them. Collision geometry is box primitives sized from the STL bounding boxes; inertial values are box-formula inertia with hand-estimated masses (~5-90g per link).
The scripts used for this (extraction, scaling, mimic-joint insertion, collision generation) were
one-off Python/ElementTree scripts, not checked into the repo. If the model ever needs
regenerating, say to also pull the right hand, or retune masses, redo it from the source repo
rather than assuming a saved script exists somewhere.
The mimic-joint physics engine gotcha¶
This is the single most important thing to know if you're extending this simulation:
dartsim silently ignores <mimic> joints
box_world.sdf uses dartsim, and that's fine for a pendulum/hinge/box learning rig. But this
Gazebo build's dartsim silently ignores <mimic> constraints. It logs "physics engine
does not support mimic constraints" and just leaves the joint free/unconstrained. Nothing
crashes; the mimic'd joints just... don't mimic.
bullet-featherstone was verified to apply mimic correctly (tested: commanding one driver
joint moved all 3 mimic-coupled finger joints together, as intended).
Any new world file relying on mimic joints must set type="bullet-featherstone" and add
<engine><filename>gz-physics-bullet-featherstone-plugin</filename></engine>
inside the Physics system plugin block. Don't copy box_world.sdf's dartsim config for
hand/mimic-joint work without changing this: it'll "run" with no errors and just be wrong.
The vision to Gazebo bridge¶
Code: sim/bridge/gz_hand_bridge.py
This lets vision/hand_tracker.py (running on the host, in the robohand conda env) drive the
simulated hand in real time, in parallel with, or instead of, the real Arduino
(--gazebo flag; --no-serial to skip real hardware entirely).
Why a separate process, not a library call¶
gz.transport13/gz.msgs10 (the Python bindings for talking to Gazebo) only exist inside the
robotics-gazebo Docker image. The host's robohand conda env has no path to them (confirmed via
apt list --installed inside the container). So hand_tracker.py launches the bridge with:
docker exec -i robotics_gazebo_sim python3 /sim/bridge/gz_hand_bridge.py
and streams one CSV line of 5 curl values (thumb,index,middle,ring,pinky, 0.0=straight to
1.0=curled, the same convention hand_tracker.py's own curls dict uses) per frame, over that
one persistent pipe. Not a subprocess call per frame: one process, one long-lived stdin pipe, for
the whole session.
Two gotchas worth knowing before extending the bridge¶
1. Topic names use the joint's last dot-segment, not the full URDF name.
hand_world.sdf's JointPositionController plugins subscribe to
/inmoov_left_hand/index_link_joint/cmd_pos, not
/inmoov_left_hand/i01.leftHand.index_link_joint/cmd_pos. The world-generation process stripped the
i01.leftHand. prefix when wiring up controller topics. The bridge does the same
(joint_name.split('.')[-1]); if you add a new joint and forget this, publishes go to a topic
nobody's listening on and nothing happens, with no error.
2. A one-shot publish right after advertise() can be dropped.
Gazebo Transport discovery (multicast, finding existing subscribers) takes on the order of ~1
second. Publish once immediately after advertising and tear the process down right after, and
you can race past that discovery window with nothing delivered. This isn't an issue for the real
use case (continuous per-frame streaming for a whole session, discovery settles well within the
first second, and everything after lands normally), but it'll bite you if you write a quick one-shot
test script against the bridge.
Known issue: the hand appears to jitter (it's rendering, not physics)¶
If you look closely at the simulated hand, even completely at rest with zero commands being sent, it visibly jitters/shimmers.
This was diagnosed by subscribing directly to every joint's axis1.position at the sim's own
~1kHz update rate for 3 seconds with nothing commanded: every joint sat flat to within ~1e-8 rad.
Physics/joint state is provably static; the jitter is a rendering artifact, not a control or
physics instability.
Best-fit explanation (not filed upstream as a bug, a real Gazebo/gz-sim limitation, worked around
locally rather than fixed): hand_world.sdf's sun light has <cast_shadows>false</cast_shadows>
set deliberately, because the default directional-light shadow-map resolution/depth-bias is tuned
for room/building-scale scenes (meters). This hand is ~10cm with many small phalanx meshes packed
closely together, and at that scale, the default shadow map produces shadow acne (flickering
self-shadow noise) that reads visually as jitter.
If you ever turn shadows back on (e.g. for a nicer screenshot or demo), expect the jitter to return. Either re-disable them, or hand-tune the shadow map resolution/near-far clip/bias for cm-scale geometry.