I need to draw two 3D surface plots in the same window and I would like to "link" the cameras' positions of them, so that if a user moves the view of the first subplot, the second one updates accordingly, in order to keep the same view orientation.
I can successfully achieve this result in matplotlib
, as explained in this answer:
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
N = 101
x = np.linspace(-10, 10, N)
y = np.linspace(-10, 10, N)
X, Y = np.meshgrid(x, y)
Z1 = np.sin(X) + np.cos(Y)
Z2 = np.cos(np.sqrt(X**2 + Y**2))
fig = plt.figure(figsize = plt.figaspect(0.5))
ax1 = fig.add_subplot(1, 2, 1, projection = '3d')
surf1 = ax1.plot_surface(X, Y, Z1, rstride = 1, cstride = 1, cmap = cm.jet, linewidth = 0, antialiased = False)
ax2 = fig.add_subplot(1, 2, 2, projection = '3d')
surf2 = ax2.plot_surface(X, Y, Z2, rstride = 1, cstride = 1, cmap = cm.jet, linewidth = 0, antialiased = False)
def on_move(event):
if event.inaxes == ax1:
if ax1.button_pressed in ax1._rotate_btn:
ax2.view_init(elev = ax1.elev, azim = ax1.azim)
elif ax1.button_pressed in ax1._zoom_btn:
ax2.set_xlim3d(ax1.get_xlim3d())
ax2.set_ylim3d(ax1.get_ylim3d())
ax2.set_zlim3d(ax1.get_zlim3d())
elif event.inaxes == ax2:
if ax2.button_pressed in ax2._rotate_btn:
ax1.view_init(elev = ax2.elev, azim = ax2.azim)
elif ax2.button_pressed in ax2._zoom_btn:
ax1.set_xlim3d(ax2.get_xlim3d())
ax1.set_ylim3d(ax2.get_ylim3d())
ax1.set_zlim3d(ax2.get_zlim3d())
else:
return
fig.canvas.draw_idle()
c1 = fig.canvas.mpl_connect('motion_notify_event', on_move)
plt.show()
However, I must use pyqtgraph
to perform this task. I can draw the two surfaces, but I don't know how to "link" the cameras' positions of the two subplots.
import numpy as np
import pyqtgraph as pg
import pyqtgraph.opengl as gl
from matplotlib.cm import get_cmap
from pyqtgraph.Qt import QtGui
N = 101
x = np.linspace(-10, 10, N)
y = np.linspace(-10, 10, N)
cmap = get_cmap('jet')
X, Y = np.meshgrid(x, y)
Z1 = np.sin(X) + np.cos(Y)
Z1_min = np.min(Z1)
Z1_max = np.max(Z1)
colors1 = cmap((Z1 - Z1_min)/(Z1_max - Z1_min))
Z2 = np.cos(np.sqrt(X**2 + Y**2))
Z2_min = np.min(Z2)
Z2_max = np.max(Z2)
colors2 = cmap((Z2 - Z2_min)/(Z2_max - Z2_min))
win = pg.GraphicsLayoutWidget(show = True, size = (1000, 500))
layoutgb = QtGui.QGridLayout()
win.setLayout(layoutgb)
glvw1 = gl.GLViewWidget()
surf1 = gl.GLSurfacePlotItem(x = x, y = y, z = Z1, colors = colors1)
glvw1.addItem(surf1)
glvw1.sizeHint = lambda: pg.QtCore.QSize(100, 500)
layoutgb.addWidget(glvw1, 0, 0)
glvw2 = gl.GLViewWidget()
surf2 = gl.GLSurfacePlotItem(x = x, y = y, z = Z2, colors = colors2)
glvw2.addItem(surf2)
glvw2.sizeHint = lambda: pg.QtCore.QSize(100, 500)
layoutgb.addWidget(glvw2, 0, 2)
QtGui.QApplication.instance().exec_()
To achieve what You need, You have to sync "Camera" with all linked plots.
I choose to override gl.GLViewWidget (SyncedCameraViewWidget) and re-implement three events wheelEvent
, mouseMoveEvent
and mouseReleaseEvent
.
To make things work You have to sync_camera_with
with another SyncedCameraViewWidget.
Technically You can use pure gl.GLViewWidget there.
I like flexibility, so I'm not enforcing passing another SyncedCameraViewWidget there.
That's why You have to link PlotA -> PlotB and PlotB -> PlotA.
Otherwise, when You change Camera of PlotB it won't change PlotA Camera.
Here is Your code with SyncedCameraViewWidget:
from typing import List
import numpy as np
import pyqtgraph as pg
import pyqtgraph.opengl as gl
from matplotlib.cm import get_cmap
from pyqtgraph.Qt import QtGui
class SyncedCameraViewWidget(gl.GLViewWidget):
def __init__(self, parent=None, devicePixelRatio=None, rotationMethod='euler'):
self.linked_views: List[gl.GLViewWidget] = []
super().__init__(parent, devicePixelRatio, rotationMethod)
def wheelEvent(self, ev):
"""Update view on zoom event"""
super().wheelEvent(ev)
self._update_views()
def mouseMoveEvent(self, ev):
"""Update view on move event"""
super().mouseMoveEvent(ev)
self._update_views()
def mouseReleaseEvent(self, ev):
"""Update view on move event"""
super().mouseReleaseEvent(ev)
self._update_views()
def _update_views(self):
"""Take camera parameters and sync with all views"""
camera_params = self.cameraParams()
# Remove rotation, we can't update all params at once (Azimuth and Elevation)
camera_params["rotation"] = None
for view in self.linked_views:
view.setCameraParams(**camera_params)
def sync_camera_with(self, view: gl.GLViewWidget):
"""Add view to sync camera with"""
self.linked_views.append(view)
N = 101
x = np.linspace(-10, 10, N)
y = np.linspace(-10, 10, N)
cmap = get_cmap('jet')
X, Y = np.meshgrid(x, y)
Z1 = np.sin(X) + np.cos(Y)
Z1_min = np.min(Z1)
Z1_max = np.max(Z1)
colors1 = cmap((Z1 - Z1_min) / (Z1_max - Z1_min))
Z2 = np.cos(np.sqrt(X ** 2 + Y ** 2))
Z2_min = np.min(Z2)
Z2_max = np.max(Z2)
colors2 = cmap((Z2 - Z2_min) / (Z2_max - Z2_min))
win = pg.GraphicsLayoutWidget(show=True, size=(1000, 500))
layoutgb = QtGui.QGridLayout()
win.setLayout(layoutgb)
glvw1 = SyncedCameraViewWidget()
surf1 = gl.GLSurfacePlotItem(x=x, y=y, z=Z1, colors=colors1)
glvw1.addItem(surf1)
layoutgb.addWidget(glvw1, 0, 0)
glvw2 = SyncedCameraViewWidget()
surf2 = gl.GLSurfacePlotItem(x=x, y=y, z=Z2, colors=colors2)
glvw2.addItem(surf2)
layoutgb.addWidget(glvw2, 0, 2)
glvw1.sync_camera_with(glvw2)
glvw2.sync_camera_with(glvw1)
QtGui.QApplication.instance().exec_()
Notice that in the Event method, You must first execute Event in gl.GLViewWidget (change camera in main plot) and only after that _update_views() (change camera for all linked plots).
Otherwise plot camera won't be in sync.