diff --git a/README.md b/README.md index 88341e6..d5a9bbc 100644 --- a/README.md +++ b/README.md @@ -9,39 +9,53 @@ [![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. +A Blender add-on to create add-ons! This script will take your Geometry Nodes or Materials and convert them into legible Python add-ons! 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. I wanted to make scripting node groups 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 +I think Blender's node-based editors are powerful, yet accessible tools, and I wanted to make scripting them 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 * different node trees for different versions or settings * interfacing with other parts of the software. -NodeToPython is compatible with Blender 3.0-3.4 +NodeToPython recreates the node networks for you, so you can focus on the good stuff. ## Supported Versions -Blender 3.0 - 3.4 +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. -* Once the 3.5 beta drops, I'll start adding nodes from that release +## 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! -## 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. +## Usage +Once you've installed the add-on, you'll see a new tab to the side of a Node Editor. + +In the tab, there's panels to create add-ons for Geometry Nodes and Materials, each with a drop-down menu. + +Just select the one you want, and soon a python 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 Shader and Compositing nodes +* Expansion to Compositing nodes * Copy over referenced assets in the scene (Collections, Objects, Materials, Textures, etc.) * Automatically format code to be PEP8 compliant ## Potential Issues -* As of version 1.2.1, the add-on will not set default values for +* As of version 2.0.0, the add-on will not set default values for * Collections * Images * Materials * Objects * Textures + * Scripts + * IES files + * Filepaths - as they won't exist in every blend file. I plan on implementing these soon. + as they won't exist in every blend file. I'm expecting to support some of these in the future. + + There are a few nodes that don't set their default values like other ones, though these should also soon be supported. ## Bug Reports and Suggestions @@ -49,9 +63,7 @@ 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 - -If you don't mind sharing a blend file, that helps a lot! - -Suggestions for how to improve the add-on are more than welcome! +* 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/__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/__pycache__/geo_nodes.cpython-310.pyc b/__pycache__/geo_nodes.cpython-310.pyc new file mode 100644 index 0000000..300890b Binary files /dev/null and b/__pycache__/geo_nodes.cpython-310.pyc differ diff --git a/node_to_python.py b/geo_nodes.py similarity index 72% rename from node_to_python.py rename to geo_nodes.py index 99c3f3d..cdbcfcd 100644 --- a/node_to_python.py +++ b/geo_nodes.py @@ -11,6 +11,8 @@ import bpy import os +from . import utils + #node tree input sockets that have default properties default_sockets = {'NodeSocketBool', 'NodeSocketColor', @@ -32,13 +34,13 @@ 'NodeSocketTexture', 'NodeSocketVirtual'} -node_settings = { +geo_node_settings = { #attribute - "GeometryNodeAttributeStatistic" : ["data_type", "domain"], - "GeometryNodeCaptureAttribute" : ["data_type", "domain"], - "GeometryNodeAttributeDomainSize" : ["component"], - "GeometryNodeStoreNamedAttribute" : ["data_type", "domain"], - "GeometryNodeAttributeTransfer" : ["data_type", "mapping"], + "GeometryNodeAttributeStatistic" : ["data_type", "domain"], + "GeometryNodeCaptureAttribute" : ["data_type", "domain"], + "GeometryNodeAttributeDomainSize" : ["component"], + "GeometryNodeStoreNamedAttribute" : ["data_type", "domain"], + "GeometryNodeAttributeTransfer" : ["data_type", "mapping"], #color "ShaderNodeMixRGB" : ["blend_type", "use_clamp"], @@ -46,104 +48,101 @@ "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"], + "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"], + "GeometryNodeCurveArc" : ["mode"], + "GeometryNodeCurvePrimitiveBezierSegment" : ["mode"], + "GeometryNodeCurvePrimitiveCircle" : ["mode"], + "GeometryNodeCurvePrimitiveLine" : ["mode"], + "GeometryNodeCurvePrimitiveQuadrilateral" : ["mode"], #geometry - "GeometryNodeDeleteGeometry" : ["domain", "mode"], + "GeometryNodeDeleteGeometry" : ["domain", "mode"], "GeometryNodeDuplicateElements" : ["domain"], - "GeometryNodeProximity" : ["target_element"], - "GeometryNodeMergeByDistance" : ["mode"], - "GeometryNodeRaycast" : ["data_type", "mapping"], - "GeometryNodeSampleIndex" : ["data_type", "domain", "clamp"], - "GeometryNodeSampleNearest" : ["domain"], - "GeometryNodeSeparateGeometry" : ["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"], + "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"], + "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"], + "GeometryNodeMeshCone" : ["fill_type"], + "GeometryNodeMeshCylinder" : ["fill_type"], + "GeometryNodeMeshCircle" : ["fill_type"], + "GeometryNodeMeshLine" : ["mode"], #output - "GeometryNodeViewer" : ["domain"], + "GeometryNodeViewer" : ["domain"], #point - "GeometryNodeDistributePointsInVolume" : ["mode"], - "GeometryNodeDistributePointsOnFaces" : ["distribute_method"], - "GeometryNodePointsToVolume" : ["resolution_mode"], + "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"], + "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"], + "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"], + "ShaderNodeVectorMath" : ["operation"], + "ShaderNodeVectorRotate" : ["rotation_type", "invert"], #volume "GeometryNodeVolumeToMesh" : ["resolution_mode"] @@ -153,9 +152,9 @@ 'ShaderNodeVectorCurve', 'ShaderNodeRGBCurve'} -class NodeToPython(bpy.types.Operator): - bl_idname = "node.node_to_python" - bl_label = "Node to Python" +class GeoNodesToPython(bpy.types.Operator): + bl_idname = "node.geo_nodes_to_python" + bl_label = "Geo Node to Python" bl_options = {'REGISTER', 'UNDO'} node_group_name: bpy.props.StringProperty(name="Node Group") @@ -164,8 +163,8 @@ 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 = ng.name.lower().replace(' ', '_') - class_name = ng.name.replace(" ", "") + ng_name = utils.clean_string(ng.name) + class_name = ng.name.replace(" ", "").replace('.', "") dir = bpy.path.abspath("//") if not dir or dir == "": self.report({'ERROR'}, @@ -205,7 +204,7 @@ def init_class(): file.write("\tdef execute(self, context):\n") def process_node_group(node_group, level): - ng_name = node_group.name.lower().replace(' ', '_') + ng_name = utils.clean_string(node_group.name) outer = "\t"*level #outer indentation inner = "\t"*(level + 1) #inner indentation @@ -223,7 +222,8 @@ def process_node_group(node_group, level): 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) + if node.node_tree is not None: + process_node_group(node.node_tree, level + 1) elif node.bl_idname == 'NodeGroupInput': file.write(f"{inner}#{ng_name} inputs\n") for i, input in enumerate(node.outputs): @@ -271,8 +271,7 @@ def process_node_group(node_group, level): file.write("\n") #create node - node_name = node.name.lower() - node_name = node_name.replace(' ', '_').replace('.', '_') + node_name = utils.clean_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")) @@ -284,8 +283,8 @@ def process_node_group(node_group, level): 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]: + if node.bl_idname in geo_node_settings: + for setting in geo_node_settings[node.bl_idname]: attr = getattr(node, setting, None) if attr: if type(attr) == str: @@ -293,9 +292,10 @@ def process_node_group(node_group, level): 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")) + if node.node_tree is not None: + 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") @@ -318,7 +318,7 @@ def process_node_group(node_group, level): 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") + file.write(f"{inner}#mapping settings\n") mapping = f"{inner}{node_name}.mapping" extend = f"\'{node.mapping.extend}\'" @@ -346,7 +346,7 @@ def process_node_group(node_group, level): file.write(f"{mapping}.use_clip = {use_clip}\n") for i, curve in enumerate(node.mapping.curves): - file.write(f"{inner}#curve {i}") + 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")) @@ -360,7 +360,7 @@ def process_node_group(node_group, level): handle = f"\'{point.handle_type}\'" file.write(f"{point_j}.handle_type = {handle}\n") - file.write(f"{inner}#update curve after changes") + file.write(f"{inner}#update curve after changes\n") file.write(f"{mapping}.update()\n") if node.bl_idname != 'NodeReroute': @@ -387,8 +387,7 @@ def process_node_group(node_group, level): if node_group.links: file.write(f"{inner}#initialize {ng_name} links\n") for link in node_group.links: - input_node = link.from_node.name.lower() - input_node = input_node.replace(' ', '_').replace('.', '_') + input_node = utils.clean_string(link.from_node.name) input_socket = link.from_socket """ @@ -402,8 +401,7 @@ def process_node_group(node_group, level): input_idx = i break - output_node = link.to_node.name.lower() - output_node = output_node.replace(' ', '_').replace('.', '_') + output_node = utils.clean_string(link.to_node.name) output_socket = link.to_socket for i, item in enumerate(link.to_node.inputs.items()): @@ -458,26 +456,27 @@ def create_main(): file.close() return {'FINISHED'} -class NodeToPythonMenu(bpy.types.Menu): - bl_idname = "NODE_MT_node_to_python" - bl_label = "Node To Python" +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): - geo_node_groups = [node for node in bpy.data.node_groups if node.type == 'GEOMETRY'] - 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(NodeToPython.bl_idname, text=geo_ng.name) + op = layout.operator(GeoNodesToPython.bl_idname, text=geo_ng.name) op.node_group_name = geo_ng.name -class NodeToPythonPanel(bpy.types.Panel): - bl_label = "Node To Python" - bl_idname = "NODE_PT_node_to_python" +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 = '' @@ -496,23 +495,12 @@ def draw(self, context): 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 = [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_node_to_python", text="Geometry Node Groups") - -classes = [NodeToPythonMenu, NodeToPythonPanel, NodeToPython] - -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 + 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..9d1ba87 --- /dev/null +++ b/materials.py @@ -0,0 +1,439 @@ +import bpy +import mathutils +import os + +from . import utils + +#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', + 'NodeSocketShader', + 'NodeSocketTexture', + 'NodeSocketVirtual'} + +node_settings = { + #input + "ShaderNodeAmbientOcclusion" : ["samples", "inside", "only_local"], + "ShaderNodeAttribute" : ["attribute_type", "attribute_name"], + "ShaderNodeBevel" : ["samples"], + "ShaderNodeVertexColor" : ["layer_name"], + "ShaderNodeTangent" : ["direction_type", "axis"], + "ShaderNodeTexCoord" : ["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", "space", "radius", + "interpolation", "resolution", + "vertex_color_source"], + "ShaderNodeTexSky" : ["sky_type", "sun_direction", "turbidity", + "ground_albedo", "sun_disc", "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'} + +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): + if self.material_name not in bpy.data.materials: + return {'FINISHED'} + + #set up addon file + ng = bpy.data.materials[self.material_name].node_tree + if ng is None: + self.report({'ERROR'}, + ("NodeToPython: This doesn't seem to be a valid " + "material. Is Use Nodes selected?")) + return {'CANCELLED'} + ng_name = utils.clean_string(self.material_name) + class_name = ng.name.replace(" ", "") + + dir = bpy.path.abspath("//") + if not dir or dir == "": + self.report({'ERROR'}, + ("NodeToPython: Save your blender 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\" : \"{self.material_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 = \"{self.material_name}\"\n") + file.write("\tbl_options = {\'REGISTER\', \'UNDO\'}\n") + file.write("\n") + init_class() + + 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() + + def process_mat_node_group(node_group, level): + ng_name = utils.clean_string(node_group.name) + ng_label = node_group.name + + if level == 2: #outermost node group + ng_name = utils.clean_string(self.material_name) + ng_label = self.material_name + + outer = "\t"*level + inner = "\t"*(level + 1) + + #initialize node group + file.write(f"{outer}#initialize {ng_name} node group\n") + file.write(f"{outer}def {ng_name}_node_group():\n") + + if level == 2: #outermost node group + file.write(f"{inner}{ng_name} = mat.node_tree\n") + else: + file.write((f"{inner}{ng_name}" + f"= bpy.data.node_groups.new(" + f"type = \"ShaderNodeTree\", " + f"name = \"{ng_label}\")\n")) + file.write("\n") + + #initialize nodes + file.write(f"{inner}#initialize {ng_name} nodes\n") + + """ + The bl_idname for AOV output nodes is the name field. + I've been using these for the variable names, but if you don't name + the AOV node it just doesn't assign anything, so we need to do it + manually. + """ + unnamed_index = 0 + for node in node_group.nodes: + if node.bl_idname == 'ShaderNodeGroup': + if node.node_tree is not None: + process_mat_node_group(node.node_tree, level + 1) + #create node + node_name = utils.clean_string(node.name) + if node_name == "": + node_name = f"node_{unnamed_index}" + unnamed_index += 1 + + 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}\'" + if type(attr) == mathutils.Vector: + attr = f"({attr[0]}, {attr[1]}, {attr[2]})" + file.write((f"{inner}{node_name}.{setting} " + f"= {attr}\n")) + elif node.bl_idname == 'ShaderNodeGroup': + if node.node_tree is not None: + 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': + def default_value(i, socket, list_name): + if socket.bl_idname not in dont_set_defaults: + dv = None + if socket.bl_idname == 'NodeSocketColor': + col = socket.default_value + dv = f"({col[0]}, {col[1]}, {col[2]}, {col[3]})" + elif "Vector" in socket.bl_idname: + vector = socket.default_value + dv = f"({vector[0]}, {vector[1]}, {vector[2]})" + elif socket.bl_idname == 'NodeSocketString': + dv = f"\"\"" + else: + dv = socket.default_value + if dv is not None: + file.write(f"{inner}#{socket.identifier}\n") + file.write((f"{inner}{node_name}" + f".{list_name}[{i}]" + f".default_value = {dv}\n")) + for i, input in enumerate(node.inputs): + default_value(i, input, "inputs") + """ + TODO: some shader nodes require you set the default value in the output. + this will need to be handled case by case it looks like though + + for i, output in enumerate(node.outputs): + default_value(i, output, "outputs") + """ + + #initialize links + if node_group.links: + file.write(f"{inner}#initialize {ng_name} links\n") + for link in node_group.links: + input_node = utils.clean_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 = utils.clean_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")) + + file.write(f"{outer}{ng_name}_node_group()\n") + + process_mat_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'} + +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/utils.py b/utils.py new file mode 100644 index 0000000..6afff6b --- /dev/null +++ b/utils.py @@ -0,0 +1,6 @@ +def clean_string(string: str): + bad_chars = [' ', '.', '/'] + clean_str = string.lower() + for char in bad_chars: + clean_str = clean_str.replace(char, '_') + return clean_str \ No newline at end of file