Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d9afaf6
Fix 3D axes to properly support non-linear scales (log, symlog, etc.)
scottshambaugh Jan 17, 2026
1afb165
Cleanup and linting
scottshambaugh Jan 17, 2026
0fb87fc
Have zoom and pan work with 3D log scales
scottshambaugh Jan 17, 2026
657942a
Fix test regressions
scottshambaugh Jan 17, 2026
5112998
Respect limit_range_for_scale in 3d
scottshambaugh Jan 17, 2026
9821771
Break repeated code out to a helper function
scottshambaugh Jan 17, 2026
9b057ea
Commonize axis scale setting code
scottshambaugh Jan 17, 2026
280acb8
Commonize autoscaling each axis
scottshambaugh Jan 17, 2026
2e19d87
Cleanup and consolidation
scottshambaugh Jan 17, 2026
6b3de8f
Code for 3d scale transform tests
scottshambaugh Jan 17, 2026
02d4fdf
Docstring cleanup and coordinates explainer
scottshambaugh Jan 17, 2026
f4ce6ba
Loosen tolerance on flaky image comparison tests
scottshambaugh Jan 17, 2026
d46c5df
Fix 3d hover coordinates with scaled axes
scottshambaugh Jan 17, 2026
24ab58b
Add gallery example and whats new for 3d scales
scottshambaugh Jan 17, 2026
a24160f
Factor repeated code into proj3d functions
scottshambaugh Feb 23, 2026
8b8c7aa
Code review cleanup
scottshambaugh Feb 27, 2026
5d335a3
More 3D log scale tests
scottshambaugh Feb 27, 2026
77038d3
Fix empty plot scale handling
scottshambaugh Feb 27, 2026
ac6722e
Combine tests for 3d artists with log scales
scottshambaugh Feb 27, 2026
418a488
Baseline images for scale3d tests
scottshambaugh Jan 17, 2026
3050aae
Code review updates
scottshambaugh Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions doc/release/next_whats_new/3d_scales.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Non-linear scales on 3D axes
----------------------------

Resolving a long-standing issue, 3D axes now support non-linear axis scales
such as 'log', 'symlog', 'logit', 'asinh', and custom 'function' scales, just
like 2D axes. Use `~.Axes3D.set_xscale`, `~.Axes3D.set_yscale`, and
`~.Axes3D.set_zscale` to set the scale for each axis independently.

.. plot::
:include-source: true
:alt: A 3D plot with a linear x-axis, logarithmic y-axis, and symlog z-axis.

import matplotlib.pyplot as plt
import numpy as np

# A sine chirp with increasing frequency and amplitude
x = np.linspace(0, 1, 400) # time
y = 10 ** (2 * x) # frequency, growing exponentially from 1 to 100 Hz
phase = 2 * np.pi * (10 ** (2 * x) - 1) / (2 * np.log(10))
z = np.sin(phase) * x ** 2 * 10 # amplitude, growing quadratically

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot(x, y, z)

ax.set_xlabel('Time (linear)')
ax.set_ylabel('Frequency, Hz (log)')
ax.set_zlabel('Amplitude (symlog)')

ax.set_yscale('log')
ax.set_zscale('symlog')

plt.show()

See `matplotlib.scale` for details on all available scales and their parameters.
52 changes: 52 additions & 0 deletions galleries/examples/mplot3d/scales3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
================================
Scales on 3D (Log, Symlog, etc.)
================================

Demonstrate how to use non-linear scales such as logarithmic scales on 3D axes.

3D axes support the same axis scales as 2D plots: 'linear', 'log', 'symlog',
'logit', 'asinh', and custom 'function' scales. This example shows a mix of
scales: linear on X, log on Y, and symlog on Z.

For a complete list of built-in scales, see `matplotlib.scale`. For an overview
of scale transformations, see :doc:`/gallery/scales/scales`.
"""

import matplotlib.pyplot as plt
import numpy as np

# A sine chirp with increasing frequency and amplitude
x = np.linspace(0, 1, 400) # time
y = 10 ** (2 * x) # frequency, growing exponentially from 1 to 100 Hz
phase = 2 * np.pi * (10 ** (2 * x) - 1) / (2 * np.log(10))
z = np.sin(phase) * x **2 * 10 # amplitude, growing quadratically

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot(x, y, z)

ax.set_xlabel('Time (linear)')
ax.set_ylabel('Frequency, Hz (log)')
ax.set_zlabel('Amplitude (symlog)')

ax.set_yscale('log')
ax.set_zscale('symlog')

plt.show()

# %%
#
# .. admonition:: References
#
# The use of the following functions, methods, classes and modules is shown
# in this example:
#
# - `mpl_toolkits.mplot3d.axes3d.Axes3D.set_xscale`
# - `mpl_toolkits.mplot3d.axes3d.Axes3D.set_yscale`
# - `mpl_toolkits.mplot3d.axes3d.Axes3D.set_zscale`
# - `matplotlib.scale`
#
# .. tags::
# plot-type: 3D,
# level: beginner
5 changes: 3 additions & 2 deletions galleries/examples/scales/scales.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

Illustrate the scale transformations applied to axes, e.g. log, symlog, logit.

See `matplotlib.scale` for a full list of built-in scales, and
:doc:`/gallery/scales/custom_scale` for how to create your own scale.
See `matplotlib.scale` for a full list of built-in scales,
:doc:`/gallery/scales/custom_scale` for how to create your own scale, and
:doc:`/gallery/mplot3d/scales3d` for using scales on 3D axes.
"""

import matplotlib.pyplot as plt
Expand Down
42 changes: 21 additions & 21 deletions lib/mpl_toolkits/mplot3d/art3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _viewlim_mask(xs, ys, zs, axes):
Parameters
----------
xs, ys, zs : array-like
The points to mask.
The points to mask. These should be in data coordinates.
axes : Axes3D
The axes to use for the view limits.

Expand Down Expand Up @@ -198,7 +198,10 @@ def draw(self, renderer):
else:
pos3d = np.array([self._x, self._y, self._z], dtype=float)

proj = proj3d._proj_trans_points([pos3d, pos3d + self._dir_vec], self.axes.M)
dir_end = pos3d + self._dir_vec
points = np.asarray([pos3d, dir_end])
proj = proj3d._scale_proj_transform(
points[:, 0], points[:, 1], points[:, 2], self.axes)
dx = proj[0][1] - proj[0][0]
dy = proj[1][1] - proj[1][0]
angle = math.degrees(math.atan2(dy, dx))
Expand Down Expand Up @@ -334,9 +337,7 @@ def draw(self, renderer):
dtype=float, mask=mask).filled(np.nan)
else:
xs3d, ys3d, zs3d = self._verts3d
xs, ys, zs, tis = proj3d._proj_transform_clip(xs3d, ys3d, zs3d,
self.axes.M,
self.axes._focal_length)
xs, ys, zs, tis = proj3d._scale_proj_transform_clip(xs3d, ys3d, zs3d, self.axes)
self.set_data(xs, ys)
super().draw(renderer)
self.stale = False
Expand Down Expand Up @@ -427,7 +428,8 @@ def do_3d_projection(self):
vs_list = [np.ma.array(vs, mask=np.broadcast_to(
_viewlim_mask(*vs.T, self.axes), vs.shape))
for vs in vs_list]
xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) for vs in vs_list]
xyzs_list = [proj3d._scale_proj_transform(
vs[:, 0], vs[:, 1], vs[:, 2], self.axes) for vs in vs_list]
self._paths = [mpath.Path(np.ma.column_stack([xs, ys]), cs)
for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)]
zs = np.concatenate([zs for _, _, zs in xyzs_list])
Expand Down Expand Up @@ -497,6 +499,11 @@ def do_3d_projection(self):
"""
segments = np.asanyarray(self._segments3d)

# Handle empty segments
Comment thread
scottshambaugh marked this conversation as resolved.
if segments.size == 0:
LineCollection.set_segments(self, [])
return np.nan

mask = False
if np.ma.isMA(segments):
mask = segments.mask
Expand All @@ -511,8 +518,9 @@ def do_3d_projection(self):
viewlim_mask = np.broadcast_to(viewlim_mask[..., np.newaxis],
(*viewlim_mask.shape, 3))
mask = mask | viewlim_mask
xyzs = np.ma.array(proj3d._proj_transform_vectors(segments, self.axes.M),
mask=mask)

xyzs = np.ma.array(
proj3d._scale_proj_transform_vectors(segments, self.axes), mask=mask)
segments_2d = xyzs[..., 0:2]
LineCollection.set_segments(self, segments_2d)

Expand Down Expand Up @@ -595,9 +603,7 @@ def do_3d_projection(self):
dtype=float, mask=mask).filled(np.nan)
else:
xs, ys, zs = zip(*s)
vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
self.axes.M,
self.axes._focal_length)
vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(xs, ys, zs, self.axes)
self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]))
return min(vzs)

Expand Down Expand Up @@ -657,9 +663,7 @@ def do_3d_projection(self):
dtype=float, mask=mask).filled(np.nan)
else:
xs, ys, zs = zip(*s)
vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
self.axes.M,
self.axes._focal_length)
vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(xs, ys, zs, self.axes)
self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d)
return min(vzs)

Expand Down Expand Up @@ -802,9 +806,7 @@ def do_3d_projection(self):
xs, ys, zs = np.ma.array(self._offsets3d, mask=mask)
else:
xs, ys, zs = self._offsets3d
vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs,
self.axes.M,
self.axes._focal_length)
vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(xs, ys, zs, self.axes)
self._vzs = vzs
if np.ma.isMA(vxs):
super().set_offsets(np.ma.column_stack([vxs, vys]))
Expand Down Expand Up @@ -1020,9 +1022,7 @@ def do_3d_projection(self):
xyzs = np.ma.array(self._offsets3d, mask=mask)
else:
xyzs = self._offsets3d
vxs, vys, vzs, vis = proj3d._proj_transform_clip(*xyzs,
self.axes.M,
self.axes._focal_length)
vxs, vys, vzs, vis = proj3d._scale_proj_transform_clip(*xyzs, self.axes)
self._data_scale = _get_data_scale(vxs, vys, vzs)
# Sort the points based on z coordinates
# Performance optimization: Create a sorted index array and reorder
Expand Down Expand Up @@ -1356,7 +1356,7 @@ def do_3d_projection(self):
# Some faces might contain masked vertices, so we want to ignore any
# errors that those might cause
with np.errstate(invalid='ignore', divide='ignore'):
pfaces = proj3d._proj_transform_vectors(self._faces, self.axes.M)
pfaces = proj3d._scale_proj_transform_vectors(self._faces, self.axes)

if self._axlim_clip:
viewlim_mask = _viewlim_mask(self._faces[..., 0], self._faces[..., 1],
Expand Down
Loading
Loading