PROGRAMOWANIE – PRZYKŁADY
IDE C++ WinBGI C# Wpadki

Przykład C#

Tworzenie epicykloidy i hipocykloidy Równania krzywych i ich implementacja Formularz i animacja Program w C# Rozwiązanie obiektowe Program w C# (wersja 2) Poprzedni przykład Następny przykład Program w C++ Kontakt

Tworzenie epicykloidy i hipocykloidy

Zadaniem programu jest przedsta­wienie w postaci animacji procesu powsta­wania epicykloidyhipocykloidy. Obie krzywe przestępne są opisy­wane przez koniec ramienia przytwier­dzonego sztywno do okręgu (koła) toczą­cego się bez poślizgu po stałym okręgu. Gdy okręgi stykają się zewnę­trznie (rys. poniżej z lewej), krzywa jest epicy­kloidą, a gdy wewę­trznie (rys. poniżej z prawej), jest hipocy­kloidą. Oczywiście w drugim przypadku promień ruchomego okręgu powinien być mniejszy od promienia nieru­chomego okręgu.

Na rysunkach oznaczono przez O i S środki okręgów stałego i ruchomego, przez R i r ich promienie, zaś przez d długość ramienia SP przytwier­dzonego do toczącego się okręgu. Przyjęto ponadto, że środek O stałego okręgu jest jednocze­śnie środkiem układu współ­rzędnych i na początku ruchu środek S toczącego się okręgu leży na dodatniej półosi x, a punkt P po jego lewej stronie, gdy okręgi stykają się zewnętrznie, albo po prawej, gdy stykają się wewnętrznie. Dowodzi się, że epicy­kloida spełnia równania

a hipocykloida równania

Kształt krzywych zależy od stosunku R do r. Jeżeli R jest podzielne przez r, krzywa jest zamknięta, a jeżeli nie, zamyka się po pewnej liczbie obiegów ruchomego okręgu, gdy R/r jest liczbą wymierną, a nie zamyka się nigdy, gdy jest liczbą niewy­mierną. Rozróżnia się również epicy­kloidę i hipocy­kloidę zwyczajną, gdy punkt P leży na toczącym się okręgu, czyli gdy d=r, skróconą, gdy d<r, oraz wydłużoną, gdy d>r, zaś gdy d=0, epicy­kloida redukuje się do okręgu o pro­mieniu R+r, a hipocy­kloida do okręgu o pro­mieniu R-r. Dla d<0 ustawienie początkowe punktu P jest przeciwne względem środka S.

Równania krzywych i ich implementacja

Przypomnijmy, że powyższe równania parame­tryczne były stosowane w programie rysowania epicy­kloidy i hipocy­kloidy. Określają one zależność współrzę­dnych punktu leżącego na krzywej od kąta φ, jaki tworzy odcinek łączący środki obu okręgów z osią x. Bardziej odpowie­dnią do wykorzy­stania w animacji jest zależność współrzę­dnych tego punktu od kąta t pomiędzy prostą przecho­dzącą przez środki okręgów a ramie­niem d (rys.). Parametr t kojarzy się wówczas z czasem, w miarę upływu którego okrąg o pro­mieniu r obraca się i toczy po stałym okręgu o pro­mieniu R, a koniec przytwier­dzonego do ruchomego okręgu ramienia, na którym głównie skupia uwagę obser­wator, rysuje krzywą.

Ruch jednego okręgu po drugim odbywa się bez poślizgu, toteż łuk AB ma taką samą długość jak łuk BC (rys. powyżej, łuki wyróżnione kolorem oliwkowym):

Stąd i powyższych równań parametry­cznych uzyskujemy równania epicy­kloidy

oraz równania hipocykloidy

Pierwsze składniki wyrażeń po prawej stronie znaków równości określają w obydwu przypad­kach współ­rzędne środka ruchomego okręgu. Można zatem równania te wykorzystać nie tylko wyzna­czania współrzę­dnych końca ramienia (punktu na krzywej), lecz także środka toczącego się okręgu. Uwzglę­dniając inną niż (0,0) lokali­zację (p,q) środka stałego okręgu na powierz­chni ryso­wania i odwrotny kierunek osi y układu ekrano­wego, obliczenia te możemy sformu­łować w postaci dwóch metod o takiej samej sygna­turze:

private void epicykloida(double t, out float xs, out float ys, out float xp, out float yp)
{
    double fi = t * r / R, psi = t + fi, x, y;
    xp = (float)((x = (R + r) * Math.Cos(fi)) - d * Math.Cos(psi)) + p;
    yp = q - (float)((y = (R + r) * Math.Sin(fi)) - d * Math.Sin(psi));
    xs = (float)x + p;
    ys = q - (float)y;
}

private void hipocykloida(double t, out float xs, out float ys, out float xp, out float yp)
{
    double fi = t * r / R, psi = t - fi, x, y;
    xp = (float)((x = (R - r) * Math.Cos(fi)) + d * Math.Cos(psi)) + p;
    yp = q - (float)((y = (R - r) * Math.Sin(fi)) - d * Math.Sin(psi));
    xs = (float)x + p;
    ys = q - (float)y;
}

Obie metody obliczają dla podanej wartości argu­mentu t reprezen­tującej bieżący czas współ­rzędne środka ruchomego okręgu i końca ramienia opisu­jącego krzywą, przeka­zując je przez referencję w argu­mentach xs, ys (środek okręgu) i xp, yp (koniec ramienia). Przyjęcie typu float zamiast int ma na celu polepszenie płynności animacji. Decyzję o tym, która krzywa ma być genero­wana, czyli która z metod ma być używana, będzie podejmo­wana przez użytko­wnika podczas wykony­wania programu. Wybrana metoda będzie wskazy­wana przez zmienną referen­cyjną typu obiekto­wego zwanego delegatem. Typ ten definiujemy nastę­pująco:

delegate void Cykloida(double t, out float xs, out float ys, out float xp, out float yp);

Definicja delegata może wystąpić wewnątrz klasy bądź poza nią. Powyższą defi­nicję umieścimy w pliku Form1.cs poza klasą formu­larza, miano­wicie tuż po dyrekty­wach using.

Uwaga. De facto cykloida jest krzywą opisywaną przez koniec ramienia przytwier­dzonego do okręgu (koła) toczącego się bez poślizgu po prostej. Gdy taki okrąg toczy się wzdłuż zewnętrznej lub wewnętrznej strony stałego okręgu, zakreślona przez koniec ramienia krzywa jest trochoidą.

Formularz i animacja

Podobnie jak w programie generowania krzywych Lissajous, formu­larz aplikacji jest podzie­lony na dwie części (rys.). Jego pierwszą częścią jest szary panel zadokowany przy lewej krawędzi okna zawiera­jący dwa kontenery typu GroupBox z dwoma przyciskami radiowymi służącymi do wyboru krzywej i trzema kompo­nentami edycyj­nymi do wprowa­dzania parame­trów krzywej oraz cztery zwykłe przyciski do stero­wania animacją. Właści­wość Anchor przycisków steru­jących jest ustawiona na BottomLeft, dzięki czemu po zmianie rozmiaru okna pozostaną w takiej samej odległości od dolnej i lewej krawędzi panela jak na początku. Drugą część formu­larza stanowi wolna (niezajęta przez panel) powierz­chnia ryso­wania koloru jasno-kremowego. Na niej będzie reali­zowana animacja. Rozmiar okna zostanie ustalony na początku wyko­nania programu w metodzie obsługi zdarzenia Load, użytkownik nie będzie mógł go zmieniać (właści­wości FormBorder­StyleMaxi­mizeBox formu­larza ustawione, odpowiednio, na Fixed­SingleFalse).

Wstępnie tylko pierwszy przycisk jest aktywny, trzy pozostałe blokujemy, ustawiając ich właści­wość Enabled na False. Naciśnięcie przycisku Start ma uruchamiać animację dla widnie­jących na panelu parametrów krzywej. W trakcie animacji tylko przycisk Stop będzie aktywny. Gdy zostanie naciśnięty, animacja zostanie zatrzymana. Użytkownik będzie mógł wtedy wznowić animację za pomocą przycisku Dalej lub wyczyścić powierz­chnię rysowania i przejść do edycji parame­trów krzywej, naciskając przycisk Wyczyść. Parametry krzywej będzie można zmieniać na początku przebiegu programu lub po wyczyszczeniu powierzcni rysowania, podczas animacji będą niedo­stępne.

Animacja będzie oparta na zegarze klasy Timer generu­jącym zdarzenia co 20 milisekund. W celu zminima­lizowania efektu migo­tania ekranu metoda obsługi takiego zdarzenia będzie przetwa­rzać obrazy przecho­wywane na bitmapach w pamięci opera­cyjnej komputera i na koniec przery­sowywać na powierz­chni formu­larza fragment obrazu, który uległ zmianie od poprze­dniego zdarzenia zegaro­wego. W pro­gramie będą używane dwie bitmapy o jednakowym rozmiarze: pierwsza do ryso­wania krzywej tworzonej w trakcie animacji, druga do przygoto­wywania fragmentu obrazu zmienio­nego w kolejnym kroku animacji i wyświe­tlania go na ekranie. Referencje do nich będą przecho­wywane w dwóch polach klasy Form1, których pełny zestaw jest bardziej liczny:

private const int NB = 600;         // Rozmiar obszaru animacji (NBxNB)
private const double DT = 0.025;    // Odstęp czasowy

private Bitmap Krzywa = null;       // Obraz generowanej krzywej
private Bitmap Ekran = null;        // Kopia obrazu na ekranie
private Cykloida cykloida;          // Generowana krzywa (delegat)
private double R;                   // Promień stałego okręgu
private double r;                   // Promień ruchomego okręgu
private double d;                   // Długość ramienia
private float p, q;                 // Środek stałego okręgu
private float xs, ys;               // Środek ruchomego okręgu
private float xp, yp;               // Koniec ramienia
private double t;                   // Bieżący czas
private int a;                      // Parametr kopiowanego prostokąta

Obie bitmapy najwygodniej jest utworzyć w metodzie obsługi zdarzenia Load formu­larza, które jest wyzwalane, gdy okno zostało załado­wane do pamięci i ma być po raz pierwszy wyświe­tlone. Zgodnie z wcześniej­szym ustale­niem metoda powinna również wyznaczyć rozmiar okna apli­kacji. Gwoli ścisłości, łatwiej tego dokonać, określając rozmiar obszaru robo­czego okna. Warto przy okazji obliczyć współ­rzędne środka stałego okręgu potrzebne w metodach Epicy­kloidaHipocy­kloida. Aby uniknąć wycieku pamięci, utworzone bitmapy należy zniszczyć za pomocą metody Dispose, gdy kończy się wykonanie programu, np. w metodzie obsługi zdarzenia FormClose formularza, które zachodzi po zamknięciu okna. Metody obsługi tych dwóch zdarzeń można sformu­łować następu­jąco:

private void Form1_Load(object sender, EventArgs e)
{
    ClientSize = new Size(panel1.Width + NB, NB);
    Krzywa = new Bitmap(NB, NB);
    Ekran = new Bitmap(NB, NB);
    p = q = NB / 2;
}

private void Form1_FormClosed(object sender, FormClosedEventArgs e)
{
    if (Ekran != null) Ekran.Dispose();
    if (Krzywa != null) Krzywa.Dispose();
}

Animacja rozpocznie się, gdy użytkownik naciśnie przycisk z etykietą Start. Metoda obsługi tego zdarzenia powinna pobrać para­metry krzywej z kontrolek panela, pokazać na ekranie obydwa okręgi i ramię w chwili początkowej zero, zablo­kować edycję danych, udostępnić przycisk Stop i zablo­kować pozostałe trzy przyciski oraz uaktywnić zegar generu­jący zdarzenia steru­jące animacją. Pobranie danych może się nie udać, toteż operacje te powinny być wykony­wane w bloku chro­nionym try–catch:

private void button1_Click(object sender, EventArgs e)
{
    try
    {
        ...                         // Pobierz dane
        ...                         // Pokaż ekran startowy
        groupBox1.Enabled = false;  // Blokada edycji
        groupBox2.Enabled = false;  // parametrów krzywej
        button1.Enabled = false;
        button2.Enabled = true;
        button2.Focus();
        timer1.Start();
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message, "Błąd", MessageBoxButtons.OK, MessageBoxIcon.Error);
    }
}

Przy formułowaniu metody pobierania danych posłużymy się używaną w programie kreślenia epicy­kloidy i hipocy­kloidy metodą Liczba, która przekształca tekst kontrolki edycy­jnej określonej w pierwszym argu­mencie na liczbę rzeczy­wistą, traktując jako poprawne tylko liczby dodatnie lub dowolnego znaku w zale­żności od wartości logicznej true lub false drugiego argu­mentu. Pobrane wartości wypada przeska­lować, by oba okręgi, ramię i krzywa mieściły się z niewielkim margi­nesem na bitmapach i powierz­chni ryso­wania. Kierując się względami estety­cznymi, dobieramy czynnik skalujący tak, by cały obraz mieścił się w okręgu o pro­mieniu równym 9/10 odle­głości środka bitmapy od jej brzegu. Na koniec ustalamy jeszcze, która krzywa będzie genero­wana, przypi­sując zmiennej referen­cyjnej cykloida metodę Epicy­kloida lub Hipocy­kloida. Rozwa­żania te prowadzą do następu­jącej metody:

private void Pobierz_dane()
{
    double R = Liczba(textBox1, true);
    double r = Liczba(textBox2, true);
    if (radioButton2.Checked && (R <= r))
        throw new Exception("Nieprawidłowe dane (powinno być r < R).");
    double d = Liczba(textBox3, false);
    double skala = 0.9 * Math.Min(p, q) / (
                   (radioButton1.Checked ? (R + r) : (R - r)) + Math.Max(r, Math.Abs(d)));
    this.R = skala * R;
    this.r = skala * r;
    this.d = skala * d;
    cykloida = radioButton1.Checked ? (Cykloida)epicykloida : (Cykloida)hipocykloida;
}

Druga zapowiedziana metoda wywoływana bezpośre­dnio po pobraniu parame­trów krzywej ma pokazać na ekranie oba okręgi i ramię w pozycji startowej tuż przed rozpo­częciem animacji. Reali­zacja tego zadania polega na wyczyszczeniu bitmapy Krzywa i naryso­waniu na niej stałego okręgu, obli­czeniu współrzę­dnych środka rucho­mego okręgu i końca ramienia w chwili początkowej zero, skopio­waniu obrazu reprezento­wanego przez bitmapę Krzywa na powierz­chnię bitmapy Ekran i doryso­waniu na niej rucho­mego okręgu wraz z ramieniem, a na koniec skopio­waniu obrazu z bitmapy Ekran na powierz­chnię formu­larza:

private void Ekran_startowy()
{
    using (Graphics g = Graphics.FromImage(Krzywa))
    {
        float Rf = (float)R;
        g.Clear(BackColor);
        g.DrawEllipse(Pens.DarkCyan, p - Rf, q - Rf, 2 * Rf, 2 * Rf);
    }
    cykloida(t = 0, out xs, out ys, out xp, out yp);
    using (Graphics g = Graphics.FromImage(Ekran))
    {
        float rf = (float)r;
        g.DrawImage(Krzywa, 0, 0);
        g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
        g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
    }
    using (Graphics g = CreateGraphics())
        g.DrawImage(Ekran, panel1.Width, 0);
    a = (int)Math.Max(Math.Abs(d), r) + 7;
}

Operacje rysowania wymagają dostępu do obiektów klasy Graphics, które są tworzone w zasięgu instrukcji using gwarantu­jącej zwolnienie zasobów systemo­wych po zakoń­czeniu operacji grafi­cznych. W trakcie animacji prosto­kątny fragment obrazu zamazany przez ruchomy okrąg i ramię musi być w następnym momencie czasowym odtwo­rzony. Prostokąt ten jest kwadratem o boku 2a, gdzie a jest większą z wartości r i d (rys.). Parametr a warto nieco powiększyć (przyjęto 7 pikseli), aby uniknąć obcinania obrazu na brzegu prostokąta. Wyzna­czenie wartości tego parametru jest ostatnią operacją rozpatry­wanej metody.

Metoda obsługi zdarzenia Tick generowa­nego cyklicznie przez zegar przetwarza obie bitmapy i rysuje na powierz­chni formu­larza fragment obrazu, który uległ zmianie w odstępie czasu, jaki upłynął od poprze­dniego zdarzenia zegaro­wego. W poniższym sformuło­waniu metody kluczową rolę odgry­wają trzy prosto­kąty (struktury typu RectangleF) określa­jące lokali­zację i rozmiar fragmentów obrazu:

private void timer1_Tick(object sender, EventArgs e)
{
    RectangleF r1 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
    float xpop = xp, ypop = yp;
    cykloida(t += DT, out xs, out ys, out xp, out yp);
    using (Graphics g = Graphics.FromImage(Krzywa))
    {
        using (Pen pióro = new Pen(Color.Red, 2))
            g.DrawLine(pióro, xpop, ypop, xp, yp);
    }
    RectangleF r2 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
    RectangleF ru = RectangleF.Union(r1, r2);
    using (Graphics g = Graphics.FromImage(Ekran))
    {
        float rf = (float)r;
        g.DrawImage(Krzywa, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
        g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
        g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
    }
    using (Graphics g = CreateGraphics())
        g.DrawImage(Ekran, ru.Left + panel1.Width, ru.Top, ru, GraphicsUnit.Pixel);
}

Na początku metoda wyznacza prostokąt r1 zawiera­jący ruchomy okrąg i ramię oraz zapamię­tuje współ­rzędne jego końca w pomocni­czych zmiennych lokalnych, ponieważ będą one potrzebne do ryso­wania odcinka wyobraża­jącego fragment krzywej wygene­rowany w czasie, jaki upłynął od poprze­dniego zdarzenia zegarowego. Następnie oblicza nowe współ­rzędne środka ruchomego okręgu i końca ramienia dla kolejnego momentu czasowego, rysuje czerwonym piórem o szero­kości 2 odcinek na bitmapie Krzywa oraz wyznacza prosto­kąty r2ru. Po tych przygoto­waniach metoda kopiuje prostokąt ru z bitmapy Krzywa na bitmapę Ekran, wymazując tym samym dotychcza­sowy okrąg i ramię z bitmapy Ekran, po czym rysuje na niej nowy okrąg i ramię. Na koniec kopiuje prostokąt ru z bitmapy Ekran w odpo­wiednie miejsce na powierz­chni formu­larza, przez co odnosi się wrażenie, że okrąg przesunął się nieco i obrócił wraz z ramieniem, którego koniec dorysował kolejny fragment krzywej. W ten sposób uzyskuje się efekt toczenia się jednego okręgu wzdłuż drugiego i ryso­wania krzywej za pomocą ramienia przytwier­dzonego do rucho­mego okręgu.

Program w C#

Pełny kod źródłowy programu animacji ukazującej powsta­wanie epicy­kloidy i hipocy­kloidy zawarty w pliku Form1.cs jest przedsta­wiony na poniższym listingu. W programie uwzglę­dniono nieco inny sposób blokady edycji parame­trów krzywej w metodzie obsługi zdarzenia Click przycisku Start oraz niesprecy­zowane dotąd metody obsługi tego zdarzenia dla trzech pozostałych przycisków steru­jących animacją opisanych etykietami Stop, DalejWyczyść, a także metodę obsługi zdarzenia Paint formu­larza, bez której obraz po zwinięciu i przywró­ceniu okna nie byłby wyświe­tlany poprawnie (por. program generowania krzywych Lissajous).

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

delegate void Cykloida(double t, out float xs, out float ys, out float xp, out float yp);

namespace EpiHipo1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private const int NB = 600;         // Rozmiar obszaru animacji (NBxNB)
        private const double DT = 0.025;    // Odstęp czasowy

        private Bitmap Krzywa = null;       // Obraz generowanej krzywej
        private Bitmap Ekran = null;        // Kopia obrazu na ekranie
        private Cykloida cykloida;          // Generowana krzywa (delegat)
        private double R;                   // Promień stałego okręgu
        private double r;                   // Promień ruchomego okręgu
        private double d;                   // Długość ramienia
        private float p, q;                 // Środek stałego okręgu
        private float xs, ys;               // Środek ruchomego okręgu
        private float xp, yp;               // Koniec ramienia
        private double t;                   // Bieżący czas
        private int a;                      // Parametr kopiowanego prostokąta

        private void Form1_Load(object sender, EventArgs e)
        {
            ClientSize = new Size(panel1.Width + NB, NB);
            Krzywa = new Bitmap(NB, NB);
            Ekran = new Bitmap(NB, NB);
            p = q = NB / 2;
        }

        private void Form1_FormClosed(object sender, FormClosedEventArgs e)
        {
            if (Ekran != null) Ekran.Dispose();
            if (Krzywa != null) Krzywa.Dispose();
        }

        private void Form1_Paint(object sender, PaintEventArgs e)
        {
            e.Graphics.DrawImage(Ekran, panel1.Width, 0);
        }

        private double Liczba(TextBox tb, bool plus)
        {
            tb.Focus();
            double x = Convert.ToDouble(tb.Text);
            if (plus && (x <= 0))
                throw new Exception("Wartość powinna być dodatnia.");
            return x;
        }

        private void Pobierz_dane()
        {
            double R = Liczba(textBox1, true);
            double r = Liczba(textBox2, true);
            if (radioButton2.Checked && (R <= r))
                throw new Exception("Nieprawidłowe dane (powinno być r < R).");
            double d = Liczba(textBox3, false);
            double skala = 0.9 * Math.Min(p, q) / (
                           (radioButton1.Checked ? (R + r) : (R - r)) + Math.Max(r, Math.Abs(d)));
            this.R = skala * R;
            this.r = skala * r;
            this.d = skala * d;
            cykloida = radioButton1.Checked ? (Cykloida)epicykloida : (Cykloida)hipocykloida;
        }

        private void epicykloida(double t, out float xs, out float ys, out float xp, out float yp)
        {
            double fi = t * r / R, psi = t + fi, x, y;
            xp = (float)((x = (R + r) * Math.Cos(fi)) - d * Math.Cos(psi)) + p;
            yp = q - (float)((y = (R + r) * Math.Sin(fi)) - d * Math.Sin(psi));
            xs = (float)x + p;
            ys = q - (float)y;
        }

        private void hipocykloida(double t, out float xs, out float ys, out float xp, out float yp)
        {
            double fi = t * r / R, psi = t - fi, x, y;
            xp = (float)((x = (R - r) * Math.Cos(fi)) + d * Math.Cos(psi)) + p;
            yp = q - (float)((y = (R - r) * Math.Sin(fi)) - d * Math.Sin(psi));
            xs = (float)x + p;
            ys = q - (float)y;
        }

        private void Ekran_startowy()
        {
            using (Graphics g = Graphics.FromImage(Krzywa))
            {
                float Rf = (float)R;
                g.Clear(BackColor);
                g.DrawEllipse(Pens.DarkCyan, p - Rf, q - Rf, 2 * Rf, 2 * Rf);
            }
            cykloida(t = 0, out xs, out ys, out xp, out yp);
            using (Graphics g = Graphics.FromImage(Ekran))
            {
                float rf = (float)r;
                g.DrawImage(Krzywa, 0, 0);
                g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
                g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
            }
            using (Graphics g = CreateGraphics())
                g.DrawImage(Ekran, panel1.Width, 0);
            a = (int)Math.Max(Math.Abs(d), r) + 7;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            try
            {
                Pobierz_dane();
                Ekran_startowy();
                groupBox1.Enabled = false;
                groupBox2.Enabled = false;
                button1.Enabled = false;
                button2.Enabled = true;
                button2.Focus();
                timer1.Start();
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "Błąd", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

        private void button2_Click(object sender, EventArgs e)
        {
            timer1.Stop();
            button2.Enabled = false;
            button3.Enabled = true;
            button4.Enabled = true;
            button3.Focus();
        }

        private void button3_Click(object sender, EventArgs e)
        {
            button2.Enabled = true;
            button3.Enabled = false;
            button4.Enabled = false;
            button2.Focus();
            timer1.Start();
        }

        private void button4_Click(object sender, EventArgs e)
        {
            using (Graphics g = Graphics.FromImage(Ekran))
                g.Clear(BackColor);
            using (Graphics g = CreateGraphics())
                g.Clear(BackColor);
            button1.Enabled = true;
            button3.Enabled = false;
            button4.Enabled = false;
            groupBox1.Enabled = true;
            groupBox2.Enabled = true;
            if (radioButton1.Checked)
                radioButton1.Focus();
            else
                radioButton2.Focus();
        }

        private void timer1_Tick(object sender, EventArgs e)
        {
            RectangleF r1 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
            float xpop = xp, ypop = yp;
            cykloida(t += DT, out xs, out ys, out xp, out yp);
            using (Graphics g = Graphics.FromImage(Krzywa))
            using (Pen pióro = new Pen(Color.Red, 2))
                g.DrawLine(pióro, xpop, ypop, xp, yp);
            RectangleF r2 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
            RectangleF ru = RectangleF.Union(r1, r2);
            using (Graphics g = Graphics.FromImage(Ekran))
            {
                float rf = (float)r;
                g.DrawImage(Krzywa, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
                g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
                g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
            }
            using (Graphics g = CreateGraphics())
                g.DrawImage(Ekran, ru.Left + panel1.Width, ru.Top, ru, GraphicsUnit.Pixel);
        }
    }
}

Poniższy rysunek przedstawia hipocy­kloidę o parame­trach R=2, r=0,45 i d=1,75 w trakcie jej genero­wania. Obraz okna programu został uchwycony tuż przed zamknięciem krzywej.

Rozwiązanie obiektowe

Realizowana w powyższym programie animacja wymagała rozbu­dowy klasy Form1 o szereg pól określa­jących cechy wyświe­tlanych figur geometry­cznych (dwóch okręgów, ramienia i krzywej) oraz metod opisu­jących ich zachowanie. W drugiej wersji programu składowe te wyodrę­bnimy, naturalnie z pewnymi zmianami, do nowej klasy o nazwie Rysunek. Przy okazji zastąpimy ruchomy okrąg kołem, przez co przekaz animacji stanie się bardziej ekspre­syjny. Zmienimy również formularz aplikacji, dokując panel sterujący przy jego prawym brzegu (rys.), co nieco uprości wyświe­tlanie obrazu, gdyż nie trzeba będzie wtedy uwzglę­dniać szero­kości panela przy kopio­waniu bitmapy Ekran lub jej fragmentu na powierz­chnię obszaru robo­czego okna.

Zatem po zbudowaniu formularza programu dodajemy do jego projektu nowy składnik (pole­cenie Dodaj klasę...), zmie­niając zapropono­waną przez Visual Studio nazwę Class.cs na Rysunek.cs. Utworzona klasa powinna mieć dostęp do podsta­wowych narzędzi grafi­cznych klasy Graphics i niektórych składo­wych klasy Form, toteż wygenero­wany przez system zestaw dyrektyw using uzupeł­niamy o dwie pozycje:

using System.Drawing;
using System.Windows.Forms;

Następnie z pliku Form1.cs poprze­dniej aplikacji kopiujemy do kodu zawartego w pliku Rysunek.cs definicję delegata Cykloida, metody epicy­kloidahipocy­kloida oraz wszystkie pola klasy Form1 oprócz dwóch stałych NBDT, które kopiujemy do klasy Form1 nowej aplikacji. Ponadto w klasie Rysunek dekla­rujemy dodat­kowe pola:

private Bitmap Koło = null;   // Prostokąt z obrazkiem koła
private Form okno;            // Formularz (okno) aplikacji

Bitmapa Koło o rozmiarze określonym przez parametr a będzie zawierać obraz turkuso­wego koła o nieco ciemniej­szym brzegu otoczo­nego czarnym kolorem ustawionym jako przezro­czysty. Oznacza to, że po skopio­waniu jej na powierz­chnię innej bitmapy lub ekranu tło wokół naryso­wanego koła pozostanie niezmie­nione. Oczywiście tak samo jak poprze­dnio użyjemy bitmap KrzywaEkran do przetwa­rzania obrazów, by zminima­lizować efekt migotania ekranu. Tło bitmapy Krzywa będzie jednak, tak jak w przypadku bitmapy Koło, czarne i przezro­czyste. Obie bitmapy wygodnie jest utworzyć w konstru­ktorze klasy Rysunek, podając ich szerokość i wysokość:

public Rysunek(int szer, int wys, Form form)
{
    Krzywa = new Bitmap(szer, wys);
    Ekran = new Bitmap(szer, wys);
    okno = form;
    p = szer / 2;
    q = wys / 2;
}

Wartością trzeciego argumentu konstruktora jest refe­rencja do okna aplikacji użyteczna przy określaniu koloru tła bitmapy Ekran i wyświe­tlaniu jej na powierz­chni formularza. Utworzone w konstru­ktorze i w trakcie działania obiektu bitmapy powinny być zniszczone, gdy obiekt przestanie być potrzebny. Odpowie­dnią metodą do reali­zacji tych operacji jest destruktor wywoły­wany automa­tycznie przez wykonywany w oddzielnym wątku mechanizm odśmie­cania pamięci (ang. garbage collector), który zajmuje się wyszuki­waniem obiektów, do których nie ma referencji, oraz ich niszczeniem i zwal­nianiem zajmo­wanej przez nie pamięci. Chociaż nie można określić, kiedy mechanizm odśmiecania usunie obiekt, destruktor klasy Rysunek powinien niszczyć trzy bitmapy, jeśli oczywiście istnieją:

~Rysunek()
{
    if (Koło != null) Koło.Dispose();
    if (Ekran != null) Ekran.Dispose();
    if (Krzywa != null) Krzywa.Dispose();
}

Parametry krzywej potrzebne do funkcjono­wania obiektu klasy Rysunek przekażemy po jego utwo­rzeniu w argu­mentach metody Dane, która je dostosuje do rozmiaru powierz­chni rysowania i przypisze zmiennej cykloida referencję wskazu­jącą, która z dwóch krzywych ma być genero­wana:

public void Dane(double R, double r, double d, bool epic)
{
    double skala = 0.9 * Math.Min(p, q) / (
                   (epic ? (R + r) : (R - r)) + Math.Max(r, Math.Abs(d)));
    this.R = skala * R;
    this.r = skala * r;
    this.d = skala * d;
    cykloida = epic ? (Cykloida)epicykloida : (Cykloida)hipocykloida;
}

Pozostałe metody klasy Rysunek dotyczą przetwa­rzania obrazów w pamięci komputera oraz ryso­wania na powierz­chni formu­larza. Dzięki przezro­czystości bitmap KrzywaKoło nietrudno jest zastąpić operacje rysowania ruchomego okręgu na obrazie reprezen­towanym przez bitmapę Ekran i wymazy­wania go analogi­cznymi opera­cjami ryso­wania i wymazy­wania koła z zacho­waniem zasła­nianego fragmentu tego obrazu. Na przykład występu­jące w metodzie Ekran_­startowy instrukcje

using (Graphics g = Graphics.FromImage(Ekran))
{
    float rf = (float)r;
    g.DrawImage(Krzywa, 0, 0);
    g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
    g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
}

tworzące obraz przedstawiający stały i ruchomy okrąg oraz ramię w chwili początkowej zero, tj. tuż przed rozpo­częciem animacji, zastępujemy instrukcjami:

using (Graphics g = Graphics.FromImage(Ekran))
{
    g.DrawImage(Koło, xs - a, ys - a);
    g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
    g.DrawImage(Krzywa, 0, 0);
}

Oczywiście w drugim przypadku zamiast okręgu rysowane jest koło. Inna jest też kolej­ność tych instrukcji, miano­wicie najpierw na bitmapie Ekran rysowane jest koło i ramię, a dopiero potem kopio­wana jest bitmapa Krzywa zawiera­jąca obraz stałego okręgu. W podobny sposób zastępujemy występu­jące w metodzie timer1_Tick obsługi zdarzenia zegaro­wego instrukcje

using (Graphics g = Graphics.FromImage(Ekran))
{
    float rf = (float)r;
    g.DrawImage(Krzywa, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
    g.DrawEllipse(Pens.DarkCyan, xs - rf, ys - rf, 2 * rf, 2 * rf);
    g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
}

które kopiują prostokąt ru z bitmapy Krzywa na bitmapę Ekran (skutkiem czego jest wyma­zanie nieaktu­alnego okręgu i ramienia oraz doryso­wanie nowego odcinka krzywej), a następnie rysują w innym miejscu tej bitmapy okrąg i ramię. Przypo­mnijmy, że ru jest najmniej­szym prosto­kątem obejmu­jącym prosto­kąty r1r2 określa­jące dotychcza­sowe i nowe miejsce rucho­mego okręgu i ramienia. Dostoso­wane do nowych wymagań instrukcje mają postać:

using (Graphics g = Graphics.FromImage(Ekran))
{
    using (Brush pióro = new SolidBrush(okno.BackColor))
        g.FillRectangle(pióro, r1);
    g.DrawImage(Koło, r2.Left, r2.Top);
    g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
    g.DrawImage(Krzywa, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
}

Najpierw zdezaktualizowany fragment bitmapy Ekran zajmowany przez koło i ramię zostaje wypeł­niony kolorem tła okna, następnie w nowym jej miejscu rysowane jest koło i ramię, a na koniec na całym zmienionym fragmencie bitmapy odtwarzany jest stały okrąg i krzywa poprzez skopio­wanie w to miejsce odpowie­dniego prostokąta bitmapy Krzywa. Efektem wykonania tych operacji jest przesunięcie koła z przytwier­dzonym do niego ramieniem z jednego miejsca obrazu reprezento­wanego przez bitmapę Ekran na inne.

Sformułowane dwie sekwencje instrukcji użyjemy w metodach StartPrzesuń klasy Rysunek. Zadaniem metody Start jest przygoto­wanie trzech bitmap i wyświe­tlenie stałego okręgu, rucho­mego koła i ramienia w pozycji startowej, od której rozpo­czyna się animacja, natomiast metody Przesuń – doryso­wanie fragmentu krzywej oraz przesu­nięcie koła i ramienia do miejsca odle­głego od poprze­dniego o odcinek wyobra­żający drogę przebytą przez koło i ramię w odstępie czasu określonym w argu­mencie. Pełną definicję klasy możemy sprecy­zować następu­jąco:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Drawing;
using System.Windows.Forms;

delegate void Cykloida(double t, out float xs, out float ys, out float xp, out float yp);

namespace EpiHipo2
{
    class Rysunek
    {
        private Bitmap Krzywa = null;   // Obraz generowanej krzywej
        private Bitmap Ekran = null;    // Kopia obrazu na ekranie
        private Bitmap Koło = null;     // Prostokąt z obrazkiem koła
        private Form okno;              // Formularz (okno) aplikacji
        private Cykloida cykloida;      // Generowana krzywa (delegat)
        private double R;               // Promień stałego okręgu
        private double r;               // Promień ruchomego okręgu (koła)
        private double d;               // Długość ramienia
        private float p, q;             // Środek stałego okręgu
        private float xs, ys;           // Środek ruchomego okręgu (koła)
        private float xp, yp;           // Koniec ramienia
        private double t;               // Bieżący czas
        private int a;                  // Parametr kopiowanego prostokąta

        public Rysunek(int szer, int wys, Form form)
        {
            Krzywa = new Bitmap(szer, wys);
            Ekran = new Bitmap(szer, wys);
            okno = form;
            p = szer / 2;
            q = wys / 2;
        }

        ~Rysunek()
        {
            if (Koło != null) Koło.Dispose();
            if (Ekran != null) Ekran.Dispose();
            if (Krzywa != null) Krzywa.Dispose();
        }

        public void Dane(double R, double r, double d, bool epic)
        {
            double skala = 0.9 * Math.Min(p, q) / (
                           (epic ? (R + r) : (R - r)) + Math.Max(r, Math.Abs(d)));
            this.R = skala * R;
            this.r = skala * r;
            this.d = skala * d;
            cykloida = epic ? (Cykloida)epicykloida : (Cykloida)hipocykloida;
        }

        public void Start()
        {
            using (Graphics g = Graphics.FromImage(Krzywa))
            {
                float Rf = (float)R;
                g.Clear(Color.Black);
                g.DrawEllipse(Pens.DarkCyan, p - Rf, q - Rf, 2 * Rf, 2 * Rf);
            }
            Krzywa.MakeTransparent(Color.Black);
            a = (int)Math.Max(Math.Abs(d), r) + 7;
            Koło = new Bitmap(2 * a, 2 * a);
            using (Graphics g = Graphics.FromImage(Koło))
            {
                float rf = (float)r;
                g.Clear(Color.Black);
                g.FillEllipse(Brushes.Cyan, a - rf, a - rf, 2 * rf, 2 * rf);
                g.DrawEllipse(Pens.DarkCyan, a - rf, a - rf, 2 * rf, 2 * rf);
            }
            Koło.MakeTransparent(Color.Black);
            cykloida(t = 0, out xs, out ys, out xp, out yp);
            using (Graphics g = Graphics.FromImage(Ekran))
            {
                g.DrawImage(Koło, xs - a, ys - a);
                g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
                g.DrawImage(Krzywa, 0, 0);
            }
            using (Graphics g = okno.CreateGraphics())
                g.DrawImage(Ekran, 0, 0);
        }

        public void Przesuń(double dt)
        {
            RectangleF r1 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
            float xpop = xp, ypop = yp;
            cykloida(t += dt, out xs, out ys, out xp, out yp);
            using (Graphics g = Graphics.FromImage(Krzywa))
            using (Pen pióro = new Pen(Color.Red, 2))
                g.DrawLine(pióro, xpop, ypop, xp, yp);
            RectangleF r2 = new RectangleF(xs - a, ys - a, 2 * a, 2 * a);
            RectangleF ru = RectangleF.Union(r1, r2);
            using (Graphics g = Graphics.FromImage(Ekran))
            {
                using (Brush pędzel = new SolidBrush(okno.BackColor))
                    g.FillRectangle(pędzel, r1);
                g.DrawImage(Koło, r2.Left, r2.Top);
                g.DrawLine(Pens.DarkCyan, xs, ys, xp, yp);
                g.DrawImage(Krzywa, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
            }
            using (Graphics g = okno.CreateGraphics())
                g.DrawImage(Ekran, ru.Left, ru.Top, ru, GraphicsUnit.Pixel);
        }

        public void Wyczyść()
        {
            Koło.Dispose();
            Koło = null;
            using (Graphics g = Graphics.FromImage(Ekran))
                g.Clear(okno.BackColor);
            using (Graphics g = okno.CreateGraphics())
                g.Clear(okno.BackColor);
        }

        public void Pokaż(Graphics g)
        {
            g.DrawImage(Ekran, 0, 0);
        }

        private void epicykloida(double t, out float xs, out float ys, out float xp, out float yp)
        {
            double fi = t * r / R, psi = t + fi, x, y;
            xp = (float)((x = (R + r) * Math.Cos(fi)) - d * Math.Cos(psi)) + p;
            yp = q - (float)((y = (R + r) * Math.Sin(fi)) - d * Math.Sin(psi));
            xs = (float)x + p;
            ys = q - (float)y;
        }

        private void hipocykloida(double t, out float xs, out float ys, out float xp, out float yp)
        {
            double fi = t * r / R, psi = t - fi, x, y;
            xp = (float)((x = (R - r) * Math.Cos(fi)) + d * Math.Cos(psi)) + p;
            yp = q - (float)((y = (R - r) * Math.Sin(fi)) - d * Math.Sin(psi));
            xs = (float)x + p;
            ys = q - (float)y;
        }
    }
}

Niewspomniane dotąd metody Wyczyść i Pokaż powodują wypełnianie bitmapy Ekran i powierz­chni obszaru robo­czego okna aplikacji kolorem tła oraz odtwa­rzanie utraco­nego obrazu animacji. Pierwsza będzie wywoł­ywana, gdy użytko­wnik naciśnie przycisk Wyczyść, by przejść do edycji danych, zaś druga w metodzie obsługi zdarzenia Paint formularza, gdy obszar roboczy okna będzie wymagał przemalo­wania, np. przy przywra­caniu uprzednio zwinię­tego okna.

Uwaga. Niszczenie bitmapy Koło w metodzie Wyczyść jest potrzebne. Gdyby tego nie zrobić, nastą­piłby wyciek pamięci w przypadku, gdyby po zatrzy­maniu animacji wyczyścić ekran i po ew. edycji danych rozpocząć animację. Wówczas w metodzie Start utworzona byłaby bitmapa Koło bez zniszczenia poprze­dniej. Z kolei przypi­sanie zmiennej Koło referencji null zapo­biega w destru­ktorze obiektu niszczeniu nieistnie­jącej bitmapy.

Program w C# (wersja 2)

Kod źródłowy programu w C# korzysta­jącego z klasy Rysunek i ukazują­cego za pomocą animacji tworzenie epicy­kloidy i hipocy­kloidy jest przedsta­wiony na poniższym listingu.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace EpiHipo2
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private const int NB = 600;         // Rozmiar obszaru animacji (NBxNB)
        private const double DT = 0.025;    // Odstęp czasowy

        private Rysunek obiekt = null;

        private void Form1_Load(object sender, EventArgs e)
        {
            ClientSize = new Size(panel1.Width + NB, NB);
            obiekt = new Rysunek(NB, NB, this);
        }

        private void Form1_Paint(object sender, PaintEventArgs e)
        {
            obiekt.Pokaż(e.Graphics);
        }

        private double Liczba(TextBox tb, bool plus)
        {
            tb.Focus();
            double x = Convert.ToDouble(tb.Text);
            if (plus && (x <= 0))
                throw new Exception("Wartość powinna być dodatnia.");
            return x;
        }

        private void Pobierz_dane()
        {
            double R = Liczba(textBox1, true);
            double r = Liczba(textBox2, true);
            if (radioButton2.Checked && (R <= r))
                throw new Exception("Nieprawidłowe dane (powinno być r < R).");
            double d = Liczba(textBox3, false);
            obiekt.Dane(R, r, d, radioButton1.Checked);
        }

        private void button1_Click(object sender, EventArgs e)
        {
            try
            {
                Pobierz_dane();
                obiekt.Start();
                groupBox1.Enabled = false;
                groupBox1.Enabled = false;
                button1.Enabled = false;
                button2.Enabled = true;
                button2.Focus();
                timer1.Start();
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "Błąd", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

        private void button2_Click(object sender, EventArgs e)
        {
            timer1.Stop();
            button2.Enabled = false;
            button3.Enabled = true;
            button4.Enabled = true;
            button3.Focus();
        }

        private void button3_Click(object sender, EventArgs e)
        {
            button2.Enabled = true;
            button3.Enabled = false;
            button4.Enabled = false;
            button2.Focus();
            timer1.Start();
        }

        private void button4_Click(object sender, EventArgs e)
        {
            obiekt.Wyczyść();
            button1.Enabled = true;
            button3.Enabled = false;
            button4.Enabled = false;
            groupBox1.Enabled = true;
            groupBox1.Enabled = true;
            if (radioButton1.Checked)
                radioButton1.Focus();
            else
                radioButton2.Focus();
        }

        private void timer1_Tick(object sender, EventArgs e)
        {
            obiekt.Przesuń(DT);
        }
    }
}

Poniższy rysunek przedstawia epicy­kloidę o parame­trach R=3,75, r=3 i d=-5,2 wygene­rowaną przez program. Animacja została zatrzy­mana tuż przed zamknię­ciem krzywej.

Warto wspomnieć, że Mikołaj Kopernik (1473-1543), anali­zując traje­ktorie planet Układu Słone­cznego, sformu­łował interesu­jące twier­dzenia dotyczące zaga­dnienia powsta­wania hipocy­kloidy. Prawdzi­wość kilku z nich możemy sprawdzić, posłu­gując się powyż­szym programem. Jeśli miano­wicie będziemy obserwo­wali ruch koła toczącego się bez poślizgu wewnątrz dwa razy większego okręgu (R=2r), to zobaczymy, że


Opracowanie przykładu: wrzesień/październik 2019