C#で3D形状データを表示・操作し、画像を保存する。

# 概要
　C#で3D形状、3Dモデルを簡単に表示・操作し、画像として保存するまでの良いテンプレートが見つかりませんでしたので、自分の行っている手法を紹介いたします。本記事では、3D表示にOpenGLのラッパーである**OpenTK**を、画像データの保存にOpenCVのC#ラッパーの**OpenCVSharp**を、3D形状の読み込みに**SurfaceAnalyzer**を使います。完成イメージは以下のgifになります。はじめに三次元形状を読み込み、マウスで回転動作をし、画像として保存します。
　なお、今回用いたソースコードは[git](https://github.com/bananbo/3dview)にまとめています。
![AGD_20200814_205618_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/bca9fc23-6dfb-ecfb-6f0d-4adc16f15d9e.gif)

# 準備
　初めに、必要なライブラリのインストールを行います。

### Windows Form Applicationの作成
　今回は、WindowsFormApplicationとして作成していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/02888a22-0f6c-32f0-e733-aaea2029a3fb.png)

### OpenTK, OpenTK.GLControlのインストール
　OpenTKは、C#でOpenGLを扱うためのライブラリです。GLControlは、フォーム上にOpenGLの画面を表示するためのコントロールです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/c57225be-0884-5c91-091e-4b8cbb05e446.png)

### OpenCVSharp4, OpenCVSharp4.runtime.winのインストール
　OpenCVSharp4は、C#でOpenCVを使うためのラッパーです。今回は、画像を保存するために使用するため、画像保存が不要でしたらインストール不要です。System.Drawing.Bitmapでも良いのですが、取得した画像後処理をかけることを考慮して汎用性のあるこちらを用います。画像をウィンドウで表示するためには、それぞれの環境にあったruntimeもインストールする必要があります。Windowsの場合は、OpenCVSharp.runtime.winをインストールしましょう。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/e5fd8da2-67b9-658d-c996-9b2d76cec3a0.png)

### Surface Analyzerのインストール
　Surface Analyzerは、C#でSTLデータを扱うためのライブラリです。詳細な説明は、[こちら](https://qiita.com/bananbo/items/5d0f867224c172f012cb)の記事をご覧ください。STLファイルを読み込むことができれば、他のライブラリでも大丈夫です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/f8b13cc0-f4d9-0d71-f085-c08ccdf04fce.png)

　それでは、これからコーディングを始めます。本記事では、**実際に形状を表示する"Viewerフォーム"**と、**その表示をコントロールする"Controlフォーム"**に分けてコーディングしていきます。

# Viewerフォームのコーディング
　
### Viewerフォームの作成
　今回は、3D形状を表示する専用のフォームを作成し、Controlフォームから操作します。[プログラム名]>追加>フォーム(Windows Form)の順にクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/8a75b924-258f-5204-6a2d-3b5c7f608149.png)


### ビューアの設定
　下記のようにGLControlを追加し、イベントを追加します。[こちら]()のサイトを参考にしています。**GLControlはデザイナ側でも追加可能ですが、なぜかうまくいかない**ことが多いので自分はこちらの方法を利用しています。追加したイベントは画面上をカメラ操作するためのものです。右クリック＆ドラッグ回転し、ホイール回転で回転中心からの距離を制御します。

```C#:Viewer.cs_GLControl系
using System;
using System.Drawing;
using System.Windows.Forms;

// openTK
using OpenTK;
using OpenTK.Graphics;
using OpenTK.Graphics.OpenGL;

// surfaceAnalyzer
using SurfaceAnalyzer;

namespace _3dview
{
    public partial class Viewer : Form
    {
        #region Camera__Field
        
        bool isCameraRotating;      //カメラが回転状態かどうか
        Vector2 current, previous;  //現在の点、前の点
        float zoom = 1.0f;                 //拡大度
        double rotateX = 1, rotateY = 0, rotateZ = 0;//カメラの回転による移動
        float theta = 0;
        float phi = 0;

        #endregion

        public Viewer()
        {
            InitializeComponent();

            AddglControl();
        }

        // glControlの追加
        GLControl glControl;
        private void AddglControl()
        {
            SuspendLayout();

            int width = this.Width;
            int height = this.Height;

            //GLControlの初期化
            glControl = new GLControl();

            glControl.Name = "SHAPE";
            glControl.Size = new Size(width, height);
            glControl.Location = new System.Drawing.Point(0, 0);
            glControl.SendToBack();

            //イベントハンドラ
            glControl.Load += new EventHandler(glControl_Load);
            glControl.Resize += new EventHandler(glControl_Resize);
            glControl.MouseDown += new System.Windows.Forms.MouseEventHandler(this._3DView_MouseDown);
            glControl.MouseMove += new System.Windows.Forms.MouseEventHandler(this._3DView_MouseMove);
            glControl.MouseUp += new System.Windows.Forms.MouseEventHandler(this._3DView_MouseUp);
            glControl.MouseWheel += new System.Windows.Forms.MouseEventHandler(this._3DView_MouseWheel);

            Controls.Add(glControl);

            ResumeLayout(false);

        }

        private void glControl_Load(object sender, EventArgs e)
        {
            GLControl s = (GLControl)sender;
            s.MakeCurrent();

            GL.ClearColor(Color4.White);
            GL.Enable(EnableCap.DepthTest);

            Update();
        }

        private void glControl_Resize(object sender, EventArgs e)
        {
            GL.Viewport(0, 0, glControl.Size.Width, glControl.Size.Height);
            GL.MatrixMode(MatrixMode.Projection);
            Matrix4 projection = Matrix4.CreatePerspectiveFieldOfView((float)Math.PI / 4,
                (float)glControl.Size.Width / (float)glControl.Size.Height, 1.0f, 256.0f);
            GL.LoadMatrix(ref projection);

            Update();
        }

        private void _3DView_MouseDown(object sender, System.Windows.Forms.MouseEventArgs e)
        {
            // 右ボタンが押された場合
            if (e.Button == MouseButtons.Right)
            {
                isCameraRotating = true;
                current = new Vector2(e.X, e.Y);
            }
            Update();
        }


        private void _3DView_MouseUp(object sender, System.Windows.Forms.MouseEventArgs e)
        {
            //右ボタンが押された場合
            if (e.Button == MouseButtons.Right)
            {
                isCameraRotating = false;
                previous = Vector2.Zero;
            }
            Update();
        }


        private void _3DView_MouseMove(object sender, System.Windows.Forms.MouseEventArgs e)
        {
            // カメラが回転状態の場合
            if (isCameraRotating)
            {
                previous = current;
                current = new Vector2(e.X, e.Y);
                Vector2 delta = current - previous;
                delta /= (float)Math.Sqrt(this.Width * this.Width + this.Height * this.Height);
                float length = delta.Length;

                if (length > 0.0)
                {
                    theta += delta.X * 10;
                    phi += delta.Y * 10;
                    rotateX = Math.Cos(theta) * Math.Cos(phi);
                    rotateY = Math.Sin(phi);
                    rotateZ = Math.Sin(theta) * Math.Cos(phi);
                }

                Update();
            }
        }

        private void _3DView_MouseWheel(object sender, System.Windows.Forms.MouseEventArgs e)
        {
            float delta = e.Delta;

            zoom *= (float)Math.Pow(1.001, delta);

            //拡大、縮小の制限
            if (zoom > 4.0f)
                zoom = 4.0f;
            if (zoom < 0.03f)
                zoom = 0.03f;

            Update();
        }
    }
}

```

### 3D形状のレンダリングメソッドの追加
　次に、3D形状の表示部を作成します。
　**Update()メソッド**は、画面の表示の更新があるたびに呼ばれるメソッドです。ここから毎回Render()メソッドを呼び出します。
　**Render()メソッド**は、画面の表示を変更するメソッドとなります。引数polygonは、SurfaceAnalyzerにより読み込んだ形状となります。このメソッドをControl.cs側から呼ぶことで画面表示を操作します。
　**DrawPolygons()メソッド**は読み込んだ形状のポリゴンを一つ一つ表示します。GL.Begin()とGL.End()で囲まれた間で色、法線、頂点を与えることで画面上に表示することができます。ここでは、法線の方向に応じて色を面の描画色を指定しています。
　**N2TK()メソッド**では、System.NumericsとOpenTKのVector3ベクトルを変換します。

```C#:Viewer.cs_画面表示

        PolygonModel Polygon;
        public void Update()
        {
            if (Polygon == null) return;
            Render(Polygon);
        }
        public void Render(PolygonModel polygon)
        {
            Polygon = polygon;

            // バッファのクリア
            GL.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

            // カメラ設定
            Vector3 vec_rotate = new Vector3((float)rotateX, (float)rotateY, (float)rotateZ);
            Vector3 center = new Vector3(N2TK(Polygon.GravityPoint()));
            Vector3 eye = center + vec_rotate * center.LengthFast / zoom;
            Matrix4 modelView = Matrix4.LookAt(eye, center, Vector3.UnitY);

            // 表示設定
            GL.MatrixMode(MatrixMode.Modelview);
            GL.LoadMatrix(ref modelView);

            // 3D形状の表示
            DrawPolygons(polygon);

            // バッファの入れ替え
            glControl.SwapBuffers();
        }

        private void DrawPolygons(PolygonModel polygon)
        {
            if (polygon == null) return;

            //描画
            GL.Begin(PrimitiveType.Triangles);

            //三角形を描画
            for (int l = 0; l < polygon.Faces.Count; l++)
            {![Something went wrong]()

                var normal = polygon.Faces[l].Normal();
                GL.Color4(Math.Abs(normal.X), Math.Abs(normal.Y), Math.Abs(normal.Z), 0);
                GL.Normal3(N2TK(normal));
                GL.Vertex3(N2TK(polygon.Faces[l].Vertices[0].P));
                GL.Vertex3(N2TK(polygon.Faces[l].Vertices[2].P));
                GL.Vertex3(N2TK(polygon.Faces[l].Vertices[1].P));
            }
            GL.End();
        }

        // Numerics.Vector3をOpenTK.Vector3に変換します。
        private static OpenTK.Vector3 N2TK(System.Numerics.Vector3 vec3) => new Vector3(vec3.X, vec3.Z, vec3.Y);
```

### 画像化メソッドの追加
　OpenTKの表示内容を1ピクセルずつ読み取り、OpenCVSharpのMatに変換します。データを1ピクセルずつ読み込むと遅いため、メモリを直接コピーする手法を採用しています。Marshalを使用するために、usingの追加が必要です。

```C#:Viewer.cs_画像化
using System.Runtime.InteropServices;

        // 画像の保存
        public OpenCvSharp.Mat GetMat()
        {
            int width = glControl.Width;
            int height = glControl.Height;
            
            float[] floatArr = new float[width * height * 3];
            OpenCvSharp.Mat ret = new OpenCvSharp.Mat(height, width, OpenCvSharp.MatType.CV_32FC3);

            // dataBufferへの画像の読み込み
            IntPtr dataBuffer = Marshal.AllocHGlobal(width * height * 12);
            GL.ReadBuffer(ReadBufferMode.Front);
            GL.ReadPixels(0, 0, width, height, PixelFormat.Bgr, PixelType.Float, dataBuffer);

            // imgへの読み込み
            Marshal.Copy(dataBuffer, floatArr, 0, floatArr.Length);

            // opencvsharp.Matへの変換
            Marshal.Copy(floatArr, 0, ret.Data, floatArr.Length);

            // 破棄
            Marshal.FreeHGlobal(dataBuffer);

            return ret;
        }
```

　以上で、Viewerフォームのコーディングは終了です。

# Controlフォームのコーディング
　続いて、Controlフォームのコーディングを行います。

### ボタンの配置
　Controlフォーム上にボタンを追加し、それぞれ「ビューアの表示」、「形状の表示」、「保存」とします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/5cc70e19-515f-8992-f5db-b57273a75d44.png)

### Viewerフォームの表示
　各ボタンをダブルクリックし、イベントを追加し以下のように記述します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/16d35f59-b3d1-868d-4e80-d042b1120310.png)

　初めに、「ビューアの表示」ボタンをクリックした際の動作を作成します。

```C#:Control.cs途中
using System;
using System.Windows.Forms;

namespace _3dview
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        Viewer viewer;
        private void button1_Click(object sender, EventArgs e)
        {
            viewer = new Viewer();
            viewer.Show();
        }
    }
}
```


### 3D形状の表示
　続いて形状を読み込み、表示します。

```C#:Control.cs
using System;
using System.Windows.Forms;

namespace _3dview
{
    public partial class Control : Form
    {
        public Control()
        {
            InitializeComponent();
        }

        Viewer viewer;
        private void button1_Click(object sender, EventArgs e)
        {
            viewer = new Viewer();
            viewer.Show();
        }

        private void button2_Click(object sender, EventArgs e)
        {
            // 形状の読み込み
            var polygon = SurfaceAnalyzer.LoadData.LoadSTL(@"local\cube3_とんがり2.STL", true);

            // 形状のレンダリング
            viewer.Render(polygon);
        }

        private void button3_Click(object sender, EventArgs e)
        {

        }
    }
}

```

　実行し、「ビューアの表示」ボタンをクリックしビューアを表示します。そして「形状の表示」ボタンをクリックすると、形状が表示されます。右クリックで回転、ホイールで拡大・縮小が可能なです。
　![スクリーンショット 2020-08-14 20.03.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/9d759a85-7c34-fe23-8c9d-32e8af9098e7.png)

### 3D形状の画像化
　最後に、Viewerフォームの内容を保存します。

```C#:Control.cs_画像化および保存
using OpenCvSharp;
using System;
using System.Windows.Forms;

namespace _3dview
{
    public partial class Control : Form
    {
        public Control()
        {
            InitializeComponent();
        }

        Viewer viewer;
        private void button1_Click(object sender, EventArgs e)
        {
            viewer = new Viewer();
            viewer.Show();
        }

        private void button2_Click(object sender, EventArgs e)
        {
            // 形状の読み込み
            var polygon = SurfaceAnalyzer.LoadData.LoadSTL(@"local\cube3_とんがり2.STL", true);

            // 形状のレンダリング
            viewer.Render(polygon);
        }

        private void button3_Click(object sender, EventArgs e)
        {
            // viewerの画像の取得
            using (Mat mat = viewer.GetMat())
            {
                // 画像の表示
                Cv2.ImShow("mat", mat);

                // 画像の保存
                Cv2.ImWrite(@"local\mat.jpg", mat * 256);
            }
        }
    }
}
```

　実行[F5]し、「ビューアの表示」、「形状の表示」、「保存」の順にボタンをクリックすると画像が表示され、そのまま保存されます。OpenGLとOpenCVで座標軸の基準が異なるため、上下が反転した状態となります。
![スクリーンショット 2020-08-14 20.33.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628237/76692dfd-58b0-ae00-8654-dac4fba49a27.png)

# まとめ
　本記事では、C#でSTLデータを読み込んで、表示・操作し、画像として保存する方法を紹介しました。C#で3D形状を扱いたいときに役に立てると幸いです。