diff --git a/doc/APIreference/APItypes.rst b/doc/APIreference/APItypes.rst index 6a61f75c..5fc71074 100644 --- a/doc/APIreference/APItypes.rst +++ b/doc/APIreference/APItypes.rst @@ -1580,6 +1580,29 @@ used for opening and reading resources. .. mujoco-include:: mjpResourceProvider +.. _mjpDecoder: + +mjpDecoder +~~~~~~~~~~~~~~~~~~~ + +This data structure defines a decoder. It contains a set of callbacks used for decoding :ref:`mjResource` +into :ref:`mjSpec`. + +.. mujoco-include:: mjpDecoder + +.. _mjpEncoder: + +mjpEncoder +~~~~~~~~~~~~~~~~~~~ + +This data structure defines an encoder. It contains a set of callbacks used for encoding of :ref:`mjSpec` and +:ref:`mjModel` into :ref:`mjResource`. + +.. mujoco-include:: mjpEncoder + + + + .. _tyFunction: Function types @@ -1757,6 +1780,48 @@ This callback is for checking if a resource was modified since it was last read. Returns positive value if the resource was modified since last open, 0 if resource was not modified, and negative value if inconclusive. +.. _mjfDecode: + +mjfDecode +~~~~~~~~~ + +.. code-block:: C + + typedef mjSpec* (*mjfDecode)(mjResource* resource, const mjVFS* vfs); + + +This callback is given an opened resource, and is responsible for decoding it into a :ref:`mjSpec`. +Ownership of the resource and the returned spec is responsibility of the caller. +When decoding fails, the callback should return NULL. + +.. _mjfCanDecode: + +mjfCanDecode +~~~~~~~~~~~~ + +.. code-block:: C + + typedef int (*mjfCanDecode)(const mjResource* resource); + + +This callback is given an opened resource, and is responsible for returning true if the resource can +be decoded by the :ref:`mjpDecoder`. + +.. _mjfEncode: + +mjfEncode +~~~~~~~~~ + +.. code-block:: C + + typedef int (*mjfEncode)(const mjSpec* s, const mjModel* m, const mjVFS* vfs, + mjResource* resource); + + +This callback populates the :ref:`mjResource` `data` member with bytes representing the +given spec in the format associated with the owning plugin. This may be called with the associated +compiled :ref:`mjModel`. + .. _tyNotes: diff --git a/doc/APIreference/functions.rst b/doc/APIreference/functions.rst index 71ab1fa6..60819311 100644 --- a/doc/APIreference/functions.rst +++ b/doc/APIreference/functions.rst @@ -59,6 +59,19 @@ Parse spec from a file. *Nullable:* ``vfs``, ``error`` +.. _mj_encode: + +`mj_encode <#mj_encode>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. mujoco-include:: mj_encode + +Encode spec/model to a file using a registered encoder. + +Returns the number of bytes written on success, -1 on failure. + +*Nullable:* ``m``, ``vfs``, ``error`` + .. _mj_compile: `mj_compile <#mj_compile>`__ @@ -3255,6 +3268,39 @@ Return the resource provider with the prefix that matches against the resource n If no match, return NULL. +.. _mjp_registerEncoder: + +`mjp_registerEncoder <#mjp_registerEncoder>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. mujoco-include:: mjp_registerEncoder + +Globally register an encoder. This function is thread-safe. + +If an identical mjpEncoder is already registered, this function does nothing. + +If a non-identical mjpEncoder with the same name is already registered, an mju_error is raised. + +.. _mjp_defaultEncoder: + +`mjp_defaultEncoder <#mjp_defaultEncoder>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. mujoco-include:: mjp_defaultEncoder + +Set default resource encoder definition. + +.. _mjp_findEncoder: + +`mjp_findEncoder <#mjp_findEncoder>`__ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. mujoco-include:: mjp_findEncoder + +Return the encoder that matches against the content type or filename extension. + +If no match, return NULL. + .. _Thread: Threads diff --git a/doc/changelog.rst b/doc/changelog.rst index 23d2dd87..39316130 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -25,6 +25,10 @@ General The polynomial order is defined by the new constant :ref:`mjNPOLY`. A future breaking C-API change may unify the linear and higher-order coefficients into a single array. +- Introduced :ref:`mjpEncoder`, the counterpart to :ref:`mjpDecoder` for encoding of :ref:`mjSpec` and :ref:`mjModel` into :ref:`mjResource`. + + - Added :ref:`mj_encode`, :ref:`mjp_registerEncoder`, :ref:`mjp_defaultEncoder`, and :ref:`mjp_findEncoder`. + .. admonition:: Breaking API changes :class: attention diff --git a/doc/includes/references.h b/doc/includes/references.h index 78afebc3..6f6e5b33 100644 --- a/doc/includes/references.h +++ b/doc/includes/references.h @@ -1686,6 +1686,13 @@ struct mjpDecoder { // for cleaning it up }; typedef struct mjpDecoder mjpDecoder; +struct mjpEncoder { + const char* content_type; + const char* extension; + mjfEncode encode; + mjfCloseResource close_resource; +}; +typedef struct mjpEncoder mjpEncoder; typedef enum mjtPluginCapabilityBit_ { mjPLUGIN_ACTUATOR = 1<<0, // actuator forces mjPLUGIN_SENSOR = 1<<1, // sensor measurements @@ -3155,6 +3162,9 @@ mjSpec* mj_parseXML(const char* filename, const mjVFS* vfs, char* error, int err mjSpec* mj_parseXMLString(const char* xml, const mjVFS* vfs, char* error, int error_sz); mjSpec* mj_parse(const char* filename, const char* content_type, const mjVFS* vfs, char* error, int error_sz); +int mj_encode(const mjSpec* s, const mjModel* m, const char* filename, + const char* content_type, const mjVFS* vfs, char* error, + int error_sz); mjModel* mj_compile(mjSpec* s, const mjVFS* vfs); int mj_copyBack(mjSpec* s, const mjModel* m); int mj_recompile(mjSpec* s, const mjVFS* vfs, mjModel* m, mjData* d); @@ -3591,6 +3601,9 @@ const mjpResourceProvider* mjp_getResourceProviderAtSlot(int slot); void mjp_registerDecoder(const mjpDecoder* decoder); void mjp_defaultDecoder(mjpDecoder* decoder); const mjpDecoder* mjp_findDecoder(const mjResource* resource, const char* content_type); +void mjp_registerEncoder(const mjpEncoder* encoder); +void mjp_defaultEncoder(mjpEncoder* encoder); +const mjpEncoder* mjp_findEncoder(const char* filename, const char* content_type); mjResource* mju_openResource(const char* dir, const char* name, const mjVFS* vfs, char* error, size_t nerror); void mju_closeResource(mjResource* resource); diff --git a/include/mujoco/mjplugin.h b/include/mujoco/mjplugin.h index 93268618..2d34cbaa 100644 --- a/include/mujoco/mjplugin.h +++ b/include/mujoco/mjplugin.h @@ -89,6 +89,19 @@ struct mjpDecoder { }; typedef struct mjpDecoder mjpDecoder; +//---------------------------------- Encoder ------------------------------------------------------- + +typedef int (*mjfEncode)(const mjSpec* s, const mjModel* m, const mjVFS* vfs, + mjResource* resource); + +struct mjpEncoder { + const char* content_type; + const char* extension; + mjfEncode encode; + mjfCloseResource close_resource; +}; +typedef struct mjpEncoder mjpEncoder; + //---------------------------------- Plugins ------------------------------------------------------- typedef enum mjtPluginCapabilityBit_ { diff --git a/include/mujoco/mujoco.h b/include/mujoco/mujoco.h index f3cfef95..44455b86 100644 --- a/include/mujoco/mujoco.h +++ b/include/mujoco/mujoco.h @@ -135,6 +135,13 @@ MJAPI mjSpec* mj_parseXMLString(const char* xml, const mjVFS* vfs, char* error, MJAPI mjSpec* mj_parse(const char* filename, const char* content_type, const mjVFS* vfs, char* error, int error_sz); +// Encode spec/model to a file using a registered encoder. +// Returns the number of bytes written on success, -1 on failure. +// Nullable: m, vfs, error +MJAPI int mj_encode(const mjSpec* s, const mjModel* m, const char* filename, + const char* content_type, const mjVFS* vfs, char* error, + int error_sz); + // Compile spec to model. // Nullable: vfs MJAPI mjModel* mj_compile(mjSpec* s, const mjVFS* vfs); @@ -1522,6 +1529,19 @@ MJAPI void mjp_defaultDecoder(mjpDecoder* decoder); // If no match, return NULL. MJAPI const mjpDecoder* mjp_findDecoder(const mjResource* resource, const char* content_type); +// Globally register an encoder. This function is thread-safe. +// If an identical mjpEncoder is already registered, this function does nothing. +// If a non-identical mjpEncoder with the same name is already registered, an mju_error is raised. +MJAPI void mjp_registerEncoder(const mjpEncoder* encoder); + +// Set default resource encoder definition. +MJAPI void mjp_defaultEncoder(mjpEncoder* encoder); + +// Return the encoder that matches against the content type or filename extension. +// If no match, return NULL. +MJAPI const mjpEncoder* mjp_findEncoder(const char* filename, const char* content_type); + + //---------------------------------- Resources ----------------------------------------------------- diff --git a/python/mujoco/codegen/generate_cs_bindings.py b/python/mujoco/codegen/generate_cs_bindings.py index 1ce3b414..c9f783c6 100644 --- a/python/mujoco/codegen/generate_cs_bindings.py +++ b/python/mujoco/codegen/generate_cs_bindings.py @@ -209,6 +209,7 @@ _OPAQUE_STRUCTS = [ 'mjTask', 'mjThreadPool', 'mjpDecoder', + 'mjpEncoder', 'mjpResourceProvider', 'mjsElement', 'mjString', diff --git a/python/mujoco/introspect/codegen/generate_structs.py b/python/mujoco/introspect/codegen/generate_structs.py index 787b08e6..6a060f65 100644 --- a/python/mujoco/introspect/codegen/generate_structs.py +++ b/python/mujoco/introspect/codegen/generate_structs.py @@ -41,6 +41,8 @@ _ANONYMOUS_KEY_PATTERN = re.compile(r'\d+:\d+(?=\))') _EXCLUDED = ( 'mjpDecoder', 'mjpDecoder_', + 'mjpEncoder', + 'mjpEncoder_', 'mjpPlugin', 'mjpPlugin_', 'mjpResourceProvider', diff --git a/python/mujoco/introspect/functions.py b/python/mujoco/introspect/functions.py index bf608c26..ff1bcf12 100644 --- a/python/mujoco/introspect/functions.py +++ b/python/mujoco/introspect/functions.py @@ -387,6 +387,57 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([ ), doc='Parse spec from a file.', )), + ('mj_encode', + FunctionDecl( + name='mj_encode', + return_type=ValueType(name='int'), + parameters=( + FunctionParameterDecl( + name='s', + type=PointerType( + inner_type=ValueType(name='mjSpec', is_const=True), + ), + ), + FunctionParameterDecl( + name='m', + type=PointerType( + inner_type=ValueType(name='mjModel', is_const=True), + ), + nullable=True, + ), + FunctionParameterDecl( + name='filename', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + FunctionParameterDecl( + name='content_type', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + FunctionParameterDecl( + name='vfs', + type=PointerType( + inner_type=ValueType(name='mjVFS', is_const=True), + ), + nullable=True, + ), + FunctionParameterDecl( + name='error', + type=PointerType( + inner_type=ValueType(name='char'), + ), + nullable=True, + ), + FunctionParameterDecl( + name='error_sz', + type=ValueType(name='int'), + ), + ), + doc='Encode spec/model to a file using a registered encoder. Returns the number of bytes written on success, -1 on failure.', # pylint: disable=line-too-long + )), ('mj_compile', FunctionDecl( name='mj_compile', @@ -9600,6 +9651,56 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([ ), doc='Return the resource provider with the prefix that matches against the resource name. If no match, return NULL.', # pylint: disable=line-too-long )), + ('mjp_registerEncoder', + FunctionDecl( + name='mjp_registerEncoder', + return_type=ValueType(name='void'), + parameters=( + FunctionParameterDecl( + name='encoder', + type=PointerType( + inner_type=ValueType(name='mjpEncoder', is_const=True), + ), + ), + ), + doc='Globally register an encoder. This function is thread-safe. If an identical mjpEncoder is already registered, this function does nothing. If a non-identical mjpEncoder with the same name is already registered, an mju_error is raised.', # pylint: disable=line-too-long + )), + ('mjp_defaultEncoder', + FunctionDecl( + name='mjp_defaultEncoder', + return_type=ValueType(name='void'), + parameters=( + FunctionParameterDecl( + name='encoder', + type=PointerType( + inner_type=ValueType(name='mjpEncoder'), + ), + ), + ), + doc='Set default resource encoder definition.', + )), + ('mjp_findEncoder', + FunctionDecl( + name='mjp_findEncoder', + return_type=PointerType( + inner_type=ValueType(name='mjpEncoder', is_const=True), + ), + parameters=( + FunctionParameterDecl( + name='filename', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + FunctionParameterDecl( + name='content_type', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + ), + doc='Return the encoder that matches against the content type or filename extension. If no match, return NULL.', # pylint: disable=line-too-long + )), ('mju_openResource', FunctionDecl( name='mju_openResource', diff --git a/src/engine/engine_plugin.cc b/src/engine/engine_plugin.cc index 31eb4f5f..ec0a1ef5 100644 --- a/src/engine/engine_plugin.cc +++ b/src/engine/engine_plugin.cc @@ -357,6 +357,85 @@ bool GlobalTable::CopyObject(mjpDecoder& dst, const mjpDecoder& src, return true; } +template <> +const char* GlobalTable::HumanReadableTypeName() { + return "resource encoder"; +} + +template <> +std::string_view GlobalTable::ObjectKey(const mjpEncoder& encoder) { + if (encoder.content_type) { + if (int len = strklen(encoder.content_type); len != -1) { + return std::string_view(encoder.content_type, len); + } + } + return std::string_view(encoder.extension, strklen(encoder.extension)); +} + +template <> +bool GlobalTable::ObjectEqual(const mjpEncoder& e1, + const mjpEncoder& e2) { + bool content_type_match = false; + if (e1.content_type && e2.content_type) { + content_type_match = CaseInsensitiveEqual(e1.content_type, e2.content_type); + } else { + content_type_match = (e1.content_type == e2.content_type); + } + + bool extension_match = false; + if (e1.extension && e2.extension) { + extension_match = CaseInsensitiveEqual(e1.extension, e2.extension); + } else { + extension_match = (e1.extension == e2.extension); + } + + return content_type_match && extension_match + && e1.encode == e2.encode && e1.close_resource == e2.close_resource; +} + +template <> +bool GlobalTable::CopyObject(mjpEncoder& dst, const mjpEncoder& src, ErrorMessage& err) { + dst = src; + dst.content_type = nullptr; + dst.extension = nullptr; + + if (src.content_type) { + std::unique_ptr content_type = CopyName(src.content_type); + if (!content_type) { + if (strklen(src.content_type) == -1) { + std::snprintf( + err, sizeof(err), + "encoder->content_type length exceeds the maximum limit of %d", + kMaxNameLength); + } else { + std::snprintf(err, sizeof(err), "failed to allocate memory for encoder content_type"); + } + return false; + } + + dst.content_type = content_type.release(); + } + + if (src.extension) { + std::unique_ptr extension = CopyName(src.extension); + if (!extension) { + if (strklen(src.extension) == -1) { + std::snprintf( + err, sizeof(err), + "encoder->extension length exceeds the maximum limit of %d", + kMaxNameLength); + } else { + std::snprintf(err, sizeof(err), "failed to allocate memory for encoder extension"); + } + return false; + } + + dst.extension = extension.release(); + } + + return true; +} + // globally register a plugin (thread-safe), return new slot id int mjp_registerPlugin(const mjpPlugin* plugin) { if (!plugin->name) { @@ -539,6 +618,75 @@ const mjpDecoder* mjp_findDecoder(const mjResource* resource, const char* conten return nullptr; } +void mjp_registerEncoder(const mjpEncoder* encoder) { + if (!encoder->encode) { + mju_warning("encoder must provide an encode callback."); + return; + } + + if (!encoder->close_resource) { + mju_warning("encoder must provide a close_resource callback."); + return; + } + + if (!encoder->content_type && !encoder->extension) { + mju_warning("encoder must provide content_type and/or extensions."); + return; + } + + mjpEncoder encoder_copy = *encoder; + + if (encoder->content_type) { + encoder_copy.extension = nullptr; + GlobalTable::GetSingleton().AppendIfUnique(encoder_copy); + } + + if (encoder->extension) { + encoder_copy.content_type = nullptr; + std::string extensions_str(encoder->extension); + std::stringstream ss(extensions_str); + std::string extension; + while (std::getline(ss, extension, '|')) { + if (!extension.empty()) { + encoder_copy.extension = extension.c_str(); + GlobalTable::GetSingleton().AppendIfUnique(encoder_copy); + } + } + } +} + +void mjp_defaultEncoder(mjpEncoder* encoder) { + std::memset(encoder, 0, sizeof(*encoder)); +} + +const mjpEncoder* mjp_findEncoder(const char* filename, + const char* content_type) { + auto extension = getext(filename ? filename : ""); + bool has_content_type = content_type && strklen(content_type) > 0; + if (!has_content_type && extension.empty()) { + mju_warning("Must provide extension or content_type to mjp_findEncoder."); + return nullptr; + } + + if (has_content_type) { + auto* encoder = + GlobalTable::GetSingleton().GetByKey(content_type, nullptr); + if (encoder) { + return encoder; + } + } + + if (!extension.empty()) { + auto* encoder = GlobalTable::GetSingleton().GetByKey( + extension.c_str(), nullptr); + if (encoder) { + return encoder; + } + } + + return nullptr; +} + // load plugins from a dynamic library void mj_loadPluginLibrary(const char* path) { #if defined(_WIN32) || defined(__CYGWIN__) diff --git a/src/engine/engine_plugin.h b/src/engine/engine_plugin.h index 74149686..048148ca 100644 --- a/src/engine/engine_plugin.h +++ b/src/engine/engine_plugin.h @@ -71,6 +71,15 @@ MJAPI void mjp_defaultDecoder(mjpDecoder* decoder); // find a decoder that can process a given resource MJAPI const mjpDecoder* mjp_findDecoder(const mjResource* resource, const char* content_type); +// registers a resource encoder +MJAPI void mjp_registerEncoder(const mjpEncoder* encoder); + +// set default encoder definition +MJAPI void mjp_defaultEncoder(mjpEncoder* encoder); + +// find an encoder that can write a given format +MJAPI const mjpEncoder* mjp_findEncoder(const char* filename, const char* content_type); + // ================================================================================================= // MuJoCo-internal functions beyond this point. // "Unsafe" suffix indicates that improper use of these functions may result in data races. diff --git a/src/user/user_api.cc b/src/user/user_api.cc index 2202478e..f5b569d9 100644 --- a/src/user/user_api.cc +++ b/src/user/user_api.cc @@ -16,6 +16,8 @@ #include #include +#include +#include #include #include #include @@ -136,6 +138,57 @@ mjSpec* mj_parse(const char* filename, const char* content_type, return spec; } +// encode spec/model to file +int mj_encode(const mjSpec* s, const mjModel* m, const char* filename, + const char* content_type, const mjVFS* vfs, char* error, + int error_sz) { + const mjpEncoder* encoder = mjp_findEncoder(filename, content_type); + if (!encoder) { + if (error) { + strncpy(error, "no encoder found", error_sz); + error[error_sz - 1] = '\0'; + } + return -1; + } + + mjResource resource; + memset(&resource, 0, sizeof(resource)); + resource.name = const_cast(filename); + + const int nbytes = encoder->encode(s, m, vfs, &resource); + if (nbytes < 0 || !resource.data) { + if (error) { + strncpy(error, "encoder failed", error_sz); + error[error_sz - 1] = '\0'; + } + return -1; + } + + FILE* fp = fopen(filename, "wb"); + if (!fp) { + std::free(resource.data); + if (error) { + strncpy(error, "could not open file for writing", error_sz); + error[error_sz - 1] = '\0'; + } + return -1; + } + + const std::size_t written = fwrite(resource.data, 1, nbytes, fp); + fclose(fp); + encoder->close_resource(&resource); + + if (static_cast(written) != nbytes) { + if (error) { + strncpy(error, "failed to write all bytes to file", error_sz); + error[error_sz - 1] = '\0'; + } + return -1; + } + + return nbytes; +} + // compile model mjModel* mj_compile(mjSpec* s, const mjVFS* vfs) { mjCModel* modelC = static_cast(s->element); diff --git a/test/plugin/encoder/encoder_test.cc b/test/plugin/encoder/encoder_test.cc new file mode 100644 index 00000000..ceb43f63 --- /dev/null +++ b/test/plugin/encoder/encoder_test.cc @@ -0,0 +1,134 @@ +// Copyright 2025 DeepMind Technologies Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Tests for encoder plugins. + +#include +#include + +#include +#include +#include +#include +#include +#include "test/fixture.h" + +namespace mujoco { +namespace { + +struct FakeEncoderOutput { + int nbody; + int ngeom; + int njnt; + char resource_name[512]; +}; + +int FakeEncode(const mjSpec* s, const mjModel* m, const mjVFS* vfs, + mjResource* resource) { + auto* output = new FakeEncoderOutput; + output->nbody = m->nbody; + output->ngeom = m->ngeom; + output->njnt = m->njnt; + std::snprintf(output->resource_name, sizeof(output->resource_name), "%s", + resource->name); + resource->data = output; + return sizeof(FakeEncoderOutput); +} + +void CloseResource(mjResource* resource) { + delete static_cast(resource->data); + delete resource; +} + +mjpEncoder FakeEncoder() { + mjpEncoder encoder; + mjp_defaultEncoder(&encoder); + encoder.content_type = "model/fakeformat"; + encoder.extension = ".fakeformat|.alsoFakeFormat"; + encoder.encode = FakeEncode; + encoder.close_resource = CloseResource; + return encoder; +} + +using EncoderPluginTest = MujocoTest; + +TEST_F(EncoderPluginTest, RegisterAndFindByExtension) { + mjpEncoder encoder = FakeEncoder(); + mjp_registerEncoder(&encoder); + + const mjpEncoder* found = mjp_findEncoder("output.fakeformat", nullptr); + ASSERT_THAT(found, testing::NotNull()); + EXPECT_EQ(found->encode, FakeEncode); +} + +TEST_F(EncoderPluginTest, FindByAlternateExtension) { + const mjpEncoder* found = mjp_findEncoder("output.alsoFakeFormat", nullptr); + ASSERT_THAT(found, testing::NotNull()); + EXPECT_EQ(found->encode, FakeEncode); +} + +TEST_F(EncoderPluginTest, FindByContentType) { + const mjpEncoder* found = mjp_findEncoder(nullptr, "model/fakeformat"); + ASSERT_THAT(found, testing::NotNull()); + EXPECT_EQ(found->encode, FakeEncode); +} + +TEST_F(EncoderPluginTest, FindUnknownExtensionReturnsNull) { + const mjpEncoder* found = mjp_findEncoder("output.unknown", nullptr); + EXPECT_THAT(found, testing::IsNull()); +} + +TEST_F(EncoderPluginTest, DefaultEncoderIsZeroed) { + mjpEncoder encoder; + mjp_defaultEncoder(&encoder); + EXPECT_EQ(encoder.content_type, nullptr); + EXPECT_EQ(encoder.extension, nullptr); + EXPECT_EQ(encoder.encode, nullptr); +} + +TEST_F(EncoderPluginTest, EncodeModel) { + mjSpec* spec = mj_makeSpec(); + mjsBody* world = mjs_findBody(spec, "world"); + mjsBody* body = mjs_addBody(world, nullptr); + mjsGeom* geom = mjs_addGeom(body, nullptr); + geom->size[0] = 1.0; + geom->size[1] = 1.0; + geom->size[2] = 1.0; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + const mjpEncoder* found = mjp_findEncoder("output.fakeformat", nullptr); + ASSERT_THAT(found, testing::NotNull()); + + mjResource resource = {}; + resource.name = const_cast("output.fakeformat"); + + int result = found->encode(spec, model, nullptr, &resource); + EXPECT_GT(result, 0); + + auto* output = static_cast(resource.data); + ASSERT_THAT(output, testing::NotNull()); + EXPECT_EQ(output->nbody, 2); + EXPECT_EQ(output->ngeom, 1); + EXPECT_EQ(output->njnt, 0); + EXPECT_STREQ(output->resource_name, "output.fakeformat"); + + delete output; + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +} // namespace +} // namespace mujoco diff --git a/unity/Runtime/Bindings/MjBindings.cs b/unity/Runtime/Bindings/MjBindings.cs index 1692a4b9..ea8ae869 100644 --- a/unity/Runtime/Bindings/MjBindings.cs +++ b/unity/Runtime/Bindings/MjBindings.cs @@ -6700,6 +6700,9 @@ public static unsafe extern void mj_clearCache(mjCache_* cache); [DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] public static unsafe extern mjModel_* mj_loadXML([MarshalAs(UnmanagedType.LPStr)]string filename, void* vfs, StringBuilder error, int error_sz); +[DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] +public static unsafe extern int mj_encode(void* s, mjModel_* m, [MarshalAs(UnmanagedType.LPStr)]string filename, [MarshalAs(UnmanagedType.LPStr)]string content_type, void* vfs, StringBuilder error, int error_sz); + [DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] public static unsafe extern int mj_saveLastXML([MarshalAs(UnmanagedType.LPStr)]string filename, mjModel_* m, StringBuilder error, int error_sz); diff --git a/wasm/codegen/generators/constants.py b/wasm/codegen/generators/constants.py index a365a6e2..1a8aaeee 100644 --- a/wasm/codegen/generators/constants.py +++ b/wasm/codegen/generators/constants.py @@ -46,15 +46,18 @@ _SKIPPED_PLUGIN_FUNCTIONS: tuple[str, ...] = ( "mjc_getSDF", "mjc_gradient", "mjp_defaultDecoder", + "mjp_defaultEncoder", "mjp_defaultPlugin", "mjp_defaultResourceProvider", "mjp_findDecoder", + "mjp_findEncoder", "mjp_getPlugin", "mjp_getPluginAtSlot", "mjp_getResourceProvider", "mjp_getResourceProviderAtSlot", "mjp_pluginCount", "mjp_registerDecoder", + "mjp_registerEncoder", "mjp_registerPlugin", "mjp_registerResourceProvider", "mjp_resourceProviderCount", @@ -74,6 +77,8 @@ _SKIPPED_CLASS_METHODS: tuple[str, ...] = ( "mj_deleteModel", "mj_deleteSpec", "mj_deleteVFS", + "mj_encode", + "mj_parse", # TODO(manevi): Bind this function. "mj_loadModel", "mj_loadXML", "mj_makeData",