Simulation
Running a robot in simulation is a switch, not a rewrite. Take your existing Robot, set is_sim = True, give it a scene, and the framework runs every Module inside a shared MuJoCo world instead of on hardware — same Observation/Action schemas, same transport, same control loop.
There are no SimRobot, SimModule, SimServo or SimCamera classes. Your real modules, servos, and cameras run unchanged; the only extra input the sim needs is a scene describing the environment and how each module's MuJoCo model attaches, plus sim_joint / sim_range_* on each servo and a sim: block on each camera.
MuJoCo and the other simulation dependencies live in the [sim] extra (mujoco, mink, onshape-to-robot). They are imported lazily inside the sim code paths, so import solarian never requires them — only running a robot with is_sim = True does. Install with pip install "solarian[sim]".
Flip the switch
Set is_sim = True and a scene on your robot class:
from solarian.core import Robot
from solarian.sim import SceneConfig, ModuleAttachment
from myrobot.modules.arm import ArmModule
class MyRobot(Robot):
is_sim = True # run in MuJoCo
view = True # show the live overview window
arm = ArmModule() # the SAME module used on real hardware
scene = SceneConfig(
world_mjcf="assets/scene.xml",
attachments=[
ModuleAttachment(module="arm", mjcf="assets/arm.xml", prefix="arm/"),
],
)
That's the whole difference from a real robot. The scene builds the world, attaching modules places each module's model in it, and simulated servos and cameras map each component onto a joint or a camera of that model.
- Name
is_sim- Type
- bool
- Description
Class attribute, default
False. When true,connect/start/disconnectuse the MuJoCo path instead of hardware.
- Name
scene- Type
- SceneConfig
- Description
Required when
is_simis true (a clear error is raised otherwise). See Scenes.
- Name
view- Type
- bool
- Description
Show a live overview window while running. Overridable at runtime with the
SOLARIAN_SIM_VIEWenv var (1/0). Best-effort: with no display available the sim runs headless.
The module attribute name (arm) is still the canonical module ID — it keys conf/modules/arm.yaml, the Observation/Action schemas, and the module field of each ModuleAttachment. Nothing about the schema changes between real and sim.
Choosing real vs sim at run time
is_sim defaults to the class attribute but is overridable per run by the sim field on RobotConfig. So the same robot class can run on hardware or in sim:
solarian run-robot # uses the class attribute (sim here)
solarian run-robot sim=false # force the real hardware path
solarian run-robot sim=true # force simulation
The resolution order is: sim from config/CLI if set, otherwise the class attribute. Keep sim: null in your conf/robot.yaml so the CLI override is accepted:
# conf/robot.yaml
connection: ${deployment.connection}
fps: 30
img_compression_quality: 70
sim: null # null defers to the robot class's is_sim; override with sim=true/false
# solarian/core/robot.py — resolution in Robot.__init__
cfg_sim = getattr(cfg, "sim", None)
self.is_sim = type(self).is_sim if cfg_sim is None else bool(cfg_sim)
So one class serves both: hard-code is_sim = True for a sim-first project and run sim=false for the occasional hardware run, or leave it False and opt in with sim=true.
Running the stack
Sim uses the same three-node setup as hardware — only the robot node changes:
solarian run-server # relay server
solarian run-robot sim=true # the simulated robot
solarian run-controller # unchanged controller (keyboard, leader arm, policy...)
How it works
When is_sim is set, Robot delegates to solarian/sim/runtime.py instead of opening hardware buses. The hardware lifecycle from Robots is preserved; only the bus layer changes:
MyRobot(cfg) # resolves is_sim; requires `scene`
│
├── connect() # compose scene → launch MujocoWorldServer →
│ # inject a BusClient proxy per (module, port) →
│ # module.connect()
├── initialize() # module.initialize() on every module (unchanged)
└── start() # the world server is the sole "bus server";
# fork module processes, start observation sender,
# run control_loop()
connect()callssim_connect(self): it composes the scene into oneMjModel, starts a singleMujocoWorldServerthat owns the physics, and injects aBusClientproxy for every(module, port)so each module's I/O is routed to the world instead of a serial port. Ifviewis on, aSimViewerwindow process is started too.initialize()runs each module'sinitialize()against the live world.start()registers the world server as the only bus server, then forks the module subprocesses, starts the observation sender thread, and enters the control loop — identical to hardware from here on.disconnect()tears down the viewer, then the world server (it is listed inself._bus_servers) and the base robot.
The MujocoWorldServer owns one composed MjModel/MjData, steps the physics in real time, and answers each module's read/write/render requests over a pipe — exactly the same BusClient/BusServer protocol hardware modules use when several of them share one serial port. Because the module subprocesses, observation sender, and control loop are all inherited unchanged from Robot, a real leader arm can drive a simulated follower with zero schema or transport changes.
Scenes
A scene describes the MuJoCo world the robot lives in: the environment (floor, lights, objects), how each module's MJCF model attaches into it, any extra cameras, and physics options. It is a plain declarative SceneConfig you set on the robot:
from solarian.sim import SceneConfig, ModuleAttachment, CameraMount
scene = SceneConfig(
world_mjcf="assets/scene.xml",
attachments=[
ModuleAttachment(module="arm", mjcf="assets/arm.xml", prefix="arm/"),
],
cameras=[
CameraMount(name="wrist_cam", parent_body="arm/gripper",
pos=[0.0, -0.05, 0.02], quat=[1, 0, 0, 0], fovy=70.0),
],
timestep=0.002,
)
- Name
world_mjcf- Type
- str | None
- Description
MJCF for the environment (ground, lights, manipulable objects). If
None, a minimal ground+light world is generated for you.
- Name
attachments- Type
- list[ModuleAttachment]
- Description
One entry per module MJCF to attach into the world. See Attaching modules.
- Name
cameras- Type
- list[CameraMount]
- Description
Extra MuJoCo cameras added to composed bodies (e.g. a wrist camera). See Mounting cameras.
- Name
timestep- Type
- float | None
- Description
Override the physics timestep (seconds). e.g.
0.002= 500 Hz.
- Name
gravity- Type
- list[float] | None
- Description
Override gravity, e.g.
[0, 0, -9.81].
- Name
show_frames- Type
- bool
- Description
Draw coordinate-frame triads in the overview viewer: the world frame at the origin and the end-effector frame of every arm that declares an IK backend. Default
True.
- Name
show_cameras- Type
- bool
- Description
Draw a wireframe frustum for every mounted camera in the overview viewer. Default
True.
How composition works
Scenes are built programmatically with MuJoCo's MjSpec API — not with XML <include>/<attach> tags — by compose_scene():
# solarian/sim/scene.py (excerpt)
world = _load_spec(scene.world_mjcf) # environment (or default world)
for att in scene.attachments:
child = mujoco.MjSpec.from_file(resolve_mjcf(att.mjcf))
parent = _find_body(world, att.parent_body) # "world" → worldbody
frame = parent.add_frame(pos=att.pos, quat=att.quat)
world.attach(child, frame=frame, prefix=att.prefix) # prefix namespaces names
for cam in scene.cameras:
body = _find_body(world, cam.parent_body)
body.add_camera(name=cam.name, pos=cam.pos, quat=cam.quat, fovy=cam.fovy)
model = world.compile() # single MjModel for the whole scene
The compiled MjModel is handed to the MujocoWorldServer, which owns it and serves every module's I/O.
The default world
If world_mjcf is None, compose_scene generates a minimal world — a skybox, a grid floor plane, and a directional light — so you can bring up a robot before authoring an environment:
scene = SceneConfig(
attachments=[ModuleAttachment(module="arm", mjcf="assets/arm.xml", prefix="arm/")],
) # world_mjcf omitted → default floor + light
A custom world_mjcf is a normal MuJoCo XML with your ground, lighting, and any free-floating objects (a manipulable cube, props, fixtures) the robot interacts with. The robot itself is not part of the world MJCF — it is attached at runtime from the module models.
Attaching modules
A scene's attachments list controls how each module's MuJoCo model is placed into the world. Each ModuleAttachment attaches one module's MJCF sub-model to a body of the (already-composed) scene:
ModuleAttachment(
module="arm",
mjcf="assets/arm.xml",
parent_body="world",
pos=[0.0, 0.0, 0.0],
quat=[1.0, 0.0, 0.0, 0.0],
prefix="arm/",
)
- Name
module- Type
- str
- Description
The robot's module attribute name this MJCF belongs to (e.g.
"arm").
- Name
mjcf- Type
- str
- Description
Path to the module's
.xml, or the name of arobot_descriptionsmodule (e.g."so_arm101_mj_description"). See Resolving MJCF paths.
- Name
parent_body- Type
- str
- Description
Body in the scene to attach to.
"world"(default) attaches to the worldbody. Ignored whenparent_siteis set.
- Name
parent_site- Type
- str | None
- Description
Site/frame already in the composed scene to mate against (e.g.
"right/side_bracket"). Requireschild_site.
- Name
child_site- Type
- str | None
- Description
Site/frame in the child MJCF (unprefixed) that should coincide with
parent_site(e.g."side_bracket"). Requiresparent_site.
- Name
pos- Type
- list[float]
- Description
Attachment position
[x, y, z]in metres (body mode), or an optional offset in the parent-site frame (site-mate mode).
- Name
quat- Type
- list[float]
- Description
Attachment orientation as a quaternion
[w, x, y, z](body mode), or an optional offset in the parent-site frame (site-mate mode).
- Name
prefix- Type
- str
- Description
Name prefix applied to every joint / actuator / camera in the child model. This is the namespacing mechanism that lets multiple modules coexist.
The prefix connects the MJCF to your YAML
prefix namespaces the attached model, so a joint named shoulder_pan in arm.xml becomes arm/shoulder_pan in the composed world. Each servo's sim_joint in the module YAML must reference the prefixed name:
# conf/modules/arm.yaml
actuators:
shoulder_pan:
# ... hardware fields ...
sim_joint: "arm/shoulder_pan" # matches prefix="arm/"
sim_range_min: -1.91986
sim_range_max: 1.91986
Mating two frames (site-mate mode)
When both modules export a matching Onshape frame (a <site> in MJCF), attach by coinciding those sites instead of hand-tuning pos/quat against a body origin:
ModuleAttachment(module="right_arm", mjcf="assets/so106.xml", prefix="right/"),
ModuleAttachment(
module="right_gripper",
mjcf=UMI_SLIM_MJCF,
parent_site="right/side_bracket", # already prefixed by the arm attach
child_site="side_bracket", # unprefixed name in the gripper MJCF
prefix="right_gripper/",
)
pos/quat remain available as a fine offset relative to the parent site after the mate.
Resolving MJCF paths
Every mjcf reference (the world and each attachment) is resolved by resolve_mjcf(), in order:
- An absolute path that exists.
- A path relative to
SOLARIAN_PROJECT_PATH(set automatically by the CLI to your project root) — this is the usual case, e.g."assets/arm.xml". - A path relative to the current working directory.
- Otherwise, treated as a
robot_descriptionsmodule name (e.g."so_arm101_mj_description"), using itsMJCF_PATH.
This means you can attach a community model without shipping any files:
ModuleAttachment(module="arm", mjcf="so_arm101_mj_description", prefix="arm/")
A plugin that ships its own model uses option 1: it exposes an absolute MJCF_PATH from its package, so a project attaches it without copying the model into assets/. See Shipping the model inside a plugin.
Bimanual (and multi-module) setups
Attaching two copies of the same arm is just two attachments with different prefixes and offsets — the prefixes keep their identically named joints distinct:
class Bimanual(Robot):
is_sim = True
left_arm = ArmModule()
right_arm = ArmModule()
scene = SceneConfig(
world_mjcf="assets/scene.xml",
attachments=[
ModuleAttachment(module="left_arm", mjcf="assets/arm.xml",
pos=[0.0, 0.2, 0.0], prefix="left/"),
ModuleAttachment(module="right_arm", mjcf="assets/arm.xml",
pos=[0.0, -0.2, 0.0], prefix="right/"),
],
)
left_arm's YAML then maps sim_joint: "left/shoulder_pan", right_arm's maps sim_joint: "right/shoulder_pan", and so on. Internally the world server keys every component as "{module}/{component}", so even without prefixes the two modules stay separate — but a prefix is required so their MJCF joint names don't collide in the compiled model.
Simulated servos
In simulation your ordinary Components — the same FeetechServo, OpenCVCamera, etc. used on hardware — are driven by the MuJoCo world instead of a bus. Each module's port bus is transparently replaced by a BusClient proxy to the shared MujocoWorldServer, so the component's own bus_class (Feetech, OpenCV, …) is simply never used.
At connect time the framework walks every component and infers its MuJoCo role from its type:
- a
Camera→ renders a MuJoCo camera (see Simulated cameras); - any other
Actuator(a servo/motor) → drives a MuJoCo joint + actuator; - a
CartesianIKActuator→ skipped (it is not a MuJoCo joint; it is resolved by the module's IK wrapper over the joint bus).
Each component is keyed as "{module}/{component}" in the world, so identically named servos on different modules stay distinct.
Declare the servo exactly as you would for hardware and add flat sim_* fields on ActuatorConfig. Real and simulated robots read the same conf/modules/<module>.yaml: deployment fields (id, port) are ignored in sim, and the sim_* fields are ignored on hardware. The MuJoCo actuator name defaults to the same string as sim_joint:
# conf/modules/arm.yaml
actuators:
shoulder_pan:
id: 1 # hardware fields — ignored in sim
port: ${actuator_port}
sim_joint: "arm/shoulder_pan" # MJCF joint (after the attach prefix)
sim_range_min: -1.91986 # radians mapped to the low end of `mode`
sim_range_max: 1.91986 # radians mapped to the high end of `mode`
sim_drive_mode: 0 # 1 to invert direction
- Name
sim_joint- Type
- str | None
- Description
MJCF joint the servo reads and writes. The MuJoCo actuator name is the same string. Virtual servos omit this field.
- Name
sim_range_min- Type
- float
- Description
Radians mapped to the low end of the servo's normalized command space.
- Name
sim_range_max- Type
- float
- Description
Radians mapped to the high end.
- Name
sim_drive_mode- Type
- int
- Description
Set to
1to invert direction (mirrors a flipped hardware servo).
The world server maps the servo's normalized command space (e.g. mode: range_m100_100 → [-100, 100]) onto [range_min, range_max] radians on write, and back on read. A write sets the MuJoCo actuator ctrl target and a read returns the present joint position — so the physics loop tracks targets just like a real servo running its own control loop.
Keep sim_range_min/sim_range_max equal to the joint's actual MJCF limits (jnt_range). A mismatch silently skews every normalized ↔ radian conversion. The so106 tests assert the YAML ranges match the model, and onshape-export --update-yaml keeps them in sync.
Virtual servos
Omit sim_joint to make a servo virtual: it holds the last commanded value and echoes it back on read, without touching physics. This is useful when your Observation/Action schema has a joint that the chosen MJCF model doesn't physically simulate:
# no sim_joint → virtual: echoes the commanded value
sim_range_min: -1.57
sim_range_max: 1.57
Simulated cameras
A camera renders a named MuJoCo camera instead of capturing from a device. Use the ordinary OpenCVCameraModule and add a sim: block (SimSensorConfig) naming the camera:
# conf/modules/wrist_camera.yaml
sensors:
camera:
port: ${camera_port} # hardware — ignored in sim
fps: 30
width: 1920
height: 1080
resize_width: 480
resize_height: 270
type: opencv
sim:
camera: wrist_cam # a MuJoCo camera name (mounted via CameraMount)
- Name
camera- Type
- str | None
- Description
Name of the MuJoCo camera to render, mounted onto a body via
CameraMount.
- Name
distortion- Type
- FisheyeDistortionConfig | None
- Description
Optional calibrated fisheye distortion applied to the rendered frames. See Matching a real lens.
The render resolution follows resize_width/resize_height (falling back to width/height), so the observation buffer is sized identically to the real camera — the schema doesn't change between real and sim.
Mounting cameras
CameraMount, in the scene's cameras list, adds a MuJoCo camera to any body in the composed scene, including a body contributed by a module (using its prefix):
- Name
name- Type
- str
- Description
Camera name. A simulated camera's
sim.camerafield references this.
- Name
parent_body- Type
- str
- Description
Body to attach to, e.g.
"arm/gripper"for a wrist camera.
- Name
pos- Type
- list[float]
- Description
- Camera position on the body.
- Name
quat- Type
- list[float]
- Description
- Camera orientation quaternion.
- Name
fovy- Type
- float
- Description
- Vertical field of view in degrees (default 58).
Matching a real lens (fisheye distortion)
MuJoCo renders a rectilinear (pinhole) image. If your real camera is a fisheye (e.g. a GoPro), add a distortion block so the sim frames match the real lens — important when training on mixed real/sim data. The intrinsics K and coefficients D come from cv2.fisheye.calibrate and are rescaled from the calibration resolution to the render resolution automatically (solarian/sim/distortion.py):
sim:
camera: wrist_cam
distortion:
K: [[1000.0, 0.0, 960.0], [0.0, 1000.0, 540.0], [0.0, 0.0, 1.0]]
D: [0.1, 0.01, 0.0, 0.0]
calib_width: 1920
calib_height: 1080
render_scale: 2.0 # supersample the pinhole render before the remap
Models from Onshape
Each module's MJCF model can come straight from an Onshape assembly. solarian onshape-export pulls the CAD through onshape-to-robot, then cleans the raw export into an attachable MuJoCo model you can drop into a scene. The whole process is driven by a single config.json, so once an assembly is set up, re-exporting after a CAD change is one command — and the recipe is version-controlled next to the meshes. It is the pipeline used to build the SO-106 arm and its gripper.
solarian onshape-export <assembly_dir> # full export (needs network)
solarian onshape-export <assembly_dir> --clean-only # re-run only the clean stage
solarian onshape-export <assembly_dir> --keys-file path/to/keys.txt
solarian onshape-export <assembly_dir> --update-yaml conf/modules/right_arm.yaml
<assembly_dir> is a directory containing a config.json. The command writes the cleaned, attachable MJCF (by default to the project's assets/, see output_xml) and prints where it landed.
- Name
--clean-only- Type
- flag
- Description
Skip the Onshape API call and re-run only the clean stage on the previously-exported raw MJCF. Fast iteration on cleaning options (density, self-collision) without re-hitting the network.
- Name
--keys-file- Type
- path
- Description
File with
ONSHAPE_ACCESS_KEY/ONSHAPE_SECRET_KEYlines, loaded into the environment if the keys aren't already set. When omitted, the command triesonshape_keys.txtthen.envin the current directory.
- Name
--update-yaml- Type
- path
- Description
After export, also sync joint ranges from the cleaned MJCF into this module conf (
sim_range_min/sim_range_max). Matches actuators by key name or by the bare name ofsim_joint(attach prefixes likeright/are stripped). Actuators withoutsim_joint/sim_range_minare left alone.config.jsonjoint_properties.*.limitsare always rewritten from the CAD mate limits, with or without this flag.
Credentials
onshape-to-robot reads credentials from the environment. Create an API key pair at dev-portal.onshape.com/keys and either export them:
export ONSHAPE_API=https://cad.onshape.com # the command defaults this for you
export ONSHAPE_ACCESS_KEY=your_access_key
export ONSHAPE_SECRET_KEY=your_secret_key
…or drop them in a git-ignored keys file that the command loads automatically — either onshape_keys.txt or a project .env:
# .env (or onshape_keys.txt)
ONSHAPE_ACCESS_KEY=your_access_key
ONSHAPE_SECRET_KEY=your_secret_key
The config.json
The file is the ordinary onshape-to-robot config (which assembly to pull, joint limits, output_format: mujoco) plus a solarian block that describes how to turn the raw export into an attachable Solarian model:
{
"document_id": "d60e2cd05d40533c485d06e9",
"workspace_id": "361314ab0f218f78e02b4966",
"element_id": "f8420291ebade3c3ad36c733",
"output_format": "mujoco",
"output_filename": "so106_raw",
"assets_directory": "meshes",
"robot_name": "so106",
"joint_properties": {
"shoulder_pan": {"range": true},
"shoulder_lift": {"range": true},
"elbow_flex": {"range": true},
"wrist_flex": {"range": true},
"wrist_yaw": {"range": true},
"wrist_roll": {"range": true}
},
"solarian": {
"dof_mates": ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_yaw", "wrist_roll"],
"base_dof": "shoulder_pan",
"output_xml": "../so106.xml",
"meshdir": "so106_description/meshes",
"density": 1400
}
}
A tool TCP is a named Frame in Onshape on the gripper assembly (call it tcp). onshape-to-robot exports it as a <site>; IK tracks that site after the gripper is attached. Do not invent a synthetic site in config.json.
You can point at the assembly with a single "url" field (the Onshape tab URL) instead of document_id/workspace_id/element_id — onshape-to-robot parses the IDs out of it.
Joint limits flow CAD → MJCF → config.json. On export, any stale joint_properties.*.limits are ignored so Onshape mate limits win; after cleaning, those CAD limits are written back into config.json (and into the module yaml when you pass --update-yaml). Set solarian.prefer_config_limits: true only if you intentionally want config.json to override the assembly.
The solarian block
Parsed into SolarianExportSpec. Every field has a default that reproduces the SO-106 arm's behaviour:
- Name
dof_mates- Type
- list[str]
- Description
CAD names of the revolute/prismatic mates to expose as actuated joints. onshape-to-robot only treats a mate as a joint when its name starts with
dof_; rather than renaming mates in the CAD, the command renames these on the fly in the API responses.
- Name
base_dof- Type
- str | None
- Description
Root the kinematic tree at the body of this DOF that no other DOF touches (the mechanism's base), so the model attaches to the world / a parent body at its base rather than at the tool.
Nonekeeps onshape-to-robot's default root.
- Name
drop_unmated_links- Type
- bool
- Description
Drop links that carry no mate — free-floating duplicate shells the CAD sometimes surfaces. Default
true; keeps the export a single connected mechanism.
- Name
output_xml- Type
- str | None
- Description
Where to write the cleaned MJCF, relative to the assembly dir. Default
../<robot_name>.xml(the projectassets/).
- Name
meshdir- Type
- str | None
- Description
meshdirwritten into the cleaned MJCF (relative tooutput_xml). Defaults to the assembly's assets directory.
- Name
density- Type
- float
- Description
Uniform density (kg/m³) used to infer link mass/inertia from the collision meshes, since the CAD assigns no materials. Default
1400(~1 kg for an SO-ARM-sized part).
- Name
attach- Type
- bool
- Description
Drop the base
<freejoint>so the model attaches rigidly into a scene. Defaulttrue.
- Name
strip_inertials- Type
- bool
- Description
Remove the placeholder inertials and infer mass/inertia from geometry at
density. Defaulttrue.
- Name
self_collision- Type
- bool
- Description
Let the mechanism's own links collide with each other. Default
false: full-link collision meshes overlap by design at every joint and MuJoCo does not auto-exclude parent/child contacts, so self-collision produces permanent interpenetrations that fight the actuators.
- Name
prefer_config_limits- Type
- bool
- Description
When
false(default), ignore stalejoint_properties.*.limitsduring the Onshape pull so mate limits from the assembly win, then rewrite those limits inconfig.jsonfrom the cleaned MJCF. Settrueonly to keep config.json as an intentional override of CAD.
How the export works
The pipeline (solarian/sim/onshape.py) has two stages:
solarian onshape-export <dir>
│
├── Stage 1: raw export (Onshape API + network)
│ ├── clear joint_properties.*.limits (CAD mate limits win)
│ ├── rename dof_mates → dof_<name> (so o2r treats them as joints)
│ ├── root the tree at base_dof
│ ├── drop unmated (free-floating) links
│ └── write <output_filename>.xml + STL meshes
│
├── Stage 2: clean (no network)
│ ├── drop the base <freejoint> (attach)
│ ├── strip inertials + infer mass/inertia (density)
│ ├── set meshdir, non-self-colliding geoms
│ └── write output_xml
│
└── Sync limits: MJCF → config.json (+ module yaml with --update-yaml)
Raw export. onshape-to-robot pulls the assembly and writes a raw MJCF plus one STL per part. Two things it doesn't do out of the box are patched in for the duration of the export (and cleanly restored afterward): exposing your chosen mates as joints, and rooting the kinematic tree at the base so the model hangs off its mount rather than its tool.
Clean. The raw model is a free-floating body with placeholder inertials and no materials. The clean stage makes it attachable: it drops the base free joint, lets the compiler infer mass and inertia from the collision meshes at a fixed density, repoints meshdir, makes the mechanism non-self-colliding, and adds the optional end-effector site. Re-run just this stage with --clean-only while tuning those knobs.
Worked example: adding a gripper
The export gives you an MJCF. Wiring it into a robot is the same as for any model: attach it with a ModuleAttachment and map each servo with sim_joint / sim_range_*. The SO-106 gripper is a separate assembly with a single prismatic jaw mate. Its config.json differs from the arm only in the solarian block:
{
"url": "https://cad.onshape.com/documents/.../w/.../e/...",
"output_format": "mujoco",
"output_filename": "umi_slim_gripper_raw",
"assets_directory": "meshes",
"robot_name": "umi_slim_gripper",
"joint_properties": { "jaw": {"limits": [0.0, 0.037], "range": true} },
"solarian": {
"dof_mates": ["jaw"],
"base_dof": "jaw",
"output_xml": "../umi_slim_gripper.xml",
"meshdir": "umi_slim_gripper_description/meshes",
"density": 1400
}
}
Export it, attach it to the arm's wrist body, and give its servo sim_joint / sim_range_*:
solarian onshape-export assets/umi_slim_gripper_description --update-yaml conf/modules/right_gripper.yaml
right_gripper = GripperModule()
scene = SceneConfig(
attachments=[
ModuleAttachment(module="right_arm", mjcf="assets/so106.xml", prefix="right/"),
ModuleAttachment(
module="right_gripper",
mjcf="assets/umi_slim_gripper.xml",
parent_body="right/so100_6dof_wrist_roll_to_gripper", # the arm flange
prefix="right_gripper/",
),
],
)
# conf/modules/right_gripper.yaml
actuators:
gripper:
id: 7
sim_joint: "right_gripper/jaw"
sim_range_min: 0.0
sim_range_max: 0.037
Tips & gotchas
- Mate names.
dof_matesare the CAD mate names in Onshape (before thedof_prefixing). The resulting MJCF joint/actuator name is the bare mate name, then the attachprefix. - Rooting. If the model attaches by the wrong end (e.g. hangs from the tool), set
base_dofto the DOF at the base of the chain. - Mesh order is nondeterministic. Re-exporting can reorder the
<mesh>asset lines with no functional change; this is cosmetic. - Byproducts. onshape-to-robot caches part metadata (
.partfiles) and may write ascene.xmlwrapper next to the raw export; meshes for dropped unmated links are also downloaded. Only the cleanedoutput_xmland the referenced meshes matter — git-ignore the rest.
Shipping the model inside a plugin
The worked example above keeps the model in the project's own assets/. When the module instead ships as a plugin package — reused across projects — its MJCF should travel with the package. That way every project that installs the plugin gets the model, and there is a single source of truth with no copy to drift out of sync (edit the density in one place, not one-per-project). This is how the solarian-umi-slim gripper works.
1. Keep the assets inside the package. Point output_xml at an assets/ directory inside the importable package, so the export writes there instead of into a project:
solarian-umi-slim/
├── pyproject.toml
└── solarian_umi_slim/ # the importable package
├── __init__.py
└── assets/
├── umi_slim.xml # the attachable model (output_xml)
└── umi_slim_description/
├── config.json # the export recipe
└── meshes/*.stl # meshes, resolved relative to umi_slim.xml
2. Expose the path from the package. MuJoCo resolves a model's meshdir relative to the MJCF file itself, so a scene only needs the absolute path to umi_slim.xml. Publish it from the package __init__.py:
# solarian_umi_slim/__init__.py
from pathlib import Path
#: Simulation assets shipped inside the package.
ASSETS_DIR = Path(__file__).resolve().parent / "assets"
#: Absolute path to the attachable gripper MJCF.
MJCF_PATH = str(ASSETS_DIR / "umi_slim.xml")
MJCF_PATH is an absolute path derived from the installed package location, so it is correct on any machine — no hardcoded paths, and no copy of the model in the consuming project.
3. Attach it by importing the path. ModuleAttachment.mjcf accepts an absolute path, so pass MJCF_PATH straight through:
from solarian_umi_slim import MJCF_PATH as UMI_SLIM_MJCF
ModuleAttachment(
module="right_gripper",
mjcf=UMI_SLIM_MJCF, # loaded from the installed plugin
parent_body="right/so100_6dof_wrist_roll_to_gripper", # the arm flange
prefix="right_gripper/",
)
Making sure the assets are packaged
Meshes and MJCF are not Python files, so setuptools leaves them out of a built wheel unless you declare them — and then MJCF_PATH breaks for anyone who pip installs the plugin (it still works from an editable/source checkout, which is why this is easy to miss). Two requirements:
- The
assets/directory must live inside the importable package (solarian_umi_slim/assets/, as above). Data outside the package can't be shipped as importable package data. - Declare the files as
package-datainpyproject.toml. Ship only the runtime files — leave out the onshape-export byproducts (*_raw.xml,scene.xml,meshes/*.part):
[tool.setuptools.package-data]
solarian_umi_slim = [
"assets/umi_slim.xml",
"assets/umi_slim_description/config.json",
"assets/umi_slim_description/meshes/*.stl",
]
Then verify the build actually contains them before publishing:
python -m build --wheel
unzip -l dist/*.whl | grep assets # expect umi_slim.xml + meshes/*.stl, and no *.part / *_raw.xml
See also
- Robots / Modules — the classes you reuse unchanged.
- Inverse Kinematics — Cartesian control that works in real and sim alike; IK tracks the gripper's CAD
tcpsite, and the arm's end-effector frame is drawn in the viewer. - Plugins — packaging a module (and its model) as a reusable plugin.
- Calibration — keeping the model's joint ranges equal to the real arm's.