# 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 geodesic operators for geolocated meshes.
Notes
-----
.. versionadded:: 0.1.0
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import warnings
import lazy_loader as lazy
from .common import (
GV_FIELD_RADIUS,
GV_MANIFOLD_CELL_IDS,
RADIUS,
ZLEVEL_SCALE,
StrEnumPlus,
distance,
to_cartesian,
wrap,
)
from .common import cast_UnstructuredGrid_to_PolyData as cast
from .crs import WGS84, CRSLike, from_wkt, to_wkt
from .transform import transform_mesh, transform_points
if TYPE_CHECKING:
from collections.abc import Sequence
import numpy as np
from numpy.typing import ArrayLike
import pyproj
import pyvista as pv
# lazy import third-party dependencies
np = lazy.load("numpy")
pyproj = lazy.load("pyproj")
pv = lazy.load("pyvista")
__all__ = [
"BBOX_C",
"BBOX_OUTSIDE",
"BBOX_RADIUS_RATIO",
"BBOX_TOLERANCE",
"ELLIPSE",
"GEODESIC_NPTS",
"N_PANELS",
"PANEL_BBOX_BY_IDX",
"PANEL_IDX_BY_NAME",
"PANEL_NAMES",
"PANEL_NAME_BY_IDX",
"PREFERENCE",
"BBox",
"Corners",
"EnclosedPreference",
"line",
"npoints",
"npoints_by_idx",
"panel",
"wedge",
]
# this is a type alias
type Corners = tuple[float, float, float, float]
"""Type alias for the corners of a bounding-box."""
# constants
ELLIPSE: str = "WGS84"
"""Default geodesic ellipse. See :func:`pyproj.list.get_ellps_map`."""
GEODESIC_NPTS: int = 64
"""Number of equally spaced geodesic points between/including end-point/s."""
BBOX_C: int = 256
"""The bounding-box face geometry will contain ``BBOX_C**2`` cells."""
BBOX_OUTSIDE: bool = False
"""Preference for selecting sample points outside/inside bounding-box."""
BBOX_RADIUS_RATIO: float = 1e-1
"""Ratio the bounding-box inner and outer faces are offset from the surface mesh."""
BBOX_TOLERANCE: float = 1e-6
"""The bounding-box tolerance on intersection."""
PANEL_IDX_BY_NAME: dict[str, int] = {
"africa": 0,
"asia": 1,
"pacific": 2,
"americas": 3,
"arctic": 4,
"antarctic": 5,
}
"""Lookup table for cubed-sphere panel index by panel name."""
PANEL_NAME_BY_IDX: dict[int, str] = {
0: "africa",
1: "asia",
2: "pacific",
3: "americas",
4: "arctic",
5: "antarctic",
}
"""Lookup table for cubed-sphere panel name by panel index."""
PANEL_NAMES: list[str] = list(PANEL_IDX_BY_NAME.keys())
"""Cubed-sphere panel names."""
CSC: float = np.rad2deg(np.arcsin(1 / np.sqrt(3)))
"""Latitude (degrees) of a cubed-sphere panel corner."""
PANEL_BBOX_BY_IDX: dict[int, tuple[Corners, Corners]] = {
0: ((-45, 45, 45, -45), (CSC, CSC, -CSC, -CSC)),
1: ((45, 135, 135, 45), (CSC, CSC, -CSC, -CSC)),
2: ((135, -135, -135, 135), (CSC, CSC, -CSC, -CSC)),
3: ((-135, -45, -45, -135), (CSC, CSC, -CSC, -CSC)),
4: ((-45, 45, 135, -135), (CSC, CSC, CSC, CSC)),
5: ((-45, 45, 135, -135), (-CSC, -CSC, -CSC, -CSC)),
}
"""Cubed-sphere panel bounding-box longitudes and latitudes."""
N_PANELS: int = len(PANEL_IDX_BY_NAME)
"""The number of cubed-sphere panels."""
PREFERENCE: str = "center"
"""The default bounding-box preference."""
[ドキュメント]
class EnclosedPreference(StrEnumPlus):
"""Enumeration of mesh geometry enclosed preferences.
Notes
-----
.. versionadded:: 0.3.0
"""
CELL = "cell"
"""Enclosed if all cell vertices within bounding-box manifold."""
CENTER = "center"
"""Enclosed if cell center within bounding-box manifold."""
POINT = "point"
"""Enclosed if at least one cell vertex within bounding-box manifold."""
[ドキュメント]
class BBox: # numpydoc ignore=PR01
"""A 3D bounding-box constructed from geodesic lines or great circles."""
def __init__(
self,
xs: ArrayLike,
ys: ArrayLike,
*,
crs: CRSLike | None = WGS84,
ellps: str | None = ELLIPSE,
c: int = BBOX_C,
triangulate: bool | None = False,
) -> None:
"""Create 3D geodesic bounding-box to extract enclosed mesh, lines or point.
The bounding-box region is specified in terms of its four corners using
coordinates defined by the projection `crs` (defaults to
:data:`geovista.crs.WGS84`). As the bounding-box is a geodesic
composed of great-circle lines, it can only ever at most enclose
half of an ellipsoid.
The geometry of the bounding-box may be specified as either an open or
closed point geometry i.e., 4 or 5 corner values.
Parameters
----------
xs : ArrayLike
The x-coordinates of the bounding-box in canonical `crs` units.
Note that longitudes will be wrapped to the half-closed interval
``[-180, 180)``.
ys : ArrayLike
The y-coordinates of the bounding-box in canonical `crs` units.
Note that latitudes are in the closed interval ``[-90, 90]``.
crs : str, optional
The Coordinate Reference System of the provided `xs` and `ys`.
Defaults to :data:`geovista.crs.WGS84`.
ellps : str, optional
The ellipsoid for geodesic calculations. See
:func:`pyproj.list.get_ellps_map`. Defaults to :data:`ELLIPSE`.
c : float, optional
The bounding-box face geometry will contain ``c**2`` cells.
Defaults to :data:`BBOX_C`.
triangulate : bool, optional
Specify whether the bounding-box faces are triangulated. Defaults to
``False``.
Notes
-----
.. versionadded:: 0.1.0
"""
xs = np.atleast_1d(xs)
ys = np.atleast_1d(ys)
nx, ny = xs.size, ys.size
if nx != ny:
emsg = f"Require the same number of x points ({nx}) and y points ({ny})."
raise ValueError(emsg)
if nx < 4:
emsg = (
"Require a bounding-box geometry containing at least 4 "
"x/y values to create the bounding-box manifold, "
f"got '{nx}'."
)
raise ValueError(emsg)
if nx > 5:
emsg = (
"Require a bounding-box geometry containing 4 (open) or 5 (closed) "
"x/y values to create the bounding-box manifold, "
f"got {nx}."
)
raise ValueError(emsg)
# ensure the specified bbox geometry is open
if nx == 5:
if not (np.isclose(xs[0], xs[-1]) and np.isclose(ys[0], ys[-1])):
wmsg = (
"geovista bounding-box was specified with 5 x/y "
"values, however the first and last values are not close enough to "
"specify a closed geometry - ignoring last value."
)
warnings.warn(wmsg, stacklevel=2)
xs, ys = xs[:-1], ys[:-1]
self.xs = xs
"""The x points of the bounding-box in canonical CRS units."""
self.ys = ys
"""The y points of the bounding-box in canonical CRS units."""
self.crs = crs
"""The coordinate reference system of the bounding-box points."""
self.ellps = ellps
"""The ellipsoid defining the geodesic surface."""
self.c = c
"""The number of cells that define the bounding-box face geometry i.e., c^2."""
self.triangulate = triangulate
"""Whether the bounding-box faces are triangulated."""
# the resultant bounding-box mesh
self._mesh: pv.PolyData | None = None
# the bounding-box mesh edges
self._outline: pv.PolyData | None = None
# enclosed preference for points outside/inside manifold
self._outside = BBOX_OUTSIDE
# enclosed cell preference
self._preference = EnclosedPreference(PREFERENCE)
# cache prior surface radius, as an optimisation
self._surface_radius: float | None = None
# enclosed tolerance of intersection operation
self._tolerance = BBOX_TOLERANCE
if self.crs != WGS84:
latlon = transform_points(
xs=self.xs, ys=self.ys, src_crs=self.crs, tgt_crs=WGS84
)
self.lons = latlon[:, 0]
self.lats = latlon[:, 1]
else:
self.lons = xs
self.lats = ys
[ドキュメント]
def __call__(self, surface: pv.PolyData) -> pv.PolyData:
"""Extract region of the `surface` contained within the bounding-box.
Parameters
----------
surface : PolyData
The :class:`~pyvista.PolyData` mesh to be checked for containment.
Returns
-------
PolyData
The :class:`~pyvista.PolyData` representing those parts of the
provided `surface` enclosed by the bounding-box.
See Also
--------
enclosed : Equivalent method.
outside : Property that selects points inside/outside manifold.
preference : Property that specifies criterion for `surface` cell inclusion.
tolerance : Property that controls manifold intersection tolerance.
Notes
-----
.. versionadded:: 0.6.0
"""
return self.enclosed(surface)
[ドキュメント]
def __eq__(self, other: object) -> bool:
"""Perform an equality comparison with the `other` operand.
Parameters
----------
other : object
Equality comparison performed with this operand.
Returns
-------
bool
Equality comparison result.
Notes
-----
.. versionadded:: 0.1.0
"""
result: bool = NotImplemented
if isinstance(other, BBox):
result = False
lhs = (self.ellps, self.c, self.triangulate)
rhs = (other.ellps, other.c, other.triangulate)
if all(x[0] == x[1] for x in zip(lhs, rhs, strict=True)) and np.allclose(
self.lons, other.lons
):
result = np.allclose(self.lats, other.lats)
return result
[ドキュメント]
def __hash__(self) -> int:
"""Support hash-based collections.
Returns
-------
int
The computed :func:`hash` value of the instance.
Notes
-----
.. versionadded:: 0.6.0
"""
return hash(
(
self.crs,
self.ellps,
self.c,
self.triangulate,
self.xs.tobytes(),
self.ys.tobytes(),
)
)
[ドキュメント]
def __ne__(self, other: object) -> bool:
"""Perform an inequality comparison with the `other` operand.
Parameters
----------
other : object
Inequality comparison performed with this operand.
Returns
-------
bool
Inequality comparison result.
Notes
-----
.. versionadded:: 0.1.0
"""
result = self == other
if result is not NotImplemented:
result = not result
return result
[ドキュメント]
def __repr__(self) -> str:
"""Serialize :class:`BBox` representation.
Returns
-------
str
String representation of the instance.
Notes
-----
.. versionadded:: 0.1.0
"""
params = (
f"crs={self.crs}, ellps={self.ellps}, c={self.c}, "
f"n_points={self.mesh.n_points}, n_cells={self.mesh.n_cells}"
)
return f"{__package__}.{self.__class__.__name__}<{params}>"
@property
def mesh(self) -> pv.PolyData:
"""The manifold bounding-box mesh.
Returns
-------
PolyData
The bounding-box mesh.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add a ``C32`` bounding-box around the Gulf of Guinea.
The geodesic bounding-box is generated by defining 4 longitude-latitude
corners. Natural Earth coastlines are also rendered along
with a texture mapped Natural Earth base layer.
>>> import geovista
>>> from geovista.geodesic import BBox
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(
... texture=geovista.natural_earth_hypsometric(), opacity=0.5
... )
>>> bbox = BBox(xs=[-15, 20, 25, -15], ys=[-25, -20, 15, 10], c=32)
>>> _ = p.add_mesh(bbox.mesh, color="white")
>>> p.camera.zoom(1.5)
>>> p.show()
"""
if self._mesh is None:
self._generate_bbox_mesh()
return self._mesh
@property
def outline(self) -> pv.PolyData:
"""The manifold bounding-box mesh edges.
Returns
-------
PolyData
The bounding-box mesh edges.
Notes
-----
.. versionadded:: 0.6.0
Examples
--------
Add the geodesic bounding-box manifold for the Arctic cubed-sphere
panel along with its edges. A texture mapped Natural Earth base layer
is also rendered.
>>> import geovista
>>> from geovista.geodesic import panel
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.natural_earth_1(), opacity=0.5)
>>> bbox = panel("arctic")
>>> _ = p.add_mesh(bbox.mesh, color="orange")
>>> _ = p.add_mesh(bbox.outline, color="yellow", line_width=3)
>>> p.view_vector(vector=(1, 1, 0))
>>> p.camera.zoom(1.5)
>>> p.show()
"""
if self._mesh is None:
self._generate_bbox_mesh()
if self._outline is None:
self._outline = self.mesh.extract_feature_edges()
return self._outline
@property
def outside(self) -> bool:
"""The preference to select points outside/inside the bounding-box.
Returns
-------
bool
Manifold bounding-box selection preference.
Notes
-----
.. versionadded:: 0.6.0
"""
return self._outside
@outside.setter
def outside(self, value: bool | None) -> None:
"""Set preference to select points outside/inside the bounding-box.
Parameters
----------
value : bool
Whether to select points outside/inside the bounding-box.
Notes
-----
.. versionadded:: 0.6.0
"""
if value is not None:
self._outside = bool(value)
@property
def preference(self) -> EnclosedPreference:
"""The criterion for cell containment within the bounding-box.
Returns
-------
EnclosedPreference
The bounding-box enclosed preference.
Notes
-----
.. versionadded:: 0.6.0
"""
return self._preference
@preference.setter
def preference(self, value: str | EnclosedPreference | None) -> None:
"""Set the criterion for cell containment within the bounding-box.
Parameters
----------
value : str or EnclosedPreference
The bounding-box enclosed preference for cell membership.
Notes
-----
.. versionadded:: 0.6.0
"""
if value is not None:
if not EnclosedPreference.valid(value):
options = " or ".join(
f"{item!r}" for item in EnclosedPreference.values()
)
emsg = f"Expected a preference of {options}, got '{value}'."
raise ValueError(emsg)
self._preference = EnclosedPreference(value)
@property
def tolerance(self) -> float:
"""The tolerance of the bounding-box manifold intersection.
See :meth:`~pyvista.DataSetFilters.select_interior_points` for further
details.
Returns
-------
float
The bounding-box manifold intersection tolerance.
Notes
-----
.. versionadded:: 0.6.0
"""
return self._tolerance
@tolerance.setter
def tolerance(self, value: float | None) -> None:
"""Set the tolerance of the bounding-box manifold intersection.
Parameters
----------
value : float
The bounding-box manifold intersection tolerance.
Notes
-----
.. versionadded:: 0.6.0
"""
if value is not None:
self._tolerance = float(value)
def _init(self) -> None:
"""Bootstrap the bounding-box state.
Notes
-----
.. versionadded:: 0.1.0
"""
self._idx_map = np.empty((self.c + 1, self.c + 1), dtype=int)
self._bbox_lons: list[float] = []
self._bbox_lats: list[float] = []
self._bbox_count = 0
self._geod = pyproj.Geod(ellps=self.ellps)
self._npts = self.c - 1
self._n_faces = self.c * self.c
self._n_points = (self.c + 1) * (self.c + 1)
def _bbox_face_edge_idxs(self) -> np.ndarray:
"""Get the bounding-box outer edge indices.
Inspects the index map (_idx_map) topology to determine the sequence
of indices that define the bounding-box edge/boundary. This sequence of
indices is open i.e., it's implied that the last index is connected
to the first in the sequence.
The indices (_idx_map) reference the actual longitude/latitude
values (_bbox_lons/_bbox_lats). This state is configured when
generating the bounding-box face (_generate_bbox_face).
Returns
-------
ndarray
The indices of the bounding-box edge.
Notes
-----
.. versionadded:: 0.1.0
"""
return np.concatenate(
[
self._idx_map[0],
self._idx_map[1:, -1],
self._idx_map[-1, -2::-1],
self._idx_map[-2:0:-1, 0],
]
)
def _generate_bbox_face(self) -> None:
"""Construct 2D geodetic bounding-box surface defined by corners.
Given the longitude/latitude corners of the bounding-box and the number
of faces that define the bounding-box mesh i.e., c**2, determine all
the associated geodesic points (geometry) and indices (topology) of the
bounding-box mesh.
The indices (_idx_map) reference the longitude/latitude points
(_bbox_lon/_bbox_lat), and together are required to create the
resultant bounding-box :class:`~pyvista.PolyData` mesh.
Notes
-----
.. versionadded:: 0.1.0
"""
# corner indices
c1_idx, c2_idx, c3_idx, c4_idx = range(4)
def bbox_extend(lons: Sequence[float], lats: Sequence[float]) -> None:
"""Register the bounding box longitudes and latitudes.
Parameters
----------
lons : iterable of float
The bounding box longitudes.
lats : iterable of float
The bounding box latitudes.
"""
assert len(lons) == len(lats)
self._bbox_lons.extend(lons)
self._bbox_lats.extend(lats)
self._bbox_count += len(lons)
def bbox_update(
idx1: int, idx2: int, row: int | None = None, column: int | None = None
) -> None:
"""Generate and register a sampled geodesic bounding box line.
The line is either a row or column that is a bounding box edge, or
a row or column that traverses across the surface of the bounding box.
Parameters
----------
idx1 : int
The start index of the bounding box, located on an edge.
idx2 : int
The end index of the bounding box, located on an edge.
row : int, optional
The index of the bounding box row. If not provided, the column spans
all rows.
column : int, optional
The index of the bounding box column. If not provided, the row spans
all columns.
"""
assert row is not None or column is not None
row_slice = np.s_[:] if row is None else np.s_[row]
column_slice = np.s_[:] if column is None else np.s_[column]
glons, glats = npoints_by_idx(
self._bbox_lons,
self._bbox_lats,
start_idx=idx1,
end_idx=idx2,
npts=self._npts,
geod=self._geod,
)
self._idx_map[row_slice, column_slice] = [
idx1,
*(np.arange(self._npts) + self._bbox_count),
idx2,
]
bbox_extend(glons, glats)
# register bbox edge indices, and points
bbox_extend(self.lons, self.lats)
bbox_update(c1_idx, c2_idx, row=0)
bbox_update(c4_idx, c3_idx, row=-1)
bbox_update(c1_idx, c4_idx, column=0)
bbox_update(c2_idx, c3_idx, column=-1)
# register bbox inner indices and points
for row_idx in range(1, self.c):
row_idx_map = self._idx_map[row_idx]
bbox_update(row_idx_map[0], row_idx_map[-1], row=row_idx)
def _generate_bbox_mesh(
self, surface: pv.PolyData | None = None, *, radius: float | None = None
) -> None:
"""Construct 3D geodetic bounding-box extruded surface defined by corners.
The bounding-box mesh consists of an inner surface, an outer surface,
and a skirt that joins these two surfaces together to create a mesh
that is a manifold.
The purpose of the bounding-box is to determine which faces/points of
a third-party mesh are contained/enclosed within the bounding-box
manifold.
Note that the bounding-box inner surface and outer surface are
congruent.
Parameters
----------
surface : PolyData, optional
The surface that the bounding-box will be enclosing.
radius : float, optional
The radius of the surface that the bounding-box will be enclosing.
Note that the `radius` is only used when the `surface` is not
provided. Defaults to :data:`geovista.common.RADIUS`.
Notes
-----
.. versionadded:: 0.1.0
"""
if surface is not None:
radius = distance(surface)
radius = RADIUS if radius is None else abs(float(radius))
if radius != self._surface_radius:
self._init()
self._surface_radius = radius
self._generate_bbox_face()
skirt_faces = self._generate_bbox_skirt()
# generate the face indices
bbox_n_faces = self._n_faces * 2
faces_n = np.broadcast_to(np.array([4], dtype=np.int8), (bbox_n_faces, 1))
faces_c1 = np.ravel(self._idx_map[: self.c, : self.c]).reshape(-1, 1)
faces_c2 = np.ravel(self._idx_map[: self.c, 1:]).reshape(-1, 1)
faces_c3 = np.ravel(self._idx_map[1:, 1:]).reshape(-1, 1)
faces_c4 = np.ravel(self._idx_map[1:, : self.c]).reshape(-1, 1)
inner_faces = np.hstack([faces_c1, faces_c2, faces_c3, faces_c4])
outer_faces = inner_faces + self._n_points
faces = np.vstack([inner_faces, outer_faces])
bbox_faces = np.hstack([faces_n, faces])
# convert bbox lons/lats to ndarray (internal convenience i.e., boundary)
self._bbox_lons = np.asanyarray(self._bbox_lons)
self._bbox_lats = np.asanyarray(self._bbox_lats)
# calculate the radii of the inner and outer bbox faces
offset = self._surface_radius * BBOX_RADIUS_RATIO
inner_radius = self._surface_radius - offset
outer_radius = self._surface_radius + offset
# generate the face points
inner_xyz = to_cartesian(
self._bbox_lons, self._bbox_lats, radius=inner_radius
)
outer_xyz = to_cartesian(
self._bbox_lons, self._bbox_lats, radius=outer_radius
)
bbox_xyz = np.vstack([inner_xyz, outer_xyz])
# include the bbox skirt
bbox_faces = np.vstack([bbox_faces, skirt_faces])
# create the bbox mesh
self._mesh = pv.PolyData(bbox_xyz, faces=bbox_faces)
self._mesh.field_data[GV_FIELD_RADIUS] = np.array([radius])
to_wkt(self._mesh, WGS84)
if self.triangulate:
self._mesh = self._mesh.triangulate()
def _generate_bbox_skirt(self) -> np.ndarray:
"""Calculate indices of faces for boundary-box skirt.
Determine the indices of the skirt that will join the inner and outer
bounding-box surfaces to create a "water-tight" manifold.
Returns
-------
ndarray
The indices of the bounding-box skirt.
Notes
-----
.. verseionadded:: 0.1.0
"""
skirt_n_faces = 4 * self.c
faces_n = np.broadcast_to(np.array([4], dtype=np.int8), (skirt_n_faces, 1))
faces_c1 = self._bbox_face_edge_idxs().reshape(-1, 1)
faces_c2 = np.roll(faces_c1, -1)
faces_c3 = faces_c2 + self._n_points
faces_c4 = np.roll(faces_c3, 1)
return np.hstack([faces_n, faces_c1, faces_c2, faces_c3, faces_c4])
[ドキュメント]
def boundary(
self, surface: pv.PolyData | None = None, *, radius: float | None = None
) -> pv.PolyData:
"""Footprint of bounding-box intersecting on the provided mesh surface.
The region of the bounding-box that intersects on the surface of the mesh
that will be enclosed.
Parameters
----------
surface : PolyData, optional
The :class:`~pyvista.PolyData` mesh that will be enclosed by the
bounding-box boundary.
radius : float, optional
The radius of the spherical mesh that will be enclosed by the
bounding-box
boundary. Note that the `radius` is only used when the `surface`
is not provided. Defaults to :data:`geovista.common.RADIUS`.
Returns
-------
PolyData
The boundary of the bounding-box.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add the boundary of a ``C32`` bounding-box to the plotter for a region
around the Gulf of Guinea. The geodesic bounding-box is generated by
defining 4 longitude-latitude corners.
The boundary is generated from where the bounding-box intersects with the
surface of the C48 Sea Surface Temperature (SST) cubed-sphere mesh.
>>> import geovista
>>> from geovista.geodesic import BBox
>>> from geovista.pantry.meshes import lfric_sst
>>> p = geovista.GeoPlotter()
>>> mesh = lfric_sst()
>>> _ = p.add_mesh(mesh, cmap="balance")
>>> bbox = BBox(xs=[-15, 20, 25, -15], ys=[-25, -20, 15, 10], c=32)
>>> _ = p.add_mesh(bbox.boundary(mesh), color="orange", line_width=3)
>>> p.view_yz()
>>> p.show()
"""
self._generate_bbox_mesh(surface=surface, radius=radius)
assert isinstance(self._surface_radius, float)
radius = self._surface_radius + self._surface_radius * ZLEVEL_SCALE
edge_idxs = self._bbox_face_edge_idxs()
edge_lons = self._bbox_lons[edge_idxs]
edge_lats = self._bbox_lats[edge_idxs]
edge_xyz = to_cartesian(edge_lons, edge_lats, radius=radius)
edge = pv.lines_from_points(edge_xyz, close=True)
edge.field_data[GV_FIELD_RADIUS] = np.array([radius])
to_wkt(edge, WGS84)
return edge
[ドキュメント]
def enclosed(
self,
surface: pv.PolyData,
/,
*,
tolerance: float | None = None,
outside: bool | None = None,
preference: str | EnclosedPreference | None = None,
) -> pv.PolyData:
"""Extract region of the `surface` contained within the bounding-box.
Note that points that are on the surface of the bounding-box manifold are not
considered within the bounding-box. See the `preference` and `tolerance`
options.
Parameters
----------
surface : PolyData
The :class:`~pyvista.PolyData` mesh to be checked for containment.
tolerance : float, optional
The tolerance on the intersection operation with the `surface`,
expressed as a fraction of the diagonal of the bounding-box.
See :meth:`~pyvista.DataSetFilters.select_interior_points` for
further details. Defaults to :data:`BBOX_TOLERANCE`.
outside : bool, optional
By default, select those points of the `surface` that are inside
the bounding-box. Otherwise, select those points that are outside
the bounding-box. Defaults to :data:`BBOX_OUTSIDE`.
preference : str or EnclosedPreference, optional
Criteria for defining whether a face of a `surface` mesh is
deemed to be enclosed by the bounding-box. A `preference` of
``cell`` requires all points defining the face to be within the
bounding-box. A `preference` of ``center`` requires that only the
face cell center is within the bounding-box. A `preference` of
``point`` requires at least one point that defines the face to be
within the bounding-box. Defaults to :data:`PREFERENCE`.
Returns
-------
PolyData
The :class:`~pyvista.PolyData` representing those parts of
the provided `surface` enclosed by the bounding-box. This behaviour
may be inverted with the `outside` parameter.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add the boundary of a ``C32`` bounding-box to the plotter for a region
around the Gulf of Guinea. The geodesic bounding-box is generated by
defining 4 longitude-latitude corners.
Add the region enclosed by a ``C32`` bounding-box manifold to the
plotter for a region around the ``0`` meridian. The geodesic
bounding-box is generated by defining 4 longitude-latitude corners.
The region is generated from all cells of the C48 Sea Surface Temperature
(SST) cubed-sphere mesh that have their cell ``center`` enclosed by the
bounding-box manifold.
>>> import geovista
>>> from geovista.geodesic import BBox
>>> from geovista.pantry.meshes import lfric_sst
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.natural_earth_hypsometric())
>>> mesh = lfric_sst()
>>> bbox = BBox(xs=[-15, 20, 25, -15], ys=[-25, -20, 15, 10], c=32)
>>> region = bbox.enclosed(mesh)
>>> _ = p.add_mesh(region, cmap="balance")
>>> p.view_yz()
>>> p.show()
The same ``region`` is rendered again, but with the land mask cells removed
using the :meth:`pyvista.DataSetFilters.threshold` filter.
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.natural_earth_hypsometric())
>>> _ = p.add_mesh(region.threshold(), cmap="balance")
>>> p.view_yz()
>>> p.show()
"""
original = surface
self.tolerance = tolerance
self.outside = outside
self.preference = preference
# capture the current active scalars name
active_scalars_name = surface.active_scalars_name
crs = from_wkt(surface)
if crs is not None:
if transformed := crs != WGS84:
if self.preference == EnclosedPreference.CENTER:
surface[GV_MANIFOLD_CELL_IDS] = np.arange(surface.n_cells)
surface = transform_mesh(surface, tgt_crs=WGS84)
else:
# assume we have a raw mesh with cartesian points
transformed = False
# perform after transformation to avoid cloud specific transform behaviour
if self.preference == EnclosedPreference.CENTER:
surface = surface.cell_centers()
self._generate_bbox_mesh(surface=surface)
# filter the surface with the bbox manifold mesh
selected = surface.select_interior_points(
self.mesh,
method="cell_locator",
locator_tolerance=self.tolerance,
inside_out=self.outside,
check_surface=False,
)
# name of the point mask generated by select_interior_points
scalars = "selected_points"
# sample the surface with the enclosed cells to extract the bbox region
if self.preference == EnclosedPreference.CENTER:
mask = selected[scalars]
idxs = surface[GV_MANIFOLD_CELL_IDS][mask] if transformed else mask
region = original.extract_cells(idxs)
elif self.preference == EnclosedPreference.POINT:
original.point_data[scalars] = selected[scalars]
region = original.threshold(0.5, scalars=scalars, preference="cell")
else:
region = original.extract_points(selected[scalars], adjacent_cells=False)
# ensure to preserve active scalars name
region.active_scalars_name = active_scalars_name
return cast(region)
[ドキュメント]
def line(
lons: ArrayLike,
lats: ArrayLike,
*,
surface: pv.PolyData | None = None,
radius: float | None = None,
npts: int | None = None,
ellps: str | None = None,
close: bool | None = False,
zlevel: int | None = None,
zscale: float | None = None,
) -> pv.PolyData:
"""Geodesic line consisting of one or more connected geodesic line segments.
Note that as a convenience, if a single value is provided for `lons` and ``N``
values are provided for `lats`, then the longitude value will be automatically
repeated ``N`` times, and vice versa, providing ``N >= 2``.
Parameters
----------
lons : ArrayLike
The longitudes (degrees) of the geodesic line segments, in the half-closed
interval ``[-180, 180)``. Note that longitudes will be wrapped to this
interval.
lats : ArrayLike
The latitudes (degrees) of the geodesic line segments, in the closed
interval ``[-90, 90]``.
surface : PolyData, optional
The surface that the geodesic line will be rendered over.
radius : float, optional
The radius of the spherical surface that the geodesic line will be
rendered over.
Note that the `radius` is only used when the `surface` is not
provided. Defaults to :data:`geovista.common.RADIUS`.
npts : float, optional
The number of equally spaced geodesic points in a line segment, excluding
the segment end-point, but including the segment start-point i.e., `npts`
must be at least 2. Defaults to :data:`GEODESIC_NPTS`.
ellps : str, optional
The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`.
Defaults to :data:`ELLIPSE`.
close : bool, optional
Whether to close the geodesic line segments into a loop i.e., the last
point is connected to the first point. Defaults to ``False``.
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:`geovista.common.ZLEVEL_SCALE`.
Returns
-------
PolyData
The geodesic line.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add the anti-meridian great circle to the plotter. A texture mapped Natural Earth
base layer is also rendered.
>>> import geovista
>>> from geovista.geodesic import line
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.natural_earth_1())
>>> meridian = line(lons=-180, lats=[90, 0, -90])
>>> _ = p.add_mesh(meridian, color="orange", line_width=3)
>>> p.view_xy()
>>> p.show()
"""
if surface is not None:
radius = distance(surface)
else:
radius = RADIUS if radius is None else abs(float(radius))
zscale = ZLEVEL_SCALE if zscale is None else float(zscale)
zlevel = 1 if zlevel is None else int(zlevel)
radius += radius * zlevel * zscale
if npts is None:
npts = GEODESIC_NPTS
if ellps is None:
ellps = ELLIPSE
lons, lats = np.atleast_1d(lons), np.atleast_1d(lats)
# check whether to auto-repeat lons or lats
if (lons.size == 1 or lats.size == 1) and (lons.size + lats.size > 2):
if lons.size == 1:
lons = np.repeat(lons[0], lats.size)
else:
lats = np.repeat(lats[0], lons.size)
n_lons, n_lats = lons.size, lats.size
if n_lons != n_lats:
emsg = (
f"Require the same number of longitudes ({n_lons}) and "
f"latitudes ({n_lats})."
)
raise ValueError(emsg)
if n_lons < 2:
emsg = (
"Require a line geometry containing at least 2 longitude/latitude "
f"values, got '{n_lons}'."
)
raise ValueError(emsg)
lons = wrap(lons)
# check for minimal loop corner case
if np.isclose(lons[0], lons[-1]) and np.isclose(lats[0], lats[-1]) and n_lons == 2:
emsg = (
"Require a closed line (loop) geometry containing at least 3 "
f"longitude/latitude values, got '{n_lons}'."
)
raise ValueError(emsg)
line_lons: list[float] = []
line_lats: list[float] = []
geod = pyproj.Geod(ellps=ellps)
for idx in range(n_lons - 1):
glons, glats = npoints_by_idx(
lons,
lats,
idx,
idx + 1,
npts=npts,
include_start=True,
include_end=False,
geod=geod,
)
line_lons.extend(glons)
line_lats.extend(glats)
# finally, include the end-point
line_lons.append(lons[-1])
line_lats.append(lats[-1])
xyz = to_cartesian(line_lons, line_lats, radius=radius)
lines = pv.lines_from_points(xyz, close=close)
lines.field_data[GV_FIELD_RADIUS] = np.array([radius])
to_wkt(lines, WGS84)
return lines
[ドキュメント]
def npoints(
start_lon: float,
start_lat: float,
end_lon: float,
end_lat: float,
*,
npts: int | None = GEODESIC_NPTS,
radians: bool | None = False,
include_start: bool | None = False,
include_end: bool | None = False,
geod: pyproj.Geod | None = None,
) -> tuple[tuple[float], tuple[float]]:
"""Calculate geodesic mid-points between provided start and end points.
Given a single start-point and end-point, calculate the equally spaced
intermediate longitude and latitude `npts` points along the geodesic line
that spans between the start and end points.
Note that longitudes (degrees) will be wrapped to the half-closed interval
``[-180, 180)``.
Parameters
----------
start_lon : float
The longitude of the start-point for the geodesic line.
start_lat : float
The latitude of the start-point for the geodesic line.
end_lon : float
The longitude of the end-point for the geodesic line.
end_lat : float
The latitude of the end-point for the geodesic line.
npts : int, optional
The number of points to be returned, which may include the start-point
and/or the end-point, if required. Defaults to :data:`GEODESIC_NPTS`.
radians : bool, optional
If ``True``, the start and end points are assumed to be in radians,
otherwise degrees. Defaults to ``False``.
include_start : bool, optional
Whether to include the start-point in the geodesic points returned. Defaults to
``False``.
include_end : bool, optional
Whether to include the end-point in the geodesic points returned. Defaults to
``False``.
geod : Geod, optional
Definition of the ellipsoid for geodesic calculations. Defaults to
:data:`ELLIPSE`.
Returns
-------
tuple
Tuple of longitude points and latitude points along the geodesic line
between the start-point and the end-point.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
>>> from geovista.geodesic import npoints
>>> import numpy as np
>>> points = npoints(start_lon=-10, start_lat=20, end_lon=10, end_lat=30, npts=5)
>>> np.array(points, dtype=np.float16)
array([[-6.887, -3.69 , -0.41 , 2.963, 6.43 ],
[21.84 , 23.62 , 25.34 , 26.98 , 28.53 ]], dtype=float16)
"""
if geod is None:
geod = pyproj.Geod(ellps=ELLIPSE)
initial_idx = 0 if include_start else 1
terminus_idx = 0 if include_end else 1
glonlats = geod.npts(
start_lon,
start_lat,
end_lon,
end_lat,
npts,
radians=radians,
initial_idx=initial_idx,
terminus_idx=terminus_idx,
)
glons, glats = zip(*glonlats, strict=True)
glons = tuple(wrap(glons))
return glons, glats
[ドキュメント]
def npoints_by_idx(
lons: ArrayLike,
lats: ArrayLike,
start_idx: int,
end_idx: int,
*,
npts: int | None = GEODESIC_NPTS,
radians: bool | None = False,
include_start: bool | None = False,
include_end: bool | None = False,
geod: pyproj.Geod | None = None,
) -> tuple[tuple[float], tuple[float]]:
"""Calculate geodesic mid-points between provided start and end indices.
Given a single start-point index and end-point index, calculate the equally
spaced intermediate longitude and latitude `npts` points along the geodesic
line that spans between the start and end points.
Note that longitudes (degrees) will be wrapped to the half-closed interval
``[-180, 180)``.
Parameters
----------
lons : ArrayLike
The longitudes to be sampled by the provided indices.
lats : ArrayLike
The latitudes to be sampled by the provided indices.
start_idx : int
The index of the start-point.
end_idx : int
The index of the end-point.
npts : int, optional
The number of points to be returned, which may include the start-point
and/or the end-point, if required. Defaults to :data:`GEODESIC_NPTS`.
radians : bool, optional
If ``True``, the `lons` and `lats` are assumed to be in radians,
otherwise degrees. Defaults to ``False``.
include_start : bool, optional
Whether to include the start-point in the geodesic points returned. Defaults
to ``False``.
include_end : bool, optional
Whether to include the end-point in the geodesic points returned. Defaults to
``False``.
geod : Geod, optional
Definition of the ellipsoid for geodesic calculations. Defaults to
:data:`ELLIPSE`.
Returns
-------
tuple
Tuple of longitude points and latitude points along the geodesic line
between the start-point and the end-point.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
>>> from geovista.geodesic import npoints_by_idx
>>> import numpy as np
>>> points = npoints_by_idx(
... lons=[-10, 0, 10], lats=[20, 25, 30], start_idx=0, end_idx=1, npts=5
... )
>>> np.array(points, dtype=np.float16)
array([[-8.38 , -6.746, -5.09 , -3.414, -1.718],
[20.88 , 21.73 , 22.58 , 23.4 , 24.22 ]], dtype=float16)
"""
if geod is None:
geod = pyproj.Geod(ellps=ELLIPSE)
start_lonlat = lons[start_idx], lats[start_idx]
end_lonlat = lons[end_idx], lats[end_idx]
return npoints(
*start_lonlat,
*end_lonlat,
npts=npts,
radians=radians,
include_start=include_start,
include_end=include_end,
geod=geod,
)
[ドキュメント]
def panel(
name: int | str,
/,
*,
ellps: str | None = ELLIPSE,
c: int = BBOX_C,
triangulate: bool | None = False,
) -> BBox:
"""Create boundary-box for specific cubed-sphere panel.
Parameters
----------
name : int or str
The cubed-sphere index, see :data:`PANEL_NAME_BY_IDX`, or name, see
:data:`PANEL_IDX_BY_NAME`, which specifies the panel bounding-box,
see :data:`PANEL_BBOX_BY_IDX`.
ellps : str, optional
The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`.
Defaults to :data:`ELLIPSE`.
c : float, optional
The bounding-box face geometry will contain ``c**2`` cells. Defaults
to :data:`BBOX_C`.
triangulate : bool, optional
Specify whether the panel bounding-box faces are triangulated. Defaults to
``False``.
Returns
-------
BBox
The bounding-box that encloses the required cubed-sphere panel.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add a ``wireframe`` bounding-box to the plotter for the ``americas`` panel of a
cubed-sphere. The geodesic bounding-box is generated from the 4 corners of the
cubed-sphere panel located over Americas. A texture mapped Natural Earth base
layer is also rendered.
>>> import geovista
>>> from geovista.geodesic import panel
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.natural_earth_hypsometric(), opacity=0.5)
>>> bbox = panel("americas", c=5, triangulate=True)
>>> _ = p.add_mesh(bbox.mesh, color="orange", show_edges=True)
>>> p.view_xz()
>>> p.show()
"""
if isinstance(name, str):
if name.lower() not in PANEL_IDX_BY_NAME:
ordered = sorted(PANEL_IDX_BY_NAME)
valid = ", ".join(f"'{kind}'" for kind in ordered[:-1])
valid = f"{valid} or '{ordered[-1]}'"
emsg = f"Panel name must be either {valid}, got '{name}'."
raise ValueError(emsg)
idx = PANEL_IDX_BY_NAME[name.lower()]
else:
idx = name
if idx not in range(N_PANELS):
emsg = (
f"Panel index must be in the closed interval "
f"[0, {N_PANELS - 1}], got '{idx}'."
)
raise ValueError(emsg)
lons, lats = PANEL_BBOX_BY_IDX[idx]
return BBox(lons, lats, ellps=ellps, c=c, triangulate=triangulate)
[ドキュメント]
def wedge(
lon1: float,
lon2: float,
*,
ellps: str | None = ELLIPSE,
c: int = BBOX_C,
triangulate: bool | None = False,
) -> BBox:
"""Create geodesic bounding-box manifold wedge from the north to the south pole.
Parameters
----------
lon1 : float
The first longitude (degrees) defining the geodesic wedge region.
lon2 : float
The second longitude (degrees) defining the geodesic wedge region.
ellps : str, optional
The ellipsoid for geodesic calculations. See :func:`pyproj.list.get_ellps_map`.
Defaults to :data:`ELLIPSE`.
c : float, optional
The bounding-box face geometry will contain ``c**2`` cells. Defaults
to :data:`BBOX_C`.
triangulate : bool, optional
Specify whether the wedge bounding-box faces are triangulated. Defaults to
``False``.
Returns
-------
BBox
The bounding-box that encloses the required geodesic wedge.
Notes
-----
.. versionadded:: 0.1.0
Examples
--------
Add a ``C8`` sixty-degree wide ``wireframe`` bounding-box wedge to the plotter. A
texture mapped NASA Blue Marble base layer is also rendered.
>>> import geovista
>>> from geovista.geodesic import wedge
>>> p = geovista.GeoPlotter()
>>> _ = p.add_base_layer(texture=geovista.blue_marble(), opacity=0.5)
>>> bbox = wedge(-30, 30, c=5)
>>> _ = p.add_mesh(bbox.mesh, color="orange", show_edges=True)
>>> p.view_yz()
>>> p.show()
"""
delta = abs(lon1 - lon2)
if 0 < delta >= 180:
emsg = (
"A geodesic wedge must have an absolute longitudinal difference "
f"(degrees) in the open interval (0, 180), got '{delta}'."
)
raise ValueError(emsg)
lons = (lon1, lon2, lon2, lon1)
lats = (90, 90, -90, -90)
return BBox(lons, lats, ellps=ellps, c=c, triangulate=triangulate)