Skip to content

Commit 6b3776f

Browse files
julianadrianheineFrancisco Rossi
andauthored
Add xacro support to bazel_ros2_rules (#422)
Add ros_xacro rule with support for xacro args, xacro composition and includes. --------- Co-authored-by: Francisco Rossi <frossi@ekumenlabs.com>
1 parent 5391c6d commit 6b3776f

12 files changed

Lines changed: 265 additions & 20 deletions

File tree

bazel_ros2_rules/lib/extensions.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def _local_ros2_implementation(module_ctx):
3939
"rosidl_default_generators",
4040
"service_msgs",
4141
"unique_identifier_msgs",
42+
"xacro",
4243
]
4344

4445
underlay = find_local_ros2_distribution(module_ctx)

bazel_ros2_rules/lib/private/common.bzl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ def interfaces_filegroup(name, share_directory):
3535
], allow_empty = True),
3636
)
3737

38+
def _generate_file_impl(ctx):
39+
out = ctx.actions.declare_file(ctx.label.name)
40+
ctx.actions.write(out, ctx.attr.content, ctx.attr.is_executable)
41+
return [DefaultInfo(
42+
files = depset([out]),
43+
data_runfiles = ctx.runfiles(files = [out]),
44+
)]
45+
46+
generate_file = rule(
47+
attrs = {
48+
"content": attr.string(mandatory = True),
49+
"is_executable": attr.bool(default = False),
50+
},
51+
output_to_genfiles = True,
52+
implementation = _generate_file_impl,
53+
)
54+
"""Writes a string to a file at build time."""
55+
3856
def incorporate_rmw_implementation(kwargs, env_changes, rmw_implementation):
3957
target = REPOSITORY_ROOT + ":%s_cc" % rmw_implementation
4058
kwargs["data"] = kwargs.get("data", []) + [target]

bazel_ros2_rules/lib/private/repos.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ COMMON_FILES_MANIFEST = [
55
"ros_cc.bzl",
66
"ros_py.bzl",
77
"rosidl.bzl",
8+
"xacro.bzl",
89
"cmake_tools/__init__.py",
910
"cmake_tools/file_api.py",
1011
"cmake_tools/packages.py",

bazel_ros2_rules/lib/private/ros_py.bzl

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ load(
1515
)
1616
load(
1717
":common.bzl",
18+
"generate_file",
1819
"incorporate_rmw_implementation",
1920
)
2021
load(
@@ -23,6 +24,8 @@ load(
2324
"RUNTIME_ENVIRONMENT",
2425
)
2526

27+
_WORKSPACE_NAME = Label(REPOSITORY_ROOT + ":ros2").workspace_name
28+
2629
def ros_import_binary(
2730
name,
2831
executable,
@@ -144,24 +147,7 @@ def _add_deps(existing, new):
144147
deps.append(dep)
145148
return deps
146149

147-
def _generate_file_impl(ctx):
148-
out = ctx.actions.declare_file(ctx.label.name)
149-
ctx.actions.write(out, ctx.attr.content, ctx.attr.is_executable)
150-
return [DefaultInfo(
151-
files = depset([out]),
152-
data_runfiles = ctx.runfiles(files = [out]),
153-
)]
154-
155-
_generate_file = rule(
156-
attrs = {
157-
"content": attr.string(mandatory = True),
158-
"is_executable": attr.bool(default = False),
159-
},
160-
output_to_genfiles = True,
161-
implementation = _generate_file_impl,
162-
)
163-
164-
_LAUNCH_PY_TEMPLATE = """
150+
_LAUNCH_PY_TEMPLATE = """\
165151
import os
166152
import sys
167153
@@ -170,7 +156,7 @@ from python.runfiles import runfiles as runfiles_api
170156
assert __name__ == "__main__"
171157
runfiles = runfiles_api.Create()
172158
launch_file = runfiles.Rlocation({launch_respath}) # noqa
173-
ros2_bin = runfiles.Rlocation("ros2/ros2")
159+
ros2_bin = runfiles.Rlocation("{ros2_rlocation}")
174160
args = [ros2_bin, "launch", launch_file] + sys.argv[1:]
175161
os.execv(ros2_bin, args)
176162
"""
@@ -215,8 +201,9 @@ def ros_launch(
215201

216202
content = _LAUNCH_PY_TEMPLATE.format(
217203
launch_respath = repr(launch_respath),
204+
ros2_rlocation = _WORKSPACE_NAME + "/ros2",
218205
)
219-
_generate_file(
206+
generate_file(
220207
name = main,
221208
content = content,
222209
visibility = ["//visibility:private"],
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
""" Defines a rule and macro for transforming xacro files to a URDF.
2+
"""
3+
4+
load("@bazel_ros2_rules//lib:ament_index.bzl", "ament_index_share_files")
5+
load(":common.bzl", "generate_file")
6+
load(":distro.bzl", "REPOSITORY_ROOT")
7+
load(":ros_py.bzl", "ros_py_binary")
8+
9+
# Derive the plain workspace name for use in Rlocation paths ("name/target").
10+
# Label.workspace_name handles both Bzlmod ("@@name//") and WORKSPACE ("@name//")
11+
# formats correctly without manual string manipulation.
12+
_WORKSPACE_NAME = Label(REPOSITORY_ROOT + ":xacro").workspace_name
13+
14+
# Runner script template. The outer dload shim (from ros_py_binary) has
15+
# already set AMENT_PREFIX_PATH to include both system and user-package
16+
# prefixes (all absolute via $RUNFILES_DIR) before this script runs. What is
17+
# needed then, is to locate and exec the inner @ros2//:xacro shim.
18+
_XACRO_RUNNER_TEMPLATE = """\
19+
import os
20+
import sys
21+
22+
from python.runfiles import runfiles as runfiles_api
23+
24+
assert __name__ == "__main__"
25+
runfiles = runfiles_api.Create()
26+
xacro_bin = runfiles.Rlocation("{xacro_rlocation}")
27+
os.execv(xacro_bin, [xacro_bin] + sys.argv[1:])
28+
"""
29+
30+
def _ros_xacro_impl(ctx):
31+
output = ctx.actions.declare_file(ctx.label.name + ".urdf")
32+
args = ctx.actions.args()
33+
args.add(ctx.file.src)
34+
args.add("-o", output)
35+
args.add_all(ctx.attr.xacro_args)
36+
ctx.actions.run(
37+
inputs = [ctx.file.src] + ctx.files.data,
38+
outputs = [output],
39+
executable = ctx.executable.xacro_tool,
40+
arguments = [args],
41+
)
42+
return [DefaultInfo(files = depset([output]))]
43+
44+
_ros_xacro_rule = rule(
45+
attrs = {
46+
"src": attr.label(
47+
allow_single_file = [".xacro"],
48+
mandatory = True,
49+
doc = "The main .urdf.xacro file to process.",
50+
),
51+
"data": attr.label_list(
52+
allow_files = True,
53+
default = [],
54+
doc = "Additional files included via relative paths (e.g. .xacro, .yaml).",
55+
),
56+
"xacro_args": attr.string_list(
57+
default = [],
58+
doc = "Extra key:=value arguments forwarded to xacro.",
59+
),
60+
"xacro_tool": attr.label(
61+
executable = True,
62+
cfg = "exec",
63+
mandatory = True,
64+
doc = "The per-invocation xacro runner binary.",
65+
),
66+
},
67+
implementation = _ros_xacro_impl,
68+
)
69+
70+
def ros_xacro(name, src, data = [], ros_packages = {}, xacro_args = [], visibility = None):
71+
"""Transforms a .urdf.xacro file into a .urdf file.
72+
73+
User-defined packages are declared inline via the ros_packages dict. Each
74+
entry maps a ROS package name to the list of files to place under
75+
share/<package_name>/. The strip_prefix is derived automatically from the
76+
calling BUILD file's package path, so files are placed relative to that
77+
package directory.
78+
79+
The dload shim from ros_py_binary extends AMENT_PREFIX_PATH with all
80+
registered package prefixes (absolute, via $RUNFILES_DIR) before xacro
81+
runs, making $(find <pkg>) work for both local and system packages.
82+
83+
Example:
84+
ros_xacro(
85+
name = "example",
86+
src = "robot.urdf.xacro",
87+
data = ["base.xacro", "arm.xacro"],
88+
ros_packages = {
89+
"my_robot": glob(["urdf/**"]),
90+
},
91+
xacro_args = ["sim:=false"],
92+
)
93+
94+
Args:
95+
name: target name; the output file is named <name>.urdf
96+
src: the .urdf.xacro source file
97+
data: additional files included via relative paths
98+
(e.g. plain <xacro:include filename="other.xacro"/>
99+
or referenced .yaml configs); must be listed here so
100+
Bazel sandboxes them and tracks them as dependencies
101+
for incremental rebuilds
102+
ros_packages: dict mapping ROS package name to list of share files;
103+
files are stripped of the calling package's path prefix
104+
automatically before being placed under share/<pkg>/
105+
xacro_args: list of key:=value arguments forwarded to xacro
106+
visibility: target visibility
107+
"""
108+
pkg_targets = []
109+
strip_prefix = native.package_name() + "/" if native.package_name() else ""
110+
for pkg_name, srcs in ros_packages.items():
111+
index_name = "_{}_pkg_{}".format(name, pkg_name)
112+
ament_index_share_files(
113+
name = index_name,
114+
package_name = pkg_name,
115+
srcs = srcs,
116+
strip_prefix = strip_prefix,
117+
visibility = ["//visibility:private"],
118+
)
119+
pkg_targets.append(":" + index_name)
120+
121+
runner_data = [REPOSITORY_ROOT + ":xacro"] + pkg_targets
122+
123+
runner_main = "_{}_runner_main.py".format(name)
124+
generate_file(
125+
name = runner_main,
126+
content = _XACRO_RUNNER_TEMPLATE.format(
127+
xacro_rlocation = _WORKSPACE_NAME + "/xacro",
128+
),
129+
visibility = ["//visibility:private"],
130+
)
131+
132+
runner_name = "_{}_runner".format(name)
133+
ros_py_binary(
134+
name = runner_name,
135+
main = runner_main,
136+
srcs = [runner_main],
137+
data = runner_data,
138+
deps = ["@bazel_ros2_rules//deps/python/runfiles"],
139+
visibility = ["//visibility:private"],
140+
)
141+
142+
_ros_xacro_rule(
143+
name = name,
144+
src = src,
145+
data = data,
146+
xacro_args = xacro_args,
147+
xacro_tool = ":" + runner_name,
148+
visibility = visibility,
149+
)

ros2_example_bazel_installed/MODULE.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ ROS_REQUIRED_PACKAGES = [
1717
"rclcpp_action",
1818
"rclpy",
1919
"rosbag2",
20+
"realsense2_description",
2021
"ros2bag_mcap_cli",
2122
"ros2bag_sqlite3_cli",
2223
"tf2_py",
24+
"xacro",
2325
] + [
2426
# These are possible RMW implementations. Uncomment one and only one to
2527
# change implementations
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
load("@ros2//:ros_py.bzl", "ros_py_test")
2+
load("@ros2//:xacro.bzl", "ros_xacro")
3+
4+
ros_xacro(
5+
name = "example",
6+
src = "example.urdf.xacro",
7+
data = ["snippet.xacro"],
8+
ros_packages = {
9+
"my_robot": glob(["urdf/**"]),
10+
},
11+
xacro_args = ["sim:=false"],
12+
)
13+
14+
ros_py_test(
15+
name = "xacro_test",
16+
srcs = ["test/xacro_test.py"],
17+
data = [":example"],
18+
main = "test/xacro_test.py",
19+
deps = ["@rules_python//python/runfiles"],
20+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!-- urdf/robot.urdf.xacro -->
2+
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
3+
<!-- Should be able to include `.xacros` via a relative path (data= attribute) -->
4+
<xacro:include filename="snippet.xacro"/>
5+
<!-- Should be able to compose with local `.xacros` -->
6+
<xacro:include filename="$(find my_robot)/urdf/macros.xacro"/>
7+
<!-- Should be able to bring `.xacros` installed in the system (via `realsense2_description` for this example)-->
8+
<xacro:include filename="$(find realsense2_description)/urdf/_d435i.urdf.xacro"/>
9+
<!-- Should be able to retrieve xacro args-->
10+
<xacro:arg name="sim" default="true" />
11+
<xacro:property name="sim_mode" value="$(arg sim)"/>
12+
<sim>${sim_mode}</sim>
13+
<!-- Instantiate the D435i sensor to verify $(find realsense2_description) actually resolved. -->
14+
<link name="base_link"/>
15+
<xacro:sensor_d435i parent="base_link" name="head_camera" use_nominal_extrinsics="true">
16+
<origin xyz="0 0 0" rpy="0 0 0"/>
17+
</xacro:sensor_d435i>
18+
</robot>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0"?>
2+
<!-- A minimal xacro included via a relative path to test the data= attribute. -->
3+
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
4+
<link name="snippet_link"/>
5+
</robot>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Tests that ros_xacro correctly processes xacro args and includes."""
2+
3+
import unittest
4+
5+
from python.runfiles import runfiles
6+
7+
8+
class XacroTest(unittest.TestCase):
9+
@classmethod
10+
def setUpClass(cls):
11+
r = runfiles.Create()
12+
path = r.Rlocation(
13+
"ros2_example_bazel_installed/ros2_example_xacro/example.urdf"
14+
)
15+
with open(path) as f:
16+
cls.urdf = f.read()
17+
18+
def test_sim_arg_is_substituted(self):
19+
"""The sim xacro arg value appears in the output URDF."""
20+
self.assertIn("<sim>False</sim>", self.urdf)
21+
22+
def test_local_package_macros_expanded(self):
23+
"""Macros from the local my_robot package are resolved and expanded."""
24+
self.assertIn("aluminum", self.urdf)
25+
26+
def test_relative_include_expanded(self):
27+
"""A xacro included via a relative path (data=)"""
28+
self.assertIn('name="snippet_link"', self.urdf)
29+
30+
def test_system_package_include_resolved(self):
31+
"""$(find realsense2_description) is resolved and expanded."""
32+
self.assertIn('name="head_camera_link"', self.urdf)
33+
34+
35+
if __name__ == "__main__":
36+
unittest.main()

0 commit comments

Comments
 (0)