MuJoCo models for the ADA robot-assisted feeding system.
ADA is a Kinova JACO2 arm mounted on a Permobil wheelchair, with a seated human user, wrist-mounted Intel RealSense D415 camera, and an interchangeable tool (articutool or forque). This package provides composable MuJoCo XML components and a Python assembly API that builds the full scene programmatically.
uv pip install -e .Dependencies: mujoco. For viewing, install mj_viser.
from ada_assets.assembly import assemble_ada
# Default: wheelchair + JACO2 + camera + human + articutool with fork
model, data = assemble_ada()View it:
uv run python -m ada_assets.assembly --viewThe assemble_ada() function composes the full scene from components:
# Default — all components, articutool with fork tip
model, data = assemble_ada()
# Articutool with spoon tip
model, data = assemble_ada(tool_tip="spoon")
# Forque (rigid fork, no motors)
model, data = assemble_ada(tool="forque")
# No tool: the hand is empty, and every keyframe opens the fingers
model, data = assemble_ada(tool=None)
# No camera
model, data = assemble_ada(with_camera=False)
# No human
model, data = assemble_ada(with_human=False)
# No floor/lighting (for embedding in a larger scene)
model, data = assemble_ada(with_floor=False)CLI:
uv run python -m ada_assets.assembly --view # default
uv run python -m ada_assets.assembly --view --tool-tip spoon # spoon
uv run python -m ada_assets.assembly --view --tool forque # forque
uv run python -m ada_assets.assembly --view --no-tool # bare arm
uv run python -m ada_assets.assembly --save assembled.xml # save XMLFive models are generated from the components by ada_assets.assembly.generate() and checked in. Don't edit them; edit the components and regenerate:
uv run python -c 'from ada_assets.assembly import generate; generate()'A test fails if any generated file differs from what its sources produce.
| File | Contents | Use |
|---|---|---|
models/ada.xml |
The robot: wheelchair, JACO2, wrist camera, and the articutool mounted rigidly in the hand | The canonical model of the ssrobot package (ssrobot.toml) |
models/ada_hand.xml |
The same robot with an empty hand, its fingers open in every keyframe | The canonical model of the bare-hand ssrobot package (models/ssrobot.toml) |
models/scene.xml |
The robot, the seated user, and the floor | Viewing and simulation |
models/seated_user_scene.xml |
The seated user and the floor, without the robot; the head is a mocap body | A task scene to compose with the robot in MuJoCo |
models/seated_user_fixtures.xml |
The seated user alone, user_body and head as fixtures |
An ssrobot scene: MujocoRuntime(package, scene=...) |
In the generated models the articutool is rigidly mounted: its tilt and roll joints and its sensors are part of the robot. On the real robot the tool is set down to charge. assemble_ada() keeps the releasable form, a free tool held by a weld (tool_mount="weld", the default), and build_spec(tool_mount="rigid") builds the mounted form.
ada_hand.xml is assemble_ada(tool=None)'s robot. jaco2.xml's keyframes close the fingers on the tool's handle; with no tool, the assembly opens them in every keyframe, so the empty hand starts ready to close on an object.
seated_user_fixtures.xml is the seated user as ssrobot composes a scene: no floor or light, and no mocap body, which ssrobot scenes cannot have. Its meshes are under models/, the scene's root. Planners avoid the head, whose collision box collides; the mouth is outside the box, so the fork can reach it. The face wall and the body envelope do not collide: the face wall stands across every feeding approach, and the body envelope encloses the arm's mount, so it would touch the arm at every configuration. The file's header gives the details.
Start at a keyframe. MuJoCo's default state, every joint at zero, is the JACO2's mathematical zero, not a pose the arm can reach: joints 2 and 3 are outside their ranges ([0.82, 5.46] and [0.33, 5.95] rad), and the links interpenetrate. Making it valid would mean changing the joints' reference angles, and with them the arm's kinematics. So a simulation starts at a keyframe instead: resting, staging, above_plate, or stow. Each is within the joint limits, contact-free, and held by its own controls, and tests guard all three properties. assemble_ada() starts at stow; ssrobot's MuJoCo profile names its start keyframe.
ssrobot.toml makes this an ssrobot robot package, with models/ada.xml as its canonical model.
- MuJoCo profile (
mujoco.toml): ssrobot'sMujocoRuntimestarts at therestingkeyframe. It drives the gripper through the finger tendon actuators, from 0 (open) to 1.35, just short of the fingertips meeting. - Groups:
arm(the six JACO2 joints) andarticutool(tilt and roll). - Commands: joint position and trajectory for
armandarticutool, andgripper. - Channels:
- joint position, velocity, and effort for
arm; - the gripper opening;
articutooljoint position;- the poses of
ee_siteand the fork tip; - the tool's wrench;
- the wrist camera's RGB and depth, at 640x360.
- joint position, velocity, and effort for
- Named configurations:
resting,staging,above_plate,stow, equal to the keyframes of the same names. - Joint limits: the JACO2's velocity and effort, and the articutool's, from their URDFs in ada_ros2 and articutool_ros2. Kinova publishes no acceleration limit, so ADA's operating limits are declared instead, as ada_mj uses: each joint's velocity limit reached in 0.1 s. The sources are in comments in
ssrobot.toml. - Gripper positions (
open_positions,closed_positions): where the gripper's four joints settle commanded fully open and fully closed on nothing, so a gripper command can be checked against the scene before it runs. They are MuJoCo's values; the real hand's are unmeasured. ssrobot'sscripts/calibrate_gripper.pymeasures them;evidence/mujoco-gripper.jsonholds the measurement.
models/ssrobot.toml is a second ssrobot package, ada_hand: ADA with an empty hand, for grasping objects with the fingers. Its root is models/, which holds its canonical model, models/ada_hand.xml, and the meshes, so every file it needs is inside it.
import ssrobot
package = ssrobot.load_installed_package("ada_assets.models") # the bare-hand ADA
package = ssrobot.load_installed_package("ada_assets") # ADA with the articutool- Same as ADA's: the
armgroup and manipulator, thegripper, the named configurations, the JACO2's joint limits, and the gripper's controls and completion detector in its MuJoCo profile (models/mujoco.toml). - Gripper positions measured on this model:
evidence/ada_hand-mujoco-gripper.jsonholds the measurement. - End effector
hand, atpalm_grasp_site(see the JACO2 sites below), with the gripper. - Commands: joint position and trajectory for
arm, andgripper. - Channels: joint position, velocity, and effort for
arm, and the gripper opening. - No articutool, tool wrench, or wrist camera sensor. The camera is still on the model.
- Rest criteria for
arm_qin its MuJoCo profile: ADA's declared variation, with a dwell of 370 ms measured on this model, since ADA's was measured with the articutool. It covers the arm both empty and holding the reference cylinder.evidence/ada_hand-mujoco-rest.jsonholds the measurement.
tests/test_hand_package.py opens it with ssrobot's MujocoRuntime, puts a cylinder of ADA's size (r 25 mm, h 100 mm) in the open hand where sstsr's KinovaJaco2TwoFinger side grasp places it relative to palm_grasp_site, and closes the gripper: both fingers touch the cylinder and the palm does not. It also checks, with the fingers open, that the finger pads lie on either side of the site's z axis, along its y. Its record, artifacts/test_hand_package/hand_grasp.json, is written twice and must be byte-identical. The test needs Python 3.11 or later, as ssrobot does.
Each component is a standalone MuJoCo XML that can be loaded independently or composed via the assembly API. All meshes live in a single flat models/assets/ directory.
Kinova JACO2 j2n6s200 — 6-DOF arm with 2 underactuated fingers.
- Joints: 6 revolute (arm) + 2 revolute per finger (proximal + distal with spring)
- Actuators: 6 arm + 2 finger position servos
- Fingers: Tendon-coupled underactuated mechanism. Proximal and distal joints linked by a fixed tendon (coef 1.0 proximal, 0.5 distal). Distal has a return spring — when the proximal is blocked by contact, force transfers to the distal via the tendon, wrapping around the object.
- Finger contacts:
condim 4on the fingers' collision geoms, so a pinched object resists twisting about the line between the fingertips; friction values are MuJoCo's defaults (#46) - Materials: Carbon fiber (arm links), brushed aluminum (rings), rubber (finger pads) — from the original DAE files.
- Gravity compensation: Enabled on all moving bodies (
gravcomp="1") - Effort limits: From URDF — J1/J3 ±40 Nm, J2 ±80 Nm, J4-6 ±20 Nm
- Finger drives: Position servos at the hand's rated grip, Kinova's 25 N per finger (#46): force limit ±1.81 Nm on each finger's tendon (25 N at the 72.3 mm moment arm of a fingertip contact, within the URDF's ±2 Nm), stiffness 134, and tendon damping 1.56 for the KG-2's 1.2 s open or close. jaco2.xml's comment gives the sources and the derivation. Being underactuated, the fingers roll an object pinched at their tips into the palm, as the real hand does
- Sites:
base_mount_site,tool_attachment_site,ee_site,palm_grasp_site,forque_attachment_site,articutool_attachment_site,camera_attachment_site - Palm grasp site (
palm_grasp_site): the frame sstsr's parallel-jaw grasp templates constrain. Its origin is the palm, 125 mm along link_6's −z and at the finger mounts' x (−2.5 mm); its z is the approach, y the fingers' opening, and x the palm normal (link_6 rotated 180° about x). These are sstsr 4.2.0'sKinovaJaco2TwoFinger.PALM_OFFSET_FROM_LINK_6andPALM_X_IN_LINK_6, measured from this model. - Keyframes:
above_plate,resting,staging,stow. Each carries thectrlthat holds it: the joint servos at their positions, and each finger servo at its tendon length (proximal + 0.5 × distal)
Permobil wheelchair — static body with visual and collision meshes.
- Position:
z = 0.4612(from physics drop test — wheels on floor) - Collision: 38 convex parts (
wheelchair_collision_NN.stl), a CoACD decomposition of the visual mesh made byscripts/decompose_wheelchair.py. A single mesh geom would collide as its convex hull, which fills the space between seat, back, and armrests: over 4,000 random arm poses, the hull reported 395 contacts the real chair does not make - Site:
arm_attachment_siteat the JACO2 mounting point
Seated user on the wheelchair — body collision envelope and head with mouth target.
- Body collision:
body_collision_in_wheelchair.stlsafety envelope at wheelchair height - Head: Mocap body (6-DOF, controllable via
data.mocap_pos/data.mocap_quat). Visual mesh fromtom.stl, collision box sized to mesh bounds. - Mouth site: Feeding target at
pos="0.02 0 0"in head frame. On the real robot this comes from face detection — in sim, move the head mocap body. - Face wall: Thin collision box in front of the face, child of head (tracks head movement). Initially disabled (
contype=0) — enable at runtime for safety testing.
Intel RealSense D415 on a Jetson Nano enclosure, mounted on JACO2 link_6.
- Assembly: Arm mount → enclosure (bottom + top + front stabilizer) → Jetson Nano → camera back mount → D415
- Extrinsics: Calibrated transform from
cameraMounttocamera_link(fromada.xacrohard-coded extrinsics) - Cameras:
d415_colorandd415_depthMuJoCo cameras at the calibratedcolor_optical_frame - Color: 1280x720, fovy=43° (from nominal fy=911)
- Depth: Same frame (aligned_depth_to_color), clip to [0.16, 10.0] m in post-processing
- Nominal intrinsics (1280x720): fx=913.16, fy=911.31, cx=635.49, cy=372.79
- Sites:
camera_link(calibrated extrinsics frame),color_optical_frame(OpenCV convention: z into scene) - Limitations: MuJoCo only supports
fovy— off-center principal point and non-square pixels require post-processing (see ada_mj#5)
2-DOF motorized fork/spoon end-effector with F/T sensor and IMU.
- Joints:
atool_joint1(tilt, ±π/2) andatool_joint2(roll, ±π) - Motors: Dynamixel XC430-W250-T, stall torque 1.4 Nm, position-controlled with forcerange clamping
- F/T sensor: Resense Hex21 (±50 N force, ±0.5 Nm torque, 1% accuracy)
- IMU: TDK ICM-20948 9-axis (accelerometer + gyro) on the handle — used to keep tool tip level during transport
- Tool tips: Fork (
fork_tool.stl) or spoon (spoon_tool.stl), swappable viatool_tipparameter - Grasp: Freejoint object, held by a weld constraint (
articutool_grasp_weld). Disable to release:eq_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, "articutool_grasp_weld") model.eq_active[eq_id] = 0 # release
- Sites:
grasp_site(weld point),imu_site,ft_sensor_site,fork_tip(feeding contact) - Sensors:
ft_force,ft_torque,imu_accel,imu_gyro
Rigid fork with ATI Nano25-E F/T sensor, no motors. The simpler predecessor to the articutool.
- No actuated joints — fork tine is rigidly connected to the sensor body
- F/T sensor: ATI Nano25-E (±125 N force, ±3 Nm torque, noise 0.06 N / 0.003 Nm)
- Grasp: Same freejoint + weld pattern as articutool (
forque_grasp_weld) - Sites:
grasp_site,ft_sensor_site,fork_tip - Sensors:
ft_force,ft_torque
The Python assembly (assembly.py) uses MuJoCo's MjSpec API:
- Load wheelchair as the base spec
- Attach JACO2 at
arm_attachment_siteviaspec.attach() - Attach wrist camera at
camera_attachment_siteon link_6 - Attach seated human on worldbody
- Attach the tool (articutool or forque). With
tool_mount="weld"(the default), it is a freejoint object on the worldbody: a weld equality constraint holds itsgrasp_siteat the attachment site on link_6, andcompile_and_initplaces it there in the initial state and in every keyframe. Withtool_mount="rigid", it is mounted at the attachment site with no free joint. - Add floor and lighting
Each component XML has no meshdir — the assembly sets it once to the shared assets/ directory.
from ada_assets import MODELS_DIR, ASSETS_DIR
import mujoco
# Load a single component
spec = mujoco.MjSpec.from_file(str(MODELS_DIR / "jaco2.xml"))
spec.meshdir = str(ASSETS_DIR)
model = spec.compile()Or use the helper:
from ada_assets import get_model_path, ASSETS_DIR
path = get_model_path("jaco2") # returns Path to jaco2.xmlada_assets/
├── src/ada_assets/
│ ├── __init__.py # MODELS_DIR, ASSETS_DIR, get_model_path()
│ ├── assembly.py # assemble_ada() + CLI
│ └── models/
│ ├── jaco2.xml # JACO2 arm
│ ├── wheelchair.xml # Permobil wheelchair
│ ├── seated.xml # Seated human
│ ├── articutool.xml # 2-DOF motorized tool
│ ├── forque.xml # Rigid fork + F/T sensor
│ ├── camera.xml # Wrist camera (D415 + Jetson Nano)
│ ├── ada.xml # Generated: the robot (canonical ssrobot model)
│ ├── ada_hand.xml # Generated: the robot with an empty hand (bare-hand package)
│ ├── ssrobot.toml # The bare-hand ssrobot package; models/ is its root
│ ├── mujoco.toml # Its MuJoCo profile
│ ├── scene.xml # Generated: robot + seated user + floor
│ ├── seated_user_scene.xml # Generated: seated user + floor
│ └── assets/ # 41 STL mesh files (single flat directory)
├── pyproject.toml
├── LICENSE
└── README.md
All transforms, inertials, joint limits, and mesh files are traced to their original sources:
Each mesh's source commit, changes, original origin, and license are recorded in models/assets/PROVENANCE.md. The package's code is MIT-licensed; meshes keep their own licenses, whose texts ship in models/assets/licenses/.
- JACO2 arm: Kinova JACO2 j2n6s200,
ada_ros2/ada_description/urdf/j2n6s200.xacro+ DAE meshes for materials - Wheelchair:
ada_feeding/ada_planning_scene+ physics drop test for z-height - Seated human:
ada_feeding/ada_planning_sceneconfig (positions converted from arm-root frame to floor frame) - Forque:
ada_ros2/ada_description/urdf/forque/forque.xacro - Articutool:
articutool_ros2/articutool_description/urdf/articutool.xacro - Wrist camera:
ada_ros2/ada_description/urdf/camera/camera.xacro+d415urdf.xacro, extrinsics fromada.xacro - Sensor specs: ATI Nano25-E, Resense Hex21, TDK ICM-20948, Intel D415
The tool weld is a mechanical attachment, not a grasp. Key design decisions for the robot controller (see ada_mj#4):
- GraspManager does not manage tools. The weld constraint is pre-built by
assemble_ada(). GraspManager handles physics-based grasps (finger contact on food, cans, etc.). - Exclude tool from Environment. The tool is a freejoint body but should not appear in the scene object list when welded — it's kinematically part of the arm.
- Collision filtering. When welded, treat tool bodies as arm bodies. When released, they become regular scene objects.
- F/T sensor requires the weld. Kinematic attachment (qpos updates) bypasses the constraint solver — F/T sensors read zero. The weld creates real constraint forces.
ft_sensor_siteposition on the forque needs verification against the ATI Nano25-E gage origin from the datasheet drawing (currently at mesh midplane)- Articutool attachment transform derived from composed URDF frames — visual alignment confirmed but should be verified on hardware
