Merging for Composition: Proposed behavior
- Authors:
Eric Cousineau
<eric.cousineau@tri.global>
, Addisu Taddese<addisu@openrobotics.org>
Steve Peters<scpeters@openrobotics.org>
- Status: Accepted
- SDFormat Version: 1.9
libsdformat
Version: 12.0
All sections affected by amendments are explicitly denoted as being added or modified.
These are added as amendments given that the current proposal has not yet been migrated to the specification documentation.
Amendment 1: World merge-include (//world/include/@merge
)
- Status: Draft
- SDFormat Version: 1.10
libsdformat
Version: TBD
Introduction
This proposal suggests a new behavior for the //model/include
tag that copies
and merges the contents of a model file into the current model without adding
additional nested model hierarchy or naming scope. The new merging behavior is
used when the //include/@merge
attribute is true
.
In SDFormat 1.8 and earlier, composing an SDFormat model file from content
stored in separate files using //model/include
requires each included model
to be fully encapsulated and creates a nested model hierarchy that mirrors
the file structure used to store the models.
This approach is guaranteed to avoid name collisions but constrains the
model structure to match the file structure and limits the flexibility of model
composition.
The proposed behavior decouples the model structures available via composition
from the file structure used to store the underlying model components.
This is useful both for creating new models and for decomposing existing models
into separate components without visible changes to downstream consumers,
while maintaining the encapsulation provided by SDFormat 1.8.
The cost of this feature is that users must take care to avoid name collisions
between the entities of the models to be merged: consider an analogy to Python
module imports. A normal //model/include
is similar to a import my_model
.
If the //model/include/name
element is specified, then it is is similar to
import my_model as renamed_model
. With the proposed behavior, a merge include
is effectively the same as from my_model import *
.
Document summary
This proposal includes the following sections:
- Motivation: background and rationale.
- Proposed changes: The addition to the SDFormat specification and the
libsdformat
implementation. - Examples: Models and workflows using this features.
Motivation
The //world/include
tag was first introduced in SDFormat 1.4 to support
insertion of models into a world. This was the first way to compose an SDFormat
document using content from separate SDFormat files.
The //model/model
tag was added in SDFormat 1.5 to allow a hierarchical
nesting of models and was accompanied by the //model/include
tag to allow
nested models to be composed using content from separate SDFormat model files,
though the behavior was not entirely consistent
(see documentation).
The behavior of nested models specified using //model/model
and
//model/include
was made consistent through improvements to the specification
in SDFormat 1.8
(see composition proposal).
The SDFormat 1.8 specification allows a parent model to reference frames or
entities in nested child models but not the reverse.
This asymmetry enforces hierarchical encapsulation of models and ensures that
each model is fully defined by its own contents and those of its nested models.
Separate name scopes are defined for each model in the hierarchy to avoid name
collisions.
While useful for avoiding name collisions, hierarchical namespacing causes
entity names to change if an existing model file is refactored into components
in separate files that are reconstituted using //model/include
tags. Those
name changes may break any downstream consumers of those model files that
depend on the existing naming scheme.
For example, if multiple model files use combinations of repeated arm, flange,
and gripper components, storing the repeated components in separate files
and incorporating them with an //include
tag would reduce duplication of
model content and allow the components to be used in composition or isolation.
Refactoring an existing model in this way could change the names of relevant
interface elements, most significantly links, joints, and frames. A frame
previously called composite_arm::gripper_mount
may become something like
composite_arm::flange::gripper_mount
if it was included in a nested model
file named flange
.
With the proposed feature, however, the user could choose to preserve the
entity names of interface elements that may be referenced
by downstream models, such as composite_arm::gripper_mount
from above.
Proposed changes
//model/include/@merge
This adds a new attribute, @merge
, to //model/include
tags that when set to
true
changes the include behavior to insert the contents (links, joints,
frames, plugins, etc.) of the included model file
directly into the parent model without introducing a new scope into the
model hierarchy. Some model elements are not merged: //model/static
,
//model/self_collide
, //model/enable_wind
, and //model/allow_auto_disable
.
To posture the included model contents via the //model/include/pose
tag,
a frame is added as a proxy for the implicit __model__
frame of the included model.
The proxy frame is attached to the canonical link of the model
to be merged and assigned the pose specified in //model/include/pose
.
If no //model/@placement_frame
or //include/placement_frame
is specified,
the raw pose may simply be copied, but in general the model to be merged should
be loaded into an sdf::Root
object so that graphs are constructed and
the model pose can be resolved (see code in parser.cc).
For the entities to be merged, any explicit references to the
implicit __model__
frame are replaced with references to the proxy frame.
Additionally, the name of the proxy frame is inserted anywhere there is an
implicit reference to the included model's __model__
frame, such as a link
with an empty //pose/@relative_to
attribute or a frame with an empty
@attached_to
attribute.
The name of the included model's proxy frame is an implementation detail and
is not guaranteed to be stable.
As of libsdformat 12.4.0,
the name of the included model's proxy frame follows the pattern
_merged__<model_name>__model__
(where <model_name>
is the name of the
included model), avoiding a double underscore at the start of the name to
respect the reserved name rules.
In order to reference the model frame of a merge-included model, it is
recommended to add an explicitly named //frame
to the model that is attached
to the included model's __model__
frame or to use
//include/experimental:params
to inject such a frame directly (see
documentation).
//world/include/@merge
Amendment: This section has been added as part of Amendment 1.
Merge-include in <world>
would allow models that themselves contain nested
models to be merged into the world such that the nested models are placed
directly in the <world>
without the additional name scope of the parent
model. The mechanism for merging works the same way as for models except that
links and grippers cannot be merged into the world since //world/link
and
//world/gripper
are not valid SDFormat elements. Note: as of libsdformat
13.x, //world/joint
is
included in the spec, thus //model/joint
is allowed. //model/frame
is also
allowed and gets converted to //world/frame
. The parser should emit
errors if it encounters forbidden elements while trying to merge-include models
into the world.
Examples
Small example:
Given the parent model:
<sdf version="1.9">
<model name="robot">
<include merge="true">
<uri>test_model</uri>
<pose>100 0 0 0 0 0</pose>
</include>
</model>
</sdf>
and the included model:
<sdf version="1.9">
<model name="test_model">
<link name="L1" />
<frame name="F1" />
</model>
</sdf>
The resulting merged model is shown below.
A proxy frame is added with the pose
value of 100 0 0 0 0 0
from //model/include/pose
and attached to L1
,
which is the canonical link of test_model
.
<sdf version='1.9'>
<model name='robot'>
<frame name='_merged__test_model__model__' attached_to='L1'>
<pose relative_to='__model__'>100 0 0 0 0 0</pose>
</frame>
<link name='L1'>
<pose relative_to='_merged__test_model__model__'/>
</link>
<frame name='F1' attached_to='_merged__test_model__model__'/>
</model>
</sdf>
Example of //world/include/@merge
Amendment: This example has been added as part of Amendment 1.
Given a world SDFormat file:
<sdf version="1.10">
<world name="example_world">
<include merge="true">
<uri>multiple_robots</uri>
<pose>100 0 0 0 0 0</pose>
</include>
</world>
</sdf>
and the included model:
<sdf version="1.10">
<model name="multiple_robots">
<include>
<uri>robot</uri> <!-- `robot` is a model form the previous example -->
<name>robot1</name>
</include>
<include>
<uri>robot</uri> <!-- `robot` is a model form the previous example -->
<pose>0 10 0 0 0 0</pose>
<name>robot2</name>
</include>
</model>
</sdf>
The resulting merged world is shown below.
A proxy frame is added with the pose
value of 100 0 0 0 0 0
from //world/include/pose
and attached to robot1::L1
,
which is the canonical link of multiple_robots
.
<sdf version='1.10'>
<world name="example_world">
<frame name='_merged__multiple_robots__model__' attached_to='robot1::L1'>
<pose relative_to='world'>100 0 0 0 0 0</pose>
</frame>
<model name='robot1'>
<pose relative_to='_merged__multiple_robots__model__'/>
<link name='L1'/>
<frame name='F1'/>
</model>
<model name='robot2'>
<pose relative_to='_merged__multiple_robots__model__'>0 10 0 0 0 0</pose>
<link name='L1'/>
<frame name='F1'/>
</model>
</world>
</sdf>
Decomposing an existing model into separate model files
The following is an abridged version of a Clearpath Husky skid-steer with sensors mounted on a pan-tilt gimbal used in the DARPA Subterranean Challenge (MARBLE HUSKY SENSOR CONFIG 3):
<sdf version="1.7">
<model name="marble_husky_sensor_config_3">
<link name="base_link"/>
<link name="front_left_wheel_link">
<pose>0.256 0.2854 0.03282 0 0 0</pose>
</link>
<joint name="front_left_wheel_joint" type="revolute">
<child>front_left_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="front_right_wheel_link">
<pose>0.256 -0.2854 0.03282 0 0 0</pose>
</link>
<joint name="front_right_wheel_joint" type="revolute">
<child>front_right_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="rear_left_wheel_link">
<pose>-0.256 0.2854 0.03282 0 0 0</pose>
</link>
<joint name="rear_left_wheel_joint" type="revolute">
<child>rear_left_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="rear_right_wheel_link">
<pose>-0.256 -0.2854 0.03282 0 0 0</pose>
</link>
<joint name="rear_right_wheel_joint" type="revolute">
<child>rear_right_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="pan_gimbal_link">
<pose>0.424 0 0.427 0 0 0</pose>
</link>
<link name="tilt_gimbal_link">
<pose>0.424 0 0.460 0 0 0</pose>
<!-- Based on Intel realsense D435 (intrinsics and distortion not modeled)-->
<sensor name="camera_pan_tilt" type="rgbd_camera">
<!-- ... -->
</sensor>
<light name="flashlight_flashlight_light_source_lamp_light" type="spot">
<!-- ... -->
</light>
</link>
<joint name="pan_gimbal_joint" type="revolute">
<child>pan_gimbal_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in="__model__">0 0 1</xyz>
</axis>
</joint>
<joint name="tilt_gimbal_joint" type="revolute">
<child>tilt_gimbal_link</child>
<parent>pan_gimbal_link</parent>
<axis>
<xyz expressed_in="__model__">0 1 0</xyz>
<limit>
<lower>-1.5708</lower> <!-- 90 degrees both direction (mechanical interference)-->
<upper>1.5708</upper>
<effort>10</effort>
</limit>
</axis>
</joint>
<!-- Gimbal Joints Plugins -->
<plugin
filename="libgz-gazebo-joint-controller-system.so"
name="gz::sim::systems::JointController">
<joint_name>pan_gimbal_joint</joint_name>
<use_force_commands>true</use_force_commands>
<p_gain>0.4</p_gain>
<i_gain>10</i_gain>
</plugin>
<plugin
filename="libgz-gazebo-joint-controller-system.so"
name="gz::sim::systems::JointController">
<joint_name>tilt_gimbal_joint</joint_name>
<use_force_commands>true</use_force_commands>
<p_gain>0.4</p_gain>
<i_gain>10</i_gain>
</plugin>
<plugin
filename="libgz-gazebo-joint-state-publisher-system.so"
name="gz::sim::systems::JointStatePublisher">
<joint_name>pan_gimbal_joint</joint_name>
<joint_name>tilt_gimbal_joint</joint_name>
</plugin>
</model>
</sdf>
This model fle can be decomposed into marble_husky_base.sdf
containing the
chassis and wheels:
<sdf version="1.7">
<model name="marble_husky_base">
<link name="base_link"/>
<link name="front_left_wheel_link">
<pose>0.256 0.2854 0.03282 0 0 0</pose>
</link>
<joint name="front_left_wheel_joint" type="revolute">
<child>front_left_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="front_right_wheel_link">
<pose>0.256 -0.2854 0.03282 0 0 0</pose>
</link>
<joint name="front_right_wheel_joint" type="revolute">
<child>front_right_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="rear_left_wheel_link">
<pose>-0.256 0.2854 0.03282 0 0 0</pose>
</link>
<joint name="rear_left_wheel_joint" type="revolute">
<child>rear_left_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
<link name="rear_right_wheel_link">
<pose>-0.256 -0.2854 0.03282 0 0 0</pose>
</link>
<joint name="rear_right_wheel_joint" type="revolute">
<child>rear_right_wheel_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in='__model__'>0 1 0</xyz>
</axis>
</joint>
</model>
</sdf>
and pan_tilt_sensors_3.sdf
containing an additional explicit frame named
pan_tilt_sensors_3_model
that is attached to the __model__
frame by default
along with the gimbal with sensors
(but excluding pan_gimbal_joint
since it references base_link
and would
violate encapsulation to include it in either file):
<sdf version="1.7">
<model name="pan_tilt_sensors_3">
<frame name="pan_tilt_sensors_3_model"/>
<link name="pan_gimbal_link"/>
<link name="tilt_gimbal_link">
<!-- Based on Intel realsense D435 (intrinsics and distortion not modeled)-->
<sensor name="camera_pan_tilt" type="rgbd_camera">
<!-- ... -->
</sensor>
<light name="flashlight_flashlight_light_source_lamp_light" type="spot">
<!-- ... -->
</light>
</link>
<joint name="tilt_gimbal_joint" type="revolute">
<child>tilt_gimbal_link</child>
<parent>pan_gimbal_link</parent>
<axis>
<xyz expressed_in="__model__">0 1 0</xyz>
<limit>
<lower>-1.5708</lower> <!-- 90 degrees both direction (mechanical interference)-->
<upper>1.5708</upper>
<effort>10</effort>
</limit>
</axis>
</joint>
<!-- Gimbal Joints Plugins -->
<plugin
filename="libgz-gazebo-joint-controller-system.so"
name="gz::sim::systems::JointController">
<joint_name>pan_gimbal_joint</joint_name>
<use_force_commands>true</use_force_commands>
<p_gain>0.4</p_gain>
<i_gain>10</i_gain>
</plugin>
<plugin
filename="libgz-gazebo-joint-controller-system.so"
name="gz::sim::systems::JointController">
<joint_name>tilt_gimbal_joint</joint_name>
<use_force_commands>true</use_force_commands>
<p_gain>0.4</p_gain>
<i_gain>10</i_gain>
</plugin>
<plugin
filename="libgz-gazebo-joint-state-publisher-system.so"
name="gz::sim::systems::JointStatePublisher">
<joint_name>pan_gimbal_joint</joint_name>
<joint_name>tilt_gimbal_joint</joint_name>
</plugin>
</model>
</sdf>
The split files can then be recomposed as follows by merge-including both
marble_husky_base.sdf
and pan_tilt_sensors_3.sdf
alongside the
pan_gimbal_joint
, which uses the explictly named frame
pan_tilt_sensors_3_model
to express the //joint/axis/xyz
value.
<sdf version="1.9">
<model name="marble_husky_sensor_config_3_recomposed">
<include merge="true">
<uri>marble_husky_base.sdf</uri>
</include>
<include merge="true">
<uri>pan_tilt_sensors_3.sdf</uri>
<pose>0.424 0 0.427 0 0 0</pose>
</include>
<joint name="pan_gimbal_joint" type="revolute">
<child>pan_gimbal_link</child>
<parent>base_link</parent>
<axis>
<xyz expressed_in="pan_tilt_sensors_3_model">0 0 1</xyz>
</axis>
</joint>
</model>
</sdf>
Appendix
(Unused)