Interfaces

The public surfaces you use to configure and extend roqsim.

The world YAML

A single file defines the world and the plugin pipeline. Plugin order is execution order.

sim:
  timestep: 0.004        # optional; else taken from the model
  pacing: realtime       # realtime | asap | {factor: 4.0}   (standalone only)
  integrator: implicitfast   # euler | rk4 | implicit | implicitfast (default)
  dedup_assets: true     # default; merge identical attached prop assets before compile

components:
  - floorplan:                       # (1) entry-point short name -- the ref *is* the key
      size: 3.0
    name: ground                     # optional instance name (reserved sibling key)
  - "my_pkg.mod:MyPlugin": { ... }   # (2) importable module:Class (PYTHONPATH)
  - "./plugins/x.py:Foo": { ... }    # (3) path to a .py file (relative to this YAML)

Each entry is a mapping with exactly one plugin-ref key (whose value is the plugin’s config map) plus an optional reserved name: sibling, which defaults to the ref. Writing name: inside the config map is refused with the corrected spelling: no plugin reads it there, and a document that placed it there would load with every entry answering to the plugin’s ref. The plugin ref has three resolution forms:

  1. short name — a registered roqsim.plugins entry-point.

  2. module.path:Class — imported off PYTHONPATH.

  3. path/to/file.py:Class — loaded directly from a file (relative to the YAML).

Because forms 2 and 3 contain a colon and the ref is the entry’s key, quote it (- "my_pkg.mod:MyPlugin": {...}): unquoted it parses only while no space follows the colon, so a stray key: value space would silently truncate the ref. Short names have no colon and need no quotes.

Each plugin validates its own config: section; the engine aggregates all errors and fails fast before the scene is built.

Stating a joint’s gains (actuators:)

A spawn plugin’s actuators: block says what law the model’s joints run under, and with which gains, without editing the shared model file. Start from the names it keys on:

roqsim catalog model ur5e     # lists the actuators, and the joint each drives

Then state the law once, and only what differs per actuator:

- spawn_arm:
    model: ur5e
    actuators:
      control: impedance     # position | velocity | effort | impedance
      stiffness: 2.0         # N*m/rad
      damping: 0.02          # N*m*s/rad
      each:
        shoulder_lift: {stiffness: 5.0, effort_limit: 150}   # N*m
        wrist_3: {control: position, p: 2000, d: 500}

Anything not stated keeps the model’s own value, so a world says what the experiment fixes and nothing else. The names in each: are actuator names (wrist_3), not joint names (wrist_3_joint) — a joint name is refused, naming the actuator that drives it.

The gains a real robot exposes are the ones written here: control is a ros2_control command interface, p/d are a controller’s PID gains, effort_limit is URDF’s <limit effort=>. impedance is joint-impedance control — a position law that also carries the arm’s own weight, so a soft stiffness holds a pose instead of folding; at zero gravity it is identical to position. Which law and gains every joint ended up with is written into the run’s recording, so a result can state what its joints ran under. See Architecture & porting playbook for the full mechanism.

Overriding the world

A caller can override any part of the world before it is built, by passing a nested dict that mirrors the YAML. Plugins are addressed by name (their name:, else their plugin ref), so callers never depend on list indices:

from roqsim import load_config

cfg = load_config("world.yaml", {"sim": {"headless": False},
                                 "plugins": {"floorplan": {"size": 4.0}}})

Values deep-merge (scalars and lists replace). Overrides must be applied at load time – the scene is compiled when the engine is built, so mutating a built SimConfig has no effect.

The standalone runner exposes the same thing on the command line, where roqsim.overrides_from_dotlist parses the path=value form:

roqsim sim world.yaml --set components.floorplan.size=4.0

…and in a file, which is the same nested mapping kept somewhere a command line cannot keep it:

roqsim sim world.yaml --override debug.yaml

Reach for the file whenever the value is structured – a list of obstacle instances, a nested plugin config – because flattening one onto argv loses it to quoting and word splitting. It is also how a saved override set is reused, and how a run’s own settings are replayed afterwards: an embedding driver that varies the world per run writes exactly this document beside the results. Both flags are repeatable and compose, later winning, so a saved set plus one ad-hoc tweak is --override debug.yaml --set sim.pacing=asap.

Driven from scenario-execution, MujocoSim takes the nested dict as a world_overrides parameter – naturally an OSC struct, which the framework passes as a nested dict – and (re)builds the world with it applied. That shape has no command line, so a deployment supplies the same document through ROQSIM_WORLD_OVERRIDES (a path, alongside ROQSIM_WORLD); a scenario passing world_overrides itself wins, being the experiment speaking rather than the deployment. See Quickstart.

Selecting the world itself

A world is normally chosen once, by whoever starts the run: the positional argument to roqsim sim, or ROQSIM_WORLD for the scenario-execution adapter (which is constructed with no arguments). A scenario never has to know that this simulator has a thing called a world – and neither does it need to when a run harness sweeps the world across configurations, since that too is the deployment choosing, one process at a time.

The exception is a study whose experiment is the world – one problem instance per configuration. Such a scenario declares a world parameter and MujocoSim.reset() takes it, rebuilding when it changes, which makes that study one scenario instead of N. The world is part of the rebuild key, not just the overrides: reusing the previous model for a new world would silently run the wrong one.

Adding ROS transport

A checked-in world is ROS-free, and deliberately so: a world that declares ros2_bridge cannot be loaded where the bridge is not registered – it is a colcon package, absent from a pip-only install – so keeping it out of the file is what makes the world standalone-runnable. Transport is a property of how a run is deployed, not of the experiment.

So it is appended at load time instead, by roqsim.config.with_transport() – the exact inverse of drop_transport_plugins, and idempotent, so a caller never has to know whether the author put one there:

roqsim sim world.yaml --ros                       # append ros2_bridge
roqsim sim world.yaml --ros --tf-namespace robot  # publish /robot/tf, frames unchanged
roqsim sim world.yaml --sim-control               # also serve simulation_interfaces

--tf-namespace and --sim-control imply --ros. Going the other way – running a world whose author did put the bridge in the file, on a machine without the middleware – is --no-communication, which drops it and warns that the run now communicates with nothing (see Transport plugins and the scene-only consumers). The two are refused together. The adapter reads the same three from the environment (ROQSIM_ROS, ROQSIM_TF_NAMESPACE, ROQSIM_SIM_CONTROL), because there is no command line to put them on – and because they must not become scenario parameters, for the same reason the world is not one.

This replaces a rewrite that copied the world to a temporary file and appended the plugin there – implemented twice in one experiment, in bash and in Python. Nothing is copied now, so the scene’s relative path needs no fixing up either.

Once up, the bridge holds the graph to its endpoints’ types. A ROS 2 topic is one name and one type, and the middleware never connects a publisher of another type to it – nor logs that it did not – so a stack sending a plain Twist to a base subscribing TwistStamped shows up only as a robot that never moves. The 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.

Choosing a GL backend

MUJOCO_GL selects the offscreen renderer, and which value works is a property of the machine: egl needs a render device, and a CPU-only node needs osmesa. Left unset, MuJoCo’s own default fails on such a node with a message about a broken GL install – the wrong diagnosis.

The variable is read once, while ``import mujoco`` runs, and binds ``GLContext`` there and then. Setting it afterwards moves the variable and nothing else. Unset is not an error either: an empty value falls through to glfw, which on a headless node opens no display and aborts inside the first mujoco.Renderer with mujoco.FatalError: gladLoadGL error.

So import roqsim picks one when MUJOCO_GL is unset (see roqsim.gl.select_offscreen_gl()): a render node means egl, its absence means osmesa. An explicit MUJOCO_GL is always honoured, and ROQSIM_NO_GL_SELECT=1 opts out. This has to happen where the simulator runs – a run configured on one machine and dispatched to another cannot know.

The package __init__ is the call site, not a driver’s main, and that is load-bearing rather than tidy. A driver’s own module-level imports reach mujoco long before its main body executes, so a selection made there (in roqsim.runner.main, say) is made too late to bind anything. It is silently ineffective for every headless run, and stays invisible because a world with no camera never constructs a Renderer: the first camera world on a headless node is what instantiates the mis-bound backend.

Because the residual case – a consumer that imports mujoco before roqsim – cannot be reached from here, roqsim.rendering.check_gl_backend() guards every renderer in the tree and names both the cause and the fix instead of letting gladLoadGL error stand.

DISPLAY is deliberately not consulted, unlike the shell script this replaces. That script set MUJOCO_GL for the whole process; this picks only the offscreen renderer, and a window comes from mujoco.viewer’s own glfw context regardless. A base image may well set DISPLAY=:0 unconditionally – ours does – so trusting it would make a headless run choose glfw and fail against an X server that was never started.

Listing what a world is made of

A world is never one file: it is the YAML, whatever it extends, the MJCF that chain settles on, and the meshes and textures that MJCF names – all referenced by paths relative to each other:

roqsim scenes inputs worlds/depot.yaml
{"world": "/abs/worlds/depot.yaml", "packaged": false, "inputs": ["/abs/...", ...]}

One line of JSON, the same machine contract roqsim render uses. It exists as a command because the caller is often not a roqsim process: something staging a world into a container has no other reason to have roqsim installed, so it asks the image that does. packaged: true means the files arrive with an installed package and nothing has to travel.

The walk is best-effort: a world that does not fully resolve yields what was resolved, so a caller about to report its own error is not pre-empted by this one. --require-complete is for the caller who cannot use that answer – one copying these files somewhere the originals are unreachable – and it fails instead, naming on stderr every part it gave up on: an extends that does not resolve, a YAML in the chain it cannot read, a world that does not load, a plugin it cannot ask. A file that is simply absent is not one of those: dropping it is the contract Plugin.sources is written against, where an optional file needs no guard. Without the flag a short list and a whole one are the same value, and what is missing surfaces wherever the files were going – after the copy.

Listing what a world provides

The other half of the same question, for a caller holding an override rather than staging files:

roqsim scenes describe worlds/turtlebot_nav2.yaml
{"world": "...", "packaged": false, "inputs": [...],
 "plugins": [{"address": "robot", "ref": "spawn_robot", "name": "robot",
              "entity": null, "enabled": true, "origin": "document",
              "paths": ["components.robot.model", "components.robot.pos"]},
             {"address": "robot.rplidar.lidar", "ref": "lidar", "name": null,
              "entity": "robot.rplidar", "enabled": true, "origin": "manifest",
              "paths": ["components.robot.rplidar.lidar.rays",
                        "components.robot.rplidar.lidar.max_range"]}],
 "addresses": ["robot", "robot.diff_drive", "robot.rplidar", "robot.rplidar.lidar",
               "robot.oakd_camera"],
 "entities": null}

plugins reports every component that will run – the document’s own entries and everything its models’ manifests contribute – under the address an override names it by, with the dotted paths into its config that already exist. origin says which of the two a component came from.

addresses is that set on its own, and it is exactly what resolution accepts: a caller checks a sweep key against it before spending an image pull. Note the world above declares one entry and gets three more from the turtlebot4’s manifest – those three are the ones a sweep is most likely to want, and they used not to appear here at all.

A path not listed is not necessarily wrong (a plugin may accept a key its world leaves at the default), so a caller reports an unlisted path as unverifiable. What the list does settle is the expensive mistake: an address matching nothing, refused at load – inside the container, after the image pull.

entities is null unless --entities is passed, because naming them means compiling the model. There is no cheaper way to ask: which entities exist is settled at compile time, since roqsim never recompiles mid-run – SpawnEntity makes a declared entity present, it adds none. A caller checking that a scenario only drives entities the world has pays for it; one resolving paths does not.

overridable answers the same question one layer down, for the model values a run can change while it is in progress (the model_override plugin, architecture §9.2):

roqsim scenes describe tiago_pick:tiago_pick --overridable 'gripper_right*'
{..., "overridable": {
   "fields": [{"field": "geom_friction", "namespace": "geom", "write": "live",
               "does": "...", "caveats": "...", "measured": "..."}, ...],
   "targets": {"geom": [{"name": "gripper_right_left_pad1",
                         "body": "gripper_right_fingertip_left_link",
                         "geom_priority": 1, "geom_friction": [0.7, 0.02, 0.001],
                         "geom_contype": 2, "geom_conaffinity": 1}, ...],
               "actuator": [{"name": "gripper_right_finger_pos",
                             "actuator_forcerange": [-10.0, 10.0]}]}}}

fields is always present and costs nothing: mjModel’s field set is a property of MuJoCo rather than of this world, so the allowlist needs no model built. Each row carries what the field does and how it can silently do nothing, because that is what a caller choosing an override needs and cannot infer from a field name.

targets is the world-specific half – the names an override can select and their current values – and is null unless --overridable GLOB is passed, for the same reason entities is. The glob is not a convenience: a mobile-manipulator world has hundreds of geoms, and unnamed ones are omitted because nothing can address them. A geom also reports its geom_priority, which is what decides whether overriding this side of a contact does anything at all.

Both halves come from one build when both flags are given: compiling the world is the expensive part.

--override FILE applies an override tree first, the same file roqsim sim --override takes. It is what makes the build-fed halves answer about the world a run would load rather than the one the file declares: which entities a world compiles depends on its plugins’ config, so a caller whose obstacles come from its own overrides sees none of them without it:

roqsim scenes describe world/secorolab_nav2.yaml --entities --override run.overrides.yaml
{..., "entities": ["obstacle_0", "robot"], "errors": null}

An address the world does not have is still refused, exactly as it is refused when a run loads – which is the expensive mistake this command exists to catch first.

That build has no transport in it, and dropped_transport names what went:

roqsim scenes describe worlds/depot_ros.yaml --entities
describing the scene without transport: dropped ros2_bridge, sim_interfaces   # on stderr
{..., "entities": ["obstacle_0", ...], "dropped_transport": ["ros2_bridge", "sim_interfaces"]}

A describe publishes nothing, so a world’s bridge is dead weight here exactly as it is for roqsim render and the exporters – and since the ROS bridge ships in a colcon package, a pip-only environment cannot resolve it at all, so requiring it would fail a describe over plugins that contribute no geometry. Only identified transport goes (roqsim.config.drop_transport(), never the lenient drop_transport_plugins): a misspelt geometry plugin has to stay fatal, because dropping it would leave an entity missing from a list a caller reads as complete. The bridge is still in plugins, so plugins.ros2_bridge.* remains a checkable override.

When only the build fails, the reply is still printed – with errors.build set and the build-fed keys left null:

{"plugins": [...], "entities": null, "dropped_transport": [],
 "errors": {"build": "mesh not found: ..."}}

A caller keeps the half that cost nothing (which plugin keys exist) instead of losing the lot. The exit code is still non-zero: 0 goes on meaning “fully answered”, so a caller reading only the status is never told a partial reply was a complete one. A world that cannot load has no half to hand back and prints nothing.

Extending another world

Overrides modify an existing world; extends inherits one. A world YAML may name a parent to inherit its sim block and plugins list, then add, remove, or modify elements:

extends: roqsim_scenes:depot # a parent world YAML: "<package>:<world>" ref or a path
sim:
  timestep: 0.001              # deep-merged over the parent's sim (child wins per key)
disable:                       # OPTIONAL: drop inherited plugins by name (needs ``extends``)
  - graspable_box
components:                    # child entries are APPENDED after the (kept) parent entries
  - spawn_robot: {model: oli, name: oli, prefix: oli_, pose: {position: {x: 13.2, y: 2.6}}}

The extends value resolves like sim.world – a <package>:<world> ref against a registered roqsim.worlds provider (to that provider’s <world>.yaml), or a path relative to the child YAML’s dir. The parent’s relative sim.world keeps resolving from the child’s location: a path parent’s becomes an absolute path, and a package parent’s becomes a <package>:<path> ref (roqsim_scenes:depot/depot.xml) – by reference, never by where this interpreter has the package installed, so a run’s provenance rebuilds on any machine that has the package. Parent worlds may themselves extends (cycles are rejected).

disable selectors match a plugin’s reserved name: or its config name field (e.g. spawn_model: {name: graspable_box, ...}); a selector that matches nothing is an error, not a silent no-op. There is no separate “modify” key – to change an inherited plugin, disable it and re-add a tweaked copy in the child’s plugins.

Drawing on a render (roqsim.render_overlays)

roqsim render draws a recording one sample per frame, and an overlay paints on each frame after the scene is rasterised and before it is encoded – in pixels, with the sample’s simulated time in hand. clock ships with roqsim; any installed package adds its own under the roqsim.render_overlays entry-point group, and it is then available by name on the command line and in a shot document without roqsim knowing it:

[project.entry-points."roqsim.render_overlays"]
costmap = "some_package.video:CostmapOverlay"

The contract is small and duck-typed. An overlay is constructed as cls(placement, **options) – or through a from_spec(options, placement) classmethod where it wants to validate them – and provides:

name: str
prepare(width, height, *, state) -> None     # optional; once, before the first frame
draw(frame, t) -> frame                      # every frame

frame is the rendered picture as an HxWx3 uint8 array and may be painted in place; what comes back must be the same shape and type, and a frame that is not is refused by the overlay’s name rather than handed to the encoder, which would silently produce a sheared video. state is the recording being drawn (a path, or None), so an overlay that reads files beside it can find them without being told where. placement (roqsim.render_overlays.Placement) carries the three options every overlay shares – anchor, width as a fraction of the frame, margin – and roqsim.render_overlays.paste() composites a PIL image at it.

roqsim render --overlay list prints what this environment registers, with where each comes from.

The plugin lifecycle

A plugin subclasses roqsim.plugin.Plugin and implements any subset of these hooks (the engine only calls the ones you override):

Hook

When

Use

build(spec, ctx)

once, pre-compile

mutate the MjSpec (add bodies/geoms/sensors/assets)

configure(ctx)

once, post-compile

resolve ids/handles, open resources, advertise services, register entities

on_reset(ctx)

each reset

restore initial state

pre_step(ctx)

each tick, before mj_step

write controls/actuators

post_step(ctx)

each tick, after mj_step

read state, publish, record

shutdown(ctx)

teardown (reverse order)

release resources

validate_config(config) -> list[str] returns error strings (empty = valid).

SimContext

Every hook receives a SimContext with:

  • model / data / dt / sim_time — the MuJoCo handles (spec during build).

  • config — the parsed world dict.

  • blackboard — a typed key/value store for cross-plugin cooperation (set / get / require). E.g. a controller registers a RobotHandle under robot:<name> for in-process consumers (teleop, standalone drivers).

  • interface — the interface registry (add / all / by_direction): where a plugin declares its Endpoints (see below). A transport/bridge plugin reads it to wire the robot up.

  • entities — the entity registry (add / get / names); backs simulation_interfaces.

  • post(cmd) — the thread-safe command queue. External threads (ROS callbacks) must use this; the engine drains it on the physics thread at the start of pre_step. Never touch data from another thread.

  • control — run-control (play / pause / step / reset) consulted by the standalone driver.

Switching an arm between controllers

A robot that registers controllers is served a controller_manager at /<namespace>/controller_manager, with the message set and the semantics ros2_control uses – so ros2 control list_controllers and ros2 run controller_manager spawner work against a world, and a scenario that switches here is the scenario that switches on the robot. Nothing in a world asks for it: on real hardware nobody opts into a controller manager, and a robot with no controllers is served none.

service

what it does

list_controllers

every controller of that robot, with its state and what it holds

switch_controller

activate and deactivate, atomically

load_controller / configure_controller

move a declared controller to inactive

unload_controller

refused while the controller is active, as upstream refuses it

list_hardware_interfaces

the command and state interfaces, and which are claimed

Each controller also publishes <controller>/transition_event stamped with the simulation time, which is where a run reads the instant of a hand-over rather than inferring it from when the motion changed.

The world file is the parameter file. ros2_control’s manager is given its controllers as parameters and load_controller instantiates one of those; here the robot’s components: is that list. A controller the world never declared cannot be loaded – which is what spawner does against the real robot for a name absent from its parameters, so it is the behaviour rather than a limitation.

Three semantics are worth stating because getting them wrong makes a scenario that works here fail on the arm:

  • Only command interfaces are claimed, and only while a controller is active. A broadcaster claims nothing, which is why it reads a joint another controller drives. claimed_interfaces is empty while inactive; required_command_interfaces is what a controller would take.

  • Neither ``STRICT`` nor ``BEST_EFFORT`` deactivates a controller the caller did not name. A hand-over names both sides in one request. FORCE_AUTO is the one that arbitrates.

  • ``strictness`` has no zero. A default-constructed request carries one and it is read as best effort, so pass the field explicitly from a scenario:

    service_call('/ur5e/controller_manager/switch_controller',
                 'controller_manager_msgs.srv.SwitchController',
                 '{\"activate_controllers\": [\"cartesian_compliance_controller\"],
                   \"deactivate_controllers\": [\"scaled_joint_trajectory_controller\"],
                   \"strictness\": 2}')
    

Controller parameters are not settable at runtime. A real controller is retuned with ros2 param set; here the gains are world-authored, because a campaign varies them by varying the world and a run whose gains changed mid-flight is not reproducible.

Declaring a robot interface (endpoints)

A robot describes its own I/O so a bridge can wire it to any transport (ROS 2, and later zenoh / zmq) without the robot package importing that transport. In configure a plugin registers Endpoints on ctx.interface:

from roqsim.context import Endpoint

ctx.interface.add(Endpoint(
    name="scan", direction="out", owner=self.robot,
    read=lambda: self._scan,          # returns a neutral payload (numpy/tuple/dataclass)
    rate_hz=10.0,
    backend={"ros2": {"type": "sensor_msgs.msg.LaserScan", "topic": "scan",
                      "frame_id": self.frame_id}},   # the site the rays are cast from
))
  • direction — "out" (sim → world; provide read) or "in" (world → sim; provide write). read/write traffic in neutral payloads, never wire messages — this is what keeps the robot package backend-independent.

    An in endpoint also says what kind of interaction it is, through its backend hints, and the choice is about the interaction rather than about taste:

    Hint

    Served as

    Use when

    type

    a subscription

    the input is a stream and the sender needs no answer (cmd_vel).

    service

    a service

    the input is a command whose outcome the caller needs, so it can fail on it (model_override’s std_srvs/SetBool: the reply says whether the fault landed).

    action

    an action server

    the input is a goal that takes time, with feedback and cancellation (FollowJointTrajectory for MoveIt 2).

    An action handler whose only output is a stream of setpoints must command a STOP when its goal is cancelled, because the producer holds the last target it was given every tick: ending the stream leaves that setpoint standing and the robot converging on a path nobody wants any more. The trajectory and gripper handlers post the measured position as that hold before they return, and grade the cancelled goal on the pose it stopped in – so a caller that cancels and reads the joints reads an arm that has stopped, and a result it can tell from a goal that ran to its end.

    write returns None in all three cases. A reply is assembled by the backend’s handler from the producer’s published state — named by a state_key hint — rather than returned from the plugin, which is what keeps Endpoint free of any backend’s reply types. Both the service and action handlers come from per-type registries in roqsim_ros_bridge (services.py / actions.py), so a new srv or action type is a handler there and no change here.

  • owner — the entity the port belongs to, so a bridge can serve one robot in a many-robot world.

  • backend — inert per-backend hints keyed by backend name. Naming the message type as a string (resolved by the bridge) means the robot package imports nothing transport-specific. Anything transport-specific a robot needs lives here. One hint is worth knowing about because it saves writing a converter: field names a member of a structured payload to publish as a primitive message, so a producer keeps its rich payload for in-process consumers and still gets a single-field topic. contact_monitor reads its ContactReport in-process and publishes

    backend={"ros2": {"type": "std_msgs.msg.Bool", "field": "in_contact", "topic": "collision"}}
    

    Without it the bridge’s reflective fallback would assign the whole dataclass to Bool.data. The alternative — a std_msgs.msg.Bool converter in the bridge registry — would put one plugin’s attribute names in shared code, and the next primitive endpoint would add another. model_override is the second user and publishes two members of one report this way (a Bool of active and a String of verified), which is the shape to copy when a producer has several primitives each worth a topic.

  • has_subscribers — optional performance hint a bridge sets after wiring the endpoint (e.g. from a ROS 2 publisher’s subscription count). A producer whose read is expensive to produce (a rendered camera frame) may check it in post_step and skip the work when it’s False; None (no bridge loaded, or one that doesn’t report this) means “assume yes”. See roqsim_sensors’s camera plugins.

  • lazy — opt this endpoint out of publishing while has_subscribers reports nobody listening, so its payload is never even read. Distinct from the check above, and both are needed: the producer’s own check is an OR over every endpoint one render feeds (one depth subscriber justifies the whole GL pass), while lazy is per-endpoint (a raw-image subscriber must not make the camera pay for a JPEG nobody wants). Default False, because a publish can carry more than its message — a bridge deriving TF from an odometry payload would stop broadcasting the transform whenever nothing subscribed to /odom — and because it buys nothing for a cheap payload. Set on the camera plugins’ image, image_compressed, depth, depth_compressed and points.

Running the ROS 2 bridge then needs no per-topic config — add ros2_bridge to the world. For a second robot add another with namespace: robot2: it serves that robot’s endpoints and prefixes its topics/frames (/robot2/...). See Architecture & porting playbook for how the bridge machinery works.

RobotHandle(name, drive(vx, vy, w), read_odom() -> (x, y, yaw, vx, vy, w)) remains the uniform way a controller exposes a robot to in-process consumers (teleop, the standalone driver).

simulation_interfaces services (ROS 2)

The sim_interfaces plugin (in roqsim_ros_bridge) exposes a subset of ros-simulation/simulation_interfaces:

  • GetSimulatorFeatures — advertised capabilities.

  • GetEntities, GetEntityState, SetEntityState — list and read/teleport entities.

  • GetSpawnables — the absent entities SpawnEntity can select.

  • SpawnEntity, DeleteEntity — make an entity appear or disappear (see below).

  • GetSimulationState, SetSimulationState — play / pause / stop.

  • StepSimulation — step N times while paused.

  • ResetSimulation — reset the world.

Spawning is activation, not creation

roqsim never recompiles the model at runtime, so there is no body to add. A world declares everything a trial may bring in, and SpawnEntity selects one of those by uri: the entity’s name, as GetSpawnables lists it. name may be left empty or repeat the uri; a different one would be a rename, which a compiled entity cannot take. A request with no uri selects by name, which the service does not define but older callers rely on. A uri the world does not carry is refused rather than approximated, because the alternative is a trial that believes it spawned something. Spawning CONSUMES a spawnable, since it activates one entity rather than copying a model: GetSpawnables lists the absent ones, GetEntities the present ones, and an entity moves between the two lists as it is spawned and deleted. spawn_formats is therefore empty — offering mjcf would invite a caller to send geometry that nothing can load.

DeleteEntity makes an entity absent: excluded from raycasts, from rendering, from contacts, and from what GetEntities lists. Its pose does not move, which is the point — parking it out of sight leaves a free body accelerating under gravity for as long as it is away, so it comes back with whatever velocity it accumulated. See roqsim.presence for the three model fields this flips and why the geom group is the one that matters: mj_multiRay ignores contype/conaffinity and tests the real triangles, so disabling contact alone would leave an absent obstacle a perfectly good lidar return.

A world can declare an entity absent from the start, with present: false on the entry that registers it:

- spawn_model: {model: pallet, pose: {position: {x: 4.0, y: 1.0}}, motion: physics, present: false}
  name: obstacle

That is what gives a trial something to spawn. A population entry (boxes, cylinders) forwards it to every instance, and each instance is an entity of its own, so a pool of spares is a uri per spare – obstacle_0, obstacle_1, or whatever an instance names itself – and each leaves the list as it is spawned. The declared value is restored on every reset, so a spare brought in during one repetition is a spare again in the next. Do not confuse it with enabled: false, which removes the entry entirely – no body is built, and there is nothing left to spawn.

Moving one needs a free joint

SetEntityState places an entity by writing its base free joint, and SpawnEntity writes the same joint when it is given a pose. A body compiled without one is welded scenery: it holds the pose the world gave it, and both services refuse to move it, naming the weld and the motion: physics that resolves it.

This is worth stating because nothing else about such a world looks wrong. It compiles, the entity exists under the name the caller uses, and GetEntities lists it – so a world that parks an obstacle out of the way and teleports it in on cue fails on its first call, every run, and the count and names all agree. A placement is motion: physics by default, so this is what a world says when it has welded something with motion: static that the trial then tries to move.

Asking a welded entity for the pose it already holds succeeds. Only a move is what a weld refuses, so a world that states a pose twice is not an error.