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:
committed by
Copybara-Service
parent
30357c38a3
commit
410c73168c
@@ -915,6 +915,15 @@ Type of built-in procedural mesh.
|
||||
|
||||
.. mujoco-include:: mjtMeshBuiltin
|
||||
|
||||
.. _mjtConflict:
|
||||
|
||||
mjtConflict
|
||||
~~~~~~~~~~~
|
||||
|
||||
Conflict resolution mode for attach.
|
||||
|
||||
.. mujoco-include:: mjtConflict
|
||||
|
||||
|
||||
.. _mjtCTimer:
|
||||
|
||||
|
||||
@@ -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** |?|
|
||||
|
||||
@@ -254,6 +254,9 @@
|
||||
.. grid-item::
|
||||
:ref:`alignfree<compiler-alignfree>`
|
||||
|
||||
.. grid-item::
|
||||
:ref:`conflict<compiler-conflict>`
|
||||
|
||||
|
||||
.. dropdown:: :ref:`lengthrange<compiler-lengthrange>` :octicon:`dot`
|
||||
|
||||
|
||||
@@ -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>`__.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -146,6 +146,7 @@ raw::MjModel* MjSpec::Compile(mjVFS* vfs) {
|
||||
warnings.attr("warn")(mjs_getWarning(ptr, i));
|
||||
}
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 ------------------------------------------------
|
||||
|
||||
|
||||
+617
-373
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Vendored
+10
@@ -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
@@ -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>
|
||||
Vendored
+13
@@ -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>
|
||||
Vendored
+13
@@ -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
@@ -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>
|
||||
Vendored
+15
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user