From 3c72f84170b6230a3262c02c866c54a40394d28c Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 3 Jun 2023 17:48:37 -0500 Subject: [PATCH 01/12] feat: added UI for script mode --- __init__.py | 5 +++-- geo_nodes.py | 19 ++++++++++++++++++- materials.py | 18 +++++++++++++++++- utils.py | 2 -- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/__init__.py b/__init__.py index dacc2e7..0ccd674 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ "name": "Node to Python", "description": "Convert Blender node groups to a Python add-on!", "author": "Brendan Parmer", - "version": (2, 1, 0), + "version": (2, 2, 0), "blender": (3, 0, 0), "location": "Node", "category": "Node", @@ -36,7 +36,8 @@ def draw(self, context): geo_nodes.GeoNodesToPythonPanel, materials.MaterialToPython, materials.SelectMaterialMenu, - materials.MaterialToPythonPanel] + materials.MaterialToPythonPanel + ] def register(): for cls in classes: diff --git a/geo_nodes.py b/geo_nodes.py index 93c4cc3..0ca27ff 100644 --- a/geo_nodes.py +++ b/geo_nodes.py @@ -168,9 +168,20 @@ class GeoNodesToPython(bpy.types.Operator): bl_label = "Geo Nodes to Python" bl_options = {'REGISTER', 'UNDO'} + mode : bpy.props.EnumProperty( + name = "Mode", + items = [ + ('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"), + ('ADDON', "Addon", "Create a full addon") + ] + ) geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") def execute(self, context): + if self.mode == 'SCRIPT': + print("Script!") + elif self.mode == 'ADDON': + print("Addon!") #find node group to replicate nt = bpy.data.node_groups[self.geo_nodes_group_name] @@ -335,9 +346,15 @@ def apply_modifier(): file.close() zip_addon(zip_dir) - + self.report({'INFO'}, "NodeToPython: Saved geometry nodes group") return {'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + def draw(self, context): + self.layout.prop(self, "mode") + class SelectGeoNodesMenu(bpy.types.Menu): bl_idname = "NODE_MT_ntp_geo_nodes_selection" bl_label = "Select Geo Nodes" diff --git a/materials.py b/materials.py index 7b6b803..50213a1 100644 --- a/materials.py +++ b/materials.py @@ -81,9 +81,19 @@ class MaterialToPython(bpy.types.Operator): bl_label = "Material to Python" bl_options = {'REGISTER', 'UNDO'} + mode : bpy.props.EnumProperty( + name = "Mode", + items = [ + ('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"), + ('ADDON', "Addon", "Create a full addon") + ] + ) material_name: bpy.props.StringProperty(name="Node Group") - def execute(self, context): + if self.mode == 'SCRIPT': + print("Script!") + elif self.mode == 'ADDON': + print("Addon!") #find node group to replicate nt = bpy.data.materials[self.material_name].node_tree if nt is None: @@ -220,7 +230,13 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): file.close() zip_addon(zip_dir) + self.report({'INFO'}, "NodeToPython: Saved material") return {'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + def draw(self, context): + self.layout.prop(self, "mode") class SelectMaterialMenu(bpy.types.Menu): bl_idname = "NODE_MT_npt_mat_selection" diff --git a/utils.py b/utils.py index 738c948..c81be18 100644 --- a/utils.py +++ b/utils.py @@ -286,9 +286,7 @@ def group_io_settings(node, file: TextIO, inner: str, io: str, node_tree_var: st socket = ntio[i] socket_var = f"{node_tree_var}.{io}s[{i}]" - print(f"{io} {i}: {name}") if inout.type in default_sockets: - print(socket.default_value) #default value if inout.type == 'RGBA': dv = vec4_to_py_str(socket.default_value) From a505ce6a02ea140042033b285c934d91a9084516 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 4 Jun 2023 14:16:02 -0500 Subject: [PATCH 02/12] feat: copies script to clipboard --- geo_nodes.py | 94 ++++++++++++++++++++++------------------ materials.py | 118 +++++++++++++++++++++++++++++++-------------------- utils.py | 4 +- 3 files changed, 128 insertions(+), 88 deletions(-) diff --git a/geo_nodes.py b/geo_nodes.py index 0ca27ff..e20b985 100644 --- a/geo_nodes.py +++ b/geo_nodes.py @@ -2,6 +2,7 @@ import os from .utils import * +from io import StringIO geo_node_settings = { # Attribute nodes @@ -178,37 +179,35 @@ class GeoNodesToPython(bpy.types.Operator): geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") def execute(self, context): - if self.mode == 'SCRIPT': - print("Script!") - elif self.mode == 'ADDON': - print("Addon!") #find node group to replicate nt = bpy.data.node_groups[self.geo_nodes_group_name] #set up names to use in generated addon nt_var = clean_string(nt.name) - class_name = clean_string(nt.name.replace(" ", "").replace('.', ""), - lower = False) - - #find base directory to save new addon - base_dir = bpy.path.abspath("//") - if not base_dir or base_dir == "": - self.report({'ERROR'}, - ("NodeToPython: Save your blend file before using " - "NodeToPython!")) - return {'CANCELLED'} - - #save in /addons/ subdirectory - zip_dir = os.path.join(base_dir, "addons", nt_var) - addon_dir = os.path.join(zip_dir, nt_var) - if not os.path.exists(addon_dir): - os.makedirs(addon_dir) - file = open(f"{addon_dir}/__init__.py", "w") - - create_header(file, nt.name) - init_operator(file, class_name, nt_var, nt.name) - file.write("\tdef execute(self, context):\n") + if self.mode == 'ADDON': + #find base directory to save new addon + base_dir = bpy.path.abspath("//") + if not base_dir or base_dir == "": + self.report({'ERROR'}, + ("NodeToPython: Save your blend file before using " + "NodeToPython!")) + return {'CANCELLED'} + + #save in addons/ subdirectory + zip_dir = os.path.join(base_dir, "addons", nt_var) + addon_dir = os.path.join(zip_dir, nt_var) + if not os.path.exists(addon_dir): + os.makedirs(addon_dir) + file = open(f"{addon_dir}/__init__.py", "w") + + create_header(file, nt.name) + class_name = clean_string(nt.name.replace(" ", "").replace('.', ""), + lower = False) + init_operator(file, class_name, nt_var, nt.name) + file.write("\tdef execute(self, context):\n") + else: + file = StringIO("") #set to keep track of already created node trees node_trees = set() @@ -229,8 +228,8 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): file.write(f"{outer}def {nt_var}_node_group():\n") file.write((f"{inner}{nt_var}" f"= bpy.data.node_groups.new(" - f"type = \"GeometryNodeTree\", " - f"name = \"{node_tree.name}\")\n")) + f"type = \'GeometryNodeTree\', " + f"name = {str_to_py_str(node_tree.name)})\n")) file.write("\n") inputs_set = False @@ -267,12 +266,12 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): if node.node_tree is not None: file.write((f"{inner}{node_var}.node_tree = " f"bpy.data.node_groups" - f"[\"{node.node_tree.name}\"]\n")) + f"[{str_to_py_str(node.node_tree.name)}]\n")) elif node.bl_idname == 'ShaderNodeValToRGB': color_ramp_settings(node, file, inner, node_var) elif node.bl_idname in curve_nodes: curve_node_settings(node, file, inner, node_var) - elif node.bl_idname in image_nodes: + elif node.bl_idname in image_nodes and self.mode == 'ADDON': img = node.image if img is not None and img.source in {'FILE', 'GENERATED', 'TILED'}: save_image(img, addon_dir) @@ -295,7 +294,10 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): f"{attr_domain}\n")) if node.bl_idname != 'GeometryNodeSimulationInput': - set_input_defaults(node, file, inner, node_var, addon_dir) + if self.mode == 'ADDON': + set_input_defaults(node, file, inner, node_var, addon_dir) + else: + set_input_defaults(node, file, inner, node_var) set_output_defaults(node, file, inner, node_var) #create simulation zones @@ -306,7 +308,10 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): f"({sim_output_var})\n")) #must set defaults after paired with output - set_input_defaults(sim_input, file, inner, sim_input_var, addon_dir) + if self.mode == 'ADDON': + set_input_defaults(node, file, inner, node_var, addon_dir) + else: + set_input_defaults(node, file, inner, node_var) set_output_defaults(sim_input, file, inner, sim_input_var) set_parents(node_tree, file, inner, node_vars) @@ -322,7 +327,11 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): f"{nt_var}_node_group()\n\n")) return used_vars - process_geo_nodes_group(nt, 2, node_vars, used_vars) + if self.mode == 'ADDON': + level = 2 + else: + level = 0 + process_geo_nodes_group(nt, level, node_vars, used_vars) def apply_modifier(): #get object @@ -334,18 +343,21 @@ def apply_modifier(): file.write((f"\t\tmod = obj.modifiers.new(name = {mod_name}, " f"type = 'NODES')\n")) file.write(f"\t\tmod.node_group = {nt_var}\n") - apply_modifier() - - file.write("\t\treturn {'FINISHED'}\n\n") - - create_menu_func(file, class_name) - create_register_func(file, class_name) - create_unregister_func(file, class_name) - create_main_func(file) + if self.mode == 'ADDON': + apply_modifier() + file.write("\t\treturn {'FINISHED'}\n\n") + + create_menu_func(file, class_name) + create_register_func(file, class_name) + create_unregister_func(file, class_name) + create_main_func(file) + else: + context.window_manager.clipboard = file.getvalue() file.close() - zip_addon(zip_dir) + if self.mode == 'ADDON': + zip_addon(zip_dir) self.report({'INFO'}, "NodeToPython: Saved geometry nodes group") return {'FINISHED'} diff --git a/materials.py b/materials.py index 50213a1..eea30fb 100644 --- a/materials.py +++ b/materials.py @@ -2,6 +2,7 @@ import os from .utils import * +from io import StringIO node_settings = { #input @@ -89,45 +90,49 @@ class MaterialToPython(bpy.types.Operator): ] ) material_name: bpy.props.StringProperty(name="Node Group") + def execute(self, context): - if self.mode == 'SCRIPT': - print("Script!") - elif self.mode == 'ADDON': - print("Addon!") #find node group to replicate nt = bpy.data.materials[self.material_name].node_tree if nt is None: - self.report({'ERROR'}, - ("NodeToPython: This doesn't seem to be a valid " - "material. Is Use Nodes selected?")) + self.report({'ERROR'},("NodeToPython: This doesn't seem to be a " + "valid material. Is Use Nodes selected?")) return {'CANCELLED'} #set up names to use in generated addon mat_var = clean_string(self.material_name) - class_name = clean_string(self.material_name, lower=False) - dir = bpy.path.abspath("//") - if not dir or dir == "": - self.report({'ERROR'}, - ("NodeToPython: Save your blender file before using " - "NodeToPython!")) - return {'CANCELLED'} - zip_dir = os.path.join(dir, "addons", mat_var) - addon_dir = os.path.join(zip_dir, mat_var) - if not os.path.exists(addon_dir): - os.makedirs(addon_dir) - file = open(f"{addon_dir}/__init__.py", "w") - - create_header(file, self.material_name) - init_operator(file, class_name, mat_var, self.material_name) - - file.write("\tdef execute(self, context):\n") - - def create_material(): - file.write((f"\t\tmat = bpy.data.materials.new(" - f"name = \"{self.material_name}\")\n")) - file.write(f"\t\tmat.use_nodes = True\n") - create_material() + if self.mode == 'ADDON': + dir = bpy.path.abspath("//") + if not dir or dir == "": + self.report({'ERROR'}, + ("NodeToPython: Save your blender file before using " + "NodeToPython!")) + return {'CANCELLED'} + + #save in addons/ subdirectory + zip_dir = os.path.join(dir, "addons", mat_var) + addon_dir = os.path.join(zip_dir, mat_var) + if not os.path.exists(addon_dir): + os.makedirs(addon_dir) + file = open(f"{addon_dir}/__init__.py", "w") + + create_header(file, self.material_name) + class_name = clean_string(self.material_name, lower=False) + init_operator(file, class_name, mat_var, self.material_name) + + file.write("\tdef execute(self, context):\n") + else: + file = StringIO("") + + def create_material(indent: str): + file.write((f"{indent}mat = bpy.data.materials.new(" + f"name = {str_to_py_str(self.material_name)})\n")) + file.write(f"{indent}mat.use_nodes = True\n") + if self.mode == 'ADDON': + create_material("\t\t") + elif self.mode == 'SCRIPT': + create_material("") #set to keep track of already created node trees node_trees = set() @@ -138,9 +143,15 @@ def create_material(): #keeps track of all used variables used_vars = set() - def process_mat_node_group(node_tree, level, node_vars, used_vars): + def is_outermost_node_group(level: int) -> bool: + if self.mode == 'ADDON' and level == 2: + return True + elif self.mode == 'SCRIPT' and level == 0: + return True + return False - if level == 2: #outermost node group + def process_mat_node_group(node_tree, level, node_vars, used_vars): + if is_outermost_node_group(level): nt_var = create_var(self.material_name, used_vars) nt_name = self.material_name else: @@ -153,7 +164,7 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): file.write(f"{outer}#initialize {nt_var} node group\n") file.write(f"{outer}def {nt_var}_node_group():\n") - if level == 2: #outermost node group + if is_outermost_node_group(level): #outermost node group file.write(f"{inner}{nt_var} = mat.node_tree\n") file.write(f"{inner}#start with a clean node tree\n") file.write(f"{inner}for node in {nt_var}.nodes:\n") @@ -161,8 +172,8 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): else: file.write((f"{inner}{nt_var}" f"= bpy.data.node_groups.new(" - f"type = \"ShaderNodeTree\", " - f"name = \"{nt_name}\")\n")) + f"type = \'ShaderNodeTree\', " + f"name = {str_to_py_str(nt_name)})\n")) file.write("\n") inputs_set = False @@ -171,7 +182,9 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): #initialize nodes file.write(f"{inner}#initialize {nt_var} nodes\n") + #dictionary to keep track of node->variable name pairs node_vars = {} + for node in node_tree.nodes: if node.bl_idname == 'ShaderNodeGroup': node_nt = node.node_tree @@ -193,22 +206,28 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): elif node.bl_idname == 'NodeGroupInput' and not inputs_set: group_io_settings(node, file, inner, "input", nt_var, node_tree) inputs_set = True + elif node.bl_idname == 'NodeGroupOutput' and not outputs_set: group_io_settings(node, file, inner, "output", nt_var, node_tree) outputs_set = True - elif node.bl_idname in image_nodes: + elif node.bl_idname in image_nodes and self.mode == 'ADDON': img = node.image if img is not None and img.source in {'FILE', 'GENERATED', 'TILED'}: save_image(img, addon_dir) load_image(img, file, inner, f"{node_var}.image") image_user_settings(node, file, inner, node_var) + elif node.bl_idname == 'ShaderNodeValToRGB': color_ramp_settings(node, file, inner, node_var) + elif node.bl_idname in curve_nodes: curve_node_settings(node, file, inner, node_var) - set_input_defaults(node, file, inner, node_var, addon_dir) + if self.mode == 'ADDON': + set_input_defaults(node, file, inner, node_var, addon_dir) + else: + set_input_defaults(node, file, inner, node_var) set_output_defaults(node, file, inner, node_var) set_parents(node_tree, file, inner, node_vars) @@ -218,18 +237,27 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): init_links(node_tree, file, inner, nt_var, node_vars) file.write(f"\n{outer}{nt_var}_node_group()\n\n") - - process_mat_node_group(nt, 2, node_vars, used_vars) - file.write("\t\treturn {'FINISHED'}\n\n") + if self.mode == 'ADDON': + level = 2 + else: + level = 0 + process_mat_node_group(nt, level, node_vars, used_vars) - create_menu_func(file, class_name) - create_register_func(file, class_name) - create_unregister_func(file, class_name) - create_main_func(file) + if self.mode == 'ADDON': + file.write("\t\treturn {'FINISHED'}\n\n") + + create_menu_func(file, class_name) + create_register_func(file, class_name) + create_unregister_func(file, class_name) + create_main_func(file) + else: + context.window_manager.clipboard = file.getvalue() file.close() - zip_addon(zip_dir) + + if self.mode == 'ADDON': + zip_addon(zip_dir) self.report({'INFO'}, "NodeToPython: Saved material") return {'FINISHED'} diff --git a/utils.py b/utils.py index c81be18..41d831b 100644 --- a/utils.py +++ b/utils.py @@ -441,7 +441,7 @@ def curve_node_settings(node, file: TextIO, inner: str, node_var: str): file.write(f"{mapping_var}.update()\n") def set_input_defaults(node, file: TextIO, inner: str, node_var: str, - addon_dir: str): + addon_dir: str = ""): """ Sets defaults for input sockets @@ -476,7 +476,7 @@ def set_input_defaults(node, file: TextIO, inner: str, node_var: str, elif input.bl_idname == 'NodeSocketImage': print("Input is linked: ", input.is_linked) img = input.default_value - if img is not None: + if img is not None and addon_dir != "": #write in a better way save_image(img, addon_dir) load_image(img, file, inner, f"{socket_var}.default_value") default_val = None From 2b769e03b73970956f5ffc85229438e55f92bb81 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 24 Jun 2023 19:33:20 -0500 Subject: [PATCH 03/12] feat: added option panel and save directory --- __init__.py | 35 +++++++++++++++++++++++++++++++++-- geo_nodes.py | 11 +++++++---- materials.py | 6 +++--- utils.py | 2 -- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/__init__.py b/__init__.py index 0ccd674..9f77d96 100644 --- a/__init__.py +++ b/__init__.py @@ -21,7 +21,7 @@ class NodeToPythonMenu(bpy.types.Menu): bl_idname = "NODE_MT_node_to_python" bl_label = "Node To Python" - + @classmethod def poll(cls, context): return True @@ -30,21 +30,52 @@ def draw(self, context): layout = self.layout.column_flow(columns=1) layout.operator_context = 'INVOKE_DEFAULT' + +class NTPOptions(bpy.types.PropertyGroup): + dir_path : bpy.props.StringProperty( + name = "Save Location", + subtype='DIR_PATH', + description="Save location if generating an add-on", + default = "//" + ) + +class NTPOptionsPanel(bpy.types.Panel): + bl_label = "Options" + bl_idname = "NODE_PT_ntp_options" + bl_space_type = 'NODE_EDITOR' + bl_region_type = 'UI' + bl_context = '' + bl_category = "NodeToPython" + + @classmethod + def poll(cls, context): + return True + def draw(self, context): + layout = self.layout + layout.operator_context = 'INVOKE_DEFAULT' + layout.prop(context.scene.ntp_options, "dir_path") + classes = [NodeToPythonMenu, + NTPOptions, geo_nodes.GeoNodesToPython, geo_nodes.SelectGeoNodesMenu, geo_nodes.GeoNodesToPythonPanel, materials.MaterialToPython, materials.SelectMaterialMenu, - materials.MaterialToPythonPanel + materials.MaterialToPythonPanel, + NTPOptionsPanel ] def register(): for cls in classes: bpy.utils.register_class(cls) + scene = bpy.types.Scene + scene.ntp_options = bpy.props.PointerProperty(type=NTPOptions) + def unregister(): for cls in classes: bpy.utils.unregister_class(cls) + del bpy.types.Scene.ntp_options if __name__ == "__main__": register() \ No newline at end of file diff --git a/geo_nodes.py b/geo_nodes.py index e20b985..07d5a56 100644 --- a/geo_nodes.py +++ b/geo_nodes.py @@ -176,6 +176,7 @@ class GeoNodesToPython(bpy.types.Operator): ('ADDON', "Addon", "Create a full addon") ] ) + geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") def execute(self, context): @@ -187,16 +188,17 @@ def execute(self, context): if self.mode == 'ADDON': #find base directory to save new addon - base_dir = bpy.path.abspath("//") - if not base_dir or base_dir == "": + dir = bpy.path.abspath(context.scene.ntp_options.dir_path) + if not dir or dir == "": self.report({'ERROR'}, ("NodeToPython: Save your blend file before using " "NodeToPython!")) return {'CANCELLED'} #save in addons/ subdirectory - zip_dir = os.path.join(base_dir, "addons", nt_var) + zip_dir = os.path.join(dir, nt_var) addon_dir = os.path.join(zip_dir, nt_var) + if not os.path.exists(addon_dir): os.makedirs(addon_dir) file = open(f"{addon_dir}/__init__.py", "w") @@ -358,7 +360,8 @@ def apply_modifier(): if self.mode == 'ADDON': zip_addon(zip_dir) - self.report({'INFO'}, "NodeToPython: Saved geometry nodes group") + self.report({'INFO'}, + f"NodeToPython: Saved geometry nodes group to {dir}") return {'FINISHED'} def invoke(self, context, event): diff --git a/materials.py b/materials.py index eea30fb..7f244b8 100644 --- a/materials.py +++ b/materials.py @@ -103,7 +103,7 @@ def execute(self, context): mat_var = clean_string(self.material_name) if self.mode == 'ADDON': - dir = bpy.path.abspath("//") + dir = bpy.path.abspath(context.scene.ntp_options.dir_path) if not dir or dir == "": self.report({'ERROR'}, ("NodeToPython: Save your blender file before using " @@ -111,7 +111,7 @@ def execute(self, context): return {'CANCELLED'} #save in addons/ subdirectory - zip_dir = os.path.join(dir, "addons", mat_var) + zip_dir = os.path.join(dir, mat_var) addon_dir = os.path.join(zip_dir, mat_var) if not os.path.exists(addon_dir): os.makedirs(addon_dir) @@ -258,7 +258,7 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): if self.mode == 'ADDON': zip_addon(zip_dir) - self.report({'INFO'}, "NodeToPython: Saved material") + self.report({'INFO'}, f"NodeToPython: Saved material to {dir}") return {'FINISHED'} def invoke(self, context, event): diff --git a/utils.py b/utils.py index 41d831b..1b20533 100644 --- a/utils.py +++ b/utils.py @@ -474,7 +474,6 @@ def set_input_defaults(node, file: TextIO, inner: str, node_var: str, #images elif input.bl_idname == 'NodeSocketImage': - print("Input is linked: ", input.is_linked) img = input.default_value if img is not None and addon_dir != "": #write in a better way save_image(img, addon_dir) @@ -719,7 +718,6 @@ def save_image(img, addon_dir: str): #save the image img_str = img_to_py_str(img) img_path = f"{img_dir}/{img_str}" - print("Image Path: ", img_path) if not os.path.exists(img_path): img.save_render(img_path) From 199661b960224fe0e4ac98881601dac6d754759f Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 24 Jun 2023 19:40:31 -0500 Subject: [PATCH 04/12] fix: info popup now chooses between clipboard and directory --- geo_nodes.py | 8 +++++++- materials.py | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/geo_nodes.py b/geo_nodes.py index 07d5a56..ffda531 100644 --- a/geo_nodes.py +++ b/geo_nodes.py @@ -360,8 +360,14 @@ def apply_modifier(): if self.mode == 'ADDON': zip_addon(zip_dir) + + #alert user that NTP is finished + if self.mode == 'SCRIPT': + location = "clipboard" + else: + location = dir self.report({'INFO'}, - f"NodeToPython: Saved geometry nodes group to {dir}") + f"NodeToPython: Saved geometry nodes group to {location}") return {'FINISHED'} def invoke(self, context, event): diff --git a/materials.py b/materials.py index 7f244b8..46be769 100644 --- a/materials.py +++ b/materials.py @@ -258,7 +258,11 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): if self.mode == 'ADDON': zip_addon(zip_dir) - self.report({'INFO'}, f"NodeToPython: Saved material to {dir}") + if self.mode == 'SCRIPT': + location = "clipboard" + else: + location = dir + self.report({'INFO'}, f"NodeToPython: Saved material to {location}") return {'FINISHED'} def invoke(self, context, event): From aa0aa442febd32257e20712bb20c1846f8270080 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Tue, 4 Jul 2023 20:39:28 -0500 Subject: [PATCH 05/12] refactor: moved options to separate module --- __init__.py | 30 +++++------------------------- options.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 options.py diff --git a/__init__.py b/__init__.py index 9f77d96..201080e 100644 --- a/__init__.py +++ b/__init__.py @@ -12,9 +12,11 @@ import importlib importlib.reload(materials) importlib.reload(geo_nodes) + importlib.reload(options) else: from . import materials from . import geo_nodes + from . import options import bpy @@ -31,46 +33,24 @@ def draw(self, context): layout.operator_context = 'INVOKE_DEFAULT' -class NTPOptions(bpy.types.PropertyGroup): - dir_path : bpy.props.StringProperty( - name = "Save Location", - subtype='DIR_PATH', - description="Save location if generating an add-on", - default = "//" - ) -class NTPOptionsPanel(bpy.types.Panel): - bl_label = "Options" - bl_idname = "NODE_PT_ntp_options" - bl_space_type = 'NODE_EDITOR' - bl_region_type = 'UI' - bl_context = '' - bl_category = "NodeToPython" - - @classmethod - def poll(cls, context): - return True - def draw(self, context): - layout = self.layout - layout.operator_context = 'INVOKE_DEFAULT' - layout.prop(context.scene.ntp_options, "dir_path") classes = [NodeToPythonMenu, - NTPOptions, + options.NTPOptions, geo_nodes.GeoNodesToPython, geo_nodes.SelectGeoNodesMenu, geo_nodes.GeoNodesToPythonPanel, materials.MaterialToPython, materials.SelectMaterialMenu, materials.MaterialToPythonPanel, - NTPOptionsPanel + options.NTPOptionsPanel ] def register(): for cls in classes: bpy.utils.register_class(cls) scene = bpy.types.Scene - scene.ntp_options = bpy.props.PointerProperty(type=NTPOptions) + scene.ntp_options = bpy.props.PointerProperty(type=options.NTPOptions) def unregister(): for cls in classes: diff --git a/options.py b/options.py new file mode 100644 index 0000000..09c8310 --- /dev/null +++ b/options.py @@ -0,0 +1,28 @@ +import bpy + +class NTPOptions(bpy.types.PropertyGroup): + """ + Property group used during conversion of node group to python + """ + dir_path : bpy.props.StringProperty( + name = "Save Location", + subtype='DIR_PATH', + description="Save location if generating an add-on", + default = "//" + ) + +class NTPOptionsPanel(bpy.types.Panel): + bl_label = "Options" + bl_idname = "NODE_PT_ntp_options" + bl_space_type = 'NODE_EDITOR' + bl_region_type = 'UI' + bl_context = '' + bl_category = "NodeToPython" + + @classmethod + def poll(cls, context): + return True + def draw(self, context): + layout = self.layout + layout.operator_context = 'INVOKE_DEFAULT' + layout.prop(context.scene.ntp_options, "dir_path") \ No newline at end of file From 7ac4da5874f236762e16ed1dddd5214351024a38 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Tue, 4 Jul 2023 21:16:16 -0500 Subject: [PATCH 06/12] refactor: variable names use dictionary with counts instead of set, cleanup --- geo_nodes.py | 25 ++++++++++++++++++------- materials.py | 7 ++++--- utils.py | 17 ++++++++--------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/geo_nodes.py b/geo_nodes.py index ffda531..c70f219 100644 --- a/geo_nodes.py +++ b/geo_nodes.py @@ -217,8 +217,8 @@ def execute(self, context): #dictionary to keep track of node->variable name pairs node_vars = {} - #keeps track of all used variables - used_vars = set() + #dictionary to keep track of variables->usage count pairs + used_vars = {} def process_geo_nodes_group(node_tree, level, node_vars, used_vars): nt_var = create_var(node_tree.name, used_vars) @@ -250,18 +250,20 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): used_vars) node_trees.add(node_nt) elif node.bl_idname == 'NodeGroupInput' and not inputs_set: - group_io_settings(node, file, inner, "input", nt_var, node_tree) + group_io_settings(node, file, inner, "input", nt_var, + node_tree) inputs_set = True elif node.bl_idname == 'NodeGroupOutput' and not outputs_set: - group_io_settings(node, file, inner, "output", nt_var, node_tree) + group_io_settings(node, file, inner, "output", nt_var, + node_tree) outputs_set = True #create node node_var = create_node(node, file, inner, nt_var, - node_vars, used_vars) + node_vars, used_vars) set_settings_defaults(node, geo_node_settings, file, inner, - node_var) + node_var) hide_sockets(node, file, inner, node_var) if node.bl_idname == 'GeometryNodeGroup': @@ -269,21 +271,27 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): file.write((f"{inner}{node_var}.node_tree = " f"bpy.data.node_groups" f"[{str_to_py_str(node.node_tree.name)}]\n")) + elif node.bl_idname == 'ShaderNodeValToRGB': color_ramp_settings(node, file, inner, node_var) + elif node.bl_idname in curve_nodes: curve_node_settings(node, file, inner, node_var) + elif node.bl_idname in image_nodes and self.mode == 'ADDON': img = node.image if img is not None and img.source in {'FILE', 'GENERATED', 'TILED'}: save_image(img, addon_dir) load_image(img, file, inner, f"{node_var}.image") + elif node.bl_idname == 'GeometryNodeSimulationInput': sim_inputs.append(node) + elif node.bl_idname == 'GeometryNodeSimulationOutput': file.write(f"{inner}#remove generated sim state items\n") file.write(f"{inner}for item in {node_var}.state_items:\n") file.write(f"{inner}\t{node_var}.state_items.remove(item)\n") + for i, si in enumerate(node.state_items): socket_type = enum_to_py_str(si.socket_type) name = str_to_py_str(si.name) @@ -316,10 +324,12 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars): set_input_defaults(node, file, inner, node_var) set_output_defaults(sim_input, file, inner, sim_input_var) + #set look of nodes set_parents(node_tree, file, inner, node_vars) set_locations(node_tree, file, inner, node_vars) set_dimensions(node_tree, file, inner, node_vars) + #create connections init_links(node_tree, file, inner, nt_var, node_vars) file.write(f"{inner}return {nt_var}\n") @@ -388,7 +398,8 @@ def draw(self, context): layout = self.layout.column_flow(columns=1) layout.operator_context = 'INVOKE_DEFAULT' - geo_node_groups = [node for node in bpy.data.node_groups if node.type == 'GEOMETRY'] + geo_node_groups = [node for node in bpy.data.node_groups + if node.type == 'GEOMETRY'] for geo_ng in geo_node_groups: op = layout.operator(GeoNodesToPython.bl_idname, text=geo_ng.name) diff --git a/materials.py b/materials.py index 46be769..466bee5 100644 --- a/materials.py +++ b/materials.py @@ -110,7 +110,6 @@ def execute(self, context): "NodeToPython!")) return {'CANCELLED'} - #save in addons/ subdirectory zip_dir = os.path.join(dir, mat_var) addon_dir = os.path.join(zip_dir, mat_var) if not os.path.exists(addon_dir): @@ -129,6 +128,7 @@ def create_material(indent: str): file.write((f"{indent}mat = bpy.data.materials.new(" f"name = {str_to_py_str(self.material_name)})\n")) file.write(f"{indent}mat.use_nodes = True\n") + if self.mode == 'ADDON': create_material("\t\t") elif self.mode == 'SCRIPT': @@ -141,7 +141,7 @@ def create_material(indent: str): node_vars = {} #keeps track of all used variables - used_vars = set() + used_vars = {} def is_outermost_node_group(level: int) -> bool: if self.mode == 'ADDON' and level == 2: @@ -189,7 +189,8 @@ def process_mat_node_group(node_tree, level, node_vars, used_vars): if node.bl_idname == 'ShaderNodeGroup': node_nt = node.node_tree if node_nt is not None and node_nt not in node_trees: - process_mat_node_group(node_nt, level + 1, node_vars, used_vars) + process_mat_node_group(node_nt, level + 1, node_vars, + used_vars) node_trees.add(node_nt) node_var = create_node(node, file, inner, nt_var, node_vars, diff --git a/utils.py b/utils.py index 1b20533..d416d6b 100644 --- a/utils.py +++ b/utils.py @@ -132,13 +132,13 @@ def init_operator(file: TextIO, name: str, idname: str, label: str): file.write("\tbl_options = {\'REGISTER\', \'UNDO\'}\n") file.write("\n") -def create_var(name: str, used_vars: set) -> str: +def create_var(name: str, used_vars: dict) -> str: """ Creates a unique variable name for a node tree Parameters: name (str): basic string we'd like to create the variable name out of - used_vars (set): set containing all used variable names so far + used_vars (dict): dictionary containing variable names and usage counts Returns: clean_name (str): variable name for the node tree @@ -147,13 +147,12 @@ def create_var(name: str, used_vars: set) -> str: name = "unnamed" clean_name = clean_string(name) var = clean_name - i = 0 - while var in used_vars: - i += 1 - var = f"{clean_name}_{i}" - - used_vars.add(var) - return var + if var in used_vars: + used_vars[var] += 1 + return f"{clean_name}_{used_vars[var]}" + else: + used_vars[var] = 0 + return clean_name def make_indents(level: int) -> Tuple[str, str]: """ From b6b7e37a22e1f108f9937a5f0d9c41e5abb37132 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 22:27:49 -0500 Subject: [PATCH 07/12] fix: typo in generated unregister function --- utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils.py b/utils.py index d416d6b..a61f817 100644 --- a/utils.py +++ b/utils.py @@ -684,7 +684,7 @@ def create_unregister_func(file: TextIO, name: str): """ file.write("def unregister():\n") file.write(f"\tbpy.utils.unregister_class({name})\n") - file.write("\tbpy.types.VIEW3D_MT_objects.remove(menu_func)\n") + file.write("\tbpy.types.VIEW3D_MT_object.remove(menu_func)\n") file.write("\n") def create_main_func(file: TextIO): From b765e3ff8ee6253d6f10b992af68871ddf29ebfa Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 22:34:14 -0500 Subject: [PATCH 08/12] fix: change generated bl_info category to "Node" --- utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils.py b/utils.py index a61f817..d66632b 100644 --- a/utils.py +++ b/utils.py @@ -109,7 +109,7 @@ def create_header(file: TextIO, name: str): file.write("\t\"version\" : (1, 0, 0),\n") file.write(f"\t\"blender\" : {bpy.app.version},\n") file.write("\t\"location\" : \"Object\",\n") - file.write("\t\"category\" : \"Object\"\n") + file.write("\t\"category\" : \"Node\"\n") file.write("}\n") file.write("\n") file.write("import bpy\n") From cfdd976d4a19e0e14732de27d375f7ff4791a01b Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 22:53:24 -0500 Subject: [PATCH 09/12] docs: update README --- docs/README.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index 3065a6b..3902bb1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,11 +15,11 @@ Blender's node-based editors are powerful, yet accessible tools, and I wanted to * interfacing with other parts of the software or properties of an object ## Supported Versions -NodeToPython v2.1 is compatible with Blender 3.0 - 3.6 on Windows, macOS, and Linux. I generally try to update the add-on to handle new nodes around the beta release of each update. +NodeToPython v2.2 is compatible with Blender 3.0 - 3.6 on Windows, macOS, and Linux. I generally try to update the add-on to handle new nodes around the beta release of each update. ## Installation 1. Download the `NodeToPython.zip` file from the [latest release](https://github.com/BrendanParmer/NodeToPython/releases) - * If you clone the repository or download other options, you'll need to rename the zip and the first folder to "NodeToPython" so Blender can properly import the add-on + * 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 2. In Blender, navigate to `Edit > Preferences > Add-ons` 3. Click Install, and find where you downloaded the zip file. Then hit the `Install Add-on` button, and you're done! @@ -35,13 +35,9 @@ Just select the one you want, and soon a zip file will be created in an `addons` From here, you can install it like a regular add-on. ## Future -### v2.2 -* A "copy" mode, where just the functionality to build the node group is just copied to the clipbaord -* Choose the location where to save the add-on - -### v2.3 +### v3.0 * Expansion to Compositing nodes -* Add all referenced assets to the Asset Library for use outside of the original blend file +* Better asset handling ### Later * Auto-set handle movies and image sequences @@ -49,7 +45,7 @@ From here, you can install it like a regular add-on. * Automatically detect the minimum version of Blender needed to run the add-on ## Potential Issues -* As of version 2.1, the add-on will not set default values for +* As of version 2.2, the add-on will not set default values for * Scripts * IES files * Filepaths @@ -62,6 +58,7 @@ From here, you can install it like a regular add-on. * Textures * In a future version, I plan on having the add-on adding all of the above to the Asset Library for reference +* You may run into naming conflicts if your addon shares a name with another Blender addon or operator ## Bug Reports and Suggestions From f11db0fa352087928dfb2bc3807e5565765c21d4 Mon Sep 17 00:00:00 2001 From: Brendan Parmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 22:59:27 -0500 Subject: [PATCH 10/12] docs: update README --- docs/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 3902bb1..47b8800 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,9 +37,10 @@ From here, you can install it like a regular add-on. ## Future ### v3.0 * Expansion to Compositing nodes -* Better asset handling +* New Blender 4.0 nodes and changes ### Later +* Better asset handling * Auto-set handle movies and image sequences * Automatically format code to be PEP8 compliant * Automatically detect the minimum version of Blender needed to run the add-on @@ -58,7 +59,7 @@ From here, you can install it like a regular add-on. * Textures * In a future version, I plan on having the add-on adding all of the above to the Asset Library for reference -* You may run into naming conflicts if your addon shares a name with another Blender addon or operator +* You may run into naming conflicts if your addon shares a name with another Blender addon or operator (see [#56](https://github.com/BrendanParmer/NodeToPython/issues/56)) ## Bug Reports and Suggestions @@ -69,4 +70,4 @@ When submitting an issue, please include * A short description of what you were trying to accomplish, or steps to reproduce the issue. * Sample blend files are more than welcome! -Got suggestions? Create an issue, happy to hear what features people want. \ No newline at end of file +Got suggestions? Create an issue, happy to hear what features people want. From f67b6ec35d168c00dbb001110a5ce5efb4c26c1d Mon Sep 17 00:00:00 2001 From: Brendan Parmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 23:30:51 -0500 Subject: [PATCH 11/12] docs: update README.md --- docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 47b8800..b2dab5f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,7 +35,7 @@ Just select the one you want, and soon a zip file will be created in an `addons` From here, you can install it like a regular add-on. ## Future -### v3.0 +### v3.x * Expansion to Compositing nodes * New Blender 4.0 nodes and changes @@ -59,7 +59,7 @@ From here, you can install it like a regular add-on. * Textures * In a future version, I plan on having the add-on adding all of the above to the Asset Library for reference -* You may run into naming conflicts if your addon shares a name with another Blender addon or operator (see [#56](https://github.com/BrendanParmer/NodeToPython/issues/56)) +* You may run into naming conflicts if your add-on shares a name with another Blender add-on or operator (see [#56](https://github.com/BrendanParmer/NodeToPython/issues/56)) ## Bug Reports and Suggestions From 7452ba30055f85a02d5e427a32121ebc63d10855 Mon Sep 17 00:00:00 2001 From: Brendan Parmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 2 Aug 2023 23:40:29 -0500 Subject: [PATCH 12/12] docs: update README.md --- docs/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index b2dab5f..e82bbb8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ [![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) ## About -A Blender add-on to create add-ons! This add-on will take your Geometry Nodes or Materials and convert them into legible Python add-ons! +A Blender add-on to create scripts and add-ons! This add-on will take your Geometry Nodes or Materials and convert them into legible Python code. Node To Python automatically handles node layout, default values, subgroups, naming, colors, and more! @@ -30,9 +30,11 @@ In the tab, there's panels to create add-ons for Geometry Nodes and Materials, e ![Add-on Location](./img/location.png "Add-on Location") -Just select the one you want, and soon a zip file will be created in an `addons` folder located in the folder where your blend file is. - -From here, you can install it like a regular add-on. +Select the node group you want code for, and you'll be prompted with a **Script** or **Add-on** option. +* **Script** mode creates a function that generates the node tree and copies it to your Blender clipboard. + * Doesn't include `import bpy` line + * To keep NodeToPython cross-platform and independent of third-party libraries, to get it into your system clipboard you need to paste into the Blender text editor and recopy it currently +* **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 for the node tree to be used in. ## Future ### v3.x