Add frame element to MJCF.

PiperOrigin-RevId: 585598244
Change-Id: I4c06be3dd586dab5a4458f05faaf1e7f2b615458
This commit is contained in:
Alessio Quaglino
2023-11-27 03:31:25 -08:00
committed by Copybara-Service
parent 8010aad7be
commit eb9568a48b
13 changed files with 411 additions and 17 deletions
+90 -2
View File
@@ -103,13 +103,72 @@ In the remainder of this chapter we describe all valid MJCF elements and their a
multiple contexts, in which case their meaning depends on the parent element. This is why we always show the parent as a
prefix in the documentation below.
.. _meta-element:
Meta elements
~~~~~~~~~~~~~
These elements are not strictly part of the low-level MJCF format definition, but rather instruct the compiler to
perform some operation on the model. A general property of meta-elements is that they disappear from the model upon
saving the XML. There are currently four meta-elements in MJCF:
- :ref:`include<include>` and :ref:`frame<frame>`, which are outside of the schema.
- :ref:`composite<body-composite>` and :ref:`flexcomp<body-flexcomp>` which are part of the schema, but serve to
procedurally generate other MJCF elements.
.. _frame:
**frame** (R)
^^^^^^^^^^^^^
The frame meta-element is a pure coordinate transformation that can wrap any group of elements in the kinematic tree
(under :ref:`worldbody<body>`). After compilation, frame elements disappear and their transformation is accumulated
in their direct children. The attributes of the frame meta-element are documented :ref:`below<body-frame>`.
.. collapse:: Usage example of frame
Loading this model and saving it:
.. code-block:: xml
<mujoco>
<worldbody>
<frame quat="0 0 1 0">
<geom name="Alice" quat="0 1 0 0" size="1"/>
</frame>
<frame pos="0 1 0">
<geom name="Bob" pos="0 1 0" size="1"/>
<body name="Carl" pos="1 0 0">
...
</body>
</frame>
</worldbody>
</mujoco>
Results in this model:
.. code-block:: xml
<mujoco>
<worldbody>
<geom name="Alice" quat="0 0 0 1" size="1"/>
<geom name="Bob" pos="0 2 0" size="1"/>
<body name="Carl" pos="1 1 0">
...
</body>
</worldbody>
</mujoco>
Note that in the saved model, the frame elements have disappeared but their transformation was accumulated with those
of their child elements.
.. _include:
**include** (*)
~~~~~~~~~~~~~~~
^^^^^^^^^^^^^^^
This element does not strictly speaking belong to MJCF. Instead it is a meta-element, used to assemble multiple XML
This element does not strictly belong to MJCF. Instead it is a meta-element, used to assemble multiple XML
files in a single document object model (DOM) before parsing. The included file must be a valid XML file with a unique
top-level element. This top-level element is removed by the parser, and the elements below it are inserted at the
location of the :el:`include` element. At least one element must be inserted as a result of this procedure. The
@@ -3913,6 +3972,35 @@ Associate this flexcomp with an :ref:`engine plugin<exPlugin>`. Either :at:`plug
:at:`instance`: :at-val:`string, optional`
Instance name, used for explicit plugin instantiation.
.. _body-frame:
:el-prefix:`body/` |-| **frame** (*)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Frames specify a coordinate transformation which is applied to all child elements. They disappear during compilation
and the transformation they encode is accumulated in their direct children. See :ref:`frame<frame>` for examples.
.. _frame-pos:
:at:`pos`: :at-val:`real(3), "0 0 0"`
The 3D position of the frame, in the parent coordinate system.
.. _frame-quat:
.. _frame-axisangle:
.. _frame-xyaxes:
.. _frame-zaxis:
.. _frame-euler:
:at:`quat`, :at:`axisangle`, :at:`xyaxes`, :at:`zaxis`, :at:`euler`
See :ref:`COrientation`.
.. _contact:
**contact** (*)
+2 -1
View File
@@ -7,10 +7,11 @@ Upcoming version (not yet released)
General
^^^^^^^
- Improved convergence of Signed Distance Function (SDF) collisions by using line search and a new objective function
for the optimization. This allows to decrease the number of initial points needed for finding the contacts and is more
robust for very small or large geom sizes.
- Added :ref:`frame<frame>` to MJCF, a :ref:`meta-element<meta-element>` which defines a pure coordinate transformation
on its direct children, without requiring a :ref:`body<body>`.
Plugins
^^^^^^^
+8 -3
View File
@@ -204,16 +204,21 @@ cameras and lights.
A related attribute is :ref:`compiler/angle<compiler-angle>`. It specifies whether angles in the MJCF file are expressed
in degrees or radians (after compilation, angles are always expressed in radians).
Positions are specified using
:at:`pos`: :at-val:`real(3), "0 0 0"`
Position relative to parent.
.. _COrientation:
Frame orientations
~~~~~~~~~~~~~~~~~~
^^^^^^^^^^^^^^^^^^
Several model elements have right-handed spatial frames associated with them. These are all the elements defined in the
kinematic tree except for joints. A spatial frame is defined by its position and orientation. Specifying 3D positions is
straightforward, but specifying 3D orientations can be challenging. This is why MJCF provides several alternative
mechanisms. No matter which mechanism the user chooses, the frame orientation is always represented as a unit quaternion
after compilation. Recall that a 3D rotation by angle :math:`a` around axis given by the unit vector :math:`(x, y, z)`
mechanisms. No matter which mechanism the user chooses, the frame orientation is always converted internally to a unit
quaternion. Recall that a 3D rotation by angle :math:`a` around axis given by the unit vector :math:`(x, y, z)`
corresponds to the quaternion :math:`(\cos(a/2), \: \sin(a/2) \cdot (x, y, z))`. Also recall that every 3D orientation
can be uniquely specified by a single 3D rotation by some angle around some axis.
+3 -1
View File
@@ -1506,7 +1506,9 @@ void mjCModel::CopyTree(mjModel* m) {
if (rotfound ||
!IsNullPose(m->jnt_pos+3*jid, NULL) ||
((pj->type==mjJNT_HINGE || pj->type==mjJNT_SLIDE) &&
((pj->locaxis[0]!=0) + (pj->locaxis[1]!=0) + (pj->locaxis[2]!=0))>1)) {
((mju_abs(pj->locaxis[0])>mjEPS) +
(mju_abs(pj->locaxis[1])>mjEPS) +
(mju_abs(pj->locaxis[2])>mjEPS)) > 1)) {
m->body_simple[i] = 0;
}
+91 -1
View File
@@ -491,6 +491,7 @@ mjCBase::mjCBase() {
xmlpos[0] = xmlpos[1] = -1;
model = 0;
def = 0;
frame = nullptr;
// plugin variables
is_plugin = false;
@@ -534,6 +535,15 @@ std::string mjCBase::GetAssetContentType(std::string_view resource_name,
}
void mjCBase::SetFrame(mjCFrame* _frame) {
if (!_frame) {
return;
}
frame = _frame;
frame->Compile();
}
//------------------ class mjCBody implementation --------------------------------------------------
// constructor
@@ -574,6 +584,7 @@ mjCBody::mjCBody(mjCModel* _model) {
// clear object lists
bodies.clear();
geoms.clear();
frames.clear();
joints.clear();
sites.clear();
cameras.clear();
@@ -587,6 +598,7 @@ mjCBody::~mjCBody() {
// delete objects allocated here
for (int i=0; i<bodies.size(); i++) delete bodies[i];
for (int i=0; i<geoms.size(); i++) delete geoms[i];
for (int i=0; i<frames.size(); i++) delete frames[i];
for (int i=0; i<joints.size(); i++) delete joints[i];
for (int i=0; i<sites.size(); i++) delete sites[i];
for (int i=0; i<cameras.size(); i++) delete cameras[i];
@@ -594,6 +606,7 @@ mjCBody::~mjCBody() {
bodies.clear();
geoms.clear();
frames.clear();
joints.clear();
sites.clear();
cameras.clear();
@@ -616,6 +629,15 @@ mjCBody* mjCBody::AddBody(mjCDef* _def) {
// create new frame and add it to body
mjCFrame* mjCBody::AddFrame(mjCFrame* _frame) {
mjCFrame* obj = new mjCFrame(model, _frame ? _frame : NULL);
frames.push_back(obj);
return obj;
}
// create new joint and add it to body
// _def==NULL means no defaults, unlike all others which inherit from body
mjCJoint* mjCBody::AddJoint(mjCDef* _def, bool isfree) {
@@ -938,7 +960,7 @@ void mjCBody::Compile(void) {
GeomFrame();
}
// both pos and ipos undefiend: error
// both pos and ipos undefined: error
if (!mjuu_defined(ipos[0]) && !mjuu_defined(pos[0])) {
throw mjCError(this, "body pos and ipos are both undefined");
}
@@ -980,6 +1002,11 @@ void mjCBody::Compile(void) {
}
}
// frame
if (frame) {
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
// compute local frame rel. to parent body
if (id>0) {
model->bodies[parentid]->MakeLocal(locpos, locquat, pos, quat);
@@ -1076,6 +1103,39 @@ void mjCBody::Compile(void) {
//------------------ class mjCFrame implementation -------------------------------------------------
// initialize frame
mjCFrame::mjCFrame(mjCModel* _model, mjCFrame* _frame) {
compiled = false;
model = _model;
frame = _frame ? _frame : NULL;
mju_zero3(pos);
mjuu_setvec(quat, 1, 0, 0, 0);
}
void mjCFrame::Compile() {
if (compiled) {
return;
}
const char* err = alt.Set(quat, 0, model->degree, model->euler);
if (err) {
throw mjCError(this, "orientation specification error '%s' in site %d", err, id);
}
// compile parents and accumulate result
if (frame) {
frame->Compile();
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
mjuu_normvec(quat, 4);
compiled = true;
}
//------------------ class mjCJoint implementation -------------------------------------------------
// initialize default joint
@@ -1197,6 +1257,13 @@ int mjCJoint::Compile(void) {
}
}
// frame
if (frame) {
double mat[9];
mjuu_quat2mat(mat, frame->quat);
mjuu_mulvecmat(axis, axis, mat);
}
// FREE or BALL: set axis to (0,0,1)
if (type==mjJNT_FREE || type==mjJNT_BALL) {
axis[0] = axis[1] = 0;
@@ -1223,6 +1290,9 @@ int mjCJoint::Compile(void) {
if (type!=mjJNT_FREE) {
double qunit[4] = {1, 0, 0, 0};
double qloc[4];
if (frame) {
mjuu_frameaccum(pos, qunit, frame->pos, frame->quat);
}
body->MakeLocal(locpos, qloc, pos, qunit);
} else {
mjuu_zerovec(locpos, 3);
@@ -1842,6 +1912,11 @@ void mjCGeom::Compile(void) {
throw mjCError(this, "plugin '%s' does not support sign distance fields", plugin->name);
}
}
// frame
if (frame) {
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
}
@@ -1952,6 +2027,11 @@ void mjCSite::Compile(void) {
}
}
// frame
if (frame) {
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
// normalize quaternion
mjuu_normvec(quat, 4);
@@ -2017,6 +2097,11 @@ void mjCCamera::Compile(void) {
throw mjCError(this, "orientation specification error '%s' in camera %d", err, id);
}
// frame
if (frame) {
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
// normalize quaternion
mjuu_normvec(quat, 4);
@@ -2121,6 +2206,11 @@ mjCLight::mjCLight(mjCModel* _model, mjCDef* _def) {
void mjCLight::Compile(void) {
double locquat[4], quat[4]= {1, 0, 0, 0};
// frame
if (frame) {
mjuu_frameaccum(pos, quat, frame->pos, frame->quat);
}
// normalize direction, make sure it is not zero
if (mjuu_normvec(dir, 3)<mjMINVAL) {
throw mjCError(this, "zero direction in light '%s' (id = %d)", name.c_str(), id);
+29
View File
@@ -30,6 +30,7 @@ class mjCError;
class mjCAlternative;
class mjCBase;
class mjCBody;
class mjCFrame;
class mjCJoint;
class mjCGeom;
class mjCSite;
@@ -176,12 +177,16 @@ class mjCBase {
// content type from resource_name; throw on failure
std::string GetAssetContentType(std::string_view resource_name, std::string_view raw_text);
// Add frame transformation
void SetFrame(mjCFrame* _frame);
std::string name; // object name
std::string classname; // defaults class name
int id; // object id
int xmlpos[2]; // row and column in xml file
mjCDef* def; // defaults class used to init this object
mjCModel* model; // pointer to model that created object
mjCFrame* frame; // pointer to frame transformation
// plugin support
bool is_plugin;
@@ -215,6 +220,7 @@ class mjCBody : public mjCBase {
public:
// API for adding objects to body
mjCBody* AddBody(mjCDef* = 0);
mjCFrame* AddFrame(mjCFrame* = 0);
mjCJoint* AddJoint(mjCDef* = 0, bool isfree = false);
mjCGeom* AddGeom(mjCDef* = 0);
mjCSite* AddSite(mjCDef* = 0);
@@ -278,6 +284,7 @@ class mjCBody : public mjCBase {
// objects allocated by Add functions
std::vector<mjCBody*> bodies; // child bodies
std::vector<mjCGeom*> geoms; // geoms attached to this body
std::vector<mjCFrame*> frames; // frames attached to this body
std::vector<mjCJoint*> joints; // joints allowing motion relative to parent
std::vector<mjCSite*> sites; // sites attached to this body
std::vector<mjCCamera*> cameras; // cameras attached to this body
@@ -286,6 +293,28 @@ class mjCBody : public mjCBase {
//------------------------- class mjCFrame ---------------------------------------------------------
// Describes a coordinate transformation relative to its parent
class mjCFrame : public mjCBase {
friend class mjCBase;
friend class mjCBody;
friend class mjCModel;
public:
double pos[3]; // frame position
double quat[4]; // frame orientation
mjCAlternative alt; // alternative orientation specification
private:
bool compiled; // frame already compiled
mjCFrame(mjCModel* = 0, mjCFrame* = 0); // constructor
void Compile(void); // compiler
};
//------------------------- class mjCJoint ---------------------------------------------------------
// Describes a motion degree of freedom of a body relative to its parent
+8 -3
View File
@@ -191,9 +191,14 @@ void mjuu_mulquat(double* res, const double* qa, const double* qb) {
// multiply vector by 3-by-3 matrix
void mjuu_mulvecmat(double* res, const double* vec, const double* mat) {
res[0] = mat[0]*vec[0] + mat[1]*vec[1] + mat[2]*vec[2];
res[1] = mat[3]*vec[0] + mat[4]*vec[1] + mat[5]*vec[2];
res[2] = mat[6]*vec[0] + mat[7]*vec[1] + mat[8]*vec[2];
double tmp[3] = {
mat[0]*vec[0] + mat[1]*vec[1] + mat[2]*vec[2],
mat[3]*vec[0] + mat[4]*vec[1] + mat[5]*vec[2],
mat[6]*vec[0] + mat[7]*vec[1] + mat[8]*vec[2]
};
res[0] = tmp[0];
res[1] = tmp[1];
res[2] = tmp[2];
}
+25 -4
View File
@@ -872,7 +872,7 @@ void mjXReader::Parse(XMLElement* root) {
for (XMLElement* section = root->FirstChildElement("worldbody"); section;
section = section->NextSiblingElement("worldbody")) {
Body(section, model->GetWorld());
Body(section, model->GetWorld(), nullptr);
}
for (XMLElement* section = root->FirstChildElement("contact"); section;
@@ -2949,7 +2949,7 @@ void mjXReader::Asset(XMLElement* section) {
// body/world section parser; recursive
void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
void mjXReader::Body(XMLElement* section, mjCBody* pbody, mjCFrame* frame) {
string text, name;
XMLElement* elem;
int n;
@@ -2960,7 +2960,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
}
// no attributes allowed in world body
if (pbody->id==0 && section->FirstAttribute()) {
if (pbody->id==0 && section->FirstAttribute() && !frame) {
throw mjXError(section, "World body cannot have attributes");
}
@@ -3000,6 +3000,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create joint and parse
mjCJoint* pjoint = pbody->AddJoint(def);
OneJoint(elem, pjoint);
pjoint->SetFrame(frame);
}
// freejoint sub-element
@@ -3011,6 +3012,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create free joint without defaults
mjCJoint* pjoint = pbody->AddJoint(NULL, true);
pjoint->SetFrame(frame);
// save defaults after creation, to make sure writing is ok
pjoint->def = def;
@@ -3025,6 +3027,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create geom and parse
mjCGeom* pgeom = pbody->AddGeom(def);
OneGeom(elem, pgeom);
pgeom->SetFrame(frame);
// discard visual
if (!pgeom->contype && !pgeom->conaffinity && model->discardvisual) {
@@ -3038,6 +3041,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create site and parse
mjCSite* psite = pbody->AddSite(def);
OneSite(elem, psite);
psite->SetFrame(frame);
}
// camera sub-element
@@ -3045,6 +3049,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create camera and parse
mjCCamera* pcam = pbody->AddCamera(def);
OneCamera(elem, pcam);
pcam->SetFrame(frame);
}
// light sub-element
@@ -3052,6 +3057,7 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// create light and parse
mjCLight* plight = pbody->AddLight(def);
OneLight(elem, plight);
plight->SetFrame(frame);
}
// plugin sub-element
@@ -3071,6 +3077,18 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
OneFlexcomp(elem, pbody);
}
// frame sub-element
else if (name=="frame") {
mjCFrame* pframe = pbody->AddFrame(frame);
GetXMLPos(elem, pframe);
ReadAttr(elem, "pos", 3, pframe->pos, text);
ReadQuat(elem, "quat", pframe->quat, text);
ReadAlternative(elem, pframe->alt);
Body(elem, pbody, pframe);
}
// body sub-element
else if (name=="body") {
// read childdef
@@ -3102,8 +3120,11 @@ void mjXReader::Body(XMLElement* section, mjCBody* pbody) {
// read userdata
ReadVector(elem, "user", pchild->userdata, text);
// add frame
pchild->SetFrame(frame);
// make recursive call
Body(elem, pchild);
Body(elem, pchild, nullptr);
}
// no match
+2 -1
View File
@@ -42,7 +42,8 @@ class mjXReader : public mjXBase {
void Visual(tinyxml2::XMLElement* section); // visual section
void Statistic(tinyxml2::XMLElement* section); // statistic section
void Asset(tinyxml2::XMLElement* section); // asset section
void Body(tinyxml2::XMLElement* section, mjCBody* pbody); // body/world section
void Body(tinyxml2::XMLElement* section, mjCBody* pbody,
mjCFrame* pframe); // body/world section
void Contact(tinyxml2::XMLElement* section); // contact section
void Deformable(tinyxml2::XMLElement* section); // deformable section
void Equality(tinyxml2::XMLElement* section); // equality section
+3 -1
View File
@@ -372,7 +372,9 @@ bool mjXSchema::NameMatch(XMLElement* elem, int level) {
return true;
}
return false;
if (level>=1 && !strcmp(elem->Value(), "frame")) {
return true;
}
}
// regular check
+43
View File
@@ -0,0 +1,43 @@
<mujoco>
<default>
<geom type="box" size=".1 .2 .3"/>
</default>
<worldbody>
<frame euler="0 0 30">
<geom pos=".5 0 0" euler="0 0 20"/>
</frame>
<frame quat="0 1 0 0">
<geom quat="0 -1 0 0"/>
</frame>
<body pos="0 .5 0">
<frame quat="0 1 0 0">
<geom/>
</frame>
</body>
<frame euler="0 0 30">
<frame euler="0 0 20">
<geom/>
</frame>
</frame>
<body>
<frame pos="0 1 0">
<geom pos="0 1 0"/>
<body pos="1 0 0">
<geom pos="0 0 1"/>
</body>
</frame>
</body>
<body>
<geom/>
<frame euler="90 0 0">
<joint type="hinge" axis="0 0 1"/>
</frame>
</body>
</worldbody>
</mujoco>
+74
View File
@@ -1398,5 +1398,79 @@ TEST_F(SpringrangeTest, InvalidRange) {
EXPECT_THAT(error.data(), HasSubstr("invalid springlength in tendon"));
}
// ------------- test frame ----------------------------------------------------
TEST_F(MujocoTest, Frame) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<frame euler="0 0 30">
<geom size=".1" euler="0 0 20"/>
</frame>
<frame axisangle="0 0 1 90">
<frame axisangle="0 1 0 90">
<geom size=".1"/>
</frame>
</frame>
<body>
<frame pos="0 1 0">
<geom size=".1" pos="0 1 0"/>
<body pos="1 0 0">
<geom size=".1" pos="0 0 1"/>
</body>
</frame>
</body>
<body>
<geom size=".1"/>
<frame euler="90 0 0">
<joint type="hinge" axis="0 0 1"/>
</frame>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjModel* m = LoadModelFromString(xml, error.data(), error.size());
EXPECT_THAT(m, testing::NotNull()) << error.data();
EXPECT_EQ(m->nbody, 4);
// geom quat transformed to euler = 0 0 50
EXPECT_NEAR(m->geom_quat[0], mju_cos(25. * mjPI / 180.), 1e-3);
EXPECT_NEAR(m->geom_quat[1], 0, 0);
EXPECT_NEAR(m->geom_quat[2], 0, 0);
EXPECT_NEAR(m->geom_quat[3], mju_sin(25. * mjPI / 180.), 1e-3);
// geom transformed to frame 0 1 0, 0 0 1, 1 0 0
EXPECT_NEAR(m->geom_quat[4], .5, 1e-6);
EXPECT_NEAR(m->geom_quat[5], .5, 1e-6);
EXPECT_NEAR(m->geom_quat[6], .5, 1e-6);
EXPECT_NEAR(m->geom_quat[7], .5, 1e-6);
// geom pos transformed from 0 1 0 to 0 2 0
EXPECT_EQ(m->geom_pos[6], 0);
EXPECT_EQ(m->geom_pos[7], 2);
EXPECT_EQ(m->geom_pos[8], 0);
// body pos transformed from 1 0 0 to 1 1 0
EXPECT_EQ(m->body_pos[6], 1);
EXPECT_EQ(m->body_pos[7], 1);
EXPECT_EQ(m->body_pos[8], 0);
// nested geom pos not transformed
EXPECT_EQ(m->geom_pos[ 9], 0);
EXPECT_EQ(m->geom_pos[10], 0);
EXPECT_EQ(m->geom_pos[11], 1);
// joint axis transformed to 0 -1 0
EXPECT_NEAR(m->jnt_axis[0], 0, 1e-6);
EXPECT_NEAR(m->jnt_axis[1], -1, 1e-6);
EXPECT_NEAR(m->jnt_axis[2], 0, 1e-6);
mj_deleteModel(m);
}
} // namespace
} // namespace mujoco
+33
View File
@@ -407,6 +407,39 @@ TEST_F(XMLReaderTest, InvalidDoubleOrientation) {
}
}
// ---------------------- test frame parsing ---------------------------------
TEST_F(XMLReaderTest, ParseFrame) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<frame euler="0 0 30">
<geom size=".1" euler="0 0 20"/>
</frame>
<body>
<frame pos="0 1 0">
<geom size=".1" pos="0 1 0"/>
<body pos="1 0 0">
<geom size=".1" pos="0 0 1"/>
</body>
</frame>
</body>
<frame euler="0 0 30">
<frame euler="0 0 20">
<geom size=".1"/>
</frame>
</frame>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjModel* m = LoadModelFromString(xml, error.data(), error.size());
EXPECT_THAT(m, testing::NotNull()) << error.data();
mj_deleteModel(m);
}
// ---------------------- test camera parsing ---------------------------------
TEST_F(XMLReaderTest, CameraInvalidFovyAndSensorsize) {