Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ui] add support for selecting multiple nodes at once #1227

Merged
merged 7 commits into from
May 3, 2021
2 changes: 2 additions & 0 deletions meshroom/common/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def pop(self, key):
############
# List API #
############
@QtCore.Slot(QtCore.QObject)
def append(self, obj):
""" Insert object at the end of the model. """
self.extend([obj])
Expand Down Expand Up @@ -182,6 +183,7 @@ def removeAt(self, i, count=1):
self.endRemoveRows()
self.countChanged.emit()

@QtCore.Slot(QtCore.QObject)
def remove(self, obj):
""" Removes the first occurrence of the given object. Raises a ValueError if not in list. """
if not self.contains(obj):
Expand Down
25 changes: 3 additions & 22 deletions meshroom/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,29 +362,15 @@ def copyNode(self, srcNode, withEdges=False):
child.resetValue()
return node, skippedEdges

def duplicateNode(self, srcNode):
""" Duplicate a node in the graph with its connections.
def duplicateNodes(self, srcNodes):
""" Duplicate nodes in the graph with their connections.

Args:
srcNode: the node to duplicate

Returns:
Node: the created node
"""
node, edges = self.copyNode(srcNode, withEdges=True)
return self.addNode(node)

def duplicateNodesFromNode(self, fromNode):
"""
Duplicate 'fromNode' and all the following nodes towards graph's leaves.

Args:
fromNode (Node): the node to start the duplication from
srcNodes: the nodes to duplicate

Returns:
OrderedDict[Node, Node]: the source->duplicate map
"""
srcNodes, srcEdges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True, dependenciesOnly=True)
# use OrderedDict to keep duplicated nodes creation order
duplicates = OrderedDict()

Expand Down Expand Up @@ -1146,11 +1132,6 @@ def clearSubmittedNodes(self):
for node in self.nodes:
node.clearSubmittedChunks()

@Slot(Node)
def clearDataFrom(self, startNode):
for node in self.dfsOnDiscover(startNodes=[startNode], reverse=True, dependenciesOnly=True)[0]:
node.clearData()

def iterChunksByStatus(self, status):
""" Iterate over NodeChunks with the given status """
for node in self.nodes:
Expand Down
1 change: 0 additions & 1 deletion meshroom/core/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,6 @@ def hasStatus(self, status):
def _isComputed(self):
return self.hasStatus(Status.SUCCESS)

@Slot()
def clearData(self):
""" Delete this Node internal folder.
Status will be reset to Status.NONE
Expand Down
32 changes: 12 additions & 20 deletions meshroom/ui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def __init__(self, graph, node, parent=None):

def redoImpl(self):
# only keep outEdges since inEdges are serialized in nodeDict
inEdges, self.outEdges = self.graph.removeNode(self.nodeName)
_, self.outEdges = self.graph.removeNode(self.nodeName)
return True

def undoImpl(self):
Expand All @@ -173,33 +173,25 @@ def undoImpl(self):
self.graph.attribute(dstAttr))


class DuplicateNodeCommand(GraphCommand):
class DuplicateNodesCommand(GraphCommand):
"""
Handle node duplication in a Graph.
"""
def __init__(self, graph, srcNode, duplicateFollowingNodes, parent=None):
super(DuplicateNodeCommand, self).__init__(graph, parent)
self.srcNodeName = srcNode.name
self.duplicateFollowingNodes = duplicateFollowingNodes
self.duplicates = []
def __init__(self, graph, srcNodes, parent=None):
super(DuplicateNodesCommand, self).__init__(graph, parent)
self.srcNodeNames = [ n.name for n in srcNodes ]
self.setText("Duplicate Nodes")

def redoImpl(self):
srcNode = self.graph.node(self.srcNodeName)

if self.duplicateFollowingNodes:
duplicates = list(self.graph.duplicateNodesFromNode(srcNode).values())
self.setText("Duplicate {} nodes from {}".format(len(duplicates), self.srcNodeName))
else:
duplicates = [self.graph.duplicateNode(srcNode)]
self.setText("Duplicate {}".format(self.srcNodeName))

self.duplicates = [n.name for n in duplicates]
srcNodes = [ self.graph.node(i) for i in self.srcNodeNames ]
duplicates = list(self.graph.duplicateNodes(srcNodes).values())
self.duplicates = [ n.name for n in duplicates ]
return duplicates

def undoImpl(self):
# delete all the duplicated nodes
for nodeName in self.duplicates:
self.graph.removeNode(nodeName)
# remove all duplicates
for duplicate in self.duplicates:
self.graph.removeNode(duplicate)


class SetAttributeCommand(GraphCommand):
Expand Down
186 changes: 145 additions & 41 deletions meshroom/ui/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def __init__(self, undoStack, taskManager, parent=None):
self._sortedDFSChunks = QObjectListModel(parent=self)
self._layout = GraphLayout(self)
self._selectedNode = None
self._selectedNodes = QObjectListModel(parent=self)
self._hoveredNode = None

self.computeStatusChanged.connect(self.updateLockedUndoStack)
Expand Down Expand Up @@ -499,36 +500,114 @@ def addNewNode(self, nodeType, position=None, **kwargs):
position = Position(position.x(), position.y())
return self.push(commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs))

@Slot(Node, QPoint)
def moveNode(self, node, position):
def filterNodes(self, nodes):
"""Filter out the nodes that do not exist on the graph."""
return [ n for n in nodes if n in self._graph.nodes.values() ]

@Slot(Node, QPoint, QObject)
def moveNode(self, node, position, nodes=None):
"""
Move 'node' to the given 'position'.
Move 'node' to the given 'position' and also update the positions of 'nodes' if neccessary.

Args:
node (Node): the node to move
position (QPoint): the target position
nodes (list[Node]): the nodes to update the position of
"""
if not nodes:
nodes = [node]
nodes = self.filterNodes(nodes)
if isinstance(position, QPoint):
position = Position(position.x(), position.y())
self.push(commands.MoveNodeCommand(self._graph, node, position))
deltaX = position.x - node.x
deltaY = position.y - node.y
with self.groupedGraphModification("Move Selected Nodes"):
for n in nodes:
position = Position(n.x + deltaX, n.y + deltaY)
self.push(commands.MoveNodeCommand(self._graph, n, position))

@Slot(QObject)
def removeNodes(self, nodes):
"""
Remove 'nodes' from the graph.

@Slot(Node)
def removeNode(self, node):
self.push(commands.RemoveNodeCommand(self._graph, node))
Args:
nodes (list[Node]): the nodes to remove
"""
nodes = self.filterNodes(nodes)
if any([ n.locked for n in nodes ]):
return
with self.groupedGraphModification("Remove Selected Nodes"):
for node in nodes:
self.push(commands.RemoveNodeCommand(self._graph, node))

@Slot(Node)
def removeNodesFrom(self, startNode):
@Slot(QObject)
def removeNodesFrom(self, nodes):
"""
Remove all nodes starting from 'startNode' to graph leaves.

Args:
startNode (Node): the node to start from.
"""
with self.groupedGraphModification("Remove Nodes from {}".format(startNode.name)):
nodes, _ = self._graph.dfsOnDiscover(startNodes=[startNode], reverse=True, dependenciesOnly=True)
with self.groupedGraphModification("Remove Nodes From Selected Nodes"):
nodesToRemove, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
# Perform nodes removal from leaves to start node so that edges
# can be re-created in correct order on redo.
for node in reversed(nodes):
self.removeNode(node)
self.removeNodes(list(reversed(nodesToRemove)))

@Slot(QObject, result="QVariantList")
def duplicateNodes(self, nodes):
"""
Duplicate 'nodes'.

Args:
nodes (list[Node]): the nodes to duplicate
Returns:
list[Node]: the list of duplicated nodes
"""
nodes = self.filterNodes(nodes)
# enable updates between duplication and layout to get correct depths during layout
with self.groupedGraphModification("Duplicate Selected Nodes", disableUpdates=False):
# disable graph updates during duplication
with self.groupedGraphModification("Node duplication", disableUpdates=True):
duplicates = self.push(commands.DuplicateNodesCommand(self._graph, nodes))
# move nodes below the bounding box formed by the duplicated node(s)
bbox = self._layout.boundingBox(duplicates)
for n in duplicates:
self.moveNode(n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y))
return duplicates

@Slot(QObject, result="QVariantList")
def duplicateNodesFrom(self, nodes):
"""
Duplicate all nodes starting from 'nodes' to graph leaves.

Args:
nodes (list[Node]): the nodes to start from.
Returns:
list[Node]: the list of duplicated nodes
"""
with self.groupedGraphModification("Duplicate Nodes From Selected Nodes"):
nodesToDuplicate, _ = self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)
duplicates = self.duplicateNodes(nodesToDuplicate)
return duplicates

@Slot(QObject)
def clearData(self, nodes):
""" Clear data from 'nodes'. """
nodes = self.filterNodes(nodes)
for n in nodes:
n.clearData()

@Slot(QObject)
def clearDataFrom(self, nodes):
"""
Clear data from all nodes starting from 'nodes' to graph leaves.

Args:
nodes (list[Node]): the nodes to start from.
"""
self.clearData(self._graph.dfsOnDiscover(startNodes=nodes, reverse=True, dependenciesOnly=True)[0])

@Slot(Attribute, Attribute)
def addEdge(self, src, dst):
Expand Down Expand Up @@ -557,31 +636,6 @@ def resetAttribute(self, attribute):
""" Reset 'attribute' to its default value """
self.push(commands.SetAttributeCommand(self._graph, attribute, attribute.defaultValue()))

@Slot(Node, bool, result="QVariantList")
def duplicateNode(self, srcNode, duplicateFollowingNodes=False):
"""
Duplicate a node an optionally all the following nodes to graph leaves.

Args:
srcNode (Node): node to start the duplication from
duplicateFollowingNodes (bool): whether to duplicate all the following nodes to graph leaves

Returns:
[Nodes]: the list of duplicated nodes
"""
title = "Duplicate Nodes from {}" if duplicateFollowingNodes else "Duplicate {}"
# enable updates between duplication and layout to get correct depths during layout
with self.groupedGraphModification(title.format(srcNode.name), disableUpdates=False):
# disable graph updates during duplication
with self.groupedGraphModification("Node duplication", disableUpdates=True):
duplicates = self.push(commands.DuplicateNodeCommand(self._graph, srcNode, duplicateFollowingNodes))
# move nodes below the bounding box formed by the duplicated node(s)
bbox = self._layout.boundingBox(duplicates)
for n in duplicates:
self.moveNode(n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y))

return duplicates

@Slot(CompatibilityNode, result=Node)
def upgradeNode(self, node):
""" Upgrade a CompatibilityNode. """
Expand Down Expand Up @@ -615,9 +669,55 @@ def appendAttribute(self, attribute, value=QJsonValue()):
def removeAttribute(self, attribute):
self.push(commands.ListAttributeRemoveCommand(self._graph, attribute))

@Slot(Node)
def appendSelection(self, node):
""" Append 'node' to the selection if it is not already part of the selection. """
if not self._selectedNodes.contains(node):
self._selectedNodes.append(node)

@Slot("QVariantList")
def selectNodes(self, nodes):
""" Append 'nodes' to the selection. """
for node in nodes:
self.appendSelection(node)
self.selectedNodesChanged.emit()

@Slot(Node)
def selectFollowing(self, node):
""" Select all the nodes the depend on 'node'. """
self.selectNodes(self._graph.dfsOnDiscover(startNodes=[node], reverse=True, dependenciesOnly=True)[0])

@Slot(QObject, QObject)
def boxSelect(self, selection, draggable):
"""
Select nodes that overlap with 'selection'.
Takes into account the zoom and position of 'draggable'.

Args:
selection: the rectangle selection widget.
draggable: the parent widget that has position and scale data.
"""
x = selection.x() - draggable.x()
y = selection.y() - draggable.y()
otherX = x + selection.width()
otherY = y + selection.height()
x, y, otherX, otherY = [ i / draggable.scale() for i in [x, y, otherX, otherY] ]
if x == otherX or y == otherY:
return
for n in self._graph.nodes:
bbox = self._layout.boundingBox([n])
# evaluate if the selection and node intersect
if not (x > bbox[2] + bbox[0] or otherX < bbox[0] or y > bbox[3] + bbox[1] or otherY < bbox[1]):
self.appendSelection(n)
self.selectedNodesChanged.emit()

@Slot()
def clearNodeSelection(self):
""" Clear node selection. """
self.selectedNode = None
""" Clear all node selection. """
self._selectedNode = None
self._selectedNodes.clear()
self.selectedNodeChanged.emit()
self.selectedNodesChanged.emit()

def clearNodeHover(self):
""" Reset currently hovered node to None. """
Expand All @@ -640,9 +740,13 @@ def clearNodeHover(self):
lockedChanged = Signal()

selectedNodeChanged = Signal()
# Currently selected node
# Current main selected node
selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True)

selectedNodesChanged = Signal()
# Currently selected nodes
selectedNodes = makeProperty(QObject, "_selectedNodes", selectedNodesChanged, resetOnDestroy=True)

hoveredNodeChanged = Signal()
# Currently hovered node
hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True)
Loading