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 9a2b367fb502..62bf74b70c1c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5137,112 +5137,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 e4f60aac37a8..f39d2f62b8f8 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -503,6 +503,31 @@ def is_scalar_or_string(val): return isinstance(val, str) or not np.iterable(val) +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 + + @_api.delete_parameter( "3.8", "np_load", alternative="open(get_sample_data(..., asfileobj=False))") def get_sample_data(fname, asfileobj=True, *, np_load=True): @@ -567,6 +592,23 @@ def flatten(seq, scalarp=is_scalar_or_string): yield from flatten(item, scalarp) +def pairwise(iterable): + """ + Returns an iterator of paired items, overlapping, from the original + + take(4, pairwise(count())) + [(0, 1), (1, 2), (2, 3), (3, 4)] + + From more_itertools: + https://more-itertools.readthedocs.io/en/stable/_modules/more_itertools/recipes.html#pairwise + + Can be removed on python >3.10 in favour of itertools.pairwise + """ + a, b = itertools.tee(iterable) + next(b, None) + return zip(a, b) + + @_api.deprecated("3.8") class Stack: """ @@ -1473,6 +1515,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 d727b8065b7a..5ddb9f2370b0 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -72,6 +72,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] = ..., *, np_load: Literal[True] @@ -91,6 +92,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, default: _T | None = ...) -> None: ... @@ -144,6 +146,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 ec4ab07e4874..5d81925e20a5 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -8,21 +8,94 @@ """ import math - -import numpy as np - +import warnings from contextlib import contextmanager -from matplotlib import ( - _api, artist, cbook, colors as mcolors, lines, text as mtext, - path as mpath) -from matplotlib.collections import ( - Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) -from matplotlib.colors import Normalize +import numpy as np +from matplotlib import (_api, artist, cbook, lines, colors as mcolors, + path as mpath, text as mtext) from matplotlib.patches import Patch +from matplotlib.collections import (Collection, LineCollection, PatchCollection, + PathCollection, PolyCollection) + 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.""" a = (a + 360) % 360 @@ -1096,7 +1169,7 @@ def set_alpha(self, alpha): pass try: self._edgecolors = mcolors.to_rgba_array( - self._edgecolor3d, self._alpha) + self._edgecolor3d, self._alpha) except (AttributeError, TypeError, IndexError): pass self.stale = True @@ -1118,6 +1191,294 @@ def get_edgecolor(self): return np.asarray(self._edgecolors2d) +class Bar3DCollection(Poly3DCollection): + """ + 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*. + """ + + _n_faces = 6 + + def __init__(self, x, y, z, dxy='0.8', z0=0, shade=True, lightsource=None, + cmap=None, **kws): + # + 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): + + d = list(cbook.duplicate_if_scalar(dxy)) + + for i, xy in enumerate(self.xy): + # 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) + data_step = _get_grid_step(xy, -i - 1) if isinstance(d[i], str) else 1 + d[i] = float(d[i]) * data_step + + dx, dy = d + assert (dx != 0) + assert (dy != 0) + + return dx, dy + + @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()) + + 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 enumerate((x, y, z)): + if p is not None: + self._xyz[i] = p + + if z0 is not None: + self.z0 = float(z0) + + # compute points + super().set_verts(self._compute_verts()) + self.set_array(z := self.z.ravel()) + + if clim is None or clim is True: + clim = (z.min(), z.max()) + + if clim is not False: + self.set_clim(*clim) + + if not self.axes: + return + + 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 do_3d_projection(self): + """ + Perform the 3D projection for this object. + """ + if self._A is not None: + # force update of color mapping because we re-order them + # below. If we do not do this here, the 2D draw will call + # this, but we will never port the color mapped values back + # to the 3D versions. + # + # We hold the 3D versions in a fixed order (the order the user + # passed in) and sort the 2D version by view depth. + self.update_scalarmappable() + if self._face_is_mapped: + self._facecolor3d = self._facecolors + if self._edge_is_mapped: + self._edgecolor3d = self._edgecolors + + txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M) + xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices] + + # get panel facecolors + cface, cedge = self._compute_colors(xyzlist, self._lightsource) + + if xyzlist: + zorder = self._compute_zorder() + occluded = np.isnan(zorder) + + z_segments_2d = sorted( + ((zo, np.column_stack([xs, ys]), fc, ec, idx) + for idx, (ok, zo, (xs, ys, _), fc, ec) + in enumerate(zip(~occluded, zorder, xyzlist, cface, cedge)) + if ok), + key=lambda x: x[0], reverse=True) + + _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \ + zip(*z_segments_2d) + else: + segments_2d = [] + self._facecolors2d = np.empty((0, 4)) + self._edgecolors2d = np.empty((0, 4)) + idxs = [] + + if self._codes3d is None: + PolyCollection.set_verts(self, segments_2d, self._closed) + else: + codes = [self._codes3d[idx] for idx in idxs] + PolyCollection.set_verts_and_codes(self, segments_2d, codes) + + if len(self._edgecolor3d) != len(cface): + self._edgecolors2d = self._edgecolor3d + + # 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] + + return np.min(tzs) if tzs.size > 0 else np.nan + + def _compute_colors(self, xyzlist, lightsource): + # This extra fuss is to re-order face / edge colors + cface = self._facecolor3d + cedge = self._edgecolor3d + n, nc = len(xyzlist), len(cface) + + if (nc == 1) or (nc * self._n_faces == n): + cface = cface.repeat(n // nc, axis=0) + if self._shade: + verts = self._compute_verts() + normals = _generate_normals(verts) + cface = _shade_colors(cface, normals, lightsource) + + if self._original_alpha is not None: + cface[:, -1] = self._original_alpha + + if len(cface) != n: + raise ValueError + # cface = cface.repeat(n, axis=0) + + if len(cedge) != n: + cedge = cface if len(cedge) == 0 else cedge.repeat(n, axis=0) + + return cface, cedge + + def _compute_zorder(self): + # 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, + self._original_alpha == 1, + self._n_faces - 2) + return (zorder[..., None] + face_zorder).ravel() + + +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'): """ Convert a `.PolyCollection` into a `.Poly3DCollection` object. @@ -1134,7 +1495,7 @@ def poly_collection_2d_to_3d(col, zs=0, zdir='z'): 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() @@ -1179,7 +1540,7 @@ def _zalpha(colors, zs): # Should really normalize against the viewing depth. if len(colors) == 0 or len(zs) == 0: return np.zeros((0, 4)) - norm = Normalize(min(zs), max(zs)) + norm = mcolors.Normalize(min(zs), max(zs)) sats = 1 - norm(zs) * 0.7 rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) @@ -1213,7 +1574,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: @@ -1222,7 +1583,7 @@ def _generate_normals(polygons): v2 = np.empty((len(polygons), 3)) for poly_i, ps in enumerate(polygons): n = len(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) @@ -1264,3 +1625,100 @@ 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 _get_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, cbook.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 panel sequence based on camera position + + # 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 the 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 = {pformat(dict(zip(*cosort(zorder, names)[::-1])))}', + # sep='\n') + + return zorder diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 408fd69ff5c3..e944e68ec16d 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2838,6 +2838,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` @@ -2943,6 +2947,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 fdd90ccf4c90..bd672e96c1e9 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2,6 +2,7 @@ import itertools import platform +import numpy as np import pytest from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d @@ -14,12 +15,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( @@ -32,7 +33,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(extensions=["png"]) @@ -220,6 +256,79 @@ def test_bar3d_lightsource(): np.testing.assert_array_max_ulp(color, collection._facecolor3d[1::6], 4) +@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)