Add policies for global attribute conflict resolution upon attach

For example when loading parent_merge.xml:

```
WARNING: Attach conflict when attaching 'child' to 'parent_merge', policy is 'merge'
timestep: parent has 0.005, child has 0.002, taking the minimum
iterations: parent has 50, child has 100, taking the maximum
flag 'Damper': added from child
```

When loading parent_error.xml:

```
XML Error: Attach conflict when attaching 'child' to 'parent_error', policy is 'error'
timestep: parent has 0.005, child has 0.002
iterations: parent has 50, child has 100
Element 'attach', line 10
```
PiperOrigin-RevId: 933620810
Change-Id: Ib477863b5ef763474d27fb4be5a4148be1d5d500
This commit is contained in:
Yuval Tassa
2026-06-17 03:34:10 -07:00
committed by Copybara-Service
parent 30357c38a3
commit 410c73168c
37 changed files with 2705 additions and 449 deletions
+9
View File
@@ -915,6 +915,15 @@ Type of built-in procedural mesh.
.. mujoco-include:: mjtMeshBuiltin
.. _mjtConflict:
mjtConflict
~~~~~~~~~~~
Conflict resolution mode for attach.
.. mujoco-include:: mjtConflict
.. _mjtCTimer:
+21
View File
@@ -900,6 +900,27 @@ has any effect. The settings here are global and apply to the entire model.
:at:`saveinertial`: :at-val:`[false, true], "false"`
If set to "true", the compiler will save explicit :ref:`inertial <body-inertial>` clauses for all bodies.
.. _compiler-conflict:
:at:`conflict`: :at-val:`[warning, merge, error], "warning"`
This attribute controls how conflicting global attributes (physics options, sizes, visual settings) are resolved when
a child spec is attached to a parent using :ref:`mjs_attach`. A conflict occurs when both the parent and child specify
authored values for the same field and those values differ. See :ref:`Attribute Merging <meAttributeMerging>` for
details and a per-field table.
:at-val:`warning`
Parent values take precedence. When a conflict is detected, a warning is emitted but the parent value is not
modified. This is the default and preserves the pre-existing attachment behavior.
:at-val:`merge`
Fields are merged using field-specific strategies (minimum, maximum, OR, or error), depending on the field's
semantics. When only the child specifies an authored value, it is adopted by the parent. See the
:ref:`merging table <meAttributeMergingTable>` for per-field details.
:at-val:`error`
Any conflict between authored values results in a compile error. This is the strictest mode and is useful for
detecting unintended attribute mismatches.
.. _compiler-lengthrange:
:el-prefix:`compiler/` |-| **lengthrange** |?|
+3
View File
@@ -254,6 +254,9 @@
.. grid-item::
:ref:`alignfree<compiler-alignfree>`
.. grid-item::
:ref:`conflict<compiler-conflict>`
.. dropdown:: :ref:`lengthrange<compiler-lengthrange>` :octicon:`dot`
+11
View File
@@ -22,6 +22,17 @@ General
- Added :ref:`mjs_numWarnings` and :ref:`mjs_getWarning` for retrieving all warnings accumulated during model
compilation and attachment. Deprecated :ref:`mjs_isWarning` in favor of ``mjs_numWarnings(s) > 0``.
- Added the :ref:`compiler/conflict<compiler-conflict>` attribute for controlling how conflicting global attributes
are resolved during :ref:`attachment<mjs_attach>`. Possible values are "warning" (default: parent values take
precedence, warnings emitted on conflicts), "merge" (per-field min/max/error strategy), and "error" (any
conflict raises an error). See :ref:`Attribute Merging <meAttributeMerging>` for details.
.. admonition:: Future breaking API changes
:class: warning
The current default conflict resolution policy "warn" (ignore the child model) is backward compatible.
However, the default policy will change to "merge" in a future release.
- Improved primal solver convergence under float32. Improvements initially proposed by :github:user:`n3b` in
:issue:`2313` and :github:user:`denzeler-nvidia` in :doc:`MJWarp <mjwarp/index>` pull request
`1374 <https://github.com/google-deepmind/mujoco_warp/pull/1374>`__.
+6
View File
@@ -1549,6 +1549,11 @@ typedef enum mjtOrientation_ { // type of orientation specifier
mjORIENTATION_ZAXIS, // z axis (minimal rotation)
mjORIENTATION_EULER, // Euler angles
} mjtOrientation;
typedef enum mjtConflict_ { // conflict resolution for attach
mjCONFLICT_WARNING = 0, // keep parent, warn on conflict
mjCONFLICT_MERGE, // merge: min/max/error per field
mjCONFLICT_ERROR, // error on any conflict
} mjtConflict;
typedef enum mjtCTimer_ { // compiler timing categories
// top-level timers (wall-clock)
mjCTIMER_TOTAL = 0, // total compile time
@@ -1585,6 +1590,7 @@ typedef struct mjsCompiler_ { // compiler options
int inertiagrouprange[2]; // range of geom groups used to compute inertia
mjtByte saveinertial; // save explicit inertial clause for all bodies to XML
int alignfree; // align free joints with inertial frame
int conflict; // conflict resolution for attach (mjtConflict)
mjLROpt LRopt; // options for lengthrange computation
mjString* meshdir; // mesh and hfield directory
mjString* texturedir; // texture directory
+61
View File
@@ -171,6 +171,67 @@ Note also that once a child is attached by reference to a parent, the child cann
finalized. If a second attachment is performed without compilation, the keyframes from the first attachment will be
lost.
.. _meAttributeMerging:
Attribute Merging
^^^^^^^^^^^^^^^^^
When attaching a child spec (or an element from a child spec) to a parent spec using :ref:`mjs_attach`, global
attributes from the child may conflict with those in the parent. A conflict occurs when both the parent and child
specify authored values for the same field and those values differ. Note that for XML-based models, explicitly writing a
value (even if it matches the default value) counts as authoring and can trigger conflicts. The
:ref:`compiler/conflict<compiler-conflict>` attribute controls how such conflicts are resolved. Fields where only one
side specifies an authored value never conflict.
:at-val:`warning` (default)
Parent values take precedence. Whenever a conflict is detected, a warning is emitted but the parent value is not
modified. This preserves the pre-existing attachment behavior.
:at-val:`merge`
Attribute values are merged using a per-field strategy as described in the table below. When only the child specifies
an authored value, it is adopted by the parent.
:at-val:`error`
Any conflict results in a compile error. No values are modified.
The table below describes the per-field merge strategy used in :at-val:`merge` mode.
.. list-table:: Attribute Merging Behavior (:at-val:`merge` mode)
:widths: 15 60 25
:header-rows: 1
:name: meAttributeMergingTable
* - Behavior
- Fields
- Justification
* - **Minimum**
- **option**: :ref:`timestep<option-timestep>`, :ref:`tolerance<option-tolerance>`, :ref:`ls_tolerance<option-ls_tolerance>`,
:ref:`noslip_tolerance<option-noslip_tolerance>`, :ref:`ccd_tolerance<option-ccd_tolerance>`,
:ref:`sleep_tolerance<option-sleep_tolerance>`,
|br| **visual**: :ref:`znear<visual-map-znear>`, :ref:`realtime<visual-global-realtime>`
- Preserves precision and stability.
* - **Maximum**
- **option**: :ref:`iterations<option-iterations>`, :ref:`ls_iterations<option-ls_iterations>`,
:ref:`noslip_iterations<option-noslip_iterations>`, :ref:`ccd_iterations<option-ccd_iterations>`,
:ref:`sdf_iterations<option-sdf_iterations>`, :ref:`sdf_initpoints<option-sdf_initpoints>`,
|br| **size**: :ref:`memory<size-memory>`, :ref:`nkey<size-nkey>`, :ref:`nuserdata<size-nuserdata>`,
:ref:`nuser_body<size-nuser_body>`, :ref:`nuser_jnt<size-nuser_jnt>`, :ref:`nuser_geom<size-nuser_geom>`, :ref:`nuser_site<size-nuser_site>`,
:ref:`nuser_cam<size-nuser_cam>`, :ref:`nuser_tendon<size-nuser_tendon>`, :ref:`nuser_actuator<size-nuser_actuator>`, :ref:`nuser_sensor<size-nuser_sensor>`
|br| **visual**: :ref:`zfar<visual-map-zfar>`
- Ensures sufficient resources and limits.
* - **OR (union)**
- **option**: :ref:`disableflags<option-flag>`, :ref:`enableflags<option-flag>`,
:ref:`disableactuator<option-actuatorgroupdisable>`
- Flags from both models are combined.
* - **Error**
- **option**: :ref:`gravity<option-gravity>`, :ref:`wind<option-wind>`, :ref:`magnetic<option-magnetic>`,
:ref:`density<option-density>`, :ref:`viscosity<option-viscosity>`, :ref:`integrator<option-integrator>`,
:ref:`cone<option-cone>`, :ref:`jacobian<option-jacobian>`, :ref:`solver<option-solver>`,
:ref:`impratio<option-impratio>`,
:ref:`o_margin<option-o_margin>`, :ref:`o_solref<option-o_solref>`, :ref:`o_solimp<option-o_solimp>`,
:ref:`o_friction<option-o_friction>`
- Raised if non-default values conflict.
.. _meDefault:
Default classes
+6
View File
@@ -128,6 +128,11 @@ typedef enum mjtOrientation_ { // type of orientation specifier
mjORIENTATION_EULER, // Euler angles
} mjtOrientation;
typedef enum mjtConflict_ { // conflict resolution for attach
mjCONFLICT_WARNING = 0, // keep parent, warn on conflict
mjCONFLICT_MERGE, // merge: min/max/error per field
mjCONFLICT_ERROR, // error on any conflict
} mjtConflict;
typedef enum mjtCTimer_ { // compiler timing categories
// top-level timers (wall-clock)
@@ -171,6 +176,7 @@ typedef struct mjsCompiler_ { // compiler options
int inertiagrouprange[2]; // range of geom groups used to compute inertia
mjtByte saveinertial; // save explicit inertial clause for all bodies to XML
int alignfree; // align free joints with inertial frame
int conflict; // conflict resolution for attach (mjtConflict)
mjLROpt LRopt; // options for lengthrange computation
mjString* meshdir; // mesh and hfield directory
mjString* texturedir; // texture directory
+21 -21
View File
@@ -25,27 +25,27 @@
//-------------------------------- mjsCompiler -----------------------------------------------------
#define MJSCOMPILER_FIELDS \
X ( mjtByte, autolimits, 1 ) \
X ( double, boundmass, 1 ) \
X ( double, boundinertia, 1 ) \
X ( double, settotalmass, 1 ) \
X ( mjtByte, balanceinertia, 1 ) \
X ( mjtByte, fitaabb, 1 ) \
X ( mjtByte, degree, 1 ) \
XVEC( char, eulerseq, 3 ) \
X ( mjtByte, discardvisual, 1 ) \
X ( mjtByte, usethread, 1 ) \
X ( mjtByte, fusestatic, 1 ) \
X ( int, inertiafromgeom, 1 ) \
XVEC( int, inertiagrouprange, 2 ) \
X ( mjtByte, saveinertial, 1 ) \
X ( int, alignfree, 1 ) \
X ( mjLROpt, LRopt, 1 ) \
X ( mjString*, meshdir, 1 ) \
X ( mjString*, texturedir, 1 ) \
X ( uint64_t, authored, 1 )
#define MJSCOMPILER_FIELDS \
X(mjtByte, autolimits, 1) \
X(double, boundmass, 1) \
X(double, boundinertia, 1) \
X(double, settotalmass, 1) \
X(mjtByte, balanceinertia, 1) \
X(mjtByte, fitaabb, 1) \
X(mjtByte, degree, 1) \
XVEC(char, eulerseq, 3) \
X(mjtByte, discardvisual, 1) \
X(mjtByte, usethread, 1) \
X(mjtByte, fusestatic, 1) \
X(int, inertiafromgeom, 1) \
XVEC(int, inertiagrouprange, 2) \
X(mjtByte, saveinertial, 1) \
X(int, alignfree, 1) \
X(int, conflict, 1) \
X(mjLROpt, LRopt, 1) \
X(mjString*, meshdir, 1) \
X(mjString*, texturedir, 1) \
X(uint64_t, authored, 1)
//-------------------------------- mjSpec ----------------------------------------------------------
+10
View File
@@ -731,6 +731,16 @@ ENUMS: Mapping[str, EnumDecl] = dict([
('mjORIENTATION_EULER', 4),
]),
)),
('mjtConflict',
EnumDecl(
name='mjtConflict',
declname='enum mjtConflict_',
values=dict([
('mjCONFLICT_WARNING', 0),
('mjCONFLICT_MERGE', 1),
('mjCONFLICT_ERROR', 2),
]),
)),
('mjtCTimer',
EnumDecl(
name='mjtCTimer',
+5
View File
@@ -6977,6 +6977,11 @@ STRUCTS: Mapping[str, StructDecl] = dict([
type=ValueType(name='int'),
doc='align free joints with inertial frame',
),
StructFieldDecl(
name='conflict',
type=ValueType(name='int'),
doc='conflict resolution for attach (mjtConflict)',
),
StructFieldDecl(
name='LRopt',
type=ValueType(name='mjLROpt'),
+129 -2
View File
@@ -20,6 +20,7 @@ import math
import os
import textwrap
import typing
import warnings
import zipfile # pylint: disable=unused-import
from absl import flags
@@ -1160,7 +1161,7 @@ class SpecsTest(absltest.TestCase):
spec = mujoco.MjSpec()
material = spec.add_material(name='mat')
texture = spec.add_texture(
spec.add_texture(
name='tex', builtin=mujoco.mjtBuiltin.mjBUILTIN_FLAT, width=2, height=2
)
@@ -1795,7 +1796,9 @@ class SpecsTest(absltest.TestCase):
sidesite = body.add_site(name='sidesite', pos=[2, 0, -5])
site4 = body.add_site(name='site4', pos=[0, 1, -6])
sphere = spec.worldbody.add_geom(name='sphere', size=[.2, 0, 0], pos=[0, 0, -2])
sphere = spec.worldbody.add_geom(
name='sphere', size=[0.2, 0, 0], pos=[0, 0, -2]
)
cylinder = spec.worldbody.add_geom(
name='cylinder',
@@ -2217,5 +2220,129 @@ class SpecsTest(absltest.TestCase):
self.assertEqual(spec.authored.visual_quality, 0)
self.assertEqual(spec.authored.visual_map, 0)
def test_attach_conflict_merge_procedural(self):
# Procedural attach with merge mode: min/max fields are merged.
parent = mujoco.MjSpec()
parent.compiler.conflict = mujoco.mjtConflict.mjCONFLICT_MERGE
parent.option.timestep = 0.005
parent.option.iterations = 150
parent.worldbody.add_geom().size[0] = 1
child = mujoco.MjSpec()
child.option.timestep = 0.001 # smaller -> wins (min-merge)
child.option.iterations = 200 # larger -> wins (max-merge)
child_body = child.worldbody.add_body()
child_body.add_geom().size[0] = 1
frame = parent.worldbody.add_frame()
frame.attach_body(child_body, prefix='child_')
# merged values are applied immediately at attach time
self.assertEqual(parent.option.timestep, 0.001)
self.assertEqual(parent.option.iterations, 200)
# compile succeeds with the merged values
model = parent.compile()
self.assertIsNotNone(model)
self.assertEqual(model.opt.timestep, 0.001)
self.assertEqual(model.opt.iterations, 200)
def test_attach_conflict_error_procedural(self):
# Procedural attach with unmergeable conflict: raises ValueError,
# parent spec is unchanged.
parent = mujoco.MjSpec()
parent.compiler.conflict = mujoco.mjtConflict.mjCONFLICT_MERGE
parent.option.timestep = 0.005
parent.option.integrator = mujoco.mjtIntegrator.mjINT_RK4
child = mujoco.MjSpec()
child.option.timestep = 0.001
child.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICIT
child_body = child.worldbody.add_body()
child_body.add_geom().size[0] = 1
frame = parent.worldbody.add_frame()
with self.assertRaisesRegex(ValueError, 'integrator'):
frame.attach_body(child_body, prefix='child_')
# parent spec should be unchanged (two-pass guarantee)
self.assertEqual(parent.option.timestep, 0.005)
self.assertEqual(parent.option.integrator, mujoco.mjtIntegrator.mjINT_RK4)
def test_attach_conflict_merge_xml(self):
# XML-based attach with merge mode: child timestep wins (min).
parent_xml = textwrap.dedent("""\
<mujoco>
<compiler conflict="merge"/>
<option timestep="0.005" iterations="50"/>
<asset>
<model name="child" file="child.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom size="1"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
""")
child_xml = textwrap.dedent("""\
<mujoco>
<option timestep="0.001" iterations="150"/>
<worldbody>
<body name="child_body">
<geom size="1"/>
</body>
</worldbody>
</mujoco>
""")
spec = mujoco.MjSpec.from_string(
parent_xml,
include={'child.xml': child_xml.encode()},
)
model = spec.compile()
self.assertIsNotNone(model)
self.assertEqual(model.opt.timestep, 0.001) # min
self.assertEqual(model.opt.iterations, 150) # max
def test_attach_conflict_error_xml(self):
# XML-based attach with error mode: any conflict raises ValueError.
parent_xml = textwrap.dedent("""\
<mujoco>
<compiler conflict="error"/>
<option timestep="0.005"/>
<asset>
<model name="child" file="child.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom size="1"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
""")
child_xml = textwrap.dedent("""\
<mujoco>
<option timestep="0.001"/>
<worldbody>
<body name="child_body">
<geom size="1"/>
</body>
</worldbody>
</mujoco>
""")
with self.assertRaisesRegex(ValueError, 'timestep'):
mujoco.MjSpec.from_string(
parent_xml,
include={'child.xml': child_xml.encode()},
)
if __name__ == '__main__':
absltest.main()
+1
View File
@@ -146,6 +146,7 @@ raw::MjModel* MjSpec::Compile(mjVFS* vfs) {
warnings.attr("warn")(mjs_getWarning(ptr, i));
}
}
return m;
}
+2
View File
@@ -27,6 +27,8 @@ set(MUJOCO_USER_SRCS
user_model.h
user_objects.cc
user_objects.h
user_resolver.cc
user_resolver.h
user_resource.cc
user_resource.h
user_threadpool.cc
+50 -13
View File
@@ -20,7 +20,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <filesystem> // NOLINT
#include <functional>
#include <iterator>
#include <map>
@@ -30,15 +30,16 @@
#include <utility>
#include <vector>
#include <mujoco/mujoco.h>
#include <mujoco/mjspecmacro.h>
#include <mujoco/mjxmacro.h>
#include <mujoco/mujoco.h>
#include "engine/engine_support.h"
#include "engine/engine_util_errmem.h"
#include "user/user_cache.h"
#include "user/user_flexcomp.h"
#include "user/user_model.h"
#include "user/user_objects.h"
#include "user/user_resolver.h"
#include "user/user_resource.h"
#include "user/user_util.h"
@@ -382,7 +383,6 @@ static mjsElement* attachFrameToSite(mjCSite* parent, const mjCFrame* child,
return attached_frame;
}
mjsElement* mjs_attach(mjsElement* parent, const mjsElement* child,
const char* prefix, const char* suffix) {
if (!parent) {
@@ -394,6 +394,31 @@ mjsElement* mjs_attach(mjsElement* parent, const mjsElement* child,
return nullptr;
}
mjCModel* model = static_cast<mjCModel*>(mjs_getSpec(parent)->element);
const mjSpec* child_spec = nullptr;
if (child->elemtype == mjOBJ_MODEL) {
child_spec = &(static_cast<const mjCModel*>(child)->spec);
} else {
child_spec = &(static_cast<const mjCBase*>(child)->model->spec);
}
// handle global attribute conflicts
if (child_spec && child_spec != &model->spec) {
std::string error_msg, warning_subject, warning_body;
bool success = mujoco::ResolveConflicts(
&model->spec, child_spec,
static_cast<mjtConflict>(model->spec.compiler.conflict), &error_msg,
&warning_subject, &warning_body);
if (!success) {
model->SetError(mjCError(0, "%s", error_msg.c_str()));
return nullptr;
}
if (!warning_body.empty()) {
model->AddGroupedWarning(warning_subject, warning_body);
}
}
if (child->elemtype == mjOBJ_MODEL) {
mjCModel* child_model = static_cast<mjCModel*>((mjsElement*)child);
mjsBody* worldbody = mjs_findBody(&child_model->spec, "world");
@@ -411,11 +436,12 @@ mjsElement* mjs_attach(mjsElement* parent, const mjsElement* child,
SetFrame(worldbody, mjOBJ_CAMERA, worldframe);
child = worldframe->element;
}
mjsElement* result = nullptr;
switch (parent->elemtype) {
case mjOBJ_FRAME:
if (child->elemtype == mjOBJ_BODY) {
return attachBody(static_cast<mjCFrame*>(parent),
static_cast<const mjCBody*>(child), prefix, suffix);
result = attachBody(static_cast<mjCFrame*>(parent),
static_cast<const mjCBody*>(child), prefix, suffix);
} else if (child->elemtype == mjOBJ_FRAME) {
mjsBody* parent_body = mjs_getParent(parent);
if (!parent_body) {
@@ -429,35 +455,46 @@ mjsElement* mjs_attach(mjsElement* parent, const mjsElement* child,
if (mjs_setFrame(attached_frame, &frame->spec)) {
return nullptr;
}
return attached_frame;
result = attached_frame;
} else {
model->SetError(mjCError(0, "child element is not a body or frame"));
return nullptr;
}
break;
case mjOBJ_BODY:
if (child->elemtype == mjOBJ_FRAME) {
return attachFrame(static_cast<mjCBody*>(parent),
static_cast<const mjCFrame*>(child), prefix, suffix);
result =
attachFrame(static_cast<mjCBody*>(parent),
static_cast<const mjCFrame*>(child), prefix, suffix);
} else {
model->SetError(mjCError(0, "child element is not a frame"));
return nullptr;
}
break;
case mjOBJ_SITE:
if (child->elemtype == mjOBJ_BODY) {
return attachToSite(static_cast<mjCSite*>(parent),
static_cast<const mjCBody*>(child), prefix, suffix);
result =
attachToSite(static_cast<mjCSite*>(parent),
static_cast<const mjCBody*>(child), prefix, suffix);
} else if (child->elemtype == mjOBJ_FRAME) {
return attachFrameToSite(static_cast<mjCSite*>(parent),
static_cast<const mjCFrame*>(child), prefix, suffix);
result = attachFrameToSite(static_cast<mjCSite*>(parent),
static_cast<const mjCFrame*>(child), prefix,
suffix);
} else {
model->SetError(mjCError(0, "child element is not a body or frame"));
return nullptr;
}
break;
default:
model->SetError(mjCError(0, "parent element is not a frame, body or site"));
return nullptr;
}
return nullptr;
// mark all warnings accumulated so far as attach-phase
if (result) {
model->SetAttachWarningBoundary();
}
return result;
}
+19
View File
@@ -1525,6 +1525,22 @@ void mjCModel::AddWarning(std::string msg, const mjCBase* obj) {
warnings_.push_back(std::move(msg));
}
// add grouped warning with subject/body split (immediate delivery outside
// compile)
void mjCModel::AddGroupedWarning(const std::string& subject,
const std::string& body) {
std::string full = body.empty() ? subject : subject + "\n" + body;
warnings_.push_back(full);
// outside compile: deliver immediately via structured log message
if (!compiling_) {
mjLogMessage m = {.level = mjLOG_WARNING};
snprintf(m.subject, sizeof(m.subject), "%s", subject.c_str());
m.body = body.empty() ? nullptr : body.c_str();
mju_message(&m);
}
}
// pointer to world body
mjCBody* mjCModel::GetWorld() {
return bodies_[0];
@@ -5026,6 +5042,9 @@ void mjCModel::TryCompile(mjModel*& m, mjData*& d, const mjVFS* vfs) {
} disable_usethread(compiler.usethread);
#endif
// clear compile-phase warnings from previous compile, keep attach warnings
ClearCompileWarnings();
using Clock = std::chrono::steady_clock;
using Seconds = std::chrono::duration<double>;
for (int i=0; i < mjNCTIMER; i++) {
+2
View File
@@ -251,6 +251,8 @@ class mjCModel : public mjCModel_, private mjSpec {
void SetError(const mjCError& error) { errInfo = error; } // set value of error object
void AddWarning(std::string msg, // add warning to vector
const mjCBase* obj = nullptr);
void AddGroupedWarning(const std::string& subject, // add grouped warning
const std::string& body);
const std::vector<std::string>& GetWarnings()
const { // get accumulated warnings
return warnings_;
+449
View File
@@ -0,0 +1,449 @@
// Copyright 2026 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 "user/user_resolver.h"
#include <algorithm>
#include <array>
#include <cstdio>
#include <functional>
#include <string>
#include <type_traits>
#include <vector>
#include <mujoco/mjspec.h>
#include <mujoco/mujoco.h>
#include "user/user_api.h"
namespace mujoco {
namespace {
// format a numeric value for conflict messages
template <typename T>
std::string fmtVal(T val) {
if constexpr (std::is_same_v<T, mjtNum>) {
char buf[32];
snprintf(buf, sizeof(buf), "%g", val);
return buf;
} else {
return std::to_string(val);
}
}
// format an array of numeric values for conflict messages
std::string fmtArr(const mjtNum* val, int n) {
std::string s;
for (int i = 0; i < n; i++) {
if (i) s += ' ';
s += fmtVal(val[i]);
}
return s;
}
// merge strategy for scalar conflict resolution
enum ResolveMerge { kMergeMin, kMergeMax, kMergeError };
// single-pass conflict resolver: accumulates errors, warnings, and deferred
// mutations; commits mutations only if no errors were found
struct Resolver {
mjtConflict mode;
mjSpec* parent;
const mjSpec* child;
std::vector<std::string> errs;
std::vector<std::string> warnings;
std::vector<std::function<void()>> ops;
Resolver(mjtConflict mode, mjSpec* parent, const mjSpec* child)
: mode(mode), parent(parent), child(child) {}
// resolve a scalar field conflict
template <typename T>
void operator()(const char* name, T& pval, const T& cval, T dval,
ResolveMerge merge = kMergeError) {
if (pval == cval) return;
// check authored status
bool parent_authored = mjs_isAuthored(parent, &pval);
bool child_authored = mjs_isAuthored(child, &cval);
// fall back to default comparison for fields without authored tracking
if (!parent_authored && !child_authored) {
parent_authored = (pval != dval);
child_authored = (cval != dval);
}
if (!child_authored) return;
// "FIELD: parent has X, child has Y"
auto prefix = [&]() {
return std::string(name) + ": parent has " + fmtVal(pval) +
", child has " + fmtVal(cval);
};
// only child authored: adopt or keep
if (!parent_authored) {
std::string p = prefix();
if (mode == mjCONFLICT_MERGE) {
ops.push_back([&pval, cval]() { pval = cval; });
warnings.push_back(p + ", adopting child value");
} else {
warnings.push_back(p + ", keeping parent value");
}
return;
}
// both authored: dispatch by mode
switch (mode) {
case mjCONFLICT_WARNING:
warnings.push_back(prefix() + ", keeping parent value");
break;
case mjCONFLICT_MERGE:
if (merge == kMergeError) {
errs.push_back(prefix());
break;
}
{
T merged =
merge == kMergeMin ? std::min(pval, cval) : std::max(pval, cval);
const char* desc = merge == kMergeMin ? "the minimum" : "the maximum";
warnings.push_back(
prefix() +
(merged == cval
? std::string(", taking ") + desc
: std::string(", keeping parent value (") + desc + ")"));
ops.push_back([&pval, merged]() { pval = merged; });
break;
}
case mjCONFLICT_ERROR:
errs.push_back(prefix());
break;
}
}
// resolve an unmergeable array field
template <int N>
void operator()(const char* name, mjtNum (&pval)[N], const mjtNum (&cval)[N],
const mjtNum (&dval)[N]) {
bool equal = true;
for (int i = 0; i < N; i++) {
if (pval[i] != cval[i]) {
equal = false;
break;
}
}
if (equal) return;
// check authored status
bool parent_authored = mjs_isAuthored(parent, pval);
bool child_authored = mjs_isAuthored(child, cval);
// fall back to default comparison for fields without authored tracking
if (!parent_authored && !child_authored) {
for (int i = 0; i < N; i++) {
if (pval[i] != dval[i]) {
parent_authored = true;
break;
}
}
for (int i = 0; i < N; i++) {
if (cval[i] != dval[i]) {
child_authored = true;
break;
}
}
}
if (!child_authored) return;
// "FIELD: parent has X Y Z, child has X Y Z"
auto prefix = [&]() {
return std::string(name) + ": parent has " + fmtArr(pval, N) +
", child has " + fmtArr(cval, N);
};
// only child authored: adopt or keep
if (!parent_authored) {
std::string p = prefix();
if (mode == mjCONFLICT_MERGE) {
std::array<mjtNum, N> vals;
for (int i = 0; i < N; i++) vals[i] = cval[i];
ops.push_back([&pval, vals]() {
for (int i = 0; i < N; i++) pval[i] = vals[i];
});
warnings.push_back(p + ", adopting child value");
} else {
warnings.push_back(p + ", keeping parent value");
}
return;
}
// both authored: dispatch by mode
switch (mode) {
case mjCONFLICT_WARNING:
warnings.push_back(prefix() + ", keeping parent value");
break;
case mjCONFLICT_MERGE:
case mjCONFLICT_ERROR:
errs.push_back(prefix());
break;
}
}
// resolve bitfield conflicts
void operator()(int& pval, int cval, const char* const names[], int nbit,
int pauth, int cauth) {
// fall back to treating all bits as authored if no tracking available
if (!pauth && !cauth) {
pauth = ~0;
cauth = ~0;
}
// only consider bits that the child actually authored
int child_authored = cval & cauth;
if (!child_authored) return;
if (mode == mjCONFLICT_MERGE) {
int added = child_authored & ~pval;
for (int i = 0; i < nbit; i++) {
if ((added >> i) & 1) {
warnings.push_back(std::string("flag '") + names[i] +
"': added from child");
}
}
ops.push_back([&pval, child_authored]() { pval |= child_authored; });
return;
}
// only report conflicts for bits where both parent and child authored
int both = pauth & cauth;
int diff = (pval ^ cval) & both;
for (int i = 0; i < nbit; i++) {
if ((diff >> i) & 1) {
bool parent_set = (pval >> i) & 1;
std::string msg = std::string("flag '") + names[i] + "': parent " +
(parent_set ? "set" : "unset") + ", child " +
(parent_set ? "unset" : "set");
if (mode == mjCONFLICT_ERROR) {
errs.push_back(msg);
} else {
warnings.push_back(msg + ", keeping parent");
}
}
}
}
// resolve disableactuator bitfield conflicts
void operator()(int& pval, int cval, int pauth, int cauth) {
// fall back to treating all bits as authored if no tracking available
if (!pauth && !cauth) {
pauth = ~0;
cauth = ~0;
}
// only consider bits that the child actually authored
int child_authored = cval & cauth;
if (!child_authored) return;
if (mode == mjCONFLICT_MERGE) {
for (int i = 0; i < mjNGROUP; i++) {
if (((child_authored & ~pval) >> i) & 1) {
char buf[64];
snprintf(buf, sizeof(buf),
"disableactuator group %d: added from child", i);
warnings.push_back(buf);
}
}
ops.push_back([&pval, child_authored]() { pval |= child_authored; });
return;
}
int both = pauth & cauth;
int diff = (pval ^ cval) & both;
for (int i = 0; i < mjNGROUP; i++) {
if ((diff >> i) & 1) {
char buf[128];
bool parent_set = (pval >> i) & 1;
snprintf(buf, sizeof(buf),
"disableactuator group %d: parent %s, child %s", i,
parent_set ? "set" : "unset", parent_set ? "unset" : "set");
if (mode == mjCONFLICT_ERROR) {
errs.push_back(buf);
} else {
warnings.push_back(std::string(buf) + ", keeping parent");
}
}
}
}
// apply deferred mutations (only if no errors)
bool Apply() {
if (!errs.empty()) return false;
for (auto& op : ops) op();
return true;
}
};
// enumerate all conflictable fields, dispatching each to the resolver
void VisitConflicts(mjSpec* parent, const mjSpec* child, Resolver& r) {
// ==== mjOption ====
mjOption d;
mj_defaultOption(&d);
mjOption& po = parent->option;
const mjOption& co = child->option;
// min-merge fields
r("timestep", po.timestep, co.timestep, d.timestep, kMergeMin);
r("tolerance", po.tolerance, co.tolerance, d.tolerance, kMergeMin);
r("ls_tolerance", po.ls_tolerance, co.ls_tolerance, d.ls_tolerance,
kMergeMin);
r("noslip_tolerance", po.noslip_tolerance, co.noslip_tolerance,
d.noslip_tolerance, kMergeMin);
r("ccd_tolerance", po.ccd_tolerance, co.ccd_tolerance, d.ccd_tolerance,
kMergeMin);
r("sleep_tolerance", po.sleep_tolerance, co.sleep_tolerance,
d.sleep_tolerance, kMergeMin);
// max-merge fields
r("iterations", po.iterations, co.iterations, d.iterations, kMergeMax);
r("ls_iterations", po.ls_iterations, co.ls_iterations, d.ls_iterations,
kMergeMax);
r("noslip_iterations", po.noslip_iterations, co.noslip_iterations,
d.noslip_iterations, kMergeMax);
r("ccd_iterations", po.ccd_iterations, co.ccd_iterations, d.ccd_iterations,
kMergeMax);
r("sdf_iterations", po.sdf_iterations, co.sdf_iterations, d.sdf_iterations,
kMergeMax);
r("sdf_initpoints", po.sdf_initpoints, co.sdf_initpoints, d.sdf_initpoints,
kMergeMax);
// unmergeable scalars
r("impratio", po.impratio, co.impratio, d.impratio);
r("density", po.density, co.density, d.density);
r("viscosity", po.viscosity, co.viscosity, d.viscosity);
r("o_margin", po.o_margin, co.o_margin, d.o_margin);
r("integrator", po.integrator, co.integrator, d.integrator);
r("cone", po.cone, co.cone, d.cone);
r("jacobian", po.jacobian, co.jacobian, d.jacobian);
r("solver", po.solver, co.solver, d.solver);
// unmergeable arrays
r("gravity", po.gravity, co.gravity, d.gravity);
r("wind", po.wind, co.wind, d.wind);
r("magnetic", po.magnetic, co.magnetic, d.magnetic);
r("o_solref", po.o_solref, co.o_solref, d.o_solref);
r("o_solimp", po.o_solimp, co.o_solimp, d.o_solimp);
r("o_friction", po.o_friction, co.o_friction, d.o_friction);
// bitfields (pass authored bitmasks)
const mjsAuthored& pa = parent->authored;
const mjsAuthored& ca = child->authored;
r(po.disableflags, co.disableflags, mjDISABLESTRING, mjNDISABLE,
pa.disableflags, ca.disableflags);
r(po.enableflags, co.enableflags, mjENABLESTRING, mjNENABLE, pa.enableflags,
ca.enableflags);
r(po.disableactuator, co.disableactuator, pa.disableactuator,
ca.disableactuator);
// ==== mjVisual ====
mjVisual dv;
mj_defaultVisual(&dv);
mjVisual& pv = parent->visual;
const mjVisual& cv = child->visual;
r("znear", pv.map.znear, cv.map.znear, dv.map.znear, kMergeMin);
r("realtime", pv.global.realtime, cv.global.realtime, dv.global.realtime,
kMergeMin);
r("zfar", pv.map.zfar, cv.map.zfar, dv.map.zfar, kMergeMax);
// ==== mjSpec sizes (no authored tracking, uses default fallback) ====
mjSpec ds;
mjs_defaultSpec(&ds);
mjSpec& ps = *parent;
const mjSpec& cs = *child;
r("memory", ps.memory, cs.memory, ds.memory, kMergeMax);
r("njmax", ps.njmax, cs.njmax, ds.njmax, kMergeMax);
r("nconmax", ps.nconmax, cs.nconmax, ds.nconmax, kMergeMax);
r("nuserdata", ps.nuserdata, cs.nuserdata, ds.nuserdata, kMergeMax);
r("nkey", ps.nkey, cs.nkey, ds.nkey, kMergeMax);
r("nuser_body", ps.nuser_body, cs.nuser_body, ds.nuser_body, kMergeMax);
r("nuser_jnt", ps.nuser_jnt, cs.nuser_jnt, ds.nuser_jnt, kMergeMax);
r("nuser_geom", ps.nuser_geom, cs.nuser_geom, ds.nuser_geom, kMergeMax);
r("nuser_site", ps.nuser_site, cs.nuser_site, ds.nuser_site, kMergeMax);
r("nuser_cam", ps.nuser_cam, cs.nuser_cam, ds.nuser_cam, kMergeMax);
r("nuser_tendon", ps.nuser_tendon, cs.nuser_tendon, ds.nuser_tendon,
kMergeMax);
r("nuser_actuator", ps.nuser_actuator, cs.nuser_actuator, ds.nuser_actuator,
kMergeMax);
r("nuser_sensor", ps.nuser_sensor, cs.nuser_sensor, ds.nuser_sensor,
kMergeMax);
}
// compose subject line for conflict messages
std::string ConflictSubject(const mjSpec* parent, const mjSpec* child) {
const char* mode_str = parent->compiler.conflict == mjCONFLICT_MERGE ? "merge"
: parent->compiler.conflict == mjCONFLICT_ERROR
? "error"
: "warning";
std::string pname = *parent->modelname;
std::string cname = *child->modelname;
bool has_parent = (pname != "MuJoCo Model" && !pname.empty());
bool has_child = (cname != "MuJoCo Model" && !cname.empty());
if (has_child && has_parent) {
return "Attach conflict when attaching '" + cname + "' to '" + pname +
"', policy is '" + mode_str + "'";
} else if (has_child) {
return "Attach conflict when attaching '" + cname + "', policy is '" +
mode_str + "'";
} else if (has_parent) {
return "Attach conflict when attaching to '" + pname + "', policy is '" +
mode_str + "'";
}
return std::string("Attach conflict on attach, policy is '") + mode_str + "'";
}
} // namespace
bool ResolveConflicts(mjSpec* parent, const mjSpec* child, mjtConflict mode,
std::string* error_msg, std::string* warning_subject,
std::string* warning_body) {
Resolver r(mode, parent, child);
VisitConflicts(parent, child, r);
if (!r.Apply()) {
if (error_msg) {
*error_msg = ConflictSubject(parent, child);
for (const auto& e : r.errs) {
error_msg->append("\n");
error_msg->append(e);
}
}
return false;
}
if (!r.warnings.empty()) {
if (warning_subject) {
*warning_subject = ConflictSubject(parent, child);
}
if (warning_body) {
warning_body->clear();
for (size_t i = 0; i < r.warnings.size(); ++i) {
if (i > 0) {
warning_body->append("\n");
}
warning_body->append(r.warnings[i]);
}
}
}
return true;
}
} // namespace mujoco
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 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_USER_USER_RESOLVER_H_
#define MUJOCO_SRC_USER_USER_RESOLVER_H_
#include <string>
#include <mujoco/mjspec.h>
namespace mujoco {
// Resolves global attribute conflicts between parent and child specs.
// Returns true on success (no errors). If errors are found, mutations are not
// applied, and 'error_msg' will contain the summary. If warnings are generated
// during resolution, 'warning_subject' and 'warning_body' will be populated.
bool ResolveConflicts(mjSpec* parent, const mjSpec* child, mjtConflict mode,
std::string* error_msg, std::string* warning_subject,
std::string* warning_body);
} // namespace mujoco
#endif // MUJOCO_SRC_USER_USER_RESOLVER_H_
+2 -1
View File
@@ -48,6 +48,7 @@ extern const int stage_sz;
extern const int datatype_sz;
extern const int camout_sz;
extern const int reduce_sz;
extern const int conflict_sz;
extern const mjMap angle_map[];
extern const mjMap enable_map[];
extern const mjMap bool_map[];
@@ -86,7 +87,7 @@ extern const mjMap meshtype_map[];
extern const mjMap meshinertia_map[];
extern const mjMap flexself_map[];
extern const mjMap elastic2d_map[];
extern const mjMap conflict_map[];
//---------------------------------- Base XML class ------------------------------------------------
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1034,6 +1034,8 @@ void mjXWriter::Compiler(XMLElement* root) {
if (!model->compiler.autolimits) {
WriteAttrTxt(section, "autolimits", "false");
}
WriteAttrKey(section, "conflict", conflict_map, conflict_sz,
model->compiler.conflict, mjCONFLICT_WARNING);
}
+1
View File
@@ -51,6 +51,7 @@ using ::testing::Truly;
bool IsBenignWarning(const std::string& msg) {
static const char* const kBenignWarnings[] = {
"is not rigid and has no equality constraints",
"Attach conflict",
};
for (const char* warning : kBenignWarnings) {
if (absl::StrContains(msg, warning)) {
+101 -8
View File
@@ -2668,18 +2668,17 @@ void AttachNestedKeyframe(bool compile) {
// compile required before further attachment
mjModel* m_child = compile ? mj_compile(child, 0) : nullptr;
// check warning is issued, empty for a compiled model
static char warning[1024];
warning[0] = '\0';
mju_user_warning = [](const char* msg) {
util::strcpy_arr(warning, msg);
};
// attach child to parent
mjs_attach(mjs_findFrame(parent, "frame")->element,
mjs_findBody(child, "body")->element, "child-", "");
EXPECT_THAT(warning, HasSubstr(compile ? "" : "model has pending keyframes"));
if (compile) {
EXPECT_EQ(mjs_numWarnings(parent), 0);
} else {
EXPECT_GE(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0),
HasSubstr("model has pending keyframes"));
}
// compare models
mjtNum tol = 0;
@@ -2702,6 +2701,7 @@ void AttachNestedKeyframe(bool compile) {
}
TEST_F(MujocoTest, TestAttachNestedKeyframe) {
mock_warning_handler.ExpectWarnings();
AttachNestedKeyframe(/*compile=*/true);
AttachNestedKeyframe(/*compile=*/false);
}
@@ -3454,5 +3454,98 @@ TEST_F(MujocoTest, CompileWarningChainedToHandler) {
mj_deleteSpec(spec);
}
TEST_F(MujocoTest, WarningAccumulationAndRetrieval) {
mock_warning_handler.ExpectWarnings();
static constexpr char xml_parent[] = R"(
<mujoco>
<worldbody>
<frame name="parent"/>
</worldbody>
</mujoco>
)";
static constexpr char xml_child[] = R"(
<mujoco>
<worldbody>
<body name="child">
<frame name="child_frame"/>
</body>
</worldbody>
</mujoco>
)";
static constexpr char xml_gchild[] = R"(
<mujoco>
<worldbody>
<body name="gchild"/>
</worldbody>
<keyframe>
<key name="k1"/>
</keyframe>
</mujoco>
)";
std::array<char, 1024> er;
mjSpec* parent = mj_parseXMLString(xml_parent, 0, er.data(), er.size());
ASSERT_THAT(parent, NotNull()) << er.data();
mjSpec* child = mj_parseXMLString(xml_child, 0, er.data(), er.size());
ASSERT_THAT(child, NotNull()) << er.data();
mjSpec* gchild = mj_parseXMLString(xml_gchild, 0, er.data(), er.size());
ASSERT_THAT(gchild, NotNull()) << er.data();
mjs_setDeepCopy(parent, true);
mjs_setDeepCopy(child, true);
mjs_setDeepCopy(gchild, true);
mjs_attach(mjs_findFrame(child, "child_frame")->element,
mjs_findBody(gchild, "gchild")->element, "gchild-", "");
mjs_attach(mjs_findFrame(parent, "parent")->element,
mjs_findBody(child, "child")->element, "child-", "");
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0),
HasSubstr("Child model has pending keyframes"));
EXPECT_THAT(mjs_getWarning(parent, 1), IsNull());
mj_deleteSpec(parent);
mj_deleteSpec(child);
mj_deleteSpec(gchild);
}
TEST_F(MujocoTest, CompilationWarningsClearedOnRecompile) {
mock_warning_handler.ExpectWarnings();
// flex with no edge stiffness or equality triggers passive forces warning
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<flexcomp name="test" type="grid" count="4 4 1" spacing=".2 .2 .2"
dim="2" radius=".1"/>
</worldbody>
</mujoco>
)";
std::array<char, 1024> er;
mjSpec* spec = mj_parseXMLString(xml, 0, er.data(), er.size());
ASSERT_THAT(spec, NotNull()) << er.data();
// first compile: should generate flex warning
mjModel* m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull());
int n1 = mjs_numWarnings(spec);
EXPECT_EQ(n1, 1);
EXPECT_THAT(mjs_getWarning(spec, 0),
HasSubstr("no equality constraints or passive forces"));
// recompile: warnings should be cleared and regenerated
mj_deleteModel(m);
m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull());
EXPECT_EQ(mjs_numWarnings(spec), n1);
mj_deleteModel(m);
mj_deleteSpec(spec);
}
} // namespace
} // namespace mujoco
+3 -2
View File
@@ -47,8 +47,9 @@ std::vector<std::string> GetRecompileTestModels() {
if (absl::StrContains(xml, "malformed_") ||
absl::StrContains(xml, "_fail") ||
absl::StrContains(xml, "touch_grid") ||
absl::StrContains(xml, "perf") ||
absl::StrContains(xml, "cow")) {
absl::StrContains(xml, "perf") || absl::StrContains(xml, "cow") ||
// exclude conflict test assets (designed to fail compile)
absl::StrContains(xml, "xml/testdata/parent_")) {
continue;
}
models.push_back(xml);
+918
View File
@@ -0,0 +1,918 @@
// Copyright 2026 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 <array>
#include <string>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <mujoco/mjspec.h>
#include <mujoco/mujoco.h>
#include "src/xml/xml_api.h"
#include "test/fixture.h"
namespace mujoco {
namespace {
using ::testing::HasSubstr;
using ::testing::IsNull;
using ::testing::NotNull;
TEST_F(MujocoTest, AttachWarningsSurviveRecompile) {
mock_warning_handler.ExpectWarnings();
// attach warnings should persist through recompilation
static constexpr char xml_parent[] = R"(
<mujoco>
<option timestep="0.005"/>
<worldbody>
<frame name="attachment"/>
</worldbody>
</mujoco>
)";
static constexpr char xml_child[] = R"(
<mujoco>
<option timestep="0.001"/>
<worldbody>
<body name="child">
<geom size="1"/>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> er;
mjSpec* parent = mj_parseXMLString(xml_parent, 0, er.data(), er.size());
ASSERT_THAT(parent, NotNull()) << er.data();
mjSpec* child = mj_parseXMLString(xml_child, 0, er.data(), er.size());
ASSERT_THAT(child, NotNull()) << er.data();
mjsFrame* frame = mjs_findFrame(parent, "attachment");
ASSERT_THAT(frame, NotNull());
mjsElement* attached = mjs_attach(
frame->element, mjs_findBody(child, "child")->element, "child-", "");
ASSERT_THAT(attached, NotNull());
// attach warning should be present
int attach_warnings = mjs_numWarnings(parent);
EXPECT_GE(attach_warnings, 1);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
// compile
mjModel* m = mj_compile(parent, nullptr);
ASSERT_THAT(m, NotNull()) << mjs_getError(parent);
// attach warnings should survive compilation
EXPECT_GE(mjs_numWarnings(parent), attach_warnings);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
// recompile
mj_deleteModel(m);
m = mj_compile(parent, nullptr);
ASSERT_THAT(m, NotNull()) << mjs_getError(parent);
// attach warnings should still be there after recompile
EXPECT_GE(mjs_numWarnings(parent), attach_warnings);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
mj_deleteModel(m);
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictWarningDefault) {
mock_warning_handler.ExpectWarnings();
// default mode (warning): conflicting values should keep parent, set warning
mjSpec* parent = mj_makeSpec();
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// parent value should be unchanged
EXPECT_EQ(parent->option.timestep, mjtNum(0.005));
// one grouped warning per attach
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("policy is 'warning'"));
EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001,"
" keeping parent value"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictWarningNoConflict) {
mock_warning_handler.ExpectWarnings();
// warning mode: child has non-default, parent is default → warn + keep parent
mjSpec* parent = mj_makeSpec();
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// warning produced when child is non-default
EXPECT_TRUE(mjs_isWarning(parent));
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeMin) {
mock_warning_handler.ExpectWarnings();
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.timestep = 0.005;
parent->option.tolerance = 1e-10;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
child->option.tolerance = 1e-12;
child->option.sleep_tolerance = 0.005;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// min-fields should take the smaller value
EXPECT_EQ(parent->option.timestep, mjtNum(0.001));
EXPECT_EQ(parent->option.tolerance, mjtNum(1e-12));
// one side is default -> copy and warn
EXPECT_EQ(parent->option.sleep_tolerance, mjtNum(0.005));
// one grouped warning containing all three fields
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("policy is 'merge'"));
EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001,"
" taking the minimum"));
EXPECT_THAT(w, HasSubstr("tolerance"));
EXPECT_THAT(w, HasSubstr("sleep_tolerance"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeMax) {
mock_warning_handler.ExpectWarnings();
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.iterations = 150;
parent->nkey = 5;
parent->nuser_body = 2;
mjSpec* child = mj_makeSpec();
child->option.iterations = 200;
child->nkey = 8;
child->nuser_body = 4;
mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0);
mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr);
mjsElement* attached =
mjs_attach(frame->element, child_body->element, "child_", "");
ASSERT_THAT(attached, NotNull());
// max-fields should take the larger value
EXPECT_EQ(parent->option.iterations, 200);
EXPECT_EQ(parent->nkey, 8);
EXPECT_EQ(parent->nuser_body, 4);
// one grouped warning containing all three fields
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("iterations: parent has 150, child has 200,"
" taking the maximum"));
EXPECT_THAT(w, HasSubstr("nkey"));
EXPECT_THAT(w, HasSubstr("nuser_body"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeError) {
// merge mode: conflicting error-fields should produce an error
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.integrator = mjINT_RK4;
mjSpec* child = mj_makeSpec();
child->option.integrator = mjINT_IMPLICIT;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
// error-fields should fail on conflict
EXPECT_THAT(attached, IsNull());
EXPECT_THAT(mjs_getError(parent), HasSubstr("integrator: parent has"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictErrorMode) {
// error mode: any conflict -> error
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_ERROR;
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
EXPECT_THAT(attached, IsNull());
std::string error = mjs_getError(parent);
EXPECT_THAT(error, HasSubstr("policy is 'error'"));
EXPECT_THAT(error, HasSubstr("timestep: parent has 0.005, child has 0.001"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictErrorNoConflict) {
// error mode: no conflict (one side default) -> succeeds
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_ERROR;
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
// child timestep is default -> no conflict
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// parent unchanged
EXPECT_EQ(parent->option.timestep, mjtNum(0.005));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeFromBody) {
mock_warning_handler.ExpectWarnings();
// merge mode: attach from body (not spec) still merges
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0);
mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr);
mjsElement* attached =
mjs_attach(frame->element, child_body->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(parent->option.timestep, mjtNum(0.001));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeGravityError) {
// merge mode: conflicting gravity -> error
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.gravity[2] = -10.0;
mjSpec* child = mj_makeSpec();
child->option.gravity[2] = -1.62;
mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0);
mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr);
mjs_attach(frame->element, child_body->element, "child_", "");
EXPECT_THAT(mjs_getError(parent),
HasSubstr("gravity: parent has 0 0 -10, child has 0 0 -1.62"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeRealtimeMin) {
mock_warning_handler.ExpectWarnings();
// merge mode: conflicting realtime -> take min
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->visual.global.realtime = 2.0f;
mjSpec* child = mj_makeSpec();
child->visual.global.realtime = 0.5f;
mjsBody* child_body = mjs_addBody(mjs_findBody(child, "world"), 0);
mjsFrame* frame = mjs_addFrame(mjs_findBody(parent, "world"), nullptr);
mjsElement* attached =
mjs_attach(frame->element, child_body->element, "child_", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(parent->visual.global.realtime, 0.5f);
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictWarningBitfields) {
mock_warning_handler.ExpectWarnings();
// warning mode: conflicting disable flags report individual flag names
mjSpec* parent = mj_makeSpec();
parent->option.disableflags = mjDSBL_GRAVITY | mjDSBL_CONTACT;
mjSpec* child = mj_makeSpec();
child->option.disableflags = mjDSBL_GRAVITY | mjDSBL_ACTUATION;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// Contact and Actuation differ, Gravity agrees -> 1 grouped warning
EXPECT_TRUE(mjs_isWarning(parent));
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("flag 'Contact': parent set, child unset,"
" keeping parent"));
EXPECT_THAT(w, HasSubstr("flag 'Actuation': parent unset, child set,"
" keeping parent"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeMultipleWarnings) {
mock_warning_handler.ExpectWarnings();
// merge mode: multiple min/max conflicts should be in one grouped warning
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.timestep = 0.005;
parent->option.iterations = 150;
parent->visual.global.realtime = 2.0f;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001; // min conflict
child->option.iterations = 200; // max conflict
child->visual.global.realtime = 0.5f; // min conflict
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// values should be merged
EXPECT_EQ(parent->option.timestep, mjtNum(0.001));
EXPECT_EQ(parent->option.iterations, 200);
EXPECT_EQ(parent->visual.global.realtime, 0.5f);
// one grouped warning containing all three fields
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("policy is 'merge'"));
EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.001,"
" taking the minimum"));
EXPECT_THAT(w, HasSubstr("iterations: parent has 150, child has 200,"
" taking the maximum"));
EXPECT_THAT(w, HasSubstr("realtime"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictWarningBitfieldsNoConflict) {
// warning mode: one side has zero disableflags -> no conflict
mjSpec* parent = mj_makeSpec();
parent->option.disableflags = mjDSBL_GRAVITY;
mjSpec* child = mj_makeSpec();
// child disableflags is 0 (default) -> no conflict
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_FALSE(mjs_isWarning(parent));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMergeBitfields) {
mock_warning_handler.ExpectWarnings();
// merge mode: flags are ORed together
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.disableflags = mjDSBL_GRAVITY | mjDSBL_CONTACT;
parent->option.enableflags = mjENBL_OVERRIDE;
mjSpec* child = mj_makeSpec();
child->option.disableflags = mjDSBL_GRAVITY | mjDSBL_ACTUATION;
child->option.enableflags = mjENBL_ENERGY;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// union of flags
EXPECT_EQ(parent->option.disableflags,
mjDSBL_GRAVITY | mjDSBL_CONTACT | mjDSBL_ACTUATION);
EXPECT_EQ(parent->option.enableflags, mjENBL_OVERRIDE | mjENBL_ENERGY);
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictErrorBitfields) {
// error mode: differing flags -> per-flag error
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_ERROR;
parent->option.disableflags = mjDSBL_GRAVITY;
mjSpec* child = mj_makeSpec();
child->option.disableflags = mjDSBL_CONTACT;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
EXPECT_THAT(attached, IsNull());
// should mention the specific flag name, not "disableflags"
std::string error = mjs_getError(parent);
EXPECT_THAT(error, HasSubstr("flag 'Contact'"));
EXPECT_THAT(error, HasSubstr("flag 'Gravity'"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictErrorNoMutation) {
// failed attach should not mutate the parent spec
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_MERGE;
parent->option.timestep = 0.005;
parent->option.iterations = 150;
parent->option.integrator = mjINT_RK4;
parent->option.gravity[2] = -10.0;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001; // min-mergeable
child->option.iterations = 200; // max-mergeable
child->option.integrator = mjINT_IMPLICIT; // unmergeable -> error
child->option.gravity[2] = -1.62; // unmergeable array -> error
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
// attach should fail
EXPECT_THAT(attached, IsNull());
// parent should be completely unchanged
EXPECT_EQ(parent->option.timestep, mjtNum(0.005));
EXPECT_EQ(parent->option.iterations, 150);
EXPECT_EQ(parent->option.integrator, mjINT_RK4);
EXPECT_EQ(parent->option.gravity[2], mjtNum(-10.0));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictMultipleErrors) {
// error mode: multiple conflicts should all be reported in the error message
mjSpec* parent = mj_makeSpec();
parent->compiler.conflict = mjCONFLICT_ERROR;
parent->option.timestep = 0.005;
parent->option.iterations = 150;
parent->option.gravity[2] = -10.0;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
child->option.iterations = 200;
child->option.gravity[2] = -1.62;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
EXPECT_THAT(attached, IsNull());
// all three conflicting fields should appear in the error message
std::string error = mjs_getError(parent);
EXPECT_THAT(error, HasSubstr("timestep"));
EXPECT_THAT(error, HasSubstr("iterations"));
EXPECT_THAT(error, HasSubstr("gravity"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictWarningArray) {
mock_warning_handler.ExpectWarnings();
// warning mode: conflicting array -> keep parent, warn
mjSpec* parent = mj_makeSpec();
parent->option.gravity[2] = -10.0;
mjSpec* child = mj_makeSpec();
child->option.gravity[2] = -1.62;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
// parent gravity should be unchanged
EXPECT_EQ(parent->option.gravity[2], mjtNum(-10.0));
// one grouped warning containing gravity
EXPECT_TRUE(mjs_isWarning(parent));
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0),
HasSubstr("gravity: parent has 0 0 -10, child has 0 0 -1.62,"
" keeping parent value"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictSubjectNames) {
mock_warning_handler.ExpectWarnings();
// Test case 1: both custom names
{
mjSpec* parent = mj_makeSpec();
mjs_setString(parent->modelname, "parent_model");
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
mjs_setString(child->modelname, "child_model");
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("Attach conflict when attaching 'child_model' to "
"'parent_model', policy is 'warning'"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
// Test case 2: only child custom name
{
mjSpec* parent = mj_makeSpec();
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
mjs_setString(child->modelname, "child_model");
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("Attach conflict when attaching 'child_model', "
"policy is 'warning'"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
// Test case 3: only parent custom name
{
mjSpec* parent = mj_makeSpec();
mjs_setString(parent->modelname, "parent_model");
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("Attach conflict when attaching to 'parent_model',"
" policy is 'warning'"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
// Test case 4: no custom name (both "MuJoCo Model")
{
mjSpec* parent = mj_makeSpec();
parent->option.timestep = 0.005;
mjSpec* child = mj_makeSpec();
child->option.timestep = 0.001;
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached = mjs_attach(world->element, child->element, "", "");
ASSERT_THAT(attached, NotNull());
EXPECT_EQ(mjs_numWarnings(parent), 1);
std::string w = mjs_getWarning(parent, 0);
EXPECT_THAT(w, HasSubstr("Attach conflict on attach, "
"policy is 'warning'"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
}
TEST_F(MujocoTest, AttachConflictAuthoredDefault) {
// child explicitly sets timestep to its default value (0.002) in XML,
// which should still conflict with a non-default parent timestep
static constexpr char parent_xml[] = R"(
<mujoco>
<compiler conflict="error"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option timestep="0.002"/>
<worldbody>
<body name="child_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> err;
mjSpec* parent =
mj_parseXMLString(parent_xml, nullptr, err.data(), err.size());
ASSERT_THAT(parent, NotNull()) << err.data();
mjSpec* child = mj_parseXMLString(child_xml, nullptr, err.data(), err.size());
ASSERT_THAT(child, NotNull()) << err.data();
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached =
mjs_attach(world->element, child->element, "child_", "");
// attach should fail: child authored timestep=0.002 (the default),
// but parent has timestep=0.005 — authored tracking catches this
EXPECT_THAT(attached, IsNull());
EXPECT_THAT(mjs_getError(parent), HasSubstr("timestep"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictNonAuthoredDefault) {
// child does NOT set timestep in XML, parent has non-default timestep.
// no conflict should be detected (child didn't author the field)
static constexpr char parent_xml[] = R"(
<mujoco>
<compiler conflict="error"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<worldbody>
<body name="child_body"><geom size="1"/></body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> err;
mjSpec* parent =
mj_parseXMLString(parent_xml, nullptr, err.data(), err.size());
ASSERT_THAT(parent, NotNull()) << err.data();
mjSpec* child = mj_parseXMLString(child_xml, nullptr, err.data(), err.size());
ASSERT_THAT(child, NotNull()) << err.data();
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached =
mjs_attach(world->element, child->element, "child_", "");
// attach should succeed: child didn't author timestep
EXPECT_THAT(attached, NotNull()) << mjs_getError(parent);
// parent timestep should be unchanged
EXPECT_EQ(parent->option.timestep, mjtNum(0.005));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictXMLMergeAuthoredDefault) {
mock_warning_handler.ExpectWarnings();
static constexpr char parent_xml[] = R"(
<mujoco>
<compiler conflict="merge"/>
<option timestep="0.005"/>
<worldbody/>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option timestep="0.002"/>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* parent =
mj_parseXMLString(parent_xml, nullptr, error.data(), error.size());
ASSERT_THAT(parent, NotNull()) << error.data();
mjSpec* child =
mj_parseXMLString(child_xml, nullptr, error.data(), error.size());
ASSERT_THAT(child, NotNull()) << error.data();
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached =
mjs_attach(world->element, child->element, "child_", "");
ASSERT_THAT(attached, NotNull()) << "Error details: " << mjs_getError(parent);
// Child explicitly authored timestep=0.002. Parent has timestep=0.005. So
// they conflict on both-authored. Under merge mode, min-merge applies ->
// child wins.
EXPECT_EQ(parent->option.timestep, mjtNum(0.002));
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0),
HasSubstr("timestep: parent has 0.005, child has 0.002, "
"taking the minimum"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachConflictXMLBitfieldSilentAdoption) {
mock_warning_handler.ExpectWarnings();
static constexpr char parent_xml[] = R"(
<mujoco>
<compiler conflict="merge"/>
<worldbody/>
</mujoco>
)";
static constexpr char child_xml[] = R"(
<mujoco>
<option>
<flag constraint="disable"/>
</option>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* parent =
mj_parseXMLString(parent_xml, nullptr, error.data(), error.size());
ASSERT_THAT(parent, NotNull()) << error.data();
mjSpec* child =
mj_parseXMLString(child_xml, nullptr, error.data(), error.size());
ASSERT_THAT(child, NotNull()) << error.data();
mjsBody* world = mjs_findBody(parent, "world");
mjsElement* attached =
mjs_attach(world->element, child->element, "child_", "");
ASSERT_THAT(attached, NotNull()) << "Error details: " << mjs_getError(parent);
// Parent should adopt child's constraint disable flag since parent didn't
// restrict flags. In merge mode, this logging counts as 1 warning.
EXPECT_EQ(parent->option.disableflags, mjDSBL_CONSTRAINT);
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0),
HasSubstr("flag 'Constraint': added from child"));
mj_deleteSpec(parent);
mj_deleteSpec(child);
}
TEST_F(MujocoTest, AttachWarningBoundaryAndPreservation) {
mock_warning_handler.ExpectWarnings();
// 1. Build a parent XML that generates a compile warning
static constexpr char parent_xml[] = R"(
<mujoco>
<compiler conflict="warning"/>
<option timestep="0.005"/>
<worldbody>
<body name="parent">
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
// 2. Child 1 has conflicting timestep (0.001)
static constexpr char child1_xml[] = R"(
<mujoco>
<option timestep="0.001"/>
<worldbody/>
</mujoco>
)";
// 3. Child 2 has conflicting sleep_tolerance (0.005)
static constexpr char child2_xml[] = R"(
<mujoco>
<option sleep_tolerance="0.005"/>
<worldbody/>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* parent =
mj_parseXMLString(parent_xml, nullptr, error.data(), error.size());
ASSERT_THAT(parent, NotNull()) << error.data();
mjSpec* child1 =
mj_parseXMLString(child1_xml, nullptr, error.data(), error.size());
ASSERT_THAT(child1, NotNull()) << error.data();
mjSpec* child2 =
mj_parseXMLString(child2_xml, nullptr, error.data(), error.size());
ASSERT_THAT(child2, NotNull()) << error.data();
mjsBody* world = mjs_findBody(parent, "world");
// Perform Attach 1
mjsElement* attached1 =
mjs_attach(world->element, child1->element, "c1_", "");
ASSERT_THAT(attached1, NotNull())
<< "Error details: " << mjs_getError(parent);
EXPECT_EQ(mjs_numWarnings(parent), 1);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
// Perform Attach 2
mjsElement* attached2 =
mjs_attach(world->element, child2->element, "c2_", "");
ASSERT_THAT(attached2, NotNull())
<< "Error details: " << mjs_getError(parent);
EXPECT_EQ(mjs_numWarnings(parent), 2);
EXPECT_THAT(mjs_getWarning(parent, 1), HasSubstr("sleep_tolerance"));
// Compile 1: compiles and generates a compile warning (flex is not rigid)
mjModel* model1 = mj_compile(parent, nullptr);
ASSERT_THAT(model1, NotNull());
// Warnings should include both attach warnings (indices 0 and 1) AND the
// compile warning (at index 2)
int num_warnings_after_compile = mjs_numWarnings(parent);
EXPECT_GE(num_warnings_after_compile, 3);
EXPECT_THAT(mjs_getWarning(parent, 2), HasSubstr("not rigid"));
// Compile 2 (Recompile): Recompiling should CLEAR compile warnings but
// PRESERVE all attach warnings!
mjModel* model2 = mj_compile(parent, nullptr);
ASSERT_THAT(model2, NotNull());
// Attach warnings must still be preserved and compile warnings refreshed
EXPECT_EQ(mjs_numWarnings(parent), num_warnings_after_compile);
EXPECT_THAT(mjs_getWarning(parent, 0), HasSubstr("timestep"));
EXPECT_THAT(mjs_getWarning(parent, 1), HasSubstr("sleep_tolerance"));
EXPECT_THAT(mjs_getWarning(parent, 2), HasSubstr("not rigid"));
mj_deleteModel(model1);
mj_deleteModel(model2);
mj_deleteSpec(parent);
mj_deleteSpec(child1);
mj_deleteSpec(child2);
}
} // namespace
} // namespace mujoco
+2
View File
@@ -99,6 +99,8 @@ std::vector<std::string> GetWriteReadTestModels() {
// exclude files that fail since we do not save pinned flex nodes
absl::StrContains(xml, "gripper_trilinear") ||
absl::StrContains(xml, "strain") ||
// exclude conflict test assets (designed to fail compile)
absl::StrContains(xml, "xml/testdata/parent_") ||
// exclude mjz testdata with VFS files
absl::StrContains(xml, "mixed_test")) {
continue;
+10
View File
@@ -0,0 +1,10 @@
<mujoco model="child_mergable">
<option timestep="0.002" iterations="100" gravity="0 0 -10">
<flag damper="disable"/>
</option>
<worldbody>
<body name="child_body">
<geom name="child_geom" size="1"/>
</body>
</worldbody>
</mujoco>
+10
View File
@@ -0,0 +1,10 @@
<mujoco model="child_unmergable">
<option timestep="0.005" iterations="50" gravity="0 0 0">
<flag damper="enable" constraint="disable"/>
</option>
<worldbody>
<body name="child_body">
<geom name="child_geom" size="1"/>
</body>
</worldbody>
</mujoco>
+13
View File
@@ -0,0 +1,13 @@
<mujoco model="parent_error">
<compiler conflict="error"/>
<option timestep="0.005" iterations="50" gravity="0 0 -10"/>
<asset>
<model name="child" file="child_mergable.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom name="parent_geom" size="2"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
+13
View File
@@ -0,0 +1,13 @@
<mujoco model="parent_merge">
<compiler conflict="merge"/>
<option timestep="0.005" iterations="50" gravity="0 0 -10"/>
<asset>
<model name="child" file="child_mergable.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom name="parent_geom" size="2"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
+13
View File
@@ -0,0 +1,13 @@
<mujoco model="parent_merge_unmergable">
<compiler conflict="merge"/>
<option timestep="0.005" iterations="50" gravity="0 0 -10"/>
<asset>
<model name="child" file="child_unmergable.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom name="parent_geom" size="2"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
+15
View File
@@ -0,0 +1,15 @@
<mujoco model="parent_warn">
<compiler conflict="warning"/>
<option timestep="0.005" iterations="50" gravity="0 0 -10">
<flag damper="disable"/>
</option>
<asset>
<model name="child" file="child_unmergable.xml"/>
</asset>
<worldbody>
<body name="parent_body">
<geom name="parent_geom" size="2"/>
<attach model="child" body="child_body" prefix="child/"/>
</body>
</worldbody>
</mujoco>
+75
View File
@@ -3897,5 +3897,80 @@ TEST_F(ActuatorParseTest, DampingArmatureDefaultsPropagate) {
mj_deleteModel(model);
}
TEST_F(XMLReaderTest, AttachConflictXMLWarning) {
mock_warning_handler.ExpectWarnings();
std::array<char, 1024> error;
std::string path = GetTestDataFilePath("xml/testdata/parent_warn.xml");
mjSpec* spec = mj_parseXML(path.c_str(), nullptr, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
// one grouped warning per attach, containing gravity and damper flag
// (constraint is only authored by child, not a conflict)
EXPECT_EQ(mjs_numWarnings(spec), 1);
std::string w = mjs_getWarning(spec, 0);
EXPECT_THAT(w, HasSubstr("gravity: parent has 0 0 -10, child has 0 0 0,"
" keeping parent value"));
EXPECT_THAT(w, HasSubstr("flag 'Damper'"));
mjModel* m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull());
// Gravity should be parent's value (0 0 -10)
EXPECT_MJTNUM_EQ(m->opt.gravity[0], 0);
EXPECT_MJTNUM_EQ(m->opt.gravity[1], 0);
EXPECT_MJTNUM_EQ(m->opt.gravity[2], -10);
mj_deleteModel(m);
mj_deleteSpec(spec);
}
TEST_F(XMLReaderTest, AttachConflictXMLMerge) {
mock_warning_handler.ExpectWarnings();
std::array<char, 1024> error;
std::string path = GetTestDataFilePath("xml/testdata/parent_merge.xml");
mjSpec* spec = mj_parseXML(path.c_str(), nullptr, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
// one grouped warning per attach, containing timestep, iterations, and flag
EXPECT_GE(mjs_numWarnings(spec), 1);
std::string w = mjs_getWarning(spec, 0);
EXPECT_THAT(w, HasSubstr("timestep: parent has 0.005, child has 0.002,"
" taking the minimum"));
EXPECT_THAT(w, HasSubstr("iterations: parent has 50, child has 100,"
" taking the maximum"));
mjModel* m = mj_compile(spec, nullptr);
ASSERT_THAT(m, NotNull());
// Timestep should be min (0.002)
EXPECT_MJTNUM_EQ(m->opt.timestep, 0.002);
// Iterations should be max (100)
EXPECT_EQ(m->opt.iterations, 100);
mj_deleteModel(m);
mj_deleteSpec(spec);
}
TEST_F(XMLReaderTest, AttachConflictXMLError) {
std::array<char, 1024> error;
std::string path = GetTestDataFilePath("xml/testdata/parent_error.xml");
mjSpec* spec = mj_parseXML(path.c_str(), nullptr, error.data(), error.size());
// Should fail to parse because of conflict in timestep in error mode
EXPECT_THAT(spec, IsNull());
EXPECT_THAT(error.data(),
HasSubstr("timestep: parent has 0.005, child has 0.002"));
}
TEST_F(XMLReaderTest, AttachConflictXMLMergeUnmergableError) {
std::array<char, 1024> error;
std::string path =
GetTestDataFilePath("xml/testdata/parent_merge_unmergable.xml");
mjSpec* spec = mj_parseXML(path.c_str(), nullptr, error.data(), error.size());
// Should fail to parse because gravity is unmergeable in merge mode
EXPECT_THAT(spec, IsNull());
EXPECT_THAT(error.data(),
HasSubstr("gravity: parent has 0 0 -10, child has 0 0 0"));
}
} // namespace
} // namespace mujoco
+24 -29
View File
@@ -45,29 +45,30 @@ std::vector<std::string> GetWriteReadTestModels() {
if (p.path().extension() == ext) {
std::string xml = p.path().string();
if ( // if file is meant to fail, skip it
absl::StrContains(xml, "malformed_") ||
absl::StrContains(xml, "_fail") ||
// exclude files that are too slow to load
absl::StrContains(xml, "cow") ||
absl::StrContains(xml, "gmsh_") ||
absl::StrContains(xml, "shark_") ||
absl::StrContains(xml, "perf") ||
// exclude files that fail the comparison test
absl::StrContains(xml, "rfcamera") ||
absl::StrContains(xml, "tactile") ||
absl::StrContains(xml, "makemesh") ||
absl::StrContains(xml, "many_dependencies") ||
absl::StrContains(xml, "usd") ||
absl::StrContains(xml, "torus_maxhull") ||
absl::StrContains(xml, "fitmesh_") ||
absl::StrContains(xml, "lengthrange") ||
absl::StrContains(xml, "hfield_xml") ||
absl::StrContains(xml, "fromto_convex") ||
absl::StrContains(xml, "cube_skin") ||
absl::StrContains(xml, "cube_3x3x3") ||
// exclude files that fail since we do not save pinned flex nodes
absl::StrContains(xml, "gripper_trilinear") ||
absl::StrContains(xml, "strain")) {
absl::StrContains(xml, "malformed_") ||
absl::StrContains(xml, "_fail") ||
// exclude files that are too slow to load
absl::StrContains(xml, "cow") || absl::StrContains(xml, "gmsh_") ||
absl::StrContains(xml, "shark_") ||
absl::StrContains(xml, "perf") ||
// exclude files that fail the comparison test
absl::StrContains(xml, "rfcamera") ||
absl::StrContains(xml, "tactile") ||
absl::StrContains(xml, "makemesh") ||
absl::StrContains(xml, "many_dependencies") ||
absl::StrContains(xml, "usd") ||
absl::StrContains(xml, "torus_maxhull") ||
absl::StrContains(xml, "fitmesh_") ||
absl::StrContains(xml, "lengthrange") ||
absl::StrContains(xml, "hfield_xml") ||
absl::StrContains(xml, "fromto_convex") ||
absl::StrContains(xml, "cube_skin") ||
absl::StrContains(xml, "cube_3x3x3") ||
// 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;
}
models.push_back(xml);
@@ -84,12 +85,6 @@ class WriteReadCompareTest : public XMLWriterTest,
TEST_P(WriteReadCompareTest, WriteReadCompare) {
std::string xml = GetParam();
// If this is the flex_line_obj model, expect the 'is not rigid' warning
if (absl::StrContains(xml, "flex_line_obj")) {
EXPECT_CALL(mock_warning_handler, Warn(testing::HasSubstr("is not rigid")))
.WillRepeatedly(testing::Return());
}
// full precision float printing
FullFloatPrecision increase_precision;
+6
View File
@@ -583,6 +583,11 @@ public enum mjtOrientation : int{
mjORIENTATION_ZAXIS = 3,
mjORIENTATION_EULER = 4,
}
public enum mjtConflict : int{
mjCONFLICT_WARNING = 0,
mjCONFLICT_MERGE = 1,
mjCONFLICT_ERROR = 2,
}
public enum mjtCTimer : int{
mjCTIMER_TOTAL = 0,
mjCTIMER_ASSETS = 1,
@@ -5865,6 +5870,7 @@ public unsafe struct mjsCompiler_ {
public fixed int inertiagrouprange[2];
public byte saveinertial;
public int alignfree;
public int conflict;
public mjLROpt_ LRopt;
public void* meshdir;
public void* texturedir;
+11
View File
@@ -2331,6 +2331,12 @@ struct MjsCompiler {
void set_alignfree(int value) {
ptr_->alignfree = value;
}
int conflict() const {
return ptr_->conflict;
}
void set_conflict(int value) {
ptr_->conflict = value;
}
mjString meshdir() const {
return (ptr_ && ptr_->meshdir) ? *(ptr_->meshdir) : "";
}
@@ -11312,6 +11318,10 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
enum_<mjtCone>("mjtCone")
.value("mjCONE_PYRAMIDAL", mjCONE_PYRAMIDAL)
.value("mjCONE_ELLIPTIC", mjCONE_ELLIPTIC);
enum_<mjtConflict>("mjtConflict")
.value("mjCONFLICT_WARNING", mjCONFLICT_WARNING)
.value("mjCONFLICT_MERGE", mjCONFLICT_MERGE)
.value("mjCONFLICT_ERROR", mjCONFLICT_ERROR);
enum_<mjtConstraint>("mjtConstraint")
.value("mjCNSTR_EQUALITY", mjCNSTR_EQUALITY)
.value("mjCNSTR_FRICTION_DOF", mjCNSTR_FRICTION_DOF)
@@ -12993,6 +13003,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
.property("balanceinertia", &MjsCompiler::balanceinertia, &MjsCompiler::set_balanceinertia, reference())
.property("boundinertia", &MjsCompiler::boundinertia, &MjsCompiler::set_boundinertia, reference())
.property("boundmass", &MjsCompiler::boundmass, &MjsCompiler::set_boundmass, reference())
.property("conflict", &MjsCompiler::conflict, &MjsCompiler::set_conflict, reference())
.property("degree", &MjsCompiler::degree, &MjsCompiler::set_degree, reference())
.property("discardvisual", &MjsCompiler::discardvisual, &MjsCompiler::set_discardvisual, reference())
.property("eulerseq", &MjsCompiler::eulerseq)
+30
View File
@@ -1803,6 +1803,36 @@ describe('MuJoCo WASM Bindings', () => {
}
});
it('should compile a spec and emit warnings', () => {
const xml = `
<mujoco>
<worldbody>
<flexcomp name="test" type="grid" count="4 4 1" spacing=".2 .2 .2"
dim="2" radius=".1"/>
</worldbody>
</mujoco>`;
let spec = null;
let model = null;
const warnSpy = spyOn(console, 'warn');
try {
spec = mujoco.parseXMLString(xml);
expect(spec).not.toBeNull();
model = mujoco.mj_compile(spec);
expect(model).not.toBeNull();
expect(warnSpy).toHaveBeenCalled();
const warningCall = warnSpy.calls.mostRecent();
expect(warningCall.args[0]).toContain("no equality constraints or passive forces");
} finally {
if (spec) {
spec.delete();
}
if (model) {
model.delete();
}
}
});
it('should compile a spec from XML string with no assets', () => {
let spec = null;
let model = null;