Developer guide¶
The user-facing interfaces are covered in Interfaces and Available plugins. For the full
architecture and — importantly — the porting playbook for reworking existing MuJoCo code into
plugins, see the Architecture & porting playbook document: the plugin lifecycle in depth, the concurrency model
(single-writer + ctx.post command queue), rendering, the foreseen synchronous/lockstep mode,
performance/benchmarking, conventions, and the porting playbook (a decision tree and recipes for
turning monolithic MuJoCo code into cooperating plugins).
- Architecture & porting playbook
- 1. Overview & goals
- 2. Lifecycle reference
- 3. API contracts
- 4. Config & registry
- 5. Plugin-type catalog
- 6. Porting playbook
- 7. Concurrency & threading model
- 8. Rendering
- 9. Sensor noise
- 10. Synchronous / lockstep mode [planned — seams exist, behaviour inert]
- 11. Performance & benchmarking
- 12. Conventions
- 13. Robot interface & transport bridges
- 14. Glossary & FAQ
Repository layout¶
roqsim/ core pip package (framework, ROS-free; depends on no sibling)
roqsim_sensors/ generic sensor plugins + models + the coverage analysis layer (depends on roqsim)
roqsim_assets/ prop library; roqsim_scenes/ baked scenes + world generators
roqsim_mobile/ wheeled BASE plugins + base models (depends on roqsim + roqsim_sensors)
roqsim_manipulation/ arm plugins only; roqsim_manipulation_assets/ arm + gripper models
roqsim_mobile_manipulation/ base AND arm robots — the one package depending on both families
roqsim_humanoid/ roqsim_quadruped/ legged families; roqsim_walker/ pedestrians (dynamic obstacles)
roqsim_scene_builder/ roqsim_webctrl/ human-in-the-loop scene windows, web control UI
scenario_execution_roqsim/ OSC actions (entity_moved, entity_rotated, set_model_override);
the ONLY package here that may import scenario_execution
ros2_ws/src/
roqsim_ros_bridge/ ROS 2 bridge + simulation_interfaces (plugins)
roqsim_nav2_example/ minimal nav2 example + goal test
docs/ this documentation (+ architecture.rst)
Golden rules¶
Single-writer: only the physics thread (the one calling
engine.step()) touchesmodel/data. External input goes throughctx.post(cmd).No mid-run recompile: modify the
MjSpeconly inbuild(); at runtime use mocap/qpos writes or a pre-compiled entity pool.Keep the core ROS-free. The ROS bridge is just another plugin.
Read a model field through ``int()`` before matching it against an ``mjt*`` enum.
model.jnt_type[j]is a numpy scalar, andx in (mjJNT_HINGE, mjJNT_SLIDE)puts the enum on the left of==— which MuJoCo 3.12 answersFalsewhere 3.11 answeredTrue. A barevalue == enumstill works, so the break is silent and partial: the filter just returns nothing, and an arm reports no joints instead of raising.int()on the model value (or plain-intmembers, as inexport_capture._SCALAR_JOINTS) is correct on every version.
Developer workflow¶
make venv # create .venv and install everything
make test # unit tests (+ nav2 integration when ROS is sourced)
make format # ruff format + autofix
make lint # ruff check (no changes)
make doc # build HTML docs into build/html
make view-doc # build + open in a browser
make test runs the packages in parallel over pytest-xdist. The worker count is JOBS,
and it is bounded by memory rather than by cores: a full run peaks at roughly 3 GB per worker,
because between them the tests import mujoco, torch, cv2 and matplotlib and load every robot mesh
in the tree. The default is deliberately small – raise it if you have the RAM, and drop to the
serial path if a parallel run ever looks flaky:
make test JOBS=6 # more workers, if there is memory for them
make test JOBS=1 # serial; the fallback when a parallel run looks wrong
make test-roqsim_sensors # just one package, for the edit/run loop
Adding a tool¶
Every tool is a subcommand of roqsim, so roqsim --help is the whole inventory and nobody has to know
a path or which package a tool lives in:
roqsim --help # the groups, one per package that ships tools
roqsim scenes --help # one line per tool in that group
roqsim scenes sdf-to-scene --help # that tool's own options
python -m pydoc roqsim_scenes.cli.sdf_to_scene # the reasoning behind it
Two tools are top-level rather than in a group, because they are the two verbs the substrate exists for:
roqsim sim runs a world and roqsim render draws one. Everything else is roqsim <group> <tool>.
Write it standalone, then link it in — in the same commit.
Draft it as a script under
<pkg>/tools/<name>.pywith amain(argv)and a__main__block.Move the logic to
<pkg>/src/<pkg>/cli/<name>.pyand register it with one line in that package’s group (src/<pkg>/cli/__init__.py):group.add_command(tool("<pkg>.cli.<name>"))
Leave
<pkg>/tools/<name>.pyas a three-line wrapper, so running it from the folder still works:#!/usr/bin/env python3 """Run-from-the-folder wrapper for `roqsim scenes <name>`; the logic is in roqsim_scenes.cli.<name>.""" from roqsim_scenes.cli.<name> import main raise SystemExit(main())
A package that ships its first tool also declares the group itself, in its pyproject.toml:
[project.entry-points."roqsim.commands"]
scenes = "roqsim_scenes.cli:scenes"
That is the whole mechanism: any installed package can contribute a group this way, including one maintained outside this repository. It never edits the core.
You write no help text. All three levels come from the docstring you already wrote — click takes
the listing line from its first line, --help is your own argparse, and python -m pydoc
prints the rest. There is no short_help to fill in and no flag to add. Two conventions carry it,
both ordinary Python:
the docstring’s first line is a one-line summary — it is what a listing shows, so keep it plain prose (a terminal prints
\`\`markup\`\`verbatim);the parser says
ArgumentParser(description=__doc__.split("\n")[0]). Handing it the whole docstring can make a single--helpcost more than the rest of the tree together.
make test fails while a tool with a __main__ block is unregistered, while a wrapper carries
logic, or while a --help grows an essay. These are checks (roqsim/tests/test_command_registry.py),
not conventions — a convention nothing checks decays silently, until a checked-in Makefile calls a
binary that does not exist.
A tool that runs inside Blender is registered with tool(..., blender=True): it cannot be imported
here, so the command locates blender and runs the module inside it.
Sensor coverage (analysis layer)¶
roqsim_sensors.coverage is an offline analysis layer over a compiled world — not part of the
tick pipeline. It answers “how much of the room / which objects do these sensors see, and by how
many?” (user docs: Sensor coverage). Design worth knowing when extending it:
One shared FOV, per-type extraction.
coverage/fov.pydefines a singleSensorFov(a posed angular sector: cameraFRUSTUMvia pinhole intrinsics, lidarCONE_BANDvia azimuth/elevation bands) and thein_fovmembership test — it knows nothing about specific sensors.coverage/ adapters.pyis the only module that does: a per-type registry (@register_adapter) that builds aSensorFovfrom a sensor’s own parameters, reusing each plugin’s resolution logic (a camera’s MJCFfovy/resolution; a lidar plugin’s resolved attributes). A new sensor gets a new adapter there; the sensor plugins are never edited. This is the extension seam — keep every other coverage module dependent onSensorFovalone.Engine.
coverage/engine.pygates each point range → angular FOV (numpy) → line of sight (one batchedroqsim.raycast.cast()per sensor — the same seam every raycaster in the tree uses). The raycastgeomgroupmask excludes group 4, so an absent entity cannot occlude; that is the seam’s default, so it holds without each call site remembering it. A model’s FOV-visualisation mesh is kept out by a different axis — it is alpha 0, andmj_rayskips a geom exactly when its resolved alpha is 0, whatever the mask says (its group is 2, not 4/5). Masking by group is still required, because raycasts hit visible geometry regardless of contact flags.Sampling (
coverage/sampling.py) is model-based (works for any world source) and uses only numpy + MuJoCo raycasts (viaraycast.cast_many:mj_multiRaycasts from one origin, so six axis rays from each of P grid points is irreducibly P calls — what the helper buys is the flat buffers, so the classification vectorises over all points at once instead of per point) — no scipy/trimesh (they are broken under numpy 2 in the system venv, and the layer must not depend on them). Free-space classification is deliberately conservative (drops are safe: they only make coverage look worse).Two front doors, one core: the
sensor_coverage_probeplugin (world-YAML toggle,plugins/sensor_coverage_probe.py) and theroqsim sensors coverageCLI (coverage/cli.py). Thecoverageextra (matplotlib, for the 2D heatmap) is optional; the 3D render needs only MuJoCo.