Fusion 360 APIのPython

# 環境

- Windows 10
- Fusion 360
- VSCode
- 2022年6月18日

# Fusion 360 APIのPython

　「OpenSCADだと無理なことをしたい」と思ってしまった良い子の諸君！　Fusion 360にはPythonのAPIがあり、スクリプトとアドインが作れる。

[Fusion 360 を Pythonで動かそう その１　スクリプトの新規作成](https://qiita.com/reisyu/items/84aa97117439e4962bbf)

　できることはOpenSCADよりずっと多い。
　が、「できることが多い＝APIが複雑」というトレードオフもある。また、Fusion 360 APIの設計がPythonを前提にしておらず（内部はC++）、そのミスマッチをAPIユーザに丸投げしていることが多い。
　それだけでもダルくなるところ、さらにSWIGである。pybind11ではなくSWIG。まさしく　あきれ　はてたな…
　とはいえなんとかやっていくしかない。本記事は、そのための工夫を記録している。

# VSCodeのコード補完とPythonの型アノテーション

　コード補完なしでFusion 360 APIを使うのはマゾゲーなのでやめておこう。

　Fusion 360 APIは、IDEがコード補完するための型推論の材料として、 `%APPDATA%\Autodesk\Autodesk Fusion 360\API\Python\defs` にスタブ的なものを提供している。しかしこれはPEP 484のスタブファイル（.pyiファイル）とは縁もゆかりもないもので、あまり実用的ではない。`itemByName()`の型が`Optional`でなかったり、`IntEnum`を使っていなかったり、~~`__getitem__`のインターフェイスがなかったり、~~ オプションの引数が必須だったりする。

## 言語サーバをPylanceにする

　`settings.json` で `"python.languageServer": "Pylance"` を設定する。

## スタブ的なものを書き換えて多少マトモにしたい

　`%APPDATA%\Autodesk` の下はいつAutodeskに書き換えられるかわからない。スタブ的なもの（`adsk` フォルダ）を安全なフォルダ `foo` にコピーして、`%APPDATA%\Autodesk\Autodesk Fusion 360\API\Python\defs\adsk` をそのフォルダへのジャンクションにしてしまう。そして`foo`フォルダをgitで管理すれば大丈夫。

## Fusion 360のアップデート対策とその手順

　「gitで管理すれば大丈夫」と言ったが、`adsk` フォルダはFusion 360のアップデートのたびに書き換えられるので（だからかなり頻繁）、ちょっと工夫が必要だった。

### 最初の準備

　Fusion 360が書き込んでくるスタブをupstreamという名前のブランチ、変更を加えたスタブをmineという名前のブランチで管理することにする。
　トリックの準備その1。`.gitignore`ファイルを作り、`.git-upstream`というファイル名をgit管理から除外する。`.git-upstream`とは何か？　後述。
　次に、空のフォルダ（仮に`foo`とする）で`git init`して、`.gitignore`をaddしていったんコミット（でないとブランチを切り替えられない）。upstreamブランチを作成＆切り替え（`git branch upstream`, `git checkout upstream`）、スタブの`adsk`フォルダを`foo`フォルダ内にコピーする。`adsk`フォルダ内の全ファイルをaddしてコミット。
　mineブランチを作成して切り替え（`git branch mine`, `git checkout mine`）、スタブに変更（`itemByName()`の型を`Optional`にしたり。後述）を加えてコミット。
　ここでトリックの準備その2。upstreamブランチに切り替え（`git checkout upstream`）、その状態で`.git`フォルダをコピーして、`.git-upstream`という名前で保存する。
　mineブランチに切り替える（`git checkout mine`）。`%APPDATA%\Autodesk\Autodesk Fusion 360\API\Python\defs`内に`adsk`という名前のジャンクションを作り、`foo`内の`adsk`フォルダに張る。
　この状態でFusion 360での普段のプログラミング作業を行う。

### Fusion 360がアップデートされたとき

　そしてトリック発動。`foo`フォルダ内の`.git`フォルダを削除し、`.git-upstream`フォルダを`.git`フォルダにリネームする。これによりgitは、管理下のファイルに触れることなく、upstreamブランチへと切り替わる。
　Fusion 360が書き込んできたスタブをupstreamブランチとしてコミット。`git checkout mine`して`git merge upstream`。`git checkout upstream`して`.git`フォルダをコピーして、`.git-upstream`という名前で保存する。`git checkout mine`。

　アップデートされたときの手順をまとめると、

```powershell
Remove-Item .git -Recurse -Force
Rename-Item .git-upstream -newName .git
git commit -a -m "regular update"
git checkout . # gitの warning: LF will be replaced by CRLF 対策
git checkout mine
git merge upstream
git checkout upstream
Copy-Item .git .git-upstream -Recurse
git checkout mine
```

　自分がスタブを書き換えたあとは、

```powershell
git checkout upstream
Remove-Item .\.git-upstream\ -Recurse -Force
Copy-Item .git .git-upstream -Recurse
git checkout mine
```

## ジェネリックな型アノテーション

　C++とのミスマッチの要は `adsk.core.ObjectCollection` クラスである。Fusion 360 APIはコンテナとしてこれを使うことが多い。~~オブジェクトをデバッガで軽く調べたところ、[iterable](https://docs.python.org/ja/3/glossary.html#term-iterable)かつ `Sized` と`__getitem__`のインターフェイスを持っているように見える。しかしスタブ的なものは恐ろしく不親切で、iterableにさえなっていない。~~
　2022年6月のアップデートでSized等が追加された。とはいえジェネリックなコンテナではない。
　そこで `core.py` の末尾に追加：

```python
import typing as ty
import collections.abc as abc

T = ty.TypeVar('T')

class ObjectCollectionT(abc.Iterable[T], abc.Sized, ObjectCollection):
    """
    Generic collection used to handle lists of any object type. Type generics version.
    Abstract class for type hinting.
    """
    def __init__(self):
        pass
    def item(self, index: int) -> T:
        pass
    def add(self, item: T):
        pass
    def contains(self, item: T) -> bool:
        pass
    def find(self, item: T, startIndex: int) -> int:
        pass
    def removeByItem(self, item: T) -> bool:
        pass
    def __getitem__(self, idx) -> T:
        pass
```

　この `ObjectCollectionT` クラスは、型アノテーションの中だけで完結しなければならない。実行時に現れようとするとエラーになることに注意。
　`ObjectCollectionT` クラスを使って、スタブ的なものに型アノテーションをつけていく。`fusion.py` の `BaseComponent` クラスの `findBRepUsingRay` につけてみた例：

```python
    def findBRepUsingRay(
        self, 
        originPoint: core.Point3D, 
        rayDirection: core.Vector3D, 
        entityType: int, 
        proximityTolerance: float, 
        visibleEntitiesOnly: bool, 
        hitPoints: core.ObjectCollectionT[core.Point3D]
        ) -> core.ObjectCollectionT['BRepFaces']:
        """
        ...docstring...
        """
        pass

```

　この作業をスタブ的なもの全部にやるのは99%無意味なので、自分が使うところだけやる。ちなみにAPIドキュメントのすべてがdocstringに書いてあるわけではないことに注意。

## 言語サーバを本当に再起動する

　スタブ的なものを書き換えると、言語サーバがおかしくなることが多い。Ctrl+Shift+Pで `Python: Restart Language Server` をすると、言語サーバが再起動するかのように思えるが、それほど深くは再起動しないらしく、これでは治らないことが多い。~~`settings.json` の `"python.languageServer": "Pylance"` のPylanceをJediに書き換える。1秒ほどで右下にリロードボタンが出てくる。それを見てから、値をPylanceに戻してからリロード、これで本当に再起動する。~~
　2022年6月5日現在、上記の方法ではリロードボタンが出てこなくなった。代替手段は見つかっていない。

## `ObjectCollection.create()`

　生成と同時に `ObjectCollectionT[T]` へのダウンキャストをしたい。

```python
def CreateObjectCollectionT(cls):
    ret: ac.ObjectCollectionT[cls] = ac.ObjectCollection.create()
    return ret
```

　Pylanceで型チェックするとエラーになるが、補完したいだけなので放置。これで `hitPoints = CreateObjectCollectionT(adsk.core.Point3D)` のhitPointsの型が `ObjectCollectionT[Point3D]` になる。

# 正規表現でまとめてスタブを改善

`core.py`と`fusion.py`には自動生成された辛いスタブがたくさんあるので、VSCodeの置換でまとめて改善する。上の行が置換元、下が置換先。

```
class (.*)\(\):\n(\s+"""\n[\s\S\n^"]*?"""\n).*\n.*\n(.* = [-\d]+\n)

class $1(IntEnum):\n$2$3
```

```
def itemBy(.*\)).*:\n([\s\S\n]*?)return (.*)\(\)

def itemBy$1 -> Optional['$3']:\n$2pass\n    
```

なお全スタブのインポートに

```python
from enum import IntEnum
from typing import Optional
```

を追加。

# スクリプトを複数のモジュールに分けたい

　スクリプトが長くなってくると、当然モジュールを分けたくなる。`HelloWorld.py`から`mymod.py`を分けた例：

```
.
├── HelloWorld.manifest
├── HelloWorld.py
├── __pycache__
└── mymod.py
```

　しかし問題が2つある。

- `HelloWorld.py`のあるディレクトリがsys.pathに入っていないので、そのままでは`import mymod`とはできない
- Fusion 360のPythonはずっと同じプロセスで動いている。VSCodeはアタッチ/デタッチ/リスタートするだけ。リスタートでHelloWorldモジュールはリロードされるが、mymodモジュールはされない

　そのため、`HelloWorld.py`の最初のほうで、こんな具合に書く必要がある：

```python
import os, sys, importlib

src_dir = os.path.dirname(__file__)
if not src_dir in sys.path:
    sys.path.append(src_dir)
del src_dir

importlib.invalidate_caches()
if 'mymod' in sys.modules:
    importlib.reload(sys.modules['mymod'])
import mymod
```

# 単体テストがしたい

　こんな`foo.py`をテストしたいとする：

```python
import adsk
import adsk.core as ac

class FooCommandCreatedHandler(ac.CommandCreatedEventHandler):
    def notify(self, args):
        command = ac.Command.cast(args.command)
        inputs = command.commandInputs
        
        # Add the selection input to get the one face.
        selectInput = inputs.addSelectionInput('selectEnt', 'Selection', 'Select an entity')
        selectInput.addSelectionFilter('Faces')
        selectInput.setSelectionLimits(1, 1)
        adsk.terminate()
```

　これをテストするスクリプトファイル（Scripts and Add-InsダイアログボックスのFull Pathで指定されているファイル。`def run(context):`がある）はこんな感じ：

```python
import unittest
import traceback, sys, os, importlib
import adsk.core as ac
import adsk

current_dir = os.path.dirname(__file__)
if not current_dir in sys.path:
    sys.path.append(current_dir)
del current_dir

if 'foo' in sys.modules:
    importlib.reload(sys.modules['foo'])
from foo import FooCommandCreatedHandler

HANDLERS = []


def run(context):
    ui = None
    try:
        tests = unittest.TestSuite()
        loader = unittest.TestLoader()

        # tests.addTests(loader.loadTestsFromTestCase(TestFoo))
        tests.addTest(TestFoo('test_foo'))

        runner = unittest.TextTestRunner()
        runner.run(tests)
    except:
        app = ac.Application.get()
        ui  = app.userInterface
        ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))


class TestFoo(unittest.TestCase):
    def test_foo(self):
        TEST_BUTTON_ID = 'TestButtonId'
        app = ac.Application.get()
        ui  = app.userInterface

        # Get the CommandDefinitions collection.
        cmdDefs = ui.commandDefinitions

        # Create a button command definition.
        buttonSample = cmdDefs.itemById(TEST_BUTTON_ID)
        if buttonSample is not None:
            buttonSample.deleteMe()  # deleteMe() always returns true regardless the result :-)
            buttonSample = cmdDefs.itemById(TEST_BUTTON_ID)
            if buttonSample is not None:
                raise Exception(f'{TEST_BUTTON_ID} deleteMe() failed. Close the existing dialog box.')
        buttonSample = cmdDefs.addButtonDefinition(TEST_BUTTON_ID, 
                                                'Test Button', 
                                                'Test button tooltip')
        
        # Connect to the command created event.
        sampleCommandCreated = FooCommandCreatedHandler()
        buttonSample.commandCreated.add(sampleCommandCreated)
        HANDLERS.append(sampleCommandCreated)
        
        # Execute the command.
        buttonSample.execute()
        # Keep the script running.
        adsk.autoTerminate(False)
```

　最初に実行するとテストは成功し、TEST BUTTONダイアログボックスが現れる。これを消さないままVSCodeからRestart（緑の回転矢印）すると、テストは失敗する。消してからまたRestartすると、今度は成功する。

# pipを使いたい

　まず`settings.json`に`"terminal.integrated.env.windows"`を加えて、こんな感じにする：

```json
{
	"python.autoComplete.extraPaths":	["C:/Users/Hoge/AppData/Roaming/Autodesk/Autodesk Fusion 360/API/Python/defs"],
	"python.linting.pylintEnabled":	false,
	"python.languageServer": "Pylance",
	"terminal.integrated.env.windows": {
        "Path": "C:/Users/Hoge/AppData/Local/Autodesk/webdeploy/production/d61caf495bff6c1f315f777537e978cd28259ee4/Python/;${env:Path}"
    }
}
```

　`Path`の値はFusion 360が`python.pythonPath`に設定している。これはFusion 360がアップデートするたびに変わるので、そのたびに設定しなおす。

　これでVSCodeのターミナルを開けば、`python -m pip`でpipが使える。ちなみに2021年3月4日現在、`python -m pip list`の出力はこんな感じ：

```
Package       Version
------------- -------
pip           19.2.3
setuptools    41.2.0
speedtest-cli 2.0.2
wget          3.2
WARNING: You are using pip version 19.2.3, however version 21.0.1 is available.
You should consider upgrading via the 'python -m pip install --upgrade pip' command.
```

# `Product.findAttributes()`の罠

　アトリビュートの親が存在しないときに、その`parent`プロパティが`None`になることは公式に書いてあるが、この仕様は書いていない。

```python
for a in des.findAttributes('foo', 'bar'):
    b = adsk.fusion.BRepBody.cast(a.parent)
    if b is not None:
        a2 = b.attributes.itemByName('foo', 'bar')
        if a2 is None:
            print('Acknowledgment problem... ')
```

　つまり、子のアトリビュートがあるオブジェクトを親だと思っていても、逆が成り立たない場合がある。実際にそうなっているドキュメントを作り出さないと発見できない仕様なので、罠だ。

　なお、`Product.findAttributes()`を避けてオブジェクトの`attributes`プロパティを走査すると、想像を絶するほど遅いので、一度お試しあれ。きっとあなたの想像より1桁は遅い。

　なにか見つけたら追記する。