Construction de courbes de Bézier SVG

entre Vous et Nous

Accès direct au résultat

Le problème

Nous voulons réaliser un système expliquant le principe de la création des courbes de Bézier qui je vous le rappelle est un principe mathématique mis en lumière en 1962 par l'ingénieur français Pierre Bézier de la régie Renault (pour en savoir plus consulter le grimoire universel).

Le principe de ces courbes repose sur la proportionnalité des distances séparant des points, il est donc facilement réalisable par un langage informatique ayant des aptitudes de dessin tel que le permet le javascript associé au langage SVG.

Nous allons donc illustrer de façon interactive comment dessiner chacun de ces types de courbes. Pour cela nous construirons dans une image :

La solution

Pour commencer nous allons partir d'une image SVG destinée à la zone de dessin.

Pour ceux qui l'ignorent encore, le format SVG est destiné à produire du dessin vectoriel via une syntaxe XML normalisée par le W3C. De par sa nature il est :

De plus il supporte des primitives graphiques de dessin de courbes de Bézier en mode quadratique et cubique. Il semble donc parfaitement indiqué pour notre réalisation.

Il sera également nécessaire de dessiner des cercles et des segments de droite. Ceci pourra être réalisé par les éléments <circle> et <line>.

L'exemple suivant montre comment produire une image contenant 2 points, une droite et une courbe de Bézier quadratique délimitée par ces 2 points.

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="500" height="300">
  <path d="M 10,290 Q 250,-250 490,290" style="fill-opacity:0;stroke:#B20;stroke-width:2px"/>
  <line x1="10" y1="290" x2="490" y2="290" style="stroke-width:10px;stroke:#444"/>
  <circle r="9" cx="10" cy="290" style="fill:#F0F;stroke:#CCC;stroke-width:2px"/>
  <circle r="9" cx="490" cy="290" style="fill:#00F;stroke:#888;stroke-width:2px"/>
</svg>
Le résultat directement produit à partir du code précédent
La directive <path d="M 10,290 Q 250,-250 490,290"/> donne l'ordre de dessin d'une courbe quadratique (d'où le caractère Q) accompagné des 3 points indiquant les coordonnées des poignées de Bézier associées.

Et maintenant que ceci est dit intéressons-nous à notre image interactive...

L'image SVG

Nous allons donc commencer par créer une image, ou plutôt un squelette d'image SVG destiné à recevoir notre dessin.

Cette image sera adaptable à la taille de la fenêtre par les attributs height=100% et width=100% et elle contiendra :

<svg xmlns="http://www.w3.org/2000/svg"
     xmlns:xlink="http://www.w3.org/1999/xlink"
     version="1.1"
     width="100%"
     height="100%"
     viewBox="0 0 900 500"
     style="background:#88C">
  <title>Construction interactive de courbes de Bézier</title>
  <style type="text/css">
    /* CSS pour ne pas être géné par la sélection du texte */
    text {
      -o-user-select:none;
      -ms-user-select:none;
      -moz-user-select:none;
      -webkit-user-select:none;
      user-select:none;
    }
    /* les cercles cliquables sont modifiés au survol */
    circle.draggable:hover {fill:#000 !important;stroke:#FFF;stroke-width:2px}
    circle.move {fill:#888 !important;stroke:#000;stroke-width:2px}

    /* le texte de changement de mode quadratique/cubique est modifié au survol */
    text:hover {
      fill:#FFF;
      stroke:#000;
      stroke-width:1px;
      cursor:pointer;
    }
  </style>
  <!-- importation du script externalisé -->
  <script type="text/ecmascript" xlink:href="/uploads/javascript/exercices/bezier_construct.es"/>
  <text id="texte" x="10" y="35" fill="#FFF" style="font-size:35px">cubique</text>
</svg>

Comme vous l'avez sûrement remarqué il n'y a pas la moindre directive de dessin et c'est normal car celui-ci sera produit par le script que nous allons maintenant présenter.

La partie dynamique (javascript)

C'est la partie la plus importante car elle est chargée de :

Pour commencer nous allons déclarer des variables globales. Je sais il vaut mieux éviter, mais si vous le souhaitez vous pouvez placer tout le code dans un objet.

Les définitions globales en javascript

Afin de rendre notre programme plus léger et paramétrable nous allons :

var svgNS = 'http://www.w3.org/2000/svg'; // l'espace de nommage XML du SVG
var clcList = [];  // liste des points cliquables
var refList = [];  // liste des points de référence
var clicXY  = {};  // coordonnées initiales du click
var tpos = 0.4;    // position relative des points intermédiaires sur les segments (valeur allant de 0 à 1) 
var ratio;         // pour cohérence entre déplacement du curseur et image

// les styles à appliquer en fonction des niveaux
var levelStyles = [{stroke:"#F00", // premier niveau (points de repère) couleur de contour rouge
                    fill:"#FF0",   // couleur de remplissage jaune
                    lw:3,          // largeur de bordure 3 pixels
                    r:20},         // rayon 20 pixels
                   {stroke:"#F0F", fill:"#FA0", lw:2, r:14}, // second niveau (milieu des segments premier niveau)
                   {stroke:"#4FF", fill:"#F60", lw:1, r:10}, // troisième niveau (uniquement utilisé en mode cubique)
		   {stroke:"#00F", fill:"#FFF", lw:1, r:3}]; // dernier niveau (le point de contact à la courbe de Bézier)
}

Les niveaux correspondent aux différentes phases de la construction de la courbe, qui je le rappelle est réalisée par une succession d'interpolations imbriquées, la première de celles-ci travaillant sur les points de référence (3 en mode quadratique et 4 en mode cubique), chacune donnant un nombre de points diminué de un par rapport à la précédente.

La création du dessin (javascript)

Les points de référence de la courbe de Bézier seront uniformément répartis sur l'image en fonction du type de courbe retenu :

Ceci sera réalisée par la fonction initView() qui appellera :

  1. la fonction de nettoyage préliminaire cleanView() destinée comme son nom l'indique au nettoyage,
  2. la fonction de dessin à partir des références générées draw_all().
La fonction de création des points initView()
function initView() {
    var txt = document.getElementById('texte');
    var num_points;
    // la mémorisation de l'état est réalisée par le contenu du texte affiché
    if (txt.textContent.indexOf('cubique') !== 0)  {
	// pour une courbe cubique il faut 4 points
	num_points = 4;
	txt.textContent = 'cubique (cliquer pour passer en quadratique)';
    } else {
	// pour une courbe quadratique il faut 3 points
	num_points = 3;
	txt.textContent = 'quadratique (cliquer pour passer en cubique)';
    }
    cleanView();

    // pour positionner les points de façon uniforme
    var relmt = document.getElementsByTagName('svg')[0];
    refList = [];
    var width = relmt.viewBox.animVal.width - 20;
    var height = relmt.viewBox.animVal.height - 40;
    for (var i = 0; i < num_points; i++) {
	var alpha = Math.PI*2*(i+0.5)/num_points;
	var cx = 10 + Math.sin(alpha)*width/2 + width/2;
	var cy = 60 + Math.cos(alpha)*height/2 + height/2;
	refList[i] = {x:cx, y:cy, w:24, id:'pt_0_'+i, level:0};
    }
    draw_level(refList, 0);
}
La fonction de nettoyage cleanView()

Comme indiqué précédemment cette fonction sera appelée avant de commencer le dessin afin de supprimer tous les points de référence utilisés dans le mode précédent.

function cleanView() {
    var elm;
    while (elm=document.getElementsByTagName('circle')[0]) elm.parentNode.removeChild(elm);
    while (elm=document.getElementsByTagName('line')[0]) elm.parentNode.removeChild(elm);
}
La fonction de production des points draw_level(ptList, level)

Cette fonction est récursive, elle reçoit en argument :

  1. la liste des points à afficher ptList,
  2. le niveau de traitement réaliser level (utile pour choisir le style du point).

Elle procède ainsi :

  1. elle dessine l'ensemble des points par la fonction draw_point(crc, style, sclass),
  2. elle dessine l'ensemble des segments reliant les points par la fonction draw_line(id, x0, y0, x1, y1, style),
  3. elle calcule l'ensemble des points intermédiaires du niveau suivant, c'est à dire les points situés entre les points transmis en respectant la proportionnalité,
  4. elle indique que les 2 premiers niveaux sont cliquables par attribution de la classe draggable,
  5. elle s'appelle elle même tant que le dernier niveau n'est pas atteint, c'est à dire s'il y a plus d'un point à placer dans le niveau suivant (en augmentant le niveau de un).
  6. en dernier niveau :
    • le point correspond au point de tangence à la courbe de Bézier,
    • il faut tracer la courbe de Bézier par appel à la fonction draw_curve(id, ptList).
function draw_level(ptList, level) {
    var subptList = new Array;
    // les points à pratir du niveau 2 ne sont pas cliquables et donc n'ont pas la classe "draggable"
    var sclass = level < 2 ? 'draggable':null;
    if (ptList.length > 1) {
	// tracé des points délimitant les droites
	for (var i = 0; i < ptList.length-1; i++) {
	    var ox = ptList[i].x;
	    var oy = ptList[i].y;
	    var ex = ptList[i+1].x;
	    var ey = ptList[i+1].y;
	    var ix = (ox*(1-tpos) + ex*tpos);
	    var iy = (oy*(1-tpos) + ey*tpos);
	    subptList.push({x:ix,
			    y:iy,
			    w:ptList[i].w-3,
			    id:'pt_'+(level+1)+'_'+i,
			    level:level+1,
			    deb:i,
			    fin:i+1});

	    draw_line('lig-'+level+'-'+i, ox, oy, ex, ey, levelStyles[level]);
	    draw_point(ptList[i], levelStyles[level], sclass)
	    if (i == ptList.length-2) draw_point(ptList[i+1],levelStyles[level], sclass);
	}
	if (level == 0) {
	    // niveau=0 (premier niveau) donc déclaration des points cliquables et tracé de la courbe
	    clcList = [];
	    for (var i in ptList) clcList.push(ptList[i]);
	    // on enregistre les points du niveau=1 (second niveau) en tant que points cliquables
	    // car par le contrôle de la position de ces point sur le segment auquel il appartiennent
	    // permet de parcourir toute la courbe de Bézier
	    for (var i in subptList) clcList.push(subptList[i]);
	    draw_curve('bcurve', ptList);
	}
	draw_level(subptList, level+1);
    } else {
	// tracé du point de contact à la courbe
	draw_point(ptList[0],levelStyles[levelStyles.length-1], sclass);
    }
}
La fonction de dessin d'un point draw_point(crc, style, sclass)

Cette fonction place le point sur le dessin SVG avec le style transmis en argument. Deux cas sont possibles 

Lors de la création d'un nouvel élément nous aurons recours à l'appel document.createElementNS() permettant de construire un élément dans un espace de nommage identifié dans le DOM. Ici cet espace sera donné par svgNS=http://www.w3.org/2000/svg et les éléments créés seront de type circle.

function draw_point(crc, style, sclass) {
    var elmt = document.getElementById(crc.id);
    // si l'élément existe déjà il ne faut pas le créer, il suffit de le modifier
    if (!elmt) {
	elmt = document.createElementNS(svgNS, 'circle');
	elmt.setAttribute('style', 'fill:'+style['fill']+';');
        elmt.setAttribute('r', style['r']);
        elmt.setAttribute('id', crc.id);
	var img = document.getElementsByTagName('svg')[0];
	img.appendChild(elmt);
    }
    if (clicXY.pt && clicXY.pt.id == crc.id) {
        // pour donner du style aux points que l'on peut attraper (niveaux 0 et 1)
	elmt.setAttribute('class', 'move');
    } else if (sclass) elmt.setAttribute('class', sclass);
    else elmt.removeAttribute('class');
    elmt.setAttribute('cx', crc.x);
    elmt.setAttribute('cy', crc.y);
}
La fonction de dessin des segments draw_line(id, x0, y0, x1, y1, style)

Cette fonction place le segment sur le dessin SVG avec le style transmis en argument. Ici aussi deux cas sont possibles 

function draw_line(id, x0, y0, x1, y1, style) {
    var elmt = document.getElementById(id);
    // si l'élément existe déjà il ne faut pas le créer, il suffit de le modifier
    if (!elmt) {
	elmt = document.createElementNS(svgNS, 'line');
	elmt.setAttribute('style', 'stroke:'+style['stroke']+';stroke-width:'+style['lw']);
	elmt.setAttribute('id', id);
	var img = document.getElementsByTagName('svg')[0];
	img.appendChild(elmt);
    }
    elmt.setAttribute('x1', x0);
    elmt.setAttribute('y1', y0);
    elmt.setAttribute('x2', x1);
    elmt.setAttribute('y2', y1);
}
La fonction de dessin de la courbe draw_curve(id, ptList)

Cette fonction trace la courbe en fonction du mode choisi sur le dessin SVG. Là encore deux cas sont possibles 

function draw_curve(id, ptList) {
    var elmt = document.getElementById(id);
    // si l'élément existe déjà il ne faut pas le créer, il suffit de le modifier
    if (!elmt) {
	elmt = document.createElementNS(svgNS, 'path');
	elmt.setAttribute('style', 'fill:none;stroke:#000;stroke-width:6');
	elmt.setAttribute('id', id);
	var img = document.getElementsByTagName('svg')[0];
	img.insertBefore(elmt, img.firstChild);
    }
    var d;
    // le mode cubique/quadratique est déduit du nombre de points
    if (ptList.length == 4) {
	// mode quadratique
	d = 'M '+ptList[0].x+' '+ptList[0].y+' C'+ptList[1].x+','+ptList[1].y+' '+ptList[2].x+','+ptList[2].y+' '+ptList[3].x+','+ptList[3].y;
    } else {
	// mode cubique
	d = 'M '+ptList[0].x+' '+ptList[0].y+' Q'+ptList[1].x+','+ptList[1].y+' '+ptList[2].x+','+ptList[2].y;
    }
    elmt.setAttribute('d', d);
}
L'interactivité (javascript)

Il faut maintenant gérer les événements en provenance de la souris. Ces événements sont :

Les fonctions ci-dessous assurent ces opérations :

La fonction grip(evt)

Cette fonction est en charge de la mémorisation :

function grip(event) {
    var index = [];
    if (event.touches) {
        // dans le cas d'un appareil tactile il peut y avoir plusieurs actions simultanées. Il faut donc remplir un tableau
        event.preventDefault(); // pour bloquer le comportement par défaut car celui-ci gêne les actions de l'utilisateur
        for (var i=0; i < event.changedTouches.length; i++) {
            index[i] = {};
            index[i].num = event.changedTouches[i].identifier;
            index[i].x = event.changedTouches[i].clientX;
            index[i].y = event.changedTouches[i].clientY;
            index[i].id = event.changedTouches[i].target.id;
        }
    } else {
        // dans le cas d'une souris le tableau ne contiendra qu'un seul élément, permettant ainsi d'uniformiser le traitement
        index[0] = {};
        index[0].num = 0;
        index[0].x = event.clientX;
        index[0].y = event.clientY;
        index[0].id = event.target.id;
    }

    // mémorisation des positions du début de chaque action enregistrée
    for (var i=0; i < index.length; i++) {
        var idx = index[i].num;
        var clicPt;
        for(var pt in clcList) {
            if (index[i].id == clcList[pt].id) {
                clicPt=clcList[pt];
            }
        }
        if (!clicPt) return released();
        clicXY[idx] = {};
        clicXY[idx].pt = clicPt;
        clicXY[idx].Ox = parseFloat(clicPt.x); // mémorisation position horizontale de l'élément visé lors du clic
        clicXY[idx].Oy = parseFloat(clicPt.y); // mémorisation position verticale de l'élément visé lors du clic
        clicXY[idx].x = index[i].x;            // mémorisation coordonnée horizontale pixel visé lors du clic
        clicXY[idx].y = index[i].y;            // mémorisation coordonnée verticale pixel visé lors du clic
    }
}

Le traitement des appareils tactiles est extrapolé aux appareils classiques. La différence entre les deux approches se situe essentiellement sur la simultanéité d'événements à raison d'un touch par doigt contre un seul clic dans le cas de la souris.
Afin de permettre la gestion des deux types de périphériques nous mémorisons pour chaque événement les coordonnées d'origine de l'objet visé ainsi que celles du pixel l'ayant déclenché. Ce maintient est assuré par la variable clicXY qui contiendra également l'objet visé et qui sera limité aux éléments cliquables (niveau 1 et 2).

La fonction drag(evt)

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

function drag(event) {
    var index = [];
    if (event.touches) {
        // même gestion que précédemment dans le cas du tactile
        event.preventDefault();
        for (var i=0; i < event.changedTouches.length; i++) {
            index[i] = {};
            index[i].num = event.changedTouches[i].identifier;
            index[i].x = event.changedTouches[i].clientX;
            index[i].y = event.changedTouches[i].clientY;
        }
    } else {
        index[0] = {};
        index[0].num = 0;
        index[0].x = event.clientX;
        index[0].y = event.clientY;
    }

    for (var i=0; i < index.length; i++) {
        var idx = index[i].num;
        if (!clicXY[idx]) return;
        if (!clicXY[idx].pt) return;
        var X  = index[i].x;         // la coordonnée horizontale du curseur
        var Y  = index[i].y;         // la coordonnée verticale du curseur
        var x  = clicXY[idx].x;      // la position horizontale d'origine du clic
        var y  = clicXY[idx].y;      // la position verticale d'origine du clic
        var x0 = clicXY[idx].Ox;     // la coordonnée horizontale d'origine du point
        var y0 = clicXY[idx].Oy;     // la coordonnée verticale d'origine du point
        var xn = x0 + (X - x)*ratio; // la nouvelle coordonnée horizontale
        var yn = y0 + (Y - y)*ratio; // la nouvelle coordonnée verticale

        if (clicXY[idx].pt.level == 0) {
            // en niveau 0 il faut déplacer les points
            clicXY[idx].pt.x = xn;     // positionnement horizontal du point
            clicXY[idx].pt.y = yn;     // positionnement verticale du point
        } else {
            // en niveau 1 il faut modifier le ratio contenu dans tpos pour déplacer le point de tangence
            var deb = clcList[clicXY[idx].pt.deb];
            var fin = clcList[clicXY[idx].pt.fin];
            if (Math.abs(fin.x-deb.x) > Math.abs(fin.y-deb.y)) {
                tpos = (xn - deb.x)/(fin.x - deb.x);
            } else {
                tpos = (yn - deb.y)/(fin.y - deb.y);
            }
            if (tpos < 0) tpos = 0;
            if (tpos > 1) tpos = 1;
        }
    }
    draw_level(refList, 0);
}

À la lecture de ce code on voit que la fonction en charge de la génération complète du dessin sera appelée autant de fois qu'il y a d'événements.
Le calcul des points sera différent selon qu'il s'agit du premier ou du second niveau :

La fonction released(event)

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

function released(){
    if (event && event.touches) {
        event.preventDefault();
        for (var i=0; i < event.changedTouches.length; i++) {
            var idx = event.changedTouches[i].identifier;
            clicXY[idx] = undefined;
        }
    } else {
        clicXY[0] = undefined;
    }
    draw_level(refList, 0);
}
La fonction initRatio()

Il ne reste plus qu'a améliorer le comportement en faisant en sorte que l'image réalise les mêmes déplacements que ceux constatés par le curseur, c'est plus agréable !

Pour cela il faut utiliser une valeur numérique ratio assurant cette correspondance et qui sera recalculée lors de chaque modification de la taille de l'image par le déclenchement sur resize.

function initRatio() {
    var relmt = document.getElementsByTagName('svg')[0];
    if (relmt.clientWidth && relmt.clientHeight) {
	ratio = Math.max(relmt.viewBox.animVal.width/relmt.clientWidth, relmt.viewBox.animVal.height/relmt.clientHeight);
    } else {
	// pour les navigateurs n'ayant pas les propriétés clientWidth et clientHeight
	ratio = 1;
    }
}

Cette fonction n'a pas d'intérêt pour les appareils tactiles, de plus elle n'est pas opérationnelle sous FireFox. Dans tous les cas elle n'est pas d'une utilité fondamentale et il est tout-à-fait possible de ne pas l'intégrer.

La mise en place des bindings en javascript

C'est presque fini, mais les procédures que nous avons déclarées ne sont pas encore reliées à des événements. C'est ce que nous allons faire ici sur la fin du chargement de la page grâce à window.addEventListener('load', fonction) qui lancera la fonction en charge de cette mission. Utiliser un chargement différé permet d'externaliser le javascript et ainsi de conserver une structuration propre des documents assurant le découplage entre la structure (code SVG) et les comportements (code javascript).

window.addEventListener('load', function() {                                                                                                                                
    initRatio();  // calcul du ratio                                                                                                                                         
    initView();   // génération de l'image                                                                                                                                  
    window.addEventListener('resize', initRatio, false);                                                                                                                    
    var svg_elmnt = document.getElementById('bezier_crv');
    svg_elmnt.getElementById('texte').addEventListener('touchstart', initView, false);
    svg_elmnt.getElementById('texte').addEventListener('click', initView, false);
    // gestion de la souris                                                                                                                                                 
    svg_elmnt.addEventListener('mousedown', grip, false);
    svg_elmnt.addEventListener('mousemove', drag, false);
    svg_elmnt.addEventListener('mouseup', released, false);
    // gestion des systèmes tactiles                                                                                                                                        
    svg_elmnt.addEventListener('touchstart', grip, false);
    svg_elmnt.addEventListener('touchmove', drag, false);
    svg_elmnt.addEventListener('touchend', released, false);
}, false);

On voit que les événements sont positionnés à destination des équipements tactiles et des équipements classiques.
La fonction initRatio() sera appelé à chaque modification de la taille de la zone d'affichage.

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 !