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):