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] 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"