Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/roboticstoolbox/backends/PyPlot/PyPlot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from roboticstoolbox.backends.Connector import Connector
from roboticstoolbox.backends.PyPlot.EllipsePlot import EllipsePlot, ShapePlot
from roboticstoolbox.backends.PyPlot.RobotPlot import RobotPlot
from roboticstoolbox.tools.plot import set_window_title
from roboticstoolbox.tools.plot import plane_view, set_window_title
from roboticstoolbox.tools.types import ArrayLike

# from roboticstoolbox.tools import Ticker
Expand Down Expand Up @@ -133,6 +133,7 @@ def launch(
fig: Any = None,
ax: Any = None,
limits: ArrayLike | None = None,
view: str | None = None,
**kwargs: Any,
) -> None:
"""
Expand All @@ -141,6 +142,13 @@ def launch(
```env = launch()``` creates a blank 3D matplotlib figure and returns
a reference to the backend.

:param view: ``"xy"``, ``"xz"`` or ``"yz"`` to start with a flat,
axis-aligned view of that plane, see
:func:`~roboticstoolbox.tools.plot.plane_view`, or None for the usual
three-dimensional view. The view is not locked, the plot can be rotated
as usual.
:raises ValueError: if ``view`` is not one of those

Optional keyword arguments
--------------------------
ax
Expand All @@ -162,6 +170,9 @@ def launch(
"""


# checked first, so that a bad name does not leave a figure behind
view_init = plane_view(view) if view is not None else None

self.render_mode = _resolve_render_mode(
kwargs.get("render_mode"), bool(kwargs.get("animation", False))
)
Expand Down Expand Up @@ -233,6 +244,9 @@ def launch(

self.limits = limits

if view_init is not None:
self.ax.view_init(**view_init)

# disable the display of value under cursor
# self.ax.format_coord = lambda x, y: ''

Expand Down
32 changes: 31 additions & 1 deletion src/roboticstoolbox/robot/BaseRobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,7 @@ def plot(
notebook: bool | None = None,
ax: Any = None,
env: Connector | None = None,
view: str | None = None,
**kwargs: Any,
) -> Connector:
"""
Expand Down Expand Up @@ -2132,6 +2133,7 @@ def plot(
:param ax: (Plot Option) Existing Matplotlib 3D axes to draw the scene on, instead of making a figure (this option is for 'pyplot' only)
:param env: The scene returned by an earlier ``plot``, to add this robot to, instead of making a new scene; see *Several configurations* below. The backend of the scene is used, so ``backend`` is ignored, and the options that are set when a scene is made (``limits``, ``fig``, ``ax``, ``render_mode``, ``inline_*``, ``notebook`` and ``browser``) cannot be given
:param color: (Plot Option) The color of the robot's line, any Matplotlib color (this option is for 'pyplot' only)
:param view: (Plot Option) ``"xy"``, ``"xz"`` or ``"yz"`` to start with a flat view of that plane, the first axis to the right and the second up, or ``None`` (default) for the usual three-dimensional view. The view is not locked, the plot can be rotated as usual. With ``env`` it changes the view of that scene (this option is for 'pyplot' only)
:param movie: (Plot Option) The filename to save the movie to (this option is for 'pyplot' only)
:param loop: (Plot Option) Loop the movie (this option is for 'pyplot' only)
:param notebook: (Plot Option, Swift only) ``True`` shows Swift in a frame of the current notebook cell, ``False`` opens it in a browser window, and ``None`` (default) chooses the frame in a notebook, unless a ``browser`` is named, and a window otherwise, so the same call works in a script and in a notebook. The browser showing the notebook must be on the machine running the kernel, since the frame points at ``localhost``
Expand Down Expand Up @@ -2219,6 +2221,15 @@ def plot(
if key in kwargs:
launch_kwargs[key] = kwargs.pop(key)

if view is not None:
from roboticstoolbox.tools.plot import plane_view

plane_view(view) # a bad name raises before a scene is made
if "view" not in inspect.signature(env.launch).parameters:
raise ValueError(f"the {type(env).__name__} backend cannot set a view")
if not adding:
launch_kwargs["view"] = view

if adding:
# these are fixed when a scene is made
fixed = [k for k in launch_kwargs if k != "realtime"]
Expand Down Expand Up @@ -2293,6 +2304,11 @@ def plot(
**launch_kwargs,
)

if adding and view is not None:
from roboticstoolbox.tools.plot import set_plane_view

set_plane_view(env.ax, view) # type: ignore[attr-defined]

# The drawing of this robot holds the pose it is drawn in, if the backend lets
# it, so the same robot can be added to a scene again in another pose.
if "q" in inspect.signature(env.add).parameters:
Expand Down Expand Up @@ -2365,6 +2381,7 @@ def teach(
vellipse: bool = False,
fellipse: bool = False,
backend: L["swift", "pyplot", "pyplot2"] | None = None,
view: str | None = None,
) -> Connector:
"""
Graphical teach pendant
Expand Down Expand Up @@ -2392,6 +2409,11 @@ def teach(
:param backend: The graphical backend to use -- 'swift', 'pyplot',
or 'pyplot2'. Defaults to whatever :meth:`plot` would pick for
this robot (see its own ``backend`` for the resolution rule).
:param view: ``"xy"``, ``"xz"`` or ``"yz"`` to start with a flat view of
that plane, the first axis to the right and the second up, or ``None``
(default) for the usual three-dimensional view. The view is not
locked, the plot can be rotated as usual (this option is for 'pyplot'
only)

:returns: A reference to the environment object which controls
the figure/view
Expand Down Expand Up @@ -2433,7 +2455,15 @@ def teach(
)

# Add the self to the figure in readonly mode
env.launch(name="Teach " + self.name, limits=limits)
launch_kwargs: dict[str, Any] = {}
if view is not None:
from roboticstoolbox.tools.plot import plane_view

plane_view(view) # a bad name raises before a scene is made
if "view" not in inspect.signature(env.launch).parameters:
raise ValueError(f"the {type(env).__name__} backend cannot set a view")
launch_kwargs["view"] = view
env.launch(name="Teach " + self.name, limits=limits, **launch_kwargs)
handle = env.add(
self,
readonly=True,
Expand Down
46 changes: 46 additions & 0 deletions src/roboticstoolbox/tools/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,52 @@ def set_window_title(fig: Any, title: str) -> bool:
return False


#: the view of each plane, for ``view_init``, which is exact: in each the first named
#: axis points to the right and the second up, in an orthographic projection
_PLANE_VIEWS: dict[str, dict[str, float]] = {
# looking down on the plane from +Z: x to the right, y up
"xy": {"elev": 90, "azim": -90},
# looking from -Y: x to the right, z up
"xz": {"elev": 0, "azim": -90},
# looking from +X: y to the right, z up
"yz": {"elev": 0, "azim": 0},
}


def plane_view(plane: str) -> dict[str, float]:
"""
The elevation and azimuth of an axis-aligned view of a 3D plot

:param plane: ``"xy"``, ``"xz"`` or ``"yz"``, in any case
:returns: the arguments for ``Axes3D.view_init``
:raises ValueError: if ``plane`` is not one of those

The first axis named is horizontal, increasing to the right, and the second is
vertical, increasing upwards. With an orthographic projection, which is what the
robot plots use, the view is a flat plot of that plane.
"""
key = plane.lower() if isinstance(plane, str) else plane
if key not in _PLANE_VIEWS:
raise ValueError(
f"unknown view {plane!r}, choose from: {', '.join(_PLANE_VIEWS)}"
)
return dict(_PLANE_VIEWS[key])


def set_plane_view(ax: Any, plane: str) -> None:
"""
Set an axis-aligned view of the plane of a 3D plot

:param ax: Matplotlib 3D axes
:param plane: ``"xy"``, ``"xz"`` or ``"yz"``, see :func:`plane_view`
:raises ValueError: if ``plane`` is not one of those

This sets the starting view, which is not locked: the plot can be rotated
interactively as usual afterwards.
"""
ax.view_init(**plane_view(plane))


def apply_block(block: bool | None) -> None:
"""
Apply the ``block`` argument of a plot method: whether to show the figure and wait
Expand Down
202 changes: 202 additions & 0 deletions tests/test_plot_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""
The ``view`` option: start a 3D plot in an axis-aligned view of a plane.
"""

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
import numpy.testing as nt # noqa: E402
import pytest # noqa: E402
from mpl_toolkits.mplot3d import proj3d # noqa: E402

import roboticstoolbox as rtb # noqa: E402
from roboticstoolbox import ET2 # noqa: E402
from roboticstoolbox.backends.PyPlot import PyPlot # noqa: E402
from roboticstoolbox.tools.plot import plane_view, set_plane_view # noqa: E402

pytestmark = pytest.mark.timeout(30)

#: (view, elev, azim)
VIEWS = [("xy", 90, -90), ("xz", 0, -90), ("yz", 0, 0)]


@pytest.fixture(autouse=True)
def close_figures():
plt.close("all")
yield
plt.close("all")


@pytest.fixture
def puma():
return rtb.models.DH.Puma560()


def axes3d():
fig = plt.figure()
return fig.add_subplot(111, projection="3d", proj_type="ortho")


def screen_direction(ax, vector):
"""Where a unit vector along a world axis points on the screen, from the origin."""
ax.figure.canvas.draw()
proj = ax.get_proj()
x0, y0, _ = proj3d.proj_transform(0, 0, 0, proj)
x1, y1, _ = proj3d.proj_transform(*vector, proj)
return np.array([x1 - x0, y1 - y0])


class TestPlaneView:
@pytest.mark.parametrize("name, elev, azim", VIEWS)
def test_angles(self, name, elev, azim):
assert plane_view(name) == {"elev": elev, "azim": azim}

Check warning on line 55 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L55

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_any_case(self):
assert plane_view("XZ") == plane_view("xz")

Check warning on line 58 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L58

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert plane_view("Yz") == plane_view("yz")

Check warning on line 59 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L59

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_the_result_can_be_changed_without_changing_the_next(self):
plane_view("xy")["elev"] = 0
assert plane_view("xy")["elev"] == 90

Check warning on line 63 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L63

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

@pytest.mark.parametrize("bad", ["zz", "xyz", "x", "", "top", " xy", 3, None])
def test_unknown(self, bad):
with pytest.raises(ValueError, match="unknown view.*xy, xz, yz"):
plane_view(bad)

@pytest.mark.parametrize(
"name, right, up",
[
("xy", (1, 0, 0), (0, 1, 0)),
("xz", (1, 0, 0), (0, 0, 1)),
("yz", (0, 1, 0), (0, 0, 1)),
],
)
def test_the_first_axis_is_to_the_right_and_the_second_up(self, name, right, up):
ax = axes3d()
set_plane_view(ax, name)
r, u = screen_direction(ax, right), screen_direction(ax, up)
assert r[0] > 0 and abs(r[1]) < 1e-9 # to the right, level

Check warning on line 82 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L82

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert u[1] > 0 and abs(u[0]) < 1e-9 # up, upright

Check warning on line 83 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L83

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

@pytest.mark.parametrize("name, elev, azim", VIEWS)
def test_the_third_axis_is_end_on(self, name, elev, azim):
ax = axes3d()
set_plane_view(ax, name)
third = {"xy": (0, 0, 1), "xz": (0, 1, 0), "yz": (1, 0, 0)}[name]
nt.assert_allclose(screen_direction(ax, third), [0, 0], atol=1e-9)

def test_the_view_can_be_changed_afterwards(self):
ax = axes3d()
set_plane_view(ax, "xy")
ax.view_init(elev=20, azim=30) # what dragging the plot does
assert (ax.elev, ax.azim) == (20, 30)

Check warning on line 96 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L96

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.


class TestLaunch:
def test_default_view_is_unchanged(self):
env = PyPlot()
env.launch()
default = axes3d()
assert (env.ax.elev, env.ax.azim) == (default.elev, default.azim)

Check warning on line 104 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L104

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

@pytest.mark.parametrize("name, elev, azim", VIEWS)
def test_view(self, name, elev, azim):
env = PyPlot()
env.launch(view=name)
assert (env.ax.elev, env.ax.azim) == (elev, azim)

Check warning on line 110 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L110

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_bad_view_leaves_no_figure(self):
env = PyPlot()
with pytest.raises(ValueError, match="unknown view"):
env.launch(view="top")
assert plt.get_fignums() == []

Check warning on line 116 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L116

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_on_given_axes(self):
ax = axes3d()
env = PyPlot()
env.launch(ax=ax, view="yz")
assert env.ax is ax

Check warning on line 122 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L122

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert (ax.elev, ax.azim) == (0, 0)

Check warning on line 123 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L123

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.


class TestPlot:
@pytest.mark.parametrize("name, elev, azim", VIEWS)
def test_view(self, puma, name, elev, azim):
env = puma.plot(puma.qr, backend="pyplot", view=name, block=False)
assert (env.ax.elev, env.ax.azim) == (elev, azim)

def test_the_robot_is_drawn_in_that_plane(self, puma):
env = puma.plot(puma.qr, backend="pyplot", view="xz", block=False)
tip = np.array(env.robots[0].links[0].get_data_3d()).T[-1]
# the screen position of the tip follows x to the right and z up
env.fig.canvas.draw()
x, y, _ = proj3d.proj_transform(*tip, env.ax.get_proj())
x0, y0, _ = proj3d.proj_transform(0, 0, 0, env.ax.get_proj())
assert (x - x0) * tip[0] > 0 or abs(tip[0]) < 1e-6

Check warning on line 139 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L139

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert (y - y0) * tip[2] > 0 or abs(tip[2]) < 1e-6

Check warning on line 140 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L140

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_a_trajectory_does_not_reset_the_view(self, puma):
qt = np.array([puma.qr + (puma.qn - puma.qr) * s for s in np.linspace(0, 1, 4)])
env = puma.plot(qt, backend="pyplot", view="xy", block=False)
assert (env.ax.elev, env.ax.azim) == (90, -90)

Check warning on line 145 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L145

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_the_user_can_rotate_it_afterwards(self, puma):
env = puma.plot(puma.qr, backend="pyplot", view="xy", block=False)
env.ax.view_init(elev=10, azim=20)
env.step(0.001)
assert (env.ax.elev, env.ax.azim) == (10, 20)

Check warning on line 151 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L151

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_with_env_the_view_of_the_scene_changes(self, puma):
env = puma.plot(puma.qr, backend="pyplot", view="xy", block=False)
env2 = puma.plot(puma.qn, backend="pyplot", env=env, view="yz", block=False)
assert env2 is env

Check warning on line 156 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L156

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert (env.ax.elev, env.ax.azim) == (0, 0)

Check warning on line 157 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L157

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert len(env.robots) == 2

Check warning on line 158 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L158

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_with_env_and_no_view_it_is_left_alone(self, puma):
env = puma.plot(puma.qr, backend="pyplot", view="xz", block=False)
puma.plot(puma.qn, backend="pyplot", env=env, block=False)
assert (env.ax.elev, env.ax.azim) == (0, -90)

Check warning on line 163 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L163

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_on_given_axes(self, puma):
ax = axes3d()
env = puma.plot(puma.qr, backend="pyplot", ax=ax, view="xy", block=False)
assert env.ax is ax
assert (ax.elev, ax.azim) == (90, -90)

Check warning on line 169 in tests/test_plot_view.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/test_plot_view.py#L169

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

def test_bad_view_is_an_error_before_anything_is_drawn(self, puma):
with pytest.raises(ValueError, match="unknown view"):
puma.plot(puma.qr, backend="pyplot", view="up", block=False)
assert plt.get_fignums() == []

def test_a_planar_backend_cannot(self):
planar = rtb.Robot2(ET2.R() * ET2.tx(1) * ET2.R() * ET2.tx(1), name="planar")
with pytest.raises(ValueError, match="cannot set a view"):
planar.plot([0.1, 0.2], backend="pyplot2", view="xy", block=False)
assert plt.get_fignums() == []


class TestTeach:
@pytest.mark.parametrize("name, elev, azim", VIEWS)
def test_view(self, puma, name, elev, azim):
env = puma.teach(puma.qr, backend="pyplot", view=name, block=False)
assert (env.ax.elev, env.ax.azim) == (elev, azim)

def test_default_view_is_unchanged(self, puma):
env = puma.teach(puma.qr, backend="pyplot", block=False)
default = axes3d()
assert (env.ax.elev, env.ax.azim) == (default.elev, default.azim)

def test_bad_view_leaves_no_figure(self, puma):
with pytest.raises(ValueError, match="unknown view"):
puma.teach(puma.qr, backend="pyplot", view="up", block=False)
assert plt.get_fignums() == []

def test_a_planar_backend_cannot(self):
planar = rtb.Robot2(ET2.R() * ET2.tx(1) * ET2.R() * ET2.tx(1), name="planar")
with pytest.raises(ValueError, match="cannot set a view"):
planar.teach([0.1, 0.2], backend="pyplot2", view="xy", block=False)
Loading