3D 形状の簡易ビューワーをFreeCADのpython モジュールで作りたい(調査中メモ、とりあえずFreeCADデータ生成マクロまで完成)

# 解決したい問題
外面が平面で構成される立体についての計算をExcel上で行うのだが、入力ミスをし易く、穴の開いた形状となったり、面がダブったりし易い。
その確認をしたいのだが、Excelは3Dのソリッド形状をグラフ化する機能がない。

# 手段の選択
gnuplotを使うことも考えたのだが、以下の理由からFreeCADのpythonモジュールを使うこととした。

- FreeCADのデータ、またはそれから変換したCADデータにすれば、寸法の確認をCADアプリ上で行うことや、データの２次利用などが可能となる
- Excelとpythonの連携は[xlwings](https://www.xlwings.org/)を用いることで実装可能
- pythonスクリプト中でデータのチェックを補助する計算を加えることもできる

# python.exeのバージョン
Anacondaでインストールしたpython.exeを使って簡単な例を実行してみたら、バージョンの差異により実行できなかった。また、FreeCADのドキュメントの中で、一部の機能を使うには、FreeCADをビルドした時に使ったのと同じCコンパイラでコンパイルしたpython.exeが必要との記述を見つけた。
よって、FreeCADに同梱のpython.exeを使うこととする。これにより、Anacondaをインストールしていなくても、FreeCADさえインストールしていれば、実行できる、というオマケもついた。

### 参考：python.exeのバージョン
- Anacoonda版 : 3.7
- FreeCAD同梱 : 3.6
  
## Anaconda spyder を使うための設定変更
FreeCADに同梱のpython.exeを使うため、以下の設定変更をした。

1. SpyderでTool>Preference>Python Interpreterで"Use the following Python interpreter"をチェックし、FreeCADに同梱のpython.exeファイルを指定
2. SpyderでConsole>New Consoleを実施したところ、spyder-kernelモジュールが無いとエラーが出た。
3. Anaconda promptを”管理者として実行"として開き、カレントディレクトリを"C:\Program Files\FreeCAD 0.18\bin\Scripts"に変更し、そこにあるpipを使い、"pip install spyder-kernels"を実行


# 開発ステップ(当初計画)
中途段階でも利用可能とするよう、以下のステップで開発することとする。

1. pythonのオブジェクト(変数)として定義されて3D形状データから、FreeCADのオブジェクトを生成し、FreeCADのネーティブファイルで保存するモジュールの実装
2. 3D形状を定義するテキストファイルを読み込み、FreeCADのネーティブファイルで保存するスクリプトの実装
3. Excelから3D形状を定義するテキストファイルの生成を簡易化するExcelシートの作成
4. [xlwings](https://www.xlwings.org/)を用いて、Excel上から3DデータをFreeCADのネーティブファイルで保存する機能の実装
5. 3D形状を表示する簡易ビューワーの実装

# とりあえずFreeCADデータ生成マクロの作成
## 作るもの
点の座標と、面の頂点のリストを定義したデータ(Python上の辞書オブジェクト2つ)から、FreeCAD上でshellデータを生成するマクロを作る。

## 生成コードの調査
FreeCAD上で、Partモジュールを用い、マクロ記録をオンにして、座標値を指定した点の生成、フェースの生成、シェル(Shell)の生成を実行。マクロとしてファイルに記録。

## オブジェクト名の指定方法
ファイルに保存した生成コードを確認すると、下記のように生成時に指定した名前Vertex001がコード中のオブジェクトの属性(プロパティー)の指定に使われていることを発見。

```
App.ActiveDocument.Vertex001.Placement=Base.Placement(Base.Vector(0.000,0.000,0.000),Base.Rotation(0.000,0.000,0.000,1.000))
```
これに対するpythonでの対処方法を知らなかったため、ググって下のページを見つけた。getattr()を使えばよいことを知る。

- [ [Python] インスタンスのプロパティへ動的にアクセスする](https://www.yoheim.net/blog.php?q=20161002)


## オブジェクト名の重複の対処
作りかけのマクロをテストしていて、たまたま同じマクロを2回実行して気づいたのだが、同じ名前を指定して、オブジェクトを生成すると、名前の重複を避けるように、修正された名前が付けられる。(オブジェクト名だけでなく、ラベルも同様に修正されていた。)
よって、このままでは複数回実行すると正しく動作しない(2回目に生成するシェルが1回目に生成したシェルに用いた点の座標を使ってしまうなどしてしまう)。
これに対処するため、オブジェクト生成後に実際に付けられた名前を取得して、それを用いるようにコードを書くこととする。

## 作成したpythonモジュール
これらを踏まえて、作成したpythonモジュールは下記。

```MakeShell.py
# -*- coding: utf-8 -*-
"""
MakeShell.py : Make shell object in FreeCAD

  Written by Kinsan at 2020/8
"""

import FreeCAD as App
import Part
# import Part,PartGui


def EntryVertex(name, pos,
                pls=[0.0, 0.0, 0.0], rot=[0.0, 0.0, 0.0, 1.0]):
    """ Entry Vertex to active document
    Parameters:
        name : name of vertex used for object name and label
        pos  : coordination [x,y,z]
        pls  : placement
        rot  :
    Return : name
    """
    from FreeCAD import Base
    App.ActiveDocument.addObject("Part::Vertex", name)

    name = App.ActiveDocument.ActiveObject.Name

    getattr(App.ActiveDocument, name).X = pos[0]
    getattr(App.ActiveDocument, name).Y = pos[1]
    getattr(App.ActiveDocument, name).Z = pos[2]
    getattr(App.ActiveDocument, name).Placement = Base.Placement(
        Base.Vector(*pls),
        Base.Rotation(*rot))
    getattr(App.ActiveDocument, name).Label = name
    App.ActiveDocument.recompute()

    return(name)


def EntryFace(name, vnames):
    """ Entry Face to active document
    Parameters:
        name   : name of Face used for object name
        vnames : names of vertex
    Return : name
    """
    pol = []
    for vn in vnames:
        pol.append(
          getattr(App.ActiveDocument, vn).Shape.Vertex1.Point)
    _ = Part.Face(Part.makePolygon(pol, True))
    if _.isNull():
        raise RuntimeError('Failed to create face')

#   App.ActiveDocument.addObject('Part::Feature', name).Shape = _
    newobj = App.ActiveDocument.addObject('Part::Feature', name)
    name = App.ActiveDocument.ActiveObject.Name
    newobj.Shape = _
    del _
    App.ActiveDocument.recompute()
    return(name)


def EntryShell(name, fnames):
    """ Entry Shell to active document
    Parameters:
        name   : name of Face used for object name
        fnames : names of faces
    Return : name
    """
    f = []
    for fn in fnames:
        f.extend(
            getattr(App.ActiveDocument, fn).Shape.Faces)
    _ = Part.Shell(f)
    if _.isNull():
        raise RuntimeError('Failed to create shell')
    App.ActiveDocument.addObject('Part::Feature', name).Shape = _
    del _
    App.ActiveDocument.recompute()


def Array2Dic(a, h="F", fmt='03d', s=None):
    """ Convert Array to Dic with auto geration of key
    Parameters:
        a : array
        h : heading str of key
        fmt : format of str for index to put
        s : start number of index to generate key
            if s is not specified or s is None, latest index +1 is used.
    Return : dic
    """
    if not hasattr(Array2Dic, "idx"):
        Array2Dic.idx = 1

    d = {}

    if s is None:
        i = Array2Dic.idx
    else:
        i = s

    for v in a:
        d[h + f'{i:{fmt}}'] = v
        i += 1

    Array2Dic.idx = i

    return d


def ReplArray(a, renew):
    """ Replace member of array
    Parameters
    ----------
    a : Array
        Target of renewing
    renew : Dic.
        key : original value
        val : new value
    Returns
    ----Renewed array
    """

    ret = []
    for v in a:
        if v in renew:
            ret.append(renew[v])
        else:
            ret.append(v)
    return ret


def RenewDicVal(d, renew):
    """ Renew values of dic.
    Parameters
    ----------
    d : Di.
        Target of renewing
    renew : Dic.
        key : original value
        val : new value

    Returns
    ----------
    Nothing
    """

    for k, v in d.items():
        d[k] = ReplArray(v, renew)


def RenewDicKey(d, renew):
    """ Renew keys of dic.
    Parameters
    ----------
    d : Di.
        Target of renewing
    renew : Dic.
        key : original value
        val : new value

    Returns
    ----------
    Nothing
    """
    for k, v in d.items():
        if k in renew:
            d[renew[k]] = v
            del d[k]
        else:
            d[k] = v


def MakePoints(points):
    """ Make Point Objects

    Parameters
    ----------
    poins : Dic. of points
        key : name of point
        val : array of coordinate [x,y,z]
         Note: key will be updated with entry name

    Returns
    -------
    dic. defined old and new name of points
    """

    renew = {}
    for n, p in points.items():
        nn = EntryVertex(n, p)
        # print(f"Created point : {nn}")
        if nn != n:
            renew[n] = nn
    RenewDicKey(points, renew)
    return renew


def MakeFaces(points, faces):
    """ Make Face objects from points and faces
    Parameters
    ----------
    poins : Dic. of points
        key : name of point
        val : array of coordinate [x,y,z]
    faces : Dic. of faces
        key : name of face
        val : array of names of points on a face

    Returns
    -------
    Nothing
    """

    renew = MakePoints(points)

    RenewDicVal(faces, renew)
    renew2 = {}
    for n, v in faces.items():
        nn = EntryFace(n, v)
        if nn != n:
            renew2[n] = nn
    RenewDicKey(faces, renew2)


def MakeShell(points, faces):
    """ Make Shell object from points and faces
    Parameters
    ----------
    poins : Dic. of points
        key : name of point
        val : array of coordinate [x,y,z]
    faces : Dic. of faces
        key : name of face
        val : array of names of points on a face

    Returns
    -------
    Nothing
    """

    MakeFaces(points, faces)
    EntryShell("Shell", list(faces.keys()))


# Main for debug
if __name__ == "__main__":

    def Test():
        """ Test routine
        """
        points = {
              'P00': [0.0, 0.0, 0.0],
              'P01': [1000.0, 0.0, 0.0],
              'P02': [0.0, 1000.0, 0.0],
              'P03': [0.0, 0.0, 1000.0]
        }
        faces = {
            'F01': ['P00', 'P01', 'P02'],
            'F02': ['P00', 'P02', 'P03'],
            'F03': ['P00', 'P03', 'P01'],
            'F04': ['P01', 'P02', 'P03']
        }

        MakeShell(points, faces)

    Test()
```

## 使い方
### 入力データ
以下のように、座標値を値、点の名前をキーとする辞書オブジェクトと、面を構成する点の名前のリストを要素するリストを定義し、上記のモジュール中の関数を呼ぶpythonコード形式のファイルを用意する。
なお、座標値の単位はmmなので注意。

```sample.py
# -*- coding: utf-8 -*-
points = {
    'P00': [0.0, 0.0, 0.0],
    'P01': [1000.0, 0.0, 0.0],
    'P02': [0.0, 1000.0, 0.0],
    'P03': [0.0, 0.0, 1000.0]
} # End of Points define
fcs = [
    ['P00', 'P01', 'P02'],
    ['P00', 'P02', 'P03'],
    ['P00', 'P03', 'P01'],
    ['P01', 'P02', 'P03']
] # End of Face define
from MakeShell import *
faces = Array2Dic(fcs,s=1)
MakeShell(points, faces)
```

## マクロの実行
FreeCADの設定で「ユーザーマクロを置く場所」として指定しているディレクトリーに上記のモジュールと入力データファイルをコピーする。
FreeCAD上で、この入力データファイルを指定して「マクロを実行」すれば、シェルデータが生成される。

# Excelとの連携
Excel上での座標値の計算や、面の生成をしているセルの右の方に、そのデータを使って、入力データ用のpythonスクリプトを生成する式を入力する。
これをテキストファイルにして使えばよいのだが、毎回コピペするのは面倒。
なので、下記のVBAコードを登録し、データを定義するシート上に置いたボタンから実行するように指定する。

このVBAコードは、アクティブなシートのZ1にファイル名、Z2以降にデータを書いているとして、Excelブックと同じディレクトリーにテキストファイルを生成するもの。
出力に用いるデータの最終行のセルには、"#EndOfScript"を入力しておく。空行が多いと見づらいので、空のセルの分は出力しないようにしてある。

```SaveScript.vba
Sub SaveScript()
Dim ws As Worksheet
Set ws = ActiveSheet

Dim datFile As String
Dim fname As String

fname = ws.Range("Z1").Value

If fname = "" Then fanme = "Sample.py"

datFile = ActiveWorkbook.Path & "\" & fname

Open datFile For Output As #1

Dim i, c As Long
i = 2
c = 26
Do While ws.Cells(i, c).Value <> "#EndOfScript"
    If ws.Cells(i, c) <> "" Then Print #1, ws.Cells(i, c).Value
    i = i + 1
    If i > 10000 Then
      MsgBox "'#EndOfScript' can not be found yet, and Too many loop"
      Exit Do
    End If
Loop

Close #1
```
MsgBox fname & " に書き出しました"

End Sub
```

***
# 以下、調査中メモ

- [FreeCAD/Python scripting Tutrial](https://wiki.freecadweb.org/Python_scripting_tutorial)

- FreeCADの外部で実行するpythonスクリプトから、FreeCAD関連のモジュールを利用する場合には、pythonのモジュール検索パスに、パスの追加が必要。Windowsに標準インストールした場合には、以下

```
FREECADPATH = r'C:\Program Files\FreeCAD 0.18\bin'
import sys
sys.path.append(FREECADPATH)
```

- FreeCAD関連のメインのモジュールは、[FreeCAD](https://wiki.freecadweb.org/FreeCAD_API)と[FreeCADGui](https://wiki.freecadweb.org/FreeCADGui_API)。モジュールFreeCADがファイルIOその他全般、モジュールFreeCADGuiがGUI関連。importするとそれぞれ、AppとGuiという別名からアクセスできる。(ドキュメント中の例などがApp, Guiで書かれているので、このことを知らないと理解しづらい。)

- 3D View関連は過去の経緯から、OpenCASCADEではなく、OpenInvertor standardを実装した[Coin3D](https://coin3d.github.io/)を使用。そのpython側のラップモジュールが[pivy](https://wiki.freecadweb.org/Pivy)。

- pivyモジュールを直接使って、boxなどを3Dシーンに追加することは可能。但し、これは同時にFreeCADのGeometryオブジェクトを作っているわけではないので、注意が必要。寸法線などの説明的要素の表示を追加するなどのために設けられた裏口的なものと思った方が良い。

- ビューワー的なものを実現方法の例がいくつか、[ここ Embedding FreeCADGui](https://wiki.freecadweb.org/Embedding_FreeCADGui)にあり。現状、一番下の例が良いらしい。