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.

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/disconnect use the MuJoCo path instead of hardware.

  • Name
    scene
    Type
    SceneConfig
    Description

    Required when is_sim is 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_VIEW env var (1/0). Best-effort: with no display available the sim runs headless.

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() calls sim_connect(self): it composes the scene into one MjModel, starts a single MujocoWorldServer that owns the physics, and injects a BusClient proxy for every (module, port) so each module's I/O is routed to the world instead of a serial port. If view is on, a SimViewer window process is started too.
  • initialize() runs each module's initialize() 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 in self._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 a robot_descriptions module (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 when parent_site is set.

  • Name
    parent_site
    Type
    str | None
    Description

    Site/frame already in the composed scene to mate against (e.g. "right/side_bracket"). Requires child_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"). Requires parent_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:

  1. An absolute path that exists.
  2. 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".
  3. A path relative to the current working directory.
  4. Otherwise, treated as a robot_descriptions module name (e.g. "so_arm101_mj_description"), using its MJCF_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 1 to 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.

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.camera field 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_KEY lines, loaded into the environment if the keys aren't already set. When omitted, the command tries onshape_keys.txt then .env in 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 of sim_joint (attach prefixes like right/ are stripped). Actuators without sim_joint / sim_range_min are left alone. config.json joint_properties.*.limits are 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.

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. None keeps 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 project assets/).

  • Name
    meshdir
    Type
    str | None
    Description

    meshdir written into the cleaned MJCF (relative to output_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. Default true.

  • Name
    strip_inertials
    Type
    bool
    Description

    Remove the placeholder inertials and infer mass/inertia from geometry at density. Default true.

  • 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 stale joint_properties.*.limits during the Onshape pull so mate limits from the assembly win, then rewrite those limits in config.json from the cleaned MJCF. Set true only 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_mates are the CAD mate names in Onshape (before the dof_ prefixing). The resulting MJCF joint/actuator name is the bare mate name, then the attach prefix.
  • Rooting. If the model attaches by the wrong end (e.g. hangs from the tool), set base_dof to 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 (.part files) and may write a scene.xml wrapper next to the raw export; meshes for dropped unmated links are also downloaded. Only the cleaned output_xml and 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-data in pyproject.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 tcp site, 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.

Was this page helpful?