Search code examples
c#system.drawing

Erasing already drawn shapes


I am coding my own Paint in C# using the System.Drawing namespace and basically everything is going well so far except for one thing, the eraser tool.

Right now I the eraser works just by drawing a line with the same color as the background so it appears like it's erasing something.

However, I want to make a new Eraser tool, perhaps improved. This new Eraser would be able to DELETE an element with a single click if the click is within it's bounds. I know that if one thing has already been drawn it's there and nothing can be done but I was thinking about creating a string array and I'm going to add new elements to the array. For example when I add a line and a rectangle the first two elements of array would be:

line startPoint endPoint

rectangle height width x y

Something like that. And when the Erase tool is used, just compare the coordinates.

Is there an easier approach to this?

Thanks a lot!


Solution

  • Yes, there is. What you're planning to do is essentially retained mode rendering in a way. You keep a list (or other data structure) of objects that are on the canvas and you can reorder or change that list in any way, e.g. by removing or adding objects. After that you just re-create the drawing, that is, clear your drawing area and then draw each object in your list in order. This is necessary because once you have drawn something you only have pixels, and if your line and rectangle intersect, you may have trouble separating line pixels from rectangle pixels.

    With GDI+ this is the only approach, since you don't get much more than a raw drawing surface. However, other things exist which already provide that rendering model for you, e.g. WPF.

    However, a string[] is a horrible way of solving this. Usually you would have some kind of interface, e.g.

    public interface IShape {
      public void Draw(Graphics g);
      public bool IsHit(PointF p);
    }
    

    which your shapes implement. A line would keep its stroke colour and start/end coordinates as state which it would then use to draw itself in the Draw method. Furthermore, when you want to click on a shape with the eraser, you'd have the IsHit method to determine whether a shape was hit. That way each shape is responsible for its own hit-testing. E.g a line could implement a little fuzziness so that you can click a little next to the line instead of having to hit a single pixel exactly.

    That's the general idea, anyway. You could expand this as necessary to other ideas. Note that by using this approach your core code doesn't have to know anything about the shapes that are possible (comparing coordinates can be a bit cumbersome if you have to maintain an ever-growing switch statement of different shapes). Of course, for drawing those shapes you still need a bit more code because lines may need a different interaction than rectangles, ellipses or text objects.

    I created a small sample that outlines above approach here. The interesting parts are as follows:

    interface IShape
    {
        Pen Pen { get; set; }
        Brush Fill { get; set; }
        void Draw(Graphics g);
        bool IsHit(PointF p);
    }
    
    class Line : IShape
    {
        public Brush Fill { get; set; }
        public Pen Pen { get; set; }
        public PointF Start { get; set; }
        public PointF End { get; set; }
    
        public void Draw(Graphics g)
        {
            g.DrawLine(Pen, Start, End);
        }
    
        public bool IsHit(PointF p)
        {
            // Find distance to the end points
            var d1 = Math.Sqrt((Start.X - p.X) * (Start.X - p.X) + (Start.Y - p.Y) * (Start.Y - p.Y));
            var d2 = Math.Sqrt((End.X - p.X) * (End.X - p.X) + (End.Y - p.Y) * (End.Y - p.Y));
    
            // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line
            var dx = End.X - Start.X;
            var dy = End.Y - Start.Y;
            var length = Math.Sqrt(dx * dx + dy * dy);
            var distance = Math.Abs(dy * p.X - dx * p.Y + End.X * Start.Y - End.Y * Start.X) / Math.Sqrt(dy * dy + dx * dx);
    
            // Make sure the click was really near the line because the distance above also works beyond the end points
            return distance < 3 && (d1 < length + 3 && d2 < length + 3);
        }
    }
    
    public partial class Form1 : Form
    {
        private ObservableCollection<IShape> shapes = new ObservableCollection<IShape>();
        private static Random random = new Random();
    
        public Form1()
        {
            InitializeComponent();
            pictureBox1.Image = new Bitmap(pictureBox1.Width, pictureBox1.Height);
            shapes.CollectionChanged += Shapes_CollectionChanged;
        }
    
        private void Shapes_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            Redraw();
        }
    
        public void Redraw()
        {
            using (var g = Graphics.FromImage(pictureBox1.Image))
            {
                foreach (var shape in shapes)
                {
                    shape.Draw(g);
                }
            }
            pictureBox1.Invalidate();
        }
    
        private void button1_Click(object sender, EventArgs e)
        {
            shapes.Add(new Line
            {
                Pen = Pens.Red,
                Start = new PointF(random.Next(pictureBox1.Width), random.Next(pictureBox1.Height)),
                End = new PointF(random.Next(pictureBox1.Width), random.Next(pictureBox1.Height))
            });
        }
    
        private void pictureBox1_SizeChanged(object sender, EventArgs e)
        {
            pictureBox1.Image = new Bitmap(pictureBox1.Width, pictureBox1.Height);
            Redraw();
        }
    
        private IShape FindShape(PointF p)
        {
            // Reverse search order because we draw from bottom to top, but we need to hit-test from top to bottom.
            foreach (var shape in shapes.Reverse())
            {
                if (shape.IsHit(p))
                    return shape;
            }
            return null;
        }
    
        private void button1_MouseClick(object sender, MouseEventArgs e)
        {
            var shape = FindShape(e.Location);
            if (shape != null)
            {
                shape.Pen = Pens.Blue;
                Redraw();
            }
        }
    }
    

    Clicking the button creates a random line and redraws. Redrawing is as simple as

    public void Redraw()
    {
        using (var g = Graphics.FromImage(pictureBox1.Image))
        {
            foreach (var shape in shapes)
            {
                shape.Draw(g);
            }
        }
        pictureBox1.Invalidate();
    }
    

    Clicking the picture will try finding a shape at the click point, and if it finds one, it colours it blue (along with a redraw). Finding the item works as follows:

    private IShape FindShape(PointF p)
    {
        // Reverse search order because we draw from bottom to top, but we need to hit-test from top to bottom.
        foreach (var shape in shapes.Reverse())
        {
            if (shape.IsHit(p))
                return shape;
        }
        return null;
    }
    

    So as you can see, the fundamental parts of actually drawing things and maybe selecting them again, are fairly easy that way. Of course, the different drawing tools are another matter, although that has solutions, too.