diff --git a/doc/includes/references.h b/doc/includes/references.h index b494a351..4a2344f3 100644 --- a/doc/includes/references.h +++ b/doc/includes/references.h @@ -3181,6 +3181,7 @@ mjtNum mju_rayFlex(const mjModel* m, const mjData* d, int flex_layer, mjtByte fl const mjtNum* pnt, const mjtNum* vec, int vertid[1]); mjtNum mju_raySkin(int nface, int nvert, const int* face, const float* vert, const mjtNum pnt[3], const mjtNum vec[3], int vertid[1]); +void mju_getXMLDependencies(const char* filename, mjStringVec* dependencies); void mjv_defaultCamera(mjvCamera* cam); void mjv_defaultFreeCamera(const mjModel* m, mjvCamera* cam); void mjv_defaultPerturb(mjvPerturb* pert); diff --git a/include/mujoco/mujoco.h b/include/mujoco/mujoco.h index 1809c0b2..f971ae3c 100644 --- a/include/mujoco/mujoco.h +++ b/include/mujoco/mujoco.h @@ -644,6 +644,10 @@ MJAPI mjtNum mju_rayFlex(const mjModel* m, const mjData* d, int flex_layer, mjtB MJAPI mjtNum mju_raySkin(int nface, int nvert, const int* face, const float* vert, const mjtNum pnt[3], const mjtNum vec[3], int vertid[1]); +//---------------------------------- Dependencies -------------------------------------------------- + +// Given MJCF filename, fills dependencies with a list of all other files it depends on. +MJAPI void mju_getXMLDependencies(const char* filename, mjStringVec* dependencies); //---------------------------------- Interaction --------------------------------------------------- diff --git a/python/mujoco/bindings_test.py b/python/mujoco/bindings_test.py index eb8ac9d8..90ac1302 100644 --- a/python/mujoco/bindings_test.py +++ b/python/mujoco/bindings_test.py @@ -16,6 +16,7 @@ import contextlib import copy +from etils import epath import pickle import sys @@ -1658,6 +1659,16 @@ Euler integrator, semi-implicit in velocity. model = mujoco.MjModel.from_xml_string(TEST_XML_TEXTURE) self.assertEqual(model.tex('tex').data.shape, (512, 512, 3)) + def test_xml_dependencies(self): + model_path = str(epath.resource_path("mujoco") / "testdata" / "msh.xml") + msh_path =str(epath.resource_path("mujoco") / "testdata" / "abdomen_1_body.msh") + + model_path = model_path.replace('\\', '/') + msh_path = msh_path.replace('\\', '/') + dependencies = mujoco.mju_getXMLDependencies(model_path) + self.assertIn(model_path, dependencies) + self.assertIn(msh_path, dependencies) + def _assert_attributes_equal(self, actual_obj, expected_obj, attr_to_compare): for name in attr_to_compare: actual_value = getattr(actual_obj, name) diff --git a/python/mujoco/functions.cc b/python/mujoco/functions.cc index 2968cfbd..480af1fc 100644 --- a/python/mujoco/functions.cc +++ b/python/mujoco/functions.cc @@ -99,6 +99,14 @@ PYBIND11_MODULE(_functions, pymodule) { return std::string(buffer.get(), out_length); }); + DEF_WITH_OMITTED_PY_ARGS(traits::mju_getXMLDependencies, + "dependencies")( + pymodule, [](const char* filename){ + mjStringVec dependencies; + InterceptMjErrors(::mju_getXMLDependencies)(filename, &dependencies); + return dependencies; + }); + // Main simulation pymodule.def( "mj_step", diff --git a/python/mujoco/introspect/functions.py b/python/mujoco/introspect/functions.py index 33ac3921..fb042ae2 100644 --- a/python/mujoco/introspect/functions.py +++ b/python/mujoco/introspect/functions.py @@ -3925,6 +3925,26 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([ ), doc='Intersect ray with skin, return nearest distance or -1 if no intersection, and also output nearest vertex id.', # pylint: disable=line-too-long )), + ('mju_getXMLDependencies', + FunctionDecl( + name='mju_getXMLDependencies', + return_type=ValueType(name='void'), + parameters=( + FunctionParameterDecl( + name='filename', + type=PointerType( + inner_type=ValueType(name='char', is_const=True), + ), + ), + FunctionParameterDecl( + name='dependencies', + type=PointerType( + inner_type=ValueType(name='mjStringVec'), + ), + ), + ), + doc='Given MJCF filename, fills dependencies with a list of all other files it depends on.', # pylint: disable=line-too-long + )), ('mjv_defaultCamera', FunctionDecl( name='mjv_defaultCamera', diff --git a/src/xml/xml_util.cc b/src/xml/xml_util.cc index 407fc30a..cb08a494 100644 --- a/src/xml/xml_util.cc +++ b/src/xml/xml_util.cc @@ -26,8 +26,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -104,8 +106,110 @@ FilePath ResolveFilePath(XMLElement* e, const FilePath& filename, return FilePath(path) + filename; } -} // namespace +void AccumulateFiles(std::unordered_set &files, + tinyxml2::XMLElement *root, const FilePath &model_dir) { + std::optional asset_dir; + std::optional mesh_dir; + std::optional texture_dir; + std::set include_and_model_files; + std::set texture_files; + std::set mesh_files; + std::set hfield_files; + auto accumulate_files = [&](const std::set &candidate_files, + std::optional prefix) { + for (const auto &file : candidate_files) { + FilePath file_with_prefix = !prefix.has_value() ? FilePath(file) : prefix.value() + FilePath(file); + if (file_with_prefix.IsAbs()) { + files.insert(file_with_prefix.Str()); + } else { + // Else insert dir_path / prefix / file. + auto full_path = model_dir + file_with_prefix; + files.insert(full_path.Str()); + } + } + }; + + std::stack elements; + elements.push(root); + while (!elements.empty()) { + tinyxml2::XMLElement *elem = elements.top(); + elements.pop(); + + if (!std::strcmp(elem->Value(), "include") || + !std::strcmp(elem->Value(), "model")) { + auto file_attr = mjXUtil::ReadAttrFile(elem, "file", nullptr); + if (file_attr.has_value()) { + include_and_model_files.insert(file_attr->Str()); + // Neither of these elements should have children. + continue; + } + } else if (!std::strcmp(elem->Value(), "compiler")) { + auto assetdir_str = mjXUtil::ReadAttrStr(elem, "assetdir", false); + if (assetdir_str.has_value()) asset_dir = FilePath(assetdir_str.value()); + auto meshdir_str = mjXUtil::ReadAttrStr(elem, "meshdir", false); + if (meshdir_str.has_value()) mesh_dir = FilePath(meshdir_str.value()); + auto texturedir_str = mjXUtil::ReadAttrStr(elem, "texturedir", false); + if (texturedir_str.has_value()) texture_dir = FilePath(texturedir_str.value()); + + // compiler elements don't have children. + continue; + } else if (!std::strcmp(elem->Value(), "mesh") || + !std::strcmp(elem->Value(), "flexcomp") || + !std::strcmp(elem->Value(), "skin")) { + // mesh elements don't have children. + auto file_attr = mjXUtil::ReadAttrFile(elem, "file", nullptr); + if (file_attr.has_value()) { + mesh_files.insert(file_attr->Str()); + } + continue; + } else if (!std::strcmp(elem->Value(), "hfield")) { + // hfield elements don't have children. + auto file_attr = mjXUtil::ReadAttrFile(elem, "file", nullptr); + if (file_attr.has_value()) { + hfield_files.insert(file_attr->Str()); + } + continue; + } else if (!std::strcmp(elem->Value(), "texture")) { + static const char *attributes[] = {"file", "fileright", "fileup", + "fileleft", "filedown", "filefront", + "fileback"}; + for (const auto &attribute : attributes) { + auto file_attr = mjXUtil::ReadAttrFile(elem, attribute, nullptr); + if (file_attr.has_value()) { + texture_files.insert(file_attr->Str()); + } + } + } + + tinyxml2::XMLElement *child = elem->FirstChildElement(); + while (child) { + elements.push(child); + child = child->NextSiblingElement(); + } + } + + // TODO(shaves): When we have resource decoders implemented they should have a + // "get dependencies" function to call here. For non XML types we assume they + // have no dependencies here. + + // First resolve all dependent XML files. + for (const auto &file : include_and_model_files) { + mjStringVec subdeps; + FilePath full_path = model_dir + FilePath(file); + mju_getXMLDependencies(full_path.Str().c_str(), &subdeps); + for (const auto &subdep : subdeps) { + files.insert(subdep); + } + } + // Then for each non MJCF resource file, add them to the set of files using their respective + // compiler prefixes (if they exist). + accumulate_files(texture_files, + texture_dir.has_value() ? texture_dir : asset_dir); + accumulate_files(mesh_files, mesh_dir.has_value() ? mesh_dir : asset_dir); + accumulate_files(hfield_files, asset_dir); +} +} //---------------------------------- utility functions --------------------------------------------- @@ -117,7 +221,38 @@ void mjCopyError(char* dst, const char* src, int maxlen) { } } +void mju_getXMLDependencies(const char* filename, mjStringVec* dependencies) { + // load XML file or parse string + tinyxml2::XMLDocument doc; + doc.LoadFile(filename); + // error checking + if (doc.Error()) { + mju_error("Problem reading XML file '%s': %s", filename, doc.ErrorStr()); + } + + // get top-level element + tinyxml2::XMLElement *root = doc.RootElement(); + if (!root) { + mju_error("XML root element not found"); + } + std::unordered_set files = {filename}; + + std::optional model_dir = std::nullopt; + mjResource *resource = mju_openResource("", filename, nullptr, + nullptr, 0); + if (resource != nullptr) { + const char* dir; + int ndir; + mju_getResourceDir(resource, &dir, &ndir); + model_dir = FilePath(std::string(dir, ndir)); + mju_closeResource(resource); + } + // Get file references from include and model tags. + AccumulateFiles(files, root, model_dir.value()); + + *dependencies = {files.begin(), files.end()}; +} // error constructor mjXError::mjXError(const XMLElement* elem, const char* msg, const char* str, int pos) { diff --git a/test/xml/CMakeLists.txt b/test/xml/CMakeLists.txt index 91753174..2815dcb3 100644 --- a/test/xml/CMakeLists.txt +++ b/test/xml/CMakeLists.txt @@ -16,6 +16,8 @@ mujoco_test(xml_api_test) mujoco_test(xml_native_reader_test) +mujoco_test(xml_utils_test) + mujoco_test( xml_native_writer_test ADDITIONAL_LINK_LIBRARIES diff --git a/test/xml/testdata/many_dependencies.xml b/test/xml/testdata/many_dependencies.xml new file mode 100644 index 00000000..97d1c9e7 --- /dev/null +++ b/test/xml/testdata/many_dependencies.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/xml/testdata/meshes/cube.skn b/test/xml/testdata/meshes/cube.skn new file mode 100644 index 00000000..498a7926 Binary files /dev/null and b/test/xml/testdata/meshes/cube.skn differ diff --git a/test/xml/testdata/meshes/flex.obj b/test/xml/testdata/meshes/flex.obj new file mode 100644 index 00000000..a482dcae --- /dev/null +++ b/test/xml/testdata/meshes/flex.obj @@ -0,0 +1,39 @@ +# Simple Cube - OBJ File + +# Vertices (v) +# 8 corners of the cube + +# Back Face +v -1.0 -1.0 -1.0 +v 1.0 -1.0 -1.0 +v 1.0 1.0 -1.0 +v -1.0 1.0 -1.0 + +# Front Face +v -1.0 -1.0 1.0 +v 1.0 -1.0 1.0 +v 1.0 1.0 1.0 +v -1.0 1.0 1.0 + +# Faces (f) +# A cube has 6 faces. Each face is a quad, defined by 4 vertices. +# The vertices are numbered according to their order of appearance above, starting from 1. +# The order defines the face normal (front-facing) based on the right-hand rule. + +# Back Face (v1, v2, v3, v4) +f 1 2 3 4 + +# Right Face (v2, v6, v7, v3) +f 2 6 7 3 + +# Front Face (v5, v8, v7, v6) - Note the ordering for correct normal +f 5 8 7 6 + +# Left Face (v1, v4, v8, v5) +f 1 4 8 5 + +# Top Face (v4, v3, v7, v8) +f 4 3 7 8 + +# Bottom Face (v1, v5, v6, v2) +f 1 5 6 2 diff --git a/test/xml/testdata/meshes/mesh1.obj b/test/xml/testdata/meshes/mesh1.obj new file mode 100644 index 00000000..a482dcae --- /dev/null +++ b/test/xml/testdata/meshes/mesh1.obj @@ -0,0 +1,39 @@ +# Simple Cube - OBJ File + +# Vertices (v) +# 8 corners of the cube + +# Back Face +v -1.0 -1.0 -1.0 +v 1.0 -1.0 -1.0 +v 1.0 1.0 -1.0 +v -1.0 1.0 -1.0 + +# Front Face +v -1.0 -1.0 1.0 +v 1.0 -1.0 1.0 +v 1.0 1.0 1.0 +v -1.0 1.0 1.0 + +# Faces (f) +# A cube has 6 faces. Each face is a quad, defined by 4 vertices. +# The vertices are numbered according to their order of appearance above, starting from 1. +# The order defines the face normal (front-facing) based on the right-hand rule. + +# Back Face (v1, v2, v3, v4) +f 1 2 3 4 + +# Right Face (v2, v6, v7, v3) +f 2 6 7 3 + +# Front Face (v5, v8, v7, v6) - Note the ordering for correct normal +f 5 8 7 6 + +# Left Face (v1, v4, v8, v5) +f 1 4 8 5 + +# Top Face (v4, v3, v7, v8) +f 4 3 7 8 + +# Bottom Face (v1, v5, v6, v2) +f 1 5 6 2 diff --git a/test/xml/testdata/parent_model.xml b/test/xml/testdata/parent_model.xml new file mode 100644 index 00000000..f0c112b8 --- /dev/null +++ b/test/xml/testdata/parent_model.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/test/xml/xml_native_writer_test.cc b/test/xml/xml_native_writer_test.cc index 991d3018..aa89b647 100644 --- a/test/xml/xml_native_writer_test.cc +++ b/test/xml/xml_native_writer_test.cc @@ -1388,6 +1388,7 @@ TEST_F(XMLWriterTest, WriteReadCompare) { // exclude files that fail the comparison test absl::StrContains(p.path().string(), "tactile") || absl::StrContains(p.path().string(), "makemesh") || + absl::StrContains(p.path().string(), "many_dependencies") || absl::StrContains(p.path().string(), "usd") || absl::StrContains(p.path().string(), "torus_maxhull") || absl::StrContains(p.path().string(), "fitmesh_") || diff --git a/test/xml/xml_utils_test.cc b/test/xml/xml_utils_test.cc new file mode 100644 index 00000000..c820753d --- /dev/null +++ b/test/xml/xml_utils_test.cc @@ -0,0 +1,46 @@ +// 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. + + +#include +#include +#include + +#include +#include +#include "test/fixture.h" + +namespace mujoco { +namespace { + +TEST_F(MujocoTest, GetXMLDependenciesTest) { + static const std::vector kModelPaths = { + GetTestDataFilePath("xml/testdata/many_dependencies.xml"), + GetTestDataFilePath("xml/testdata/parent_model.xml"), + GetTestDataFilePath("xml/testdata/child.xml"), + GetTestDataFilePath("xml/testdata/meshes/mesh1.obj"), + GetTestDataFilePath("xml/testdata/meshes/flex.obj"), + GetTestDataFilePath("xml/testdata/meshes/cube.skn"), + }; + + mjStringVec dependencies; + mju_getXMLDependencies(kModelPaths[0].c_str(), &dependencies); + std::set dependency_set{dependencies.begin(), + dependencies.end()}; + std::set expected_dependency_set{kModelPaths.begin(), + kModelPaths.end()}; + EXPECT_EQ(dependency_set, expected_dependency_set); +} +} // namespace +} // namespace mujoco diff --git a/unity/Runtime/Bindings/MjBindings.cs b/unity/Runtime/Bindings/MjBindings.cs index dc791081..0075cbb7 100644 --- a/unity/Runtime/Bindings/MjBindings.cs +++ b/unity/Runtime/Bindings/MjBindings.cs @@ -6757,6 +6757,9 @@ public static unsafe extern double mju_rayFlex(mjModel_* m, mjData_* d, int flex [DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] public static unsafe extern double mju_raySkin(int nface, int nvert, int* face, float* vert, double* pnt, double* vec, int* vertid); +[DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] +public static unsafe extern void mju_getXMLDependencies([MarshalAs(UnmanagedType.LPStr)]string filename, void* dependencies); + [DllImport("mujoco", CallingConvention = CallingConvention.Cdecl)] public static unsafe extern void mjv_defaultCamera(mjvCamera_* cam);