From 3f8db4c17af81c4fd408b83a970bc922b206de4f Mon Sep 17 00:00:00 2001 From: Yuval Tassa Date: Sun, 2 Aug 2026 16:09:23 -0700 Subject: [PATCH] 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/ref, 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 --- doc/generate/mjcf_schema.py | 767 ++++++++++++ src/xml/mjcf.schema | 2247 ++++++++++++++++++++++++++++++++++ test/doc/CMakeLists.txt | 3 + test/doc/mjcf_schema_test.py | 430 +++++++ 4 files changed, 3447 insertions(+) create mode 100644 doc/generate/mjcf_schema.py create mode 100644 src/xml/mjcf.schema create mode 100644 test/doc/mjcf_schema_test.py diff --git a/doc/generate/mjcf_schema.py b/doc/generate/mjcf_schema.py new file mode 100644 index 00000000..c054bd48 --- /dev/null +++ b/doc/generate/mjcf_schema.py @@ -0,0 +1,767 @@ +# 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. +# ============================================================================== +"""Parser for the MJCF schema definition language. + +The MJCF grammar is defined in a single hand-edited file (src/xml/mjcf.schema) +written in a small declarative language. This module parses and validates that +language and exposes the result as plain dataclasses, consumed by the +generators in this directory. + +Grammar (see designs doc for rationale): + + schema := { decl } + decl := enum | group | element + enum := "enum" IDENT [":" IDENT] "{" { key "=" value } "}" + key := IDENT | STRING # XML keyword ("2d" needs quoting) + value := IDENT | NUMBER # C constant name or literal value + group := "group" IDENT ["variant"] "{" { attr | use | constraint } "}" + element := "element" IDENT [":" IDENT] ["(" facet {"," facet} ")"] + "{" { attr | use | child | const | constraint } "}" + const := "set" IDENT "=" IDENT + (field takes the C constant when the element is read; states + what the element's identity implies, e.g. a sensor's type) + attr := IDENT ":" type ["=" default] ["(" facet {"," facet} ")"] + type := scalar ["[" [arity] "]"] | "enum" "<" IDENT ">" + | "flags" "<" IDENT ">" | "id" "<" IDENT ">" | "ref" "<" IDENT ">" + constraint:= verb bundle bundle { bundle } + verb := "exclusive" | "together" | "requires" | "oneof" + bundle := IDENT { "+" IDENT } + (presence constraints over attributes: exclusive = at most one + bundle present, together = all-or-none, requires a b = a needs + b, oneof = at least one bundle complete; a bundle is complete + when all its attributes appear) + scalar := "double" | "float" | "int" | "bool" | "string" | "file" + | "chars" + arity := NUMBER | NUMBER ".." NUMBER | NUMBER ".." IDENT + default := NUMBER | STRING | IDENT | "{" NUMBER {"," NUMBER} "}" + facet := IDENT ["=" (IDENT | STRING | NUMBER)] + child := "child" IDENT card + card := "?" | "!" | "*" | "R" + use := "use" IDENT + +Names and references (the dm_control model, namespaces explicit): an +attribute of type id declares a name in namespace `ns` (e.g. geom name); +an attribute of type ref holds the name of an object in that namespace +(e.g. an actuator's site). Namespaces exist by virtue of id declarations; +a ref into a namespace nothing declares into is an error. +""" + +import dataclasses +import re +import sys + +from typing import Any, Optional, Union + +# Facets accepted on attributes. Unknown facets are an error: forward +# compatibility is explicit, not silent. +KNOWN_FACETS = frozenset({'field', 'required', 'nodefault', 'pattern', + 'reading', 'writing', 'min', 'max', 'positive'}) + +# Facets accepted on elements. Element names must be unique, but the same XML +# tag means different elements in different contexts (joint under body, +# equality, composite, tendon/fixed); 'xml' gives the tag when it differs from +# the declaration name. 'alias' records the mjXSchema::NameMatch behavior of +# tags validated against another element's row (worldbody, frame, replicate +# all match body): the MJCF[] emitter skips aliased elements, the XSD emitter +# declares them fully. 'field' names the sub-struct of the bound spec that +# this element's attributes live in (the mjVisual sub-sections). +ELEMENT_FACETS = frozenset({'xml', 'alias', 'field'}) + +# 'file' is a string resolved against asset directories and the VFS +# (the reader's ReadAttrFile); kept distinct for XSD/tooling and bindings. +# 'bool' is a primitive whose XML keywords are exactly "true" and "false". +# 'chars' is text bound to a fixed char array; its arity counts characters, +# not space-separated tokens, and must be bounded. +SCALAR_TYPES = frozenset({'double', 'float', 'int', 'bool', 'string', + 'file', 'chars'}) + +CARDINALITIES = frozenset({'?', '!', '*', 'R'}) + + +class SchemaError(Exception): + """Parse or validation error, formatted as path:line: message.""" + + def __init__(self, path: str, line: int, message: str): + super().__init__(f'{path}:{line}: {message}') + self.path = path + self.line = line + self.message = message + + +@dataclasses.dataclass +class Arity: + """Token count of a vector attribute: exact, ranged, or unbounded. + + `hi` is an int, a symbolic C constant name (e.g. 'mjNREF'), or None for + unbounded. Scalar attributes have arity (1, 1). + """ + lo: int + hi: Union[int, str, None] + + def is_scalar(self) -> bool: + return self.lo == 1 and self.hi == 1 + + +@dataclasses.dataclass +class Attr: + """An attribute declaration in the MJCF schema.""" + name: str + type: str # scalar name, 'enum' or 'ref' + target: Optional[str] # enum or ref target name + arity: Arity + default: Union[None, float, str, tuple[float, ...]] + facets: dict[str, Union[bool, str, float]] + doc: Optional[str] + line: int + + +@dataclasses.dataclass +class Use: + """A `use` directive referencing a group in the MJCF schema.""" + group: str + line: int + + +@dataclasses.dataclass +class Child: + """A child element declaration in the MJCF schema.""" + name: str + card: str + doc: Optional[str] + line: int + + +@dataclasses.dataclass +class Const: + """A constant assignment (`set field = CONST`) in an element declaration.""" + field: str # bound C field + value: str # C constant it takes + doc: Optional[str] + line: int + + +@dataclasses.dataclass +class Constraint: + """A presence constraint over attributes in the MJCF schema.""" + kind: str # exclusive | together | requires | oneof + bundles: list[tuple[str, ...]] # attribute bundles ('+'-joined in source) + doc: Optional[str] + line: int + + +@dataclasses.dataclass +class Group: + """An attribute group declaration in the MJCF schema.""" + name: str + variant: bool + members: list[Union[Attr, Use, Constraint]] + doc: Optional[str] + line: int + + +@dataclasses.dataclass +class Element: + """An XML element declaration in the MJCF schema.""" + name: str + spec: Optional[str] # bound mjs struct name, e.g. 'mjsGeom' + facets: dict[str, Union[bool, str, float]] + members: list[Union[Attr, Use, Child, Const, Constraint]] + doc: Optional[str] + line: int + + def children(self) -> list[Child]: + return [m for m in self.members if isinstance(m, Child)] + + def consts(self) -> list[Const]: + return [m for m in self.members if isinstance(m, Const)] + + def constraints(self) -> list[Constraint]: + return [m for m in self.members if isinstance(m, Constraint)] + + def xml_name(self) -> str: + return str(self.facets.get('xml', self.name)) + + +@dataclasses.dataclass +class Enum: + """An enum declaration in the MJCF schema.""" + name: str + ctype: Optional[str] # bound C enum type, e.g. 'mjtGeom' + items: list[tuple[str, str]] # (xml keyword, C constant or literal) + doc: Optional[str] + line: int + + def keywords(self) -> list[str]: + return [key for key, _ in self.items] + + +@dataclasses.dataclass +class Schema: + """Top-level parsed representation of an MJCF schema.""" + enums: dict[str, Enum] + groups: dict[str, Group] + enums: dict[str, Enum] + groups: dict[str, Group] + elements: dict[str, Element] + path: str + + def expanded_attrs(self, element: Element) -> list[Attr]: + """Element's attributes with `use` groups expanded, in declaration order.""" + out = [] + for member in element.members: + if isinstance(member, Attr): + out.append(member) + elif isinstance(member, Use): + out.extend(self._group_attrs(member.group)) + return out + + def _group_attrs(self, name: str) -> list[Attr]: + out = [] + for member in self.groups[name].members: + if isinstance(member, Attr): + out.append(member) + elif isinstance(member, Use): + out.extend(self._group_attrs(member.group)) + return out + + +#--------------------------------- lexer --------------------------------------- + +# NUMBER must not swallow the first dot of a '..' range: 0..3 lexes as +# NUMBER(0) DOTDOT NUMBER(3) via the (?!\.) lookahead. +_TOKEN_RE = re.compile(r""" + (?P[ \t]+) + | (?P\#[^\n]*) + | (?P\n) + | (?P"[^"\n]*") + | (?P-?(?:\d+(?:\.(?!\.)\d*)?|\.\d+)(?:[eE][+-]?\d+)?) + | (?P\.\.) + | (?P[A-Za-z_][A-Za-z0-9_]*) + | (?P[{}()\[\]<>:=,?!*+]) +""", re.VERBOSE) + + +@dataclasses.dataclass +class _Token: + kind: str # 'string' | 'number' | 'dotdot' | 'ident' | punct char | 'eof' + value: str + line: int + + +def _lex(text: str, path: str) -> tuple[list[_Token], dict[int, str]]: + """Returns tokens and a map of line number -> trailing comment text.""" + tokens = [] + comments = {} + line = 1 + pos = 0 + while pos < len(text): + match = _TOKEN_RE.match(text, pos) + if not match: + raise SchemaError(path, line, f'unexpected character {text[pos]!r}') + kind = match.lastgroup + value = match.group() + if kind == 'newline': + line += 1 + elif kind == 'comment': + comments[line] = value[1:].strip() + elif kind == 'punct': + tokens.append(_Token(value, value, line)) + elif kind != 'ws': + tokens.append(_Token(kind, value, line)) + pos = match.end() + tokens.append(_Token('eof', '', line)) + return tokens, comments + + +#--------------------------------- parser -------------------------------------- + + +class _Parser: + """Recursive-descent parser over the token stream.""" + + def __init__(self, text: str, path: str): + self.path = path + self.tokens, self.comments = _lex(text, path) + self.pos = 0 + + def error(self, message: str, line: Optional[int] = None) -> SchemaError: + return SchemaError(self.path, line or self.peek().line, message) + + def peek(self) -> _Token: + return self.tokens[self.pos] + + def next(self) -> _Token: + token = self.tokens[self.pos] + self.pos += 1 + return token + + def expect(self, kind: str) -> _Token: + token = self.next() + if token.kind != kind: + raise self.error(f'expected {kind!r}, got {token.value!r}', + line=token.line) + return token + + def accept(self, kind: str, value: Optional[str] = None) -> Optional[_Token]: + token = self.peek() + if token.kind == kind and (value is None or token.value == value): + return self.next() + return None + + def doc_for(self, line: int) -> Optional[str]: + return self.comments.get(line) + + def parse(self) -> Schema: + """Parses the token stream into a Schema dataclass.""" + schema = Schema(enums={}, groups={}, elements={}, + path=self.path) + while self.peek().kind != 'eof': + token = self.expect('ident') + if token.value == 'enum': + enum = self.parse_enum(token.line) + self.declare(schema.enums, enum.name, 'enum', token.line) + schema.enums[enum.name] = enum + elif token.value == 'group': + group = self.parse_group(token.line) + self.declare(schema.groups, group.name, 'group', token.line) + schema.groups[group.name] = group + elif token.value == 'element': + element = self.parse_element(token.line) + self.declare(schema.elements, element.name, 'element', token.line) + schema.elements[element.name] = element + else: + raise self.error( + f"expected 'enum', 'group' or 'element', " + f'got {token.value!r}', line=token.line) + return schema + + def declare(self, table: dict[str, Any], name: str, what: str, line: int): + """Ensures declaration names are unique within a table.""" + if name in table: + raise self.error(f'duplicate {what} {name!r} ' + f'(first declared on line {table[name].line})', + line=line) + + def parse_enum(self, line: int) -> Enum: + """Parses an enum declaration.""" + name = self.expect('ident').value + ctype = self.expect('ident').value if self.accept(':') else None + doc = self.doc_for(line) + self.expect('{') + items = [] + seen = {} + while not self.accept('}'): + key_token = self.next() + if key_token.kind == 'string': + key = key_token.value.strip('"') + elif key_token.kind == 'ident': + key = key_token.value + else: + raise self.error(f'expected enum keyword, got {key_token.value!r}', + line=key_token.line) + if key in seen: + raise self.error(f'duplicate enum keyword {key!r}', + line=key_token.line) + seen[key] = key_token.line + self.expect('=') + value_token = self.next() + if value_token.kind not in ('ident', 'number'): + raise self.error(f'expected C constant or number, ' + f'got {value_token.value!r}', line=value_token.line) + items.append((key, value_token.value)) + if not items: + raise self.error(f'enum {name!r} is empty', line=line) + return Enum(name=name, ctype=ctype, items=items, doc=doc, line=line) + + def parse_group(self, line: int) -> Group: + """Parses a group declaration.""" + name = self.expect('ident').value + variant = bool(self.accept('ident', 'variant')) + doc = self.doc_for(line) + self.expect('{') + members = [] + while not self.accept('}'): + members.append(self.parse_member(allow_child=False)) + if not members: + raise self.error(f'group {name!r} is empty', line=line) + return Group(name=name, variant=variant, members=members, doc=doc, + line=line) + + def parse_element(self, line: int) -> Element: + """Parses an element declaration.""" + name = self.expect('ident').value + spec = self.expect('ident').value if self.accept(':') else None + facets = self.parse_facets(ELEMENT_FACETS) if self.accept('(') else {} + doc = self.doc_for(line) + self.expect('{') + members = [] + while not self.accept('}'): + members.append(self.parse_member(allow_child=True)) + return Element(name=name, spec=spec, facets=facets, members=members, + doc=doc, line=line) + + CONSTRAINT_VERBS = frozenset({'exclusive', 'together', 'requires', 'oneof'}) + + def parse_member(self, allow_child: bool) -> Union[Attr, Use, Child, Const, + Constraint]: + """Parses a member of a group or element.""" + token = self.expect('ident') + if token.value == 'use': + return Use(group=self.expect('ident').value, line=token.line) + if token.value in self.CONSTRAINT_VERBS and self.peek().kind == 'ident': + # constraints are single-line constructs + bundles = [] + while self.peek().kind == 'ident' and self.peek().line == token.line: + bundle = [self.expect('ident').value] + while self.accept('+'): + bundle.append(self.expect('ident').value) + bundles.append(tuple(bundle)) + if len(bundles) < 2: + raise self.error(f'{token.value!r} needs at least two attributes', + line=token.line) + return Constraint(kind=token.value, bundles=bundles, + doc=self.doc_for(token.line), line=token.line) + if token.value == 'set': + if not allow_child: + raise self.error("'set' is not allowed in a group", line=token.line) + field = self.expect('ident').value + self.expect('=') + value = self.expect('ident').value + return Const(field=field, value=value, doc=self.doc_for(token.line), + line=token.line) + if token.value == 'child': + if not allow_child: + raise self.error("'child' is not allowed in a group", line=token.line) + name = self.expect('ident').value + card = self.next() + if card.value not in CARDINALITIES: + raise self.error(f'expected cardinality (? ! * R), ' + f'got {card.value!r}', line=card.line) + return Child(name=name, card=card.value, doc=self.doc_for(token.line), + line=token.line) + return self.parse_attr(token) + + def parse_attr(self, name_token: _Token) -> Attr: + """Parses an attribute declaration.""" + line = name_token.line + self.expect(':') + attr_type, target, arity = self.parse_type() + default = self.parse_default() if self.accept('=') else None + facets = self.parse_facets() if self.accept('(') else {} + return Attr(name=name_token.value, type=attr_type, target=target, + arity=arity, default=default, facets=facets, + doc=self.doc_for(line), line=line) + + def parse_type(self) -> tuple[str, Optional[str], Arity]: + """Parses an attribute type declaration.""" + token = self.expect('ident') + if token.value in ('enum', 'flags', 'ref', 'id'): + self.expect('<') + target = self.expect('ident').value + self.expect('>') + return token.value, target, Arity(1, 1) + if token.value not in SCALAR_TYPES: + raise self.error(f'unknown type {token.value!r}', line=token.line) + return token.value, None, self.parse_arity() + + def parse_arity(self) -> Arity: + """Parses attribute arity specification.""" + if not self.accept('['): + return Arity(1, 1) + if self.accept(']'): + return Arity(0, None) # unbounded + lo_token = self.expect('number') + lo = self.parse_int(lo_token) + if not self.accept('dotdot'): + self.expect(']') + return Arity(lo, lo) + hi_token = self.next() + if hi_token.kind == 'number': + hi = self.parse_int(hi_token) + if hi <= lo: + raise self.error(f'arity range [{lo}..{hi}] is not increasing', + line=hi_token.line) + elif hi_token.kind == 'ident': + hi = hi_token.value # symbolic bound, e.g. mjNREF + else: + raise self.error(f'expected arity bound, got {hi_token.value!r}', + line=hi_token.line) + self.expect(']') + return Arity(lo, hi) + + def parse_int(self, token: _Token) -> int: + """Parses an integer value from a token.""" + try: + value = int(token.value) + except ValueError: + raise self.error(f'expected integer, got {token.value!r}', + line=token.line) from None + if value < 0: + raise self.error('arity may not be negative', line=token.line) + return value + + def parse_default(self) -> Union[float, str, tuple[float, ...]]: + """Parses default value for an attribute.""" + token = self.next() + if token.kind == 'number': + return float(token.value) + if token.kind == 'string': + return token.value.strip('"') + if token.kind == 'ident': + return token.value # enum keyword + if token.kind == '{': + values = [float(self.expect('number').value)] + while self.accept(','): + values.append(float(self.expect('number').value)) + self.expect('}') + return tuple(values) + raise self.error(f'expected default value, got {token.value!r}', + line=token.line) + + def parse_facets(self, known=KNOWN_FACETS) -> dict[str, Union[bool, str, + float]]: + """Parses attribute or element facets.""" + facets = {} + while True: + token = self.expect('ident') + if token.value not in known: + raise self.error(f'unknown facet {token.value!r}', line=token.line) + if token.value in facets: + raise self.error(f'duplicate facet {token.value!r}', line=token.line) + if self.accept('='): + value_token = self.next() + if value_token.kind == 'string': + facets[token.value] = value_token.value.strip('"') + elif value_token.kind == 'ident': + facets[token.value] = value_token.value + elif value_token.kind == 'number': + facets[token.value] = float(value_token.value) + else: + raise self.error(f'expected facet value, got {value_token.value!r}', + line=value_token.line) + else: + facets[token.value] = True + if self.accept(')'): + return facets + self.expect(',') + + +#------------------------------- validation ------------------------------------ + + +def _validate(schema: Schema): + """Semantic checks; raises SchemaError on the first violation.""" + path = schema.path + + def err(line: int, message: str): + raise SchemaError(path, line, message) + + # group use graph: dangling targets and cycles + for group in schema.groups.values(): + _check_group_cycle(schema, group.name, [], group.line) + for group in schema.groups.values(): + member_names = {m.name for m in group.members + if isinstance(m, Attr)} + for con in [m for m in group.members if isinstance(m, Constraint)]: + for bundle in con.bundles: + for name in bundle: + if name not in member_names: + err(con.line, f'constraint references unknown attribute {name!r}') + if group.variant: + for member in group.members: + if isinstance(member, Use): + err(member.line, + f"variant group {group.name!r} may not contain 'use'") + elif isinstance(member, Attr) and member.facets.get('required'): + err(member.line, f'attribute {member.name!r} in variant group ' + f'{group.name!r} may not be required') + + containers = list(schema.groups.values()) + list(schema.elements.values()) + for container in containers: + for member in container.members: + if isinstance(member, Use) and member.group not in schema.groups: + err(member.line, f'use of undeclared group {member.group!r}') + + # namespaces exist by virtue of id declarations + namespaces = set() + for container in containers: + for member in container.members: + if isinstance(member, Attr) and member.type == 'id': + namespaces.add(member.target) + + for element in schema.elements.values(): + # element facets + for facet in ('xml', 'alias'): + if facet in element.facets and not isinstance(element.facets[facet], str): + err(element.line, f'element facet {facet!r} requires a name') + alias = element.facets.get('alias') + if alias is not None and alias not in schema.elements: + err(element.line, + f'alias references undeclared element {alias!r}') + + # children: dangling targets and duplicates + seen_children = set() + for child in element.children(): + if child.name not in schema.elements: + err(child.line, f'child references undeclared element {child.name!r}') + if child.name in seen_children: + err(child.line, f'duplicate child {child.name!r}') + seen_children.add(child.name) + + # attributes: duplicates across direct and use-expanded members + seen_attrs = {} + for attr in schema.expanded_attrs(element): + if attr.name in seen_attrs: + err(attr.line if attr.line > seen_attrs[attr.name] else element.line, + f'duplicate attribute {attr.name!r} in element {element.name!r} ' + f'(directly or via use)') + seen_attrs[attr.name] = attr.line + + # constraints reference the element's own (expanded) attributes + for con in element.constraints(): + for bundle in con.bundles: + for name in bundle: + if name not in seen_attrs: + err(con.line, f'constraint references unknown attribute {name!r}') + if con.kind == 'requires' and ( + len(con.bundles) != 2 or any(len(b) != 1 for b in con.bundles)): + err(con.line, "'requires' takes exactly two attributes") + + # per-attribute checks, wherever the attribute is declared + for container in containers: + for attr in container.members: + if isinstance(attr, Attr): + _validate_attr(schema, attr, namespaces) + + +def _check_group_cycle(schema: Schema, name: str, stack: list[str], line: int): + """Recursively checks for cycles in group `use` references.""" + if name in stack: + cycle = ' -> '.join(stack + [name]) + raise SchemaError(schema.path, line, f'group use cycle: {cycle}') + group = schema.groups.get(name) + if group is None: + return # dangling use is reported separately with its own line + for member in group.members: + if isinstance(member, Use): + _check_group_cycle(schema, member.group, stack + [name], member.line) + + +def _validate_attr(schema: Schema, attr: Attr, namespaces: set[str]): + """Validates semantic constraints for a single attribute.""" + path = schema.path + + def err(message: str): + raise SchemaError(path, attr.line, message) + + # target existence + if attr.type in ('enum', 'flags') and attr.target not in schema.enums: + err(f'attribute {attr.name!r} references undeclared enum {attr.target!r}') + if attr.type == 'ref' and attr.target not in namespaces: + err(f'attribute {attr.name!r} references namespace {attr.target!r}, ' + f'which no id<{attr.target}> declares') + + # arity restrictions + if attr.type in ('file', 'bool') and not attr.arity.is_scalar(): + err(f'{attr.type} attribute {attr.name!r} may not be a vector') + if attr.type == 'chars' and not isinstance(attr.arity.hi, int): + err(f'chars attribute {attr.name!r} must declare a bounded length') + + # facet payloads + if 'pattern' in attr.facets and attr.type not in ('string', 'chars'): + err("facet 'pattern' requires a text attribute") + numeric = attr.type in ('double', 'float', 'int') + for facet in ('min', 'max'): + if facet in attr.facets and not ( + numeric and isinstance(attr.facets[facet], (int, float))): + err(f'facet {facet!r} requires a numeric attribute and value') + if 'min' in attr.facets and 'max' in attr.facets: + if attr.facets['min'] > attr.facets['max']: + err("facet 'min' cannot be greater than 'max'") + if attr.facets.get('positive') and not numeric: + err("facet 'positive' requires a numeric attribute") + if attr.facets.get('required') and attr.default is not None: + err(f'attribute {attr.name!r} is required and has a default') + + # defaults + if attr.default is None: + return + if attr.type == 'enum': + if not isinstance(attr.default, str): + err(f'default for enum attribute {attr.name!r} must be a keyword') + keywords = schema.enums[attr.target].keywords() + if attr.default not in keywords: + err(f'default {attr.default!r} is not a keyword of enum ' + f'{attr.target!r}') + return + if attr.type in ('ref', 'id', 'chars'): + err(f'{attr.type} attribute {attr.name!r} may not have a default') + if attr.type == 'bool': + if attr.default not in ('true', 'false'): + err(f'default for bool attribute {attr.name!r} must be true or false') + return + if attr.type in ('string', 'file'): + if not isinstance(attr.default, str): + err(f'default for {attr.type} attribute {attr.name!r} must be a string') + return + # numeric scalars and vectors + if isinstance(attr.default, str): + err(f'default for numeric attribute {attr.name!r} must be numeric') + n = len(attr.default) if isinstance(attr.default, tuple) else 1 + lo, hi = attr.arity.lo, attr.arity.hi + if isinstance(attr.default, tuple) and attr.arity.is_scalar(): + err(f'vector default for scalar attribute {attr.name!r}') + if n < lo: + err(f'default for {attr.name!r} has {n} values, arity requires ' + f'at least {lo}') + if isinstance(hi, int) and n > hi: + err(f'default for {attr.name!r} has {n} values, arity allows ' + f'at most {hi}') + + +#--------------------------------- entry points -------------------------------- + + +def parse_string(text: str, path: str = '') -> Schema: + """Parses an MJCF schema from a string.""" + schema = _Parser(text, path).parse() + _validate(schema) + return schema + + +def parse_file(path: str) -> Schema: + """Parses an MJCF schema from a file path.""" + with open(path, 'r', encoding='utf-8') as file: + return parse_string(file.read(), path) + + +def main() -> int: + """CLI entry point for checking an MJCF schema file.""" + if len(sys.argv) != 2: + sys.exit(f'usage: {sys.argv[0]} ') + try: + schema = parse_file(sys.argv[1]) + except SchemaError as error: + sys.exit(str(error)) + n_attrs = sum(len(schema.expanded_attrs(e)) + for e in schema.elements.values()) + print(f'{sys.argv[1]}: ' + f'{len(schema.elements)} elements, {len(schema.groups)} groups, ' + f'{len(schema.enums)} enums, {n_attrs} attributes') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/xml/mjcf.schema b/src/xml/mjcf.schema new file mode 100644 index 00000000..7fa4b5ff --- /dev/null +++ b/src/xml/mjcf.schema @@ -0,0 +1,2247 @@ +# 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. +# ============================================================================== +# +# MJCF schema -- the single source of truth for the MJCF modeling language +# +# +# --------------------- Syntax reference ----------------------------------------------------------- +# +# enum name : mjtType { key = C_CONST } XML keyword set, with C bindings +# group name { ... } reusable attribute block, spliced with 'use' +# group name variant { ... } ...whose attributes are mutually exclusive +# +# element name : mjsStruct (facets) { element, bound to its mjSpec struct +# use groupname splice a group's attributes here +# attr : type = default (facets) # trailing comment = doc string +# child name card +# } +# +# attribute types +# double, float, int numbers (C type as in mjSpec) +# bool keywords "true" / "false" +# double[4] exactly 4 space-separated values +# double[1..3] 1 to 3 values ([1..mjN*]: symbolic bound) +# double[] any number of values +# string, file text; file resolves against asset dirs/VFS +# chars[3], chars[1..12] fixed char array; arity counts characters +# enum one keyword of the named enum ([]: list) +# flags several keywords, combined bitwise +# id declares this element's name into namespace ns +# ref holds the name of an object in namespace ns +# e.g. body declares name : id +# camera refers with target : ref +# +# attribute facets +# required attribute must be present +# nodefault not settable in classes +# field=name bound C field, when it differs from the attr +# pattern="regex" constrained string (e.g. eulerseq) +# min= / max= / positive numeric range constraints +# reading=custom hand-written read semantics; no typed binding +# is generated, so saving is also hand-written +# writing=custom binding read-driven; hand-written save policy +# +# element facets +# xml=tag XML tag, when it differs from the declaration +# name (same tag, different element by context) +# alias=body tag validated against body's grammar row +# (worldbody, frame, replicate): NameMatch() +# +# child cardinality +# ? optional, at most one ! required, exactly one +# * any number R any number, recursive +# +# presence constraints (single-line, over attributes; a+b is a bundle, +# complete when all its attributes appear) +# exclusive a b+c at most one bundle may be present +# together a b appear together or not at all +# requires a b if a appears, b must appear +# oneof a b+c at least one complete bundle +# +# Declaration order is semantic: it sets attribute order in generated tables +# and in saved XML. + + +#---------------------- enums ---------------------------------------------------------------------- + +enum coordinate { + local = 0 + global = 1 +} + +enum angle { + radian = 0 + degree = 1 +} + +enum fluidshape { + none = 0 + ellipsoid = 1 +} + +enum enable { + disable = 0 + enable = 1 +} + +enum FalseTrueAuto { + false = 0 + true = 1 + auto = 2 +} + +enum FalseAuto { + false = 0 + auto = 1 +} + +enum bodysleep : mjtSleepPolicy { + auto = mjSLEEP_AUTO + never = mjSLEEP_NEVER + allowed = mjSLEEP_ALLOWED + init = mjSLEEP_INIT +} + +enum jointtype : mjtJoint { + free = mjJNT_FREE + ball = mjJNT_BALL + slide = mjJNT_SLIDE + hinge = mjJNT_HINGE +} + +enum geomtype : mjtGeom { + plane = mjGEOM_PLANE + hfield = mjGEOM_HFIELD + sphere = mjGEOM_SPHERE + capsule = mjGEOM_CAPSULE + ellipsoid = mjGEOM_ELLIPSOID + cylinder = mjGEOM_CYLINDER + box = mjGEOM_BOX + mesh = mjGEOM_MESH + sdf = mjGEOM_SDF +} + +enum projection : mjtProjection { + perspective = mjPROJ_PERSPECTIVE + orthographic = mjPROJ_ORTHOGRAPHIC +} + +enum camlight : mjtCamLight { + fixed = mjCAMLIGHT_FIXED + track = mjCAMLIGHT_TRACK + trackcom = mjCAMLIGHT_TRACKCOM + targetbody = mjCAMLIGHT_TARGETBODY + targetbodycom = mjCAMLIGHT_TARGETBODYCOM +} + +enum lighttype : mjtLightType { + spot = mjLIGHT_SPOT + directional = mjLIGHT_DIRECTIONAL + point = mjLIGHT_POINT + image = mjLIGHT_IMAGE +} + +enum texrole : mjtTextureRole { + rgb = mjTEXROLE_RGB + occlusion = mjTEXROLE_OCCLUSION + roughness = mjTEXROLE_ROUGHNESS + metallic = mjTEXROLE_METALLIC + normal = mjTEXROLE_NORMAL + opacity = mjTEXROLE_OPACITY + emissive = mjTEXROLE_EMISSIVE + rgba = mjTEXROLE_RGBA + orm = mjTEXROLE_ORM +} + +enum integrator : mjtIntegrator { + Euler = mjINT_EULER + RK4 = mjINT_RK4 + implicit = mjINT_IMPLICIT + implicitfast = mjINT_IMPLICITFAST +} + +enum cone : mjtCone { + pyramidal = mjCONE_PYRAMIDAL + elliptic = mjCONE_ELLIPTIC +} + +enum jacobian : mjtJacobian { + dense = mjJAC_DENSE + sparse = mjJAC_SPARSE + auto = mjJAC_AUTO +} + +enum solver : mjtSolver { + PGS = mjSOL_PGS + CG = mjSOL_CG + Newton = mjSOL_NEWTON +} + +enum equality : mjtEq { + connect = mjEQ_CONNECT + weld = mjEQ_WELD + joint = mjEQ_JOINT + tendon = mjEQ_TENDON + flex = mjEQ_FLEX + flexvert = mjEQ_FLEXVERT + flexstrain = mjEQ_FLEXSTRAIN + distance = mjEQ_DISTANCE +} + +enum texture : mjtTexture { + "2d" = mjTEXTURE_2D + cube = mjTEXTURE_CUBE + skybox = mjTEXTURE_SKYBOX +} + +enum colorspace : mjtColorSpace { + auto = mjCOLORSPACE_AUTO + linear = mjCOLORSPACE_LINEAR + sRGB = mjCOLORSPACE_SRGB +} + +enum builtin : mjtBuiltin { + none = mjBUILTIN_NONE + gradient = mjBUILTIN_GRADIENT + checker = mjBUILTIN_CHECKER + flat = mjBUILTIN_FLAT +} + +enum mark : mjtMark { + none = mjMARK_NONE + edge = mjMARK_EDGE + cross = mjMARK_CROSS + random = mjMARK_RANDOM +} + +enum dyn : mjtDyn { + none = mjDYN_NONE + integrator = mjDYN_INTEGRATOR + filter = mjDYN_FILTER + filterexact = mjDYN_FILTEREXACT + muscle = mjDYN_MUSCLE + dcmotor = mjDYN_DCMOTOR + pid = mjDYN_PID + user = mjDYN_USER +} + +enum dcmotorinput { + voltage = 0 + position = 1 + velocity = 2 +} + +enum gain : mjtGain { + fixed = mjGAIN_FIXED + affine = mjGAIN_AFFINE + muscle = mjGAIN_MUSCLE + dcmotor = mjGAIN_DCMOTOR + so3 = mjGAIN_SO3 + pid = mjGAIN_PID + user = mjGAIN_USER +} + +enum inputchart : mjtCtrlChart { + expmap = mjCHART_EXPMAP + quat = mjCHART_QUAT +} + +enum inputbit : mjtCtrlInput { # bitflags: keywords combine bitwise + pos = mjINPUT_POS + vel = mjINPUT_VEL + ff = mjINPUT_FF +} + +enum bias : mjtBias { + none = mjBIAS_NONE + affine = mjBIAS_AFFINE + muscle = mjBIAS_MUSCLE + dcmotor = mjBIAS_DCMOTOR + so3 = mjBIAS_SO3 + user = mjBIAS_USER +} + +enum interp { + zoh = 0 + linear = 1 + cubic = 2 +} + +enum stage : mjtStage { + none = mjSTAGE_NONE + pos = mjSTAGE_POS + vel = mjSTAGE_VEL + acc = mjSTAGE_ACC +} + +enum datatype : mjtDataType { + real = mjDATATYPE_REAL + positive = mjDATATYPE_POSITIVE + axis = mjDATATYPE_AXIS + quaternion = mjDATATYPE_QUATERNION +} + +enum frameobj : mjtObj { # object kinds with a spatial frame + body = mjOBJ_BODY + xbody = mjOBJ_XBODY + geom = mjOBJ_GEOM + site = mjOBJ_SITE + camera = mjOBJ_CAMERA +} + +enum condata : mjtConDataField { # bitflags: keywords combine bitwise + found = mjCONDATA_FOUND + force = mjCONDATA_FORCE + torque = mjCONDATA_TORQUE + dist = mjCONDATA_DIST + pos = mjCONDATA_POS + normal = mjCONDATA_NORMAL + tangent = mjCONDATA_TANGENT +} + +enum raydata : mjtRayDataField { # bitflags: keywords combine bitwise + dist = mjRAYDATA_DIST + dir = mjRAYDATA_DIR + origin = mjRAYDATA_ORIGIN + point = mjRAYDATA_POINT + normal = mjRAYDATA_NORMAL + depth = mjRAYDATA_DEPTH +} + +enum camout : mjtCamOutBit { # bitflags: keywords combine bitwise + rgb = mjCAMOUT_RGB + depth = mjCAMOUT_DEPTH + distance = mjCAMOUT_DIST + normal = mjCAMOUT_NORMAL + segmentation = mjCAMOUT_SEG +} + +enum reduce { + none = 0 + mindist = 1 + maxforce = 2 + netforce = 3 +} + +enum conflict : mjtConflict { + warning = mjCONFLICT_WARNING + merge = mjCONFLICT_MERGE + error = mjCONFLICT_ERROR +} + +enum lrmode : mjtLRMode { + none = mjLRMODE_NONE + muscle = mjLRMODE_MUSCLE + muscleuser = mjLRMODE_MUSCLEUSER + all = mjLRMODE_ALL +} + +enum comp { + particle = mjCOMPTYPE_PARTICLE + grid = mjCOMPTYPE_GRID + rope = mjCOMPTYPE_ROPE + loop = mjCOMPTYPE_LOOP + cable = mjCOMPTYPE_CABLE + cloth = mjCOMPTYPE_CLOTH +} + +enum jkind { # vestigial: composite pruning left a single kind + main = mjCOMPKIND_JOINT +} + +enum shape { + s = mjCOMPSHAPE_LINE + "cos(s)" = mjCOMPSHAPE_COS + "sin(s)" = mjCOMPSHAPE_SIN + "0" = mjCOMPSHAPE_ZERO +} + +enum meshinertia : mjtMeshInertia { + convex = mjMESH_INERTIA_CONVEX + legacy = mjMESH_INERTIA_LEGACY + exact = mjMESH_INERTIA_EXACT + shell = mjMESH_INERTIA_SHELL +} + +enum meshbuiltin : mjtMeshBuiltin { + none = mjMESH_BUILTIN_NONE + sphere = mjMESH_BUILTIN_SPHERE + hemisphere = mjMESH_BUILTIN_HEMISPHERE + cone = mjMESH_BUILTIN_CONE + supertorus = mjMESH_BUILTIN_SUPERTORUS + supersphere = mjMESH_BUILTIN_SUPERSPHERE + wedge = mjMESH_BUILTIN_WEDGE + plate = mjMESH_BUILTIN_PLATE +} + +enum fcomp { + grid = mjFCOMPTYPE_GRID + box = mjFCOMPTYPE_BOX + cylinder = mjFCOMPTYPE_CYLINDER + ellipsoid = mjFCOMPTYPE_ELLIPSOID + square = mjFCOMPTYPE_SQUARE + disc = mjFCOMPTYPE_DISC + circle = mjFCOMPTYPE_CIRCLE + mesh = mjFCOMPTYPE_MESH + gmsh = mjFCOMPTYPE_GMSH + direct = mjFCOMPTYPE_DIRECT +} + +enum fdof { + full = mjFCOMPDOF_FULL + radial = mjFCOMPDOF_RADIAL + trilinear = mjFCOMPDOF_TRILINEAR + quadratic = mjFCOMPDOF_QUADRATIC + "2d" = mjFCOMPDOF_2D +} + +enum flexself : mjtFlexSelf { + none = mjFLEXSELF_NONE + narrow = mjFLEXSELF_NARROW + bvh = mjFLEXSELF_BVH + sap = mjFLEXSELF_SAP + auto = mjFLEXSELF_AUTO +} + +enum elastic2d { + none = 0 + bend = 1 + stretch = 2 + both = 3 +} + +enum flexeq { + false = 0 + true = 1 + vert = 2 + strain = 3 +} + + +#---------------------- groups --------------------------------------------------------------------- + +group orientation variant { + quat : double[4] = {1, 0, 0, 0} + axisangle : double[4] + xyaxes : double[6] + zaxis : double[3] + euler : double[3] +} + +group actuator_base { + name : id + class : ref + group : int + nsample : int + interp : enum + delay : double + ctrlrange : double[2] + user : double[] (field=userdata) +} + +group actuator_dynamics { + lengthrange : double[2] (nodefault) + gear : double[1..6] = {1, 0, 0, 0, 0, 0} + damping : double[1..3] # damper polynomial, 1+mjNPOLY coefficients + armature : double + cranklength : double (reading=custom) # slidercrank-only, validated +} + +group transmission { + joint : ref (nodefault) + jointinparent : string (nodefault) + tendon : ref (nodefault) + slidersite : ref (nodefault) + cranksite : ref (nodefault) + site : ref (nodefault) + refsite : ref (nodefault) +} + +group sensor_base { + name : id + nsample : int + interp : enum + delay : double + interval : double[1..2] + cutoff : double + noise : double + user : double[] (field=userdata) +} + +group frame_object { # the object whose frame is measured + objtype : enum (required) + objname : string (required) +} + +group frame_reference { # frame of reference; world if absent + reftype : enum + refname : string + together reftype refname +} + +group equality_base { + name : id + class : ref + active : bool = true + solref : double[1..mjNREF] = {0.02, 1} + solimp : double[1..mjNIMP] = {0.9, 0.95, 0.001, 0.5, 2} +} + + +#---------------------- mujoco --------------------------------------------------------------------- + +element mujoco { + model : ref + child compiler * + child option * + child size * + child statistic * + child visual * + child default R + child extension * + child asset * + child body R + child deformable * + child contact * + child tendon * + child equality * + child actuator * + child sensor * + child custom * + child keyframe * +} + + +#---------------------- compiler ------------------------------------------------------------------- + +element compiler : mjsCompiler { + autolimits : bool + boundmass : double + boundinertia : double + settotalmass : double + balanceinertia : bool + strippath : bool (reading=custom) # stored on the spec + coordinate : enum (reading=custom) # deprecation error + angle : enum (field=degree) + fitaabb : bool + eulerseq : chars[3] (pattern="[xyzXYZ]{3}") + meshdir : string + texturedir : string + discardvisual : bool + usethread : bool + fusestatic : bool + inertiafromgeom : enum + inertiagrouprange : int[2] + saveinertial : bool + assetdir : string (reading=custom) # fans out to mesh/texturedir + alignfree : bool + conflict : enum + child lengthrange ? +} + +element lengthrange : mjLROpt { + mode : enum + useexisting : bool + uselimit : bool + accel : double + maxforce : double + timeconst : double + timestep : double + inttotal : double + interval : double + tolrange : double +} + + +#---------------------- option --------------------------------------------------------------------- + +element option : mjOption { + timestep : double = 0.002 + impratio : double = 1 + tolerance : double = 1e-08 + ls_tolerance : double = 0.01 + noslip_tolerance : double = 1e-06 + ccd_tolerance : double = 1e-06 + sleep_tolerance : double = 0.001 + gravity : double[3] = {0, 0, -9.81} + wind : double[3] = {0, 0, 0} + magnetic : double[3] = {0, -0.5, 0} + density : double + viscosity : double + o_margin : double + o_solref : double[1..mjNREF] + o_solimp : double[1..mjNIMP] + o_friction : double[1..5] = {1, 1, 0.005, 0.0001, 0.0001} + integrator : enum = Euler + cone : enum = pyramidal + jacobian : enum = auto + solver : enum = Newton + iterations : int = 100 + ls_iterations : int = 50 + noslip_iterations : int + ccd_iterations : int = 35 + sdf_iterations : int = 10 + sdf_initpoints : int = 40 + actuatorgroupdisable : int[] (reading=custom) # bits of disableactuator + child flag ? +} + +element flag { + # disable family: a bit in mjOption.disableflags, default enable (bit unset) + constraint : enum = enable + equality : enum = enable + frictionloss : enum = enable + limit : enum = enable + contact : enum = enable + spring : enum = enable + damper : enum = enable + gravity : enum = enable + clampctrl : enum = enable + warmstart : enum = enable + filterparent : enum = enable + actuation : enum = enable + refsafe : enum = enable + sensor : enum = enable + midphase : enum = enable + eulerdamp : enum = enable + autoreset : enum = enable + nativeccd : enum = enable + island : enum = enable + multiccd : enum = enable + # enable family: a bit in mjOption.enableflags, default disable (bit unset) + override : enum = disable + energy : enum = disable + fwdinv : enum = disable + invdiscrete : enum = disable + sleep : enum = disable + diagexact : enum = disable +} + + +#---------------------- size ----------------------------------------------------------------------- + +element size : mjSpec { + memory : string (reading=custom) # suffixed byte count + njmax : int (reading=custom) # range/exclusivity checks + nconmax : int (reading=custom) # range check + nstack : int (reading=custom) # range/exclusivity checks + nuserdata : int (min=-1) + nkey : int (min=-1) + nuser_body : int (min=-1) + nuser_jnt : int (min=-1) + nuser_geom : int (min=-1) + nuser_site : int (min=-1) + nuser_cam : int (min=-1) + nuser_tendon : int (min=-1) + nuser_actuator : int (min=-1) + nuser_sensor : int (min=-1) + exclusive memory nstack + exclusive memory njmax +} + + +#---------------------- statistic ------------------------------------------------------------------ + +element statistic : mjStatistic { + meaninertia : double + meanmass : double + meansize : double + extent : double (positive) # when defined + center : double[3] +} + + +#---------------------- visual --------------------------------------------------------------------- + +element visual { + child global ? + child quality ? + child headlight ? + child map ? + child scale ? + child rgba ? +} + +element global : mjVisual (field=global) { + cameraid : int = -1 + orthographic : bool = false + fovy : float = 45 + ipd : float = 0.068 + azimuth : float = 90 + elevation : float = -45 + linewidth : float = 1 + glow : float = 0.3 + offwidth : int = 640 + offheight : int = 480 + realtime : float = 1 (positive) + ellipsoidinertia : bool = false + bvactive : bool = true +} + +element quality : mjVisual (field=quality) { + shadowsize : int = 4096 + offsamples : int = 4 + numslices : int = 28 + numstacks : int = 16 + numquads : int = 4 +} + +element headlight : mjVisual (field=headlight) { + ambient : float[3] = {0.1, 0.1, 0.1} + diffuse : float[3] = {0.4, 0.4, 0.4} + specular : float[3] = {0.5, 0.5, 0.5} + active : int = 1 +} + +element map : mjVisual (field=map) { + stiffness : float = 100 + stiffnessrot : float = 500 + force : float = 0.005 + torque : float = 0.1 + alpha : float = 0.3 + fogstart : float = 3 + fogend : float = 10 + znear : float = 0.01 (positive) + zfar : float = 50 + haze : float = 0.3 + shadowclip : float = 1 + shadowscale : float = 0.6 + actuatortendon : float = 2 +} + +element scale : mjVisual (field=scale) { + forcewidth : float = 0.1 + contactwidth : float = 0.3 + contactheight : float = 0.1 + connect : float = 0.2 + com : float = 0.4 + camera : float = 0.3 + light : float = 0.3 + selectpoint : float = 0.2 + jointlength : float = 1 + jointwidth : float = 0.1 + actuatorlength : float = 0.7 + actuatorwidth : float = 0.2 + framelength : float = 1 + framewidth : float = 0.1 + constraint : float = 0.1 + slidercrank : float = 0.2 + frustum : float = 10 +} + +element rgba : mjVisual (field=rgba) { + fog : float[4] + haze : float[4] + force : float[4] + inertia : float[4] + joint : float[4] + actuator : float[4] + actuatornegative : float[4] + actuatorpositive : float[4] + com : float[4] + camera : float[4] + light : float[4] + selectpoint : float[4] + connect : float[4] + contactpoint : float[4] + contactforce : float[4] + contactfriction : float[4] + contacttorque : float[4] + contactgap : float[4] + rangefinder : float[4] + constraint : float[4] + slidercrank : float[4] + crankbroken : float[4] + frustum : float[4] + bv : float[4] + bvactive : float[4] +} + + +#---------------------- default -------------------------------------------------------------------- + +element default { + class : id + child default R # recursive + # children below are projections of the primary decls: + # reduced rows = attrs minus name/class minus (nodefault) + child mesh ? + child material ? + child joint ? + child geom ? + child site ? + child camera ? + child light ? + child pair ? + child default_equality ? + child default_tendon ? + child general ? + child motor ? + child position ? + child velocity ? + child intvelocity ? + child orientation ? + child pid ? + child damper ? + child cylinder ? + child muscle ? + child adhesion ? + child dcmotor ? +} + +element layer { + texture : ref + role : string (required) +} + + +#---------------------- extension ------------------------------------------------------------------ + +element extension { + child extension_plugin * +} + +element extension_plugin (xml=plugin) { + plugin : string + child instance * +} + +element instance { + name : id (required) + child config * +} + +element config { + key : string (required) + value : string +} + + +#---------------------- asset ---------------------------------------------------------------------- + +element asset { + child mesh * + child hfield * + child skin * + child texture * + child material * + child model * +} + +element mesh : mjsMesh { + name : id + class : ref + content_type : string (nodefault) + file : file (nodefault) + vertex : float[] (nodefault, field=uservert) + normal : float[] (nodefault, field=usernormal) + texcoord : float[] (nodefault, field=usertexcoord) + face : int[] (nodefault, field=userface) + refpos : double[3] (nodefault) + refquat : double[4] = {1, 0, 0, 0} (nodefault) + scale : double[3] = {1, 1, 1} + smoothnormal : bool (nodefault) + maxhullvert : int = -1 (reading=custom) + inertia : enum = legacy + builtin : enum (nodefault, reading=custom) + params : double[] (nodefault, reading=custom) + material : ref (nodefault) + exclusive builtin file + exclusive builtin vertex + child plugin * +} + +element plugin : mjsPlugin { + plugin : string + instance : ref + child config * +} + +element hfield : mjsHField { + name : id + content_type : string + file : file + nrow : int + ncol : int + size : double[4] (required) + elevation : double[] (reading=custom) # flipped and zero-filled +} + +element skin : mjsSkin { + name : id + file : file + material : ref + rgba : float[4] = {0.5, 0.5, 0.5, 1} + inflate : float + vertex : float[] (field=vert) + texcoord : float[] + face : int[] + group : int (min=0, max=5, reading=custom) + child bone * +} + +element bone { + body : ref (required) + bindpos : double[3] + bindquat : double[4] + vertid : double[] + vertweight : double[] +} + +element texture : mjsTexture { + name : id + type : enum = cube + colorspace : enum = auto + content_type : string + file : file + gridsize : int[2] = {1, 1} + gridlayout : chars[1..12] # length must equal the gridsize product + fileright : file + fileleft : file + fileup : file + filedown : file + filefront : file + fileback : file + builtin : enum + rgb1 : double[3] = {0.8, 0.8, 0.8} + rgb2 : double[3] = {0.5, 0.5, 0.5} + mark : enum + markrgb : double[3] + random : double = 0.01 + width : int + height : int + hflip : bool + vflip : bool + nchannel : int = 3 +} + +element material : mjsMaterial { + name : id + class : ref + texture : ref (reading=custom) + texrepeat : float[2] = {1, 1} + texuniform : bool + emission : float + specular : float = 0.5 + shininess : float = 0.5 + reflectance : float + metallic : float = -1 + roughness : float = -1 + rgba : float[4] = {1, 1, 1, 1} + child layer * +} + +# read via mj_parse of the sub-model file: hand-written +element model { + name : id + file : file + content_type : string +} + + +#---------------------- body ----------------------------------------------------------------------- + +element body : mjsBody { + name : id + childclass : ref + pos : double[3] = {0, 0, 0} + use orientation + mocap : bool + gravcomp : double + sleep : enum + simple : enum = auto + user : double[] (field=userdata) + child body R # recursive + child inertial ? + child joint * + child freejoint * + child geom * + child attach * + child site * + child camera * + child light * + child plugin * + child composite * + child flexcomp * +} + +element inertial : mjsBody { # projects into the parent body's i-frame + pos : double[3] (required, field=ipos) + quat : double[4] (reading=custom, field=iquat) + mass : double (required) + diaginertia : double[3] (field=inertia) + axisangle : double[4] (reading=custom) + xyaxes : double[6] (reading=custom) + zaxis : double[3] (reading=custom) + euler : double[3] (reading=custom) + fullinertia : double[6] (reading=custom) + exclusive fullinertia quat axisangle xyaxes zaxis euler +} + +element joint : mjsJoint { + name : id + class : ref + type : enum = hinge + group : int + pos : double[3] (writing=custom) # saved unless free + axis : double[3] = {0, 0, 1} (writing=custom) # saved for slide/hinge + springdamper : double[2] (writing=custom) # compile directive: saved as stiffness/damping + limited : enum (writing=custom) # saved unless free + actuatorfrclimited : enum (field=actfrclimited, writing=custom) # saved for slide/hinge + solreflimit : double[1..mjNREF] (field=solref_limit) + solimplimit : double[1..mjNIMP] (field=solimp_limit) + solreffriction : double[1..mjNREF] (field=solref_friction) + solimpfriction : double[1..mjNIMP] (field=solimp_friction) + stiffness : double[1..3] # spring polynomial, 1+mjNPOLY coefficients + range : double[2] + actuatorfrcrange : double[2] (field=actfrcrange) + actuatorgravcomp : bool (field=actgravcomp) + margin : double + ref : double + springref : double + armature : double + damping : double[1..3] # damper polynomial, 1+mjNPOLY coefficients + frictionloss : double + user : double[] (field=userdata) +} + +element freejoint { + name : id + group : int + align : enum +} + +element geom : mjsGeom { + name : id + class : ref + type : enum = sphere + contype : int = 1 + conaffinity : int = 1 + condim : int = 3 + group : int + priority : int + size : double[1..3] (writing=custom) # saved length is type-dependent + material : ref + friction : double[1..3] = {1, 0.005, 0.0001} + mass : double (writing=custom) # mass/density: one is saved + density : double = 1000 (writing=custom) + shellinertia : bool (field=typeinertia, writing=custom) # saved unless mesh + solmix : double = 1 + solref : double[1..mjNREF] = {0.02, 1} + solimp : double[1..mjNIMP] = {0.9, 0.95, 0.001, 0.5, 2} + margin : double + gap : double + surfacevel : double[1..6] + adhesion : double + fromto : double[6] (writing=custom) # compile directive: saved as pos/quat/size + pos : double[3] = {0, 0, 0} (writing=custom) # saved in the mesh-corrected frame + use orientation + hfield : ref (field=hfieldname) + mesh : ref (field=meshname) + fitscale : double = 1 (writing=custom) # compile directive: not saved + rgba : float[4] = {0.5, 0.5, 0.5, 1} + fluidshape : enum (field=fluid_ellipsoid, reading=custom) + fluidcoef : double[1..5] (field=fluid_coefs) + user : double[] (field=userdata) + child plugin * +} + +element attach { + model : ref + body : ref + frame : ref + prefix : string (required) + exclusive body frame +} + +element site : mjsSite { + name : id + class : ref + type : enum = sphere + group : int + pos : double[3] = {0, 0, 0} + use orientation + material : ref + size : double[1..3] = {0.005, 0.005, 0.005} (writing=custom) # saved length is type-dependent + fromto : double[6] (writing=custom) # compile directive: saved as pos/quat/size + rgba : float[4] = {0.5, 0.5, 0.5, 1} + user : double[] (field=userdata) +} + +element camera : mjsCamera { + name : id + class : ref + projection : enum (field=proj) + fovy : double = 45 (writing=custom) # fovy or the intrinsics family is saved + ipd : double = 0.068 + resolution : int[2] = {1, 1} + output : flags + pos : double[3] = {0, 0, 0} + use orientation + mode : enum = fixed + target : ref (nodefault, field=targetbody) + focal : float[2] (field=focal_length, writing=custom) + focalpixel : float[2] (field=focal_pixel, writing=custom) + principal : float[2] (field=principal_length, writing=custom) + principalpixel : float[2] (field=principal_pixel, writing=custom) + sensorsize : float[2] (field=sensor_size, writing=custom) + user : double[] (field=userdata) + exclusive fovy sensorsize +} + +element light : mjsLight { + name : id + class : ref + directional : bool (reading=custom) + type : enum (reading=custom) + castshadow : bool = true + active : bool = true + pos : double[3] = {0, 0, 0} + dir : double[3] = {0, 0, -1} + bulbradius : float = 0.02 + intensity : float + range : float = 10 + attenuation : float[3] = {1, 0, 0} + cutoff : float = 45 + exponent : float = 10 + ambient : float[3] + diffuse : float[3] = {0.7, 0.7, 0.7} + specular : float[3] = {0.3, 0.3, 0.3} + mode : enum = fixed + target : ref (nodefault, field=targetbody) + texture : ref (nodefault) + exclusive directional type +} + +element composite { + prefix : string + type : enum (required) + count : double[1..3] + offset : double[3] + vertex : double[] + initial : string + curve : string + size : double[1..3] + quat : double[4] + child composite_joint * + child composite_skin ? + child composite_geom ? + child composite_site ? + child plugin * +} + +element composite_joint (xml=joint) { + kind : enum (required) + group : int + stiffness : double + damping : double + armature : double + solreffix : double[1..mjNREF] + solimpfix : double[1..mjNIMP] + type : enum (required) + axis : double[3] + limited : enum + range : double[2] + margin : double + solreflimit : double[1..mjNREF] + solimplimit : double[1..mjNIMP] + frictionloss : double + solreffriction : double[1..mjNREF] + solimpfriction : double[1..mjNIMP] +} + +element composite_skin (xml=skin) { + texcoord : bool + material : ref + group : int + rgba : double[4] + inflate : double + subgrid : int +} + +element composite_geom (xml=geom) { + type : enum (required) + contype : int + conaffinity : int + condim : int + group : int + priority : int + size : double[1..3] + material : ref + rgba : double[4] + friction : double[1..3] + mass : double + density : double + solmix : double + solref : double[1..mjNREF] + solimp : double[1..mjNIMP] + margin : double + gap : double + surfacevel : double[1..6] + adhesion : double +} + +element composite_site (xml=site) { + group : int + size : double[1..3] + material : ref + rgba : double[4] +} + +element flexcomp { + name : id (required) + type : enum + group : int + dim : int + dof : enum + count : double[3] + cellcount : double[3] + spacing : double[3] + radius : double + rigid : bool + mass : double + inertiabox : double + scale : double[3] + file : file + point : double[] + element : double[] + texcoord : double[] + material : ref + rgba : double[4] + flatskin : bool + pos : double[3] + quat : double[4] + axisangle : double[4] + xyaxes : double[6] + zaxis : double[3] + euler : double[3] + origin : double[3] + child flexcomp_edge ? + child elasticity ? + child flexcomp_contact ? + child pin * + child plugin * +} + +element flexcomp_edge (xml=edge) { + equality : enum + solref : double[1..mjNREF] + solimp : double[1..mjNIMP] + stiffness : double + damping : double +} + +element elasticity : mjsFlex { + young : double + poisson : double + damping : double + thickness : double + elastic2d : enum +} + +element flexcomp_contact : mjsFlex (xml=contact) { + contype : int + conaffinity : int + condim : int + priority : int + friction : double[1..3] + solmix : double + solref : double[1..mjNREF] + solimp : double[1..mjNIMP] + margin : double + gap : double + internal : bool + selfcollide : enum + activelayers : int + passive : bool +} + +element pin { + id : double[] + range : double[] + grid : double[] + gridrange : double[] +} + + +#---------------------- deformable ----------------------------------------------------------------- + +element deformable { + child flex * + child skin * +} + +element flex : mjsFlex { + name : id + group : int + dim : int + radius : double + material : ref + rgba : float[4] + flatskin : bool + body : string (required, field=vertbody) # space-separated body names + vertex : double[] (field=vert) + element : int[] (required, field=elem) + texcoord : float[] + elemtexcoord : int[] + node : string (field=nodebody) # space-separated body names + cellcount : int[3] (reading=custom) # seeded to {1,1,1} before reading + dof : enum (reading=custom) # lowers to interpolation order + child flexcomp_contact ? + child flex_edge ? + child elasticity ? +} + +element flex_edge : mjsFlex (xml=edge) { + stiffness : double (field=edgestiffness) + damping : double (field=edgedamping) +} + + +#---------------------- contact -------------------------------------------------------------------- + +element contact { + child pair * + child exclude * +} + +element pair : mjsPair { + name : id + class : ref + geom1 : ref (nodefault, field=geomname1) + geom2 : ref (nodefault, field=geomname2) + condim : int = 3 + friction : double[1..5] = {1, 1, 0.005, 0.0001, 0.0001} + solref : double[1..mjNREF] = {0.02, 1} + solreffriction : double[1..mjNREF] + solimp : double[1..mjNIMP] = {0.9, 0.95, 0.001, 0.5, 2} + gap : double + margin : double + adhesion : double +} + +element exclude : mjsExclude { + name : id + body1 : ref (required, field=bodyname1) + body2 : ref (required, field=bodyname2) +} + + +#---------------------- tendon --------------------------------------------------------------------- + +element tendon { + child spatial * + child fixed * +} + +element spatial : mjsTendon { + name : id + class : ref + group : int + limited : enum + actuatorfrclimited : enum (field=actfrclimited) + range : double[2] + actuatorfrcrange : double[2] (field=actfrcrange) + solreflimit : double[1..mjNREF] (field=solref_limit) + solimplimit : double[1..mjNIMP] (field=solimp_limit) + solreffriction : double[1..mjNREF] (field=solref_friction) + solimpfriction : double[1..mjNIMP] (field=solimp_friction) + frictionloss : double + springlength : double[1..2] = {-1, -1} (reading=custom) # one value: copied to both + width : double = 0.003 + material : ref + margin : double + stiffness : double[1..3] # spring polynomial, 1+mjNPOLY coefficients + damping : double[1..3] # damper polynomial, 1+mjNPOLY coefficients + armature : double + rgba : float[4] = {0.5, 0.5, 0.5, 1} + user : double[] (field=userdata) + child spatial_site * + child spatial_geom * + child pulley * +} + +# read via the mjs_wrapSite constructor: attributes are arguments +element spatial_site (xml=site) { + site : ref (required) +} + +# read via the mjs_wrapGeom constructor: attributes are arguments +element spatial_geom (xml=geom) { + geom : ref (required) + sidesite : ref +} + +# read via the mjs_wrapPulley constructor: attributes are arguments +element pulley { + divisor : double +} + +element fixed : mjsTendon { + name : id + class : ref + group : int + limited : enum + actuatorfrclimited : enum (field=actfrclimited) + range : double[2] + actuatorfrcrange : double[2] (field=actfrcrange) + solreflimit : double[1..mjNREF] (field=solref_limit) + solimplimit : double[1..mjNIMP] (field=solimp_limit) + solreffriction : double[1..mjNREF] (field=solref_friction) + solimpfriction : double[1..mjNIMP] (field=solimp_friction) + frictionloss : double + springlength : double[1..2] = {-1, -1} (reading=custom) # one value: copied to both + margin : double + stiffness : double[1..3] # spring polynomial, 1+mjNPOLY coefficients + damping : double[1..3] # damper polynomial, 1+mjNPOLY coefficients + armature : double + user : double[] (field=userdata) + child fixed_joint * +} + +# read via the mjs_wrapJoint constructor: attributes are arguments +element fixed_joint (xml=joint) { + joint : ref (required) + coef : double +} + + +#---------------------- equality ------------------------------------------------------------------- + +element equality : mjsEquality { + child connect * + child weld * + child equality_joint * + child equality_tendon * + child equality_flex * + child flexvert * + child flexstrain * +} + +element connect : mjsEquality { + use equality_base + exclusive site1+site2 body1+body2+anchor # site and body semantics cannot mix + oneof site1+site2 body1+anchor + together site1 site2 + body1 : ref + body2 : ref + anchor : double[3] + site1 : ref + site2 : ref +} + +element weld : mjsEquality { + use equality_base + exclusive site1+site2 body1+body2+anchor+relpose + oneof site1+site2 body1 + together site1 site2 + body1 : ref + body2 : ref + relpose : double[7] + anchor : double[3] + site1 : ref + site2 : ref + torquescale : double +} + +element equality_joint : mjsEquality (xml=joint) { + use equality_base + joint1 : ref (required) + joint2 : ref + polycoef : double[1..5] +} + +element equality_tendon : mjsEquality (xml=tendon) { + use equality_base + tendon1 : ref (required) + tendon2 : ref + polycoef : double[1..5] +} + +element equality_flex : mjsEquality (xml=flex) { + use equality_base + flex : ref (required) +} + +element flexvert : mjsEquality { + use equality_base + flex : ref (required) +} + +element flexstrain : mjsEquality { + use equality_base + flex : ref (required) + cell : double[3] +} + + +#---------------------- actuator ------------------------------------------------------------------- + +element actuator : mjsActuator { + child general * + child motor * + child position * + child velocity * + child intvelocity * + child orientation * + child pid * + child damper * + child cylinder * + child muscle * + child adhesion * + child dcmotor * + child actuator_plugin * +} + +element general : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + actlimited : enum + forcerange : double[2] + actrange : double[2] + use actuator_dynamics + use transmission + body : ref (nodefault, reading=custom) # transmission target + actdim : int = -1 (writing=custom) # saved default depends on dyntype + input : enum (reading=custom) # so3 chart keyword, or servo token subset + velrange : double[2] + ffrange : double[2] + dyntype : enum = none + gaintype : enum = fixed (writing=custom) # gain/bias family is not + biastype : enum = none (writing=custom) # saved for plugin actuators + dynprm : double[1..mjNDYN] = {1} + gainprm : double[1..mjNGAIN] = {1} (writing=custom) + biasprm : double[1..mjNBIAS] (writing=custom) + actearly : bool +} + +element motor : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + forcerange : double[2] + use actuator_dynamics + use transmission +} + +element position : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + inheritrange : double + forcerange : double[2] + use actuator_dynamics + use transmission + kp : double + kv : double + dampratio : double + timeconst : double +} + +element velocity : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + forcerange : double[2] + use actuator_dynamics + use transmission + kv : double +} + +element intvelocity : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + actlimited : enum + forcerange : double[2] + actrange : double[2] + inheritrange : double + use actuator_dynamics + use transmission + kp : double + kv : double + dampratio : double +} + +element orientation : mjsActuator { + use actuator_base + forcelimited : enum + forcerange : double[2] + joint : ref (nodefault) + site : ref (nodefault) + refsite : ref (nodefault) + kp : double + kv : double + dampratio : double + input : enum +} + +element pid : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + posrange : double[2] (field=ctrlrange) # alias: the position-setpoint range + velrange : double[2] + ffrange : double[2] + forcerange : double[2] + inheritrange : double + use actuator_dynamics + use transmission + kp : double + kv : double + dampratio : double + ki : double + imax : double + slewmax : double + input : flags +} + +element damper : mjsActuator { + use actuator_base + forcelimited : enum + forcerange : double[2] + use actuator_dynamics + use transmission + kv : double +} + +element cylinder : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + forcerange : double[2] + use actuator_dynamics + use transmission + timeconst : double + area : double + diameter : double + bias : double[3] +} + +element muscle : mjsActuator { + use actuator_base + ctrllimited : enum + forcelimited : enum + forcerange : double[2] + use actuator_dynamics + joint : ref (nodefault) + jointinparent : string (nodefault) + tendon : ref (nodefault) + slidersite : ref (nodefault) + cranksite : ref (nodefault) + timeconst : double + tausmooth : double (nodefault) + range : double[2] + force : double + scale : double + lmin : double + lmax : double + vmax : double + fpmax : double + fvmax : double +} + +element adhesion : mjsActuator { + use actuator_base + forcelimited : enum + forcerange : double[2] + body : ref (nodefault) + gain : double +} + +element dcmotor : mjsActuator { + use actuator_base + ctrllimited : enum + use actuator_dynamics + use transmission + motorconst : double[1..2] + resistance : double + nominal : double[1..3] + saturation : double[1..3] + inductance : double[1..2] + cogging : double[1..3] + controller : double[1..6] + thermal : double[1..6] + lugre : double[1..5] + input : enum +} + +element actuator_plugin : mjsActuator (xml=plugin) { + use actuator_base + plugin : string + instance : ref + ctrllimited : enum + forcelimited : enum + actlimited : enum + forcerange : double[2] + actrange : double[2] + use actuator_dynamics + joint : ref + jointinparent : string + site : ref + actdim : int = -1 + dyntype : enum = none + dynprm : double[1..mjNDYN] = {1} + tendon : ref + cranksite : ref + slidersite : ref + actearly : bool + child config * +} + + +#---------------------- sensor --------------------------------------------------------------------- + +element sensor : mjsSensor { + child touch * + child accelerometer * + child velocimeter * + child gyro * + child force * + child torque * + child magnetometer * + child camprojection * + child rangefinder * + child jointpos * + child jointvel * + child tendonpos * + child tendonvel * + child actuatorpos * + child actuatorvel * + child actuatorfrc * + child jointactuatorfrc * + child tendonactuatorfrc * + child ballquat * + child ballangvel * + child jointlimitpos * + child jointlimitvel * + child jointlimitfrc * + child tendonlimitpos * + child tendonlimitvel * + child tendonlimitfrc * + child framepos * + child framequat * + child framexaxis * + child frameyaxis * + child framezaxis * + child framelinvel * + child frameangvel * + child framelinacc * + child frameangacc * + child subtreecom * + child subtreelinvel * + child subtreeangmom * + child insidesite * + child distance * + child normal * + child fromto * + child sensor_contact * + child e_potential * + child e_kinetic * + child clock * + child tactile * + child user * + child sensor_plugin * +} + +element touch : mjsSensor { + set type = mjSENS_TOUCH + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element accelerometer : mjsSensor { + set type = mjSENS_ACCELEROMETER + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element velocimeter : mjsSensor { + set type = mjSENS_VELOCIMETER + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element gyro : mjsSensor { + set type = mjSENS_GYRO + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element force : mjsSensor { + set type = mjSENS_FORCE + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element torque : mjsSensor { + set type = mjSENS_TORQUE + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element magnetometer : mjsSensor { + set type = mjSENS_MAGNETOMETER + set objtype = mjOBJ_SITE + use sensor_base + site : ref (required, field=objname) +} + +element camprojection : mjsSensor { + set type = mjSENS_CAMPROJECTION + set objtype = mjOBJ_SITE + set reftype = mjOBJ_CAMERA + use sensor_base + site : ref (required, field=objname) + camera : ref (required, field=refname) +} + +element rangefinder : mjsSensor { + use sensor_base + site : ref + camera : ref + data : flags (reading=custom) # ordering-checked + exclusive site camera + oneof site camera +} + +element jointpos : mjsSensor { + set type = mjSENS_JOINTPOS + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element jointvel : mjsSensor { + set type = mjSENS_JOINTVEL + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element tendonpos : mjsSensor { + set type = mjSENS_TENDONPOS + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element tendonvel : mjsSensor { + set type = mjSENS_TENDONVEL + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element actuatorpos : mjsSensor { + set type = mjSENS_ACTUATORPOS + set objtype = mjOBJ_ACTUATOR + use sensor_base + actuator : ref (required, field=objname) +} + +element actuatorvel : mjsSensor { + set type = mjSENS_ACTUATORVEL + set objtype = mjOBJ_ACTUATOR + use sensor_base + actuator : ref (required, field=objname) +} + +element actuatorfrc : mjsSensor { + set type = mjSENS_ACTUATORFRC + set objtype = mjOBJ_ACTUATOR + use sensor_base + actuator : ref (required, field=objname) +} + +element jointactuatorfrc : mjsSensor { + set type = mjSENS_JOINTACTFRC + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element tendonactuatorfrc : mjsSensor { + set type = mjSENS_TENDONACTFRC + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element ballquat : mjsSensor { + set type = mjSENS_BALLQUAT + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element ballangvel : mjsSensor { + set type = mjSENS_BALLANGVEL + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element jointlimitpos : mjsSensor { + set type = mjSENS_JOINTLIMITPOS + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element jointlimitvel : mjsSensor { + set type = mjSENS_JOINTLIMITVEL + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element jointlimitfrc : mjsSensor { + set type = mjSENS_JOINTLIMITFRC + set objtype = mjOBJ_JOINT + use sensor_base + joint : ref (required, field=objname) +} + +element tendonlimitpos : mjsSensor { + set type = mjSENS_TENDONLIMITPOS + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element tendonlimitvel : mjsSensor { + set type = mjSENS_TENDONLIMITVEL + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element tendonlimitfrc : mjsSensor { + set type = mjSENS_TENDONLIMITFRC + set objtype = mjOBJ_TENDON + use sensor_base + tendon : ref (required, field=objname) +} + +element framepos : mjsSensor { + set type = mjSENS_FRAMEPOS + use sensor_base + use frame_object + use frame_reference +} + +element framequat : mjsSensor { + set type = mjSENS_FRAMEQUAT + use sensor_base + use frame_object + use frame_reference +} + +element framexaxis : mjsSensor { + set type = mjSENS_FRAMEXAXIS + use sensor_base + use frame_object + use frame_reference +} + +element frameyaxis : mjsSensor { + set type = mjSENS_FRAMEYAXIS + use sensor_base + use frame_object + use frame_reference +} + +element framezaxis : mjsSensor { + set type = mjSENS_FRAMEZAXIS + use sensor_base + use frame_object + use frame_reference +} + +element framelinvel : mjsSensor { + set type = mjSENS_FRAMELINVEL + use sensor_base + use frame_object + use frame_reference +} + +element frameangvel : mjsSensor { + set type = mjSENS_FRAMEANGVEL + use sensor_base + use frame_object + use frame_reference +} + +element framelinacc : mjsSensor { + set type = mjSENS_FRAMELINACC + use sensor_base + use frame_object +} + +element frameangacc : mjsSensor { + set type = mjSENS_FRAMEANGACC + use sensor_base + use frame_object +} + +element subtreecom : mjsSensor { + set type = mjSENS_SUBTREECOM + set objtype = mjOBJ_BODY + use sensor_base + body : ref (required, field=objname) +} + +element subtreelinvel : mjsSensor { + set type = mjSENS_SUBTREELINVEL + set objtype = mjOBJ_BODY + use sensor_base + body : ref (required, field=objname) +} + +element subtreeangmom : mjsSensor { + set type = mjSENS_SUBTREEANGMOM + set objtype = mjOBJ_BODY + use sensor_base + body : ref (required, field=objname) +} + +element insidesite : mjsSensor { + set type = mjSENS_INSIDESITE + set reftype = mjOBJ_SITE + use sensor_base + site : ref (required, field=refname) + objtype : enum (required) + objname : string (required) +} + +element distance : mjsSensor { + use sensor_base + exclusive geom1 body1 + oneof geom1 body1 + exclusive geom2 body2 + oneof geom2 body2 + geom1 : ref + geom2 : ref + body1 : ref + body2 : ref +} + +element normal : mjsSensor { + use sensor_base + exclusive geom1 body1 + oneof geom1 body1 + exclusive geom2 body2 + oneof geom2 body2 + geom1 : ref + geom2 : ref + body1 : ref + body2 : ref +} + +element fromto : mjsSensor { + use sensor_base + exclusive geom1 body1 + oneof geom1 body1 + exclusive geom2 body2 + oneof geom2 body2 + geom1 : ref + geom2 : ref + body1 : ref + body2 : ref +} + +element sensor_contact : mjsSensor (xml=contact) { + use sensor_base + geom1 : ref + geom2 : ref + body1 : ref + body2 : ref + subtree1 : ref + subtree2 : ref + site : ref + num : int + data : flags (reading=custom) + reduce : enum + exclusive geom1 body1 subtree1 site + exclusive geom2 body2 subtree2 +} + +element e_potential : mjsSensor { + set type = mjSENS_E_POTENTIAL + set objtype = mjOBJ_UNKNOWN + use sensor_base +} + +element e_kinetic : mjsSensor { + set type = mjSENS_E_KINETIC + set objtype = mjOBJ_UNKNOWN + use sensor_base +} + +element clock : mjsSensor { + set type = mjSENS_CLOCK + set objtype = mjOBJ_UNKNOWN + use sensor_base +} + +element tactile : mjsSensor { + name : id + geom : ref (required) + mesh : ref (required) + nsample : int + interp : enum + delay : double + interval : double[1..2] + user : double[] +} + +element user : mjsSensor { + name : id + objtype : string + objname : string + together objtype objname + datatype : enum = real + needstage : enum = acc + dim : int + cutoff : double + noise : double + user : double[] +} + +element sensor_plugin : mjsSensor (xml=plugin) { + name : id + plugin : string + instance : ref + cutoff : double + objtype : string (required) + objname : string (required) + reftype : string + refname : string (required) + user : double[] + child config * +} + + +#---------------------- custom --------------------------------------------------------------------- + +element custom { + child numeric * + child text * + child tuple * +} + +element numeric : mjsNumeric { + name : id (required) + size : int + data : string (required) +} + +element text : mjsText { + name : id (required) + data : string (required) +} + +element tuple : mjsTuple { + name : id (required) + child element * +} + +element element { + objtype : string (required) + objname : string (required) + prm : double +} + + +#---------------------- keyframe ------------------------------------------------------------------- + +element keyframe { + child key * +} + +element key : mjsKey { + name : id (reading=custom) # set even when absent + time : double + qpos : double[] + qvel : double[] + act : double[] + mpos : double[] + mquat : double[] + ctrl : double[] +} + + +#---------------------- worldbody, frame, replicate (body aliases) --------------------------------- + +element worldbody (alias=body) { + child body * + child frame * + child replicate * + # body children are admitted through the alias (all except inertial) +} + +element frame : mjsFrame (alias=body) { + name : id + childclass : ref + pos : double[3] + use orientation + # body children are admitted through the alias +} + +# read into the replicate expansion: hand-written +element replicate (alias=body) { + count : int (required) + offset : double[3] + euler : double[3] + sep : string + childclass : ref + # body children are admitted through the alias +} + + +#---------------------- default-context elements --------------------------------------------------- + +element default_equality (xml=equality) { + active : bool + solref : double[1..mjNREF] + solimp : double[1..mjNIMP] +} + +element default_tendon (xml=tendon) { + group : int + limited : enum + range : double[2] + solreflimit : double[1..mjNREF] + solimplimit : double[1..mjNIMP] + solreffriction : double[1..mjNREF] + solimpfriction : double[1..mjNIMP] + frictionloss : double + springlength : double[1..2] + width : double + material : ref + margin : double + stiffness : double + damping : double + rgba : double[4] + user : double[] +} diff --git a/test/doc/CMakeLists.txt b/test/doc/CMakeLists.txt index be3d7ed4..d4a626a8 100644 --- a/test/doc/CMakeLists.txt +++ b/test/doc/CMakeLists.txt @@ -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() diff --git a/test/doc/mjcf_schema_test.py b/test/doc/mjcf_schema_test.py new file mode 100644 index 00000000..e23553f8 --- /dev/null +++ b/test/doc/mjcf_schema_test.py @@ -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 # name of this class +} + +element geom : mjsGeom { # geometric entity + use posed + name : id # element name + class : ref (field=classname) # defaults class + type : enum = 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(':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\n}') + self.assertIn("undeclared enum 'nosuch'", message) + + def test_dangling_ref(self): + message = self.error('element geom {\n mesh : ref\n}') + self.assertIn("namespace 'nosuch'", message) + + def test_ref_resolved_by_id_elsewhere(self): + mjcf_schema.parse_string( + 'element mesh {\n name : id\n}\n' + 'element geom {\n mesh : ref\n}') + + def test_id_with_default(self): + message = self.error('element geom {\n name : id = "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 = 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\n site2 : ref\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\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\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\n}\n' + 'element geom {\n r : ref = 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='') + 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()