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 !

1. Dessiner, mais sur quoi ?

2. Dessiner dans un pixmap

3. Couleurs, pinceaux et brosses

4. Lignes, rectangles et éllipses

5. Transformations du système de coordonnées

6. Matrice de transformation

7. Conclusion

codes sources

1. Dessiner, mais sur quoi ?

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 :

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.

2. Dessiner dans un pixmap

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 :

Mais avant de dessiner, nous avons besoins de...

3. Couleurs, pinceaux et brosses

La couleur, vous voyez ce que c’est. Pour définir et éventuellement manipuler des couleurs, la classe QColor est à votre disposition :

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.

4. Lignes, rectangles et éllipses

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.

5. Transformations du système de coordonnées

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

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.

6. Matrice de transformation

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.


7. Conclusion

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