Introduction

In the previous tutorial, we added the ability to apply some basic transformations to our wireframe cube, but it still looked like a square. In this tutorial, we will:

  • Add the ability to rotate wireframes about three axes

Rotations are more complex that the previous two transformations but more interesting. Translations add a constant to coordinates, while scaling multiples a constant by the coordinate, so both preserve the shape of the projection (i.e. square). Rotations on the other hand change the values of two coordinates e.g. `x` and y, by a function of both those coordinates. This means that the coordinates effectively interact, so the z-coordinate will come into play by influencing the value of the x and y coordinates. Thus we will finally see a different side of our 3D object. Hopefully this will become clear with an example.

Defining an axis

Rotations are defined by an angle and a vector through which the rotation occurs. The easiest example is to rotate the cube through an axis parallel to the z-axis. For example, as we look at our cube end on, we rotate the square we see about its centre. As we are rotating about the z-axis, only the x and y coordinates will change, so we won't see our z-coordinates just yet. So it is essentially a 2D problem.

First let's create a method for Wireframe to find its centre. The centre is just the mean of the x, y and z coordinates. In the Wireframe class add:

def findCentre(self):
    """ Find the centre of the wireframe. """

    num_nodes = len(self.nodes)
    meanX = sum([node.x for node in self.nodes]) / num_nodes
    meanY = sum([node.y for node in self.nodes]) / num_nodes
    meanZ = sum([node.z for node in self.nodes]) / num_nodes

    return (meanX, meanY, meanZ)

Converting from Cartesian to a polar coordinates

Since we are going to rotate points about an angle, it's easier to switch to using polar coordinates. This means rather than refer to a point as being x units along the screen and y units up the screen, we refer to it as being an angle and distance from the point of rotation. For example, below, we convert (x, y) to (θ, d).

Diagram of a rotating square

We can find the angle, θ (in radians) using a handy function from the math module called atan2(), which also deals with orthogonal situations. The angle measured, is between the vector and the x-axis. This isn't particularly important, so long as we're consistent when we convert back to a coordinate later.

The distance, d, in the diagram is calculated as the hypotenuse of the triangle formed by the vector:

import math
d = math.hypot(y - cy, x - cx)
theta = math.atan2(y - cy, x - cx)

Converting from polar to a Cartesian coordinates

Now we have an angle, θ, we add the angle of our rotation, then convert back to Cartesian coordinates.

The new x-coordinate is the width of the triangle (the distance between cy and y), which is, by simple trigonometry, d × cos(θ). The new y-coordinate is the height of the triangle, which is d × sin(θ).

Diagram showing a rotated square

The complete Wireframe method should look like this (remember to import math at the start of the program):

def rotateZ(self, (cx,cy,cz), radians):        
    for node in self.nodes:
        x      = node.x - cx
        y      = node.y - cy
        d      = math.hypot(y, x)
        theta  = math.atan2(y, x) + radians
        node.x = cx + d * math.cos(theta)
        node.y = cy + d * math.sin(theta)

We can now rotate the projected square about its centre with:

cube.rotateZ(cube.findCentre(), 0.1)

We still haven't yet seen into the third dimension, but by analogy to a rotation around a vector parallel to the z-axis, we can also rotate our object about vectors parallel to the x- and y-axes with the following methods:

def rotateX(self, (cx,cy,cz), radians):
    for node in self.nodes:
        y      = node.y - cy
        z      = node.z - cz
        d      = math.hypot(y, z)
        theta  = math.atan2(y, z) + radians
        node.z = cz + d * math.cos(theta)
        node.y = cy + d * math.sin(theta)

def rotateY(self, (cx,cy,cz), radians):
    for node in self.nodes:
        x      = node.x - cx
        z      = node.z - cz
        d      = math.hypot(x, z)
        theta  = math.atan2(x, z) + radians
        node.z = cz + d * math.cos(theta)
        node.x = cx + d * math.sin(theta)

Now - finally - we can see all aspects of our cube. Having three separate rotation methods is not the most efficient way to do things - in a later tutorial I show how matrices can deal with transformations more efficiently.

Key Controls

Now we have the rotation transformation functions, we can to add a rotate function to the ProjectionViewer class. The method below rotates all wireframes about their centres and a given axis. Depending on what you want, it may be more useful to rotate all objects about the origin or about the centre of the screen.

def rotateAll(self, axis, theta):
    """ Rotate all wireframe about their centre, along a given axis by a given angle. """

    rotateFunction = 'rotate' + axis

    for wireframe in self.wireframes.itervalues():
        centre = wireframe.findCentre()
        getattr(wireframe, rotateFunction)(centre, theta)

Finally we can update our key_to_function dictionary to bind these new function calls to keys. For example:

key_to_function = {
...
    pygame.K_q: (lambda x: x.rotateAll('X',  0.1)),
    pygame.K_w: (lambda x: x.rotateAll('X', -0.1)),
    pygame.K_a: (lambda x: x.rotateAll('Y',  0.1)),
    pygame.K_s: (lambda x: x.rotateAll('Y', -0.1)),
    pygame.K_z: (lambda x: x.rotateAll('Z',  0.1)),
    pygame.K_x: (lambda x: x.rotateAll('Z', -0.1))}

So at last we can rotate our cube and see it from every angle. In the next tutorial we'll switch to using matrices so we can carry out transformations faster so we can look at object more complex than a cube.