午後わてんのブログ

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

WPF、図形を中心で回転、PathGeometry+Canvas+Thumbのカスタムコントロール

今回 = 前回 + 前々回
WPF、中のTextBlock(子要素)を回転させたときに、自身(親要素)のサイズと位置を違和感なく変更できた - 午後わてんのブログ
WPF、簡単に折れ線描画できて見た目通りのサイズと位置が取得できるクラスをShapeクラス継承で - 午後わてんのブログ

結果

ユーチューブ
youtu.be

GIFアニメーション

Animation20250206_112932.gif
マウスドラッグ移動できて、図形を回転したときに図形を中心に回転するカスタムコントロール
コントロールの要素の構成は

  • Thumb
    • Canvas(Templateに使用)
      • EzLine(Geometry図形)

回転処理はEzLineに対して行って、そこで得られた回転後のEzLineのサイズと位置を元に、EzLineは位置の調整(オフセット)、Thumbはサイズと位置の両方を調整している

無調整の場合

Thumbの位置だけ無調整

Thumbの位置だけ無調整

EzLineの位置だけ無調整

EzLineの位置だけ無調整

両方の位置無調整

両方の位置を無調整




テストアプリのコード

2025WPF/20250206_RotateEzLineThumb at main · gogowaten/2025WPF
github.com

環境




EzLine.sc

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

namespace _20250206_RotateEzLineThumb
{
    public class EzLine : Shape
    {

        protected override Geometry DefiningGeometry
        {
            get
            {
                if (MyPoints == null || MyPoints.Count == 0) { return Geometry.Empty; }
                StreamGeometry geo = new() { FillRule = MyFillRule };
                using (var context = geo.Open())
                {
                    context.BeginFigure(MyPoints[0], MyIsFilled, MyIsClosed);
                    context.PolyLineTo(MySegmentPoints, MyIsStroked, MyIsSmoothJoin);
                }
                geo.Freeze();

                //Boundsの更新はここで行う必要がある。OnRenderではなんか違う
                MyBounds1 = geo.Bounds;
                MyBounds2 = geo.GetRenderBounds(MyPen);
                //回転後のBounds
                var clone = geo.Clone();
                clone.Transform = RenderTransform;
                MyBounds3 = clone.Bounds;
                MyBounds4 = clone.GetRenderBounds(MyPen);

                return geo;
            }
        }

        public EzLine()
        {
            DataContext = this;
            SetBinding(MySegmentPointsProperty, new Binding() { Source = this, Path = new PropertyPath(MyPointsProperty), Mode = BindingMode.OneWay, Converter = new MyConverterSegmentPoints() });

            MyPenBind();
            MyRenderTransformBind();
            MyOffsetBind();
        }

        //オフセット移動値のバインド
        private void MyOffsetBind()
        {
            MultiBinding mb = new() { Converter = new MyConverterIsOffsetX() };
            mb.Bindings.Add(MakeOneWayBind(MyIsOffsetProperty));
            mb.Bindings.Add(MakeOneWayBind(MyBounds4Property));
            SetBinding(Canvas.LeftProperty, mb);

            mb = new() { Converter = new MyConverterIsOffsetY() };
            mb.Bindings.Add(MakeOneWayBind(MyIsOffsetProperty));
            mb.Bindings.Add(MakeOneWayBind(MyBounds4Property));
            SetBinding(Canvas.TopProperty, mb);
        }

        //Penのバインド、Penは図形のBoundsを計測するために必要
        private void MyPenBind()
        {
            MultiBinding mb = new() { Converter = new MyConverterPen() };
            mb.Bindings.Add(MakeOneWayBind(StrokeThicknessProperty));
            mb.Bindings.Add(MakeOneWayBind(StrokeMiterLimitProperty));
            mb.Bindings.Add(MakeOneWayBind(StrokeEndLineCapProperty));
            mb.Bindings.Add(MakeOneWayBind(StrokeStartLineCapProperty));
            mb.Bindings.Add(MakeOneWayBind(StrokeLineJoinProperty));
            SetBinding(MyPenProperty, mb);
        }

        //RenderTransformとのバインド、これでMyAngle変更時にもBoundsが更新される
        private void MyRenderTransformBind()
        {
            //中心軸座標、Penでの描画Boundsの中心にしているけど、それ以外にも試したい
            //三角形なら重心、四角形ならそのまま、それ以上なら平均座標とか
            SetBinding(MyCenterXProperty, new Binding() { Source = this, Path = new PropertyPath(MyBounds2Property), Converter = new MyConverterCenterX(), Mode = BindingMode.OneWay });
            SetBinding(MyCenterYProperty, new Binding() { Source = this, Path = new PropertyPath(MyBounds2Property), Converter = new MyConverterCenterY(), Mode = BindingMode.OneWay });

            //RenderTransformとのバインド、RenderTransformはRotateTransformに決め打ちしている
            MultiBinding mb = new() { Converter = new MyConverterRenderTransform() };
            mb.Bindings.Add(MakeOneWayBind(MyAngleProperty));
            mb.Bindings.Add(MakeOneWayBind(MyCenterXProperty));
            mb.Bindings.Add(MakeOneWayBind(MyCenterYProperty));
            SetBinding(RenderTransformProperty, mb);
        }

        private Binding MakeOneWayBind(DependencyProperty property)
        {
            return new Binding() { Source = this, Path = new PropertyPath(property), Mode = BindingMode.OneWay };
        }

        #region 依存関係プロパティ

        //オフセット移動フラグ
        //回転時にMyBounds4分を移動するかどうか
        //trueで移動、falseで処理無し
        public bool MyIsOffset
        {
            get { return (bool)GetValue(MyIsOffsetProperty); }
            set { SetValue(MyIsOffsetProperty, value); }
        }
        public static readonly DependencyProperty MyIsOffsetProperty =
            DependencyProperty.Register(nameof(MyIsOffset), typeof(bool), typeof(EzLine),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        #region 通常

        #region 回転

        public double MyAngle
        {
            get { return (double)GetValue(MyAngleProperty); }
            set { SetValue(MyAngleProperty, value); }
        }
        public static readonly DependencyProperty MyAngleProperty =
            DependencyProperty.Register(nameof(MyAngle), typeof(double), typeof(EzLine),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public double MyCenterX
        {
            get { return (double)GetValue(MyCenterXProperty); }
            set { SetValue(MyCenterXProperty, value); }
        }
        public static readonly DependencyProperty MyCenterXProperty =
            DependencyProperty.Register(nameof(MyCenterX), typeof(double), typeof(EzLine),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public double MyCenterY
        {
            get { return (double)GetValue(MyCenterYProperty); }
            set { SetValue(MyCenterYProperty, value); }
        }
        public static readonly DependencyProperty MyCenterYProperty =
            DependencyProperty.Register(nameof(MyCenterY), typeof(double), typeof(EzLine),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        #endregion 回転

        public FillRule MyFillRule
        {
            get { return (FillRule)GetValue(MyFillRuleProperty); }
            set { SetValue(MyFillRuleProperty, value); }
        }
        public static readonly DependencyProperty MyFillRuleProperty =
            DependencyProperty.Register(nameof(MyFillRule), typeof(FillRule), typeof(EzLine),
                new FrameworkPropertyMetadata(FillRule.EvenOdd,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public bool MyIsSmoothJoin
        {
            get { return (bool)GetValue(MyIsSmoothJoinProperty); }
            set { SetValue(MyIsSmoothJoinProperty, value); }
        }
        public static readonly DependencyProperty MyIsSmoothJoinProperty =
            DependencyProperty.Register(nameof(MyIsSmoothJoin), typeof(bool), typeof(EzLine),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public bool MyIsStroked
        {
            get { return (bool)GetValue(MyIsStrokedProperty); }
            set { SetValue(MyIsStrokedProperty, value); }
        }
        public static readonly DependencyProperty MyIsStrokedProperty =
            DependencyProperty.Register(nameof(MyIsStroked), typeof(bool), typeof(EzLine),
                new FrameworkPropertyMetadata(true,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public PointCollection MyPoints
        {
            get { return (PointCollection)GetValue(MyPointsProperty); }
            set { SetValue(MyPointsProperty, value); }
        }
        public static readonly DependencyProperty MyPointsProperty =
            DependencyProperty.Register(nameof(MyPoints), typeof(PointCollection), typeof(EzLine),
                new FrameworkPropertyMetadata(null,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public bool MyIsFilled
        {
            get { return (bool)GetValue(MyIsFilledProperty); }
            set { SetValue(MyIsFilledProperty, value); }
        }
        public static readonly DependencyProperty MyIsFilledProperty =
            DependencyProperty.Register(nameof(MyIsFilled), typeof(bool), typeof(EzLine),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public bool MyIsClosed
        {
            get { return (bool)GetValue(MyIsClosedProperty); }
            set { SetValue(MyIsClosedProperty, value); }
        }
        public static readonly DependencyProperty MyIsClosedProperty =
            DependencyProperty.Register(nameof(MyIsClosed), typeof(bool), typeof(EzLine),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        #endregion 通常

        #region Private set

        //変形後の線描画のBounds
        //TFGeo.GetRenderBounds(MyPen);
        public Rect MyBounds4
        {
            get { return (Rect)GetValue(MyBounds4Property); }
            private set { SetValue(MyBounds4Property, value); }
        }
        public static readonly DependencyProperty MyBounds4Property =
            DependencyProperty.Register(nameof(MyBounds4), typeof(Rect), typeof(EzLine), new PropertyMetadata(new Rect()));

        //変形後のGeometryBounds
        //TFGeo.Bounds;
        public Rect MyBounds3
        {
            get { return (Rect)GetValue(MyBounds3Property); }
            private set { SetValue(MyBounds3Property, value); }
        }
        public static readonly DependencyProperty MyBounds3Property =
            DependencyProperty.Register(nameof(MyBounds3), typeof(Rect), typeof(EzLine), new PropertyMetadata(new Rect()));


        //線描画のBounds
        //GetRenderBounds(MyPen);
        public Rect MyBounds2
        {
            get { return (Rect)GetValue(MyBounds2Property); }
            private set { SetValue(MyBounds2Property, value); }
        }
        public static readonly DependencyProperty MyBounds2Property =
            DependencyProperty.Register(nameof(MyBounds2), typeof(Rect), typeof(EzLine), new PropertyMetadata(new Rect()));

        //GeometryBounds
        public Rect MyBounds1
        {
            get { return (Rect)GetValue(MyBounds1Property); }
            private set { SetValue(MyBounds1Property, value); }
        }
        public static readonly DependencyProperty MyBounds1Property =
            DependencyProperty.Register(nameof(MyBounds1), typeof(Rect), typeof(EzLine), new PropertyMetadata(new Rect()));

        public Pen MyPen
        {
            get { return (Pen)GetValue(MyPenProperty); }
            private set { SetValue(MyPenProperty, value); }
        }
        public static readonly DependencyProperty MyPenProperty =
            DependencyProperty.Register(nameof(MyPen), typeof(Pen), typeof(EzLine), new PropertyMetadata(new Pen()));

        public PointCollection MySegmentPoints
        {
            get { return (PointCollection)GetValue(MySegmentPointsProperty); }
            private set { SetValue(MySegmentPointsProperty, value); }
        }


        public static readonly DependencyProperty MySegmentPointsProperty =
            DependencyProperty.Register(nameof(MySegmentPoints), typeof(PointCollection), typeof(EzLine), new PropertyMetadata(null));

        #endregion Private set

        #endregion 依存関係プロパティ



    }




    #region コンバーター

    public class MyConverterIsOffsetY : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var offset = (bool)values[0];
            var bounds = (Rect)values[1];
            if (offset) { return -bounds.Y; }
            else { return 0.0; }
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }


    public class MyConverterIsOffsetX : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var offset = (bool)values[0];
            var bounds = (Rect)values[1];
            if (offset) { return -bounds.X; }
            else { return 0.0; }
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    //回転軸のY座標、見た目通りの矩形(Bounds2)の中央にしている
    public class MyConverterCenterY : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var r = (Rect)value;
            return (r.Y * 2 + r.Height) / 2.0;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
    public class MyConverterCenterX : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var r = (Rect)value;
            return (r.X * 2 + r.Width) / 2.0;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    //RenderTransformはRotateTransformに決め打ちしている
    public class MyConverterRenderTransform : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var angle = (double)values[0];
            var x = (double)values[1];
            var y = (double)values[2];
            return new RotateTransform(angle, x, y);
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    //Penの生成、各種プロパティも反映
    public class MyConverterPen : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var thick = (double)values[0];
            var miter = (double)values[1];
            var end = (PenLineCap)values[2];
            var sta = (PenLineCap)values[3];
            var join = (PenLineJoin)values[4];
            Pen result = new(Brushes.Transparent, thick)
            {
                EndLineCap = end,
                StartLineCap = sta,
                LineJoin = join,
                MiterLimit = miter
            };
            return result;
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    //Segment用のPointCollection生成
    //ソースに影響を与えないためにクローン作成して、その先頭要素を削除して返す
    public class MyConverterSegmentPoints : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is PointCollection pc && pc.Count > 0)
            {
                var clone = pc.Clone();
                clone.RemoveAt(0);
                return clone;
            }
            else
            {
                return new PointCollection();
            }
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
    #endregion コンバーター


}

前回のClass1.csからのコピペに追加したところが

オフセット移動するかどうかのフラグ用プロパティ

オフセット移動フラグ

オフセット位置とのバインド設定

オフセット位置とのバインド設定

フラグとMyBounds4からの変換になるので、そのコンバーター

コンバーター



Generic.xaml

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:_20250206_RotateEzLineThumb">



  <Style TargetType="{x:Type local:KisoThumb}" x:Key="kiso">
    <Setter Property="Canvas.Left" Value="{Binding RelativeSource={RelativeSource Mode=Self},Path=MyLeft}"/>
    <Setter Property="Canvas.Top" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=MyTop}"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:KisoThumb}">
          <Canvas x:Name="PART_Canvas" Background="{TemplateBinding Background}">
            <Rectangle/>
          </Canvas>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>


  <Style TargetType="{x:Type local:KisoThumb}" BasedOn="{StaticResource kiso}">

  </Style>


  <Style TargetType="{x:Type local:EzLineThumb}" BasedOn="{StaticResource kiso}">

    <Setter Property="Canvas.Left" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=MyOffsetLeft}"/>
    <Setter Property="Canvas.Top" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Path=MyOffsetTop}"/>

    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:EzLineThumb}">
          <Canvas x:Name="PART_Canvas" Background="{TemplateBinding Background}"
                  Width="{Binding ElementName=ez, Path=MyBounds4.Width}"
                  Height="{Binding ElementName=ez, Path=MyBounds4.Height}"
                  >
            <local:EzLine
                x:Name="ez"
                MyIsOffset="True"
                MyPoints="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyPoints}"
                Stroke="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStroke}"
                StrokeThickness="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeThickness}"
                MyAngle="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyAngle}"
                Fill="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyFill}"
                MyIsFilled="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyIsFilled}"
                MyIsStroked="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyIsStroked}"
                MyIsClosed="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyIsClosed}"
                MyIsSmoothJoin="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyIsSmoothJoin}"
                MyFillRule="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyFillRule}"
                StrokeDashArray="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeDashArray}"
                StrokeDashCap="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeDashCap}"
                StrokeDashOffset="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeDashOffset}"
                StrokeEndLineCap="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeEndLineCap}"
                StrokeStartLineCap="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeStartLineCap}"
                StrokeMiterLimit="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeMiterLimit}"
                StrokeLineJoin="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=MyStrokeLineJoin}">
            </local:EzLine>
          </Canvas>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>


</ResourceDictionary>

肝心なところはここだけ

位置の調整



CustomControl1.cs

using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;

namespace _20250206_RotateEzLineThumb
{

    public class KisoThumb : Thumb
    {
        static KisoThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(KisoThumb), new FrameworkPropertyMetadata(typeof(KisoThumb)));
        }
        public KisoThumb()
        {
            DataContext = this;
            DragDelta += KisoThumb_DragDelta;
        }

        private void KisoThumb_DragDelta(object sender, DragDeltaEventArgs e)
        {
            if (sender is KisoThumb kiso)
            {
                kiso.MyLeft += e.HorizontalChange;
                kiso.MyTop += e.VerticalChange;
                e.Handled = true;
            }
        }

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            if (GetTemplateChild("PART_Canvas") is Canvas panel)
            {
                MyBaseCanvas = panel;
            }
        }

        #region 依存関係プロパティ

        #region 共通

        //テキスト系要素のText要素
        public string MyText
        {
            get { return (string)GetValue(MyTextProperty); }
            set { SetValue(MyTextProperty, value); }
        }
        public static readonly DependencyProperty MyTextProperty =
            DependencyProperty.Register(nameof(MyText), typeof(string), typeof(KisoThumb),
                new FrameworkPropertyMetadata(string.Empty,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        //コンテンツの回転角度
        public double MyAngle
        {
            get { return (double)GetValue(MyAngleProperty); }
            set { SetValue(MyAngleProperty, value); }
        }
        public static readonly DependencyProperty MyAngleProperty =
            DependencyProperty.Register(nameof(MyAngle), typeof(double), typeof(KisoThumb),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));



        public Canvas MyBaseCanvas
        {
            get { return (Canvas)GetValue(MyBaseCanvasProperty); }
            private set { SetValue(MyBaseCanvasProperty, value); }
        }
        public static readonly DependencyProperty MyBaseCanvasProperty =
            DependencyProperty.Register(nameof(MyBaseCanvas), typeof(Canvas), typeof(KisoThumb), new PropertyMetadata(null));


        public double MyLeft
        {
            get { return (double)GetValue(MyLeftProperty); }
            set { SetValue(MyLeftProperty, value); }
        }
        public static readonly DependencyProperty MyLeftProperty =
            DependencyProperty.Register(nameof(MyLeft), typeof(double), typeof(KisoThumb),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        public double MyTop
        {
            get { return (double)GetValue(MyTopProperty); }
            set { SetValue(MyTopProperty, value); }
        }
        public static readonly DependencyProperty MyTopProperty =
            DependencyProperty.Register(nameof(MyTop), typeof(double), typeof(KisoThumb),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public double MyOffsetLeft
        {
            get { return (double)GetValue(MyOffsetLeftProperty); }
            internal set { SetValue(MyOffsetLeftProperty, value); }
        }
        public static readonly DependencyProperty MyOffsetLeftProperty =
            DependencyProperty.Register(nameof(MyOffsetLeft), typeof(double), typeof(KisoThumb), new PropertyMetadata(0.0));

        public double MyOffsetTop
        {
            get { return (double)GetValue(MyOffsetTopProperty); }
            internal set { SetValue(MyOffsetTopProperty, value); }
        }
        public static readonly DependencyProperty MyOffsetTopProperty =
            DependencyProperty.Register(nameof(MyOffsetTop), typeof(double), typeof(KisoThumb), new PropertyMetadata(0.0));


        #endregion 共通

        #region 図形関連
        #region 図形基本

        public PointCollection MyPoints
        {
            get { return (PointCollection)GetValue(MyPointsProperty); }
            set { SetValue(MyPointsProperty, value); }
        }
        public static readonly DependencyProperty MyPointsProperty =
            DependencyProperty.Register(nameof(MyPoints), typeof(PointCollection), typeof(KisoThumb),
                new FrameworkPropertyMetadata(null,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public double MyStrokeThickness
        {
            get { return (double)GetValue(MyStrokeThicknessProperty); }
            set { SetValue(MyStrokeThicknessProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeThicknessProperty =
            DependencyProperty.Register(nameof(MyStrokeThickness), typeof(double), typeof(KisoThumb), new FrameworkPropertyMetadata(1.0,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));

        public Brush MyStroke
        {
            get { return (Brush)GetValue(MyStrokeProperty); }
            set { SetValue(MyStrokeProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeProperty =
            DependencyProperty.Register(nameof(MyStroke), typeof(Brush), typeof(KisoThumb), new FrameworkPropertyMetadata(Brushes.Magenta,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));


        public Brush MyFill
        {
            get { return (Brush)GetValue(MyFillProperty); }
            set { SetValue(MyFillProperty, value); }
        }
        public static readonly DependencyProperty MyFillProperty =
            DependencyProperty.Register(nameof(MyFill), typeof(Brush), typeof(KisoThumb), new FrameworkPropertyMetadata(Brushes.Pink,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));


        public bool MyIsFilled
        {
            get { return (bool)GetValue(MyIsFilledProperty); }
            set { SetValue(MyIsFilledProperty, value); }
        }
        public static readonly DependencyProperty MyIsFilledProperty =
            DependencyProperty.Register(nameof(MyIsFilled), typeof(bool), typeof(KisoThumb), new FrameworkPropertyMetadata(true,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));

        public bool MyIsStroked
        {
            get { return (bool)GetValue(MyIsStrokedProperty); }
            set { SetValue(MyIsStrokedProperty, value); }
        }
        public static readonly DependencyProperty MyIsStrokedProperty =
            DependencyProperty.Register(nameof(MyIsStroked), typeof(bool), typeof(KisoThumb), new FrameworkPropertyMetadata(true,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));


        public bool MyIsClosed
        {
            get { return (bool)GetValue(MyIsClosedProperty); }
            set { SetValue(MyIsClosedProperty, value); }
        }
        public static readonly DependencyProperty MyIsClosedProperty =
            DependencyProperty.Register(nameof(MyIsClosed), typeof(bool), typeof(KisoThumb), new FrameworkPropertyMetadata(false,
                FrameworkPropertyMetadataOptions.AffectsRender |
                FrameworkPropertyMetadataOptions.AffectsMeasure));

        public bool MyIsSmoothJoin
        {
            get { return (bool)GetValue(MyIsSmoothJoinProperty); }
            set { SetValue(MyIsSmoothJoinProperty, value); }
        }
        public static readonly DependencyProperty MyIsSmoothJoinProperty =
            DependencyProperty.Register(nameof(MyIsSmoothJoin), typeof(bool), typeof(KisoThumb),
                new FrameworkPropertyMetadata(false,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public FillRule MyFillRule
        {
            get { return (FillRule)GetValue(MyFillRuleProperty); }
            set { SetValue(MyFillRuleProperty, value); }
        }
        public static readonly DependencyProperty MyFillRuleProperty =
            DependencyProperty.Register(nameof(MyFillRule), typeof(FillRule), typeof(KisoThumb),
                new FrameworkPropertyMetadata(FillRule.Nonzero,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public PenLineJoin MyStrokeLineJoin
        {
            get { return (PenLineJoin)GetValue(MyStrokeLineJoinProperty); }
            set { SetValue(MyStrokeLineJoinProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeLineJoinProperty =
            DependencyProperty.Register(nameof(MyStrokeLineJoin), typeof(PenLineJoin), typeof(KisoThumb),
                new FrameworkPropertyMetadata(PenLineJoin.Miter,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        #endregion 図形基本

        #region 図形細部

        public DoubleCollection MyStrokeDashArray
        {
            get { return (DoubleCollection)GetValue(MyStrokeDashArrayProperty); }
            set { SetValue(MyStrokeDashArrayProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeDashArrayProperty =
            DependencyProperty.Register(nameof(MyStrokeDashArray), typeof(DoubleCollection), typeof(KisoThumb),
                new FrameworkPropertyMetadata(null,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public PenLineCap MyStrokeDashCap
        {
            get { return (PenLineCap)GetValue(MyStrokeDashCapProperty); }
            set { SetValue(MyStrokeDashCapProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeDashCapProperty =
            DependencyProperty.Register(nameof(MyStrokeDashCap), typeof(PenLineCap), typeof(KisoThumb),
                new FrameworkPropertyMetadata(PenLineCap.Flat,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public double MyStrokeDashOffset
        {
            get { return (double)GetValue(MyStrokeDashOffsetProperty); }
            set { SetValue(MyStrokeDashOffsetProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeDashOffsetProperty =
            DependencyProperty.Register(nameof(MyStrokeDashOffset), typeof(double), typeof(KisoThumb),
                new FrameworkPropertyMetadata(0.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public PenLineCap MyStrokeEndLineCap
        {
            get { return (PenLineCap)GetValue(MyStrokeEndLineCapProperty); }
            set { SetValue(MyStrokeEndLineCapProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeEndLineCapProperty =
            DependencyProperty.Register(nameof(MyStrokeEndLineCap), typeof(PenLineCap), typeof(KisoThumb),
                new FrameworkPropertyMetadata(PenLineCap.Flat,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public PenLineCap MyStrokeStartLineCap
        {
            get { return (PenLineCap)GetValue(MyStrokeStartLineCapProperty); }
            set { SetValue(MyStrokeStartLineCapProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeStartLineCapProperty =
            DependencyProperty.Register(nameof(MyStrokeStartLineCap), typeof(PenLineCap), typeof(KisoThumb),
                new FrameworkPropertyMetadata(PenLineCap.Flat,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));


        public double MyStrokeMiterLimit
        {
            get { return (double)GetValue(MyStrokeMiterLimitProperty); }
            set { SetValue(MyStrokeMiterLimitProperty, value); }
        }
        public static readonly DependencyProperty MyStrokeMiterLimitProperty =
            DependencyProperty.Register(nameof(MyStrokeMiterLimit), typeof(double), typeof(KisoThumb),
                new FrameworkPropertyMetadata(10.0,
                    FrameworkPropertyMetadataOptions.AffectsRender |
                    FrameworkPropertyMetadataOptions.AffectsMeasure |
                    FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

        #endregion 図形細部
        #endregion 図形関連

        #region 特殊

        //public Rect MyContentBounds
        //{
        //    get { return (Rect)GetValue(MyContentBoundsProperty); }
        //    set { SetValue(MyContentBoundsProperty, value); }
        //}
        //public static readonly DependencyProperty MyContentBoundsProperty =
        //    DependencyProperty.Register(nameof(MyContentBounds), typeof(Rect), typeof(KisoThumb), new PropertyMetadata(new Rect()));

        #endregion 特殊
        #endregion 依存関係プロパティ

    }


    public class EzLineThumb : KisoThumb
    {
        static EzLineThumb()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(EzLineThumb), new FrameworkPropertyMetadata(typeof(EzLineThumb)));
        }
        public EzLineThumb()
        {

        }

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            //テンプレートの中からEzLineを取得してバインド設定
            if (GetTemplateChild("ez") is EzLine ez)
            {
                MyEzLine = ez;

                //オフセット位置のバインド
                //このタイミングじゃないとバインドできないし、XAMLでもできない。
                //ソースにEzLineを使っているから、取得後じゃないとできないみたい
                MultiBinding mb = new() { Converter = new MyConverterOffsetX() };
                mb.Bindings.Add(MakeOneWayBind(MyLeftProperty));
                mb.Bindings.Add(new Binding() { Source = MyEzLine, Path = new PropertyPath(EzLine.MyBounds4Property), Mode = BindingMode.OneWay });
                SetBinding(MyOffsetLeftProperty, mb);

                mb = new() { Converter = new MyConverterOffsetY() };
                mb.Bindings.Add(MakeOneWayBind(MyTopProperty));
                mb.Bindings.Add(new Binding() { Source = MyEzLine, Path = new PropertyPath(EzLine.MyBounds4Property), Mode = BindingMode.OneWay });
                SetBinding(MyOffsetTopProperty, mb);
            }
        }

        /// <summary>
        /// Binding生成。ソースはthis固定、ModeはOneWay固定
        /// </summary>
        /// <param name="property"></param>
        /// <returns></returns>
        private Binding MakeOneWayBind(DependencyProperty property)
        {
            return new Binding() { Source = this, Path = new PropertyPath(property), Mode = BindingMode.OneWay };
        }


        public EzLine MyEzLine
        {
            get { return (EzLine)GetValue(MyEzLineProperty); }
            private set { SetValue(MyEzLineProperty, value); }
        }
        public static readonly DependencyProperty MyEzLineProperty =
            DependencyProperty.Register(nameof(MyEzLine), typeof(EzLine), typeof(EzLineThumb), new PropertyMetadata(null));

    }

    //指定表示座標とコンテンツの変形後のRectから、自身の実際のY座標を返す
    public class MyConverterOffsetY : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var y = (double)values[0];
            var r = (Rect)values[1];
            return y + r.Top;
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

    //指定表示座標とコンテンツの変形後のRectから、自身の実際のX座標を返す
    public class MyConverterOffsetX : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var x = (double)values[0];
            var r = (Rect)values[1];
            return x + r.Left;
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

KisoThumbには全てのプロパティを持たせているので、その宣言が長いだけ




MainWindow.xaml

<Window x:Class="_20250206_RotateEzLineThumb.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:_20250206_RotateEzLineThumb"
        mc:Ignorable="d"
        Title="MainWindow" Height="367" Width="602">
  <Window.Resources>
    <Storyboard x:Key="anime">
      <DoubleAnimation Storyboard.TargetName="MyEz"
                       Storyboard.TargetProperty="MyAngle"
                       From="0" To="360"
                       Duration="0:0:4" RepeatBehavior="0:0:4"
                       FillBehavior="Stop"/>
    </Storyboard>
  </Window.Resources>
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition Width="250"/>
    </Grid.ColumnDefinitions>
    <Canvas>
      <local:EzLineThumb MyLeft="100" MyTop="100" Background="SteelBlue" Opacity="0.4"
                         MyPoints="0,0,100,0,50,50"
                         MyStrokeThickness="30" MyStroke="YellowGreen" MyIsStroked="True" MyIsClosed="False"
                         MyStrokeEndLineCap="Flat" MyStrokeStartLineCap="Round"
                         MyFill="Transparent"
                         MyStrokeMiterLimit="1.5" MyStrokeLineJoin="Miter"
                         MyAngle="0"/>

      <local:EzLineThumb x:Name="MyEz" MyLeft="100" MyTop="100" Background="SteelBlue" Opacity="0.5"
                         MyPoints="0,0,100,0,50,50"
                         MyStrokeThickness="30" MyStroke="YellowGreen" MyIsStroked="True" MyIsClosed="False"
                         MyStrokeEndLineCap="Flat" MyStrokeStartLineCap="Round"
                         MyFill="Transparent" MyFillRule="Nonzero"
                         MyStrokeMiterLimit="1.5" MyStrokeLineJoin="Miter"
                         MyAngle="0"/>
     

    </Canvas>
    <Rectangle StrokeEndLineCap="Flat" StrokeLineJoin="Bevel"/>
    <StackPanel Grid.Column="1" DataContext="{Binding ElementName=MyEz}" Margin="10">
      <TextBlock Text="{Binding MyLeft, StringFormat=Left {0:0.0}}"/>
      <TextBlock Text="{Binding MyTop, StringFormat=Top {0:0.0}}"/>
      <TextBlock Text="{Binding ActualWidth, StringFormat=W {0:0.0}}"/>
      <TextBlock Text="{Binding ActualHeight, StringFormat=H {0:0.0}}"/>
      <TextBlock Text="{Binding MyEzLine.MyBounds4, StringFormat=H {0:0.0}}"/>
      <TextBlock Text="{Binding MyOffsetLeft, StringFormat=OffsetLeft {0:0.0}}"/>
      <TextBlock Text="{Binding MyOffsetTop, StringFormat=OffsetTop {0:0.0}}"/>
      
      <Separator/>
      <Button Content="anime">
        <Button.Triggers>
          <EventTrigger RoutedEvent="Button.Click">
            <BeginStoryboard Storyboard="{StaticResource anime}"/>
          </EventTrigger>
        </Button.Triggers>
      </Button>
      <TextBlock Text="{Binding MyAngle, StringFormat=MyAngle {0:0.0}}"/>

      <Slider Value="{Binding MyAngle}" Minimum="0" Maximum="360"
              TickFrequency="10" IsSnapToTickEnabled="True"/>
      <Separator/>

      <TextBlock Text="{Binding MyStrokeThickness, StringFormat=StrokeThickness 0.0}"/>
      <Slider Value="{Binding MyStrokeThickness}" Minimum="0" Maximum="50"
              TickFrequency="10" IsSnapToTickEnabled="True"/>
      <Separator/>
      
      <Button Content="addRandomPoint" Click="Button_Click"/>
      <Button Content="RemovePoint0" Click="Button_Click_1"/>
      <TextBlock Text="{Binding MyPoints}"/>
      
    </StackPanel>
  </Grid>
</Window>

アニメーションが終了してから プロパティーの値を変更したくても変わらない #C# - Qiita
qiita.com
まさにこれだった、最初はそういうものだと思っていたけど違った

FillBehavior="Stop"

これをつけるだけ




MainWindow.xaml.cs

using System.Windows;

namespace _20250206_RotateEzLineThumb
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Random r = new();
            Point p = new(r.Next(200), r.Next(200));
            MyEz.MyPoints.Add(p);
        }

        private void Button_Click_1(object sender, RoutedEventArgs e)
        {
            if (MyEz.MyPoints.Count > 0)
            {
                MyEz.MyPoints.RemoveAt(0);
            }
        }
    }
}




感想

テストアプリ
できたけど、もっといい方法があるかも。Canvasをもう一枚挟んでおいて、EzLineはオフセットだけして、挟んだCanvasを回転するようにしておけば、前々回のTextBlockの回転と処理を共通化できるかも




関連記事

次のWPF記事
WPF、図形の回転後の頂点移動できた、ただし回転軸は左上 - 午後わてんのブログ
gogowaten.hatenablog.com




前回のWPF WPF、簡単に折れ線描画できて見た目通りのサイズと位置が取得できるクラスをShapeクラス継承で - 午後わてんのブログ
gogowaten.hatenablog.com




前々回
WPF、中のTextBlock(子要素)を回転させたときに、自身(親要素)のサイズと位置を違和感なく変更できた - 午後わてんのブログ
gogowaten.hatenablog.com