diff --git a/src/roboticstoolbox/bin/rtbtool.py b/src/roboticstoolbox/bin/rtbtool.py
index 2f01ac5b9..9a730b63d 100755
--- a/src/roboticstoolbox/bin/rtbtool.py
+++ b/src/roboticstoolbox/bin/rtbtool.py
@@ -321,8 +321,7 @@ def main() -> None:
# set default backend for Robot.plot
if args.swift:
- # an attribute that shadows the class method, see findings
- Robot.default_backend = "swift" # type: ignore[method-assign,assignment]
+ Robot.set_default_backend("swift")
# set matrix printing mode for spatialmath
SE3._ansimatrix = args.ansi # type: ignore[name-defined] # from the star import
diff --git a/src/roboticstoolbox/models/URDF/URDFRobot.py b/src/roboticstoolbox/models/URDF/URDFRobot.py
index 2ae8d84c5..1950bf358 100644
--- a/src/roboticstoolbox/models/URDF/URDFRobot.py
+++ b/src/roboticstoolbox/models/URDF/URDFRobot.py
@@ -363,11 +363,9 @@ def _links_from_urdf(urdf: URDF) -> Any:
elinks.append(elink)
elinkdict[link.name] = elink
- try:
- # geometry has no setter, so this raises AttributeError and is ignored
- elink.geometry = [v.geometry.ob for v in link.visuals]
- except AttributeError:
- pass
+ # a visual without a shape, or with a mesh that cannot be found, is
+ # rejected when the URDF is parsed, so every visual here has an `ob`
+ elink.geometry = [v.geometry.ob for v in link.visuals]
if link.collisions:
shapes = []
diff --git a/src/roboticstoolbox/robot/BaseRobot.py b/src/roboticstoolbox/robot/BaseRobot.py
index 1f394ce99..27c0aee67 100644
--- a/src/roboticstoolbox/robot/BaseRobot.py
+++ b/src/roboticstoolbox/robot/BaseRobot.py
@@ -77,6 +77,10 @@ class BaseRobot(SceneNode, DynamicsMixin, RobotPlottingMPLMixin, ABC, Generic[Li
# set when the links are processed in the constructor
_base_link: LinkType
+ #: default graphical backend for every robot of this class, used when the
+ #: robot has not been given its own, see :meth:`set_default_backend`
+ _class_default_backend: Any = None
+
def __init__(
self,
links: list[LinkType],
@@ -823,19 +827,39 @@ def default_backend(self) -> Any:
Get default graphical backend
- ``robot.default_backend`` Get the default graphical backend, used when
- no explicit backend is passed to ``Robot.plot``.
+ no explicit backend is passed to ``Robot.plot``. This is the value set
+ for the robot, otherwise the value set for all robots by
+ :meth:`set_default_backend`, otherwise ``None``.
- ``robot.default_backend = ...`` Set the default graphical backend, used when
no explicit backend is passed to ``Robot.plot``. The default set here will
be overridden if the particular ``Robot`` subclass cannot support it.
:returns: backend name
"""
- return self._default_backend
+ if self._default_backend is not None:
+ return self._default_backend
+ return self._class_default_backend
@default_backend.setter
def default_backend(self, be: Any) -> None:
self._default_backend = be
+ @classmethod
+ def set_default_backend(cls, be: Any) -> None:
+ """
+ Set the default graphical backend for all robots
+
+ :param be: backend name, for example ``"swift"``, or ``None`` to clear
+
+ ``Robot.set_default_backend("swift")`` sets the backend used by
+ :meth:`plot` for every robot, existing or created later, that has not
+ been given one of its own by ``robot.default_backend = ...``. The robot's
+ own value takes precedence.
+
+ :seealso: :attr:`default_backend`
+ """
+ cls._class_default_backend = be
+
@property
def gravity(self) -> NDArray:
"""
diff --git a/tests/test_default_backend.py b/tests/test_default_backend.py
new file mode 100644
index 000000000..af84cee9b
--- /dev/null
+++ b/tests/test_default_backend.py
@@ -0,0 +1,45 @@
+"""Robot.default_backend, per robot, and the class-level default used by rtbtool"""
+
+import inspect
+
+import pytest
+
+import roboticstoolbox as rtb
+from roboticstoolbox import Robot
+
+
+@pytest.fixture
+def restore_class_default():
+ yield
+ Robot.set_default_backend(None)
+
+
+def test_class_default_reaches_every_robot(restore_class_default) -> None:
+ before = rtb.models.DH.Puma560()
+ assert before.default_backend is None
+
+ Robot.set_default_backend("swift")
+
+ after = rtb.models.DH.Panda()
+ assert before.default_backend == "swift"
+ assert after.default_backend == "swift"
+
+
+def test_instance_value_wins_over_the_class_default(restore_class_default) -> None:
+ Robot.set_default_backend("swift")
+ robot = rtb.models.DH.Puma560()
+ robot.default_backend = "pyplot"
+ assert robot.default_backend == "pyplot"
+ assert rtb.models.DH.Panda().default_backend == "swift"
+
+
+def test_default_backend_remains_a_property(restore_class_default) -> None:
+ # rtbtool used to replace it with a string on the class
+ Robot.set_default_backend("swift")
+ assert isinstance(inspect.getattr_static(Robot, "default_backend"), property)
+
+
+def test_class_default_can_be_cleared(restore_class_default) -> None:
+ Robot.set_default_backend("swift")
+ Robot.set_default_backend(None)
+ assert rtb.models.DH.Puma560().default_backend is None
diff --git a/tests/test_urdf_visual_geometry.py b/tests/test_urdf_visual_geometry.py
new file mode 100644
index 000000000..b0586594f
--- /dev/null
+++ b/tests/test_urdf_visual_geometry.py
@@ -0,0 +1,43 @@
+"""Visual geometry of a URDF robot reaches the links, or fails at parse time"""
+
+import pytest
+
+from roboticstoolbox.models.URDF.URDFRobot import _links_from_urdf
+from roboticstoolbox.tools.urdf import URDF, URDFError
+
+_LINK = """
+
+
+ {visuals}
+
+"""
+
+
+def test_visual_shapes_are_set_on_the_link() -> None:
+ urdf = URDF.loadstr(
+ _LINK.format(
+ visuals=(
+ ''
+ ''
+ )
+ ),
+ None,
+ )
+ links, _ = _links_from_urdf(urdf)
+ assert len(links[0].geometry.scene_children) == 2
+
+
+@pytest.mark.parametrize(
+ "visual",
+ [
+ "",
+ "",
+ "",
+ ''
+ "",
+ ],
+)
+def test_a_visual_without_a_shape_is_rejected_when_parsed(visual: str) -> None:
+ # so that _links_from_urdf never meets a visual without an `ob`
+ with pytest.raises(URDFError):
+ URDF.loadstr(_LINK.format(visuals=visual), None)