From 644b42bee39348a456202840bf6cbe84200dbab0 Mon Sep 17 00:00:00 2001 From: Google DeepMind Date: Wed, 26 Mar 2025 05:35:54 -0700 Subject: [PATCH] Add mujoco/experimental and move USD work there. PiperOrigin-RevId: 740730698 Change-Id: If9c68798cda502d6d7632906a034437e2557802e --- .../usd/plugins/mjcf/mjcf_file_format.cc | 201 ++++ .../usd/plugins/mjcf/mjcf_file_format.h | 85 ++ .../usd/plugins/mjcf/mujoco_to_usd.cc | 1000 +++++++++++++++++ .../usd/plugins/mjcf/mujoco_to_usd.h | 32 + .../usd/plugins/mjcf/plugInfo.json | 25 + src/experimental/usd/plugins/mjcf/utils.cc | 199 ++++ src/experimental/usd/plugins/mjcf/utils.h | 127 +++ test/experimental/usd/plugins/mjcf/fixture.cc | 64 ++ test/experimental/usd/plugins/mjcf/fixture.h | 67 ++ .../usd/plugins/mjcf/mjcf_file_format_test.cc | 387 +++++++ .../usd/plugins/mjcf/testdata/materials.xml | 15 + .../usd/plugins/mjcf/testdata/mesh_obj.xml | 10 + .../mjcf/testdata/meshes/tetrahedron.obj | 16 + .../plugins/mjcf/testdata/textures/cube.png | Bin 0 -> 6888 bytes 14 files changed, 2228 insertions(+) create mode 100644 src/experimental/usd/plugins/mjcf/mjcf_file_format.cc create mode 100644 src/experimental/usd/plugins/mjcf/mjcf_file_format.h create mode 100644 src/experimental/usd/plugins/mjcf/mujoco_to_usd.cc create mode 100644 src/experimental/usd/plugins/mjcf/mujoco_to_usd.h create mode 100644 src/experimental/usd/plugins/mjcf/plugInfo.json create mode 100644 src/experimental/usd/plugins/mjcf/utils.cc create mode 100644 src/experimental/usd/plugins/mjcf/utils.h create mode 100644 test/experimental/usd/plugins/mjcf/fixture.cc create mode 100644 test/experimental/usd/plugins/mjcf/fixture.h create mode 100644 test/experimental/usd/plugins/mjcf/mjcf_file_format_test.cc create mode 100644 test/experimental/usd/plugins/mjcf/testdata/materials.xml create mode 100644 test/experimental/usd/plugins/mjcf/testdata/mesh_obj.xml create mode 100644 test/experimental/usd/plugins/mjcf/testdata/meshes/tetrahedron.obj create mode 100644 test/experimental/usd/plugins/mjcf/testdata/textures/cube.png diff --git a/src/experimental/usd/plugins/mjcf/mjcf_file_format.cc b/src/experimental/usd/plugins/mjcf/mjcf_file_format.cc new file mode 100644 index 00000000..8c5858c9 --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/mjcf_file_format.cc @@ -0,0 +1,201 @@ +// 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 "mjcf/mjcf_file_format.h" + +#include +#include +#include +#include + +#include +#include "mjcf/mujoco_to_usd.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "tinyxml2.h" + +PXR_NAMESPACE_OPEN_SCOPE + +TF_DEFINE_PUBLIC_TOKENS(UsdMjcfFileFormatTokens, USD_MJCF_FILE_FORMAT_TOKENS); + +TF_REGISTRY_FUNCTION(TfType) { + SDF_DEFINE_FILE_FORMAT(UsdMjcfFileFormat, SdfFileFormat); +} + +enum ErrorCodes { XmlParsingError }; +TF_REGISTRY_FUNCTION(TfEnum) { + TF_ADD_ENUM_NAME(XmlParsingError, "Error when parsing XML."); +}; + +namespace { + +void ResolveMjcfDependencies(const std::string &xml_string, + const std::string &resolved_path); + +void AccumulateFilesRecursive(std::unordered_set &files, + tinyxml2::XMLElement *elem, + const std::string &resolved_path) { + // get filename + const char *file = elem->Attribute("file"); + + if (file != nullptr) { + auto identifier = pxr::ArGetResolver().CreateIdentifier( + std::string(file), pxr::ArResolvedPath(resolved_path)); + if (!strcasecmp(elem->Value(), "include") || + !strcasecmp(elem->Value(), "model")) { + auto include_resolved_path = pxr::ArGetResolver().Resolve(identifier); + auto asset = pxr::ArGetResolver().OpenAsset(include_resolved_path); + ResolveMjcfDependencies(asset->GetBuffer().get(), include_resolved_path); + + // Neither of these elements should have children. + return; + } + + files.insert(identifier); + } + + if (!strcasecmp(elem->Value(), "texture")) { + static const char *attributes[] = {"fileright", "fileup", "fileleft", + "filedown", "filefront", "fileback"}; + for (const auto &attribute : attributes) { + const char *file = elem->Attribute(attribute); + if (file != nullptr) { + auto identifier = pxr::ArGetResolver().CreateIdentifier( + std::string(file), pxr::ArResolvedPath(resolved_path)); + files.insert(identifier); + } + } + } + + tinyxml2::XMLElement *child = elem->FirstChildElement(); + for (; child; child = child->NextSiblingElement()) { + AccumulateFilesRecursive(files, child, resolved_path); + } +} + +void ResolveMjcfDependencies(const std::string &xml_string, + const std::string &resolved_path) { + // load XML file or parse string + tinyxml2::XMLDocument doc; + doc.Parse(xml_string.c_str()); + + // error checking + if (doc.Error()) { + TF_ERROR(XmlParsingError, "%d:\n%s\n", doc.ErrorID(), doc.ErrorStr()); + return; + } + + // get top-level element + tinyxml2::XMLElement *root = doc.RootElement(); + if (!root) { + TF_ERROR(XmlParsingError, "XML root element not found"); + return; + } + + // Accumulate file dependencies. + std::unordered_set files = {}; + AccumulateFilesRecursive(files, root, resolved_path); + + auto open_asset = [resolved_path](const std::string &identifier) { + pxr::ArGetResolver().OpenAsset(pxr::ArGetResolver().Resolve(identifier)); + }; + // Open all assets in parallel. + pxr::WorkParallelForEach(files.begin(), files.end(), open_asset); +} +} // namespace + +UsdMjcfFileFormat::UsdMjcfFileFormat() + : SdfFileFormat( + UsdMjcfFileFormatTokens->Id, UsdMjcfFileFormatTokens->Version, + UsdMjcfFileFormatTokens->Target, UsdMjcfFileFormatTokens->Id) {} + +UsdMjcfFileFormat::~UsdMjcfFileFormat() {} + +bool UsdMjcfFileFormat::CanRead(const std::string &filePath) const { + auto extension = pxr::TfGetExtension(filePath); + if (extension.empty()) { + return false; + } + + return extension == this->GetFormatId(); +} + +bool UsdMjcfFileFormat::ReadImpl(pxr::SdfLayer *layer, mjSpec *spec) const { + auto data = InitData(layer->GetFileFormatArguments()); + + auto success = mujoco::usd::WriteSpecToData(spec, data); + mj_deleteSpec(spec); + if (!success) { + return false; + } + + _SetLayerData(layer, data); + + return true; +} + +bool UsdMjcfFileFormat::ReadFromString(pxr::SdfLayer *layer, + const std::string &str) const { + std::array error; + mjSpec *spec = + mj_parseXMLString(str.c_str(), nullptr, error.data(), error.size()); + if (spec == nullptr) { + TF_WARN(XmlParsingError, "%s", error.data()); + return false; + } + + return ReadImpl(layer, spec); +} + +bool UsdMjcfFileFormat::Read(pxr::SdfLayer *layer, + const std::string &resolved_path, + bool metadata_only) const { + // Resolved all dependencies so that they are accessible when parsing + // the XML. + std::shared_ptr asset = + pxr::ArGetResolver().OpenAsset(pxr::ArResolvedPath(resolved_path)); + auto buffer = asset->GetBuffer(); + ResolveMjcfDependencies(buffer.get(), resolved_path); + + // Parse to USD. + std::array error; + mjSpec *spec = + mj_parseXML(resolved_path.c_str(), nullptr, error.data(), error.size()); + if (spec == nullptr) { + TF_WARN(XmlParsingError, "%s", error.data()); + return false; + } + return ReadImpl(layer, spec); +} + +bool UsdMjcfFileFormat::WriteToString(const SdfLayer &layer, std::string *str, + const std::string &comment) const { + return SdfFileFormat::FindById(pxr::UsdUsdaFileFormatTokens->Id) + ->WriteToString(layer, str, comment); +} + +PXR_NAMESPACE_CLOSE_SCOPE diff --git a/src/experimental/usd/plugins/mjcf/mjcf_file_format.h b/src/experimental/usd/plugins/mjcf/mjcf_file_format.h new file mode 100644 index 00000000..c5776ec4 --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/mjcf_file_format.h @@ -0,0 +1,85 @@ +// 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. + +#ifndef MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MJCF_FILE_FORMAT_H_ +#define MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MJCF_FILE_FORMAT_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_OPEN_SCOPE + +// The Id should realistically be mjcf, but the id and extension need to match. +// So near term it just assumes the only .xml file we would import is MJCF. +#define USD_MJCF_FILE_FORMAT_TOKENS \ + ((Id, "xml"))((Version, "1.0"))((Target, "usd")) + +TF_DECLARE_PUBLIC_TOKENS(UsdMjcfFileFormatTokens, USD_MJCF_FILE_FORMAT_TOKENS); + +TF_DECLARE_WEAK_AND_REF_PTRS(UsdMjcfFileFormat); + +class UsdMjcfFileFormat : public SdfFileFormat { + public: + using SdfFileFormat::FileFormatArguments; + + // Returns true if 'file' can be read by this format plugin. + USD_API + bool CanRead(const std::string &file) const override; + + // Reads scene description from the asset specified by resolved_path into + // 'layer'. + // + // metadataOnly is a flag that asks for only the layer metadata to be read in, + // which can be much faster if that is all that is required but currently we + // ignore it. + // + // Returns true if the asset is successfully read into layer, false otherwise. + USD_API + bool Read(pxr::SdfLayer *layer, const std::string &resolved_path, + bool metadata_only) const override; + + // Reads data in the string 'str' into 'layer'. + // + // If the file is successfully read, this method returns true. Otherwise, + // false is returned and errors are posted. + USD_API + bool ReadFromString(SdfLayer *layer, const std::string &str) const override; + + // Writes the contents in 'layer' to 'str'. This just forwards to the usda + // implementation. + USD_API + bool WriteToString(const SdfLayer &layer, std::string *str, + const std::string &comment) const override; + + protected: + SDF_FILE_FORMAT_FACTORY_ACCESS; + + UsdMjcfFileFormat(); + virtual ~UsdMjcfFileFormat(); + + private: + // Function delegated to by Read and ReadFromString. + bool ReadImpl(SdfLayer *layer, mjSpec *spec) const; +}; + +PXR_NAMESPACE_CLOSE_SCOPE + +#endif // MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MJCF_FILE_FORMAT_H_ diff --git a/src/experimental/usd/plugins/mjcf/mujoco_to_usd.cc b/src/experimental/usd/plugins/mjcf/mujoco_to_usd.cc new file mode 100644 index 00000000..f1c8e5ee --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/mujoco_to_usd.cc @@ -0,0 +1,1000 @@ +// 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 "mjcf/mujoco_to_usd.h" + +#include +#include +#include +#include +#include + +#include +#include "mjcf/utils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// The ID of the World in mjModel and mjData. +static constexpr int kWorldIndex = 0; + +// Using to satisfy TF_DEFINE_PRIVATE_TOKENS macro below and avoid operating in +// PXR_NS. +using pxr::TfToken; +template +using TfStaticData = pxr::TfStaticData; + +// clang-format off +TF_DEFINE_PRIVATE_TOKENS(kTokens, + // Xform ops + ((body, "Body")) + ((body_name, "mujoco:body_name")) + ((geom, "Geom")) + ((light, "Light")) + ((meshScope, "MeshSources")) + ((materialsScope, "Materials")) + ((surface, "PreviewSurface")) + ((world, "World")) + ((xformOpTransform, "xformOp:transform")) + ((xformOpScale, "xformOp:scale")) + (st) + ((primvarsSt, "primvars:st")) + ((outputsSt, "outputs:st")) + ((inputsSt, "inputs:st")) + ((inputsVarname, "inputs:varname")) + ((inputsFile, "inputs:file")) + ((inputsWrapS, "inputs:wrapS")) + ((inputsWrapT, "inputs:wrapT")) + ((inputsDiffuseColor, "inputs:diffuseColor")) + ((outputsRgb, "outputs:rgb")) + ((inputsMetallic, "inputs:metallic")) + (repeat) + ); + +// Using to satisfy TF_REGISTRY_FUNCTION macro below and avoid operating in PXR_NS. +using pxr::TfEnum; +using pxr::Tf_RegistryStaticInit; +using pxr::Tf_RegistryInit; +using pxr::TfEnum; +template +using Arch_PerLibInit = pxr::Arch_PerLibInit; +enum ErrorCodes { UnsupportedGeomTypeError, MujocoCompilationError }; + +TF_REGISTRY_FUNCTION(pxr::TfEnum) { + TF_ADD_ENUM_NAME(UnsupportedGeomTypeError, "UsdGeom type is unsupported.") + TF_ADD_ENUM_NAME(MujocoCompilationError, "Mujoco spec failed to compile.") +} + +// Usings to satisfy TF_ERROR macro. +using pxr::TfCallContext; +using pxr::Tf_PostErrorHelper; +// clang-format on + +using mujoco::usd::AddAttributeConnection; +using mujoco::usd::AddPrimInherit; +using mujoco::usd::AddPrimReference; +using mujoco::usd::ApplyApiSchema; +using mujoco::usd::CreateAttributeSpec; +using mujoco::usd::CreateClassSpec; +using mujoco::usd::CreatePrimSpec; +using mujoco::usd::CreateRelationshipSpec; +using mujoco::usd::SetAttributeDefault; +using mujoco::usd::SetAttributeMetadata; +using mujoco::usd::SetLayerMetadata; +using mujoco::usd::SetPrimKind; +using mujoco::usd::SetPrimMetadata; + +pxr::GfMatrix4d MujocoPosQuatToTransform(double *pos, double *quat) { + pxr::GfQuatd quaternion = pxr::GfQuatd::GetIdentity(); + quaternion.SetReal(quat[0]); + quaternion.SetImaginary(quat[1], quat[2], quat[3]); + pxr::GfRotation rotation(quaternion); + + pxr::GfVec3d translation(0.0, 0.0, 0.0); + translation.Set(pos[0], pos[1], pos[2]); + + pxr::GfMatrix4d transform; + transform.SetTransform(rotation, translation); + return transform; +} + +} // namespace + +class ModelWriter { + public: + ModelWriter(mjSpec *spec, mjModel *model, pxr::SdfAbstractDataRefPtr &data) + : spec_(spec), model_(model), data_(data), class_path_("/Bad_Path") { + body_paths_ = std::vector(model->nbody); + body_xforms_ = std::vector(model->nbody); + } + ~ModelWriter() { mj_deleteModel(model_); } + + void Write() { + // Create top level class holder. + class_path_ = CreateClassSpec(data_, pxr::SdfPath::AbsoluteRootPath(), + pxr::TfToken("__class__")); + + // Create the world body. + body_paths_[kWorldIndex] = WriteWorldBody(kWorldIndex); + body_xforms_[kWorldIndex] = pxr::GfMatrix4d().SetIdentity(); + + SetLayerMetadata(data_, pxr::SdfFieldKeys->Documentation, + "Generated by mujoco model writer."); + // Mujoco is Z up by default. + SetLayerMetadata(data_, pxr::UsdGeomTokens->upAxis, pxr::UsdGeomTokens->z); + // Mujoco is authored in meters by default. + SetLayerMetadata(data_, pxr::UsdGeomTokens->metersPerUnit, + pxr::UsdGeomLinearUnits::meters); + + // Set the world body to be the default prim for referencing/payloads. + SetLayerMetadata(data_, pxr::SdfFieldKeys->DefaultPrim, + body_paths_[kWorldIndex].GetNameToken()); + + // Author mesh scope + mesh prims to be referenced. + WriteMeshes(); + WriteMaterials(); + WriteBodies(); + } + + private: + mjSpec *spec_; + mjModel *model_; + + // This is a handle to the Sdf data to be written into the generated USD + // layer. + pxr::SdfAbstractDataRefPtr &data_; + // Path to top level class spec that all classes should be children of. + pxr::SdfPath class_path_; + // Mapping from Mujoco body id to SdfPath. + std::vector body_paths_; + // Mapping from Mujoco body id to world space transform. + std::vector body_xforms_; + // Mapping from mesh names to Mesh prim path. + std::unordered_map mesh_paths_; + + // Given a name index and a parent prim path this returns a + // token such that appending it to the parent prim path does not + // identify an existing prim spec. + // + // This is necessary since mujoco does not require names for elements + // so we must differentiate between elements of the same type. + // + // For example: + // + // + // + // + // + // + // + // We expect that the occurrence of this happens little enough that linear + // searching is plenty efficient. + pxr::TfToken GetAvailablePrimName(const std::string base_name, + const pxr::TfToken fallback_name, + const pxr::SdfPath &parent_path) { + const auto valid_base_name = pxr::TfMakeValidIdentifier( + base_name.empty() ? fallback_name : base_name); + std::string name = valid_base_name; + pxr::SdfPath test_path = parent_path.AppendChild(pxr::TfToken(name)); + int count = 1; + while (data_->HasSpec(test_path) && + data_->GetSpecType(test_path) == pxr::SdfSpecType::SdfSpecTypePrim) { + name = pxr::TfStringPrintf("%s_%d", valid_base_name.c_str(), count++); + test_path = parent_path.AppendChild(pxr::TfToken(name)); + } + return pxr::TfToken(name); + } + + // This function, conversely to GetAvailablePrimName will not handle + // collisions. This is useful when looking up a prim path that might exist or + // a path that you know must be unique. + pxr::TfToken GetValidPrimName(const std::string name) { + return pxr::TfToken(pxr::TfMakeValidIdentifier(name)); + } + + struct BodyPathComponents { + pxr::SdfPath parent_path; + pxr::TfToken body_name; + }; + + pxr::SdfPath CreateParentIfNotExists(mjsBody *body, + const pxr::SdfPath &world_path, + pxr::SdfAbstractDataRefPtr &data) { + // To allow for easier scene authoring and modification, we want to + // place MJCF bodies belonging to the same kinematic chain under some + // identity parent Xform prim. This allows users to move the entire + // asset. + // + // We cannot simply recreate the MJCF kinematic tree structure + // because in USD it is assumed that children move rigidly with their + // parents. This is not true in MJCF if you have joints. We could perhaps + // use a more complex heuristic where we evaluate a common tree prefix + // in MJCF that is effectively welded together but for now we choose + // simplicity. + + // In the trivial case where the parent of body is already the world + // body we want to create a parent xform of the same name. + // So if the MJCF has a child of the world body called "root" we will + // create a parent Xform at /World/root and the actual body will be + // created at /World/root/root. + mjsBody *last_parent = body; + mjsBody *parent = mjs_getParent(body->element); + while (mjs_getId(parent->element) != kWorldIndex) { + last_parent = parent; + parent = mjs_getParent(parent->element); + } + + pxr::TfToken last_parent_name = GetValidPrimName(*last_parent->name); + pxr::SdfPath parent_xform_path = world_path.AppendChild(last_parent_name); + if (!data->HasSpec(parent_xform_path)) { + pxr::SdfPath prim_path = CreatePrimSpec( + data, world_path, last_parent_name, pxr::UsdGeomTokens->Xform); + + SetPrimKind(data_, prim_path, pxr::KindTokens->component); + } + return parent_xform_path; + } + + void WriteScaleXformOp(const pxr::SdfPath &prim_path, + const pxr::GfVec3f &scale) { + pxr::SdfPath scale_attr_path = + CreateAttributeSpec(data_, prim_path, kTokens->xformOpScale, + pxr::SdfValueTypeNames->Float3); + SetAttributeDefault(data_, scale_attr_path, scale); + } + + void WriteTransformXformOp(const pxr::SdfPath &prim_path, + const pxr::GfMatrix4d &transform) { + pxr::SdfPath transform_op_path = + CreateAttributeSpec(data_, prim_path, kTokens->xformOpTransform, + pxr::SdfValueTypeNames->Matrix4d); + SetAttributeDefault(data_, transform_op_path, transform); + } + + void WriteXformOpOrder(const pxr::SdfPath &prim_path, + const pxr::VtArray &order) { + pxr::SdfPath xform_op_order_path = + CreateAttributeSpec(data_, prim_path, pxr::UsdGeomTokens->xformOpOrder, + pxr::SdfValueTypeNames->TokenArray); + SetAttributeDefault(data_, xform_op_order_path, order); + } + + void PrependToXformOpOrder(const pxr::SdfPath &prim_path, + const pxr::VtArray &order) { + auto xform_op_order_path = + prim_path.AppendProperty(pxr::UsdGeomTokens->xformOpOrder); + if (!data_->HasSpec(xform_op_order_path)) { + WriteXformOpOrder(prim_path, order); + return; + } + + auto existing_order = + data_->Get(xform_op_order_path, pxr::SdfFieldKeys->Default) + .UncheckedGet>(); + + pxr::VtArray new_order(order.size() + existing_order.size()); + std::copy(order.begin(), order.end(), new_order.begin()); + std::copy(existing_order.begin(), existing_order.end(), + new_order.begin() + order.size()); + + SetAttributeDefault(data_, xform_op_order_path, new_order); + } + + void WriteMesh(const mjsMesh *mesh, const pxr::SdfPath &parent_path) { + auto name = GetAvailablePrimName(*mesh->name, pxr::UsdGeomTokens->Mesh, + parent_path); + pxr::SdfPath subcomponent_path = + CreatePrimSpec(data_, parent_path, name, pxr::UsdGeomTokens->Xform); + pxr::SdfPath mesh_path = + CreatePrimSpec(data_, subcomponent_path, pxr::UsdGeomTokens->Mesh, + pxr::UsdGeomTokens->Mesh); + mesh_paths_[*mesh->name] = subcomponent_path; + + // NOTE: The geometry data taken from the spec is the post-compilation + // data after it has been mjCMesh::Compile'd. So don't be surprised if + // things like user defined vertices have moved due to re-centering to + // CoM and other modifications (see mjCMesh::Process for other xforms). + int mesh_id = mjs_getId(mesh->element); + int vert_start_offset = model_->mesh_vertadr[mesh_id] * 3; + int nvert = model_->mesh_vertnum[mesh_id]; + pxr::VtArray points; + points.reserve(nvert); + for (int i = vert_start_offset; i < vert_start_offset + nvert * 3; i += 3) { + points.emplace_back(&model_->mesh_vert[i]); + } + + pxr::SdfPath points_attr_path = + CreateAttributeSpec(data_, mesh_path, pxr::UsdGeomTokens->points, + pxr::SdfValueTypeNames->Vector3fArray); + SetAttributeDefault(data_, points_attr_path, points); + + // NOTE: nface is never 0. + int nface = model_->mesh_facenum[mesh_id]; + pxr::VtArray faces; + faces.reserve(nface * 3); + int face_start_offset = model_->mesh_faceadr[mesh_id] * 3; + for (int i = face_start_offset; i < face_start_offset + nface * 3; i += 3) { + faces.push_back(model_->mesh_face[i]); + faces.push_back(model_->mesh_face[i + 1]); + faces.push_back(model_->mesh_face[i + 2]); + } + pxr::SdfPath face_vertex_idx_attr_path = CreateAttributeSpec( + data_, mesh_path, pxr::UsdGeomTokens->faceVertexIndices, + pxr::SdfValueTypeNames->IntArray); + SetAttributeDefault(data_, face_vertex_idx_attr_path, faces); + + pxr::VtArray vertex_counts; + for (int i = 0; i < nface; ++i) { + // Mujoco is always triangles. + vertex_counts.push_back(3); + } + pxr::SdfPath face_vertex_counts_attr_path = CreateAttributeSpec( + data_, mesh_path, pxr::UsdGeomTokens->faceVertexCounts, + pxr::SdfValueTypeNames->IntArray); + SetAttributeDefault(data_, face_vertex_counts_attr_path, vertex_counts); + + if (model_->mesh_normalnum[mesh_id]) { + // We have to convert from Mujoco's indexed normals to USD's faceVarying + // normals. + pxr::VtArray normals; + normals.reserve(nface * 3); + int normal_start_adr = model_->mesh_normaladr[mesh_id]; + int face_start_offset = model_->mesh_faceadr[mesh_id] * 3; + for (int i = face_start_offset; i < face_start_offset + nface * 3; ++i) { + int normal_adr = normal_start_adr + model_->mesh_facenormal[i]; + normals.emplace_back(&model_->mesh_normal[normal_adr * 3]); + } + pxr::SdfPath normals_attr_path = + CreateAttributeSpec(data_, mesh_path, pxr::UsdGeomTokens->normals, + pxr::SdfValueTypeNames->Vector3fArray); + SetAttributeDefault(data_, normals_attr_path, normals); + SetAttributeMetadata(data_, normals_attr_path, + pxr::UsdGeomTokens->interpolation, + pxr::UsdGeomTokens->faceVarying); + } + + if (model_->mesh_texcoordnum[mesh_id]) { + // We have to convert from Mujoco's indexed texcoords to USD's faceVarying + // texcoords. + pxr::VtArray texcoords; + texcoords.reserve(nface * 3); + int texcoord_start_adr = model_->mesh_texcoordadr[mesh_id]; + int face_start_offset = model_->mesh_faceadr[mesh_id] * 3; + for (int i = face_start_offset; i < face_start_offset + nface * 3; ++i) { + int texcoord_adr = texcoord_start_adr + model_->mesh_facetexcoord[i]; + // Invert the V coordinate, Mujoco assumes OpenGL 0,0 is top left. + // But USD UVs use image bottom left 0,0 convention. + pxr::GfVec2f uv(&model_->mesh_texcoord[texcoord_adr * 2]); + uv[1] = 1.0f - uv[1]; + texcoords.push_back(uv); + } + + pxr::SdfPath texcoords_attr_path = + CreateAttributeSpec(data_, mesh_path, kTokens->primvarsSt, + pxr::SdfValueTypeNames->TexCoord2fArray); + SetAttributeDefault(data_, texcoords_attr_path, texcoords); + SetAttributeMetadata(data_, texcoords_attr_path, + pxr::UsdGeomTokens->interpolation, + pxr::UsdGeomTokens->faceVarying); + } + + // Default subdivision scheme is catmull clark so explicitly set it + // to none here. + pxr::SdfPath subdivision_scheme_path = CreateAttributeSpec( + data_, mesh_path, pxr::UsdGeomTokens->subdivisionScheme, + pxr::SdfValueTypeNames->Token); + SetAttributeDefault(data_, subdivision_scheme_path, + pxr::UsdGeomTokens->none); + } + + void WriteMeshes() { + // Create a scope for the meshes to keep things organized + pxr::SdfPath scope_path = + CreatePrimSpec(data_, body_paths_[kWorldIndex], kTokens->meshScope, + pxr::UsdGeomTokens->Scope); + + // Make the mesh scope invisible since they will be referenced by the bits + // that should be visible. + SetPrimMetadata(data_, scope_path, pxr::SdfFieldKeys->Active, false); + + mjsMesh *mesh = mjs_asMesh(mjs_firstElement(spec_, mjOBJ_MESH)); + while (mesh) { + WriteMesh(mesh, scope_path); + mesh = mjs_asMesh(mjs_nextElement(spec_, mesh->element)); + } + } + + pxr::SdfPath AddTextureShader(const pxr::SdfPath &material_path, + const char *texture_file) { + // Shader "uvmap" + pxr::SdfPath uvmap_shader_path = + CreatePrimSpec(data_, material_path, pxr::TfToken("uvmap"), + pxr::UsdShadeTokens->Shader); + + pxr::SdfPath uvmap_info_id_attr = CreateAttributeSpec( + data_, uvmap_shader_path, pxr::UsdShadeTokens->infoId, + pxr::SdfValueTypeNames->Token, pxr::SdfVariabilityUniform); + SetAttributeDefault(data_, uvmap_info_id_attr, + pxr::UsdImagingTokens->UsdPrimvarReader_float2); + + pxr::SdfPath uvmap_varname_attr = + CreateAttributeSpec(data_, uvmap_shader_path, kTokens->inputsVarname, + pxr::SdfValueTypeNames->Token); + SetAttributeDefault(data_, uvmap_varname_attr, kTokens->st); + + pxr::SdfPath uvmap_st_output_attr = + CreateAttributeSpec(data_, uvmap_shader_path, kTokens->outputsSt, + pxr::SdfValueTypeNames->Float2); + + // Shader "texture" + pxr::SdfPath texture_shader_path = + CreatePrimSpec(data_, material_path, pxr::TfToken("texture"), + pxr::UsdShadeTokens->Shader); + pxr::SdfPath texture_info_id_attr = CreateAttributeSpec( + data_, texture_shader_path, pxr::UsdShadeTokens->infoId, + pxr::SdfValueTypeNames->Token, pxr::SdfVariabilityUniform); + SetAttributeDefault(data_, texture_info_id_attr, + pxr::UsdImagingTokens->UsdUVTexture); + + pxr::SdfPath texture_file_attr = + CreateAttributeSpec(data_, texture_shader_path, kTokens->inputsFile, + pxr::SdfValueTypeNames->Asset); + SetAttributeDefault(data_, texture_file_attr, + pxr::SdfAssetPath(texture_file)); + + pxr::SdfPath texture_st_input_attr = + CreateAttributeSpec(data_, texture_shader_path, kTokens->inputsSt, + pxr::SdfValueTypeNames->Float2); + AddAttributeConnection(data_, texture_st_input_attr, uvmap_st_output_attr); + + pxr::SdfPath texture_wrap_s_attr = + CreateAttributeSpec(data_, texture_shader_path, kTokens->inputsWrapS, + pxr::SdfValueTypeNames->Token); + SetAttributeDefault(data_, texture_wrap_s_attr, kTokens->repeat); + + pxr::SdfPath texture_wrap_t_attr = + CreateAttributeSpec(data_, texture_shader_path, kTokens->inputsWrapT, + pxr::SdfValueTypeNames->Token); + SetAttributeDefault(data_, texture_wrap_t_attr, kTokens->repeat); + + pxr::SdfPath texture_rgb_output_attr = + CreateAttributeSpec(data_, texture_shader_path, kTokens->outputsRgb, + pxr::SdfValueTypeNames->Float3); + + return texture_rgb_output_attr; + } + + void WriteMaterial(mjsMaterial *material, const pxr::SdfPath &parent_path) { + auto name = GetAvailablePrimName( + *material->name, pxr::UsdShadeTokens->Material, parent_path); + pxr::SdfPath material_path = + CreatePrimSpec(data_, parent_path, name, pxr::UsdShadeTokens->Material); + + // Shader "PreviewSurface" + pxr::SdfPath preview_surface_shader_path = CreatePrimSpec( + data_, material_path, kTokens->surface, pxr::UsdShadeTokens->Shader); + + pxr::SdfPath info_id_attr = CreateAttributeSpec( + data_, preview_surface_shader_path, pxr::UsdShadeTokens->infoId, + pxr::SdfValueTypeNames->Token, pxr::SdfVariabilityUniform); + SetAttributeDefault(data_, info_id_attr, + pxr::UsdImagingTokens->UsdPreviewSurface); + + pxr::SdfPath surface_output_attr = CreateAttributeSpec( + data_, preview_surface_shader_path, pxr::UsdShadeTokens->outputsSurface, + pxr::SdfValueTypeNames->Token); + + pxr::SdfPath displacement_output_attr = + CreateAttributeSpec(data_, preview_surface_shader_path, + pxr::UsdShadeTokens->outputsDisplacement, + pxr::SdfValueTypeNames->Token); + + pxr::SdfPath diffuse_color_attr = CreateAttributeSpec( + data_, preview_surface_shader_path, kTokens->inputsDiffuseColor, + pxr::SdfValueTypeNames->Color3f); + + // Find the main texture if specified. + std::string main_texture_name = (*material->textures)[mjTEXROLE_RGB]; + mjsTexture *main_texture = mjs_asTexture( + mjs_findElement(spec_, mjOBJ_TEXTURE, main_texture_name.c_str())); + if (main_texture) { + // Create the texture shader and connect it to the diffuse color + // attribute. + pxr::SdfPath texture_rgb_output_attr = + AddTextureShader(material_path, main_texture->file->c_str()); + AddAttributeConnection(data_, diffuse_color_attr, + texture_rgb_output_attr); + } else { + // If no texture is specified, use the rgba diffuse color. + SetAttributeDefault(data_, diffuse_color_attr, + pxr::GfVec3f(material->rgba[0], material->rgba[1], + material->rgba[2])); + } + + pxr::SdfPath metallic_attr = CreateAttributeSpec( + data_, preview_surface_shader_path, kTokens->inputsMetallic, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, metallic_attr, material->metallic); + + pxr::SdfPath material_surface_output_attr = CreateAttributeSpec( + data_, material_path, pxr::UsdShadeTokens->outputsSurface, + pxr::SdfValueTypeNames->Token); + + AddAttributeConnection(data_, material_surface_output_attr, + surface_output_attr); + + pxr::SdfPath material_displacement_output_attr = CreateAttributeSpec( + data_, material_path, pxr::UsdShadeTokens->outputsDisplacement, + pxr::SdfValueTypeNames->Token); + + AddAttributeConnection(data_, material_displacement_output_attr, + displacement_output_attr); + } + + void WriteMaterials() { + // Create a scope for the meshes to keep things organized + pxr::SdfPath scope_path = + CreatePrimSpec(data_, body_paths_[kWorldIndex], kTokens->materialsScope, + pxr::UsdGeomTokens->Scope); + + mjsMaterial *material = + mjs_asMaterial(mjs_firstElement(spec_, mjOBJ_MATERIAL)); + while (material) { + WriteMaterial(material, scope_path); + material = mjs_asMaterial(mjs_nextElement(spec_, material->element)); + } + } + + pxr::SdfPath WriteMeshGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + std::string mj_name = geom->name->empty() ? *geom->meshname : *geom->name; + auto name = + GetAvailablePrimName(mj_name, pxr::UsdGeomTokens->Mesh, body_path); + pxr::SdfPath subcomponent_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Xform); + + // Reference the mesh asset written in WriteMeshes. + AddPrimReference(data_, subcomponent_path, mesh_paths_[*geom->meshname]); + + return subcomponent_path; + } + + pxr::SdfPath WriteBoxGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + auto name = + GetAvailablePrimName(*geom->name, pxr::UsdGeomTokens->Cube, body_path); + pxr::SdfPath box_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Cube); + + int geom_idx = mjs_getId(geom->element); + mjtNum *geom_size = &model_->geom_size[geom_idx * 3]; + + // MuJoCo uses half sizes. + pxr::SdfPath size_attr_path = + CreateAttributeSpec(data_, box_path, pxr::UsdGeomTokens->size, + pxr::SdfValueTypeNames->Float); + pxr::GfVec3f scale(static_cast(geom_size[0]), + static_cast(geom_size[1]), + static_cast(geom_size[2])); + SetAttributeDefault(data_, size_attr_path, 2.0); + + pxr::SdfPath extent_attr_path = + CreateAttributeSpec(data_, box_path, pxr::UsdGeomTokens->extent, + pxr::SdfValueTypeNames->Float3Array); + SetAttributeDefault( + data_, extent_attr_path, + pxr::VtArray({ + pxr::GfVec3f(-geom_size[0], -geom_size[1], -geom_size[2]), + pxr::GfVec3f(geom_size[0], geom_size[1], geom_size[2]), + })); + + WriteScaleXformOp(box_path, scale); + WriteXformOpOrder(box_path, + pxr::VtArray{kTokens->xformOpScale}); + return box_path; + } + + pxr::SdfPath WriteCapsuleGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + auto name = GetAvailablePrimName(*geom->name, pxr::UsdGeomTokens->Capsule, + body_path); + pxr::SdfPath capsule_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Capsule); + + int geom_idx = mjs_getId(geom->element); + mjtNum *geom_size = &model_->geom_size[geom_idx * 3]; + + // MuJoCo uses half sizes. + pxr::SdfPath radius_attr_path = + CreateAttributeSpec(data_, capsule_path, pxr::UsdGeomTokens->radius, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, radius_attr_path, geom_size[0] * 2); + + pxr::SdfPath height_attr_path = + CreateAttributeSpec(data_, capsule_path, pxr::UsdGeomTokens->height, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, height_attr_path, geom_size[1] * 2); + return capsule_path; + } + + pxr::SdfPath WriteCylinderGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + auto name = GetAvailablePrimName(*geom->name, pxr::UsdGeomTokens->Cylinder, + body_path); + pxr::SdfPath cylinder_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Cylinder); + + int geom_idx = mjs_getId(geom->element); + mjtNum *geom_size = &model_->geom_size[geom_idx * 3]; + + // MuJoCo uses half sizes. + pxr::SdfPath radius_attr_path = + CreateAttributeSpec(data_, cylinder_path, pxr::UsdGeomTokens->radius, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, radius_attr_path, geom_size[0] * 2); + + pxr::SdfPath height_attr_path = + CreateAttributeSpec(data_, cylinder_path, pxr::UsdGeomTokens->height, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, height_attr_path, geom_size[1] * 2); + return cylinder_path; + } + + pxr::SdfPath WriteEllipsoidGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + auto name = GetAvailablePrimName(*geom->name, pxr::UsdGeomTokens->Sphere, + body_path); + pxr::SdfPath ellipsoid_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Sphere); + + int geom_idx = mjs_getId(geom->element); + mjtNum *geom_size = &model_->geom_size[geom_idx * 3]; + + pxr::GfVec3f scale = {static_cast(geom_size[0] * 2), + static_cast(geom_size[1] * 2), + static_cast(geom_size[2] * 2)}; + + // MuJoCo uses half sizes. + pxr::SdfPath radius_attr_path = + CreateAttributeSpec(data_, ellipsoid_path, pxr::UsdGeomTokens->radius, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, radius_attr_path, 1.0f); + + WriteScaleXformOp(ellipsoid_path, scale); + WriteXformOpOrder(ellipsoid_path, + pxr::VtArray{kTokens->xformOpScale}); + return ellipsoid_path; + } + + pxr::SdfPath WriteSphereGeom(const mjsGeom *geom, + const pxr::SdfPath &body_path) { + auto name = GetAvailablePrimName(*geom->name, pxr::UsdGeomTokens->Sphere, + body_path); + pxr::SdfPath sphere_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Sphere); + + int geom_idx = mjs_getId(geom->element); + mjtNum *geom_size = &model_->geom_size[geom_idx * 3]; + + // MuJoCo uses half sizes. + pxr::SdfPath radius_attr_path = + CreateAttributeSpec(data_, sphere_path, pxr::UsdGeomTokens->radius, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, radius_attr_path, geom_size[0] * 2); + return sphere_path; + } + + void WriteGeom(mjsGeom *geom, const mjsBody *body) { + const int body_id = mjs_getId(body->element); + const auto &body_path = body_paths_[body_id]; + auto name = GetAvailablePrimName(*geom->name, kTokens->geom, body_path); + + pxr::SdfPath geom_path; + int geom_id = mjs_getId(geom->element); + switch (geom->type) { + case mjGEOM_MESH: + geom_path = WriteMeshGeom(geom, body_path); + break; + case mjGEOM_BOX: + geom_path = WriteBoxGeom(geom, body_path); + break; + case mjGEOM_CAPSULE: + geom_path = WriteCapsuleGeom(geom, body_path); + break; + case mjGEOM_CYLINDER: + geom_path = WriteCylinderGeom(geom, body_path); + break; + case mjGEOM_ELLIPSOID: + geom_path = WriteEllipsoidGeom(geom, body_path); + break; + case mjGEOM_SPHERE: + geom_path = WriteSphereGeom(geom, body_path); + break; + default: + TF_WARN(UnsupportedGeomTypeError, "Unsupported geom type for geom %d", + geom_id); + return; + } + + mjsDefault *spec_default = mjs_getDefault(geom->element); + pxr::TfToken valid_class_name = GetValidPrimName(*spec_default->name); + pxr::SdfPath geom_class_path = class_path_.AppendChild(valid_class_name); + if (!data_->HasSpec(geom_class_path)) { + pxr::SdfPath class_path = + CreateClassSpec(data_, class_path_, valid_class_name); + auto visibility_attr = + CreateAttributeSpec(data_, class_path, pxr::UsdGeomTokens->visibility, + pxr::SdfValueTypeNames->Token); + SetAttributeDefault(data_, visibility_attr, + pxr::UsdGeomTokens->inherited); + } + + // Bind material if it exists. + if (!geom->material->empty()) { + pxr::SdfPath material_path = + body_paths_[kWorldIndex] + .AppendChild(kTokens->materialsScope) + .AppendChild(GetValidPrimName(*geom->material)); + if (data_->HasSpec(material_path)) { + ApplyApiSchema(data_, geom_path, + pxr::UsdShadeTokens->MaterialBindingAPI); + // Bind the material to this geom. + CreateRelationshipSpec(data_, geom_path, + pxr::UsdShadeTokens->materialBinding, + material_path, pxr::SdfVariabilityUniform); + } + } + + if (body_id == kWorldIndex) { + SetPrimKind(data_, geom_path, pxr::KindTokens->component); + } + // Inherit from class. + AddPrimInherit(data_, geom_path, geom_class_path); + + auto transform = MujocoPosQuatToTransform(&model_->geom_pos[3 * geom_id], + &model_->geom_quat[4 * geom_id]); + WriteTransformXformOp(geom_path, transform); + + PrependToXformOpOrder( + geom_path, pxr::VtArray{kTokens->xformOpTransform}); + } + + void WriteGeoms(mjsBody *body) { + mjsGeom *geom = mjs_asGeom(mjs_firstChild(body, mjOBJ_GEOM, false)); + while (geom) { + WriteGeom(geom, body); + geom = mjs_asGeom(mjs_nextChild(body, geom->element, false)); + } + } + + void WriteCamera(mjsCamera *spec_cam, const mjsBody *body) { + const auto &body_path = body_paths_[mjs_getId(body->element)]; + auto name = GetAvailablePrimName(*spec_cam->name, + pxr::UsdGeomTokens->Camera, body_path); + // Create a root Xform for the world body with the model name if it exists + // otherwise called 'World'. + pxr::SdfPath camera_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdGeomTokens->Camera); + + int cam_id = mjs_getId(spec_cam->element); + auto transform = MujocoPosQuatToTransform(&model_->cam_pos[3 * cam_id], + &model_->cam_quat[4 * cam_id]); + WriteTransformXformOp(camera_path, transform); + WriteXformOpOrder(camera_path, + pxr::VtArray{kTokens->xformOpTransform}); + + // If the camera intrinsics are specified, then it is important that we + // reproduce the code in mujoco/src/engine/engine_vis_visualize.c + const float *cam_sensorsize = &model_->cam_sensorsize[cam_id * 2]; + bool use_intrinsic = cam_sensorsize[1] > 0.0f; + float znear = spec_->visual.map.znear * model_->stat.extent * 100; + float zfar = spec_->visual.map.zfar * model_->stat.extent * 100; + mjtNum fovy = model_->cam_fovy[cam_id]; + const float *cam_intrinsic = &model_->cam_intrinsic[cam_id * 4]; + + const float aspect_ratio = + use_intrinsic ? cam_sensorsize[0] / cam_sensorsize[1] : 4.0f / 3; + float vertical_apperture = + 2 * znear * + (use_intrinsic ? 1.0f / cam_intrinsic[1] * + (cam_sensorsize[1] / 2.f - cam_intrinsic[3]) + : mju_tan((fovy / 2) * (M_PI / 180.0))); + float horizontal_aperture = + use_intrinsic ? 2 * znear / cam_intrinsic[0] * + (cam_sensorsize[0] / 2.f - cam_intrinsic[2]) + : vertical_apperture * aspect_ratio; + + pxr::SdfPath clipping_range_attr_path = CreateAttributeSpec( + data_, camera_path, pxr::UsdGeomTokens->clippingRange, + pxr::SdfValueTypeNames->Float2); + SetAttributeDefault(data_, clipping_range_attr_path, + pxr::GfVec2f(znear, zfar)); + pxr::SdfPath focal_length_attr_path = + CreateAttributeSpec(data_, camera_path, pxr::UsdGeomTokens->focalLength, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, focal_length_attr_path, znear); + + pxr::SdfPath vertical_aperture_attr_path = CreateAttributeSpec( + data_, camera_path, pxr::UsdGeomTokens->verticalAperture, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, vertical_aperture_attr_path, vertical_apperture); + + pxr::SdfPath horizontal_aperture_attr_path = CreateAttributeSpec( + data_, camera_path, pxr::UsdGeomTokens->horizontalAperture, + pxr::SdfValueTypeNames->Float); + SetAttributeDefault(data_, horizontal_aperture_attr_path, + horizontal_aperture); + } + + void WriteCameras(mjsBody *body) { + mjsCamera *cam = mjs_asCamera(mjs_firstChild(body, mjOBJ_CAMERA, false)); + while (cam) { + WriteCamera(cam, body); + cam = mjs_asCamera(mjs_nextChild(body, cam->element, false)); + } + } + + void WriteLight(mjsLight *light, const mjsBody *body) { + const auto &body_path = body_paths_[mjs_getId(body->element)]; + auto name = GetAvailablePrimName(*light->name, kTokens->light, body_path); + // Create a root Xform for the world body with the model name if it exists + // otherwise called 'World'. + pxr::SdfPath light_path = + CreatePrimSpec(data_, body_path, name, pxr::UsdLuxTokens->SphereLight); + + int light_id = mjs_getId(light->element); + auto transform = MujocoPosQuatToTransform(&model_->light_pos[3 * light_id], + &model_->light_dir[4 * light_id]); + WriteTransformXformOp(light_path, transform); + WriteXformOpOrder(light_path, + pxr::VtArray{kTokens->xformOpTransform}); + } + + void WriteLights(mjsBody *body) { + mjsLight *light = mjs_asLight(mjs_firstChild(body, mjOBJ_LIGHT, false)); + while (light) { + WriteLight(light, body); + light = mjs_asLight(mjs_nextChild(body, light->element, false)); + } + } + + void WriteBody(mjsBody *body) { + int body_id = mjs_getId(body->element); + pxr::SdfPath parent_path = + CreateParentIfNotExists(body, body_paths_[kWorldIndex], data_); + pxr::TfToken body_name = GetValidPrimName(*body->name); + + // Create Xform prim for body. + pxr::SdfPath body_path = CreatePrimSpec(data_, parent_path, body_name, + pxr::UsdGeomTokens->Xform); + // The parent_path will be a component which makes the actual articulated + // bodies subcomponents. + SetPrimKind(data_, body_path, pxr::KindTokens->subcomponent); + + // Create classes if necessary + mjsDefault *spec_default = mjs_getDefault(body->element); + + pxr::TfToken body_class_name = GetValidPrimName(*spec_default->name); + pxr::SdfPath body_class_path = class_path_.AppendChild(body_class_name); + if (!data_->HasSpec(body_class_path)) { + CreateClassSpec(data_, class_path_, body_class_name); + } + + // Create XformOp attribute for body transform. + pxr::SdfPath xform_op_path = + CreateAttributeSpec(data_, body_path, kTokens->xformOpTransform, + pxr::SdfValueTypeNames->Matrix4d); + + // Make sure to account for the parent since UsdPhysics doesn't support + // nested bodies! + auto parent_xform = body_xforms_[model_->body_parentid[body_id]]; + // mjModel will have all frames already accounted for so no need to worry + // about them here. + body_xforms_[body_id] = + MujocoPosQuatToTransform(&model_->body_pos[body_id * 3], + &model_->body_quat[body_id * 4]) * + parent_xform; + SetAttributeDefault(data_, xform_op_path, body_xforms_[body_id]); + + // Create XformOpOrder attribute for body transform order. + // For us this is simply the transform we authored above. + WriteXformOpOrder(body_path, + pxr::VtArray{kTokens->xformOpTransform}); + + pxr::VtDictionary customData; + customData[kTokens->body_name] = *body->name; + SetPrimMetadata(data_, body_path, pxr::SdfFieldKeys->CustomData, + customData); + + body_paths_[body_id] = body_path; + } + + void WriteBodies() { + mjsBody *body = mjs_asBody(mjs_firstElement(spec_, mjOBJ_BODY)); + while (body) { + // Only write a rigidbody if we are not the world body. + // We fall through since the world body might have static + // geom children. + if (mjs_getId(body->element) != kWorldIndex) { + WriteBody(body); + } + WriteGeoms(body); + WriteCameras(body); + WriteLights(body); + body = mjs_asBody(mjs_nextElement(spec_, body->element)); + } + } + + pxr::SdfPath WriteWorldBody(const size_t body_index) { + // Create a root Xform for the world body with the model name if it exists + // otherwise called 'World'. + auto name = GetAvailablePrimName(*spec_->modelname, kTokens->world, + pxr::SdfPath::AbsoluteRootPath()); + pxr::SdfPath world_group_path = + CreatePrimSpec(data_, pxr::SdfPath::AbsoluteRootPath(), name, + pxr::UsdGeomTokens->Xform); + SetPrimKind(data_, world_group_path, pxr::KindTokens->group); + return world_group_path; + } +}; + +namespace mujoco { +namespace usd { + +bool WriteSpecToData(mjSpec *spec, pxr::SdfAbstractDataRefPtr &data) { + // Create pseudo root first. + data->CreateSpec(pxr::SdfPath::AbsoluteRootPath(), + pxr::SdfSpecTypePseudoRoot); + + mjModel *model = mj_compile(spec, nullptr); + if (model == nullptr) { + TF_ERROR(MujocoCompilationError, "%s", mjs_getError(spec)); + return false; + } + + ModelWriter(spec, model, data).Write(); + + return true; +} + +} // namespace usd +} // namespace mujoco diff --git a/src/experimental/usd/plugins/mjcf/mujoco_to_usd.h b/src/experimental/usd/plugins/mjcf/mujoco_to_usd.h new file mode 100644 index 00000000..4c5934c6 --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/mujoco_to_usd.h @@ -0,0 +1,32 @@ +// 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. + +#ifndef MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MUJOCO_TO_USD_H_ +#define MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MUJOCO_TO_USD_H_ + +#include +#include + +namespace mujoco { +namespace usd { +// Given an mjSpec, write it to a SdfAbstractData. +// +// Args: +// spec: mjSpec built programmatically or via parsed XML. +// data: SdfAbstractDataRefPtr that will be written to. +bool WriteSpecToData(mjSpec* spec, pxr::SdfAbstractDataRefPtr& data); +} // namespace usd +} // namespace mujoco + +#endif // MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_MUJOCO_TO_USD_H_ diff --git a/src/experimental/usd/plugins/mjcf/plugInfo.json b/src/experimental/usd/plugins/mjcf/plugInfo.json new file mode 100644 index 00000000..25869b76 --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/plugInfo.json @@ -0,0 +1,25 @@ +{ + "Plugins": [ + { + "Info": { + "Types": { + "UsdMjcfFileFormat": { + "bases": ["SdfFileFormat"], + "displayName": "MJCF USD File Format", + "extensions": ["xml"], + "formatId": "xml", + "primary": true, + "supportsReading": true, + "supportsWriting": false, + "target": "usd" + } + } + }, + "LibraryPath": "", + "Name": "usdMjcf", + "ResourcePath": "", + "Root": ".", + "Type": "library" + } + ] +} diff --git a/src/experimental/usd/plugins/mjcf/utils.cc b/src/experimental/usd/plugins/mjcf/utils.cc new file mode 100644 index 00000000..880cce55 --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/utils.cc @@ -0,0 +1,199 @@ +// 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 "mjcf/utils.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace { + +template +void AppendChild(pxr::SdfAbstractDataRefPtr& data, const pxr::SdfPath& specPath, + const pxr::TfToken& childKey, const T& child) { + // Get existing children. + std::vector children; + pxr::SdfAbstractDataTypedValue> getter(&children); + data->Has(specPath, childKey, &getter); + + children.push_back(child); + data->Set(specPath, childKey, + pxr::SdfAbstractDataConstTypedValue>(&children)); +} + +template +void AppendListOp(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& spec_path, const pxr::TfToken& field, + const T& item) { + // Get existing list op. + pxr::SdfListOp list_op; + pxr::SdfAbstractDataTypedValue> getter(&list_op); + data->Has(spec_path, field, &getter); + + auto items = list_op.GetExplicitItems(); + items.push_back(item); + list_op.SetExplicitItems(items); + data->Set(spec_path, field, + pxr::SdfAbstractDataConstTypedValue>(&list_op)); +} + +template +void PrependListOp(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& spec_path, const pxr::TfToken& field, + const T& item) { + // Get existing list op. + pxr::SdfListOp listOp; + pxr::SdfAbstractDataTypedValue> getter(&listOp); + data->Has(spec_path, field, &getter); + + auto prependedItems = listOp.GetPrependedItems(); + prependedItems.insert(prependedItems.begin(), item); + listOp.SetPrependedItems(prependedItems); + data->Set(spec_path, field, + pxr::SdfAbstractDataConstTypedValue>(&listOp)); +} +} // namespace + +namespace mujoco { +namespace usd { + +pxr::SdfPath CreatePrimSpec(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& parent_path, + const pxr::TfToken& name, const pxr::TfToken& type, + pxr::SdfSpecifier specifier) { + const pxr::SdfPath prim_path = parent_path.AppendChild(name); + data->CreateSpec(prim_path, pxr::SdfSpecTypePrim); + data->Set(prim_path, pxr::SdfFieldKeys->Specifier, + pxr::SdfAbstractDataConstTypedValue(&specifier)); + if (!type.IsEmpty()) { + data->Set(prim_path, pxr::SdfFieldKeys->TypeName, + pxr::SdfAbstractDataConstTypedValue(&type)); + } + + AppendChild(data, parent_path, pxr::SdfChildrenKeys->PrimChildren, name); + + return prim_path; +} + +pxr::SdfPath CreateAttributeSpec(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& name, + const pxr::SdfValueTypeName& type_name, + pxr::SdfVariability variability) { + const pxr::SdfPath propertyPath = prim_path.AppendProperty(name); + data->CreateSpec(propertyPath, pxr::SdfSpecTypeAttribute); + + pxr::TfToken typeNameToken = type_name.GetAsToken(); + data->Set(propertyPath, pxr::SdfFieldKeys->TypeName, + pxr::SdfAbstractDataConstTypedValue(&typeNameToken)); + if (variability != pxr::SdfVariabilityVarying) { + data->Set( + propertyPath, pxr::SdfFieldKeys->Variability, + pxr::SdfAbstractDataConstTypedValue(&variability)); + } + + AppendChild(data, prim_path, pxr::SdfChildrenKeys->PropertyChildren, name); + + return propertyPath; +} + +pxr::SdfPath CreateRelationshipSpec(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& relationship_name, + const pxr::SdfPath& relationship_path, + pxr::SdfVariability variability) { + pxr::SdfPath prop_path = prim_path.AppendProperty(relationship_name); + data->CreateSpec(prop_path, pxr::SdfSpecTypeRelationship); + if (variability != pxr::SdfVariabilityVarying) { + data->Set( + prop_path, pxr::SdfFieldKeys->Variability, + pxr::SdfAbstractDataConstTypedValue(&variability)); + } + + AppendChild(data, prim_path, pxr::SdfChildrenKeys->PropertyChildren, + relationship_name); + + AppendChild(data, prop_path, pxr::SdfChildrenKeys->RelationshipTargetChildren, + relationship_path); + AppendListOp(data, prop_path, pxr::SdfFieldKeys->TargetPaths, + relationship_path); + + pxr::SdfPath target_path = prop_path.AppendTarget(relationship_path); + data->CreateSpec(target_path, pxr::SdfSpecTypeRelationshipTarget); + + return prop_path; +} + +pxr::SdfPath CreateClassSpec(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& class_name) { + pxr::SdfPath class_path = prim_path.AppendChild(class_name); + pxr::SdfSpecifier class_specifier = pxr::SdfSpecifier::SdfSpecifierClass; + data->CreateSpec(class_path, pxr::SdfSpecTypePrim); + data->Set( + class_path, pxr::SdfFieldKeys->Specifier, + pxr::SdfAbstractDataConstTypedValue(&class_specifier)); + + AppendChild(data, prim_path, pxr::SdfChildrenKeys->PrimChildren, class_name); + + return class_path; +} + +void AddAttributeConnection(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& attribute_path, + const pxr::SdfPath& target_attribute_path) { + AppendChild(data, attribute_path, pxr::SdfChildrenKeys->ConnectionChildren, + target_attribute_path); + AppendListOp(data, attribute_path, pxr::SdfFieldKeys->ConnectionPaths, + target_attribute_path); + + data->CreateSpec(attribute_path.AppendTarget(target_attribute_path), + pxr::SdfSpecTypeConnection); +} + +void AddPrimReference(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::SdfPath& referenced_prim_path) { + PrependListOp(data, prim_path, pxr::SdfFieldKeys->References, + pxr::SdfReference("", referenced_prim_path)); +} + +void AddPrimInherit(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::SdfPath& class_path) { + PrependListOp(data, prim_path, pxr::SdfFieldKeys->InheritPaths, class_path); +} + +void ApplyApiSchema(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& schema_name) { + PrependListOp(data, prim_path, pxr::UsdTokens->apiSchemas, schema_name); +} + +void SetPrimKind(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, pxr::TfToken kind) { + SetPrimMetadata(data, prim_path, pxr::TfToken("kind"), kind); +} + +} // namespace usd +} // namespace mujoco diff --git a/src/experimental/usd/plugins/mjcf/utils.h b/src/experimental/usd/plugins/mjcf/utils.h new file mode 100644 index 00000000..4a2643ec --- /dev/null +++ b/src/experimental/usd/plugins/mjcf/utils.h @@ -0,0 +1,127 @@ +// 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. + +#ifndef MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_UTILS_H_ +#define MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_UTILS_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace mujoco { +namespace usd { + +// Create a prim spec and append it as a child of parent_path. +pxr::SdfPath CreatePrimSpec( + pxr::SdfAbstractDataRefPtr& data, const pxr::SdfPath& parent_path, + const pxr::TfToken& name, const pxr::TfToken& type = pxr::TfToken(), + pxr::SdfSpecifier specifier = pxr::SdfSpecifier::SdfSpecifierDef); + +// Create an attribute spec and append it as a child of parent_path. +// By default the attribute will be varying. +pxr::SdfPath CreateAttributeSpec( + pxr::SdfAbstractDataRefPtr& data, const pxr::SdfPath& prim_path, + const pxr::TfToken& name, const pxr::SdfValueTypeName& type_name, + pxr::SdfVariability variability = pxr::SdfVariabilityVarying); + +// Create a relationship spec and append it as a child of prim_path. +pxr::SdfPath CreateRelationshipSpec( + pxr::SdfAbstractDataRefPtr& data, const pxr::SdfPath& prim_path, + const pxr::TfToken& relationship_name, + const pxr::SdfPath& relationship_path, + pxr::SdfVariability variability = pxr::SdfVariabilityVarying); + +pxr::SdfPath CreateClassSpec(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& class_name); + +void AddAttributeConnection(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& attribute_path, + const pxr::SdfPath& target_attribute_path); + +void AddPrimReference(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::SdfPath& referenced_prim_path); + +void AddPrimInherit(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::SdfPath& class_path); + +void ApplyApiSchema(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, + const pxr::TfToken& schema_name); + +void SetPrimKind(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, pxr::TfToken kind); + +// Set the value specified by key on any field at field_path. +template +void SetField(pxr::SdfAbstractDataRefPtr& data, const pxr::SdfPath& field_path, + const pxr::TfToken key, T&& value) { + using Deduced = typename std::remove_reference_t; + const auto typed_val = pxr::SdfAbstractDataConstTypedValue(&value); + const pxr::SdfAbstractDataConstValue& untyped_val = typed_val; + + data->Set(field_path, key, untyped_val); +} + +// Set the value specified by key on an attribute spec at attribute_path. +template +void SetAttribute(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& attribute_path, const pxr::TfToken key, + T&& value) { + SetField(data, attribute_path, key, value); +} + +// Set the value specified by key on a prim spec at prim_path. +template +void SetPrimMetadata(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& prim_path, const pxr::TfToken key, + T&& value) { + SetAttribute(data, prim_path, key, value); +} + +// Set the value specified by key on an attribute spec at attribute_path. +template +void SetAttributeMetadata(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& attribute_path, + const pxr::TfToken key, T&& value) { + SetAttribute(data, attribute_path, key, value); +} + +// Set the default value on an attribute spec at attribute_path. +template +void SetAttributeDefault(pxr::SdfAbstractDataRefPtr& data, + const pxr::SdfPath& attribute_path, + T&& default_value) { + SetAttribute(data, attribute_path, pxr::SdfFieldKeys->Default, default_value); +} + +// Set the value specified by key on the root layer. +template +void SetLayerMetadata(pxr::SdfAbstractDataRefPtr& data, const pxr::TfToken& key, + T&& value) { + SetAttribute(data, pxr::SdfPath::AbsoluteRootPath(), key, value); +} + +} // namespace usd +} // namespace mujoco + +#endif // MUJOCO_SRC_EXPERIMENTAL_USD_PLUGINS_MJCF_UTILS_H_ diff --git a/test/experimental/usd/plugins/mjcf/fixture.cc b/test/experimental/usd/plugins/mjcf/fixture.cc new file mode 100644 index 00000000..19cd8e6e --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/fixture.cc @@ -0,0 +1,64 @@ +// 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 "test/experimental/usd/plugins/mjcf/fixture.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace mujoco { + +using pxr::SdfPath; + +pxr::SdfLayerRefPtr LoadLayer(const std::string& xml) { + auto layer = pxr::SdfLayer::CreateAnonymous( + "test_layer", pxr::SdfFileFormat::FindByExtension("xml")); + layer->ImportFromString(xml); + EXPECT_THAT(layer, testing::NotNull()); + return layer; +} + +template <> +void ExpectAttributeEqual(pxr::UsdStageRefPtr stage, + const char* path, + const pxr::SdfAssetPath& value) { + auto attr = stage->GetAttributeAtPath(pxr::SdfPath(path)); + EXPECT_TRUE(attr.IsValid()); + pxr::SdfAssetPath attr_value; + attr.Get(&attr_value); + EXPECT_EQ(attr_value.GetAssetPath(), value.GetAssetPath()); +} + +void ExpectAttributeHasConnection(pxr::UsdStageRefPtr stage, const char* path, + const char* connection_path) { + auto attr = stage->GetAttributeAtPath(SdfPath(path)); + EXPECT_TRUE(attr.IsValid()); + pxr::SdfPathVector sources; + attr.GetConnections(&sources); + EXPECT_EQ(sources.size(), 1); + EXPECT_EQ(sources[0], SdfPath(connection_path)); +} +// +} // namespace mujoco diff --git a/test/experimental/usd/plugins/mjcf/fixture.h b/test/experimental/usd/plugins/mjcf/fixture.h new file mode 100644 index 00000000..64985fae --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/fixture.h @@ -0,0 +1,67 @@ +// 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. + +#ifndef MUJOCO_TEST_EXPERIMENTAL_USD_PLUGINS_MJCF_FIXTURE_H_ +#define MUJOCO_TEST_EXPERIMENTAL_USD_PLUGINS_MJCF_FIXTURE_H_ + +#include + +#include +#include "test/fixture.h" +#include +#include +#include +#include +#include +#include + +#define EXPECT_PRIM_VALID(stage, path) \ + EXPECT_TRUE((stage)->GetPrimAtPath(SdfPath(path)).IsValid()); + +#define EXPECT_PRIM_KIND(stage, path, kind) \ + { \ + pxr::TfToken prim_kind; \ + pxr::UsdModelAPI::Get(stage, SdfPath(path)).GetKind(&prim_kind); \ + EXPECT_EQ(kind, prim_kind); \ + } +namespace mujoco { + +pxr::SdfLayerRefPtr LoadLayer(const std::string& xml); + +template +void ExpectAttributeEqual(pxr::UsdStageRefPtr stage, const char* path, + const T& value) { + auto attr = stage->GetAttributeAtPath(pxr::SdfPath(path)); + EXPECT_TRUE(attr.IsValid()); + T attr_value; + attr.Get(&attr_value); + EXPECT_EQ(attr_value, value); +} + +// Specialization for SdfAssetPath, so that we can compare only the asset path +// and not care about whatever the resolved path is. +// Otherwise the default operator== would fail because it tests for equality of +// the asset path AND the resolved path. +template <> +void ExpectAttributeEqual(pxr::UsdStageRefPtr stage, + const char* path, + const pxr::SdfAssetPath& value); + +void ExpectAttributeHasConnection(pxr::UsdStageRefPtr stage, const char* path, + const char* connection_path); + +using MjcfSdfFileFormatPluginTest = MujocoTest; + +} // namespace mujoco +#endif // MUJOCO_TEST_EXPERIMENTAL_USD_PLUGINS_MJCF_FIXTURE_H_ diff --git a/test/experimental/usd/plugins/mjcf/mjcf_file_format_test.cc b/test/experimental/usd/plugins/mjcf/mjcf_file_format_test.cc new file mode 100644 index 00000000..8ca9bdba --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/mjcf_file_format_test.cc @@ -0,0 +1,387 @@ +// 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/experimental/usd/plugins/mjcf/fixture.h" +#include "test/fixture.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_OPEN_SCOPE +// clang-format off +TF_DEFINE_PRIVATE_TOKENS(_tokens, + (st) + ); +// clang-format on +PXR_NAMESPACE_CLOSE_SCOPE + +namespace mujoco { +namespace { + +using pxr::SdfPath; + +static const char* kMaterialsPath = + "experimental/usd/plugins/mjcf/testdata/materials.xml"; +static const char* kMeshObjPath = + "experimental/usd/plugins/mjcf/testdata/mesh_obj.xml"; + +TEST_F(MjcfSdfFileFormatPluginTest, TestClassAuthored) { + static constexpr char kXml[] = R"( + + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_VALID(stage, "/__class__"); + EXPECT_PRIM_VALID(stage, "/__class__/test"); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestBasicMeshSources) { + static constexpr char kXml[] = R"( + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_VALID(stage, "/mesh_test"); + EXPECT_PRIM_VALID(stage, "/mesh_test/test_body/test_body/tetrahedron"); + EXPECT_PRIM_VALID(stage, "/mesh_test/test_body/test_body/tetrahedron/Mesh"); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestMaterials) { + const std::string xml_path = GetTestDataFilePath(kMaterialsPath); + + auto stage = pxr::UsdStage::Open(xml_path); + EXPECT_THAT(stage, testing::NotNull()); + + EXPECT_PRIM_VALID(stage, "/mesh_test"); + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials"); + + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials/material_red"); + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials/material_red/PreviewSurface"); + ExpectAttributeEqual( + stage, + "/mesh_test/Materials/material_red/PreviewSurface.inputs:diffuseColor", + pxr::GfVec3f(0.8, 0, 0)); + + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials/material_texture"); + EXPECT_PRIM_VALID(stage, + "/mesh_test/Materials/material_texture/PreviewSurface"); + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials/material_texture/uvmap"); + EXPECT_PRIM_VALID(stage, "/mesh_test/Materials/material_texture/texture"); + ExpectAttributeHasConnection( + stage, + "/mesh_test/Materials/material_texture/" + "PreviewSurface.inputs:diffuseColor", + "/mesh_test/Materials/material_texture/texture.outputs:rgb"); + ExpectAttributeEqual( + stage, "/mesh_test/Materials/material_texture/texture.inputs:file", + pxr::SdfAssetPath("textures/cube.png")); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestFaceVaryingMeshSourcesSimpleMjcfMesh) { + static constexpr char kXml[] = R"( + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + + auto mesh = pxr::UsdGeomMesh::Get( + stage, SdfPath("/mesh_test/test_body/test_body/tetrahedron/Mesh")); + ASSERT_TRUE(mesh); + pxr::VtArray face_vertex_counts; + mesh.GetFaceVertexCountsAttr().Get(&face_vertex_counts); + EXPECT_EQ(face_vertex_counts.size(), 4); + EXPECT_EQ(face_vertex_counts, pxr::VtArray({3, 3, 3, 3})); + + pxr::VtArray face_vertex_indices; + mesh.GetFaceVertexIndicesAttr().Get(&face_vertex_indices); + EXPECT_EQ(face_vertex_indices.size(), 12); + EXPECT_EQ(face_vertex_indices, + pxr::VtArray({0, 3, 2, 0, 1, 3, 0, 2, 1, 1, 2, 3})); + + pxr::VtArray normals; + mesh.GetNormalsAttr().Get(&normals); + EXPECT_EQ(normals.size(), face_vertex_indices.size()); + // We can't directly check the normals values because they are altered by + // Mujoco's compiling step. So we at least check that normals with the same + // original index are the same. + for (int i = 0; i < face_vertex_indices.size(); ++i) { + for (int j = i + 1; j < face_vertex_indices.size(); ++j) { + if (face_vertex_indices[i] == face_vertex_indices[j]) { + EXPECT_EQ(normals[i], normals[j]); + } + } + } + + auto primvars_api = pxr::UsdGeomPrimvarsAPI(mesh.GetPrim()); + + pxr::VtArray texcoords; + EXPECT_TRUE(primvars_api.HasPrimvar(pxr::_tokens->st)); + auto primvar_st = primvars_api.GetPrimvar(pxr::_tokens->st); + primvar_st.Get(&texcoords); + EXPECT_EQ(texcoords.size(), face_vertex_indices.size()); + + // Check the faceVarying texcoords against the manually indexed source + // texcoords. + pxr::VtArray source_texcoords{ + {0.5, 0.5}, {0, 0.5}, {1, 0}, {1, 1}}; + for (int i = 0; i < face_vertex_indices.size(); ++i) { + EXPECT_EQ(texcoords[i], source_texcoords[face_vertex_indices[i]]); + } +} + +TEST_F(MjcfSdfFileFormatPluginTest, + TestFaceVaryingMeshSourcesObjWithIndexedNormals) { + const std::string xml_path = GetTestDataFilePath(kMeshObjPath); + + auto stage = pxr::UsdStage::Open(xml_path); + EXPECT_THAT(stage, testing::NotNull()); + + auto mesh = pxr::UsdGeomMesh::Get( + stage, SdfPath("/mesh_test/test_body/test_body/mesh/Mesh")); + ASSERT_TRUE(mesh); + pxr::VtArray face_vertex_counts; + mesh.GetFaceVertexCountsAttr().Get(&face_vertex_counts); + EXPECT_EQ(face_vertex_counts.size(), 4); + EXPECT_EQ(face_vertex_counts, pxr::VtArray({3, 3, 3, 3})); + + pxr::VtArray face_vertex_indices; + mesh.GetFaceVertexIndicesAttr().Get(&face_vertex_indices); + EXPECT_EQ(face_vertex_indices.size(), 12); + EXPECT_EQ(face_vertex_indices, + pxr::VtArray({0, 3, 2, 0, 1, 3, 0, 2, 1, 1, 2, 3})); + + pxr::VtArray normals; + mesh.GetNormalsAttr().Get(&normals); + EXPECT_EQ(normals.size(), face_vertex_indices.size()); + // We can't directly check the normals values because they are altered by + // Mujoco's compiling step. + // We also can't access the normals indexing data, and can't use the vertex + // indexing data here because they are separate. + // So we check that the first half of the normals are the same, then the + // second half, as set in the OBJ file. + pxr::GfVec3f first_half_normal = normals[0]; + pxr::GfVec3f second_half_normal = normals[face_vertex_indices.size() / 2]; + EXPECT_NE(first_half_normal, second_half_normal); + int i = 0; + for (; i < face_vertex_indices.size() / 2; ++i) { + EXPECT_EQ(normals[i], first_half_normal); + } + for (; i < face_vertex_indices.size(); ++i) { + EXPECT_EQ(normals[i], second_half_normal); + } + + auto primvars_api = pxr::UsdGeomPrimvarsAPI(mesh.GetPrim()); + + pxr::VtArray texcoords; + EXPECT_TRUE(primvars_api.HasPrimvar(pxr::_tokens->st)); + auto primvar_st = primvars_api.GetPrimvar(pxr::_tokens->st); + primvar_st.Get(&texcoords); + EXPECT_EQ(texcoords.size(), face_vertex_indices.size()); + + // Check the faceVarying texcoords against the manually indexed source + // texcoords. + // NOTE: For OBJ we must use different indices for the texcoords than for the + // vertices! + std::vector source_face_texcoord_indices{0, 1, 2, 1, 2, 3, + 2, 3, 0, 3, 0, 1}; + pxr::VtArray source_texcoords{ + {0.5, 0.5}, {0, 0.5}, {1, 0}, {1, 1}}; + // NOTE: The v component of the texcoords is flipped when Mujoco loads the + // OBJ. + for (auto& uv : source_texcoords) { + uv[1] = 1 - uv[1]; + } + for (int i = 0; i < source_face_texcoord_indices.size(); ++i) { + EXPECT_EQ(texcoords[i], source_texcoords[source_face_texcoord_indices[i]]); + } +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestBody) { + static constexpr char kXml[] = R"( + + + + + + + + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_VALID(stage, "/body_test"); + EXPECT_PRIM_VALID(stage, "/body_test/test_body"); + EXPECT_PRIM_VALID(stage, "/body_test/test_body/test_body"); + EXPECT_PRIM_VALID(stage, "/body_test/test_body/test_body_2"); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestBasicParenting) { + static constexpr char kXml[] = R"( + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_VALID(stage, "/test/root"); + EXPECT_PRIM_VALID(stage, "/test/root/root"); + EXPECT_PRIM_VALID(stage, "/test/root/root_body_1"); + EXPECT_PRIM_VALID(stage, "/test/root/root_body_2"); + EXPECT_PRIM_VALID(stage, "/test/root/root_body_3"); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestJointsDoNotAffectParenting) { + static constexpr char kXml[] = R"( + + + + + + + + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_VALID(stage, "/test/root"); + EXPECT_PRIM_VALID(stage, "/test/root/root"); + EXPECT_PRIM_VALID(stage, "/test/root/middle"); + EXPECT_PRIM_VALID(stage, "/test/root/tet"); +} + +TEST_F(MjcfSdfFileFormatPluginTest, TestKindAuthoring) { + static constexpr char kXml[] = R"( + + + + + + + + + + + + + + + + + + )"; + + pxr::SdfLayerRefPtr layer = LoadLayer(kXml); + + auto stage = pxr::UsdStage::Open(layer); + EXPECT_PRIM_KIND(stage, "/test", pxr::KindTokens->group); + EXPECT_PRIM_KIND(stage, "/test/root", pxr::KindTokens->component); + EXPECT_PRIM_KIND(stage, "/test/root/root", pxr::KindTokens->subcomponent); + EXPECT_PRIM_KIND(stage, "/test/root/middle", pxr::KindTokens->subcomponent); + EXPECT_PRIM_KIND(stage, "/test/root/tet", pxr::KindTokens->subcomponent); +} + +} // namespace +} // namespace mujoco diff --git a/test/experimental/usd/plugins/mjcf/testdata/materials.xml b/test/experimental/usd/plugins/mjcf/testdata/materials.xml new file mode 100644 index 00000000..735a789d --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/testdata/materials.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/experimental/usd/plugins/mjcf/testdata/mesh_obj.xml b/test/experimental/usd/plugins/mjcf/testdata/mesh_obj.xml new file mode 100644 index 00000000..11465088 --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/testdata/mesh_obj.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/experimental/usd/plugins/mjcf/testdata/meshes/tetrahedron.obj b/test/experimental/usd/plugins/mjcf/testdata/meshes/tetrahedron.obj new file mode 100644 index 00000000..98ab2e51 --- /dev/null +++ b/test/experimental/usd/plugins/mjcf/testdata/meshes/tetrahedron.obj @@ -0,0 +1,16 @@ +v 0 1 0 +v 0 0 0 +v 1 0 1 +v 1 0 -1 +vn 1 0 0 +vn -1 0 0 +vt 0.5 0.5 +vt 0 0.5 +vt 1 1 +vt 1 0 + +f 1/1/1 4/2/1 3/3/1 +f 1/2/1 2/3/1 4/4/1 +f 1/3/2 3/4/2 2/1/2 +f 2/4/2 3/1/2 4/2/2 + diff --git a/test/experimental/usd/plugins/mjcf/testdata/textures/cube.png b/test/experimental/usd/plugins/mjcf/testdata/textures/cube.png new file mode 100644 index 0000000000000000000000000000000000000000..8c244eca6405e36009a8cf885df2f6487315bbb2 GIT binary patch literal 6888 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 ZZg$RQK;>7N3*+AbFw{5Id!a+R{y$HZ@S6Yt literal 0 HcmV?d00001