graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

PySide で QGraphicsView を使ってみる。

f:id:hal1932:20190811210508j:plain

ノードエディタっぽいものを試しにつくってみたので、気になったとことかをいくつかメモっておく。
maya_test/tools/node_editor at master · hal1932/maya_test · GitHub

基本設計

f:id:hal1932:20190812142858p:plain

  • NodeGraphWidget
    • ユーザーに公開するWidget
  • GraphicsView
    • NodeGraphWidgetで必要な各種UI系イベントをまとめてSignal化する
  • NodeGraphScene
    • シーン内のGraphicsItemに渡す各種イベントの取りまとめ
  • NodeView
    • シーン内に配置するノードのベースクラス
  • PlugView
    • ノード間にエッジを張るための接続点
  • EdgeView
    • ノード間のエッジ

QGraphicsItem が QObject 派生ではない

class QGraphicsItem(__Shiboken.Object):
    """ QGraphicsItem(self, parent: PySide2.QtWidgets.QGraphicsItem = None) """
    def acceptDrops(self): # real signature unknown; restored from __doc__
        """ acceptDrops(self) -> bool """
        return False
    ...

QtCore.pyi をみると分かるんだけど、見ての通り __Shiboken.Object を直接継承してる。
マウスとかの各種イベントは QGraphicsItem やその継承クラスに直接実装されてるからいいんだけど、Signal/Slot が使えないのがちょっと痛い。

C++ の Qt であれば 多重継承で解決する のがどうやら正解っぽいんだけど、Python 自体が多重継承をあまりちゃんとサポートしてくれてない*1ので、とりあえず今回は簡易Signalクラス*2を自前で用意してみた。

これはこれで微妙だけど、とはいえ手動でコンストラクタを1個ずつ叩くのとどっちが汚いかと言われると、正直なんとも言えないところだと思う。

class GraphicsItemSignal(object):
    def __init__(self, *_):
        self.__slots = []
    def connect(self, slot):
        self.__slots.append(slot)
    def disconnect(self, slot):
        self.__slots.remove(slot)
    def emit(self, *args):
        for slot in self.__slots:
            slot(*args)

QGraphicsItem にイベント実装が足りない

たとえば「マウスオーバーしたPlugをハイライトする」みたいなことを実現するには mouseOverEvent() が必要なんだけど、QGraphicsItem はその実装を持ってない。なので、NodeGraphScene から各 Item に向けてカスタムイベントを投げることにした。
カスタムイベントと言ってもそうたいしたことはなく、マウスポインタの下にいる Items たちを取得して QGraphicsSceneMouseEvent を横流しするだけ。Qt のこういうシンプルさは個人的には嫌いじゃない。*3

class NodeGraphScene(QGraphicsScene):
    def __init__(self, *args, **kwargs):
        super(_NodeGraphScene, self).__init__(*args, **kwargs)
        self.__mouse_overed_items = set()
    def mouseMoveEvent(self, e):
        # type: (QGraphicsSceneMouseEvent) -> NoReturn
        all_items = self.items()
        over_items = self.items(e.scenePos())
        for item in all_items:
            if item in over_items:
                if hasattr(item, 'mouseOverEvent'):
                    item.mouseOverEvent(e)
                self.__mouse_overed_items.add(item)
            elif item in self.__mouse_overed_items:
                if hasattr(item, 'mouseLeaveEvent'):
                    item.mouseLeaveEvent(e)
                self.__mouse_overed_items.remove(item)
        super(_NodeGraphScene, self).mouseMoveEvent(e)
class PlugView(QGraphicsEllipseItem):
    def mouseOverEvent(self, e):
        # type: (QGraphicsSceneMouseEvent) -> NoReturn
        self.setBrush(ItemStyles.PLUG_BACKGROUND_TARGET)
    def mouseLeaveEvent(self, e):
        # type: (QGraphicsSceneMouseEvent) -> NoReturn
        self.setBrush(ItemStyles.PLUG_BACKGROUND_NORMAL)

カスタム描画と当たり判定

f:id:hal1932:20190812145433p:plain

Maya とかのノードエディタもそうなんだけど、ふつうこういうのは Node の種類や Plug の個数によって描画範囲が変わってくる。
カスタム描画自体は例によって paint() をオーバーライドしてやればいいんだけど、今回は「マウスクリックでノードを選択する」とかの挙動も実装する必要があるから、boundingRect() も一緒にオーバーライドしてやらないといけない。

class QGraphicsRectItem(QAbstractGraphicsShapeItem):
    def boundingRect(self): # real signature unknown; restored from __doc__
        """ boundingRect(self) -> PySide2.QtCore.QRectF """
        pass

boundingRect() と paint() とは本来まったく異なる文脈で呼び出される関数ではあるんだけど、今回のケースでは、当たり判定の範囲が描画結果に完全に依存する。ので、paint() の中で当たり判定も一緒に計算しておくことにした。

class NodeView(QGraphicsRectItem):
    def boundingRect(self):
        # type: () -> QRectF
        return self.__bounding_rect
    def paint(self, painter, item, widget):
        # type: (QPainter, QStyleOptionGraphicsItem, QWidget) -> NoReturn
        painter.drawText(...)
        painter.drawRoundedRect(...)
        self.__bounding_rect.setRect(x, y, w, h)

コンテキストメニュー

f:id:hal1932:20190812150320p:plainf:id:hal1932:20190812150340p:plain

Scene 上の「何もない場所」で右クリックしたときはノード生成メニューを出したいけど、Node を右クリックしたときは Node 用のメニューを出したい。

これが微妙に面倒だった。シーン上に Item を配置してるときに、Scene 側で contextMenuEvent() の実装をしてしまうと、Item を右クリックしたときに Item ではなく Scene の contextMenuEvent() が呼び出されるようになってしまう。

f:id:hal1932:20190812150647p:plain
Nodeを右クリックしたのに「何もない場所」用のメニューが出てくる

どうやら Qt 内では Item → Scene の順に bubbling が行われているのではなく、Scene.contextMenuEvent() の中で Item.contextMenuEvent() を呼び出す実装になってるらしい。*4

そういうことであれば回避は簡単で、オーバーライドした Scene.contextMenuEvent() の中で、右クリックした位置に Item がある場合は本来の contextMenuEvent() を呼んであげればいい。

class NodeGraphScene(QGraphicsScene):
    def contextMenuEvent(self, e):
        # type: (QGraphicsSceneContextMenuEvent) -> NoReturn
        item = self.itemAt(e.scenePos(), QTransform())
        if item is not None:
            super(NodeGraphScene, self).contextMenuEvent(e)
            return
        # Scene側のMenu生成処理
        menu = QMenu()
        ...

Item が折り重なってるような場合が厄介な気がするけど、そのへんはたぶん zValue をいいかんじにコントロールしてやればどうにかなる気がする。

イベントハンドラの実装がカオス化する

これはもう Qt を使う以上ある程度は仕方ない気もする。
View 関連のクラスに直接ハンドラを実装していくのが結構やばい。MVC みたいな設計をちゃんとしようって話ではあるんだけど、これがなかなかめんどくさい。。
今回のコードは実験用と割り切ってぐちゃっとしたまま進めちゃったけど、もし仕事でやるならもうちょい真面目に設計しないと駄目だろうなぁ……。

*1:というか、__init__がその他メソッドとまったく同じように呼び出し解決される(MRO的に一番近いやつだけ呼び出す)ので、C++的な多段コンストラクトのノリでsuper().__init__(self)すると死ぬ。

*2:あくまで簡易版。実戦投入するならもうちょいちゃんと考える。

*3:プリミティブすぎて困ることは多々あるけど、じゃあWPFみたいな複雑さがそんなに素晴らしいかっていうとそれはそれで……。

*4:実挙動からの推測なので、ほんとにそうなってるかどうかはよくわからん。