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
+ _api , 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,20 @@ 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*.
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
+
822
838
*args, **kwargs
823
839
All other parameters are forwarded to `.PolyCollection`.
824
840
@@ -827,6 +843,23 @@ def __init__(self, verts, *args, zsort='average', **kwargs):
827
843
Note that this class does a bit of magic with the _facecolors
828
844
and _edgecolors properties.
829
845
"""
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." )
830
863
super ().__init__ (verts , * args , ** kwargs )
831
864
if isinstance (verts , np .ndarray ):
832
865
if verts .ndim != 3 :
@@ -1086,3 +1119,84 @@ def _zalpha(colors, zs):
1086
1119
sats = 1 - norm (zs ) * 0.7
1087
1120
rgba = np .broadcast_to (mcolors .to_rgba_array (colors ), (len (zs ), 4 ))
1088
1121
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