diff --git a/doc/users/next_whats_new/bar3d_plots.rst b/doc/users/next_whats_new/bar3d_plots.rst new file mode 100644 index 000000000000..f21314b23c94 --- /dev/null +++ b/doc/users/next_whats_new/bar3d_plots.rst @@ -0,0 +1,23 @@ +New and improved 3D bar plots +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We fixed a long standing issue with incorrect z-sorting in 3d bar graphs. +It is now possible to produce 3D bar charts that render correctly for all +viewing angles by using `.Axes3D.bar3d_grid`. In addition, bar charts with +hexagonal cross section can now be created with `.Axes3Dx.hexbar3d`. This +supports visualisation of density maps on hexagonal tessellations of the data +space. Two new artist collections are introduced to support this functionality: +`.Bar3DCollection` and `.HexBar3DCollection`. + + +.. plot:: + :include-source: true + :alt: Example of creating hexagonal 3D bars + + import matplotlib.pyplot as plt + import numpy as np + + fig, (ax1, ax2) = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + bars3d = ax1.bar3d_grid([0, 1], [0, 1], [1, 2], '0.8', facecolors=('m', 'y')) + hexbars3d = ax2.hexbar3d([0, 1], [0, 1], [1, 2], '0.8', facecolors=('m', 'y')) + plt.show() diff --git a/galleries/examples/mplot3d/hexbin3d.py b/galleries/examples/mplot3d/hexbin3d.py new file mode 100644 index 000000000000..ff14b0f72536 --- /dev/null +++ b/galleries/examples/mplot3d/hexbin3d.py @@ -0,0 +1,37 @@ +""" +======================================== +3D Histogram with hexagonal bins +======================================== + +Demonstrates visualising a 3D density map of data using hexagonal tessellation. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from matplotlib.cbook import hexbin + +# Fixing random state for reproducibility +np.random.seed(42) + +# Generate samples from mltivariate Gaussian +# Parameters +mu = (0, 0) +sigma = ([0.8, 0.3], + [0.3, 0.5]) +n = 10_000 +gridsize = 15 +# draw samples +xy = np.random.multivariate_normal(mu, sigma, n) +# histogram samples with hexbin +xyz, (xmin, xmax), (ymin, ymax), (nx, ny) = hexbin(*xy.T, gridsize=gridsize, + mincnt=3) +# compute bar cross section size +dxy = np.array([(xmax - xmin) / nx, (ymax - ymin) / ny / np.sqrt(3)]) * 0.95 + +# plot +fig, ax = plt.subplots(subplot_kw={'projection': '3d'}) +ax.hexbar3d(*xyz, dxy, cmap='plasma') +ax.set(xlabel='x', ylabel='y', zlabel='z') + +plt.show() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index b4ed7ae22d35..cbb6daa887b2 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5507,112 +5507,19 @@ def reduce_C_function(C: array) -> float x, y, C = cbook.delete_masked_points(x, y, C) - # Set the size of the hexagon grid - if np.iterable(gridsize): - nx, ny = gridsize - else: - nx = gridsize - ny = int(nx / math.sqrt(3)) # Count the number of data in each hexagon x = np.asarray(x, float) y = np.asarray(y, float) - # Will be log()'d if necessary, and then rescaled. - tx = x - ty = y - - if xscale == 'log': - if np.any(x <= 0.0): - raise ValueError( - "x contains non-positive values, so cannot be log-scaled") - tx = np.log10(tx) - if yscale == 'log': - if np.any(y <= 0.0): - raise ValueError( - "y contains non-positive values, so cannot be log-scaled") - ty = np.log10(ty) - if extent is not None: - xmin, xmax, ymin, ymax = extent - if xmin > xmax: - raise ValueError("In extent, xmax must be greater than xmin") - if ymin > ymax: - raise ValueError("In extent, ymax must be greater than ymin") - else: - xmin, xmax = (tx.min(), tx.max()) if len(x) else (0, 1) - ymin, ymax = (ty.min(), ty.max()) if len(y) else (0, 1) - - # to avoid issues with singular data, expand the min/max pairs - xmin, xmax = mtransforms.nonsingular(xmin, xmax, expander=0.1) - ymin, ymax = mtransforms.nonsingular(ymin, ymax, expander=0.1) - - nx1 = nx + 1 - ny1 = ny + 1 - nx2 = nx - ny2 = ny - n = nx1 * ny1 + nx2 * ny2 - - # In the x-direction, the hexagons exactly cover the region from - # xmin to xmax. Need some padding to avoid roundoff errors. - padding = 1.e-9 * (xmax - xmin) - xmin -= padding - xmax += padding + (*offsets, accum), (xmin, xmax), (ymin, ymax), (nx, ny) = cbook.hexbin( + x, y, C, gridsize, xscale, yscale, extent, reduce_C_function, mincnt + ) + offsets = np.transpose(offsets) sx = (xmax - xmin) / nx sy = (ymax - ymin) / ny - # Positions in hexagon index coordinates. - ix = (tx - xmin) / sx - iy = (ty - ymin) / sy - ix1 = np.round(ix).astype(int) - iy1 = np.round(iy).astype(int) - ix2 = np.floor(ix).astype(int) - iy2 = np.floor(iy).astype(int) - # flat indices, plus one so that out-of-range points go to position 0. - i1 = np.where((0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1), - ix1 * ny1 + iy1 + 1, 0) - i2 = np.where((0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2), - ix2 * ny2 + iy2 + 1, 0) - - d1 = (ix - ix1) ** 2 + 3.0 * (iy - iy1) ** 2 - d2 = (ix - ix2 - 0.5) ** 2 + 3.0 * (iy - iy2 - 0.5) ** 2 - bdist = (d1 < d2) - - if C is None: # [1:] drops out-of-range points. - counts1 = np.bincount(i1[bdist], minlength=1 + nx1 * ny1)[1:] - counts2 = np.bincount(i2[~bdist], minlength=1 + nx2 * ny2)[1:] - accum = np.concatenate([counts1, counts2]).astype(float) - if mincnt is not None: - accum[accum < mincnt] = np.nan + + if C is None: C = np.ones(len(x)) - else: - # store the C values in a list per hexagon index - Cs_at_i1 = [[] for _ in range(1 + nx1 * ny1)] - Cs_at_i2 = [[] for _ in range(1 + nx2 * ny2)] - for i in range(len(x)): - if bdist[i]: - Cs_at_i1[i1[i]].append(C[i]) - else: - Cs_at_i2[i2[i]].append(C[i]) - if mincnt is None: - mincnt = 1 - accum = np.array( - [reduce_C_function(acc) if len(acc) >= mincnt else np.nan - for Cs_at_i in [Cs_at_i1, Cs_at_i2] - for acc in Cs_at_i[1:]], # [1:] drops out-of-range points. - float) - - good_idxs = ~np.isnan(accum) - - offsets = np.zeros((n, 2), float) - offsets[:nx1 * ny1, 0] = np.repeat(np.arange(nx1), ny1) - offsets[:nx1 * ny1, 1] = np.tile(np.arange(ny1), nx1) - offsets[nx1 * ny1:, 0] = np.repeat(np.arange(nx2) + 0.5, ny2) - offsets[nx1 * ny1:, 1] = np.tile(np.arange(ny2), nx2) + 0.5 - offsets[:, 0] *= sx - offsets[:, 1] *= sy - offsets[:, 0] += xmin - offsets[:, 1] += ymin - # remove accumulation bins with no data - offsets = offsets[good_idxs, :] - accum = accum[good_idxs] polygon = [sx, sy / 3] * np.array( [[.5, -.5], [.5, .5], [0., 1.], [-.5, .5], [-.5, -.5], [0., -1.]]) diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index a09780965b0c..e5c6e31cbf2a 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -566,7 +566,32 @@ def is_scalar_or_string(val): return isinstance(val, str) or not np.iterable(val) -def get_sample_data(fname, asfileobj=True): +def duplicate_if_scalar(obj, n=2, raises=True): + """Ensure object size or duplicate into a list if necessary.""" + + if is_scalar_or_string(obj): + return [obj] * n + + size = len(obj) + if size == 0: + if raises: + raise ValueError(f'Cannot duplicate empty {type(obj)}.') + return [obj] * n + + if size == 1: + return list(obj) * n + + if (size != n) and raises: + raise ValueError( + f'Input object of type {type(obj)} has incorrect size. Expected ' + f'either a scalar type object, or a Container with length in {{1, ' + f'{n}}}.' + ) + + return obj + + +def get_sample_data(fname, asfileobj=True, *, np_load=True): """ Return a sample data file. *fname* is a path relative to the :file:`mpl-data/sample_data` directory. If *asfileobj* is `True` @@ -1430,6 +1455,120 @@ def _reshape_2D(X, name): return result +def hexbin(x, y, C=None, gridsize=100, + xscale='linear', yscale='linear', extent=None, + reduce_C_function=np.mean, mincnt=None): + + # local import to avoid circular import + import matplotlib.transforms as mtransforms + + # Set the size of the hexagon grid + if np.iterable(gridsize): + nx, ny = gridsize + else: + nx = gridsize + ny = int(nx / math.sqrt(3)) + + # Will be log()'d if necessary, and then rescaled. + tx = x + ty = y + + if xscale == 'log': + if np.any(x <= 0.0): + raise ValueError( + "x contains non-positive values, so cannot be log-scaled") + tx = np.log10(tx) + if yscale == 'log': + if np.any(y <= 0.0): + raise ValueError( + "y contains non-positive values, so cannot be log-scaled") + ty = np.log10(ty) + if extent is not None: + xmin, xmax, ymin, ymax = extent + if xmin > xmax: + raise ValueError("In extent, xmax must be greater than xmin") + if ymin > ymax: + raise ValueError("In extent, ymax must be greater than ymin") + else: + xmin, xmax = (tx.min(), tx.max()) if len(x) else (0, 1) + ymin, ymax = (ty.min(), ty.max()) if len(y) else (0, 1) + + # to avoid issues with singular data, expand the min/max pairs + xmin, xmax = mtransforms.nonsingular(xmin, xmax, expander=0.1) + ymin, ymax = mtransforms.nonsingular(ymin, ymax, expander=0.1) + + nx1 = nx + 1 + ny1 = ny + 1 + nx2 = nx + ny2 = ny + n = nx1 * ny1 + nx2 * ny2 + + # In the x-direction, the hexagons exactly cover the region from + # xmin to xmax. Need some padding to avoid roundoff errors. + padding = 1.e-9 * (xmax - xmin) + xmin -= padding + xmax += padding + sx = (xmax - xmin) / nx + sy = (ymax - ymin) / ny + # Positions in hexagon index coordinates. + ix = (tx - xmin) / sx + iy = (ty - ymin) / sy + ix1 = np.round(ix).astype(int) + iy1 = np.round(iy).astype(int) + ix2 = np.floor(ix).astype(int) + iy2 = np.floor(iy).astype(int) + # flat indices, plus one so that out-of-range points go to position 0. + i1 = np.where((0 <= ix1) & (ix1 < nx1) & (0 <= iy1) & (iy1 < ny1), + ix1 * ny1 + iy1 + 1, 0) + i2 = np.where((0 <= ix2) & (ix2 < nx2) & (0 <= iy2) & (iy2 < ny2), + ix2 * ny2 + iy2 + 1, 0) + + d1 = (ix - ix1) ** 2 + 3.0 * (iy - iy1) ** 2 + d2 = (ix - ix2 - 0.5) ** 2 + 3.0 * (iy - iy2 - 0.5) ** 2 + bdist = (d1 < d2) + + if C is None: # [1:] drops out-of-range points. + counts1 = np.bincount(i1[bdist], minlength=1 + nx1 * ny1)[1:] + counts2 = np.bincount(i2[~bdist], minlength=1 + nx2 * ny2)[1:] + accum = np.concatenate([counts1, counts2]).astype(float) + if mincnt is not None: + accum[accum < mincnt] = np.nan + + else: + # store the C values in a list per hexagon index + Cs_at_i1 = [[] for _ in range(1 + nx1 * ny1)] + Cs_at_i2 = [[] for _ in range(1 + nx2 * ny2)] + for i in range(len(x)): + if bdist[i]: + Cs_at_i1[i1[i]].append(C[i]) + else: + Cs_at_i2[i2[i]].append(C[i]) + if mincnt is None: + mincnt = 1 + accum = np.array( + [reduce_C_function(acc) if len(acc) >= mincnt else np.nan + for Cs_at_i in [Cs_at_i1, Cs_at_i2] + for acc in Cs_at_i[1:]], # [1:] drops out-of-range points. + float) + + good_idxs = ~np.isnan(accum) + + offsets = np.zeros((n, 2), float) + offsets[:nx1 * ny1, 0] = np.repeat(np.arange(nx1), ny1) + offsets[:nx1 * ny1, 1] = np.tile(np.arange(ny1), nx1) + offsets[nx1 * ny1:, 0] = np.repeat(np.arange(nx2) + 0.5, ny2) + offsets[nx1 * ny1:, 1] = np.tile(np.arange(ny2), nx2) + 0.5 + offsets[:, 0] *= sx + offsets[:, 1] *= sy + offsets[:, 0] += xmin + offsets[:, 1] += ymin + # remove accumulation bins with no data + offsets = offsets[good_idxs, :] + accum = accum[good_idxs] + + return (*offsets.T, accum), (xmin, xmax), (ymin, ymax), (nx, ny) + + def violin_stats(X, method, points=100, quantiles=None): """ Return a list of dictionaries of data which can be used to draw a series diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index 6c2d9c303eb2..9d80c14ba435 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -73,6 +73,7 @@ def open_file_cm( encoding: str | None = ..., ) -> contextlib.AbstractContextManager[IO]: ... def is_scalar_or_string(val: Any) -> bool: ... +def duplicate_if_scalar(obj: Any, n: int = 2, raises: bool = True) -> list: ... @overload def get_sample_data( fname: str | os.PathLike, asfileobj: Literal[True] = ... @@ -83,6 +84,7 @@ def _get_data_path(*args: Path | str) -> Path: ... def flatten( seq: Iterable[Any], scalarp: Callable[[Any], bool] = ... ) -> Generator[Any, None, None]: ... +def pairwise(iterable: Iterable[Any]) -> Iterable[Any]: ... class _Stack(Generic[_T]): def __init__(self) -> None: ... @@ -132,6 +134,17 @@ ls_mapper_r: dict[str, str] def contiguous_regions(mask: ArrayLike) -> list[np.ndarray]: ... def is_math_text(s: str) -> bool: ... +def hexbin( + x: ArrayLike, + y: ArrayLike, + C: ArrayLike | None = None, + gridsize: int | tuple[int, int] = 100, + xscale: str = 'linear', + yscale: str = 'linear', + extent: ArrayLike | None = None, + reduce_C_function: Callable = np.mean, + mincnt: int | None = None +) -> tuple[tuple]: ... def violin_stats( X: ArrayLike, method: Callable, points: int = ..., quantiles: ArrayLike | None = ... ) -> list[dict[str, Any]]: ... diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index 483fd09be163..f21efd765ad1 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -8,19 +8,97 @@ """ import math +import numbers +import warnings +from contextlib import contextmanager +import itertools import numpy as np -from contextlib import contextmanager - +import contextlib from matplotlib import ( _api, artist, cbook, colors as mcolors, lines, text as mtext, path as mpath, rcParams) from matplotlib.collections import ( Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) from matplotlib.patches import Patch + from . import proj3d +# ---------------------------------------------------------------------------- # +# chosen for backwards-compatibility +CLASSIC_LIGHTSOURCE = mcolors.LightSource(azdeg=225, altdeg=19.4712) + +# Unit cube +# All faces are oriented facing outwards - when viewed from the +# outside, their vertices are in a counterclockwise ordering. +# shape (6, 4, 3) +# panel order: -x, -y, +x, +y, -z, +z +CUBOID = np.array([ + # -x + ( + (0, 0, 0), + (0, 0, 1), + (0, 1, 1), + (0, 1, 0), + ), + # -y + ( + (0, 0, 0), + (1, 0, 0), + (1, 0, 1), + (0, 0, 1), + ), + # +x + ( + (1, 0, 0), + (1, 1, 0), + (1, 1, 1), + (1, 0, 1), + ), + # +y + ( + (0, 1, 0), + (0, 1, 1), + (1, 1, 1), + (1, 1, 0), + ), + # -z + ( + (0, 0, 0), + (0, 1, 0), + (1, 1, 0), + (1, 0, 0), + ), + # +z + ( + (0, 0, 1), + (1, 0, 1), + (1, 1, 1), + (0, 1, 1), + ), + +]) + + +# Base hexagon for creating prisms (HexBar3DCollection). +# sides are ordered anti-clockwise from left: ['W', 'SW', 'SE', 'E', 'NE', 'NW'] +HEXAGON = np.array([ + [-2, 1], + [-2, -1], + [0, -2], + [2, -1], + [2, 1], + [0, 2] +]) / 4 + +# ---------------------------------------------------------------------------- # + + +def is_none(*args): + for a in args: + yield a is None + def _norm_angle(a): """Return the given angle normalized to -180 < *a* <= 180 degrees.""" @@ -1342,15 +1420,65 @@ def do_3d_projection(self): if self._edge_is_mapped: self._edgecolor3d = self._edgecolors - needs_masking = np.any(self._invalid_vertices) - num_faces = len(self._faces) - mask = self._invalid_vertices + # Broadcast the input colors to the number of faces + cface, cedge = self._compute_colors() + + # Apply mask to projected z coordinates of the polygon faces + mask, needs_masking = self._compute_mask() # Some faces might contain masked vertices, so we want to ignore any # errors that those might cause with np.errstate(invalid='ignore', divide='ignore'): pfaces = proj3d._proj_transform_vectors(self._faces, self.axes.M) + # get the projected z-coordinate array for computing the drawing order + # of the faces + pzs = pfaces[..., 2] + if needs_masking: + pzs = np.ma.MaskedArray(pzs, mask=mask) + + # Compute drawing order + face_order = self._compute_faces_order(pzs, needs_masking) + + # Compute 2d coordinates of the faces (and order) + faces_2d, *codes = self._compute_faces_2d(pfaces, face_order, mask, needs_masking) + + if codes: + PolyCollection.set_verts_and_codes(self, faces_2d, codes) + else: + PolyCollection.set_verts(self, faces_2d, self._closed) + + # Set the 2d facecolors and edgecolors + self._facecolors2d = cface[face_order] if len(cface) > 0 else cface + if len(self._edgecolor3d) == len(cface) and len(cedge) > 0: + self._edgecolors2d = cedge[face_order] + else: + self._edgecolors2d = self._edgecolor3d + + # Return zorder value + if self._sort_zpos is None: + # FIXME: Some results still don't look quite right. + # In particular, examine contourf3d_demo2.py + # with az = -54 and elev = -45. + return np.min(pzs) if pzs.size > 0 else np.nan + + # use _sort_zpos + zvec = np.array([[0], [0], [self._sort_zpos], [1]]) + ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) + return ztrans[2][0] + + def _compute_faces_order(self, pzs, needs_masking): + face_z = self._zsortfunc(pzs, axis=-1) if len(pzs) > 0 else pzs + + if needs_masking: + face_z = face_z.data + + return np.argsort(face_z, axis=-1)[::-1] + + def _compute_mask(self): + needs_masking = np.any(self._invalid_vertices) + mask = self._invalid_vertices + if self._axlim_clip: viewlim_mask = _viewlim_mask(self._faces[..., 0], self._faces[..., 1], self._faces[..., 2], self.axes) @@ -1358,70 +1486,41 @@ def do_3d_projection(self): needs_masking = True mask = mask | viewlim_mask - pzs = pfaces[..., 2] - if needs_masking: - pzs = np.ma.MaskedArray(pzs, mask=mask) + return mask, needs_masking - # This extra fuss is to re-order face / edge colors + def _compute_colors(self): + # Broadcast the input colors to the number of faces cface = self._facecolor3d cedge = self._edgecolor3d + num_faces = len(self._faces) + if len(cface) != num_faces: cface = cface.repeat(num_faces, axis=0) + if len(cedge) != num_faces: - if len(cedge) == 0: - cedge = cface - else: - cedge = cedge.repeat(num_faces, axis=0) + cedge = cface if len(cedge) == 0 else cedge.repeat(num_faces, axis=0) - if len(pzs) > 0: - face_z = self._zsortfunc(pzs, axis=-1) - else: - face_z = pzs - if needs_masking: - face_z = face_z.data - face_order = np.argsort(face_z, axis=-1)[::-1] + return cface, cedge + + def _compute_faces_2d(self, pfaces, face_order, mask, needs_masking): + + faces_2d = pfaces[face_order, :, :2] if len(pfaces) > 0 else pfaces - if len(pfaces) > 0: - faces_2d = pfaces[face_order, :, :2] - else: - faces_2d = pfaces if self._codes3d is not None and len(self._codes3d) > 0: if needs_masking: segment_mask = ~mask[face_order, :] faces_2d = [face[mask, :] for face, mask - in zip(faces_2d, segment_mask)] + in zip(faces_2d, segment_mask)] codes = [self._codes3d[idx] for idx in face_order] - PolyCollection.set_verts_and_codes(self, faces_2d, codes) - else: - if needs_masking and len(faces_2d) > 0: - invalid_vertices_2d = np.broadcast_to( - mask[face_order, :, None], - faces_2d.shape) - faces_2d = np.ma.MaskedArray( - faces_2d, mask=invalid_vertices_2d) - PolyCollection.set_verts(self, faces_2d, self._closed) + return faces_2d, codes - if len(cface) > 0: - self._facecolors2d = cface[face_order] - else: - self._facecolors2d = cface - if len(self._edgecolor3d) == len(cface) and len(cedge) > 0: - self._edgecolors2d = cedge[face_order] - else: - self._edgecolors2d = self._edgecolor3d + if needs_masking and len(faces_2d) > 0: + invalid_vertices_2d = np.broadcast_to( + mask[face_order, :, None], + faces_2d.shape) + faces_2d = np.ma.MaskedArray(faces_2d, mask=invalid_vertices_2d) - # Return zorder value - if self._sort_zpos is not None: - zvec = np.array([[0], [0], [self._sort_zpos], [1]]) - ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) - return ztrans[2][0] - elif pzs.size > 0: - # FIXME: Some results still don't look quite right. - # In particular, examine contourf3d_demo2.py - # with az = -54 and elev = -45. - return np.min(pzs) - else: - return np.nan + return faces_2d, def set_facecolor(self, colors): # docstring inherited @@ -1436,16 +1535,15 @@ def set_edgecolor(self, colors): def set_alpha(self, alpha): # docstring inherited artist.Artist.set_alpha(self, alpha) - try: + safety_net = contextlib.suppress(AttributeError, TypeError, IndexError) + with safety_net: self._facecolor3d = mcolors.to_rgba_array( self._facecolor3d, self._alpha) - except (AttributeError, TypeError, IndexError): - pass - try: + + with safety_net: self._edgecolors = mcolors.to_rgba_array( - self._edgecolor3d, self._alpha) - except (AttributeError, TypeError, IndexError): - pass + self._edgecolor3d, self._alpha) + self.stale = True def get_facecolor(self): @@ -1465,6 +1563,346 @@ def get_edgecolor(self): return np.asarray(self._edgecolors2d) +class Bar3DCollection(Poly3DCollection): + """ + A collection of 3D bars. + + The bars are rectangular prisms with constant square cross-section. + + Attributes + ---------- + xyz : numpy.ndarray + Array of bar positions and heights. + x: numpy.ndarray + The x-coordinates of the bar bases. + y: numpy.ndarray + The y-coordinates of the bar bases. + xy: numpy.ndarray + The x and y coordinates of the bar bases. + dxy : tuple[float, float] + The width (dx) and depth (dy) of the bars in data units. + dx : float + The width of the bars in data units. + dy : float + The depth of the bars in data units. + z : numpy.ndarray + The height of the bars. + z0 : float + The z-coordinate of the bar bases. + n_bars + The number of bars. + + Methods + ------- + set_data: + Set the data for the bars. Can also be used to set the color limits + for color mapped data. + """ + _n_faces_base = 6 + + def __init__(self, x, y, z, dxy='0.8', z0=0, shade=True, lightsource=None, + cmap=None, **kws): + """ + Create a collection of 3D bars. + + Bars (rectangular prisms) with constant square cross section, bases + located on z-plane at *z0*, arranged in a regular grid at *x*, *y* + locations and with height *z - z0*. + + Parameters + ---------- + x, y, z : array-like + The coordinates of the bar bases. + dxy : float or str or tuple[float|str, float|str], default: '0.8' + The width and depth of the bars: + - float: *dxy* is the width and depth of the bars. The bars are + scaled to the data with this value. If *dxy* is a string, it must + be parsable as a float. + - (float, float): *dxy* is the width and depth of the bars in + data units. + z0 : float, default: 0 + Z-coordinate of the bases. + shade : bool, default: True + When *True*, the faces of the bars are shaded. + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + cmap : `~matplotlib.colors.Colormap`, optional + Colormap for the bars. + **kws + Additional keyword arguments are passed to `.Poly3DCollection`. + + Raises + ------ + ValueError: + When arrays have inconsistent shapes. + TypeError: + If the provided lightsource is not a `matplotlib.colors.LightSource` + object. + """ + + x, y, z, z0 = np.ma.atleast_1d(x, y, z, z0) + assert x.shape == y.shape == z.shape + + # array for bar positions, height (x, y, z) + self._xyz = np.empty((3, *x.shape)) + for i, p in enumerate((x, y, z)): + if p is not None: + self._xyz[i] = p + + # bar width and breadth + self.dxy = dxy + self.dx, self.dy = self._resolve_dx_dy(dxy) + + if z0 is not None: + self.z0 = float(z0) + + # Shade faces by angle to light source + self._original_alpha = kws.pop('alpha', 1) + self._shade = bool(shade) + # resolve light source + if lightsource is None: + # chosen for backwards-compatibility + lightsource = CLASSIC_LIGHTSOURCE + else: + assert isinstance(lightsource, mcolors.LightSource) + self._lightsource = lightsource + + # resolve cmap + if cmap is not None: + COLOR_KWS = {'color', 'facecolor', 'facecolors'} + if (ckw := COLOR_KWS.intersection(kws)): + warnings.warn(f'Ignoring cmap since {ckw!r} provided.') + else: + kws.update(cmap=cmap) + + # init Poly3DCollection + # rectangle side panel vertices + Poly3DCollection.__init__(self, self._compute_verts(), **kws) + + if cmap: + self.set_array(self.z.ravel()) + + def _resolve_dx_dy(self, dxy): + + def _resolve_dx_dy(self, dxy): + # if dxy a number -> use it directly else if str, + # scale dxy to data step. + # get x/y step along axis -1/-2 (x/y considered constant along axis + # -2/-1) + dxy = list(cbook.duplicate_if_scalar(dxy)) + + for i, xy in enumerate(self.xy): + delta = resolve_dxy(dxy[i], xy, -i - 1) + if delta == 0: + raise ValueError( + f'Data step cannot be 0 in the {"xy"[i]} direction.' + ) + dxy[i] = delta + + return tuple(dxy) + + @property + def xyz(self): + return self._xyz + + @xyz.setter + def xyz(self, xyz): + self.set_data(*xyz) + + @property + def x(self): + return self._xyz[0] + + @x.setter + def x(self, x): + self.set_data(x=x) + + @property + def y(self): + return self._xyz[1] + + @y.setter + def y(self, y): + self.set_data(y=y) + + @property + def xy(self): + return self._xyz[:2] + + @property + def z(self): + return self._xyz[2] + + def set_z(self, z, z0=None, clim=None): + self.set_data(z=z, z0=z0, clim=clim) + + def set_z0(self, z0): + self.z0 = float(z0) + super().set_verts(self._compute_verts()) + + @property + def n_bars(self): + """The number of bars in the collection.""" + return self.x.size + + def set_data(self, x=None, y=None, z=None, z0=None, clim=None): + # self._xyz = np.atleast_3d(xyz) + assert not all(map(is_none, (x, y, z, z0))) + + if (x is not None) or (y is not None): + self.dx, self.dy = self._resolve_dx_dy(self.dxy) + + for i, p in filter((lambda _, w: w), enumerate((x, y, z))): + self._xyz[i] = p + + if z0 is not None: + self.z0 = float(z0) + + # compute points + super().set_verts(self._compute_verts()) + + # set array for scalar mappable + self.set_array(z := self.z.ravel()) + + # compute color limits + if clim is None or clim is True: + clim = (z.min(), z.max()) + + if clim is not False: + self.set_clim(*clim) + + # Return if artist not added to axes yet + if not self.axes: + return + + # Recompute 3d projection + if self.axes.M is not None: + self.do_3d_projection() + + def _compute_verts(self): + + x, y = self.xy + z = np.full(x.shape, self.z0) + + # indexed by [bar, face, vertex, axis] + xyz = np.expand_dims(np.moveaxis([x, y, z], 0, -1), (-2, -3)) + dxyz = np.empty_like(xyz) + dxyz[..., :2] = np.array([[[self.dx]], [[self.dy]]]).T + dxyz[..., 2] = np.array(self.z - self.z0)[..., np.newaxis, np.newaxis] + polys = xyz + dxyz * CUBOID[np.newaxis, :] # (n, 6, 4, 3) + + # collapse the first two axes + return polys.reshape((-1, 4, 3)) # *polys.shape[-2:] + + def _compute_colors(self): + # This extra fuss is to re-order face / edge colors + cface = self._facecolor3d + cedge = self._edgecolor3d + num_faces = len(self._faces) + num_facecolors = len(cface) + + # Check that the number of facecolors make sense for the number of bars + # or faces + if num_facecolors not in {1, self.n_bars, num_faces}: + raise ValueError( + f"Invalid number of facecolors ({num_facecolors}) provided for" + f"{self.__class__.__name__} with {self.n_bars} bars ({num_faces} faces)" + ) + + # For single facecolor, or number matching number of bars we have to + # duplicate to the total number of faces + if (repeat := num_faces // num_facecolors) > 1: + cface = cface.repeat(repeat, axis=0) + + # apply shading if required + if self._shade: + verts = self._compute_verts() + normals = _generate_normals(verts) + cface = _shade_colors(cface, normals, self._lightsource) + + # apply transparancy if required + if self._original_alpha is not None: + cface[:, -1] = self._original_alpha + + # Duplicate edgecolors if needed + if len(cedge) != num_faces: + cedge = cface if len(cedge) == 0 else cedge.repeat(num_faces, axis=0) + + return cface, cedge + + def _compute_mask(self): + mask, needs_masking = super()._compute_mask() + # Get drawing order for the base shape at this orientation + prism_face_zorder = get_prism_face_zorder(self.axes, + False, + self._n_faces_base - 2) + # Mask faces that are behind others so we don't draw them + occluded = prism_face_zorder < 0.5 + if self._original_alpha == 1: + # only mask back panels if bars are fully opaque + if needs_masking: + mask[occluded] = True + else: + mask = occluded[None].repeat(self._faces.shape[1], 0).T + needs_masking = True + + return mask, needs_masking + + def _compute_faces_order(self, pzs, needs_masking): + # sort by depth (furthest drawn first) + zorder = camera_distance(self.axes, *self.xy) + zorder = (zorder - zorder.min()) / (np.ptp(zorder) or 1) + zorder = zorder.ravel() * len(zorder) + face_zorder = get_prism_face_zorder(self.axes, + False, + self._n_faces_base - 2) + return np.argsort((zorder[..., None] + face_zorder).ravel())[::-1] + + +class HexBar3DCollection(Bar3DCollection): + """ + Hexagonal prisms with uniform cross section, bases located on z-plane at *z0*, + arranged in a regular grid at *x*, *y* locations and height *z - z0*. + """ + _n_faces = 8 + + def _compute_verts(self): + + # scale the base hexagon + hexagon = np.array([self.dx, self.dy]).T * HEXAGON + xy_pairs = np.moveaxis([hexagon, np.roll(hexagon, -1, 0)], 0, 1) + xy_sides = xy_pairs[np.newaxis] + self.xy[:, None, None].T # (n,6,2,2) + + # sides (rectangle faces) + # Array of vertices of the faces composing the prism moving counter + # clockwise when looking from above starting at west (-x) facing panel. + # Vertex sequence is counter-clockwise when viewed from outside. + # shape: (n, [...], 6, 4, 3) + # indexed by [bars..., face, vertex, axis] + data_shape = np.shape(self.z) + shape = (*data_shape, 6, 2, 1) + z0 = np.full(shape, self.z0) + z1 = self.z0 + (self.z * np.ones(shape[::-1])).T + sides = np.concatenate( + [np.concatenate([xy_sides, z0], -1), + np.concatenate([xy_sides, z1], -1)[..., ::-1, :]], + axis=-2) # (n, [...], 6, 4, 3) + + # endcaps (hexagons) # (n, [...], 6, 3) + xy_ends = (self.xy[..., None] + hexagon.T[:, None]) + z0 = self.z0 * np.ones((1, *data_shape, 6)) + z1 = z0 + self.z[None, ..., None] + base = np.moveaxis(np.vstack([xy_ends, z0]), 0, -1) + top = np.moveaxis(np.vstack([xy_ends, z1]), 0, -1) + + # get list of arrays of polygon vertices + verts = [] + for s, b, t in zip(sides, base, top): + verts.extend([*s, b, t]) + + return verts + + def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): """ Convert a `.PolyCollection` into a `.Poly3DCollection` object. @@ -1481,7 +1919,7 @@ def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): See `.get_dir_vector` for a description of the values. """ segments_3d, codes = _paths_to_3d_segments_with_codes( - col.get_paths(), zs, zdir) + col.get_paths(), zs, zdir) col.__class__ = Poly3DCollection col.set_verts_and_codes(segments_3d, codes) col.set_3d_properties() @@ -1616,7 +2054,7 @@ def _generate_normals(polygons): # optimization: polygons all have the same number of points, so can # vectorize n = polygons.shape[-2] - i1, i2, i3 = 0, n//3, 2*n//3 + i1, i2, i3 = 0, n // 3, 2 * n // 3 v1 = polygons[..., i1, :] - polygons[..., i2, :] v2 = polygons[..., i2, :] - polygons[..., i3, :] else: @@ -1626,7 +2064,7 @@ def _generate_normals(polygons): for poly_i, ps in enumerate(polygons): n = len(ps) ps = np.asarray(ps) - i1, i2, i3 = 0, n//3, 2*n//3 + i1, i2, i3 = 0, n // 3, 2 * n // 3 v1[poly_i, :] = ps[i1, :] - ps[i2, :] v2[poly_i, :] = ps[i2, :] - ps[i3, :] return np.cross(v1, v2) @@ -1668,3 +2106,157 @@ def norm(x): colors = np.asanyarray(color).copy() return colors + + +def camera_distance(ax, x, y, z=None): + z = np.zeros_like(x) if z is None else z + return np.sqrt(np.square( + # location of points + [x, y, z] - + # camera position in xyz + np.array(sph2cart(*_camera_position(ax)), ndmin=x.ndim + 1).T + ).sum(0)) + + +def sph2cart(r, theta, phi): + """Spherical to cartesian transform.""" + r_sinθ = r * np.sin(theta) + return (r_sinθ * np.cos(phi), + r_sinθ * np.sin(phi), + r * np.cos(theta)) + + +def _camera_position(ax): + """ + Returns the camera position for 3D axes in spherical coordinates. + """ + r = np.square(np.max([ax.get_xlim(), + ax.get_ylim()], 1)).sum() + theta, phi = np.radians((90 - ax.elev, ax.azim)) + return r, theta, phi + + +def resolve_dxy(step, xy, axis): + """ + Resolve the bar size from input step size and xy-positions along an axis of + the array. + + Parameters + ---------- + step : float or str + The bar size. If the step input is float, it is returned unchanged. If + it is a string, it is converted to a float and multiplied + by the grid step size along the specified *axis*. + xy : array-like + The coordinates of the bar centers. + axis : int + The axis along which to calculate the grid step size. + + Returns + ------- + float + The resolved bar size. + + Raises + ------ + TypeError + If *step* is not a float or a string. + """ + if isinstance(step, str): + return float(step) * _resolve_grid_step(xy, axis) + + if not isinstance(step, numbers.Real): + raise TypeError( + f"Invalid type ({type(step)}) for specifying bar size `dxy`" + ) + + return float(step) + + +def _resolve_grid_step(x, axis=0): + # for data arrange in a regular grid, get the size of the data step by + # looking for the first non-zero step along an axis. + # If axis is singular, return 1 + + # deal with singular dimension (this ignores axis param) + if x.ndim == 1: + if d := next(filter(None, map(np.diff, itertools.pairwise(x))), None): + return d.item() + + if x.shape[axis % x.ndim] == 1: + return 1 + + key = [0] * x.ndim + key[axis] = np.s_[:2] + return np.diff(x[tuple(key)]).item() + + +def get_prism_face_zorder(ax, mask_occluded=True, nfaces=4): + """ + Compute the zorder of prism faces based on camera position. + + The zorder determines the order in which the faces are drawn. Faces further + from the camera are drawn first. + + Parameters + ---------- + ax : Axes3D + The 3D axes. + mask_occluded : bool, default: True + Whether to mask occluded faces. + nfaces : int, default: 4 + The number of faces of the prism's base shape. Eg: 4 for square bars, 6 + for hexagonal bars. + + Returns + ------- + zorder : ndarray + The zorder of the prism faces. + """ + + # NOTE: these index positions are determined by the order of the faces + # returned by `_compute_verts` + base, top = nfaces, nfaces + 1 + if ax.elev < 0: + base, top = top, base + + # This is to figure out which of the vertical faces to draw first + angle_step = 360 / nfaces + zero = -angle_step / 2 + flip = (np.abs(ax.elev) % 180 > 90) + sector = (((ax.azim - zero + 180 * flip) % 360) / angle_step) % nfaces + + # get indices for panels in plot order + first = int(sector) + second = (first + 1) % nfaces + third = (first + nfaces - 1) % nfaces + if (sector - first) < 0.5: + second, third = third, second + + sequence = [base, first, second, third] + sequence = [*sequence, *np.setdiff1d(np.arange(nfaces), sequence), top] + + # reverse panel sequence if elevation has flipped the axes by 180 multiple + if np.abs(ax.elev) % 360 > 180: + sequence = sequence[::-1] + + # normalize zorder to < 1 + zorder = np.argsort(sequence) / len(sequence) + + if mask_occluded: + # we don't need to draw back panels since they are behind others + zorder[zorder < 0.5] = np.nan + + # This order is determined by the ordering of `CUBOID` and `HEXAGON` globals + # names = {4: ['+x', '+y', '-x', '-y', '-z', '+z'], + # 6: ['W', 'SW', 'SE', 'E', 'NE', 'NW', 'BASE', 'TOP']}[nfaces] + # print('', + # f'Panel draw sequence ({ax.azim = :}, {ax.elev = :}):', + # f'{sector = :}', + # f'{sequence = :}', + # f'names = {list(np.take(names, sequence))}', + # f'{zorder = :}', + # f'zorder = {dict(sorted(zip(zorder, names))[::-1])}', + # sep='\n') + + return zorder diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 55b204022fb9..2684926edbe2 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -3131,6 +3131,10 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, Any additional keyword arguments are passed onto `~.art3d.Poly3DCollection`. + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.bar3d_grid + Returns ------- collection : `~.art3d.Poly3DCollection` @@ -3237,6 +3241,115 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, return col + @_preprocess_data() + def bar3d_grid(self, x, y, z, dxy='0.8', z0=0, **kwargs): + """ + Generate a 3D barplot. + + This method creates three-dimensional barplot for bars on a regular + xy-grid and on the same level z-plane. Color of the bars can be + set uniquely, or cmap can be provided to map the bar heights *z* to + colors. + + Parameters + ---------- + x, y : array-like + The coordinates of the anchor point of the bars. + + z : array-like + The height of the bars. + + dxy : str, tuple[str], optional + Width of the bars as a fraction of the data step, by default '0.8' + + z0 : float + z-position of the base of the bars. All bars share the same base + value. + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are forwarded to + `~.art3d.Poly3DCollection`. + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.bar3d + + Returns + ------- + bars : `~.art3d.Bar3DCollection` + A collection of three-dimensional polygons representing the bars + (rectangular prisms). + """ + + bars = art3d.Bar3DCollection(x, y, z, dxy, z0, **kwargs) + self.add_collection(bars) + + # compute axes limits + viewlim = np.array([(np.min(x), np.max(x) + bars.dx), + (np.min(y), np.max(y) + bars.dy), + (min(bars.z0, np.min(z)), np.max(z))]) + + self.auto_scale_xyz(*viewlim, False) + + return bars + + @_preprocess_data() + def hexbar3d(self, x, y, z, dxy='0.8', z0=0, **kwargs): + """ + This method creates three-dimensional barplot with hexagonal bars for a + regular xy-grid on the same level z-plane. Color of the bars can be set + uniquely, or cmap can be provided to map the bar heights *z* to colors. + + Parameters + ---------- + x, y: array-like + The coordinates of the anchor point of the bars. + + z: array-like + The height of the bars. + + dxy : str, optional + _description_, by default '0.8' + + z0 : float + z-position of the base of the bars. All bars share the same base + value. + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are forwarded to + `~.art3d.Poly3DCollection`. + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.bar3d + + Returns + ------- + bars : `~.art3d.HexBar3DCollection` + A collection of three-dimensional polygons representing the bars + (hexagonal prisms). + """ + + bars = art3d.HexBar3DCollection(x, y, z, dxy, z0, **kwargs) + self.add_collection(bars) + + # compute axes limits + dx = bars.dx / 2 + dy = bars.dy / 2 + viewlim = np.array([(np.min(x) - dx, np.max(x) + dx), + (np.min(y) - dy, np.max(y) + dy), + (min(bars.z0, np.min(z)), np.max(z))]) + + self.auto_scale_xyz(*viewlim, False) + + return bars + def set_title(self, label, fontdict=None, loc='center', **kwargs): # docstring inherited ret = super().set_title(label, fontdict=fontdict, loc=loc, **kwargs) diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_cmap.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_cmap.png new file mode 100644 index 000000000000..6b430e4d2de3 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_cmap.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_facecolors.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_facecolors.png new file mode 100644 index 000000000000..1174e4227538 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_facecolors.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_1d_data.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_1d_data.png new file mode 100644 index 000000000000..cab1dc363966 Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_1d_data.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_2d_data.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_2d_data.png new file mode 100644 index 000000000000..fb2845b5b7cb Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_with_2d_data.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort.png new file mode 100644 index 000000000000..488bc6da571c Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort_hex.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort_hex.png new file mode 100644 index 000000000000..6c4313edaecf Binary files /dev/null and b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/bar3d_zsort_hex.png differ diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index cd45c8e33a6f..5a744a73e75d 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -3,6 +3,7 @@ import platform import sys +import numpy as np import pytest from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d @@ -15,12 +16,12 @@ from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.testing.widgets import mock_event from matplotlib.collections import LineCollection, PolyCollection +from matplotlib.cbook import hexbin from matplotlib.patches import Circle, PathPatch from matplotlib.path import Path from matplotlib.text import Text import matplotlib.pyplot as plt -import numpy as np mpl3d_image_comparison = functools.partial( @@ -33,7 +34,42 @@ def plot_cuboid(ax, scale): pts = itertools.combinations(np.array(list(itertools.product(r, r, r))), 2) for start, end in pts: if np.sum(np.abs(start - end)) == r[1] - r[0]: - ax.plot3D(*zip(start*np.array(scale), end*np.array(scale))) + ax.plot3D(*zip(start * np.array(scale), end * np.array(scale))) + + +def get_gaussian_bars(mu=(0, 0), + sigma=([0.8, 0.3], + [0.3, 0.5]), + range=(-3, 3), + res=8, + seed=123): + np.random.seed(seed) + sl = slice(*range, complex(res)) + xy = np.array(np.mgrid[sl, sl][::-1]).T - mu + p = np.linalg.inv(sigma) + exp = np.sum(np.moveaxis(xy.T, 0, 1) * (p @ np.moveaxis(xy, 0, -1)), 1) + z = np.exp(-exp / 2) / np.sqrt(np.linalg.det(sigma)) / np.pi / 2 + return *xy.T, z, '0.8' + + +def get_gaussian_hexs(mu=(0, 0), + sigma=([0.8, 0.3], + [0.3, 0.5]), + n=10_000, + res=8, + seed=123): + np.random.seed(seed) + xy = np.random.multivariate_normal(mu, sigma, n) + xyz, (xmin, xmax), (ymin, ymax), (nx, ny) = hexbin(*xy.T, gridsize=res) + dxy = np.array([(xmax - xmin) / nx, (ymax - ymin) / ny / np.sqrt(3)]) * 0.95 + return *xyz, dxy + + +def get_bar3d_test_data(): + return { + 'rect': get_gaussian_bars(), + 'hex': get_gaussian_hexs() + } @check_figures_equal() @@ -221,8 +257,82 @@ def test_bar3d_lightsource(): np.testing.assert_array_max_ulp(color, collection._facecolor3d[1::6], 4) -@mpl3d_image_comparison(['contour3d.png'], style='mpl20', - tol=0 if platform.machine() == 'x86_64' else 0.002) +@pytest.fixture(params=[get_bar3d_test_data]) +def bar3d_test_data(request): + return request.param() + + +class TestBar3D: + + shapes = ('rect', 'hex') + + def _plot_bar3d(self, ax, x, y, z, dxy, shape, azim=None, elev=None, **kws): + + api_function = ax.hexbar3d if shape == 'hex' else ax.bar3d_grid + bars = api_function(x, y, z, dxy, **kws) + + if azim: + ax.azim = azim + if elev: + ax.elev = elev + + return bars + + @mpl3d_image_comparison(['bar3d_with_1d_data.png']) + def test_bar3d_with_1d_data(self): + fig, axes = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + for ax, shape in zip(axes, self.shapes): + self._plot_bar3d(ax, 0, 0, 1, '0.8', shape, ec='0.5', lw=0.5) + + @mpl3d_image_comparison(['bar3d_zsort.png', 'bar3d_zsort_hex.png']) + def test_bar3d_zsort(self): + for shape in self.shapes: + fig, axes = plt.subplots(2, 4, subplot_kw={'projection': '3d'}) + elev = 45 + azim0, astep = -22.5, 45 + camera = itertools.product(np.r_[azim0:(180 + azim0):astep], + (elev, -elev)) + # sourcery skip: no-loop-in-tests + for ax, (azim, elev) in zip(axes.T.ravel(), camera): + self._plot_bar3d(ax, + [0, 1], [0, 1], [1, 2], + '0.8', + shape, + azim=azim, elev=elev, + ec='0.5', lw=0.5) + + @mpl3d_image_comparison(['bar3d_with_2d_data.png']) + def test_bar3d_with_2d_data(self, bar3d_test_data): + fig, axes = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + for ax, shape in zip(axes, self.shapes): + x, y, z, dxy = bar3d_test_data[shape] + self._plot_bar3d(ax, x, y, z, dxy, shape, ec='0.5', lw=0.5) + + def _gen_bar3d_subplots(self, bar3d_test_data): + config = dict(edgecolors='0.5', lw=0.5) + fig, axes = plt.subplots(2, 2, subplot_kw={'projection': '3d'}) + for i, shape in enumerate(self.shapes): + x, y, z, dxy = bar3d_test_data[shape] + for j, shade in enumerate((0, 1)): + yield (axes[i, j], x, y, z, dxy, shape), {**config, 'shade': shade} + + @mpl3d_image_comparison(['bar3d_facecolors.png']) + def test_bar3d_facecolors(self, bar3d_test_data): + for (ax, x, y, z, dxy, shape), kws in self._gen_bar3d_subplots(bar3d_test_data): + bars = self._plot_bar3d( + ax, x, y, z, dxy, shape, **kws, + facecolors=list(mcolors.CSS4_COLORS)[:x.size] + ) + + @mpl3d_image_comparison(['bar3d_cmap.png']) + def test_bar3d_cmap(self, bar3d_test_data): + for (ax, x, y, z, dxy, shape), kws in self._gen_bar3d_subplots(bar3d_test_data): + bars = self._plot_bar3d(ax, x, y, z, dxy, shape, cmap='viridis', **kws) + + +@mpl3d_image_comparison( + ['contour3d.png'], style='mpl20', + tol=0.002 if platform.machine() in ('aarch64', 'ppc64le', 's390x') else 0) def test_contour3d(): plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated fig = plt.figure()
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: