Skip to content

Commit de8739f

Browse files
authored
Merge pull request #23914 from oscargus/shaderefactor
Add shading of Poly3DCollection
2 parents 185a421 + d9d75f2 commit de8739f

File tree

3 files changed

+166
-124
lines changed

3 files changed

+166
-124
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
``Poly3DCollection`` supports shading
2+
-------------------------------------
3+
4+
It is now possible to shade a `.Poly3DCollection`. This is useful if the
5+
polygons are obtained from e.g. a 3D model.
6+
7+
.. plot::
8+
:include-source: true
9+
10+
import numpy as np
11+
import matplotlib.pyplot as plt
12+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
13+
14+
# Define 3D shape
15+
block = np.array([
16+
[[1, 1, 0],
17+
[1, 0, 0],
18+
[0, 1, 0]],
19+
[[1, 1, 0],
20+
[1, 1, 1],
21+
[1, 0, 0]],
22+
[[1, 1, 0],
23+
[1, 1, 1],
24+
[0, 1, 0]],
25+
[[1, 0, 0],
26+
[1, 1, 1],
27+
[0, 1, 0]]
28+
])
29+
30+
ax = plt.subplot(projection='3d')
31+
pc = Poly3DCollection(block, facecolors='b', shade=True)
32+
ax.add_collection(pc)
33+
plt.show()

lib/mpl_toolkits/mplot3d/art3d.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
import numpy as np
1313

1414
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)
1617
from matplotlib.collections import (
1718
LineCollection, PolyCollection, PatchCollection, PathCollection)
1819
from 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,17 @@ 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*. 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+
822835
*args, **kwargs
823836
All other parameters are forwarded to `.PolyCollection`.
824837
@@ -827,6 +840,23 @@ def __init__(self, verts, *args, zsort='average', **kwargs):
827840
Note that this class does a bit of magic with the _facecolors
828841
and _edgecolors properties.
829842
"""
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.")
830860
super().__init__(verts, *args, **kwargs)
831861
if isinstance(verts, np.ndarray):
832862
if verts.ndim != 3:
@@ -1086,3 +1116,84 @@ def _zalpha(colors, zs):
10861116
sats = 1 - norm(zs) * 0.7
10871117
rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4))
10881118
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

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy