Allow self-attach in MJCF

PiperOrigin-RevId: 941676717
Change-Id: I9ff8fc89716bc14b438a41e2b4075bbe5fdb83cf
This commit is contained in:
Yuval Tassa
2026-07-02 06:51:04 -07:00
committed by Copybara-Service
parent cdc35dec38
commit 2d283b5e9d
4 changed files with 178 additions and 37 deletions
+14 -11
View File
@@ -3996,16 +3996,18 @@ Associate this body with an :ref:`engine plugin<exPlugin>`. Either :at:`plugin`
:el-prefix:`body/` |-| **attach** |*| :el-prefix:`body/` |-| **attach** |*|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The :el:`attach` element is used to insert elements from another (child) model into this (parent) model's kinematic tree. The :el:`attach` element is used to insert elements from another (child) model, or from the current model itself
Unlike :ref:`include<include>`, which is implemented in the parser and is equivalent to copying and pasting XML from one (self-attachment), into this (parent) model's kinematic tree. Unlike :ref:`include<include>`, which is implemented in
file into another, :el:`attach` is implemented in the model compiler. In order to use this element, the sub-model must the parser and is equivalent to copying and pasting XML from one file into another, :el:`attach` is implemented in the
first be defined as an :ref:`asset<asset-model>`. When creating an attachment, a frame, body or the entire child model in the model compiler. In order to use this element to import from another model, the sub-model must first be defined as an
child model is specified, and all referencing elements outside the kinematic tree (e.g., sensors and actuators), are also copied into :ref:`asset<asset-model>`. When creating an attachment, a frame, body or the entire child model in the child model is
the parent model. Additionally, any elements referenced from within the attached subtree (e.g. defaults and assets) specified, and all referencing elements outside the kinematic tree (e.g., sensors and actuators), are also copied into
will be copied in to the parent model. :el:`attach` is a :ref:`meta-element`, so upon saving all attachments will the parent model. Additionally, any elements referenced from within the attached subtree (e.g. defaults and assets) will
appear in the saved XML file. Note that this element is a subset of the functionality of the procedural be copied in to the parent model. For self-attaching within the same model, the :at:`model` attribute is omitted, and a
:ref:`attachment<meAttachment>` functionality. As such, it shares the same limitations as described there. See example `here body or frame must be specified. :el:`attach` is a :ref:`meta-element`, so upon saving all attachments will appear in
<https://github.com/google-deepmind/mujoco/blob/main/test/xml/testdata/parent.xml>`__. the saved XML file. Note that this element is a subset of the functionality of the procedural
:ref:`attachment<meAttachment>` functionality. As such, it shares the same limitations as described there. See example
`here <https://github.com/google-deepmind/mujoco/blob/main/test/xml/testdata/parent.xml>`__.
.. admonition:: Known issues .. admonition:: Known issues
:class: note :class: note
@@ -4020,8 +4022,9 @@ appear in the saved XML file. Note that this element is a subset of the function
.. _body-attach-model: .. _body-attach-model:
:at:`model`: :at-val:`string, required` :at:`model`: :at-val:`string, optional`
The child model from which to attach a subtree or a frame. The child model from which to attach a subtree or a frame.
If omitted, the attachment is performed within the current model (self-attachment).
.. _body-attach-body: .. _body-attach-body:
+4 -2
View File
@@ -9,8 +9,10 @@ General
^^^^^^^ ^^^^^^^
- Added Nesterov momentum extrapolation with adaptive gradient restart (O'Donoghue-Candès) to the PGS solver, - Added Nesterov momentum extrapolation with adaptive gradient restart (O'Donoghue-Candès) to the PGS solver,
significantly improving convergence. Overall PGS now requires ~2x fewer iterations. significantly improving convergence. Overall PGS now requires ~2x fewer iterations.
- :ref:`mj_encode` now supports encoding of MJB and TXT files.
* :ref:`mj_encode` now supports encoding of MJB and TXT files. - The :el:`attach` element now supports self-attachment (attaching elements of the current model to itself) by omitting
the :at:`model` attribute. It also supports attaching a frame via the new :at:`frame` attribute, which is mutually
exclusive with :at:`body`.
.. admonition:: Breaking API changes .. admonition:: Breaking API changes
:class: attention :class: attention
+49 -19
View File
@@ -3942,7 +3942,7 @@ void mjXReader::Body(XMLElement* section, mjsBody* body, mjsFrame* frame,
// attachment // attachment
else if (name == "attach") { else if (name == "attach") {
string model_name, child_name, prefix; string model_name, child_name, prefix;
ReadAttrTxt(elem, "model", model_name, /*required=*/true); bool has_model = ReadAttrTxt(elem, "model", model_name, /*required=*/false);
bool has_body = ReadAttrTxt(elem, "body", child_name, /*required=*/false); bool has_body = ReadAttrTxt(elem, "body", child_name, /*required=*/false);
bool has_frame = ReadAttrTxt(elem, "frame", child_name, /*required=*/false); bool has_frame = ReadAttrTxt(elem, "frame", child_name, /*required=*/false);
ReadAttrTxt(elem, "prefix", prefix, /*required=*/true); ReadAttrTxt(elem, "prefix", prefix, /*required=*/true);
@@ -3950,31 +3950,61 @@ void mjXReader::Body(XMLElement* section, mjsBody* body, mjsFrame* frame,
if (has_body && has_frame) { if (has_body && has_frame) {
throw mjXError(elem, "only one of body or frame can be specified in attach"); throw mjXError(elem, "only one of body or frame can be specified in attach");
} }
mjtObj type = has_body ? mjOBJ_BODY : mjOBJ_FRAME; mjtObj type = mjOBJ_UNKNOWN;
if (has_body) type = mjOBJ_BODY;
else if (has_frame) type = mjOBJ_FRAME;
string full_name = prefix+child_name; mjsElement* source_elem = nullptr;
if (mjs_findElement(spec, type, full_name.c_str())) { if (!has_model) { // Self-attach
throw mjXError(elem, "cannot attach: element %s already exists", full_name.c_str()); if (type == mjOBJ_UNKNOWN) {
} throw mjXError(elem, "either 'body' or 'frame' attribute must be specified for self-attach");
}
mjSpec* asset = mjs_findSpec(spec, model_name.c_str()); // check for name collision in the current spec
if (!asset) { string full_name = prefix + child_name;
throw mjXError(elem, "could not find model '%s'", model_name.c_str()); if (mjs_findElement(spec, type, full_name.c_str())) {
} throw mjXError(elem, "cannot self-attach: element %s already exists", full_name.c_str());
}
source_elem = mjs_findElement(spec, type, child_name.c_str());
if (!source_elem) {
throw mjXError(elem, "%s",
(string("could not find ") + mju_type2Str(type) + " '" + child_name +
"' in the current model for self-attachment").c_str());
}
} else { // Attach from external model asset
// Check for name collision in the current spec
if (!child_name.empty()) {
string full_name = prefix + child_name;
if (mjs_findElement(spec, type, full_name.c_str())) {
throw mjXError(elem, "%s",
(string("cannot attach: element ") + child_name +
" already exists with prefix " + prefix).c_str());
}
}
mjsElement* child; mjSpec* asset = mjs_findSpec(spec, model_name.c_str());
if (child_name.empty()) { if (!asset) {
child = asset->element; throw mjXError(elem, "could not find model '%s'", model_name.c_str());
} else { }
child = mjs_findElement(asset, type, child_name.c_str());
if (!child) { if (type == mjOBJ_UNKNOWN) { // Attach world body contents
throw mjXError(elem, "could not find %s", source_elem = asset->element;
(string(mju_type2Str(type)) + " '" + child_name + "'").c_str()); } else { // Attach specific body or frame
source_elem = mjs_findElement(asset, type, child_name.c_str());
if (!source_elem) {
throw mjXError(elem, "%s",
(string("could not find ") + mju_type2Str(type) + " '" + child_name +
"' in model asset '" + model_name + "'").c_str());
}
} }
} }
mjsFrame* pframe = frame ? frame : mjs_addFrame(body, nullptr); mjsFrame* pframe = frame ? frame : mjs_addFrame(body, nullptr);
if (!mjs_attach(pframe->element, child, prefix.c_str(), "")) { // Set default for the new frame from the current context
mjs_setDefault(pframe->element, mjs_getDefault(frame ? frame->element : body->element));
mjs_setString(pframe->info, ("line = " + std::to_string(elem->GetLineNum())).c_str());
if (!mjs_attach(pframe->element, source_elem, prefix.c_str(), "")) {
throw mjXError(elem, "%s", stripError(mjs_getError(spec))); throw mjXError(elem, "%s", stripError(mjs_getError(spec)));
} }
} }
+111 -5
View File
@@ -521,7 +521,8 @@ TEST_F(XMLReaderTest, InvalidDoubleOrientation) {
if (orient1 == orient2) continue; if (orient1 == orient2) continue;
std::string xml = prefix + field + orient1 + orient2 + suffix; std::string xml = prefix + field + orient1 + orient2 + suffix;
std::array<char, 1024> error; std::array<char, 1024> error;
MjModelPtr model = LoadModelFromString(xml.c_str(), error.data(), error.size()); MjModelPtr model =
LoadModelFromString(xml.c_str(), error.data(), error.size());
ASSERT_THAT(model.get(), IsNull()); ASSERT_THAT(model.get(), IsNull());
EXPECT_THAT( EXPECT_THAT(
error.data(), error.data(),
@@ -1836,7 +1837,8 @@ TEST_F(XMLReaderTest, AttachSpecAssets) {
mj_addBufferVFS(vfs.get(), "xml_child.xml", xml_child, sizeof(xml_child)); mj_addBufferVFS(vfs.get(), "xml_child.xml", xml_child, sizeof(xml_child));
std::array<char, 1024> er; std::array<char, 1024> er;
MjModelPtr model = LoadModelFromString(xml_parent, er.data(), er.size(), vfs.get()); MjModelPtr model =
LoadModelFromString(xml_parent, er.data(), er.size(), vfs.get());
EXPECT_THAT(model.get(), NotNull()) << er.data(); EXPECT_THAT(model.get(), NotNull()) << er.data();
MjModelPtr expected = LoadModelFromString(xml_expected, er.data(), er.size()); MjModelPtr expected = LoadModelFromString(xml_expected, er.data(), er.size());
@@ -1890,7 +1892,8 @@ TEST_F(XMLReaderTest, InvalidAttach) {
mj_addBufferVFS(vfs.get(), "child.xml", xml_child, sizeof(xml_child)); mj_addBufferVFS(vfs.get(), "child.xml", xml_child, sizeof(xml_child));
std::array<char, 1024> er; std::array<char, 1024> er;
MjModelPtr model = LoadModelFromString(xml_parent, er.data(), er.size(), vfs.get()); MjModelPtr model =
LoadModelFromString(xml_parent, er.data(), er.size(), vfs.get());
EXPECT_THAT(model.get(), IsNull()) << er.data(); EXPECT_THAT(model.get(), IsNull()) << er.data();
EXPECT_THAT(er.data(), HasSubstr("repeated name '_actuator' in actuator")); EXPECT_THAT(er.data(), HasSubstr("repeated name '_actuator' in actuator"));
@@ -1999,7 +2002,8 @@ TEST_F(XMLReaderTest, ResizeKeyframeAfterParsing) {
mj_addBufferVFS(vfs.get(), "child.xml", child_xml, sizeof(child_xml)); mj_addBufferVFS(vfs.get(), "child.xml", child_xml, sizeof(child_xml));
std::array<char, 1024> error; std::array<char, 1024> error;
MjModelPtr m = LoadModelFromString(parent_xml, error.data(), error.size(), vfs.get()); MjModelPtr m =
LoadModelFromString(parent_xml, error.data(), error.size(), vfs.get());
EXPECT_THAT(m.get(), NotNull()) << error.data(); EXPECT_THAT(m.get(), NotNull()) << error.data();
mj_deleteVFS(vfs.get()); mj_deleteVFS(vfs.get());
} }
@@ -3725,7 +3729,7 @@ TEST_F(ActuatorParseTest, ActuatorDelayParsed) {
ASSERT_EQ(model->nu, 3); ASSERT_EQ(model->nu, 3);
// actuator_history[2*i] = nsample, actuator_history[2*i+1] = interp // actuator_history[2*i] = nsample, actuator_history[2*i+1] = interp
EXPECT_EQ(model->actuator_history[0], 0); // jnt1 nsample EXPECT_EQ(model->actuator_history[0], 0); // jnt1 nsample
EXPECT_EQ(model->actuator_history[1], 0); // jnt1 interp (no buffer, default 0) EXPECT_EQ(model->actuator_history[1], 0); // jnt1 interp (no buffer, def 0)
EXPECT_EQ(model->actuator_history[2], 3); // jnt2 nsample EXPECT_EQ(model->actuator_history[2], 3); // jnt2 nsample
EXPECT_EQ(model->actuator_history[3], 0); // jnt2 interp (ZOH) EXPECT_EQ(model->actuator_history[3], 0); // jnt2 interp (ZOH)
EXPECT_EQ(model->actuator_history[4], 10); // jnt3 nsample EXPECT_EQ(model->actuator_history[4], 10); // jnt3 nsample
@@ -3785,6 +3789,7 @@ TEST_F(ActuatorParseTest, ActuatorDelayRequiresHistory) {
EXPECT_THAT(error.data(), EXPECT_THAT(error.data(),
HasSubstr("setting delay > 0 without a history buffer")); HasSubstr("setting delay > 0 without a history buffer"));
} }
TEST_F(ActuatorParseTest, DampingArmatureDefaultsPropagate) { TEST_F(ActuatorParseTest, DampingArmatureDefaultsPropagate) {
static constexpr char xml[] = R"( static constexpr char xml[] = R"(
<mujoco> <mujoco>
@@ -3884,5 +3889,106 @@ TEST_F(XMLReaderTest, AttachConflictXMLMergeUnmergableError) {
HasSubstr("gravity: parent has 0 0 -10, child has 0 0 0")); HasSubstr("gravity: parent has 0 0 -10, child has 0 0 0"));
} }
TEST_F(XMLReaderTest, SelfAttach) {
static constexpr char xml[] = R"(
<mujoco model="self-attach-test">
<worldbody>
<body name="body1">
<geom name="geom1" size="1"/>
</body>
<body name="body2">
<attach body="body1" prefix="attached_"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, nullptr, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
mjModel* m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull());
int body2_id = mj_name2id(m, mjOBJ_BODY, "body2");
int attached_body1_id = mj_name2id(m, mjOBJ_BODY, "attached_body1");
EXPECT_GE(body2_id, 0);
EXPECT_GE(attached_body1_id, 0);
EXPECT_EQ(m->body_parentid[attached_body1_id], body2_id);
mj_deleteModel(m);
mj_deleteSpec(spec);
}
TEST_F(XMLReaderTest, SelfAttachCollisionError) {
static constexpr char xml[] = R"(
<mujoco model="self-attach-collision">
<worldbody>
<body name="body1"/>
<body name="attached_body1"/>
<body name="body2">
<attach body="body1" prefix="attached_"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, nullptr, error.data(), error.size());
EXPECT_THAT(spec, IsNull());
EXPECT_THAT(
error.data(),
HasSubstr("cannot self-attach: element attached_body1 already exists"));
}
TEST_F(XMLReaderTest, SelfAttachMissingError) {
static constexpr char xml[] = R"(
<mujoco model="self-attach-missing">
<worldbody>
<body name="body1">
<attach body="nonexistent" prefix="attached_"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, nullptr, error.data(), error.size());
EXPECT_THAT(spec, IsNull());
EXPECT_THAT(error.data(), HasSubstr("could not find body 'nonexistent' in "
"the current model for self-attachment"));
}
TEST_F(XMLReaderTest, SelfAttachFrame) {
static constexpr char xml[] = R"(
<mujoco model="self-attach-frame-test">
<worldbody>
<frame name="frame1">
<body name="body1" pos="-1 0 0">
<geom name="geom1" type="box" size="1 1 1"/>
</body>
</frame>
<body name="body2">
<attach frame="frame1" prefix="attached_"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, nullptr, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
mjModel* m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull()) << mjs_getError(spec);
int body2_id = mj_name2id(m, mjOBJ_BODY, "body2");
int attached_body1_id = mj_name2id(m, mjOBJ_BODY, "attached_body1");
EXPECT_GE(body2_id, 0);
EXPECT_GE(attached_body1_id, 0);
EXPECT_EQ(m->body_parentid[attached_body1_id], body2_id);
mj_deleteModel(m);
mj_deleteSpec(spec);
}
} // namespace } // namespace
} // namespace mujoco } // namespace mujoco