diff --git a/README.md b/README.md deleted file mode 100644 index b2d79df..0000000 --- a/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Node to Python -Node To Python - -[![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 script will take your Geometry Node group and convert it into a legible Python script. - -It automatically handles node layout, default values, sub-node groups, naming, and more! - -I think Geometry Nodes is a powerful tool that's fairly accessible to people, and I wanted to create a lightweight, faster way of distributing them than just passing around blend files. It also makes scripting Geometry Nodes easier for add-on creators in cases when Python is needed, as you don't need to recreate the whole node tree from scratch to do things like -* `for` loops -* different node trees for different versions or settings -* interfacing with other parts of the software. - -NodeToPython is compatible with Blender 3.0-3.4 - -## Supported Versions -Blender 3.0 - 3.4 - -* Once the 3.5 beta drops, I'll start adding nodes from that release - -## Installation and Usage -Download `node_to_python.py`, and install it to Blender like other add-ons. Then, go to `Object > Node to Python`, and type in the name of your node group. It will then save an add-on to where your blend file is stored. - -## Future -* Expansion to Shader and Compositing nodes -* Copy over referenced assets in the scene (Collections, Objects, Materials, Textures, etc.) -* Automatically format code to be PEP8 compliant - -## Potential Issues -* This should work on Unix-like systems (macOS, Linux), but I haven't tested it on Windows yet. If you use Windows, please let me know if it does! -* As of version 1.0.0, the add-on will not set default values for - * Collections - * Images - * Materials - * Objects - * Textures - - as they won't exist in every blend file. In the future, I may have the script automatically recreate these assets, espcially with materials. - -## Bug Reports and Suggestions - -When submitting an issue, please include - -* Your version of Blender -* Your operating system -* A short description of what you were trying to accomplish, or steps to reproduce the issue - -Suggestions for how to improve the add-on are more than welcome! - diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..513d9d3 --- /dev/null +++ b/__init__.py @@ -0,0 +1,49 @@ +bl_info = { + "name": "Node to Python", + "description": "Convert Blender node groups to a Python add-on!", + "author": "Brendan Parmer", + "version": (2, 0, 0), + "blender": (3, 0, 0), + "location": "Node", + "category": "Node", +} + +if "bpy" in locals(): + import importlib + importlib.reload(materials) + importlib.reload(geo_nodes) +else: + from . import materials + from . import geo_nodes + +import bpy + +class NodeToPythonMenu(bpy.types.Menu): + bl_idname = "NODE_MT_node_to_python" + bl_label = "Node To Python" + + @classmethod + def poll(cls, context): + return True + + def draw(self, context): + layout = self.layout.column_flow(columns=1) + layout.operator_context = 'INVOKE_DEFAULT' + +classes = [NodeToPythonMenu, + geo_nodes.GeoNodesToPython, + geo_nodes.SelectGeoNodesMenu, + geo_nodes.GeoNodesToPythonPanel, + materials.MaterialToPython, + materials.SelectMaterialMenu, + materials.MaterialToPythonPanel] + +def register(): + for cls in classes: + bpy.utils.register_class(cls) +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + +if __name__ == "__main__": + register() \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..74d6bdd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# Node to Python + +![Node To Python Logo](./img/ntp.jpg "Node To Python Logo") + +[![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! + +It automatically handles node layout, default values, subgroups, naming, colors, and more! + +I think 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 +* `for` loops +* creating different node trees for different versions or settings +* interfacing with other parts of the software or properties of an object + +NodeToPython recreates the node networks for you, so you can focus on the good stuff. + +## Supported Versions +NodeToPython v2.0 is compatible with Blender 3.0 - 3.4 on Windows, macOS, and Linux. I generally try to update the addon to handle new nodes around the beta release of each update. + +## Installation +1. Download the .zip file from the [latest release](https://github.com/BrendanParmer/NodeToPython/releases) +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! + +## Usage +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. + +In the tab, there's panels to create add-ons for Geometry Nodes and Materials, each with a drop-down menu. + +![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. + +## Future +* Expansion to Compositing nodes +* Add all referenced assets to the Asset Library for use outside of the original blend file +* 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 + +## Potential Issues +* As of version 2.0.0, the add-on will not set default values for + * Scripts + * IES files + * Filepaths + * UV maps +* Currently when setting default values for the following, the add-on must be run in the same blend file as the node group was created in to set the default, otherwise it will just set it to `None`: + * Materials + * Objects + * Collections + * Textures + +* In a future version, I plan on having the add-on adding all of the above to the Asset Library for reference + +## Bug Reports and Suggestions + +When submitting an issue, please include + +* Your version of Blender +* Your operating system +* A short description of what you were trying to accomplish, or steps to reproduce the issue. +* Sample blend files are more than welcome! + +Suggestions for how to improve the add-on are more than welcome! \ No newline at end of file diff --git a/docs/img/location.png b/docs/img/location.png new file mode 100644 index 0000000..98c8b79 Binary files /dev/null and b/docs/img/location.png differ diff --git a/img/ntp.jpg b/docs/img/ntp.jpg similarity index 100% rename from img/ntp.jpg rename to docs/img/ntp.jpg diff --git a/geo_nodes.py b/geo_nodes.py new file mode 100644 index 0000000..e9eff4c --- /dev/null +++ b/geo_nodes.py @@ -0,0 +1,415 @@ +import bpy +import os + +from .utils import * + +#node tree input sockets that have default properties +default_sockets = {'NodeSocketBool', + 'NodeSocketColor', + 'NodeSocketFloat', + 'NodeSocketInt', + 'NodeSocketVector'} + +geo_node_settings = { + #attribute + "GeometryNodeAttributeStatistic" : ["data_type", "domain"], + "GeometryNodeCaptureAttribute" : ["data_type", "domain"], + "GeometryNodeAttributeDomainSize" : ["component"], + "GeometryNodeStoreNamedAttribute" : ["data_type", "domain"], + "GeometryNodeAttributeTransfer" : ["data_type", "mapping"], + + #color + "ShaderNodeMixRGB" : ["blend_type", "use_clamp"], + "FunctionNodeCombineColor" : ["mode"], + "FunctionNodeSeparateColor" : ["mode"], + + #curve + "GeometryNodeCurveToPoints" : ["mode"], + "GeometryNodeFillCurve" : ["mode"], + "GeometryNodeFilletCurve" : ["mode"], + "GeometryNodeResampleCurve" : ["mode"], + "GeometryNodeSampleCurve" : ["data_type", "mode", "use_all_curves"], + "GeometryNodeTrimCurve" : ["mode"], + "GeometryNodeSetCurveNormal" : ["mode"], + "GeometryNodeCurveHandleTypeSelection" : ["mode", "handle_type"], + "GeometryNodeSetCurveHandlePositions" : ["mode"], + "GeometryNodeCurveSetHandles" : ["mode", "handle_type"], + "GeometryNodeCurveSplineType" : ["spline_type"], + + #curve primitives + "GeometryNodeCurveArc" : ["mode"], + "GeometryNodeCurvePrimitiveBezierSegment" : ["mode"], + "GeometryNodeCurvePrimitiveCircle" : ["mode"], + "GeometryNodeCurvePrimitiveLine" : ["mode"], + "GeometryNodeCurvePrimitiveQuadrilateral" : ["mode"], + + #geometry + "GeometryNodeDeleteGeometry" : ["domain", "mode"], + "GeometryNodeDuplicateElements" : ["domain"], + "GeometryNodeProximity" : ["target_element"], + "GeometryNodeMergeByDistance" : ["mode"], + "GeometryNodeRaycast" : ["data_type", "mapping"], + "GeometryNodeSampleIndex" : ["data_type", "domain", "clamp"], + "GeometryNodeSampleNearest" : ["domain"], + "GeometryNodeSeparateGeometry" : ["domain"], + + #input + "FunctionNodeInputBool" : ["boolean"], + "GeometryNodeCollectionInfo" : ["transform_space"], + "FunctionNodeInputColor" : ["color"], + "FunctionNodeInputInt" : ["integer"], + "GeometryNodeInputMaterial" : ["material"], + "GeometryNodeObjectInfo" : ["transform_space"], + "FunctionNodeInputString" : ["string"], + "FunctionNodeInputVector" : ["vector"], + "GeometryNodeInputNamedAttribute" : ["data_type"], + + #mesh + "GeometryNodeExtrudeMesh" : ["mode"], + "GeometryNodeMeshBoolean" : ["operation"], + "GeometryNodeMeshToPoints" : ["mode"], + "GeometryNodeMeshToVolume" : ["resolution_mode"], + "GeometryNodeSampleNearestSurface" : ["data_type"], + "GeometryNodeSampleUVSurface" : ["data_type"], + "GeometryNodeSubdivisionSurface" : ["uv_smooth", "boundary_smooth"], + "GeometryNodeTriangulate" : ["quad_method", "ngon_method"], + "GeometryNodeScaleElements" : ["domain", "scale_mode"], + + #mesh primitives + "GeometryNodeMeshCone" : ["fill_type"], + "GeometryNodeMeshCylinder" : ["fill_type"], + "GeometryNodeMeshCircle" : ["fill_type"], + "GeometryNodeMeshLine" : ["mode"], + + #output + "GeometryNodeViewer" : ["domain"], + + #point + "GeometryNodeDistributePointsInVolume" : ["mode"], + "GeometryNodeDistributePointsOnFaces" : ["distribute_method"], + "GeometryNodePointsToVolume" : ["resolution_mode"], + + #text + "GeometryNodeStringToCurves" : ["overflow", "align_x", "align_y", + "pivot_mode"], + + #texture + "ShaderNodeTexBrick" : ["offset", "offset_frequency", "squash", + "squash_frequency"], + "ShaderNodeTexGradient" : ["gradient_type"], + "GeometryNodeImageTexture" : ["interpolation", "extension"], + "ShaderNodeTexMagic" : ["turbulence_depth"], + "ShaderNodeTexNoise" : ["noise_dimensions"], + "ShaderNodeTexVoronoi" : ["voronoi_dimensions", "feature", "distance"], + "ShaderNodeTexWave" : ["wave_type", "bands_direction", "wave_profile"], + "ShaderNodeTexWhiteNoise" : ["noise_dimensions"], + + #utilities + "GeometryNodeAccumulateField" : ["data_type", "domain"], + "FunctionNodeAlignEulerToVector" : ["axis", "pivot_axis"], + "FunctionNodeBooleanMath" : ["operation"], + "ShaderNodeClamp" : ["clamp_type"], + "FunctionNodeCompare" : ["data_type", "operation", "mode"], + "GeometryNodeFieldAtIndex" : ["data_type", "domain"], + "FunctionNodeFloatToInt" : ["rounding_mode"], + "GeometryNodeFieldOnDomain" : ["data_type", "domain" ], + "ShaderNodeMapRange" : ["data_type", "interpolation_type", "clamp"], + "ShaderNodeMath" : ["operation", "use_clamp"], + "FunctionNodeRandomValue" : ["data_type"], + "FunctionNodeRotateEuler" : ["type", "space"], + "GeometryNodeSwitch" : ["input_type"], + + #uv + "GeometryNodeUVUnwrap" : ["method"], + + #vector + "ShaderNodeVectorMath" : ["operation"], + "ShaderNodeVectorRotate" : ["rotation_type", "invert"], + + #volume + "GeometryNodeVolumeToMesh" : ["resolution_mode"] +} + +curve_nodes = {'ShaderNodeFloatCurve', + 'ShaderNodeVectorCurve', + 'ShaderNodeRGBCurve'} + +class GeoNodesToPython(bpy.types.Operator): + bl_idname = "node.geo_nodes_to_python" + bl_label = "Geo Nodes to Python" + bl_options = {'REGISTER', 'UNDO'} + + geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") + + def execute(self, context): + #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") + + #set to keep track of already created node trees + node_trees = set() + + #dictionary to keep track of node->variable name pairs + node_vars = {} + + #keeps track of all used variables + used_vars = set() + + def process_geo_nodes_group(node_tree, level, node_vars, used_vars): + node_tree_var = create_var(node_tree.name, used_vars) + + outer, inner = make_indents(level) + + #initialize node group + file.write(f"{outer}#initialize {node_tree_var} node group\n") + file.write(f"{outer}def {node_tree_var}_node_group():\n") + file.write((f"{inner}{node_tree_var}" + f"= bpy.data.node_groups.new(" + f"type = \"GeometryNodeTree\", " + f"name = \"{node_tree.name}\")\n")) + file.write("\n") + + inputs_set = False + outputs_set = False + + #initialize nodes + file.write(f"{inner}#initialize {node_tree_var} nodes\n") + + for node in node_tree.nodes: + if node.bl_idname == 'GeometryNodeGroup': + node_nt = node.node_tree + if node_nt is not None and node_nt not in node_trees: + process_geo_nodes_group(node_nt, level + 1, node_vars, + used_vars) + node_trees.add(node_nt) + elif node.bl_idname == 'NodeGroupInput' and not inputs_set: + file.write(f"{inner}#{node_tree_var} inputs\n") + for i, input in enumerate(node.outputs): + if input.bl_idname != "NodeSocketVirtual": + file.write(f"{inner}#input {input.name}\n") + file.write((f"{inner}{node_tree_var}.inputs.new" + f"(\"{input.bl_idname}\", " + f"\"{input.name}\")\n")) + socket = node_tree.inputs[i] + if input.bl_idname in default_sockets: + if input.bl_idname == 'NodeSocketColor': + col = socket.default_value + r, g, b, a = col[0], col[1], col[2], col[3] + dv = f"({r}, {g}, {b}, {a})" + elif input.bl_idname == 'NodeSocketVector': + vec = socket.default_value + dv = f"({vec[0]}, {vec[1]}, {vec[2]})" + else: + dv = socket.default_value + + #default value + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".default_value = {dv}\n")) + + #min value + if hasattr(socket, "min_value"): + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".min_value = " + f"{socket.min_value}\n")) + #max value + if hasattr(socket, "max_value"): + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".max_value = " + f"{socket.max_value}\n")) + #default attribute name + if hasattr(socket, "default_attribute_name"): + if socket.default_attribute_name != "": + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".default_attribute_name = \"" + f"{socket.default_attribute_name}" + f"\"\n")) + #description + if socket.description != "": + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".description = " + f"\"{socket.description}\"\n")) + #hide value + if socket.hide_value is True: + file.write((f"{inner}{node_tree_var}" + f".inputs[{i}]" + f".hide_value = " + f"{socket.hide_value}\n")) + file.write("\n") + file.write("\n") + inputs_set = True + + elif node.bl_idname == 'NodeGroupOutput' and not outputs_set: + file.write(f"{inner}#{node_tree_var} outputs\n") + for i, output in enumerate(node.inputs): + if output.bl_idname != 'NodeSocketVirtual': + file.write((f"{inner}{node_tree_var}.outputs" + f".new(\"{output.bl_idname}\", " + f"\"{output.name}\")\n")) + + socket = node_tree.outputs[i] + #description + if socket.description != "": + file.write((f"{inner}{node_tree_var}" + f".outputs[{i}]" + f".description = " + f"\"{socket.description}\"\n")) + #hide value + if socket.hide_value is True: + file.write((f"{inner}{node_tree_var}" + f".outputs[{i}]" + f".hide_value = " + f"{socket.hide_value}\n")) + + #default attribute name + if hasattr(socket, "default_attribute_name"): + if socket.default_attribute_name != "": + file.write((f"{inner}{node_tree_var}" + f".outputs[{i}]" + f".default_attribute_name = \"" + f"{socket.default_attribute_name}" + f"\"\n")) + #attribute domain + if hasattr(socket, "attribute_domain"): + file.write((f"{inner}{node_tree_var}" + f".outputs[{i}]" + f".attribute_domain = " + f"\'{socket.attribute_domain}\'\n")) + file.write("\n") + outputs_set = True + + #create node + node_var = create_node(node, file, inner, node_tree_var, + node_vars, used_vars) + set_settings_defaults(node, geo_node_settings, file, inner, + node_var) + hide_sockets(node, file, inner, node_var) + + if node.bl_idname == 'GeometryNodeGroup': + 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")) + 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) + set_output_defaults(node, file, inner, node_var) + set_parents(node_tree, file, inner, node_vars) + set_locations(node_tree, file, inner, node_vars) + set_dimensions(node_tree, file, inner, node_vars) + + init_links(node_tree, file, inner, node_tree_var, node_vars) + + file.write(f"{inner}return {node_tree_var}\n") + + #create node group + file.write((f"\n{outer}{node_tree_var} = " + f"{node_tree_var}_node_group()\n\n")) + return used_vars + + process_geo_nodes_group(nt, 2, node_vars, used_vars) + + def apply_modifier(): + #get object + file.write(f"\t\tname = bpy.context.object.name\n") + file.write(f"\t\tobj = bpy.data.objects[name]\n") + + #set modifier to the one we just created + mod_name = str_to_py_str(nt.name) + 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) + + file.close() + + zip_addon(zip_dir) + + return {'FINISHED'} + +class SelectGeoNodesMenu(bpy.types.Menu): + bl_idname = "NODE_MT_ntp_geo_nodes_selection" + bl_label = "Select Geo Nodes" + + @classmethod + def poll(cls, context): + return True + + 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'] + + for geo_ng in geo_node_groups: + op = layout.operator(GeoNodesToPython.bl_idname, text=geo_ng.name) + op.geo_nodes_group_name = geo_ng.name + +class GeoNodesToPythonPanel(bpy.types.Panel): + bl_label = "Geometry Nodes to Python" + bl_idname = "NODE_PT_geo_nodes_to_python" + bl_space_type = 'NODE_EDITOR' + bl_region_type = 'UI' + bl_context = '' + bl_category = "NodeToPython" + + @classmethod + def poll(cls, context): + return True + + def draw_header(self, context): + layout = self.layout + + def draw(self, context): + layout = self.layout + col = layout.column() + row = col.row() + + # Disables menu when len of geometry nodes is 0 + geo_node_groups = [node + for node in bpy.data.node_groups + if node.type == 'GEOMETRY'] + geo_node_groups_exist = len(geo_node_groups) > 0 + row.enabled = geo_node_groups_exist + + row.alignment = 'EXPAND' + row.operator_context = 'INVOKE_DEFAULT' + row.menu("NODE_MT_ntp_geo_nodes_selection", text="Geometry Nodes") \ No newline at end of file diff --git a/materials.py b/materials.py new file mode 100644 index 0000000..b03fa4d --- /dev/null +++ b/materials.py @@ -0,0 +1,256 @@ +import bpy +import os + +from .utils import * + +node_settings = { + #input + "ShaderNodeAmbientOcclusion" : ["samples", "inside", "only_local"], + "ShaderNodeAttribute" : ["attribute_type", "attribute_name"], + "ShaderNodeBevel" : ["samples"], + "ShaderNodeVertexColor" : ["layer_name"], + "ShaderNodeTangent" : ["direction_type", "axis"], + "ShaderNodeTexCoord" : ["object", "from_instancer"], + "ShaderNodeUVMap" : ["from_instancer", "uv_map"], + "ShaderNodeWireframe" : ["use_pixel_size"], + + #output + "ShaderNodeOutputAOV" : ["name"], + "ShaderNodeOutputMaterial" : ["target"], + + #shader + "ShaderNodeBsdfGlass" : ["distribution"], + "ShaderNodeBsdfGlossy" : ["distribution"], + "ShaderNodeBsdfPrincipled" : ["distribution", "subsurface_method"], + "ShaderNodeBsdfRefraction" : ["distribution"], + "ShaderNodeSubsurfaceScattering" : ["falloff"], + + #texture + "ShaderNodeTexBrick" : ["offset", "offset_frequency", "squash", "squash_frequency"], + "ShaderNodeTexEnvironment" : ["interpolation", "projection", "image_user.frame_duration", "image_user.frame_start", "image_user.frame_offset", "image_user.use_cyclic", "image_user.use_auto_refresh"], + "ShaderNodeTexGradient" : ["gradient_type"], + "ShaderNodeTexIES" : ["mode"], + "ShaderNodeTexImage" : ["interpolation", "projection", "projection_blend", + "extension"], + "ShaderNodeTexMagic" : ["turbulence_depth"], + "ShaderNodeTexMusgrave" : ["musgrave_dimensions", "musgrave_type"], + "ShaderNodeTexNoise" : ["noise_dimensions"], + "ShaderNodeTexPointDensity" : ["point_source", "object", "space", "radius", + "interpolation", "resolution", + "vertex_color_source"], + "ShaderNodeTexSky" : ["sky_type", "sun_direction", "turbidity", + "ground_albedo", "sun_disc", "sun_size", + "sun_intensity", "sun_elevation", + "sun_rotation", "altitude", "air_density", + "dust_density", "ozone_density"], + "ShaderNodeTexVoronoi" : ["voronoi_dimensions", "feature", "distance"], + "ShaderNodeTexWave" : ["wave_type", "rings_direction", "wave_profile"], + "ShaderNodeTexWhiteNoise" : ["noise_dimensions"], + + #color + "ShaderNodeMix" : ["data_type", "clamp_factor", "factor_mode", "blend_type", + "clamp_result"], + + #vector + "ShaderNodeBump" : ["invert"], + "ShaderNodeDisplacement" : ["space"], + "ShaderNodeMapping" : ["vector_type"], + "ShaderNodeNormalMap" : ["space", "uv_map"], + "ShaderNodeVectorDisplacement" : ["space"], + "ShaderNodeVectorRotate" : ["rotation_type", "invert"], + "ShaderNodeVectorTransform" : ["vector_type", "convert_from", "convert_to"], + + #converter + "ShaderNodeClamp" : ["clamp_type"], + "ShaderNodeCombineColor" : ["mode"], + "ShaderNodeMapRange" : ["data_type", "interpolation_type", "clamp"], + "ShaderNodeMath" : ["operation", "use_clamp"], + "ShaderNodeSeparateColor" : ["mode"], + "ShaderNodeVectorMath" : ["operation"] +} + +curve_nodes = {'ShaderNodeFloatCurve', + 'ShaderNodeVectorCurve', + 'ShaderNodeRGBCurve'} + +image_nodes = {'ShaderNodeTexEnvironment', + 'ShaderNodeTexImage'} + +class MaterialToPython(bpy.types.Operator): + bl_idname = "node.material_to_python" + bl_label = "Material to Python" + bl_options = {'REGISTER', 'UNDO'} + + 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?")) + 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() + + #set to keep track of already created node trees + node_trees = set() + + #dictionary to keep track of node->variable name pairs + node_vars = {} + + #keeps track of all used variables + used_vars = set() + + def process_mat_node_group(node_tree, level, node_vars, used_vars): + + if level == 2: #outermost node group + nt_var = create_var(self.material_name, used_vars) + nt_name = self.material_name + else: + nt_var = create_var(node_tree.name, used_vars) + nt_name = node_tree.name + + outer, inner = make_indents(level) + + #initialize node group + 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 + 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") + file.write(f"{inner}\t{nt_var}.nodes.remove(node)\n") + else: + file.write((f"{inner}{nt_var}" + f"= bpy.data.node_groups.new(" + f"type = \"ShaderNodeTree\", " + f"name = \"{nt_name}\")\n")) + file.write("\n") + + #initialize nodes + file.write(f"{inner}#initialize {nt_var} nodes\n") + + node_vars = {} + for node in node_tree.nodes: + 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) + node_trees.add(node_nt) + + node_var = create_node(node, file, inner, nt_var, node_vars, + used_vars) + + set_settings_defaults(node, node_settings, file, inner, node_var) + hide_sockets(node, file, inner, node_var) + + if node.bl_idname == 'ShaderNodeGroup': + 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")) + elif node.bl_idname in image_nodes: + img = node.image + if 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) + set_output_defaults(node, file, inner, node_var) + + set_parents(node_tree, file, inner, node_vars) + set_locations(node_tree, file, inner, node_vars) + set_dimensions(node_tree, file, inner, node_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") + + create_menu_func(file, class_name) + create_register_func(file, class_name) + create_unregister_func(file, class_name) + create_main_func(file) + + file.close() + zip_addon(zip_dir) + return {'FINISHED'} + +class SelectMaterialMenu(bpy.types.Menu): + bl_idname = "NODE_MT_npt_mat_selection" + bl_label = "Select Material" + + @classmethod + def poll(cls, context): + return True + + def draw(self, context): + layout = self.layout.column_flow(columns=1) + layout.operator_context = 'INVOKE_DEFAULT' + for mat in bpy.data.materials: + op = layout.operator(MaterialToPython.bl_idname, text=mat.name) + op.material_name = mat.name + +class MaterialToPythonPanel(bpy.types.Panel): + bl_label = "Material to Python" + bl_idname = "NODE_PT_mat_to_python" + bl_space_type = 'NODE_EDITOR' + bl_region_type = 'UI' + bl_context = '' + bl_category = "NodeToPython" + + @classmethod + def poll(cls, context): + return True + + def draw_header(self, context): + layout = self.layout + + def draw(self, context): + layout = self.layout + row = layout.row() + + # Disables menu when there are no materials + materials = bpy.data.materials + materials_exist = len(materials) > 0 + row.enabled = materials_exist + + row.alignment = 'EXPAND' + row.operator_context = 'INVOKE_DEFAULT' + row.menu("NODE_MT_npt_mat_selection", text="Materials") \ No newline at end of file diff --git a/node_to_python.py b/node_to_python.py deleted file mode 100644 index af8acac..0000000 --- a/node_to_python.py +++ /dev/null @@ -1,531 +0,0 @@ -bl_info = { - "name": "Node to Python", - "description": "Convert Geometry Node Groups to a Python add-on", - "author": "Brendan Parmer", - "version": (1, 3, 0), - "blender": (3, 0, 0), - "location": "Node", - "category": "Node", -} - -import bpy -import os - -#node tree input sockets that have default properties -default_sockets = {'NodeSocketBool', - 'NodeSocketColor', - 'NodeSocketFloat', - 'NodeSocketInt', - 'NodeSocketVector'} - -#node tree input sockets that have min/max properties -value_sockets = {'NodeSocketInt', - 'NodeSocketFloat', - 'NodeSocketVector'} - -#node input sockets that are messy to set default values for -dont_set_defaults = {'NodeSocketCollection', - 'NodeSocketGeometry', - 'NodeSocketImage', - 'NodeSocketMaterial', - 'NodeSocketObject', - 'NodeSocketTexture', - 'NodeSocketVirtual'} - -node_settings = { - #attribute - "GeometryNodeAttributeStatistic" : ["data_type", "domain"], - "GeometryNodeCaptureAttribute" : ["data_type", "domain"], - "GeometryNodeAttributeDomainSize" : ["component"], - "GeometryNodeStoreNamedAttribute" : ["data_type", "domain"], - "GeometryNodeAttributeTransfer" : ["data_type", "mapping"], - - #color - "ShaderNodeMixRGB" : ["blend_type", "use_clamp"], - "FunctionNodeCombineColor" : ["mode"], - "FunctionNodeSeparateColor" : ["mode"], - - #curve - "GeometryNodeCurveToPoints" : ["mode"], - "GeometryNodeFillCurve" : ["mode"], - "GeometryNodeFilletCurve" : ["mode"], - "GeometryNodeResampleCurve" : ["mode"], - "GeometryNodeSampleCurve" : ["data_type", "mode", - "use_all_curves"], - "GeometryNodeTrimCurve" : ["mode"], - "GeometryNodeSetCurveNormal" : ["mode"], - "GeometryNodeCurveHandleTypeSelection" : ["mode", "handle_type"], - "GeometryNodeSetCurveHandlePositions" : ["mode"], - "GeometryNodeCurveSetHandles" : ["mode", "handle_type"], - "GeometryNodeCurveSplineType" : ["spline_type"], - - #curve primitives - "GeometryNodeCurveArc" : ["mode"], - "GeometryNodeCurvePrimitiveBezierSegment" : ["mode"], - "GeometryNodeCurvePrimitiveCircle" : ["mode"], - "GeometryNodeCurvePrimitiveLine" : ["mode"], - "GeometryNodeCurvePrimitiveQuadrilateral" : ["mode"], - - #geometry - "GeometryNodeDeleteGeometry" : ["domain", "mode"], - "GeometryNodeDuplicateElements" : ["domain"], - "GeometryNodeProximity" : ["target_element"], - "GeometryNodeMergeByDistance" : ["mode"], - "GeometryNodeRaycast" : ["data_type", "mapping"], - "GeometryNodeSampleIndex" : ["data_type", "domain", "clamp"], - "GeometryNodeSampleNearest" : ["domain"], - "GeometryNodeSeparateGeometry" : ["domain"], - - #input - "GeometryNodeCollectionInfo" : ["transform_space"], - "GeometryNodeObjectInfo" : ["transform_space"], - "GeometryNodeInputNamedAttribute" : ["data_type"], - - #mesh - "GeometryNodeExtrudeMesh" : ["mode"], - "GeometryNodeMeshBoolean" : ["operation"], - "GeometryNodeMeshToPoints" : ["mode"], - "GeometryNodeMeshToVolume" : ["resolution_mode"], - "GeometryNodeSampleNearestSurface" : ["data_type"], - "GeometryNodeSampleUVSurface" : ["data_type"], - "GeometryNodeSubdivisionSurface" : ["uv_smooth", "boundary_smooth"], - "GeometryNodeTriangulate" : ["quad_method", "ngon_method"], - "GeometryNodeScaleElements" : ["domain", "scale_mode"], - - #mesh primitives - "GeometryNodeMeshCone" : ["fill_type"], - "GeometryNodeMeshCylinder" : ["fill_type"], - "GeometryNodeMeshCircle" : ["fill_type"], - "GeometryNodeMeshLine" : ["mode"], - - #output - "GeometryNodeViewer" : ["domain"], - - #point - "GeometryNodeDistributePointsInVolume" : ["mode"], - "GeometryNodeDistributePointsOnFaces" : ["distribute_method"], - "GeometryNodePointsToVolume" : ["resolution_mode"], - - #text - "GeometryNodeStringToCurves" : ["overflow", "align_x", "align_y", - "pivot_mode"], - - #texture - "ShaderNodeTexBrick" : ["offset", "offset_frequency", "squash", - "squash_frequency"], - "ShaderNodeTexGradient" : ["gradient_type"], - "GeometryNodeImageTexture" : ["interpolation", "extension"], - "ShaderNodeTexMagic" : ["turbulence_depth"], - "ShaderNodeTexNoise" : ["noise_dimensions"], - "ShaderNodeTexVoronoi" : ["voronoi_dimensions", "feature", "distance"], - "ShaderNodeTexWave" : ["wave_type", "bands_direction", - "wave_profile"], - "ShaderNodeTexWhiteNoise" : ["noise_dimensions"], - - #utilities - "GeometryNodeAccumulateField" : ["data_type", "domain"], - "FunctionNodeAlignEulerToVector" : ["axis", "pivot_axis"], - "FunctionNodeBooleanMath" : ["operation"], - "ShaderNodeClamp" : ["clamp_type"], - "FunctionNodeCompare" : ["data_type", "operation", "mode"], - "GeometryNodeFieldAtIndex" : ["data_type", "domain"], - "FunctionNodeFloatToInt" : ["rounding_mode"], - "GeometryNodeFieldOnDomain" : ["data_type", "domain" ], - "ShaderNodeMapRange" : ["data_type", "interpolation_type", - "clamp"], - "ShaderNodeMath" : ["operation", "use_clamp"], - "FunctionNodeRandomValue" : ["data_type"], - "FunctionNodeRotateEuler" : ["type", "space"], - "GeometryNodeSwitch" : ["input_type"], - - #uv - "GeometryNodeUVUnwrap" : ["method"], - - #vector - "ShaderNodeVectorMath" : ["operation"], - "ShaderNodeVectorRotate" : ["rotation_type", "invert"], - - #volume - "GeometryNodeVolumeToMesh" : ["resolution_mode"] -} - -curve_nodes = {'ShaderNodeFloatCurve', - 'ShaderNodeVectorCurve', - 'ShaderNodeRGBCurve'} - -def cleanup_string(string: str): - return string.lower().replace(' ', '_').replace('.', '_') - -class NodeToPython(bpy.types.Operator): - bl_idname = "object.node_to_python" - bl_label = "Node to Python" - bl_options = {'REGISTER', 'UNDO'} - - node_group_name: bpy.props.StringProperty(name="Node Group") - - def execute(self, context): - if self.node_group_name not in bpy.data.node_groups: - return {'FINISHED'} - ng = bpy.data.node_groups[self.node_group_name] - ng_name = cleanup_string(ng.name) - class_name = ng.name.replace(" ", "").replace('.', "") - dir = bpy.path.abspath("//") - if not dir or dir == "": - self.report({'ERROR'}, - ("NodeToPython: Save your blend file before using " - "NodeToPython!")) - return {'CANCELLED'} - addon_dir = os.path.join(dir, "addons") - if not os.path.exists(addon_dir): - os.mkdir(addon_dir) - file = open(f"{addon_dir}/{ng_name}_addon.py", "w") - - """Sets up bl_info and imports Blender""" - def header(): - file.write("bl_info = {\n") - file.write(f"\t\"name\" : \"{ng.name}\",\n") - file.write("\t\"author\" : \"Node To Python\",\n") - 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("}\n") - file.write("\n") - file.write("import bpy\n") - file.write("\n") - header() - - """Creates the class and its variables""" - def init_class(): - file.write(f"class {class_name}(bpy.types.Operator):\n") - file.write(f"\tbl_idname = \"object.{ng_name}\"\n") - file.write(f"\tbl_label = \"{ng.name}\"\n") - file.write("\tbl_options = {\'REGISTER\', \'UNDO\'}\n") - file.write("\n") - init_class() - - """Construct the execute function""" - file.write("\tdef execute(self, context):\n") - - def process_node_group(node_group, level): - ng_name = cleanup_string(node_group.name) - - outer = "\t"*level #outer indentation - inner = "\t"*(level + 1) #inner indentation - - #initialize node group - file.write(f"{outer}#initialize {ng_name} node group\n") - file.write(f"{outer}def {ng_name}_node_group():\n") - file.write((f"{inner}{ng_name}" - f"= bpy.data.node_groups.new(" - f"type = \"GeometryNodeTree\", " - f"name = \"{node_group.name}\")\n")) - file.write("\n") - - inputs_set = False - outputs_set = False - - #initialize nodes - file.write(f"{inner}#initialize {ng_name} nodes\n") - for node in node_group.nodes: - if node.bl_idname == 'GeometryNodeGroup': - process_node_group(node.node_tree, level + 1) - elif node.bl_idname == 'NodeGroupInput' and not inputs_set: - file.write(f"{inner}#{ng_name} inputs\n") - for i, input in enumerate(node.outputs): - if input.bl_idname != "NodeSocketVirtual": - file.write(f"{inner}#input {input.name}\n") - file.write((f"{inner}{ng_name}.inputs.new" - f"(\"{input.bl_idname}\", " - f"\"{input.name}\")\n")) - socket = node_group.inputs[i] - if input.bl_idname in default_sockets: - if input.bl_idname == 'NodeSocketColor': - col = socket.default_value - r, g, b, a = col[0], col[1], col[2], col[3] - dv = f"({r}, {g}, {b}, {a})" - elif input.bl_idname == 'NodeSocketVector': - vec = socket.default_value - dv = f"({vec[0]}, {vec[1]}, {vec[2]})" - else: - dv = socket.default_value - - #default value - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".default_value = {dv}\n")) - - #min value - if hasattr(socket, "min_value"): - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".min_value = " - f"{socket.min_value}\n")) - #max value - if hasattr(socket, "max_value"): - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".max_value = " - f"{socket.max_value}\n")) - #default attribute name - if hasattr(socket, "default_attribute_name"): - if socket.default_attribute_name != "": - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".default_attribute_name = \"" - f"{socket.default_attribute_name}" - f"\"\n")) - #description - if socket.description != "": - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".description = " - f"\"{socket.description}\"\n")) - #hide value - if socket.hide_value is True: - file.write((f"{inner}{ng_name}" - f".inputs[{i}]" - f".hide_value = " - f"{socket.hide_value}\n")) - file.write("\n") - file.write("\n") - inputs_set = True - - elif node.bl_idname == 'NodeGroupOutput' and not outputs_set: - file.write(f"{inner}#{ng_name} outputs\n") - for i, output in enumerate(node.inputs): - if output.bl_idname != 'NodeSocketVirtual': - file.write((f"{inner}{ng_name}.outputs" - f".new(\"{output.bl_idname}\", " - f"\"{output.name}\")\n")) - - socket = node_group.outputs[i] - #description - if socket.description != "": - file.write((f"{inner}{ng_name}" - f".outputs[{i}]" - f".description = " - f"\"{socket.description}\"\n")) - #hide value - if socket.hide_value is True: - file.write((f"{inner}{ng_name}" - f".outputs[{i}]" - f".hide_value = " - f"{socket.hide_value}\n")) - - #default attribute name - if hasattr(socket, "default_attribute_name"): - if socket.default_attribute_name != "": - file.write((f"{inner}{ng_name}" - f".outputs[{i}]" - f".default_attribute_name = \"" - f"{socket.default_attribute_name}" - f"\"\n")) - #attribute domain - if hasattr(socket, "attribute_domain"): - file.write((f"{inner}{ng_name}" - f".outputs[{i}]" - f".attribute_domain = " - f"\'{socket.attribute_domain}\'\n")) - - file.write("\n") - outputs_set = True - - #create node - node_name = cleanup_string(node.name) - file.write(f"{inner}#node {node.name}\n") - file.write((f"{inner}{node_name} " - f"= {ng_name}.nodes.new(\"{node.bl_idname}\")\n")) - file.write((f"{inner}{node_name}.location " - f"= ({node.location.x}, {node.location.y})\n")) - file.write((f"{inner}{node_name}.width, {node_name}.height " - f"= {node.width}, {node.height}\n")) - if node.label: - file.write(f"{inner}{node_name}.label = \"{node.label}\"\n") - - #special nodes - if node.bl_idname in node_settings: - for setting in node_settings[node.bl_idname]: - attr = getattr(node, setting, None) - if attr: - if type(attr) == str: - attr = f"\'{attr}\'" - file.write((f"{inner}{node_name}.{setting} " - f"= {attr}\n")) - elif node.bl_idname == 'GeometryNodeGroup': - file.write((f"{inner}{node_name}.node_tree = " - f"bpy.data.node_groups" - f"[\"{node.node_tree.name}\"]\n")) - elif node.bl_idname == 'ShaderNodeValToRGB': - color_ramp = node.color_ramp - file.write("\n") - file.write((f"{inner}{node_name}.color_ramp.color_mode = " - f"\'{color_ramp.color_mode}\'\n")) - file.write((f"{inner}{node_name}.color_ramp" - f".hue_interpolation = " - f"\'{color_ramp.hue_interpolation}\'\n")) - file.write((f"{inner}{node_name}.color_ramp.interpolation " - f"= '{color_ramp.interpolation}'\n")) - file.write("\n") - for i, element in enumerate(color_ramp.elements): - file.write((f"{inner}{node_name}_cre_{i} = " - f"{node_name}.color_ramp.elements" - f".new({element.position})\n")) - file.write((f"{inner}{node_name}_cre_{i}.alpha = " - f"{element.alpha}\n")) - col = element.color - r, g, b, a = col[0], col[1], col[2], col[3] - file.write((f"{inner}{node_name}_cre_{i}.color = " - f"({r}, {g}, {b}, {a})\n\n")) - elif node.bl_idname in curve_nodes: - file.write(f"{inner}#mapping settings\n") - mapping = f"{inner}{node_name}.mapping" - - extend = f"\'{node.mapping.extend}\'" - file.write(f"{mapping}.extend = {extend}\n") - tone = f"\'{node.mapping.tone}\'" - file.write(f"{mapping}.tone = {tone}\n") - - b_lvl = node.mapping.black_level - b_lvl_str = f"({b_lvl[0]}, {b_lvl[1]}, {b_lvl[2]})" - file.write((f"{mapping}.black_level = {b_lvl_str}\n")) - w_lvl = node.mapping.white_level - w_lvl_str = f"({w_lvl[0]}, {w_lvl[1]}, {w_lvl[2]})" - file.write((f"{mapping}.white_level = {w_lvl_str}\n")) - - min_x = node.mapping.clip_min_x - file.write(f"{mapping}.clip_min_x = {min_x}\n") - min_y = node.mapping.clip_min_y - file.write(f"{mapping}.clip_min_y = {min_y}\n") - max_x = node.mapping.clip_max_x - file.write(f"{mapping}.clip_max_x = {max_x}\n") - max_y = node.mapping.clip_max_y - file.write(f"{mapping}.clip_max_y = {max_y}\n") - - use_clip = node.mapping.use_clip - file.write(f"{mapping}.use_clip = {use_clip}\n") - - for i, curve in enumerate(node.mapping.curves): - file.write(f"{inner}#curve {i}\n") - curve_i = f"{node_name}_curve_{i}" - file.write((f"{inner}{curve_i} = " - f"{node_name}.mapping.curves[{i}]\n")) - for j, point in enumerate(curve.points): - point_j = f"{inner}{curve_i}_point_{j}" - - loc = point.location - file.write((f"{point_j} = " - f"{curve_i}.points.new" - f"({loc[0]}, {loc[1]})\n")) - - handle = f"\'{point.handle_type}\'" - file.write(f"{point_j}.handle_type = {handle}\n") - file.write(f"{inner}#update curve after changes\n") - file.write(f"{mapping}.update()\n") - - if node.bl_idname != 'NodeReroute': - for i, input in enumerate(node.inputs): - if input.bl_idname not in dont_set_defaults: - if input.bl_idname == 'NodeSocketColor': - col = input.default_value - dv = f"({col[0]}, {col[1]}, {col[2]}, {col[3]})" - elif "Vector" in input.bl_idname: - vector = input.default_value - dv = f"({vector[0]}, {vector[1]}, {vector[2]})" - elif input.bl_idname == 'NodeSocketString': - dv = f"\"\"" - else: - dv = input.default_value - if dv is not None: - file.write(f"{inner}#{input.identifier}\n") - file.write((f"{inner}{node_name}" - f".inputs[{i}]" - f".default_value = {dv}\n")) - file.write("\n") - - #initialize links - if node_group.links: - file.write(f"{inner}#initialize {ng_name} links\n") - for link in node_group.links: - input_node = cleanup_string(link.from_node.name) - input_socket = link.from_socket - - """ - Blender's socket dictionary doesn't guarantee - unique keys, which has caused much wailing and - gnashing of teeth. This is a quick fix that - doesn't run quick - """ - for i, item in enumerate(link.from_node.outputs.items()): - if item[1] == input_socket: - input_idx = i - break - - output_node = cleanup_string(link.to_node.name) - output_socket = link.to_socket - - for i, item in enumerate(link.to_node.inputs.items()): - if item[1] == output_socket: - output_idx = i - break - - file.write((f"{inner}#{input_node}.{input_socket.name} " - f"-> {output_node}.{output_socket.name}\n")) - file.write((f"{inner}{ng_name}.links.new({input_node}" - f".outputs[{input_idx}], " - f"{output_node}.inputs[{output_idx}])\n")) - - #create node group - file.write("\n") - file.write(f"{outer}{ng_name}_node_group()\n") - file.write("\n") - - process_node_group(ng, 2) - - file.write("\t\treturn {'FINISHED'}\n\n") - - """Create the function that adds the addon to the menu""" - def create_menu_func(): - file.write("def menu_func(self, context):\n") - file.write(f"\tself.layout.operator({class_name}.bl_idname)\n") - file.write("\n") - create_menu_func() - - """Create the register function""" - def create_register(): - file.write("def register():\n") - file.write(f"\tbpy.utils.register_class({class_name})\n") - file.write("\tbpy.types.VIEW3D_MT_object.append(menu_func)\n") - file.write("\n") - create_register() - - """Create the unregister function""" - def create_unregister(): - file.write("def unregister():\n") - file.write(f"\tbpy.utils.unregister_class({class_name})\n") - file.write("\tbpy.types.VIEW3D_MT_objects.remove(menu_func)\n") - file.write("\n") - create_unregister() - - """Create the main function""" - def create_main(): - file.write("if __name__ == \"__main__\":\n") - file.write("\tregister()") - create_main() - - file.close() - return {'FINISHED'} - -def menu_func(self, context): - self.layout.operator(NodeToPython.bl_idname, text=NodeToPython.bl_label) - -def register(): - bpy.utils.register_class(NodeToPython) - bpy.types.VIEW3D_MT_object.append(menu_func) - -def unregister(): - bpy.utils.unregister_class(NodeToPython) - bpy.types.VIEW3D_MT_object.remove(menu_func) - -if __name__ == "__main__": - register() \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..81de806 --- /dev/null +++ b/utils.py @@ -0,0 +1,713 @@ +import bpy +import mathutils + +import os +import re +import shutil +from typing import TextIO, Tuple + +image_dir_name = "imgs" + +#node input sockets that are messy to set default values for +dont_set_defaults = {'NodeSocketGeometry', + 'NodeSocketShader', + 'NodeSocketVirtual'} + +def clean_string(string: str, lower: bool = True) -> str: + """ + Cleans up a string for use as a variable or file name + + Parameters: + string (str): The input string + + Returns: + clean_str: The input string with nasty characters converted to underscores + """ + + if lower: + string = string.lower() + clean_str = re.sub(r"[^a-zA-Z0-9_]", '_', string) + return clean_str + +def enum_to_py_str(enum: str) -> str: + """ + Converts an enum into a string usuable in the add-on + + Parameters: + enum (str): enum to be converted + + Returns: + (str): converted string + """ + return f"\'{enum}\'" + +def str_to_py_str(string: str) -> str: + """ + Converts a regular string into one usuable in the add-on + + Parameters: + string (str): string to be converted + + Returns: + (str): converted string + """ + return f"\"{string}\"" + +def vec3_to_py_str(vec) -> str: + """ + Converts a 3D vector to a string usable by the add-on + + Parameters: + vec (mathutils.Vector): a 3d vector + + Returns: + (str): string version + """ + return f"({vec[0]}, {vec[1]}, {vec[2]})" + +def vec4_to_py_str(vec) -> str: + """ + Converts a 4D vector to a string usable by the add-on + + Parameters: + vec (mathutils.Vector): a 4d vector + + Returns: + (str): string version + """ + return f"({vec[0]}, {vec[1]}, {vec[2]}, {vec[3]})" + +def img_to_py_str(img) -> str: + """ + Converts a Blender image into its string + + Paramters: + img (bpy.types.Image): a Blender image + + Returns: + (str): string version + """ + name = img.name.split('.', 1)[0] + format = img.file_format.lower() + return f"{name}.{format}" + +def create_header(file: TextIO, name: str): + """ + Sets up the bl_info and imports the Blender API + + Parameters: + file (TextIO): the file for the generated add-on + name (str): name of the add-on + """ + + file.write("bl_info = {\n") + file.write(f"\t\"name\" : \"{name}\",\n") + file.write("\t\"author\" : \"Node To Python\",\n") + 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("}\n") + file.write("\n") + file.write("import bpy\n") + file.write("import os\n") + file.write("\n") + +def init_operator(file: TextIO, name: str, idname: str, label: str): + """ + Initializes the add-on's operator + + Parameters: + file (TextIO): the file for the generated add-on + name (str): name for the class + idname (str): name for the operator + label (str): appearence inside Blender + """ + file.write(f"class {name}(bpy.types.Operator):\n") + file.write(f"\tbl_idname = \"object.{idname}\"\n") + file.write(f"\tbl_label = \"{label}\"\n") + file.write("\tbl_options = {\'REGISTER\', \'UNDO\'}\n") + file.write("\n") + +def create_var(name: str, used_vars: set) -> 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 + + Returns: + clean_name (str): variable name for the node tree + """ + if name == "": + 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 + +def make_indents(level: int) -> Tuple[str, str]: + """ + Returns strings with the correct number of indentations + given the level in the function. + + Node groups need processed recursively, + so there can sometimes be functions in functions. + + Parameters: + level (int): base number of indentations need + + Returns: + outer (str): a basic level of indentation for a node group. + inner (str): a level of indentation beyond outer + """ + outer = "\t"*level + inner = "\t"*(level + 1) + return outer, inner + +def create_node(node, file: TextIO, inner: str, node_tree_var: str, + node_vars: dict, used_vars: set) -> str: + """ + Initializes a new node with location, dimension, and label info + + Parameters: + node (bpy.types.Node): node to be copied + file (TextIO): file containing the generated add-on + inner (str): indentation level for this logic + node_tree_var (str): variable name for the node tree + node_vars (dict): dictionary containing (bpy.types.Node, str) + pairs, with a Node and its corresponding variable name + used_vars (set): set of used variable names + + Returns: + node_var (str): variable name for the node + """ + + file.write(f"{inner}#node {node.name}\n") + + node_var = create_var(node.name, used_vars) + node_vars[node] = node_var + + file.write((f"{inner}{node_var} " + f"= {node_tree_var}.nodes.new(\"{node.bl_idname}\")\n")) + #label + if node.label: + file.write(f"{inner}{node_var}.label = \"{node.label}\"\n") + + #color + if node.use_custom_color: + file.write(f"{inner}{node_var}.use_custom_color = True\n") + file.write(f"{inner}{node_var}.color = {vec3_to_py_str(node.color)}\n") + + #mute + if node.mute: + file.write(f"{inner}{node_var}.mute = True\n") + + return node_var + +def set_settings_defaults(node, settings: dict, file: TextIO, inner: str, + node_var: str): + """ + Sets the defaults for any settings a node may have + + Parameters: + node (bpy.types.Node): the node object we're copying settings from + settings (dict): a predefined dictionary of all settings every node has + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_var (str): name of the variable we're using for the node in our add-on + """ + if node.bl_idname in settings: + for setting in settings[node.bl_idname]: + attr = getattr(node, setting, None) + if attr: + if type(attr) == str: + attr = enum_to_py_str(attr) + if type(attr) == mathutils.Vector: + attr = vec3_to_py_str(attr) + if type(attr) == bpy.types.bpy_prop_array: + attr = vec4_to_py_str(list(attr)) + if type(attr) == bpy.types.Material: + name = str_to_py_str(attr.name) + file.write((f"{inner}if {name} in bpy.data.materials:\n")) + file.write((f"{inner}\t{node_var}.{setting} = " + f"bpy.data.materials[{name}]\n")) + continue + if type(attr) == bpy.types.Object: + name = str_to_py_str(attr.name) + file.write((f"{inner}if {name} in bpy.data.objects:\n")) + file.write((f"{inner}\t{node_var}.{setting} = " + f"bpy.data.objects[{name}]\n")) + continue + file.write((f"{inner}{node_var}.{setting} " + f"= {attr}\n")) + +def hide_sockets(node, file: TextIO, inner: str, node_var: str): + """ + Hide hidden sockets + + Parameters: + node (bpy.types.Node): node object we're copying socket settings from + file (TextIO): file we're generating the add-on into + inner (str): indentation string + node_var (str): name of the variable we're using for this node + """ + for i, socket in enumerate(node.inputs): + if socket.hide is True: + file.write(f"{inner}{node_var}.inputs[{i}].hide = True\n") + for i, socket in enumerate(node.outputs): + if socket.hide is True: + file.write(f"{inner}{node_var}.outputs[{i}].hide = True\n") + +def color_ramp_settings(node, file: TextIO, inner: str, node_var: str): + """ + Replicate a color ramp node + + Parameters + node (bpy.types.Node): node object we're copying settings from + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_var (str): name of the variable we're using for the color ramp + """ + + color_ramp = node.color_ramp + #settings + file.write((f"{inner}{node_var}.color_ramp.color_mode = " + f"\'{color_ramp.color_mode}\'\n")) + file.write((f"{inner}{node_var}.color_ramp.hue_interpolation = " + f"\'{color_ramp.hue_interpolation}\'\n")) + file.write((f"{inner}{node_var}.color_ramp.interpolation " + f"= '{color_ramp.interpolation}'\n")) + file.write("\n") + #key points + for i, element in enumerate(color_ramp.elements): + file.write((f"{inner}{node_var}_cre_{i} = " + f"{node_var}.color_ramp.elements" + f".new({element.position})\n")) + file.write((f"{inner}{node_var}_cre_{i}.alpha = " + f"{element.alpha}\n")) + col = element.color + r, g, b, a = col[0], col[1], col[2], col[3] + file.write((f"{inner}{node_var}_cre_{i}.color = " + f"({r}, {g}, {b}, {a})\n\n")) + +def curve_node_settings(node, file: TextIO, inner: str, node_var: str): + """ + Sets defaults for Float, Vector, and Color curves + + Parameters: + node (bpy.types.Node): curve node we're copying settings from + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_var (str): variable name for the add-on's curve node + """ + + #mapping settings + file.write(f"{inner}#mapping settings\n") + mapping = f"{inner}{node_var}.mapping" + + #extend + extend = f"\'{node.mapping.extend}\'" + file.write(f"{mapping}.extend = {extend}\n") + #tone + tone = f"\'{node.mapping.tone}\'" + file.write(f"{mapping}.tone = {tone}\n") + + #black level + b_lvl = node.mapping.black_level + b_lvl_str = f"({b_lvl[0]}, {b_lvl[1]}, {b_lvl[2]})" + file.write((f"{mapping}.black_level = {b_lvl_str}\n")) + #white level + w_lvl = node.mapping.white_level + w_lvl_str = f"({w_lvl[0]}, {w_lvl[1]}, {w_lvl[2]})" + file.write((f"{mapping}.white_level = {w_lvl_str}\n")) + + #minima and maxima + min_x = node.mapping.clip_min_x + file.write(f"{mapping}.clip_min_x = {min_x}\n") + min_y = node.mapping.clip_min_y + file.write(f"{mapping}.clip_min_y = {min_y}\n") + max_x = node.mapping.clip_max_x + file.write(f"{mapping}.clip_max_x = {max_x}\n") + max_y = node.mapping.clip_max_y + file.write(f"{mapping}.clip_max_y = {max_y}\n") + + #use_clip + use_clip = node.mapping.use_clip + file.write(f"{mapping}.use_clip = {use_clip}\n") + + #create curves + for i, curve in enumerate(node.mapping.curves): + file.write(f"{inner}#curve {i}\n") + curve_i = f"{node_var}_curve_{i}" + file.write((f"{inner}{curve_i} = {node_var}.mapping.curves[{i}]\n")) + for j, point in enumerate(curve.points): + point_j = f"{inner}{curve_i}_point_{j}" + + loc = point.location + file.write((f"{point_j} = {curve_i}.points.new({loc[0]}, {loc[1]})\n")) + + handle = f"\'{point.handle_type}\'" + file.write(f"{point_j}.handle_type = {handle}\n") + + #update curve + file.write(f"{inner}#update curve after changes\n") + file.write(f"{mapping}.update()\n") + +def set_input_defaults(node, file: TextIO, inner: str, node_var: str, + addon_dir: str): + """ + Sets defaults for input sockets + + Parameters: + node (bpy.types.Node): node we're setting inputs for + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_var (str): variable name we're using for the copied node + addon_dir (str): directory of the add-on, for if we need to save other + objects for the add-on + """ + if node.bl_idname == 'NodeReroute': + return + + for i, input in enumerate(node.inputs): + if input.bl_idname not in dont_set_defaults and not input.is_linked: + socket_var = f"{node_var}.inputs[{i}]" + + #colors + if input.bl_idname == 'NodeSocketColor': + default_val = vec4_to_py_str(input.default_value) + + #vector types + elif "Vector" in input.bl_idname: + default_val = vec3_to_py_str(input.default_value) + + #strings + elif input.bl_idname == 'NodeSocketString': + default_val = str_to_py_str(input.default_value) + + #images + elif input.bl_idname == 'NodeSocketImage': + print("Input is linked: ", input.is_linked) + img = input.default_value + if img is not None: + save_image(img, addon_dir) + load_image(img, file, inner, f"{socket_var}.default_value") + default_val = None + + #materials + elif input.bl_idname == 'NodeSocketMaterial': + in_file_inputs(input, file, inner, socket_var, "materials") + default_val = None + + #collections + elif input.bl_idname == 'NodeSocketCollection': + in_file_inputs(input, file, inner, socket_var, "collections") + default_val = None + + #objects + elif input.bl_idname == 'NodeSocketObject': + in_file_inputs(input, file, inner, socket_var, "objects") + default_val = None + + #textures + elif input.bl_idname == 'NodeSocketTexture': + in_file_inputs(input, file, inner, socket_var, "textures") + default_val = None + + else: + default_val = input.default_value + if default_val is not None: + file.write(f"{inner}#{input.identifier}\n") + file.write((f"{inner}{socket_var}.default_value" + f" = {default_val}\n")) + file.write("\n") + +def in_file_inputs(input, file: TextIO, inner: str, socket_var: str, type: str): + """ + Sets inputs for a node input if one already exists in the blend file + + Parameters: + input (bpy.types.NodeSocket): input socket we're setting the value for + file (TextIO): file we're writing the add-on into + inner (str): indentation string + socket_var (str): variable name we're using for the socket + type (str): from what section of bpy.data to pull the default value from + """ + + if input.default_value is not None: + name = str_to_py_str(input.default_value.name) + file.write(f"{inner}if {name} in bpy.data.{type}:\n") + file.write((f"{inner}\t{socket_var}.default_value = " + f"bpy.data.{type}[{name}]\n")) + +def set_output_defaults(node, file: TextIO, inner: str, node_var: str): + """ + Some output sockets need default values set. It's rather annoying + + Parameters: + node (bpy.types.Node): node for the output we're setting + file (TextIO): file we're generating the add-on into + inner (str): indentation string + node_var (str): variable name for the node we're setting output defaults for + """ + output_default_nodes = {'ShaderNodeValue', + 'ShaderNodeRGB', + 'ShaderNodeNormal'} + + if node.bl_idname in output_default_nodes: + dv = node.outputs[0].default_value + if node.bl_idname == 'ShaderNodeRGB': + dv = vec4_to_py_str(list(dv)) + if node.bl_idname == 'ShaderNodeNormal': + dv = vec3_to_py_str(dv) + file.write((f"{inner}{node_var}.outputs[0].default_value = {dv}\n")) + +def set_parents(node_tree, file: TextIO, inner: str, node_vars: dict): + """ + Sets parents for all nodes, mostly used to put nodes in frames + + Parameters: + node_tree (bpy.types.NodeTree): node tree we're obtaining nodes from + file (TextIO): file for the generated add-on + inner (str): indentation string + node_vars (dict): dictionary for (node, variable) name pairs + """ + file.write(f"{inner}#Set parents\n") + for node in node_tree.nodes: + if node is not None and node.parent is not None: + node_var = node_vars[node] + parent_var = node_vars[node.parent] + file.write(f"{inner}{node_var}.parent = {parent_var}\n") + file.write("\n") + +def set_locations(node_tree, file: TextIO, inner: str, node_vars: dict): + """ + Set locations for all nodes + + Parameters: + node_tree (bpy.types.NodeTree): node tree we're obtaining nodes from + file (TextIO): file for the generated add-on + inner (str): indentation string + node_vars (dict): dictionary for (node, variable) name pairs + """ + + file.write(f"{inner}#Set locations\n") + for node in node_tree.nodes: + node_var = node_vars[node] + file.write((f"{inner}{node_var}.location " + f"= ({node.location.x}, {node.location.y})\n")) + file.write("\n") + +def set_dimensions(node_tree, file: TextIO, inner: str, node_vars: dict): + """ + Set dimensions for all nodes + + Parameters: + node_tree (bpy.types.NodeTree): node tree we're obtaining nodes from + file (TextIO): file for the generated add-on + inner (str): indentation string + node_vars (dict): dictionary for (node, variable) name pairs + """ + + file.write(f"{inner}#sSet dimensions\n") + for node in node_tree.nodes: + node_var = node_vars[node] + file.write((f"{inner}{node_var}.width, {node_var}.height " + f"= {node.width}, {node.height}\n")) + file.write("\n") + +def init_links(node_tree, file: TextIO, inner: str, node_tree_var: str, + node_vars: dict): + """ + Create all the links between nodes + + Parameters: + node_tree (bpy.types.NodeTree): node tree we're copying + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_tree_var (str): variable name we're using for the copied node tree + node_vars (dict): dictionary containing node to variable name pairs + """ + + if node_tree.links: + file.write(f"{inner}#initialize {node_tree_var} links\n") + for link in node_tree.links: + in_node_var = node_vars[link.from_node] + input_socket = link.from_socket + + """ + Blender's socket dictionary doesn't guarantee + unique keys, which has caused much wailing and + gnashing of teeth. This is a quick fix that + doesn't run quick + """ + for i, item in enumerate(link.from_node.outputs.items()): + if item[1] == input_socket: + input_idx = i + break + + out_node_var = node_vars[link.to_node] + output_socket = link.to_socket + + for i, item in enumerate(link.to_node.inputs.items()): + if item[1] == output_socket: + output_idx = i + break + + file.write((f"{inner}#{in_node_var}.{input_socket.name} " + f"-> {out_node_var}.{output_socket.name}\n")) + file.write((f"{inner}{node_tree_var}.links.new({in_node_var}" + f".outputs[{input_idx}], " + f"{out_node_var}.inputs[{output_idx}])\n")) + +def create_menu_func(file: TextIO, name: str): + """ + Creates the menu function + + Parameters: + file (TextIO): file we're generating the add-on into + name (str): name of the generated operator class + """ + + file.write("def menu_func(self, context):\n") + file.write(f"\tself.layout.operator({name}.bl_idname)\n") + file.write("\n") + +def create_register_func(file: TextIO, name: str): + """ + Creates the register function + + Parameters: + file (TextIO): file we're generating the add-on into + name (str): name of the generated operator class + """ + file.write("def register():\n") + file.write(f"\tbpy.utils.register_class({name})\n") + file.write("\tbpy.types.VIEW3D_MT_object.append(menu_func)\n") + file.write("\n") + +def create_unregister_func(file: TextIO, name: str): + """ + Creates the unregister function + + Parameters: + file (TextIO): file we're generating the add-on into + name (str): name of the generated operator class + """ + 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("\n") + +def create_main_func(file: TextIO): + """ + Creates the main function + + Parameters: + file (TextIO): file we're generating the add-on into + """ + file.write("if __name__ == \"__main__\":\n") + file.write("\tregister()") + +def save_image(img, addon_dir: str): + """ + Saves an image to an image directory of the add-on + + Parameters: + img (bpy.types.Image): image to be saved + addon_dir (str): directory of the addon + """ + + if img is None: + return + + #create image dir if one doesn't exist + img_dir = os.path.join(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}" + print("Image Path: ", img_path) + if not os.path.exists(img_path): + img.save_render(img_path) + +def load_image(img, file: TextIO, inner: str, img_var: str): + """ + Loads an image from the add-on into a blend file and assigns it + + Parameters: + img (bpy.types.Image): Blender image from the original node group + file (TextIO): file for the generated add-on + inner (str): indentation string + img_var (str): variable name to be used for the image + """ + + if img is None: + return + + img_str = img_to_py_str(img) + + file.write(f"{inner}#load image {img_str}\n") + file.write((f"{inner}base_dir = " + f"os.path.dirname(os.path.abspath(__file__))\n")) + file.write((f"{inner}image_path = " + f"os.path.join(base_dir, \"{image_dir_name}\", " + f"\"{img_str}\")\n")) + file.write((f"{inner}{img_var} = " + f"bpy.data.images.load(image_path, check_existing = True)\n")) + + #copy image settings + file.write(f"{inner}#set image settings\n") + + #source + file.write(f"{inner}{img_var}.source = \'{img.source}\'\n") + + #color space settings + file.write((f"{inner}{img_var}.colorspace_settings.name = " + f"\'{img.colorspace_settings.name}\'\n")) + + #alpha mode + file.write(f"{inner}{img_var}.alpha_mode = \'{img.alpha_mode}\'\n") + +def image_user_settings(node, file: TextIO, inner: str, node_var: str): + """ + Replicate the image user of an image node + + Parameters + node (bpy.types.Node): node object we're copying settings from + file (TextIO): file we're generating the add-on into + inner (str): indentation + node_var (str): name of the variable we're using for the color ramp + """ + + if not hasattr(node, "image_user"): + raise ValueError("Node must have attribute \"image_user\"") + + img_usr = node.image_user + img_usr_var = f"{node_var}.image_user" + + img_usr_attrs = ["frame_current", "frame_duration", "frame_offset", + "frame_start", "tile", "use_auto_refresh", "use_cyclic"] + + for img_usr_attr in img_usr_attrs: + file.write((f"{inner}{img_usr_var}.{img_usr_attr} = " + f"{getattr(img_usr, img_usr_attr)}\n")) + +def zip_addon(zip_dir: str): + """ + Zips up the addon and removes the directory + + Parameters: + zip_dir (str): path to the top-level addon directory + """ + shutil.make_archive(zip_dir, "zip", zip_dir) + shutil.rmtree(zip_dir) \ No newline at end of file