From 751bad96c68d693a34337e01238e1e507c7806ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Thu, 18 Aug 2022 15:46:13 +0200 Subject: [PATCH 01/11] Copy selected node's content to the clipboard when Ctrl+C is pressed When Ctrl+C is pressed while a node selected, its content is formatted to JSON and copied to the system's clipboard. --- meshroom/ui/graph.py | 13 ++++++++++++ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 22 +++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 71846a9f..661e1697 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 @@ -753,6 +754,18 @@ class UIGraph(QObject): """ Reset currently hovered node to None. """ self.hoveredNode = None + @Slot(result=str) + def getSelectedNodeContent(self): + """ + Return the content of the currently selected node in a string, formatted to JSON. + If no node is currently selected, an empty string is returned. + """ + if self._selectedNode: + d = self._graph.toDict() + node = d[self._selectedNode.name] + return json.dumps(node, indent=4) + return '' + 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..0d776fb4 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -75,17 +75,31 @@ Item { uigraph.selectNodes(nodes) } + /// Copy node content to clipboard + function copyNode() + { + var nodeContent = uigraph.getSelectedNodeContent() + if (nodeContent !== '') { + Clipboard.clear() + Clipboard.setText(nodeContent) + } + } + 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) + + if (event.key === Qt.Key_C) + if (event.modifiers == Qt.ControlModifier) + copyNode() } MouseArea { From ddda62a652aaa8f1065c24d8a7cab6cff3bc899c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Thu, 18 Aug 2022 16:51:05 +0200 Subject: [PATCH 02/11] Add node to graph with Ctrl+V if valid node content is in the clipboard Attempt to paste the clipboard's content in the graph when Ctrl+V is pressed. If the clipboard contains a valid node description, add the corresponding node to the graph. Otherwise, do nothing. --- meshroom/core/graph.py | 9 +++++ meshroom/ui/commands.py | 20 +++++++++++ meshroom/ui/components/clipboard.py | 4 +++ meshroom/ui/graph.py | 38 +++++++++++++++++++++ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 9 +++++ 5 files changed, 80 insertions(+) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 89faefb5..c1e690f1 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -396,6 +396,15 @@ class Graph(BaseObject): return duplicates + def pasteNode(self, nodeType, **kwargs): + name = self._createUniqueNodeName(nodeType) + node = None + with GraphModification(self): + node = Node(nodeType, **kwargs) + self._addNode(node, name) + self._applyExpr() + return node + def outEdges(self, attribute): """ Return the list of edges starting from the given attribute """ # type: (Attribute,) -> [Edge] diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index 615d6036..b4e4734c 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -195,6 +195,26 @@ class DuplicateNodesCommand(GraphCommand): self.graph.removeNode(duplicate) +class PasteNodeCommand(GraphCommand): + """ + Handle node pasting in a Graph. + """ + def __init__(self, graph, nodeType, parent=None, **kwargs): + super(PasteNodeCommand, self).__init__(graph, parent) + self.nodeType = nodeType + self.nodeName = None + self.kwargs = kwargs + + def redoImpl(self): + node = self.graph.pasteNode(self.nodeType, **self.kwargs) + self.nodeName = node.name + self.setText("Paste Node {}".format(self.nodeName)) + return node + + def undoImpl(self): + self.graph.removeNode(self.nodeName) + + 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..4de692c9 100644 --- a/meshroom/ui/components/clipboard.py +++ b/meshroom/ui/components/clipboard.py @@ -15,6 +15,10 @@ class ClipboardHelper(QObject): 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 661e1697..119102ec 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -766,6 +766,44 @@ class UIGraph(QObject): return json.dumps(node, indent=4) return '' + @Slot(str) + def pasteNode(self, clipboardContent): + """ + Parse the content of the clipboard to see whether it contains + a valid node description. If that is the case, the node described + in the clipboard is built with the available information. + Otherwise, nothing is done. + + This function does not need to be preceded by a call to "copyNodeContent". + 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: + {"nodeType":"CameraInit"} + in the clipboard to create a default CameraInit node. + """ + if not clipboardContent: + return + d = {} + 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.") + + nodeType = d.get("nodeType", None) + if not nodeType: + raise ValueError("The clipboard does not contain a valid node. Cannot paste it.") + + attributes = {} + attributes.update(d.get("inputs", {})) + attributes.update(d.get("outputs", {})) + + self.push(commands.PasteNodeCommand(self._graph, nodeType, **attributes)) + 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 0d776fb4..c03f98c9 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -85,6 +85,12 @@ Item { } } + /// Paste content of clipboard to graph editor and create new node if valid + function pasteNode() + { + var copiedContent = Clipboard.getText() + uigraph.pasteNode(copiedContent) + } Keys.onPressed: { if (event.key === Qt.Key_F) @@ -100,6 +106,9 @@ Item { if (event.key === Qt.Key_C) if (event.modifiers == Qt.ControlModifier) copyNode() + if (event.key === Qt.Key_V) + if (event.modifiers == Qt.ControlModifier) + pasteNode() } MouseArea { From 5b65866e4935f72a284191d49f18702db71adedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Fri, 19 Aug 2022 11:02:42 +0200 Subject: [PATCH 03/11] [ui] Paste a node on the mouse's position When creating a node with a "paste" operation, place the node on the mouse's position in the graph instead of default position (0,0). If the mouse is placed on an existing node, the pasted node will be placed on the mouse's position plus an offset so that the pasted node does not directly overlap with the existing node. --- meshroom/core/graph.py | 4 ++-- meshroom/ui/commands.py | 5 +++-- meshroom/ui/graph.py | 9 ++++++--- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 4 +++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index c1e690f1..dd86c4bc 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -396,11 +396,11 @@ class Graph(BaseObject): return duplicates - def pasteNode(self, nodeType, **kwargs): + def pasteNode(self, nodeType, position, **kwargs): name = self._createUniqueNodeName(nodeType) node = None with GraphModification(self): - node = Node(nodeType, **kwargs) + node = Node(nodeType, position=position, **kwargs) self._addNode(node, name) self._applyExpr() return node diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index b4e4734c..614e465f 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -199,14 +199,15 @@ class PasteNodeCommand(GraphCommand): """ Handle node pasting in a Graph. """ - def __init__(self, graph, nodeType, parent=None, **kwargs): + def __init__(self, graph, nodeType, position=None, parent=None, **kwargs): super(PasteNodeCommand, self).__init__(graph, parent) self.nodeType = nodeType + self.position = position self.nodeName = None self.kwargs = kwargs def redoImpl(self): - node = self.graph.pasteNode(self.nodeType, **self.kwargs) + node = self.graph.pasteNode(self.nodeType, self.position, **self.kwargs) self.nodeName = node.name self.setText("Paste Node {}".format(self.nodeName)) return node diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 119102ec..f47cf2c5 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -766,8 +766,8 @@ class UIGraph(QObject): return json.dumps(node, indent=4) return '' - @Slot(str) - def pasteNode(self, clipboardContent): + @Slot(str, QPoint) + def pasteNode(self, clipboardContent, position=None): """ Parse the content of the clipboard to see whether it contains a valid node description. If that is the case, the node described @@ -802,7 +802,10 @@ class UIGraph(QObject): attributes.update(d.get("inputs", {})) attributes.update(d.get("outputs", {})) - self.push(commands.PasteNodeCommand(self._graph, nodeType, **attributes)) + if isinstance(position, QPoint): + position = Position(position.x(), position.y()) + + self.push(commands.PasteNodeCommand(self._graph, nodeType, position=position, **attributes)) undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index c03f98c9..7f355a6a 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) @@ -88,8 +89,9 @@ Item { /// Paste content of clipboard to graph editor and create new node if valid function pasteNode() { + root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) var copiedContent = Clipboard.getText() - uigraph.pasteNode(copiedContent) + uigraph.pasteNode(copiedContent, root.pastePosition) } Keys.onPressed: { From 08d502f4a6e79fa2683506caa7e767ce1bbed6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Fri, 19 Aug 2022 15:24:30 +0200 Subject: [PATCH 04/11] Clear clipboard's content when exiting Workaround for a bug in QClipboard that occurs when the clipboard has been used within the app and its content exceeds a certain size on X11/XCB. This issue will hold up the app when exiting and is present in Qt5 versions. With this workaround, the content of the clipboard will be lost when exiting, but the app will exit normally. --- meshroom/ui/components/clipboard.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/meshroom/ui/components/clipboard.py b/meshroom/ui/components/clipboard.py index 4de692c9..ab95d70c 100644 --- a/meshroom/ui/components/clipboard.py +++ b/meshroom/ui/components/clipboard.py @@ -11,6 +11,13 @@ 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) From e11452efdb2e90c0f06b4f9f2895bc5e1d491cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Wed, 24 Aug 2022 17:51:05 +0200 Subject: [PATCH 05/11] Extend copy/paste support to selections containing multiple nodes --- meshroom/ui/graph.py | 55 ++++++++++++++------- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 12 ++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index f47cf2c5..2dc6adad 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -755,23 +755,25 @@ class UIGraph(QObject): self.hoveredNode = None @Slot(result=str) - def getSelectedNodeContent(self): + def getSelectedNodesContent(self): """ - Return the content of the currently selected node in a string, formatted to JSON. + 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._selectedNode: + if self._selectedNodes: d = self._graph.toDict() - node = d[self._selectedNode.name] - return json.dumps(node, indent=4) + selection = {} + for node in self._selectedNodes: + selection[node.name] = d[node.name] + return json.dumps(selection, indent=4) return '' @Slot(str, QPoint) - def pasteNode(self, clipboardContent, position=None): + def pasteNodes(self, clipboardContent, position=None): """ Parse the content of the clipboard to see whether it contains - a valid node description. If that is the case, the node described - in the clipboard is built with the available information. + 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 "copyNodeContent". @@ -780,7 +782,7 @@ class UIGraph(QObject): can be used to generate a node. For example, it is enough to have: - {"nodeType":"CameraInit"} + {"nodeName": {"nodeType":"CameraInit"}} in the clipboard to create a default CameraInit node. """ if not clipboardContent: @@ -794,18 +796,35 @@ class UIGraph(QObject): if not isinstance(d, dict): raise ValueError("The clipboard does not contain a valid node. Cannot paste it.") - nodeType = d.get("nodeType", None) - if not nodeType: - raise ValueError("The clipboard does not contain a valid node. Cannot paste it.") + finalPosition = None + prevPosition = None - attributes = {} - attributes.update(d.get("inputs", {})) - attributes.update(d.get("outputs", {})) + for key in d: + nodeDesc = d[key] + nodeType = nodeDesc.get("nodeType", None) + if not nodeType: + pass - if isinstance(position, QPoint): - position = Position(position.x(), position.y()) + attributes = {} + attributes.update(nodeDesc.get("inputs", {})) + attributes.update(nodeDesc.get("outputs", {})) - self.push(commands.PasteNodeCommand(self._graph, nodeType, position=position, **attributes)) + currentPosition = nodeDesc.get("position", None) + if isinstance(position, QPoint) and not finalPosition: + finalPosition = Position(position.x(), position.y()) + else: + if prevPosition and currentPosition: + # if the nodes both have a position, recreate the distance between them from 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 lack a position, use a custom one + finalPosition = Position(finalPosition.x + self.layout.gridSpacing + self.layout.nodeWidth, finalPosition.y) + + self.push(commands.PasteNodeCommand(self._graph, nodeType, position=finalPosition, **attributes)) + prevPosition = currentPosition undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 7f355a6a..bb2d8554 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -77,9 +77,9 @@ Item { } /// Copy node content to clipboard - function copyNode() + function copyNodes() { - var nodeContent = uigraph.getSelectedNodeContent() + var nodeContent = uigraph.getSelectedNodesContent() if (nodeContent !== '') { Clipboard.clear() Clipboard.setText(nodeContent) @@ -87,11 +87,11 @@ Item { } /// Paste content of clipboard to graph editor and create new node if valid - function pasteNode() + function pasteNodes() { root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) var copiedContent = Clipboard.getText() - uigraph.pasteNode(copiedContent, root.pastePosition) + uigraph.pasteNodes(copiedContent, root.pastePosition) } Keys.onPressed: { @@ -107,10 +107,10 @@ Item { if (event.key === Qt.Key_C) if (event.modifiers == Qt.ControlModifier) - copyNode() + copyNodes() if (event.key === Qt.Key_V) if (event.modifiers == Qt.ControlModifier) - pasteNode() + pasteNodes() } MouseArea { From ede24713d03ebb2b9e6471f19de7e8556b89d1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Thu, 25 Aug 2022 11:10:59 +0200 Subject: [PATCH 06/11] Add "Copy" and "Paste" actions in the "Edit" menu Also add a "Copy Node(s)" option in the nodes' right click menu. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 12 +++--- meshroom/ui/qml/main.qml | 46 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index bb2d8554..6252d3d5 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -104,13 +104,6 @@ Item { uigraph.removeNodes(uigraph.selectedNodes) if (event.key === Qt.Key_D) duplicateNode(event.modifiers == Qt.AltModifier) - - if (event.key === Qt.Key_C) - if (event.modifiers == Qt.ControlModifier) - copyNodes() - if (event.key === Qt.Key_V) - if (event.modifiers == Qt.ControlModifier) - pasteNodes() } MouseArea { @@ -407,6 +400,11 @@ Item { onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) } MenuSeparator {} + MenuItem { + text: "Copy Node(s)" + enabled: true + onTriggered: copyNodes() + } MenuItem { text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "") enabled: true diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 5b4197d6..91d72ee7 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -393,6 +393,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" @@ -568,6 +604,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" From a33f79e4d70a2e50e14e3a61c20571f13b01f857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Wed, 31 Aug 2022 10:50:35 +0200 Subject: [PATCH 07/11] Import a scene from an .mg file into the current graph Add an "Import Scene" (Ctrl+Shift+I) option in the File menu that allows to select an existing .mg file and import it in the current graph. The imported scene will automatically be placed below the lowest existing node along the Y axis. --- meshroom/core/graph.py | 100 ++++++++++++++++++++++++++++++++++++++- meshroom/ui/commands.py | 33 ++++++++++++- meshroom/ui/graph.py | 13 +++++ meshroom/ui/qml/main.qml | 16 +++++++ 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index dd86c4bc..c986705f 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)) @@ -278,6 +285,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() @@ -288,6 +298,85 @@ 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 = {} + updatedData = {} + + 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 + + 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 + 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 + + # 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(): + inputs = nodeData.get("inputs", {}) + outputs = nodeData.get("outputs", {}) + + if inputs: + inputs = updateLinks(inputs) + updatedData[nodeName]["inputs"] = inputs + if outputs: + outputs = updateLinks(outputs) + updatedData[nodeName]["outputs"] = outputs + + return updatedData + @property def updateEnabled(self): return self._updateEnabled @@ -441,6 +530,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 @@ -1188,6 +1279,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 614e465f..86024d54 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): @@ -216,6 +216,37 @@ class PasteNodeCommand(GraphCommand): self.graph.removeNode(self.nodeName) +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/graph.py b/meshroom/ui/graph.py index 2dc6adad..ad4be81e 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -353,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): if isinstance(url, (str)): diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 91d72ee7..bf9ae26a 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -304,6 +304,16 @@ ApplicationWindow { } } + FileDialog { + id: importSceneDialog + title: "Import Scene" + selectMultiple: false + nameFilters: ["Meshroom Graphs (*.mg)"] + onAccepted: { + graphEditor.uigraph.importScene(importSceneDialog.fileUrl) + } + } + AboutDialog { id: aboutDialog } @@ -530,6 +540,12 @@ ApplicationWindow { } } } + Action { + id: importSceneAction + text: "Import Scene" + shortcut: "Ctrl+Shift+I" + onTriggered: importSceneDialog.open() + } Action { id: importActionItem text: "Import Images" From d06acf6722853ba4a4a28bd27d586ebc300b5f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Tue, 6 Sep 2022 11:13:29 +0200 Subject: [PATCH 08/11] 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. --- meshroom/core/graph.py | 143 +++++++++++++++----- meshroom/ui/commands.py | 24 ++-- meshroom/ui/graph.py | 48 ++++--- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 7 +- 4 files changed, 161 insertions(+), 61 deletions(-) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index c986705f..ae640686 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -314,9 +314,8 @@ class Graph(BaseObject): Returns: updatedData (dict): the dictionary containing all the nodes to import with their updated names and data """ - - nameCorrespondences = {} - updatedData = {} + nameCorrespondences = {} # maps the old node name to its updated one + updatedData = {} # input data with updated node names and links def createUniqueNodeName(nodeNames, inputName): """ @@ -330,26 +329,6 @@ class Graph(BaseObject): return newName 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 for nodeName, nodeData in sorted(data.items(), key=lambda x: self.getNodeIndexFromName(x[0])): if not isinstance(nodeData, dict): @@ -363,20 +342,97 @@ class Graph(BaseObject): 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 = updateLinks(inputs) + inputs = self.updateLinks(inputs, nameCorrespondences) + inputs = self.resetExternalLinks(inputs, nodeDesc.inputs, newNames) updatedData[nodeName]["inputs"] = inputs if outputs: - outputs = updateLinks(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 @@ -485,14 +541,39 @@ class Graph(BaseObject): return duplicates - def pasteNode(self, nodeType, position, **kwargs): - name = self._createUniqueNodeName(nodeType) - node = None + 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): - node = Node(nodeType, position=position, **kwargs) - self._addNode(node, name) + 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 node + return nodes def outEdges(self, attribute): """ Return the list of edges starting from the given attribute """ diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index 86024d54..c34d674b 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -195,25 +195,26 @@ class DuplicateNodesCommand(GraphCommand): self.graph.removeNode(duplicate) -class PasteNodeCommand(GraphCommand): +class PasteNodesCommand(GraphCommand): """ Handle node pasting in a Graph. """ - def __init__(self, graph, nodeType, position=None, parent=None, **kwargs): - super(PasteNodeCommand, self).__init__(graph, parent) - self.nodeType = nodeType + def __init__(self, graph, data, position=None, parent=None): + super(PasteNodesCommand, self).__init__(graph, parent) + self.data = data self.position = position - self.nodeName = None - self.kwargs = kwargs + self.nodeNames = [] def redoImpl(self): - node = self.graph.pasteNode(self.nodeType, self.position, **self.kwargs) - self.nodeName = node.name - self.setText("Paste Node {}".format(self.nodeName)) - return node + 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): - self.graph.removeNode(self.nodeName) + for name in self.nodeNames: + self.graph.removeNode(name) class ImportSceneCommand(GraphCommand): @@ -247,6 +248,7 @@ class ImportSceneCommand(GraphCommand): 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/graph.py b/meshroom/ui/graph.py index ad4be81e..af5c49eb 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -781,7 +781,7 @@ class UIGraph(QObject): return json.dumps(selection, indent=4) return '' - @Slot(str, QPoint) + @Slot(str, QPoint, result="QVariantList") def pasteNodes(self, clipboardContent, position=None): """ 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. 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 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": {"nodeType":"CameraInit"}} - in the clipboard to create a default CameraInit node. + {"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 - d = {} + try: d = json.loads(clipboardContent) except ValueError as e: @@ -809,35 +817,39 @@ class UIGraph(QObject): 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 + finalPosition = None prevPosition = None + positions = [] - for key in d: - nodeDesc = d[key] - nodeType = nodeDesc.get("nodeType", None) + for key in sorted(d): + nodeType = d[key].get("nodeType", None) if not nodeType: - pass + raise ValueError("Invalid node description: no provided node type for '{}'".format(key)) - attributes = {} - attributes.update(nodeDesc.get("inputs", {})) - attributes.update(nodeDesc.get("outputs", {})) - - currentPosition = nodeDesc.get("position", None) + currentPosition = d[key].get("position", None) if isinstance(position, QPoint) and not finalPosition: finalPosition = Position(position.x(), position.y()) else: 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 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 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) - - self.push(commands.PasteNodeCommand(self._graph, nodeType, position=finalPosition, **attributes)) 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() diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 6252d3d5..d0bdb32c 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -91,7 +91,12 @@ Item { { root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) 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: { From 6bb0ad45088e59c543ce2dd142fe1d79247dadb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Tue, 30 Aug 2022 18:46:38 +0200 Subject: [PATCH 09/11] Add a "Paste Node(s)" option in the nodes' right click menu The "Paste Node(s)" option copies the selected nodes to the clipboard (as does the "Copy Node(s)" option) and immediately pastes them into the graph. --- meshroom/ui/qml/GraphEditor/GraphEditor.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index d0bdb32c..9b91670a 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -408,8 +408,20 @@ Item { 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 From 9f1b866e78941cb5f5fb52e9319064468200b9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Tue, 6 Sep 2022 15:40:59 +0200 Subject: [PATCH 10/11] Paste the selected nodes relatively to the mouse's position The mouse's position is the top-left corner of a zone in which all the selected nodes will be pasted. The bottom-right corner of that zone is (x, y), with x the maximum of the selected nodes' position along the x-axis and y the maximum of the selected nodes' position along the y-axis. The nodes relative position to one another - if positions are provided - is preserved. If no node in the selection has a specified position, the nodes will be pasted from left to right alphabetically, with the top-left corner of the first node at the mouse's position. --- meshroom/ui/graph.py | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index af5c49eb..14fd3c7e 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -824,18 +824,48 @@ class UIGraph(QObject): if not d: return - finalPosition = None - prevPosition = None - positions = [] + if isinstance(position, QPoint): + position = Position(position.x(), position.y()) + # 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 isinstance(position, QPoint) and not finalPosition: - finalPosition = Position(position.x(), position.y()) + 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 From 61e0482a21eb4b1c2d00dfe41a47db4882def25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Candice=20Bent=C3=A9jac?= Date: Wed, 7 Sep 2022 20:18:40 +0200 Subject: [PATCH 11/11] Add an offset to the position of pasted nodes if a node is hovered If a node is being hovered when the user performs the paste, an offset is added to the mouse's position, both in the horizontal and vertical directions. This aims at avoiding a complete overlap with the nodes on top of which the clipboard content is pasted. --- meshroom/ui/graph.py | 3 +++ meshroom/ui/qml/GraphEditor/GraphEditor.qml | 7 ++++++- meshroom/ui/qml/GraphEditor/Node.qml | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 14fd3c7e..74bb3a7a 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -826,6 +826,9 @@ class UIGraph(QObject): 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 diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 9b91670a..034331c1 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -89,7 +89,12 @@ Item { /// Paste content of clipboard to graph editor and create new node if valid function pasteNodes() { - root.pastePosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) + 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) { 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