Cum să obțineți forme de colț rotunjite în C++ folosind Bezier Curves și QPainter: un ghid pas cu pas
Publicat: 2022-03-11Introducere
Tendința actuală în designul grafic este de a folosi o mulțime de colțuri rotunjite în tot felul de forme. Putem observa acest fapt pe multe pagini web, dispozitive mobile și aplicații desktop. Cele mai notabile exemple sunt butoanele aplicației, care sunt folosite pentru a declanșa anumite acțiuni atunci când se da clic. În loc de formă strict dreptunghiulară cu unghiuri de 90 de grade în colțuri, acestea sunt adesea desenate cu colțuri rotunjite. Colțurile rotunjite fac ca interfața cu utilizatorul să se simtă mai netedă și mai plăcută. Nu sunt pe deplin convins de asta, dar prietenul meu designer îmi spune așa.
Elementele vizuale ale interfețelor utilizator sunt create de designeri, iar programatorul trebuie doar să le pună în locurile potrivite. Dar ce se întâmplă când trebuie să generăm din mers o formă cu colțuri rotunjite și nu o putem preîncărca? Unele biblioteci de programare oferă capacități limitate pentru a crea forme predefinite cu colțuri rotunjite, dar, de obicei, nu pot fi utilizate în cazuri mai complicate. De exemplu, cadrul Qt are o clasă QPainter
, care este folosită pentru a desena toate clasele derivate din QPaintDevice
, inclusiv widget-uri, hărți pixeli și imagini. Are o metodă numită drawRoundedRect
, care, așa cum sugerează și numele, desenează un dreptunghi cu colțuri rotunjite. Dar dacă avem nevoie de o formă puțin mai complexă, trebuie să o implementăm singuri. Cum am putea face asta cu un poligon, o formă plană delimitată de un grup de segmente de linie dreaptă? Dacă avem un poligon desenat cu un creion pe o bucată de hârtie, prima mea idee ar fi să folosesc o gumă de șters și să șterg o mică parte din liniile de la fiecare colț și apoi să conectez capetele segmentelor rămase cu un arc circular. Întregul proces poate fi ilustrat în figura de mai jos.
Clasa QPainter
are câteva metode supraîncărcate numite drawArc
, care pot desena arce circulare. Toate necesită parametri, care definesc centrul și dimensiunea arcului, unghiul de pornire și lungimea arcului. Deși este ușor să determinați valorile necesare ale acestor parametri pentru un dreptunghi nerotat, este o problemă cu totul diferită atunci când avem de-a face cu poligoane mai complexe. În plus, ar trebui să repetăm acest calcul pentru fiecare vârf de poligon. Acest calcul este o sarcină lungă și obositoare, iar oamenii sunt predispuși la tot felul de erori de calcul în acest proces. Cu toate acestea, este datoria dezvoltatorilor de software să facă computerele să funcționeze pentru ființe umane, și nu invers. Deci, aici voi arăta cum să dezvolt o clasă simplă, care poate transforma un poligon complex într-o formă cu colțuri rotunjite. Utilizatorii acestei clase vor trebui doar să atașeze vârfuri de poligon, iar clasa va face restul. Instrumentul matematic esențial pe care îl folosesc pentru această sarcină este curba Bezier.
curbe Bezier
Există o mulțime de cărți de matematică și resurse de internet care descriu teoria curbelor Bezier, așa că voi sublinia pe scurt proprietățile relevante.
Prin definiție, curba Bezier este o curbă între două puncte de pe o suprafață bidimensională, a cărei traiectorie este guvernată de unul sau mai multe puncte de control. Strict vorbind, o curbă între două puncte fără puncte de control suplimentare este, de asemenea, o curbă Bezier. Cu toate acestea, deoarece acest lucru are ca rezultat o linie dreaptă între cele două puncte, nu este deosebit de interesant și nici util.
Curbe Bezier cuadratice
Curbele Bezier pătratice au un singur punct de control. Teoria spune că o curbă Bezier pătratică între punctele P 0 și P 2 cu punctul de control P 1 este definită după cum urmează:
B(t) = (1 - t) 2 P 0 + 2t(1 - t)P 1 + t 2 P 2 , unde 0 ≤ t ≤ 1 (1)
Deci, când t este egal cu 0 , B(t) va da P 0 , când t este egal cu 1 , B(t) va da P2 , dar în orice alt caz, valoarea lui B(t) va depinde și de P 1 . Deoarece expresia 2t(1 - t) are o valoare maximă la t = 0,5 , acolo influența lui P 1 asupra B(t) va fi cea mai mare. Ne putem gândi la P 1 ca la o sursă imaginară de gravitație, care trage traiectoria funcției spre sine. Figura de mai jos prezintă câteva exemple de curbe Bezier pătratice cu punctele lor de început, sfârșit și de control.
Deci, cum ne rezolvăm problema folosind curbele Bezier? Figura de mai jos oferă o explicație.
Dacă ne imaginăm ștergerea unui vârf de poligon și a unei părți scurte din segmentele de linie conectate în împrejurimile lui, ne putem gândi la un capăt de segment de linie ca de P 0 , celălalt capăt de segment de linie ca de P 2 și vârful șters ca de P 1 . Aplicăm o curbă Bezier pătratică acestui set de puncte și voilà, există colțul rotunjit dorit.
Implementarea C++/Qt folosind QPainter
Clasa QPainter
nu are o modalitate de a desena curbe Bezier pătratice. Deși este destul de ușor să-l implementați de la zero urmând ecuația (1), biblioteca Qt oferă o soluție mai bună. Există o altă clasă puternică pentru desenul 2D: QPainterPath
. Clasa QPainterPath
este o colecție de linii și curbe care pot fi adăugate și utilizate ulterior cu obiectul QPainter
. Există câteva metode supraîncărcate care adaugă curbe Bezier la o colecție curentă. În special, metodele quadTo
vor adăuga curbe Bezier pătratice. Curba va începe la punctul curent QPainterPath
( P 0 ), în timp ce P 1 și P 2 trebuie să fie transmise la quadTo
ca parametri.
Metoda QPainter
drawPath
este folosită pentru a desena o colecție de linii și curbe din obiectul QPainterPath
, care trebuie dat ca parametru, cu creion și pensulă active.
Deci, să vedem declarația clasei:
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; };
Am decis să subclasez QPolygon
, astfel încât să nu fiu nevoit să implementez singur adăugarea de vârfuri și alte lucruri. Pe lângă constructor, care doar setează raza la o valoare inițială sensibilă, această clasă are alte două metode publice:
- Metoda
SetRadius
setează raza la o valoare dată. Raza este lungimea unei linii drepte (în pixeli) lângă fiecare vârf, care va fi ștearsă (sau, mai precis, nedesenată) pentru colțul rotunjit. -
GetPath
este locul unde au loc toate calculele. Va returna obiectulQPainterPath
generat din punctele poligonului adăugate laRoundedPolygon
.
Metodele din partea privată sunt doar metode auxiliare utilizate de GetPath
.
Să vedem implementarea și voi începe cu metodele private:
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); }
Nu sunt multe de explicat aici, metoda returnează distanța euclidiană dintre cele două puncte date.

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; }
Metoda GetLineStart
calculează locația punctului P 2 din ultima cifră, dacă punctele sunt adăugate la poligon în sensul acelor de ceasornic. Mai precis, va returna un punct, care este m_uiRadius
pixeli depărtare de i
--lea vârf în direcția către (i+1)
--lea vârf. La accesarea vârfului (i+1)
--lea, trebuie să ne amintim că în poligon există și un segment de linie între ultimul și primul vârf, ceea ce îl face o formă închisă, deci expresia (i+1)%count()
. Acest lucru împiedică, de asemenea, metoda să iasă din interval și accesează în schimb primul punct. Variabila fRat
deține raportul dintre rază și lungimea i
-lea a segmentului de linie. Există, de asemenea, o verificare care împiedică fRat
să aibă o valoare peste 0.5
. Dacă fRat
ar avea o valoare peste 0.5
, atunci cele două colțuri rotunjite consecutive s-ar suprapune, ceea ce ar provoca un rezultat vizual slab.
Când călătorim de la punctul P 1 la P 2 în linie dreaptă și completând 30 la sută din distanță, putem determina locația noastră folosind formula 0,7 • P 1 + 0,3 • P 2 . În general, dacă obținem o fracțiune din distanța completă, iar α = 1 indică distanța completă, locația curentă este la (1 - α) • P1 + α • P2 .
Acesta este modul în care metoda GetLineStart
determină locația punctului care este m_uiRadius
pixeli distanță de i
--lea vârf în direcția (i+1)
--lea.
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; }
Această metodă este foarte asemănătoare cu GetLineStart
. Acesta calculează locația punctului P 0 pentru (i+1)
--lea vârf, nu i
--lea. Cu alte cuvinte, dacă tragem o linie de la GetLineStart(i)
la GetLineEnd(i)
pentru fiecare i
între 0
și n-1
, unde n
este numărul de vârfuri din poligon, am obține poligonul cu vârfuri șterse și lor în apropierea împrejurimilor.
Și acum, metoda clasei 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; }
În această metodă, construim obiectul QPainterPath
. Dacă poligonul nu are cel puțin trei vârfuri, nu mai avem de-a face cu o formă 2D, iar în acest caz, metoda emite un avertisment și returnează calea goală. Când sunt disponibile suficiente puncte, trecem peste toate segmentele de linie dreaptă ale poligonului (numărul de segmente de linie este, desigur, egal cu numărul de vârfuri), calculând începutul și sfârșitul fiecărui segment de linie dreaptă dintre cele rotunjite. colțuri. Punem o linie dreaptă între aceste două puncte și o curbă Bezier pătratică între sfârșitul segmentului de linie anterior și începutul curentului, folosind locația vârfului curent ca punct de control. După buclă, trebuie să închidem traseul cu o curbă Bezier între ultimul și primul segment de linie pentru că în buclă am tras cu o linie dreaptă mai mult decât curbele Bezier.
Utilizarea și rezultatele clasei RoundedPolygon
Acum este timpul să vedem cum să folosiți această clasă în practică.
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");
Această bucată de cod sursă este destul de simplă. După inițializarea a două QPixmaps
și QPainters
lor, creăm un obiect RoundedPolygon
și îl umplem cu puncte. Painter P1
desenează poligonul obișnuit, în timp ce P2
desenează QPainterPath
cu colțuri rotunjite, generate din poligon. Ambele hărți pixeli rezultate sunt salvate în fișierele lor, iar rezultatele sunt următoarele:
Concluzie
Am văzut că generarea unei forme cu colțuri rotunjite dintr-un poligon nu este atât de dificilă până la urmă, mai ales dacă folosim un cadru de programare bun, cum ar fi Qt. Acest proces poate fi automatizat de clasa pe care am descris-o în acest blog ca o dovadă de concept. Cu toate acestea, există încă mult loc de îmbunătățire, cum ar fi:
- Faceți colțuri rotunjite numai la vârfurile selectate și deloc.
- Faceți colțuri rotunjite cu raze diferite la vârfuri diferite.
- Implementați o metodă, care generează o polilinie cu colțuri rotunjite (polilinia în terminologia Qt este la fel ca poligonul, cu excepția faptului că nu este o formă închisă, deoarece îi lipsește segmentul de linie dintre ultimul și primul vârf).
- Utilizați
RoundedPolygon
pentru a genera hărți de biți, care pot fi utilizate ca mască de widget de fundal pentru a produce widget-uri în formă nebună. - Clasa
RoundedPolygon
nu este optimizată pentru viteza de execuție; L-am lăsat așa cum este pentru a înțelege mai ușor conceptul. Optimizarea poate include calcularea multor valori intermediare la adăugarea unui nou vârf la poligon. De asemenea, atunci cândGetPath
este pe cale să returneze o referință laQPainterPath
generat, ar putea seta un steag, care indică faptul că obiectul este actualizat. Următorul apel laGetPath
ar avea ca rezultat returnarea aceluiași obiectQPainterPath
, fără a recalcula nimic. Dezvoltatorul ar trebui, totuși, să se asigure că acest steag este șters la fiecare modificare în oricare dintre nodurile poligonului, precum și pe fiecare vârf nou, ceea ce mă face să cred că clasa optimizată ar fi mai bine dezvoltată de la zero și nu derivată. de laQPolygon
. Vestea bună este că acest lucru nu este atât de dificil pe cât pare.
În total, clasa RoundedPolygon
, așa cum este, poate fi folosită ca instrument oricând dorim să adăugăm o notă de designer la GUI-ul nostru din mers, fără a pregăti hărți pixeli sau forme în avans.