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

発展課題

  1. 透明消しゴムに切替(CompositingMode.SourceCopy を使って透明で上書き)
  2. レイヤー名の変更(入力ダイアログを出して Layer.Name を更新)
  3. 履歴の最適化(ストローク単位で「描いた矩形領域だけ」差分保存)
  4. ベクタライズ(ストロークを頂点列として保持→拡大縮小でも劣化しない)
  5. 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や別アプリ)で読み込ませやすい。

訪問数 6 回, 今日の訪問数 6回