Repository: matplotlib/matplotlib

Issue:
# [ENH]: Modifier key to discretize rotations for 3D plots

### Problem

Inspired by https://github.com/matplotlib/matplotlib/issues/23544 and talked about on the dev call today.

Want to make it easier to find a repeatable view angles when rotating 3D plots.

### Proposed solution

Add a modifier key (ctrl) so that when rotating 3D plots with this pressed, the view snaps to every 5 degree azimuth/elevation/roll increment instead of rotating continuously. Maybe 15 degrees, should play with it. 10 probably not good because it excludes the 45 deg angle.

Implementation patch:
diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py
index e0867fc3d999..a215bee9ad47 100644
--- a/lib/matplotlib/rcsetup.py
+++ b/lib/matplotlib/rcsetup.py
@@ -1181,6 +1181,7 @@ def _convert_validator_spec(key, conv):
     "axes3d.mouserotationstyle": ["azel", "trackball", "sphere", "arcball"],
     "axes3d.trackballsize": validate_float,
     "axes3d.trackballborder": validate_float,
+    "axes3d.snap_rotation": validate_float,
 
     # scatter props
     "scatter.marker":     _validate_marker,
@@ -2143,6 +2144,12 @@ class _Param:
         description="trackball border width, in units of the Axes bbox (only for "
                     "'sphere' and 'arcball' style)"
     ),
+    _Param(
+        "axes3d.snap_rotation",
+        default=5.0,
+        validator=validate_float,
+        description="Snap angle (in degrees) for 3D rotation when holding Control."
+   ),
     _Param(
         "xaxis.labellocation",
         default="center",
diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py
index d2e12c6e08d9..93cd724080d6 100644
--- a/lib/matplotlib/typing.py
+++ b/lib/matplotlib/typing.py
@@ -222,6 +222,7 @@
     "axes3d.grid",
     "axes3d.mouserotationstyle",
     "axes3d.trackballborder",
+    "axes3d.snap_rotation",
     "axes3d.trackballsize",
     "axes3d.xaxis.panecolor",
     "axes3d.yaxis.panecolor",
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index 53beaf97ffeb..d915fb0c4f17 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -1597,6 +1597,11 @@ def _on_move(self, event):
 
                 q = dq * q
                 elev, azim, roll = np.rad2deg(q.as_cardan_angles())
+            step = mpl.rcParams["axes3d.snap_rotation"]
+            if step > 0 and getattr(event, "key", None) == "control":
+                elev = step * round(elev / step)
+                azim = step * round(azim / step)
+                roll = step * round(roll / step)
 
             # update view
             vertical_axis = self._axis_names[self._vertical_axis]


Test patch:
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index e9809ce2a106..314a3dcdb3b0 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -2785,3 +2785,64 @@ def test_axis_get_tightbbox_includes_offset_text():
             f"bbox.x1 ({bbox.x1}) should be >= offset_bbox.x1 ({offset_bbox.x1})"
         assert bbox.y1 >= offset_bbox.y1 - 1e-6, \
             f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})"
+
+
+def test_ctrl_rotation_snaps_to_5deg():
+    fig = plt.figure()
+    ax = fig.add_subplot(projection='3d')
+
+    initial = (12.3, 33.7, 2.2)
+    ax.view_init(*initial)
+    fig.canvas.draw()
+
+    s = 0.25
+    step = plt.rcParams["axes3d.snap_rotation"]
+
+    # First rotation without Ctrl
+    with mpl.rc_context({'axes3d.mouserotationstyle': 'azel'}):
+        MouseEvent._from_ax_coords(
+            "button_press_event", ax, (0, 0), MouseButton.LEFT
+        )._process()
+
+        MouseEvent._from_ax_coords(
+            "motion_notify_event",
+            ax,
+            (s * ax._pseudo_w, s * ax._pseudo_h),
+            MouseButton.LEFT,
+        )._process()
+
+    fig.canvas.draw()
+
+    rotated_elev = ax.elev
+    rotated_azim = ax.azim
+    rotated_roll = ax.roll
+
+    # Reset before ctrl rotation
+    ax.view_init(*initial)
+    fig.canvas.draw()
+
+    # Now rotate with Ctrl
+    with mpl.rc_context({'axes3d.mouserotationstyle': 'azel'}):
+        MouseEvent._from_ax_coords(
+            "button_press_event", ax, (0, 0), MouseButton.LEFT
+        )._process()
+
+        MouseEvent._from_ax_coords(
+            "motion_notify_event",
+            ax,
+            (s * ax._pseudo_w, s * ax._pseudo_h),
+            MouseButton.LEFT,
+            key="control"
+        )._process()
+
+    fig.canvas.draw()
+
+    expected_elev = step * round(rotated_elev / step)
+    expected_azim = step * round(rotated_azim / step)
+    expected_roll = step * round(rotated_roll / step)
+
+    assert ax.elev == pytest.approx(expected_elev)
+    assert ax.azim == pytest.approx(expected_azim)
+    assert ax.roll == pytest.approx(expected_roll)
+
+    plt.close(fig)