animationrotation

A pyCairo/PyGTK animation framework

There are two demos now, the first shows (gasp) a square rotating around an arbitrary point. Scroll down for the second one which is longer and a bit more interesting.

This demo is two things: First it's a nice little framework that gives "life" to a function so that animation becomes possible. If you want stuff to be drawn again and again with little changes, this is a good start. (I'd love to see other solutions). Secondly it shows how to rotate a "shape" (set of cairo commands) around any given ( x, y ) point.

I tried to comment it thoroughly, so it will 'splain itself.

##    cairo demos Copyright  (C)  2007 Donn.C.Ingle
##
##    Contact: donn.ingle@gmail.com - I hope this email lasts.
##
##    This program is free software; you can redistribute it and/or modify
##    it under the terms of the GNU General Public License as published by
##    the Free Software Foundation; either version 2 of the License, or
##     ( at your option )  any later version.
##
##    This program is distributed in the hope that it will be useful,
##    but WITHOUT ANY WARRANTY; without even the implied warranty of
##    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##    GNU General Public License for more details.
##
##    You should have received a copy of the GNU General Public License
##    along with this program; if not, write to the Free Software
##    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import pygtk
import gtk, gobject, cairo
from gtk import gdk

class Screen( gtk.DrawingArea ):
    """ This class is a Drawing Area"""
    def __init__(self):
        super(Screen,self).__init__()
        ## Old fashioned way to connect expose. I don't savvy the gobject stuff.
        self.connect ( "expose_event", self.do_expose_event )
        ## This is what gives the animation life!
        gobject.timeout_add( 50, self.tick ) # Go call tick every 50 whatsits.

    def tick ( self ):
        ## This invalidates the screen, causing the expose event to fire.
        self.alloc = self.get_allocation ( )
        rect = gtk.gdk.Rectangle ( self.alloc.x, self.alloc.y, self.alloc.width, self.alloc.height )
        self.window.invalidate_rect ( rect, True )        
        return True # Causes timeout to tick again.

    ## When expose event fires, this is run
    def do_expose_event( self, widget, event ):
        self.cr = self.window.cairo_create( )
        ## Call our draw function to do stuff.
        self.draw( *self.window.get_size( ) )

class MyStuff ( Screen ):
    """This class is also a Drawing Area, coming from Screen."""
    def __init__ ( self ):
        Screen.__init__( self )
        ## x,y is where I'm at
        self.x, self.y = 25, -25
        ## rx,ry is point of rotation
        self.rx, self.ry = -10, -25
        ## rot is angle counter
        self.rot = 0
        ## sx,sy is to mess with scale
        self.sx, self.sy = 1, 1

    def draw( self, width, height ):
        ## A shortcut
        cr = self.cr

        ## First, let's shift 0,0 to be in the center of page
        ## This means:
        ##  -y | -y
        ##  -x | +x
        ## ----0------
        ##  -x | +x
        ##  +y | +y

        matrix = cairo.Matrix ( 1, 0, 0, 1, width/2, height/2 )
        cr.transform ( matrix ) # Make it so...

        ## Now save that situation so that we can mess with it.
        ## This preserves the last context ( the one at 0,0)
        ## and let's us do new stuff.
        cr.save ( )

        ## Now attempt to rotate something around a point
        ## Use a matrix to change the shape's position and rotation.

        ## First, make a matrix. Don't look at me, I only use this stuff :)
        ThingMatrix = cairo.Matrix ( 1, 0, 0, 1, 0, 0 )

        ## Next, move the drawing to it's x,y
        cairo.Matrix.translate ( ThingMatrix, self.x, self.y )
        cr.transform ( ThingMatrix ) # Changes the context to reflect that

        ## Now, change the matrix again to:
        cairo.Matrix.translate( ThingMatrix, self.rx, self.ry ) # move it all to point of rotation
        cairo.Matrix.rotate( ThingMatrix, self.rot ) # Do the rotation
        cairo.Matrix.translate( ThingMatrix, -self.rx, -self.ry ) # move it back again
        cairo.Matrix.scale( ThingMatrix, self.sx, self.sy ) # Now scale it all
        cr.transform ( ThingMatrix ) # and commit it to the context

        ## Now, whatever is draw is "under the influence" of the 
        ## context and all that matrix magix we just did.
        self.drawCairoStuff ( cr )

        ## Let's inc the angle a little
        self.rot += 0.1

        ## Now mess with scale too
        self.sx += 0 # Change to 0 to see if rotation is working...
        if self.sx > 4: self.sx=0.5
        self.sy = self.sx

        ## We restore to a clean context, to undo all that hocus-pocus
        cr.restore ( )

        ## Let's draw a crosshair so we can identify 0,0
        ## Drawn last to be above the red square.
        self.drawcross ( cr )         

    def drawCairoStuff ( self, cr ):
        ## Thrillingly, we draw a red rectangle.
        ## It's drawn such that 0,0 is in it's center.
        cr.rectangle( -25, -25, 50, 50 )
        cr.set_source_rgb( 1, 0, 0) 
        cr.fill( )
        ## Now a visual indicator of the point of rotation
        ## I have no idea (yet) how to keep this as a 
        ## tiny dot when the entire thing scales.
        cr.set_source_rgb( 1, 1, 1 )
        cr.move_to( self.rx, self.ry )
        cr.line_to ( self.rx+1, self.ry+1 )
        cr.stroke( )

    def drawcross ( self, ctx ):
        ## Also drawn around 0,0 in the center
        ctx.set_source_rgb ( 0, 0, 0 )
        ctx.move_to ( 0,10 )
        ctx.line_to ( 0, -10 )
        ctx.move_to ( -10, 0 )
        ctx.line_to ( 10, 0 )
        ctx.stroke ( )


def run( Widget ):
    window = gtk.Window( )
    window.connect( "delete-event", gtk.main_quit )
    window.set_size_request ( 400, 400 )
    widget = Widget( )
    widget.show( )
    window.add( widget )
    window.present( )
    gtk.main( )

run( MyStuff )

Here's a longer version with many things moving

I spent some time (and got some help from the list - thanks) working on a better version to demonstrate how to move many things around. This one also shows how you can use context.save() to create what I call "bubbles" of private space on the screen where different rules can apply for a short time. By putting bubbles within other bubbles you can create parent->child relationships. The sample has a red square that is a parent to a green one. What the red does, the green does too.

This also shows primitive mouse hit detection (prints to the console, so hold onto your seats because the budget was just blown baby!) on the green square.

I hope this help someone out there.

##    cairo demos Copyright  (C)  2007 Donn.C.Ingle
##
##    Contact: donn.ingle@gmail.com - I hope this email lasts.
##
##    This program is free software; you can redistribute it and/or modify
##    it under the terms of the GNU General Public License as published by
##    the Free Software Foundation; either version 2 of the License, or
##     ( at your option )  any later version.
##
##    This program is distributed in the hope that it will be useful,
##    but WITHOUT ANY WARRANTY; without even the implied warranty of
##    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##    GNU General Public License for more details.
##
##    You should have received a copy of the GNU General Public License
##    along with this program; if not, write to the Free Software
##    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import pygtk
import gtk, gobject, cairo
from gtk import gdk

class Screen( gtk.DrawingArea ):
    """ This class is a Drawing Area"""
    def __init__( self, w, h, speed ):
        super( Screen, self ).__init__( )
        ## Old fashioned way to connect expose. I don't savvy the gobject stuff.
        self.connect ( "expose_event", self.do_expose_event )
        ## We want to know where the mouse is:
        self.connect ( "motion_notify_event", self._mouseMoved )
        ## More GTK voodoo : unmask events
        self.add_events ( gdk.BUTTON_PRESS_MASK |   gdk.BUTTON_RELEASE_MASK |   gdk.POINTER_MOTION_MASK )        
        ## This is what gives the animation life!
        gobject.timeout_add( speed, self.tick ) # Go call tick every 'speed' whatsits.
        self.width, self.height = w, h
        self.set_size_request ( w, h )
        self.x, self.y = 11110,11111110 #unlikely first coord to prevent false hits.

    def tick ( self ):
        """This invalidates the screen, causing the expose event to fire."""
        self.alloc = self.get_allocation ( )
        rect = gtk.gdk.Rectangle ( self.alloc.x, self.alloc.y, self.alloc.width, self.alloc.height )
        self.window.invalidate_rect ( rect, True )        
        return True # Causes timeout to tick again.

    ## When expose event fires, this is run
    def do_expose_event( self, widget, event ):
        self.cr = self.window.cairo_create( )
        ## Call our draw function to do stuff.
        self.draw( )

    def _mouseMoved ( self, widget, event ):
        self.x = event.x
        self.y = event.y

class BunchOfStuff ( object ):
    """Stores a bunch of data"""
    def __init__ ( self, x=0, y=0, rx=0, ry=0, rot=0, sx=1, sy=1 ):
        self.x = x
        self.y = y
        self.rx = rx
        self.ry = ry
        self.rot = rot
        self.sx = sx
        self.sy  = sy

class MyStuff ( Screen ):
    """This class is also a Drawing Area, coming from Screen."""
    def __init__ ( self, w, h, speed):
        Screen.__init__( self, w, h, speed )

        ## Setup three sets of data for the three objects to be drawn
        self.red = BunchOfStuff ( x=50, y=-10, rx=50, ry=25 )
        self.green = BunchOfStuff ( x=-10, y=10 )
        self.blue = BunchOfStuff ( x=-70,y=30, sx=1, sy=1 )

        self.sign = +1 # to flip the blue animation's sign

    def setToCenter ( self ):
        """Shift 0,0 to be in the center of page."""
        matrix = cairo.Matrix ( 1, 0, 0, 1, self.width/2, self.height/2 )
        self.cr.transform ( matrix ) # Make it so...

    def doMatrixVoodoo ( self, bos ):
        """Do all the matrix mumbo to get stuff to the right place on the screen."""
        ThingMatrix =cairo.Matrix ( 1, 0, 0, 1, 0, 0 )
        ## Next, move the drawing to it's x,y
        cairo.Matrix.translate ( ThingMatrix, bos.x, bos.y )
        self.cr.transform ( ThingMatrix ) # Changes the context to reflect that
        ## Now, change the matrix again to:
        if bos.rx != 0 and bos.ry != 0 : # Only do this if there's a special reason
            cairo.Matrix.translate( ThingMatrix, bos.rx, bos.ry ) # move it all to point of rotation
        cairo.Matrix.rotate( ThingMatrix, bos.rot ) # Do the rotation
        if bos.rx != 0 and bos.ry != 0 :        
            cairo.Matrix.translate( ThingMatrix, -bos.rx, -bos.ry ) # move it back again
        cairo.Matrix.scale( ThingMatrix, bos.sx, bos.sy ) # Now scale it all
        self.cr.transform ( ThingMatrix ) # and commit it to the context

    def draw( self ):
        cr = self.cr # Shabby shortcut.

        #---------TOP LEVEL - THE "PAGE"
        self.cr.identity_matrix  ( ) # VITAL LINE :: I'm not sure what it's doing.
        self.setToCenter ( )

        #----------FIRST LEVEL
        cr.save ( ) # Creates a 'bubble' of private coordinates. Save # 1

        ## RED - draw the red object
        self.doMatrixVoodoo ( self.red )        
        self.drawCairoStuff ( self.red  )

        #---------- SECOND LEVEL - RELATIVE TO FIRST
        cr.save ( ) #save 2

        ## GREEN - draw the green one
        self.doMatrixVoodoo ( self.green )
        self.drawCairoStuff ( self.green, col= ( 0,1,0 )  )

        ## Demonstrate how to detect a mouse hit on this shape:
        ## Draw the hit shape :: It *should* be drawn exactly over the green rectangle.
        self.drawHitShape ( )
        cr.save ( ) # Start a bubble
        cr.identity_matrix ( ) # Reset the matrix within it.
        hit = cr.in_fill ( self.x, self.y ) # Use Cairo's built-in hit test
        cr.new_path ( ) # stops the hit shape from being drawn
        cr.restore ( ) # Close the bubble like this never happened.


        cr.restore ( ) #restore 2 :: "pop" the bubble.

        ## We are in level one's influence now

        cr.restore ( ) #restore 1
        ## Back on PAGE's influence now

        #-------- THIRD LEVEL  -- RELATIVE TO PAGE
        cr.save ( ) # Creates a 'bubble' of private coordinates.
        ## Draw the blue object
        self.doMatrixVoodoo ( self.blue ) # within the bubble, this will not effect the PAGE
        self.drawCairoStuff ( self.blue, col= ( 0,0,1 )  )
        cr.restore ( )

        ## Back on the PAGE level again.

        #indicate center
        self.drawCrosshair ( )         
        self.guageScale ( )

        ## Let's animate the red object 
        ## ( which *also* moves the green because it's a 'child' 
        ## of the red by way of being in the same "bubble" )
        self.red.rot += 0.01
        ## Now animate the blue
        self.blue.sx += self.sign *  0.1
        if  self.blue.sx < 0 or self.blue.sx > 4: 
            self.sign *= -1
        self.blue.sy = self.blue.sx

        ## Print to the console -- low-tech special effects :)
        if hit: print "HIT!", self.x, self.y

    def guageScale ( self ):
        """Draw some axis so we can see where stuff is."""
        c = self.cr
        m = 0
        for x in range ( 10,210,10 ):
            m += 1
            w = 10 + ( m % 2 * 10 )
            if x == 100: w = 50
            c.rectangle ( x,-w/2,1,w )
            c.rectangle ( -x, -w/2, 1, w )
            c.rectangle ( -w/2, x, w, 1 )
            c.rectangle ( -w/2, -x , w, 1 )
        c.set_source_rgb ( 0,0,0 )
        c.fill ( )

    def drawCairoStuff ( self, bos, col= ( 1,0,0 ) ):
        """This draws the squares we see. Pass it a BagOfStuff (bos) and a colour."""
        cr = self.cr
        ## Thrillingly, we draw a rectangle.
        ## It's drawn such that 0,0 is in it's center.
        cr.rectangle( -25, -25, 50, 50 )
        cr.set_source_rgb( col[0],col[1],col[2] ) 
        cr.fill( )
        ## Now draw an axis
        self.guageScale ( )
        ## Now a visual indicator of the point of rotation
        cr.set_source_rgb( 1,1,1 )
        cr.rectangle ( bos.rx - 2, bos.ry - 2, 4, 4 )
        cr.fill ( )

    ## Same as the rectangle we see. No fill.
    def drawHitShape ( self ):
        """Draws a shape that we'll use to test hits."""
        self.cr.rectangle( -25, -25, 50, 50 ) # Same as the shape of the squares

    def drawCrosshair ( self ):
        """Another visual aid."""
        ctx = self.cr
        ctx.set_source_rgb ( 0, 0, 0 )
        ctx.move_to ( 0,10 )
        ctx.line_to ( 0, -10 )
        ctx.move_to ( -10, 0 )
        ctx.line_to ( 10, 0 )
        ctx.stroke ( )


def run( Widget, w, h, speed ):
    window = gtk.Window( )
    window.connect( "delete-event", gtk.main_quit )
    widget = Widget( w, h, speed )
    widget.show( )
    window.add( widget )
    window.present( )
    gtk.main( )

run( MyStuff, 400, 400, speed = 20 )