Animation and Complex Shapes

In the preceding section, we looked at a graphics view application in which user interaction was central. In this section, we will look at a very different kind of application, one where we simulate a population of creatures, "multipedes", by visualizing each member of the population using a set of graphics items, as shown in Figure 12.4. Each multipede has internal timers. At each time interval the multipede moves, and if it has collisions, its coloring is changed slightly, and eventually it disappears.

We will begin by looking at an extract from the main form's initializer. Then we will review the form's populate() method, which is used to create and position the multipedes. Next we will look at the action of the Pause/Resume button and at the implementation of the zoom slider. Then we will look at the form's timer event, a kind of event handler we have not used before. Once we can see how the application works as a whole, we will look at the implementations of the graphics item subclasses that are used to visualize the multipedes.

class MainForm(QDialog):

def_init_(self, parent=None):

super(MainForm, self)._init_(parent)

self.scene = QGraphicsScene(self) self.scene.setSceneRect(0, 0, SCENESIZE, SCENESIZE) self.view = QGraphicsView() self.view.setRenderHint(QPainter.Antialiasing) self.view.setScene(self.scene)

Figure 12.4 The Multipedes application

self.view.setFocusPolicy(Qt.NoFocus) zoomSlider = QSlider(Qt.Horizontal) zoomSlider.setRange(5, 200) zoomSlider.setValue(100) self.pauseButton = QPushButton("Pa&use") quitButton = QPushButton("&Quit")

The form begins by creating a graphics scene. As usual for nonvisual QObject subclasses, we give the scene a parent. The SCENESIZE is a global integer of value 500. Setting up the view is similar to what we saw in the previous example. The zoom slider is used to zoom the scene in or out. We set its initial value to 100 (100%), and give it a range of 5% to 200%. The Pause button is used to pause and resume the animation.

self.connect(zoomSlider, SIGNAL("valueChanged(int)"), self.zoom)

self.connect(self.pauseButton, SIGNAL("clicked()"), self.pauseOrResume) self.connect(quitButton, SIGNAL("clicked()"), self.accept)

self.populate()

self.startTimer(INTERVAL)

self.setWindowTitle("Multipedes")

We have omitted the layout since we have seen it so many before, and this one is not unusual. The connections contain no surprises, but they are shown so that we can see how the user interaction is handled.

Every QObject subclass (which includes all QWidgets since they are QObject subclasses) can set off a timer that causes a timer event to occur at every time interval. Here the INTERVAL is 200 milliseconds. The accuracy of timers depends on the underlying operating system, but it should be at least as good as 20 milliseconds, unless the machine is very heavily loaded. The startTimer() method returns a timer ID which is useful if we want to call the method more than once to set up multiple timers; we ignore it here because we want just one timer.

At the end of the initializer, we call populate() to create the multipedes, and set the application's window title as usual.

def pauseOrResume(self): global Running Running = not Running self.pauseButton.setText("Pa&use" if Running else "Res&ume")

If the user clicks the Pause button, we set the global Running Boolean to the opposite of what it was, and change the button's caption. The form's timer and the multipede timers refer to this variable, doing nothing if it is False.

def zoom(self, value): factor = value / 100.0 matrix = self.view.matrix() matrix.reset()

matrix.scale(factor, factor) self.view.setMatrix(matrix)

To zoom the scene, all that we need to do is change the scale of the view that shows the scene. This is achieved by getting the view's current transformation matrix, clearing any transformations (i.e., scaling) that may be in force, and then rescaling it to a factor that is proportional to the slider's setting.

Figure 12.5 Multipedes at two different zoom levels

Zooming has a significant effect on how the multipedes are drawn. This is because in the QGraphicsItem.paint() method, we can find out how zoomed in or out a scene is and can use this information to determine how much detail to draw. This means, for example, that we can draw in a faster and more simplified way if the scene is zoomed out with users unable to discern the details anyway, and that we can draw in increasing detail as users zoom in. The effect of zooming is shown in Figure 12.5.

def populate(self):

red, green, blue = 0, 150, 0 for i in range(random.randint(6, 10)): angle = random.randint(0, 360) offset = random.randint(0, SCENESIZE // 2) half = SCENESIZE / 2

x = half + (offset * math.sin(math.radians y = half + (offset * math.cos(math.radians color = QColor(red, green, blue) head = Head(color, angle, QPointF(x, y)) color = QColor(red, green + random.randint offset = 25

segment = Segment(color, offset, head) for j in range(random.randint(3, 7)): offset += 25 segment = Segment(color, offset, segment) head.rotate(random,randint(0, 360)) self.scene.addItem(head) global Running Running = True

This method is used to generate a random population of 6-10 multipedes. Each multipede is made up of a head, and between four and eight body segments. For each multipede, the head is created first, with a semirandom color, with a random angle of direction, and at a random position inside a circle with its center in the middle of the scene. Then the multipede's first segment is created, with the head being its parent. This means that whatever transformation is applied to the head, for example, moving or rotating it, will also be applied to the first segment. Next, 3-7 additional segments are created. Each one is made a child of its preceding segment. The effect of this is that if the head is transformed, the first segment is transformed correspondingly, and so is the first segment's child segment, and so on, for all the multipede's segments.

Once the head and segments have been created, we rotate the head and add it to the scene. Adding a graphics item to a scene automatically adds all the item's children recursively, so by adding just the head, the entire multipede is added.

At the end, we set the global Running Boolean to True. In addition to the form's timer, each multipede part has a timer, and as long as Running is True, the part will move at each time interval.

The red color we have used is significant for head items. The red color component is set to 0 for all multipedes when they are first created. If the red color component of a multipede's head reaches the maximum (255)—which can occur as the result of collisions—the multipede will "die", that is, it will be removed. The culling is done in the timer event.

def timerEvent(self, event): if not Running:

items = self.scene.items() if len(items) == 0: self.populate() return heads = set() for item in items:

if isinstance(item, Head): heads.add(item) if item.color.red() == 255: dead.add(item) if len(heads) == 1:

dead = heads del heads while dead:

item = dead.pop() self.scene.removeItem(item) del item

At every time interval the form's timerEvent() method is called. If the Running Boolean is False, the method does nothing and returns immediately. If there are no items in the scene (they all died), we call populate() and begin a fresh run. Otherwise we iterate over all the items in the scene, populating two sets: one the set of head items that have a red color component with value 255, and the other with the set of all head items in the scene.

If there is just one head item, we overwrite the dead set with the heads set containing the one remaining head. This ensures that if there is just one multi-pede left, it will be killed off. We then delete the heads set so that there are no references that could keep items alive. Finally, we iterate over the dead items, removing each one from the scene at random and, since ownership passes to us, deleting each one that we remove. Thanks to the parent-child relationships, when we delete a multipede's head, the head's child (the first segment) is deleted, and in turn the first segment's child (the second segment) is deleted, and so on, to the greatest grandchild so that simply by deleting a multipede's head, we delete all the segments too.

We have now seen how the application works, so we can turn our attention to the implementation of the multipedes themselves. As the population() method shows, multipedes are made up of one Head and at least four Segments—both of these classes are QGraphicsItem subclasses, and both are smart enough to draw only the amount of detail that makes sense for the current zoom level. We will look at the Head first, and then at the Segment.

class Head(QGraphicsItem):

self.color = color self.angle = angle self.setPos(position) self.timer = QTimer()

QObject.connect(self.timer, SIGNAL("timeout()"), self.timeout) self.timer.start(INTERVAL)

All heads have the same shape: an ellipse that fits inside the static Rect rectangle. When the head is initialized we record its color and angle in instance variables and move it to the given position in the scene.

The QGraphicsItem class is not a QObject subclass and does not provide built-in timers. This is not a problem since we can simply use a QTimer as we have done here.* A QTimer's timeouts do not result in timer events, but instead are signified by timeout() signals being emitted. Here we create a timer which will time out every INTERVAL (200) milliseconds, that is, five times per second. We have connected the timer's timeout() signal to our own timeout() method; we will review this method shortly.

def boundingRect(self): return Head.Rect

The bounding rectangle is easy—it is simply the static Rect rectangle that serves as the basic shape for all multipede heads.

def shape(self):

path = QPainterPath() path.addEllipse(Head.Rect) return path

*C++/Qt programmers might be tempted to multiply-inherit from QGraphicsItem and QObject, but PyQt permits inheritance only from a single Qt class.

This method is the default one used for collision detection, unless we specify a coarser-grained approach that uses just the bounding rectangle. A painter path is a series of rectangles, ellipses, arcs, and other shapes (including painter paths) that together completely describe an item's shape. In this case, the path is just one ellipse.

Using a painter path for a graphics item's shape ensures that collisions are detected accurately. For example, two multipede heads may cross at the corners of their rectangles without colliding, since their ellipses don't occupy the corners.

def paint(self, painter, option, widget=None): painter.setPen(Qt.NoPen) painter.setBrush(QBrush(self.color)) painter.drawEllipse(Head.Rect) if option.levelOfDetail > 0.5:

painter.setBrush(QBrush(Qt.yellow)) # Outer eyes painter.drawEllipse(-12, -19, 8, 8) painter.drawEllipse(-12, 11, 8, 8) if option.levelOfDetail >0.9:

painter.setBrush(QBrush(Qt.darkBlue)) # Inner eyes painter.drawEllipse(-12, -19, 4, 4) painter.drawEllipse(-12, 11, 4, 4) if option.levelOfDetail > 1.3:

painter.setBrush(QBrush(Qt.white)) # Nostrils painter.drawEllipse(-27, -5, 2, 2) painter.drawEllipse(-27, 3, 2, 2)

The head in full detail is an ellipse, two eyes, each of which is two ellipses, one inside the other, and two tiny nostrils, again ellipses. The paint() method begins by getting rid of the pen and by setting a solid brush to the multipede's color. Then the basic head shape is drawn.

The option variable is of type QStyleOptionGraphicsItem, and it holds various useful information, including the item's transformation matrix, font metrics, palette, and state (selected, "on", "off", and many others). It also holds the "level of detail", a measure of how zoomed in or out the scene is. If the scene is not zoomed at all, the level of detail is 1.0; if it is zoomed in to be twice the size, the level of detail will be 2.0, and if it is zoomed out to half the size, the level of detail will be 0.5.

If the scene is being shown at 50% of its natural size or larger, we draw the mul-tipede's yellow outer eyes. We can hard-code the coordinates because graphics items use their own local logical coordinate system and any externally applied transformations are taken care of automatically for us. If the scene is being show at 90% of its natural size or larger, we also draw the inner eyes, and if the scene is zoomed in enough to be viewed at 130% or larger, we also draw the multipedes' tiny nostrils.

The last method we must consider is the timeout() method that is called every INTERVAL milliseconds by the timer. We will look at the method in two parts, since there are two aspects to what it does.

def timeout(self): if not Running:

return angle = self.angle while True:

angle += random.randint(-9, 9) offset = random.randint(3, 15)

x = self.x() + (offset * math.sin(math.radians(angle))) y = self.y() + (offset * math.cos(math.radians(angle))) if 0 <= x <= SCENESIZE and 0 <= y <= SCENESIZE: break self.angle = angle self.rotate(random.randint(-5, 5)) self.setPos(QPointF(x, y))

If the global Running Boolean is False, we do nothing and return. Otherwise, we calculate a new position for the head based on a small random change to its angle of direction (±9°), and a small movement (3-15 logical units). To avoid the multipede wandering out of the scene, we keep moving and turning it until its new (x, y) position is within the scene's boundaries.

Once we have the new coordinates, we record the angle that was used, rotate the head slightly, and set the head's position. At this point, collisions may have occurred as a result of the movement.

for item in self.scene().collidingItems(self): if isinstance(item, Head):

self.color.setRed(min(255, self.color.red() + 1)) else:

item.color.setBlue(min(255, item.color.blue() + 1))

We ask the scene for all the items that the head has collided with. If it has hit another head, we make this head a bit redder, and if it has hit a segment, we make the segment it has hit a bit bluer. If a head's red color component reaches 255, the head (and therefore the entire multipede, including all the segments) will be removed from the scene. The removals take place in the form's timer event, as we saw earlier (page 372).

Now we will look at the Segment implementation. Its initializer is a bit longer than the Head's initializer, but the boundingRect(), shape(), and paint() methods are much simpler as a result.

class Segment(QGraphicsItem):

super(Segment, self)._init_(parent)

self.color = color self.rect = QRectF(offset, -20, 30, 40) self.path = QPainterPath() self.path.addEllipse(self.rect) x = offset + 15 y = -20

self.path.addPolygon(QPolygonF([QPointF(x, y),

QPointF(x - 5, y - 12), QPointF(x - 5, y)])) self.path.closeSubpath() y = 20

self.path.addPolygon(QPolygonF([QPointF(x, y),

QPointF(x - 5, y + 12), QPointF(x - 5, y)])) self.path.closeSubpath() self.change = 1 self.angle = 0 self.timer = QTimer()

QObject.connect(self.timer, SIGNAL("timeout()"), self.timeout) self.timer.start(INTERVAL)

The first thing to notice is that we accept a parent parameter and pass it on to the base class. We did not do this for the Head because when an item is added to a scene, the scene automatically takes ownership of the item, so there was no need. But segments are not explicitly added to the scene since they are all children of other items. The first segment's parent is the multipede's head, the second segment's parent is the first segment, the third segment's parent is the second segment, and so on. When the head is added to the scene the segments are added too; but the scene takes ownership of only the head. Although we could have given the segments no parent and added them directly to the scene, it is much more convenient to make them child items. In particular, the parent-child relationship between graphics items is used to propagate transformations from parent to child.

The offset is an x offset relative to the head, no matter which segment we are initializing. The rectangle is used to draw the segment's ellipse, but unlike the head, it does not encompass the entire shape because segments have protruding legs. Because the segment's shape isn't simple, we create it using a painter path. We begin with the segment's "body", a simple ellipse. Then we draw one leg (a very flat triangle), and then the other leg. The addPolygon() method takes a QPolygonF(), which itself is constructed with a list of QPointF objects. After each leg is added, we call closeSubpath(); alternatively, we could simply have added an extra point at the end, a copy of the first point. The change and angle instance variables are used for movement; we will cover them in the time-out() event.

def boundingRect(self):

return self.path.boundingRect()

The bounding rectangle must account for the entire shape, including the legs, but is it easy to obtain using QPainterPath.boundingRect().

def shape(self): return self.path

The shape isn't straightforward, but thanks to the path being calculated in the initializer, this method is simple.

def paint(self, painter, option, widget=None): painter.setPen(Qt.NoPen) painter.setBrush(QBrush(self.color)) if option.levelOfDetail <0.9:

painter.drawEllipse(self.rect) else:

painter.drawPath(self.path)

Thanks to precalculating the rectangle and painter path, the paint() method is much easier and faster than it would otherwise have been. If the scene is zoomed in to 90% or less, we just draw an ellipse; otherwise, we draw the shape in full detail using the painter path.

def timeout(self): if not Running:

return matrix = self.matrix() matrix.reset() self.setMatrix(matrix) self.angle += self.change if self.angle > 5: self.change = -1 self.angle -= 1 elif self.angle < -5: self.change = 1 self.angle += 1 self.rotate(self.angle)

When a multipede's head moves, its first (child) segment moves with it, and that segment's child segment moves with it, and so on. This is fine, but it means that the multipede's shape is rigid. We want the segments to gently sway from side to side as the multipede moves, and for this reason we have given the segments their own timers.

We retrieve the segment's transformation matrix, clear any transformations (rotations) that have been applied, and then rotate the segment. The change variable starts out as 1 and the rotation angle starts out at 0°. At every time interval, the change is added to the angle. If the angle reaches 6 (or -6), we make it 5 (or -5) and negate the change value. This means that the angle has the sequence 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, -1, -2, -3, -4, -5, -4, -3, -2, -1, 0, 1, 2, and so on, which produces a nice swaying effect.

This completes our review of animating complex shapes. Using painter paths, shapes of arbitrary complexity can be created, and by storing the paths as static or as instance variables, a lot of calculation can be done one-off rather than in every call to a paint method. The approach we have used to achieve animation is not the only one possible. For example, we could use QGraphics-ItemAnimation items in conjunction with a QTimeLine. Another approach would be to take the timers out of the items themselves and instead keep a set of references to them. Then, a timer in the form could be used, and at each interval each item in the set could be moved and collisions could be resolved from the form's timeout handler. There is no one and only right approach; rather, the best approach will depend on the needs of the application itself.

Was this article helpful?

+1 0
SEO Made Easy Mind Map

SEO Made Easy Mind Map

This is a quick start guide to skyrocketing your offline and online business with search engines.

Get My Free Ebook


Responses

  • isengrin
    How to design a range slider in pyqt?
    8 years ago

Post a comment