From ecd8d56d60d16e990613749f65046deb443574a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 18 Jan 2026 21:35:03 +0100 Subject: [PATCH 1/2] VoxelDict class as return type for axes3d.Voxel --- lib/mpl_toolkits/mplot3d/art3d.py | 172 ++++++++++++++++++++++++++++- lib/mpl_toolkits/mplot3d/axes3d.py | 51 +++++++-- 2 files changed, 212 insertions(+), 11 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/art3d.py b/lib/mpl_toolkits/mplot3d/art3d.py index d06d157db4ce..fcac83b9ab8b 100644 --- a/lib/mpl_toolkits/mplot3d/art3d.py +++ b/lib/mpl_toolkits/mplot3d/art3d.py @@ -1206,22 +1206,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 +1237,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 +1710,134 @@ def norm(x): colors = np.asanyarray(color).copy() return colors + + +class VoxelDict(dict): + """ + 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``. + + This class also supports the functionality required to act as a mappable + for a colorbar. + """ + + def __init__(self, axes, colorizer): + """ + Parameters + ---------- + axes : `mplot3d.axes3d.Axes3D` + The axes the voxels are contained in. + + colorizer : `mpl.colorizer.Colorizer` + The colorizer uset to convert data to color. + """ + super().__init__(self) + self.axes = axes + self.colorizer = colorizer + self._callbacks = cbook.CallbackRegistry(signals=["changed"]) + self._A = None + + @property + def cmap(self): + return self.colorizer.cmap + + @property + def norm(self): + return self.colorizer.norm + + @property + def callbacks(self): + return self._callbacks + + 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) + + def add_callback(self, func): + """ + Add a callback function that will be called whenever one of the + `.Artist`'s properties changes. + + Parameters + ---------- + func : callable + The callback function. It must have the signature:: + + def func(artist: Artist) -> Any + + where *artist* is the calling `.Artist`. Return values may exist + but are ignored. + + Returns + ------- + int + The observer id associated with the callback. This id can be + used for removing the callback with `.remove_callback` later. + + See Also + -------- + remove_callback + """ + # Wrapping func in a lambda ensures it can be connected multiple times + # and never gets weakref-gc'ed. + return self._callbacks.connect("changed", lambda: func(self)) + + def remove_callback(self, oid): + """ + Remove a callback based on its observer id. + + See Also + -------- + add_callback + """ + self._callbacks.disconnect(oid) + + def changed(self, *args): + """ + Call all of the registered callbacks. + + This function is triggered internally when a property is changed. + + See Also + -------- + add_callback + remove_callback + """ + self._callbacks.process("changed") + + def get_alpha(self): + return 1.0 + + def autoscale(self, A): + """ + Autoscale the scalar limits on the norm instance using the + current array + """ + self.colorizer.autoscale(A) + + def autoscale_None(self): + """ + Autoscale the scalar limits on the norm instance using the + current array, changing only limits that are None + """ + self.colorizer.autoscale_None(self._A) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index e8b72c421cd2..b1754bae3bf0 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(self, colorizer) 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"]) From 5f78c20db3d124de86cb236d7415091a1eb6c0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sat, 24 Jan 2026 22:18:19 +0100 Subject: [PATCH 2/2] introduction of _ColorbarMappable --- lib/matplotlib/colorbar.py | 2 + lib/matplotlib/colorizer.py | 73 ++++++++---- lib/matplotlib/colorizer.pyi | 22 +++- lib/mpl_toolkits/mplot3d/art3d.py | 111 ++++-------------- lib/mpl_toolkits/mplot3d/axes3d.py | 2 +- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 3 +- 6 files changed, 95 insertions(+), 118 deletions(-) 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 fcac83b9ab8b..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): @@ -1712,9 +1713,10 @@ def norm(x): return colors -class VoxelDict(dict): +class VoxelDict(mcolorizer._ColorbarMappable, + collections.abc.MutableMapping): """ - A dictionary subclass indexed by coordinate, where ``faces[i, j, k]`` + 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 @@ -1724,33 +1726,37 @@ class VoxelDict(dict): for a colorbar. """ - def __init__(self, axes, colorizer): + 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. - colorizer : `mpl.colorizer.Colorizer` - The colorizer uset to convert data to color. """ - super().__init__(self) + + super().__init__(colorizer) self.axes = axes - self.colorizer = colorizer - self._callbacks = cbook.CallbackRegistry(signals=["changed"]) self._A = None + self._mapping = dict() + + def __getitem__(self, key): + return self._mapping[key] - @property - def cmap(self): - return self.colorizer.cmap + def __setitem__(self, key, val): + self._mapping[key] = val - @property - def norm(self): - return self.colorizer.norm + def __delitem__(self, key): + del self._mapping[key] - @property - def callbacks(self): - return self._callbacks + def __len__(self): + return len(self._mapping) + + def __iter__(self): + return reversed(self._mapping) def get_array(self): """ @@ -1772,72 +1778,3 @@ def set_array(self, A): self._A = A for a, k in zip(A, self.keys()): self[k].set_array(a) - - def add_callback(self, func): - """ - Add a callback function that will be called whenever one of the - `.Artist`'s properties changes. - - Parameters - ---------- - func : callable - The callback function. It must have the signature:: - - def func(artist: Artist) -> Any - - where *artist* is the calling `.Artist`. Return values may exist - but are ignored. - - Returns - ------- - int - The observer id associated with the callback. This id can be - used for removing the callback with `.remove_callback` later. - - See Also - -------- - remove_callback - """ - # Wrapping func in a lambda ensures it can be connected multiple times - # and never gets weakref-gc'ed. - return self._callbacks.connect("changed", lambda: func(self)) - - def remove_callback(self, oid): - """ - Remove a callback based on its observer id. - - See Also - -------- - add_callback - """ - self._callbacks.disconnect(oid) - - def changed(self, *args): - """ - Call all of the registered callbacks. - - This function is triggered internally when a property is changed. - - See Also - -------- - add_callback - remove_callback - """ - self._callbacks.process("changed") - - def get_alpha(self): - return 1.0 - - def autoscale(self, A): - """ - Autoscale the scalar limits on the norm instance using the - current array - """ - self.colorizer.autoscale(A) - - def autoscale_None(self): - """ - Autoscale the scalar limits on the norm instance using the - current array, changing only limits that are None - """ - self.colorizer.autoscale_None(self._A) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index b1754bae3bf0..c3767bedef89 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -3592,7 +3592,7 @@ def permutation_matrices(n): # iterate over the faces, and generate a Poly3DCollection for each # voxel - polygons = art3d.VoxelDict(self, colorizer) + polygons = art3d.VoxelDict(colorizer, self) for coord, faces_inds in voxel_faces.items(): # convert indices into 3D positions if xyz is None: 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)