mirror of
https://github.com/alicevision/Meshroom.git
synced 2025-04-29 18:27:23 +02:00
Merge pull request #1758 from alicevision/dev/copyPasteNode
Support copying/pasting a node in the Graph Editor
This commit is contained in:
commit
27faf9f77c
7 changed files with 498 additions and 7 deletions
|
@ -219,6 +219,7 @@ class Graph(BaseObject):
|
||||||
self._canComputeLeaves = True
|
self._canComputeLeaves = True
|
||||||
self._nodes = DictModel(keyAttrName='name', parent=self)
|
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._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._compatibilityNodes = DictModel(keyAttrName='name', parent=self)
|
||||||
self.cacheDir = meshroom.core.defaultCacheFolder
|
self.cacheDir = meshroom.core.defaultCacheFolder
|
||||||
self._filepath = ''
|
self._filepath = ''
|
||||||
|
@ -231,6 +232,7 @@ class Graph(BaseObject):
|
||||||
# Tell QML nodes are going to be deleted
|
# Tell QML nodes are going to be deleted
|
||||||
for node in self._nodes:
|
for node in self._nodes:
|
||||||
node.alive = False
|
node.alive = False
|
||||||
|
self._importedNodes.clear()
|
||||||
self._nodes.clear()
|
self._nodes.clear()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -239,7 +241,7 @@ class Graph(BaseObject):
|
||||||
return Graph.IO.getFeaturesForVersion(self.header.get(Graph.IO.Keys.FileVersion, "0.0"))
|
return Graph.IO.getFeaturesForVersion(self.header.get(Graph.IO.Keys.FileVersion, "0.0"))
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def load(self, filepath, setupProjectFile=True):
|
def load(self, filepath, setupProjectFile=True, importScene=False):
|
||||||
"""
|
"""
|
||||||
Load a meshroom graph ".mg" file.
|
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.
|
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.
|
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:
|
with open(filepath) as jsonFile:
|
||||||
fileData = json.load(jsonFile)
|
fileData = json.load(jsonFile)
|
||||||
|
|
||||||
# older versions of Meshroom files only contained the serialized nodes
|
# older versions of Meshroom files only contained the serialized nodes
|
||||||
graphData = fileData.get(Graph.IO.Keys.Graph, fileData)
|
graphData = fileData.get(Graph.IO.Keys.Graph, fileData)
|
||||||
|
|
||||||
|
if importScene:
|
||||||
|
self._importedNodes.clear()
|
||||||
|
graphData = self.updateImportedScene(graphData)
|
||||||
|
|
||||||
if not isinstance(graphData, dict):
|
if not isinstance(graphData, dict):
|
||||||
raise RuntimeError('loadGraph error: Graph is not a dict. File: {}'.format(filepath))
|
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
|
# Add node to the graph with raw attributes values
|
||||||
self._addNode(n, nodeName)
|
self._addNode(n, nodeName)
|
||||||
|
|
||||||
|
if importScene:
|
||||||
|
self._importedNodes.add(n)
|
||||||
|
|
||||||
# Create graph edges by resolving attributes expressions
|
# Create graph edges by resolving attributes expressions
|
||||||
self._applyExpr()
|
self._applyExpr()
|
||||||
|
|
||||||
|
@ -292,6 +302,141 @@ class Graph(BaseObject):
|
||||||
|
|
||||||
return True
|
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
|
@property
|
||||||
def updateEnabled(self):
|
def updateEnabled(self):
|
||||||
return self._updateEnabled
|
return self._updateEnabled
|
||||||
|
@ -400,6 +545,40 @@ class Graph(BaseObject):
|
||||||
|
|
||||||
return duplicates
|
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):
|
def outEdges(self, attribute):
|
||||||
""" Return the list of edges starting from the given attribute """
|
""" Return the list of edges starting from the given attribute """
|
||||||
# type: (Attribute,) -> [Edge]
|
# type: (Attribute,) -> [Edge]
|
||||||
|
@ -436,6 +615,8 @@ class Graph(BaseObject):
|
||||||
|
|
||||||
node.alive = False
|
node.alive = False
|
||||||
self._nodes.remove(node)
|
self._nodes.remove(node)
|
||||||
|
if node in self._importedNodes:
|
||||||
|
self._importedNodes.remove(node)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
return inEdges, outEdges
|
return inEdges, outEdges
|
||||||
|
@ -1220,6 +1401,11 @@ class Graph(BaseObject):
|
||||||
def edges(self):
|
def edges(self):
|
||||||
return self._edges
|
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
|
@property
|
||||||
def cacheDir(self):
|
def cacheDir(self):
|
||||||
return self._cacheDir
|
return self._cacheDir
|
||||||
|
|
|
@ -7,7 +7,7 @@ from PySide2.QtCore import Property, Signal
|
||||||
|
|
||||||
from meshroom.core.attribute import ListAttribute, Attribute
|
from meshroom.core.attribute import ListAttribute, Attribute
|
||||||
from meshroom.core.graph import GraphModification
|
from meshroom.core.graph import GraphModification
|
||||||
from meshroom.core.node import nodeFactory
|
from meshroom.core.node import nodeFactory, Position
|
||||||
|
|
||||||
|
|
||||||
class UndoCommand(QUndoCommand):
|
class UndoCommand(QUndoCommand):
|
||||||
|
@ -195,6 +195,60 @@ class DuplicateNodesCommand(GraphCommand):
|
||||||
self.graph.removeNode(duplicate)
|
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):
|
class SetAttributeCommand(GraphCommand):
|
||||||
def __init__(self, graph, attribute, value, parent=None):
|
def __init__(self, graph, attribute, value, parent=None):
|
||||||
super(SetAttributeCommand, self).__init__(graph, parent)
|
super(SetAttributeCommand, self).__init__(graph, parent)
|
||||||
|
|
|
@ -11,10 +11,21 @@ class ClipboardHelper(QObject):
|
||||||
super(ClipboardHelper, self).__init__(parent)
|
super(ClipboardHelper, self).__init__(parent)
|
||||||
self._clipboard = QClipboard(parent=self)
|
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)
|
@Slot(str)
|
||||||
def setText(self, value):
|
def setText(self, value):
|
||||||
self._clipboard.setText(value)
|
self._clipboard.setText(value)
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def getText(self):
|
||||||
|
return self._clipboard.text()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self._clipboard.clear()
|
self._clipboard.clear()
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Thread, Event, Lock
|
from threading import Thread, Event, Lock
|
||||||
from multiprocessing.pool import ThreadPool
|
from multiprocessing.pool import ThreadPool
|
||||||
|
@ -352,6 +353,19 @@ class UIGraph(QObject):
|
||||||
self.setGraph(g)
|
self.setGraph(g)
|
||||||
return status
|
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)
|
@Slot(QUrl)
|
||||||
def saveAs(self, url):
|
def saveAs(self, url):
|
||||||
self._saveAs(url)
|
self._saveAs(url)
|
||||||
|
@ -761,6 +775,123 @@ class UIGraph(QObject):
|
||||||
""" Reset currently hovered node to None. """
|
""" Reset currently hovered node to None. """
|
||||||
self.hoveredNode = 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)
|
undoStack = Property(QObject, lambda self: self._undoStack, constant=True)
|
||||||
graphChanged = Signal()
|
graphChanged = Signal()
|
||||||
graph = Property(Graph, lambda self: self._graph, notify=graphChanged)
|
graph = Property(Graph, lambda self: self._graph, notify=graphChanged)
|
||||||
|
|
|
@ -41,6 +41,7 @@ Item {
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
SystemPalette { id: activePalette }
|
SystemPalette { id: activePalette }
|
||||||
|
property point pastePosition
|
||||||
|
|
||||||
/// Get node delegate for the given node object
|
/// Get node delegate for the given node object
|
||||||
function nodeDelegate(node)
|
function nodeDelegate(node)
|
||||||
|
@ -75,16 +76,43 @@ Item {
|
||||||
uigraph.selectNodes(nodes)
|
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: {
|
Keys.onPressed: {
|
||||||
if(event.key === Qt.Key_F)
|
if (event.key === Qt.Key_F)
|
||||||
fit()
|
fit()
|
||||||
if(event.key === Qt.Key_Delete)
|
if (event.key === Qt.Key_Delete)
|
||||||
if(event.modifiers == Qt.AltModifier)
|
if (event.modifiers == Qt.AltModifier)
|
||||||
uigraph.removeNodesFrom(uigraph.selectedNodes)
|
uigraph.removeNodesFrom(uigraph.selectedNodes)
|
||||||
else
|
else
|
||||||
uigraph.removeNodes(uigraph.selectedNodes)
|
uigraph.removeNodes(uigraph.selectedNodes)
|
||||||
if(event.key === Qt.Key_D)
|
if (event.key === Qt.Key_D)
|
||||||
duplicateNode(event.modifiers == Qt.AltModifier)
|
duplicateNode(event.modifiers == Qt.AltModifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,6 +410,23 @@ Item {
|
||||||
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
|
onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder))
|
||||||
}
|
}
|
||||||
MenuSeparator {}
|
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 {
|
MenuItem {
|
||||||
text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "")
|
text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "")
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
|
@ -31,6 +31,8 @@ Item {
|
||||||
readonly property color defaultColor: isCompatibilityNode ? "#444" : activePalette.base
|
readonly property color defaultColor: isCompatibilityNode ? "#444" : activePalette.base
|
||||||
property color baseColor: defaultColor
|
property color baseColor: defaultColor
|
||||||
|
|
||||||
|
property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY)
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: m
|
id: m
|
||||||
property bool displayParams: false
|
property bool displayParams: false
|
||||||
|
|
|
@ -321,6 +321,16 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: importSceneDialog
|
||||||
|
title: "Import Scene"
|
||||||
|
selectMultiple: false
|
||||||
|
nameFilters: ["Meshroom Graphs (*.mg)"]
|
||||||
|
onAccepted: {
|
||||||
|
graphEditor.uigraph.importScene(importSceneDialog.fileUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AboutDialog {
|
AboutDialog {
|
||||||
id: aboutDialog
|
id: aboutDialog
|
||||||
}
|
}
|
||||||
|
@ -410,6 +420,42 @@ ApplicationWindow {
|
||||||
enabled: _reconstruction.undoStack.canRedo && !_reconstruction.undoStack.lockedRedo
|
enabled: _reconstruction.undoStack.canRedo && !_reconstruction.undoStack.lockedRedo
|
||||||
onTriggered: _reconstruction.undoStack.redo()
|
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 {
|
Action {
|
||||||
shortcut: "Ctrl+Shift+P"
|
shortcut: "Ctrl+Shift+P"
|
||||||
|
@ -511,6 +557,12 @@ ApplicationWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action {
|
||||||
|
id: importSceneAction
|
||||||
|
text: "Import Scene"
|
||||||
|
shortcut: "Ctrl+Shift+I"
|
||||||
|
onTriggered: importSceneDialog.open()
|
||||||
|
}
|
||||||
Action {
|
Action {
|
||||||
id: importActionItem
|
id: importActionItem
|
||||||
text: "Import Images"
|
text: "Import Images"
|
||||||
|
@ -596,6 +648,16 @@ ApplicationWindow {
|
||||||
ToolTip.visible: hovered
|
ToolTip.visible: hovered
|
||||||
ToolTip.text: redoAction.tooltip
|
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 {
|
Menu {
|
||||||
title: "View"
|
title: "View"
|
||||||
|
|
Loading…
Add table
Reference in a new issue