ModelBuilder (experimental)
Warning
ModelBuilder is experimental and does not fully replicate the official MyoChallenge environments.
The challenge XML files include tendons, sites, mocap bodies, contact
exclusions, keyframes, and composite multi-geom objects that ModelBuilder
does not currently support natively. For training or evaluating agents
against a benchmark, always use the registered Gymnasium entry points
(e.g. make_env("myoChallengeBaodingP2-v1")) or instantiate the
environment class directly.
ModelBuilder is intended for rapid prototyping, ablation studies, and domain randomisation pipelines where partial scenes are sufficient.
ModelBuilder is the programmatic interface for composing MuJoCo MJCF models
in MyoSuite. It records a lazy recipe — fragment attachments, object
placements, and transform functions — and compiles everything into a
(MjModel, MjSpec) pair on the first call to build().
Results are content-hashed and cached, so identical recipes compile only once.
Current limitations
The table below lists scene features that are not natively supported. Use the apply_transform escape hatch or load from XML directly for anything in this list.
Unsupported feature |
Affected challenge envs |
|---|---|
Tendons / spatial constraints |
Baoding (ball tracking tendons) |
Sites (tracking markers, sensor anchors) |
Baoding, Die, Relocate, TableTennis |
Mocap / static kinematic bodies |
Relocate (bin), Soccer (goal posts), ChaseTag (opponent) |
Non-freejoint object kinematics (slide / hinge joints on props) |
Relocate, Die (6-DOF slide joints) |
Multi-geom composite bodies |
Die (12 capsule edges + 3 box faces) |
Cube-map / grid-layout textures |
Soccer ball, Die faces |
Aerodynamic fluid coefficients ( |
TableTennis (ping-pong ball drag) |
Mirrored body assembly |
TableTennis (left arm via spec processing) |
Heightfield terrain |
RunTrack, ChaseTag |
Contact filtering / exclusion pairs |
All locomotion envs |
Keyframes (initial pose snapshots) |
TableTennis, Soccer |
Sensors (force, touch, accelerometer, …) |
Any env with sensor obs |
Anything marked above can be added via apply_transform() — see the
escape hatch section below.
Alternatives
Direct XML loading
For full challenge parity, load the environment’s existing XML directly. This is always the most reliable approach:
import mujoco
import myosuite
from pathlib import Path
from myosuite.utils.asset_path_resolver import resolve_model_xml_path
xml = Path(myosuite.__file__).parent / "envs/myo/assets/hand/myohand_baoding.xml"
model = mujoco.MjModel.from_xml_path(str(resolve_model_xml_path(xml)))
Gymnasium entry points (recommended)
The safest way to get a fully-configured challenge environment:
from myosuite import make_env
env = make_env("myoChallengeBaodingP2-v1")
obs, info = env.reset()
env.close()
# Also registered: myoChallengeTableTennisP2-v0, myoChallengeSoccerP2-v0,
# myoChallengeRelocateP2-v0, myoChallengeDieReorientP2-v0.
These environments load their own XML scene files which include all tendons, sites, contacts, and other features that ModelBuilder does not cover.
apply_transform escape hatch
Use apply_transform to add any unsupported feature to a ModelBuilder scene:
import mujoco
from myosuite.core.model_builder import ModelBuilder
def add_tracking_sites(spec: mujoco.MjSpec) -> mujoco.MjSpec:
"""Add target tracking sites missing from the native API."""
site = spec.worldbody.add_site(name="target1_site")
site.pos = [-0.25, -0.5, 1.45]
site.size = [0.005, 0.005, 0.005]
return spec
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body("ball1", pos=[-0.227, -0.511, 1.452],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.022, 0, 0], rgba=[1, 0.8, 0.31, 1],
mass=0.043, condim=4)
.apply_transform(add_tracking_sites)
.build()
)
Quick reference
Method |
Purpose |
|---|---|
|
Attach a body-part XML at the parent body’s origin |
|
Attach a body-part at an explicit position / orientation |
|
Add a free-floating primitive prop (ball, box, cylinder, …) at a world position |
|
Add a free-floating body loaded from a mesh file with optional texture |
|
Register an arbitrary |
|
Override the simulation timestep (seconds) |
|
Set |
|
Scale all muscle peak forces (e.g. 0.5 → 50 % of nominal) |
|
Compile and return |
Fragment names
The following names are recognised out of the box:
Name |
DoF |
Muscles |
Description |
|---|---|---|---|
|
2 |
6 |
2-DoF elbow (flexion + supination), 3 flexors + 3 extensors |
|
4 |
5 |
Single finger with DIP, PIP, MCP, abduction |
|
23 |
39 |
Full myoHand — 29 bones, 23 joints |
|
27 |
63 |
Full myoArm — shoulder through fingertips |
|
20 |
80 |
Full myoLeg — hip, knee, ankle, 80-muscle model |
|
— |
— |
Leg + Osseointegrated Prosthetic Limb (OSL) add-on |
|
18 |
210 |
Lumbar spine / torso (210-muscle model) |
|
— |
— |
Full-body scaffold |
Custom fragment paths (absolute *.xml) can be added via
myo_sim.FragmentRegistry.register(name, path) when the myo_sim package
is installed.
Positioning objects
attach_fragment — default (no offset)
Attaches the fragment at the parent body’s origin. Use this when the XML already positions the body correctly, or when you don’t need a specific offset:
from myosuite.core.model_builder import ModelBuilder
model, spec = ModelBuilder().attach_fragment("elbow").build()
place_fragment — explicit offset
Use place_fragment when you need the fragment root to appear at a specific
position and/or orientation relative to the parent body frame.
The quaternion follows the MJCF convention [w, x, y, z]:
import numpy as np
from myosuite.core.model_builder import ModelBuilder
# Offset a hand fragment in world coordinates
model, spec = (
ModelBuilder()
.place_fragment("hand", pos=[0, 0, -0.35], parent="worldbody")
.build()
)
# Rotate a fragment 90° around the Z axis
angle = np.pi / 2
quat_z90 = [np.cos(angle / 2), 0, 0, np.sin(angle / 2)] # [w, x, y, z]
model, spec = (
ModelBuilder()
.place_fragment("elbow", pos=[0, 0, 0], quat=quat_z90)
.build()
)
add_free_body — free-floating props
add_free_body creates a body with a 6-DoF freejoint and a single collision
geom. Use it for balls, boxes, paddles, obstacles, and any other scene prop
whose position you want to control at runtime.
import mujoco
from myosuite.core.model_builder import ModelBuilder
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body(
"my_ball",
pos=[0.1, 0.0, 1.3], # world-frame position (m)
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.025, 0, 0], # [radius, 0, 0] for sphere
rgba=[1.0, 0.4, 0.0, 1.0], # orange
)
.build()
)
Each free body adds 7 dof to model.nq (3 translation + 4 quaternion).
Set the initial pose at runtime:
import mujoco, numpy as np
from myosuite.core.model_builder import ModelBuilder
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body(
"my_ball",
pos=[0.1, 0.0, 1.3],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.025, 0, 0],
rgba=[1.0, 0.4, 0.0, 1.0],
)
.build()
)
data = mujoco.MjData(model)
jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "my_ball_free")
qadr = model.jnt_qposadr[jid]
data.qpos[qadr:qadr+3] = [0.2, 0.0, 1.5] # new position
data.qpos[qadr+3:qadr+7] = [1, 0, 0, 0] # identity quaternion
mujoco.mj_forward(model, data)
Supported geom types and size conventions:
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
apply_transform — arbitrary MjSpec edits
For anything not covered by the methods above, pass a callable that receives
and returns mujoco.MjSpec:
import mujoco
from myosuite.core.model_builder import ModelBuilder
def _move_target(spec):
spec.body("target").pos = [0.2, 0.0, 0.8]
return spec
model, spec = (
ModelBuilder()
.attach_fragment("elbow")
.add_free_body(
"target",
pos=[0.3, 0.0, 0.8],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.015, 0, 0],
rgba=[0, 1, 0, 0.5],
)
.apply_transform(_move_target)
.build()
)
Challenge environment examples (partial parity)
The examples below show what ModelBuilder assembles natively for each challenge environment. Each example notes what is missing vs. the full XML scene. For the complete scene, use the Gymnasium entry point or load from XML directly.
Environment |
Natively composed |
Missing (needs |
|---|---|---|
Baoding |
hand + 2 balls (mass, condim) |
tendons, target sites, floor/lighting |
TableTennis |
arm + ball + table/net/paddle meshes |
mirrored left arm, |
Relocate |
arm + box object (mass, condim) |
table, bin mocap body, slide joints |
Soccer |
leg + sphere ball |
torso/head, goal meshes, cube-map ball texture |
Die Reorient |
hand fragment only |
12-capsule die body, dice texture, slide joints |
Baoding balls (myoChallengeBaodingP2-v1)
Hand + two spheres with correct mass (43 g) and torsional-friction contact.
Missing: tendons linking ball sites to target sites; target tracking sites;
scene lighting and floor (myosuite_scene.xml).
import mujoco
from myosuite.core.model_builder import ModelBuilder
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body(
"ball1",
pos=[-0.227, -0.511, 1.452],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.022, 0, 0], # 22 mm radius (nominal)
rgba=[1.0, 0.8, 0.31, 1.0], # yellow
mass=0.043, # 43 g — matches challenge XML
condim=4, # torsional friction
)
.add_free_body(
"ball2",
pos=[-0.256, -0.552, 1.442],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.022, 0, 0],
rgba=[0.84, 0.59, 0.53, 1.0], # peach
mass=0.043,
condim=4,
)
.build()
)
# model.nq = hand_nq + 7 + 7 (two freejoints)
Or via the named recipe (equivalent):
from myosuite.core.model_builder import build_from_recipe
import myosuite.core.model_recipes # registers recipes
model, spec = build_from_recipe("challenge_baoding")
Per-episode ball-size randomisation (P2 variant, radius ∈ [0.018, 0.024]):
import random, mujoco
from myosuite.core.model_builder import ModelBuilder
def make_baoding_model(radius: float) -> mujoco.MjModel:
model, _ = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body("ball1", pos=[-0.227, -0.511, 1.452],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[radius, 0, 0], rgba=[1.0, 0.8, 0.31, 1.0],
mass=0.043, condim=4)
.add_free_body("ball2", pos=[-0.256, -0.552, 1.442],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[radius, 0, 0], rgba=[0.84, 0.59, 0.53, 1.0],
mass=0.043, condim=4)
.build()
)
return model
r = random.uniform(0.018, 0.024)
model = make_baoding_model(r)
TableTennis (myoChallengeTableTennisP2-v0)
Right arm + ping-pong ball + table / net / paddle as mesh bodies with textures.
Missing: mirrored left arm (requires spec processing); ball aerodynamic drag
(fluidcoef); contact zone sites on own/opponent halves; immobilised legs.
import mujoco
from pathlib import Path
import myosuite
from myosuite.core.model_builder import ModelBuilder
assets = Path(myosuite.__file__).parent / "envs/myo/assets"
model, spec = (
ModelBuilder()
.attach_fragment("arm")
.add_free_body(
"pingpong",
pos=[0.0, -0.35, 1.5],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.02, 0, 0], # 40 mm diameter
rgba=[0.9, 0.9, 0.9, 1.0],
mass=0.0027, # 2.7 g — matches challenge XML
condim=4,
)
.add_mesh_body(
"tabletennis_table",
mesh_file=assets / "tabletennis_table.obj",
texture_file=assets / "tabletennis.png",
material_shininess=0.4, material_specular=0.2,
)
.add_mesh_body(
"tabletennis_net",
mesh_file=assets / "tabletennis_net.obj",
texture_file=assets / "tabletennis.png",
material_shininess=0.4, material_specular=0.2,
)
.add_mesh_body(
"paddle",
mesh_file=assets / "paddle.obj",
texture_file=assets / "paddle_1k.png",
pos=[0.0, -0.2, 1.3],
material_shininess=0.4, material_specular=0.2,
)
.build()
)
MjsGeom has no fluidcoef field. Use the registered environment for
the full aerodynamic ball, or set drag via a custom XML include:
from myosuite import make_env
env = make_env("myoChallengeTableTennisP2-v0")
env.close()
Relocate (myoChallengeRelocateP2-v0)
Arm + free-floating cube object. Missing: static table geometry; 4-wall target bin (mocap body); the original XML uses 6 separate slide/hinge joints on the object rather than a freejoint.
import mujoco
from myosuite.core.model_builder import ModelBuilder
model, spec = (
ModelBuilder()
.attach_fragment("arm")
.add_free_body(
"object",
pos=[0.0, -0.25, 0.95],
geom_type=mujoco.mjtGeom.mjGEOM_BOX,
geom_size=[0.0284, 0.0284, 0.0284],
rgba=[0.5, 0.2, 0.7, 1.0],
mass=0.1, # nominal 100 g (P2 range: 50–300 g)
condim=4,
)
.build()
)
Soccer (myoChallengeSoccerP2-v0)
Leg fragment + FIFA size-5 ball. Missing: torso / head composite; goal post mesh bodies; cube-map soccer-ball texture; full scene lighting.
import mujoco
from myosuite.core.model_builder import ModelBuilder
FIFA_RADIUS = 0.117 # size-5 ball, 450 g
model, spec = (
ModelBuilder()
.attach_fragment("leg")
.add_free_body(
"soccer_ball",
pos=[2.0, 0.0, FIFA_RADIUS],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[FIFA_RADIUS, 0, 0],
rgba=[0.95, 0.95, 0.95, 1.0],
mass=0.45, # 450 g
condim=4,
)
.build()
)
Die Reorient (myoChallengeDieReorientP2-v0)
Hand fragment + a free box stand-in for the die. The 12-capsule textured die
from the challenge XML is not expressible with add_free_body (no
imass / cube-map texture on MjsBody). Use
make_env("myoChallengeDieReorientP2-v0") for the full object.
import mujoco
from myosuite.core.model_builder import ModelBuilder
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.add_free_body(
"die",
pos=[-0.240, -0.535, 1.46],
geom_type=mujoco.mjtGeom.mjGEOM_BOX,
geom_size=[0.0284, 0.0284, 0.0284],
rgba=[1.0, 1.0, 1.0, 1.0],
mass=0.108,
condim=4,
)
.build()
)
Named recipes
For the most common configurations, pre-built recipes are registered via
myosuite.core.model_builder.model_recipe() and can be loaded with
build_from_recipe():
from myosuite.core.model_builder import build_from_recipe
import myosuite.core.model_recipes # registers recipes
model, spec = build_from_recipe("elbow_standard")
model, spec = build_from_recipe("elbow_sarcopenia") # 50 % muscle force
model, spec = build_from_recipe("hand_standard")
model, spec = build_from_recipe("full_arm") # shoulder + elbow + hand
# Locomotion: make_env("myoLegWalk-v0") (walk_standard duplicates leg/OSL meshes)
Hand models and the thumb joint order
Two hand models are in use, and they differ in the order of the two thumb CMC joints:
The composed hand (
myo_sim>= 0.2.0; thehand_*andfull_armrecipes) is used by the hand pose, reach and reorient tasks, Relocate and the SAR tasks. Its first CMC joint iscmc_flexion(range -0.78 to 0.7) followed bycmc_abduction(-0.5 to 0.78); the hand joints carry the_rsuffix.The legacy hand (
_legacy_hand_builder(), the release-eramyohand_*.xmlfiles; thehand_key,hand_holdandhand_penrecipes) is used bymyoHandKeyTurn*,myoHandObjHold*andmyoHandPenTwirl*. It hascmc_abduction(-0.5 to 0.78) first, thencmc_flexion(-0.78 to 0.7), without the_rsuffix. These tasks keep it on purpose, because the public NPG policies of MyoSuite 2.x for these contact tasks depend on the joint order (observation and action layout), and a swapped order would make them behave differently.
The myo_sim refactor of 2026-06 (PR #111, released as 0.2.0) exchanged the names and ranges of the two thumb
joints; MyoSuite handles it in the pose targets (tasks/basic/__init__.py) and checks the order of the legacy
envs in test_hand_recipe_parity.py. Use joint names, not qpos indices, when you write targets or keyframes.
Register your own recipe to share it across a project:
from myosuite.core.model_builder import ModelBuilder, model_recipe, build_from_recipe
@model_recipe("my_reach_scene")
def _my_scene(b: ModelBuilder) -> ModelBuilder:
return (
b.attach_fragment("elbow")
.add_free_body("target", pos=[0.3, 0.0, 0.8],
geom_type=__import__("mujoco").mjtGeom.mjGEOM_SPHERE,
geom_size=[0.015, 0, 0], rgba=[0, 1, 0, 0.5])
)
model, spec = build_from_recipe("my_reach_scene")
Pathological conditions
from myosuite.core.model_builder import ModelBuilder
# Sarcopenia: all muscles generate only 60 % of their peak force
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.apply_sarcopenia(force_scale=0.6)
.build()
)
# Combine: Baoding balls + sarcopenic hand
import mujoco
model, spec = (
ModelBuilder()
.attach_fragment("hand")
.apply_sarcopenia(force_scale=0.5)
.add_free_body("ball1", pos=[-0.227, -0.511, 1.452],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.022, 0, 0], rgba=[1.0, 0.8, 0.0, 1.0])
.add_free_body("ball2", pos=[-0.256, -0.552, 1.442],
geom_type=mujoco.mjtGeom.mjGEOM_SPHERE,
geom_size=[0.022, 0, 0], rgba=[0.8, 0.6, 0.4, 1.0])
.build()
)
Caching
build() caches the compiled model by a SHA-256 hash of:
fragment file contents + parent body name
per-fragment
posandquatper-free-body
pos,quat,geom_size,rgba,geom_type,mass,condimper-mesh-body mesh file bytes + texture file bytes +
scale,pos,quattimestep and contact settings
number of registered transforms
Identical recipes always return the same cached (MjModel, MjSpec) object.
Different positions always produce different cache entries.
See also
Architecture — CPU Gymnasium and mjlab GPU backends
Environment Reference — complete environment listing
myosuite/core/model_builder.py— sourcemyosuite/core/model_recipes.py— built-in recipesmyosuite/tests/test_model_builder.py— unit testsmyosuite/tests/test_model_builder_scenes.py— challenge-environment scene tests