Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "Node to Python",
"description": "Convert Blender node groups to a Python add-on!",
"author": "Brendan Parmer",
"version": (2, 1, 0),
"version": (2, 2, 0),
"blender": (3, 0, 0),
"location": "Node",
"category": "Node",
Expand All @@ -12,16 +12,18 @@
import importlib
importlib.reload(materials)
importlib.reload(geo_nodes)
importlib.reload(options)
else:
from . import materials
from . import geo_nodes
from . import options

import bpy

class NodeToPythonMenu(bpy.types.Menu):
bl_idname = "NODE_MT_node_to_python"
bl_label = "Node To Python"

@classmethod
def poll(cls, context):
return True
Expand All @@ -30,20 +32,30 @@ def draw(self, context):
layout = self.layout.column_flow(columns=1)
layout.operator_context = 'INVOKE_DEFAULT'




classes = [NodeToPythonMenu,
options.NTPOptions,
geo_nodes.GeoNodesToPython,
geo_nodes.SelectGeoNodesMenu,
geo_nodes.GeoNodesToPythonPanel,
materials.MaterialToPython,
materials.SelectMaterialMenu,
materials.MaterialToPythonPanel]
materials.MaterialToPythonPanel,
options.NTPOptionsPanel
]

def register():
for cls in classes:
bpy.utils.register_class(cls)
scene = bpy.types.Scene
scene.ntp_options = bpy.props.PointerProperty(type=options.NTPOptions)

def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
del bpy.types.Scene.ntp_options

if __name__ == "__main__":
register()
28 changes: 14 additions & 14 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/BrendanParmer/NodeToPython)](https://github.com/BrendanParmer/NodeToPython/releases) [![GitHub](https://img.shields.io/github/license/BrendanParmer/NodeToPython)](https://github.com/BrendanParmer/NodeToPython/blob/main/LICENSE) ![](https://visitor-badge.laobi.icu/badge?page_id=BrendanParmer.NodeToPython)

## About
A Blender add-on to create add-ons! This add-on will take your Geometry Nodes or Materials and convert them into legible Python add-ons!
A Blender add-on to create scripts and add-ons! This add-on will take your Geometry Nodes or Materials and convert them into legible Python code.

Node To Python automatically handles node layout, default values, subgroups, naming, colors, and more!

Expand All @@ -15,11 +15,11 @@ Blender's node-based editors are powerful, yet accessible tools, and I wanted to
* interfacing with other parts of the software or properties of an object

## Supported Versions
NodeToPython v2.1 is compatible with Blender 3.0 - 3.6 on Windows, macOS, and Linux. I generally try to update the add-on to handle new nodes around the beta release of each update.
NodeToPython v2.2 is compatible with Blender 3.0 - 3.6 on Windows, macOS, and Linux. I generally try to update the add-on to handle new nodes around the beta release of each update.

## Installation
1. Download the `NodeToPython.zip` file from the [latest release](https://github.com/BrendanParmer/NodeToPython/releases)
* If you clone the repository or download other options, you'll need to rename the zip and the first folder to "NodeToPython" so Blender can properly import the add-on
* If you download other options, you'll need to rename the zip and the first folder to "NodeToPython" so Blender can properly import the add-on
2. In Blender, navigate to `Edit > Preferences > Add-ons`
3. Click Install, and find where you downloaded the zip file. Then hit the `Install Add-on` button, and you're done!

Expand All @@ -30,26 +30,25 @@ In the tab, there's panels to create add-ons for Geometry Nodes and Materials, e

![Add-on Location](./img/location.png "Add-on Location")

Just select the one you want, and soon a zip file will be created in an `addons` folder located in the folder where your blend file is.

From here, you can install it like a regular add-on.
Select the node group you want code for, and you'll be prompted with a **Script** or **Add-on** option.
* **Script** mode creates a function that generates the node tree and copies it to your Blender clipboard.
* Doesn't include `import bpy` line
* To keep NodeToPython cross-platform and independent of third-party libraries, to get it into your system clipboard you need to paste into the Blender text editor and recopy it currently
* **Add-on** mode generates a zip file for you in the save directory specified in the NodeToPython menu. From here, you can install it like a regular add-on. The generated add-on comes complete with operator registration and creating a modifier/material for the node tree to be used in.

## Future
### v2.2
* A "copy" mode, where just the functionality to build the node group is just copied to the clipbaord
* Choose the location where to save the add-on

### v2.3
### v3.x
* Expansion to Compositing nodes
* Add all referenced assets to the Asset Library for use outside of the original blend file
* New Blender 4.0 nodes and changes

### Later
* Better asset handling
* Auto-set handle movies and image sequences
* Automatically format code to be PEP8 compliant
* Automatically detect the minimum version of Blender needed to run the add-on

## Potential Issues
* As of version 2.1, the add-on will not set default values for
* As of version 2.2, the add-on will not set default values for
* Scripts
* IES files
* Filepaths
Expand All @@ -62,6 +61,7 @@ From here, you can install it like a regular add-on.
* Textures

* In a future version, I plan on having the add-on adding all of the above to the Asset Library for reference
* You may run into naming conflicts if your add-on shares a name with another Blender add-on or operator (see [#56](https://github.com/BrendanParmer/NodeToPython/issues/56))

## Bug Reports and Suggestions

Expand All @@ -72,4 +72,4 @@ When submitting an issue, please include
* A short description of what you were trying to accomplish, or steps to reproduce the issue.
* Sample blend files are more than welcome!

Got suggestions? Create an issue, happy to hear what features people want.
Got suggestions? Create an issue, happy to hear what features people want.
137 changes: 93 additions & 44 deletions geo_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os

from .utils import *
from io import StringIO

geo_node_settings = {
# Attribute nodes
Expand Down Expand Up @@ -168,6 +169,14 @@ class GeoNodesToPython(bpy.types.Operator):
bl_label = "Geo Nodes to Python"
bl_options = {'REGISTER', 'UNDO'}

mode : bpy.props.EnumProperty(
name = "Mode",
items = [
('SCRIPT', "Script", "Copy just the node group to the Blender clipboard"),
('ADDON', "Addon", "Create a full addon")
]
)

geo_nodes_group_name: bpy.props.StringProperty(name="Node Group")

def execute(self, context):
Expand All @@ -176,37 +185,40 @@ def execute(self, context):

#set up names to use in generated addon
nt_var = clean_string(nt.name)
class_name = clean_string(nt.name.replace(" ", "").replace('.', ""),
lower = False)

#find base directory to save new addon
base_dir = bpy.path.abspath("//")
if not base_dir or base_dir == "":
self.report({'ERROR'},
("NodeToPython: Save your blend file before using "
"NodeToPython!"))
return {'CANCELLED'}

#save in /addons/ subdirectory
zip_dir = os.path.join(base_dir, "addons", nt_var)
addon_dir = os.path.join(zip_dir, nt_var)
if not os.path.exists(addon_dir):
os.makedirs(addon_dir)
file = open(f"{addon_dir}/__init__.py", "w")

create_header(file, nt.name)
init_operator(file, class_name, nt_var, nt.name)

file.write("\tdef execute(self, context):\n")
if self.mode == 'ADDON':
#find base directory to save new addon
dir = bpy.path.abspath(context.scene.ntp_options.dir_path)
if not dir or dir == "":
self.report({'ERROR'},
("NodeToPython: Save your blend file before using "
"NodeToPython!"))
return {'CANCELLED'}

#save in addons/ subdirectory
zip_dir = os.path.join(dir, nt_var)
addon_dir = os.path.join(zip_dir, nt_var)

if not os.path.exists(addon_dir):
os.makedirs(addon_dir)
file = open(f"{addon_dir}/__init__.py", "w")

create_header(file, nt.name)
class_name = clean_string(nt.name.replace(" ", "").replace('.', ""),
lower = False)
init_operator(file, class_name, nt_var, nt.name)
file.write("\tdef execute(self, context):\n")
else:
file = StringIO("")

#set to keep track of already created node trees
node_trees = set()

#dictionary to keep track of node->variable name pairs
node_vars = {}

#keeps track of all used variables
used_vars = set()
#dictionary to keep track of variables->usage count pairs
used_vars = {}

def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
nt_var = create_var(node_tree.name, used_vars)
Expand All @@ -218,8 +230,8 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
file.write(f"{outer}def {nt_var}_node_group():\n")
file.write((f"{inner}{nt_var}"
f"= bpy.data.node_groups.new("
f"type = \"GeometryNodeTree\", "
f"name = \"{node_tree.name}\")\n"))
f"type = \'GeometryNodeTree\', "
f"name = {str_to_py_str(node_tree.name)})\n"))
file.write("\n")

inputs_set = False
Expand All @@ -238,40 +250,48 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
used_vars)
node_trees.add(node_nt)
elif node.bl_idname == 'NodeGroupInput' and not inputs_set:
group_io_settings(node, file, inner, "input", nt_var, node_tree)
group_io_settings(node, file, inner, "input", nt_var,
node_tree)
inputs_set = True

elif node.bl_idname == 'NodeGroupOutput' and not outputs_set:
group_io_settings(node, file, inner, "output", nt_var, node_tree)
group_io_settings(node, file, inner, "output", nt_var,
node_tree)
outputs_set = True

#create node
node_var = create_node(node, file, inner, nt_var,
node_vars, used_vars)
node_vars, used_vars)
set_settings_defaults(node, geo_node_settings, file, inner,
node_var)
node_var)
hide_sockets(node, file, inner, node_var)

if node.bl_idname == 'GeometryNodeGroup':
if node.node_tree is not None:
file.write((f"{inner}{node_var}.node_tree = "
f"bpy.data.node_groups"
f"[\"{node.node_tree.name}\"]\n"))
f"[{str_to_py_str(node.node_tree.name)}]\n"))

elif node.bl_idname == 'ShaderNodeValToRGB':
color_ramp_settings(node, file, inner, node_var)

elif node.bl_idname in curve_nodes:
curve_node_settings(node, file, inner, node_var)
elif node.bl_idname in image_nodes:

elif node.bl_idname in image_nodes and self.mode == 'ADDON':
img = node.image
if img is not None and img.source in {'FILE', 'GENERATED', 'TILED'}:
save_image(img, addon_dir)
load_image(img, file, inner, f"{node_var}.image")

elif node.bl_idname == 'GeometryNodeSimulationInput':
sim_inputs.append(node)

elif node.bl_idname == 'GeometryNodeSimulationOutput':
file.write(f"{inner}#remove generated sim state items\n")
file.write(f"{inner}for item in {node_var}.state_items:\n")
file.write(f"{inner}\t{node_var}.state_items.remove(item)\n")

for i, si in enumerate(node.state_items):
socket_type = enum_to_py_str(si.socket_type)
name = str_to_py_str(si.name)
Expand All @@ -284,7 +304,10 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
f"{attr_domain}\n"))

if node.bl_idname != 'GeometryNodeSimulationInput':
set_input_defaults(node, file, inner, node_var, addon_dir)
if self.mode == 'ADDON':
set_input_defaults(node, file, inner, node_var, addon_dir)
else:
set_input_defaults(node, file, inner, node_var)
set_output_defaults(node, file, inner, node_var)

#create simulation zones
Expand All @@ -295,13 +318,18 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
f"({sim_output_var})\n"))

#must set defaults after paired with output
set_input_defaults(sim_input, file, inner, sim_input_var, addon_dir)
if self.mode == 'ADDON':
set_input_defaults(node, file, inner, node_var, addon_dir)
else:
set_input_defaults(node, file, inner, node_var)
set_output_defaults(sim_input, file, inner, sim_input_var)

#set look of nodes
set_parents(node_tree, file, inner, node_vars)
set_locations(node_tree, file, inner, node_vars)
set_dimensions(node_tree, file, inner, node_vars)

#create connections
init_links(node_tree, file, inner, nt_var, node_vars)

file.write(f"{inner}return {nt_var}\n")
Expand All @@ -311,7 +339,11 @@ def process_geo_nodes_group(node_tree, level, node_vars, used_vars):
f"{nt_var}_node_group()\n\n"))
return used_vars

process_geo_nodes_group(nt, 2, node_vars, used_vars)
if self.mode == 'ADDON':
level = 2
else:
level = 0
process_geo_nodes_group(nt, level, node_vars, used_vars)

def apply_modifier():
#get object
Expand All @@ -323,21 +355,37 @@ def apply_modifier():
file.write((f"\t\tmod = obj.modifiers.new(name = {mod_name}, "
f"type = 'NODES')\n"))
file.write(f"\t\tmod.node_group = {nt_var}\n")
apply_modifier()

file.write("\t\treturn {'FINISHED'}\n\n")

create_menu_func(file, class_name)
create_register_func(file, class_name)
create_unregister_func(file, class_name)
create_main_func(file)
if self.mode == 'ADDON':
apply_modifier()

file.write("\t\treturn {'FINISHED'}\n\n")

create_menu_func(file, class_name)
create_register_func(file, class_name)
create_unregister_func(file, class_name)
create_main_func(file)
else:
context.window_manager.clipboard = file.getvalue()
file.close()

zip_addon(zip_dir)
if self.mode == 'ADDON':
zip_addon(zip_dir)

#alert user that NTP is finished
if self.mode == 'SCRIPT':
location = "clipboard"
else:
location = dir
self.report({'INFO'},
f"NodeToPython: Saved geometry nodes group to {location}")
return {'FINISHED'}

def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)

def draw(self, context):
self.layout.prop(self, "mode")

class SelectGeoNodesMenu(bpy.types.Menu):
bl_idname = "NODE_MT_ntp_geo_nodes_selection"
bl_label = "Select Geo Nodes"
Expand All @@ -350,7 +398,8 @@ def draw(self, context):
layout = self.layout.column_flow(columns=1)
layout.operator_context = 'INVOKE_DEFAULT'

geo_node_groups = [node for node in bpy.data.node_groups if node.type == 'GEOMETRY']
geo_node_groups = [node for node in bpy.data.node_groups
if node.type == 'GEOMETRY']

for geo_ng in geo_node_groups:
op = layout.operator(GeoNodesToPython.bl_idname, text=geo_ng.name)
Expand Down
Loading