[
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n.idea/\nconfig.ini"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 ZHU Xinyu\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Blender Add-on Development Framework and Packaging Tool\n\n### Demo 1: Auto-update while developing 开发过程支持自动更新\n\n![Demo1](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo1.gif)\n\n### Demo 2: Built-in I18n solution 内置字典翻译方案\n\n![Demo2](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo2.gif)\n\n### Demo 3: Load Blender Component class like Operation, Panel etc automatically 自动加载Blender组件类(Operation, Panel等)\n\n![Demo2](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo3.gif)\n\nThe project has been updated to use MIT open source license since 2024.12.02.\n\nThis project provides a lightweight, easy-to-use framework for developing and packaging Blender addons. The main\nfeatures include:\n\n1. A single command to create a template add-on, facilitating quick development.\n1. Support developing multiple addons in one project, which allows you to reuse functions across\n   different addons, and helps you package only necessary modules into the zip file.\n1. Allows you to run and test your add-on in Blender with a single command. Support hot swap\n   for code updates. You can see the updates in Blender immediately after saving the code.\n1. A single command to package the add-on into an installable addon package, making it easier for users to install. The\n   packaging tool automatically detects and includes dependencies needed for the add-on within your project. (Not\n   including 3rd party libraries)\n1. Provide utility functions for basic development, like an auto-load utility for automatic class loading and an\n   internationalization (i18n)\n   tool, to help new developers creating high-quality addons.\n1. Support extension development in Blender 4.2 and later versions. You can choose to package your addon\n   as a legacy addon or as an extension by setting the IS_EXTENSION configuration\n\nYou can check out an overview about this framework on YouTube:\n\n- https://www.youtube.com/watch?v=eRSXO_WkY0s\n- https://youtu.be/udPBrXJZT1g\n\nThe following external library will be installed automatically when you run the framework code, you can also install\nthem\nmanually:\n\n- https://github.com/nutti/fake-bpy-module\n- https://github.com/gorakhargosh/watchdog\n\n## Basic Framework\n\n- [main.py](main.py): Configures the Blender path, add-on installation path, default add-on, package ignore files, and\n  add-on release path, among other settings.\n- [test.py](test.py): A testing tool to run and test add-ons.\n- [create.py](create.py): A tool to create add-ons, allowing you to quickly create an add-on based on the `sample_addon`\n  template.\n- [release.py](release.py): A packaging tool that packages add-ons into an installable package.\n- [framework.py](framework.py): The core business logic of the framework, which automates the development process.\n- [addons](addons): A directory to store add-ons, with each add-on in its own sub-directory. Use `create.py` to quickly\n  create a new add-on.\n- [common](common): A directory to store shared utilities.\n\n## Framework Development Guidelines\n\nBlender Version >= 2.93\nPlatform Supported: Windows, MacOs, Linux\n\nEach add-on, while adhering to the basic structure of a Blender add-on, should include a `config.py` file to configure\nthe add-on's package name, ensuring it doesn't conflict with other add-ons.\n\nThis project depends on the `addons` folder; do not rename this folder.\n\nWhen packaging an add-on, the framework will generate a __init__.py file in the add-on directory. By copying bl_info,\nand importing the register and unregister method from your target addon's __init__.py. Usually this won't cause any\nissue, but if you notice anything that might be related to this, please let us know.\n\n### Notice for extension developers\n\nTo meet the standard\nat https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#user-preferences-and-package\nYou need to follow the following instruction when using preferences in your extension addon. These instructions are also\napplicable to the legacy addon, but not enforced.\n\nSince addons developed by this framework usually have submodules. To access preferences, you must use the\n__addon_name__ defined in the config.py file as the bl_idname of the preferences.\n\nDefine:\n\n```python\nclass ExampleAddonPreferences(AddonPreferences):\n    bl_idname = __addon_name__\n```\n\nAccess\n\n```python\nfrom ..config import __addon_name__\n\naddon_prefs = bpy.context.preferences.addons[__addon_name__].preferences\n# use addon_prefs\naddon_prefs.some_property\n```\n\n## Usage\n\n1. Clone this repository.\n1. Open this project in your IDE. Optional: Configure the IDE to use the same python interpreter as Blender.\n1. Note: For PyCharm users, change the value idea.max.intellisense.filesize in idea.properties file ( Help | Edit Custom\n   Properties.) to more than 2600 because some modules have the issue of being too big for intelliSense to work. You\n   might also need to associate the __init__.pyi file as the python File Types\n   in ![setting](https://i.ibb.co/QcYZytw/script.png) to get the auto code completion working.\n1. Configure the name of the addon you want to create (ACTIVE_ADDON) in [main.py](main.py).\n1. Run create.py to create a new addon in your IDE. The first time you run this, it will download dependencies,\n   including\n   watchdog and fake-bpy-module.\n1. Develop your addon in the newly created addon directory.\n1. Run test.py to test your addon in Blender.\n1. Run release.py to package your addon into an installable package. The packaged addon path will appears in the\n   terminal when packaged successfully.\n\n## Features Provided by the Framework\n\n1. You don't need to worry about register and unregister classes in Blender add-ons. The framework automatically loads\n   and unloads classes in your add-ons. You just need to define your classes in the addon's folder. Note that the\n   classes that are automatically loaded need to be placed in a directory with an `__init__.py` file to be recognized\n   and loaded by the framework's auto load mechanism.\n1. You can use internationalization in your add-ons. Just add translations in the standard format to the `dictionary.py`\n   file in the `i18n` folder of your add-on.\n1. You can define RNA properties declaratively. Just follow the examples in the `__init__.py` file to add your RNA\n   properties. The framework will automatically register and unregister your RNA properties.\n1. You can choose to package your addon as a legacy addon or as an extension in Blender 4.2 and later versions. Just set\n   the `IS_EXTENSION` configuration to switch between the two. The framework will convert absolute import to relative\n   import for you when releasing.\n   Notice only `from XXX.XXX import XXX` is supported, `import XXX.XX` is not supported for converting to relative\n   import.\n1. You can use the `ExpandableUi` class in `common/types/framework.py` to easily extend Blender's native UI components,\n   such as menus, panels, pie menus, and headers. Just inherit from this class and implement the `draw` method. You can\n   specify the ID of the native UI component you want to extend using `target_id` and specify whether to append or\n   prepend using `expand_mode`.\n1. You can use the `reg_order` decorator in `common/types/framework.py` to specify the order of registration for your\n   classes. This is useful when you need to ensure that certain classes are registered before others. For example the\n   initial order of Panels will be in the order they are registered.\n\n## Add Optional Configuration File\n\nTo avoid having to modify the configuration items in `main.py` every time you update the framework, you can create a\n`config.ini` file in the root directory of your project to store your configuration information. This file will override\nthe configuration information in `main.py`.\n\nHere is an example of a `config.ini` file:\n\n```ini\n[blender]\n; path to the blender executable\nexe_path = C:/software/general/Blender/Blender3.5/blender.exe\n; exe_path = C:/software/general/Blender/Blender3.6/blender.exe\n\n; path to the addon directory, testing addon will be temporarily installed here\n; usually you don't need to configure this since it can be derived from the exe_path\naddon_path = C:/software/general/Blender/Blender3.5/scripts/addons/\n\n[default]\n; name of the addon to be created, tested and released\naddon = sample_addon\n; Whether the addon is an extension, if True, the addon will be packaged when released.\nis_extension = False\n; the path to store released addon zip files. Do not release to your source code directory\nrelease_dir = C:/path/to/release/dir\n; the path to store addon files used for testing, during testing, the framework will first release the addon to here and copy it to Blender's addon directory. Do not release to your source code directory\ntest_release_dir = C:/path/to/test/release/dir\n```\n\n## Contributions\n\n1. Framework Updates: If you are using this framework in your project and need to migrate to a newer version, you will\n   need to manually replace the framework files to get the new features. You may fork this project and use\n   `git fetch upstream` to update.\n   We are looking for more user-friendly migration\n   experience. In general, we aim to keep the framework lightweight and avoid making structural changes. Most future\n   updates are expected to just adding new features rather than making major changes to the framework structure. So\n   unless you personally made changes to the framework code locally, you will only need to replace the old files with\n   the new ones in future updates.\n1. Breakpoint Debugging: The framework currently does not support breakpoint debugging within the IDE. Implementing this\n   feature requires some modifications to the framework code, which may increase the complexity of using the framework.\n   We are looking for a lightweight solution to enable this feature. However, in general, breakpoint debugging is not\n   necessary for developing add-ons. Breakpoint debugging is helpful for complex add-ons features, but logging is\n   sufficient in most of the cases. For this framework, breakpoint debugging would be a nice-to-have feature, but not a\n   must-have.\n\n# Blender 插件开发框架及打包工具\n\n本项目已于2024.12.02更新为MIT开源许可证。\n\n本项目是一个轻量级的Blender插件开发框架和打包工具. 主要功能包括：\n\n1. 一条命令创建模版插件，方便进行快速开发\n1. 支持在一个项目中开发多个插件，可以让你在不同的插件之间复用函数功能，它可以自动检测功能模块之间的依赖关系，将相关联的模块打包到zip文件中，而不包含不必要的模块\n1. 在IDE中可以通过一条命令在Blender上运行插件的测试, 支持代码热更新，保存代码后可以立即在Blender中看到变化\n1. 一条命令将插件打包成一个安装包，方便用户安装，打包工具自动检测插件的依赖关系，自动打包插件所需的依赖文件(\n   不包括引用的外部库)\n1. 提供了常用的插件开发工具，比如自动加载类的auto_load工具，提供国际化翻译的i18n工具，方便新手开发者进行高水平插件开发\n1. 你可以选择将你的插件打包成传统插件或者扩展插件，只需要设置IS_EXTENSION配置即可切换，框架会在打包时自动将绝对导入转换为相对导入\n   注意只支持`from XXX.XXX import XXX`这种形式的转换，`import XXX.XX`这种形式的导入不支持转换为相对导入\n1. 兼容Blender 4.2及以后版本的扩展开发，你可以选择将你的插件打包成传统插件或者扩展插件，只需要设置IS_EXTENSION配置即可切换\n\n欢迎观看我们的中文视频教程：\n\n- [B站](https://www.bilibili.com/video/BV1Gn4y1d7Bp)\n- [深度技术讲解](https://www.bilibili.com/video/BV1VBqcY4E6x)\n- [YouTube](https://www.youtube.com/watch?v=Pjf7wg3IzDE&list=PLPkz3Ny42tJtxzw7xVUWvLI3FwEeETVOj&index=1&t=5s)\n\n下外部库会在框架代码运行时自动安装，你也可以手动安装它们：\n\n- https://github.com/nutti/fake-bpy-module\n- https://github.com/gorakhargosh/watchdog\n\n## 基础框架\n\n[main.py](main.py): 可以配置Blender路径，插件安装路径，当前默认插件，插件发布路径等\n\n[test.py](test.py): 测试工具，可以运行插件的测试\n\n[create.py](create.py): 创建插件的工具，可以根据sample_addon模版快速创建一个插件\n\n[release.py](release.py): 打包工具，可以将插件打包成一个安装包\n\n[framework.py](framework.py): 框架的核心业务代码，用于实现开发流程的自动化\n\n[addons](addons): 存放插件的目录，每个插件一个目录，使用create.py可以快速创建一个插件\n\n[common](common): 存放公共工具的目录\n\n## 框架开发要求\n\nBlender 版本 >= 2.93\n支持的平台: Windows, MacOs, Linux\n\n每个插件在符合Blender插件的结构基础上，需要有一个config.py文件用于配置插件的包名，避免与其他插件冲突。\n\n注意项目依赖addons文件夹，请勿更改这个文件夹的名称。\n\n在打包插件时，框架会在插件目录下生成一个__init__.py文件，这个__init__.py文件会复制你的插件的__init__.py文件中bl_info,\n并导入register和unregister方法。\n通常这不会引起任何问题，但如果你发现与这个有关的问题，请与我们联系。\n\n### 扩展插件开发注意事项\n\n为了满足https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#user-preferences-and-package\n的要求，当你在扩展插件中定义和使用偏好设置时，你需要遵循以下要求。这些要求也适用于传统插件，但不是强制的。\n\n由于本框架开发的插件通常带有子模块，为了定义和访问插件的偏好设置，你必须使用config.py文件中定义的__addon_name__作为偏好设置的bl_idname。\n\n定义偏好设置类：\n\n```python\nclass ExampleAddonPreferences(AddonPreferences):\n    bl_idname = __addon_name__\n```\n\n访问偏好设置：\n\n```python\nfrom ..config import __addon_name__\n\naddon_prefs = bpy.context.preferences.addons[__addon_name__].preferences\n# use addon_prefs\naddon_prefs.some_property\n```\n\n## 使用说明\n\n1. 克隆此项目。\n1. 在您的 IDE 中打开此项目。你可以将IDE使用的Python.exe配置成与Blender相同。\n1. 对于PyCharm用户，请将idea.properties文件(点击 Help | Edit Custom Properties.)\n   中的idea.max.intellisense.filesize的值更改为大于2600，因为某些模块的大小超过了intelliSense的工作范围。你可能需要将__init__\n   .pyi文件关联到python File Types ![setting](https://i.ibb.co/QcYZytw/script.png) 以使自动代码补全正常工作。\n1. 在 [main.py](main.py) 中配置 Blender 可执行文件路径（BLENDER_EXE_PATH）\n1. 在 [main.py](main.py) 中配置您想要创建的插件名称（ACTIVE_ADDON）。\n1. 运行 create.py 在您的 IDE 中创建一个新的插件。第一次运行时需要联网下载依赖库,包括watchdog和fake-bpy-module\n1. 在新创建的插件目录中开发您的插件。\n1. 运行 test.py 在 Blender 中测试您的插件。\n1. 运行 release.py 将您的插件打包成可安装的包。成功打包后，终端中将显示打包插件的路径。\n\n## 框架提供的功能\n\n1.\n你基本上无需关心Blender插件的类的加载和卸载，框架会自动加载和卸载你的插件中的类，你只需要在插件目录下定义你的类即可，注意自动加载的类需要放在有__init__\n.py文件的目录下才能被框架自动类加载机制识别并加载\n1. 你可以在插件中使用国际化翻译，只需要在插件文件夹中的i18n中的dictionary.py文件中按标准格式添加翻译即可\n1. 你可以使用声明式的方式定义RNA属性，只需要根据__init__.py中的注释示例添加你的RNA属性即可，框架会自动注册和卸载你的RNA属性\n1. 你可以使用common/types/framework.py中的ExpandableUi类来方便的扩展Blender原生的菜单，面板，饼菜单，标题栏等UI组件,\n   只需继承该类并实现draw方法，你可以通过target_id来指定需要扩展的原生UI组件的ID,\n   通过expand_mode来指定向前还是向后扩展。\n1. 你可以使用common/types/framework.py中的reg_order装饰器来指定类的注册顺序，当你需要确保某些类在其他类之前注册时，可以利用这个功能。\n   比如Panel的初始顺序将会按照注册的顺序来排列。\n\n## 添加可选的配置文件\n\n为了避免每次更新框架时需要重新修改main.py中的配置，你可以在项目的根目录下创建一个config.ini文件，用于存放你的配置信息，\n这个文件会覆盖main.py中的配置信息。\n\n以下是一个config.ini文件的示例：\n\n```ini\n[blender]\n; Blender的可执行文件路径\nexe_path = C:/software/general/Blender/Blender3.5/blender.exe\n; exe_path = C:/software/general/Blender/Blender3.6/blender.exe\n\n; 插件目录路径，测试时插件将被临时安装到这里\n; 通常不需要配置此项，因为框架可以通过exe_path的路径推导出来\naddon_path = C:/software/general/Blender/Blender3.5/scripts/addons/\n[default]\n; 创建、测试和发布的目标插件名称\naddon = sample_addon\n; 插件是否为扩展，如果为True，则插件在发布时会被打包成扩展的形式\nis_extension = False\n; 发布插件zip文件的存放路径。注意不要发布到源码所在的目录中\nrelease_dir = C:/path/to/release/dir\n; 用于测试时插件文件的临时存放路径，测试是框架首先会发布插件到这里，然后再复制到Blender的插件目录。注意不要发布到源码所在的目录中\ntest_release_dir = C:/path/to/test/release/dir\n```\n\n## 框架在以下方面可进一步完善，欢迎贡献意见和代码\n\n1. 框架的更新：如果你已经在项目中使用了这个框架进行开发，当你需要迁移到更新的框架版本时，你需要手动替换框架代码文件来获取新的功能，你可以fork这个项目，然后使用\n   `git fetch upstream`来拉取最新的代码。\n   我们希望探索一些更友好的方式来帮助更新框架代码，欢迎提供意见。但总的来说，我们希望框架保持轻量，不会在结构上有太大的变化。\n   可以预计未来的大部分更新都是在增加新的功能，而不是对框架结构进行大的调整。除非你在本地对框架代码有所改动，未来的更新你只需要将新的文件替换旧的文件即可。\n1. 断点调试：目前框架并不支持IDE内的断点调试，实现这个功能需要对框架代码进行一些修改，这也许会增加框架的使用难度，我们力求寻找尽量轻量级的解决方案。但总的来说，\n   断点调试对应开发插件并不是必须的，大部分的插件功能并没有复杂到需要断点调试，打印日志也可以达到大部分的调试的目的。对于这个框架，如果我们力求寻找一个简单的方案来支持断点调试，但这不是必须的。\n"
  },
  {
    "path": "addons/__init__.py",
    "content": ""
  },
  {
    "path": "addons/sample_addon/__init__.py",
    "content": "import bpy\n\nfrom .config import __addon_name__\nfrom .i18n.dictionary import dictionary\nfrom ...common.class_loader import auto_load\nfrom ...common.class_loader.auto_load import add_properties, remove_properties\nfrom ...common.i18n.dictionary import common_dictionary\nfrom ...common.i18n.i18n import load_dictionary\n\n# Add-on info\nbl_info = {\n    \"name\": \"Basic Add-on Sample\",\n    \"author\": \"[You name]\",\n    \"blender\": (3, 5, 0),\n    \"version\": (0, 0, 1),\n    \"description\": \"This is a template for building addons\",\n    \"warning\": \"\",\n    \"doc_url\": \"[documentation url]\",\n    \"tracker_url\": \"[contact email]\",\n    \"support\": \"COMMUNITY\",\n    \"category\": \"3D View\"\n}\n\n_addon_properties = {}\n\n\n# You may declare properties like following, framework will automatically add and remove them.\n# Do not define your own property group class in the __init__.py file. Define it in a separate file and import it here.\n# 注意不要在__init__.py文件中自定义PropertyGroup类。请在单独的文件中定义它们并在此处导入。\n# _addon_properties = {\n#     bpy.types.Scene: {\n#         \"property_name\": bpy.props.StringProperty(name=\"property_name\"),\n#     },\n# }\n\ndef register():\n    # Register classes\n    auto_load.init()\n    auto_load.register()\n    add_properties(_addon_properties)\n\n    # Internationalization\n    load_dictionary(dictionary)\n    bpy.app.translations.register(__addon_name__, common_dictionary)\n\n    print(\"{} addon is installed.\".format(__addon_name__))\n\n\ndef unregister():\n    # Internationalization\n    bpy.app.translations.unregister(__addon_name__)\n    # unRegister classes\n    auto_load.unregister()\n    remove_properties(_addon_properties)\n    print(\"{} addon is uninstalled.\".format(__addon_name__))\n"
  },
  {
    "path": "addons/sample_addon/blender_manifest.toml",
    "content": "schema_version = \"1.0.0\"\n\n# Example of manifest file for a Blender extension\n# Change the values according to your extension\n# id must be the same as the directory name, and must be unique\nid = \"sample_addon\"\nversion = \"0.0.1\"\nname = \"Basic Add-on Sample\"\ntagline = \"This is another extension\"\nmaintainer = \"Developer name <email@address.com>\"\n# Supported types: \"add-on\", \"theme\"\ntype = \"add-on\"\n\n# Optional link to documentation, support, source files, etc\n# website = \"https://extensions.blender.org/add-ons/my-example-package/\"\n\n# Optional list defined by Blender and server, see:\n# https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html\ntags = [\"Animation\", \"Sequencer\"]\n\nblender_version_min = \"4.2.0\"\n# # Optional: Blender version that the extension does not support, earlier versions are supported.\n# # This can be omitted and defined later on the extensions platform if an issue is found.\n# blender_version_max = \"5.1.0\"\n\n# License conforming to https://spdx.org/licenses/ (use \"SPDX: prefix)\n# https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html\nlicense = [\n    \"SPDX:GPL-3.0-or-later\",\n]\n# Optional: required by some licenses.\n# copyright = [\n#   \"2002-2024 Developer Name\",\n#   \"1998 Company Name\",\n# ]\n\n# Optional list of supported platforms. If omitted, the extension will be available in all operating systems.\n# platforms = [\"windows-x64\", \"macos-arm64\", \"linux-x64\"]\n# Other supported platforms: \"windows-arm64\", \"macos-x64\"\n\n# Optional: bundle 3rd party Python modules.\n# https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html\n# wheels = [\n#   \"./wheels/hexdump-3.3-py3-none-any.whl\",\n#   \"./wheels/jsmin-3.0.1-py3-none-any.whl\",\n# ]\n\n# # Optional: add-ons can list which resources they will require:\n# # * files (for access of any filesystem operations)\n# # * network (for internet access)\n# # * clipboard (to read and/or write the system clipboard)\n# # * camera (to capture photos and videos)\n# # * microphone (to capture audio)\n# #\n# # If using network, remember to also check `bpy.app.online_access`\n# # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access\n# #\n# # For each permission it is important to also specify the reason why it is required.\n# # Keep this a single short sentence without a period (.) at the end.\n# # For longer explanations use the documentation or detail page.\n#\n# [permissions]\n# network = \"Need to sync motion-capture data to server\"\n# files = \"Import/export FBX from/to disk\"\n# clipboard = \"Copy and paste bone transforms\"\n\n# Optional: build settings.\n# https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build\n# [build]\n# paths_exclude_pattern = [\n#   \"__pycache__/\",\n#   \"/.git/\",\n#   \"/*.zip\",\n# ]"
  },
  {
    "path": "addons/sample_addon/config.py",
    "content": "from ...common.types.framework import is_extension\n\n# https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#extensions-and-namespace\n# This is the unique package name of the addon, it is different from the add-on name in bl_info.\n# This name is used to identify the add-on in python code. It should also be the same to the package name of the add-on.\n__addon_name__ = \".\".join(__package__.split(\".\")[0:3]) if is_extension() else __package__.split(\".\")[0]\n"
  },
  {
    "path": "addons/sample_addon/i18n/__init__.py",
    "content": ""
  },
  {
    "path": "addons/sample_addon/i18n/dictionary.py",
    "content": "from common.i18n.dictionary import preprocess_dictionary\n\ndictionary = {\n    \"zh_CN\": {\n        (\"*\", \"Example Addon Side Bar Panel\"): \"示例插件面板\",\n        (\"*\", \"Example Functions\"): \"示例功能\",\n        (\"*\", \"ExampleAddon\"): \"示例插件\",\n        (\"*\", \"Resource Folder\"): \"资源文件夹\",\n        (\"*\", \"Int Config\"): \"整数参数\",\n        # This is not a standard way to define a translation, but it is still supported with preprocess_dictionary.\n        \"Boolean Config\": \"布尔参数\",\n        \"Second Panel\": \"第二面板\",\n        (\"*\", \"Add-on Preferences View\"): \"插件设置面板\",\n        (\"Operator\", \"ExampleOperator\"): \"示例操作\",\n    }\n}\n\ndictionary = preprocess_dictionary(dictionary)\n\ndictionary[\"zh_HANS\"] = dictionary[\"zh_CN\"]\n"
  },
  {
    "path": "addons/sample_addon/operators/AddonOperators.py",
    "content": "import bpy\n\nfrom ..config import __addon_name__\nfrom ..preference.AddonPreferences import ExampleAddonPreferences\n\n\n# This Example Operator will scale up the selected object\nclass ExampleOperator(bpy.types.Operator):\n    '''ExampleAddon'''\n    bl_idname = \"object.example_ops\"\n    bl_label = \"ExampleOperator\"\n\n    # 确保在操作之前备份数据，用户撤销操作时可以恢复\n    bl_options = {'REGISTER', 'UNDO'}\n\n    @classmethod\n    def poll(cls, context: bpy.types.Context):\n        return context.active_object is not None\n\n    def execute(self, context: bpy.types.Context):\n        addon_prefs = bpy.context.preferences.addons[__addon_name__].preferences\n        assert isinstance(addon_prefs, ExampleAddonPreferences)\n        # use operator\n        # bpy.ops.transform.resize(value=(2, 2, 2))\n\n        # manipulate the scale directly\n        context.active_object.scale *= addon_prefs.number\n        return {'FINISHED'}\n"
  },
  {
    "path": "addons/sample_addon/operators/__init__.py",
    "content": ""
  },
  {
    "path": "addons/sample_addon/panels/AddonPanels.py",
    "content": "import bpy\n\nfrom ..config import __addon_name__\nfrom ..operators.AddonOperators import ExampleOperator\nfrom ....common.i18n.i18n import i18n\nfrom ....common.types.framework import reg_order\n\n\nclass BasePanel(object):\n    bl_space_type = \"VIEW_3D\"\n    bl_region_type = \"UI\"\n    bl_category = \"ExampleAddon\"\n\n    @classmethod\n    def poll(cls, context: bpy.types.Context):\n        return True\n\n\n@reg_order(0)\nclass ExampleAddonPanel(BasePanel, bpy.types.Panel):\n    bl_label = \"Example Addon Side Bar Panel\"\n    bl_idname = \"SCENE_PT_sample\"\n\n    def draw(self, context: bpy.types.Context):\n        addon_prefs = context.preferences.addons[__addon_name__].preferences\n\n        layout = self.layout\n\n        layout.label(text=i18n(\"Example Functions\") + \": \" + str(addon_prefs.number))\n        layout.prop(addon_prefs, \"filepath\")\n        layout.separator()\n\n        row = layout.row()\n        row.prop(addon_prefs, \"number\")\n        row.prop(addon_prefs, \"boolean\")\n\n        layout.operator(ExampleOperator.bl_idname)\n\n    @classmethod\n    def poll(cls, context: bpy.types.Context):\n        return True\n\n\n# This panel will be drawn after ExampleAddonPanel since it has a higher order value\n@reg_order(1)\nclass ExampleAddonPanel2(BasePanel, bpy.types.Panel):\n    bl_label = \"Example Addon Side Bar Panel\"\n    bl_idname = \"SCENE_PT_sample2\"\n\n    def draw(self, context: bpy.types.Context):\n        layout = self.layout\n        layout.label(text=\"Second Panel\")\n        layout.operator(ExampleOperator.bl_idname)\n"
  },
  {
    "path": "addons/sample_addon/panels/__init__.py",
    "content": ""
  },
  {
    "path": "addons/sample_addon/preference/AddonPreferences.py",
    "content": "import os\n\nimport bpy\nfrom bpy.props import StringProperty, IntProperty, BoolProperty\nfrom bpy.types import AddonPreferences\n\nfrom ..config import __addon_name__\n\n\nclass ExampleAddonPreferences(AddonPreferences):\n    # this must match the add-on name (the folder name of the unzipped file)\n    bl_idname = __addon_name__\n\n    # https://docs.blender.org/api/current/bpy.props.html\n    # The name can't be dynamically translated during blender programming running as they are defined\n    # when the class is registered, i.e. we need to restart blender for the property name to be correctly translated.\n    filepath: StringProperty(\n        name=\"Resource Folder\",\n        default=os.path.join(os.path.expanduser(\"~\"), \"Documents\", __addon_name__),\n        subtype='DIR_PATH',\n    )\n    number: IntProperty(\n        name=\"Int Config\",\n        default=2,\n    )\n    boolean: BoolProperty(\n        name=\"Boolean Config\",\n        default=False,\n    )\n\n    def draw(self, context: bpy.types.Context):\n        layout = self.layout\n        layout.label(text=\"Add-on Preferences View\")\n        layout.prop(self, \"filepath\")\n        layout.prop(self, \"number\")\n        layout.prop(self, \"boolean\")\n"
  },
  {
    "path": "addons/sample_addon/preference/__init__.py",
    "content": ""
  },
  {
    "path": "common/__init__.py",
    "content": ""
  },
  {
    "path": "common/class_loader/__init__.py",
    "content": ""
  },
  {
    "path": "common/class_loader/auto_load.py",
    "content": "import importlib\nimport inspect\nimport pkgutil\nimport typing\nfrom pathlib import Path\n\nimport bpy\n\n__all__ = (\n    \"init\",\n    \"register\",\n    \"unregister\",\n    \"add_properties\",\n    \"remove_properties\",\n)\n\nfrom ..types.framework import ExpandableUi, is_extension\n\nblender_version = bpy.app.version\n\nmodules = None\nordered_classes = None\nframe_work_classes = None\n\n\ndef init():\n    global modules\n    global ordered_classes\n    global frame_work_classes\n    # notice here, the path root is the root of the project\n    modules = get_all_submodules(Path(__file__).parent.parent.parent)\n    ordered_classes = get_ordered_classes_to_register(modules)\n    frame_work_classes = get_framework_classes(modules)\n\n\ndef register():\n    for cls in ordered_classes:\n        bpy.utils.register_class(cls)\n\n    for module in modules:\n        if module.__name__ == __name__:\n            continue\n        if hasattr(module, \"register\"):\n            module.register()\n\n    for cls in frame_work_classes:\n        register_framework_class(cls)\n\ndef unregister():\n    for cls in reversed(ordered_classes):\n        bpy.utils.unregister_class(cls)\n\n    for module in modules:\n        if module.__name__ == __name__:\n            continue\n        if hasattr(module, \"unregister\"):\n            module.unregister()\n\n    for cls in frame_work_classes:\n        unregister_framework_class(cls)\n\n\n# Import modules\n#################################################\n\ndef get_all_submodules(directory):\n    return list(iter_submodules(directory))\n\n\ndef iter_submodules(path):\n    import_as_extension = is_extension()\n    for name in sorted(iter_submodule_names(path)):\n        if import_as_extension:\n            yield importlib.import_module(\"...\" + name, __package__)\n        else:\n            yield importlib.import_module(\".\" + name, path.name)\n\n\ndef iter_submodule_names(path, root=\"\"):\n    for _, module_name, is_package in pkgutil.iter_modules([str(path)]):\n        if is_package:\n            sub_path = path / module_name\n            sub_root = root + module_name + \".\"\n            yield from iter_submodule_names(sub_path, sub_root)\n        else:\n            yield root + module_name\n\n\n# Find classes to register\n#################################################\n\ndef get_ordered_classes_to_register(modules):\n    return toposort(get_register_deps_dict(modules))\n\n\ndef get_framework_classes(modules):\n    base_types = get_framework_base_classes()\n    all_framework_classes = set()\n    for cls in get_classes_in_modules(modules):\n        if any(base in base_types for base in cls.__mro__[1:]):\n            all_framework_classes.add(cls)\n    return all_framework_classes\n\n\ndef get_register_deps_dict(modules):\n    my_classes = set(iter_my_classes(modules))\n    my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, \"bl_idname\")}\n\n    deps_dict = {}\n    for cls in my_classes:\n        deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))\n    return deps_dict\n\n\ndef iter_my_register_deps(cls, my_classes, my_classes_by_idname):\n    yield from iter_my_deps_from_annotations(cls, my_classes)\n    yield from iter_my_deps_from_inheritance(cls, my_classes)\n    yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname)\n\n\ndef iter_my_deps_from_annotations(cls, my_classes):\n    # Fix for https://github.com/xzhuah/BlenderAddonPackageTool/issues/15\n    import sys\n    module = sys.modules.get(cls.__module__)\n    globalns = module.__dict__ if module else {}\n    for value in typing.get_type_hints(cls, globalns, {}).values():\n        dependency = get_dependency_from_annotation(value)\n        if dependency is not None:\n            if dependency in my_classes:\n                yield dependency\n\n\ndef iter_my_deps_from_inheritance(cls, my_classes):\n    for base_cls in cls.__mro__[1:]:\n        if base_cls in my_classes:\n            yield base_cls\n\n\ndef get_dependency_from_annotation(value):\n    if blender_version >= (2, 93):\n        # Avoid checking private types\n        # if isinstance(value, bpy.props._PropertyDeferred):\n        #     return value.keywords.get(\"type\")\n        if hasattr(value, \"keywords\") and isinstance(value.keywords, dict):\n            return value.keywords.get(\"type\")\n    else:\n        if isinstance(value, tuple) and len(value) == 2:\n            if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty):\n                return value[1][\"type\"]\n    return None\n\n\ndef iter_my_deps_from_parent_id(cls, my_classes_by_idname):\n    if bpy.types.Panel in cls.__mro__[1:]:\n        parent_idname = getattr(cls, \"bl_parent_id\", None)\n        if parent_idname is not None:\n            parent_cls = my_classes_by_idname.get(parent_idname)\n            if parent_cls is not None:\n                yield parent_cls\n\n\ndef iter_my_classes(modules):\n    base_types = get_register_base_types()\n    for cls in get_classes_in_modules(modules):\n        if any(base in base_types for base in cls.__mro__[1:]):\n            if not getattr(cls, \"is_registered\", False):\n                yield cls\n\n\ndef get_classes_in_modules(modules):\n    classes = set()\n    for module in modules:\n        for cls in iter_classes_in_module(module):\n            classes.add(cls)\n    return classes\n\n\ndef iter_classes_in_module(module):\n    for value in module.__dict__.values():\n        if inspect.isclass(value):\n            yield value\n\n\ndef get_register_base_types():\n    names = [\n        \"Panel\", \"Operator\", \"PropertyGroup\",\n        \"AddonPreferences\", \"Header\", \"Menu\",\n        \"Node\", \"NodeSocket\", \"NodeTree\",\n        \"UIList\", \"RenderEngine\",\n        \"Gizmo\", \"GizmoGroup\",\n        \"FileHandler\",\n    ]\n    return {getattr(bpy.types, name) for name in names if hasattr(bpy.types, name)}\n\n\ndef get_framework_base_classes():\n    return {ExpandableUi}\n\n\n# Find order to register to solve dependencies\n#################################################\n\ndef toposort(deps_dict):\n    # Disallow circular dependencies\n    if deps_dict:\n        test_deps = {k: v.copy() for k, v in deps_dict.items()}\n        for _ in range(len(deps_dict)):\n            resolved = [k for k, v in test_deps.items() if not v]\n            if not resolved:\n                # 避免循环依赖\n                raise ValueError(f\"Circular dependency detected: {list(test_deps.keys())}\")\n            for k in resolved:\n                del test_deps[k]\n            for v in test_deps.values():\n                v -= set(resolved)\n            if not test_deps:\n                break\n\n    sorted_list = []\n    sorted_values = set()\n    while len(deps_dict) > 0:\n        unsorted = []\n        # class with no dependencies\n        independent = []\n        for value, deps in deps_dict.items():\n            if len(deps) == 0:\n                independent.append(value)\n            else:\n                unsorted.append(value)\n\n        # sort no dependencies by _reg_order\n        independent.sort(key=lambda x: getattr(x, \"_reg_order\", float('inf')))\n        # add to sorted list\n        for value in independent:\n            sorted_list.append(value)\n            sorted_values.add(value)\n\n        deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted}\n    return sorted_list\n\n\ndef register_framework_class(cls):\n    if issubclass(cls, ExpandableUi):\n        if hasattr(bpy.types, cls.target_id):\n            if cls.expand_mode == \"APPEND\":\n                getattr(bpy.types, cls.target_id).append(cls.draw)\n            elif cls.expand_mode == \"PREPEND\":\n                getattr(bpy.types, cls.target_id).prepend(cls.draw)\n            else:\n                raise ValueError(f\"Invalid expand_mode: {cls.expand_mode}\")\n        else:\n            print(f\"Warning: Target ID not found: {cls.target_id}\")\n\n\ndef unregister_framework_class(cls):\n    if issubclass(cls, ExpandableUi):\n        if hasattr(bpy.types, cls.target_id):\n            getattr(bpy.types, cls.target_id).remove(cls.draw)\n\n\n# support adding properties in a declarative way\ndef add_properties(property_dict: dict[typing.Any, dict[str, typing.Any]]):\n    for cls, properties in property_dict.items():\n        for name, prop in properties.items():\n            setattr(cls, name, prop)\n\n\n# support removing properties in a declarative way\ndef remove_properties(property_dict: dict[typing.Any, dict[str, typing.Any]]):\n    for cls, properties in property_dict.items():\n        for name in properties.keys():\n            if hasattr(cls, name):\n                delattr(cls, name)\n"
  },
  {
    "path": "common/class_loader/module_installer.py",
    "content": "# Notice: Please do not use functions in this file for developing your Blender Addons, this file is for internal use of\n# the framework, it contains some modules that Blender officially prohibits using in Addons. Such as sys\n# 注意：请不要在Blender中使用此文件中的函数,此文件用于框架内部使用,包含了一些Blender官方禁止在插件中使用的模块 如sys\nimport importlib.metadata\nimport importlib.util\nimport os\nimport platform\nimport subprocess\nimport sys\n\n\ndef install(package):\n    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", package])\n\n\ndef has_module(module_name):\n    try:\n        return importlib.util.find_spec(module_name) is not None\n    except Exception as e:\n        return False\n\n\ndef is_package_installed(package_name):\n    try:\n        importlib.metadata.version(package_name)\n        return True\n    except importlib.metadata.PackageNotFoundError:\n        return False\n\n\ndef install_if_missing(package):\n    if not has_module(package):\n        install(package)\n\n\ndef get_blender_version(blender_exe_path):\n    try:\n        # Run the Blender executable with --version\n        result = subprocess.run(\n            [blender_exe_path, \"--version\"],\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True\n        )\n        # Check if the process was successful\n        if result.returncode == 0:\n            output = result.stdout\n            # Parse the version from the output\n            for line in output.splitlines():\n                if line.startswith(\"Blender\"):\n                    # Extract version number\n                    return line.split()[1]  # e.g., \"3.6.2\"\n        else:\n            print(\"Error running Blender:\", result.stderr)\n    except Exception as e:\n        print(f\"An error occurred when trying to determine Blender version: {e}\")\n    return None\n\n\ndef extract_blender_version(blender_exe_path: str):\n    \"\"\"Extract the first two version numbers from the Blender executable path.\"\"\"\n    version_str = get_blender_version(blender_exe_path)\n    try:\n        return \".\".join(version_str.split(\".\")[0:2])\n    except Exception as e:\n        print(f\"An error occurred when trying to extract Blender version from {version_str}: {e}\")\n    return None\n\n\ndef install_fake_bpy(blender_path: str):\n    blender_version = extract_blender_version(blender_path)\n    if blender_version is None:\n        print(\"Blender version not found in path: \" + blender_path)\n        blender_version = \"latest\"\n    desired_module = \"fake-bpy-module-\" + blender_version\n    if has_module(\"bpy\"):\n        if not is_package_installed(desired_module):\n            print(\"Your fake bpy module is different from the current blender version! You might need to update it.\")\n        return\n    else:\n        print(\"Installing fake bpy module for Blender version: \" + blender_version)\n        try:\n            install(desired_module)\n        except Exception as e:\n            if desired_module != \"fake-bpy-module-latest\":\n                print(\n                    \"Failed to install fake bpy module for Blender version: \" + blender_version + \"! Trying to install the latest version.\")\n                install(\"fake-bpy-module-latest\")\n\n\ndef normalize_blender_path_by_system(blender_path: str):\n    if is_mac():\n        if blender_path.endswith(\".app\"):\n            blender_path = os.path.join(blender_path, \"Contents/MacOS/Blender\")\n    return blender_path\n\n\ndef default_blender_addon_path(blender_path: str):\n    blender_path = normalize_blender_path_by_system(blender_path)\n    blender_version = extract_blender_version(blender_path)\n    assert blender_version is not None, \"Can not detect Blender version with \" + blender_path + \"!\"\n    if is_windows() or is_linux():\n        new_path = os.path.join(os.path.dirname(blender_path), blender_version, \"scripts\", \"addons_core\")\n        if os.path.exists(new_path):\n            return new_path\n        return os.path.join(os.path.dirname(blender_path), blender_version, \"scripts\", \"addons\")\n    elif is_mac():\n        user_path = os.path.expanduser(\"~\")\n        return os.path.join(user_path, f\"Library/Application Support/Blender/{blender_version}/scripts/addons\")\n    else:\n        raise Exception(\n            \"This Framework is currently not compatible with your operating system! Please use Windows, MacOS or Linux.\")\n\n\ndef is_windows():\n    return platform.system() == \"Windows\"\n\n\ndef is_linux():\n    return platform.system() == \"Linux\"\n\n\ndef is_mac():\n    return platform.system() == \"Darwin\"\n"
  },
  {
    "path": "common/i18n/__init__.py",
    "content": ""
  },
  {
    "path": "common/i18n/dictionary.py",
    "content": "common_dictionary = {\n    \"zh_CN\": {\n        # (\"*\", \"translation\"): \"翻译\",\n    }\n}\n\ncommon_dictionary[\"zh_HANS\"] = common_dictionary[\"zh_CN\"]\n\n\n# preprocess dictionary\ndef preprocess_dictionary(dictionary):\n    for key in dictionary:\n        invalid_items = {}\n        for translate_key in dictionary[key]:\n            if isinstance(translate_key, str):\n                invalid_items[translate_key] = dictionary[key][translate_key]\n        for invalid_item in invalid_items:\n            translation = invalid_items[invalid_item]\n            dictionary[key][(\"*\", invalid_item)] = translation\n            dictionary[key][(\"Operator\", invalid_item)] = translation\n            del dictionary[key][invalid_item]\n    return dictionary\n"
  },
  {
    "path": "common/i18n/i18n.py",
    "content": "import bpy\n\n# Get the language code when addon start up\n__language_code__ = bpy.context.preferences.view.language\n\nfrom .dictionary import common_dictionary\n\n__dictionary__ = common_dictionary\n\n\n# Dictionary for translation: https://docs.blender.org/api/current/bpy.app.translations.html\n# {\n#     \"en_US\": {\n#         (\"*\", code1): \"translation1\",\n#         (\"Operator\", code2): \"translation2\",\n#     },\n#     \"zh_CN\": {\n#         (\"*\", key): \"翻译\",\n#         (\"*, key2): \"翻译2\"\n#     }\n# }\n\n# Set a new dictionary for translation\ndef set_dictionary(new_dictionary: dict[str, dict[tuple, str]]):\n    global __dictionary__\n    __dictionary__ = new_dictionary\n\n\n# Load additional dictionary for translation\ndef load_dictionary(additional_dictionary: dict[str, dict[tuple, str]]):\n    global __dictionary__\n    for key in additional_dictionary:\n        if key in __dictionary__:\n            __dictionary__[key].update(additional_dictionary[key])\n        else:\n            __dictionary__[key] = {}\n            __dictionary__[key].update(additional_dictionary[key])\n\n\n# 在需要拼接字符串的地方使用i18n函数\ndef i18n(content: str) -> str:\n    global __language_code__, __dictionary__\n    __language_code__ = bpy.context.preferences.view.language\n    if __language_code__ not in __dictionary__:\n        return content\n    tuple_contents = [(\"*\", content), (\"Operator\", content)]\n    for tuple_content in tuple_contents:\n        if tuple_content in __dictionary__[__language_code__]:\n            return __dictionary__[__language_code__][tuple_content]\n    for key in __dictionary__[__language_code__]:\n        if key[1] == content:\n            return __dictionary__[__language_code__][key]\n    return content\n"
  },
  {
    "path": "common/io/FileManagerClient.py",
    "content": "import hashlib\nimport os\nfrom os import listdir\n\n\ndef get_all_filename(folder_path: str) -> list:\n    if os.path.exists(folder_path):\n        return [f for f in listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))]\n    else:\n        return []\n\n\ndef get_all_subfolder(folder_path: str) -> list:\n    return [f for f in listdir(folder_path) if os.path.isdir(os.path.join(folder_path, f))]\n\n\n# return true if path_a is a subdirectory under path_b\ndef is_subdirectory(path_a, path_b) -> bool:\n    path_a = os.path.abspath(path_a)\n    path_b = os.path.abspath(path_b)\n    return os.path.commonpath([path_b]) == os.path.commonpath([path_a, path_b])\n\n\ndef is_filename_postfix_in(filename: str, target_set: set):\n    if target_set is None or len(target_set) == 0:\n        return True\n    for postfix in target_set:\n        if filename.lower().endswith(postfix.lower()):\n            return True\n    return False\n\n\n# 搜索文件夹下所有文件 post_filter为后缀名集合 全小写\ndef search_files(folder_path: str, post_filter: set) -> list:\n    def __depth_first_search_files_helper__(current_folder: str, pre_result: list):\n        for filename in get_all_filename(current_folder):\n            if is_filename_postfix_in(filename, post_filter):\n                pre_result.append(os.path.join(current_folder, filename))\n        all_folders = get_all_subfolder(current_folder)\n        for folder in all_folders:\n            __depth_first_search_files_helper__(os.path.join(current_folder, folder), pre_result)\n\n    all_file = []\n    __depth_first_search_files_helper__(folder_path, all_file)\n    return all_file\n\n\ndef get_md5(filename):\n    return hashlib.md5(open(filename, 'rb').read()).hexdigest()\n\n\ndef get_md5_folder(folder_path: str) -> str:\n    all_files = search_files(folder_path, set())\n    md5_content = \"\"\n    for file in all_files:\n        md5_content += get_md5(file)\n    return hashlib.md5(md5_content.encode(\"utf-8\")).hexdigest()\n\n\ndef read_utf8(filepath: str) -> str:\n    with open(filepath, mode=\"r\", encoding=\"utf-8\") as f:\n        return f.read()\n\n\ndef read_utf8_in_lines(filepath: str) -> list[str]:\n    with open(filepath, mode=\"r\", encoding=\"utf-8\") as f:\n        return f.readlines()\n\n\ndef write_utf8(filepath: str, content: str):\n    with open(filepath, encoding=\"utf-8\", mode=\"w\") as f:\n        f.write(content)\n\n\ndef write_utf8_in_lines(filepath: str, content: list[str]):\n    with open(filepath, encoding=\"utf-8\", mode=\"w\") as f:\n        f.writelines(content)\n"
  },
  {
    "path": "common/io/__init__.py",
    "content": ""
  },
  {
    "path": "common/types/__init__.py",
    "content": ""
  },
  {
    "path": "common/types/framework.py",
    "content": "import bpy\n\n\ndef is_extension():\n    # Blender extension package starts with \"bl_ext.\"\n    # https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#extensions-and-namespace\n    return str(__package__).startswith(\"bl_ext.\")\n\n\n# This is a helper base class for you to expand native Blender UI\nclass ExpandableUi:\n    # ID of the target panel.menu to be expanded to\n    target_id: str\n    # mode of expansion, either \"PREPEND\" or \"APPEND\"\n    expand_mode: str = \"APPEND\"\n\n    def draw(self, context: bpy.types.Context):\n        raise NotImplementedError(\"draw method must be implemented\")\n\n\ndef reg_order(order_value: int):\n    \"\"\"\n    This decorator is used to specify the relative registration order of a class. The class with lower order value will\n    be registered first, class without this decorator will be registered last.\n    Notice it still respect the dependencies between classes. Only classes with no dependencies relationship will be\n    sorted by order value.\n    Notice, for UI classes, updating the order won't take effect in real-time during testing, you need to also update\n    the bl_idname of the class to let Blender clean up the drawing cache.\n    这个装饰器用于指定类的相对注册顺序。具有较低顺序值的类将首先注册，没有此装饰器的类将最后注册。\n    请注意，它仍然尊重类之间的依赖关系。只有没有依赖关系的类才会按顺序值排序。\n    请注意，对于UI类，更新顺序不会在测试期间实时生效，您还需要更新类的bl_idname，以便Blender清除绘图缓存。\n    \"\"\"\n\n    def class_decorator(cls):\n        cls._reg_order = order_value\n        return cls\n\n    return class_decorator\n"
  },
  {
    "path": "create.py",
    "content": "from framework import new_addon\nfrom main import ACTIVE_ADDON\n\n# 创建前请修改以下参数\n\n# The name of the addon to be created, this name is defined in the config.py of the addon as __addon_name__\n# 插件的config.py文件中定义的插件名称 __addon_name__\n\nif __name__ == '__main__':\n    import argparse\n\n    parser = argparse.ArgumentParser()\n    parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name')\n    args = parser.parse_args()\n    new_addon(args.addon)\n"
  },
  {
    "path": "framework.py",
    "content": "import ast\nimport atexit\nimport os\nimport re\nimport shutil\nimport subprocess\nimport sys\nimport threading\nimport time\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom common.class_loader.module_installer import install_if_missing, install_fake_bpy\nfrom common.io.FileManagerClient import search_files, read_utf8, write_utf8, is_subdirectory, get_md5_folder, \\\n    read_utf8_in_lines, write_utf8_in_lines\nfrom main import PROJECT_ROOT, BLENDER_ADDON_PATH, BLENDER_EXE_PATH, DEFAULT_RELEASE_DIR, TEST_RELEASE_DIR, IS_EXTENSION\n\ntry:\n    # added in python3.11\n    import tomllib\nexcept ImportError:\n    # for python3.10 and below\n    install_if_missing(\"toml\")\n    import toml\n\n# Following variables are used internally in the framework according to some protocols defined by Blender or\n# the framework itself. Do not change them unless you know what you are doing.\n_addon_namespace_pattern = re.compile(\"^[a-zA-Z]+[a-zA-Z0-9_]*$\")\n_import_module_pattern = re.compile(\"from ([a-zA-Z_][a-zA-Z0-9_.]*) import (.+)\")\n_relative_import_pattern = re.compile(r'^\\s*(from\\s+(\\.+))(.*)$')\n_absolute_import_pattern = re.compile(r'^\\s*from\\s+(\\w+[\\w.]*)\\s+import\\s+(.*)$')\n_addon_md5__signature = \"addon.txt\"\n_ADDON_MANIFEST_FILE = \"blender_manifest.toml\"\n_WHEELS_PATH = \"wheels\"\n# 默认使用的插件模板 不要轻易修改\n_ADDON_TEMPLATE = \"sample_addon\"\n_ADDONS_FOLDER = \"addons\"\n_ADDON_ROOT = os.path.join(PROJECT_ROOT, _ADDONS_FOLDER)\n\n# Install fake bpy module only when user have configured the blender executable path\n# 仅在用户配置了Blender可执行文件路径时安装fake bpy模块 避免在非Blender环境下安装fake bpy模块(如CICD流程中)\nif os.path.isfile(BLENDER_EXE_PATH):\n    install_fake_bpy(BLENDER_EXE_PATH)\n\n\ndef new_addon(addon_name: str):\n    new_addon_path = os.path.join(_ADDON_ROOT, addon_name)\n    if os.path.exists(new_addon_path):\n        raise ValueError(\"Addon already exists: \" + addon_name)\n    if not bool(_addon_namespace_pattern.match(addon_name)):\n        raise ValueError(\"Invalid addon name: \" + addon_name + \" Please name it as a python package name\")\n    shutil.copytree(os.path.join(_ADDON_ROOT, _ADDON_TEMPLATE), new_addon_path)\n\n    all_template_file = search_files(new_addon_path, {\".py\", \".toml\"})\n    for py_file in all_template_file:\n        content = read_utf8(py_file).replace(_ADDON_TEMPLATE, addon_name)\n        write_utf8(py_file, content)\n\n\ndef test_addon(addon_name, enable_watch=True):\n    init_file = get_init_file_path(addon_name)\n    if not enable_watch:\n        print('Do not auto reload addon when file changed')\n    start_test(init_file, addon_name, enable_watch=enable_watch)\n\n\ndef get_init_file_path(addon_name):\n    # addon_name is the name defined in addon's config.py\n    target_init_file_path = os.path.join(_ADDON_ROOT, addon_name, \"__init__.py\")\n    if not os.path.exists(target_init_file_path):\n        raise ValueError(f\"Release failed: Addon {addon_name} not found.\")\n    return target_init_file_path\n\n\n# The following code will be injected into the blender python environment to enable hot reload\n# https://devtalk.blender.org/t/plugin-hot-reload-by-cleaning-sys-modules/20040\nstart_up_command = \"\"\"\nimport bpy\nfrom bpy.app.handlers import persistent\nimport os\nimport sys\nexisting_addon_md5 = \"\"\ntry:\n    bpy.ops.preferences.addon_enable(module=\"{addon_name}\")\nexcept Exception as e:\n    print(\"Addon enable failed:\", e)\n\ndef watch_update_tick():\n    global existing_addon_md5\n    if os.path.exists(\"{addon_signature}\"):\n        with open(\"{addon_signature}\", \"r\") as f:\n            addon_md5 = f.read()\n        if existing_addon_md5 == \"\":\n            existing_addon_md5 = addon_md5\n        elif existing_addon_md5 != addon_md5:\n            print(\"Addon file changed, start to update the addon\")\n            try:\n                bpy.ops.preferences.addon_disable(module=\"{addon_name}\")\n                all_modules = sys.modules\n                all_modules = dict(sorted(all_modules.items(),key= lambda x:x[0])) #sort them\n                for k,v in all_modules.items():\n                    if k.startswith(\"{addon_name}\"):\n                        del sys.modules[k]\n                bpy.ops.preferences.addon_enable(module=\"{addon_name}\")\n            except Exception as e:\n                print(\"Addon update failed:\", e)\n            existing_addon_md5 = addon_md5\n            print(\"Addon updated\")\n    return 1.0\n\n@persistent\ndef register_watch_update_tick(dummy):\n    print(\"Watching for addon update...\")\n    bpy.app.timers.register(watch_update_tick)\n\nregister_watch_update_tick(None)\nbpy.app.handlers.load_post.append(register_watch_update_tick)\n\"\"\"\n\n\ndef start_test(init_file, addon_name, enable_watch=True):\n    update_addon_for_test(init_file, addon_name)\n    test_addon_path = os.path.normpath(os.path.join(BLENDER_ADDON_PATH, addon_name))\n\n    if not enable_watch:\n        def exit_handler():\n            if os.path.exists(test_addon_path):\n                shutil.rmtree(test_addon_path)\n\n        atexit.register(exit_handler)\n        try:\n            execute_blender_script(\n                [BLENDER_EXE_PATH, \"--python-use-system-env\", \"--python-expr\",\n                 f\"import bpy\\nbpy.ops.preferences.addon_enable(module=\\\"{addon_name}\\\")\"], test_addon_path)\n        finally:\n            exit_handler()\n        return\n\n    # start_watch_for_update(init_file, addon_name)\n    stop_event = threading.Event()\n    thread = threading.Thread(target=start_watch_for_update, args=(init_file, addon_name, stop_event))\n    thread.start()\n\n    def exit_handler():\n        stop_event.set()\n        thread.join()\n        if os.path.exists(test_addon_path):\n            shutil.rmtree(test_addon_path)\n\n    atexit.register(exit_handler)\n\n    python_script = start_up_command.format(addon_name=addon_name,\n                                            addon_signature=os.path.join(test_addon_path,\n                                                                         _addon_md5__signature).replace(\"\\\\\", \"/\"))\n\n    try:\n        execute_blender_script([BLENDER_EXE_PATH, \"--python-use-system-env\", \"--python-expr\", python_script],\n                               test_addon_path)\n    finally:\n        exit_handler()\n\n\n# This is the only corner case need to handle\n_addon_on_init_file = os.path.abspath(os.path.join(PROJECT_ROOT, \"__init__.py\"))\n\n\ndef execute_blender_script(args, addon_path):\n    process = subprocess.Popen(args, stderr=subprocess.PIPE, text=True, encoding=\"utf-8\")\n    try:\n        for line in process.stderr:\n            line: str\n            if line.lstrip().startswith(\"File\"):\n                line = line.replace(addon_path, PROJECT_ROOT)\n            sys.stderr.write(line)\n    except KeyboardInterrupt:\n        sys.stderr.write(\"interrupted, terminating the child process...\\n\")\n    finally:\n        process.terminate()\n        process.wait()\n\n\ndef read_ext_config(addon_config_file):\n    with open(addon_config_file, 'r', encoding='utf-8') as f:\n        try:\n            addon_config = tomllib.loads(f.read())\n        except Exception as e:\n            addon_config = toml.load(f)\n    return addon_config\n\n\ndef release_addon(target_init_file, addon_name,\n                  release_dir=DEFAULT_RELEASE_DIR,\n                  need_zip=True,\n                  is_extension=IS_EXTENSION,\n                  with_timestamp=False,\n                  with_version=False):\n    # if release dir is under PROJECT_ROOT, it's not allowed\n    if is_subdirectory(release_dir, PROJECT_ROOT):\n        # 不要将插件发布目录设置在当前项目内\n        raise ValueError(\"Invalid release dir:\", release_dir,\n                         \"Please set a release/test dir outside the current workspace\")\n\n    if not bool(_addon_namespace_pattern.match(addon_name)):\n        raise ValueError(\"InValid addon_name:\", addon_name, \"Please name it as a python package name\")\n\n    if is_extension:\n        # 发布为扩展时，请确保您在config.py正确的定义了__addon_name__\n        # Release as extension, please make sure you defined __addon_name__ correctly in config.py\"\n        # Make sure toml file exists\n        addon_config_file = os.path.join(_ADDON_ROOT, addon_name, _ADDON_MANIFEST_FILE)\n        if not os.path.isfile(addon_config_file):\n            raise ValueError(\"Extension config file not found:\", addon_config_file)\n\n    if not os.path.isdir(release_dir):\n        Path(release_dir).mkdir(parents=True, exist_ok=True)\n\n    # remove the folder if already exists\n    release_folder = os.path.join(release_dir, addon_name)\n    if os.path.exists(release_folder):\n        shutil.rmtree(release_folder)\n    os.mkdir(release_folder)\n\n    bootstrap_init_file = generate_bootstrap_init_file(addon_name, get_addon_info(target_init_file))\n    write_utf8(os.path.join(release_folder, \"__init__.py\"), bootstrap_init_file)\n\n    # shutil.copyfile(target_init_file, os.path.join(release_folder, \"__init__.py\"))\n    # 将target_init_file同级的其他非py文件复制到发布目录 如 toml xml等可能跟插件有关的配置文件\n    for file in os.listdir(os.path.dirname(target_init_file)):\n        file_path = os.path.join(os.path.dirname(target_init_file), file)\n        if os.path.isdir(file_path) or file.endswith(\".py\"):\n            continue\n        shutil.copy(file_path, release_folder)\n\n    # 将插件文件夹复制到发布目录\n    shutil.copytree(os.path.join(_ADDON_ROOT, addon_name), os.path.join(release_folder, _ADDONS_FOLDER, addon_name))\n    shutil.copyfile(os.path.join(_ADDON_ROOT, \"__init__.py\"),\n                    os.path.join(release_folder, _ADDONS_FOLDER, \"__init__.py\"))\n\n    all_py_files = search_files(os.path.join(_ADDON_ROOT, addon_name), {\".py\"})\n    # 对插件文件夹中的每一个py文件进行分析，找到每个py文件中依赖的其他py文件\n    visited_py_files = set()\n    for py_file in all_py_files:\n        visited_py_files.add(os.path.abspath(py_file))\n    # 注意不要漏掉__init__.py文件\n    visited_py_files.add(os.path.abspath(os.path.join(_ADDON_ROOT, \"__init__.py\")))\n\n    dependencies = find_all_dependencies(list(visited_py_files), PROJECT_ROOT)\n    for dependency in dependencies:\n        dependency = os.path.abspath(dependency)\n        if dependency in visited_py_files:\n            continue\n        visited_py_files.add(dependency)\n        target_path = os.path.join(release_folder, os.path.relpath(dependency, PROJECT_ROOT))\n        if not os.path.exists(os.path.dirname(target_path)):\n            os.makedirs(os.path.dirname(target_path))\n        shutil.copy(dependency, os.path.join(release_folder, os.path.relpath(dependency, PROJECT_ROOT)))\n\n    remove_pyc_files(release_folder)\n    removed_path = 1\n    while removed_path > 0:\n        removed_path = remove_empty_folders(release_folder)\n\n    # 必须先将绝对导入转换为相对导入，否则enhance_import_for_py_files一步会改变绝对导入的路径导致出错\n    # convert absolute import to relative import if it's an extension\n    if is_extension:\n        for py_file in search_files(release_folder, {\".py\"}):\n            convert_absolute_to_relative(py_file, release_folder)\n\n    # 更新打包后的绝对导入路径：由于打包后文件夹的层级关系发生了变化，需要更新打包后的绝对导入路径\n    enhance_import_for_py_files(release_folder)\n\n    # enhance relative import for root __init__.py\n    # enhance_relative_import_for_init_py(os.path.join(release_folder, \"__init__.py\"),\n    #                                     _ADDONS_FOLDER, addon_name)\n\n    # include wheel files when need to be zipped\n    addon_config_file = os.path.join(_ADDON_ROOT, addon_name, _ADDON_MANIFEST_FILE)\n    addon_config = {}\n    if os.path.exists(addon_config_file) and is_extension:\n        addon_config = read_ext_config(addon_config_file)\n    if need_zip:\n        # package whl files into extension\n        if \"wheels\" in addon_config:\n            wheel_files = addon_config[\"wheels\"]\n            if len(wheel_files) > 0:\n                wheel_folder = os.path.join(release_folder, _WHEELS_PATH)\n                os.mkdir(wheel_folder)\n                for wheel_file in wheel_files:\n                    # You much put the .whl file directly under the wheels folder, not in a subfolder\n                    # 你必须将.whl文件直接放在wheels文件夹下，而不是在子文件夹中\n                    assert wheel_file.startswith(\"./wheels/\") and wheel_file.count(\"/\") == 2\n                    wheel_source = os.path.join(PROJECT_ROOT, wheel_file)\n                    if not os.path.exists(wheel_source):\n                        raise ValueError(\"Wheel file not found:\", wheel_source,\n                                         \". Please download the required wheel file to the wheels folder.\")\n                    shutil.copy(wheel_source, wheel_folder)\n\n    real_addon_name = \"{addon_name}\".format(addon_name=release_folder)\n    if is_extension:\n        real_addon_name = f\"{real_addon_name}_ext\"\n    if with_version:\n        _version: str\n        if not is_extension:\n            bl_info = get_addon_info(target_init_file)\n            if bl_info is not None:\n                _version = '.'.join([str(x) for x in bl_info['version']])\n            else:\n                raise ValueError(\"bl_info not found in:\", target_init_file)\n        else:\n            if \"version\" in addon_config:\n                _version = addon_config[\"version\"]\n            else:\n                raise ValueError(\"version not found in:\", addon_config_file)\n        real_addon_name = f\"{real_addon_name}_V{_version}\"\n    if with_timestamp:\n        timestamp = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\n        real_addon_name = f\"{real_addon_name}_{timestamp}\"\n\n    released_addon_path = os.path.abspath(os.path.join(release_dir, real_addon_name) + \".zip\")\n    # zip the addon\n    if need_zip:\n        zip_folder(release_folder, real_addon_name, is_extension)\n        print(\"Add on released:\", released_addon_path)\n\n    return released_addon_path\n\n\ndef get_addon_info(filename: str):\n    file_content = read_utf8(filename)\n    try:\n        parsed_ast = ast.parse(file_content)\n        for node in ast.walk(parsed_ast):\n            if isinstance(node, ast.Assign):\n                for target in node.targets:\n                    if isinstance(target, ast.Name) and target.id == \"bl_info\":\n                        return ast.literal_eval(node.value)\n    except Exception as e:\n        return None\n\n\ndef generate_bootstrap_init_file(addon_name: str, bl_info: dict):\n    bootstrap_init_file_template = \"\"\"from .addons.{addon_name} import register as addon_register, unregister as addon_unregister\n\nbl_info = {bl_info}\n\ndef register():\n    addon_register()\n\ndef unregister():\n    addon_unregister()\n\n    \"\"\"\n    bl_info_str = (\n            \"{\\n\"\n            + \",\\n\".join(\n        f'    \"{key}\": {repr(value)}'\n        for key, value in bl_info.items()\n    )\n            + \"\\n}\"\n    )\n    return bootstrap_init_file_template.format(addon_name=addon_name, bl_info=bl_info_str)\n\n\n# pyc files are auto generated, need to be removed before release\ndef remove_pyc_files(release_folder: str):\n    all_pyc_file = search_files(release_folder, {\"pyc\"})\n    for pyc_file in all_pyc_file:\n        os.remove(pyc_file)\n\n\ndef remove_empty_folders(root_path):\n    all_folder_to_remove = []\n    for root, dirnames, filenames in os.walk(root_path, topdown=False):\n        for dirname in dirnames:\n            dir_to_check = os.path.join(root, dirname)\n            if not os.listdir(dir_to_check):\n                all_folder_to_remove.append(dir_to_check)\n    for folder in all_folder_to_remove:\n        shutil.rmtree(folder)\n    return len(all_folder_to_remove)\n\n\n# Zip the folder in a way that blender can recognize it as an addon.\ndef zip_folder(target_root, output_zip_file, is_extension):\n    if is_extension:\n        shutil.make_archive(output_zip_file, 'zip', Path(target_root))\n    else:\n        shutil.make_archive(output_zip_file, 'zip', Path(target_root).parent, base_dir=os.path.basename(target_root))\n\n\ndef find_imported_modules(file_path):\n    root = ast.parse(read_utf8(file_path), filename=file_path)\n\n    imported_modules = set()\n    for node in ast.walk(root):\n        if isinstance(node, ast.Import):\n            for alias in node.names:\n                imported_modules.add(alias.name)\n        elif isinstance(node, ast.ImportFrom):\n            if node.module:\n                module_name = node.module\n                imported_modules.add(module_name)\n            for alias in node.names:\n                if node.module:\n                    imported_modules.add(f\"{node.module}.{alias.name}\")\n                else:\n                    imported_modules.add(alias.name)\n    return imported_modules\n\n\ndef resolve_module_path(module_name, base_path, project_root):\n    if not module_name.endswith(\".*\"):\n        # Handle import all\n        module_path = module_name.replace('.', '/')\n        module_path = os.path.join(project_root, module_path)\n        if os.path.isdir(module_path):\n            module_path = os.path.join(module_path, '__init__.py')\n            return [module_path]\n        elif os.path.isfile(module_path + '.py'):\n            module_path = module_path + '.py'\n            return [module_path]\n        else:\n            if \".\" not in module_name:\n                # most likely a standard library module\n                # 有一种可能是相对导入 from . import xxx, from .. import xxx 等\n                # 这种情况下需要根据当前文件的路径来解析 看module_name.py是否存在于当前文件的同级目录或者父级目录\n                # 从base_path开始向上查找，直到找到module_name.py或者到达project_root\n                search_path = os.path.dirname(base_path)\n                potential_result = []\n                while is_subdirectory(search_path, project_root):\n                    possible_path = os.path.join(search_path, module_name + '.py')\n                    if os.path.isfile(possible_path):\n                        potential_result.append(possible_path)\n                    search_path = os.path.dirname(search_path)\n                return potential_result\n            current_search_dir = os.path.dirname(base_path)\n            while is_subdirectory(current_search_dir, project_root):\n                module_path = module_name.replace('.', '/')\n                module_path = os.path.join(current_search_dir, module_path)\n                if os.path.isdir(module_path):\n                    module_path = os.path.join(module_path, '__init__.py')\n                    return [module_path]\n                elif os.path.isfile(module_path + '.py'):\n                    module_path = module_path + '.py'\n                    return [module_path]\n                current_search_dir = os.path.dirname(current_search_dir)\n            return []\n    else:\n        module_name = module_name[:-2]\n        module_path = module_name.replace('.', '/')\n        possible_root_path = os.path.join(project_root, module_path)\n        if os.path.isdir(possible_root_path):\n            possible_root_path = os.path.join(possible_root_path, '__init__.py')\n            return [possible_root_path]\n        elif os.path.isfile(possible_root_path + '.py'):\n            possible_root_path = possible_root_path + '.py'\n            return [possible_root_path]\n        else:\n            current_search_dir = os.path.dirname(base_path)\n            while is_subdirectory(current_search_dir, project_root):\n\n                possible_root_path = os.path.join(current_search_dir, module_path)\n                if os.path.isdir(possible_root_path):\n                    possible_root_path = os.path.join(possible_root_path, '__init__.py')\n                    return [possible_root_path]\n                elif os.path.isfile(possible_root_path + '.py'):\n                    possible_root_path = possible_root_path + '.py'\n                    return [possible_root_path]\n                current_search_dir = os.path.dirname(current_search_dir)\n            return []\n\n\ndef find_all_dependencies(file_paths: list, project_root: str):\n    dependencies = set()\n    to_process = file_paths.copy()\n    processed = set()\n\n    while to_process:\n        current_file = os.path.abspath(to_process.pop())\n        if current_file in processed:\n            continue\n\n        processed.add(current_file)\n        dependencies.add(current_file)\n\n        try:\n            imported_modules = find_imported_modules(current_file)\n        except SyntaxError as e:\n            raise SyntaxError(f\"Syntax error in file {current_file}: {e}\")\n\n        # 以下代码会将除了当前目标插件文件夹以外的其他被引用的文件夹中的__init__.py文件也加入到依赖中，使之成为有效的模块，从而将其中的Blender\n        # 类也加入到自动注册的范围中，一般来说，我们引用外部文件夹的目的是复用其内部函数，而非将插件外部模块中定义的Operator，Panel等元素\n        # 直接加到当前插件中(如果需要使用其他插件的这些元素，更好的做法是将其直接存放到你的插件文件夹内)，因此注释掉，如果您有特殊需求，可以取消注释\n        # The following code will add __init__.py files in other\n        # referenced folders to the dependencies, in addition to the current ACTIVE ADDON ,making those folders valid\n        # modules and thus classes in them will be added the scope of automatic class registration. (The\n        # auto_load.py) It is commented out because usually we just want to reference reusable functions from\n        # modules outside the current addon Instead of directly adding their Operator's Panels into your own addon. (\n        # If you really want to do that, include them as sub package of your own addon would be a better option). But\n        # If you have special requirements, you can uncomment it.\n\n        # potential_init_file = os.path.abspath(os.path.join(os.path.dirname(current_file), '__init__.py'))\n        # while is_subdirectory(os.path.dirname(potential_init_file),\n        #                       project_root) and potential_init_file != os.path.abspath(\n        #         os.path.join(project_root, \"__init__.py\")):\n        #     if os.path.exists(potential_init_file) and potential_init_file not in processed:\n        #         to_process.append(potential_init_file)\n        #         dependencies.add(potential_init_file)\n        #     potential_init_file = os.path.abspath(\n        #         os.path.join(os.path.dirname(os.path.dirname(potential_init_file)), '__init__.py'))\n\n        for module in imported_modules:\n            module_path = resolve_module_path(module, current_file, project_root)\n            if len(module_path) > 0:\n                for each_module_path in module_path:\n                    each_module_path = os.path.abspath(each_module_path)\n                    if each_module_path not in processed:\n                        to_process.append(each_module_path)\n\n    return dependencies\n\n\ndef enhance_import_for_py_files(addon_dir: str):\n    namespace = os.path.basename(addon_dir)\n    all_py_modules = find_all_py_modules(addon_dir)\n    all_py_file = search_files(addon_dir, {\".py\"})\n    for py_file in all_py_file:\n        hasUpdated = False\n        content = read_utf8(py_file)\n        for module_path in _import_module_pattern.finditer(content):\n            original_module_path = module_path.groups()[0]\n            if original_module_path in all_py_modules:\n                hasUpdated = True\n                content = content.replace(\"from \" + original_module_path + \" import\",\n                                          \"from \" + namespace + \".\" + original_module_path + \" import\")\n        if hasUpdated:\n            write_utf8(py_file, content)\n\n\ndef convert_absolute_to_relative(file_path: str, project_root: str):\n    \"\"\"\n    Convert all absolute imports to relative imports in a Python file.\n    Notice this does not handle import like\n    import xxx.yyy.zzz as zzz\n    在开发扩展时，不要用这种方式导入项目内的模块，这种方式导入的模块无法被转换为相对导入\n\n    Args:\n        file_path (str): Path to the Python file to modify.\n        project_root (str): Root directory of the project.\n    \"\"\"\n    # Normalize paths\n    file_path = os.path.abspath(file_path)\n    project_root = os.path.abspath(project_root)\n\n    lines = read_utf8_in_lines(file_path)\n\n    modified_lines = []\n    changed = False\n\n    for line in lines:\n        # help skipping expensive path check\n        stripped_line = line.strip()\n        if (not stripped_line.startswith(\"from \")) or stripped_line.startswith(\"from .\"):\n            # Leave non-import lines unchanged\n            modified_lines.append(line)\n            continue\n        match = _absolute_import_pattern.match(line)\n        if match:\n            # get whitespace before the import statement\n            leading_space = line[:line.index(\"from\")]\n            absolute_module = match.group(1)\n            import_items = match.group(2)\n            # Check if the absolute module is within the project\n            absolute_module_path = absolute_module.replace('.', os.sep)\n            full_module_path = os.path.join(project_root, absolute_module_path)\n            if os.path.exists(full_module_path) or os.path.exists(f\"{full_module_path}.py\"):\n                # Calculate the relative import path\n\n                target_relative_path = os.path.relpath(\n                    os.path.join(project_root, absolute_module_path),\n                    os.path.dirname(file_path)\n                )\n                # Count the levels for leading dots\n                levels_up = target_relative_path.count(\"..\") + 1\n                leading_dots = '.' * levels_up\n\n                # Build the relative import line\n                target_relative_path = target_relative_path.strip(\".\" + os.sep)\n                relative_import_line = leading_space + f\"from {leading_dots}{target_relative_path.replace(os.sep, '.')} import {import_items}\\n\"\n                if relative_import_line != line:\n                    modified_lines.append(relative_import_line)\n                    changed = True\n                    continue\n            else:\n                # Leave non-matching lines unchanged\n                modified_lines.append(line)\n        else:\n            # Leave non-matching lines unchanged\n            modified_lines.append(line)\n        # print(f\"not match {line} in {timer() - start3} seconds\")\n\n    # Write the modified content back to the file if changes were made\n    if changed:\n        write_utf8_in_lines(file_path, modified_lines)\n\n\ndef find_all_py_modules(root_dir: str) -> set:\n    all_py_modules = set()\n    all_py_file = search_files(root_dir, {\".py\"})\n    for py_file in all_py_file:\n        rel_path = str(os.path.relpath(py_file, root_dir))\n        modules = rel_path.replace(\"__init__.py\", \"\").replace(\".py\", \"\").split(os.path.sep)\n        if len(modules[-1]) == 0:\n            modules = modules[0:-1]\n\n        module_name = \"\"\n        for i in range(len(modules)):\n            module_name += modules[i] + \".\"\n            all_py_modules.add(module_name[0:-1])\n    return all_py_modules\n\n\ndef start_watch_for_update(init_file, addon_name, stop_event: threading.Event):\n    install_if_missing(\"watchdog\")\n    from watchdog.events import FileSystemEventHandler\n    from watchdog.observers import Observer\n\n    class FileUpdateHandler(FileSystemEventHandler):\n        def __init__(self):\n            super(FileUpdateHandler, self).__init__()\n            self.has_update = False\n\n        def on_any_event(self, event):\n            source_path = event.src_path\n            if source_path.endswith(\".py\"):\n                self.has_update = True\n\n        def clear_update(self):\n            self.has_update = False\n\n    path = PROJECT_ROOT\n    event_handler = FileUpdateHandler()\n    observer = Observer()\n    observer.schedule(event_handler, path, recursive=True)\n    observer.start()\n\n    try:\n        while not stop_event.is_set():\n            time.sleep(1)\n            if event_handler.has_update:\n                try:\n                    update_addon_for_test(init_file, addon_name)\n                    event_handler.clear_update()\n                except Exception as e:\n                    print(e)\n                    print(\n                        \"Addon updated failed: Please make sure no other process is\"\n                        \" using the addon folder. You might need to restart the test to update the addon in Blender.\")\n        print(\"Stop watching for update...\")\n\n    except KeyboardInterrupt:\n        observer.stop()\n        observer.join()\n\n\ndef update_addon_for_test(init_file, addon_name):\n    if BLENDER_ADDON_PATH is None:\n        # 无法得到Blender插件路径 请检查在main.py或config.ini中的配置\n        raise ValueError(\n            \"Could not find Blender addon installation path. Please check the configuration in main.py or config.ini\")\n    addon_path = release_addon(init_file, addon_name, with_timestamp=False,\n                               is_extension=IS_EXTENSION,\n                               release_dir=TEST_RELEASE_DIR, need_zip=False)\n    executable_path = os.path.join(os.path.dirname(addon_path), addon_name)\n\n    test_addon_path = os.path.join(BLENDER_ADDON_PATH, addon_name)\n    if os.path.exists(test_addon_path):\n        shutil.rmtree(test_addon_path)\n    shutil.copytree(executable_path, test_addon_path)\n\n    # write an MD5 to the addon folder to inform the addon content has been changed\n    addon_md5 = get_md5_folder(executable_path)\n    write_utf8(os.path.join(test_addon_path, _addon_md5__signature), addon_md5)\n"
  },
  {
    "path": "main.py",
    "content": "# BlenderAddonPackageTool - A framework for developing multiple blender addons in a single workspace\n# Copyright (C) 2024 Xinyu Zhu\n\nimport os\nfrom configparser import ConfigParser\n\nfrom common.class_loader.module_installer import default_blender_addon_path, normalize_blender_path_by_system\n\n# The name of current active addon to be created, tested or released\n# 要创建、测试或发布的当前活动插件的名称\nACTIVE_ADDON = \"sample_addon\"\n\n# The path of the blender executable. Blender2.93 is the minimum version required\n# Blender可执行文件的路径，Blender2.93是所需的最低版本\nBLENDER_EXE_PATH = \"C:/software/general/Blender/blender-3.6.0-windows-x64/blender.exe\"\n\n# Linux example Linux示例\n# BLENDER_EXE_PATH = \"/usr/local/blender/blender-3.6.0-linux-x64/blender\"\n\n# MacOS examplenotice \"/Contents/MacOS/Blender\" will be appended automatically if you didn't write it explicitly\n# MacOS示例 框架会自动附加\"/Contents/MacOS/Blender\" 所以您不必写出\n# BLENDER_EXE_PATH = \"/Applications/Blender/blender-3.6.0-macOS/Blender.app\"\n\n# Are you developing an extension(for Blender4.2) instead of legacy addon?\n# https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html\n# The framework will convert absolute import to relative import when packaging the extension.\n# Make sure to update __addon_name__ in config.py if you are migrating from legacy addon to extension.\n# 是否是面向Blender4.2以后的扩展而不是传统插件？\n# https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html\n# 在打包扩展时，框架会将绝对导入转换为相对导入。如果你从传统插件迁移到扩展，请确保更新config.py中的__addon_name__\nIS_EXTENSION = False\n\n# You can override the default path by setting the path manually\n# 您可以通过手动设置路径来覆盖默认插件安装路径 或者在config.ini中设置\n# BLENDER_ADDON_PATH = \"C:/software/general/Blender/Blender3.5/3.5/scripts/addons/\"\nBLENDER_ADDON_PATH = None\nif os.path.exists(BLENDER_EXE_PATH):\n    BLENDER_ADDON_PATH = default_blender_addon_path(BLENDER_EXE_PATH)\n\nPROJECT_ROOT = os.path.abspath(os.path.dirname(__file__))\n\n# 若存在config.ini则从其中中读取配置\nCONFIG_FILEPATH = os.path.join(PROJECT_ROOT, 'config.ini')\n\n# The default release dir. Must not within the current workspace\n# 插件发布的默认目录，不能在当前工作空间内\nDEFAULT_RELEASE_DIR = os.path.join(PROJECT_ROOT, \"../addon_release/\")\n\n# The default test release dir. Must not within the current workspace\n# 测试插件发布的默认目录，不能在当前工作空间内\nTEST_RELEASE_DIR = os.path.join(PROJECT_ROOT, \"../addon_test/\")\n\nif os.path.isfile(CONFIG_FILEPATH):\n    configParser = ConfigParser()\n    configParser.read(CONFIG_FILEPATH, encoding='utf-8')\n\n    if configParser.has_option('blender', 'exe_path'):\n        BLENDER_EXE_PATH = configParser.get('blender', 'exe_path')\n        # The path of the blender addon folder\n        # 同时更改Blender插件文件夹的路径\n        BLENDER_ADDON_PATH = default_blender_addon_path(BLENDER_EXE_PATH)\n\n    if configParser.has_option('blender', 'addon_path') and configParser.get('blender', 'addon_path'):\n        BLENDER_ADDON_PATH = configParser.get('blender', 'addon_path')\n\n    if configParser.has_option('default', 'addon') and configParser.get('default', 'addon'):\n        ACTIVE_ADDON = configParser.get('default', 'addon')\n\n    if configParser.has_option('default', 'is_extension') and configParser.get('default', 'is_extension'):\n        IS_EXTENSION = configParser.getboolean('default', 'is_extension')\n\n    if configParser.has_option('default', 'release_dir') and configParser.get('default', 'release_dir'):\n        DEFAULT_RELEASE_DIR = configParser.get('default', 'release_dir')\n\n    if configParser.has_option('default', 'test_release_dir') and configParser.get('default', 'test_release_dir'):\n        TEST_RELEASE_DIR = configParser.get('default', 'test_release_dir')\n\nBLENDER_EXE_PATH = normalize_blender_path_by_system(BLENDER_EXE_PATH)\n\n# If you want to override theBLENDER_ADDON_PATH(the path to install addon during testing), uncomment the following line and set the path manually.\n# 如果要覆盖BLENDER_ADDON_PATH(测试插件安装路径)，请取消下一行的注释并手动设置路径\n# BLENDER_ADDON_PATH = \"\"\n\n# Could not find the blender addon path, raise error. Please set BLENDER_ADDON_PATH manually.\n# 未找到Blender插件路径，引发错误 请手动设置BLENDER_ADDON_PATH\nif os.path.exists(BLENDER_EXE_PATH) and (not BLENDER_ADDON_PATH or not os.path.exists(BLENDER_ADDON_PATH)):\n    raise ValueError(\"Blender addon path not found: \" + BLENDER_ADDON_PATH, \"Please set the correct path in config.ini\")\n"
  },
  {
    "path": "release.py",
    "content": "from framework import get_init_file_path, release_addon\nfrom main import ACTIVE_ADDON, IS_EXTENSION\n\n# 发布前请修改ACTIVE_ADDON参数\n\n# The name of the addon to be released, this name is defined in the config.py of the addon as __addon_name__\n# 插件的config.py文件中定义的插件名称 __addon_name__\n\n\nif __name__ == '__main__':\n    import argparse\n\n    parser = argparse.ArgumentParser()\n    parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name')\n    parser.add_argument('--is_extension', default=IS_EXTENSION, action='store_true', help='If true, package the addon '\n                                                                                          'as extension, framework '\n                                                                                          'will convert absolute '\n                                                                                          'import to relative import '\n                                                                                          'for you and will take care '\n                                                                                          'of packaging the '\n                                                                                          'extension. Default is the '\n                                                                                          'value of IS_EXTENSION')\n    parser.add_argument('--disable_zip', default=False, action='store_true', help='If true, release the addon into a '\n                                                                                  'plain folder and do not zip it '\n                                                                                  'into an installable package, '\n                                                                                  'useful if you want to add more '\n                                                                                  'files and zip by yourself.')\n    parser.add_argument('--with_version', default=False, action='store_true', help='Append the addon version number ('\n                                                                                   'as specified in bl_info) to the '\n                                                                                   'released zip file name.')\n    parser.add_argument('--with_timestamp', default=False, action='store_true', help='Append a timestamp to the zip '\n                                                                                     'file name.')\n    args = parser.parse_args()\n    release_addon(target_init_file=get_init_file_path(args.addon),\n                  addon_name=args.addon,\n                  need_zip=not args.disable_zip,\n                  is_extension=args.is_extension,\n                  with_timestamp=args.with_timestamp,\n                  with_version=args.with_version,\n                  )\n"
  },
  {
    "path": "test.py",
    "content": "from framework import test_addon\nfrom main import ACTIVE_ADDON\n\n# 测试前请修改ACTIVE_ADDON参数\n\n# The name of the addon to be tested, this name is defined in the config.py of the addon as __addon_name__\n# 插件的config.py文件中定义的插件名称 __addon_name__\n\nif __name__ == '__main__':\n    import argparse\n\n    parser = argparse.ArgumentParser()\n    parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name')\n    parser.add_argument('--disable_watch', default=False, action='store_true', help='Do not reload addon when file '\n                                                                                    'changed')\n    args = parser.parse_args()\n    test_addon(args.addon, enable_watch=not args.disable_watch)\n"
  },
  {
    "path": "wheels/README.md",
    "content": "# How to include Python wheels for addons starting from Blender 4.2\n\nPlease read https://docs.blender.org/manual/en/latest/advanced/extensions/python_wheels.html for context.\n\nTo bundle Python wheels with your addon, you need to download to this `wheels` directory.\nAnd In blender_manifest.toml include the wheels as a list of paths, e.g.:\n\n```\nwheels = [\n   \"./wheels/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl\",\n   \"./wheels/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl\",\n   \"./wheels/pillow-10.3.0-cp311-cp311-win_amd64.whl\",\n]\n```\n\nWhen you release your addon, the framework will copy these wheels into the zip file according to the toml configuration.\nThis way you don't have to maintain wheels in your addon directory which could lead to duplication.\n\nNoticed that when testing your addon, the framework will not automatically include these wheels to avoid\noverhead. You might see `ModuleNotFoundError` when testing your addon even though you have included the wheels in the\nproject.\nTo solve that, you need to install them manually using `pip install` or `python -m pip install` for your testing\nBlender python environment. This could also be done in the IDE if you choose to use the same python environment for\ndeveloping.\n\n[]:\n\n# 如何给Blender 4.2及以后的插件添加 Python wheels\n\n请参考 https://docs.blender.org/manual/en/latest/advanced/extensions/python_wheels.html 了解背景知识。\n\n给插件添加 Python wheels ,你需要将.whl文件下载到这个 `wheels` 文件夹中.\n并且在插件配置文件 blender_manifest.toml 中声明插件所需的whl文件路径，例如:\n\n```\nwheels = [\n   \"./wheels/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl\",\n   \"./wheels/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl\",\n   \"./wheels/pillow-10.3.0-cp311-cp311-win_amd64.whl\",\n]\n```\n\n当你打包插件时，框架会根据toml将这些whl文件复制到zip文件中。因此你不需要在插件的目录中存放这些whl文件导致重复。\n\n注意，当你测试插件时，框架不会自动包含这些whl文件以避免额外的开销。你可能会在测试插件时看到 `ModuleNotFoundError`\n错误，即使你已经在项目中包含了这些whl文件。\n为此你需要手动使用 `pip install` 或 `python -m pip install` 命令\n将这些whl文件安装到你进行测试的Blender python环境中。如果你恰巧选择在IDE中使用相同的python环境进行开发，你也可以通过IDE中安装这些whl文件。\n\n"
  },
  {
    "path": "workflows/addon_release.yml",
    "content": "# Put this file in the .github/workflows directory of your repository to take effect. Modify the addon name that needs to be automatically released according to your project. (At Step 3)\n# Notice you need to change your project setting: Settings -> Actions -> General -> Workflow permissions -> Read and write permission for this workflow to work.\n# 将此文件放在您的项目目录 .github/workflows 中即可生效。请根据您的项目需要，修改自动发布的插件名称。(在 Step 3中)\n# 注意您需要在Github项目中 Settings -> Actions -> General -> Workflow permissions -> 为此工作流程添加读写权限才能使其正常工作。\nname: Automatic Release Addon Action\n\non:\n  push:\n    branches:\n      - main # Change this to the branch you want to trigger the workflow, e.g., main, master, or develop\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n\n    steps:\n      # Step 1: Checkout the code\n      - name: Checkout code\n        uses: actions/checkout@v3\n\n      # Step 2: Set up Python environment\n      - name: Set up Python\n        uses: actions/setup-python@v4\n        with:\n          python-version: '3.11'\n\n      # Step 3: Run the release command for each addon. Please update this step according to your project.\n      - name: Run release command for sample_addon\n        run: |\n          python release.py sample_addon --with_version\n# Add additional steps for each addon as needed... 按如下格式添加其他插件的发布命令\n#      - name: Run release command for addon1 as extension\n#        run: |\n#          python release.py addon1 --is_extension --with_version\n#\n#      - name: Run release command for addon2\n#        run: |\n#          python release.py addon2 --is_extension --with_version --with_timestamp\n\n      # Add additional steps for each addon as needed...\n      # You can add more steps as needed for each addon.\n\n      # Step 4: List files in addon_release directory\n      - name: List files in addon_release directory\n        run: |\n          echo \"Files in addon_release directory:\"\n          ls ../addon_release\n\n      # Step 5: Create GitHub Release\n      - name: Create GitHub Release\n        id: create_release\n        uses: actions/create-release@v1\n        with:\n          tag_name: \"release-${{ github.run_id }}\"\n          release_name: \"Release ${{ github.run_id }}\"\n          draft: false\n          prerelease: false\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      # Step 6: Upload release assets\n      - name: Upload release assets\n        run: |\n          for file in ../addon_release/*.zip; do\n            echo \"Uploading $file...\"\n            gh release upload \"release-${{ github.run_id }}\" \"$file\"\n          done\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}"
  }
]