After some experimenting, I couldn't find any way of doing this with bpy.data or with the operator for making a group, so I decided to write my own:
This operator takes in the name of the node group that should be created and then moves all selected nodes from the actives object active material in a node group with that name. It also copies all the links between the nodes and the property values. It doesn't just copy the input values but also dropdowns and similar options.
Here's the code:
class NODE_OT_MakeGroup(bpy.types.Operator):
bl_idname = "node.group_make_no_context"
bl_label = "Make Context Free Group"
bl_description = "Make group from selected nodes without context"
bl_options = { "REGISTER", "UNDO" }
name: bpy.props.StringProperty( default="NodeGroup" )
@classmethod
def poll( cls, context ):
"""run operator only if an active object with an active material exists"""
if context.active_object:
if context.active_object.active_material:
if context.active_object.active_material.use_nodes:
return True
return False
def get_selected_nodes(self,context):
"""returns a list of the selected nodes from the active objects active material"""
nodes = []
for node in context.active_object.active_material.node_tree.nodes:
if node.select:
nodes.append(node)
return nodes
def copy_attributes(self, attributes, old_prop, new_prop):
"""copies the list of attributes from the old to the new prop if the attribute exists"""
#check if the attribute exists and copy it
for attr in attributes:
if hasattr( new_prop, attr ):
setattr( new_prop, attr, getattr( old_prop, attr ) )
def get_node_attributes(self, node):
"""returns a list of all propertie identifiers if they shoulnd't be ignored"""
#all attributes that shouldn't be copied
ignore_attributes = ( "rna_type", "type", "dimensions", "inputs", "outputs", "internal_links", "select")
attributes = []
for attr in node.bl_rna.properties:
#check if the attribute should be copied and add it to the list of attributes to copy
if not attr.identifier in ignore_attributes and not attr.identifier.split("_")[0] == "bl":
attributes.append(attr.identifier)
return attributes
def copy_nodes(self, nodes, group):
"""copies all nodes from the given list into the group with their attributes"""
#the attributes that should be copied for every link
input_attributes = ( "default_value", "name" )
output_attributes = ( "default_value", "name" )
for node in nodes:
#create a new node in the group and find and copy its attributes
new_node = group.nodes.new( node.bl_idname )
node_attributes = self.get_node_attributes( node )
self.copy_attributes( node_attributes, node, new_node )
#copy the attributes for all inputs
for i, inp in enumerate(node.inputs):
self.copy_attributes( input_attributes, inp, new_node.inputs[i] )
#copy the attributes for all outputs
for i, out in enumerate(node.outputs):
self.copy_attributes( output_attributes, out, new_node.outputs[i] )
def copy_links(self, context, nodes, group):
"""copies all links between the nodes in the list to the nodes in the group"""
for node in nodes:
#find the corresponding node in the created group
new_node = group.nodes[ node.name ]
#enumerate over every link in the nodes inputs
for i, inp in enumerate( node.inputs ):
for link in inp.links:
#find the connected node for the link in the group
connected_node = group.nodes[ link.from_node.name ]
#connect the group nodes
group.links.new( connected_node.outputs[ link.from_socket.name ], new_node.inputs[i] )
def add_group_nodes(self, group):
"""adds the group input and output node and positions them correctly"""
#add group input and output
group_input = group.nodes.new("NodeGroupInput")
group_output = group.nodes.new("NodeGroupOutput")
#if there are any nodes in the group, find the mini and maxi x position of all nodes and position the group nodes
if len(group.nodes) > 0:
min_pos = 9999999
max_pos = -9999999
for node in group.nodes:
if node.location[0] < min_pos:
min_pos = node.location[0]
elif node.location[0] + node.width > max_pos:
max_pos = node.location[0]
group_input.location = (min_pos - 250, 0)
group_output.location = (max_pos + 250, 0)
def execute( self, context ):
"""creates the node group, copies all attributes and links and adds the group input and output"""
#create new node group
group = bpy.data.node_groups.new( name=self.name, type="ShaderNodeTree" )
#get the list of all selected nodes from the active objects active material
nodes = self.get_selected_nodes(context)
#copy all nodes from the list to the created group with all their attributes
self.copy_nodes( nodes, group )
#copy the links between the nodes to the created groups nodes
self.copy_links( context, nodes, group )
#add the group input and output node to the created group
self.add_group_nodes(group)
return { "FINISHED" }
I'm sure there are some improvements that could be done to this and of course, it's not a perfect solution because that operator basically already exists, but I think it's the best option while these are not working. I tried it with 2.83 and it seems to work fine for all cases I tested.
EDIT:
Here's a version that was better suited for my personal case. It has an additional parameter to specify a specific material. If it doesn't get a value for that parameter it defaults to the active objects active material. Otherwise, it's the same as the one above:
class NODE_OT_MakeGroup(bpy.types.Operator):
bl_idname = "node.group_make_no_context"
bl_label = "Make Context Free Group"
bl_description = "Make group from selected nodes without context"
bl_options = { "REGISTER", "UNDO" }
name: bpy.props.StringProperty( default="NodeGroup" )
material: bpy.props.StringProperty( default="")
def get_selected_nodes(self,context):
"""returns a list of the selected nodes from the material"""
nodes = []
for node in bpy.data.materials[self.material].node_tree.nodes:
if node.select:
nodes.append(node)
return nodes
def copy_attributes(self, attributes, old_prop, new_prop):
"""copies the list of attributes from the old to the new prop if the attribute exists"""
#check if the attribute exists and copy it
for attr in attributes:
if hasattr( new_prop, attr ):
setattr( new_prop, attr, getattr( old_prop, attr ) )
def get_node_attributes(self, node):
"""returns a list of all propertie identifiers if they shoulnd't be ignored"""
#all attributes that shouldn't be copied
ignore_attributes = ( "rna_type", "type", "dimensions", "inputs", "outputs", "internal_links", "select")
attributes = []
for attr in node.bl_rna.properties:
#check if the attribute should be copied and add it to the list of attributes to copy
if not attr.identifier in ignore_attributes and not attr.identifier.split("_")[0] == "bl":
attributes.append(attr.identifier)
return attributes
def copy_nodes(self, nodes, group):
"""copies all nodes from the given list into the group with their attributes"""
#the attributes that should be copied for every link
input_attributes = ( "default_value", "name" )
output_attributes = ( "default_value", "name" )
for node in nodes:
#create a new node in the group and find and copy its attributes
new_node = group.nodes.new( node.bl_idname )
node_attributes = self.get_node_attributes( node )
self.copy_attributes( node_attributes, node, new_node )
#copy the attributes for all inputs
for i, inp in enumerate(node.inputs):
self.copy_attributes( input_attributes, inp, new_node.inputs[i] )
#copy the attributes for all outputs
for i, out in enumerate(node.outputs):
self.copy_attributes( output_attributes, out, new_node.outputs[i] )
def copy_links(self, context, nodes, group):
"""copies all links between the nodes in the list to the nodes in the group"""
for node in nodes:
#find the corresponding node in the created group
new_node = group.nodes[ node.name ]
#enumerate over every link in the nodes inputs
for i, inp in enumerate( node.inputs ):
for link in inp.links:
#find the connected node for the link in the group
connected_node = group.nodes[ link.from_node.name ]
#connect the group nodes
group.links.new( connected_node.outputs[ link.from_socket.name ], new_node.inputs[i] )
def add_group_nodes(self, group):
"""adds the group input and output node and positions them correctly"""
#add group input and output
group_input = group.nodes.new("NodeGroupInput")
group_output = group.nodes.new("NodeGroupOutput")
#if there are any nodes in the group, find the min and maxi x position of all nodes and position the group nodes
if len(group.nodes) > 0:
min_pos = 9999999
max_pos = -9999999
for node in group.nodes:
if node.location[0] < min_pos:
min_pos = node.location[0]
elif node.location[0] + node.width > max_pos:
max_pos = node.location[0]
group_input.location = (min_pos - 250, 0)
group_output.location = (max_pos + 250, 0)
def execute( self, context ):
"""creates the node group, copies all attributes and links and adds the group input and output"""
#set material if not given
if not self.material:
self.material = bpy.context.active_object.active_material.name
#create new node group
group = bpy.data.node_groups.new( name=self.name, type="ShaderNodeTree" )
#get the list of all selected nodes from the active objects active material
nodes = self.get_selected_nodes(context)
#copy all nodes from the list to the created group with all their attributes
self.copy_nodes( nodes, group )
#copy the links between the nodes to the created groups nodes
self.copy_links( context, nodes, group )
#add the group input and output node to the created group
self.add_group_nodes(group)
return { "FINISHED" }
space.edit_tree
is not None. When you set the areas type to NODE_EDITOR it seems like that attribute is None while the operator is still running. Afterwards it's set though. I'll do some more testing to see if I can figure out how to set it. $\endgroup$