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

Skip to content

Commit 86e5f04

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 86e5f04

File tree

6 files changed

+127
-112
lines changed

6 files changed

+127
-112
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: 81 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,45 +1036,82 @@ 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
"""
10411041
Given the 2D view coordinates attempt to guess a 3D coordinate.
10421042
Looks for the nearest edge to the point and then assumes that
10431043
the point is at the same z location as the nearest point on the edge.
10441044
"""
1045-
10461045
if self.M is None:
1047-
return ''
1046+
coords = ''
10481047

1049-
if self.button_pressed in self._rotate_btn:
1048+
elif self.button_pressed in self._rotate_btn:
10501049
# ignore xd and yd and display angles instead
10511050
norm_elev = art3d._norm_angle(self.elev)
10521051
norm_azim = art3d._norm_angle(self.azim)
10531052
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}'
1053+
coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, "
1054+
f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, "
1055+
f"roll={norm_roll:.0f}\N{DEGREE SIGN}"
1056+
).replace("-", "\N{MINUS SIGN}")
1057+
1058+
else:
1059+
p1 = self._calc_coord(xv, yv, renderer)
1060+
xs = self.format_xdata(p1[0])
1061+
ys = self.format_ydata(p1[1])
1062+
zs = self.format_zdata(p1[2])
1063+
coords = f'x={xs}, y={ys}, z={zs}'
1064+
1065+
return coords
1066+
1067+
def _get_camera_loc(self):
1068+
"""
1069+
Returns the current camera location in data coordinates.
1070+
"""
1071+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1072+
c = np.array([cx, cy, cz])
1073+
r = np.array([dx, dy, dz])
1074+
1075+
if self._focal_length == np.inf: # orthographic projection
1076+
focal_length = max(abs(r)) * 1e9 # large enough to be effectively infinite
1077+
else: # perspective projection
1078+
focal_length = self._focal_length
1079+
eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length
1080+
return eye
1081+
1082+
def _calc_coord(self, xv, yv, renderer=None):
1083+
"""
1084+
Given the 2D view coordinates, find the point on the nearest axis pane
1085+
that lies directly below those coordinates. Returns a 3D point in data
1086+
coordinates.
1087+
"""
1088+
if self._focal_length == np.inf: # orthographic projection
1089+
zv = 1
1090+
else: # perspective projection
1091+
zv = -1 / self._focal_length
1092+
1093+
# Convert point on view plane to data coordinates
1094+
p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.M)).ravel()
1095+
vec = self._get_camera_loc() - p1
1096+
1097+
# Get the pane locations for each of the axes
1098+
pane_locs = []
1099+
for axis in self._axis_map.values():
1100+
xys, loc = axis.active_pane(renderer)
1101+
pane_locs.append(loc)
1102+
1103+
# Find the distance to the nearest pane
1104+
scales = np.zeros(3)
1105+
for i in range(3):
1106+
if vec[i] == 0:
1107+
scales[i] = np.inf
1108+
else:
1109+
scales[i] = (p1[i] - pane_locs[i]) / vec[i]
1110+
scale = scales[np.argmin(abs(scales))]
1111+
1112+
# Calculate the point on the closest pane
1113+
p2 = p1 - scale*vec
1114+
return p2
10781115

10791116
def _on_move(self, event):
10801117
"""
@@ -1296,21 +1333,27 @@ def _scale_axis_limits(self, scale_x, scale_y, scale_z):
12961333
scale_z : float
12971334
Scale factor for the z data axis.
12981335
"""
1299-
# Get the axis limits and centers
1336+
# Get the axis centers and ranges
1337+
cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges()
1338+
1339+
# Set the scaled axis limits
1340+
self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2)
1341+
self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2)
1342+
self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2)
1343+
1344+
def _get_w_centers_ranges(self):
1345+
"""Get 3D world centers and axis ranges."""
1346+
# Calculate center of axis limits
13001347
minx, maxx, miny, maxy, minz, maxz = self.get_w_lims()
13011348
cx = (maxx + minx)/2
13021349
cy = (maxy + miny)/2
13031350
cz = (maxz + minz)/2
13041351

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)
1352+
# Calculate range of axis limits
1353+
dx = (maxx - minx)
1354+
dy = (maxy - miny)
1355+
dz = (maxz - minz)
1356+
return cx, cy, cz, dx, dy, dz
13141357

13151358
def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs):
13161359
"""

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)