Skip to main content
  1. Projects/
  2. Tendon-Driven Robotic Hand — A Vision-Teleoperated MuJoCo Digital Twin/

From an Onshape assembly to a MuJoCo model with onshape-to-robot

Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
Table of Contents
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 12: This Article
The simulated hand was never modelled by hand. It is an Onshape assembly — five SG90 servos, fifteen knuckle mates, a palm full of tendon channels — pulled through the Onshape API and written out as MuJoCo XML. Here is the design, and every setting that steers the export.
Open the Onshape assembly

The design
#

  • Palm and fingers: four three-phalanx fingers and a three-segment thumb, every knuckle a revolute mate with limits. The RGB triads in the views are mate connectors.
  • Tendon channels: one per finger, running down the palm into the base.
  • Servo block: five SG90-class servos, staggered so each horn sits under a tendon exit.

The design has a history
#

  1. Start

    2026-09-02

    The first version in the history.
  2. v1.0.0 — MediaPipe

    2026-09-06

    The joint-angle-driven hand behind the RViz predecessor project.
  3. v1.0.1 → Main

    2026-09-08

    Point release and the main line the later work branches from.
  4. V3 → Mujoco branch

    2026-09-16

    The current design used by this twin — the version with the servo base block shown above.
Onshape version history Mate features
Onshape version graph: Start, v1.0.0 MediaPipe, v1.0.1, Main, V3, Mujoco branch
Mate features list: dof_pinky_dip through dof_thumb_ip, then Fastened mates

43 part instances, 112 mate features: the 15 dof_* knuckle mates, the servo mates, and many Fastened mates.

An early assembly from 2026-09-09, before the servo base block
An earlier revision (2026-09-09), before the servo base existed.

Mates become joints
#

onshape-to-robot turns each mate into a MuJoCo joint and keeps its name, stripping the dof_ prefix — dof_index_pip becomes joint index_pip. Naming in CAD is part of the software interface.

The 15 knuckle mates in Onshape’s feature tree
Early revision’s mate list — the same dof_* names survive into robot.xml.

The model ends up with 20 hinge joints: 15 knuckles and 5 servo horns, with limits taken from the mates.

Running the export
#

pip install onshape-to-robot                        # any recent Python; separate env is fine

# Onshape API keys — create at https://dev-portal.onshape.com/keys, keep them in a git-ignored .env
export ONSHAPE_API=https://cad.onshape.com
export ONSHAPE_ACCESS_KEY=...
export ONSHAPE_SECRET_KEY=...

onshape-to-robot mujoco_twin/model                  # the directory holding config.json

It writes robot.xml plus assets/*.stl (and .part metadata) into that directory.

The export overwrites robot.xml. The joint defaults and the <actuator> block are manual edits that must be re-applied (Part 13). Commit first, so git diff shows exactly what changed.

config.json, field by field
#

{
  "url": "https://cad.onshape.com/documents/a2dbb5f16624f10f1aa22f02/w/693ffc0be3e83ae21f78f6ed/e/4d69727744037003575f4068",
  "output_format": "mujoco",
  "clearance": 0.0,
  "ignoreLimits": false,
  "useMeshes": true,
  "mergeSTLs": "no",
  "additional_xml": "tendons.xml",
  "joint_properties": {
    "default": { "actuated": false },
    "servo_index":  { "actuated": true },
    "servo_middle": { "actuated": true },
    "servo_ring":   { "actuated": true },
    "servo_pinky":  { "actuated": true },
    "servo_thumb":  { "actuated": true }
  }
}
Field Value What it does Why it matters for a tendon hand
url assembly URL document / workspace / element to export a …/w/… workspace URL exports the current state; use …/v/… for a frozen, reproducible export
output_format "mujoco" MJCF instead of the default URDF MJCF can express tendons, sites and tendon actuators; URDF can’t
additional_xml "tendons.xml" injects a file into the output tendons and contacts survive every re-export (robot.xml carries <!-- Additional tendons.xml -->)
clearance 0.0 padding on collision geometry 1:1 CAD tolerances — intersecting parts show up instead of hiding
ignoreLimits false keep mate limits as joint ranges the 90° knuckle stops come from here, and the tendon physics depends on them
useMeshes true STL meshes for geoms phalanges keep their real shape
mergeSTLs "no" one STL per part per-part mass and inertia stay accurate for 1.8–5.4 g phalanges

joint_properties: keep the knuckles passive
#

By default the exporter actuates every joint — 20 motors, one inside each knuckle. "default": {"actuated": false} makes the knuckles passive, so only tendons can move them — which is what an underactuated hand is. The servo_* entries mark the horn joints as actuated, but in the committed model those generated servo actuators are replaced by hand with five linear tendon motors, and the horns spin freely.

CAD rules that make exports work
#

  • Name mates as you want joints named. Code and XML address joints and sites by name.
  • Set limits on every knuckle — and mind the sign. The mate’s axis direction is exported as-is, so some joints come out [−90°, 0] and others [0, +90°]. This model has both (Part 14).
  • Define the tendon via-points in CAD as reference frames the exporter turns into <site>s, following its frame-naming convention (documentation). A renamed frame fails at load time with Error: site '…' not found in wrap N.
  • Avoid interpenetrating parts at the export pose.
Mulham Fetna
Author
Mulham Fetna
Renaissance Engineer
ROS 2 Tendon-Driven Hand MuJoCo Twin - This article is part of a series.
Part 12: This Article

Related

The manual MJCF edits that turn a CAD export into a tendon-driven hand

A raw CAD export gives you bodies, joints, meshes and sites — and a hand that does nothing. Four edits and one extra file turn it into a tendon-driven twin. One of those edits, it turns out, does nothing at all — which is worth understanding too. Which edits survive a re-export # flowchart TB subgraph generated["robot.xml — regenerated by onshape-to-robot"] D["① joint defaults manual — re-apply"] T["② tendons + ③ contacts auto-injected from tendons.xml"] B["bodies · joints · sites · meshes generated"] A["④ actuators manual — re-apply"] end S["⑤ scene.xml floor · lights · skybox never regenerated"] -->|"include robot.xml"| generated # Edit Lives in Survives re-export? ① Joint friction / armature / damping defaults robot.xml <default> ✘ re-apply ② Flexor + extensor spatial tendons tendons.xml → injected ✔ ③ Contact exclusions tendons.xml → injected ✔ ④ Five tendon motors robot.xml <actuator> ✘ re-apply ⑤ Environment scene.xml ✔ separate file tendons.xml is kept byte-identical to the <tendon> and <contact> region of robot.xml. Tune a tendon in robot.xml without copying it back and the next export silently reverts it. ① Joint defaults — stability # <default class="ros2-tendon-driven-hand-gazebo-digital-twin"> <joint frictionloss="0.001" armature="0.0001" damping="0.01"/> Attribute Value Role damping 0.01 N·m·s/rad stops a 2 g phalanx reaching absurd speed when 50 N yanks it armature 0.0001 kg·m² rotor-like inertia on each joint’s diagonal — conditions the solver for very light bodies frictionloss 0.001 N·m a small dry-friction dead-band so joints settle instead of creeping Phalanges weigh 1.8–5.4 g. Without these, tiny inertias under large tendon forces blow up the integrator — an earlier, larger revision of the model logged Nan, Inf or huge value in QACC at DOF 128. The simulation is unstable.

Underactuation: why three knuckle angles become one tendon command

avg_angle = np.mean(angles) looks like noise filtering. It isn’t. It is a mechanical decision: a human finger has three joints you can move separately, and this robot finger has one string. Not smoothing — compression # “Averaging” in a sensor pipeline usually means averaging over time to reduce noise. Nothing here keeps history between frames. The mean is taken across space — over the three joints of one finger in a single frame — to solve a problem called underactuation. The 3-to-1 problem # Human finger Robot finger Joints 3 (MCP, PIP, DIP) 3 hinges (*_mcp, *_pip, *_dip) Independent actuators many muscles; joints move semi-independently 1 flexor tendon, 1 motor Degrees of freedom you can command ~3 1 A system with fewer actuators than joints is underactuated. A single tendon threads all three joints of each robot finger, so the only command is “pull this string with force F” — and the joints share that pull according to routing geometry and dynamics. One string, six via-points, three joints. Pull it and all three knuckles move together. So the vision layer must compress three human measurements into one robot command.

From an Onshape assembly to a robot ROS 2 can reason about

Bridging a modern parametric CAD platform and a fifteen-year-old XML standard is where most roboticists lose days. The exporter translates exactly what it sees — so every shortcut taken in CAD becomes a bug in ROS. → Open the assembly on Onshape — it is public, so everything below is checkable against the source. The assembly: four fingers on blue linkages, the thumb on an orange one, all mounted to a single palm block. onshape-to-robot is a compiler. Assembly in, robot description out. It reads mate names, mate limits and material densities directly from the CAD document and writes them into URDF as joint names, joint limits and inertia tensors. That is a genuinely good deal — done properly, the physical properties of your robot are generated rather than hand-typed, and they stay correct when the mechanism changes. Done improperly, you spend your evenings editing XML by hand and discovering that your ring finger has no knuckle. flowchart LR A["Onshape assembly mates · limits · materials"] --> B["onshape-to-robot API pull"] B --> C["robot.urdf links · joints · inertials"] B --> D["assets/*.stl visual + collision meshes"] C --> E["robot_state_publisher"] C --> F["JOINT_MAPPING limits transcribed by hand"] D --> G["RViz / Gazebo"] Five rules that move work back into CAD # Each of these exists because its absence cost real time on this project.

From finger flexion to tendon force: linear interpolation onto a MuJoCo motor

On the far side of the ROS 2 topic, five flexions arrive and five tendon motors wait. One line of linear interpolation connects them — plus a name lookup that can fail silently, and a string that is allowed to push. The formula # $$F(t) = F_\text{open} + t\,(F_\text{closed} - F_\text{open}) = 50 + t\,(-50 - 50) = 50 - 100\,t$$FORCE_OPEN = 50.0 FORCE_CLOSED = -50.0 def _lerp(self, start_val, end_val, t): return start_val + t * (end_val - start_val) def apply_flexions(self, flexions): for finger, flexion_amount in flexions.items(): target_force = self._lerp(FORCE_OPEN, FORCE_CLOSED, flexion_amount) self.data.ctrl[self.motors[finger]] = target_force Three parts of one line # With \(t = 0.75\), a finger 75% closed:

From tutorial project to production robot: a roadmap for the tendon hand twin

The system works, and it teaches well. Getting it to drive real servos safely is a sequence of well-scoped upgrades — each one grounded in a limitation measured earlier in this series. Where it stands # Area Today Production target Hand tracking image-normalized landmarks, one global calibration metric world landmarks, per-finger calibration, a temporal filter Command mapping flexion → ±50 N; the twin is a switch flexion → tendon length; proportional curl Physics model force motors, decorative horns, no self-contact position servos on horns, tuned stiffness, contacts for grasping Middleware one topic, default QoS, open on the LAN parameters, explicit QoS, a watchdog, SROS 2 Containers privileged, host namespaces, root, xhost least privilege, non-root, optional headless Code classes copied into three files, no tests one shared package, tests, CI Hardware simulation only a servo driver on the same topic Stage 1 · Correctness Measure the right thing, command the right quantity World landmarks. Image-normalized coordinates bend angles by up to 16° with hand orientation (Part 4). Read multi_hand_world_landmarks instead, then recalibrate.