From dc0473080442d23d0f6da366fd8b92b8a2d03ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Fri, 10 Jul 2026 22:42:52 +0400 Subject: [PATCH] Add a zoom-to-mouse-position option to the 3D viewer When the "Zoom to mouse position" preference is enabled, the mouse wheel zooms toward the point under the cursor via the view's ZoomAtPoint instead of the centered SetZoom. The preference defaults to off, preserving the existing centered-zoom behavior. --- cq_editor/widgets/occt_widget.py | 22 ++++++++++++-- cq_editor/widgets/viewer.py | 3 ++ tests/test_app.py | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/cq_editor/widgets/occt_widget.py b/cq_editor/widgets/occt_widget.py index 195a5a51..1d2602a3 100755 --- a/cq_editor/widgets/occt_widget.py +++ b/cq_editor/widgets/occt_widget.py @@ -14,6 +14,9 @@ from OCP.Quantity import Quantity_Color ZOOM_STEP = 0.9 +# Pixel delta fed to ZoomAtPoint so a wheel notch matches SetZoom(1 / ZOOM_STEP). +# OCCT derives its coefficient as |delta| / 100 + 1. +ZOOM_AT_POINT_STEP = round((1 / ZOOM_STEP - 1) * 100) class OCCTWidget(QWidget): @@ -40,6 +43,9 @@ def __init__(self, parent=None): # Orbit method settings self._orbit_method = "Turntable" + # Zoom towards the cursor instead of the view center + self._zoom_to_cursor = False + # OCCT secific things self.display_connection = Aspect_DisplayConnection() self.graphics_driver = OpenGl_GraphicDriver(self.display_connection) @@ -87,12 +93,24 @@ def set_orbit_method(self, method): else: raise ValueError(f"Unknown orbit method: {method}") + def set_zoom_to_cursor(self, enabled): + + self._zoom_to_cursor = enabled + def wheelEvent(self, event): delta = event.angleDelta().y() - factor = ZOOM_STEP if delta < 0 else 1 / ZOOM_STEP - self.view.SetZoom(factor) + if self._zoom_to_cursor: + pos = event.pos() + self.view.StartZoomAtPoint(pos.x(), pos.y()) + if delta < 0: + self.view.ZoomAtPoint(ZOOM_AT_POINT_STEP, 0, 0, 0) + else: + self.view.ZoomAtPoint(0, 0, ZOOM_AT_POINT_STEP, 0) + else: + factor = ZOOM_STEP if delta < 0 else 1 / ZOOM_STEP + self.view.SetZoom(factor) def mousePressEvent(self, event): diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index 05cdad12..230d0302 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -49,6 +49,7 @@ class OCCViewer(QWidget, ComponentMixin): name="Pref", children=[ {"name": "Fit automatically", "type": "bool", "value": True}, + {"name": "Zoom to mouse position", "type": "bool", "value": False}, {"name": "Use gradient", "type": "bool", "value": False}, {"name": "Background color", "type": "color", "value": (95, 95, 95)}, {"name": "Background color (aux)", "type": "color", "value": (30, 30, 30)}, @@ -158,6 +159,8 @@ def updatePreferences(self, *args): orbit_method = "Trackball" self.canvas.set_orbit_method(orbit_method) + self.canvas.set_zoom_to_cursor(self.preferences["Zoom to mouse position"]) + self.canvas.update() ctx = self.canvas.context diff --git a/tests/test_app.py b/tests/test_app.py index 76deda64..d31acd1a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2126,6 +2126,56 @@ def test_viewer_orbit_methods(main): assert True +class _WheelEvent: + """Minimal stand-in for QWheelEvent; wheelEvent only reads these two.""" + + def __init__(self, pos, delta): + self._pos = pos + self._delta = delta + + def pos(self): + return self._pos + + def angleDelta(self): + return QPoint(0, self._delta) + + +def test_viewer_zoom_to_mouse(main): + """ + The 'Zoom to mouse position' preference switches wheel zooming between the + centered SetZoom behavior and zooming toward the cursor via ZoomAtPoint. + """ + + qtbot, win = main + + viewer = win.components["viewer"] + debugger = win.components["debugger"] + canvas = viewer.canvas + view = canvas.view + + # Render a shape so the view has something to zoom on + debugger._actions["Run"][0].triggered.emit() + + # Preference off -> centered zoom, flag not set + viewer.preferences["Zoom to mouse position"] = False + assert canvas._zoom_to_cursor is False + + scale0 = view.Scale() + canvas.wheelEvent(_WheelEvent(QPoint(10, 10), 120)) + assert view.Scale() != pytest.approx(scale0) + + # Preference on -> zoom toward the cursor, flag set through updatePreferences + viewer.preferences["Zoom to mouse position"] = True + assert canvas._zoom_to_cursor is True + + scale1 = view.Scale() + canvas.wheelEvent(_WheelEvent(QPoint(10, 10), 120)) + assert view.Scale() != pytest.approx(scale1) + + # Both wheel directions are handled without error + canvas.wheelEvent(_WheelEvent(QPoint(30, 30), -120)) + + # @pytest.mark.repeat(1) def test_editor_autoreload(editor):