Save flex node coordinates in the new nodecoord attribute.

Interpolated flexes with pinned nodes could not be reloaded after saving:
pinned nodes share their parent body, and their positions within it lived
only in mjsFlex.node, which had no MJCF attribute. On reload the pinned
nodes collapsed onto the parent body origin, degenerating the trilinear
interpolation grid ("flex grid rotation R0 is not orthonormal"). This
made model/flex/strain.xml and gripper_trilinear.xml fail to round-trip.

Add flex/nodecoord, real(3*nnode), the node analog of flex/vertex: local
node coordinates within the corresponding body frames. The reader picks
it up from the regenerated schema tables; the writer emits it with the
precision-aware WriteVector, since VectorToString ignores the XML
precision setting and truncating node coordinates to 6 digits while body
positions carry 17 fails the R0 orthonormality check at full precision.

Add a WritesPinnedFlexNodes round-trip regression test, and remove the
two write-read sweep exclusions documenting this bug. The removed
substring filter "strain" was also matching core_constraint, silently
excluding that entire testdata directory from the sweep; its ~40 models
are now covered and pass.

PiperOrigin-RevId: 959025281
Change-Id: I2fed28c01491c5a8431e813102a423d12b659911
This commit is contained in:
Yuval Tassa
2026-08-04 08:03:25 -07:00
committed by Copybara-Service
parent 574b6bd6bf
commit 9553926158
11 changed files with 56 additions and 4 deletions
+7
View File
@@ -4389,6 +4389,13 @@ cases, the user will specify a :el:`flexcomp` which will then automatically cons
An array of MuJoCo body names (separated by white space) to which each node belongs. The number of body names
should equal the number of nodes (nnode). See the flexcomp :ref:`dof<body-flexcomp-dof>` attribute for more details.
.. _deformable-flex-nodecoord:
:at:`nodecoord`: :at-val:`real(3*nnode), optional`
The local coordinates of the nodes within the corresponding body frames. If this attribute is omitted, all
coordinates are (0,0,0) or in other words, the nodes coincide with the centers of the body frames. Nonzero
coordinates are required when several nodes share a body, for example nodes pinned to a parent body.
.. _deformable-flex-cellcount:
:at:`cellcount`: :at-val:`int(3), optional`
+3
View File
@@ -1694,6 +1694,9 @@
.. grid-item::
:ref:`node<deformable-flex-node>`
.. grid-item::
:ref:`nodecoord<deformable-flex-nodecoord>`
.. grid-item::
:ref:`cellcount<deformable-flex-cellcount>`
+4
View File
@@ -61,6 +61,10 @@ Models
Bug fixes
^^^^^^^^^
- Fixed a bug where models with pinned interpolated flex nodes (e.g. a :ref:`flexcomp<body-flexcomp>` with
:at:`dof` "trilinear" and pinned vertices) could not be reloaded after saving: node coordinates within their body
frames were not saved, degenerating the interpolation grid. They are now saved in the new flex
:ref:`nodecoord<deformable-flex-nodecoord>` attribute.
- Fixed a bug in the box-box collider where near-degenerate face clipping could generate contacts with spuriously
large penetration depth between nearly touching thin boxes with positive margin, causing resting stacks to explode.
- Fixed a bug in the box-box collider where penetrations deeper than a box's smallest half-size could produce no
+1
View File
@@ -1043,6 +1043,7 @@ class mjCFlex: public mjCFlex_, private mjsFlex {
const std::vector<float>& get_texcoord() const { return texcoord_; }
const std::vector<int>& get_elemtexcoord() const { return elemtexcoord_; }
const std::vector<std::string>& get_nodebody() const { return nodebody_; }
const std::vector<double>& get_node() const { return node_; }
bool HasTexcoord() const; // texcoord not null
void DelTexcoord(); // delete texcoord
+5
View File
@@ -2389,6 +2389,11 @@
<xs:documentation>space-separated body names</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="nodecoord" type="doublelist">
<xs:annotation>
<xs:documentation>node positions within body frames</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="cellcount" type="int3" default="1 1 1">
<xs:annotation>
<xs:documentation>seeded before reading</xs:documentation>
+1
View File
@@ -480,6 +480,7 @@ inline constexpr mjXAttr kFlexAttrs[] = {
{"texcoord", mjXAttr::kFloatVec, 1, true, false, false, false, (int)offsetof(mjsFlex, texcoord)},
{"elemtexcoord", mjXAttr::kIntVec, 1, true, false, false, false, (int)offsetof(mjsFlex, elemtexcoord)},
{"node", mjXAttr::kStringVec, 1, true, false, false, false, (int)offsetof(mjsFlex, nodebody)},
{"nodecoord", mjXAttr::kDoubleVec, 1, true, false, false, false, (int)offsetof(mjsFlex, node)},
};
inline constexpr int kFlexAttrsN = sizeof(kFlexAttrs) / sizeof(kFlexAttrs[0]);
+2 -1
View File
@@ -247,7 +247,8 @@ std::vector<const char*> MJCF[] = {
{"deformable", "*"},
{"<"},
{"flex", "*", "name", "group", "dim", "radius", "material", "rgba", "flatskin", "body",
"vertex", "element", "texcoord", "elemtexcoord", "node", "cellcount", "dof"},
"vertex", "element", "texcoord", "elemtexcoord", "node", "nodecoord", "cellcount",
"dof"},
{"<"},
{"contact", "?", "contype", "conaffinity", "condim", "priority", "friction", "solmix",
"solref", "solimp", "margin", "gap", "internal", "selfcollide", "activelayers",
+1
View File
@@ -1299,6 +1299,7 @@ element flex : mjsFlex {
texcoord : float[]
elemtexcoord : int[]
node : string (field=nodebody) # space-separated body names
nodecoord : double[] (field=node) # node positions within body frames
cellcount : int[3] = {1, 1, 1} (reading=custom) # seeded before reading
dof : enum<fdof> (reading=custom) # lowers to interpolation order
child flexcomp_contact ?
+3
View File
@@ -175,6 +175,9 @@ void mjXWriter::OneFlex(XMLElement* elem, const mjCFlex* flex) {
text = VectorToString(flex->get_nodebody());
WriteAttrTxt(elem, "node", text);
}
if (!flex->get_node().empty()) {
WriteVector(elem, "nodecoord", flex->get_node());
}
// contact subelement
XMLElement* cont = InsertEnd(elem, "contact");
+29
View File
@@ -936,6 +936,35 @@ TEST_F(XMLWriterTest, WritesSkin) {
EXPECT_THAT(mtemp->nskin, 1);
}
TEST_F(XMLWriterTest, WritesPinnedFlexNodes) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body name="parent">
<flexcomp name="soft" type="box" count="3 3 3" spacing=".03 .01 .01" mass=".5" dof="trilinear">
<contact selfcollide="none" internal="false"/>
<edge equality="true"/>
<pin id="4 5 6 7"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
MjModelPtr model = LoadModelFromString(xml);
ASSERT_THAT(model.get(), NotNull());
// pinned nodes have no body of their own: their coordinates in the parent
// body frame must be saved or the interpolation grid degenerates on reload
std::string saved_xml = SaveAndReadXml(model.get());
EXPECT_THAT(saved_xml, HasSubstr("nodecoord"));
char error[1024];
MjModelPtr mtemp = LoadModelFromString(saved_xml, error, sizeof(error));
ASSERT_THAT(mtemp.get(), NotNull()) << error;
EXPECT_EQ(SaveAndReadXml(mtemp.get()), saved_xml);
}
TEST_F(XMLWriterTest, WritesHfield) {
static constexpr char xml[] = R"(
<mujoco>
-3
View File
@@ -67,9 +67,6 @@ std::vector<std::string> GetWriteReadTestModels() {
absl::StrContains(xml, "cube_3x3x3") ||
// flex_stiffness: stretch amplifies geometry XML rounds on save
absl::StrContains(xml, "flex/bag") ||
// exclude files that fail since we do not save pinned flex nodes
absl::StrContains(xml, "gripper_trilinear") ||
absl::StrContains(xml, "strain") ||
// exclude conflict tests (known option conflict warnings/errors)
absl::StrContains(xml, "xml/testdata/parent_")) {
continue;