Pull to refresh

Пример использования WxPython для создания нодового интерфейса. Часть 5: Соединяем ноды

Reading time 6 min
Views 6.8K
Медленно, но верно, я продолжаю делать серию туториалов о WxPython, где я хочу рассмотреть разработку ферймворка для создания нодового интерфейса с нуля и до чего-то вполне функционального и рабочего. В прошлых частях уже рассказано как добавлять ноды, в этой же части, мы их будем соединять, а на этой картинке показан результат, который мы в этой статье получим:

Еще не идеально, но уже вырисовывается что-то вполне полезное и рабочее.

Прошлые части живут тут:
Часть 1: Учимся рисовать
Часть 2: Обработка событий мыши
Часть 3: Продолжаем добавлять фичи + обработка клавиатуры
Часть 4: Реализуем Drag&Drop
Часть 5: Соединяем ноды


13. Создаем простейшее соединение


В погоне за соединениями нод, мы начнем с ключевого компонента, класса соединения, который в простейшем виде выглядит так:
class Connection(CanvasObject):
    def __init__(self, source, destination, **kwargs):
        super(Connection, self).__init__(**kwargs)

        self.source = source
        self.destination = destination

    def Render(self, gc):
        gc.SetPen(wx.Pen('#000000', 1, wx.SOLID))
        gc.DrawLines([self.source.position, self.destination.position])

    def RenderHighlighting(self, gc):
        return

    def ReturnObjectUnderCursor(self, pos):
        return None

Все просто и тривиально, у нас есть начальный и конечный объекты и мы просто рисуем линию между позициями этих объектов. Вместо остальных методов пока заглушки.

Теперь нам надо реализовать процесс соединения нод. Интерфейс пользователя будет простым: удерживая Shift, пользователь нажимает на исходную ноду и тянет соединение к конечной. Для реализации мы запомним исходный объект при нажатии на него, добавив в «OnMouseLeftDown» следующий код:
        if evt.ShiftDown() and self._objectUnderCursor.connectableSource:
            self._connectionStartObject = self._objectUnderCursor

При отпускании же кнопки, мы также проверим, чтобы объект под курсором мог принять входящее соединение и соединим их, если все хорошо. Для этого в начале «OnMouseLeftUp» мы добавим соответствующий код:
        if (self._connectionStartObject 
                and self._objectUnderCursor 
                and self._connectionStartObject != self._objectUnderCursor 
                and self._objectUnderCursor.connectableDestination):
            self.ConnectNodes(self._connectionStartObject, self._objectUnderCursor)

Метод «ConnectNodes» занимается созданием соединения и его регистрацией в обеих соединяемых нодах:
    def ConnectNodes(self, source, destination):
        newConnection = Connection(source, destination)
        self._connectionStartObject.AddOutcomingConnection(newConnection)
        self._objectUnderCursor.AddIncomingConnection(newConnection)

Осталось научить ноды быть соединяемыми. Для этого мы введем соответствующий интерфейс, да не один, а целых 3. «ConnectableObject» будет общим интерфейсом для объекта, который может быть соединен с другим объектом. В данном случае, ему необходимо предоставлять точку соединения и центр ноды (чуть позже, мы это будем использовать).
class ConnectableObject(CanvasObject):
    def __init__(self, **kwargs):
        super(ConnectableObject, self).__init__(**kwargs)

    def GetConnectionPortForTargetPoint(self, targetPoint):
        """
        GetConnectionPortForTargetPoint method should return an end 
        point position for a connection object.
        """
        raise NotImplementedError()
    
    def GetCenter(self):
        """
        GetCenter method should return a center of this object. 
        It is used during a connection process as a preview of a future connection.
        """
        raise NotImplementedError()

Также мы наследуюем от «ConnectableObject» два класс для объектов подходящих для входящих и исходящих соединений:
class ConnectableDestination(ConnectableObject):
    def __init__(self, **kwargs):
        super(ConnectableDestination, self).__init__(**kwargs)
        self.connectableDestination = True
        
        self._incomingConnections = []
        
    def AddIncomingConnection(self, connection):
        self._incomingConnections.append(connection)
        
    def DeleteIncomingConnection(self, connection):
        self._incomingConnections.remove(connection)


class ConnectableSource(ConnectableObject):
    def __init__(self, **kwargs):
        super(ConnectableSource, self).__init__(**kwargs)
        self.connectableSource = True
        
        self._outcomingConnections = []
        
    def AddOutcomingConnection(self, connection):
        self._outcomingConnections.append(connection)
    
    def DeleteOutcomingConnection(self, connection):
        self._outcomingConnections.remove(connection)
        
    def GetOutcomingConnections(self):
        return self._outcomingConnections

Оба эти класса весьма похожи и позволяют хранить списки входящих и исходящих соединений соответственно. Плюс они устанавливают соответствующие флаги, чтобы канвас знал о том, что такой-то объект может быть соединен.

Остался последний шаг: немного модифицировать нашу ноду, добавив в ее родители соответствующие базовые классы и модифицировать процесс рендеринга. С рендерингом все интересно, можно хранить ноды в канвасе и там же их рендерить, а можно возложить эту задачу на ноду и заставить ее рендерить исходящие соединения. Это мы и сделаем, добавив в код рендеринг ноды вот такой код:
        for connection in self.GetOutcomingConnections():
            connection.Render(gc)

Итак, если запустить это дело и немного поиграться, то можно получить что-то вроде этого:

Не сильно красиво, но уже функционально:) Текущая версия кода живет тут.

14. Делаем красивые стрелочки


Линии, соединяющие углы нод — это хорошо для теста, но не очень красиво и эстетично. Ну да не страшно, сейчас мы сделаем красивые и эстетичные стрелочки. Для начала, нам понадобится метод рисования стрелочек, который я быстренько написал, вспомнив школьную геометрию и использую NumPy:
    def RenderArrow(self, gc, sourcePoint, destinationPoint):
        gc.DrawLines([sourcePoint, destinationPoint])
        
        #Draw arrow
        p0 = np.array(sourcePoint)
        p1 = np.array(destinationPoint)
        dp = p0-p1
        l = np.linalg.norm(dp)
        dp = dp / l
        n = np.array([-dp[1], dp[0]])
        neck = p1 + self.arrowLength*dp
        lp = neck + n*self.arrowWidth
        rp = neck - n*self.arrowWidth
        
        gc.DrawLines([lp, destinationPoint])
        gc.DrawLines([rp, destinationPoint])

Мы тут отсчитываем «self.arrowLength» от конца стрелочки к началу и затем двигаемся в обе стороны по нормали на расстояние «self.arrowWidth». Так мы находим точки концов отрезков, соединяющих конец стрелочки с… не знаю как это назвать, с концами острия что ли.
Осталось в методе рендеринга заменить рисование линии на рисование стрелочки и можно будет созерцать такую картину:

Код живе тут.

15. Получаем корректные точки концов соединений


Выглядит уже лучше, но еще не совсем красиво, так как концы стрелочке болтаются непонятно где. Для начала мы модифицируем класс нашего соединения, чтобы сделать все более универсальным и добавим туда методы вычисления начальной и конечной точек соединения:
    def SourcePoint(self):
        return np.array(self.source.GetConnectionPortForTargetPoint(self.destination.GetCenter()))
    
    def DestinationPoint(self):
        return np.array(self.destination.GetConnectionPortForTargetPoint(self.source.GetCenter()))

В данном случае, мы просим каждую ноду указать, откуда стоит начинать соединение, передавая ей центр противоположной ноды как другой конец. Это не идеальный и не самый универсальный способ, но для начала сойдет. Рендеринг соединения теперь выглядит так:
    def Render(self, gc):
        gc.SetPen(wx.Pen('#000000', 1, wx.SOLID))
        self.RenderArrow(gc, self.SourcePoint(), self.DestinationPoint())

Осталось собственно реализовать метод «GetConnectionPortForTargetPoint» у ноды, который будет вычислять точку на границе ноды, откуда следует начинать соединение. Для прямоугольника без учета закругленных углов, можно использовать следующий метод:
    def GetConnectionPortForTargetPoint(self, targetPoint):
        targetPoint = np.array(targetPoint)
        center = np.array(self.GetCenter())
        direction = targetPoint - center
        
        if direction[0] > 0:
            #Check right border
            borderX = self.position[0] + self.boundingBoxDimensions[0] 
        else:
            #Check left border
            borderX = self.position[0]
        if direction[0] == 0:
            t1 = float("inf")
        else:
            t1 = (borderX - center[0]) / direction[0] 
        
        
        if direction[1] > 0:
            #Check bottom border
            borderY = self.position[1] + self.boundingBoxDimensions[1] 
        else:
            #Check top border
            borderY = self.position[1]
        if direction[1] == 0: 
            t2 = float("inf")
        else:
            t2 = (borderY - center[1]) / direction[1]
        
        t = min(t1, t2)
        boundaryPoint = center + t*direction

        return boundaryPoint

Тут мы находим бпижайшее пересечение между лучом, выходящим из центра ноды в точку назначение, и сторонами прямоугольника. Этак точка лежит на границе прямоугольника и, в целом, нам подходит. Так мы можем получить что-нибудь такое:

Или что-то, похожее на картинку в самом начале статьи, которая представляет собой иерархию классов текстовой ноды, которая уже близка к чему-то вполне полезному.

Код живет в тут.

PS: Об опечатках пишите в личку.
Tags:
Hubs:
+11
Comments 0
Comments Leave a comment

Articles