2011年10月3日月曜日

wpf : 3点を通る曲線をベジェ曲線を組み合わせて作る

マウスドラッグのイベントで通知された点をつなぐ滑らかな曲線を得る。 ペイントソフトを作るとに一番最初に必要となるコードです。 「どうやってるんだろう?」と気になって検索してみたんですが、そのものズバリの解説はありませんでした。

「gimpのコード読め」っていうのが見つかった中での真っ当な意見ですね。 実用的なものを作ろういうならそうするしか無いのかもしれませんが、そこまでやる必要性も根性もなし。 まぁとりあえず自分の持っている知識でそれっぽいコードを書いてみることにしました。

まずは3点を通る曲線を求めるコードに挑戦。 普通はスプライン曲線を使うらしいんですが、「手持ちの知識で」と言うことでベジェ曲線を求めました。 wpfで適当に作ったら意外と時間がかかりました。 こういう算数を使うコードって深夜の思考力がないときに作ったらダメですね。

コードは長ったらしいので後の方に載せるとして、サンプルプログラムをキャプチャーした画像はこんな感じです。

青い太線が線分line1、赤い太線が線分line2です。 line1とline2の長さと角度は上のスライダーで変えられるようになっています。

薄いグレーの曲線が求めるベジェ曲線です。 滑らかにつながっているので1本の曲線に見えますが、line1に対応した曲線1とline2に対応した曲線2の2本があります。 曲線1の制御点は、1つ目がline1上、2つ目が細短い青線(line1c)の先にあります。 曲線2の制御点は、1つ目が細短い赤線(line2c)の先で、2つ目がline2上です。

制御点はline1とline2のパラメータが変更されるごとに計算します。 line1cとline2cが一直線になり、青い角と赤い角が同じ角度になるようにします。 青い角と赤い角は90度以下で、交差してはダメです。

制御点の位置は、「line1の長さ = line1cの長さ * 3」となるように決めました。 line2も同じです。 3っていうのは適当に決めた定数です。 線分上にある方の制御点も同じような距離に置いてます。

座標とかのパラメータの説明はあまりやる気がないので、こんな感じの分かりにくい説明でカンベンしてください。 ↓のコードも無機質な変数名が多くて分かりづらいですが、算数だから仕方がない(逃)ってことで。 まぁ、スライダーをぐりぐり動かすと曲線もそれっぽく動くのが確認できたので目的達成です。 赤青の線が入れ替わったときも、青線と赤線の角度が鈍角⇔鋭角の境目でも動くようにするのがポイント。

wpfはこういうサンプルコードを書くときに作業が少なくて済むのでありがたいですね。 スライダーとテキストボックスの連携とか、win32sdkでやったらけっこうな作業量になります。

ただし、wpfの欠点としてxamlにバインドを書くという性質上、コードとデザインは分離されていません。 本来コードでなんとかするはずの機能がデザイン部分のxamlに食い込んできているのです。 大規模なプロジェクトでwpfを使う場合はこのへんがかなり煩雑になりそうです。

最小限のバインドで済ませてコードとデザインを上手く分ければ使えないことも無いのかな? そのルールを明確にしとかないと、すぐに誰も保守できないプロジェクトになりますよね。 でもマイクロソフトはそういうのを勧めないだろうなぁ。

あと、↓のコードで気になったところはPoint構造体のnewやコピーです。 構造体は値渡しです。 構造体型のプロパティをgetしても、参照渡しではなく値渡しなので、帰ってくるのは構造体のコピーです。 コピーの値をいくら書き換えても本体には影響が無いので、BezierSegmentのPoint1~Point3プロパティのXやYを直接書き換えることはできません。 例えば、

BezierSegment curve1 = なんたら;
curve1.Point1.X = 0.0;

のようなコードを書くと「変数ではないため、'Point1' の戻り値を変更できません。」というエラーがでてしまいます。

BezierSegment curve1 = なんたら;
curve1.Point1 = new Point(x, y);

のように書かないとダメ。

コレの何が気になるって、何の気なしに書いていると「new」や「暗黙の構造体コピー」があふれるコードになってしまうというところですよね。 「フラグメンテーションが!?」とか「newやコピーのコストは?」とか気になってしまいます。 コンピュータの性能ギリギリのコードでも書かない限り気にする必要のないことなんでしょうけど、なんか、なんとかしなければならないと思ってしまいます。 もうこれは、学生のころに読んだ解説書の呪いですね。

次の投稿で試すコードは「複数の点をつなぐ滑らかな曲線を描くプログラム」になりそうですが、それでは暗黙の構造体コピーが起きないように工夫して書いてみましょうか?

以下、今回のコードです。 まずはMainWindow.xaml。

<Window x:Class="BezierCurveTest01.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="600" Width="500" Loaded="WindowLoaded">
    <StackPanel>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
                <RowDefinition/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="70"/>
                <ColumnDefinition Width="314"/>
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Label Grid.Row="0" Grid.Column="0">線1の角度</Label>
            <Slider Grid.Row="0" Grid.Column="1" Name="sliderAngle1" Width="314" Minimum="-180" Maximum="180"/>
            <TextBox Grid.Row="0" Grid.Column="2" Name="textBoxAngle1" Width="80" TextAlignment="Right" Text="{Binding ElementName=sliderAngle1, Path=Value, UpdateSourceTrigger=PropertyChanged}"/>
            <Label Grid.Row="1" Grid.Column="0">線1の長さ</Label>
            <Slider Grid.Row="1" Grid.Column="1" Name="sliderLength1" Width="314" Minimum="0" Maximum="200" Value="200"/>
            <TextBox Grid.Row="1" Grid.Column="2" Name="textBoxLength1" Width="80" TextAlignment="Right" Text="{Binding ElementName=sliderLength1, Path=Value, UpdateSourceTrigger=PropertyChanged}"/>
            <Label Grid.Row="2" Grid.Column="0">線2の角度</Label>
            <Slider Grid.Row="2" Grid.Column="1" Name="sliderAngle2" Width="314" Minimum="-180" Maximum="180"/>
            <TextBox Grid.Row="2" Grid.Column="2" Name="textBoxAngle2" Width="80" TextAlignment="Right" Text="{Binding ElementName=sliderAngle2, Path=Value, UpdateSourceTrigger=PropertyChanged}"/>
            <Label Grid.Row="3" Grid.Column="0">線2の長さ</Label>
            <Slider Grid.Row="3" Grid.Column="1" Name="sliderLength2" Width="314" Minimum="0" Maximum="200" Value="200"/>
            <TextBox Grid.Row="3" Grid.Column="2" Name="textBoxLength2" Width="80" TextAlignment="Right" Text="{Binding ElementName=sliderLength2, Path=Value, UpdateSourceTrigger=PropertyChanged}"/>
        </Grid>
        <Canvas Width="400" Height="400" Margin="20">
            <Path Name="path" Stroke="Gray"/>
            <Ellipse Width="400" Height="400" Stroke="LightGray"/>
            <Line Name="line1" X1="200" Y1="200" X2="400" Y2="200" Stroke="Blue" StrokeThickness="2"/>
            <Line Name="line1c" X1="200" Y1="200" X2="0" Y2="0" Stroke="Blue"/>
            <Line Name="line2" X1="200" Y1="200" X2="400" Y2="200" Stroke="Red" StrokeThickness="2"/>
            <Line Name="line2c" X1="200" Y1="200" X2="0" Y2="0" Stroke="Red"/>
        </Canvas>
    </StackPanel>
</Window>

そしてMainWindow.xaml.csです。

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using System.Windows.Media;

namespace BezierCurveTest01
{
    public partial class MainWindow : Window
    {
        private const double SMOOTH_HINT = 1.0 / 3.0;

        public MainWindow()
        {
            InitializeComponent();

            PathFigure pathFigure = new PathFigure();
            pathFigure.Segments.Add(new BezierSegment(
                new Point(),
                new Point(),
                new Point(),
                true));
            pathFigure.Segments.Add(new BezierSegment(
                new Point(),
                new Point(),
                new Point(),
                true));

            PathGeometry pathGeometry = new PathGeometry();
            pathGeometry.Figures.Add(pathFigure);

            path.Data = pathGeometry;
        }

        private void WindowLoaded(object sender, RoutedEventArgs e)
        {
            UpdateCurve();
            textBoxAngle1.TextChanged += TextBox1Changed;
            textBoxLength1.TextChanged += TextBox1Changed;
            textBoxAngle2.TextChanged += TextBox2Changed;
            textBoxLength2.TextChanged += TextBox2Changed;
        }

        private void TextBox1Changed(object sender, TextChangedEventArgs e)
        {
            UpdateLine(textBoxAngle1, textBoxLength1, line1);
        }

        private void TextBox2Changed(object sender, TextChangedEventArgs e)
        {
            UpdateLine(textBoxAngle2, textBoxLength2, line2);
        }

        private void UpdateLine(TextBox textBoxAngle, TextBox textBoxLen, Line line)
        {
            double theta, len;
            string txt = textBoxAngle.Text;
            if (!double.TryParse(txt, out theta))
                return;

            txt = textBoxLen.Text;
            if (!double.TryParse(txt, out len))
                return;

            theta = Math.PI * theta / 180.0;
            line.X2 = line.X1 + len * Math.Cos(theta);
            line.Y2 = line.Y1 + len * Math.Sin(theta);

            UpdateCurve();
        }

        private void UpdateCurve()
        {
            Point s1 = new Point(line1.X2, line1.Y2);
            Point e1 = new Point(line1.X1, line1.Y1);
            Point e2 = new Point(line2.X2, line2.Y2);

            double tx1 = s1.X - e1.X;
            double ty1 = s1.Y - e1.Y;
            double d1 = Math.Sqrt(tx1 * tx1 + ty1 * ty1);
            double theta1 = Math.Atan2(ty1, tx1);
            Point c11 = new Point(
                e1.X + d1 * Math.Cos(theta1) * (1.0 - SMOOTH_HINT),
                e1.Y + d1 * Math.Sin(theta1) * (1.0 - SMOOTH_HINT)
            );
            double tx2 = e2.X - e1.X;
            double ty2 = e2.Y - e1.Y;
            double d2 = Math.Sqrt(tx2 * tx2 + ty2 * ty2);
            double theta2 = Math.Atan2(ty2, tx2);
            Point c22 = new Point(
                e1.X + d2 * Math.Cos(theta2) * (1.0 - SMOOTH_HINT),
                e1.Y + d2 * Math.Sin(theta2) * (1.0 - SMOOTH_HINT)
            );
            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;
            }
            Point c12 = new Point(
                e1.X + d1 * Math.Cos(theta1c) * SMOOTH_HINT,
                e1.Y + d1 * Math.Sin(theta1c) * SMOOTH_HINT
            );
            Point c21 = new Point(
                e1.X + d2 * Math.Cos(theta2c) * SMOOTH_HINT,
                e1.Y + d2 * Math.Sin(theta2c) * SMOOTH_HINT
            );

            PathFigure pathFigure = ((PathGeometry)path.Data).Figures[0];
            pathFigure.StartPoint = s1;
            BezierSegment curve1 = (BezierSegment)pathFigure.Segments[0];
            curve1.Point1 = c11;
            curve1.Point2 = c12;
            curve1.Point3 = e1;
            line1c.X2 = c12.X;
            line1c.Y2 = c12.Y;
            BezierSegment curve2 = (BezierSegment)pathFigure.Segments[1];
            curve2.Point1 = c21;
            curve2.Point2 = c22;
            curve2.Point3 = e2;
            line2c.X2 = c21.X;
            line2c.Y2 = c21.Y;
        }
    }
}