Skip to content

Commit fff2a79

Browse files
Fix displayed 3d coordinates showing gibberish (#23485)
* Rework 3d coordinates mouse hover, now displays coordinate of underlying view pane Get 3d ortho coordinates working Get non-1 focal lengths working Docs * Make 3D coordinates explicitly call out the backing pane they are on * Save inverse projection matrix for speed * Don't use deprecated function --------- Co-authored-by: Scott Shambaugh <scottshambaugh@users.noreply.github.com>
1 parent d59d7e5 commit fff2a79

File tree

6 files changed

+158
-127
lines changed

6 files changed

+158
-127
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
3D hover coordinates
2+
--------------------
3+
4+
The x, y, z coordinates displayed in 3D plots were previously showing
5+
nonsensical values. This has been fixed to report the coordinate on the view
6+
pane directly beneath the mouse cursor. This is likely to be most useful when
7+
viewing 3D plots along a primary axis direction when using an orthographic
8+
projection, or when a 2D plot has been projected onto one of the 3D axis panes.
9+
Note that there is still no way to directly display the coordinates of plotted
10+
data points.

lib/mpl_toolkits/mplot3d/axes3d.py

+106-45
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def __init__(
164164
# Enable drawing of axes by Axes3D class
165165
self.set_axis_on()
166166
self.M = None
167+
self.invM = None
167168

168169
# func used to format z -- fall back on major formatters
169170
self.fmt_zdata = None
@@ -455,6 +456,7 @@ def draw(self, renderer):
455456

456457
# add the projection matrix to the renderer
457458
self.M = self.get_proj()
459+
self.invM = np.linalg.inv(self.M)
458460

459461
collections_and_patches = (
460462
artist for artist in self._children
@@ -1060,45 +1062,97 @@ def format_zdata(self, z):
10601062
val = func(z)
10611063
return val
10621064

1063-
def format_coord(self, xd, yd):
1065+
def format_coord(self, xv, yv, renderer=None):
10641066
"""
1065-
Given the 2D view coordinates attempt to guess a 3D coordinate.
1066-
Looks for the nearest edge to the point and then assumes that
1067-
the point is at the same z location as the nearest point on the edge.
1067+
Return a string giving the current view rotation angles, or the x, y, z
1068+
coordinates of the point on the nearest axis pane underneath the mouse
1069+
cursor, depending on the mouse button pressed.
10681070
"""
1069-
1070-
if self.M is None:
1071-
return ''
1071+
coords = ''
10721072

10731073
if self.button_pressed in self._rotate_btn:
1074-
# ignore xd and yd and display angles instead
1075-
norm_elev = art3d._norm_angle(self.elev)
1076-
norm_azim = art3d._norm_angle(self.azim)
1077-
norm_roll = art3d._norm_angle(self.roll)
1078-
return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1079-
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1080-
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
1081-
).replace("-", "\N{MINUS SIGN}")
1082-
1083-
# nearest edge
1084-
p0, p1 = min(self._tunit_edges(),
1085-
key=lambda edge: proj3d._line2d_seg_dist(
1086-
(xd, yd), edge[0][:2], edge[1][:2]))
1087-
1088-
# scale the z value to match
1089-
x0, y0, z0 = p0
1090-
x1, y1, z1 = p1
1091-
d0 = np.hypot(x0-xd, y0-yd)
1092-
d1 = np.hypot(x1-xd, y1-yd)
1093-
dt = d0+d1
1094-
z = d1/dt * z0 + d0/dt * z1
1095-
1096-
x, y, z = proj3d.inv_transform(xd, yd, z, self.M)
1097-
1098-
xs = self.format_xdata(x)
1099-
ys = self.format_ydata(y)
1100-
zs = self.format_zdata(z)
1101-
return f'x={xs}, y={ys}, z={zs}'
1074+
# ignore xv and yv and display angles instead
1075+
coords = self._rotation_coords()
1076+
1077+
elif self.M is not None:
1078+
coords = self._location_coords(xv, yv, renderer)
1079+
1080+
return coords
1081+
1082+
def _rotation_coords(self):
1083+
"""
1084+
Return the rotation angles as a string.
1085+
"""
1086+
norm_elev = art3d._norm_angle(self.elev)
1087+
norm_azim = art3d._norm_angle(self.azim)
1088+
norm_roll = art3d._norm_angle(self.roll)
1089+
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1090+
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1091+
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
1092+
).replace("-", "\N{MINUS SIGN}")
1093+
return coords
1094+
1095+
def _location_coords(self, xv, yv, renderer):
1096+
"""
1097+
Return the location on the axis pane underneath the cursor as a string.
1098+
"""
1099+
p1 = self._calc_coord(xv, yv, renderer)
1100+
xs = self.format_xdata(p1[0])
1101+
ys = self.format_ydata(p1[1])
1102+
zs = self.format_zdata(p1[2])
1103+
coords = f'x={xs}, y={ys}, z={zs}'
1104+
return coords
1105+
1106+
def _get_camera_loc(self):
1107+
"""
1108+
Returns the current camera location in data coordinates.
1109+
"""
1110+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1111+
c = np.array([cx, cy, cz])
1112+
r = np.array([dx, dy, dz])
1113+
1114+
if self._focal_length == np.inf: # orthographic projection
1115+
focal_length = 1e9 # large enough to be effectively infinite
1116+
else: # perspective projection
1117+
focal_length = self._focal_length
1118+
eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length
1119+
return eye
1120+
1121+
def _calc_coord(self, xv, yv, renderer=None):
1122+
"""
1123+
Given the 2D view coordinates, find the point on the nearest axis pane
1124+
that lies directly below those coordinates. Returns a 3D point in data
1125+
coordinates.
1126+
"""
1127+
if self._focal_length == np.inf: # orthographic projection
1128+
zv = 1
1129+
else: # perspective projection
1130+
zv = -1 / self._focal_length
1131+
1132+
# Convert point on view plane to data coordinates
1133+
p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel()
1134+
1135+
# Get the vector from the camera to the point on the view plane
1136+
vec = self._get_camera_loc() - p1
1137+
1138+
# Get the pane locations for each of the axes
1139+
pane_locs = []
1140+
for axis in self._axis_map.values():
1141+
xys, loc = axis.active_pane(renderer)
1142+
pane_locs.append(loc)
1143+
1144+
# Find the distance to the nearest pane by projecting the view vector
1145+
scales = np.zeros(3)
1146+
for i in range(3):
1147+
if vec[i] == 0:
1148+
scales[i] = np.inf
1149+
else:
1150+
scales[i] = (p1[i] - pane_locs[i]) / vec[i]
1151+
scale = scales[np.argmin(abs(scales))]
1152+
1153+
# Calculate the point on the closest pane
1154+
p2 = p1 - scale*vec
1155+
return p2
11021156

11031157
def _on_move(self, event):
11041158
"""
@@ -1143,6 +1197,7 @@ def _on_move(self, event):
11431197
self.view_init(elev=elev, azim=azim, roll=roll, share=True)
11441198
self.stale = True
11451199

1200+
# Pan
11461201
elif self.button_pressed in self._pan_btn:
11471202
# Start the pan event with pixel coordinates
11481203
px, py = self.transData.transform([self._sx, self._sy])
@@ -1321,21 +1376,27 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z):
13211376
scale_z : float
13221377
Scale factor for the z data axis.
13231378
"""
1324-
# Get the axis limits and centers
1379+
# Get the axis centers and ranges
1380+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1381+
1382+
# Set the scaled axis limits
1383+
self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2)
1384+
self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2)
1385+
self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2)
1386+
1387+
def _get_w_centers_ranges(self):
1388+
"""Get 3D world centers and axis ranges."""
1389+
# Calculate center of axis limits
13251390
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
13261391
cx = (maxx + minx)/2
13271392
cy = (maxy + miny)/2
13281393
cz = (maxz + minz)/2
13291394

1330-
# Scale the data range
1331-
dx = (maxx - minx)*scale_x
1332-
dy = (maxy - miny)*scale_y
1333-
dz = (maxz - minz)*scale_z
1334-
1335-
# Set the scaled axis limits
1336-
self.set_xlim3d(cx - dx/2, cx + dx/2)
1337-
self.set_ylim3d(cy - dy/2, cy + dy/2)
1338-
self.set_zlim3d(cz - dz/2, cz + dz/2)
1395+
# Calculate range of axis limits
1396+
dx = (maxx - minx)
1397+
dy = (maxy - miny)
1398+
dz = (maxz - minz)
1399+
return cx, cy, cz, dx, dy, dz
13391400

13401401
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
13411402
"""

lib/mpl_toolkits/mplot3d/axis3d.py

+15-13
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,19 @@ def _get_tickdir(self):
295295
tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i]
296296
return tickdir
297297

298+
def active_pane(self, renderer):
299+
mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
300+
info = self._axinfo
301+
index = info['i']
302+
if not highs[index]:
303+
loc = mins[index]
304+
plane = self._PLANES[2 * index]
305+
else:
306+
loc = maxs[index]
307+
plane = self._PLANES[2 * index + 1]
308+
xys = np.array([tc[p] for p in plane])
309+
return xys, loc
310+
298311
def draw_pane(self, renderer):
299312
"""
300313
Draw pane.
@@ -304,20 +317,9 @@ def draw_pane(self, renderer):
304317
renderer : `~matplotlib.backend_bases.RendererBase` subclass
305318
"""
306319
renderer.open_group('pane3d', gid=self.get_gid())
307-
308-
mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
309-
310-
info = self._axinfo
311-
index = info['i']
312-
if not highs[index]:
313-
plane = self._PLANES[2 * index]
314-
else:
315-
plane = self._PLANES[2 * index + 1]
316-
xys = np.asarray([tc[p] for p in plane])
317-
xys = xys[:, :2]
318-
self.pane.xy = xys
320+
xys, loc = self.active_pane(renderer)
321+
self.pane.xy = xys[:, :2]
319322
self.pane.draw(renderer)
320-
321323
renderer.close_group('pane3d')
322324

323325
@artist.allow_rasterization

lib/mpl_toolkits/mplot3d/proj3d.py

+8-32
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,10 @@
33
"""
44

55
import numpy as np
6-
import numpy.linalg as linalg
76

87
from matplotlib import _api
98

109

11-
def _line2d_seg_dist(p, s0, s1):
12-
"""
13-
Return the distance(s) from point(s) *p* to segment(s) (*s0*, *s1*).
14-
15-
Parameters
16-
----------
17-
p : (ndim,) or (N, ndim) array-like
18-
The points from which the distances are computed.
19-
s0, s1 : (ndim,) or (N, ndim) array-like
20-
The xy(z...) coordinates of the segment endpoints.
21-
"""
22-
s0 = np.asarray(s0)
23-
s01 = s1 - s0 # shape (ndim,) or (N, ndim)
24-
s0p = p - s0 # shape (ndim,) or (N, ndim)
25-
l2 = s01 @ s01 # squared segment length
26-
# Avoid div. by zero for degenerate segments (for them, s01 = (0, 0, ...)
27-
# so the value of l2 doesn't matter; this just replaces 0/0 by 0/1).
28-
l2 = np.where(l2, l2, 1)
29-
# Project onto segment, without going past segment ends.
30-
p1 = s0 + np.multiply.outer(np.clip(s0p @ s01 / l2, 0, 1), s01)
31-
return ((p - p1) ** 2).sum(axis=-1) ** (1/2)
32-
33-
3410
def world_transformation(xmin, xmax,
3511
ymin, ymax,
3612
zmin, zmax, pb_aspect=None):
@@ -213,17 +189,17 @@ def _proj_transform_vec_clip(vec, M):
213189
return txs, tys, tzs, tis
214190

215191

216-
def inv_transform(xs, ys, zs, M):
192+
def inv_transform(xs, ys, zs, invM):
217193
"""
218-
Transform the points by the inverse of the projection matrix *M*.
194+
Transform the points by the inverse of the projection matrix, *invM*.
219195
"""
220-
iM = linalg.inv(M)
221196
vec = _vec_pad_ones(xs, ys, zs)
222-
vecr = np.dot(iM, vec)
223-
try:
224-
vecr = vecr / vecr[3]
225-
except OverflowError:
226-
pass
197+
vecr = np.dot(invM, vec)
198+
if vecr.shape == (4,):
199+
vecr = vecr.reshape((4, 1))
200+
for i in range(vecr.shape[1]):
201+
if vecr[3][i] != 0:
202+
vecr[:, i] = vecr[:, i] / vecr[3][i]
227203
return vecr[0], vecr[1], vecr[2]
228204

229205

Binary file not shown.

lib/mpl_toolkits/mplot3d/tests/test_axes3d.py

+19-37
Original file line numberDiff line numberDiff line change
@@ -1061,13 +1061,14 @@ def _test_proj_make_M():
10611061

10621062
def test_proj_transform():
10631063
M = _test_proj_make_M()
1064+
invM = np.linalg.inv(M)
10641065

10651066
xs = np.array([0, 1, 1, 0, 0, 0, 1, 1, 0, 0]) * 300.0
10661067
ys = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 0]) * 300.0
10671068
zs = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) * 300.0
10681069

10691070
txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M)
1070-
ixs, iys, izs = proj3d.inv_transform(txs, tys, tzs, M)
1071+
ixs, iys, izs = proj3d.inv_transform(txs, tys, tzs, invM)
10711072

10721073
np.testing.assert_almost_equal(ixs, xs)
10731074
np.testing.assert_almost_equal(iys, ys)
@@ -1154,39 +1155,6 @@ def test_world():
11541155
[0, 0, 0, 1]])
11551156

11561157

1157-
@mpl3d_image_comparison(['proj3d_lines_dists.png'], style='mpl20')
1158-
def test_lines_dists():
1159-
fig, ax = plt.subplots(figsize=(4, 6), subplot_kw=dict(aspect='equal'))
1160-
1161-
xs = (0, 30)
1162-
ys = (20, 150)
1163-
ax.plot(xs, ys)
1164-
p0, p1 = zip(xs, ys)
1165-
1166-
xs = (0, 0, 20, 30)
1167-
ys = (100, 150, 30, 200)
1168-
ax.scatter(xs, ys)
1169-
1170-
dist0 = proj3d._line2d_seg_dist((xs[0], ys[0]), p0, p1)
1171-
dist = proj3d._line2d_seg_dist(np.array((xs, ys)).T, p0, p1)
1172-
assert dist0 == dist[0]
1173-
1174-
for x, y, d in zip(xs, ys, dist):
1175-
c = Circle((x, y), d, fill=0)
1176-
ax.add_patch(c)
1177-
1178-
ax.set_xlim(-50, 150)
1179-
ax.set_ylim(0, 300)
1180-
1181-
1182-
def test_lines_dists_nowarning():
1183-
# No RuntimeWarning must be emitted for degenerate segments, see GH#22624.
1184-
s0 = (10, 30, 50)
1185-
p = (20, 150, 180)
1186-
proj3d._line2d_seg_dist(p, s0, s0)
1187-
proj3d._line2d_seg_dist(np.array(p), s0, s0)
1188-
1189-
11901158
def test_autoscale():
11911159
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
11921160
assert ax.get_zscale() == 'linear'
@@ -1963,16 +1931,30 @@ def test_format_coord():
19631931
ax = fig.add_subplot(projection='3d')
19641932
x = np.arange(10)
19651933
ax.plot(x, np.sin(x))
1934+
xv = 0.1
1935+
yv = 0.1
19661936
fig.canvas.draw()
1967-
assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553'
1937+
assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444'
1938+
19681939
# Modify parameters
19691940
ax.view_init(roll=30, vertical_axis="y")
19701941
fig.canvas.draw()
1971-
assert ax.format_coord(0, 0) == 'x=9.1651, y=−0.9215, z=−0.0359'
1942+
assert ax.format_coord(xv, yv) == 'x=9.1875, y=0.9761, z=0.1291'
1943+
19721944
# Reset parameters
19731945
ax.view_init()
19741946
fig.canvas.draw()
1975-
assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553'
1947+
assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444'
1948+
1949+
# Check orthographic projection
1950+
ax.set_proj_type('ortho')
1951+
fig.canvas.draw()
1952+
assert ax.format_coord(xv, yv) == 'x=10.8869, y=1.0417, z=0.1528'
1953+
1954+
# Check non-default perspective projection
1955+
ax.set_proj_type('persp', focal_length=0.1)
1956+
fig.canvas.draw()
1957+
assert ax.format_coord(xv, yv) == 'x=9.0620, y=1.0417, z=0.1110'
19761958

19771959

19781960
def test_get_axis_position():

0 commit comments

Comments
 (0)