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:
short name — a registered
roqsim.pluginsentry-point.module.path:Class — imported off
PYTHONPATH.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 |
|---|---|---|
|
once, pre-compile |
mutate the |
|
once, post-compile |
resolve ids/handles, open resources, advertise services, register entities |
|
each reset |
restore initial state |
|
each tick, before |
write controls/actuators |
|
each tick, after |
read state, publish, record |
|
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 (specduringbuild).config— the parsed world dict.blackboard— a typed key/value store for cross-plugin cooperation (set/get/require). E.g. a controller registers aRobotHandleunderrobot:<name>for in-process consumers (teleop, standalone drivers).interface— the interface registry (add/all/by_direction): where a plugin declares itsEndpoints (see below). A transport/bridge plugin reads it to wire the robot up.entities— the entity registry (add/get/names); backssimulation_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 ofpre_step. Never touchdatafrom 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 |
|---|---|
|
every controller of that robot, with its state and what it holds |
|
activate and deactivate, atomically |
|
move a declared controller to |
|
refused while the controller is active, as upstream refuses it |
|
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_interfacesis empty while inactive;required_command_interfacesis 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_AUTOis 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; provideread) or"in"(world → sim; providewrite).read/writetraffic in neutral payloads, never wire messages — this is what keeps the robot package backend-independent.An
inendpoint 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
typea subscription
the input is a stream and the sender needs no answer (
cmd_vel).servicea service
the input is a command whose outcome the caller needs, so it can fail on it (
model_override’sstd_srvs/SetBool: the reply says whether the fault landed).actionan action server
the input is a goal that takes time, with feedback and cancellation (
FollowJointTrajectoryfor 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.
writereturnsNonein all three cases. A reply is assembled by the backend’s handler from the producer’s published state — named by astate_keyhint — rather than returned from the plugin, which is what keepsEndpointfree of any backend’s reply types. Both the service and action handlers come from per-type registries inroqsim_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:fieldnames 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_monitorreads itsContactReportin-process and publishesbackend={"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 — astd_msgs.msg.Boolconverter in the bridge registry — would put one plugin’s attribute names in shared code, and the next primitive endpoint would add another.model_overrideis the second user and publishes two members of one report this way (aBoolofactiveand aStringofverified), 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 whosereadis expensive to produce (a rendered camera frame) may check it inpost_stepand skip the work when it’sFalse;None(no bridge loaded, or one that doesn’t report this) means “assume yes”. Seeroqsim_sensors’s camera plugins.lazy— opt this endpoint out of publishing whilehas_subscribersreports 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), whilelazyis per-endpoint (a raw-image subscriber must not make the camera pay for a JPEG nobody wants). DefaultFalse, 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_compressedandpoints.
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 entitiesSpawnEntitycan 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.