World building¶
from strands_robots import Robot
sim = Robot("so100") # one arm on flat ground plane
sim.add_robot(name="so100", position=[0.0, 0.5, 0.0]) # second arm
sim.add_object(name="table", shape="box", size=[0.5, 0.5, 0.02],
position=[0.0, 0.0, 0.0], color=[0.5, 0.3, 0.1, 1.0], mass=20.0)
sim.add_camera(name="overhead", position=[0.0, 0.0, 1.5], target=[0.0, 0.0, 0.0])
Setup entry points¶
Robot("so100") is the one-step way to get a ready-to-drive engine: it builds
the world and adds the named robot for you. Constructing a backend directly -
create_simulation("mujoco") or Simulation() - gives an empty engine; you
then call create_world() and add_robot("so100") yourself.
robot_name therefore belongs to Robot(...) and add_robot(...), never to a
backend constructor. Passing it to the constructor
(Simulation(robot_name="so100")) is rejected with a TypeError rather than
silently ignored, so the mistake is caught up front instead of surfacing later
as an unrelated No world error.
Strategies¶
| Need | Approach |
|---|---|
| Add robots / objects incrementally | add_robot / add_object / add_camera |
| Replace entire world | load_scene(scene_path=...) |
| Procedural scene | loop over add_object |
| Raw MJCF tweak without recompile | patch_scene_mjcf(ops) |
Spawn pose (keyframes)¶
By default a robot spawns at the all-zero joint configuration. Many MuJoCo
Menagerie models ship a canonical ready pose in a MJCF <keyframe> (panda,
ur5e, fr3, kuka home; aloha neutral_pose; quadrupeds/humanoids a standing
home). Pass keyframe= to spawn in that pose instead - important when a
policy was trained from the home pose, since the zero configuration is
out-of-distribution:
The pose is applied to the robot's joints by name and is restored by reset(),
so a keyframe spawn is sticky across episodes. An
unknown keyframe name/index
is an error that lists the model's available keyframes. keyframe=None (the
default) keeps the zero-pose spawn. (MuJoCo backend; the Newton backend rejects
keyframe= as not-yet-supported.)
Adding a robot does not disturb the scene it joins¶
Only the robot being added is placed at a defined configuration - its keyframe,
or the zero pose. Everything already in the world is left exactly as it was: an
arm keeps the pose it is in (whether that is its keyframe pose or wherever a
policy or send_action has driven it) and the actuator setpoints holding it
there, objects stay where they settled or were carried to, latched apply_force
wrenches persist, and the clock keeps counting.
So a scene can be composed in any order, and a robot can be added mid-session without invalidating what has already happened in it:
sim.run_policy(robot_name="panda", ...) # arm ends up somewhere useful
sim.add_robot(name="helper", data_config="so101", position=[0.0, -0.6, 0.0])
# 'panda' is still where the rollout left it; 'helper' starts at its zero pose
To return the whole world to its initial state - every robot, every object and
the clock - call reset(), which is what that method is for.
Declared physics options¶
A robot MJCF may declare the solver settings its contacts and actuators were
tuned for. add_robot carries them onto the scene, because MuJoCo's <option>
is model-global and does not survive the spec attach:
sim.create_world()
sim.add_robot(name="panda") # model declares integrator="implicitfast"
sim.mj_model.opt.integrator # -> mjINT_IMPLICITFAST
This matters for manipulation. Under the default Euler integrator a Panda's
position servos diverge enough that a top-down grasp pushes the object away and
squeezes through it; so100, so101, aloha, shadow_hand and robotiq_2f85
likewise declare cone="elliptic" impratio="10" so their grippers can hold load.
Precedence, highest first:
| Source | Wins for |
|---|---|
create_world(timestep=, gravity=) |
timestep, gravity - always |
Your own scene MJCF (replace_scene_mjcf) |
any field it sets |
| First robot attached that declares the field | everything else |
A model-global field holds one value, so if a second robot declares a different value for a field already set, the existing value is kept and the discarded request is logged with the field, both values and the robot name. Add that robot first, or declare the value in your own scene MJCF, to make it win.
Vector environment fields (wind, magnetic, contact overrides) and the flag
bitfields describe the world rather than the robot and are never adopted.
Adoption is committed only once the robot is actually in the scene, so an
add_robot that reports an error leaves the world's solver settings exactly as
they were - and leaves the field free for the next robot that declares it.
Rough terrain¶
By default create_world() lays down a flat ground plane. A locomotion
policy is only interesting on ground it can trip on, so pass a terrain=
kind to lay down a deterministic heightfield instead - a floating-base robot
then settles onto and walks over it. Four kinds ship:
terrain="rough"- smoothed value-noise bumps (robustness to uneven ground).terrain="stairs"- a flight of discrete step plateaus rising along +x (foot placement + climbing).terrain="pyramid"- concentric square step plateaus rising toward the centre from every direction (an omnidirectional climb).terrain="slope"- a constant-grade inclined ramp rising along +x (a continuous uphill pitch).
sim.create_world(terrain="rough") # bumpy heightfield ground
sim.add_robot("unitree_go2", keyframe="home")
The field spans the same +/-5 m footprint as the flat plane (the reachable
workspace is unchanged), its surface ranges from 0 up to ~8 cm on a solid
base slab (flush with z=0 at its lowest point, so a robot never falls
below the nominal floor), and it is regenerated identically on every
reset() (deterministic given the terrain kind), so a benchmark that
evaluates a policy on rough ground is reproducible. terrain only applies
when ground_plane=True (the default, which is the master floor switch);
an unknown kind is rejected with an error listing the supported kinds. It
is the ground-generation primitive a terrain curriculum (progressive
difficulty across resets) builds on. (MuJoCo backend; the Newton backend
rejects terrain= as not-yet-supported.)
That curriculum knob is difficulty, which scales the terrain's peak
elevation (the metre height its normalized [0, 1] field maps to) without
changing the terrain kind:
sim.create_world(terrain="rough", difficulty=0.3) # gentle bumps (early stage)
# ... later, harder stages ...
sim.create_world(terrain="rough", difficulty=1.0) # full ~8 cm bumps (default)
sim.create_world(terrain="rough", difficulty=2.0) # exaggerated ~16 cm bumps
difficulty=1.0 (the default) is the full-height terrain, byte-identical to
omitting it; <1 is gentler, >1 harsher. It must be a finite number
> 0 - the same positive-real domain every other continuous knob accepts, so
0, a negative value, nan/inf, a bool (True is not a scale, even
though it is an int subclass) and a string (including a numeric one like
"0.5") are all refused with a structured error naming the parameter. Every
backend reports through that one domain, so a scale one create_world refuses
cannot be honored by another. It only applies with a terrain - setting
difficulty != 1.0 on a flat world (no terrain) is rejected with an error
rather than silently having no effect. A locomotion curriculum ramps difficulty across resets to grow the
terrain the policy must handle.
A floating-base robot added to a terrain world (or reset in one) spawns
SEATED on the local terrain surface: its base is raised by the heightfield
height beneath its (x, y) so its feet rest on the ground, rather than at
the flat-ground keyframe height (which would leave them buried below a raised
heightfield). A flat ground plane and a fixed-base arm (no free joint) are
unaffected.
Procedural objects¶
import random
sim = Robot("so100")
for i in range(5):
sim.add_object(
name=f"cube_{i}", shape="box", size=[0.025, 0.025, 0.025],
position=[random.uniform(0.2, 0.5), random.uniform(-0.15, 0.15), 0.025],
color=[random.random(), random.random(), random.random(), 1.0],
)
Object size¶
size is the full extent in meters along each local axis - not MuJoCo's
native half-extent. It is halved when the geom is compiled, so
size=[0.05, 0.05, 0.05] is a 5 cm cube.
Pass every component the shape consumes; a partial vector is rejected rather
than completed from a default, because a completed vector compiles a
differently-sized object while add_object reports success:
| Shape | Components consumed |
|---|---|
box / ellipsoid |
[x, y, z] - all three full edge lengths / diameters |
cylinder / capsule |
[diameter, unused, full height] - three (index 1 is ignored) |
sphere |
[diameter] - one is enough |
plane |
[x] or [x, y] visual half-widths (y mirrors x when omitted) |
mesh |
none - the asset's own units define the extent |
At most 3 components are accepted; omit size entirely for the 5 cm default.
sim.add_object("crate", shape="box", size=[0.5])
# status=error: box needs 3 'size' component(s) [x, y, z] full edge lengths,
# got 1 (size=[0.5]). ...
sim.add_object("crate", shape="box", size=[0.5, 0.5, 0.5]) # 50 cm crate
Every component must also be a finite number, and that part of the domain is
shared with the Newton and Isaac backends' add_object - word for word, not just
verdict for verdict - so an extent one backend refuses is refused by all three
with the same message. A nan/inf, boolean, None or otherwise non-numeric
component is rejected by name rather than reaching the solver, a NumPy array is
accepted and normalized to plain floats, and a value that is not a vector at all
is refused instead of raising from whatever first tries to iterate it:
sim.add_object("crate", shape="box", size=[float("nan"), 0.1, 0.1])
# status=error: add_object: 'size' must contain finite numbers (no nan/inf),
# got [nan, 0.1, 0.1]
sim.add_object("crate", shape="box", size=0.5)
# status=error: add_object: 'size' must be a list/tuple of numbers, got 0.5
sim.add_object("crate", shape="box", size=np.array([0.5, 0.5, 0.5])) # accepted
An empty size is a component count, not an omission, so it is rejected
rather than quietly taking the default extent - omit size (or pass None) to
ask for the default. Here the three backends agree on the verdict but not on the
wording, because MuJoCo reaches an empty vector through the per-shape count above
and so names the count the shape needs:
sim.add_object("crate", shape="box", size=[])
# status=error: box needs 3 'size' component(s) [x, y, z] full edge lengths,
# got 0 (size=[]). ...
The per-shape counts in the table above remain MuJoCo's alone. Newton and Isaac
accept a short size (Isaac documents completing the missing trailing components
from defaults), and neither bounds a component to be positive, so a vector this
backend refuses on either of those axes may still be accepted there. Converging
the three is tracked in
#1858.
Object mass¶
mass (kg) applies to dynamic objects and must be a finite number greater than
zero - the same domain set_body_properties(mass=...) enforces when it writes
the same body, and the same one the Newton and Isaac backends' add_object
applies, so a mass one backend refuses is refused by all three. A mass outside it
is rejected up front, naming the parameter, instead of surfacing as a recompile
failure:
sim.add_object("crate", shape="box", mass=0)
# status=error: add_object: 'mass' must be a finite number > 0, got 0.0
sim.add_object("crate", shape="box", mass=1e-16)
# status=error: add_object: 'mass' must be >= MuJoCo's mjMINVAL (1e-15 kg) ...
This matters beyond the one object: a body's mass divides every force acting on
it, and the solver keeps a single state vector, so an infinite mass turns the
whole world's qpos/qvel to nan on the next step - every other body
included. is_static=True needs no mass (MuJoCo derives it from the geom's
density), so mass is ignored there - and not validated, on any backend, since
nothing reads it. The Newton backend additionally documents mass=0 as an
alternative spelling of is_static=True and keeps accepting it; MuJoCo and Isaac
refuse a zero mass and name that flag as the remedy.
Whatever the reason for a rejection - mass, size, an unsupported shape, an
unloadable mesh - the scene is rolled back to its previous compilable state and
the object name stays reusable, so a corrected retry under the same name works
and one bad add never bricks later scene edits.
Mesh objects¶
Beyond primitives, add_object can inject a triangle-mesh asset (STL/OBJ) into
the live scene at runtime. Pass shape="mesh" with a mesh_path to the asset
file; the extent is defined by the mesh's own units, so size is ignored.
sim.add_object(name="bracket", shape="mesh", mesh_path="/abs/path/bracket.stl",
position=[0.3, 0.0, 0.1])
mesh_path is required for shape="mesh" - a mesh without a path is rejected
with an actionable error rather than an opaque recompile failure. If the mesh
file cannot be loaded the add is rejected and the scene is rolled back to its
previous compilable state (including the mesh asset), so the object name stays
reusable and one bad add never bricks later scene edits.
Materials and textures¶
By default an object renders with a flat color (rgba) - a glossy, obviously
synthetic primitive. Pass material= to add_object to attach a real MuJoCo
material so the surface can be matte or carry a texture. This narrows the
sim-to-real visual gap for VLM/VLA policies trained on real footage. The
color (rgba) still applies and tints a textured or solid material.
# Matte (non-plastic) surface: kill specular highlight + shininess.
sim.add_object("apple", shape="sphere", size=[0.04, 0, 0], color=[0.8, 0.1, 0.1, 1],
material={"specular": 0, "shininess": 0, "reflectance": 0})
# Image texture from disk (absolute path), tiled 2x2 across the surface.
sim.add_object("table", shape="box", size=[0.5, 0.5, 0.02], is_static=True,
material={"texture": "/abs/path/wood.png", "texrepeat": [2, 2],
"specular": 0, "shininess": 0})
# Procedural builtin texture (no image file needed).
sim.add_object("floor_tile", shape="box", size=[0.3, 0.3, 0.01], is_static=True,
material={"builtin": "checker", "rgb1": [0.2, 0.3, 0.4],
"rgb2": [0.1, 0.2, 0.3], "texdim": 512})
material is a dict; all keys are optional:
| Key | Type | Meaning |
|---|---|---|
reflectance / specular / shininess |
float 0..1 | Surface response. specular=0, shininess=0 = matte; the defaults read as glossy plastic. |
texrepeat |
[u, v] |
Texture tiling across the surface. |
texture |
str | Absolute path to an image file (PNG/etc.) used as the RGB texture. |
builtin |
"checker" \| "gradient" \| "flat" |
Procedural texture, coloured by rgb1 / rgb2 and sized texdim (default 512) per side. |
Specify either texture or builtin, not both. An invalid texture
path, an unknown builtin name, or specifying both fails loudly with a
ValueError (returned as a status=error dict through the agent tool) - there
is no silent fallback to the flat-plastic default.
Only the keys in the table above are accepted. A key outside it (a typo such as
rgb_1, or a field borrowed from another renderer such as roughness), an
empty material={}, or rgb1/rgb2/texdim without builtin is rejected the
same way - the alternative is an object that compiles with MuJoCo's glossy
defaults while add_object reports success:
sim.add_object("cube", material={"builtin": "checker", "rgb_1": [1, 0, 0]})
# status=error: unknown material key(s): 'rgb_1' (did you mean 'rgb1'?).
# Accepted keys: builtin, reflectance, rgb1, rgb2, shininess, ...
For natural surfaces prefer
an image texture; the checker builtin reads as a literal checkerboard.
Materials are currently supported by the MuJoCo backend; the Newton backend
rejects a non-None material rather than silently ignoring it.
Surgical MJCF edits¶
patch_scene_mjcf(ops) applies a list of structured ops to the live spec and
recompiles once, preserving joint state for untouched joints. Each op accepts
only the keys it reads:
| Op | Keys |
|---|---|
add_body |
parent (default "world"), name (required), pos, quat |
add_geom |
body (required), type (default "box"), size, rgba, name, pos, quat |
add_site |
body (default "world"), name (required), pos, size, rgba |
set_body_pos |
name (required), pos |
set_body_quat |
name (required), quat |
delete_body |
name (required) |
Any other key is rejected. Every field above has a fallback default (pos the
origin, quat identity, type "box", parent the worldbody), so a key the op
does not read is not inert - it would leave that default in place while the patch
reports success:
sim.patch_scene_mjcf([{"op": "set_body_pos", "name": "crate", "position": [0.4, 0, 0.9]}])
# status=error: set_body_pos: unknown op key(s): 'position' (did you mean 'pos'?).
# Accepted keys: name, op, pos.
Every numeric field an op writes is held to the domain the scene-construction calls apply to the same buffer:
| field | accepted |
|---|---|
pos |
exactly 3 finite components |
quat |
exactly 4 finite components |
rgba |
3 (RGB, completed with an opaque alpha) or 4 finite components |
size |
finite components, in the count the geom's shape consumes |
MuJoCo bakes a nan/inf component into the model without complaint, so an
unchecked one reports success and only surfaces later as a poisoned physics
state. A wrong component count is reported by the library rather than left to
MuJoCo, which for the two attribute-assigning ops (set_body_pos,
set_body_quat) dumps a C++ overload table naming neither the op nor the field:
sim.patch_scene_mjcf([{"op": "set_body_pos", "name": "crate", "pos": [float("nan"), 0, 0.3]}])
# status=error: set_body_pos: 'pos' must contain finite numbers (no nan/inf),
# got [nan, 0, 0.3]
sim.patch_scene_mjcf([{"op": "set_body_pos", "name": "crate", "pos": [0.4, 0.9]}])
# status=error: set_body_pos: 'pos' must be a 3-element vector, got 2 ([0.4, 0.9])
A three-component rgba is the same RGB add_object(color=...) accepts, so the
two surfaces that write geom_rgba agree on what a colour is:
sim.patch_scene_mjcf([{"op": "add_geom", "body": "rig", "type": "box",
"size": [0.1, 0.1, 0.1], "rgba": [0.9, 0.3, 0.1]}])
# status=success - stored as [0.9, 0.3, 0.1, 1.0]
The batch is atomic: if any op is rejected the world is rolled back to its
pre-patch state, so a bad key or a non-finite component never leaves a
half-applied scene. Use
replace_scene_mjcf(xml) for MJCF elements this vocabulary does not cover.
Cameras¶
Free cameras look from position toward target (fov=60.0, width=640, height=480). Robot-URDF cameras (wrist, etc.) are auto-discovered on add_robot - no add_camera needed.
To mount a camera ON a moving body (a realistic wrist/gripper view that rides with the arm), pass parent_body. Body names are namespaced <robot>/<body>; discover the exact mount point with list_bodies instead of guessing:
bodies = sim.list_bodies(robot_name="so101")["content"][1]["json"]
mount = bodies["gripper_body"] # e.g. "so101/gripper" -- the wrist mount
sim.add_camera(name="wrist", parent_body=mount,
position=[0.0, 0.0, 0.05], target=[0.0, 0.0, 0.1]) # local frame
list_bodies() (no robot_name) lists every body in the world; with robot_name it scopes to that robot and also returns gripper_body, the best-guess end-effector mount.
A mounted camera survives remove_robot, which rebuilds the whole scene: it is
re-mounted on its body once every surviving robot is re-attached, keeping its
local pose and its tracking. Removing the robot the camera is mounted ON leaves
it with no mount point, so that camera is dropped (with a warning naming it)
rather than blocking the removal.
Multi-robot policies¶
from strands_robots.policies import create_policy
sim.run_multi_policy(
policies={"so100": create_policy("mock"), "panda": create_policy("mock")},
instructions={"so100": "pick cube", "panda": "hold tray"},
duration=10.0,
)