Thanks to visit codestin.com
Credit goes to github.com

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
Lines changed: 9 additions & 0 deletions
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

Lines changed: 101 additions & 45 deletions
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

Lines changed: 15 additions & 11 deletions
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

Lines changed: 5 additions & 27 deletions
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

Lines changed: 17 additions & 36 deletions
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)