Como obter formas de cantos arredondados em C++ usando curvas de Bezier e QPainter: um guia passo a passo

Publicados: 2022-03-11

Introdução

A tendência atual no design gráfico é usar muitos cantos arredondados em todos os tipos de formas. Podemos observar esse fato em muitas páginas da web, dispositivos móveis e aplicativos de desktop. Os exemplos mais notáveis ​​são os botões de ação do aplicativo, que são usados ​​para acionar alguma ação quando clicados. Em vez de forma estritamente retangular com ângulos de 90 graus nos cantos, eles geralmente são desenhados com cantos arredondados. Os cantos arredondados tornam a interface do usuário mais suave e agradável. Não estou totalmente convencido disso, mas meu amigo designer me diz isso.

Os cantos arredondados tornam a interface do usuário mais suave e agradável

Os cantos arredondados tornam a interface do usuário mais suave e agradável.
Tweet

Os elementos visuais das interfaces de usuário são criados por designers, e o programador só precisa colocá-los nos lugares certos. Mas o que acontece quando temos que gerar uma forma com cantos arredondados na hora e não podemos pré-carregá-la? Algumas bibliotecas de programação oferecem recursos limitados para criar formas predefinidas com cantos arredondados, mas geralmente não podem ser usadas em casos mais complicados. Por exemplo, o framework Qt tem uma classe QPainter , que é usada para desenhar em todas as classes derivadas de QPaintDevice , incluindo widgets, pixmaps e imagens. Ele tem um método chamado drawRoundedRect , que, assim como o nome sugere, desenha um retângulo com cantos arredondados. Mas se precisarmos de uma forma um pouco mais complexa, temos que implementá-la nós mesmos. Como poderíamos fazer isso com um polígono, uma forma plana limitada por um grupo de segmentos de linha reta? Se tivermos um polígono desenhado a lápis em um pedaço de papel, minha primeira ideia seria usar uma borracha e apagar uma pequena parte das linhas em cada canto e depois conectar as extremidades do segmento restante com um arco circular. Todo o processo pode ser ilustrado na figura abaixo.

Como criar cantos arredondados manualmente

A classe QPainter tem alguns métodos sobrecarregados chamados drawArc , que podem desenhar arcos circulares. Todos eles requerem parâmetros, que definem o centro e o tamanho do arco, o ângulo inicial e o comprimento do arco. Embora seja fácil determinar os valores necessários desses parâmetros para um retângulo não girado, é uma questão totalmente diferente quando estamos lidando com polígonos mais complexos. Além disso, teríamos que repetir esse cálculo para cada vértice do polígono. Esse cálculo é uma tarefa longa e cansativa, e os humanos são propensos a todos os tipos de erros de cálculo no processo. No entanto, é trabalho dos desenvolvedores de software fazer com que os computadores funcionem para os seres humanos, e não vice-versa. Então, aqui vou mostrar como desenvolver uma classe simples, que pode transformar um polígono complexo em uma forma com cantos arredondados. Os usuários desta classe só terão que anexar vértices de polígono, e a classe fará o resto. A ferramenta matemática essencial que uso para esta tarefa é a curva de Bezier.

Curvas de Bézier

Existem muitos livros de matemática e recursos na Internet que descrevem a teoria das curvas de Bezier, portanto, descreverei brevemente as propriedades relevantes.

Por definição, a curva de Bezier é uma curva entre dois pontos em uma superfície bidimensional, cuja trajetória é governada por um ou mais pontos de controle. Estritamente falando, uma curva entre dois pontos sem pontos de controle adicionais também é uma curva de Bezier. No entanto, como isso resulta em uma linha reta entre os dois pontos, não é particularmente interessante, nem útil.

Curvas quadráticas de Bezier

As curvas quadráticas de Bezier têm um ponto de controle. A teoria diz que uma curva de Bezier quadrática entre os pontos P 0 e P 2 com ponto de controle P 1 é definida da seguinte forma:

B(t) = (1 - t) 2 P 0 + 2t(1 - t) P 1 + t 2 P 2 , onde 0 ≤ t ≤ 1 (1)

Então, quando t for igual a 0 , B(t) resultará em P 0 , quando t for igual a 1 , B(t) resultará em P 2 , mas em todos os outros casos, o valor de B(t) também dependerá de P1 . Como a expressão 2t(1 - t) tem um valor máximo em t = 0,5 , é aí que a influência de P 1 em B(t) será maior. Podemos pensar em P 1 como uma fonte imaginária de gravidade, que puxa a trajetória da função em sua direção. A figura abaixo mostra alguns exemplos de curvas de Bezier quadráticas com seus pontos inicial, final e de controle.

Curvas quadráticas de Bezier

Então, como resolvemos nosso problema usando curvas de Bezier? A figura abaixo oferece uma explicação.

Como criar cantos arredondados usando o código

Se imaginarmos a exclusão de um vértice de polígono e uma pequena parte de segmentos de linha conectados em seu entorno, podemos pensar em um segmento de linha final como P 0 , o outro segmento de linha final como P 2 e o vértice excluído como P 1 . Aplicamos uma curva de Bezier quadrática a este conjunto de pontos e pronto, temos o canto arredondado desejado.

Implementação C++/Qt usando QPainter

A classe QPainter não tem como desenhar curvas de Bezier quadráticas. Embora seja bastante fácil implementá-lo do zero seguindo a equação (1), a biblioteca Qt oferece uma solução melhor. Existe outra classe poderosa para desenho 2D: QPainterPath . A classe QPainterPath é uma coleção de linhas e curvas que podem ser adicionadas e usadas posteriormente com o objeto QPainter . Existem alguns métodos sobrecarregados que adicionam curvas de Bezier a uma coleção atual. Em particular, os métodos quadTo adicionarão curvas de Bezier quadráticas. A curva começará no ponto QPainterPath atual ( P 0 ), enquanto P 1 e P 2 devem ser passados ​​para quadTo como parâmetros.

O método QPainter do drawPath é usado para desenhar uma coleção de linhas e curvas do objeto QPainterPath , que deve ser dado como parâmetro, com caneta e pincel ativos.

Então vamos ver a declaração da classe:

 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; };

Eu decidi subclassificar QPolygon para que eu não tenha que implementar a adição de vértices e outras coisas por mim mesmo. Além do construtor, que apenas define o raio para algum valor inicial sensato, essa classe tem dois outros métodos públicos:

  • O método SetRadius define o raio para um determinado valor. Raio é o comprimento de uma linha reta (em pixels) próxima a cada vértice, que será deletada (ou, mais precisamente, não desenhada) para o canto arredondado.
  • GetPath é onde todos os cálculos ocorrem. Ele retornará o objeto QPainterPath gerado a partir dos pontos de polígono adicionados a RoundedPolygon .

Os métodos da parte privada são apenas métodos auxiliares usados ​​pelo GetPath .

Vamos ver a implementação e vou começar com os 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); }

Não há muito o que explicar aqui, o método retorna a distância euclidiana entre os dois pontos 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; }

O método GetLineStart calcula a localização do ponto P 2 da última figura, se os pontos forem adicionados ao polígono no sentido horário. Mais precisamente, ele retornará um ponto, que está m_uiRadius pixels de distância do i -th vértice na direção do (i+1) -th vértice. Ao acessar o (i+1) -th vértice, temos que lembrar que no polígono também existe um segmento de reta entre o último e o primeiro vértice, o que o torna uma forma fechada, daí a expressão (i+1)%count() . Isso também evita que o método saia do alcance e acesse o primeiro ponto. A variável fRat contém a razão entre o raio e o i -ésimo comprimento do segmento de linha. Há também uma verificação que impede que fRat tenha um valor superior a 0.5 . Se fRat tivesse um valor superior a 0.5 , os dois cantos arredondados consecutivos se sobreporiam, o que causaria um resultado visual ruim.

Ao viajar do ponto P 1 ao P 2 em linha reta e completando 30% da distância, podemos determinar nossa localização usando a fórmula 0,7 • P 1 + 0,3 • P 2 . Em geral, se atingirmos uma fração da distância total, e α = 1 denota a distância total, a localização atual é (1 - α) • P1 + α • P2 .

É assim que o método GetLineStart determina a localização do ponto que está a m_uiRadius pixels de distância do i -th vértice na direção de (i+1) -th.

 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 é muito semelhante ao GetLineStart . Ele calcula a localização do ponto P 0 para o (i+1) -ésimo vértice, não i -ésimo. Em outras palavras, se desenharmos uma linha de GetLineStart(i) a GetLineEnd(i) para cada i entre 0 e n-1 , onde n é o número de vértices no polígono, obteremos o polígono com vértices apagados e seus próximo ao entorno.

E agora, o método da classe 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; }

Neste método, construímos o objeto QPainterPath . Se o polígono não tiver pelo menos três vértices, não se trata mais de uma forma 2D e, neste caso, o método emite um aviso e retorna o caminho vazio. Quando pontos suficientes estão disponíveis, fazemos um loop sobre todos os segmentos de linha reta do polígono (o número de segmentos de linha é, obviamente, igual ao número de vértices), calculando o início e o fim de cada segmento de linha reta entre os pontos arredondados. cantos. Colocamos uma linha reta entre esses dois pontos e uma curva de Bezier quadrática entre o final do segmento de linha anterior e o início da corrente, usando a localização do vértice atual como ponto de controle. Após o loop, temos que fechar o caminho com uma curva de Bezier entre o último e o primeiro segmento de linha porque no loop desenhamos uma linha reta a mais que as curvas de Bezier.

Uso e resultados da classe RoundedPolygon

Agora é hora de ver como usar essa classe na prática.

 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 pedaço de código-fonte é bastante simples. Após inicializar dois QPixmaps e seus QPainters , criamos um objeto RoundedPolygon e o preenchemos com pontos. O pintor P1 desenha o polígono regular, enquanto o P2 desenha o QPainterPath com cantos arredondados, gerado a partir do polígono. Ambos os pixmaps resultantes são salvos em seus arquivos e os resultados são os seguintes:

Cantos arredondados usando o QPainter

Conclusão

Vimos que gerar uma forma com cantos arredondados a partir de um polígono não é tão difícil, principalmente se usarmos um bom framework de programação como o Qt. Esse processo pode ser automatizado pela classe que descrevi neste blog como uma prova de conceito. No entanto, ainda há muito espaço para melhorias, como:

  • Faça cantos arredondados apenas em vértices selecionados e não em todos eles.
  • Faça cantos arredondados com raios diferentes em vértices diferentes.
  • Implemente um método que gere uma polilinha com cantos arredondados (polilinha na terminologia Qt é como polígono, exceto que não é uma forma fechada porque está faltando o segmento de linha entre o último e o primeiro vértice).
  • Use RoundedPolygon para gerar bitmaps, que podem ser utilizados como máscara de widget de plano de fundo para produzir widgets de formas malucas.
  • A classe RoundedPolygon não é otimizada para velocidade de execução; Deixei como está para facilitar a compreensão do conceito. A otimização pode incluir o cálculo de muitos valores intermediários ao anexar um novo vértice ao polígono. Além disso, quando GetPath está prestes a retornar uma referência ao QPainterPath gerado, ele pode definir um sinalizador, indicando que o objeto está atualizado. A próxima chamada para GetPath resultaria no retorno apenas do mesmo objeto QPainterPath , sem recalcular nada. O desenvolvedor teria, no entanto, que certificar-se de que esse sinalizador seja limpo em cada alteração em qualquer um dos vértices do polígono, bem como em cada novo vértice, o que me faz pensar que a classe otimizada seria melhor desenvolvida do zero e não derivada do QPolygon . A boa notícia é que isso não é tão difícil quanto parece.

Ao todo, a classe RoundedPolygon , como está, pode ser usada como uma ferramenta sempre que quisermos adicionar um toque de designer à nossa GUI em tempo real, sem preparar pixmaps ou formas antecipadamente.

Relacionado: Como aprender as linguagens C e C++: a lista definitiva