PyVista/Qt tutorial for interactive 3D plotting in Python¶

This tutorial shows how to use the basic functionality of PyVista, a Python library for 3D interactive plotting. PyVista uses the Visualization Toolkit (VTK) for 3D plotting, and optionally PyQt6 for the user interface.

Here are the main Python documentations useful for this tutorial

  • PyVista: docs.pyvista.org/api/
  • PyQt6: riverbankcomputing.com/static/Docs/PyQt6/index.html
  • PyVista/Qt: qtdocs.pyvista.org

Required packages can be installed with

pip install pyvista pyvistaqt PyQt6

This tutorial is available as a Jupyter notebook in this GitHub repository and as an html page.

In [ ]:
import pyvista as pv
import pyvistaqt as pvqt
import PyQt6 as Qt

1. Default plot¶

The BackgroundPlotter is the main PyVista class that plots 3D objects using a Qt window. The window is interactive and continues to live even after the end of the Python script.

By default, a toolbar is included to manage the camera, and a menu is added to take screenshots, change view settings, and edit the scene.

default plot

Here are the main camera control using the mouse:

  • rotate: left click + mouse move
  • translate: left click + mouse move + hold shift
  • zoom: mouse wheel
In [ ]:
mesh_plane = pv.examples.load_airplane()

plotter = pvqt.BackgroundPlotter()
plotter.add_mesh(mesh_plane)

2. Predefined shapes¶

PyVista comes with several predefined 3D shapes.

predefined shapes

See here for more shapes with their various parameters.

In [ ]:
meshes = [
    pv.Arrow(),
    pv.Box(),
    pv.Capsule(),
    pv.Circle(),
    pv.Cone(),
    pv.Cube(),
    pv.Cylinder(),
    pv.CylinderStructured(),
    pv.Disc(),
    pv.Icosphere(),
    pv.Plane(),
    pv.Polygon(),
    pv.Pyramid(),
    pv.Rectangle(),
    pv.SolidSphere(),
    pv.SolidSphereGeneric(),
    pv.Sphere(),
    pv.Superquadric(),
    pv.Triangle(),
    pv.Tube(),
]

plotter = pvqt.BackgroundPlotter()
for i,mesh in enumerate(meshes):
    x = i % 4
    y = i // 4
    mesh.translate((x*2.5,y*2.5,0), inplace=True)
    plotter.add_mesh(mesh, show_edges=True)

3. Custom mesh and line¶

PyVista plots objects of type PolyData that correspond to 3D meshes and lines.

custom

A polygonal mesh can be defined by a list of 3D vertices (var_inp below) and a list of faces specified as a continuous list of integers with the number of vertices in a polygon followed by the index of these vertices.

Lines can be defined from a list of 3D vertices using lines_from_points.

The parameters of add_mesh control the appearance of the objects like their color and opacity.

In [ ]:
mesh_custom = pv.PolyData(
    var_inp=[
        (-1.0, -1.0, 0.0), # vertex 0
        (-1.0, +1.0, 0.0), # vertex 1
        (+1.0, +1.0, 0.0), # vertex 2
        (+1.0, -1.0, 0.0), # vertex 3
        ( 0.0,  0.0, 2.0), # vertex 4 (apex)
    ],
    faces=[
        4, 0,1,2,3, # bottom quad
        3, 0,1,4,
        3, 1,2,4,
        3, 2,3,4,
        3, 3,0,4,
    ],
)

line_custom = pv.lines_from_points([
    [ 0, 0,-0.5],
    [ 0, 0, 0.0],
    [-1,-1, 0.0],
    [-1,+1, 0.0],
    [+1,+1, 0.0],
    [+1,-1, 0.0],
    [ 0, 0, 2.0],
    [ 0, 0, 2.5],
])

plotter = pvqt.BackgroundPlotter()
plotter.add_mesh(
    mesh_custom, 
    opacity=0.5, 
    color='lightblue',
    show_edges=True, 
    edge_color='black',
    line_width=4)
plotter.add_mesh(
    line_custom,
    color='red', 
    line_width=4)

4. Custom user interface¶

The BackgroundPlotter of pyvistaqt uses a QMainWindow that can have a QMenuBar, a QToolBar, a QStatusBar, and several QDockWidget as shown below:

mainwindowlayout

The status bar shows text message permanently or during a certain amount of time.

The tool bar includes buttons and checkboxes that call Python methods when they are clicked on.

The menu bar contains menus and sub-menus that also call Python methods.

interface

The menu 'File/Open...' allows to select the OBJ file data/bunny.obj to load a custom triangular mesh.

mesh

In [ ]:
mesh_plane = pv.examples.load_airplane()

plotter = pvqt.BackgroundPlotter(
    # disable default toolbar and menus
    toolbar=False,
    menu_bar=False,
)

# store the 'actor' associated to the mesh to change its color and visibility later
actor_plane = plotter.add_mesh(mesh_plane)

main_window = plotter.app_window # QMainWindow
main_window.showMaximized()

status_bar = Qt.QtWidgets.QStatusBar()
tool_bar = Qt.QtWidgets.QToolBar()
menu_bar = Qt.QtWidgets.QMenuBar()
menu_file = Qt.QtWidgets.QMenu("File")

def hello():
    '''Prints a message in the console and in the status bar at the bottom of the window'''
    print("Hello World!")
    status_bar.showMessage("Hello World!", 2000) # message for 2 second

def set_red():
    '''Change the plane color to red'''
    actor_plane.GetProperty().SetColor(1,0.2,0)
    plotter.render() # update the rendering

def set_blue():
    '''Change the plane color to blue'''
    actor_plane.GetProperty().SetColor(0,0.2,1)
    plotter.render() # update the rendering

def open():
    '''Open a mesh file and add it to the scene'''
    filename, _ = Qt.QtWidgets.QFileDialog.getOpenFileName(
        main_window,
        'Open file',
        '',
        'Mesh files (*.stl *.obj *.ply *.vtp *.vtu *.vtk *.g *.3ds *.glb *.gltf);;All files (*)'
    )
    if filename:
        mesh = pv.read(filename)
        plotter.add_mesh(mesh)
        status_bar.showMessage(f"Loaded file {filename}", 2000) # message for 2 seconds

checkbox = Qt.QtWidgets.QCheckBox("Show plane")
checkbox.setChecked(True)
def show_hide_plane(checked: bool):
    '''Show/hide the plane'''
    actor_plane.SetVisibility(checked)
    plotter.render() # update the rendering
checkbox.stateChanged.connect(show_hide_plane)

tool_bar.addAction("Hello", hello)
tool_bar.addAction("Red",  set_red)
tool_bar.addAction("Blue", set_blue)
tool_bar.addWidget(checkbox)

menu_file.addAction("Open...", open)
menu_file.addAction("Close", plotter.close)
menu_bar.addMenu(menu_file)

main_window.addToolBar(tool_bar)
main_window.setStatusBar(status_bar)
main_window.setMenuBar(menu_bar)

5. More widgets¶

Many more Qt widgets are available such as spinbox, combobox, slider...

widgets

These Qt widgets can be added into 'layouts' (like QGridLayout, QVBoxLayout or QHboxLayout) that organize top-level widgets (like a QDialog).

Their 'signals' can be 'connected' to Python methods that are automatically called when some events occur like when a button is clicked, when a value changes...

In [ ]:
mesh_plane = pv.examples.load_airplane()

plotter = pvqt.BackgroundPlotter(
    toolbar=False,
    menu_bar=False,
)
plotter.add_mesh(mesh_plane)

main_window = plotter.app_window
main_window.showMaximized()

status_bar = Qt.QtWidgets.QStatusBar()
tool_bar = Qt.QtWidgets.QToolBar()

def demo_widgets():
    # QCheckBox _________________________________________________
    check_box = Qt.QtWidgets.QCheckBox("QCheckBox")
    check_box_label = Qt.QtWidgets.QLabel("checked")
    check_box.setChecked(True)
    def checkbox_changed(checked: bool):
        status_bar.showMessage("QCheckBox clicked")
        if checked:
            check_box_label.setText("checked")
        else:
            check_box_label.setText("unchecked")
    check_box.stateChanged.connect(checkbox_changed)
    # QSpinBox _________________________________________________
    spin_box = Qt.QtWidgets.QSpinBox()
    spin_box_label = Qt.QtWidgets.QLabel("QSpinBox value: 50")
    spin_box.setRange(0,100)
    spin_box.setValue(50)
    def spin_box_changed(value: int):
        spin_box_label.setText(f"QSpinBox value: {value}")
        status_bar.showMessage(f"QSpinBox value: {value}")
    spin_box.valueChanged.connect(spin_box_changed)
    # QDoubleSpinBox ___________________________________________
    double_spin_box = Qt.QtWidgets.QDoubleSpinBox()
    double_spin_box_label = Qt.QtWidgets.QLabel("QDoubleSpinBox value: 0.000")
    double_spin_box.setRange(-10,10)
    double_spin_box.setValue(0)
    double_spin_box.setDecimals(3)
    double_spin_box.setSingleStep(0.01)
    def double_spin_box_changed(value: int):
        double_spin_box_label.setText(f"QDoubleSpinBox value: {value:.3f}")
        status_bar.showMessage(f"QDoubleSpinBox value: {value:.3f}")
    double_spin_box.valueChanged.connect(double_spin_box_changed)
    # QComboBox ________________________________________________
    combo_box = Qt.QtWidgets.QComboBox()
    combo_box_label = Qt.QtWidgets.QLabel("QComboBox index/value: 0/Item 1")
    combo_box.addItems([f"Item {i+1}" for i in range(5)])
    def combo_box_changed(index: int):
        value = combo_box.currentText()
        combo_box_label.setText(f"QComboBox index/value: {index}/{value}")
        status_bar.showMessage(f"QComboBox index/value: {index}/{value}")
    combo_box.currentIndexChanged.connect(combo_box_changed)
    # QSlider __________________________________________________
    slider = Qt.QtWidgets.QSlider(Qt.QtCore.Qt.Orientation.Horizontal)
    slider_label = Qt.QtWidgets.QLabel("QSlider value: 50")
    slider.setRange(0,100)
    slider.setValue(50)
    def slider_changed(value: int):
        slider_label.setText(f"QSlider value: {value}")
        status_bar.showMessage(f"QSlider value: {value}")
    slider.valueChanged.connect(slider_changed)
    # QLineEdit ________________________________________________
    line_edit = Qt.QtWidgets.QLineEdit()
    line_edit.setText("enter text...")
    # QPushButton ______________________________________________
    push_button = Qt.QtWidgets.QPushButton("Ok")
    def push_button_clicked():
        print(f'QCheckBox:      {check_box.isChecked()}')
        print(f'QSpinBox:       {spin_box.value()}')
        print(f'QDoubleSpinBox: {double_spin_box.value()}')
        print(f'QComboBox:      {combo_box.currentText()}')
        print(f'QSlider:        {slider.value()}')
        print(f'QLineEdit:      {line_edit.text()}')
        dialog.close()
    push_button.clicked.connect(push_button_clicked)

    # QDialog
    dialog = Qt.QtWidgets.QDialog(parent=main_window)
    layout = Qt.QtWidgets.QGridLayout()
    layout.addWidget(Qt.QtWidgets.QLabel("This is a dialog box to demonstrate more widgets"), 0,0,1,2) # fromRow, fromColumn, rowSpan, columnSpan
    layout.addWidget(check_box,1,0) # row, column
    layout.addWidget(check_box_label,1,1)
    layout.addWidget(spin_box,2,0)
    layout.addWidget(spin_box_label,2,1)
    layout.addWidget(double_spin_box,3,0)
    layout.addWidget(double_spin_box_label,3,1)
    layout.addWidget(combo_box,4,0)
    layout.addWidget(combo_box_label,4,1)
    layout.addWidget(slider,5,0)
    layout.addWidget(slider_label,5,1)
    layout.addWidget(line_edit,6,0,1,2) # fromRow, fromColumn, rowSpan, columnSpan
    layout.addWidget(push_button,8,0,1,2)
    dialog.setLayout(layout)
    dialog.show()

tool_bar.addAction("QtWidgets", demo_widgets)
main_window.addToolBar(tool_bar)
main_window.setStatusBar(status_bar)