Grafikus C# – Windows Forms tanfolyam › 9.10 lecke

Windows Forms · 9.10 · záró mini projekt

Mini projekt: rajzolóprogram

A 9. fejezet záró projektje egy rajzolóprogram: szabadkézi vonal, egyenes, téglalap és ellipszis; az alakzatok kijelölhetők, mozgathatók, átméretezhetők, a rétegsorrendjük módosítható; a műveletek visszavonhatók; háttérképet tölthetsz be; az eredményt PNG-ként vagy JPEG-ként exportálhatod, a szerkeszthető rajzot pedig saját formátumban mentheted. A program mindazt egyesíti, amit a fejezetben tanultál: képkezelés (9.1–9.2), Paint és Graphics (9.3), tollak és ecsetek (9.4), koordináták (9.5), egéres húzás (9.6), az alakzat-modell (9.7) és az erőforrás-kezelés (9.8).

  • kb. 8–10 óra
  • haladó szint
  • záró projekt
Anime stílusú fiú és lány egy C# programozási feladatot beszél meg

A projekt végére

  • összetett, többszörös felelősségű grafikus alkalmazást tervezel meg és építesz fel;
  • az alakzat-modellt kiegészíted új típussal (szabadkézi vonal), és a közös viselkedést az ősben tartod;
  • visszavonást és újra végrehajtást valósítasz meg pillanatképekkel;
  • kompozit képet (háttér + alakzatok) exportálsz PNG-be és JPEG-be;
  • szerkeszthető rajzot mentesz és töltesz be szöveges formátumban, hibatűréssel;
  • erőforrásokat kezelsz (using, Dispose), és a programot végigteszteled.

Mielőtt elkezded

Ehhez a projekthez a 9.1–9.9 lecke szükséges (különösen a 9.2, 9.6, 9.7 és 9.8), valamint a 7.8 lecke (OpenFileDialog, SaveFileDialog) és a 7.4 lecke (MessageBox). Új Windows Forms projektben vedd át a 9.7 lecke osztályait (Alakzat, KetPontos, Vonal, Teglalap, Ellipszis, Geometria) és a RajzPanel osztályt (9.3).

9.9 Grafikonok →

Követelmények és pontozás

RészKövetelményPont
Alakzat-modell (20)Absztrakt Alakzat ős és leszármazottak (Vonal, Teglalap, Ellipszis, Szabadkezi); a viselkedés (Rajzol, Hatar, Talal, Mozgat, Masol) polimorfizmussal, egyetlen List<Alakzat> kezeli őket.12
Pontos találat-vizsgálat (vékony vonal tűréssel, üres és kitöltött alakzat), hátulról előre; a rajzolás a Paint-ben, a modellből.8
Rajzolás és szerkesztés (25)Eszközök: szabadkézi (2 pixeles ritkítás, egy kattintás pont), vonal, téglalap, ellipszis (húzás közben gumi-előnézet), kijelölés.10
Kijelölt alakzat mozgatása (különbség módszer), átméretezése fogóponttal; szín, vastagság, kitöltés módosítása; a kijelölés keretet és fogót kap (nem csak színt).9
Z-sorrend: előre, hátra, egy lépés fel/le; törlés (Delete), mindent töröl (megerősítéssel).6
Visszavonás (10)Ctrl+Z és Ctrl+Y (pillanatképekkel, minden módosításra); az Esc megszakítja a húzást, és visszaállítja az állapotot.10
Képek (15)Háttérkép betöltése zárolás nélkül, hibatűréssel (9.1 lecke), arányos illesztéssel; eltávolítható.7
Export PNG-be és JPEG-be (minőségcsúszka, fehér háttér a JPEG-nél, 9.2 lecke): háttér + alakzatok, kijelölés nélkül, a panel méretében.8
Fájl (10)A szerkeszthető rajz mentése és betöltése szöveges (.rajz) formátumban; hibás sorok jelentése, adatvesztés nélkül; mentetlen változtatás kezelése (* a címben, megerősítés).10
Teljesítmény és erőforrás (10)Kettős pufferelés (RajzPanel), Invalidate(Rectangle) a régi+új helyre, ClipRectangle szerinti kihagyás; minden toll, ecset, Bitmap, Graphics using blokkban vagy a FormClosed-ben felszabadítva; a régi háttérkép a csere után.10
Minőség (10)Szabályok egy helyen, nincs ismétlődő kód; billentyűzetes használat (Delete, Esc, Ctrl+Z, nyilakkal mozgatás), AccessibleName, állapotsor (koordináta, alakzatok száma, aktív eszköz), érthető hibaüzenetek.10

Összesen: 100 pont.

Felületterv

TerületVezérlők
Menü (menu)Fájl: Új, Megnyitás (.rajz)..., Mentés, Mentés másként..., Exportálás képként..., Kilépés; Szerkesztés: Visszavonás (Ctrl+Z), Újra (Ctrl+Y), Törlés (Delete), Mindent töröl; Nézet: Háttérkép betöltése..., Háttérkép eltávolítása; Súgó → Névjegy.
Eszköztár (bal oldali Panel, FlowLayoutPanel)Eszközök (RadioButton Appearance = Button): Szabadkézi, Vonal, Téglalap, Ellipszis, Kijelölés; Szín... (és a szín mintája), nudVastag (1–30), chkKitolt; z-sorrend: Előre, Hátra, Fel, Le.
Rajzfelület (Dock = Fill)rp (RajzPanel), a helyi menüvel (Törlés, Előre, Hátra).
ÁllapotsortsslEszkoz, tsslPoz (egér koordinátája), tsslDb (alakzatok és kijelölt száma), tsslUzenet (Spring).

Gondold végig!

A visszavonáshoz két megoldás van: (a) minden műveletre egy „fordított művelet” (parancs-objektum), (b) minden módosítás előtt a teljes alakzatlista pillanatképe (másolat). Melyik az egyszerűbb, és mi az ára?

Egy lehetséges válasz

A pillanatkép (b) egyszerűbb: nem kell minden művelethez külön visszafordítást írni, a visszavonás a lista visszaállítása. Az ára a memória (minden pillanatkép másolat) és az idő; néhány száz alakzatnál és tucatnyi lépésnél ez elhanyagolható. A parancs-objektum (a) memóriatakarékos és pontosabb, de sokkal több kód (12. fejezet).

Építés lépésenként

1. lépés: az alakzat-modell bővítése

Feladat: vedd át a 9.7 osztályait; egészítsd ki az Alakzat ősét a virtual bool VanFogo és a virtual Alakzat Masol() taggal; írd meg a Szabadkezi osztályt (pontlista: Rajzol, Hatar, Talal szakaszokra, Mozgat, Masol mély másolattal).

Támpont: 9.6 lecke (szabadkézi vonal), 9.7 lecke (TavolsagSzakasztol), 7.7 lecke (másolat és hivatkozás).

2. lépés: felület és a Paint

Feladat: a felületterv megépítése, RajzPanel, a Paint: háttér (ha van), az alakzatok a ClipRectangle szerinti kihagyással, a kijelölés (keret és fogó) a végén.

Támpont: 9.3 és 9.7 lecke.

3. lépés: az egéres állapotgép

Feladat: Allapot felsorolás (Nincs, Rajzol, Letrehoz, Mozgat, Atmeretez); a MouseDown, MouseMove, MouseUp a választott eszköz szerint; Invalidate(Rectangle) a régi és új helyre.

Támpont: 9.6 és 9.7 lecke.

4. lépés: tulajdonságok és z-sorrend

Feladat: szín, vastagság, kitöltés alkalmazása a kijelöltre (és az új alakzatokra); a négy z-sorrend gomb; a törlés, helyi menü, billentyűk.

Támpont: 9.4 és 9.7 lecke.

5. lépés: visszavonás és újra

Feladat: Pillanatkep() (mély másolat), Stack<List<Alakzat>> a visszavonáshoz és az újrához; a pillanatkép a módosítás előtt készül; az Esc a félbeszakított művelet pillanatképét állítja vissza.

Támpont: 7.7 lecke (másolat), 9.6 lecke (Esc).

6. lépés: háttérkép és export

Feladat: a KepBetolt (másolat, hibatűrés), arányos illesztés (Illeszt), a régi háttér felszabadítása; az exportálás kompozit Bitmapra, PNG/JPEG a minőségcsúszkával.

Támpont: 9.1 és 9.2 lecke.

7. lépés: mentés és betöltés (.rajz)

Feladat: soronként egy alakzat (típus, pontok, szín, vastagság, kitöltés); a betöltés előbb új listába olvas, hibás sorokat jelent, a mentetlen változtatás megerősítése.

Támpont: 8.9 lecke (CSV-szerű feldolgozás, adatvesztés nélküli betöltés).

8. lépés: utómunka, hozzáférhetőség, erőforrások

Feladat: AccessibleName, billentyűzetes használat, állapotsor; minden erőforrás kezelése; a tesztlista végigjátszása.

Támpont: 6.9 és 9.8 lecke.

Mintamegoldás

Előbb próbáld meg önállóan!

A mintamegoldás az ellenőrzésre való. A felület a tiéd; itt a logikát adom meg. A 9.7 leckei osztályokat (KetPontos, Vonal, Teglalap, Ellipszis, Geometria) nem ismétlem, csak a módosításokat és az új részeket.

Megoldás: az Alakzat bővítése és a Szabadkezi osztály
Alakzat.cs (a 9.7 osztályok módosítása és új osztály)C#
// Az Alakzat ősbe (9.7) kerül két új tag:
public virtual bool VanFogo { get { return true; } }

public virtual Alakzat Masol()
{
return (Alakzat)this.MemberwiseClone(); // a Point és a Color érték típus: a KetPontos másolata önálló
}

// új alakzat: szabadkézi vonal (pontlista)
public class Szabadkezi : Alakzat
{
public List<Point> Pontok = new List<Point>();

public override bool VanFogo { get { return false; } }

public override Point Fogo { get { return Pontok.Count > 0 ? Pontok[Pontok.Count - 1] : Point.Empty; } }

public override void FogoMozgat(Point uj) { } // a szabadkézi vonalnak nincs átméretező fogója

public override Alakzat Masol()
{
Szabadkezi m = (Szabadkezi)this.MemberwiseClone();
m.Pontok = new List<Point>(this.Pontok); // mély másolat: a lista nem lehet közös
return m;
}

public override Rectangle Hatar()
{
if (Pontok.Count == 0) { return Rectangle.Empty; }

int minX = Pontok.Min(p => p.X), maxX = Pontok.Max(p => p.X), minY = Pontok.Min(p => p.Y), maxY = Pontok.Max(p => p.Y);
int m = (int)Math.Ceiling(Vastagsag / 2) + 3;

return new Rectangle(minX - m, minY - m, maxX - minX + 2 * m, maxY - minY + 2 * m);
}

public override void Rajzol(Graphics g)
{
using (Pen toll = UjToll())
using (Brush b = new SolidBrush(Szin))
{
toll.StartCap = toll.EndCap = LineCap.Round;
toll.LineJoin = LineJoin.Round;

if (Pontok.Count == 1) { g.FillEllipse(b, Pontok[0].X - Vastagsag / 2, Pontok[0].Y - Vastagsag / 2, Vastagsag, Vastagsag); }
else if (Pontok.Count > 1) { g.DrawLines(toll, Pontok.ToArray()); }
}
}

public override bool Talal(Point p)
{
double tures = Math.Max(4, Vastagsag / 2 + 3);

if (Pontok.Count == 1) { return Geometria.TavolsagSzakasztol(p, Pontok[0], Pontok[0]) <= tures; }

for (int i = 1; i < Pontok.Count; i++)
{
if (Geometria.TavolsagSzakasztol(p, Pontok[i - 1], Pontok[i]) <= tures) { return true; }
}

return false;
}

public override void Mozgat(int dx, int dy)
{
for (int i = 0; i < Pontok.Count; i++) { Pontok[i] = new Point(Pontok[i].X + dx, Pontok[i].Y + dy); }
}
}
Megoldás: Form1.cs (állapotgép, visszavonás, Paint)
Form1.cs (using System.Collections.Generic; using System.Linq; using System.IO; using System.Globalization; using System.Drawing.Drawing2D; using System.Drawing.Imaging;)C#
private enum Allapot { Nincs, Rajzol, Letrehoz, Mozgat, Atmeretez }

private List<Alakzat> alakzatok = new List<Alakzat>();
private Stack<List<Alakzat>> visszavon = new Stack<List<Alakzat>>();
private Stack<List<Alakzat>> ujra = new Stack<List<Alakzat>>();
private List<Alakzat> mentettAllapot = null; // az Esc-hez: a művelet kezdete előtti állapot

private Allapot allapot = Allapot.Nincs;
private Alakzat aktiv = null;
private Point elozo;
private Color szin = Color.SteelBlue;
private Bitmap hatter = null;
private string fajl = "";
private bool modosult = false;
private bool beallit = false;
private const int FogoMeret = 8;

// ---- visszavonás pillanatképekkel ----

private List<Alakzat> Pillanatkep()
{
return alakzatok.Select(a => a.Masol()).ToList(); // mély másolat
}

private void Mentes() // a módosítás ELŐTT hívjuk
{
visszavon.Push(Pillanatkep());
ujra.Clear();
Modosult();
}

private void Visszaallit(List<Alakzat> allapotLista)
{
alakzatok = allapotLista;
Allapotsor();
rp.Invalidate();
}

private void miVisszavon_Click(object sender, EventArgs e)
{
if (visszavon.Count == 0 || allapot != Allapot.Nincs) { return; }

ujra.Push(Pillanatkep());
Visszaallit(visszavon.Pop());
}

private void miUjra_Click(object sender, EventArgs e)
{
if (ujra.Count == 0 || allapot != Allapot.Nincs) { return; }

visszavon.Push(Pillanatkep());
Visszaallit(ujra.Pop());
}

// ---- eszközök és egér ----

private Alakzat Kijelolt() { return alakzatok.Find(x => x.Kijelolt); }

private void Kijeloles(Alakzat a)
{
foreach (Alakzat x in alakzatok) { x.Kijelolt = x == a; }

if (a != null)
{
beallit = true;
nudVastag.Value = (decimal)Math.Max(1, Math.Min(30, a.Vastagsag));
chkKitolt.Checked = a.Kitoltott;
beallit = false;
}

Allapotsor();
rp.Invalidate();
}

private Rectangle FogoTeglalap(Alakzat a)
{
Point f = a.Fogo;
return new Rectangle(f.X - FogoMeret / 2, f.Y - FogoMeret / 2, FogoMeret, FogoMeret);
}

private Alakzat TalalatKeres(Point p)
{
for (int i = alakzatok.Count - 1; i >= 0; i--) { if (alakzatok[i].Talal(p)) { return alakzatok[i]; } }

return null;
}

private void Frissit(Rectangle regi, Alakzat a)
{
Rectangle t = Rectangle.Union(regi, a.Hatar());
t.Inflate(FogoMeret + 2, FogoMeret + 2);
rp.Invalidate(t);
}

private Alakzat UjAlakzat(Point p)
{
Alakzat a;

if (rbSzabad.Checked) { Szabadkezi sz = new Szabadkezi(); sz.Pontok.Add(p); a = sz; }
else
{
KetPontos kp = rbTeglalap.Checked ? (KetPontos)new Teglalap() : rbEllipszis.Checked ? (KetPontos)new Ellipszis() : new Vonal();
kp.A = kp.B = p;
a = kp;
}

a.Szin = szin;
a.Vastagsag = (float)nudVastag.Value;
a.Kitoltott = chkKitolt.Checked;

return a;
}

private void rp_MouseDown(object sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) { return; }

elozo = e.Location;
mentettAllapot = Pillanatkep();

if (!rbKijelol.Checked)
{
Mentes();
aktiv = UjAlakzat(e.Location);
alakzatok.Add(aktiv);
Kijeloles(null);
allapot = rbSzabad.Checked ? Allapot.Rajzol : Allapot.Letrehoz;
return;
}

Alakzat kij = Kijelolt();

if (kij != null && kij.VanFogo && FogoTeglalap(kij).Contains(e.Location))
{
Mentes();
aktiv = kij;
allapot = Allapot.Atmeretez;
return;
}

Alakzat talalat = TalalatKeres(e.Location);

Kijeloles(talalat);

if (talalat != null)
{
Mentes();
aktiv = talalat;
allapot = Allapot.Mozgat;
rp.Cursor = Cursors.SizeAll;
}
}

private void rp_MouseMove(object sender, MouseEventArgs e)
{
tsslPoz.Text = e.X + ", " + e.Y;

if (aktiv == null || allapot == Allapot.Nincs) { return; }

Rectangle regi = aktiv.Hatar();

switch (allapot)
{
case Allapot.Rajzol:
Szabadkezi sz = (Szabadkezi)aktiv;
Point utolso = sz.Pontok[sz.Pontok.Count - 1];

if (Math.Abs(e.X - utolso.X) + Math.Abs(e.Y - utolso.Y) >= 2) { sz.Pontok.Add(e.Location); }
break;

case Allapot.Letrehoz:
case Allapot.Atmeretez:
aktiv.FogoMozgat(e.Location);
break;

case Allapot.Mozgat:
aktiv.Mozgat(e.X - elozo.X, e.Y - elozo.Y);
elozo = e.Location;
break;
}

Frissit(regi, aktiv);
}

private void rp_MouseUp(object sender, MouseEventArgs e)
{
if (allapot == Allapot.Letrehoz && aktiv is KetPontos)
{
KetPontos kp = (KetPontos)aktiv;

if (Math.Abs(kp.A.X - kp.B.X) < 3 && Math.Abs(kp.A.Y - kp.B.Y) < 3)
{
alakzatok.Remove(aktiv); // egyetlen kattintás: nincs alakzat
visszavon.Pop(); // a felesleges pillanatkép törlése
}
}

Veg();
}

private void rp_MouseCaptureChanged(object sender, EventArgs e)
{
if (allapot != Allapot.Nincs) { Megszakit(); }
}

private void Veg()
{
allapot = Allapot.Nincs;
aktiv = null;
mentettAllapot = null;
rp.Cursor = Cursors.Cross;
Allapotsor();
rp.Invalidate();
}

private void Megszakit() // Esc: a művelet kezdete előtti állapot vissza
{
if (mentettAllapot != null)
{
alakzatok = mentettAllapot;

if (visszavon.Count > 0) { visszavon.Pop(); } // a művelet pillanatképe nem kell
}

Veg();
}

protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (keyData == Keys.Escape && allapot != Allapot.Nincs) { Megszakit(); return true; }
if (keyData == Keys.Delete && allapot == Allapot.Nincs) { btnTorol.PerformClick(); return true; }

Alakzat kij = Kijelolt();

if (kij != null && allapot == Allapot.Nincs && (keyData == Keys.Left || keyData == Keys.Right || keyData == Keys.Up || keyData == Keys.Down))
{
Mentes();
Rectangle regi = kij.Hatar();

kij.Mozgat(keyData == Keys.Left ? -5 : keyData == Keys.Right ? 5 : 0, keyData == Keys.Up ? -5 : keyData == Keys.Down ? 5 : 0);
Frissit(regi, kij);
return true;
}

return base.ProcessCmdKey(ref msg, keyData); // a Ctrl+Z / Ctrl+Y a menüpontok gyorsbillentyűi
}

// ---- tulajdonságok, z-sorrend, törlés ----

private void Tulajdonsag_Valtozott(object sender, EventArgs e)
{
Alakzat a = Kijelolt();

if (beallit || a == null) { return; }

Mentes();
a.Vastagsag = (float)nudVastag.Value;
a.Kitoltott = chkKitolt.Checked;
a.Szin = szin;
rp.Invalidate();
}

private void btnTorol_Click(object sender, EventArgs e)
{
Alakzat a = Kijelolt();

if (a != null) { Mentes(); alakzatok.Remove(a); Allapotsor(); rp.Invalidate(); }
}

private void ZSorrend(Action<Alakzat> muvelet)
{
Alakzat a = Kijelolt();

if (a != null) { Mentes(); muvelet(a); rp.Invalidate(); }
}

private void btnElre_Click(object sender, EventArgs e) { ZSorrend(a => { alakzatok.Remove(a); alakzatok.Add(a); }); }
private void btnHatra_Click(object sender, EventArgs e) { ZSorrend(a => { alakzatok.Remove(a); alakzatok.Insert(0, a); }); }
private void btnFel_Click(object sender, EventArgs e) { ZSorrend(a => { int i = alakzatok.IndexOf(a); if (i < alakzatok.Count - 1) { alakzatok.RemoveAt(i); alakzatok.Insert(i + 1, a); } }); }
private void btnLe_Click(object sender, EventArgs e) { ZSorrend(a => { int i = alakzatok.IndexOf(a); if (i > 0) { alakzatok.RemoveAt(i); alakzatok.Insert(i - 1, a); } }); }

private void miMindentTorol_Click(object sender, EventArgs e)
{
if (alakzatok.Count > 0 && MessageBox.Show(this, "Minden alakzat törlődik. (A visszavonás visszaállítja.) Folytatod?", "Mindent töröl", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) == DialogResult.Yes)
{
Mentes();
alakzatok = new List<Alakzat>();
Allapotsor();
rp.Invalidate();
}
}

// ---- rajzolás ----

private Size Illeszt(Size forras, Size doboz)
{
double arany = Math.Min((double)doboz.Width / forras.Width, (double)doboz.Height / forras.Height);

return new Size(Math.Max(1, (int)Math.Round(forras.Width * arany)), Math.Max(1, (int)Math.Round(forras.Height * arany)));
}

private void KepetRajzol(Graphics g, Size meret, bool kijeloles, Rectangle klip)
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.White);

if (hatter != null)
{
Size m = Illeszt(hatter.Size, meret);

g.InterpolationMode = InterpolationMode.HighQualityBilinear;
g.DrawImage(hatter, new Rectangle((meret.Width - m.Width) / 2, (meret.Height - m.Height) / 2, m.Width, m.Height));
}

foreach (Alakzat a in alakzatok)
{
Rectangle h = a.Hatar();
h.Inflate(FogoMeret, FogoMeret);

if (klip.IsEmpty || h.IntersectsWith(klip))
{
a.Rajzol(g);

if (kijeloles && a.Kijelolt)
{
using (Pen szagg = new Pen(Color.RoyalBlue) { DashStyle = DashStyle.Dash })
{
Rectangle k = a.Hatar();
g.DrawRectangle(szagg, k.X, k.Y, k.Width, k.Height);
}

if (a.VanFogo)
{
Rectangle fogo = FogoTeglalap(a);
g.FillRectangle(Brushes.White, fogo);
g.DrawRectangle(Pens.RoyalBlue, fogo.X, fogo.Y, fogo.Width, fogo.Height);
}
}
}
}
}

private void rp_Paint(object sender, PaintEventArgs e)
{
KepetRajzol(e.Graphics, rp.ClientSize, true, e.ClipRectangle);
}

private void Allapotsor()
{
tsslDb.Text = alakzatok.Count + " alakzat, " + alakzatok.Count(a => a.Kijelolt) + " kijelölve";
}

private void Modosult()
{
if (!modosult) { modosult = true; CimFrissit(); }
}

private void CimFrissit()
{
this.Text = (fajl.Length == 0 ? "Névtelen" : Path.GetFileName(fajl)) + (modosult ? "*" : "") + " – Rajzoló";
}
Megoldás: háttérkép, export, mentés és betöltés
Form1.cs (KepBetolt: 9.1; Kodek és JpegMent: 9.2)C#
private void miHatter_Click(object sender, EventArgs e)
{
ofd.Filter = "Képek (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp";

if (ofd.ShowDialog(this) != DialogResult.OK) { return; }

string hiba;
Bitmap uj = KepBetolt(ofd.FileName, out hiba); // zárolás nélkül, hibatűréssel (9.1 lecke)

if (uj == null) { tsslUzenet.Text = hiba; return; }

Bitmap regi = hatter;

hatter = uj;
rp.Invalidate();

if (regi != null) { regi.Dispose(); } // az új beállítása után szabadítjuk a régit
}

private void miHatterTorol_Click(object sender, EventArgs e)
{
if (hatter != null) { hatter.Dispose(); hatter = null; rp.Invalidate(); }
}

private void miExport_Click(object sender, EventArgs e)
{
sfd.Filter = "PNG (*.png)|*.png|JPEG (*.jpg)|*.jpg";
sfd.DefaultExt = "png";

if (sfd.ShowDialog(this) != DialogResult.OK) { return; }

try
{
using (Bitmap kep = new Bitmap(rp.Width, rp.Height))
{
using (Graphics g = Graphics.FromImage(kep))
{
KepetRajzol(g, kep.Size, false, Rectangle.Empty); // kijelölés nélkül
}

if (sfd.FilterIndex == 2) { JpegMent(kep, sfd.FileName, tbMinoseg.Value); }
else { kep.Save(sfd.FileName, ImageFormat.Png); }
}

tsslUzenet.Text = "Exportálva: " + Path.GetFileName(sfd.FileName);
}
catch (Exception ex)
{
MessageBox.Show(this, "Az exportálás nem sikerült: " + ex.Message, "Exportálás", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}

// ---- .rajz fájl: soronként egy alakzat ----
// V;ax;ay;bx;by;#szin;vastag;kitolt (Vonal)
// T;... (Teglalap), E;... (Ellipszis)
// S;x,y x,y x,y;#szin;vastag;kitolt (Szabadkezi)

private string Sor(Alakzat a)
{
string kozos = ";" + ColorTranslator.ToHtml(a.Szin) + ";" + a.Vastagsag.ToString(CultureInfo.InvariantCulture) + ";" + (a.Kitoltott ? 1 : 0);

Szabadkezi sz = a as Szabadkezi;

if (sz != null) { return "S;" + string.Join(" ", sz.Pontok.Select(p => p.X + "," + p.Y)) + kozos; }

KetPontos kp = (KetPontos)a;
string tipus = a is Teglalap ? "T" : a is Ellipszis ? "E" : "V";

return tipus + ";" + kp.A.X + ";" + kp.A.Y + ";" + kp.B.X + ";" + kp.B.Y + kozos;
}

private Alakzat Elemez(string sor)
{
string[] m = sor.Split(';');
Alakzat a;
int kozosKezdet;

if (m[0] == "S")
{
Szabadkezi sz = new Szabadkezi();

foreach (string pont in m[1].Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries))
{
string[] xy = pont.Split(',');
sz.Pontok.Add(new Point(int.Parse(xy[0]), int.Parse(xy[1])));
}

if (sz.Pontok.Count == 0) { throw new FormatException("nincs pont"); }

a = sz;
kozosKezdet = 2;
}
else
{
KetPontos kp = m[0] == "T" ? (KetPontos)new Teglalap() : m[0] == "E" ? (KetPontos)new Ellipszis() : new Vonal();

if (m[0] != "T" && m[0] != "E" && m[0] != "V") { throw new FormatException("ismeretlen típus"); }

kp.A = new Point(int.Parse(m[1]), int.Parse(m[2]));
kp.B = new Point(int.Parse(m[3]), int.Parse(m[4]));
a = kp;
kozosKezdet = 5;
}

a.Szin = ColorTranslator.FromHtml(m[kozosKezdet]);
a.Vastagsag = Math.Max(1, Math.Min(30, float.Parse(m[kozosKezdet + 1], CultureInfo.InvariantCulture)));
a.Kitoltott = m[kozosKezdet + 2] == "1";

return a;
}

private bool Ment(bool maskent)
{
string ut = fajl;

if (maskent || ut.Length == 0)
{
sfd.Filter = "Rajz (*.rajz)|*.rajz";
sfd.DefaultExt = "rajz";

if (sfd.ShowDialog(this) != DialogResult.OK) { return false; }

ut = sfd.FileName;
}

try
{
File.WriteAllLines(ut + ".tmp", alakzatok.Select(Sor), new UTF8Encoding(false));

if (File.Exists(ut)) { File.Delete(ut); }

File.Move(ut + ".tmp", ut);
fajl = ut;
modosult = false;
CimFrissit();
return true;
}
catch (Exception ex)
{
MessageBox.Show(this, "A mentés nem sikerült: " + ex.Message, "Mentés", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
}

private bool Folytathat()
{
if (!modosult) { return true; }

switch (MessageBox.Show(this, "Mented a változtatásokat?", "Rajzoló", MessageBoxButtons.YesNoCancel, MessageBoxIcon.Warning))
{
case DialogResult.Yes: return Ment(false);
case DialogResult.No: return true;
default: return false;
}
}

private void miMegnyit_Click(object sender, EventArgs e)
{
ofd.Filter = "Rajz (*.rajz)|*.rajz";

if (!Folytathat() || ofd.ShowDialog(this) != DialogResult.OK) { return; }

List<Alakzat> uj = new List<Alakzat>();
int hibas = 0;

try
{
foreach (string sor in File.ReadAllLines(ofd.FileName))
{
if (sor.Trim().Length == 0) { continue; }

try { uj.Add(Elemez(sor)); }
catch (Exception) { hibas++; } // a hibás sor kimarad, számoljuk
}
}
catch (IOException ex)
{
MessageBox.Show(this, "A fájl nem olvasható: " + ex.Message, "Megnyitás", MessageBoxButtons.OK, MessageBoxIcon.Error);
return; // az eddigi rajz érintetlen
}

if (hibas > 0 && (uj.Count == 0 || MessageBox.Show(this, hibas + " hibás sor van. A többi (" + uj.Count + " alakzat) betöltsem?", "Megnyitás", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes))
{
return;
}

alakzatok = uj; // csak a teljes feldolgozás után
visszavon.Clear();
ujra.Clear();
fajl = ofd.FileName;
modosult = false;
CimFrissit();
Allapotsor();
rp.Invalidate();
}

private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing && !Folytathat()) { e.Cancel = true; }
}

private void Form1_FormClosed(object sender, FormClosedEventArgs e)
{
if (hatter != null) { hatter.Dispose(); }
}
📷 Képernyőkép helye

Mit tegyél ide: a kész rajzolóprogram: háttérkép, színes alakzatok, egy kijelölt alakzat fogóponttal, az eszköztár és az állapotsor.Javasolt fájlnév: winforms-9-10-kesz.webp · Alt szöveg: A kész rajzolóprogram

Tesztlista

#TesztVárt eredmény
1Szabadkézi vonal: lassú és gyors húzás, egy kattintás.Sima vonal; egy kattintásra pont; nincs kivétel.
2Vonal, téglalap, ellipszis; fordított húzás (jobbról balra, lentről felfelé).Érvényes alakzat, gumi-előnézettel; a pár pixeles húzás nem hagy alakzatot.
3Kijelölés: üres hely, vékony vonal, kitöltött és üres téglalap belseje.A vékony vonal tűréssel eltalálható; az üres alakzat belseje nem találat; a kitöltött igen.
4Átfedő alakzatok kijelölése.A felül látszó alakzat választódik ki.
5Mozgatás (különbség módszer), a panel szélén és azon túl.A megfogott pont az egér alatt marad; nincs nyom a régi helyen.
6Átméretezés fogóponttal, átfordítva; a szabadkézi vonalnak nincs fogója.A fogó követi az egeret; a szabadkézi csak mozgatható.
7Szín, vastagság, kitöltés módosítása a kijelölten, kijelölés nélkül.Kijelölten az alakzat változik, egyébként az új alakzatok tulajdonsága.
8Z-sorrend gombok.A kép és a lista sorrendje egyezik.
9Ctrl+Z és Ctrl+Y: létrehozás, mozgatás, törlés, z-sorrend, tulajdonság után.Minden lépés visszavonható és újra végrehajtható; új művelet után az „újra” törlődik.
10Esc létrehozás, mozgatás, átméretezés közben.Az állapot a művelet előtti; nem keletkezik felesleges visszavonási lépés.
11Húzás a panelen kívülre, az egér elengedése az ablakon kívül.A húzás tisztán befejeződik (nem ragad be).
12Háttérkép: PNG, JPEG, nem képfájl (.txt átnevezve), törlés a fájlból.Arányos illesztés; érthető hibaüzenet; a fájl nem zárolt (törölhető).
13Export PNG-be és JPEG-be (20, 85, 100 minőség).A mentett kép a háttér és az alakzatok, kijelölés nélkül; a JPEG háttere nem fekete; a fájlméret változik.
14Mentés .rajz fájlba, megnyitás újraindítás után.Az alakzatok azonosak (típus, hely, szín, vastagság, kitöltés).
15Hibás .rajz (2 elrontott sor, üres fájl, ismeretlen típus).A hibás sorok számát jelzi; Nem esetén a rajz érintetlen.
16Új, megnyitás, kilépés mentetlen rajzzal: Igen, Nem, Mégse.Mentés + folytatás, elvetés + folytatás, maradás.
17500 alakzat, mozgatás; ablak átméretezése.Sima mozgás (nincs villogás), a rajz teljesen újrarajzolódik átméretezéskor.
18Billentyűzet: nyilak mozgatás, Delete; 150% betűméret; a Feladatkezelőben a memória és a GDI-objektumok száma hosszú használat után.Minden elérhető; a memória és a GDI-szám nem nő folyamatosan.

Gyakori hibák és önértékelés

Ezeken szoktak elcsúszni

Gondold végig, nálad előfordul-e valamelyik.

1. A visszavonás nem állítja vissza az alakzatot

A Ctrl+Z után az alakzat továbbra is a módosított állapotban van.

Ok és javítás

A pillanatkép nem mély másolat (a lista elemei ugyanazok az objektumok), vagy a módosítás után készült. Készíts Masol() másolatokat, és a módosítás előtt mentsd a pillanatképet.

2. A szabadkézi vonal másolata közös pontlistát használ

Egy vonal mozgatása a másolatot is mozgatja.

Ok és javítás

A MemberwiseClone sekély másolat: a List<Point> közös marad. A Szabadkezi.Masol() új listát készít (new List<Point>(Pontok)).

3. Az Esc után a visszavonás eggyel többet lép

Az Esc megszakított művelete is visszavonási lépés maradt.

Ok és javítás

A pillanatkép a művelet kezdetén készül, ezért megszakításkor a verem tetejét el kell dobni (visszavon.Pop()), ahogy a Megszakit teszi.

4. A mentett JPEG háttere fekete, vagy a kijelölés is rajta van

Az exportált képen a kék keret és a fogó látszik.

Ok és javítás

Az export a Paint rajzát másolja, kijelöléssel együtt. Használd a kijeloles = false paraméterű közös rajzoló metódust, és a JPEG-et fehér háttérre rajzolva mentsd (9.2 lecke).

5. A régi háttérkép a memóriában marad

Sok háttércsere után nő a memória.

Ok és javítás

A régi Bitmapot a csere után Dispose-olni kell (9.8 lecke), és a FormClosed-ben az aktuálisat.

6. A betöltés elveszi a rajzot hibás fájlnál

A hibás .rajz megnyitása után a rajz üres.

Ok és javítás

Új listába olvass, a hibás sorokat számold, és az aktuális rajzot csak a sikeres feldolgozás után cseréld (8.9 lecke).

Az értékeléshez ellenőrizd az alábbi szempontokat is:

MűködésA tesztlista mind a 18 pontja teljesül.
SzerkezetAz alakzat-modell, a felület és a fájlkezelés elkülönül; a rajzolás egyetlen közös metódusból (KepetRajzol) megy a panelre és az exportba; nincs ismétlődő kód.
Erőforrás-kezelésMinden toll, ecset, Bitmap, Graphics felszabadul; a visszavonási pillanatképek száma korlátozott (például 100).
HozzáférhetőségBillentyűzettel használható, a kijelölést keret jelzi (nem csak szín), az eszközök felirata és AccessibleName értéke érthető, a hibák szövegesek.

Bővítési ötletek, ha kész vagy

Ha maradt időd, próbáld ki:

  • Nagyítás és görgetés: a világ–képernyő átszámítás (9.5 lecke) és a jobb gombos mozgatás (9.6 lecke).
  • Többes kijelölés: kijelölő téglalap (gumi-előnézettel) és csoportos mozgatás.
  • Új alakzatok: háromszög, szöveg (DrawString), nyíl (LineCap.ArrowAnchor).
  • Másolás, kivágás, beillesztés: a vágólapon a kijelölt alakzatok (szöveges formátumban) és a kép (Clipboard.SetImage).
  • Átlátszóság: az alfa csatorna a színválasztóban (tbAlfa), sakktábla háttérrel.
  • Rétegek: névvel ellátott rétegek, láthatóság-kapcsoló.
  • Adatformátum: a .rajz helyett JSON (10.6 lecke).

Gyakorlótár: 6 további feladat

Három szinten gyakorolhatsz: alap, közepes és kihívás. A kész programot bővíted. Csak a próbálkozás után nyisd le a megoldást.

Alap szint

1. Új alakzat: nyíl

Vegyél fel Nyil osztályt, amely a Vonal leszármazottja, és nyílheggyel végződik. Mit kell módosítanod?

Megoldás megnyitása

Csak a Rajzol metódust (a toll EndCap = LineCap.ArrowAnchor), és az eszközválasztót (Letrehoz a Nyil példányt), valamint a fájlformátumot (új típusjel). A modell, a mozgatás, a z-sorrend és a visszavonás változatlan.

2. Visszavonási lépések korlátja

A verem legfeljebb 100 lépést őrizzen. Hogyan oldod meg a Stack típussal?

Megoldás megnyitása

A Stack nem tud az aljáról törölni: LinkedList<List<Alakzat>> (AddLast, RemoveFirst, RemoveLast) vagy List használható, a legrégebbi elem eldobásával.

Közepes szint

3. Csoportos mozgatás nyilakkal

A Shift + nyíl 20 pixelt mozgat, a nyíl önmagában 5-öt. Hol és hogyan döntöd el?

Megoldás megnyitása

A ProcessCmdKey keyData értékében a Keys.Shift jelző benne van ((keyData & Keys.Shift) == Keys.Shift); a billentyűt előbb tisztítsd (keyData & Keys.KeyCode).

Form1.csC#
int lepes = (keyData & Keys.Shift) == Keys.Shift ? 20 : 5;Keys gomb = keyData & Keys.KeyCode;if (gomb == Keys.Left) { kij.Mozgat(-lepes, 0); }

4. Mentés a vágólapra

A Másolás képként parancs a kész rajzot a vágólapra teszi. Hogyan?

Megoldás megnyitása

A KepetRajzol egy Bitmapra, majd Clipboard.SetImage(kep). A vágólap a képet másolja, ezért a Bitmap utána felszabadítható (using), a gyakorlatban a vágólap viselkedése alkalmazásonként eltér: érdemes kipróbálni.

Kihívás szint

5. Parancs-minta a pillanatkép helyett

Írj Parancs absztrakt osztályt (Vegrehajt, Visszavon) és leszármazottakat (LetrehozasParancs, MozgatasParancs). Mi az előnye a pillanatképpel szemben?

Megoldás megnyitása

Memóriatakarékos (csak a különbséget tárolja, pl. a mozgatás dx, dy értékét), és pontos; hátránya a több kód. A visszavonás a Stack<Parancs> tetejének Visszavon() hívása (12. fejezet).

Parancs.csC#
public abstract class Parancs{    public abstract void Vegrehajt();    public abstract void Visszavon();}public class MozgatasParancs : Parancs{    private readonly Alakzat alakzat;    private readonly int dx, dy;    public MozgatasParancs(Alakzat a, int dx, int dy) { alakzat = a; this.dx = dx; this.dy = dy; }    public override void Vegrehajt() { alakzat.Mozgat(dx, dy); }    public override void Visszavon() { alakzat.Mozgat(-dx, -dy); }}

6. Automatikus mentés összeomlás ellen

A program percenként egy ideiglenes fájlba menti a rajzot, és induláskor felajánlja a helyreállítást. Mit kell megoldanod?

Megoldás megnyitása

Az ideiglenes fájl az ApplicationData mappában, a rendes mentés és a tiszta kilépés törli; induláskor a File.Exists alapján YesNo kérdés. A mentés try–catch védelmet kap, és nem zavarja a felhasználót (az állapotsorban jelez). Hosszabb rajznál a mentést háttérszálra kell tenni (11. fejezet).

Gyors önellenőrzés

Válaszolj fejben, és csak utána nyisd le a választ.

1. Miért pillanatképpel valósul meg a visszavonás?

Egyszerű: a módosítás előtt a lista mély másolata mentődik, a visszavonás ezt állítja vissza.

2. Miért kell mély másolat?

A lista elemei hivatkozások: sekély másolatnál a pillanatkép és az élő lista ugyanazokat az objektumokat mutatja.

3. Miért hívod a Mentés() metódust a módosítás előtt?

A pillanatképnek a változtatás előtti állapotot kell tárolnia.

4. Miért van egy közös rajzoló metódus a panelnek és az exportnak?

Ugyanaz a kód két célra: nincs ismétlődés, és a mentett kép egyezik a képernyővel (kijelölés nélkül).

5. Mikor szabadítod fel a régi háttérképet?

Az új beállítása után (Dispose), és a Form zárásakor az aktuálisat.

6. Hogyan kezeli a program a hibás .rajz fájlt?

Új listába olvas, a hibás sorokat számolja, és a rajzot csak a sikeres feldolgozás után cseréli.

7. Miért polimorfizmus az alakzat-lista?

Egyetlen lista, egyetlen ciklus kezeli a különböző alakzatokat; új típusnál a ciklus nem változik.

Mit tudsz már? (a 9. fejezet végén)

  • Képeket töltesz be, méretezel, vágsz és mentesz, és kezeled a képek erőforrásait.
  • A Paint eseményben, tárolt adatokból rajzolsz, és ismered a rajzeszközöket.
  • Koordinátákat számolsz át, és transzformációkat használsz.
  • Egéres húzást, kijelölést, mozgatást és átméretezést valósítasz meg alakzat-modellel.
  • Grafikonokat rajzolsz, és hozzáférhető módon szemléltetsz adatokat.
  • Elkészítettél egy teljes rajzolóprogramot visszavonással, háttérrel, exporttal és mentéssel.

Következő fejezet: fájlok, adatformátumok és beállítások

A 9. fejezet kész. A 10. fejezetben a fájlkezelés rendszerét tanuljuk: File, Path, Directory, adatfolyamok, CSV, JSON, XML és az alkalmazás beállításai.