※本記事は下記のエントリから始まる連載記事となります。
.NET5.0/C#9.0でオートシェイプ風図形描画ライブラリを作ろう!(Chapter0)
Chapter2 ドラッグ操作で図形を動かしてみよう
今回は、マウス操作で図形を動かす処理を追加してみます。
ソースコード
Capter0からCapter2までの内容は下記ブランチにて実装されています。実装の詳細はこちらをご確認ください。
https://github.com/pierre3/CoreShape/tree/blog/chapter0-2
IShapeに当たり判定と移動処理を追加しよう
ドラッグ操作を行うには、次の2つの処理が必要になります。
- マウスポインタが図形の上に乗っているかの判定(当たり判定)処理
- ドラッグ時の移動処理
まずはIShapeにこの2つの処理を追加しましょう。
基本的な動作は下記の通り。
HitTest()
: マウスポインタの座標を受け取り、図形の上に乗っていればTrueを返すDrag()
:現在のマウスポインタの座標と1フレーム前のマウスポインタの座標を受け取り、その差分だけ図形を移動する
publicinterfaceIShape{voidDraw(IGraphicsg);boolHitTest(Pointp);voidDrag(PointoldPointer,PointcurrentPointer);}
RectangleShapeの実装
HitTest メソッド
図形内部の判定と輪郭の判定を個別に行い、塗りつぶしなしの場合は輪郭周辺にマウスがある場合のみTrueを返すようにします。
publicvirtualboolHitTest(Pointp){if(shape.Strokeisnotnull){//上辺との当たり判定if(p.X>=shape.Bounds.Left&&p.X<=shape.Bounds.Right&&p.Y>=shape.Bounds.Top-2&&p.Y<=shape.Bounds.Top+2){returntrue;}//下辺との当たり判定if(p.X>=shape.Bounds.Left&&p.X<=shape.Bounds.Right&&p.Y>=shape.Bounds.Bottom-2&&p.Y<=shape.Bounds.Bottom+2){returntrue;}//左辺との当たり判定if(p.Y>=shape.Bounds.Top&&p.Y<=shape.Bounds.Bottom&&p.X>=shape.Bounds.Left-2&&p.X<=shape.Bounds.Left+2){returntrue;}//右辺との当たり判定if(p.Y>=shape.Bounds.Top&&p.Y<=shape.Bounds.Bottom&&p.X>=shape.Bounds.Right-2&&p.X<=shape.Bounds.Right+2){returntrue;}}if(shape.Fillisnotnull){//図形内部の当たり判定if(shape.Bounds.Left<=p.X&&p.X<=shape.Bounds.Right&&shape.Bounds.Top<=p.Y&&p.Y<=shape.Bounds.Bottom){returntrue;}}returnfalse;}
Drag メソッド
マウスポインタのX,Y座標の差分だけBoundsのLocationを移動します。
publicvirtualvoidDrag(PointoldPointer,PointcurrentPointer){var(dx,dy)=(currentPointer.X-oldPointer.X,currentPointer.Y-oldPointer.Y);Bounds=newRectangle(Bounds.Left+dx,Bounds.Top+dy,Bounds.Size.Width,Bounds.Size.Height);}
OvalShapeの実装
HitTest メソッド
ついでに楕円の当たり判定も実装してみます。
楕円の方程式 x^2/a^2 + y^2/b^2 = 1
から楕円の内部判定を行います。
左辺が1以下なら楕円の内部、1より大きければ楕円の外となります。
(※ 楕円の中心が原点(0,0)にあり、aは原点からX軸と楕円の交点までの距離、bは原点からY軸と楕円の交点までの距離とした場合)
下記では、方程式の左辺にあたる部分をローカル関数で計算するようにしています。
また、輪郭の判定では一回り小さい楕円と一回り大きい楕円の間を輪郭とみなすようにしています。
publicovarrideboolHitTest(Pointp){staticdoubleDiscriminant(floatx,floaty,floatxr,floatyr)=>(x*x)/(xr*xr)+(y*y)/(yr*yr);varxr=Bounds.Size.Width/2;varyr=Bounds.Size.Height/2;varx=p.X-Bounds.Left-xr;vary=p.Y-Bounds.Top-yr;if(Strokeisnotnull){if(Discriminant(x,y,xr+2,yr+2)<=1&&Discriminant(x,y,xr-2,yr-2)>=1){returntrue;}}if(Fillisnotnull){returnDiscriminant(x,y,xr,yr)<1;}returnfalse;}
Dragメソッド
DragメソッドはRectangleShapeでの実装がそのまま利用できるのでここでの実装は不要です。
SampleWPFにドラッグ操作を追加しよう
続いてSampleWPFプロジェクトの処理を変更しドラッグ操作ができるようにします。
- Mainwindow.xaml でSKElementに MouseMoveイベントを追加します
<Windowx:Class="SampleWPF.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:skiaSharp="clr-namespace:SkiaSharp.Views.WPF;assembly=SkiaSharp.Views.WPF"xmlns:local="clr-namespace:SampleWPF"mc:Ignorable="d"Title="MainWindow"Height="450"Width="800"><Grid><skiaSharp:SKElementx:Name="skElement"PaintSurface="sKElement_PaintSurface"MouseMove="sKElement_MouseMove"/></Grid></Window>
- 描画する図形のインスタンスをshapesフィールドにIShapeの配列として作成しておきます。
- MouseMoveのイベントハンドラで以下の処理を行います。
IShape
の配列を回して当たり判定をおこなう- ヒットしたらマウスカーソルを十字矢印に変更し、ヒットした図形を
activeShape
フィールドに入れておく - マウスの左ボタンが押されていて
activeShape
がnullでない場合、activeShape.Drag()
メソッドを実行する
処理の詳細は下記ソースコードとそのコメントを参照ください。
publicpartialclassMainWindow:Window{publicMainWindow(){InitializeComponent();}//描画する図形をListに定義privateIList<IShape>shapes=new[]{newOvalShape(newCoreShape.Rectangle(100,100,200,150)){Stroke=newStroke(CoreShape.Color.Red,2),//Fill = new Fill(CoreShape.Color.LightSkyBlue)},newRectangleShape(newCoreShape.Rectangle(350,100,100,150)){Stroke=newStroke(CoreShape.Color.Black,2),Fill=newFill(CoreShape.Color.LightPink)},};//処理の対象となる図形privateIShape?activeShape;//1フレーム前のマウス座標privateCoreShape.PointoldPoint;//描画イベントprivatevoidsKElement_PaintSurface(objectsender,SKPaintSurfaceEventArgse){varg=newSkiaGraphics(e.Surface.Canvas);g.ClearCanvas(CoreShape.Color.Ivory);foreach(varshapeinshapes){shape.Draw(g);}}//マウス移動イベントprivatevoidsKElement_MouseMove(objectsender,MouseEventArgse){//マウスポインタの座標を取得varp=e.GetPosition(skElement);varcurrentPoint=newCoreShape.Point((float)p.X,(float)p.Y);if(e.LeftButton==MouseButtonState.Pressed){//左ボタン押下中、activeShapeがあればドラッグ処理を実行して描画更新if(activeShapeisnull){return;}activeShape.Drag(oldPoint,currentPoint);skElement.InvalidateVisual();}else{//カーソルとactiveShapeを一旦初期化Cursor=Cursors.Arrow;activeShape=null;//当たり判定foreach(varshapeinshapes){if(shape.HitTest(currentPoint)){//ヒットしたら十字矢印のカーソルに変更。ヒットした図形オブジェクトをactiveShapeに入れてループを抜けるCursor=Cursors.SizeAll;activeShape=shape;break;}}}//1フレーム前のポインタを更新oldPoint=currentPoint;}}
動作確認
楕円は輪郭のみ、矩形(四角形)は輪郭+内部の塗りつぶしで表示しています。
うん、いい感じに動いています!
当たり判定の処理を差し替え可能にしよう
PathとRegionを利用した当たり判定
ここまで当たり判定の処理は自力で実装してきましたが、もう少し複雑な図形を扱ったり、図形の回転が入ったりした場合自力で実装するのはちょっと厳しいですね。
実は今回扱っているSkiaSharpのようなグラフィックエンジンには、ある点が図形領域内に入っているかを判定する仕組みが備わっています。
その仕組みを利用するためのオブジェクトがPathとRegionです。
正確に説明するのは難しいのですが、PathとRegionはおおよそ以下のようなものだと理解しています(間違っていたらコメントください!)
- Path:直線、曲線、楕円、矩形などの図形を組み合わせて表現するためのクラス(描画手順のようなもの)。Regionに変換可能(単一の図形のみを含めても良い)
- Region:Pathとそれを囲む四角形のグラフィック領域を表す。Pathを実際に描画した場合の描画面のピクセルを表す
(Path=ベクタ画像のデータ、Region=ラスタ画像のデータ)
下記はPathとRegionを利用した楕円のHitTestの実装例です。
publicboolHitTest(Pointp){//テスト用varovalShape=newOvalShape(newRectangle(100,100,200,150)){Stroke=newStroke(Color.black,2),Fill=null}//作成したPathに楕円を追加usingvarpath=newSKPath();path.AddOval(ovalShape.Bounds);//描画スタイルを指定するSKPaintを作成usingvarpaint=newSKPaint().SetStroke(ovalShape.Stroke).SetFill(ovalShape.Fill).SetPaintStyle(ovalShape.Stroke,ovalShape.Fill);//PathにSKPaintの設定(色、輪郭の太さなど)を反映するusingvarfillPath=paint.GetFillPath(path);//PathをRegionに変換usingvarregion=newSKRegion(fillPath);//RegionのContaintsメソッドで領域の内部か否かの判定を行うreturnregion.Contains((int)p.X,(int)p.Y);}
SKPaint
のGetFillPath
を実施することで塗りつぶしの有無や輪郭の太さなどの設定をPathに反映させます。
StrategyパターンによるHitTestの処理方式のカスタマイズ
ではこの処理をOvalShapeのHitTestメソッドに実装してみたいと思います。
しかし、この処理はSkiaSharpのSKPath、SKRegionに依存してしまっているため、CoreShape側のクラスに直接実装することはできません。
そこで今回はStrategyパターンを用いてHitTestの処理方式を切り替え可能としてみましょう。
IHitTestStrategy<TShape>
インターフェース
以下のようなインターフェースを定義します。
ポインタ座標とIShapeのオブジェクトを引数に取る HitTestメソッドを持ちます。
引数のshapeの型はIShapeインターフェースそのものではなく、ジェネリクスで具体的な型を指定するようにしています。
publicinterfaceIHitTestStrategy<inTShape>whereTShape:IShape{boolHitTest(Pointp,TShapeshape);}
RectangleHitTestStrategy
クラス
ここからは、RectangleShapeの処理を例に実装を進めていきたいと思います
まずは、既存のRectangleShapeで実装したHitTestの処理をRectangleHitTestStrategy
として切り出します。
publicclassRectangleHitTestStrategy:IHitTestStrategy<RectangleShape>{publicboolHitTest(Pointp,RectangleShapeshape){if(shape.Strokeisnotnull){if(p.X>=shape.Bounds.Left&&p.X<=shape.Bounds.Right&&p.Y>=shape.Bounds.Top-2&&p.Y<=shape.Bounds.Top+2){returntrue;}//中略...}if(shape.Fillisnotnull){if(shape.Bounds.Left<=p.X&&p.X<=shape.Bounds.Right&&shape.Bounds.Top<=p.Y&&p.Y<=shape.Bounds.Bottom){returntrue;}}returnfalse;}}
次に RectangleShape
にHitTestStrategy
プロパティを実装します。
- コンストラクタは、規定で
RectangleHitTestStragegy
を設定するものと、引数でIHitTestStrategy<RectangleShape>
を指定するものの2種類を用意します。 RectangleShape
のHitTest()
メソッド内では、HitTestStrategy
のHitTest()
メソッドを実行するのみとなります。
publicclassRectangleShape:IShape{protectedIHitTestStrategy<RectangleShape>HitTestStrategy{get;set;}publicRectangleShape(Rectanglebounds){Bounds=bounds;HitTestStrategy=newRectangleHitTestStrategy();}publicRectangleShape(Rectanglebounds,IHitTestStrategy<RectangleShape>hitTestStrategy){Bounds=bounds;HitTestStrategy=hitTestStrategy;}publicvirtualboolHitTest(Pointp){returnHitTestStrategy.HitTest(p,this);}}
OvalShapeについても同様に実装を変更しておきます。
SKRegionOvalHitTestStrategy
クラス
それでは、先ほど「Regionを利用した楕円の当たり判定の処理」を実装したHitTestStrategyを実装します。
CoreShape.Extensions.SkiaSharp プロジェクトにSKRegionOvalHitTestStrategy
クラスを作成します。
(輪郭の幅が細いと操作しずらいので、4ピクセル以上となるように調整する処理が追加されています。)
publicclassSKRegionOvalHitTestStrategy:IHitTestStrategy<RectangleShape>{publicboolHitTest(Pointp,RectangleShapeshape){varstroke=shape.Stroke;//輪郭の幅が4未満の場合は4に拡張して判定if(strokeisnotnull&&stroke.Width<4){stroke=newStroke(color:stroke.Color,width:4);}usingvarpath=newSKPath();path.AddOval(shape.Bounds.ToSk());usingvarpaint=newSKPaint().SetStroke(stroke).SetFill(shape.Fill).SetPaintStyle(stroke,shape.Fill);usingvarfillPath=paint.GetFillPath(path);usingvarregion=newSKRegion(fillPath);returnregion.Contains((int)p.X,(int)p.Y);}}
Regionを使った当たり判定に変更する
これで必要なものは一通りそろいました。
あとはOvalShape生成時にコンストラクタにSKRegionOvalHitTestStrategy
のインスタンスを渡してあげるだけです。
publicpartialclassMainWindow:Window{privateIList<IShape>shapes=new[]{//Regionを使った当たり判定を使用するように指定newOvalShape(newCoreShape.Rectangle(100,100,200,150),newSKRegionOvalHitTestStrategy()){Stroke=newStroke(CoreShape.Color.Red,2),Fill=newFill(CoreShape.Color.LightSkyBlue)},newRectangleShape(newCoreShape.Rectangle(350,100,100,150)){Stroke=newStroke(CoreShape.Color.Black,2),Fill=newFill(CoreShape.Color.LightPink)}};
以上、OvalShapeの当たり判定差し替え部分をクラス図にすると、下記のようになります。
既定では、OvalHitTestStragegy
が設定されていますが、IHitTestStrategy<RectangleShape>
インターフェースを実装したクラスのインスタンスをコンストラクタに渡すことでHitTestの処理を別のものに差し替えることができます。
次回
次回は
- Chapter3 ドラッグ操作で図形のサイズを変更してみよう
です。