2011年10月4日火曜日

wpf : 複数の点を通る滑らかな曲線を描く

昨日の続きです。 複数の点を通る滑らかな曲線を描くプログラムを作りました。

サンプルプログラムをキャプチャーするとこんな感じ。

コードは長いので、今回も下の方に載せます。

左クリックされた点をつなぐように曲線を描きます。 前のプログラムにならって、制御点などを表す補助線を付けてみました。 右クリックで全消去です。

昨日書いたように、ベジェ曲線のパラメータを求める部分では暗黙の構造体コピーが起きないように工夫してみました。 BezierCurveクラスの部分です。 Point構造体を使って計算するときにのプロパティではなく、private変数を使うことで暗黙のコピーが起きないようにしています。

wpfのPathクラスで描画しているため、その部分では構造体のコピーはできまくっています。 ただ、もともと「ペイントソフトで曲線をどう求めているか?」という疑問から始まったネタなので、「描画にPathクラス」というのは本筋ではありません。 「WriteableBitmapに直描き」とか、「メモリ上に自作クラスで画像データを持っていて、表示はWriteableBitmap」とかが本筋でしょう。 一番単純に考えるなら、BezierCurveクラスにDrawメソッドを追加してWriteableBitmapに直描きとかになるのかな? それならPoint構造体のコピーは起きません。 一応は工夫できたということで...

その「WriteableBitmapを使う」のと「クリックイベントをドラッグイベントに変更」をすれば、シンプルなペイントソフトの原型みたいなのができますね。

以下、今回のコードです。 4つあります。

まずはMainWindow.xaml。

<Window x:Class="BezierCurveTest02.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Canvas Background="White" MouseLeftButtonUp="OnMouseLeftButtonUp" MouseRightButtonUp="OnMouseRightButtonUp">
        <Canvas Name="auxiliaryLineCanvas" Visibility="Hidden">
            <Line Name="auxiliaryLine1" Stroke="Blue" StrokeThickness="2.0"/>
            <Line Name="auxiliaryLine1cs" Stroke="Blue"/>
            <Line Name="auxiliaryLine1ce" Stroke="Blue"/>
            <Line Name="auxiliaryLine2" Stroke="Red" StrokeThickness="2.0"/>
            <Line Name="auxiliaryLine2cs" Stroke="Red"/>
            <Line Name="auxiliaryLine2ce" Stroke="Red"/>
        </Canvas>
        <Path Name="path" Stroke="Black"/>
    </Canvas>
</Window>

MainWindow.xaml.csです。

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace BezierCurveTest02
{
    public partial class MainWindow : Window
    {
        private InputCurves _inputCurves;

        public MainWindow()
        {
            InitializeComponent();

            _inputCurves = new InputCurves();

            PathFigure pathFigure = new PathFigure();
            pathFigure.StartPoint = new Point();

            PolyBezierSegment polyBezierSegment = new PolyBezierSegment();
            polyBezierSegment.Points = new PointCollection();

            PathSegmentCollection pathSegmentCollection = new PathSegmentCollection();
            pathSegmentCollection.Add(polyBezierSegment);

            pathFigure.Segments = pathSegmentCollection;

            PathFigureCollection pathFigureCollection = new PathFigureCollection();
            pathFigureCollection.Add(pathFigure);

            PathGeometry pathGeometry = new PathGeometry();
            pathGeometry.Figures = pathFigureCollection;

            path.Data = pathGeometry;
        }

        // 左クリックされたら線を延長(手抜きのため最初の点は表示しない)
        private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
        {
            Point p = e.GetPosition(sender as Canvas);
            _inputCurves.AddPoint(p.X, p.Y);

            PathFigure pathFigure = ((PathGeometry)path.Data).Figures[0];
            PointCollection pointCollection = ((PolyBezierSegment)pathFigure.Segments[0]).Points;
            pointCollection.Clear();

            Point[] curvePoints = _inputCurves.GetAllPoints();
            pathFigure.StartPoint = curvePoints[0];
            for (int i = 1; i < curvePoints.Length; i++)
                pointCollection.Add(curvePoints[i]);

            SetAuxiliaryLineParam(curvePoints);
        }

        // 右クリックされたら線を消去
        private void OnMouseRightButtonUp(object sender, MouseButtonEventArgs e)
        {
            auxiliaryLineCanvas.Visibility = Visibility.Hidden;

            _inputCurves.Clear();

            PathFigure pathFigure = ((PathGeometry)path.Data).Figures[0];
            PointCollection points = ((PolyBezierSegment)pathFigure.Segments[0]).Points;

            pathFigure.StartPoint = new Point(double.NaN, double.NaN);
            points.Clear();
        }

        private void SetAuxiliaryLineParam(Point[] points)
        {
            int len = points.Length;
            if (len < 7)
            {
                auxiliaryLineCanvas.Visibility = Visibility.Hidden;
                return;
            }

            auxiliaryLine1.X1 = points[len - 7].X;
            auxiliaryLine1.Y1 = points[len - 7].Y;
            auxiliaryLine1.X2 = points[len - 4].X;
            auxiliaryLine1.Y2 = points[len - 4].Y;

            auxiliaryLine1cs.X1 = points[len - 7].X;
            auxiliaryLine1cs.Y1 = points[len - 7].Y;
            auxiliaryLine1cs.X2 = points[len - 6].X;
            auxiliaryLine1cs.Y2 = points[len - 6].Y;

            auxiliaryLine1ce.X1 = points[len - 5].X;
            auxiliaryLine1ce.Y1 = points[len - 5].Y;
            auxiliaryLine1ce.X2 = points[len - 4].X;
            auxiliaryLine1ce.Y2 = points[len - 4].Y;

            auxiliaryLine2.X1 = points[len - 4].X;
            auxiliaryLine2.Y1 = points[len - 4].Y;
            auxiliaryLine2.X2 = points[len - 1].X;
            auxiliaryLine2.Y2 = points[len - 1].Y;

            auxiliaryLine2cs.X1 = points[len - 4].X;
            auxiliaryLine2cs.Y1 = points[len - 4].Y;
            auxiliaryLine2cs.X2 = points[len - 3].X;
            auxiliaryLine2cs.Y2 = points[len - 3].Y;

            auxiliaryLine2ce.X1 = points[len - 2].X;
            auxiliaryLine2ce.Y1 = points[len - 2].Y;
            auxiliaryLine2ce.X2 = points[len - 1].X;
            auxiliaryLine2ce.Y2 = points[len - 1].Y;

            auxiliaryLineCanvas.Visibility = Visibility.Visible;
        }
    }
}

BezierCurveクラスは前の曲線の終点から次にクリックされた点までの曲線をあらわします。 座標などのパラメータを計算するためだけのクラスで、描画はMainWindow.xaml.csで行います。

using System;
using System.Windows;

namespace BezierCurveTest02
{
    class BezierCurve
    {
        public const double SMOOTH_HINT = 1.0 / 4.0;

        private BezierCurve(){}

        public Point Start
        {
            get
            {
                return _prev._end;
            }
        }

        private Point _controlS;
        public Point ControlS
        {
            get
            {
                return _controlS;
            }
        }

        private Point _controlE;
        public Point ControlE
        {
            get
            {
                return _controlE;
            }
        }

        private Point _end;
        public Point End
        {
            get
            {
                return _end;
            }
        }

        private BezierCurve _prev;
        public BezierCurve Prev
        {
            get
            {
                return _prev;
            }
        }

        private BezierCurve _next;
        public BezierCurve Next
        {
            get
            {
                return _next;
            }
        }

        private double _distance;
        private double _theta;

        public static BezierCurve CreateStartPoint(double x, double y)
        {
            BezierCurve res = new BezierCurve();
            res._end.X = x;
            res._end.Y = y;
            return res;
        }

        // 最初の2つの点が登録されたところで直線ができる。
        // 制御点はとりあえず直線上に作る。
        public static BezierCurve CreateFirstLine(BezierCurve startPoint, double x2, double y2)
        {
            BezierCurve res = CalcParam(startPoint, x2, y2);
            double startX = startPoint._end.X;
            double startY = startPoint._end.Y;
            double d = res._distance;
            double theta = res._theta;

            res._controlS = new Point(
                startX + d * Math.Cos(theta) * SMOOTH_HINT,
                startY + d * Math.Sin(theta) * SMOOTH_HINT
            );
            res._controlE = new Point(
                startX + d * Math.Cos(theta) * (1.0 - SMOOTH_HINT),
                startY + d * Math.Sin(theta) * (1.0 - SMOOTH_HINT)
            );

            return res;
        }

        // prevとresの制御点と(startX,startY)は直線上に並ぶように配置する。
        public static BezierCurve CreateNext(BezierCurve prev, double endX, double endY)
        {
            BezierCurve res = CalcParam(prev, endX, endY);
            double startX = prev._end.X;
            double startY = prev._end.Y;
            double d1 = prev._distance;
            double theta1 = prev._theta + Math.PI; // (startX,startY)からの角度が欲しいので180度回転
            if (Math.PI < theta1)
                theta1 -= Math.PI * 2.0;
            double d2 = res._distance;
            double theta2 = res._theta;

            double thetaTmp = theta1 - theta2;
            double theta1c, theta2c;
            if (thetaTmp < -Math.PI)
            {
                thetaTmp = (-thetaTmp - Math.PI) / 2.0;
                theta1c = theta1 + thetaTmp;
                theta2c = theta2 - thetaTmp;
            }
            else if (thetaTmp < 0)
            {
                thetaTmp = (Math.PI + thetaTmp) / 2.0;
                theta1c = theta1 - thetaTmp;
                theta2c = theta2 + thetaTmp;
            }
            else if (thetaTmp < Math.PI)
            {
                thetaTmp = (Math.PI - thetaTmp) / 2.0;
                theta1c = theta1 + thetaTmp;
                theta2c = theta2 - thetaTmp;
            }
            else
            {
                thetaTmp = (thetaTmp - Math.PI) / 2.0;
                theta1c = theta1 - thetaTmp;
                theta2c = theta2 + thetaTmp;
            }

            prev._controlE.X = startX + d1 * Math.Cos(theta1c) * SMOOTH_HINT;
            prev._controlE.Y = startY + d1 * Math.Sin(theta1c) * SMOOTH_HINT;

            // とりあえず、曲線全部を通して最初の制御点は次の制御点と同じ座標にする。
            if (prev._prev._prev == null)
            {
                prev._controlS.X = prev._controlE.X;
                prev._controlS.Y = prev._controlE.Y;
            }

            res._controlS.X = startX + d2 * Math.Cos(theta2c) * SMOOTH_HINT;
            res._controlS.Y = startY + d2 * Math.Sin(theta2c) * SMOOTH_HINT;

            // とりあえず、曲線全部を通して最後の制御点は前の制御点と同じ座標にする。
            res._controlE.X = res._controlS.X;
            res._controlE.Y = res._controlS.Y;


            return res;
        }

        private static BezierCurve CalcParam(BezierCurve prev, double x, double y)
        {
            BezierCurve res = new BezierCurve();
            prev._next = res;
            res._prev = prev;
            res._end.X = x;
            res._end.Y = y;

            double startX = prev._end.X;
            double startY = prev._end.Y;
            double tx = res._end.X - startX;
            double ty = res._end.Y - startY;
            double d = Math.Sqrt(tx * tx + ty * ty);
            res._distance = d;
            double theta = Math.Atan2(ty, tx);
            res._theta = theta;

            return res;
        }
    }
}

InputCurvesクラスです。 AddPointされた座標をBezierCurveで連結して一連の曲線を作ります。

using System.Collections.Generic;
using System.Windows;

namespace BezierCurveTest02
{
    class InputCurves
    {
        // _curves[0]は曲線としては使用せず、全体の開始点をEndに登録するためだけに使う。
        // Endを使うのは以降のコードで場合分けが少なくすむように。
        private List<BezierCurve> _curves;

        public InputCurves()
        {
            _curves = new List<BezierCurve>();
        }

        public void AddPoint(double x, double y)
        {
            if (_curves.Count == 0)
                _curves.Add(BezierCurve.CreateStartPoint(x, y));
            else if (_curves.Count == 1)
                _curves.Add(BezierCurve.CreateFirstLine(_curves[0], x, y));
            else
                _curves.Add(BezierCurve.CreateNext(_curves[_curves.Count - 1], x, y));
        }

        public Point[] GetAllPoints()
        {
            if (_curves.Count == 0)
                return null;

            Point[] res = new Point[(_curves.Count - 1) * 3 + 1];

            res[0] = _curves[0].End;

            for (int i = 1; i < _curves.Count; i++)
            {
                BezierCurve curve = _curves[i];
                res[i * 3 - 2] = curve.ControlS;
                res[i * 3 - 1] = curve.ControlE;
                res[i * 3] = curve.End;
            }

            return res;
        }

        public void Clear()
        {
            _curves.Clear();
        }
    }
}