diff --git a/pyvista/plotting/camera.py b/pyvista/plotting/camera.py index 9fbf9f1348..f905839e00 100644 --- a/pyvista/plotting/camera.py +++ b/pyvista/plotting/camera.py @@ -8,6 +8,8 @@ from pyvista import _vtk from pyvista.utilities.misc import PyVistaDeprecationWarning +from .helpers import view_vectors + class Camera(_vtk.vtkCamera): """PyVista wrapper for the VTK Camera class. @@ -645,9 +647,18 @@ def copy(self): return new_camera - def tight(self, padding=0.0, adjust_render_window=True): + def tight(self, padding=0.0, adjust_render_window=True, view='xy', negative=False): """Adjust the camera position so that the actors fill the entire renderer. + The camera view direction is reoriented to be normal to the ``view`` + plane. When ``negative=False``, The first letter of ``view`` refers + to the axis that points to the right. The second letter of ``view`` + refers to axis that points up. When ``negative=True``, the first + letter refers to the axis that points left. The up direction is + unchanged. + + Parallel projection is enabled when using this function. + Parameters ---------- padding : float, optional @@ -658,9 +669,15 @@ def tight(self, padding=0.0, adjust_render_window=True): Adjust the size of the render window as to match the dimensions of the visible actors. + view : {'xy', 'yx', 'xz', 'zx', 'yz', 'zy'} + Plane to which the view is oriented. Default 'xy'. + + negative : bool + Whether to view in opposite direction. Default ``False``. + Notes ----- - This resets the view direction to look at the XY plane. + This resets the view direction to look at a plane with parallel projection. Examples -------- @@ -690,15 +707,29 @@ def tight(self, padding=0.0, adjust_render_window=True): self._renderer.ComputeAspect() aspect = self._renderer.GetAspect() - angle = np.pi * self.view_angle / 180.0 - dx, dy = (x1 - x0), (y1 - y0) - dist = max(dx / aspect[0], dy) / np.sin(angle / 2) / 2 - self.SetViewUp(0, 1, 0) - self.SetPosition(x0 + dx / 2, y0 + dy / 2, dist * (1 + padding)) - self.SetFocalPoint(x0 + dx / 2, y0 + dy / 2, 0) + position0 = np.array([x0, y0, z0]) + position1 = np.array([x1, y1, z1]) + objects_size = position1 - position0 + position = position0 + objects_size / 2 + + direction, viewup = view_vectors(view, negative) + horizontal = np.cross(direction, viewup) + + vert_dist = abs(objects_size @ viewup) + horiz_dist = abs(objects_size @ horizontal) + + # set focal point to objects' center + # offset camera position from objects center by dist in opposite of viewing direction + # (actual distance doesn't matter due to parallel projection) + dist = 1 + camera_position = position + dist * direction + + self.SetViewUp(*viewup) + self.SetPosition(*camera_position) + self.SetFocalPoint(*position) - ps = max(dx / aspect[0], dy) / 2 + ps = max(horiz_dist / aspect[0], vert_dist) / 2 self.parallel_scale = ps * (1 + padding) self._renderer.ResetCameraClippingRange(x0, x1, y0, y1, z0, z1) @@ -706,7 +737,7 @@ def tight(self, padding=0.0, adjust_render_window=True): ren_win = self._renderer.GetRenderWindow() size = list(ren_win.GetSize()) size_ratio = size[0] / size[1] - tight_ratio = dx / dy + tight_ratio = horiz_dist / vert_dist resize_ratio = tight_ratio / size_ratio if resize_ratio < 1: size[0] = round(size[0] * resize_ratio) @@ -717,4 +748,4 @@ def tight(self, padding=0.0, adjust_render_window=True): # simply call tight again to reset the parallel scale due to the # resized window - self.tight(padding=padding, adjust_render_window=False) + self.tight(padding=padding, adjust_render_window=False, view=view, negative=negative) diff --git a/pyvista/plotting/helpers.py b/pyvista/plotting/helpers.py index f2f1e66bc6..928217ed9f 100644 --- a/pyvista/plotting/helpers.py +++ b/pyvista/plotting/helpers.py @@ -1,12 +1,12 @@ """This module contains some convenience helper functions.""" +from typing import Tuple + import numpy as np import pyvista from pyvista.utilities import is_pyvista_dataset -from .plotting import Plotter - def plot( var_item, @@ -227,7 +227,7 @@ def plot( show_grid = kwargs.pop('show_grid', False) auto_close = kwargs.get('auto_close') - pl = Plotter( + pl = pyvista.Plotter( window_size=window_size, off_screen=off_screen, notebook=notebook, @@ -462,3 +462,52 @@ def plot_itk(mesh, color=None, scalars=None, opacity=1.0, smooth_shading=False): else: pl.add_mesh(mesh, color, scalars, opacity, smooth_shading) return pl.show() + + +def view_vectors(view: str, negative: bool = False) -> Tuple[np.ndarray, np.ndarray]: + """Given a plane to view, return vectors for setting up camera. + + Parameters + ---------- + view : {'xy', 'yx', 'xz', 'zx', 'yz', 'zy'} + Plane to return vectors for. + + negative : bool + Whether to view from opposite direction. Default ``False``. + + Returns + ------- + vec : numpy.ndarray + ``[x, y, z]`` vector that points in the viewing direction + + viewup : numpy.ndarray + ``[x, y, z]`` vector that points to the viewup direction + + """ + if view == 'xy': + vec = np.array([0, 0, 1]) + viewup = np.array([0, 1, 0]) + elif view == 'yx': + vec = np.array([0, 0, -1]) + viewup = np.array([1, 0, 0]) + elif view == 'xz': + vec = np.array([0, -1, 0]) + viewup = np.array([0, 0, 1]) + elif view == 'zx': + vec = np.array([0, 1, 0]) + viewup = np.array([1, 0, 0]) + elif view == 'yz': + vec = np.array([1, 0, 0]) + viewup = np.array([0, 0, 1]) + elif view == 'zy': + vec = np.array([-1, 0, 0]) + viewup = np.array([0, 1, 0]) + else: + raise ValueError( + f"Unexpected value for direction {view}\n" + " Expected: 'xy', 'yx', 'xz', 'zx', 'yz', 'zy'" + ) + + if negative: + vec *= -1 + return vec, viewup diff --git a/pyvista/plotting/renderer.py b/pyvista/plotting/renderer.py index 6bb83a18dc..f74f7a7e6a 100644 --- a/pyvista/plotting/renderer.py +++ b/pyvista/plotting/renderer.py @@ -17,6 +17,7 @@ from .camera import Camera from .charts import Charts from .colors import Color +from .helpers import view_vectors from .render_passes import RenderPasses from .tools import create_axes_marker, create_axes_orientation_box, parse_font_family @@ -2385,11 +2386,7 @@ def view_xy(self, negative=False): >>> pl.show() """ - vec = np.array([0, 0, 1]) - viewup = np.array([0, 1, 0]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('xy', negative=negative)) def view_yx(self, negative=False): """View the YX plane. @@ -2412,11 +2409,7 @@ def view_yx(self, negative=False): >>> pl.show() """ - vec = np.array([0, 0, -1]) - viewup = np.array([1, 0, 0]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('yx', negative=negative)) def view_xz(self, negative=False): """View the XZ plane. @@ -2439,11 +2432,7 @@ def view_xz(self, negative=False): >>> pl.show() """ - vec = np.array([0, -1, 0]) - viewup = np.array([0, 0, 1]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('xz', negative=negative)) def view_zx(self, negative=False): """View the ZX plane. @@ -2466,11 +2455,7 @@ def view_zx(self, negative=False): >>> pl.show() """ - vec = np.array([0, 1, 0]) - viewup = np.array([1, 0, 0]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('zx', negative=negative)) def view_yz(self, negative=False): """View the YZ plane. @@ -2493,11 +2478,7 @@ def view_yz(self, negative=False): >>> pl.show() """ - vec = np.array([1, 0, 0]) - viewup = np.array([0, 0, 1]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('yz', negative=negative)) def view_zy(self, negative=False): """View the ZY plane. @@ -2520,11 +2501,7 @@ def view_zy(self, negative=False): >>> pl.show() """ - vec = np.array([-1, 0, 0]) - viewup = np.array([0, 1, 0]) - if negative: - vec *= -1 - self.view_vector(vec, viewup) + self.view_vector(*view_vectors('zy', negative=negative)) def disable(self): """Disable this renderer's camera from being interactive.""" diff --git a/tests/plotting/conftest.py b/tests/plotting/conftest.py index 1827f07ae4..2f9b2b00f5 100644 --- a/tests/plotting/conftest.py +++ b/tests/plotting/conftest.py @@ -41,3 +41,10 @@ def check_gc(request): assert len(after) == 0, 'Not all objects GCed:\n' + '\n'.join( sorted(o.__class__.__name__ for o in after) ) + + +@pytest.fixture() +def colorful_tetrahedron(): + mesh = pyvista.Tetrahedron() + mesh.cell_data["colors"] = [[255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255]] + return mesh diff --git a/tests/plotting/image_cache/tight_direction[False-xy].png b/tests/plotting/image_cache/tight_direction[False-xy].png new file mode 100644 index 0000000000..a1a4be4f58 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-xy].png differ diff --git a/tests/plotting/image_cache/tight_direction[False-xz].png b/tests/plotting/image_cache/tight_direction[False-xz].png new file mode 100644 index 0000000000..7d7e1da517 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-xz].png differ diff --git a/tests/plotting/image_cache/tight_direction[False-yx].png b/tests/plotting/image_cache/tight_direction[False-yx].png new file mode 100644 index 0000000000..7957692f68 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-yx].png differ diff --git a/tests/plotting/image_cache/tight_direction[False-yz].png b/tests/plotting/image_cache/tight_direction[False-yz].png new file mode 100644 index 0000000000..d5387f21af Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-yz].png differ diff --git a/tests/plotting/image_cache/tight_direction[False-zx].png b/tests/plotting/image_cache/tight_direction[False-zx].png new file mode 100644 index 0000000000..bad254386f Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-zx].png differ diff --git a/tests/plotting/image_cache/tight_direction[False-zy].png b/tests/plotting/image_cache/tight_direction[False-zy].png new file mode 100644 index 0000000000..cbbda85e6c Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[False-zy].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-xy].png b/tests/plotting/image_cache/tight_direction[True-xy].png new file mode 100644 index 0000000000..ec434cc7ce Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-xy].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-xz].png b/tests/plotting/image_cache/tight_direction[True-xz].png new file mode 100644 index 0000000000..f4929b2a7d Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-xz].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-yx].png b/tests/plotting/image_cache/tight_direction[True-yx].png new file mode 100644 index 0000000000..86fdcae315 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-yx].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-yz].png b/tests/plotting/image_cache/tight_direction[True-yz].png new file mode 100644 index 0000000000..bcfca06cdf Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-yz].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-zx].png b/tests/plotting/image_cache/tight_direction[True-zx].png new file mode 100644 index 0000000000..61193e19a8 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-zx].png differ diff --git a/tests/plotting/image_cache/tight_direction[True-zy].png b/tests/plotting/image_cache/tight_direction[True-zy].png new file mode 100644 index 0000000000..b2fa5323a7 Binary files /dev/null and b/tests/plotting/image_cache/tight_direction[True-zy].png differ diff --git a/tests/plotting/image_cache/tight_multiple_objects.png b/tests/plotting/image_cache/tight_multiple_objects.png new file mode 100644 index 0000000000..984b90edb5 Binary files /dev/null and b/tests/plotting/image_cache/tight_multiple_objects.png differ diff --git a/tests/plotting/image_cache/view_xyz[False-xy].png b/tests/plotting/image_cache/view_xyz[False-xy].png new file mode 100644 index 0000000000..584a400079 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-xy].png differ diff --git a/tests/plotting/image_cache/view_xyz[False-xz].png b/tests/plotting/image_cache/view_xyz[False-xz].png new file mode 100644 index 0000000000..5b356732d9 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-xz].png differ diff --git a/tests/plotting/image_cache/view_xyz[False-yx].png b/tests/plotting/image_cache/view_xyz[False-yx].png new file mode 100644 index 0000000000..402213d67f Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-yx].png differ diff --git a/tests/plotting/image_cache/view_xyz[False-yz].png b/tests/plotting/image_cache/view_xyz[False-yz].png new file mode 100644 index 0000000000..bd3064a95f Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-yz].png differ diff --git a/tests/plotting/image_cache/view_xyz[False-zx].png b/tests/plotting/image_cache/view_xyz[False-zx].png new file mode 100644 index 0000000000..01f7f5d506 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-zx].png differ diff --git a/tests/plotting/image_cache/view_xyz[False-zy].png b/tests/plotting/image_cache/view_xyz[False-zy].png new file mode 100644 index 0000000000..cf94c05c57 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[False-zy].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-xy].png b/tests/plotting/image_cache/view_xyz[True-xy].png new file mode 100644 index 0000000000..c69194dde8 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-xy].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-xz].png b/tests/plotting/image_cache/view_xyz[True-xz].png new file mode 100644 index 0000000000..3abde185f5 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-xz].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-yx].png b/tests/plotting/image_cache/view_xyz[True-yx].png new file mode 100644 index 0000000000..27b687bae5 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-yx].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-yz].png b/tests/plotting/image_cache/view_xyz[True-yz].png new file mode 100644 index 0000000000..ed456fe47a Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-yz].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-zx].png b/tests/plotting/image_cache/view_xyz[True-zx].png new file mode 100644 index 0000000000..4e7c391c6c Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-zx].png differ diff --git a/tests/plotting/image_cache/view_xyz[True-zy].png b/tests/plotting/image_cache/view_xyz[True-zy].png new file mode 100644 index 0000000000..191e4cd6f7 Binary files /dev/null and b/tests/plotting/image_cache/view_xyz[True-zy].png differ diff --git a/tests/plotting/test_helpers.py b/tests/plotting/test_helpers.py new file mode 100644 index 0000000000..bf03d84368 --- /dev/null +++ b/tests/plotting/test_helpers.py @@ -0,0 +1,20 @@ +"""Tests for plotting helpers.""" + +import numpy as np +import pytest + +from pyvista.plotting.helpers import view_vectors + + +def test_view_vectors(): + views = ('xy', 'yx', 'xz', 'zx', 'yz', 'zy') + + for view in views: + vec, viewup = view_vectors(view) + assert isinstance(vec, np.ndarray) + assert np.array_equal(vec.shape, (3,)) + assert isinstance(viewup, np.ndarray) + assert np.array_equal(viewup.shape, (3,)) + + with pytest.raises(ValueError, match="Unexpected value for direction"): + view_vectors('invalid') diff --git a/tests/plotting/test_plotting.py b/tests/plotting/test_plotting.py index bd94019fab..5ecbdcdec1 100644 --- a/tests/plotting/test_plotting.py +++ b/tests/plotting/test_plotting.py @@ -4,6 +4,7 @@ See the image regression notes in doc/extras/developer_notes.rst """ +from functools import partial import inspect import io import os @@ -167,7 +168,12 @@ def get_cmd_opt(pytestconfig): glb_fail_extra_image_cache = pytestconfig.getoption('fail_extra_image_cache') -def verify_cache_image(plotter): +@pytest.fixture() +def test_name(request): + return request.node.name + + +def verify_cache_image(plotter, name=None): """Either store or validate an image. This is function should only be called within a pytest @@ -178,6 +184,11 @@ def verify_cache_image(plotter): Assign this only once for each test you'd like to validate the previous image of. This will not work with parameterized tests. + Parameters + ---------- + name : str, optional + Provide a test name to use for the filename to store. + Example Usage: plotter = pyvista.Plotter() plotter.add_mesh(sphere) @@ -192,18 +203,21 @@ def verify_cache_image(plotter): # since each test must contain a unique name, we can simply # use the function test to name the image - stack = inspect.stack() - for item in stack: - if item.function == 'check_gc': - return - if item.function[:5] == 'test_': - test_name = item.function - break + if name is None: + stack = inspect.stack() + for item in stack: + if item.function == 'check_gc': + return + if item.function[:5] == 'test_': + test_name = item.function + break + else: + raise RuntimeError( + 'Unable to identify calling test function. This function ' + 'should only be used within a pytest environment.' + ) else: - raise RuntimeError( - 'Unable to identify calling test function. This function ' - 'should only be used within a pytest environment.' - ) + test_name = name if test_name in HIGH_VARIANCE_TESTS: allowed_error = VER_IMAGE_REGRESSION_ERROR @@ -2945,6 +2959,29 @@ def test_tight_wide(): pl.show(before_close_callback=verify_cache_image) +@pytest.mark.parametrize('view', ['xy', 'yx', 'xz', 'zx', 'yz', 'zy']) +@pytest.mark.parametrize('negative', [False, True]) +def test_tight_direction(view, negative, colorful_tetrahedron, test_name): + """Test camera.tight() with various views like xy.""" + + pl = pyvista.Plotter() + pl.add_mesh(colorful_tetrahedron, scalars="colors", rgb=True, preference="cell") + pl.camera.tight(view=view, negative=negative) + pl.add_axes() + pl.show(before_close_callback=partial(verify_cache_image, name=test_name)) + + +def test_tight_multiple_objects(): + pl = pyvista.Plotter() + pl.add_mesh( + pyvista.Cone(center=(0.0, -2.0, 0.0), direction=(0.0, -1.0, 0.0), height=1.0, radius=1.0) + ) + pl.add_mesh(pyvista.Sphere(center=(0.0, 0.0, 0.0))) + pl.camera.tight() + pl.add_axes() + pl.show(before_close_callback=verify_cache_image) + + def test_backface_params(): mesh = pyvista.ParametricCatalanMinimal() @@ -3029,6 +3066,18 @@ def test_wireframe_color(sphere): ) +@pytest.mark.parametrize('direction', ['xy', 'yx', 'xz', 'zx', 'yz', 'zy']) +@pytest.mark.parametrize('negative', [False, True]) +def test_view_xyz(direction, negative, colorful_tetrahedron, test_name): + """Test various methods like view_xy.""" + + pl = pyvista.Plotter() + pl.add_mesh(colorful_tetrahedron, scalars="colors", rgb=True, preference="cell") + getattr(pl, f"view_{direction}")(negative=negative) + pl.add_axes() + pl.show(before_close_callback=partial(verify_cache_image, name=test_name)) + + @skip_windows def test_add_point_scalar_labels_fmt(): mesh = examples.load_uniform().slice()