La
librairie Qt - 6
Dessiner avec QPainter
Nous avons entr'aperçu la classe QPainter le mois dernier, pour dessiner des zones de couleurs. Je vous propose ce mois-ci de la découvrir réellement, d'explorer ses capacités. Après cela, vous devriez pouvoir composer vos propres graphiques complexes !
3. Couleurs, pinceaux et brosses
4. Lignes, rectangles et éllipses
Qt propose une
abstraction du support du dessin, par la classe QPaintDevice
.
En réalité, une instance de QPainter
ne dessine que sur une instance de QPaintDevice
...
ou plutôt d'une classe dérivée. En particuliers,
les classes qui dérivent de QPaintDevice
sont :
QWidget
,
que nous manipulons depuis le début ; c’est de cette
manière que Qt réalise assez élégamment
sa portabilité : seule la classe QPaintDevice
contient les fonctions spécifiques de chaque système ;
QPixmap
,
pour créer des images pour l'affichage ;
QPicture
,
pour mémoriser une séquence d'action de QPainter
et les “rejouer" ultérieurement ; ces objets
peuvent être sauvegardés, et sont portables d'une
plate-forme à l'autre ;
enfin, QPrinter
,
pour dessiner... sur l'imprimante !
De cette manière,
un même code peut être utilisé pour créer
une image ou l’imprimer. Pour explorer QPainter
,
je vous propose de commencer par dessiner dans un pixmap, que nous
afficherons à l’aide d’un simple QLabel
.
Voici un programme tout simple pour nos expérimentations :
01: #include <qapplication.h> 02: #include <qpainter.h> 03: #include <qlabel.h> 04: #include <qpixmap.h> 05: #include <qpaintdevice.h> 06: void Dessiner(QPaintDevice * dev) 07: { } 08: int main (int argc, char* argv[]) 09: { QApplication app(argc, argv) ; 10: QPixmap pix(300, 200) ; 11: Dessiner(&pix) ; 12: QLabel label(0) ; 13: label.setPixmap(pix) ; 14: app.setMainWidget(&label) ; 15: label.show() ; 16: return app.exec() ; 17: }
Vous l’aurez
compris, la partie intéressante se trouvera dans la fonction
Dessiner()
. Remarquez qu’elle
prend en argument un pointeur sur un QPaintDevice
,
et non sur un QPixmap
: elle est ainsi
assez générique. Une instance de QPixmap
est créée ligne 10, les deux paramètres étant
(dans cet ordre) la largeur et la hauteur en pixels. Vous pouvez dès
maintenant compiler et exécuter ce programme :
$ g++ -I$QTDIR/include -L$QTDIR/lib -lqt -o draw_in_pix.x draw_in_pix.cpp
...et le résultat devrait ressembler à quelque chose dans le genre :
Bien étrange, n’est-ce pas ? En fait, nous affichons un pixmap qui n’a pas été initialisé : son contenu est donc parfaitement aléatoire, et ce que nous voyons n’est qu’une représentation de la zone mémoire allouée au dessin. La première chose à faire est donc sans doute de “nettoyer” l’image. Au moins deux possibilités s’offrent à nous :
utiliser la
méthode fill()
de la classe
QPixmap
, en insérant par exemple
juste après la ligne 10 :pix.fill(Qt::white)
;
pour remplir avec la couleur blanche. C’est la
méthode la plus courante.
dessiner le fond
dans la fonction Dessiner()
. Il nous
faut pour cela connaître les dimension de l’objet sur
lequel on dessine, information que ne nous donne pas directement la
classe QPaintDevice
. On peut néammoins
l’obtenir, comme nous le verrons un peu plus loin.
Mais avant de dessiner, nous avons besoins de...
La couleur, vous voyez
ce que c’est. Pour définir et éventuellement
manipuler des couleurs, la classe QColor
est à votre disposition :
vous créez
une couleur en instanciant QColor
, dont
le constructeur prend en argument les composantes RGB (rouge, vert,
bleu) de la couleur, entre 0 et 255. Vous pouvez également
donner les valeurs HSV (indice de couleur, saturation et intensité)
en ajoutant un quatrième paramètre ayant la valeur
QColor::Hsv
;
les méthodes
setRgb()
et setHsv()
vous permettent de changer la couleur, chacune prenant trois int
;
les méthodes
light()
et dark()
renvoient une couleur respectivement plus clair ou plus sombre que
celle de l’instance ;
il existe des
couleurs prédéfinies dans la classe Qt : black
(noir), white
(blanc), darkGray
(gris sombre), gray
(gris), lightGray
(gris clair), red
(rouge), green
(vert), blue
(blue), cyan
,
magenta
, yellow
(jaune), darkRed
, darkGreen
,
darkBlue
, darkCyan
,
darkMagenta
et darkYellow
(versions assombries).
D’autres méthodes
et informations sont disponibles, consultez la documentation de
QColor
pour plus de détails.
Mais la couleur ne
suffit pas. Si vous voulez dessiner des traits, il est nécessaire
de définir un pinceau. C’est le rôle de la classe
QPen
: celle-ci contient, outre la
couleur, l’épaisseur (en pixels) du trait ainsi que son
style (continu, pointillé, tiret-point-point...). Le
constructeur le plus simple de QPen
n’attend qu’une couleur. Le plus complet vous permet en
plus de définir le style du trait, le style des extrémités
du trait, et le style des jointures entre deux segments. Ces styles
sont décrits dans les énumérations Qt::PenStyle
,
Qt::PenCapStyle
et Qt::PenJoinStyle
:
consultez la documentation de la classe Qt
pour voir des exemples.
Voilà pour le
trait, mais quid du remplissage de surfaces ? Cette
fois-ci, c’est la brosse qui intervient, représentée
par la classe QBrush
. Elle sera utilisée
lorsque vous voudrez dessiner un disque, ou un rectanble plein par
exemple. Le style de brosse est cette fois un motif qui sera utilisé
pour le dessin. Vous pouvez définir votre propre motif, il
suffit de le dessiner dans un QPixmap
puis de passer l'instance à la méthode setPixmap()
de QBrush
.
Assez de discours,
passons à la pratique. Les codes qui suivent sont à
placer dans la fonction Dessiner()
de
notre programme d'expérimentation.
Nous allons commencer
par nettoyer notre planche à dessin - donc utiliser la
deuxième méthode évoquée plus haut.
Première chose à faire lorsque l'on veut dessiner,
créer une instance de QPainter
,
en lui donnant l'objet sur lequel nous voulons dessiner (dans notre
exemple, le pointeur sur QPaintDevice
passé à la fonction) :
QPainter p(dev) ;
Ensuite, il serait bon de
connaître les limites du dessin. L'information nous est donnée
par la méthode window()
, sous la
forme d'un QRect
:
QRect dim = p.window() ;
dim.width()
et dim.height()
nous donnent
respectivement la largeur et la hauteur du dessin. Nantis de ces
informations, nous pouvons initialiser le contenu de notre dessin,
par exemple en dessinant un rectangle blanc bordé de 4 pixels
en bleu. Il nous faut donc un pinceau bleu et une brosse blanche, qui
vont définir les futurs actions de l'instance de QPainter
:
p.setPen(QPen(Qt::blue, 4)) ; p.setBrush(QBrush(Qt::white)) ;
Après ces deux lignes, et jusqu'à nouvel ordre, les traits seront tous bleu et épais de 4 pixels, et toutes les surfaces seront remplies de blanc. Pour nettoyer notre dessin, il suffit de dessiner un rectangle le couvrant totalement :
p.drawRect(dim) ;
Nous donnons ici le QRect
contenant les dimensions du rectangle. Il est également
possible de dessiner un rectangle en passant quatre entiers,
représentants (dans l'ordre) les coordonnées (x,y)
du coin supérieur gauche, la largeur (taille sur l'axe x)
et la hauteur (taille sur l'axe y).
Traçons maintenant une ligne verte du coin supérieur gauche vers le coin inférieur droit, d'épaisseur 1 :
p.setPen(QPen(Qt::green, 1)) ; p.drawLine(dim.topLeft(), dim.bottomRight()) ;
Enfin, nous voulons une
éllipse rouge "vide", c'est-à-dire sans
remplissage. Il faut pour cela indiquer une brosse ayant le style
NoBrush
, de cette façon :
p.setPen(QPen(Qt::red, 2)) ; p.setBrush(Qt::NoBrush) ; p.drawEllipse(50, 50, 200, 100) ;
La méthode
drawEllipse()
attend soit un QRect
,
soit quatre entiers ayant la même signification que ceux donnés
pour le rectangle. L'éllipse que nous dessinons sera donc
comprise dans un rectangle dont le coin supérieur gauche est
aux coordonnées (50, 50), ayant une largeur de 200 pixels et
une hauteur de 100 pixels. D'une manière générale,
si nous passons les valeurs (x, y, w, h)
à drawEllipse()
, l'éllipse
sera dessinée ainsi :
Compilez le programme, exécutez-le, voyez le résultat :
Ce qui est bien le
résultat attendu. La classe QPainter
contient de nombreuses autres fonctions de dessin, du polygône
jusqu'au pixmap (oui, vous pouvez afficher directement un pixmap) en
passant par le texte. Je vous laisse découvrir toutes ces
fonctions en consultant la documentation.
Il est fréquent
que le système de coordonnées « normal »,
un repère orthonormé dont l'origine est dans le coin
supérieur gauche, avec les x allants de gauche à
droite et les y de haut en bas, ne soit pas satisfaisant. Qt
nous permet de définir notre propre système de
coordonnées, soit par le biais de méthodes de QPainter
,
soit en utilisant une matrice de transformation. Les deux solutions
permettent d'obtenir les mêmes effets, mais l'utilisation de la
matrice est sensiblement plus efficace si vos transformations ne sont
pas triviales (i.e. composées de plusieurs
transformations simples).
Pour illustrer cela, nous allons dessiner une horloge, et pour changer dans un widget. Notre programme prend l'allure suivante, dans un premier temps :
01: /* omission des #include */ 02: void Dessiner(QPaintDevice * dev) 03: { /* omission du dessin */ } 04: class wMyWidget : public QWidget 05: { public: 06: wMyWidget(QWidget * parent) : QWidget(parent) 07: { } 08: } ; 09: int main (int argc, char* argv[]) 10: { 11: QApplication app(argc, argv) ; 12: wMyWidget w(0) ; 13: w.resize(300, 200) ; 14: Dessiner(&w) ; 15: app.setMainWidget(&w) ; 16: w.show() ; 17: return app.exec() ; 18: }
Apparemment, rien que de
très classique. La classe wMyWidget
paraît même bien saugrenue, n'est-ce pas ? Elle ne
fait pour ainsi dire rien. Placez dans la fonction Dessiner()
ce que nous avons fait jusqu'à présent, compilez,
exécutez... Surprise ! Un simple rectangle gris, rien ne
se dessine !
Cela provient de la manière dont Qt et le système graphique dessinent leurs éléments. Les instructions de dessin sont bien exécutées, mais le résultat du dessin n'est mémorisé nul part. Lorsque le système graphique demande au widget de se rafraîchir - par exemple, pour son premier affichage, ou s'il a été occulté par une autre fenêtre - ce dernier fait ce qu'il peut. Et par défaut, ce qu'il peut, c'est se remplir d'un gris uniforme.
Pour que nos dessins
soient pris en compte, il faudrait que les instructions de dessin
soient exécutées à chaque fois que le widget
doit se rafraîchir. La classe QWidget
possède une méthode virtuelle (et protégée)
pour cela : paintEvent()
. C'est
cette méthode qui est appellée chaque fois que le
widget doit se redessiner, totalement ou partiellement. Insérez
juste après la ligne 7 :
protected: virtual void paintEvent(QPaintEvent*) { Dessiner(this) ; }
Et cela fonctionne ! Ces problèmes de rafraîchissement sont une petite subtilité à garder à l'esprit.
Voyons maintenant
comment dessiner notre horloge. Effacez tout ce qui se trouve dans la
fonction Dessiner()
. Pour commencer,
nous voulons des chiffres romains, créons un tableau des douze
premiers :
static char* Heures[] = {"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"} ;
Ensuite, pour nous simplifier la vie et les calculs, il serait bon que l'origine des coordonnées coïncide avec le centre de notre horloge. Si nous voulons que ce centre soit aussi celui de notre widget, entrons :
QPainter p(dev) ; QRect dim = p.window() ; p.translate(dim.width()/2, dim.height()/2) ;
La dernière ligne effectue une translation de l'origine des coordonnées. Nous la placons ici au centre du widget. Pour visualiser les axes x et y, ajoutez ces deux lignes :
p.drawLine(0, 0, 100, 0) ; p.drawLine(0, 0, 0, 50) ;
Compilez, exécutez : l'origine est bien au centre, l'axe x (le trait le plus long) est horizontale vers la gauche, l'axe y est vertical vers le bas.
Dessinons maintenant nos heures. Une première solution serait de se replonger dans un vieux manuel de trigonométrie, et de dégainer les sinus et autres cosinus pour calculer les coordonnées des textes à afficher. Quelque chose dans ce goût-là :
01: p.setFont(QFont("Times", 14, QFont::Bold)) ; 02: int diametre = dim.width() < dim.height() ? 03: (dim.width()-30)/2 : (dim.height()-30)/2 ; 04: double da = 3.1415926536 / 6.0 ; 05: double angle = -2.0*da ; 06: for (int i=0;i<12;i++) 07: { 08: int x = diametre * cos(angle) ; 09: int y = diametre * sin(angle) ; 10: p.drawText(x, y, Heures[i]) ; 11: angle += da ; 12: }
La première ligne
sélectionne une police de caractères, la suivante
détermine un diamètre pour l'horloge en laissant une
petite marge. Ensuite apparaissent les calculs pour afficher les
chiffres à leur place (les lignes 8 et 9 provoquant un
avertissement du compilateur), notamment le calcul de l'intervalle
angulaire entre deux nombres (en radians) et stocké dans la
variable da
. Le résultat de ce
programme est le suivant :
Voyons une version
utilisant les possibilités de rotation de QPainter
:
1: int da = 360 / 12 ; 2: int angle = -2*da ; 3: p.rotate(angle) ; 4: for (int i=0;i<12;i++) 5: { p.drawText(diametre, 0, Heures[i]) ; 6: p.rotate(da) ; 7: }
Premier changement : les angles sont cette fois donnés en degrés. Deuxième changement : plutôt que de calculer les coordonnées du texte, nous faisons tourner tout le système de coordonnées : lignes 3 et 6. Comme c'est tout le repère qui change de direction, les coordonnées du texte sont invariantes (ligne 5). La Lune tourne autour de la Terre, mais la Terre tourne aussi autour de la Lune... tout est relatif ! Voici ce que donne ce programme :
Vous voyez le problème ? J'ai fait figuré les axes x (en rouge) et y (en bleu) correspondants au nombre IV. Lorsqu'un texte est affiché, il s'étend vers les x croissants et les y décroissants (vers le "haut"). Lorsque l'on fait tourner tout le repère, l'affichage des textes tourne aussi. D'où cet effet. Pour le corriger, il faudrait effectuer une rotation inverse, mais centrée sur le texte lui-même. Cela peut être fait assez simplement ainsi :
1: for (int i=0;i<12;i++) 2: { p.save() ; 3: p.translate(diametre, 0) ; 4: p.rotate(-angle) ; 5: p.drawText(0, 0, Heures[i]) ; 6: p.restore() ; 7: angle += da ; 8: p.rotate(da) ; 9: }
ligne 2 et 6 :
la méthode QPainter::save()
permet de sauvegarder l'état courant de l'instance de
QPainter
, aussi bien les pinceaux et
brosses que les tranformations. Vous pouvez ensuite altérer
cet état, et le retrouver avec restore()
.
Le fonctionnement est celui d'une pile : vous pouvez appeler
plusieurs fois save()
et restore()
,
pour sauvegarder et restaurer des états intermédiaires.
ligne 3 : déplacement de l'origine du repère à l'endroit où l'on veut afficher le texte. Ce déplacement est relatif à l'origine courante.
ligne 4 : la rotation inverse, pour remettre les axes d'applomb.
Ce qui donne ce résultat :
Vous retrouvez les axes précédents, ainsi que les axes « redressés ». Cette fois les nombres sont affichés correctement.
Nous avons utilisé
des méthodes de QPainter
pour
effectuer nos transformations. L'autre méthode consiste à
utiliser une instance de QWMatrix
(déclarée dans <qwmatrix.h>
),
définir nos transformations, puis attribuer cette matrice au
QPainter
. Placez ceci dans la fonction
Dessiner()
:
01: QPainter p(dev) ; 02: QRect dim = p.window() ; 03: QWMatrix m ; 04: p.setWorldMatrix(m) ; 05: p.setPen(QPen(Qt::green, 2)) ; 06: p.drawRect(0, 0, 30, 30) ; 07: p.setPen(QPen(Qt::red, 2)) ; 08: p.drawLine(-500, 0, 500, 0) ; 09: p.drawLine(30, 0, 20, 7) ; 10: p.drawLine(30, 0, 20, -7) ; 11: p.setPen(QPen(Qt::blue, 2)) ; 12: p.drawLine(0, -500, 0, 500) ; 13: p.drawLine(0, 30, 7, 20) ; 14: p.drawLine(0, 30, -7, 20) ;
Cela permet de visualiser
les axes, x en rouge et y en bleu. Remarquez la ligne 4 : c'est là
que nous donnons la matrice de transformation au QPainter
.
Ici, cela ne fait véritablement rien : la matrice créée
ligne 3 est la matrice identité, qui ne change pas le système.
Mais insérez successivement les lignes suivantes juste avant
la ligne 4, en les ajoutant l'une après l'autre :
// 1. translate l'origine m.translate(50, 50) ; // 2. Mise à l'échelle sur y et renversement m.scale(1.0, -1.5) ; // 3. Effet de cisaillement sur x m.shear(0.5, 0.0) ; // 4. Rotation de 45° (sens horaire) m.rotate(-45) ;
Les résultats successifs de ces manipulations successives apparaissent ici :
0. 1. 2. 3. 4.
La classe QPainter
permet donc de réaliser des dessins complexes sur divers
supports. Comme toujours, consultez la documentation de cette classe
pour plus d'information, ceci n'est qu'un apperçu succint. Le
mois prochain, nous verrons comment l'utilisation du QPainter
peut s'avérer intéressante pour personnaliser certains
affichages, comme le contenu des listes.
Yves Bailly
http://www.kafka-fr.net
Article publié dans LinuxMagazine 42 de septembre 2002