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