From 7ac3cb4e49f486ffad5acc73898cb8a65431b58f 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 7f5917b6..ef36abfa 100755 --- a/cq_editor/widgets/occt_widget.py +++ b/cq_editor/widgets/occt_widget.py @@ -24,6 +24,9 @@ from .navigation_cube import ANIMATION_DURATION, NavigationCube, RotationArrow 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): @@ -61,6 +64,9 @@ def __init__(self, parent=None): self._hovered_arrow = None self.setMouseTracking(True) + # 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) @@ -116,12 +122,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 2c093e95..0e5e0e50 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -52,6 +52,7 @@ class OCCViewer(QWidget, ComponentMixin): children=[ {"name": "Fit automatically", "type": "bool", "value": True}, {"name": "Show navigation cube", "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)}, @@ -175,6 +176,8 @@ def updatePreferences(self, *args): ctx.Erase(arrow, False) ctx.UpdateCurrentViewer() + self.canvas.set_zoom_to_cursor(self.preferences["Zoom to mouse position"]) + self.canvas.update() ctx.SetDeviationCoefficient(self.preferences["Deviation"]) diff --git a/tests/test_app.py b/tests/test_app.py index e64acb0c..423547e7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2160,6 +2160,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):