From c78e4c90fd6d5613d9836db3bf682c0851fdafd2 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:33:37 -0500 Subject: [PATCH 01/28] feat: ui improvements --- NodeToPython/export_operator.py | 1 + NodeToPython/ui/main.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/NodeToPython/export_operator.py b/NodeToPython/export_operator.py index 5ecf950..9922fdb 100644 --- a/NodeToPython/export_operator.py +++ b/NodeToPython/export_operator.py @@ -87,6 +87,7 @@ def get_single_node_group(self): class NTP_OT_Export(bpy.types.Operator): bl_idname = "ntp.export" bl_label = "Export" + bl_description = "Export node group(s) to Python" bl_options = {'REGISTER', 'UNDO'} def execute(self, context: bpy.types.Context): diff --git a/NodeToPython/ui/main.py b/NodeToPython/ui/main.py index fb33206..fde92c6 100644 --- a/NodeToPython/ui/main.py +++ b/NodeToPython/ui/main.py @@ -34,8 +34,8 @@ def draw(self, context: bpy.types.Context): location = "clipboard" export_icon = 'COPYDOWN' elif ntp_options.mode == 'ADDON': - location = pathlib.PurePath(ntp_options.dir_path).name - export_icon = 'CONSOLE' + location = f"{pathlib.PurePath(ntp_options.dir_path).name}/" + export_icon = 'FILE_FOLDER' gatherer = NodeGroupGatherer() gatherer.gather_node_groups(context) @@ -48,8 +48,12 @@ def draw(self, context: bpy.types.Context): export_text = f"Export {node_group} to {location}" + if location == "": + export_text = "Add a save location to get started!" + export_icon = 'WARNING_LARGE' + if num_node_groups == 0: - export_text = f"0 node groups selected!" + export_text = "Add a node group to get started!" export_icon = 'WARNING_LARGE' export_button = row.operator( @@ -57,7 +61,7 @@ def draw(self, context: bpy.types.Context): text=export_text, icon=export_icon ) - row.enabled = num_node_groups > 0 + row.enabled = num_node_groups > 0 and location != "" classes: list[type] = [ NTP_PT_Main From 4caf9d58504bec06b5df0d8d7c00d1262433bf2b Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 8 Nov 2025 16:14:44 -0600 Subject: [PATCH 02/28] refactor: separate operator from nodetree export logic Also fixes issues with Euler rotation settings and removes some comments/declarations when unnecessary --- NodeToPython/__init__.py | 16 +- NodeToPython/compositor/__init__.py | 14 - NodeToPython/compositor/operator.py | 306 --- NodeToPython/export/__init__.py | 30 + NodeToPython/export/compositor/__init__.py | 11 + NodeToPython/export/compositor/exporter.py | 199 ++ .../{shader => export/geometry}/__init__.py | 11 +- NodeToPython/export/geometry/exporter.py | 106 + .../{ => export}/geometry/node_tree.py | 9 +- .../{ => export}/license_templates.py | 0 .../node_group_gatherer.py} | 55 +- NodeToPython/{ => export}/node_settings.py | 0 .../node_tree_exporter.py} | 1739 ++++++++--------- NodeToPython/{ => export}/ntp_node_tree.py | 9 +- NodeToPython/export/ntp_operator.py | 404 ++++ NodeToPython/{ => export}/ntp_options.py | 0 .../{geometry => export/shader}/__init__.py | 11 +- NodeToPython/export/shader/exporter.py | 180 ++ NodeToPython/{ => export}/shader/node_tree.py | 5 +- NodeToPython/{ => export}/utils.py | 0 NodeToPython/geometry/operator.py | 240 --- NodeToPython/shader/operator.py | 322 --- NodeToPython/ui/__init__.py | 11 +- NodeToPython/ui/addon_settings.py | 6 +- .../ui => ui/compositor}/__init__.py | 0 .../compositor}/compositor_node_groups.py | 0 .../{compositor/ui => ui/compositor}/panel.py | 0 .../ui => ui/compositor}/scenes.py | 0 NodeToPython/ui/generation_settings.py | 3 +- .../{geometry/ui => ui/geometry}/__init__.py | 0 .../geometry}/geometry_node_groups.py | 0 .../{geometry/ui => ui/geometry}/panel.py | 0 NodeToPython/ui/main.py | 8 +- NodeToPython/ui/settings.py | 3 +- .../{shader/ui => ui/shader}/__init__.py | 0 .../{shader/ui => ui/shader}/lights.py | 0 .../{shader/ui => ui/shader}/line_styles.py | 0 .../{shader/ui => ui/shader}/materials.py | 0 .../{shader/ui => ui/shader}/panel.py | 0 .../ui => ui/shader}/shader_node_groups.py | 0 .../{shader/ui => ui/shader}/worlds.py | 0 41 files changed, 1780 insertions(+), 1918 deletions(-) delete mode 100644 NodeToPython/compositor/__init__.py delete mode 100644 NodeToPython/compositor/operator.py create mode 100644 NodeToPython/export/__init__.py create mode 100644 NodeToPython/export/compositor/__init__.py create mode 100644 NodeToPython/export/compositor/exporter.py rename NodeToPython/{shader => export/geometry}/__init__.py (51%) create mode 100644 NodeToPython/export/geometry/exporter.py rename NodeToPython/{ => export}/geometry/node_tree.py (53%) rename NodeToPython/{ => export}/license_templates.py (100%) rename NodeToPython/{export_operator.py => export/node_group_gatherer.py} (55%) rename NodeToPython/{ => export}/node_settings.py (100%) rename NodeToPython/{ntp_operator.py => export/node_tree_exporter.py} (63%) rename NodeToPython/{ => export}/ntp_node_tree.py (63%) create mode 100644 NodeToPython/export/ntp_operator.py rename NodeToPython/{ => export}/ntp_options.py (100%) rename NodeToPython/{geometry => export/shader}/__init__.py (51%) create mode 100644 NodeToPython/export/shader/exporter.py rename NodeToPython/{ => export}/shader/node_tree.py (58%) rename NodeToPython/{ => export}/utils.py (100%) delete mode 100644 NodeToPython/geometry/operator.py delete mode 100644 NodeToPython/shader/operator.py rename NodeToPython/{compositor/ui => ui/compositor}/__init__.py (100%) rename NodeToPython/{compositor/ui => ui/compositor}/compositor_node_groups.py (100%) rename NodeToPython/{compositor/ui => ui/compositor}/panel.py (100%) rename NodeToPython/{compositor/ui => ui/compositor}/scenes.py (100%) rename NodeToPython/{geometry/ui => ui/geometry}/__init__.py (100%) rename NodeToPython/{geometry/ui => ui/geometry}/geometry_node_groups.py (100%) rename NodeToPython/{geometry/ui => ui/geometry}/panel.py (100%) rename NodeToPython/{shader/ui => ui/shader}/__init__.py (100%) rename NodeToPython/{shader/ui => ui/shader}/lights.py (100%) rename NodeToPython/{shader/ui => ui/shader}/line_styles.py (100%) rename NodeToPython/{shader/ui => ui/shader}/materials.py (100%) rename NodeToPython/{shader/ui => ui/shader}/panel.py (100%) rename NodeToPython/{shader/ui => ui/shader}/shader_node_groups.py (100%) rename NodeToPython/{shader/ui => ui/shader}/worlds.py (100%) diff --git a/NodeToPython/__init__.py b/NodeToPython/__init__.py index 49a9141..37e3c22 100644 --- a/NodeToPython/__init__.py +++ b/NodeToPython/__init__.py @@ -10,23 +10,15 @@ if "bpy" in locals(): import importlib - importlib.reload(export_operator) - importlib.reload(ntp_options) + importlib.reload(export) importlib.reload(ui) - importlib.reload(compositor) - importlib.reload(geometry) - importlib.reload(shader) else: - from . import export_operator - from . import ntp_options + from . import export from . import ui - from . import compositor - from . import geometry - from . import shader import bpy -modules = [export_operator, ntp_options] -for parent_module in [ui, compositor, geometry, shader]: +modules = [] +for parent_module in [export, ui]: if hasattr(parent_module, "modules"): modules += parent_module.modules else: diff --git a/NodeToPython/compositor/__init__.py b/NodeToPython/compositor/__init__.py deleted file mode 100644 index cc9aa89..0000000 --- a/NodeToPython/compositor/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -if "bpy" in locals(): - import importlib - importlib.reload(operator) - importlib.reload(ui) -else: - from . import operator - from . import ui - -import bpy - -modules = [ - operator -] -modules += ui.modules \ No newline at end of file diff --git a/NodeToPython/compositor/operator.py b/NodeToPython/compositor/operator.py deleted file mode 100644 index a698b1a..0000000 --- a/NodeToPython/compositor/operator.py +++ /dev/null @@ -1,306 +0,0 @@ -import bpy - -from bpy.types import Node, CompositorNodeColorBalance, CompositorNodeTree - -from ..ntp_operator import NTP_Operator, INDEX -from ..ntp_node_tree import NTP_NodeTree -from ..utils import * -from ..node_settings import NTPNodeSetting, ST -from io import StringIO -from ..node_settings import node_settings - -SCENE = "scene" -BASE_NAME = "base_name" -END_NAME = "end_name" -NODE = "node" - -COMP_OP_RESERVED_NAMES = {SCENE, BASE_NAME, END_NAME, NODE} - -class NTP_OT_Compositor(NTP_Operator): - bl_idname = "ntp.compositor" - bl_label = "Compositor to Python" - bl_options = {'REGISTER', 'UNDO'} - - compositor_name: bpy.props.StringProperty(name="Node Group") - is_scene : bpy.props.BoolProperty( - name="Is Scene", - description="Blender stores compositing node trees differently for scenes and in groups") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._node_infos = node_settings - for name in COMP_OP_RESERVED_NAMES: - self._used_vars[name] = 0 - - - def _create_scene(self, indent_level: int): - #TODO: wrap in more general unique name util function - self._write(f"# Generate unique scene name", indent_level) - self._write(f"{BASE_NAME} = {str_to_py_str(self.compositor_name)}", - indent_level) - self._write(f"{END_NAME} = {BASE_NAME}", indent_level) - self._write(f"if bpy.data.scenes.get({END_NAME}) is not None:", indent_level) - - self._write(f"{INDEX} = 1", indent_level + 1) - self._write(f"{END_NAME} = {BASE_NAME} + f\".{{i:03d}}\"", - indent_level + 1) - self._write(f"while bpy.data.scenes.get({END_NAME}) is not None:", - indent_level + 1) - - self._write(f"{END_NAME} = {BASE_NAME} + f\".{{{INDEX}:03d}}\"", - indent_level + 2) - self._write(f"{INDEX} += 1\n", indent_level + 2) - - self._write(f"bpy.ops.scene.new(type='NEW')", indent_level) - self._write(f"{SCENE} = bpy.context.scene", indent_level) - self._write(f"{SCENE}.name = {END_NAME}", indent_level) - self._write(f"{SCENE}.use_fake_user = True", indent_level) - self._write(f"bpy.context.window.scene = {SCENE}", indent_level) - self._write("", 0) - - def _initialize_compositor_node_tree(self, ntp_nt, nt_name): - #initialize node group - self._write(f"def {ntp_nt.var}_node_group():", self._outer_indent_level) - self._write(f'"""Initialize {nt_name} node group"""') - - if ntp_nt.node_tree == self._base_node_tree and self.is_scene: - self._write("if bpy.app.version < (5, 0, 0):") - self._write(f"{ntp_nt.var} = {SCENE}.node_tree", - self._inner_indent_level + 1) - self._write("else:") - self._write((f"{SCENE}.compositing_node_group = " - f"bpy.data.node_groups.new(" - f"type = \'CompositorNodeTree\', " - f"name = {str_to_py_str(nt_name)})"), - self._inner_indent_level + 1) - self._write(f"{ntp_nt.var} = {SCENE}.compositing_node_group", - self._inner_indent_level + 1) - self._write("", 0) - self._write(f"# Start with a clean node tree") - self._write(f"for {NODE} in {ntp_nt.var}.nodes:") - self._write(f"{ntp_nt.var}.nodes.remove({NODE})", - self._inner_indent_level + 1) - else: - self._write((f"{ntp_nt.var} = bpy.data.node_groups.new(" - f"type = \'CompositorNodeTree\', " - f"name = {str_to_py_str(nt_name)})")) - self._write("", 0) - - # Compositor node tree settings - #TODO: might be good to make this optional - enum_settings = ["chunk_size", "edit_quality", "execution_mode", - "precision", "render_quality"] - for enum in enum_settings: - if not hasattr(ntp_nt.node_tree, enum): - continue - setting = getattr(ntp_nt.node_tree, enum) - if setting != None and setting != "": - py_str = enum_to_py_str(setting) - self._write(f"{ntp_nt.var}.{enum} = {py_str}") - - bool_settings = ["use_groupnode_buffer", "use_opencl", "use_two_pass", - "use_viewer_border"] - for bool_setting in bool_settings: - if not hasattr(ntp_nt.node_tree, bool_setting): - continue - if getattr(ntp_nt.node_tree, bool_setting) is True: - self._write(f"{ntp_nt.var}.{bool_setting} = True") - - - if bpy.app.version < (4, 5, 0): - def _set_color_balance_settings(self, node: CompositorNodeColorBalance - ) -> None: - """ - Sets the color balance settings so we only set the active variables, - preventing conflict - - node (CompositorNodeColorBalance): the color balance node - """ - if node.correction_method == 'LIFT_GAMMA_GAIN': - lst = [NTPNodeSetting("correction_method", ST.ENUM), - NTPNodeSetting("gain", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("gain", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), - NTPNodeSetting("gamma", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("gamma", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), - NTPNodeSetting("lift", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("lift", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0))] - elif node.correction_method == 'OFFSET_POWER_SLOPE': - lst = [NTPNodeSetting("correction_method", ST.ENUM), - NTPNodeSetting("offset", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("offset", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), - NTPNodeSetting("offset_basis", ST.FLOAT), - NTPNodeSetting("power", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("power", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), - NTPNodeSetting("slope", ST.VEC3, max_version_=(3, 5, 0)), - NTPNodeSetting("slope", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0))] - elif node.correction_method == 'WHITEPOINT': - lst = [NTPNodeSetting("correction_method", ST.ENUM, max_version_=(4, 5, 0)), - NTPNodeSetting("input_temperature", ST.FLOAT, max_version_=(4, 5, 0)), - NTPNodeSetting("input_tint", ST.FLOAT, max_version_=(4, 5, 0)), - NTPNodeSetting("output_temperature", ST.FLOAT, max_version_=(4, 5, 0)), - NTPNodeSetting("output_tint", ST.FLOAT, max_version_=(4, 5, 0))] - else: - self.report({'ERROR'}, - f"Unknown color balance correction method " - f"{enum_to_py_str(node.correction_method)}") - return - - color_balance_info = self._node_infos['CompositorNodeColorBalance'] - self._node_infos['CompositorNodeColorBalance'] = color_balance_info._replace(attributes_ = lst) - - def _process_node(self, node: Node, ntp_nt: NTP_NodeTree): - """ - Create node and set settings, defaults, and cosmetics - - Parameters: - node (Node): node to process - ntp_nt (NTP_NodeTree): the node tree that node belongs to - """ - node_var: str = self._create_node(node, ntp_nt.var) - - if bpy.app.version < (4, 5, 0): - if node.bl_idname == 'CompositorNodeColorBalance': - self._set_color_balance_settings(node) - - self._set_settings_defaults(node) - self._hide_hidden_sockets(node) - - if bpy.app.version < (4, 0, 0): - if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: - self._group_io_settings(node, "input", ntp_nt) - ntp_nt.inputs_set = True - - elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: - self._group_io_settings(node, "output", ntp_nt) - ntp_nt.outputs_set = True - - self._set_socket_defaults(node) - - def _process_node_tree(self, node_tree: CompositorNodeTree): - """ - Generates a Python function to recreate a compositor node tree - - Parameters: - node_tree (CompositorNodeTree): node tree to be recreated - """ - if node_tree == self._base_node_tree: - nt_var = self._create_var(self.compositor_name) - nt_name = self.compositor_name - else: - nt_var = self._create_var(node_tree.name) - nt_name = node_tree.name - - self._node_tree_vars[node_tree] = nt_var - - ntp_nt = NTP_NodeTree(node_tree, nt_var) - self._initialize_compositor_node_tree(ntp_nt, nt_name) - - self._set_node_tree_properties(node_tree) - - if bpy.app.version >= (4, 0, 0): - self._tree_interface_settings(ntp_nt) - - #initialize nodes - self._write(f"# Initialize {nt_var} nodes\n") - - for node in node_tree.nodes: - self._process_node(node, ntp_nt) - - #set look of nodes - self._set_parents(node_tree) - self._set_locations(node_tree) - self._set_dimensions(node_tree) - - #create connections - self._init_links(node_tree) - - self._write(f"return {nt_var}\n") - if self._mode == 'SCRIPT': - self._write("", 0) - - #create node group - self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) - - def execute(self, context): - if not self._setup_options(context.scene.ntp_options): - return {'CANCELLED'} - - #find node group to replicate - if self.is_scene: - scene = bpy.data.scenes[self.compositor_name] - if bpy.app.version < (5, 0, 0): - self._base_node_tree = scene.node_tree - else: - self._base_node_tree = scene.compositing_node_group - else: - self._base_node_tree = bpy.data.node_groups[self.compositor_name] - - if self._base_node_tree is None: - #shouldn't happen - self.report({'ERROR'},("NodeToPython: This doesn't seem to be a " - "valid compositor node tree. Is Use Nodes " - "selected?")) - return {'CANCELLED'} - - #set up names to use in generated addon - comp_var = clean_string(self.compositor_name) - - if self._mode == 'ADDON': - self._outer_indent_level = 2 - self._inner_indent_level = 3 - - if not self._setup_addon_directories(context, comp_var): - return {'CANCELLED'} - - self._file = open(f"{self._addon_dir}/__init__.py", "w") - - self._create_bl_info(self.compositor_name) - self._create_imports() - self._class_name = clean_string(self.compositor_name, lower=False) - self._init_operator(comp_var, self.compositor_name) - - self._write("def execute(self, context):", 1) - else: - self._file = StringIO("") - if self._include_imports: - self._create_imports() - - if self.is_scene: - if self._mode == 'ADDON': - self._create_scene(2) - elif self._mode == 'SCRIPT': - self._create_scene(0) - self._write("", 0) - - node_trees_to_process = self._topological_sort(self._base_node_tree) - - self._import_essential_libs() - - for node_tree in node_trees_to_process: - self._process_node_tree(node_tree) - - if self._mode == 'ADDON': - self._write("return {'FINISHED'}\n", self._outer_indent_level) - - self._create_menu_func() - self._create_register_func() - self._create_unregister_func() - self._create_main_func() - self._create_license() - if bpy.app.version >= (4, 2, 0): - self._create_manifest() - else: - context.window_manager.clipboard = self._file.getvalue() - - self._file.close() - - if self._mode == 'ADDON': - self._zip_addon() - - self._report_finished("compositor nodes") - - return {'FINISHED'} - -classes: list[type] = [ - NTP_OT_Compositor -] \ No newline at end of file diff --git a/NodeToPython/export/__init__.py b/NodeToPython/export/__init__.py new file mode 100644 index 0000000..e84bfd2 --- /dev/null +++ b/NodeToPython/export/__init__.py @@ -0,0 +1,30 @@ +if "bpy" in locals(): + import importlib + importlib.reload(license_templates) + importlib.reload(node_group_gatherer) + importlib.reload(node_settings) + importlib.reload(node_tree_exporter) + importlib.reload(ntp_operator) + importlib.reload(ntp_options) + importlib.reload(utils) + importlib.reload(compositor) + importlib.reload(geometry) + importlib.reload(shader) +else: + from . import license_templates + from . import node_group_gatherer + from . import node_settings + from . import node_tree_exporter + from . import ntp_operator + from . import ntp_options + from . import utils + from . import compositor + from . import geometry + from . import shader + +import bpy + +modules = [ + ntp_options, + ntp_operator +] \ No newline at end of file diff --git a/NodeToPython/export/compositor/__init__.py b/NodeToPython/export/compositor/__init__.py new file mode 100644 index 0000000..3426b6a --- /dev/null +++ b/NodeToPython/export/compositor/__init__.py @@ -0,0 +1,11 @@ +if "bpy" in locals(): + import importlib + importlib.reload(exporter) +else: + from . import exporter + +import bpy + +modules = [ + exporter +] \ No newline at end of file diff --git a/NodeToPython/export/compositor/exporter.py b/NodeToPython/export/compositor/exporter.py new file mode 100644 index 0000000..13821eb --- /dev/null +++ b/NodeToPython/export/compositor/exporter.py @@ -0,0 +1,199 @@ +import bpy + +from ..node_group_gatherer import NodeGroupType +from ..node_settings import NTPNodeSetting +from ..node_tree_exporter import NodeTreeExporter, INDEX +from ..ntp_node_tree import NTP_NodeTree +from ..ntp_operator import NTP_OT_Export +from ..utils import * + +SCENE = "scene" +BASE_NAME = "base_name" +END_NAME = "end_name" +NODE = "node" + +COMP_OP_RESERVED_NAMES = {SCENE, BASE_NAME, END_NAME, NODE} + +class CompositorExporter(NodeTreeExporter): + def __init__( + self, + ntp_operator: NTP_OT_Export, + obj_name: str, + group_type: NodeGroupType + ): + if group_type not in { + NodeGroupType.COMPOSITOR_NODE_GROUP, + NodeGroupType.SCENE + }: + ntp_operator.report( + {'ERROR'}, + f"Cannot initialize CompositorExporter with group type {group_type}" + ) + NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + for name in COMP_OP_RESERVED_NAMES: + self._used_vars[name] = 0 + + def _create_scene(self): + indent_level = self._get_obj_creation_indent() + + #TODO: wrap in more general unique name util function + self._write(f"# Generate unique scene name", indent_level) + self._write(f"{BASE_NAME} = {str_to_py_str(self._obj_name)}", + indent_level) + self._write(f"{END_NAME} = {BASE_NAME}", indent_level) + self._write(f"if bpy.data.scenes.get({END_NAME}) is not None:", indent_level) + + self._write(f"{INDEX} = 1", indent_level + 1) + self._write(f"{END_NAME} = {BASE_NAME} + f\".{{i:03d}}\"", + indent_level + 1) + self._write(f"while bpy.data.scenes.get({END_NAME}) is not None:", + indent_level + 1) + + self._write(f"{END_NAME} = {BASE_NAME} + f\".{{{INDEX}:03d}}\"", + indent_level + 2) + self._write(f"{INDEX} += 1\n", indent_level + 2) + + self._write(f"bpy.ops.scene.new(type='NEW')", indent_level) + self._write(f"{SCENE} = bpy.context.scene", indent_level) + self._write(f"{SCENE}.name = {END_NAME}", indent_level) + self._write(f"{SCENE}.use_fake_user = True", indent_level) + self._write(f"bpy.context.window.scene = {SCENE}", indent_level) + self._write("", 0) + + # NodeTreeExporter interface + def _create_obj(self): + if self._group_type == NodeGroupType.SCENE: + self._create_scene() + + # NodeTreeExporter interface + def _set_base_node_tree(self) -> None: + if self._group_type == NodeGroupType.SCENE: + scene = bpy.data.scenes[self._obj_name] + if bpy.app.version < (5, 0, 0): + self._base_node_tree = getattr(scene, "node_tree") + else: + self._base_node_tree = scene.compositing_node_group + else: + self._base_node_tree = bpy.data.node_groups[self._obj_name] + + if self._base_node_tree is None: + #shouldn't happen + self._operator.report( + {'ERROR'}, + ("NodeToPython: This doesn't seem to be a valid compositor " + "node tree. Is Use Nodes selected?")) + + # NodeTreeExporter interface + def _initialize_node_tree( + self, + ntp_node_tree: NTP_NodeTree + ) -> None: + nt_name = ntp_node_tree._node_tree.name + + #initialize node group + self._write(f"def {ntp_node_tree._var}_node_group():", + self._operator._outer_indent_level) + self._write(f'"""Initialize {nt_name} node group"""') + + is_tree_base = (ntp_node_tree._node_tree == self._base_node_tree) + is_scene = self._group_type == NodeGroupType.SCENE + if is_tree_base and is_scene: + self._write("if bpy.app.version < (5, 0, 0):") + self._write(f"{ntp_node_tree._var} = {SCENE}.node_tree", + self._operator._inner_indent_level + 1) + self._write("else:") + self._write((f"{SCENE}.compositing_node_group = " + f"bpy.data.node_groups.new(" + f"type = \'CompositorNodeTree\', " + f"name = {str_to_py_str(nt_name)})"), + self._operator._inner_indent_level + 1) + self._write(f"{ntp_node_tree._var} = {SCENE}.compositing_node_group", + self._operator._inner_indent_level + 1) + self._write("", 0) + self._write(f"# Start with a clean node tree") + self._write(f"for {NODE} in {ntp_node_tree._var}.nodes:") + self._write(f"{ntp_node_tree._var}.nodes.remove({NODE})", + self._operator._inner_indent_level + 1) + else: + self._write((f"{ntp_node_tree._var} = bpy.data.node_groups.new(" + f"type = \'CompositorNodeTree\', " + f"name = {str_to_py_str(nt_name)})")) + self._write("", 0) + + # Compositor node tree settings + #TODO: might be good to make this optional + enum_settings = ["chunk_size", "edit_quality", "execution_mode", + "precision", "render_quality"] + for enum in enum_settings: + if not hasattr(ntp_node_tree._node_tree, enum): + continue + setting = getattr(ntp_node_tree._node_tree, enum) + if setting != None and setting != "": + py_str = enum_to_py_str(setting) + self._write(f"{ntp_node_tree._var}.{enum} = {py_str}") + + bool_settings = ["use_groupnode_buffer", "use_opencl", "use_two_pass", + "use_viewer_border"] + for bool_setting in bool_settings: + if not hasattr(ntp_node_tree._node_tree, bool_setting): + continue + if getattr(ntp_node_tree._node_tree, bool_setting) is True: + self._write(f"{ntp_node_tree._var}.{bool_setting} = True") + + def _process_node(self, node: bpy.types.Node, ntp_nt: NTP_NodeTree) -> None: + if bpy.app.version < (4, 5, 0): + if node.bl_idname == 'CompositorNodeColorBalance': + self._set_color_balance_settings(node) + NodeTreeExporter._process_node(self, node, ntp_nt) + + if bpy.app.version < (4, 5, 0): + def _set_color_balance_settings( + self, + node: bpy.types.CompositorNodeColorBalance + ) -> None: + """ + Sets the color balance settings so we only set the active variables, + preventing conflict + + node (CompositorNodeColorBalance): the color balance node + """ + correction_method = getattr(node, "correction_method") + if correction_method == 'LIFT_GAMMA_GAIN': + lst = [ + NTPNodeSetting("correction_method", ST.ENUM), + NTPNodeSetting("gain", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("gain", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), + NTPNodeSetting("gamma", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("gamma", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), + NTPNodeSetting("lift", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("lift", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)) + ] + elif correction_method == 'OFFSET_POWER_SLOPE': + lst = [ + NTPNodeSetting("correction_method", ST.ENUM), + NTPNodeSetting("offset", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("offset", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), + NTPNodeSetting("offset_basis", ST.FLOAT), + NTPNodeSetting("power", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("power", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)), + NTPNodeSetting("slope", ST.VEC3, max_version_=(3, 5, 0)), + NTPNodeSetting("slope", ST.COLOR, min_version_=(3, 5, 0), max_version_=(4, 5, 0)) + ] + elif correction_method == 'WHITEPOINT': + lst = [ + NTPNodeSetting("correction_method", ST.ENUM, max_version_=(4, 5, 0)), + NTPNodeSetting("input_temperature", ST.FLOAT, max_version_=(4, 5, 0)), + NTPNodeSetting("input_tint", ST.FLOAT, max_version_=(4, 5, 0)), + NTPNodeSetting("output_temperature", ST.FLOAT, max_version_=(4, 5, 0)), + NTPNodeSetting("output_tint", ST.FLOAT, max_version_=(4, 5, 0)) + ] + else: + self._operator.report({'ERROR'}, + f"Unknown color balance correction method " + f"{enum_to_py_str(correction_method)}") + return + + color_balance_info = self._node_settings['CompositorNodeColorBalance'] + self._node_settings['CompositorNodeColorBalance'] = color_balance_info._replace(attributes_ = lst) + + diff --git a/NodeToPython/shader/__init__.py b/NodeToPython/export/geometry/__init__.py similarity index 51% rename from NodeToPython/shader/__init__.py rename to NodeToPython/export/geometry/__init__.py index 83ec3ca..e689686 100644 --- a/NodeToPython/shader/__init__.py +++ b/NodeToPython/export/geometry/__init__.py @@ -1,17 +1,14 @@ if "bpy" in locals(): import importlib importlib.reload(node_tree) - importlib.reload(operator) - importlib.reload(ui) + importlib.reload(exporter) else: from . import node_tree - from . import operator - from . import ui + from . import exporter import bpy modules = [ node_tree, - operator -] -modules += ui.modules \ No newline at end of file + exporter +] \ No newline at end of file diff --git a/NodeToPython/export/geometry/exporter.py b/NodeToPython/export/geometry/exporter.py new file mode 100644 index 0000000..a59a0da --- /dev/null +++ b/NodeToPython/export/geometry/exporter.py @@ -0,0 +1,106 @@ +import bpy + +from ..node_group_gatherer import NodeGroupType +from ..node_tree_exporter import NodeTreeExporter +from ..ntp_operator import NTP_OT_Export +from ..utils import * + +from .node_tree import NTP_GeoNodeTree, NTP_NodeTree + +OBJECT_NAME = "name" +OBJECT = "obj" +MODIFIER = "mod" +GEO_OP_RESERVED_NAMES = { + OBJECT_NAME, + OBJECT, + MODIFIER +} + +class GeometryNodesExporter(NodeTreeExporter): + bl_idname = "ntp.geometry_nodes" + bl_label = "Geometry Nodes to Python" + bl_options = {'REGISTER', 'UNDO'} + + def __init__( + self, + ntp_operator: NTP_OT_Export, + obj_name: str, + group_type: NodeGroupType + ): + if group_type not in { + NodeGroupType.GEOMETRY_NODE_GROUP + }: + ntp_operator.report( + {'ERROR'}, + f"Cannot initialize GeometryNodesExporter with group type {group_type}" + ) + NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + for name in GEO_OP_RESERVED_NAMES: + self._used_vars[name] = 0 + + def _set_node_tree_properties(self, node_tree: bpy.types.NodeTree) -> None: + NodeTreeExporter._set_node_tree_properties(self, node_tree) + if bpy.app.version >= (4, 0, 0): + self._set_geo_tree_properties(node_tree) + + if bpy.app.version >= (4, 0, 0): + def _set_geo_tree_properties(self, node_tree: bpy.types.GeometryNodeTree) -> None: + is_mod = node_tree.is_modifier + is_tool = node_tree.is_tool + + nt_var = self._node_tree_vars[node_tree] + + if is_mod: + self._write(f"{nt_var}.is_modifier = True") + if is_tool: + self._write(f"{nt_var}.is_tool = True") + + tool_flags = [ + "is_mode_object", + "is_mode_edit", + "is_mode_sculpt", + "is_type_curve", + "is_type_mesh", + "is_type_point_cloud" + ] + + for flag in tool_flags: + if hasattr(node_tree, flag) is True: + self._write(f"{nt_var}.{flag} = {getattr(node_tree, flag)}") + + if bpy.app.version >= (4, 2, 0): + if node_tree.use_wait_for_click: + self._write(f"{nt_var}.use_wait_for_click = True") + + if bpy.app.version >= (5, 0, 0): + if node_tree.show_modifier_manage_panel: + self._write(f"{nt_var}.show_modifier_manage_panel = True") + self._write("", 0) + + # NodeTreeExporter interface + def _create_obj(self) -> None: + pass + + # NodeTreeExporter interface + def _set_base_node_tree(self) -> None: + self._base_node_tree = bpy.data.node_groups[self._obj_name] + + def _initialize_ntp_node_tree( + self, + node_tree: bpy.types.NodeTree, + nt_var: str + ) -> NTP_NodeTree: + return NTP_GeoNodeTree(node_tree, nt_var) + + # NodeTreeExporter interface + def _initialize_node_tree( + self, + ntp_node_tree: NTP_NodeTree + ) -> None: + nt_name = ntp_node_tree._node_tree.name + self._write(f"def {ntp_node_tree._var}_node_group():", + self._operator._outer_indent_level) + self._write(f'"""Initialize {nt_name} node group"""') + self._write(f"{ntp_node_tree._var} = bpy.data.node_groups.new(" + f"type=\'GeometryNodeTree\', " + f"name={str_to_py_str(nt_name)})\n") \ No newline at end of file diff --git a/NodeToPython/geometry/node_tree.py b/NodeToPython/export/geometry/node_tree.py similarity index 53% rename from NodeToPython/geometry/node_tree.py rename to NodeToPython/export/geometry/node_tree.py index 19910fe..44e9351 100644 --- a/NodeToPython/geometry/node_tree.py +++ b/NodeToPython/export/geometry/node_tree.py @@ -5,12 +5,11 @@ class NTP_GeoNodeTree(NTP_NodeTree): def __init__(self, node_tree: bpy.types.GeometryNodeTree, var: str): super().__init__(node_tree, var) - self.zone_inputs: dict[str, list[bpy.types.Node]] = {} if bpy.app.version >= (3, 6, 0): - self.zone_inputs["GeometryNodeSimulationInput"] = [] + self._zone_inputs["GeometryNodeSimulationInput"] = [] if bpy.app.version >= (4, 0, 0): - self.zone_inputs["GeometryNodeRepeatInput"] = [] + self._zone_inputs["GeometryNodeRepeatInput"] = [] if bpy.app.version >= (4, 3, 0): - self.zone_inputs["GeometryNodeForeachGeometryElementInput"] = [] + self._zone_inputs["GeometryNodeForeachGeometryElementInput"] = [] if bpy.app.version >= (5, 0, 0): - self.zone_inputs["NodeClosureInput"] = [] + self._zone_inputs["NodeClosureInput"] = [] diff --git a/NodeToPython/license_templates.py b/NodeToPython/export/license_templates.py similarity index 100% rename from NodeToPython/license_templates.py rename to NodeToPython/export/license_templates.py diff --git a/NodeToPython/export_operator.py b/NodeToPython/export/node_group_gatherer.py similarity index 55% rename from NodeToPython/export_operator.py rename to NodeToPython/export/node_group_gatherer.py index 9922fdb..9dfdc79 100644 --- a/NodeToPython/export_operator.py +++ b/NodeToPython/export/node_group_gatherer.py @@ -26,44 +26,44 @@ def __init__(self): } def gather_node_groups(self, context: bpy.types.Context): - for group_slot in context.scene.ntp_compositor_node_group_slots: + for group_slot in getattr(context.scene, "ntp_compositor_node_group_slots"): if group_slot.node_tree is not None: self.node_groups[NodeGroupType.COMPOSITOR_NODE_GROUP].append( group_slot.node_tree ) - for scene_slot in context.scene.ntp_scene_slots: + for scene_slot in getattr(context.scene, "ntp_scene_slots"): if scene_slot.scene is not None: self.node_groups[NodeGroupType.SCENE].append(scene_slot.scene) - for group_slot in context.scene.ntp_geometry_node_group_slots: + for group_slot in getattr(context.scene, "ntp_geometry_node_group_slots"): if group_slot.node_tree is not None: self.node_groups[NodeGroupType.GEOMETRY_NODE_GROUP].append( group_slot.node_tree ) - for light_slot in context.scene.ntp_light_slots: + for light_slot in getattr(context.scene, "ntp_light_slots"): if light_slot.light is not None: self.node_groups[NodeGroupType.LIGHT].append(light_slot.light) - for line_style_slot in context.scene.ntp_line_style_slots: + for line_style_slot in getattr(context.scene, "ntp_line_style_slots"): if line_style_slot.line_style is not None: self.node_groups[NodeGroupType.LINE_STYLE].append( line_style_slot.line_style ) - for material_slot in context.scene.ntp_material_slots: + for material_slot in getattr(context.scene, "ntp_material_slots"): if material_slot.material is not None: self.node_groups[NodeGroupType.MATERIAL].append( material_slot.material ) - for group_slot in context.scene.ntp_shader_node_group_slots: + for group_slot in getattr(context.scene, "ntp_shader_node_group_slots"): if group_slot.node_tree is not None: self.node_groups[NodeGroupType.SHADER_NODE_GROUP].append( group_slot.node_tree ) - for world_slot in context.scene.ntp_world_slots: + for world_slot in getattr(context.scene, "ntp_world_slots"): if world_slot.world is not None: self.node_groups[NodeGroupType.WORLD].append(world_slot.world) @@ -84,41 +84,4 @@ def get_single_node_group(self): raise AssertionError("Expected this to be unreachable") -class NTP_OT_Export(bpy.types.Operator): - bl_idname = "ntp.export" - bl_label = "Export" - bl_description = "Export node group(s) to Python" - bl_options = {'REGISTER', 'UNDO'} - - def execute(self, context: bpy.types.Context): - gatherer = NodeGroupGatherer() - gatherer.gather_node_groups(context) - - if gatherer.get_number_node_groups() != 1: - self.report({'ERROR'}, "Can only export one node group currently") - return {'CANCELLED'} - - group_type, obj = gatherer.get_single_node_group() - - if group_type == NodeGroupType.COMPOSITOR_NODE_GROUP: - bpy.ops.ntp.compositor(compositor_name=obj.name, is_scene=False) - elif group_type == NodeGroupType.SCENE: - bpy.ops.ntp.compositor(compositor_name=obj.name, is_scene=True) - elif group_type == NodeGroupType.GEOMETRY_NODE_GROUP: - bpy.ops.ntp.geometry_nodes(geo_nodes_group_name=obj.name) - elif group_type == NodeGroupType.LIGHT: - bpy.ops.ntp.shader(name=obj.name, group_type='LIGHT') - elif group_type == NodeGroupType.LINE_STYLE: - bpy.ops.ntp.shader(name=obj.name, group_type='LINE_STYLE') - elif group_type == NodeGroupType.MATERIAL: - bpy.ops.ntp.shader(name=obj.name, group_type='MATERIAL') - elif group_type == NodeGroupType.SHADER_NODE_GROUP: - bpy.ops.ntp.shader(name=obj.name, group_type='NODE_GROUP') - elif group_type == NodeGroupType.WORLD: - bpy.ops.ntp.shader(name=obj.name, group_type='WORLD') - - return {'FINISHED'} - -classes = [ - NTP_OT_Export -] \ No newline at end of file +classes = [] \ No newline at end of file diff --git a/NodeToPython/node_settings.py b/NodeToPython/export/node_settings.py similarity index 100% rename from NodeToPython/node_settings.py rename to NodeToPython/export/node_settings.py diff --git a/NodeToPython/ntp_operator.py b/NodeToPython/export/node_tree_exporter.py similarity index 63% rename from NodeToPython/ntp_operator.py rename to NodeToPython/export/node_tree_exporter.py index 12db9eb..c60a537 100644 --- a/NodeToPython/ntp_operator.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -1,49 +1,51 @@ -import bpy -from bpy.types import Context, Operator -from bpy.types import Node, NodeTree - -if bpy.app.version < (4, 0, 0): - from bpy.types import NodeSocketInterface -else: - from bpy.types import NodeTreeInterfacePanel, NodeTreeInterfaceSocket - from bpy.types import NodeTreeInterfaceItem +import abc +import pathlib +import os +from typing import Callable -from bpy.types import bpy_prop_array +import bpy -import datetime -import os -import pathlib -import shutil -from typing import TextIO, Callable +from .node_group_gatherer import NodeGroupType -from .license_templates import license_templates -from .ntp_node_tree import NTP_NodeTree -from .ntp_options import NTP_PG_Options -from .node_settings import NodeInfo, ST +from .node_settings import node_settings, ST +from .ntp_node_tree import * +from .ntp_operator import NTP_OT_Export from .utils import * -INDEX = "i" +BASE_DIR = "base_dir" +DATA_DST = "data_dst" +DATA_SRC = "data_src" +DATAFILES_PATH = "datafiles_path" IMAGE_DIR_NAME = "imgs" IMAGE_PATH = "image_path" +INDEX = "i" ITEM = "item" -BASE_DIR = "base_dir" -DATAFILES_PATH = "datafiles_path" LIB_RELPATH = "lib_relpath" LIB_PATH = "lib_path" -DATA_SRC = "data_src" -DATA_DST = "data_dst" +NODE = "node" RESERVED_NAMES = { - INDEX, + BASE_DIR, + DATA_DST, + DATA_SRC, + DATAFILES_PATH, IMAGE_DIR_NAME, IMAGE_PATH, + INDEX, ITEM, - BASE_DIR, - DATAFILES_PATH, LIB_RELPATH, - LIB_PATH, - DATA_SRC, - DATA_DST + LIB_PATH +} + +NO_DEFAULT_SOCKETS = { + bpy.types.NodeTreeInterfaceSocketCollection, + bpy.types.NodeTreeInterfaceSocketGeometry, + bpy.types.NodeTreeInterfaceSocketImage, + bpy.types.NodeTreeInterfaceSocketMaterial, + bpy.types.NodeTreeInterfaceSocketObject, + bpy.types.NodeTreeInterfaceSocketShader, + bpy.types.NodeTreeInterfaceSocketTexture, + bpy.types.NodeTreeInterfaceSocketClosure } #node input sockets that are messy to set default values for @@ -56,208 +58,105 @@ 'NodeSocketClosure' } -MAX_BLENDER_VERSION = (5, 1, 0) - -class NTP_Operator(Operator): - """ - "Abstract" base class for all NTP operators. Blender types and abstraction - don't seem to mix well, but this should only be inherited from - """ - - bl_idname = "" - bl_label = "" - - # node tree input sockets that have default properties - if bpy.app.version < (4, 0, 0): - default_sockets_v3 = {'VALUE', 'INT', 'BOOLEAN', 'VECTOR', 'RGBA'} - else: - nondefault_sockets_v4 = { - bpy.types.NodeTreeInterfaceSocketCollection, - bpy.types.NodeTreeInterfaceSocketGeometry, - bpy.types.NodeTreeInterfaceSocketImage, - bpy.types.NodeTreeInterfaceSocketMaterial, - bpy.types.NodeTreeInterfaceSocketObject, - bpy.types.NodeTreeInterfaceSocketShader, - bpy.types.NodeTreeInterfaceSocketTexture, - bpy.types.NodeTreeInterfaceSocketClosure - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Write functions after nodes are mostly initialized and linked up - self._write_after_links: list[Callable] = [] - - # File (TextIO) or string (StringIO) the add-on/script is generated into - self._file: TextIO = None - - # Path to the directory of the zip file - self._zip_dir: str = None - - # Path to the directory for the generated addon - self._addon_dir: str = None +class NodeTreeExporter(metaclass=abc.ABCMeta): + _type = "" - # Class named for the generated operator - self._class_name: str = None - - # Indentation to use for the default write function - self._outer_indent_level: int = 0 - self._inner_indent_level: int = 1 - - # Base node tree we're converting - self._base_node_tree: NodeTree = None - - # Dictionary to keep track of node tree->variable name pairs - self._node_tree_vars: dict[NodeTree, str] = {} + def __init__( + self, + ntp_op: NTP_OT_Export, + obj_name: str, + group_type: NodeGroupType + ): + # Operator executing the conversion + self._operator : NTP_OT_Export = ntp_op - # Dictionary to keep track of node->variable name pairs - self._node_vars: dict[Node, str] = {} + # Name of the object to be exported + self._obj_name : str = obj_name # Dictionary to keep track of variables->usage count pairs self._used_vars: dict[str, int] = {} - - # Dictionary used for setting node properties - self._node_infos: dict[str, NodeInfo] = {} - for name in RESERVED_NAMES: self._used_vars[name] = 0 - # Generate socket default, min, and max values - self._include_group_socket_values = True + # Variable name to be used for object + self._obj_var : str = self._create_var(self._obj_name) + + self._group_type = group_type + + # Class name for the operator, if it exists + self._class_name : str = ( + f"{self._operator.name}_OT_" + f"{clean_string(self._obj_name, lower=False)}" + ) - # Set dimensions of generated nodes - self._should_set_dimensions = True + # Node tree this exporter is responsible for exporting + self._base_node_tree : bpy.types.NodeTree = None - # Indentation string (default four spaces) - self._indentation = " " + # Dictionary to keep track of node->variable name pairs + self._node_vars: dict[bpy.types.Node, str] = {} - self._link_external_node_groups = True + # Dictionary to keep track of node tree->variable name pairs + self._node_tree_vars: dict[bpy.types.NodeTree, str] = {} + # Library trees to link self._lib_trees: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} - if bpy.app.version >= (3, 4, 0): - # Set default values for hidden sockets - self._set_unavailable_defaults = False - - def _write(self, string: str, indent_level: int = None): - if indent_level is None: - indent_level = self._inner_indent_level - indent_str = indent_level * self._indentation - self._file.write(f"{indent_str}{string}\n") - - def _setup_options(self, options: NTP_PG_Options) -> bool: - if bpy.app.version >= MAX_BLENDER_VERSION: - self.report({'WARNING'}, - f"Blender version {bpy.app.version} is not supported yet!\n" - "NodeToPython is currently supported up to Blender 4.5.\n" - "Some nodes, settings, and features may not work yet. See:") - self.report({'WARNING'}, - "\t\thttps://github.com/BrendanParmer/NodeToPython/blob/main/docs/README.md#supported-versions ") - self.report({'WARNING'}, "for more details") - - # General - self._mode = options.mode - self._include_group_socket_values = options.set_group_defaults - self._should_set_dimensions = options.set_node_sizes - - if options.indentation_type == 'SPACES_2': - self._indentation = " " - elif options.indentation_type == 'SPACES_4': - self._indentation = " " - elif options.indentation_type == 'SPACES_8': - self._indentation = " " - elif options.indentation_type == 'TABS': - self._indentation = "\t" - - self._link_external_node_groups = options.link_external_node_groups - - if bpy.app.version >= (3, 4, 0): - self._set_unavailable_defaults = options.set_unavailable_defaults - - #Script - if options.mode == 'SCRIPT': - self._include_imports = options.include_imports - #Addon - elif options.mode == 'ADDON': - self._dir_path = bpy.path.abspath(options.dir_path) - self._name_override = options.name_override - self._description = options.description - self._author_name = options.author_name - self._version = options.version - self._location = options.location - self._license = options.license - self._should_create_license = options.should_create_license - self._category = options.category - self._custom_category = options.custom_category - if options.menu_id in dir(bpy.types): - self._menu_id = options.menu_id - else: - self.report({'ERROR'}, f"{options.menu_id} is not a valid menu") - return False - return True + # Write functions after nodes are mostly initialized and linked up + self._write_after_links: list[Callable] = [] + + # Copy of node settings (may have to modify for some nodes) + self._node_settings = node_settings - def _setup_addon_directories(self, context: Context, obj_var: str) -> bool: - """ - Finds/creates directories to save add-on to + def export(self) -> None: + if self._operator._mode == 'ADDON': + self._init_operator(self._obj_var, self._obj_name) + self._write("def execute(self, context: bpy.types.Context):", 1) + + self._set_base_node_tree() + + self._create_obj() - Parameters: - context (Context): the current scene context - obj_var (str): variable name of the object + tree_to_process : list[bpy.types.NodeTree] = self._topological_sort( + self._base_node_tree + ) - Returns: - (bool): success of addon directory setup - """ - if not self._dir_path or self._dir_path == "": - self.report({'ERROR'}, - ("NodeToPython: No save location found. Please select " - "one in the NodeToPython Options panel")) - return False + self._import_essential_libs() - self._zip_dir = os.path.join(self._dir_path, obj_var) - self._addon_dir = os.path.join(self._zip_dir, obj_var) + for node_tree in tree_to_process: + self._process_node_tree(node_tree) - if not os.path.exists(self._addon_dir): - os.makedirs(self._addon_dir) - - return True + if self._operator._mode == 'ADDON': + self._write("return {'FINISHED'}\n", self._operator._outer_indent_level) - def _create_bl_info(self, name: str) -> None: - """ - Sets up the bl_info and imports the Blender API + def _write(self, string: str, indent_level: int = -1): + self._operator._write(string, indent_level) - Parameters: - name (str): name of the add-on + def _create_var(self, name: str) -> str: """ + Creates a unique variable name for a node tree - self._write("bl_info = {", 0) - self._name = name - if self._name_override and self._name_override != "": - self._name = self._name_override - self._write(f"\"name\" : {str_to_py_str(self._name)},", 1) - if self._description and self._description != "": - self._write(f"\"description\" : {str_to_py_str(self._description)},", 1) - self._write(f"\"author\" : {str_to_py_str(self._author_name)},", 1) - self._write(f"\"version\" : {vec3_to_py_str(self._version)},", 1) - self._write(f"\"blender\" : {bpy.app.version},", 1) - self._write(f"\"location\" : {str_to_py_str(self._location)},", 1) - category = self._category - if category == "Custom": - category = self._custom_category - self._write(f"\"category\" : {str_to_py_str(category)},", 1) - self._write("}\n", 0) - - def _create_imports(self) -> None: - self._write("import bpy", 0) - self._write("import mathutils", 0) - self._write("import os", 0) - self._write("\n", 0) + Parameters: + name (str): basic string we'd like to create the variable name out of + Returns: + clean_name (str): variable name for the node tree + """ + if name == "": + name = "unnamed" + clean_name = clean_string(name) + var = clean_name + if var in self._used_vars: + self._used_vars[var] += 1 + return f"{clean_name}_{self._used_vars[var]}" + else: + self._used_vars[var] = 0 + return clean_name + def _init_operator(self, idname: str, label: str) -> None: """ Initializes the add-on's operator Parameters: - name (str): name for the class idname (str): name for the operator label (str): appearence inside Blender """ @@ -266,11 +165,31 @@ def _init_operator(self, idname: str, label: str) -> None: self._write("def __init__(self, *args, **kwargs):", 1) self._write("super().__init__(*args, **kwargs)\n", 2) - self._write(f"bl_idname = \"node.{idname}\"", 1) + idname_str = f"{clean_string(self._operator.name)}.{idname}" + self._write(f"bl_idname = {str_to_py_str(idname_str)}", 1) self._write(f"bl_label = {str_to_py_str(label)}", 1) self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) - def _topological_sort(self, node_tree: NodeTree) -> list[NodeTree]: + @abc.abstractmethod + def _set_base_node_tree(self) -> None: + pass + + @abc.abstractmethod + def _create_obj(self): + pass + + def _get_obj_creation_indent(self) -> int: + indent_level = -1 + if self._operator._mode == 'ADDON': + indent_level = 2 + elif self._operator._mode == 'SCRIPT': + indent_level = 0 + return indent_level + + def _topological_sort( + self, + node_tree: bpy.types.NodeTree + ) -> list[bpy.types.NodeTree]: """ Perform a topological sort on the node graph to determine dependencies and which node groups need processed first @@ -281,6 +200,7 @@ def _topological_sort(self, node_tree: NodeTree) -> list[NodeTree]: Returns: (list[NodeTree]): the node trees in order of processing """ + group_node_type = '' if isinstance(node_tree, bpy.types.CompositorNodeTree): group_node_type = 'CompositorNodeGroup' elif isinstance(node_tree, bpy.types.GeometryNodeTree): @@ -289,9 +209,9 @@ def _topological_sort(self, node_tree: NodeTree) -> list[NodeTree]: group_node_type = 'ShaderNodeGroup' visited = set() - result: list[NodeTree] = [] + result: list[bpy.types.NodeTree] = [] - def dfs(nt: NodeTree) -> None: + def dfs(nt: bpy.types.NodeTree) -> None: """ Helper function to perform depth-first search on a NodeTree @@ -299,11 +219,15 @@ def dfs(nt: NodeTree) -> None: nt (NodeTree): current node tree in the dependency graph """ if nt is None: - self.report({'ERROR'}, "NodeToPython: Found an invalid node tree. " - "Are all data blocks valid?") + self._operator.report( + {'ERROR'}, + "NodeToPython: Found an invalid node tree. " + "Are all data blocks valid?" + ) return - if self._link_external_node_groups and nt.library is not None: + if (self._operator._link_external_node_groups + and nt.library is not None): bpy_lib_path = bpy.path.abspath(nt.library.filepath) lib_path = pathlib.Path(os.path.realpath(bpy_lib_path)) bpy_datafiles_path = bpy.path.abspath( @@ -323,16 +247,19 @@ def dfs(nt: NodeTree) -> None: if nt not in visited: visited.add(nt) - for group_node in [node for node in nt.nodes - if node.bl_idname == group_node_type]: - if group_node.node_tree not in visited: - if group_node.node_tree is None: - self.report( - {'ERROR'}, - "NodeToPython: Found an invalid node tree. " - "Are all data blocks valid?" - ) - dfs(group_node.node_tree) + group_nodes = [node for node in nt.nodes + if node.bl_idname == group_node_type] + for group_node in group_nodes: + node_nt = getattr(group_node, "node_tree") + if node_nt is None: + self._operator.report( + {'ERROR'}, + "NodeToPython: Found an invalid node tree. " + "Are all data blocks valid?" + ) + continue + if node_nt not in visited: + dfs(node_nt) result.append(nt) dfs(node_tree) @@ -340,7 +267,9 @@ def dfs(nt: NodeTree) -> None: return result def _import_essential_libs(self) -> None: - self._inner_indent_level -= 1 + if len(self._lib_trees) == 0: + return + self._operator._inner_indent_level -= 1 self._write("# Import node groups from Blender essentials library") self._write(f"{DATAFILES_PATH} = bpy.utils.system_resource('DATAFILES')") for path, node_trees in self._lib_trees.items(): @@ -360,32 +289,415 @@ def _import_essential_libs(self) -> None: self._node_tree_vars[node_tree] = nt_var self._write(f"{nt_var} = {DATA_DST}.node_groups[{i}]") self._write("\n") - self._inner_indent_level += 1 - + self._operator._inner_indent_level += 1 + + def _initialize_ntp_node_tree( + self, + node_tree: bpy.types.NodeTree, + nt_var: str + ) -> NTP_NodeTree: + return NTP_NodeTree(node_tree, nt_var) + + def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: + """ + Generates a Python function to recreate a compositor node tree - def _create_var(self, name: str) -> str: + Parameters: + node_tree (NodeTree): node tree to be recreated + """ + nt_var = self._create_var(node_tree.name) + self._node_tree_vars[node_tree] = nt_var + + ntp_nt = self._initialize_ntp_node_tree(node_tree, nt_var) + + self._initialize_node_tree(ntp_nt) + + self._set_node_tree_properties(node_tree) + + if bpy.app.version >= (4, 0, 0): + self._tree_interface_settings(ntp_nt) + + #initialize nodes + self._write(f"# Initialize {nt_var} nodes\n") + + for node in node_tree.nodes: + self._process_node(node, ntp_nt) + + for zone_list in ntp_nt._zone_inputs.values(): + self._process_zones(zone_list) + + #set look of nodes + self._set_parents(node_tree) + self._set_locations(node_tree) + self._set_dimensions(node_tree) + + #create connections + self._init_links(node_tree) + + self._write(f"return {nt_var}\n") + if self._operator._mode == 'SCRIPT': + self._write("", 0) + + #create node group + self._write(f"{nt_var} = {nt_var}_node_group()\n", + self._operator._outer_indent_level) + + @abc.abstractmethod + def _initialize_node_tree( + self, + ntp_node_tree: NTP_NodeTree + ) -> None: + pass + + def _set_node_tree_properties(self, node_tree: bpy.types.NodeTree) -> None: + nt_var = self._node_tree_vars[node_tree] + + if bpy.app.version >= (4, 2, 0): + color_tag_str = enum_to_py_str(node_tree.color_tag) + self._write(f"{nt_var}.color_tag = {color_tag_str}") + desc_str = str_to_py_str(node_tree.description) + self._write(f"{nt_var}.description = {desc_str}") + if bpy.app.version >= (4, 3, 0): + default_width = node_tree.default_group_node_width + self._write(f"{nt_var}.default_group_node_width = {default_width}") + + def _tree_interface_settings(self, ntp_nt: NTP_NodeTree) -> None: """ - Creates a unique variable name for a node tree + Set the settings for group input and output sockets Parameters: - name (str): basic string we'd like to create the variable name out of + ntp_nt (NTP_NodeTree): the node tree to set the interface for + """ + if len(ntp_nt._node_tree.interface.items_tree) == 0: + return + + self._write(f"# {ntp_nt._var} interface\n") + panel_dict: dict[bpy.types.NodeTreeInterfacePanel, str] = {} + items_processed: set[bpy.types.NodeTreeInterfaceItem] = set() + + self._process_items(None, panel_dict, items_processed, ntp_nt) + + def _process_items( + self, + parent: bpy.types.NodeTreeInterfacePanel | None, + panel_dict: dict[bpy.types.NodeTreeInterfacePanel, str], + items_processed: set[bpy.types.NodeTreeInterfaceItem], + ntp_nt: NTP_NodeTree + ) -> None: + """ + Recursive function to process all node tree interface items in a + given layer - Returns: - clean_name (str): variable name for the node tree + Helper function to _tree_interface_settings() + + Parameters: + parent (NodeTreeInterfacePanel): parent panel of the layer + (possibly None to signify the base) + panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable + items_processed (set[NodeTreeInterfacePanel]): set of already + processed items, so none are done twice + ntp_nt (NTP_NodeTree): owner of the socket """ - if name == "": - name = "unnamed" - clean_name = clean_string(name) - var = clean_name - if var in self._used_vars: - self._used_vars[var] += 1 - return f"{clean_name}_{self._used_vars[var]}" + + if parent is None: + items = ntp_nt._node_tree.interface.items_tree else: - self._used_vars[var] = 0 - return clean_name + items = parent.interface_items - def _create_node(self, node: Node, node_tree_var: str) -> str: + for item in items: + if item.parent.index != -1 and item.parent not in panel_dict: + continue # child of panel not processed yet + if item in items_processed: + continue + + items_processed.add(item) + + if item.item_type in {'SOCKET', 'PANEL_TOGGLE'}: + self._create_socket(item, parent, panel_dict, ntp_nt) + + elif item.item_type == 'PANEL': + self._create_panel( + item, + panel_dict, + items_processed, + parent, + ntp_nt + ) + if bpy.app.version >= (4, 4, 0) and parent is not None: + nt_var = self._node_tree_vars[ntp_nt._node_tree] + interface_var = f"{nt_var}.interface" + panel_var = panel_dict[item] + parent_var = panel_dict[parent] + self._write(f"{interface_var}.move_to_parent(" + f"{panel_var}, {parent_var}, {item.index})") + + def _create_socket( + self, + socket: bpy.types.NodeTreeInterfaceSocket, + parent: bpy.types.NodeTreeInterfacePanel, + panel_dict: dict[bpy.types.NodeTreeInterfacePanel, str], + ntp_nt: NTP_NodeTree + ) -> None: + """ + Initialize a new tree socket + + Helper function to _process_items() + + Parameters: + socket (NodeTreeInterfaceSocket): the socket to recreate + parent (NodeTreeInterfacePanel): parent panel of the socket + (possibly None) + panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable + ntp_nt (NTP_NodeTree): owner of the socket + """ + + self._write(f"# Socket {socket.name}") + # initialization + socket_var = self._create_var(socket.name + "_socket") + name = str_to_py_str(socket.name) + in_out_enum = enum_to_py_str(socket.in_out) + + socket_type = enum_to_py_str(socket.bl_socket_idname) + """ + I might be missing something, but the Python API's set up a bit + weird here now. The new socket initialization only accepts types + from a list of basic ones, but there doesn't seem to be a way of + retrieving just this basic type without the subtype information. + """ + if 'Float' in socket_type: + socket_type = enum_to_py_str('NodeSocketFloat') + elif 'Int' in socket_type: + socket_type = enum_to_py_str('NodeSocketInt') + elif 'Vector' in socket_type: + socket_type = enum_to_py_str('NodeSocketVector') + + if parent is None: + optional_parent_str = "" + else: + optional_parent_str = f", parent = {panel_dict[parent]}" + + self._write(f"{socket_var} = " + f"{ntp_nt._var}.interface.new_socket(" + f"name={name}, in_out={in_out_enum}, " + f"socket_type={socket_type}" + f"{optional_parent_str})") + + # vector dimensions + if hasattr(socket, "dimensions"): + dimensions : int = getattr(socket, "dimensions") + if dimensions != 3: + self._write(f"{socket_var}.dimensions = {dimensions}") + self._write("# Get the socket again, as its default value may " + "have been updated") + self._write(f"{socket_var} = {ntp_nt._var}.interface.items_tree[{socket_var}.index]") + + self._set_tree_socket_defaults(socket, socket_var) + + # subtype + if hasattr(socket, "subtype"): + subtype : str = getattr(socket, "subtype") + if subtype != '': + subtype = enum_to_py_str(subtype) + self._write(f"{socket_var}.subtype = {subtype}") + + # default attribute name + if socket.default_attribute_name != "": + dan = str_to_py_str( + socket.default_attribute_name) + self._write(f"{socket_var}.default_attribute_name = {dan}") + + # attribute domain + ad = enum_to_py_str(socket.attribute_domain) + self._write(f"{socket_var}.attribute_domain = {ad}") + + # hide_value + if socket.hide_value is True: + self._write(f"{socket_var}.hide_value = True") + + # hide in modifier + if socket.hide_in_modifier is True: + self._write(f"{socket_var}.hide_in_modifier = True") + + # force non field + if socket.force_non_field is True: + self._write(f"{socket_var}.force_non_field = True") + + # tooltip + if socket.description != "": + description = str_to_py_str(socket.description) + self._write(f"{socket_var}.description = {description}") + + # layer selection field + if socket.layer_selection_field: + self._write(f"{socket_var}.layer_selection_field = True") + + if bpy.app.version >= (4, 2, 0): + # is inspect output + if socket.is_inspect_output: + self._write(f"{socket_var}.is_inspect_output = True") + + if bpy.app.version >= (4, 5, 0): + # default input + default_input = enum_to_py_str(socket.default_input) + self._write(f"{socket_var}.default_input = {default_input}") + + # is panel toggle + if socket.is_panel_toggle: + self._write(f"{socket_var}.is_panel_toggle = True") + + # menu expanded + if socket.menu_expanded: + self._write(f"{socket_var}.menu_expanded = True") + + # structure type + structure_type = enum_to_py_str(socket.structure_type) + self._write(f"{socket_var}.structure_type = {structure_type}") + + if bpy.app.version >= (5, 0, 0): + # optional label + if socket.optional_label: + self._write(f"{socket_var}.optional_label = True") + + self._write("", 0) + + def _create_panel( + self, + panel: bpy.types.NodeTreeInterfacePanel, + panel_dict: dict[bpy.types.NodeTreeInterfacePanel, str], + items_processed: set[bpy.types.NodeTreeInterfacePanel], + parent: bpy.types.NodeTreeInterfacePanel, + ntp_nt: NTP_NodeTree): + """ + Initialize a new tree panel and its subitems + + Helper function to _process_items() + + Parameters: + panel (NodeTreeInterfacePanel): the panel to recreate + panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable + items_processed (set[NodeTreeInterfacePanel]): set of already + processed items, so none are done twice + parent (NodeTreeInterfacePanel): parent panel of the socket + (possibly None) + ntp_nt (NTP_NodeTree): owner of the socket + """ + + self._write(f"# Panel {panel.name}") + + panel_var = self._create_var(panel.name + "_panel") + panel_dict[panel] = panel_var + + closed_str = "" + if panel.default_closed is True: + closed_str = f", default_closed=True" + + parent_str = "" + if parent is not None and bpy.app.version < (4, 2, 0): + parent_str = f", parent = {panel_dict[parent]}" + + self._write(f"{panel_var} = " + f"{ntp_nt._var}.interface.new_panel(" + f"{str_to_py_str(panel.name)}" + f"{closed_str}{parent_str})") + + # tooltip + if panel.description != "": + description = str_to_py_str(panel.description) + self._write(f"{panel_var}.description = {description}") + + panel_dict[panel] = panel_var + + if len(panel.interface_items) > 0: + self._process_items(panel, panel_dict, items_processed, ntp_nt) + + self._write("", 0) + + def _set_tree_socket_defaults( + self, + socket_interface: bpy.types.NodeTreeInterfaceSocket, + socket_var: str + ) -> None: + """ + Set a node tree input/output's default properties if they exist + + Helper function to _create_socket() + + Parameters: + socket_interface (NodeTreeInterfaceSocket): socket interface associated + with the input/output + socket_var (str): variable name for the socket + """ + if not self._operator._include_group_socket_values: + return + + if type(socket_interface) in NO_DEFAULT_SOCKETS: + return + + dv = getattr(socket_interface, "default_value") + + if bpy.app.version >= (4, 1, 0): + if type(socket_interface) is bpy.types.NodeTreeInterfaceSocketMenu: + if dv == "": + self._operator.report({'WARNING'}, + "NodeToPython: No menu found for socket " + f"{socket_interface.name}" + ) + return + + self._write_after_links.append( + lambda _socket_var=socket_var, _dv=enum_to_py_str(dv): ( + self._write(f"{_socket_var}.default_value = {_dv}") + ) + ) + return + + if type(socket_interface) == bpy.types.NodeTreeInterfaceSocketColor: + dv = vec4_to_py_str(dv) + elif type(dv) == mathutils.Euler: + dv = vec3_to_py_str(dv) + elif type(dv) == bpy_prop_array: + dv = array_to_py_str(dv) + elif type(dv) == str: + dv = str_to_py_str(dv) + elif type(dv) == mathutils.Vector: + if len(dv) == 2: + dv = vec2_to_py_str(dv) + elif len(dv) == 3: + dv = vec3_to_py_str(dv) + elif len(dv) == 4: + dv = vec4_to_py_str(dv) + self._write(f"{socket_var}.default_value = {dv}") + + # min value + if hasattr(socket_interface, "min_value"): + min_val = getattr(socket_interface, "min_value") + self._write(f"{socket_var}.min_value = {min_val}") + # max value + if hasattr(socket_interface, "min_value"): + max_val = getattr(socket_interface, "max_value") + self._write(f"{socket_var}.max_value = {max_val}") + + def _process_node(self, node: bpy.types.Node, ntp_nt: NTP_NodeTree) -> None: + """ + Create node and set settings, defaults, and cosmetics + + Parameters: + node (Node): node to process + ntp_nt (NTP_NodeTree): the node tree that node belongs to + """ + node_var: str = self._create_node(node, ntp_nt._var) + self._set_settings_defaults(node) + + if node.bl_idname in ntp_nt._zone_inputs: + ntp_nt._zone_inputs[node.bl_idname].append(node) + + self._hide_hidden_sockets(node) + + if node.bl_idname not in ntp_nt._zone_inputs: + self._set_socket_defaults(node) + + def _create_node(self, node: bpy.types.Node, node_tree_var: str) -> str: """ Initializes a new node with location, dimension, and label info @@ -430,8 +742,8 @@ def _create_node(self, node: Node, node_tree_var: str) -> str: self._write(f"{node_var}.warning_propagation = " f"{enum_to_py_str(node.warning_propagation)}") return node_var - - def _set_settings_defaults(self, node: Node) -> None: + + def _set_settings_defaults(self, node: bpy.types.Node) -> None: """ Sets the defaults for any settings a node may have @@ -439,15 +751,15 @@ def _set_settings_defaults(self, node: Node) -> None: node (Node): the node object we're copying settings from node_var (str): name of the variable we're using for the node in our add-on """ - if node.bl_idname not in self._node_infos: - self.report({'WARNING'}, + if node.bl_idname not in self._node_settings: + self._operator.report({'WARNING'}, (f"NodeToPython: couldn't find {node.bl_idname} in " f"settings. Your Blender version may not be supported")) return node_var = self._node_vars[node] - node_info = self._node_infos[node.bl_idname] + node_info = self._node_settings[node.bl_idname] for attr_info in node_info.attributes_: attr_name = attr_info.name_ st = attr_info.st_ @@ -460,7 +772,7 @@ def _set_settings_defaults(self, node: Node) -> None: continue if not hasattr(node, attr_name): - self.report({'WARNING'}, + self._operator.report({'WARNING'}, f"NodeToPython: Couldn't find attribute " f"\"{attr_name}\" for node {node.name} of type " f"{node.bl_idname}") @@ -494,6 +806,8 @@ def _set_settings_defaults(self, node: Node) -> None: self._write(f"{setting_str} = {vec4_to_py_str(attr)}") elif st == ST.COLOR: self._write(f"{setting_str} = {color_to_py_str(attr)}") + elif st == ST.EULER: + self._write(f"{setting_str} = {vec3_to_py_str(attr)}") elif st == ST.MATERIAL: self._set_if_in_blend_file(attr, setting_str, "materials") elif st == ST.OBJECT: @@ -509,7 +823,7 @@ def _set_settings_defaults(self, node: Node) -> None: elif st == ST.IMAGE: if attr is None: continue - if self._addon_dir is not None: + if self._operator._addon_dir != "": if attr.source in {'FILE', 'GENERATED', 'TILED'}: if self._save_image(attr): self._load_image(attr, setting_str) @@ -558,546 +872,10 @@ def _set_settings_defaults(self, node: Node) -> None: self._field_to_grid_items(attr, setting_str) elif st == ST.GEOMETRY_VIEWER_ITEMS: self._geometry_viewer_items(attr, setting_str) - elif st == ST.COMBINE_BUNDLE_ITEMS: - self._combine_bundle_items(attr, setting_str) - elif st == ST.SEPARATE_BUNDLE_ITEMS: - self._separate_bundle_items(attr, setting_str) - - if bpy.app.version < (4, 0, 0): - def _set_group_socket_defaults(self, socket_interface: NodeSocketInterface, - socket_var: str) -> None: - """ - Set a node group input/output's default properties if they exist - Helper function to _group_io_settings() - - Parameters: - socket_interface (NodeSocketInterface): socket interface associated - with the input/output - socket_var (str): variable name for the socket - """ - if not self._include_group_socket_values: - return - - if socket_interface.type not in self.default_sockets_v3: - return - - if not hasattr(socket_interface, "default_value"): - self.report({'WARNING'}, - f"Socket {socket_interface.type} had no default value") - return - - if socket_interface.type == 'RGBA': - dv = vec4_to_py_str(socket_interface.default_value) - elif socket_interface.type == 'VECTOR': - dv = vec3_to_py_str(socket_interface.default_value) - else: - dv = socket_interface.default_value - self._write(f"{socket_var}.default_value = {dv}") - - # min value - if hasattr(socket_interface, "min_value"): - min_val = socket_interface.min_value - self._write(f"{socket_var}.min_value = {min_val}") - # max value - if hasattr(socket_interface, "min_value"): - max_val = socket_interface.max_value - self._write(f"{socket_var}.max_value = {max_val}") - - def _group_io_settings(self, node: Node, - io: str, # TODO: convert to enum - ntp_node_tree: NTP_NodeTree) -> None: - """ - Set the settings for group input and output sockets - - Parameters: - node (Node) : group input/output node - io (str): whether we're generating the input or output settings - ntp_node_tree (NTP_NodeTree): node tree that we're generating - input and output settings for - """ - node_tree_var = ntp_node_tree.var - node_tree = ntp_node_tree.node_tree - - if io == "input": - io_sockets = node.outputs - io_socket_interfaces = node_tree.inputs - else: - io_sockets = node.inputs - io_socket_interfaces = node_tree.outputs - - self._write(f"# {node_tree_var} {io}s") - for i, inout in enumerate(io_sockets): - if inout.bl_idname == 'NodeSocketVirtual': - continue - self._write(f"# {io.capitalize()} {inout.name}") - idname = enum_to_py_str(inout.bl_idname) - name = str_to_py_str(inout.name) - self._write(f"{node_tree_var}.{io}s.new({idname}, {name})") - socket_interface = io_socket_interfaces[i] - socket_var = f"{node_tree_var}.{io}s[{i}]" - - self._set_group_socket_defaults(socket_interface, socket_var) - - # default attribute name - if hasattr(socket_interface, "default_attribute_name"): - if socket_interface.default_attribute_name != "": - dan = str_to_py_str(socket_interface.default_attribute_name) - self._write(f"{socket_var}.default_attribute_name = {dan}") - - # attribute domain - if hasattr(socket_interface, "attribute_domain"): - ad = enum_to_py_str(socket_interface.attribute_domain) - self._write(f"{socket_var}.attribute_domain = {ad}") - - # tooltip - if socket_interface.description != "": - description = str_to_py_str(socket_interface.description) - self._write(f"{socket_var}.description = {description}") - - # hide_value - if socket_interface.hide_value is True: - self._write(f"{socket_var}.hide_value = True") - - # hide in modifier - if hasattr(socket_interface, "hide_in_modifier"): - if socket_interface.hide_in_modifier is True: - self._write(f"{socket_var}.hide_in_modifier = True") - - self._write("", 0) - self._write("", 0) - - elif bpy.app.version >= (4, 0, 0): - def _set_tree_socket_defaults(self, socket_interface: NodeTreeInterfaceSocket, - socket_var: str) -> None: - """ - Set a node tree input/output's default properties if they exist - - Helper function to _create_socket() - - Parameters: - socket_interface (NodeTreeInterfaceSocket): socket interface associated - with the input/output - socket_var (str): variable name for the socket - """ - if not self._include_group_socket_values: - return - if type(socket_interface) in self.nondefault_sockets_v4: - return - - dv = socket_interface.default_value - - if bpy.app.version >= (4, 1, 0): - if type(socket_interface) is bpy.types.NodeTreeInterfaceSocketMenu: - if dv == "": - self.report({'WARNING'}, - "NodeToPython: No menu found for socket " - f"{socket_interface.name}" - ) - return - - self._write_after_links.append( - lambda _socket_var=socket_var, _dv=enum_to_py_str(dv): ( - self._write(f"{_socket_var}.default_value = {_dv}") - ) - ) - return - if type(socket_interface) == bpy.types.NodeTreeInterfaceSocketColor: - dv = vec4_to_py_str(dv) - elif type(dv) == mathutils.Euler: - dv = vec3_to_py_str(dv) - elif type(dv) == bpy_prop_array: - dv = array_to_py_str(dv) - elif type(dv) == str: - dv = str_to_py_str(dv) - elif type(dv) == mathutils.Vector: - if len(dv) == 2: - dv = vec2_to_py_str(dv) - elif len(dv) == 3: - dv = vec3_to_py_str(dv) - elif len(dv) == 4: - dv = vec4_to_py_str(dv) - self._write(f"{socket_var}.default_value = {dv}") - - # min value - if hasattr(socket_interface, "min_value"): - min_val = socket_interface.min_value - self._write(f"{socket_var}.min_value = {min_val}") - # max value - if hasattr(socket_interface, "min_value"): - max_val = socket_interface.max_value - self._write(f"{socket_var}.max_value = {max_val}") - - def _create_socket(self, socket: NodeTreeInterfaceSocket, - parent: NodeTreeInterfacePanel, - panel_dict: dict[NodeTreeInterfacePanel, str], - ntp_nt: NTP_NodeTree) -> None: - """ - Initialize a new tree socket - - Helper function to _process_items() - - Parameters: - socket (NodeTreeInterfaceSocket): the socket to recreate - parent (NodeTreeInterfacePanel): parent panel of the socket - (possibly None) - panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable - ntp_nt (NTP_NodeTree): owner of the socket - """ - - self._write(f"# Socket {socket.name}") - # initialization - socket_var = self._create_var(socket.name + "_socket") - name = str_to_py_str(socket.name) - in_out_enum = enum_to_py_str(socket.in_out) - - socket_type = enum_to_py_str(socket.bl_socket_idname) - """ - I might be missing something, but the Python API's set up a bit - weird here now. The new socket initialization only accepts types - from a list of basic ones, but there doesn't seem to be a way of - retrieving just this basic type without the subtype information. - """ - if 'Float' in socket_type: - socket_type = enum_to_py_str('NodeSocketFloat') - elif 'Int' in socket_type: - socket_type = enum_to_py_str('NodeSocketInt') - elif 'Vector' in socket_type: - socket_type = enum_to_py_str('NodeSocketVector') - - if parent is None: - optional_parent_str = "" - else: - optional_parent_str = f", parent = {panel_dict[parent]}" - - self._write(f"{socket_var} = " - f"{ntp_nt.var}.interface.new_socket(" - f"name={name}, in_out={in_out_enum}, " - f"socket_type={socket_type}" - f"{optional_parent_str})") - - # vector dimensions - if hasattr(socket, "dimensions"): - dimensions = socket.dimensions - if socket.dimensions != 3: - self._write(f"{socket_var}.dimensions = {dimensions}") - self._write("# Get the socket again, as its default value could have been updated") - self._write(f"{socket_var} = {ntp_nt.var}.interface.items_tree[{socket_var}.index]") - - self._set_tree_socket_defaults(socket, socket_var) - - # subtype - if hasattr(socket, "subtype"): - if socket.subtype != '': - subtype = enum_to_py_str(socket.subtype) - self._write(f"{socket_var}.subtype = {subtype}") - - # default attribute name - if socket.default_attribute_name != "": - dan = str_to_py_str( - socket.default_attribute_name) - self._write(f"{socket_var}.default_attribute_name = {dan}") - - # attribute domain - ad = enum_to_py_str(socket.attribute_domain) - self._write(f"{socket_var}.attribute_domain = {ad}") - - # hide_value - if socket.hide_value is True: - self._write(f"{socket_var}.hide_value = True") - - # hide in modifier - if socket.hide_in_modifier is True: - self._write(f"{socket_var}.hide_in_modifier = True") - - # force non field - if socket.force_non_field is True: - self._write(f"{socket_var}.force_non_field = True") - - # tooltip - if socket.description != "": - description = str_to_py_str(socket.description) - self._write(f"{socket_var}.description = {description}") - - # layer selection field - if socket.layer_selection_field: - self._write(f"{socket_var}.layer_selection_field = True") - - if bpy.app.version >= (4, 2, 0): - # is inspect output - if socket.is_inspect_output: - self._write(f"{socket_var}.is_inspect_output = True") - - if bpy.app.version >= (4, 5, 0): - # default input - default_input = enum_to_py_str(socket.default_input) - self._write(f"{socket_var}.default_input = {default_input}") - - # is panel toggle - if socket.is_panel_toggle: - self._write(f"{socket_var}.is_panel_toggle = True") - - # menu expanded - if socket.menu_expanded: - self._write(f"{socket_var}.menu_expanded = True") - - # structure type - structure_type = enum_to_py_str(socket.structure_type) - self._write(f"{socket_var}.structure_type = {structure_type}") - - if bpy.app.version >= (5, 0, 0): - # optional label - if socket.optional_label: - self._write(f"{socket_var}.optional_label = True") - - self._write("", 0) - - def _create_panel(self, panel: NodeTreeInterfacePanel, - panel_dict: dict[NodeTreeInterfacePanel], - items_processed: set[NodeTreeInterfacePanel], - parent: NodeTreeInterfacePanel, ntp_nt: NTP_NodeTree): - """ - Initialize a new tree panel and its subitems - - Helper function to _process_items() - - Parameters: - panel (NodeTreeInterfacePanel): the panel to recreate - panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable - items_processed (set[NodeTreeInterfacePanel]): set of already - processed items, so none are done twice - parent (NodeTreeInterfacePanel): parent panel of the socket - (possibly None) - ntp_nt (NTP_NodeTree): owner of the socket - """ - - self._write(f"# Panel {panel.name}") - - panel_var = self._create_var(panel.name + "_panel") - panel_dict[panel] = panel_var - - closed_str = "" - if panel.default_closed is True: - closed_str = f", default_closed=True" - - parent_str = "" - if parent is not None and bpy.app.version < (4, 2, 0): - parent_str = f", parent = {panel_dict[parent]}" - - self._write(f"{panel_var} = " - f"{ntp_nt.var}.interface.new_panel(" - f"{str_to_py_str(panel.name)}" - f"{closed_str}{parent_str})") - - # tooltip - if panel.description != "": - description = str_to_py_str(panel.description) - self._write(f"{panel_var}.description = {description}") - - panel_dict[panel] = panel_var - - if len(panel.interface_items) > 0: - self._process_items(panel, panel_dict, items_processed, ntp_nt) - - self._write("", 0) - - def _process_items(self, parent: NodeTreeInterfacePanel, - panel_dict: dict[NodeTreeInterfacePanel], - items_processed: set[NodeTreeInterfacePanel], - ntp_nt: NTP_NodeTree) -> None: - """ - Recursive function to process all node tree interface items in a - given layer - - Helper function to _tree_interface_settings() - - Parameters: - parent (NodeTreeInterfacePanel): parent panel of the layer - (possibly None to signify the base) - panel_dict (dict[NodeTreeInterfacePanel, str]: panel -> variable - items_processed (set[NodeTreeInterfacePanel]): set of already - processed items, so none are done twice - ntp_nt (NTP_NodeTree): owner of the socket - """ - - if parent is None: - items = ntp_nt.node_tree.interface.items_tree - else: - items = parent.interface_items - - for item in items: - if item.parent.index != -1 and item.parent not in panel_dict: - continue # child of panel not processed yet - if item in items_processed: - continue - - items_processed.add(item) - - if item.item_type in {'SOCKET', 'PANEL_TOGGLE'}: - self._create_socket(item, parent, panel_dict, ntp_nt) - - elif item.item_type == 'PANEL': - self._create_panel(item, panel_dict, items_processed, - parent, ntp_nt) - if bpy.app.version >= (4, 4, 0) and parent is not None: - nt_var = self._node_tree_vars[ntp_nt.node_tree] - interface_var = f"{nt_var}.interface" - panel_var = panel_dict[item] - parent_var = panel_dict[parent] - self._write(f"{interface_var}.move_to_parent(" - f"{panel_var}, {parent_var}, {item.index})") - - - def _tree_interface_settings(self, ntp_nt: NTP_NodeTree) -> None: - """ - Set the settings for group input and output sockets - - Parameters: - ntp_nt (NTP_NodeTree): the node tree to set the interface for - """ - - self._write(f"# {ntp_nt.var} interface\n") - panel_dict: dict[NodeTreeInterfacePanel, str] = {} - items_processed: set[NodeTreeInterfaceItem] = set() - - self._process_items(None, panel_dict, items_processed, ntp_nt) - - def _set_input_defaults(self, node: Node) -> None: - """ - Sets defaults for input sockets - - Parameters: - node (Node): node we're setting inputs for - """ - if node.bl_idname == 'NodeReroute': - return - - node_var = self._node_vars[node] - - for i, input in enumerate(node.inputs): - if input.bl_idname not in DONT_SET_DEFAULTS and not input.is_linked: - if bpy.app.version >= (3, 4, 0): - if (not self._set_unavailable_defaults) and input.is_unavailable: - continue - - # TODO: this could be cleaner - socket_var = f"{node_var}.inputs[{i}]" - - # colors - if input.bl_idname == 'NodeSocketColor': - default_val = vec4_to_py_str(input.default_value) - - # vector types - elif "Vector" in input.bl_idname: - if "2D" in input.bl_idname: - default_val = vec2_to_py_str(input.default_value) - elif "4D" in input.bl_idname: - default_val = vec4_to_py_str(input.default_value) - else: - default_val = vec3_to_py_str(input.default_value) - - # rotation types - elif input.bl_idname == 'NodeSocketRotation': - default_val = vec3_to_py_str(input.default_value) - - # strings - elif input.bl_idname in {'NodeSocketString', 'NodeSocketStringFilePath'}: - default_val = str_to_py_str(input.default_value) - - #menu - elif input.bl_idname == 'NodeSocketMenu': - if input.default_value == '': - continue - default_val = enum_to_py_str(input.default_value) - - # images - elif input.bl_idname == 'NodeSocketImage': - img = input.default_value - if img is not None: - if self._addon_dir != None: # write in a better way - if self._save_image(img): - self._load_image(img, f"{socket_var}.default_value") - else: - self._in_file_inputs(input, socket_var, "images") - default_val = None - - # materials - elif input.bl_idname == 'NodeSocketMaterial': - self._in_file_inputs(input, socket_var, "materials") - default_val = None - - # collections - elif input.bl_idname == 'NodeSocketCollection': - self._in_file_inputs(input, socket_var, "collections") - default_val = None - - # objects - elif input.bl_idname == 'NodeSocketObject': - self._in_file_inputs(input, socket_var, "objects") - default_val = None - - # textures - elif input.bl_idname == 'NodeSocketTexture': - self._in_file_inputs(input, socket_var, "textures") - default_val = None - - else: - default_val = input.default_value - if default_val is not None: - self._write(f"# {input.identifier}") - self._write(f"{socket_var}.default_value = {default_val}") - self._write("", 0) - - def _set_output_defaults(self, node: Node) -> None: - """ - Some output sockets need default values set. It's rather annoying - - Parameters: - node (Node): node for the output we're setting - """ - # TODO: probably should define elsewhere - output_default_nodes = {'ShaderNodeValue', - 'ShaderNodeRGB', - 'ShaderNodeNormal', - 'CompositorNodeValue', - 'CompositorNodeRGB', - 'CompositorNodeNormal'} - - if node.bl_idname not in output_default_nodes: - return - - node_var = self._node_vars[node] - - dv = node.outputs[0].default_value - if node.bl_idname in {'ShaderNodeRGB', 'CompositorNodeRGB'}: - dv = vec4_to_py_str(list(dv)) - if node.bl_idname in {'ShaderNodeNormal', 'CompositorNodeNormal'}: - dv = vec3_to_py_str(dv) - self._write(f"{node_var}.outputs[0].default_value = {dv}") - - def _in_file_inputs(self, input: bpy.types.NodeSocket, socket_var: str, - type: str) -> None: - """ - 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 - 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 None: - return - name = str_to_py_str(input.default_value.name) - self._write(f"if {name} in bpy.data.{type}:") - self._write(f"{socket_var}.default_value = bpy.data.{type}[{name}]", - self._inner_indent_level + 1) - - def _set_socket_defaults(self, node: Node): - """ - Set input and output socket defaults - """ - self._set_input_defaults(node) - self._set_output_defaults(node) + elif st == ST.COMBINE_BUNDLE_ITEMS: + self._combine_bundle_items(attr, setting_str) + elif st == ST.SEPARATE_BUNDLE_ITEMS: + self._separate_bundle_items(attr, setting_str) def _set_if_in_blend_file(self, attr, setting_str: str, data_type: str ) -> None: @@ -1107,9 +885,9 @@ def _set_if_in_blend_file(self, attr, setting_str: str, data_type: str name = str_to_py_str(attr.name) self._write(f"if {name} in bpy.data.{data_type}:") self._write(f"{setting_str} = bpy.data.{data_type}[{name}]", - self._inner_indent_level + 1) - - def _color_ramp_settings(self, node: Node, color_ramp_name: str) -> None: + self._operator._inner_indent_level + 1) + + def _color_ramp_settings(self, node: bpy.types.Node, color_ramp_name: str) -> None: """ Replicate a color ramp node @@ -1157,7 +935,7 @@ def _color_ramp_settings(self, node: Node, color_ramp_name: str) -> None: color_str = vec4_to_py_str(element.color) self._write(f"{element_var}.color = {color_str}\n") - def _curve_mapping_settings(self, node: Node, + def _curve_mapping_settings(self, node: bpy.types.Node, curve_mapping_name: str) -> None: """ Sets defaults for Float, Vector, and Color curves @@ -1214,7 +992,7 @@ def _curve_mapping_settings(self, node: Node, self._write(f"# Update curve after changes") self._write(f"{mapping_var}.update()") - def _create_curve_map(self, node: Node, i: int, curve: bpy.types.CurveMap, + def _create_curve_map(self, node: bpy.types.Node, i: int, curve: bpy.types.CurveMap, curve_mapping_name: str) -> None: """ Helper function to create the ith curve of a node's curve mapping @@ -1239,7 +1017,7 @@ def _create_curve_map(self, node: Node, i: int, curve: bpy.types.CurveMap, f"(len({curve_i_var}.points.values()) - 1, 1, -1):") self._write(f"{curve_i_var}.points.remove(" f"{curve_i_var}.points[{INDEX}])", - self._inner_indent_level + 1) + self._operator._inner_indent_level + 1) for j, point in enumerate(curve.points): self._create_curve_map_point(j, point, curve_i_var) @@ -1267,7 +1045,7 @@ def _create_curve_map_point(self, j: int, point: bpy.types.CurveMapPoint, handle = enum_to_py_str(point.handle_type) self._write(f"{point_j_var}.handle_type = {handle}") - def _node_tree_settings(self, node: Node, attr_name: str) -> None: + def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: """ Processes node tree of group node if one is present @@ -1284,12 +1062,12 @@ def _node_tree_settings(self, node: Node, attr_name: str) -> None: node_var = self._node_vars[node] self._write(f"{node_var}.{attr_name} = {nt_var}") else: - self.report( + self._operator.report( {'WARNING'}, f"NodeToPython: Node tree dependency graph " f"wasn't properly initialized! Couldn't find " f"node tree {node_tree.name}") - + def _save_image(self, img: bpy.types.Image) -> bool: """ Saves an image to an image directory of the add-on @@ -1304,11 +1082,14 @@ def _save_image(self, img: bpy.types.Image) -> bool: img_str = img_to_py_str(img) if not img.has_data: - self.report({'WARNING'}, f"{img_str} has no data") + self._operator.report( + {'WARNING'}, + f"{img_str} has no data" + ) return False # create image dir if one doesn't exist - img_dir = os.path.join(self._addon_dir, IMAGE_DIR_NAME) + img_dir = os.path.join(self._operator._addon_dir, IMAGE_DIR_NAME) if not os.path.exists(img_dir): os.mkdir(img_dir) @@ -1357,7 +1138,7 @@ def _load_image(self, img: bpy.types.Image, img_var: str) -> None: # alpha mode alpha_mode = enum_to_py_str(img.alpha_mode) self._write(f"{img_var}.alpha_mode = {alpha_mode}") - + def _image_user_settings(self, img_user: bpy.types.ImageUser, img_user_var: str) -> None: """ @@ -1397,6 +1178,24 @@ def _output_zone_items(self, output_items, items_str: str, ad = enum_to_py_str(item.attribute_domain) self._write(f"{item_var}.attribute_domain = {ad}") + if bpy.app.version >= (4, 1, 0) and bpy.app.version < (4, 2, 0): + def _enum_definition(self, enum_def: bpy.types.NodeEnumDefinition, + enum_def_str: str) -> None: + """ + Set enum definition item for a node + + Parameters: + enum_def (bpy.types.NodeEnumDefinition): enum definition to replicate + enum_def_str (str): string for the generated enum definition + """ + self._write(f"{enum_def_str}.enum_items.clear()") + for i, enum_item in enumerate(enum_def.enum_items): + name = str_to_py_str(enum_item.name) + self._write(f"{enum_def_str}.enum_items.new({name})") + if enum_item.description != "": + self._write(f"{enum_def_str}.enum_items[{i}].description = " + f"{str_to_py_str(enum_item.description)}") + if bpy.app.version >= (4, 1, 0): def _index_switch_items(self, switch_items: bpy.types.NodeIndexSwitchItems, items_str: str) -> None: @@ -1432,25 +1231,7 @@ def _bake_items(self, bake_items: bpy.types.NodeGeometryBakeItems, if bake_item.is_attribute: self._write(f"{bake_items_str}[{i}].is_attribute = True") - - if bpy.app.version >= (4, 1, 0) and bpy.app.version < (4, 2, 0): - def _enum_definition(self, enum_def: bpy.types.NodeEnumDefinition, - enum_def_str: str) -> None: - """ - Set enum definition item for a node - - Parameters: - enum_def (bpy.types.NodeEnumDefinition): enum definition to replicate - enum_def_str (str): string for the generated enum definition - """ - self._write(f"{enum_def_str}.enum_items.clear()") - for i, enum_item in enumerate(enum_def.enum_items): - name = str_to_py_str(enum_item.name) - self._write(f"{enum_def_str}.enum_items.new({name})") - if enum_item.description != "": - self._write(f"{enum_def_str}.enum_items[{i}].description = " - f"{str_to_py_str(enum_item.description)}") - + if bpy.app.version >= (4, 2, 0): def _capture_attribute_items(self, capture_attribute_items: bpy.types.NodeGeometryCaptureAttributeItems, capture_attrs_str: str) -> None: """ @@ -1690,8 +1471,188 @@ def _color_managed_view_settings(self, look_str = enum_to_py_str(view_settings.look) self._write(f"{view_settings_str}.look = {look_str}") + def _hide_hidden_sockets(self, node: bpy.types.Node) -> None: + """ + Hide hidden sockets + + Parameters: + node (Node): node object we're copying socket settings from + """ + node_var = self._node_vars[node] + + for i, socket in enumerate(node.inputs): + if socket.hide is True: + self._write(f"{node_var}.inputs[{i}].hide = True") + for i, socket in enumerate(node.outputs): + if socket.hide is True: + self._write(f"{node_var}.outputs[{i}].hide = True") + + def _set_socket_defaults(self, node: bpy.types.Node) -> None: + """ + Set input and output socket defaults + """ + self._set_input_defaults(node) + self._set_output_defaults(node) + + def _set_input_defaults(self, node: bpy.types.Node) -> None: + """ + Sets defaults for input sockets + + Parameters: + node (Node): node we're setting inputs for + """ + if node.bl_idname == 'NodeReroute': + return + + node_var = self._node_vars[node] + + for i, input in enumerate(node.inputs): + if input.bl_idname not in DONT_SET_DEFAULTS and not input.is_linked: + if bpy.app.version >= (3, 4, 0): + if (not self._operator._set_unavailable_defaults) and input.is_unavailable: + continue + + # TODO: this could be cleaner + 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: + if "2D" in input.bl_idname: + default_val = vec2_to_py_str(input.default_value) + elif "4D" in input.bl_idname: + default_val = vec4_to_py_str(input.default_value) + else: + default_val = vec3_to_py_str(input.default_value) + + # rotation types + elif input.bl_idname == 'NodeSocketRotation': + default_val = vec3_to_py_str(input.default_value) + + # strings + elif input.bl_idname in {'NodeSocketString', 'NodeSocketStringFilePath'}: + default_val = str_to_py_str(input.default_value) + + #menu + elif input.bl_idname == 'NodeSocketMenu': + if input.default_value == '': + continue + default_val = enum_to_py_str(input.default_value) + + # images + elif input.bl_idname == 'NodeSocketImage': + img = getattr(input, "default_value") + if img is not None: + if self._operator._addon_dir != "": # write in a better way + if self._save_image(img): + self._load_image(img, f"{socket_var}.default_value") + else: + self._in_file_inputs(input, socket_var, "images") + default_val = None + + # materials + elif input.bl_idname == 'NodeSocketMaterial': + self._in_file_inputs(input, socket_var, "materials") + default_val = None + + # collections + elif input.bl_idname == 'NodeSocketCollection': + self._in_file_inputs(input, socket_var, "collections") + default_val = None + + # objects + elif input.bl_idname == 'NodeSocketObject': + self._in_file_inputs(input, socket_var, "objects") + default_val = None + + # textures + elif input.bl_idname == 'NodeSocketTexture': + self._in_file_inputs(input, socket_var, "textures") + default_val = None + + else: + default_val = input.default_value + + if default_val is not None: + self._write(f"# {input.identifier}") + self._write(f"{socket_var}.default_value = {default_val}") + self._write("", 0) + + def _set_output_defaults(self, node: bpy.types.Node) -> None: + """ + Some output sockets need default values set. It's rather annoying + + Parameters: + node (Node): node for the output we're setting + """ + # TODO: probably should define elsewhere + OUTPUT_SOCKET_DEFAULT_NODES = { + 'ShaderNodeValue', + 'ShaderNodeRGB', + 'ShaderNodeNormal', + 'CompositorNodeValue', + 'CompositorNodeRGB', + 'CompositorNodeNormal' + } + + if node.bl_idname not in OUTPUT_SOCKET_DEFAULT_NODES: + return + + node_var = self._node_vars[node] + + dv = node.outputs[0].default_value + if node.bl_idname in {'ShaderNodeRGB', 'CompositorNodeRGB'}: + dv = vec4_to_py_str(list(dv)) + if node.bl_idname in {'ShaderNodeNormal', 'CompositorNodeNormal'}: + dv = vec3_to_py_str(dv) + self._write(f"{node_var}.outputs[0].default_value = {dv}") + + def _in_file_inputs(self, input: bpy.types.NodeSocket, socket_var: str, + type: str) -> None: + """ + 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 + 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 + """ + dv = getattr(input, "default_value") + if dv is None: + return + name = str_to_py_str(dv.name) + self._write(f"if {name} in bpy.data.{type}:") + self._write(f"{socket_var}.default_value = bpy.data.{type}[{name}]", + self._operator._inner_indent_level + 1) + + if bpy.app.version >= (3, 6, 0): + def _process_zones(self, zone_input_list: list[bpy.types.Node]) -> None: + """ + Recreates a zone + zone_input_list (list[bpy.types.Node]): list of zone input + nodes + """ + for input_node in zone_input_list: + zone_output = getattr(input_node, "paired_output") + + zone_input_var = self._node_vars[input_node] + zone_output_var = self._node_vars[zone_output] + + self._write(f"# Process zone input {input_node.name}") + self._write(f"{zone_input_var}.pair_with_output" + f"({zone_output_var})") + + #must set defaults after paired with output + self._set_socket_defaults(input_node) + self._set_socket_defaults(zone_output) - def _set_parents(self, node_tree: NodeTree) -> None: + if zone_input_list: + self._write("", 0) + + def _set_parents(self, node_tree: bpy.types.NodeTree) -> None: """ Sets parents for all nodes, mostly used to put nodes in frames @@ -1710,7 +1671,7 @@ def _set_parents(self, node_tree: NodeTree) -> None: if parent_comment: self._write("", 0) - def _set_locations(self, node_tree: NodeTree) -> None: + def _set_locations(self, node_tree: bpy.types.NodeTree) -> None: """ Set locations for all nodes @@ -1726,14 +1687,14 @@ def _set_locations(self, node_tree: NodeTree) -> None: if node_tree.nodes: self._write("", 0) - def _set_dimensions(self, node_tree: NodeTree) -> None: + def _set_dimensions(self, node_tree: bpy.types.NodeTree) -> None: """ Set dimensions for all nodes Parameters: node_tree (NodeTree): node tree we're obtaining nodes from """ - if not self._should_set_dimensions: + if not self._operator._should_set_dimensions: return self._write(f"# Set dimensions") @@ -1744,7 +1705,7 @@ def _set_dimensions(self, node_tree: NodeTree) -> None: if node_tree.nodes: self._write("", 0) - def _init_links(self, node_tree: NodeTree) -> None: + def _init_links(self, node_tree: bpy.types.NodeTree) -> None: """ Create all the links between nodes @@ -1762,6 +1723,19 @@ def _init_links(self, node_tree: NodeTree) -> None: links = sorted(links, key=lambda link: link.multi_input_sort_id) for link in links: + if link.from_node is None: + self._operator.report( + {'WARNING'}, + "Link's from_node was None. This shouldn't happen" + ) + continue + if link.to_node is None: + self._operator.report( + {'WARNING'}, + "Link's to_node was None. This shouldn't happen" + ) + continue + in_node_var = self._node_vars[link.from_node] input_socket = link.from_socket @@ -1795,127 +1769,4 @@ def _init_links(self, node_tree: NodeTree) -> None: _func() self._write_after_links = [] self._write("", 0) - - - def _set_node_tree_properties(self, node_tree: NodeTree) -> None: - nt_var = self._node_tree_vars[node_tree] - - if bpy.app.version >= (4, 2, 0): - color_tag_str = enum_to_py_str(node_tree.color_tag) - self._write(f"{nt_var}.color_tag = {color_tag_str}") - desc_str = str_to_py_str(node_tree.description) - self._write(f"{nt_var}.description = {desc_str}") - if bpy.app.version >= (4, 3, 0): - default_width = node_tree.default_group_node_width - self._write(f"{nt_var}.default_group_node_width = {default_width}") - - def _hide_hidden_sockets(self, node: Node) -> None: - """ - Hide hidden sockets - - Parameters: - node (Node): node object we're copying socket settings from - """ - node_var = self._node_vars[node] - - for i, socket in enumerate(node.inputs): - if socket.hide is True: - self._write(f"{node_var}.inputs[{i}].hide = True") - for i, socket in enumerate(node.outputs): - if socket.hide is True: - self._write(f"{node_var}.outputs[{i}].hide = True") - - def _create_menu_func(self) -> None: - """ - Creates the menu function - """ - self._write("def menu_func(self, context):", 0) - self._write(f"self.layout.operator({self._class_name}.bl_idname)\n", 1) - - def _create_register_func(self) -> None: - """ - Creates the register function - """ - self._write("def register():", 0) - self._write(f"bpy.utils.register_class({self._class_name})", 1) - self._write(f"bpy.types.{self._menu_id}.append(menu_func)\n", 1) - - def _create_unregister_func(self) -> None: - """ - Creates the unregister function - """ - self._write("def unregister():", 0) - self._write(f"bpy.utils.unregister_class({self._class_name})", 1) - self._write(f"bpy.types.{self._menu_id}.remove(menu_func)\n", 1) - - def _create_main_func(self) -> None: - """ - Creates the main function - """ - self._write("if __name__ == \"__main__\":", 0) - self._write("register()", 1) - - def _create_license(self) -> None: - if not self._should_create_license: - return - if self._license == 'OTHER': - return - license_file = open(f"{self._addon_dir}/LICENSE", "w") - year = datetime.date.today().year - license_txt = license_templates[self._license](year, self._author_name) - license_file.write(license_txt) - license_file.close() - - if bpy.app.version >= (4, 2, 0): - def _create_manifest(self) -> None: - manifest = open(f"{self._addon_dir}/blender_manifest.toml", "w") - manifest.write("schema_version = \"1.0.0\"\n\n") - manifest.write(f"id = {str_to_py_str(self._idname)}\n") - - manifest.write(f"version = {version_to_manifest_str(self._version)}\n") - manifest.write(f"name = {str_to_py_str(self._name)}\n") - if self._description == "": - self._description = self._name - manifest.write(f"tagline = {str_to_py_str(self._description)}\n") - manifest.write(f"maintainer = {str_to_py_str(self._author_name)}\n") - manifest.write("type = \"add-on\"\n") - manifest.write(f"blender_version_min = {version_to_manifest_str(bpy.app.version)}\n") - if self._license != 'OTHER': - manifest.write(f"license = [{str_to_py_str(self._license)}]\n") - else: - self.report({'WARNING'}, "No license selected. Please add a license to the manifest file") - - manifest.close() - - def _zip_addon(self) -> None: - """ - Zips up the addon and removes the directory - """ - shutil.make_archive(self._zip_dir, "zip", self._zip_dir) - shutil.rmtree(self._zip_dir) - - # ABSTRACT - def _process_node(self, node: Node, ntp_node_tree: NTP_NodeTree) -> None: - return - - # ABSTRACT - def _process_node_tree(self, node_tree: NodeTree) -> None: - return - - def _report_finished(self, object: str): - """ - Alert user that NTP is finished - - Parameters: - object (str): the copied node tree or encapsulating structure - (geometry node modifier, material, scene, etc.) - """ - if self._mode == 'SCRIPT': - location = "clipboard" - else: - location = self._dir_path - self.report({'INFO'}, f"NodeToPython: Saved {object} to {location}") - # ABSTRACT - def execute(self): - return {'FINISHED'} diff --git a/NodeToPython/ntp_node_tree.py b/NodeToPython/export/ntp_node_tree.py similarity index 63% rename from NodeToPython/ntp_node_tree.py rename to NodeToPython/export/ntp_node_tree.py index a551d01..a99ecf7 100644 --- a/NodeToPython/ntp_node_tree.py +++ b/NodeToPython/export/ntp_node_tree.py @@ -1,13 +1,14 @@ -from bpy.types import NodeTree import bpy class NTP_NodeTree: - def __init__(self, node_tree: NodeTree, var: str): + def __init__(self, node_tree: bpy.types.NodeTree, var: str): # Blender node tree object being copied - self.node_tree: NodeTree = node_tree + self._node_tree: bpy.types.NodeTree = node_tree # The variable named for the regenerated node tree - self.var: str = var + self._var: str = var + + self._zone_inputs: dict[str, list[bpy.types.Node]] = {} if bpy.app.version < (4, 0, 0): # Keep track of if we need to set the default values for the node diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py new file mode 100644 index 0000000..18d2aec --- /dev/null +++ b/NodeToPython/export/ntp_operator.py @@ -0,0 +1,404 @@ +import datetime +from io import StringIO +import os +import pathlib +import shutil +from typing import TextIO, Callable + +import bpy + +from .node_group_gatherer import NodeGroupGatherer, NodeGroupType +from .license_templates import license_templates +from .ntp_options import NTP_PG_Options +from .utils import * + +INDEX = "i" +IMAGE_DIR_NAME = "imgs" +IMAGE_PATH = "image_path" +ITEM = "item" +BASE_DIR = "base_dir" +DATAFILES_PATH = "datafiles_path" +LIB_RELPATH = "lib_relpath" +LIB_PATH = "lib_path" +DATA_SRC = "data_src" +DATA_DST = "data_dst" + +RESERVED_NAMES = { + INDEX, + IMAGE_DIR_NAME, + IMAGE_PATH, + ITEM, + BASE_DIR, + DATAFILES_PATH, + LIB_RELPATH, + LIB_PATH, + DATA_SRC, + DATA_DST +} + +#node input sockets that are messy to set default values for +DONT_SET_DEFAULTS = { + 'NodeSocketGeometry', + 'NodeSocketShader', + 'NodeSocketMatrix', + 'NodeSocketVirtual', + 'NodeSocketBundle', + 'NodeSocketClosure' +} + +MAX_BLENDER_VERSION = (5, 1, 0) + +class NTP_OT_Export(bpy.types.Operator): + bl_idname = "ntp.export" + bl_label = "Export" + bl_description = "Export node group(s) to Python" + bl_options = {'REGISTER', 'UNDO'} + + # node tree input sockets that have default properties + if bpy.app.version < (4, 0, 0): + default_sockets_v3 = {'VALUE', 'INT', 'BOOLEAN', 'VECTOR', 'RGBA'} + else: + nondefault_sockets_v4 = { + bpy.types.NodeTreeInterfaceSocketCollection, + bpy.types.NodeTreeInterfaceSocketGeometry, + bpy.types.NodeTreeInterfaceSocketImage, + bpy.types.NodeTreeInterfaceSocketMaterial, + bpy.types.NodeTreeInterfaceSocketObject, + bpy.types.NodeTreeInterfaceSocketShader, + bpy.types.NodeTreeInterfaceSocketTexture, + bpy.types.NodeTreeInterfaceSocketClosure + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Write functions after nodes are mostly initialized and linked up + self._write_after_links: list[Callable] = [] + + # File (TextIO) or string (StringIO) the add-on/script is generated into + self._file: TextIO | StringIO = StringIO() + + # Path to the directory of the zip file + self._zip_dir: str = "" + + # Path to the directory for the generated addon + self._addon_dir: str = "" + + # Class named for the generated operator + self._class_name: str = "" + + # Indentation to use for the default write function + self._outer_indent_level: int = 0 + self._inner_indent_level: int = 1 + + # Dictionary to keep track of variables->usage count pairs + self._used_vars: dict[str, int] = {} + + for name in RESERVED_NAMES: + self._used_vars[name] = 0 + + # Generate socket default, min, and max values + self._include_group_socket_values = True + + # Set dimensions of generated nodes + self._should_set_dimensions = True + + # Indentation string (default: four spaces) + self._indentation = " " + + self._link_external_node_groups = True + + self._lib_trees: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} + + if bpy.app.version >= (3, 4, 0): + # Set default values for hidden sockets + self._set_unavailable_defaults = False + + def execute(self, context: bpy.types.Context): + if bpy.app.version >= MAX_BLENDER_VERSION: + self.report( + {'WARNING'}, + f"Blender version {bpy.app.version} is not supported yet!\n" + f"NodeToPython is currently supported up to " + f"{vec3_to_py_str(MAX_BLENDER_VERSION)}.\n" + f"Some nodes, settings, and features may not work yet. " + f" For more details, visit " + ) + self.report( + {'WARNING'}, + "\t\thttps://github.com/BrendanParmer/NodeToPython/blob/main/" + "docs/README.md#supported-versions " + ) + return {'CANCELLED'} + + if not self._setup_options(getattr(context.scene, "ntp_options")): + return {'CANCELLED'} + + if self._mode == 'ADDON': + self._outer_indent_level = 2 + self._inner_indent_level = 3 + + if not self._setup_addon_directories(self.name): + return {'CANCELLED'} + + self._file = open(f"{self._addon_dir}/__init__.py", "w") + + self._create_bl_info(self.name) + self._create_imports() + + #self._init_operator(self.obj_var, self.name) + #self._write("def execute(self, context):", 1) + + elif self._mode == 'SCRIPT': + self._file = StringIO("") + if self._include_imports: + self._create_imports() + + gatherer = NodeGroupGatherer() + gatherer.gather_node_groups(context) + + # TODO: multiple node groups + if gatherer.get_number_node_groups() != 1: + self.report({'ERROR'}, "Can only export one node group currently") + return {'CANCELLED'} + + group_type, obj = gatherer.get_single_node_group() + + # Imported here to avoid circular dependency issues + from .compositor.exporter import CompositorExporter + from .geometry.exporter import GeometryNodesExporter + from .shader.exporter import ShaderExporter + + match group_type: + case NodeGroupType.COMPOSITOR_NODE_GROUP | NodeGroupType.SCENE: + exporter = CompositorExporter(self, obj.name, group_type) + case NodeGroupType.GEOMETRY_NODE_GROUP: + exporter = GeometryNodesExporter(self, obj.name, group_type) + case ( NodeGroupType.LIGHT + | NodeGroupType.LINE_STYLE + | NodeGroupType.MATERIAL + | NodeGroupType.SHADER_NODE_GROUP + | NodeGroupType.WORLD + ): + exporter = ShaderExporter(self, obj.name, group_type) + exporter.export() + + if self._mode == 'ADDON': + self._write("return {'FINISHED'}\n", self._outer_indent_level) + + self._create_menu_func() + self._create_register_func() + self._create_unregister_func() + self._create_main_func() + self._create_license() + if bpy.app.version >= (4, 2, 0): + self._create_manifest() + else: + context.window_manager.clipboard = self._file.getvalue() + + self._file.close() + + if self._mode == 'ADDON': + self._zip_addon() + + return {'FINISHED'} + + def _write(self, string: str, indent_level: int = -1): + if indent_level == -1: + indent_level = self._inner_indent_level + indent_str = indent_level * self._indentation + self._file.write(f"{indent_str}{string}\n") + + def _setup_options(self, options: NTP_PG_Options) -> bool: + # General + self._mode = options.mode + self._include_group_socket_values = options.set_group_defaults + self._should_set_dimensions = options.set_node_sizes + + if options.indentation_type == 'SPACES_2': + self._indentation = " " + elif options.indentation_type == 'SPACES_4': + self._indentation = " " + elif options.indentation_type == 'SPACES_8': + self._indentation = " " + elif options.indentation_type == 'TABS': + self._indentation = "\t" + + self._link_external_node_groups = options.link_external_node_groups + + if bpy.app.version >= (3, 4, 0): + self._set_unavailable_defaults = options.set_unavailable_defaults + + #Script + if options.mode == 'SCRIPT': + self._include_imports = options.include_imports + #Addon + elif options.mode == 'ADDON': + self._dir_path = bpy.path.abspath(options.dir_path) + self._name_override = options.name_override + self._description = options.description + self._author_name = options.author_name + self._version = options.version + self._location = options.location + self._license = options.license + self._should_create_license = options.should_create_license + self._category = options.category + self._custom_category = options.custom_category + if options.menu_id in dir(bpy.types): + self._menu_id = options.menu_id + else: + self.report({'ERROR'}, f"{options.menu_id} is not a valid menu") + return False + return True + + def _setup_addon_directories( + self, + addon_name: str + ) -> bool: + """ + Finds/creates directories to save add-on to + + Parameters: + context (Context): the current scene context + obj_var (str): variable name of the object + + Returns: + (bool): success of addon directory setup + """ + if not self._dir_path or self._dir_path == "": + self.report({'ERROR'}, + ("NodeToPython: No save location found. Please select " + "one in the NodeToPython Options panel")) + return False + + self._zip_dir = os.path.join(self._dir_path, addon_name) + self._addon_dir = os.path.join(self._zip_dir, addon_name) + + if not os.path.exists(self._addon_dir): + os.makedirs(self._addon_dir) + + return True + + def _create_bl_info(self, name: str) -> None: + """ + Sets up the bl_info and imports the Blender API + + Parameters: + name (str): name of the add-on + """ + + self._write("bl_info = {", 0) + self._name = name + if self._name_override and self._name_override != "": + self._name = self._name_override + self._write(f"\"name\" : {str_to_py_str(self._name)},", 1) + if self._description and self._description != "": + self._write(f"\"description\" : {str_to_py_str(self._description)},", 1) + self._write(f"\"author\" : {str_to_py_str(self._author_name)},", 1) + self._write(f"\"version\" : {vec3_to_py_str(self._version)},", 1) + self._write(f"\"blender\" : {bpy.app.version},", 1) + self._write(f"\"location\" : {str_to_py_str(self._location)},", 1) + category = self._category + if category == "Custom": + category = self._custom_category + self._write(f"\"category\" : {str_to_py_str(category)},", 1) + self._write("}\n", 0) + + def _create_imports(self) -> None: + self._write("import bpy", 0) + self._write("import mathutils", 0) + self._write("import os", 0) + self._write("\n", 0) + + def _create_menu_func(self) -> None: + """ + Creates the menu function + """ + self._write("def menu_func(self, context):", 0) + self._write(f"self.layout.operator({self._class_name}.bl_idname)\n", 1) + + def _create_register_func(self) -> None: + """ + Creates the register function + """ + self._write("def register():", 0) + self._write(f"bpy.utils.register_class({self._class_name})", 1) + self._write(f"bpy.types.{self._menu_id}.append(menu_func)\n", 1) + + def _create_unregister_func(self) -> None: + """ + Creates the unregister function + """ + self._write("def unregister():", 0) + self._write(f"bpy.utils.unregister_class({self._class_name})", 1) + self._write(f"bpy.types.{self._menu_id}.remove(menu_func)\n", 1) + + def _create_main_func(self) -> None: + """ + Creates the main function + """ + self._write("if __name__ == \"__main__\":", 0) + self._write("register()", 1) + + def _create_license(self) -> None: + if not self._should_create_license: + return + if self._license == 'OTHER': + return + license_file = open(f"{self._addon_dir}/LICENSE", "w") + year = datetime.date.today().year + license_txt = license_templates[self._license](year, self._author_name) + license_file.write(license_txt) + license_file.close() + + if bpy.app.version >= (4, 2, 0): + def _create_manifest(self) -> None: + manifest = open(f"{self._addon_dir}/blender_manifest.toml", "w") + manifest.write("schema_version = \"1.0.0\"\n\n") + idname = self._name_override.lower() #TODO: this isn't safe + manifest.write(f"id = {str_to_py_str(idname)}\n") + + manifest.write(f"version = {version_to_manifest_str(self._version)}\n") + manifest.write(f"name = {str_to_py_str(self._name)}\n") + if self._description == "": + self._description = self._name + manifest.write(f"tagline = {str_to_py_str(self._description)}\n") + manifest.write(f"maintainer = {str_to_py_str(self._author_name)}\n") + manifest.write("type = \"add-on\"\n") + min_version_str = f"{version_to_manifest_str(bpy.app.version)}" + manifest.write(f"blender_version_min = {min_version_str}\n") + if self._license != 'OTHER': + manifest.write(f"license = [{str_to_py_str(self._license)}]\n") + else: + self.report( + {'WARNING'}, + "No license selected. Please add a license to " + "the manifest file" + ) + + manifest.close() + + def _zip_addon(self) -> None: + """ + Zips up the addon and removes the directory + """ + shutil.make_archive(self._zip_dir, "zip", self._zip_dir) + shutil.rmtree(self._zip_dir) + + def _report_finished(self, object: str): + """ + Alert user that NTP is finished + + Parameters: + object (str): the copied node tree or encapsulating structure + (geometry node modifier, material, scene, etc.) + """ + if self._mode == 'SCRIPT': + location = "clipboard" + else: + location = self._dir_path + self.report({'INFO'}, f"NodeToPython: Saved {object} to {location}") + +classes = [ + NTP_OT_Export +] \ No newline at end of file diff --git a/NodeToPython/ntp_options.py b/NodeToPython/export/ntp_options.py similarity index 100% rename from NodeToPython/ntp_options.py rename to NodeToPython/export/ntp_options.py diff --git a/NodeToPython/geometry/__init__.py b/NodeToPython/export/shader/__init__.py similarity index 51% rename from NodeToPython/geometry/__init__.py rename to NodeToPython/export/shader/__init__.py index 83ec3ca..e689686 100644 --- a/NodeToPython/geometry/__init__.py +++ b/NodeToPython/export/shader/__init__.py @@ -1,17 +1,14 @@ if "bpy" in locals(): import importlib importlib.reload(node_tree) - importlib.reload(operator) - importlib.reload(ui) + importlib.reload(exporter) else: from . import node_tree - from . import operator - from . import ui + from . import exporter import bpy modules = [ node_tree, - operator -] -modules += ui.modules \ No newline at end of file + exporter +] \ No newline at end of file diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py new file mode 100644 index 0000000..3fbc370 --- /dev/null +++ b/NodeToPython/export/shader/exporter.py @@ -0,0 +1,180 @@ +import bpy + +from ..node_group_gatherer import NodeGroupType +from ..node_tree_exporter import NodeTreeExporter +from ..ntp_operator import NTP_OT_Export +from ..utils import * + +from .node_tree import NTP_ShaderNodeTree, NTP_NodeTree + +NODE = "node" +LIGHT_OBJ = "light_obj" +SHADER_OP_RESERVED_NAMES = { + NODE, + LIGHT_OBJ +} + +class ShaderExporter(NodeTreeExporter): + def __init__( + self, + ntp_operator: NTP_OT_Export, + obj_name: str, + group_type: NodeGroupType + ): + if group_type not in { + NodeGroupType.LIGHT, + NodeGroupType.LINE_STYLE, + NodeGroupType.MATERIAL, + NodeGroupType.SHADER_NODE_GROUP, + NodeGroupType.WORLD + }: + ntp_operator.report( + {'ERROR'}, + f"Cannot initialize ShaderExporter with group type {group_type}" + ) + NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + + for name in SHADER_OP_RESERVED_NAMES: + self._used_vars[name] = 0 + + def _initialize_shader_node_tree(self, + ntp_node_tree: NTP_ShaderNodeTree, + nt_name: str + ) -> None: + """ + Initialize the shader node group + + Parameters: + ntp_node_tree (NTP_ShaderNodeTree): node tree to be generated and + variable to use + nt_name (str): name to use for the node tree + """ + self._write(f"def {ntp_node_tree._var}_node_group():", + self._operator._outer_indent_level) + self._write(f'"""Initialize {nt_name} node group"""') + + is_base : bool = ntp_node_tree._node_tree == self._base_node_tree + is_obj : bool = self._group_type != NodeGroupType.SHADER_NODE_GROUP + if is_base and is_obj: + self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") + self._write(f"# Start with a clean node tree") + self._write(f"for {NODE} in {ntp_node_tree._var}.nodes:") + self._write(f"{ntp_node_tree._var}.nodes.remove({NODE})", + self._operator._inner_indent_level + 1) + else: + self._write((f"{ntp_node_tree._var} = bpy.data.node_groups.new(" + f"type = \'ShaderNodeTree\', " + f"name = {str_to_py_str(nt_name)})")) + self._write("", 0) + + # NodeTreeExporter interface + def _set_base_node_tree(self) -> None: + match self._group_type: + case NodeGroupType.MATERIAL: + self._obj = bpy.data.materials[self._obj_name] + case NodeGroupType.LIGHT: + self._obj = bpy.data.lights[self._obj_name] + case NodeGroupType.LINE_STYLE: + self._obj = bpy.data.linestyles[self._obj_name] + case NodeGroupType.WORLD: + self._obj = bpy.data.worlds[self._obj_name] + + if self._group_type == NodeGroupType.SHADER_NODE_GROUP: + self._base_node_tree = bpy.data.node_groups[self._obj_name] + else: + self._base_node_tree = self._obj.node_tree + + if self._base_node_tree is None: + self._operator.report( + {'ERROR'}, + ("NodeToPython: Couldn't find base node tree") + ) + + def _initialize_ntp_node_tree( + self, + node_tree: bpy.types.NodeTree, + nt_var: str + ) -> NTP_NodeTree: + return NTP_ShaderNodeTree(node_tree, nt_var) + + # NodeTreeExporter interface + def _create_obj(self): + match self._group_type: + case NodeGroupType.MATERIAL: + self._create_material() + case NodeGroupType.LIGHT: + self._create_light() + case NodeGroupType.LINE_STYLE: + self._create_line_style() + case NodeGroupType.WORLD: + self._create_world() + + # NodeTreeExporter interface + def _initialize_node_tree(self, ntp_node_tree: NTP_NodeTree) -> None: + nt_name = ntp_node_tree._node_tree.name + self._write(f"def {ntp_node_tree._var}_node_group():", + self._operator._outer_indent_level) + self._write(f'"""Initialize {nt_name} node group"""') + + is_tree_base : bool = ntp_node_tree._node_tree == self._base_node_tree + is_obj : bool = self._group_type != NodeGroupType.SHADER_NODE_GROUP + if is_tree_base and is_obj: + self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") + self._write(f"# Start with a clean node tree") + self._write(f"for {NODE} in {ntp_node_tree._var}.nodes:") + self._write(f"{ntp_node_tree._var}.nodes.remove({NODE})", + self._operator._inner_indent_level + 1) + else: + self._write((f"{ntp_node_tree._var} = bpy.data.node_groups.new(" + f"type = \'ShaderNodeTree\', " + f"name = {str_to_py_str(nt_name)})")) + self._write("", 0) + + def _create_material(self): + indent_level = self._get_obj_creation_indent() + + self._write(f"{self._obj_var} = bpy.data.materials.new(" + f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write("if bpy.app.version < (5, 0, 0):", indent_level) + self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level + 1) + + #TODO: other material settings + + def _create_light(self): + indent_level = self._get_obj_creation_indent() + + self._write( + f"{self._obj_var} = bpy.data.lights.new(" + f"name = {str_to_py_str(self._obj_name)}, " + f"type = {enum_to_py_str(self._obj.type)})", + indent_level + ) + self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level) + self._write( + f"{LIGHT_OBJ} = bpy.data.objects.new(" + f"name = {str_to_py_str(self._obj.name)}, " + f"object_data={self._obj_var})", + indent_level + ) + self._write( + f"bpy.context.collection.objects.link({LIGHT_OBJ})", + indent_level + ) + #TODO: other light settings + + self._write("", 0) + + def _create_line_style(self): + indent_level = self._get_obj_creation_indent() + + self._write(f"{self._obj_var} = bpy.data.linestyles.new(" + f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write(f"{self._obj_var}.use_nodes = True\n", indent_level) + + def _create_world(self): + indent_level = self._get_obj_creation_indent() + + self._write(f"{self._obj_var} = bpy.data.worlds.new(" + f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write("if bpy.app.version < (5, 0, 0):", indent_level) + self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level + 1) \ No newline at end of file diff --git a/NodeToPython/shader/node_tree.py b/NodeToPython/export/shader/node_tree.py similarity index 58% rename from NodeToPython/shader/node_tree.py rename to NodeToPython/export/shader/node_tree.py index ca6a210..870beae 100644 --- a/NodeToPython/shader/node_tree.py +++ b/NodeToPython/export/shader/node_tree.py @@ -5,7 +5,6 @@ class NTP_ShaderNodeTree(NTP_NodeTree): def __init__(self, node_tree: bpy.types.ShaderNodeTree, var: str): super().__init__(node_tree, var) - self.zone_inputs: dict[str, list[bpy.types.Node]] = {} if bpy.app.version >= (5, 0, 0): - self.zone_inputs["GeometryNodeRepeatInput"] = [] - self.zone_inputs["NodeClosureInput"] = [] + self._zone_inputs["GeometryNodeRepeatInput"] = [] + self._zone_inputs["NodeClosureInput"] = [] diff --git a/NodeToPython/utils.py b/NodeToPython/export/utils.py similarity index 100% rename from NodeToPython/utils.py rename to NodeToPython/export/utils.py diff --git a/NodeToPython/geometry/operator.py b/NodeToPython/geometry/operator.py deleted file mode 100644 index 3041786..0000000 --- a/NodeToPython/geometry/operator.py +++ /dev/null @@ -1,240 +0,0 @@ -import bpy -from bpy.types import GeometryNode, GeometryNodeTree -from bpy.types import Node - -from io import StringIO - -from ..ntp_operator import NTP_Operator -from ..utils import * -from .node_tree import NTP_GeoNodeTree -from ..node_settings import node_settings - -OBJECT_NAME = "name" -OBJECT = "obj" -MODIFIER = "mod" -GEO_OP_RESERVED_NAMES = {OBJECT_NAME, - OBJECT, - MODIFIER} - -class NTP_OT_GeometryNodes(NTP_Operator): - bl_idname = "ntp.geometry_nodes" - bl_label = "Geometry Nodes to Python" - bl_options = {'REGISTER', 'UNDO'} - - geo_nodes_group_name: bpy.props.StringProperty(name="Node Group") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._node_infos = node_settings - for name in GEO_OP_RESERVED_NAMES: - self._used_vars[name] = 0 - - def _process_node(self, node: Node, ntp_nt: NTP_GeoNodeTree) -> None: - """ - Create node and set settings, defaults, and cosmetics - - Parameters: - node (Node): node to process - ntp_nt (NTP_NodeTree): the node tree that node belongs to - """ - node_var: str = self._create_node(node, ntp_nt.var) - self._set_settings_defaults(node) - - if bpy.app.version < (4, 0, 0): - if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: - self._group_io_settings(node, "input", ntp_nt) - ntp_nt.inputs_set = True - - elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: - self._group_io_settings(node, "output", ntp_nt) - ntp_nt.outputs_set = True - - if node.bl_idname in ntp_nt.zone_inputs: - ntp_nt.zone_inputs[node.bl_idname].append(node) - - self._hide_hidden_sockets(node) - - if node.bl_idname not in ntp_nt.zone_inputs: - self._set_socket_defaults(node) - - if bpy.app.version >= (3, 6, 0): - def _process_zones(self, zone_input_list: list[bpy.types.Node]) -> None: - """ - Recreates a zone - zone_input_list (list[bpy.types.Node]): list of zone input - nodes - """ - for input_node in zone_input_list: - zone_output = input_node.paired_output - - zone_input_var = self._node_vars[input_node] - zone_output_var = self._node_vars[zone_output] - - self._write(f"# Process zone input {input_node.name}") - self._write(f"{zone_input_var}.pair_with_output" - f"({zone_output_var})") - - #must set defaults after paired with output - self._set_socket_defaults(input_node) - self._set_socket_defaults(zone_output) - - if zone_input_list: - self._write("", 0) - - if bpy.app.version >= (4, 0, 0): - def _set_geo_tree_properties(self, node_tree: GeometryNodeTree) -> None: - is_mod = node_tree.is_modifier - is_tool = node_tree.is_tool - - nt_var = self._node_tree_vars[node_tree] - - if is_mod: - self._write(f"{nt_var}.is_modifier = True") - if is_tool: - self._write(f"{nt_var}.is_tool = True") - - tool_flags = [ - "is_mode_object", - "is_mode_edit", - "is_mode_sculpt", - "is_type_curve", - "is_type_mesh", - "is_type_point_cloud" - ] - - for flag in tool_flags: - if hasattr(node_tree, flag) is True: - self._write(f"{nt_var}.{flag} = {getattr(node_tree, flag)}") - - if bpy.app.version >= (4, 2, 0): - if node_tree.use_wait_for_click: - self._write(f"{nt_var}.use_wait_for_click = True") - - if bpy.app.version >= (5, 0, 0): - if node_tree.show_modifier_manage_panel: - self._write(f"{nt_var}.show_modifier_manage_panel = True") - self._write("", 0) - - def _process_node_tree(self, node_tree: GeometryNodeTree) -> None: - """ - Generates a Python function to recreate a node tree - - Parameters: - node_tree (GeometryNodeTree): geometry node tree to be recreated - """ - - nt_var = self._create_var(node_tree.name) - self._node_tree_vars[node_tree] = nt_var - - #initialize node group - self._write(f"def {nt_var}_node_group():", self._outer_indent_level) - self._write(f'"""Initialize {nt_var} node group"""') - self._write(f"{nt_var} = bpy.data.node_groups.new(" - f"type=\'GeometryNodeTree\', " - f"name={str_to_py_str(node_tree.name)})\n") - - self._set_node_tree_properties(node_tree) - if bpy.app.version >= (4, 0, 0): - self._set_geo_tree_properties(node_tree) - - ntp_nt = NTP_GeoNodeTree(node_tree, nt_var) - - if bpy.app.version >= (4, 0, 0): - self._tree_interface_settings(ntp_nt) - - #initialize nodes - self._write(f"# Initialize {nt_var} nodes\n") - for node in node_tree.nodes: - self._process_node(node, ntp_nt) - - for zone_list in ntp_nt.zone_inputs.values(): - self._process_zones(zone_list) - - #set look of nodes - self._set_parents(node_tree) - self._set_locations(node_tree) - self._set_dimensions(node_tree) - - #create connections - self._init_links(node_tree) - - self._write(f"return {nt_var}\n\n") - - #create node group - self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) - - - def _apply_modifier(self, nt: GeometryNodeTree, nt_var: str): - #get object - self._write(f"{OBJECT_NAME} = bpy.context.object.name", self._outer_indent_level) - self._write(f"{OBJECT} = bpy.data.objects[{OBJECT_NAME}]", self._outer_indent_level) - - #set modifier to the one we just created - mod_name = str_to_py_str(nt.name) - self._write(f"{MODIFIER} = obj.modifiers.new(name = {mod_name}, " - f"type = 'NODES')", self._outer_indent_level) - self._write(f"{MODIFIER}.node_group = {nt_var}", self._outer_indent_level) - - - def execute(self, context): - if not self._setup_options(context.scene.ntp_options): - return {'CANCELLED'} - - #find node group to replicate - nt = bpy.data.node_groups[self.geo_nodes_group_name] - - #set up names to use in generated addon - nt_var = clean_string(nt.name) - - if self._mode == 'ADDON': - self._outer_indent_level = 2 - self._inner_indent_level = 3 - - if not self._setup_addon_directories(context, nt_var): - return {'CANCELLED'} - - self._file = open(f"{self._addon_dir}/__init__.py", "w") - - self._create_bl_info(nt.name) - self._create_imports() - self._class_name = clean_string(nt.name, lower = False) - self._init_operator(nt_var, nt.name) - self._write("def execute(self, context):", 1) - else: - self._file = StringIO("") - if self._include_imports: - self._create_imports() - - - node_trees_to_process = self._topological_sort(nt) - - self._import_essential_libs() - - for node_tree in node_trees_to_process: - self._process_node_tree(node_tree) - - if self._mode == 'ADDON': - self._apply_modifier(nt, nt_var) - self._write("return {'FINISHED'}\n", self._outer_indent_level) - self._create_menu_func() - self._create_register_func() - self._create_unregister_func() - self._create_main_func() - self._create_license() - if bpy.app.version >= (4, 2, 0): - self._create_manifest() - else: - context.window_manager.clipboard = self._file.getvalue() - self._file.close() - - if self._mode == 'ADDON': - self._zip_addon() - - self._report_finished("geometry node group") - - return {'FINISHED'} - -classes = [ - NTP_OT_GeometryNodes -] \ No newline at end of file diff --git a/NodeToPython/shader/operator.py b/NodeToPython/shader/operator.py deleted file mode 100644 index a1a813c..0000000 --- a/NodeToPython/shader/operator.py +++ /dev/null @@ -1,322 +0,0 @@ -import bpy -from bpy.types import Node -from bpy.types import ShaderNodeTree - -from io import StringIO - -from ..utils import * -from ..ntp_operator import NTP_Operator -from .node_tree import NTP_ShaderNodeTree -from ..node_settings import node_settings - -NODE = "node" -LIGHT_OBJ = "light_obj" -SHADER_OP_RESERVED_NAMES = {NODE, LIGHT_OBJ} - -class NTP_OT_Shader(NTP_Operator): - bl_idname = "ntp.shader" - bl_label = "Shader to Python" - bl_options = {'REGISTER', 'UNDO'} - - name: bpy.props.StringProperty(name="Node Group") - group_type : bpy.props.EnumProperty( - name = "Group Type", - items=[ - ('MATERIAL', "Material", "Material"), - ('NODE_GROUP', "Node Group", "Node group (typically a sub-group of a graph)"), - ('LIGHT', "Light", "Light"), - ('LINE_STYLE', "Line Style", "Line Style"), - ('WORLD', "World", "World") - ] - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._node_infos = node_settings - self.obj_var : str = "" - self.obj : ( - bpy.types.Material | - bpy.types.Light | - bpy.types.FreestyleLineStyle | - bpy.types.World | - None - ) = None - for name in SHADER_OP_RESERVED_NAMES: - self._used_vars[name] = 0 - - def _create_material(self): - indent_level: int = 0 - if self._mode == 'ADDON': - indent_level = 2 - elif self._mode == 'SCRIPT': - indent_level = 0 - - self._write(f"{self.obj_var} = bpy.data.materials.new(" - f"name = {str_to_py_str(self.name)})", indent_level) - self._write("if bpy.app.version < (5, 0, 0):", indent_level) - self._write(f"{self.obj_var}.use_nodes = True\n\n", indent_level + 1) - - #TODO: other material settings - - def _create_light(self): - indent_level: int = 0 - if self._mode == 'ADDON': - indent_level = 2 - elif self._mode == 'SCRIPT': - indent_level = 0 - - self._write( - f"{self.obj_var} = bpy.data.lights.new(" - f"name = {str_to_py_str(self.name)}, " - f"type = {enum_to_py_str(self.obj.type)})", - indent_level - ) - self._write(f"{self.obj_var}.use_nodes = True\n\n", indent_level) - self._write( - f"{LIGHT_OBJ} = bpy.data.objects.new(" - f"name = {str_to_py_str(self.obj.name)}, " - f"object_data={self.obj_var})", - indent_level - ) - self._write( - f"bpy.context.collection.objects.link({LIGHT_OBJ})", - indent_level - ) - #TODO: other light settings - - self._write("", 0) - - def _create_line_style(self): - indent_level: int = 0 - if self._mode == 'ADDON': - indent_level = 2 - elif self._mode == 'SCRIPT': - indent_level = 0 - - self._write(f"{self.obj_var} = bpy.data.linestyles.new(" - f"name = {str_to_py_str(self.name)})", indent_level) - self._write(f"{self.obj_var}.use_nodes = True\n", indent_level) - - def _create_world(self): - indent_level: int = 0 - if self._mode == 'ADDON': - indent_level = 2 - elif self._mode == 'SCRIPT': - indent_level = 0 - - self._write(f"{self.obj_var} = bpy.data.worlds.new(" - f"name = {str_to_py_str(self.name)})", indent_level) - self._write("if bpy.app.version < (5, 0, 0):", indent_level) - self._write(f"{self.obj_var}.use_nodes = True\n\n", indent_level + 1) - - def _initialize_shader_node_tree(self, - ntp_node_tree: NTP_ShaderNodeTree, - nt_name: str - ) -> None: - """ - Initialize the shader node group - - Parameters: - ntp_node_tree (NTP_ShaderNodeTree): node tree to be generated and - variable to use - nt_name (str): name to use for the node tree - """ - self._write(f"def {ntp_node_tree.var}_node_group():", self._outer_indent_level) - self._write(f'"""Initialize {nt_name} node group"""') - - is_base : bool = ntp_node_tree.node_tree == self._base_node_tree - is_obj : bool = self.group_type != 'NODE_GROUP' - if is_base and is_obj: - self._write(f"{ntp_node_tree.var} = {self.obj_var}.node_tree\n") - self._write(f"# Start with a clean node tree") - self._write(f"for {NODE} in {ntp_node_tree.var}.nodes:") - self._write(f"{ntp_node_tree.var}.nodes.remove({NODE})", self._inner_indent_level + 1) - else: - self._write((f"{ntp_node_tree.var} = bpy.data.node_groups.new(" - f"type = \'ShaderNodeTree\', " - f"name = {str_to_py_str(nt_name)})")) - self._write("", 0) - - def _process_node(self, node: Node, ntp_nt: NTP_ShaderNodeTree) -> None: - """ - Create node and set settings, defaults, and cosmetics - - Parameters: - node (Node): node to process - ntp_nt (NTP_ShaderNodeTree): the node tree that node belongs to - """ - node_var: str = self._create_node(node, ntp_nt.var) - self._set_settings_defaults(node) - - if bpy.app.version < (4, 0, 0): - if node.bl_idname == 'NodeGroupInput' and not ntp_nt.inputs_set: - self._group_io_settings(node, "input", ntp_nt) - ntp_nt.inputs_set = True - - elif node.bl_idname == 'NodeGroupOutput' and not ntp_nt.outputs_set: - self._group_io_settings(node, "output", ntp_nt) - ntp_nt.outputs_set = True - - if node.bl_idname in ntp_nt.zone_inputs: - ntp_nt.zone_inputs[node.bl_idname].append(node) - - self._hide_hidden_sockets(node) - if node.bl_idname not in ntp_nt.zone_inputs: - self._set_socket_defaults(node) - - if bpy.app.version >= (5, 0, 0): - def _process_zones(self, zone_input_list: list[bpy.types.Node]) -> None: - """ - Recreates a zone - zone_input_list (list[bpy.types.Node]): list of zone input - nodes - """ - for input_node in zone_input_list: - zone_output = input_node.paired_output - - zone_input_var = self._node_vars[input_node] - zone_output_var = self._node_vars[zone_output] - - self._write(f"# Process zone input {input_node.name}") - self._write(f"{zone_input_var}.pair_with_output" - f"({zone_output_var})") - - #must set defaults after paired with output - self._set_socket_defaults(input_node) - self._set_socket_defaults(zone_output) - - if zone_input_list: - self._write("", 0) - - def _process_node_tree(self, node_tree: ShaderNodeTree) -> None: - """ - Generates a Python function to recreate a node tree - - Parameters: - node_tree (NodeTree): node tree to be recreated - level (int): number of tabs to use for each line, used with - node groups within node groups and script/add-on differences - """ - - nt_var = self._create_var(node_tree.name) - nt_name = node_tree.name - - self._node_tree_vars[node_tree] = nt_var - - ntp_nt = NTP_ShaderNodeTree(node_tree, nt_var) - - self._initialize_shader_node_tree(ntp_nt, nt_name) - - self._set_node_tree_properties(node_tree) - - if bpy.app.version >= (4, 0, 0): - self._tree_interface_settings(ntp_nt) - - #initialize nodes - self._write(f"# Initialize {nt_var} nodes\n") - for node in node_tree.nodes: - self._process_node(node, ntp_nt) - - for zone_list in ntp_nt.zone_inputs.values(): - self._process_zones(zone_list) - - #set look of nodes - self._set_parents(node_tree) - self._set_locations(node_tree) - self._set_dimensions(node_tree) - - #create connections - self._init_links(node_tree) - - self._write(f"return {nt_var}\n\n") - - #create node group - self._write(f"{nt_var} = {nt_var}_node_group()\n", self._outer_indent_level) - - - def execute(self, context): - if not self._setup_options(context.scene.ntp_options): - return {'CANCELLED'} - - #find node group to replicate - if self.group_type == 'MATERIAL': - self.obj = bpy.data.materials[self.name] - elif self.group_type == 'LIGHT': - self.obj = bpy.data.lights[self.name] - elif self.group_type == 'LINE_STYLE': - self.obj = bpy.data.linestyles[self.name] - elif self.group_type == 'WORLD': - self.obj = bpy.data.worlds[self.name] - - if self.group_type == 'NODE_GROUP': - self._base_node_tree = bpy.data.node_groups[self.name] - else: - self._base_node_tree = self.obj.node_tree - - if self._base_node_tree is None: - self.report({'ERROR'}, ("NodeToPython: Couldn't find base node tree")) - return {'CANCELLED'} - - #set up names to use in generated addon - self.obj_var = clean_string(self.name) - - if self._mode == 'ADDON': - self._outer_indent_level = 2 - self._inner_indent_level = 3 - - if not self._setup_addon_directories(context, self.obj_var): - return {'CANCELLED'} - - self._file = open(f"{self._addon_dir}/__init__.py", "w") - - self._create_bl_info(self.name) - self._create_imports() - self._class_name = clean_string(self.name, lower=False) - self._init_operator(self.obj_var, self.name) - - self._write("def execute(self, context):", 1) - else: - self._file = StringIO("") - if self._include_imports: - self._create_imports() - - if self.group_type == 'MATERIAL': - self._create_material() - elif self.group_type == 'LIGHT': - self._create_light() - elif self.group_type == 'LINE_STYLE': - self._create_line_style() - elif self.group_type == 'WORLD': - self._create_world() - - node_trees_to_process = self._topological_sort(self._base_node_tree) - - self._import_essential_libs() - - for node_tree in node_trees_to_process: - self._process_node_tree(node_tree) - - if self._mode == 'ADDON': - self._write("return {'FINISHED'}", self._outer_indent_level) - self._create_menu_func() - self._create_register_func() - self._create_unregister_func() - self._create_main_func() - self._create_license() - if bpy.app.version >= (4, 2, 0): - self._create_manifest() - else: - context.window_manager.clipboard = self._file.getvalue() - - self._file.close() - - if self._mode == 'ADDON': - self._zip_addon() - - self._report_finished("material") - - return {'FINISHED'} - -classes = [ - NTP_OT_Shader -] \ No newline at end of file diff --git a/NodeToPython/ui/__init__.py b/NodeToPython/ui/__init__.py index 1a54699..283bb6e 100644 --- a/NodeToPython/ui/__init__.py +++ b/NodeToPython/ui/__init__.py @@ -4,11 +4,17 @@ importlib.reload(settings) importlib.reload(generation_settings) importlib.reload(addon_settings) + importlib.reload(compositor) + importlib.reload(geometry) + importlib.reload(shader) else: from . import main from . import settings from . import generation_settings from . import addon_settings + from . import compositor + from . import geometry + from . import shader import bpy @@ -17,4 +23,7 @@ settings, generation_settings, addon_settings -] \ No newline at end of file +] +modules += compositor.modules +modules += geometry.modules +modules += shader.modules \ No newline at end of file diff --git a/NodeToPython/ui/addon_settings.py b/NodeToPython/ui/addon_settings.py index 45224c8..6addb73 100644 --- a/NodeToPython/ui/addon_settings.py +++ b/NodeToPython/ui/addon_settings.py @@ -1,6 +1,7 @@ import bpy from . import settings +from ..export.ntp_options import NTP_PG_Options class NTP_PT_AddonSettings(bpy.types.Panel): bl_idname = "NTP_PT_addon_settings" @@ -17,12 +18,13 @@ def __init__(self, *args, **kwargs): @classmethod def poll(cls, context): - return context.scene.ntp_options.mode == 'ADDON' + options: NTP_PG_Options = getattr(context.scene, "ntp_options") + return options.mode == 'ADDON' def draw(self, context): layout = self.layout layout.operator_context = 'INVOKE_DEFAULT' - ntp_options = context.scene.ntp_options + ntp_options : NTP_PG_Options = getattr(context.scene, "ntp_options") addon_options = [ "dir_path", diff --git a/NodeToPython/compositor/ui/__init__.py b/NodeToPython/ui/compositor/__init__.py similarity index 100% rename from NodeToPython/compositor/ui/__init__.py rename to NodeToPython/ui/compositor/__init__.py diff --git a/NodeToPython/compositor/ui/compositor_node_groups.py b/NodeToPython/ui/compositor/compositor_node_groups.py similarity index 100% rename from NodeToPython/compositor/ui/compositor_node_groups.py rename to NodeToPython/ui/compositor/compositor_node_groups.py diff --git a/NodeToPython/compositor/ui/panel.py b/NodeToPython/ui/compositor/panel.py similarity index 100% rename from NodeToPython/compositor/ui/panel.py rename to NodeToPython/ui/compositor/panel.py diff --git a/NodeToPython/compositor/ui/scenes.py b/NodeToPython/ui/compositor/scenes.py similarity index 100% rename from NodeToPython/compositor/ui/scenes.py rename to NodeToPython/ui/compositor/scenes.py diff --git a/NodeToPython/ui/generation_settings.py b/NodeToPython/ui/generation_settings.py index 1be58a3..c895faa 100644 --- a/NodeToPython/ui/generation_settings.py +++ b/NodeToPython/ui/generation_settings.py @@ -1,6 +1,7 @@ import bpy from . import settings +from ..export.ntp_options import NTP_PG_Options class NTP_PT_GenerationSettings(bpy.types.Panel): bl_idname = "NTP_PT_generation_settings" @@ -22,7 +23,7 @@ def poll(cls, context): def draw(self, context): layout = self.layout layout.operator_context = 'INVOKE_DEFAULT' - ntp_options = context.scene.ntp_options + ntp_options : NTP_PG_Options = getattr(context.scene, "ntp_options") generation_options = [ "set_group_defaults", diff --git a/NodeToPython/geometry/ui/__init__.py b/NodeToPython/ui/geometry/__init__.py similarity index 100% rename from NodeToPython/geometry/ui/__init__.py rename to NodeToPython/ui/geometry/__init__.py diff --git a/NodeToPython/geometry/ui/geometry_node_groups.py b/NodeToPython/ui/geometry/geometry_node_groups.py similarity index 100% rename from NodeToPython/geometry/ui/geometry_node_groups.py rename to NodeToPython/ui/geometry/geometry_node_groups.py diff --git a/NodeToPython/geometry/ui/panel.py b/NodeToPython/ui/geometry/panel.py similarity index 100% rename from NodeToPython/geometry/ui/panel.py rename to NodeToPython/ui/geometry/panel.py diff --git a/NodeToPython/ui/main.py b/NodeToPython/ui/main.py index fde92c6..d52c176 100644 --- a/NodeToPython/ui/main.py +++ b/NodeToPython/ui/main.py @@ -1,6 +1,8 @@ import bpy -from ..export_operator import NTP_OT_Export, NodeGroupGatherer +from ..export.node_group_gatherer import NodeGroupGatherer +from ..export.ntp_operator import NTP_OT_Export +from ..export.ntp_options import NTP_PG_Options import pathlib @@ -27,13 +29,13 @@ def draw(self, context: bpy.types.Context): col = layout.column(align=True) row = col.row() - ntp_options = context.scene.ntp_options + ntp_options : NTP_PG_Options = getattr(context.scene, "ntp_options") location = "" export_icon = '' if ntp_options.mode == 'SCRIPT': location = "clipboard" export_icon = 'COPYDOWN' - elif ntp_options.mode == 'ADDON': + else: # mode == 'ADDON' location = f"{pathlib.PurePath(ntp_options.dir_path).name}/" export_icon = 'FILE_FOLDER' diff --git a/NodeToPython/ui/settings.py b/NodeToPython/ui/settings.py index e92d1f5..4fe83bd 100644 --- a/NodeToPython/ui/settings.py +++ b/NodeToPython/ui/settings.py @@ -1,6 +1,7 @@ import bpy from . import main +from ..export.ntp_options import NTP_PG_Options class NTP_PT_Settings(bpy.types.Panel): bl_idname = "NTP_PT_settings" @@ -22,7 +23,7 @@ def poll(cls, context): def draw(self, context): layout = self.layout layout.operator_context = 'INVOKE_DEFAULT' - ntp_options = context.scene.ntp_options + ntp_options : NTP_PG_Options = getattr(context.scene, "ntp_options") option_list = [ "mode" diff --git a/NodeToPython/shader/ui/__init__.py b/NodeToPython/ui/shader/__init__.py similarity index 100% rename from NodeToPython/shader/ui/__init__.py rename to NodeToPython/ui/shader/__init__.py diff --git a/NodeToPython/shader/ui/lights.py b/NodeToPython/ui/shader/lights.py similarity index 100% rename from NodeToPython/shader/ui/lights.py rename to NodeToPython/ui/shader/lights.py diff --git a/NodeToPython/shader/ui/line_styles.py b/NodeToPython/ui/shader/line_styles.py similarity index 100% rename from NodeToPython/shader/ui/line_styles.py rename to NodeToPython/ui/shader/line_styles.py diff --git a/NodeToPython/shader/ui/materials.py b/NodeToPython/ui/shader/materials.py similarity index 100% rename from NodeToPython/shader/ui/materials.py rename to NodeToPython/ui/shader/materials.py diff --git a/NodeToPython/shader/ui/panel.py b/NodeToPython/ui/shader/panel.py similarity index 100% rename from NodeToPython/shader/ui/panel.py rename to NodeToPython/ui/shader/panel.py diff --git a/NodeToPython/shader/ui/shader_node_groups.py b/NodeToPython/ui/shader/shader_node_groups.py similarity index 100% rename from NodeToPython/shader/ui/shader_node_groups.py rename to NodeToPython/ui/shader/shader_node_groups.py diff --git a/NodeToPython/shader/ui/worlds.py b/NodeToPython/ui/shader/worlds.py similarity index 100% rename from NodeToPython/shader/ui/worlds.py rename to NodeToPython/ui/shader/worlds.py From 318f6c0b711ee335d49dc760b574f8af88a4696e Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 8 Nov 2025 16:30:10 -0600 Subject: [PATCH 03/28] feat: basic multiple node group export --- NodeToPython/export/ntp_operator.py | 79 ++++++----------------------- 1 file changed, 16 insertions(+), 63 deletions(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 18d2aec..0d787bd 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -12,39 +12,14 @@ from .ntp_options import NTP_PG_Options from .utils import * -INDEX = "i" IMAGE_DIR_NAME = "imgs" -IMAGE_PATH = "image_path" -ITEM = "item" BASE_DIR = "base_dir" -DATAFILES_PATH = "datafiles_path" -LIB_RELPATH = "lib_relpath" -LIB_PATH = "lib_path" -DATA_SRC = "data_src" -DATA_DST = "data_dst" RESERVED_NAMES = { - INDEX, IMAGE_DIR_NAME, - IMAGE_PATH, - ITEM, - BASE_DIR, - DATAFILES_PATH, - LIB_RELPATH, - LIB_PATH, - DATA_SRC, - DATA_DST + BASE_DIR } -#node input sockets that are messy to set default values for -DONT_SET_DEFAULTS = { - 'NodeSocketGeometry', - 'NodeSocketShader', - 'NodeSocketMatrix', - 'NodeSocketVirtual', - 'NodeSocketBundle', - 'NodeSocketClosure' -} MAX_BLENDER_VERSION = (5, 1, 0) @@ -54,21 +29,6 @@ class NTP_OT_Export(bpy.types.Operator): bl_description = "Export node group(s) to Python" bl_options = {'REGISTER', 'UNDO'} - # node tree input sockets that have default properties - if bpy.app.version < (4, 0, 0): - default_sockets_v3 = {'VALUE', 'INT', 'BOOLEAN', 'VECTOR', 'RGBA'} - else: - nondefault_sockets_v4 = { - bpy.types.NodeTreeInterfaceSocketCollection, - bpy.types.NodeTreeInterfaceSocketGeometry, - bpy.types.NodeTreeInterfaceSocketImage, - bpy.types.NodeTreeInterfaceSocketMaterial, - bpy.types.NodeTreeInterfaceSocketObject, - bpy.types.NodeTreeInterfaceSocketShader, - bpy.types.NodeTreeInterfaceSocketTexture, - bpy.types.NodeTreeInterfaceSocketClosure - } - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -108,8 +68,6 @@ def __init__(self, *args, **kwargs): self._link_external_node_groups = True - self._lib_trees: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} - if bpy.app.version >= (3, 4, 0): # Set default values for hidden sockets self._set_unavailable_defaults = False @@ -157,31 +115,26 @@ def execute(self, context: bpy.types.Context): gatherer = NodeGroupGatherer() gatherer.gather_node_groups(context) - # TODO: multiple node groups - if gatherer.get_number_node_groups() != 1: - self.report({'ERROR'}, "Can only export one node group currently") - return {'CANCELLED'} - - group_type, obj = gatherer.get_single_node_group() - # Imported here to avoid circular dependency issues from .compositor.exporter import CompositorExporter from .geometry.exporter import GeometryNodesExporter from .shader.exporter import ShaderExporter - match group_type: - case NodeGroupType.COMPOSITOR_NODE_GROUP | NodeGroupType.SCENE: - exporter = CompositorExporter(self, obj.name, group_type) - case NodeGroupType.GEOMETRY_NODE_GROUP: - exporter = GeometryNodesExporter(self, obj.name, group_type) - case ( NodeGroupType.LIGHT - | NodeGroupType.LINE_STYLE - | NodeGroupType.MATERIAL - | NodeGroupType.SHADER_NODE_GROUP - | NodeGroupType.WORLD - ): - exporter = ShaderExporter(self, obj.name, group_type) - exporter.export() + for group_type, groups in gatherer.node_groups.items(): + for obj in groups: + match group_type: + case NodeGroupType.COMPOSITOR_NODE_GROUP | NodeGroupType.SCENE: + exporter = CompositorExporter(self, obj.name, group_type) + case NodeGroupType.GEOMETRY_NODE_GROUP: + exporter = GeometryNodesExporter(self, obj.name, group_type) + case ( NodeGroupType.LIGHT + | NodeGroupType.LINE_STYLE + | NodeGroupType.MATERIAL + | NodeGroupType.SHADER_NODE_GROUP + | NodeGroupType.WORLD + ): + exporter = ShaderExporter(self, obj.name, group_type) + exporter.export() if self._mode == 'ADDON': self._write("return {'FINISHED'}\n", self._outer_indent_level) From 725a308295b0cb2bbbb8364d532afe18340a6a8f Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:47:32 -0600 Subject: [PATCH 04/28] refactor: easier group type enum handling --- NodeToPython/export/compositor/exporter.py | 7 ++---- NodeToPython/export/geometry/exporter.py | 4 +--- NodeToPython/export/node_group_gatherer.py | 28 ++++++++++++++++++++++ NodeToPython/export/ntp_operator.py | 18 +++++--------- NodeToPython/export/shader/exporter.py | 10 ++------ 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/NodeToPython/export/compositor/exporter.py b/NodeToPython/export/compositor/exporter.py index 13821eb..cd3a84a 100644 --- a/NodeToPython/export/compositor/exporter.py +++ b/NodeToPython/export/compositor/exporter.py @@ -1,7 +1,7 @@ import bpy from ..node_group_gatherer import NodeGroupType -from ..node_settings import NTPNodeSetting +from ..node_settings import NTPNodeSetting, ST from ..node_tree_exporter import NodeTreeExporter, INDEX from ..ntp_node_tree import NTP_NodeTree from ..ntp_operator import NTP_OT_Export @@ -21,10 +21,7 @@ def __init__( obj_name: str, group_type: NodeGroupType ): - if group_type not in { - NodeGroupType.COMPOSITOR_NODE_GROUP, - NodeGroupType.SCENE - }: + if not group_type.is_compositor(): ntp_operator.report( {'ERROR'}, f"Cannot initialize CompositorExporter with group type {group_type}" diff --git a/NodeToPython/export/geometry/exporter.py b/NodeToPython/export/geometry/exporter.py index a59a0da..4471e39 100644 --- a/NodeToPython/export/geometry/exporter.py +++ b/NodeToPython/export/geometry/exporter.py @@ -27,9 +27,7 @@ def __init__( obj_name: str, group_type: NodeGroupType ): - if group_type not in { - NodeGroupType.GEOMETRY_NODE_GROUP - }: + if not group_type.is_geometry(): ntp_operator.report( {'ERROR'}, f"Cannot initialize GeometryNodesExporter with group type {group_type}" diff --git a/NodeToPython/export/node_group_gatherer.py b/NodeToPython/export/node_group_gatherer.py index 9dfdc79..564bd42 100644 --- a/NodeToPython/export/node_group_gatherer.py +++ b/NodeToPython/export/node_group_gatherer.py @@ -12,6 +12,34 @@ class NodeGroupType(Enum): SHADER_NODE_GROUP = auto() WORLD = auto() + def is_group(self) -> bool: + return self in { + NodeGroupType.COMPOSITOR_NODE_GROUP, + NodeGroupType.GEOMETRY_NODE_GROUP, + NodeGroupType.SHADER_NODE_GROUP + } + + def is_obj(self) -> bool: + return not self.is_group + + def is_compositor(self) -> bool: + return self in { + NodeGroupType.COMPOSITOR_NODE_GROUP, + NodeGroupType.SCENE + } + def is_geometry(self) -> bool: + return self in { + NodeGroupType.GEOMETRY_NODE_GROUP + } + def is_shader(self) -> bool: + return self in { + NodeGroupType.LIGHT, + NodeGroupType.LINE_STYLE, + NodeGroupType.MATERIAL, + NodeGroupType.SHADER_NODE_GROUP, + NodeGroupType.WORLD + } + class NodeGroupGatherer: def __init__(self): self.node_groups : dict[NodeGroupType, list] = { diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 0d787bd..1ff00c2 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -122,18 +122,12 @@ def execute(self, context: bpy.types.Context): for group_type, groups in gatherer.node_groups.items(): for obj in groups: - match group_type: - case NodeGroupType.COMPOSITOR_NODE_GROUP | NodeGroupType.SCENE: - exporter = CompositorExporter(self, obj.name, group_type) - case NodeGroupType.GEOMETRY_NODE_GROUP: - exporter = GeometryNodesExporter(self, obj.name, group_type) - case ( NodeGroupType.LIGHT - | NodeGroupType.LINE_STYLE - | NodeGroupType.MATERIAL - | NodeGroupType.SHADER_NODE_GROUP - | NodeGroupType.WORLD - ): - exporter = ShaderExporter(self, obj.name, group_type) + if group_type.is_compositor(): + exporter = CompositorExporter(self, obj.name, group_type) + elif group_type.is_geometry(): + exporter = GeometryNodesExporter(self, obj.name, group_type) + elif group_type.is_shader(): + exporter = ShaderExporter(self, obj.name, group_type) exporter.export() if self._mode == 'ADDON': diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py index 3fbc370..dc6e723 100644 --- a/NodeToPython/export/shader/exporter.py +++ b/NodeToPython/export/shader/exporter.py @@ -21,13 +21,7 @@ def __init__( obj_name: str, group_type: NodeGroupType ): - if group_type not in { - NodeGroupType.LIGHT, - NodeGroupType.LINE_STYLE, - NodeGroupType.MATERIAL, - NodeGroupType.SHADER_NODE_GROUP, - NodeGroupType.WORLD - }: + if not group_type.is_shader(): ntp_operator.report( {'ERROR'}, f"Cannot initialize ShaderExporter with group type {group_type}" @@ -79,7 +73,7 @@ def _set_base_node_tree(self) -> None: case NodeGroupType.WORLD: self._obj = bpy.data.worlds[self._obj_name] - if self._group_type == NodeGroupType.SHADER_NODE_GROUP: + if self._group_type.is_group(): self._base_node_tree = bpy.data.node_groups[self._obj_name] else: self._base_node_tree = self._obj.node_tree From 9fcfc62049e3da0e7d1802927a7cc09803d87e3e Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:53:08 -0600 Subject: [PATCH 05/28] fix: multiple export now takes into account tree dependencies --- NodeToPython/export/node_group_gatherer.py | 21 ++- NodeToPython/export/node_tree_exporter.py | 107 ++---------- NodeToPython/export/ntp_operator.py | 181 +++++++++++++++++++-- 3 files changed, 202 insertions(+), 107 deletions(-) diff --git a/NodeToPython/export/node_group_gatherer.py b/NodeToPython/export/node_group_gatherer.py index 564bd42..265805f 100644 --- a/NodeToPython/export/node_group_gatherer.py +++ b/NodeToPython/export/node_group_gatherer.py @@ -39,10 +39,29 @@ def is_shader(self) -> bool: NodeGroupType.SHADER_NODE_GROUP, NodeGroupType.WORLD } + +NTPObject = ( + bpy.types.NodeTree + | bpy.types.Scene + | bpy.types.Light + | bpy.types.FreestyleLineStyle + | bpy.types.Material + | bpy.types.World +) + +def get_base_node_tree( + ntp_obj: NTPObject, group_type: NodeGroupType + ) -> bpy.types.NodeTree: + if group_type.is_group(): + return ntp_obj + elif group_type == NodeGroupType.SCENE and bpy.app.version >= (5, 0, 0): + return getattr(ntp_obj, "compositing_node_group") + else: + return getattr(ntp_obj, "node_tree") class NodeGroupGatherer: def __init__(self): - self.node_groups : dict[NodeGroupType, list] = { + self.node_groups : dict[NodeGroupType, list[NTPObject]] = { NodeGroupType.COMPOSITOR_NODE_GROUP : [], NodeGroupType.SCENE : [], NodeGroupType.GEOMETRY_NODE_GROUP : [], diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index c60a537..2db98a3 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -98,9 +98,6 @@ def __init__( # Dictionary to keep track of node tree->variable name pairs self._node_tree_vars: dict[bpy.types.NodeTree, str] = {} - # Library trees to link - self._lib_trees: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} - # Write functions after nodes are mostly initialized and linked up self._write_after_links: list[Callable] = [] @@ -116,13 +113,12 @@ def export(self) -> None: self._create_obj() - tree_to_process : list[bpy.types.NodeTree] = self._topological_sort( - self._base_node_tree - ) + nt_info = self._operator._node_trees[self._base_node_tree] + trees_to_process : list[bpy.types.NodeTree] = nt_info._dependencies self._import_essential_libs() - for node_tree in tree_to_process: + for node_tree in trees_to_process: self._process_node_tree(node_tree) if self._operator._mode == 'ADDON': @@ -185,94 +181,15 @@ def _get_obj_creation_indent(self) -> int: elif self._operator._mode == 'SCRIPT': indent_level = 0 return indent_level - - def _topological_sort( - self, - node_tree: bpy.types.NodeTree - ) -> list[bpy.types.NodeTree]: - """ - Perform a topological sort on the node graph to determine dependencies - and which node groups need processed first - - Parameters: - node_tree (NodeTree): the base node tree to convert - - Returns: - (list[NodeTree]): the node trees in order of processing - """ - group_node_type = '' - if isinstance(node_tree, bpy.types.CompositorNodeTree): - group_node_type = 'CompositorNodeGroup' - elif isinstance(node_tree, bpy.types.GeometryNodeTree): - group_node_type = 'GeometryNodeGroup' - elif isinstance(node_tree, bpy.types.ShaderNodeTree): - group_node_type = 'ShaderNodeGroup' - - visited = set() - result: list[bpy.types.NodeTree] = [] - - def dfs(nt: bpy.types.NodeTree) -> None: - """ - Helper function to perform depth-first search on a NodeTree - - Parameters: - nt (NodeTree): current node tree in the dependency graph - """ - if nt is None: - self._operator.report( - {'ERROR'}, - "NodeToPython: Found an invalid node tree. " - "Are all data blocks valid?" - ) - return - - if (self._operator._link_external_node_groups - and nt.library is not None): - bpy_lib_path = bpy.path.abspath(nt.library.filepath) - lib_path = pathlib.Path(os.path.realpath(bpy_lib_path)) - bpy_datafiles_path = bpy.path.abspath( - bpy.utils.system_resource('DATAFILES') - ) - datafiles_path = pathlib.Path(os.path.realpath(bpy_datafiles_path)) - is_lib_essential = lib_path.is_relative_to(datafiles_path) - - if is_lib_essential: - relative_path = lib_path.relative_to(datafiles_path) - if relative_path not in self._lib_trees: - self._lib_trees[relative_path] = [] - self._lib_trees[relative_path].append(nt) - return - else: - print(f"Library {lib_path} didn't seem essential, copying node groups") - - if nt not in visited: - visited.add(nt) - group_nodes = [node for node in nt.nodes - if node.bl_idname == group_node_type] - for group_node in group_nodes: - node_nt = getattr(group_node, "node_tree") - if node_nt is None: - self._operator.report( - {'ERROR'}, - "NodeToPython: Found an invalid node tree. " - "Are all data blocks valid?" - ) - continue - if node_nt not in visited: - dfs(node_nt) - result.append(nt) - - dfs(node_tree) - - return result def _import_essential_libs(self) -> None: - if len(self._lib_trees) == 0: + nt_info = self._operator._node_trees[self._base_node_tree] + if len(nt_info._lib_dependencies) == 0: return self._operator._inner_indent_level -= 1 self._write("# Import node groups from Blender essentials library") self._write(f"{DATAFILES_PATH} = bpy.utils.system_resource('DATAFILES')") - for path, node_trees in self._lib_trees.items(): + for path, node_trees in nt_info._lib_dependencies.items(): self._write(f"{LIB_RELPATH} = {str_to_py_str(str(path))}") self._write(f"{LIB_PATH} = os.path.join({DATAFILES_PATH}, {LIB_RELPATH})") self._write(f"with bpy.data.libraries.load({LIB_PATH}, link=True) " @@ -340,7 +257,9 @@ def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: self._write("", 0) #create node group - self._write(f"{nt_var} = {nt_var}_node_group()\n", + node_tree_info = self._operator._node_trees[node_tree] + node_tree_info._func = f"{nt_var}_node_group()" + self._write(f"{nt_var} = {node_tree_info._func}\n", self._operator._outer_indent_level) @abc.abstractmethod @@ -1057,10 +976,16 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: if node_tree is None: return + node_var = self._node_vars[node] if node_tree in self._node_tree_vars: nt_var = self._node_tree_vars[node_tree] - node_var = self._node_vars[node] self._write(f"{node_var}.{attr_name} = {nt_var}") + elif node_tree in self._operator._node_trees: + # TODO: probably should be done similar to lib trees + node_tree_info = self._operator._node_trees[node_tree] + if self._operator._mode == 'ADDON': + self._write(f"import {node_tree_info._module}") + self._write(f"{node_var}.{attr_name} = {node_tree_info._func}") else: self._operator.report( {'WARNING'}, diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 1ff00c2..ff58d76 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import datetime from io import StringIO import os @@ -7,7 +8,7 @@ import bpy -from .node_group_gatherer import NodeGroupGatherer, NodeGroupType +from .node_group_gatherer import * from .license_templates import license_templates from .ntp_options import NTP_PG_Options from .utils import * @@ -20,9 +21,19 @@ BASE_DIR } - MAX_BLENDER_VERSION = (5, 1, 0) +class NodeTreeInfo(): + def __init__(self): + self._func : str = "" + self._module : str = "" + self._is_base : bool = False + self._dependencies: list[bpy.types.NodeTree] = [] + self._base_tree_dependencies: list[bpy.types.NodeTree] = [] + self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} + self._obj: NTPObject = None + self._group_type: NodeGroupType = NodeGroupType.GEOMETRY_NODE_GROUP + class NTP_OT_Export(bpy.types.Operator): bl_idname = "ntp.export" bl_label = "Export" @@ -56,6 +67,8 @@ def __init__(self, *args, **kwargs): for name in RESERVED_NAMES: self._used_vars[name] = 0 + + self._node_trees: dict[bpy.types.NodeTree, NodeTreeInfo] = {} # Generate socket default, min, and max values self._include_group_socket_values = True @@ -111,24 +124,26 @@ def execute(self, context: bpy.types.Context): self._file = StringIO("") if self._include_imports: self._create_imports() - - gatherer = NodeGroupGatherer() - gatherer.gather_node_groups(context) - + # Imported here to avoid circular dependency issues from .compositor.exporter import CompositorExporter from .geometry.exporter import GeometryNodesExporter from .shader.exporter import ShaderExporter - for group_type, groups in gatherer.node_groups.items(): - for obj in groups: - if group_type.is_compositor(): - exporter = CompositorExporter(self, obj.name, group_type) - elif group_type.is_geometry(): - exporter = GeometryNodesExporter(self, obj.name, group_type) - elif group_type.is_shader(): - exporter = ShaderExporter(self, obj.name, group_type) - exporter.export() + for group_type, obj in self._get_objects_to_export(context): + if group_type.is_compositor(): + exporter = CompositorExporter(self, obj.name, group_type) + elif group_type.is_geometry(): + exporter = GeometryNodesExporter(self, obj.name, group_type) + elif group_type.is_shader(): + exporter = ShaderExporter(self, obj.name, group_type) + else: + self.report( + {'ERROR'}, + "Couldn't match group type (should be unreachable)" + ) + return + exporter.export() if self._mode == 'ADDON': self._write("return {'FINISHED'}\n", self._outer_indent_level) @@ -257,6 +272,142 @@ def _create_imports(self) -> None: self._write("import os", 0) self._write("\n", 0) + def _get_objects_to_export( + self, context: bpy.types.Context + ) -> list[tuple[NodeGroupType, NTPObject]]: + # TODO: this is really messy + gatherer = NodeGroupGatherer() + gatherer.gather_node_groups(context) + + # Peform topological sort on node groups to determine export order + for group_type, groups in gatherer.node_groups.items(): + for obj in groups: + base_tree = get_base_node_tree(obj, group_type) + if base_tree not in self._node_trees: + self._node_trees[base_tree] = NodeTreeInfo() + node_info = self._node_trees[base_tree] + + if self._mode == 'ADDON': + file = f"{clean_string(base_tree.name)}" + else: + file = "" + node_info._module = file + + node_info._is_base = True + node_info._obj = obj + node_info._group_type = group_type + + for group_type, groups in gatherer.node_groups.items(): + for obj in groups: + base_tree = get_base_node_tree(obj, group_type) + self._topological_sort(base_tree) + + visited : set[NodeTreeInfo] = set() + objects_to_export : list[tuple[NodeGroupType, NTPObject]] = [] + def visit(nt_info: NodeTreeInfo): + if nt_info not in visited: + visited.add(nt_info) + for dependency in nt_info._base_tree_dependencies: + visit(self._node_trees[dependency]) + objects_to_export.append((nt_info._group_type, nt_info._obj)) + + for group_type, groups in gatherer.node_groups.items(): + for obj in groups: + base_tree = get_base_node_tree(obj, group_type) + visit(self._node_trees[base_tree]) + + return objects_to_export + + def _topological_sort( + self, + node_tree: bpy.types.NodeTree + ): + """ + Perform a topological sort on the node graph to determine dependencies + and which node groups need processed first + + Parameters: + node_tree (NodeTree): the base node tree to convert + """ + group_node_type = '' + if isinstance(node_tree, bpy.types.CompositorNodeTree): + group_node_type = 'CompositorNodeGroup' + elif isinstance(node_tree, bpy.types.GeometryNodeTree): + group_node_type = 'GeometryNodeGroup' + elif isinstance(node_tree, bpy.types.ShaderNodeTree): + group_node_type = 'ShaderNodeGroup' + + node_info = self._node_trees[node_tree] + + visited : set[bpy.types.NodeTree] = set() + result = node_info._dependencies + + def dfs(nt: bpy.types.NodeTree) -> None: + """ + Helper function to perform depth-first search on a NodeTree + + Parameters: + nt (NodeTree): current node tree in the dependency graph + """ + if nt is None: + self.report( + {'ERROR'}, + "NodeToPython: Found an invalid node tree. " + "Are all data blocks valid?" + ) + return + + if nt in self._node_trees and nt != node_tree: + nt_info = self._node_trees[nt] + if nt_info._is_base: + # don't repeat exported node groups + node_info._base_tree_dependencies.append(nt) + return + elif nt_info._module != node_info._module: + # has multiple parents, needs referenced separately + nt_info._module = "common" + + if (self._link_external_node_groups + and nt.library is not None): + bpy_lib_path = bpy.path.abspath(nt.library.filepath) + lib_path = pathlib.Path(os.path.realpath(bpy_lib_path)) + bpy_datafiles_path = bpy.path.abspath( + bpy.utils.system_resource('DATAFILES') + ) + datafiles_path = pathlib.Path(os.path.realpath(bpy_datafiles_path)) + is_lib_essential = lib_path.is_relative_to(datafiles_path) + + if is_lib_essential: + relative_path = lib_path.relative_to(datafiles_path) + if relative_path not in node_info._lib_dependencies: + node_info._lib_dependencies[relative_path] = [] + node_info._lib_dependencies[relative_path].append(nt) + return + else: + print(f"Library {lib_path} didn't seem essential, copying node groups") + + if nt not in visited: + visited.add(nt) + if nt not in self._node_trees: + self._node_trees[nt] = NodeTreeInfo() + self._node_trees[nt]._module = node_tree.name # TODO + group_nodes = [node for node in nt.nodes + if node.bl_idname == group_node_type] + for group_node in group_nodes: + node_nt = getattr(group_node, "node_tree") + if node_nt is None: + self.report( + {'ERROR'}, + "NodeToPython: Found an invalid node tree. " + "Are all data blocks valid?" + ) + continue + if node_nt not in visited: + dfs(node_nt) + result.append(nt) + + dfs(node_tree) + def _create_menu_func(self) -> None: """ Creates the menu function From e07cba93f206f0201eac172addf26abb80126df7 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 15:31:21 -0600 Subject: [PATCH 06/28] fix: dependency issues, some unsafe refs --- NodeToPython/export/node_tree_exporter.py | 68 ++++++++++++++---- NodeToPython/export/ntp_operator.py | 84 ++++++++++------------- 2 files changed, 91 insertions(+), 61 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 2db98a3..cf936b4 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -112,17 +112,15 @@ def export(self) -> None: self._set_base_node_tree() self._create_obj() - - nt_info = self._operator._node_trees[self._base_node_tree] - trees_to_process : list[bpy.types.NodeTree] = nt_info._dependencies - + self._import_essential_libs() - for node_tree in trees_to_process: - self._process_node_tree(node_tree) + self._process_node_tree(self._base_node_tree) if self._operator._mode == 'ADDON': - self._write("return {'FINISHED'}\n", self._operator._outer_indent_level) + self._write("return {'FINISHED'}", self._operator._outer_indent_level) + + self._write("", self._operator._outer_indent_level) def _write(self, string: str, indent_level: int = -1): self._operator._write(string, indent_level) @@ -253,14 +251,14 @@ def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: self._init_links(node_tree) self._write(f"return {nt_var}\n") - if self._operator._mode == 'SCRIPT': - self._write("", 0) #create node group node_tree_info = self._operator._node_trees[node_tree] node_tree_info._func = f"{nt_var}_node_group()" - self._write(f"{nt_var} = {node_tree_info._func}\n", - self._operator._outer_indent_level) + self._call_node_tree_creation( + node_tree, + self._operator._outer_indent_level + ) @abc.abstractmethod def _initialize_node_tree( @@ -985,7 +983,15 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: node_tree_info = self._operator._node_trees[node_tree] if self._operator._mode == 'ADDON': self._write(f"import {node_tree_info._module}") - self._write(f"{node_var}.{attr_name} = {node_tree_info._func}") + + if node_tree_info._name_var == "": + self._call_node_tree_creation( + node_tree, self._operator._inner_indent_level + ) + self._write( + f"{node_var}.{attr_name} = " + f"bpy.data.node_groups[{node_tree_info._name_var}]" + ) else: self._operator.report( {'WARNING'}, @@ -1686,12 +1692,44 @@ def _init_links(self, node_tree: bpy.types.NodeTree) -> None: self._write(f"# {in_node_var}.{input_socket.name} " f"-> {out_node_var}.{output_socket.name}") - self._write(f"{nt_var}.links.new({in_node_var}" - f".outputs[{input_idx}], " - f"{out_node_var}.inputs[{output_idx}])") + + self._write(f"{nt_var}.links.new(") + self._write( + f"{nt_var}.nodes[{str_to_py_str(link.from_node.name)}]" + f".outputs[{input_idx}],", + self._operator._inner_indent_level + 1 + ) + self._write( + f"{nt_var}.nodes[{str_to_py_str(link.to_node.name)}]" + f".inputs[{output_idx}]", + self._operator._inner_indent_level + 1 + ) + self._write(")") for _func in self._write_after_links: _func() self._write_after_links = [] self._write("", 0) + def _call_node_tree_creation( + self, + node_tree: bpy.types.NodeTree, + indent_level: int, + create_name_var: bool = True + ) -> None: + node_tree_info = self._operator._node_trees[node_tree] + if node_tree in self._node_tree_vars: + nt_var = self._node_tree_vars[node_tree] + else: + nt_var = self._create_var(f"{node_tree.name}") + + self._write( + f"{nt_var} = {node_tree_info._func}", + indent_level + ) + if create_name_var: + node_tree_info._name_var = self._create_var(f"{nt_var}_name") + self._write( + f"{node_tree_info._name_var} = {nt_var}.name", + indent_level + ) \ No newline at end of file diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index ff58d76..b6a7c53 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -33,6 +33,7 @@ def __init__(self): self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} self._obj: NTPObject = None self._group_type: NodeGroupType = NodeGroupType.GEOMETRY_NODE_GROUP + self._name_var : str = "" class NTP_OT_Export(bpy.types.Operator): bl_idname = "ntp.export" @@ -112,7 +113,7 @@ def execute(self, context: bpy.types.Context): if not self._setup_addon_directories(self.name): return {'CANCELLED'} - self._file = open(f"{self._addon_dir}/__init__.py", "w") + self._file = open(f"{self._addon_dir}/__init__.py", 'w') self._create_bl_info(self.name) self._create_imports() @@ -130,13 +131,18 @@ def execute(self, context: bpy.types.Context): from .geometry.exporter import GeometryNodesExporter from .shader.exporter import ShaderExporter - for group_type, obj in self._get_objects_to_export(context): - if group_type.is_compositor(): - exporter = CompositorExporter(self, obj.name, group_type) - elif group_type.is_geometry(): - exporter = GeometryNodesExporter(self, obj.name, group_type) - elif group_type.is_shader(): - exporter = ShaderExporter(self, obj.name, group_type) + objs_to_export = self._get_objects_to_export(context) + for nt_info in objs_to_export: + print(f"Exporting {nt_info._obj.name}") + if self._mode == 'ADDON': + self._file.close() + self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') + if nt_info._group_type.is_compositor(): + exporter = CompositorExporter(self, nt_info._obj.name, nt_info._group_type) + elif nt_info._group_type.is_geometry(): + exporter = GeometryNodesExporter(self, nt_info._obj.name, nt_info._group_type) + elif nt_info._group_type.is_shader(): + exporter = ShaderExporter(self, nt_info._obj.name, nt_info._group_type) else: self.report( {'ERROR'}, @@ -146,7 +152,8 @@ def execute(self, context: bpy.types.Context): exporter.export() if self._mode == 'ADDON': - self._write("return {'FINISHED'}\n", self._outer_indent_level) + self._file.close() + self._file = open(f"{self._addon_dir}/__init__.py", 'a') self._create_menu_func() self._create_register_func() @@ -274,7 +281,7 @@ def _create_imports(self) -> None: def _get_objects_to_export( self, context: bpy.types.Context - ) -> list[tuple[NodeGroupType, NTPObject]]: + ) -> list[NodeTreeInfo]: # TODO: this is really messy gatherer = NodeGroupGatherer() gatherer.gather_node_groups(context) @@ -297,26 +304,14 @@ def _get_objects_to_export( node_info._obj = obj node_info._group_type = group_type - for group_type, groups in gatherer.node_groups.items(): - for obj in groups: - base_tree = get_base_node_tree(obj, group_type) - self._topological_sort(base_tree) - - visited : set[NodeTreeInfo] = set() - objects_to_export : list[tuple[NodeGroupType, NTPObject]] = [] - def visit(nt_info: NodeTreeInfo): - if nt_info not in visited: - visited.add(nt_info) - for dependency in nt_info._base_tree_dependencies: - visit(self._node_trees[dependency]) - objects_to_export.append((nt_info._group_type, nt_info._obj)) + self._visited : set[bpy.types.NodeTree] = set() + self._export_order : list[NodeTreeInfo] = [] for group_type, groups in gatherer.node_groups.items(): for obj in groups: base_tree = get_base_node_tree(obj, group_type) - visit(self._node_trees[base_tree]) - - return objects_to_export + self._topological_sort(base_tree) + return self._export_order def _topological_sort( self, @@ -330,22 +325,22 @@ def _topological_sort( node_tree (NodeTree): the base node tree to convert """ group_node_type = '' + common_module = "" if isinstance(node_tree, bpy.types.CompositorNodeTree): group_node_type = 'CompositorNodeGroup' + common_module = "compositor_common" elif isinstance(node_tree, bpy.types.GeometryNodeTree): group_node_type = 'GeometryNodeGroup' + common_module = "geometry_common" elif isinstance(node_tree, bpy.types.ShaderNodeTree): group_node_type = 'ShaderNodeGroup' + common_module = "shader_common" node_info = self._node_trees[node_tree] - visited : set[bpy.types.NodeTree] = set() - result = node_info._dependencies - def dfs(nt: bpy.types.NodeTree) -> None: """ Helper function to perform depth-first search on a NodeTree - Parameters: nt (NodeTree): current node tree in the dependency graph """ @@ -357,16 +352,12 @@ def dfs(nt: bpy.types.NodeTree) -> None: ) return - if nt in self._node_trees and nt != node_tree: - nt_info = self._node_trees[nt] - if nt_info._is_base: - # don't repeat exported node groups - node_info._base_tree_dependencies.append(nt) - return - elif nt_info._module != node_info._module: - # has multiple parents, needs referenced separately - nt_info._module = "common" - + if self._mode == 'ADDON': + if nt in self._node_trees and nt != node_tree: + if self._node_trees[nt]._module != node_info._module: + # has multiple parents, needs referenced separately + self._node_trees[nt]._module = common_module + if (self._link_external_node_groups and nt.library is not None): bpy_lib_path = bpy.path.abspath(nt.library.filepath) @@ -376,7 +367,6 @@ def dfs(nt: bpy.types.NodeTree) -> None: ) datafiles_path = pathlib.Path(os.path.realpath(bpy_datafiles_path)) is_lib_essential = lib_path.is_relative_to(datafiles_path) - if is_lib_essential: relative_path = lib_path.relative_to(datafiles_path) if relative_path not in node_info._lib_dependencies: @@ -386,10 +376,12 @@ def dfs(nt: bpy.types.NodeTree) -> None: else: print(f"Library {lib_path} didn't seem essential, copying node groups") - if nt not in visited: - visited.add(nt) + if nt not in self._visited: + print(f"Visiting {nt.name}") + self._visited.add(nt) if nt not in self._node_trees: self._node_trees[nt] = NodeTreeInfo() + self._node_trees[nt]._obj = nt self._node_trees[nt]._module = node_tree.name # TODO group_nodes = [node for node in nt.nodes if node.bl_idname == group_node_type] @@ -402,10 +394,10 @@ def dfs(nt: bpy.types.NodeTree) -> None: "Are all data blocks valid?" ) continue - if node_nt not in visited: + if node_nt not in self._visited: dfs(node_nt) - result.append(nt) - + self._export_order.append(self._node_trees[nt]) + print(f"Adding {nt.name} to export order list") dfs(node_tree) def _create_menu_func(self) -> None: From 6177f14186b9bd4d36f94236b62e98b08e0fb006 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 15:35:19 -0600 Subject: [PATCH 07/28] cleanup: remove debug prints --- NodeToPython/export/ntp_operator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index b6a7c53..206034c 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -133,7 +133,6 @@ def execute(self, context: bpy.types.Context): objs_to_export = self._get_objects_to_export(context) for nt_info in objs_to_export: - print(f"Exporting {nt_info._obj.name}") if self._mode == 'ADDON': self._file.close() self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') @@ -377,7 +376,6 @@ def dfs(nt: bpy.types.NodeTree) -> None: print(f"Library {lib_path} didn't seem essential, copying node groups") if nt not in self._visited: - print(f"Visiting {nt.name}") self._visited.add(nt) if nt not in self._node_trees: self._node_trees[nt] = NodeTreeInfo() @@ -397,7 +395,6 @@ def dfs(nt: bpy.types.NodeTree) -> None: if node_nt not in self._visited: dfs(node_nt) self._export_order.append(self._node_trees[nt]) - print(f"Adding {nt.name} to export order list") dfs(node_tree) def _create_menu_func(self) -> None: From ea0ed4484bffafc901c7801bf949915df37f5390 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 16:49:51 -0600 Subject: [PATCH 08/28] fix: some addon mode export bugs --- NodeToPython/export/ntp_operator.py | 78 +++++++++++++++-------------- NodeToPython/export/ntp_options.py | 8 +-- NodeToPython/ui/addon_settings.py | 2 +- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 206034c..0431006 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -28,7 +28,8 @@ def __init__(self): self._func : str = "" self._module : str = "" self._is_base : bool = False - self._dependencies: list[bpy.types.NodeTree] = [] + self._dependencies: set[bpy.types.NodeTree] = set() + self._base_dependents: set[bpy.types.NodeTree] = set() self._base_tree_dependencies: list[bpy.types.NodeTree] = [] self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} self._obj: NTPObject = None @@ -110,17 +111,13 @@ def execute(self, context: bpy.types.Context): self._outer_indent_level = 2 self._inner_indent_level = 3 - if not self._setup_addon_directories(self.name): + if not self._setup_addon_directories(self._name): return {'CANCELLED'} self._file = open(f"{self._addon_dir}/__init__.py", 'w') - self._create_bl_info(self.name) self._create_imports() - #self._init_operator(self.obj_var, self.name) - #self._write("def execute(self, context):", 1) - elif self._mode == 'SCRIPT': self._file = StringIO("") if self._include_imports: @@ -203,7 +200,7 @@ def _setup_options(self, options: NTP_PG_Options) -> bool: #Addon elif options.mode == 'ADDON': self._dir_path = bpy.path.abspath(options.dir_path) - self._name_override = options.name_override + self._name = options.name self._description = options.description self._author_name = options.author_name self._version = options.version @@ -247,31 +244,6 @@ def _setup_addon_directories( return True - def _create_bl_info(self, name: str) -> None: - """ - Sets up the bl_info and imports the Blender API - - Parameters: - name (str): name of the add-on - """ - - self._write("bl_info = {", 0) - self._name = name - if self._name_override and self._name_override != "": - self._name = self._name_override - self._write(f"\"name\" : {str_to_py_str(self._name)},", 1) - if self._description and self._description != "": - self._write(f"\"description\" : {str_to_py_str(self._description)},", 1) - self._write(f"\"author\" : {str_to_py_str(self._author_name)},", 1) - self._write(f"\"version\" : {vec3_to_py_str(self._version)},", 1) - self._write(f"\"blender\" : {bpy.app.version},", 1) - self._write(f"\"location\" : {str_to_py_str(self._location)},", 1) - category = self._category - if category == "Custom": - category = self._custom_category - self._write(f"\"category\" : {str_to_py_str(category)},", 1) - self._write("}\n", 0) - def _create_imports(self) -> None: self._write("import bpy", 0) self._write("import mathutils", 0) @@ -297,6 +269,7 @@ def _get_objects_to_export( file = f"{clean_string(base_tree.name)}" else: file = "" + print(f"Setting object {obj.name} module to {file}") node_info._module = file node_info._is_base = True @@ -310,6 +283,29 @@ def _get_objects_to_export( for obj in groups: base_tree = get_base_node_tree(obj, group_type) self._topological_sort(base_tree) + + # Probably a better way algorithmically of handling this, + # need to move on though. Should be fast enough for reasonably sized + # node tree dependency graphs + for group_type, groups in gatherer.node_groups.items(): + common_module = "" + if group_type.is_compositor(): + common_module = "compositor_common" + elif group_type.is_geometry(): + common_module = "geometry_common" + elif group_type.is_shader(): + common_module = "shader_common" + + for obj in groups: + base_tree = get_base_node_tree(obj, group_type) + nt_info = self._node_trees[base_tree] + for dependency in nt_info._dependencies: + dependency_info = self._node_trees[dependency] + base_dependents = dependency_info._base_dependents + base_dependents.add(base_tree) + if len(base_dependents) > 1: + dependency_info._module = common_module + return self._export_order def _topological_sort( @@ -350,12 +346,15 @@ def dfs(nt: bpy.types.NodeTree) -> None: "Are all data blocks valid?" ) return - + """ if self._mode == 'ADDON': if nt in self._node_trees and nt != node_tree: if self._node_trees[nt]._module != node_info._module: - # has multiple parents, needs referenced separately - self._node_trees[nt]._module = common_module + if not self._node_trees[nt]._is_base: + # has multiple parents, needs referenced separately + print(f"Found duplicate parent! Setting {nt.name} module to {common_module}") + self._node_trees[nt]._module = common_module + """ if (self._link_external_node_groups and nt.library is not None): @@ -374,13 +373,14 @@ def dfs(nt: bpy.types.NodeTree) -> None: return else: print(f"Library {lib_path} didn't seem essential, copying node groups") - + if nt not in self._visited: self._visited.add(nt) if nt not in self._node_trees: self._node_trees[nt] = NodeTreeInfo() self._node_trees[nt]._obj = nt - self._node_trees[nt]._module = node_tree.name # TODO + self._node_trees[nt]._module = clean_string(node_tree.name) + print(f"Node tree not visited yet. Setting {nt.name} module to {self._node_trees[nt]._module}") group_nodes = [node for node in nt.nodes if node.bl_idname == group_node_type] for group_node in group_nodes: @@ -392,9 +392,11 @@ def dfs(nt: bpy.types.NodeTree) -> None: "Are all data blocks valid?" ) continue + self._node_trees[nt]._dependencies.add(node_nt) if node_nt not in self._visited: dfs(node_nt) self._export_order.append(self._node_trees[nt]) + node_info._dependencies.update(self._node_trees[nt]._dependencies) dfs(node_tree) def _create_menu_func(self) -> None: @@ -442,7 +444,7 @@ def _create_license(self) -> None: def _create_manifest(self) -> None: manifest = open(f"{self._addon_dir}/blender_manifest.toml", "w") manifest.write("schema_version = \"1.0.0\"\n\n") - idname = self._name_override.lower() #TODO: this isn't safe + idname = self._name.lower() #TODO: this isn't safe manifest.write(f"id = {str_to_py_str(idname)}\n") manifest.write(f"version = {version_to_manifest_str(self._version)}\n") diff --git a/NodeToPython/export/ntp_options.py b/NodeToPython/export/ntp_options.py index 6be3e8b..5dc959f 100644 --- a/NodeToPython/export/ntp_options.py +++ b/NodeToPython/export/ntp_options.py @@ -74,10 +74,10 @@ def __init__(self, *args, **kwargs): description="Save location if generating an add-on", default = "//" ) - name_override : bpy.props.StringProperty( - name = "Name Override", - description="Name used for the add-on's, default is node group name", - default = "" + name : bpy.props.StringProperty( + name = "Name", + description="Name used for the add-on's", + default = "My Add-on" ) description : bpy.props.StringProperty( name = "Description", diff --git a/NodeToPython/ui/addon_settings.py b/NodeToPython/ui/addon_settings.py index 6addb73..6a3ac92 100644 --- a/NodeToPython/ui/addon_settings.py +++ b/NodeToPython/ui/addon_settings.py @@ -28,7 +28,7 @@ def draw(self, context): addon_options = [ "dir_path", - "name_override", + "name", "description", "author_name", "version", From 8732ddf66920a2088a024f0f86e3e1da2a873006 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:44:08 -0600 Subject: [PATCH 09/28] refactor: use NodeTreeInfo in exporter constructor --- NodeToPython/export/compositor/exporter.py | 40 +++--------- NodeToPython/export/geometry/exporter.py | 16 ++--- NodeToPython/export/node_tree_exporter.py | 27 ++++---- NodeToPython/export/ntp_operator.py | 31 +++++---- NodeToPython/export/shader/exporter.py | 73 +++++++++------------- 5 files changed, 72 insertions(+), 115 deletions(-) diff --git a/NodeToPython/export/compositor/exporter.py b/NodeToPython/export/compositor/exporter.py index cd3a84a..8941d29 100644 --- a/NodeToPython/export/compositor/exporter.py +++ b/NodeToPython/export/compositor/exporter.py @@ -4,7 +4,7 @@ from ..node_settings import NTPNodeSetting, ST from ..node_tree_exporter import NodeTreeExporter, INDEX from ..ntp_node_tree import NTP_NodeTree -from ..ntp_operator import NTP_OT_Export +from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * SCENE = "scene" @@ -18,15 +18,15 @@ class CompositorExporter(NodeTreeExporter): def __init__( self, ntp_operator: NTP_OT_Export, - obj_name: str, - group_type: NodeGroupType + node_tree_info: NodeTreeInfo ): - if not group_type.is_compositor(): + if not node_tree_info._group_type.is_compositor(): ntp_operator.report( {'ERROR'}, - f"Cannot initialize CompositorExporter with group type {group_type}" + f"Cannot initialize CompositorExporter with group type " + f"{node_tree_info._group_type}" ) - NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + NodeTreeExporter.__init__(self, ntp_operator, node_tree_info) for name in COMP_OP_RESERVED_NAMES: self._used_vars[name] = 0 @@ -35,7 +35,7 @@ def _create_scene(self): #TODO: wrap in more general unique name util function self._write(f"# Generate unique scene name", indent_level) - self._write(f"{BASE_NAME} = {str_to_py_str(self._obj_name)}", + self._write(f"{BASE_NAME} = {str_to_py_str(self._node_tree_info._obj.name)}", indent_level) self._write(f"{END_NAME} = {BASE_NAME}", indent_level) self._write(f"if bpy.data.scenes.get({END_NAME}) is not None:", indent_level) @@ -59,27 +59,9 @@ def _create_scene(self): # NodeTreeExporter interface def _create_obj(self): - if self._group_type == NodeGroupType.SCENE: + if self._node_tree_info._group_type == NodeGroupType.SCENE: self._create_scene() - # NodeTreeExporter interface - def _set_base_node_tree(self) -> None: - if self._group_type == NodeGroupType.SCENE: - scene = bpy.data.scenes[self._obj_name] - if bpy.app.version < (5, 0, 0): - self._base_node_tree = getattr(scene, "node_tree") - else: - self._base_node_tree = scene.compositing_node_group - else: - self._base_node_tree = bpy.data.node_groups[self._obj_name] - - if self._base_node_tree is None: - #shouldn't happen - self._operator.report( - {'ERROR'}, - ("NodeToPython: This doesn't seem to be a valid compositor " - "node tree. Is Use Nodes selected?")) - # NodeTreeExporter interface def _initialize_node_tree( self, @@ -93,7 +75,7 @@ def _initialize_node_tree( self._write(f'"""Initialize {nt_name} node group"""') is_tree_base = (ntp_node_tree._node_tree == self._base_node_tree) - is_scene = self._group_type == NodeGroupType.SCENE + is_scene = self._node_tree_info._group_type == NodeGroupType.SCENE if is_tree_base and is_scene: self._write("if bpy.app.version < (5, 0, 0):") self._write(f"{ntp_node_tree._var} = {SCENE}.node_tree", @@ -191,6 +173,4 @@ def _set_color_balance_settings( return color_balance_info = self._node_settings['CompositorNodeColorBalance'] - self._node_settings['CompositorNodeColorBalance'] = color_balance_info._replace(attributes_ = lst) - - + self._node_settings['CompositorNodeColorBalance'] = color_balance_info._replace(attributes_ = lst) \ No newline at end of file diff --git a/NodeToPython/export/geometry/exporter.py b/NodeToPython/export/geometry/exporter.py index 4471e39..9988fa3 100644 --- a/NodeToPython/export/geometry/exporter.py +++ b/NodeToPython/export/geometry/exporter.py @@ -2,7 +2,7 @@ from ..node_group_gatherer import NodeGroupType from ..node_tree_exporter import NodeTreeExporter -from ..ntp_operator import NTP_OT_Export +from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * from .node_tree import NTP_GeoNodeTree, NTP_NodeTree @@ -24,15 +24,15 @@ class GeometryNodesExporter(NodeTreeExporter): def __init__( self, ntp_operator: NTP_OT_Export, - obj_name: str, - group_type: NodeGroupType + node_tree_info: NodeTreeInfo ): - if not group_type.is_geometry(): + if not node_tree_info._group_type.is_geometry(): ntp_operator.report( {'ERROR'}, - f"Cannot initialize GeometryNodesExporter with group type {group_type}" + f"Cannot initialize GeometryNodesExporter with group type " + f"{node_tree_info._group_type}" ) - NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + NodeTreeExporter.__init__(self, ntp_operator, node_tree_info) for name in GEO_OP_RESERVED_NAMES: self._used_vars[name] = 0 @@ -79,10 +79,6 @@ def _set_geo_tree_properties(self, node_tree: bpy.types.GeometryNodeTree) -> Non def _create_obj(self) -> None: pass - # NodeTreeExporter interface - def _set_base_node_tree(self) -> None: - self._base_node_tree = bpy.data.node_groups[self._obj_name] - def _initialize_ntp_node_tree( self, node_tree: bpy.types.NodeTree, diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index cf936b4..3989e02 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -9,7 +9,7 @@ from .node_settings import node_settings, ST from .ntp_node_tree import * -from .ntp_operator import NTP_OT_Export +from .ntp_operator import NTP_OT_Export, NodeTreeInfo from .utils import * BASE_DIR = "base_dir" @@ -64,14 +64,13 @@ class NodeTreeExporter(metaclass=abc.ABCMeta): def __init__( self, ntp_op: NTP_OT_Export, - obj_name: str, - group_type: NodeGroupType + node_tree_info: NodeTreeInfo ): # Operator executing the conversion self._operator : NTP_OT_Export = ntp_op - - # Name of the object to be exported - self._obj_name : str = obj_name + + # Info for the node tree being exported + self._node_tree_info : NodeTreeInfo = node_tree_info # Dictionary to keep track of variables->usage count pairs self._used_vars: dict[str, int] = {} @@ -79,18 +78,16 @@ def __init__( self._used_vars[name] = 0 # Variable name to be used for object - self._obj_var : str = self._create_var(self._obj_name) - - self._group_type = group_type + self._obj_var : str = self._create_var(self._node_tree_info._obj.name) # Class name for the operator, if it exists self._class_name : str = ( f"{self._operator.name}_OT_" - f"{clean_string(self._obj_name, lower=False)}" + f"{clean_string(self._node_tree_info._obj.name, lower=False)}" ) # Node tree this exporter is responsible for exporting - self._base_node_tree : bpy.types.NodeTree = None + self._base_node_tree : bpy.types.NodeTree = self._node_tree_info._base_tree # Dictionary to keep track of node->variable name pairs self._node_vars: dict[bpy.types.Node, str] = {} @@ -105,11 +102,9 @@ def __init__( self._node_settings = node_settings def export(self) -> None: - if self._operator._mode == 'ADDON': - self._init_operator(self._obj_var, self._obj_name) + if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: + self._init_operator(self._obj_var, self._node_tree_info._obj.name) self._write("def execute(self, context: bpy.types.Context):", 1) - - self._set_base_node_tree() self._create_obj() @@ -117,7 +112,7 @@ def export(self) -> None: self._process_node_tree(self._base_node_tree) - if self._operator._mode == 'ADDON': + if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._write("return {'FINISHED'}", self._operator._outer_indent_level) self._write("", self._operator._outer_indent_level) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 0431006..9a43c00 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -33,6 +33,7 @@ def __init__(self): self._base_tree_dependencies: list[bpy.types.NodeTree] = [] self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} self._obj: NTPObject = None + self._base_tree : bpy.types.NodeTree = None self._group_type: NodeGroupType = NodeGroupType.GEOMETRY_NODE_GROUP self._name_var : str = "" @@ -133,12 +134,20 @@ def execute(self, context: bpy.types.Context): if self._mode == 'ADDON': self._file.close() self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') + + if nt_info._is_base: + self._outer_indent_level = 2 + self._inner_indent_level = 3 + else: + self._outer_indent_level = 0 + self._inner_indent_level = 1 + if nt_info._group_type.is_compositor(): - exporter = CompositorExporter(self, nt_info._obj.name, nt_info._group_type) + exporter = CompositorExporter(self, nt_info) elif nt_info._group_type.is_geometry(): - exporter = GeometryNodesExporter(self, nt_info._obj.name, nt_info._group_type) + exporter = GeometryNodesExporter(self, nt_info) elif nt_info._group_type.is_shader(): - exporter = ShaderExporter(self, nt_info._obj.name, nt_info._group_type) + exporter = ShaderExporter(self, nt_info) else: self.report( {'ERROR'}, @@ -264,6 +273,7 @@ def _get_objects_to_export( if base_tree not in self._node_trees: self._node_trees[base_tree] = NodeTreeInfo() node_info = self._node_trees[base_tree] + node_info._base_tree = base_tree if self._mode == 'ADDON': file = f"{clean_string(base_tree.name)}" @@ -320,16 +330,12 @@ def _topological_sort( node_tree (NodeTree): the base node tree to convert """ group_node_type = '' - common_module = "" if isinstance(node_tree, bpy.types.CompositorNodeTree): group_node_type = 'CompositorNodeGroup' - common_module = "compositor_common" elif isinstance(node_tree, bpy.types.GeometryNodeTree): group_node_type = 'GeometryNodeGroup' - common_module = "geometry_common" elif isinstance(node_tree, bpy.types.ShaderNodeTree): group_node_type = 'ShaderNodeGroup' - common_module = "shader_common" node_info = self._node_trees[node_tree] @@ -346,15 +352,6 @@ def dfs(nt: bpy.types.NodeTree) -> None: "Are all data blocks valid?" ) return - """ - if self._mode == 'ADDON': - if nt in self._node_trees and nt != node_tree: - if self._node_trees[nt]._module != node_info._module: - if not self._node_trees[nt]._is_base: - # has multiple parents, needs referenced separately - print(f"Found duplicate parent! Setting {nt.name} module to {common_module}") - self._node_trees[nt]._module = common_module - """ if (self._link_external_node_groups and nt.library is not None): @@ -380,7 +377,7 @@ def dfs(nt: bpy.types.NodeTree) -> None: self._node_trees[nt] = NodeTreeInfo() self._node_trees[nt]._obj = nt self._node_trees[nt]._module = clean_string(node_tree.name) - print(f"Node tree not visited yet. Setting {nt.name} module to {self._node_trees[nt]._module}") + self._node_trees[nt]._base_tree = nt group_nodes = [node for node in nt.nodes if node.bl_idname == group_node_type] for group_node in group_nodes: diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py index dc6e723..bd2ba5e 100644 --- a/NodeToPython/export/shader/exporter.py +++ b/NodeToPython/export/shader/exporter.py @@ -2,7 +2,7 @@ from ..node_group_gatherer import NodeGroupType from ..node_tree_exporter import NodeTreeExporter -from ..ntp_operator import NTP_OT_Export +from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * from .node_tree import NTP_ShaderNodeTree, NTP_NodeTree @@ -18,15 +18,15 @@ class ShaderExporter(NodeTreeExporter): def __init__( self, ntp_operator: NTP_OT_Export, - obj_name: str, - group_type: NodeGroupType + node_tree_info: NodeTreeInfo ): - if not group_type.is_shader(): + if not node_tree_info._group_type.is_shader(): ntp_operator.report( {'ERROR'}, - f"Cannot initialize ShaderExporter with group type {group_type}" + f"Cannot initialize ShaderExporter with group type " + f"{node_tree_info._group_type}" ) - NodeTreeExporter.__init__(self, ntp_operator, obj_name, group_type) + NodeTreeExporter.__init__(self, ntp_operator, node_tree_info) for name in SHADER_OP_RESERVED_NAMES: self._used_vars[name] = 0 @@ -48,7 +48,7 @@ def _initialize_shader_node_tree(self, self._write(f'"""Initialize {nt_name} node group"""') is_base : bool = ntp_node_tree._node_tree == self._base_node_tree - is_obj : bool = self._group_type != NodeGroupType.SHADER_NODE_GROUP + is_obj : bool = self._node_tree_info._group_type.is_obj() if is_base and is_obj: self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") self._write(f"# Start with a clean node tree") @@ -61,29 +61,6 @@ def _initialize_shader_node_tree(self, f"name = {str_to_py_str(nt_name)})")) self._write("", 0) - # NodeTreeExporter interface - def _set_base_node_tree(self) -> None: - match self._group_type: - case NodeGroupType.MATERIAL: - self._obj = bpy.data.materials[self._obj_name] - case NodeGroupType.LIGHT: - self._obj = bpy.data.lights[self._obj_name] - case NodeGroupType.LINE_STYLE: - self._obj = bpy.data.linestyles[self._obj_name] - case NodeGroupType.WORLD: - self._obj = bpy.data.worlds[self._obj_name] - - if self._group_type.is_group(): - self._base_node_tree = bpy.data.node_groups[self._obj_name] - else: - self._base_node_tree = self._obj.node_tree - - if self._base_node_tree is None: - self._operator.report( - {'ERROR'}, - ("NodeToPython: Couldn't find base node tree") - ) - def _initialize_ntp_node_tree( self, node_tree: bpy.types.NodeTree, @@ -93,7 +70,7 @@ def _initialize_ntp_node_tree( # NodeTreeExporter interface def _create_obj(self): - match self._group_type: + match self._node_tree_info._group_type: case NodeGroupType.MATERIAL: self._create_material() case NodeGroupType.LIGHT: @@ -111,7 +88,7 @@ def _initialize_node_tree(self, ntp_node_tree: NTP_NodeTree) -> None: self._write(f'"""Initialize {nt_name} node group"""') is_tree_base : bool = ntp_node_tree._node_tree == self._base_node_tree - is_obj : bool = self._group_type != NodeGroupType.SHADER_NODE_GROUP + is_obj : bool = self._node_tree_info._group_type.is_obj() if is_tree_base and is_obj: self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") self._write(f"# Start with a clean node tree") @@ -127,8 +104,11 @@ def _initialize_node_tree(self, ntp_node_tree: NTP_NodeTree) -> None: def _create_material(self): indent_level = self._get_obj_creation_indent() - self._write(f"{self._obj_var} = bpy.data.materials.new(" - f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write( + f"{self._obj_var} = bpy.data.materials.new(" + f"name = {str_to_py_str(self._node_tree_info._obj.name)})", + indent_level + ) self._write("if bpy.app.version < (5, 0, 0):", indent_level) self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level + 1) @@ -137,16 +117,17 @@ def _create_material(self): def _create_light(self): indent_level = self._get_obj_creation_indent() + light_type = getattr(self._node_tree_info._obj, "type") self._write( f"{self._obj_var} = bpy.data.lights.new(" - f"name = {str_to_py_str(self._obj_name)}, " - f"type = {enum_to_py_str(self._obj.type)})", + f"name = {str_to_py_str(self._node_tree_info._obj.name)}, " + f"type = {enum_to_py_str(light_type)})", indent_level ) self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level) self._write( f"{LIGHT_OBJ} = bpy.data.objects.new(" - f"name = {str_to_py_str(self._obj.name)}, " + f"name = {str_to_py_str(self._node_tree_info._obj.name)}, " f"object_data={self._obj_var})", indent_level ) @@ -161,14 +142,22 @@ def _create_light(self): def _create_line_style(self): indent_level = self._get_obj_creation_indent() - self._write(f"{self._obj_var} = bpy.data.linestyles.new(" - f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write( + f"{self._obj_var} = bpy.data.linestyles.new(" + f"name = {str_to_py_str(self._node_tree_info._obj.name)})", + indent_level + ) self._write(f"{self._obj_var}.use_nodes = True\n", indent_level) + # TODO: other line style settings def _create_world(self): indent_level = self._get_obj_creation_indent() - self._write(f"{self._obj_var} = bpy.data.worlds.new(" - f"name = {str_to_py_str(self._obj_name)})", indent_level) + self._write( + f"{self._obj_var} = bpy.data.worlds.new(" + f"name = {str_to_py_str(self._node_tree_info._obj.name)})", + indent_level + ) self._write("if bpy.app.version < (5, 0, 0):", indent_level) - self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level + 1) \ No newline at end of file + self._write(f"{self._obj_var}.use_nodes = True\n\n", indent_level + 1) + # TODO: other world settings \ No newline at end of file From 51510386e0837c70bdb704fc10a2df02a42af78d Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:44:50 -0600 Subject: [PATCH 10/28] refactor: import modules just once at beginning of each base export --- NodeToPython/export/node_tree_exporter.py | 15 +++++++------- NodeToPython/export/ntp_operator.py | 24 ++++++++++++++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 3989e02..a18e4f0 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -159,10 +159,6 @@ def _init_operator(self, idname: str, label: str) -> None: self._write(f"bl_label = {str_to_py_str(label)}", 1) self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) - @abc.abstractmethod - def _set_base_node_tree(self) -> None: - pass - @abc.abstractmethod def _create_obj(self): pass @@ -976,16 +972,19 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: elif node_tree in self._operator._node_trees: # TODO: probably should be done similar to lib trees node_tree_info = self._operator._node_trees[node_tree] - if self._operator._mode == 'ADDON': - self._write(f"import {node_tree_info._module}") if node_tree_info._name_var == "": + print("This shouldn't happen!") self._call_node_tree_creation( node_tree, self._operator._inner_indent_level ) + if self._operator._mode == 'ADDON': + name_var = f"{node_tree_info._module}.{node_tree_info._name_var}" + else: + name_var = node_tree_info._name_var + self._write( - f"{node_var}.{attr_name} = " - f"bpy.data.node_groups[{node_tree_info._name_var}]" + f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" ) else: self._operator.report( diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 9a43c00..3484aea 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -130,6 +130,17 @@ def execute(self, context: bpy.types.Context): from .shader.exporter import ShaderExporter objs_to_export = self._get_objects_to_export(context) + + # Create files + if self._mode == 'ADDON': + for nt_info in objs_to_export: + if nt_info._is_base: + self._file.close() + self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'w') + self._create_imports() + self._import_modules(nt_info) + + # Export objects for nt_info in objs_to_export: if self._mode == 'ADDON': self._file.close() @@ -396,6 +407,17 @@ def dfs(nt: bpy.types.NodeTree) -> None: node_info._dependencies.update(self._node_trees[nt]._dependencies) dfs(node_tree) + def _import_modules(self, node_tree_info: NodeTreeInfo) -> None: + modules = set() + for dependency in node_tree_info._dependencies: + modules.add(self._node_trees[dependency]._module) + if node_tree_info._module in modules: + modules.remove(node_tree_info._module) + + for module in modules: + self._write(f"import {module}", 0) + self._write("", 0) + def _create_menu_func(self) -> None: """ Creates the menu function @@ -441,7 +463,7 @@ def _create_license(self) -> None: def _create_manifest(self) -> None: manifest = open(f"{self._addon_dir}/blender_manifest.toml", "w") manifest.write("schema_version = \"1.0.0\"\n\n") - idname = self._name.lower() #TODO: this isn't safe + idname = clean_string(self._name) manifest.write(f"id = {str_to_py_str(idname)}\n") manifest.write(f"version = {version_to_manifest_str(self._version)}\n") From 7f61c311ac5444716c5fb12710875f70cab97d44 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:47:43 -0600 Subject: [PATCH 11/28] fix: clean up generated class names --- NodeToPython/export/node_tree_exporter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index a18e4f0..8373348 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -82,7 +82,7 @@ def __init__( # Class name for the operator, if it exists self._class_name : str = ( - f"{self._operator.name}_OT_" + f"{clean_string(self._operator._name, lower=False)}_OT_" f"{clean_string(self._node_tree_info._obj.name, lower=False)}" ) @@ -154,7 +154,7 @@ def _init_operator(self, idname: str, label: str) -> None: self._write("def __init__(self, *args, **kwargs):", 1) self._write("super().__init__(*args, **kwargs)\n", 2) - idname_str = f"{clean_string(self._operator.name)}.{idname}" + idname_str = f"{clean_string(self._operator._name)}.{idname}" self._write(f"bl_idname = {str_to_py_str(idname_str)}", 1) self._write(f"bl_label = {str_to_py_str(label)}", 1) self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) From 21b90279494ad4c6b1f8536fc280ec2d53509bf2 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:51:03 -0600 Subject: [PATCH 12/28] fix: separate node tree generation from operator --- NodeToPython/export/node_tree_exporter.py | 11 ++++++----- NodeToPython/export/ntp_operator.py | 9 ++------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 8373348..8b8a354 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -102,15 +102,14 @@ def __init__( self._node_settings = node_settings def export(self) -> None: + self._import_essential_libs() + self._process_node_tree(self._base_node_tree) + if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._init_operator(self._obj_var, self._node_tree_info._obj.name) self._write("def execute(self, context: bpy.types.Context):", 1) self._create_obj() - - self._import_essential_libs() - - self._process_node_tree(self._base_node_tree) if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._write("return {'FINISHED'}", self._operator._outer_indent_level) @@ -158,6 +157,8 @@ def _init_operator(self, idname: str, label: str) -> None: self._write(f"bl_idname = {str_to_py_str(idname_str)}", 1) self._write(f"bl_label = {str_to_py_str(label)}", 1) self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) + self._operator._outer_indent_level = 2 + self._operator._inner_indent_level = 3 @abc.abstractmethod def _create_obj(self): @@ -1724,6 +1725,6 @@ def _call_node_tree_creation( if create_name_var: node_tree_info._name_var = self._create_var(f"{nt_var}_name") self._write( - f"{node_tree_info._name_var} = {nt_var}.name", + f"{node_tree_info._name_var} = {nt_var}.name\n", indent_level ) \ No newline at end of file diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 3484aea..254c0ee 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -145,13 +145,8 @@ def execute(self, context: bpy.types.Context): if self._mode == 'ADDON': self._file.close() self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') - - if nt_info._is_base: - self._outer_indent_level = 2 - self._inner_indent_level = 3 - else: - self._outer_indent_level = 0 - self._inner_indent_level = 1 + self._outer_indent_level = 0 + self._inner_indent_level = 1 if nt_info._group_type.is_compositor(): exporter = CompositorExporter(self, nt_info) From d249202bdd0427e46e6a3e99e764b471064dafa1 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:56:49 -0600 Subject: [PATCH 13/28] refactor: input socket default value --- NodeToPython/export/node_tree_exporter.py | 38 +++++++++++++---------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 8b8a354..0a9a631 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -1441,40 +1441,46 @@ def _set_input_defaults(self, node: bpy.types.Node) -> None: # TODO: this could be cleaner socket_var = f"{node_var}.inputs[{i}]" + default_val = getattr(input, "default_value") # colors if input.bl_idname == 'NodeSocketColor': - default_val = vec4_to_py_str(input.default_value) + default_val = vec4_to_py_str(default_val) # vector types elif "Vector" in input.bl_idname: if "2D" in input.bl_idname: - default_val = vec2_to_py_str(input.default_value) + default_val = vec2_to_py_str(default_val) elif "4D" in input.bl_idname: - default_val = vec4_to_py_str(input.default_value) + default_val = vec4_to_py_str(default_val) else: - default_val = vec3_to_py_str(input.default_value) + default_val = vec3_to_py_str(default_val) # rotation types elif input.bl_idname == 'NodeSocketRotation': - default_val = vec3_to_py_str(input.default_value) + default_val = vec3_to_py_str(default_val) # strings - elif input.bl_idname in {'NodeSocketString', 'NodeSocketStringFilePath'}: - default_val = str_to_py_str(input.default_value) + elif input.bl_idname in { + 'NodeSocketString', + 'NodeSocketStringFilePath' + }: + default_val = str_to_py_str(default_val) #menu elif input.bl_idname == 'NodeSocketMenu': - if input.default_value == '': + if default_val == '': continue - default_val = enum_to_py_str(input.default_value) + default_val = enum_to_py_str(default_val) # images elif input.bl_idname == 'NodeSocketImage': - img = getattr(input, "default_value") - if img is not None: - if self._operator._addon_dir != "": # write in a better way - if self._save_image(img): - self._load_image(img, f"{socket_var}.default_value") + if default_val is not None: + if self._operator._mode == 'ADDON': + if self._save_image(default_val): + self._load_image( + default_val, + f"{socket_var}.default_value" + ) else: self._in_file_inputs(input, socket_var, "images") default_val = None @@ -1500,7 +1506,7 @@ def _set_input_defaults(self, node: bpy.types.Node) -> None: default_val = None else: - default_val = input.default_value + default_val = getattr(input, "default_value") if default_val is not None: self._write(f"# {input.identifier}") @@ -1529,7 +1535,7 @@ def _set_output_defaults(self, node: bpy.types.Node) -> None: node_var = self._node_vars[node] - dv = node.outputs[0].default_value + dv = getattr(node.outputs[0], "default_value") if node.bl_idname in {'ShaderNodeRGB', 'CompositorNodeRGB'}: dv = vec4_to_py_str(list(dv)) if node.bl_idname in {'ShaderNodeNormal', 'CompositorNodeNormal'}: From 71fcb0aa8159da598d4ccdbcabff962b9675165f Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 18:03:53 -0600 Subject: [PATCH 14/28] fix: string safety --- NodeToPython/export/ntp_operator.py | 2 ++ NodeToPython/export/utils.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 254c0ee..f8716d1 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -61,6 +61,8 @@ def __init__(self, *args, **kwargs): # Class named for the generated operator self._class_name: str = "" + self._name: str = "" + # Indentation to use for the default write function self._outer_indent_level: int = 0 self._inner_indent_level: int = 1 diff --git a/NodeToPython/export/utils.py b/NodeToPython/export/utils.py index 5f84414..7ee7104 100644 --- a/NodeToPython/export/utils.py +++ b/NodeToPython/export/utils.py @@ -23,6 +23,9 @@ def clean_string(string: str, lower: bool = True) -> str: string = string.lower() string = re.sub(r"[^a-zA-Z0-9_]", '_', string) + if string == "": + string = "ntp" + if keyword.iskeyword(string): string = "_" + string elif not (string[0].isalpha() or string[0] == '_'): From 3850fcaa0b4bbb4fdd5868b0b00e200c12b22756 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:58:01 -0600 Subject: [PATCH 15/28] cleanup: remove old/unnecessary code --- NodeToPython/export/ntp_operator.py | 1 - NodeToPython/export/shader/exporter.py | 30 -------------------------- 2 files changed, 31 deletions(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index f8716d1..541d98e 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -287,7 +287,6 @@ def _get_objects_to_export( file = f"{clean_string(base_tree.name)}" else: file = "" - print(f"Setting object {obj.name} module to {file}") node_info._module = file node_info._is_base = True diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py index bd2ba5e..e14b23c 100644 --- a/NodeToPython/export/shader/exporter.py +++ b/NodeToPython/export/shader/exporter.py @@ -31,36 +31,6 @@ def __init__( for name in SHADER_OP_RESERVED_NAMES: self._used_vars[name] = 0 - def _initialize_shader_node_tree(self, - ntp_node_tree: NTP_ShaderNodeTree, - nt_name: str - ) -> None: - """ - Initialize the shader node group - - Parameters: - ntp_node_tree (NTP_ShaderNodeTree): node tree to be generated and - variable to use - nt_name (str): name to use for the node tree - """ - self._write(f"def {ntp_node_tree._var}_node_group():", - self._operator._outer_indent_level) - self._write(f'"""Initialize {nt_name} node group"""') - - is_base : bool = ntp_node_tree._node_tree == self._base_node_tree - is_obj : bool = self._node_tree_info._group_type.is_obj() - if is_base and is_obj: - self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") - self._write(f"# Start with a clean node tree") - self._write(f"for {NODE} in {ntp_node_tree._var}.nodes:") - self._write(f"{ntp_node_tree._var}.nodes.remove({NODE})", - self._operator._inner_indent_level + 1) - else: - self._write((f"{ntp_node_tree._var} = bpy.data.node_groups.new(" - f"type = \'ShaderNodeTree\', " - f"name = {str_to_py_str(nt_name)})")) - self._write("", 0) - def _initialize_ntp_node_tree( self, node_tree: bpy.types.NodeTree, From cac57ad8c39ed7457a8714d0c81fd9fa21f10b2b Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 20:58:27 -0600 Subject: [PATCH 16/28] fix: group type now correctly identifies objects --- NodeToPython/export/node_group_gatherer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NodeToPython/export/node_group_gatherer.py b/NodeToPython/export/node_group_gatherer.py index 265805f..7d90e0c 100644 --- a/NodeToPython/export/node_group_gatherer.py +++ b/NodeToPython/export/node_group_gatherer.py @@ -20,7 +20,7 @@ def is_group(self) -> bool: } def is_obj(self) -> bool: - return not self.is_group + return (not self.is_group()) def is_compositor(self) -> bool: return self in { From 5a8d0b3e31b20b8bedcd898a4af5ba9e88b01bcb Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:00:31 -0600 Subject: [PATCH 17/28] fix: object node trees --- NodeToPython/export/node_tree_exporter.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 0a9a631..dcec49a 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -103,13 +103,16 @@ def __init__( def export(self) -> None: self._import_essential_libs() - self._process_node_tree(self._base_node_tree) + if self._node_tree_info._group_type.is_group(): + self._process_node_tree(self._base_node_tree) if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._init_operator(self._obj_var, self._node_tree_info._obj.name) self._write("def execute(self, context: bpy.types.Context):", 1) - self._create_obj() + if self._node_tree_info._group_type.is_obj(): + self._create_obj() + self._process_node_tree(self._base_node_tree) if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._write("return {'FINISHED'}", self._operator._outer_indent_level) @@ -150,13 +153,14 @@ def _init_operator(self, idname: str, label: str) -> None: """ self._idname = idname self._write(f"class {self._class_name}(bpy.types.Operator):", 0) - self._write("def __init__(self, *args, **kwargs):", 1) - self._write("super().__init__(*args, **kwargs)\n", 2) idname_str = f"{clean_string(self._operator._name)}.{idname}" self._write(f"bl_idname = {str_to_py_str(idname_str)}", 1) self._write(f"bl_label = {str_to_py_str(label)}", 1) self._write("bl_options = {\'REGISTER\', \'UNDO\'}\n", 1) + + self._write("def __init__(self, *args, **kwargs):", 1) + self._write("super().__init__(*args, **kwargs)\n", 2) self._operator._outer_indent_level = 2 self._operator._inner_indent_level = 3 From fefb8c7202be952ca06190bd119aaa0539f1d65a Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:14:28 -0600 Subject: [PATCH 18/28] fix: WIP on module imports in addon mode --- NodeToPython/export/node_tree_exporter.py | 20 ++++- NodeToPython/export/ntp_operator.py | 101 +++++++++++++++------- 2 files changed, 85 insertions(+), 36 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index dcec49a..3387651 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -81,10 +81,19 @@ def __init__( self._obj_var : str = self._create_var(self._node_tree_info._obj.name) # Class name for the operator, if it exists - self._class_name : str = ( - f"{clean_string(self._operator._name, lower=False)}_OT_" - f"{clean_string(self._node_tree_info._obj.name, lower=False)}" - ) + if self._operator._mode == 'ADDON': + #TODO: probably a better spot for this + #if self._node_tree_info._module not in self._operator._modules: + # self._operator._modules[self._node_tree_info._module] = [] + + if self._node_tree_info._is_base: + self._class_name : str = ( + f"{clean_string(self._operator._name, lower=False)}_OT_" + f"{clean_string(self._node_tree_info._obj.name, lower=False)}" + ) + self._operator._modules[self._node_tree_info._module].append( + self._class_name + ) # Node tree this exporter is responsible for exporting self._base_node_tree : bpy.types.NodeTree = self._node_tree_info._base_tree @@ -1722,6 +1731,9 @@ def _call_node_tree_creation( indent_level: int, create_name_var: bool = True ) -> None: + # TODO: Blender not happy about this being called at registration time. + # Need to move inside operator execution functions. + # How to handle cases where multiple operators depend on a node group? node_tree_info = self._operator._node_trees[node_tree] if node_tree in self._node_tree_vars: nt_var = self._node_tree_vars[node_tree] diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 541d98e..1118a92 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -15,10 +15,14 @@ IMAGE_DIR_NAME = "imgs" BASE_DIR = "base_dir" +CLASS = "cls" +CLASSES = "classes" RESERVED_NAMES = { IMAGE_DIR_NAME, - BASE_DIR + BASE_DIR, + CLASS, + CLASSES } MAX_BLENDER_VERSION = (5, 1, 0) @@ -58,8 +62,8 @@ def __init__(self, *args, **kwargs): # Path to the directory for the generated addon self._addon_dir: str = "" - # Class named for the generated operator - self._class_name: str = "" + # Modules with list operators to import and register + self._modules: dict[str, list[str]] = {} self._name: str = "" @@ -116,10 +120,6 @@ def execute(self, context: bpy.types.Context): if not self._setup_addon_directories(self._name): return {'CANCELLED'} - - self._file = open(f"{self._addon_dir}/__init__.py", 'w') - - self._create_imports() elif self._mode == 'SCRIPT': self._file = StringIO("") @@ -131,19 +131,24 @@ def execute(self, context: bpy.types.Context): from .geometry.exporter import GeometryNodesExporter from .shader.exporter import ShaderExporter - objs_to_export = self._get_objects_to_export(context) + self._calculate_export_order(context) - # Create files if self._mode == 'ADDON': - for nt_info in objs_to_export: + # Create files + for module in self._modules: + self._file.close() + self._file = open(f"{self._addon_dir}/{module}.py", 'w') + self._create_imports() + + # Import dependencies + for nt_info in self._export_order: if nt_info._is_base: self._file.close() - self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'w') - self._create_imports() + self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') self._import_modules(nt_info) - + # Export objects - for nt_info in objs_to_export: + for nt_info in self._export_order: if self._mode == 'ADDON': self._file.close() self._file = open(f"{self._addon_dir}/{nt_info._module}.py", 'a') @@ -166,11 +171,11 @@ def execute(self, context: bpy.types.Context): if self._mode == 'ADDON': self._file.close() - self._file = open(f"{self._addon_dir}/__init__.py", 'a') - + self._file = open(f"{self._addon_dir}/__init__.py", 'w') + self._create_operator_module_imports() + self._create_imports() self._create_menu_func() - self._create_register_func() - self._create_unregister_func() + self._create_registration_funcs() self._create_main_func() self._create_license() if bpy.app.version >= (4, 2, 0): @@ -266,10 +271,10 @@ def _create_imports(self) -> None: self._write("import mathutils", 0) self._write("import os", 0) self._write("\n", 0) - - def _get_objects_to_export( + + def _calculate_export_order( self, context: bpy.types.Context - ) -> list[NodeTreeInfo]: + ) -> None: # TODO: this is really messy gatherer = NodeGroupGatherer() gatherer.gather_node_groups(context) @@ -284,7 +289,7 @@ def _get_objects_to_export( node_info._base_tree = base_tree if self._mode == 'ADDON': - file = f"{clean_string(base_tree.name)}" + file = f"{clean_string(obj.name)}" else: file = "" node_info._module = file @@ -316,12 +321,14 @@ def _get_objects_to_export( for obj in groups: base_tree = get_base_node_tree(obj, group_type) nt_info = self._node_trees[base_tree] + self._modules[nt_info._module] = [] for dependency in nt_info._dependencies: dependency_info = self._node_trees[dependency] base_dependents = dependency_info._base_dependents base_dependents.add(base_tree) if len(base_dependents) > 1: dependency_info._module = common_module + self._modules[dependency_info._module] = [] return self._export_order @@ -414,28 +421,58 @@ def _import_modules(self, node_tree_info: NodeTreeInfo) -> None: self._write(f"import {module}", 0) self._write("", 0) + def _create_operator_module_imports(self) -> None: + visited_modules: set[str] = set() + module_order: list[str] = [] + for nt_info in self._export_order: + if nt_info._module not in visited_modules: + visited_modules.add(nt_info._module) + module_order.append(nt_info._module) + + self._write("if \"bpy\" in locals():", 0) + self._write("import importlib", 1) + for module in module_order: + self._write(f"importlib.reload({module})", 1) + self._write("else:", 0) + for module in module_order: + self._write(f"from . import {module}", 1) + self._write("", 0) + def _create_menu_func(self) -> None: """ Creates the menu function """ self._write("def menu_func(self, context):", 0) - self._write(f"self.layout.operator({self._class_name}.bl_idname)\n", 1) + for module, classes in self._modules.items(): + for cls in classes: + self._write(f"self.layout.operator({module}.{cls}.bl_idname)", 1) + self._write("") - def _create_register_func(self) -> None: + def _create_registration_funcs(self) -> None: """ Creates the register function """ + # classes + self._write(f"{CLASSES} = [", 0) + for module, classes in self._modules.items(): + for cls in classes: + self._write(f"{module}.{cls},", 1) + self._write("]", 0) + self._write("") + + # register() self._write("def register():", 0) - self._write(f"bpy.utils.register_class({self._class_name})", 1) - self._write(f"bpy.types.{self._menu_id}.append(menu_func)\n", 1) + self._write(f"for {CLASS} in {CLASSES}:", 1) + self._write(f"bpy.utils.register_class({CLASS})", 2) + self._write(f"bpy.types.{self._menu_id}.append(menu_func)", 1) + self._write("") - def _create_unregister_func(self) -> None: - """ - Creates the unregister function - """ + # unregister() self._write("def unregister():", 0) - self._write(f"bpy.utils.unregister_class({self._class_name})", 1) - self._write(f"bpy.types.{self._menu_id}.remove(menu_func)\n", 1) + self._write(f"bpy.types.{self._menu_id}.remove(menu_func)", 1) + self._write(f"for {CLASS} in {CLASSES}:", 1) + self._write(f"bpy.utils.unregister_class({CLASS})", 2) + self._write("") def _create_main_func(self) -> None: """ From afc0152c73ee24059583406c753bed8d1751ae29 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:30:19 -0600 Subject: [PATCH 19/28] fix: move to using dictionary of node tree names instead of globals --- NodeToPython/export/compositor/exporter.py | 6 ++- NodeToPython/export/geometry/exporter.py | 7 ++- NodeToPython/export/node_tree_exporter.py | 54 +++++++++++----------- NodeToPython/export/ntp_operator.py | 15 ++++-- NodeToPython/export/shader/exporter.py | 7 ++- 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/NodeToPython/export/compositor/exporter.py b/NodeToPython/export/compositor/exporter.py index 8941d29..09aafb0 100644 --- a/NodeToPython/export/compositor/exporter.py +++ b/NodeToPython/export/compositor/exporter.py @@ -2,7 +2,7 @@ from ..node_group_gatherer import NodeGroupType from ..node_settings import NTPNodeSetting, ST -from ..node_tree_exporter import NodeTreeExporter, INDEX +from ..node_tree_exporter import NodeTreeExporter, INDEX, NODE_TREE_NAMES from ..ntp_node_tree import NTP_NodeTree from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * @@ -69,8 +69,10 @@ def _initialize_node_tree( ) -> None: nt_name = ntp_node_tree._node_tree.name + self._node_tree_info._func = f"{ntp_node_tree._var}_node_group" #initialize node group - self._write(f"def {ntp_node_tree._var}_node_group():", + self._write(f"def {self._node_tree_info._func}(" + f"{NODE_TREE_NAMES}: dict[typing.Callable, str]):", self._operator._outer_indent_level) self._write(f'"""Initialize {nt_name} node group"""') diff --git a/NodeToPython/export/geometry/exporter.py b/NodeToPython/export/geometry/exporter.py index 9988fa3..788171b 100644 --- a/NodeToPython/export/geometry/exporter.py +++ b/NodeToPython/export/geometry/exporter.py @@ -1,7 +1,7 @@ import bpy from ..node_group_gatherer import NodeGroupType -from ..node_tree_exporter import NodeTreeExporter +from ..node_tree_exporter import NodeTreeExporter, NODE_TREE_NAMES from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * @@ -92,7 +92,10 @@ def _initialize_node_tree( ntp_node_tree: NTP_NodeTree ) -> None: nt_name = ntp_node_tree._node_tree.name - self._write(f"def {ntp_node_tree._var}_node_group():", + self._node_tree_info._func = f"{ntp_node_tree._var}_node_group" + #initialize node group + self._write(f"def {self._node_tree_info._func}(" + f"{NODE_TREE_NAMES}: dict[typing.Callable, str]):", self._operator._outer_indent_level) self._write(f'"""Initialize {nt_name} node group"""') self._write(f"{ntp_node_tree._var} = bpy.data.node_groups.new(" diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 3387651..accb66f 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -1,4 +1,5 @@ import abc +import copy import pathlib import os from typing import Callable @@ -23,6 +24,7 @@ LIB_RELPATH = "lib_relpath" LIB_PATH = "lib_path" NODE = "node" +NODE_TREE_NAMES = "node_tree_names" RESERVED_NAMES = { BASE_DIR, @@ -34,7 +36,8 @@ INDEX, ITEM, LIB_RELPATH, - LIB_PATH + LIB_PATH, + NODE_TREE_NAMES } NO_DEFAULT_SOCKETS = { @@ -73,7 +76,7 @@ def __init__( self._node_tree_info : NodeTreeInfo = node_tree_info # Dictionary to keep track of variables->usage count pairs - self._used_vars: dict[str, int] = {} + self._used_vars: dict[str, int] = copy.copy(self._operator._used_vars) for name in RESERVED_NAMES: self._used_vars[name] = 0 @@ -118,12 +121,22 @@ def export(self) -> None: if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._init_operator(self._obj_var, self._node_tree_info._obj.name) self._write("def execute(self, context: bpy.types.Context):", 1) + + # node tree names + self._write("# Maps node tree creation functions to the node tree ", 2) + self._write("# name, such that we don't recreate node trees unnecessarily", 2) + self._write(f"{NODE_TREE_NAMES} : dict[typing.Callable, str] = {{}}", 2) + self._write("", 0) + + for dependency in self._node_tree_info._dependencies: + self._call_node_tree_creation(dependency, 2) if self._node_tree_info._group_type.is_obj(): self._create_obj() self._process_node_tree(self._base_node_tree) if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: + self._call_node_tree_creation(self._base_node_tree, 2) self._write("return {'FINISHED'}", self._operator._outer_indent_level) self._write("", self._operator._outer_indent_level) @@ -256,14 +269,6 @@ def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: self._init_links(node_tree) self._write(f"return {nt_var}\n") - - #create node group - node_tree_info = self._operator._node_trees[node_tree] - node_tree_info._func = f"{nt_var}_node_group()" - self._call_node_tree_creation( - node_tree, - self._operator._outer_indent_level - ) @abc.abstractmethod def _initialize_node_tree( @@ -981,21 +986,14 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: node_var = self._node_vars[node] if node_tree in self._node_tree_vars: + print("Shouldn't happen anymore?") nt_var = self._node_tree_vars[node_tree] self._write(f"{node_var}.{attr_name} = {nt_var}") elif node_tree in self._operator._node_trees: # TODO: probably should be done similar to lib trees node_tree_info = self._operator._node_trees[node_tree] - if node_tree_info._name_var == "": - print("This shouldn't happen!") - self._call_node_tree_creation( - node_tree, self._operator._inner_indent_level - ) - if self._operator._mode == 'ADDON': - name_var = f"{node_tree_info._module}.{node_tree_info._name_var}" - else: - name_var = node_tree_info._name_var + name_var = f"{NODE_TREE_NAMES}[{node_tree_info._func}]" self._write( f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" @@ -1720,17 +1718,17 @@ def _init_links(self, node_tree: bpy.types.NodeTree) -> None: ) self._write(")") - for _func in self._write_after_links: - _func() + for func in self._write_after_links: + func() self._write_after_links = [] self._write("", 0) def _call_node_tree_creation( self, node_tree: bpy.types.NodeTree, - indent_level: int, - create_name_var: bool = True + indent_level: int ) -> None: + print(f"{self._base_node_tree.name} creating node tree creation for {node_tree.name}") # TODO: Blender not happy about this being called at registration time. # Need to move inside operator execution functions. # How to handle cases where multiple operators depend on a node group? @@ -1740,13 +1738,15 @@ def _call_node_tree_creation( else: nt_var = self._create_var(f"{node_tree.name}") + if node_tree_info._module != self._node_tree_info._module: + func = f"{node_tree_info._module}.{node_tree_info._func}" + else: + func = node_tree_info._func self._write( - f"{nt_var} = {node_tree_info._func}", + f"{nt_var} = {func}({NODE_TREE_NAMES})", indent_level ) - if create_name_var: - node_tree_info._name_var = self._create_var(f"{nt_var}_name") self._write( - f"{node_tree_info._name_var} = {nt_var}.name\n", + f"{NODE_TREE_NAMES}[{func}] = {nt_var}.name\n", indent_level ) \ No newline at end of file diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 1118a92..939bb20 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -270,6 +270,7 @@ def _create_imports(self) -> None: self._write("import bpy", 0) self._write("import mathutils", 0) self._write("import os", 0) + self._write("import typing", 0) self._write("\n", 0) def _calculate_export_order( @@ -293,6 +294,10 @@ def _calculate_export_order( else: file = "" node_info._module = file + if file not in self._used_vars: + self._used_vars[file] = 0 + else: + self._used_vars[file] += 1 node_info._is_base = True node_info._obj = obj @@ -328,10 +333,10 @@ def _calculate_export_order( base_dependents.add(base_tree) if len(base_dependents) > 1: dependency_info._module = common_module + if common_module not in self._used_vars: + self._used_vars[common_module] = 0 self._modules[dependency_info._module] = [] - return self._export_order - def _topological_sort( self, node_tree: bpy.types.NodeTree @@ -418,7 +423,7 @@ def _import_modules(self, node_tree_info: NodeTreeInfo) -> None: modules.remove(node_tree_info._module) for module in modules: - self._write(f"import {module}", 0) + self._write(f"from . import {module}", 0) self._write("", 0) def _create_operator_module_imports(self) -> None: @@ -523,8 +528,8 @@ def _zip_addon(self) -> None: """ Zips up the addon and removes the directory """ - shutil.make_archive(self._zip_dir, "zip", self._zip_dir) - shutil.rmtree(self._zip_dir) + #shutil.make_archive(self._zip_dir, "zip", self._zip_dir) + #shutil.rmtree(self._zip_dir) def _report_finished(self, object: str): """ diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py index e14b23c..9f19da3 100644 --- a/NodeToPython/export/shader/exporter.py +++ b/NodeToPython/export/shader/exporter.py @@ -1,7 +1,7 @@ import bpy from ..node_group_gatherer import NodeGroupType -from ..node_tree_exporter import NodeTreeExporter +from ..node_tree_exporter import NodeTreeExporter, NODE_TREE_NAMES from ..ntp_operator import NTP_OT_Export, NodeTreeInfo from ..utils import * @@ -53,7 +53,10 @@ def _create_obj(self): # NodeTreeExporter interface def _initialize_node_tree(self, ntp_node_tree: NTP_NodeTree) -> None: nt_name = ntp_node_tree._node_tree.name - self._write(f"def {ntp_node_tree._var}_node_group():", + self._node_tree_info._func = f"{ntp_node_tree._var}_node_group" + #initialize node group + self._write(f"def {self._node_tree_info._func}(" + f"{NODE_TREE_NAMES}: dict[typing.Callable, str]):", self._operator._outer_indent_level) self._write(f'"""Initialize {nt_name} node group"""') From 4a176207b59beb97d510b2b862744e700b65eb41 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:38:59 -0600 Subject: [PATCH 20/28] fix: dependency creation order --- NodeToPython/export/node_tree_exporter.py | 13 +++++++------ NodeToPython/export/ntp_operator.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index accb66f..ad9cf15 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -985,15 +985,16 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: return node_var = self._node_vars[node] - if node_tree in self._node_tree_vars: - print("Shouldn't happen anymore?") - nt_var = self._node_tree_vars[node_tree] - self._write(f"{node_var}.{attr_name} = {nt_var}") - elif node_tree in self._operator._node_trees: + if node_tree in self._operator._node_trees: # TODO: probably should be done similar to lib trees node_tree_info = self._operator._node_trees[node_tree] - name_var = f"{NODE_TREE_NAMES}[{node_tree_info._func}]" + if node_tree_info._module == self._node_tree_info._module: + func = node_tree_info._func + else: + func = f"{node_tree_info._module}.{node_tree_info._func}" + + name_var = f"{NODE_TREE_NAMES}[{func}]" self._write( f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 939bb20..581684d 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -408,9 +408,9 @@ def dfs(nt: bpy.types.NodeTree) -> None: "Are all data blocks valid?" ) continue - self._node_trees[nt]._dependencies.add(node_nt) if node_nt not in self._visited: dfs(node_nt) + self._node_trees[nt]._dependencies.add(node_nt) self._export_order.append(self._node_trees[nt]) node_info._dependencies.update(self._node_trees[nt]._dependencies) dfs(node_tree) From c824cd9ae5019d342c3d8ffb0d9ebd9ceb9b70cf Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 11:46:32 -0600 Subject: [PATCH 21/28] fix: guarantee that dependency order is preserved --- NodeToPython/export/node_tree_exporter.py | 2 +- NodeToPython/export/ntp_operator.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index ad9cf15..61f359d 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -128,7 +128,7 @@ def export(self) -> None: self._write(f"{NODE_TREE_NAMES} : dict[typing.Callable, str] = {{}}", 2) self._write("", 0) - for dependency in self._node_tree_info._dependencies: + for dependency in self._node_tree_info._dependencies.keys(): self._call_node_tree_creation(dependency, 2) if self._node_tree_info._group_type.is_obj(): diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 581684d..15e5450 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -32,7 +32,8 @@ def __init__(self): self._func : str = "" self._module : str = "" self._is_base : bool = False - self._dependencies: set[bpy.types.NodeTree] = set() + # Dictionary acts as an ordered set + self._dependencies: dict[bpy.types.NodeTree, None] = {} self._base_dependents: set[bpy.types.NodeTree] = set() self._base_tree_dependencies: list[bpy.types.NodeTree] = [] self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} @@ -327,7 +328,7 @@ def _calculate_export_order( base_tree = get_base_node_tree(obj, group_type) nt_info = self._node_trees[base_tree] self._modules[nt_info._module] = [] - for dependency in nt_info._dependencies: + for dependency in nt_info._dependencies.keys(): dependency_info = self._node_trees[dependency] base_dependents = dependency_info._base_dependents base_dependents.add(base_tree) @@ -410,14 +411,14 @@ def dfs(nt: bpy.types.NodeTree) -> None: continue if node_nt not in self._visited: dfs(node_nt) - self._node_trees[nt]._dependencies.add(node_nt) + self._node_trees[nt]._dependencies[node_nt] = None self._export_order.append(self._node_trees[nt]) - node_info._dependencies.update(self._node_trees[nt]._dependencies) + node_info._dependencies |= self._node_trees[nt]._dependencies dfs(node_tree) def _import_modules(self, node_tree_info: NodeTreeInfo) -> None: modules = set() - for dependency in node_tree_info._dependencies: + for dependency in node_tree_info._dependencies.keys(): modules.add(self._node_trees[dependency]._module) if node_tree_info._module in modules: modules.remove(node_tree_info._module) From f15f0b164f7dd388690b01767812391e42671975 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:04:19 -0600 Subject: [PATCH 22/28] fix: script mode node tree creation and misc. errors --- NodeToPython/export/node_tree_exporter.py | 10 ++-- NodeToPython/export/ntp_operator.py | 70 ++++++++++++++++++----- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 61f359d..a33f40f 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -10,7 +10,7 @@ from .node_settings import node_settings, ST from .ntp_node_tree import * -from .ntp_operator import NTP_OT_Export, NodeTreeInfo +from .ntp_operator import NTP_OT_Export, NodeTreeInfo, NODE_TREE_NAMES from .utils import * BASE_DIR = "base_dir" @@ -24,7 +24,6 @@ LIB_RELPATH = "lib_relpath" LIB_PATH = "lib_path" NODE = "node" -NODE_TREE_NAMES = "node_tree_names" RESERVED_NAMES = { BASE_DIR, @@ -989,7 +988,8 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: # TODO: probably should be done similar to lib trees node_tree_info = self._operator._node_trees[node_tree] - if node_tree_info._module == self._node_tree_info._module: + if (self._operator._mode == 'SCRIPT' or + node_tree_info._module == self._node_tree_info._module): func = node_tree_info._func else: func = f"{node_tree_info._module}.{node_tree_info._func}" @@ -1730,9 +1730,7 @@ def _call_node_tree_creation( indent_level: int ) -> None: print(f"{self._base_node_tree.name} creating node tree creation for {node_tree.name}") - # TODO: Blender not happy about this being called at registration time. - # Need to move inside operator execution functions. - # How to handle cases where multiple operators depend on a node group? + node_tree_info = self._operator._node_trees[node_tree] if node_tree in self._node_tree_vars: nt_var = self._node_tree_vars[node_tree] diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 15e5450..9964691 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -17,12 +17,14 @@ BASE_DIR = "base_dir" CLASS = "cls" CLASSES = "classes" +NODE_TREE_NAMES = "node_tree_names" RESERVED_NAMES = { IMAGE_DIR_NAME, BASE_DIR, CLASS, - CLASSES + CLASSES, + NODE_TREE_NAMES } MAX_BLENDER_VERSION = (5, 1, 0) @@ -182,6 +184,14 @@ def execute(self, context: bpy.types.Context): if bpy.app.version >= (4, 2, 0): self._create_manifest() else: + # node tree names + self._write("if __name__ == \"__main__\":", 0) + self._write("# Maps node tree creation functions to the node tree ", 1) + self._write("# name, such that we don't recreate node trees unnecessarily", 1) + self._write(f"{NODE_TREE_NAMES} : dict[typing.Callable, str] = {{}}", 1) + self._write("", 0) + for nt_info in self._export_order: + self._call_node_tree_creation(nt_info._base_tree, 1) context.window_manager.clipboard = self._file.getvalue() self._file.close() @@ -189,6 +199,8 @@ def execute(self, context: bpy.types.Context): if self._mode == 'ADDON': self._zip_addon() + self._report_finished() + return {'FINISHED'} def _write(self, string: str, indent_level: int = -1): @@ -291,14 +303,7 @@ def _calculate_export_order( node_info._base_tree = base_tree if self._mode == 'ADDON': - file = f"{clean_string(obj.name)}" - else: - file = "" - node_info._module = file - if file not in self._used_vars: - self._used_vars[file] = 0 - else: - self._used_vars[file] += 1 + node_info._module = self._create_var(obj.name) node_info._is_base = True node_info._obj = obj @@ -525,14 +530,53 @@ def _create_manifest(self) -> None: manifest.close() + def _call_node_tree_creation( + self, + node_tree: bpy.types.NodeTree, + indent_level: int + ) -> None: + node_tree_info = self._node_trees[node_tree] + nt_var = self._create_var(f"{node_tree.name}") + + func = node_tree_info._func + self._write( + f"{nt_var} = {func}({NODE_TREE_NAMES})", + indent_level + ) + self._write( + f"{NODE_TREE_NAMES}[{func}] = {nt_var}.name\n", + indent_level + ) + + def _create_var(self, name: str) -> 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 + + Returns: + clean_name (str): variable name for the node tree + """ + if name == "": + name = "unnamed" + clean_name = clean_string(name) + var = clean_name + if var in self._used_vars: + self._used_vars[var] += 1 + return f"{clean_name}_{self._used_vars[var]}" + else: + self._used_vars[var] = 0 + return clean_name + def _zip_addon(self) -> None: """ Zips up the addon and removes the directory """ - #shutil.make_archive(self._zip_dir, "zip", self._zip_dir) - #shutil.rmtree(self._zip_dir) + shutil.make_archive(self._zip_dir, "zip", self._zip_dir) + shutil.rmtree(self._zip_dir) - def _report_finished(self, object: str): + def _report_finished(self): """ Alert user that NTP is finished @@ -544,7 +588,7 @@ def _report_finished(self, object: str): location = "clipboard" else: location = self._dir_path - self.report({'INFO'}, f"NodeToPython: Saved {object} to {location}") + self.report({'INFO'}, f"NodeToPython: Saved {self._name} to {location}") classes = [ NTP_OT_Export From 74fbb7e58278a94b0fea6fef6a7477a70a99564e Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:33:04 -0600 Subject: [PATCH 23/28] fix: bug where subtree group types went uninitialized --- NodeToPython/export/ntp_operator.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 9964691..bfcc33f 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -355,12 +355,16 @@ def _topological_sort( node_tree (NodeTree): the base node tree to convert """ group_node_type = '' + group_type = NodeGroupType.GEOMETRY_NODE_GROUP if isinstance(node_tree, bpy.types.CompositorNodeTree): group_node_type = 'CompositorNodeGroup' + group_type = NodeGroupType.COMPOSITOR_NODE_GROUP elif isinstance(node_tree, bpy.types.GeometryNodeTree): group_node_type = 'GeometryNodeGroup' + group_type = NodeGroupType.GEOMETRY_NODE_GROUP elif isinstance(node_tree, bpy.types.ShaderNodeTree): group_node_type = 'ShaderNodeGroup' + group_node_type = NodeGroupType.SHADER_NODE_GROUP node_info = self._node_trees[node_tree] @@ -403,6 +407,7 @@ def dfs(nt: bpy.types.NodeTree) -> None: self._node_trees[nt]._obj = nt self._node_trees[nt]._module = clean_string(node_tree.name) self._node_trees[nt]._base_tree = nt + self._node_trees[nt]._group_type = group_type group_nodes = [node for node in nt.nodes if node.bl_idname == group_node_type] for group_node in group_nodes: @@ -416,7 +421,10 @@ def dfs(nt: bpy.types.NodeTree) -> None: continue if node_nt not in self._visited: dfs(node_nt) - self._node_trees[nt]._dependencies[node_nt] = None + if (node_nt.library is None or + (not self._link_external_node_groups) + ): + self._node_trees[nt]._dependencies[node_nt] = None self._export_order.append(self._node_trees[nt]) node_info._dependencies |= self._node_trees[nt]._dependencies dfs(node_tree) From deac06910f9a4edfa4bfa3f8c758acc0cfce887c Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:05:56 -0600 Subject: [PATCH 24/28] fix: some library issues --- NodeToPython/export/node_tree_exporter.py | 60 ++++++++++++++++------- NodeToPython/export/ntp_operator.py | 3 +- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index a33f40f..10a5d4a 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -84,10 +84,6 @@ def __init__( # Class name for the operator, if it exists if self._operator._mode == 'ADDON': - #TODO: probably a better spot for this - #if self._node_tree_info._module not in self._operator._modules: - # self._operator._modules[self._node_tree_info._module] = [] - if self._node_tree_info._is_base: self._class_name : str = ( f"{clean_string(self._operator._name, lower=False)}_OT_" @@ -999,12 +995,31 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: self._write( f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" ) - else: - self._operator.report( - {'WARNING'}, - f"NodeToPython: Node tree dependency graph " - f"wasn't properly initialized! Couldn't find " - f"node tree {node_tree.name}") + elif node_tree in self._node_tree_vars: + # Library nodes + # TODO: not the cleanest way of doing this.... + libs = self._node_tree_info._lib_dependencies + bpy_lib_path = bpy.path.abspath(node_tree.library.filepath) + lib_path = pathlib.Path(os.path.realpath(bpy_lib_path)) + bpy_datafiles_path = bpy.path.abspath( + bpy.utils.system_resource('DATAFILES') + ) + datafiles_path = pathlib.Path(os.path.realpath(bpy_datafiles_path)) + is_lib_essential = lib_path.is_relative_to(datafiles_path) + if is_lib_essential: + relative_path = lib_path.relative_to(datafiles_path) + if relative_path in libs: + index = libs[relative_path].index(node_tree) + # TODO: probably doesn't work for multiple libraries + nt_var = f"{DATA_DST}.node_groups[{index}]" + self._write(f"{node_var}.{attr_name} = {nt_var}") + return + + self._operator.report( + {'ERROR'}, + f"NodeToPython: Node tree dependency graph " + f"wasn't properly initialized! Couldn't find " + f"node tree {node_tree.name}") def _save_image(self, img: bpy.types.Image) -> bool: """ @@ -1596,6 +1611,14 @@ def _process_zones(self, zone_input_list: list[bpy.types.Node]) -> None: if zone_input_list: self._write("", 0) + def _get_node_var( + self, + node_tree: bpy.types.NodeTree, + node: bpy.types.Node + ) -> str: + nt_var = self._node_tree_vars[node_tree] + return f"{nt_var}.nodes[{str_to_py_str(node.name)}]" + def _set_parents(self, node_tree: bpy.types.NodeTree) -> None: """ Sets parents for all nodes, mostly used to put nodes in frames @@ -1609,8 +1632,8 @@ def _set_parents(self, node_tree: bpy.types.NodeTree) -> None: if not parent_comment: self._write(f"# Set parents") parent_comment = True - node_var = self._node_vars[node] - parent_var = self._node_vars[node.parent] + node_var = self._get_node_var(node_tree, node) + parent_var = self._get_node_var(node_tree, node.parent) self._write(f"{node_var}.parent = {parent_var}") if parent_comment: self._write("", 0) @@ -1625,7 +1648,7 @@ def _set_locations(self, node_tree: bpy.types.NodeTree) -> None: self._write(f"# Set locations") for node in node_tree.nodes: - node_var = self._node_vars[node] + node_var = self._get_node_var(node_tree, node) self._write(f"{node_var}.location " f"= ({node.location.x}, {node.location.y})") if node_tree.nodes: @@ -1643,9 +1666,10 @@ def _set_dimensions(self, node_tree: bpy.types.NodeTree) -> None: self._write(f"# Set dimensions") for node in node_tree.nodes: - node_var = self._node_vars[node] - self._write(f"{node_var}.width, {node_var}.height " - f"= {node.width}, {node.height}") + node_var = self._get_node_var(node_tree, node) + self._write(f"{node_var}.width = {node.width}") + self._write(f"{node_var}.height = {node.height}") + self._write("", 0) if node_tree.nodes: self._write("", 0) @@ -1708,12 +1732,12 @@ def _init_links(self, node_tree: bpy.types.NodeTree) -> None: self._write(f"{nt_var}.links.new(") self._write( - f"{nt_var}.nodes[{str_to_py_str(link.from_node.name)}]" + f"{self._get_node_var(node_tree, link.from_node)}" f".outputs[{input_idx}],", self._operator._inner_indent_level + 1 ) self._write( - f"{nt_var}.nodes[{str_to_py_str(link.to_node.name)}]" + f"{self._get_node_var(node_tree, link.to_node)}" f".inputs[{output_idx}]", self._operator._inner_indent_level + 1 ) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index bfcc33f..02f4b65 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -364,7 +364,7 @@ def _topological_sort( group_type = NodeGroupType.GEOMETRY_NODE_GROUP elif isinstance(node_tree, bpy.types.ShaderNodeTree): group_node_type = 'ShaderNodeGroup' - group_node_type = NodeGroupType.SHADER_NODE_GROUP + group_type = NodeGroupType.SHADER_NODE_GROUP node_info = self._node_trees[node_tree] @@ -401,6 +401,7 @@ def dfs(nt: bpy.types.NodeTree) -> None: print(f"Library {lib_path} didn't seem essential, copying node groups") if nt not in self._visited: + print(f"Visiting {nt.name}") self._visited.add(nt) if nt not in self._node_trees: self._node_trees[nt] = NodeTreeInfo() From 8da80a7d64cec4dd5820dcf67e7ddc119e0de1a3 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:11:01 -0600 Subject: [PATCH 25/28] fix: more library issues --- NodeToPython/export/node_tree_exporter.py | 39 +++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 10a5d4a..7eb5919 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -24,6 +24,7 @@ LIB_RELPATH = "lib_relpath" LIB_PATH = "lib_path" NODE = "node" +NODE_GROUP = "node_group" RESERVED_NAMES = { BASE_DIR, @@ -36,7 +37,8 @@ ITEM, LIB_RELPATH, LIB_PATH, - NODE_TREE_NAMES + NODE_TREE_NAMES, + NODE_GROUP } NO_DEFAULT_SOCKETS = { @@ -109,7 +111,6 @@ def __init__( self._node_settings = node_settings def export(self) -> None: - self._import_essential_libs() if self._node_tree_info._group_type.is_group(): self._process_node_tree(self._base_node_tree) @@ -233,7 +234,9 @@ def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: Parameters: node_tree (NodeTree): node tree to be recreated - """ + """ + self._import_essential_libs() + nt_var = self._create_var(node_tree.name) self._node_tree_vars[node_tree] = nt_var @@ -995,25 +998,21 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: self._write( f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" ) + return elif node_tree in self._node_tree_vars: # Library nodes - # TODO: not the cleanest way of doing this.... - libs = self._node_tree_info._lib_dependencies - bpy_lib_path = bpy.path.abspath(node_tree.library.filepath) - lib_path = pathlib.Path(os.path.realpath(bpy_lib_path)) - bpy_datafiles_path = bpy.path.abspath( - bpy.utils.system_resource('DATAFILES') - ) - datafiles_path = pathlib.Path(os.path.realpath(bpy_datafiles_path)) - is_lib_essential = lib_path.is_relative_to(datafiles_path) - if is_lib_essential: - relative_path = lib_path.relative_to(datafiles_path) - if relative_path in libs: - index = libs[relative_path].index(node_tree) - # TODO: probably doesn't work for multiple libraries - nt_var = f"{DATA_DST}.node_groups[{index}]" - self._write(f"{node_var}.{attr_name} = {nt_var}") - return + + # Keys don't seem to be unique for linked groups, + # need to do this nonsense + self._write(f"# Finding linked library node group") + self._write(f"for {NODE_GROUP} in bpy.data.node_groups:") + self._write(f"if {NODE_GROUP}.name == {str_to_py_str(node_tree.name)}:", + self._operator._inner_indent_level + 1) + self._write(f"if {NODE_GROUP}.bl_idname == {enum_to_py_str(node_tree.bl_idname)}:", + self._operator._inner_indent_level + 2) + self._write(f"{node_var}.{attr_name} = {NODE_GROUP}", + self._operator._inner_indent_level + 3) + return self._operator.report( {'ERROR'}, From fcb3faf0ca32091d534ca081edd69d23eba969c7 Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:38:57 -0600 Subject: [PATCH 26/28] fix: object dependency node group module names --- NodeToPython/export/ntp_operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index 02f4b65..a686632 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -406,7 +406,7 @@ def dfs(nt: bpy.types.NodeTree) -> None: if nt not in self._node_trees: self._node_trees[nt] = NodeTreeInfo() self._node_trees[nt]._obj = nt - self._node_trees[nt]._module = clean_string(node_tree.name) + self._node_trees[nt]._module = clean_string(node_info._module) self._node_trees[nt]._base_tree = nt self._node_trees[nt]._group_type = group_type group_nodes = [node for node in nt.nodes From 5da4ba7dc72300ee2ff6b09b8ae86d6ce5def09e Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:39:39 -0600 Subject: [PATCH 27/28] fix: library linking in addon mode --- NodeToPython/export/node_tree_exporter.py | 36 ++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index 7eb5919..a2cdbbe 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -111,6 +111,10 @@ def __init__( self._node_settings = node_settings def export(self) -> None: + # TODO: cleanup + if self._operator._mode == 'SCRIPT': + self._import_essential_libs() + if self._node_tree_info._group_type.is_group(): self._process_node_tree(self._base_node_tree) @@ -118,6 +122,8 @@ def export(self) -> None: self._init_operator(self._obj_var, self._node_tree_info._obj.name) self._write("def execute(self, context: bpy.types.Context):", 1) + self._import_essential_libs() + # node tree names self._write("# Maps node tree creation functions to the node tree ", 2) self._write("# name, such that we don't recreate node trees unnecessarily", 2) @@ -212,11 +218,6 @@ def _import_essential_libs(self) -> None: self._write(f"\tif {name_str} in {DATA_SRC}.node_groups:") self._write(f"\t\t{DATA_DST}.node_groups.append({name_str})") # TODO: handle bad case with warning (in both script and addon mode) - - for i, node_tree in enumerate(node_trees): - nt_var = self._create_var(node_tree.name) - self._node_tree_vars[node_tree] = nt_var - self._write(f"{nt_var} = {DATA_DST}.node_groups[{i}]") self._write("\n") self._operator._inner_indent_level += 1 @@ -235,7 +236,6 @@ def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: Parameters: node_tree (NodeTree): node tree to be recreated """ - self._import_essential_libs() nt_var = self._create_var(node_tree.name) self._node_tree_vars[node_tree] = nt_var @@ -999,26 +999,28 @@ def _node_tree_settings(self, node: bpy.types.Node, attr_name: str) -> None: f"{node_var}.{attr_name} = bpy.data.node_groups[{name_var}]" ) return - elif node_tree in self._node_tree_vars: + else: # Library nodes # Keys don't seem to be unique for linked groups, # need to do this nonsense self._write(f"# Finding linked library node group") self._write(f"for {NODE_GROUP} in bpy.data.node_groups:") - self._write(f"if {NODE_GROUP}.name == {str_to_py_str(node_tree.name)}:", - self._operator._inner_indent_level + 1) - self._write(f"if {NODE_GROUP}.bl_idname == {enum_to_py_str(node_tree.bl_idname)}:", + self._write(f"if (", self._operator._inner_indent_level + 1) + self._write(f"{NODE_GROUP}.name == {str_to_py_str(node_tree.name)}", self._operator._inner_indent_level + 2) + self._write(f"and {NODE_GROUP}.bl_idname == {enum_to_py_str(node_tree.bl_idname)}", + self._operator._inner_indent_level + 2) + self._write("):", self._operator._inner_indent_level + 1) self._write(f"{node_var}.{attr_name} = {NODE_GROUP}", - self._operator._inner_indent_level + 3) + self._operator._inner_indent_level + 2) + + self._write(f"if {node_var}.{attr_name} is None:") + self._write(f"print(\"Couldn't find node group " + f"{node_tree.name}, failing\")", + self._operator._inner_indent_level + 1) + self._write(f"return", self._operator._inner_indent_level + 1) return - - self._operator.report( - {'ERROR'}, - f"NodeToPython: Node tree dependency graph " - f"wasn't properly initialized! Couldn't find " - f"node tree {node_tree.name}") def _save_image(self, img: bpy.types.Image) -> bool: """ From e319483ed2cea75a2e6a874ba809882283a364be Mon Sep 17 00:00:00 2001 From: BrendanParmer <51296046+BrendanParmer@users.noreply.github.com> Date: Sun, 16 Nov 2025 18:56:03 -0600 Subject: [PATCH 28/28] refactor: cleanup, get rid of unnecessary code --- NodeToPython/export/compositor/exporter.py | 4 +-- NodeToPython/export/node_tree_exporter.py | 40 ++++++++-------------- NodeToPython/export/ntp_operator.py | 3 -- NodeToPython/export/shader/exporter.py | 4 +-- 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/NodeToPython/export/compositor/exporter.py b/NodeToPython/export/compositor/exporter.py index 09aafb0..b23a01e 100644 --- a/NodeToPython/export/compositor/exporter.py +++ b/NodeToPython/export/compositor/exporter.py @@ -76,9 +76,7 @@ def _initialize_node_tree( self._operator._outer_indent_level) self._write(f'"""Initialize {nt_name} node group"""') - is_tree_base = (ntp_node_tree._node_tree == self._base_node_tree) - is_scene = self._node_tree_info._group_type == NodeGroupType.SCENE - if is_tree_base and is_scene: + if self._node_tree_info._group_type == NodeGroupType.SCENE: self._write("if bpy.app.version < (5, 0, 0):") self._write(f"{ntp_node_tree._var} = {SCENE}.node_tree", self._operator._inner_indent_level + 1) diff --git a/NodeToPython/export/node_tree_exporter.py b/NodeToPython/export/node_tree_exporter.py index a2cdbbe..0d01ab6 100644 --- a/NodeToPython/export/node_tree_exporter.py +++ b/NodeToPython/export/node_tree_exporter.py @@ -1,13 +1,10 @@ import abc import copy -import pathlib import os from typing import Callable import bpy -from .node_group_gatherer import NodeGroupType - from .node_settings import node_settings, ST from .ntp_node_tree import * from .ntp_operator import NTP_OT_Export, NodeTreeInfo, NODE_TREE_NAMES @@ -85,18 +82,14 @@ def __init__( self._obj_var : str = self._create_var(self._node_tree_info._obj.name) # Class name for the operator, if it exists - if self._operator._mode == 'ADDON': - if self._node_tree_info._is_base: - self._class_name : str = ( - f"{clean_string(self._operator._name, lower=False)}_OT_" - f"{clean_string(self._node_tree_info._obj.name, lower=False)}" - ) - self._operator._modules[self._node_tree_info._module].append( - self._class_name - ) - - # Node tree this exporter is responsible for exporting - self._base_node_tree : bpy.types.NodeTree = self._node_tree_info._base_tree + if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: + self._class_name : str = ( + f"{clean_string(self._operator._name, lower=False)}_OT_" + f"{clean_string(self._node_tree_info._obj.name, lower=False)}" + ) + self._operator._modules[self._node_tree_info._module].append( + self._class_name + ) # Dictionary to keep track of node->variable name pairs self._node_vars: dict[bpy.types.Node, str] = {} @@ -116,7 +109,7 @@ def export(self) -> None: self._import_essential_libs() if self._node_tree_info._group_type.is_group(): - self._process_node_tree(self._base_node_tree) + self._process_node_tree() if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: self._init_operator(self._obj_var, self._node_tree_info._obj.name) @@ -135,10 +128,10 @@ def export(self) -> None: if self._node_tree_info._group_type.is_obj(): self._create_obj() - self._process_node_tree(self._base_node_tree) + self._process_node_tree() if self._operator._mode == 'ADDON' and self._node_tree_info._is_base: - self._call_node_tree_creation(self._base_node_tree, 2) + self._call_node_tree_creation(self._node_tree_info._base_tree, 2) self._write("return {'FINISHED'}", self._operator._outer_indent_level) self._write("", self._operator._outer_indent_level) @@ -201,13 +194,12 @@ def _get_obj_creation_indent(self) -> int: return indent_level def _import_essential_libs(self) -> None: - nt_info = self._operator._node_trees[self._base_node_tree] - if len(nt_info._lib_dependencies) == 0: + if len(self._node_tree_info._lib_dependencies) == 0: return self._operator._inner_indent_level -= 1 self._write("# Import node groups from Blender essentials library") self._write(f"{DATAFILES_PATH} = bpy.utils.system_resource('DATAFILES')") - for path, node_trees in nt_info._lib_dependencies.items(): + for path, node_trees in self._node_tree_info._lib_dependencies.items(): self._write(f"{LIB_RELPATH} = {str_to_py_str(str(path))}") self._write(f"{LIB_PATH} = os.path.join({DATAFILES_PATH}, {LIB_RELPATH})") self._write(f"with bpy.data.libraries.load({LIB_PATH}, link=True) " @@ -229,14 +221,14 @@ def _initialize_ntp_node_tree( ) -> NTP_NodeTree: return NTP_NodeTree(node_tree, nt_var) - def _process_node_tree(self, node_tree: bpy.types.NodeTree) -> None: + def _process_node_tree(self) -> None: """ Generates a Python function to recreate a compositor node tree Parameters: node_tree (NodeTree): node tree to be recreated """ - + node_tree = self._node_tree_info._base_tree nt_var = self._create_var(node_tree.name) self._node_tree_vars[node_tree] = nt_var @@ -1754,8 +1746,6 @@ def _call_node_tree_creation( node_tree: bpy.types.NodeTree, indent_level: int ) -> None: - print(f"{self._base_node_tree.name} creating node tree creation for {node_tree.name}") - node_tree_info = self._operator._node_trees[node_tree] if node_tree in self._node_tree_vars: nt_var = self._node_tree_vars[node_tree] diff --git a/NodeToPython/export/ntp_operator.py b/NodeToPython/export/ntp_operator.py index a686632..97f0ad5 100644 --- a/NodeToPython/export/ntp_operator.py +++ b/NodeToPython/export/ntp_operator.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass import datetime from io import StringIO import os @@ -37,12 +36,10 @@ def __init__(self): # Dictionary acts as an ordered set self._dependencies: dict[bpy.types.NodeTree, None] = {} self._base_dependents: set[bpy.types.NodeTree] = set() - self._base_tree_dependencies: list[bpy.types.NodeTree] = [] self._lib_dependencies: dict[pathlib.Path, list[bpy.types.NodeTree]] = {} self._obj: NTPObject = None self._base_tree : bpy.types.NodeTree = None self._group_type: NodeGroupType = NodeGroupType.GEOMETRY_NODE_GROUP - self._name_var : str = "" class NTP_OT_Export(bpy.types.Operator): bl_idname = "ntp.export" diff --git a/NodeToPython/export/shader/exporter.py b/NodeToPython/export/shader/exporter.py index 9f19da3..f45a417 100644 --- a/NodeToPython/export/shader/exporter.py +++ b/NodeToPython/export/shader/exporter.py @@ -60,9 +60,7 @@ def _initialize_node_tree(self, ntp_node_tree: NTP_NodeTree) -> None: self._operator._outer_indent_level) self._write(f'"""Initialize {nt_name} node group"""') - is_tree_base : bool = ntp_node_tree._node_tree == self._base_node_tree - is_obj : bool = self._node_tree_info._group_type.is_obj() - if is_tree_base and is_obj: + if self._node_tree_info._group_type.is_obj(): self._write(f"{ntp_node_tree._var} = {self._obj_var}.node_tree\n") self._write(f"# Start with a clean node tree") self._write(f"for {NODE} in {ntp_node_tree._var}.nodes:")