PyQt5とPyQtGraphで3Dモデルビューワーを作る

#はじめに
なんとなく[PyQtGraphのドキュメント](https://pyqtgraph.readthedocs.io/en/latest/index.html#)を眺めていたら，APIの中に3D Graphicsの機能があることに気づきました．気になったので試しにPyQt5と組み合わせて3Dモデルを表示する簡単なGUIアプリケーションを作ってみました．

私が3Dプリンタをよく使う関係で，ここでいう3DモデルはSTLファイル形式のものを指しています．

#作ったもの
![test2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320225/3747d4b0-5a83-35be-c789-db22f69cf137.gif)

STLファイルを選択するかドラッグアンドドロップすることで，STLファイルをワイヤフレーム表示できます．１度に１つのSTLファイルのみを表示するシンプルなプログラムです．コードはGitHubにもあります．
GitHub：https://github.com/Be4rR/STLViewer

#PyQtGraphとは？
PyQtGraphはグラフ描画用のライブラリで，単体でも使えますが，作成したグラフをPyQt製のGUIに埋め込むことも簡単にできます．定番のMatplotlibと比べると機能は弱いですが，非常に軽いためリアルタイムにデータをプロットするような場合に適しています．あまり知られていないライブラリではありますが，個人的に重宝しています．
公式ページ：http://www.pyqtgraph.org/
公式ドキュメント：https://pyqtgraph.readthedocs.io/en/latest/index.html

#環境

Python3.8，PyQt5，PyQtGraph，PyOpenGL，Numpy，Numpy-STLを使用しています．
PyOpenGLはPyQtGraphで3D Graphicsの機能を使う際に必要になります．また[Numpy-STL](https://github.com/WoLpH/numpy-stl/)でSTLファイルを読み込みます．

```
conda create -n stlviewer python=3.8 pyqt pyqtgraph numpy numpy-stl pyopengl 
```

#プログラム
少し長いです．
<details><summary>stl-viewer.py</summary><div>

```python
from pyqtgraph.Qt import QtCore, QtGui
import pyqtgraph as pg
import pyqtgraph.opengl as gl

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *  

import numpy as np
from stl import mesh

from pathlib import Path
        
class MyWindow(QMainWindow):
    def __init__(self):
        super(MyWindow, self).__init__()
        self.setGeometry(0, 0, 700, 900) 
        self.setAcceptDrops(True)
        
        self.initUI()
        
        self.currentSTL = None
        self.lastDir = None
        
        self.droppedFilename = None
    
    def initUI(self):
        centerWidget = QWidget()
        self.setCentralWidget(centerWidget)
        
        layout = QVBoxLayout()
        centerWidget.setLayout(layout)
        
        self.viewer = gl.GLViewWidget()
        layout.addWidget(self.viewer, 1)
        
        self.viewer.setWindowTitle('STL Viewer')
        self.viewer.setCameraPosition(distance=40)
        
        g = gl.GLGridItem()
        g.setSize(200, 200)
        g.setSpacing(5, 5)
        self.viewer.addItem(g)

        btn = QPushButton(text="Load STL")
        btn.clicked.connect(self.showDialog)
        btn.setFont(QFont("Ricty Diminished", 14))
        layout.addWidget(btn)
            
    def showDialog(self):
        directory = Path("")
        if self.lastDir:
            directory = self.lastDir
        fname = QFileDialog.getOpenFileName(self, "Open file", str(directory), "STL (*.stl)")
        if fname[0]:
            self.showSTL(fname[0])
            self.lastDir = Path(fname[0]).parent
            
    def showSTL(self, filename):
        if self.currentSTL:
            self.viewer.removeItem(self.currentSTL)

        points, faces = self.loadSTL(filename)
        meshdata = gl.MeshData(vertexes=points, faces=faces)
        mesh = gl.GLMeshItem(meshdata=meshdata, smooth=True, drawFaces=False, drawEdges=True, edgeColor=(0, 1, 0, 1))
        self.viewer.addItem(mesh)
        
        self.currentSTL = mesh
        
    def loadSTL(self, filename):
        m = mesh.Mesh.from_file(filename)
        shape = m.points.shape
        points = m.points.reshape(-1, 3)
        faces = np.arange(points.shape[0]).reshape(-1, 3)
        return points, faces

    def dragEnterEvent(self, e):
        print("enter")
        mimeData = e.mimeData()
        mimeList = mimeData.formats()
        filename = None
        
        if "text/uri-list" in mimeList:
            filename = mimeData.data("text/uri-list")
            filename = str(filename, encoding="utf-8")
            filename = filename.replace("file:///", "").replace("\r\n", "").replace("%20", " ")
            filename = Path(filename)
            
        if filename.exists() and filename.suffix == ".stl":
            e.accept()
            self.droppedFilename = filename
        else:
            e.ignore()
            self.droppedFilename = None
        
    def dropEvent(self, e):
        if self.droppedFilename:
            self.showSTL(self.droppedFilename)

if __name__ == '__main__':
    app = QtGui.QApplication([])
    window = MyWindow()
    window.show()
    app.exec_()
```
</div></details>

#解説
あまり複雑なことはしていませんが，いくつかポイントとなる部分を説明します．

##3D表示用のウィジェット`GLViewWidget`

PyQtGraphのドキュメントの[3D Graphics System](https://pyqtgraph.readthedocs.io/en/latest/3dgraphics/index.html)に様々なGraphics Itemが挙げられています．

- GLViewWidget
- GLGridItem
- GLSurfacePlotItem
- GLVolumeItem
- GLImageItem
- GLMeshItem
- GLLinePlotItem
- GLAxisItem
- GLGraphicsItem
- GLScatterPlotItem
- MeshData

１番目の`GLViewWidget`は3Dモデルなどを表示するためのウィジェットです．このウィジェットに2番目以降のGraphics Itemを追加していきます．たとえば`GLGridItem`でグリッド平面を追加したり，`GLMeshItem`でSTLファイルなどのメッシュデータを追加できます．詳しくは公式のドキュメントを見てください．

`GLViewWidget`はPyQtのウィジェットと全く同じように扱えるので，PyQtのGUIにそのまま埋め込むことができます．

##GLMeshItemで3Dモデルを表示

```python
    def showSTL(self, filename):
        # 既に他の3Dモデルを表示している場合，その3Dモデルを取り除く．
        if self.currentSTL:
            self.viewer.removeItem(self.currentSTL)

        # STLファイルから頂点points，面facesを抽出する．
        points, faces = self.loadSTL(filename)

        # メッシュを作成し，3Dモデルを表示するウィジェット(self.viewer)に追加する．
        meshdata = gl.MeshData(vertexes=points, faces=faces)
        mesh = gl.GLMeshItem(meshdata=meshdata, smooth=True, drawFaces=False, drawEdges=True, edgeColor=(0, 1, 0, 1))
        self.viewer.addItem(mesh)
        
        self.currentSTL = mesh
```

`loadSTL`関数はSTLファイルから頂点と面の情報を抽出します．`points`，`faces`のいずれもNumpy配列で，`points`は`(頂点の数, 3)`，`faces`は`(面の数，3)`の形をしています．


上のプログラムでは頂点と面の情報を`MeshData`に渡して`meshdata`を作成し，さらにそれをもとに`gl.GLMeshItem`を作成して描画方法（面や辺の色など）を決めるという二段階を踏んでいます．


そして作成した`GLMeshItem`を`GLViewWidget`である`self.viewer`に追加します．

```python
self.viewer.addItem(mesh)
```

## グリッドを表示する
グリッドも`GLMeshItem`と同じGraphics Itemなので，同じようにして表示できます．

`initUI`関数の部分です．

```python
        g = gl.GLGridItem()
        g.setSize(200, 200)
        g.setSpacing(5, 5)
        self.viewer.addItem(g)
```
`GLGridItem()`で作成後，`setSize`関数でサイズを決め，`setSpacing`関数でグリッド１つ分の大きさを指定しています．最後に`GLViewWidget`の`self.viewer`に`addItem`関数で追加します．