Les transformations géométriques du SVG

entre Vous et Nous

Accès direct au résultat

Le problème

Nous voulons réaliser une système illustrant les transformations applicables à tout élément composant une image SVG. Ces transformations sont :

Étant donné que les transformations matricielles permettent à elles seules toutes les autres, nous allons réaliser un système n'utilisant que ces transformations et à partir duquel on indiquera par une combinaison de rotation, translation, grossissement et cisaillement l'opération à réaliser.

Pour illustrer chacune de ces transformations il suffira donc :

Le principe est simple mais pour utiliser une méthode interactive 100% SVG il faudra créer des éléments de saisie semblables à des curseurs et modifiables par exemple à l'aide de la souris, ce qui est tout-à-fait réalisable par un script javascript.

La solution

Pour commencer regardons l'image SVG à réaliser.

L'image au format SVG

L'image qui subira les transformations sera très simple dans la mesure ou toute la partie contrôle est gérée par le javascript. Elle devra intégrer :

il faudra donc fournir les fichiers suivants :

  1. Le document SVG dont le code est donné ci-dessous :
    <?xml-stylesheet type="text/css" href="svg_style.css" ?>
    <svg
       xmlns="http://www.w3.org/2000/svg"
       xmlns:xlink="http://www.w3.org/1999/xlink"
       width="100%"
       height="100%"
       version="1.0"
       viewBox="0 0 700 400"
       id="svg2">
      <!-- chargement des comportements entièrement déportés dans les fichiers cursors.es et matrix.es -->
      <script xlink:href="cursors.es" type="text/ecmascript"/>
      <script xlink:href="matrix.es" type="text/ecmascript"/>
      <defs>
        <!-- les marqueurs pour le repère Oxy -->
        <marker id="cercle"
    	    markerUnits="userSpaceOnUse"
    	    markerWidth="8"
    	    markerHeight="8"
    	    viewBox="-2 -2 4 4"
    	    orient="auto">
          <circle r="2" cx="0" cy="0" style="stroke-width:0.2px;stroke:#666"/>
        </marker>
        <marker id="fleche"
                markerWidth="14"
                markerHeight="14"
                orient="auto"
                refY="5"
                refX="9">
        <path d="M0,1 L10,5 0,9"/>
        </marker>
      </defs>
      <!-- les 2 éléments à representer l'un avant et l'autre après transformation -->
      <g transform="translate(200,150)">
        <g id="original">
          <rect id="r_original" x="0" y="0" width="100" height="100" style="fill:#888"/>
          <line x1="0" y1="0" x2="0" y2="120" class="axe" marker-start="url(#cercle)" marker-end="url(#fleche)"/>
          <text x="140" y="-8" class="axe">→</text>
          <text x="140" y="0" class="axe">ox</text>
          <line x1="0" y1="0" x2="120" y2="0" class="axe" marker-start="url(#cercle)" marker-end="url(#fleche)"/>
          <text x="0" y="130" class="axe">→</text>
          <text x="0" y="140" class="axe">oy</text>
          <text x="50" y="50" class="face">Original</text>
        </g>
        <g id="transforme">
          <rect id="r_transforme" x="0" y="0" width="100" height="100" style="fill:#FCC"/>
          <line x1="0" y1="0" x2="0" y2="120" class="axe" marker-start="url(#cercle)" marker-end="url(#fleche)"/>
          <text x="140" y="-8" class="axe">→</text>
          <text x="140" y="0" class="axe">ox'</text>
          <line x1="0" y1="0" x2="120" y2="0" class="axe" marker-start="url(#cercle)" marker-end="url(#fleche)"/>
          <text x="0" y="130" class="axe">→</text>
          <text x="0" y="140" class="axe">oy'</text>
          <text x="50" y="50" class="face">transformé</text>
        </g>
      </g>
    
      <!-- les curseurs des transformations à appliquer -->
      <g id="matrix" transform="translate(10, 20)"/>
      <g id="cursors" transform="translate(0, 300)"/>
    </svg>

    Ce document intègre deux fichiers javascript, ceci permet de séparer ce qui est spécifique à cette application de ce qui peut être utilisé ailleurs ; c'est pourquoi nous avons ici :

    • le fichier cursors.es qui contient la partie IHM pour la gestion des curseurs,
    • le fichier matrix.es ne contenant que le code spécifique à cette application.
  2. la feuille de style utilisée (indiquée par la première ligne <?xml-stylesheet type="text/css" href="svg_style.css" ?>) et dont le code est ici :
    text {
        -o-user-select:none;
        -ms-user-select:none;
        -moz-user-select:none;
        -webkit-user-select:none;
        user-select:none;
    }
    text.face {
        font-size:15px;
        text-anchor:middle;
        alignment-baseline:middle;
        text-decoration:underline;
    }
    text.axe {
        font-size:20px;
        text-anchor:middle;
        alignment-baseline:middle;
    }
    line.vect {
        stroke-linecap:butt;
        stroke-width:4;
    }
    line.axe {
        stroke:#000;
        stroke-width:2;
        stroke-dasharray:10 2 1 2;
    }
    text.title {
        text-anchor:middle;
    }
    path {
        fill:#AAA;
        fill-opacity:0.4;
        stroke-width:0.5px;
        stroke:#000;
        stroke-dasharray:0;
        stroke-width:2;
        stroke-linejoin:miter;
        stroke-miterlimit:4.0;
    }
    #matrix .delim {
        font-size:150px;
        fill:#666;
        text-anchor:middle;
        alignment-baseline:middle;
    }

Maintenant que l'image est construite regardons l'IHM à réaliser.

Les éléments d'interface

Nous voulons réaliser les opérations suivantes :

Il nous faudra donc une interface pourvue de 7 curseurs couvrant les plages des valeurs correspondantes.
Pour cela nous procéderons en 5 étapes :

  1. la déclaration de l'interface

    Afin de simplifier la maintenance du script nous allons créer dynamiquement les éléments de contrôle qui seront donc définis à partir d'un tableau associatif contenant :

    • min qui sera la valeur minimale de la plage,
    • max qui sera la valeur maximale de la plage,
    • def qui sera la valeur par défaut,
    • style qui sera le style CSS à appliquer,
    • txt qui sera le label à placer au dessus du curseur.

    Nous placerons ensuite chaque élément dans le tableau associatif controls en utilisant comme clef le nom du contrôle.

    Ainsi le code du tableau décrivant nos 7 curseurs (propre à cette application et donc placé dans matrix.es) sera :

    controls = {tx: {min: -300, max:300, def:300, cursorStyle:'fill:#00F', txt: 'tr X'},
    	    ty: {min: -300, max:300, def:-100, cursorStyle:'fill:#00F', txt: 'tr Y'},
    	    rot: {min: 0, max: 360, def: 45, cursorStyle:'fill:#0FF', txt: 'rot'},
    	    zx: {min: -3, max:3, def:1.5, cursorStyle:'fill:#F0F', txt:'Zoom X'},
    	    zy: {min: -3, max:3, def:1.5, cursorStyle:'fill:#F0F', txt:'Zoom Y'},
    	    cx: {min: -89, max: 89, def:0, cursorStyle:'fill:#FF0', txt: 'Skew X'},
    	    cy: {min: -89, max: 89, def:0, cursorStyle:'fill:#FF0', txt: 'Skew Y'}};

    Par ailleurs nous devrons utiliser plusieurs variables globales nécessaires au fonctionnement de l'IHM (et donc placées dans cursors.es) dont le code de déclaration est le suivant :

    var svgNS   = "http://www.w3.org/2000/svg";
    var grab    = {}; // destiné à mémoriser l'élément sélectionné et ses coordonnées
    var cursors = []; // tableau des éléments curseurs
    var ratio   = 1;  // facteur de corresponcance entre pixels de l'image et déplacement du curseur
  2. la création des éléments

    Nous allons maintenant créer des éléments de contrôle (toujours dans cursors.es) constitués de 2 rectangles :

    • le curseur qui se déplacera à l'aide de la souris,
    • la course du curseur qui sera fixe,
    • un rectangle commun à tous les éléments définira la zone de contrôle.
    La fonction de génération des curseurs initCursors()

    Cette fonction assure le dessin des curseurs dans l'élément SVG identifié par id="curseurs" et dont le code est :

    function initCursors() {
        var cursor_style = 'fill:#F00';   // style par défaut du curseur
        var cursor_bgstyle = 'fill:#444'; // style par défaut de la zone de course du curseur
        var cursor_shift = 100;           // écart horizontal entre 2 curseurs consécutifs
        var cursor_height = 10;           // hauteur du curseur
        var cursor_width = 10;            // largeur du curseur
        var cursor_slide_height = 80;     // longueur de la course du curseur
        var cursor_slide_width = 5;       // largeur de la course du curseur
        var cursor_Y_pos = 15;            // position verticale de la course du curseur
        var cursor_textX_pos = 10;        // position horizontale du label du curseur
        var cursor_textY_pos = 12;        // position verticale du label du curseur
        var cursor_valX_pos = 60;         // position horizontale de la valeur numérique liée au curseur
        var cursor_valY_pos = 50;         // position verticale de la valeur numérique liée au curseur
        var cursors_bgstyle = 'fill:#CCC;fill-opacity:0.5';
        var ndcrs = document.getElementById('cursors');
        var fondCurseurs = document.createElementNS(svgNS, 'rect');
        fondCurseurs.setAttribute('x', 0);
        fondCurseurs.setAttribute('y', 0);
        fondCurseurs.setAttribute('height', cursor_slide_height+20);
        fondCurseurs.setAttribute('style', cursors_bgstyle);
        ndcrs.appendChild(fondCurseurs);
        
        // création des curseurs
        var i = 0; // utile pour calculer la position du curseur
        for(var crs in controls) {
    	var elmnt = {}
    	cursors[crs] = elmnt;
    	// création du champ texte contenant la valeur
    	elmnt.text = document.createElementNS(svgNS, 'text');
    	elmnt.text.textContent = controls[crs].def;
    	elmnt.text.setAttribute('x', cursor_valX_pos + i*cursor_shift - cursor_width/2);
    	elmnt.text.setAttribute('y', cursor_valY_pos);
    	// création du rectangle représentant le curseur
    	elmnt.cursor = document.createElementNS(svgNS, 'rect');
    	elmnt.cursor.setAttribute('x', (i+0.5)*cursor_shift - cursor_width/2);
    	elmnt.cursor.setAttribute('width', cursor_width);
    	elmnt.cursor.setAttribute('height', cursor_height);
    	elmnt.cursor.setAttribute('style', cursor_style);
    	elmnt.cursor.setAttribute('id', 'curseur_'+crs);
    	// création du rectangle représentant la course du curseur
    	elmnt.fond = document.createElementNS(svgNS, 'rect');
    	elmnt.fond.setAttribute('x', (i+0.5)*cursor_shift - cursor_slide_width/2);
    	elmnt.fond.setAttribute('y', cursor_Y_pos);
    	elmnt.fond.setAttribute('width', cursor_slide_width);
    	elmnt.fond.setAttribute('height', cursor_slide_height);
    	elmnt.fond.setAttribute('style', cursor_bgstyle);
    	// mémorisation des amplitude et offset du curseur pour calcul de la valeur à attribuer
    	elmnt.ampY = parseFloat(elmnt.fond.getAttribute('height')) - parseFloat(elmnt.cursor.getAttribute('height'));
    	elmnt.minY = parseFloat(elmnt.fond.getAttribute('y'));
    	
    	// positionnement à la valeur par défaut
    	posFromValue(crs);
    	ndcrs.appendChild(elmnt.fond);
    	ndcrs.appendChild(elmnt.cursor);
    	ndcrs.appendChild(elmnt.text);
    	// affichage du label si celui-ci existe (champ txt de controls)
    	if (controls[crs].txt) {
    	    var textd = document.createElementNS(svgNS, 'text');
    	    textd.textContent = controls[crs].txt;
    	    textd.setAttribute('x', cursor_textX_pos + i*cursor_shift - cursor_width/2);
    	    textd.setAttribute('y', cursor_textY_pos);
    	    ndcrs.appendChild(textd);
    	}
    	// application du style si celui-ci existe (champ style de controls)	
    	if (controls[crs].cursorStyle) elmnt.cursor.setAttribute('style', controls[crs].cursorStyle);
    	i++;
        }
        // mise en place du fond des curseurs à la dimension attendue
        fondCurseurs.setAttribute('width', i*cursor_shift);
        transform();
    }
  3. l'interaction

    Il faut maintenant gérer les événements en provenance de la souris (toujours dans cursors.es). Ces événements sont :

    • le click qui repère quel curseur est concerné et le mémorise,
    • le drag qui déplace le curseur visé par le clic précédent,
    • le release qui interrompt toute action sur le curseur visé par le clic précédent.

    Les fonctions ci-dessous assurent ces opérations :

    La fonction grip(evt)

    Cette fonction est en charge de la mémorisation :

    1. de l'élément sollicités lors du clic,
    2. des coordonnées le l'élément lors du clic.
    function grip(evt) {
        for(var ct in controls) {
    	if (evt.target.id == 'curseur_'+ct) {
    	    grab.elmt = ct;
    	}
        }
        if (grab.elmt) {
    	grab.cy = parseFloat(cursors[grab.elmt].cursor.getAttribute("y"));
    	grab.Y  = evt.clientY;
        }
    }
    La fonction drag(evt)

    Cette fonction assure le déplacement du curseur et la régénération du dessin :

    1. elle déplace verticalement l'élément du tableau des curseurs cursor visé par grab.elmt,
    2. elle lance ensuite la procédure de dessin transform().
    function drag(evt){  
        if (!grab.elmt) return;
    
        var Y  = evt.clientY;
        var y0 = cursors[grab.elmt].minY;
        var s  = cursors[grab.elmt].ampY;
        var y  = Math.min(Math.max(grab.cy+(Y-grab.Y)*ratio, y0), y0+s);       
    
        cursors[grab.elmt].cursor.setAttribute('y',y);
        cursors[grab.elmt].text.textContent = valueFromPos(grab.elmt);
        
        transform();
    }
    La fonction released()

    Cette fonction abandonne la sélection et donc la mémorisation du curseur.

    function released(){
        grab.elmt = undefined;
    }
    Les fonctions d'aide au maintien des valeurs

    Afin de rationaliser la correspondance valeurs numériques / position des curseurs nous utilisons 2 fonctions dont le rôle est :

    • de récupérer la valeur à partir de la position du curseur, c'est la fonction valueFromPos(elmt) :
      function valueFromPos(elmt) {
          var s  = cursors[elmt].ampY;
          var y0 = cursors[elmt].minY;
          var y = cursors[elmt].cursor.getAttribute('y');
          var min = controls[elmt].min;
          var max = controls[elmt].max;
          var value = (min + (max-min)*(1-(y-y0)/s)).toFixed(1);
          return value;
      }
    • de mettre le curseur à sa position à partir de sa valeur, c'est la fonction posFromValue(elmt) dont le code est :
      function valueFromPos(elmt) {
          var s  = cursors[elmt].ampY;
          var y0 = cursors[elmt].minY;
          var min = controls[elmt].min;
          var max = controls[elmt].max;
          var def = parseFloat(cursors[elmt].text.textContent); 
          var y = y0 + s * ((max - def)/(max-min));
          cursors[elmt].cursor.setAttribute('y', y);
      }
  4. l'application de la transformation.

    D'après le code des fonctions drag() et initCursors() on voit que la fonction transform() est appelée à chaque action sur un curseur. c'est donc elle qui sera spécifique à notre application (et donc dans matrix.es) et qui devra :

    1. calculer le matrice de transformation,
    2. appliquer celle-ci à l'image SVG.

    Rappel : l'opération de transformation sera donc appliquée par la méthode transform() qui utilise les valeurs stockées dans des variables remises à jour par la partie contrôle pilotée via les curseurs. Le nom de ces variable est cursors[ctrl].text (où ctrl désigne le nom du contrôle).
    Nous allons donc créer la fonction calculant la matrice à partir ce ces valeurs.

    Pour cela nous réaliserons plusieurs fonctions javascript comme décrit ici :

    1. la fonction de transformation de matrice transformMatrix(matrix)

      Le rôle de cette fonction sera de convertir une matrice en coordonnées généralisées, c'est à dire apte à produire simultanément des translations et des déformations, et donc représentée sous la forme d'un tableau de 3x3 vers une chaîne de caractères correspondant aux attentes du SVG.

      Il faudra donc fournir une chaîne de la forme matrix(v1,v2,v3,v4,v5,v6).

      Cette fonction est donnée par le code suivant :

      function TransformMatrix(matrix) {
          return 'matrix('+matrix[0][0].toFixed(2)+','+matrix[1][0].toFixed(2)+','
      	            +matrix[0][1].toFixed(2)+','+matrix[1][1].toFixed(2)+','
      	            +matrix[0][2].toFixed(2)+','+matrix[1][2].toFixed(2)+')';
      }
    2. la fonction produit de matrices 3x3 prodMatrix(m1, m2)

      Son rôle sera de produire une matrice 3x3 résultant du produit de 2 matrices 3x3.

      Cette fonction est donnée par le code suivant :

      function prodMatrix(m1, m2) {
          var res = new Array();
          for (var j=0; j<m1.length; j++) {
      	res[j] = new Array();
      	for (var k=0; k<m2[j].length; k++) {
      	    res[j][k] = 0;
      	    for (var i=0; i<m2[j].length; i++) {
      		res[j][k] += m1[j][i] * m2[i][k];
      	    }
      	}
          }
          return res;
      }
    3. la fonction de génération de la matrice à partir des curseurs getMatrix()

      Son rôle sera de produire la matrice à appliquer à l'image SVG à partir des curseurs de l'interface. Elle aura donc recours aux procédures précédentes pour combiner les différentes opérations réalisées : translation + rotation + grossissement + cisaillement.

      Cette fonction est donnée par le code suivant :

      function getMatrix() {
          var tx = parseFloat(cursors['tx'].text.textContent);
          var ty = parseFloat(cursors['ty'].text.textContent);
          var zx = parseFloat(cursors['zx'].text.textContent);
          var zy = parseFloat(cursors['zy'].text.textContent);
          var cx = parseFloat(cursors['cx'].text.textContent);
          var cy = parseFloat(cursors['cy'].text.textContent);
          var teta_rot = parseFloat(cursors['rot'].text.textContent) * Math.PI / 180;
          var teta_cx = parseFloat(cursors['cx'].text.textContent) * Math.PI / 180;
          var teta_cy = parseFloat(cursors['cy'].text.textContent) * Math.PI / 180;
          
          var cos_r = Math.cos(teta_rot);
          var sin_r = Math.sin(teta_rot);
          var tan_cx = Math.tan(teta_cx);
          var tan_cy = Math.tan(teta_cy);
          
          var mat_rt = [[cos_r, -sin_r, tx],
      		  [sin_r,  cos_r, ty],
      		  [0,      0,      1]];
      
          var mat_cxy = [[1,     tan_cx, 0],
      		  [tan_cy, 1,      0],
      		  [0,      0,      1]];
      
          var mat_zm = [[zx, 0, 0],
      		  [0, zy, 0],
      		  [0,  0, 1]];
      
          var matrix = prodMatrix(prodMatrix(mat_rt, mat_zm), mat_cxy);
      
          return matrix;
      }
    4. la fonction d'affichage d'une matrice 3x3 DisplayMatrix(matrix)

      Afin d'illustrer l'impact des transformations sur les coefficients de la matrice nous allons créer une fonction destiné à afficher son contenu dans l'image SVG. Cette fonction utilisera l'élément <g id="matrix"> pour faire le rendu.

      Elle est donnée par le code suivant :

      function DisplayMatrix(matrix) {
          // la matrice ne sera affichée que si l'élément 'matrix' existe
          var mid = document.getElementById('matrix');
          if (mid) {
      	for (var j in matrix) {
      	    for (var i in matrix[j]) {
      		var id = 'matrix_xy_'+i+'_'+j;
      		var nd = document.getElementById(id);
      		if (!nd) {
      		    nd = document.createElementNS(svgNS, 'text');
      		    nd.setAttribute('x', i * 50);
      		    nd.setAttribute('y', j * 50);
      		    nd.setAttribute('id', id);
      		    mid.appendChild(nd);
      		}
      		nd.textContent = matrix[j][i].toFixed(2);
      	    }
      	}
          }
      }
    5. fonction d'application de la transformation transform()

      Pour finir il ne reste plus qu'a appliquer les changements via la fonction transform() appelée par l'interface de contrôle.

      celle-ci assurera les opérations suivantes :

      • elle appliquera la matrice retournée par getMatrix() à l'élément dont l'identité est transforme,
      • elle affichera les valeurs de la matrice appliquée par DisplayMatrix().

      Elle est donnée par le code suivant :

      function transform() {
          var gid = document.getElementById('transforme');
          var matrx = getMatrix();
          //application du de la transformation SVG
          gid.setAttribute('transform',TransformMatrix(matrx));
          //affichage de la matrice 3x3
          DisplayMatrix(matrx);
      }
  5. mise en place des bindings

    Toutes les fonctions sont maintenant écrites mais il faut les attacher à des événements javascript, ce que nous allons faire maintenant.
    Pour des raisons de découplage entre l'image SVG et le script javascript nous déclencherons la production des éléments en fin de chargement de l'image SVG et ce à l'aide de la propriété window.onload (à placer dans cursors.es).

    
    function DisplayMatrix(matrix) {
    window.onload = function() {
        initRatio();
        initCursors();
        window.onresize = initRatio;
        window.onmouseup = released;
        window.onmousemove = drag;
        window.onmousedown = grip;
    }

    Il faudra également écrire la fonction initRatio() destinée à calculer la correspondance entre un pixel image et un pixel déplacement curseur souris. Cela aura le bon goût de conserver le curseur de la souris sur le curseur SVG piloté par l'interface et ce même lors du déplacement.

    Cette fonction n'est pas indispensable et si on ne veut pas l'utiliser il suffit éliminer le binding nommé window.onresize de la procédure d'initialisation.

    Cette fonction initRatio() peut s'écrire ainsi :

    function initRatio() {
        var relmt = document.getElementsByTagName('svg')[0];
        if (relmt.clientWidth && relmt.clientHeight) {
    	// n'est utilisé que par les navigateurs disposant des propriétés clientWidth et clientHeight
    	ratio = Math.max(relmt.viewBox.animVal.width/relmt.clientWidth, relmt.viewBox.animVal.height/relmt.clientHeight);
        }
    }

Et c'est fini, il n'y a plus qu'a charger l'image SVG dans un navigateur et jouer avec, mais si vous n'avez pas le courage de copier/coller tous les fragments de code de cet exercice vous pouvez directement voir et agir sur le résultat ci-dessous :

Un navigateur moderne devrait vous montrer ici le résultat, en désespoir de cause vous pouvez essayer le lien suivant sur cette image SVG, mais je doute du résultat car un navigateur qui ne connaît pas <object> a peu de chances de connaître le SVG !