Jak uzyskać zaokrąglone kształty narożników w C++ za pomocą krzywych Beziera i QPainter: przewodnik krok po kroku

Opublikowany: 2022-03-11

Wstęp

Obecny trend w projektowaniu graficznym polega na używaniu wielu zaokrąglonych rogów o różnych kształtach. Fakt ten możemy zaobserwować na wielu stronach internetowych, urządzeniach mobilnych i aplikacjach desktopowych. Najbardziej godnymi uwagi przykładami są przyciski aplikacji, które są używane do wyzwalania niektórych akcji po kliknięciu. Zamiast ściśle prostokątnego kształtu z 90-stopniowymi kątami w rogach, często rysuje się je z zaokrąglonymi rogami. Zaokrąglone rogi sprawiają, że interfejs użytkownika jest płynniejszy i ładniejszy. Nie jestem do końca przekonany o tym, ale mówi mi o tym moja koleżanka projektantka.

Zaokrąglone rogi sprawiają, że interfejs użytkownika jest płynniejszy i ładniejszy

Zaokrąglone rogi sprawiają, że interfejs użytkownika jest płynniejszy i ładniejszy.
Ćwierkać

Wizualne elementy interfejsów użytkownika są tworzone przez projektantów, a programista musi je jedynie umieścić w odpowiednich miejscach. Ale co się dzieje, gdy musimy w locie wygenerować kształt z zaokrąglonymi rogami, a nie możemy go wstępnie wczytać? Niektóre biblioteki programistyczne oferują ograniczone możliwości tworzenia predefiniowanych kształtów z zaokrąglonymi narożnikami, ale zazwyczaj nie można ich używać w bardziej skomplikowanych przypadkach. Na przykład framework Qt ma klasę QPainter , która jest używana do rysowania na wszystkich klasach pochodzących z QPaintDevice , w tym widżetach, piksmapach i obrazach. Posiada metodę o nazwie drawRoundedRect , która, jak sama nazwa wskazuje, rysuje prostokąt z zaokrąglonymi rogami. Ale jeśli potrzebujemy nieco bardziej złożonego kształtu, musimy go sami zaimplementować. Jak moglibyśmy to zrobić z wielokątem, płaskim kształtem ograniczonym grupą prostych odcinków linii? Jeśli mamy wielokąt narysowany ołówkiem na kartce papieru, to moim pierwszym pomysłem byłoby użycie gumki i usunięcie niewielkiej części linii w każdym rogu, a następnie połączenie pozostałych końców odcinka łukiem kołowym. Cały proces można zilustrować na poniższym rysunku.

Jak ręcznie tworzyć zaokrąglone rogi?

Klasa QPainter ma kilka przeciążonych metod o nazwie drawArc , które mogą rysować łuki kołowe. Wszystkie wymagają parametrów, które definiują środek i rozmiar łuku, kąt początkowy i długość łuku. O ile łatwo jest określić niezbędne wartości tych parametrów dla prostokąta nieobróconego, o tyle sprawa ma się zupełnie inaczej, gdy mamy do czynienia z bardziej złożonymi wielokątami. Dodatkowo musielibyśmy powtórzyć to obliczenie dla każdego wierzchołka wielokąta. Takie obliczenia są długotrwałym i męczącym zadaniem, a ludzie są podatni na różnego rodzaju błędy obliczeniowe w procesie. Jednak zadaniem programistów jest sprawienie, aby komputery działały dla ludzi, a nie odwrotnie. Więc tutaj pokażę, jak stworzyć prostą klasę, która potrafi zamienić złożony wielokąt w kształt z zaokrąglonymi rogami. Użytkownicy tej klasy będą musieli tylko dołączyć wierzchołki wielokąta, a klasa zajmie się resztą. Podstawowym narzędziem matematycznym, którego używam do tego zadania, jest krzywa Beziera.

krzywe Beziera

Istnieje wiele książek matematycznych i zasobów internetowych opisujących teorię krzywych Beziera, więc pokrótce przedstawię odpowiednie właściwości.

Z definicji krzywa Beziera jest krzywą między dwoma punktami na dwuwymiarowej powierzchni, której trajektoria jest zarządzana przez jeden lub więcej punktów kontrolnych. Ściśle mówiąc, krzywa pomiędzy dwoma punktami bez dodatkowych punktów kontrolnych jest również krzywą Beziera. Ponieważ jednak prowadzi to do linii prostej między dwoma punktami, nie jest to szczególnie interesujące ani użyteczne.

Kwadratowe krzywe Beziera

Kwadratowe krzywe Beziera mają jeden punkt kontrolny. Teoria mówi, że kwadratowa krzywa Beziera pomiędzy punktami P 0 i P 2 z punktem kontrolnym P 1 jest zdefiniowana następująco:

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

Więc kiedy t jest równe 0 , B(t) da P 0 , kiedy t jest równe 1 , B(t) da P 2 , ale w każdym innym przypadku wartość B(t) będzie również zależeć od P 1 . Ponieważ wyrażenie 2t(1 - t) ma maksymalną wartość w t = 0.5 , to właśnie tam wpływ P 1 na B(t) będzie największy. Możemy myśleć o P 1 jako o wyimaginowanym źródle grawitacji, które przyciąga trajektorię funkcji do siebie. Poniższy rysunek przedstawia kilka przykładów kwadratowych krzywych Beziera z ich punktami początkowymi, końcowymi i kontrolnymi.

Kwadratowe krzywe Beziera

Jak więc rozwiązać nasz problem za pomocą krzywych Beziera? Poniższy rysunek przedstawia wyjaśnienie.

Jak tworzyć zaokrąglone rogi za pomocą kodu?

Jeśli wyobrazimy sobie usuwanie wierzchołka wielokąta i krótkiej części połączonych odcinków linii w jego otoczeniu, możemy pomyśleć o końcu jednego odcinka linii jako P 0 , koniec drugiego odcinka linii jako P 2 , a usunięty wierzchołek jako P 1 . Stosujemy kwadratową krzywą Beziera do tego zestawu punktów i voila, mamy pożądany zaokrąglony róg.

Implementacja C++/Qt przy użyciu QPainter

Klasa QPainter nie ma możliwości narysowania kwadratowych krzywych Beziera. Chociaż implementacja od podstaw zgodnie z równaniem (1) jest dość łatwa, biblioteka Qt oferuje lepsze rozwiązanie. Istnieje inna potężna klasa do rysowania 2D: QPainterPath . Klasa QPainterPath to zbiór linii i krzywych, które można dodawać i używać później z obiektem QPainter . Istnieje kilka przeciążonych metod, które dodają krzywe Beziera do bieżącej kolekcji. W szczególności metody quadTo dodadzą kwadratowe krzywe Beziera. Krzywa rozpocznie się w bieżącym punkcie QPainterPath ( P 0 ), podczas gdy P 1 i P 2 muszą zostać przekazane do quadTo jako parametry.

Metoda QPainter drawPath służy do rysowania kolekcji linii i krzywych z obiektu QPainterPath , który należy podać jako parametr, przy użyciu aktywnego pióra i pędzla.

Zobaczmy więc deklarację klasy:

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

Zdecydowałem się na podklasę QPolygon , abym nie musiał samodzielnie implementować dodawania wierzchołków i innych rzeczy. Oprócz konstruktora, który po prostu ustawia promień na jakąś sensowną wartość początkową, ta klasa ma dwie inne metody publiczne:

  • Metoda SetRadius ustawia promień na zadaną wartość. Promień to długość linii prostej (w pikselach) w pobliżu każdego wierzchołka, która zostanie usunięta (a dokładniej nie narysowana) dla zaokrąglonego narożnika.
  • GetPath to miejsce, w którym odbywają się wszystkie obliczenia. Zwróci obiekt QPainterPath wygenerowany z punktów wielokąta dodanych do RoundedPolygon .

Metody z części prywatnej są tylko metodami pomocniczymi używanymi przez GetPath .

Zobaczmy implementację i zacznę od metod prywatnych:

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

Niewiele do wyjaśnienia, metoda zwraca odległość euklidesową między danymi dwoma punktami.

 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 oblicza położenie punktu P 2 od ostatniej figury, jeśli punkty są dodawane do wielokąta w kierunku zgodnym z ruchem wskazówek zegara. Dokładniej, zwróci punkt, który jest oddalony o m_uiRadius pikseli od i -tego wierzchołka w kierunku (i+1) -tego wierzchołka. Uzyskując dostęp do (i+1) -tego wierzchołka, musimy pamiętać, że w wielokącie znajduje się również odcinek między ostatnim a pierwszym wierzchołkiem, co czyni go kształtem zamkniętym, stąd wyrażenie (i+1)%count() . Zapobiega to również wyjściu metody poza zasięg i zamiast tego uzyskuje dostęp do pierwszego punktu. Zmienna fRat zawiera stosunek między promieniem a długością i -tego odcinka linii. Istnieje również kontrola, która uniemożliwia fRat uzyskanie wartości powyżej 0.5 . Jeśli fRat miałby wartość większą niż 0.5 , to dwa kolejne zaokrąglone rogi nakładałyby się na siebie, co powodowałoby słabe wyniki wizualne.

Przemieszczając się z punktu P 1 do P 2 w linii prostej i pokonując 30 procent odległości, możemy określić nasze położenie za pomocą wzoru 0,7 • P 1 + 0,3 • P 2 . Ogólnie rzecz biorąc, jeśli osiągniemy ułamek pełnej odległości, a α = 1 oznacza pełną odległość, aktualna lokalizacja to (1 - α) • P1 + α • P2 .

W ten sposób metoda GetLineStart określa położenie punktu oddalonego o m_uiRadius pikseli od i -tego wierzchołka w kierunku (i+1) -tego.

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

Ta metoda jest bardzo podobna do GetLineStart . Oblicza położenie punktu P 0 dla (i+1) -tego wierzchołka, a nie i -tego. Innymi słowy, jeśli narysujemy linię od GetLineStart(i) do GetLineEnd(i) dla każdego i pomiędzy 0 a n-1 , gdzie n jest liczbą wierzchołków wielokąta, otrzymalibyśmy wielokąt z wymazanymi wierzchołkami i ich w pobliżu okolic.

A teraz główna metoda klasy:

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

W tej metodzie budujemy obiekt QPainterPath . Jeśli wielokąt nie ma co najmniej trzech wierzchołków, nie mamy już do czynienia z kształtem 2D i w tym przypadku metoda wystawia ostrzeżenie i zwraca pustą ścieżkę. Gdy dostępna jest wystarczająca liczba punktów, wykonujemy pętlę nad wszystkimi odcinkami linii prostej wielokąta (liczba odcinków linii jest oczywiście równa liczbie wierzchołków), obliczając początek i koniec każdego odcinka linii prostej między zaokrąglonymi rogi. Umieściliśmy linię prostą między tymi dwoma punktami i kwadratową krzywą Beziera między końcem poprzedniego odcinka linii a początkiem bieżącego, używając położenia bieżącego wierzchołka jako punktu kontrolnego. Po pętli musimy zamknąć ścieżkę krzywą Beziera pomiędzy ostatnim i pierwszym odcinkiem linii, ponieważ w pętli narysowaliśmy o jedną prostą więcej niż krzywe Beziera.

Użycie i wyniki klasy RoundedPolygon

Teraz nadszedł czas, aby zobaczyć, jak wykorzystać tę klasę w praktyce.

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

Ten fragment kodu źródłowego jest dość prosty. Po zainicjowaniu dwóch QPixmaps i ich QPainters tworzymy obiekt RoundedPolygon i wypełniamy go punktami. Painter P1 rysuje wielokąt foremny, podczas gdy P2 rysuje QPainterPath z zaokrąglonymi narożnikami wygenerowanymi z wielokąta. Obie powstałe piksmapy są zapisywane w swoich plikach, a wyniki są następujące:

Zaokrąglone rogi za pomocą QPainter

Wniosek

Widzieliśmy, że generowanie kształtu z zaokrąglonymi narożnikami z wielokąta nie jest wcale takie trudne, zwłaszcza jeśli używamy dobrego frameworka programistycznego, takiego jak Qt. Ten proces może zostać zautomatyzowany przez klasę, którą opisałem na tym blogu jako dowód koncepcji. Jednak wciąż jest wiele do zrobienia, takich jak:

  • Zaokrąglaj rogi tylko na wybranych wierzchołkach, a nie na wszystkich.
  • Twórz zaokrąglone rogi o różnych promieniach na różnych wierzchołkach.
  • Zaimplementuj metodę, która generuje polilinię z zaokrąglonymi narożnikami (polilinia w terminologii Qt jest podobna do wielokąta, z wyjątkiem tego, że nie jest to kształt zamknięty, ponieważ brakuje w niej odcinka między ostatnim a pierwszym wierzchołkiem).
  • Użyj RoundedPolygon do generowania map bitowych, które można wykorzystać jako maskę widżetu tła do tworzenia widżetów o szalonych kształtach.
  • Klasa RoundedPolygon nie jest zoptymalizowana pod kątem szybkości wykonywania; Zostawiłem to tak, jak jest dla łatwiejszego zrozumienia koncepcji. Optymalizacja może obejmować obliczenie wielu wartości pośrednich po dołączeniu nowego wierzchołka do wielokąta. Ponadto, gdy GetPath ma zwrócić odwołanie do wygenerowanego QPainterPath , może ustawić flagę wskazującą, że obiekt jest aktualny. Następne wywołanie GetPath spowodowałoby zwrócenie tylko tego samego obiektu QPainterPath , bez ponownego obliczania czegokolwiek. Deweloper musiałby jednak upewnić się, że ta flaga jest wyczyszczona przy każdej zmianie dowolnego z wierzchołków wielokąta, a także przy każdym nowym wierzchołku, co skłania mnie do myślenia, że ​​zoptymalizowana klasa byłaby lepiej tworzona od zera, a nie pochodna z QPolygon . Dobrą wiadomością jest to, że nie jest to takie trudne, jak się wydaje.

Ogólnie rzecz biorąc, klasa RoundedPolygon , taka jaka jest, może być używana jako narzędzie za każdym razem, gdy chcemy dodać designerskie akcenty do naszego GUI w locie, bez wcześniejszego przygotowywania piksmap lub kształtów.

Powiązane: Jak nauczyć się języków C i C++: ostateczna lista