diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index a4292a323035..d80e22bc91c8 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -1043,6 +1043,8 @@ def remove(self): try: ax = self.mappable.axes + if ax is None: + return except AttributeError: return try: diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index 120e816fed45..0ddb67de2baf 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -514,7 +514,51 @@ def _sig_digits_from_norm(norm, data, n): return g_sig_digits -class _ScalarMappable(_ColorizerInterface): +class _ColorbarMappable(_ColorizerInterface): + + def __init__(self, colorizer, **kwargs): + """ + Base class for objects that can connect to a colorbar. + + All classes that can act as a mappable for `fig.colorbar(mappable)` + will subclass this class. + """ + super().__init__(**kwargs) + self._colorizer = colorizer + self.colorbar = None + self._id_colorizer = self._colorizer.callbacks.connect('changed', self.changed) + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + self._axes = None + + @property + def axes(self): + return self._axes + + @axes.setter + def axes(self, axes): + self._axes = axes + + @property + def colorizer(self): + return self._colorizer + + @colorizer.setter + def colorizer(self, cl): + _api.check_isinstance(Colorizer, colorizer=cl) + self._colorizer.callbacks.disconnect(self._id_colorizer) + self._colorizer = cl + self._id_colorizer = cl.callbacks.connect('changed', self.changed) + + def changed(self): + """ + Call this whenever the mappable is changed to notify all the + callbackSM listeners to the 'changed' signal. + """ + self.callbacks.process('changed') + self.stale = True + + +class _ScalarMappable(_ColorbarMappable): """ A mixin class to map one or multiple sets of scalar data to RGBA. @@ -550,13 +594,9 @@ def __init__(self, norm=None, cmap=None, *, colorizer=None, **kwargs): cmap : str or `~matplotlib.colors.Colormap` The colormap used to map normalized data values to RGBA colors. """ - super().__init__(**kwargs) self._A = None - self._colorizer = self._get_colorizer(colorizer=colorizer, norm=norm, cmap=cmap) - - self.colorbar = None - self._id_colorizer = self._colorizer.callbacks.connect('changed', self.changed) - self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + colorizer = self._get_colorizer(colorizer=colorizer, norm=norm, cmap=cmap) + super().__init__(colorizer, **kwargs) def set_array(self, A): """ @@ -600,14 +640,6 @@ def get_array(self): """ return self._A - def changed(self): - """ - Call this whenever the mappable is changed to notify all the - callbackSM listeners to the 'changed' signal. - """ - self.callbacks.process('changed', self) - self.stale = True - @staticmethod def _check_exclusionary_keywords(colorizer, **kwargs): """ @@ -710,17 +742,6 @@ def __init__(self, colorizer, **kwargs): _api.check_isinstance(Colorizer, colorizer=colorizer) super().__init__(colorizer=colorizer, **kwargs) - @property - def colorizer(self): - return self._colorizer - - @colorizer.setter - def colorizer(self, cl): - _api.check_isinstance(Colorizer, colorizer=cl) - self._colorizer.callbacks.disconnect(self._id_colorizer) - self._colorizer = cl - self._id_colorizer = cl.callbacks.connect('changed', self.changed) - def _set_colorizer_check_keywords(self, colorizer, **kwargs): """ Raises a ValueError if any kwarg is not None while colorizer is not None. diff --git a/lib/matplotlib/colorizer.pyi b/lib/matplotlib/colorizer.pyi index 9a5a73415d83..2d2931c463a7 100644 --- a/lib/matplotlib/colorizer.pyi +++ b/lib/matplotlib/colorizer.pyi @@ -1,4 +1,4 @@ -from matplotlib import cbook, colorbar, colors, artist +from matplotlib import cbook, colorbar, colors, artist, axes as maxes import numpy as np from numpy.typing import ArrayLike @@ -71,7 +71,24 @@ class _ColorizerInterface: def autoscale_None(self) -> None: ... -class _ScalarMappable(_ColorizerInterface): +class _ColorbarMappable(_ColorizerInterface): + def __init__( + self, + colorizer: Colorizer | None, + **kwargs + ) -> None: ... + @property + def colorizer(self) -> Colorizer: ... + @colorizer.setter + def colorizer(self, cl: Colorizer) -> None: ... + def changed(self) -> None: ... + @property + def axes(self) -> maxes._base._AxesBase | None: ... + @axes.setter + def axes(self, new_axes: maxes._base._AxesBase | None) -> None: ... + + +class _ScalarMappable(_ColorbarMappable): def __init__( self, norm: colors.Norm | None = ..., @@ -82,7 +99,6 @@ class _ScalarMappable(_ColorizerInterface): ) -> None: ... def set_array(self, A: ArrayLike | None) -> None: ... def get_array(self) -> np.ndarray | None: ... - def changed(self) -> None: ... class ColorizingArtist(_ScalarMappable, artist.Artist): diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index d06d157db4ce..3f832106f302 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -15,11 +15,12 @@ from matplotlib import ( _api, artist, cbook, colors as mcolors, lines, text as mtext, - path as mpath, rcParams) + path as mpath, rcParams, colorizer as mcolorizer) from matplotlib.collections import ( Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) from matplotlib.patches import Patch from . import proj3d +import collections def _norm_angle(a): @@ -1206,22 +1207,26 @@ def __init__(self, verts, *args, zsort='average', shade=False, and _edgecolors properties. """ if shade: - normals = _generate_normals(verts) + self.shaded = True + self.normals = _generate_normals(verts) + self.lightsource = lightsource facecolors = kwargs.get('facecolors', None) if facecolors is not None: kwargs['facecolors'] = _shade_colors( - facecolors, normals, lightsource + facecolors, self.normals, self.lightsource ) edgecolors = kwargs.get('edgecolors', None) if edgecolors is not None: kwargs['edgecolors'] = _shade_colors( - edgecolors, normals, lightsource + edgecolors, self.normals, self.lightsource ) if facecolors is None and edgecolors is None: raise ValueError( "You must provide facecolors, edgecolors, or both for " "shade to work.") + else: + self.shaded = False super().__init__(verts, *args, **kwargs) if isinstance(verts, np.ndarray): if verts.ndim != 3: @@ -1233,6 +1238,37 @@ def __init__(self, verts, *args, zsort='average', shade=False, self._codes3d = None self._axlim_clip = axlim_clip + def update_scalarmappable(self): + """ + Update colors from the scalar mappable array, if any. + + This overrides `Collection.update_scalarmappable()`. + This function differs in the following way: + 1. This function only sets facecolors, never edgecolors + 2. This function applies shading. + 3. self._A is assumed to have the correct shape + """ + if not self._set_mappable_flags(): + return + # Allow possibility to call 'self.set_array(None)'. + if self._A is not None: + if np.iterable(self._alpha): + if self._alpha.size != self._A.size: + raise ValueError( + f'Data array shape, {self._A.shape} ' + 'is incompatible with alpha array shape, ' + f'{self._alpha.shape}. ' + ) + self._alpha = self._alpha.reshape(self._A.shape) + self._mapped_colors = self.to_rgba(self._A, self._alpha) + if self.shaded: + self._mapped_colors = _shade_colors( + self._mapped_colors, self.normals, self.lightsource + ) + + self._facecolors = self._mapped_colors + self.stale = True + _zsort_functions = { 'average': np.average, 'min': np.min, @@ -1675,3 +1711,70 @@ def norm(x): colors = np.asanyarray(color).copy() return colors + + +class VoxelDict(mcolorizer._ColorbarMappable, + collections.abc.MutableMapping): + """ + A mapping indexed by coordinate, where ``faces[i, j, k]`` + is a `.Poly3DCollection` of the faces drawn for the voxel + ``filled[i, j, k]``. If no faces were drawn for a given voxel, + either because it was not asked to be drawn, or it is fully + occluded, then ``(i, j, k) not in faces``. + + This class also supports the functionality required to act as a mappable + for a colorbar. + """ + + def __init__(self, colorizer, axes): + """ + Parameters + ---------- + colorizer : `mpl.colorizer.Colorizer` + The colorizer uset to convert data to color. + + axes : `mplot3d.axes3d.Axes3D` + The axes the voxels are contained in. + + """ + + super().__init__(colorizer) + self.axes = axes + self._A = None + self._mapping = dict() + + def __getitem__(self, key): + return self._mapping[key] + + def __setitem__(self, key, val): + self._mapping[key] = val + + def __delitem__(self, key): + del self._mapping[key] + + def __len__(self): + return len(self._mapping) + + def __iter__(self): + return reversed(self._mapping) + + def get_array(self): + """ + Return the array of values, that are mapped to colors. + """ + return self._A + + def set_array(self, A): + """ + Set the value array from array-like *A*. + + Parameters + ---------- + A : array-like of length equal to the number of voxels. + The values that are mapped to colors. + + """ + + self._A = A + for a, k in zip(A, self.keys()): + self[k].set_array(a) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index e8b72c421cd2..c3767bedef89 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -3380,7 +3380,8 @@ def calc_arrows(UVW): quiver3D = quiver def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, - lightsource=None, axlim_clip=False, **kwargs): + lightsource=None, axlim_clip=False, colorizer=None, + norm=None, cmap=None, vmin=None, vmax=None, **kwargs): """ ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \ **kwargs) @@ -3393,9 +3394,10 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, Parameters ---------- - filled : 3D np.array of bool - A 3D array of values, with truthy values indicating which voxels - to fill + filled : 3D np.array of bool or float + If bool, with truthy values indicate which voxels to fill. + If float, voxels with finite walues are shown with color + mapped via *cmap* and *norm*, while voxels with nan are ignored. x, y, z : 3D np.array, optional The coordinates of the corners of the voxels. This should broadcast @@ -3432,15 +3434,28 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, .. versionadded:: 3.10 + cmap : Colormap, optional + Colormap to use if facecolor is scalar. + + norm : `~matplotlib.colors.Normalize`, optional + Normalization for the colormap. + + vmin, vmax : float, optional + Bounds for the normalization. + + colorizer : `~matplotlib.colorizer.Colorizer` or None, default: None + The Colorizer object used to map color to data. If None, a Colorizer + object is created from a *norm* and *cmap*. + **kwargs Additional keyword arguments to pass onto `~mpl_toolkits.mplot3d.art3d.Poly3DCollection`. Returns ------- - faces : dict - A dictionary indexed by coordinate, where ``faces[i, j, k]`` is a - `.Poly3DCollection` of the faces drawn for the voxel + faces : VoxelDict + A dictionary subclass indexed by coordinate, where ``faces[i, j, k]`` + is a `.Poly3DCollection` of the faces drawn for the voxel ``filled[i, j, k]``. If no faces were drawn for a given voxel, either because it was not asked to be drawn, or it is fully occluded, then ``(i, j, k) not in faces``. @@ -3452,6 +3467,10 @@ def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, .. plot:: gallery/mplot3d/voxels_torus.py .. plot:: gallery/mplot3d/voxels_numpy_logo.py """ + mpl.colorizer.ColorizingArtist._check_exclusionary_keywords( + colorizer, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax) + if colorizer is None: + colorizer = mpl.colorizer.Colorizer(cmap, norm) # work out which signature we should be using, and use it to parse # the arguments. Name must be voxels for the correct error message @@ -3465,6 +3484,13 @@ def voxels(filled, **kwargs): xyz, filled, kwargs = voxels(*args, **kwargs) + if filled.dtype == np.float64: + scalars = filled + filled = np.isfinite(filled) + #colorizer.autoscale_None(scalars[filled]) + else: + scalars = None + # check dimensions if filled.ndim != 3: raise ValueError("Argument filled must be 3-dimensional") @@ -3566,7 +3592,7 @@ def permutation_matrices(n): # iterate over the faces, and generate a Poly3DCollection for each # voxel - polygons = {} + polygons = art3d.VoxelDict(colorizer, self) for coord, faces_inds in voxel_faces.items(): # convert indices into 3D positions if xyz is None: @@ -3588,10 +3614,19 @@ def permutation_matrices(n): poly = art3d.Poly3DCollection( faces, facecolors=facecolor, edgecolors=edgecolor, shade=shade, lightsource=lightsource, axlim_clip=axlim_clip, + colorizer=colorizer, **kwargs) self.add_collection3d(poly) polygons[coord] = poly + poly.voxel_dict_cid = poly.callbacks.connect( + 'changed', polygons.changed) + + if scalars is not None: + A = scalars[*np.array([*polygons.keys()]).T] + polygons.colorizer.autoscale_None(A) + polygons.set_array(A) + return polygons @_preprocess_data(replace_names=["x", "y", "z", "xerr", "yerr", "zerr"]) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index e9809ce2a106..5d0ef23686aa 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 sys +import collections.abc from packaging.version import parse as parse_version import pytest @@ -1496,7 +1497,7 @@ def test_alpha(self): colors[v1] = [0, 1, 0, 0.5] v = ax.voxels(voxels, facecolors=colors) - assert type(v) is dict + assert issubclass(type(v), collections.abc.MutableMapping) for coord, poly in v.items(): assert voxels[coord], "faces returned for absent voxel" assert isinstance(poly, art3d.Poly3DCollection)