Add mjcf.schema and its declarative schema language.
The complete MJCF surface in one hand-maintained file: 144 elements, 8 shared attribute groups, 47 enums, 1,497 typed attributes with defaults, the presence-constraint inventory previously visible only as hand-written reader checks, and the save policies previously visible only as hand-written writer logic. The language is a small IDL: elements bound to their mjSpec structs, typed attributes with arities and defaults, enum keyword sets with C bindings, reusable and variant groups, explicit name/reference namespaces (id<ns>/ref<ns>, following dm_control's identifier/reference model), child cardinalities, presence constraints (exclusive/together/requires/oneof over attribute bundles), bitwise flag sets, identity constants (set field = CONST), fixed char arrays (chars[n], arity counting characters), numeric range facets, and two escape hatches: reading=custom (no typed binding is generated; both reading and saving are hand-written) and writing=custom (the binding drives the reader, the save policy is hand-written). doc/generate/mjcf_schema.py is the dependency-free parser and semantic validator; errors report file:line; 55 unit tests. The language is documented by the cheat-sheet legend at the top of the schema file. The schema was bootstrapped by extraction from the sources of record -- the MJCF[] table, the mjMap keyword tables, the ~660 ReadAttr*/MapValue call sites, mjspec.h struct fields, and the default-constructors in user_init.c and engine_init.c -- then hand-curated. Same-tag elements that differ by context are distinct declarations carrying an xml= facet; worldbody, frame and replicate carry alias=body, mirroring mjXSchema::NameMatch. The top-level order is by dependency, what a saved file should read like: front matter, declarations before use, the tree, the sections that reference it, the data tail. PiperOrigin-RevId: 958060695 Change-Id: Ie10fd9f0ef202a3626f4d635d02c8731a4d287df
This commit is contained in:
committed by
Copybara-Service
parent
fb07a9ca50
commit
3f8db4c17a
@@ -17,4 +17,7 @@ if(Python3_FOUND)
|
||||
add_test(NAME doc_test
|
||||
COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/doc_test.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
add_test(NAME mjcf_schema_test
|
||||
COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/mjcf_schema_test.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# 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.
|
||||
# ==============================================================================
|
||||
"""Tests for the MJCF schema definition language parser."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest as googletest
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||
sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'generate'))
|
||||
import mjcf_schema
|
||||
|
||||
GOOD = '''
|
||||
enum geomtype : mjtGeom { # geom shapes
|
||||
plane = mjGEOM_PLANE
|
||||
sphere = mjGEOM_SPHERE
|
||||
"2d" = mjGEOM_PLANE
|
||||
}
|
||||
|
||||
enum onoff {
|
||||
false = 0
|
||||
true = 1
|
||||
}
|
||||
|
||||
group orientation variant { # at most one spelling
|
||||
quat : double[4] = {1, 0, 0, 0}
|
||||
axisangle : double[4] # (x, y, z, angle)
|
||||
euler : double[3]
|
||||
}
|
||||
|
||||
group posed {
|
||||
pos : double[3] = {0, 0, 0}
|
||||
use orientation
|
||||
}
|
||||
|
||||
element defaults {
|
||||
class : id<defaults> # name of this class
|
||||
}
|
||||
|
||||
element geom : mjsGeom { # geometric entity
|
||||
use posed
|
||||
name : id<geom> # element name
|
||||
class : ref<defaults> (field=classname) # defaults class
|
||||
type : enum<geomtype> = sphere # geom shape
|
||||
condim : int = 3
|
||||
size : double[0..3] # type-specific size
|
||||
friction : double[1..3] = {1, 0.005, 0.0001} # slide, roll, spin
|
||||
solref : double[0..mjNREF]
|
||||
eulerseq : string = "xyz" (pattern="[xyzXYZ]{3}")
|
||||
margin : double = 0 (nodefault)
|
||||
file : string (required)
|
||||
user : double[] # user data
|
||||
child geom * # nested geoms
|
||||
child defaults R
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class ParserTest(googletest.TestCase):
|
||||
|
||||
def parse(self, text):
|
||||
return mjcf_schema.parse_string(text)
|
||||
|
||||
def error(self, text):
|
||||
with self.assertRaises(mjcf_schema.SchemaError) as ctx:
|
||||
self.parse(text)
|
||||
return str(ctx.exception)
|
||||
|
||||
def test_good_schema_parses(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertEqual(set(schema.enums), {'geomtype', 'onoff'})
|
||||
self.assertEqual(set(schema.groups), {'orientation', 'posed'})
|
||||
self.assertEqual(set(schema.elements), {'geom', 'defaults'})
|
||||
|
||||
def test_enum(self):
|
||||
schema = self.parse(GOOD)
|
||||
enum = schema.enums['geomtype']
|
||||
self.assertEqual(enum.ctype, 'mjtGeom')
|
||||
self.assertEqual(enum.items[0], ('plane', 'mjGEOM_PLANE'))
|
||||
self.assertEqual(enum.items[2], ('2d', 'mjGEOM_PLANE'))
|
||||
self.assertEqual(enum.doc, 'geom shapes')
|
||||
self.assertIsNone(schema.enums['onoff'].ctype)
|
||||
self.assertEqual(schema.enums['onoff'].items[0], ('false', '0'))
|
||||
|
||||
def test_groups_and_expansion(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertTrue(schema.groups['orientation'].variant)
|
||||
self.assertFalse(schema.groups['posed'].variant)
|
||||
names = [a.name for a in schema.expanded_attrs(schema.elements['geom'])]
|
||||
# posed expands to pos + orientation members, in order, before own attrs.
|
||||
self.assertEqual(names[:4], ['pos', 'quat', 'axisangle', 'euler'])
|
||||
self.assertIn('friction', names)
|
||||
|
||||
def test_attr_types_and_arity(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['condim'].arity, mjcf_schema.Arity(1, 1))
|
||||
self.assertEqual(attrs['quat'].arity, mjcf_schema.Arity(4, 4))
|
||||
self.assertEqual(attrs['size'].arity, mjcf_schema.Arity(0, 3))
|
||||
self.assertEqual(attrs['friction'].arity, mjcf_schema.Arity(1, 3))
|
||||
self.assertEqual(attrs['solref'].arity, mjcf_schema.Arity(0, 'mjNREF'))
|
||||
self.assertEqual(attrs['user'].arity, mjcf_schema.Arity(0, None))
|
||||
self.assertEqual(attrs['type'].type, 'enum')
|
||||
self.assertEqual(attrs['type'].target, 'geomtype')
|
||||
self.assertEqual(attrs['class'].type, 'ref')
|
||||
self.assertEqual(attrs['class'].target, 'defaults')
|
||||
self.assertEqual(attrs['name'].type, 'id')
|
||||
self.assertEqual(attrs['name'].target, 'geom')
|
||||
|
||||
def test_defaults(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['friction'].default, (1, 0.005, 0.0001))
|
||||
self.assertEqual(attrs['condim'].default, 3.0)
|
||||
self.assertEqual(attrs['type'].default, 'sphere')
|
||||
self.assertEqual(attrs['eulerseq'].default, 'xyz')
|
||||
self.assertIsNone(attrs['size'].default)
|
||||
|
||||
def test_facets(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['class'].facets, {'field': 'classname'})
|
||||
self.assertEqual(attrs['eulerseq'].facets, {'pattern': '[xyzXYZ]{3}'})
|
||||
self.assertEqual(attrs['margin'].facets, {'nodefault': True})
|
||||
self.assertEqual(attrs['file'].facets, {'required': True})
|
||||
|
||||
def test_children(self):
|
||||
schema = self.parse(GOOD)
|
||||
children = schema.elements['geom'].children()
|
||||
self.assertEqual([(c.name, c.card) for c in children],
|
||||
[('geom', '*'), ('defaults', 'R')])
|
||||
|
||||
def test_docs(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertEqual(schema.elements['geom'].doc, 'geometric entity')
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['friction'].doc, 'slide, roll, spin')
|
||||
self.assertEqual(attrs['axisangle'].doc, '(x, y, z, angle)')
|
||||
self.assertIsNone(attrs['condim'].doc)
|
||||
|
||||
|
||||
class ErrorTest(googletest.TestCase):
|
||||
|
||||
def error(self, text):
|
||||
with self.assertRaises(mjcf_schema.SchemaError) as ctx:
|
||||
mjcf_schema.parse_string(text)
|
||||
return str(ctx.exception)
|
||||
|
||||
def test_error_has_line_number(self):
|
||||
message = self.error('element geom {\n size ; double\n}')
|
||||
self.assertIn('<string>:2:', message)
|
||||
|
||||
def test_duplicate_element(self):
|
||||
message = self.error('element geom {}\nelement geom {}')
|
||||
self.assertIn('duplicate element', message)
|
||||
|
||||
def test_duplicate_attr(self):
|
||||
message = self.error('element geom {\n a : int\n a : double\n}')
|
||||
self.assertIn("duplicate attribute 'a'", message)
|
||||
|
||||
def test_duplicate_attr_via_use(self):
|
||||
message = self.error('group g {\n a : int\n}\n'
|
||||
'element geom {\n use g\n a : double\n}')
|
||||
self.assertIn("duplicate attribute 'a'", message)
|
||||
|
||||
def test_dangling_enum(self):
|
||||
message = self.error('element geom {\n type : enum<nosuch>\n}')
|
||||
self.assertIn("undeclared enum 'nosuch'", message)
|
||||
|
||||
def test_dangling_ref(self):
|
||||
message = self.error('element geom {\n mesh : ref<nosuch>\n}')
|
||||
self.assertIn("namespace 'nosuch'", message)
|
||||
|
||||
def test_ref_resolved_by_id_elsewhere(self):
|
||||
mjcf_schema.parse_string(
|
||||
'element mesh {\n name : id<mesh>\n}\n'
|
||||
'element geom {\n mesh : ref<mesh>\n}')
|
||||
|
||||
def test_id_with_default(self):
|
||||
message = self.error('element geom {\n name : id<geom> = "x"\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_dangling_use(self):
|
||||
message = self.error('element geom {\n use nosuch\n}')
|
||||
self.assertIn("undeclared group 'nosuch'", message)
|
||||
|
||||
def test_dangling_child(self):
|
||||
message = self.error('element geom {\n child nosuch *\n}')
|
||||
self.assertIn("undeclared element 'nosuch'", message)
|
||||
|
||||
def test_use_cycle(self):
|
||||
message = self.error('group a {\n use b\n}\ngroup b {\n use a\n}')
|
||||
self.assertIn('cycle', message)
|
||||
|
||||
def test_default_too_long(self):
|
||||
message = self.error('element geom {\n size : double[3] = {1, 2, 3, 4}\n}')
|
||||
self.assertIn('at most 3', message)
|
||||
|
||||
def test_default_too_short(self):
|
||||
message = self.error('element geom {\n size : double[3] = {1, 2}\n}')
|
||||
self.assertIn('at least 3', message)
|
||||
|
||||
def test_vector_default_on_scalar(self):
|
||||
message = self.error('element geom {\n mass : double = {1, 2}\n}')
|
||||
self.assertIn('vector default for scalar', message)
|
||||
|
||||
def test_enum_default_not_a_keyword(self):
|
||||
message = self.error('enum e {\n a = 0\n}\n'
|
||||
'element geom {\n t : enum<e> = b\n}')
|
||||
self.assertIn('not a keyword', message)
|
||||
|
||||
def test_unknown_facet(self):
|
||||
message = self.error('element geom {\n a : int (frobnicate)\n}')
|
||||
self.assertIn("unknown facet 'frobnicate'", message)
|
||||
|
||||
def test_required_with_default(self):
|
||||
message = self.error('element geom {\n a : int = 1 (required)\n}')
|
||||
self.assertIn('required and has a default', message)
|
||||
|
||||
def test_variant_with_required(self):
|
||||
message = self.error('group g variant {\n a : int (required)\n}\n'
|
||||
'element geom {\n use g\n}')
|
||||
self.assertIn('may not be required', message)
|
||||
|
||||
def test_variant_with_use(self):
|
||||
message = self.error('group inner {\n a : int\n}\n'
|
||||
'group g variant {\n use inner\n}')
|
||||
self.assertIn("may not contain 'use'", message)
|
||||
|
||||
def test_duplicate_enum_keyword(self):
|
||||
message = self.error('enum e {\n a = 0\n a = 1\n}')
|
||||
self.assertIn('duplicate enum keyword', message)
|
||||
|
||||
def test_duplicate_child(self):
|
||||
message = self.error('element a {}\n'
|
||||
'element geom {\n child a *\n child a ?\n}')
|
||||
self.assertIn("duplicate child 'a'", message)
|
||||
|
||||
def test_empty_enum(self):
|
||||
message = self.error('enum e {\n}')
|
||||
self.assertIn('is empty', message)
|
||||
|
||||
def test_child_in_group(self):
|
||||
message = self.error('group g {\n child geom *\n}')
|
||||
self.assertIn('not allowed in a group', message)
|
||||
|
||||
def test_decreasing_arity(self):
|
||||
message = self.error('element geom {\n a : double[3..2]\n}')
|
||||
self.assertIn('not increasing', message)
|
||||
|
||||
def test_constraints(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element connect {\n'
|
||||
' site1 : ref<site>\n site2 : ref<site>\n'
|
||||
' body1 : string\n anchor : double[3]\n'
|
||||
' exclusive site1+site2 body1+anchor # semantics cannot mix\n'
|
||||
' oneof site1+site2 body1+anchor\n'
|
||||
' requires site1 site2\n'
|
||||
'}\n'
|
||||
'element site {\n name : id<site>\n}')
|
||||
cons = schema.elements['connect'].constraints()
|
||||
self.assertEqual([c.kind for c in cons],
|
||||
['exclusive', 'oneof', 'requires'])
|
||||
self.assertEqual(cons[0].bundles,
|
||||
[('site1', 'site2'), ('body1', 'anchor')])
|
||||
self.assertEqual(cons[0].doc, 'semantics cannot mix')
|
||||
self.assertEqual(cons[2].bundles, [('site1',), ('site2',)])
|
||||
|
||||
def test_constraint_unknown_attr(self):
|
||||
message = self.error('element a {\n x : int\n exclusive x nosuch\n}')
|
||||
self.assertIn("unknown attribute 'nosuch'", message)
|
||||
|
||||
def test_requires_arity(self):
|
||||
message = self.error(
|
||||
'element a {\n x : int\n y : int\n z : int\n'
|
||||
' requires x y+z\n}')
|
||||
self.assertIn('exactly two attributes', message)
|
||||
|
||||
def test_flags_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'enum camout : mjtCamOutBit {\n rgb = mjCAMOUT_RGB\n}\n'
|
||||
'element camera {\n output : flags<camout>\n}')
|
||||
attr = schema.elements['camera'].members[0]
|
||||
self.assertEqual(attr.type, 'flags')
|
||||
self.assertEqual(attr.target, 'camout')
|
||||
|
||||
def test_min_max_positive_facets(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element size {\n'
|
||||
' nkey : int (min=-1)\n'
|
||||
' group : int (min=0, max=5)\n'
|
||||
' znear : float (positive)\n'
|
||||
'}')
|
||||
attrs = {a.name: a for a in schema.elements['size'].members}
|
||||
self.assertEqual(attrs['nkey'].facets['min'], -1.0)
|
||||
self.assertEqual(attrs['group'].facets['max'], 5.0)
|
||||
self.assertTrue(attrs['znear'].facets['positive'])
|
||||
|
||||
def test_min_on_string_rejected(self):
|
||||
message = self.error('element a {\n s : string (min=0)\n}')
|
||||
self.assertIn('requires a numeric attribute', message)
|
||||
|
||||
def test_const_member(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element touch : mjsSensor {\n'
|
||||
' set type = mjSENS_TOUCH # sensor type from tag\n'
|
||||
' set objtype = mjOBJ_SITE\n'
|
||||
' a : int\n}')
|
||||
consts = schema.elements['touch'].consts()
|
||||
self.assertEqual([(c.field, c.value) for c in consts],
|
||||
[('type', 'mjSENS_TOUCH'), ('objtype', 'mjOBJ_SITE')])
|
||||
self.assertEqual(consts[0].doc, 'sensor type from tag')
|
||||
|
||||
def test_const_in_group_rejected(self):
|
||||
message = self.error('group g {\n set type = mjSENS_TOUCH\n}')
|
||||
self.assertIn('not allowed in a group', message)
|
||||
|
||||
def test_element_facets(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element body {}\n'
|
||||
'element eq_joint : mjsEquality (xml=joint) {\n'
|
||||
' polycoef : double[5]\n}\n'
|
||||
'element frame (alias=body) {}\n')
|
||||
self.assertEqual(schema.elements['eq_joint'].xml_name(), 'joint')
|
||||
self.assertEqual(schema.elements['body'].xml_name(), 'body')
|
||||
self.assertEqual(schema.elements['frame'].facets, {'alias': 'body'})
|
||||
|
||||
def test_element_field_facet(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element global : mjVisual (field=global) {\n fovy : double\n}')
|
||||
self.assertEqual(schema.elements['global'].facets['field'], 'global')
|
||||
|
||||
def test_element_unknown_facet(self):
|
||||
message = self.error('element geom (required) {}')
|
||||
self.assertIn("unknown facet 'required'", message)
|
||||
|
||||
def test_element_dangling_alias(self):
|
||||
message = self.error('element frame (alias=nosuch) {}')
|
||||
self.assertIn("undeclared element 'nosuch'", message)
|
||||
|
||||
def test_bool_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element compiler {\n autolimits : bool = true\n}')
|
||||
attr = schema.elements['compiler'].members[0]
|
||||
self.assertEqual(attr.type, 'bool')
|
||||
self.assertEqual(attr.default, 'true')
|
||||
|
||||
def test_bool_bad_default(self):
|
||||
message = self.error('element compiler {\n autolimits : bool = maybe\n}')
|
||||
self.assertIn('must be true or false', message)
|
||||
|
||||
def test_bool_vector_rejected(self):
|
||||
message = self.error('element compiler {\n a : bool[2]\n}')
|
||||
self.assertIn('may not be a vector', message)
|
||||
|
||||
def test_file_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element mesh {\n file : file (required) # mesh file\n}')
|
||||
attr = schema.elements['mesh'].members[0]
|
||||
self.assertEqual(attr.type, 'file')
|
||||
self.assertTrue(attr.arity.is_scalar())
|
||||
|
||||
def test_file_vector_rejected(self):
|
||||
message = self.error('element mesh {\n file : file[3]\n}')
|
||||
self.assertIn('may not be a vector', message)
|
||||
|
||||
def test_pattern_on_numeric(self):
|
||||
message = self.error('element geom {\n a : int (pattern="x")\n}')
|
||||
self.assertIn("'pattern' requires a text attribute", message)
|
||||
|
||||
def test_chars_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element compiler {\n eulerseq : chars[3] (pattern="[xyz]{3}")\n}')
|
||||
attr = schema.elements['compiler'].members[0]
|
||||
self.assertEqual(attr.type, 'chars')
|
||||
self.assertEqual((attr.arity.lo, attr.arity.hi), (3, 3))
|
||||
|
||||
def test_chars_unbounded_rejected(self):
|
||||
message = self.error('element compiler {\n a : chars[]\n}')
|
||||
self.assertIn('must declare a bounded length', message)
|
||||
|
||||
def test_chars_with_default_rejected(self):
|
||||
message = self.error('element compiler {\n a : chars[3] = "xyz"\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_ref_with_default(self):
|
||||
message = self.error('element a {\n name : id<a>\n}\n'
|
||||
'element geom {\n r : ref<a> = a\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_string_lexing_range_vs_float(self):
|
||||
# 0..3 must lex as a range, not the floats '0.' and '.3'.
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element geom {\n a : double[0..3] = {0.5, .25, 1e-3}\n}')
|
||||
attr = schema.elements['geom'].members[0]
|
||||
self.assertEqual(attr.arity, mjcf_schema.Arity(0, 3))
|
||||
self.assertEqual(attr.default, (0.5, 0.25, 0.001))
|
||||
|
||||
def test_min_max_integer_facet(self):
|
||||
# Integer values in facets dictionary must be accepted as numeric.
|
||||
attr = mjcf_schema.Attr(
|
||||
name='group', type='int', target=None,
|
||||
arity=mjcf_schema.Arity(1, 1), default=None,
|
||||
facets={'min': 0, 'max': 5}, doc=None, line=1)
|
||||
element = mjcf_schema.Element(
|
||||
name='geom', spec=None, facets={}, members=[attr], doc=None, line=1)
|
||||
schema = mjcf_schema.Schema(
|
||||
enums={}, groups={}, elements={'geom': element}, path='<test>')
|
||||
mjcf_schema._validate(schema)
|
||||
|
||||
def test_min_greater_than_max(self):
|
||||
message = self.error(
|
||||
'element geom {\n a : double (min=10, max=5)\n}')
|
||||
self.assertIn("facet 'min' cannot be greater than 'max'", message)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
googletest.main()
|
||||
Reference in New Issue
Block a user