Рисовалка (Paint)

Рассмотрим как сделать простейший аналог программы Paint. 

Создадим шаблон приложения с анимацией

Попробуем рисовать закрашенный шарик в позиции курсора на каждой итерации цикла отрисовки, без очистки экрана.

Почему в данной задаче удобно не очищать экран?

Поскольку редактор растровый и мы меняем и работаем именно с пикселями, а не с примитивами вроде линий, окружностей (как это делается в векторных графических редакторах), то хранить сцену удобно тоже именно в пикселях. Смысла очищать и затем выводить опять эти же самые пиксели нет никакого. Поэтому мы работаем с нашей сценой в инкрементальном режиме (только перерисовываем пиксели под кистью). Остальные пиксели мы не трогаем.

 

Для начала давайте создадим растр Bitmap где будет храниться наша сцена

public Form1()
{
    InitializeComponent();
    DoubleBuffered = true; // включаем двойную буферизацию, чтобы избежать фликеринга (flickering)
    StartPosition = FormStartPosition.CenterScreen;
    bmp = new Bitmap(ClientSize.Width, ClientSize.Height); // создаем растр где будет храниться сцена
    gr = Graphics.FromImage(bmp); // создает объект Graphics для работы со сценой
    gr.Clear(Color.Black); // очищаем один раз при создании все черным цветом
    gr.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;  //включаем сглаживание
    var timer = new System.Windows.Forms.Timer(); // создаем таймер анимации
    timer.Interval = 10; // ставим интервал отрисовки
    timer.Start(); // запускаем таймер 
    timer.Tick += Timer_Tick; // подписываемся на хендлер таймера
    Paint += Form1_Paint;
}

 

Теперь давайте попробуем на каждой итерации отрисовки рисовать кружок в позицию курсора без очистки экрана

float brushSize = 16; // радиус кисти

private void Form1_Paint(object? sender, PaintEventArgs e)
{
    var cursor = PointToClient(Cursor.Position);

    Color color = Color.Orange;
    gr.FillEllipse(new SolidBrush(color), cursor .X - brushSize / 2, cursor .Y - brushSize / 2, brushSize, brushSize);
    e.Graphics.DrawImageUnscaled(bmp, 0, 0);
}

Запустив и подвигав мышью мы увидем что отрисовка происходит рваная, это связано с тем что курсор успевает переместиться быстрее, чем отрабатывает цикл отрисовки.

Чтобы гарантировать непрерывность линии и убрать все разрывы, давайте попробуем рисовать не отдельные блобы, а будем запоминать последнюю позицию курсора и рисовать линию от нее до текущей позиции курсора.

PointF? lastPoint = null; // последня запомненная точка

private void Form1_Paint(object? sender, PaintEventArgs e)
{
    var cursor = PointToClient(Cursor.Position);

    Color color = Color.Orange;

    if (lastPoint != null)            
        gr.DrawLine(new Pen(Color.Orange, brushSize), cursor .X, cursor .Y, lastPoint.Value.X, lastPoint.Value.Y);
    
    lastPoint = cur;

    e.Graphics.DrawImageUnscaled(bmp, 0, 0);
}

Посмотрим результат:

Картина стала намного лучше, но появились множественные артефакты в виде рванных переходов от одной линии к другой. Это вызвано тем что толстые линии рисуются без скругления, а с прямым торцом. Давайте нивелируем эти артефакты добавив отрисовку закрашенного круга в конец линии.

private void Form1_Paint(object? sender, PaintEventArgs e)
{            
    var cursor = PointToClient(Cursor.Position);

    Color color = Color.Orange;

    if (lastPoint != null)
    {
        gr.DrawLine(new Pen(Color.Orange, brushSize), cursor.X, cursor.Y, lastPoint.Value.X, lastPoint.Value.Y);
        gr.FillEllipse(new SolidBrush(color), cursor.X - brushSize / 2, cursor.Y - brushSize / 2, brushSize, brushSize);
    }

    lastPoint = cursor;

    e.Graphics.DrawImageUnscaled(bmp, 0, 0);
    return;
}

Запустим:

Все артефакты пропали и теперь линия выглядит плавно.

Добавляем левую кнопку мыши

Давайте сделаем чтобы линия рисовалась только в случае если была нажата левая кнопка мыши.

Введем флаг - признак нажатия левой кнопки мыши и подпишемся на событий MouseUp, MouseDown

// добавим это в конструктор
     MouseDown += Form1_MouseDown;
     MouseUp += Form1_MouseUp;
} // конец конструктора Form1

        bool isPressed = false; // объявим флаг признак нажатия мышки

// напишем обработчики для мышки
private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
    isPressed = false; // если отпустили любую клавишу мышки, то сбрасываем все флаги
    lastPoint = null; // также сбрасываем последнюю точку, т.к. следующая линия будет рисоваться с новой точки
}

private void Form1_MouseDown(object? sender, MouseEventArgs e)
{
    if (e.Button == MouseButtons.Left) // если была нажата левая кнопка мыши
        isPressed = true;  // выставляем флаг отрисовки
}

Запустим и убедимся, что теперь можно зажимая и отпуская левую кнопку мыши рисовать новые линии.

Добавляем изменение размера кисти колесиком

Давайте сделаем чтобы размер кисти можно было менять крутя колесико. Для этого давайте подишемся на событие MouseWheel

//в конструктор добавим
    MouseWheel += Form1_MouseWheel;
} // конец конструктора Form1


private void Form1_MouseWheel(object? sender, MouseEventArgs e)
{
    const int brushSizeStep = 4;
    brushSize += e.Delta > 0 ? brushSizeStep : -brushSizeStep;
}

Теперь запустим программу и крутя колесиком можете убедиться, что мы можем  менять размер кисти и выводить линии разного размера.

Добавляем правую кнопку мыши для второго цвета

Чтобы можно было стирать нарисованное давайте сделаем что при нажатии правой кнопки мыши можно было рисовать линии цвета фона (черные). Добавим второй флаг признак нажатия кнопки по аналогии с первым.

bool isPressed2 = false;// объявим новый флаг

// допишем хендлеры MouseUp, MouseDown
private void Form1_MouseDown(object? sender, MouseEventArgs e)
{
    if (e.Button == MouseButtons.Left)
        isPressed = true;
    else if (e.Button == MouseButtons.Right)
        isPressed2 = true;
}

private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
    isPressed = false;
    isPressed2 = false;
    lastPoint = null;
}

private void Form1_Paint(object? sender, PaintEventArgs e)
{
    var cursor = PointToClient(Cursor.Position);
    Color color = isPressed ? Color.Orange : Color.Black;            // выбираем цвет исходя из того какая кнопка была нажата
    if (lastPoint != null && (isPressed || isPressed2)) // рисуем линию только если была нажата кнопка, и последняя точка не равна null
    {
        gr.DrawLine(new Pen(color, brushSize), cursor.X, cursor.Y, lastPoint.Value.X, lastPoint.Value.Y);
        gr.FillEllipse(new SolidBrush(color), cursor.X - brushSize / 2, cursor.Y - brushSize / 2, brushSize, brushSize);
    }

    lastPoint = cursor;

    e.Graphics.DrawImageUnscaled(bmp, 0, 0);
}

Теперь запустив программу убедимся что зажимая правую кнопку мыши мы можем стирать ранее нарисованные линии

Добавляем многослойность

Сейчас очень не удобно потому что мы не видим размер кисти, давайте отрисовывать белым кругом текущий размер кисти.  Для этого отрисуем его в объект Graphics нашей формы, после отрисовки основной сцены

private void Form1_Paint(object? sender, PaintEventArgs e)
{
    var cursor = PointToClient(Cursor.Position);
    Color color = isPressed ? Color.Orange : Color.Black;            
    //gr.FillEllipse(new SolidBrush(color), cur.X - brushSize / 2, cur.Y - brushSize / 2, brushSize, brushSize);
    if (lastPoint != null && (isPressed || isPressed2))
    {
        gr.DrawLine(new Pen(color, brushSize), cursor.X, cursor.Y, lastPoint.Value.X, lastPoint.Value.Y);
        gr.FillEllipse(new SolidBrush(color), cursor.X - brushSize / 2, cursor.Y - brushSize / 2, brushSize, brushSize);
    }

    lastPoint = cursor;                

    e.Graphics.DrawImageUnscaled(bmp, 0, 0);
// отрисуем обводку нашей кисти
    e.Graphics.DrawEllipse(Pens.White, cursor.X - brushSize / 2, cursor.Y - brushSize / 2, brushSize, brushSize);
}

Используя такой подход можно комбинировать несколько слоев, в нашем случае: слой основной сцены и слой UI хинтов (вспомогательных графических элементов, не относящихся к основной сцене).

Исходный код можно посмотреть здесь 

Задания:

1. Добавить возможность на клавиатуре переключать цвета кнопками 1,2,3,4,5. Использовать предустановленные цвета для эти кнопок на свое усмотрение.

2. Добавить отрисовку левой панели с палитрой цветов (отрисовать прямоугольниками), при нажатии на прямоуглоьник с цветом в палитре переключать кисть на него.

3. Сделать две кнопки для сохранения битмапа в файл и для загрузки изображений (bmp, png, jpg)