1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Fusion 360 APIのPython

Last updated at Posted at 2020-11-26

環境

  • 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 upstreamgit 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.pyBaseComponent クラスの 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.pyfusion.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桁は遅い。

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

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?