diff --git a/doc/changelog.rst b/doc/changelog.rst index 4a8f22d4..bb1c7a20 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -7,6 +7,11 @@ Upcoming version (not yet released) - Added island support for the :ref:`PGS solver`. +Python +^^^^^^ + +- Added ``MjSpec.encode`` method, wrapping :ref:`mj_encode`. + Version 3.8.0 (April 24, 2026) ------------------------------ diff --git a/doc/python.rst b/doc/python.rst index 61761b0b..95beb3ff 100644 --- a/doc/python.rst +++ b/doc/python.rst @@ -547,6 +547,12 @@ Compiled ``MjSpec`` objects can be saved to XML string with the ``to_xml()`` met +Alternatively, the spec can be saved directly to a file using ``encode()``: + +.. code-block:: python + + spec.encode('model.xml', model) + Attachment ---------- diff --git a/python/mujoco/specs.cc b/python/mujoco/specs.cc index e58d66cc..2ba20431 100644 --- a/python/mujoco/specs.cc +++ b/python/mujoco/specs.cc @@ -431,6 +431,51 @@ PYBIND11_MODULE(_specs, m) { throw FatalError(std::string(err.data())); } }); + mjSpec.def( + "encode", + [](MjSpec& self, std::string filename, + std::optional model, + std::optional content_type) -> int { + raw::MjModel* m = nullptr; + if (model.has_value() && !model->is_none()) { + auto& wrapper = + py::cast<_impl::MjModelWrapper&>(*model); + m = wrapper.get(); + } + + mjVFS vfs; + mjVFS* vfs_ptr = nullptr; + if (!self.assets.empty()) { + mj_defaultVFS(&vfs); + vfs_ptr = &vfs; + for (const auto& asset : self.assets) { + std::string buffer_name = + py::cast(asset.first); + std::string buffer = + py::cast(asset.second); + mj_addBufferVFS(vfs_ptr, buffer_name.c_str(), + buffer.c_str(), buffer.size()); + } + } + + std::array err; + err[0] = '\0'; + const char* ct = + content_type.has_value() ? content_type->c_str() : nullptr; + int nbytes = mj_encode(self.ptr, m, filename.c_str(), ct, + vfs_ptr, err.data(), err.size()); + + if (vfs_ptr) { + mj_deleteVFS(vfs_ptr); + } + + if (nbytes < 0) { + throw FatalError(std::string(err.data())); + } + return nbytes; + }, + py::arg("filename"), py::arg("model") = py::none(), + py::arg("content_type") = py::none()); mjSpec.def( "add_default", [](MjSpec* spec, std::string& classname, diff --git a/python/mujoco/specs_test.py b/python/mujoco/specs_test.py index 8bb9360e..fe3f45b1 100644 --- a/python/mujoco/specs_test.py +++ b/python/mujoco/specs_test.py @@ -22,6 +22,7 @@ import textwrap import typing import zipfile # pylint: disable=unused-import +from absl import flags from absl.testing import absltest from etils import epath import mujoco @@ -34,6 +35,11 @@ def get_linenumber(): class SpecsTest(absltest.TestCase): + def setUp(self): + super().setUp() + # Mark flags as parsed to avoid pytest errors about unparsed flags. + # This is needed for `create_tempdir()` calls below. + flags.FLAGS.mark_as_parsed() def test_typing(self): spec = mujoco.MjSpec() @@ -1964,5 +1970,49 @@ class SpecsTest(absltest.TestCase): self.assertGreater(cam_sd[0], cam_sd[1]) # dist > depth self.assertAlmostEqual(cam_sd[1], 2.0, places=6) # depth is still 2.0 + def test_encode_xml(self): + # Create a simple spec and compile. + spec = mujoco.MjSpec() + body = spec.worldbody.add_body() + geom = body.add_geom() + geom.size[0] = 1 + model = spec.compile() + + # Encode to XML. + filename = os.path.join(self.create_tempdir().full_path, 'output.xml') + nbytes = spec.encode(filename, model) + self.assertGreater(nbytes, 0) + + # Verify the output is valid XML that can be loaded. + reloaded = mujoco.MjSpec.from_file(filename) + reloaded_model = reloaded.compile() + self.assertEqual(reloaded_model.ngeom, model.ngeom) + + def test_encode_xml_without_model(self): + # Create a simple spec and compile so XML can be written. + spec = mujoco.MjSpec() + body = spec.worldbody.add_body() + geom = body.add_geom() + geom.size[0] = 1 + spec.compile() + + # Encode to XML without passing a model explicitly. + filename = os.path.join(self.create_tempdir().full_path, 'output.xml') + nbytes = spec.encode(filename) + self.assertGreater(nbytes, 0) + + def test_encode_no_encoder_raises(self): + # Create a simple spec and compile. + spec = mujoco.MjSpec() + body = spec.worldbody.add_body() + geom = body.add_geom() + geom.size[0] = 1 + model = spec.compile() + + # Encode with an unknown extension should fail. + filename = os.path.join(self.create_tempdir().full_path, 'output.unknown') + with self.assertRaises(mujoco.FatalError): + spec.encode(filename, model) + if __name__ == '__main__': absltest.main() diff --git a/src/user/user_api.cc b/src/user/user_api.cc index 4b5effaf..f03b5e85 100644 --- a/src/user/user_api.cc +++ b/src/user/user_api.cc @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -143,6 +144,18 @@ mjSpec* mj_parse(const char* filename, const char* content_type, int mj_encode(const mjSpec* s, const mjModel* m, const char* filename, const char* content_type, const mjVFS* vfs, char* error, int error_sz) { + // TODO(shaves) Move MJCF and URDF to encoders/decoders. + auto filepath = mujoco::user::FilePath(filename); + if (filepath.Ext() == ".xml" || + (content_type && std::strcmp(content_type, "text/xml") == 0)) { + int result = mj_saveXML(s, filename, error, error_sz); + if (result < 0) { + return -1; + } + + return std::filesystem::file_size(filename); + } + const mjpEncoder* encoder = mjp_findEncoder(filename, content_type); if (!encoder) { if (error) {