Skip to content

Commit ca521a8

Browse files
Add ros_xacro support
1 parent 02bdb4e commit ca521a8

8 files changed

Lines changed: 253 additions & 0 deletions

File tree

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

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
</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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests that ros_xacro correctly processes xacro args and includes."""
2+
3+
from python.runfiles import runfiles
4+
5+
6+
def _read_urdf():
7+
r = runfiles.Create()
8+
path = r.Rlocation(
9+
"ros2_example_bazel_installed/ros2_example_xacro/example.urdf"
10+
)
11+
with open(path) as f:
12+
return f.read()
13+
14+
15+
def test_sim_arg_is_substituted():
16+
"""The sim xacro arg value appears in the output URDF."""
17+
content = _read_urdf()
18+
assert "<sim>False</sim>" in content, (
19+
"Expected '<sim>False</sim>' in URDF output:\n" + content
20+
)
21+
22+
23+
def test_local_package_macros_expanded():
24+
"""Macros from the local my_robot package are resolved and expanded."""
25+
content = _read_urdf()
26+
# The macros.xacro defines materials; verify they appear in the output.
27+
assert "aluminum" in content, (
28+
"Expected materials from my_robot/urdf/macros.xacro in output:\n"
29+
+ content
30+
)
31+
32+
33+
def test_relative_include_expanded():
34+
"""A xacro included via a relative path (data=) appears in the output."""
35+
content = _read_urdf()
36+
assert 'name="snippet_link"' in content, (
37+
"Expected snippet_link from snippet.xacro in output:\n" + content
38+
)
39+
40+
41+
if __name__ == "__main__":
42+
test_sim_arg_is_substituted()
43+
test_local_package_macros_expanded()
44+
test_relative_include_expanded()
45+
print("All tests passed.")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version="1.0"?>
2+
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
3+
<xacro:macro name="simple_link" params="name">
4+
<link name="${name}"/>
5+
</xacro:macro>
6+
</robot>

0 commit comments

Comments
 (0)