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..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 @@ -168,6 +169,13 @@ 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): @@ -176,28 +184,30 @@ def execute(self, context): #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() @@ -218,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 @@ -256,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) @@ -284,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 @@ -295,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) @@ -311,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 @@ -323,21 +343,30 @@ 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'} + + 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..eea30fb 100644 --- a/materials.py +++ b/materials.py @@ -2,6 +2,7 @@ import os from .utils import * +from io import StringIO node_settings = { #input @@ -81,43 +82,57 @@ 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): #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() @@ -128,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: @@ -143,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") @@ -151,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 @@ -161,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 @@ -183,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) @@ -208,19 +237,34 @@ 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) + + 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) + 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'} + + 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..41d831b 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) @@ -443,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 @@ -478,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