Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

UltraDynamo (Part 4) - Graphics and Fonts

0.00/5 (No votes)
14 Jan 2013 1  
In this section, we will take a look at some of the graphic and font handling routines.

Table Of Contents

Note: This is a multi-part article. The downloads are available in Part 1.

Introduction

In the application, there are various graphics (images) used to present information to the user. For instance, there are:

  • Compass Rose
  • Car Side Elevation
  • Car Front Elevation
  • Car Plan

These images are stored within the application as resources. In the initial versions of UltraDynamo, the user is stuck with these images, but at a later date, I may allow the user to select their own images for use with the application.

The images are jpg files that I stored within an "/images" folder in the project solution. After copying the images into the solution, to make these available to the code as embedded resources and not as images that accompany the main executable as additional files, it is required to set a couple of properties for each of the files. If we look at the properties for the compass rose:

You will notice that the Build Action has been set to Embedded Resource and the Copy to Output Directory has been set to Do not Copy.

Loading the Resources

The next thing we need to be able to do is load the images into the application at runtime and make them visible to the user. To achieve this, I created a helper function that returns a stream to the caller, that contains the image resource. The function to do this is shown below:

private Stream LoadCompassImage()
{
  Assembly _assembly;
  Stream _imageStream;

  _assembly = Assembly.GetExecutingAssembly();

  _imageStream = _assembly.GetManifestResourceStream("UltraDynamo.Images.compass_200x200.jpg");

  return _imageStream;
}

As you can see in the code, we first get a reference to the running assembly and then set the path to the internal image as string.

Rotating the Image

The compass is not much use unless it is rotated relevant to compass sensor current heading. A compass typically always leaves the users heading at the top, and rotates the compass accordingly. The rotation function is shown below, we simply pass in the image to rotate, and the angle of rotation. The function returns the rotated image.

private Image getRotatedImage(Image image,float angle)
{
  //Create a new image based on the original
  Bitmap rotated = new Bitmap(image);

  //Create a graphics object to work with the image
  Graphics g = Graphics.FromImage(rotated);

  //Move the rotation point to centre by moving image
  g.TranslateTransform((float)image.Width / 2, (float)image.Height / 2);

  //rotate
  g.RotateTransform(angle);

  //undo the image for rotation point
  g.TranslateTransform(-(float)image.Width / 2, -(float)image.Height / 2);

  //draw source image on graphics object
  g.DrawImage(image, new Point(0, 0));

  //Return the rotated image
  return rotated;
}

An important fact to notice is the image rotation is normally performed on the top left of the image. We need to be able to rotate on the centre of the image. This requires for a transform to be done to move image to correct point on which to rotate, and then return it back to its original location post rotation.

The compass heading in fact needs to be a negative number, otherwise the compass rotates in the wrong direction, so we simply multiply the Heading from the sensor by -1, before passing into the rotation function.

I was using a user control to host the compass. This made it easy to then drop the control onto anywhere I needed it within the application. The paint event handler is where we placed the code to actually draw the image onto the user control surface.

The code below was placed in the paint event, and you can see the image being loaded, the compass heading being read and multiplied by -1 to get the rotation direction correct, and then being rotated:

Graphics g = e.Graphics;
//Load and rotate the image
g.DrawImage(getRotatedImage(Image.FromStream(LoadCompassImage()),
(float)compassValues.Heading *-1), 0, 0, this.Width, this.Height);

The last thing I did was to then draw a line at the top of the compass as a heading indicator. The pen was defined as a private member of the control, so it did not need to be defined with each paint event, then the line was simply drawn using the pen:

//Heading Line (Approx 75% opacity Red Pen)
Pen headingPen = new Pen(Color.FromArgb(190, 255, 0, 0), 3);

//Draw Heading Line NOTE: the -1 is to take into account the pen thickness of 3 to ensure line central
g.DrawLine(headingPen, new Point((this.Width / 2) -1, this.Height / 2), 
new Point((this.Width/2)-1, (this.Height - (int)(this.Height * 0.92))));

Below, you can see what the rotated compass rose with heading line looks like once loaded into a standalone form window;

The Inclinometer Views

The three car images were used to show the Roll, Pitch and Yaw of the vehicle. These images were manipulated in much the same way as the compass rose.

The main difference however with these views were the addition of the angle being displayed and also reference lines for the base line and the angle of offset lines.

The Inclinometer views also employed a small improvement / optimisation over the compass view (which I will endeavour to update in the future), namely the base image was only loaded from the assembly when the usercontrol was first instantiated. It is not repeatedly loaded with each paint event. The image is simply stored as a private member variable and the image loaded on construction of the user control.

The Trigonometry

When creating the lines, the hardest thing to do was calculate where the starting and ending points of the rotate lines would fall in relation to the X/Y co-ordinates for the points. And this is where the "haven't used or needed for years" trigonometry came into play. Yes, Sohcahtoa came back to haunt me!

Now, I don't know about the rest of you, but for me, I have had absolutely no use for this since it was taught to me at school, then probably refreshed again during my apprenticeship. For me, I needed to head of to Google and find some refreshers. Here is one such page you can refer [^].

Basically, we know the rotation angle, and we know the width (and height) of the display surface we are drawing in. Using these as parameters into the trig, we can calculate the other line lengths and thus give us the (x,y) co-ordinates for the points.

If we take a look at the Inclinometer Pitch, we first draw the horizontal reference line (Pens.Blue) and then use trigonometry to calculate the rotated lines (Pens.Red) (x,y) coordinates.

//Pitch or Roll - Horizontal
g.DrawLine(Pens.Blue, new Point(0, this.Height / 2), new Point(this.Width, this.Height / 2));
if (this.InclinometerViewState == InclinometerViewOptions.Pitch)
{
  try
  {
    g.DrawLine(Pens.Red,
    new Point(0,(int)((this.Height / 2) - ((this.Width / 2) * 
            Math.Tan(((double)inclinometerValues.Pitch * Math.PI) / 180)))),
    new Point(this.Width,(int)((this.Height / 2) + ((this.Width / 2) * 
            Math.Tan(((double)inclinometerValues.Pitch * Math.PI) / 180)))));
  }
  catch (Exception)
  {
    //TODO: Pitch Overflow improvement
    //This is just a dirty catch for a Pitch of 90' 
    //which causes an overflow exception. - Need to find improved approach later
    g.DrawLine(Pens.Red, new Point(this.Width/2, 0), new Point(this.Width/2, this.Height));
  }
}

You will also notice that there is a dirty hack in there to catch the overflow that occurs if the angle hits 90degrees, and we simply draw a straight line where we know it should be. This is another area that I need to look into in more detail in the future.

Drawing the Angle String

I also write the angle out to the top centre of the inclinometer views. We must calculate the size of this string to allow us to calculate where the string will be placed on the form.

if (this.ShowAngleValue)
{
  Font f = this.Font;
  f = new Font(f.FontFamily, 18, FontStyle.Bold);
  SizeF testSize = g.MeasureString(angle, f);

  g.DrawString(angle, f, Brushes.Green, new Point((this.Width - (int)testSize.Width) / 2, 0));
}

Once thing you will note is that this is placed within an if{}, I have added a number of private variables so that in future, I can change the application so the user has more configuration options for the displays. At present, these are all fixed.

With the image rotation, the angle text and the reference lines all added to the inclinometer views, the images below show what these look like:

Fitting Font To Space

One interesting aspect of fonts came to light during development of the Compass Heading display. The Compass Heading display, simply displays either N, NE, E, SE, S, SW, W, NW to the user, to give a general idea of direction.

I turns out you cannot simply provide a given rectangle to the graphics objects and say 'fill this with the biggest text allowed for the string and the rectangle size', but you have to physically iterate through the fonts, increasing them until the size of the output exceeds the available space and then go back a size.

If you take a look at the following code, you can see this in action:

private void UICompassHeadingLetters_Paint(object sender, PaintEventArgs e)
        {
            String output = "N"; //Default to North removes need for two ifs below
            //Get the string representation of the heading
            double heading = compassValues.Heading;

            if (heading >= 22.5 && heading < 67.5)
                output = "NE";

            if (heading >= 67.5 && heading < 112.5)
                output = "E";

            if (heading >= 112.5 && heading < 157.5)
                output = "SE";

            if (heading >= 157.5 && heading < 202.5)
                output = "S";

            if (heading >= 202.5 && heading < 247.5)
                output = "SW";

            if (heading >= 247.5 && heading < 292.5)
                output = "W";

            if (heading >= 292.5 && heading < 337.5)
                output = "NW";

            //Establish base fonts and graphics objects
            Graphics g = e.Graphics;

            Font f = this.Font;
            Font newFont = this.Font;
            SizeF testSize;
            float fHeight=0;
            float fWidth=0;
            bool fontGood = true;
            
            for (int newSize = 1; fontGood; newSize++)
            {
                //Increase the size and test height again)
                newFont=new Font(f.Name,newSize,f.Style);
                testSize = g.MeasureString(output,newFont);
            
                //Determine boundary size for the text
                fHeight = testSize.Height;
                fWidth = testSize.Width;

                //Test the text height and width against control size
                if ((fHeight > (this.Height)) | (fWidth > (this.Width)))
                {
                    fontGood = false;
                    newSize--;  //go back by 1 size, 
                                //recalculate size required for positioning central on control
                    if (newSize <6 ) { newSize=6;};
                    newFont = new Font(f.Name,newSize,f.Style);
                    testSize = g.MeasureString(output,newFont);
                    fHeight = testSize.Height;
                    fWidth = testSize.Width;
                }
            }            

            //Draw the text heading on the control
            g.DrawString(output, newFont, Brushes.Green, 
                 new Point((this.Width - (int)fWidth) / 2, (this.Height - (int)fHeight) / 2));

First, we read the compass sensor for the heading value, and translate this into the relevant string representation, e.g. 'NW'. Then we grab the grabs object, and pick a small font and calculate the size of the space it occupies. If it is smaller than the available space, we try the next size. If it exceeds the space available, we know the previous size was the correct maximum size, so draw it again using that size and draw it to the graphics surface.

There has to be a more efficient way of doing this, but something for another day! You can see the Compass Heading forms automatically scale the text to fit the drawing surface as the forms resizes:

Double Buffering

This is probably the single most important thing you must not forget to enable. In the very first version I forgot, and this resulted in 'flickering' displays. So, whether it is a Windows Form or a UserControl, don't forget to set DoubleBuffered property to True.

You can read exactly what this does here.

Suspend Your Layouts

Another thing that you should do is suspend layouts during paint events. This basically stops lots of screen flickering, etc. and all the various aspects of the changes are drawn to the surface of the form or control. You suspend the layout at the start of a paint event, and then resume them again immediately before leaving the paint event, for example:

private void UICompassFull_Paint(object sender, PaintEventArgs e)
{
  //Suspend 
  this.SuspendLayout();
  
  //Do all your painting here
  
  //Resume
  this.ResumeLayout();
}

You can read more on this here.

On to the Next Part

Part 5 is all about the building, packing and code signing aspects of deployment.

Part 5 - Building, Code Signing and Packaging

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here