Как получить закругленные формы углов в C++ с помощью кривых Безье и QPainter: пошаговое руководство
Опубликовано: 2022-03-11Введение
Текущая тенденция в графическом дизайне заключается в использовании большого количества закругленных углов во всевозможных формах. Мы можем наблюдать этот факт на многих веб-страницах, мобильных устройствах и настольных приложениях. Наиболее яркими примерами являются кнопки приложений, которые используются для запуска некоторых действий при нажатии. Вместо строго прямоугольной формы с 90-градусными углами в углах их часто рисуют со скругленными углами. Закругленные углы делают пользовательский интерфейс более плавным и приятным. Я не совсем в этом уверен, но мне так говорит мой друг-дизайнер.
Визуальные элементы пользовательских интерфейсов создаются дизайнерами, а программисту остается только разместить их в нужных местах. Но что происходит, когда нам нужно сгенерировать форму со скругленными углами на лету, и мы не можем предварительно загрузить ее? Некоторые библиотеки программирования предлагают ограниченные возможности для создания предопределенных фигур с закругленными углами, но обычно их нельзя использовать в более сложных случаях. Например, в среде Qt есть класс QPainter , который используется для рисования всех классов, производных от QPaintDevice , включая виджеты, растровые изображения и изображения. У него есть метод drawRoundedRect , который, как следует из названия, рисует прямоугольник с закругленными углами. Но если нам нужна более сложная форма, мы должны реализовать ее сами. Как мы могли бы сделать это с многоугольником, плоской формой, ограниченной группой отрезков прямой линии? Если у нас есть многоугольник, нарисованный карандашом на листе бумаги, моей первой идеей было бы использовать ластик и удалить небольшую часть линий в каждом углу, а затем соединить концы оставшихся сегментов дугой окружности. Весь процесс можно проиллюстрировать на рисунке ниже.
Класс QPainter имеет несколько перегруженных методов с именем drawArc , которые могут рисовать дуги окружности. Все они требуют параметров, определяющих центр и размер дуги, начальный угол и длину дуги. Если для неповернутого прямоугольника легко определить необходимые значения этих параметров, то совсем другое дело, когда мы имеем дело с более сложными многоугольниками. Кроме того, нам пришлось бы повторять этот расчет для каждой вершины полигона. Этот расчет является длительной и утомительной задачей, и люди склонны к всевозможным ошибкам вычислений в процессе. Однако работа разработчиков программного обеспечения заключается в том, чтобы заставить компьютеры работать на людей, а не наоборот. Итак, здесь я собираюсь показать, как разработать простой класс, который может превратить сложный многоугольник в фигуру со скругленными углами. Пользователям этого класса нужно будет только добавить вершины многоугольника, а класс сделает все остальное. Основным математическим инструментом, который я использую для этой задачи, является кривая Безье.
Кривые Безье
Существует множество математических книг и интернет-ресурсов, описывающих теорию кривых Безье, поэтому я кратко опишу соответствующие свойства.
По определению кривая Безье — это кривая между двумя точками на двумерной поверхности, траектория которой определяется одной или несколькими контрольными точками. Строго говоря, кривая между двумя точками без дополнительных контрольных точек также является кривой Безье. Однако, поскольку это приводит к прямой линии между двумя точками, это не особенно интересно и не полезно.
Квадратичные кривые Безье
Квадратичные кривые Безье имеют одну контрольную точку. Теория гласит, что квадратичная кривая Безье между точками P 0 и P 2 с контрольной точкой P 1 определяется следующим образом:
B(t) = (1 - t) 2 P 0 + 2t(1 - t)P 1 + t 2 P 2 , где 0 ≤ t ≤ 1 (1)
Таким образом, когда t равно 0 , B(t) даст P0 , когда t равно 1 , B(t) даст P2 , но во всех остальных случаях значение B(t) также будет зависеть от Р 1 . Поскольку выражение 2t(1 - t) имеет максимальное значение при t = 0,5 , именно здесь влияние P 1 на B(t) будет наибольшим. Мы можем думать о P 1 как о воображаемом источнике гравитации, который притягивает траекторию функции к себе. На рисунке ниже показано несколько примеров квадратичных кривых Безье с их начальной, конечной и контрольной точками.
Итак, как нам решить нашу задачу с помощью кривых Безье? Рисунок ниже предлагает объяснение.
Если мы представим себе удаление вершины многоугольника и короткой части соединенных сегментов линии в ее окружении, мы можем думать о конце одного сегмента линии как о P 0 , конце другого сегмента линии как о P 2 и удаленной вершине как о P 1 . Применяем к этому набору точек квадратичную кривую Безье и вуаля, есть искомый закругленный угол.
Реализация C++/Qt с использованием QPainter
Класс QPainter не позволяет рисовать квадратичные кривые Безье. Хотя его довольно легко реализовать с нуля, следуя уравнению (1), библиотека Qt предлагает лучшее решение. Есть еще один мощный класс для 2D-рисования: QPainterPath . Класс QPainterPath — это набор линий и кривых, которые можно добавить и использовать позже с объектом QPainter . Есть несколько перегруженных методов, которые добавляют кривые Безье в текущую коллекцию. В частности, методы quadTo добавят квадратичные кривые Безье. Кривая начнется в текущей точке QPainterPath ( P 0 ), а P 1 и P 2 должны быть переданы в quadTo в качестве параметров.
Метод QPainter drawPath используется для рисования набора линий и кривых из объекта QPainterPath , который должен быть задан как параметр, с активным пером и кистью.
Итак, давайте посмотрим объявление класса:
class RoundedPolygon : public QPolygon { public: RoundedPolygon() { SetRadius(10); } void SetRadius(unsigned int iRadius) { m_iRadius = iRadius; } const QPainterPath& GetPath(); private: QPointF GetLineStart(int i) const; QPointF GetLineEnd(int i) const; float GetDistance(QPoint pt1, QPoint pt2) const; private: QPainterPath m_path; unsigned int m_iRadius; }; Я решил создать подкласс QPolygon , чтобы мне не приходилось самому добавлять вершины и другие вещи. Помимо конструктора, который просто устанавливает радиус в какое-то разумное начальное значение, этот класс имеет два других общедоступных метода:
- Метод
SetRadiusустанавливает радиус на заданное значение. Радиус — это длина прямой линии (в пикселях) возле каждой вершины, которая будет удалена (точнее, не нарисована) для скругленного угла. -
GetPath— это место, где происходят все вычисления. Он вернет объектQPainterPath, сгенерированный из точек многоугольника, добавленных вRoundedPolygon.
Методы из приватной части — это просто вспомогательные методы, используемые GetPath .
Давайте посмотрим на реализацию, и я начну с приватных методов:

float RoundedPolygon::GetDistance(QPoint pt1, QPoint pt2) const { float fD = (pt1.x() - pt2.x())*(pt1.x() - pt2.x()) + (pt1.y() - pt2.y()) * (pt1.y() - pt2.y()); return sqrtf(fD); }Здесь нечего объяснять, метод возвращает евклидово расстояние между заданными двумя точками.
QPointF RoundedPolygon::GetLineStart(int i) const { QPointF pt; QPoint pt1 = at(i); QPoint pt2 = at((i+1) % count()); float fRat = m_uiRadius / GetDistance(pt1, pt2); if (fRat > 0.5f) fRat = 0.5f; pt.setX((1.0f-fRat)*pt1.x() + fRat*pt2.x()); pt.setY((1.0f-fRat)*pt1.y() + fRat*pt2.y()); return pt; } Метод GetLineStart вычисляет положение точки P 2 по последней фигуре, если точки добавляются к многоугольнику по часовой стрелке. Точнее, он вернет точку, которая находится на расстоянии m_uiRadius пикселей от i -й вершины в направлении к (i+1) -й вершине. При доступе к (i+1) -й вершине мы должны помнить, что в многоугольнике также есть отрезок между последней и первой вершиной, что делает его замкнутой формой, поэтому выражение (i+1)%count() . Это также предотвращает выход метода за пределы диапазона и вместо этого обращается к первой точке. Переменная fRat содержит соотношение между радиусом и длиной i -го отрезка линии. Существует также проверка, которая не позволяет fRat иметь значение выше 0.5 . Если бы значение fRat превышало 0.5 , то два последовательных закругленных угла перекрывались бы, что приводило к плохому визуальному результату.
При движении из точки P 1 в P 2 по прямой и пройдя 30 процентов пути, мы можем определить свое местоположение по формуле 0,7 • P 1 + 0,3 • P 2 . В общем, если мы достигаем части полного расстояния, а α = 1 обозначает полное расстояние, текущее местоположение находится в (1 - α) • P1 + α • P2 .
Вот как метод GetLineStart определяет местоположение точки, которая находится на расстоянии m_uiRadius пикселей от i -й вершины в направлении (i+1) -й.
QPointF RoundedPolygon::GetLineEnd(int i) const { QPointF pt; QPoint pt1 = at(i); QPoint pt2 = at((i+1) % count()); float fRat = m_uiRadius / GetDistance(pt1, pt2); if (fRat > 0.5f) fRat = 0.5f; pt.setX(fRat*pt1.x() + (1.0f - fRat)*pt2.x()); pt.setY(fRat*pt1.y() + (1.0f - fRat)*pt2.y()); return pt; } Этот метод очень похож на GetLineStart . Он вычисляет положение точки P 0 для (i+1) -й вершины, а не i -й. Другими словами, если мы нарисуем линию от GetLineStart(i) до GetLineEnd(i) для каждого i от 0 до n-1 , где n — количество вершин в многоугольнике, мы получим многоугольник со стертыми вершинами и их близкое окружение.
А теперь метод основного класса:
const QPainterPath& RoundedPolygon::GetPath() { m_path = QPainterPath(); if (count() < 3) { qWarning() << "Polygon should have at least 3 points!"; return m_path; } QPointF pt1; QPointF pt2; for (int i = 0; i < count(); i++) { pt1 = GetLineStart(i); if (i == 0) m_path.moveTo(pt1); else m_path.quadTo(at(i), pt1); pt2 = GetLineEnd(i); m_path.lineTo(pt2); } // close the last corner pt1 = GetLineStart(0); m_path.quadTo(at(0), pt1); return m_path; } В этом методе мы создаем объект QPainterPath . Если у многоугольника нет хотя бы трех вершин, мы больше не имеем дело с 2D-фигурой, и в этом случае метод выдает предупреждение и возвращает пустой путь. Когда точек достаточно, мы перебираем все прямые отрезки многоугольника (количество отрезков, конечно, равно количеству вершин), вычисляя начало и конец каждого отрезка прямой между закругленными углы. Проведем прямую линию между этими двумя точками и квадратичную кривую Безье между концом предыдущего отрезка прямой и началом текущего, используя положение текущей вершины в качестве контрольной точки. После цикла мы должны замкнуть путь кривой Безье между последним и первым отрезками прямой, потому что в цикле мы нарисовали на одну прямую больше, чем кривых Безье.
Использование и результаты класса RoundedPolygon
Теперь пришло время посмотреть, как использовать этот класс на практике.
QPixmap pix1(300, 200); QPixmap pix2(300, 200); pix1.fill(Qt::white); pix2.fill(Qt::white); QPainter P1(&pix1); QPainter P2(&pix2); P1.setRenderHints(QPainter::Antialiasing); P2.setRenderHints(QPainter::Antialiasing); P1.setPen(QPen(Qt::blue, 2)); P1.setBrush(Qt::red); P2.setPen(QPen(Qt::blue, 2)); P2.setBrush(Qt::red); RoundedPolygon poly; poly << QPoint(147, 187) << QPoint(95, 187) << QPoint(100, 175) << QPoint(145, 165) << QPoint(140, 95) << QPoint(5, 85) << QPoint(5, 70) << QPoint(140, 70) << QPoint(135, 45) << QPoint(138, 25) << QPoint(145, 5) << QPoint(155, 5) << QPoint(162, 25) << QPoint(165, 45) << QPoint(160, 70) << QPoint(295, 70) << QPoint(295, 85) << QPoint(160, 95) << QPoint(155, 165) << QPoint(200, 175) << QPoint(205, 187) << QPoint(153, 187) << QPoint(150, 199); P1.drawPolygon(poly); P2.drawPath(poly.GetPath()); pix1.save("1.png"); pix2.save("2.png"); Этот фрагмент исходного кода довольно прост. После инициализации двух QPixmaps и их QPainters мы создаем объект RoundedPolygon и заполняем его точками. Painter P1 рисует правильный многоугольник, а P2 рисует QPainterPath со скругленными углами, сгенерированными из многоугольника. Оба получившихся растровых изображения сохраняются в свои файлы, и результаты следующие:
Заключение
Мы видели, что создать фигуру со скругленными углами из многоугольника в конце концов не так уж и сложно, особенно если мы используем хорошую среду программирования, такую как Qt. Этот процесс можно автоматизировать с помощью класса, который я описал в этом блоге в качестве доказательства концепции. Тем не менее, есть еще много возможностей для улучшения, например:
- Скругляйте углы только в выбранных вершинах, а не во всех.
- Сделайте закругленные углы с разными радиусами в разных вершинах.
- Реализуйте метод, генерирующий полилинию со скругленными углами (полилиния в терминологии Qt аналогична многоугольнику, за исключением того, что это не замкнутая форма, поскольку отсутствует отрезок линии между последней и первой вершиной).
- Используйте
RoundedPolygonдля создания растровых изображений, которые можно использовать в качестве маски фонового виджета для создания виджетов сумасшедшей формы. - Класс
RoundedPolygonне оптимизирован для скорости выполнения; Я оставил это как есть для более легкого понимания концепции. Оптимизация может включать вычисление множества промежуточных значений при добавлении новой вершины к многоугольнику. Кроме того, когдаGetPathсобирается вернуть ссылку на сгенерированныйQPainterPath, он может установить флаг, указывающий, что объект обновлен. Следующий вызовGetPathприведет только к возврату того же объектаQPainterPathбез пересчета чего-либо. Однако разработчик должен убедиться, что этот флаг снимается при каждом изменении любой из вершин полигона, а также при каждой новой вершине, что наводит меня на мысль, что оптимизированный класс лучше разрабатывать с нуля, а не производным изQPolygon. Хорошая новость заключается в том, что это не так сложно, как кажется.
В целом, класс RoundedPolygon как таковой можно использовать в качестве инструмента в любое время, когда мы хотим добавить дизайнерский штрих к нашему графическому интерфейсу на лету, без предварительной подготовки растровых изображений или форм.
