diff --git a/meshroom/ui/components/edge.py b/meshroom/ui/components/edge.py index 4108fc6a2c..25477cefcc 100755 --- a/meshroom/ui/components/edge.py +++ b/meshroom/ui/components/edge.py @@ -1,4 +1,4 @@ -from PySide6.QtCore import Signal, Property, QPointF, Qt, QObject +from PySide6.QtCore import Signal, Property, QPointF, Qt, QObject, Slot, QRectF from PySide6.QtGui import QPainterPath, QVector2D from PySide6.QtQuick import QQuickItem @@ -17,7 +17,7 @@ def __init__(self, evt): x = Property(float, lambda self: self._x, constant=True) y = Property(float, lambda self: self._y, constant=True) button = Property(Qt.MouseButton, lambda self: self._button, constant=True) - modifiers = Property(int, lambda self: self._modifiers, constant=True) + modifiers = Property(Qt.KeyboardModifier, lambda self: self._modifiers, constant=True) class EdgeMouseArea(QQuickItem): @@ -110,6 +110,17 @@ def setContainsMouse(self, value): self._containsMouse = value self.containsMouseChanged.emit() + @Slot(QRectF, result=bool) + def intersects(self, rect): + """ Checks whether the given rectangle's diagonal intersects with the Path. """ + path = QPainterPath() + # Starting point + path.moveTo(QPointF(rect.x(), rect.y())) + # Create a diagonal line to the other end of the rect + path.lineTo(QPointF(rect.width() + rect.x(), rect.height() + rect.y())) + + return self._path.intersects(path) + thicknessChanged = Signal() thickness = Property(float, getThickness, setThickness, notify=thicknessChanged) curveScaleChanged = Signal() diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 0cde6b2ddd..b6c5cb23ad 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -850,6 +850,25 @@ def removeEdge(self, edge): else: self.push(commands.RemoveEdgeCommand(self._graph, edge)) + @Slot(list) + def deleteEdgesByIndices(self, indices): + with self.groupedGraphModification("Remove Edges"): + copied = list(self._graph.edges) + for index in indices: + self.removeEdge(copied[index]) + + @Slot() + def disconnectSelectedNodes(self): + with self.groupedGraphModification("Disconnect Nodes"): + selectedNodes = self.getSelectedNodes() + for edge in self._graph.edges[:]: + # Remove only the edges which are coming or going out of the current selection + if edge.src.node in selectedNodes and edge.dst.node in selectedNodes: + continue + + if edge.dst.node in selectedNodes or edge.src.node in selectedNodes: + self.removeEdge(edge) + @Slot(Edge, Attribute, Attribute, result=Edge) def replaceEdge(self, edge, newSrc, newDst): with self.groupedGraphModification("Replace Edge '{}'->'{}' with '{}'->'{}'".format(edge.src.getFullNameToNode(), edge.dst.getFullNameToNode(), newSrc.getFullNameToNode(), newDst.getFullNameToNode())): diff --git a/meshroom/ui/qml/Controls/DelegateSelectionLine.qml b/meshroom/ui/qml/Controls/DelegateSelectionLine.qml new file mode 100644 index 0000000000..779ec5fd9c --- /dev/null +++ b/meshroom/ui/qml/Controls/DelegateSelectionLine.qml @@ -0,0 +1,31 @@ +import QtQuick +import Meshroom.Helpers + +/* +A SelectionLine that can be used to select delegates in a model instantiator (Repeater, ListView...). +Interesection test is done in the coordinate system of the container Item, using delegate's bounding boxes. +The list of selected indices is emitted when the selection ends. +*/ + +SelectionLine { + id: root + + // The Item instantiating the delegates. + property Item modelInstantiator + // The Item containing the delegates (used for coordinate mapping). + property Item container + // Emitted when the selection has ended, with the list of selected indices and modifiers. + signal delegateSelectionEnded(list indices, int modifiers) + + onSelectionEnded: function(selectionRect, modifiers) { + let selectedIndices = []; + const mappedSelectionRect = mapToItem(container, selectionRect); + for (var i = 0; i < modelInstantiator.count; ++i) { + const delegate = modelInstantiator.itemAt(i); + if (delegate.intersects(mappedSelectionRect)) { + selectedIndices.push(i); + } + } + delegateSelectionEnded(selectedIndices, modifiers); + } +} diff --git a/meshroom/ui/qml/Controls/SelectionLine.qml b/meshroom/ui/qml/Controls/SelectionLine.qml new file mode 100644 index 0000000000..80cf854584 --- /dev/null +++ b/meshroom/ui/qml/Controls/SelectionLine.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Shapes + +/* +Simple selection line that can be used by a MouseArea. + +Usage: +1. Create a MouseArea and a selectionShape. +2. Bind the selectionShape to the MouseArea by setting the `mouseArea` property. +3. Call startSelection() with coordinates when the selection starts. +4. Call endSelection() when the selection ends. +5. Listen to the selectionEnded signal to get the rectangle whose Diagonal is the selection line. +*/ + +Item { + id: root + + property MouseArea mouseArea + + readonly property bool active: mouseArea.drag.target == dragTarget + + signal selectionEnded(rect selectionRect, int modifiers) + + function startSelection(mouse) { + dragTarget.startPos.x = dragTarget.x = mouse.x; + dragTarget.startPos.y = dragTarget.y = mouse.y; + dragTarget.modifiers = mouse.modifiers; + mouseArea.drag.target = dragTarget; + } + + function endSelection() { + if (!active) { + return; + } + mouseArea.drag.target = null; + const rect = Qt.rect(selectionShape.x, selectionShape.y, selectionShape.width, selectionShape.height) + selectionEnded(rect, dragTarget.modifiers); + } + + visible: active + + Item { + id: selectionShape + x: dragTarget.startPos.x + y: dragTarget.startPos.y + width: dragTarget.x - dragTarget.startPos.x + height: dragTarget.y - dragTarget.startPos.y + + Shape { + id: dynamicLine; + width: selectionShape.width; + height: selectionShape.height; + anchors.fill: parent; + + ShapePath { + strokeWidth: 2; + strokeStyle: ShapePath.DashLine; + strokeColor: "#FF0000"; + dashPattern: [3, 2]; + + startX: 0; + startY: 0; + + PathLine { + x: selectionShape.width; + y: selectionShape.height; + } + } + } + } + + Item { + id: dragTarget + property point startPos + property var modifiers + } +} diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index 9d3e23cb75..c11085ac7d 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -18,4 +18,6 @@ MScrollBar 1.0 MScrollBar.qml MSplitView 1.0 MSplitView.qml DirectionalLightPane 1.0 DirectionalLightPane.qml SelectionBox 1.0 SelectionBox.qml -DelegateSelectionBox 1.0 DelegateSelectionBox.qml \ No newline at end of file +SelectionLine 1.0 SelectionLine.qml +DelegateSelectionBox 1.0 DelegateSelectionBox.qml +DelegateSelectionLine 1.0 DelegateSelectionLine.qml diff --git a/meshroom/ui/qml/GraphEditor/Edge.qml b/meshroom/ui/qml/GraphEditor/Edge.qml index ba90a688a8..e0eef12f87 100644 --- a/meshroom/ui/qml/GraphEditor/Edge.qml +++ b/meshroom/ui/qml/GraphEditor/Edge.qml @@ -40,6 +40,17 @@ Item { property real endY: height + function intersects(rect) { + /** + * Detects whether a line along the given rects diagonal intersects with the edge mouse area. + */ + // The edgeArea is within the parent Item and its bounds and position are relative to its parent + // Map the original rect to the coordinates of the edgeArea by subtracting the parent's coordinates from the rect + // This mapped rect would ensure that the rect coordinates map to 0 of the edge area + const mappedRect = Qt.rect(rect.x - x, rect.y - y, rect.width, rect.height); + return edgeArea.intersects(mappedRect); + } + Shape { anchors.fill: parent // Cause rendering artifacts when enabled (and don't support hot reload really well) diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 0f33730828..eedccfe629 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -120,9 +120,14 @@ Item { } } else if (event.key === Qt.Key_D) { duplicateNode(event.modifiers === Qt.AltModifier) - } else if (event.key === Qt.Key_X && event.modifiers === Qt.ControlModifier) { - copyNodes() - uigraph.removeSelectedNodes() + } else if (event.key === Qt.Key_X) { + if (event.modifiers === Qt.ControlModifier) { + copyNodes() + uigraph.removeSelectedNodes() + } + else { + uigraph.disconnectSelectedNodes() + } } else if (event.key === Qt.Key_C) { if (event.modifiers === Qt.ControlModifier) { copyNodes() @@ -145,6 +150,7 @@ Item { id: mouseArea anchors.fill: parent property double factor: 1.15 + property bool removingEdges: false; // Activate multisampling for edges antialiasing layer.enabled: true layer.samples: 8 @@ -152,7 +158,7 @@ Item { hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton drag.threshold: 0 - cursorShape: drag.target == draggable ? Qt.ClosedHandCursor : Qt.ArrowCursor + cursorShape: drag.target == draggable ? Qt.ClosedHandCursor : removingEdges ? Qt.CrossCursor : Qt.ArrowCursor onWheel: function(wheel) { var zoomFactor = wheel.angleDelta.y > 0 ? factor : 1 / factor @@ -177,9 +183,15 @@ Item { if (mouse.button == Qt.MiddleButton || (mouse.button == Qt.LeftButton && mouse.modifiers & Qt.AltModifier)) { drag.target = draggable // start drag } + if (mouse.button == Qt.LeftButton && (mouse.modifiers & Qt.ControlModifier) && (mouse.modifiers & Qt.AltModifier)) { + edgeSelectionLine.startSelection(mouse); + removingEdges = true; + } } onReleased: { + removingEdges = false; + edgeSelectionLine.endSelection() nodeSelectionBox.endSelection(); drag.target = null; root.forceActiveFocus() @@ -501,7 +513,7 @@ Item { if (event.button) { if (canEdit && (event.modifiers & Qt.AltModifier)) { uigraph.removeEdge(edge) - } else { + } else if (event.button == Qt.RightButton) { edgeMenu.currentEdge = edge edgeMenu.forLoop = forLoop var spawnPosition = mouseArea.mapToItem(draggable, mouseArea.mouseX, mouseArea.mouseY) @@ -716,6 +728,13 @@ Item { pasteNodes() } } + MenuItem { + text: "Disconnect Node(s)"; + enabled: true; + ToolTip.text: "Disconnect all edges from the selected Node(s)"; + ToolTip.visible: hovered; + onTriggered: uigraph.disconnectSelectedNodes(); + } MenuItem { text: "Duplicate Node(s)" + (duplicateFollowingButton.hovered ? " From Here" : "") enabled: true @@ -848,6 +867,10 @@ Item { onAttributePinCreated: function(attribute, pin) { registerAttributePin(attribute, pin) } onAttributePinDeleted: function(attribute, pin) { unregisterAttributePin(attribute, pin) } + onShaked: { + uigraph.disconnectSelectedNodes(); + } + onPressed: function(mouse) { nodeRepeater.updateSelectionOnClick = true; nodeRepeater.ongoingDrag = true; @@ -943,6 +966,10 @@ Item { if(!selected || !dragging) { return; } + + // Check for shake on the node + checkForShake(); + // Compute offset between the delegate and the stored node position. const offset = Qt.point(x - node.x, y - node.y); @@ -989,6 +1016,16 @@ Item { } } + DelegateSelectionLine { + id: edgeSelectionLine + mouseArea: mouseArea + modelInstantiator: edgesRepeater + container: draggable + onDelegateSelectionEnded: function(selectedIndices, modifiers) { + uigraph.deleteEdgesByIndices(selectedIndices); + } + } + DropArea { id: dropArea anchors.fill: parent diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index 65b95cd827..6d069c51c4 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -31,6 +31,20 @@ Item { readonly property color defaultColor: isCompatibilityNode ? "#444" : !node.isComputable ? "#BA3D69" : activePalette.base property color baseColor: defaultColor + /// Shake Relevance + readonly property double maxAmplitude: 300.0; + readonly property int shakeThreshold: 5; + + property int shakeCounter: 0; + property bool shaking: false; + property int shakeDetectionInterval: 1000; // 1 Second to complete the shake else the counter is reset + + property double originalRootX: 0.0; + property double originalRootY: 0.0; + + property int directionX: 0; + property int directionY: 0; + property point mousePosition: Qt.point(mouseArea.mouseX, mouseArea.mouseY) Item { @@ -44,6 +58,7 @@ Item { signal clicked(var mouse) signal doubleClicked(var mouse) signal moved(var position) + signal shaked() signal entered() signal exited() @@ -75,6 +90,107 @@ Item { } } + Timer { + id: shakeDetectionTimer; + interval: root.shakeDetectionInterval; + onTriggered: { + if (root.shaking) { + root.resetShaking(); + } + } + } + + function beginShaking() { + /** + * Sets up the shake related values. + * Enables Shake detection. + */ + root.shaking = true; + + // Capture the current Root's X and Y to use in detecting the movement of the node around these points + root.originalRootX = root.x; + root.originalRootY = root.y; + } + + function resetShaking() { + /** + * Resets the shaking and the variables tracking a shake. + */ + // Reset the shake counter when shaking has ended + root.shakeCounter = 0; + + // Reset the direction detection + root.directionX = 0; + root.directionY = 0; + } + + function endShaking() { + /** + * Resets all values related to shaking. + * Ends the shake detection. + */ + root.shaking = false; + + root.resetShaking(); + } + + function checkForShake() { + /** + * Detects a shake if a the node has been moved across the originally captured x and y positions + * back and forth a given number of times specified by the amplitude. + */ + + if (!root.shaking) { + return; + } + + // This indicates that the shake was either reset or we're starting from scratch + if (root.shakeCounter === 0 && !shakeDetectionTimer.running) { + shakeDetectionTimer.start(); + } + + const deltaX = root.x - root.originalRootX; + const deltaY = root.y - root.originalRootY; + + // Check if the node has not travelled too much from the original position + // If so, stop detecting a shake as that might not be needed + if (Math.abs(deltaX) > root.maxAmplitude || Math.abs(deltaY) > root.maxAmplitude) { + root.endShaking(); + } + + // This checks the current direction in which the node is travelling + // if the node has moved on the left side of the original position -1 + // if the node has moved on the right side of the original position +1 + + // <-- Origin + // [Node] | + // | [Node] + // | --> + // If the motion continues to be like this 'threshold' number of times + // This will be considered as a shake effect + + const currentDirectionX = deltaX > 0 ? 1 : -1; + const currentDirectionY = deltaY > 0 ? 1 : -1; + + // Check if we're in the opposite direction of what was the previous direction of the Node + // If yes then we're propagating as a shake effect + if (currentDirectionX != root.directionX || currentDirectionY != root.directionY) { + // One shake cycle is complete, increment the counter + root.shakeCounter++; + + // Update the original direction to be the current one + root.directionX = currentDirectionX; + root.directionY = currentDirectionY; + } + + // The node has moved in a shake effect to match the threshold and this is causing it to be detected as a shake + if (root.shakeCounter > root.shakeThreshold) { + root.shaked(); + // Reset the counter to detect another shake effect + root.resetShaking(); + } + } + function formatInternalAttributesTooltip(invalidation, comment) { /* * Creates a string that contains the invalidation message (if it is not empty) in bold, @@ -127,8 +243,6 @@ Item { drag.threshold: 2 hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton - onPressed: (mouse) => root.pressed(mouse) - onReleased: (mouse) => root.released(mouse) onClicked: (mouse) => root.clicked(mouse) onDoubleClicked: (mouse) => root.doubleClicked(mouse) onEntered: root.entered() @@ -141,6 +255,18 @@ Item { cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor + onPressed: function(mouse) { + root.pressed(mouse); + // Begin shake detection + root.beginShaking(); + } + + onReleased: function(mouse) { + root.released(mouse); + // End shake detection + root.endShaking(); + } + // Selection border Rectangle { anchors.fill: nodeContent