diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 89faefb5..817af336 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -261,6 +261,9 @@ class Graph(BaseObject): self.header = fileData.get(Graph.IO.Keys.Header, {}) nodesVersions = self.header.get(Graph.IO.Keys.NodesVersions, {}) + # check whether the file was saved as a template in minimal mode + isTemplate = self.header.get("template", False) + with GraphModification(self): # iterate over nodes sorted by suffix index in their names for nodeName, nodeData in sorted(graphData.items(), key=lambda x: self.getNodeIndexFromName(x[0])): @@ -273,7 +276,8 @@ class Graph(BaseObject): # 3. fallback to no version "0.0": retro-compatibility if "version" not in nodeData: nodeData["version"] = nodesVersions.get(nodeData["nodeType"], "0.0") - n = nodeFactory(nodeData, nodeName) + + n = nodeFactory(nodeData, nodeName, template=isTemplate) # Add node to the graph with raw attributes values self._addNode(n, nodeName) @@ -999,7 +1003,7 @@ class Graph(BaseObject): def asString(self): return str(self.toDict()) - def save(self, filepath=None, setupProjectFile=True): + def save(self, filepath=None, setupProjectFile=True, template=False): path = filepath or self._filepath if not path: raise ValueError("filepath must be specified for unsaved files.") @@ -1015,10 +1019,18 @@ class Graph(BaseObject): for p in usedNodeTypes } - data = { - Graph.IO.Keys.Header: self.header, - Graph.IO.Keys.Graph: self.toDict() - } + data = {} + if template: + self.header["template"] = True + data = { + Graph.IO.Keys.Header: self.header, + Graph.IO.Keys.Graph: self.getNonDefaultAttributes() + } + else: + data = { + Graph.IO.Keys.Header: self.header, + Graph.IO.Keys.Graph: self.toDict() + } with open(path, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) @@ -1026,6 +1038,35 @@ class Graph(BaseObject): if path != self._filepath and setupProjectFile: self._setFilepath(path) + def getNonDefaultAttributes(self): + """ + Instead of getting all the inputs/outputs attribute keys, only get the keys of + the attributes whose value is not the default one. + + Returns: + dict: self.toDict() with all the inputs/outputs attributes with default values removed + """ + graph = self.toDict() + for nodeName in graph.keys(): + node = self.node(nodeName) + + inputKeys = list(graph[nodeName]["inputs"].keys()) + outputKeys = list(graph[nodeName]["outputs"].keys()) + + for attrName in inputKeys: + attribute = node.attribute(attrName) + # check that attribute is not a link for choice attributes + if attribute.isDefault and not attribute.isLink: + del graph[nodeName]["inputs"][attrName] + + for attrName in outputKeys: + attribute = node.attribute(attrName) + # check that attribute is not a link for choice attributes + if attribute.isDefault and not attribute.isLink: + del graph[nodeName]["outputs"][attrName] + + return graph + def _setFilepath(self, filepath): """ Set the internal filepath of this Graph. diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 2149c8cd..ecaab7fc 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1393,7 +1393,7 @@ class CompatibilityNode(BaseNode): issueDetails = Property(str, issueDetails.fget, constant=True) -def nodeFactory(nodeDict, name=None): +def nodeFactory(nodeDict, name=None, template=False): """ Create a node instance by deserializing the given node data. If the serialized data matches the corresponding node type description, a Node instance is created. @@ -1437,9 +1437,10 @@ def nodeFactory(nodeDict, name=None): compatibilityIssue = CompatibilityIssue.VersionConflict # in other cases, check attributes compatibility between serialized node and its description else: - # check that the node has the exact same set of inputs/outputs as its description - if sorted([attr.name for attr in nodeDesc.inputs]) != sorted(inputs.keys()) or \ - sorted([attr.name for attr in nodeDesc.outputs]) != sorted(outputs.keys()): + # check that the node has the exact same set of inputs/outputs as its description, except if the node + # is described in a template file, in which only non-default parameters are saved + if not template and (sorted([attr.name for attr in nodeDesc.inputs]) != sorted(inputs.keys()) or \ + sorted([attr.name for attr in nodeDesc.outputs]) != sorted(outputs.keys())): compatibilityIssue = CompatibilityIssue.DescriptionConflict # verify that all inputs match their descriptions for attrName, value in inputs.items(): @@ -1463,5 +1464,7 @@ def nodeFactory(nodeDict, name=None): if not internalFolder and nodeDesc: logging.warning("No serialized output data: performing automatic upgrade on '{}'".format(name)) node = node.upgrade() + elif template: # if the node comes from a template file and there is a conflict, it should be upgraded anyway + node = node.upgrade() return node diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 71846a9f..0b450562 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -354,6 +354,14 @@ class UIGraph(QObject): @Slot(QUrl) def saveAs(self, url): + self._saveAs(url) + + @Slot(QUrl) + def saveAsTemplate(self, url): + self._saveAs(url, setupProjectFile=False, template=True) + + def _saveAs(self, url, setupProjectFile=True, template=False): + """ Helper function for 'save as' features. """ if isinstance(url, (str)): localFile = url else: @@ -361,7 +369,7 @@ class UIGraph(QObject): # ensure file is saved with ".mg" extension if os.path.splitext(localFile)[-1] != ".mg": localFile += ".mg" - self._graph.save(localFile) + self._graph.save(localFile, setupProjectFile=setupProjectFile, template=template) self._undoStack.setClean() # saving file on disk impacts cache folder location # => force re-evaluation of monitored status files paths diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 5b4197d6..63f056f4 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -142,6 +142,22 @@ ApplicationWindow { onRejected: closed(Platform.Dialog.Rejected) } + Platform.FileDialog { + id: saveTemplateDialog + + signal closed(var result) + + title: "Save Template" + nameFilters: ["Meshroom Graphs (*.mg)"] + defaultSuffix: ".mg" + fileMode: Platform.FileDialog.SaveFile + onAccepted: { + _reconstruction.saveAsTemplate(file) + closed(Platform.Dialog.Accepted) + } + onRejected: closed(Platform.Dialog.Rejected) + } + Item { id: computeManager @@ -550,6 +566,17 @@ ApplicationWindow { saveFileDialog.open() } } + Action { + id: saveAsTemplateAction + text: "Save As Template..." + shortcut: "Ctrl+Shift+T" + onTriggered: { + if(_reconstruction.graph && _reconstruction.graph.filepath) { + saveTemplateDialog.folder = Filepath.stringToUrl(Filepath.dirname(_reconstruction.graph.filepath)) + } + saveTemplateDialog.open() + } + } MenuSeparator { } Action { text: "Quit"