├── .gitattributes ├── .github └── workflows │ ├── publish-beta.yml │ └── publish.yml ├── .gitignore ├── .idea └── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── DEVELOPMENT.md ├── EXTENSION-SUPPORT.md ├── LICENSE ├── README.md ├── generated └── enums.json ├── icons └── blender_icon.ico ├── package.json ├── pythonFiles ├── generate_data.py ├── include │ └── blender_vscode │ │ ├── __init__.py │ │ ├── communication.py │ │ ├── environment.py │ │ ├── external │ │ └── get-pip.py │ │ ├── installation.py │ │ ├── load_addons.py │ │ ├── log.py │ │ ├── operators │ │ ├── __init__.py │ │ ├── addon_update.py │ │ ├── script_runner.py │ │ └── stop_blender.py │ │ ├── ui.py │ │ └── utils.py ├── launch.py ├── templates │ ├── addons │ │ ├── simple │ │ │ └── __init__.py │ │ └── with_auto_load │ │ │ ├── __init__.py │ │ │ └── auto_load.py │ ├── blender_manifest.toml │ ├── operator_simple.py │ ├── panel_simple.py │ └── script.py └── tests │ └── blender_vscode │ └── test_load_addons.py ├── src ├── addon_folder.ts ├── blender_executable.ts ├── blender_executable_linux.ts ├── blender_executable_windows.ts ├── commands_new_addon.ts ├── commands_new_operator.ts ├── commands_scripts.ts ├── commands_scripts_data_loader.ts ├── communication.ts ├── extension.ts ├── notifications.ts ├── paths.ts ├── python_debugging.ts ├── select_utils.ts └── utils.ts ├── tsconfig.json └── tslint.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/publish-beta.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | # push: 4 | # branches: 5 | # - main 6 | 7 | name: Deploy Extension pre-release 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - run: npm ci 17 | # - name: Publish to Open VSX Registry 18 | # uses: HaaLeo/publish-vscode-extension@v2 19 | # with: 20 | # pat: ${{ secrets.OPEN_VSX_TOKEN }} 21 | # preRelease: true 22 | - name: Publish to Visual Studio Marketplace 23 | uses: HaaLeo/publish-vscode-extension@v2 24 | with: 25 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 26 | registryUrl: https://marketplace.visualstudio.com 27 | preRelease: true -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | 5 | name: Deploy Extension 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v5 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 22 14 | - run: npm ci 15 | # - name: Publish to Open VSX Registry 16 | # uses: HaaLeo/publish-vscode-extension@v2 17 | # with: 18 | # pat: ${{ secrets.OPEN_VSX_TOKEN }} 19 | - name: Publish to Visual Studio Marketplace 20 | uses: HaaLeo/publish-vscode-extension@v2 21 | with: 22 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 23 | registryUrl: https://marketplace.visualstudio.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | npm-debug.log 6 | package-lock.json 7 | __pycache__ 8 | .idea -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: watch" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test" 29 | ], 30 | "outFiles": [ 31 | "${workspaceFolder}/out/test/**/*.js" 32 | ], 33 | "preLaunchTask": "npm: watch" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "python.linting.enabled": false, 12 | "spellright.language": [ 13 | "en_US" 14 | ], 15 | "spellright.documentTypes": [ 16 | "markdown", 17 | "plaintext" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | out/**/*.map 5 | src/** 6 | .gitignore 7 | tsconfig.json 8 | vsc-extension-quickstart.md 9 | tslint.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Changed 6 | 7 | - Disconnecting debug session will now hard close/terminate blender 8 | 9 | ### Added 10 | 11 | - A `blender.executable` can be now marked as default. 12 | - When no blender is marked as default, a notification will appear after and offer setting it as default 13 | ```json 14 | "blender.executables": [ 15 | { 16 | "name": "blender-4.5.1", 17 | "path": "C:\\...\\blender-4.5.1-windows-x64\\blender.exe", 18 | "isDefault": true 19 | } 20 | ] 21 | ``` 22 | - Run `Blender: Start` using single button by adding snippet to `keybindings.json`. Other commands are not supported. 23 | 24 | Simple example: 25 | ```json 26 | { 27 | "key": "ctrl+h", 28 | "command": "blender.start", 29 | } 30 | ``` 31 | 32 | Advanced example: 33 | ```json 34 | { 35 | "key": "ctrl+h", 36 | "command": "blender.start", 37 | "args": { 38 | "blenderExecutable": { 39 | "path": "C:\\...\\blender.exe" 40 | }, 41 | // optional, run script after debugger is attached, must be absolute path 42 | "script": "C\\script.py" 43 | } 44 | } 45 | ``` 46 | - Improvements for `Blender: Run Script`: 47 | - When **no** Blender instances are running, start blender automatically 48 | - When Blender instances are running, just run the script on all available instances (consistant with old behavior) 49 | - Run `Blender: Run Script` using single button by adding snippet to `keybindings.json`. 50 | 51 | Simple example: 52 | ```json 53 | { 54 | "key": "ctrl+shift+enter", 55 | "command": "blender.runScript", 56 | "when": "editorLangId == 'python'" 57 | } 58 | ``` 59 | 60 | Advanced example: 61 | ```json 62 | { 63 | "key": "ctrl+shift+enter", 64 | "command": "blender.runScript", 65 | "args": { 66 | // optional, same format as item in blender.executables 67 | // if missing user will be prompted to choose blender.exe or default blender.exe will be used 68 | "blenderExecutable": { 69 | "path": "C:\\...\\blender.exe" 70 | }, 71 | // optional, run script after debugger is attached, must be absolute path, defaults to current open file 72 | "script": "C:\\script.py" 73 | }, 74 | "when": "editorLangId == 'python'" 75 | } 76 | ``` 77 | 78 | ### Removed 79 | 80 | - Removed dependency on `ms-vscode.cpptools` what causes problems for other editors #235, #157. There is no plans to further support Blender Core develompent in this addon. 81 | - Deprecated setting: `blender.core.buildDebugCommand` 82 | - Removed commands: 83 | - `blender.build` 84 | - `blender.buildAndStart` 85 | - `blender.startWithoutCDebugger` 86 | - `blender.buildPythonApiDocs` 87 | 88 | 89 | ## [0.0.26] - 2025-08-14 90 | 91 | ### Added 92 | 93 | - Run `Blender: Start` using single button by adding snippet to `keybindings.json`. Other Commands (like `Blender: Build and Start`) are not supported ([#199](https://github.com/JacquesLucke/blender_vscode/pull/199)). 94 | ```json 95 | { 96 | "key": "ctrl+h", 97 | "command": "blender.start", 98 | "args": { 99 | "blenderExecutable": { // optional, same format as item in blender.executables 100 | "path": "C:\\...\\blender.exe" // optional, if missing user will be prompted to choose blender.exe 101 | }, 102 | // define command line arguments in setting blender.additionalArguments 103 | } 104 | ``` 105 | - You can now configure VS Code internal log level using [`blender.addon.logLevel`](vscode://settings/blender.addon.logLevel) ([#198](https://github.com/JacquesLucke/blender_vscode/pull/198)) 106 | - to mute logs set log level to critical 107 | - to enable more logs set log level to debug 108 | - changing log level required blender restart 109 | - logs now have colors 110 | - Print python dependency version (log level info) and path (log level debug) even when it is already installed 111 | - 2 new operators: `blender.openWithBlender` - usable with right click from file explorer and `blender.openFiles` - usable from command palette ([#225](https://github.com/JacquesLucke/blender_vscode/pull/225)). 112 | - 2 new settings: `blender.preFileArguments` and `blender.postFileArguments` - they work only with above new commands. Placement of file path within command line arguments is important, this distincion was needed ([#225](https://github.com/JacquesLucke/blender_vscode/pull/225)). 113 | - `blender.postFileArguments` can not have `--` in args (it causes invalid syntax). Note: the `--` is used for passing arguments to python scripts inside blender. 114 | - `blender.additionalArguments` remains unchanged and will work only with `Blender: Start` command. 115 | 116 | ### Fixed 117 | 118 | - Linux only: temporary variable `linuxInode` will no longer be saved in VS Code settings ([#208](https://github.com/JacquesLucke/blender_vscode/pull/208)). 119 | 120 | ### Changed 121 | 122 | - updated dependency engines.vscode to 1.63 (to support beta releases) 123 | 124 | ## [0.0.25] - 2024-11-07 125 | 126 | ### Fixed 127 | 128 | - Remove clashes with legacy version when linking extension ([#210](https://github.com/JacquesLucke/blender_vscode/pull/210)). 129 | 130 | ## [0.0.24] - 2024-09-12 131 | 132 | ### Fixed 133 | 134 | - Starting Blender with C and Python debugger. 135 | - Pin Werkzeug library to avoid crash when opening add-ons in user-preferences ([#191](https://github.com/JacquesLucke/blender_vscode/pull/191)). 136 | 137 | ## [0.0.23] - 2024-09-06 138 | 139 | ### Added 140 | 141 | - Make `.blend` files use the Blender icon (#187) 142 | 143 | ### Fixed 144 | 145 | - Linux and MacOS: fixed `blender.executables` not showing when calling `Blender: Start` (introduced in #179) 146 | 147 | ## [0.0.22] - 2024-09-04 148 | 149 | ### Added 150 | - Add setting to specify to which repository install addons from VS code. Default value is `vscode_development` ([#180](https://github.com/JacquesLucke/blender_vscode/pull/180)) 151 | - Automatically add Blender executables to quick pick window. Search PATH and typical installation folders ([#179](https://github.com/JacquesLucke/blender_vscode/pull/179)) 152 | - If Blender executable does not exist indicate it in quick pick window ([#179](https://github.com/JacquesLucke/blender_vscode/pull/179)) 153 | - Support bl_order in auto_load.py (#118) 154 | - Allow user to develop addon even it is placed in directories like (#172) 155 | - `\4.2\scripts\addons` -> default dir for addons 156 | - `\4.2\extensions\blender_org` -> directory indicated by `bpy.context.preferences.extensions.repos` (list of directories) 157 | - Remove duplicate links to development (VSCode) directory (#172) 158 | - Remove broken links in addon and extension dir (#172) 159 | 160 | ### Changed 161 | - Updated dependencies. Now oldest supported VS Code version is `1.28.0` - version from September 2018. ([#147](https://github.com/JacquesLucke/blender_vscode/pull/147)) 162 | - Addon_update operator: Check more precisely which module to delete (#175) 163 | - Formatted all python code with `black -l 120` (#167) 164 | - Fix most of the user reported permission denied errors by changing python packages directory ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)): 165 | - Instead of installing to system python interpreter (`.\blender-4.2.0-windows-x64\4.2\python\Lib\site-packages`) 166 | - Install to local blender modules `%appdata%\Blender Foundation\Blender\4.2\scripts\modules` (path indicated by `bpy.utils.user_resource("SCRIPTS", path="modules")`). 167 | - Existing installations will work fine, it is not a breaking change 168 | 169 | ### Deprecated 170 | - setting `blender.allowModifyExternalPython` is now deprecated ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)) 171 | 172 | ### Fixed 173 | - Path to addon indicated by [`blender.addonFolders`](vscode://settings/blender.addonFolders) now works correctly for non-system drive (usually `C:`) on Windows ([#147](https://github.com/JacquesLucke/blender_vscode/pull/147)) 174 | - Pinned requests to version 2.29 to maintain compatibility with blender 2.80 ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)) 175 | - Find correct python path for blender 2.92 and before (#174). This partly fixes compatibility with blender 2.80. 176 | - "Blender: Run Script" will no longer open read-only file when hitting debug point (#142) 177 | 178 | ## [0.0.21] - 2024-07-16 179 | 180 | ### Added 181 | - Initial support for extensions for Blender 4.2. 182 | 183 | ## [0.0.20] - 2024-05-01 184 | 185 | ### Added 186 | - New `blender.addon.justMyCode` option. Previously, this was enabled by default and made it more difficult to debug addons that used external libraries. Restart Blender debug session after changing this option. 187 | 188 | ### Fixed 189 | - Prioritize addon path mappings to make it more likely that the right path is mapped. 190 | 191 | ## [0.0.19] - 2023-12-05 192 | 193 | ### Fixed 194 | - Fixed "Run Script" support for Blender 4.0. 195 | 196 | ## [0.0.18] - 2023-04-02 197 | 198 | ### Added 199 | - New `blender.environmentVariables` option. Can be used to define environment variables passed to 200 | blender on `Blender Start`. 201 | - New `blender.additionalArguments` option. Can be used to define additional arguments used when 202 | starting blender on `Blender Start`. 203 | 204 | ### Changed 205 | - Changed scope of `blender.executables` to `resource`. The value is firstly looked up in workspace 206 | settings before user settings. 207 | 208 | ### Fixed 209 | - Behavior of scripts that changed context like the active object. 210 | 211 | ## [0.0.17] - 2022-06-08 212 | 213 | ### Added 214 | - New `blender.addonFolders` option. Allows to specify absolute or root workspace relative 215 | directories where to search for addons. If not specified all workspace folders are searched. 216 | 217 | ### Fixed 218 | - Update `get-pip.py`. 219 | - Use `ensurepip` if available. 220 | 221 | ## [0.0.16] - 2021-06-15 222 | 223 | ### Fixed 224 | - Fix after api breakage. 225 | 226 | ## [0.0.15] - 2021-05-10 227 | 228 | ### Fixed 229 | - Use `debugpy` instead of deprecated `ptvsd`. 230 | 231 | ## [0.0.14] - 2021-02-27 232 | 233 | ### Fixed 234 | - Update `auto_load.py` again. 235 | 236 | ## [0.0.13] - 2021-02-21 237 | 238 | ### Fixed 239 | - Update `auto_load.py` to its latest version to support Blender 2.93. 240 | 241 | ## [0.0.12] - 2019-04-24 242 | 243 | ### Added 244 | - New `blender.addon.moduleName` setting. It controls the name if the generated symlink into the addon directory. By default, the original addon folder name is used. 245 | 246 | ### Fixed 247 | - Fix detection for possibly bad addon folder names. 248 | - Addon templates did not contain `version` field in `bl_info`. 249 | 250 | ## [0.0.11] - 2019-03-06 251 | 252 | ### Added 253 | - New `Blender: Open Scripts Folder` command. 254 | - New `CTX` variable that is passed into scripts to make overwriting the context easier. It can be used when calling operators (e.g. `bpy.ops.object.join(CTX)`). This will hopefully be replaced as soon as I find a more automatic reliable solution. 255 | 256 | ### Fixed 257 | - Scripts were debugged in new readonly documents on some platforms. 258 | - Addon package was put in `sys.path` in subprocesses for installation. 259 | - Warn user when new addon folder name is not a valid Python module name. 260 | - Path to Blender executable can contain spaces. 261 | 262 | ## [0.0.10] - 2018-12-02 263 | 264 | ### Added 265 | - Support for multiple addon templates. 266 | - Addon template with automatic class registration. 267 | - Initial `Blender: New Operator` command. 268 | 269 | ### Fixed 270 | - Handle path to `.app` file on MacOS correctly ([#5](https://github.com/JacquesLucke/blender_vscode/issues/5)). 271 | - Better error handling when there is no internet connection. 272 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please make sure that your PR works with Blender 2.80: 4 | - That requires you to use python 3.7 and 5 | - Compatible Blender API (do a version check) 6 | - Remember to update [CHANGELOG](./CHANGELOG.md): https://keepachangelog.com/en/1.0.0/ 7 | - Upcoming releases (roadmap) are planned in [milestones](https://github.com/JacquesLucke/blender_vscode/milestones) 8 | - Generally don't commit commented out code unless there is a really good reason 9 | - Prefer comments to be full sentences 10 | 11 | # Structure 12 | 13 | - Blender entrypoint is in `pythonFiles/launch.py` 14 | - VS Code entrypoint is `src/extension.ts`. Refer to VS code docs, there is nothing non standard here. 15 | 16 | # Python guideline 17 | 18 | Use Black formatter with `black --line-length 120` 19 | 20 | ## Python tests 21 | 22 | There is no clear guideline or commitment to testing. 23 | 24 | Some tests are prototyped in `pythonFiles/tests/blender_vscode/test_load_addons.py`. 25 | They should be run outside of Blender what makes them easy to execute, but prone to breaking: there is a lot of patching 26 | for small number of test. 27 | 28 | Run tests: 29 | 30 | ```powershell 31 | pip install pytest 32 | cd pythonFile 33 | $env:PYTHONPATH="./include" # powershell 34 | pytest -s .\tests 35 | ``` 36 | 37 | # Typescript guideline 38 | 39 | Nothing more than `tslint.json`. -------------------------------------------------------------------------------- /EXTENSION-SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Addon/Extension support 2 | 3 | > With the introduction of Extensions in Blender 4.2, the old way of creating add-ons is considered deprecated. 4 | 5 | [Extensions](https://docs.blender.org/manual/en/4.2/advanced/extensions/getting_started.html) are supported. 6 | For general comparison visit [Legacy vs Extension Add-ons](https://docs.blender.org/manual/en/4.2/advanced/extensions/addons.html#legacy-vs-extension-add-ons). 7 | VS code uses the automatic logic to determine if you are using addon or extension: 8 | 9 | | Blender version | Addon has `blender_manifest.toml` | Development location: | Your addon is interpreted as: | Link is created? | 10 | | --------------- | --------------------------------- | ---------------------- | ----------------------------- | -------------------------------------------------- | 11 | | Pre 4.2 | Does not matter | ADDON_DEFAULT_LOCATION | Legacy addon | No link is created | 12 | | Pre 4.2 | Does not matter | Anywhere on disk. | Legacy addon | You addon is linked to ADDON_DEFAULT_LOCATION | 13 | | 4.2 and above | True | Anywhere on disk. | Extension | You addon is linked to EXTENSIONS_DEFAULT_LOCATION | 14 | | 4.2 and above | True | ADDON_DEFAULT_LOCATION | Legacy addon | No link is created | 15 | | 4.2 and above | True | ANY_REPO_LOCATION | Extension | No link is created | 16 | | 4.2 and above | False | Does not apply. | Legacy addon | You addon is linked to ADDON_DEFAULT_LOCATION | 17 | 18 | There is no setting to override this behaviour. Find out your default paths: 19 | ```python 20 | >>> ADDON_DEFAULT_LOCATION = bpy.utils.user_resource("SCRIPTS", path="addons") 21 | '/home/user/.config/blender/4.2/scripts/addons' 22 | >>> ANY_REPO_LOCATION = [repo.custom_directory if repo.use_custom_directory else repo.directory for repo in bpy.context.preferences.extensions.repos if repo.enabled] 23 | ['/home/user/.config/blender/4.2/extensions/blender_org', '/home/user/.config/blender/4.2/extensions/user_default', '/snap/blender/5088/4.2/extensions/system'] 24 | >>> EXTENSIONS_DEFAULT_LOCATION = bpy.utils.user_resource("EXTENSIONS", path="vscode_development") 25 | '/home/user/.config/blender/4.2/extensions/vscode_development' 26 | ``` 27 | 28 | Note: `EXTENSIONS_DEFAULT_LOCATION` is defined by [`blender.addon.extensionsRepository`](vscode://settings/blender.addon.extensionsRepository) 29 | 30 | ## Examples 31 | 32 | I am using Blender 3.0 to develop my addon in `/home/user/blender-projects/test_extension/`. 33 | My addon supports addons and extensions (has both `bl_info` defined in `__init__.py` and `blender_manifest.toml` file.) 34 | - Result: my addon is interpreted to be addon (because Blender 3 does not support extension). My addon is linked to ADDON_DEFAULT_LOCATION 35 | 36 | I am using Blender 4.2 to develop my addon in `/home/user/blender-projects/test_extension/`. 37 | My addon supports addons and extensions (has both `bl_info` defined in `__init__.py` and `blender_manifest.toml` file.) 38 | - Result: my addon is interpreted to be extension. My addon is linked to EXTENSIONS_DEFAULT_LOCATION 39 | 40 | # Uninstall addon and cleanup 41 | 42 | ## How to uninstall addon? 43 | 44 | Manually remove links from locations: 45 | 46 | - Extensions (Blender 4.2 onwards): `bpy.utils.user_resource("EXTENSIONS", path="vscode_development")` 47 | - Addons: `bpy.utils.user_resource("SCRIPTS", path="addons")` 48 | - For older installations manually remove links from: `bpy.utils.user_resource("EXTENSIONS", path="user_default")` 49 | 50 | > [!WARNING] 51 | > Do not use Blender UI to uninstall addons: 52 | > - On windows uninstalling addon with Blender Preferences will result in data loss. It does not matter if your addon is linked or you are developing in directory that Blender recognizes by default (see above table). 53 | > - On linux/mac from blender [2.80](https://projects.blender.org/blender/blender/commit/e6ba760ce8fda5cf2e18bf26dddeeabdb4021066) uninstalling **linked** addon with Blender Preferences is handled correctly. If you are developing in that Blender recognizes by default (see above table) data loss will occur. 54 | 55 | ## How to completely cleanup all changes? 56 | 57 | - Remove installed dependencies in path: `bpy.utils.user_resource("SCRIPTS", path="addons")` 58 | - Older version install dependencies to global Blender packages folder and they are impossible to remove easily `/4.2/python/Lib/site-packages` 59 | - Remove extension repository called `vscode_development`: `Blender -> Preferences -> Get Extensions -> Repositories (dropdown, top right)` 60 | 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jacques Lucke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blender Development in VS Code 2 | 3 | The only key combination you have to remember is `ctrl+shift+P`. 4 | All commands of this extension can be found by searching for `Blender`. 5 | 6 | ## Installation 7 | 8 | The extension is [installed](https://code.visualstudio.com/docs/editor/extension-gallery) like any other extension in Visual Studio Code. 9 | 10 | ## Addon Tools 11 | 12 | You can develop your addon anywhere, VS Code will create a **permanent soft link** (in windows: junction) to link you workspace: 13 | - for addons to `bpy.utils.user_resource("SCRIPTS", path="addons")` 14 | - for extensions to `bpy.utils.user_resource("EXTENSIONS", path="vscode_development")` 15 | - VS code installs to local `vscode_development` extensions repository `Blender -> Preferences -> Get Extensions -> Repositories (dropdown, top right)`, see [`blender.addon.extensionsRepository`](vscode://settings/blender.addon.extensionsRepository) 16 | 17 | > [!WARNING] 18 | > In some cases uninstalling addon using Blender Preferences UI interface [might lead to data loss](./EXTENSION-SUPPORT.md#uninstall-addon-and-cleanup) 19 | 20 | ### How do I create a new addon? 21 | 22 | Execute the **Blender: New Addon** operator and use the setup wizard. 23 | You will be asked for the following information: 24 | * Which addon template should be used? 25 | * Where should the addon be created? This should be an empty folder, preferably without spaces or special characters in the name. 26 | * What is the name of the addon? 27 | * What is your name? 28 | 29 | ### How can I use the extension with my existing addon? 30 | 31 | The extension only supports addons that have a folder structure. 32 | If your addon is a single `.py` file, you have to convert it first. 33 | To do this, move the file into a new empty folder and rename it to `__init__.py`. 34 | 35 | To use the extension with your addon, just load the addon folder into Visual Studio Code. 36 | In Visual Studio Code, open the Command Palette (with CTRL + SHIFT + P) then execute the `Blender: Start` command. 37 | This will ask you for a path to a Blender executable. 38 | 39 | Only Blender 2.8.34 onwards is supported. 40 | 41 | After you choose a path, Blender will open. 42 | The terminal output can be seen inside of VS Code. 43 | The first time you open a new Blender build like this can take a few seconds longer than usual because some Python libraries are installed automatically. 44 | For that it is important that you have an internet connection. 45 | 46 | Once Blender is started, you can use the addon in Blender. 47 | Debugging with the VS Code debugger frontend should work now like for any other Python script. 48 | You can set breakpoints by placing the red dot next to the line number in VS Code and the debugger will hit it while using the extension inside Blender. 49 | 50 | ### Extension support 51 | 52 | > With the introduction of Extensions in Blender 4.2, the old way of creating add-ons is considered deprecated. 53 | 54 | [Extensions](https://docs.blender.org/manual/en/4.2/advanced/extensions/getting_started.html) are supported. 55 | For migration guide visit [Legacy vs Extension Add-ons](https://docs.blender.org/manual/en/4.2/advanced/extensions/addons.html#legacy-vs-extension-add-ons). 56 | VS code uses the [automatic logic to determine if you are using addon or extension](./EXTENSION-SUPPORT.md) 57 | 58 | ### How can I reload my addon in Blender? 59 | 60 | Execute the `Blender: Reload Addons` command in VS Code's Command Palette. 61 | For that to work, Blender has to be started using the extension. 62 | Your addon does not need to support reloading itself. 63 | It only has to have correct `register` and `unregister` methods. 64 | 65 | To reload the addon every time a file is saved, activate the [`blender.addon.reloadOnSave`](vscode://settings/blender.addon.reloadOnSave) setting in VS Code. 66 | 67 | ### How can I open blender file automatically when running `Blender: Start`? 68 | 69 | Add the path to .blend file to [`blender.additionalArguments`](vscode://settings/blender.additionalArguments): 70 | 71 | ```javascript 72 | "blender.additionalArguments": [ 73 | "--factory-startup", // any arguments you want 74 | // "--open-last", // Open the most recently opened blend file, or: 75 | "./path/to/my-file.blend" // prefered to be last argument, watch out for trailing spaces (which are invisible in VS code UI) 76 | ], 77 | ``` 78 | 79 | Note: You can also right click .blend file and use `Open With Blender`. That command uses separate arguments as position of filename in blender arguments is important ([`blender.preFileArguments`](vscode://settings/blender.preFileArguments), [`blender.postFileArguments`](vscode://settings/blender.postFileArguments)) 80 | 81 | ### How can I separate development environment from my daily work? 82 | 83 | By default, Blender started from VS Code uses your global Blender settings (in windows: `%appdata%\Blender Foundation\Blender\`). 84 | 85 | To prevent any accidental changes to your daily setup, change environment var in VS Code setting [`blender.environmentVariables`](vscode://settings/blender.environmentVariables): 86 | 87 | ```javascript 88 | "blender.environmentVariables": { 89 | "BLENDER_USER_RESOURCES": "${workspaceFolder}/blender_vscode_development" // changes folder for addons, extensions, modules, config 90 | }, 91 | ``` 92 | 93 | See `blender --help` for more environment vars with finer controls: 94 | 95 | ```shell 96 | Environment Variables: 97 | $BLENDER_USER_RESOURCES Replace default directory of all user files. 98 | Other 'BLENDER_USER_*' variables override when set. 99 | $BLENDER_USER_CONFIG Directory for user configuration files. 100 | $BLENDER_USER_SCRIPTS Directory for user scripts. 101 | $BLENDER_USER_EXTENSIONS Directory for user extensions. 102 | $BLENDER_USER_DATAFILES Directory for user data files (icons, translations, ..). 103 | ``` 104 | 105 | ### How to use with multiple addons? 106 | 107 | Use VS Code feature [Multi-root Workspaces](https://code.visualstudio.com/docs/editor/multi-root-workspaces). Each folder in workspace is treated as addon root. 108 | 109 | ### How can I debug into third party library code from within my addon code? 110 | 111 | Addon can be debugged when started from VS Code using the `Blender: Start` command in VS Code's Command Palette. 112 | By default, debug breakpoints work only for files and directories opened in the current workspace and it is also not possible to step into code that is not part of the workspace. 113 | Disable the VS Code setting [`blender.addon.justMyCode`](vscode://settings/blender.addon.justMyCode) to debug code anywhere. 114 | In rare cases debugging with VS Code can crash Blender (ex. https://github.com/JacquesLucke/blender_vscode/issues/188). 115 | 116 | ### How to start Blender with shortcut? 117 | 118 | Limited shortcuts are supported by editing `keybindings.json`. 119 | Add `"isDefault": true"` to one of [`blender.executables`](vscode://settings/blender.executables) to mute most popups. 120 | 121 | Shortcut to `Blender: Start` simple example: 122 | ```json 123 | { 124 | "key": "ctrl+h", 125 | "command": "blender.start", 126 | } 127 | ``` 128 | 129 | Shortcut to `Blender: Start` advanced example: 130 | ```json 131 | { 132 | "key": "ctrl+h", 133 | "command": "blender.start", 134 | "args": { 135 | "blenderExecutable": { 136 | "path": "C:\\...\\blender.exe" 137 | }, 138 | // optional, run script after debugger is attached, must be absolute path 139 | "script": "C:\\script.py" 140 | } 141 | } 142 | ``` 143 | 144 | Shortcut to `Blender: Run Script` simple example: 145 | ```json 146 | { 147 | "key": "ctrl+shift+enter", 148 | "command": "blender.runScript", 149 | "when": "editorLangId == 'python'" 150 | } 151 | ``` 152 | 153 | Shortcut to `Blender: Run Script` advanced example: 154 | ```json 155 | { 156 | "key": "ctrl+shift+enter", 157 | "command": "blender.runScript", 158 | "args": { 159 | // optional, same format as item in blender.executables 160 | // if missing user will be prompted to choose blender.exe or default blender.exe will be used 161 | "blenderExecutable": { 162 | "path": "C:\\...\\blender.exe" 163 | }, 164 | // optional, run script after debugger is attached, must be absolute path, defaults to current open file 165 | "script": "C:\\script.py" 166 | }, 167 | "when": "editorLangId == 'python'" 168 | } 169 | ``` 170 | 171 | 172 | 173 | ## Script Tools 174 | 175 | When I say "script" I mean a piece of Python code that runs in Blender but is not an addon. 176 | Scripts are best to test and learn Blender's Python API but also to solve simple tasks at hand. 177 | Usually scripts are written in Blender's text editor. 178 | However, the text editor has fairly limited capabilities compared to modern text editors and IDEs. 179 | 180 | For script writing this extension offers 181 | - all text editing features VS Code and its extensions can offer 182 | - a way to quickly organize your scripts into folders 183 | - easy execution of the script inside of Blender 184 | - a simple way to change the context, the script runs in 185 | - debugging 186 | 187 | ### How can I create a new script? 188 | 189 | Execute the `Blender: New Script` command in VS Code's Command Palette. 190 | You will be asked for a folder to save the script and a script name. 191 | For quick tests you can also just use the given default name. 192 | 193 | The new script file already contains a little bit of code to make it easier to get started. 194 | 195 | ### How can I run the script in Blender? 196 | 197 | First you have to start a Blender instance by executing the `Blender: Start` command in VS Code's Command Palette. 198 | To execute the script in all Blender instances that have been started this way, execute the `Blender: Run Script` command. 199 | 200 | You can assign a shortcut to `Blender: Run Script` by editing `keybindings.json`, see section [How to start Blender with shortcut?](#how-to-start-blender-with-shortcut) 201 | 202 | ### How can I change the context the script runs in? 203 | 204 | Currently the support for this is very basic, but still useful. 205 | To run the script in a specific area type in Blender insert a comment like `#context.area: VIEW_3D`. 206 | The preferred way to insert this comment is to execute the `Blender: Set Script Context` command. 207 | 208 | ### How can I pass command line argument to my script? 209 | 210 | Specify your arguments in [`blender.additionalArguments`](vscode://settings/blender.additionalArguments) after `--`, which 211 | indicates [End option processing, following arguments passed unchanged](https://docs.blender.org/manual/en/latest/advanced/command_line/arguments.html). Access via Python’s `sys.argv` 212 | 213 | Be aware about: 214 | 215 | - [sys.exit gotcha](https://docs.blender.org/api/current/info_gotcha.html#sys-exit) 216 | - and [register_cli_command](https://docs.blender.org/api/current/bpy.utils.html#bpy.utils.register_cli_command) 217 | 218 | ## Core Blender development 219 | 220 | This addon has some ability to help with [Blender source code development](https://developer.blender.org/docs/handbook/building_blender/) but it is undocumented. 221 | 222 | ## Troubleshooting 223 | 224 | - Make sure you use the newest version of VS Code. 225 | - Use the latest Blender version from https://www.blender.org/download/. 226 | - Check [CHANGELOG](./CHANGELOG.md) for breaking changes. 227 | - Search Issues for similar problems. 228 | - When reporting issue please enable debug logs using [`blender.addon.logLevel`](vscode://settings/blender.addon.logLevel) 229 | - Look in VS Code Output window. 230 | 231 | ## Status 232 | 233 | This extension is not actively developed anymore. However, if you are interested in working on this extension, please contact me. 234 | 235 | ## Contributing 236 | 237 | See [DEVELOPMENT.md](./DEVELOPMENT.md) 238 | -------------------------------------------------------------------------------- /generated/enums.json: -------------------------------------------------------------------------------- 1 | { 2 | "areaTypeItems": [ 3 | { 4 | "identifier": "EMPTY", 5 | "name": "Empty", 6 | "description": "" 7 | }, 8 | { 9 | "identifier": "VIEW_3D", 10 | "name": "3D Viewport", 11 | "description": "Manipulate objects in a 3D environment" 12 | }, 13 | { 14 | "identifier": "IMAGE_EDITOR", 15 | "name": "UV/Image Editor", 16 | "description": "View and edit images and UV Maps" 17 | }, 18 | { 19 | "identifier": "NODE_EDITOR", 20 | "name": "Node Editor", 21 | "description": "Editor for node-based shading and compositing tools" 22 | }, 23 | { 24 | "identifier": "SEQUENCE_EDITOR", 25 | "name": "Video Sequencer", 26 | "description": "Video editing tools" 27 | }, 28 | { 29 | "identifier": "CLIP_EDITOR", 30 | "name": "Movie Clip Editor", 31 | "description": "Motion tracking tools" 32 | }, 33 | { 34 | "identifier": "DOPESHEET_EDITOR", 35 | "name": "Dope Sheet", 36 | "description": "Adjust timing of keyframes" 37 | }, 38 | { 39 | "identifier": "GRAPH_EDITOR", 40 | "name": "Graph Editor", 41 | "description": "Edit drivers and keyframe interpolation" 42 | }, 43 | { 44 | "identifier": "NLA_EDITOR", 45 | "name": "Nonlinear Animation", 46 | "description": "Combine and layer Actions" 47 | }, 48 | { 49 | "identifier": "TEXT_EDITOR", 50 | "name": "Text Editor", 51 | "description": "Edit scripts and in-file documentation" 52 | }, 53 | { 54 | "identifier": "CONSOLE", 55 | "name": "Python Console", 56 | "description": "Interactive programmatic console for advanced editing and script development" 57 | }, 58 | { 59 | "identifier": "INFO", 60 | "name": "Info", 61 | "description": "Main menu bar and list of error messages (drag down to expand and display)" 62 | }, 63 | { 64 | "identifier": "TOPBAR", 65 | "name": "Top Bar", 66 | "description": "Global bar at the top of the screen for global per-window settings" 67 | }, 68 | { 69 | "identifier": "STATUSBAR", 70 | "name": "Status Bar", 71 | "description": "Global bar at the bottom of the screen for general status information" 72 | }, 73 | { 74 | "identifier": "OUTLINER", 75 | "name": "Outliner", 76 | "description": "Overview of scene graph and all available data-blocks" 77 | }, 78 | { 79 | "identifier": "PROPERTIES", 80 | "name": "Properties", 81 | "description": "Edit properties of active object and related data-blocks" 82 | }, 83 | { 84 | "identifier": "FILE_BROWSER", 85 | "name": "File Browser", 86 | "description": "Browse for files and assets" 87 | }, 88 | { 89 | "identifier": "USER_PREFERENCES", 90 | "name": "User Preferences", 91 | "description": "Edit persistent configuration settings" 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /icons/blender_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JacquesLucke/blender_vscode/b4c6ebba67172d9425f28533e0ece5cac1977da6/icons/blender_icon.ico -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blender-development", 3 | "displayName": "Blender Development", 4 | "description": "Tools to simplify Blender development.", 5 | "version": "0.0.26", 6 | "publisher": "JacquesLucke", 7 | "license": "MIT", 8 | "engines": { 9 | "vscode": "^1.63.0" 10 | }, 11 | "categories": [ 12 | "Other" 13 | ], 14 | "activationEvents": [ 15 | "onCommand:blender.start", 16 | "onCommand:blender.stop", 17 | "onCommand:blender.build", 18 | "onCommand:blender.buildAndStart", 19 | "onCommand:blender.startWithoutCDebugger", 20 | "onCommand:blender.buildPythonApiDocs", 21 | "onCommand:blender.reloadAddons", 22 | "onCommand:blender.newAddon", 23 | "onCommand:blender.newScript", 24 | "onCommand:blender.runScript", 25 | "onCommand:blender.setScriptContext", 26 | "onCommand:blender.openScriptsFolder", 27 | "onCommand:blender.newOperator", 28 | "onCommand:blender.openWithBlender", 29 | "onCommand:blender.openFiles" 30 | ], 31 | "main": "./out/extension", 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/JacquesLucke/blender_vscode" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/JacquesLucke/blender_vscode/issues" 38 | }, 39 | "contributes": { 40 | "commands": [ 41 | { 42 | "command": "blender.start", 43 | "title": "Start", 44 | "category": "Blender" 45 | }, 46 | { 47 | "command": "blender.stop", 48 | "title": "Stop", 49 | "category": "Blender" 50 | }, 51 | { 52 | "command": "blender.build", 53 | "title": "Build", 54 | "category": "Blender" 55 | }, 56 | { 57 | "command": "blender.buildAndStart", 58 | "title": "Build and Start", 59 | "category": "Blender" 60 | }, 61 | { 62 | "command": "blender.startWithoutCDebugger", 63 | "title": "Start without C Debugger", 64 | "category": "Blender" 65 | }, 66 | { 67 | "command": "blender.buildPythonApiDocs", 68 | "title": "Build Python API Docs", 69 | "category": "Blender" 70 | }, 71 | { 72 | "command": "blender.reloadAddons", 73 | "title": "Reload Addons", 74 | "category": "Blender" 75 | }, 76 | { 77 | "command": "blender.newAddon", 78 | "title": "New Addon", 79 | "category": "Blender" 80 | }, 81 | { 82 | "command": "blender.newScript", 83 | "title": "New Script", 84 | "category": "Blender" 85 | }, 86 | { 87 | "command": "blender.runScript", 88 | "title": "Run Script", 89 | "category": "Blender" 90 | }, 91 | { 92 | "command": "blender.setScriptContext", 93 | "title": "Set Script Context", 94 | "category": "Blender" 95 | }, 96 | { 97 | "command": "blender.openScriptsFolder", 98 | "title": "Open Scripts Folder", 99 | "category": "Blender" 100 | }, 101 | { 102 | "command": "blender.newOperator", 103 | "title": "New Operator", 104 | "category": "Blender" 105 | }, 106 | { 107 | "command": "blender.openWithBlender", 108 | "title": "Open With Blender", 109 | "category": "Blender" 110 | }, 111 | { 112 | "command": "blender.openFiles", 113 | "title": "Open File(s)", 114 | "category": "Blender" 115 | } 116 | ], 117 | "configuration": [ 118 | { 119 | "title": "Blender", 120 | "properties": { 121 | "blender.executables": { 122 | "type": "array", 123 | "scope": "resource", 124 | "description": "Paths to Blender executables.", 125 | "items": { 126 | "type": "object", 127 | "title": "Single Blender Path", 128 | "properties": { 129 | "path": { 130 | "type": "string", 131 | "description": "Absolute file path to a Blender executable." 132 | }, 133 | "name": { 134 | "type": "string", 135 | "description": "Custom name for this Blender version." 136 | 137 | }, 138 | "isDefault": { 139 | "type": "boolean", 140 | "description": "Set default choice for blender executable. The interactive choise will no longer be offered.", 141 | "default": false 142 | } 143 | } 144 | } 145 | }, 146 | "blender.allowModifyExternalPython": { 147 | "type": "boolean", 148 | "scope": "application", 149 | "default": false, 150 | "description": "Deprecated: automatically installing modules in Python distributions outside of Blender.", 151 | "markdownDeprecationMessage": "**Deprecated**: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`.", 152 | "deprecationMessage": "Deprecated: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`." 153 | }, 154 | "blender.addon.reloadOnSave": { 155 | "type": "boolean", 156 | "scope": "resource", 157 | "default": false, 158 | "description": "Reload addon in Blender when a document is saved." 159 | }, 160 | "blender.addon.justMyCode": { 161 | "type": "boolean", 162 | "scope": "resource", 163 | "default": true, 164 | "description": "If true, debug only the code in this addon. Otherwise, allow stepping into external python library code." 165 | }, 166 | "blender.addon.buildTaskName": { 167 | "type": "string", 168 | "scope": "resource", 169 | "description": "Task that should be executed before the addon can be loaded into Blender." 170 | }, 171 | "blender.addon.loadDirectory": { 172 | "type": "string", 173 | "scope": "resource", 174 | "default": "auto", 175 | "examples": [ 176 | "auto", 177 | "./", 178 | "./build" 179 | ], 180 | "description": "Directory that contains the addon that should be loaded into Blender." 181 | }, 182 | "blender.addon.sourceDirectory": { 183 | "type": "string", 184 | "scope": "resource", 185 | "default": "auto", 186 | "examples": [ 187 | "auto", 188 | "./", 189 | "./source" 190 | ], 191 | "description": "Directory that contains the source code of the addon (used for path mapping for the debugger)." 192 | }, 193 | "blender.addon.moduleName": { 194 | "type": "string", 195 | "scope": "resource", 196 | "default": "auto", 197 | "examples": [ 198 | "auto", 199 | "my_addon_name" 200 | ], 201 | "description": "Name or the symlink that is created in Blenders addon folder." 202 | }, 203 | "blender.addon.extensionsRepository": { 204 | "type": "string", 205 | "scope": "resource", 206 | "default": "vscode_development", 207 | "examples": [ 208 | "vscode_development", 209 | "user_default", 210 | "blender_org" 211 | ], 212 | "description": "Blender extensions only: repository to use when developing addon. \nBlender -> Preferences -> Get Extensions -> Repositories (dropdown, top right)", 213 | "pattern": "^[\\w_]+$", 214 | "patternErrorMessage": "Must be valid name of Python module (allowed: lower/upper case, underscore)" 215 | }, 216 | "blender.core.buildDebugCommand": { 217 | "type": "string", 218 | "scope": "resource", 219 | "description": "Command used to compile Blender.", 220 | "default": "make debug", 221 | "deprecationMessage": "Removed in version 0.0.27" 222 | }, 223 | "blender.scripts.directories": { 224 | "type": "array", 225 | "scope": "application", 226 | "description": "Directories to store scripts in.", 227 | "items": { 228 | "type": "object", 229 | "title": "Single Script Directory", 230 | "properties": { 231 | "path": { 232 | "type": "string", 233 | "description": "Absolute file path to a Blender executable." 234 | }, 235 | "name": { 236 | "type": "string", 237 | "description": "Custom name for this Blender version." 238 | } 239 | } 240 | } 241 | }, 242 | "blender.addonFolders": { 243 | "type": "array", 244 | "scope": "resource", 245 | "description": "Array of paths to addon folders. Relative folders are resolved from the path of root workspace. If empty workspace folders are used.", 246 | "items": { 247 | "type": "string" 248 | } 249 | }, 250 | "blender.environmentVariables": { 251 | "type": "object", 252 | "scope": "resource", 253 | "title": "Startup Environment Variables", 254 | "description": "Environment variables set before Blender starts. Keys of the object are used as environment variable names, values as values.", 255 | "examples": [ 256 | { 257 | "BLENDER_USER_SCRIPTS": "C:/custom_scripts_folder", 258 | "BLENDER_USER_CONFIG": "C:/custom_user_config" 259 | } 260 | ] 261 | }, 262 | "blender.additionalArguments": { 263 | "type": "array", 264 | "scope": "resource", 265 | "title": "Command Line Additional Arguments", 266 | "markdownDescription": "Additional arguments used for starting Blender via the `Blender: Start` command. One argument per line. Note: option `--python` is already used by `blender_vscode` extension.", 267 | "items": { 268 | "type": "string" 269 | }, 270 | "examples": [ 271 | [ 272 | "--factory-startup" 273 | ] 274 | ] 275 | }, 276 | "blender.preFileArguments": { 277 | "type": "array", 278 | "scope": "resource", 279 | "title": "Command Line Arguments: Before File Path", 280 | "markdownDescription": "Arguments passed **before** file path, used only with `Open With Blender`(right click menu) and `Blender: Open File(s)` commands.\n\nPopulates 'preFileArgs' in `blender.exe [preFileArgs ...] [file] [postFileArgs ...]`", 281 | "items": { 282 | "type": "string" 283 | }, 284 | "examples": [ 285 | [ 286 | "--window-fullscreen" 287 | ] 288 | ] 289 | }, 290 | "blender.postFileArguments": { 291 | "type": "array", 292 | "scope": "resource", 293 | "title": "Command Line Arguments: After File Path", 294 | "markdownDescription": "Arguments passed **after** file path, used only with `Open With Blender`(right click menu) and `Blender: Open File(s)` commands.\n\nPopulates 'preFileArgs' in `blender.exe [preFileArgs ...] [file] [postFileArgs ...]`", 295 | "items": { 296 | "type": "string" 297 | }, 298 | "examples": [ 299 | [ 300 | "--render-output", 301 | "/tmp" 302 | ] 303 | ] 304 | }, 305 | "blender.addon.logLevel": { 306 | "type": "string", 307 | "scope": "resource", 308 | "description": "Log level for blender_vscode extension inside Blender. Debug is most verbose.", 309 | "default": "info", 310 | "enum": [ 311 | "debug-with-flask", 312 | "debug", 313 | "info", 314 | "warning", 315 | "error", 316 | "critical" 317 | ] 318 | } 319 | } 320 | } 321 | ], 322 | "menus": { 323 | "commandPalette": [ 324 | { 325 | "command": "blender.openWithBlender", 326 | "when": "false" 327 | } 328 | ], 329 | "explorer/context": [ 330 | { 331 | "command": "blender.openWithBlender", 332 | "group": "navigation", 333 | "when": "resourceScheme == file && resourceExtname == .blend" 334 | } 335 | ] 336 | }, 337 | "languages": [ 338 | { 339 | "id": "blend", 340 | "extensions": [ 341 | ".blend" 342 | ], 343 | "aliases": [ 344 | "Blend File", 345 | "blend" 346 | ], 347 | "icon": { 348 | "light": "./icons/blender_icon.ico", 349 | "dark": "./icons/blender_icon.ico" 350 | }, 351 | "filenames": [ 352 | "blender", 353 | "blender.exe", 354 | "blender-launcher", 355 | "blender-launcher.exe" 356 | ] 357 | } 358 | ] 359 | }, 360 | "scripts": { 361 | "vscode:prepublish": "npm run compile", 362 | "compile": "tsc -p ./", 363 | "watch": "tsc -watch -p ./" 364 | }, 365 | "devDependencies": { 366 | "@types/mocha": "^2.2.42", 367 | "@types/node": "^8.10.25", 368 | "@types/request": "^2.48.1", 369 | "@types/vscode": "^1.28.0", 370 | "tslint": "^5.8.0", 371 | "typescript": "^5.5.2" 372 | }, 373 | "dependencies": { 374 | "request": "^2.87.0" 375 | }, 376 | "extensionDependencies": [ 377 | "ms-python.python" 378 | ] 379 | } 380 | -------------------------------------------------------------------------------- /pythonFiles/generate_data.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import json 3 | from pathlib import Path 4 | 5 | output_dir = Path(__file__).parent.parent / "generated" 6 | enums_output_path = output_dir / "enums.json" 7 | 8 | 9 | def insert_enum_data(data, identifier): 10 | type_name, prop_name = identifier.split(".") 11 | enum_name = type_name.lower() + prop_name.title() + "Items" 12 | data[enum_name] = enum_prop_to_dict(type_name, prop_name) 13 | 14 | 15 | def enum_prop_to_dict(type_name, prop_name): 16 | type = getattr(bpy.types, type_name) 17 | prop = type.bl_rna.properties[prop_name] 18 | return enum_items_to_dict(prop.enum_items) 19 | 20 | 21 | def enum_items_to_dict(items): 22 | return [{"identifier": item.identifier, "name": item.name, "description": item.description} for item in items] 23 | 24 | 25 | data = {} 26 | insert_enum_data(data, "Area.type") 27 | 28 | with open(enums_output_path, "w") as f: 29 | f.write(json.dumps(data, indent=2)) 30 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pprint import pformat 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import List 6 | 7 | import bpy 8 | 9 | from . import log 10 | 11 | LOG = log.getLogger() 12 | 13 | 14 | @dataclass 15 | class AddonInfo: 16 | load_dir: Path 17 | module_name: str 18 | 19 | 20 | def startup(editor_address, addons_to_load: List[AddonInfo]): 21 | if bpy.app.version < (2, 80, 34): 22 | handle_fatal_error("Please use a newer version of Blender") 23 | 24 | from . import installation 25 | 26 | # blender 2.80 'ssl' module is compiled with 'OpenSSL 1.1.0h' what breaks with requests >2.29.0 27 | installation.ensure_packages_are_installed(["debugpy", "requests<=2.29.0", "werkzeug<=3.0.3", "flask<=3.0.3"]) 28 | 29 | from . import load_addons 30 | 31 | path_mappings = load_addons.setup_addon_links(addons_to_load) 32 | 33 | from . import communication 34 | 35 | communication.setup(editor_address, path_mappings) 36 | 37 | from . import operators, ui 38 | 39 | ui.register() 40 | operators.register() 41 | 42 | load_addons.load(addons_to_load) 43 | 44 | 45 | def handle_fatal_error(message): 46 | print() 47 | print("#" * 80) 48 | for line in message.splitlines(): 49 | print("> ", line) 50 | print("#" * 80) 51 | print(f"PATHONPATH: {pformat(sys.path)}") 52 | print() 53 | sys.exit(1) 54 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/communication.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import threading 4 | import time 5 | from functools import partial 6 | from typing import Callable, Dict 7 | 8 | import debugpy 9 | import flask 10 | import requests 11 | 12 | from .environment import LOG_FLASK, VSCODE_IDENTIFIER, blender_path, scripts_folder, python_path 13 | from .utils import run_in_main_thread 14 | from . import log 15 | 16 | LOG = log.getLogger() 17 | 18 | EDITOR_ADDRESS = None 19 | OWN_SERVER_PORT = None 20 | DEBUGPY_PORT = None 21 | 22 | SERVER = flask.Flask("Blender Server") 23 | SERVER.logger.setLevel(logging.DEBUG if LOG_FLASK else logging.ERROR) 24 | POST_HANDLERS = {} 25 | 26 | 27 | def setup(address: str, path_mappings): 28 | global EDITOR_ADDRESS, OWN_SERVER_PORT, DEBUGPY_PORT 29 | EDITOR_ADDRESS = address 30 | 31 | OWN_SERVER_PORT = start_own_server() 32 | DEBUGPY_PORT = start_debug_server() 33 | 34 | send_connection_information(path_mappings) 35 | 36 | LOG.info("Waiting for debug client.") 37 | debugpy.wait_for_client() 38 | LOG.info("Debug client attached.") 39 | 40 | 41 | def start_own_server(): 42 | port = [None] 43 | 44 | def server_thread_function(): 45 | while True: 46 | try: 47 | port[0] = get_random_port() 48 | SERVER.run(debug=True, port=port[0], use_reloader=False) 49 | except OSError: 50 | pass 51 | 52 | thread = threading.Thread(target=server_thread_function) 53 | thread.daemon = True 54 | thread.start() 55 | 56 | while port[0] is None: 57 | time.sleep(0.01) 58 | 59 | return port[0] 60 | 61 | 62 | def start_debug_server(): 63 | while True: 64 | port = get_random_port() 65 | try: 66 | # for < 2.92 support (debugpy has problems when using bpy.app.binary_path_python) 67 | # https://github.com/microsoft/debugpy/issues/1330 68 | debugpy.configure(python=str(python_path)) 69 | debugpy.listen(("localhost", port)) 70 | break 71 | except OSError: 72 | pass 73 | return port 74 | 75 | 76 | # Server 77 | ######################################### 78 | 79 | 80 | @SERVER.route("/", methods=["POST"]) 81 | def handle_post(): 82 | data = flask.request.get_json() 83 | LOG.debug(f"Got POST: {data}") 84 | 85 | if data["type"] in POST_HANDLERS: 86 | return POST_HANDLERS[data["type"]](data) 87 | else: 88 | LOG.warning(f"Unhandled POST: {data}") 89 | 90 | return "OK" 91 | 92 | 93 | @SERVER.route("/", methods=["GET"]) 94 | def handle_get(): 95 | data = flask.request.get_json() 96 | LOG.debug(f"Got GET: {str(data)}") 97 | 98 | if data["type"] == "ping": 99 | pass 100 | elif data["type"] == "complete": 101 | from .blender_complete import complete 102 | 103 | return {"items": complete(data)} 104 | else: 105 | LOG.warning(f"Unhandled GET: {data}") 106 | return "OK" 107 | 108 | 109 | def register_post_handler(type: str, handler: Callable): 110 | assert type not in POST_HANDLERS, POST_HANDLERS 111 | POST_HANDLERS[type] = handler 112 | 113 | 114 | def register_post_action(type: str, handler: Callable): 115 | def request_handler_wrapper(data): 116 | run_in_main_thread(partial(handler, data)) 117 | return "OK" 118 | 119 | register_post_handler(type, request_handler_wrapper) 120 | 121 | 122 | # Sending Data 123 | ############################### 124 | 125 | 126 | def send_connection_information(path_mappings: Dict): 127 | send_dict_as_json( 128 | { 129 | "type": "setup", 130 | "blenderPort": OWN_SERVER_PORT, 131 | "debugpyPort": DEBUGPY_PORT, 132 | "blenderPath": str(blender_path), 133 | "scriptsFolder": str(scripts_folder), 134 | "addonPathMappings": path_mappings, 135 | "vscodeIdentifier": VSCODE_IDENTIFIER, 136 | } 137 | ) 138 | 139 | 140 | def send_dict_as_json(data): 141 | LOG.debug(f"Sending: {data}") 142 | requests.post(EDITOR_ADDRESS, json=data) 143 | 144 | 145 | # Utils 146 | ############################### 147 | 148 | 149 | def get_random_port(): 150 | return random.randint(2000, 10000) 151 | 152 | 153 | def get_blender_port(): 154 | return OWN_SERVER_PORT 155 | 156 | 157 | def get_debugpy_port(): 158 | return DEBUGPY_PORT 159 | 160 | 161 | def get_editor_address(): 162 | return EDITOR_ADDRESS 163 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/environment.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from pathlib import Path 5 | from typing import Optional 6 | from typing import Tuple 7 | 8 | import addon_utils 9 | import bpy 10 | 11 | _str_to_log_level = { 12 | "debug-with-flask": logging.DEBUG, 13 | "debug": logging.DEBUG, 14 | "info": logging.INFO, 15 | "warning": logging.WARNING, 16 | "error": logging.ERROR, 17 | "critical": logging.CRITICAL, 18 | } 19 | 20 | 21 | def _parse_log(env_var_name: str) -> Tuple[int, bool]: 22 | log_env_global = os.environ.get(env_var_name, "info") or "info" 23 | try: 24 | enable_flask_logs = "debug-with-flask" == log_env_global 25 | return _str_to_log_level[log_env_global], enable_flask_logs 26 | except KeyError as e: 27 | logging.warning(f"Log level for {env_var_name} not set: {e}") 28 | return logging.WARNING, False 29 | 30 | 31 | # binary_path_python was removed in blender 2.92 32 | # but it is the most reliable way of getting python path for older versions 33 | # https://github.com/JacquesLucke/blender_vscode/issues/80 34 | python_path = Path(getattr(bpy.app, "binary_path_python", sys.executable)) 35 | blender_path = Path(bpy.app.binary_path) 36 | blender_directory = blender_path.parent 37 | 38 | version = bpy.app.version 39 | scripts_folder = blender_path.parent / f"{version[0]}.{version[1]}" / "scripts" 40 | addon_directories = tuple(map(Path, addon_utils.paths())) 41 | 42 | EXTENSIONS_REPOSITORY: Optional[str] = os.environ.get("VSCODE_EXTENSIONS_REPOSITORY", "user_default") or "user_default" 43 | LOG_LEVEL, LOG_FLASK = _parse_log("VSCODE_LOG_LEVEL") 44 | VSCODE_IDENTIFIER: Optional[str] = os.environ.get("VSCODE_IDENTIFIER", "") or "" 45 | 46 | logging.getLogger("werkzeug").setLevel(logging.DEBUG if LOG_FLASK else logging.ERROR) 47 | # to mute all logs, disable also those logs. Be careful, the libs are extremely popular and it will mute logs for everyone! 48 | # logging.getLogger("requests").setLevel(logging.DEBUG if LOG_FLASK else logging.INFO) 49 | # logging.getLogger("urllib3").setLevel(logging.DEBUG if LOG_FLASK else logging.INFO) 50 | 51 | VSCODE_IDENTIFIER: Optional[str] = os.environ.get("VSCODE_IDENTIFIER", "") or "" -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/installation.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import bpy 5 | 6 | from pathlib import Path 7 | 8 | from . import handle_fatal_error 9 | from . import log 10 | from .environment import python_path 11 | 12 | LOG = log.getLogger() 13 | _CWD_FOR_SUBPROCESSES = python_path.parent 14 | 15 | 16 | def ensure_packages_are_installed(package_names): 17 | if packages_are_installed(package_names): 18 | return 19 | 20 | install_packages(package_names) 21 | 22 | 23 | def packages_are_installed(package_names): 24 | return all(module_can_be_imported(name) for name in package_names) 25 | 26 | 27 | def install_packages(package_names): 28 | if not module_can_be_imported("pip"): 29 | install_pip() 30 | 31 | for name in package_names: 32 | ensure_package_is_installed(name) 33 | 34 | assert packages_are_installed(package_names) 35 | 36 | 37 | def ensure_package_is_installed(name: str): 38 | if not module_can_be_imported(name): 39 | install_package(name) 40 | 41 | 42 | def install_package(name: str): 43 | target = get_package_install_directory() 44 | command = [str(python_path), "-m", "pip", "install", name, "--target", target] 45 | LOG.info(f"Execute: {' '.join(command)}") 46 | subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES) 47 | 48 | if not module_can_be_imported(name): 49 | handle_fatal_error(f"could not install {name}") 50 | 51 | 52 | def install_pip(): 53 | # try ensurepip before get-pip.py 54 | if module_can_be_imported("ensurepip"): 55 | command = [str(python_path), "-m", "ensurepip", "--upgrade"] 56 | LOG.info(f"Execute: {' '.join(command)}") 57 | subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES) 58 | return 59 | # pip can not necessarily be imported into Blender after this 60 | get_pip_path = Path(__file__).parent / "external" / "get-pip.py" 61 | subprocess.run([str(python_path), str(get_pip_path)], cwd=_CWD_FOR_SUBPROCESSES) 62 | 63 | 64 | def get_package_install_directory() -> str: 65 | # user modules loaded are loaded by default by blender from this path 66 | # https://docs.blender.org/manual/en/4.2/editors/preferences/file_paths.html#script-directories 67 | modules_path = bpy.utils.user_resource("SCRIPTS", path="modules") 68 | if modules_path not in sys.path: 69 | # if the path does not exist blender will not load it, usually occurs in fresh install 70 | sys.path.append(modules_path) 71 | return modules_path 72 | 73 | 74 | def module_can_be_imported(name: str): 75 | try: 76 | stripped_name = _strip_pip_version(name) 77 | mod = __import__(stripped_name) 78 | LOG.info("module: " + name + " is already installed") 79 | LOG.debug(stripped_name + ":" + getattr(mod ,"__version__", "None") + " in path: " + getattr(mod, "__file__", "None")) 80 | return True 81 | except ModuleNotFoundError: 82 | return False 83 | 84 | 85 | def _strip_pip_version(name: str) -> str: 86 | name_strip_comparison_sign = name.replace(">", "=").replace("<", "=") 87 | return name_strip_comparison_sign.split("=")[0] 88 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/load_addons.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import traceback 5 | from pathlib import Path 6 | from typing import List, Union, Optional, Dict 7 | 8 | import bpy 9 | 10 | from . import AddonInfo, log 11 | from .communication import send_dict_as_json 12 | from .environment import addon_directories, EXTENSIONS_REPOSITORY 13 | from .utils import is_addon_legacy, addon_has_bl_info 14 | 15 | LOG = log.getLogger() 16 | 17 | if bpy.app.version >= (4, 2, 0): 18 | _EXTENSIONS_DEFAULT_DIR = Path(bpy.utils.user_resource("EXTENSIONS", path=EXTENSIONS_REPOSITORY)) 19 | else: 20 | _EXTENSIONS_DEFAULT_DIR = None 21 | _ADDONS_DEFAULT_DIR = Path(bpy.utils.user_resource("SCRIPTS", path="addons")) 22 | 23 | 24 | def setup_addon_links(addons_to_load: List[AddonInfo]) -> List[Dict]: 25 | path_mappings: List[Dict] = [] 26 | 27 | # always make sure addons are in path, important when running fresh blender install 28 | # do it always to avoid very confusing logic in the loop below 29 | os.makedirs(_ADDONS_DEFAULT_DIR, exist_ok=True) 30 | if str(_ADDONS_DEFAULT_DIR) not in sys.path: 31 | sys.path.append(str(_ADDONS_DEFAULT_DIR)) 32 | 33 | remove_broken_addon_links() 34 | if bpy.app.version >= (4, 2, 0): 35 | ensure_extension_repo_exists(EXTENSIONS_REPOSITORY) 36 | remove_broken_extension_links() 37 | 38 | for addon_info in addons_to_load: 39 | try: 40 | load_path = _link_addon_or_extension(addon_info) 41 | except PermissionError as e: 42 | LOG.error( 43 | f"""ERROR: {e} 44 | Path "{e.filename}" can not be removed. **Please remove it manually!** Most likely causes: 45 | - Path requires admin permissions to remove 46 | - Windows only: You upgraded Blender version and imported old setting. Now links became real directories. 47 | - Path is a real directory with the same name as addon (removing might cause data loss!)""" 48 | ) 49 | raise e 50 | else: 51 | path_mappings.append({"src": str(addon_info.load_dir), "load": str(load_path)}) 52 | 53 | return path_mappings 54 | 55 | 56 | def _link_addon_or_extension(addon_info: AddonInfo) -> Path: 57 | if is_addon_legacy(addon_info.load_dir): 58 | if is_in_any_addon_directory(addon_info.load_dir): 59 | # blender knows about addon and can load it 60 | load_path = addon_info.load_dir 61 | else: # addon is in external dir or is in extensions dir 62 | _remove_duplicate_addon_links(addon_info) 63 | load_path = _ADDONS_DEFAULT_DIR / addon_info.module_name 64 | create_link_in_user_addon_directory(addon_info.load_dir, load_path) 65 | else: 66 | if addon_has_bl_info(addon_info.load_dir) and is_in_any_addon_directory(addon_info.load_dir): 67 | # this addon is compatible with legacy addons and extensions 68 | # but user is developing it in addon directory. Treat it as addon. 69 | load_path = addon_info.load_dir 70 | elif is_in_any_extension_directory(addon_info.load_dir): 71 | # blender knows about extension and can load it 72 | load_path = addon_info.load_dir 73 | else: 74 | # blender does not know about extension, and it must be linked to default location 75 | _remove_duplicate_extension_links(addon_info) 76 | _remove_duplicate_addon_links(addon_info) 77 | os.makedirs(_EXTENSIONS_DEFAULT_DIR, exist_ok=True) 78 | load_path = _EXTENSIONS_DEFAULT_DIR / addon_info.module_name 79 | create_link_in_user_addon_directory(addon_info.load_dir, load_path) 80 | return load_path 81 | 82 | 83 | def _remove_duplicate_addon_links(addon_info: AddonInfo): 84 | existing_addon_with_the_same_target = does_addon_link_exist(addon_info.load_dir) 85 | while existing_addon_with_the_same_target: 86 | if existing_addon_with_the_same_target: 87 | LOG.info(f"Removing old link: {existing_addon_with_the_same_target}") 88 | os.remove(existing_addon_with_the_same_target) 89 | existing_addon_with_the_same_target = does_addon_link_exist(addon_info.load_dir) 90 | 91 | 92 | def _remove_duplicate_extension_links(addon_info: AddonInfo): 93 | existing_extension_with_the_same_target = does_extension_link_exist(addon_info.load_dir) 94 | while existing_extension_with_the_same_target: 95 | if existing_extension_with_the_same_target: 96 | LOG.info(f"Removing old link: {existing_extension_with_the_same_target}") 97 | os.remove(existing_extension_with_the_same_target) 98 | existing_extension_with_the_same_target = does_extension_link_exist(addon_info.load_dir) 99 | 100 | 101 | def _resolve_link_windows_cmd(path: Path) -> Optional[str]: 102 | IO_REPARSE_TAG_MOUNT_POINT = "0xa0000003" 103 | JUNCTION_INDICATOR = f"Reparse Tag Value : {IO_REPARSE_TAG_MOUNT_POINT}" 104 | try: 105 | output = subprocess.check_output(["fsutil", "reparsepoint", "query", str(path)]) 106 | except subprocess.CalledProcessError: 107 | return None 108 | output_lines = output.decode().split(os.linesep) 109 | if not output_lines[0].startswith(JUNCTION_INDICATOR): 110 | return None 111 | TARGET_PATH_INDICATOR = "Print Name: " 112 | for line in output_lines: 113 | if line.startswith(TARGET_PATH_INDICATOR): 114 | possible_target = line[len(TARGET_PATH_INDICATOR) :] 115 | if os.path.exists(possible_target): 116 | return possible_target 117 | 118 | 119 | def _resolve_link(path: Path) -> Optional[str]: 120 | """Return target if is symlink or junction""" 121 | try: 122 | return os.readlink(str(path)) 123 | except OSError as e: 124 | # OSError: [WinError 4390] The file or directory is not a reparse point 125 | if sys.platform == "win32": 126 | if e.winerror == 4390: 127 | return None 128 | else: 129 | # OSError: [Errno 22] Invalid argument: '/snap/blender/5088/4.2/extensions/system/readme.txt' 130 | if e.errno == 22: 131 | return None 132 | LOG.warning(f"can not resolve link target {e}") 133 | return None 134 | except ValueError as e: 135 | # there are major differences in python windows junction support (3.7.0 and 3.7.9 give different errors) 136 | if sys.platform == "win32": 137 | return _resolve_link_windows_cmd(path) 138 | else: 139 | LOG.warning(f"can not resolve link target {e}") 140 | return None 141 | 142 | 143 | def does_addon_link_exist(development_directory: Path) -> Optional[Path]: 144 | """Search default addon path and return first path that links to `development_directory`""" 145 | for file in os.listdir(_ADDONS_DEFAULT_DIR): 146 | existing_addon_dir = Path(_ADDONS_DEFAULT_DIR, file) 147 | target = _resolve_link(existing_addon_dir) 148 | if target: 149 | windows_being_windows = target.lstrip(r"\\?") 150 | if Path(windows_being_windows) == Path(development_directory): 151 | return existing_addon_dir 152 | return None 153 | 154 | 155 | def does_extension_link_exist(development_directory: Path) -> Optional[Path]: 156 | """Search all available extension paths and return path that links to `development_directory""" 157 | for repo in bpy.context.preferences.extensions.repos: 158 | if not repo.enabled: 159 | continue 160 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 161 | if not os.path.isdir(repo_dir): 162 | continue # repo dir might not exist 163 | for file in os.listdir(repo_dir): 164 | existing_extension_dir = Path(repo_dir, file) 165 | target = _resolve_link(existing_extension_dir) 166 | if target: 167 | windows_being_windows = target.lstrip(r"\\?") 168 | if Path(windows_being_windows) == Path(development_directory): 169 | return existing_extension_dir 170 | return None 171 | 172 | 173 | def ensure_extension_repo_exists(extensions_repository: str): 174 | for repo in bpy.context.preferences.extensions.repos: 175 | repo: bpy.types.UserExtensionRepo 176 | if repo.module == extensions_repository: 177 | return repo 178 | LOG.debug(f'New extensions repository "{extensions_repository}" created') 179 | return bpy.context.preferences.extensions.repos.new(name=extensions_repository, module=extensions_repository) 180 | 181 | 182 | def remove_broken_addon_links(): 183 | for file in os.listdir(_ADDONS_DEFAULT_DIR): 184 | addon_dir = _ADDONS_DEFAULT_DIR / file 185 | if not addon_dir.is_dir(): 186 | continue 187 | target = _resolve_link(addon_dir) 188 | if target and not os.path.exists(target): 189 | LOG.info(f"Removing invalid link: {addon_dir} -> {target}") 190 | os.remove(addon_dir) 191 | 192 | 193 | def remove_broken_extension_links(): 194 | for repo in bpy.context.preferences.extensions.repos: 195 | if not repo.enabled: 196 | continue 197 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 198 | repo_dir = Path(repo_dir) 199 | if not repo_dir.is_dir(): 200 | continue 201 | for file in os.listdir(repo_dir): 202 | existing_extension_dir = repo_dir / file 203 | target = _resolve_link(existing_extension_dir) 204 | if target and not os.path.exists(target): 205 | LOG.info(f"Removing invalid link: {existing_extension_dir} -> {target}") 206 | os.remove(existing_extension_dir) 207 | 208 | 209 | def load(addons_to_load: List[AddonInfo]): 210 | for addon_info in addons_to_load: 211 | if is_addon_legacy(Path(addon_info.load_dir)): 212 | bpy.ops.preferences.addon_refresh() 213 | addon_name = addon_info.module_name 214 | elif addon_has_bl_info(addon_info.load_dir) and is_in_any_addon_directory(addon_info.load_dir): 215 | # this addon is compatible with legacy addons and extensions 216 | # but user is developing it in addon directory. Treat it as addon. 217 | bpy.ops.preferences.addon_refresh() 218 | addon_name = addon_info.module_name 219 | else: 220 | bpy.ops.extensions.repo_refresh_all() 221 | addon_name = "bl_ext." + EXTENSIONS_REPOSITORY + "." + addon_info.module_name 222 | 223 | try: 224 | bpy.ops.preferences.addon_enable(module=addon_name) 225 | except Exception: 226 | traceback.print_exc() 227 | send_dict_as_json({"type": "enableFailure", "addonPath": str(addon_info.load_dir)}) 228 | 229 | 230 | def create_link_in_user_addon_directory(directory: Union[str, os.PathLike], link_path: Union[str, os.PathLike]): 231 | if os.path.exists(link_path): 232 | os.remove(link_path) 233 | 234 | if sys.platform == "win32": 235 | import _winapi 236 | 237 | _winapi.CreateJunction(str(directory), str(link_path)) 238 | else: 239 | os.symlink(str(directory), str(link_path), target_is_directory=True) 240 | 241 | 242 | def is_in_any_addon_directory(module_path: Path) -> bool: 243 | for path in addon_directories: 244 | if path == module_path.parent: 245 | return True 246 | return False 247 | 248 | 249 | def is_in_any_extension_directory(module_path: Path) -> Optional["bpy.types.UserExtensionRepo"]: 250 | for repo in bpy.context.preferences.extensions.repos: 251 | if not repo.enabled: 252 | continue 253 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 254 | if Path(repo_dir) == module_path.parent: 255 | return repo 256 | return None 257 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .environment import LOG_LEVEL 4 | 5 | 6 | class ColoredFormatter(logging.Formatter): 7 | white = "\x1b[1;37;20m" 8 | grey = "\x1b[1;38;20m" 9 | yellow = "\x1b[1;33;20m" 10 | red = "\x1b[1;31;20m" 11 | bold_red = "\x1b[1;31;1m" 12 | reset = "\x1b[1;0m" 13 | format = "%(levelname)s: %(message)s (%(filename)s:%(lineno)d)" 14 | 15 | FORMATS = { 16 | logging.DEBUG: grey + format + reset, 17 | logging.INFO: white + format + reset, 18 | logging.WARNING: yellow + format + reset, 19 | logging.ERROR: red + format + reset, 20 | logging.CRITICAL: bold_red + format + reset, 21 | } 22 | 23 | def format(self, record): 24 | log_fmt = self.FORMATS.get(record.levelno) 25 | formatter = logging.Formatter(log_fmt) 26 | return formatter.format(record) 27 | 28 | 29 | def getLogger(name: str = "blender_vs"): 30 | logging.getLogger().setLevel(LOG_LEVEL) 31 | 32 | log = logging.getLogger(name) 33 | if log.handlers: 34 | # log is already configured 35 | return log 36 | log.propagate = False 37 | log.setLevel(LOG_LEVEL) 38 | 39 | # create console handler with a higher log level 40 | ch = logging.StreamHandler() 41 | ch.setLevel(logging.DEBUG) 42 | 43 | ch.setFormatter(ColoredFormatter()) 44 | 45 | log.addHandler(ch) 46 | 47 | return log 48 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/__init__.py: -------------------------------------------------------------------------------- 1 | from . import addon_update 2 | from . import script_runner 3 | from . import stop_blender 4 | 5 | modules = ( 6 | addon_update, 7 | script_runner, 8 | stop_blender, 9 | ) 10 | 11 | 12 | def register(): 13 | for module in modules: 14 | module.register() 15 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/addon_update.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from pathlib import Path 4 | 5 | import bpy 6 | from bpy.props import * 7 | 8 | from ..environment import EXTENSIONS_REPOSITORY 9 | from ..utils import addon_has_bl_info 10 | from ..load_addons import is_in_any_addon_directory 11 | from ..communication import send_dict_as_json, register_post_action 12 | from ..utils import is_addon_legacy, redraw_all 13 | 14 | 15 | class UpdateAddonOperator(bpy.types.Operator): 16 | bl_idname = "dev.update_addon" 17 | bl_label = "Update Addon" 18 | 19 | module_name: StringProperty() 20 | 21 | def execute(self, context): 22 | try: 23 | bpy.ops.preferences.addon_disable(module=self.module_name) 24 | except Exception: 25 | traceback.print_exc() 26 | send_dict_as_json({"type": "disableFailure"}) 27 | return {"CANCELLED"} 28 | 29 | for name in list(sys.modules.keys()): 30 | if name == self.module_name or name.startswith(self.module_name + "."): 31 | del sys.modules[name] 32 | 33 | try: 34 | bpy.ops.preferences.addon_enable(module=self.module_name) 35 | except Exception: 36 | traceback.print_exc() 37 | send_dict_as_json({"type": "enableFailure"}) 38 | return {"CANCELLED"} 39 | 40 | send_dict_as_json({"type": "addonUpdated"}) 41 | 42 | redraw_all() 43 | return {"FINISHED"} 44 | 45 | 46 | def reload_addon_action(data): 47 | module_names = [] 48 | for name, dir in zip(data["names"], data["dirs"]): 49 | if is_addon_legacy(Path(dir)): 50 | module_names.append(name) 51 | elif addon_has_bl_info(Path(dir)) and is_in_any_addon_directory(Path(dir)): 52 | # this addon is compatible with legacy addons and extensions 53 | # but user is developing it in addon directory. Treat it as addon. 54 | module_names.append(name) 55 | else: 56 | module_names.append("bl_ext." + EXTENSIONS_REPOSITORY + "." + name) 57 | 58 | for name in module_names: 59 | bpy.ops.dev.update_addon(module_name=name) 60 | 61 | 62 | def register(): 63 | bpy.utils.register_class(UpdateAddonOperator) 64 | register_post_action("reload", reload_addon_action) 65 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/script_runner.py: -------------------------------------------------------------------------------- 1 | import re 2 | import bpy 3 | import runpy 4 | from pprint import pformat 5 | from bpy.props import * 6 | from ..utils import redraw_all 7 | from ..communication import register_post_action 8 | from .. import log 9 | 10 | LOG = log.getLogger() 11 | 12 | 13 | class RunScriptOperator(bpy.types.Operator): 14 | bl_idname = "dev.run_script" 15 | bl_label = "Run Script" 16 | 17 | filepath: StringProperty() 18 | 19 | def execute(self, context): 20 | ctx = prepare_script_context(self.filepath) 21 | LOG.info(f'Run script: "{self.filepath}"') 22 | LOG.debug(f"Run script context override: {pformat(ctx)}") 23 | runpy.run_path(self.filepath, init_globals={"CTX": ctx}) 24 | redraw_all() 25 | return {"FINISHED"} 26 | 27 | 28 | def run_script_action(data): 29 | path = data["path"] 30 | context = prepare_script_context(path) 31 | 32 | if bpy.app.version < (4, 0, 0): 33 | bpy.ops.dev.run_script(context, filepath=path) 34 | return 35 | 36 | with bpy.context.temp_override(**context): 37 | bpy.ops.dev.run_script(filepath=path) 38 | 39 | 40 | def prepare_script_context(filepath): 41 | with open(filepath) as fs: 42 | text = fs.read() 43 | 44 | area_type = "VIEW_3D" 45 | region_type = "WINDOW" 46 | 47 | for line in text.splitlines(): 48 | match = re.match(r"^\s*#\s*context\.area\s*:\s*(\w+)", line, re.IGNORECASE) 49 | if match: 50 | area_type = match.group(1) 51 | 52 | context = {} 53 | context["window_manager"] = bpy.data.window_managers[0] 54 | context["window"] = context["window_manager"].windows[0] 55 | context["scene"] = context["window"].scene 56 | context["view_layer"] = context["window"].view_layer 57 | context["screen"] = context["window"].screen 58 | context["workspace"] = context["window"].workspace 59 | context["area"] = get_area_by_type(area_type) 60 | context["region"] = get_region_in_area(context["area"], region_type) if context["area"] else None 61 | return context 62 | 63 | 64 | def get_area_by_type(area_type): 65 | for area in bpy.data.window_managers[0].windows[0].screen.areas: 66 | if area.type == area_type: 67 | return area 68 | return None 69 | 70 | 71 | def get_region_in_area(area, region_type): 72 | for region in area.regions: 73 | if region.type == region_type: 74 | return region 75 | return None 76 | 77 | 78 | def register(): 79 | bpy.utils.register_class(RunScriptOperator) 80 | register_post_action("script", run_script_action) 81 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/stop_blender.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from ..communication import register_post_action 3 | 4 | 5 | def stop_action(data): 6 | bpy.ops.wm.quit_blender() 7 | 8 | 9 | def register(): 10 | register_post_action("stop", stop_action) 11 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .communication import get_blender_port, get_debugpy_port, get_editor_address 3 | 4 | 5 | class DevelopmentPanel(bpy.types.Panel): 6 | bl_idname = "DEV_PT_panel" 7 | bl_label = "Development" 8 | bl_space_type = "VIEW_3D" 9 | bl_region_type = "UI" 10 | bl_category = "Dev" 11 | 12 | def draw(self, context): 13 | layout = self.layout 14 | layout.label(text=f"Blender at Port {get_blender_port()}") 15 | layout.label(text=f"debugpy at Port {get_debugpy_port()}") 16 | layout.label(text=f"Editor at Address {get_editor_address()}") 17 | 18 | 19 | classes = (DevelopmentPanel,) 20 | 21 | 22 | def register(): 23 | for cls in classes: 24 | bpy.utils.register_class(cls) 25 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from pathlib import Path 3 | import bpy 4 | import queue 5 | import traceback 6 | 7 | 8 | def is_addon_legacy(addon_dir: Path) -> bool: 9 | """Return whether an addon uses the legacy bl_info behavior, or the new blender_manifest behavior""" 10 | if bpy.app.version < (4, 2, 0): 11 | return True 12 | if not (addon_dir / "blender_manifest.toml").exists(): 13 | return True 14 | return False 15 | 16 | 17 | def addon_has_bl_info(addon_dir: Path) -> bool: 18 | """Perform best effort check to find bl_info. Does not perform an import on file to avoid code execution.""" 19 | with open(addon_dir / "__init__.py") as init_addon_file: 20 | node = ast.parse(init_addon_file.read()) 21 | for element in node.body: 22 | if not isinstance(element, ast.Assign): 23 | continue 24 | for target in element.targets: 25 | if not isinstance(target, ast.Name): 26 | continue 27 | if target.id == "bl_info": 28 | return True 29 | return False 30 | 31 | 32 | def redraw_all(): 33 | for window in bpy.context.window_manager.windows: 34 | for area in window.screen.areas: 35 | area.tag_redraw() 36 | 37 | 38 | def get_prefixes(all_names, separator): 39 | return set(name.split(separator)[0] for name in all_names if separator in name) 40 | 41 | 42 | execution_queue = queue.Queue() 43 | 44 | 45 | def run_in_main_thread(func): 46 | execution_queue.put(func) 47 | 48 | 49 | def always(): 50 | while not execution_queue.empty(): 51 | func = execution_queue.get() 52 | try: 53 | func() 54 | except Exception: 55 | traceback.print_exc() 56 | return 0.1 57 | 58 | 59 | bpy.app.timers.register(always, persistent=True) 60 | -------------------------------------------------------------------------------- /pythonFiles/launch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import json 5 | import traceback 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | include_dir = Path(__file__).parent / "include" 10 | sys.path.append(str(include_dir)) 11 | 12 | # Get proper type hinting without impacting runtime 13 | if TYPE_CHECKING: 14 | from .include import blender_vscode 15 | else: 16 | import blender_vscode 17 | 18 | LOG = blender_vscode.log.getLogger() 19 | LOG.info(f"ADDONS_TO_LOAD {json.loads(os.environ['ADDONS_TO_LOAD'])}") 20 | 21 | try: 22 | addons_to_load = [] 23 | for info in json.loads(os.environ["ADDONS_TO_LOAD"]): 24 | addon_info = blender_vscode.AddonInfo(**info) 25 | addon_info.load_dir = Path(addon_info.load_dir) 26 | addons_to_load.append(addon_info) 27 | 28 | blender_vscode.startup( 29 | editor_address=f"http://localhost:{os.environ['EDITOR_PORT']}", 30 | addons_to_load=addons_to_load, 31 | ) 32 | except Exception as e: 33 | if type(e) is not SystemExit: 34 | traceback.print_exc() 35 | sys.exit() 36 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/simple/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | bl_info = { 15 | "name": "ADDON_NAME", 16 | "author": "AUTHOR_NAME", 17 | "description": "", 18 | "blender": (2, 80, 0), 19 | "version": (0, 0, 1), 20 | "location": "", 21 | "warning": "", 22 | "category": "Generic", 23 | } 24 | 25 | 26 | def register(): ... 27 | 28 | 29 | def unregister(): ... 30 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/with_auto_load/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | bl_info = { 15 | "name": "ADDON_NAME", 16 | "author": "AUTHOR_NAME", 17 | "description": "", 18 | "blender": (2, 80, 0), 19 | "version": (0, 0, 1), 20 | "location": "", 21 | "warning": "", 22 | "category": "Generic", 23 | } 24 | 25 | from . import auto_load 26 | 27 | auto_load.init() 28 | 29 | 30 | def register(): 31 | auto_load.register() 32 | 33 | 34 | def unregister(): 35 | auto_load.unregister() 36 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/with_auto_load/auto_load.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import typing 3 | import inspect 4 | import pkgutil 5 | import importlib 6 | from pathlib import Path 7 | 8 | __all__ = ( 9 | "init", 10 | "register", 11 | "unregister", 12 | ) 13 | 14 | blender_version = bpy.app.version 15 | 16 | modules = None 17 | ordered_classes = None 18 | 19 | 20 | def init(): 21 | global modules 22 | global ordered_classes 23 | 24 | modules = get_all_submodules(Path(__file__).parent) 25 | ordered_classes = get_ordered_classes_to_register(modules) 26 | 27 | 28 | def register(): 29 | for cls in ordered_classes: 30 | bpy.utils.register_class(cls) 31 | 32 | for module in modules: 33 | if module.__name__ == __name__: 34 | continue 35 | if hasattr(module, "register"): 36 | module.register() 37 | 38 | 39 | def unregister(): 40 | for cls in reversed(ordered_classes): 41 | bpy.utils.unregister_class(cls) 42 | 43 | for module in modules: 44 | if module.__name__ == __name__: 45 | continue 46 | if hasattr(module, "unregister"): 47 | module.unregister() 48 | 49 | 50 | # Import modules 51 | ################################################# 52 | 53 | 54 | def get_all_submodules(directory): 55 | return list(iter_submodules(directory, __package__)) 56 | 57 | 58 | def iter_submodules(path, package_name): 59 | for name in sorted(iter_submodule_names(path)): 60 | yield importlib.import_module("." + name, package_name) 61 | 62 | 63 | def iter_submodule_names(path, root=""): 64 | for _, module_name, is_package in pkgutil.iter_modules([str(path)]): 65 | if is_package: 66 | sub_path = path / module_name 67 | sub_root = root + module_name + "." 68 | yield from iter_submodule_names(sub_path, sub_root) 69 | else: 70 | yield root + module_name 71 | 72 | 73 | # Find classes to register 74 | ################################################# 75 | 76 | 77 | def get_ordered_classes_to_register(modules): 78 | return toposort(get_register_deps_dict(modules)) 79 | 80 | 81 | def get_register_deps_dict(modules): 82 | my_classes = set(iter_my_classes(modules)) 83 | my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} 84 | 85 | deps_dict = {} 86 | for cls in my_classes: 87 | deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) 88 | return deps_dict 89 | 90 | 91 | def iter_my_register_deps(cls, my_classes, my_classes_by_idname): 92 | yield from iter_my_deps_from_annotations(cls, my_classes) 93 | yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) 94 | 95 | 96 | def iter_my_deps_from_annotations(cls, my_classes): 97 | for value in typing.get_type_hints(cls, {}, {}).values(): 98 | dependency = get_dependency_from_annotation(value) 99 | if dependency is not None: 100 | if dependency in my_classes: 101 | yield dependency 102 | 103 | 104 | def get_dependency_from_annotation(value): 105 | if blender_version >= (2, 93): 106 | if isinstance(value, bpy.props._PropertyDeferred): 107 | return value.keywords.get("type") 108 | else: 109 | if isinstance(value, tuple) and len(value) == 2: 110 | if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): 111 | return value[1]["type"] 112 | return None 113 | 114 | 115 | def iter_my_deps_from_parent_id(cls, my_classes_by_idname): 116 | if issubclass(cls, bpy.types.Panel): 117 | parent_idname = getattr(cls, "bl_parent_id", None) 118 | if parent_idname is not None: 119 | parent_cls = my_classes_by_idname.get(parent_idname) 120 | if parent_cls is not None: 121 | yield parent_cls 122 | 123 | 124 | def iter_my_classes(modules): 125 | base_types = get_register_base_types() 126 | for cls in get_classes_in_modules(modules): 127 | if any(issubclass(cls, base) for base in base_types): 128 | if not getattr(cls, "is_registered", False): 129 | yield cls 130 | 131 | 132 | def get_classes_in_modules(modules): 133 | classes = set() 134 | for module in modules: 135 | for cls in iter_classes_in_module(module): 136 | classes.add(cls) 137 | return classes 138 | 139 | 140 | def iter_classes_in_module(module): 141 | for value in module.__dict__.values(): 142 | if inspect.isclass(value): 143 | yield value 144 | 145 | 146 | def get_register_base_types(): 147 | return set( 148 | getattr(bpy.types, name) 149 | for name in [ 150 | "Panel", 151 | "Operator", 152 | "PropertyGroup", 153 | "AddonPreferences", 154 | "Header", 155 | "Menu", 156 | "Node", 157 | "NodeSocket", 158 | "NodeTree", 159 | "UIList", 160 | "RenderEngine", 161 | "Gizmo", 162 | "GizmoGroup", 163 | ] 164 | ) 165 | 166 | 167 | # Find order to register to solve dependencies 168 | ################################################# 169 | 170 | 171 | def toposort(deps_dict): 172 | sorted_list = [] 173 | sorted_values = set() 174 | while len(deps_dict) > 0: 175 | unsorted = [] 176 | sorted_list_sub = [] # helper for additional sorting by bl_order - in panels 177 | for value, deps in deps_dict.items(): 178 | if len(deps) == 0: 179 | sorted_list_sub.append(value) 180 | sorted_values.add(value) 181 | else: 182 | unsorted.append(value) 183 | deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted} 184 | sorted_list_sub.sort(key=lambda cls: getattr(cls, "bl_order", 0)) 185 | sorted_list.extend(sorted_list_sub) 186 | return sorted_list 187 | -------------------------------------------------------------------------------- /pythonFiles/templates/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | 3 | # Example of manifest file for a Blender extension 4 | # Change the values according to your extension 5 | id = "ADDON_ID" 6 | version = "1.0.0" 7 | name = "ADDON_NAME" 8 | tagline = "This is another extension" 9 | maintainer = "AUTHOR_NAME" 10 | # Supported types: "add-on", "theme" 11 | type = "add-on" 12 | 13 | # Optional link to documentation, support, source files, etc 14 | # website = "https://extensions.blender.org/add-ons/my-example-package/" 15 | 16 | # Optional list defined by Blender and server, see: 17 | # https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html 18 | tags = ["Animation", "Sequencer"] 19 | 20 | blender_version_min = "4.2.0" 21 | # # Optional: Blender version that the extension does not support, earlier versions are supported. 22 | # # This can be omitted and defined later on the extensions platform if an issue is found. 23 | # blender_version_max = "5.1.0" 24 | 25 | # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) 26 | # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html 27 | license = [ 28 | "SPDX:GPL-3.0-or-later", 29 | ] 30 | # Optional: required by some licenses. 31 | # copyright = [ 32 | # "2002-2024 Developer Name", 33 | # "1998 Company Name", 34 | # ] 35 | 36 | # Optional list of supported platforms. If omitted, the extension will be available in all operating systems. 37 | # platforms = ["windows-x64", "macos-arm64", "linux-x64"] 38 | # Other supported platforms: "windows-arm64", "macos-x64" 39 | 40 | # Optional: bundle 3rd party Python modules. 41 | # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html 42 | # wheels = [ 43 | # "./wheels/hexdump-3.3-py3-none-any.whl", 44 | # "./wheels/jsmin-3.0.1-py3-none-any.whl", 45 | # ] 46 | 47 | # Optional: add-ons can list which resources they will require: 48 | # * files (for access of any filesystem operations) 49 | # * network (for internet access) 50 | # * clipboard (to read and/or write the system clipboard) 51 | # * camera (to capture photos and videos) 52 | # * microphone (to capture audio) 53 | # 54 | # If using network, remember to also check `bpy.app.online_access` 55 | # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access 56 | # 57 | # For each permission it is important to also specify the reason why it is required. 58 | # Keep this a single short sentence without a period (.) at the end. 59 | # For longer explanations use the documentation or detail page. 60 | # 61 | # [permissions] 62 | # network = "Need to sync motion-capture data to server" 63 | # files = "Import/export FBX from/to disk" 64 | # clipboard = "Copy and paste bone transforms" 65 | 66 | # Optional: build settings. 67 | # https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build 68 | # [build] 69 | # paths_exclude_pattern = [ 70 | # "__pycache__/", 71 | # "/.git/", 72 | # "/*.zip", 73 | # ] 74 | -------------------------------------------------------------------------------- /pythonFiles/templates/operator_simple.py: -------------------------------------------------------------------------------- 1 | class CLASS_NAME(OPERATOR_CLASS): 2 | bl_idname = "IDNAME" 3 | bl_label = "LABEL" 4 | 5 | def execute(self, context): 6 | return {"FINISHED"} 7 | -------------------------------------------------------------------------------- /pythonFiles/templates/panel_simple.py: -------------------------------------------------------------------------------- 1 | class CLASS_NAME(PANEL_CLASS): 2 | bl_idname = "IDNAME" 3 | bl_label = "LABEL" 4 | bl_space_type = "SPACE_TYPE" 5 | bl_region_type = "REGION_TYPE" 6 | 7 | def draw(self, context): 8 | layout = self.layout 9 | -------------------------------------------------------------------------------- /pythonFiles/templates/script.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from mathutils import * 3 | 4 | D = bpy.data 5 | C = bpy.context 6 | -------------------------------------------------------------------------------- /pythonFiles/tests/blender_vscode/test_load_addons.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os.path 3 | import sys 4 | from pathlib import Path 5 | from typing import Dict 6 | from unittest.mock import MagicMock, patch, Mock, PropertyMock 7 | 8 | import pytest 9 | 10 | 11 | @pytest.fixture(scope="function", autouse=True) 12 | def bpy_global_defaults(request: pytest.FixtureRequest): 13 | # selection of modules provided with blender 14 | # when fake-bpy-module is installed: override it 15 | # when bpy is not available: provide Mock for further patching 16 | sys.modules["bpy"] = Mock() 17 | sys.modules["addon_utils"] = Mock() 18 | # DANGER: patching imports with global scope. Use returned patches to modify those values. 19 | # those defaults are required by global variables in blender_vscode.environment 20 | # todo those values are different for different blender versions 21 | patches = { 22 | "bpy.app": patch( 23 | "bpy.app", 24 | binary_path="/bin/usr/blender", 25 | # binary_path_python="/bin/usr/blender/Lib/bin/python", # enable to emulate blender <2.92 26 | version=(4, 2, 0), 27 | spec_set=[ 28 | "binary_path", 29 | "version", 30 | "timers", 31 | # "binary_path_python", # enable to emulate blender <2.92 32 | ], 33 | ), 34 | "bpy.utils.user_resource": patch("bpy.utils.user_resource", side_effect=bpy_utils_user_resource), 35 | "addon_utils.paths": patch("addon_utils.paths", return_value=[]), 36 | } 37 | with contextlib.ExitStack() as stack: 38 | active_patches = {key: stack.enter_context(value) for key, value in patches.items()} 39 | yield active_patches 40 | 41 | # unload modules 42 | for module_name in [k for k in sys.modules.keys()]: 43 | if ( 44 | module_name.startswith("blender_vscode") 45 | or module_name.startswith("bpy") 46 | or module_name.startswith("addon_utils") 47 | ): 48 | try: 49 | del sys.modules[module_name] 50 | except: 51 | pass 52 | 53 | 54 | def bpy_utils_user_resource(resource_type, path=None): 55 | if resource_type == "SCRIPTS": 56 | return os.path.sep.join(("", "4.2", "scripts", path)) 57 | elif resource_type == "EXTENSIONS": 58 | return os.path.sep.join(("", "4.2", "extensions", path)) 59 | else: 60 | raise ValueError("This resource is not supported in tests") 61 | 62 | 63 | @patch("blender_vscode.load_addons.sys", path=[]) 64 | @patch("blender_vscode.load_addons.os.makedirs") 65 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=True) 66 | @patch("blender_vscode.load_addons.create_link_in_user_addon_directory") 67 | @patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": []}) 68 | @patch("blender_vscode.load_addons.os.listdir", return_value=[]) 69 | class TestSetupAddonLinksDevelopAddon: 70 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 71 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=False) 72 | def test_setup_addon_links_develop_addon_in_external_dir( 73 | self, 74 | is_in_any_extension_directory: MagicMock, 75 | is_in_any_addon_directory: MagicMock, 76 | listdir: MagicMock, 77 | bpy_context: MagicMock, 78 | create_link_in_user_addon_directory: MagicMock, 79 | is_addon_legacy: MagicMock, 80 | makedirs: MagicMock, 81 | sys_mock: MagicMock, 82 | ): 83 | """Example: user is developing addon in `/home/user/blenderProject`""" 84 | from blender_vscode import AddonInfo 85 | from blender_vscode.load_addons import setup_addon_links 86 | 87 | addons_to_load = [AddonInfo(load_dir=Path("/home/user/blenderProject/test-addon"), module_name="test_addon")] 88 | 89 | mappings = setup_addon_links(addons_to_load=addons_to_load) 90 | 91 | assert mappings == [ 92 | { 93 | "src": os.path.sep.join("/home/user/blenderProject/test-addon".split("/")), 94 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 95 | } 96 | ] 97 | is_addon_legacy.assert_called_once() 98 | is_in_any_extension_directory.assert_not_called() 99 | create_link_in_user_addon_directory.assert_called_once_with( 100 | Path("/home/user/blenderProject/test-addon"), 101 | Path("/4.2/scripts/addons/test_addon"), 102 | ) 103 | 104 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 105 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=True) 106 | def test_setup_addon_links_develop_addon_in_extension_dir( 107 | self, 108 | is_in_any_extension_directory: MagicMock, 109 | is_in_any_addon_directory: MagicMock, 110 | listdir: MagicMock, 111 | bpy_context: MagicMock, 112 | create_link_in_user_addon_directory: MagicMock, 113 | is_addon_legacy: MagicMock, 114 | makedirs: MagicMock, 115 | sys_mock: MagicMock, 116 | ): 117 | """Example: user is developing addon in `/4.2/scripts/extensions/blender_org`""" 118 | from blender_vscode import AddonInfo 119 | from blender_vscode.load_addons import setup_addon_links 120 | 121 | addons_to_load = [ 122 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-addon"), module_name="test_addon") 123 | ] 124 | 125 | mappings = setup_addon_links(addons_to_load=addons_to_load) 126 | 127 | assert mappings == [ 128 | { 129 | "src": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-addon".split("/")), 130 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 131 | } 132 | ] 133 | is_addon_legacy.assert_called_once() 134 | create_link_in_user_addon_directory.assert_called_once() 135 | is_in_any_extension_directory.assert_not_called() 136 | 137 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 138 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=False) 139 | def test_setup_addon_links_develop_addon_in_addon_dir( 140 | self, 141 | is_in_any_extension_directory: MagicMock, 142 | is_in_any_addon_directory: MagicMock, 143 | listdir: MagicMock, 144 | bpy_context: MagicMock, 145 | create_link_in_user_addon_directory: MagicMock, 146 | is_addon_legacy: MagicMock, 147 | makedirs: MagicMock, 148 | sys_mock: MagicMock, 149 | ): 150 | """Example: user is developing addon in `/4.2/scripts/extensions/blender_org`""" 151 | from blender_vscode import AddonInfo 152 | from blender_vscode.load_addons import setup_addon_links 153 | 154 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test_addon"), module_name="test_addon")] 155 | 156 | mappings = setup_addon_links(addons_to_load=addons_to_load) 157 | 158 | assert mappings == [ 159 | { 160 | "src": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 161 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 162 | } 163 | ] 164 | is_in_any_addon_directory.assert_called_once() 165 | create_link_in_user_addon_directory.assert_not_called() 166 | is_in_any_extension_directory.assert_not_called() 167 | 168 | 169 | @patch("blender_vscode.load_addons.sys", path=[]) 170 | @patch("blender_vscode.load_addons.os.makedirs") 171 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 172 | @patch("blender_vscode.load_addons.create_link_in_user_addon_directory") 173 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=None) 174 | @patch("blender_vscode.load_addons.addon_has_bl_info", return_value=False) 175 | @patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": []}) 176 | @patch("blender_vscode.load_addons.os.listdir", return_value=[]) 177 | class TestSetupAddonLinksDevelopExtension: 178 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 179 | def test_setup_addon_links_develop_extension_in_addon_dir_is_treated_as_addon( 180 | self, 181 | is_in_any_addon_directory: MagicMock, 182 | listdir: MagicMock, 183 | bpy_context: MagicMock, 184 | addon_has_bl_info: MagicMock, 185 | is_in_any_extension_directory: MagicMock, 186 | create_link_in_user_addon_directory: MagicMock, 187 | is_addon_legacy: MagicMock, 188 | makedirs: MagicMock, 189 | sys_mock: MagicMock, 190 | ): 191 | """Example: user is developing extension in `/4.2/scripts/addon/test-extension` **but** extension supports legacy addons""" 192 | addon_has_bl_info.return_value = True 193 | is_in_any_extension_directory.return_value = None 194 | 195 | from blender_vscode import AddonInfo 196 | from blender_vscode.load_addons import setup_addon_links 197 | 198 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-extension"), module_name="test_extension")] 199 | 200 | mappings = setup_addon_links(addons_to_load=addons_to_load) 201 | 202 | assert mappings == [ 203 | { 204 | "src": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 205 | "load": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 206 | } 207 | ] 208 | create_link_in_user_addon_directory.assert_not_called() 209 | 210 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 211 | def test_setup_addon_links_develop_extension_in_extension_dir( 212 | self, 213 | is_in_any_addon_directory: MagicMock, 214 | listdir: MagicMock, 215 | bpy_context: MagicMock, 216 | addon_has_bl_info: MagicMock, 217 | is_in_any_extension_directory: MagicMock, 218 | create_link_in_user_addon_directory: MagicMock, 219 | is_addon_legacy: MagicMock, 220 | makedirs: MagicMock, 221 | sys_mock: MagicMock, 222 | ): 223 | """Example: user is developing extension in `/4.2/scripts/extensions/blender_org`""" 224 | repo_mock = Mock( 225 | enabled=True, 226 | use_custom_directory=False, 227 | custom_directory="", 228 | directory="/4.2/scripts/extensions/blender_org", 229 | ) 230 | is_in_any_extension_directory.return_value = repo_mock 231 | 232 | from blender_vscode import AddonInfo 233 | from blender_vscode.load_addons import setup_addon_links 234 | 235 | addons_to_load = [ 236 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-extension"), module_name="test_extension") 237 | ] 238 | 239 | mappings = setup_addon_links(addons_to_load=addons_to_load) 240 | 241 | assert mappings == [ 242 | { 243 | "src": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-extension".split("/")), 244 | "load": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-extension".split("/")), 245 | } 246 | ] 247 | is_in_any_addon_directory.assert_not_called() 248 | create_link_in_user_addon_directory.assert_not_called() 249 | is_in_any_extension_directory.assert_called() 250 | 251 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 252 | def test_setup_addon_links_develop_extension_in_addon_dir( 253 | self, 254 | is_in_any_addon_directory: MagicMock, 255 | listdir: MagicMock, 256 | bpy_context: MagicMock, 257 | addon_has_bl_info: MagicMock, 258 | is_in_any_extension_directory: MagicMock, 259 | create_link_in_user_addon_directory: MagicMock, 260 | is_addon_legacy: MagicMock, 261 | makedirs: MagicMock, 262 | sys_mock: MagicMock, 263 | ): 264 | """Example: user is developing extension in `/4.2/scripts/addons/test-extension`""" 265 | is_in_any_extension_directory.return_value = None 266 | 267 | from blender_vscode import AddonInfo 268 | from blender_vscode.load_addons import setup_addon_links 269 | 270 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-extension"), module_name="test_extension")] 271 | 272 | mappings = setup_addon_links(addons_to_load=addons_to_load) 273 | 274 | assert mappings == [ 275 | { 276 | "src": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 277 | "load": os.path.sep.join("/4.2/extensions/user_default/test_extension".split("/")), 278 | } 279 | ] 280 | is_in_any_addon_directory.assert_not_called() 281 | create_link_in_user_addon_directory.assert_called_once() 282 | is_in_any_extension_directory.assert_called() 283 | 284 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 285 | def test_setup_addon_links_develop_extension_in_external_dir( 286 | self, 287 | is_in_any_addon_directory: MagicMock, 288 | listdir: MagicMock, 289 | bpy_context: MagicMock, 290 | addon_has_bl_info: MagicMock, 291 | is_in_any_extension_directory: MagicMock, 292 | create_link_in_user_addon_directory: MagicMock, 293 | is_addon_legacy: MagicMock, 294 | makedirs: MagicMock, 295 | sys_mock: MagicMock, 296 | ): 297 | """Example: user is developing extension in `/home/user/blenderProjects/test-extension`""" 298 | from blender_vscode.load_addons import setup_addon_links 299 | from blender_vscode import AddonInfo 300 | 301 | addons_to_load = [ 302 | AddonInfo(load_dir=Path("/home/user/blenderProject/test-extension"), module_name="test_extension") 303 | ] 304 | 305 | mappings = setup_addon_links(addons_to_load=addons_to_load) 306 | 307 | assert mappings == [ 308 | { 309 | "src": os.path.sep.join("/home/user/blenderProject/test-extension".split("/")), 310 | "load": os.path.sep.join("/4.2/extensions/user_default/test_extension".split("/")), 311 | } 312 | ] 313 | create_link_in_user_addon_directory.assert_called_once() 314 | is_in_any_extension_directory.assert_called() 315 | 316 | 317 | class TestIsInAnyAddonDirectory: 318 | def test_is_in_any_addon_directory(self, bpy_global_defaults: Dict): 319 | bpy_global_defaults["addon_utils.paths"].return_value = ["/4.2/scripts/addons"] 320 | 321 | import blender_vscode.load_addons as load_addons 322 | 323 | ret = load_addons.is_in_any_addon_directory(Path("/4.2/scripts/addons/my-addon1")) 324 | assert ret 325 | 326 | ret = load_addons.is_in_any_addon_directory(Path("scripts/my-addon2")) 327 | assert not ret 328 | 329 | 330 | class TestIsInAnyExtensionDirectory: 331 | def test_is_in_any_extension_directory(self): 332 | repo_mock = Mock( 333 | enabled=True, 334 | use_custom_directory=False, 335 | custom_directory="", 336 | directory="/4.2/scripts/extensions/blender_org", 337 | ) 338 | with patch("blender_vscode.load_addons.bpy", **{"context.preferences.extensions.repos": [repo_mock]}) as repos: 339 | from blender_vscode import load_addons 340 | 341 | ret = load_addons.is_in_any_extension_directory(Path("/4.2/scripts/addons/my-addon1")) 342 | assert ret is None 343 | 344 | ret = load_addons.is_in_any_extension_directory(Path("/4.2/scripts/extensions/blender_org/my-addon2")) 345 | assert ret is repo_mock 346 | 347 | 348 | @patch("blender_vscode.load_addons.bpy.ops.preferences.addon_refresh") 349 | @patch("blender_vscode.load_addons.bpy.ops.preferences.addon_enable") 350 | @patch("blender_vscode.load_addons.bpy.ops.extensions.repo_refresh_all") 351 | @patch("blender_vscode.load_addons.addon_has_bl_info", return_value=False) 352 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 353 | class TestLoad: 354 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=True) 355 | def test_load_legacy_addon_from_addons_dir( 356 | self, 357 | is_addon_legacy: MagicMock, 358 | addon_has_bl_info: MagicMock, 359 | is_in_any_addon_directory: MagicMock, 360 | repo_refresh_all: MagicMock, 361 | addon_enable: MagicMock, 362 | addon_refresh: MagicMock, 363 | ): 364 | from blender_vscode import AddonInfo 365 | 366 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-addon"), module_name="test-addon")] 367 | from blender_vscode.load_addons import load 368 | 369 | load(addons_to_load=addons_to_load) 370 | 371 | addon_enable.assert_called_once_with(module="test-addon") 372 | is_addon_legacy.assert_called_once() 373 | addon_refresh.assert_called_once() 374 | repo_refresh_all.assert_not_called() 375 | 376 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 377 | def test_load_extension_from_extensions_dir( 378 | self, 379 | is_addon_legacy: MagicMock, 380 | addon_has_bl_info: MagicMock, 381 | is_in_any_addon_directory: MagicMock, 382 | repo_refresh_all: MagicMock, 383 | addon_enable: MagicMock, 384 | addon_refresh: MagicMock, 385 | ): 386 | repo_mock = Mock( 387 | enabled=True, 388 | use_custom_directory=False, 389 | custom_directory="", 390 | directory="/4.2/scripts/extensions/blender_org", 391 | module="blender_org", 392 | ) 393 | 394 | with patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": [repo_mock]}): 395 | from blender_vscode import AddonInfo 396 | 397 | addons_to_load = [ 398 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-addon2"), module_name="testaddon2"), 399 | ] 400 | 401 | from blender_vscode.load_addons import load 402 | 403 | load(addons_to_load=addons_to_load) 404 | 405 | addon_enable.assert_called_once_with(module="bl_ext.blender_org.testaddon2") 406 | is_addon_legacy.assert_called_once() 407 | repo_refresh_all.assert_called_once() 408 | addon_refresh.assert_not_called() 409 | 410 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 411 | def test_load_extension_extension_in_addon_dir_is_treated_as_addon( 412 | self, 413 | is_addon_legacy: MagicMock, 414 | addon_has_bl_info: MagicMock, 415 | is_in_any_addon_directory: MagicMock, 416 | repo_refresh_all: MagicMock, 417 | addon_enable: MagicMock, 418 | addon_refresh: MagicMock, 419 | ): 420 | addon_has_bl_info.return_value = True 421 | is_in_any_addon_directory.return_value = True 422 | from blender_vscode import AddonInfo 423 | 424 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-addon"), module_name="test-addon")] 425 | from blender_vscode.load_addons import load 426 | 427 | load(addons_to_load=addons_to_load) 428 | 429 | addon_enable.assert_called_once_with(module="test-addon") 430 | is_addon_legacy.assert_called_once() 431 | addon_refresh.assert_called_once() 432 | repo_refresh_all.assert_not_called() 433 | -------------------------------------------------------------------------------- /src/addon_folder.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { 4 | getConfig, readTextFile, getWorkspaceFolders, 5 | getSubfolders, executeTask, getAnyWorkspaceFolder, pathExists 6 | } from './utils'; 7 | 8 | // TODO: It would be superior to use custom AddonFolder interface that is not bound to the 9 | // vscode.WorkspaceFolder directly. The 'uri' property is only one used at this point. 10 | 11 | export class AddonWorkspaceFolder { 12 | folder: vscode.WorkspaceFolder; 13 | 14 | constructor(folder: vscode.WorkspaceFolder) { 15 | this.folder = folder; 16 | } 17 | 18 | public static async All() { 19 | // Search folders specified by settings first, if nothing is specified 20 | // search workspace folders instead. 21 | let addonFolders = await foldersToWorkspaceFoldersMockup( 22 | getConfig().get('addonFolders')); 23 | 24 | let searchableFolders = addonFolders.length !== 0 ? addonFolders : getWorkspaceFolders(); 25 | 26 | let folders = []; 27 | for (let folder of searchableFolders) { 28 | let addon = new AddonWorkspaceFolder(folder); 29 | if (await addon.hasAddonEntryPoint()) { 30 | folders.push(addon); 31 | } 32 | } 33 | return folders; 34 | } 35 | 36 | get uri() { 37 | return this.folder.uri; 38 | } 39 | 40 | get buildTaskName() { 41 | return this.getConfig().get('addon.buildTaskName'); 42 | } 43 | 44 | get reloadOnSave() { 45 | return this.getConfig().get('addon.reloadOnSave'); 46 | } 47 | 48 | get justMyCode() { 49 | return this.getConfig().get('addon.justMyCode'); 50 | } 51 | 52 | public async hasAddonEntryPoint() { 53 | try { 54 | let sourceDir = await this.getSourceDirectory(); 55 | return folderContainsAddonEntry(sourceDir); 56 | } 57 | catch (err) { 58 | return false; 59 | } 60 | } 61 | 62 | public async buildIfNecessary() { 63 | let taskName = this.buildTaskName; 64 | if (taskName === '') return Promise.resolve(); 65 | await executeTask(taskName, true); 66 | } 67 | 68 | public getConfig() { 69 | return getConfig(this.uri); 70 | } 71 | 72 | public async getLoadDirectoryAndModuleName() { 73 | let load_dir = await this.getLoadDirectory(); 74 | let module_name = await this.getModuleName(); 75 | return { 76 | 'load_dir' : load_dir, 77 | 'module_name' : module_name, 78 | }; 79 | } 80 | 81 | public async getModuleName() { 82 | let value = getConfig(this.uri).get('addon.moduleName'); 83 | if (value === 'auto') { 84 | return path.basename(await this.getLoadDirectory()); 85 | } 86 | else { 87 | return value; 88 | } 89 | } 90 | 91 | public async getLoadDirectory() { 92 | let value = getConfig(this.uri).get('addon.loadDirectory'); 93 | if (value === 'auto') { 94 | return this.getSourceDirectory(); 95 | } 96 | else { 97 | return this.makePathAbsolute(value); 98 | } 99 | } 100 | 101 | public async getSourceDirectory() { 102 | let value = getConfig(this.uri).get('addon.sourceDirectory'); 103 | if (value === 'auto') { 104 | return await tryFindActualAddonFolder(this.uri.fsPath); 105 | } 106 | else { 107 | return this.makePathAbsolute(value); 108 | } 109 | } 110 | 111 | private makePathAbsolute(directory: string) { 112 | if (path.isAbsolute(directory)) { 113 | return directory; 114 | } 115 | else { 116 | return path.join(this.uri.fsPath, directory); 117 | } 118 | } 119 | } 120 | 121 | async function tryFindActualAddonFolder(root: string) { 122 | if (await folderContainsAddonEntry(root)) return root; 123 | for (let folder of await getSubfolders(root)) { 124 | if (await folderContainsAddonEntry(folder)) { 125 | return folder; 126 | } 127 | } 128 | return Promise.reject(new Error('cannot find actual addon code, please set the path in the settings')); 129 | } 130 | 131 | async function folderContainsAddonEntry(folderPath: string) { 132 | let manifestPath = path.join(folderPath, "blender_manifest.toml"); 133 | if (await pathExists(manifestPath)) { 134 | return true; 135 | } 136 | 137 | let initPath = path.join(folderPath, '__init__.py'); 138 | try { 139 | let content = await readTextFile(initPath); 140 | return content.includes('bl_info'); 141 | } 142 | catch { 143 | return false; 144 | } 145 | } 146 | 147 | async function foldersToWorkspaceFoldersMockup(folders: string[]) { 148 | let mockups: vscode.WorkspaceFolder[] = []; 149 | // Assume this functionality is only used with a single workspace folder for now. 150 | let rootFolder = getAnyWorkspaceFolder(); 151 | for (let i = 0; i < folders.length; i++) { 152 | let absolutePath; 153 | if (path.isAbsolute(folders[i])) { 154 | absolutePath = folders[i]; 155 | } else { 156 | absolutePath = path.join(rootFolder.uri.fsPath, folders[i]) 157 | } 158 | 159 | let exists = await pathExists(absolutePath); 160 | if (!exists) { 161 | vscode.window.showInformationMessage( 162 | `Revise settings, path to addon doesn't exist ${absolutePath}`); 163 | continue; 164 | } 165 | 166 | mockups.push({ 167 | "name" : path.basename(absolutePath), 168 | "uri": vscode.Uri.from({ scheme: "file", path: absolutePath }), 169 | "index": i 170 | }); 171 | } 172 | return mockups; 173 | } 174 | -------------------------------------------------------------------------------- /src/blender_executable.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | import * as vscode from 'vscode'; 4 | import * as child_process from 'child_process'; 5 | import * as fs from 'fs'; 6 | import * as util from 'util'; 7 | 8 | import { launchPath } from './paths'; 9 | import { getServerPort, RunningBlenders } from './communication'; 10 | import { letUserPickItem, PickItem } from './select_utils'; 11 | import { getConfig, cancel, runTask, getAnyWorkspaceFolder, getRandomString } from './utils'; 12 | import { AddonWorkspaceFolder } from './addon_folder'; 13 | import { outputChannel, showNotificationAddDefault } from './extension'; 14 | import { getBlenderWindows } from './blender_executable_windows'; 15 | import { deduplicateSameHardLinks } from './blender_executable_linux'; 16 | 17 | 18 | const stat = util.promisify(fs.stat) 19 | 20 | export async function LaunchAnyInteractive(blend_filepaths?: string[], script?: string) { 21 | const executable = await getFilteredBlenderPath({ 22 | label: 'Blender Executable', 23 | selectNewLabel: 'Choose a new Blender executable...', 24 | predicate: () => true, 25 | setSettings: () => { } 26 | }); 27 | showNotificationAddDefault(executable) 28 | return await LaunchAny(executable, blend_filepaths, script) 29 | } 30 | 31 | export async function LaunchAny(executable: BlenderExecutableData, blend_filepaths?: string[], script?: string) { 32 | if (blend_filepaths === undefined || !blend_filepaths.length) { 33 | await launch(executable, undefined, script); 34 | return; 35 | } 36 | for (const blend_filepath of blend_filepaths) { 37 | await launch(executable, blend_filepath, script); 38 | } 39 | } 40 | 41 | export class BlenderTask { 42 | task: vscode.TaskExecution 43 | script?: string 44 | vscodeIdentifier: string 45 | 46 | constructor(task: vscode.TaskExecution, vscode_identifier: string, script?: string) { 47 | this.task = task 48 | this.script = script 49 | this.vscodeIdentifier = vscode_identifier 50 | } 51 | 52 | public onStartDebugging() { 53 | if (this.script !== undefined) { 54 | RunningBlenders.sendToResponsive({ type: 'script', path: this.script }) 55 | } 56 | } 57 | } 58 | 59 | export async function launch(data: BlenderExecutableData, blend_filepath?: string, script?: string) { 60 | const blenderArgs = getBlenderLaunchArgs(blend_filepath); 61 | const execution = new vscode.ProcessExecution( 62 | data.path, 63 | blenderArgs, 64 | { env: await getBlenderLaunchEnv() } 65 | ); 66 | outputChannel.appendLine(`Starting blender: ${data.path} ${blenderArgs.join(' ')}`) 67 | outputChannel.appendLine('With ENV Vars: ' + JSON.stringify(execution.options?.env, undefined, 2)) 68 | 69 | const vscode_identifier = getRandomString() 70 | const task = await runTask('blender', execution, vscode_identifier); 71 | 72 | const blenderTask = new BlenderTask(task, vscode_identifier, script) 73 | RunningBlenders.registerTask(blenderTask) 74 | 75 | return task; 76 | } 77 | 78 | export type BlenderExecutableSettings = { 79 | path: string; 80 | name: string; 81 | linuxInode?: never; 82 | isDefault?: boolean; 83 | } 84 | 85 | export type BlenderExecutableData = { 86 | path: string; 87 | name: string; 88 | linuxInode?: number; 89 | isDefault?: boolean; 90 | } 91 | 92 | async function searchBlenderInSystem(): Promise { 93 | const blenders: BlenderExecutableData[] = []; 94 | if (process.platform === "win32") { 95 | const windowsBlenders = await getBlenderWindows(); 96 | blenders.push(...windowsBlenders.map(blend_path => ({ path: blend_path, name: "" }))) 97 | } 98 | const separator = process.platform === "win32" ? ";" : ":" 99 | const path_env = process.env.PATH?.split(separator); 100 | if (path_env === undefined) { 101 | return blenders; 102 | } 103 | const exe = process.platform === "win32" ? "blender.exe" : "blender" 104 | for (const p of path_env) { 105 | const executable = path.join(p, exe) 106 | const stats = await stat(executable).catch((err: NodeJS.ErrnoException) => undefined); 107 | if (stats === undefined || !stats?.isFile()) continue; 108 | blenders.push({ path: executable, name: "", linuxInode: stats.ino }) 109 | } 110 | return blenders; 111 | } 112 | 113 | interface BlenderType { 114 | label: string; 115 | selectNewLabel: string; 116 | predicate: (item: BlenderExecutableData) => boolean; 117 | setSettings: (item: BlenderExecutableData) => void; 118 | } 119 | 120 | async function getFilteredBlenderPath(type: BlenderType): Promise { 121 | let result: BlenderExecutableData[] = [] 122 | { 123 | const blenderPathsInSystem: BlenderExecutableData[] = await searchBlenderInSystem(); 124 | const deduplicatedBlenderPaths: BlenderExecutableData[] = deduplicateSamePaths(blenderPathsInSystem); 125 | if (process.platform !== 'win32') { 126 | try { 127 | result = await deduplicateSameHardLinks(deduplicatedBlenderPaths, true); 128 | } catch { // weird cases as network attached storage or FAT32 file system are not tested 129 | result = deduplicatedBlenderPaths; 130 | } 131 | } else { 132 | result = deduplicatedBlenderPaths; 133 | } 134 | } 135 | 136 | const config = getConfig(); 137 | const settingsBlenderPaths = (config.get('executables')).filter(type.predicate); 138 | { // deduplicate Blender paths twice: it preserves proper order in UI 139 | const deduplicatedBlenderPaths: BlenderExecutableData[] = deduplicateSamePaths(result, settingsBlenderPaths); 140 | if (process.platform !== 'win32') { 141 | try { 142 | result = [...settingsBlenderPaths, ...await deduplicateSameHardLinks(deduplicatedBlenderPaths, false, settingsBlenderPaths)] 143 | } catch { // weird cases as network attached storage or FAT32 file system are not tested 144 | result = [...settingsBlenderPaths, ...deduplicatedBlenderPaths]; 145 | } 146 | } else { 147 | result = [...settingsBlenderPaths, ...deduplicatedBlenderPaths]; 148 | } 149 | } 150 | 151 | const quickPickItems: PickItem[] = []; 152 | for (const blenderPath of result) { 153 | quickPickItems.push({ 154 | data: async () => blenderPath, 155 | label: blenderPath.name || blenderPath.path, 156 | description: await stat(path.isAbsolute(blenderPath.path) ? blenderPath.path : path.join(getAnyWorkspaceFolder().uri.fsPath, blenderPath.path)).then(_stats => undefined).catch((err: NodeJS.ErrnoException) => "File does not exist") 157 | }); 158 | } 159 | 160 | // last option opens interactive window 161 | quickPickItems.push({ label: type.selectNewLabel, data: async () => askUser_FilteredBlenderPath(type) }) 162 | 163 | const pickedItem = await letUserPickItem(quickPickItems); 164 | const pathData: BlenderExecutableData = await pickedItem.data(); 165 | 166 | // update VScode settings 167 | if (settingsBlenderPaths.find(data => data.path === pathData.path) === undefined) { 168 | settingsBlenderPaths.push(pathData); 169 | const toSave: BlenderExecutableSettings[] = settingsBlenderPaths.map(item => { return { 'name': item.name, 'path': item.path, "isDefault": item.isDefault } }) 170 | config.update('executables', toSave, vscode.ConfigurationTarget.Global); 171 | } 172 | 173 | return pathData; 174 | } 175 | 176 | function deduplicateSamePaths(blenderPathsToReduce: BlenderExecutableData[], additionalBlenderPaths: BlenderExecutableData[] = []) { 177 | const deduplicatedBlenderPaths: BlenderExecutableData[] = []; 178 | const uniqueBlenderPaths: string[] = []; 179 | const isTheSamePath = (path_one: string, path_two: string) => path.relative(path_one, path_two) === ''; 180 | for (const item of blenderPathsToReduce) { 181 | if (uniqueBlenderPaths.some(path => isTheSamePath(item.path, path))) { 182 | continue; 183 | } 184 | if (additionalBlenderPaths.some(blenderPath => isTheSamePath(item.path, blenderPath.path))) { 185 | continue; 186 | } 187 | uniqueBlenderPaths.push(item.path); 188 | deduplicatedBlenderPaths.push(item); 189 | } 190 | return deduplicatedBlenderPaths; 191 | } 192 | 193 | async function askUser_FilteredBlenderPath(type: BlenderType): Promise { 194 | let filepath = await askUser_BlenderPath(type.label); 195 | let pathData: BlenderExecutableData = { 196 | path: filepath, 197 | name: '', 198 | }; 199 | type.setSettings(pathData); 200 | return pathData; 201 | } 202 | 203 | async function askUser_BlenderPath(openLabel: string) { 204 | let value = await vscode.window.showOpenDialog({ 205 | canSelectFiles: true, 206 | canSelectFolders: false, 207 | canSelectMany: false, 208 | openLabel: openLabel 209 | }); 210 | if (value === undefined) return Promise.reject(cancel()); 211 | let filepath = value[0].fsPath; 212 | 213 | if (os.platform() === 'darwin') { 214 | if (filepath.toLowerCase().endsWith('.app')) { 215 | filepath += '/Contents/MacOS/blender'; 216 | } 217 | } 218 | 219 | await testIfPathIsBlender(filepath); 220 | return filepath; 221 | } 222 | 223 | async function testIfPathIsBlender(filepath: string) { 224 | let name: string = path.basename(filepath); 225 | 226 | if (!name.toLowerCase().startsWith('blender')) { 227 | return Promise.reject(new Error('Expected executable name to begin with \'blender\'')); 228 | } 229 | 230 | let testString = '###TEST_BLENDER###'; 231 | let command = `"${filepath}" --factory-startup -b --python-expr "import sys;print('${testString}');sys.stdout.flush();sys.exit()"`; 232 | 233 | return new Promise((resolve, reject) => { 234 | child_process.exec(command, {}, (err, stdout, stderr) => { 235 | let text = stdout.toString(); 236 | if (!text.includes(testString)) { 237 | var message = 'A simple check to test if the selected file is Blender failed.'; 238 | message += ' Please create a bug report when you are sure that the selected file is Blender 2.8 or newer.'; 239 | message += ' The report should contain the full path to the executable.'; 240 | reject(new Error(message)); 241 | } 242 | else { 243 | resolve(); 244 | } 245 | }); 246 | }); 247 | } 248 | 249 | function getBlenderLaunchArgs(blend_filepath?: string) { 250 | const config = getConfig(); 251 | let additional_args = []; 252 | if (blend_filepath !== undefined) { 253 | if (!fs.existsSync(blend_filepath)) { 254 | new Error(`File does not exist: '${blend_filepath}'`); 255 | } 256 | let pre_args = config.get("preFileArguments", []); 257 | let post_args = config.get("postFileArguments", []); 258 | for (const [index, arg] of pre_args.entries()) { 259 | if (arg === "--" || arg.startsWith("-- ")) { 260 | outputChannel.appendLine(`WARNING: ignoring any remainning arguments: '--' arument can not be in preFileArguments. Please put arguemnts [${pre_args.slice(index).toString()}] in postFileArguments`) 261 | break; 262 | } 263 | additional_args.push(arg); 264 | } 265 | additional_args.push(blend_filepath); 266 | additional_args = additional_args.concat(post_args); 267 | } else { 268 | additional_args = config.get("additionalArguments", []); 269 | } 270 | const args = ['--python', launchPath].concat(additional_args); 271 | return args; 272 | } 273 | 274 | async function getBlenderLaunchEnv() { 275 | let config = getConfig(); 276 | let addons = await AddonWorkspaceFolder.All(); 277 | let loadDirsWithNames = await Promise.all(addons.map(a => a.getLoadDirectoryAndModuleName())); 278 | 279 | return { 280 | ADDONS_TO_LOAD: JSON.stringify(loadDirsWithNames), 281 | VSCODE_EXTENSIONS_REPOSITORY: config.get("addon.extensionsRepository"), 282 | VSCODE_LOG_LEVEL: config.get("addon.logLevel"), 283 | EDITOR_PORT: getServerPort().toString(), 284 | ...config.get("environmentVariables", {}), 285 | }; 286 | } 287 | -------------------------------------------------------------------------------- /src/blender_executable_linux.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as util from 'util'; 3 | 4 | import { BlenderExecutableData } from "./blender_executable"; 5 | 6 | const stat = util.promisify(fs.stat) 7 | 8 | export async function deduplicateSameHardLinks(blenderPathsToReduce: BlenderExecutableData[], removeMissingFiles = true, additionalBlenderPaths: BlenderExecutableData[] = []) { 9 | let missingItem = -1; 10 | const additionalBlenderPathsInodes = new Set(); 11 | for (const item of additionalBlenderPaths) { 12 | if (item.linuxInode === undefined) { 13 | const stats = await stat(item.path).catch((err: NodeJS.ErrnoException) => undefined); 14 | if (stats === undefined) { continue; } 15 | item.linuxInode = stats.ino; 16 | } 17 | additionalBlenderPathsInodes.add(item.linuxInode); 18 | } 19 | 20 | const deduplicateHardLinks = new Map(); 21 | for (const item of blenderPathsToReduce) { 22 | if (item.linuxInode === undefined) { 23 | // try to find missing information 24 | const stats = await stat(item.path).catch((err: NodeJS.ErrnoException) => undefined); 25 | if (stats === undefined) { 26 | if (removeMissingFiles) { 27 | deduplicateHardLinks.set(missingItem, item); 28 | missingItem -= 1; 29 | } 30 | continue; 31 | } 32 | item.linuxInode = stats.ino; 33 | } 34 | if (deduplicateHardLinks.has(item.linuxInode)) continue; 35 | if (additionalBlenderPathsInodes.has(item.linuxInode)) continue; 36 | deduplicateHardLinks.set(item.linuxInode, item); 37 | } 38 | return Array.from(deduplicateHardLinks.values()); 39 | } 40 | -------------------------------------------------------------------------------- /src/blender_executable_windows.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as util from 'util'; 4 | 5 | 6 | const readdir = util.promisify(fs.readdir) 7 | const stat = util.promisify(fs.stat) 8 | 9 | 10 | async function getDirectories(path_: string): Promise { 11 | let filesAndDirectories = await readdir(path_); 12 | 13 | let directories: string[] = []; 14 | await Promise.all( 15 | filesAndDirectories.map(async name => { 16 | const stats = await stat(path.join(path_, name)); 17 | if (stats.isDirectory()) directories.push(name); 18 | }) 19 | ); 20 | return directories; 21 | } 22 | 23 | // todo read from registry Blender installation path 24 | const typicalWindowsBlenderFoundationPaths: string[] = [ 25 | path.join(process.env.ProgramFiles || "C:\\Program Files", "Blender Foundation"), 26 | path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Blender Foundation"), 27 | ] 28 | 29 | 30 | export async function getBlenderWindows(): Promise { 31 | let blenders: string[] = []; 32 | let dirs_to_check: string[] = [] 33 | for (const typicalPath of typicalWindowsBlenderFoundationPaths) { 34 | const dirs: string[] = await getDirectories(typicalPath).catch((err: NodeJS.ErrnoException) => []); 35 | dirs_to_check.push(...dirs.map((dir: string) => path.join(typicalPath, dir))) 36 | } 37 | 38 | const exe = "blender.exe"; 39 | for (const p of dirs_to_check) { 40 | const executable = path.join(p, exe) 41 | const stats = await stat(executable).catch((err: NodeJS.ErrnoException) => undefined); 42 | if (stats === undefined) continue; 43 | if (stats.isFile()) { 44 | blenders.push(executable) 45 | } 46 | } 47 | return blenders; 48 | } -------------------------------------------------------------------------------- /src/commands_new_addon.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import { templateFilesDir } from './paths'; 5 | import { letUserPickItem } from './select_utils'; 6 | import { 7 | cancel, readTextFile, writeTextFile, getWorkspaceFolders, 8 | addFolderToWorkspace, multiReplaceText, pathExists, 9 | isValidPythonModuleName, renamePath, toTitleCase 10 | } from './utils'; 11 | 12 | type AddonBuilder = (path: string, addonName: string, authorName: string, supportLegacy: boolean) => Promise; 13 | 14 | const addonTemplateDir = path.join(templateFilesDir, 'addons'); 15 | const manifestFile = path.join(templateFilesDir, 'blender_manifest.toml') 16 | 17 | export async function COMMAND_newAddon() { 18 | let builder = await getNewAddonGenerator(); 19 | let [addonName, authorName, supportLegacy] = await askUser_SettingsForNewAddon(); 20 | let folderPath = await getFolderForNewAddon(); 21 | folderPath = await fixAddonFolderName(folderPath); 22 | let mainPath = await builder(folderPath, addonName, authorName, supportLegacy); 23 | 24 | await vscode.window.showTextDocument(vscode.Uri.file(mainPath)); 25 | addFolderToWorkspace(folderPath); 26 | } 27 | 28 | async function getNewAddonGenerator(): Promise { 29 | let items = []; 30 | items.push({ label: 'Simple', data: generateAddon_Simple }); 31 | items.push({ label: 'With Auto Load', data: generateAddon_WithAutoLoad }); 32 | let item = await letUserPickItem(items, 'Choose Template'); 33 | return item.data; 34 | } 35 | 36 | async function getFolderForNewAddon(): Promise { 37 | let items = []; 38 | 39 | for (let workspaceFolder of getWorkspaceFolders()) { 40 | let folderPath = workspaceFolder.uri.fsPath; 41 | if (await canAddonBeCreatedInFolder(folderPath)) { 42 | items.push({ data: async () => folderPath, label: folderPath }); 43 | } 44 | } 45 | 46 | if (items.length > 0) { 47 | items.push({ data: selectFolderForAddon, label: 'Open Folder...' }); 48 | let item = await letUserPickItem(items); 49 | return await item.data(); 50 | } 51 | else { 52 | return await selectFolderForAddon(); 53 | } 54 | } 55 | 56 | async function selectFolderForAddon() { 57 | let value = await vscode.window.showOpenDialog({ 58 | canSelectFiles: false, 59 | canSelectFolders: true, 60 | canSelectMany: false, 61 | openLabel: 'New Addon' 62 | }); 63 | if (value === undefined) return Promise.reject(cancel()); 64 | let folderPath = value[0].fsPath; 65 | 66 | if (!(await canAddonBeCreatedInFolder(folderPath))) { 67 | let message: string = 'Cannot create new addon in this folder.'; 68 | message += ' Maybe it contains other files already.'; 69 | return Promise.reject(new Error(message)); 70 | } 71 | 72 | return folderPath; 73 | } 74 | 75 | async function canAddonBeCreatedInFolder(folder: string) { 76 | return new Promise(resolve => { 77 | fs.stat(folder, (err, stat) => { 78 | if (err !== null) { 79 | resolve(false); 80 | return; 81 | } 82 | if (!stat.isDirectory()) { 83 | resolve(false); 84 | return; 85 | } 86 | 87 | fs.readdir(folder, {}, (err, files) => { 88 | for (let name of files) { 89 | if (!(name).startsWith('.')) { 90 | resolve(false); 91 | return; 92 | } 93 | } 94 | resolve(true); 95 | }); 96 | }); 97 | }); 98 | } 99 | 100 | async function fixAddonFolderName(folder: string) { 101 | let name = path.basename(folder); 102 | if (isValidPythonModuleName(name)) { 103 | return folder; 104 | } 105 | 106 | let items = []; 107 | let alternatives = getFolderNameAlternatives(name).map(newName => path.join(path.dirname(folder), newName)); 108 | items.push(...alternatives.filter(async p => !(await pathExists(p))).map(p => ({ label: p, data: p }))); 109 | items.push({ label: "Don't change the name.", data: folder }); 110 | 111 | let item = await letUserPickItem(items, 'Warning: This folder name should not be used.'); 112 | let newPath = item.data; 113 | if (folder !== newPath) { 114 | renamePath(folder, newPath); 115 | } 116 | return newPath; 117 | } 118 | 119 | function getFolderNameAlternatives(name: string): string[] { 120 | let alternatives = []; 121 | alternatives.push(name.replace(/\W/, '_')); 122 | alternatives.push(name.replace(/\W/, '')); 123 | return alternatives; 124 | } 125 | 126 | async function askUser_SettingsForNewAddon() { 127 | let addonName = await vscode.window.showInputBox({ placeHolder: 'Addon Name' }); 128 | if (addonName === undefined) { 129 | return Promise.reject(cancel()); 130 | } 131 | else if (addonName === "") { 132 | return Promise.reject(new Error('Can\'t create an addon without a name.')); 133 | } 134 | 135 | let authorName = await vscode.window.showInputBox({ placeHolder: 'Your Name' }); 136 | if (authorName === undefined) { 137 | return Promise.reject(cancel()); 138 | } 139 | else if (authorName === "") { 140 | return Promise.reject(new Error('Can\'t create an addon without an author name.')); 141 | } 142 | 143 | let items = []; 144 | items.push({ label: "Yes", data: true }); 145 | items.push({ label: "No", data: false }); 146 | let item = await letUserPickItem(items, "Support legacy Blender versions (<4.2)?"); 147 | let supportLegacy = item.data; 148 | 149 | return [addonName, authorName, supportLegacy]; 150 | } 151 | 152 | async function generateAddon_Simple(folder: string, addonName: string, authorName: string, supportLegacy: boolean) { 153 | let srcDir = path.join(addonTemplateDir, 'simple'); 154 | 155 | let initSourcePath = path.join(srcDir, '__init__.py'); 156 | let initTargetPath = path.join(folder, '__init__.py'); 157 | await copyModifiedInitFile(initSourcePath, initTargetPath, addonName, authorName, supportLegacy); 158 | 159 | let manifestTargetPath = path.join(folder, 'blender_manifest.toml'); 160 | await copyModifiedManifestFile(manifestFile, manifestTargetPath, addonName, authorName); 161 | 162 | return manifestTargetPath; 163 | } 164 | 165 | async function generateAddon_WithAutoLoad(folder: string, addonName: string, authorName: string, supportLegacy: boolean) { 166 | let srcDir = path.join(addonTemplateDir, 'with_auto_load'); 167 | 168 | let initSourcePath = path.join(srcDir, '__init__.py'); 169 | let initTargetPath = path.join(folder, '__init__.py'); 170 | await copyModifiedInitFile(initSourcePath, initTargetPath, addonName, authorName, supportLegacy); 171 | 172 | let manifestTargetPath = path.join(folder, 'blender_manifest.toml'); 173 | await copyModifiedManifestFile(manifestFile, manifestTargetPath, addonName, authorName); 174 | 175 | let autoLoadSourcePath = path.join(srcDir, 'auto_load.py'); 176 | let autoLoadTargetPath = path.join(folder, 'auto_load.py'); 177 | await copyFileWithReplacedText(autoLoadSourcePath, autoLoadTargetPath, {}); 178 | 179 | try { 180 | let defaultFilePath = path.join(folder, await getDefaultFileName()); 181 | if (!(await pathExists(defaultFilePath))) { 182 | await writeTextFile(defaultFilePath, 'import bpy\n'); 183 | } 184 | return defaultFilePath; 185 | } 186 | catch { 187 | return manifestTargetPath; 188 | } 189 | } 190 | 191 | async function getDefaultFileName() { 192 | let items = []; 193 | items.push({ label: '__init__.py' }); 194 | items.push({ label: 'operators.py' }); 195 | 196 | let item = await letUserPickItem(items, 'Open File'); 197 | return item.label; 198 | } 199 | 200 | async function copyModifiedInitFile(src: string, dst: string, addonName: string, authorName: string, supportLegacy: boolean) { 201 | let replacements; 202 | 203 | // Remove bl_info if not supporting legacy addon system 204 | if (supportLegacy) { 205 | replacements = { 206 | ADDON_NAME: toTitleCase(addonName), 207 | AUTHOR_NAME: authorName, 208 | } 209 | } 210 | else { 211 | // https://regex101.com/r/RmBWrk/1 212 | replacements = { 213 | 'bl_info.+=.+{[\\s\\S]*}\\s*': '', 214 | } 215 | } 216 | await copyFileWithReplacedText(src, dst, replacements); 217 | } 218 | 219 | async function copyModifiedManifestFile(src: string, dst: string, addonName: string, authorName: string) { 220 | let replacements = { 221 | ADDON_ID: addonName.toLowerCase().replace(/\s/g, '_'), 222 | ADDON_NAME: toTitleCase(addonName), 223 | AUTHOR_NAME: authorName, 224 | }; 225 | await copyFileWithReplacedText(src, dst, replacements); 226 | } 227 | 228 | async function copyFileWithReplacedText(src: string, dst: string, replacements: object) { 229 | let text = await readTextFile(src); 230 | let new_text = multiReplaceText(text, replacements); 231 | await writeTextFile(dst, new_text); 232 | } 233 | -------------------------------------------------------------------------------- /src/commands_new_operator.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { templateFilesDir } from './paths'; 4 | import { 5 | cancel, nameToClassIdentifier, nameToIdentifier, readTextFile, 6 | multiReplaceText 7 | } from './utils'; 8 | 9 | export async function COMMAND_newOperator(): Promise { 10 | let editor = vscode.window.activeTextEditor; 11 | if (editor === undefined) return; 12 | 13 | let operatorName = await vscode.window.showInputBox({ 14 | placeHolder: 'Name', 15 | }); 16 | if (operatorName === undefined) return Promise.reject(cancel()); 17 | 18 | let group: string = 'object'; 19 | await insertOperator(editor, operatorName, group); 20 | } 21 | 22 | async function insertOperator(editor: vscode.TextEditor, name: string, group: string) { 23 | let className = nameToClassIdentifier(name) + 'Operator'; 24 | let idname = group + '.' + nameToIdentifier(name); 25 | 26 | let text = await readTextFile(path.join(templateFilesDir, 'operator_simple.py')); 27 | text = multiReplaceText(text, { 28 | CLASS_NAME: className, 29 | OPERATOR_CLASS: 'bpy.types.Operator', 30 | IDNAME: idname, 31 | LABEL: name, 32 | }); 33 | 34 | let workspaceEdit = new vscode.WorkspaceEdit(); 35 | 36 | if (!hasImportBpy(editor.document)) { 37 | workspaceEdit.insert(editor.document.uri, new vscode.Position(0, 0), 'import bpy\n'); 38 | } 39 | 40 | workspaceEdit.replace(editor.document.uri, editor.selection, '\n' + text + '\n'); 41 | await vscode.workspace.applyEdit(workspaceEdit); 42 | } 43 | 44 | function hasImportBpy(document: vscode.TextDocument) { 45 | for (let i = 0; i< document.lineCount; i++) { 46 | let line = document.lineAt(i); 47 | if (line.text.match(/import.*\bbpy\b/)) { 48 | return true; 49 | } 50 | } 51 | return false; 52 | } -------------------------------------------------------------------------------- /src/commands_scripts.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { RunningBlenders } from './communication'; 4 | import { getAreaTypeItems } from './commands_scripts_data_loader'; 5 | import { COMMAND_start, outputChannel, StartCommandArguments } from './extension'; 6 | import { templateFilesDir } from './paths'; 7 | import { letUserPickItem, PickItem } from './select_utils'; 8 | import { addFolderToWorkspace, cancel, copyFile, getConfig, getRandomString, pathExists } from './utils'; 9 | 10 | export function COMMAND_runScript_registerCleanup() { 11 | const disposableDebugSessionListener = vscode.debug.onDidTerminateDebugSession(session => { 12 | // if (session.name !== 'Debug Blender' && !session.name.startsWith('Python at Port ')) 13 | // return 14 | const id = session.configuration.identifier; 15 | RunningBlenders.kill(id); 16 | }); 17 | const disposableTaskListener = vscode.tasks.onDidEndTaskProcess((e) => { 18 | if (e.execution.task.source !== 'blender') 19 | return 20 | const id = e.execution.task.definition.type; 21 | RunningBlenders.kill(id); 22 | }); 23 | return [disposableDebugSessionListener, disposableTaskListener] 24 | } 25 | 26 | type RunScriptCommandArguments = { 27 | // compability with <0.26 28 | path?: string // now called script 29 | } & StartCommandArguments; 30 | 31 | export async function COMMAND_runScript(args?: RunScriptCommandArguments): Promise { 32 | let scriptPath = args?.script; 33 | if (args?.path !== undefined) { 34 | scriptPath = args?.path; 35 | } 36 | if (args?.script === undefined && args?.path === undefined) { 37 | const editor = vscode.window.activeTextEditor; 38 | if (editor === undefined) 39 | return Promise.reject(new Error('no active script')); 40 | const document = editor.document; 41 | await document.save(); 42 | outputChannel.appendLine(`Blender: Run Script: ${document.uri.fsPath}`) 43 | scriptPath = document.uri.fsPath; 44 | } 45 | 46 | const instances = await RunningBlenders.getResponsive(); 47 | 48 | if (instances.length !== 0) { 49 | RunningBlenders.sendToResponsive({ type: 'script', path: scriptPath }) 50 | } else { 51 | const commandArgs: StartCommandArguments = { script: scriptPath, blenderExecutable: args?.blenderExecutable } 52 | await COMMAND_start(commandArgs) 53 | } 54 | } 55 | 56 | export async function COMMAND_newScript(): Promise { 57 | let [folderPath, filePath] = await getPathForNewScript(); 58 | await createNewScriptAtPath(filePath); 59 | 60 | await vscode.window.showTextDocument(vscode.Uri.file(filePath)); 61 | await vscode.commands.executeCommand('cursorBottom'); 62 | addFolderToWorkspace(folderPath); 63 | } 64 | 65 | export async function COMMAND_openScriptsFolder() { 66 | let folderPath = await getFolderForScripts(); 67 | addFolderToWorkspace(folderPath); 68 | } 69 | 70 | export async function COMMAND_setScriptContext() { 71 | let editor = vscode.window.activeTextEditor; 72 | if (editor === undefined) return; 73 | 74 | let items = (await getAreaTypeItems()).map(item => ({ label: item.name, description: item.identifier })); 75 | let item = await letUserPickItem(items); 76 | await setScriptContext(editor.document, item.description); 77 | } 78 | 79 | async function setScriptContext(document: vscode.TextDocument, areaType: string): Promise { 80 | let workspaceEdit = new vscode.WorkspaceEdit(); 81 | let [line, match] = findAreaContextLine(document); 82 | if (match === null) { 83 | workspaceEdit.insert(document.uri, new vscode.Position(0, 0), `# context.area: ${areaType}\n`); 84 | } 85 | else { 86 | let start = new vscode.Position(line, match[0].length); 87 | let end = new vscode.Position(line, document.lineAt(line).range.end.character); 88 | let range = new vscode.Range(start, end); 89 | workspaceEdit.replace(document.uri, range, areaType); 90 | } 91 | await vscode.workspace.applyEdit(workspaceEdit); 92 | } 93 | 94 | function findAreaContextLine(document: vscode.TextDocument): [number, RegExpMatchArray | null] { 95 | for (let i = 0; i < document.lineCount; i++) { 96 | let line = document.lineAt(i); 97 | let match = line.text.match(/^\s*#\s*context\.area\s*:\s*/i); 98 | if (match !== null) { 99 | return [i, match]; 100 | } 101 | } 102 | return [-1, null]; 103 | } 104 | 105 | async function getPathForNewScript() { 106 | let folderPath = await getFolderForScripts(); 107 | let fileName = await askUser_ScriptFileName(folderPath); 108 | let filePath = path.join(folderPath, fileName); 109 | 110 | if (await pathExists(filePath)) { 111 | return Promise.reject(new Error('file exists already')); 112 | } 113 | 114 | return [folderPath, filePath]; 115 | } 116 | 117 | async function createNewScriptAtPath(filePath: string) { 118 | let defaultScriptPath = path.join(templateFilesDir, 'script.py'); 119 | await copyFile(defaultScriptPath, filePath); 120 | } 121 | 122 | export interface ScriptFolderData { 123 | path: string; 124 | name: string; 125 | } 126 | 127 | async function getFolderForScripts() { 128 | let scriptFolders = getStoredScriptFolders(); 129 | 130 | let items: PickItem[] = []; 131 | for (let folderData of scriptFolders) { 132 | let useCustomName = folderData.name !== ''; 133 | items.push({ 134 | label: useCustomName ? folderData.name : folderData.path, 135 | data: async () => folderData, 136 | }); 137 | } 138 | 139 | items.push({ 140 | label: 'New Folder...', 141 | data: askUser_ScriptFolder, 142 | }); 143 | 144 | let item = await letUserPickItem(items); 145 | let folderData: ScriptFolderData = await item.data(); 146 | 147 | if (scriptFolders.find(data => data.path === folderData.path) === undefined) { 148 | scriptFolders.push(folderData); 149 | let config = getConfig(); 150 | config.update('scripts.directories', scriptFolders, vscode.ConfigurationTarget.Global); 151 | } 152 | 153 | return folderData.path; 154 | } 155 | 156 | export function getStoredScriptFolders() { 157 | let config = getConfig(); 158 | return config.get('scripts.directories'); 159 | } 160 | 161 | async function askUser_ScriptFolder(): Promise { 162 | let value = await vscode.window.showOpenDialog({ 163 | canSelectFiles: false, 164 | canSelectFolders: true, 165 | canSelectMany: false, 166 | openLabel: 'Script Folder' 167 | }); 168 | if (value === undefined) return Promise.reject(cancel()); 169 | return { 170 | path: value[0].fsPath, 171 | name: '' 172 | }; 173 | } 174 | 175 | async function askUser_ScriptFileName(folder: string): Promise { 176 | let defaultName = await getDefaultScriptName(folder); 177 | let name = await vscode.window.showInputBox({ 178 | value: defaultName 179 | }); 180 | if (name === undefined) return Promise.reject(cancel()); 181 | if (!name.toLowerCase().endsWith('.py')) { 182 | name += '.py'; 183 | } 184 | return name; 185 | } 186 | 187 | async function getDefaultScriptName(folder: string) { 188 | while (true) { 189 | let name = 'script ' + getRandomString(10) + '.py'; 190 | if (!(await pathExists(path.join(folder, name)))) { 191 | return name; 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/commands_scripts_data_loader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { generatedDir } from './paths'; 3 | import { readTextFile } from './utils'; 4 | 5 | let enumsPath = path.join(generatedDir, 'enums.json'); 6 | 7 | interface EnumItem { 8 | identifier: string; 9 | name: string; 10 | description: string; 11 | } 12 | 13 | export async function getAreaTypeItems() { 14 | return getGeneratedEnumData('areaTypeItems'); 15 | } 16 | 17 | async function getGeneratedEnumData(identifier: string): Promise { 18 | let text = await readTextFile(enumsPath); 19 | let data = JSON.parse(text); 20 | return data[identifier]; 21 | } 22 | -------------------------------------------------------------------------------- /src/communication.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as vscode from 'vscode'; 3 | import * as request from 'request'; 4 | import { getConfig } from './utils'; 5 | import { attachPythonDebuggerToBlender } from './python_debugging'; 6 | import { BlenderTask } from './blender_executable'; 7 | 8 | const RESPONSIVE_LIMIT_MS = 1000; 9 | 10 | 11 | /* Manage connected Blender instances 12 | ********************************************** */ 13 | 14 | export type AddonPathMapping = { src: string, load: string }; 15 | 16 | export class BlenderInstance { 17 | blenderPort: number; 18 | debugpyPort: number; 19 | justMyCode: boolean; 20 | path: string; 21 | scriptsFolder: string; 22 | addonPathMappings: AddonPathMapping[]; 23 | connectionErrors: Error[]; 24 | vscodeIdentifier: string; // can identify vs code task and in http communication 25 | debug_session: any; 26 | 27 | constructor(blenderPort: number, debugpyPort: number, justMyCode: boolean, path: string, 28 | scriptsFolder: string, addonPathMappings: AddonPathMapping[], vscodeIdentifier: string) { 29 | this.blenderPort = blenderPort; 30 | this.debugpyPort = debugpyPort; 31 | this.justMyCode = justMyCode; 32 | this.path = path; 33 | this.scriptsFolder = scriptsFolder; 34 | this.addonPathMappings = addonPathMappings; 35 | this.connectionErrors = []; 36 | this.vscodeIdentifier = vscodeIdentifier; 37 | } 38 | 39 | post(data: object) { 40 | return request.post(this.address, { json: data }); 41 | } 42 | 43 | async ping(): Promise { 44 | return new Promise((resolve, reject) => { 45 | let req = request.get(this.address, { json: { type: 'ping' } }); 46 | req.on('end', () => resolve()); 47 | req.on('error', err => { this.connectionErrors.push(err); reject(err); }); 48 | }); 49 | } 50 | 51 | async isResponsive(timeout: number = RESPONSIVE_LIMIT_MS) { 52 | return new Promise(resolve => { 53 | this.ping().then(() => resolve(true)).catch(); 54 | setTimeout(() => resolve(false), timeout); 55 | }); 56 | } 57 | 58 | attachDebugger() { 59 | this.debug_session = attachPythonDebuggerToBlender(this.debugpyPort, this.path, this.justMyCode, this.scriptsFolder, this.addonPathMappings, this.vscodeIdentifier); 60 | return this.debug_session 61 | } 62 | 63 | get address() { 64 | return `http://localhost:${this.blenderPort}`; 65 | } 66 | } 67 | 68 | export class RunningBlenderInstances { 69 | protected instances: BlenderInstance[]; 70 | protected tasks: BlenderTask[]; 71 | 72 | constructor() { 73 | this.instances = []; 74 | this.tasks = []; 75 | } 76 | 77 | registerInstance(instance: BlenderInstance) { 78 | this.instances.push(instance); 79 | } 80 | registerTask(task: BlenderTask) { 81 | this.tasks.push(task) 82 | } 83 | 84 | public getTask(vscodeIdentifier: string): BlenderTask | undefined { 85 | return this.tasks.filter(item => item.vscodeIdentifier === vscodeIdentifier)[0] 86 | } 87 | public getInstance(vscodeIdentifier: string): BlenderInstance | undefined { 88 | return this.instances.filter(item => item.vscodeIdentifier === vscodeIdentifier)[0] 89 | } 90 | 91 | public async kill(vscodeIdentifier: string) { 92 | const task = this.getTask(vscodeIdentifier) 93 | task?.task.terminate() 94 | this.tasks = this.tasks.filter(item => item.vscodeIdentifier !== vscodeIdentifier) 95 | 96 | // const instance = this.getInstance(vscodeIdentifier) 97 | this.instances = this.instances.filter(item => item.vscodeIdentifier !== vscodeIdentifier) 98 | } 99 | 100 | async getResponsive(timeout: number = RESPONSIVE_LIMIT_MS): Promise { 101 | if (this.instances.length === 0) return []; 102 | 103 | return new Promise(resolve => { 104 | let responsiveInstances: BlenderInstance[] = []; 105 | let pingAmount = this.instances.length; 106 | 107 | function addInstance(instance: BlenderInstance) { 108 | responsiveInstances.push(instance); 109 | if (responsiveInstances.length === pingAmount) { 110 | resolve(responsiveInstances.slice()); 111 | } 112 | } 113 | 114 | for (let instance of this.instances) { 115 | instance.ping().then(() => addInstance(instance)).catch(() => { }); 116 | } 117 | setTimeout(() => resolve(responsiveInstances.slice()), timeout); 118 | }); 119 | } 120 | 121 | async sendToResponsive(data: object, timeout: number = RESPONSIVE_LIMIT_MS) { 122 | let sentTo: request.Request[] = [] 123 | for (const instance of this.instances) { 124 | const isResponsive = await instance.isResponsive(timeout) 125 | if (!isResponsive) 126 | continue 127 | try { 128 | sentTo.push(instance.post(data)) 129 | } catch { } 130 | } 131 | return sentTo; 132 | } 133 | 134 | sendToAll(data: object) { 135 | for (const instance of this.instances) { 136 | instance.post(data); 137 | } 138 | } 139 | } 140 | 141 | 142 | /* Own server 143 | ********************************************** */ 144 | 145 | export function startServer() { 146 | server = http.createServer(SERVER_handleRequest); 147 | server.listen(); 148 | } 149 | 150 | export function stopServer() { 151 | server.close(); 152 | } 153 | 154 | export function getServerPort(): number { 155 | return server.address().port; 156 | } 157 | 158 | function SERVER_handleRequest(request: any, response: any) { 159 | if (request.method === 'POST') { 160 | let body = ''; 161 | request.on('data', (chunk: any) => body += chunk.toString()); 162 | request.on('end', () => { 163 | let req = JSON.parse(body); 164 | 165 | switch (req.type) { 166 | case 'setup': { 167 | let config = getConfig(); 168 | let justMyCode: boolean = config.get('addon.justMyCode') 169 | let instance = new BlenderInstance(req.blenderPort, req.debugpyPort, justMyCode, req.blenderPath, req.scriptsFolder, req.addonPathMappings, req.vscodeIdentifier); 170 | response.end('OK'); 171 | instance.attachDebugger().then(() => { 172 | RunningBlenders.registerInstance(instance) 173 | RunningBlenders.getTask(instance.vscodeIdentifier)?.onStartDebugging() 174 | 175 | } 176 | ) 177 | break; 178 | } 179 | case 'enableFailure': { 180 | vscode.window.showWarningMessage('Enabling the addon failed. See console.'); 181 | response.end('OK'); 182 | break; 183 | } 184 | case 'disableFailure': { 185 | vscode.window.showWarningMessage('Disabling the addon failed. See console.'); 186 | response.end('OK'); 187 | break; 188 | } 189 | case 'addonUpdated': { 190 | response.end('OK'); 191 | break; 192 | } 193 | default: { 194 | throw new Error('unknown type'); 195 | } 196 | } 197 | }); 198 | } 199 | } 200 | 201 | var server: http.Server | any = undefined; 202 | export const RunningBlenders = new RunningBlenderInstances(); -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { AddonWorkspaceFolder } from './addon_folder'; 5 | import { BlenderExecutableData, BlenderExecutableSettings, LaunchAny, LaunchAnyInteractive } from './blender_executable'; 6 | import { RunningBlenders, startServer, stopServer } from './communication'; 7 | import { COMMAND_newAddon } from './commands_new_addon'; 8 | import { COMMAND_newOperator } from './commands_new_operator'; 9 | import { factoryShowNotificationAddDefault } from './notifications'; 10 | import { 11 | COMMAND_newScript, 12 | COMMAND_openScriptsFolder, 13 | COMMAND_runScript, 14 | COMMAND_runScript_registerCleanup, 15 | COMMAND_setScriptContext 16 | } from './commands_scripts'; 17 | import { getDefaultBlenderSettings, handleErrors } from './utils'; 18 | 19 | export let outputChannel: vscode.OutputChannel; 20 | 21 | 22 | /* Registration 23 | *********************************************/ 24 | 25 | export let showNotificationAddDefault: (executable: BlenderExecutableData) => Promise 26 | 27 | 28 | export function activate(context: vscode.ExtensionContext) { 29 | outputChannel = vscode.window.createOutputChannel("Blender debugpy"); 30 | outputChannel.appendLine("Addon starting."); 31 | outputChannel.show(true); 32 | type CommandFuncType = (args?: any) => Promise 33 | let commands: [string, CommandFuncType][] = [ 34 | ['blender.start', COMMAND_start], 35 | ['blender.stop', COMMAND_stop], 36 | ['blender.reloadAddons', COMMAND_reloadAddons], 37 | ['blender.newAddon', COMMAND_newAddon], 38 | ['blender.newScript', COMMAND_newScript], 39 | ['blender.openScriptsFolder', COMMAND_openScriptsFolder], 40 | ['blender.openFiles', COMMAND_openFiles], 41 | ['blender.openWithBlender', COMMAND_openWithBlender], 42 | ['blender.runScript', COMMAND_runScript], 43 | ['blender.setScriptContext', COMMAND_setScriptContext], 44 | ['blender.newOperator', COMMAND_newOperator], 45 | ]; 46 | 47 | let disposables = [ 48 | vscode.workspace.onDidSaveTextDocument(HANDLER_updateOnSave), 49 | ]; 50 | 51 | for (const [identifier, func] of commands) { 52 | const command = vscode.commands.registerCommand(identifier, handleErrors(func)); 53 | disposables.push(command); 54 | } 55 | disposables.push(...COMMAND_runScript_registerCleanup()) 56 | 57 | context.subscriptions.push(...disposables); 58 | showNotificationAddDefault = factoryShowNotificationAddDefault(context) 59 | startServer(); 60 | } 61 | 62 | export function deactivate() { 63 | stopServer(); 64 | } 65 | 66 | 67 | /* Commands 68 | *********************************************/ 69 | 70 | export type StartCommandArguments = { 71 | blenderExecutable?: BlenderExecutableSettings; 72 | blendFilepaths?: string[] 73 | // run python script after degugger is attached 74 | script?: string 75 | // additionalArguments?: string[]; // support someday 76 | } 77 | 78 | export async function COMMAND_start(args?: StartCommandArguments) { 79 | let blenderToRun = getDefaultBlenderSettings() 80 | let filePaths: string[] | undefined = undefined 81 | let script: string | undefined = undefined 82 | if (args !== undefined) { 83 | script = args.script 84 | if (args.blenderExecutable !== undefined) { 85 | if (args.blenderExecutable.path !== undefined) { 86 | blenderToRun = args.blenderExecutable 87 | } 88 | filePaths = args.blendFilepaths 89 | } 90 | } 91 | 92 | if (blenderToRun === undefined) { 93 | await LaunchAnyInteractive(filePaths, script) 94 | } else { 95 | await LaunchAny(blenderToRun, filePaths, script) 96 | } 97 | } 98 | 99 | async function COMMAND_openWithBlender(resource: vscode.Uri) { 100 | const args: StartCommandArguments = { 101 | blendFilepaths: [resource.fsPath] 102 | } 103 | COMMAND_start(args); 104 | } 105 | 106 | async function COMMAND_openFiles() { 107 | let resources = await vscode.window.showOpenDialog({ 108 | canSelectFiles: true, 109 | canSelectFolders: false, 110 | canSelectMany: true, 111 | filters: { 'Blender files': ['blend'] }, 112 | openLabel: "Select .blend file(s)" 113 | }); 114 | if (resources === undefined) { 115 | return Promise.reject(new Error('No .blend file selected.')); 116 | } 117 | const args: StartCommandArguments = { 118 | blendFilepaths: resources.map(r => r.fsPath) 119 | } 120 | COMMAND_start(args); 121 | } 122 | 123 | async function COMMAND_stop() { 124 | RunningBlenders.sendToAll({ type: 'stop' }); 125 | } 126 | 127 | let isSavingForReload = false; 128 | 129 | async function COMMAND_reloadAddons() { 130 | isSavingForReload = true; 131 | await vscode.workspace.saveAll(false); 132 | isSavingForReload = false; 133 | await reloadAddons(await AddonWorkspaceFolder.All()); 134 | } 135 | 136 | async function reloadAddons(addons: AddonWorkspaceFolder[]) { 137 | if (addons.length === 0) return; 138 | let instances = await RunningBlenders.getResponsive(); 139 | if (instances.length === 0) return; 140 | 141 | await rebuildAddons(addons); 142 | let names = await Promise.all(addons.map(a => a.getModuleName())); 143 | // Send source dirs so that the python script can determine if each addon is an extension or not. 144 | let dirs = await Promise.all(addons.map(a => a.getSourceDirectory())); 145 | instances.forEach(instance => instance.post({ type: 'reload', names: names, dirs: dirs })); 146 | } 147 | 148 | async function rebuildAddons(addons: AddonWorkspaceFolder[]) { 149 | await Promise.all(addons.map(a => a.buildIfNecessary())); 150 | } 151 | 152 | 153 | /* Event Handlers 154 | ***************************************/ 155 | 156 | async function HANDLER_updateOnSave(document: vscode.TextDocument) { 157 | if (isSavingForReload) return; 158 | let addons = await AddonWorkspaceFolder.All(); 159 | await reloadAddons(addons.filter(a => a.reloadOnSave)); 160 | } 161 | -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getConfig } from './utils'; 3 | import { BlenderExecutableData, BlenderExecutableSettings } from './blender_executable'; 4 | 5 | export function factoryShowNotificationAddDefault(context: vscode.ExtensionContext) { 6 | return async function showNotificationAddDefault(executable : BlenderExecutableData 7 | ) { 8 | // context.globalState.update('showNotificationAddDefault', undefined); 9 | const show = context.globalState.get('showNotificationAddDefault'); 10 | if (show == false) { 11 | return 12 | } 13 | 14 | const choice = await vscode.window.showInformationMessage( 15 | `Make "${executable.name}" default?\n\`${executable.path}\``, 16 | 'Never show again', 17 | 'Make default' 18 | ); 19 | if (choice === 'Never show again') { 20 | context.globalState.update('showNotificationAddDefault', false); 21 | } else if (choice === 'Make default') { 22 | let config = getConfig(); 23 | const settingsBlenderPaths = (config.get('executables')); 24 | 25 | const toSave: BlenderExecutableSettings[] = settingsBlenderPaths.map(item => { return { 'name': item.name, 'path': item.path, 'isDefault': item.isDefault } }) 26 | 27 | let matchFound = false 28 | for (const setting of toSave) { 29 | setting.isDefault = undefined 30 | if (setting.path == executable.path) { 31 | setting.isDefault = true 32 | matchFound = true 33 | } 34 | } 35 | 36 | if (matchFound === false) { 37 | toSave.push({ 38 | name: executable.name, 39 | path: executable.path, 40 | isDefault: true 41 | }) 42 | } 43 | 44 | config.update('executables', toSave, vscode.ConfigurationTarget.Global); 45 | vscode.window.showInformationMessage(`"${executable.name}" is now default. Use settings \`blender.executables\` to change that.`); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'path'; 2 | 3 | const mainDir = dirname(__dirname); 4 | export const pythonFilesDir = join(mainDir, 'pythonFiles'); 5 | export const templateFilesDir = join(pythonFilesDir, 'templates'); 6 | export const launchPath = join(pythonFilesDir, 'launch.py'); 7 | export const generatedDir = join(mainDir, 'generated'); 8 | -------------------------------------------------------------------------------- /src/python_debugging.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as vscode from 'vscode'; 3 | import { getStoredScriptFolders } from './commands_scripts'; 4 | import { AddonPathMapping } from './communication'; 5 | import { outputChannel } from './extension'; 6 | import { getAnyWorkspaceFolder } from './utils'; 7 | 8 | type PathMapping = { localRoot: string, remoteRoot: string }; 9 | 10 | export async function attachPythonDebuggerToBlender( 11 | port: number, blenderPath: string, justMyCode: boolean, scriptsFolder: string, 12 | addonPathMappings: AddonPathMapping[], identifier: string) { 13 | 14 | let mappings = await getPythonPathMappings(scriptsFolder, addonPathMappings); 15 | return attachPythonDebugger(port, justMyCode, mappings, identifier); 16 | } 17 | 18 | function attachPythonDebugger(port: number, justMyCode: boolean, pathMappings: PathMapping[], identifier: string) { 19 | let configuration: vscode.DebugConfiguration = { 20 | name: `Python at Port ${port}`, 21 | request: "attach", 22 | type: 'python', 23 | port: port, 24 | host: 'localhost', 25 | pathMappings: pathMappings, 26 | justMyCode: justMyCode, 27 | identifier: identifier, 28 | }; 29 | 30 | outputChannel.appendLine("Python debug configuration: " + JSON.stringify(configuration, undefined, 2)); 31 | 32 | return vscode.debug.startDebugging(undefined, configuration); 33 | } 34 | 35 | async function getPythonPathMappings(scriptsFolder: string, addonPathMappings: AddonPathMapping[]) { 36 | let mappings = []; 37 | 38 | // first, add the mapping to the addon as it is the most specific one. 39 | mappings.push(...addonPathMappings.map(item => ({ 40 | localRoot: item.src, 41 | remoteRoot: item.load 42 | }))); 43 | 44 | // add optional scripts folders 45 | for (let folder of getStoredScriptFolders()) { 46 | mappings.push({ 47 | localRoot: folder.path, 48 | remoteRoot: folder.path 49 | }); 50 | } 51 | 52 | // add blender scripts last, otherwise it seem to take all the scope and not let the proper mapping of other files 53 | mappings.push(await getBlenderScriptsPathMapping(scriptsFolder)); 54 | 55 | // add the workspace folder as last resort for mapping loose scripts inside it 56 | let wsFolder = getAnyWorkspaceFolder(); 57 | mappings.push({ 58 | localRoot: wsFolder.uri.fsPath, 59 | remoteRoot: wsFolder.uri.fsPath 60 | }); 61 | 62 | // change drive letter for some systems 63 | fixMappings(mappings); 64 | return mappings; 65 | } 66 | 67 | async function getBlenderScriptsPathMapping(scriptsFolder: string): Promise { 68 | return { 69 | localRoot: scriptsFolder, 70 | remoteRoot: scriptsFolder 71 | }; 72 | } 73 | 74 | function fixMappings(mappings: PathMapping[]) { 75 | for (let i = 0; i < mappings.length; i++) { 76 | mappings[i].localRoot = fixPath(mappings[i].localRoot); 77 | } 78 | } 79 | 80 | /* This is to work around a bug where vscode does not find 81 | * the path: c:\... but only C:\... on windows. 82 | * https://github.com/Microsoft/vscode-python/issues/2976 */ 83 | function fixPath(filepath: string) { 84 | if (os.platform() !== 'win32') return filepath; 85 | 86 | if (filepath.match(/^[a-zA-Z]:/) !== null) { 87 | return filepath[0].toUpperCase() + filepath.substring(1); 88 | } 89 | 90 | return filepath; 91 | } 92 | -------------------------------------------------------------------------------- /src/select_utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { cancel } from './utils'; 3 | import { QuickPickItem } from 'vscode'; 4 | import { BlenderExecutableData } from './blender_executable'; 5 | 6 | export interface PickItem extends QuickPickItem { 7 | data?: any | (() => Promise), 8 | } 9 | 10 | export async function letUserPickItem(items: PickItem[], placeholder: undefined | string = undefined): Promise { 11 | let quickPick = vscode.window.createQuickPick(); 12 | quickPick.items = items; 13 | quickPick.placeholder = placeholder; 14 | 15 | return new Promise((resolve, reject) => { 16 | quickPick.onDidAccept(() => { 17 | resolve(quickPick.activeItems[0]); 18 | quickPick.hide(); 19 | }); 20 | quickPick.onDidHide(() => { 21 | reject(cancel()); 22 | quickPick.dispose(); 23 | }); 24 | quickPick.show(); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as vscode from 'vscode'; 4 | import * as crypto from 'crypto'; 5 | import { BlenderExecutableSettings } from './blender_executable'; 6 | 7 | const CANCEL = 'CANCEL'; 8 | 9 | export function cancel() { 10 | return new Error(CANCEL); 11 | } 12 | 13 | export async function waitUntilTaskEnds(taskName: string) { 14 | return new Promise(resolve => { 15 | let disposable = vscode.tasks.onDidEndTask(e => { 16 | if (e.execution.task.name === taskName) { 17 | disposable.dispose(); 18 | resolve(); 19 | } 20 | }); 21 | }); 22 | } 23 | 24 | export async function executeTask(taskName: string, wait: boolean = false) { 25 | await vscode.commands.executeCommand('workbench.action.tasks.runTask', taskName); 26 | if (wait) { 27 | await waitUntilTaskEnds(taskName); 28 | } 29 | } 30 | 31 | export function getWorkspaceFolders() { 32 | let folders = vscode.workspace.workspaceFolders; 33 | if (folders === undefined) return []; 34 | else return folders; 35 | } 36 | 37 | export function getAnyWorkspaceFolder() { 38 | let folders = getWorkspaceFolders(); 39 | if (folders.length === 0) { 40 | throw new Error('no workspace folder found'); 41 | } 42 | return folders[0]; 43 | } 44 | 45 | export function handleErrors(func: (args?: any) => Promise) { 46 | return async (args?: any) => { 47 | try { 48 | await func(args); 49 | } 50 | catch (err: any) { 51 | if (err instanceof Error) { 52 | if (err.message !== CANCEL) { 53 | vscode.window.showErrorMessage(err.message); 54 | } 55 | } 56 | } 57 | }; 58 | } 59 | 60 | export function getRandomString(length: number = 12) { 61 | return crypto.randomBytes(length).toString('hex').substring(0, length); 62 | } 63 | 64 | export function readTextFile(path: string) { 65 | return new Promise((resolve, reject) => { 66 | fs.readFile(path, 'utf8', (err, data) => { 67 | if (err !== null) { 68 | reject(new Error(`Could not read the file: ${path}`)); 69 | } 70 | else { 71 | resolve(data); 72 | } 73 | }); 74 | }); 75 | } 76 | 77 | export async function writeTextFile(path: string, content: string) { 78 | return new Promise((resove, reject) => { 79 | fs.writeFile(path, content, err => { 80 | if (err !== null) { 81 | return reject(err); 82 | } 83 | else { 84 | resove(); 85 | } 86 | }); 87 | }); 88 | } 89 | 90 | export async function renamePath(oldPath: string, newPath: string) { 91 | return new Promise((resolve, reject) => { 92 | fs.rename(oldPath, newPath, err => { 93 | if (err !== null) { 94 | reject(err); 95 | } 96 | else { 97 | resolve(); 98 | } 99 | }); 100 | }); 101 | } 102 | 103 | export async function copyFile(from: string, to: string) { 104 | return new Promise((resolve, reject) => { 105 | fs.copyFile(from, to, err => { 106 | if (err === null) resolve(); 107 | else reject(err); 108 | }); 109 | }); 110 | } 111 | 112 | export async function pathExists(path: string) { 113 | return new Promise(resolve => { 114 | fs.stat(path, (err, stats) => { 115 | resolve(err === null); 116 | }); 117 | }); 118 | } 119 | 120 | export async function pathsExist(paths: string[]) { 121 | let promises = paths.map(p => pathExists(p)); 122 | let results = await Promise.all(promises); 123 | return results.every(v => v); 124 | } 125 | 126 | export async function getSubfolders(root: string) { 127 | return new Promise((resolve, reject) => { 128 | fs.readdir(root, { encoding: 'utf8' }, async (err, files) => { 129 | if (err !== null) { 130 | reject(err); 131 | return; 132 | } 133 | 134 | let folders = []; 135 | for (let name of files) { 136 | let fullpath = path.join(root, name); 137 | if (await isDirectory(fullpath)) { 138 | folders.push(fullpath); 139 | } 140 | } 141 | 142 | resolve(folders); 143 | }); 144 | }); 145 | } 146 | 147 | export async function isDirectory(filepath: string) { 148 | return new Promise(resolve => { 149 | fs.stat(filepath, (err, stat) => { 150 | if (err !== null) resolve(false); 151 | else resolve(stat.isDirectory()); 152 | }); 153 | }); 154 | } 155 | 156 | export function getConfig(resource: vscode.Uri | undefined = undefined) { 157 | return vscode.workspace.getConfiguration('blender', resource); 158 | } 159 | 160 | export function getDefaultBlenderSettings(): BlenderExecutableSettings | undefined { 161 | let config = getConfig(); 162 | const settingsBlenderPaths = (config.get('executables')); 163 | const defaultBlenders = settingsBlenderPaths.filter(item => item.isDefault) 164 | const defaultBlender = defaultBlenders[0] 165 | return defaultBlender 166 | } 167 | 168 | export async function runTask( 169 | name: string, 170 | execution: vscode.ProcessExecution | vscode.ShellExecution, 171 | vscode_identifier: string, 172 | target: vscode.WorkspaceFolder = getAnyWorkspaceFolder(), 173 | ) { 174 | let taskDefinition = { type: vscode_identifier }; 175 | let source = 'blender'; 176 | let problemMatchers: string[] = []; 177 | if (execution.options === undefined) 178 | execution.options = {} 179 | if (execution.options.env === undefined) 180 | execution.options.env = {} 181 | execution.options.env['VSCODE_IDENTIFIER'] = vscode_identifier; 182 | let task = new vscode.Task(taskDefinition, target, name, source, execution, problemMatchers); 183 | let taskExecution = await vscode.tasks.executeTask(task); 184 | 185 | // if (wait) { 186 | // return new Promise(resolve => { 187 | // let disposable = vscode.tasks.onDidEndTask(e => { 188 | // if (e.execution.task.definition.type === vscode_identifier) { 189 | // disposable.dispose(); 190 | // resolve(taskExecution); 191 | // } 192 | // }); 193 | // }); 194 | // } 195 | // else { 196 | return taskExecution; 197 | // } 198 | } 199 | 200 | export function addFolderToWorkspace(folder: string) { 201 | /* Warning: This might restart all extensions if there was no folder before. */ 202 | vscode.workspace.updateWorkspaceFolders(getWorkspaceFolders().length, null, { uri: vscode.Uri.file(folder) }); 203 | } 204 | 205 | export function nameToIdentifier(name: string) { 206 | return name.toLowerCase().replace(/\W+/, '_'); 207 | } 208 | 209 | export function nameToClassIdentifier(name: string) { 210 | let parts = name.split(/\W+/); 211 | let result = ''; 212 | let allowNumber = false; 213 | for (let part of parts) { 214 | if (part.length > 0 && (allowNumber || !startsWithNumber(part))) { 215 | result += part.charAt(0).toUpperCase() + part.slice(1); 216 | allowNumber = true; 217 | } 218 | } 219 | return result; 220 | } 221 | 222 | export function startsWithNumber(text: string) { 223 | return text.charAt(0).match(/[0-9]/) !== null; 224 | } 225 | 226 | export function multiReplaceText(text: string, replacements: object) { 227 | for (let old of Object.keys(replacements)) { 228 | let matcher = RegExp(old, 'g'); 229 | text = text.replace(matcher, (replacements)[old]); 230 | } 231 | return text; 232 | } 233 | 234 | export function isValidPythonModuleName(text: string): boolean { 235 | let match = text.match(/^[_a-z][_0-9a-z]*$/i); 236 | return match !== null; 237 | } 238 | 239 | export function toTitleCase(str: string) { 240 | return str.replace( 241 | /\w\S*/g, 242 | text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() 243 | ); 244 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "es2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-string-throw": true, 4 | "no-unused-expression": true, 5 | "no-duplicate-variable": true, 6 | "curly": [true, "ignore-same-line"], 7 | "class-name": true, 8 | "semicolon": [ 9 | true, 10 | "always" 11 | ], 12 | "triple-equals": true 13 | }, 14 | "defaultSeverity": "warning" 15 | } 16 | --------------------------------------------------------------------------------