Reconnect copied/pasted nodes together but not to the graph

If several nodes are described in the clipboard and are supposed to
be connected with each other, reconnect them together, but do not
attempt to reconnect them to the graph they were copied from, even if
it is the current graph.
This commit is contained in:
Candice Bentéjac 2022-09-06 11:13:29 +02:00
parent a33f79e4d7
commit d06acf6722
4 changed files with 161 additions and 61 deletions

View file

@ -314,9 +314,8 @@ class Graph(BaseObject):
Returns: Returns:
updatedData (dict): the dictionary containing all the nodes to import with their updated names and data updatedData (dict): the dictionary containing all the nodes to import with their updated names and data
""" """
nameCorrespondences = {} # maps the old node name to its updated one
nameCorrespondences = {} updatedData = {} # input data with updated node names and links
updatedData = {}
def createUniqueNodeName(nodeNames, inputName): def createUniqueNodeName(nodeNames, inputName):
""" """
@ -330,26 +329,6 @@ class Graph(BaseObject):
return newName return newName
i += 1 i += 1
def updateLinks(attributes):
"""
Update all the links that refer to nodes that are going to be imported and whose
name has to be updated.
"""
for key, val in attributes.items():
for corr in nameCorrespondences.keys():
if isinstance(val, pyCompatibility.basestring) and corr in val:
attributes[key] = val.replace(corr, nameCorrespondences[corr])
elif isinstance(val, list):
for v in val:
if isinstance(v, pyCompatibility.basestring):
if corr in v:
val[val.index(v)] = v.replace(corr, nameCorrespondences[corr])
else: # the list does not contain strings, so there cannot be links to update
break
attributes[key] = val
return attributes
# First pass to get all the names that already exist in the graph, update them, and keep track of the changes # First pass to get all the names that already exist in the graph, update them, and keep track of the changes
for nodeName, nodeData in sorted(data.items(), key=lambda x: self.getNodeIndexFromName(x[0])): for nodeName, nodeData in sorted(data.items(), key=lambda x: self.getNodeIndexFromName(x[0])):
if not isinstance(nodeData, dict): if not isinstance(nodeData, dict):
@ -363,20 +342,97 @@ class Graph(BaseObject):
else: else:
updatedData[nodeName] = nodeData updatedData[nodeName] = nodeData
newNames = [nodeName for nodeName in updatedData] # names of all the nodes that will be added
# Second pass to update all the links in the input/output attributes for every node with the new names # Second pass to update all the links in the input/output attributes for every node with the new names
for nodeName, nodeData in updatedData.items(): for nodeName, nodeData in updatedData.items():
nodeType = nodeData.get("nodeType", None)
nodeDesc = meshroom.core.nodesDesc[nodeType]
inputs = nodeData.get("inputs", {}) inputs = nodeData.get("inputs", {})
outputs = nodeData.get("outputs", {}) outputs = nodeData.get("outputs", {})
if inputs: if inputs:
inputs = updateLinks(inputs) inputs = self.updateLinks(inputs, nameCorrespondences)
inputs = self.resetExternalLinks(inputs, nodeDesc.inputs, newNames)
updatedData[nodeName]["inputs"] = inputs updatedData[nodeName]["inputs"] = inputs
if outputs: if outputs:
outputs = updateLinks(outputs) outputs = self.updateLinks(outputs, nameCorrespondences)
outputs = self.resetExternalLinks(outputs, nodeDesc.outputs, newNames)
updatedData[nodeName]["outputs"] = outputs updatedData[nodeName]["outputs"] = outputs
return updatedData return updatedData
@staticmethod
def updateLinks(attributes, nameCorrespondences):
"""
Update all the links that refer to nodes that are going to be imported and whose
names have to be updated.
Args:
attributes (dict): attributes whose links need to be updated
nameCorrespondences (dict): node names to replace in the links with the name to replace them with
Returns:
attributes (dict): the attributes with all the updated links
"""
for key, val in attributes.items():
for corr in nameCorrespondences.keys():
if isinstance(val, pyCompatibility.basestring) and corr in val:
attributes[key] = val.replace(corr, nameCorrespondences[corr])
elif isinstance(val, list):
for v in val:
if isinstance(v, pyCompatibility.basestring):
if corr in v:
val[val.index(v)] = v.replace(corr, nameCorrespondences[corr])
else: # the list does not contain strings, so there cannot be links to update
break
attributes[key] = val
return attributes
@staticmethod
def resetExternalLinks(attributes, nodeDesc, newNames):
"""
Reset all links to nodes that are not part of the nodes which are going to be imported:
if there are links to nodes that are not in the list, then it means that the references
are made to external nodes, and we want to get rid of those.
Args:
attributes (dict): attributes whose links might need to be reset
nodeDesc (list): list with all the attributes' description (including their default value)
newNames (list): names of the nodes that are going to be imported; no node name should be referenced
in the links except those contained in this list
Returns:
attributes (dict): the attributes with all the links referencing nodes outside those which will be imported
reset to their default values
"""
for key, val in attributes.items():
defaultValue = None
for desc in nodeDesc:
if desc.name == key:
defaultValue = desc.value
break
if isinstance(val, pyCompatibility.basestring):
if Attribute.isLinkExpression(val) and not any(name in val for name in newNames):
if defaultValue is not None: # prevents from not entering condition if defaultValue = ''
attributes[key] = defaultValue
elif isinstance(val, list):
removedCnt = len(val) # counter to know whether all the list entries will be deemed invalid
tmpVal = list(val) # deep copy to ensure we iterate over the entire list (even if elements are removed)
for v in tmpVal:
if isinstance(v, pyCompatibility.basestring) and Attribute.isLinkExpression(v) and not any(
name in v for name in newNames):
val.remove(v)
removedCnt -= 1
if removedCnt == 0 and defaultValue is not None: # if all links were wrong, reset the attribute
attributes[key] = defaultValue
return attributes
@property @property
def updateEnabled(self): def updateEnabled(self):
return self._updateEnabled return self._updateEnabled
@ -485,14 +541,39 @@ class Graph(BaseObject):
return duplicates return duplicates
def pasteNode(self, nodeType, position, **kwargs): def pasteNodes(self, data, position):
name = self._createUniqueNodeName(nodeType) """
node = None Paste node(s) in the graph with their connections. The connections can only be between
the pasted nodes and not with the rest of the graph.
Args:
data (dict): the dictionary containing the information about the nodes to paste, with their names and
links already updated to be added to the graph
position (list): the list of positions for each node to paste
Returns:
list: the list of Node objects that were pasted and added to the graph
"""
nodes = []
with GraphModification(self): with GraphModification(self):
node = Node(nodeType, position=position, **kwargs) positionCnt = 0 # always valid because we know the data is sorted the same way as the position list
self._addNode(node, name) for key in sorted(data):
nodeType = data[key].get("nodeType", None)
if not nodeType: # this case should never occur, as the data should have been prefiltered first
pass
attributes = {}
attributes.update(data[key].get("inputs", {}))
attributes.update(data[key].get("outputs", {}))
node = Node(nodeType, position=position[positionCnt], **attributes)
self._addNode(node, key)
nodes.append(node)
positionCnt += 1
self._applyExpr() self._applyExpr()
return node return nodes
def outEdges(self, attribute): def outEdges(self, attribute):
""" Return the list of edges starting from the given attribute """ """ Return the list of edges starting from the given attribute """

View file

@ -195,25 +195,26 @@ class DuplicateNodesCommand(GraphCommand):
self.graph.removeNode(duplicate) self.graph.removeNode(duplicate)
class PasteNodeCommand(GraphCommand): class PasteNodesCommand(GraphCommand):
""" """
Handle node pasting in a Graph. Handle node pasting in a Graph.
""" """
def __init__(self, graph, nodeType, position=None, parent=None, **kwargs): def __init__(self, graph, data, position=None, parent=None):
super(PasteNodeCommand, self).__init__(graph, parent) super(PasteNodesCommand, self).__init__(graph, parent)
self.nodeType = nodeType self.data = data
self.position = position self.position = position
self.nodeName = None self.nodeNames = []
self.kwargs = kwargs
def redoImpl(self): def redoImpl(self):
node = self.graph.pasteNode(self.nodeType, self.position, **self.kwargs) data = self.graph.updateImportedScene(self.data)
self.nodeName = node.name nodes = self.graph.pasteNodes(data, self.position)
self.setText("Paste Node {}".format(self.nodeName)) self.nodeNames = [node.name for node in nodes]
return node self.setText("Paste Node{} ({})".format("s" if len(self.nodeNames) > 1 else "", ", ".join(self.nodeNames)))
return nodes
def undoImpl(self): def undoImpl(self):
self.graph.removeNode(self.nodeName) for name in self.nodeNames:
self.graph.removeNode(name)
class ImportSceneCommand(GraphCommand): class ImportSceneCommand(GraphCommand):
@ -247,6 +248,7 @@ class ImportSceneCommand(GraphCommand):
self.graph.removeNode(nodeName) self.graph.removeNode(nodeName)
self.importedNames = [] self.importedNames = []
class SetAttributeCommand(GraphCommand): class SetAttributeCommand(GraphCommand):
def __init__(self, graph, attribute, value, parent=None): def __init__(self, graph, attribute, value, parent=None):
super(SetAttributeCommand, self).__init__(graph, parent) super(SetAttributeCommand, self).__init__(graph, parent)

View file

@ -781,7 +781,7 @@ class UIGraph(QObject):
return json.dumps(selection, indent=4) return json.dumps(selection, indent=4)
return '' return ''
@Slot(str, QPoint) @Slot(str, QPoint, result="QVariantList")
def pasteNodes(self, clipboardContent, position=None): def pasteNodes(self, clipboardContent, position=None):
""" """
Parse the content of the clipboard to see whether it contains Parse the content of the clipboard to see whether it contains
@ -789,18 +789,26 @@ class UIGraph(QObject):
in the clipboard are built with the available information. in the clipboard are built with the available information.
Otherwise, nothing is done. Otherwise, nothing is done.
This function does not need to be preceded by a call to "copyNodeContent". This function does not need to be preceded by a call to "getSelectedNodesContent".
Any clipboard content that contains at least a node type with a valid JSON Any clipboard content that contains at least a node type with a valid JSON
formatting (dictionary form with double quotes around the keys and values) formatting (dictionary form with double quotes around the keys and values)
can be used to generate a node. can be used to generate a node.
For example, it is enough to have: For example, it is enough to have:
{"nodeName": {"nodeType":"CameraInit"}} {"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}}
in the clipboard to create a default CameraInit node. in the clipboard to create a default CameraInit and a default FeatureMatching nodes.
Args:
clipboardContent (str): the string contained in the clipboard, that may or may not contain valid
node information
position (QPoint): the position of the mouse in the Graph Editor when the function was called
Returns:
list: the list of Node objects that were pasted and added to the graph
""" """
if not clipboardContent: if not clipboardContent:
return return
d = {}
try: try:
d = json.loads(clipboardContent) d = json.loads(clipboardContent)
except ValueError as e: except ValueError as e:
@ -809,35 +817,39 @@ class UIGraph(QObject):
if not isinstance(d, dict): if not isinstance(d, dict):
raise ValueError("The clipboard does not contain a valid node. Cannot paste it.") raise ValueError("The clipboard does not contain a valid node. Cannot paste it.")
# If the clipboard contains a header, then a whole file is contained in the clipboard
# Extract the "graph" part and paste it all, ignore the rest
if d.get("header", None):
d = d.get("graph", None)
if not d:
return
finalPosition = None finalPosition = None
prevPosition = None prevPosition = None
positions = []
for key in d: for key in sorted(d):
nodeDesc = d[key] nodeType = d[key].get("nodeType", None)
nodeType = nodeDesc.get("nodeType", None)
if not nodeType: if not nodeType:
pass raise ValueError("Invalid node description: no provided node type for '{}'".format(key))
attributes = {} currentPosition = d[key].get("position", None)
attributes.update(nodeDesc.get("inputs", {}))
attributes.update(nodeDesc.get("outputs", {}))
currentPosition = nodeDesc.get("position", None)
if isinstance(position, QPoint) and not finalPosition: if isinstance(position, QPoint) and not finalPosition:
finalPosition = Position(position.x(), position.y()) finalPosition = Position(position.x(), position.y())
else: else:
if prevPosition and currentPosition: if prevPosition and currentPosition:
# if the nodes both have a position, recreate the distance between them from a different # If the nodes both have a position, recreate the distance between them with a different
# starting point # starting point
x = finalPosition.x + (currentPosition[0] - prevPosition[0]) x = finalPosition.x + (currentPosition[0] - prevPosition[0])
y = finalPosition.y + (currentPosition[1] - prevPosition[1]) y = finalPosition.y + (currentPosition[1] - prevPosition[1])
finalPosition = Position(x, y) finalPosition = Position(x, y)
else: else:
# if either the current node or previous one lack a position, use a custom one # If either the current node or previous one lacks a position, use a custom one
finalPosition = Position(finalPosition.x + self.layout.gridSpacing + self.layout.nodeWidth, finalPosition.y) finalPosition = Position(finalPosition.x + self.layout.gridSpacing + self.layout.nodeWidth, finalPosition.y)
self.push(commands.PasteNodeCommand(self._graph, nodeType, position=finalPosition, **attributes))
prevPosition = currentPosition prevPosition = currentPosition
positions.append(finalPosition)
return self.push(commands.PasteNodesCommand(self.graph, d, position=positions))
undoStack = Property(QObject, lambda self: self._undoStack, constant=True) undoStack = Property(QObject, lambda self: self._undoStack, constant=True)
graphChanged = Signal() graphChanged = Signal()

View file

@ -91,7 +91,12 @@ Item {
{ {
root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
var copiedContent = Clipboard.getText() var copiedContent = Clipboard.getText()
uigraph.pasteNodes(copiedContent, root.pastePosition) var nodes = uigraph.pasteNodes(copiedContent, root.pastePosition)
if (nodes.length > 0) {
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
}
} }
Keys.onPressed: { Keys.onPressed: {