diff --git a/doc/release/next_whats_new/snap_rotation.rst b/doc/release/next_whats_new/snap_rotation.rst new file mode 100644 index 000000000000..144a4fa96011 --- /dev/null +++ b/doc/release/next_whats_new/snap_rotation.rst @@ -0,0 +1,16 @@ +Snapping 3D rotation angles with Control key +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +3D axes rotation now supports snapping to fixed angular increments +when holding the ``Control`` key during mouse rotation. + +The snap step size is controlled by the new +``axes3d.snap_rotation`` rcParam (default: 5.0 degrees). +Setting it to 0 disables snapping. + +For example:: + + mpl.rcParams["axes3d.snap_rotation"] = 10 + +will snap elevation, azimuth, and roll angles to multiples +of 10 degrees while rotating with the mouse. diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 17705fe60347..7bfe317535a3 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -451,7 +451,7 @@ # See also https://matplotlib.org/stable/api/toolkits/mplot3d/view_angles.html#rotation-with-mouse #axes3d.trackballsize: 0.667 # trackball diameter, in units of the Axes bbox #axes3d.trackballborder: 0.2 # trackball border width, in units of the Axes bbox (only for 'sphere' and 'arcball' style) - +#axes3d.snap_rotation: 5.0 # Snap angle (degrees) for 3D rotation when holding Control. ## *************************************************************************** ## * AXIS * ## *************************************************************************** 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] 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)