« Using Transformations

Posted by Brad Miller on January 30, 2002 [Feedback (0) & TrackBack (0)]

// Introduction

In this tutorial we'll take a look at geometrical transformations. We will discuss how rotation, scaling, and translation work and how to easily implement them using the NSAffineTransform class. To illustrate the concepts, we'll create a simple application with controls to adjust each of the transformation values. We can then observe how the drawing changes as the transform values change.

We'll also look at the rotation slider that is part of Jiiva's Application Builder Collection (ABC) framework. It will be used to control the amount we rotate our image. (Note: Update to ABC version 1.1 if you haven't yet. This tutorial will not run correctly with version 1.0)

new cocoa app
Figure 1: Final application.

 

// Create the Project

We'll start by making a Cocoa Application project called Rotate. Next we'll add the files for a class called Controller that is a subclass of NSObject and for a class called RotateView that is a subclass of NSView. The following outlets and actions need to be added to Controller.

IBOutlet NSTextField *rotateField;
IBOutlet ABCRotation *rotateSlider;
IBOutlet NSTextField *scaleField;
IBOutlet NSSlider *scaleSlider;
IBOutlet NSTextField *xField;
IBOutlet NSSlider *xSlider;
IBOutlet NSTextField *yField;
IBOutlet NSSlider *ySlider;
IBOutlet RotateView *view;

- (IBAction)setRotation:(id)sender;
- (IBAction)setScale:(id)sender;
- (IBAction)setXPosition:(id)sender;
- (IBAction)setYPosition:(id)sender;
- (IBAction)setOffset:(id)sender;

 

// Interface

Let's build our interface now. Double click on MainMenu.nib to open it in Interface Builder. Once it's open, the first thing we need to do is import the Controller and RotateView classes. The easiest way to do it is to drag the header files from Project Builder onto the MainMenu.nib window in Interface Builder. Next we need to create an instance of Controller. Select Controller in the Classes pane and choose Instantiate (Classes ->).

Now we'll create the interface that is shown in Figure 2.

interface
Figure 2: The interface layout.

When adding the controls, we need to set some of their attributes. The custom view's size should be set to be 400 by 400 and set its class to be the RotateView class. Both the X and Y sliders' minimum need to be set to 0 and their maximum set to 400 to match the dimensions of the custom view. The scale slider's minimum should be set to 1 and its maximum to 300. If alternate sizes fit your monitor better, feel free to use them. Just make sure you set the sliders' maximum to the same values of your view's size.

 

// The ABCs of Rotation

We have one more control to add, an ABCRotation slider. ABCRotation is a slider control that's specifically designed for rotation in degrees. We will be using one to control the position we rotate our object to.

We'll add an ABCRotation to the Rotation box as shown in Figure 3.

final interface
Figure 3: The final interface layout.

Now that we have the ABCRotation in place, we need to set a couple of its attributes. The first one we'll set is the zero attribute. The path that we will be drawing is arrowish in shape. So, we want to set the zero to have a value of 90 to match the direction the drawing is pointing. Setting the zero position to 90 changes the slider's zero point to be at the top of the circle instead of at the default position of the right side.

The other attribute we want to set is the snap values. For our app, we'll use the 0, 90, 180, 270 option and leave the tolerance at the default position. The snap value feature makes it easier for the user to move the slider to the set values. When the slider moves into the tolerance range, about 5 degrees for our setting, the rotation point jumps to that value.

 

// Outlets and Actions

Once the window is laid out, we need to make all of the connections. Connect all of the outlets by control-dragging from the Controller instance to each of the controls and then selecting the outlet that matches the control. Next create a connection from each of the controls to the Controller and select the action that matches the control. Each action should be connected to both a slider and a text field when completed.

Now we need to fill in the Controller class's code. For each of the actions and the awakeFromNib method, enter the code that appears below. The actions set their corresponding values in view object and then set the sliders and text fields to match each other. The awakeFromNib method sets initial values of all the sliders and text fields.

- (void)setFields
{
    [scaleField setFloatValue:[view scale]];
    [scaleSlider setFloatValue:[view scale]];

    [rotateField setFloatValue:[view rotate]];
    [rotateSlider setRotationDegree:[view rotate]];

    [xField setFloatValue:[view position].x];
    [xSlider setFloatValue:[view position].x];

    [yField setFloatValue:[view position].y];
    [ySlider setFloatValue:[view position].y];
}

- (void)awakeFromNib
{
    [self setFields];
}

- (IBAction)setRotation:(id)sender
{
    [view setRotate:[sender floatValue]];
    [self setFields];
    [view setNeedsDisplay:YES];
}

- (IBAction)setScale:(id)sender
{
    [view setScale:[sender floatValue]];
    [self setFields];
    [view setNeedsDisplay:YES];
}

- (IBAction)setXPosition:(id)sender
{
    NSPoint p = NSMakePoint([sender floatValue], [view position].y);
        
    [view setPosition:p];
    [self setFields];
    [view setNeedsDisplay:YES];
}

- (IBAction)setYPosition:(id)sender
{
    NSPoint p = NSMakePoint([view position].x, [sender floatValue]);

    [view setPosition:p];
    [self setFields];
    [view setNeedsDisplay:YES];
}

- (IBAction)setOffset:(id)sender
{
    if ([view offset])
    {
        [view setOffset:NO];
    }
    else
    {
        [view setOffset:YES];
    }

    [view setNeedsDisplay:YES];
}

 

// Transformers, More than Meets the Eye

Now that we have the interface we need built, we can turn our focus to the transformations. We will be performing all of our transforms using the NSAffineTransform class. But before we get into how to use NSAffineTransform, let's discuss what the transforms are and their mechanics.

Transformations are calculations that are used to change the position, orientation, and size of the objects being drawn. They are generally represented as matrices using homogenous coordinates. Affine transformations are a specific type of transformation that have the property of preserving the parallelism of lines, but not their lengths and angles.

The first type of transformation we'll look at are translations. A translation simply moves an object by adding an offset to the X and the Y coordinates as shown in Figure 4.

translation
Figure 4: Translation by (3, 2).

The second type of transformation is scaling. Scaling changes the size of an object by multiplying the X and Y coordinates by the scale value. A scaling example is shown in Figure 5.

scaling
Figure 5: Scaling by Sx=2 Sy=2.

The last type of transformation we're going to look at are rotations. Points are rotated about the origin by the given angle as shown in Figure 6. Positive angles are measured counterclockwise from x toward y. Figure 7 shows the effect of rotating an object.

rotation around the origin
Figure 6: Rotation around the origin.

object rotation
Figure 7: Rotation of an object by 45 degrees.

All three of the transforms are performed by performing the matrix multiplications that are shown in figure 8.

transformation matrices
Figure 8: Formulas for translation, scaling, and rotation.

Looking at the formulas, the use of homogenous coordinates in a 3x3 matrix to represent a translation makes sense since a translation off-sets a point by adding a vector to it, but why do we use them for scaling and rotation? For both types of operations the 3x3 matrix is not needed, the same results can be achieved by using a 2x2 matrix. The reason we use them is to make life simpler through composition. Composition is the combining of multiple transformations into a single transformation. For example, say you want to rotate and then resize an object. You can do it the long way by first rotating each point in the object and then scaling each point. Two transformations are done on each point this way. With composition, we first multiply the two transformation matrices together and then perform only one transformation on each point in the object. This is easier to implement and in this example only takes half the number of operations to get the same results.

You might be thinking at this point that composition isn't that important since you don't have a need to do a bunch of transformations at once. After all, you only want to do a rotation every once in a while. Sounds reasonable, but rotation and scaling have a property that we haven't discussed yet, both occur about the origin. That means that if you have an object at some arbitrary location and want to spin it, you first have to translate it so that its centroid is located at the origin, rotate it, and then translate it back. If the translations were not performed, the object would move along an arc which is centered at the origin.

If an object is not at the origin when performing a scaling, the object will change its locations in addition to changing its size as shown in Figure 9.

scale not at the origin
Figure 9: Scaling an object not at the origin.

One other item to keep in mind with composition is that matrix multiplication is not commutative. When creating the composition matrix, you have to add the transformations in the order that you want them to occur. For the rotation example above that worked by performing a translation, rotation, translation, if we change the ordering to translation, translation, rotation, it would have the same effect as doing only the rotation with none of the translations.

 

// Making the Transforms

Now that we have an idea of the mechanics behind transformations, let's put them to use. We'll implement them using the NSAffineTransform class. It makes creating transformations simple. For the most part, all you have to do is tell it what type of transform to make and the value of the transform. There are no matrices to calculate. Composition still matters though, so you still have to add the transformations in the order that they are to occur.

The header for the the RotateView class needs to have the following variables and method prototypes added to it:

@interface RotateView : NSView
{
    NSBezierPath *path;
    float scale;
    float rotate;
    NSPoint position;
    BOOL offset;
}

- (float)scale;
- (void)setScale:(float)value;

- (float)rotate;
- (void)setRotate:(float)value;

- (NSPoint)position;
- (void)setPosition:(NSPoint)value;

- (BOOL)offset;
- (void)setOffset:(BOOL)value;
@end

The first items we'll set up in RotateView are the initWithFrame and dealloc methods. initWithFrame sets up the default transformation values and then creates the NSBezierPath that will be used as our drawing object. The only thing dealloc does is release the path object that is created in initWithFrame.

- (id)initWithFrame:(NSRect)frame
{
    NSPoint point;
    
    self = [super initWithFrame:frame];
    if (self)
    {
    	path = [[NSBezierPath bezierPath] retain];
    	
        [self setRotate:0.0];
        [self setScale:100.0];
        [self setPosition:NSMakePoint(200, 200)];
            
        point.x = 0;
        point.y = .9;
        [path moveToPoint:point];

        point.x = -.9;
        point.y = 0;
        [path lineToPoint:point];

        point.x = -.3;
        point.y = -.2;
        [path lineToPoint:point];

        point.x = -.7;
        point.y = -.9;
        [path lineToPoint:point];

        point.x = 0;
        point.y = -.5;
        [path lineToPoint:point];

        point.x = .7;
        point.y = -.9;
        [path lineToPoint:point];

        point.x = .3;
        point.y = -.2;
        [path lineToPoint:point];

        point.x = .9;
        point.y = 0;
        [path lineToPoint:point];

        [path closePath];
    }
    return self;
}

- (void)dealloc
{
    [path release];
    [super dealloc];
}

Nothing too exciting here. Just our accessor methods.

- (float)scale
{
    return scale;
}

- (void)setScale:(float)value
{
    scale = value;
}

- (float)rotate
{
    return rotate;
}

- (void)setRotate:(float)value
{
    rotate = value;
}

- (NSPoint)position
{
    return position;
}

- (void)setPosition:(NSPoint)value;
{
    position = value;
}

- (BOOL)offset
{
    return offset;
}

- (void)setOffset:(BOOL)value
{
    offset = value;
}

We now have the boring stuff that we had to include out of the way and can get to the fun stuff. We'll perform our transformations inside the drawRect method.

- (void)drawRect:(NSRect)rect
{
    NSBezierPath *drawPath;
    
    //Copy the path so that we can transform it without effecting the original
    NSBezierPath *rawPath = [path copy];
    
    NSAffineTransform *scaleTransform;
    NSAffineTransform *locationTransform;
    NSAffineTransform *rotateTransform;

    NSAffineTransform *transform;

We first check if the offset button is checked or not. If it is, we translate the path so that it's lower left hand point, instead of its centroid, is at the origin. This translation is made so that we can illustrate how rotation occurs about the origin.

The transform is made by making an instance of NSAffineTransform and then calling the translateXBy:yBy method with the correct offsets as parameters. Once the transformation is created, we transform the path by calling NSBezierPath's method transformUsingAffineTransform. The method transforms the path it's called on by the NSAffineTransform that is passed to it. The original path is destroyed for the transformed path which is the reason we copied our path earlier.

    if ([self offset])
    {
        NSAffineTransform *os = [NSAffineTransform transform];
        [os translateXBy:.7 yBy:.9];
        [rawPath transformUsingAffineTransform:os];
    }

We'll now create our transformations that will transform the path by the values that the user sets.

    scaleTransform = [NSAffineTransform transform];
    [scaleTransform scaleBy:[self scale]];

    rotateTransform = [NSAffineTransform transform];
    [rotateTransform rotateByDegrees:[self rotate]];

    locationTransform = [NSAffineTransform transform];
    [locationTransform translateXBy:[self position].x yBy:[self position].y];

Now that we have the three transformations created, we'll combine them into a single transformation. NSAffineTranforms are combined using the appendTransform method. It works by combining the transform that is passed in with its current transformation. Remember that the transform order matters here. So, we have to perform the translation last in order for it not to affect the scaling or rotation.

    transform = [NSAffineTransform transform];
    [transform appendTransform:scaleTransform];
    [transform appendTransform:rotateTransform];
    [transform appendTransform:locationTransform];

Our composite transform will now be used to transform our path into the path that will be drawn. To create the transformed NSBezierPath we'll look at and use the second method that will transform a path, the transformBezierPath method of NSAffineTransform. It works by passing an NSBezierPath to the method. The transformed path is then returned as a new autoreleased NSBezierPath.

    drawPath = [transform transformBezierPath:rawPath];

Finally, we'll create a white background to draw on and then draw the transformed path.

    [[NSColor whiteColor] set];
    NSRectFill([self bounds]);

    [[NSColor blueColor] set];
    [drawPath setLineWidth:2.0];
    [drawPath stroke];

    [rawPath release];
}

 

// Conclusion

We're done. Run the application and play around with the transformation values. You can watch how the object moves when the settings are changed. A good way to see how rotation occurs about the origin is to center the object in the view, then first spin it with the offset button off and then repeat with it on. By doing so, you can clearly see how the rotations differ. it's also helpful to set a break point at the beginning of the drawRect method and then walk through the code as the program executes. As each transform is created and appended, you will be able to observe how NSAffineTransform performs the matrix work for you.

As always, feel free to contact me if you have any questions or comments. You can also download a copy of my project from here.


Comments
Post a comment