From fa8ac7a516cbb0c177ae0a6621669e38aaf75743 Mon Sep 17 00:00:00 2001 From: Sam Haves Date: Wed, 20 May 2026 09:32:00 -0700 Subject: [PATCH] Implement mjz encoder. PiperOrigin-RevId: 918493808 Change-Id: I150d182ff27801d829537a8001a0b3a04c1a0499 --- CMakeLists.txt | 2 +- src/xml/mjz/CMakeLists.txt | 1 + src/xml/mjz/mjz_encoder.cc | 421 +++++++ test/xml/mjz/CMakeLists.txt | 4 + test/xml/mjz/mjz_encoder_test.cc | 1105 +++++++++++++++++ .../asset_collision_test/child_a/child.xml | 10 + .../asset_collision_test/child_a/mesh.obj | 20 + .../asset_collision_test/child_b/child.xml | 10 + .../asset_collision_test/child_b/mesh.obj | 8 + test/xml/mjz/testdata/disk_mesh/box.obj | 20 + test/xml/mjz/testdata/disk_mesh/model.xml | 10 + test/xml/mjz/testdata/include_test/child.xml | 7 + test/xml/mjz/testdata/include_test/parent.xml | 8 + .../mjz/testdata/meshdir_test/assets/box.obj | 20 + test/xml/mjz/testdata/meshdir_test/model.xml | 11 + test/xml/mjz/testdata/mixed_test/disk_box.obj | 20 + .../mjz/testdata/subdir_mesh/meshes/box.obj | 20 + test/xml/mjz/testdata/subdir_mesh/model.xml | 10 + .../mjz/testdata/transitive_test/child.xml | 8 + .../testdata/transitive_test/grandchild.xml | 7 + .../mjz/testdata/transitive_test/parent.xml | 8 + 21 files changed, 1729 insertions(+), 1 deletion(-) create mode 100644 src/xml/mjz/mjz_encoder.cc create mode 100644 test/xml/mjz/mjz_encoder_test.cc create mode 100644 test/xml/mjz/testdata/asset_collision_test/child_a/child.xml create mode 100644 test/xml/mjz/testdata/asset_collision_test/child_a/mesh.obj create mode 100644 test/xml/mjz/testdata/asset_collision_test/child_b/child.xml create mode 100644 test/xml/mjz/testdata/asset_collision_test/child_b/mesh.obj create mode 100644 test/xml/mjz/testdata/disk_mesh/box.obj create mode 100644 test/xml/mjz/testdata/disk_mesh/model.xml create mode 100644 test/xml/mjz/testdata/include_test/child.xml create mode 100644 test/xml/mjz/testdata/include_test/parent.xml create mode 100644 test/xml/mjz/testdata/meshdir_test/assets/box.obj create mode 100644 test/xml/mjz/testdata/meshdir_test/model.xml create mode 100644 test/xml/mjz/testdata/mixed_test/disk_box.obj create mode 100644 test/xml/mjz/testdata/subdir_mesh/meshes/box.obj create mode 100644 test/xml/mjz/testdata/subdir_mesh/model.xml create mode 100644 test/xml/mjz/testdata/transitive_test/child.xml create mode 100644 test/xml/mjz/testdata/transitive_test/grandchild.xml create mode 100644 test/xml/mjz/testdata/transitive_test/parent.xml diff --git a/CMakeLists.txt b/CMakeLists.txt index 9157b7c4..2ed0692e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -157,6 +157,7 @@ add_subdirectory(plugin/stl_decoder) add_subdirectory(src/engine) add_subdirectory(src/user) add_subdirectory(src/xml) +add_subdirectory(src/xml/mjz) add_subdirectory(src/thread) if(NOT EMSCRIPTEN AND NOT MUJOCO_USE_FILAMENT_MJR_COMPAT) add_subdirectory(src/render/classic) @@ -256,7 +257,6 @@ if(MUJOCO_BUILD_SIMULATE) endif() if(MUJOCO_BUILD_STUDIO) - add_subdirectory(src/xml/mjz) add_subdirectory(src/experimental/platform) add_subdirectory(src/experimental/studio) endif() diff --git a/src/xml/mjz/CMakeLists.txt b/src/xml/mjz/CMakeLists.txt index a44e096b..ab92623b 100644 --- a/src/xml/mjz/CMakeLists.txt +++ b/src/xml/mjz/CMakeLists.txt @@ -14,6 +14,7 @@ set(MUJOCO_MJZ_SRCS mjz_decoder.cc + mjz_encoder.cc ) target_sources(mujoco PRIVATE ${MUJOCO_MJZ_SRCS}) diff --git a/src/xml/mjz/mjz_encoder.cc b/src/xml/mjz/mjz_encoder.cc new file mode 100644 index 00000000..b209fb04 --- /dev/null +++ b/src/xml/mjz/mjz_encoder.cc @@ -0,0 +1,421 @@ +// Copyright 2026 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. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Disable unused function warnings for miniz. +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#endif +#include +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#include +#include "tinyxml2.h" + +namespace { + +namespace fs = std::filesystem; + +// asset file to be packed into the archive +struct AssetEntry { + fs::path archive_path; + // modelfiledir of the spec that originally owned the asset + fs::path source_dir; + fs::path disk_path; +}; + +// Key for identifying an asset element that needs its file path rewritten. +// Using (tag, file, name) allows disambiguation of unnamed elements and +// individual cubemap face files on the same texture element. +struct RewriteKey { + std::string_view tag; + std::string_view file; + std::string_view name; + + bool operator==(const RewriteKey&) const = default; +}; + +struct RewriteKeyHash { + std::size_t operator()(const RewriteKey& k) const { + const std::size_t h1 = std::hash{}(k.tag); + const std::size_t h2 = std::hash{}(k.file); + const std::size_t h3 = std::hash{}(k.name); + std::size_t seed = h1; + seed ^= h2 + 0x9e3779b9 + (seed << 6) + (seed >> 2); + seed ^= h3 + 0x9e3779b9 + (seed << 6) + (seed >> 2); + return seed; + } +}; + +using RewriteMap = std::unordered_map; + +fs::path SanitizePath(const fs::path& path) { + // replace characters that are not alphanumeric, '/', '.', '_', or '-' + // with '_' + std::string result = path.string(); + for (char& c : result) { + if (!std::isalnum(static_cast(c)) && c != '/' && c != '.' && + c != '_' && c != '-') { + c = '_'; + } + } + return result; +} + +// Remove leading ".." path components from a normalized path. +std::string RemoveLeadingDotDot(const fs::path& p) { + fs::path result; + bool skipping = true; + for (const auto& component : p) { + if (skipping && component == "..") { + continue; + } + skipping = false; + result /= component; + } + return result.string(); +} + +// Apply file-attribute rewrites to the serialized XML. +// For each element, all attributes are checked against the rewrite map keyed +// by (tag_name, attribute_value, element_name). This handles both regular +// "file" attributes and cubemap face attributes (fileright, fileleft, etc.). +void ApplyRewrites(std::string& xml, const RewriteMap& rewrites) { + if (rewrites.empty()) return; + + tinyxml2::XMLDocument doc; + if (doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return; + + // Walk all elements in the document. + std::stack stack; + if (auto* root = doc.RootElement()) stack.push(root); + while (!stack.empty()) { + auto* elem = stack.top(); + stack.pop(); + + const char* tag_name = elem->Value(); + const char* name_attr = elem->Attribute("name"); + + // Check file-related attributes for paths that need rewriting. + // This covers "file" and cubemap face attributes: fileright, fileleft, etc. + for (const tinyxml2::XMLAttribute* attr = elem->FirstAttribute(); attr; + attr = attr->Next()) { + std::string_view attr_name{attr->Name()}; + if (!attr_name.starts_with("file")) continue; + + const char* attr_value = attr->Value(); + if (!attr_value) continue; + + RewriteKey key{tag_name, attr_value, name_attr ? name_attr : ""}; + auto it = rewrites.find(key); + if (it != rewrites.end()) { + elem->SetAttribute(attr->Name(), it->second.c_str()); + } + } + + for (auto* child = elem->FirstChildElement(); child; + child = child->NextSiblingElement()) { + stack.push(child); + } + } + + tinyxml2::XMLPrinter printer; + doc.Print(&printer); + xml = printer.CStr(); +} + +// collect all referenced asset files from the spec +// +// returns a map from unique archive entry names to AssetEntry structs +// and populates xml_rewrites with XML file attributes that need to be updated. +std::unordered_map CollectAssets( + const mjSpec* spec, RewriteMap& xml_rewrites) { + const mjString* root_meshdir = spec->compiler.meshdir; + const mjString* root_texturedir = spec->compiler.texturedir; + + struct PathHash { + std::size_t operator()(const fs::path& p) const { + return fs::hash_value(p); + } + }; + // maps full disk paths to their archive paths + std::unordered_map archived_paths; + // all asset entries, keyed by the final archive path + std::unordered_map archive_entries; + + auto process = [&](mjsElement* elem, const mjString* raw_file, + bool use_meshdir, std::string_view tag_name) { + // skip empty files + if (!raw_file || raw_file->empty()) return; + + const mjString* elem_name = mjs_getName(elem); + const mjSpec* owning_spec = mjs_getOriginSpec(elem); + + mjsCompiler* comp = mjs_getCompiler(elem); + if (!comp) { + mju_error( + "MJZ encoder: no compiler for element '%s', this should never " + "happen.", + elem_name->c_str()); + } + + auto rewrite_key = RewriteKey{tag_name, *raw_file, *elem_name}; + + const fs::path owning_spec_dir{ + owning_spec->modelfiledir ? *owning_spec->modelfiledir : ""}; + const fs::path raw_path{*raw_file}; + const fs::path prefix_dir{use_meshdir ? *comp->meshdir : *comp->texturedir}; + const fs::path root_dir = + fs::path(use_meshdir ? *root_meshdir : *root_texturedir); + + const char* ch = std::strchr(raw_file->c_str(), ':'); + const bool is_uri = ch != nullptr; + + // full path of the asset relative to its spec, or absolute/URI if the + // raw path is not relative. + fs::path full_spec_path = raw_path; + // If the raw path is relative AND it does not have a valid resource + // provider URI, then we need to prefix it with the specs + // meshdir/texturedir. + if (!is_uri && raw_path.is_relative()) { + full_spec_path = prefix_dir / raw_path; + } + + // full path of the asset regardless of spec, URI and absolute paths + // remain unchanged but relative paths are prefixed with modelfiledir + fs::path full_path = full_spec_path; + if (!is_uri && full_spec_path.is_relative()) { + full_path = owning_spec_dir / full_spec_path; + } + + // sanitize the path to remove any URI schemes or other non-path characters + // if the file path has a URI scheme (e.g. "http://foo/mesh.stl"), strip it + // so the archive entry uses a concrete path and decoding the MJZ won't try + // to invoke a resource provider. + // First remove any leading ".." path components. + // my_provider:a/../b/c_$.obj -> my_provider_a/../b/c__.obj + const fs::path sanitized = SanitizePath(raw_path); + // my_provider_a/../b/c__.obj -> my_provider_a/b/c__.obj + const fs::path normalized = sanitized.lexically_normal(); + // ../../b/c__.obj -> b/c__.obj + // Also remove any leading '/'. + const fs::path localized = RemoveLeadingDotDot(normalized.relative_path()); + // path relative to the root XML in the archive + fs::path archive_path = root_dir / localized; + + // If this file was already archived, we may still need to add a rewrite + // if raw_file was sanitized or collision-renamed for the first occurrence. + if (auto it = archived_paths.find(full_path); it != archived_paths.end()) { + if (archive_path != it->second || localized != raw_path) { + xml_rewrites[rewrite_key] = it->second; + } + return; + } + + if (localized != raw_path) { + xml_rewrites[rewrite_key] = archive_path.string(); + } + + // Collision renaming: if this archive path is already in use, + // try again with an incremented suffix. + fs::path parent = archive_path.parent_path(); + fs::path stem = archive_path.stem(); + fs::path extension = archive_path.extension(); + for (int i = 0; archive_entries.contains(archive_path.string()); ++i) { + std::string new_name = + stem.string() + "_" + std::to_string(i) + extension.string(); + archive_path = parent / new_name; + xml_rewrites[rewrite_key] = archive_path.string(); + } + + archived_paths[full_path] = archive_path.string(); + archive_entries[archive_path.string()] = + AssetEntry{archive_path, owning_spec_dir, full_spec_path}; + }; + + // Meshes, heightfields, and skins use meshdir. + { + const mjsMesh* mesh = mjs_asMesh(mjs_firstElement(spec, mjOBJ_MESH)); + while (mesh != nullptr) { + if (!mesh->file) continue; + process(mesh->element, mesh->file, /*use_meshdir=*/true, "mesh"); + mesh = mjs_asMesh(mjs_nextElement(spec, mesh->element)); + } + } + + { + const mjsHField* hf = mjs_asHField(mjs_firstElement(spec, mjOBJ_HFIELD)); + while (hf != nullptr) { + if (!hf->file) continue; + process(hf->element, hf->file, /*use_meshdir=*/true, "hfield"); + hf = mjs_asHField(mjs_nextElement(spec, hf->element)); + } + } + + { + const mjsSkin* skin = mjs_asSkin(mjs_firstElement(spec, mjOBJ_SKIN)); + while (skin != nullptr) { + if (!skin->file) continue; + process(skin->element, skin->file, /*use_meshdir=*/true, "skin"); + skin = mjs_asSkin(mjs_nextElement(spec, skin->element)); + } + } + + // Textures use texturedir. + { + const mjsTexture* tex = + mjs_asTexture(mjs_firstElement(spec, mjOBJ_TEXTURE)); + while (tex != nullptr) { + if (!tex->file) continue; + process(tex->element, tex->file, /*use_meshdir=*/false, "texture"); + if (tex->cubefiles) { + for (const mjString& file : *tex->cubefiles) { + process(tex->element, &file, /*use_meshdir=*/false, "texture"); + } + } + tex = mjs_asTexture(mjs_nextElement(spec, tex->element)); + } + } + + return archive_entries; +} + +int MjzEncode(const mjSpec* spec, const mjModel* model, const mjVFS* vfs, + mjResource* resource) { + if (!spec || !resource) { + return -1; + } + + const fs::path archive_path(resource->name); + const std::string stem = archive_path.stem().string(); + + // Collect assets and compute archive paths + XML rewrite list. + RewriteMap xml_rewrites; + std::unordered_map assets = + CollectAssets(spec, xml_rewrites); + + // Serialize spec to XML. + char error[1024] = {0}; + int xml_sz = 1024 * 1024; + std::vector xml_buf(xml_sz); + int result = + mj_saveXMLString(spec, xml_buf.data(), xml_sz, error, sizeof(error)); + // mj_saveXMLString returns 0 on success, -1 on failure, or a positive value + // indicating the required buffer size when the buffer is too small. + if (result > 0) { + xml_sz = result + 1; + xml_buf.resize(xml_sz); + result = + mj_saveXMLString(spec, xml_buf.data(), xml_sz, error, sizeof(error)); + } + if (result != 0) { + mju_warning("MJZ encoder: failed to serialize spec to XML: %s", error); + return -1; + } + + // Post-process the XML: apply file attribute rewrites for collision-renamed + // assets. Only colliding entries get rewritten; non-colliding models are + // unchanged. + std::string xml_str(xml_buf.data()); + ApplyRewrites(xml_str, xml_rewrites); + + // NOLINTBEGIN(misc-include-cleaner) - Disable clang-tidy for miniz. + // Initialize zip writer. + mz_zip_archive zip; + std::memset(&zip, 0, sizeof(zip)); + if (!mz_zip_writer_init_heap(&zip, 0, 0)) { + mju_warning("MJZ encoder: failed to init zip writer"); + return -1; + } + + // Add XML to archive. + const std::string xml_name = stem + ".xml"; + if (!mz_zip_writer_add_mem(&zip, xml_name.c_str(), xml_str.data(), + xml_str.size(), MZ_DEFAULT_COMPRESSION)) { + mju_warning("MJZ encoder: failed to add XML to archive"); + mz_zip_writer_end(&zip); + return -1; + } + + // Pack each asset file into the archive using the unique archive paths. + for (const auto& [archive_entry, entry] : assets) { + mjResource* res = mju_openResource(entry.source_dir.string().c_str(), + entry.disk_path.string().c_str(), vfs, + error, sizeof(error)); + if (!res) { + mju_warning("MJZ encoder: failed to open resource '%s' (dir='%s'): %s", + entry.disk_path.c_str(), entry.source_dir.c_str(), error); + continue; + } + + const void* buf = nullptr; + int nbytes = mju_readResource(res, &buf); + if (nbytes > 0 && buf) { + if (!mz_zip_writer_add_mem(&zip, archive_entry.c_str(), buf, nbytes, + MZ_DEFAULT_COMPRESSION)) { + mju_warning("MJZ encoder: failed to write '%s' to archive", + archive_entry.c_str()); + } + } + mju_closeResource(res); + } + + // Finalize archive. + void* archive_buf = nullptr; + size_t archive_size = 0; + if (!mz_zip_writer_finalize_heap_archive(&zip, &archive_buf, &archive_size)) { + mju_warning("MJZ encoder: failed to finalize archive"); + mz_zip_writer_end(&zip); + return -1; + } + mz_zip_writer_end(&zip); + // NOLINTEND(misc-include-cleaner) - End clang-tidy disable for miniz. + + resource->data = archive_buf; + + return static_cast(archive_size); +} + +void MjzCloseResource(mjResource* resource) { + if (resource && resource->data) { + std::free(resource->data); + resource->data = nullptr; + } +} +} // namespace + +mjPLUGIN_LIB_INIT(mjz_encoder) { + mjpEncoder encoder; + mjp_defaultEncoder(&encoder); + encoder.content_type = "application/zip"; + encoder.extension = ".mjz|.zip"; + encoder.encode = MjzEncode; + encoder.close_resource = MjzCloseResource; + mjp_registerEncoder(&encoder); +} diff --git a/test/xml/mjz/CMakeLists.txt b/test/xml/mjz/CMakeLists.txt index 721be4fc..718cf9df 100644 --- a/test/xml/mjz/CMakeLists.txt +++ b/test/xml/mjz/CMakeLists.txt @@ -17,3 +17,7 @@ mujoco_test(mjz_api_test) mujoco_test( mjz_decoder_test ) + +mujoco_test( + mjz_encoder_test +) diff --git a/test/xml/mjz/mjz_encoder_test.cc b/test/xml/mjz/mjz_encoder_test.cc new file mode 100644 index 00000000..c9cd1ff1 --- /dev/null +++ b/test/xml/mjz/mjz_encoder_test.cc @@ -0,0 +1,1105 @@ +// Copyright 2026 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 the MJZ encoder plugin. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include "src/xml/xml_numeric_format.h" +#include "test/fixture.h" + +namespace mujoco { + +namespace fs = std::filesystem; +using ::testing::NotNull; + +static mjSpec* MakeSimpleSpec() { + mjSpec* s = mj_makeSpec(); + mjsBody* world = mjs_findBody(s, "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; + return s; +} + +static std::string SanitizePathForTestName(const std::string& path) { + fs::path p(path); + std::string name = p.stem().string(); + std::string full_name = p.string(); + size_t pos = full_name.find("third_party/mujoco"); + if (pos != std::string::npos) { + full_name = full_name.substr(pos); + } + std::string sanitized; + for (char c : full_name) { + if (std::isalnum(c)) { + sanitized += c; + } else { + sanitized += '_'; + } + } + return sanitized; +} + +std::vector GetWriteReadTestModels() { + std::vector models; + std::string ext(".xml"); + for (const auto& path : {GetTestDataFilePath("."), GetModelPath(".")}) { + for (const auto& p : std::filesystem::recursive_directory_iterator(path)) { + if (p.path().extension() == ext) { + std::string xml = p.path().string(); + if ( // if file is meant to fail, skip it + absl::StrContains(xml, "malformed_") || + absl::StrContains(xml, "_fail") || + // exclude files that are too slow to load + absl::StrContains(xml, "cow") || absl::StrContains(xml, "gmsh_") || + absl::StrContains(xml, "shark_") || + absl::StrContains(xml, "perf") || + // exclude files that fail the comparison test + absl::StrContains(xml, "rfcamera") || + absl::StrContains(xml, "tactile") || + absl::StrContains(xml, "makemesh") || + absl::StrContains(xml, "many_dependencies") || + absl::StrContains(xml, "usd") || + absl::StrContains(xml, "torus_maxhull") || + absl::StrContains(xml, "fitmesh_") || + absl::StrContains(xml, "lengthrange") || + absl::StrContains(xml, "hfield_xml") || + absl::StrContains(xml, "fromto_convex") || + absl::StrContains(xml, "cube_skin") || + absl::StrContains(xml, "cube_3x3x3") || + // exclude files that fail since we do not save pinned flex nodes + absl::StrContains(xml, "gripper_trilinear") || + absl::StrContains(xml, "strain") || + // exclude mjz testdata with VFS files + absl::StrContains(xml, "mixed_test")) { + continue; + } + models.push_back(xml); + } + } + } + return models; +} + +using MjzEncoderTest = MujocoTest; + +TEST_F(MjzEncoderTest, EncoderIsRegistered) { + const mjpEncoder* enc = mjp_findEncoder("model.mjz", nullptr); + ASSERT_THAT(enc, testing::NotNull()); + ASSERT_THAT(enc->encode, testing::NotNull()); +} + +TEST_F(MjzEncoderTest, EncodeReturnsPositiveByteCount) { + mjSpec* spec = MakeSimpleSpec(); + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + const mjpEncoder* enc = mjp_findEncoder("model.mjz", nullptr); + ASSERT_THAT(enc, testing::NotNull()); + + mjResource resource = {}; + resource.name = const_cast("model.mjz"); + + int nbytes = enc->encode(spec, model, nullptr, &resource); + EXPECT_GT(nbytes, 0); + EXPECT_THAT(resource.data, testing::NotNull()); + + std::free(resource.data); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, RoundTrip) { + mjSpec* spec = MakeSimpleSpec(); + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + const std::string tmp_path = + testing::TempDir() + "/mjz_encoder_roundtrip.mjz"; + + char error[1024] = {0}; + int nbytes = mj_encode(spec, model, tmp_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mjSpec* decoded = + mj_parse(tmp_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()); + + EXPECT_EQ(decoded_model->nbody, model->nbody); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + EXPECT_EQ(decoded_model->njnt, model->njnt); + + std::remove(tmp_path.c_str()); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripFromXmlString) { + const char* xml = R"( + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + const std::string tmp_path = testing::TempDir() + "/xml_string.mjz"; + int nbytes = mj_encode(spec, model, tmp_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mjSpec* decoded = + mj_parse(tmp_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()); + + EXPECT_EQ(decoded_model->nbody, model->nbody); + EXPECT_EQ(decoded_model->njnt, model->njnt); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + std::remove(tmp_path.c_str()); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripPreservesModelCounts) { + const char* xml = R"( + + + + + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + const std::string tmp_path = testing::TempDir() + "/counts.mjz"; + int nbytes = mj_encode(spec, model, tmp_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mjSpec* decoded = + mj_parse(tmp_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()); + + EXPECT_EQ(decoded_model->nbody, model->nbody); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + EXPECT_EQ(decoded_model->njnt, model->njnt); + EXPECT_EQ(decoded_model->nq, model->nq); + EXPECT_EQ(decoded_model->nv, model->nv); + + std::remove(tmp_path.c_str()); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripWithMeshFromVfs) { + const char* xml = R"( + + + + + + + + + + + )"; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mj_addFileVFS( + &vfs, nullptr, + GetTestDataFilePath("xml/mjz/testdata/disk_mesh/box.obj").c_str()); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, &vfs, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + auto* mesh = mjs_asMesh(mjs_firstElement(spec, mjOBJ_MESH)); + ASSERT_THAT(mesh, testing::NotNull()); + ASSERT_THAT(mesh->file, testing::NotNull()); + + mjModel* model = mj_compile(spec, &vfs); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(spec); + + const std::string tmp_path = testing::TempDir() + "/vfs_mesh.mjz"; + int nbytes = mj_encode(spec, model, tmp_path.c_str(), nullptr, &vfs, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Reset VFS + mj_deleteVFS(&vfs); + mj_defaultVFS(&vfs); + + mjSpec* decoded = + mj_parse(tmp_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + std::remove(tmp_path.c_str()); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripWithMeshFromDisk) { + std::string xml_path = + GetTestDataFilePath("xml/mjz/testdata/disk_mesh/model.xml"); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXML(xml_path.c_str(), nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + fs::path tmpdir = fs::path(testing::TempDir()) / "disk_mesh_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, RoundTripWithMeshInSubdirectory) { + std::string xml_path = + GetTestDataFilePath("xml/mjz/testdata/subdir_mesh/model.xml"); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXML(xml_path.c_str(), nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + fs::path tmpdir = fs::path(testing::TempDir()) / "subdir_mesh_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, RoundTripWithMeshdir) { + std::string xml_path = + GetTestDataFilePath("xml/mjz/testdata/meshdir_test/model.xml"); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXML(xml_path.c_str(), nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + fs::path tmpdir = fs::path(testing::TempDir()) / "meshdir_test_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, RoundTripWithInclude) { + std::string xml_path = + GetTestDataFilePath("xml/mjz/testdata/include_test/parent.xml"); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXML(xml_path.c_str(), nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + fs::path tmpdir = fs::path(testing::TempDir()) / "include_test_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()); + + EXPECT_EQ(decoded_model->nbody, model->nbody); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + fs::remove_all(tmpdir); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripTransitiveInclude) { + std::string xml_path = + GetTestDataFilePath("xml/mjz/testdata/transitive_test/parent.xml"); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXML(xml_path.c_str(), nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + fs::path tmpdir = fs::path(testing::TempDir()) / "transitive_test_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &vfs); + ASSERT_THAT(decoded_model, testing::NotNull()); + + EXPECT_EQ(decoded_model->nbody, model->nbody); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + fs::remove_all(tmpdir); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, RoundTripMixedDiskAndVfs) { + static const char* xml = R"( + + + + + + + + + + + + + + + + )"; + + mjVFS vfs; + mj_defaultVFS(&vfs); + mj_addFileVFS( + &vfs, nullptr, + GetTestDataFilePath("xml/mjz/testdata/disk_mesh/box.obj").c_str()); + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, &vfs, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + // Fix the on disk mesh path + mjsMesh* mesh = mjs_asMesh(mjs_findElement(spec, mjOBJ_MESH, "disk_box")); + ASSERT_THAT(mesh, testing::NotNull()); + mjs_setString( + mesh->file, + GetTestDataFilePath("xml/mjz/testdata/mixed_test/disk_box.obj").c_str()); + + mjModel* model = mj_compile(spec, &vfs); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(spec); + + fs::path tmpdir = fs::path(testing::TempDir()) / "mixed_test_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, &vfs, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + EXPECT_EQ(decoded_model->ngeom, model->ngeom); + + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); + mj_deleteVFS(&vfs); +} + +TEST_F(MjzEncoderTest, AttachedSpecsWithCollidingAssetNames) { + std::string child_a_xml = GetTestDataFilePath( + "xml/mjz/testdata/asset_collision_test/child_a/child.xml"); + std::string child_b_xml = GetTestDataFilePath( + "xml/mjz/testdata/asset_collision_test/child_b/child.xml"); + + char error[1024] = {0}; + + // Parse both children from their respective directories. + mjSpec* child_a = + mj_parse(child_a_xml.c_str(), nullptr, nullptr, error, sizeof(error)); + ASSERT_THAT(child_a, testing::NotNull()) << error; + mjSpec* child_b = + mj_parse(child_b_xml.c_str(), nullptr, nullptr, error, sizeof(error)); + ASSERT_THAT(child_b, testing::NotNull()) << error; + + // Create parent spec and attach both children. + mjSpec* parent = mj_makeSpec(); + ASSERT_THAT(parent, testing::NotNull()); + mjsBody* world = mjs_findBody(parent, "world"); + + // Attach child_a with prefix "a_". + mjsFrame* frame_a = mjs_addFrame(world, nullptr); + mjs_attach(frame_a->element, mjs_findBody(child_a, "body")->element, "a_", + ""); + + // Attach child_b with prefix "b_". + mjsFrame* frame_b = mjs_addFrame(world, nullptr); + mjs_attach(frame_b->element, mjs_findBody(child_b, "body")->element, "b_", + ""); + + // Compile the combined model. + mjModel* model = mj_compile(parent, nullptr); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(parent); + EXPECT_EQ(model->nmesh, 2) << "Expected two distinct meshes"; + + // Record the original vertex counts for both meshes. + int mesh0_nvert = model->mesh_vertnum[0]; + int mesh1_nvert = model->mesh_vertnum[1]; + EXPECT_NE(mesh0_nvert, mesh1_nvert) + << "The two meshes should have different vertex counts"; + + // Encode to MJZ. + fs::path tmpdir = fs::path(testing::TempDir()) / "asset_collision_test_out"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + int nbytes = mj_encode(parent, model, out_path.c_str(), nullptr, nullptr, + error, sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Decode from MJZ. + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + // Verify both meshes survived with correct, distinct vertex counts. + EXPECT_EQ(decoded_model->nmesh, 2) << "Decoded model should have two meshes"; + EXPECT_EQ(decoded_model->mesh_vertnum[0], mesh0_nvert) + << "First mesh vertex count mismatch after roundtrip"; + EXPECT_EQ(decoded_model->mesh_vertnum[1], mesh1_nvert) + << "Second mesh vertex count mismatch after roundtrip"; + + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(parent); + mj_deleteSpec(child_a); + mj_deleteSpec(child_b); +} + +// --- Fake resource provider for URI stripping tests --- + +const char* kBoxObj = R"( +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 +)"; + +// Static STL data served by the fake provider. +static std::vector g_fake_data; + +int FakeUriOpen(mjResource* resource) { + if (g_fake_data.empty()) return 0; + resource->data = g_fake_data.data(); + return 1; +} + +int FakeUriRead(mjResource* resource, const void** buffer) { + if (!resource->data) return -1; + *buffer = resource->data; + return static_cast(g_fake_data.size()); +} + +void FakeUriClose(mjResource* resource) { resource->data = nullptr; } + +// Register the fake provider once (idempotent due to AppendIfUnique). +static void EnsureFakeProviderRegistered() { + static bool registered = false; + if (registered) return; + mjpResourceProvider provider = {}; + mjp_defaultResourceProvider(&provider); + provider.prefix = "testmjzenc"; + provider.open = FakeUriOpen; + provider.read = FakeUriRead; + provider.close = FakeUriClose; + mjp_registerResourceProvider(&provider); + registered = true; +} + +TEST_F(MjzEncoderTest, RoundTripStripsUriPrefix) { + EnsureFakeProviderRegistered(); + + // Set up the fake STL data that the provider will serve. + g_fake_data.assign(kBoxObj, kBoxObj + std::strlen(kBoxObj)); + + const char* xml = R"( + + + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + // Compile. + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(spec); + EXPECT_EQ(model->nmesh, 1); + + // Encode to MJZ. + fs::path tmpdir = fs::path(testing::TempDir()) / "uri_strip_test"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Decode from MJZ. + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + // Verify the decoded mesh file attribute does NOT contain the URI prefix. + auto* decoded_mesh = mjs_asMesh(mjs_firstElement(decoded, mjOBJ_MESH)); + ASSERT_THAT(decoded_mesh, testing::NotNull()); + const char* decoded_file = mjs_getString(decoded_mesh->file); + ASSERT_THAT(decoded_file, testing::NotNull()); + EXPECT_FALSE(absl::StrContains(decoded_file, ":")) + << "Decoded mesh file should not contain ':': " << decoded_file; + EXPECT_THAT(decoded_file, testing::HasSubstr("box.obj")); + + // Verify the model compiles correctly from the decoded spec. + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + + // Cleanup. + g_fake_data.clear(); + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, RoundTripStripsUriWithSpecialChars) { + EnsureFakeProviderRegistered(); + + // Set up the fake STL data. + g_fake_data.assign(kBoxObj, kBoxObj + std::strlen(kBoxObj)); + + const char* xml = R"( + + + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()); + + // Encode to MJZ. + fs::path tmpdir = fs::path(testing::TempDir()) / "uri_special_test"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Decode and verify no URI prefix and no special chars in the file attr. + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + // Check the decoded mesh file attribute. + auto* decoded_mesh = mjs_asMesh(mjs_firstElement(decoded, mjOBJ_MESH)); + ASSERT_THAT(decoded_mesh, testing::NotNull()); + const char* decoded_file = mjs_getString(decoded_mesh->file); + ASSERT_THAT(decoded_file, testing::NotNull()); + EXPECT_FALSE(absl::StrContains(decoded_file, "testmjzenc://")) + << "Decoded mesh file should not contain the URI prefix: " + << decoded_file; + EXPECT_FALSE(absl::StrContains(decoded_file, "+")) + << "Decoded mesh file should not contain '+': " << decoded_file; + EXPECT_FALSE(absl::StrContains(decoded_file, "%")) + << "Decoded mesh file should not contain '%': " << decoded_file; + EXPECT_THAT(decoded_file, testing::HasSubstr(".obj")); + + // Verify roundtrip compiles. + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + + g_fake_data.clear(); + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +class MjzEncoderParameterizedTest + : public MujocoTest, + public ::testing::WithParamInterface {}; + +TEST_P(MjzEncoderParameterizedTest, WriteReadCompare) { + mujoco::FullFloatPrecision increase_precision; + + std::string xml = GetParam(); + std::array error; + + mjSpec* s = mj_parseXML(xml.c_str(), nullptr, error.data(), error.size()); + ASSERT_THAT(s, NotNull()) << error.data(); + + mjModel* m = mj_compile(s, nullptr); + ASSERT_THAT(m, NotNull()) << mjs_getError(s); + + const std::string tmp_path = testing::TempDir() + "/mjz_roundtrip.mjz"; + int nbytes = mj_encode(s, m, tmp_path.c_str(), nullptr, nullptr, error.data(), + error.size()); + ASSERT_GT(nbytes, 0) << error.data(); + mj_deleteSpec(s); + + mjVFS vfs; + mj_defaultVFS(&vfs); + + mjSpec* stemp = + mj_parse(tmp_path.c_str(), nullptr, &vfs, error.data(), error.size()); + ASSERT_THAT(stemp, NotNull()) << error.data(); + + mjModel* mtemp = mj_compile(stemp, &vfs); + ASSERT_THAT(mtemp, NotNull()) << mjs_getError(stemp); + mj_deleteSpec(stemp); + + mjtNum tol = 0; + if (absl::StrContains(xml, "belt.xml") || + absl::StrContains(xml, "cable.xml")) { + tol = 1e-13; + } + + // Make paths identical to avoid failure in CompareModel due to localization. + // For example, we might localize "../../y" to "y", which changes the paths. + char* old_m_paths = m->paths; + char* old_mtemp_paths = mtemp->paths; + int old_m_npaths = m->npaths; + int old_mtemp_npaths = mtemp->npaths; + + std::string dummy = "ignored_path"; + int dummy_len = dummy.length() + 1; + + m->paths = (char*)mju_malloc(dummy_len); + mtemp->paths = (char*)mju_malloc(dummy_len); + + std::memcpy(m->paths, dummy.c_str(), dummy_len); + std::memcpy(mtemp->paths, dummy.c_str(), dummy_len); + + m->npaths = dummy_len; + mtemp->npaths = dummy_len; + + std::string field = ""; + mjtNum result = CompareModel(m, mtemp, field); + + // Restore old pointers and sizes so mj_deleteModel can free them correctly! + mju_free(m->paths); + mju_free(mtemp->paths); + m->paths = old_m_paths; + mtemp->paths = old_mtemp_paths; + m->npaths = old_m_npaths; + mtemp->npaths = old_mtemp_npaths; + + EXPECT_LE(result, tol) << "Loaded and saved models are different!\n" + << "Affected file: " << xml << '\n' + << "Different field: " << field << '\n'; + + mj_deleteVFS(&vfs); + mj_deleteModel(mtemp); + mj_deleteModel(m); + std::remove(tmp_path.c_str()); +} + +INSTANTIATE_TEST_SUITE_P(MjzEncoderParameterizedTests, + MjzEncoderParameterizedTest, + ::testing::ValuesIn(GetWriteReadTestModels()), + [](const ::testing::TestParamInfo& info) { + return SanitizePathForTestName(info.param); + }); + +TEST_F(MjzEncoderTest, DuplicateFileReferencesRewrite) { + EnsureFakeProviderRegistered(); + + // Set up the fake data that the provider will serve. + g_fake_data.assign(kBoxObj, kBoxObj + std::strlen(kBoxObj)); + + // Two meshes referencing the same URI-prefixed file. + const char* xml = R"( + + + + + + + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(spec); + + // Encode to MJZ. + fs::path tmpdir = fs::path(testing::TempDir()) / "dup_file_test"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Decode from MJZ. + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + // Both decoded meshes should have sanitized file paths (no URI prefix). + auto* mesh_a = mjs_asMesh(mjs_firstElement(decoded, mjOBJ_MESH)); + ASSERT_THAT(mesh_a, testing::NotNull()); + const char* file_a = mjs_getString(mesh_a->file); + ASSERT_THAT(file_a, testing::NotNull()); + EXPECT_FALSE(absl::StrContains(file_a, ":")) + << "First mesh file should not contain ':': " << file_a; + + auto* mesh_b = mjs_asMesh(mjs_nextElement(decoded, mesh_a->element)); + ASSERT_THAT(mesh_b, testing::NotNull()); + const char* file_b = mjs_getString(mesh_b->file); + ASSERT_THAT(file_b, testing::NotNull()); + EXPECT_FALSE(absl::StrContains(file_b, ":")) + << "Second mesh file should not contain ':': " << file_b; + + // Both should compile correctly. + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + EXPECT_EQ(decoded_model->nmesh, model->nmesh); + + // Cleanup. + g_fake_data.clear(); + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +TEST_F(MjzEncoderTest, CubefileUriRewrite) { + EnsureFakeProviderRegistered(); + + // The fake provider serves arbitrary data; cubefiles need valid PNG data to + // compile textures, but the encoder just packs bytes. We verify archive + // contents and XML rewriting without requiring valid image data. + // Use a minimal valid 1x1 white PNG for each face. + static const unsigned char kTinyPng[] = { + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, + 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xff, 0xff, 0x3f, + 0x00, 0x05, 0xfe, 0x02, 0xfe, 0x0d, 0xef, 0x46, 0xb8, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + }; + g_fake_data.assign( + reinterpret_cast(kTinyPng), + reinterpret_cast(kTinyPng) + sizeof(kTinyPng)); + + const char* xml = R"( + + + + + + + + + + + + )"; + + char error[1024] = {0}; + mjSpec* spec = mj_parseXMLString(xml, nullptr, error, sizeof(error)); + ASSERT_THAT(spec, testing::NotNull()) << error; + + mjModel* model = mj_compile(spec, nullptr); + ASSERT_THAT(model, testing::NotNull()) << mjs_getError(spec); + + // Encode to MJZ. + fs::path tmpdir = fs::path(testing::TempDir()) / "cubefile_test"; + fs::create_directories(tmpdir); + const std::string out_path = (tmpdir / "model.mjz").string(); + int nbytes = mj_encode(spec, model, out_path.c_str(), nullptr, nullptr, error, + sizeof(error)); + ASSERT_GT(nbytes, 0) << error; + + // Decode from MJZ. + mjVFS decode_vfs; + mj_defaultVFS(&decode_vfs); + mjSpec* decoded = + mj_parse(out_path.c_str(), nullptr, &decode_vfs, error, sizeof(error)); + ASSERT_THAT(decoded, testing::NotNull()) << error; + + // Verify the decoded texture's cubefile paths don't contain URI prefix. + auto* decoded_tex = mjs_asTexture(mjs_firstElement(decoded, mjOBJ_TEXTURE)); + ASSERT_THAT(decoded_tex, testing::NotNull()); + ASSERT_THAT(decoded_tex->cubefiles, testing::NotNull()); + for (const mjString& cubefile : *decoded_tex->cubefiles) { + EXPECT_FALSE(absl::StrContains(cubefile, ":")) + << "Cubefile should not contain ':': " << cubefile; + EXPECT_THAT(std::string(cubefile), testing::HasSubstr(".png")); + } + + // Verify the model compiles correctly from the decoded spec. + mjModel* decoded_model = mj_compile(decoded, &decode_vfs); + ASSERT_THAT(decoded_model, testing::NotNull()) << mjs_getError(decoded); + + // Cleanup. + g_fake_data.clear(); + fs::remove_all(tmpdir); + mj_deleteVFS(&decode_vfs); + mj_deleteModel(decoded_model); + mj_deleteSpec(decoded); + mj_deleteModel(model); + mj_deleteSpec(spec); +} + +} // namespace mujoco diff --git a/test/xml/mjz/testdata/asset_collision_test/child_a/child.xml b/test/xml/mjz/testdata/asset_collision_test/child_a/child.xml new file mode 100644 index 00000000..60850688 --- /dev/null +++ b/test/xml/mjz/testdata/asset_collision_test/child_a/child.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/xml/mjz/testdata/asset_collision_test/child_a/mesh.obj b/test/xml/mjz/testdata/asset_collision_test/child_a/mesh.obj new file mode 100644 index 00000000..b4fbc738 --- /dev/null +++ b/test/xml/mjz/testdata/asset_collision_test/child_a/mesh.obj @@ -0,0 +1,20 @@ +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 diff --git a/test/xml/mjz/testdata/asset_collision_test/child_b/child.xml b/test/xml/mjz/testdata/asset_collision_test/child_b/child.xml new file mode 100644 index 00000000..1886f514 --- /dev/null +++ b/test/xml/mjz/testdata/asset_collision_test/child_b/child.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/xml/mjz/testdata/asset_collision_test/child_b/mesh.obj b/test/xml/mjz/testdata/asset_collision_test/child_b/mesh.obj new file mode 100644 index 00000000..dfc38fb3 --- /dev/null +++ b/test/xml/mjz/testdata/asset_collision_test/child_b/mesh.obj @@ -0,0 +1,8 @@ +v 0 0 0 +v 1 0 0 +v 0.5 0.866 0 +v 0.5 0.289 0.816 +f 1 2 3 +f 1 2 4 +f 2 3 4 +f 1 3 4 diff --git a/test/xml/mjz/testdata/disk_mesh/box.obj b/test/xml/mjz/testdata/disk_mesh/box.obj new file mode 100644 index 00000000..b4fbc738 --- /dev/null +++ b/test/xml/mjz/testdata/disk_mesh/box.obj @@ -0,0 +1,20 @@ +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 diff --git a/test/xml/mjz/testdata/disk_mesh/model.xml b/test/xml/mjz/testdata/disk_mesh/model.xml new file mode 100644 index 00000000..e12ba5b8 --- /dev/null +++ b/test/xml/mjz/testdata/disk_mesh/model.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/xml/mjz/testdata/include_test/child.xml b/test/xml/mjz/testdata/include_test/child.xml new file mode 100644 index 00000000..5bd5d122 --- /dev/null +++ b/test/xml/mjz/testdata/include_test/child.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/xml/mjz/testdata/include_test/parent.xml b/test/xml/mjz/testdata/include_test/parent.xml new file mode 100644 index 00000000..22a0b82f --- /dev/null +++ b/test/xml/mjz/testdata/include_test/parent.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/xml/mjz/testdata/meshdir_test/assets/box.obj b/test/xml/mjz/testdata/meshdir_test/assets/box.obj new file mode 100644 index 00000000..b4fbc738 --- /dev/null +++ b/test/xml/mjz/testdata/meshdir_test/assets/box.obj @@ -0,0 +1,20 @@ +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 diff --git a/test/xml/mjz/testdata/meshdir_test/model.xml b/test/xml/mjz/testdata/meshdir_test/model.xml new file mode 100644 index 00000000..c3defdba --- /dev/null +++ b/test/xml/mjz/testdata/meshdir_test/model.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/xml/mjz/testdata/mixed_test/disk_box.obj b/test/xml/mjz/testdata/mixed_test/disk_box.obj new file mode 100644 index 00000000..b4fbc738 --- /dev/null +++ b/test/xml/mjz/testdata/mixed_test/disk_box.obj @@ -0,0 +1,20 @@ +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 diff --git a/test/xml/mjz/testdata/subdir_mesh/meshes/box.obj b/test/xml/mjz/testdata/subdir_mesh/meshes/box.obj new file mode 100644 index 00000000..b4fbc738 --- /dev/null +++ b/test/xml/mjz/testdata/subdir_mesh/meshes/box.obj @@ -0,0 +1,20 @@ +v 0 0 0 +v 1 0 0 +v 1 1 0 +v 0 1 0 +v 0 0 1 +v 1 0 1 +v 1 1 1 +v 0 1 1 +f 1 2 3 +f 1 3 4 +f 5 6 7 +f 5 7 8 +f 1 2 6 +f 1 6 5 +f 2 3 7 +f 2 7 6 +f 3 4 8 +f 3 8 7 +f 4 1 5 +f 4 5 8 diff --git a/test/xml/mjz/testdata/subdir_mesh/model.xml b/test/xml/mjz/testdata/subdir_mesh/model.xml new file mode 100644 index 00000000..e3e001df --- /dev/null +++ b/test/xml/mjz/testdata/subdir_mesh/model.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/xml/mjz/testdata/transitive_test/child.xml b/test/xml/mjz/testdata/transitive_test/child.xml new file mode 100644 index 00000000..23724726 --- /dev/null +++ b/test/xml/mjz/testdata/transitive_test/child.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/xml/mjz/testdata/transitive_test/grandchild.xml b/test/xml/mjz/testdata/transitive_test/grandchild.xml new file mode 100644 index 00000000..7f209770 --- /dev/null +++ b/test/xml/mjz/testdata/transitive_test/grandchild.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/xml/mjz/testdata/transitive_test/parent.xml b/test/xml/mjz/testdata/transitive_test/parent.xml new file mode 100644 index 00000000..3780230f --- /dev/null +++ b/test/xml/mjz/testdata/transitive_test/parent.xml @@ -0,0 +1,8 @@ + + + + + + + +