環境
- Windows 10
- Fusion 360
- VSCode
- 2022年6月18日
Fusion 360 APIのPython
「OpenSCADだと無理なことをしたい」と思ってしまった良い子の諸君! Fusion 360にはPythonのAPIがあり、スクリプトとアドインが作れる。
Fusion 360 を Pythonで動かそう その1 スクリプトの新規作成
できることは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
。
アップデートされたときの手順をまとめると、
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
自分がスタブを書き換えたあとは、
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かつ Sized
と__getitem__
のインターフェイスを持っているように見える。しかしスタブ的なものは恐ろしく不親切で、iterableにさえなっていない。
2022年6月のアップデートでSized等が追加された。とはいえジェネリックなコンテナではない。
そこで core.py
の末尾に追加:
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
につけてみた例:
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]
へのダウンキャストをしたい。
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
なお全スタブのインポートに
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
の最初のほうで、こんな具合に書く必要がある:
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
をテストしたいとする:
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):
がある)はこんな感じ:
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"
を加えて、こんな感じにする:
{
"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
になることは公式に書いてあるが、この仕様は書いていない。
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桁は遅い。
なにか見つけたら追記する。