Merge pull request #1758 from alicevision/dev/copyPasteNode

Support copying/pasting a node in the Graph Editor
This commit is contained in:
Fabien Castan 2022-09-08 17:42:12 +02:00 committed by GitHub
commit 27faf9f77c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 498 additions and 7 deletions

View file

@ -219,6 +219,7 @@ class Graph(BaseObject):
self._canComputeLeaves = True
self._nodes = DictModel(keyAttrName='name', parent=self)
self._edges = DictModel(keyAttrName='dst', parent=self) # use dst attribute as unique key since it can only have one input connection
self._importedNodes = DictModel(keyAttrName='name', parent=self)
self._compatibilityNodes = DictModel(keyAttrName='name', parent=self)
self.cacheDir = meshroom.core.defaultCacheFolder
self._filepath = ''
@ -231,6 +232,7 @@ class Graph(BaseObject):
# Tell QML nodes are going to be deleted
for node in self._nodes:
node.alive = False
self._importedNodes.clear()
self._nodes.clear()
@property
@ -239,7 +241,7 @@ class Graph(BaseObject):
return Graph.IO.getFeaturesForVersion(self.header.get(Graph.IO.Keys.FileVersion, "0.0"))
@Slot(str)
def load(self, filepath, setupProjectFile=True):
def load(self, filepath, setupProjectFile=True, importScene=False):
"""
Load a meshroom graph ".mg" file.
@ -248,13 +250,18 @@ class Graph(BaseObject):
setupProjectFile: Store the reference to the project file and setup the cache directory.
If false, it only loads the graph of the project file as a template.
"""
self.clear()
if not importScene:
self.clear()
with open(filepath) as jsonFile:
fileData = json.load(jsonFile)
# older versions of Meshroom files only contained the serialized nodes
graphData = fileData.get(Graph.IO.Keys.Graph, fileData)
if importScene:
self._importedNodes.clear()
graphData = self.updateImportedScene(graphData)
if not isinstance(graphData, dict):
raise RuntimeError('loadGraph error: Graph is not a dict. File: {}'.format(filepath))
@ -282,6 +289,9 @@ class Graph(BaseObject):
# Add node to the graph with raw attributes values
self._addNode(n, nodeName)
if importScene:
self._importedNodes.add(n)
# Create graph edges by resolving attributes expressions
self._applyExpr()
@ -292,6 +302,141 @@ class Graph(BaseObject):
return True
def updateImportedScene(self, data):
"""
Update the names and links of the scene to import so that it can fit
correctly in the existing graph.
Parse all the nodes from the scene that is going to be imported.
If their name already exists in the graph, replace them with new names,
then parse all the nodes' inputs/outputs to replace the old names with
the new ones in the links.
Args:
data (dict): the dictionary containing all the nodes to import and their data
Returns:
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
updatedData = {} # input data with updated node names and links
def createUniqueNodeName(nodeNames, inputName):
"""
Create a unique name that does not already exist in the current graph or in the list
of nodes that will be imported.
"""
i = 1
while i:
newName = "{name}_{index}".format(name=inputName, index=i)
if newName not in nodeNames and newName not in updatedData.keys():
return newName
i += 1
# 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])):
if not isinstance(nodeData, dict):
raise RuntimeError('updateImportedScene error: Node is not a dict.')
if nodeName in self._nodes.keys() or nodeName in updatedData.keys():
newName = createUniqueNodeName(self._nodes.keys(), nodeData["nodeType"])
updatedData[newName] = nodeData
nameCorrespondences[nodeName] = newName
else:
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
for nodeName, nodeData in updatedData.items():
nodeType = nodeData.get("nodeType", None)
nodeDesc = meshroom.core.nodesDesc[nodeType]
inputs = nodeData.get("inputs", {})
outputs = nodeData.get("outputs", {})
if inputs:
inputs = self.updateLinks(inputs, nameCorrespondences)
inputs = self.resetExternalLinks(inputs, nodeDesc.inputs, newNames)
updatedData[nodeName]["inputs"] = inputs
if outputs:
outputs = self.updateLinks(outputs, nameCorrespondences)
outputs = self.resetExternalLinks(outputs, nodeDesc.outputs, newNames)
updatedData[nodeName]["outputs"] = outputs
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
def updateEnabled(self):
return self._updateEnabled
@ -400,6 +545,40 @@ class Graph(BaseObject):
return duplicates
def pasteNodes(self, data, position):
"""
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):
positionCnt = 0 # always valid because we know the data is sorted the same way as the position list
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()
return nodes
def outEdges(self, attribute):
""" Return the list of edges starting from the given attribute """
# type: (Attribute,) -> [Edge]
@ -436,6 +615,8 @@ class Graph(BaseObject):
node.alive = False
self._nodes.remove(node)
if node in self._importedNodes:
self._importedNodes.remove(node)
self.update()
return inEdges, outEdges
@ -1220,6 +1401,11 @@ class Graph(BaseObject):
def edges(self):
return self._edges
@property
def importedNodes(self):
"""" Return the list of nodes that were added to the graph with the latest 'Import Scene' action. """
return self._importedNodes
@property
def cacheDir(self):
return self._cacheDir

View file

@ -7,7 +7,7 @@ from PySide2.QtCore import Property, Signal
from meshroom.core.attribute import ListAttribute, Attribute
from meshroom.core.graph import GraphModification
from meshroom.core.node import nodeFactory
from meshroom.core.node import nodeFactory, Position
class UndoCommand(QUndoCommand):
@ -195,6 +195,60 @@ class DuplicateNodesCommand(GraphCommand):
self.graph.removeNode(duplicate)
class PasteNodesCommand(GraphCommand):
"""
Handle node pasting in a Graph.
"""
def __init__(self, graph, data, position=None, parent=None):
super(PasteNodesCommand, self).__init__(graph, parent)
self.data = data
self.position = position
self.nodeNames = []
def redoImpl(self):
data = self.graph.updateImportedScene(self.data)
nodes = self.graph.pasteNodes(data, self.position)
self.nodeNames = [node.name for node in nodes]
self.setText("Paste Node{} ({})".format("s" if len(self.nodeNames) > 1 else "", ", ".join(self.nodeNames)))
return nodes
def undoImpl(self):
for name in self.nodeNames:
self.graph.removeNode(name)
class ImportSceneCommand(GraphCommand):
"""
Handle the import of a scene into a Graph.
"""
def __init__(self, graph, filepath=None, yOffset=0, parent=None):
super(ImportSceneCommand, self).__init__(graph, parent)
self.filepath = filepath
self.importedNames = []
self.yOffset = yOffset
def redoImpl(self):
status = self.graph.load(self.filepath, setupProjectFile=False, importScene=True)
importedNodes = self.graph.importedNodes
self.setText("Import Scene ({} nodes)".format(importedNodes.count))
lowestY = 0
for node in self.graph.nodes:
if node not in importedNodes and node.y > lowestY:
lowestY = node.y
for node in importedNodes:
self.importedNames.append(node.name)
self.graph.node(node.name).position = Position(node.x, node.y + lowestY + self.yOffset)
return status
def undoImpl(self):
for nodeName in self.importedNames:
self.graph.removeNode(nodeName)
self.importedNames = []
class SetAttributeCommand(GraphCommand):
def __init__(self, graph, attribute, value, parent=None):
super(SetAttributeCommand, self).__init__(graph, parent)

View file

@ -11,10 +11,21 @@ class ClipboardHelper(QObject):
super(ClipboardHelper, self).__init__(parent)
self._clipboard = QClipboard(parent=self)
def __del__(self):
# Workaround to avoid the "QXcbClipboard: Unable to receive an event from the clipboard manager
# in a reasonable time" that will hold up the application when exiting if the clipboard has been
# used at least once and its content exceeds a certain size (on X11/XCB).
# The bug occurs in QClipboard and is present on all Qt5 versions.
self.clear()
@Slot(str)
def setText(self, value):
self._clipboard.setText(value)
@Slot(result=str)
def getText(self):
return self._clipboard.text()
@Slot()
def clear(self):
self._clipboard.clear()

View file

@ -3,6 +3,7 @@
import logging
import os
import time
import json
from enum import Enum
from threading import Thread, Event, Lock
from multiprocessing.pool import ThreadPool
@ -352,6 +353,19 @@ class UIGraph(QObject):
self.setGraph(g)
return status
@Slot(QUrl, result=bool)
def importScene(self, filepath):
if isinstance(filepath, (QUrl)):
# depending how the QUrl has been initialized,
# toLocalFile() may return the local path or an empty string
localFile = filepath.toLocalFile()
if not localFile:
localFile = filepath.toString()
else:
localFile = filepath
yOffset = self.layout.gridSpacing + self.layout.nodeHeight
return self.push(commands.ImportSceneCommand(self._graph, localFile, yOffset=yOffset))
@Slot(QUrl)
def saveAs(self, url):
self._saveAs(url)
@ -761,6 +775,123 @@ class UIGraph(QObject):
""" Reset currently hovered node to None. """
self.hoveredNode = None
@Slot(result=str)
def getSelectedNodesContent(self):
"""
Return the content of the currently selected nodes in a string, formatted to JSON.
If no node is currently selected, an empty string is returned.
"""
if self._selectedNodes:
d = self._graph.toDict()
selection = {}
for node in self._selectedNodes:
selection[node.name] = d[node.name]
return json.dumps(selection, indent=4)
return ''
@Slot(str, QPoint, result="QVariantList")
def pasteNodes(self, clipboardContent, position=None):
"""
Parse the content of the clipboard to see whether it contains
valid node descriptions. If that is the case, the nodes described
in the clipboard are built with the available information.
Otherwise, nothing is done.
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
formatting (dictionary form with double quotes around the keys and values)
can be used to generate a node.
For example, it is enough to have:
{"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}}
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:
return
try:
d = json.loads(clipboardContent)
except ValueError as e:
raise ValueError(e)
if not isinstance(d, dict):
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
if isinstance(position, QPoint):
position = Position(position.x(), position.y())
if self.hoveredNode:
# If a node is hovered, add an offset to prevent complete occlusion
position = Position(position.x + self.layout.gridSpacing, position.y + self.layout.gridSpacing)
# Get the position of the first node in a zone whose top-left corner is the mouse and the bottom-right
# corner the (x, y) coordinates, with x the maximum of all the nodes' position along the x-axis, and y the
# maximum of all the nodes' position along the y-axis. All nodes with a position will be placed relatively
# to the first node within that zone.
firstNodePos = None
minX = 0
minY = 0
for key in sorted(d):
nodeType = d[key].get("nodeType", None)
if not nodeType:
raise ValueError("Invalid node description: no provided node type for '{}'".format(key))
pos = d[key].get("position", None)
if pos:
if not firstNodePos:
firstNodePos = pos
minX = pos[0]
minY = pos[1]
else:
if minX > pos[0]:
minX = pos[0]
if minY > pos[1]:
minY = pos[1]
# Ensure there will not be an error if no node has a specified position
if not firstNodePos:
firstNodePos = [0, 0]
# Position of the first node within the zone
position = Position(position.x + firstNodePos[0] - minX, position.y + firstNodePos[1] - minY)
finalPosition = None
prevPosition = None
positions = []
for key in sorted(d):
currentPosition = d[key].get("position", None)
if not finalPosition:
finalPosition = position
else:
if prevPosition and currentPosition:
# If the nodes both have a position, recreate the distance between them with a different
# starting point
x = finalPosition.x + (currentPosition[0] - prevPosition[0])
y = finalPosition.y + (currentPosition[1] - prevPosition[1])
finalPosition = Position(x, y)
else:
# 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)
prevPosition = currentPosition
positions.append(finalPosition)
return self.push(commands.PasteNodesCommand(self.graph, d, position=positions))
undoStack = Property(QObject, lambda self: self._undoStack, constant=True)
graphChanged = Signal()
graph = Property(Graph, lambda self: self._graph, notify=graphChanged)

View file

@ -41,6 +41,7 @@ Item {
clip: true
SystemPalette { id: activePalette }
property point pastePosition
/// Get node delegate for the given node object
function nodeDelegate(node)
@ -75,16 +76,43 @@ Item {
uigraph.selectNodes(nodes)
}
/// Copy node content to clipboard
function copyNodes()
{
var nodeContent = uigraph.getSelectedNodesContent()
if (nodeContent !== '') {
Clipboard.clear()
Clipboard.setText(nodeContent)
}
}
/// Paste content of clipboard to graph editor and create new node if valid
function pasteNodes()
{
if (uigraph.hoveredNode != null) {
var node = nodeDelegate(uigraph.hoveredNode)
root.pastePosition = Qt.point(node.mousePosition.x + node.x, node.mousePosition.y + node.y)
} else {
root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
}
var copiedContent = Clipboard.getText()
var nodes = uigraph.pasteNodes(copiedContent, root.pastePosition)
if (nodes.length > 0) {
uigraph.clearNodeSelection()
uigraph.selectedNode = nodes[0]
uigraph.selectNodes(nodes)
}
}
Keys.onPressed: {
if(event.key === Qt.Key_F)
if (event.key === Qt.Key_F)
fit()
if(event.key === Qt.Key_Delete)
if(event.modifiers == Qt.AltModifier)
if (event.key === Qt.Key_Delete)
if (event.modifiers == Qt.AltModifier)
uigraph.removeNodesFrom(uigraph.selectedNodes)
else
uigraph.removeNodes(uigraph.selectedNodes)
if(event.key === Qt.Key_D)
if (event.key === Qt.Key_D)
duplicateNode(event.modifiers == Qt.AltModifier)
}
@ -382,6 +410,23 @@ Item {
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
}
MenuSeparator {}
MenuItem {
text: "Copy Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard"
ToolTip.visible: hovered
onTriggered: copyNodes()
}
MenuItem {
text: "Paste Node(s)"
enabled: true
ToolTip.text: "Copy selection to the clipboard and immediately paste it"
ToolTip.visible: hovered
onTriggered: {
copyNodes();
pasteNodes();
}
}
MenuItem {
text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "")
enabled: true

View file

@ -31,6 +31,8 @@ Item {
readonly property color defaultColor: isCompatibilityNode ? "#444" : activePalette.base
property color baseColor: defaultColor
property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)
Item {
id: m
property bool displayParams: false

View file

@ -321,6 +321,16 @@ ApplicationWindow {
}
}
FileDialog {
id: importSceneDialog
title: "Import Scene"
selectMultiple: false
nameFilters: ["Meshroom Graphs (*.mg)"]
onAccepted: {
graphEditor.uigraph.importScene(importSceneDialog.fileUrl)
}
}
AboutDialog {
id: aboutDialog
}
@ -410,6 +420,42 @@ ApplicationWindow {
enabled: _reconstruction.undoStack.canRedo && !_reconstruction.undoStack.lockedRedo
onTriggered: _reconstruction.undoStack.redo()
}
Action {
id: copyAction
property string tooltip: {
var s = "Copy selected node"
s += (_reconstruction.selectedNodes.count > 1 ? "s (" : " (") + getSelectedNodesName()
s += ") to the clipboard"
return s
}
text: "Copy Node" + (_reconstruction.selectedNodes.count > 1 ? "s " : " ")
shortcut: "Ctrl+C"
enabled: _reconstruction.selectedNodes.count > 0
onTriggered: graphEditor.copyNodes()
function getSelectedNodesName()
{
var nodesName = ""
for (var i = 0; i < _reconstruction.selectedNodes.count; i++)
{
if (nodesName !== "")
nodesName += ", "
var node = _reconstruction.selectedNodes.at(i)
nodesName += node.name
}
return nodesName
}
}
Action {
id: pasteAction
property string tooltip: "Paste the clipboard content to the scene if it contains valid nodes"
text: "Paste Node(s)"
shortcut: "Ctrl+V"
onTriggered: graphEditor.pasteNodes()
}
Action {
shortcut: "Ctrl+Shift+P"
@ -511,6 +557,12 @@ ApplicationWindow {
}
}
}
Action {
id: importSceneAction
text: "Import Scene"
shortcut: "Ctrl+Shift+I"
onTriggered: importSceneDialog.open()
}
Action {
id: importActionItem
text: "Import Images"
@ -596,6 +648,16 @@ ApplicationWindow {
ToolTip.visible: hovered
ToolTip.text: redoAction.tooltip
}
MenuItem {
action: copyAction
ToolTip.visible: hovered
ToolTip.text: copyAction.tooltip
}
MenuItem {
action: pasteAction
ToolTip.visible: hovered
ToolTip.text: pasteAction.tooltip
}
}
Menu {
title: "View"