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)