Cómo obtener formas de esquinas redondeadas en C++ usando Bezier Curves y QPainter: una guía paso a paso
Publicado: 2022-03-11Introducción
La tendencia actual en el diseño gráfico es utilizar muchas esquinas redondeadas en todo tipo de formas. Podemos observar este hecho en muchas páginas web, dispositivos móviles y aplicaciones de escritorio. Los ejemplos más notables son los botones de la aplicación, que se utilizan para activar alguna acción cuando se hace clic en ellos. En lugar de una forma estrictamente rectangular con ángulos de 90 grados en las esquinas, a menudo se dibujan con esquinas redondeadas. Las esquinas redondeadas hacen que la interfaz de usuario se sienta más fluida y agradable. No estoy del todo convencido de esto, pero mi amigo diseñador me lo dice.
Los elementos visuales de las interfaces de usuario son creados por diseñadores y el programador solo tiene que colocarlos en los lugares correctos. Pero, ¿qué sucede cuando tenemos que generar una forma con esquinas redondeadas sobre la marcha y no podemos precargarla? Algunas bibliotecas de programación ofrecen capacidades limitadas para crear formas predefinidas con esquinas redondeadas, pero, por lo general, no se pueden usar en casos más complicados. Por ejemplo, Qt framework tiene una clase QPainter
, que se usa para dibujar en todas las clases derivadas de QPaintDevice
, incluidos widgets, mapas de píxeles e imágenes. Tiene un método llamado drawRoundedRect
que, tal como su nombre indica, dibuja un rectángulo con esquinas redondeadas. Pero si necesitamos una forma un poco más compleja, tenemos que implementarla nosotros mismos. ¿Cómo podríamos hacer eso con un polígono, una forma plana delimitada por un grupo de segmentos de línea recta? Si tenemos un polígono dibujado con un lápiz en una hoja de papel, mi primera idea sería usar un borrador y borrar una pequeña parte de las líneas en cada esquina y luego conectar los extremos del segmento restante con un arco circular. Todo el proceso se puede ilustrar en la siguiente figura.
La clase QPainter
tiene algunos métodos sobrecargados llamados drawArc
, que pueden dibujar arcos circulares. Todos ellos requieren parámetros, que definen el centro y el tamaño del arco, el ángulo inicial y la longitud del arco. Si bien es fácil determinar los valores necesarios de estos parámetros para un rectángulo no rotado, es un asunto completamente diferente cuando tratamos con polígonos más complejos. Además, tendríamos que repetir este cálculo para cada vértice del polígono. Este cálculo es una tarea larga y tediosa, y los humanos son propensos a todo tipo de errores de cálculo en el proceso. Sin embargo, es trabajo de los desarrolladores de software hacer que las computadoras funcionen para los seres humanos, y no al revés. Entonces, aquí voy a mostrar cómo desarrollar una clase simple, que puede convertir un polígono complejo en una forma con esquinas redondeadas. Los usuarios de esta clase solo tendrán que añadir vértices de polígonos y la clase hará el resto. La herramienta matemática esencial que utilizo para esta tarea es la curva de Bezier.
curvas de Bézier
Hay muchos libros matemáticos y recursos de Internet que describen la teoría de las curvas de Bezier, por lo que describiré brevemente las propiedades relevantes.
Por definición, la curva de Bezier es una curva entre dos puntos en una superficie bidimensional, cuya trayectoria está gobernada por uno o más puntos de control. Estrictamente hablando, una curva entre dos puntos sin puntos de control adicionales, también es una curva de Bezier. Sin embargo, como esto da como resultado una línea recta entre los dos puntos, no es particularmente interesante ni útil.
Curvas de Bézier cuadráticas
Las curvas cuadráticas de Bézier tienen un punto de control. La teoría dice que una curva Bezier cuadrática entre los puntos P 0 y P 2 con el punto de control P 1 se define de la siguiente manera:
B(t) = (1 - t) 2 PAGS 0 + 2t(1 - t)P 1 + t 2 PAGS 2 , donde 0 ≤ t ≤ 1 (1)
Entonces, cuando t es igual a 0 , B(t) dará P 0 , cuando t es igual a 1 , B(t) dará P 2 , pero en cualquier otro caso, el valor de B(t) también dependerá de PAG 1 . Dado que la expresión 2t(1 - t) tiene un valor máximo en t = 0,5 , ahí es donde la influencia de P 1 en B(t) será mayor. Podemos pensar en P 1 como en una fuente imaginaria de gravedad, que atrae la trayectoria de la función hacia sí misma. La siguiente figura muestra algunos ejemplos de curvas Bezier cuadráticas con sus puntos de inicio, final y control.
Entonces, ¿cómo resolvemos nuestro problema usando las curvas de Bezier? La siguiente figura ofrece una explicación.
Si imaginamos eliminar un vértice de un polígono y una pequeña parte de los segmentos de línea conectados en su entorno, podemos pensar que un segmento de línea termina en P 0 , el otro segmento de línea termina en P 2 y el vértice eliminado en P 1 . Aplicamos una curva Bezier cuadrática a este conjunto de puntos y listo, ahí está la esquina redondeada deseada.
Implementación de C++/Qt usando QPainter
Class QPainter
no tiene una forma de dibujar curvas Bezier cuadráticas. Si bien es bastante fácil implementarlo desde cero siguiendo la ecuación (1), la biblioteca Qt ofrece una mejor solución. Hay otra clase poderosa para dibujar en 2D: QPainterPath
. La clase QPainterPath
es una colección de líneas y curvas que se pueden agregar y usar más adelante con el objeto QPainter
. Hay algunos métodos sobrecargados que agregan curvas Bezier a una colección actual. En particular, los métodos quadTo
agregarán curvas Bezier cuadráticas. La curva comenzará en el punto QPainterPath
actual ( P 0 ), mientras que P 1 y P 2 deben pasarse a quadTo
como parámetros.
El método QPainter
de drawPath
se utiliza para dibujar una colección de líneas y curvas a partir del objeto QPainterPath
, que debe proporcionarse como parámetro, con lápiz y pincel activos.
Así que veamos la declaración de la clase:
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; };
Decidí crear una subclase de QPolygon
para no tener que implementar la adición de vértices y otras cosas por mi cuenta. Además del constructor, que simplemente establece el radio en un valor inicial sensato, esta clase tiene otros dos métodos públicos:
- El método
SetRadius
establece el radio en un valor dado. El radio es la longitud de una línea recta (en píxeles) cerca de cada vértice, que se eliminará (o, más precisamente, no se dibujará) para la esquina redondeada. -
GetPath
es donde se realizan todos los cálculos. Devolverá el objetoQPainterPath
generado a partir de los puntos de polígono agregados aRoundedPolygon
.
Los métodos de la parte privada son solo métodos auxiliares utilizados por GetPath
.
Veamos la implementación y comenzaré con los métodos privados:

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); }
No hay mucho que explicar aquí, el método devuelve la distancia euclidiana entre los dos puntos dados.
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; }
El método GetLineStart
calcula la ubicación del punto P 2 a partir de la última figura, si los puntos se agregan al polígono en el sentido de las agujas del reloj. Más precisamente, devolverá un punto, que está m_uiRadius
píxeles del i
-ésimo vértice en dirección al (i+1)
-ésimo vértice. Al acceder al (i+1)
-ésimo vértice, debemos recordar que en el polígono, también hay un segmento de línea entre el último y el primer vértice, lo que lo convierte en una forma cerrada, de ahí la expresión (i+1)%count()
. Esto también evita que el método se salga del rango y acceda al primer punto en su lugar. La variable fRat
contiene la relación entre el radio y la i
-ésima longitud del segmento de línea. También hay una verificación que evita que fRat
tenga un valor superior a 0.5
. Si fRat
tuviera un valor superior a 0.5
, las dos esquinas redondeadas consecutivas se superpondrían, lo que provocaría un resultado visual deficiente.
Al viajar del punto P 1 al P 2 en línea recta y al completar el 30 por ciento de la distancia, podemos determinar nuestra ubicación usando la fórmula 0.7 • P 1 + 0.3 • P 2 . En general, si logramos una fracción de la distancia total y α = 1 denota la distancia total, la ubicación actual es (1 - α) • P1 + α • P2 .
Así es como el método GetLineStart
determina la ubicación del punto que está m_uiRadius
píxeles del i
-ésimo vértice en la dirección de (i+1)
-ésimo.
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; }
Este método es muy similar a GetLineStart
. Calcula la ubicación del punto P 0 para el (i+1)
-ésimo vértice, no i
-ésimo. En otras palabras, si dibujamos una línea desde GetLineStart(i)
hasta GetLineEnd(i)
para cada i
entre 0
y n-1
, donde n
es el número de vértices del polígono, obtendríamos el polígono con los vértices borrados y sus alrededores cercanos.
Y ahora, el método de la clase principal:
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; }
En este método, construimos el objeto QPainterPath
. Si el polígono no tiene al menos tres vértices, ya no estamos tratando con una forma 2D y, en este caso, el método emite una advertencia y devuelve la ruta vacía. Cuando hay suficientes puntos disponibles, hacemos un bucle sobre todos los segmentos de línea recta del polígono (el número de segmentos de línea es, por supuesto, igual al número de vértices), calculando el inicio y el final de cada segmento de línea recta entre los redondeados. esquinas Colocamos una línea recta entre estos dos puntos y una curva Bézier cuadrática entre el final del segmento de línea anterior y el inicio del actual, utilizando la ubicación del vértice actual como punto de control. Después del bucle, tenemos que cerrar el camino con una curva Bézier entre el último y el primer segmento de línea porque en el bucle dibujamos una línea recta más que las curvas Bézier.
Uso y resultados de Class RoundedPolygon
Ahora es el momento de ver cómo usar esta clase en la práctica.
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");
Este fragmento de código fuente es bastante sencillo. Después de inicializar dos QPixmaps
y sus QPainters
, creamos un objeto RoundedPolygon
y lo llenamos de puntos. Painter P1
dibuja el polígono regular, mientras que P2
dibuja el QPainterPath
con esquinas redondeadas, generado a partir del polígono. Ambos mapas de píxeles resultantes se guardan en sus archivos y los resultados son los siguientes:
Conclusión
Hemos visto que generar una forma con esquinas redondeadas a partir de un polígono no es tan difícil después de todo, especialmente si usamos un buen marco de programación como Qt. Este proceso puede ser automatizado por la clase que he descrito en este blog como prueba de concepto. Sin embargo, todavía hay mucho margen de mejora, como por ejemplo:
- Haga esquinas redondeadas solo en los vértices seleccionados y no en todos ellos.
- Haz esquinas redondeadas con diferentes radios en diferentes vértices.
- Implemente un método que genere una polilínea con esquinas redondeadas (la polilínea en la terminología de Qt es como un polígono, excepto que no es una forma cerrada porque le falta el segmento de línea entre el último y el primer vértice).
- Use
RoundedPolygon
para generar mapas de bits, que se pueden utilizar como máscara de widget de fondo para producir widgets con formas locas. - La clase
RoundedPolygon
no está optimizada para la velocidad de ejecución; Lo dejé como está para facilitar la comprensión del concepto. La optimización podría incluir el cálculo de muchos valores intermedios al agregar un nuevo vértice al polígono. Además, cuandoGetPath
está a punto de devolver una referencia alQPainterPath
generado, podría establecer una marca que indique que el objeto está actualizado. La siguiente llamada aGetPath
solo devolvería el mismo objetoQPainterPath
, sin volver a calcular nada. Sin embargo, el desarrollador tendría que asegurarse de que esta marca se borre en cada cambio en cualquiera de los vértices del polígono, así como en cada nuevo vértice, lo que me hace pensar que sería mejor desarrollar la clase optimizada desde cero y no derivada. deQPolygon
. La buena noticia es que esto no es tan difícil como parece.
En total, la clase RoundedPolygon
, tal como es, se puede usar como una herramienta en cualquier momento que queramos agregar un toque de diseñador a nuestra GUI sobre la marcha, sin preparar mapas de píxeles o formas de antemano.