Come ottenere forme di angoli arrotondati in C++ usando le curve di Bezier e QPainter: una guida passo passo

Pubblicato: 2022-03-11

introduzione

L'attuale tendenza nella progettazione grafica è quella di utilizzare molti angoli arrotondati in tutti i tipi di forme. Possiamo osservare questo fatto su molte pagine Web, dispositivi mobili e applicazioni desktop. Gli esempi più notevoli sono i pulsanti dell'applicazione, che vengono utilizzati per attivare alcune azioni quando vengono cliccati. Invece di una forma rigorosamente rettangolare con angoli di 90 gradi negli angoli, sono spesso disegnati con angoli arrotondati. Gli angoli arrotondati rendono l'interfaccia utente più liscia e piacevole. Non ne sono del tutto convinto, ma me lo dice il mio amico designer.

Gli angoli arrotondati rendono l'interfaccia utente più liscia e piacevole

Gli angoli arrotondati rendono l'interfaccia utente più liscia e piacevole.
Twitta

Gli elementi visivi delle interfacce utente sono creati dai designer e il programmatore deve solo metterli nei posti giusti. Ma cosa succede quando dobbiamo generare al volo una forma con gli angoli arrotondati e non possiamo precaricarla? Alcune librerie di programmazione offrono capacità limitate per la creazione di forme predefinite con angoli arrotondati, ma di solito non possono essere utilizzate nei casi più complicati. Ad esempio, il framework Qt ha una classe QPainter , che viene utilizzata per disegnare su tutte le classi derivate da QPaintDevice , inclusi widget, pixmap e immagini. Ha un metodo chiamato drawRoundedRect , che, proprio come suggerisce il nome, disegna un rettangolo con angoli arrotondati. Ma se abbiamo bisogno di una forma un po' più complessa, dobbiamo implementarla noi stessi. Come potremmo farlo con un poligono, una forma planare delimitata da un gruppo di segmenti di linea retta? Se abbiamo un poligono disegnato con una matita su un pezzo di carta, la mia prima idea sarebbe quella di usare una gomma e cancellare una piccola parte delle linee ad ogni angolo e quindi collegare le estremità del segmento rimanente con un arco circolare. L'intero processo può essere illustrato nella figura seguente.

Come creare angoli arrotondati manualmente

La classe QPainter ha alcuni metodi sovraccaricati denominati drawArc , che possono disegnare archi circolari. Tutti richiedono parametri che definiscono il centro e la dimensione dell'arco, l'angolo iniziale e la lunghezza dell'arco. Sebbene sia facile determinare i valori necessari di questi parametri per un rettangolo non ruotato, è una questione completamente diversa quando abbiamo a che fare con poligoni più complessi. Inoltre, dovremmo ripetere questo calcolo per ogni vertice del poligono. Questo calcolo è un compito lungo e noioso e gli esseri umani sono inclini a tutti i tipi di errori di calcolo nel processo. Tuttavia, è compito degli sviluppatori di software far funzionare i computer per gli esseri umani e non viceversa. Quindi, qui mostrerò come sviluppare una classe semplice, che può trasformare un poligono complesso in una forma con angoli arrotondati. Gli utenti di questa classe dovranno solo aggiungere i vertici del poligono e la classe farà il resto. Lo strumento matematico essenziale che utilizzo per questo compito è la curva di Bezier.

Curve di Bézier

Ci sono molti libri di matematica e risorse Internet che descrivono la teoria delle curve di Bézier, quindi delineerò brevemente le proprietà rilevanti.

Per definizione, la curva di Bézier è una curva tra due punti su una superficie bidimensionale, la cui traiettoria è governata da uno o più punti di controllo. A rigor di termini, anche una curva tra due punti senza punti di controllo aggiuntivi è una curva di Bezier. Tuttavia, poiché ciò si traduce in una linea retta tra i due punti, non è particolarmente interessante né utile.

Curve di Bézier quadratiche

Le curve di Bezier quadratiche hanno un punto di controllo. La teoria dice che una curva di Bezier quadratica tra i punti P 0 e P 2 con punto di controllo P 1 è definita come segue:

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

Quindi quando t è uguale a 0 , B(t) produrrà P 0 , quando t è uguale a 1 , B(t) produrrà P 2 , ma in ogni altro caso il valore di B(t) dipenderà anche da P1 . Poiché l'espressione 2t(1 - t) ha un valore massimo a t = 0,5 , è qui che l'influenza di P 1 su B(t) sarà maggiore. Possiamo pensare a P 1 come a una fonte immaginaria di gravità, che attira a sé la traiettoria della funzione. La figura seguente mostra alcuni esempi di curve di Bezier quadratiche con i loro punti di inizio, fine e controllo.

Curve di Bézier quadratiche

Quindi, come risolviamo il nostro problema usando le curve di Bezier? La figura seguente offre una spiegazione.

Come creare angoli arrotondati utilizzando il codice

Se immaginiamo di eliminare un vertice di un poligono e una breve parte di segmenti di linea collegati nei suoi dintorni, possiamo pensare all'estremità di un segmento di linea come di P 0 , l'altra estremità del segmento di linea come di P 2 e il vertice cancellato come di P 1 . Applichiamo una curva di Bezier quadratica a questo insieme di punti e voilà, c'è l'angolo arrotondato desiderato.

Implementazione C++/Qt utilizzando QPainter

La classe QPainter non ha un modo per disegnare curve di Bezier quadratiche. Sebbene sia abbastanza facile implementarlo da zero seguendo l'equazione (1), la libreria Qt offre una soluzione migliore. Esiste un'altra potente classe per il disegno 2D: QPainterPath . La classe QPainterPath è una raccolta di linee e curve che possono essere aggiunte e utilizzate successivamente con l'oggetto QPainter . Esistono alcuni metodi sovraccaricati che aggiungono curve di Bezier a una raccolta corrente. In particolare, i metodi quadTo aggiungeranno una curva di Bezier quadratica. La curva partirà dal punto QPainterPath corrente ( P 0 ), mentre P 1 e P 2 devono essere passati a quadTo come parametri.

Il metodo QPainter di drawPath viene utilizzato per disegnare una raccolta di linee e curve dall'oggetto QPainterPath , che deve essere fornito come parametro, con penna e pennello attivi.

Quindi vediamo la dichiarazione di 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; };

Ho deciso di sottoclassare QPolygon in modo da non dover implementare l'aggiunta di vertici e altre cose da solo. Oltre al costruttore, che imposta il raggio su un valore iniziale ragionevole, questa classe ha altri due metodi pubblici:

  • Il metodo SetRadius imposta il raggio su un determinato valore. Il raggio è la lunghezza di una linea retta (in pixel) vicino a ciascun vertice, che verrà cancellata (o, più precisamente, non disegnata) per l'angolo arrotondato.
  • GetPath è dove si svolgono tutti i calcoli. Restituirà l'oggetto QPainterPath generato dai punti del poligono aggiunti a RoundedPolygon .

I metodi della parte privata sono solo metodi ausiliari utilizzati da GetPath .

Vediamo l'implementazione e inizierò con i metodi privati:

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

Non c'è molto da spiegare qui, il metodo restituisce la distanza euclidea tra i due punti dati.

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

Il metodo GetLineStart calcola la posizione del punto P 2 dall'ultima cifra, se i punti vengono aggiunti al poligono in senso orario. Più precisamente, restituirà un punto, che è m_uiRadius pixel lontano dall'i i vertice nella direzione verso il (i+1) -esimo vertice. Quando accediamo al (i+1) -esimo vertice, dobbiamo ricordare che nel poligono c'è anche un segmento di linea tra l'ultimo e il primo vertice, che lo rende una forma chiusa, quindi l'espressione (i+1)%count() . Ciò impedisce anche al metodo di uscire dall'intervallo e accede invece al primo punto. La variabile fRat contiene il rapporto tra il raggio e la i -esima lunghezza del segmento di linea. C'è anche un controllo che impedisce a fRat di avere un valore superiore a 0.5 . Se fRat avesse un valore superiore a 0.5 , i due angoli arrotondati consecutivi si sovrapporrebbero, causando un risultato visivo scadente.

Quando viaggiamo dal punto P 1 a P 2 in linea retta e completando il 30 percento della distanza, possiamo determinare la nostra posizione utilizzando la formula 0,7 • P 1 + 0,3 • P 2 . In generale, se otteniamo una frazione della distanza intera, e α = 1 denota la distanza intera, la posizione corrente è a (1 - α) • P1 + α • P2 .

Questo è il modo in cui il metodo GetLineStart determina la posizione del punto che è m_uiRadius pixel di distanza dall'i i vertice nella direzione di (i+1) -esimo.

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

Questo metodo è molto simile a GetLineStart . Calcola la posizione del punto P 0 per il (i+1) -esimo vertice, non i -esimo. In altre parole, se tracciamo una linea da GetLineStart(i) a GetLineEnd(i) per ogni i compreso tra 0 e n-1 , dove n è il numero di vertici nel poligono, otterremmo il poligono con i vertici cancellati e i loro dintorni vicini.

E ora, il metodo della classe principale:

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

In questo metodo, costruiamo l'oggetto QPainterPath . Se il poligono non ha almeno tre vertici, non abbiamo più a che fare con una forma 2D e, in questo caso, il metodo emette un avviso e restituisce il percorso vuoto. Quando sono disponibili abbastanza punti, eseguiamo un ciclo su tutti i segmenti di retta del poligono (il numero di segmenti di linea è, ovviamente, uguale al numero di vertici), calcolando l'inizio e la fine di ogni segmento di retta tra gli arrotondati angoli. Mettiamo una linea retta tra questi due punti e una curva di Bezier quadratica tra la fine del segmento di linea precedente e l'inizio della corrente, usando la posizione del vertice corrente come punto di controllo. Dopo il loop, dobbiamo chiudere il percorso con una curva di Bezier tra l'ultimo e il primo segmento di linea perché nel loop abbiamo tracciato una linea retta in più rispetto alle curve di Bezier.

Utilizzo e risultati della classe RoundedPolygon

Ora è il momento di vedere come usare questa classe in pratica.

 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");

Questo pezzo di codice sorgente è abbastanza semplice. Dopo aver inizializzato due QPixmaps e i loro QPainters , creiamo un oggetto RoundedPolygon e lo riempiamo di punti. Il pittore P1 disegna il poligono regolare, mentre P2 disegna il QPainterPath con gli angoli arrotondati, generato dal poligono. Entrambe le pixmap risultanti vengono salvate nei rispettivi file e i risultati sono i seguenti:

Angoli arrotondati con QPainter

Conclusione

Abbiamo visto che generare una forma con angoli arrotondati da un poligono non è poi così difficile, soprattutto se utilizziamo un buon framework di programmazione come Qt. Questo processo può essere automatizzato dalla classe che ho descritto in questo blog come prova di concetto. Tuttavia, c'è ancora molto spazio per miglioramenti, come ad esempio:

  • Crea angoli arrotondati solo sui vertici selezionati e non su tutti.
  • Crea angoli arrotondati con raggi diversi su vertici diversi.
  • Implementare un metodo che generi una polilinea con angoli arrotondati (la polilinea nella terminologia Qt è proprio come il poligono, tranne per il fatto che non è una forma chiusa perché manca il segmento di linea tra l'ultimo e il primo vertice).
  • Usa RoundedPolygon per generare bitmap, che possono essere utilizzate come maschera widget di sfondo per produrre widget dalla forma folle.
  • La classe RoundedPolygon non è ottimizzata per la velocità di esecuzione; L'ho lasciato così com'è per una più facile comprensione del concetto. L'ottimizzazione potrebbe includere il calcolo di molti valori intermedi dopo l'aggiunta di un nuovo vertice al poligono. Inoltre, quando GetPath sta per restituire un riferimento al QPainterPath generato, potrebbe impostare un flag, indicando che l'oggetto è aggiornato. La successiva chiamata a GetPath comporterebbe solo la restituzione dello stesso oggetto QPainterPath , senza ricalcolare nulla. Lo sviluppatore dovrebbe, tuttavia, assicurarsi che questo flag sia cancellato su ogni modifica in uno qualsiasi dei vertici del poligono, così come su ogni nuovo vertice, il che mi fa pensare che la classe ottimizzata sarebbe meglio sviluppata da zero e non derivata da QPolygon . La buona notizia è che non è così difficile come sembra.

Complessivamente, la classe RoundedPolygon , così com'è, può essere utilizzata come strumento ogni volta che vogliamo aggiungere un tocco di design alla nostra GUI al volo, senza preparare pixmap o forme in anticipo.

Correlati: Come imparare i linguaggi C e C++: l'elenco definitivo