├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── LICENSE ├── NodeToPython ├── LICENSE ├── __init__.py ├── blender_manifest.toml ├── compositor │ ├── __init__.py │ ├── operator.py │ └── ui.py ├── geometry │ ├── __init__.py │ ├── node_tree.py │ ├── operator.py │ └── ui.py ├── license_templates.py ├── node_settings.py ├── ntp_node_tree.py ├── ntp_operator.py ├── options.py ├── shader │ ├── __init__.py │ ├── operator.py │ └── ui.py └── utils.py ├── docs ├── CONTRIBUTORS.md ├── README.md └── img │ ├── NodeToPython_Location.png │ ├── logo.png │ └── logo_256x256.png └── tools └── node_settings_generator ├── README.md ├── parse_nodes.py └── types_utils.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve NodeToPython 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **System Information** 11 | * OS (Windows, MacOS, Linux) 12 | * Blender version (3.0 - 4.3) 13 | * Lower versions will not receive support 14 | * Higher Blender versions are expected to break NodeToPython. Development to add the required new nodes, data types, and breaking API changes tends to start after the Beta phase in the [release cycle](https://developer.blender.org/docs/handbook/release_process/release_cycle/) 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 Brendan Parmer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NodeToPython/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 Brendan Parmer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NodeToPython/__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | "name": "Node to Python", 3 | "description": "Convert Blender node groups to a Python add-on!", 4 | "author": "Brendan Parmer", 5 | "version": (3, 4, 0), 6 | "blender": (3, 0, 0), 7 | "location": "Node", 8 | "category": "Node", 9 | } 10 | 11 | if "bpy" in locals(): 12 | import importlib 13 | importlib.reload(compositor) 14 | importlib.reload(geometry) 15 | importlib.reload(shader) 16 | importlib.reload(options) 17 | else: 18 | from . import compositor 19 | from . import geometry 20 | from . import shader 21 | from . import options 22 | 23 | import bpy 24 | 25 | 26 | class NodeToPythonMenu(bpy.types.Menu): 27 | bl_idname = "NODE_MT_node_to_python" 28 | bl_label = "Node To Python" 29 | 30 | @classmethod 31 | def poll(cls, context): 32 | return True 33 | 34 | def draw(self, context): 35 | layout = self.layout.column_flow(columns=1) 36 | layout.operator_context = 'INVOKE_DEFAULT' 37 | 38 | 39 | classes = [ 40 | NodeToPythonMenu, 41 | #options 42 | options.NTPOptions, 43 | options.NTPOptionsPanel, 44 | #compositor 45 | compositor.operator.NTPCompositorOperator, 46 | compositor.ui.NTPCompositorScenesMenu, 47 | compositor.ui.NTPCompositorGroupsMenu, 48 | compositor.ui.NTPCompositorPanel, 49 | #geometry 50 | geometry.operator.NTPGeoNodesOperator, 51 | geometry.ui.NTPGeoNodesMenu, 52 | geometry.ui.NTPGeoNodesPanel, 53 | #material 54 | shader.operator.NTPShaderOperator, 55 | shader.ui.NTPShaderMenu, 56 | shader.ui.NTPShaderPanel, 57 | ] 58 | 59 | def register(): 60 | for cls in classes: 61 | bpy.utils.register_class(cls) 62 | scene = bpy.types.Scene 63 | scene.ntp_options = bpy.props.PointerProperty(type=options.NTPOptions) 64 | 65 | def unregister(): 66 | for cls in classes: 67 | bpy.utils.unregister_class(cls) 68 | del bpy.types.Scene.ntp_options 69 | 70 | if __name__ == "__main__": 71 | register() -------------------------------------------------------------------------------- /NodeToPython/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | 3 | id = "node_to_python" 4 | version = "3.4.0" 5 | name = "Node To Python" 6 | tagline = "Turn node groups into Python code" 7 | maintainer = "Brendan Parmer " 8 | type = "add-on" 9 | 10 | website = "https://github.com/BrendanParmer/NodeToPython" 11 | 12 | tags = ["Development", "Compositing", "Geometry Nodes", "Material", "Node"] 13 | 14 | blender_version_min = "4.2.0" 15 | blender_version_max = "4.5.0" 16 | 17 | license = [ 18 | "SPDX:MIT", 19 | ] 20 | 21 | [permissions] 22 | files = "Creates and writes to files in a specified directory" -------------------------------------------------------------------------------- /NodeToPython/compositor/__init__.py: -------------------------------------------------------------------------------- 1 | if "bpy" in locals(): 2 | import importlib 3 | importlib.reload(operator) 4 | importlib.reload(ui) 5 | else: 6 | from . import operator 7 | from . import ui 8 | 9 | import bpy -------------------------------------------------------------------------------- /NodeToPython/compositor/operator.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.types import Node, CompositorNodeColorBalance, CompositorNodeTree 4 | 5 | from ..ntp_operator import NTP_Operator, INDEX 6 | from ..ntp_node_tree import NTP_NodeTree 7 | from ..utils import * 8 | from ..node_settings import NTPNodeSetting, ST 9 | from io import StringIO 10 | from ..node_settings import node_settings 11 | 12 | SCENE = "scene" 13 | BASE_NAME = "base_name" 14 | END_NAME = "end_name" 15 | NODE = "node" 16 | 17 | COMP_OP_RESERVED_NAMES = {SCENE, BASE_NAME, END_NAME, NODE} 18 | 19 | class NTPCompositorOperator(NTP_Operator): 20 | bl_idname = "node.ntp_compositor" 21 | bl_label = "Compositor to Python" 22 | bl_options = {'REGISTER', 'UNDO'} 23 | 24 | compositor_name: bpy.props.StringProperty(name="Node Group") 25 | is_scene : bpy.props.BoolProperty( 26 | name="Is Scene", 27 | description="Blender stores compositing node trees differently for scenes and in groups") 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | self._node_infos = node_settings 32 | for name in COMP_OP_RESERVED_NAMES: 33 | self._used_vars[name] = 0 34 | 35 | 36 | def _create_scene(self, indent_level: int): 37 | #TODO: wrap in more general unique name util function 38 | self._write(f"# Generate unique scene name", indent_level) 39 | self._write(f"{BASE_NAME} = {str_to_py_str(self.compositor_name)}", 40 | indent_level) 41 | self._write(f"{END_NAME} = {BASE_NAME}", indent_level) 42 | self._write(f"if bpy.data.scenes.get({END_NAME}) != None:", indent_level) 43 | 44 | self._write(f"{INDEX} = 1", indent_level + 1) 45 | self._write(f"{END_NAME} = {BASE_NAME} + f\".{{i:03d}}\"", 46 | indent_level + 1) 47 | self._write(f"while bpy.data.scenes.get({END_NAME}) != None:", 48 | indent_level + 1) 49 | 50 | self._write(f"{END_NAME} = {BASE_NAME} + f\".{{{INDEX}:03d}}\"", 51 | indent_level + 2) 52 | self._write(f"{INDEX} += 1\n", indent_level + 2) 53 | 54 | self._write(f"{SCENE} = bpy.context.window.scene.copy()\n", indent_level) 55 | self._write(f"{SCENE}.name = {END_NAME}", indent_level) 56 | self._write(f"{SCENE}.use_fake_user = True", indent_level) 57 | self._write(f"bpy.context.window.scene = {SCENE}", indent_level) 58 | 59 | def _initialize_compositor_node_tree(self, ntp_nt, nt_name): 60 | #initialize node group 61 | self._write(f"#initialize {nt_name} node group", self._outer_indent_level) 62 | self._write(f"def {ntp_nt.var}_node_group():", self._outer_indent_level) 63 | 64 | if ntp_nt.node_tree == self._base_node_tree and self.is_scene: 65 | self._write(f"{ntp_nt.var} = {SCENE}.node_tree") 66 | self._write(f"#start with a clean node tree") 67 | self._write(f"for {NODE} in {ntp_nt.var}.nodes:") 68 | self._write(f"{ntp_nt.var}.nodes.remove({NODE})", self._inner_indent_level + 1) 69 | else: 70 | self._write((f"{ntp_nt.var} = bpy.data.node_groups.new(" 71 | f"type = \'CompositorNodeTree\', " 72 | f"name = {str_to_py_str(nt_name)})")) 73 | self._write("", 0) 74 | 75 | # Compositor node tree settings 76 | #TODO: might be good to make this optional 77 | enum_settings = ["chunk_size", "edit_quality", "execution_mode", 78 | "precision", "render_quality"] 79 | for enum in enum_settings: 80 | if not hasattr(ntp_nt.node_tree, enum): 81 | continue 82 | setting = getattr(ntp_nt.node_tree, enum) 83 | if setting != None and setting != "": 84 | py_str = enum_to_py_str(setting) 85 | self._write(f"{ntp_nt.var}.{enum} = {py_str}") 86 | 87 | bool_settings = ["use_groupnode_buffer", "use_opencl", "use_two_pass", 88 | "use_viewer_border"] 89 | for bool_setting in bool_settings: 90 | if not hasattr(ntp_nt.node_tree, bool_setting): 91 | continue 92 | if getattr(ntp_nt.node_tree, bool_setting) is True: 93 | self._write(f"{ntp_nt.var}.{bool_setting} = True") 94 | 95 | 96 | def _set_color_balance_settings(self, node: CompositorNodeColorBalance 97 | ) -> None: 98 | """ 99 | Sets the color balance settings so we only set the active variables, 100 | preventing conflict 101 | 102 | node (CompositorNodeColorBalance): the color balance node 103 | """ 104 | if node.correction_method == 'LIFT_GAMMA_GAIN': 105 | lst = [NTPNodeSetting("correction_method", ST.ENUM), 106 | NTPNodeSetting("gain", ST.VEC3, max_version_=(3, 5, 0)), 107 | NTPNodeSetting("gain", ST.COLOR, min_version_=(3, 5, 0)), 108 | NTPNodeSetting("gamma", ST.VEC3, max_version_=(3, 5, 0)), 109 | NTPNodeSetting("gamma", ST.COLOR, min_version_=(3, 5, 0)), 110 | NTPNodeSetting("lift", ST.VEC3, max_version_=(3, 5, 0)), 111 | NTPNodeSetting("lift", ST.COLOR, min_version_=(3, 5, 0))] 112 | elif node.correction_method == 'OFFSET_POWER_SLOPE': 113 | lst = [NTPNodeSetting("correction_method", ST.ENUM), 114 | NTPNodeSetting("offset", ST.VEC3, max_version_=(3, 5, 0)), 115 | NTPNodeSetting("offset", ST.COLOR, min_version_=(3, 5, 0)), 116 | NTPNodeSetting("offset_basis", ST.FLOAT), 117 | NTPNodeSetting("power", ST.VEC3, max_version_=(3, 5, 0)), 118 | NTPNodeSetting("power", ST.COLOR, min_version_=(3, 5, 0)), 119 | NTPNodeSetting("slope", ST.VEC3, max_version_=(3, 5, 0)), 120 | NTPNodeSetting("slope", ST.COLOR, min_version_=(3, 5, 0))] 121 | elif node.correction_method == 'WHITEPOINT': 122 | lst = [NTPNodeSetting("correction_method", ST.ENUM), 123 | NTPNodeSetting("input_temperature", ST.FLOAT), 124 | NTPNodeSetting("input_tint", ST.FLOAT), 125 | NTPNodeSetting("output_temperature", ST.FLOAT), 126 | NTPNodeSetting("output_tint", ST.FLOAT)] 127 | else: 128 | self.report({'ERROR'}, 129 | f"Unknown color balance correction method " 130 | f"{enum_to_py_str(node.correction_method)}") 131 | return 132 | 133 | color_balance_info = self._node_infos['CompositorNodeColorBalance'] 134 | self._node_infos['CompositorNodeColorBalance'] = color_balance_info._replace(attributes_ = lst) 135 | 136 | def _process_node(self, node: Node, ntp_nt: NTP_NodeTree): 137 | """ 138 | Create node and set settings, defaults, and cosmetics 139 | 140 | Parameters: 141 | node (Node): node to process 142 | ntp_nt (NTP_NodeTree): the node tree that node belongs to 143 | """ 144 | node_var: str = self._create_node(node, ntp_nt.var) 145 | 146 | if node.bl_idname == 'CompositorNodeColorBalance': 147 | self._set_color_balance_settings(node) 148 | 149 | self._set_settings_defaults(node) 150 | self._hide_hidden_sockets(node) 151 | 152 | if bpy.app.version < (4, 0, 0): 153 | if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: 154 | self._group_io_settings(node, "input", ntp_nt) 155 | ntp_nt.inputs_set = True 156 | 157 | elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: 158 | self._group_io_settings(node, "output", ntp_nt) 159 | ntp_nt.outputs_set = True 160 | 161 | self._set_socket_defaults(node) 162 | 163 | def _process_node_tree(self, node_tree: CompositorNodeTree): 164 | """ 165 | Generates a Python function to recreate a compositor node tree 166 | 167 | Parameters: 168 | node_tree (CompositorNodeTree): node tree to be recreated 169 | """ 170 | if node_tree == self._base_node_tree: 171 | nt_var = self._create_var(self.compositor_name) 172 | nt_name = self.compositor_name 173 | else: 174 | nt_var = self._create_var(node_tree.name) 175 | nt_name = node_tree.name 176 | 177 | self._node_tree_vars[node_tree] = nt_var 178 | 179 | ntp_nt = NTP_NodeTree(node_tree, nt_var) 180 | self._initialize_compositor_node_tree(ntp_nt, nt_name) 181 | 182 | self._set_node_tree_properties(node_tree) 183 | 184 | if bpy.app.version >= (4, 0, 0): 185 | self._tree_interface_settings(ntp_nt) 186 | 187 | #initialize nodes 188 | self._write(f"#initialize {nt_var} nodes") 189 | 190 | for node in node_tree.nodes: 191 | self._process_node(node, ntp_nt) 192 | 193 | #set look of nodes 194 | self._set_parents(node_tree) 195 | self._set_locations(node_tree) 196 | self._set_dimensions(node_tree) 197 | 198 | #create connections 199 | self._init_links(node_tree) 200 | 201 | self._write(f"return {nt_var}\n") 202 | 203 | #create node group 204 | self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) 205 | 206 | def execute(self, context): 207 | if not self._setup_options(context.scene.ntp_options): 208 | return {'CANCELLED'} 209 | 210 | #find node group to replicate 211 | if self.is_scene: 212 | self._base_node_tree = bpy.data.scenes[self.compositor_name].node_tree 213 | else: 214 | self._base_node_tree = bpy.data.node_groups[self.compositor_name] 215 | 216 | if self._base_node_tree is None: 217 | #shouldn't happen 218 | self.report({'ERROR'},("NodeToPython: This doesn't seem to be a " 219 | "valid compositor node tree. Is Use Nodes " 220 | "selected?")) 221 | return {'CANCELLED'} 222 | 223 | #set up names to use in generated addon 224 | comp_var = clean_string(self.compositor_name) 225 | 226 | if self._mode == 'ADDON': 227 | self._outer_indent_level = 2 228 | self._inner_indent_level = 3 229 | 230 | if not self._setup_addon_directories(context, comp_var): 231 | return {'CANCELLED'} 232 | 233 | self._file = open(f"{self._addon_dir}/__init__.py", "w") 234 | 235 | self._create_header(self.compositor_name) 236 | self._class_name = clean_string(self.compositor_name, lower=False) 237 | self._init_operator(comp_var, self.compositor_name) 238 | 239 | self._write("def execute(self, context):", 1) 240 | else: 241 | self._file = StringIO("") 242 | if self._include_imports: 243 | self._file.write("import bpy, mathutils\n\n") 244 | 245 | if self.is_scene: 246 | if self._mode == 'ADDON': 247 | self._create_scene(2) 248 | elif self._mode == 'SCRIPT': 249 | self._create_scene(0) 250 | 251 | node_trees_to_process = self._topological_sort(self._base_node_tree) 252 | 253 | for node_tree in node_trees_to_process: 254 | self._process_node_tree(node_tree) 255 | 256 | if self._mode == 'ADDON': 257 | self._write("return {'FINISHED'}\n", self._outer_indent_level) 258 | 259 | self._create_menu_func() 260 | self._create_register_func() 261 | self._create_unregister_func() 262 | self._create_main_func() 263 | self._create_license() 264 | if bpy.app.version >= (4, 2, 0): 265 | self._create_manifest() 266 | else: 267 | context.window_manager.clipboard = self._file.getvalue() 268 | 269 | self._file.close() 270 | 271 | if self._mode == 'ADDON': 272 | self._zip_addon() 273 | 274 | self._report_finished("compositor nodes") 275 | 276 | return {'FINISHED'} -------------------------------------------------------------------------------- /NodeToPython/compositor/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Panel 3 | from bpy.types import Menu 4 | from .operator import NTPCompositorOperator 5 | 6 | class NTPCompositorPanel(Panel): 7 | bl_label = "Compositor to Python" 8 | bl_idname = "NODE_PT_ntp_compositor" 9 | bl_space_type = 'NODE_EDITOR' 10 | bl_region_type = 'UI' 11 | bl_context = '' 12 | bl_category = "NodeToPython" 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | @classmethod 18 | def poll(cls, context): 19 | return True 20 | 21 | def draw_header(self, context): 22 | layout = self.layout 23 | 24 | def draw(self, context): 25 | layout = self.layout 26 | scenes_row = layout.row() 27 | 28 | # Disables menu when there are no compositing node groups 29 | scenes = [scene for scene in bpy.data.scenes if scene.node_tree] 30 | scenes_exist = len(scenes) > 0 31 | scenes_row.enabled = scenes_exist 32 | 33 | scenes_row.alignment = 'EXPAND' 34 | scenes_row.operator_context = 'INVOKE_DEFAULT' 35 | scenes_row.menu("NODE_MT_ntp_comp_scenes", 36 | text="Scene Compositor Nodes") 37 | 38 | groups_row = layout.row() 39 | groups = [node_tree for node_tree in bpy.data.node_groups 40 | if node_tree.bl_idname == 'CompositorNodeTree'] 41 | groups_exist = len(groups) > 0 42 | groups_row.enabled = groups_exist 43 | 44 | groups_row.alignment = 'EXPAND' 45 | groups_row.operator_context = 'INVOKE_DEFAULT' 46 | groups_row.menu("NODE_MT_ntp_comp_groups", 47 | text="Group Compositor Nodes") 48 | 49 | class NTPCompositorScenesMenu(Menu): 50 | bl_idname = "NODE_MT_ntp_comp_scenes" 51 | bl_label = "Select " 52 | 53 | @classmethod 54 | def poll(cls, context): 55 | return True 56 | 57 | def draw(self, context): 58 | layout = self.layout.column_flow(columns=1) 59 | layout.operator_context = 'INVOKE_DEFAULT' 60 | for scene in bpy.data.scenes: 61 | if scene.node_tree: 62 | op = layout.operator(NTPCompositorOperator.bl_idname, 63 | text=scene.name) 64 | op.compositor_name = scene.name 65 | op.is_scene = True 66 | 67 | class NTPCompositorGroupsMenu(Menu): 68 | bl_idname = "NODE_MT_ntp_comp_groups" 69 | bl_label = "Select " 70 | 71 | @classmethod 72 | def poll(cls, context): 73 | return True 74 | 75 | def draw(self, context): 76 | layout = self.layout.column_flow(columns=1) 77 | layout.operator_context = 'INVOKE_DEFAULT' 78 | for node_group in bpy.data.node_groups: 79 | if node_group.bl_idname == 'CompositorNodeTree': 80 | op = layout.operator(NTPCompositorOperator.bl_idname, 81 | text=node_group.name) 82 | op.compositor_name = node_group.name 83 | op.is_scene = False -------------------------------------------------------------------------------- /NodeToPython/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | if "bpy" in locals(): 2 | import importlib 3 | importlib.reload(node_tree) 4 | importlib.reload(operator) 5 | importlib.reload(ui) 6 | else: 7 | from . import node_tree 8 | from . import operator 9 | from . import ui 10 | 11 | import bpy -------------------------------------------------------------------------------- /NodeToPython/geometry/node_tree.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import GeometryNodeTree, GeometryNode 3 | 4 | if bpy.app.version >= (3, 6, 0): 5 | from bpy.types import GeometryNodeSimulationInput 6 | 7 | if bpy.app.version >= (4, 0, 0): 8 | from bpy.types import GeometryNodeRepeatInput 9 | 10 | if bpy.app.version >= (4, 3, 0): 11 | from bpy.types import GeometryNodeForeachGeometryElementInput 12 | 13 | from ..ntp_node_tree import NTP_NodeTree 14 | 15 | class NTP_GeoNodeTree(NTP_NodeTree): 16 | def __init__(self, node_tree: GeometryNodeTree, var: str): 17 | super().__init__(node_tree, var) 18 | self.zone_inputs: dict[list[GeometryNode]] = {} 19 | if bpy.app.version >= (3, 6, 0): 20 | self.zone_inputs["GeometryNodeSimulationInput"] = [] 21 | if bpy.app.version >= (4, 0, 0): 22 | self.zone_inputs["GeometryNodeRepeatInput"] = [] 23 | if bpy.app.version >= (4, 3, 0): 24 | self.zone_inputs["GeometryNodeForeachGeometryElementInput"] = [] 25 | -------------------------------------------------------------------------------- /NodeToPython/geometry/operator.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import GeometryNode, GeometryNodeTree 3 | from bpy.types import Node 4 | 5 | from io import StringIO 6 | 7 | from ..ntp_operator import NTP_Operator 8 | from ..utils import * 9 | from .node_tree import NTP_GeoNodeTree 10 | from ..node_settings import node_settings 11 | 12 | OBJECT_NAME = "name" 13 | OBJECT = "obj" 14 | MODIFIER = "mod" 15 | GEO_OP_RESERVED_NAMES = {OBJECT_NAME, 16 | OBJECT, 17 | MODIFIER} 18 | 19 | class NTPGeoNodesOperator(NTP_Operator): 20 | bl_idname = "node.ntp_geo_nodes" 21 | bl_label = "Geo Nodes to Python" 22 | bl_options = {'REGISTER', 'UNDO'} 23 | 24 | geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") 25 | 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, **kwargs) 28 | 29 | self._node_infos = node_settings 30 | for name in GEO_OP_RESERVED_NAMES: 31 | self._used_vars[name] = 0 32 | 33 | def _process_node(self, node: Node, ntp_nt: NTP_GeoNodeTree) -> None: 34 | """ 35 | Create node and set settings, defaults, and cosmetics 36 | 37 | Parameters: 38 | node (Node): node to process 39 | ntp_nt (NTP_NodeTree): the node tree that node belongs to 40 | """ 41 | node_var: str = self._create_node(node, ntp_nt.var) 42 | self._set_settings_defaults(node) 43 | 44 | if bpy.app.version < (4, 0, 0): 45 | if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: 46 | self._group_io_settings(node, "input", ntp_nt) 47 | ntp_nt.inputs_set = True 48 | 49 | elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: 50 | self._group_io_settings(node, "output", ntp_nt) 51 | ntp_nt.outputs_set = True 52 | 53 | if node.bl_idname in ntp_nt.zone_inputs: 54 | ntp_nt.zone_inputs[node.bl_idname].append(node) 55 | 56 | self._hide_hidden_sockets(node) 57 | 58 | if node.bl_idname not in ntp_nt.zone_inputs: 59 | self._set_socket_defaults(node) 60 | 61 | if bpy.app.version >= (3, 6, 0): 62 | def _process_zones(self, zone_inputs: list[GeometryNode]) -> None: 63 | """ 64 | Recreates a zone 65 | zone_inputs (list[GeometryNodeSimulationInput]): list of 66 | simulation input nodes 67 | """ 68 | for zone_input in zone_inputs: 69 | zone_output = zone_input.paired_output 70 | 71 | zone_input_var = self._node_vars[zone_input] 72 | zone_output_var = self._node_vars[zone_output] 73 | 74 | self._write(f"#Process zone input {zone_input.name}") 75 | self._write(f"{zone_input_var}.pair_with_output" 76 | f"({zone_output_var})") 77 | 78 | #must set defaults after paired with output 79 | self._set_socket_defaults(zone_input) 80 | self._set_socket_defaults(zone_output) 81 | self._write("", 0) 82 | 83 | if bpy.app.version >= (4, 0, 0): 84 | def _set_geo_tree_properties(self, node_tree: GeometryNodeTree) -> None: 85 | is_mod = node_tree.is_modifier 86 | is_tool = node_tree.is_tool 87 | 88 | nt_var = self._node_tree_vars[node_tree] 89 | 90 | if is_mod: 91 | self._write(f"{nt_var}.is_modifier = True") 92 | if is_tool: 93 | self._write(f"{nt_var}.is_tool = True") 94 | 95 | tool_flags = ["is_mode_object", 96 | "is_mode_edit", 97 | "is_mode_sculpt", 98 | "is_type_curve", 99 | "is_type_mesh", 100 | "is_type_point_cloud"] 101 | 102 | for flag in tool_flags: 103 | if hasattr(node_tree, flag) is True: 104 | self._write(f"{nt_var}.{flag} = {getattr(node_tree, flag)}") 105 | self._write("", 0) 106 | 107 | def _process_node_tree(self, node_tree: GeometryNodeTree) -> None: 108 | """ 109 | Generates a Python function to recreate a node tree 110 | 111 | Parameters: 112 | node_tree (GeometryNodeTree): geometry node tree to be recreated 113 | """ 114 | 115 | nt_var = self._create_var(node_tree.name) 116 | self._node_tree_vars[node_tree] = nt_var 117 | 118 | #initialize node group 119 | self._write(f"#initialize {nt_var} node group", self._outer_indent_level) 120 | self._write(f"def {nt_var}_node_group():", self._outer_indent_level) 121 | self._write(f"{nt_var} = bpy.data.node_groups.new(" 122 | f"type = \'GeometryNodeTree\', " 123 | f"name = {str_to_py_str(node_tree.name)})\n") 124 | 125 | self._set_node_tree_properties(node_tree) 126 | if bpy.app.version >= (4, 0, 0): 127 | self._set_geo_tree_properties(node_tree) 128 | 129 | ntp_nt = NTP_GeoNodeTree(node_tree, nt_var) 130 | 131 | if bpy.app.version >= (4, 0, 0): 132 | self._tree_interface_settings(ntp_nt) 133 | 134 | #initialize nodes 135 | self._write(f"#initialize {nt_var} nodes") 136 | for node in node_tree.nodes: 137 | self._process_node(node, ntp_nt) 138 | 139 | for zone_list in ntp_nt.zone_inputs.values(): 140 | self._process_zones(zone_list) 141 | 142 | #set look of nodes 143 | self._set_parents(node_tree) 144 | self._set_locations(node_tree) 145 | self._set_dimensions(node_tree) 146 | 147 | #create connections 148 | self._init_links(node_tree) 149 | 150 | self._write(f"return {nt_var}\n") 151 | 152 | #create node group 153 | self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) 154 | 155 | 156 | def _apply_modifier(self, nt: GeometryNodeTree, nt_var: str): 157 | #get object 158 | self._write(f"{OBJECT_NAME} = bpy.context.object.name", self._outer_indent_level) 159 | self._write(f"{OBJECT} = bpy.data.objects[{OBJECT_NAME}]", self._outer_indent_level) 160 | 161 | #set modifier to the one we just created 162 | mod_name = str_to_py_str(nt.name) 163 | self._write(f"{MODIFIER} = obj.modifiers.new(name = {mod_name}, " 164 | f"type = 'NODES')", self._outer_indent_level) 165 | self._write(f"{MODIFIER}.node_group = {nt_var}", self._outer_indent_level) 166 | 167 | 168 | def execute(self, context): 169 | if not self._setup_options(context.scene.ntp_options): 170 | return {'CANCELLED'} 171 | 172 | #find node group to replicate 173 | nt = bpy.data.node_groups[self.geo_nodes_group_name] 174 | 175 | #set up names to use in generated addon 176 | nt_var = clean_string(nt.name) 177 | 178 | if self._mode == 'ADDON': 179 | self._outer_indent_level = 2 180 | self._inner_indent_level = 3 181 | 182 | if not self._setup_addon_directories(context, nt_var): 183 | return {'CANCELLED'} 184 | 185 | self._file = open(f"{self._addon_dir}/__init__.py", "w") 186 | 187 | self._create_header(nt.name) 188 | self._class_name = clean_string(nt.name, lower = False) 189 | self._init_operator(nt_var, nt.name) 190 | self._write("def execute(self, context):", 1) 191 | else: 192 | self._file = StringIO("") 193 | if self._include_imports: 194 | self._file.write("import bpy, mathutils\n\n") 195 | 196 | 197 | node_trees_to_process = self._topological_sort(nt) 198 | 199 | for node_tree in node_trees_to_process: 200 | self._process_node_tree(node_tree) 201 | 202 | if self._mode == 'ADDON': 203 | self._apply_modifier(nt, nt_var) 204 | self._write("return {'FINISHED'}\n", self._outer_indent_level) 205 | self._create_menu_func() 206 | self._create_register_func() 207 | self._create_unregister_func() 208 | self._create_main_func() 209 | self._create_license() 210 | if bpy.app.version >= (4, 2, 0): 211 | self._create_manifest() 212 | else: 213 | context.window_manager.clipboard = self._file.getvalue() 214 | self._file.close() 215 | 216 | if self._mode == 'ADDON': 217 | self._zip_addon() 218 | 219 | self._report_finished("geometry node group") 220 | 221 | return {'FINISHED'} -------------------------------------------------------------------------------- /NodeToPython/geometry/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Panel 3 | from bpy.types import Menu 4 | 5 | from .operator import NTPGeoNodesOperator 6 | 7 | class NTPGeoNodesPanel(Panel): 8 | bl_label = "Geometry Nodes to Python" 9 | bl_idname = "NODE_PT_geo_nodes" 10 | bl_space_type = 'NODE_EDITOR' 11 | bl_region_type = 'UI' 12 | bl_context = '' 13 | bl_category = "NodeToPython" 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | @classmethod 19 | def poll(cls, context): 20 | return True 21 | 22 | def draw_header(self, context): 23 | layout = self.layout 24 | 25 | def draw(self, context): 26 | layout = self.layout 27 | col = layout.column() 28 | row = col.row() 29 | 30 | # Disables menu when len of geometry nodes is 0 31 | geo_node_groups = [node_tree for node_tree in bpy.data.node_groups 32 | if node_tree.bl_idname == 'GeometryNodeTree'] 33 | geo_node_groups_exist = len(geo_node_groups) > 0 34 | row.enabled = geo_node_groups_exist 35 | 36 | row.alignment = 'EXPAND' 37 | row.operator_context = 'INVOKE_DEFAULT' 38 | row.menu("NODE_MT_ntp_geo_nodes", text="Geometry Nodes") 39 | 40 | class NTPGeoNodesMenu(Menu): 41 | bl_idname = "NODE_MT_ntp_geo_nodes" 42 | bl_label = "Select Geo Nodes" 43 | 44 | @classmethod 45 | def poll(cls, context): 46 | return True 47 | 48 | def draw(self, context): 49 | layout = self.layout.column_flow(columns=1) 50 | layout.operator_context = 'INVOKE_DEFAULT' 51 | 52 | geo_node_groups = [node_tree for node_tree in bpy.data.node_groups 53 | if node_tree.bl_idname == 'GeometryNodeTree'] 54 | 55 | for node_tree in geo_node_groups: 56 | op = layout.operator(NTPGeoNodesOperator.bl_idname, 57 | text=node_tree.name) 58 | op.geo_nodes_group_name = node_tree.name -------------------------------------------------------------------------------- /NodeToPython/ntp_node_tree.py: -------------------------------------------------------------------------------- 1 | from bpy.types import NodeTree 2 | import bpy 3 | 4 | class NTP_NodeTree: 5 | def __init__(self, node_tree: NodeTree, var: str): 6 | # Blender node tree object being copied 7 | self.node_tree: NodeTree = node_tree 8 | 9 | # The variable named for the regenerated node tree 10 | self.var: str = var 11 | 12 | if bpy.app.version < (4, 0, 0): 13 | # Keep track of if we need to set the default values for the node 14 | # tree inputs and outputs 15 | self.inputs_set: bool = False 16 | self.outputs_set: bool = False -------------------------------------------------------------------------------- /NodeToPython/ntp_operator.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Context, Operator 3 | from bpy.types import Node, NodeTree 4 | 5 | if bpy.app.version < (4, 0, 0): 6 | from bpy.types import NodeSocketInterface 7 | else: 8 | from bpy.types import NodeTreeInterfacePanel, NodeTreeInterfaceSocket 9 | from bpy.types import NodeTreeInterfaceItem 10 | 11 | from bpy.types import bpy_prop_array 12 | 13 | import datetime 14 | import os 15 | import shutil 16 | from typing import TextIO, Callable 17 | 18 | from .license_templates import license_templates 19 | from .ntp_node_tree import NTP_NodeTree 20 | from .options import NTPOptions 21 | from .node_settings import NodeInfo, ST 22 | from .utils import * 23 | 24 | INDEX = "i" 25 | IMAGE_DIR_NAME = "imgs" 26 | IMAGE_PATH = "image_path" 27 | ITEM = "item" 28 | BASE_DIR = "base_dir" 29 | 30 | RESERVED_NAMES = { 31 | INDEX, 32 | IMAGE_DIR_NAME, 33 | IMAGE_PATH, 34 | ITEM, 35 | BASE_DIR 36 | } 37 | 38 | #node input sockets that are messy to set default values for 39 | DONT_SET_DEFAULTS = {'NodeSocketGeometry', 40 | 'NodeSocketShader', 41 | 'NodeSocketMatrix', 42 | 'NodeSocketVirtual'} 43 | 44 | class NTP_Operator(Operator): 45 | """ 46 | "Abstract" base class for all NTP operators. Blender types and abstraction 47 | don't seem to mix well, but this should only be inherited from 48 | """ 49 | 50 | bl_idname = "" 51 | bl_label = "" 52 | 53 | # node tree input sockets that have default properties 54 | if bpy.app.version < (4, 0, 0): 55 | default_sockets_v3 = {'VALUE', 'INT', 'BOOLEAN', 'VECTOR', 'RGBA'} 56 | else: 57 | nondefault_sockets_v4 = { 58 | bpy.types.NodeTreeInterfaceSocketCollection, 59 | bpy.types.NodeTreeInterfaceSocketGeometry, 60 | bpy.types.NodeTreeInterfaceSocketImage, 61 | bpy.types.NodeTreeInterfaceSocketMaterial, 62 | bpy.types.NodeTreeInterfaceSocketObject, 63 | bpy.types.NodeTreeInterfaceSocketShader, 64 | bpy.types.NodeTreeInterfaceSocketTexture 65 | } 66 | 67 | def __init__(self, *args, **kwargs): 68 | super().__init__(*args, **kwargs) 69 | 70 | # Write functions after nodes are mostly initialized and linked up 71 | self._write_after_links: list[Callable] = [] 72 | 73 | # File (TextIO) or string (StringIO) the add-on/script is generated into 74 | self._file: TextIO = None 75 | 76 | # Path to the directory of the zip file 77 | self._zip_dir: str = None 78 | 79 | # Path to the directory for the generated addon 80 | self._addon_dir: str = None 81 | 82 | # Class named for the generated operator 83 | self._class_name: str = None 84 | 85 | # Indentation to use for the default write function 86 | self._outer_indent_level: int = 0 87 | self._inner_indent_level: int = 1 88 | 89 | # Base node tree we're converting 90 | self._base_node_tree: NodeTree = None 91 | 92 | # Dictionary to keep track of node tree->variable name pairs 93 | self._node_tree_vars: dict[NodeTree, str] = {} 94 | 95 | # Dictionary to keep track of node->variable name pairs 96 | self._node_vars: dict[Node, str] = {} 97 | 98 | # Dictionary to keep track of variables->usage count pairs 99 | self._used_vars: dict[str, int] = {} 100 | 101 | # Dictionary used for setting node properties 102 | self._node_infos: dict[str, NodeInfo] = {} 103 | 104 | for name in RESERVED_NAMES: 105 | self._used_vars[name] = 0 106 | 107 | # Generate socket default, min, and max values 108 | self._include_group_socket_values = True 109 | 110 | # Set dimensions of generated nodes 111 | self._should_set_dimensions = True 112 | 113 | # Indentation string (default four spaces) 114 | self._indentation = " " 115 | 116 | if bpy.app.version >= (3, 4, 0): 117 | # Set default values for hidden sockets 118 | self._set_unavailable_defaults = False 119 | 120 | def _write(self, string: str, indent_level: int = None): 121 | if indent_level is None: 122 | indent_level = self._inner_indent_level 123 | indent_str = indent_level * self._indentation 124 | self._file.write(f"{indent_str}{string}\n") 125 | 126 | def _setup_options(self, options: NTPOptions) -> bool: 127 | # General 128 | self._mode = options.mode 129 | self._include_group_socket_values = options.include_group_socket_values 130 | self._should_set_dimensions = options.set_dimensions 131 | 132 | if options.indentation_type == 'SPACES_2': 133 | self._indentation = " " 134 | elif options.indentation_type == 'SPACES_4': 135 | self._indentation = " " 136 | elif options.indentation_type == 'SPACES_8': 137 | self._indentation = " " 138 | elif options.indentation_type == 'TABS': 139 | self._indentation = "\t" 140 | 141 | if bpy.app.version >= (3, 4, 0): 142 | self._set_unavailable_defaults = options.set_unavailable_defaults 143 | 144 | #Script 145 | if options.mode == 'SCRIPT': 146 | self._include_imports = options.include_imports 147 | #Addon 148 | elif options.mode == 'ADDON': 149 | self._dir_path = bpy.path.abspath(options.dir_path) 150 | self._name_override = options.name_override 151 | self._description = options.description 152 | self._author_name = options.author_name 153 | self._version = options.version 154 | self._location = options.location 155 | self._license = options.license 156 | self._should_create_license = options.should_create_license 157 | self._category = options.category 158 | self._custom_category = options.custom_category 159 | if options.menu_id in dir(bpy.types): 160 | self._menu_id = options.menu_id 161 | else: 162 | self.report({'ERROR'}, f"{options.menu_id} is not a valid menu") 163 | return False 164 | return True 165 | 166 | def _setup_addon_directories(self, context: Context, nt_var: str) -> bool: 167 | """ 168 | Finds/creates directories to save add-on to 169 | 170 | Parameters: 171 | context (Context): the current scene context 172 | nt_var (str): variable name of the ndoe tree 173 | 174 | Returns: 175 | (bool): success of addon directory setup 176 | """ 177 | if not self._dir_path or self._dir_path == "": 178 | self.report({'ERROR'}, 179 | ("NodeToPython: No save location found. Please select " 180 | "one in the NodeToPython Options panel")) 181 | return False 182 | 183 | self._zip_dir = os.path.join(self._dir_path, nt_var) 184 | self._addon_dir = os.path.join(self._zip_dir, nt_var) 185 | 186 | if not os.path.exists(self._addon_dir): 187 | os.makedirs(self._addon_dir) 188 | 189 | return True 190 | 191 | def _create_header(self, name: str) -> None: 192 | """ 193 | Sets up the bl_info and imports the Blender API 194 | 195 | Parameters: 196 | file (TextIO): the file for the generated add-on 197 | name (str): name of the add-on 198 | """ 199 | 200 | self._write("bl_info = {", 0) 201 | self._name = name 202 | if self._name_override and self._name_override != "": 203 | self._name = self._name_override 204 | self._write(f"\"name\" : {str_to_py_str(self._name)},", 1) 205 | if self._description and self._description != "": 206 | self._write(f"\"description\" : {str_to_py_str(self._description)},", 1) 207 | self._write(f"\"author\" : {str_to_py_str(self._author_name)},", 1) 208 | self._write(f"\"version\" : {vec3_to_py_str(self._version)},", 1) 209 | self._write(f"\"blender\" : {bpy.app.version},", 1) 210 | self._write(f"\"location\" : {str_to_py_str(self._location)},", 1) 211 | category = self._category 212 | if category == "Custom": 213 | category = self._custom_category 214 | self._write(f"\"category\" : {str_to_py_str(category)},", 1) 215 | self._write("}\n", 0) 216 | self._write("import bpy", 0) 217 | self._write("import mathutils", 0) 218 | self._write("import os\n", 0) 219 | 220 | def _init_operator(self, idname: str, label: str) -> None: 221 | """ 222 | Initializes the add-on's operator 223 | 224 | Parameters: 225 | file (TextIO): the file for the generated add-on 226 | name (str): name for the class 227 | idname (str): name for the operator 228 | label (str): appearence inside Blender 229 | """ 230 | self._idname = idname 231 | self._write(f"class {self._class_name}(bpy.types.Operator):", 0) 232 | self._write("def __init__(self, *args, **kwargs):", 1) 233 | self._write("super().__init__(*args, **kwargs)\n", 2) 234 | 235 | self._write(f"bl_idname = \"node.{idname}\"", 1) 236 | self._write(f"bl_label = {str_to_py_str(label)}", 1) 237 | self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) 238 | 239 | def _topological_sort(self, node_tree: NodeTree) -> list[NodeTree]: 240 | """ 241 | Perform a topological sort on the node graph to determine dependencies 242 | and which node groups need processed first 243 | 244 | Parameters: 245 | node_tree (NodeTree): the base node tree to convert 246 | 247 | Returns: 248 | (list[NodeTree]): the node trees in order of processing 249 | """ 250 | if isinstance(node_tree, bpy.types.CompositorNodeTree): 251 | group_node_type = 'CompositorNodeGroup' 252 | elif isinstance(node_tree, bpy.types.GeometryNodeTree): 253 | group_node_type = 'GeometryNodeGroup' 254 | elif isinstance(node_tree, bpy.types.ShaderNodeTree): 255 | group_node_type = 'ShaderNodeGroup' 256 | 257 | visited = set() 258 | result: list[NodeTree] = [] 259 | 260 | def dfs(nt: NodeTree) -> None: 261 | """ 262 | Helper function to perform depth-first search on a NodeTree 263 | 264 | Parameters: 265 | nt (NodeTree): current node tree in the dependency graph 266 | """ 267 | if nt is None: 268 | self.report({'ERROR'}, "NodeToPython: Found an invalid node tree. " 269 | "Are all data blocks valid?") 270 | return 271 | if nt not in visited: 272 | visited.add(nt) 273 | for group_node in [node for node in nt.nodes 274 | if node.bl_idname == group_node_type]: 275 | if group_node.node_tree not in visited: 276 | dfs(group_node.node_tree) 277 | result.append(nt) 278 | 279 | dfs(node_tree) 280 | 281 | return result 282 | 283 | def _create_var(self, name: str) -> str: 284 | """ 285 | Creates a unique variable name for a node tree 286 | 287 | Parameters: 288 | name (str): basic string we'd like to create the variable name out of 289 | 290 | Returns: 291 | clean_name (str): variable name for the node tree 292 | """ 293 | if name == "": 294 | name = "unnamed" 295 | clean_name = clean_string(name) 296 | var = clean_name 297 | if var in self._used_vars: 298 | self._used_vars[var] += 1 299 | return f"{clean_name}_{self._used_vars[var]}" 300 | else: 301 | self._used_vars[var] = 0 302 | return clean_name 303 | 304 | def _create_node(self, node: Node, node_tree_var: str) -> str: 305 | """ 306 | Initializes a new node with location, dimension, and label info 307 | 308 | Parameters: 309 | node (Node): node to be copied 310 | node_tree_var (str): variable name for the node tree 311 | Returns: 312 | node_var (str): variable name for the node 313 | """ 314 | 315 | self._write(f"#node {node.name}") 316 | 317 | node_var = self._create_var(node.name) 318 | self._node_vars[node] = node_var 319 | 320 | idname = str_to_py_str(node.bl_idname) 321 | self._write(f"{node_var} = {node_tree_var}.nodes.new({idname})") 322 | 323 | # label 324 | if node.label: 325 | self._write(f"{node_var}.label = {str_to_py_str(node.label)}") 326 | 327 | # name 328 | self._write(f"{node_var}.name = {str_to_py_str(node.name)}") 329 | 330 | # color 331 | if node.use_custom_color: 332 | self._write(f"{node_var}.use_custom_color = True") 333 | self._write(f"{node_var}.color = {vec3_to_py_str(node.color)}") 334 | 335 | # mute 336 | if node.mute: 337 | self._write(f"{node_var}.mute = True") 338 | 339 | # hide 340 | if node.hide: 341 | self._write(f"{node_var}.hide = True") 342 | 343 | # Warning propagation 344 | if bpy.app.version >= (4, 3, 0): 345 | if node.warning_propagation != 'ALL': 346 | self._write(f"{node_var}.warning_propagation = " 347 | f"{enum_to_py_str(node.warning_propagation)}") 348 | return node_var 349 | 350 | def _set_settings_defaults(self, node: Node) -> None: 351 | """ 352 | Sets the defaults for any settings a node may have 353 | 354 | Parameters: 355 | node (Node): the node object we're copying settings from 356 | node_var (str): name of the variable we're using for the node in our add-on 357 | """ 358 | if node.bl_idname not in self._node_infos: 359 | self.report({'WARNING'}, 360 | (f"NodeToPython: couldn't find {node.bl_idname} in " 361 | f"settings. Your Blender version may not be supported")) 362 | return 363 | 364 | node_var = self._node_vars[node] 365 | 366 | node_info = self._node_infos[node.bl_idname] 367 | for attr_info in node_info.attributes_: 368 | attr_name = attr_info.name_ 369 | st = attr_info.st_ 370 | 371 | version_gte_min = bpy.app.version >= max(attr_info.min_version_, node_info.min_version_) 372 | version_lt_max = bpy.app.version < min(attr_info.max_version_, node_info.max_version_) 373 | 374 | is_version_valid = version_gte_min and version_lt_max 375 | if not hasattr(node, attr_name): 376 | if is_version_valid: 377 | self.report({'WARNING'}, 378 | f"NodeToPython: Couldn't find attribute " 379 | f"\"{attr_name}\" for node {node.name} of type " 380 | f"{node.bl_idname}") 381 | continue 382 | elif not is_version_valid: 383 | continue 384 | 385 | attr = getattr(node, attr_name, None) 386 | if attr is None: 387 | continue 388 | 389 | setting_str = f"{node_var}.{attr_name}" 390 | """ 391 | A switch statement would've been nice here, 392 | but Blender 3.0 was on Python v3.9 393 | """ 394 | if st == ST.ENUM: 395 | if attr != '': 396 | self._write(f"{setting_str} = {enum_to_py_str(attr)}") 397 | elif st == ST.ENUM_SET: 398 | self._write(f"{setting_str} = {attr}") 399 | elif st == ST.STRING: 400 | self._write(f"{setting_str} = {str_to_py_str(attr)}") 401 | elif st == ST.BOOL or st == ST.INT or st == ST.FLOAT: 402 | self._write(f"{setting_str} = {attr}") 403 | elif st == ST.VEC1: 404 | self._write(f"{setting_str} = {vec1_to_py_str(attr)}") 405 | elif st == ST.VEC2: 406 | self._write(f"{setting_str} = {vec2_to_py_str(attr)}") 407 | elif st == ST.VEC3: 408 | self._write(f"{setting_str} = {vec3_to_py_str(attr)}") 409 | elif st == ST.VEC4: 410 | self._write(f"{setting_str} = {vec4_to_py_str(attr)}") 411 | elif st == ST.COLOR: 412 | self._write(f"{setting_str} = {color_to_py_str(attr)}") 413 | elif st == ST.MATERIAL: 414 | self._set_if_in_blend_file(attr, setting_str, "materials") 415 | elif st == ST.OBJECT: 416 | self._set_if_in_blend_file(attr, setting_str, "objects") 417 | elif st == ST.COLLECTION: 418 | self._set_if_in_blend_file(attr, setting_str, "collections") 419 | elif st == ST.COLOR_RAMP: 420 | self._color_ramp_settings(node, attr_name) 421 | elif st == ST.CURVE_MAPPING: 422 | self._curve_mapping_settings(node, attr_name) 423 | elif st == ST.NODE_TREE: 424 | self._node_tree_settings(node, attr_name) 425 | elif st == ST.IMAGE: 426 | if attr is None: 427 | continue 428 | if self._addon_dir is not None: 429 | if attr.source in {'FILE', 'GENERATED', 'TILED'}: 430 | if self._save_image(attr): 431 | self._load_image(attr, f"{node_var}.{attr_name}") 432 | else: 433 | self._set_if_in_blend_file(attr, setting_str, "images") 434 | 435 | elif st == ST.IMAGE_USER: 436 | self._image_user_settings(attr, f"{node_var}.{attr_name}") 437 | elif st == ST.SIM_OUTPUT_ITEMS: 438 | self._output_zone_items(attr, f"{node_var}.{attr_name}", True) 439 | elif st == ST.REPEAT_OUTPUT_ITEMS: 440 | self._output_zone_items(attr, f"{node_var}.{attr_name}", False) 441 | elif st == ST.INDEX_SWITCH_ITEMS: 442 | self._index_switch_items(attr, f"{node_var}.{attr_name}") 443 | elif st == ST.ENUM_DEFINITION: 444 | self._enum_definition(attr, f"{node_var}.{attr_name}") 445 | elif st == ST.BAKE_ITEMS: 446 | self._bake_items(attr, f"{node_var}.{attr_name}") 447 | elif st == ST.CAPTURE_ATTRIBUTE_ITEMS: 448 | self._capture_attribute_items(attr, f"{node_var}.{attr_name}") 449 | elif st == ST.MENU_SWITCH_ITEMS: 450 | self._menu_switch_items(attr, f"{node_var}.{attr_name}") 451 | elif st == ST.FOREACH_GEO_ELEMENT_GENERATION_ITEMS: 452 | self._foreach_geo_element_generation_items(attr, f"{node_var}.{attr_name}") 453 | elif st == ST.FOREACH_GEO_ELEMENT_INPUT_ITEMS: 454 | self._foreach_geo_element_input_items(attr, f"{node_var}.{attr_name}") 455 | elif st == ST.FOREACH_GEO_ELEMENT_MAIN_ITEMS: 456 | self._foreach_geo_element_main_items(attr, f"{node_var}.{attr_name}") 457 | 458 | if bpy.app.version < (4, 0, 0): 459 | def _set_group_socket_defaults(self, socket_interface: NodeSocketInterface, 460 | socket_var: str) -> None: 461 | """ 462 | Set a node group input/output's default properties if they exist 463 | Helper function to _group_io_settings() 464 | 465 | Parameters: 466 | socket_interface (NodeSocketInterface): socket interface associated 467 | with the input/output 468 | socket_var (str): variable name for the socket 469 | """ 470 | if not self._include_group_socket_values: 471 | return 472 | 473 | if socket_interface.type not in self.default_sockets_v3: 474 | return 475 | 476 | if socket_interface.type == 'RGBA': 477 | dv = vec4_to_py_str(socket_interface.default_value) 478 | elif socket_interface.type == 'VECTOR': 479 | dv = vec3_to_py_str(socket_interface.default_value) 480 | else: 481 | dv = socket_interface.default_value 482 | self._write(f"{socket_var}.default_value = {dv}") 483 | 484 | # min value 485 | if hasattr(socket_interface, "min_value"): 486 | min_val = socket_interface.min_value 487 | self._write(f"{socket_var}.min_value = {min_val}") 488 | # max value 489 | if hasattr(socket_interface, "min_value"): 490 | max_val = socket_interface.max_value 491 | self._write(f"{socket_var}.max_value = {max_val}") 492 | 493 | def _group_io_settings(self, node: Node, 494 | io: str, # TODO: convert to enum 495 | ntp_node_tree: NTP_NodeTree) -> None: 496 | """ 497 | Set the settings for group input and output sockets 498 | 499 | Parameters: 500 | node (Node) : group input/output node 501 | io (str): whether we're generating the input or output settings 502 | ntp_node_tree (NTP_NodeTree): node tree that we're generating 503 | input and output settings for 504 | """ 505 | node_tree_var = ntp_node_tree.var 506 | node_tree = ntp_node_tree.node_tree 507 | 508 | if io == "input": 509 | io_sockets = node.outputs 510 | io_socket_interfaces = node_tree.inputs 511 | else: 512 | io_sockets = node.inputs 513 | io_socket_interfaces = node_tree.outputs 514 | 515 | self._write(f"#{node_tree_var} {io}s") 516 | for i, inout in enumerate(io_sockets): 517 | if inout.bl_idname == 'NodeSocketVirtual': 518 | continue 519 | self._write(f"#{io} {inout.name}") 520 | idname = enum_to_py_str(inout.bl_idname) 521 | name = str_to_py_str(inout.name) 522 | self._write(f"{node_tree_var}.{io}s.new({idname}, {name})") 523 | socket_interface = io_socket_interfaces[i] 524 | socket_var = f"{node_tree_var}.{io}s[{i}]" 525 | 526 | self._set_group_socket_defaults(socket_interface, socket_var) 527 | 528 | # default attribute name 529 | if hasattr(socket_interface, "default_attribute_name"): 530 | if socket_interface.default_attribute_name != "": 531 | dan = str_to_py_str(socket_interface.default_attribute_name) 532 | self._write(f"{socket_var}.default_attribute_name = {dan}") 533 | 534 | # attribute domain 535 | if hasattr(socket_interface, "attribute_domain"): 536 | ad = enum_to_py_str(socket_interface.attribute_domain) 537 | self._write(f"{socket_var}.attribute_domain = {ad}") 538 | 539 | # tooltip 540 | if socket_interface.description != "": 541 | description = str_to_py_str(socket_interface.description) 542 | self._write(f"{socket_var}.description = {description}") 543 | 544 | # hide_value 545 | if socket_interface.hide_value is True: 546 | self._write(f"{socket_var}.hide_value = True") 547 | 548 | # hide in modifier 549 | if hasattr(socket_interface, "hide_in_modifier"): 550 | if socket_interface.hide_in_modifier is True: 551 | self._write(f"{socket_var}.hide_in_modifier = True") 552 | 553 | self._write("", 0) 554 | self._write("", 0) 555 | 556 | elif bpy.app.version >= (4, 0, 0): 557 | def _set_tree_socket_defaults(self, socket_interface: NodeTreeInterfaceSocket, 558 | socket_var: str) -> None: 559 | """ 560 | Set a node tree input/output's default properties if they exist 561 | 562 | Helper function to _create_socket() 563 | 564 | Parameters: 565 | socket_interface (NodeTreeInterfaceSocket): socket interface associated 566 | with the input/output 567 | socket_var (str): variable name for the socket 568 | """ 569 | if not self._include_group_socket_values: 570 | return 571 | if type(socket_interface) in self.nondefault_sockets_v4: 572 | return 573 | 574 | dv = socket_interface.default_value 575 | 576 | if bpy.app.version >= (4, 1, 0): 577 | if type(socket_interface) is bpy.types.NodeTreeInterfaceSocketMenu: 578 | if dv == "": 579 | self.report({'WARNING'}, 580 | "NodeToPython: No menu found for socket " 581 | f"{socket_interface.name}" 582 | ) 583 | return 584 | 585 | self._write_after_links.append( 586 | lambda _socket_var=socket_var, _dv=enum_to_py_str(dv): ( 587 | self._write(f"{_socket_var}.default_value = {_dv}") 588 | ) 589 | ) 590 | return 591 | if type(socket_interface) == bpy.types.NodeTreeInterfaceSocketColor: 592 | dv = vec4_to_py_str(dv) 593 | elif type(dv) in {mathutils.Vector, mathutils.Euler}: 594 | dv = vec3_to_py_str(dv) 595 | elif type(dv) == bpy_prop_array: 596 | dv = array_to_py_str(dv) 597 | elif type(dv) == str: 598 | dv = str_to_py_str(dv) 599 | self._write(f"{socket_var}.default_value = {dv}") 600 | 601 | # min value 602 | if hasattr(socket_interface, "min_value"): 603 | min_val = socket_interface.min_value 604 | self._write(f"{socket_var}.min_value = {min_val}") 605 | # max value 606 | if hasattr(socket_interface, "min_value"): 607 | max_val = socket_interface.max_value 608 | self._write(f"{socket_var}.max_value = {max_val}") 609 | 610 | def _create_socket(self, socket: NodeTreeInterfaceSocket, 611 | parent: NodeTreeInterfacePanel, 612 | panel_dict: dict[NodeTreeInterfacePanel, str], 613 | ntp_nt: NTP_NodeTree) -> None: 614 | """ 615 | Initialize a new tree socket 616 | 617 | Helper function to _process_items() 618 | 619 | Parameters: 620 | socket (NodeTreeInterfaceSocket): the socket to recreate 621 | parent (NodeTreeInterfacePanel): parent panel of the socket 622 | (possibly None) 623 | panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable 624 | ntp_nt (NTP_NodeTree): owner of the socket 625 | """ 626 | 627 | self._write(f"#Socket {socket.name}") 628 | # initialization 629 | socket_var = self._create_var(socket.name + "_socket") 630 | name = str_to_py_str(socket.name) 631 | in_out_enum = enum_to_py_str(socket.in_out) 632 | 633 | socket_type = enum_to_py_str(socket.bl_socket_idname) 634 | """ 635 | I might be missing something, but the Python API's set up a bit 636 | weird here now. The new socket initialization only accepts types 637 | from a list of basic ones, but there doesn't seem to be a way of 638 | retrieving just this basic type without the subtype information. 639 | """ 640 | if 'Float' in socket_type: 641 | socket_type = enum_to_py_str('NodeSocketFloat') 642 | elif 'Int' in socket_type: 643 | socket_type = enum_to_py_str('NodeSocketInt') 644 | elif 'Vector' in socket_type: 645 | socket_type = enum_to_py_str('NodeSocketVector') 646 | 647 | if parent is None: 648 | optional_parent_str = "" 649 | else: 650 | optional_parent_str = f", parent = {panel_dict[parent]}" 651 | 652 | self._write(f"{socket_var} = " 653 | f"{ntp_nt.var}.interface.new_socket(" 654 | f"name = {name}, in_out={in_out_enum}, " 655 | f"socket_type = {socket_type}" 656 | f"{optional_parent_str})") 657 | 658 | self._set_tree_socket_defaults(socket, socket_var) 659 | 660 | # subtype 661 | if hasattr(socket, "subtype"): 662 | if socket.subtype != '': 663 | subtype = enum_to_py_str(socket.subtype) 664 | self._write(f"{socket_var}.subtype = {subtype}") 665 | 666 | # default attribute name 667 | if socket.default_attribute_name != "": 668 | dan = str_to_py_str( 669 | socket.default_attribute_name) 670 | self._write(f"{socket_var}.default_attribute_name = {dan}") 671 | 672 | # attribute domain 673 | ad = enum_to_py_str(socket.attribute_domain) 674 | self._write(f"{socket_var}.attribute_domain = {ad}") 675 | 676 | # hide_value 677 | if socket.hide_value is True: 678 | self._write(f"{socket_var}.hide_value = True") 679 | 680 | # hide in modifier 681 | if socket.hide_in_modifier is True: 682 | self._write(f"{socket_var}.hide_in_modifier = True") 683 | 684 | # force non field 685 | if socket.force_non_field is True: 686 | self._write(f"{socket_var}.force_non_field = True") 687 | 688 | # tooltip 689 | if socket.description != "": 690 | description = str_to_py_str(socket.description) 691 | self._write(f"{socket_var}.description = {description}") 692 | 693 | self._write("", 0) 694 | 695 | def _create_panel(self, panel: NodeTreeInterfacePanel, 696 | panel_dict: dict[NodeTreeInterfacePanel], 697 | items_processed: set[NodeTreeInterfacePanel], 698 | parent: NodeTreeInterfacePanel, ntp_nt: NTP_NodeTree): 699 | """ 700 | Initialize a new tree panel and its subitems 701 | 702 | Helper function to _process_items() 703 | 704 | Parameters: 705 | panel (NodeTreeInterfacePanel): the panel to recreate 706 | panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable 707 | items_processed (set[NodeTreeInterfacePanel]): set of already 708 | processed items, so none are done twice 709 | parent (NodeTreeInterfacePanel): parent panel of the socket 710 | (possibly None) 711 | ntp_nt (NTP_NodeTree): owner of the socket 712 | """ 713 | 714 | self._write(f"#Panel {panel.name}") 715 | 716 | panel_var = self._create_var(panel.name + "_panel") 717 | panel_dict[panel] = panel_var 718 | 719 | closed_str = "" 720 | if panel.default_closed is True: 721 | closed_str = f", default_closed=True" 722 | 723 | parent_str = "" 724 | if parent is not None and bpy.app.version < (4, 2, 0): 725 | parent_str = f", parent = {panel_dict[parent]}" 726 | 727 | self._write(f"{panel_var} = " 728 | f"{ntp_nt.var}.interface.new_panel(" 729 | f"{str_to_py_str(panel.name)}" 730 | f"{closed_str}{parent_str})") 731 | 732 | # tooltip 733 | if panel.description != "": 734 | description = str_to_py_str(panel.description) 735 | self._write(f"{panel_var}.description = {description}") 736 | 737 | panel_dict[panel] = panel_var 738 | 739 | if len(panel.interface_items) > 0: 740 | self._process_items(panel, panel_dict, items_processed, ntp_nt) 741 | 742 | self._write("", 0) 743 | 744 | def _process_items(self, parent: NodeTreeInterfacePanel, 745 | panel_dict: dict[NodeTreeInterfacePanel], 746 | items_processed: set[NodeTreeInterfacePanel], 747 | ntp_nt: NTP_NodeTree) -> None: 748 | """ 749 | Recursive function to process all node tree interface items in a 750 | given layer 751 | 752 | Helper function to _tree_interface_settings() 753 | 754 | Parameters: 755 | parent (NodeTreeInterfacePanel): parent panel of the layer 756 | (possibly None to signify the base) 757 | panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable 758 | items_processed (set[NodeTreeInterfacePanel]): set of already 759 | processed items, so none are done twice 760 | ntp_nt (NTP_NodeTree): owner of the socket 761 | """ 762 | 763 | if parent is None: 764 | items = ntp_nt.node_tree.interface.items_tree 765 | else: 766 | items = parent.interface_items 767 | 768 | for item in items: 769 | if item.parent.index != -1 and item.parent not in panel_dict: 770 | continue # child of panel not processed yet 771 | if item in items_processed: 772 | continue 773 | 774 | items_processed.add(item) 775 | 776 | if item.item_type == 'SOCKET': 777 | self._create_socket(item, parent, panel_dict, ntp_nt) 778 | 779 | elif item.item_type == 'PANEL': 780 | self._create_panel(item, panel_dict, items_processed, 781 | parent, ntp_nt) 782 | if bpy.app.version >= (4, 4, 0) and parent is not None: 783 | nt_var = self._node_tree_vars[ntp_nt.node_tree] 784 | interface_var = f"{nt_var}.interface" 785 | panel_var = panel_dict[item] 786 | parent_var = panel_dict[parent] 787 | self._write(f"{interface_var}.move_to_parent(" 788 | f"{panel_var}, {parent_var}, {item.index})") 789 | 790 | 791 | def _tree_interface_settings(self, ntp_nt: NTP_NodeTree) -> None: 792 | """ 793 | Set the settings for group input and output sockets 794 | 795 | Parameters: 796 | ntp_nt (NTP_NodeTree): the node tree to set the interface for 797 | """ 798 | 799 | self._write(f"#{ntp_nt.var} interface") 800 | panel_dict: dict[NodeTreeInterfacePanel, str] = {} 801 | items_processed: set[NodeTreeInterfaceItem] = set() 802 | 803 | self._process_items(None, panel_dict, items_processed, ntp_nt) 804 | 805 | self._write("", 0) 806 | 807 | def _set_input_defaults(self, node: Node) -> None: 808 | """ 809 | Sets defaults for input sockets 810 | 811 | Parameters: 812 | node (Node): node we're setting inputs for 813 | """ 814 | if node.bl_idname == 'NodeReroute': 815 | return 816 | 817 | node_var = self._node_vars[node] 818 | 819 | for i, input in enumerate(node.inputs): 820 | if input.bl_idname not in DONT_SET_DEFAULTS and not input.is_linked: 821 | if bpy.app.version >= (3, 4, 0): 822 | if (not self._set_unavailable_defaults) and input.is_unavailable: 823 | continue 824 | 825 | # TODO: this could be cleaner 826 | socket_var = f"{node_var}.inputs[{i}]" 827 | 828 | # colors 829 | if input.bl_idname == 'NodeSocketColor': 830 | default_val = vec4_to_py_str(input.default_value) 831 | 832 | # vector types 833 | elif "Vector" in input.bl_idname: 834 | default_val = vec3_to_py_str(input.default_value) 835 | 836 | # rotation types 837 | elif input.bl_idname == 'NodeSocketRotation': 838 | default_val = vec3_to_py_str(input.default_value) 839 | 840 | # strings 841 | elif input.bl_idname == 'NodeSocketString': 842 | default_val = str_to_py_str(input.default_value) 843 | 844 | #menu 845 | elif input.bl_idname == 'NodeSocketMenu': 846 | if input.default_value == '': 847 | continue 848 | default_val = enum_to_py_str(input.default_value) 849 | 850 | # images 851 | elif input.bl_idname == 'NodeSocketImage': 852 | img = input.default_value 853 | if img is not None: 854 | if self._addon_dir != None: # write in a better way 855 | if self._save_image(img): 856 | self._load_image(img, f"{socket_var}.default_value") 857 | else: 858 | self._in_file_inputs(input, socket_var, "images") 859 | default_val = None 860 | 861 | # materials 862 | elif input.bl_idname == 'NodeSocketMaterial': 863 | self._in_file_inputs(input, socket_var, "materials") 864 | default_val = None 865 | 866 | # collections 867 | elif input.bl_idname == 'NodeSocketCollection': 868 | self._in_file_inputs(input, socket_var, "collections") 869 | default_val = None 870 | 871 | # objects 872 | elif input.bl_idname == 'NodeSocketObject': 873 | self._in_file_inputs(input, socket_var, "objects") 874 | default_val = None 875 | 876 | # textures 877 | elif input.bl_idname == 'NodeSocketTexture': 878 | self._in_file_inputs(input, socket_var, "textures") 879 | default_val = None 880 | 881 | else: 882 | default_val = input.default_value 883 | if default_val is not None: 884 | self._write(f"#{input.identifier}") 885 | self._write(f"{socket_var}.default_value = {default_val}") 886 | self._write("", 0) 887 | 888 | def _set_output_defaults(self, node: Node) -> None: 889 | """ 890 | Some output sockets need default values set. It's rather annoying 891 | 892 | Parameters: 893 | node (Node): node for the output we're setting 894 | """ 895 | # TODO: probably should define elsewhere 896 | output_default_nodes = {'ShaderNodeValue', 897 | 'ShaderNodeRGB', 898 | 'ShaderNodeNormal', 899 | 'CompositorNodeValue', 900 | 'CompositorNodeRGB', 901 | 'CompositorNodeNormal'} 902 | 903 | if node.bl_idname not in output_default_nodes: 904 | return 905 | 906 | node_var = self._node_vars[node] 907 | 908 | dv = node.outputs[0].default_value 909 | if node.bl_idname in {'ShaderNodeRGB', 'CompositorNodeRGB'}: 910 | dv = vec4_to_py_str(list(dv)) 911 | if node.bl_idname in {'ShaderNodeNormal', 'CompositorNodeNormal'}: 912 | dv = vec3_to_py_str(dv) 913 | self._write(f"{node_var}.outputs[0].default_value = {dv}") 914 | 915 | def _in_file_inputs(self, input: bpy.types.NodeSocket, socket_var: str, 916 | type: str) -> None: 917 | """ 918 | Sets inputs for a node input if one already exists in the blend file 919 | 920 | Parameters: 921 | input (bpy.types.NodeSocket): input socket we're setting the value for 922 | socket_var (str): variable name we're using for the socket 923 | type (str): from what section of bpy.data to pull the default value from 924 | """ 925 | 926 | if input.default_value is None: 927 | return 928 | name = str_to_py_str(input.default_value.name) 929 | self._write(f"if {name} in bpy.data.{type}:") 930 | self._write(f"{socket_var}.default_value = bpy.data.{type}[{name}]", 931 | self._inner_indent_level + 1) 932 | 933 | def _set_socket_defaults(self, node: Node): 934 | """ 935 | Set input and output socket defaults 936 | """ 937 | self._set_input_defaults(node) 938 | self._set_output_defaults(node) 939 | 940 | def _set_if_in_blend_file(self, attr, setting_str: str, data_type: str 941 | ) -> None: 942 | """ 943 | Attempts to grab referenced thing from blend file 944 | """ 945 | name = str_to_py_str(attr.name) 946 | self._write(f"if {name} in bpy.data.{data_type}:") 947 | self._write(f"{setting_str} = bpy.data.{data_type}[{name}]", 948 | self._inner_indent_level + 1) 949 | 950 | def _color_ramp_settings(self, node: Node, color_ramp_name: str) -> None: 951 | """ 952 | Replicate a color ramp node 953 | 954 | Parameters 955 | node (Node): node object we're copying settings from 956 | color_ramp_name (str): name of the color ramp to be copied 957 | """ 958 | 959 | color_ramp: bpy.types.ColorRamp = getattr(node, color_ramp_name) 960 | if not color_ramp: 961 | raise ValueError(f"No color ramp named \"{color_ramp_name}\" found") 962 | 963 | node_var = self._node_vars[node] 964 | 965 | # settings 966 | ramp_str = f"{node_var}.{color_ramp_name}" 967 | 968 | #color mode 969 | color_mode = enum_to_py_str(color_ramp.color_mode) 970 | self._write(f"{ramp_str}.color_mode = {color_mode}") 971 | 972 | #hue interpolation 973 | hue_interpolation = enum_to_py_str(color_ramp.hue_interpolation) 974 | self._write(f"{ramp_str}.hue_interpolation = {hue_interpolation}") 975 | 976 | #interpolation 977 | interpolation = enum_to_py_str(color_ramp.interpolation) 978 | self._write(f"{ramp_str}.interpolation = {interpolation}") 979 | self._write("", 0) 980 | 981 | # key points 982 | self._write(f"#initialize color ramp elements") 983 | self._write((f"{ramp_str}.elements.remove" 984 | f"({ramp_str}.elements[0])")) 985 | for i, element in enumerate(color_ramp.elements): 986 | element_var = self._create_var(f"{node_var}_cre_{i}") 987 | if i == 0: 988 | self._write(f"{element_var} = {ramp_str}.elements[{i}]") 989 | self._write(f"{element_var}.position = {element.position}") 990 | else: 991 | self._write(f"{element_var} = {ramp_str}.elements" 992 | f".new({element.position})") 993 | 994 | self._write(f"{element_var}.alpha = {element.alpha}") 995 | color_str = vec4_to_py_str(element.color) 996 | self._write(f"{element_var}.color = {color_str}\n") 997 | 998 | def _curve_mapping_settings(self, node: Node, 999 | curve_mapping_name: str) -> None: 1000 | """ 1001 | Sets defaults for Float, Vector, and Color curves 1002 | 1003 | Parameters: 1004 | node (Node): curve node we're copying settings from 1005 | curve_mapping_name (str): name of the curve mapping to be set 1006 | """ 1007 | 1008 | mapping = getattr(node, curve_mapping_name) 1009 | if not mapping: 1010 | raise ValueError((f"Curve mapping \"{curve_mapping_name}\" not found " 1011 | f"in node \"{node.bl_idname}\"")) 1012 | 1013 | node_var = self._node_vars[node] 1014 | 1015 | # mapping settings 1016 | self._write(f"#mapping settings") 1017 | mapping_var = f"{node_var}.{curve_mapping_name}" 1018 | 1019 | # extend 1020 | extend = enum_to_py_str(mapping.extend) 1021 | self._write(f"{mapping_var}.extend = {extend}") 1022 | # tone 1023 | tone = enum_to_py_str(mapping.tone) 1024 | self._write(f"{mapping_var}.tone = {tone}") 1025 | 1026 | # black level 1027 | b_lvl_str = vec3_to_py_str(mapping.black_level) 1028 | self._write(f"{mapping_var}.black_level = {b_lvl_str}") 1029 | # white level 1030 | w_lvl_str = vec3_to_py_str(mapping.white_level) 1031 | self._write(f"{mapping_var}.white_level = {w_lvl_str}") 1032 | 1033 | # minima and maxima 1034 | min_x = mapping.clip_min_x 1035 | self._write(f"{mapping_var}.clip_min_x = {min_x}") 1036 | min_y = mapping.clip_min_y 1037 | self._write(f"{mapping_var}.clip_min_y = {min_y}") 1038 | max_x = mapping.clip_max_x 1039 | self._write(f"{mapping_var}.clip_max_x = {max_x}") 1040 | max_y = mapping.clip_max_y 1041 | self._write(f"{mapping_var}.clip_max_y = {max_y}") 1042 | 1043 | # use_clip 1044 | use_clip = mapping.use_clip 1045 | self._write(f"{mapping_var}.use_clip = {use_clip}") 1046 | 1047 | # create curves 1048 | for i, curve in enumerate(mapping.curves): 1049 | self._create_curve_map(node, i, curve, curve_mapping_name) 1050 | 1051 | # update curve 1052 | self._write(f"#update curve after changes") 1053 | self._write(f"{mapping_var}.update()") 1054 | 1055 | def _create_curve_map(self, node: Node, i: int, curve: bpy.types.CurveMap, 1056 | curve_mapping_name: str) -> None: 1057 | """ 1058 | Helper function to create the ith curve of a node's curve mapping 1059 | 1060 | Parameters: 1061 | node (Node): the node with a curve mapping 1062 | i (int): index of the CurveMap within the mapping 1063 | curve (bpy.types.CurveMap): the curve map to recreate 1064 | curve_mapping_name (str): attribute name of the recreated curve mapping 1065 | """ 1066 | node_var = self._node_vars[node] 1067 | 1068 | self._write(f"#curve {i}") 1069 | curve_i_var = self._create_var(f"{node_var}_curve_{i}") 1070 | self._write(f"{curve_i_var} = " 1071 | f"{node_var}.{curve_mapping_name}.curves[{i}]") 1072 | 1073 | # Remove default points when CurveMap is initialized with more than 1074 | # two points (just CompositorNodeHueCorrect) 1075 | if (node.bl_idname == 'CompositorNodeHueCorrect'): 1076 | self._write(f"for {INDEX} in range" 1077 | f"(len({curve_i_var}.points.values()) - 1, 1, -1):") 1078 | self._write(f"{curve_i_var}.points.remove(" 1079 | f"{curve_i_var}.points[{INDEX}])", 1080 | self._inner_indent_level + 1) 1081 | 1082 | for j, point in enumerate(curve.points): 1083 | self._create_curve_map_point(j, point, curve_i_var) 1084 | 1085 | def _create_curve_map_point(self, j: int, point: bpy.types.CurveMapPoint, 1086 | curve_i_var: str) -> None: 1087 | """ 1088 | Helper function to recreate a curve map point 1089 | 1090 | Parameters: 1091 | j (int): index of the point within the curve map 1092 | point (CurveMapPoint): point to recreate 1093 | curve_i_var (str): variable name of the point's curve map 1094 | """ 1095 | point_j_var = self._create_var(f"{curve_i_var}_point_{j}") 1096 | 1097 | loc = point.location 1098 | loc_str = f"{loc[0]}, {loc[1]}" 1099 | if j < 2: 1100 | self._write(f"{point_j_var} = {curve_i_var}.points[{j}]") 1101 | self._write(f"{point_j_var}.location = ({loc_str})") 1102 | else: 1103 | self._write(f"{point_j_var} = {curve_i_var}.points.new({loc_str})") 1104 | 1105 | handle = enum_to_py_str(point.handle_type) 1106 | self._write(f"{point_j_var}.handle_type = {handle}") 1107 | 1108 | def _node_tree_settings(self, node: Node, attr_name: str) -> None: 1109 | """ 1110 | Processes node tree of group node if one is present 1111 | 1112 | Parameters: 1113 | node (Node): the group node 1114 | attr_name (str): name of the node tree attribute 1115 | """ 1116 | node_tree = getattr(node, attr_name) 1117 | if node_tree is None: 1118 | return 1119 | if node_tree in self._node_tree_vars: 1120 | nt_var = self._node_tree_vars[node_tree] 1121 | node_var = self._node_vars[node] 1122 | self._write(f"{node_var}.{attr_name} = {nt_var}") 1123 | else: 1124 | self.report({'WARNING'}, (f"NodeToPython: Node tree dependency graph " 1125 | f"wasn't properly initialized")) 1126 | 1127 | def _save_image(self, img: bpy.types.Image) -> bool: 1128 | """ 1129 | Saves an image to an image directory of the add-on 1130 | 1131 | Parameters: 1132 | img (bpy.types.Image): image to be saved 1133 | """ 1134 | 1135 | if img is None: 1136 | return False 1137 | 1138 | img_str = img_to_py_str(img) 1139 | 1140 | if not img.has_data: 1141 | self.report({'WARNING'}, f"{img_str} has no data") 1142 | return False 1143 | 1144 | # create image dir if one doesn't exist 1145 | img_dir = os.path.join(self._addon_dir, IMAGE_DIR_NAME) 1146 | if not os.path.exists(img_dir): 1147 | os.mkdir(img_dir) 1148 | 1149 | # save the image 1150 | 1151 | img_path = f"{img_dir}/{img_str}" 1152 | if not os.path.exists(img_path): 1153 | img.save_render(img_path) 1154 | return True 1155 | 1156 | def _load_image(self, img: bpy.types.Image, img_var: str) -> None: 1157 | """ 1158 | Loads an image from the add-on into a blend file and assigns it 1159 | 1160 | Parameters: 1161 | img (bpy.types.Image): Blender image from the original node group 1162 | img_var (str): variable name to be used for the image 1163 | """ 1164 | 1165 | if img is None: 1166 | return 1167 | 1168 | img_str = img_to_py_str(img) 1169 | 1170 | # TODO: convert to special variables 1171 | self._write(f"#load image {img_str}") 1172 | self._write(f"{BASE_DIR} = " 1173 | f"os.path.dirname(os.path.abspath(__file__))") 1174 | self._write(f"{IMAGE_PATH} = " 1175 | f"os.path.join({BASE_DIR}, {str_to_py_str(IMAGE_DIR_NAME)}, " 1176 | f"{str_to_py_str(img_str)})") 1177 | self._write(f"{img_var} = bpy.data.images.load" 1178 | f"({IMAGE_PATH}, check_existing = True)") 1179 | 1180 | # copy image settings 1181 | self._write(f"#set image settings") 1182 | 1183 | # source 1184 | source = enum_to_py_str(img.source) 1185 | self._write(f"{img_var}.source = {source}") 1186 | 1187 | # color space settings 1188 | color_space = enum_to_py_str(img.colorspace_settings.name) 1189 | self._write(f"{img_var}.colorspace_settings.name = {color_space}") 1190 | 1191 | # alpha mode 1192 | alpha_mode = enum_to_py_str(img.alpha_mode) 1193 | self._write(f"{img_var}.alpha_mode = {alpha_mode}") 1194 | 1195 | def _image_user_settings(self, img_user: bpy.types.ImageUser, 1196 | img_user_var: str) -> None: 1197 | """ 1198 | Replicate the image user of an image node 1199 | 1200 | Parameters 1201 | img_usr (bpy.types.ImageUser): image user to be copied 1202 | img_usr_var (str): variable name for the generated image user 1203 | """ 1204 | 1205 | img_usr_attrs = ["frame_current", "frame_duration", "frame_offset", 1206 | "frame_start", "tile", "use_auto_refresh", "use_cyclic"] 1207 | 1208 | for img_usr_attr in img_usr_attrs: 1209 | self._write(f"{img_user_var}.{img_usr_attr} = " 1210 | f"{getattr(img_user, img_usr_attr)}") 1211 | 1212 | if bpy.app.version >= (3, 6, 0): 1213 | def _output_zone_items(self, output_items, items_str: str, 1214 | is_sim: bool) -> None: 1215 | """ 1216 | Set items for a zone's output 1217 | 1218 | output_items (NodeGeometry(Simulation/Repeat)OutputItems): items 1219 | to copy 1220 | items_str (str): 1221 | """ 1222 | self._write(f"{items_str}.clear()") 1223 | for i, item in enumerate(output_items): 1224 | socket_type = enum_to_py_str(item.socket_type) 1225 | name = str_to_py_str(item.name) 1226 | self._write(f"# Create item {name}") 1227 | self._write(f"{items_str}.new({socket_type}, {name})") 1228 | 1229 | if is_sim: 1230 | item_var = f"{items_str}[{i}]" 1231 | ad = enum_to_py_str(item.attribute_domain) 1232 | self._write(f"{item_var}.attribute_domain = {ad}") 1233 | 1234 | if bpy.app.version >= (4, 1, 0): 1235 | def _index_switch_items(self, switch_items: bpy.types.NodeIndexSwitchItems, 1236 | items_str: str) -> None: 1237 | """ 1238 | Set the proper amount of index switch items 1239 | 1240 | Parameters: 1241 | switch_items (bpy.types.NodeIndexSwitchItems): switch items to copy 1242 | items_str (str): string for the generated switch items attribute 1243 | """ 1244 | num_items = len(switch_items) 1245 | self._write(f"{items_str}.clear()") 1246 | for i in range(num_items): 1247 | self._write(f"{items_str}.new()") 1248 | 1249 | def _bake_items(self, bake_items: bpy.types.NodeGeometryBakeItems, 1250 | bake_items_str: str) -> None: 1251 | """ 1252 | Set bake items for a node 1253 | 1254 | Parameters: 1255 | bake_items (bpy.types.NodeGeometryBakeItems): bake items to replicate 1256 | bake_items_str (str): string for the generated bake items 1257 | """ 1258 | self._write(f"{bake_items_str}.clear()") 1259 | for i, bake_item in enumerate(bake_items): 1260 | socket_type = enum_to_py_str(bake_item.socket_type) 1261 | name = str_to_py_str(bake_item.name) 1262 | self._write(f"{bake_items_str}.new({socket_type}, {name})") 1263 | 1264 | ad = enum_to_py_str(bake_item.attribute_domain) 1265 | self._write(f"{bake_items_str}[{i}].attribute_domain = {ad}") 1266 | 1267 | if bake_item.is_attribute: 1268 | self._write(f"{bake_items_str}[{i}].is_attribute = True") 1269 | 1270 | if bpy.app.version >= (4, 1, 0) and bpy.app.version < (4, 2, 0): 1271 | def _enum_definition(self, enum_def: bpy.types.NodeEnumDefinition, 1272 | enum_def_str: str) -> None: 1273 | """ 1274 | Set enum definition item for a node 1275 | 1276 | Parameters: 1277 | enum_def (bpy.types.NodeEnumDefinition): enum definition to replicate 1278 | enum_def_str (str): string for the generated enum definition 1279 | """ 1280 | self._write(f"{enum_def_str}.enum_items.clear()") 1281 | for i, enum_item in enumerate(enum_def.enum_items): 1282 | name = str_to_py_str(enum_item.name) 1283 | self._write(f"{enum_def_str}.enum_items.new({name})") 1284 | if enum_item.description != "": 1285 | self._write(f"{enum_def_str}.enum_items[{i}].description = " 1286 | f"{str_to_py_str(enum_item.description)}") 1287 | 1288 | if bpy.app.version >= (4, 2, 0): 1289 | def _capture_attribute_items(self, capture_attribute_items: bpy.types.NodeGeometryCaptureAttributeItems, capture_attrs_str: str) -> None: 1290 | """ 1291 | Sets capture attribute items 1292 | """ 1293 | self._write(f"{capture_attrs_str}.clear()") 1294 | for item in capture_attribute_items: 1295 | name = str_to_py_str(item.name) 1296 | self._write(f"{capture_attrs_str}.new('FLOAT', {name})") 1297 | 1298 | # Need to initialize capture attribute item with a socket, 1299 | # which has a slightly different enum to the attribute type 1300 | data_type = enum_to_py_str(item.data_type) 1301 | self._write(f"{capture_attrs_str}[{name}].data_type = {data_type}") 1302 | 1303 | def _menu_switch_items(self, menu_switch_items: bpy.types.NodeMenuSwitchItems, menu_switch_items_str: str) -> None: 1304 | self._write(f"{menu_switch_items_str}.clear()") 1305 | for i, item in enumerate(menu_switch_items): 1306 | name_str = str_to_py_str(item.name) 1307 | self._write(f"{menu_switch_items_str}.new({name_str})") 1308 | desc_str = str_to_py_str(item.description) 1309 | self._write(f"{menu_switch_items_str}[{i}].description = {desc_str}") 1310 | 1311 | if bpy.app.version >= (4, 3, 0): 1312 | def _foreach_geo_element_generation_items(self, 1313 | generation_items: bpy.types.NodeGeometryForeachGeometryElementGenerationItems, 1314 | generation_items_str: str 1315 | ) -> None: 1316 | self._write(f"{generation_items_str}.clear()") 1317 | for i, item in enumerate(generation_items): 1318 | socket_type = enum_to_py_str(item.socket_type) 1319 | name_str = str_to_py_str(item.name) 1320 | self._write(f"{generation_items_str}.new({socket_type}, {name_str})") 1321 | 1322 | item_str = f"{generation_items_str}[{i}]" 1323 | 1324 | ad = enum_to_py_str(item.domain) 1325 | self._write(f"{item_str}.domain = {ad}") 1326 | 1327 | def _foreach_geo_element_input_items(self, 1328 | input_items: bpy.types.NodeGeometryForeachGeometryElementInputItems, 1329 | input_items_str: str 1330 | ) -> None: 1331 | self._write(f"{input_items_str}.clear()") 1332 | for i, item in enumerate(input_items): 1333 | socket_type = enum_to_py_str(item.socket_type) 1334 | name_str = str_to_py_str(item.name) 1335 | self._write(f"{input_items_str}.new({socket_type}, {name_str})") 1336 | 1337 | def _foreach_geo_element_main_items(self, 1338 | main_items: bpy.types.NodeGeometryForeachGeometryElementMainItems, 1339 | main_items_str: str 1340 | ) -> None: 1341 | self._write(f"{main_items_str}.clear()") 1342 | for i, item in enumerate(main_items): 1343 | socket_type = enum_to_py_str(item.socket_type) 1344 | name_str = str_to_py_str(item.name) 1345 | self._write(f"{main_items_str}.new({socket_type}, {name_str})") 1346 | 1347 | 1348 | def _set_parents(self, node_tree: NodeTree) -> None: 1349 | """ 1350 | Sets parents for all nodes, mostly used to put nodes in frames 1351 | 1352 | Parameters: 1353 | node_tree (NodeTree): node tree we're obtaining nodes from 1354 | """ 1355 | parent_comment = False 1356 | for node in node_tree.nodes: 1357 | if node is not None and node.parent is not None: 1358 | if not parent_comment: 1359 | self._write(f"#Set parents") 1360 | parent_comment = True 1361 | node_var = self._node_vars[node] 1362 | parent_var = self._node_vars[node.parent] 1363 | self._write(f"{node_var}.parent = {parent_var}") 1364 | self._write("", 0) 1365 | 1366 | def _set_locations(self, node_tree: NodeTree) -> None: 1367 | """ 1368 | Set locations for all nodes 1369 | 1370 | Parameters: 1371 | node_tree (NodeTree): node tree we're obtaining nodes from 1372 | """ 1373 | 1374 | self._write(f"#Set locations") 1375 | for node in node_tree.nodes: 1376 | node_var = self._node_vars[node] 1377 | self._write(f"{node_var}.location " 1378 | f"= ({node.location.x}, {node.location.y})") 1379 | self._write("", 0) 1380 | 1381 | def _set_dimensions(self, node_tree: NodeTree) -> None: 1382 | """ 1383 | Set dimensions for all nodes 1384 | 1385 | Parameters: 1386 | node_tree (NodeTree): node tree we're obtaining nodes from 1387 | """ 1388 | if not self._should_set_dimensions: 1389 | return 1390 | 1391 | self._write(f"#Set dimensions") 1392 | for node in node_tree.nodes: 1393 | node_var = self._node_vars[node] 1394 | self._write(f"{node_var}.width, {node_var}.height " 1395 | f"= {node.width}, {node.height}") 1396 | self._write("", 0) 1397 | 1398 | def _init_links(self, node_tree: NodeTree) -> None: 1399 | """ 1400 | Create all the links between nodes 1401 | 1402 | Parameters: 1403 | node_tree (NodeTree): node tree to copy, with variable 1404 | """ 1405 | 1406 | nt_var = self._node_tree_vars[node_tree] 1407 | 1408 | links = node_tree.links 1409 | if links: 1410 | self._write(f"#initialize {nt_var} links") 1411 | if hasattr(links[0], "multi_input_sort_id"): 1412 | # generate links in the correct order for multi input sockets 1413 | links = sorted(links, key=lambda link: link.multi_input_sort_id) 1414 | 1415 | for link in links: 1416 | in_node_var = self._node_vars[link.from_node] 1417 | input_socket = link.from_socket 1418 | 1419 | """ 1420 | Blender's socket dictionary doesn't guarantee 1421 | unique keys, which has caused much wailing and 1422 | gnashing of teeth. This is a quick fix that 1423 | doesn't run quick 1424 | """ 1425 | # TODO: try using index() method 1426 | for i, item in enumerate(link.from_node.outputs.items()): 1427 | if item[1] == input_socket: 1428 | input_idx = i 1429 | break 1430 | 1431 | out_node_var = self._node_vars[link.to_node] 1432 | output_socket = link.to_socket 1433 | 1434 | for i, item in enumerate(link.to_node.inputs.items()): 1435 | if item[1] == output_socket: 1436 | output_idx = i 1437 | break 1438 | 1439 | self._write(f"#{in_node_var}.{input_socket.name} " 1440 | f"-> {out_node_var}.{output_socket.name}") 1441 | self._write(f"{nt_var}.links.new({in_node_var}" 1442 | f".outputs[{input_idx}], " 1443 | f"{out_node_var}.inputs[{output_idx}])") 1444 | 1445 | for _func in self._write_after_links: 1446 | _func() 1447 | self._write_after_links = [] 1448 | 1449 | 1450 | def _set_node_tree_properties(self, node_tree: NodeTree) -> None: 1451 | nt_var = self._node_tree_vars[node_tree] 1452 | 1453 | if bpy.app.version >= (4, 2, 0): 1454 | color_tag_str = enum_to_py_str(node_tree.color_tag) 1455 | self._write(f"{nt_var}.color_tag = {color_tag_str}") 1456 | desc_str = str_to_py_str(node_tree.description) 1457 | self._write(f"{nt_var}.description = {desc_str}") 1458 | if bpy.app.version >= (4, 3, 0): 1459 | default_width = node_tree.default_group_node_width 1460 | self._write(f"{nt_var}.default_group_node_width = {default_width}") 1461 | self._write("\n") 1462 | 1463 | def _hide_hidden_sockets(self, node: Node) -> None: 1464 | """ 1465 | Hide hidden sockets 1466 | 1467 | Parameters: 1468 | node (Node): node object we're copying socket settings from 1469 | """ 1470 | node_var = self._node_vars[node] 1471 | 1472 | for i, socket in enumerate(node.inputs): 1473 | if socket.hide is True: 1474 | self._write(f"{node_var}.inputs[{i}].hide = True") 1475 | for i, socket in enumerate(node.outputs): 1476 | if socket.hide is True: 1477 | self._write(f"{node_var}.outputs[{i}].hide = True") 1478 | 1479 | def _create_menu_func(self) -> None: 1480 | """ 1481 | Creates the menu function 1482 | """ 1483 | self._write("def menu_func(self, context):", 0) 1484 | self._write(f"self.layout.operator({self._class_name}.bl_idname)\n", 1) 1485 | 1486 | def _create_register_func(self) -> None: 1487 | """ 1488 | Creates the register function 1489 | """ 1490 | self._write("def register():", 0) 1491 | self._write(f"bpy.utils.register_class({self._class_name})", 1) 1492 | self._write(f"bpy.types.{self._menu_id}.append(menu_func)\n", 1) 1493 | 1494 | def _create_unregister_func(self) -> None: 1495 | """ 1496 | Creates the unregister function 1497 | """ 1498 | self._write("def unregister():", 0) 1499 | self._write(f"bpy.utils.unregister_class({self._class_name})", 1) 1500 | self._write(f"bpy.types.{self._menu_id}.remove(menu_func)\n", 1) 1501 | 1502 | def _create_main_func(self) -> None: 1503 | """ 1504 | Creates the main function 1505 | """ 1506 | self._write("if __name__ == \"__main__\":", 0) 1507 | self._write("register()", 1) 1508 | 1509 | def _create_license(self) -> None: 1510 | if not self._should_create_license: 1511 | return 1512 | if self._license == 'OTHER': 1513 | return 1514 | license_file = open(f"{self._addon_dir}/LICENSE", "w") 1515 | year = datetime.date.today().year 1516 | license_txt = license_templates[self._license](year, self._author_name) 1517 | license_file.write(license_txt) 1518 | license_file.close() 1519 | 1520 | if bpy.app.version >= (4, 2, 0): 1521 | def _create_manifest(self) -> None: 1522 | manifest = open(f"{self._addon_dir}/blender_manifest.toml", "w") 1523 | manifest.write("schema_version = \"1.0.0\"\n\n") 1524 | manifest.write(f"id = {str_to_py_str(self._idname)}\n") 1525 | 1526 | manifest.write(f"version = {version_to_manifest_str(self._version)}\n") 1527 | manifest.write(f"name = {str_to_py_str(self._name)}\n") 1528 | if self._description == "": 1529 | self._description = self._name 1530 | manifest.write(f"tagline = {str_to_py_str(self._description)}\n") 1531 | manifest.write(f"maintainer = {str_to_py_str(self._author_name)}\n") 1532 | manifest.write("type = \"add-on\"\n") 1533 | manifest.write(f"blender_version_min = {version_to_manifest_str(bpy.app.version)}\n") 1534 | if self._license != 'OTHER': 1535 | manifest.write(f"license = [{str_to_py_str(self._license)}]\n") 1536 | else: 1537 | self.report({'WARNING'}, "No license selected. Please add a license to the manifest file") 1538 | 1539 | manifest.close() 1540 | 1541 | def _zip_addon(self) -> None: 1542 | """ 1543 | Zips up the addon and removes the directory 1544 | """ 1545 | shutil.make_archive(self._zip_dir, "zip", self._zip_dir) 1546 | shutil.rmtree(self._zip_dir) 1547 | 1548 | # ABSTRACT 1549 | def _process_node(self, node: Node, ntp_node_tree: NTP_NodeTree) -> None: 1550 | return 1551 | 1552 | # ABSTRACT 1553 | def _process_node_tree(self, node_tree: NodeTree) -> None: 1554 | return 1555 | 1556 | def _report_finished(self, object: str): 1557 | """ 1558 | Alert user that NTP is finished 1559 | 1560 | Parameters: 1561 | object (str): the copied node tree or encapsulating structure 1562 | (geometry node modifier, material, scene, etc.) 1563 | """ 1564 | if self._mode == 'SCRIPT': 1565 | location = "clipboard" 1566 | else: 1567 | location = self._dir_path 1568 | self.report({'INFO'}, f"NodeToPython: Saved {object} to {location}") 1569 | 1570 | # ABSTRACT 1571 | def execute(self): 1572 | return {'FINISHED'} 1573 | -------------------------------------------------------------------------------- /NodeToPython/options.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | class NTPOptions(bpy.types.PropertyGroup): 4 | """ 5 | Property group used during conversion of node group to python 6 | """ 7 | 8 | def __init__(self, *args, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | 11 | # General properties 12 | mode: bpy.props.EnumProperty( 13 | name = "Mode", 14 | items = [ 15 | ('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"), 16 | ('ADDON', "Addon", "Create a full add-on") 17 | ] 18 | ) 19 | include_group_socket_values : bpy.props.BoolProperty( 20 | name = "Include group socket values", 21 | description = "Generate group socket default, min, and max values", 22 | default = True 23 | ) 24 | set_dimensions : bpy.props.BoolProperty( 25 | name = "Set dimensions", 26 | description = "Set dimensions of generated nodes", 27 | default = True 28 | ) 29 | 30 | indentation_type: bpy.props.EnumProperty( 31 | name="Indentation Type", 32 | description="Whitespace to use for each indentation block", 33 | items = [ 34 | ('SPACES_2', "2 Spaces", ""), 35 | ('SPACES_4', "4 Spaces", ""), 36 | ('SPACES_8', "8 Spaces", ""), 37 | ('TABS', "Tabs", "") 38 | ], 39 | default = 'SPACES_4' 40 | ) 41 | 42 | if bpy.app.version >= (3, 4, 0): 43 | set_unavailable_defaults : bpy.props.BoolProperty( 44 | name = "Set unavailable defaults", 45 | description = "Set default values for unavailable sockets", 46 | default = False 47 | ) 48 | 49 | #Script properties 50 | include_imports : bpy.props.BoolProperty( 51 | name = "Include imports", 52 | description="Generate necessary import statements", 53 | default = True 54 | ) 55 | 56 | # Addon properties 57 | dir_path : bpy.props.StringProperty( 58 | name = "Save Location", 59 | subtype='DIR_PATH', 60 | description="Save location if generating an add-on", 61 | default = "//" 62 | ) 63 | name_override : bpy.props.StringProperty( 64 | name = "Name Override", 65 | description="Name used for the add-on's, default is node group name", 66 | default = "" 67 | ) 68 | description : bpy.props.StringProperty( 69 | name = "Description", 70 | description="Description used for the add-on", 71 | default="" 72 | ) 73 | author_name : bpy.props.StringProperty( 74 | name = "Author", 75 | description = "Name used for the author/maintainer of the add-on", 76 | default = "Node To Python" 77 | ) 78 | version: bpy.props.IntVectorProperty( 79 | name = "Version", 80 | description="Version of the add-on", 81 | default = (1, 0, 0) 82 | ) 83 | location: bpy.props.StringProperty( 84 | name = "Location", 85 | description="Location of the addon", 86 | default="Node" 87 | ) 88 | menu_id: bpy.props.StringProperty( 89 | name = "Menu ID", 90 | description = "Python ID of the menu you'd like to register the add-on " 91 | "to. You can find this by enabling Python tooltips " 92 | "(Preferences > Interface > Python tooltips) and " 93 | "hovering over the desired menu", 94 | default="NODE_MT_add" 95 | ) 96 | license: bpy.props.EnumProperty( 97 | name="License", 98 | items = [ 99 | ('SPDX:GPL-2.0-or-later', "GNU General Public License v2.0 or later", ""), 100 | ('SPDX:GPL-3.0-or-later', "GNU General Public License v3.0 or later", ""), 101 | ('SPDX:LGPL-2.1-or-later', "GNU Lesser General Public License v2.1 or later", ""), 102 | ('SPDX:LGPL-3.0-or-later', "GNU Lesser General Public License v3.0 or later", ""), 103 | ('SPDX:BSD-1-Clause', "BSD 1-Clause \"Simplified\" License", ""), 104 | ('SPDX:BSD-2-Clause', "BSD 2-Clause \"Simplified\" License", ""), 105 | ('SPDX:BSD-3-Clause', "BSD 3-Clause \"New\" or \"Revised\" License", ""), 106 | ('SPDX:BSL-1.0', "Boost Software License 1.0", ""), 107 | ('SPDX:MIT', "MIT License", ""), 108 | ('SPDX:MIT-0', "MIT No Attribution", ""), 109 | ('SPDX:MPL-2.0', "Mozilla Public License 2.0", ""), 110 | ('SPDX:Pixar', "Pixar License", ""), 111 | ('SPDX:Zlib', "Zlib License", ""), 112 | ('OTHER', "Other", "User is responsible for including the license " 113 | "and adding it to the manifest.\n" 114 | "Please note that by using the Blender Python " 115 | "API your add-on must comply with the GNU GPL. " 116 | "See https://www.blender.org/about/license/ for " 117 | "more details") 118 | ], 119 | default = 'SPDX:GPL-3.0-or-later' 120 | ) 121 | should_create_license: bpy.props.BoolProperty( 122 | name="Create License", 123 | description="Should NodeToPython include a license file", 124 | default=True 125 | ) 126 | category: bpy.props.EnumProperty( 127 | name = "Category", 128 | items = [ 129 | ('Custom', "Custom", "Use an unofficial category"), 130 | ('3D View', "3D View", ""), 131 | ('Add Curve', "Add Curve", ""), 132 | ('Add Mesh', "Add Mesh", ""), 133 | ('Animation', "Animation", ""), 134 | ('Bake', "Bake", ""), 135 | ('Compositing', "Compositing", ""), 136 | ('Development', "Development", ""), 137 | ('Game Engine', "Game Engine", ""), 138 | ('Geometry Nodes', "Geometry Nodes", ""), 139 | ("Grease Pencil", "Grease Pencil", ""), 140 | ('Import-Export', "Import-Export", ""), 141 | ('Lighting', "Lighting", ""), 142 | ('Material', "Material", ""), 143 | ('Mesh', "Mesh", ""), 144 | ('Modeling', "Modeling", ""), 145 | ('Node', "Node", ""), 146 | ('Object', "Object", ""), 147 | ('Paint', "Paint", ""), 148 | ('Pipeline', "Pipeline", ""), 149 | ('Physics', "Physics", ""), 150 | ('Render', "Render", ""), 151 | ('Rigging', "Rigging", ""), 152 | ('Scene', "Scene", ""), 153 | ('Sculpt', "Sculpt", ""), 154 | ('Sequencer', "Sequencer", ""), 155 | ('System', "System", ""), 156 | ('Text Editor', "Text Editor", ""), 157 | ('Tracking', "Tracking", ""), 158 | ('UV', "UV", ""), 159 | ('User Interface', "User Interface", ""), 160 | ], 161 | default = 'Node' 162 | ) 163 | custom_category: bpy.props.StringProperty( 164 | name="Custom Category", 165 | description="Custom category", 166 | default = "" 167 | ) 168 | 169 | class NTPOptionsPanel(bpy.types.Panel): 170 | bl_label = "Options" 171 | bl_idname = "NODE_PT_ntp_options" 172 | bl_space_type = 'NODE_EDITOR' 173 | bl_region_type = 'UI' 174 | bl_context = '' 175 | bl_category = "NodeToPython" 176 | 177 | def __init__(self, *args, **kwargs): 178 | super().__init__(*args, **kwargs) 179 | 180 | @classmethod 181 | def poll(cls, context): 182 | return True 183 | def draw(self, context): 184 | layout = self.layout 185 | layout.operator_context = 'INVOKE_DEFAULT' 186 | ntp_options = context.scene.ntp_options 187 | 188 | option_list = [ 189 | "mode", 190 | "include_group_socket_values", 191 | "set_dimensions", 192 | "indentation_type" 193 | ] 194 | if bpy.app.version >= (3, 4, 0): 195 | option_list.append("set_unavailable_defaults") 196 | 197 | if ntp_options.mode == 'SCRIPT': 198 | script_options = [ 199 | "include_imports" 200 | ] 201 | option_list += script_options 202 | elif ntp_options.mode == 'ADDON': 203 | addon_options = [ 204 | "dir_path", 205 | "name_override", 206 | "description", 207 | "author_name", 208 | "version", 209 | "location", 210 | "menu_id", 211 | "license", 212 | "should_create_license", 213 | "category" 214 | ] 215 | option_list += addon_options 216 | if ntp_options.category == 'CUSTOM': 217 | option_list.append("custom_category") 218 | 219 | for option in option_list: 220 | layout.prop(ntp_options, option) -------------------------------------------------------------------------------- /NodeToPython/shader/__init__.py: -------------------------------------------------------------------------------- 1 | if "bpy" in locals(): 2 | import importlib 3 | importlib.reload(operator) 4 | importlib.reload(ui) 5 | else: 6 | from . import operator 7 | from . import ui 8 | 9 | import bpy -------------------------------------------------------------------------------- /NodeToPython/shader/operator.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Node 3 | from bpy.types import ShaderNodeTree 4 | 5 | from io import StringIO 6 | 7 | from ..utils import * 8 | from ..ntp_operator import NTP_Operator 9 | from ..ntp_node_tree import NTP_NodeTree 10 | from ..node_settings import node_settings 11 | 12 | MAT_VAR = "mat" 13 | NODE = "node" 14 | SHADER_OP_RESERVED_NAMES = {MAT_VAR, NODE} 15 | 16 | class NTPShaderOperator(NTP_Operator): 17 | bl_idname = "node.ntp_material" 18 | bl_label = "Material to Python" 19 | bl_options = {'REGISTER', 'UNDO'} 20 | 21 | #TODO: add option for general shader node groups 22 | material_name: bpy.props.StringProperty(name="Node Group") 23 | 24 | def __init__(self, *args, **kwargs): 25 | super().__init__(*args, **kwargs) 26 | self._node_infos = node_settings 27 | for name in SHADER_OP_RESERVED_NAMES: 28 | self._used_vars[name] = 0 29 | 30 | def _create_material(self, indent_level: int): 31 | self._write(f"{MAT_VAR} = bpy.data.materials.new(" 32 | f"name = {str_to_py_str(self.material_name)})", indent_level) 33 | self._write(f"{MAT_VAR}.use_nodes = True", indent_level) 34 | 35 | def _initialize_shader_node_tree(self, ntp_node_tree: NTP_NodeTree, 36 | nt_name: str) -> None: 37 | """ 38 | Initialize the shader node group 39 | 40 | Parameters: 41 | ntp_node_tree (NTP_NodeTree): node tree to be generated and 42 | variable to use 43 | nt_name (str): name to use for the node tree 44 | """ 45 | self._write(f"#initialize {nt_name} node group", self._outer_indent_level) 46 | self._write(f"def {ntp_node_tree.var}_node_group():\n", self._outer_indent_level) 47 | 48 | if ntp_node_tree.node_tree == self._base_node_tree: 49 | self._write(f"{ntp_node_tree.var} = {MAT_VAR}.node_tree") 50 | self._write(f"#start with a clean node tree") 51 | self._write(f"for {NODE} in {ntp_node_tree.var}.nodes:") 52 | self._write(f"{ntp_node_tree.var}.nodes.remove({NODE})", self._inner_indent_level + 1) 53 | else: 54 | self._write((f"{ntp_node_tree.var} = bpy.data.node_groups.new(" 55 | f"type = \'ShaderNodeTree\', " 56 | f"name = {str_to_py_str(nt_name)})")) 57 | self._write("", 0) 58 | 59 | def _process_node(self, node: Node, ntp_nt: NTP_NodeTree) -> None: 60 | """ 61 | Create node and set settings, defaults, and cosmetics 62 | 63 | Parameters: 64 | node (Node): node to process 65 | ntp_nt (NTP_NodeTree): the node tree that node belongs to 66 | """ 67 | node_var: str = self._create_node(node, ntp_nt.var) 68 | self._set_settings_defaults(node) 69 | 70 | if bpy.app.version < (4, 0, 0): 71 | if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: 72 | self._group_io_settings(node, "input", ntp_nt) 73 | ntp_nt.inputs_set = True 74 | 75 | elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: 76 | self._group_io_settings(node, "output", ntp_nt) 77 | ntp_nt.outputs_set = True 78 | 79 | self._hide_hidden_sockets(node) 80 | self._set_socket_defaults(node) 81 | 82 | def _process_node_tree(self, node_tree: ShaderNodeTree) -> None: 83 | """ 84 | Generates a Python function to recreate a node tree 85 | 86 | Parameters: 87 | node_tree (NodeTree): node tree to be recreated 88 | level (int): number of tabs to use for each line, used with 89 | node groups within node groups and script/add-on differences 90 | """ 91 | 92 | if node_tree == self._base_node_tree: 93 | nt_var = self._create_var(self.material_name) 94 | nt_name = self.material_name #TODO: this is probably overcomplicating things if we move to a harder material vs shader node tree difference 95 | else: 96 | nt_var = self._create_var(node_tree.name) 97 | nt_name = node_tree.name 98 | 99 | self._node_tree_vars[node_tree] = nt_var 100 | 101 | ntp_nt = NTP_NodeTree(node_tree, nt_var) 102 | 103 | self._initialize_shader_node_tree(ntp_nt, nt_name) 104 | 105 | self._set_node_tree_properties(node_tree) 106 | 107 | if bpy.app.version >= (4, 0, 0): 108 | self._tree_interface_settings(ntp_nt) 109 | 110 | #initialize nodes 111 | self._write(f"#initialize {nt_var} nodes") 112 | 113 | for node in node_tree.nodes: 114 | self._process_node(node, ntp_nt) 115 | 116 | #set look of nodes 117 | self._set_parents(node_tree) 118 | self._set_locations(node_tree) 119 | self._set_dimensions(node_tree) 120 | 121 | #create connections 122 | self._init_links(node_tree) 123 | 124 | self._write(f"return {nt_var}\n") 125 | 126 | #create node group 127 | self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) 128 | 129 | 130 | def execute(self, context): 131 | if not self._setup_options(context.scene.ntp_options): 132 | return {'CANCELLED'} 133 | 134 | #find node group to replicate 135 | self._base_node_tree = bpy.data.materials[self.material_name].node_tree 136 | if self._base_node_tree is None: 137 | self.report({'ERROR'}, ("NodeToPython: This doesn't seem to be a " 138 | "valid material. Is Use Nodes selected?")) 139 | return {'CANCELLED'} 140 | 141 | #set up names to use in generated addon 142 | mat_var = clean_string(self.material_name) 143 | 144 | if self._mode == 'ADDON': 145 | self._outer_indent_level = 2 146 | self._inner_indent_level = 3 147 | 148 | if not self._setup_addon_directories(context, mat_var): 149 | return {'CANCELLED'} 150 | 151 | self._file = open(f"{self._addon_dir}/__init__.py", "w") 152 | 153 | self._create_header(self.material_name) 154 | self._class_name = clean_string(self.material_name, lower=False) 155 | self._init_operator(mat_var, self.material_name) 156 | 157 | self._write("def execute(self, context):", 1) 158 | else: 159 | self._file = StringIO("") 160 | if self._include_imports: 161 | self._file.write("import bpy, mathutils\n\n") 162 | 163 | if self._mode == 'ADDON': 164 | self._create_material(2) 165 | elif self._mode == 'SCRIPT': 166 | self._create_material(0) 167 | 168 | node_trees_to_process = self._topological_sort(self._base_node_tree) 169 | 170 | for node_tree in node_trees_to_process: 171 | self._process_node_tree(node_tree) 172 | 173 | if self._mode == 'ADDON': 174 | self._write("return {'FINISHED'}", self._outer_indent_level) 175 | self._create_menu_func() 176 | self._create_register_func() 177 | self._create_unregister_func() 178 | self._create_main_func() 179 | self._create_license() 180 | if bpy.app.version >= (4, 2, 0): 181 | self._create_manifest() 182 | else: 183 | context.window_manager.clipboard = self._file.getvalue() 184 | 185 | self._file.close() 186 | 187 | if self._mode == 'ADDON': 188 | self._zip_addon() 189 | 190 | self._report_finished("material") 191 | 192 | return {'FINISHED'} -------------------------------------------------------------------------------- /NodeToPython/shader/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Panel 3 | from bpy.types import Menu 4 | from .operator import NTPShaderOperator 5 | 6 | class NTPShaderPanel(Panel): 7 | bl_label = "Material to Python" 8 | bl_idname = "NODE_PT_mat_to_python" 9 | bl_space_type = 'NODE_EDITOR' 10 | bl_region_type = 'UI' 11 | bl_context = '' 12 | bl_category = "NodeToPython" 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | @classmethod 18 | def poll(cls, context): 19 | return True 20 | 21 | def draw_header(self, context): 22 | layout = self.layout 23 | 24 | def draw(self, context): 25 | layout = self.layout 26 | row = layout.row() 27 | 28 | # Disables menu when there are no materials 29 | materials = [mat for mat in bpy.data.materials if mat.node_tree] 30 | materials_exist = len(materials) > 0 31 | row.enabled = materials_exist 32 | 33 | row.alignment = 'EXPAND' 34 | row.operator_context = 'INVOKE_DEFAULT' 35 | row.menu("NODE_MT_ntp_material", text="Materials") 36 | 37 | class NTPShaderMenu(Menu): 38 | bl_idname = "NODE_MT_ntp_material" 39 | bl_label = "Select Material" 40 | 41 | @classmethod 42 | def poll(cls, context): 43 | return True 44 | 45 | def draw(self, context): 46 | layout = self.layout.column_flow(columns=1) 47 | layout.operator_context = 'INVOKE_DEFAULT' 48 | for mat in bpy.data.materials: 49 | if mat.node_tree: 50 | op = layout.operator(NTPShaderOperator.bl_idname, 51 | text=mat.name) 52 | op.material_name = mat.name -------------------------------------------------------------------------------- /NodeToPython/utils.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import mathutils 3 | 4 | 5 | from bpy.types import bpy_prop_array 6 | 7 | import keyword 8 | import re 9 | 10 | 11 | def clean_string(string: str, lower: bool = True) -> str: 12 | """ 13 | Cleans up a string for use as a variable or file name 14 | 15 | Parameters: 16 | string (str): The input string 17 | 18 | Returns: 19 | string (str): The input string ready to be used as a variable/file 20 | """ 21 | 22 | if lower: 23 | string = string.lower() 24 | string = re.sub(r"[^a-zA-Z0-9_]", '_', string) 25 | 26 | if keyword.iskeyword(string): 27 | string = "_" + string 28 | elif not (string[0].isalpha() or string[0] == '_'): 29 | string = "_" + string 30 | 31 | return string 32 | 33 | def enum_to_py_str(enum: str) -> str: 34 | """ 35 | Converts an enum into a string usuable in the add-on 36 | 37 | Parameters: 38 | enum (str): enum to be converted 39 | 40 | Returns: 41 | (str): converted string 42 | """ 43 | return f"\'{enum}\'" 44 | 45 | def str_to_py_str(string: str) -> str: 46 | """ 47 | Converts a regular string into one usuable in the add-on 48 | 49 | Parameters: 50 | string (str): string to be converted 51 | 52 | Returns: 53 | (str): converted string 54 | """ 55 | repr_str = repr(string) 56 | if repr_str.startswith("'") and repr_str.endswith("'"): 57 | repr_str = "\"" + repr_str[1:-1].replace('\"', '\\"') + "\"" 58 | return repr_str 59 | 60 | def vec1_to_py_str(vec1) -> str: 61 | """ 62 | Converts a 1D vector to a string usable by the add-on 63 | 64 | Parameters: 65 | vec1: a 1d vector 66 | 67 | Returns: 68 | (str): string representation of the vector 69 | """ 70 | return f"[{vec1[0]}]" 71 | 72 | def vec2_to_py_str(vec2) -> str: 73 | """ 74 | Converts a 2D vector to a string usable by the add-on 75 | 76 | Parameters: 77 | vec2: a 2D vector 78 | 79 | Returns: 80 | (str): string representation of the vector 81 | """ 82 | return f"({vec2[0]}, {vec2[1]})" 83 | 84 | def vec3_to_py_str(vec3) -> str: 85 | """ 86 | Converts a 3D vector to a string usable by the add-on 87 | 88 | Parameters: 89 | vec3: a 3d vector 90 | 91 | Returns: 92 | (str): string representation of the vector 93 | """ 94 | return f"({vec3[0]}, {vec3[1]}, {vec3[2]})" 95 | 96 | def version_to_manifest_str(version) -> str: 97 | return f"\"{version[0]}.{version[1]}.{version[2]}\"" 98 | 99 | def vec4_to_py_str(vec4) -> str: 100 | """ 101 | Converts a 4D vector to a string usable by the add-on 102 | 103 | Parameters: 104 | vec4: a 4d vector 105 | 106 | Returns: 107 | (str): string version 108 | """ 109 | return f"({vec4[0]}, {vec4[1]}, {vec4[2]}, {vec4[3]})" 110 | 111 | def array_to_py_str(array: bpy_prop_array) -> str: 112 | """ 113 | Converts a bpy_prop_array into a string 114 | 115 | Parameters: 116 | array (bpy_prop_array): Blender Python array 117 | 118 | Returns: 119 | (str): string version 120 | """ 121 | string = "(" 122 | for i in range(0, array.__len__()): 123 | if i > 0: 124 | string += ", " 125 | string += f"{array[i]}" 126 | string += ")" 127 | return string 128 | 129 | def color_to_py_str(color: mathutils.Color) -> str: 130 | """ 131 | Converts a mathutils.Color into a string 132 | 133 | Parameters: 134 | color (mathutils.Color): a Blender color 135 | 136 | Returns: 137 | (str): string version 138 | """ 139 | return f"mathutils.Color(({color.r}, {color.g}, {color.b}))" 140 | 141 | def img_to_py_str(img : bpy.types.Image) -> str: 142 | """ 143 | Converts a Blender image into its string 144 | 145 | Paramters: 146 | img (bpy.types.Image): a Blender image 147 | 148 | Returns: 149 | (str): string version 150 | """ 151 | name = img.name.split('.', 1)[0] 152 | format = img.file_format.lower() 153 | return f"{name}.{format}" -------------------------------------------------------------------------------- /docs/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | * [Brendan Parmer](https://github.com/BrendanParmer) 2 | * [Carlsu](https://github.com/carls3d) 3 | * [atticus-lv](https://github.com/atticus-lv) -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Node to Python 2 | 3 | ![Node To Python Logo](./img/logo.png "Node To Python Logo") 4 | 5 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/BrendanParmer/NodeToPython)](https://github.com/BrendanParmer/NodeToPython/releases) [![GitHub](https://img.shields.io/github/license/BrendanParmer/NodeToPython)](https://github.com/BrendanParmer/NodeToPython/blob/main/LICENSE) ![](https://visitor-badge.laobi.icu/badge?page_id=BrendanParmer.NodeToPython) ![](https://img.shields.io/github/downloads/BrendanParmer/NodeToPython/total.svg) 6 | 7 | ## About 8 | A Blender add-on to create scripts and add-ons! This add-on will take your Geometry Nodes, Materials, and Compositing nodes and convert them into legible Python code. 9 | 10 | Node To Python automatically handles node layout, default values, subgroups, naming, colors, and more! 11 | 12 | Blender's node-based editors are powerful, yet accessible tools, and I wanted to make scripting them easier for add-on creators. Combining Python with node based setups allows you to do things that would otherwise be tedious or impossible, such as 13 | * `for` loops 14 | * creating different node trees for different versions or settings 15 | * interfacing with other parts of the software or properties of an object 16 | 17 | ## Supported Versions 18 | NodeToPython v3.4.0 is supported for Blender 3.0 - 4.4 on Windows, macOS, and Linux 19 | * Some work is required to update NodeToPython for each Blender version, so experimental versions will not work properly 20 | 21 | ## Installation 22 | ### Blender Extensions Platform 23 | NodeToPython is now on the official [Blender Extensions Platform](https://extensions.blender.org/add-ons/node-to-python/)! See https://extensions.blender.org/about/ for installation instructions and more info. 24 | 25 | ### GitHub 26 | 1. Download the `NodeToPython.zip` file from the [latest release](https://github.com/BrendanParmer/NodeToPython/releases) 27 | * If you download other options, you'll need to rename the zip and the first folder to "NodeToPython" so Blender can properly import the add-on 28 | 2. In Blender, navigate to `Edit > Preferences > Add-ons` 29 | 3. Click Install, and find where you downloaded the zip file. Then hit the `Install Add-on` button, and you're done! 30 | 31 | ## Usage 32 | Once you've installed the add-on, you'll see a new tab in any Node Editor's sidebar. You can open this with keyboard shortcut `N` when focused in the Node Editor. 33 | 34 | In the tab, there are panels to create add-ons for Geometry Nodes, Materials, and Compositing Nodes, each with a drop-down menu. You can set options in the options tab. 35 | 36 | ![Add-on Location](./img/NodeToPython_Location.png "Add-on Location") 37 | 38 | In the options panel, select either **Script** or **Add-on**. 39 | * **Script** mode creates a function that generates the node tree and copies it to your Blender clipboard. 40 | * **Add-on** mode generates a zip file for you in the save directory specified in the NodeToPython menu. From here, you can install it like a regular add-on. The generated add-on comes complete with operator registration and creating a modifier/material/scene for the node tree to be used in. 41 | * When exporting to an add-on in Blender 4.2 or higher, you'll need to select a GPL-compliant liscense for Blender to be able to register the extension. 42 | 43 | ## Bug Reports and Suggestions 44 | 45 | When submitting an issue, please include 46 | 47 | * Your version of Blender (3.0 - 4.4) 48 | * Higher versions of Blender are expected to break NodeToPython. In general, work doesn't start on new versions until after the Beta stage of the release cycle 49 | * Your operating system 50 | * Steps to reproduce the issue or a description of what you were trying to accomplish. Providing a test blend file is especially helpful 51 | 52 | Got suggestions? Please create an [issue](https://github.com/BrendanParmer/NodeToPython/issues)! I'm happy to hear what features people want 53 | 54 | ## Legal Disclaimer 55 | 56 | **Important Notice:** 57 | 58 | This tool is provided under the MIT license and is intended for lawful use only. It is your responsibility to ensure that any use of this tool complies with all applicable laws, including but not limited to copyright and intellectual property laws. The authors of this tool are not liable for any illegal use of this tool or any legal consequences that may arise from such use. 59 | 60 | In particular, this tool generates code that requires the Blender Python API, which is licensed under the GNU General Public License (GPL). Code generated by this tool that incorporates or uses the Blender Python API must comply with the GPL requirements. For more details on the GPL, please see https://www.gnu.org/licenses/gpl-3.0.html. For more details on Blender's licensing, please see https://www.blender.org/about/license/ 61 | 62 | **Disclaimer**: The authors of this tool are not legal professionals. This notice is provided for informational purposes only and should not be construed as legal advice. For specific legal advice related to the use of this tool and compliance with the GPL or other legal matters, please consult a qualified attorney. 63 | 64 | # Credits 65 | See [CONTRIBUTORS.md](./CONTRIBUTORS.md) for all the people who've made this project possible 66 | -------------------------------------------------------------------------------- /docs/img/NodeToPython_Location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrendanParmer/NodeToPython/4412ce9fd82db2e3ad964b0522c717b7fb80d261/docs/img/NodeToPython_Location.png -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrendanParmer/NodeToPython/4412ce9fd82db2e3ad964b0522c717b7fb80d261/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/logo_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrendanParmer/NodeToPython/4412ce9fd82db2e3ad964b0522c717b7fb80d261/docs/img/logo_256x256.png -------------------------------------------------------------------------------- /tools/node_settings_generator/README.md: -------------------------------------------------------------------------------- 1 | # Node Settings Generator 2 | (Instructions may need adjusted depending on your operating system, especially Windows) 3 | 1. To create a node settings file, run 4 | ``` 5 | python3 node_settings_generator/parse_nodes.py x y 6 | ``` 7 | where `x.y` is the Blender version you want to generate settings up to. 8 | * Note that the minimum version is hard-coded to 3.0, as there aren't currently plans to extend NodeToPython compatibility to before that version. -------------------------------------------------------------------------------- /tools/node_settings_generator/parse_nodes.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from bs4 import BeautifulSoup 3 | from threading import Thread, Lock 4 | from io import TextIOWrapper 5 | import os 6 | import re 7 | import time 8 | from typing import NamedTuple 9 | import urllib.request 10 | 11 | import types_utils 12 | 13 | class NTPNodeSetting(NamedTuple): 14 | name_: str 15 | type_: types_utils.ST 16 | 17 | def __lt__(self, other): 18 | return self.name_ < other.name_ 19 | 20 | class Version(NamedTuple): 21 | major_: int 22 | minor_: int 23 | 24 | def tuple_str(self) -> str: 25 | return f"({self.major_}, {self.minor_})" 26 | 27 | def point_str(self) -> str: 28 | return f"{self.major_}.{self.minor_}" 29 | 30 | class NodeInfo(NamedTuple): 31 | versions_: list[Version] 32 | attributes_: dict[NTPNodeSetting, list[Version]] 33 | 34 | mutex = Lock() 35 | log_mutex = Lock() 36 | nodes_dict : dict[str, NodeInfo] = {} 37 | types_dict : dict[str, set[str]] = {} 38 | log_file = None 39 | 40 | NTP_MIN_VERSION = (3, 0) 41 | 42 | def process_attr(attr, section, node: str, version: Version) -> None: 43 | name_section = attr.find(["code", "span"], class_="sig-name descname") 44 | 45 | if not name_section: 46 | raise ValueError(f"{version.tuple_str()} {node}: Couldn't find name section in\n\t{section}") 47 | name = name_section.text 48 | 49 | type_section = attr.find("dd", class_="field-odd") 50 | if not type_section: 51 | raise ValueError(f"{version.tuple_str()} {node}.{name}: Couldn't find type section in\n\t{section}") 52 | type_text = type_section.text 53 | 54 | with mutex: 55 | first_word = type_text.split()[0] 56 | if first_word not in types_dict: 57 | types_dict[first_word] = {type_text} 58 | else: 59 | types_dict[first_word].add(type_text) 60 | 61 | ntp_type = types_utils.get_NTP_type(type_text) 62 | if ntp_type is None: 63 | # Read-only attribute, don't add to attribute list 64 | with log_mutex: 65 | log_file.write(f"WARNING: {version.tuple_str()} {node}.{name}'s type is being ignored:\n\t{type_text.strip()}\n") 66 | return 67 | 68 | ntp_setting = NTPNodeSetting(name, ntp_type) 69 | with mutex: 70 | if ntp_setting not in nodes_dict[node].attributes_: 71 | nodes_dict[node].attributes_[ntp_setting] = [] 72 | nodes_dict[node].attributes_[ntp_setting].append(version) 73 | 74 | def process_node(node: str, section, version: Version): 75 | global nodes_dict 76 | with mutex: 77 | if node not in nodes_dict: 78 | nodes_dict[node] = NodeInfo([], {}) 79 | nodes_dict[node].versions_.append(version) 80 | 81 | attrs = section.find_all("dl", class_="py attribute") 82 | 83 | for attr in attrs: 84 | process_attr(attr, section, node, version) 85 | 86 | datas = section.find_all("dl", class_="py data") 87 | for data in datas: 88 | process_attr(data, section, node, version) 89 | 90 | def download_file(filepath: str, version: Version, local_path: str) -> bool: 91 | file_url = f"https://docs.blender.org/api/{version.point_str()}/{filepath}" 92 | 93 | headers_ = {'User-Agent': 'Mozilla/5.0'} 94 | 95 | req = urllib.request.Request(file_url, headers=headers_) 96 | 97 | if not os.path.exists(os.path.dirname(local_path)): 98 | os.makedirs(os.path.dirname(local_path)) 99 | 100 | NUM_TRIES = 10 101 | for i in range(NUM_TRIES): 102 | try: 103 | with urllib.request.urlopen(req) as response: 104 | with open(local_path, 'wb') as file: 105 | file.write(response.read()) 106 | break 107 | except Exception as e: 108 | if i == NUM_TRIES - 1: 109 | raise e 110 | time.sleep(1.0) 111 | 112 | print(f"Downloaded {file_url} to {local_path}") 113 | return True 114 | 115 | 116 | def get_subclasses(current: str, parent: str, root_path: str, 117 | version: Version) -> list[str]: 118 | relative_path = f"bpy.types.{current}.html" 119 | current_path = os.path.join(root_path, relative_path) 120 | 121 | if not os.path.exists(current_path): 122 | download_file(relative_path, version, current_path) 123 | 124 | if os.path.getsize(current_path) == 0: 125 | download_file(relative_path, version, current_path) 126 | 127 | with open(current_path, "r") as current_file: 128 | current_html = current_file.read() 129 | 130 | soup = BeautifulSoup(current_html, "html.parser") 131 | 132 | main_id = f"{current.lower()}-{parent.lower()}" 133 | sections = soup.find_all(id=main_id) 134 | if not sections: 135 | raise ValueError(f"{version.tuple_str()} {current}: Couldn't find main section with id {main_id}") 136 | 137 | section = sections[0] 138 | paragraphs = section.find_all("p") 139 | if len(paragraphs) < 2: 140 | raise ValueError(f"{version.tuple_str()} {current}: Couldn't find subclass section") 141 | 142 | subclasses_paragraph = paragraphs[1] 143 | if not subclasses_paragraph.text.strip().startswith("subclasses —"): 144 | # No subclasses for this type 145 | process_node(current, section, version) 146 | return 147 | 148 | subclass_anchors = subclasses_paragraph.find_all("a") 149 | if not subclass_anchors: 150 | raise ValueError(f"{version.tuple_str()} {current} No anchors in subclasses paragraph") 151 | 152 | subclass_types = [anchor.get("title") for anchor in subclass_anchors] 153 | threads: list[Thread] = [] 154 | for type in subclass_types: 155 | if not type: 156 | raise ValueError(f"{version.tuple_str()} {current} Type was invalid") 157 | is_matching = re.match(r"bpy\.types\.(.*)", type) 158 | if not is_matching: 159 | raise ValueError(f"{version.tuple_str()} {current}: Type {type} was not of the form \"bpy.types.x\"") 160 | pure_type = is_matching.group(1) 161 | if (pure_type == "TextureNode"): 162 | # unsupported 163 | continue 164 | 165 | thread = Thread(target=get_subclasses, args=(pure_type, current, root_path, version)) 166 | threads.append(thread) 167 | thread.start() 168 | 169 | for thread in threads: 170 | thread.join() 171 | 172 | def process_bpy_version(version: Version) -> None: 173 | print(f"Processing version {version.point_str()}") 174 | 175 | current = "NodeInternal" 176 | parent = "Node" 177 | 178 | root_path = os.path.join(bpy_docs_path, f"{version.point_str()}/") 179 | 180 | get_subclasses(current, parent, root_path, version) 181 | 182 | def generate_versions(max_version_inc: Version) -> list[Version]: 183 | BLENDER_3_MAX_VERSION = 6 184 | 185 | versions = [Version(3, i) for i in range(0, BLENDER_3_MAX_VERSION + 1)] 186 | versions += [Version(4, i) for i in range(0, max_version_inc[1] + 1)] 187 | 188 | #lazy max version check 189 | for version in versions[::-1]: 190 | if version > max_version_inc: 191 | versions.remove(version) 192 | 193 | return versions 194 | 195 | def subminor(version: Version) -> tuple: 196 | return (version[0], version[1], 0) 197 | 198 | def get_min_version(versions: list[Version]) -> Version: 199 | min_version = min(versions) 200 | 201 | if min_version != NTP_MIN_VERSION: 202 | return min_version 203 | else: 204 | return None 205 | 206 | def get_max_version(versions: list[Version], blender_versions: list[Version] 207 | ) -> Version: 208 | max_v_inclusive = max(versions) 209 | max_v_inclusive_index = blender_versions.index(max_v_inclusive) 210 | max_v_exclusive = blender_versions[max_v_inclusive_index + 1] 211 | 212 | if max_v_exclusive != blender_versions[-1]: 213 | return max_v_exclusive 214 | else: 215 | return None 216 | 217 | def write_imports(file: TextIOWrapper): 218 | file.write("from enum import Enum, auto\n") 219 | file.write("from typing import NamedTuple\n") 220 | file.write("\n") 221 | 222 | def write_st_enum(file: TextIOWrapper): 223 | file.write("class ST(Enum):\n") 224 | file.write("\t\"\"\"\n\tSettings Types\n\t\"\"\"\n") 225 | 226 | for setting_type in types_utils.ST: 227 | file.write(f"\t{setting_type.name} = auto()\n") 228 | 229 | file.write("\n") 230 | 231 | def write_ntp_node_setting_class(file: TextIOWrapper): 232 | file.write("class NTPNodeSetting(NamedTuple):\n") 233 | file.write("\tname_: str\n") 234 | file.write("\tst_: ST\n") 235 | file.write(f"\tmin_version_: tuple = {subminor(NTP_MIN_VERSION)}\n") 236 | file.write(f"\tmax_version_: tuple = {subminor(NTP_MAX_VERSION_EXC)}\n") 237 | file.write("\n") 238 | 239 | def write_node_info_class(file: TextIOWrapper): 240 | file.write("class NodeInfo(NamedTuple):\n") 241 | file.write("\tattributes_: list[NTPNodeSetting]\n") 242 | file.write(f"\tmin_version_: tuple = {subminor(NTP_MIN_VERSION)}\n") 243 | file.write(f"\tmax_version_: tuple = {subminor(NTP_MAX_VERSION_EXC)}\n") 244 | file.write("\n") 245 | 246 | def write_ntp_node_settings(node_info: NodeInfo, file: TextIOWrapper, 247 | node_min_v: Version, node_max_v: Version): 248 | attr_dict = node_info.attributes_ 249 | file.write("\n\t\t[") 250 | attrs_exist = len(attr_dict.items()) > 0 251 | if attrs_exist: 252 | file.write("\n") 253 | sorted_attrs = dict(sorted(attr_dict.items())) 254 | for attr, attr_versions in sorted_attrs.items(): 255 | min_version_str = "" 256 | attr_min_version = get_min_version(attr_versions) 257 | if attr_min_version != None and attr_min_version != node_min_v: 258 | min_version_str = f", min_version_={subminor(attr_min_version)}" 259 | 260 | max_version_str = "" 261 | attr_max_version = get_max_version(attr_versions, versions) 262 | if attr_max_version != None and attr_max_version != node_max_v: 263 | max_version_str = f", max_version_={subminor(attr_max_version)}" 264 | 265 | file.write(f"\t\t\tNTPNodeSetting(\"{attr.name_}\", ST.{attr.type_.name}" 266 | f"{min_version_str}{max_version_str}),\n") 267 | 268 | if attrs_exist: 269 | file.write("\t\t") 270 | file.write("]") 271 | 272 | def write_node(name: str, node_info: NodeInfo, file: TextIOWrapper): 273 | file.write(f"\t\'{name}\' : NodeInfo(") 274 | 275 | node_min_v = get_min_version(node_info.versions_) 276 | node_max_v = get_max_version(node_info.versions_, versions) 277 | 278 | write_ntp_node_settings(node_info, file, node_min_v, node_max_v) 279 | 280 | if node_min_v != None: 281 | file.write(f",\n\t\tmin_version_ = {subminor(node_min_v)}") 282 | if node_max_v != None: 283 | file.write(f",\n\t\tmax_version_ = {subminor(node_max_v)}") 284 | 285 | file.write("\n\t),\n\n") 286 | 287 | if __name__ == "__main__": 288 | parser = argparse.ArgumentParser() 289 | parser.add_argument('max_major_version', type=int, 290 | help="Max major version (inclusive) of Blender to generate node settings for") 291 | parser.add_argument('max_minor_version', type=int, 292 | help="Max minor version (inclusive) of Blender to generate node settings for") 293 | args = parser.parse_args() 294 | 295 | current_path = os.path.dirname(os.path.realpath(__file__)) 296 | bpy_docs_path = os.path.join(current_path, "bpy_docs") 297 | 298 | NTP_MAX_VERSION_INC = Version(args.max_major_version, args.max_minor_version) 299 | max_version_path = os.path.join(bpy_docs_path, f"{NTP_MAX_VERSION_INC.point_str()}") 300 | 301 | versions = generate_versions(NTP_MAX_VERSION_INC) 302 | 303 | output_dir_path = os.path.join(current_path, "output") 304 | if not os.path.exists(output_dir_path): 305 | os.makedirs(output_dir_path) 306 | 307 | log_filepath = os.path.join(output_dir_path, "log.txt") 308 | log_file = open(log_filepath, 'w') 309 | 310 | for version in versions: 311 | process_bpy_version(version) 312 | 313 | NTP_MAX_VERSION_EXC = (NTP_MAX_VERSION_INC[0], NTP_MAX_VERSION_INC[1] + 1) 314 | versions.append(NTP_MAX_VERSION_EXC) 315 | 316 | sorted_nodes = dict(sorted(nodes_dict.items())) 317 | 318 | output_filepath = os.path.join(output_dir_path, "node_settings.py") 319 | 320 | with open(output_filepath, 'w') as file: 321 | print(f"Writing settings to {output_filepath}") 322 | 323 | write_imports(file) 324 | 325 | write_st_enum(file) 326 | 327 | write_ntp_node_setting_class(file) 328 | 329 | write_node_info_class(file) 330 | 331 | file.write("node_settings : dict[str, NodeInfo] = {\n") 332 | 333 | for name, node_info in sorted_nodes.items(): 334 | write_node(name, node_info, file) 335 | 336 | file.write("}") 337 | 338 | print("Successfully finished") 339 | 340 | sorted_types = dict(sorted(types_dict.items())) 341 | log_file.write("\nTypes encountered:\n") 342 | for key, value in types_dict.items(): 343 | log_file.write(f"{key}\n") 344 | for string in value: 345 | log_file.write(f"\t{string}\n") -------------------------------------------------------------------------------- /tools/node_settings_generator/types_utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | class ST(Enum): 4 | """ 5 | Settings Types 6 | """ 7 | # Primitives 8 | BOOL = auto() 9 | COLOR = auto() 10 | ENUM = auto() 11 | ENUM_SET = auto() 12 | EULER = auto() 13 | FLOAT = auto() 14 | INT = auto() 15 | STRING = auto() 16 | VEC1 = auto() 17 | VEC2 = auto() 18 | VEC3 = auto() 19 | VEC4 = auto() 20 | 21 | # Special settings 22 | BAKE_ITEMS = auto() 23 | CAPTURE_ATTRIBUTE_ITEMS = auto() 24 | COLOR_RAMP = auto() 25 | CURVE_MAPPING = auto() 26 | ENUM_DEFINITION = auto() 27 | ENUM_ITEM = auto() 28 | FOREACH_GEO_ELEMENT_GENERATION_ITEMS = auto() 29 | FOREACH_GEO_ELEMENT_INPUT_ITEMS = auto() 30 | FOREACH_GEO_ELEMENT_MAIN_ITEMS = auto() 31 | INDEX_SWITCH_ITEMS = auto() 32 | MENU_SWITCH_ITEMS = auto() 33 | NODE_TREE = auto() 34 | REPEAT_OUTPUT_ITEMS = auto() 35 | SIM_OUTPUT_ITEMS = auto() 36 | 37 | # Image 38 | IMAGE = auto() #needs refactor 39 | IMAGE_USER = auto() #needs refactor 40 | 41 | # Currently unimplemented 42 | COLLECTION = auto() 43 | CRYPTOMATTE_ENTRIES = auto() 44 | FILE_SLOTS = auto() 45 | FONT = auto() 46 | IMAGE_FORMAT_SETTINGS = auto() 47 | LAYER_SLOTS = auto() 48 | MASK = auto() 49 | MATERIAL = auto() #TODO asset library 50 | MOVIE_CLIP = auto() 51 | OBJECT = auto() #TODO asset library 52 | PARTICLE_SYSTEM = auto() 53 | SCENE = auto() 54 | TEXT = auto() 55 | TEXTURE = auto() 56 | 57 | #types expected to be marked as read-only 58 | READ_ONLY_TYPES : set[ST] = { 59 | ST.BAKE_ITEMS, 60 | ST.CAPTURE_ATTRIBUTE_ITEMS, 61 | ST.COLOR_RAMP, 62 | ST.CRYPTOMATTE_ENTRIES, 63 | ST.CURVE_MAPPING, 64 | ST.ENUM_DEFINITION, 65 | ST.FILE_SLOTS, 66 | ST.FOREACH_GEO_ELEMENT_GENERATION_ITEMS, 67 | ST.FOREACH_GEO_ELEMENT_INPUT_ITEMS, 68 | ST.FOREACH_GEO_ELEMENT_MAIN_ITEMS, 69 | ST.IMAGE_FORMAT_SETTINGS, 70 | ST.IMAGE_USER, 71 | ST.INDEX_SWITCH_ITEMS, 72 | ST.LAYER_SLOTS, 73 | ST.MENU_SWITCH_ITEMS, 74 | ST.REPEAT_OUTPUT_ITEMS, 75 | ST.SIM_OUTPUT_ITEMS, 76 | } 77 | 78 | doc_to_NTP_type_dict : dict[str, ST] = { 79 | "" : "", 80 | "bpy_prop_collection of CryptomatteEntry": ST.CRYPTOMATTE_ENTRIES, 81 | "boolean" : ST.BOOL, 82 | "Collection" : ST.COLLECTION, 83 | "ColorMapping" : None, # Always read-only 84 | "ColorRamp" : ST.COLOR_RAMP, 85 | "CompositorNodeOutputFileFileSlots" : ST.FILE_SLOTS, 86 | "CompositorNodeOutputFileLayerSlots" : ST.LAYER_SLOTS, 87 | "CurveMapping" : ST.CURVE_MAPPING, 88 | "enum" : ST.ENUM, 89 | "enum set" : ST.ENUM_SET, 90 | "float" : ST.FLOAT, 91 | "float array of 1" : ST.VEC1, 92 | "float array of 2" : ST.VEC2, 93 | "float array of 3" : ST.VEC3, 94 | "float array of 4" : ST.VEC4, 95 | "Image" : ST.IMAGE, 96 | "ImageFormatSettings" : ST.IMAGE_FORMAT_SETTINGS, 97 | "ImageUser" : ST.IMAGE_USER, 98 | "int" : ST.INT, 99 | "Mask" : ST.MASK, 100 | "Material" : ST.MATERIAL, 101 | "mathutils.Color" : ST.COLOR, 102 | "mathutils.Euler" : ST.EULER, #TODO 103 | "mathutils.Vector of 3" : ST.VEC3, 104 | "MovieClip" : ST.MOVIE_CLIP, 105 | "Node" : None, # (<4.2) Always used with zone inputs, need to make sure 106 | # output nodes exist. Handled separately from NTP attr system 107 | "NodeEnumDefinition" : ST.ENUM_DEFINITION, 108 | "NodeEnumItem" : ST.ENUM_ITEM, 109 | "NodeGeometryBakeItems" : ST.BAKE_ITEMS, 110 | "NodeGeometryCaptureAttributeItems" : ST.CAPTURE_ATTRIBUTE_ITEMS, 111 | "NodeGeometryForeachGeometryElementGenerationItems": ST.FOREACH_GEO_ELEMENT_GENERATION_ITEMS, 112 | "NodeGeometryForeachGeometryElementInputItems" : ST.FOREACH_GEO_ELEMENT_INPUT_ITEMS, 113 | "NodeGeometryForeachGeometryElementMainItems": ST.FOREACH_GEO_ELEMENT_MAIN_ITEMS, 114 | "NodeGeometryRepeatOutputItems" : ST.REPEAT_OUTPUT_ITEMS, 115 | "NodeGeometrySimulationOutputItems" : ST.SIM_OUTPUT_ITEMS, 116 | "NodeIndexSwitchItems" : ST.INDEX_SWITCH_ITEMS, 117 | "NodeMenuSwitchItems" : ST.MENU_SWITCH_ITEMS, 118 | "NodeTree" : ST.NODE_TREE, 119 | "Object" : ST.OBJECT, 120 | "ParticleSystem" : ST.PARTICLE_SYSTEM, 121 | "PropertyGroup" : None, #Always read-only 122 | "RepeatItem" : None, #Always set with index 123 | "Scene" : ST.SCENE, 124 | "SimulationStateItem" : None, #Always set with index 125 | "string" : ST.STRING, 126 | "TexMapping" : None, #Always read-only 127 | "Text" : ST.TEXT, 128 | "Texture" : ST.TEXTURE, 129 | "VectorFont" : ST.FONT 130 | } 131 | 132 | def get_NTP_type(type_str: str) -> str: 133 | """ 134 | Time complexity isn't great, might be able to optimize with 135 | a trie or similar data structure 136 | """ 137 | longest_prefix = "" 138 | for key in doc_to_NTP_type_dict.keys(): 139 | if type_str.startswith(key) and len(key) > len(longest_prefix): 140 | longest_prefix = key 141 | 142 | if longest_prefix == "": 143 | print(f"Couldn't find prefix of {type_str.strip()} in dictionary") 144 | 145 | result = doc_to_NTP_type_dict[longest_prefix] 146 | 147 | is_readonly = "read" in type_str 148 | if is_readonly and result not in READ_ONLY_TYPES: 149 | return None 150 | else: 151 | return result --------------------------------------------------------------------------------