Etape 7 – Style de dessin

1 Introduction

Le but principal de cette étape est d'écrire des classes permettant de paramétrer plusieurs aspects du dessin des cartes, dessin qui sera fait à l'étape suivante. En plus de ces classes, une méthode permettant de spécifier facilement un changement de repère est à ajouter à une classe déjà écrite précédemment.

1.1 Dessin des cartes

En simplifiant un peu les choses, on peut dire qu'une carte est dessinée en empilant un certain nombre de couches, chacune d'entre-elles représentant un seul type d'éléments. Par exemple, une carte très simple des lacs et des bâtiments s'obtient en empilant les deux couches suivantes :

  1. une couche des lacs, sur laquelle tous les polygones représentant un lac sont dessinés en bleu,
  2. une couche des bâtiments, sur laquelle tous les polygones représentant un bâtiment sont dessinés en noir.

La seconde couche est placée sur la première, de manière à ce que les éventuels bâtiments construits au-dessus d'un lac (p.ex. un hangar à bateau) apparaissent sur la carte.

Selon le même principe, mais en combinant passablement plus de couches, on peut obtenir des cartes plus détaillées comme celle d'Interlaken présentée dans le document d'introduction au projet.

La description simplifiée ci-dessus illustre le fait que le dessin d'une couche implique deux opérations successives :

  1. dans un premier temps, la carte est filtrée pour ne garder qu'un sous-ensemble de ses éléments, p.ex. les bâtiments,
  2. dans un deuxième temps, les éléments de cette carte filtrée sont dessinés au moyen d'un style donné, spécifiant p.ex. de dessiner tous les polygones en noir.

Les classes à réaliser pour cette étape ont pour but de paramétrer ces deux opérations de filtrage et de dessin, décrites plus en détails ci-après.

1.2 Filtrage des données

Le filtrage des données d'une carte se fait uniquement en fonction des attributs attachés aux entités géométriques qui la composent, puisque c'est grâce à eux que les différents types d'éléments sont distingués dans le modèle OpenStreetMap.

Par exemple, une carte des bâtiments pourrait s'obtenir en ne gardant que les entités possédant l'attribut building, indépendemment de la valeur qui lui est associée.

1.3 Style de dessin

Chacune des deux entités géométriques composant nos cartes — polygones et polylignes — peuvent être dessinés de différentes manières, que nous appellerons des styles (de dessin). Par exemple, l'épaisseur du trait utilisé pour dessiner une polyligne fait partie du style de dessin des polylignes.

Style des polygones

Le dessin d'un polygone est très simple puisqu'il consiste simplement à remplir l'intérieur du polygone — trous exclus — au moyen d'une couleur, comme nous le verrons lors de l'étape suivante. Dès lors, le style de dessin d'un polygone consiste uniquement en cette couleur de remplissage.

Style des polylignes

Le dessin d'une polyligne est plus complexe que celui d'un polygone car, au lieu d'un unique paramètre, il en possède cinq dans notre modèle :

  1. la largeur du trait,
  2. la couleur du trait,
  3. la terminaison des lignes (line cap en anglais),
  4. la jointure des segments (line join en anglais),
  5. l'alternance des sections opaques et transparentes, pour le dessin en traitillés (dashing pattern en anglais).

Les trois derniers paramètres méritent une explication plus détaillée.

Lors du dessin d'une polyligne ouverte, il existe plusieurs manières de la terminer, c-à-d de dessiner ses deux extrémités. La plupart des modèles graphiques actuels en proposent trois, que nous reprenons simplement ici, et qui sont :

  1. de manière abrupte (butt en anglais),
  2. au moyen d'un demi-cercle (round en anglais),
  3. au moyen d'un demi-carré (square en anglais).

Ces trois terminaisons sont illustrées dans la figure ci-dessous sur une seule et même polyligne constituée d'un unique segment vertical allant d'une ligne traitillée à l'autre. Cette polyligne est dessinée ici avec un trait large pour que la différence de terminaison soit bien visible.

Sorry, your browser does not support SVG.

Figure 1 : Terminaison des lignes (de gauche à doite : butt, round et square)

Au même titre qu'il existe plusieurs manières de terminer les polylignes, il existe plusieurs manières de joindre leurs segments. Là aussi, trois variantes sont généralement offertes par les modèles graphiques actuels, que nous reprenons ici et qui sont :

  1. par un trait (bevel en anglais),
  2. par prologation des côtés (miter en anglais),
  3. par un arc de cercle (round en anglais),

Ces trois jointure sont illustrés dans la figure ci-dessous sur une seule et même polyligne constituée de deux segments formant un V inversé dont le sommet se situe sur la ligne traitillée. Cette polyligne est encore une fois dessinée avec un trait large pour que la différence entre les types de jointures soit bien visible.

Sorry, your browser does not support SVG.

Figure 2 : Jointure des segments (de gauche à droite : bevel, miter et round)

Le dernier attribut spécifie quant à lui l'alternance des sections opaques et transparentes de la ligne et permet le dessin de lignes en traitillés. Cet attribut est une séquence de nombres réels donnant la longueur des sections opaques et transparentes, et elle est utilisée de manière circulaire. Par exemple, la séquence [2, 1] spécifie qu'une section opaque de 2 unités doit être dessinée, suivie d'une section transparente (donc invisible) de 1 unité, puis à nouveau d'une section pleine de 2 unités, et ainsi de suite. Par convention, la séquence vide [] correspond à un trait opaque continu.

La figure ci-dessous présente une même polyligne dessinée avec différentes séquences d'alternance, dont la valeur est donnée en-dessus de la ligne.

Sorry, your browser does not support SVG.

Figure 3 : Alternances de sections opaques et transparentes

1.4 Changement de repère

Lors de l'écriture du code de dessin, il sera à plusieurs reprises nécessaire d'effectuer un changement de repère. C'est-à-dire qu'un point dont les coordonnées sont exprimées dans un premier repère devra être transformé pour que ses coordonnées soient exprimées dans un second repère.

Les repères entre lesquels de telles transformations devront être faites seront toujours alignés, c-à-d que les axes des abscisses des deux repères seront parallèles, et il en ira de même des axes des ordonnées. Dans ce cas, la transformation est une combinaison d'une translation et d'une dilatation. Dès lors, quatre valeurs suffisent à spécifier un tel changement de repère : les composantes x et y de la translation, et les composantes x et y de la dilatation.

Ces quatre valeurs, qui déterminent le passage d'un repère à l'autre, peuvent bien entendu être spécifiées directement. Mais souvent il est plus simple de pouvoir spécifier un tel passage en donnant simplement deux paires de coordonnées correspondant à deux points dont la position est exprimée dans le premier et le second repère.

Par exemple, admettons que l'on désire effectuer un changement depuis le repère bleu vers le repère rouge de la figure ci-dessous. Admettons de plus que les coordonnées du point P1 sont (1, –1) dans le repère bleu et (5, 4) dans le repère rouge, tandis que les coordonnées du point P2 sont (–1.5, 1) dans le repère bleu et (0, 0) dans le repère rouge.

Sorry, your browser does not support SVG.

Figure 4 : Changement de repères alignés

Dans ce cas, le changement de repère, qui permet de déterminer les coordonnées \((x_r, y_r)\) d'un point dans le repère rouge en fonction de ses coordonnées \((x_b, y_b)\) dans le repère bleu, est la fonction suivante :

\begin{displaymath} (x_r, y_r) = (2x_b + 3, -2y_b + 2) \end{displaymath}

Le dernier but de cette étape est de fournir une méthode permettant de déterminer un changement de repère spécifié ainsi par deux points dont les coordonnées sont connues dans le repère de départ et celui d'arrivée.

2 Mise en œuvre Java

A partir de cette étape, la première après le rendu intermédiaire, la mise en œuvre Java sera moins guidée que précédemment. En particulier, l'interface publique des classes ne sera plus décrit avec autant de détails qu'auparavant.

Il est conseillé — mais plus obligatoire — de placer la totalité des classes et interfaces de cette étape dans un paquetage dédié au dessin et nommé ch.epfl.imhof.painting.

Les noms des classes, interfaces et méthodes donnés ci-dessous ne sont également que des propositions, libre à vous d'en choisir d'autres. Sachez néanmoins que lors d'une étape ultérieure nous vous fournirons un fichier Java décrivant le style des cartes du projet. Ce fichier utilisera les noms donnés dans cette étape et les suivantes, donc leur adoption facilitera grandement votre travail.

2.1 Couleur

La première classe à définir pour cette étape représente une couleur, décrite par ses trois composantes rouge, verte et bleue. Chacune de ces composantes est un nombre réel compris entre 0 et 1 (inclus).

Cette classe, immuable et nommée p.ex. Color, doit offrir sous forme de champs statiques des constantes pour les trois couleurs primaires (rouge, vert et bleu) ainsi que pour le noir et le blanc.

La construction d'instances de cette classe ne se fait qu'à travers l'une des trois méthodes statiques de construction suivantes, le constructeur étant privé :

  • une méthode, nommée p.ex. gray, prenant en argument une seule valeur comprise entre 0 et 1 et construisant la couleur grise dont les trois composantes sont égales à cette valeur,
  • une méthode, nommée p.ex. rgb, prenant en arguments les trois composantes individuelles, comprises entre 0 et 1, et construisant la couleur correspondantes,
  • une méthode, nommée p.ex. aussi rgb, prenant en arguments les trois composantes individuelles « empaquetées » dans un entier de type int, la composante rouge se trouvant dans les bits 23 à 16, la composante verte dans les bits 15 à 8 et la composante bleue dans les bits 7 à 0.

Chacune de ces méthodes lève une exception lorsque l'un de ces paramètres n'est pas dans sa plage de validité.

Notez que si vous n'êtes pas encore à l'aise avec les opérateurs travaillant sur les bits, vous pouvez attendre que nous les ayons vus au cours pour définir la dernière de ces méthodes, qui ne sera pas utile avant plusieurs semaines.

Finalement, la classe des couleurs offre des méthodes publiques donnant accès aux trois composantes de la couleur ainsi que des méthodes permettant de :

Notez que cette classe est très similaire à la classe ColorRGB que nous vous avons fournie avec la série 5 et dont vous pouvez bien entendu vous inspirer.

2.2 Style de ligne

La seconde classe à définir pour cette étape, également immuable et nommée p.ex. LineStyle, regroupe tous les paramètres de style utiles au dessin d'une ligne, dont la liste est donnée plus haut.

Bien entendu, les paramètres définissant la terminaison des polylignes et la jointure des segments se représentent idéalement au moyen d'énumérations. Quant à l'épaisseur du trait et à la séquence d'alternance des segments, il est suggéré de leur attribuer le type float et float[] respectivement, car des valeurs non entières sont admises. Il est bien entendu aussi possible d'utiliser le type double à la place de float, mais ce dernier est utilisé par l'API Java et il est plus simple de l'utiliser également ici.

La classe des styles de ligne est équipées des deux constructeurs suivants :

  1. le constructeur principal, prenant en arguments la totalité des cinq paramètres de style décrits plus haut, et levant une exception si la largeur du trait est négative ou si l'un des éléments de la séquence d'alternance des segments est négatif ou nul,
  2. un constructeur secondaire qui ne prend en arguments que la largeur et la couleur du trait, et qui appelle le constructeur principal en lui passant des valeurs par défaut pour les autres paramètres, qui sont :
    • butt pour la terminaison des lignes,
    • miter pour la jointure des segments,
    • la séquence vide pour l'alternance des segments opaques et transparents.

En plus de ces constructeurs, cette classe fournit des accesseurs permettant d'obtenir la valeur de chacun de ses attributs ainsi que des méthodes permettant d'obtenir un style dérivé d'un autre. Ainsi, pour chacun des cinq paramètres, elle offre une méthode permettant d'obtenir un style identique à celui auquel on l'applique, sauf pour le paramètre en question, dont la valeur est passée en argument.

Par exemple, la méthode qui pourrait être nommée withWidth prend en argument une largeur de trait et retourne un style identique à celui auquel on l'applique, si ce n'est que sa largeur de trait est celle passée en argument. L'extrait de programme ci-dessous illustre son utilisation pour obtenir, à partir d'un style ls1 existant, un second style ls2 en tout point identique au premier, si ce n'est que la largeur de trait vaut 4 plutôt que 1 :

LineStyle ls1 = new LineStyle(1, Color.RED);
LineStyle ls2 = ls1.withWidth(4);

Des méthodes identiques, nommées de manière similaire, existent pour les quatre autres attributs du style.

2.3 Filtres

Le but d'un filtre est de déterminer, étant donnée une entité attribuée, si elle doit être gardée ou non. Dès lors, un filtre se représente assez naturellement en Java au moyen du type Predicate<Attributed<?>>, où Predicate est l'interface fonctionnelle fournie par la bibliothèque Java.

Pour faciliter la construction de filtres, définissez une classe finale et non instanciable (c-à-d dont le constructeur est privé), nommée p.ex. Filters et offrant les trois méthodes statiques suivantes :

  • une méthode nommée p.ex. tagged, prenant un nom d'attribut en argument et retournant un prédicat qui n'est vrai que si la valeur attribuée à laquelle on l'applique possède un attribut portant ce nom, indépendemment de sa valeur,
  • une méthode nommée comme la précédente mais prenant, en plus du nom d'attribut, un nombre variable mais supérieur à 0 de valeurs d'arguments, et retournant un prédicat qui n'est vrai que si la valeur attribuée à laquelle on l'applique possède un attribut portant le nom donné et si de plus la valeur associée à cet attribut fait partie de celles données,
  • une méthode nommée p.ex. onLayer, prenant un numéro (entier) de couche en argument et retournant un prédicat qui n'est vrai que lorsqu'on l'applique à une entité attribuée appartenant cette couche.

Pour comprendre l'intérêt et le fonctionnement de la dernière méthode, il faut savoir qu'OpenStreetMap utilise un attribut nommé layer pour organiser les éléments en couches et déterminer l'ordre vertical d'objets qui se chevauchent, p.ex. deux routes dont l'une enjambe l'autre au moyen d'un pont. La valeur de l'attribut layer est un entier compris entre –5 et 5, qui est 0 par défaut — c-à-d que toute entité ne possédant pas cet attribut (ou le possédant mais avec une valeur non entière) se comporte comme si elle le possédait et sa valeur était 0.

(Attention, cette notion de couche n'est pas tout à fait la même que celle mentionnée dans l'introduction. La différence deviendra claire ultérieurement.)

L'utilisation de la classe décrite ci-dessus est illustrée dans l'extrait de programme ci-dessous, qui crée trois filtres. Le premier n'est vrai que pour les entités possédant l'attribut natural avec la valeur water (les lacs, principalement), le second pour toutes les entités possédant l'attribut building avec une valeur quelconque, et le troisième pour toutes les entités situées sur la couche 0.

Predicate<Attributed<?>> isLake =
    Filters.tagged("natural", "water");
Predicate<Attributed<?>> isBuilding =
    Filters.tagged("building");
Predicate<Attributed<?>> onLayer0 =
    Filters.onLayer(0);

2.4 Changement de repère

Un changement de repère a pour but de transformer un point exprimé dans le système de départ en un point exprimé dans le système d'arrivée. En d'autres termes, il s'agit d'une fonction d'un point vers un autre, qui se représente naturellement en Java avec le type Function<Point, Point>, où Function est l'interface fonctionnelle fournie dans la bibliothèque Java, et Point est la classe des points écrite à l'étape 1.

Comme cela a été mentionné plus haut, il sera nécessaire à plusieurs reprises dans le code de dessin de calculer un changement de repères alignés, c-à-d dont les axes sont parallèles deux à deux. Pour faciliter cette tâche, ajoutez une méthode statique à la classe Point réalisée lors de l'étape 1 et qui, étant donnés deux paires de points (donc un total de quatre points) retourne le changement de repère correspondant.

En admettant que cette méthode s'appelle alignedCoordinateChange, elle devrait pouvoir s'utiliser ainsi pour déterminer le changement de repère illustré à la figure 4 :

Function<Point, Point> blueToRed =
    Point.alignedCoordinateChange(new Point(1, -1),
                                  new Point(5, 4),
                                  new Point(-1.5, 1),
                                  new Point(0, 0));
blueToRed.apply(new Point(0, 0)); // retourne le point (3,2)

Cette méthode lève une exception si les deux points sont situés sur une même ligne horizontale ou verticale, car il n'est alors pas possible de déterminer le changement de repère.

2.5 Tests

A partir de cette étape, étant donné que vous n'avez plus à nommer les entités que vous définissez comme nous vous le demandons, nous ne vous fournirons plus de fichiers de vérification de noms, ni bien entendu de tests.

A vous d'écrire des tests pour les classes et méthodes ci-dessus, si vous le désirez, ce qui est en tout cas fortement conseillé pour la méthode de changement de repère. En effet, elle n'est pas très facile à écrire et si elle est erronée cela produira plus tard des problèmes dont l'origine sera probablement difficile à diagnostiquer.

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes et interfaces représentant les couleurs, les styles de dessin de polylignes, la classe permettant de créer facilement des filtres et la méthode permettant de calculer un changement de repère, selon les spécifications données plus haut,
  • documenter la totalité des entités publiques que vous avez définies.

Aucun rendu n'est à faire pour cette étape.