📜 ⬆️ ⬇️

An example of using WxPython to create a node interface. Part 5: We connect nodes

Slowly but surely, I continue to do a series of tutorials about WxPython, where I want to consider developing a fermwork for creating a node interface from scratch to something completely functional and working. In the previous parts we have already described how to add nodes, in the same part, we will connect them, and this picture shows the result, which we will get in this article:

Not perfect yet, but something quite useful and working is already emerging.

Past parts live here:
Part 1: Learning to draw
Part 2: Handling Mouse Events
Part 3: We continue to add features + keyboard handling
Part 4: Implementing Drag & Drop
Part 5: We connect nodes


13. Create the simplest connection.


In pursuit of node connections, we start with a key component, the connection class, which in its simplest form looks like this:
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 

Everything is simple and trivial, we have initial and final objects and we just draw a line between the positions of these objects. Instead of the rest of the methods, stubs
')
Now we need to implement the connection node process. The user interface will be simple: holding Shift, the user clicks on the source node and pulls the connection to the end. To implement, we will remember the source object when clicking on it, adding the following code to “OnMouseLeftDown”:
  if evt.ShiftDown() and self._objectUnderCursor.connectableSource: self._connectionStartObject = self._objectUnderCursor 

When the buttons are released, we will also check that the object under the cursor can accept the incoming connection and connect them if everything is fine. To do this, at the beginning of "OnMouseLeftUp" we will add the appropriate code:
  if (self._connectionStartObject and self._objectUnderCursor and self._connectionStartObject != self._objectUnderCursor and self._objectUnderCursor.connectableDestination): self.ConnectNodes(self._connectionStartObject, self._objectUnderCursor) 

The “ConnectNodes” method creates a connection and registers it in both connected nodes:
  def ConnectNodes(self, source, destination): newConnection = Connection(source, destination) self._connectionStartObject.AddOutcomingConnection(newConnection) self._objectUnderCursor.AddIncomingConnection(newConnection) 

It remains to teach nodes to be connected. To do this, we introduce the appropriate interface, and not one, but as many as 3. "ConnectableObject" will be a common interface for an object that can be connected to another object. In this case, it needs to provide the connection point and center of the node (a little later, we will use this).
 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() 

We also inherit from “ConnectableObject” two classes for objects suitable for incoming and outgoing connections:
 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 

Both these classes are very similar and allow you to store lists of incoming and outgoing connections, respectively. Plus, they set the appropriate flags so that the canvas knows that such an object can be connected.

The last step remains: to modify our node a little, add the corresponding base classes to its parents and modify the rendering process. With rendering everything is interesting, you can store nodes in the canvas and render them there, or you can assign this task to the node and make it render outgoing connections. We will do this by adding the following code to the node's rendering code:
  for connection in self.GetOutcomingConnections(): connection.Render(gc) 

So, if you run this thing and play around a bit, you can get something like this:

Not very nice, but functional already :) The current version of the code lives here .

14. Making beautiful arrows


The lines connecting the angles of the nodes is good for dough, but not very nice and aesthetic. Well, not scary, now we will make beautiful and aesthetic arrows. To begin with, we need a method of drawing arrows, which I quickly wrote, recalling school geometry and using 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]) 

We are counting “self.arrowLength” from the end of the arrow to the beginning and then moving in both directions along the normal by the distance “self.arrowWidth”. So we find the end points of the segments connecting the end of the arrow with ... I do not know what to call it, with the ends of the tip or something.
It remains to replace the line drawing method with the drawing of an arrow in the rendering method and it will be possible to contemplate the following picture:

The code lives here .

15. Get the correct end points of the connections.


It looks better already, but still not quite beautiful, since the ends of the arrow dangle where it is not clear where. To begin with, we modify our connection class to make it more universal and add methods for calculating the start and end points of the connection:
  def SourcePoint(self): return np.array(self.source.GetConnectionPortForTargetPoint(self.destination.GetCenter())) def DestinationPoint(self): return np.array(self.destination.GetConnectionPortForTargetPoint(self.source.GetCenter())) 

In this case, we ask each node to indicate where the connection should start, passing the center of the opposite node to it as the other end. This is not an ideal and not the most universal way, but it will do for a start. Rendering the connection now looks like this:
  def Render(self, gc): gc.SetPen(wx.Pen('#000000', 1, wx.SOLID)) self.RenderArrow(gc, self.SourcePoint(), self.DestinationPoint()) 

It remains to actually implement the “GetConnectionPortForTargetPoint” method at the node, which will calculate the point on the border of the node, from where the connection should start. For a rectangle without considering rounded corners, you can use the following method:
  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 

Here we find the closest intersection between the beam coming from the center of the node to the destination point and the sides of the rectangle. So the point lies on the border of the rectangle and, in general, suits us. So we can get something like this:

Or something similar to the picture at the very beginning of the article, which is a hierarchy of text node classes that are already close to something quite useful.

The code lives in here .

PS: Write about typos in PM.

Source: https://habr.com/ru/post/201930/


All Articles