[ui] Refactor node pasting using graph partial serialization

Re-implement node pasting by relying on the graph partial serializer,
to serialize only the subset of selected nodes.
On pasting, use standard graph deserialization and import the content
of the serialized graph in the active graph instance.

Simplify the positioning of pasted nodes to only consider mouse position
or center of the graph, which works well for the major variety of use-cases.
Compute the offset to apply to imported nodes by using the de-serialized
graph content's bounding box.
This commit is contained in:
Yann Lanthony 2025-02-06 16:46:04 +01:00
parent f8f03b0bd5
commit d54ba012a0
4 changed files with 63 additions and 158 deletions

View file

@ -676,41 +676,6 @@ 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", {}))
attributes.update(data[key].get("internalInputs", {}))
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]

View file

@ -211,15 +211,27 @@ class PasteNodesCommand(GraphCommand):
""" """
Handle node pasting in a Graph. Handle node pasting in a Graph.
""" """
def __init__(self, graph, data, position=None, parent=None): def __init__(self, graph: "Graph", data: dict, position: Position, parent=None):
super(PasteNodesCommand, self).__init__(graph, parent) super(PasteNodesCommand, self).__init__(graph, parent)
self.data = data self.data = data
self.position = position self.position = position
self.nodeNames = [] self.nodeNames: list[str] = []
def redoImpl(self): def redoImpl(self):
data = self.graph.updateImportedProject(self.data) graph = Graph("")
nodes = self.graph.pasteNodes(data, self.position) try:
graph._deserialize(self.data)
except:
return False
boundingBoxCenter = self._boundingBoxCenter(graph.nodes)
offset = Position(self.position.x - boundingBoxCenter.x, self.position.y - boundingBoxCenter.y)
for node in graph.nodes:
node.position = Position(node.position.x + offset.x, node.position.y + offset.y)
nodes = self.graph.importGraphContent(graph)
self.nodeNames = [node.name for node in nodes] self.nodeNames = [node.name for node in nodes]
self.setText("Paste Node{} ({})".format("s" if len(self.nodeNames) > 1 else "", ", ".join(self.nodeNames))) self.setText("Paste Node{} ({})".format("s" if len(self.nodeNames) > 1 else "", ", ".join(self.nodeNames)))
return nodes return nodes
@ -228,6 +240,24 @@ class PasteNodesCommand(GraphCommand):
for name in self.nodeNames: for name in self.nodeNames:
self.graph.removeNode(name) self.graph.removeNode(name)
def _boundingBox(self, nodes) -> tuple[int, int, int, int]:
if not nodes:
return (0, 0, 0 , 0)
minX = maxX = nodes[0].x
minY = maxY = nodes[0].y
for node in nodes[1:]:
minX = min(minX, node.x)
minY = min(minY, node.y)
maxX = max(maxX, node.x)
maxY = max(maxY, node.y)
return (minX, minY, maxX, maxY)
def _boundingBoxCenter(self, nodes):
minX, minY, maxX, maxY = self._boundingBox(nodes)
return Position((minX + maxX) / 2, (minY + maxY) / 2)
class ImportProjectCommand(GraphCommand): class ImportProjectCommand(GraphCommand):
""" """

View file

@ -1050,126 +1050,43 @@ class UIGraph(QObject):
""" """
if not self._nodeSelection.hasSelection(): if not self._nodeSelection.hasSelection():
return "" return ""
serializedSelection = {node.name: node.toDict() for node in self.iterSelectedNodes()} graphData = self._graph.serializePartial(self.getSelectedNodes())
return json.dumps(serializedSelection, indent=4) return json.dumps(graphData, indent=4)
@Slot(str, QPoint, bool, result=list) @Slot(str, QPoint, result=list)
def pasteNodes(self, clipboardContent, position=None, centerPosition=False) -> list[Node]: def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> list[Node]:
""" """
Parse the content of the clipboard to see whether it contains Import string-serialized graph content `serializedData` in the current graph, optionally at the given
valid node descriptions. If that is the case, the nodes described `position`.
in the clipboard are built with the available information. If the `serializedData` does not contain valid serialized graph data, nothing is done.
Otherwise, nothing is done.
This function does not need to be preceded by a call to "getSelectedNodesContent". This method can be used with the result of "getSelectedNodesContent".
Any clipboard content that contains at least a node type with a valid JSON But it also accepts any serialized content that matches the graph data or graph content format.
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: For example, it is enough to have:
{"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}} {"nodeName_1": {"nodeType":"CameraInit"}, "nodeName_2": {"nodeType":"FeatureMatching"}}
in the clipboard to create a default CameraInit and a default FeatureMatching nodes. in `serializedData` to create a default CameraInit and a default FeatureMatching nodes.
Args: Args:
clipboardContent (str): the string contained in the clipboard, that may or may not contain valid serializedData: The string-serialized graph data.
node information position: The position where to paste the nodes. If None, the nodes are pasted at (0, 0).
position (QPoint): the position of the mouse in the Graph Editor when the function was called
centerPosition (bool): whether the provided position is not the top-left corner of the pasting
zone, but its center
Returns: Returns:
list: the list of Node objects that were pasted and added to the graph list: the list of Node objects that were pasted and added to the graph
""" """
if not clipboardContent:
return
try: try:
d = json.loads(clipboardContent) graphData = json.loads(serializedData)
except ValueError as e: except json.JSONDecodeError:
raise ValueError(e) logging.warning("Content is not a valid JSON string.")
return []
if not isinstance(d, dict): pos = Position(position.x(), position.y()) if position else Position(0, 0)
raise ValueError("The clipboard does not contain a valid node. Cannot paste it.") result = self.push(commands.PasteNodesCommand(self._graph, graphData, pos))
if result is False:
logging.warning("Content is not a valid graph data.")
return []
return result
# 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
maxX = 0
minY = 0
maxY = 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]
maxX = pos[0]
minY = pos[1]
maxY = pos[1]
else:
if minX > pos[0]:
minX = pos[0]
if maxX < pos[0]:
maxX = pos[0]
if minY > pos[1]:
minY = pos[1]
if maxY < pos[1]:
maxY = 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)
if centerPosition: # Center the zone around the mouse's position (mouse's position might be artificial)
maxX = maxX + self.layout.nodeWidth # maxX and maxY are the position of the furthest node's top-left corner
maxY = maxY + self.layout.nodeHeight # We want the position of the furthest node's bottom-right corner
position = Position(position.x - ((maxX - minX) / 2), position.y - ((maxY - minY) / 2))
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()

View file

@ -82,25 +82,18 @@ Item {
/// Paste content of clipboard to graph editor and create new node if valid /// Paste content of clipboard to graph editor and create new node if valid
function pasteNodes() { function pasteNodes() {
var finalPosition = undefined let finalPosition = undefined;
var centerPosition = false
if (mouseArea.containsMouse) { if (mouseArea.containsMouse) {
if (uigraph.hoveredNode !== null) { finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY);
var node = nodeDelegate(uigraph.hoveredNode)
finalPosition = Qt.point(node.mousePosition.x + node.x, node.mousePosition.y + node.y)
} else {
finalPosition = mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY)
}
} else { } else {
finalPosition = getCenterPosition() finalPosition = getCenterPosition();
centerPosition = true
} }
var copiedContent = Clipboard.getText() const copiedContent = Clipboard.getText();
var nodes = uigraph.pasteNodes(copiedContent, finalPosition, centerPosition) const nodes = uigraph.pasteNodes(copiedContent, finalPosition);
if (nodes.length > 0) { if (nodes.length > 0) {
uigraph.selectedNode = nodes[0] uigraph.selectedNode = nodes[0];
uigraph.selectNodes(nodes) uigraph.selectNodes(nodes);
} }
} }