From a924b132e197112e3e47a1b40ad4e92c0cac99c4 Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sun, 24 Jul 2022 22:32:00 -0600 Subject: [PATCH 1/4] Rework 3d coordinates mouse hover, now displays coordinate of underlying view pane Get 3d ortho coordinates working Get non-1 focal lengths working Docs --- .../next_whats_new/3d_hover_coordinates.rst | 9 ++ lib/mpl_toolkits/mplot3d/axes3d.py | 149 ++++++++++++------ lib/mpl_toolkits/mplot3d/axis3d.py | 27 ++-- lib/mpl_toolkits/mplot3d/proj3d.py | 32 +--- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 20 ++- 5 files changed, 150 insertions(+), 87 deletions(-) create mode 100644 doc/users/next_whats_new/3d_hover_coordinates.rst diff --git a/doc/users/next_whats_new/3d_hover_coordinates.rst b/doc/users/next_whats_new/3d_hover_coordinates.rst new file mode 100644 index 000000000000..c908bdb391c4 --- /dev/null +++ b/doc/users/next_whats_new/3d_hover_coordinates.rst @@ -0,0 +1,9 @@ +3D hover coordinates +-------------------- + +The x, y, z coordinates displayed in 3D plots were previously showing +nonsensical values. This has been fixed to report the coordinate on the view +pane directly beneath the mouse cursor. This is likely to be most useful when +viewing 3D plots along a primary axis direction when using an orthographic +projection. Note that there is still no way to directly display the coordinates +of the plotted data points. diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 25cf17cab126..fbf9ac1cbcf5 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1060,45 +1060,97 @@ def format_zdata(self, z): val = func(z) return val - def format_coord(self, xd, yd): + def format_coord(self, xv, yv, renderer=None): """ - Given the 2D view coordinates attempt to guess a 3D coordinate. - Looks for the nearest edge to the point and then assumes that - the point is at the same z location as the nearest point on the edge. + Return a string giving the current view rotation angles, or the x, y, z + coordinates of the point on the nearest axis pane underneath the mouse + cursor, depending on the mouse button pressed. """ - - if self.M is None: - return '' + coords = '' if self.button_pressed in self._rotate_btn: - # ignore xd and yd and display angles instead - norm_elev = art3d._norm_angle(self.elev) - norm_azim = art3d._norm_angle(self.azim) - norm_roll = art3d._norm_angle(self.roll) - return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " - f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " - f"roll={norm_roll:.0f}\N{DEGREE SIGN}" - ).replace("-", "\N{MINUS SIGN}") - - # nearest edge - p0, p1 = min(self._tunit_edges(), - key=lambda edge: proj3d._line2d_seg_dist( - (xd, yd), edge[0][:2], edge[1][:2])) - - # scale the z value to match - x0, y0, z0 = p0 - x1, y1, z1 = p1 - d0 = np.hypot(x0-xd, y0-yd) - d1 = np.hypot(x1-xd, y1-yd) - dt = d0+d1 - z = d1/dt * z0 + d0/dt * z1 - - x, y, z = proj3d.inv_transform(xd, yd, z, self.M) - - xs = self.format_xdata(x) - ys = self.format_ydata(y) - zs = self.format_zdata(z) - return f'x={xs}, y={ys}, z={zs}' + # ignore xv and yv and display angles instead + coords = self._rotation_coords() + + elif self.M is not None: + coords = self._location_coords(xv, yv, renderer) + + return coords + + def _rotation_coords(self): + """ + Return the rotation angles as a string. + """ + norm_elev = art3d._norm_angle(self.elev) + norm_azim = art3d._norm_angle(self.azim) + norm_roll = art3d._norm_angle(self.roll) + coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " + f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " + f"roll={norm_roll:.0f}\N{DEGREE SIGN}" + ).replace("-", "\N{MINUS SIGN}") + return coords + + def _location_coords(self, xv, yv, renderer): + """ + Return the location on the axis pane underneath the cursor as a string. + """ + p1 = self._calc_coord(xv, yv, renderer) + xs = self.format_xdata(p1[0]) + ys = self.format_ydata(p1[1]) + zs = self.format_zdata(p1[2]) + coords = f'x={xs}, y={ys}, z={zs}' + return coords + + def _get_camera_loc(self): + """ + Returns the current camera location in data coordinates. + """ + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + c = np.array([cx, cy, cz]) + r = np.array([dx, dy, dz]) + + if self._focal_length == np.inf: # orthographic projection + focal_length = 1e9 # large enough to be effectively infinite + else: # perspective projection + focal_length = self._focal_length + eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length + return eye + + def _calc_coord(self, xv, yv, renderer=None): + """ + Given the 2D view coordinates, find the point on the nearest axis pane + that lies directly below those coordinates. Returns a 3D point in data + coordinates. + """ + if self._focal_length == np.inf: # orthographic projection + zv = 1 + else: # perspective projection + zv = -1 / self._focal_length + + # Convert point on view plane to data coordinates + p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.M)).ravel() + + # Get the vector from the camera to the point on the view plane + vec = self._get_camera_loc() - p1 + + # Get the pane locations for each of the axes + pane_locs = [] + for axis in self._axis_map.values(): + xys, loc = axis.active_pane(renderer) + pane_locs.append(loc) + + # Find the distance to the nearest pane by projecting the view vector + scales = np.zeros(3) + for i in range(3): + if vec[i] == 0: + scales[i] = np.inf + else: + scales[i] = (p1[i] - pane_locs[i]) / vec[i] + scale = scales[np.argmin(abs(scales))] + + # Calculate the point on the closest pane + p2 = p1 - scale*vec + return p2 def _on_move(self, event): """ @@ -1143,6 +1195,7 @@ def _on_move(self, event): self.view_init(elev=elev, azim=azim, roll=roll, share=True) self.stale = True + # Pan elif self.button_pressed in self._pan_btn: # Start the pan event with pixel coordinates px, py = self.transData.transform([self._sx, self._sy]) @@ -1321,21 +1374,27 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z): scale_z : float Scale factor for the z data axis. """ - # Get the axis limits and centers + # Get the axis centers and ranges + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + + # Set the scaled axis limits + self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2) + self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2) + self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2) + + def _get_w_centers_ranges(self): + """Get 3D world centers and axis ranges.""" + # Calculate center of axis limits minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() cx = (maxx + minx)/2 cy = (maxy + miny)/2 cz = (maxz + minz)/2 - # Scale the data range - dx = (maxx - minx)*scale_x - dy = (maxy - miny)*scale_y - dz = (maxz - minz)*scale_z - - # Set the scaled axis limits - self.set_xlim3d(cx - dx/2, cx + dx/2) - self.set_ylim3d(cy - dy/2, cy + dy/2) - self.set_zlim3d(cz - dz/2, cz + dz/2) + # Calculate range of axis limits + dx = (maxx - minx) + dy = (maxy - miny) + dz = (maxz - minz) + return cx, cy, cz, dx, dy, dz def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): """ diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index f6caba030f44..d3a763ab957c 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -295,6 +295,20 @@ def _get_tickdir(self): tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i] return tickdir + def active_pane(self, renderer): + mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) + info = self._axinfo + index = info['i'] + if not highs[index]: + loc = mins[index] + plane = self._PLANES[2 * index] + else: + loc = maxs[index] + plane = self._PLANES[2 * index + 1] + xys = [tc[p] for p in plane] + self.pane.xy = xys + return xys, loc + def draw_pane(self, renderer): """ Draw pane. @@ -304,20 +318,9 @@ def draw_pane(self, renderer): renderer : `~matplotlib.backend_bases.RendererBase` subclass """ renderer.open_group('pane3d', gid=self.get_gid()) - - mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) - - info = self._axinfo - index = info['i'] - if not highs[index]: - plane = self._PLANES[2 * index] - else: - plane = self._PLANES[2 * index + 1] - xys = np.asarray([tc[p] for p in plane]) - xys = xys[:, :2] + xys, loc = self.active_pane(renderer) self.pane.xy = xys self.pane.draw(renderer) - renderer.close_group('pane3d') @artist.allow_rasterization diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index a1692ea15baf..85b7fcc969d5 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -8,29 +8,6 @@ from matplotlib import _api -def _line2d_seg_dist(p, s0, s1): - """ - Return the distance(s) from point(s) *p* to segment(s) (*s0*, *s1*). - - Parameters - ---------- - p : (ndim,) or (N, ndim) array-like - The points from which the distances are computed. - s0, s1 : (ndim,) or (N, ndim) array-like - The xy(z...) coordinates of the segment endpoints. - """ - s0 = np.asarray(s0) - s01 = s1 - s0 # shape (ndim,) or (N, ndim) - s0p = p - s0 # shape (ndim,) or (N, ndim) - l2 = s01 @ s01 # squared segment length - # Avoid div. by zero for degenerate segments (for them, s01 = (0, 0, ...) - # so the value of l2 doesn't matter; this just replaces 0/0 by 0/1). - l2 = np.where(l2, l2, 1) - # Project onto segment, without going past segment ends. - p1 = s0 + np.multiply.outer(np.clip(s0p @ s01 / l2, 0, 1), s01) - return ((p - p1) ** 2).sum(axis=-1) ** (1/2) - - def world_transformation(xmin, xmax, ymin, ymax, zmin, zmax, pb_aspect=None): @@ -220,10 +197,11 @@ def inv_transform(xs, ys, zs, M): iM = linalg.inv(M) vec = _vec_pad_ones(xs, ys, zs) vecr = np.dot(iM, vec) - try: - vecr = vecr / vecr[3] - except OverflowError: - pass + if vecr.shape == (4,): + vecr = vecr.reshape((4, 1)) + for i in range(vecr.shape[1]): + if vecr[3][i] != 0: + vecr[:, i] = vecr[:, i] / vecr[3][i] return vecr[0], vecr[1], vecr[2] diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index dbc0f23876c0..7d2df74cf8ab 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1963,16 +1963,30 @@ def test_format_coord(): ax = fig.add_subplot(projection='3d') x = np.arange(10) ax.plot(x, np.sin(x)) + xv = 0.1 + yv = 0.1 fig.canvas.draw() - assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553' + assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444' + # Modify parameters ax.view_init(roll=30, vertical_axis="y") fig.canvas.draw() - assert ax.format_coord(0, 0) == 'x=9.1651, y=−0.9215, z=−0.0359' + assert ax.format_coord(xv, yv) == 'x=9.1875, y=0.9761, z=0.1291' + # Reset parameters ax.view_init() fig.canvas.draw() - assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553' + assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444' + + # Check orthographic projection + ax.set_proj_type('ortho') + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=10.8869, y=1.0417, z=0.1528' + + # Check non-default perspective projection + ax.set_proj_type('persp', focal_length=0.1) + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=9.0620, y=1.0417, z=0.1110' def test_get_axis_position(): From 22bfa096f54828682f751e58809926dff8f24f8e Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 23 Feb 2023 14:31:10 -0700 Subject: [PATCH 2/4] Make 3D coordinates explicitly call out the backing pane they are on --- doc/users/next_whats_new/3d_hover_coordinates.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/users/next_whats_new/3d_hover_coordinates.rst b/doc/users/next_whats_new/3d_hover_coordinates.rst index c908bdb391c4..5cad9967ff35 100644 --- a/doc/users/next_whats_new/3d_hover_coordinates.rst +++ b/doc/users/next_whats_new/3d_hover_coordinates.rst @@ -5,5 +5,6 @@ The x, y, z coordinates displayed in 3D plots were previously showing nonsensical values. This has been fixed to report the coordinate on the view pane directly beneath the mouse cursor. This is likely to be most useful when viewing 3D plots along a primary axis direction when using an orthographic -projection. Note that there is still no way to directly display the coordinates -of the plotted data points. +projection, or when a 2D plot has been projected onto one of the 3D axis panes. +Note that there is still no way to directly display the coordinates of plotted +data points. From f820c405d9c069a9e6008253a06edb9a4a463e7a Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Thu, 23 Feb 2023 19:27:57 -0700 Subject: [PATCH 3/4] Save inverse projection matrix for speed --- lib/mpl_toolkits/mplot3d/axes3d.py | 4 +++- lib/mpl_toolkits/mplot3d/proj3d.py | 8 +++----- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index fbf9ac1cbcf5..e8141770511f 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -164,6 +164,7 @@ def __init__( # Enable drawing of axes by Axes3D class self.set_axis_on() self.M = None + self.invM = None # func used to format z -- fall back on major formatters self.fmt_zdata = None @@ -455,6 +456,7 @@ def draw(self, renderer): # add the projection matrix to the renderer self.M = self.get_proj() + self.invM = np.linalg.inv(self.M) collections_and_patches = ( artist for artist in self._children @@ -1128,7 +1130,7 @@ def _calc_coord(self, xv, yv, renderer=None): zv = -1 / self._focal_length # Convert point on view plane to data coordinates - p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.M)).ravel() + p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel() # Get the vector from the camera to the point on the view plane vec = self._get_camera_loc() - p1 diff --git a/lib/mpl_toolkits/mplot3d/proj3d.py b/lib/mpl_toolkits/mplot3d/proj3d.py index 85b7fcc969d5..098a7b6f6667 100644 --- a/lib/mpl_toolkits/mplot3d/proj3d.py +++ b/lib/mpl_toolkits/mplot3d/proj3d.py @@ -3,7 +3,6 @@ """ import numpy as np -import numpy.linalg as linalg from matplotlib import _api @@ -190,13 +189,12 @@ def _proj_transform_vec_clip(vec, M): return txs, tys, tzs, tis -def inv_transform(xs, ys, zs, M): +def inv_transform(xs, ys, zs, invM): """ - Transform the points by the inverse of the projection matrix *M*. + Transform the points by the inverse of the projection matrix, *invM*. """ - iM = linalg.inv(M) vec = _vec_pad_ones(xs, ys, zs) - vecr = np.dot(iM, vec) + vecr = np.dot(invM, vec) if vecr.shape == (4,): vecr = vecr.reshape((4, 1)) for i in range(vecr.shape[1]): diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 7d2df74cf8ab..290fc00630b5 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1061,13 +1061,14 @@ def _test_proj_make_M(): def test_proj_transform(): M = _test_proj_make_M() + invM = np.linalg.inv(M) xs = np.array([0, 1, 1, 0, 0, 0, 1, 1, 0, 0]) * 300.0 ys = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 0]) * 300.0 zs = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) * 300.0 txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M) - ixs, iys, izs = proj3d.inv_transform(txs, tys, tzs, M) + ixs, iys, izs = proj3d.inv_transform(txs, tys, tzs, invM) np.testing.assert_almost_equal(ixs, xs) np.testing.assert_almost_equal(iys, ys) From 9701a3957fcb3035f472750bd3d167ab00d1016f Mon Sep 17 00:00:00 2001 From: Scott Shambaugh Date: Sun, 12 Mar 2023 16:28:40 -0600 Subject: [PATCH 4/4] Don't use deprecated function --- lib/mpl_toolkits/mplot3d/axis3d.py | 5 ++- .../test_axes3d/proj3d_lines_dists.png | Bin 18898 -> 0 bytes lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 33 ------------------ 3 files changed, 2 insertions(+), 36 deletions(-) delete mode 100644 lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_lines_dists.png diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index d3a763ab957c..30f56c70f9f5 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -305,8 +305,7 @@ def active_pane(self, renderer): else: loc = maxs[index] plane = self._PLANES[2 * index + 1] - xys = [tc[p] for p in plane] - self.pane.xy = xys + xys = np.array([tc[p] for p in plane]) return xys, loc def draw_pane(self, renderer): @@ -319,7 +318,7 @@ def draw_pane(self, renderer): """ renderer.open_group('pane3d', gid=self.get_gid()) xys, loc = self.active_pane(renderer) - self.pane.xy = xys + self.pane.xy = xys[:, :2] self.pane.draw(renderer) renderer.close_group('pane3d') diff --git a/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_lines_dists.png b/lib/mpl_toolkits/mplot3d/tests/baseline_images/test_axes3d/proj3d_lines_dists.png deleted file mode 100644 index 9d4ee7ab593829e7497093ec2abb7a450d2111eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18898 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