├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── __init__.py ├── addon_updater.py ├── addon_updater_ops.py ├── auto_load.py ├── bake.test.py ├── blender_manifest.toml ├── brush icons ├── PS_Gouache.png └── PS_Gouache_Strokes.png ├── common.py ├── common_layers.py ├── custom_icons.py ├── icons ├── paint_system.png └── sunflower.png ├── library.blend ├── library.blend1 ├── nested_list_manager.py ├── node_builder.py ├── operators_bake.py ├── operators_layers.py ├── operators_utils.py ├── paint_system.py ├── panels.py ├── properties.py └── tests.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | branches: ['**'] 6 | workflow_dispatch: 7 | inputs: 8 | version: 9 | description: 'version' 10 | required: false # Make this optional 11 | default: '' 12 | release_stage: 13 | type: choice 14 | description: 'release stage' 15 | required: True 16 | default: 'gold' 17 | options: 18 | - alpha 19 | - beta 20 | - rc 21 | - gold 22 | 23 | permissions: 24 | contents: write 25 | 26 | jobs: 27 | Build: 28 | runs-on: ubuntu-latest 29 | outputs: 30 | version: ${{ steps.extract_version.outputs.version }} 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | - name: Extract version from __init__.py 36 | id: extract_version 37 | run: | 38 | VERSION=$(python <> $GITHUB_OUTPUT 65 | 66 | - name: Validate version 67 | id: validate_version 68 | run: | 69 | if [[ -z "${{ steps.extract_version.outputs.version }}" ]]; then 70 | echo "Error: Extracted version is invalid." 71 | exit 1 72 | else 73 | echo "Extracted version: ${{ steps.extract_version.outputs.version }}" 74 | fi 75 | 76 | - name: Build addon 77 | uses: natapol2547/blender-addon-build@main 78 | with: 79 | name: paint-system 80 | exclude-files: '.git;.github;README.md;library.blend1' 81 | 82 | - name: Build addon (No Updater) 83 | uses: natapol2547/blender-addon-build@main 84 | with: 85 | name: paint-system-no-updater 86 | exclude-files: '.git;.github;README.md;library.blend1;addon_updater.py;addon_updater_ops.py' 87 | 88 | Release: 89 | runs-on: ubuntu-latest 90 | needs: Build 91 | steps: 92 | - name: Checkout repository 93 | uses: actions/checkout@v4 94 | 95 | - name: Release addon 96 | uses: natapol2547/blender-addon-release@main 97 | with: 98 | artifact_name: paint-system 99 | version: ${{ needs.Build.outputs.version }} 100 | release_stage: ${{ github.event.inputs.release_stage }} 101 | addon_folder_name: paint_system 102 | 103 | - name: Checkout repository 104 | uses: actions/checkout@v4 105 | 106 | - name: Release addon (No Updater) 107 | uses: natapol2547/blender-addon-release@main 108 | with: 109 | artifact_name: paint-system-no-updater 110 | version: ${{ needs.Build.outputs.version }} 111 | release_stage: ${{ github.event.inputs.release_stage }} 112 | addon_folder_name: paint_system 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | # wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # Paint System Files 165 | paintsystem_updater/ 166 | bl_ext.vscode_development.paintsystem_updater/ 167 | 168 | .vscode 169 | .VSCodeCounter 170 | extension_build/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "editor.formatOnSave": true, 5 | "proseWrap": "always", 6 | "tabWidth": 4, 7 | "requireConfig": false, 8 | "useTabs": false, 9 | "trailingComma": "none", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false, 12 | "semi": true 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paint System Addon 2 | A simple but flexible painting system for **non-photorealistic-rendering** in Blender 3 | 4 | ![Paint System](https://github.com/user-attachments/assets/0db90d3b-b52a-425d-bc37-8ed9d4f41fa4) 5 | 6 | ## Installation 7 | **Note: Please use Blender 4.1+** 8 | 9 | Under **Releases** on the right click one of **Paint System** version, then simply click the .zip file. The addon will begin to download. 10 | 11 | In blender click **Edit > Preferences > Add-ons** 12 | (For Blender 4.2+) Click the small dropdown on the top right and choose **Install From Disk**. (For Blender 4.1) Click **Install** 13 | 14 | Navigate to the downloaded .zip file and install it. 15 | 16 | ## Documentation 17 | [Paint System Documentation](https://prairie-yarrow-e25.notion.site/PAINT-SYSTEM-DOCUMENTATION-1910a7029e86803f9ac3e0c79c67bd8c?pvs=74) 18 | 19 | ### Have questions about the addon? You can email me anytime! 20 | tawan.sunflower.nc@gmail.com 21 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | from bpy.utils import register_submodule_factory 15 | import bpy 16 | from .properties import update_active_image 17 | from bpy.app.handlers import persistent 18 | from .paint_system import PaintSystem, get_paint_system_images 19 | from .common import import_legacy_updater 20 | from .custom_icons import load_custom_icons, unload_custom_icons 21 | from . import auto_load 22 | 23 | bl_info = { 24 | "name": "Paint System", 25 | "author": "Tawan Sunflower, @blastframe", 26 | "description": "", 27 | "blender": (4, 1, 0), 28 | "version": (1, 3, 3), 29 | "location": "View3D > Sidebar > Paint System", 30 | "warning": "", 31 | "category": "Node", 32 | 'support': 'COMMUNITY', 33 | "tracker_url": "https://github.com/natapol2547/paintsystem" 34 | } 35 | 36 | bl_info_copy = bl_info.copy() 37 | 38 | print("Paint System: Registering...", __package__) 39 | 40 | auto_load.init() 41 | addon_updater_ops = import_legacy_updater() 42 | 43 | 44 | @persistent 45 | def texture_paint_handler(scene): 46 | # Get the active object and its mode 47 | obj = getattr(bpy.context, "object", None) 48 | if obj and hasattr(obj, "mode") and obj.mode == 'TEXTURE_PAINT': 49 | update_active_image() 50 | 51 | 52 | @persistent 53 | def save_handler(scene: bpy.types.Scene): 54 | images = get_paint_system_images() 55 | for image in images: 56 | if not image.is_dirty: 57 | continue 58 | if image.packed_file or image.filepath == '': 59 | image.pack() 60 | else: 61 | image.save() 62 | 63 | 64 | @persistent 65 | def refresh_image(scene: bpy.types.Scene): 66 | ps = PaintSystem(bpy.context) 67 | active_layer = ps.get_active_layer() 68 | if active_layer and active_layer.image: 69 | active_layer.image.reload() 70 | 71 | 72 | submodules = [ 73 | "properties", 74 | "operators_layers", 75 | "operators_utils", 76 | "operators_bake", 77 | "panels", 78 | # "tests", 79 | # "node_organizer", 80 | # "operation/test", 81 | ] 82 | 83 | _register, _unregister = register_submodule_factory(__name__, submodules) 84 | 85 | 86 | def register(): 87 | _register() 88 | load_custom_icons() 89 | if addon_updater_ops: 90 | addon_updater_ops.register(bl_info_copy) 91 | bpy.app.handlers.depsgraph_update_post.append(texture_paint_handler) 92 | bpy.app.handlers.save_pre.append(save_handler) 93 | bpy.app.handlers.load_post.append(refresh_image) 94 | 95 | 96 | def unregister(): 97 | bpy.app.handlers.load_post.remove(refresh_image) 98 | bpy.app.handlers.save_pre.remove(save_handler) 99 | bpy.app.handlers.depsgraph_update_post.remove(texture_paint_handler) 100 | if addon_updater_ops: 101 | addon_updater_ops.unregister() 102 | unload_custom_icons() 103 | _unregister() 104 | -------------------------------------------------------------------------------- /auto_load.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import typing 3 | import inspect 4 | import pkgutil 5 | import importlib 6 | from pathlib import Path 7 | 8 | __all__ = ( 9 | "init", 10 | "register", 11 | "unregister", 12 | ) 13 | 14 | blender_version = bpy.app.version 15 | 16 | modules = None 17 | ordered_classes = None 18 | 19 | 20 | def init(): 21 | global modules 22 | global ordered_classes 23 | 24 | modules = get_all_submodules(Path(__file__).parent) 25 | ordered_classes = get_ordered_classes_to_register(modules) 26 | 27 | 28 | def register(): 29 | for cls in ordered_classes: 30 | bpy.utils.register_class(cls) 31 | 32 | for module in modules: 33 | if module.__name__ == __name__: 34 | continue 35 | if hasattr(module, "register"): 36 | module.register() 37 | 38 | 39 | def unregister(): 40 | for cls in reversed(ordered_classes): 41 | bpy.utils.unregister_class(cls) 42 | 43 | for module in modules: 44 | if module.__name__ == __name__: 45 | continue 46 | if hasattr(module, "unregister"): 47 | module.unregister() 48 | 49 | 50 | # Import modules 51 | ################################################# 52 | 53 | 54 | def get_all_submodules(directory): 55 | return list(iter_submodules(directory, __package__)) 56 | 57 | 58 | def iter_submodules(path, package_name): 59 | for name in sorted(iter_submodule_names(path)): 60 | yield importlib.import_module("." + name, package_name) 61 | 62 | 63 | def iter_submodule_names(path, root=""): 64 | for _, module_name, is_package in pkgutil.iter_modules([str(path)]): 65 | if is_package: 66 | sub_path = path / module_name 67 | sub_root = root + module_name + "." 68 | yield from iter_submodule_names(sub_path, sub_root) 69 | else: 70 | yield root + module_name 71 | 72 | 73 | # Find classes to register 74 | ################################################# 75 | 76 | 77 | def get_ordered_classes_to_register(modules): 78 | return toposort(get_register_deps_dict(modules)) 79 | 80 | 81 | def get_register_deps_dict(modules): 82 | my_classes = set(iter_my_classes(modules)) 83 | my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} 84 | 85 | deps_dict = {} 86 | for cls in my_classes: 87 | deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) 88 | return deps_dict 89 | 90 | 91 | def iter_my_register_deps(cls, my_classes, my_classes_by_idname): 92 | yield from iter_my_deps_from_annotations(cls, my_classes) 93 | yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) 94 | 95 | 96 | def iter_my_deps_from_annotations(cls, my_classes): 97 | for value in typing.get_type_hints(cls, {}, {}).values(): 98 | dependency = get_dependency_from_annotation(value) 99 | if dependency is not None: 100 | if dependency in my_classes: 101 | yield dependency 102 | 103 | 104 | def get_dependency_from_annotation(value): 105 | if blender_version >= (2, 93): 106 | if isinstance(value, bpy.props._PropertyDeferred): 107 | return value.keywords.get("type") 108 | else: 109 | if isinstance(value, tuple) and len(value) == 2: 110 | if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): 111 | return value[1]["type"] 112 | return None 113 | 114 | 115 | def iter_my_deps_from_parent_id(cls, my_classes_by_idname): 116 | if issubclass(cls, bpy.types.Panel): 117 | parent_idname = getattr(cls, "bl_parent_id", None) 118 | if parent_idname is not None: 119 | parent_cls = my_classes_by_idname.get(parent_idname) 120 | if parent_cls is not None: 121 | yield parent_cls 122 | 123 | 124 | def iter_my_classes(modules): 125 | base_types = get_register_base_types() 126 | for cls in get_classes_in_modules(modules): 127 | if any(issubclass(cls, base) for base in base_types): 128 | if not getattr(cls, "is_registered", False): 129 | yield cls 130 | 131 | 132 | def get_classes_in_modules(modules): 133 | classes = set() 134 | for module in modules: 135 | for cls in iter_classes_in_module(module): 136 | classes.add(cls) 137 | return classes 138 | 139 | 140 | def iter_classes_in_module(module): 141 | for value in module.__dict__.values(): 142 | if inspect.isclass(value): 143 | yield value 144 | 145 | 146 | def get_register_base_types(): 147 | return set( 148 | getattr(bpy.types, name) 149 | for name in [ 150 | "Panel", 151 | "Operator", 152 | "PropertyGroup", 153 | "AddonPreferences", 154 | "Header", 155 | "Menu", 156 | "Node", 157 | "NodeSocket", 158 | "NodeTree", 159 | "UIList", 160 | "RenderEngine", 161 | "Gizmo", 162 | "GizmoGroup", 163 | ] 164 | ) 165 | 166 | 167 | # Find order to register to solve dependencies 168 | ################################################# 169 | 170 | 171 | def toposort(deps_dict): 172 | sorted_list = [] 173 | sorted_values = set() 174 | while len(deps_dict) > 0: 175 | unsorted = [] 176 | sorted_list_sub = [] # helper for additional sorting by bl_order - in panels 177 | for value, deps in deps_dict.items(): 178 | if len(deps) == 0: 179 | sorted_list_sub.append(value) 180 | sorted_values.add(value) 181 | else: 182 | unsorted.append(value) 183 | deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted} 184 | sorted_list_sub.sort(key=lambda cls: getattr(cls, "bl_order", 0)) 185 | sorted_list.extend(sorted_list_sub) 186 | return sorted_list 187 | -------------------------------------------------------------------------------- /bake.test.py: -------------------------------------------------------------------------------- 1 | from bpy.props import EnumProperty, BoolProperty, StringProperty 2 | from bpy.types import Operator, Panel 3 | import bpy 4 | bl_info = { 5 | "name": "Material Baker", 6 | "author": "Tawan Sunflower", 7 | "version": (1, 0), 8 | "blender": (3, 0, 0), 9 | "location": "Properties > Material > Bake Material", 10 | "description": "Bakes materials by temporarily switching to Cycles if needed", 11 | "category": "Material", 12 | } 13 | 14 | 15 | def bake_node(node_name, bake_type): 16 | """ 17 | Bakes a specific node from the active material 18 | 19 | Args: 20 | node_name (str): Name of the node to bake 21 | bake_type (str): Type of bake to perform ('DIFFUSE', 'NORMAL', etc.) 22 | 23 | Returns: 24 | bool: True if successful, False otherwise 25 | """ 26 | obj = bpy.context.object 27 | if not obj or not obj.active_material: 28 | return False 29 | 30 | material = obj.active_material 31 | nodes = material.node_tree.nodes 32 | 33 | # Find the specified node 34 | target_node = nodes.get(node_name) 35 | if not target_node: 36 | return False 37 | 38 | # Create a temporary image texture node for baking 39 | bake_node = nodes.new('ShaderNodeTexImage') 40 | bake_node.name = "temp_bake_node" 41 | 42 | # Create a new image for baking 43 | image_name = f"{material.name}_{node_name}_{bake_type.lower()}" 44 | image = bpy.data.images.new(image_name, 1024, 1024) 45 | bake_node.image = image 46 | 47 | # Store active render engine 48 | original_engine = bpy.context.scene.render.engine 49 | 50 | try: 51 | # Switch to Cycles if needed 52 | if bpy.context.scene.render.engine != 'CYCLES': 53 | bpy.context.scene.render.engine = 'CYCLES' 54 | 55 | # Setup bake settings 56 | bake_settings = bpy.context.scene.render.bake 57 | bake_settings.use_selected_to_active = False 58 | 59 | # Configure bake settings based on type 60 | if bake_type == 'DIFFUSE': 61 | bake_settings.type = 'DIFFUSE' 62 | bake_settings.use_pass_direct = True 63 | bake_settings.use_pass_indirect = True 64 | elif bake_type == 'NORMAL': 65 | bake_settings.type = 'NORMAL' 66 | bake_settings.normal_space = 'TANGENT' 67 | elif bake_type == 'ROUGHNESS': 68 | bake_settings.type = 'ROUGHNESS' 69 | elif bake_type == 'EMISSION': 70 | bake_settings.type = 'EMIT' 71 | 72 | # Set the target node as active 73 | nodes.active = target_node 74 | 75 | # Perform bake 76 | bpy.ops.object.bake(type=bake_settings.type) 77 | 78 | # Save the baked image 79 | image.save() 80 | 81 | return True 82 | 83 | except Exception as e: 84 | print(f"Baking failed: {str(e)}") 85 | return False 86 | 87 | finally: 88 | # Cleanup 89 | nodes.remove(bake_node) 90 | bpy.context.scene.render.engine = original_engine 91 | 92 | 93 | class MATERIAL_OT_bake_material(Operator): 94 | bl_idname = "material.bake_material" 95 | bl_label = "Bake Material" 96 | bl_description = "Bakes the active material's textures" 97 | 98 | bake_type: EnumProperty( 99 | name="Bake Type", 100 | items=[ 101 | ('DIFFUSE', "Diffuse", "Bake diffuse colors"), 102 | ('NORMAL', "Normal", "Bake normal maps"), 103 | ('ROUGHNESS', "Roughness", "Bake roughness maps"), 104 | ('EMISSION', "Emission", "Bake emission maps"), 105 | ], 106 | default='DIFFUSE' 107 | ) 108 | 109 | use_selected: BoolProperty( 110 | name="Selected Objects Only", 111 | description="Only bake materials for selected objects", 112 | default=True 113 | ) 114 | 115 | node_name: StringProperty( 116 | name="Node Name", 117 | description="Name of the specific node to bake (optional)", 118 | default="" 119 | ) 120 | 121 | def execute(self, context): 122 | if self.node_name: 123 | # Bake specific node 124 | success = bake_node(self.node_name, self.bake_type) 125 | if success: 126 | self.report( 127 | {'INFO'}, f"Node '{self.node_name}' baked successfully!") 128 | else: 129 | self.report( 130 | {'ERROR'}, f"Failed to bake node '{self.node_name}'") 131 | return {'CANCELLED'} 132 | else: 133 | # Original functionality for baking entire material 134 | original_engine = context.scene.render.engine 135 | 136 | if context.scene.render.engine != 'CYCLES': 137 | self.report({'INFO'}, "Switching to Cycles for baking...") 138 | context.scene.render.engine = 'CYCLES' 139 | 140 | bake_settings = context.scene.render.bake 141 | bake_settings.use_selected_to_active = False 142 | 143 | if self.bake_type == 'DIFFUSE': 144 | bake_settings.type = 'DIFFUSE' 145 | bake_settings.use_pass_direct = True 146 | bake_settings.use_pass_indirect = True 147 | elif self.bake_type == 'NORMAL': 148 | bake_settings.type = 'NORMAL' 149 | bake_settings.normal_space = 'TANGENT' 150 | elif self.bake_type == 'ROUGHNESS': 151 | bake_settings.type = 'ROUGHNESS' 152 | elif self.bake_type == 'EMISSION': 153 | bake_settings.type = 'EMIT' 154 | 155 | objects_to_bake = context.selected_objects if self.use_selected else context.scene.objects 156 | 157 | try: 158 | for obj in objects_to_bake: 159 | if obj.type == 'MESH' and obj.active_material: 160 | context.view_layer.objects.active = obj 161 | bpy.ops.object.bake(type=bake_settings.type) 162 | 163 | self.report({'INFO'}, "Baking completed successfully!") 164 | 165 | except Exception as e: 166 | self.report({'ERROR'}, f"Baking failed: {str(e)}") 167 | return {'CANCELLED'} 168 | 169 | finally: 170 | context.scene.render.engine = original_engine 171 | 172 | return {'FINISHED'} 173 | 174 | 175 | class MATERIAL_PT_baker(Panel): 176 | bl_label = "Material Baker" 177 | bl_idname = "MATERIAL_PT_baker" 178 | bl_space_type = 'PROPERTIES' 179 | bl_region_type = 'WINDOW' 180 | bl_context = "material" 181 | 182 | def draw(self, context): 183 | layout = self.layout 184 | 185 | row = layout.row() 186 | op = row.operator("material.bake_material") 187 | 188 | layout.prop(op, "bake_type") 189 | layout.prop(op, "use_selected") 190 | layout.prop(op, "node_name") 191 | 192 | 193 | classes = ( 194 | MATERIAL_OT_bake_material, 195 | MATERIAL_PT_baker, 196 | ) 197 | 198 | 199 | def register(): 200 | for cls in classes: 201 | bpy.utils.register_class(cls) 202 | 203 | 204 | def unregister(): 205 | for cls in classes: 206 | bpy.utils.unregister_class(cls) 207 | 208 | 209 | if __name__ == "__main__": 210 | register() 211 | -------------------------------------------------------------------------------- /blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | 3 | # Example of manifest file for a Blender extension 4 | # Change the values according to your extension 5 | id = "paint_system" 6 | version = "1.3.3" 7 | name = "Paint System" 8 | tagline = "Addon for adding painting system to blender" 9 | maintainer = "Tawan Sunflower" 10 | # Supported types: "add-on", "theme" 11 | type = "add-on" 12 | 13 | # Optional link to documentation, support, source files, etc 14 | # website = "https://extensions.blender.org/add-ons/my-example-package/" 15 | 16 | # Optional list defined by Blender and server, see: 17 | # https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html 18 | tags = ["Paint"] 19 | 20 | blender_version_min = "4.2.0" 21 | # # Optional: Blender version that the extension does not support, earlier versions are supported. 22 | # # This can be omitted and defined later on the extensions platform if an issue is found. 23 | # blender_version_max = "5.1.0" 24 | 25 | # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) 26 | # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html 27 | license = [ 28 | "SPDX:GPL-3.0-or-later", 29 | ] 30 | # Optional: required by some licenses. 31 | # copyright = [ 32 | # "2002-2024 Developer Name", 33 | # "1998 Company Name", 34 | # ] 35 | 36 | # Optional list of supported platforms. If omitted, the extension will be available in all operating systems. 37 | # platforms = ["windows-x64", "macos-arm64", "linux-x64"] 38 | # Other supported platforms: "windows-arm64", "macos-x64" 39 | 40 | # Optional: bundle 3rd party Python modules. 41 | # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html 42 | # wheels = [ 43 | # "./wheels/pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", 44 | # "./wheels/pillow-11.1.0-cp311-cp311-win_amd64.whl", 45 | # "./wheels/numpy-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", 46 | # "./wheels/numpy-2.2.2-cp311-cp311-win_amd64.whl" 47 | # ] 48 | 49 | 50 | # Optional: add-ons can list which resources they will require: 51 | # * files (for access of any filesystem operations) 52 | # * network (for internet access) 53 | # * clipboard (to read and/or write the system clipboard) 54 | # * camera (to capture photos and videos) 55 | # * microphone (to capture audio) 56 | # 57 | # If using network, remember to also check `bpy.app.online_access` 58 | # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access 59 | # 60 | # For each permission it is important to also specify the reason why it is required. 61 | # Keep this a single short sentence without a period (.) at the end. 62 | # For longer explanations use the documentation or detail page. 63 | # 64 | # [permissions] 65 | network = "Check for updates from Github" 66 | # files = "Import/export FBX from/to disk" 67 | clipboard = "Copy and paste brush colors" 68 | 69 | # Optional: build settings. 70 | # https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build 71 | # [build] 72 | # paths_exclude_pattern = [ 73 | # "__pycache__/", 74 | # "/.git/", 75 | # "/*.zip", 76 | # ] 77 | -------------------------------------------------------------------------------- /brush icons/PS_Gouache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/brush icons/PS_Gouache.png -------------------------------------------------------------------------------- /brush icons/PS_Gouache_Strokes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/brush icons/PS_Gouache_Strokes.png -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Context, Node, NodeTree, Image, ImagePreview 3 | from typing import List 4 | from mathutils import Vector 5 | from typing import List, Tuple 6 | icons = bpy.types.UILayout.bl_rna.functions["prop"].parameters["icon"].enum_items.keys( 7 | ) 8 | 9 | 10 | def icon_parser(icon: str, default="NONE") -> str: 11 | if icon in icons: 12 | return icon 13 | return default 14 | 15 | 16 | def is_online(): 17 | return not bpy.app.version >= (4, 2, 0) or bpy.app.online_access 18 | 19 | 20 | def is_newer_than(major, minor=0, patch=0): 21 | return bpy.app.version >= (major, minor, patch) 22 | 23 | 24 | def import_legacy_updater(): 25 | try: 26 | from . import addon_updater_ops 27 | return addon_updater_ops 28 | except ImportError: 29 | return None 30 | 31 | 32 | def redraw_panel(self, context: Context): 33 | # Force the UI to update 34 | if context.area: 35 | context.area.tag_redraw() 36 | 37 | 38 | def map_range(num, inMin, inMax, outMin, outMax): 39 | return outMin + (float(num - inMin) / float(inMax - inMin) * (outMax - outMin)) 40 | 41 | 42 | # Fixes UnicodeDecodeError bug 43 | STRING_CACHE = {} 44 | 45 | 46 | def intern_enum_items(items): 47 | def intern_string(s): 48 | if not isinstance(s, str): 49 | return s 50 | global STRING_CACHE 51 | if s not in STRING_CACHE: 52 | STRING_CACHE[s] = s 53 | return STRING_CACHE[s] 54 | return [tuple(intern_string(s) for s in item) for item in items] 55 | 56 | 57 | class NodeOrganizer: 58 | created_nodes_names: List[str] 59 | 60 | def __init__(self, material: bpy.types.Material): 61 | self.node_tree = material.node_tree 62 | self.nodes = self.node_tree.nodes 63 | self.links = self.node_tree.links 64 | self.rightmost = max( 65 | self.nodes, key=lambda node: node.location.x).location 66 | self.created_nodes_names = [] 67 | 68 | def value_set(self, obj, path, value): 69 | if '.' in path: 70 | path_prop, path_attr = path.rsplit('.', 1) 71 | prop = obj.path_resolve(path_prop) 72 | else: 73 | prop = obj 74 | path_attr = path 75 | setattr(prop, path_attr, value) 76 | 77 | def create_node(self, node_type, attrs = {}): 78 | node = self.nodes.new(node_type) 79 | for attr in attrs: 80 | self.value_set(node, attr, attrs[attr]) 81 | self.created_nodes_names.append(node.name) 82 | return node 83 | 84 | def create_link(self, from_node_name: str, to_node_name: str, from_socket_name, to_socket_name): 85 | output_node = self.nodes[from_node_name] 86 | input_node = self.nodes[to_node_name] 87 | self.links.new(input_node.inputs[to_socket_name], 88 | output_node.outputs[from_socket_name]) 89 | 90 | def move_nodes_offset(self, offset: Vector): 91 | created_nodes = [self.nodes[name] for name in self.created_nodes_names] 92 | for node in created_nodes: 93 | if node.type != 'FRAME': 94 | node.location += offset 95 | 96 | def move_nodes_to_end(self): 97 | created_nodes = [self.nodes[name] for name in self.created_nodes_names] 98 | created_nodes_leftmost = min( 99 | created_nodes, key=lambda node: node.location.x).location 100 | offset = self.rightmost - created_nodes_leftmost + Vector((200, 0)) 101 | self.move_nodes_offset(offset) 102 | 103 | 104 | def get_object_uv_maps(self, context: Context): 105 | items = [ 106 | (uv_map.name, uv_map.name, "") for uv_map in context.object.data.uv_layers 107 | ] 108 | return intern_enum_items(items) 109 | 110 | 111 | def find_keymap(keymap_name): 112 | wm = bpy.context.window_manager 113 | kc = wm.keyconfigs.addon 114 | if kc: 115 | for km in kc.keymaps: 116 | if km: 117 | kmi = km.keymap_items.get(keymap_name) 118 | if kmi: 119 | return kmi 120 | return None 121 | 122 | 123 | def get_event_icons(kmi): 124 | """Return a list of icons for a keymap item, including modifiers 125 | 126 | Args: 127 | kmi: KeyMapItem object 128 | 129 | Returns: 130 | list: List of Blender icon identifiers 131 | """ 132 | # Create a list to store all icons 133 | icons = [] 134 | 135 | # Add modifier icons first (in standard order) 136 | if kmi.ctrl: 137 | icons.append('EVENT_CTRL') 138 | if kmi.alt: 139 | icons.append('EVENT_ALT') 140 | if kmi.shift: 141 | icons.append('EVENT_SHIFT') 142 | if kmi.oskey: 143 | icons.append('EVENT_OS') 144 | 145 | # Dictionary mapping key types to icons 146 | key_icons = { 147 | # Mouse 148 | 'LEFTMOUSE': 'MOUSE_LMB', 149 | 'RIGHTMOUSE': 'MOUSE_RMB', 150 | 'MIDDLEMOUSE': 'MOUSE_MMB', 151 | 'WHEELUPMOUSE': 'MOUSE_LMB_DRAG', 152 | 'WHEELDOWNMOUSE': 'MOUSE_LMB_DRAG', 153 | 154 | # Special keys 155 | 'ESC': 'EVENT_ESC', 156 | 'RET': 'EVENT_RETURN', 157 | 'SPACE': 'EVENT_SPACEKEY', 158 | 'TAB': 'EVENT_TAB', 159 | 'DEL': 'EVENT_DELETEKEY', 160 | 'BACK_SPACE': 'EVENT_BACKSPACEKEY', 161 | 'COMMA': 'EVENT_COMMA', 162 | 'PERIOD': 'EVENT_PERIOD', 163 | 'SEMI_COLON': 'EVENT_SEMI_COLON', 164 | 'QUOTE': 'EVENT_QUOTE', 165 | 166 | # Numbers 167 | '0': 'EVENT_0', 168 | '1': 'EVENT_1', 169 | '2': 'EVENT_2', 170 | '3': 'EVENT_3', 171 | '4': 'EVENT_4', 172 | '5': 'EVENT_5', 173 | '6': 'EVENT_6', 174 | '7': 'EVENT_7', 175 | '8': 'EVENT_8', 176 | '9': 'EVENT_9', 177 | 178 | # Letters 179 | 'A': 'EVENT_A', 180 | 'B': 'EVENT_B', 181 | 'C': 'EVENT_C', 182 | 'D': 'EVENT_D', 183 | 'E': 'EVENT_E', 184 | 'F': 'EVENT_F', 185 | 'G': 'EVENT_G', 186 | 'H': 'EVENT_H', 187 | 'I': 'EVENT_I', 188 | 'J': 'EVENT_J', 189 | 'K': 'EVENT_K', 190 | 'L': 'EVENT_L', 191 | 'M': 'EVENT_M', 192 | 'N': 'EVENT_N', 193 | 'O': 'EVENT_O', 194 | 'P': 'EVENT_P', 195 | 'Q': 'EVENT_Q', 196 | 'R': 'EVENT_R', 197 | 'S': 'EVENT_S', 198 | 'T': 'EVENT_T', 199 | 'U': 'EVENT_U', 200 | 'V': 'EVENT_V', 201 | 'W': 'EVENT_W', 202 | 'X': 'EVENT_X', 203 | 'Y': 'EVENT_Y', 204 | 'Z': 'EVENT_Z', 205 | 206 | # Function keys 207 | 'F1': 'EVENT_F1', 208 | 'F2': 'EVENT_F2', 209 | 'F3': 'EVENT_F3', 210 | 'F4': 'EVENT_F4', 211 | 'F5': 'EVENT_F5', 212 | 'F6': 'EVENT_F6', 213 | 'F7': 'EVENT_F7', 214 | 'F8': 'EVENT_F8', 215 | 'F9': 'EVENT_F9', 216 | 'F10': 'EVENT_F10', 217 | 'F11': 'EVENT_F11', 218 | 'F12': 'EVENT_F12', 219 | 220 | # Arrows 221 | 'LEFT_ARROW': 'EVENT_LEFT_ARROW', 222 | 'RIGHT_ARROW': 'EVENT_RIGHT_ARROW', 223 | 'UP_ARROW': 'EVENT_UP_ARROW', 224 | 'DOWN_ARROW': 'EVENT_DOWN_ARROW', 225 | 226 | # Numpad 227 | 'NUMPAD_0': 'EVENT_0', 228 | 'NUMPAD_1': 'EVENT_1', 229 | 'NUMPAD_2': 'EVENT_2', 230 | 'NUMPAD_3': 'EVENT_3', 231 | 'NUMPAD_4': 'EVENT_4', 232 | 'NUMPAD_5': 'EVENT_5', 233 | 'NUMPAD_6': 'EVENT_6', 234 | 'NUMPAD_7': 'EVENT_7', 235 | 'NUMPAD_8': 'EVENT_8', 236 | 'NUMPAD_9': 'EVENT_9', 237 | 'NUMPAD_PLUS': 'EVENT_PLUS', 238 | 'NUMPAD_MINUS': 'EVENT_MINUS', 239 | 'NUMPAD_ASTERIX': 'EVENT_ASTERISK', 240 | 'NUMPAD_SLASH': 'EVENT_SLASH', 241 | 'NUMPAD_PERIOD': 'EVENT_PERIOD', 242 | 'NUMPAD_ENTER': 'EVENT_RETURN', 243 | } 244 | 245 | # Add the key icon if it exists in our mapping 246 | if kmi.type in key_icons: 247 | icons.append(key_icons[kmi.type]) 248 | else: 249 | # Fall back to a generic keyboard icon for unknown keys 250 | icons.append('KEYINGSET') 251 | 252 | return icons 253 | 254 | 255 | def get_connected_nodes(output_node: Node) -> List[Tuple[Node, int]]: 256 | """ 257 | Gets all nodes connected to the given output_node with their search depth, 258 | maintaining the order in which they were found and removing duplicates. 259 | 260 | Args: 261 | output_node: The output node. 262 | 263 | Returns: 264 | A list of tuples (Node, depth), preserving the order of discovery and removing duplicates. 265 | """ 266 | nodes = [] 267 | visited = set() # Track visited nodes to avoid duplicates 268 | 269 | def traverse(node: Node, depth: int = 0): 270 | if node not in visited: # Check if the node has been visited 271 | visited.add(node) # Add the node to the visited set 272 | if not node.mute: 273 | nodes.append((node, depth)) 274 | if hasattr(node, 'node_tree') and node.node_tree: 275 | for sub_node in node.node_tree.nodes: 276 | traverse(sub_node, depth + 1) 277 | for input in node.inputs: 278 | for link in input.links: 279 | traverse(link.from_node, depth) 280 | 281 | traverse(output_node) 282 | return nodes 283 | 284 | 285 | def get_active_material_output(node_tree: NodeTree) -> Node: 286 | """Get the active material output node 287 | 288 | Args: 289 | node_tree (bpy.types.NodeTree): The node tree to check 290 | 291 | Returns: 292 | bpy.types.Node: The active material output node 293 | """ 294 | for node in node_tree.nodes: 295 | if node.bl_idname == "ShaderNodeOutputMaterial" and node.is_active_output: 296 | return node 297 | return None 298 | 299 | 300 | def is_image_painted(image: Image | ImagePreview) -> bool: 301 | """Check if the image is painted 302 | 303 | Args: 304 | image (bpy.types.Image): The image to check 305 | 306 | Returns: 307 | bool: True if the image is painted, False otherwise 308 | """ 309 | if not image: 310 | return False 311 | if isinstance(image, Image): 312 | return image.pixels and len(image.pixels) > 0 and any(image.pixels) 313 | elif isinstance(image, ImagePreview): 314 | # print("ImagePreview", image.image_pixels, image.image_size[0], image.image_size[1], len(list(image.icon_pixels)[3::4])) 315 | return any([pixel > 0 for pixel in list(image.image_pixels_float)[3::4]]) 316 | return False 317 | 318 | def get_unified_settings(context: Context, unified_name=None): 319 | ups = context.tool_settings.unified_paint_settings 320 | tool_settings = context.tool_settings.image_paint 321 | brush = tool_settings.brush 322 | prop_owner = brush 323 | if unified_name and getattr(ups, unified_name): 324 | prop_owner = ups 325 | return prop_owner -------------------------------------------------------------------------------- /common_layers.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.props import ( 3 | BoolProperty, 4 | StringProperty, 5 | FloatVectorProperty, 6 | EnumProperty, 7 | IntProperty, 8 | ) 9 | from bpy.types import Operator, Context 10 | from .common import get_object_uv_maps 11 | from .paint_system import PaintSystem 12 | 13 | 14 | class MultiMaterialOperator(Operator): 15 | multiple_objects: BoolProperty( 16 | name="Multiple Objects", 17 | description="Run the operator on multiple objects", 18 | default=True, 19 | ) 20 | multiple_materials: BoolProperty( 21 | name="Multiple Materials", 22 | description="Run the operator on multiple materials", 23 | default=False, 24 | ) 25 | def execute(self, context: Context): 26 | error_count = 0 27 | objects = set() 28 | objects.add(context.object) 29 | if self.multiple_objects: 30 | objects.update(context.selected_objects) 31 | 32 | seen_materials = set() 33 | for object in objects: 34 | if object.type != 'MESH': 35 | continue 36 | object_mats = object.data.materials 37 | if object_mats: 38 | if self.multiple_materials: 39 | for mat in object_mats: 40 | if mat in seen_materials: 41 | continue 42 | with context.temp_override(active_object=object, selected_objects=[object], active_material=mat): 43 | error_count += self._process_material(bpy.context) 44 | seen_materials.add(mat) 45 | else: 46 | if object.active_material in seen_materials: 47 | continue 48 | with context.temp_override(active_object=object, selected_objects=[object], active_material=object.active_material): 49 | error_count += self._process_material(bpy.context) 50 | seen_materials.add(object.active_material) 51 | else: 52 | with context.temp_override(active_object=object, selected_objects=[object]): 53 | error_count += self._process_material(bpy.context) 54 | 55 | if error_count > 0: 56 | self.report({'WARNING'}, f"Completed with {error_count} error{'s' if error_count > 1 else ''}") 57 | 58 | return {'FINISHED'} 59 | 60 | # @staticmethod 61 | # def process_object(self, context: Context, obj: bpy.types.Object): 62 | # error_count = 0 63 | # materials = set() 64 | # if self.multiple_materials: 65 | # materials = obj.data.materials 66 | # if not materials: 67 | # with context.temp_override(active_object=obj, selected_objects=[obj]): 68 | # error_count += self._process_material(bpy.context) 69 | # else: 70 | # for mat in obj.data.materials: 71 | # with context.temp_override(active_object=obj, selected_objects=[obj], active_material=mat): 72 | # error_count += self._process_material(bpy.context) 73 | # else: 74 | # with context.temp_override(active_object=obj, selected_objects=[obj]): 75 | # error_count += self._process_material(bpy.context) 76 | 77 | # return error_count 78 | 79 | def _process_material(self, context: Context): 80 | try: 81 | return self.process_material(context) 82 | except Exception as e: 83 | print(f"Error processing material: {e}") 84 | return 1 85 | 86 | def multiple_objects_ui(self, layout): 87 | box = layout.box() 88 | box.label(text="Applying to all selected objects", icon='INFO') 89 | 90 | def process_material(self, context: Context): 91 | raise NotImplementedError('This method should be overridden in subclasses') 92 | return 0 # Return 0 errors by default 93 | 94 | 95 | 96 | class UVLayerHandler(Operator): 97 | uv_map_mode: EnumProperty( 98 | name="UV Map", 99 | items=[ 100 | ('PAINT_SYSTEM', "Paint System UV", "Use the Paint System UV Map"), 101 | ('OPEN', "Use Existing", "Open an existing UV Map"), 102 | ] 103 | ) 104 | uv_map_name: EnumProperty( 105 | name="UV Map", 106 | items=get_object_uv_maps 107 | ) 108 | 109 | def get_uv_mode(self, context: Context): 110 | ps = PaintSystem(context) 111 | mat_settings = ps.get_material_settings() 112 | if mat_settings: 113 | self.uv_map_mode = 'PAINT_SYSTEM' if mat_settings.use_paintsystem_uv else 'OPEN' 114 | # print(f"UV Mode: {self.uv_map_mode}") 115 | 116 | def set_uv_mode(self, context: Context): 117 | ps = PaintSystem(context) 118 | mat_settings = ps.get_material_settings() 119 | if mat_settings: 120 | ps.get_material_settings().use_paintsystem_uv = self.uv_map_mode == "PAINT_SYSTEM" 121 | 122 | self.ensure_uv_map(context) 123 | return self.uv_map_mode 124 | 125 | def ensure_uv_map(self, context): 126 | if self.uv_map_mode == 'PAINT_SYSTEM': 127 | if 'PaintSystemUVMap' not in [uvmap[0] for uvmap in get_object_uv_maps(self, context)]: 128 | bpy.ops.paint_system.create_new_uv_map( 129 | 'INVOKE_DEFAULT', uv_map_name="PaintSystemUVMap") 130 | self.uv_map_name = "PaintSystemUVMap" 131 | elif not self.uv_map_name: 132 | self.report({'ERROR'}, "No UV Map selected") 133 | return {'CANCELLED'} 134 | 135 | def select_uv_ui(self, layout): 136 | layout.label(text="UV Map", icon='UV') 137 | row = layout.row(align=True) 138 | row.prop(self, "uv_map_mode", expand=True) 139 | if self.uv_map_mode == 'OPEN': 140 | layout.prop(self, "uv_map_name", text="") 141 | -------------------------------------------------------------------------------- /custom_icons.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | 4 | ICON_FOLDER = 'icons' 5 | 6 | 7 | def load_custom_icons(): 8 | import bpy.utils.previews 9 | # Custom Icon 10 | if not hasattr(bpy.utils, 'previews'): 11 | return 12 | global custom_icons 13 | custom_icons = bpy.utils.previews.new() 14 | 15 | folder = os.path.dirname(bpy.path.abspath( 16 | __file__)) + os.sep + ICON_FOLDER + os.sep 17 | 18 | for f in os.listdir(folder): 19 | # Remove file extension 20 | icon_name = os.path.splitext(f)[0] 21 | custom_icons.load(icon_name, folder + f, 'IMAGE') 22 | 23 | 24 | def unload_custom_icons(): 25 | global custom_icons 26 | if hasattr(bpy.utils, 'previews'): 27 | bpy.utils.previews.remove(custom_icons) 28 | custom_icons = None 29 | 30 | 31 | def get_icon(custom_icon_name): 32 | if custom_icons is None: 33 | return None 34 | if custom_icon_name not in custom_icons: 35 | return None 36 | return custom_icons[custom_icon_name].icon_id 37 | -------------------------------------------------------------------------------- /icons/paint_system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/icons/paint_system.png -------------------------------------------------------------------------------- /icons/sunflower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/icons/sunflower.png -------------------------------------------------------------------------------- /library.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/library.blend -------------------------------------------------------------------------------- /library.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natapol2547/paintsystem/3f4ad4c33be8c77f590bb738c986d22ff61a0375/library.blend1 -------------------------------------------------------------------------------- /nested_list_manager.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.props import StringProperty, IntProperty, CollectionProperty, PointerProperty, EnumProperty 3 | from bpy.types import (PropertyGroup, UIList, Operator, Panel) 4 | 5 | 6 | class BaseNestedListItem(PropertyGroup): 7 | """Base class for nested list items. Extend this class to add custom properties.""" 8 | id: IntProperty() 9 | name: StringProperty() 10 | parent_id: IntProperty(default=-1) 11 | order: IntProperty() 12 | type: EnumProperty( 13 | items=[ 14 | ('FOLDER', "Folder", "Can contain other items"), 15 | ('ITEM', "Item", "Cannot contain other items") 16 | ], 17 | default='ITEM' 18 | ) 19 | 20 | 21 | class BaseNestedListManager(PropertyGroup): 22 | """Base class for nested list manager. Override items property in your subclass.""" 23 | active_index: IntProperty() 24 | next_id: IntProperty(default=0) 25 | 26 | @property 27 | def item_type(self): 28 | """Override this to return your custom item type class""" 29 | return BaseNestedListItem 30 | 31 | def get_active_item(self): 32 | """Get currently active item based on active_index""" 33 | # active_id = self.get_id_from_flattened_index(self.active_index) 34 | # if active_id != -1: 35 | # return self.get_item_by_id(active_id) 36 | # return None 37 | if self.items is None or self.active_index < 0 or self.active_index >= len(self.items): 38 | return None 39 | return self.items[self.active_index] 40 | 41 | def adjust_sibling_orders(self, parent_id, insert_order): 42 | """Increase order of all items at or above the insert_order under the same parent""" 43 | for item in self.items: 44 | if item.parent_id == parent_id and item.order >= insert_order: 45 | item.order += 1 46 | 47 | def get_insertion_data(self, active_item=None): 48 | """Get parent_id and insert_order for new item based on active item""" 49 | if active_item is None: 50 | active_item = self.get_active_item() 51 | 52 | parent_id = -1 53 | insert_order = 1 54 | 55 | if active_item: 56 | if active_item.type == 'FOLDER': 57 | # If selected item is a folder, add inside it 58 | parent_id = active_item.id 59 | # Find lowest order in folder or default to 1 60 | orders = [ 61 | item.order for item in self.items if item.parent_id == parent_id] 62 | insert_order = min(orders) if orders else 1 63 | else: 64 | # If selected item is not a folder, add at same level 65 | parent_id = active_item.parent_id 66 | insert_order = active_item.order 67 | 68 | return parent_id, insert_order 69 | 70 | def add_item(self, name, item_type='ITEM', parent_id=-1, **kwargs): 71 | """Add a new item to the list""" 72 | if item_type == 'ITEM' and parent_id != -1: 73 | parent = self.get_item_by_id(parent_id) 74 | if parent and parent.type != 'FOLDER': 75 | return -1 76 | 77 | new_item = self.items.add() 78 | new_item.id = self.next_id 79 | new_item.name = name 80 | new_item.parent_id = parent_id 81 | new_item.type = item_type 82 | new_item.order = self.get_next_order(parent_id) 83 | 84 | for key, value in kwargs.items(): 85 | if hasattr(new_item, key): 86 | setattr(new_item, key, value) 87 | 88 | self.next_id += 1 89 | return new_item.id 90 | 91 | def remove_item_and_children(self, item_id, on_delete=None): 92 | """Remove an item and all its children""" 93 | to_remove = [] 94 | 95 | def collect_children(parent_id): 96 | for i, item in enumerate(self.items): 97 | if item.parent_id == parent_id: 98 | to_remove.append(i) 99 | if item.type == 'FOLDER': 100 | collect_children(item.id) 101 | 102 | # Collect the item index 103 | item_index = self.get_collection_index_from_id(item_id) 104 | if item_index != -1: 105 | to_remove.append(item_index) 106 | # If it's a folder, collect all children 107 | item = self.get_item_by_id(item_id) 108 | if item and item.type == 'FOLDER': 109 | collect_children(item_id) 110 | 111 | # Remove items from highest index to lowest 112 | for index in sorted(to_remove, reverse=True): 113 | if on_delete: 114 | on_delete(self.items[index]) 115 | self.items.remove(index) 116 | 117 | return True 118 | return False 119 | 120 | def get_next_order(self, parent_id): 121 | return max((item.order for item in self.items if item.parent_id == parent_id), default=-1) + 1 122 | 123 | def get_item_by_id(self, item_id): 124 | for item in self.items: 125 | if item.id == item_id: 126 | return item 127 | return None 128 | 129 | def get_collection_index_from_id(self, item_id): 130 | for index, item in enumerate(self.items): 131 | if item.id == item_id: 132 | return index 133 | return -1 134 | 135 | def get_id_from_flattened_index(self, flattened_index): 136 | flattened = self.items 137 | if 0 <= flattened_index < len(flattened): 138 | return flattened[flattened_index].id 139 | return -1 140 | 141 | def flatten_hierarchy(self): 142 | # children = self.items 143 | # print("Children:", [(i.id, i.parent_id, i.order) for i in children]) 144 | # flattened = sorted( 145 | # children, key=lambda i: (i.order, i.parent_id)) 146 | # return [(item, self.get_item_level_from_id(item.id)) for item in flattened] 147 | def collect_items(parent_id, level): 148 | collected = [] 149 | children = sorted( 150 | [item for item in self.items if item.parent_id == parent_id], 151 | key=lambda i: i.order 152 | ) 153 | for item in children: 154 | collected.append((item, level)) 155 | if item.type == 'FOLDER': 156 | collected.extend(collect_items(item.id, level + 1)) 157 | return collected 158 | test = collect_items(-1, 0) 159 | # print("Flattened hierarchy:", [v[0].id for v in test]) 160 | # print("Expected flattened:", [v.id for v in flattened]) 161 | return test 162 | 163 | def get_item_level_from_id(self, item_id): 164 | """Get the level of an item in the hierarchy""" 165 | item = self.get_item_by_id(item_id) 166 | if not item: 167 | return -1 168 | 169 | level = 0 170 | while item.parent_id != -1: 171 | item = self.get_item_by_id(item.parent_id) 172 | level += 1 173 | 174 | return level 175 | 176 | def normalize_orders(self): 177 | """Normalize orders to be sequential starting from 1 within each parent level""" 178 | # Group items by parent_id 179 | parent_groups = {} 180 | for item in self.items: 181 | if item.parent_id not in parent_groups: 182 | parent_groups[item.parent_id] = [] 183 | parent_groups[item.parent_id].append(item) 184 | 185 | # Sort and reassign orders within each parent group 186 | for parent_id, items in parent_groups.items(): 187 | sorted_items = sorted(items, key=lambda x: x.order) 188 | for index, item in enumerate(sorted_items, start=1): # Start from 1 189 | item.order = index 190 | 191 | def get_next_sibling_item(self, flattened, current_flat_index): 192 | """ 193 | Get the next sibling item, skipping over children if the current item is a folder. 194 | Returns (item, index) or (None, -1) if not found. 195 | """ 196 | current_item, current_level = flattened[current_flat_index] 197 | 198 | # Find where this folder's contents end 199 | if current_item.type == 'FOLDER': 200 | i = current_flat_index + 1 201 | while i < len(flattened): 202 | item, level = flattened[i] 203 | if level <= current_level: 204 | return (item, i) 205 | i += 1 206 | else: 207 | # For non-folders, just get the next item 208 | if current_flat_index + 1 < len(flattened): 209 | return (flattened[current_flat_index + 1][0], current_flat_index + 1) 210 | 211 | return (None, -1) 212 | 213 | def get_movement_options(self, item_id, direction): 214 | """ 215 | Analyze possible movement options for an item. 216 | Returns a list of possible actions and their descriptions. 217 | """ 218 | item = self.get_item_by_id(item_id) 219 | if not item: 220 | return [] 221 | 222 | flattened = self.flatten_hierarchy() 223 | current_flat_index = next( 224 | (i for i, (it, _) in enumerate(flattened) if it.id == item_id), -1) 225 | options = [] 226 | 227 | if direction == 'UP': 228 | if current_flat_index > 0: 229 | above_item, _ = flattened[current_flat_index - 1] 230 | 231 | # Special case: If the above item is the parent folder 232 | if above_item.id == item.parent_id: 233 | options.append( 234 | ('MOVE_OUT', f"Move out of folder '{above_item.name}'")) 235 | return options # Only show this option in this case 236 | 237 | # Normal cases 238 | if above_item.type == 'FOLDER': 239 | options.append( 240 | ('MOVE_INTO', f"Move into folder '{above_item.name}'")) 241 | if above_item.parent_id != item.parent_id: 242 | parent = self.get_item_by_id(above_item.parent_id) 243 | parent_name = parent.name if parent else "root" 244 | options.append( 245 | ('MOVE_ADJACENT', f"Move as sibling (into '{parent_name}')")) 246 | options.append(('SKIP', "Skip over")) 247 | 248 | # Check if at top of current parent 249 | siblings = sorted( 250 | [i for i in self.items if i.parent_id == item.parent_id], key=lambda x: x.order) 251 | if item.order == 0 and item.parent_id != -1: # At top of current parent and not at root 252 | parent = self.get_item_by_id(item.parent_id) 253 | if parent: 254 | options.append( 255 | ('MOVE_OUT', f"Move out of folder '{parent.name}'")) 256 | 257 | else: # DOWN 258 | # Get next sibling item (skipping folder contents if necessary) 259 | next_item, next_index = self.get_next_sibling_item( 260 | flattened, current_flat_index) 261 | 262 | if next_item is None: # At bottom of its level 263 | if item.parent_id != -1: # If not at root level 264 | parent = self.get_item_by_id(item.parent_id) 265 | if parent: 266 | options.append( 267 | ('MOVE_OUT_BOTTOM', f"Move out of folder '{parent.name}'")) 268 | else: 269 | if next_item.type == 'FOLDER': 270 | options.append( 271 | ('MOVE_INTO_TOP', f"Move into folder '{next_item.name}' (at top)")) 272 | if next_item.parent_id != item.parent_id: 273 | parent = self.get_item_by_id(next_item.parent_id) 274 | parent_name = parent.name if parent else "root" 275 | options.append( 276 | ('MOVE_ADJACENT', f"Move as sibling (into '{parent_name}')")) 277 | options.append(('SKIP', "Skip over")) 278 | 279 | return options 280 | 281 | def get_movement_menu_items(self, item_id, direction): 282 | """ 283 | Get menu items for movement options. 284 | Returns list of tuples (identifier, label, description) 285 | """ 286 | options = self.get_movement_options(item_id, direction) 287 | menu_items = [] 288 | 289 | # Map option identifiers to their operators 290 | operator_map = { 291 | 'UP': 'nested_list.move_up', 292 | 'DOWN': 'nested_list.move_down' 293 | } 294 | 295 | for identifier, description in options: 296 | menu_items.append(( 297 | operator_map[direction], 298 | description, 299 | {'action': identifier} 300 | )) 301 | 302 | return menu_items 303 | 304 | def move_item_out_of_folder(self, item, parent, direction): 305 | """Move item out of its current folder""" 306 | grandparent_id = parent.parent_id 307 | 308 | if direction == 'UP': 309 | # Move item out to grandparent level at parent's position 310 | item.parent_id = grandparent_id 311 | item.order = parent.order 312 | 313 | # Shift parent and siblings down 314 | for sibling in self.items: 315 | if sibling.parent_id == grandparent_id and sibling.order >= parent.order: 316 | sibling.order += 1 317 | else: # DOWN 318 | # Move to bottom of grandparent level 319 | max_order = max( 320 | (i.order for i in self.items if i.parent_id == grandparent_id), 321 | default=-1 322 | ) 323 | item.parent_id = grandparent_id 324 | item.order = max_order + 1 325 | 326 | return True 327 | 328 | def move_item_into_folder(self, item, target_folder, position='BOTTOM'): 329 | """Move item into a folder at specified position (TOP or BOTTOM)""" 330 | item.parent_id = target_folder.id 331 | 332 | if position == 'TOP': 333 | # Shift existing items down 334 | for other in self.items: 335 | if other.parent_id == target_folder.id: 336 | other.order += 1 337 | item.order = 0 338 | else: # BOTTOM 339 | item.order = self.get_next_order(target_folder.id) 340 | 341 | return True 342 | 343 | def move_item_adjacent(self, item, target_item, direction): 344 | """Move item as sibling of target item""" 345 | item.parent_id = target_item.parent_id 346 | 347 | if direction == 'UP': 348 | item.order = target_item.order + 1 349 | else: # DOWN 350 | item.order = target_item.order 351 | # Shift other items 352 | for other in self.items: 353 | if (other.parent_id == target_item.parent_id and 354 | other.order >= target_item.order and 355 | other.id != item.id): 356 | other.order += 1 357 | 358 | return True 359 | 360 | def skip_over_item(self, item, siblings, direction): 361 | """Simple reorder within same parent level""" 362 | idx = siblings.index(item) 363 | 364 | if direction == 'UP' and idx > 0: 365 | item.order, siblings[idx - 366 | 1].order = siblings[idx - 1].order, item.order 367 | return True 368 | elif direction == 'DOWN' and idx < len(siblings) - 1: 369 | item.order, siblings[idx + 370 | 1].order = siblings[idx + 1].order, item.order 371 | return True 372 | 373 | return False 374 | 375 | def execute_movement(self, item_id, direction, action): 376 | """Execute the selected movement action.""" 377 | item = self.get_item_by_id(item_id) 378 | if not item: 379 | return False 380 | 381 | flattened = self.flatten_hierarchy() 382 | current_flat_index = next( 383 | (i for i, (it, _) in enumerate(flattened) if it.id == item_id), -1) 384 | 385 | # Handle moving out of folder 386 | if action in ['MOVE_OUT', 'MOVE_OUT_BOTTOM'] and item.parent_id != -1: 387 | parent = self.get_item_by_id(item.parent_id) 388 | if parent: 389 | return self.move_item_out_of_folder(item, parent, direction) 390 | 391 | # Get relevant items based on direction 392 | if direction == 'UP': 393 | if current_flat_index > 0: 394 | target_item, _ = flattened[current_flat_index - 1] 395 | 396 | if action == 'MOVE_INTO' and target_item.type == 'FOLDER': 397 | return self.move_item_into_folder(item, target_item, 'BOTTOM') 398 | elif action == 'MOVE_ADJACENT': 399 | return self.move_item_adjacent(item, target_item, direction) 400 | elif action == 'SKIP': 401 | siblings = sorted( 402 | [i for i in self.items if i.parent_id == item.parent_id], 403 | key=lambda x: x.order 404 | ) 405 | return self.skip_over_item(item, siblings, direction) 406 | else: # DOWN 407 | next_item, _ = self.get_next_sibling_item( 408 | flattened, current_flat_index) 409 | 410 | if next_item: 411 | if action == 'MOVE_INTO_TOP' and next_item.type == 'FOLDER': 412 | return self.move_item_into_folder(item, next_item, 'TOP') 413 | elif action == 'MOVE_ADJACENT': 414 | return self.move_item_adjacent(item, next_item, direction) 415 | elif action == 'SKIP': 416 | siblings = sorted( 417 | [i for i in self.items if i.parent_id == item.parent_id], 418 | key=lambda x: x.order 419 | ) 420 | return self.skip_over_item(item, siblings, direction) 421 | 422 | return False 423 | # Example of how to create a custom implementation: 424 | 425 | 426 | class CustomNestedListItem(BaseNestedListItem): 427 | """Example custom item with additional properties""" 428 | custom_int: IntProperty(name="Custom Integer") 429 | custom_string: StringProperty(name="Custom String") 430 | 431 | 432 | class CustomNestedListManager(BaseNestedListManager): 433 | """Example custom manager that uses the custom item type""" 434 | # Define the collection property directly in the class 435 | items: CollectionProperty(type=CustomNestedListItem) 436 | 437 | @property 438 | def item_type(self): 439 | return CustomNestedListItem 440 | 441 | # Modified UI list to handle custom properties 442 | 443 | 444 | class BaseNLM_UL_List(UIList): 445 | 446 | use_filter_show = False 447 | 448 | def draw_item(self, context, layout, data, item, icon, active_data, active_property, index): 449 | nested_list_manager = self.get_list_manager(context) 450 | flattened = nested_list_manager.flatten_hierarchy() 451 | if index < len(flattened): 452 | display_item, level = flattened[index] 453 | # indent = " " * (level * 4) 454 | icon = 'FILE_FOLDER' if display_item.type == 'FOLDER' else 'OBJECT_DATA' 455 | row = layout.row(align=True) 456 | for _ in range(level): 457 | row.label(icon='BLANK1') 458 | row.prop(display_item, "name", text="", emboss=False, icon=icon) 459 | self.draw_custom_properties(row, display_item) 460 | 461 | def draw_custom_properties(self, layout, item): 462 | """Override this to draw custom properties""" 463 | pass 464 | 465 | def get_list_manager(self, context): 466 | """Override this to return the correct list manager instance""" 467 | return context.scene.nested_list_manager 468 | 469 | # Example custom UI list implementation 470 | 471 | 472 | class CustomNLM_UL_List(BaseNLM_UL_List): 473 | def draw_custom_properties(self, layout, item): 474 | if hasattr(item, 'custom_int'): 475 | layout.label(text=str(item.order)) 476 | # if hasattr(item, 'custom_string'): 477 | # layout.label(text=item.custom_string) 478 | 479 | 480 | # Update the NLM_OT_AddItem operator: 481 | class NLM_OT_AddItem(Operator): 482 | bl_idname = "nested_list.add_item" 483 | bl_label = "Add Item" 484 | 485 | item_type: EnumProperty( 486 | items=[ 487 | ('FOLDER', "Folder", "Add a folder"), 488 | ('ITEM', "Item", "Add an item") 489 | ], 490 | default='ITEM' 491 | ) 492 | 493 | def execute(self, context): 494 | manager = context.scene.nested_list_manager 495 | 496 | # Get insertion position 497 | parent_id, insert_order = manager.get_insertion_data() 498 | 499 | # Adjust existing items' order 500 | manager.adjust_sibling_orders(parent_id, insert_order) 501 | 502 | # Create the new item 503 | new_id = manager.add_item( 504 | name=f"{'Folder' if self.item_type == 'FOLDER' else 'Item'} {manager.next_id}", 505 | item_type=self.item_type, 506 | parent_id=parent_id, 507 | order=insert_order 508 | ) 509 | 510 | # Update active index 511 | if new_id != -1: 512 | flattened = manager.flatten_hierarchy() 513 | for i, (item, _) in enumerate(flattened): 514 | if item.id == new_id: 515 | manager.active_index = i 516 | break 517 | 518 | return {'FINISHED'} 519 | 520 | 521 | class NLM_OT_RemoveItem(Operator): 522 | bl_idname = "nested_list.remove_item" 523 | bl_label = "Remove Item" 524 | 525 | def execute(self, context): 526 | manager = context.scene.nested_list_manager 527 | item_id = manager.get_id_from_flattened_index(manager.active_index) 528 | 529 | if item_id != -1 and manager.remove_item_and_children(item_id): 530 | # Update active_index 531 | flattened = manager.flatten_hierarchy() 532 | manager.active_index = min( 533 | manager.active_index, len(flattened) - 1) 534 | # Run normalize orders to fix any gaps 535 | manager.normalize_orders() 536 | return {'FINISHED'} 537 | 538 | return {'CANCELLED'} 539 | 540 | 541 | class NLM_OT_MoveUp(Operator): 542 | bl_idname = "nested_list.move_up" 543 | bl_label = "Move Item Up" 544 | 545 | action: EnumProperty( 546 | items=[ 547 | ('MOVE_INTO', "Move Into", "Move into folder"), 548 | ('MOVE_ADJACENT', "Move Adjacent", "Move as sibling"), 549 | ('MOVE_OUT', "Move Out", "Move out of folder"), 550 | ('SKIP', "Skip", "Skip over item"), 551 | ] 552 | ) 553 | 554 | def invoke(self, context, event): 555 | manager = context.scene.nested_list_manager 556 | item_id = manager.get_id_from_flattened_index(manager.active_index) 557 | 558 | options = manager.get_movement_options(item_id, 'UP') 559 | if not options: 560 | return {'CANCELLED'} 561 | 562 | if len(options) == 1 and options[0][0] == 'SKIP': 563 | self.action = 'SKIP' 564 | return self.execute(context) 565 | 566 | context.window_manager.popup_menu( 567 | self.draw_menu, 568 | title="Move Options" 569 | ) 570 | return {'FINISHED'} 571 | 572 | def draw_menu(self, self_menu, context): 573 | manager = context.scene.nested_list_manager 574 | item_id = manager.get_id_from_flattened_index(manager.active_index) 575 | 576 | for op_id, label, props in manager.get_movement_menu_items(item_id, 'UP'): 577 | op = self_menu.layout.operator(op_id, text=label) 578 | for key, value in props.items(): 579 | setattr(op, key, value) 580 | 581 | def execute(self, context): 582 | manager = context.scene.nested_list_manager 583 | item_id = manager.get_id_from_flattened_index(manager.active_index) 584 | 585 | if manager.execute_movement(item_id, 'UP', self.action): 586 | # Update active_index to follow the moved item 587 | flattened = manager.flatten_hierarchy() 588 | for i, (item, _) in enumerate(flattened): 589 | if item.id == item_id: 590 | manager.active_index = i 591 | break 592 | manager.normalize_orders() 593 | return {'FINISHED'} 594 | 595 | return {'CANCELLED'} 596 | 597 | 598 | class NLM_OT_MoveDown(Operator): 599 | bl_idname = "nested_list.move_down" 600 | bl_label = "Move Item Down" 601 | 602 | action: EnumProperty( 603 | items=[ 604 | ('MOVE_OUT_BOTTOM', "Move Out Bottom", "Move out of folder"), 605 | ('MOVE_INTO_TOP', "Move Into Top", "Move to top of folder"), 606 | ('MOVE_ADJACENT', "Move Adjacent", "Move as sibling"), 607 | ('SKIP', "Skip", "Skip over item"), 608 | ] 609 | ) 610 | 611 | def invoke(self, context, event): 612 | manager = context.scene.nested_list_manager 613 | item_id = manager.get_id_from_flattened_index(manager.active_index) 614 | 615 | options = manager.get_movement_options(item_id, 'DOWN') 616 | if not options: 617 | return {'CANCELLED'} 618 | 619 | if len(options) == 1 and options[0][0] == 'SKIP': 620 | self.action = 'SKIP' 621 | return self.execute(context) 622 | 623 | context.window_manager.popup_menu( 624 | self.draw_menu, 625 | title="Move Options" 626 | ) 627 | return {'FINISHED'} 628 | 629 | def draw_menu(self, self_menu, context): 630 | manager = context.scene.nested_list_manager 631 | item_id = manager.get_id_from_flattened_index(manager.active_index) 632 | 633 | for op_id, label, props in manager.get_movement_menu_items(item_id, 'DOWN'): 634 | op = self_menu.layout.operator(op_id, text=label) 635 | for key, value in props.items(): 636 | setattr(op, key, value) 637 | 638 | def execute(self, context): 639 | manager = context.scene.nested_list_manager 640 | item_id = manager.get_id_from_flattened_index(manager.active_index) 641 | 642 | if manager.execute_movement(item_id, 'DOWN', self.action): 643 | # Update active_index to follow the moved item 644 | flattened = manager.flatten_hierarchy() 645 | for i, (item, _) in enumerate(flattened): 646 | if item.id == item_id: 647 | manager.active_index = i 648 | break 649 | manager.normalize_orders() 650 | return {'FINISHED'} 651 | 652 | return {'CANCELLED'} 653 | 654 | 655 | class NLM_OT_NormalizeOrders(Operator): 656 | bl_idname = "nested_list.normalize_orders" 657 | bl_label = "Normalize Orders" 658 | 659 | def execute(self, context): 660 | manager = context.scene.nested_list_manager 661 | manager.normalize_orders() 662 | return {'FINISHED'} 663 | 664 | 665 | class NLM_PT_Panel(Panel): 666 | bl_label = "Nested List Manager" 667 | bl_idname = "NLM_PT_panel" 668 | bl_space_type = 'VIEW_3D' 669 | bl_region_type = 'UI' 670 | bl_category = "Nested List" 671 | 672 | def draw(self, context): 673 | layout = self.layout 674 | manager = context.scene.nested_list_manager 675 | flattened = manager.flatten_hierarchy() 676 | 677 | row = layout.row() 678 | row.template_list( 679 | "CustomNLM_UL_List", "", manager, "items", manager, "active_index", 680 | rows=len(flattened) if flattened else 1 681 | ) 682 | 683 | col = row.column(align=True) 684 | row = col.row(align=True) 685 | row.operator("nested_list.add_item", text="", 686 | icon='ADD').item_type = 'ITEM' 687 | row.operator("nested_list.add_item", text="", 688 | icon='FILE_FOLDER').item_type = 'FOLDER' 689 | col.operator("nested_list.remove_item", icon="REMOVE", text="") 690 | col.operator("nested_list.move_up", icon="TRIA_UP", text="") 691 | col.operator("nested_list.move_down", icon="TRIA_DOWN", text="") 692 | 693 | 694 | # Example registration 695 | classes = [ 696 | BaseNestedListItem, 697 | BaseNestedListManager, 698 | CustomNestedListItem, 699 | CustomNestedListManager, 700 | BaseNLM_UL_List, 701 | CustomNLM_UL_List, 702 | # NLM_PT_Panel, 703 | # NLM_OT_AddItem, 704 | NLM_OT_MoveUp, 705 | NLM_OT_MoveDown, 706 | # NLM_OT_RemoveItem, 707 | # NLM_OT_NormalizeOrders, 708 | ] 709 | 710 | 711 | def register(): 712 | 713 | for cls in classes: 714 | bpy.utils.register_class(cls) 715 | 716 | # Register the property directly 717 | bpy.types.Scene.nested_list_manager = PointerProperty( 718 | type=CustomNestedListManager) 719 | 720 | 721 | def unregister(): 722 | del bpy.types.Scene.nested_list_manager 723 | 724 | for cls in reversed(classes): 725 | bpy.utils.unregister_class(cls) 726 | 727 | 728 | if __name__ == "__main__": 729 | register() 730 | -------------------------------------------------------------------------------- /node_builder.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import NodeTree, Node, NodeSocket 3 | 4 | class NodeTreeBuilder: 5 | def __init__(self, node_tree: NodeTree): 6 | """ 7 | Initialize the NodeTreeBuilder with a given node tree. 8 | :param node_tree: The node tree to be built. 9 | """ 10 | if not isinstance(node_tree, NodeTree): 11 | raise TypeError("node_tree must be of type NodeTree") 12 | self.node_tree = node_tree 13 | 14 | def create_node(self, type: str, attrs: dict = None) -> Node: 15 | """ 16 | Create a node of the specified type with optional attributes. 17 | """ 18 | node = self.node_tree.nodes.new(type) 19 | if attrs: 20 | for key, value in attrs.items(): 21 | setattr(node, key, value) 22 | return node.name 23 | 24 | def create_link(self, from_node_name: str, from_socket: str, to_node_name: str, to_socket: str): 25 | """ 26 | Create a link between two nodes. 27 | """ 28 | from_node = self.node_tree.nodes.get(from_node_name) 29 | if not from_node: 30 | raise ValueError("From node not found") 31 | 32 | to_node = self.node_tree.nodes.get(to_node_name) 33 | if not to_node: 34 | raise ValueError("To node not found") 35 | 36 | from_socket = from_node.outputs.get(from_socket) 37 | to_socket = to_node.inputs.get(to_socket) 38 | 39 | if not from_socket or not to_socket: 40 | raise ValueError("One or both sockets not found") 41 | 42 | self.node_tree.links.new(to_socket, from_socket) 43 | 44 | def delete_node(self, node_name: str): 45 | """ 46 | Delete a node by its name. 47 | """ 48 | node = self.node_tree.nodes.get(node_name) 49 | if not node: 50 | raise ValueError("Node not found") 51 | self.node_tree.nodes.remove(node) -------------------------------------------------------------------------------- /operators_bake.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import bpy 3 | from bpy.types import Operator, Context, Node, NodeTree, Image 4 | from bpy.props import ( 5 | BoolProperty, 6 | StringProperty, 7 | FloatVectorProperty, 8 | EnumProperty, 9 | IntProperty 10 | ) 11 | from bpy.utils import register_classes_factory 12 | from .paint_system import PaintSystem, get_nodetree_from_library 13 | from typing import List, Tuple 14 | from mathutils import Vector 15 | from .common import NodeOrganizer, get_object_uv_maps, get_connected_nodes, get_active_material_output 16 | import copy 17 | from .common_layers import UVLayerHandler 18 | 19 | IMPOSSIBLE_NODES = ( 20 | "ShaderNodeShaderInfo" 21 | ) 22 | REQUIRES_INTERMEDIATE_STEP = ( 23 | "ShaderNodeShaderToRGB" 24 | ) 25 | 26 | 27 | def is_bakeable(context: Context) -> Tuple[bool, str, List[Node]]: 28 | """Check if the node tree is multi-user 29 | 30 | Args: 31 | context (bpy.types.Context): The context to check 32 | 33 | Returns: 34 | Tuple[bool, str, List[bpy.types.Node]]: A tuple containing a boolean indicating if the node tree is multi-user and an error message if any 35 | """ 36 | ps = PaintSystem(context) 37 | active_group = ps.get_active_group() 38 | mat = ps.get_active_material() 39 | if not mat: 40 | return False, "No active material found.", [] 41 | if not mat.use_nodes: 42 | return False, "Material does not use nodes.", [] 43 | if not mat.node_tree: 44 | return False, "Material has no node tree.", [] 45 | if not mat.node_tree.nodes: 46 | return False, "Material node tree has no nodes.", [] 47 | output_node = get_active_material_output(mat.node_tree) 48 | if not output_node: 49 | return False, "No active material output node found.", [] 50 | node_tree = active_group.node_tree 51 | 52 | connected_nodes = get_connected_nodes(output_node) 53 | 54 | ps_groups = [] 55 | ris_nodes = [] 56 | impossible_nodes = [] 57 | 58 | for node, depth in connected_nodes: 59 | if node.bl_idname == "ShaderNodeGroup" and node.node_tree == node_tree: 60 | ps_groups.append(node) 61 | if node.bl_idname in REQUIRES_INTERMEDIATE_STEP: 62 | ris_nodes.append(node) 63 | if node.bl_idname in IMPOSSIBLE_NODES: 64 | impossible_nodes.append(node) 65 | 66 | if len(ps_groups) == 0: 67 | return False, "Paint System group is not connceted to Material Output.", [] 68 | if len(ps_groups) > 1: 69 | return False, "Paint System group used Multiple Times.", ps_groups 70 | if impossible_nodes: 71 | return False, "Unsupported nodes found.", impossible_nodes 72 | 73 | # TODO: Remove until the intermediate step is implemented 74 | if ris_nodes: 75 | return False, "Unsupported nodes found.", ris_nodes 76 | 77 | return True, "", [] 78 | 79 | 80 | def save_cycles_settings(): 81 | """Saves relevant Cycles render settings to a dictionary.""" 82 | settings = {} 83 | scene = bpy.context.scene 84 | 85 | if scene.render.engine == 'CYCLES': # Only save if Cycles is the engine 86 | settings['render_engine'] = scene.render.engine 87 | settings['device'] = scene.cycles.device 88 | settings['samples'] = scene.cycles.samples 89 | settings['preview_samples'] = scene.cycles.preview_samples 90 | settings['denoiser'] = scene.cycles.denoiser 91 | settings['use_denoising'] = scene.cycles.use_denoising 92 | 93 | # Add more settings you need to save here! 94 | return copy.deepcopy(settings) 95 | 96 | 97 | def rollback_cycles_settings(saved_settings): 98 | """Rolls back Cycles render settings using the saved dictionary, with robustness checks.""" 99 | scene = bpy.context.scene 100 | 101 | # Only rollback if settings were saved and we are in Cycles 102 | if saved_settings and scene.render.engine == 'CYCLES': 103 | try: # Use a try-except block to catch potential errors during rollback 104 | # Check if 'engine' attribute still exists 105 | if 'render_engine' in saved_settings and hasattr(scene.render, 'engine'): 106 | scene.render.engine = saved_settings['render_engine'] 107 | 108 | # Check if 'cycles' and 'device' exist 109 | if 'device' in saved_settings and hasattr(scene.cycles, 'device'): 110 | scene.cycles.device = saved_settings['device'] 111 | if 'samples' in saved_settings and hasattr(scene.cycles, 'samples'): 112 | scene.cycles.samples = saved_settings['samples'] 113 | if 'preview_samples' in saved_settings and hasattr(scene.cycles, 'preview_samples'): 114 | scene.cycles.preview_samples = saved_settings['preview_samples'] 115 | if 'denoiser' in saved_settings and hasattr(scene.cycles, 'denoiser'): 116 | scene.cycles.denoiser = saved_settings['denoiser'] 117 | if 'use_denoising' in saved_settings and hasattr(scene.cycles, 'use_denoising'): 118 | scene.cycles.use_denoising = saved_settings['use_denoising'] 119 | 120 | # Add rollbacks for any other settings you saved with similar checks! 121 | 122 | except Exception as e: 123 | # Log any errors during rollback 124 | print(f"Error during Cycles settings rollback: {e}") 125 | # You might want to handle the error more specifically, e.g., show a message to the user. 126 | 127 | 128 | def bake_node(context: Context, target_node: Node, image: Image, uv_layer: str, output_socket_name: str, alpha_socket_name: str = None, gpu=True) -> Node: 129 | """ 130 | Bakes a specific node from the active material with optimized settings 131 | 132 | Args: 133 | context (bpy.types.Context): The context to bake in 134 | target_node (bpy.types.Node): The node to bake 135 | image (bpy.types.Image): The image to bake to 136 | uv_layer (str): The UV layer to bake 137 | output_socket_name (str): The output socket name 138 | alpha_socket_name (str): The alpha socket name 139 | width (int, optional): The width of the image. Defaults to 1024. 140 | height (int, optional): The height of the image. Defaults to 1024. 141 | 142 | Returns: 143 | Image Texture Node: The baked image texture node 144 | """ 145 | 146 | # Debug 147 | print(f"Baking {target_node.name}") 148 | ps = PaintSystem(context) 149 | obj = ps.active_object 150 | if not obj or not obj.active_material: 151 | return None 152 | 153 | material = obj.active_material 154 | material.use_nodes = True 155 | nodes = material.node_tree.nodes 156 | material_output = get_active_material_output(material.node_tree) 157 | connected_nodes = get_connected_nodes(material_output) 158 | last_node_socket = material_output.inputs[0].links[0].from_socket 159 | 160 | # Save the original links from connected_nodes 161 | links = material.node_tree.links 162 | original_links = [] 163 | for node, depth in connected_nodes: 164 | for input_socket in node.inputs: 165 | for link in input_socket.links: 166 | original_links.append(link) 167 | 168 | # try: 169 | # Store original settings 170 | original_engine = copy.deepcopy( 171 | getattr(context.scene.render, "engine")) 172 | # Switch to Cycles if needed 173 | if context.scene.render.engine != 'CYCLES': 174 | context.scene.render.engine = 'CYCLES' 175 | 176 | cycles_settings = save_cycles_settings() 177 | cycles = context.scene.cycles 178 | cycles.device = 'GPU' if gpu else 'CPU' 179 | bake_node = None 180 | node_organizer = NodeOrganizer(material) 181 | socket_type = target_node.outputs[output_socket_name].type 182 | if socket_type != 'SHADER': 183 | # bake_nt = get_nodetree_from_library("_PS_Bake") 184 | bake_node = node_organizer.create_node( 185 | 'ShaderNodeEmission') 186 | node_organizer.create_link( 187 | target_node.name, bake_node.name, output_socket_name, 'Color') 188 | node_organizer.create_link( 189 | bake_node.name, material_output.name, 'Emission', 'Surface') 190 | # Check if target node has Alpha output 191 | # if alpha_socket_name: 192 | # node_organizer.create_link( 193 | # target_node.name, bake_node.name, alpha_socket_name, 'Alpha') 194 | bake_params = { 195 | "type": 'EMIT', 196 | } 197 | cycles.samples = 1 198 | cycles.use_denoising = False 199 | cycles.use_adaptive_sampling = False 200 | else: 201 | node_organizer.create_link( 202 | target_node.name, material_output.name, output_socket_name, 'Surface') 203 | bake_params = { 204 | "type": 'COMBINED', 205 | } 206 | cycles.samples = 128 207 | cycles.use_denoising = True 208 | cycles.use_adaptive_sampling = True 209 | 210 | # Change the only selected object to the active one 211 | # TODO: Allow baking multiple objects 212 | for obj in context.scene.objects: 213 | if obj != context.object: 214 | obj.select_set(False) 215 | else: 216 | obj.select_set(True) 217 | 218 | # Create and set up the image texture node 219 | bake_color_tex_node = node_organizer.create_node('ShaderNodeTexImage', { 220 | "name": "temp_bake_node", "image": image, "location": target_node.location + Vector((0, 300))}) 221 | 222 | image_alpha = image.copy() 223 | image_alpha.name = f"{image.name}_alpha" 224 | 225 | bake_alpha_tex_node = node_organizer.create_node('ShaderNodeTexImage', { 226 | "name": "temp_bake_node", "image": image_alpha, "location": target_node.location + Vector((0, 600))}) 227 | 228 | for node in nodes: 229 | node.select = False 230 | 231 | bake_color_tex_node.select = True 232 | nodes.active = bake_color_tex_node 233 | 234 | # Perform bake Color 235 | bpy.ops.object.bake(**bake_params, uv_layer=uv_layer, use_clear=True) 236 | 237 | for node in nodes: 238 | node.select = False 239 | 240 | bake_alpha_tex_node.select = True 241 | nodes.active = bake_alpha_tex_node 242 | 243 | node_organizer.create_link( 244 | target_node.name, bake_node.name, alpha_socket_name, 'Color') 245 | 246 | # Perform bake Alpha 247 | bpy.ops.object.bake(**bake_params, uv_layer=uv_layer, use_clear=True) 248 | 249 | nodes.remove(bake_alpha_tex_node) 250 | 251 | # Apply the red channel of image_alpha to the alpha channel of image 252 | if image and image_alpha: 253 | # Get pixel data 254 | width, height = image.size 255 | image_pixels = image.pixels[:] 256 | alpha_pixels = image_alpha.pixels[:] 257 | 258 | # Create a new pixel array 259 | new_pixels = list(image_pixels) 260 | 261 | # Apply red channel of alpha image to alpha channel of main image 262 | for i in range(0, len(image_pixels), 4): 263 | # Get the red channel value from alpha image (every 4 values) 264 | alpha_value = alpha_pixels[i] # Red channel 265 | # Set the alpha channel of the main image (index + 3) 266 | new_pixels[i + 3] = alpha_value 267 | 268 | # Update the image with the new pixel data 269 | image.pixels = new_pixels 270 | image.update() 271 | 272 | # Clean up the alpha image as we don't need it anymore 273 | bpy.data.images.remove(image_alpha) 274 | 275 | # Pack the image 276 | if not image.packed_file: 277 | image.pack() 278 | image.reload() 279 | print(f"Image {image.name} packed.") 280 | 281 | # Delete temporary bake node 282 | if bake_node: 283 | nodes.remove(bake_node) 284 | rollback_cycles_settings(cycles_settings) 285 | 286 | # Restore original links 287 | links.new(material_output.inputs[0], last_node_socket) 288 | context.scene.render.engine = original_engine 289 | 290 | # Debug 291 | # print(f"Backed {target_node.name} to {image.name}") 292 | 293 | return bake_color_tex_node 294 | 295 | # except Exception as e: 296 | # print(f"Baking failed: {str(e)}") 297 | # return None 298 | 299 | 300 | # ...existing code... 301 | 302 | 303 | @dataclass 304 | class BakingStep: 305 | node: Node 306 | output_socket_name: str 307 | alpha_socket_name: str 308 | image: Image 309 | uv_layer: str 310 | 311 | 312 | class PAINTSYSTEM_OT_BakeLayerIDToImageLayer(UVLayerHandler): 313 | bl_idname = "paint_system.bake_image_id_to_image_layer" 314 | bl_label = "Bake Active to Image Layer" 315 | bl_description = "Bake the active node to the selected image layer" 316 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 317 | 318 | image_resolution: EnumProperty( 319 | items=[ 320 | ('1024', "1024", "1024x1024"), 321 | ('2048', "2048", "2048x2048"), 322 | ('4096', "4096", "4096x4096"), 323 | ('8192', "8192", "8192x8192"), 324 | ], 325 | default='1024', 326 | ) 327 | use_gpu: BoolProperty( 328 | name="Use GPU", 329 | default=True 330 | ) 331 | layer_name: StringProperty( 332 | name="Layer Name", 333 | default="Bake" 334 | ) 335 | keep_original: BoolProperty( 336 | name="Keep Original", 337 | default=False 338 | ) 339 | layer_id: IntProperty( 340 | name="Layer ID", 341 | default=0 342 | ) 343 | 344 | def execute(self, context): 345 | ps = PaintSystem(context) 346 | active_group = ps.get_active_group() 347 | # active_layer = ps.get_active_layer() 348 | selected_layer = active_group.get_item_by_id(self.layer_id) 349 | is_folder = selected_layer.type == 'FOLDER' 350 | layer_id = self.layer_id 351 | if not selected_layer: 352 | self.report({'ERROR'}, "No active layer found.") 353 | return {'CANCELLED'} 354 | if selected_layer.clip: 355 | self.report({'ERROR'}, "Cannot merge clipped layer.") 356 | return {'CANCELLED'} 357 | disabled_layer_ids = [] 358 | for layer in active_group.items: 359 | if layer != selected_layer and layer.parent_id != layer_id and layer.enabled: 360 | layer.enabled = False 361 | disabled_layer_ids.append(layer.id) 362 | 363 | self.set_uv_mode(context) 364 | bakeable, error_message, nodes = is_bakeable(context) 365 | if not bakeable: 366 | self.report({'ERROR'}, error_message) 367 | 368 | bpy.ops.paint_system.merge_group( 369 | image_resolution=self.image_resolution, 370 | uv_map_mode=self.uv_map_mode, 371 | uv_map_name=self.uv_map_name, 372 | use_gpu=self.use_gpu, 373 | as_new_layer=True, 374 | new_layer_name=f"{selected_layer.name} Bake" 375 | ) 376 | if is_folder: 377 | bpy.ops.paint_system.move_up(action="MOVE_OUT") 378 | if not self.keep_original: 379 | ps.delete_item_id(layer_id) 380 | for layer_id in disabled_layer_ids: 381 | active_group.get_item_by_id(layer_id).enabled = True 382 | return {'FINISHED'} 383 | 384 | def invoke(self, context, event): 385 | ps = PaintSystem(context) 386 | active_layer = ps.get_active_layer() 387 | self.get_uv_mode(context) 388 | if active_layer: 389 | self.layer_name = f"{active_layer.name} Bake" 390 | return context.window_manager.invoke_props_dialog(self) 391 | 392 | def draw(self, context): 393 | layout = self.layout 394 | layout.prop(self, "layer_name") 395 | layout.prop(self, "use_gpu", text="Use GPU (Faster)") 396 | layout.prop(self, "image_resolution", expand=True) 397 | layout.prop(self, "keep_original") 398 | box = layout.box() 399 | self.select_uv_ui(box) 400 | 401 | 402 | class PAINTSYSTEM_OT_MergeGroup(UVLayerHandler): 403 | bl_idname = "paint_system.merge_group" 404 | bl_label = "Merge Group" 405 | bl_description = "Merge the selected group Layers" 406 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 407 | 408 | image_resolution: EnumProperty( 409 | items=[ 410 | ('1024', "1024", "1024x1024"), 411 | ('2048', "2048", "2048x2048"), 412 | ('4096', "4096", "4096x4096"), 413 | ('8192', "8192", "8192x8192"), 414 | ], 415 | default='1024', 416 | ) 417 | use_gpu: BoolProperty( 418 | name="Use GPU", 419 | default=True 420 | ) 421 | as_new_layer: BoolProperty( 422 | name="As New Layer", 423 | default=False 424 | ) 425 | new_layer_name: StringProperty( 426 | name="New Layer Name", 427 | ) 428 | 429 | @classmethod 430 | def poll(cls, context): 431 | bakeable, error_message, nodes = is_bakeable(context) 432 | return bakeable 433 | 434 | def execute(self, context): 435 | context.window.cursor_set('WAIT') 436 | ps = PaintSystem(context) 437 | self.set_uv_mode(context) 438 | print(f"UV Map: {self.uv_map_name}") 439 | obj = ps.active_object 440 | if obj.mode != 'OBJECT': 441 | bpy.ops.object.mode_set(mode='OBJECT') 442 | mat = ps.get_active_material() 443 | active_group = ps.get_active_group() 444 | if not mat: 445 | return {'CANCELLED'} 446 | 447 | active_group.use_bake_image = False 448 | active_group.update_node_tree() 449 | 450 | bakable, error, problem_nodes = is_bakeable(context) 451 | if not bakable: 452 | self.report({'ERROR'}, error) 453 | return {'CANCELLED'} 454 | 455 | connected_node = get_connected_nodes( 456 | get_active_material_output(mat.node_tree)) 457 | baking_steps: List[BakingStep] = [] 458 | image_resolution = int(self.image_resolution) 459 | for node, depth in connected_node: 460 | # TODO: Allow Baking inside groups 461 | if depth != 0: 462 | continue 463 | if node.bl_idname in REQUIRES_INTERMEDIATE_STEP: 464 | # Create a new image with appropriate settings 465 | image_name = f"{mat.name}_{node.name}" 466 | image = bpy.data.images.new( 467 | name=image_name, 468 | width=image_resolution, 469 | height=image_resolution, 470 | alpha=True, 471 | ) 472 | image.colorspace_settings.name = 'sRGB' 473 | 474 | if node.bl_idname == "ShaderNodeShaderToRGB": 475 | link = node.inputs[0].links[0] 476 | baking_step = BakingStep( 477 | link.from_node, link.from_socket.name, None, image, self.uv_map_name) 478 | baking_steps.append(baking_step) 479 | if node.bl_idname == "ShaderNodeGroup" and node.node_tree == active_group.node_tree: 480 | if self.as_new_layer: 481 | image = bpy.data.images.new( 482 | name=f"{active_group.name}_Merge", 483 | width=image_resolution, 484 | height=image_resolution, 485 | alpha=True, 486 | ) 487 | 488 | else: 489 | image = active_group.bake_image 490 | if not image: 491 | # Create a new image with appropriate settings 492 | image_name = f"{active_group.name}_bake" 493 | image = bpy.data.images.new( 494 | name=image_name, 495 | width=image_resolution, 496 | height=image_resolution, 497 | alpha=True, 498 | ) 499 | image.colorspace_settings.name = 'Non-Color' 500 | active_group.bake_image = image 501 | baking_step = BakingStep( 502 | node, "Color", "Alpha", image, self.uv_map_name) 503 | baking_steps.append(baking_step) 504 | 505 | baking_steps.reverse() 506 | # (node, output_socket_name, alpha_socket_name, image, uv_layer) 507 | for idx, baking_step in enumerate(baking_steps): 508 | tex_node = bake_node( 509 | context, 510 | target_node=baking_step.node, 511 | image=image, 512 | uv_layer=baking_step.uv_layer, 513 | output_socket_name=baking_step.output_socket_name, 514 | alpha_socket_name=baking_step.alpha_socket_name, 515 | gpu=self.use_gpu 516 | ) 517 | # TODO: Handle nodes that require intermediate steps 518 | nodes = mat.node_tree.nodes 519 | nodes.remove(nodes.get(tex_node.name)) 520 | if not tex_node: 521 | self.report({'ERROR'}, f"Failed to bake {node.name}.") 522 | return {'CANCELLED'} 523 | 524 | if not self.as_new_layer: 525 | active_group.use_bake_image = True 526 | active_group.bake_uv_map = self.uv_map_name 527 | else: 528 | ps.create_image_layer( 529 | image.name if not self.new_layer_name else self.new_layer_name, image, self.uv_map_name) 530 | 531 | return {'FINISHED'} 532 | 533 | def invoke(self, context, event): 534 | self.get_uv_mode(context) 535 | return context.window_manager.invoke_props_dialog(self) 536 | 537 | def draw(self, context): 538 | layout = self.layout 539 | layout.prop(self, "use_gpu", text="Use GPU (Faster)") 540 | layout.prop(self, "image_resolution", expand=True) 541 | box = layout.box() 542 | self.select_uv_ui(box) 543 | 544 | 545 | class PAINTSYSTEM_OT_MergeAndExportGroup(UVLayerHandler): 546 | bl_idname = "paint_system.merge_and_export_group" 547 | bl_label = "Merge and Export Group" 548 | bl_description = "Merge the selected group Layers and export the baked image" 549 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 550 | 551 | image_resolution: EnumProperty( 552 | items=[ 553 | ('1024', "1024", "1024x1024"), 554 | ('2048', "2048", "2048x2048"), 555 | ('4096', "4096", "4096x4096"), 556 | ('8192', "8192", "8192x8192"), 557 | ], 558 | default='1024', 559 | ) 560 | use_gpu: BoolProperty( 561 | name="Use GPU", 562 | default=True 563 | ) 564 | 565 | def execute(self, context): 566 | ps = PaintSystem(context) 567 | self.set_uv_mode(context) 568 | bpy.ops.paint_system.merge_group( 569 | image_resolution=self.image_resolution, 570 | uv_map_name=self.uv_map_name, 571 | use_gpu=self.use_gpu, 572 | as_new_layer=False 573 | ) 574 | bpy.ops.paint_system.export_baked_image() 575 | return {'FINISHED'} 576 | 577 | def invoke(self, context, event): 578 | ps = PaintSystem(context) 579 | self.get_uv_mode(context) 580 | return context.window_manager.invoke_props_dialog(self) 581 | 582 | def draw(self, context): 583 | layout = self.layout 584 | layout.prop(self, "use_gpu", text="Use GPU (Faster)") 585 | layout.prop(self, "image_resolution", expand=True) 586 | box = layout.box() 587 | self.select_uv_ui(box) 588 | 589 | 590 | class PAINTSYSTEM_OT_DeleteBakedImage(Operator): 591 | bl_idname = "paint_system.delete_bake_image" 592 | bl_label = "Delete Baked Image" 593 | bl_description = "Delete the baked image" 594 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 595 | 596 | def execute(self, context): 597 | ps = PaintSystem(context) 598 | active_group = ps.get_active_group() 599 | if not active_group: 600 | return {'CANCELLED'} 601 | 602 | image = active_group.bake_image 603 | if not image: 604 | self.report({'ERROR'}, "No baked image found.") 605 | return {'CANCELLED'} 606 | 607 | bpy.data.images.remove(image) 608 | active_group.bake_image = None 609 | active_group.use_bake_image = False 610 | active_group.bake_uv_map = "" 611 | 612 | return {'FINISHED'} 613 | 614 | def invoke(self, context, event): 615 | return context.window_manager.invoke_props_dialog(self) 616 | 617 | def draw(self, context): 618 | layout = self.layout 619 | layout.label( 620 | text="Click OK to delete the baked image.") 621 | 622 | 623 | def split_area(context: Context, direction='VERTICAL', factor=0.5): 624 | current_area = context.area 625 | screen = context.screen 626 | areas_before = set(screen.areas) 627 | bpy.ops.screen.area_split(direction=direction, factor=factor) 628 | areas_after = set(screen.areas) 629 | new_area_set = areas_after - areas_before 630 | if not new_area_set: 631 | print("Failed to create a new area.") 632 | return 633 | new_area = new_area_set.pop() 634 | return new_area 635 | 636 | 637 | class PAINTSYSTEM_OT_ExportBakedImage(Operator): 638 | bl_idname = "paint_system.export_baked_image" 639 | bl_label = "Export Baked Image" 640 | bl_description = "Export the baked image" 641 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 642 | 643 | def execute(self, context): 644 | ps = PaintSystem(context) 645 | active_group = ps.get_active_group() 646 | if not active_group: 647 | return {'CANCELLED'} 648 | 649 | image = active_group.bake_image 650 | # if not image: 651 | # self.report({'ERROR'}, "No baked image found.") 652 | # return {'CANCELLED'} 653 | 654 | # image_editor_area = split_area( 655 | # context, direction='VERTICAL', factor=0.5) 656 | # image_editor_area.type = 'IMAGE_EDITOR' # Set the new area to be Image Editor 657 | # with context.temp_override(area=image_editor_area): 658 | # context.space_data.image = image 659 | # bpy.ops.image.save_as('INVOKE_DEFAULT', copy=True) 660 | # bpy.ops.screen.area_close('INVOKE_DEFAULT') 661 | with bpy.context.temp_override(**{'edit_image': bpy.data.images[image.name]}): 662 | bpy.ops.image.save_as('INVOKE_DEFAULT', copy=True) 663 | return {'FINISHED'} 664 | 665 | 666 | class PAINTSYSTEM_OT_FocusNode(Operator): 667 | bl_idname = "paint_system.focus_node" 668 | bl_label = "Focus Node" 669 | bl_description = "Focus on the selected node" 670 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 671 | 672 | node_name: StringProperty() 673 | 674 | def execute(self, context): 675 | ps = PaintSystem(context) 676 | mat = ps.get_active_material() 677 | nodes = mat.node_tree.nodes 678 | select_node = nodes.get(self.node_name) 679 | if not select_node: 680 | return {'CANCELLED'} 681 | # Check if Shader Editor is open 682 | ne_area = None 683 | for area in context.screen.areas: 684 | if area.type == 'NODE_EDITOR' and area.ui_type == 'ShaderNodeTree': 685 | ne_area = area 686 | break 687 | if not ne_area: 688 | ne_area = split_area(context, direction='VERTICAL', factor=0.6) 689 | ne_area.type = 'NODE_EDITOR' # Set the new area to be Shader Editor 690 | ne_area.ui_type = 'ShaderNodeTree' 691 | with context.temp_override(area=ne_area, region=ne_area.regions[3]): 692 | context.space_data.node_tree = mat.node_tree 693 | for node in nodes: 694 | node.select = False 695 | select_node.select = True 696 | nodes.active = select_node 697 | bpy.ops.node.view_selected('INVOKE_DEFAULT') 698 | 699 | return {'FINISHED'} 700 | 701 | 702 | classes = ( 703 | PAINTSYSTEM_OT_BakeLayerIDToImageLayer, 704 | PAINTSYSTEM_OT_MergeGroup, 705 | PAINTSYSTEM_OT_MergeAndExportGroup, 706 | PAINTSYSTEM_OT_DeleteBakedImage, 707 | PAINTSYSTEM_OT_ExportBakedImage, 708 | PAINTSYSTEM_OT_FocusNode, 709 | ) 710 | 711 | register, unregister = register_classes_factory(classes) 712 | -------------------------------------------------------------------------------- /operators_utils.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.props import ( 4 | BoolProperty, 5 | StringProperty, 6 | EnumProperty, 7 | IntProperty 8 | ) 9 | import gpu 10 | from bpy.types import Operator, Context 11 | from bpy.utils import register_classes_factory 12 | from .paint_system import PaintSystem, get_brushes_from_library, TEMPLATE_ENUM 13 | from mathutils import Vector 14 | from .common import redraw_panel, NodeOrganizer, get_active_material_output, get_unified_settings 15 | from typing import List 16 | from .common_layers import UVLayerHandler 17 | 18 | # bpy.types.Image.pack 19 | # ------------------------------------------------------------------- 20 | # Group Operators 21 | # ------------------------------------------------------------------- 22 | 23 | 24 | class PAINTSYSTEM_OT_SaveFileAndImages(Operator): 25 | """Save all images in the active group""" 26 | bl_idname = "paint_system.save_file_and_images" 27 | bl_label = "Save File and Images" 28 | bl_options = {'REGISTER', 'UNDO'} 29 | bl_description = "Save all images and the blend file" 30 | 31 | @classmethod 32 | def poll(cls, context): 33 | ps = PaintSystem(context) 34 | return ps.get_active_group() and ps.get_active_group().flatten_hierarchy() 35 | 36 | def execute(self, context): 37 | # ps = PaintSystem(context) 38 | # flattened = ps.get_active_group().flatten_hierarchy() 39 | # for item, _ in flattened: 40 | # if item.image: 41 | # item.image.pack() 42 | bpy.ops.wm.save_mainfile() 43 | return {'FINISHED'} 44 | 45 | 46 | class PAINTSYSTEM_OT_AddCameraPlane(Operator): 47 | bl_idname = "paint_system.add_camera_plane" 48 | bl_label = "Add Camera Plane" 49 | bl_options = {'REGISTER', 'UNDO'} 50 | bl_description = "Add a plane with a camera texture" 51 | 52 | align_up: EnumProperty( 53 | name="Align Up", 54 | items=[ 55 | ('NONE', "None", "No alignment"), 56 | ('X', "X", "Align up with X axis"), 57 | ('Y', "Y", "Align up with Y axis"), 58 | ('Z', "Z", "Align up with Z axis"), 59 | ], 60 | default='NONE' 61 | ) 62 | 63 | def execute(self, context): 64 | bpy.ops.mesh.primitive_plane_add('INVOKE_DEFAULT', align='VIEW') 65 | return {'FINISHED'} 66 | 67 | 68 | class PAINTSYSTEM_OT_TogglePaintMode(Operator): 69 | bl_idname = "paint_system.toggle_paint_mode" 70 | bl_label = "Toggle Paint Mode" 71 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 72 | bl_description = "Toggle between texture paint and object mode" 73 | 74 | def execute(self, context): 75 | ps = PaintSystem(context) 76 | active_group = ps.get_active_group() 77 | if not active_group: 78 | return {'CANCELLED'} 79 | 80 | bpy.ops.object.mode_set(mode='TEXTURE_PAINT', toggle=True) 81 | is_cycles = bpy.context.scene.render.engine == 'CYCLES' 82 | if bpy.context.object.mode == 'TEXTURE_PAINT': 83 | # Change shading mode 84 | if bpy.context.space_data.shading.type != ('RENDERED' if not is_cycles else 'MATERIAL'): 85 | bpy.context.space_data.shading.type = ('RENDERED' if not is_cycles else 'MATERIAL') 86 | 87 | # if ps.preferences.unified_brush_color: 88 | # bpy.context.scene.tool_settings.unified_paint_settings.use_unified_color = True 89 | # if ps.preferences.unified_brush_size: 90 | # bpy.context.scene.tool_settings.unified_paint_settings.use_unified_size = True 91 | 92 | return {'FINISHED'} 93 | 94 | 95 | class PAINTSYSTEM_OT_AddPresetBrushes(Operator): 96 | bl_idname = "paint_system.add_preset_brushes" 97 | bl_label = "Import Paint System Brushes" 98 | bl_options = {'REGISTER', 'UNDO'} 99 | bl_description = "Add preset brushes to the active group" 100 | 101 | def execute(self, context): 102 | ps = PaintSystem(context) 103 | active_group = ps.get_active_group() 104 | if not active_group: 105 | return {'CANCELLED'} 106 | 107 | get_brushes_from_library() 108 | 109 | return {'FINISHED'} 110 | 111 | 112 | def set_active_panel_category(category, area_type): 113 | areas = ( 114 | area for win in bpy.context.window_manager.windows for area in win.screen.areas if area.type == area_type) 115 | for a in areas: 116 | for r in a.regions: 117 | if r.type == 'UI': 118 | if r.width == 1: 119 | with bpy.context.temp_override(area=a): 120 | bpy.ops.wm.context_toggle( 121 | data_path='space_data.show_region_ui') 122 | try: 123 | if r.active_panel_category != category: 124 | r.active_panel_category = category 125 | a.tag_redraw() 126 | except NameError as e: 127 | raise e 128 | 129 | 130 | class PAINTSYSTEM_OT_SetActivePanel(Operator): 131 | bl_idname = "paint_system.set_active_panel" 132 | bl_label = "Set Active Panel" 133 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 134 | bl_description = "Set active panel" 135 | bl_options = {'INTERNAL'} 136 | 137 | category: StringProperty() 138 | 139 | area_type: StringProperty( 140 | default='VIEW_3D', 141 | ) 142 | 143 | def execute(self, context: Context): 144 | set_active_panel_category(self.category, self.area_type) 145 | return {'FINISHED'} 146 | 147 | 148 | class PAINTSYSTEM_OT_PaintModeSettings(Operator): 149 | bl_label = "Paint Mode Settings" 150 | bl_idname = "paint_system.paint_mode_menu" 151 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 152 | bl_description = "Paint mode settings" 153 | 154 | def draw(self, context): 155 | layout = self.layout 156 | row = layout.row() 157 | col = row.column() 158 | unified_settings = bpy.context.scene.tool_settings.unified_paint_settings 159 | col.prop(unified_settings, "use_unified_color", text="Unified Color") 160 | col.prop(unified_settings, "use_unified_size", text="Unified Size") 161 | 162 | def execute(self, context): 163 | return {'FINISHED'} 164 | 165 | def invoke(self, context, event): 166 | context.window_manager.invoke_popup(self, width=200) 167 | return {'FINISHED'} 168 | 169 | 170 | # def get_uv_maps_names(self, context: Context): 171 | # return [(uv_map.name, uv_map.name, "") for uv_map in context.object.data.uv_layers] 172 | 173 | # ------------------------------------------------------------------- 174 | # Template Material Creation 175 | # ------------------------------------------------------------------- 176 | class PAINTSYSTEM_OT_CreateTemplateSetup(UVLayerHandler): 177 | bl_idname = "paint_system.create_template_setup" 178 | bl_label = "Create Template Setup" 179 | bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} 180 | bl_description = "Create a template material setup for painting" 181 | 182 | template: EnumProperty( 183 | name="Template", 184 | items=TEMPLATE_ENUM, 185 | default='STANDARD' 186 | ) 187 | 188 | disable_popup: BoolProperty( 189 | name="Disable Popup", 190 | description="Disable popup", 191 | default=False 192 | ) 193 | 194 | use_alpha_blend: BoolProperty( 195 | name="Use Alpha Blend", 196 | description="Use alpha blend instead of alpha clip", 197 | default=False 198 | ) 199 | 200 | disable_show_backface: BoolProperty( 201 | name="Disable Show Backface", 202 | description="Disable Show Backface", 203 | default=True 204 | ) 205 | 206 | use_paintsystem_uv: BoolProperty( 207 | name="Use Paint System UV", 208 | description="Use Paint System UV", 209 | default=True 210 | ) 211 | 212 | @classmethod 213 | def poll(cls, context): 214 | ps = PaintSystem(context) 215 | return ps.get_active_group() 216 | 217 | def execute(self, context): 218 | ps = PaintSystem(context) 219 | active_group = ps.get_active_group() 220 | mat = ps.get_active_material() 221 | mat.use_nodes = True 222 | 223 | self.set_uv_mode(context) 224 | 225 | if self.template in ('STANDARD', 'NONE'): 226 | bpy.ops.paint_system.new_solid_color( 227 | 'INVOKE_DEFAULT', disable_popup=True) 228 | if self.template != "TRANSPARENT": 229 | bpy.ops.paint_system.new_image('INVOKE_DEFAULT', disable_popup=True, 230 | uv_map_mode=self.uv_map_mode, 231 | uv_map_name=self.uv_map_name) 232 | 233 | node_organizer = NodeOrganizer(mat) 234 | if self.template == 'NONE': 235 | node_group = node_organizer.create_node( 236 | 'ShaderNodeGroup', {'node_tree': active_group.node_tree}) 237 | node_organizer.move_nodes_to_end() 238 | return {'FINISHED'} 239 | 240 | if self.template == 'EXISTING': 241 | # Use existing connected node to surface 242 | material_output = get_active_material_output(mat.node_tree) 243 | link = material_output.inputs['Surface'].links[0] 244 | linked_node = link.from_node 245 | socket = link.from_socket 246 | is_shader = socket.type == 'SHADER' 247 | if is_shader: 248 | shader_to_rgb_node = node_organizer.create_node( 249 | 'ShaderNodeShaderToRGB', {'location': linked_node.location + Vector((200, 0))}) 250 | node_organizer.create_link( 251 | linked_node.name, shader_to_rgb_node.name, socket.name, 'Shader') 252 | linked_node = shader_to_rgb_node 253 | socket = shader_to_rgb_node.outputs['Color'] 254 | node_group = node_organizer.create_node( 255 | 'ShaderNodeGroup', {'node_tree': active_group.node_tree, 'location': linked_node.location + Vector((200, 0))}) 256 | node_group.inputs['Alpha'].default_value = 1 257 | node_organizer.create_link( 258 | linked_node.name, node_group.name, socket.name, 'Color') 259 | if is_shader: 260 | node_organizer.create_link( 261 | linked_node.name, node_group.name, linked_node.outputs['Alpha'].name, 'Alpha') 262 | emission_node = node_organizer.create_node( 263 | 'ShaderNodeEmission', {'location': node_group.location + Vector((200, -100))}) 264 | transparent_node = node_organizer.create_node( 265 | 'ShaderNodeBsdfTransparent', {'location': node_group.location + Vector((200, 100))}) 266 | shader_mix_node = node_organizer.create_node( 267 | 'ShaderNodeMixShader', {'location': node_group.location + Vector((400, 0))}) 268 | node_organizer.create_link( 269 | node_group.name, emission_node.name, 'Color', 'Color') 270 | node_organizer.create_link( 271 | node_group.name, shader_mix_node.name, 'Alpha', 0) 272 | node_organizer.create_link( 273 | transparent_node.name, shader_mix_node.name, 'BSDF', 1) 274 | node_organizer.create_link( 275 | emission_node.name, shader_mix_node.name, 'Emission', 2) 276 | node_organizer.create_link( 277 | shader_mix_node.name, material_output.name, 'Shader', 'Surface') 278 | material_output.location = shader_mix_node.location + \ 279 | Vector((200, 0)) 280 | 281 | return {'FINISHED'} 282 | 283 | if self.use_alpha_blend: 284 | mat.blend_method = 'BLEND' 285 | if self.disable_show_backface: 286 | mat.show_transparent_back = False 287 | mat.use_backface_culling = True 288 | 289 | if self.template in ['STANDARD', 'TRANSPARENT']: 290 | node_group = node_organizer.create_node( 291 | 'ShaderNodeGroup', {'location': Vector((-600, 0)), 'node_tree': active_group.node_tree}) 292 | emission_node = node_organizer.create_node( 293 | 'ShaderNodeEmission', {'location': Vector((-400, -100))}) 294 | transparent_node = node_organizer.create_node( 295 | 'ShaderNodeBsdfTransparent', {'location': Vector((-400, 100))}) 296 | shader_mix_node = node_organizer.create_node( 297 | 'ShaderNodeMixShader', {'location': Vector((-200, 0))}) 298 | output_node = node_organizer.create_node( 299 | 'ShaderNodeOutputMaterial', {'location': Vector((0, 0)), 'is_active_output': True}) 300 | node_organizer.create_link( 301 | node_group.name, emission_node.name, 'Color', 'Color') 302 | node_organizer.create_link( 303 | node_group.name, shader_mix_node.name, 'Alpha', 0) 304 | node_organizer.create_link( 305 | transparent_node.name, shader_mix_node.name, 'BSDF', 1) 306 | node_organizer.create_link( 307 | emission_node.name, shader_mix_node.name, 'Emission', 2) 308 | node_organizer.create_link( 309 | shader_mix_node.name, output_node.name, 'Shader', 'Surface') 310 | 311 | elif self.template == 'NORMAL': 312 | tex_coord_node = node_organizer.create_node( 313 | 'ShaderNodeTexCoord', {'location': Vector((-1000, 0))}) 314 | vector_math_node1 = node_organizer.create_node( 315 | 'ShaderNodeVectorMath', {'location': Vector((-800, 0)), 316 | 'operation': 'MULTIPLY_ADD', 317 | 'inputs[1].default_value': (0.5, 0.5, 0.5), 318 | 'inputs[2].default_value': (0.5, 0.5, 0.5)}) 319 | node_group = node_organizer.create_node( 320 | 'ShaderNodeGroup', {'location': Vector((-600, 0)), 321 | 'node_tree': active_group.node_tree, 322 | 'inputs["Alpha"].default_value': 1}) 323 | frame = node_organizer.create_node( 324 | 'NodeFrame', {'label': "Plug this when you are done painting"}) 325 | vector_math_node2 = node_organizer.create_node( 326 | 'ShaderNodeVectorMath', {'location': Vector((-400, -200)), 327 | 'parent': frame, 328 | 'operation': 'MULTIPLY_ADD', 329 | 'inputs[1].default_value': (2, 2, 2), 330 | 'inputs[2].default_value': (-1, -1, -1)}) 331 | vector_transform_node = node_organizer.create_node( 332 | 'ShaderNodeVectorTransform', {'location': Vector((-200, -200)), 333 | 'parent': frame, 334 | 'vector_type': 'NORMAL', 335 | 'convert_from': 'OBJECT', 336 | 'convert_to': 'WORLD'}) 337 | output_node = node_organizer.create_node( 338 | 'ShaderNodeOutputMaterial', {'location': Vector((0, 0)), 'is_active_output': True}) 339 | node_organizer.create_link( 340 | tex_coord_node.name, vector_math_node1.name, 'Normal', 'Vector') 341 | node_organizer.create_link( 342 | vector_math_node1.name, node_group.name, 'Vector', 'Color') 343 | node_organizer.create_link( 344 | node_group.name, output_node.name, 'Color', 'Surface') 345 | node_organizer.create_link( 346 | vector_math_node2.name, vector_transform_node.name, 'Vector', 'Vector') 347 | 348 | node_organizer.move_nodes_to_end() 349 | 350 | # match self.template: 351 | # case 'NONE': 352 | # node_group = nodes.new('ShaderNodeGroup') 353 | # node_group.node_tree = active_group.node_tree 354 | # node_group.location = position + Vector((200, 0)) 355 | 356 | # case 'COLOR': 357 | # node_group = nodes.new('ShaderNodeGroup') 358 | # node_group.node_tree = active_group.node_tree 359 | # node_group.location = position + Vector((200, 0)) 360 | # vector_scale_node = nodes.new('ShaderNodeVectorMath') 361 | # vector_scale_node.operation = 'SCALE' 362 | # vector_scale_node.location = position + Vector((400, 0)) 363 | # output_node = nodes.new('ShaderNodeOutputMaterial') 364 | # output_node.location = position + Vector((600, 0)) 365 | # output_node.is_active_output = True 366 | # links.new( 367 | # vector_scale_node.inputs['Vector'], node_group.outputs['Color']) 368 | # links.new( 369 | # vector_scale_node.inputs['Scale'], node_group.outputs['Alpha']) 370 | # links.new(output_node.inputs['Surface'], 371 | # vector_scale_node.outputs['Vector']) 372 | 373 | # case 'STANDARD': 374 | # node_group = nodes.new('ShaderNodeGroup') 375 | # node_group.node_tree = active_group.node_tree 376 | # node_group.location = position + Vector((200, 0)) 377 | # emission_node = nodes.new('ShaderNodeEmission') 378 | # emission_node.location = position + Vector((400, -100)) 379 | # transparent_node = nodes.new('ShaderNodeBsdfTransparent') 380 | # transparent_node.location = position + Vector((400, 100)) 381 | # shader_mix_node = nodes.new('ShaderNodeMixShader') 382 | # shader_mix_node.location = position + Vector((600, 0)) 383 | # output_node = nodes.new('ShaderNodeOutputMaterial') 384 | # output_node.location = position + Vector((800, 0)) 385 | # output_node.is_active_output = True 386 | # links.new( 387 | # emission_node.inputs['Color'], node_group.outputs['Color']) 388 | # links.new(shader_mix_node.inputs[0], 389 | # node_group.outputs['Alpha']) 390 | # links.new(shader_mix_node.inputs[1], 391 | # transparent_node.outputs['BSDF']) 392 | # links.new(shader_mix_node.inputs[2], 393 | # emission_node.outputs['Emission']) 394 | # links.new(output_node.inputs['Surface'], 395 | # shader_mix_node.outputs['Shader']) 396 | # if self.use_alpha_blend: 397 | # mat.blend_method = 'BLEND' 398 | # if self.disable_show_backface: 399 | # mat.show_transparent_back = False 400 | 401 | # case 'TRANSPARENT': 402 | 403 | return {'FINISHED'} 404 | 405 | def invoke(self, context, event): 406 | if self.disable_popup: 407 | return self.execute(context) 408 | self.get_uv_mode(context) 409 | return context.window_manager.invoke_props_dialog(self) 410 | 411 | def draw(self, context): 412 | layout = self.layout 413 | layout.prop(self, "template") 414 | if self.template == 'COLORALPHA': 415 | layout.prop(self, "use_alpha_blend") 416 | layout.prop(self, "disable_show_backface") 417 | 418 | # ------------------------------------------------------------------- 419 | # Image Sampler 420 | # ------------------------------------------------------------------- 421 | 422 | 423 | class PAINTSYSTEM_OT_ColorSampler(Operator): 424 | """Sample the color under the mouse cursor""" 425 | bl_idname = "paint_system.color_sampler" 426 | bl_label = "Color Sampler" 427 | 428 | x: IntProperty() 429 | y: IntProperty() 430 | 431 | def execute(self, context): 432 | # Get the screen dimensions 433 | x, y = self.x, self.y 434 | 435 | buffer = gpu.state.active_framebuffer_get() 436 | pixel = buffer.read_color(x, y, 1, 1, 3, 0, 'FLOAT') 437 | pixel.dimensions = 1 * 1 * 3 438 | pix_value = [float(item) for item in pixel] 439 | 440 | tool_settings = bpy.context.scene.tool_settings 441 | unified_settings = tool_settings.unified_paint_settings 442 | brush_settings = tool_settings.image_paint.brush 443 | unified_settings.color = pix_value 444 | brush_settings.color = pix_value 445 | 446 | return {'FINISHED'} 447 | 448 | @classmethod 449 | def poll(cls, context): 450 | ps = PaintSystem(context) 451 | return context.area.type == 'VIEW_3D' and ps.active_object.mode == 'TEXTURE_PAINT' 452 | 453 | def invoke(self, context, event): 454 | self.x = event.mouse_x 455 | self.y = event.mouse_y 456 | return self.execute(context) 457 | 458 | 459 | class PAINTSYSTEM_OT_ToggleBrushEraseAlpha(Operator): 460 | bl_idname = "paint_system.toggle_brush_erase_alpha" 461 | bl_label = "Toggle Brush Erase Alpha" 462 | bl_options = {'REGISTER', 'UNDO'} 463 | bl_description = "Toggle between brush and erase alpha" 464 | 465 | def execute(self, context): 466 | tool_settings = context.tool_settings 467 | paint = tool_settings.image_paint 468 | 469 | if paint is not None: 470 | brush = paint.brush 471 | if brush is not None: 472 | if brush.blend == 'ERASE_ALPHA': 473 | brush.blend = 'MIX' # Switch back to normal blending 474 | else: 475 | brush.blend = 'ERASE_ALPHA' # Switch to Erase Alpha mode 476 | return {'FINISHED'} 477 | 478 | 479 | class PAINTSYSTEM_OT_ToggleMaskErase(Operator): 480 | bl_idname = "paint_system.toggle_mask_erase" 481 | bl_label = "Toggle Brush Erase Alpha" 482 | bl_options = {'REGISTER', 'UNDO'} 483 | bl_description = "Toggle between brush and erase alpha" 484 | 485 | def execute(self, context): 486 | prop_owner = get_unified_settings(context, unified_name='use_unified_color') 487 | # Alternate between Black and White 488 | if prop_owner.color[0] == 1.0 and prop_owner.color[1] == 1.0 and prop_owner.color[2] == 1.0: 489 | prop_owner.color = (0.0, 0.0, 0.0) 490 | else: 491 | prop_owner.color = (1.0, 1.0, 1.0) 492 | return {'FINISHED'} 493 | 494 | # ------------------------------------------------------------------- 495 | # For changing preferences 496 | # ------------------------------------------------------------------- 497 | 498 | 499 | class PAINTSYSTEM_OT_DisableTooltips(Operator): 500 | bl_idname = "paint_system.disable_tool_tips" 501 | bl_label = "Disable Tool Tips" 502 | bl_options = {'REGISTER', 'UNDO'} 503 | bl_description = "Disable Tool Tips" 504 | 505 | def execute(self, context): 506 | ps = PaintSystem(context) 507 | preferences = ps.preferences 508 | preferences.show_tooltips = False 509 | 510 | # Force the UI to update 511 | redraw_panel(self, context) 512 | 513 | return {'FINISHED'} 514 | 515 | def invoke(self, context, event): 516 | return context.window_manager.invoke_props_dialog(self) 517 | 518 | def draw(self, context): 519 | layout = self.layout 520 | layout.label(text="Disable Tool Tips?") 521 | layout.label(text="You can enable them again in the preferences") 522 | 523 | # ------------------------------------------------------------------- 524 | # Mesh 525 | # ------------------------------------------------------------------- 526 | 527 | class PAINTSYSTEM_OT_FlipNormals(Operator): 528 | """Flip normals of the selected mesh""" 529 | bl_idname = "paint_system.flip_normals" 530 | bl_label = "Flip Normals" 531 | bl_options = {'REGISTER', 'UNDO'} 532 | bl_description = "Flip normals of the selected mesh" 533 | 534 | @classmethod 535 | def poll(cls, context): 536 | return context.object and context.object.type == 'MESH' 537 | 538 | def execute(self, context): 539 | obj = context.object 540 | orig_mode = str(obj.mode) 541 | if obj.type == 'MESH': 542 | bpy.ops.object.mode_set(mode='EDIT') 543 | bpy.ops.mesh.select_all(action='SELECT') 544 | bpy.ops.mesh.flip_normals() 545 | bpy.ops.object.mode_set(mode=orig_mode) 546 | return {'FINISHED'} 547 | 548 | class PAINTSYSTEM_OT_RecalculateNormals(Operator): 549 | """Recalculate normals of the selected mesh""" 550 | bl_idname = "paint_system.recalculate_normals" 551 | bl_label = "Recalculate Normals" 552 | bl_options = {'REGISTER', 'UNDO'} 553 | bl_description = "Recalculate normals of the selected mesh" 554 | 555 | @classmethod 556 | def poll(cls, context): 557 | return context.object and context.object.type == 'MESH' 558 | 559 | def execute(self, context): 560 | obj = context.object 561 | orig_mode = str(obj.mode) 562 | if obj.type == 'MESH': 563 | bpy.ops.object.mode_set(mode='EDIT') 564 | bpy.ops.mesh.select_all(action='SELECT') 565 | bpy.ops.mesh.normals_make_consistent(inside=False) 566 | bpy.ops.object.mode_set(mode=orig_mode) 567 | return {'FINISHED'} 568 | 569 | 570 | class PAINTSYSTEM_OT_SelectMaterialIndex(Operator): 571 | """Select the item in the UI list""" 572 | bl_idname = "paint_system.select_material_index" 573 | bl_label = "Select Material Index" 574 | bl_options = {'REGISTER', 'UNDO'} 575 | bl_description = "Select the material index in the UI list" 576 | 577 | index: IntProperty() 578 | 579 | def execute(self, context): 580 | ps = PaintSystem(context) 581 | ob = ps.active_object 582 | if not ob: 583 | return {'CANCELLED'} 584 | if ob.type != 'MESH': 585 | return {'CANCELLED'} 586 | ob.active_material_index = self.index 587 | return {'FINISHED'} 588 | 589 | # ------------------------------------------------------------------- 590 | # For testing 591 | # ------------------------------------------------------------------- 592 | 593 | 594 | # class PAINTSYSTEM_OT_Test(Operator): 595 | # """Test importing node groups from library""" 596 | # bl_idname = "paint_system.test" 597 | # bl_label = "Test" 598 | 599 | # node_name: StringProperty() 600 | 601 | # def execute(self, context): 602 | # return {'FINISHED'} 603 | 604 | # def invoke(self, context, event): 605 | # return context.window_manager.invoke_props_dialog(self) 606 | 607 | # def draw(self, context): 608 | # layout = self.layout 609 | # layout.prop(self, "node_name") 610 | 611 | 612 | classes = ( 613 | PAINTSYSTEM_OT_SaveFileAndImages, 614 | PAINTSYSTEM_OT_AddCameraPlane, 615 | PAINTSYSTEM_OT_TogglePaintMode, 616 | PAINTSYSTEM_OT_AddPresetBrushes, 617 | PAINTSYSTEM_OT_SetActivePanel, 618 | PAINTSYSTEM_OT_PaintModeSettings, 619 | PAINTSYSTEM_OT_CreateTemplateSetup, 620 | PAINTSYSTEM_OT_ColorSampler, 621 | PAINTSYSTEM_OT_ToggleBrushEraseAlpha, 622 | PAINTSYSTEM_OT_ToggleMaskErase, 623 | PAINTSYSTEM_OT_DisableTooltips, 624 | PAINTSYSTEM_OT_FlipNormals, 625 | PAINTSYSTEM_OT_RecalculateNormals, 626 | PAINTSYSTEM_OT_SelectMaterialIndex, 627 | # PAINTSYSTEM_OT_Test, 628 | ) 629 | 630 | _register, _unregister = register_classes_factory(classes) 631 | 632 | addon_keymaps = [] 633 | 634 | 635 | def register(): 636 | _register() 637 | # Add the hotkey 638 | wm = bpy.context.window_manager 639 | kc = wm.keyconfigs.addon 640 | if kc: 641 | km = kc.keymaps.new(name="3D View", space_type='VIEW_3D') 642 | kmi = km.keymap_items.new( 643 | PAINTSYSTEM_OT_ColorSampler.bl_idname, 'I', 'PRESS', repeat=True) 644 | kmi = km.keymap_items.new( 645 | PAINTSYSTEM_OT_ToggleBrushEraseAlpha.bl_idname, type='E', value='PRESS') 646 | addon_keymaps.append((km, kmi)) 647 | 648 | 649 | def unregister(): 650 | for km, kmi in addon_keymaps: 651 | km.keymap_items.remove(kmi) 652 | addon_keymaps.clear() 653 | _unregister() -------------------------------------------------------------------------------- /paint_system.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Context, Material, Image, NodeTree, Node, PropertyGroup 3 | from typing import Optional, Tuple, LiteralString 4 | from dataclasses import dataclass 5 | from mathutils import Vector 6 | import os 7 | 8 | 9 | LIBRARY_FILE_NAME = "library.blend" 10 | NODE_GROUP_PREFIX = "_PS" 11 | BRUSH_PREFIX = "PS_" 12 | ADJUSTMENT_ENUM = [ 13 | ('ShaderNodeBrightContrast', "Brightness and Contrast", ""), 14 | ('ShaderNodeGamma', "Gamma", ""), 15 | ('ShaderNodeHueSaturation', "Hue Saturation Value", ""), 16 | ('ShaderNodeInvert', "Invert", ""), 17 | ('ShaderNodeRGBCurve', "RGB Curves", ""), 18 | # ('ShaderNodeAmbientOcclusion', "Ambient Occlusion", ""), 19 | ] 20 | LAYER_ENUM = [ 21 | ('FOLDER', "Folder", "Folder layer"), 22 | ('IMAGE', "Image", "Image layer"), 23 | ('SOLID_COLOR', "Solid Color", "Solid Color layer"), 24 | ('ATTRIBUTE', "Attribute", "Attribute layer"), 25 | ('ADJUSTMENT', "Adjustment", "Adjustment layer"), 26 | ('SHADER', "Shader", "Shader layer"), 27 | ('NODE_GROUP', "Node Group", "Node Group layer"), 28 | ('GRADIENT', "Gradient", "Gradient layer"), 29 | ] 30 | SHADER_ENUM = [ 31 | ('_PS_Toon_Shader', "Toon Shader (EEVEE)", "Toon Shader"), 32 | # ('_PS_Light', "Light (EEVEE)", "Light"), 33 | ('_PS_Ambient_Occlusion', "Ambient Occlusion", "Ambient Occlusion"), 34 | ] 35 | TEMPLATE_ENUM = [ 36 | ('STANDARD', "Standard", "Replace the existing material and start off with a basic setup", "IMAGE", 0), 37 | ('EXISTING', "Convert Existing Material", "Add to existing material setup", "FILE_REFRESH", 1), 38 | ('NORMAL', "Normals Painting", "Start off with a normal painting setup", "NORMALS_VERTEX_FACE", 2), 39 | ('TRANSPARENT', "Transparent", "Start off with a transparent setup" , "FILE", 3), 40 | ('NONE', "Manual", "Just add node group to material", "NONE", 4), 41 | ] 42 | GRADIENT_ENUM = [ 43 | ('LINEAR', "Linear Gradient", "Linear gradient"), 44 | ('RADIAL', "Radial Gradient", "Radial gradient"), 45 | ] 46 | 47 | 48 | def get_addon_filepath(): 49 | return os.path.dirname(bpy.path.abspath(__file__)) + os.sep 50 | 51 | 52 | def cleanup_duplicate_nodegroups(node_tree: NodeTree): 53 | """ 54 | Cleanup duplicate node groups by using Blender's remap_users feature. 55 | This automatically handles all node links and nested node groups. 56 | 57 | Args: 58 | node_group_name (str): Name of the main node group to clean up 59 | """ 60 | def find_original_nodegroup(name): 61 | # Get the base name by removing the .001, .002 etc. if present 62 | # This gets the part before the first dot 63 | base_name = name.split('.')[0] 64 | 65 | # Find all matching node groups 66 | matching_groups = [ng for ng in bpy.data.node_groups 67 | if ng.name == base_name or ng.name.split('.')[0] == base_name] 68 | 69 | if not matching_groups: 70 | return None 71 | 72 | # The original is likely the one without a number suffix 73 | # or the one with the lowest number if all have suffixes 74 | for ng in matching_groups: 75 | if ng.name == base_name: # Exact match without any suffix 76 | return ng 77 | 78 | # If we didn't find an exact match, return the one with lowest suffix number 79 | return sorted(matching_groups, key=lambda x: x.name)[0] 80 | 81 | # Process each node group 82 | for node in node_tree.nodes: 83 | if node.type == 'GROUP' and node.node_tree: 84 | ng = node.node_tree 85 | 86 | # Find the original node group 87 | original_group = find_original_nodegroup(ng.name) 88 | 89 | # If this is a duplicate (not the original) and we found the original 90 | if original_group and ng != original_group: 91 | # Remap all users of this node group to the original 92 | ng.user_remap(original_group) 93 | # Remove the now-unused node group 94 | bpy.data.node_groups.remove(ng) 95 | 96 | 97 | def get_nodetree_from_library(tree_name, force_reload=False): 98 | # Check if the node group already exists 99 | nt = bpy.data.node_groups.get(tree_name) 100 | if nt: 101 | if force_reload: 102 | bpy.data.node_groups.remove(nt) 103 | else: 104 | return nt 105 | 106 | # Load the library file 107 | filepath = get_addon_filepath() + LIBRARY_FILE_NAME 108 | with bpy.data.libraries.load(filepath) as (lib_file, current_file): 109 | lib_node_group_names = lib_file.node_groups 110 | current_node_groups_names = current_file.node_groups 111 | for node_group_name in lib_node_group_names: 112 | if node_group_name == tree_name: 113 | current_node_groups_names.append(node_group_name) 114 | 115 | # Getting the node group 116 | nt = bpy.data.node_groups.get(tree_name) 117 | if not nt: 118 | return None 119 | cleanup_duplicate_nodegroups(nt) 120 | return nt 121 | 122 | 123 | def get_brushes_from_library(): 124 | # Load the library file 125 | filepath = get_addon_filepath() + LIBRARY_FILE_NAME 126 | with bpy.data.libraries.load(filepath) as (lib_file, current_file): 127 | lib_brushes = lib_file.brushes 128 | current_brushes = current_file.brushes 129 | for brush in lib_brushes: 130 | if brush.startswith(BRUSH_PREFIX) and brush not in bpy.data.brushes: 131 | current_brushes.append(brush) 132 | 133 | # For blender 4.3 134 | if bpy.app.version >= (4, 3, 0): 135 | for brush in bpy.data.brushes: 136 | if brush.name.startswith(BRUSH_PREFIX): 137 | brush.asset_mark() 138 | 139 | 140 | def get_paint_system_groups(): 141 | groups = [] 142 | for mat in bpy.data.materials: 143 | if hasattr(mat, "paint_system"): 144 | ps = mat.paint_system 145 | for group in ps.groups: 146 | groups.append(group) 147 | return groups 148 | 149 | 150 | def get_paint_system_images(is_dirty_only=True): 151 | images = [] 152 | groups = get_paint_system_groups() 153 | for group in groups: 154 | bake_image = group.bake_image 155 | if bake_image and (bake_image.is_dirty or not is_dirty_only): 156 | images.append(image) 157 | for item in group.items: 158 | image = item.image 159 | if image and (image.is_dirty or not is_dirty_only): 160 | images.append(image) 161 | mask_image = item.mask_image 162 | if mask_image and (mask_image.is_dirty or not is_dirty_only): 163 | images.append(mask_image) 164 | return images 165 | 166 | 167 | # def update_paintsystem_data(self, context): 168 | # ps = PaintSystem(context) 169 | # active_group = ps.get_active_group() 170 | # mat = ps.get_active_material() 171 | # for layer in active_group.items: 172 | # if layer.node_tree: 173 | # layer.node_tree.name = f"PS_{layer.type} {layer.name} (MAT: {mat.name})" 174 | # if layer.image: 175 | # layer.image.name = f"PS {mat.name} {active_group.name} {layer.name}" 176 | 177 | 178 | @dataclass 179 | class PaintSystemPreferences: 180 | show_tooltips: bool 181 | use_compact_design: bool 182 | name_layers_group: bool 183 | 184 | 185 | class PaintSystem: 186 | def __init__(self, context: Context): 187 | self.preferences: PaintSystemPreferences = bpy.context.preferences.addons[ 188 | __package__].preferences 189 | self.settings = context.scene.paint_system_settings if hasattr( 190 | context, "scene") else None 191 | self.context = context 192 | self.active_object = context.object 193 | # self.settings = self.get_settings() 194 | # mat = self.get_active_material() 195 | # self.groups = self.get_groups() 196 | # active_group = self.get_active_group() 197 | # active_layer = self.get_active_layer() 198 | # layer_node_tree = self.get_active_layer().node_tree 199 | # self.layer_node_group = self.get_active_layer_node_group() 200 | # self.color_mix_node = self.find_color_mix_node() 201 | # self.uv_map_node = self.find_uv_map_node() 202 | # self.opacity_mix_node = self.find_opacity_mix_node() 203 | # self.clip_mix_node = self.find_clip_mix_node() 204 | # self.rgb_node = self.find_rgb_node() 205 | 206 | def add_group(self, name: str) -> PropertyGroup: 207 | """Creates a new group in the active material's paint system. 208 | 209 | Args: 210 | name (str): The name of the new group. 211 | 212 | Returns: 213 | PropertyGroup: The newly created group. 214 | """ 215 | mat = self.get_active_material() 216 | new_group = mat.paint_system.groups.add() 217 | new_group.name = name 218 | node_tree = bpy.data.node_groups.new( 219 | name=f"PS_GROUP {name} (MAT: {mat.name})", type='ShaderNodeTree') 220 | new_group.node_tree = node_tree 221 | new_group.update_node_tree() 222 | # Set the active group to the newly created one 223 | mat.paint_system.active_group = str( 224 | len(mat.paint_system.groups) - 1) 225 | 226 | return new_group 227 | 228 | def delete_active_group(self): 229 | """ 230 | Deletes the currently active group along with all its items and children. 231 | Returns: 232 | bool: True if the active group and its items were successfully deleted, False otherwise. 233 | """ 234 | use_node_tree = False 235 | mat = self.get_active_material() 236 | active_group = self.get_active_group() 237 | for node in mat.node_tree.nodes: 238 | if node.type == 'GROUP' and node.node_tree == active_group.node_tree: 239 | use_node_tree = True 240 | break 241 | 242 | # 2 users: 1 for the material node tree, 1 for the datablock 243 | if active_group.node_tree and active_group.node_tree.users <= 1 + use_node_tree: 244 | bpy.data.node_groups.remove(active_group.node_tree) 245 | 246 | for item, _ in active_group.flatten_hierarchy(): 247 | self._on_item_delete(item) 248 | 249 | active_group_idx = int(mat.paint_system.active_group) 250 | mat.paint_system.groups.remove(active_group_idx) 251 | 252 | if mat.paint_system.active_group: 253 | mat.paint_system.active_group = str( 254 | min(active_group_idx, len(mat.paint_system.groups) - 1)) 255 | 256 | return True 257 | 258 | def delete_active_item(self): 259 | """ 260 | Deletes the currently active item in the active group along with its children. 261 | Returns: 262 | bool: True if the active item and its children were successfully deleted, False otherwise. 263 | """ 264 | active_group = self.get_active_group() 265 | item_id = active_group.get_id_from_flattened_index( 266 | active_group.active_index) 267 | 268 | return self.delete_item_id(item_id) 269 | 270 | def delete_item(self, item: PropertyGroup): 271 | """Deletes the specified item and its children. 272 | 273 | Args: 274 | item (PropertyGroup): The item to be deleted. 275 | 276 | Returns: 277 | bool: True if the item was successfully deleted, False otherwise. 278 | """ 279 | active_group = self.get_active_group() 280 | item_id = active_group.get_id_from_flattened_index(item.id) 281 | 282 | return self.delete_item_id(item_id) 283 | 284 | def delete_item_id(self, item_id): 285 | active_group = self.get_active_group() 286 | active_layer = self.get_active_layer() 287 | item = active_group.get_item_by_id(item_id) 288 | order = int(active_layer.order) 289 | parent_id = int(active_layer.parent_id) 290 | # In case Item type is GRADIENT 291 | if item.type == 'GRADIENT': 292 | empty_object = None 293 | if item.node_tree: 294 | empty_object = item.node_tree.nodes["Texture Coordinate"].object 295 | if empty_object and empty_object.type == 'EMPTY': 296 | bpy.data.objects.remove(empty_object, do_unlink=True) 297 | 298 | if item_id != -1 and active_group.remove_item_and_children(item_id, self._on_item_delete): 299 | # Update active_index 300 | active_group.normalize_orders() 301 | flattened = active_group.flatten_hierarchy() 302 | for i, item in enumerate(active_group.items): 303 | if item.order == order and item.parent_id == parent_id: 304 | active_group.active_index = i 305 | break 306 | 307 | active_group.update_node_tree() 308 | 309 | return True 310 | return False 311 | 312 | def create_image_layer(self, name: str, image: Image, uv_map_name: str = None) -> PropertyGroup: 313 | """Creates a new image layer in the active group. 314 | 315 | Args: 316 | image (Image): The image to be used in the layer. 317 | uv_map_name (str, optional): The name of the UV map to be used. Defaults to None. 318 | 319 | Returns: 320 | PropertyGroup: The newly created image layer. 321 | """ 322 | # image.pack() 323 | 324 | active_group = self.get_active_group() 325 | 326 | # Get insertion position 327 | # parent_id, insert_order = active_group.get_insertion_data() 328 | # # Adjust existing items' order 329 | # active_group.adjust_sibling_orders(parent_id, insert_order) 330 | 331 | # mat = self.get_active_material() 332 | # layer_template = get_nodetree_from_library( 333 | # '_PS_Layer_Template', False) 334 | # layer_nt = layer_template.copy() 335 | # layer_nt.name = f"PS {name} (MAT: {mat.name})" 336 | 337 | new_layer = self._add_layer( 338 | name, f'_PS_Layer_Template', 'IMAGE', image=image, force_reload=False, make_copy=True) 339 | layer_nt = new_layer.node_tree 340 | 341 | # Find the image texture node 342 | image_texture_node = None 343 | for node in layer_nt.nodes: 344 | if node.type == 'TEX_IMAGE': 345 | image_texture_node = node 346 | break 347 | uv_map_node = None 348 | # Find UV Map node 349 | for node in layer_nt.nodes: 350 | if node.type == 'UVMAP': 351 | uv_map_node = node 352 | break 353 | # use uv_map_name or default to first uv map 354 | if uv_map_name: 355 | uv_map_node.uv_map = uv_map_name 356 | else: 357 | uv_map_node.uv_map = bpy.context.object.data.uv_layers[0].name 358 | image_texture_node.image = image 359 | # node_tree = self._create_layer_node_tree(name, image, uv_map_name) 360 | 361 | # Create the new item 362 | # new_id = active_group.add_item( 363 | # name=name, 364 | # item_type='IMAGE', 365 | # parent_id=parent_id, 366 | # order=insert_order, 367 | # image=image, 368 | # node_tree=node_tree, 369 | # ) 370 | 371 | # Update active index 372 | # if new_id != -1: 373 | # flattened = active_group.flatten_hierarchy() 374 | # for i, (item, _) in enumerate(flattened): 375 | # if item.id == new_id: 376 | # active_group.active_index = i 377 | # break 378 | 379 | active_group.update_node_tree() 380 | 381 | return new_layer 382 | 383 | def create_attribute_layer(self, name: str, attribute_name: str, attribute_type: str = "") -> PropertyGroup: 384 | """Creates a new attribute layer in the active group. 385 | 386 | Args: 387 | name (str): The name of the new attribute layer. 388 | attribute_name (str): The name of the attribute to be used. 389 | 390 | Returns: 391 | PropertyGroup: The newly created attribute layer. 392 | """ 393 | active_group = self.get_active_group() 394 | new_layer = self._add_layer( 395 | name, '_PS_Attribute_Template', 'ATTRIBUTE', make_copy=True) 396 | attr_node = new_layer.node_tree.nodes['Attribute'] 397 | attr_node.attribute_name = attribute_name 398 | active_group.update_node_tree() 399 | return new_layer 400 | 401 | def create_solid_color_layer(self, name: str, color: Tuple[float, float, float, float]) -> PropertyGroup: 402 | """Creates a new solid color layer in the active group. 403 | 404 | Args: 405 | color (Tuple[float, float, float, float]): The color to be used in the layer. 406 | 407 | Returns: 408 | PropertyGroup: The newly created solid color layer. 409 | """ 410 | # mat = self.get_active_material() 411 | active_group = self.get_active_group() 412 | # # Get insertion position 413 | # parent_id, insert_order = active_group.get_insertion_data() 414 | # # Adjust existing items' order 415 | # active_group.adjust_sibling_orders(parent_id, insert_order) 416 | 417 | # solid_color_template = get_nodetree_from_library( 418 | # '_PS_Solid_Color_Template', False) 419 | # solid_color_nt = solid_color_template.copy() 420 | # solid_color_nt.name = f"PS_IMG {name} (MAT: {mat.name})" 421 | new_layer = self._add_layer( 422 | name, f'_PS_Solid_Color_Template', 'SOLID_COLOR', make_copy=True) 423 | solid_color_nt = new_layer.node_tree 424 | solid_color_nt.nodes['RGB'].outputs[0].default_value = color 425 | 426 | # Create the new item 427 | # new_id = active_group.add_item( 428 | # name=name, 429 | # item_type='SOLID_COLOR', 430 | # parent_id=parent_id, 431 | # order=insert_order, 432 | # node_tree=solid_color_nt, 433 | # ) 434 | 435 | # Update active index 436 | # if new_id != -1: 437 | # flattened = active_group.flatten_hierarchy() 438 | # for i, (item, _) in enumerate(flattened): 439 | # if item.id == new_id: 440 | # active_group.active_index = i 441 | # break 442 | 443 | active_group.update_node_tree() 444 | 445 | return new_layer 446 | 447 | def create_folder(self, name: str) -> PropertyGroup: 448 | """Creates a new folder in the active group. 449 | 450 | Args: 451 | name (str): The name of the new folder. 452 | 453 | Returns: 454 | PropertyGroup: The newly created folder. 455 | """ 456 | # mat = self.get_active_material() 457 | active_group = self.get_active_group() 458 | # # Get insertion position 459 | # parent_id, insert_order = active_group.get_insertion_data() 460 | 461 | # # Adjust existing items' order 462 | # active_group.adjust_sibling_orders(parent_id, insert_order) 463 | 464 | # folder_template = get_nodetree_from_library( 465 | # '_PS_Folder_Template', False) 466 | # folder_nt = folder_template.copy() 467 | # folder_nt.name = f"PS_FLD {name} (MAT: {mat.name})" 468 | 469 | new_layer = self._add_layer( 470 | name, f'_PS_Folder_Template', 'FOLDER', make_copy=True) 471 | 472 | # Create the new item 473 | # new_id = active_group.add_item( 474 | # name=name, 475 | # item_type='FOLDER', 476 | # parent_id=parent_id, 477 | # order=insert_order, 478 | # node_tree=folder_nt 479 | # ) 480 | 481 | # Update active index 482 | # if new_id != -1: 483 | # flattened = active_group.flatten_hierarchy() 484 | # for i, (item, _) in enumerate(flattened): 485 | # if item.id == new_id: 486 | # active_group.active_index = i 487 | # break 488 | 489 | active_group.update_node_tree() 490 | 491 | return new_layer 492 | 493 | def create_adjustment_layer(self, name: str, adjustment_type: str) -> PropertyGroup: 494 | """Creates a new adjustment layer in the active group. 495 | 496 | Args: 497 | name (str): The name of the new adjustment layer. 498 | adjustment_type (str): The type of adjustment to be applied. 499 | 500 | Returns: 501 | PropertyGroup: The newly created adjustment layer. 502 | """ 503 | mat = self.get_active_material() 504 | active_group = self.get_active_group() 505 | # # Get insertion position 506 | # parent_id, insert_order = active_group.get_insertion_data() 507 | 508 | # # Adjust existing items' order 509 | # active_group.adjust_sibling_orders(parent_id, insert_order) 510 | 511 | # adjustment_template = get_nodetree_from_library( 512 | # f'_PS_Adjustment_Template', False) 513 | # adjustment_nt: NodeTree = adjustment_template.copy() 514 | # adjustment_nt.name = f"PS_ADJ {name} (MAT: {mat.name})" 515 | new_layer = self._add_layer( 516 | name, f'_PS_Adjustment_Template', 'ADJUSTMENT', make_copy=True) 517 | adjustment_nt = new_layer.node_tree 518 | nodes = adjustment_nt.nodes 519 | links = adjustment_nt.links 520 | # Find Vector Math node 521 | group_input_node = None 522 | for node in nodes: 523 | if node.type == 'GROUP_INPUT': 524 | group_input_node = node 525 | break 526 | 527 | # Find Mix node 528 | mix_node = None 529 | for node in nodes: 530 | if node.type == 'MIX' and node.data_type == 'RGBA': 531 | mix_node = node 532 | break 533 | 534 | adjustment_node = nodes.new(adjustment_type) 535 | adjustment_node.label = 'Adjustment' 536 | adjustment_node.location = mix_node.location + Vector([0, -200]) 537 | 538 | # Checks if the adjustment node has a factor input 539 | if 'Fac' in adjustment_node.inputs: 540 | # Create a value node 541 | value_node = nodes.new('ShaderNodeValue') 542 | value_node.label = 'Factor' 543 | value_node.outputs[0].default_value = 1.0 544 | value_node.location = adjustment_node.location + Vector([-200, 0]) 545 | links.new(value_node.outputs['Value'], 546 | adjustment_node.inputs['Fac']) 547 | 548 | links.new(adjustment_node.inputs['Color'], 549 | group_input_node.outputs['Color']) 550 | links.new(mix_node.inputs['B'], adjustment_node.outputs['Color']) 551 | 552 | # Create the new item 553 | # new_id = active_group.add_item( 554 | # name=name, 555 | # item_type='ADJUSTMENT', 556 | # parent_id=parent_id, 557 | # order=insert_order, 558 | # node_tree=adjustment_nt 559 | # ) 560 | 561 | # Update active index 562 | # if new_id != -1: 563 | # flattened = active_group.flatten_hierarchy() 564 | # for i, (item, _) in enumerate(flattened): 565 | # if item.id == new_id: 566 | # active_group.active_index = i 567 | # break 568 | 569 | active_group.update_node_tree() 570 | 571 | return new_layer 572 | 573 | def create_gradient_layer(self, name: str, gradient_type: str) -> PropertyGroup: 574 | """Creates a new gradient layer in the active group. 575 | 576 | Args: 577 | name (str): The name of the new gradient layer. 578 | 579 | Returns: 580 | PropertyGroup: The newly created gradient layer. 581 | """ 582 | obj = self.active_object 583 | active_group = self.get_active_group() 584 | view_layer = bpy.context.view_layer 585 | gradient_type = gradient_type.title() 586 | 587 | with bpy.context.temp_override(): 588 | if "Paint System Collection" not in view_layer.layer_collection.collection.children: 589 | collection = bpy.data.collections.new("Paint System Collection") 590 | view_layer.layer_collection.collection.children.link(collection) 591 | else: 592 | collection = view_layer.layer_collection.collection.children["Paint System Collection"] 593 | 594 | new_layer = self._add_layer( 595 | name, f'_PS_{gradient_type}_Gradient_Template', 'GRADIENT', make_copy=True) 596 | empty_object = bpy.data.objects.new(f"{active_group.name} {name}", None) 597 | collection.objects.link(empty_object) 598 | empty_object.location = obj.location 599 | if gradient_type == 'Linear': 600 | empty_object.empty_display_type = 'SINGLE_ARROW' 601 | elif gradient_type == 'Radial': 602 | empty_object.empty_display_type = 'SPHERE' 603 | empty_object.show_in_front = True 604 | empty_object.parent = obj 605 | new_layer.node_tree.nodes["Texture Coordinate"].object = empty_object 606 | # gradient_nt.nodes['Gradient'].label = name 607 | 608 | # Create the new item 609 | # new_id = active_group.add_item( 610 | # name=name, 611 | # item_type='GRADIENT', 612 | # parent_id=parent_id, 613 | # order=insert_order, 614 | # node_tree=gradient_nt 615 | # ) 616 | 617 | # Update active index 618 | # if new_id != -1: 619 | # flattened = active_group.flatten_hierarchy() 620 | # for i, (item, _) in enumerate(flattened): 621 | # if item.id == new_id: 622 | # active_group.active_index = i 623 | # break 624 | 625 | active_group.update_node_tree() 626 | 627 | return new_layer 628 | 629 | def create_shader_layer(self, name: str, shader_type: str) -> PropertyGroup: 630 | active_group = self.get_active_group() 631 | new_layer = self._add_layer( 632 | name, shader_type, 'SHADER', sub_type=shader_type, make_copy=True) 633 | active_group.update_node_tree() 634 | return new_layer 635 | 636 | def create_node_group_layer(self, name: str, node_tree_name: str) -> PropertyGroup: 637 | active_group = self.get_active_group() 638 | new_layer = self._add_layer( 639 | name, node_tree_name, 'NODE_GROUP') 640 | active_group.update_node_tree() 641 | return new_layer 642 | 643 | def get_active_material(self) -> Optional[Material]: 644 | if not self.active_object or self.active_object.type != 'MESH': 645 | return None 646 | 647 | return self.active_object.active_material 648 | 649 | def get_material_settings(self): 650 | mat = self.get_active_material() 651 | if not mat or not hasattr(mat, "paint_system"): 652 | return None 653 | return mat.paint_system 654 | 655 | def get_groups(self) -> Optional[PropertyGroup]: 656 | paint_system = self.get_material_settings() 657 | if not paint_system: 658 | return None 659 | return paint_system.groups 660 | 661 | def get_active_group(self) -> Optional[PropertyGroup]: 662 | paint_system = self.get_material_settings() 663 | if not paint_system or len(paint_system.groups) == 0: 664 | return None 665 | active_group_idx = int(paint_system.active_group) 666 | if active_group_idx >= len(paint_system.groups): 667 | return None # handle cases where active index is invalid 668 | return paint_system.groups[active_group_idx] 669 | 670 | def get_active_layer(self) -> Optional[PropertyGroup]: 671 | active_group = self.get_active_group() 672 | if not active_group or len(active_group.items) == 0 or active_group.active_index >= len(active_group.items): 673 | return None 674 | 675 | return active_group.items[active_group.active_index] 676 | 677 | def get_layer_node_tree(self) -> Optional[NodeTree]: 678 | active_layer = self.get_active_layer() 679 | if not active_layer: 680 | return None 681 | return active_layer.node_tree 682 | 683 | def get_active_layer_node_group(self) -> Optional[Node]: 684 | active_group = self.get_active_group() 685 | layer_node_tree = self.get_active_layer().node_tree 686 | if not layer_node_tree: 687 | return None 688 | node_details = {'type': 'GROUP', 'node_tree': layer_node_tree} 689 | node = self.find_node(active_group.node_tree, node_details) 690 | return node 691 | 692 | def find_color_mix_node(self) -> Optional[Node]: 693 | layer_node_tree = self.get_active_layer().node_tree 694 | node_details = {'type': 'MIX', 'data_type': 'RGBA'} 695 | return self.find_node(layer_node_tree, node_details) 696 | 697 | def find_uv_map_node(self) -> Optional[Node]: 698 | layer_node_tree = self.get_active_layer().node_tree 699 | node_details = {'type': 'UVMAP'} 700 | return self.find_node(layer_node_tree, node_details) 701 | 702 | def find_opacity_mix_node(self) -> Optional[Node]: 703 | layer_node_tree = self.get_active_layer().node_tree 704 | node_details = {'type': 'MIX', 'name': 'Opacity'} 705 | return self.find_node(layer_node_tree, node_details) or self.find_color_mix_node() 706 | 707 | def find_clip_mix_node(self) -> Optional[Node]: 708 | layer_node_tree = self.get_active_layer().node_tree 709 | node_details = {'type': 'MIX', 'name': 'Clip'} 710 | return self.find_node(layer_node_tree, node_details) 711 | 712 | def find_image_texture_node(self) -> Optional[Node]: 713 | layer_node_tree = self.get_active_layer().node_tree 714 | node_details = {'type': 'TEX_IMAGE'} 715 | return self.find_node(layer_node_tree, node_details) 716 | 717 | def find_rgb_node(self) -> Optional[Node]: 718 | layer_node_tree = self.get_active_layer().node_tree 719 | node_details = {'name': 'RGB'} 720 | return self.find_node(layer_node_tree, node_details) 721 | 722 | def find_adjustment_node(self) -> Optional[Node]: 723 | layer_node_tree = self.get_active_layer().node_tree 724 | node_details = {'label': 'Adjustment'} 725 | return self.find_node(layer_node_tree, node_details) 726 | 727 | def find_node_group(self, node_tree: NodeTree) -> Optional[Node]: 728 | node_tree = self.get_active_group().node_tree 729 | for node in node_tree.nodes: 730 | if hasattr(node, 'node_tree') and node.node_tree and node.node_tree.name == node_tree.name: 731 | return node 732 | return None 733 | 734 | def find_attribute_node(self) -> Optional[Node]: 735 | layer_node_tree = self.get_active_layer().node_tree 736 | node_details = {'type': 'ATTRIBUTE'} 737 | return self.find_node(layer_node_tree, node_details) 738 | 739 | def is_valid_ps_nodetree(self, node_tree: NodeTree): 740 | # check if the node tree has both Color and Alpha inputs and outputs 741 | has_color_input = False 742 | has_alpha_input = False 743 | has_color_output = False 744 | has_alpha_output = False 745 | for interface_item in node_tree.interface.items_tree: 746 | if interface_item.item_type == "SOCKET": 747 | # print(interface_item.name, interface_item.socket_type, interface_item.in_out) 748 | if interface_item.name == "Color" and interface_item.socket_type == "NodeSocketColor": 749 | if interface_item.in_out == "INPUT": 750 | has_color_input = True 751 | else: 752 | has_color_output = True 753 | elif interface_item.name == "Alpha" and interface_item.socket_type == "NodeSocketFloat": 754 | if interface_item.in_out == "INPUT": 755 | has_alpha_input = True 756 | else: 757 | has_alpha_output = True 758 | return has_color_input and has_alpha_input and has_color_output and has_alpha_output 759 | 760 | 761 | def _update_paintsystem_data(self): 762 | active_group = self.get_active_group() 763 | mat = self.get_active_material() 764 | # active_group.update_node_tree() 765 | if active_group.node_tree: 766 | active_group.node_tree.name = f"PS_GROUP {active_group.name} (MAT: {mat.name})" 767 | for layer in active_group.items: 768 | if not layer.type == 'NODE_GROUP': 769 | if layer.node_tree: 770 | layer.node_tree.name = f"PS_{layer.type} {active_group.name} {layer.name} (MAT: {mat.name})" 771 | if layer.image: 772 | layer.image.name = f"PS {active_group.name} {layer.name} (MAT: {mat.name})" 773 | 774 | def _add_layer(self, layer_name, tree_name: str, item_type: str, sub_type="", image=None, force_reload=False, make_copy=False) -> NodeTree: 775 | active_group = self.get_active_group() 776 | # Get insertion position 777 | parent_id, insert_order = active_group.get_insertion_data() 778 | # Adjust existing items' order 779 | active_group.adjust_sibling_orders(parent_id, insert_order) 780 | nt = get_nodetree_from_library( 781 | tree_name, force_reload) 782 | if make_copy: 783 | nt = nt.copy() 784 | # Create the new item 785 | new_id = active_group.add_item( 786 | name=layer_name, 787 | item_type=item_type, 788 | sub_type=sub_type, 789 | parent_id=parent_id, 790 | order=insert_order, 791 | node_tree=nt, 792 | image=image, 793 | ) 794 | 795 | # Update active index 796 | if new_id != -1: 797 | flattened = active_group.flatten_hierarchy() 798 | for i, item in enumerate(active_group.items): 799 | if item.id == new_id: 800 | active_group.active_index = i 801 | break 802 | self._update_paintsystem_data() 803 | return active_group.get_item_by_id(new_id) 804 | 805 | def _value_set(self, obj, path, value): 806 | if '.' in path: 807 | path_prop, path_attr = path.rsplit('.', 1) 808 | prop = obj.path_resolve(path_prop) 809 | else: 810 | prop = obj 811 | path_attr = path 812 | setattr(prop, path_attr, value) 813 | 814 | def find_node(self, node_tree, node_details): 815 | if not node_tree: 816 | return None 817 | for node in node_tree.nodes: 818 | match = True 819 | for key, value in node_details.items(): 820 | if getattr(node, key) != value: 821 | match = False 822 | break 823 | if match: 824 | return node 825 | return None 826 | 827 | def _create_folder_node_tree(self, folder_name: str, force_reload=False) -> NodeTree: 828 | mat = self.get_active_material() 829 | folder_template = get_nodetree_from_library( 830 | '_PS_Folder_Template', force_reload) 831 | folder_nt = folder_template.copy() 832 | folder_nt.name = f"PS {folder_name} (MAT: {mat.name})" 833 | return folder_nt 834 | 835 | def _create_layer_node_tree(self, layer_name: str, image: Image, uv_map_name: str = None, force_reload=True) -> NodeTree: 836 | mat = self.get_active_material() 837 | layer_template = get_nodetree_from_library( 838 | '_PS_Layer_Template', force_reload) 839 | layer_nt = layer_template.copy() 840 | layer_nt.name = f"PS {layer_name} (MAT: {mat.name})" 841 | # Find the image texture node 842 | image_texture_node = None 843 | for node in layer_nt.nodes: 844 | if node.type == 'TEX_IMAGE': 845 | image_texture_node = node 846 | break 847 | uv_map_node = None 848 | # Find UV Map node 849 | for node in layer_nt.nodes: 850 | if node.type == 'UVMAP': 851 | uv_map_node = node 852 | break 853 | # use uv_map_name or default to first uv map 854 | if uv_map_name: 855 | uv_map_node.uv_map = uv_map_name 856 | else: 857 | uv_map_node.uv_map = bpy.context.object.data.uv_layers[0].name 858 | image_texture_node.image = image 859 | return layer_nt 860 | 861 | def _on_item_delete(self, item): 862 | if item.node_tree: 863 | # 2 users: 1 for the node tree, 1 for the datablock 864 | if item.node_tree.users <= 2: 865 | # print("Removing node tree") 866 | bpy.data.node_groups.remove(item.node_tree) 867 | 868 | if item.image: 869 | # 2 users: 1 for the image datablock, 1 for the panel 870 | if item.image and item.image.users <= 2: 871 | # print("Removing image") 872 | bpy.data.images.remove(item.image) 873 | -------------------------------------------------------------------------------- /properties.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.props import (IntProperty, 4 | FloatVectorProperty, 5 | BoolProperty, 6 | StringProperty, 7 | PointerProperty, 8 | CollectionProperty, 9 | EnumProperty) 10 | from bpy.types import (PropertyGroup, Context, 11 | NodeTreeInterface, Nodes, Node, NodeTree, NodeLinks, NodeSocket, Image) 12 | from .nested_list_manager import BaseNestedListItem, BaseNestedListManager 13 | from mathutils import Vector 14 | from .paint_system import PaintSystem, get_nodetree_from_library, LAYER_ENUM, TEMPLATE_ENUM 15 | from dataclasses import dataclass, field 16 | from typing import Dict 17 | import time 18 | 19 | 20 | def get_all_group_names(self, context): 21 | ps = PaintSystem(context) 22 | mat = ps.active_object.active_material 23 | if not mat or not hasattr(mat, "paint_system"): 24 | return [] 25 | return [(str(i), group.name, f"Group {i}") for i, group in enumerate(mat.paint_system.groups)] 26 | 27 | 28 | def update_active_image(self=None, context: Context = None): 29 | context = context or bpy.context 30 | ps = PaintSystem(context) 31 | if not ps.settings.allow_image_overwrite: 32 | return 33 | image_paint = context.tool_settings.image_paint 34 | mat = ps.get_active_material() 35 | active_group = ps.get_active_group() 36 | if not mat or not active_group: 37 | return 38 | active_layer = ps.get_active_layer() 39 | update_brush_settings(self, context) 40 | if not active_layer: 41 | return 42 | 43 | if image_paint.mode == 'MATERIAL': 44 | image_paint.mode = 'IMAGE' 45 | selected_image: Image = active_layer.mask_image if active_layer.edit_mask else active_layer.image 46 | if not selected_image or active_layer.lock_layer or active_group.use_bake_image: 47 | image_paint.canvas = None 48 | # Unable to paint 49 | return 50 | else: 51 | # print("Selected image: ", selected_image) 52 | image_paint.canvas = selected_image 53 | uv_map_node = ps.find_uv_map_node() 54 | if uv_map_node: 55 | ps.active_object.data.uv_layers[uv_map_node.uv_map].active = True 56 | # for i, image in enumerate(mat.texture_paint_images): 57 | # if not selected_image or active_layer.lock_layer or active_group.use_bake_image: 58 | # image_paint.canvas = None 59 | # # Unable to paint 60 | # return 61 | # if image == selected_image: 62 | # image_paint.canvas = selected_image 63 | # # Get uv map name 64 | # uv_map_node = ps.find_uv_map_node() 65 | # if uv_map_node: 66 | # ps.active_object.data.uv_layers[uv_map_node.uv_map].active = True 67 | # break 68 | 69 | 70 | def update_brush_settings(self=None, context: Context = bpy.context): 71 | if context.mode != 'PAINT_TEXTURE': 72 | return 73 | ps = PaintSystem(context) 74 | active_layer = ps.get_active_layer() 75 | brush = context.tool_settings.image_paint.brush 76 | if not brush: 77 | return 78 | brush.use_alpha = not active_layer.lock_alpha 79 | 80 | 81 | def update_paintsystem_data(self, context): 82 | ps = PaintSystem(context) 83 | ps._update_paintsystem_data() 84 | 85 | 86 | class PaintSystemLayer(BaseNestedListItem): 87 | 88 | def update_node_tree(self, context): 89 | PaintSystem(context).get_active_group().update_node_tree() 90 | 91 | def select_layer(self, context): 92 | ps = PaintSystem(context) 93 | active_group = ps.get_active_group() 94 | if not active_group: 95 | return 96 | active_group.active_index = active_group.items.values().index(self) 97 | 98 | name: StringProperty( 99 | name="Name", 100 | description="Layer name", 101 | default="Layer", 102 | update=update_paintsystem_data 103 | ) 104 | enabled: BoolProperty( 105 | name="Enabled", 106 | description="Toggle layer visibility", 107 | default=True, 108 | update=update_node_tree 109 | ) 110 | image: PointerProperty( 111 | name="Image", 112 | type=Image 113 | ) 114 | type: EnumProperty( 115 | items=LAYER_ENUM, 116 | default='IMAGE' 117 | ) 118 | sub_type: StringProperty( 119 | name="Sub Type", 120 | default="", 121 | ) 122 | clip: BoolProperty( 123 | name="Clip to Below", 124 | description="Clip the layer to the one below", 125 | default=False, 126 | update=update_node_tree 127 | ) 128 | lock_alpha: BoolProperty( 129 | name="Lock Alpha", 130 | description="Lock the alpha channel", 131 | default=False, 132 | update=update_brush_settings 133 | ) 134 | lock_layer: BoolProperty( 135 | name="Lock Layer", 136 | description="Lock the layer", 137 | default=False, 138 | update=update_active_image 139 | ) 140 | node_tree: PointerProperty( 141 | name="Node Tree", 142 | type=NodeTree 143 | ) 144 | edit_mask: BoolProperty( 145 | name="Edit Mask", 146 | description="Edit mask", 147 | default=False, 148 | ) 149 | mask_image: PointerProperty( 150 | name="Mask Image", 151 | type=Image, 152 | update=update_node_tree 153 | ) 154 | enable_mask: BoolProperty( 155 | name="Enabled Mask", 156 | description="Toggle mask visibility", 157 | default=False, 158 | update=update_node_tree 159 | ) 160 | mask_uv_map: StringProperty( 161 | name="Mask UV Map", 162 | default="", 163 | update=update_node_tree 164 | ) 165 | external_image: PointerProperty( 166 | name="Edit External Image", 167 | type=Image, 168 | ) 169 | expanded: BoolProperty( 170 | name="Expanded", 171 | description="Expand the layer", 172 | default=True, 173 | update=select_layer 174 | ) 175 | 176 | 177 | @dataclass 178 | class NodeEntry: 179 | # color_input: NodeSocket 180 | # alpha_input: NodeSocket 181 | location: Vector 182 | inputs: Dict[str, NodeSocket] = field(default_factory=dict) 183 | outputs: Dict[str, NodeSocket] = field(default_factory=dict) 184 | is_previous_clipped: bool = False 185 | mask_color_input: NodeSocket | None = None 186 | mask_alpha_input: NodeSocket | None = None 187 | 188 | 189 | class PaintSystemGroup(BaseNestedListManager): 190 | 191 | def update_node_tree(self, context=bpy.context): 192 | time_start = time.time() 193 | self.normalize_orders() 194 | flattened = self.flatten_hierarchy() 195 | interface: NodeTreeInterface = self.node_tree.interface 196 | nodes: Nodes = self.node_tree.nodes 197 | links: NodeLinks = self.node_tree.links 198 | 199 | def find_node(self, node_details): 200 | for node in nodes: 201 | match = True 202 | for key, value in node_details.items(): 203 | if getattr(node, key) != value: 204 | match = False 205 | break 206 | if match: 207 | return node 208 | return None 209 | 210 | # Create new node group 211 | def ensure_nodes(item): 212 | node_group = None 213 | for node in nodes: 214 | if node.type == 'GROUP' and node.node_tree == item.node_tree: 215 | node_group = node 216 | break 217 | if not node_group: 218 | node_group = nodes.new('ShaderNodeGroup') 219 | node_group.node_tree = item.node_tree 220 | return node_group 221 | 222 | def connect_mask_nodes(node_entry: NodeEntry, node: Node): 223 | if node_entry.mask_color_input and node_entry.mask_alpha_input: 224 | links.new(node_entry.mask_color_input, 225 | node.outputs['Color']) 226 | links.new(node_entry.mask_alpha_input, 227 | node.outputs['Alpha']) 228 | node_entry.mask_color_input = None 229 | node_entry.mask_alpha_input = None 230 | 231 | # Remode every links 232 | links.clear() 233 | 234 | # Remove unused node groups 235 | for node in nodes: 236 | if node.type == 'GROUP': 237 | if node.node_tree not in [item.node_tree for item, _ in flattened]: 238 | nodes.remove(node) 239 | else: 240 | nodes.remove(node) 241 | 242 | # Check inputs and outputs 243 | if not interface.items_tree: 244 | interface.new_socket( 245 | name="Color", in_out='INPUT', socket_type="NodeSocketColor") 246 | new_socket = interface.new_socket( 247 | name="Alpha", in_out='INPUT', socket_type="NodeSocketFloat") 248 | new_socket.subtype = 'FACTOR' 249 | new_socket.min_value = 0.0 250 | new_socket.max_value = 1.0 251 | interface.new_socket( 252 | name="Color", in_out='OUTPUT', socket_type="NodeSocketColor") 253 | new_socket = interface.new_socket( 254 | name="Alpha", in_out='OUTPUT', socket_type="NodeSocketFloat") 255 | new_socket.subtype = 'FACTOR' 256 | new_socket.min_value = 0.0 257 | new_socket.max_value = 1.0 258 | 259 | # Check if any node uses the normal input 260 | special_inputs = ['Normal'] 261 | # special_sockets: list[NodeSocket] = [] 262 | for input_name in special_inputs: 263 | interface_socket = interface.items_tree.get(input_name) 264 | 265 | special_socket_type = [] 266 | for item, _ in flattened: 267 | if item.node_tree: 268 | socket = item.node_tree.interface.items_tree.get( 269 | input_name) 270 | if socket: 271 | special_socket_type.append(socket) 272 | if any(special_socket_type) != bool(interface_socket): 273 | if interface_socket: 274 | interface.remove(interface_socket) 275 | else: 276 | new_socket = interface.new_socket( 277 | name=input_name, in_out='INPUT', socket_type=special_socket_type[0].socket_type) 278 | new_socket.hide_value = special_socket_type[0].hide_value 279 | 280 | ps_nodes_store: Dict[int, NodeEntry] = {} 281 | 282 | # Add group input and output nodes 283 | ng_input = nodes.new('NodeGroupInput') 284 | ng_output = nodes.new('NodeGroupOutput') 285 | clamp_node = nodes.new('ShaderNodeClamp') 286 | clamp_node.hide = True 287 | clamp_node.location = ng_output.location + Vector((0, -120)) 288 | links.new(ng_output.inputs['Alpha'], clamp_node.outputs['Result']) 289 | ps_nodes_store[-1] = NodeEntry( 290 | inputs={'Color': ng_output.inputs['Color'], 291 | 'Alpha': clamp_node.inputs['Value'], 292 | }, 293 | location=ng_output.location) 294 | 295 | # lookup_socket_names = ['Color', 'Alpha'] 296 | # lookup_socket_names.extend(special_inputs) 297 | for item, _ in flattened: 298 | is_clipped = item.clip 299 | node_entry = ps_nodes_store.get(item.parent_id) 300 | 301 | group_node = ensure_nodes(item) 302 | group_node.inputs['Alpha'].default_value = 0.0 303 | group_node.location = node_entry.location + \ 304 | Vector((-200, 0)) 305 | group_node.mute = not item.enabled 306 | node_entry.outputs['Color'] = group_node.outputs['Color'] 307 | node_entry.outputs['Alpha'] = group_node.outputs['Alpha'] 308 | if item.type == 'FOLDER': 309 | ps_nodes_store[item.id] = NodeEntry( 310 | inputs={'Color': group_node.inputs['Over Color'], 311 | 'Alpha': group_node.inputs['Over Alpha']}, 312 | location=group_node.location + Vector((0, -250)) 313 | ) 314 | 315 | # MASKING 316 | # Connect the mask color and alpha inputs if they exist 317 | if node_entry.inputs.get('Mask Color') and node_entry.inputs.get('Mask Alpha'): 318 | links.new(node_entry.inputs['Mask Color'], 319 | group_node.outputs['Color']) 320 | links.new(node_entry.inputs['Mask Alpha'], 321 | group_node.outputs['Alpha']) 322 | node_entry.inputs['Mask Color'] = None 323 | node_entry.inputs['Mask Alpha'] = None 324 | 325 | if item.enable_mask and item.mask_image: 326 | mask_nt = get_nodetree_from_library( 327 | '_PS_Mask') 328 | mask_node = nodes.new('ShaderNodeGroup') 329 | mask_node.node_tree = mask_nt 330 | mask_node.mute = not item.enabled 331 | mask_node.location = group_node.location 332 | group_node.location += Vector((-200, 0)) 333 | 334 | mask_image_node = nodes.new('ShaderNodeTexImage') 335 | mask_image_node.image = item.mask_image 336 | mask_image_node.location = mask_node.location + Vector((-200, -200)) 337 | mask_image_node.width = 140 338 | mask_image_node.hide = True 339 | 340 | mask_uvmap_node = nodes.new('ShaderNodeUVMap') 341 | mask_uvmap_node.uv_map = item.mask_uv_map 342 | mask_uvmap_node.location = mask_image_node.location + Vector((0, -50)) 343 | mask_uvmap_node.hide = True 344 | 345 | if item.image: 346 | image_texture_node = item.node_tree.nodes['Image Texture'] 347 | mask_image_node.interpolation = image_texture_node.interpolation 348 | mask_image_node.extension = image_texture_node.extension 349 | 350 | node_entry.location = mask_node.location 351 | links.new(mask_image_node.outputs['Color'], 352 | mask_node.inputs['Mask Alpha']) 353 | links.new(mask_uvmap_node.outputs['UV'], 354 | mask_image_node.inputs['Vector']) 355 | links.new(group_node.outputs['Color'], 356 | mask_node.inputs['Color']) 357 | links.new(group_node.outputs['Alpha'], 358 | mask_node.inputs['Alpha']) 359 | node_entry.outputs['Color'] = mask_node.outputs['Color'] 360 | node_entry.outputs['Alpha'] = mask_node.outputs['Alpha'] 361 | node_entry.inputs['Mask Color'] = mask_node.inputs['Original Color'] 362 | node_entry.inputs['Mask Alpha'] = mask_node.inputs['Original Alpha'] 363 | 364 | 365 | # CLIPPING 366 | if node_entry.is_previous_clipped and not is_clipped: 367 | for node in nodes: 368 | node.select = False 369 | group_node.select = True 370 | node_entry.is_previous_clipped = False 371 | ps_nodes_store[item.parent_id] = NodeEntry( 372 | inputs={ 373 | "Color": node_entry.inputs['Clip Color'], 374 | "Alpha": node_entry.inputs['Clip Alpha'] 375 | }, 376 | location=group_node.location, 377 | ) 378 | # links.new(node_entry.inputs['Clip Color'], 379 | # node_entry.outputs['Color']) 380 | links.new(node_entry.inputs['Clip Mask Alpha'], 381 | node_entry.outputs['Alpha']) 382 | 383 | # CLIPPING 384 | if is_clipped and not node_entry.is_previous_clipped: 385 | alpha_over_nt = get_nodetree_from_library( 386 | '_PS_Alpha_Over') 387 | alpha_over_node = nodes.new('ShaderNodeGroup') 388 | alpha_over_node.node_tree = alpha_over_nt 389 | alpha_over_node.location = node_entry.location + \ 390 | Vector((-200, 0)) 391 | group_node.location += Vector((-200, 0)) 392 | links.new(alpha_over_node.inputs['Over Color'], 393 | node_entry.outputs['Color']) 394 | links.new(alpha_over_node.inputs['Over Alpha'], 395 | node_entry.outputs['Alpha']) 396 | node_entry.outputs['Alpha'].default_value = 1.0 397 | node_entry.outputs['Color'] = alpha_over_node.outputs['Color'] 398 | node_entry.outputs['Alpha'] = alpha_over_node.outputs['Alpha'] 399 | node_entry.inputs['Clip Color'] = alpha_over_node.inputs['Under Color'] 400 | node_entry.inputs['Clip Alpha'] = alpha_over_node.inputs['Under Alpha'] 401 | node_entry.inputs['Clip Mask Alpha'] = alpha_over_node.inputs['Over Alpha'] 402 | 403 | # links.new(node_entry.inputs['Color'], 404 | # alpha_over_node.outputs['Color']) 405 | # links.new(node_entry.inputs['Alpha'], 406 | # alpha_over_node.outputs['Alpha']) 407 | # node_entry.color_input = alpha_over_node.inputs['Under Color'] 408 | # node_entry.alpha_input = alpha_over_node.inputs['Under Alpha'] 409 | # node_entry.location = alpha_over_node.location 410 | # node_entry.clip_color_input = alpha_over_node.inputs['Over Color'] 411 | # node_entry.clip_alpha_input = alpha_over_node.inputs['Over Alpha'] 412 | # connect_mask_nodes(node_entry, alpha_over_node) 413 | # node_entry.outputs['Color'] = alpha_over_node.outputs['Color'] 414 | # node_entry.outputs['Alpha'] = alpha_over_node.outputs['Alpha'] 415 | # node_entry.inputs['Clip Color'] = alpha_over_node.inputs['Over Color'] 416 | # node_entry.inputs['Clip Alpha'] = alpha_over_node.inputs['Over Alpha'] 417 | 418 | 419 | links.new(node_entry.inputs['Color'], node_entry.outputs['Color']) 420 | links.new(node_entry.inputs['Alpha'], node_entry.outputs['Alpha']) 421 | node_entry.inputs['Color'] = group_node.inputs['Color'] 422 | node_entry.inputs['Alpha'] = group_node.inputs['Alpha'] 423 | 424 | # if is_clip or node_entry.is_clip: 425 | # links.new(node_entry.clip_color_input, 426 | # group_node.outputs['Color']) 427 | # node_entry.clip_color_input = group_node.inputs['Color'] 428 | # if not is_clip: 429 | # links.new(node_entry.clip_alpha_input, 430 | # group_node.outputs['Alpha']) 431 | # node_entry.clip_alpha_input = group_node.inputs['Alpha'] 432 | # else: 433 | # group_node.inputs['Alpha'].default_value = 1.0 434 | # else: 435 | 436 | 437 | # links.new(node_entry.color_input, 438 | # group_node.outputs['Color']) 439 | # links.new(node_entry.alpha_input, 440 | # group_node.outputs['Alpha']) 441 | # node_entry.color_input = group_node.inputs['Color'] 442 | # node_entry.alpha_input = group_node.inputs['Alpha'] 443 | 444 | node_entry.location = group_node.location 445 | node_entry.is_previous_clipped = is_clipped 446 | 447 | 448 | 449 | if self.bake_image and self.use_bake_image: 450 | bake_image_node = nodes.new('ShaderNodeTexImage') 451 | bake_image_node.image = self.bake_image 452 | bake_image_node.location = ng_output.location + Vector((-300, 300)) 453 | bake_image_node.interpolation = 'Closest' 454 | uvmap_node = nodes.new('ShaderNodeUVMap') 455 | uvmap_node.uv_map = self.bake_uv_map 456 | uvmap_node.location = bake_image_node.location + Vector((-200, 0)) 457 | links.new(uvmap_node.outputs['UV'], 458 | bake_image_node.inputs['Vector']) 459 | links.new(ng_output.inputs['Color'], 460 | bake_image_node.outputs['Color']) 461 | links.new(ng_output.inputs['Alpha'], 462 | bake_image_node.outputs['Alpha']) 463 | 464 | # Connect special inputs 465 | for input_name in special_inputs: 466 | for node in nodes: 467 | if node.type == 'GROUP': 468 | socket = node.inputs.get( 469 | input_name) 470 | if socket: 471 | links.new(socket, ng_input.outputs[socket.name]) 472 | 473 | node_entry = ps_nodes_store[-1] 474 | # connect_mask_nodes(node_entry, ng_input) 475 | ng_input.location = node_entry.location + Vector((-200, 0)) 476 | clamp_node = nodes.new('ShaderNodeClamp') 477 | clamp_node.hide = True 478 | clamp_node.location = ng_input.location + Vector((0, -120)) 479 | links.new(clamp_node.inputs['Value'], ng_input.outputs['Alpha']) 480 | links.new(node_entry.inputs['Color'], ng_input.outputs['Color']) 481 | links.new(node_entry.inputs['Alpha'], clamp_node.outputs['Result']) 482 | 483 | print("Updated node tree: %.4f sec" % (time.time() - time_start)) 484 | 485 | # Define the collection property directly in the class 486 | items: CollectionProperty(type=PaintSystemLayer) 487 | name: StringProperty( 488 | name="Name", 489 | description="Group name", 490 | default="Group", 491 | update=update_paintsystem_data 492 | ) 493 | active_index: IntProperty( 494 | name="Active Index", 495 | description="Active layer index", 496 | update=update_active_image, 497 | ) 498 | node_tree: PointerProperty( 499 | name="Node Tree", 500 | type=bpy.types.NodeTree 501 | ) 502 | bake_image: PointerProperty( 503 | name="Bake Image", 504 | type=Image 505 | ) 506 | bake_uv_map: StringProperty( 507 | name="Bake Image UV Map", 508 | default="UVMap", 509 | update=update_node_tree 510 | ) 511 | use_bake_image: BoolProperty( 512 | name="Use Bake Image", 513 | default=False, 514 | update=update_node_tree 515 | ) 516 | 517 | @property 518 | def item_type(self): 519 | return PaintSystemLayer 520 | 521 | def get_movement_menu_items(self, item_id, direction): 522 | """ 523 | Get menu items for movement options. 524 | Returns list of tuples (identifier, label, description) 525 | """ 526 | options = self.get_movement_options(item_id, direction) 527 | menu_items = [] 528 | 529 | # Map option identifiers to their operators 530 | operator_map = { 531 | 'UP': 'paint_system.move_up', 532 | 'DOWN': 'paint_system.move_down' 533 | } 534 | 535 | for identifier, description in options: 536 | menu_items.append(( 537 | operator_map[direction], 538 | description, 539 | {'action': identifier} 540 | )) 541 | 542 | return menu_items 543 | 544 | 545 | class PaintSystemGroups(PropertyGroup): 546 | name: StringProperty( 547 | name="Name", 548 | description="Paint system name", 549 | default="Paint System" 550 | ) 551 | groups: CollectionProperty(type=PaintSystemGroup) 552 | active_group: EnumProperty( 553 | name="Active Group", 554 | description="Select active group", 555 | items=get_all_group_names, 556 | update=update_active_image, 557 | ) 558 | use_paintsystem_uv: BoolProperty( 559 | name="Use Paint System UV", 560 | description="Use the Paint System UV Map", 561 | default=True 562 | ) 563 | 564 | 565 | class PaintSystemSettings(PropertyGroup): 566 | brush_xray: BoolProperty( 567 | name="Brush X-Ray", 568 | description="Brush X-Ray", 569 | default=False 570 | ) 571 | allow_image_overwrite: BoolProperty( 572 | name="Allow Image Overwrite", 573 | description="Make Image in 3D Viewport the same as the active layer", 574 | default=True 575 | ) 576 | 577 | template: EnumProperty( 578 | name="Template", 579 | items=TEMPLATE_ENUM, 580 | default='STANDARD', 581 | ) 582 | 583 | 584 | classes = ( 585 | PaintSystemLayer, 586 | PaintSystemGroup, 587 | PaintSystemGroups, 588 | PaintSystemSettings 589 | ) 590 | 591 | 592 | def register(): 593 | for cls in classes: 594 | bpy.utils.register_class(cls) 595 | bpy.types.Scene.paint_system_settings = PointerProperty( 596 | type=PaintSystemSettings) 597 | bpy.types.Material.paint_system = PointerProperty(type=PaintSystemGroups) 598 | 599 | 600 | def unregister(): 601 | del bpy.types.Material.paint_system 602 | del bpy.types.Scene.paint_system_settings 603 | for cls in reversed(classes): 604 | bpy.utils.unregister_class(cls) 605 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.utils import register_classes_factory 3 | from unittest import TestLoader, TestResult, TextTestRunner 4 | from pathlib import Path 5 | from .paint_system import PaintSystem 6 | 7 | 8 | def run_tests(): 9 | test_loader = TestLoader() 10 | 11 | test_directory = str(Path(__file__).resolve().parent / 'tests') 12 | 13 | test_suite = test_loader.discover(test_directory, pattern='test_*.py') 14 | runner = TextTestRunner(verbosity=2) 15 | result = runner.run(test_suite) 16 | return result 17 | 18 | 19 | class PAINTSYSTEM_OT_run_tests(bpy.types.Operator): 20 | bl_idname = "paintsystem.run_tests" 21 | bl_label = "Run Tests" 22 | bl_description = "Run the test suite for the paint system" 23 | 24 | @classmethod 25 | def poll(cls, context): 26 | ps = PaintSystem(context) 27 | return ps.active_object is not None 28 | 29 | def execute(self, context): 30 | result = run_tests() 31 | print(result) 32 | return {'FINISHED'} 33 | 34 | 35 | classes = ( 36 | PAINTSYSTEM_OT_run_tests, 37 | ) 38 | 39 | register, unregister = register_classes_factory(classes) --------------------------------------------------------------------------------