# Copyright (c) 2021, GeoVista Contributors.
#
# This file is part of GeoVista and is distributed under the 3-Clause BSD license.
# See the LICENSE file in the package root directory for licensing details.
"""Provide specialisation to support a geospatial aware plotter.
See :class:`pyvista.Plotter`.
Notes
-----
.. versionadded:: 0.1.0
"""
from __future__ import annotations
from functools import lru_cache
import os
from typing import TYPE_CHECKING, Any
from warnings import warn
import lazy_loader as lazy
from .bridge import Transform
from .common import (
GV_FIELD_ZSCALE,
GV_REMESH_POINT_IDS,
LRU_CACHE_SIZE,
RADIUS,
REMESH_SEAM,
ZLEVEL_SCALE,
ZTRANSFORM_FACTOR,
distance,
point_cloud,
to_cartesian,
to_lonlat,
vtk_warnings_off,
)
from .common import cast_UnstructuredGrid_to_PolyData as cast
from .core import add_texture_coords, resize, slice_mesh
from .crs import (
WGS84,
CRSLike,
from_wkt,
get_central_meridian,
has_wkt,
projected,
set_central_meridian,
to_wkt,
)
from .geometry import coastlines
from .gridlines import (
GRATICULE_ZLEVEL,
GraticuleGrid,
create_meridians,
create_parallels,
)
from .pantry.meshes import (
LFRIC_RESOLUTION,
REGULAR_RESOLUTION,
lfric,
regular_grid,
)
from .raster import wrap_texture
from .transform import transform_mesh, transform_point
if TYPE_CHECKING:
from numpy.typing import ArrayLike
import pyvista as pv
from geovista.crs import CRSLike
# lazy import third-party dependencies
np = lazy.load("numpy")
pyproj = lazy.load("pyproj")
pv = lazy.load("pyvista")
__all__ = [
"ADD_POINTS_STYLE",
"BASE_ZLEVEL_SCALE",
"COASTLINES_RTOL",
"GRATICULE_LABEL_FONT_SIZE",
"GRATICULE_SHOW_LABELS",
"GeoPlotter",
"GeoPlotterBase",
"OPACITY_BLACKLIST",
]
ADD_POINTS_STYLE: list[str, str] = ["points", "points_gaussian"]
"""The valid 'style' options for adding points."""
BASE_ZLEVEL_SCALE: int = 1.0e-3
"""Proportional multiplier for z-axis levels/offsets of base-layer mesh."""
COASTLINES_RTOL: float = 1.0e-8
"""Coastlines relative tolerance for longitudes close to 'wrap meridian'."""
GRATICULE_LABEL_FONT_SIZE: int = 9
"""The default font size for graticule labels."""
GRATICULE_SHOW_LABELS: bool = True
"""Whether to render graticule labels by default."""
OPACITY_BLACKLIST = [
("llvmpipe (LLVM 7.0, 256 bits)", "3.3 (Core Profile) Mesa 18.3.4"),
]
"""Known GPU renderer and version combinations that don't support opacity."""
@lru_cache(maxsize=LRU_CACHE_SIZE)
def _lfric_mesh(
resolution: str | None = None,
radius: float | None = None,
) -> pv.PolyData:
"""Retrieve the LFRic unstructured cubed-sphere from the geovista cache.
Parameters
----------
resolution : str, optional
The resolution of the LFRic unstructured cubed-sphere. Defaults to
:data:`geovista.pantry.meshes.LFRIC_RESOLUTION`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
Returns
-------
PolyData
The LFRic spherical mesh.
Notes
-----
.. versionadded:: 0.1.0
"""
mesh = lfric(resolution=resolution)
if radius:
mesh = resize(mesh, radius=radius)
mesh.set_active_scalars(name=None)
return mesh
[docs]
class GeoPlotterBase: # numpydoc ignore=PR01
"""Base class with common behaviour for a geospatial aware plotter.
See :class:`pyvista.Plotter`.
Notes
-----
.. versionadded:: 0.1.0
"""
def __init__(
self, *args: Any | None, crs: CRSLike | None = None, **kwargs: Any | None
) -> None:
"""Create geospatial aware plotter.
Parameters
----------
*args : tuple, optional
See :class:`pyvista.Plotter` for further details.
crs : CRSLike, optional
The target CRS to render geolocated meshes added to the plotter.
May be anything accepted by :meth:`pyproj.crs.CRS.from_user_input`.
Defaults to ``EPSG:4326`` i.e., ``WGS 84``.
**kwargs : dict, optional
See :class:`pyvista.Plotter` for further details.
Notes
-----
.. versionadded:: 0.1.0
"""
if args:
klass = f"'{self.__class__.__name__}'"
if len(args) == 1 and ("crs" not in kwargs or kwargs["crs"] is None):
wmsg = (
f"geovista {klass} received an unexpected argument. "
"Assuming 'crs' keyword argument instead..."
)
warn(wmsg, stacklevel=2)
kwargs["crs"] = args[0]
args = ()
else:
plural = "s" if len(args) > 1 else ""
pre = ",".join([f"'{arg}'" for arg in args[:-1]])
bad = f"{pre} and '{args[-1]}'" if pre else f"'{args[0]}'"
emsg = (
f"{klass} received {len(args)} unexpected argument{plural}, {bad}."
)
raise ValueError(emsg)
self.crs = pyproj.CRS.from_user_input(crs) if crs is not None else WGS84
# status of gpu opacity support
self._missing_opacity = False
# cartesian (xyz) center of last mesh added to the plotter
self._poi = None
super().__init__(*args, **kwargs)
def _add_graticule_labels(
self,
graticule: GraticuleGrid,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Render the labels for the given parallels/meridians.
Parameters
----------
graticule : GraticuleGrid
The labels and associated lon/lat points for the graticule
parallels/meridians.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
if zlevel is None:
zlevel = GRATICULE_ZLEVEL
if point_labels_args is None:
point_labels_args = {}
lonlat = graticule.lonlat
xyz = to_cartesian(
lonlat[:, 0], lonlat[:, 1], radius=radius, zlevel=zlevel, zscale=zscale
)
mesh = pv.PolyData(xyz)
if graticule.mask is not None:
mask = graticule.mask * REMESH_SEAM
mesh.point_data[GV_REMESH_POINT_IDS] = mask
mesh.set_active_scalars(name=None)
to_wkt(mesh, WGS84)
# the point-cloud won't be sliced, however it's important that the
# central-meridian rotation is performed here
mesh = transform_mesh(mesh, self.crs, zlevel=zlevel, inplace=True)
xyz = mesh.points
if "show_points" in point_labels_args:
_ = point_labels_args.pop("show_points")
if "font_size" not in point_labels_args:
point_labels_args["font_size"] = GRATICULE_LABEL_FONT_SIZE
# labels over-plot points, therefore enforce non-rendering of points,
# which is more efficient
point_labels_args["show_points"] = False
# opinionated over-ride to disable label visibility filter
point_labels_args["always_visible"] = False
self.add_point_labels(xyz, graticule.labels, **point_labels_args)
def _warn_opacity(self) -> None:
"""Add textual warning for no opacity support to plotter scene.
Convenience for adding a text warning to the render scene for known GPU
configurations that don't support opacity.
Notes
-----
.. versionadded:: 0.4.0
"""
if not self._missing_opacity:
info = pv.GPUInfo()
renderer_version = info.renderer, info.version
if renderer_version in OPACITY_BLACKLIST:
self.add_text(
"Requires GPU opacity support",
position="lower_right",
font_size=7,
color="red",
shadow=True,
)
self._missing_opacity = True
[docs]
def add_base_layer(
self,
mesh: pv.PolyData | None = None,
resolution: str | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
rtol: float | None = None,
atol: float | None = None,
**kwargs: Any | None,
) -> pv.Actor:
"""Generate a cubed-sphere base layer mesh and add to the plotter scene.
Optionally, a `mesh` may be provided (e.g. if one is available that
better fits the geometry of the surface mesh).
Parameters
----------
mesh : PolyData, optional
Use the provided mesh as the base layer.
resolution : str, optional
The resolution of the cubed-sphere to generate as the base layer,
which may be either ``c48``, ``c96`` or ``c192``. Defaults to
:data:`geovista.pantry.meshes.LFRIC_RESOLUTION`. Alternatively, generate a
regular grid using a format of ``rN``, where ``N`` is the number of cells
in latitude, and ``N * 1.5`` cells in longitude. When adding a base layer
to a projection, the default is to use a regular grid with resolution
:data:`geovista.pantry.meshes.REGULAR_RESOLUTION`.
radius : float, optional
The radius of the spherical mesh to generate as the base layer. Defaults
to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to ``-1``.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`BASE_ZLEVEL_SCALE`.
rtol : float, optional
The relative tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
atol : float, optional
The absolute tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
**kwargs : dict, optional
See :meth:`pyvista.Plotter.add_mesh`.
Returns
-------
Actor
The rendered actor added to the plotter scene.
Notes
-----
.. versionadded:: 0.1.0
"""
if resolution is None:
resolution = (
REGULAR_RESOLUTION if self.crs.is_projected else LFRIC_RESOLUTION
)
else:
resolution = str(resolution)
if self.crs.is_projected:
# pass through "zlevel" and "zscale" to the "add_mesh" method,
# but ignore "radius", as it's not applicable to planar projections
radius = None
kwargs["zlevel"] = -1 if zlevel is None else zlevel
kwargs["zscale"] = BASE_ZLEVEL_SCALE if zscale is None else zscale
else:
if radius is None:
radius = RADIUS
if zlevel is None:
zlevel = -1
if zscale is None:
zscale = BASE_ZLEVEL_SCALE
radius += radius * zlevel * zscale
if mesh is not None:
if radius is not None:
mesh = resize(mesh, radius=radius)
elif resolution.startswith("r"):
mesh = regular_grid(resolution=resolution, radius=radius)
else:
mesh = _lfric_mesh(resolution=resolution, radius=radius)
return self.add_mesh(mesh, rtol=rtol, atol=atol, **kwargs)
[docs]
def add_coastlines(
self,
resolution: str | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
rtol: float | None = None,
atol: float | None = None,
**kwargs: Any | None,
) -> pv.Actor:
"""Generate coastlines and add to the plotter scene.
Parameters
----------
resolution : str, optional
The resolution of the Natural Earth coastlines, which may be either
``110m``, ``50m``, or ``10m``. Defaults to
:data:`geovista.common.COASTLINES_RESOLUTION`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.common.ZTRANSFORM_FACTOR`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
rtol : float, optional
The relative tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
atol : float, optional
The absolute tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
**kwargs : dict, optional
See :meth:`pyvista.Plotter.add_mesh`.
Returns
-------
Actor
The rendered actor added to the plotter scene.
Notes
-----
.. versionadded:: 0.1.0
"""
if self.crs.is_projected:
# ignore "radius", as it's not applicable to planar projections
radius = None
if rtol is None:
rtol = COASTLINES_RTOL
if zscale is None:
zscale = ZLEVEL_SCALE
if zlevel is None:
zlevel = ZTRANSFORM_FACTOR
# pass through kwargs to "add_mesh" method
kwargs.update({"zlevel": zlevel, "zscale": zscale})
mesh = coastlines(
resolution=resolution, radius=radius, zlevel=zlevel, zscale=zscale
)
return self.add_mesh(mesh, rtol=rtol, atol=atol, **kwargs)
[docs]
def add_graticule(
self,
lon_start: float | None = None,
lon_stop: float | None = None,
lon_step: float | None = None,
lat_start: float | None = None,
lat_stop: float | None = None,
lat_step: float | None = None,
poles_parallel: bool | None = None,
poles_label: bool | None = None,
show_labels: bool | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
mesh_args: dict[Any, Any] | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Generate a graticule and add to the plotter scene.
This involves generating lines of constant latitude (parallels) and
lines of constant longitude (meridians), which together form the graticule.
Parameters
----------
lon_start : float, optional
The first line of longitude (degrees). The graticule will include this
meridian. Defaults to :data:`geovista.gridlines.LONGITUDE_START`.
lon_stop : float, optional
The last line of longitude (degrees). The graticule will include this
meridian when it is a multiple of `lon_step`. Also see
``closed_interval`` in :func:`~geovista.gridlines.create_meridians`.
Defaults to :data:`geovista.gridlines.LONGITUDE_STOP`.
lon_step : float, optional
The delta (degrees) between neighbouring meridians. Defaults to
:data:`geovista.gridlines.LONGITUDE_STEP`.
lat_start : float, optional
The first line of latitude (degrees). The graticule will include this
parallel. Also see `poles_parallel`. Defaults to
:data:`geovista.gridlines.LATITUDE_START`.
lat_stop : float, optional
The last line of latitude (degrees). The graticule will include this
parallel when it is a multiple of `lat_step`. Defaults to
:data:`geovista.gridlines.LATITUDE_STOP`.
lat_step : float, optional
The delta (degrees) between neighbouring parallels. Defaults to
:data:`geovista.gridlines.LATITUDE_STEP`.
poles_parallel : bool, optional
Whether to create a line of latitude at the north/south poles. Defaults to
:data:`geovista.gridlines.LATITUDE_POLES_PARALLEL`.
poles_label : bool, optional
Whether to create a single north/south pole label. Only applies when
``poles_parallel=False``. Defaults to
:data:`geovista.gridlines.LATITUDE_POLES_LABEL`.
show_labels : bool, optional
Whether to render the labels of the parallels and meridians. Defaults to
:data:`GRATICULE_SHOW_LABELS`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
mesh_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_mesh`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
self.add_meridians(
start=lon_start,
stop=lon_stop,
step=lon_step,
lat_step=lat_step,
show_labels=show_labels,
radius=radius,
zlevel=zlevel,
zscale=zscale,
mesh_args=mesh_args,
point_labels_args=point_labels_args,
)
self.add_parallels(
start=lat_start,
stop=lat_stop,
step=lat_step,
lon_step=lon_step,
poles_parallel=poles_parallel,
poles_label=poles_label,
show_labels=show_labels,
radius=radius,
zlevel=zlevel,
zscale=zscale,
mesh_args=mesh_args,
point_labels_args=point_labels_args,
)
[docs]
def add_mesh(
self,
mesh: Any,
radius: float | None = None,
zlevel: int | ArrayLike | None = None,
zscale: float | None = None,
rtol: float | None = None,
atol: float | None = None,
**kwargs: Any | None,
) -> pv.Actor:
"""Add the `mesh` to the plotter scene.
See :meth:`pyvista.Plotter.add_mesh`.
Parameters
----------
mesh : PolyData
The mesh to add to the plotter.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int or ArrayLike, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius`/vertical by a proportional amount e.g.,
``radius * zlevel * zscale``. If `zlevel` is not a scalar, then its shape
must match or broadcast with the shape of the ``mesh.points``.
Defaults to ``0``.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
rtol : float, optional
The relative tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
atol : float, optional
The absolute tolerance for longitudes close to the 'wrap meridian' -
see :func:`geovista.common.wrap` for more.
**kwargs : dict, optional
See :meth:`pyvista.Plotter.add_mesh`.
Returns
-------
Actor
The rendered actor added to the plotter scene.
Notes
-----
.. versionadded:: 0.1.0
"""
# assume this is a sane default
if os.environ.get("GEOVISTA_VTK_WARNINGS", "false").lower() == "false":
vtk_warnings_off()
if isinstance(mesh, pv.UnstructuredGrid):
mesh = cast(mesh)
if isinstance(mesh, pv.PolyData):
if zlevel is None:
zlevel = 0
if zscale is None:
zscale = ZLEVEL_SCALE
cloud = point_cloud(mesh)
if cloud and GV_FIELD_ZSCALE in mesh.field_data:
zscale = mesh[GV_FIELD_ZSCALE][0]
src_crs = from_wkt(mesh)
if src_crs is None:
wmsg = (
"geovista found no coordinate reference system (CRS) attached "
"to mesh."
)
warn(wmsg, stacklevel=2)
tgt_crs = self.crs
transform_required = src_crs and src_crs != tgt_crs
central_meridian = get_central_meridian(tgt_crs) or 0
if transform_required and not cloud:
if central_meridian:
mesh.rotate_z(-central_meridian, inplace=True)
tgt_crs = set_central_meridian(tgt_crs, 0)
# the sliced_mesh is guaranteed to be a new instance,
# even if not bisected
sliced_mesh = slice_mesh(mesh, rtol=rtol, atol=atol)
if central_meridian:
# undo rotation of original mesh
mesh.rotate_z(central_meridian, inplace=True)
mesh = sliced_mesh
if "texture" in kwargs and kwargs["texture"] is not None:
mesh = add_texture_coords(mesh, antimeridian=True)
texture = wrap_texture(
kwargs["texture"], central_meridian=central_meridian
)
kwargs["texture"] = texture
if transform_required:
mesh = transform_mesh(
mesh,
tgt_crs,
slice_connectivity=False,
rtol=rtol,
atol=atol,
zlevel=zlevel,
zscale=zscale,
inplace=not cloud,
)
to_wkt(mesh, self.crs)
elif not projected(mesh) and zlevel:
if not cloud:
radius = distance(mesh)
mesh = resize(mesh, radius=radius, zlevel=zlevel, zscale=zscale)
def _check(option: str) -> bool:
"""Determine whether the keyword argument is present and not ``None``.
Parameters
----------
option : str
Keyword argument to check.
Returns
-------
bool
Whether the keyword argument is present and not ``None``.
Notes
-----
.. versionadded:: 0.1.0
"""
return option in kwargs and kwargs[option] is not None
if not self._missing_opacity and (_check("opacity") or _check("nan_opacity")):
self._warn_opacity()
if hasattr(mesh, "center"):
# POI cartesian xyz
self._poi = mesh.center
return super().add_mesh(mesh, **kwargs)
[docs]
def add_meridian(
self,
lon: float,
lat_step: float | None = None,
n_samples: int | None = None,
show_labels: bool | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
mesh_args: dict[Any, Any] | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Generate a line of constant longitude and add to the plotter scene.
Parameters
----------
lon : float
The constant line of longitude (degrees) to generate.
lat_step : float, optional
The delta (degrees) between neighbouring parallels. Sets the
frequency of the labels. Defaults to
:data:`geovista.gridlines.LATITUDE_STEP`.
n_samples : int, optional
The number of points in a single line of longitude. Defaults to
:data:`geovista.gridlines.LONGITUDE_N_SAMPLES`.
show_labels : bool, optional
Whether to render the meridian label. Defaults to
:data:`GRATICULE_SHOW_LABELS`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
mesh_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_mesh`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
self.add_meridians(
start=lon,
stop=lon,
lat_step=lat_step,
n_samples=n_samples,
show_labels=show_labels,
radius=radius,
zlevel=zlevel,
zscale=zscale,
mesh_args=mesh_args,
point_labels_args=point_labels_args,
)
[docs]
def add_meridians(
self,
start: float | None = None,
stop: float | None = None,
step: float | None = None,
lat_step: float | None = None,
n_samples: int | None = None,
show_labels: bool | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
mesh_args: dict[Any, Any] | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Generate lines of constant longitude and add to the plotter scene.
Parameters
----------
start : float, optional
The first line of longitude (degrees). The graticule will include this
meridian. Defaults to :data:`geovista.gridlines.LONGITUDE_START`.
stop : float, optional
The last line of longitude (degrees). The graticule will include this
meridian when it is a multiple of `step`. Also see
``closed_interval`` in :func:`~geovista.gridlines.create_meridians`.
Defaults to :data:`geovista.gridlines.LONGITUDE_STOP`.
step : float, optional
The delta (degrees) between neighbouring meridians. Defaults to
:data:`geovista.gridlines.LONGITUDE_STEP`.
lat_step : float, optional
The delta (degrees) between neighbouring parallels. Sets the
frequency of the labels. Defaults to
:data:`geovista.gridlines.LATITUDE_STEP`.
n_samples : int, optional
The number of points in a single line of longitude. Defaults to
:data:`geovista.gridlines.LONGITUDE_N_SAMPLES`.
show_labels : bool, optional
Whether to render the labels of the meridians. Defaults to
:data:`GRATICULE_SHOW_LABELS`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
mesh_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_mesh`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
from . import GEOVISTA_IMAGE_TESTING
if show_labels is None:
show_labels = False if GEOVISTA_IMAGE_TESTING else GRATICULE_SHOW_LABELS
if zlevel is None:
zlevel = ZTRANSFORM_FACTOR if self.crs.is_projected else GRATICULE_ZLEVEL
if mesh_args is None:
mesh_args = {}
if point_labels_args is None:
point_labels_args = {}
closed_interval = self.crs.is_projected
central_meridian = get_central_meridian(self.crs)
meridians = create_meridians(
start=start,
stop=stop,
step=step,
lat_step=lat_step,
n_samples=n_samples,
closed_interval=closed_interval,
central_meridian=central_meridian,
radius=radius,
zlevel=zlevel,
zscale=zscale,
)
if radius is not None:
mesh_args["radius"] = radius
if zlevel is not None:
mesh_args["zlevel"] = zlevel
if zscale is not None:
mesh_args["zscale"] = zscale
for mesh in meridians.blocks:
self.add_mesh(mesh, **mesh_args)
if show_labels:
self._add_graticule_labels(
meridians,
radius=radius,
zlevel=zlevel,
zscale=zscale,
point_labels_args=point_labels_args,
)
[docs]
def add_parallel(
self,
lat: float,
lon_step: float | None = None,
n_samples: int | None = None,
poles_parallel: bool | None = None,
show_labels: bool | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
mesh_args: dict[Any, Any] | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Generate a line of constant latitude and add to the plotter scene.
Parameters
----------
lat : float
The constant line of latitude (degrees) to generate.
lon_step : float, optional
The delta (degrees) between neighbouring meridians. Sets the
frequency of the labels. Defaults to
:data:`geovista.gridlines.LONGITUDE_STEP`.
n_samples : int, optional
The number of points in a single line of latitude. Defaults to
:data:`geovista.gridlines.LATITUDE_N_SAMPLES`.
poles_parallel : bool, optional
Whether to create a line of latitude at the north/south poles. Defaults to
:data:`geovista.gridlines.LATITUDE_POLES_PARALLEL`.
show_labels : bool, optional
Whether to render the parallel label. Defaults to
:data:`GRATICULE_SHOW_LABELS`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
mesh_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_mesh`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
self.add_parallels(
start=lat,
stop=lat,
lon_step=lon_step,
n_samples=n_samples,
poles_parallel=poles_parallel,
show_labels=show_labels,
radius=radius,
zlevel=zlevel,
zscale=zscale,
mesh_args=mesh_args,
point_labels_args=point_labels_args,
)
[docs]
def add_parallels(
self,
start: float | None = None,
stop: float | None = None,
step: float | None = None,
lon_step: float | None = None,
n_samples: int | None = None,
poles_parallel: bool | None = None,
poles_label: bool | None = None,
show_labels: bool | None = None,
radius: float | None = None,
zlevel: int | None = None,
zscale: float | None = None,
mesh_args: dict[Any, Any] | None = None,
point_labels_args: dict[Any, Any] | None = None,
) -> None:
"""Generate lines of constant latitude and add to the plotter scene.
Parameters
----------
start : float, optional
The first line of latitude (degrees). The graticule will include this
parallel. Also see `poles_parallel`. Defaults to
:data:`geovista.gridlines.LATITUDE_START`.
stop : float, optional
The last line of latitude (degrees). The graticule will include this
parallel when it is a multiple of `step`. Also see `poles_parallel`.
Defaults to :data:`geovista.gridlines.LATITUDE_STOP`.
step : float, optional
The delta (degrees) between neighbouring parallels. Defaults to
:data:`geovista.gridlines.LATITUDE_STEP`.
lon_step : float, optional
The delta (degrees) between neighbouring meridians. Sets the
frequency of the labels. Defaults to
:data:`geovista.gridlines.LONGITUDE_STEP`.
n_samples : int, optional
The number of points in a single line of latitude. Defaults to
:data:`geovista.gridlines.LATITUDE_N_SAMPLES`.
poles_parallel : bool, optional
Whether to create a line of latitude at the north/south poles. Defaults to
:data:`geovista.gridlines.LATITUDE_POLES_PARALLEL`.
poles_label : bool, optional
Whether to create a single north/south pole label. Only applies when
``poles_parallel=False``. Defaults to
:data:`geovista.gridlines.LATITUDE_POLES_LABEL`.
show_labels : bool, optional
Whether to render the labels of the parallels. Defaults to
:data:`GRATICULE_SHOW_LABELS`.
radius : float, optional
The radius of the sphere. Defaults to :data:`geovista.common.RADIUS`.
zlevel : int, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
Defaults to :data:`geovista.gridlines.GRATICULE_ZLEVEL`.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
mesh_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_mesh`.
point_labels_args : dict, optional
Arguments to pass through to :meth:`pyvista.Plotter.add_point_labels`.
Notes
-----
.. versionadded:: 0.3.0
"""
from . import GEOVISTA_IMAGE_TESTING
if show_labels is None:
show_labels = False if GEOVISTA_IMAGE_TESTING else GRATICULE_SHOW_LABELS
if zlevel is None:
zlevel = ZTRANSFORM_FACTOR if self.crs.is_projected else GRATICULE_ZLEVEL
if mesh_args is None:
mesh_args = {}
if point_labels_args is None:
point_labels_args = {}
# TODO @bjlittle: Fix behaviour of longitudes at poles.
poles_parallel = False
parallels = create_parallels(
start=start,
stop=stop,
step=step,
lon_step=lon_step,
n_samples=n_samples,
poles_parallel=poles_parallel,
poles_label=poles_label,
radius=radius,
zlevel=zlevel,
zscale=zscale,
)
if radius is not None:
mesh_args["radius"] = radius
if zlevel is not None:
mesh_args["zlevel"] = zlevel
if zscale is not None:
mesh_args["zscale"] = zscale
for mesh in parallels.blocks:
self.add_mesh(mesh, **mesh_args)
if show_labels:
self._add_graticule_labels(
parallels,
radius=radius,
zlevel=zlevel,
zscale=zscale,
point_labels_args=point_labels_args,
)
[docs]
def add_points(
self,
points: ArrayLike | pv.PolyData | None = None,
xs: ArrayLike | None = None,
ys: ArrayLike | None = None,
scalars: str | ArrayLike | None = None,
crs: CRSLike | None = None,
radius: float | None = None,
style: str | None = None,
zlevel: int | ArrayLike | None = None,
zscale: float | None = None,
**kwargs: Any | None,
) -> pv.Actor:
"""Add points to the plotter scene.
See :meth:`pyvista.Plotter.add_mesh`.
Parameters
----------
points : ArrayLike or PolyData, optional
Array of xyz points, or the points of the mesh to be rendered.
Passed to :meth:`pyvista.core.utilities.helpers.wrap` without any
cartographic transformation, i.e. ``0 0 0`` is centre of the globe
(the plot origin), ``0 0 1`` is the north pole.
xs : ArrayLike, optional
A 1-D, 2-D or 3-D array of point-cloud x-values, in canonical `crs` units.
Must have the same shape as the `ys`.
ys : ArrayLike
A 1-D, 2-D or 3-D array of point-cloud y-values, in canonical `crs` units.
Must have the same shape as the `xs`.
scalars : str or ArrayLike, optional
Values used to color the points. Either, the string name of an array that is
present on the `points` mesh or an array equal to the number of points.
If both `color` (see `kwargs`) and `scalars` are ``None``, then the active
scalars on the `points` mesh are used.
crs : CRSLike, optional
The Coordinate Reference System of the provided `points`, or `xs` and `ys`.
May be anything accepted by :meth:`pyproj.crs.CRS.from_user_input`. Defaults
to ``EPSG:4326`` i.e., ``WGS 84``.
radius : float, optional
The radius of the mesh point-cloud. Defaults to
:data:`geovista.common.RADIUS`.
style : str, optional
Visualization style of the points to be rendered. May be either ``points``
or ``points_gaussian``. The ``points_gaussian`` option may be controlled
with the ``emissive`` and ``render_points_as_spheres`` options in
`kwargs`.
zlevel : int or ArrayLike, optional
The z-axis level. Used in combination with the `zscale` to offset the
`radius` by a proportional amount i.e., ``radius * zlevel * zscale``.
If `zlevel` is not a scalar, then its shape must match or broadcast
with the shape of the `xs` and `ys`. Defaults to ``0``.
zscale : float, optional
The proportional multiplier for z-axis `zlevel`. Defaults to
:data:`geovista.common.ZLEVEL_SCALE`.
**kwargs : dict, optional
See :meth:`pyvista.Plotter.add_mesh`.
Returns
-------
Actor
The rendered actor added to the plotter scene.
Notes
-----
.. versionadded:: 0.4.0
"""
if crs is not None:
# sanity check the source crs
crs = pyproj.CRS.from_user_input(crs)
if style is None:
style = "points"
if style not in ADD_POINTS_STYLE:
options = "or ".join(f"{option!r}" for option in ADD_POINTS_STYLE)
emsg = (
f"Invalid 'style' for 'add_points', expected {options}, "
f"got {style!r}."
)
raise ValueError(emsg)
if points is None and xs is None and ys is None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got neither."
)
raise ValueError(emsg)
if points is not None and xs is not None and ys is not None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got both 'points', and 'xs' and 'ys'."
)
raise ValueError(emsg)
if points is not None:
if xs is not None or ys is not None:
emsg = (
"Require either 'points' or both 'xs' and 'ys' to be specified, "
"got both 'points', and 'xs' or 'ys'."
)
raise ValueError(emsg)
if not pv.core.utilities.helpers.is_pyvista_dataset(points):
points = pv.core.utilities.helpers.wrap(points)
mesh = points
if crs is not None:
if has_wkt(mesh):
other = from_wkt(mesh)
if other != crs:
emsg = (
"The CRS serialized as WKT on the 'points' mesh does not "
"match the provided 'crs'."
)
raise ValueError(emsg)
else:
# serialize the provided CRS on the points mesh as wkt
to_wkt(mesh, crs)
elif not has_wkt(mesh):
# assume CRS is WGS84
to_wkt(mesh, WGS84)
else:
if xs is None or ys is None:
emsg = (
"Require either 'points', or both 'xs' and 'ys' to be specified, "
"got only 'xs' or 'ys'."
)
raise ValueError(emsg)
if isinstance(scalars, str):
wmsg = (
f"geovista ignoring the 'scalars' string name '{scalars}', as no "
"'points' mesh was provided."
)
warn(wmsg, stacklevel=2)
mesh = Transform.from_points(
xs=xs,
ys=ys,
crs=crs,
radius=radius,
zlevel=zlevel,
zscale=zscale,
)
# defensive kwarg pop
if "texture" in kwargs:
_ = kwargs.pop("texture")
return self.add_mesh(mesh, style=style, scalars=scalars, **kwargs)
[docs]
def view_poi(
self,
x: float | None = None,
y: float | None = None,
crs: CRSLike | None = None,
) -> None:
"""Center the camera at a point-of-interest (POI).
If no POI is provided, then the center of the previous mesh added to the scene
is used, if available.
Note that, the camera will be positioned at the POI to view **all** the actors
added to the scene so far; order is important. The point at which
:meth:`~geovista.geoplotter.GeoPlotterBase.view_poi` is called relative to the
other actors influences the final rendered scene.
Parameters
----------
x : float, optional
The spatial x-value point, in canonical `crs` units, of the POI.
Defaults to ``0`` if unspecified and `y` has been provided,
otherwise ``None``.
y : float, optional
The spatial y-value point, in canonical `crs` units, of the POI.
Defaults to ``0`` if unspecified and `x` has been provided,
otherwise ``None``.
crs : CRSLike, optional
The Coordinate Reference System of the POI. May be anything accepted by
:meth:`pyproj.crs.CRS.from_user_input`. Defaults to ``EPSG:4326`` i.e.,
``WGS 84``.
Notes
-----
.. versionadded:: 0.5.0
Examples
--------
First, create an RGB mesh of the Bahamas from a GeoTIFF.
>>> import geovista
>>> from geovista.pantry import fetch_raster
>>> fname = fetch_raster("bahamas_rgb.tif")
>>> bahamas = geovista.Transform.from_tiff(
... fname, rgb=True, sieve=True, extract=True
... )
Now add the ``bahamas`` mesh to a ``plotter`` **before** adding a texture mapped
base layer. Note that, the camera is centered over the ``bahamas`` mesh, which
is the primary focus of the scene.
>>> plotter = geovista.GeoPlotter()
>>> _ = plotter.add_mesh(bahamas, rgb=True)
>>> plotter.view_poi()
>>> _ = plotter.add_base_layer(texture=geovista.natural_earth_1())
>>> plotter.show()
In comparison, add a texture mapped base layer to a ``plotter`` **before** the
``bahamas`` mesh. The camera is still centered over the ``bahamas`` in the
rendered scene, however the base layer is now fully visible.
>>> plotter = geovista.GeoPlotter()
>>> _ = plotter.add_base_layer(texture=geovista.natural_earth_1())
>>> _ = plotter.add_mesh(bahamas, rgb=True)
>>> plotter.view_poi()
>>> plotter.show()
"""
camera = self.camera
if crs is None:
crs = WGS84
use_mesh_poi = x is None and y is None
if not use_mesh_poi:
# at the very least x or y has been provided
# default any unset to 0, which allows the API to support the
# convenience "view_poi(x=30)" instead of "view_poi(x=30, y=0)"
if x is None:
x = 0
if y is None:
y = 0
if use_mesh_poi:
if self._poi is None:
# this is a no-op
wmsg = "No point-of-interest (POI) is available or has been provided."
warn(wmsg, stacklevel=2)
return
if self.crs.is_geographic:
# convert cartesian xyz to lon/lat
x, y = to_lonlat(self._poi)
crs = WGS84
else:
x, y, _ = self._poi
crs = self.crs
if crs != self.crs:
x, y, _ = transform_point(crs, self.crs, x, y)
if self.crs.is_geographic:
camera.focal_point = (0, 0, 0)
# convert POI lon/lat to cartesian xyz
xyz = to_cartesian(x, y)[0]
# calculate the unit vector
u_hat = xyz / np.linalg.norm(xyz)
# set the new camera position at the same magnitude from the focal point
camera.position = u_hat * np.linalg.norm(camera.position)
self.reset_camera(render=False)
clip = camera.clipping_range
# defensive: extend far clip range to ensure no accidental
# back culling of any potential forth-coming actors that
# may be added to the scene
camera.clipping_range = (clip[0], clip[1] * 2)
else:
position = np.array(camera.position)
focal_point = np.array(camera.focal_point)
magnitude = np.linalg.norm(focal_point - position)
camera.up = (0, 1, 0)
camera.focal_point = (x, y, 0)
camera.position = (x, y, magnitude)
[docs]
class GeoPlotter(GeoPlotterBase, pv.Plotter):
"""A geospatial aware plotter.
See :class:`geovista.geoplotter.GeoPlotterBase` and
:class:`pyvista.Plotter`.
Notes
-----
.. versionadded:: 0.1.0
"""