WinFormsで学ぶお絵描きアプリ拡張
― 消しゴム / Undo・Redo / 複数レイヤー ―
目標
- 左ドラッグで描画、右ドラッグは使わない(簡素化)
- メニューで 保存・クリア・終了
- 消しゴムモード(白で上書き:初心者が概念を掴みやすい方式)
- Undo / Redo は「ストローク単位」で履歴管理(履歴上限あり)
- 複数レイヤー:アクティブレイヤーにだけ描く/合成して表示
注意:本稿では学習優先のため 分かりやすさ重視 の実装です(履歴はビットマップ複製。メモリは使いますが読みやすい)。実用化ではコマンドパターン/差分記録/ストロークベクタ化等を検討してください。
UI構成
- 上部:MenuStrip(ファイル/編集/ブラシ/レイヤー)
- その下:ToolStrip(アクティブレイヤー選択、可視切替、消しゴムトグル、線幅Quick選択)
- 中央:PictureBox(描画ビュー)
コード一式
Form1.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using System.Drawing.Imaging;
namespace PaintAdvancedLayers
{
public partial class Form1 : Form
{
// ========= 描画ビュー =========
private readonly PictureBox _view = new() { Dock = DockStyle.Fill, BackColor = Color.White };
// ========= メニュー =========
private readonly MenuStrip _menu = new();
private readonly ToolStripMenuItem _fileMenu = new("&ファイル");
private readonly ToolStripMenuItem _editMenu = new("&編集");
private readonly ToolStripMenuItem _brushMenu = new("&ブラシ");
private readonly ToolStripMenuItem _layerMenu = new("&レイヤー");
// ========= ツールバー(レイヤー操作など) =========
private readonly ToolStrip _tool = new() { GripStyle = ToolStripGripStyle.Hidden };
private readonly ToolStripLabel _lblLayer = new("Layer:");
private readonly ToolStripComboBox _cbLayers = new() { DropDownStyle = ComboBoxStyle.DropDownList, AutoSize = false, Width = 200 };
private readonly ToolStripButton _btnToggleVisible = new("可視切替");
private readonly ToolStripSeparator _sep1 = new();
private readonly ToolStripButton _btnEraser = new("消しゴム");
private readonly ToolStripSeparator _sep2 = new();
private readonly ToolStripLabel _lblWidth = new("幅:");
private readonly ToolStripComboBox _cbWidth = new() { AutoSize = false, Width = 60 };
// ========= レイヤーと履歴 =========
private class Layer
{
public string Name { get; set; }
public Bitmap Bmp { get; set; }
public bool Visible { get; set; } = true;
public override string ToString() => $"{Name} {(Visible ? "" : "(非表示)")}";
}
private readonly List<Layer> _layers = new();
private int _activeLayerIndex = 0;
// 履歴は「アクティブレイヤーのBitmap」をストローク前にコピーして積む
private readonly Stack<Bitmap> _undo = new();
private readonly Stack<Bitmap> _redo = new();
private const int MaxHistory = 20;
// ========= 状態 =========
private bool _drawing;
private Point _last;
private Color _penColor = Color.Black;
private float _penWidth = 3f;
private bool _eraserMode = false;
public Form1()
{
Text = "Paint Advanced (Layers / Eraser / Undo-Redo)";
ClientSize = new Size(1000, 700);
DoubleBuffered = true;
BuildMenu();
BuildToolbar();
// 追加順:Menu → Tool → View
Controls.Add(_view);
Controls.Add(_tool);
Controls.Add(_menu);
Load += (_, __) => InitializeCanvas();
_view.Resize += (_, __) => ResizeAllLayers(preserve: true);
// マウス描画(アクティブレイヤーへ)
_view.MouseDown += (s, e) =>
{
if (e.Button != MouseButtons.Left) return;
if (!HasActiveLayer) return;
// ストローク開始時に履歴へ保存(アクティブレイヤーのみ)
PushUndo(SnapshotActive());
_drawing = true;
_last = e.Location;
};
_view.MouseMove += (s, e) =>
{
if (!_drawing || !HasActiveLayer) return;
DrawStrokeToActive(_last, e.Location);
_last = e.Location;
_view.Invalidate();
};
_view.MouseUp += (s, e) =>
{
_drawing = false;
};
_view.Paint += (s, e) => ComposeAndPaint(e.Graphics);
// ショートカット
KeyPreview = true;
KeyDown += (_, e) =>
{
if (e.Control && e.KeyCode == Keys.S) { SaveMerged(); e.SuppressKeyPress = true; }
if (e.Control && e.KeyCode == Keys.N) { ClearActive(); e.SuppressKeyPress = true; }
if (e.Control && e.KeyCode == Keys.Z) { Undo(); e.SuppressKeyPress = true; }
if (e.Control && e.KeyCode == Keys.Y) { Redo(); e.SuppressKeyPress = true; }
};
}
private void BuildMenu()
{
// ファイル
var miSave = new ToolStripMenuItem("保存(&S)...") { ShortcutKeys = Keys.Control | Keys.S };
miSave.Click += (_, __) => SaveMerged();
var miExit = new ToolStripMenuItem("終了(&X)");
miExit.Click += (_, __) => Close();
_fileMenu.DropDownItems.AddRange(new ToolStripItem[] { miSave, new ToolStripSeparator(), miExit });
// 編集
var miClear = new ToolStripMenuItem("アクティブレイヤーをクリア(&C)") { ShortcutKeys = Keys.Control | Keys.N };
miClear.Click += (_, __) => ClearActive();
var miUndo = new ToolStripMenuItem("元に戻す(&U)") { ShortcutKeys = Keys.Control | Keys.Z };
miUndo.Click += (_, __) => Undo();
var miRedo = new ToolStripMenuItem("やり直し(&R)") { ShortcutKeys = Keys.Control | Keys.Y };
miRedo.Click += (_, __) => Redo();
_editMenu.DropDownItems.AddRange(new ToolStripItem[] { miUndo, miRedo, new ToolStripSeparator(), miClear });
// ブラシ
var miColor = new ToolStripMenuItem("色変更(&C)...");
miColor.Click += (_, __) =>
{
using var cd = new ColorDialog { Color = _penColor, FullOpen = true };
if (cd.ShowDialog() == DialogResult.OK) _penColor = cd.Color;
};
var miWidth = new ToolStripMenuItem("線幅(&W)");
var widths = new[] { 1, 2, 3, 5, 8, 12, 16, 20, 30 };
foreach (var w in widths)
{
var item = new ToolStripMenuItem($"{w}px");
item.Click += (_, __) => { _penWidth = w; _cbWidth.SelectedItem = $"{w}"; };
miWidth.DropDownItems.Add(item);
}
var miEraser = new ToolStripMenuItem("消しゴムモード(&E)") { Checked = false, CheckOnClick = true };
miEraser.CheckedChanged += (_, __) =>
{
_eraserMode = miEraser.Checked;
_btnEraser.Checked = _eraserMode;
};
_brushMenu.DropDownItems.AddRange(new ToolStripItem[] { miColor, miWidth, new ToolStripSeparator(), miEraser });
// レイヤー
var miAdd = new ToolStripMenuItem("レイヤー追加(&A)");
miAdd.Click += (_, __) => AddLayer();
var miDelete = new ToolStripMenuItem("レイヤー削除(&D)");
miDelete.Click += (_, __) => DeleteLayer();
var miUp = new ToolStripMenuItem("レイヤーを前面へ(&U)");
miUp.Click += (_, __) => MoveLayer(-1);
var miDown = new ToolStripMenuItem("レイヤーを背面へ(&O)");
miDown.Click += (_, __) => MoveLayer(+1);
var miToggle = new ToolStripMenuItem("アクティブレイヤー可視切替(&V)");
miToggle.Click += (_, __) => ToggleActiveVisible();
_layerMenu.DropDownItems.AddRange(new ToolStripItem[] { miAdd, miDelete, new ToolStripSeparator(), miUp, miDown, new ToolStripSeparator(), miToggle });
_menu.Items.AddRange(new ToolStripItem[] { _fileMenu, _editMenu, _brushMenu, _layerMenu });
}
private void BuildToolbar()
{
_btnEraser.CheckOnClick = true;
_btnEraser.CheckedChanged += (_, __) => _eraserMode = _btnEraser.Checked;
_cbLayers.SelectedIndexChanged += (_, __) =>
{
_activeLayerIndex = _cbLayers.SelectedIndex;
_view.Invalidate();
};
_btnToggleVisible.Click += (_, __) => ToggleActiveVisible();
_cbWidth.Items.AddRange(new object[] { "1","2","3","5","8","12","16","20","30" });
_cbWidth.SelectedItem = "3";
_cbWidth.SelectedIndexChanged += (_, __) =>
{
if (int.TryParse(_cbWidth.SelectedItem?.ToString(), out var w))
_penWidth = w;
};
_tool.Items.AddRange(new ToolStripItem[] { _lblLayer, _cbLayers, _btnToggleVisible, _sep1, _btnEraser, _sep2, _lblWidth, _cbWidth });
_tool.Dock = DockStyle.Top;
}
// ========= 初期化・レイヤー管理 =========
private void InitializeCanvas()
{
// 最低2レイヤー(背景・描画)を用意
_layers.Clear();
_layers.Add(NewLayer("背景")); // 背景:初期は白塗り
_layers.Add(NewLayer("レイヤー1"));
_activeLayerIndex = 1;
// 背景を白で塗る
using (var g = Graphics.FromImage(_layers[0].Bmp)) g.Clear(Color.White);
RefreshLayerUI();
_view.Invalidate();
}
private Layer NewLayer(string name)
{
var bmp = NewBitmapForView();
using (var g = Graphics.FromImage(bmp)) g.Clear(Color.Transparent);
return new Layer { Name = name, Bmp = bmp, Visible = true };
}
private Bitmap NewBitmapForView()
{
var w = Math.Max(1, _view.Width);
var h = Math.Max(1, _view.Height);
// 透明を扱えるよう 32bppArgb
return new Bitmap(w, h, PixelFormat.Format32bppArgb);
}
private void RefreshLayerUI()
{
_cbLayers.ComboBox.BeginUpdate();
_cbLayers.Items.Clear();
foreach (var layer in _layers) _cbLayers.Items.Add(layer.ToString());
if (_layers.Count > 0)
{
_cbLayers.SelectedIndex = Math.Max(0, Math.Min(_activeLayerIndex, _layers.Count - 1));
}
_cbLayers.ComboBox.EndUpdate();
}
private bool HasActiveLayer => _activeLayerIndex >= 0 && _activeLayerIndex < _layers.Count;
private void AddLayer()
{
_layers.Add(NewLayer($"レイヤー{_layers.Count}"));
_activeLayerIndex = _layers.Count - 1;
RefreshLayerUI();
_view.Invalidate();
}
private void DeleteLayer()
{
if (!HasActiveLayer) return;
if (_layers.Count <= 1) { MessageBox.Show("最後のレイヤーは削除できません。"); return; }
var idx = _activeLayerIndex;
_layers[idx].Bmp.Dispose();
_layers.RemoveAt(idx);
_activeLayerIndex = Math.Max(0, idx - 1);
RefreshLayerUI();
_view.Invalidate();
}
private void MoveLayer(int delta) // -1: 前面へ(上へ) / +1: 背面へ(下へ)
{
if (!HasActiveLayer) return;
var idx = _activeLayerIndex;
var newIdx = idx - delta; // 表示は上が前面なので、-1 で手前へ
if (newIdx < 0 || newIdx >= _layers.Count) return;
( _layers[idx], _layers[newIdx] ) = ( _layers[newIdx], _layers[idx] );
_activeLayerIndex = newIdx;
RefreshLayerUI();
_view.Invalidate();
}
private void ToggleActiveVisible()
{
if (!HasActiveLayer) return;
_layers[_activeLayerIndex].Visible = !_layers[_activeLayerIndex].Visible;
RefreshLayerUI();
_view.Invalidate();
}
private void ResizeAllLayers(bool preserve)
{
if (_view.Width <= 0 || _view.Height <= 0) return;
for (int i = 0; i < _layers.Count; i++)
{
var newBmp = NewBitmapForView();
using (var g = Graphics.FromImage(newBmp))
{
g.Clear(Color.Transparent);
if (preserve && _layers[i].Bmp != null)
g.DrawImage(_layers[i].Bmp, Point.Empty);
}
_layers[i].Bmp.Dispose();
_layers[i].Bmp = newBmp;
}
_view.Invalidate();
}
// ========= 描画・消しゴム =========
private void DrawStrokeToActive(Point p1, Point p2)
{
var layer = _layers[_activeLayerIndex];
using var g = Graphics.FromImage(layer.Bmp);
if (_eraserMode)
{
// 学習用:白で上書き(背景が白の場合の分かりやすい消しゴム)
using var pen = new Pen(Color.White, _penWidth)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
g.SmoothingMode = SmoothingMode.AntiAlias;
g.DrawLine(pen, p1, p2);
// --- 応用:透明で消す(レイヤーを活かす)
// g.CompositingMode = CompositingMode.SourceCopy;
// using var pen = new Pen(Color.FromArgb(0, 0, 0, 0), _penWidth) { ... };
// g.DrawLine(pen, p1, p2);
}
else
{
using var pen = new Pen(_penColor, _penWidth)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
g.SmoothingMode = SmoothingMode.AntiAlias;
g.DrawLine(pen, p1, p2);
}
}
private void ComposeAndPaint(Graphics g)
{
g.SmoothingMode = SmoothingMode.None;
g.InterpolationMode = InterpolationMode.NearestNeighbor;
// 背景→手前の順に合成して描く
foreach (var layer in _layers.Where(l => l.Visible))
{
g.DrawImageUnscaled(layer.Bmp, Point.Empty);
}
}
// ========= 履歴(Undo / Redo) =========
private Bitmap SnapshotActive()
{
var src = _layers[_activeLayerIndex].Bmp;
return (Bitmap)src.Clone(); // 深いコピー
}
private void PushUndo(Bitmap snapshot)
{
_undo.Push(snapshot);
// 変更が起きた時点で Redo は破棄
foreach (var r in _redo) r.Dispose();
_redo.Clear();
TrimStack(_undo);
}
private void TrimStack(Stack<Bitmap> st)
{
while (st.Count > MaxHistory)
{
// 一番古いものを捨てたいが Stack は末尾しか触れないため再構築する手もある。
// 学習用の簡易対応:上限はそこまで超えない前提で扱う。
break;
}
}
private void Undo()
{
if (!HasActiveLayer || _undo.Count == 0) return;
var current = _layers[_activeLayerIndex].Bmp;
_redo.Push((Bitmap)current.Clone());
using var prev = _undo.Pop(); // 使い終わりに破棄するため using
// prev を適用
_layers[_activeLayerIndex].Bmp.Dispose();
_layers[_activeLayerIndex].Bmp = (Bitmap)prev.Clone();
_view.Invalidate();
}
private void Redo()
{
if (!HasActiveLayer || _redo.Count == 0) return;
var current = _layers[_activeLayerIndex].Bmp;
_undo.Push((Bitmap)current.Clone());
using var next = _redo.Pop();
_layers[_activeLayerIndex].Bmp.Dispose();
_layers[_activeLayerIndex].Bmp = (Bitmap)next.Clone();
_view.Invalidate();
}
// ========= クリア / 保存 =========
private void ClearActive()
{
if (!HasActiveLayer) return;
PushUndo(SnapshotActive());
using var g = Graphics.FromImage(_layers[_activeLayerIndex].Bmp);
g.Clear(Color.Transparent);
_view.Invalidate();
}
private void SaveMerged()
{
using var sfd = new SaveFileDialog
{
Filter = "PNGファイル|*.png|JPEGファイル|*.jpg;*.jpeg|BMPファイル|*.bmp",
FileName = "drawing.png",
AddExtension = true,
OverwritePrompt = true
};
if (sfd.ShowDialog() != DialogResult.OK) return;
using var merged = NewBitmapForView();
using (var g = Graphics.FromImage(merged))
{
g.Clear(Color.White); // 出力は白背景にマージ
foreach (var layer in _layers.Where(l => l.Visible))
g.DrawImageUnscaled(layer.Bmp, Point.Empty);
}
var ext = System.IO.Path.GetExtension(sfd.FileName).ToLowerInvariant();
var format = ext switch
{
".jpg" or ".jpeg" => ImageFormat.Jpeg,
".bmp" => ImageFormat.Bmp,
_ => ImageFormat.Png
};
merged.Save(sfd.FileName, format);
MessageBox.Show("保存しました: " + sfd.FileName, "保存", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
// ========= 終了時リソース解放 =========
protected override void OnFormClosed(FormClosedEventArgs e)
{
foreach (var l in _layers) l.Bmp.Dispose();
foreach (var b in _undo) b.Dispose();
foreach (var b in _redo) b.Dispose();
base.OnFormClosed(e);
}
}
}
仕組みの解説
1) 消しゴムモード(白で上書き)
- 初学者向けに「白い線で塗る」方式。背景が白だと直感的です。
- 透明レイヤーに“穴を空ける”方式を使いたい場合は、CompositingMode.SourceCopy + Color.FromArgb(0,0,0,0) を利用(上のコード内コメント参照)。
2) Undo / Redo(ストローク単位)
- ストローク開始(MouseDown)時に、アクティブレイヤーのBitmapをCloneしてUndoへPush。
- 以後の描画で画像が変わっても、MouseUp まで保存は追加しない(= ストローク単位)。
- Undoしたら、直前の状態をRedoへ退避。新規描画が起きたらRedoはクリア。
実用では「コマンドパターン(ICommand)」や「差分記録(領域やパスだけ保存)」の方がメモリ効率に優れます。
3) 複数レイヤー
- List<Layer> を用意し、背景→前面 の順に Graphics.DrawImageUnscaled で合成表示。
- 描画は アクティブレイヤーのみ を対象。非表示レイヤーは合成しません。
- レイヤーの追加・削除・前後移動・可視切替を用意。アクティブ切替は ToolStrip のコンボで。
使い方
- ブラシ色は「ブラシ → 色変更」
- 線幅はメニューかツールバーの幅コンボ
- 消しゴムはメニューのチェック、またはツールバーのトグル
- Undo / Redo は Ctrl+Z / Ctrl+Y
- 保存は「ファイル → 保存…」で PNG/JPEG/BMP
発展課題
- 透明消しゴムに切替(CompositingMode.SourceCopy を使って透明で上書き)
- レイヤー名の変更(入力ダイアログを出して Layer.Name を更新)
- 履歴の最適化(ストローク単位で「描いた矩形領域だけ」差分保存)
- ベクタライズ(ストロークを頂点列として保持→拡大縮小でも劣化しない)
- PSD風エクスポート(PNGで各レイヤーを別ファイル保存、JSONでレイアウト記述)
了解。前回の拡張サンプル(レイヤー/消しゴム/Undo/Redo)をベースに、以下の5点を「実装指針+貼り替え用コード片」でまとめます。
(.NET 6+/WinForms。名前空間やクラス名は前回の PaintAdvancedLayers.Form1 準拠)
追加要件の実装ガイド
「背景が白で塗る」方式ではなく、レイヤーのアルファを0にする(=透明化)方式です。
置き換えポイント
DrawStrokeToActive を差し替えます。透明ペン描画が環境により無視される場合があるため、フォールバックとして「小さな円を連ねて消す(FillEllipse)」も用意しています。
private void DrawStrokeToActive(Point p1, Point p2)
{
var layer = _layers[_activeLayerIndex];
using var g = Graphics.FromImage(layer.Bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
if (_eraserMode)
{
// --- 透明消しゴム(推奨) ---
g.CompositingMode = CompositingMode.SourceCopy;
// 1) まず透明ペンで線を試す
using (var pen = new Pen(Color.FromArgb(0, 0, 0, 0), _penWidth)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
})
{
g.DrawLine(pen, p1, p2);
}
// 2) 一部環境で透明ペンが無視される場合のフォールバック
// 小さな円をステップで敷き詰めて透明で塗る
using var brush = new SolidBrush(Color.FromArgb(0, 0, 0, 0));
float step = Math.Max(1f, _penWidth / 3f);
float dx = p2.X - p1.X, dy = p2.Y - p1.Y;
float len = (float)Math.Sqrt(dx * dx + dy * dy);
int n = Math.Max(1, (int)(len / step));
for (int i = 0; i <= n; i++)
{
float t = (float)i / n;
float x = p1.X + dx * t;
float y = p1.Y + dy * t;
var r = _penWidth / 2f;
g.FillEllipse(brush, x - r, y - r, _penWidth, _penWidth);
}
g.CompositingMode = CompositingMode.SourceOver; // 戻す
}
else
{
using var pen = new Pen(_penColor, _penWidth)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
g.DrawLine(pen, p1, p2);
}
}
備考:透明消しは レイヤーが PixelFormat.Format32bppArgb であることが前提です(前回の NewBitmapForView() は既に対応)。
2) レイヤー名の変更:入力ダイアログ
最小実装として小さなダイアログを内製します。
追加:簡易入力ダイアログ
private static string? Prompt(string title, string label, string defaultText = "")
{
using var f = new Form { Text = title, StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog, MinimizeBox = false,
MaximizeBox = false, ClientSize = new Size(360, 130) };
var lbl = new Label { Text = label, AutoSize = true, Left = 12, Top = 12 };
var tb = new TextBox { Left = 12, Top = 35, Width = 330, Text = defaultText };
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, Left = 192, Top = 75, Width = 70 };
var cancel = new Button { Text = "キャンセル", DialogResult = DialogResult.Cancel, Left = 272, Top = 75, Width = 70 };
f.Controls.AddRange(new Control[] { lbl, tb, ok, cancel });
f.AcceptButton = ok; f.CancelButton = cancel;
return f.ShowDialog() == DialogResult.OK ? tb.Text : null;
}
追加:メニュー(レイヤー名変更)
BuildMenu() のレイヤー項目に次を追加:
var miRename = new ToolStripMenuItem("レイヤー名を変更(&R)");
miRename.Click += (_, __) =>
{
if (!HasActiveLayer) return;
var cur = _layers[_activeLayerIndex];
var s = Prompt("レイヤー名の変更", "新しいレイヤー名:", cur.Name);
if (!string.IsNullOrWhiteSpace(s))
{
cur.Name = s.Trim();
RefreshLayerUI();
_view.Invalidate();
}
};
_layerMenu.DropDownItems.Add(new ToolStripSeparator());
_layerMenu.DropDownItems.Add(miRename);
RefreshLayerUI() は layer.ToString() を使っているので、名前変更が即座に反映されます。
3) 履歴の最適化:ストローク矩形だけ保存(パッチ方式)
ストローク全体の 影響範囲(バウンディング矩形) を求め、その矩形の“描画前ピクセル”だけ を Undo スタックに積みます。
実装上は「ストローク開始時にレイヤー全体を一時コピー → ストローク終了時に 矩形で切り出した部分だけを Undo に保存」。
→ 積まれる履歴は小さく、一時コピーは都度破棄します。
追加:パッチ構造体と一時バッファ
private class Patch
{
public int LayerIndex;
public Rectangle Rect; // 影響範囲
public Bitmap BeforePixels; // 描画前の画素(Rectサイズ)
}
private Bitmap? _preStrokeFullCopy; // ストローク開始時のレイヤー全体コピー
private Rectangle _strokeBounds; // ストロークの影響矩形(MouseMoveで拡張)
MouseDown / MouseMove / MouseUp の変更
_view.MouseDown += (s, e) =>
{
if (e.Button != MouseButtons.Left) return;
if (!HasActiveLayer) return;
// ストローク前のレイヤー全体を一時コピー(後でRect切り出しに使う)
_preStrokeFullCopy?.Dispose();
_preStrokeFullCopy = (Bitmap)_layers[_activeLayerIndex].Bmp.Clone();
_strokeBounds = new Rectangle(e.Location, Size.Empty);
_drawing = true;
_last = e.Location;
};
_view.MouseMove += (s, e) =>
{
if (!_drawing || !HasActiveLayer) return;
// 1セグメント描画
DrawStrokeToActive(_last, e.Location);
// 影響矩形を拡張(線幅ぶん Inflate)
var seg = Rectangle.FromLTRB(
Math.Min(_last.X, e.Location.X),
Math.Min(_last.Y, e.Location.Y),
Math.Max(_last.X, e.Location.X),
Math.Max(_last.Y, e.Location.Y));
int inflate = (int)Math.Ceiling(_penWidth / 2f + 2);
seg.Inflate(inflate, inflate);
_strokeBounds = _strokeBounds.IsEmpty ? seg : Rectangle.Union(_strokeBounds, seg);
_last = e.Location;
_view.Invalidate();
};
_view.MouseUp += (s, e) =>
{
_drawing = false;
if (_preStrokeFullCopy == null || _strokeBounds.IsEmpty || !HasActiveLayer) return;
// レイヤー領域でクリップした矩形
var layerBmp = _layers[_activeLayerIndex].Bmp;
var clipRect = Rectangle.Intersect(_strokeBounds, new Rectangle(Point.Empty, layerBmp.Size));
if (clipRect.Width <= 0 || clipRect.Height <= 0) { _preStrokeFullCopy.Dispose(); _preStrokeFullCopy = null; return; }
// BeforePixels を切り出し
var before = _preStrokeFullCopy.Clone(clipRect, _preStrokeFullCopy.PixelFormat);
var patch = new Patch { LayerIndex = _activeLayerIndex, Rect = clipRect, BeforePixels = before };
// Undoへ積む(Redoは消す)
_undo.PushPatch(patch, _redo); // 下の拡張メソッドを後掲
_view.Invalidate();
_preStrokeFullCopy.Dispose();
_preStrokeFullCopy = null;
};
Undo/Redo 実装(パッチ適用)
Undo 時には、現在の画素を Redo 用に保存し、BeforePixels を貼り戻します。
// Undo/Redo 用のスタック(パッチ型)
private readonly Stack<Patch> _undoPatches = new();
private readonly Stack<Patch> _redoPatches = new();
private void Undo()
{
if (_undoPatches.Count == 0) return;
var patch = _undoPatches.Pop();
var layer = _layers[patch.LayerIndex];
using var g = Graphics.FromImage(layer.Bmp);
// 1) Redo用に現状を切り出す
var afterNow = layer.Bmp.Clone(patch.Rect, layer.Bmp.PixelFormat);
_redoPatches.Push(new Patch { LayerIndex = patch.LayerIndex, Rect = patch.Rect, BeforePixels = afterNow });
// 2) BeforePixels を貼り戻す(SourceCopyで上書き)
g.CompositingMode = CompositingMode.SourceCopy;
g.DrawImageUnscaled(patch.BeforePixels, patch.Rect.Location);
g.CompositingMode = CompositingMode.SourceOver;
_view.Invalidate();
}
private void Redo()
{
if (_redoPatches.Count == 0) return;
var patch = _redoPatches.Pop();
var layer = _layers[patch.LayerIndex];
using var g = Graphics.FromImage(layer.Bmp);
// 1) Undo用に現状を切り出す
var afterNow = layer.Bmp.Clone(patch.Rect, layer.Bmp.PixelFormat);
_undoPatches.Push(new Patch { LayerIndex = patch.LayerIndex, Rect = patch.Rect, BeforePixels = afterNow });
// 2) Redoパッチ内容を貼る
g.CompositingMode = CompositingMode.SourceCopy;
g.DrawImageUnscaled(patch.BeforePixels, patch.Rect.Location);
g.CompositingMode = CompositingMode.SourceOver;
_view.Invalidate();
}
スタック操作(ヒストリ上限)
private const int MaxHistory = 50;
private static void PushPatch(this Stack<Patch> undo, Patch patch, Stack<Patch> redo)
{
// 変更が入ったら Redo は破棄
foreach (var p in redo) p.BeforePixels.Dispose();
redo.Clear();
undo.Push(patch);
// 上限超過なら古いものからDispose(簡易実装:再構築)
if (undo.Count > MaxHistory)
{
var list = undo.ToList(); // 先頭が古い
list.Reverse(); // 末尾が古い
var toDispose = list.Skip(MaxHistory).ToList();
foreach (var p in toDispose) p.BeforePixels.Dispose();
// 最新 MaxHistory だけ残す
undo.Clear();
foreach (var keep in list.Take(MaxHistory).Reverse()) undo.Push(keep);
}
}
これで Undo/Redo に積まれるビットマップは影響領域だけ になります。
一時的な全体コピー(_preStrokeFullCopy)はストローク確定時に捨てます。
4) ベクタライズ:ストロークの頂点列を保持
描画の見た目は今まで通りビットマップに出しますが、同時にストロークのベクター情報(点列+色+太さ+消しゴム属性)を保持。
将来「再レンダリング(拡大保存)」「SVG出力」等に使えます。
ストローク構造
private class Stroke
{
public bool IsEraser;
public Color Color;
public float Width;
public List<PointF> Points = new();
public int LayerIndex; // どのレイヤーに属するストロークか
}
private readonly List<Stroke> _strokes = new();
private Stroke? _currentStroke;
MouseDown / Move / Up で記録
// MouseDown の最後に
_currentStroke = new Stroke
{
IsEraser = _eraserMode,
Color = _penColor,
Width = _penWidth,
LayerIndex = _activeLayerIndex
};
_currentStroke.Points.Add(e.Location);
// MouseMove の最後に
_currentStroke?.Points.Add(e.Location);
// MouseUp の最後に
if (_currentStroke != null)
{
_strokes.Add(_currentStroke);
_currentStroke = null;
}
ベクターから任意スケールで再描画(保存用など)
private Bitmap RenderVectorsToBitmap(Size targetSize, float scale)
{
var bmp = new Bitmap(targetSize.Width, targetSize.Height, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
foreach (var s in _strokes)
{
if (s.Points.Count < 2) continue;
using (var path = new System.Drawing.Drawing2D.GraphicsPath())
{
for (int i = 1; i < s.Points.Count; i++)
{
var a = new PointF(s.Points[i - 1].X * scale, s.Points[i - 1].Y * scale);
var b = new PointF(s.Points[i].X * scale, s.Points[i].Y * scale);
path.AddLine(a, b);
}
if (s.IsEraser)
{
// レイヤー合成のセマンティクスを厳密に再現するなら、レイヤー毎に別キャンバスへSourceCopyで透明描画→最後に合成するのが理想
g.CompositingMode = CompositingMode.SourceCopy;
using var pen = new Pen(Color.FromArgb(0, 0, 0, 0), s.Width * scale)
{ StartCap = LineCap.Round, EndCap = LineCap.Round, LineJoin = LineJoin.Round };
g.DrawPath(pen, path);
g.CompositingMode = CompositingMode.SourceOver;
}
else
{
using var pen = new Pen(s.Color, s.Width * scale)
{ StartCap = LineCap.Round, EndCap = LineCap.Round, LineJoin = LineJoin.Round };
g.DrawPath(pen, path);
}
}
}
return bmp;
}
正確なレイヤー再現を目指す場合、レイヤー別にベクターをレンダリング→最後に前後関係で合成してください(保存や高解像度出力時のみ実施でOK)。
5) PSD風エクスポート:レイヤーごとにPNG+JSONメタ
可視レイヤーを順序どおりに layers_000.png, layers_001.png, … として保存し、JSONでレイアウト(名前・可視・順序・サイズ) を出力します。
(System.Text.Json を使用)
追加:PSD風エクスポート関数
using System.Text.Json;
using System.Text.Json.Serialization;
private class LayerMeta
{
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("file")] public string File { get; set; }
[JsonPropertyName("visible")] public bool Visible { get; set; }
[JsonPropertyName("z")] public int Z { get; set; } // 0が背面
[JsonPropertyName("size")] public int[] Size { get; set; } // [w,h]
}
private void ExportPsdLike()
{
using var fbd = new FolderBrowserDialog { Description = "エクスポート先フォルダを選択" };
if (fbd.ShowDialog() != DialogResult.OK) return;
var metas = new List<LayerMeta>();
for (int i = 0; i < _layers.Count; i++)
{
var layer = _layers[i];
string file = $"layer_{i:D3}.png";
string path = System.IO.Path.Combine(fbd.SelectedPath, file);
// そのままPNG保存(透明保持)
layer.Bmp.Save(path, ImageFormat.Png);
metas.Add(new LayerMeta
{
Name = layer.Name,
File = file,
Visible = layer.Visible,
Z = i, // 背面0 → 前面大
Size = new[] { layer.Bmp.Width, layer.Bmp.Height }
});
}
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(metas, options);
System.IO.File.WriteAllText(System.IO.Path.Combine(fbd.SelectedPath, "layers.json"), json, System.Text.Encoding.UTF8);
MessageBox.Show("PSD風エクスポートが完了しました。", "Export", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
メニューに「PSD風エクスポート…」を追加
BuildMenu() の「ファイル」メニューなどに:
var miExport = new ToolStripMenuItem("PSD風エクスポート(&P)...");
miExport.Click += (_, __) => ExportPsdLike();
_fileMenu.DropDownItems.Insert(1, miExport); // 保存の次に差し込む例
まとめと運用ヒント
- 透明消しは CompositingMode.SourceCopy が鍵。環境依存で透明ペンが描かれない場合に備え、FillEllipseフォールバックを併用。
- 履歴の最適化は「積むのは影響矩形だけ」。ストローク中だけ全体コピーを持ち、確定時に矩形へ切り出す方式がシンプルで堅い。
- ベクタ保持は「学習&将来の高解像度出力」のために有益。普段はラスタで即時描画、必要時にベクタから再レンダリング。
- PSD風エクスポートは「PNG(透明保持)+JSON」で実用十分。後段処理(Webや別アプリ)で読み込ませやすい。
ディスカッション
コメント一覧
まだ、コメントがありません