art3d.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252
  1. # art3d.py, original mplot3d version by John Porter
  2. # Parts rewritten by Reinier Heeres <reinier@heeres.eu>
  3. # Minor additions by Ben Axelrod <baxelrod@coroware.com>
  4. """
  5. Module containing 3D artist code and functions to convert 2D
  6. artists into 3D versions which can be added to an Axes3D.
  7. """
  8. import math
  9. import numpy as np
  10. from contextlib import contextmanager
  11. from matplotlib import (
  12. artist, cbook, colors as mcolors, lines, text as mtext,
  13. path as mpath)
  14. from matplotlib.collections import (
  15. Collection, LineCollection, PolyCollection, PatchCollection, PathCollection)
  16. from matplotlib.colors import Normalize
  17. from matplotlib.patches import Patch
  18. from . import proj3d
  19. def _norm_angle(a):
  20. """Return the given angle normalized to -180 < *a* <= 180 degrees."""
  21. a = (a + 360) % 360
  22. if a > 180:
  23. a = a - 360
  24. return a
  25. def _norm_text_angle(a):
  26. """Return the given angle normalized to -90 < *a* <= 90 degrees."""
  27. a = (a + 180) % 180
  28. if a > 90:
  29. a = a - 180
  30. return a
  31. def get_dir_vector(zdir):
  32. """
  33. Return a direction vector.
  34. Parameters
  35. ----------
  36. zdir : {'x', 'y', 'z', None, 3-tuple}
  37. The direction. Possible values are:
  38. - 'x': equivalent to (1, 0, 0)
  39. - 'y': equivalent to (0, 1, 0)
  40. - 'z': equivalent to (0, 0, 1)
  41. - *None*: equivalent to (0, 0, 0)
  42. - an iterable (x, y, z) is converted to an array
  43. Returns
  44. -------
  45. x, y, z : array
  46. The direction vector.
  47. """
  48. if zdir == 'x':
  49. return np.array((1, 0, 0))
  50. elif zdir == 'y':
  51. return np.array((0, 1, 0))
  52. elif zdir == 'z':
  53. return np.array((0, 0, 1))
  54. elif zdir is None:
  55. return np.array((0, 0, 0))
  56. elif np.iterable(zdir) and len(zdir) == 3:
  57. return np.array(zdir)
  58. else:
  59. raise ValueError("'x', 'y', 'z', None or vector of length 3 expected")
  60. class Text3D(mtext.Text):
  61. """
  62. Text object with 3D position and direction.
  63. Parameters
  64. ----------
  65. x, y, z : float
  66. The position of the text.
  67. text : str
  68. The text string to display.
  69. zdir : {'x', 'y', 'z', None, 3-tuple}
  70. The direction of the text. See `.get_dir_vector` for a description of
  71. the values.
  72. Other Parameters
  73. ----------------
  74. **kwargs
  75. All other parameters are passed on to `~matplotlib.text.Text`.
  76. """
  77. def __init__(self, x=0, y=0, z=0, text='', zdir='z', **kwargs):
  78. mtext.Text.__init__(self, x, y, text, **kwargs)
  79. self.set_3d_properties(z, zdir)
  80. def get_position_3d(self):
  81. """Return the (x, y, z) position of the text."""
  82. return self._x, self._y, self._z
  83. def set_position_3d(self, xyz, zdir=None):
  84. """
  85. Set the (*x*, *y*, *z*) position of the text.
  86. Parameters
  87. ----------
  88. xyz : (float, float, float)
  89. The position in 3D space.
  90. zdir : {'x', 'y', 'z', None, 3-tuple}
  91. The direction of the text. If unspecified, the *zdir* will not be
  92. changed. See `.get_dir_vector` for a description of the values.
  93. """
  94. super().set_position(xyz[:2])
  95. self.set_z(xyz[2])
  96. if zdir is not None:
  97. self._dir_vec = get_dir_vector(zdir)
  98. def set_z(self, z):
  99. """
  100. Set the *z* position of the text.
  101. Parameters
  102. ----------
  103. z : float
  104. """
  105. self._z = z
  106. self.stale = True
  107. def set_3d_properties(self, z=0, zdir='z'):
  108. """
  109. Set the *z* position and direction of the text.
  110. Parameters
  111. ----------
  112. z : float
  113. The z-position in 3D space.
  114. zdir : {'x', 'y', 'z', 3-tuple}
  115. The direction of the text. Default: 'z'.
  116. See `.get_dir_vector` for a description of the values.
  117. """
  118. self._z = z
  119. self._dir_vec = get_dir_vector(zdir)
  120. self.stale = True
  121. @artist.allow_rasterization
  122. def draw(self, renderer):
  123. position3d = np.array((self._x, self._y, self._z))
  124. proj = proj3d._proj_trans_points(
  125. [position3d, position3d + self._dir_vec], self.axes.M)
  126. dx = proj[0][1] - proj[0][0]
  127. dy = proj[1][1] - proj[1][0]
  128. angle = math.degrees(math.atan2(dy, dx))
  129. with cbook._setattr_cm(self, _x=proj[0][0], _y=proj[1][0],
  130. _rotation=_norm_text_angle(angle)):
  131. mtext.Text.draw(self, renderer)
  132. self.stale = False
  133. def get_tightbbox(self, renderer=None):
  134. # Overwriting the 2d Text behavior which is not valid for 3d.
  135. # For now, just return None to exclude from layout calculation.
  136. return None
  137. def text_2d_to_3d(obj, z=0, zdir='z'):
  138. """
  139. Convert a `.Text` to a `.Text3D` object.
  140. Parameters
  141. ----------
  142. z : float
  143. The z-position in 3D space.
  144. zdir : {'x', 'y', 'z', 3-tuple}
  145. The direction of the text. Default: 'z'.
  146. See `.get_dir_vector` for a description of the values.
  147. """
  148. obj.__class__ = Text3D
  149. obj.set_3d_properties(z, zdir)
  150. class Line3D(lines.Line2D):
  151. """
  152. 3D line object.
  153. .. note:: Use `get_data_3d` to obtain the data associated with the line.
  154. `~.Line2D.get_data`, `~.Line2D.get_xdata`, and `~.Line2D.get_ydata` return
  155. the x- and y-coordinates of the projected 2D-line, not the x- and y-data of
  156. the 3D-line. Similarly, use `set_data_3d` to set the data, not
  157. `~.Line2D.set_data`, `~.Line2D.set_xdata`, and `~.Line2D.set_ydata`.
  158. """
  159. def __init__(self, xs, ys, zs, *args, **kwargs):
  160. """
  161. Parameters
  162. ----------
  163. xs : array-like
  164. The x-data to be plotted.
  165. ys : array-like
  166. The y-data to be plotted.
  167. zs : array-like
  168. The z-data to be plotted.
  169. *args, **kwargs
  170. Additional arguments are passed to `~matplotlib.lines.Line2D`.
  171. """
  172. super().__init__([], [], *args, **kwargs)
  173. self.set_data_3d(xs, ys, zs)
  174. def set_3d_properties(self, zs=0, zdir='z'):
  175. """
  176. Set the *z* position and direction of the line.
  177. Parameters
  178. ----------
  179. zs : float or array of floats
  180. The location along the *zdir* axis in 3D space to position the
  181. line.
  182. zdir : {'x', 'y', 'z'}
  183. Plane to plot line orthogonal to. Default: 'z'.
  184. See `.get_dir_vector` for a description of the values.
  185. """
  186. xs = self.get_xdata()
  187. ys = self.get_ydata()
  188. zs = cbook._to_unmasked_float_array(zs).ravel()
  189. zs = np.broadcast_to(zs, len(xs))
  190. self._verts3d = juggle_axes(xs, ys, zs, zdir)
  191. self.stale = True
  192. def set_data_3d(self, *args):
  193. """
  194. Set the x, y and z data
  195. Parameters
  196. ----------
  197. x : array-like
  198. The x-data to be plotted.
  199. y : array-like
  200. The y-data to be plotted.
  201. z : array-like
  202. The z-data to be plotted.
  203. Notes
  204. -----
  205. Accepts x, y, z arguments or a single array-like (x, y, z)
  206. """
  207. if len(args) == 1:
  208. args = args[0]
  209. for name, xyz in zip('xyz', args):
  210. if not np.iterable(xyz):
  211. raise RuntimeError(f'{name} must be a sequence')
  212. self._verts3d = args
  213. self.stale = True
  214. def get_data_3d(self):
  215. """
  216. Get the current data
  217. Returns
  218. -------
  219. verts3d : length-3 tuple or array-like
  220. The current data as a tuple or array-like.
  221. """
  222. return self._verts3d
  223. @artist.allow_rasterization
  224. def draw(self, renderer):
  225. xs3d, ys3d, zs3d = self._verts3d
  226. xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
  227. self.set_data(xs, ys)
  228. super().draw(renderer)
  229. self.stale = False
  230. def line_2d_to_3d(line, zs=0, zdir='z'):
  231. """
  232. Convert a `.Line2D` to a `.Line3D` object.
  233. Parameters
  234. ----------
  235. zs : float
  236. The location along the *zdir* axis in 3D space to position the line.
  237. zdir : {'x', 'y', 'z'}
  238. Plane to plot line orthogonal to. Default: 'z'.
  239. See `.get_dir_vector` for a description of the values.
  240. """
  241. line.__class__ = Line3D
  242. line.set_3d_properties(zs, zdir)
  243. def _path_to_3d_segment(path, zs=0, zdir='z'):
  244. """Convert a path to a 3D segment."""
  245. zs = np.broadcast_to(zs, len(path))
  246. pathsegs = path.iter_segments(simplify=False, curves=False)
  247. seg = [(x, y, z) for (((x, y), code), z) in zip(pathsegs, zs)]
  248. seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg]
  249. return seg3d
  250. def _paths_to_3d_segments(paths, zs=0, zdir='z'):
  251. """Convert paths from a collection object to 3D segments."""
  252. if not np.iterable(zs):
  253. zs = np.broadcast_to(zs, len(paths))
  254. else:
  255. if len(zs) != len(paths):
  256. raise ValueError('Number of z-coordinates does not match paths.')
  257. segs = [_path_to_3d_segment(path, pathz, zdir)
  258. for path, pathz in zip(paths, zs)]
  259. return segs
  260. def _path_to_3d_segment_with_codes(path, zs=0, zdir='z'):
  261. """Convert a path to a 3D segment with path codes."""
  262. zs = np.broadcast_to(zs, len(path))
  263. pathsegs = path.iter_segments(simplify=False, curves=False)
  264. seg_codes = [((x, y, z), code) for ((x, y), code), z in zip(pathsegs, zs)]
  265. if seg_codes:
  266. seg, codes = zip(*seg_codes)
  267. seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg]
  268. else:
  269. seg3d = []
  270. codes = []
  271. return seg3d, list(codes)
  272. def _paths_to_3d_segments_with_codes(paths, zs=0, zdir='z'):
  273. """
  274. Convert paths from a collection object to 3D segments with path codes.
  275. """
  276. zs = np.broadcast_to(zs, len(paths))
  277. segments_codes = [_path_to_3d_segment_with_codes(path, pathz, zdir)
  278. for path, pathz in zip(paths, zs)]
  279. if segments_codes:
  280. segments, codes = zip(*segments_codes)
  281. else:
  282. segments, codes = [], []
  283. return list(segments), list(codes)
  284. class Collection3D(Collection):
  285. """A collection of 3D paths."""
  286. def do_3d_projection(self):
  287. """Project the points according to renderer matrix."""
  288. xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M)
  289. for vs, _ in self._3dverts_codes]
  290. self._paths = [mpath.Path(np.column_stack([xs, ys]), cs)
  291. for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)]
  292. zs = np.concatenate([zs for _, _, zs in xyzs_list])
  293. return zs.min() if len(zs) else 1e9
  294. def collection_2d_to_3d(col, zs=0, zdir='z'):
  295. """Convert a `.Collection` to a `.Collection3D` object."""
  296. zs = np.broadcast_to(zs, len(col.get_paths()))
  297. col._3dverts_codes = [
  298. (np.column_stack(juggle_axes(
  299. *np.column_stack([p.vertices, np.broadcast_to(z, len(p.vertices))]).T,
  300. zdir)),
  301. p.codes)
  302. for p, z in zip(col.get_paths(), zs)]
  303. col.__class__ = cbook._make_class_factory(Collection3D, "{}3D")(type(col))
  304. class Line3DCollection(LineCollection):
  305. """
  306. A collection of 3D lines.
  307. """
  308. def set_sort_zpos(self, val):
  309. """Set the position to use for z-sorting."""
  310. self._sort_zpos = val
  311. self.stale = True
  312. def set_segments(self, segments):
  313. """
  314. Set 3D segments.
  315. """
  316. self._segments3d = segments
  317. super().set_segments([])
  318. def do_3d_projection(self):
  319. """
  320. Project the points according to renderer matrix.
  321. """
  322. xyslist = [proj3d._proj_trans_points(points, self.axes.M)
  323. for points in self._segments3d]
  324. segments_2d = [np.column_stack([xs, ys]) for xs, ys, zs in xyslist]
  325. LineCollection.set_segments(self, segments_2d)
  326. # FIXME
  327. minz = 1e9
  328. for xs, ys, zs in xyslist:
  329. minz = min(minz, min(zs))
  330. return minz
  331. def line_collection_2d_to_3d(col, zs=0, zdir='z'):
  332. """Convert a `.LineCollection` to a `.Line3DCollection` object."""
  333. segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir)
  334. col.__class__ = Line3DCollection
  335. col.set_segments(segments3d)
  336. class Patch3D(Patch):
  337. """
  338. 3D patch object.
  339. """
  340. def __init__(self, *args, zs=(), zdir='z', **kwargs):
  341. """
  342. Parameters
  343. ----------
  344. verts :
  345. zs : float
  346. The location along the *zdir* axis in 3D space to position the
  347. patch.
  348. zdir : {'x', 'y', 'z'}
  349. Plane to plot patch orthogonal to. Default: 'z'.
  350. See `.get_dir_vector` for a description of the values.
  351. """
  352. super().__init__(*args, **kwargs)
  353. self.set_3d_properties(zs, zdir)
  354. def set_3d_properties(self, verts, zs=0, zdir='z'):
  355. """
  356. Set the *z* position and direction of the patch.
  357. Parameters
  358. ----------
  359. verts :
  360. zs : float
  361. The location along the *zdir* axis in 3D space to position the
  362. patch.
  363. zdir : {'x', 'y', 'z'}
  364. Plane to plot patch orthogonal to. Default: 'z'.
  365. See `.get_dir_vector` for a description of the values.
  366. """
  367. zs = np.broadcast_to(zs, len(verts))
  368. self._segment3d = [juggle_axes(x, y, z, zdir)
  369. for ((x, y), z) in zip(verts, zs)]
  370. def get_path(self):
  371. return self._path2d
  372. def do_3d_projection(self):
  373. s = self._segment3d
  374. xs, ys, zs = zip(*s)
  375. vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
  376. self.axes.M)
  377. self._path2d = mpath.Path(np.column_stack([vxs, vys]))
  378. return min(vzs)
  379. class PathPatch3D(Patch3D):
  380. """
  381. 3D PathPatch object.
  382. """
  383. def __init__(self, path, *, zs=(), zdir='z', **kwargs):
  384. """
  385. Parameters
  386. ----------
  387. path :
  388. zs : float
  389. The location along the *zdir* axis in 3D space to position the
  390. path patch.
  391. zdir : {'x', 'y', 'z', 3-tuple}
  392. Plane to plot path patch orthogonal to. Default: 'z'.
  393. See `.get_dir_vector` for a description of the values.
  394. """
  395. # Not super().__init__!
  396. Patch.__init__(self, **kwargs)
  397. self.set_3d_properties(path, zs, zdir)
  398. def set_3d_properties(self, path, zs=0, zdir='z'):
  399. """
  400. Set the *z* position and direction of the path patch.
  401. Parameters
  402. ----------
  403. path :
  404. zs : float
  405. The location along the *zdir* axis in 3D space to position the
  406. path patch.
  407. zdir : {'x', 'y', 'z', 3-tuple}
  408. Plane to plot path patch orthogonal to. Default: 'z'.
  409. See `.get_dir_vector` for a description of the values.
  410. """
  411. Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir)
  412. self._code3d = path.codes
  413. def do_3d_projection(self):
  414. s = self._segment3d
  415. xs, ys, zs = zip(*s)
  416. vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
  417. self.axes.M)
  418. self._path2d = mpath.Path(np.column_stack([vxs, vys]), self._code3d)
  419. return min(vzs)
  420. def _get_patch_verts(patch):
  421. """Return a list of vertices for the path of a patch."""
  422. trans = patch.get_patch_transform()
  423. path = patch.get_path()
  424. polygons = path.to_polygons(trans)
  425. return polygons[0] if len(polygons) else np.array([])
  426. def patch_2d_to_3d(patch, z=0, zdir='z'):
  427. """Convert a `.Patch` to a `.Patch3D` object."""
  428. verts = _get_patch_verts(patch)
  429. patch.__class__ = Patch3D
  430. patch.set_3d_properties(verts, z, zdir)
  431. def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'):
  432. """Convert a `.PathPatch` to a `.PathPatch3D` object."""
  433. path = pathpatch.get_path()
  434. trans = pathpatch.get_patch_transform()
  435. mpath = trans.transform_path(path)
  436. pathpatch.__class__ = PathPatch3D
  437. pathpatch.set_3d_properties(mpath, z, zdir)
  438. class Patch3DCollection(PatchCollection):
  439. """
  440. A collection of 3D patches.
  441. """
  442. def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
  443. """
  444. Create a collection of flat 3D patches with its normal vector
  445. pointed in *zdir* direction, and located at *zs* on the *zdir*
  446. axis. 'zs' can be a scalar or an array-like of the same length as
  447. the number of patches in the collection.
  448. Constructor arguments are the same as for
  449. :class:`~matplotlib.collections.PatchCollection`. In addition,
  450. keywords *zs=0* and *zdir='z'* are available.
  451. Also, the keyword argument *depthshade* is available to indicate
  452. whether to shade the patches in order to give the appearance of depth
  453. (default is *True*). This is typically desired in scatter plots.
  454. """
  455. self._depthshade = depthshade
  456. super().__init__(*args, **kwargs)
  457. self.set_3d_properties(zs, zdir)
  458. def get_depthshade(self):
  459. return self._depthshade
  460. def set_depthshade(self, depthshade):
  461. """
  462. Set whether depth shading is performed on collection members.
  463. Parameters
  464. ----------
  465. depthshade : bool
  466. Whether to shade the patches in order to give the appearance of
  467. depth.
  468. """
  469. self._depthshade = depthshade
  470. self.stale = True
  471. def set_sort_zpos(self, val):
  472. """Set the position to use for z-sorting."""
  473. self._sort_zpos = val
  474. self.stale = True
  475. def set_3d_properties(self, zs, zdir):
  476. """
  477. Set the *z* positions and direction of the patches.
  478. Parameters
  479. ----------
  480. zs : float or array of floats
  481. The location or locations to place the patches in the collection
  482. along the *zdir* axis.
  483. zdir : {'x', 'y', 'z'}
  484. Plane to plot patches orthogonal to.
  485. All patches must have the same direction.
  486. See `.get_dir_vector` for a description of the values.
  487. """
  488. # Force the collection to initialize the face and edgecolors
  489. # just in case it is a scalarmappable with a colormap.
  490. self.update_scalarmappable()
  491. offsets = self.get_offsets()
  492. if len(offsets) > 0:
  493. xs, ys = offsets.T
  494. else:
  495. xs = []
  496. ys = []
  497. self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir)
  498. self._z_markers_idx = slice(-1)
  499. self._vzs = None
  500. self.stale = True
  501. def do_3d_projection(self):
  502. xs, ys, zs = self._offsets3d
  503. vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
  504. self.axes.M)
  505. self._vzs = vzs
  506. super().set_offsets(np.column_stack([vxs, vys]))
  507. if vzs.size > 0:
  508. return min(vzs)
  509. else:
  510. return np.nan
  511. def _maybe_depth_shade_and_sort_colors(self, color_array):
  512. color_array = (
  513. _zalpha(color_array, self._vzs)
  514. if self._vzs is not None and self._depthshade
  515. else color_array
  516. )
  517. if len(color_array) > 1:
  518. color_array = color_array[self._z_markers_idx]
  519. return mcolors.to_rgba_array(color_array, self._alpha)
  520. def get_facecolor(self):
  521. return self._maybe_depth_shade_and_sort_colors(super().get_facecolor())
  522. def get_edgecolor(self):
  523. # We need this check here to make sure we do not double-apply the depth
  524. # based alpha shading when the edge color is "face" which means the
  525. # edge colour should be identical to the face colour.
  526. if cbook._str_equal(self._edgecolors, 'face'):
  527. return self.get_facecolor()
  528. return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor())
  529. class Path3DCollection(PathCollection):
  530. """
  531. A collection of 3D paths.
  532. """
  533. def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs):
  534. """
  535. Create a collection of flat 3D paths with its normal vector
  536. pointed in *zdir* direction, and located at *zs* on the *zdir*
  537. axis. 'zs' can be a scalar or an array-like of the same length as
  538. the number of paths in the collection.
  539. Constructor arguments are the same as for
  540. :class:`~matplotlib.collections.PathCollection`. In addition,
  541. keywords *zs=0* and *zdir='z'* are available.
  542. Also, the keyword argument *depthshade* is available to indicate
  543. whether to shade the patches in order to give the appearance of depth
  544. (default is *True*). This is typically desired in scatter plots.
  545. """
  546. self._depthshade = depthshade
  547. self._in_draw = False
  548. super().__init__(*args, **kwargs)
  549. self.set_3d_properties(zs, zdir)
  550. self._offset_zordered = None
  551. def draw(self, renderer):
  552. with self._use_zordered_offset():
  553. with cbook._setattr_cm(self, _in_draw=True):
  554. super().draw(renderer)
  555. def set_sort_zpos(self, val):
  556. """Set the position to use for z-sorting."""
  557. self._sort_zpos = val
  558. self.stale = True
  559. def set_3d_properties(self, zs, zdir):
  560. """
  561. Set the *z* positions and direction of the paths.
  562. Parameters
  563. ----------
  564. zs : float or array of floats
  565. The location or locations to place the paths in the collection
  566. along the *zdir* axis.
  567. zdir : {'x', 'y', 'z'}
  568. Plane to plot paths orthogonal to.
  569. All paths must have the same direction.
  570. See `.get_dir_vector` for a description of the values.
  571. """
  572. # Force the collection to initialize the face and edgecolors
  573. # just in case it is a scalarmappable with a colormap.
  574. self.update_scalarmappable()
  575. offsets = self.get_offsets()
  576. if len(offsets) > 0:
  577. xs, ys = offsets.T
  578. else:
  579. xs = []
  580. ys = []
  581. self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir)
  582. # In the base draw methods we access the attributes directly which
  583. # means we cannot resolve the shuffling in the getter methods like
  584. # we do for the edge and face colors.
  585. #
  586. # This means we need to carry around a cache of the unsorted sizes and
  587. # widths (postfixed with 3d) and in `do_3d_projection` set the
  588. # depth-sorted version of that data into the private state used by the
  589. # base collection class in its draw method.
  590. #
  591. # Grab the current sizes and linewidths to preserve them.
  592. self._sizes3d = self._sizes
  593. self._linewidths3d = np.array(self._linewidths)
  594. xs, ys, zs = self._offsets3d
  595. # Sort the points based on z coordinates
  596. # Performance optimization: Create a sorted index array and reorder
  597. # points and point properties according to the index array
  598. self._z_markers_idx = slice(-1)
  599. self._vzs = None
  600. self.stale = True
  601. def set_sizes(self, sizes, dpi=72.0):
  602. super().set_sizes(sizes, dpi)
  603. if not self._in_draw:
  604. self._sizes3d = sizes
  605. def set_linewidth(self, lw):
  606. super().set_linewidth(lw)
  607. if not self._in_draw:
  608. self._linewidths3d = np.array(self._linewidths)
  609. def get_depthshade(self):
  610. return self._depthshade
  611. def set_depthshade(self, depthshade):
  612. """
  613. Set whether depth shading is performed on collection members.
  614. Parameters
  615. ----------
  616. depthshade : bool
  617. Whether to shade the patches in order to give the appearance of
  618. depth.
  619. """
  620. self._depthshade = depthshade
  621. self.stale = True
  622. def do_3d_projection(self):
  623. xs, ys, zs = self._offsets3d
  624. vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs,
  625. self.axes.M)
  626. # Sort the points based on z coordinates
  627. # Performance optimization: Create a sorted index array and reorder
  628. # points and point properties according to the index array
  629. z_markers_idx = self._z_markers_idx = np.argsort(vzs)[::-1]
  630. self._vzs = vzs
  631. # we have to special case the sizes because of code in collections.py
  632. # as the draw method does
  633. # self.set_sizes(self._sizes, self.figure.dpi)
  634. # so we cannot rely on doing the sorting on the way out via get_*
  635. if len(self._sizes3d) > 1:
  636. self._sizes = self._sizes3d[z_markers_idx]
  637. if len(self._linewidths3d) > 1:
  638. self._linewidths = self._linewidths3d[z_markers_idx]
  639. PathCollection.set_offsets(self, np.column_stack((vxs, vys)))
  640. # Re-order items
  641. vzs = vzs[z_markers_idx]
  642. vxs = vxs[z_markers_idx]
  643. vys = vys[z_markers_idx]
  644. # Store ordered offset for drawing purpose
  645. self._offset_zordered = np.column_stack((vxs, vys))
  646. return np.min(vzs) if vzs.size else np.nan
  647. @contextmanager
  648. def _use_zordered_offset(self):
  649. if self._offset_zordered is None:
  650. # Do nothing
  651. yield
  652. else:
  653. # Swap offset with z-ordered offset
  654. old_offset = self._offsets
  655. super().set_offsets(self._offset_zordered)
  656. try:
  657. yield
  658. finally:
  659. self._offsets = old_offset
  660. def _maybe_depth_shade_and_sort_colors(self, color_array):
  661. color_array = (
  662. _zalpha(color_array, self._vzs)
  663. if self._vzs is not None and self._depthshade
  664. else color_array
  665. )
  666. if len(color_array) > 1:
  667. color_array = color_array[self._z_markers_idx]
  668. return mcolors.to_rgba_array(color_array, self._alpha)
  669. def get_facecolor(self):
  670. return self._maybe_depth_shade_and_sort_colors(super().get_facecolor())
  671. def get_edgecolor(self):
  672. # We need this check here to make sure we do not double-apply the depth
  673. # based alpha shading when the edge color is "face" which means the
  674. # edge colour should be identical to the face colour.
  675. if cbook._str_equal(self._edgecolors, 'face'):
  676. return self.get_facecolor()
  677. return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor())
  678. def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True):
  679. """
  680. Convert a `.PatchCollection` into a `.Patch3DCollection` object
  681. (or a `.PathCollection` into a `.Path3DCollection` object).
  682. Parameters
  683. ----------
  684. zs : float or array of floats
  685. The location or locations to place the patches in the collection along
  686. the *zdir* axis. Default: 0.
  687. zdir : {'x', 'y', 'z'}
  688. The axis in which to place the patches. Default: "z".
  689. See `.get_dir_vector` for a description of the values.
  690. depthshade
  691. Whether to shade the patches to give a sense of depth. Default: *True*.
  692. """
  693. if isinstance(col, PathCollection):
  694. col.__class__ = Path3DCollection
  695. col._offset_zordered = None
  696. elif isinstance(col, PatchCollection):
  697. col.__class__ = Patch3DCollection
  698. col._depthshade = depthshade
  699. col._in_draw = False
  700. col.set_3d_properties(zs, zdir)
  701. class Poly3DCollection(PolyCollection):
  702. """
  703. A collection of 3D polygons.
  704. .. note::
  705. **Filling of 3D polygons**
  706. There is no simple definition of the enclosed surface of a 3D polygon
  707. unless the polygon is planar.
  708. In practice, Matplotlib fills the 2D projection of the polygon. This
  709. gives a correct filling appearance only for planar polygons. For all
  710. other polygons, you'll find orientations in which the edges of the
  711. polygon intersect in the projection. This will lead to an incorrect
  712. visualization of the 3D area.
  713. If you need filled areas, it is recommended to create them via
  714. `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_trisurf`, which creates a
  715. triangulation and thus generates consistent surfaces.
  716. """
  717. def __init__(self, verts, *args, zsort='average', shade=False,
  718. lightsource=None, **kwargs):
  719. """
  720. Parameters
  721. ----------
  722. verts : list of (N, 3) array-like
  723. The sequence of polygons [*verts0*, *verts1*, ...] where each
  724. element *verts_i* defines the vertices of polygon *i* as a 2D
  725. array-like of shape (N, 3).
  726. zsort : {'average', 'min', 'max'}, default: 'average'
  727. The calculation method for the z-order.
  728. See `~.Poly3DCollection.set_zsort` for details.
  729. shade : bool, default: False
  730. Whether to shade *facecolors* and *edgecolors*. When activating
  731. *shade*, *facecolors* and/or *edgecolors* must be provided.
  732. .. versionadded:: 3.7
  733. lightsource : `~matplotlib.colors.LightSource`, optional
  734. The lightsource to use when *shade* is True.
  735. .. versionadded:: 3.7
  736. *args, **kwargs
  737. All other parameters are forwarded to `.PolyCollection`.
  738. Notes
  739. -----
  740. Note that this class does a bit of magic with the _facecolors
  741. and _edgecolors properties.
  742. """
  743. if shade:
  744. normals = _generate_normals(verts)
  745. facecolors = kwargs.get('facecolors', None)
  746. if facecolors is not None:
  747. kwargs['facecolors'] = _shade_colors(
  748. facecolors, normals, lightsource
  749. )
  750. edgecolors = kwargs.get('edgecolors', None)
  751. if edgecolors is not None:
  752. kwargs['edgecolors'] = _shade_colors(
  753. edgecolors, normals, lightsource
  754. )
  755. if facecolors is None and edgecolors is None:
  756. raise ValueError(
  757. "You must provide facecolors, edgecolors, or both for "
  758. "shade to work.")
  759. super().__init__(verts, *args, **kwargs)
  760. if isinstance(verts, np.ndarray):
  761. if verts.ndim != 3:
  762. raise ValueError('verts must be a list of (N, 3) array-like')
  763. else:
  764. if any(len(np.shape(vert)) != 2 for vert in verts):
  765. raise ValueError('verts must be a list of (N, 3) array-like')
  766. self.set_zsort(zsort)
  767. self._codes3d = None
  768. _zsort_functions = {
  769. 'average': np.average,
  770. 'min': np.min,
  771. 'max': np.max,
  772. }
  773. def set_zsort(self, zsort):
  774. """
  775. Set the calculation method for the z-order.
  776. Parameters
  777. ----------
  778. zsort : {'average', 'min', 'max'}
  779. The function applied on the z-coordinates of the vertices in the
  780. viewer's coordinate system, to determine the z-order.
  781. """
  782. self._zsortfunc = self._zsort_functions[zsort]
  783. self._sort_zpos = None
  784. self.stale = True
  785. def get_vector(self, segments3d):
  786. """Optimize points for projection."""
  787. if len(segments3d):
  788. xs, ys, zs = np.vstack(segments3d).T
  789. else: # vstack can't stack zero arrays.
  790. xs, ys, zs = [], [], []
  791. ones = np.ones(len(xs))
  792. self._vec = np.array([xs, ys, zs, ones])
  793. indices = [0, *np.cumsum([len(segment) for segment in segments3d])]
  794. self._segslices = [*map(slice, indices[:-1], indices[1:])]
  795. def set_verts(self, verts, closed=True):
  796. """
  797. Set 3D vertices.
  798. Parameters
  799. ----------
  800. verts : list of (N, 3) array-like
  801. The sequence of polygons [*verts0*, *verts1*, ...] where each
  802. element *verts_i* defines the vertices of polygon *i* as a 2D
  803. array-like of shape (N, 3).
  804. closed : bool, default: True
  805. Whether the polygon should be closed by adding a CLOSEPOLY
  806. connection at the end.
  807. """
  808. self.get_vector(verts)
  809. # 2D verts will be updated at draw time
  810. super().set_verts([], False)
  811. self._closed = closed
  812. def set_verts_and_codes(self, verts, codes):
  813. """Set 3D vertices with path codes."""
  814. # set vertices with closed=False to prevent PolyCollection from
  815. # setting path codes
  816. self.set_verts(verts, closed=False)
  817. # and set our own codes instead.
  818. self._codes3d = codes
  819. def set_3d_properties(self):
  820. # Force the collection to initialize the face and edgecolors
  821. # just in case it is a scalarmappable with a colormap.
  822. self.update_scalarmappable()
  823. self._sort_zpos = None
  824. self.set_zsort('average')
  825. self._facecolor3d = PolyCollection.get_facecolor(self)
  826. self._edgecolor3d = PolyCollection.get_edgecolor(self)
  827. self._alpha3d = PolyCollection.get_alpha(self)
  828. self.stale = True
  829. def set_sort_zpos(self, val):
  830. """Set the position to use for z-sorting."""
  831. self._sort_zpos = val
  832. self.stale = True
  833. def do_3d_projection(self):
  834. """
  835. Perform the 3D projection for this object.
  836. """
  837. if self._A is not None:
  838. # force update of color mapping because we re-order them
  839. # below. If we do not do this here, the 2D draw will call
  840. # this, but we will never port the color mapped values back
  841. # to the 3D versions.
  842. #
  843. # We hold the 3D versions in a fixed order (the order the user
  844. # passed in) and sort the 2D version by view depth.
  845. self.update_scalarmappable()
  846. if self._face_is_mapped:
  847. self._facecolor3d = self._facecolors
  848. if self._edge_is_mapped:
  849. self._edgecolor3d = self._edgecolors
  850. txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M)
  851. xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices]
  852. # This extra fuss is to re-order face / edge colors
  853. cface = self._facecolor3d
  854. cedge = self._edgecolor3d
  855. if len(cface) != len(xyzlist):
  856. cface = cface.repeat(len(xyzlist), axis=0)
  857. if len(cedge) != len(xyzlist):
  858. if len(cedge) == 0:
  859. cedge = cface
  860. else:
  861. cedge = cedge.repeat(len(xyzlist), axis=0)
  862. if xyzlist:
  863. # sort by depth (furthest drawn first)
  864. z_segments_2d = sorted(
  865. ((self._zsortfunc(zs), np.column_stack([xs, ys]), fc, ec, idx)
  866. for idx, ((xs, ys, zs), fc, ec)
  867. in enumerate(zip(xyzlist, cface, cedge))),
  868. key=lambda x: x[0], reverse=True)
  869. _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \
  870. zip(*z_segments_2d)
  871. else:
  872. segments_2d = []
  873. self._facecolors2d = np.empty((0, 4))
  874. self._edgecolors2d = np.empty((0, 4))
  875. idxs = []
  876. if self._codes3d is not None:
  877. codes = [self._codes3d[idx] for idx in idxs]
  878. PolyCollection.set_verts_and_codes(self, segments_2d, codes)
  879. else:
  880. PolyCollection.set_verts(self, segments_2d, self._closed)
  881. if len(self._edgecolor3d) != len(cface):
  882. self._edgecolors2d = self._edgecolor3d
  883. # Return zorder value
  884. if self._sort_zpos is not None:
  885. zvec = np.array([[0], [0], [self._sort_zpos], [1]])
  886. ztrans = proj3d._proj_transform_vec(zvec, self.axes.M)
  887. return ztrans[2][0]
  888. elif tzs.size > 0:
  889. # FIXME: Some results still don't look quite right.
  890. # In particular, examine contourf3d_demo2.py
  891. # with az = -54 and elev = -45.
  892. return np.min(tzs)
  893. else:
  894. return np.nan
  895. def set_facecolor(self, colors):
  896. # docstring inherited
  897. super().set_facecolor(colors)
  898. self._facecolor3d = PolyCollection.get_facecolor(self)
  899. def set_edgecolor(self, colors):
  900. # docstring inherited
  901. super().set_edgecolor(colors)
  902. self._edgecolor3d = PolyCollection.get_edgecolor(self)
  903. def set_alpha(self, alpha):
  904. # docstring inherited
  905. artist.Artist.set_alpha(self, alpha)
  906. try:
  907. self._facecolor3d = mcolors.to_rgba_array(
  908. self._facecolor3d, self._alpha)
  909. except (AttributeError, TypeError, IndexError):
  910. pass
  911. try:
  912. self._edgecolors = mcolors.to_rgba_array(
  913. self._edgecolor3d, self._alpha)
  914. except (AttributeError, TypeError, IndexError):
  915. pass
  916. self.stale = True
  917. def get_facecolor(self):
  918. # docstring inherited
  919. # self._facecolors2d is not initialized until do_3d_projection
  920. if not hasattr(self, '_facecolors2d'):
  921. self.axes.M = self.axes.get_proj()
  922. self.do_3d_projection()
  923. return np.asarray(self._facecolors2d)
  924. def get_edgecolor(self):
  925. # docstring inherited
  926. # self._edgecolors2d is not initialized until do_3d_projection
  927. if not hasattr(self, '_edgecolors2d'):
  928. self.axes.M = self.axes.get_proj()
  929. self.do_3d_projection()
  930. return np.asarray(self._edgecolors2d)
  931. def poly_collection_2d_to_3d(col, zs=0, zdir='z'):
  932. """
  933. Convert a `.PolyCollection` into a `.Poly3DCollection` object.
  934. Parameters
  935. ----------
  936. zs : float or array of floats
  937. The location or locations to place the polygons in the collection along
  938. the *zdir* axis. Default: 0.
  939. zdir : {'x', 'y', 'z'}
  940. The axis in which to place the patches. Default: 'z'.
  941. See `.get_dir_vector` for a description of the values.
  942. """
  943. segments_3d, codes = _paths_to_3d_segments_with_codes(
  944. col.get_paths(), zs, zdir)
  945. col.__class__ = Poly3DCollection
  946. col.set_verts_and_codes(segments_3d, codes)
  947. col.set_3d_properties()
  948. def juggle_axes(xs, ys, zs, zdir):
  949. """
  950. Reorder coordinates so that 2D *xs*, *ys* can be plotted in the plane
  951. orthogonal to *zdir*. *zdir* is normally 'x', 'y' or 'z'. However, if
  952. *zdir* starts with a '-' it is interpreted as a compensation for
  953. `rotate_axes`.
  954. """
  955. if zdir == 'x':
  956. return zs, xs, ys
  957. elif zdir == 'y':
  958. return xs, zs, ys
  959. elif zdir[0] == '-':
  960. return rotate_axes(xs, ys, zs, zdir)
  961. else:
  962. return xs, ys, zs
  963. def rotate_axes(xs, ys, zs, zdir):
  964. """
  965. Reorder coordinates so that the axes are rotated with *zdir* along
  966. the original z axis. Prepending the axis with a '-' does the
  967. inverse transform, so *zdir* can be 'x', '-x', 'y', '-y', 'z' or '-z'.
  968. """
  969. if zdir in ('x', '-y'):
  970. return ys, zs, xs
  971. elif zdir in ('-x', 'y'):
  972. return zs, xs, ys
  973. else:
  974. return xs, ys, zs
  975. def _zalpha(colors, zs):
  976. """Modify the alphas of the color list according to depth."""
  977. # FIXME: This only works well if the points for *zs* are well-spaced
  978. # in all three dimensions. Otherwise, at certain orientations,
  979. # the min and max zs are very close together.
  980. # Should really normalize against the viewing depth.
  981. if len(colors) == 0 or len(zs) == 0:
  982. return np.zeros((0, 4))
  983. norm = Normalize(min(zs), max(zs))
  984. sats = 1 - norm(zs) * 0.7
  985. rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4))
  986. return np.column_stack([rgba[:, :3], rgba[:, 3] * sats])
  987. def _generate_normals(polygons):
  988. """
  989. Compute the normals of a list of polygons, one normal per polygon.
  990. Normals point towards the viewer for a face with its vertices in
  991. counterclockwise order, following the right hand rule.
  992. Uses three points equally spaced around the polygon. This method assumes
  993. that the points are in a plane. Otherwise, more than one shade is required,
  994. which is not supported.
  995. Parameters
  996. ----------
  997. polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like
  998. A sequence of polygons to compute normals for, which can have
  999. varying numbers of vertices. If the polygons all have the same
  1000. number of vertices and array is passed, then the operation will
  1001. be vectorized.
  1002. Returns
  1003. -------
  1004. normals : (..., 3) array
  1005. A normal vector estimated for the polygon.
  1006. """
  1007. if isinstance(polygons, np.ndarray):
  1008. # optimization: polygons all have the same number of points, so can
  1009. # vectorize
  1010. n = polygons.shape[-2]
  1011. i1, i2, i3 = 0, n//3, 2*n//3
  1012. v1 = polygons[..., i1, :] - polygons[..., i2, :]
  1013. v2 = polygons[..., i2, :] - polygons[..., i3, :]
  1014. else:
  1015. # The subtraction doesn't vectorize because polygons is jagged.
  1016. v1 = np.empty((len(polygons), 3))
  1017. v2 = np.empty((len(polygons), 3))
  1018. for poly_i, ps in enumerate(polygons):
  1019. n = len(ps)
  1020. i1, i2, i3 = 0, n//3, 2*n//3
  1021. v1[poly_i, :] = ps[i1, :] - ps[i2, :]
  1022. v2[poly_i, :] = ps[i2, :] - ps[i3, :]
  1023. return np.cross(v1, v2)
  1024. def _shade_colors(color, normals, lightsource=None):
  1025. """
  1026. Shade *color* using normal vectors given by *normals*,
  1027. assuming a *lightsource* (using default position if not given).
  1028. *color* can also be an array of the same length as *normals*.
  1029. """
  1030. if lightsource is None:
  1031. # chosen for backwards-compatibility
  1032. lightsource = mcolors.LightSource(azdeg=225, altdeg=19.4712)
  1033. with np.errstate(invalid="ignore"):
  1034. shade = ((normals / np.linalg.norm(normals, axis=1, keepdims=True))
  1035. @ lightsource.direction)
  1036. mask = ~np.isnan(shade)
  1037. if mask.any():
  1038. # convert dot product to allowed shading fractions
  1039. in_norm = mcolors.Normalize(-1, 1)
  1040. out_norm = mcolors.Normalize(0.3, 1).inverse
  1041. def norm(x):
  1042. return out_norm(in_norm(x))
  1043. shade[~mask] = 0
  1044. color = mcolors.to_rgba_array(color)
  1045. # shape of color should be (M, 4) (where M is number of faces)
  1046. # shape of shade should be (M,)
  1047. # colors should have final shape of (M, 4)
  1048. alpha = color[:, 3]
  1049. colors = norm(shade)[:, np.newaxis] * color
  1050. colors[:, 3] = alpha
  1051. else:
  1052. colors = np.asanyarray(color).copy()
  1053. return colors