1212import numpy as np
1313
1414from matplotlib import (
15- artist , cbook , colors as mcolors , lines , text as mtext , path as mpath )
15+ _api , artist , cbook , colors as mcolors , lines , text as mtext ,
16+ path as mpath )
1617from matplotlib .collections import (
1718 LineCollection , PolyCollection , PatchCollection , PathCollection )
1819from matplotlib .colors import Normalize
@@ -808,7 +809,8 @@ class Poly3DCollection(PolyCollection):
808809 triangulation and thus generates consistent surfaces.
809810 """
810811
811- def __init__ (self , verts , * args , zsort = 'average' , ** kwargs ):
812+ def __init__ (self , verts , * args , zsort = 'average' , shade = False ,
813+ lightsource = None , ** kwargs ):
812814 """
813815 Parameters
814816 ----------
@@ -819,6 +821,20 @@ def __init__(self, verts, *args, zsort='average', **kwargs):
819821 zsort : {'average', 'min', 'max'}, default: 'average'
820822 The calculation method for the z-order.
821823 See `~.Poly3DCollection.set_zsort` for details.
824+ shade : bool, default: False
825+ Whether to shade *facecolors* and *edgecolors*.
826+
827+ .. versionadded:: 3.7
828+
829+ .. note::
830+ *facecolors* and/or *edgecolors* must be provided for shading
831+ to work.
832+
833+ lightsource : `~matplotlib.colors.LightSource`
834+ The lightsource to use when *shade* is True.
835+
836+ .. versionadded:: 3.7
837+
822838 *args, **kwargs
823839 All other parameters are forwarded to `.PolyCollection`.
824840
@@ -827,6 +843,23 @@ def __init__(self, verts, *args, zsort='average', **kwargs):
827843 Note that this class does a bit of magic with the _facecolors
828844 and _edgecolors properties.
829845 """
846+ if shade :
847+ normals = _generate_normals (verts )
848+ facecolors = kwargs .get ('facecolors' , None )
849+ if facecolors is not None :
850+ kwargs ['facecolors' ] = _shade_colors (
851+ facecolors , normals , lightsource
852+ )
853+
854+ edgecolors = kwargs .get ('edgecolors' , None )
855+ if edgecolors is not None :
856+ kwargs ['edgecolors' ] = _shade_colors (
857+ edgecolors , normals , lightsource
858+ )
859+ if facecolors is None and edgecolors in None :
860+ _api .warn_external (
861+ "You must provide facecolors, edgecolors, or both for "
862+ "shade to work as expected." )
830863 super ().__init__ (verts , * args , ** kwargs )
831864 if isinstance (verts , np .ndarray ):
832865 if verts .ndim != 3 :
@@ -1086,3 +1119,84 @@ def _zalpha(colors, zs):
10861119 sats = 1 - norm (zs ) * 0.7
10871120 rgba = np .broadcast_to (mcolors .to_rgba_array (colors ), (len (zs ), 4 ))
10881121 return np .column_stack ([rgba [:, :3 ], rgba [:, 3 ] * sats ])
1122+
1123+
1124+ def _generate_normals (polygons ):
1125+ """
1126+ Compute the normals of a list of polygons, one normal per polygon.
1127+
1128+ Normals point towards the viewer for a face with its vertices in
1129+ counterclockwise order, following the right hand rule.
1130+
1131+ Uses three points equally spaced around the polygon. This method assumes
1132+ that the points are in a plane. Otherwise, more than one shade is required,
1133+ which is not supported.
1134+
1135+ Parameters
1136+ ----------
1137+ polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like
1138+ A sequence of polygons to compute normals for, which can have
1139+ varying numbers of vertices. If the polygons all have the same
1140+ number of vertices and array is passed, then the operation will
1141+ be vectorized.
1142+
1143+ Returns
1144+ -------
1145+ normals : (..., 3) array
1146+ A normal vector estimated for the polygon.
1147+ """
1148+ if isinstance (polygons , np .ndarray ):
1149+ # optimization: polygons all have the same number of points, so can
1150+ # vectorize
1151+ n = polygons .shape [- 2 ]
1152+ i1 , i2 , i3 = 0 , n // 3 , 2 * n // 3
1153+ v1 = polygons [..., i1 , :] - polygons [..., i2 , :]
1154+ v2 = polygons [..., i2 , :] - polygons [..., i3 , :]
1155+ else :
1156+ # The subtraction doesn't vectorize because polygons is jagged.
1157+ v1 = np .empty ((len (polygons ), 3 ))
1158+ v2 = np .empty ((len (polygons ), 3 ))
1159+ for poly_i , ps in enumerate (polygons ):
1160+ n = len (ps )
1161+ i1 , i2 , i3 = 0 , n // 3 , 2 * n // 3
1162+ v1 [poly_i , :] = ps [i1 , :] - ps [i2 , :]
1163+ v2 [poly_i , :] = ps [i2 , :] - ps [i3 , :]
1164+ return np .cross (v1 , v2 )
1165+
1166+
1167+ def _shade_colors (color , normals , lightsource = None ):
1168+ """
1169+ Shade *color* using normal vectors given by *normals*,
1170+ assuming a *lightsource* (using default position if not given).
1171+ *color* can also be an array of the same length as *normals*.
1172+ """
1173+ if lightsource is None :
1174+ # chosen for backwards-compatibility
1175+ lightsource = mcolors .LightSource (azdeg = 225 , altdeg = 19.4712 )
1176+
1177+ with np .errstate (invalid = "ignore" ):
1178+ shade = ((normals / np .linalg .norm (normals , axis = 1 , keepdims = True ))
1179+ @ lightsource .direction )
1180+ mask = ~ np .isnan (shade )
1181+
1182+ if mask .any ():
1183+ # convert dot product to allowed shading fractions
1184+ in_norm = mcolors .Normalize (- 1 , 1 )
1185+ out_norm = mcolors .Normalize (0.3 , 1 ).inverse
1186+
1187+ def norm (x ):
1188+ return out_norm (in_norm (x ))
1189+
1190+ shade [~ mask ] = 0
1191+
1192+ color = mcolors .to_rgba_array (color )
1193+ # shape of color should be (M, 4) (where M is number of faces)
1194+ # shape of shade should be (M,)
1195+ # colors should have final shape of (M, 4)
1196+ alpha = color [:, 3 ]
1197+ colors = norm (shade )[:, np .newaxis ] * color
1198+ colors [:, 3 ] = alpha
1199+ else :
1200+ colors = np .asanyarray (color ).copy ()
1201+
1202+ return colors
0 commit comments