Coverage for /usr/lib/python3/dist-packages/mpl_toolkits/mplot3d/axis3d.py: 89%
297 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1# axis3d.py, original mplot3d version by John Porter
2# Created: 23 Sep 2005
3# Parts rewritten by Reinier Heeres <reinier@heeres.eu>
5import inspect
7import numpy as np
9import matplotlib as mpl
10from matplotlib import (
11 _api, artist, lines as mlines, axis as maxis, patches as mpatches,
12 transforms as mtransforms, colors as mcolors)
13from . import art3d, proj3d
16@_api.deprecated("3.6", alternative="a vendored copy of _move_from_center")
17def move_from_center(coord, centers, deltas, axmask=(True, True, True)):
18 """
19 For each coordinate where *axmask* is True, move *coord* away from
20 *centers* by *deltas*.
21 """
22 return _move_from_center(coord, centers, deltas, axmask=axmask)
25def _move_from_center(coord, centers, deltas, axmask=(True, True, True)):
26 """
27 For each coordinate where *axmask* is True, move *coord* away from
28 *centers* by *deltas*.
29 """
30 coord = np.asarray(coord)
31 return coord + axmask * np.copysign(1, coord - centers) * deltas
34@_api.deprecated("3.6", alternative="a vendored copy of _tick_update_position")
35def tick_update_position(tick, tickxs, tickys, labelpos):
36 """Update tick line and label position and style."""
37 _tick_update_position(tick, tickxs, tickys, labelpos)
40def _tick_update_position(tick, tickxs, tickys, labelpos):
41 """Update tick line and label position and style."""
43 tick.label1.set_position(labelpos)
44 tick.label2.set_position(labelpos)
45 tick.tick1line.set_visible(True)
46 tick.tick2line.set_visible(False)
47 tick.tick1line.set_linestyle('-')
48 tick.tick1line.set_marker('')
49 tick.tick1line.set_data(tickxs, tickys)
50 tick.gridline.set_data(0, 0)
53class Axis(maxis.XAxis):
54 """An Axis class for the 3D plots."""
55 # These points from the unit cube make up the x, y and z-planes
56 _PLANES = (
57 (0, 3, 7, 4), (1, 2, 6, 5), # yz planes
58 (0, 1, 5, 4), (3, 2, 6, 7), # xz planes
59 (0, 1, 2, 3), (4, 5, 6, 7), # xy planes
60 )
62 # Some properties for the axes
63 _AXINFO = {
64 'x': {'i': 0, 'tickdir': 1, 'juggled': (1, 0, 2),
65 'color': (0.95, 0.95, 0.95, 0.5)},
66 'y': {'i': 1, 'tickdir': 0, 'juggled': (0, 1, 2),
67 'color': (0.90, 0.90, 0.90, 0.5)},
68 'z': {'i': 2, 'tickdir': 0, 'juggled': (0, 2, 1),
69 'color': (0.925, 0.925, 0.925, 0.5)},
70 }
72 def _old_init(self, adir, v_intervalx, d_intervalx, axes, *args,
73 rotate_label=None, **kwargs):
74 return locals()
76 def _new_init(self, axes, *, rotate_label=None, **kwargs):
77 return locals()
79 def __init__(self, *args, **kwargs):
80 params = _api.select_matching_signature(
81 [self._old_init, self._new_init], *args, **kwargs)
82 if "adir" in params:
83 _api.warn_deprecated(
84 "3.6", message=f"The signature of 3D Axis constructors has "
85 f"changed in %(since)s; the new signature is "
86 f"{inspect.signature(type(self).__init__)}", pending=True)
87 if params["adir"] != self.axis_name:
88 raise ValueError(f"Cannot instantiate {type(self).__name__} "
89 f"with adir={params['adir']!r}")
90 axes = params["axes"]
91 rotate_label = params["rotate_label"]
92 args = params.get("args", ())
93 kwargs = params["kwargs"]
95 name = self.axis_name
97 # This is a temporary member variable.
98 # Do not depend on this existing in future releases!
99 self._axinfo = self._AXINFO[name].copy()
100 if mpl.rcParams['_internal.classic_mode']:
101 self._axinfo.update({
102 'label': {'va': 'center', 'ha': 'center'},
103 'tick': {
104 'inward_factor': 0.2,
105 'outward_factor': 0.1,
106 'linewidth': {
107 True: mpl.rcParams['lines.linewidth'], # major
108 False: mpl.rcParams['lines.linewidth'], # minor
109 }
110 },
111 'axisline': {'linewidth': 0.75, 'color': (0, 0, 0, 1)},
112 'grid': {
113 'color': (0.9, 0.9, 0.9, 1),
114 'linewidth': 1.0,
115 'linestyle': '-',
116 },
117 })
118 else:
119 self._axinfo.update({
120 'label': {'va': 'center', 'ha': 'center'},
121 'tick': {
122 'inward_factor': 0.2,
123 'outward_factor': 0.1,
124 'linewidth': {
125 True: ( # major
126 mpl.rcParams['xtick.major.width'] if name in 'xz'
127 else mpl.rcParams['ytick.major.width']),
128 False: ( # minor
129 mpl.rcParams['xtick.minor.width'] if name in 'xz'
130 else mpl.rcParams['ytick.minor.width']),
131 }
132 },
133 'axisline': {
134 'linewidth': mpl.rcParams['axes.linewidth'],
135 'color': mpl.rcParams['axes.edgecolor'],
136 },
137 'grid': {
138 'color': mpl.rcParams['grid.color'],
139 'linewidth': mpl.rcParams['grid.linewidth'],
140 'linestyle': mpl.rcParams['grid.linestyle'],
141 },
142 })
144 super().__init__(axes, *args, **kwargs)
146 # data and viewing intervals for this direction
147 if "d_intervalx" in params:
148 self.set_data_interval(*params["d_intervalx"])
149 if "v_intervalx" in params:
150 self.set_view_interval(*params["v_intervalx"])
151 self.set_rotate_label(rotate_label)
152 self._init3d() # Inline after init3d deprecation elapses.
154 __init__.__signature__ = inspect.signature(_new_init)
155 adir = _api.deprecated("3.6", pending=True)(
156 property(lambda self: self.axis_name))
158 def _init3d(self):
159 self.line = mlines.Line2D(
160 xdata=(0, 0), ydata=(0, 0),
161 linewidth=self._axinfo['axisline']['linewidth'],
162 color=self._axinfo['axisline']['color'],
163 antialiased=True)
165 # Store dummy data in Polygon object
166 self.pane = mpatches.Polygon(
167 np.array([[0, 0], [0, 1]]), closed=False)
168 self.set_pane_color(self._axinfo['color'])
170 self.axes._set_artist_props(self.line)
171 self.axes._set_artist_props(self.pane)
172 self.gridlines = art3d.Line3DCollection([])
173 self.axes._set_artist_props(self.gridlines)
174 self.axes._set_artist_props(self.label)
175 self.axes._set_artist_props(self.offsetText)
176 # Need to be able to place the label at the correct location
177 self.label._transform = self.axes.transData
178 self.offsetText._transform = self.axes.transData
180 @_api.deprecated("3.6", pending=True)
181 def init3d(self): # After deprecation elapses, inline _init3d to __init__.
182 self._init3d()
184 def get_major_ticks(self, numticks=None):
185 ticks = super().get_major_ticks(numticks)
186 for t in ticks:
187 for obj in [
188 t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]:
189 obj.set_transform(self.axes.transData)
190 return ticks
192 def get_minor_ticks(self, numticks=None):
193 ticks = super().get_minor_ticks(numticks)
194 for t in ticks:
195 for obj in [
196 t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]:
197 obj.set_transform(self.axes.transData)
198 return ticks
200 @_api.deprecated("3.6")
201 def set_pane_pos(self, xys):
202 self._set_pane_pos(xys)
204 def _set_pane_pos(self, xys):
205 xys = np.asarray(xys)
206 xys = xys[:, :2]
207 self.pane.xy = xys
208 self.stale = True
210 def set_pane_color(self, color, alpha=None):
211 """
212 Set pane color.
214 Parameters
215 ----------
216 color : color
217 Color for axis pane.
218 alpha : float, optional
219 Alpha value for axis pane. If None, base it on *color*.
220 """
221 color = mcolors.to_rgba(color, alpha)
222 self._axinfo['color'] = color
223 self.pane.set_edgecolor(color)
224 self.pane.set_facecolor(color)
225 self.pane.set_alpha(color[-1])
226 self.stale = True
228 def set_rotate_label(self, val):
229 """
230 Whether to rotate the axis label: True, False or None.
231 If set to None the label will be rotated if longer than 4 chars.
232 """
233 self._rotate_label = val
234 self.stale = True
236 def get_rotate_label(self, text):
237 if self._rotate_label is not None:
238 return self._rotate_label
239 else:
240 return len(text) > 4
242 def _get_coord_info(self, renderer):
243 mins, maxs = np.array([
244 self.axes.get_xbound(),
245 self.axes.get_ybound(),
246 self.axes.get_zbound(),
247 ]).T
249 # Get the mean value for each bound:
250 centers = 0.5 * (maxs + mins)
252 # Add a small offset between min/max point and the edge of the
253 # plot:
254 deltas = (maxs - mins) / 12
255 mins -= 0.25 * deltas
256 maxs += 0.25 * deltas
258 # Project the bounds along the current position of the cube:
259 bounds = mins[0], maxs[0], mins[1], maxs[1], mins[2], maxs[2]
260 bounds_proj = self.axes.tunit_cube(bounds, self.axes.M)
262 # Determine which one of the parallel planes are higher up:
263 means_z0 = np.zeros(3)
264 means_z1 = np.zeros(3)
265 for i in range(3):
266 means_z0[i] = np.mean(bounds_proj[self._PLANES[2 * i], 2])
267 means_z1[i] = np.mean(bounds_proj[self._PLANES[2 * i + 1], 2])
268 highs = means_z0 < means_z1
270 # Special handling for edge-on views
271 equals = np.abs(means_z0 - means_z1) <= np.finfo(float).eps
272 if np.sum(equals) == 2:
273 vertical = np.where(~equals)[0][0]
274 if vertical == 2: # looking at XY plane
275 highs = np.array([True, True, highs[2]])
276 elif vertical == 1: # looking at XZ plane
277 highs = np.array([True, highs[1], False])
278 elif vertical == 0: # looking at YZ plane
279 highs = np.array([highs[0], False, False])
281 return mins, maxs, centers, deltas, bounds_proj, highs
283 def _get_axis_line_edge_points(self, minmax, maxmin):
284 """Get the edge points for the black bolded axis line."""
285 # When changing vertical axis some of the axes has to be
286 # moved to the other plane so it looks the same as if the z-axis
287 # was the vertical axis.
288 mb = [minmax, maxmin]
289 mb_rev = mb[::-1]
290 mm = [[mb, mb_rev, mb_rev], [mb_rev, mb_rev, mb], [mb, mb, mb]]
291 mm = mm[self.axes._vertical_axis][self._axinfo["i"]]
293 juggled = self._axinfo["juggled"]
294 edge_point_0 = mm[0].copy()
295 edge_point_0[juggled[0]] = mm[1][juggled[0]]
297 edge_point_1 = edge_point_0.copy()
298 edge_point_1[juggled[1]] = mm[1][juggled[1]]
300 return edge_point_0, edge_point_1
302 def _get_tickdir(self):
303 """
304 Get the direction of the tick.
306 Returns
307 -------
308 tickdir : int
309 Index which indicates which coordinate the tick line will
310 align with.
311 """
312 # TODO: Move somewhere else where it's triggered less:
313 tickdirs_base = [v["tickdir"] for v in self._AXINFO.values()]
314 info_i = [v["i"] for v in self._AXINFO.values()]
316 i = self._axinfo["i"]
317 j = self.axes._vertical_axis - 2
318 # tickdir = [[1, 2, 1], [2, 2, 0], [1, 0, 0]][i]
319 tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i]
320 return tickdir
322 def draw_pane(self, renderer):
323 renderer.open_group('pane3d', gid=self.get_gid())
325 mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
327 info = self._axinfo
328 index = info['i']
329 if not highs[index]:
330 plane = self._PLANES[2 * index]
331 else:
332 plane = self._PLANES[2 * index + 1]
333 xys = [tc[p] for p in plane]
334 self._set_pane_pos(xys)
335 self.pane.draw(renderer)
337 renderer.close_group('pane3d')
339 @artist.allow_rasterization
340 def draw(self, renderer):
341 self.label._transform = self.axes.transData
342 renderer.open_group("axis3d", gid=self.get_gid())
344 ticks = self._update_ticks()
346 # Get general axis information:
347 info = self._axinfo
348 index = info["i"]
349 juggled = info["juggled"]
351 mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
353 minmax = np.where(highs, maxs, mins)
354 maxmin = np.where(~highs, maxs, mins)
356 # Create edge points for the black bolded axis line:
357 edgep1, edgep2 = self._get_axis_line_edge_points(minmax, maxmin)
359 # Project the edge points along the current position and
360 # create the line:
361 pep = proj3d.proj_trans_points([edgep1, edgep2], self.axes.M)
362 pep = np.asarray(pep)
363 self.line.set_data(pep[0], pep[1])
364 self.line.draw(renderer)
366 # Draw labels
367 # The transAxes transform is used because the Text object
368 # rotates the text relative to the display coordinate system.
369 # Therefore, if we want the labels to remain parallel to the
370 # axis regardless of the aspect ratio, we need to convert the
371 # edge points of the plane to display coordinates and calculate
372 # an angle from that.
373 # TODO: Maybe Text objects should handle this themselves?
374 dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) -
375 self.axes.transAxes.transform([pep[0:2, 0]]))[0]
377 lxyz = 0.5 * (edgep1 + edgep2)
379 # A rough estimate; points are ambiguous since 3D plots rotate
380 reltoinches = self.figure.dpi_scale_trans.inverted()
381 ax_inches = reltoinches.transform(self.axes.bbox.size)
382 ax_points_estimate = sum(72. * ax_inches)
383 deltas_per_point = 48 / ax_points_estimate
384 default_offset = 21.
385 labeldeltas = (
386 (self.labelpad + default_offset) * deltas_per_point * deltas)
387 axmask = [True, True, True]
388 axmask[index] = False
389 lxyz = _move_from_center(lxyz, centers, labeldeltas, axmask)
390 tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M)
391 self.label.set_position((tlx, tly))
392 if self.get_rotate_label(self.label.get_text()):
393 angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx)))
394 self.label.set_rotation(angle)
395 self.label.set_va(info['label']['va'])
396 self.label.set_ha(info['label']['ha'])
397 self.label.draw(renderer)
399 # Draw Offset text
401 # Which of the two edge points do we want to
402 # use for locating the offset text?
403 if juggled[2] == 2:
404 outeredgep = edgep1
405 outerindex = 0
406 else:
407 outeredgep = edgep2
408 outerindex = 1
410 pos = _move_from_center(outeredgep, centers, labeldeltas, axmask)
411 olx, oly, olz = proj3d.proj_transform(*pos, self.axes.M)
412 self.offsetText.set_text(self.major.formatter.get_offset())
413 self.offsetText.set_position((olx, oly))
414 angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx)))
415 self.offsetText.set_rotation(angle)
416 # Must set rotation mode to "anchor" so that
417 # the alignment point is used as the "fulcrum" for rotation.
418 self.offsetText.set_rotation_mode('anchor')
420 # ----------------------------------------------------------------------
421 # Note: the following statement for determining the proper alignment of
422 # the offset text. This was determined entirely by trial-and-error
423 # and should not be in any way considered as "the way". There are
424 # still some edge cases where alignment is not quite right, but this
425 # seems to be more of a geometry issue (in other words, I might be
426 # using the wrong reference points).
427 #
428 # (TT, FF, TF, FT) are the shorthand for the tuple of
429 # (centpt[info['tickdir']] <= pep[info['tickdir'], outerindex],
430 # centpt[index] <= pep[index, outerindex])
431 #
432 # Three-letters (e.g., TFT, FTT) are short-hand for the array of bools
433 # from the variable 'highs'.
434 # ---------------------------------------------------------------------
435 centpt = proj3d.proj_transform(*centers, self.axes.M)
436 if centpt[info['tickdir']] > pep[info['tickdir'], outerindex]:
437 # if FT and if highs has an even number of Trues
438 if (centpt[index] <= pep[index, outerindex]
439 and np.count_nonzero(highs) % 2 == 0):
440 # Usually, this means align right, except for the FTT case,
441 # in which offset for axis 1 and 2 are aligned left.
442 if highs.tolist() == [False, True, True] and index in (1, 2):
443 align = 'left'
444 else:
445 align = 'right'
446 else:
447 # The FF case
448 align = 'left'
449 else:
450 # if TF and if highs has an even number of Trues
451 if (centpt[index] > pep[index, outerindex]
452 and np.count_nonzero(highs) % 2 == 0):
453 # Usually mean align left, except if it is axis 2
454 align = 'right' if index == 2 else 'left'
455 else:
456 # The TT case
457 align = 'right'
459 self.offsetText.set_va('center')
460 self.offsetText.set_ha(align)
461 self.offsetText.draw(renderer)
463 if self.axes._draw_grid and len(ticks):
464 # Grid points where the planes meet
465 xyz0 = np.tile(minmax, (len(ticks), 1))
466 xyz0[:, index] = [tick.get_loc() for tick in ticks]
468 # Grid lines go from the end of one plane through the plane
469 # intersection (at xyz0) to the end of the other plane. The first
470 # point (0) differs along dimension index-2 and the last (2) along
471 # dimension index-1.
472 lines = np.stack([xyz0, xyz0, xyz0], axis=1)
473 lines[:, 0, index - 2] = maxmin[index - 2]
474 lines[:, 2, index - 1] = maxmin[index - 1]
475 self.gridlines.set_segments(lines)
476 gridinfo = info['grid']
477 self.gridlines.set_color(gridinfo['color'])
478 self.gridlines.set_linewidth(gridinfo['linewidth'])
479 self.gridlines.set_linestyle(gridinfo['linestyle'])
480 self.gridlines.do_3d_projection()
481 self.gridlines.draw(renderer)
483 # Draw ticks:
484 tickdir = self._get_tickdir()
485 tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir]
487 tick_info = info['tick']
488 tick_out = tick_info['outward_factor'] * tickdelta
489 tick_in = tick_info['inward_factor'] * tickdelta
490 tick_lw = tick_info['linewidth']
491 edgep1_tickdir = edgep1[tickdir]
492 out_tickdir = edgep1_tickdir + tick_out
493 in_tickdir = edgep1_tickdir - tick_in
495 default_label_offset = 8. # A rough estimate
496 points = deltas_per_point * deltas
497 for tick in ticks:
498 # Get tick line positions
499 pos = edgep1.copy()
500 pos[index] = tick.get_loc()
501 pos[tickdir] = out_tickdir
502 x1, y1, z1 = proj3d.proj_transform(*pos, self.axes.M)
503 pos[tickdir] = in_tickdir
504 x2, y2, z2 = proj3d.proj_transform(*pos, self.axes.M)
506 # Get position of label
507 labeldeltas = (tick.get_pad() + default_label_offset) * points
509 pos[tickdir] = edgep1_tickdir
510 pos = _move_from_center(pos, centers, labeldeltas, axmask)
511 lx, ly, lz = proj3d.proj_transform(*pos, self.axes.M)
513 _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly))
514 tick.tick1line.set_linewidth(tick_lw[tick._major])
515 tick.draw(renderer)
517 renderer.close_group('axis3d')
518 self.stale = False
520 # TODO: Get this to work (more) properly when mplot3d supports the
521 # transforms framework.
522 def get_tightbbox(self, renderer=None, *, for_layout_only=False):
523 # docstring inherited
524 if not self.get_visible():
525 return
526 # We have to directly access the internal data structures
527 # (and hope they are up to date) because at draw time we
528 # shift the ticks and their labels around in (x, y) space
529 # based on the projection, the current view port, and their
530 # position in 3D space. If we extend the transforms framework
531 # into 3D we would not need to do this different book keeping
532 # than we do in the normal axis
533 major_locs = self.get_majorticklocs()
534 minor_locs = self.get_minorticklocs()
536 ticks = [*self.get_minor_ticks(len(minor_locs)),
537 *self.get_major_ticks(len(major_locs))]
538 view_low, view_high = self.get_view_interval()
539 if view_low > view_high:
540 view_low, view_high = view_high, view_low
541 interval_t = self.get_transform().transform([view_low, view_high])
543 ticks_to_draw = []
544 for tick in ticks:
545 try:
546 loc_t = self.get_transform().transform(tick.get_loc())
547 except AssertionError:
548 # Transform.transform doesn't allow masked values but
549 # some scales might make them, so we need this try/except.
550 pass
551 else:
552 if mtransforms._interval_contains_close(interval_t, loc_t):
553 ticks_to_draw.append(tick)
555 ticks = ticks_to_draw
557 bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer)
558 other = []
560 if self.line.get_visible():
561 other.append(self.line.get_window_extent(renderer))
562 if (self.label.get_visible() and not for_layout_only and
563 self.label.get_text()):
564 other.append(self.label.get_window_extent(renderer))
566 return mtransforms.Bbox.union([*bb_1, *bb_2, *other])
568 d_interval = _api.deprecated(
569 "3.6", alternative="get_data_interval", pending=True)(
570 property(lambda self: self.get_data_interval(),
571 lambda self, minmax: self.set_data_interval(*minmax)))
572 v_interval = _api.deprecated(
573 "3.6", alternative="get_view_interval", pending=True)(
574 property(lambda self: self.get_view_interval(),
575 lambda self, minmax: self.set_view_interval(*minmax)))
578class XAxis(Axis):
579 axis_name = "x"
580 get_view_interval, set_view_interval = maxis._make_getset_interval(
581 "view", "xy_viewLim", "intervalx")
582 get_data_interval, set_data_interval = maxis._make_getset_interval(
583 "data", "xy_dataLim", "intervalx")
586class YAxis(Axis):
587 axis_name = "y"
588 get_view_interval, set_view_interval = maxis._make_getset_interval(
589 "view", "xy_viewLim", "intervaly")
590 get_data_interval, set_data_interval = maxis._make_getset_interval(
591 "data", "xy_dataLim", "intervaly")
594class ZAxis(Axis):
595 axis_name = "z"
596 get_view_interval, set_view_interval = maxis._make_getset_interval(
597 "view", "zz_viewLim", "intervalx")
598 get_data_interval, set_data_interval = maxis._make_getset_interval(
599 "data", "zz_dataLim", "intervalx")