Skip to content

Commit 2e81b3a

Browse files
Rework 3d coordinates mouse hover, now displays coordinate of underlying view pane
Get 3d ortho coordinates working Get non-1 focal lengths working Docs
1 parent 2b05ace commit 2e81b3a

File tree

6 files changed

+147
-119
lines changed

6 files changed

+147
-119
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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. Note that there is still no way to directly display the coordinates
9+
of the plotted data points.

lib/mpl_toolkits/mplot3d/axes3d.py

+101-45
Original file line numberDiff line numberDiff line change
@@ -1036,45 +1036,95 @@ def format_zdata(self, z):
10361036
val = func(z)
10371037
return val
10381038

1039-
def format_coord(self, xd, yd):
1039+
def format_coord(self, xv, yv, renderer=None):
10401040
"""
1041-
Given the 2D view coordinates attempt to guess a 3D coordinate.
1042-
Looks for the nearest edge to the point and then assumes that
1043-
the point is at the same z location as the nearest point on the edge.
1041+
Return a string giving the current view rotation angles, or the x, y, z
1042+
coordinates of the point on the nearest axis pane underneath the mouse
1043+
cursor, depending on the mouse button pressed.
10441044
"""
1045-
1046-
if self.M is None:
1047-
return ''
1045+
coords = ''
10481046

10491047
if self.button_pressed in self._rotate_btn:
1050-
# ignore xd and yd and display angles instead
1051-
norm_elev = art3d._norm_angle(self.elev)
1052-
norm_azim = art3d._norm_angle(self.azim)
1053-
norm_roll = art3d._norm_angle(self.roll)
1054-
return (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1055-
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1056-
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
1057-
).replace("-", "\N{MINUS SIGN}")
1058-
1059-
# nearest edge
1060-
p0, p1 = min(self._tunit_edges(),
1061-
key=lambda edge: proj3d._line2d_seg_dist(
1062-
(xd, yd), edge[0][:2], edge[1][:2]))
1063-
1064-
# scale the z value to match
1065-
x0, y0, z0 = p0
1066-
x1, y1, z1 = p1
1067-
d0 = np.hypot(x0-xd, y0-yd)
1068-
d1 = np.hypot(x1-xd, y1-yd)
1069-
dt = d0+d1
1070-
z = d1/dt * z0 + d0/dt * z1
1071-
1072-
x, y, z = proj3d.inv_transform(xd, yd, z, self.M)
1073-
1074-
xs = self.format_xdata(x)
1075-
ys = self.format_ydata(y)
1076-
zs = self.format_zdata(z)
1077-
return f'x={xs}, y={ys}, z={zs}'
1048+
# ignore xv and yv and display angles instead
1049+
coords = self._rotation_coords()
1050+
1051+
elif self.M is not None:
1052+
coords = self._location_coords(xv, yv, renderer)
1053+
1054+
return coords
1055+
1056+
def _rotation_coords(self):
1057+
"""
1058+
Return the rotation angles as a string.
1059+
"""
1060+
norm_elev = art3d._norm_angle(self.elev)
1061+
norm_azim = art3d._norm_angle(self.azim)
1062+
norm_roll = art3d._norm_angle(self.roll)
1063+
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1064+
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1065+
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
1066+
).replace("-", "\N{MINUS SIGN}")
1067+
return coords
1068+
1069+
def _location_coords(self, xv, yv, renderer):
1070+
"""
1071+
Return the location on the axis pane underneath the cursor as a string.
1072+
"""
1073+
p1 = self._calc_coord(xv, yv, renderer)
1074+
xs = self.format_xdata(p1[0])
1075+
ys = self.format_ydata(p1[1])
1076+
zs = self.format_zdata(p1[2])
1077+
coords = f'x={xs}, y={ys}, z={zs}'
1078+
return coords
1079+
1080+
def _get_camera_loc(self):
1081+
"""
1082+
Returns the current camera location in data coordinates.
1083+
"""
1084+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1085+
c = np.array([cx, cy, cz])
1086+
r = np.array([dx, dy, dz])
1087+
1088+
if self._focal_length == np.inf: # orthographic projection
1089+
focal_length = max(abs(r)) * 1e9 # large enough to be effectively infinite
1090+
else: # perspective projection
1091+
focal_length = self._focal_length
1092+
eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length
1093+
return eye
1094+
1095+
def _calc_coord(self, xv, yv, renderer=None):
1096+
"""
1097+
Given the 2D view coordinates, find the point on the nearest axis pane
1098+
that lies directly below those coordinates. Returns a 3D point in data
1099+
coordinates.
1100+
"""
1101+
if self._focal_length == np.inf: # orthographic projection
1102+
zv = 1
1103+
else: # perspective projection
1104+
zv = -1 / self._focal_length
1105+
1106+
# Convert point on view plane to data coordinates
1107+
p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.M)).ravel()
1108+
vec = self._get_camera_loc() - p1
1109+
1110+
# Get the pane locations for each of the axes
1111+
pane_locs = []
1112+
for axis in self._axis_map.values():
1113+
xys, loc = axis.active_pane(renderer)
1114+
pane_locs.append(loc)
1115+
1116+
# Find the distance to the nearest pane
1117+
scales = np.zeros(3)
1118+
for i in range(3):
1119+
if vec[i] == 0:
1120+
scales[i] = np.inf
1121+
else:
1122+
scales[i] = (p1[i] - pane_locs[i]) / vec[i]
1123+
scale = scales[np.argmin(abs(scales))]
1124+
1125+
# Calculate the point on the closest pane
1126+
p2 = p1 - scale*vec
1127+
return p2
10781128

10791129
def _on_move(self, event):
10801130
"""
@@ -1296,21 +1346,27 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z):
12961346
scale_z : float
12971347
Scale factor for the z data axis.
12981348
"""
1299-
# Get the axis limits and centers
1349+
# Get the axis centers and ranges
1350+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1351+
1352+
# Set the scaled axis limits
1353+
self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2)
1354+
self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2)
1355+
self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2)
1356+
1357+
def _get_w_centers_ranges(self):
1358+
"""Get 3D world centers and axis ranges."""
1359+
# Calculate center of axis limits
13001360
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
13011361
cx = (maxx + minx)/2
13021362
cy = (maxy + miny)/2
13031363
cz = (maxz + minz)/2
13041364

1305-
# Scale the data range
1306-
dx = (maxx - minx)*scale_x
1307-
dy = (maxy - miny)*scale_y
1308-
dz = (maxz - minz)*scale_z
1309-
1310-
# Set the scaled axis limits
1311-
self.set_xlim3d(cx - dx/2, cx + dx/2)
1312-
self.set_ylim3d(cy - dy/2, cy + dy/2)
1313-
self.set_zlim3d(cz - dz/2, cz + dz/2)
1365+
# Calculate range of axis limits
1366+
dx = (maxx - minx)
1367+
dy = (maxy - miny)
1368+
dz = (maxz - minz)
1369+
return cx, cy, cz, dx, dy, dz
13141370

13151371
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
13161372
"""

lib/mpl_toolkits/mplot3d/axis3d.py

+15-11
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,20 @@ def _get_tickdir(self):
321321
tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i]
322322
return tickdir
323323

324+
def active_pane(self, renderer):
325+
mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
326+
info = self._axinfo
327+
index = info['i']
328+
if not highs[index]:
329+
loc = mins[index]
330+
plane = self._PLANES[2 * index]
331+
else:
332+
loc = maxs[index]
333+
plane = self._PLANES[2 * index + 1]
334+
xys = [tc[p] for p in plane]
335+
self._set_pane_pos(xys)
336+
return xys, loc
337+
324338
def draw_pane(self, renderer):
325339
"""
326340
Draw pane.
@@ -330,19 +344,9 @@ def draw_pane(self, renderer):
330344
renderer : `~matplotlib.backend_bases.RendererBase` subclass
331345
"""
332346
renderer.open_group('pane3d', gid=self.get_gid())
333-
334-
mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer)
335-
336-
info = self._axinfo
337-
index = info['i']
338-
if not highs[index]:
339-
plane = self._PLANES[2 * index]
340-
else:
341-
plane = self._PLANES[2 * index + 1]
342-
xys = [tc[p] for p in plane]
347+
xys, loc = self.active_pane(renderer)
343348
self._set_pane_pos(xys)
344349
self.pane.draw(renderer)
345-
346350
renderer.close_group('pane3d')
347351

348352
@artist.allow_rasterization

lib/mpl_toolkits/mplot3d/proj3d.py

+5-27
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,6 @@
88
from matplotlib import _api
99

1010

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-
3411
def world_transformation(xmin, xmax,
3512
ymin, ymax,
3613
zmin, zmax, pb_aspect=None):
@@ -220,10 +197,11 @@ def inv_transform(xs, ys, zs, M):
220197
iM = linalg.inv(M)
221198
vec = _vec_pad_ones(xs, ys, zs)
222199
vecr = np.dot(iM, vec)
223-
try:
224-
vecr = vecr / vecr[3]
225-
except OverflowError:
226-
pass
200+
if vecr.shape == (4,):
201+
vecr = vecr.reshape((4, 1))
202+
for i in range(vecr.shape[1]):
203+
if vecr[3][i] != 0:
204+
vecr[:, i] = vecr[:, i] / vecr[3][i]
227205
return vecr[0], vecr[1], vecr[2]
228206

229207

Binary file not shown.

lib/mpl_toolkits/mplot3d/tests/test_axes3d.py

+17-36
Original file line numberDiff line numberDiff line change
@@ -1116,39 +1116,6 @@ def test_world():
11161116
[0, 0, 0, 1]])
11171117

11181118

1119-
@mpl3d_image_comparison(['proj3d_lines_dists.png'])
1120-
def test_lines_dists():
1121-
fig, ax = plt.subplots(figsize=(4, 6), subplot_kw=dict(aspect='equal'))
1122-
1123-
xs = (0, 30)
1124-
ys = (20, 150)
1125-
ax.plot(xs, ys)
1126-
p0, p1 = zip(xs, ys)
1127-
1128-
xs = (0, 0, 20, 30)
1129-
ys = (100, 150, 30, 200)
1130-
ax.scatter(xs, ys)
1131-
1132-
dist0 = proj3d._line2d_seg_dist((xs[0], ys[0]), p0, p1)
1133-
dist = proj3d._line2d_seg_dist(np.array((xs, ys)).T, p0, p1)
1134-
assert dist0 == dist[0]
1135-
1136-
for x, y, d in zip(xs, ys, dist):
1137-
c = Circle((x, y), d, fill=0)
1138-
ax.add_patch(c)
1139-
1140-
ax.set_xlim(-50, 150)
1141-
ax.set_ylim(0, 300)
1142-
1143-
1144-
def test_lines_dists_nowarning():
1145-
# No RuntimeWarning must be emitted for degenerate segments, see GH#22624.
1146-
s0 = (10, 30, 50)
1147-
p = (20, 150, 180)
1148-
proj3d._line2d_seg_dist(p, s0, s0)
1149-
proj3d._line2d_seg_dist(np.array(p), s0, s0)
1150-
1151-
11521119
def test_autoscale():
11531120
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
11541121
assert ax.get_zscale() == 'linear'
@@ -1910,16 +1877,30 @@ def test_format_coord():
19101877
ax = fig.add_subplot(projection='3d')
19111878
x = np.arange(10)
19121879
ax.plot(x, np.sin(x))
1880+
xv = 0.1
1881+
yv = 0.1
19131882
fig.canvas.draw()
1914-
assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553'
1883+
assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444'
1884+
19151885
# Modify parameters
19161886
ax.view_init(roll=30, vertical_axis="y")
19171887
fig.canvas.draw()
1918-
assert ax.format_coord(0, 0) == 'x=9.1651, y=−0.9215, z=−0.0359'
1888+
assert ax.format_coord(xv, yv) == 'x=9.1875, y=0.9761, z=0.1291'
1889+
19191890
# Reset parameters
19201891
ax.view_init()
19211892
fig.canvas.draw()
1922-
assert ax.format_coord(0, 0) == 'x=1.8066, y=1.0367, z=−0.0553'
1893+
assert ax.format_coord(xv, yv) == 'x=10.5227, y=1.0417, z=0.1444'
1894+
1895+
# Check orthographic projection
1896+
ax.set_proj_type('ortho')
1897+
fig.canvas.draw()
1898+
assert ax.format_coord(xv, yv) == 'x=10.8869, y=1.0417, z=0.1528'
1899+
1900+
# Check non-default perspective projection
1901+
ax.set_proj_type('persp', focal_length=0.1)
1902+
fig.canvas.draw()
1903+
assert ax.format_coord(xv, yv) == 'x=9.0620, y=1.0417, z=0.1110'
19231904

19241905

19251906
def test_get_axis_position():

0 commit comments

Comments
 (0)