Generate the MJCF grammar table and enforce its constraints.
The hand-written MJCF[] table in xml_native_reader.cc is replaced by
mjcf_table.inc, emitted from mjcf.schema by generate_mjcf_table.py and
checked for freshness by doc_test. nMJCF is now self-sizing. The two
tables are identical as trees of (tag, cardinality, attribute-set);
within-row attribute order changes where the schema factors shared
groups and projects default-context rows, and top-level rows follow
the schema's dependency order -- neither affects validation, which is
set-based, nor XMLschema.rst, whose generator orders sections itself
(regenerated here, reading the .inc instead of the reader source).
The schema's constraint declarations become enforcement: the emitter
writes a companion MJCF_constraints[] array (row-indexed into MJCF[]),
and mjXSchema::Check evaluates each element's constraints after its
attribute check, with uniform messages derived from the declaration:
"at most one of 'fovy', 'sensorsize' can be specified", "attributes
'reftype', 'refname' must be specified together", and so on.
Multi-attribute bundles render as ('site1', 'site2').
Fifteen hand-written co-occurrence checks across fourteen elements are
deleted -- connect/weld semantics mixing and completeness, the actuator
transmission mutex, camera fovy/sensorsize, light directional/type,
inertial fullinertia-versus-orientation, rangefinder and the distance
family, contact's matching criteria, user-sensor pairing, the frame
family's reftype/refname, size memory exclusivities, mesh builtin
exclusions, and attach body/frame (newly declared). Tests assert the
uniform messages.
Two findings along the way: sensorsize-requires-resolution is a
value-level compiler rule (positive resolution), not a presence rule --
a presence constraint would be wrong and is not declared; and Size()'s
nstack/njmax range checks tested the spec value before assignment, so
they never validated the parsed value -- now they do.
Verified by compiling all 81 models in the model/ corpus.
PiperOrigin-RevId: 958064622
Change-Id: I802cf5c0aee08a62926e36a281320ff9e34c0668
This commit is contained in:
committed by
Copybara-Service
parent
3f8db4c17a
commit
790f8fac30
+645
-647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
# ==============================================================================
|
||||
"""Generates the MJCF[] grammar table from src/xml/mjcf.schema.
|
||||
|
||||
The table (src/xml/mjcf_table.inc) is the element tree consumed by the
|
||||
mjXSchema validator: rows of {name, cardinality, attributes...} with
|
||||
{"<"}/{">"} nesting markers. It is checked in and gated by
|
||||
test/doc/doc_test.py, which regenerates it from the schema and diffs.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import os
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _SCRIPT_DIR)
|
||||
import mjcf_schema
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||
SCHEMA_PATH = os.path.join(_REPO_ROOT, 'src', 'xml', 'mjcf.schema')
|
||||
|
||||
_HEADER = '''\
|
||||
// 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.
|
||||
|
||||
// GENERATED FILE, DO NOT EDIT. Generated from src/xml/mjcf.schema by
|
||||
// doc/generate/generate_mjcf_table.py; test/doc/doc_test.py checks freshness.
|
||||
//
|
||||
// The MJCF grammar table consumed by mjXSchema: rows of {name, cardinality,
|
||||
// attributes...} with {"<"}/{">"} nesting markers. worldbody, frame and
|
||||
// replicate have no rows: mjXSchema::NameMatch validates them against the
|
||||
// body row (see the alias= facets in mjcf.schema).
|
||||
|
||||
// clang-format off
|
||||
'''
|
||||
|
||||
_WIDTH = 100
|
||||
|
||||
|
||||
def _wrap_row(parts: list[str], indent: int) -> list[str]:
|
||||
"""One {...}, initializer, wrapped at _WIDTH with hanging indent."""
|
||||
lines = []
|
||||
line = ' ' * indent + '{' + parts[0]
|
||||
for part in parts[1:]:
|
||||
candidate = f'{line}, {part}'
|
||||
if len(candidate) + 2 > _WIDTH: # room for "},"
|
||||
lines.append(line + ',')
|
||||
line = ' ' * (indent + 4) + part
|
||||
else:
|
||||
line = candidate
|
||||
lines.append(line + '},')
|
||||
return lines
|
||||
|
||||
|
||||
KIND_CHAR = {'exclusive': 'e', 'together': 't', 'requires': 'r',
|
||||
'oneof': 'o'}
|
||||
|
||||
|
||||
def _element_constraints(schema, element):
|
||||
"""Element's own constraints plus those of transitively used groups."""
|
||||
cons = list(element.constraints())
|
||||
visited = set()
|
||||
stack = [m.group for m in element.members
|
||||
if isinstance(m, mjcf_schema.Use)]
|
||||
while stack:
|
||||
name = stack.pop()
|
||||
if name in visited:
|
||||
continue
|
||||
visited.add(name)
|
||||
group = schema.groups[name]
|
||||
for member in group.members:
|
||||
if isinstance(member, mjcf_schema.Constraint):
|
||||
cons.append(member)
|
||||
elif isinstance(member, mjcf_schema.Use):
|
||||
stack.append(member.group)
|
||||
return cons
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
"""Generates the C++ grammar table header file contents from mjcf.schema."""
|
||||
schema = mjcf_schema.parse_file(SCHEMA_PATH)
|
||||
out = []
|
||||
constraints = []
|
||||
count = 0
|
||||
|
||||
def emit_entry(lines: list[str]):
|
||||
nonlocal count
|
||||
out.extend(lines)
|
||||
count += 1
|
||||
|
||||
def visit(element: mjcf_schema.Element, card: str, indent: int,
|
||||
project: bool):
|
||||
# a row in default context is the element's defaultable projection
|
||||
attrs = [a for a in schema.expanded_attrs(element)]
|
||||
if project:
|
||||
attrs = [a for a in attrs
|
||||
if a.name not in ('name', 'class')
|
||||
and not a.facets.get('nodefault')]
|
||||
parts = [f'"{element.xml_name()}"', f'"{card}"']
|
||||
parts += [f'"{a.name}"' for a in attrs]
|
||||
row_index = count
|
||||
emit_entry(_wrap_row(parts, indent))
|
||||
|
||||
# presence constraints whose attributes all survive in this row
|
||||
row_attrs = {a.name for a in attrs}
|
||||
for con in _element_constraints(schema, element):
|
||||
if all(all(n in row_attrs for n in b) for b in con.bundles):
|
||||
spec = '|'.join(' '.join(b) for b in con.bundles)
|
||||
constraints.append(f" {{{row_index}, '{KIND_CHAR[con.kind]}', "
|
||||
f'"{spec}"}},')
|
||||
|
||||
children = [c for c in element.children() if c.name != element.name]
|
||||
if project:
|
||||
# plugin configuration is not settable per-class
|
||||
children = [c for c in children if c.name != 'plugin']
|
||||
if not children:
|
||||
return
|
||||
emit_entry([' ' * indent + '{"<"},'])
|
||||
for child in children:
|
||||
decl = schema.elements[child.name]
|
||||
child_project = project or (
|
||||
element.name == 'default' and not child.name.startswith('default_'))
|
||||
visit(decl, child.card, indent + 4, child_project)
|
||||
if indent == 0:
|
||||
out.append('')
|
||||
emit_entry([' ' * indent + '{">"},'])
|
||||
|
||||
visit(schema.elements['mujoco'], '!', 0, False)
|
||||
|
||||
body = '\n'.join(out)
|
||||
con_body = '\n'.join(constraints)
|
||||
return (_HEADER +
|
||||
'std::vector<const char*> MJCF[] = {\n' + body + '\n};\n'
|
||||
'// clang-format on\n\n'
|
||||
'const int nMJCF = sizeof(MJCF) / sizeof(MJCF[0]);\n\n'
|
||||
'// presence constraints, indexed into MJCF[]; enforced by\n'
|
||||
'// mjXSchema::Check. spec: attribute bundles, space-joined,\n'
|
||||
"// '|'-separated; kind: e=exclusive t=together r=requires o=oneof\n"
|
||||
'// clang-format off\n'
|
||||
'const mjXConstraintDef MJCF_constraints[] = {\n' + con_body +
|
||||
'\n};\n'
|
||||
'// clang-format on\n\n'
|
||||
'const int nMJCF_constraints = '
|
||||
'sizeof(MJCF_constraints) / sizeof(MJCF_constraints[0]);\n')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 2:
|
||||
sys.exit('usage: generate_mjcf_table.py [output.inc]')
|
||||
text = generate()
|
||||
if len(sys.argv) == 2:
|
||||
with open(sys.argv[1], 'w', encoding='utf-8') as file:
|
||||
file.write(text)
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -113,18 +113,18 @@ def generate_dropdown(
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
"""Generates XMLschema.rst by parsing xml_native_reader.cc.
|
||||
"""Generates XMLschema.rst by parsing mjcf_table.inc.
|
||||
|
||||
The schema is defined in xml_native_reader.cc as a nested structure called
|
||||
MJCF[nMJCF]. This function parses that structure and generates nested
|
||||
dropdown directives with list-tables for attributes.
|
||||
The schema is defined in mjcf_table.inc (generated from mjcf.schema) as a
|
||||
nested structure called MJCF[]. This function parses that structure and
|
||||
generates nested dropdown directives with list-tables for attributes.
|
||||
|
||||
Returns:
|
||||
RST content with nested dropdown directives for the MJCF schema.
|
||||
"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
repo_root = os.path.dirname(os.path.dirname(script_dir))
|
||||
filepath = os.path.join(repo_root, 'src', 'xml', 'xml_native_reader.cc')
|
||||
filepath = os.path.join(repo_root, 'src', 'xml', 'mjcf_table.inc')
|
||||
xmlfile = os.path.join(repo_root, 'doc', 'XMLreference.rst')
|
||||
|
||||
# Collect all link targets from XMLreference.rst for validation.
|
||||
@@ -159,7 +159,7 @@ def generate() -> str:
|
||||
|
||||
# Skip to the MJCF schema definition in the C++ source.
|
||||
for line in file:
|
||||
if 'std::vector<const char*> MJCF[nMJCF] = {' in line.strip():
|
||||
if 'std::vector<const char*> MJCF[] = {' in line.strip():
|
||||
break
|
||||
|
||||
# Parse the schema structure.
|
||||
@@ -224,7 +224,8 @@ def generate() -> str:
|
||||
parent[level + 1] = element[0]
|
||||
element = []
|
||||
|
||||
return output
|
||||
# single newline at end of file
|
||||
return output.rstrip('\n') + '\n'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
Reference in New Issue
Block a user