Skip to content

Commit f36c415

Browse files
committed
DEV: add Bar3DCollection for 3d bar graphs
1 parent f588d2b commit f36c415

File tree

1 file changed

+284
-2
lines changed

1 file changed

+284
-2
lines changed

lib/mpl_toolkits/mplot3d/art3d.py

+284-2
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,72 @@
1818
path as mpath)
1919
from matplotlib.collections import (
2020
LineCollection, PolyCollection, PatchCollection, PathCollection)
21-
from matplotlib.colors import Normalize
21+
from matplotlib.colors import Normalize, LightSource
2222
from matplotlib.patches import Patch
2323
from . import proj3d
2424

2525

26+
# shape (6, 4, 3)
27+
# All faces are oriented facing outwards - when viewed from the
28+
# outside, their vertices are in a counterclockwise ordering.
29+
CUBOID = np.array([
30+
# -z
31+
(
32+
(0, 0, 0),
33+
(0, 1, 0),
34+
(1, 1, 0),
35+
(1, 0, 0),
36+
),
37+
# +z
38+
(
39+
(0, 0, 1),
40+
(1, 0, 1),
41+
(1, 1, 1),
42+
(0, 1, 1),
43+
),
44+
# -y
45+
(
46+
(0, 0, 0),
47+
(1, 0, 0),
48+
(1, 0, 1),
49+
(0, 0, 1),
50+
),
51+
# +y
52+
(
53+
(0, 1, 0),
54+
(0, 1, 1),
55+
(1, 1, 1),
56+
(1, 1, 0),
57+
),
58+
# -x
59+
(
60+
(0, 0, 0),
61+
(0, 0, 1),
62+
(0, 1, 1),
63+
(0, 1, 0),
64+
),
65+
# +x
66+
(
67+
(1, 0, 0),
68+
(1, 1, 0),
69+
(1, 1, 1),
70+
(1, 0, 1),
71+
),
72+
])
73+
74+
CAMERA_VIEW_QUADRANT_TO_CUBE_FACE_ZORDER = {
75+
# -z, +z, -y, +y, -x, +x
76+
# 0, 1, 2, 3, 4, 5
77+
78+
# viewing | cube face | cube face
79+
# quadrant| indices | names order
80+
0: (5, 0, 4, 1, 3, 2), # ('+z', '+y', '+x', '-x', '-y', '-z')
81+
1: (5, 0, 4, 1, 2, 3), # ('+z', '+y', '-x', '+x', '-y', '-z')
82+
2: (5, 0, 1, 4, 2, 3), # ('+z', '-y', '-x', '+x', '+y', '-z')
83+
3: (5, 0, 1, 4, 3, 2) # ('+z', '-y', '+x', '-x', '+y', '-z')
84+
}
85+
86+
2687
def _norm_angle(a):
2788
"""Return the given angle normalized to -180 < *a* <= 180 degrees."""
2889
a = (a + 360) % 360
@@ -1052,7 +1113,7 @@ def set_alpha(self, alpha):
10521113
pass
10531114
try:
10541115
self._edgecolors = mcolors.to_rgba_array(
1055-
self._edgecolor3d, self._alpha)
1116+
self._edgecolor3d, self._alpha)
10561117
except (AttributeError, TypeError, IndexError):
10571118
pass
10581119
self.stale = True
@@ -1074,6 +1135,195 @@ def get_edgecolor(self):
10741135
return np.asarray(self._edgecolors2d)
10751136

10761137

1138+
class Bar3DCollection(Poly3DCollection):
1139+
"""
1140+
Bars with constant square cross section, bases located on z-plane at *z0*,
1141+
aranged in a regular grid at *x*, *y* locations and height *z - z0*.
1142+
"""
1143+
1144+
# TODO: don't need to plot occluded faces
1145+
# ie. back panels don't need to be drawn if alpha == 1
1146+
1147+
def __init__(self, x, y, z, dxy=0.8, z0=0, shade=True, lightsource=None, **kws):
1148+
#
1149+
assert 0 < dxy <= 1
1150+
1151+
self.xyz = np.atleast_3d([x, y, z])
1152+
self.z0 = float(z0)
1153+
# self.dxy = dxy = float(dxy)
1154+
1155+
# bar width and breadth
1156+
self.dx, self.dy = self._resolve_dx_dy(dxy)
1157+
1158+
# Shade faces by angle to light source
1159+
self._original_alpha = kws.pop('alpha', None)
1160+
self._shade = bool(shade)
1161+
if lightsource is None:
1162+
# chosen for backwards-compatibility
1163+
lightsource = LightSource(azdeg=225, altdeg=19.4712)
1164+
else:
1165+
assert isinstance(lightsource, LightSource)
1166+
self._lightsource = lightsource
1167+
1168+
# rectangle polygon vertices
1169+
verts = self._compute_bar3d_verts()
1170+
1171+
# init Poly3DCollection
1172+
if (no_cmap := {'color', 'facecolor', 'facecolors'}.intersection(kws)):
1173+
kws.pop('cmap', None)
1174+
1175+
# print(kws)
1176+
Poly3DCollection.__init__(self, verts, **kws)
1177+
1178+
if not no_cmap:
1179+
self.set_array(z.ravel())
1180+
1181+
def _resolve_dx_dy(self, dxy):
1182+
d = [dxy, dxy]
1183+
for i, s in enumerate(self.xyz.shape[1:]):
1184+
# dxy * (#np.array([1]) if s == 1 else
1185+
d[i], = dxy * np.diff(self.xyz[(i, *(0, np.s_[:2])[::(1, -1)[i]])])
1186+
dx, dy = d
1187+
assert (dx != 0) & (dy != 0)
1188+
return dx, dy
1189+
1190+
@property
1191+
def xy(self):
1192+
return self.xyz[:2]
1193+
1194+
@property
1195+
def z(self):
1196+
return self.xyz[2]
1197+
1198+
def set_z0(self, z0):
1199+
self.z0 = float(z0)
1200+
super().set_verts(self._compute_verts())
1201+
1202+
def set_z(self, z, clim=None):
1203+
self.xyz[2] = z
1204+
self.set_data(self.xyz, clim)
1205+
1206+
def set_data(self, xyz, clim=None):
1207+
self.xyz = np.atleast_3d(xyz)
1208+
super().set_verts(self._compute_verts())
1209+
self.set_array(z := self.z.ravel())
1210+
1211+
if clim is None or clim is True:
1212+
clim = (z.min(), z.max())
1213+
if clim is not False:
1214+
self.set_clim(*clim)
1215+
1216+
if not self.axes:
1217+
return
1218+
1219+
if self.axes.M is not None:
1220+
self.do_3d_projection()
1221+
1222+
def _compute_verts(self):
1223+
x, y, dz = self.xyz
1224+
dx = np.full(x.shape, self.dx)
1225+
dy = np.full(x.shape, self.dy)
1226+
z = np.full(x.shape, self.z0)
1227+
return _compute_bar3d_verts(x, y, z, dx, dy, dz)
1228+
1229+
def do_3d_projection(self):
1230+
"""
1231+
Perform the 3D projection for this object.
1232+
"""
1233+
if self._A is not None:
1234+
# force update of color mapping because we re-order them
1235+
# below. If we do not do this here, the 2D draw will call
1236+
# this, but we will never port the color mapped values back
1237+
# to the 3D versions.
1238+
#
1239+
# We hold the 3D versions in a fixed order (the order the user
1240+
# passed in) and sort the 2D version by view depth.
1241+
self.update_scalarmappable()
1242+
if self._face_is_mapped:
1243+
self._facecolor3d = self._facecolors
1244+
if self._edge_is_mapped:
1245+
self._edgecolor3d = self._edgecolors
1246+
1247+
txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M)
1248+
xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices]
1249+
1250+
# get panel facecolors
1251+
cface, cedge = self._resolve_colors(xyzlist, self._lightsource)
1252+
1253+
if xyzlist:
1254+
zorder = self._compute_zorder()
1255+
1256+
z_segments_2d = sorted(
1257+
((zo, np.column_stack([xs, ys]), fc, ec, idx)
1258+
for idx, (zo, (xs, ys, _), fc, ec)
1259+
in enumerate(zip(zorder, xyzlist, cface, cedge))),
1260+
key=lambda x: x[0], reverse=True)
1261+
1262+
_, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \
1263+
zip(*z_segments_2d)
1264+
else:
1265+
segments_2d = []
1266+
self._facecolors2d = np.empty((0, 4))
1267+
self._edgecolors2d = np.empty((0, 4))
1268+
idxs = []
1269+
1270+
if self._codes3d is None:
1271+
PolyCollection.set_verts(self, segments_2d, self._closed)
1272+
else:
1273+
codes = [self._codes3d[idx] for idx in idxs]
1274+
PolyCollection.set_verts_and_codes(self, segments_2d, codes)
1275+
1276+
if len(self._edgecolor3d) != len(cface):
1277+
self._edgecolors2d = self._edgecolor3d
1278+
1279+
# Return zorder value
1280+
if self._sort_zpos is not None:
1281+
zvec = np.array([[0], [0], [self._sort_zpos], [1]])
1282+
ztrans = proj3d._proj_transform_vec(zvec, self.axes.M)
1283+
return ztrans[2][0]
1284+
1285+
if tzs.size > 0:
1286+
# FIXME: Some results still don't look quite right.
1287+
# In particular, examine contourf3d_demo2.py
1288+
# with az = -54 and elev = -45.
1289+
return np.min(tzs)
1290+
1291+
return np.nan
1292+
1293+
def _resolve_colors(self, xyzlist, lightsource):
1294+
# This extra fuss is to re-order face / edge colors
1295+
cface = self._facecolor3d
1296+
cedge = self._edgecolor3d
1297+
n, nc = len(xyzlist), len(cface)
1298+
if (nc == 1) or (nc * 6 == n):
1299+
cface = cface.repeat(n // nc, axis=0)
1300+
if self._shade:
1301+
verts = self._compute_verts()
1302+
ax = self.axes
1303+
normals = _generate_normals(verts)
1304+
cface = _shade_colors(cface, normals, lightsource)
1305+
1306+
if self._original_alpha is not None:
1307+
cface[:, -1] = self._original_alpha
1308+
1309+
if len(cface) != n:
1310+
cface = cface.repeat(n, axis=0)
1311+
1312+
if len(cedge) != n:
1313+
cedge = cface if len(cedge) == 0 else cedge.repeat(n, axis=0)
1314+
1315+
return cface, cedge
1316+
1317+
def _compute_zorder(self):
1318+
# sort by depth (furthest drawn first)
1319+
zorder = camera.distance(self.axes, *self.xy)
1320+
zorder = (zorder - zorder.min()) / zorder.ptp()
1321+
zorder = zorder.ravel() * len(zorder)
1322+
panel_order = get_cube_face_zorder(self.axes)
1323+
zorder = (zorder[..., None] + panel_order / 6).ravel()
1324+
return zorder
1325+
1326+
10771327
def poly_collection_2d_to_3d(col, zs=0, zdir='z'):
10781328
"""
10791329
Convert a `.PolyCollection` into a `.Poly3DCollection` object.
@@ -1218,3 +1468,35 @@ def norm(x):
12181468
colors = np.asanyarray(color).copy()
12191469

12201470
return colors
1471+
1472+
1473+
def _compute__bar3d_verts(x, y, z, dx, dy, dz):
1474+
# indexed by [bar, face, vertex, coord]
1475+
1476+
# handle each coordinate separately
1477+
polys = np.empty(x.shape + CUBOID.shape)
1478+
for i, (p, dp) in enumerate(((x, dx), (y, dy), (z, dz))):
1479+
p = p[..., np.newaxis, np.newaxis]
1480+
dp = dp[..., np.newaxis, np.newaxis]
1481+
polys[..., i] = p + dp * CUBOID[..., i]
1482+
1483+
# collapse the first two axes
1484+
return polys.reshape((-1,) + polys.shape[-2:])
1485+
1486+
1487+
def get_cube_face_zorder(ax):
1488+
# -z, +z, -y, +y, -x, +x
1489+
# 0, 1, 2, 3, 4, 5
1490+
1491+
view_quadrant = int((ax.azim % 360) // 90)
1492+
idx = CAMERA_VIEW_QUADRANT_TO_CUBE_FACE_ZORDER[view_quadrant]
1493+
order = np.array(idx)
1494+
1495+
if (ax.elev % 180) > 90:
1496+
order[:2] = order[1::-1]
1497+
1498+
# logger.trace('Panel draw order quadrant {}:\n{}\n{}', view_quadrant, order,
1499+
# list(np.take(['-z', '+z', '-y', '+y', '-x', '+x'],
1500+
# order)))
1501+
1502+
return order

0 commit comments

Comments
 (0)