Available plugins

Built-in plugins by package. Reference each by its short name in a world file (or by module:Class / file.py:Class for your own — see Interfaces).

The static environment — ground, light, enclosure — is one slot, filled either by a world definition selected with sim.world (default empty_room; see Architecture & porting playbook) or by a scene plugin that builds its own, such as the mobile floorplan or heightfield. A fixed cell needs no scene plugin. Naming both is refused, and so is carrying two such plugins: two grounds compile into a scene that looks right and is not the one you wrote.

Which route a given environment takes follows from whether it is baked or computed. A world definition is a builder or an MJCF file — fixed geometry, chosen by name. A plugin builds from a config, so a sweep can vary it and each run records the values it used. An environment whose shape is an experiment’s variable is therefore a plugin.

floorplan takes its building from one of two sources, and you pick by what you have. A mesh: is right when the building already exists as geometry (imported from CAD, or produced by Floorplan-DSL); its json-ld supplies exact colliders. Wall segments are right when the walls are the experiment’s variable:

components:
  - floorplan:
      lines:
        - {id: 0, x0_m: 0.0, y0_m: 0.0, x1_m: 6.0, y1_m: 0.0}
        - {id: 1, x0_m: 6.0, y0_m: 0.0, x1_m: 6.0, y1_m: 4.0}
      doors: [{line_id: 0, t: 0.5, width_m: 0.9}]
      height: 2.5

floorplan: rooms.json reads the same shape from the file roqsim scenes dxf-to-floorplan and the scene-builder’s sketch window write, which is the usual way in. Segments build one box per wall — visible and collidable, since a box is already convex, so there is no hidden companion collider to disagree with what is drawn. A corridor width is then an ordinary config value a sweep varies and the run’s provenance records, rather than a mesh baked ahead of time that nothing downstream can tell apart from another mesh. Naming both sources, or neither, is refused. The wall arithmetic is shared with the mesh baker and the plan-view renderer, so a preview, a baked world and this plugin cut the same openings.

Any endpoint-producing plugin below accepts an optional topics: map to set an endpoint’s ROS topic. An absolute name hardwires it, overriding the namespace+default — e.g. topics: {image: /camera/color/image_raw}; a relative name renames it inside the namespace — e.g. topics: {scan: scan2} for a robot’s second scanner. See Architecture & porting playbook › Hardwired topics.

Core (roqsim)

bumper

Flags: parallel_safe

Observation plugin: a bumper – which ZONE of a body is being pushed, every step.

Config:

bumper:
  # The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
  # declaring it at the top of a document is refused (`requires_owner`).
  body: ""               # base body override; default: the entity's registered base body
  namespace: ""          # transport scope for the endpoints
  zones:                 # bearing sectors, radians in the base frame, counter-clockwise from
    front: [-0.52, 0.52] #   +x: {<zone>: [from, to]}. A sector with from > to wraps through
    left: [0.52, 1.57]   #   +/-pi, so a rear zone is `[2.6, -2.6]`.
  geoms: []              # geom NAMES that ARE the bumper (default: the entity's whole subtree,
                         #   like contact_monitor). A real bumper is one shell; a contact on the
                         #   chassis roof presses no switch, so a model that names its shell
                         #   geoms lists them here.
  geom_prefixes: []      # geom name prefixes that are the bumper
  ignore: [floor]        # geom NAMES that never count (default: ['floor'])
  ignore_prefixes: []    # geom name prefixes that never count (e.g. ['ground'])
  min_force: 1.0         # N; contacts below this normal force are ignored (numerical grazing)
  rate_hz: 62.0          # endpoint publish rate

One out endpoint per zone, named bumper/<zone>, reads a bool: is that zone pressed this step. The ROS 2 backend hint publishes each as a std_msgs/Bool on bumper/<zone> (relative, so it is scoped by the entity’s namespace). A stack that wants a vendor’s message assembles it from these in its own adapter node – a bumper switch is a bool on every robot, and the vendor’s envelope around it is the stack’s business, not the simulator’s.

Read it through the blackboard, not the endpoint, inside a control loop. ctx.blackboard.get(f"bumper:{address}") returns a callable giving the current BumperReading, the same convention contact_monitor and contact_location use.

Not latched, on purpose: a bumper releases when the robot backs off, and a stack that needs “it bumped at some point” reads contact_monitor. Where a contact falls into no declared zone (behind a robot with a front bumper only), nothing is pressed – exactly as a shell that is not there reports nothing, and as contact_monitor still reports the collision.

ceiling

Flags: parallel_safe

Scene plugin: open a world’s roof by deleting its ceiling geometry at build time.

Config:

ceiling:
  keep: true         # true = keep the ceiling (no-op, the default, so adding this plugin never
                     # surprise-deletes geometry). false = remove it.
  above_z: 2.5       # a geom is "ceiling" iff its whole world-space AABB is above this height (m)

Set keep: false in the world YAML (or via --set / an extends override / a campaign factor) to open the roof.

The key is keep rather than enabled because this plugin is SUBTRACTIVE: it does its work by removing geometry, so it is the entry running that opens the roof. enabled: is the reserved sibling meaning “run this component at all”, so ceiling: {enabled: false} leaves the ceiling standing – the opposite of what someone writing it wants. It is refused by name rather than obeyed.

Removal happens in build (pre-compile); the engine’s dedup_assets pass then drops the textures the removed geoms leave unreferenced.

clearance_monitor

Flags: parallel_safe

Observation plugin: how close an entity came to something it should not touch.

Config:

clearance_monitor:
  body: ""               # base body override; default: the entity's registered base
  ignore: [floor]        # geom NAMES that never count (default: ['floor'])
  ignore_prefixes: []    # geom name prefixes that never count
  distmax: 5.0           # [m] cutoff: beyond this the distance is not computed
  compute_rate_hz: 200.0 # how often the distance is MEASURED
  rate_hz: 30.0          # how often the endpoint is PUBLISHED

Endpoint clearance (out) reads a ClearanceReport: (current, minimum, at_time, geom, saturated) – minimum is the closest approach since the last reset and geom names what it was to, so a near-miss is attributable rather than merely flagged.

compute_rate_hz is separate from rate_hz because they answer different questions. Publishing is cheap; measuring is a distance query per (watched geom, candidate geom) pair, which on a nav world costs about a quarter of the step budget if done every physics step – against a budget the simulator may already be over. Poses reach a consumer at ~30 Hz, so measuring at 200 Hz resolves ~1.5 mm at walking pace: far finer than anything downstream can use, at a fraction of the cost. Raise it if a fast pass matters more than throughput; the trade is stated here rather than hidden in a default.

distmax is the other performance knob and it is a real one: the cost is a distance query per (watched geom, candidate geom) pair, and the cutoff lets MuJoCo reject far pairs cheaply. Beyond it the report reads current == distmax with saturated true, which says “at least this far” rather than offering a number that looks measured.

contact_impulse

Observation plugin: how hard a watched entity was hit, integrated over the run.

Config:

contact_impulse:
  # The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
  # declaring it at the top of a document is refused (`requires_owner`).
  body: ""               # base body override; default: the entity's registered base body
  namespace: ""          # transport scope for the endpoint
  ignore: [floor]        # geom NAMES that never count (default: ['floor'])
  ignore_prefixes: []    # geom name prefixes that never count (e.g. ['ground'])
  reset_on_spawn: true   # spawning the watched entity restarts the integral
  rate_hz: 30.0          # endpoint publish rate -- of the RUNNING TOTAL, not of the integrand

Endpoint contact_impulse (out) reads a ContactImpulseReport: (impulse_ns, peak_normal_n, contact_time_s, normal_n, count, peak_time, peak_geom_a, peak_geom_b). The three totals run from the last reset; normal_n and count are the current step’s, so the report says what it is integrating as well as what it has integrated. peak_time is the sim time of the largest single-step load (-1.0 if nothing was touched) and the two geom names are that step’s strongest single contact, so a severity figure is attributable rather than merely large.

contact_time_s is the time a qualifying contact existed, which is contact_monitor’s notion of touching and is longer than the time force was transmitted: MuJoCo goes on listing a pair while the two geoms still overlap on the way apart, and those steps carry a zero normal force. The impulse is unaffected – a zero integrand adds nothing – and the alternative, a duration that switched off before the monitor’s in_contact did, would be the second rule this plugin exists not to have. The ROS 2 backend hint publishes impulse_ns as a std_msgs/Float64 on contact_impulse; a consumer that wants the rest reads the report through the blackboard handle contact_impulse:<address>.

Publishing is rate-limited and the integral is not. rate_hz decides how often the running total leaves the plugin, and no value of it can lose a contact: the total a slow endpoint publishes is the same total a fast one publishes, just later. There is deliberately no compute_rate_hz knob of the kind clearance_monitor and contact_location offer – decimating this computation would drop the samples the integral is made of, and the number would then be a function of the knob.

It never ends a trial, the line clearance_monitor and energy_monitor draw as well. What counts as too hard is the experiment’s threshold, stated in the experiment; a scenario reads the endpoint and decides for itself.

What a reset does. on_reset – ResetSimulation with SCOPE_STATE, and what runs between the trials one process serves – zeroes all three totals. Without it the second cell of a campaign starts with the first cell’s collisions on its bill. reset_on_spawn (default true) does the same when the watched entity gains presence, because an entity that was not in the world a moment ago carries no history and because contact_monitor restarts there too – one of the two carrying a contact the other had forgotten is exactly the disagreement this plugin is shaped to avoid. A SetEntityState is a pose and a twist, and resets nothing.

Untouched, the report reads impulse_ns = 0.0, peak_normal_n = 0.0, contact_time_s = 0.0 and peak_time = -1.0 – a measured zero, which is what “nothing was hit” is.

contact_location

Flags: parallel_safe

Observation plugin: report where a watched entity is being touched, every step.

Config:

contact_location:
  # The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
  # declaring it at the top of a document is refused (`requires_owner`).
  body: ""               # base body override; default: the entity's registered base body
  namespace: ""          # transport scope for the endpoint
  ignore: [floor]        # geom NAMES that never count (default: ['floor'])
  ignore_prefixes: []    # geom name prefixes that never count (e.g. ['ground'])
  min_force: 1.0         # N; contacts below this normal force are ignored (numerical grazing)
  merge_radius: 0.02     # m; contacts closer than this count as one sensing area
  frame: base            # 'base' -> report in the watched body's frame; 'world' -> world frame
  compute_rate_hz: 0.0   # how often the reading is COMPUTED; 0 = every physics step
  rate_hz: 30.0          # how often the endpoint is PUBLISHED

Endpoint contact_location (out) reads a ContactLocation: (in_contact, kind, x, y, z, extent, count, time). kind is "none", "point" or "line"; x/y/z is the region’s centre and extent the distance between its two furthest members (0.0 for a point), so a consumer gets both the location and how spread out it is. The ROS 2 backend hint publishes the centre as a geometry_msgs/PointStamped on contact_location.

Read it through the blackboard, not the endpoint, inside a control loop. The endpoint is rate-limited for logging; ctx.blackboard.get(f"contact_location:{address}") returns a callable giving the current reading, the same convention contact_monitor and force_torque use.

Cost. The per-step work is a vectorised pass over data.contact – a mask lookup per contact and an xor – and a contact force query only for the handful that survive it. That matters because a world’s contacts are overwhelmingly pairs the robot is not in (props resting on the floor, a crowd’s feet), and touching each of them from Python costs more than the physics step that produced them. compute_rate_hz decimates the whole computation for the case where even that is too much; it defaults to 0, meaning every step, because unlike a latching monitor this plugin cannot recover a contact it did not look at – one shorter than the interval is simply missed.

Frames. frame: base (the default) rotates the position into the watched body’s own frame, which is what a controller reasoning about “contact on my left” wants and what makes a reading independent of where the robot happens to be standing. frame: world leaves it in world coordinates.

contact_monitor

Flags: parallel_safe

Observation plugin: report when an entity touches something it should not.

Config:

contact_monitor:
  # The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
  # declaring it at the top of a document is refused (`requires_owner`).
  body: ""               # base body override; default: the entity's registered base body
  namespace: ""          # transport scope for the endpoint
  ignore: [floor]        # geom NAMES that never count as a collision (default: ['floor'])
  ignore_prefixes: []    # geom name prefixes that never count (e.g. ['ground'])
  min_force: 1.0         # N; contacts below this normal force are ignored (numerical grazing)
  latch: true            # once true, stay true until on_reset (a trial is failed, not un-failed)
  reset_on_spawn: true   # spawning the watched entity restarts the report (see below)
  rate_hz: 30.0          # endpoint publish rate

Endpoint contact (out) reads a ContactReport: (in_contact, first_time, count, geom_a, geom_b) – first_time is the simulation time of the first qualifying contact since reset (-1.0 if none), and geom_a/geom_b name the geoms of that first contact, so a failure is attributable rather than just flagged. The ROS 2 backend hint publishes in_contact as a std_msgs/Bool on collision (relative, so it is scoped by the entity’s namespace: two namespaced robots get /a/collision and /b/collision); a bridge that wants the detail reads the fields directly.

The watched set is the entity’s kinematic subtree: for a mobile base that is the chassis plus its wheels, so a wheel clipping a box counts exactly as much as the bumper does.

When the trial spawns the watched entity. reset_on_spawn (default true) restarts the report when the watched entity GAINS PRESENCE. An entity that has just been spawned has no history: it was not in the world a moment ago, so nothing it touched before it went absent is a fact about it now, and with latch that would otherwise be a permanent one.

Only presence does this. A SetEntityState does not, because the service is specified as “an instant change in its pose and/or twist” and nothing more – a simulator that also cleared an observer there would be answering a question the caller did not ask. The standard’s verb for discarding accumulated state is ResetSimulation with SCOPE_STATE, which reaches this plugin’s on_reset() like any other.

That distinction decides how a trial should place a robot it does not want observed at the pose the world compiled it at: spawn it into position rather than teleport it there. An absent entity’s geoms carry no contype/conaffinity, so it registers no contact at all while absent, and presence and pose are applied in ONE transaction – so there is no step in which it is perceivable where the world happened to put it. A trial that teleports a present robot instead is observed at the compiled pose, and an obstacle placed there by a campaign that never knew about it is a collision before the trial has moved.

Set reset_on_spawn false where a re-spawned entity should carry its history forward.

contact_pair_override

Scene plugin: override the contact between ONE pair of things.

dummy

Flags: parallel_safe

A trivial plugin used to validate the framework end-to-end without any assets.

Config:

dummy:
  size: 0.1   # half-extent (m) of the free-floating box this plugin adds; must be > 0

energy_monitor

Observation plugin: what a robot’s actuators cost it, integrated over the run.

Config:

energy_monitor:
  # The entity is the one this entry is NESTED UNDER (`requires_owner`): a battery belongs to a
  # robot, and which actuators count is decided by which ones move it.
  actuators: []            # names to meter (default: every actuator driving this entity's bodies)
  efficiency: 1.0          # mechanical -> electrical; 0 < e <= 1
  idle_w: 0.0              # W drawn regardless of motion (compute, sensors)
  resistive_w_per_nm2: 0.0 # winding loss k in k*tau^2; a number, or {actuator_name: k}
  regenerative: false      # credit negative mechanical power back
  capacity_wh: 0.0         # 0 = no battery modelled: energy is still reported, charge is not
  voltage: 0.0             # V, nominal; 0 = unknown, and the current is then not reported
  rate_hz: 5.0             # endpoint publish rate

Endpoint battery (out) reads an EnergyReport and carries a sensor_msgs/BatteryState hint on battery_state – the message a real platform publishes, so a stack that already watches a battery needs no change. An EnergyReader is published on the blackboard under energy:<address> for an in-process consumer, and the report carries the raw joules as well as the derived state of charge, because the metric a paper quotes is usually the integral, not the fraction. For the same reason it carries torque_integral_nms, the integral of the summed absolute actuator forces: where a platform’s electrical constants are not published, that effort integral is the metric a paper falls back on, and accumulated here it is the physics-rate quantity rather than a sum over whatever rate /joint_states happened to be published at.

The integral is accumulated on the physics thread, every step, not on read: a rate-limited or subscriber-gated sample would silently integrate a different signal depending on who was listening. It is integrated against elapsed sim time rather than a fixed dt so a replay over recorded samples (see roqsim.recording) accumulates the same way, at its own spacing.

heightfield

Flags: provides_world

World plugin: outdoor ground – a height field, from a DEM or generated.

Config:

heightfield:
  source: generated      # 'generated', or a path to .npy / .png / .tif (relative to the world)
  size: [20.0, 20.0]     # ground extent in metres (x, y)
  height: 1.5            # metres from the lowest sample to the highest
  base: 1.0              # metres of solid below the lowest sample -- a wall, not a shell
  resolution: 96         # samples per side, when generated (a DEM keeps its own)
  seed: 0                # generated terrain is reproducible from this
  roughness: 0.55        # 0..1: how much each finer octave contributes
  octaves: 4             # how many scales of detail
  friction: [1.0, 0.005, 0.0001]   # ground friction, as MuJoCo's three coefficients
  rgba: [0.42, 0.38, 0.30, 1.0]
  light: true            # add a ceiling-height light (the world definition it replaces had one)

Why it provides the world. sim.world builds a floor; a terrain that let one be built would put a plane through its own hills. Declaring provides_world is how the floorplan plugin already says the same thing, and the engine then skips the world definition and warns if one was also asked for.

Contact against a height field is against its triangles, not a smoothed surface, so the sample spacing is the resolution of every wheel and foot interaction: 96 samples over 20 m is a 21 cm grid, which a 10 cm wheel rides as facets. Raise resolution for a small rough patch rather than for a large smooth one – the cost is quadratic and it buys nothing where the ground is flat.

joint_state_publisher

Flags: parallel_safe

Observation plugin: every joint of an entity as one joint_states message.

Config:

joint_state_publisher:
  # The entity read is the one this entry is NESTED UNDER; at the top of a document it is
  # refused (`requires_owner`).
  namespace: ""          # transport scope for the endpoint
  joints: []             # joint NAMES to publish, the model's own before any spawn prefix;
                         #   default: every hinge and slide joint of the entity's subtree
  rate_hz: 50.0          # endpoint publish rate

Endpoint joint_states (out) reads (names, positions, velocities, efforts); the ROS 2 backend hint publishes it as sensor_msgs/JointState on joint_states (relative, so it is scoped by the entity’s namespace). Names are published without the spawn prefix, as every other producer here names joints, so a description published alongside matches them.

Only hinge and slide joints are published: a JointState carries one scalar per joint, which a ball or free joint’s quaternion is not. A named joint that is not in the model, or not on this entity, raises – a state nobody publishes is a consumer reading zeros as a fact.

model_override

Flags: parallel_safe

Fault plugin: change named MuJoCo model values while a run is in progress, on an external trigger.

Config:

model_override:
  overrides:                       # one or more; each names a field, a selection and a target
    - field: geom_friction         # must be on the allowlist (see `field_catalog`)
      select: [pad_left, pad_right]  # names in the field's own namespace (geom/body/actuator/joint)
      bodies: []                   # ...or every geom of these bodies' subtrees (geom fields only)
      entity: ""                   # ...or an entity's body subtree (geom fields only)
      to: 0.0                      # scalar (broadcast) or the field's full row
  active: false                    # initial state; false = nominal, i.e. the plugin is inert
  namespace: ""                    # transport scope; defaults to this instance's `name:`, so two
                                   # faults in one world do not both serve `/override`
  rate_hz: 10.0                    # publish rate of the two out endpoints

Endpoint override (in) is a service, std_srvs/SetBool: apply or restore, replying success plus a message carrying the verdict – so a scenario’s service_call() can fail the trial when a fault did not land, instead of a warning nobody reads. Endpoints override_state (Bool of active) and override_verified (String of verified) publish continuously, because a service call leaves no trace in a rosbag and mjModel is in neither the bag nor the state recording – without them an injected fault is invisible to every downstream analysis.

All three are scoped by the instance’s name:, so the world above serves /grip_fault/override, /grip_fault/override_state and /grip_fault/override_verified.

In-process, ctx.blackboard carries a ModelOverrideHandle under model_override:<name>, which is how a ROS-free stepped run (an .osc action, a test) fires it – and the plugin imports no ROS at all, so a world using it runs in a plain venv with no middleware installed. The service is what the same fault looks like when a bridge is present.

payload

Component plugin: a carried payload, as added mass on a robot’s body.

Config:

payload:
  mass: 2.5           # kg, REQUIRED -- added to the body's own mass
  body: tray          # body to load (default: the entity's root body)

The payload is a point mass at the body’s centre of mass: mass adds, and the inertia a point mass contributes about its own centre is zero. An offset payload is a different physical object – it shifts the centre of mass and adds a parallel-axis inertia term, changing the attitude dynamics rather than the load alone – and representing it means adding a child body before compile. An offset key is therefore refused rather than approximated.

The mass is applied in configure, after compile: the entity registry is what maps robot: to a prefixed MJCF body name, and it is populated by the spawn plugins during configure. Writing model.body_mass and re-deriving the cached constants with mj_setConst is the same mechanism roqsim.plugins.model_override uses for body_mass.

The mass is stated in the world and therefore recorded with it, so a run’s provenance carries the payload even though the compiled MJCF’s own inertial does not.

spawn_model

Scene plugin: place a static model (a prop) into the world from the world YAML, at a fixed pose.

Config:

spawn_model:
  model: industrial_table        # bundled model name, filename, or absolute path
  prefix: ""                     # MJCF name prefix (use distinct prefixes for >1 of the model)
  pose:                          # the mount pose, as SpawnEntity states one (see below)
    position:    {x: 0.0, y: 0.0, z: 0.0}
    orientation: {roll: 0.0, pitch: 0.0, yaw: 0.0}
  scale: 1.0                     # uniform geometric scale factor (see below)
  motion: physics                # who owns the pose: physics (default; a movable body),
                                 #   static (welded scenery), driven (a plugin writes it)
  mocap: false                   # make it a mocap body: moved by a plugin, not by physics
  present: true                  # false: compiled in, but absent until it is spawned
  mass: 0.5                      # override the root body's total geom mass (kg)
  friction: [1.2, 0.005, 0.0001] # override the root body's geom friction (or a single sliding val)
  publish_tf: false              # publish the root body's world pose as TF (see below)
  tf_rate: 30.0                  # publish_tf: dynamic -- stream rate (Hz)

name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names the entity this spawn registers (default: the plugin ref). Written inside the config block it is silently inert, and anything addressing the prop by the name you chose then resolves to nothing.

mass and friction exist so the two properties that decide whether a grasp holds are world-YAML keys, and therefore ordinary campaign factors – a sweep over payload or surface friction needs no new variation plugin, just ParameterVariationList against these. mass rescales the root body’s geom masses in proportion, keeping the mass distribution of a multi-geom prop; friction accepts a single sliding coefficient or the full [sliding, torsional, rolling] triple. Both are refused when the prop has nothing to scale, rather than silently doing nothing.

A prop is in one of three states, and they are mutually exclusive: welded scenery (the default), a free body physics moves, or a mocap body some plugin drives. free and mocap name the two non-default ones.

motion: driven makes the prop’s root body a MuJoCo mocap body: it has no degrees of freedom, so it costs the solver nothing and nothing can push it, but it is still collision geometry a lidar sees and a robot bumps into. Its pose is written every step by whoever owns it – a navigator component nested under this entry, say – rather than integrated. That is what a controlled obstacle is: it goes where the experiment says, and the robot under test cannot shove it off course. Like free, it is re-seated at its spawn pose on on_reset (through mocap_pos/mocap_quat rather than a joint), so a repetition never inherits where the last one left it.

motion: physics adds a <freejoint/> to the prop’s root body, making it a body physics moves – a box a robot can pick up. It also registers the joint as the entity’s base_joint, which is what lets simulation_interfaces’ SetEntityState teleport or re-seat it (the service rejects any entity without one), and what on_reset uses to put it back at its spawn pose between episodes instead of leaving it wherever the last run dropped it.

The prop must end up with mass. MuJoCo derives it from geom volume x density (default 1000), so an ordinary prop is fine as-is; a geom carrying density="0" – the convention for visual-only decoration, used throughout the robot models here – is not, and MuJoCo rejects a massless moving body at compile time rather than simulating it.

Pair it with publish_tf: dynamic: nothing else publishes a free body’s pose.

scale resizes the prop at spawn time, so one asset serves every size a scene needs instead of the library carrying a folder per size (a 2.9 m wall screen and a 1.2 m one are the same model). It is applied to the loaded child spec before attach, and covers the whole geometry – mesh scales, and the positions/sizes of bodies, geoms, sites, lights and joints – so a prop built from primitives or from several offset parts scales as one piece rather than coming apart. Two spawns of one model at different scales stay distinct assets: the dedup key in roqsim.assets includes mesh.scale.

It is deliberately a single uniform factor. Non-uniform scaling is ill-defined for a sphere or a capsule and silently shears any child body that is rotated relative to its parent, so a prop that must be stretched on one axis needs a purpose-built plugin instead (as door does for its leaf). scale is geometry only – it does not touch mass or inertia, which is why it suits the static scenery this plugin places (there is no free joint) and not a dynamic body.

pose: is the mount pose, in the shape SpawnEntity.srv gives its initial_pose. Its orientation may be a quaternion or Euler angles, so a prop turned about +Z stays short:

- spawn_model: {model: industrial_table, pose: {position: {x: -0.13, y: 0.6},
                                                orientation: {yaw: 1.5708}}}

It is the only way to state one, for the reason roqsim.pose gives: a document declaring where a thing sits and a SpawnEntity call placing it are the same pose, so they are written the same way. Omitting it mounts the prop at the origin, unrotated; an omitted position.z is the floor, which is what a prop’s frame means by z (a robot’s is its model’s resting height, since a wheeled base has one and a prop does not). scale, mass and friction are unaffected – they are properties of the prop, not of where it is.

present: false compiles the prop in and starts it absent: nothing sees or touches it, and the control plane does not list it, until SpawnEntity brings it in at the pose that call states. This is how a world provides the spares for an obstacle that must appear mid-trial – roqsim never recompiles, so everything a trial may bring in is declared up front (see roqsim.presence). The declared value is restored on on_reset, so a spare spawned in one episode is a spare again in the next; without that, repetitions after the first would begin with an obstacle already in the room.

It is not a way to leave a prop out: enabled: false does that, and does it properly, by never building the body at all. An absent prop still costs its geometry in the compiled model – that is what makes it spawnable.

Unlike the spawn_* plugins for robots/sensors this does not pull in a model manifest – a prop is inert geometry with no intrinsic controller or sensors. Place several by listing the plugin multiple times with distinct prefix (and name).

publish_tf puts the spawned root body’s world pose on TF so a viewer binds the scene node by name (child_frame_id == the exported body name) and a TF-tree consumer (rviz, an rso_web_backend federation) gets the frame. It has no effect on the baked web scene, which already seats the body at its spawn pose.

  • false (default): a static prop already seated by the baked scene needs no TF.

  • dynamic (or true): stream the live world pose on the relative tf topic at tf_rate. For a free body (a <freejoint/> prop the robot moves) – nothing else publishes its pose, so a name-binding viewer would otherwise freeze it at the spawn pose. The ros2_bridge’s gt config maps the relative topic to /gt/tf; a multi-robot gateway federates it under the robot’s scope.

  • static: publish the world pose once on the latched /tf_static (a welded prop’s frame for the TF tree). The pose is model-fixed, so one mj_forward at configure resolves it.

upright_monitor

Flags: parallel_safe

Observation plugin: did a body a trial drives in the plane stop being in the plane?

Config:

upright_monitor:
  # The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
  # declaring it at the top of a document is refused (`requires_owner`).
  body: ""               # base body override; default: the entity's registered base body
  max_tilt_deg: 30.0     # degrees the body's own +z may lean from world +z
  max_rise_m: 0.10       # metres its height may depart from where it settled, either way
  settle_s: 0.5          # sim time to let the body come to rest before judging it, and the
                         #   moment its reference height is taken
  namespace: ""          # transport scope for the endpoint
  latch: true            # once fallen, stay fallen until on_reset (a trial is failed, not
                         #   un-failed -- the same rule contact_monitor follows)
  rate_hz: 30.0          # endpoint publish rate

Endpoint upright (out) reads an UprightReport. The ROS 2 backend hint publishes upright as a std_msgs/Bool on upright (relative, so two namespaced entities get /a/upright and /b/upright); a consumer wanting the detail reads the fields.

The reference height is where the body SETTLED, not where it was spawned, which is what settle_s buys. A body spawned twenty centimetres above the floor drops onto it, and measuring against the spawn would call that drop a departure – flagging every world whose author did not place a body to the millimetre, which is a monitor people turn off. Nothing is judged and no reference is taken until settle_s; it is measured per episode, so a repetition compares against its own start.

The cost is that a body already broken at t=0 is not reported for half a second. That is the right trade: the verdict latches, so it is reported a moment later rather than not at all, whereas a false positive on a correct world is reported forever. Set settle_s: 0 where a trial starts in contact and the first instant matters.

Both thresholds are departures, not limits. max_rise_m is symmetric: a body sinking through the floor has left the plane exactly as much as one taking off, and a run where the ground gave way is no more usable than one where the pedestrian flew. max_tilt_deg is the angle between the body’s own +z and the world’s, so it says nothing about yaw – a body turning on the spot is doing what a planar trial expects.

Not a failure criterion for a robot that is meant to tip. A quadruped mid-gait, an aerial vehicle banking, an arm’s wrist – all of these leave the plane on purpose. This watches an entity because somebody nested it under one; nothing infers that an entity should be upright.

roqsim_aerial

multirotor_motors

Actuation plugin: normalized per-rotor commands -> rotor thrusts and their reaction torques.

Config:

multirotor_motors:
  robot: drone                # entity name registered by spawn_robot
  namespace: ""               # transport scope (default: inherited from spawn_robot)
  body: x500                  # root body the reaction torque acts on (default: entity's root)
  rotors: [rotor0_thrust, rotor1_thrust, rotor2_thrust, rotor3_thrust]
  spin: [1, 1, -1, -1]        # +1 = CCW seen from above, -1 = CW; PX4 quad-X order
  max_thrust: null            # N per rotor; default: read from each actuator's ctrlrange
  moment_constant: 0.05       # m, yaw torque per newton of thrust (PX4 CA_ROTOR*_KM)
  time_constant: 0.02         # s, first-order motor + ESC lag

``max_thrust`` defaults to the model’s own ``ctrlrange``, not to a constant here. The MJCF is the authority on its actuator limits; a hardcoded default would let a model and its actuation plugin disagree about what “1.0” means, and the disagreement would show up as a thrust-to-weight ratio nobody chose.

``moment_constant`` is k_m/k_f, the torque a rotor drags per newton of thrust it makes, in metres. The default 0.05 is not a generic propeller estimate: it is PX4’s published value for this airframe, CA_ROTOR*_KM in 4001_gz_x500. This number must agree between the airframe and the flight stack’s mixer. PX4’s control allocator inverts its own KM to decide how much differential thrust a commanded yaw moment needs; if the simulator drags a different amount per newton, every yaw command is scaled wrong – sluggish or oscillatory yaw that reads as a badly tuned rate loop rather than as two halves of the system disagreeing about a propeller. It is config here rather than a gear in the MJCF because it is a motor/propeller property, so a re-propped airframe changes it without touching the frame.

The spin sign convention, which is the easy thing to get backwards. A rotor spinning CCW (+1) pushes air down and, by reaction, drags the airframe CW – i.e. in -z. So the yaw torque a rotor applies to the body is -spin_i * k_m * T_i about body z, and the contracted pattern (+1, +1, -1, -1) sums to zero at equal thrust: two rotors of each handedness, which is the whole reason a quad has two of each. Yaw is commanded by spinning up the pair of one handedness and down the pair of the other, which is also why yaw authority is the weakest axis on a multirotor.

The reaction torque is applied in the WORLD frame. data.xfrc_applied is a cartesian force/torque about the body CoM expressed in world coordinates, so the body-z torque is rotated by the body’s rotation before it is written. Writing a body-frame torque straight in is correct only while the drone is level – and it is wrong precisely when the drone is tilted, which is when yaw control is being exercised.

Air matters. density/viscosity default to 0 in MuJoCo, so a world that does not set them flies this drone through a vacuum: full rotor authority, no aerodynamic damping, and a disturbance that never settles. The plugin warns rather than silently flying in vacuum.

px4_sitl

Bridge plugin: PX4 SITL flies this airframe, over PX4’s simulator-agnostic MAVLink HIL API.

Config:

px4_sitl:
  port: 4560                 # PX4's documented simulator port; PX4 connects OUT to us
  bind: "127.0.0.1"          # loopback: PX4 SITL runs beside the simulator
  lockstep: true
  body: x500                 # the flown body (default: the entity root)
  imu_rate: 250.0            # Hz -- HIL_SENSOR cadence
  mag_field: {north: 0.21, east: 0.0, down: 0.43}     # gauss, world NED
  baro: {sea_level_pressure: 1013.25, temperature: 20.0}   # hPa, degC
  sensor_noise: {accel: 0.02, gyro: 0.002, mag: 0.002, baro: 0.05}   # 1-sigma
  connect_timeout: 60.0      # s to wait for PX4 before failing
  ground_truth: true         # send HIL_STATE_QUATERNION

PX4 is the client, we are the server. Confirmed against PX4’s own SITL startup path, which runs simulator_mavlink start -c <port> (or -h/-t with PX4_SIM_HOSTNAME / PX4_SIM_HOST_ADDR): the module dials out to the simulator. The port is 4560 + instance, so the default here is right for a single vehicle and a second PX4 instance wants port: 4561.

250 Hz is not a guess. The same startup path sets IMU_INTEG_RATE to 250, which is the rate PX4 integrates HIL_SENSOR at; the default here matches it. That path also sets SENS_GPS0_DELAY/SENS_GPS1_DELAY to 10 ms, i.e. EKF2 is told to expect a fix that is 10 ms old – so timestamping HIL_GPS with the current sim time, as this plugin does, is already consistent with PX4’s assumption and no artificial delay belongs here.

Thrust numbers live in the model, never here. HIL_ACTUATOR_CONTROLS is normalized 0..1 and goes straight through MotorsHandle.set_normalized; what a 1.0 is worth in newtons is the MJCF’s ctrlrange, which multirotor_motors reads and exposes as max_thrust. A number duplicated here would be one that could disagree with the airframe being flown.

``imu_rate`` must divide sensibly into the sim rate. Sensors are sent on whole ticks, so the achieved rate is 1 / (ceil(1 / (imu_rate * dt)) * dt): at the usual dt = 0.002 s an imu_rate of 250 Hz lands exactly (every 2nd tick), 300 Hz silently becomes 250. The plugin logs the rate it will actually achieve rather than the one that was asked for.

Zero-noise sensors are not the neutral choice. It is tempting to send the physics engine’s exact values and call the result “the ideal case”, but EKF2 is tuned against noisy inputs: its process and measurement covariances assume a certain amount of jitter, and a perfect IMU is an out-of-distribution input for it – innovation gates behave differently, covariances collapse, and the estimator’s behaviour stops being the one that flies on hardware. The defaults here are small but non-zero for that reason; set them to zero deliberately and knowing that the run no longer says anything about the estimator.

The magnetic field default [0.21, 0.0, 0.43] G (north, east, down) is a representative mid-latitude northern-hemisphere IGRF value (~0.48 G total, ~64 degrees inclination, declination taken as zero). It is a stand-in for the datum’s actual field, not a computed one: this plugin does not carry an IGRF model, so an experiment whose result depends on declination must set the field for its datum explicitly.

Frames. MuJoCo is ENU (world) / FLU (body); MAVLink HIL is NED (world) / FRD (body). Every conversion goes through enu_to_ned(), flu_to_frd() and quat_enu_flu_to_ned_frd(), and nowhere else. This is not fussiness: a sign error here produces a drone that flies confidently in the wrong direction, arms and takes off exactly as it should, and is the single most common way this integration is got wrong. One helper pair means one place to check.

Threading follows architecture.rst section 7 exactly. The socket is opened in configure (which is where that section puts socket setup) and served by one background thread, and that thread never touches ctx.data, the motors handle, or any plugin state the physics thread reads. It parses frames and enqueues the result with ctx.post(...); the engine drains the queue at the start of the next pre_step, on the physics thread, in FIFO order. So the actuator update lands at a defined point in the tick rather than whenever the OS scheduled the reader.

Lockstep is architecture.rst section 10 – the designed synchronous mode, of which this plugin is the first real user. It registers a consumer gate (ctx.register_gate), pending until the expected input arrives via ctx.post, exactly as that section specifies. The engine does not yet wait on gates (“today register_gate/gates exist and are reset each reset(), but nothing waits on them”), so the wait itself is implemented here, in post_step, against that gate – with the timeout and the deadlock diagnostic section 10 requires, naming the gate that never fired. It is not a second concurrency scheme: the command queue is still the substrate, and this only adds the wait. Blocking inside ``post_step`` is listed as an anti-pattern “except deliberately in sync mode” – this is that exception, taken deliberately, and it is the reason the block is confined to one clearly named place. When the engine grows its own gate wait, this method becomes a gate.satisfy and nothing else changes.

Lockstep arms only after PX4’s first answer, and that is not a convenience. PX4 SITL runs on a lockstep_scheduler whose clock is set from the timestamps in the HIL_SENSOR messages we send: nothing in PX4 makes progress until sim time advances, including its own startup script and therefore every module that could ever publish actuator_outputs – the topic HIL_ACTUATOR_CONTROLS is emitted from. Blocking on the very first batch is consequently a true deadlock, not a slow start: the bridge waits for controls that require a boot that requires the sensor stream the bridge has just stopped. Verified against PX4 v1.18.0-beta2, which sits at ERROR [simulator_mavlink] poll timeout 0, 25 forever. PX4’s own gazebo-classic bridge carries exactly this gate (received_first_actuator_). So the first ticks free-run; from the first HIL_ACTUATOR_CONTROLS onward every tick is locked, and the gate stays armed across a reset because PX4 did not reboot.

What is locked to what: on each tick that carries a sensor batch, the plugin sends HIL_SENSOR (plus HIL_GPS at the receiver’s own rate) and then blocks until PX4’s HIL_ACTUATOR_CONTROLS for that batch has been received and queued; PX4 in turn blocks until the next HIL_SENSOR. Neither side runs ahead, so the number of physics steps between two actuator updates is fixed by construction. With ``lockstep: false`` the run is NOT reproducible: how many steps elapse between two actuator updates then depends on host load and OS scheduling, so two runs of the identical world with the identical seed diverge, and repetitions stop being samples of anything. Non-lockstep exists only for interactive flying, where a stall in one process should not freeze the other. (The sensor noise itself is reproducible either way – it draws from ctx.rng_for, keyed on (seed, episode, sim_time) per section 9, like every other stochastic plugin here. It is the coupling that lockstep makes deterministic.)

MAVLink encoding is pymavlink’s. Imported lazily in configure() so that loading a world containing this plugin (to render it, to export it) does not require the dependency, and so the error names the package. Hand-rolling a serialiser for a dialect that is generated from XML and versioned upstream would be a maintenance liability disguised as a saved dependency.

quadrotor_controller

Controller plugin: quadrotor position + attitude control over collective thrust and body moments.

Config – a component of the entry that spawns the drone, since ownership is where the entry sits rather than a config key:

quadrotor_controller:
  namespace: ""                 # transport scope (default: inherited from spawn_robot)
  body: cf2                     # the drone's root body (default: the entity's root)
  thrust_actuator: body_thrust  # collective thrust, in newtons
  moment_actuators: [x_moment, y_moment, z_moment]
  target: [0.0, 0.0, 1.0]       # position setpoint (x, y, z), world frame
  yaw: 0.0                      # heading setpoint (rad)
  max_tilt: 0.5                 # rad, cap on commanded tilt from vertical
  max_accel: 4.0                # m/s^2, cap on the commanded horizontal acceleration
  max_vel: 1.5                  # m/s, cap on the velocity a position error may ask for
  kp_pos: [3.0, 3.0, 12.0]      # position gains (x, y, z)
  kd_pos: [2.4, 2.4, 6.0]       # velocity gains
  kp_att: [0.0096, 0.0096, 0.0038]    # attitude gains (roll, pitch, yaw), N*m per unit error
  kd_att: [0.00086, 0.00086, 0.00051] # body-rate damping, N*m per rad/s

The moment actuators carry a negative gear, so a positive ctrl produces a negative body moment. The sign is read from the model at configure time rather than hardcoded – it is upstream’s convention, and a future airframe need not share it.

The attitude gains are in newton-metres, not normalised units, because the controller emits a moment and the model’s actuator gear converts it to ctrl. They are sized from the airframe: for a body inertia I and a target attitude bandwidth wn with damping zeta, kp_att ~ I*wn^2 and kd_att ~ 2*zeta*I*wn. The defaults are I = 2.4e-5 kg*m^2 at wn = 20 rad/s, zeta = 0.9. This is also why the Crazyflie’s moment gear is tuned rather than upstream’s: at the arbitrary 1e-5 N*m upstream ships, full deflection buys 0.42 rad/s^2 and no attitude loop can track a position controller’s tilt command – the drone hovers perfectly and flies away the moment it is asked to translate. See the port log.

Air matters. density/viscosity default to 0 in MuJoCo, so a world that does not set the world’s density/viscosity flies the drone through a vacuum – no drag, and a lateral step never settles. The plugin logs a warning rather than silently flying in vacuum.

wind_field

Flags: parallel_safe

World plugin: time-varying wind – steady flow, a discrete gust, and Dryden turbulence.

Config:

wind_field:
  steady: [2.0, 0.0, 0.0]     # m/s, world frame -- the mean flow
  gust:                       # optional 1-cosine discrete gust (MIL-F-8785C shape)
    magnitude: 4.0            # m/s at the peak
    axis: [1.0, 0.0, 0.0]     # direction (normalised; defaults to `steady`, else +x)
    onset: 5.0                # s, when it starts
    duration: 1.0             # s, rise-and-fall length
  turbulence:                 # optional continuous Dryden turbulence
    intensity: 0.6            # sigma, m/s (per horizontal axis)
    length_scale: 5.0         # L, m -- larger = slower, more correlated gusting
    vertical: 0.5             # sigma scale on the w axis (Dryden's low-altitude form)

Wind is a signal, not a value. model_override refuses every opt.* global at runtime, because a fault-injection write would make the value a run recorded differ from the value that ran. The wind here is fully determined by the parameters above plus the run’s seed, both recorded with the world: re-run the same world with the same seed and the same wind happens, tick for tick.

One owner per knob: declaring both sim.wind and this plugin is refused rather than merged, because the compiled model would say one thing, the first tick another, and the run’s provenance would record the value that was immediately overwritten. State the mean flow in steady:.

Randomness comes from ``ctx.rng_for``, like every other stochastic plugin, so turbulence is a pure function of (sim.seed, episode, sim_time) and this plugin has no seed key of its own. Because episode is part of the key, repetitions of one configuration see different turbulence: repetitions are samples of the weather, not copies of it, which is what makes averaging over them mean anything.

Wind is inert in a vacuum. MuJoCo applies wind as a relative velocity into the density and viscosity drag terms, so with both at 0 this plugin has no effect whatever – a silent, plausible-looking failure, so it warns.

roqsim_assets

box

Scene plugin: a parametric rectangular box obstacle placed from the world YAML.

Config:

box:
  prefix: ""           # MJCF name prefix (use distinct prefixes for >1 box)
  pose:                # world placement (REQUIRED), as SpawnEntity states one; omit z to
    position: {x: 0.0, y: 0.0}     #   sit the box ON the floor, give z to place its CENTRE
    orientation: {yaw: 0.0}        #   a full rotation, so a box may be tipped onto an edge
  size: [0.4, 0.4, 0.8]  # full extents (not half-extents), metres (REQUIRED)
  color: [r,g,b,a]     # default a light warehouse grey; alpha optional
  collide: true        # false -> visual only (raycast still sees it; nothing bumps into it)
  friction: 1.0        # sliding friction, or the full [sliding, torsional, rolling] triple
  motion: physics      # who owns the pose: physics (default; movable and TELEPORTABLE),
                       #   static (welded scenery), driven (a plugin writes it)
  motion: driven       # a plugin writes the pose: collidable, immovable, and NOT in a
                       #   navigator's planner grid

size is deliberately full extents, not MuJoCo half-extents: a world file describes a 0.4 m box, and halving it in your head is exactly the kind of silent factor-of-two a scene should not ask of its author.

By default the box is welded scenery – static, with no free joint – like every other plugin in this package. motion: physics gives it a free joint, which buys two things: physics can move it, and simulation_interfaces’ SetEntityState can teleport it (that service rejects any entity without a free base_joint).

Teleporting is how an obstacle appears mid-trial. roqsim never recompiles the model at runtime, so there is no spawning: a box that must show up on cue is compiled in at build time, kept out of the way, and moved into place by the scenario when the moment comes. Between episodes on_reset puts it back at its declared pose, so a trial never inherits the previous trial’s obstacle position.

A teleport states the box’s centre, and no part of it may be inside the floor. The z this file’s pose: lets you omit is a convenience of the world, not of the service: SetEntityState states every field, so a scenario placing a floor-standing box asks for half its height and gets what it asks for. A free box seated inside the floor is not parked – the solver answers the penetration by launching it metres upward before it settles, in view of the run. Keep it out of the way by making it ABSENT (DeleteEntity, which leaves its pose alone) or by moving it sideways, never downward.

motion: driven is the third state, and it is what makes this plugin’s opening paragraph achievable without a scenario at all. A mocap box has no degrees of freedom, so nothing can push it, and it is excluded from a navigator’s planner grid by the same rule that excludes walkers and driven props – that grid holds only what cannot move. So a mover plans straight through it and has to discover it with its forward probe, which is exactly the “obstacle the robot is not supposed to know about” this plugin exists for. Welded scenery cannot do that job: it lands in the grid and gets routed around.

boxes

Scene plugin: many parametric boxes from one config value.

Config:

boxes:
  instances: []       # list of box configs, each accepting every key `box` does (required)

ceiling_panels

Scene plugin: a parametric field of flat acoustic ceiling panels under a soffit.

Config:

ceiling_panels:
  prefix: ""          # MJCF name prefix (distinct prefixes for >1 field)
  area: [x0, y0, x1, y1]  # world rectangle to cover (REQUIRED)
  z: 3.5              # soffit height, m -- panels hang below this
  drop: 0.04          # gap between soffit and panel top, m
  panel: [1.8, 0.6]   # panel size [x, y] before `yaw`, m
  pitch: [2.6, 1.6]   # grid spacing [x, y], m (>= panel, or the panels would overlap)
  thickness: 0.04     # panel thickness, m
  yaw: 0.0            # rotation of the whole field about its centre, rad
  color: [r,g,b,a]    # panel colour (default near-white); alpha optional
  emission: 0.35      # 0..1 self-illumination -- a panel faces away from every lamp in the room
                      #   (they hang at the same height), so without it white panels render as
                      #   dark grey slabs. This is what makes them read as the white they are.

Panels are visual only (no contacts): they are out of reach of anything driving on the floor, and a convex collider per panel would be pure cost. The lidar raycaster still sees them – mj_multiRay ignores contype – so an upward-looking sensor reads the panelled ceiling, as it should.

conveyor

Scene + controller plugin: a velocity-driven belt conveyor.

Config:

conveyor:
  namespace: ""         # optional transport scope for the speed endpoint (/<ns>/speed)
  prefix: ""            # MJCF name prefix (distinct prefixes for >1 belt)
  model: conveyor       # bundled model name / path
  pos: [0.0, 0.0, 0.0]  # world placement of the belt model
  rpy: [0.0, 0.0, 0.0]  # belt orientation as roll/pitch/yaw (rad)
  length: 2.442         # optional full belt length (X, m); default keeps the base model
  width: 0.58           # optional full belt width  (Y, m); default keeps the base model
  speed: 0.1            # initial belt speed (m/s); negative reverses
  friction: 0.6         # optional belt<->package sliding friction override
  roller_radius: 0.0275
  belt_wrap: 0.025      # +/- position wrap (m); keep <= half the slab overhang
  package_pose: [1.0, 0.6, 0.996, 1, 0, 0, 0]  # free-body reset pose (x y z qw qx qy qz),
                                               # in the BELT's frame -- pos/rpy are applied to it

cylinder

Scene plugin: a parametric cylindrical obstacle placed from the world YAML.

Config:

cylinder:
  prefix: ""           # MJCF name prefix (use distinct prefixes for >1 cylinder)
  pose:                # world placement (REQUIRED), as SpawnEntity states one; omit z to
    position: {x: 0.0, y: 0.0}     #   stand it ON the floor, give z to place its CENTRE
                       #   [x, y, z] places its CENTRE at z (REQUIRED)
  radius: 0.075        # metres (REQUIRED)
  height: 0.5          # full height, metres (REQUIRED)
  color: [r,g,b,a]     # default a light warehouse grey; alpha optional
  collide: true        # false -> visual only (raycast still sees it; nothing bumps into it)
  friction: 1.0        # sliding friction, or the full [sliding, torsional, rolling] triple
  motion: physics      # who owns the pose: physics (default; movable and TELEPORTABLE),
                       #   static (welded scenery), driven (a plugin writes it)
  mass: null           # total mass, kg. Unset -> MuJoCo's default density (1000 kg/m^3), which
                       #   for a hollow container is several times too heavy

height is the full height and radius is a true radius, matching how a world file talks about a post. MuJoCo’s cylinder geom wants [radius, half-height]; that conversion happens here so it never has to happen in your head – the same reason box takes full extents.

mass sets the geom’s total mass and lets MuJoCo derive the inertia from the shape, so it stays a solid cylinder’s inertia at the stated mass. It is a separate key from the geometry because the two are independent for anything the size of a drinks can: the diameter is set by what the fingers can span, the mass by what the object is made of and whether it is full.

cylinders

A population of CylinderPlugin instances.

door

Scene + controller plugin: a hinged (swing) door with a ROS-controllable opening angle.

Config:

door:
  prefix: door_1_        # MJCF name prefix (distinct per door)
  pos: [x, y, 0]         # opening CENTRE, [x, y] or [x, y, z] world placement
  rpy: [0, 0, yaw]       # orientation; yaw aligns the closed leaf along its wall
  width: 0.9             # opening / leaf width (m)
  height: 2.0            # leaf height (m)
  thickness: 0.04        # leaf thickness (m); box leaf only
  leaf: true             # false -> a cased opening: the casing is welded, no leaf is hung
  model: door            # optional leaf mesh model (door | door_glass | pkg:name); omit -> box
  color: [r, g, b, a]    # repaint the leaf (omit -> the model's own colours); alpha optional
  frame: true            # weld a static jamb/lintel casing around the opening (frame_model)
  frame_model: door_frame  # the static frame model to weld (visual-only)
  frame_color: [r,g,b,a]   # repaint the casing (default: follow `color`)
  mount_offset: 0.06     # hang the leaf this far proud of the wall (m), on the swing side, so it
                         #   clears the reveal and can open past 90 deg
  hinge_side: left       # 'left' | 'right' -- which edge of the opening is fixed (the hinge)
  swing: 1               # +1 / -1 -- which way the leaf opens about +Z
  max_angle: 120         # fully-open hinge angle (deg); a leaf can swing well past 90 in free
                         #   space -- raise/lower per door for what its surroundings allow
  open: 0.0              # initial openness fraction 0..1 ('how open'); held until commanded
  controllable: true     # expose the ROS endpoints/action; false = passive, held at 'open'
  namespace: ""          # transport scope -> /<ns>/cmd , /<ns>/state , /<ns>/door
  kp: 40.0               # position-actuator stiffness
  kv: 8.0                # position-actuator damping (velocity gain)

The leaf-mesh convention (model:) mirrors the rest of the asset library but with one addition: a door model’s origin is its hinge (fixed) vertical edge at floor level, the leaf extending along +X and up +Z, so the plugin can hang it on the hinge with no per-model offset. A box leaf (no model) is built to the same convention procedurally.

Frame. Unless frame: false, a static jamb/lintel casing (the frame_model, default door_frame) is welded around the opening while the leaf hinges inside it. The frame is centred on the opening and rescaled to width/height like the leaf; it is visual-only (the wall already collides), so it never narrows the doorway. A missing frame model is a warning, not an error (bare opening).

Leafless openings. leaf: false welds the casing but hangs no leaf – a cased opening (German Türblatt-less door): no hinge joint, no actuator, no ROS surface, nothing to command. Use it where a wall opening should read as a doorway rather than a hole, while staying a door in the floorplan so the room loops it belongs to are unchanged. It is still registered as a door entity (on the casing body), so anything enumerating the building’s doors still finds it. leaf: false with frame: false would place nothing at all and is refused.

Colour. color repaints the leaf and (unless frame_color overrides it) the casing, so a world can match its doors to the rest of its trim without a recoloured copy of the model. Only the leaf’s colliding geometry is repainted: in this library a leaf’s decoration is non-colliding by convention (the chrome handle, contype/conaffinity 0), so it keeps its own finish instead of turning into the door colour. That also means a glazed leaf should be left alone – painting door_glass’s pane opaque would defeat it – and only its frame_color set.

duct

Scene plugin: a parametric round ventilation duct run under a ceiling.

Config:

duct:
  prefix: ""          # MJCF name prefix (distinct prefixes for >1 run)
  start: [x, y]       # run start, world (REQUIRED)
  end: [x, y]         # run end, world (REQUIRED)
  z: 3.2              # height of the run's AXIS, m
  radius: 0.14        # main tube radius, m
  branches: []        # distances along the run, m, where a drop + diffuser hangs
  branch_length: 0.40 # how far a drop hangs below the main tube, m
  branch_radius: 0.08 # drop tube radius, m
  diffuser_radius: 0.16   # disc at the bottom of a drop, m (0 = no disc)
  color: [r,g,b,a]    # galvanised steel by default; alpha optional

Visual only (no contacts), for the same reason the panels are: nothing driving on the floor reaches a duct, and the raycaster sees it regardless.

moving_box

Scene plugin: a moving rectangular box obstacle, driven kinematically from the world YAML.

Config:

moving_box:
  prefix: "cube1_"          # MJCF name prefix (use distinct prefixes for >1 mover)
  size: [0.3, 0.3, 0.3]     # full extents, metres (REQUIRED)
  pose:                     # start; omit z to sit it ON the floor, state z to set its CENTRE
    position:    {x: 0.0, y: 0.0}
    orientation: {yaw: 0.0} # constant -- the box travels without turning
  speed: 0.1                # m/s along the route (REQUIRED, > 0)
  color: [r, g, b, a]       # default a light warehouse grey; alpha optional
  collide: true             # false -> nothing bumps into it (a raycast still sees it)
  friction: 1.0             # sliding friction, or the full [sliding, torsional, rolling] triple

  # -- mode A: a fixed route -----------------------------------------------------------------
  waypoints: [[2.0, 1.0], [2.0, -3.0]]   # world metres; the box starts at `pose`
  loop: true                # true -> cycle the route forever; false -> stop at the last point
  ping_pong: false          # true -> reverse at the end instead of jumping back to the start

  # -- mode B: a seeded random walk -----------------------------------------------------------
  random_walk:
    seed: 1                 # REQUIRED — an unseeded random obstacle is not an experiment
    clearance: 0.5          # m of free space required ahead; below it, a new heading is picked
    bounds: [x0, y0, x1, y1]   # optional axis-aligned box the centre must stay inside
    turn_deg: [60, 300]     # heading change sampled uniformly from this range, in degrees

pose is where the box is at on_reset, every episode: a trial never inherits the previous trial’s obstacle position, and neither does the RNG (it is re-seeded), so repetition N of a cell sees the same obstacle motion however many trials ran before it.

palm_tree

Scene plugin: a parametric artificial palm-like tree – trunk, arching fronds, fruit bunches.

Config:

palm_tree:
  prefix: ""            # MJCF name prefix (distinct prefixes for >1 tree)
  pos: [0.0, 0.0, 0.0]  # [x, y] or [x, y, z] world placement of the trunk base
  rpy: [0.0, 0.0, 0.0]  # orientation as roll/pitch/yaw (rad)
  trunk_height: 1.60    # m, top of the pole above the floor
  trunk_radius: 0.030   # m
  pot_height: 0.16      # m (0 for a pole with no pot)
  pot_radius: 0.085     # m
  crown_height: 0.95    # m, where the fronds spring from the trunk
  fronds: 8             # blades in the crown
  frond_length: 0.45    # m, tip to trunk (split over the two segments)
  frond_width: 0.10     # m
  frond_pitch_deg: 35   # inner segment's rise above horizontal
  frond_droop_deg: 45   # extra downward turn of the outer segment
  frond_tier: 0.10      # m, height offset between alternating fronds
  bunches:              # fruit bunches; pos is relative to the trunk base
    - pos: [-0.13, 0.02, 0.88]
      radius: 0.075
      fruits: 9

The trunk, the fronds and the fruit all collide – every one of them is something an arm can hit, and the fronds in particular are the obstacle the experiment is about. The pot collides too (it is a solid object at floor level). Nothing here is decoration, so nothing here is contact-free.

prop_trajectory

Scene + controller plugin: carry a prop along a prescribed planar path at a fixed speed.

Config:

prop_trajectory:
  prefix: ""                 # MJCF name prefix (distinct prefixes for >1 stage)
  path: trajectories/t1.csv  # 2-column "x,y" CSV, resolved relative to the world YAML
  units: mm                  # mm | m -- the CSV's units (default mm)
  speed: 0.03                # m/s along the path (arc length), constant
  origin: [2.0, 1.0, 0.6]    # world pose of CSV point (0,0); z is the plate's TOP surface
  plate: [0.07, 0.07, 0.006] # carrier plate half-extents (x, y, z)
  friction: 1.0              # plate friction (a carried object must not slide off)
  loop: false                # restart at the path start when the end is reached
  start_index: 0             # begin at this CSV row (phase offset within one path)
  travel: [0.15, 0.15]       # +/- soft limits on each axis (m); the path is clamped into them

The plate is driven, not simulated: its joints have no actuator and are velocity-forced, so the stage is infinitely stiff and its motion is exactly the commanded path regardless of the load it carries. That is the right model for a stepper-driven gantry and the wrong one for a compliant conveyor.

Props carried by the plate need their own free joint – spawn them with spawn_model: {..., motion: physics} and place them just above the plate’s top surface.

shelf

Scene plugin: a parametric chipboard shelf built from primitive boxes at build time.

Config:

shelf:
  prefix: ""          # MJCF name prefix (distinct prefixes for >1 shelf)
  pos: [0.0, 0.0, 0.0]  # [x, y] or [x, y, z] world placement
  rpy: [0.0, 0.0, 0.0]  # orientation as roll/pitch/yaw (rad)
  layers: 5           # number of boards (int >= 2; default 5, matching the mesh model)
  width: 1.51         # board size along Y, m   (default 1.51)
  depth: 0.80         # board size along X, m   (default 0.80; half-depth => 0.40)
  height: 2.00        # overall shelf height, m (default 2.00)
  thickness: 0.02     # board thickness, m      (default 0.02)
  leg: 0.04           # square cross-section of a corner upright, m (default 0.04)

Defaults reproduce the baked free_chipboard_shelf (5 boards, ~0.80 x 1.51 x 2.0 m) so it is a drop-in, parameterised replacement.

strip_light

Scene plugin: a parametric linear ceiling luminaire – the LED batten, optionally lit.

Config:

strip_light:
  prefix: ""          # MJCF name prefix (distinct prefixes for >1 batten)
  pos: [x, y, z]      # fixture centre, world (z = the underside of the ceiling it hangs on)
  yaw: 0.0            # direction of the run, rad
  length: 2.4         # along yaw, m
  width: 0.09         # across it, m
  height: 0.06        # how deep the fixture hangs, m
  color: [r,g,b,a]    # fixture colour (default white)
  emission: 0.6       # 0..1 self-illumination, so the batten reads as lit, not just white
  emit: false         # also add a real light below the fixture
  diffuse: [0.25, 0.25, 0.25]   # its colour/intensity (only with emit)
  cutoff: 80.0        # its spot half-angle in degrees (only with emit)

Keep emit for the few battens that should actually light the scene: MuJoCo caps a model at 100 lights, and every extra one costs render time for a room that is already lit.

window

Scene plugin: a parametric fixed window – a glazed pane in a slim frame, built from boxes.

Config:

window:
  prefix: ""          # MJCF name prefix (distinct prefixes for >1 window)
  pos: [0.0, 0.0, 0.0]  # opening CENTRE, [x, y] or [x, y, z] world placement
  rpy: [0.0, 0.0, 0.0]  # orientation; yaw aligns the pane with its wall
  width: 0.94         # opening width along the wall, m (default 0.94)
  height: 2.06        # overall height from the floor, m (default 2.06 -- door_frame's outer height)
  depth: 0.10         # thickness through the wall, m (default 0.10 -- the default wall thickness)
  frame: 0.05         # frame border width, m (default 0.05); glass fills the rest
  glass: 0.012        # glass thickness, m (default 0.012)
  color: [r,g,b,a]    # frame colour (default a neutral grey); alpha optional
  glass_color: [r,g,b,a]  # glass colour, alpha < 1 to see through it

Both parts collide: the wall around the opening stops a robot, and the glass must too, or a robot would drive through the window.

workbench

Scene plugin: a parametric ESD assembly workbench with an electric lift column.

Config:

workbench:
  prefix: ""            # MJCF name prefix (distinct prefixes for >1 bench)
  pos: [0.0, 0.0, 0.0]  # [x, y] or [x, y, z] world placement
  rpy: [0.0, 0.0, 0.0]  # orientation as roll/pitch/yaw (rad)
  height: 0.695         # worktop height, m -- the lift column's stroke, 0.695 .. 0.995
  width: 2.00           # worktop size along X, m (default 2.00)
  depth: 0.70           # worktop size along Y, m (default 0.70)
  cabinet: left         # drawer cabinet side: 'left' | 'right' | 'none'
  superstructure: true  # uprights + tool panel + monitor arm + overhead light frame

The structure collides – worktop, frame, columns, cabinet, uprights, tool panel and the overhead beams are all things a robot or an arm can hit. The trim does not: adjustable feet pads, drawer fronts and handles, the power strip, the monitor arm and the light bar are visual, so a bench in a navigation scene does not cost contacts for decoration.

roqsim_humanoid

agibot_g2_controller

Controller plugin: whole-upper-body joint-position hold for the AgiBot G2.

Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:

agibot_g2_controller:
  namespace: ""           # transport scope (default: inherited from spawn_robot)
  rest: {idx21_arm_l_joint1: 1.6, ...}   # {joint: angle} spawn+hold stance (default: manifest)
  test_target: {idx31_gripper_l_inner_joint1: -0.6}   # optional {joint: pos} held every tick

g1_locomotion

Controller plugin: RL walking policy + PD torque loop for the Unitree G1 (12-DoF legs).

Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:

g1_locomotion:
  namespace: ""                # transport scope (default: inherited from spawn_robot's namespace)
  policy: g1_stand             # use a spec-driven policy instead of the bundled walk one: names a
                               #   directory under policy/ holding <name>.spec.yaml + its checkpoint
                               #   (roqsim.policy.PolicySpec). The spec carries the observation layout,
                               #   gains and trained envelope, so adding a policy edits no Python.
                               #   Omitted -> the bundled walk policy, unchanged.
  policy_path: <bundled>       # override the TorchScript policy (default: policy/motion.pt)
  config_path: <bundled>       # override the deploy config (default: policy/g1.yaml)
  gait_period: 0.8             # gait-phase period (s) feeding the sin/cos phase obs
  max_linear_vel: 1.0          # |vx| clamp (m/s)
  max_lateral_vel: 0.5         # |vy| clamp (m/s)
  max_angular_vel: 1.0         # |yaw_rate| clamp (rad/s)
  test_cmd: [0.4, 0.0, 0.0]    # optional [vx, vy, w] applied every tick (standalone demo)
  station_keeping: true        # hold position when commanded to stop (see below)
  station_gain: [2.5, 2.5, 2.0]  # P gains on [x, y, yaw] error -> body-frame twist
  station_deadband: [0.02, 0.05]  # [m, rad] inside which no correction is applied

station_keeping closes a position loop so a zero command means stay here rather than walk at zero velocity. It is off by default (the policy’s raw behaviour is what a locomotion study wants to measure), but anything standing still needs it: the policy takes only a velocity command and has no notion of where it is, so a stationary G1 drifts at roughly 0.06–0.09 m/s – 0.6–0.9 m over ten seconds, measured on both this model and unitree_g1. That is enough to walk a robot away from the table it was reaching for, and it also means a navigating robot creeps away after arriving at its goal.

The hold target arms itself whenever the external command returns to (near) zero, capturing the pose the robot is standing at, and releases the moment a non-zero command arrives – so nav2 drives normally and only the standstill is corrected. The correction is a P term on the world-frame error rotated into the body frame, clamped by the same max_* limits as any other command, with a deadband so the robot is not permanently taking small steps to chase millimetres.

Measured on unitree_g1_dex1, 10 s at rest – settled offset from the armed pose, and how far the base still wanders in a subsequent 2 s (the number a manipulation task actually cares about):

station_gain

settled offset

further motion / 2 s

off

0.905 m, rising

–

[1.0, 1.0, 1.5]

0.116 m

0.0045 m

[2.5, 2.5, 2.0]

0.046 m

0.0049 m

[5.0, 5.0, 3.0]

0.026 m

0.0031 m

Residual wander is ~5 mm regardless, so the gain only trades settled offset against how hard the policy is pushed; the default is the middle row. A P term leaves a steady-state offset because the policy needs a finite velocity command to step at all – plan in the robot’s own base frame (as MoveIt should here anyway) and a constant offset costs nothing.

This is what the hardware does too: the real G1’s sport mode has a stand state that holds pose rather than integrating a zero velocity.

oli_locomotion

Controller plugin: ONNX whole-body walk policy + PD torque loop for the LimX Oli (HU_D04_01).

Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:

oli_locomotion:
  namespace: ""                # transport scope (default: inherited from spawn_robot)
  policy_path: <bundled>       # override the ONNX policy (default: policy/oli/policy.onnx)
  config_path: <bundled>       # override the deploy config (default: policy/oli/walk_param.yaml)
  max_linear_vel: 0.5          # |vx| clamp (m/s)   -- vendor max_vx
  max_lateral_vel: 0.3         # |vy| clamp (m/s)   -- vendor max_vy
  max_angular_vel: 0.5         # |yaw_rate| clamp (rad/s) -- vendor max_vz
  test_cmd: [0.3, 0.0, 0.0]    # optional [vx, vy, w] applied every tick (standalone demo)

roqsim_manipulation

arm_controller

Controller plugin: joint-position hold for a manipulator + joint-state publishing.

Config – a component of the entry that spawns the arm, since ownership is where the entry sits rather than a config key:

arm_controller:
  joints: [shoulder_pan_joint, ...]  # optional: the joints this controller owns. Omitted, the
                             #   plugin claims every joint actuator sharing the entity's prefix,
                             #   which is right for a standalone arm and wrong for an arm that
                             #   shares its entity with other actuated parts -- a humanoid's legs,
                             #   a mobile manipulator's wheels. There the scan claims those too
                             #   and this plugin then fights their owner, writing position targets
                             #   into what may be torque actuators. Naming the joints also scopes
                             #   `joint_states` to this arm, so several controllers can share one
                             #   topic without each restating the others' joints.
  gripper_actuator: left_gripper  # required WITH `joints:` for a gripper, and the ONLY way to
                             #   declare one that is a plain joint actuator (the X-Series arms
                             #   drive their jaws from a `left_finger` slide, which the scan
                             #   below would otherwise claim as a seventh arm joint). Resolved by
                             #   ACTUATOR NAME, so it need not be a tendon -- and a tendon one is
                             #   not inferable anyway once an entity carries two (left/right).
  joint_prefix: ""           # prepended to every joint name this controller REPORTS in
                             #   `joint_states` and accepts in a trajectory. Empty (the default)
                             #   reports the model's own names. Set it -- conventionally to the
                             #   arm's MJCF prefix -- when two arms must appear in ONE robot
                             #   description: a URDF is a flat namespace, so two `shoulder_pan_joint`
                             #   cannot coexist there, and MoveIt matches states and trajectory
                             #   points to the description by name.
  namespace: ur10e           # transport scope (default: inherited from spawn_arm's namespace)
  topics: {joint_states: /joint_states}  # optional: hardwire the joint_states topic to an
                             #   absolute name, overriding namespace (see Plugin.topic_override)
  controller_name: arm_controller   # action at <controller_name>/follow_joint_trajectory
  goal_tolerance: 0.5        # rad the joints may end from the trajectory's last waypoint before
                             #   the action reports GOAL_TOLERANCE_VIOLATED instead of success.
                             #   A scalar applies to every joint; {joint: rad} sets them apart;
                             #   0 disables the check. Loose on purpose -- it exists to catch an
                             #   arm that never arrived (blocked, saturated, planned through the
                             #   furniture), not to grade a servo's steady-state error.
  goal_time_tolerance: 1.0   # s the joints get, after the last waypoint, to reach that
  stream_commands: false     # also expose <controller_name>/joint_trajectory as a high-rate topic
                             #   input (mirrors ros2_control's JointTrajectoryController): the path
                             #   moveit_servo streams position targets to. Off by default.
  velocity_commands: false   # also accept JOINT VELOCITIES at <controller_name>/joint_velocity
                             #   (see "Velocity commands" below). Off by default.
  velocity_timeout_s: 0.5    # watchdog: a velocity command decays to zero if not refreshed within
                             #   this window, so a dropped stream cannot leave the arm drifting.
  gripper_ctrl: 255.0        # ctrl held on any non-joint (tendon) actuator, e.g. the gripper
  rest: {joint1: 0.0, ...}   # {joint: angle} spawn+hold stance. Seeds BOTH the reset qpos (so the
                             #   arm spawns in the pose) and the held target (so it stays there).
                             #   Needed whenever the arm is carried by `spawn_robot`, which sets
                             #   only the base pose and no joint stance -- see below.
  test_target: [...]         # optional joint vector held every tick (standalone demo)

To pose the arm by hand with the viewer’s control sliders instead, run roqsim --manual-control (a run-level switch; see roqsim.context.SimContext.manual_control).

Velocity commands (velocity_commands: true). Reactive whole-body controllers – resolved-rate or QP redundancy resolution, e.g. Haviland et al.’s holistic mobile manipulation – emit joint velocities, not positions. This plugin’s actuators are position servos, so a velocity command is integrated into the held target at the physics rate: target += qd * dt, clamped to each joint’s range. That is what a real velocity-mode driver does on top of a position-controlled joint, and it keeps the servo’s gravity-compensated hold – a MuJoCo <velocity> actuator would sag under gravity whenever the command is zero.

Two consequences worth knowing before using it for a metric:

  • The achieved profile is shaped by the servo, not only by the command. Integrating and then tracking with a stiff PD adds the actuator’s own dynamics, so end-effector acceleration is not purely the controller’s. Where acceleration is the measured quantity, verify tracking error and report the servo gains as part of the setup.

  • A stream that stops must stop the arm. velocity_timeout_s zeroes a stale command; without a watchdog an interrupted stream integrates the last velocity forever.

ArmHandle.set_velocities(names, velocities) is the in-process entry point; the transport endpoint is <controller_name>/joint_velocity.

The ``rest`` stance. spawn_arm supplies a per-model home that this plugin seeds its targets from. spawn_robot does not: a robot spawn sets the base pose only, so an arm carried by a mobile base falls back to qpos0 – all joints zero. For the Panda that is not a neutral default but an actively bad pose (its link5 and hand collision geoms overlap by 0.030 m there), so a mobile manipulator must declare rest in its manifest. It seeds the reset qpos and the held target, by joint name, which is attach-safe where a model <keyframe> is not (spawn_robot strips keyframes – they cannot merge into a composed world). Mirrors agibot_g2_controller’s rest.

If the arm has a non-joint (tendon) actuator – a parallel gripper – it also becomes commandable: the plugin declares a control_msgs/GripperCommand action endpoint at <gripper_controller_name>/gripper_cmd and publishes a () -> (position, velocity) reader on the blackboard under gripper:<arm> (the bridge’s GripperCommand handler watches it to report reached/stalled). The commanded position (the gripper joint angle, e.g. 0=open .. 0.8=closed for a Robotiq 2F-85) is mapped linearly onto the tendon actuator’s ctrlrange.

Grip force. Beside the position the plugin publishes a GripperEffort under gripper_effort:<arm>, the key the endpoint’s effort_key hint names. It takes GripperCommand’s max_effort as ros2_control’s gripper action controller does: a clamp on the gripper joint’s effort, in that joint’s own unit – newtons for a slide jaw, newton-metres for a knuckle, the unit /joint_states reports – which is what that controller’s effort adapter applies (gripper_controllers/hardware_interface_adapter.hpp). A goal is never refused for its effort: a request at or above the model’s own limit saturates there, as a drive does, and max_effort <= 0 and every reset restore the model’s own force range. The clamp reaches the actuator through the transmission, so it needs a constant moment – a joint transmission or a fixed tendon, which every shipped gripper has. Any other keeps its range and executes the position alone, as a position-interface controller does. Gripper config:

gripper_controller_name: gripper_controller   # action at <name>/gripper_cmd
gripper_joint: right_driver_joint  # joint whose angle is the reported gripper position
gripper_open: 0.0          # position value that maps to the open end of the actuator ctrlrange
gripper_close: 0.8         # position value that maps to the closed end

cartesian_admittance

Controller plugin: Cartesian end-effector control for an arm, with or without force feedback.

Config – a component of the entry that spawns the arm, whose ArmHandle it drives, since ownership is where the entry sits rather than a config key:

cartesian_admittance:
  controller_type: cartesian_compliance_controller   # which of the three above; see `law`
  controller_name: ""      # ROS name its topics sit under; defaults to controller_type
  initial_state: active    # active | inactive -- `inactive` is ros2_control's `spawner --inactive`
  site: tool_site          # site whose pose is controlled (prefixed with the arm's prefix)
  ft: ft                   # blackboard key suffix of the force_torque sensor (`ft:<key>`);
                           #   required by the force and compliance types, unused by motion
  rate_hz: 100.0           # control rate; the loop runs at this, not at the physics rate
  target_wrench: [0, 0, -10, 0, 0, 0]    # w_d, what the TOOL applies, so -10 on z presses DOWN
  mass: [1, 1, 1, 0.6, 0.6, 0.6]         # M, diagonal
  damping: [80, 80, 80, 160, 160, 160]   # D, diagonal
  stiffness: [0, 0, 0, 0, 0, 0]          # C, diagonal; a zero axis is pure force control
  axes: [1, 1, 1, 1, 1, 1]               # per-axis enable mask
  kp: [2, 2, 2, 2, 2, 2]                 # motion type only: proportional gain on the pose error
  max_linear_vel: 0.1      # m/s, clamp on the commanded twist MAGNITUDE
  max_angular_vel: 1.0     # rad/s
  ik_damping: 0.01         # damped-least-squares lambda

law: admittance | position is the older spelling and still works, deriving a controller_type: position is the motion controller, and admittance is the force controller, or the compliance controller where a non-zero stiffness is configured. Prefer controller_type – a controller that changes its law on command is not something any real robot offers.

Endpoints, named as FZI’s cartesian_controllers name them, so a node written against this runs unchanged against that stack: <controller>/target_wrench (in, geometry_msgs/WrenchStamped), <controller>/target_frame (in, geometry_msgs/PoseStamped) and <controller>/current_pose (out). A commanded value overrides its configured default; until one arrives the config stands, so a world that publishes nothing behaves exactly as configured.

Also publishes a CartesianHandle on the blackboard under cartesian:<arm> for an in-process task plugin, with the same reach as the endpoints.

Frames. The commanded twist is applied in the WORLD frame, and the measured wrench is used as given. Configure force_torque’s frame: to match how the task defines its working axis; a wrench reported in the sensor frame and integrated as if it were world-frame produces a controller that drifts sideways under load and looks like a friction problem.

spawn_arm

Scene plugin: attach a manipulator MJCF into the world – to the ground, or onto a mobile base.

Config:

spawn_arm:
  model: ur10e          # bundled model name, filename, or absolute path
  namespace: ur10e      # optional transport scope; the arm's endpoints inherit it (default: "")
  prefix: "ur10e_"      # MJCF name prefix (use distinct prefixes for >1 arm)
  base_body: base       # arm root body (ur10e -> 'base', panda -> 'link0')
  pos: [-0.41, 0.0, 0.76]
  rpy: [0.0, 0.0, 3.14159]   # mount orientation as roll/pitch/yaw (rad)
  home: [...]           # joint home pose (defaults per model); applied on reset
  actuators:            # OPTIONAL: what law this arm's joints run under, and their gains.
    control: impedance  #   position | velocity | effort | impedance; the model's own if unset
    stiffness: 2.0      #   N*m/rad -- see roqsim.actuators for the gain of each control
    damping: 0.02       #   N*m*s/rad
    each:               #   per-actuator, on top of the shared keys above
      wrist_3: {control: position, p: 2000, d: 500}
  gravity_compensation: # OPTIONAL: whether the arm's bodies carry their own weight. Default:
                        #   true under position/velocity/impedance (real drives hold a pose),
                        #   false under effort (supplying the term is the controller's job).
  pedestal: false       # add a static support box under the base (floor -> mount height); only
                        # has an effect when pos[2] > 0. Leave it off when the arm mounts on a
                        # table/desk that is already there (the usual case).
  pedestal_half_width: 0.1   # pedestal: half-width (m) of that box's square footprint
  rail:                 # OPTIONAL: carry the arm on a linear axis (gantry / ceiling track)
    axis: [1, 0, 0]     #   travel direction, in the MOUNT frame (after `rpy`)
    range: [-1.5, 1.5]  #   travel limits (m) about `pos`
    home: 0.0           #   carriage position at spawn/reset (m)
    joint: rail_joint   #   MJCF joint name, under the arm's prefix (default: 'rail_joint')
    kp: 20000           #   position-servo gain of the carriage drive
    damping: 200        #   carriage joint damping
  mount:                # OPTIONAL: weld the arm onto another entity's body instead of the world
    robot: robot        #   entity name of a spawn_robot in this world
    body: base_link     #   that robot's body to weld to (default: base_link)
  end_effector:         # OPTIONAL: weld a gripper onto the arm's tool flange
    model: robotiq_2f85 #   a gripper model (robotiq_2f85, schunk_pg70)
    site: attachment_site  # arm site to weld it to (default: attachment_site)
    prefix: ""          #   MJCF name prefix for the gripper's own names
    pos: [0, 0, 0.011]  #   offset in the SITE's frame (e.g. the UR->Robotiq adapter's 11 mm)
    rpy: [0, 0, 0]      #   extra rotation in the site's frame (rad)
    replaces: [ee_plate]  # bodies of the ARM model this tool supersedes, deleted before the
                        #   attach (the ur10e ships a conveyor pushing plate 60 mm past its
                        #   flange, which a gripper would be welded straight into)

home/base_body fall back to per-model defaults so a bare {model: ur10e} works.

Riding a linear axis (``rail:``) A gantry, a ceiling track or a seventh-axis floor rail is a prismatic joint carrying the arm base, which is the one thing mount: cannot express: mount welds the arm to a body that already exists, while a rail has to introduce the moving body itself. With rail: the plugin inserts a carriage between the mount frame and the arm, gives it a slide joint along axis and a position servo, and attaches the arm to the carriage.

The point of it is kinematic redundancy: a 6-DOF arm on a rail is a 7-DOF system, so a task pose has a one-parameter family of solutions and a planner can trade base travel against arm posture. That is the class of system this option exists for, and the reason it belongs here rather than in a per-cell MJCF.

Two ordering facts that other code depends on, so they are guaranteed rather than incidental:

  • The rail joint is declared before the arm’s joints, and its actuator before the arm’s. Both prefixed_joints and prefixed_actuators return model order, so arm_controller publishes and commands [rail, <arm joints...>] – matching a URDF that puts the prismatic joint at the root of the chain, which is how MoveIt will see the same robot.

  • ``home`` stays the ARM’s joint vector; the carriage’s initial position is rail.home. Folding the rail into home would silently invalidate every per-model default (a 6-value ur10e home would land on [rail, j1..j5] and leave wrist_3 unset).

The carriage is a real body with mass, so it needs geometry; the plugin draws a small box for it and a thin beam spanning the travel. Both are visual only (contype/conaffinity = 0). A ceiling track that collides would trap the arm against its own support from the first step, and the collision model a motion planner actually reasons about comes from the URDF/planning scene, not from these two geoms – so making them solid would add a contact the planner cannot see. Model the real structure as scene geometry if the cell needs it.

Mounting on a mobile base (``mount:``) This is what turns a base + an arm into a mobile manipulator without a per-combination MJCF: the arm is attached to the base’s body rather than to worldbody, so it rides the base’s free joint. Both models stay untouched and any base pairs with any arm from the world YAML.

Two things it requires, both enforced in validate_config/build rather than left to fail obscurely later:

  • The base must be declared before the arm in the world’s plugin list. build runs in declaration order, so the body the arm welds to has to exist already.

  • The arm needs a non-empty ``prefix``. arm_controller and _apply_home select an arm’s joints by prefix scan, and with an empty prefix that scan also claims the base’s wheel joints – writing arm position targets into wheel actuators that another plugin owns. (An arm that names its joints: explicitly is safe either way, but the prefix is the cheap general guard.)

name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names the entity this spawn registers (default: the plugin ref, i.e. spawn_arm). Written inside the config block it is silently inert – the arm is then called spawn_arm, and anything addressing it by the name you chose (arm_controller’s arm:, a sensor’s robot:) resolves to nothing.

End effectors (``end_effector:``) A gripper is attached into the arm’s own spec before the arm is attached to the world, so the arm’s prefix covers it. That is deliberate: arm_controller discovers a gripper as an actuator with a non-joint (tendon) transmission sharing the arm’s prefix, which is exactly how the pre-assembled gen3 works – so a bare arm plus a gripper reaches the same state as a factory-assembled one, and the controller needed no change to gain interchangeable hands. The gripper’s own <model>.manifest.yaml supplies the gripper half of arm_controller’s config (gripper_joint/gripper_open/gripper_close), merged by SpawnArmPlugin.expand().

The attach uses MuJoCo’s site attachment, so the site’s orientation defines the tool frame and pos/rpy are offsets within it – matching how a real tool adapter is specified.

roqsim_mobile

ackermann_drive

Controller plugin: Ackermann (car-like) steering + wheel-encoder odometry.

Config:

ackermann_drive:
  wheel_radius: 0.05
  wheelbase: 0.32               # front axle to rear axle -- what turns a curvature into an angle
  track: 0.24                   # driven axle width, for the drive split (and the default below)
  steer_track: 0.24             # steering-axis (kingpin) separation, for the steer split
  max_linear_vel: 2.0
  max_steer_angle: 0.5          # rad; the rack's mechanical limit, and the turning circle with it
  steer_rate: 4.0               # rad/s slew on the steering angle (0 = instant)
  accel_limit: 2.0              # m/s^2 on the commanded speed (0 = instant)
  steer_actuators: [left_steer_motor, right_steer_motor]    # POSITION servos, left then right
  steer_joints:    [left_steer_joint, right_steer_joint]
  drive_actuators: [rear_left_motor, rear_right_motor]      # VELOCITY servos, left then right
  drive_joints:    [rear_left_joint, rear_right_joint]
  base_body: base_link
  odom_child_frame: base_link   # link the odometry TF points at (see below)
  stamped_cmd_vel: false        # true when the stack publishes TwistStamped (see below)
  test_cmd: [1.0, 0.4]          # optional [v, w] applied every tick (standalone demo)

stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist for the velocity command. Which of the two a stack publishes is a property of that stack, not of the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type, so a mismatch is not a degradation but silence – the robot receives no command at all, and the only symptom is a controller reporting that it cannot make progress.

odom_child_frame names the link the odom -> transform points at, and it must be the ROOT of whatever URDF robot_state_publisher is running beside the simulator: a description rooted at base_footprint already gives base_link a parent, and a second parent from here leaves that frame with two, which tf2 cannot resolve.

Endpoints are the ones every base here publishes, so a stack does not know which geometry it is driving until it tries to turn in place: cmd_vel in (geometry_msgs/Twist), odom out with TF, and joint_states out carrying the steer joints as well as the driven ones – a car’s steering angle is state a stack watches, and leaving it out is how a URDF’s front wheels stay straight in RViz while the robot corners.

Odometry is what the encoders say, as in diff_drive: the driven wheels’ measured speed for v, and the measured steering angle for the yaw rate through the same bicycle relation (w = v * tan(delta) / L). Reading back the commanded angle instead would report a car that corners perfectly while the rack is still slewing.

It is therefore dead reckoning and it drifts, which is deliberate. On a straight run the test vehicle’s odometry lands within a few percent; cornering, the tyres slip and the bicycle relation under-reports the turn – measured, a car that came round 1.1 rad believes it came round 0.8. No scrub factor is offered to hide it: unlike a skid-steer’s, whose scrub is systematic enough for diff_drive’s slip_factor to correct, a tyre’s slip angle varies with speed and load, so a single constant would be a fudge that makes the odometry look better than the sensor it stands for. roqsim_sensors.plugins.ground_truth_pose is what a grader compares against, and the gap between the two is what a localisation experiment is about.

diff_drive

Controller plugin: differential-drive kinematics + wheel-encoder odometry.

Config – a component of the entry that spawns the base, since ownership is where the entry sits rather than a config key:

diff_drive:
  namespace: ""                # transport scope (default: inherited from spawn_robot's namespace)
  wheel_radius: 0.03575
  wheel_separation: 0.233
  max_linear_vel: 0.31
  max_angular_vel: 1.90
  wheel_accel_limit: 0.9       # m/s^2 per wheel; ramps commands (Create 3 default/max 900 mm/s^2)
  left_actuator: left_wheel_motor
  right_actuator: right_wheel_motor
  left_joint: left_wheel_joint
  right_joint: right_wheel_joint
  odom_child_frame: base_link   # frame the odometry TF points at (see below)
  odom_rate_hz: 50.0           # publish rate of odom (and its TF) and joint_states
  stamped_cmd_vel: false       # true when the stack publishes TwistStamped (see below)
  cmd_vel_timeout: 0.0         # s; > 0 stops the base when no command arrives for this long
  publish_joint_states: true   # false when a joint_state_publisher covers the whole robot
  test_cmd: [0.15, 0.4]        # optional [v, w] applied every tick (standalone demo)
  odom_noise:                  # optional odometry error (see below); omitted = exact odometry
    linear_stddev: 0.0         # m/s, white noise on the reported linear velocity
    angular_stddev: 0.0        # rad/s, white noise on the reported yaw rate
    linear_scale: 1.0          # multiplicative bias (e.g. a wheel radius off by 1 %)
    angular_scale: 1.0         # multiplicative bias on the yaw rate (e.g. an effective track error)

Skid-steer (>1 wheel per side, e.g. Husky A200): give the per-side actuator/joint lists instead of the singular keys; every left wheel gets the same command, every right wheel the same, and odometry averages each side’s wheel velocities. slip_factor compensates the lateral scrub of a skid-steer (see below):

diff_drive:
  wheel_radius: 0.17775
  wheel_separation: 0.5708      # track width
  slip_factor: 4.0              # ICR slip compensation (1.0 = ideal diff-drive)
  left_actuators:  [front_left_wheel_motor,  rear_left_wheel_motor]
  right_actuators: [front_right_wheel_motor, rear_right_wheel_motor]
  left_joints:  [front_left_wheel_joint,  rear_left_wheel_joint]
  right_joints: [front_right_wheel_joint, rear_right_wheel_joint]

stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist for the velocity command. Which of the two a stack publishes is a property of that stack, not of the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type, so a mismatch would be not a degradation but silence – no command arrives and nothing logs it – which is why the ROS bridge fails the run when a peer of another type sits on one of its topics, naming the topic, both types and both sides.

cmd_vel_timeout is the watchdog every real base driver has: a command is good for this long and then the base stops, so a stack that dies mid-run leaves a stationary robot rather than one driving at its last velocity into a wall. ros2_control’s diff_drive_controller ships it at 0.5 s and the Create 3’s firmware behaves the same. It is off by default here, because an in-process driver that sets a twist once and steps expects it to hold; a world that runs a real stack sets it to that stack’s value. The stop goes through the same acceleration ramp as any command.

publish_joint_states switches this plugin’s own joint_states (the wheel joints) off, for a robot whose joint_state_publisher publishes every joint in one message – a consumer that needs a suspension travel in the same message as the wheels must not get two messages on one topic that each carry half.

odom_child_frame names the link the odom -> transform points at, and it must be the ROOT of whatever URDF robot_state_publisher is running: a robot_state_publisher rooted at base_footprint (every published TurtleBot 3 description) already gives base_link a parent, and a second parent from here leaves the frame with two, which tf2 cannot resolve. Robots whose description is rooted at base_link (TurtleBot 4) keep the default.

slip_factor exists because a 4-wheel skid-steer turns by scrubbing its wheels sideways: with MuJoCo point contacts the base yaws at only ~15% of the ideal differential-drive prediction, which would leave a planner unable to rotate. The factor inflates the yaw term of the wheel command and is divided back out of odometry, so commanded yaw is achieved and odometry stays consistent with the base’s real motion. It is a per-robot calibration against the model’s contact/friction setup – re-measure it (achieved vs commanded yaw rate) if wheel friction, mass, or the timestep change.

odom_noise makes the odometry wrong the way real wheel odometry is wrong, and leaves the robot’s motion alone. The error is applied to the velocities read off the wheels, before they are integrated, so the reported pose drifts rather than jitters: a linear_scale of 1.01 is a wheel radius stated 1 % too small and overstates every metre by a centimetre, and the *_stddev terms are zero-mean white noise on each reading, drawn per physics step. A white velocity error integrates to a random walk, so the pose spread grows with the square root of the distance driven and shrinks with the timestep; state the stddev together with the world’s sim.timestep. Everything downstream that consumes odometry sees it – the odom endpoint, its TF, the RobotHandle – and nothing that reports the truth does: the body’s pose, sim_poses and a ground-truth pose plugin are exact. Draws come from ctx.rng_for (docs/architecture.rst §9.1), so a noisy run needs a seed and reproduces from it; with the block omitted nothing is drawn and the odometry is exact, as before.

floorplan

Flags: provides_world

Scene plugin: a floorplan as the world – ground plane, light and walls, from a mesh or from wall segments.

Config:

floorplan:
  # --- one of these two sources ---
  mesh: <path>         # floorplan mesh (.stl); absolute, or relative to the process cwd
  # ...or the layout instead, in the floorplan JSON's own vocabulary:
  floorplan: rooms.json  # what `roqsim scenes dxf-to-floorplan` and the sketch window write
  lines:                 # ...or the segments inline
    - {id: 0, x0_m: 0.0, y0_m: 0.0, x1_m: 6.0, y1_m: 0.0}
  doors: [{line_id: 0, t: 0.5, width_m: 0.9}]   # t is 0..1 along that wall
  height: 2.5          # segments only: ceiling height (m)
  thickness: 0.12      # segments only: wall thickness (m)
  opening_height: 2.0  # segments only: door height; the wall above one becomes a lintel
  # --- the rest applies to both ---
  mesh_scale: 1.0      # float or [x, y, z]
  mesh_pos: [0, 0, 0]  # placement offset of the mesh in the world frame
  floor:               # ground-plane appearance + physics (all keys optional; default = light gray)
    rgb1: [0.85, 0.85, 0.85]        # builtin-checker colour A (0..1 RGB)
    rgb2: [0.78, 0.78, 0.79]        # builtin-checker colour B
    reflectance: 0.2                # 0..1; if omitted, a texture's manifest value (else 0.2) is used
    texture: null                   # PNG image; overrides rgb1/rgb2 when set. A package-qualified
                                    #   name ('roqsim_assets:Concrete030') or a PNG path
                                    #   (absolute / cwd-relative). MuJoCo loads PNG only.
    rgba: null                      # optional multiplicative tint on the texture/checker (like
                                    #   Poly Haven's base colour). RGB >1 brightens (not clamped
                                    #   to 1); e.g. [2.2, 2.2, 2.2, 1] = much brighter.
    physical_size: 1.8              # metres one tile spans (real-world scale); scalar or [x, y].
                                    #   If omitted, a texture's manifest value (else 1.0) is used.
    friction: [2.0, 0.005, 0.0001]  # geom contact friction [sliding, torsional, rolling]
  wall:                # floorplan-mesh appearance (same keys as 'floor' minus friction; default = gray)
    rgb1: [0.8, 0.8, 0.82]          # solid colour when rgb1 == rgb2 (the default)
    rgb2: [0.8, 0.8, 0.82]
    reflectance: 0.0
    texture: null                   # PNG image (see 'floor.texture'), applied to the wall mesh.
    rgba: null                      # optional tint (see 'floor.rgba')
    physical_size: 2.4              # metres one tile spans. Honoured for both a UV-less .stl (via
                                    #   texuniform) and a UV'd .obj (its UVs are scaled to match).
  light:               # a single overhead light at the floorplan centre + a global ambient
    height: 2.5                     # metres above the floor for the light
    diffuse: [0.35, 0.35, 0.35]     # light colour/intensity (flat across the cone)
    cutoff: 90.0                    # spot half-angle (deg); 90 = hemisphere, no visible cone edge
    fill: [0.3, 0.3, 0.3]           # uniform global ambient (not a light); [0, 0, 0] disables it

Textures are resolved via roqsim.textures.resolve_texture(): a package-qualified <package>:<name> (e.g. roqsim_assets:Concrete030, from the shared roqsim_assets) or a PNG path – no cross-package name search. A texture folder may carry a manifest.yaml (next to the PNG) with surface properties – reflectance and physical_size – used when the world does not set the matching <floor|wall> key explicitly. When no manifest exists, the defaults are used.

omni_drive

Controller plugin: holonomic (omnidirectional) base kinematics + odometry.

Config – a component of the entry that spawns the base, since ownership is where the entry sits rather than a config key:

omni_drive:
  namespace: ""                 # transport scope (default: inherited from spawn_robot)
  base_joint: base_free         # the base's free joint
  vx_actuator: base_vx          # planar drive actuators (see "Planar drive" below)
  vy_actuator: base_vy
  wz_actuator: base_wz
  max_linear_vel: 1.0           # m/s, applies to vx
  max_lateral_vel: 1.0          # m/s, applies to vy (defaults to max_linear_vel)
  max_combined_linear_vel: 0.7  # m/s, cap on hypot(vx, vy); 0 disables
  max_angular_vel: 2.09         # rad/s
  accel_limit: 1.0              # m/s^2, ramps vx and vy
  angular_accel_limit: 2.09     # rad/s^2, ramps wz
  # Mecanum wheels: OBSERVATIONAL only (see "Wheels" below). Omit to skip wheel handling.
  wheel_radius: 0.0762
  wheel_separation: 0.44715     # lateral, left <-> right
  axis_separation: 0.488        # longitudinal, front <-> rear
  wheels: [front_left, front_right, rear_left, rear_right]   # joint name stems
  wheel_actuators: [...]        # same order; defaults to <stem>_motor
  # SWERVE bases only: give the steer joints and their POSITION actuators, same order as
  # `wheels`. Their presence is what selects swerve inverse kinematics over mecanum.
  steer_joints: [...]
  steer_actuators: [...]
  odom_child_frame: base_footprint   # link the odometry TF points at (see below)
  stamped_cmd_vel: false             # true when the stack publishes TwistStamped (see below)
  test_cmd: [0.2, 0.1, 0.0]     # optional [vx, vy, wz] applied every tick (standalone demo)

stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist for the velocity command. Which of the two a stack publishes is a property of that stack, not of the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type, so a mismatch would be not a degradation but silence – no command arrives and nothing logs it – which is why the ROS bridge fails the run when a peer of another type sits on one of its topics, naming the topic, both types and both sides.

odom_child_frame names the link the odom -> transform points at, and it must be the ROOT of whatever URDF robot_state_publisher is running beside the simulator: a description rooted at base_footprint already gives base_link a parent, and a second parent from here leaves that frame with two, which tf2 cannot resolve.

Planar drive. A real omnidirectional base translates sideways because each mecanum wheel’s passive rollers let the contact patch slide along one diagonal. Those rollers are not modelled (~9 per wheel would mean ~36 extra bodies and as many contact pairs, and MuJoCo’s cylinder-on-plane contact frame is not roller-aligned, so anisotropic friction is not a reliable substitute). Instead the commanded twist is applied to the base’s free joint through three velocity actuators, and the wheels are near-frictionless load carriers – which is what an omni wheel is, in the directions that matter. This is the approach PAL themselves sketched: base_x / base_y / base_tau velocity actuators on the floating base, commented out in omni_base_description/mujoco/mj_tags.xacro.

The drive stays inside MuJoCo’s force path rather than writing qvel directly, so contacts still win: a wall stops the base, and it cannot be shoved through thin geometry.

Frames. A free joint’s translational DOFs are expressed in the world frame and its rotational DOFs in the body frame (both verified against MuJoCo 3.11), and gear inherits that. So the body-frame vx/vy command is rotated by the base yaw before it is written to ctrl, while wz needs no rotation. Getting this wrong yields a base that drives correctly only while its heading is zero – which a straight-line test would not catch.

Swerve. A steerable-wheel base is holonomic like a mecanum one, so the planar drive above is unchanged: what differs is only how the wheels are told to follow. Given steer_joints and steer_actuators, each corner’s contact velocity is computed from the body twist (v_k = v + w x r_k), the steer actuator is commanded to atan2 of it and the roll actuator to its magnitude over the wheel radius. The steer target is resolved to whichever of the two equivalent headings (theta, theta+pi) is nearer the joint’s current angle, negating the roll rate for the flipped one – without that a command crossing straight-ahead makes every wheel slew half a turn, which looks like a violent glitch and is purely an artefact of the branch cut.

Like the mecanum case this is observational: the wheels are not the motive force. The difference is that a swerve base’s steering is visible and physically meaningful, so getting it right matters for anything reading joint_states or watching the robot – but a paper measuring the steer joints themselves (their rate limits, or the reorientation delay at a direction change) needs real steer actuation, which this is not.

Wheels. The wheel velocity servos are driven from the mecanum inverse kinematics purely so that the visual and joint_states are right (the wheels turn, and turn differently when strafing). They are not the motive force: at the model’s wheel friction they transmit almost nothing. Each wheel’s roll sign is derived from its joint axis in the base frame at configure time rather than hardcoded, because which way “positive” spins depends on how the source URDF mirrored that wheel.

Odometry. Integrated from the base’s achieved twist, so a base held against a wall reports no progress. Note the consequence: with no wheel slip in the model, wheel-encoder odometry and ground truth coincide by construction. This port therefore cannot be used to study odometry drift – a skid-steer’s characteristic error source is absent here by design, not by accident.

spawn_robot

Scene plugin: attach a robot MJCF into the world and own its base spawn pose.

Config:

spawn_robot:
  model: turtlebot4     # bundled model name, filename, or absolute path
  namespace: ""         # optional transport scope; the robot's endpoints inherit it
  prefix: ""            # MJCF name prefix (use distinct prefixes for >1 robot)
  pose:                 # the spawn pose, as SpawnEntity states one (see below)
    position:    {x: 0.0, y: 0.0}
    orientation: {yaw: 0.0}
  base_joint: base_free # free joint used to place the base
  actuators:            # OPTIONAL: what law this robot's actuators run under, and their gains.
    control: velocity   #   position | velocity | effort | impedance; the model's own if unset
    d: 40               #   N*m*s/rad -- see roqsim.actuators for the gain of each control
    each:               #   per-actuator, on top of the shared keys above
      front_left_wheel_motor: {d: 25}
  present: true         # false: compiled in, but absent until it is spawned
  frames:               # OPTIONAL: fixed links beyond the manifest's own (see below)
    - {name: cover_link, parent: body_link, pos: [0, 0, 0.05], rpy: [0, 0, 0]}

Vendor frames. A top-level frames: block in the model’s manifest – plus any in this config, after it – names the fixed links the vendor description chains and the MJCF flattens (roqsim.frames). Each becomes a site <prefix><name> on its parent’s body at build, so a mounted device can hang from it (spawn_sensor’s parent_frame), and is published at configure as a static transform parent -> name read from the compiled model, in the robot’s namespace. Where a chain starts at a body other than the robot’s root, root -> body is published with it, so the chain joins the robot’s tree; that body must be welded to the root.

name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names the entity this spawn registers (default: the plugin ref). Components nested under the entry attach to that entity by position, and are addressed <name>.<label>.

The plugin registers an Entity(kind='robot') whose meta carries prefix, model, and initial_pose so controller/sensor/bridge plugins can resolve the right (prefixed) names.

pose: is the spawn pose, in the shape SpawnEntity.srv gives its initial_pose – a geometry_msgs/PoseStamped. Its orientation may be a quaternion or Euler angles, so the common case stays short:

- spawn_robot: {model: turtlebot4, pose: {position: {x: 1.5, y: 2.0},
                                          orientation: {yaw: 0.785}}}

It is the ONLY way to state one, which is the point: a world declaring where a robot starts and a SpawnEntity call placing it mid-trial are the same pose, so they are written the same way and a producer needs no conversion that depends on which door the pose came in at. It also makes the pose a value a campaign can write per configuration – one destination, not two keys to split across – so a swept start pose is spawned rather than applied after the fact.

Omitting it spawns the robot at the origin, at the model’s own resting height. See roqsim.pose for the shape and the three ways a document may say more than the message can (a world-frame header, an omitted z meaning that resting height rather than zero, and the Euler spelling).

The rotation is a full quaternion, because the service’s is: a robot can be spawned tilted. Nothing here refuses that – the base has a free joint and MuJoCo holds whatever orientation it is given – so a rotation that is not a heading is taken at its word.

present: false compiles the robot in and starts it absent – nothing sees or touches it, and the control plane does not list it – until SpawnEntity brings it in at the pose that call states (see roqsim.presence). The declared value is restored on on_reset, so what one trial spawned does not carry into the next.

Absence hides the robot’s BODY, not its software: its controller and sensor plugins keep running, so an absent robot still publishes and still responds to a twist – and being out of the contact set, a twist drives it through walls. For a start pose decided per run, move the robot with SetEntityState instead; absence is for a machine that is not meant to be in the trial yet.

roqsim_nav

navigator

Controller plugin: move an entity along a route, inside the simulator.

Config – a component of the entry that provides the entity it moves, since ownership is where the entry sits rather than a config key:

navigator:
  output: auto            # auto | drive | mocap | ... | module:Class | file.py:Class
  speed: 0.5              # m/s the route is followed at (REQUIRED, > 0)
  goals:                  # the route, world metres
    - [4.0, 3.0]          #   [x, y] or [x, y, yaw]
    - [0.0, 3.0]
  dwell: 0.0              # seconds to stand still on reaching a goal: one number, `[lo, hi]`
                          #   for a random pause, or a list of either -- one per route point,
                          #   the mover's own start included, so it lines up with `goals`
                          #   preceded by where the mover began
  route_mode: plan        # plan -> A* between the points; exact -> the points ARE the path
  tracker: waypoint       # waypoint -> steer at the goal, advance within `arrival_radius`
                          # pure_pursuit -> steer at a carrot `lookahead` along the route and
                          #   advance on crossing a goal; bounds cross-track error by the
                          #   lookahead instead of by the arrival radius, which is what a
                          #   non-holonomic base asked to follow a given path needs
  lookahead: 0.6          # m; pure_pursuit only
  autostart: true         # false -> hold at the first point until started
  loop: false             # cycle the route forever rather than stopping at the last point
  arrival_radius: 0.25

  # -- what it does about what the plan did not contain -----------------------------------
  # Three independent capabilities, not a ladder. See AVOIDANCE_KEYS for why.
  avoidance:
    stop: true            # look ahead and hold until the way is clear
    steer: none           # none | give_way | orca | module:Class -- which model gives way
    reroute: false        # remember what stopped it and plan around it (needs `stop`)
    params: {}            # per-agent keys the chosen model accepts, checked at load

    # the probe's own tuning, in the same block
    lookahead: 1.2        # m of clear corridor needed, measured from the mover's FRONT
    width: 0.6            # m of corridor swept: the body, plus the clearance it should keep
    rays: 5               # how finely that width is sampled
    height: 0             # m above the floor to scan; 0 -> just above obstacle_height's floor
    clear_time: 0.5       # s the way must stay open before setting off again
    yield_time: 3.0       # s a blockage reads as traffic before recovery may engage
    forget_after: 5.0     # s a remembered blockage keeps steering the planner (reroute only)
    blockage_radius: 0    # m of the disc a blockage marks; 0 -> half the corridor width
    ignore: []            # entities this mover never stops for

  # -- output: drive ---------------------------------------------------------------------
  kinematics: auto        # auto | unicycle | holonomic | ackermann (auto asks the output)
  heading_gain: 2.0       # rad/s of yaw command per rad of heading error
  max_angular_vel: 1.5    # rad/s cap BEFORE the drive plugin's own clip
  turn_in_place: 0.8      # rad of heading error above which a unicycle base pivots instead
  min_speed: 0.15         # m/s an ackermann base is never commanded below (it cannot pivot)
  face: travel            # holonomic only: travel | hold

  # -- output: mocap / walker ------------------------------------------------------------
  yaw_rate: 3.0           # rad/s the body is re-faced at (0 = snap)

  # -- planning --------------------------------------------------------------------------
  obstacle_height: [0.05, 0.6]  # z band a geom must span to be a wall FOR THIS MOVER
  resolution: 0.05              # m per planner grid cell
  planner:  {inflation_radius: 0.35, waypoint_radius: 0.3}
  recovery: {enabled: true, stuck_time: 1.5, backup_time: 0.5, max_recovery: 4}
  update_hz: 20.0               # nav pipeline rate; physics steps far faster

obstacle_height is per mover on purpose: a 0.4 m pallet is not stopped by a ceiling beam that blocks a walker, so “what counts as a wall” is a property of the thing navigating, not of the world. Movers that agree on it share one rasterized grid (see roqsim_nav.grid).

roqsim_quadruped

spot_locomotion

Controller plugin: RL flat-terrain walking policy for the Boston Dynamics Spot (12-DoF quadruped).

Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:

spot_locomotion:
  namespace: ""                # transport scope (default: inherited from spawn_robot's namespace)
  policy_path: <fetched>       # TorchScript policy (default: $SPOT_POLICY_PATH or policy/spot_policy.pt)
  config_path: <bundled>       # deploy config (default: policy/spot.yaml)
  max_linear_vel: 1.5          # |vx| clamp (m/s)
  max_lateral_vel: 0.8         # |vy| clamp (m/s)
  max_angular_vel: 1.5         # |yaw_rate| clamp (rad/s)
  test_cmd: [0.5, 0.0, 0.0]    # optional [vx, vy, w] applied every tick (standalone demo)

roqsim_sensors

fiducial_marker

Scene plugin: add an ArUco / AprilTag fiducial marker to the world or onto a robot body.

Config:

fiducial_marker:
  family: apriltag_36h11   # apriltag_36h11 | apriltag_25h9 | aruco_4x4_50 | aruco_5x5_100 | ...
  id: 0                    # marker id within the family
  size: 0.05               # side of the BLACK marker square (m) -- the length a detector estimates
  quiet_zone: 0.15         # white margin around the tag, as a fraction of `size` (default 0.15)
  emission: 0.0            # material emission; RAISE ONLY FOR VISIBILITY, NOT FOR DETECTION (see below)
  thickness: 0.002         # box half-thickness behind the marker face (m)
  vflip: false             # flip the texture rows / cols if the render comes out mirrored
  hflip: false             #   (a mirrored tag will NOT decode)
  # --- placement: EXACTLY ONE of the following two forms ---
  # (a) free-standing in the world:
  pose: [x, y, z]          # world position of the marker centre
  quat: [w, x, y, z]       # world orientation (or `rpy: [r, p, y]`); default: marker faces +Z (up)
  # (b) welded to a robot/arm body:
  attach_to: wrist_3_link  # body name (without prefix)
  prefix: "ur10e_"         # target robot/arm MJCF prefix (matches spawn_robot/spawn_arm `prefix`);
                           #   inherited automatically when this plugin ships in a model manifest
  rel_pose: [x, y, z]      # marker position in that body's frame (default [0, 0, 0])
  rel_quat: [w, x, y, z]   # marker orientation in that body's frame (or `rel_rpy`); default identity

The texture, material and geom this builds are named after the entry’s label (its name: sibling, else fiducial_marker), so a world carrying several markers gives each entry a label.

force_limit

Observation plugin: stop when a measured wrench exceeds what the task allows.

Config – a component of the entity whose wrench is watched:

force_limit:
  ft: ft                   # blackboard key suffix of the force_torque sensor (`ft:<key>`)
  max_force: 40.0          # N; magnitude of the measured force, 0 disables
  max_torque: 0.0          # Nm; magnitude of the measured torque, 0 disables
  settle_s: 0.0            # ignore the first seconds, while a reset transient decays
  latch: true              # once tripped, stay tripped until reset (a trial is failed, not un-failed)
  stop_run: true           # ask the driver to end the run, as a real stop ends the motion
  release_controllers: true  # deactivate the controllers driving the arm, the way a stop does
  reports_as: protective_stop  # the word this robot's own interface uses
  rate_hz: 30.0

Endpoint force_limit (out) reads a LimitReport: (tripped, reason, at_time, force, torque) – at_time is the simulation time of the first trip (-1.0 if none) and force/torque are the magnitudes that caused it, so a failure is attributable rather than merely flagged.

force_torque

Flags: parallel_safe

Sensor plugin: a six-axis force/torque sensor at a site.

Config – a component of the entry that spawns the arm whose prefix and namespace it inherits, since ownership is where the entry sits rather than a config key:

force_torque:
  site: fts_site            # REQUIRED: MJCF site to measure at (prefixed with the arm's prefix)
  frame: base               # sensor | base | world -- the frame the wrench is REPORTED in
  invert: true              # negate the reading (report the force the ENVIRONMENT applies to the
                            #   tool, the sign convention a real FT sensor and its users assume;
                            #   MuJoCo's site sensor reports the opposite). Whichever is chosen,
                            #   the blackboard reader says which it is in `measures`, so a
                            #   consumer never has to assume.
  tare_at_s: null           # sim time (s) at which to capture the zero offset, once per
                            #   episode; null (default) never tares. The `tare` service and
                            #   `WrenchReader.tare()` are the better doors -- see "Taring"
                            #   below, and note the offset is only valid at the pose it was
                            #   captured at.
  noise_force_stddev: 0.0   # N, additive Gaussian white noise on the three force channels
  noise_torque_stddev: 0.0  # Nm, likewise on the three torque channels
  rate_hz: 100.0            # endpoint publish rate
  namespace: ""             # transport scope (default: inherited from the entity)
  topics: {wrench: /ft}     # optional absolute-topic hardwire

Endpoint tare (in) is that zero button as a service; it takes no argument and its reply is what lets a scenario fail rather than measure against an offset it only assumed was applied.

Endpoint wrench (out) reads (force[3], torque[3]) and carries a geometry_msgs/WrenchStamped backend hint. A WrenchReader is published on the blackboard under ft:<entry label> for in-process consumers — the admittance controller is one — exposing read() and the resolved frame.

Frames. sensor returns MuJoCo’s raw site-frame reading. base rotates it into the owning entity’s base body frame, and world into the world frame. The choice is not cosmetic for metrics that split the wrench into an insertion axis and the plane orthogonal to it: |F_z| and ||F_x, F_y|| are frame-dependent, and a tool that tilts reports a different split in its own frame than in the world’s.

Taring: zeroing the tool’s own load. The sensor reads everything below the cut, which for a loaded flange is mostly the tool’s own weight – so a contact task measuring a 5 N push starts from 20 N of tool.

Zeroing is a command, the way it is on real hardware: an FT driver exposes a service taking no argument (zero_ftsensor) and this exposes the same thing three ways onto one implementation – the tare endpoint (std_srvs/Trigger over ROS), WrenchReader.tare() for an in-process controller, and tare_at_s for a world that wants it done once at a stated time without anything to press the button. Prefer one of the first two: a time is a number that has to stay in step with a scenario’s own timing, and if the approach runs long it fires mid-motion and zeroes against a contact.

All three re-arm on on_reset: an offset carried into the next episode is a measurement of the previous one, and repetitions of a trial would not be repetitions.

A tare is not gravity compensation, and the difference is a trap. The offset is captured in the raw sensor frame and subtracted there, exactly as a real FT sensor’s zero button works – so it is valid at the pose the tool was in when it was captured. Rotate the tool ninety degrees and the weight reappears, up to twice the tool’s load in the worst case, because the tool’s weight is fixed in the WORLD frame while the sensor frame turns with it. Compensating at every pose needs the tool’s mass and centre of mass estimated, which is a calibration, not a tare; roqsim does not do it, and a tare that quietly claimed to would leave a contact controller chasing a bias that grows with the tool’s tilt. Tare at the pose you are about to make contact in, or tare per approach.

The offset is the reading BEFORE noise is added, so what is left after taring is the noise alone rather than the noise plus one sample’s worth of it – a real tare averages many samples, and this is that average exactly. Capture happens on the first read at or after tare_at_s, so a sensor nobody reads is never tared and one read at 100 Hz tares within a step of the time asked for.

Noise is per-sensor config, deliberately. There is no generic error-model framework in roqsim (see docs/architecture.rst §9); a sensor that wants noise declares its own, as the lidar’s range_stddev does. The default is zero: a noise model that appears without being asked for is a silent change to every metric derived from the signal.

The draws come from roqsim.context.SimContext.rng_for() – the run’s seed, not a per-sensor one – for the same reason the lidars use it, plus one specific to a wrench: it is a pure function of (seed, sim_time, sensor), so two readers in the same step see the same wrench. This sensor has two by construction (the wrench endpoint and the blackboard WrenchReader an in-process controller polls), and with a stateful generator each read would have advanced the stream – the controller and the recorded signal would disagree about the force at one instant, which is indistinguishable from a controller bug. It also makes the noise reproducible from a recorded state without replaying the run, and repeats identically after on_reset because sim_time restarts.

gnss

Sensor plugin: a GNSS receiver – local ENU position converted to WGS84, with drift.

Config:

gnss:
  datum: {lat: 47.397742, lon: 8.545594, alt: 488.0}   # REQUIRED -- world origin on Earth
  body: x500                 # the body whose position is measured (default: the entity root)
  rate: 10.0                 # Hz -- real GNSS is slow, and EKF2's behaviour depends on that
  horizontal_noise: 0.5      # m, 1-sigma
  vertical_noise: 1.0        # m, 1-sigma
  velocity_noise: 0.1        # m/s, 1-sigma per axis
  bias_time: 60.0            # s, correlation time of the slowly-drifting position bias
  satellites: 12
  eph: 0.5                   # m, reported horizontal accuracy (default: horizontal_noise)
  epv: 1.0                   # m, reported vertical accuracy (default: vertical_noise)
  fix_type: 3                # 3 = 3D fix
  denied: false              # true -> no fix at all, without removing the plugin

The projection is deliberately the cheap one. dlat = north / R, dlon = east / (R cos lat) with R = 6378137.0 m (the WGS84 semi-major axis) – an equirectangular tangent-plane approximation. Its error grows with the square of the offset from the datum, and over the kilometre-scale extents a MuJoCo world can actually hold it stays well under a metre, i.e. below this receiver’s own noise floor. It is not a general geodesy routine: it ignores the ellipsoid’s flattening, uses geodetic altitude as if it were MSL, and would be wrong at continental range. Any experiment that needs true geodesy needs pyproj and a real projection, not a bigger constant here.

Noise is a drifting bias plus white noise, not white noise alone. A pure-white GNSS is the input that makes an EKF look best: averaging kills it, so position error shrinks with the filter’s window and the estimator appears to have solved a problem it has not. Real GNSS error is dominated by slowly-correlated terms (ionosphere, ephemeris, multipath geometry), and that is what a navigation experiment is about – how the estimator behaves when its position reference wanders metres over a minute. The bias here is an Ornstein-Uhlenbeck process with correlation time bias_time; the white term rides on top.

Randomness comes from ``ctx.rng_for``, like every other stochastic plugin, so the fix is a pure function of (sim.seed, episode, sim_time) and this plugin has no seed key of its own.

The fix is held between updates. At rate Hz the value changes 10 times a second and is constant in between, because that is what the sensor does – a consumer that interpolated it would be inventing measurements the receiver never made, and would hide exactly the latency an estimator has to cope with.

What this does not model: multipath and its geometry-dependent structure, ionospheric and tropospheric delay, constellation geometry (so satellites and the reported eph/epv are declared constants, not computed DOP), RTK/carrier-phase modes, receiver clock error, and acquisition time – the fix is available on the first tick.

ground_truth_pose

Flags: parallel_safe

Sensor plugin: ground-truth pose of a body or a site as a TF frame.

Config:

ground_truth_pose:
  # The pose read is the entity this entry is NESTED UNDER; ownership is position, not a
  # config key, and it is required -- at the top of a document this entry is refused.
  body: ""                  # base body override; default: the entity's registered base body
  site: ""                  # a SITE of the entity instead of a body: the pose of a sensor
                            #   mount, an emitter, an optical-flow sensor; `body` is then unused
  relative_to: world        # `world`: the true world pose (the default). `base`: the pose
                            #   in the entity's base-body frame -- what a simulator publishes
                            #   for a link of a model, and what a stack that composes link
                            #   poses with the model's own pose expects
  frame_id: map             # parent frame of the transform (`base`: default: the base body)
  child_frame: ""           # default: "<model>_base_link_gt" for a body (Gazebo-compatible),
                            #   the site's own name for a site
  rate_hz: 30.0             # TF publish rate
  lazy: false               # true: publish only while something subscribes (Endpoint.lazy) --
                            #   for a frame only a robot's own stack reads, never for /tf
  topics: { pose: /tf }     # optional absolute-topic hardwire (default relative "tf" -> /tf)

The transform is published on the relative tf topic, so it lands on the plain /tf (matching Gazebo). Configure the ros2_bridge gt: {prefix} block to divert it to /gt/tf instead, or give an instance its own topics: {pose: ...} when a stack reads ground truth from a topic of its own rather than from the TF tree.

imu

Flags: parallel_safe

Sensor plugin: a strap-down IMU – angular rate, proper acceleration, and attitude at a site.

Config:

imu:
  # The entity is the one this entry is NESTED UNDER; declaring it at the top of a document is
  # refused (`requires_owner`) -- an IMU measures a body's motion, so it belongs to something.
  body: ""                  # body the IMU is bolted to; default: the entity's registered base body
  site: ""                  # measure at an EXISTING site instead (then `body`/`pos`/`rpy` are unused)
  pos: [0.0, 0.0, 0.0]      # mount offset in the body frame (m)
  rpy: [0.0, 0.0, 0.0]      # mount orientation, fixed-axis XYZ (rad); or `quat: [w, x, y, z]`
  frame_id: imu_link        # the frame the reading is stamped in (default: '<label>_link')
  topic: imu/data           # the endpoint's RELATIVE topic, so a device can match its driver's
                            #   layout (the D435i's IMU is `camera/imu`); `topics: {imu: /abs}`
                            #   still hardwires an absolute one and wins over this
  rate_hz: 100.0            # endpoint publish rate
  orientation: true         # publish attitude; false -> orientation_covariance[0] = -1
  accel_stddev: 0.0         # m/s^2, additive Gaussian white noise, per axis
  gyro_stddev: 0.0          # rad/s, likewise
  accel_bias: [0, 0, 0]     # m/s^2, systematic -- a real accelerometer's is not zero-mean
  gyro_bias: [0, 0, 0]      # rad/s, likewise (a rate bias is what makes integrated yaw drift)
  orientation_stddev: 0.0   # rad, small-angle noise about each axis
  yaw_stddev: 0.0           # rad, EXTRA noise about the vertical axis only (see above)
  fault: {gyro_stddev: 0.4} # optional: the values it takes while degraded (set_sensor_override)

Endpoint imu (out) reads an ImuReading and carries a sensor_msgs/Imu backend hint on imu/data – the topic robot_localization and a standalone IMU driver both use – plus the static body -> frame_id transform. An ImuReader is published on the blackboard under imu:<address> for in-process consumers.

Nothing is computed while nothing is listening. The reading is assembled in the endpoint’s read() rather than in a post_step, and the endpoint is lazy, so a bridge does not even read it while the transport reports no subscriber (BridgeBase._skip_unsubscribed). That matters here because an IMU is the fastest sensor on the robot – at 200 Hz, several of them, each drawing noise from a fresh counter-based generator, is real work to do for nobody – and because a device whose manifest carries an IMU by default (the D435i does) must cost a world that ignores it nothing. Two properties make lazy safe for this endpoint, and both are why it is opt-in per endpoint: the publish has no side effect beyond the message (the mount transform is latched once at bind time, not derived per read), and an in-process consumer goes through the blackboard reader, which is not gated by anyone’s subscriptions. Where no transport can report subscribers at all, the convention is “assume yes”, so the endpoint stays live.

Covariances are the declared noise, squared, and nothing else. The reported variance is stddev**2 per channel (bias is systematic and deliberately not folded in: a covariance is what a filter uses to weight the random part, and inflating it to cover a bias tells the filter the bias will average out). A perfect sensor therefore reports zero variance, which is truthful and which some filters refuse; a world that wants a filter-friendly floor states the stddev it wants assumed, in the world, where the run’s provenance records it.

Noise draws come from ctx.rng_for, keyed imu:<address>, like the lidar’s and the FT sensor’s: reproducible from the run’s seed, identical for two readers in the same step (the endpoint and the blackboard reader are two by construction), and repeatable across a reset.

lidar

Flags: parallel_safe

Sensor plugin: 2D lidar via batched ray-casting (roqsim.raycast.cast(), no GL).

Config (in addition to lidar_common’s namespace/site/frame_id/ rate_hz/exclude_body/dropout_percent/emit_static_tf/tf_parent/lazy):

lidar:
  rays: 360                  # samples; the first at angle_min, the last exactly at angle_max
  angle_min: 0.0
  angle_max: 6.265732015     # default: angle_min + 2*pi*(rays-1)/rays, a full turn
                             #   with no bearing published twice
  range_min: 0.164           # published as the header's range_min
  max_range: 20.0            # published as the header's range_max
  detection_min: 0.164       # nearest distance measured; a nearer hit is too close
                             #   (default: range_min)
  detection_max: 20.0        # farthest distance measured; a farther hit is no return
                             #   (default: max_range)
  too_close: -inf            # published for a too-close hit: -inf, +inf, nan, raw
                             #   (the true distance) or a number
  no_return: +inf            # published where nothing is measured: +inf, nan or a number
  range_stddev: 0.0          # Gaussian range sigma (m)
  range_stddev_relative: 0.0 # sigma as a fraction of the distance, at and beyond
                             #   range_stddev_relative_from (0 = constant sigma)
  range_stddev_relative_from: 0.0   # m; nearer than this the sigma is range_stddev
  range_resolution: 0.0      # quantisation step of a published distance (m); 0 = continuous

What a scan publishes follows ROS REP 117 unless the device says otherwise. A hit nearer than detection_min is published as too_close (-inf by default) and a ray with nothing within detection_max as no_return (+inf). A too-close hit is never raised to range_min and never published as though it were measured. A device model whose real driver publishes something else declares it, e.g. urg_node publishes a Hokuyo’s too-close error code as 0.004 and its no-return code as 65.533. A consumer uses a range only where range_min <= r <= range_max and the value is finite, as REP 117 prescribes.

The header and the physics are separate keys because they differ on real hardware: a driver publishes a hard-coded range_min/range_max (neo_sick_s300 publishes 0.01/29.0 for a scanner that measures 0.05 to 30 m), while whether a return exists is the device’s physical limit. range_min/max_range are what the header says; detection_min/detection_max decide which rays are too close, measured, or no return. Each defaults to its header counterpart.

Layout. rays samples run from angle_min to angle_max inclusive, angle_increment = (angle_max - angle_min) / (rays - 1), which is how every 2D scanner driver lays out a LaserScan. A full turn is written the way its driver writes it: angle_max one increment short of angle_min + 2*pi where no bearing repeats (the LDS-01’s hls_lfcd_lds_driver), or -pi .. pi where the first and last ray share a bearing (the RPLIDAR C1’s sllidar_ros2).

Noise is drawn on the true distance of every hit and published on measured and raw too-close returns; a constant too_close/no_return carries none. A noisy distance is floored at 0 and then quantised to range_resolution. It is not re-classified: a measured return that noise carries past a header limit is published as the device would publish it, outside [range_min, range_max].

frame_id defaults to site; set it when the robot’s real description names the frame differently (the TurtleBot 4’s URDF calls it rplidar_link).

exclude_body defaults to nothing: a scanner skips only its own housing, which a device model names (exclude_body: mount), and any other robot geometry in the scan plane is a real return.

The static mount TF (emit_static_tf, on by default) is tf_parent -> frame_id, measured from that body. tf_parent defaults to the root body of the entity carrying the lidar (a robot’s base), else the resolved exclude_body, else world for a lidar nothing carries. A device model mounted with spawn_sensor sets emit_static_tf: false: the mount publishes the chain.

livox_mid360

Flags: parallel_safe

Sensor plugin: Livox Mid-360 3D lidar via batched ray-casting (roqsim.raycast.cast(), no GL).

Config (in addition to lidar_common’s shared keys):

livox_mid360:
  site: lidar                # site the rays are cast from
  frame_id: livox_frame      # defaults to `site`; Livox's own driver publishes `livox_frame`
  horizontal_rays: 360       # azimuth samples across the 360deg FoV (wraps, so none at 2pi)
  vertical_rays: 56          # elevation samples across the vertical FoV (inclusive endpoints)
  h_fov_min: 0.0
  h_fov_max: 6.283185307     # 2*pi (full 360deg)
  v_fov_min: -0.122173048    # -7 deg
  v_fov_max: 0.907571211     # +52 deg
  range_min: 0.1             # blind zone: a nearer hit is too close
  max_range: 40.0
  too_close: drop            # a hit nearer than range_min: drop (not in the cloud) or origin
  no_return: drop            # nothing within max_range: drop or origin

What a cloud carries follows the device. A measured return is a point at its distance along the ray. A too-close hit and a ray with no return are left out (drop) unless the device’s driver publishes them: origin puts a point at (0, 0, 0) of the sensor frame for each such ray, which is what the Livox Mid-360 and livox_ros_driver2 publish (see mid360.manifest.yaml). A point cloud is not a fixed-length array, so neither keeps a slot as a LaserScan ray does; with both set to origin the cloud has one point per ray, in ray order.

oakd_camera

Sensor plugin: OAK-D Pro RGB-D camera via mujoco.Renderer (GL, offscreen).

Config (in addition to camera_common.CameraPlugin’s, and depth_camera.DepthCameraPlugin’s clip_near/clip_far/depth_encoding):

oakd_camera:
  camera: oakd_rgb
  clip_near: 0.3      # m; depth outside [clip_near, clip_far] reads as "no return" (inf)
  clip_far: 100.0     # m

On depth_encoding: 16UC1: this camera’s default 100 m clip_far is further than uint16 millimetres reach, so opting in means also lowering the range to the depth the world actually needs – which the plugin says at load time rather than saturating at 65.5 m.

object_detector

Flags: parallel_safe

Sensor plugin: named objects detected relative to a robot, from ground truth plus an error model.

Config – a component of the entry that spawns the robot the detections are reported from, since ownership is where the entry sits rather than a config key:

object_detector:
  frame: base_footprint           # body whose frame the poses are expressed in
  rate_hz: 10.0
  objects:                        # body name -> what a detector would call it
    - {body: graspable_carton, class_id: parcel, size: [0.040, 0.024, 0.090]}
  position_stddev: 0.0            # m, Gaussian, per axis
  orientation_stddev: 0.0         # rad, small-angle, applied about each axis
  position_bias: [0.0, 0.0, 0.0]  # m, systematic -- calibration error is not zero-mean
  dropout_percent: 0.0            # this percentage of detections go missing
  max_range: 0.0                  # 0 = unlimited; else report only within this range
  confidence: 1.0                 # what the hypothesis score reports

The consumer contract, which a real detector must satisfy to drop in:

  • topic detections, type vision_msgs/msg/Detection3DArray

  • header.frame_id is a frame reachable through the robot’s own kinematics – never map

  • objects identified by results[0].hypothesis.class_id

  • latest-wins; a detection missing from a message means “not detected this cycle”

range_sensor

Flags: parallel_safe

Sensor plugin: a ray-grid range sensor – the ToF / IR proximity / ultrasonic / cliff model.

Config (in addition to lidar’s site/frame_id/rate_hz/range_min/max_range/ detection_min/detection_max/too_close/no_return/noise/emit_static_tf/ tf_parent keys):

range_sensor:
  site: cliff_front_left     # rays are cast along the site's +x axis
  h_rays: 1                  # rays across the horizontal field of view (>= 1)
  v_rays: 1                  # rays down the vertical field of view (>= 1)
  h_fov: 0.0873              # horizontal field of view, rad (5 deg); 0 with h_rays 1
  v_fov: 0.0                 # vertical field of view, rad; 0 with v_rays 1
  range_min: 0.0001          # published as the header's range_min
  max_range: 0.15            # published as the header's range_max
  rate_hz: 62.0

A lidar’s rays/angle_min/angle_max are refused here: the layout is the grid’s, and a world that wants a fan declares a lidar. The endpoint’s role name is range (so the topic is renamed with topics: {range: ...}), leaving scan to the robot’s scanner.

Pointing it. The site’s +x is the boresight, as for every ray sensor here; a cliff sensor is a site pitched towards the floor, a proximity sensor a site facing out through the shell. With the boresight on the floor at a known standoff, a floor return reads the standoff and a hole reads no_return (+inf by REP 117), which is exactly the comparison a cliff detector makes.

realsense_d415

Sensor plugin: Intel RealSense D415 colour stream, via mujoco.Renderer (GL, offscreen).

realsense_d435

Sensor plugin: Intel RealSense D435(i) colour + depth + point cloud, via mujoco.Renderer.

Config (also inherits camera_common.CameraPlugin’s own fields, undocumented here):

realsense_d435:
  depth: true         # publish the depth image (default: false)
  points: true        # publish a PointCloud2 -- implies depth (default: false)
  clip_near: 0.28     # m; the D435's minimum-Z. Outside [clip_near, clip_far] reads "no return"
  clip_far: 3.0       # m; the datasheet's usable range at default settings
  depth_frame_id: camera_depth_optical_frame
  depth_encoding: 32FC1  # or 16UC1 -- millimetres, 0 for invalid, as realsense-ros publishes it

References:

Note on FOV. One MuJoCo camera has one fovy, while a real D435 images colour and depth through different optics. A model that ships a d435_color camera therefore has to pick: the bundled d435 model uses the colour FOV, and the OpenMANIPULATOR-X’s eye-in-hand camera uses the depth FOV (58 deg), because the depth path is the one its experiment consumes. Override fovy in plugin config to choose per world.

realsense_d455

Sensor plugin: Intel RealSense D455 colour + depth + point cloud, via mujoco.Renderer.

segmentation_camera

Sensor plugin: per-pixel class and instance labels, and the 2D boxes that fall out of them.

Config (in addition to camera_common.CameraPlugin’s):

segmentation_camera:
  camera: camera            # the MJCF <camera> to render through
  classes:                  # REQUIRED: the experiment's vocabulary, in priority order
    - {class_id: 1, name: parcel, bodies: ["graspable_*"]}
    - {class_id: 2, name: person, entities: [walker_1]}   # whole kinematic subtree
    - {class_id: 3, name: shelf_board, geoms: ["board_*"]}   # PARTS of one body
  instances: false          # also publish the instance-id image (16UC1)
  detections: true          # publish 2D boxes derived from the mask
  min_pixels: 16            # an instance with fewer visible pixels is not reported
  color: false              # the colour stream, off by default here (see below)
  rate_hz: 10.0
  frame_id: camera_optical_frame
  topics: {}                # hardwire absolute topics, e.g. {labels: /seg/class_image}

Endpoints. labels (sensor_msgs/Image, mono8) is the class image: each pixel is the class_id of the class owning the geom that pixel hit, 0 where nothing labelled was hit. instances (16UC1, off by default) is the instance-id image. detections (vision_msgs/Detection2DArray) carries one entry per visible instance, with the class in results[0].hypothesis.class_id and the instance in Detection2D.id. camera_info comes from the base and describes all of them, since they are one render through one camera.

Instance ids are body ids, so they are stable across frames, across a reset, and across two runs of the same world – a tracker’s association is then a fact about the world rather than about the order this plugin happened to see things in. They are consequently not contiguous, which is why the detections name them rather than leaving a consumer to infer them from the image.

Class ids are the world’s, and 0 is reserved for “nothing labelled here”: a background class with an id of its own would be indistinguishable from an unlabelled geom, and a mask metric computed over it would score the wall. Classes are matched in declaration order and the first match wins, so a world can put a specific body ahead of the glob that would otherwise swallow it.

The colour stream is off by default here (PUBLISHES_COLOR = False). A label camera normally shares its MJCF <camera> with the RGB sensor that already publishes those pixels, and a second colour topic off the same optics is a duplicate that costs a serialisation. color: true turns it on for the case that wants it: a pixel-aligned image/label pair off one camera, which is what a training set is.

An absent entity is absent here too, and the pass says so rather than inheriting it. DeleteEntity hides geoms by moving them to roqsim.presence.ABSENT_GEOM_GROUP and zeroing their alpha (roqsim.presence). Measured, MuJoCo’s default view options already exclude that group, so a deleted obstacle contributes no label pixels either way – but “a deleted obstacle is not in the ground truth” is a contract this sensor owes an experiment, not something to leave resting on a default in another project that no test of ours would notice changing. So the pass is rendered with the group explicitly masked, and test_segmentation_camera.py pins the outcome. Alpha is the other half of hiding and is no help here: it is a colour, and an id pass need not respect it.

sensor_coverage_probe

Scene plugin: report the sensor coverage of a world, enabled/disabled from the world YAML.

Config:

sensor_coverage_probe:
  sensors: auto            # 'auto' = every MuJoCo camera in the world; or an explicit list of
                           #   {type, pos, rpy, config} placements (for lidars, or hypotheticals)
  camera_far: 10.0         # detection range assumed for 'auto' cameras (metres; not physics)
  target: {k: 1, frac: 0.95}
  sample:
    volume: true
    objects: true
    resolution: 0.25
    heights: [0.3, 1.0, 1.7]
    per_object: 64
  out: coverage            # output directory (report.json + render); relative to the CWD
  render: 3d               # 3d | 2d | both | none
  palette: coverage        # colour encoding: 'coverage' (red 0->green many) | 'density'
                           #   (light 0->dark many, so overlapping-sensor regions read darker)

Rendering needs a GL context, which import roqsim already selected for this machine (set MUJOCO_GL only to override it). sensors: auto discovers cameras robustly (their FOV is read straight from the MJCF); include lidars/Livox via the explicit list form or evaluate them with the CLI.

seyond_robin_w1g

Flags: parallel_safe

Sensor plugin: Seyond Robin W1G forward-facing solid-state 3D lidar via batched ray-casting.

spawn_sensor

Scene plugin: attach a standalone sensor MJCF (mesh + camera/site) into the world at a mount pose.

Config:

- spawn_sensor:
    model: d435            # bundled model name, filename, or absolute path
    namespace: ""          # optional transport scope; the capture plugin's endpoints inherit it
    prefix: ""             # MJCF name prefix (use distinct prefixes for >1 mount of the model)
    pos: [0.0, 0.0, 0.0]
    rpy: [0.0, 0.0, 0.0]   # mount orientation as roll/pitch/yaw (rad)
    motion: static         # who owns the mount's pose: static (default; welded, nothing moves
                           #   it), driven (a plugin or scenario places it), physics (the solver)
    attach_to: wrist_3_link # OPTIONAL: weld the mount to this body of an ALREADY-SPAWNED robot
                           #   or arm instead of to the world, so it rides what carries it.
                           #   `pos`/`rpy` are then relative to that body. Same spelling as
                           #   `fiducial_marker`'s, and mutually exclusive with `motion:`.
    attach_prefix: "ur10e_" # the carrier's MJCF prefix, prepended to `attach_to`/`parent_frame`
    parent_frame: cover_link # OPTIONAL, instead of `attach_to`: a body OR a declared frame of
                           #   the carrier to hang from; `pos`/`rpy` are the vendor joint origin
    frame_id: laser        # the device's scan frame name, filled into its manifest; default: the
                           #   vendor name its manifest's `frame_id:` declares (see below)
    show_fov: false        # reveal / synthesise the sensor's FOV visualisation (see below)
    fov_alpha: 0.25        # per-cone translucency when show_fov is true (0..1); ~0.25 maximises the
                           #   darkness step between single- and multi-sensor overlap
    fov_range: <far>       # far plane of a synthesised camera frustum (m); default: model manifest
    fov_near: <near>       # near plane (m); >0 truncates the cone; default: model manifest 'fov:'
    fov_rays: [32, 24]     # ray grid [horizontal, vertical] used for the occlusion clip
    intrinsics:            # this UNIT's measured lens, rendered as well as published
      {fx: 1330.23, fy: 1329.37, cx: 974.25, cy: 538.99, width: 1920, height: 1080}
  name: camera_1           # the entry's label -- a sibling of the ref -- names this mount's
                           #   entity, which a capture plugin's `robot:` then points at

Mounting on something that moves: eye-in-hand and friends. attach_to welds the mount to a named body of a robot or arm spawned EARLIER in the document, so the sensor rides the flange, the mast or the chassis and needs no pose of its own to be maintained. pos/rpy are then read relative to that body, and attach_prefix carries the carrier’s MJCF prefix.

This is what puts a sensor on an arm that does not ship one. An arm whose MODEL carries a camera needs nothing here – its manifest offers the capture plugin and a world switches it on – but that is a property of three models, not of arms, and the alternative for the rest is editing an MJCF. A model edited for one trial travels badly and is invisible to anyone reading the world, whereas a mount declared here is part of the world that states it.

attach_to and motion: are mutually exclusive, and the refusal says why: a mount that rides a body has its pose from that body, so there is nothing for a motion: answer to own. Placing such a sensor means moving what carries it.

A device mounted by its carrier. Nested under a robot or arm – in the world’s components:, or in the robot’s own manifest – a spawn_sensor is that carrier’s device and inherits its identity, so a robot manifest states a vendor mount and nothing else:

components:
  - spawn_sensor: {model: rplidar_a1, parent_frame: shell_link,
                   pos: [-0.04, 0, 0.098715], rpy: [0, 0, 1.5708], frame_id: rplidar_link}
    name: rplidar
  • attach_prefix defaults to the carrier’s prefix, and prefix to <attach_prefix><name>_, so two identical scanners on one base never collide and the device’s own components resolve its own bodies (exclude_body: mount is this device’s housing and nothing else).

  • parent_frame is where it hangs: a body of the carrier, or a frame the carrier declares (spawn_robot’s frames:), resolved under attach_prefix. It is required when nested (attach_to still works and names a body), and the two are mutually exclusive.

  • namespace defaults to the carrier entity’s, at configure; an explicit one wins, and a capture plugin’s topics: still overrides a topic outright.

  • A nested mount is welded: motion other than static is refused.

Frames and placeholders. A device manifest may declare a frames: chain relative to its own bodies (roqsim.frames), first entry hanging from the mount and the scan frame named {frame_id}, plus the vendor’s default name for that frame as frame_id::

frame_id: laser
components:
  - lidar: {site: scan, frame_id: "{frame_id}", exclude_body: mount, emit_static_tf: false}
frames:
  - {name: "{frame_id}", parent: mount, pos: [0, 0, 0.03], rpy: [3.14159, 0, 0]}

Two placeholders are filled into every string of the manifest’s component configs and frames:: {frame_id} (this mount’s frame_id) and {parent_frame} (its parent_frame, else attach_to, else world). Any other is refused. A mount that sets no frame_id takes the manifest’s frame_id: (roqsim.manifest.manifest_frame_id()); an explicit one wins. A device whose vendor names no default declares none, and a mount of it that uses {frame_id} without setting one is refused, as is a second mount on one carrier with the same frame_id. Each frame becomes a site of the device; at configure the mount publishes, from the compiled model, parent_frame -> first frame (only for a welded mount, whose pose that is) and each further frame from its declared parent, or from the first frame when its parent is a body of the device. Names are bare and scoped by the mount’s namespace.

Moving a mount after the world is built. motion: is the same three-answer key spawn_model uses for a prop, and it is what a trial needs to place a sensor at run time – a viewpoint the campaign varies, a camera a scenario repositions between phases.

motion: static is the default: the mount is welded into the model. A welded body has neither a mocap slot nor a joint, so nothing can place it at all, and a set_entity_state naming it is refused rather than silently ignored – which is the answer a trial can act on, where a placement that quietly did nothing is not.

motion: driven makes the mount a mocap body: no degrees of freedom, so it holds whatever pose it is given, nothing that touches it shoves it off, and it does not fall. That is what a sensor on a mast or a ceiling IS, and it is the mode a repositionable sensor wants.

motion: physics adds a free joint, handing the pose to the solver from the next step on. Only a mount that is meant to fall, be pushed or be carried wants it – ask for it on an overhead camera and the camera drops to the floor, which is precisely what it means.

model’s <model>.manifest.yaml (e.g. d435.manifest.yaml) ships the matching capture plugin, injected automatically the same way a robot’s manifest is (see roqsim.manifest.expand_manifest()); off with default_plugins: false.

A measured lens, per placement. intrinsics: writes fx/fy/cx/cy (pixels, at the resolution they were measured at) onto the model’s camera as sensorsize + focalpixel + principalpixel, which is what MuJoCo renders through – so the frame really has an off-centre principal point and fx != fy, and the capture plugin reads the same numbers back out of the compiled model (intrinsics_from_model(), path 1). Pixels and camera_info are then one claim rather than two.

It belongs to the placement and not to the model because a calibration describes one physical unit: three D435s of one rig measure fx 1330 / 1344 / 1413 with principal points scattered up to 14 px off centre, so a shared d435.xml has no single lens to carry, and a variant per unit would clone a mesh in order to hold three numbers. Stating them here leaves one model and gives each mount its own optics.

The resolution is part of the measurement, so width/height are required rather than inferred – the model camera’s own resolution and the size the capture plugin renders at are both within reach and neither is necessarily the frame the calibration was made in. The plugin may then render at any size: the intrinsics scale with it. Distortion is a separate matter – it cannot come out of a projection matrix – and stays on the capture plugin’s distortion:, which warps the render to match.

FOV visualisation. show_fov: true makes the sensor’s field of view visible. Three paths, tried in order: (1) if the model has cameras (e.g. the RealSense/Zivid mounts) a translucent view frustum is synthesised per camera from its fovy/aspect spanning the valid detection band fov_near..``fov_range``, always clipped against world geometry into a visibility volume that stops at walls and objects (see Occlusion below); (2) otherwise (a camera-less model), if it ships FOV geoms – non-colliding, name ending FOV_GEOM_SUFFIX (_fov), hidden at rgba alpha 0 – they are made translucent (fov_alpha); (3) otherwise, if the model manifest declares an angular fov: band (h_min/h_max/v_min/v_max – the camera-less lidars, Mid-360 / Robin W1G) a translucent sector shell is synthesised from those datasheet angles between radii fov_near..``fov_range``, using the very direction convention the capture plugin casts with (so the drawn shell matches the rays; a >= 2*pi azimuth span is a full 360deg dome). So every sensor can show its FOV, whether or not it ships a bundled mesh. show_fov: true on a model with none of a camera, an _fov geom, or an angular manifest band is a hard error, not a silent no-op.

The valid range is device knowledge: fov_near/fov_range default to the sensor model’s own fov: {near, far} block in its <model>.manifest.yaml (a world overrides either per placement), so show_fov: true alone draws the correct band without repeating device specs in every world. fov_near > 0 sets the near cap of the synthesised visibility volume so the drawn cone starts at the near plane – the shape then is the valid range band, not a cone implying validity down to distance 0. (A camera has no physical range, so these are display values.) A model that has a camera (e.g. the RealSense or Zivid) always synthesises its frustum from that camera, even when it also ships a bundled _fov envelope (the Zivid does) – the envelope is only revealed for a camera-less model that ships one. A camera-less lidar (Mid-360, Robin W1G) draws a synthesised angular sector (see below), whose ~0.1 m near cutoff is negligible.

Occlusion (always on for anything synthesised). A synthesised FOV volume is never drawn as an idealised cone that passes through walls: it is always clipped into a visibility volume that stops at world geometry. A ray grid is cast from the sensor against the world built so far, each ray clamped at its first hit, and the drawn mesh spans those hit points (a non-convex userface mesh). This covers camera frustums (a fov_rays grid from the pinhole) and lidar sectors alike (the sector’s own azimuth x elevation grid from the scan site). Only a bundled _fov envelope draws un-clipped – it is a baked mesh and not re-cuttable. The clip is a static build-time snapshot: it raycasts geom groups 0/1/3 of the partial world, so list scene/floorplan plugins before the sensors (a sensor built before the walls would see none); dynamic bodies (robots) occlude at their spawn pose; the volume never updates at runtime. Costs one extra world compile per synthesising sensor at build time.

Overlap reads as darkness. The cones are translucent and MuJoCo alpha-blends them, so where several sensors’ cones overlap the region accumulates more layers and renders darker – a visual cue for “how many sensors see here”. fov_alpha defaults to 0.25: the darkness step between single- and double-coverage is largest near this alpha (a(1-a) is maximal around a=0.3) and vanishes at very low alpha, which is why a barely-translucent cone makes single and double look identical. Raise it toward opaque only if you want solid cones; lower it only if the cones obscure the scene – but expect the overlap cue to weaken. The cones are drawn double-sided so you also see the colour when standing inside a field of view (MuJoCo back-face culls, so a single-sided shell vanishes from within); this means a lone cone shows two layers (its near and far walls) and already reads as tinted, so overlap is now a further darkening rather than the sole cue. This is still qualitative and view-dependent; for a quantitative, unambiguous per-area count use the coverage density render (sensor_coverage_probe / roqsim sensors coverage with palette: density).

(Bundled _fov geoms are identified by name, not geom group, on purpose, and synthesised frustums live in group FOV_GEOM_GROUP (2): the MuJoCo 3.x offscreen renderer drops large geoms in group 4/5 once a scene has several geoms, so an FOV volume must live in a normally-rendered group.)

zivid

Sensor plugin: Zivid 3 XL250 structured-light 3D camera, via mujoco.Renderer (GL, offscreen).

roqsim_walker

walker

Scene + controller plugin: a kinematic pedestrian that patrols a route or is driven to goals.

Config:

walker:
  walker: MaleVisitorWalk  # blueprint folder under models/people/ (required)
  namespace: ""            # transport scope for the goal endpoint
  outfit: B                # clothing variant: a letter, or {pants: C, jacket: A}
  skin: true               # false -> capsule visuals instead of the character mesh
  speed: 1.2               # m/s; past ~1.7 the run clip blends in
  pos: [0.0, 0.0]          # spawn, used when `waypoints` is empty (goal-driven only)
  waypoints:               # patrol route; the walker starts at waypoints[0]
    - [-2.5, -2.5]
    - [ 2.5, -2.5, [3, 6]] # optional per-waypoint dwell: secs, or [lo, hi] random pause
  loop: true               # cycle the patrol forever
  dwell: 0.0               # default dwell applied to every waypoint
  arrival_radius: 0.25
  avoidance: false         # true -> the shared local model gives way for it. A walker
                           #   steers or does nothing; it has never looked ahead, so this
                           #   never makes it stop. Write a `navigator` with
                           #   `avoidance: {stop: true}` for one that should.
  robot_body: base_link    # body the walker yields to (default: the robot entity's base)
  robot_radius: 0.25
  goal_endpoint: true      # false -> patrol only; declares no goal endpoint, so a bridge needs
                           #   no handler for it (a patrol-only world drops the nav2_msgs dep)
  action_name: navigate_through_poses   # relative action name of the goal endpoint
  orca: {neighbor_dist: 4.0, time_horizon: 3.0, radius: 0.26, max_speed: 1.6}
  planner: {inflation_radius: 0.3, waypoint_radius: 0.3}
  recovery: {stuck_time: 1.5, backup_time: 0.5, max_recovery: 4}
  motion: {walk: /abs/walk.npz}         # override a resolved locomotion clip

Several walker plugins may coexist: they share one WalkerController (so ORCA sees every walker, the robot and any mocap props in one simulation). The first instance to initialise owns the per-step tick; the rest only contribute their spec.

ROS 2 bridge (roqsim_ros_bridge)

The bridge plugins live in the ROS 2 workspace (ros2_ws/), which is built with colcon rather than pip-installed into the docs venv, so they are listed here by hand; they appear in the generated catalog above once ROS is sourced and the workspace is on the path.

Plugin

Purpose and key config

ros2_bridge

Generic bridge: wires every endpoint declared on ctx.interface to ROS 2 (type resolved from the endpoint’s type string; action-hinted endpoints become action servers, e.g. FollowJointTrajectory for MoveIt2), plus /clock and tf. No per-topic config — one bridge serves the whole world; each endpoint’s own namespace (from its producer’s config) scopes its topics/frames/actions. namespace (optional global outer prefix), tf_namespace (publish TF on /<ns>/tf + /<ns>/tf_static instead of the global /tf; topics only — frame ids are unchanged, use frame_prefix for those), clock_rate_hz (default step — one /clock per physics step; a rate must divide every gated publish period or that publisher’s stamps alias, and configure() warns when one does not), reuse_messages, rates (per-endpoint overrides, snapped onto the physics grid like every other publish rate — see the note below), owner (optional endpoint filter for multi-transport splits), merged_joint_states (see the note below).

sim_interfaces

simulation_interfaces control plane (features / entities / state / step / reset). No required config; reuses the bridge’s node when co-loaded.

Note

A publish rate lands on the physics grid. A gate is tested once per physics step, so the rates a world can hold are exactly physics_rate / k for integer k. Every rate this bridge gates on — an endpoint’s own rate_hz, a backend hint, a rates: override, clock_rate_hz, a merged joint_states — is snapped to the nearest of those when it is bound, and the move is logged in proportion to its size (silent below 0.1 %, a note below 1 %, a warning naming the nearby achievable rates above it). Nearest, so a snapped rate may come out slightly FASTER than asked: a rate meant as a ceiling has to be one the world can hold.

At the common timestep: 0.002 that makes 10 Hz and 25 Hz exact and 30 Hz a 500/17 — 29.41 Hz. Where a result turns on that difference there are two ways to keep the number: ask for a rate on the grid, or step the world at a whole multiple of the rate you need (30 Hz is exact at 510 Hz, i.e. timestep: 0.0019607843137254902), which is the one to reach for when the rate came from a paper. Write such a timestep out in full: the step rate is recovered from the float, and a rounded one is a different grid. Both numbers, requested and realised, reach a recording’s provenance as endpoint_rates, so a run states what it published at rather than what it was asked for.

Note

When to set merged_joint_states. A robot with several controllers declares one joint_states endpoint per controller (arm_controller does), and where those are scoped apart by namespace nothing publishes the plain topic a robot_state_publisher or MoveIt’s planning-scene monitor over their combined robot_description subscribes to — so that description gets no TF and move_group never learns a current state, silently: it still logs that planning is ready. The bridge closes that with one extra merged publisher per group, carrying every member endpoint’s names/positions/velocities/efforts in registration order, alongside each endpoint’s own topic, which is unchanged.

Which endpoints form a group is a fact about the stack — how many robot_descriptions it runs — that the world cannot answer, so it is declared:

components:
  - ros2_bridge: {}                                # "auto" (default): group by entity
  - ros2_bridge: {merged_joint_states: true}       # one /joint_states across all entities
  - ros2_bridge: {merged_joint_states: false}      # never merge
  - ros2_bridge:                                   # groups stated outright
      merged_joint_states:
        - {topic: /cell/joint_states, owners: [ur10e_left, ur10e_right]}

"auto" merges each entity’s controllers into the scope they share (dual/left + dual/right → /dual/joint_states) and keeps separate robots separate — right without any declaration, because one entity is one physical robot however the stack is arranged. Two arms that are two entities but one description (what arm_controller’s joint_prefix exists for) need true or an explicit group; auto warns when more than one entity publishes joint states here so that case is not silent. Controllers already sharing one topic get no merged publisher: they meet on the wire, and both robot_state_publisher and MoveIt accumulate partial joint states.

Note

When to set tf_namespace. By default the bridge publishes TF on the global /tf, which is right for a single robot owning the tree. But a namespaced Nav2 bringup follows the multi-robot convention of remapping /tf -> tf, so its TF lives on /<robot>/tf — and tooling that assumes that convention (notably scenario_execution’s NamespacedTransformListener, which subscribes <namespace>/tf) then never sees the bridge’s odom -> base_link. The symptom is a stack that looks healthy while a transform lookup hangs forever, e.g. scenario_execution’s init_nav2 stuck on “Waiting for transform map -> base_link”.

Set tf_namespace to the robot’s namespace so the bridge joins that tree:

components:
  - ros2_bridge: {tf_namespace: a200_0000}   # -> /a200_0000/tf, /a200_0000/tf_static

It must match the namespace the consuming stack uses, and it is all or nothing: every publisher of a link in the chain has to agree on one topic. Note this scopes the TF topics only — frame ids (map, odom, base_link) are untouched; namespace those with frame_prefix if needed. Setting the bridge’s namespace does not do this: tf2_ros’s broadcasters hardwire the absolute /tf, which is exactly why this option exists.

Transport plugins and the scene-only consumers

A bridge publishes what the other plugins built; it adds nothing to the model. Such a plugin sets the class attribute transport_only = True — BridgeBase does, so every transport inherits it, including one you write — and the tools that want the scene rather than a running simulation drop it before building: roqsim render, the scene-review window (roqsim_scene_builder), and roqsim export web/urdf/srdf/moveit.

That is what makes a *_ros world renderable without ROS. Those same tools also skip a plugin whose ref does not resolve at all (the bridge is registered by a colcon package, so it is absent from a pip-only environment) and say so on stderr, naming it — geometry is unaffected either way, but the other way to get there is a misspelt ref.

roqsim sim keeps both by default: a simulation genuinely needs its transport, so an unresolvable ros2_bridge there stays the loud failure it should be (source ros2_ws/install/setup.bash).

--no-communication is the deliberate exception, for opening a *_ros world in the viewer on a machine with no middleware installed:

roqsim sim depot_nav2.yaml --no-communication

It is not the render path’s rule with a flag on it. Two differences, both because this one is a simulation:

  • Only a plugin that can be identified as transport goes — its class declares transport_only, or its ref is one of the bridges roqsim names for --ros. An unresolvable ref that is neither stays in the world and still fails the build, because there it is a misspelt plugin that would have changed the run.

  • The run then communicates with nothing, and says so on every start: nothing published (no /clock, no TF, no sensor topics, no odometry), nothing received (no /cmd_vel, no goals, no services). An external stack sees a simulator that was never started, and a robot it would have driven stands still. Use it to look at a world, never to run its experiment.

--no-communication and --ros/--tf-namespace/--sim-control are refused together rather than silently ordered.

Nothing is required to know the flag exists, either. When a world’s only unresolvable plugins are its bridges, the failure says so and names both ways out; a bare “unknown plugin ros2_bridge” sends the reader hunting for a typo that is not there.

Model plugin manifests

A robot’s controller and sensors are intrinsic to the model, not the world, so they ship with the model in a <model>.manifest.yaml manifest next to its MJCF. A spawn plugin pulls them in automatically, so a world just spawns the robot – and the same applies to a device with more than one sensor in it: the bundled d435 is a D435i, so its manifest carries the imu component with the inertial module’s own extrinsic, and spawn_sensor: {model: d435} yields both camera/imu and the colour stream. A world that models the IMU-less D435 sets enabled: false on that component (which is also how “does this device have an IMU” becomes a campaign factor):

components:
  - spawn_robot:
      model: turtlebot4
      pose: {position: {x: 0, y: 0}}   # diff_drive + lidar + oakd_camera come with it
  - ros2_bridge: {}

An arm’s manifest can also carry an eye-in-hand sensor: a camera among the arm’s components rides its flange, exactly as one among a mobile base’s components rides the base – there is no per-family mount key to choose between. The open_manipulator_x model ships the MJCF d435_color camera at ROBOTIS’s own RealSense mount pose but deliberately leaves realsense_d435 OUT of its manifest – the arm provides the mount, the world decides whether anything renders from it, at what rate, and whether it reprojects to a point cloud.

The same applies to spawn_arm (roqsim_manipulation): {model: ur10e} pulls in that arm’s arm_controller; and to spawn_sensor (roqsim_sensors): {model: d435} pulls in its realsense_d435 capture plugin.

An eye-in-hand camera, or any sensor that rides something that moves, is spawn_sensor: {attach_to: <body>, attach_prefix: <carrier prefix>} – the same spelling fiducial_marker uses, welding the mount to a body of a robot or arm declared earlier in the document, with pos/rpy then read relative to that body – which is what a datasheet or a CAD drawing states, and what a world-frame pose cannot be once the carrier moves. An arm whose own MODEL ships a camera needs none of this (three do, and their manifests offer the capture plugin); this is how every other arm gets one, without a per-trial MJCF edit that travels badly and is invisible to anyone reading the world. It is mutually exclusive with motion:: a mount that rides a body has its pose from that body, so moving the sensor means moving what carries it.

A device a robot ships with is a spawn_sensor nested among the robot’s components, usually in the robot’s own manifest. It is mounted at the vendor’s parent_frame (a body, or a link the robot declares in its manifest’s frames:) with the vendor joint origin as pos/rpy. It inherits the robot’s prefix (its own is <robot prefix><name>_) and namespace. Its components are addressed <robot>.<name>.<plugin>, and a robot manifest overrides one by nesting it under the mount. The mount publishes the device’s frame chain as static TF. Its scan frame is the mount’s frame_id, else the vendor default the device manifest declares as frame_id:; a device whose vendor names none needs one on every mount. The spawn_sensor and spawn_robot entries below have the keys.

A standalone mount takes the same motion: key a prop does, with the same three answers, and it is what a trial needs to place a sensor at run time – a viewpoint the campaign varies, a camera a scenario repositions between phases. static is the default and welds the mount into the model, so nothing can move it and a placement naming it is refused rather than ignored. driven makes it a mocap body: it holds the pose it is given, nothing that touches it shoves it off, and – the part a free joint gets wrong – it does not fall. That is what a sensor on a mast or a ceiling is. physics hands the pose to the solver, which for an overhead camera means the camera drops to the floor; ask for it only when the mount is meant to fall, be pushed or be carried.

  • Override a default: declare the same plugin inside that robot’s/arm’s components: block — your entry wins (e.g. add test_cmd/test_target, or change lidar rays). Nothing is duplicated. Matching is on the label: the entry’s name:, else its plugin ref, among that owner’s components. There is no entity key to name — an entry belongs to the entity whose block it sits in — which is what makes an override an override: a controller that named its robot instead could sit anywhere, and one declared outside the block would run alongside the manifest default rather than replacing it, leaving two controllers on the same actuators and a config with no visible effect. The override is partial: keys you do not mention keep the model’s manifest values, so adding a test_cmd does not cost you the model’s wheel geometry or actuator names. Per key, what the world says wins; nested values (e.g. topics:) replace the manifest’s mapping outright rather than being deep-merged. To start from the plugin’s own defaults instead of the model’s, opt out with default_plugins: false and declare the plugin fully.

  • Switch one off with enabled: false – as a sibling in the document, or from an override:

    roqsim sim world.yaml --set components.robot.oakd_camera.enabled=false
    

    The component is not deleted: it stays addressable, stays in the run’s record saying it was turned off, and a later override can turn it back on. Disabling an entry disables everything it owns.

  • Opt out entirely: set default_plugins: false on the spawn_* config.

  • Derive one manifest from another with extends:. unitree_g1_dex1 is a unitree_g1 plus hands, and says exactly that – rather than repeating the base’s locomotion and lidar blocks as a second copy for someone to keep in step by hand:

    extends: unitree_g1        # a roqsim.models ref, or a path beside this manifest
    components:
      - arm_controller: {...}
        name: left_arm_controller
    

    What is inherited is components, not geometry: a derived model keeps its own MJCF. The base’s components come first, so a derived entry with the same label is the one that runs. Cycles raise. A manifest may not carry sim: at all – and neither may anything it extends – because a model is a component of a world, and the run’s seed, pacing and contact overrides belong to the world being run rather than to something included in it.

  • Add a manifest for your own model: drop a <model>.manifest.yaml beside the MJCF listing the plugins (same shape as a world’s components:); the entity name is filled in for you, and each injected plugin also inherits the spawn’s prefix — so a build-time plugin that welds geometry onto a spawned body (e.g. fiducial_marker with attach_to: wrist_3_link) resolves the prefixed body name without the world having to know it. ur10e_custom.manifest.yaml ships an arm_controller, an eye-in-hand realsense_d415, and a fiducial_marker on the wrist.

A model: name is resolved across all installed packages, so a world can spawn a model that lives in a different package from the spawn plugin. Register a package’s models once:

# pyproject.toml
[project.entry-points."roqsim.models"]
roqsim_assets = "roqsim_assets.models"   # module exposing MODELS_DIR

Then {model: conveyor} finds it by bare name (or {model: roqsim_assets:conveyor} to be explicit). A model that reuses another package’s meshes need not copy them — add an assets: <provider> key to its <model>.manifest.yaml to borrow that provider’s mesh/texture dirs (e.g. assets: roqsim_manipulation_assets for a custom arm variant that keeps the stock meshes).

# turtlebot4.manifest.yaml — shipped next to turtlebot4.xml (abridged)
components:
  - diff_drive: {max_linear_vel: 0.46, max_angular_vel: 1.9, wheel_accel_limit: 0.9,
                 cmd_vel_timeout: 0.5, odom_rate_hz: 62.0, publish_joint_states: false}
  - joint_state_publisher: {rate_hz: 62}   # every joint, wheels and suspension, in one message
  - spawn_sensor:                  # the RPLIDAR A1 device model, at the vendor joint origin
      model: rplidar_a1
      parent_frame: shell_link
      pos: [-0.04, 0.0, 0.098715]
      rpy: [0.0, 0.0, 1.5707963267948966]
      frame_id: rplidar_link
    name: rplidar
  - oakd_camera:                   # renders: needs a GL backend (roqsim selects one on import)
      camera: oakd_rgb
      topics: {image: oakd/rgb/preview/image_raw, ...}   # the TurtleBot 4's names
  - bumper: {geoms: [body_collision], zones: {bump_front_center: [-0.314, 0.314], ...}}
  - range_sensor: {site: cliff_front_left, max_range: 0.15, lazy: true, ...}   # x4 cliff, x7 IR
    name: cliff_front_left
  - imu: {pos: [0.050613, 0.043673, 0.0844], topic: imu, rate_hz: 62}
  - ground_truth_pose: {site: mouse, relative_to: base, lazy: true, ...}
    name: gt_mouse

The second half is the Create 3 base’s own sensor surface – bumper zones, cliff and IR proximity sensors, IMU, and the ground-truth streams its vendor’s simulator adapter reads – declared on the model because the reference robot carries them, on the topic names that adapter’s shipped parameter files expect, and lazy so a world that never launches that stack publishes none of it. See The Create 3 / TurtleBot 4 stack.

Selecting a policy

A policy-driven controller can be pointed at a different checkpoint with policy::

- spawn_robot: {model: unitree_g1}
  name: robot
  components:
    - g1_locomotion: {policy: g1_stand, station_keeping: true}

policy: names a directory under the family’s policy/ holding <name>.spec.yaml and its checkpoint (an absolute or relative path to a spec also works, for an out-of-tree policy). The spec declares the observation layout, the joints the policy commands, the joints it merely observes, the control gains, and the envelope it was trained for – so adding a policy is dropping in a directory, not editing Python. Omit the key and the controller uses its bundled default, unchanged.

Note the envelope is recorded, not enforced: outside it a policy does not fail, it balances worse. Check it against what a world actually contains (spec.envelope.check_payload(mass)) rather than trusting a port log to be read.

What a robot carries

payload adds a carried mass to one body of an entity – a load is a property of the trial, not of a robot family, so it is stated in the world and swept like any other factor:

- spawn_robot: {model: turtlebot4}
  name: robot
  components:
    - payload: {mass: 2.5}      # kg, on the entity's root body

It is a point mass at the body’s centre of mass: mass adds, and a point mass contributes no inertia about its own centre. An offset is refused rather than approximated – an offset payload shifts the centre of mass and adds a parallel-axis term, which is a different body, not a heavier one. mass: 0 leaves the model untouched, so the unloaded cell of a sweep is identical to a world that never declared a payload. Load a body other than the root with body: (the entity’s spawn prefix is applied for you); which robot is loaded is the entry this one sits in, so there is nothing to point with.

Where thrust is bounded this is the flight envelope rather than a detail: see roqsim_aerial/README.md, which measures a quadrotor’s hover collapsing at a thrust-to-weight ratio of 1.

A range sensor that is not a scanner

The small range sensors a base carries – IR proximity, ToF, ultrasonic, a downward cliff sensor – illuminate a narrow cone, and range_sensor models that cone as a small grid of rays from a site, published as one LaserScan with the rows concatenated:

- spawn_robot: {model: turtlebot4}
  name: robot
  components:
    - range_sensor: {site: cliff_front_left, range_min: 0.0001, max_range: 0.15, rate_hz: 62}
      name: cliff_front_left                       # 1 ray: a cliff sensor
    - range_sensor: {site: ir_front, h_rays: 5, v_rays: 5, h_fov: 0.1745, v_fov: 0.1745,
                     range_min: 0.025, max_range: 0.2, rate_hz: 62}
      name: ir_front                               # 5x5 rays: an IR proximity sensor
  • It publishes returns, not verdicts. A cliff detector asks whether the nearest return is farther than the floor should be; a proximity sensor turns the nearest return into an intensity. Both are the consumer’s rule, applied to the grid this publishes, so a single sensor plugin serves every such device and nothing in the simulator encodes what a cliff is.

  • The site’s +x is the boresight, as for every ray sensor here. A cliff sensor is a site pitched at the floor: with its boresight on the floor at a known standoff, a floor return reads the standoff and a hole reads +inf (REP 117’s no return), which is exactly the comparison a cliff detector makes.

  • The rest is lidar‘s. Detection limits, too_close / no_return, the noise model, the fault switch and the static mount TF are inherited rather than restated; a fan’s rays / angle_* keys are refused, because the layout is the grid’s. The endpoint’s role is range (renamed with topics: {range: ...}), leaving scan to the robot’s scanner.

Injecting a physical fault

Some trials need something to go wrong at a chosen instant: a gripper that stops holding halfway through a carry, a wheel that loses traction, a payload that changes under load. model_override makes that a property of the world rather than something scripted into whatever drives the robot — name a model field, name the objects, name the target value, and let the scenario switch it on:

- model_override:
    overrides:
      - field: geom_friction
        select: [gripper_right_left_pad1, gripper_right_left_pad2,
                 gripper_right_right_pad1, gripper_right_right_pad2]
        to: 0.0
  name: grip_fault

and, for a wheel that keeps its grip only until it does not:

- model_override:
    overrides: [{field: geom_friction, select: [wheel_left_tyre, wheel_right_tyre], to: 0.02}]
  name: traction_fault

The plugin is inert until fired, so adding it changes nothing about a nominal run.

Firing it is a service call, and the reply is the point. Over ROS 2 the inbound endpoint is a std_srvs/SetBool, so a scenario does:

service_call(service_name: '/grip_fault/override', service_type: 'std_srvs.srv.SetBool',
             data: '{\"data\": true}', response_variable: 'fault')

and can fail the trial when the reply says the fault did not land — a topic publish could only be followed by hoping. In a ROS-free stepped run the same switch is ctx.blackboard.require("model_override:grip_fault").set_active(True), which is what an .osc action calls. Severity is not on the wire: it is the configured to: value, so sweeping how slippery the pads get is an ordinary experiment factor rather than a runtime message.

Three things to know before writing one:

  • Which side of a contact you select decides whether anything happens. MuJoCo takes a contact’s friction from the geom with the higher priority, and at equal priority the element-wise maximum of the two. So a default-priority wheel written to 0.0 against a floor declaring 1.0 changes nothing, and overriding a carton held by pads that carry priority="1" changes nothing either — select the geoms that own the contact, or select both sides. Use roqsim scenes describe <world> --overridable 'gripper_right*' to see the names, their current friction and their priority, rather than guessing.

  • A fault that did nothing says so. One step after the change the plugin compares the applied contact against what it asked for and reports landed, no_effect (a warning, and a failed service reply) or untested — the last meaning nothing was touching the selected geoms, which is not a failure. It is published as override_verified too, because a service call leaves no trace in a rosbag and mjModel is in neither the bag nor the state recording.

  • A reset returns the world to the configured state, exactly, from the values read at startup. Without that, repetition 2 of a sweep cell would start already faulted and report a plausible wrong number — Engine.reset resets MjData and never touches MjModel.

Not every model value can be written at runtime; geom_size and the opt.* globals are refused by name, with the reason and with what to use instead (for the globals, sim.contact_override, which is global and applies before compile — a different tool for a different job). The full allowlist, with what each field does and how it can silently do nothing, is in the plugin’s own Config:: block above and in roqsim scenes describe’s overridable.fields. Details and the measurements behind each row: architecture §9.2.

Perception ground truth

Two plugins answer two different questions about the same objects, and an experiment usually wants one of them, not both. object_detector reports an object’s POSE in the robot’s frame – what a manipulation stack consumes, and what a real pose estimator would output. segmentation_camera reports which PIXELS an object covers – what an IoU, a mask AP or a training set is computed from:

components:
  - spawn_robot: {model: turtlebot4}
    name: robot
    components:
      - segmentation_camera:
          camera: oakd_rgb
          classes:
            - {class_id: 1, name: parcel, bodies: ["graspable_*"]}
            - {class_id: 2, name: person, entities: [walker_1]}
            - {class_id: 3, name: shelf_board, geoms: ["board_*"]}
          instances: true

That publishes a mono8 class image, a 16UC1 instance image and a vision_msgs/Detection2DArray of tight boxes, all off one render through the named MJCF camera.

Three selectors, because a label does not always follow a body. bodies and entities label everything a body or a whole kinematic subtree carries – what a parcel or a pedestrian is. geoms labels named geoms directly, which is what the parts of a procedural prop are: a shelf compiles to one body carrying its boards and its legs as separate geoms, and a workbench its top and its frame, so a body-granular vocabulary can only call the whole thing one class. An experiment measuring whether a mapper separates a surface from its support needs them named apart. roqsim scenes describe <world> --overridable '*' lists the geom names a world carries, which is where a prop’s parts appear — the same listing model_override’s select: is written against, so this is not a second naming scheme to learn. Selectors compose within a class and across classes, first match wins in declaration order, and a geom takes the instance of the body it sits in — so two boards of one rack are one instance of shelf_board, which is what an instance image of a rack should say.

Three properties are worth knowing before a metric is built on it. Boxes measure the visible extent, because that is the only extent derivable from a mask and the only one a detector could have produced – an occluded object shrinks and, below min_pixels, is not reported at all. Instance ids are body ids, so they are stable across frames and runs rather than depending on the order things were seen in; they are correspondingly not contiguous, which is why the detections name them. And class id 0 is background: a declared class with id 0 would be indistinguishable from an unlabelled geom, so it is refused at load.

What a run cost

energy_monitor is the third observation plugin, beside the two that watch geometry: it meters the actuators that move a robot and integrates their mechanical power, so “energy per metre”, “how far on a charge” and “which planner is cheaper” become numbers a run produces rather than numbers an analysis fits:

components:
  - spawn_robot: {model: turtlebot4}
    name: robot
    components:
      - energy_monitor: {efficiency: 0.72, idle_w: 8.0, capacity_wh: 26.0, voltage: 14.4}

The split between measurement and assumption is explicit, and the defaults assume nothing. force * velocity per actuator is measured, every step, at the physics rate – reconstructed from a recording afterwards it would be sampled at the recording’s rate and need a drivetrain model to turn poses back into effort, which is a fitted constant between the simulator and the result. efficiency, idle_w, resistive_w_per_nm2 and regenerative are the platform’s own numbers; unset, the plugin reports mechanical work and nothing else. A state of charge exists only where a capacity_wh was given – without one the fraction is reported as unknown rather than as a full battery.

The per-actuator split is what makes the number usable on an arm. Each actuator’s force * velocity is sorted into driving and driven before the sum, so one joint descending under gravity cannot pay for another one lifting – netted first, an arm changing pose reports as free. Negative mechanical power is dropped rather than billed, because a non-regenerative drive dissipates the load’s energy instead of drawing it from the pack:

components:
  - spawn_arm: {model: ur5e}
    name: arm
    components:
      - energy_monitor: {efficiency: 0.85, idle_w: 35.0, resistive_w_per_nm2: 0.012}

The torque metered is the one a real drive supplies: the actuator’s own force plus its share of the gravity-compensation force. MuJoCo carries a compensated arm’s weight outside the actuator, so actuator_force reads exactly zero on a joint holding a payload against gravity – and since every position- and impedance-driven arm is compensated, metering it alone would report an arm that is free to hold a load up and free to lift one. It is the same quantity arm_controller reports as a joint’s effort, and for the same reason. Under control: effort, where nothing is compensated because supplying the gravity term is the controller’s job, the share is zero and nothing changes.

resistive_w_per_nm2 is the term a manipulator needs and a mobile base can usually ignore: the k in k * tau^2, the winding loss. A motor torque is a motor current, so it is the one term that survives a standstill – an arm holding a payload against gravity has exactly zero mechanical power and still dissipates I^2 R, which on a slow trial is often the larger part of the bill. One number covers a machine whose motors are one class; a mapping of actuator name to coefficient gives a shoulder and a wrist their own, and an actuator the mapping omits contributes nothing. Beside the joules the report carries torque_integral_nms, the integral of the summed absolute actuator forces – the effort metric a paper falls back on where its platform’s electrical constants are not published, accumulated here at the physics rate rather than at whatever rate /joint_states was published at.

Which actuators count is derived, not configured: every actuator driving a body of the robot’s kinematic subtree, so a world’s other machines are not on this robot’s bill and a model that gains a joint does not need the world edited. An entity with no actuators is an error, because a meter reading zero forever looks exactly like a robot that costs nothing to drive.

It reports; it does not intervene. A depleted battery latches and is published; the robot keeps driving. Ending a trial is the experiment’s decision, the same line contact_monitor draws about a collision – a scenario reads the endpoint and stops the run itself.

Ground that is not flat

Without it everything a robot can stand on here is a plane, while four of the ported platforms – Spot, the Husky, the Jackal, the Warthog – are outdoor machines whose papers are about what happens when it is not. heightfield is MuJoCo’s own height field wired into a world:

components:
  - heightfield: {size: [40, 40], height: 2.5, resolution: 128, seed: 3}
  - spawn_robot: {model: husky_a200, pose: {position: {x: 0, y: 0}}}
    name: robot

It provides the ground (provides_world), so sim.world is not also built underneath it – a floor through the hills is what that would mean. Elevation comes from one of three places and is normalised the same way regardless: generated fractal noise (reproducible from seed, so two cells of a campaign share their hills), a .npy array, or a greyscale .png/.tif read at its own bit depth. A GeoTIFF is converted by the tools that own reprojection – gdal_translate -ot UInt16 -scale dem.tif dem.png – rather than by a simulator pretending to know about coordinate systems.

The vertical scale is stated in metres (height:), never inferred from the file: an image has no unit, and a guessed one would put a made-up gradient under every result. It is also the natural campaign factor – “the same hills, half as steep” is one number.

roqsim sim roqsim_mobile:warthog_terrain_demo is this with a robot on it: 2.5 m of relief over 24 m, which the skid-steer climbs at up to 23 deg of pitch. It also shows the one coupling a terrain world has to get right – spawn_robot keeps the model’s rest height, measured against flat ground at z=0, so the spawn belongs at the terrain’s lowest sample or the robot starts inside a hill. The demo picks a seed whose minimum is the grid centre and pins that in a test, because a seed changed without moving the spawn looks like a world that simply throws its robot.

Contact is against the field’s triangles, so the sample spacing is the resolution of every wheel and foot interaction: 128 samples over 40 m is a 31 cm grid, which a 10 cm wheel rides as facets. Raise resolution for a small rough patch rather than a large smooth one; the cost is quadratic and buys nothing where the ground is flat.

Documenting a plugin’s config

A plugin’s keys are written in a Config:: block in its module docstring. Two readers parse that block and nothing else: roqsim plugins describe (and the MCP tool over it), and the page you are reading – every plugin listed above renders from it. A block written in a shape they do not read is a plugin that publicly takes no configuration:

"""Sensor plugin: 2D lidar via batched ray-casting.

Config::

    lidar:
      rays: 360
      angle_min: 0.0             # trailing text after # is the key's doc, and may
                                 #   wrap onto a bare comment line like this one
      sample:
        resolution: 0.25         # nested keys are published as sample.resolution
"""

Four things the readers rely on:

  • The block opens with a line beginning Config and ending ::. Qualify it freely (“Config (in addition to lidar_common’s …)::”) and let the qualifier wrap over up to three lines – but the :: must arrive, or there is no block.

  • One key per line, as name: example. The example is documentation, not a parsed value.

  • Nest as the world YAML nests. A key opening a mapping is published under the dotted path a world writes it at, so the block and the YAML have one shape rather than two.

  • Put it on the MODULE, not the class. A class docstring that merely points at the module (“See the module docstring.”) is ignored in favour of the module’s, but a class that documents different keys than its module will publish its own.

Prefer the declaration below wherever the keys have types, bounds or units worth checking: prose cannot be validated, so it drifts, and this block is read by a caller writing a world.

Declaring a plugin’s config

Every plugin validates its own config. A plugin may also declare that config, so the checks and the published description come from one place:

from roqsim.schema import Field

class PayloadPlugin(Plugin):
    CONFIG_SCHEMA = {
        "mass": Field(float, required=True, minimum=0.0, unit="kg", doc="added to the body's own"),
        "body": Field(str, default="", static=True, doc="body to load (default: the root body)"),
    }

    def validate_config(self, config):
        return [...]                               # whatever only this plugin knows

Declaring it is what enforces it. The types, ranges, required keys and – with STRICT_KEYS – unknown keys are checked when the world’s plugins are built, beside whatever validate_config adds; there is no call to remember. A schema the catalog publishes and nothing checks would be prose with a type annotation.

roqsim plugins describe payload then carries a schema block beside the docstring-parsed parameters: the same keys with their types, defaults, units and bounds. That is what a caller generating a world needs and what prose cannot give it – and unlike a comment it cannot drift from behaviour, because validation runs on it.

It is opt-in: a plugin without a declaration is unchecked by it, and one with a declaration still owns validate_config. The schema covers what is the same everywhere; a rule only one plugin has (two lists the same length, a file that must exist, a cut that must be finite) stays where it belongs rather than growing the shared vocabulary.

STRICT_KEYS = True adds the check nothing else can do – an unknown key is a typo, and above_Z silently leaving the ceiling standing looks exactly like the plugin not working. It is opt-in because a component’s config carries keys the world’s author did not write (a manifest’s prefix, a spawn’s entity); those are known centrally, and a plugin says so once its own list is complete.

Degrading a sensor mid-run

model_override changes the physics. Its counterpart changes what a sensor reports — a lidar that starts dropping returns halfway down a corridor, a scanner whose noise triples when it fogs up. That perturbation belongs in the sensor’s own config (§9.1), not in a model field, so it is written there and needs no plugin of its own:

components:
  - spawn_robot: {model: turtlebot4}
    name: robot
    components:
      - spawn_sensor: {}          # the manifest's RPLIDAR mount, by its label
        name: rplidar
        components:
          - lidar:
              range_stddev: 0.01
              dropout_percent: 2.0
              fault: {dropout_percent: 60.0, range_stddev: 0.35}   # held while active

The sensor is nominal until the fault is switched on, so adding a fault: block changes nothing about a run that never fires it. A scenario switches it by the sensor’s address:

set_sensor_override(instance: 'robot.rplidar.lidar', active: true)
wait elapsed(8s)
set_sensor_override(instance: 'robot.rplidar.lidar', active: false)

and over ROS 2 the same switch is a std_srvs/SetBool at robot/rplidar/lidar/override, with robot/rplidar/lidar/override_state and .../override_verified reporting back. The address is the dotted path of labels with dots as slashes, because a dot is not legal in a ROS name; a bare lidar would name neither of a robot’s two lidars.

It mirrors model_override in the three ways that matter, rather than re-deciding them:

  • Severity is configured, not sent. The fault: values are ordinary config, so sweeping how bad the fault gets is components.robot.rplidar.lidar.fault.dropout_percent — an experiment factor, deterministic per cell and in the run’s provenance. One bit crosses the wire.

  • The world never decides when. No time trigger, no condition trigger; a fault’s timing is the experiment’s independent variable.

  • A fault that changed nothing is reported as such. Applying a block whose values already equal the nominal reports no_effect, and set_sensor_override’s require_landed fails the trial on it — an unfaulted outcome wearing a faulted label is worse than a failed run. A restore has nothing to verify and reports untested.

Only keys the sensor reads per frame may be written. Each sensor declares its own allowlist; on the ray-casting sensors that is range_stddev, range_stddev_relative, range_stddev_relative_from, range_resolution, dropout_percent, max_range, range_min and rate_hz, plus detection_min and detection_max on the 2D lidar, and on the imu it is the noise, the biases and orientation – so a trial can drop the attitude channel or triple the rate noise partway through, which is what an IMU failure looks like to a localisation filter. Everything else is refused at load, by name, with the reason — rays, angle_min and angle_max because they change a LaserScan’s length or the bearing its indices mean, and site/frame_id/exclude_body because they are consumed once at configure. This is the geom_size rule from the physics channel: a value that writes fine, takes effect nowhere, and reads back as though it had is worse than one that is refused.

A fault does not survive reset: one process serves several trials, and a fault leaking into the next would quietly turn a nominal control cell into a degraded one.

Bases: three geometries, one interface

diff_drive, omni_drive and ackermann_drive publish the same endpoints – cmd_vel in, odom and joint_states out – so a stack does not know which it is driving until it asks for something the geometry cannot do. That is the point of having the third one: a car cannot turn in place, and cmd_vel with v = 0 and a yaw rate moves it nowhere at all. A planner that emits that command is a planner that would not move the real vehicle, and approximating a car with a differential base and a small angular limit hides exactly the failure the experiment is looking for.

What a real base offers its stack. Three keys on diff_drive (and omni_drive) are the base driver’s behaviour rather than the kinematics’, and a model that states its robot’s interface states them in its manifest – the TurtleBot 4’s does:

- diff_drive:
    cmd_vel_timeout: 0.5          # the watchdog every base driver has; 0 (default) holds a command
    odom_rate_hz: 62.0            # the rate odom (with its TF) and joint_states are published at
    publish_joint_states: false   # when a joint_state_publisher covers the whole robot

cmd_vel_timeout is off by the plugin’s default because an in-process driver sets a twist once and steps; a stack republishes at a rate and expects a dead publisher to leave a stationary robot, and the stop goes through the same acceleration ramp as any command. publish_joint_states exists for the consumer that needs a passive joint – a suspension travel, a caster swivel – in the same message as the wheels: the core joint_state_publisher publishes every hinge and slide joint of the entity in one message, the way ros2_control’s joint_state_broadcaster does, and the base’s own two-joint message is then switched off rather than left to interleave with it:

- spawn_robot: {model: turtlebot4}
  name: robot
  components:
    - diff_drive: {publish_joint_states: false}
    - joint_state_publisher: {rate_hz: 62}      # every joint, names without the spawn prefix

The command’s message type is the stack’s choice, not the base’s, so it stays a world key: stamped_cmd_vel: true where the stack publishes a TwistStamped – a TurtleBot 4’s own nav2 configuration does, and with the Create 3 nodes the base listens where motion_control republishes (topics: {cmd_vel: diffdrive_controller/cmd_vel}). Getting it wrong is not silent: the ROS bridge asks the graph once a second and fails the run when a peer of another type sits on one of its topics, naming the topic, both types and both sides.

ackermann_drive needs the model’s four names – two steered joints and two driven ones, left then right – plus the wheelbase and the widths its geometry comes from:

components:
  - spawn_robot: {model: my_car}
    name: robot
    components:
      - ackermann_drive:
          wheelbase: 0.32
          track: 0.24
          steer_track: 0.20
          max_steer_angle: 0.5
          steer_actuators: [left_steer_motor, right_steer_motor]
          steer_joints:    [left_steer_joint, right_steer_joint]
          drive_actuators: [rear_left_motor, rear_right_motor]
          drive_joints:    [rear_left_joint, rear_right_joint]

The two front wheels are steered by different angles and the two rear wheels driven at different speeds, both derived from the same curve – the inner wheel of a turn follows a tighter radius, and a shared value would scrub the tyres. Both splits vanish as the curve straightens.

Which width is which. A real car has three and they are not interchangeable: the separation of the two steering axes, the separation of the front wheel centres, and the driven axle’s track. The two splits are measured across different ones, so there are two keys. steer_track is the width the steer split pivots about – the steering axes, which on most vehicles are inboard of the wheels since a kingpin sits inside the hub – and track is the driven axle the drive split is measured across. steer_track defaults to track, which is exact for a design whose steering axes sit at its wheel centres; anywhere else, leaving it out overstates the steer split at every radius, and the front wheel centres (neither of the two) overstate it whichever key they are passed as.

It also accepts the message a car-like stack already speaks. ackermann_cmd takes an ackermann_msgs/AckermannDriveStamped on drive, beside the cmd_vel every base here publishes:

ros2 topic pub /drive ackermann_msgs/msg/AckermannDriveStamped \
  '{drive: {steering_angle: 0.3, speed: 0.6}}'

The message’s steering_angle is defined as the yaw of a virtual wheel located at the center of the front axle, which is exactly the centre angle this plugin splits into two, so nothing is converted on the way in. That is what makes it more than an alias for a twist: a twist states a curvature, w / v, which says nothing at rest – so through cmd_vel a stopped car’s rack can only hold the angle it has. Through ackermann_cmd a stopped car can turn its wheels, which is what a real one does while parking, and what a car-like stack sends when lining up before it moves off. Whichever of the two commands arrived last owns the angle; they are never merged, because a stated angle and a curvature are two ways of saying the same thing and averaging them obeys neither.

Both interfaces are kept because their consumers differ. Nav2 plans for car-like vehicles perfectly well – Smac Hybrid-A* and the state-lattice planner both take a minimum turning radius – but its controller commands in TwistStamped, so a car driven by Nav2 needs cmd_vel. A stack built around ackermann_msgs needs the other. Neither is a superset of the other.

Its odometry is dead reckoning like the others’, and it drifts on a curve where the tyres slip. That is left visible rather than corrected by a scrub factor: a skid-steer’s scrub is systematic enough for diff_drive’s slip_factor, while a tyre’s slip angle varies with speed and load, so a constant would only make the estimate look better than the sensor it stands for.

Manipulation: an arm on a linear axis

A gantry, a ceiling track or a seventh-axis floor rail is a prismatic joint carrying the arm base. spawn_arm’s rail: expresses it, and it is the one thing mount: cannot: mount welds the arm to a body that already exists, while a rail has to introduce the moving carriage itself:

components:
  - spawn_arm:
      model: ur10e
      name: ur10e
      prefix: "ur10e_"
      pos: [0.0, 0.0, 2.6]              # where the axis sits
      rpy: [3.14159265, 0.0, 0.0]       # rolled 180 deg: the arm hangs from the ceiling
      rail: {axis: [1, 0, 0], range: [-2.0, 2.0], home: 0.0}

What this buys is kinematic redundancy: a 6-DOF arm on a rail is a 7-DOF system, so a task pose has a one-parameter family of solutions and a planner can trade base travel against arm posture.

Three facts other code depends on:

  • The rail is joint 0. Its MJCF joint and actuator are declared before the arm’s, so arm_controller publishes and commands [rail_joint, <arm joints...>] — matching a URDF with the prismatic joint at the root of the chain, which is what MoveIt plans against.

  • ``home`` stays the arm’s joint vector; the carriage’s start is rail.home. Folding the rail into home would invalidate every per-model default (a 6-value ur10e home would land on [rail, j1..j5] and leave wrist_3 unset).

  • The carriage and track geoms are visual only. A ceiling track that collides traps the arm against its own support from the first step, and the collision model a planner reasons about comes from the URDF/planning scene, not from these geoms. Model real structure as scene geometry.

roqsim export urdf handles such a robot: a jointed root is emitted below a synthetic fixed world link (--world-link), which is the standard URDF spelling for a rail and keeps the fixed root MoveIt requires. A root body with a free joint still becomes a fixed root — a floating base belongs in TF (odom -> base_link), not in the description.

The SRDF is where that base reappears, and roqsim export srdf reads it off the model rather than assuming it: a base riding a MuJoCo free joint gets a planar virtual_joint (--base-joint floating for a full 6-DOF one), while a base welded to the world — an arm on a pedestal, or a rail whose DOF is already a URDF joint — gets none, which is how MoveIt spells a bolted-down robot. Declaring a virtual joint nothing publishes does not fail loudly; move_group logs Missing virtual_joint and never assembles a complete robot state, so --base-joint planar on a welded model is refused.

Manipulation: the whole MoveIt configuration

move_group needs six files, and the two above are the two a human would call the robot description. roqsim export moveit writes all six:

roqsim export moveit --world cell.yaml --out cfg/ --tip-site pinch --check
cfg/
  <arm>.urdf   meshes/     the kinematics, FK-checked against the MJCF
  <arm>.srdf               groups, named states, a sampled collision matrix
  kinematics.yaml          a solver over the chain the SRDF names
  joint_limits.yaml        the kinematic limits that time a geometric path
  moveit_controllers.yaml  which action a trajectory is executed against
  ompl_planning.yaml       the planner -- a starting point, meant to be overridden

The invariant is the same one the URDF export exists for, extended to the rest: the configuration MoveIt plans against is derived from the model the simulator loads, and --check fails the export when the two disagree by more than a micrometre.

Where the meshes are referenced from is a separate question, and ``–check`` does not answer it. The default URI is file:// plus the path the export wrote to, which is right where the URDF is read out of the tree it was generated in and wrong everywhere else: an ament package installs to another prefix, and a campaign generates the description in one container and plans in another. --mesh-package and --mesh-prefix name the consumer’s path instead — where the meshes will be READ — and both exports take them, as alternatives to each other. move_group does not fail on a mesh it cannot fetch; it logs the failure, keeps the links without their collision geometry and plans through them. So an export whose URIs land under a temporary directory warns about it, and --check, which resolves the meshes in the export’s own mesh directory whatever the URIs say, reports that it measured the geometry rather than the reference.

That matters most for the file that looks least interesting. moveit_controllers.yaml maps MoveIt’s controller names onto the actions this substrate’s bridge serves and onto the joint list arm_controller publishes — so it is read from the Endpoint objects the controller declared, not restated. A name written by hand there can be right on the day and wrong after a world renames a controller, and the failure is that a trajectory is executed against nothing.

Four more answers come off the model rather than from flags, each because getting it wrong is quiet:

  • the joint list and its order — the ArmHandle the controller published, i.e. the names that reach /joint_states. robot_state_publisher matches by name, so a list that is right about the robot and wrong about the order leaves MoveIt planning from a pose the arm is not in.

  • the home posture — data.qpos after setup, not the world’s home: key. The controller applies that key itself and a rest stance may overlay it, while MoveIt’s start-state bounds check runs against the posture the arm is really in.

  • the collapse root — the lowest common ancestor of every body an equality constraint touches. A closed linkage is exactly what URDF cannot express, and MuJoCo says where one is; collapse it and the loop is gone, miss it and the URDF keeps revolute DOFs nothing publishes.

  • ``fix_start_state`` — emitted only for an arm that has a continuous joint. CheckStartStateBounds normalizes such a joint onto [-pi, pi], and with this false (its default) it reports START_STATE_INVALID precisely because it had to normalize. A start state that drifted a hair past pi is therefore refused rather than wrapped, which surfaces as a phase failing instantly right after a phase that succeeded — at a different phase each run. True writes the normalized state back into the request; a joint genuinely outside its limits is still refused, by a separate bounds check this flag does not relax. A range-limited arm has no such problem and gets no such setting.

The IK answer is not one of them. kinematics.yaml configures MoveIt’s KDL plugin, and that is the one generated file whose content is not a reading of the model: the plugin solves from the seed state on its first attempt and from a configuration drawn uniformly inside the joint limits on every attempt after that, until kinematics_solver_timeout is spent, and takes the first attempt that converges. The draw is seeded per process from the clock and the number of attempts that fit in the budget follows the machine, so one pose is answered by a different arm branch from run to run – the elbow the other way, or a joint turned a full revolution where the limits hold that posture twice – while everything else the export writes is the same file every time. Each branch is a correct answer: the tool frame lands where it was asked for, the plan succeeds, and the arm took another route and stands in another posture. No parameter of the solver constrains it to one branch (its own are joint weights, max_solver_iterations, epsilon, orientation_vs_position and position_only_ik), so the export states the fact in the file’s header, names there the joints whose exported limits hold one posture at more than one value, and warns when it writes it. A trial whose repeatability rests on a pose therefore solves that pose’s joint vector once, against this description, commands it in joint space, and reaches further poses by a Cartesian path from the one the arm is in – which follows the branch it is already in instead of choosing one. Where a query at run time cannot be avoided, check the joint vector that comes back against the posture expected before executing it: a plan that succeeds is not evidence that the answer was the intended one.

Pass ``–tip-site``. Without it the arm chain ends at the tool flange, and a goal for the fingertips has to be written as an offset from there — which multiplies every orientation tolerance by that lever arm, so 0.15 rad of permitted tilt becomes ±33 mm at the fingers. One cell measured 61 mm of lateral error against 12.2 mm of jaw clearance: MoveIt had satisfied the goal exactly, and the goal was about the wrong point. --tip-site pinch emits a frame link at the gripper’s own grasp site (through a collapsed parent, where such a site usually sits), so a 3 mm position tolerance means 3 mm at the pads.

Two arms that must move at once. --arm left,right describes both as one robot: one URDF with both chains under a common root, one group per arm, and a group spanning all of them. That last group is the point of it — a plan for it is a single trajectory through both arms’ joint space, so each arm’s motion is checked against where the other is at that instant rather than against where it was before it started. It deliberately gets no IK solver: KDL solves a single serial chain and this group is several, so a solver there would load and then fail every pose request; reach a pose through one arm’s own group, and use the combined group for joint-space planning. One flat namespace has to hold both arms, so their links and joints keep each arm’s MJCF prefix — and since joint names are the controller’s, not the description’s, each arm’s arm_controller needs joint_prefix: set to that same prefix. An arm publishing unprefixed names is refused rather than renamed, because those names are what reaches /joint_states and what a trajectory point carries. Every check above runs per arm: a second arm whose chain is short by a joint, or whose home disagrees with the simulator, fails as loudly as the first.

More than one planning pipeline. --pipelines ompl,chomp writes a planning_pipelines.yaml naming them for move_group and saying which one a request that names none gets; with the default single pipeline neither that file nor the selector is written, because there is nothing to select. Any name MoveIt can load is allowed — the list is open, so a pipeline this exporter has never heard of costs nothing — but each one’s planner package has to be installed where move_group runs, or the pipeline fails to load at start-up rather than failing the request that uses it.

Only ompl_planning.yaml is written. Its projection_evaluator names joints this model has, which is what makes it derivable; an optimizer’s cost weights are not — they are the operating point of a minimisation, which is the experiment’s decision, and a table of them emitted here would be the exporter making it. Every other pipeline therefore takes MoveIt’s own packaged config (the config builder falls back to moveit_configs_utils/default_configs/<name>_planning.yaml) until the experiment puts a file of that name on the config path it reads.

Use this for a comparison across pipelines, where the planners are different plugins with unrelated parameter files and a trial picks one per request through MotionPlanRequest.pipeline_id; comparing planners inside OMPL is another entry in ompl_planning.yaml and needs none of it. One thing to settle before designing such a comparison: CHOMP accepts joint-space goals only. It rejects a goal with no joint constraints, or with any position or orientation constraint, as INVALID_GOAL_CONSTRAINTS, so a trial that sets a pose target fails every CHOMP request outright — which reads like a planner performing badly rather than one that was never given a goal it could take. Solve the IK and send joint goals, or leave that pipeline out of a pose-goal comparison. The export warns about it, and planning_pipelines.yaml says so in a comment.

What it does not write is a planning.yaml. The planning frame, the group name and the gripper’s units belong to whatever node drives the trial, and that is the experiment’s file, not the substrate’s.

Manipulation: the world the arm stands in

Those six files describe the robot and nothing else, so the world the simulator loads and the world the planner reasons about are disjoint: the bench the arm is bolted to, the cabinet it opens and the wall beside it are invisible to move_group, which plans straight through them, and the simulator resolves the contact afterwards. There is no error and no warning — the symptom is a plan that looks fine and an arm that drives into furniture.

--scene writes the other half from the same compiled world, as a seventh file:

roqsim export moveit --world cell.yaml --out cfg/ --tip-site pinch --scene
# cfg/planning_scene.yaml

It is a moveit_msgs/PlanningScene, in YAML a bring-up node fills the message from directly, and it is a diff: applying it (/apply_planning_scene, or a PlanningScene publisher) adds the world’s objects and states nothing about the robot. A non-diff scene replaces everything in it, the robot state included, so applying one would hand the planner a robot at all-zero joints.

One object per static body, at that body’s pose, carrying the primitives it is built from. An industrial_table is one object called industrial_table holding its top and four legs, so a trial allows, pads or removes the bench rather than five unrelated shapes. Geoms hanging directly off the world body — the room’s walls — are each their own object, because one object holding every wall could not be padded a wall at a time. Poses are written in the frame move_group plans in: the URDF’s root link, which is the arm’s own base and not the world origin.

What it leaves out is reported by name, in the log and in the file itself, because a missing obstacle is exactly the silent failure the flag exists to end:

  • Anything with a degree of freedom — a motion: physics prop, a motion: driven obstacle, a pedestrian, another robot’s links. Its pose at export time is not where it will be. Note the default: spawn_model gives a prop a free joint unless told motion: static, so scenery meant for the planner has to say so.

  • Visual-only geometry (contype/conaffinity both zero). The simulator does not collide it, so a planner that did would refuse motions the robot can make.

  • Mesh geoms. MoveIt takes a mesh as explicit triangles and MuJoCo collides one as its convex hull, so neither is a shape the two engines agree on — and they disagree most exactly where a hull fills the span a trestle or a shelf exists to leave open. A prop for a planning scene carries primitive collision geoms behind its visual mesh, which is what roqsim assets collision measures.

  • Plane geoms. A MuJoCo plane is infinite and the robot stands on it, so a half-space in the scene puts the start state in collision and every request is refused before it is planned.

  • Ellipsoid, height-field and SDF geoms — shape_msgs/SolidPrimitive has a box, a sphere, a cylinder and a cone, and none of those is any of these. A capsule is not in this list: it is exactly a cylinder and two spheres, and one object holds all three.

A robot that is not welded down is refused rather than written: its base rides a free joint, so MoveIt plans in a frame TF provides, a prop’s pose is fixed in the world, and the offset between the two is a run-time quantity. Publish that scene from the stack, against the frame TF gives it.

The export also says which objects touch the robot at the posture the simulator starts in. CheckStartStateCollision refuses a request whose start state is in collision, so an arm bolted to a bench that is also a collision object plans nothing at all — which reads like a planner that will not work rather than like a scene saying the arm is inside its own furniture. Allow the pair, pad the object back by more than the approach clearance, or leave it out; the export names the pair and leaves the choice where it belongs. MuJoCo reports no contact for it, since both are welded to the world, so only a distance query finds it.

This is the named-object route, not the only one. A depth sensor feeding MoveIt’s octomap updater already carries what is in view to the planner as occupied voxels — realsense_d435 with points: true is that path, and it sees whatever shape a thing has and follows it as it moves. What voxels cannot be is named: attached to the gripper, allowed against a link, padded, or removed when the trial picks the part up. The two compose. MoveIt’s sensor filter removes the robot’s own links and what is attached to them from the incoming cloud, not the world’s collision objects, so a prop that is both declared and in view is carried twice — conservative, and not wrong.

Manipulation: what a contact task needs

Driving one from outside. A Cartesian controller takes its setpoints as topics, named the way the controllers that do this job on a real arm name theirs – <controller>/target_wrench and <controller>/target_frame, with <controller>/current_pose coming back. Its identity is what decides its law, as it is under ros2_control, where what a controller does is settled by which one is loaded rather than by a mode key:

controller_type

what runs

cartesian_motion_controller

pose tracking only, blind to contact

cartesian_force_controller

the wrench loop, no stiffness and so no equilibrium

cartesian_compliance_controller

both, with per-axis stiffness

An axis given zero stiffness stays under pure force control while the others track the commanded frame. That superposition is the point: it is how a task-space motion is layered on a running force loop on real hardware, which means the thing driving that motion is an ordinary publisher rather than a second controller. Two controllers cannot claim the same joints, so an experiment that ships its search or its scan as a “controller” is writing something that cannot run on the arm – ship a node that publishes target_frame instead.

law: admittance | position is the older spelling and still works, deriving a controller_type.

Zeroing is not optional. The sensor reads everything below the cut, so an arm starts from the weight of its own wrist – and a force controller has no stiffness and therefore no equilibrium anywhere, so an untared tool sinks at that force over the damping for as long as the trial runs. Tare through the service (see “Taring” above) before commanding anything.

A limit that stops the trial is force_limit: a measured wrench magnitude above a threshold latches, reports, releases whatever was driving the arm and asks the driver to stop. Named for the capability rather than for one vendor’s word for it, with that word supplied by reports_as. It is not a controller and does not pretend to be one – on a real arm a stop of this kind comes from the controller box and is surfaced through the vendor’s status interface, so reporting it as a controller switch would put a fiction in a results table’s failure-mode column.

contact_monitor (above) treats contact as the failure, and model_override (above) can take a contact away on command. A contact-rich manipulation task inverts both: contact is the task, and the measurement is the wrench, not the trajectory. Four plugins make that chain, and they are listed in a world in this order because each needs the previous one’s blackboard handle:

- spawn_arm:            {model: ur5e, name: ur5e, prefix: "ur5e_"}
- force_torque:         {name: ft, arm: ur5e, site: fts_site, frame: world}
- peg_in_hole.py:PegInHolePlugin: {arm: ur5e, clearance: 0.001, hole_pos: [-0.49, -0.13, 0.0]}
- cartesian_admittance: {arm: ur5e, ft: ft, law: admittance, site: tool_site}
- insertion_task.py:InsertionTaskPlugin: {arm: ur5e, ft: ft, law: admittance, target_pos: [...]}

Read the refs, not the order. Three are named — spawn_arm and cartesian_admittance from roqsim_manipulation, force_torque from roqsim_sensors — and resolve through entry points because they are substrate: an arm, a sensor, a control law. Two are paths, and ship with the experiment: the bored block whose clearance is the experimental variable, and the trial protocol built around it.

That division is the general one. The substrate owes a cell the mechanism — mount an arm, measure a wrench, close a Cartesian loop. What is being inserted into what, and what counts as having inserted it, is the experiment’s to state.

Three things decide whether such a world measures anything at all:

  • Where the sensor cuts. A site force sensor reports the wrench transmitted through that site from the body’s children, so the tool must hang below it. A peg attached above the measurement site produces a wrench that is identically zero — which looks like a well-behaved controller, not like a broken world. The ur5e model ships fts_site (the cut) and tool_site (the attach point, further out) so the two cannot be confused.

  • Gravity and tool mass. With gravity on and a realistically-massed tool, any metric that integrates force is dominated by the tool’s own weight. Zeroing is a command, as it is on real hardware: force_torque exposes a tare service (std_srvs/Trigger, the analogue of a driver’s zero_ftsensor), WrenchReader.tare() for an in-process controller, and tare_at_s for a world that wants it done once at a stated time. Prefer a command – a time has to stay in step with the scenario’s own timing, and fires mid-approach if that slips. All three forget the offset on reset, so a repetition really is one.

    A tare is not gravity compensation. It cancels the load at the pose it was captured at, exactly as the zero button on a real sensor does: the tool’s weight is fixed in the world frame while the sensor frame turns with the tool, so rotating after taring brings the weight back. Tare per approach on a tool that turns. Where an experiment needs a wrench that is clean at every pose, sim.gravity: [0, 0, 0] or a near-massless tool remain the honest answers.

  • The controller’s plant, not its gains. cartesian_admittance closes a loop around arm_controller’s position servo, which is stiff. Admittance gains taken from a system with a soft joint controller will oscillate and diverge on contact. Tune against a stability criterion fixed in advance, and record the result as a calibration — the ur5e model is the worked example, including the sweeps. Where the plant itself is what a reconstruction has to match, the spawn’s actuators: block states it: control: impedance with the joint stiffness and damping the reference used, in place of the model’s own servo. That is a property of the experiment rather than of the arm, so it belongs in the world and not in the shared MJCF — see Architecture & porting playbook, “Actuator overrides”, and note that a cell running at zero gravity gets identical physics from impedance and position.

A trial plugin of this shape — approach → act → succeed/timeout/abort → write — calls ctx.request_stop() when it resolves, so a run ends when the trial does instead of being padded to a guessed --seconds. Two rules are worth copying from a trial-protocol plugin: give it an explicit failure condition as well as a success one (a trial that can only succeed cannot produce a success rate, it can only hang), and write the raw observable rather than the metric, because a force-energy definition belongs to the analysis where it can still be argued with.

Manipulation: what a grasping world needs

Four things have to line up before an object can be picked up, and three of them are opt-in because they cost something a navigation world should not pay:

  1. A movable object. motion: physics — the default — adds a <freejoint/>, registers the joint as the entity’s base_joint (which is what simulation_interfaces’ SetEntityState requires to re-seat it), and re-seats it on reset so repetitions of a trial really are repetitions. Pair it with publish_tf: dynamic — nothing else publishes a movable body’s pose. graspable_box is the reference prop, sized and contact-tuned for a parallel gripper.

  2. Solver effort. sim: {noslip_iterations: 10}. Without it a firmly held object creeps out of the jaws; see “Solver options” in architecture.rst for the measurements.

  3. Scoped actuator ownership, if the arm shares its entity with anything else. arm_controller claims every joint actuator matching the entity prefix by default, which is right for a standalone arm and wrong for a humanoid or a mobile manipulator — it then also claims the legs or the wheels and fights their owner, writing position targets into what may be torque actuators. Give it joints: (and gripper_actuator:, which cannot be inferred once one entity carries two grippers), and each controller also reports only its own joints, so several can share one /joint_states topic.

  4. ``mass`` / ``friction`` on the spawn, if either is a factor you want to vary — they are ordinary world-YAML keys, so an ordinary parameter sweep varies them and needs no new variation plugin.

unitree_g1_dex1’s manifest is a worked example of (3): two arm_controller instances on one entity, each owning its seven arm joints and its own Dex1 gripper, alongside g1_locomotion on the twelve leg motors.

Two more if the target moves (dynamic grasping):

  1. A conveyance, not a mover. prop_trajectory (in roqsim_assets) carries the object along a prescribed 2-D path by friction, on force-driven slide joints. Reach for it rather than moving_box or walker: both are mocap, and a mocap body has no velocity in MuJoCo’s dynamics, so friction against it transfers no tangential force — it will slide out from under the object it is supposed to be carrying while staying in contact. Mocap blocks; a driven joint carries.

  2. A velocity command path, if the controller is reactive. Resolved-rate and QP whole-body controllers emit joint velocities, and this plugin’s actuators are position servos, so arm_controller’s velocity_commands: true integrates target += qd·dt into the held target (clamped to the joint range, with a velocity_timeout_s watchdog so a dropped stream cannot leave the arm drifting). Position servos are kept deliberately — a MuJoCo <velocity> actuator sags under gravity at zero command. Be aware of what that costs a metric: the achieved profile carries the servo’s own dynamics, so where end-effector acceleration is the measured quantity, check the tracking error and report the gains as part of the setup.

An arm carried by spawn_robot also needs arm_controller’s rest stance: a robot spawn sets the base pose and no joint stance, so the arm falls back to qpos0. For the Panda that is not neutral but an actively bad pose — its link5 and hand collision geoms overlap by 0.030 m at all-zeros. rest seeds the spawn qpos and the held target by joint name, and re-seats on reset so repeated trials start identically. frankie’s manifest is the worked example of (5), (6) and rest.

Scoring the trial, not self-reporting it

A trial’s verdict belongs to the experiment, not to the substrate — deciding what counts as success is the thing the experiment is for. tiago_pick’s pick_place_metrics is the reference implementation, and the pattern generalises even though the plugin is not shipped here:

- pick_place_metrics:
    target: parcel
    grasp_links: [gripper_fingertip_left_link, gripper_fingertip_right_link]
    container: dropbox          # omit to score a pick alone
    lift_check: 0.05            # m the object must rise ...
    hold_s: 5.0                 # ... and stay held, both pads on it
    metrics_out: metrics.csv

It reports picked (lifted and stayed held through a dwell, with a contact_debounce_s tolerance so a momentary loss of one pad is not a slip), placed (came to rest inside the container and supported by it), and success. Nothing in it knows what arm, gripper or base it is watching — the gripping links are named in config — so the same rule scores different platforms.

What matters structurally is not which package holds it but which side it sits on: the verdict must be on the SIM side, out of reach of whatever is driving. Two reasons:

  • Comparability. When an in-process phase machine and an external MoveIt node solve the same trial, each scoring itself makes the comparison partly a comparison of two verdicts.

  • Observability. Contact state is not on the ROS graph, and neither is “resting inside the box”: a client can see the object’s TF, but a parcel balanced on the rim looks identical to one on the floor of the container.

A driver that wants to react to a slip reads held() and decides for itself — the observer reports and never terminates. Keep the rule platform-agnostic anyway (name the gripping links in config rather than hard-coding them): that is what would let it move up into the substrate the day a second experiment scores a pick.

Manipulation: where a workpiece lives

The substrate ships robots, not the things they work on. A bored block whose clearance is swept at 0.1 mm, or an intersecting-pipe weldment whose dimensions were chosen because the paper never stated its own, is one experiment’s geometry: reused by nothing, and installed by everyone if it sits in a family package. So a workpiece ships with the experiment that defines it, and there is no special mechanism for that — it uses the two doors any downstream package uses:

  • an roqsim.models entry point naming a module with MODELS_DIR, after which spawn_model: {model: my_workpiece} resolves by bare name;

  • a path-loaded plugin (my_workpiece.py:MyWorkpiecePlugin, relative to the world file), for a workpiece whose geometry does not exist until the world configures it — a fit expressed as a number, or goal poses derived from the same definition as the shape they lie on. No entry point, no install, no wheel: the world and the plugin travel together.

What the substrate owes such a cell is the arm, the sensing, the control law and the trial machinery — all of which are addressed by name and none of which know what is being welded or inserted.

Writing your own

Subclass roqsim.plugin.Plugin, implement the hooks you need, add validate_config, and either register a roqsim.plugins entry-point or reference the class directly from the world YAML. Referenced directly, the ref (my_pkg.mod:MyPlugin or ./plugins/x.py:Foo) contains a colon and is the plugins-list entry’s key, so quote it — - "my_pkg.mod:MyPlugin": {...} — to keep the colon from splitting the key.

If your config names a file, implement ``sources``. roqsim.config.world_sources — what roqsim scenes inputs and the exporters’ --manifest report, and what a run harness stages a world by — walks the YAML’s extends chain and the MJCF’s assets. It cannot see into a plugin’s config, so a mesh or a CSV named there is invisible to every caller asking “what does this world need?” unless the plugin says so. Return absolute paths; entries that do not exist are dropped, and the hook must never raise (callers treat it as best-effort). It is the same rule as transport_only: a capability is declared by the plugin, never listed in the core.

And resolve it against ``self.base_dir``, not the CWD. That attribute is the directory of the world document the entry was declared in, so a path written beside the world resolves the same wherever the world is opened from. That matters more than it sounds: the working directory is not the document’s directory in general, and need not be the same twice. A world may be opened by absolute path from somewhere unrelated, or copied under a different root by a tool that stages its inputs — and a CWD-relative path then names a different file, or none. resolve_model and load_manifest both take a base_dir for this; pass self.base_dir. A bundled model name is a provider lookup and is unaffected.

A spawn plugin can bundle a model’s default plugins by implementing expand via roqsim.manifest.expand_manifest (see the manifest mechanism in the Developer guide / architecture reference). See the porting playbook in the Developer guide.