diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 186ddaaa..8b7e8487 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -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 diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index 615d6036..c34d674b 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -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) diff --git a/meshroom/ui/components/clipboard.py b/meshroom/ui/components/clipboard.py index 2cca6e1a..ab95d70c 100644 --- a/meshroom/ui/components/clipboard.py +++ b/meshroom/ui/components/clipboard.py @@ -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() diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0b450562..c615137c 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -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) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 2d3591ab..034331c1 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -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 diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index 0cf82892..213e73eb 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -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 diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 54763fa2..4dbfe6f5 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -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"