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