午後わてんのブログ

ベランダ菜園とWindows用アプリ作成(WPFとC#)

WPFで矢印直線描画、Shapeクラスを継承して作成してみた

Polylineの始点と終点に三角図形を表示する感じのクラス

結果

直線矢印表示例
指定できるプロパティ

  • Points:各頂点座標、PointCollection
  • HeadBeginType:始点の形、Arrowで矢印▲三角、Noneか* 指定無しで通常の直線
  • HeadEndType:終点の形
  • Angle:矢印三角の角度、既定値は30°

矢印のサイズは線の太さ(StrokeThickness)で変化するようにしたので指定はできない
色の指定はStrokeとFillの両方必要


実行画面


コード

github.com

PolylineZ.cs

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

//WPF Arrow and Custom Shapes - CodeProject
//https://www.codeproject.com/Articles/23116/WPF-Arrow-and-Custom-Shapes

//2022WPF/Arrow.cs at master · gogowaten/2022WPF
//https://github.com/gogowaten/2022WPF/blob/master/20221203_%E7%9F%A2%E5%8D%B0%E5%9B%B3%E5%BD%A2/20221203_%E7%9F%A2%E5%8D%B0%E5%9B%B3%E5%BD%A2/Arrow.cs
//E:\オレ\エクセル\WPFでPixtack紫陽花.xlsm_三角関数_$B$95

namespace _20230220_PolylineZ
{
    public enum HeadType { None = 0, Arrow, }
    public class PolylineZ : Shape
    {
        #region 依存プロパティ

        /// <summary>
        /// 終点のヘッドタイプ
        /// </summary>
        public HeadType HeadEndType
        {
            get { return (HeadType)GetValue(HeadEndTypeProperty); }
            set { SetValue(HeadEndTypeProperty, value); }
        }
        public static readonly DependencyProperty HeadEndTypeProperty =
            DependencyProperty.Register(nameof(HeadEndType), typeof(HeadType), typeof(PolylineZ),
                new FrameworkPropertyMetadata(HeadType.None,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure));
        /// <summary>
        /// 始点のヘッドタイプ
        /// </summary>
        public HeadType HeadBeginType
        {
            get { return (HeadType)GetValue(HeadBeginTypeProperty); }
            set { SetValue(HeadBeginTypeProperty, value); }
        }
        public static readonly DependencyProperty HeadBeginTypeProperty =
            DependencyProperty.Register(nameof(HeadBeginType), typeof(HeadType), typeof(PolylineZ),
                new FrameworkPropertyMetadata(HeadType.None,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure));

        /// <summary>
        /// 矢印角度、初期値は30.0にしている。30~40くらいが適当
        /// </summary>
        public double Angle
        {
            get { return (double)GetValue(AngleProperty); }
            set { SetValue(AngleProperty, value); }
        }
        public static readonly DependencyProperty AngleProperty =
            DependencyProperty.Register(nameof(Angle), typeof(double), typeof(PolylineZ),
                new FrameworkPropertyMetadata(30.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure));

        //[TypeConverter(typeof(MyTypeConverterPoints))]
        //public ObservableCollection<Point> MyPoints
        //{
        //    get { return (ObservableCollection<Point>)GetValue(MyPointsProperty); }
        //    set { SetValue(MyPointsProperty, value); }
        //}
        //public static readonly DependencyProperty MyPointsProperty =
        //    DependencyProperty.Register(nameof(MyPoints), typeof(ObservableCollection<Point>), typeof(PolylineZ),
        //        new FrameworkPropertyMetadata(new ObservableCollection<Point>() { new Point(0, 0), new Point(100, 100) },
        //            FrameworkPropertyMetadataOptions.AffectsRender |
        //            FrameworkPropertyMetadataOptions.AffectsMeasure));
        public PointCollection MyPoints
        {
            get { return (PointCollection)GetValue(MyPointsProperty); }
            set { SetValue(MyPointsProperty, value); }
        }

        public static readonly DependencyProperty MyPointsProperty =
            DependencyProperty.Register(nameof(MyPoints), typeof(PointCollection), typeof(PolylineZ),
                new FrameworkPropertyMetadata(new PointCollection() { new Point(0, 0), new Point(100, 100) },
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure));

        #endregion 依存プロパティ


        private static double DegreeToRadian(double degree)
        {
            return degree / 360.0 * (Math.PI * 2.0);
        }

        /// <summary>
        /// 直線の描画
        /// </summary>
        /// <param name="context"></param>
        /// <param name="begin"></param>
        /// <param name="end"></param>
        private void DrawLine(StreamGeometryContext context, Point begin, Point end)
        {
            context.BeginFigure(begin, false, false);
            for (int i = 1; i < MyPoints.Count - 1; i++)
            {
                context.LineTo(MyPoints[i], true, false);
            }
            context.LineTo(end, true, false);
        }

        protected override Geometry DefiningGeometry
        {
            get
            {
                StreamGeometry geometry = new() { FillRule = FillRule.Nonzero };
                using (var context = geometry.Open())
                {
                    Point begin = MyPoints[0];
                    Point end = MyPoints[^1];
                    switch (HeadBeginType)
                    {
                        case HeadType.None:
                            break;
                        case HeadType.Arrow:
                            begin = DrawBeginArrow(context);
                            break;
                    }
                    switch (HeadEndType)
                    {
                        case HeadType.None:
                            break;
                        case HeadType.Arrow:
                            end = DrawEndArrow(context);
                            break;
                    }
                    DrawLine(context, begin, end);
                }
                geometry.Freeze();
                return geometry;
            }
        }

        /// <summary>
        /// 始点にアローヘッド描画
        /// </summary>
        /// <param name="context"></param>
        /// <returns>アローヘッドと直線との接点座標</returns>
        private Point DrawBeginArrow(StreamGeometryContext context)
        {
            double x0 = MyPoints[0].X;
            double y0 = MyPoints[0].Y;
            double x1 = MyPoints[1].X;
            double y1 = MyPoints[1].Y;

            double lineRadian = Math.Atan2(y1 - y0, x1 - x0);
            double arrowRadian = DegreeToRadian(Angle);
            double headSize = StrokeThickness * 2.0;
            double wingLength = headSize / Math.Cos(arrowRadian);

            double wingRadian1 = lineRadian + arrowRadian;
            Point arrowP1 = new(
                wingLength * Math.Cos(wingRadian1) + x0,
                wingLength * Math.Sin(wingRadian1) + y0);
            double wingRadian2 = lineRadian - DegreeToRadian(Angle);
            Point arrowP2 = new(
                wingLength * Math.Cos(wingRadian2) + x0,
                wingLength * Math.Sin(wingRadian2) + y0);
            //アローヘッド描画、Fill(塗りつぶし)で描画
            context.BeginFigure(MyPoints[0], true, false);//fill, close
            context.LineTo(arrowP1, false, false);//isStroke, isSmoothJoin
            context.LineTo(arrowP2, false, false);

            //アローヘッドと直線との接点座標、
            //HeadSizeぴったりで計算すると僅かな隙間ができるので-1.0している、
            //-0.5でも隙間になる、-0.7で隙間なくなる
            return new Point(
                (headSize - 1.0) * Math.Cos(lineRadian) + x0,
                (headSize - 1.0) * Math.Sin(lineRadian) + y0);

        }

        /// <summary>
        /// 終点にアローヘッド描画
        /// </summary>
        /// <param name="context"></param>
        /// <returns>アローヘッドと直線との接点座標</returns>
        private Point DrawEndArrow(StreamGeometryContext context)
        {
            double x0 = MyPoints[^1].X;
            double x1 = MyPoints[^2].X;
            double y0 = MyPoints[^1].Y;
            double y1 = MyPoints[^2].Y;

            double lineRadian = Math.Atan2(y1 - y0, x1 - x0);
            double arrowRadian = DegreeToRadian(Angle);
            double headSize = StrokeThickness * 2.0;
            double wingLength = headSize / Math.Cos(arrowRadian);

            double wingRadian1 = lineRadian + arrowRadian;
            Point arrowP1 = new(
                wingLength * Math.Cos(wingRadian1) + x0,
                wingLength * Math.Sin(wingRadian1) + y0);
            double wingRadian2 = lineRadian - DegreeToRadian(Angle);
            Point arrowP2 = new(
                wingLength * Math.Cos(wingRadian2) + x0,
                wingLength * Math.Sin(wingRadian2) + y0);
            //アローヘッド描画、Fill(塗りつぶし)で描画
            context.BeginFigure(MyPoints[^1], true, false);//fill, close
            context.LineTo(arrowP1, false, false);//isStroke, isSmoothJoin
            context.LineTo(arrowP2, false, false);

            return new Point(
                (headSize - 1.0) * Math.Cos(lineRadian) + x0,
                (headSize - 1.0) * Math.Sin(lineRadian) + y0);
        }

    }
}



三角形の頂点座標を計算

三角関数を使う
エクセルならCOS、SIN、ATAN2
.NETならMathクラスのCos、Sin、Atan2
引数は角度じゃなくてラジアンなので角度をラジアンに変換する関数
エクセルならRADIANS関数
.NETにはないので適当に

private static double DegreeToRadian(double degree)
{
    return degree / 360.0 * (Math.PI * 2.0);
}

矢印
条件

  • Aが始点で、Bに終点座標を指定
  • BQの長さが三角形の高さ指定だけど、今回は20で固定にする
  • 三角形の角度指定は∠PBAのことにして、30°で固定

以上の条件のとき、PとP2の座標が取得できれば三角図形を描画できる

BPの長さは?

BPの長さ
わかっているのは三角形角度が30°とBQの長さ(三角形の高さ)が20ってこと
で、Bを中心にBPを半径だとすると、BQはBPのCOSに相当するので
BP * COS(30°) = 20
BP = 20 / COS(30°)
COSにわたす引数は実際には角度じゃなくてラジアン
これでBPの長さが取得できる

Pのx座標
これもBを中心にBPを半径でみて、BPの角度(ラジアン)のCOSで取得できた
Pのx座標 = BPの長さ * COS(BP角度) + BX(BのX座標)
y座標はSINにするだけ
Pのy座標 = BPの長さ * SIN(BP角度) + BY(BのY座標)

BPの角度(ラジアン)は?
BPの角度 = BAの角度 + 三角形角度

BAの角度は?
ATAN2っていう関数があるのでこれで
エクセルのATAN2関数
BAの角度(ラジアン) = ATAN2(BのX座標 - AのX座標, BのY座標 - AのY座標)
.NETのMath.Atan2関数
BAの角度(ラジアン) = Math.Atan2(AのY座標 - BのY座標, AのX座標 - BのX座標)
引数の順番が違う…xyの順番が逆、y座標の正負が逆

エクセルで座標計算

P2はPの反対側の角度なので、BAの角度から三角形角度を引き算して求めることができた
これで3つの頂点B、P、P2が取得できたので、矢印三角が描画できるようになった

直線部分の描画に使う座標Q
三角形部分は塗りつぶしで描画するけど、直線部分はStrokeで描画する。Strokeは太さをStrokeThicknessで指定できるのが便利だけど、そのままAからBまでを描画すると、太さの分がBの三角形からはみ出してしまう

点Qまで直線で描画
点Bまで直線で描画
なので直線の終端は三角形の底辺に接する座標にしたい、それが点Q、つまりAからQまでを直線で描画
Qの座標は、またBを中心に見て、BQが半径とするとBAの角度のCOS、SINで求めることができた
以上ですべての座標が揃った

描画してみると

隙間ができる
水平か垂直な直線なら問題ないけど、斜めになると三角形と直線の間に隙間ができてしまう
4倍拡大
じゃあ直線を1ピクセル延長しよう
右が直線を延長したもの
延長してもかすかにつなぎ目が見えるけど、これなら問題ない。2ピクセルとか延長すれば完全に消えるだろうけど、そうすると線を細くしたときに直線部分が三角形から突き出してしまうので、今回は1ピクセル延長にしてみた
1ピクセル延長
210と211行目の-1.0がそれ
headSizeが三角形の高さなので、これを短くして計算すれば点Qが三角形にめり込む形になる


三角形はFill、直線部分はStrokeなので

三角形と直線で別の色指定


参照したところ

WPF Arrow and Custom Shapes - CodeProject

www.codeproject.com
今回のはここからのパク…応用
とくにShapeクラスを継承して色々描画できるんだ!ってわかったのが良かった


感想

相変わらず三角関数がわからん、今回のも期待する結果は出せているけど、もっと楽な方法がある気がする

5年前…だと…!?
WPFで矢印線、直線(PolyLine)と矢印(Polygon)を組み合わせて表現 - 午後わてんのブログ
gogowaten.hatenablog.com
今回のがこのときと違うのはデザイナー画面で使えるってとこかなあ
それにしても同じようなことばっかりしてるなw
あとはこれをベジェ曲線でも作って、今作っているPixtack紫陽花3に取り入れるのが目標なんだけど、時間かかりそう

関連記事

次回

gogowaten.hatenablog.com