4. Simuler la cycloïde et l'astroïde

Objectif: Appliquer les connaissances de C++ acquises.

Le but de cet exercice est de compléter l'infrastructure existante pour permettre la simulation d'une cycloïde ou astroïde en faisant rouler un cercle sur une trajectoire (linéaire ou circulaire).

Introduction

La cycloïde et l'astroïde tracées lors de l'exercice précédent par des fonctions paramétriques peuvent être engendrées par un point d'un cercle qui roule sur une trajectoire. Pour la cycloïde, cette trajectoire est une droite horizontale. Pour l'astroïde, la trajectoire est elle-même un cercle. Une belle animation de ce qu'on aimerait simuler est disponible sur le site web de Wolfram Research: la cycloïde et l'astroïde.

L'implémentation se fait par petites étapes qui sont en partie données et en partie réalisées par l'étudiant.

La figure suivante montre une vue d'ensemble de la hiérarchie de classes manipulée lors de cet exercice :

Hiérarchie des classes

Les résultats des exercices précédents, c'est-à-dire les classes PtVec, PFunction, Cycloid et Astroid, vont être réutilisés. Lors des étapes qui suivent, vous allez implémenter les classes MathShape, TestTriangle, Ellipse et Circle. Toutes les autres classes seront fournies.

La classe Shape

Propriétés d'une forme

La classe Shape est une classe abstraite qui représente une forme quelconque pouvant être affichée, déplacée et tournée autour d'un centre de rotation. A cette fin, elle stocke les informations suivantes :

Le code source de cette classe abstraite est le suivant :
(Vous pouvez le copier dans un fichier .cpp)

class Shape
{
protected:
    float color[3];     // Color of shape
    PtVec centerOfRot;  // Center point (used for rotations)
    PtVec offset;       // Current displacement
    double angle;       // Current rotation angle
    
    // Translate point from the shapes local coordinate
    // system to the global coordinate system by applying the
    // rotation and displacement.
    PtVec translateRotatePoint(PtVec point)
    {
        PtVec localPoint = point - centerOfRot;
        localPoint.rotate(angle);
        localPoint = localPoint + centerOfRot;
        return localpoint + offset;  // convert to global coord.
    }
    
    // Activate color: call lspxgraph's setColor() function
    void activateColor()
    { ::setColor(color[0], color[1], color[2]); }
    
    
public:
    // Constructor
    Shape() : centerOfRot(0, 0), offset(0, 0), angle(0) {
        setColor(0, 0, 0);
    }
    // Destructor
    virtual ~Shape() {}

    // Accessor for shape color parameters
    virtual void setColor(float r, float g, float b)
    { color[0] = r; color[1] = g; color[2] = b; }
    virtual void getColor(float &r, float &g, float &b)
    { r = color[0]; g = color[1]; b = color[2]; }

    // Move shape to specified point
    void moveTo(PtVec point) { offset = point; }
    
    // Move shape (relative to current position)
    void move(PtVec vec) { offset = offset + vec; }
    
    // Accessor for rotation center
    void setCenter(PtVec p) { centerOfRot = p; }
    PtVec getCenter() { return centerOfRot; }
    
    // Modify current rotation angle
    void rotate(double theta)
    { angle = fmod(angle + theta, 2*M_PI); }

    // Set rotation angle to specified value
    void setAngle(double alpha) { angle = fmod(alpha, 2*M_PI); }
    double getAngle() { return angle; }
    
    // Abstract function which draws the shape
    virtual void draw() = 0;
};

Une fonction particulière est translateRotatePoint(). Elle applique à un point donné la rotation autour du centre et la translation. Cette fonction utilitaire permet aux sous-classes de ne pas devoir s'occuper de la position et la rotation de la forme. (C'est pour ça qu'on fait des classes, non?)

Manipulation 4.1 (Test de Shape) : Etudiez le fonctionnement de la classe Shape et écrivez une sous-classe TestTriangle qui implémente la méthode draw() pour dessiner un triangle. Utilisez comme points de base du triangle les points (0, 0), (1, 0) et (0.5, 1).

Conseils :

Question 4.1 : Pourquoi est-il nécessaire d'appliquer la fonction translateRotatePoint() à chaque point de la forme lors de l'exécution de draw()?

La classe abstraite MathShape

Maintenant, nous aimerions implémenter un moyen alternatif de définir des formes. Au lieu d'implémenter la méthode draw() dans toutes les classes de forme, on aimerait définir une forme à partir d'une fonction génératrice f. Comme on aimerait qu'il s'agisse toujours de formes qu'on peut déplacer et tourner à volonté, la classe MathShape hérite de la classe Shape vue dans l'étape précédente.

Une forme mathématique est entièrement définie par une fonction génératrice f et le domaine de définition de la fonction. La fonction f est identique à ce qu'on a vu dans l'exercice précédent sur les fonctions paramétriques. Le domaine de définition définit le domaine dans lequel le paramètre t peut varier, p.ex. [0, 2*PI] pour un cercle.

Manipulation 4.2 : Créez la classe abstraite MathShape. Elle hérite de la classe Shape et a les propriétés et méthodes suivantes :

La classe MathShape est très similaire à la classe PFunction qui a été créée lors de l'exercice précédent. Vous pouvez donc reprendre une grande partie du code!

Testez votre implémentation avec le programme de test suivant : (classe Parabol et fonction MathShape)

class Parabol : public MathShape
{
protected:
    double a, b, c;
    
    PtVec f(double t) { return PtVec(t, a*t*t+b*t+c); }
    
public:
    Parabol(double aa, double bb, double cc) :
        MathShape(-1, 1),           // Default domain [-1..1]
        a(aa), b(bb), c(cc) { }
    virtual ~Parabol() {}
};


// Test function to test the MathShape implementation
void testMathShape(void)
{
    // Create figure window
    createWindow("MathShape test", 500, 500);
    
    // Define xmin, xmax, ymin, ymax for figure window
    setAxis(-5, 5, -5, 5);
    
    // Create instance and draw it at initial position
    Parabol p(1, 0, 0);
    p.draw();
    
    // Change domain
    p.setDomain(-2, 2);
    
    // Change color to red, rotate the parabol
    p.setColor(1, 0, 0);
    p.rotate(0.5);
    p.draw();
        
    flushWindow();      // make sure everything is drawn
    sleep(3);           // wait some seconds
    
    closeWindow();
}

Manipulation 4.3 : Créez des sous-classes Circle et Ellipse qui héritent de MathShape et implémentent un cercle dont le rayon est modifiable et une ellipse dont les demis-axes a et b peuvent être spécifiés.

Conseil : Vous pouvez implémenter le cercle comme cas particulier d'ellipse. La fonction paramétrique de l'ellipse est : x=a*cos(t), y=b*sin(t)

"Rouler" un objet sur une trajectoire

Cercle roulant Quand un objet roule sur une trajectoire d'un point p1 à un point p2, l'objet doit être déplacé et tourné. Au point p1 l'objet touche la trajectoire au point q1. Au point p2 de la trajectoire, le point de contact est devenu le point q2'.

Si l'objet se déplace d'un point p1 vers un point p2, il nous faut calculer le nouveau point de contact q2. Ce point est déterminé par le point q1 et la distance entre les point p1 et p2. Il faut donc avoir une connaissance exacte de la longueur du contour. Pour ce faire on peut utiliser la formule suivante :

formule d'approximation de la distance (4.1)

Manipulation 4.4 (optionnel) : Ajoutez à la classe MathShape une méthode computeContourLength() qui approxime la longueur du contour par la formule (4.1). Utilisez la constante Δt = 10-5.

Vérifiez votre résultat en calculant le contour du cercle avec rayon r=1 et d'une ellipse avec les demi-axes a=4 et b=2. Les résultats que vous obtenez sont respectivement 6.283 et 19.38.

Manipulation 4.5 : Créez dans la classe MathShape une méthode advance() qui retourne le paramètre t2 correspondant au point se trouvant à une distance donnée d'un point t1 lorsqu'on avance le long de la fonction génératrice. Utilisez la formule (4.1) et le prototype suivant :

    virtual double advance(double t1, double length)

Tangents La méthode advance() nous permet de calculer à partir d'un point de contact actuel q1 et la distance entre les point de trajectoire p1 et p2 le nouveau point de contact q2. Pour faire semblant que l'objet "roule" sur la trajectoire l'objet doit être déplacé tel que le point q2 et le point p2 de la trajectoire coincïdent et l'objet doit être tourné tel que les tangentes au point q2 et au point p2 coincïdent.

Manipulation 4.6 : Ajoutez à la classe MathShape les méthodes :

  • PtVec getTangent(double t) qui retourne un vecteur tangent à la fonction génératrice f au point t. Approximez la tangente avec f(t+Δt)-f(t) en utilisant le même Δt comme à la manipulation 4.4.
  • void drawTangent(double t) qui dessine la tangente au point donné par t. Cette méthode peut aider à déboguer la suite de l'étape.

Conseil : Le vecteur tangent retourne par getTangent(t) doit correspondre au point retourné par computePoint(t). Alors il faut que getTangent() respecte l'orientation de la forme!

Manipulation 4.7 : Ecrivez une méthode alignWithTangentAt() qui déplace et tourne la forme (utilisez les méthodes move()/moveTo() et rotate()/setAngle()) pour que la tangente et le point donnée par le paramètre t coïncident avec le point et la tangente passé comme argument. Utilisez le prototype suivant :

void alignWithTangentAt(PtVec dst_pt, PtVec dst_tangVec, double t)

Conseil : Utilisez la fonction getTangent(t) et computePoint(t) pour calculer le point et la tangente à partir du paramètre t.

Manipulation 4.8 : La classe MathShape est maintenant complète et fournit tous ce qui est nécessaire pour implémenter la fonction principale roll(). Cette fonction parcourt la trajectoire par petits pas en "roulant" l'objet. Ceci nous permet de simuler la cycloïde et l'astroïde.

Ecrivez la fonction en traduisant le pseudo-code suivant : (=cette fonction n'appartient à aucune classe!)


void roll(MathShape &traj, MathShape &obj, int steps)
{
  déterminer le domaine de définition de la trajectoire
  déterminer le domaine de définition de l'objet
    
  initialiser les paramètres t pour la trajectoire et l'objet
  
  dessiner la trajectoire
  
  calculer le premier point de la trajectoire
  
  répéter pour chaque pas
    déterminer la tangente au point courant de la trajectoire
    aligner l'objet avec la trajectoire
  
    dessiner l'objet (ou seulement un point de l'objet)
  
    augmenter le paramètre t de la trajectoire d'une quantité
        correspondant d'un pas.
    calculer le prochain point sur la trajectoire
    calculer la distance entre le point courant et le prochain
        point de la trajectoire
    
    calculer le nouveau paramètre t de l'objet qui correspond au
        déplacement sur le contour de la fonction génératrice
        (fonction advance())
  
    (optionnel: faire une petite pause en utilisant usleep())
    
    le prochain point de la trajectoire devient le point courant
  fin de répétition
}

Manipulation 4.9 : Testez votre implémentation en faisant rouler un cercle de rayon 1 sur un segment de droite (=cycloïde) et ensuite sur un cercle de rayon 4 (=astroïde). Utilisez la classe LineSegment comme segment de droite :

class LineSegment : public MathShape
{
protected:
    PtVec point, vec;
    
    PtVec f(double t) { return point + (vec*t); }
    
public:
    LineSegment(double p1_x, double p1_y,
                double p2_x, double p2_y) :
        MathShape(0, 1),        // Parameter between 0..1
        point(p1_x, p1_y),
        vec(p2_x-p1_x, p2_y-p1_y) {}
    
    virtual ~LineSegment() {}
    
    // Draw using only 1 line (=no subdivision)
    void draw() { MathShape::draw(1); }
};

Comparez le résultat avec les fonctions paramétriques Cycloid et Astroid de l'exercice précédent.

Question 4.2 : Pourquoi est-il impossible de laisser rouler le petit cercle de l'astroïde à l'extérieur du cercle trajectoire?

Si vous avez fait une bonne implémentation, vous devriez être capable de rouler un cercle sur une ellipse, ou l'inverse, ou un cercle sur une parabole, ...

Cercle roulant sur la parabole