Blender Python APIによるUIレイアウト、Workspaceの制御方法の調査


# はじめに
AddOnを作っていると、AddOn側からユーザに想定している使いやすいGUI構成を強制、提案できないか、と考えることがあると思います。BlenderのGUI要素（つまり、Areaの幅や高さの変更、Workspaceの作成など）のPython APIの制御方法についての調査結果をまとめたものです。



## 環境
Blender 2.91, Windows 10で検証しています。

## Window, Screen, Area, Region

2.80以降Workspaceという概念が追加されています。
ユーザマニュアルではScreenの概念についてはWorkspaceに置き換わっています。
しかし、Python API側ではScreen Operatorはそのまま残っている点に注意してください。

2.80以降からBlenderを触り始めた人も多いと思いますので、Screen何それという人は2.79と2.80以降のドキュメントを見比べるとよいと思います。
[Window System 2.79](https://docs.blender.org/manual/ja/2.79/interface/index.html#window-system "Window System 2.79")
[Window System 2.91](https://docs.blender.org/manual/ja/2.91/interface/index.html#window-system "Window System 2.91")

Areaの操作はScreen Operatorで定義されています。
[Blender Screen Operator](https://docs.blender.org/api/current/bpy.ops.screen.html)


### 結論
- Workspaceの個別追加は容易に行うことができます。（実質blendファイルからのappend）Python APIでの追加も簡単にできます。
- 標準機能でのWorkspaceの追加機能は**Application Template**と紐づいている。
- WorkspaceをゼロからPythonスクリプトのみで作っていくのは難しそうです。
- Areaデータの位置、サイズのプロパティはReadOnlyなので直接値を変更することができません。
- Area操作用のOperatorはScreen Operatorとして定義されています。
- 現状のScreen Operatorで、Areaの位置、サイズ調整をすることはお勧めできません。それらのOperatorは標準機能としてマウス操作するために特化しているので、他から流用するのは、困難または不便な作りになっています。（また、Areaデータ自体が名前を持たないので、簡単に識別する方法もありません。そのこともAPIでの操作に不便さに拍車をかけています）
- area_split Operatorは使えなくはないです。（それでも、workspaceのAppendで済ました方がよいでしょう）
- `ops.screen.area_move`, `ops.screen.area_option`はpythonからは実質使用不可。`area_join`は辛うじて使用可能です。

今回調査してみて、workspaceのメリットとApplication Templateについて理解が進みました。
Blenderの環境構築、環境の配布という観点で、Application Templateを掘り下げていくのは面白そうです。
（CGの制作会社などで複数人が統一した環境で使うには、現状のAddOnシステムは不便な仕様があるので）
それについては、いずれ検証、考察してみたいです。


---
以下、Workspaceについて掘り下げていきます。その後、Area用のOperaterについて検証した内容についてまとめています。

最後に、Blenderの実装コードを確認してドキュメント化されていないOperatorの動作仕様について調査しています。

Operatorの名前がCのソースコードにそのまま記述されているので、動作仕様を結構簡単に調べることができました。C言語が読める人は、Operatorの動作検証をブラックボックスとして試行錯誤するより、直接実装を見に行ったほうが早いかもしれません。
[Screen Operatorの実装調査](## Screen Operatorの実装調査)

### Workspaceについて

Workspaceはデータとしては、Screenをwrapして、シーンデータとして取り扱うことができるようにしたものと思うのがよさそうです。
Dataとしてはscreenのデータのみ参照しています。

```python
>>> bpy.context.workspace
bpy.data.workspaces['Scripting']
>>> bpy.context.workspace.screens[0]
bpy.data.screens['Scripting']
```

シーンデータ扱いなので**Menu > File > Append**でworkspaceが現在のシーンに個別で追加ができます。

#### 手動でWorkspaceを追加し、手動でAppendする例

手動でTest Workspaceを追加します。
![append_workspace0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/efa0e424-998a-f408-b2db-e50fd4216042.png)

D:\scenes\test_workspace.blendとして保存し、File > Newでシーンを初期化します。
その後、File > Appendで先ほど保存したファイルを選択します。

![append_workspace1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/bd9c9605-35c3-22f3-8a1a-be9f18b7d90d.png)

blendファイルのWorkspaceが一覧されるので、先ほど追加した「Test」workspaceを選択します。
![append_workspace2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/01b0e2fe-c7d1-5279-34ef-59c0453656d9.png)

このようにAppendでの個別追加ができるので、Materialライブラリと同様に、Workspaceのライブラリ運用が可能です。

#### PythonでのWorkspaceの追加

Pythonを使ってもWorkspaceのAppendが可能です。Python APIでのAppendには2つの手段があります。

1. WindowManagerのAppend Operatorからの追加

```
import bpy
fpath = r"D:\scenes\test_workspace.blend"
bpy.ops.wm.append(directory=fpath+"\\WorkSpace\\", filename="Test")
```

2. Workspace Operatorのappend_activateによる追加
このOperatorは、実質1のappend処理と追加されたWorkspaceのactive化を一括で行うだけです。

```
bpy.ops.workspace.append_activate(idname="Test", filepath=fpath)
```

**おまけ**
WorkspaceのActive化はContextのWindowのWorkspaceにそのまま設定すればよいようです。

```python
>>> bpy.context.window.workspace = bpy.data.workspaces['Layout']

>>> bpy.context.workspace = bpy.data.workspaces['Layout']  # ちなみにContextのworkspace直設定は不可
Traceback (most recent call last):
  File "<blender_console>", line 1, in <module>
AttributeError: bpy_struct: attribute "workspace" from "Context" is read-only
```

https://docs.blender.org/api/current/bpy.ops.workspace.html


### Workspaceの「+」ボタンからの追加について

![add_workspace1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/4e307025-3dcb-4c8f-100a-c14f1cd439a9.png)

「+」ボタンを押すと、「General」, 「2D Animation」, ...「Video Editing」のメニューが表示されます。
![add_workspace.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/2667145c-bbdc-cc23-25d8-3548d0b1c3b0.png)

この一覧は、Aplication Templateというものの一覧です。File > Newの一覧とも同じになります。

![file_new.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/42723c1f-5367-82d0-1a25-1000194eb672.png)


## Application Templateについて

公式のドキュメントがあります。英語しかなさそうですが、自動翻訳で十分に分かります。
非常にシンプルな仕様でした。

Application Templateについての公式ドキュメント

https://docs.blender.org/manual/en/dev/advanced/app_templates.html


ここも参考になりそう

https://artisticrender.com/how-to-use-application-templates-in-blender/

簡単にいうと、下記の4点のファイルをまとめて、Blenderの初期化の仕様をパッケージにしたものです。

- startup.blend (File > Defaults > Save Startup Fileで保存されるFile > Newの時に開かれるシーンファイル)
- userpref.blend (Preferenceの設定保存ファイル)
- 初期化pythonスクリプト
- Splash Screen画像


いずれ掘り下げたいですが、workspaceに関連するところだけ簡単にまとめます。

Application TemplateとしてAddOnを提供している例がいくつか見られます。

**Blender to Unreal tools**

https://www.unrealengine.com/ja/blog/download-our-new-blender-addons

**Blender Pro**
 (2.91でインストール試したところAplication Templateとして認識はされても、スクリプト初期化でエラーになりました)

https://creativedesigner3d.com/2018/02/20/blender-pro-improving-blenders-interface-for-2-8/

Application Templateとして公開されているデータをDownloadすると、zipファイルが取得できます。そのzipファイルのまま下図のBlender Iconのメニューから読み込んでください。表示されるファイル選択ダイアログからzipそのまま選択で問題ありません。

![load_application_template.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/dae48e27-cfd9-0079-4f1a-0d97c208387b.png)

Installの結果、ユーザのBlenderコンフィグが配置されているフォルダにzipファイルが解凍されます。
ただzipが解凍されてデータがそのまま配置されるだけのようです。

```
%USERPROFILE%\AppData\Roaming\Blender Foundation\Blender\2.92\scripts\startup\bl_app_templates_user
```
File > Newにある「General」, 「Video Editing」などの組み込みのApplication Templateは下記のフォルダに配置されています。

```
C:\Program Files\Blender Foundation\Blender 2.92\2.92\scripts\startup\bl_app_templates_system
```

このフォルダを複製したり、リネームしてApplication Templateの検証ができます。

フォルダ複製などしてもBlenderは再起動の必要がありませんでした。File > New, Workspaceの+ボタンで即座に反映されています。
![application_template_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/c180244e-2550-4985-b838-5d52ba679a9b.png)


workspaceだけ必要な場合は、`startup.blend`だけフォルダに置いておけば、エラーなく「File > New」および, Workspaceの「+」 ボタンは機能しました。

他にScriptの実行などもできるので、そのあたりの動作検証はいずれしてみようと思います。
「Blender Pro」では、Blender Pro専用のAddOnの登録なども初期化スクリプトで行っているようです。

あと、templateフォルダを列挙するpython APIがあるようです。

```python
for p in bpy.utils.app_template_paths():
    print(p)
```

## Area用Screen Operator

以下は、Screen Operatorの動作検証についてまとめています。
Screen Operatorのarea関連はあまり使いようがないのですが、検証結果を捨ててしまうのはもったいないので、簡単にまとめています。


## Window, Screen, Areaのデータアクセス
前提としてwindow, screen, areaのデータのアクセスについて簡単にまとめます

### bpy.dataからのアクセス

#### Workspace
```python
>>> bpy.data.workspaces[0]
bpy.data.workspaces['Animation']

>>> len(bpy.data.workspaces)
11

>>> for ws in bpy.data.workspaces:
...     print(ws.name)
...     
Animation
Compositing
Layout
Modeling
Rendering
Scripting
Sculpting
Shading
Test
Texture Paint
UV Editing
>>> bpy.data.workspaces[0].screens[0]
bpy.data.screens['Animation']

```
#### Window, Screen, Area
```python
>>> len(bpy.data.window_managers['WinMan'].windows)
1
>>> bpy.data.window_managers['WinMan'].windows[0]
bpy.data.window_managers['WinMan']...Window
>>> window = bpy.data.window_managers['WinMan'].windows[0]
>>> window.screen
bpy.data.screens['Scripting']

>>> for scr in bpy.data.screens:
...     print(scr)
...     
<bpy_struct, Screen("Animation") at 0x000002A68B81D028>
<bpy_struct, Screen("Compositing") at 0x000002A68B81BEA8>
<bpy_struct, Screen("Default") at 0x000002A68B81E928>
<bpy_struct, Screen("Layout") at 0x000002A68B81C4E8>
<bpy_struct, Screen("Modeling") at 0x000002A68B81D2A8>
<bpy_struct, Screen("Rendering") at 0x000002A68B81D528>
<bpy_struct, Screen("Scripting") at 0x000002A68B81D668>
<bpy_struct, Screen("Sculpting") at 0x000002A68B81D7A8>
<bpy_struct, Screen("Shading") at 0x000002A68B81C268>
<bpy_struct, Screen("Texture Paint") at 0x000002A68B81D8E8>
<bpy_struct, Screen("UV Editing") at 0x000002A68B81C3A8>
```
#### Areaの列挙

```python
>>> window = bpy.data.window_managers['WinMan'].windows[0]
>>> screen = window.screen
>>> for area0 in screen.areas:
...     print("type:{} x:{} y:{} width:{} height:{}".format(area0.type, area0.x, area0.y, area0.width, area.height))
...     
type:PROPERTIES x:1587 y:20 width:333 height:594
type:OUTLINER x:1587 y:833 width:333 height:594
type:INFO x:0 y:20 width:562 height:594
type:OUTLINER x:1587 y:615 width:333 height:594
type:VIEW_3D x:0 y:646 width:562 height:594
type:CONSOLE x:0 y:188 width:562 height:594
type:TEXT_EDITOR x:563 y:20 width:1023 height:594
```

### Contextからのアクセス
現在アクティブになっているWorkspace, Areaなどが欲しい場合はこちらの方法で取得

```python
>>> bpy.context.workspace
bpy.data.workspaces['Scripting']

>>> bpy.context.window
<bpy_struct, Window at 0x000002A692404B78>

>>> bpy.context.screen
bpy.data.screens['Scripting']

>>> bpy.context.area
bpy.data.screens['Scripting']...Area

>>> bpy.context.region
bpy.data.screens['Scripting']...Region

```


## Screen Operatorの検証
以下はScriptingワークスペースの初期状態で検証しています。
コードはython Consoleでの実行を想定しています。
Areaの配置依存で動作が変わるので、下記のコードを再実行する際はその違いを適宜調整してください。

### AreaのプロパティはRead Only
Areaのx, y, width, heightはDocumentにもある通りReadOnlyで直接変更することはできません。

```Python
>>> C.window.screen.areas[0].x = 10
Traceback (most recent call last):
  File "<blender_console>", line 1, in <module>
AttributeError: bpy_struct: attribute "x" from "Area" is read-only
```


### area_splitの実行
`area_split`Operatorは簡単に使用することができます。
引数を指定しないと、アクティブなコンテキストのAreaが水平に分割されます。
分割したいAreaのWindow, screen, areaの辞書を引数として渡すと、そのareaを分割することができます。

おそらく、引数のcursorはAPIからは特に参照されていないと思われます。

```python:area_splitの実行
>>> bpy.ops.screen.area_split()  # 何も指定しないとアクティブなコンテキスト（この場合Python Console)のAreaが水平に分割）
{'FINISHED'}

>>> bpy.ops.screen.area_split(direction='VERTICAL', factor=0.5, cursor=(100, 100))  # パラメータの指定
{'FINISHED'}

>>> window = bpy.data.window_managers['WinMan'].windows[0]
>>> screen = window.screen
>>> area = screen.areas[5].type  # type: VIEW_3Dを想定
>>> region = area.regions[0]
>>> ctx = {'window': window, 'screen':screen, 'area':area, 'region':region}
>>> bpy.ops.screen.area_split(ctx, direction='VERTICAL', factor=0.3, cursor=(0,0))
{'FINISHED'}

```


### area_join
`area_split`と比べると大分面倒なAPIになります。
`area_split` Operatorと異なり、Areaの上下左右どの境界をJoinするかを指定する必要があります。
cursor引数の座標示すarea境界がJoinの対象となります。
無効なarea境界上の座標を指定した場合や境界でない座標を指定した場合は、OperatorがCANCELLEDを返します。

areaの座標、サイズ情報を使うことで、area_joinを正常終了することができます。
joinされたAreaの境界は即座に消えません（よく見ると若干細くなっている）。適当な境界をドラッグしてリフレッシュするとエッジが消えます。typeプロパティに別の値を入れて、戻すとscriptだけれもリフレッシュできました。

`area_split`とは異なり、Python ConsoleがActiveなコンテキストの状態で、他のAreaのJoinを行うことはできました。

```python:area_joinの実行例
>>> bpy.ops.screen.area_join(cursor=(100, 100))
{'CANCELLED'}

>>> bpy.ops.screen.area_join(cursor=(C.area.x, C.area.y+100))
{'FINISHED'}

>>> C.area.x, C.area.y
(225, 245)
```

![area_join.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/6c1df3fc-5515-b5ac-71fa-c9334b68d970.png)

`area_join`について議論しているforumがありました。
https://devtalk.blender.org/t/join-two-areas-by-python-area-join-what-arguments-blender-2-80/18165

### area_move, area_option

`area_join`と同様に引数でのcursor位置に加えて、実際にcursorの位置がその境界上に存在する必要があります。
![area_move.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/1a77906d-99e7-f5ca-4746-5bc877c48a6b.png)

timerを使用して、スクリプト実行から遅延してcursor移動と`area_move`を実行している例がありました。

https://blender.stackexchange.com/questions/161725/what-context-should-be-passed-to-screen-area-move-to-make-it-work

`area_option`は、右クリックした際に表示されるpopupを表示するためのOperatorのようです。
![area_option.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158905/56249dd2-bff3-c22c-8026-61f7115e78fa.png)

以上のことを鑑みると、これらのAPIが、GUI上のマウス操作もOperatorとして呼び出されているようです。
Addon開発時に標準のOperatorを流用する際には、このようなGUIに深く密接したOperatorもあることを覚えておくとよいかもしれません。
Python APIドキュメントにはこれらも一緒くたに記載されているので、実際にOperatorについて詳しく調査しないと、なかなかそのようなGUIに密接したAPIかどうかを判断することはできませんが…

---
おまけとして、このcursor位置に依存したoperatorというのが、一体どのような実装になっているのか気になったので、勉強も兼ねてソースコードを確認してみました。

## Screen Operatorの実装調査

Blenderのgitについては下記のリンクを参照してください。

https://wiki.blender.org/wiki/Tools/Git

```bash
git clone git://git.blender.org/blender.git
```
コンパイルするつもりがなければ、submoduleなどは無視で、上記のコマンドだけ実行（30分ぐらいかかります）し、grepなどでoperator名を適当に検索すれば、結構簡単に該当コードが見つかります。

``` c:screen_ops.c
static void SCREEN_OT_area_move(wmOperatorType *ot)
{
  /* identifiers */
  ot->name = "Move Area Edges";
  ot->description = "Move selected area edges";
  ot->idname = "SCREEN_OT_area_move";

  ot->exec = area_move_exec;
  ot->invoke = area_move_invoke;
  ot->cancel = area_move_cancel;
  ot->modal = area_move_modal;
  ot->poll = ED_operator_screen_mainwinactive; /* when mouse is over area-edge */

  /* flags */
  ot->flag = OPTYPE_BLOCKING | OPTYPE_INTERNAL;

  /* rna */
  RNA_def_int(ot->srna, "x", 0, INT_MIN, INT_MAX, "X", "", INT_MIN, INT_MAX);
  RNA_def_int(ot->srna, "y", 0, INT_MIN, INT_MAX, "Y", "", INT_MIN, INT_MAX);
  RNA_def_int(ot->srna, "delta", 0, INT_MIN, INT_MAX, "Delta", "", INT_MIN, INT_MAX);
}

...

/* when mouse is over area-edge */
bool ED_operator_screen_mainwinactive(bContext *C)
{
  if (CTX_wm_window(C) == NULL) {
    return false;
  }
  bScreen *screen = CTX_wm_screen(C);
  if (screen == NULL) {
    return false;
  }
  if (screen->active_region != NULL) {
    return false;
  }
  return true;
}
```
`ED_operator_screen_mainwinactive`は`area_move` Operatorのpoll関数（Operatorが実行可能か判定する関数）として呼び出されている関数で、コメントに書かれている通り、area-edge上にマウスカーソルがあるかどうか判定しているようです。
`screen->active_region`がそのフラグのようですが、bScreen *screenがbpy.types.Screenに対応すると思います。
python上でScreenのアトリビュートを確認しても、active_regionは見つかりませんでした。
contextにも対応する項目はなさそうなので、やはりarea_move Operatorのカーソル位置依存はどうしようもない仕様と結論づけられます。

## Workspace Operatorの実装調査

ついでに、workspace operatorの実装も覗いてみました。実装は下記ファイルにありました。
blender/source/blender/editors/screen/workspace_edit.c

### append_active Operator

```c:workspace_edit.c
static int workspace_append_activate_exec(bContext *C, wmOperator *op)
{
  Main *bmain = CTX_data_main(C);
  char idname[MAX_ID_NAME - 2], filepath[FILE_MAX];

  if (!RNA_struct_property_is_set(op->ptr, "idname") ||
      !RNA_struct_property_is_set(op->ptr, "filepath")) {
    return OPERATOR_CANCELLED;
  }
  RNA_string_get(op->ptr, "idname", idname);
  RNA_string_get(op->ptr, "filepath", filepath);

  WorkSpace *appended_workspace = (WorkSpace *)WM_file_append_datablock(
      bmain, CTX_data_scene(C), CTX_data_view_layer(C), CTX_wm_view3d(C), filepath, ID_WS, idname);

  if (appended_workspace) {
    /* Set defaults. */
    BLO_update_defaults_workspace(appended_workspace, NULL);

    /* Reorder to last position. */
    BKE_id_reorder(&bmain->workspaces, &appended_workspace->id, NULL, true);

    /* Changing workspace changes context. Do delayed! */
    WM_event_add_notifier(C, NC_SCREEN | ND_WORKSPACE_SET, appended_workspace);

    return OPERATOR_FINISHED;
  }

  return OPERATOR_CANCELLED;
}

static void WORKSPACE_OT_append_activate(wmOperatorType *ot)
{
  /* identifiers */
  ot->name = "Append and Activate Workspace";
  ot->description = "Append a workspace and make it the active one in the current window";
  ot->idname = "WORKSPACE_OT_append_activate";

  /* api callbacks */
  ot->exec = workspace_append_activate_exec;

  RNA_def_string(ot->srna,
                 "idname",
                 NULL,
                 MAX_ID_NAME - 2,
                 "Identifier",
                 "Name of the workspace to append and activate");
  RNA_def_string(ot->srna, "filepath", NULL, FILE_MAX, "Filepath", "Path to the library");
}
```
分かりやすい。fileのappendと読み込んだworkspaceのactive化がそのまま記述されているだけですね。

`WM_file_append_datablock`がblendファイルをappendする関数のようです。
`BLO_update_defaults_workspace`がworkspaceをactiveにしているコードでしょうか。

### add Operator
add Operatorも呼び出しても特に反応がなかったので、実装を確認してみました。
popup menuがどうのこうのとあるので、Workspace追加の「+」ボタンを押したときのMenu表示のためのOperatorのようです。

```c:workspace_edit.c
static int workspace_add_invoke(bContext *C, wmOperator *op, const wmEvent *UNUSED(event))
{
  uiPopupMenu *pup = UI_popup_menu_begin(C, op->type->name, ICON_ADD);
  uiLayout *layout = UI_popup_menu_layout(pup);

  uiItemMenuF(layout, IFACE_("General"), ICON_NONE, workspace_add_menu, NULL);

  ListBase templates;
  BKE_appdir_app_templates(&templates);

  LISTBASE_FOREACH (LinkData *, link, &templates) {
    char *template = link->data;
    char display_name[FILE_MAX];

    BLI_path_to_display_name(display_name, sizeof(display_name), template);

    /* Steals ownership of link data string. */
    uiItemMenuFN(layout, display_name, ICON_NONE, workspace_add_menu, template);
  }

  BLI_freelistN(&templates);

  uiItemS(layout);
  uiItemO(layout,
          CTX_IFACE_(BLT_I18NCONTEXT_OPERATOR_DEFAULT, "Duplicate Current"),
          ICON_DUPLICATE,
          "WORKSPACE_OT_duplicate");

  UI_popup_menu_end(C, pup);

  return OPERATOR_INTERFACE;
}

static void WORKSPACE_OT_add(wmOperatorType *ot)
{
  /* identifiers */
  ot->name = "Add Workspace";
  ot->description =
      "Add a new workspace by duplicating the current one or appending one "
      "from the user configuration";
  ot->idname = "WORKSPACE_OT_add";

  /* api callbacks */
  ot->invoke = workspace_add_invoke;
}
```

その他のOperatorはGUI操作と一対一で対応するので省略。