0

I'm trying to do something that I thought would be pretty simple: Rotate an oval around its center. So I set up a simple program to try and do that. Eventually, what I'll need to do is click on one portion of the oval, and move the mouse in a direction that will cause the oval to rotate. However, at the moment, all I want to do is right click on a form, have an oval appear with the center where the mouse click occurred, then the rotated oval draw next. And it sort of works. I say sort of because the FIRST time I click, the oval and its rotated oval, appear in exactly the correct spot, with the center of the two ovals right where my mouse pointer is. However, if I click again somewhere else on the form, the oval acts as expected (the normal and rotated oval show up, centered on each other), but the ovals are appearing in a completely random place on the form (meaning not on the e.x and e.y co-ords)

Here are the relevant parts of the code:

//this is declared at the top of the form
        float theAngle = 0;
        Matrix result = new Matrix();
        Point center = new Point(0,0);

//this is declared in the form constructor
ovalGraphic = this.CreateGraphics();

//This is declared in the event handler for the mouseclick
           if (e.Button == MouseButtons.Right)
            {

                ovalGraphic.DrawEllipse(pen2, e.X-50, e.Y-15, 100, 30);
                xUp_lbl.Text = e.X.ToString();
                yUp_lbl.Text = e.Y.ToString();

                center.X = e.X;
                center.Y = e.Y;
                result.RotateAt(theAngle+=10, center);
                ovalGraphic.Transform = result;
                ovalGraphic.DrawEllipse(pen3, e.X - 50, e.Y - 15, 100, 30);

            }

Can anyone see any reason why the oval is appearing in a random place after the first time I click on the form and move the mouse?

Cynon
  • 117
  • 1
  • 10
  • 1
    The big mistake you're making is trying to draw in the `Click` handler. That doesn't work. Only draw in the `Paint` handler. `this.CreateGraphics()` is only useful for measuring things like screen resolution; it does not draw to screen or persist. You should only `Invalidate` in your Click handler. – Dour High Arch Dec 03 '19 at 17:32
  • Also, move the Matrix inside the `Paint` event, too. Plus, you need to dispose of the Matrix object. It's not clear how you calculate the rotation angle. Adding `10` doesn't fit your requirements. – Jimi Dec 03 '19 at 17:38
  • Well, this is really just prototype code that has to go into other code that is... a real mess. However, I'm not very good at using the windows graphics, so I have some additional questions: 1) what would I be using Invalidate for? 2) I don't have a Paint event, so there is no "handler" for that. And there isn't going to be, because the code I'm inserting into has no Paint event handler. – Cynon Dec 03 '19 at 17:45
  • Call `Invalidate()` in the MouseDown or MouseUp events. Since you have a handler for these events, you can also add a handler to the Paint event, which will provide the `e.Graphics` object you need (`PaintEventArgs e`). – Jimi Dec 03 '19 at 17:49
  • The `Paint` event is the place to do any kind of owner-drawn or custom drawing behavior. If you don't override it, any time the window region is invalidated (for example, by dragging another window across it) the form is going to re-paint itself and your oval will be overwritten. What you need to do in your Click event is change the state of the oval, and then do the drawing in the Paint event. – brnlmrry Dec 03 '19 at 17:50
  • Winforms graphics basic rule #1 : __Never use `control.CreateGraphics`!__ (if you want persistent results - You can test the persistance of your graphics by doing a Minimize/Maximize sequence..) Never try to cache a `Graphics` object! Either draw into a `Bitmap bmp` using a `Graphics g = Graphics.FromImage(bmp)` or in the `Paint` event of a control, using the `e.Graphics` parameter.. Use `Invalidate` to __trigger__ the `Paint` event – TaW Dec 03 '19 at 17:54
  • All of these are good general comments, but non explain why the second time I put my mouse down and click, the oval is drawn correctly, acts correctly, but is in a completely random place -- which is the current problem. – Cynon Dec 03 '19 at 17:57
  • 1
    @Cynon: the random location is not random. Since you cache your graphics object, its the result of applying a rotational transform multiple times. You aren't clearing your first transform, so the second transform is "stacked" with the first. – Sam Axe Dec 03 '19 at 18:17
  • @Cynon: I suppose it would me more accurate to say you are caching your transform (Matrix). – Sam Axe Dec 03 '19 at 18:19

2 Answers2

3

This is not how Windows Forms painting works. The forms decide themselves when they paint. This might happen when a form is resized or moved or when another window on top is removed.

Graphics drawn on a form a volatile, i.e. when the form redraws itself it clears is contents by filling itself with the back color. All drawings are lost at this stage and must be repainted.

You can also trigger redraw by calling Invalidate();

You need a class to store the ellipses:

public class Ellipse
{
    public Rectangle Rectangle { get; set; }
    public float Angle { get; set; }

    public PointF Center => new PointF(
        Rectangle.Left + 0.5f * Rectangle.Width,
        Rectangle.Top + 0.5f * Rectangle.Height);
}

At the top of the form declare (fields are usually preceded by an underscore):

private readonly List<Ellipse> _ellipses = new List<Ellipse>();
private float _theAngle = 0.0f;

Mouse click:

private void Form1_MouseClick(object sender, MouseEventArgs e)
{
    if (e.Button == MouseButtons.Right) {
        var ellipse = new Ellipse {
            Rectangle = new Rectangle(e.X - 50, e.Y - 15, 100, 30),
            Angle = _theAngle
        };
        _ellipses.Add(ellipse);
        _theAngle += 30;  // Just for test purpose.
        Invalidate(); // Redraw!
    }
}

Then you must override OnPaint:

protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e);
    foreach (Ellipse ellipse in _ellipses) {
        var matrix = new Matrix();
        matrix.RotateAt(ellipse.Angle, ellipse.Center);
        e.Graphics.Transform = matrix;
        e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; // Creates smooth lines.
        e.Graphics.DrawEllipse(Pens.Red, ellipse.Rectangle);
    }
}

Never create a Graphics object yourself, but use the one provided in the PaintEventArgs e.

Olivier Jacot-Descombes
  • 104,806
  • 13
  • 138
  • 188
  • 1
    This mistake gets made so often, is there a canonical “only draw in the `Paint` event” answer? – Dour High Arch Dec 03 '19 at 18:12
  • Part of the problem here, is that I'm modding existing code (that I didn't write) and in spite of the fact that there's a bunch of drawing done using a picture box, there are zero OnPaint events in the code. – Cynon Dec 03 '19 at 18:16
  • 1
    @DourHighArch, I think every possible question has been asked and answered at least on thousand times on SO. But they also have very specific requirements like (in this specific case) storing the angle, etc. – Olivier Jacot-Descombes Dec 03 '19 at 18:19
  • You don't need to add a paint event. Just override the already existing Paint event handler (`OnPaint`). If you are drawing on a control instead, either derive your own control and override `OnPaint` or use an existing one and add a paint event handler to the `Paint` event. – Olivier Jacot-Descombes Dec 03 '19 at 18:21
  • @OlivierJacot-Descombes Thanks a ton. Your code explains a lot to me. I'm not a novice C# coder, but I've never had to do anything with graphics. So this helped a ton. And let's just say folks here would have a heart attack if they saw the code that I'm trying to modify. (I'm forbidden to actually fix things in the code -- like codebehind that has 3-4k lines of code in it) – Cynon Dec 03 '19 at 18:27
  • I refactored the code a little bit by moving the calculation of the center of the ellipse to the `Ellipse` class. – Olivier Jacot-Descombes Dec 03 '19 at 18:32
  • @OlivierJacot-Descombes Cool! At the end of the day though, none of this is what I need to do. What I need to do is to mouse down on one of the long edges of the ellipse, move the mouse and have the ellipse rotate to a new position. – Cynon Dec 03 '19 at 18:36
  • @Cynon this approach - responding to an input event by modifying internal state, and triggering the UI to update based on the new state, goes by many names generally (MVC, MVVM, etc) and can be applied generally. The UI in your target application is an implementation detail unless you ask a question specifically for your application. – brnlmrry Dec 03 '19 at 19:43
1

You have two very distinct questions. One about drawing the ellipses. I answered it in my previous answer on this page. Here I want to answer how to rotate an ellipse with the mouse.

First, we must detect if the mouse hit an ellipse. Therefore, let's add this method to the Ellipse class from the other answer.

public bool IsHit(Point point)
{
    // Let's change the coordinates of the point to let the ellipse
    // appear as horizontal and as centered around the origin.
    PointF p = RotatePoint(point, Center, -Angle);
    PointF center = Center;
    p.X -= center.X;
    p.Y -= center.Y;

    // Let's make the ellipse appear as a circle seen from the point.
    p.Y *= (float)Rectangle.Width / Rectangle.Height;
    float radius = 0.5f * Rectangle.Width;

    // We hit if we are inside an ellipse larger by tolerance 
    // but not inside one smaller by tolerance.
    const float tolerance = 3.0f;
    float R = radius + tolerance;
    float r = radius - tolerance;
    float px2 = p.X * p.X;
    float py2 = p.Y * p.Y;
    return px2 + py2 <= R * R && px2 + py2 >= r * r;
}

It uses this helper method

// Adapted from this answer https://stackoverflow.com/a/13695630/880990 by Fraser.
private static PointF RotatePoint(Point pointToRotate, PointF centerPoint, double angleInDegrees)
{
    double angleInRadians = angleInDegrees * (Math.PI / 180);
    double cosTheta = Math.Cos(angleInRadians);
    double sinTheta = Math.Sin(angleInRadians);
    return new PointF {
        X =
            (float)
            (cosTheta * (pointToRotate.X - centerPoint.X) -
            sinTheta * (pointToRotate.Y - centerPoint.Y) + centerPoint.X),
        Y =
            (float)
            (sinTheta * (pointToRotate.X - centerPoint.X) +
            cosTheta * (pointToRotate.Y - centerPoint.Y) + centerPoint.Y)
    };
}

We also add this method which tells us at witch angle (with respect to the center of the ellipse) we hit the ellipse:

public double HitAngle(Point point)
{
    PointF center = Center;
    return Math.Atan2(point.Y - center.Y, point.X - center.X);
}

Now let's go back to the form. We need two more fields at the form level:

private Ellipse _hitEllipse;
private double _hitAngle;

In MouseDown we detect if the mouse touches an ellipse. If it does we initiate the rotation by setting the initial parameters in our two new fields:

private void Form1_MouseDown(object sender, MouseEventArgs e)
{
    foreach (Ellipse ellipse in _ellipses) {
        if (ellipse.IsHit(e.Location)) {
            _hitEllipse = ellipse;
            _hitAngle = ellipse.HitAngle(e.Location);
            Invalidate();
            break;
        }
    }
}

In MouseMove we perform the rotation:

private void Form1_MouseMove(object sender, MouseEventArgs e)
{
    if (_hitEllipse != null) {
        double newHitAngle = _hitEllipse.HitAngle(e.Location);
        double delta = newHitAngle - _hitAngle;
        if (Math.Abs(delta) > 0.0001) {
            _hitEllipse.Angle += (float)(delta * 180.0 / Math.PI);
            _hitAngle = newHitAngle;
            Invalidate();
        }
    }
}

And finally, in MouseUp we stop rotating:

private void Form1_MouseUp(object sender, MouseEventArgs e)
{
    _hitEllipse = null; // Stop rotating the ellipse.
    Invalidate();
}

In the existing OpPaint method we draw the ellipse in different colors, depending on whether we are rotating it or not. We add additional calls of Invalidate() in MouseDown and MouseUp to make the change immediate.

In OnPaint let's replace the line with DrawEllipse with these two lines:

Pen pen = ellipse == _hitEllipse ? Pens.Red : Pens.Blue;
e.Graphics.DrawEllipse(pen, ellipse.Rectangle);

We can diminish flickering by calling DoubleBuffered = true; in the form constructor (after InitializeComponent();).

Olivier Jacot-Descombes
  • 104,806
  • 13
  • 138
  • 188
  • 1
    This is pretty awesome stuff. I'll need to do some work to specify the region where you can click to do the rotate, and for some reason when the MouseUp event happens, a new oval is created, but I really appreciate your help and input! – Cynon Dec 04 '19 at 02:29
  • Don't forget the `if (e.Button == MouseButtons.Right) {` in mouse click. (My 1st version didn't have it). As it is now, you can click on any part of the ellipse line to rotate it. But instead, you could replace my `IsHit` code and just test whether the mouse it inside a little circle or square at the tip of the ellipse. The calculation involved would be easier. – Olivier Jacot-Descombes Dec 04 '19 at 14:06