diff --git a/blender_manifest.toml b/blender_manifest.toml index 51840c2..7443d4c 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -7,9 +7,6 @@ tagline = "Turn node groups into Python code" maintainer = "Brendan Parmer " type = "add-on" -# In add-on mode, NodeToPython will create and write to files at a specified directory -permissions = ["files"] - website = "https://github.com/BrendanParmer/NodeToPython" tags = ["Development", "Compositing", "Geometry Nodes", "Material", "Node"] @@ -19,4 +16,7 @@ blender_version_max = "4.3.0" license = [ "SPDX:MIT", -] \ No newline at end of file +] + +[permissions] +files = "In add-on mode, NodeToPython will create and write to files in a specified directory" \ No newline at end of file diff --git a/compositor/operator.py b/compositor/operator.py index aad426f..f7ff414 100644 --- a/compositor/operator.py +++ b/compositor/operator.py @@ -20,14 +20,6 @@ class NTPCompositorOperator(NTP_Operator): bl_idname = "node.ntp_compositor" bl_label = "Compositor 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") - ] - ) compositor_name: bpy.props.StringProperty(name="Node Group") is_scene : bpy.props.BoolProperty(name="Is Scene", description="Blender stores compositing node trees differently for scenes and in groups") @@ -193,6 +185,9 @@ def _process_node_tree(self, node_tree: CompositorNodeTree): self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer) def execute(self, context): + if not self._setup_options(context.scene.ntp_options): + return {'CANCELLED'} + #find node group to replicate if self.is_scene: self._base_node_tree = bpy.data.scenes[self.compositor_name].node_tree @@ -209,7 +204,7 @@ def execute(self, context): #set up names to use in generated addon comp_var = clean_string(self.compositor_name) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._outer = "\t\t" self._inner = "\t\t\t" @@ -225,11 +220,13 @@ def execute(self, context): self._write("def execute(self, context):", "\t") else: self._file = StringIO("") + if self._include_imports: + self._file.write("import bpy, mathutils\n\n") if self.is_scene: - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._create_scene("\t\t") - elif self.mode == 'SCRIPT': + elif self._mode == 'SCRIPT': self._create_scene("") node_trees_to_process = self._topological_sort(self._base_node_tree) @@ -237,7 +234,7 @@ def execute(self, context): for node_tree in node_trees_to_process: self._process_node_tree(node_tree) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._write("return {'FINISHED'}\n", self._outer) self._create_menu_func() @@ -249,7 +246,7 @@ def execute(self, context): self._file.close() - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._zip_addon() self._report_finished("compositor nodes") diff --git a/geometry/operator.py b/geometry/operator.py index 5bdf4de..508fd08 100644 --- a/geometry/operator.py +++ b/geometry/operator.py @@ -20,14 +20,6 @@ class NTPGeoNodesOperator(NTP_Operator): bl_idname = "node.ntp_geo_nodes" 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") @@ -179,13 +171,16 @@ def _apply_modifier(self, nt: GeometryNodeTree, nt_var: str): def execute(self, context): + if not self._setup_options(context.scene.ntp_options): + return {'CANCELLED'} + #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) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._outer = "\t\t" self._inner = "\t\t\t" @@ -200,13 +195,16 @@ def execute(self, context): self._write("def execute(self, context):", "\t") else: self._file = StringIO("") + if self._include_imports: + self._file.write("import bpy, mathutils\n\n") + node_trees_to_process = self._topological_sort(nt) for node_tree in node_trees_to_process: self._process_node_tree(node_tree) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._apply_modifier(nt, nt_var) self._write("return {'FINISHED'}\n", self._outer) self._create_menu_func() @@ -217,7 +215,7 @@ def execute(self, context): context.window_manager.clipboard = self._file.getvalue() self._file.close() - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._zip_addon() self._report_finished("geometry node group") diff --git a/ntp_operator.py b/ntp_operator.py index a82b471..adee2b8 100644 --- a/ntp_operator.py +++ b/ntp_operator.py @@ -14,6 +14,7 @@ import shutil from .ntp_node_tree import NTP_NodeTree +from .options import NTPOptions from .node_settings import NodeInfo, ST from .utils import * @@ -46,14 +47,6 @@ class NTP_Operator(Operator): bl_idname = "" bl_label = "" - mode: bpy.props.EnumProperty( - name="Mode", - items=[ - ('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"), - ('ADDON', "Addon", "Create a full addon") - ] - ) - # node tree input sockets that have default properties if bpy.app.version < (4, 0, 0): default_sockets_v3 = {'VALUE', 'INT', 'BOOLEAN', 'VECTOR', 'RGBA'} @@ -74,9 +67,6 @@ def __init__(self): # File (TextIO) or string (StringIO) the add-on/script is generated into self._file: TextIO = None - # Path to the current directory - self._dir: str = None - # Path to the directory of the zip file self._zip_dir: str = None @@ -108,11 +98,49 @@ def __init__(self): for name in RESERVED_NAMES: self._used_vars[name] = 0 + # Generate socket default, min, and max values + self._include_group_socket_values = True + + # Set dimensions of generated nodes + self._should_set_dimensions = True + + if bpy.app.version >= (3, 4, 0): + # Set default values for hidden sockets + self._set_unavailable_defaults = False + def _write(self, string: str, indent: str = None): if indent is None: indent = self._inner self._file.write(f"{indent}{string}\n") + def _setup_options(self, options: NTPOptions) -> bool: + # General + self._mode = options.mode + self._include_group_socket_values = options.include_group_socket_values + self._should_set_dimensions = options.set_dimensions + if bpy.app.version >= (3, 4, 0): + self._set_unavailable_defaults = options.set_unavailable_defaults + + #Script + if options.mode == 'SCRIPT': + self._include_imports = options.include_imports + #Addon + elif options.mode == 'ADDON': + self._dir_path = bpy.path.abspath(options.dir_path) + self._name_override = options.name_override + self._description = options.description + self._author_name = options.author_name + self._version = options.version + self._location = options.location + self._category = options.category + self._custom_category = options.custom_category + if options.menu_id in dir(bpy.types): + self._menu_id = options.menu_id + else: + self.report({'ERROR'}, f"{options.menu_id} is not a valid menu") + return False + return True + def _setup_addon_directories(self, context: Context, nt_var: str) -> bool: """ Finds/creates directories to save add-on to @@ -124,15 +152,13 @@ def _setup_addon_directories(self, context: Context, nt_var: str) -> bool: Returns: (bool): success of addon directory setup """ - # find base directory to save new addon - self._dir = bpy.path.abspath(context.scene.ntp_options.dir_path) - if not self._dir or self._dir == "": + if not self._dir_path or self._dir_path == "": self.report({'ERROR'}, ("NodeToPython: No save location found. Please select " "one in the NodeToPython Options panel")) return False - self._zip_dir = os.path.join(self._dir, nt_var) + self._zip_dir = os.path.join(self._dir_path, nt_var) self._addon_dir = os.path.join(self._zip_dir, nt_var) if not os.path.exists(self._addon_dir): @@ -150,12 +176,19 @@ def _create_header(self, name: str) -> None: """ self._write("bl_info = {", "") - self._write(f"\t\"name\" : \"{name}\",", "") - self._write("\t\"author\" : \"Node To Python\",", "") - self._write("\t\"version\" : (1, 0, 0),", "") + if self._name_override and self._name_override != "": + name = self._name_override + self._write(f"\t\"name\" : {str_to_py_str(name)},", "") + if self._description and self._description != "": + self.write(f"\t\"description\" : {str_to_py_str(self._description)}," "") + self._write(f"\t\"author\" : {str_to_py_str(self._author_name)},", "") + self._write(f"\t\"version\" : {vec3_to_py_str(self._version)},", "") self._write(f"\t\"blender\" : {bpy.app.version},", "") - self._write("\t\"location\" : \"Object\",", "") # TODO - self._write("\t\"category\" : \"Node\"", "") + self._write(f"\t\"location\" : {str_to_py_str(self._location)},", "") + category = self._category + if category == "Custom": + category = self._custom_category + self._write(f"\t\"category\" : {str_to_py_str(category)},", "") self._write("}\n", "") self._write("import bpy", "") self._write("import mathutils", "") @@ -172,8 +205,8 @@ def _init_operator(self, idname: str, label: str) -> None: label (str): appearence inside Blender """ self._write(f"class {self._class_name}(bpy.types.Operator):", "") - self._write(f"\tbl_idname = \"object.{idname}\"", "") - self._write(f"\tbl_label = \"{label}\"", "") + self._write(f"\tbl_idname = \"node.{idname}\"", "") + self._write(f"\tbl_label = {str_to_py_str(label)}", "") self._write("\tbl_options = {\'REGISTER\', \'UNDO\'}", "") self._write("") @@ -389,6 +422,9 @@ def _set_group_socket_defaults(self, socket_interface: NodeSocketInterface, with the input/output socket_var (str): variable name for the socket """ + if not self._include_group_socket_values: + return + if socket_interface.type not in self.default_sockets_v3: return @@ -485,6 +521,8 @@ def _set_tree_socket_defaults(self, socket_interface: NodeTreeInterfaceSocket, with the input/output socket_var (str): variable name for the socket """ + if not self._include_group_socket_values: + return if type(socket_interface) in self.nondefault_sockets_v4: return @@ -710,6 +748,10 @@ def _set_input_defaults(self, node: Node) -> None: for i, input in enumerate(node.inputs): if input.bl_idname not in DONT_SET_DEFAULTS and not input.is_linked: + if bpy.app.version >= (3, 4, 0): + if (not self._set_unavailable_defaults) and input.is_unavailable: + continue + # TODO: this could be cleaner socket_var = f"{node_var}.inputs[{i}]" @@ -1006,13 +1048,19 @@ def _save_image(self, img: bpy.types.Image) -> None: if img is None: return + img_str = img_to_py_str(img) + + if not img.has_data: + self.report({'WARNING'}, f"{img_str} has no data") + return + # create image dir if one doesn't exist img_dir = os.path.join(self._addon_dir, IMAGE_DIR_NAME) if not os.path.exists(img_dir): os.mkdir(img_dir) # save the image - img_str = img_to_py_str(img) + img_path = f"{img_dir}/{img_str}" if not os.path.exists(img_path): img.save_render(img_path) @@ -1212,6 +1260,9 @@ def _set_dimensions(self, node_tree: NodeTree) -> None: Parameters: node_tree (NodeTree): node tree we're obtaining nodes from """ + if not self._should_set_dimensions: + return + self._write(f"#Set dimensions") for node in node_tree.nodes: node_var = self._node_vars[node] @@ -1305,7 +1356,7 @@ def _create_register_func(self) -> None: """ self._write("def register():", "") self._write(f"bpy.utils.register_class({self._class_name})", "\t") - self._write("bpy.types.VIEW3D_MT_object.append(menu_func)", "\t") + self._write(f"bpy.types.{self._menu_id}.append(menu_func)", "\t") self._write("") def _create_unregister_func(self) -> None: @@ -1314,7 +1365,7 @@ def _create_unregister_func(self) -> None: """ self._write("def unregister():", "") self._write(f"bpy.utils.unregister_class({self._class_name})", "\t") - self._write("bpy.types.VIEW3D_MT_object.remove(menu_func)", "\t") + self._write(f"bpy.types.{self._menu_id}.remove(menu_func)", "\t") self._write("") def _create_main_func(self) -> None: @@ -1347,18 +1398,12 @@ def _report_finished(self, object: str): object (str): the copied node tree or encapsulating structure (geometry node modifier, material, scene, etc.) """ - if self.mode == 'SCRIPT': + if self._mode == 'SCRIPT': location = "clipboard" else: - location = self._dir + location = self._dir_path self.report({'INFO'}, f"NodeToPython: Saved {object} to {location}") # ABSTRACT def execute(self): return {'FINISHED'} - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - self.layout.prop(self, "mode") diff --git a/options.py b/options.py index 09c8310..47871df 100644 --- a/options.py +++ b/options.py @@ -4,13 +4,119 @@ class NTPOptions(bpy.types.PropertyGroup): """ Property group used during conversion of node group to python """ + # General properties + mode: bpy.props.EnumProperty( + name = "Mode", + items = [ + ('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"), + ('ADDON', "Addon", "Create a full add-on") + ] + ) + include_group_socket_values : bpy.props.BoolProperty( + name = "Include group socket values", + description = "Generate group socket default, min, and max values", + default = True + ) + set_dimensions : bpy.props.BoolProperty( + name = "Set dimensions", + description = "Set dimensions of generated nodes", + default = True + ) + if bpy.app.version >= (3, 4, 0): + set_unavailable_defaults : bpy.props.BoolProperty( + name = "Set unavailable defaults", + description = "Set default values for unavailable sockets", + default = False + ) + + #Script properties + include_imports : bpy.props.BoolProperty( + name = "Include imports", + description="Generate necessary import statements", + default = True + ) + # Addon properties dir_path : bpy.props.StringProperty( name = "Save Location", subtype='DIR_PATH', description="Save location if generating an add-on", default = "//" ) - + name_override : bpy.props.StringProperty( + name = "Name Override", + description="Name used for the add-on's, default is node group name", + default = "" + ) + description : bpy.props.StringProperty( + name = "Description", + description="Description used for the add-on", + default="" + ) + author_name : bpy.props.StringProperty( + name = "Author", + description = "Name used for the author/maintainer of the add-on", + default = "Node To Python" + ) + version: bpy.props.IntVectorProperty( + name = "Version", + description="Version of the add-on", + default = (1, 0, 0) + ) + location: bpy.props.StringProperty( + name = "Location", + description="Location of the addon", + default="Node" + ) + menu_id: bpy.props.StringProperty( + name = "Menu ID", + description = "Python ID of the menu you'd like to register the add-on " + "to. You can find this by enabling Python tooltips " + "(Preferences > Interface > Python tooltips) and " + "hovering over the desired menu", + default="NODE_MT_add" + ) + category: bpy.props.EnumProperty( + name = "Category", + items = [ + ('Custom', "Custom", "Use an unofficial category"), + ('3D View', "3D View", ""), + ('Add Curve', "Add Curve", ""), + ('Add Mesh', "Add Mesh", ""), + ('Animation', "Animation", ""), + ('Bake', "Bake", ""), + ('Compositing', "Compositing", ""), + ('Development', "Development", ""), + ('Game Engine', "Game Engine", ""), + ('Geometry Nodes', "Geometry Nodes", ""), + ("Grease Pencil", "Grease Pencil", ""), + ('Import-Export', "Import-Export", ""), + ('Lighting', "Lighting", ""), + ('Material', "Material", ""), + ('Mesh', "Mesh", ""), + ('Modeling', "Modeling", ""), + ('Node', "Node", ""), + ('Object', "Object", ""), + ('Paint', "Paint", ""), + ('Pipeline', "Pipeline", ""), + ('Physics', "Physics", ""), + ('Render', "Render", ""), + ('Rigging', "Rigging", ""), + ('Scene', "Scene", ""), + ('Sculpt', "Sculpt", ""), + ('Sequencer', "Sequencer", ""), + ('System', "System", ""), + ('Text Editor', "Text Editor", ""), + ('Tracking', "Tracking", ""), + ('UV', "UV", ""), + ('User Interface', "User Interface", ""), + ], + default = 'Node' + ) + custom_category: bpy.props.StringProperty( + name="Custom Category", + description="Custom category", + default = "" + ) class NTPOptionsPanel(bpy.types.Panel): bl_label = "Options" bl_idname = "NODE_PT_ntp_options" @@ -25,4 +131,33 @@ def poll(cls, context): 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 + ntp_options = context.scene.ntp_options + + option_list = [ + "mode", + "include_group_socket_values", + "set_dimensions" + ] + if bpy.app.version >= (3, 4, 0): + option_list.append("set_unavailable_defaults") + + if ntp_options.mode == 'SCRIPT': + script_options = [ + "include_imports" + ] + option_list += script_options + elif ntp_options.mode == 'ADDON': + addon_options = [ + "dir_path", + "author_name", + "version", + "location", + "menu_id", + "category" + ] + option_list += addon_options + if ntp_options.category == 'CUSTOM': + option_list.append("custom_category") + + for option in option_list: + layout.prop(ntp_options, option) \ No newline at end of file diff --git a/shader/operator.py b/shader/operator.py index bbe7a09..bd136fe 100644 --- a/shader/operator.py +++ b/shader/operator.py @@ -128,6 +128,9 @@ def _process_node_tree(self, node_tree: ShaderNodeTree) -> None: def execute(self, context): + if not self._setup_options(context.scene.ntp_options): + return {'CANCELLED'} + #find node group to replicate self._base_node_tree = bpy.data.materials[self.material_name].node_tree if self._base_node_tree is None: @@ -138,7 +141,7 @@ def execute(self, context): #set up names to use in generated addon mat_var = clean_string(self.material_name) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._outer = "\t\t" self._inner = "\t\t\t" @@ -154,10 +157,12 @@ def execute(self, context): self._write("def execute(self, context):", "\t") else: self._file = StringIO("") + if self._include_imports: + self._file.write("import bpy, mathutils\n\n") - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._create_material("\t\t") - elif self.mode == 'SCRIPT': + elif self._mode == 'SCRIPT': self._create_material("") node_trees_to_process = self._topological_sort(self._base_node_tree) @@ -165,7 +170,7 @@ def execute(self, context): for node_tree in node_trees_to_process: self._process_node_tree(node_tree) - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._write("return {'FINISHED'}", self._outer) self._create_menu_func() self._create_register_func() @@ -176,7 +181,7 @@ def execute(self, context): self._file.close() - if self.mode == 'ADDON': + if self._mode == 'ADDON': self._zip_addon() self._report_finished("material")