From 06652d1209f8791986b8cb1a158777e3cb218fb8 Mon Sep 17 00:00:00 2001 From: Adrians Netlis Date: Tue, 26 Sep 2023 07:44:00 +0000 Subject: [PATCH 1/4] Almost all works, but something still broken on import. Haven't yet figured out the exact broken part. --- addon/Node-2-JSON/__init__.py | 436 +++++++----------- addon/Node-2-JSON/n2j_functions.py | 431 +++++++++++++++++ .../test_n2j_export_import_time.py | 6 +- tests/unit-tests/test_export.py | 6 +- tests/unit-tests/test_import.py | 12 +- 5 files changed, 597 insertions(+), 294 deletions(-) create mode 100644 addon/Node-2-JSON/n2j_functions.py diff --git a/addon/Node-2-JSON/__init__.py b/addon/Node-2-JSON/__init__.py index 2dbebba..efff5c0 100644 --- a/addon/Node-2-JSON/__init__.py +++ b/addon/Node-2-JSON/__init__.py @@ -32,247 +32,11 @@ import bpy import json -import mathutils import os from bpy.app.handlers import persistent -from bpy.props import PointerProperty +from bpy.props import PointerProperty, EnumProperty +from . n2j_functions import * -################################################################################################## -# Functions -################################################################################################## - -# Built in optional attributes associated with specific nodes -node_options = { - # Input - 'ShaderNodeAmbientOcclusion': ["samples", "inside", "only_local"], - 'ShaderNodeAttribute': ["attribute_type", "attribute_name"], - 'ShaderNodeBevel': ["samples"], - 'ShaderNodeVertexColor': ["layer_name"], - 'ShaderNodeTangent': ["direction_type", "axis", "uv_map"], - 'ShaderNodeTexCoord': ["from_instancer"], # bpy.data reliance ("object") - 'ShaderNodeUVMap': ["from_instancer", "uv_map"], - 'ShaderNodeUVMap': ["use_pixel_size"], - # Output - # 'ShaderNodeOutputAOV': [], # This is an odd one and requires more complex handling - 'ShaderNodeOutputMaterial': ["target"], - 'ShaderNodeOutputWorld': ["target"], # World - # Shader - 'ShaderNodeBsdfGlass': ["distribution"], - 'ShaderNodeBsdfGlossy': ["distribution"], - 'ShaderNodeBsdfPrincipled': ["distribution", "subsurface_method"], - 'ShaderNodeBsdfRefraction': ["distribution"], - 'ShaderNodeSubsurfaceScattering': ["falloff"], - # Texture - 'ShaderNodeTexBrick': ["offset", "offset_frequency", "squash", "squash_frequency"], - 'ShaderNodeTexEnvironment': ["interpolation", "projection"], # bpy.data reliance ("image") - 'ShaderNodeTexGradient': ["gradient_type"], - 'ShaderNodeTexIES': ["mode", "filepath"], # bpy.data_reliance ("ies"), also could break with local file paths - 'ShaderNodeTexImage': ["interpolation", "projection", "projection_blend", "extension"], # bpy.data reliance ("image") - 'ShaderNodeTexMagic': ["turbulence_depth"], - 'ShaderNodeTexMusgrave': ["musgrave_dimensions", "musgrave_type"], - 'ShaderNodeTexNoise': ["noise_dimensions"], - 'ShaderNodeTexPointDensity': - ["point_source", "space", "radius", "interpolation", "resolution", "color_source", - "vertex_color_source", "vertex_attribute_name"], # bpy.data reliance ("object", "particle_system") - 'ShaderNodeTexSky': - ["sky_type", "sun_disc", "sun_size", "sun_intensity", "sun_elevation", "sun_rotation", "altitude", - "air_density", "dust_density", "ozone_density", "sun_direction", "turbidity", "ground_albedo"], - 'ShaderNodeTexVoronoi': ["voronoi_dimensions", "feature", "distance"], - 'ShaderNodeTexWave': ["wave_type", "bands_direction", "rings_direction", "wave_profile"], - 'ShaderNodeTexWhiteNoise': ["noise_dimensions"], - # Color - 'ShaderNodeMix': ["data_type", "blend_type", "clamp_result", "clamp_factor"], - 'ShaderNodeRGBCurve': [], # bpy.data reliance ("mapping") - # Vector - 'ShaderNodeBump': ["invert"], - 'ShaderNodeDisplacement': ["space"], - 'ShaderNodeMapping': ["vector_type"], - 'ShaderNodeNormalMap': ["space", "uv_map"], - 'ShaderNodeVectorCurve': [], # bpy.data reliance ("mapping") - 'ShaderNodeVectorDisplacement': ["space"], - 'ShaderNodeVectorRotate': ["rotation_type", "invert"], - 'ShaderNodeVectorTransform': ["vector_type", "convert_from", "convert_to"], - # Converter - 'ShaderNodeClamp': ["clamp_type"], - 'ShaderNodeValToRGB': [], # bpy.data reliance ("color_ramp") - 'ShaderNodeCombineColor': ["mode"], - 'ShaderNodeFloatCurve': [], # bpy.data reliance ("mapping") - 'ShaderNodeMaprange': ["data_type", "interpolation_type", "clamp"], - 'ShaderNodeMath': ["operation", "use_clamp"], - 'ShaderNodeSeparateColor': ["mode"], - 'ShaderNodeVectorMath': ["operation"], - # Script - 'ShaderNodeScript': ["mode", "filepath"], # bpy.data reliance ("script"), also could break with local file paths - # Group - 'ShaderNodeGroup': ["node_tree"], # Special case handling for "node_tree" - 'NodeGroupInput': ["outputs"], # Special case handling for "outputs" - 'NodeGroupOutput': ["inputs"], # Special case handling for "inputs" - # Layout - 'NodeFrame': [], # bpy.data reliance ("text") -} - - -def parse_default_value(socket): - """ Look at default value of node, if present store it as a number or array depending on source type. """ - if hasattr(socket, "default_value"): - if type(socket.default_value) in (float, int, str): - return socket.default_value - else: - return [socket.default_value[i] for i in range(len(socket.default_value))] - return None - - -def generate_dict_from_node_tree(node_tree, node_groups={}, save_path=None): - """ Based on a Blender node tree, generate a Python dictionary. """ - - if node_tree is None: - return 1 # Value read by the addon code. - node_dict = {} - - # Generate base data for node - for node in node_tree.nodes: - node_dict[node.name] = { - "type": node.bl_idname, - "label": node.label, - "width": node.width, - "location": (node.location.x, node.location.y), - "node_options": {}, - "default_inputs": [], - "default_outputs": [], - "input_links": [], - "parent": None - } - - # Check if a node has a parent frame, and update the value is return True - if node.parent is not None: - node_dict[node.name]["parent"] = node.parent.name - - # Generate node options - option_template = node_options.get(node.bl_idname) - if option_template: - for option in option_template: - if option == "node_tree" and node.bl_idname == 'ShaderNodeGroup': - # This is a node group, option represents a node tree. - # Special case handling with recursive call of generate_dict_from_node_tree - node_groups[node.node_tree.name] = generate_dict_from_node_tree(node.node_tree, node_groups=node_groups) - node_dict[node.name]["node_options"][option] = node.node_tree.name # Set to reference the node group name - elif option == "inputs" and node.bl_idname == 'NodeGroupOutput': - # This is a node group output, need to add input sockets - node_dict[node.name]["node_options"][option] = [] - for input in node.inputs: - if not input.bl_idname == 'NodeSocketVirtual': - node_dict[node.name]["node_options"][option].append((input.bl_idname, input.name)) - elif option == "outputs" and node.bl_idname == 'NodeGroupInput': - # This is a node group input, need to add output sockets - node_dict[node.name]["node_options"][option] = [] - for output in node.outputs: - if not output.bl_idname == 'NodeSocketVirtual': - node_dict[node.name]["node_options"][option].append((output.bl_idname, output.name)) - else: - node_dict[node.name]["node_options"][option] = getattr(node, option) - - # Generate input and output default value stash - for input in node.inputs: - if not input.bl_idname == 'NodeSocketVirtual': - node_dict[node.name]["default_inputs"].append(parse_default_value(input)) - - for output in node.outputs: - if not output.bl_idname == 'NodeSocketVirtual': - node_dict[node.name]["default_outputs"].append(parse_default_value(output)) - - # Generate input links - for input in node.inputs: - if len(input.links) == 0: - node_dict[node.name]["input_links"].append(None) - else: - # Assume only one link per input (how it currently works in shader nodes). - # This will likely require modification for geometry nodes or future updates to shader nodes. - from_node = input.links[0].from_node - - for i in range(len(from_node.outputs)): - output = from_node.outputs[i] - if input.links[0].from_socket == output: - node_dict[node.name]["input_links"].append((from_node.name, i)) - return node_dict - - -def generate_node_tree_from_dict(node_tree, dict, node_groups={}): - """ Based on a Python dictionary, regenerate node tree. """ - - if node_tree is None: - return 1 - - # Cleanup existing node tree - for node in node_tree.nodes: - node_tree.nodes.remove(node) - - # Generate all necessary nodes - for node_name in dict: - node_data = dict[node_name] - node = node_tree.nodes.new(node_data["type"]) - node.name = node_name - node.label = node_data["label"] - node.width = node_data["width"] - - # Set all node options - for option_name, option in node_data["node_options"].items(): - if option_name == "node_tree" and node_data["type"] == 'ShaderNodeGroup': - node_tree_data = node_groups.get(option) - if node_tree_data.get(-1): - # Using a number key as a completion index since node name should't be able to be int - # Still an unelegant solution, better ideas welcome - node.node_tree = bpy.data.node_groups.get(node_tree_data.get(-1)) - else: - new_tree = bpy.data.node_groups.new(option, 'ShaderNodeTree') - generate_node_tree_from_dict(new_tree, node_tree_data, node_groups) - node.node_tree = new_tree - node_groups[option][-1] = node.node_tree.name - elif option_name == "inputs" and node_data["type"] == 'NodeGroupOutput': - if not node_tree.get("inputs_set"): - for input_data in option: - node_tree.outputs.new(input_data[0], input_data[1]) - node_tree["inputs_set"] = True - elif option_name == "outputs" and node_data["type"] == 'NodeGroupInput': - if not node_tree.get("outputs_set"): - for output_data in option: - node_tree.inputs.new(output_data[0], output_data[1]) - node_tree["outputs_set"] = True - else: - setattr(node, option_name, option) - - # Set all node default values - for i in range(len(node.inputs)): - input = node.inputs[i] - if hasattr(input, "default_value") and not node.bl_idname == 'NodeReroute': - input.default_value = node_data["default_inputs"][i] - - for i in range(len(node.outputs)): - output = node.outputs[i] - if hasattr(output, "default_value") and not node.bl_idname == 'NodeReroute': - output.default_value = node_data["default_outputs"][i] - - - # Link all nodes - for node_name in dict: - node_data = dict[node_name] - node = node_tree.nodes[node_name] - - # Determine the parent node and attach its children to it - if node_data["parent"] is not None: - parent_node = node_tree.nodes.get(node_data["parent"]) - node.parent = parent_node - - for i in range(len(node_data["input_links"])): - input_link = node_data["input_links"][i] - if input_link: - node_tree.links.new(node_tree.nodes[input_link[0]].outputs[input_link[1]], node.inputs[i]) - - # Reset the location of all nodes - for node_name in dict: - node_data = dict[node_name] - node = node_tree.nodes[node_name] - node.location = mathutils.Vector(node_data["location"]) - def _write_to_json(result_dict, directory, filename): """ Writes the generated dictionaries to a JSON file in a specifiedn directory. """ @@ -313,17 +77,27 @@ class PA_PT_Node2JSON(bpy.types.Panel): bl_category = "Node 2 JSON" def draw(self, context): - layout = self.layout - row = layout.row() - row.operator("wm.import_op", text="Import JSON File", icon = "IMPORT") - if bpy.data.scenes["Scene"].json_file_path == '': - row.enabled = False - - layout = self.layout - row = layout.row() - row.operator("wm.export_op", text="Export Blend File", icon = "EXPORT") - if bpy.data.scenes["Scene"].blend_file_path == '': - row.enabled = False + if context.scene.n2j_operation_mode == 'separate_files': + layout = self.layout + row = layout.row() + row.operator("wm.import_dict_op", text="Import JSON Files", icon="IMPORT") + + row = layout.row() + row.operator("wm.export_dict_op", text="Export JSON Files", icon="EXPORT") + + else: + # Default to single file mode + layout = self.layout + row = layout.row() + row.operator("wm.import_op", text="Import JSON File", icon = "IMPORT") + if bpy.data.scenes["Scene"].json_file_path == '': + row.enabled = False + + layout = self.layout + row = layout.row() + row.operator("wm.export_op", text="Export Blend File", icon = "EXPORT") + if bpy.data.scenes["Scene"].blend_file_path == '': + row.enabled = False @@ -338,37 +112,116 @@ class PA_PT_Node2JSONSettings(bpy.types.Panel): def draw(self, context): layout = self.layout - - row = layout.row() - row.label(text="Select Node / Node Tree") - row = layout.row() - row.operator("wm.acc_world_tree", text="Select World") - row = layout.row() - row.operator("wm.acc_active_tree", text="Select Active Material") - if (len(bpy.context.selected_objects) < 1): - row.enabled = False - row = layout.row() - row.prop(context.scene, "n2t_tree_pointer") - row = layout.row() + row.label(text="Select Addon's Operation Mode") row = layout.row() - # if bpy.data.scenes["Scene"].n2t_tree_pointer != None: - # row.label(text=f"Selected Node Tree: {bpy.context.scene.n2t_tree_pointer.id_data}") - # else: - # row.label(text=f"Selected Node Tree: {None}") + row.prop(context.scene, 'n2j_operation_mode') + + if context.scene.n2j_operation_mode == 'separate_files': + row = layout.row() + row.label(text="Select Node / Node Tree") + row = layout.row() + + row.operator("wm.acc_world_tree", text="Select World") + row = layout.row() + row.operator("wm.acc_active_tree", text="Select Active Material") + if (len(bpy.context.selected_objects) < 1): + row.enabled = False + + row = layout.row() + row.prop(context.scene, "n2j_tree_pointer") + row = layout.row() + row = layout.row() + # if bpy.data.scenes["Scene"].n2j_tree_pointer != None: + # row.label(text=f"Selected Node Tree: {bpy.context.scene.n2j_tree_pointer.id_data}") + # else: + # row.label(text=f"Selected Node Tree: {None}") + + row = layout.row() + row.label(text="Configure Directory") + row = layout.row() + row.prop(context.scene, "n2j_separate_json_directory", text="Directory for all .json files") + else: + # Default to single file mode + row = layout.row() + row.label(text="Select Node / Node Tree") + row = layout.row() + + row.operator("wm.acc_world_tree", text="Select World") + row = layout.row() + row.operator("wm.acc_active_tree", text="Select Active Material") + if (len(bpy.context.selected_objects) < 1): + row.enabled = False + + row = layout.row() + row.prop(context.scene, "n2j_tree_pointer") + row = layout.row() + row = layout.row() + # if bpy.data.scenes["Scene"].n2j_tree_pointer != None: + # row.label(text=f"Selected Node Tree: {bpy.context.scene.n2j_tree_pointer.id_data}") + # else: + # row.label(text=f"Selected Node Tree: {None}") + + row = layout.row() + row.label(text="Configure Paths") + row = layout.row() + row.prop(context.scene, "json_file_path", text="Import from .json") + + row = layout.row() + row.prop(context.scene, "blend_file_path", text="Export from .blend") + row.enabled = False - row = layout.row() - row.label(text="Configure Paths") - row = layout.row() - row.prop(context.scene, "json_file_path", text="Import from .json") + row = layout.row() + row.prop(context.scene, "checkbox_property", text="Export on Save") - row = layout.row() - row.prop(context.scene, "blend_file_path", text="Export from .blend") - row.enabled = False - row = layout.row() - row.prop(context.scene, "checkbox_property", text="Export on Save") +class PA_OT_ImportJSONDict(bpy.types.Operator): + bl_idname = "wm.import_dict_op" + bl_label = "Imports a set of JSON files from a dictionary containing node trees" + bl_description = "Imports a set of JSON files from a dictionary containing node trees" + + def execute(self, context): + directory = context.scene.n2j_separate_json_directory + current_json_file_path = os.path.join(directory, context.scene.n2j_tree_pointer.name) + if current_json_file_path == '': + self.report({'WARNING'},"Node2JSON WARNING: Current JSON filepath is set to ''. Please select a valid .json path, from which to import the Node Tree structrue.") + return {'CANCELLED'} + elif os.path.exists(current_json_file_path) == False: + self.report({'WARNING'},r"Node2JSON WARNING: Please provide a valid .json path that exists (example: C:\tmp\test.json).") # Can't be an error, cause it will cause a test to fail (test_impoty.py > test_import_no_json_path()). + return {'CANCELLED'} + elif _return_file_extension(current_json_file_path) != ".json": + self.report({'ERROR'}, r"Node2JSON ERROR: The provided json filepath does not end with a .json extention. Please provide a path to a JSON file.") + return {'CANCELLED'} + else: + try: + with open(current_json_file_path) as file: + data = json.load(file) + result = None + result = generate_node_tree_from_dict_separate_files(context.scene.n2j_tree_pointer, directory, context.scene.n2j_tree_pointer.name) + if result == 1: + self.report({"WARNING"}, "Node2JSON WARNING: No Node Tree was selected before imporint a JSON file. Please select a Node Tree to continue.") + return {'CANCELLED'} + else: + self.report({"INFO"}, f"Node2JSON INFO: Node tree has been generated succesfully.") + return {'FINISHED'} + except FileNotFoundError: + print("File not found.") + except json.JSONDecodeError: + print("Invalid JSON file.") + return {'FINISHED'} + + +class PA_OT_ExportJSONDict(bpy.types.Operator): + bl_idname = "wm.export_dict_op" + bl_label = "Exports a set of JSON files containing node trees to a dictionary" + bl_description = "Exports a set of JSON files containing node trees to a dictionary" + + def execute(self, context): + + generate_dict_from_node_tree_separate_files(context.scene.n2j_tree_pointer, context.scene.n2j_separate_json_directory) + + return {'FINISHED'} class PA_OT_ImportJSON(bpy.types.Operator): @@ -410,7 +263,7 @@ def execute(self, context): if (nodes_exist == True and node_groups_exist == True): result = None - result = generate_node_tree_from_dict(context.scene.n2t_tree_pointer, adict, node_groups=bdict) + result = generate_node_tree_from_dict(context.scene.n2j_tree_pointer, adict, node_groups=bdict) if result == 1: self.report({"WARNING"}, "Node2JSON WARNING: No Node Tree was selected before imporint a JSON file. Please select a Node Tree to continue.") return {'CANCELLED'} @@ -455,8 +308,8 @@ def execute(self, context): self.report({'ERROR'},r"Node2JSON ERROR: Please provide a valid .blend path that exists (example: C:\tmp\test.blend).") return {'CANCELLED'} else: - node_groups = {} # context.scene.n2t_tree_pointer # bpy.data.worlds['World'].node_tree - dict = generate_dict_from_node_tree(context.scene.n2t_tree_pointer, node_groups=node_groups) + node_groups = {} # context.scene.n2j_tree_pointer # bpy.data.worlds['World'].node_tree + dict = generate_dict_from_node_tree(context.scene.n2j_tree_pointer, node_groups=node_groups) if dict == 1: self.report({'WARNING'},f"Node2JSON WARNING: No Node Tree has been selected. Please, select a Node Tree to export it to JSON.") return {'CANCELLED'} @@ -479,7 +332,7 @@ class PA_OT_AccessWorldTree(bpy.types.Operator): bl_description = "Selects the world node tree as the shader nodes to be exported into JSON" def execute(self, context): - context.scene.n2t_tree_pointer = bpy.context.scene.world.node_tree + context.scene.n2j_tree_pointer = bpy.context.scene.world.node_tree self.report({'INFO'},"NODE2JSON INFO: World node tree selected to be exported.") return {'FINISHED'} @@ -493,12 +346,12 @@ def execute(self, context): if (len(bpy.context.selected_objects) > 0): if bpy.context.selected_objects[0].type == 'MESH' and bpy.context.selected_objects[0].data.materials: - context.scene.n2t_tree_pointer = bpy.context.selected_objects[0].data.materials[0].node_tree + context.scene.n2j_tree_pointer = bpy.context.selected_objects[0].data.materials[0].node_tree self.report({'INFO'},"NODE2JSON INFO: Active material node tree selected to be exported.") elif (bpy.context.selected_objects[0].type != 'CAMERA' and bpy.context.selected_objects[0].type != 'MESH') or bpy.context.selected_objects[0].type == 'LIGHT': if bpy.context.selected_objects[0].data.use_nodes == True: - context.scene.n2t_tree_pointer = bpy.context.selected_objects[0].data.materials[0].node_tree + context.scene.n2j_tree_pointer = bpy.context.selected_objects[0].data.materials[0].node_tree self.report({'INFO'},"NODE2JSON INFO: Active material node tree selected to be exported.") else: self.report({'WARNING'},f"NODE2JSON INFO: Active material {bpy.context.selected_objects[0]} has no attribute 'materials' (does not use any shader nodes).") @@ -520,11 +373,26 @@ def register(): bpy.utils.register_class(PA_PT_Node2JSONSettings) bpy.utils.register_class(PA_OT_ExportJSON) bpy.utils.register_class(PA_OT_ImportJSON) + bpy.utils.register_class(PA_OT_ExportJSONDict) + bpy.utils.register_class(PA_OT_ImportJSONDict) bpy.app.handlers.save_post.append(update_filepath_blend) - bpy.types.Scene.n2t_tree_pointer = PointerProperty( + bpy.types.Scene.n2j_operation_mode = EnumProperty( + items=[ + ('single_file', 'Single File', 'Store all node tree in a single file with main dictionary and a list of sub-dictionaries'), + ('separate_files', 'Separate Files', 'Store each node tree into separate files by their name in a given dictionary') + ], + default='single_file', + ) + bpy.types.Scene.n2j_tree_pointer = PointerProperty( name="NodeTree", type=bpy.types.NodeTree, ) + bpy.types.Scene.n2j_separate_json_directory = bpy.props.StringProperty( + name="JSON File Directory", + subtype='FILE_PATH', + description="This file has to contain a path to a dictionary with multiple JSON file structure", + default=r"-", + ) bpy.types.Scene.json_file_path = bpy.props.StringProperty( name="JSON File Path", subtype='FILE_PATH', @@ -553,8 +421,12 @@ def unregister(): bpy.utils.unregister_class(PA_PT_Node2JSONSettings) bpy.utils.unregister_class(PA_OT_ExportJSON) bpy.utils.unregister_class(PA_OT_ImportJSON) + bpy.utils.unregister_class(PA_OT_ExportJSONDict) + bpy.utils.unregister_class(PA_OT_ImportJSONDict) bpy.app.handlers.save_post.remove(update_filepath_blend) - del bpy.types.Scene.n2t_tree_pointer + del bpy.types.Scene.n2j_operation_mode + del bpy.types.Scene.n2j_tree_pointer + del bpy.types.Scene.n2j_separate_json_directory del bpy.types.Scene.json_file_path del bpy.types.Scene.blend_file_path del bpy.types.Scene.checkbox_property diff --git a/addon/Node-2-JSON/n2j_functions.py b/addon/Node-2-JSON/n2j_functions.py new file mode 100644 index 0000000..34e3772 --- /dev/null +++ b/addon/Node-2-JSON/n2j_functions.py @@ -0,0 +1,431 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# Node-2-JSON is a developer tool built for Blender, +# that can import and export shader node structure from and to .json files. +# Copyright (C) 2023 Physical Addons + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import mathutils +import os +import json + +################################################################################################## +# Functions +################################################################################################## + +# Built in optional attributes associated with specific nodes +node_options = { + # Input + 'ShaderNodeAmbientOcclusion': ["samples", "inside", "only_local"], + 'ShaderNodeAttribute': ["attribute_type", "attribute_name"], + 'ShaderNodeBevel': ["samples"], + 'ShaderNodeVertexColor': ["layer_name"], + 'ShaderNodeTangent': ["direction_type", "axis", "uv_map"], + 'ShaderNodeTexCoord': ["from_instancer"], # bpy.data reliance ("object") + 'ShaderNodeUVMap': ["from_instancer", "uv_map"], + 'ShaderNodeUVMap': ["use_pixel_size"], + # Output + # 'ShaderNodeOutputAOV': [], # This is an odd one and requires more complex handling + 'ShaderNodeOutputMaterial': ["target"], + 'ShaderNodeOutputWorld': ["target"], # World + # Shader + 'ShaderNodeBsdfGlass': ["distribution"], + 'ShaderNodeBsdfGlossy': ["distribution"], + 'ShaderNodeBsdfPrincipled': ["distribution", "subsurface_method"], + 'ShaderNodeBsdfRefraction': ["distribution"], + 'ShaderNodeSubsurfaceScattering': ["falloff"], + # Texture + 'ShaderNodeTexBrick': ["offset", "offset_frequency", "squash", "squash_frequency"], + 'ShaderNodeTexEnvironment': ["interpolation", "projection"], # bpy.data reliance ("image") + 'ShaderNodeTexGradient': ["gradient_type"], + 'ShaderNodeTexIES': ["mode", "filepath"], # bpy.data_reliance ("ies"), also could break with local file paths + 'ShaderNodeTexImage': ["interpolation", "projection", "projection_blend", "extension"], # bpy.data reliance ("image") + 'ShaderNodeTexMagic': ["turbulence_depth"], + 'ShaderNodeTexMusgrave': ["musgrave_dimensions", "musgrave_type"], + 'ShaderNodeTexNoise': ["noise_dimensions"], + 'ShaderNodeTexPointDensity': + ["point_source", "space", "radius", "interpolation", "resolution", "color_source", + "vertex_color_source", "vertex_attribute_name"], # bpy.data reliance ("object", "particle_system") + 'ShaderNodeTexSky': + ["sky_type", "sun_disc", "sun_size", "sun_intensity", "sun_elevation", "sun_rotation", "altitude", + "air_density", "dust_density", "ozone_density", "sun_direction", "turbidity", "ground_albedo"], + 'ShaderNodeTexVoronoi': ["voronoi_dimensions", "feature", "distance"], + 'ShaderNodeTexWave': ["wave_type", "bands_direction", "rings_direction", "wave_profile"], + 'ShaderNodeTexWhiteNoise': ["noise_dimensions"], + # Color + 'ShaderNodeMix': ["data_type", "blend_type", "clamp_result", "clamp_factor"], + 'ShaderNodeRGBCurve': [], # bpy.data reliance ("mapping") + # Vector + 'ShaderNodeBump': ["invert"], + 'ShaderNodeDisplacement': ["space"], + 'ShaderNodeMapping': ["vector_type"], + 'ShaderNodeNormalMap': ["space", "uv_map"], + 'ShaderNodeVectorCurve': [], # bpy.data reliance ("mapping") + 'ShaderNodeVectorDisplacement': ["space"], + 'ShaderNodeVectorRotate': ["rotation_type", "invert"], + 'ShaderNodeVectorTransform': ["vector_type", "convert_from", "convert_to"], + # Converter + 'ShaderNodeClamp': ["clamp_type"], + 'ShaderNodeValToRGB': [], # bpy.data reliance ("color_ramp") + 'ShaderNodeCombineColor': ["mode"], + 'ShaderNodeFloatCurve': [], # bpy.data reliance ("mapping") + 'ShaderNodeMaprange': ["data_type", "interpolation_type", "clamp"], + 'ShaderNodeMath': ["operation", "use_clamp"], + 'ShaderNodeSeparateColor': ["mode"], + 'ShaderNodeVectorMath': ["operation"], + # Script + 'ShaderNodeScript': ["mode", "filepath"], # bpy.data reliance ("script"), also could break with local file paths + # Group + 'ShaderNodeGroup': ["node_tree"], # Special case handling for "node_tree" + 'NodeGroupInput': ["outputs"], # Special case handling for "outputs" + 'NodeGroupOutput': ["inputs"], # Special case handling for "inputs" + # Layout + 'NodeFrame': [], # bpy.data reliance ("text") +} + + +def parse_default_value(socket): + """ Look at default value of node, if present store it as a number or array depending on source type. """ + if hasattr(socket, "default_value"): + if type(socket.default_value) in (float, int, str): + return socket.default_value + else: + return [socket.default_value[i] for i in range(len(socket.default_value))] + return None + + +def generate_dict_from_node_tree_separate_files(node_tree, directory): + """ Based on a Blender node tree, generate a set of Python dictionaries in a file structure. """ + + if node_tree is None: + return 1 # Value read by the addon code. + node_dict = {} + + # Generate base data for node + for node in node_tree.nodes: + node_dict[node.name] = { + "type": node.bl_idname, + "label": node.label, + "width": node.width, + "location": (node.location.x, node.location.y), + "node_options": {}, + "default_inputs": [], + "default_outputs": [], + "input_links": [], + "parent": None + } + + # Check if a node has a parent frame, and update the value is return True + if node.parent is not None: + node_dict[node.name]["parent"] = node.parent.name + + # Generate node options + option_template = node_options.get(node.bl_idname) + if option_template: + for option in option_template: + if option == "node_tree" and node.bl_idname == 'ShaderNodeGroup': + # This is a node group, option represents a node tree. + # Special case handling with recursive call of generate_dict_from_node_tree + generate_dict_from_node_tree_separate_files(node.node_tree, directory) + node_dict[node.name]["node_options"][option] = node.node_tree.name # Set to reference the node group name + elif option == "inputs" and node.bl_idname == 'NodeGroupOutput': + # This is a node group output, need to add input sockets + node_dict[node.name]["node_options"][option] = [] + for input in node.inputs: + if not input.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["node_options"][option].append((input.bl_idname, input.name)) + elif option == "outputs" and node.bl_idname == 'NodeGroupInput': + # This is a node group input, need to add output sockets + node_dict[node.name]["node_options"][option] = [] + for output in node.outputs: + if not output.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["node_options"][option].append((output.bl_idname, output.name)) + else: + node_dict[node.name]["node_options"][option] = getattr(node, option) + + # Generate input and output default value stash + for input in node.inputs: + if not input.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["default_inputs"].append(parse_default_value(input)) + + for output in node.outputs: + if not output.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["default_outputs"].append(parse_default_value(output)) + + # Generate input links + for input in node.inputs: + if len(input.links) == 0: + node_dict[node.name]["input_links"].append(None) + else: + # Assume only one link per input (how it currently works in shader nodes). + # This will likely require modification for geometry nodes or future updates to shader nodes. + from_node = input.links[0].from_node + + for i in range(len(from_node.outputs)): + output = from_node.outputs[i] + if input.links[0].from_socket == output: + node_dict[node.name]["input_links"].append((from_node.name, i)) + + node_tree_folders = node_tree.name.split('/') + file_name = node_tree_folders[-1] + full_directory = directory + for i in range(len(node_tree_folders) - 1): + full_directory = os.path.join(full_directory, node_tree_folders[i]) + + full_path = os.path.join(full_directory, file_name) + os.makedirs(full_directory, exist_ok=True) + + with open(full_path, 'w') as json_file: + json.dump(node_dict, json_file, indent=4) + + +def generate_node_tree_from_dict_separate_files(node_tree, directory, file_path): + """ Based on a Python dictionary in a file structure, regenerate node tree. """ + + if node_tree is None: + return 1 + + # Open the base node tree file + full_path = os.path.join(directory, file_path) + with open(full_path) as json_data: + data = json.load(json_data) + + # Cleanup existing node tree + for node in node_tree.nodes: + node_tree.nodes.remove(node) + + # Generate all necessary nodes + nodes = data#.get('nodes') + for node_name in nodes: + node_data = nodes[node_name] + node = node_tree.nodes.new(node_data["type"]) + node.name = node_name + node.label = node_data["label"] + node.width = node_data["width"] + + # Set all node options + for option_name, option in node_data["node_options"].items(): + if option_name == "node_tree" and node_data["type"] == 'ShaderNodeGroup': + # TODO Handling of hidden groups with `. PSA_` appendix + group_data = bpy.data.node_groups.get(option) + if group_data: + # Given node group has already been generated, use existing group + node.node_tree = group_data + else: + # The group hasn't been generated yet, need to generate it here + new_tree = bpy.data.node_groups.new(option, 'ShaderNodeTree') + generate_node_tree_from_dict_separate_files(new_tree, directory, option) + node.node_tree = new_tree + elif option_name == "inputs" and node_data["type"] == 'NodeGroupOutput': + if not node_tree.get("inputs_set"): + for input_data in option: + node_tree.outputs.new(input_data[0], input_data[1]) + node_tree["inputs_set"] = True + elif option_name == "outputs" and node_data["type"] == 'NodeGroupInput': + if not node_tree.get("outputs_set"): + for output_data in option: + node_tree.inputs.new(output_data[0], output_data[1]) + node_tree["outputs_set"] = True + else: + setattr(node, option_name, option) + + # Set all node default values + for i in range(len(node.inputs)): + input = node.inputs[i] + if hasattr(input, "default_value") and not node.bl_idname == 'NodeReroute': + input.default_value = node_data["default_inputs"][i] + + for i in range(len(node.outputs)): + output = node.outputs[i] + if hasattr(output, "default_value") and not node.bl_idname == 'NodeReroute': + output.default_value = node_data["default_outputs"][i] + + + # Link all nodes + for node_name in nodes: + node_data = nodes[node_name] + node = node_tree.nodes[node_name] + + # Determine the parent node and attach its children to it + if node_data["parent"] is not None: + parent_node = node_tree.nodes.get(node_data["parent"]) + node.parent = parent_node + + for i in range(len(node_data["input_links"])): + input_link = node_data["input_links"][i] + if input_link: + node_tree.links.new(node_tree.nodes[input_link[0]].outputs[input_link[1]], node.inputs[i]) + + # Reset the location of all nodes + for node_name in nodes: + node_data = nodes[node_name] + node = node_tree.nodes[node_name] + node.location = mathutils.Vector(node_data["location"]) + + # Set the node tree name to file path to make sure it exports properly + node_tree.name = file_path + + +def generate_dict_from_node_tree(node_tree, node_groups={}, save_path=None): + """ Based on a Blender node tree, generate a Python dictionary. """ + + if node_tree is None: + return 1 # Value read by the addon code. + node_dict = {} + + # Generate base data for node + for node in node_tree.nodes: + node_dict[node.name] = { + "type": node.bl_idname, + "label": node.label, + "width": node.width, + "location": (node.location.x, node.location.y), + "node_options": {}, + "default_inputs": [], + "default_outputs": [], + "input_links": [], + "parent": None + } + + # Check if a node has a parent frame, and update the value is return True + if node.parent is not None: + node_dict[node.name]["parent"] = node.parent.name + + # Generate node options + option_template = node_options.get(node.bl_idname) + if option_template: + for option in option_template: + if option == "node_tree" and node.bl_idname == 'ShaderNodeGroup': + # This is a node group, option represents a node tree. + # Special case handling with recursive call of generate_dict_from_node_tree + node_groups[node.node_tree.name] = generate_dict_from_node_tree(node.node_tree, node_groups=node_groups) + node_dict[node.name]["node_options"][option] = node.node_tree.name # Set to reference the node group name + elif option == "inputs" and node.bl_idname == 'NodeGroupOutput': + # This is a node group output, need to add input sockets + node_dict[node.name]["node_options"][option] = [] + for input in node.inputs: + if not input.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["node_options"][option].append((input.bl_idname, input.name)) + elif option == "outputs" and node.bl_idname == 'NodeGroupInput': + # This is a node group input, need to add output sockets + node_dict[node.name]["node_options"][option] = [] + for output in node.outputs: + if not output.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["node_options"][option].append((output.bl_idname, output.name)) + else: + node_dict[node.name]["node_options"][option] = getattr(node, option) + + # Generate input and output default value stash + for input in node.inputs: + if not input.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["default_inputs"].append(parse_default_value(input)) + + for output in node.outputs: + if not output.bl_idname == 'NodeSocketVirtual': + node_dict[node.name]["default_outputs"].append(parse_default_value(output)) + + # Generate input links + for input in node.inputs: + if len(input.links) == 0: + node_dict[node.name]["input_links"].append(None) + else: + # Assume only one link per input (how it currently works in shader nodes). + # This will likely require modification for geometry nodes or future updates to shader nodes. + from_node = input.links[0].from_node + + for i in range(len(from_node.outputs)): + output = from_node.outputs[i] + if input.links[0].from_socket == output: + node_dict[node.name]["input_links"].append((from_node.name, i)) + return node_dict + + +def generate_node_tree_from_dict(node_tree, dict, node_groups={}): + """ Based on a Python dictionary, regenerate node tree. """ + + if node_tree is None: + return 1 + + # Cleanup existing node tree + for node in node_tree.nodes: + node_tree.nodes.remove(node) + + # Generate all necessary nodes + for node_name in dict: + node_data = dict[node_name] + node = node_tree.nodes.new(node_data["type"]) + node.name = node_name + node.label = node_data["label"] + node.width = node_data["width"] + + # Set all node options + for option_name, option in node_data["node_options"].items(): + if option_name == "node_tree" and node_data["type"] == 'ShaderNodeGroup': + node_tree_data = node_groups.get(option) + if node_tree_data.get(-1): + # Using a number key as a completion index since node name should't be able to be int + # Still an unelegant solution, better ideas welcome + node.node_tree = bpy.data.node_groups.get(node_tree_data.get(-1)) + else: + new_tree = bpy.data.node_groups.new(option, 'ShaderNodeTree') + generate_node_tree_from_dict(new_tree, node_tree_data, node_groups) + node.node_tree = new_tree + node_groups[option][-1] = node.node_tree.name + elif option_name == "inputs" and node_data["type"] == 'NodeGroupOutput': + if not node_tree.get("inputs_set"): + for input_data in option: + node_tree.outputs.new(input_data[0], input_data[1]) + node_tree["inputs_set"] = True + elif option_name == "outputs" and node_data["type"] == 'NodeGroupInput': + if not node_tree.get("outputs_set"): + for output_data in option: + node_tree.inputs.new(output_data[0], output_data[1]) + node_tree["outputs_set"] = True + else: + setattr(node, option_name, option) + + # Set all node default values + for i in range(len(node.inputs)): + input = node.inputs[i] + if hasattr(input, "default_value") and not node.bl_idname == 'NodeReroute': + input.default_value = node_data["default_inputs"][i] + + for i in range(len(node.outputs)): + output = node.outputs[i] + if hasattr(output, "default_value") and not node.bl_idname == 'NodeReroute': + output.default_value = node_data["default_outputs"][i] + + + # Link all nodes + for node_name in dict: + node_data = dict[node_name] + node = node_tree.nodes[node_name] + + # Determine the parent node and attach its children to it + if node_data["parent"] is not None: + parent_node = node_tree.nodes.get(node_data["parent"]) + node.parent = parent_node + + for i in range(len(node_data["input_links"])): + input_link = node_data["input_links"][i] + if input_link: + node_tree.links.new(node_tree.nodes[input_link[0]].outputs[input_link[1]], node.inputs[i]) + + # Reset the location of all nodes + for node_name in dict: + node_data = dict[node_name] + node = node_tree.nodes[node_name] + node.location = mathutils.Vector(node_data["location"]) \ No newline at end of file diff --git a/tests/unit-tests/performance_tests/test_n2j_export_import_time.py b/tests/unit-tests/performance_tests/test_n2j_export_import_time.py index d7d57ac..006e091 100644 --- a/tests/unit-tests/performance_tests/test_n2j_export_import_time.py +++ b/tests/unit-tests/performance_tests/test_n2j_export_import_time.py @@ -36,7 +36,7 @@ def test_import_execution_time_with_PSA(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(blend_file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -44,7 +44,7 @@ def test_import_execution_time_with_PSA(): # ASSIGN VALUES json_file_path = Path(__file__).resolve().parent.parent / "tmp" / "test.json" bpy.data.scenes["Scene"].json_file_path = str(json_file_path) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT STARTING_TIME = time.time() @@ -66,7 +66,7 @@ def test_export_execution_time_with_PSA(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT STARTING_TIME = time.time() diff --git a/tests/unit-tests/test_export.py b/tests/unit-tests/test_export.py index 72b240b..71c9554 100644 --- a/tests/unit-tests/test_export.py +++ b/tests/unit-tests/test_export.py @@ -50,7 +50,7 @@ def test_export_active_material(): bpy.ops.wm.save_as_mainfile(filepath=str(file_path)) # ASSIGN VALUES - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.materials['Material'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.materials['Material'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -69,7 +69,7 @@ def test_export_world(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -88,7 +88,7 @@ def test_export_no_node_tree(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = None + bpy.data.scenes["Scene"].n2j_tree_pointer = None # ASSERT assert (bpy.ops.wm.export_op() == {'CANCELLED'}) diff --git a/tests/unit-tests/test_import.py b/tests/unit-tests/test_import.py index ceaafac..18bc894 100644 --- a/tests/unit-tests/test_import.py +++ b/tests/unit-tests/test_import.py @@ -48,7 +48,7 @@ def test_import_for_active_material(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(blend_file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.materials['Material'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.materials['Material'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -56,7 +56,7 @@ def test_import_for_active_material(): # ASSIGN VALUES json_file_path = Path(__file__).resolve().parent / "tmp" / "test.json" bpy.data.scenes["Scene"].json_file_path = str(json_file_path) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.materials['Material'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.materials['Material'].node_tree # ASSERT assert bpy.ops.wm.import_op() == {'FINISHED'} @@ -75,7 +75,7 @@ def test_import_for_world(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(blend_file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -83,7 +83,7 @@ def test_import_for_world(): # ASSIGN VALUES json_file_path = Path(__file__).resolve().parent / "tmp" / "test.json" bpy.data.scenes["Scene"].json_file_path = str(json_file_path) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT assert bpy.ops.wm.import_op() == {'FINISHED'} @@ -101,7 +101,7 @@ def test_import_no_node_tree(): # Create a blend file inside tmp bpy.ops.wm.save_as_mainfile(filepath=str(file_path)) - bpy.data.scenes["Scene"].n2t_tree_pointer = bpy.data.worlds['World'].node_tree + bpy.data.scenes["Scene"].n2j_tree_pointer = bpy.data.worlds['World'].node_tree # ASSERT assert bpy.ops.wm.export_op() == {'FINISHED'} # Should work on its own. @@ -109,7 +109,7 @@ def test_import_no_node_tree(): # ASSIGN VALUES json_file_path = Path(__file__).resolve().parent / "tmp" / "test.json" bpy.data.scenes["Scene"].json_file_path = str(json_file_path) - bpy.data.scenes["Scene"].n2t_tree_pointer = None + bpy.data.scenes["Scene"].n2j_tree_pointer = None # ASSERT assert (bpy.ops.wm.import_op() == {'CANCELLED'}) From df6482b81d6fa1f7c1c4a75b4b6d99cf7bc9ebd3 Mon Sep 17 00:00:00 2001 From: Adrians Netlis Date: Tue, 26 Sep 2023 10:26:57 +0000 Subject: [PATCH 2/4] Fixes the generation, but breaks main node tree for unknown reason. --- addon/Node-2-JSON/n2j_functions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/addon/Node-2-JSON/n2j_functions.py b/addon/Node-2-JSON/n2j_functions.py index 34e3772..0998eff 100644 --- a/addon/Node-2-JSON/n2j_functions.py +++ b/addon/Node-2-JSON/n2j_functions.py @@ -206,6 +206,8 @@ def generate_node_tree_from_dict_separate_files(node_tree, directory, file_path) # Cleanup existing node tree for node in node_tree.nodes: node_tree.nodes.remove(node) + node_tree.outputs.clear() + node_tree.inputs.clear() # Generate all necessary nodes nodes = data#.get('nodes') From 288b86b35131180cf3e2fb8603105e216acc1529 Mon Sep 17 00:00:00 2001 From: Adrians Netlis Date: Tue, 26 Sep 2023 11:08:04 +0000 Subject: [PATCH 3/4] This works now. --- addon/Node-2-JSON/n2j_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addon/Node-2-JSON/n2j_functions.py b/addon/Node-2-JSON/n2j_functions.py index 0998eff..d4ba077 100644 --- a/addon/Node-2-JSON/n2j_functions.py +++ b/addon/Node-2-JSON/n2j_functions.py @@ -206,8 +206,8 @@ def generate_node_tree_from_dict_separate_files(node_tree, directory, file_path) # Cleanup existing node tree for node in node_tree.nodes: node_tree.nodes.remove(node) - node_tree.outputs.clear() - node_tree.inputs.clear() + #node_tree.outputs.clear() + #node_tree.inputs.clear() # Generate all necessary nodes nodes = data#.get('nodes') From ce86dbfc90a1a64850027323f6d28ecb36833c08 Mon Sep 17 00:00:00 2001 From: Adrians Netlis Date: Tue, 26 Sep 2023 11:26:47 +0000 Subject: [PATCH 4/4] Reset inputs/outputs set flag to ensure they get generated. --- addon/Node-2-JSON/n2j_functions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/addon/Node-2-JSON/n2j_functions.py b/addon/Node-2-JSON/n2j_functions.py index d4ba077..bd06a71 100644 --- a/addon/Node-2-JSON/n2j_functions.py +++ b/addon/Node-2-JSON/n2j_functions.py @@ -206,8 +206,12 @@ def generate_node_tree_from_dict_separate_files(node_tree, directory, file_path) # Cleanup existing node tree for node in node_tree.nodes: node_tree.nodes.remove(node) - #node_tree.outputs.clear() - #node_tree.inputs.clear() + node_tree.outputs.clear() + node_tree.inputs.clear() + if node_tree.get("inputs_set"): + del node_tree["inputs_set"] + if node_tree.get("outputs_set"): + del node_tree["outputs_set"] # Generate all necessary nodes nodes = data#.get('nodes')