Aller au contenu

Atelier : dessiner avec des objets

Séance 4 · ~5 périodes

Vous allez écrire une petite bibliothèque de formes géométriques, puis un moteur de rendu qui transforme ces objets en image SVG. Les tests sont fournis. Ils décrivent exactement ce que votre code doit faire, et chaque étape vous donne un indice.

À la fin de ce chapitre, vous serez capables de :

  1. construire une hiérarchie de classes à partir d'un contrat de tests ;
  2. choisir entre hériter d'une classe et en posséder une, et justifier ce choix ;
  3. implémenter une interface pour produire deux formats de sortie à partir des mêmes objets ;
  4. produire un fichier SVG à partir d'objets PHP et l'ouvrir dans un navigateur.

Le dépôt : https://github.com/opmvpc/formes-26. Forkez-le maintenant.

1. Le code qui sent mauvais

Cinq minutes, en binôme. Voici un extrait d'un ancien projet de départ. Ce code s'exécute sans erreur. Il contient pourtant un défaut de conception, ce qu'on appelle un code qui sent mauvais (code smell). Lequel ?

class SVGRenderer implements Renderer
{
    public function render(): string { /* fabrique le texte SVG */ }
    public function save(string $path): void { /* écrit le .svg */ }
}

class JPGRenderer extends SVGRenderer implements Renderer
{
    public function save(string $path): void { /* écrit le .jpg */ }
}

À gauche, JpgRenderer hérite de SvgRenderer et l'objet contient tout le SVG ; à droite, JpgRenderer possède un SvgRenderer et lui demande le texte

La question à se poser devant tout extends : la phrase « un X est un Y » est-elle vraie en dehors du code ? Éviter de recopier trois méthodes ne suffit pas à justifier un héritage.

2. Pourquoi ces objets-là

Le dessin vectoriel se prête bien à la POO. Chaque notion des trois dernières séances y sert :

  • une valeur qui ne change plus : un Point, deux coordonnées, readonly ;
  • un héritage vrai : un cercle est une forme, un rectangle est une forme ;
  • une classe abstraite utile : toutes les formes ont une aire, chacune la calcule autrement ;
  • une composition : un Canvas contient des formes, il n'en est pas une ;
  • une interface : Renderer, deux façons de dessiner les mêmes objets.

Et le résultat se regarde : à la fin, vous ouvrez votre dessin dans un navigateur.

3. Le SVG en cinq balises

Un SVG est un fichier texte. Vous n'allez pas manipuler des pixels. Vous allez écrire des balises, comme en HTML.

<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" viewBox="0 0 500 500">
  <rect x="0" y="0" width="500" height="500" fill="#FFFFFF" />
  <line x1="0" y1="0" x2="500" y2="500" stroke="#0000FF" stroke-width="1" />
  <circle cx="250" cy="250" r="80" fill="#00FF00" />
  <rect x="50" y="50" width="250" height="400" fill="#FF0000" />
  <polygon points="250,0 300,200 500,200" fill="#FFFF00" />
</svg>

Ces cinq balises suffisent pour tout l'atelier.

Le repère SVG : origine en haut à gauche, axe Y vers le bas, et les attributs d'un rectangle et d'un cercle

Attention

L'origine (0, 0) est en haut à gauche et l'axe Y descend. Une figure placée avec les coordonnées d'un repère mathématique apparaît donc retournée.

4. Le dépôt et les commandes

git clone https://github.com/<votre-compte>/formes-26.git
cd formes-26
composer install
composer test

Tout est rouge au départ. Les fichiers de src/ sont des squelettes : les classes et les signatures existent, et chaque corps de méthode lève LogicException('À implémenter'). Chacun commence déjà par declare(strict_types=1). Les tests de tests/ sont complets et ne se modifient pas. Ils sont le cahier des charges.

Une étape à la fois :

composer test:etape-1
composer test:etape-2
composer test:etape-3
composer test:etape-4
composer test:bonus

Envoyez votre travail sur GitHub à la fin de chaque étape. L'onglet Actions de votre fork lance une vérification automatique par étape et affiche quatre coches, vertes ou rouges.

Astuce

Lisez la ligne Failed asserting that… avant de modifier votre code. Elle donne la valeur attendue et la valeur obtenue.

5. Étape 1 : Point et Line

Fourni : src/Point.php et src/Line.php, avec toutes les signatures et des indices en commentaire. À écrire : les deux constructeurs et les corps de méthode.

@startuml
class Point {
  +x: float
  +y: float
  +translate(dx: float, dy: float): Point
  +distanceTo(other: Point): float
  +equals(other: Point): bool
  +__toString(): string
}
class Line {
  -color: string
  +start(): Point
  +end(): Point
  +color(): string
  +length(): float
}
Line --> "1" Point : start
Line --> "1" Point : end
note right of Point : Valeur immuable, marquée readonly.
@enduml

Un point est une valeur, pas un objet qui évolue. Un héros change d'état, il perd des points de vie. Un point, non. (3, 4) reste (3, 4). On dit qu'il est immuable : il ne change plus après sa construction. readonly fait respecter la règle par PHP.

final readonly class Point implements \Stringable
{
    public function __construct(
        public float $x,
        public float $y,
    ) {}

    public function translate(float $dx, float $dy): self
    {
        return new self($this->x + $dx, $this->y + $dy);
    }
}

translate() ne déplace rien. Elle renvoie un nouveau point, et l'original garde ses coordonnées.

$depart = new Point(1, 1);
$arrivee = $depart->translate(4, -1);

echo $depart;    // (1, 1)
echo $arrivee;   // (5, 0)

Toute écriture dans un point déjà construit est refusée :

$depart->x = 10;
// Error: Cannot modify readonly property Shapes\Point::$x

Ce que les tests attendent

Classe Comportement vérifié
Point new Point(3, 4) donne 3.0 et 4.0. Une écriture lève Error. translate(4, -1) sur (1, 1) rend (5, 0) sans toucher l'original. distanceTo() vaut 5.0 entre (0, 0) et (3, 4). equals() compare les coordonnées. (string) new Point(10, -3) vaut (10, -3).
Line start() et end() rendent les objets reçus. color() vaut #000000 par défaut, et #ff0000 devient #FF0000. length() vaut 5.0 entre (1, 1) et (4, 5), et 0.0 si les deux points sont confondus.

Indice : ne recopiez pas la formule de la distance dans Line. Point::distanceTo() la connaît déjà.

Vérification : composer test:etape-1, 12 tests verts.

Question : deux Point(2, 7) distincts sont égaux pour equals(), mais === répond false. Pourquoi ?

6. Étape 2 : Shape, Circle, Rectangle

Fourni : src/Shape.php avec sa constante et sa méthode abstraite, src/Circle.php et src/Rectangle.php avec leurs signatures. À écrire : les trois constructeurs, les accesseurs et les calculs d'aire.

@startuml
abstract class Shape {
  #color: string
  +color(): string
  +{abstract} area(): float
}
class Line {
  +start(): Point
  +end(): Point
  +length(): float
  +area(): float
}
class Circle {
  -center: Point
  -radius: float
  +radius(): float
  +diameter(): float
  +area(): float
}
class Rectangle {
  -origin: Point
  -width: float
  -height: float
  +perimeter(): float
  +area(): float
}
Shape <|-- Line
Shape <|-- Circle
Shape <|-- Rectangle
Circle --> "1" Point : center
Rectangle --> "1" Point : origin
note right of Shape : Abstraite, on ne l'instancie jamais.
@enduml

Un cercle et un rectangle ont deux choses en commun : une couleur, et une aire. On les déclare dans la classe parente Shape. La couleur a du code, puisqu'on la valide et qu'on la met en majuscules. L'aire n'en a pas, puisqu'on ne peut rien calculer sans savoir de quelle forme il s'agit.

abstract class Shape
{
    public const string DEFAULT_COLOR = '#000000';

    public function __construct(
        protected string $color = self::DEFAULT_COLOR,
    ) {
        if (preg_match('/^#[0-9A-Fa-f]{6}$/', $this->color) !== 1) {
            throw new \InvalidArgumentException("Couleur invalide : {$this->color}");
        }

        $this->color = strtoupper($this->color);
    }

    public function color(): string
    {
        return $this->color;
    }

    abstract public function area(): float;
}

Le paramètre promu $color est déjà rangé dans $this->color quand le corps du constructeur démarre. On peut donc le valider, puis le réécrire en majuscules.

abstract class interdit d'instancier Shape :

new Shape('#FF0000');
// Fatal error: Uncaught Error: Cannot instantiate abstract class Shapes\Shape

abstract public function area(): float; n'a pas de corps. C'est une obligation faite aux classes filles, vérifiée par PHP au chargement du fichier.

Revenez ensuite sur Line. Elle aussi est une forme. Faites-la hériter de Shape, supprimez sa couleur en double et appelez parent::__construct($color). Relancez les tests de l'étape 1 : ils doivent rester verts pendant que vous déplacez ce code.

Attention

area() d'une ligne renvoie 0.0. Un segment n'a pas de surface. Le contrat n'est donc pas parfait, et on l'accepte pour n'avoir qu'une seule classe parente.

Ce que les tests attendent

Classe Comportement vérifié
Shape La classe est abstraite, area(): float aussi. Line, Circle et Rectangle en héritent. Couleur par défaut #000000. Les valeurs rouge, #FFF, 000000, #GGGGGG et la chaîne vide lèvent InvalidArgumentException.
Circle new Circle(Point $center, float $radius, string $color = '#000000'). diameter() vaut le double du rayon, area() vaut M_PI pour un rayon de 1. Un rayon nul ou négatif lève InvalidArgumentException.
Rectangle new Rectangle(Point $origin, float $width, float $height, string $color = '#000000'). area() vaut 20.0 et perimeter() vaut 18.0 pour un rectangle de 4 sur 5. Une dimension nulle ou négative lève InvalidArgumentException.

Indice : une classe fille qui définit son propre constructeur doit appeler parent::__construct() elle-même. PHP ne le fait pas à sa place.

Vérification : composer test:etape-2, 23 tests verts, et composer test:etape-1 toujours vert.

Question : Shape::area() est abstraite, Shape::color() ne l'est pas. Sur quel critère avez-vous tranché ?

7. Étape 3 : Canvas et Polygon

Fourni : src/Canvas.php avec la propriété $shapes déjà déclarée, src/Polygon.php avec la formule du lacet décrite en commentaire. À écrire : les deux constructeurs, les accesseurs, add(), totalArea() et les deux aires.

@startuml
class Canvas {
  -width: float
  -height: float
  -background: string
  +add(shape: Shape): void
  +shapes(): array
  +isEmpty(): bool
  +totalArea(): float
}
abstract class Shape {
  #color: string
  +{abstract} area(): float
}
class Polygon {
  -points: array
  +points(): array
  +pointCount(): int
  +area(): float
}
Canvas *-- "0..*" Shape : shapes
Shape <|-- Polygon
Polygon o-- "3..*" Point : points
@enduml

Le losange plein entre Canvas et Shape se lit ainsi : le canvas tient la liste des formes, cette liste naît avec lui et disparaît avec lui. Il n'y a pas de triangle, donc pas d'héritage. Un canvas n'est pas une forme, il en contient. On voit parfois class Canvas extends Shape : relisez la phrase à voix haute, elle ne tient pas.

Le losange vide entre Polygon et Point dit l'inverse. Les sommets sont construits à l'extérieur, puis donnés au polygone. Comme un Point est immuable, le partager ne présente aucun risque.

add() ne contient aucun if pour refuser un Point. Le type du paramètre s'en charge, et PHP lève un TypeError tout seul :

public function add(Shape $shape): void
{
    $this->shapes[] = $shape;
}

Voici le morceau qui justifie la hiérarchie de l'étape 2 :

public function totalArea(): float
{
    $total = 0.0;

    foreach ($this->shapes as $shape) {
        $total += $shape->area();
    }

    return $total;
}

Le canvas ne demande jamais à une forme ce qu'elle est. À chaque tour de boucle, PHP appelle la méthode area() de l'objet parcouru. C'est le polymorphisme, et il évite ici d'écrire un cas par forme.

Ce que les tests attendent

Classe Comportement vérifié
Canvas new Canvas(float $width, float $height, string $background = '#FFFFFF'). Le fond ressort en majuscules. Un canvas neuf a shapes() vide et isEmpty() à true. add() conserve l'ordre. add(new Point(0, 0)) lève TypeError. totalArea() vaut 40.0 pour deux rectangles de 20 et une ligne. Une taille nulle ou négative lève InvalidArgumentException.
Polygon new Polygon(array $points, string $color = '#000000'), et Polygon hérite de Shape. points() garde l'ordre. area() vaut 6.0 pour le triangle (0,0) (4,0) (0,3) et 100.0 pour un carré de côté 10. Moins de trois sommets, ou un élément qui n'est pas un Point, lève InvalidArgumentException.

Indice : le type array de PHP ne dit pas ce qu'il contient. C'est à vous de parcourir le tableau reçu par Polygon et de vérifier chaque élément avec instanceof Point.

Vérification : composer test:etape-3, 17 tests verts.

Question : une même forme peut-elle être ajoutée à deux canvas différents ? Que devient-elle si l'un des deux disparaît ?

8. Étape 4 : Renderer et SvgRenderer

Fourni : l'interface src/Renderers/Renderer.php, complète et à ne pas modifier, et le squelette src/Renderers/SvgRenderer.php. À écrire : le constructeur, render(), save(), renderShape() et number().

@startuml
interface Renderer {
  +render(): string
  +save(path: string): void
}
class SvgRenderer {
  -canvas: Canvas
  +render(): string
  +save(path: string): void
  -renderShape(shape: Shape): string
  -number(value: float): string
}
class JpgRenderer {
  -quality: int
  +render(): string
  +save(path: string): void
}
Renderer <|.. SvgRenderer
Renderer <|.. JpgRenderer
SvgRenderer --> "1" Canvas : canvas
JpgRenderer *-- "1" SvgRenderer : svg
note bottom of JpgRenderer : Il possède un SvgRenderer, il n'en hérite pas.
@enduml

Lisez Renderer avant tout le reste. Deux méthodes, aucune ligne de code. L'interface dit ce qu'un moteur de rendu doit savoir faire, pas comment il s'y prend. C'est le pointillé du diagramme : SvgRenderer réalise ce contrat.

Le moteur de rendu reçoit le canvas dans son constructeur. Il ne le fabrique pas. Il l'utilise pour produire du texte.

Le cœur du travail est la traduction d'une forme en balise :

private function renderShape(Shape $shape): string
{
    return match (true) {
        $shape instanceof Circle => sprintf(
            '<circle cx="%s" cy="%s" r="%s" fill="%s" />',
            $this->number($shape->center()->x),
            $this->number($shape->center()->y),
            $this->number($shape->radius()),
            $shape->color(),
        ),
        // … Line, Rectangle, Polygon …
        default => throw new \InvalidArgumentException(
            'Forme inconnue : '.$shape::class,
        ),
    };
}

match (true) remplace une suite de if/elseif. Le default lève une exception, parce qu'un moteur de rendu qui rencontre une forme inconnue doit le signaler au lieu de dessiner du vide.

Vous venez d'écrire la cascade de instanceof que le chapitre 5 déconseillait. C'est un choix assumé, et la question de fin de section le discute.

Ce que les tests attendent

Le document commence par <?xml version="1.0" encoding="UTF-8"?>, contient xmlns="http://www.w3.org/2000/svg", la largeur, la hauteur et le viewBox, puis se termine par </svg>. Le fond est peint en premier. Les formes suivent dans l'ordre d'ajout. save() écrit dans le fichier exactement ce que render() renvoie.

Élément Balise attendue
fond du canvas <rect x="0" y="0" width="500" height="500" fill="#00FFFF" />
Line <line x1="1" y1="1" x2="1" y2="500" stroke="#0000FF" stroke-width="1" />
Circle <circle cx="250" cy="250" r="12.5" fill="#000000" />
Rectangle <rect x="50" y="50" width="250" height="400" fill="#FF0000" />
Polygon <polygon points="1,1 1,500 500,500" fill="#FF0000" />

Attention

Les tests attendent des nombres écrits sans décimale inutile. 500.0 doit sortir en 500, et 12.5 doit rester 12.5. Un flottant concaténé tel quel ne donne pas ce résultat. Indice : number_format(), puis deux rtrim().

Vérification : composer test:etape-4, 10 tests verts. Lancez ensuite l'exemple fourni.

php examples/star.php
# Étoile écrite dans examples/star.svg (2 formes, aire totale : 77527 px²).

Ouvrez examples/star.svg dans votre navigateur.

Question : le match de renderShape() grandit d'un cas à chaque nouvelle forme. On pourrait plutôt donner une méthode toSvg() à chaque forme. Que coûterait cette seconde solution le jour où on ajoute un troisième format de sortie ?

9. Bonus : JpgRenderer

Le bonus reprend le problème posé en ouverture. Vous écrivez la deuxième classe qui remplit le contrat Renderer, cette fois avec l'extension GD et le paquet meyfa/php-svg déjà installé.

JpgRenderer possède un SvgRenderer au lieu d'en hériter. Il lui demande le texte SVG, le convertit en image, puis renvoie les octets du JPEG. Deux classes sœurs, pas une classe et sa fille.

php -m

Cherchez gd dans la liste. Sans cette extension, les tests du groupe bonus sont ignorés et non rouges. Les indices détaillés sont dans le squelette et dans le README du dépôt.

10. Le rendu

  1. Les quatre étapes vertes, en local et dans l'onglet Actions de votre fork.
  2. php examples/star.php écrit l'étoile.
  3. Modifiez ensuite examples/star.php et dessinez ce que vous voulez. Une maison, un logo, une fractale. Une seule contrainte : au moins trois types de formes différents.
  4. Postez votre SVG sur le canal du cours, avec le lien vers votre fork.

À retenir

  • readonly interdit toute écriture après la construction. Une méthode qui « déplace » un tel objet renvoie une nouvelle instance.
  • Une classe abstraite met en commun ce qui est partagé et impose le reste par des méthodes abstraites.
  • Le polymorphisme, c'est un même appel qui exécute un code différent selon l'objet reçu. Dans totalArea(), il évite d'écrire un cas par forme.
  • Hériter n'est pas économiser du code. Si « un X est un Y » sonne faux, X doit posséder un Y.
  • Une interface est un contrat sans code. Deux classes sans parenté commune peuvent le remplir.

Exercices

  1. Obligatoire. Le rendu de la section 10, en entier.
  2. Au choix. Ajoutez une forme Ellipse, définie par un centre et deux rayons. Comptez les fichiers que vous avez dû ouvrir. Ce nombre répond à la question de l'étape 4.
  3. Bonus. JpgRenderer vert.
  4. Bonus difficile. Ajoutez Canvas::withBackground(string $color): Canvas, qui renvoie un nouveau canvas avec les mêmes formes et un autre fond, sans modifier l'original. Quelle idée de l'étape 1 réutilisez-vous ?