diff --git a/doc/api/index.rst b/doc/api/index.rst index 3656bba286e..63585003996 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -214,6 +214,7 @@ Class-style Parameters Box Pattern + Perspective Enums ----- diff --git a/pygmt/params/__init__.py b/pygmt/params/__init__.py index b80b921407a..498c5a05bf3 100644 --- a/pygmt/params/__init__.py +++ b/pygmt/params/__init__.py @@ -4,3 +4,4 @@ from pygmt.params.box import Box from pygmt.params.pattern import Pattern +from pygmt.params.perspective import Perspective diff --git a/pygmt/params/perspective.py b/pygmt/params/perspective.py new file mode 100644 index 00000000000..c67889eab50 --- /dev/null +++ b/pygmt/params/perspective.py @@ -0,0 +1,90 @@ +""" +The Perspective class for setting perspective view. +""" + +import dataclasses +from collections.abc import Sequence +from typing import Literal + +from pygmt.alias import Alias +from pygmt.exceptions import GMTInvalidInput +from pygmt.params.base import BaseParam + + +@dataclasses.dataclass(repr=False) +class Perspective(BaseParam): + """ + Class for setting perspective view. + + Examples + -------- + >>> import pygmt + >>> from pygmt.params import Perspective + >>> fig = pygmt.Figure() + >>> fig.basemap( + ... region=[0, 10, 0, 10, 0, 20], + ... projection="X3c", + ... zsize="3c", + ... frame=["xafg", "yafg", "zafg", "wSEnZ"], + ... perspective=Perspective(azimuth=135, elevation=40, zlevel=10), + ... ) + >>> fig.show() + """ + + #: Azimuth of the viewpoint in degress. Default is 180.0 (looking from south to + #: north). + azimuth: float | None = None + + #: Elevation angle of the viewpoint in degrees. Default is 90.0 (looking straight + #: down at nadir). + elevation: float | None = None + + #: The z-level at which all 2-D material, like the plot frame, is plotted (only + #: valid when used in consort with parameters ``zsize``/``zscale``. Default is at + #: the bottom of the z-axis. + zlevel: float | None = None + + #: The plane to plot against the "wall" x = level (using x) or y = level (using y) + #: or the horizontal plain (using z). Default is the z-plane. + plane: Literal["x", "y", "z"] | None = None + + #: For frames used for animation, the center of the data domain is fixed. Specify + #: another center using either parameters ``center`` or ``viewpoint``. + #: + #: Project the coordinate (*lon0*, *lat0*) or (*lon0*, *lat0*, *z0*) to the center + #: of the page size. + center: Sequence[float] | None = None + + #: Specify the coordinates (*x0*, *y0*) of the projected 2-D view point. + viewpoint: Sequence[float] | None = None + + def __post_init__(self): + """ + Post-initialization processing to validate parameters. + """ + # Ensure that center and viewpoint are mutually exclusive. + if self.center is not None and self.viewpoint is not None: + msg = "Parameters 'center' and 'viewpoint' are mutually exclusive." + raise GMTInvalidInput(msg) + + # azimuth is required, so it must be set to the default if not specified. + if self.azimuth is None: + self.azimuth = 180.0 # Default azimuth is 180.0 + + # Set default elevation if zlevel is set but elevation is not. + if self.zlevel is not None and self.elevation is None: + self.elevation = 90.0 # Default elevation is 90.0 + + @property + def _aliases(self): + """ + Aliases for the parameters. + """ + return [ + Alias(self.plane, name="plane"), + Alias(self.azimuth, name="azimuth"), + Alias(self.elevation, name="elevation", prefix="/"), + Alias(self.zlevel, name="zlevel", prefix="/"), + Alias(self.center, name="center", prefix="+w", sep="/", size={2, 3}), + Alias(self.viewpoint, name="viewpoint", prefix="+v", sep="/", size=2), + ] diff --git a/pygmt/tests/test_params_perspective.py b/pygmt/tests/test_params_perspective.py new file mode 100644 index 00000000000..fddf02dc7c9 --- /dev/null +++ b/pygmt/tests/test_params_perspective.py @@ -0,0 +1,49 @@ +""" +Test the Perspective class. +""" + +import pytest +from pygmt.exceptions import GMTInvalidInput +from pygmt.params import Perspective + + +def test_params_perspective(): + """ + Test the Perspective class with various parameters. + """ + # Test azimuth, elevation, and zlevel separately + assert str(Perspective(azimuth=120)) == "120" + assert str(Perspective(elevation=30)) == "180.0/30" + assert str(Perspective(zlevel=1000)) == "180.0/90.0/1000" + + # Test combinations of azimuth, elevation, and zlevel + assert str(Perspective(azimuth=120, elevation=30)) == "120/30" + assert str(Perspective(azimuth=120, elevation=30, zlevel=1000)) == "120/30/1000" + assert str(Perspective(elevation=30, zlevel=1000)) == "180.0/30/1000" + + # Test center parameter + assert str(Perspective(center=(10, 20))) == "180.0/90.0+w10/20" + assert str(Perspective(azimuth=120, elevation=30, center=(0, 0))) == "120/30+w0/0" + pp = Perspective(azimuth=120, elevation=30, center=(10, 20, 30)) + assert str(pp) == "120/30+w10/20/30" + + # Test viewpoint parameter + assert str(Perspective(viewpoint=(10, 20))) == "180.0+v10/20" + pp = Perspective(azimuth=120, elevation=30, viewpoint=(0, 0)) + assert str(pp) == "120/30+v0/0" + + # Test plane parameter + assert str(Perspective(azimuth=120, elevation=30, plane="x")) == "x120/30" + assert str(Perspective(azimuth=120, elevation=30, plane="y")) == "y120/30" + assert str(Perspective(azimuth=120, elevation=30, plane="z")) == "z120/30" + pp = Perspective(azimuth=120, elevation=30, plane="x", viewpoint=(0, 0)) + assert str(pp) == "x120/30+v0/0" + assert str(Perspective(plane="y")) == "y180.0" + + +def test_params_viewpoint_center_exclusive(): + """ + Test that center and viewpoint are mutually exclusive. + """ + with pytest.raises(GMTInvalidInput): + _ = Perspective(azimuth=120, elevation=30, center=(10, 20), viewpoint=(0, 0))