diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index 59856e4290..aed962e5d3 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -12,6 +12,11 @@ from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot from meshroom.core import desc, hashValue +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from meshroom.core.graph import Edge + def attributeFactory(description, value, isOutput, node, root=None, parent=None): """ @@ -320,12 +325,35 @@ def hasOutputConnections(self): # safety check to avoid evaluation errors if not self.node.graph or not self.node.graph.edges: return False - # if the attribute is a ListAttribute, we need to check if any of its elements has output connections - if isinstance(self, ListAttribute): - return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None or \ - any(attr.hasOutputConnections for attr in self._value if hasattr(attr, 'hasOutputConnections')) + return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None + def getInputConnections(self) -> list["Edge"]: + """ Retrieve the upstreams connected edges """ + + if not self.node.graph or not self.node.graph.edges: + return [] + + return [edge for edge in self.node.graph.edges.values() if edge.dst == self] + + def getOutputConnections(self) -> list["Edge"]: + """ Retrieve all the edges connected to this attribute """ + + if not self.node.graph or not self.node.graph.edges: + return [] + + return [edge for edge in self.node.graph.edges.values() if edge.src == self] + + def getLinkedInAttributes(self) -> list["Attribute"]: + """ Return the upstreams connected attributes """ + + return [edge.src for edge in self.getInputConnections()] + + def getLinkedOutAttributes(self) -> list["Attribute"]: + """ Return the downstreams connected attributes """ + + return [edge.dst for edge in self.getOutputConnections()] + def _applyExpr(self): """ For string parameters with an expression (when loaded from file), @@ -449,6 +477,8 @@ def updateInternals(self): isLinkNested = isLink hasOutputConnectionsChanged = Signal() hasOutputConnections = Property(bool, hasOutputConnections.fget, notify=hasOutputConnectionsChanged) + linkedInAttributes = Property(Variant, getLinkedInAttributes) + linkedOutAttributes = Property(Variant, getLinkedOutAttributes) isDefault = Property(bool, _isDefault, notify=valueChanged) linkParam = Property(BaseObject, getLinkParam, notify=isLinkChanged) rootLinkParam = Property(BaseObject, lambda self: self.getLinkParam(recursive=True), notify=isLinkChanged) @@ -700,12 +730,42 @@ def isLinkNested(self): return self.isLink \ or self.node.graph and self.isInput and self.node.graph._edges \ and any(v in self.node.graph._edges.keys() for v in self._value) + + # override + @property + def hasOutputConnections(self): + """ Whether the attribute has output connections, i.e is the source of at least one edge. """ + # safety check to avoid evaluation errors + if not self.node.graph or not self.node.graph.edges: + return False + + return next((edge for edge in self.node.graph.edges.values() if edge.src in self._value), None) is not None or \ + any(attr.hasOutputConnections for attr in self._value if hasattr(attr, 'hasOutputConnections')) + + # override + def getInputConnections(self) -> list["Edge"]: + + if not self.node.graph or not self.node.graph.edges: + return [] + + return [edge for edge in self.node.graph.edges.values() if edge.dst == self or edge.dst in self._value] + + # override + def getOutputConnections(self) -> list["Edge"]: + + if not self.node.graph or not self.node.graph.edges: + return [] + + return [edge for edge in self.node.graph.edges.values() if edge.src == self or edge.src in self._value] + # Override value property setter value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged) baseType = Property(str, getBaseType, constant=True) isLinkNested = Property(bool, isLinkNested.fget) + hasOutputConnections = Property(bool, hasOutputConnections.fget, notify=Attribute.hasOutputConnectionsChanged) + class GroupAttribute(Attribute): diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 59e04d1288..85f04520e9 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -1335,14 +1335,88 @@ Page { SplitView.minimumWidth: 80 node: _reconstruction ? _reconstruction.selectedNode : null - property bool computing: _reconstruction ? _reconstruction.computing : false + property bool computing: _reconstruction ? _reconstruction.computing : false + property var currentAttributes: [] + // Make NodeEditor readOnly when computing readOnly: node ? node.locked : false onUpgradeRequest: { var n = _reconstruction.upgradeNode(node) _reconstruction.selectedNode = n + } + + onInAttributeClicked: function(srcItem, mouse, inAttributes) { + _handleNavButtonClick(srcItem, mouse, inAttributes) + } + + onOutAttributeClicked: function(srcItem, mouse, outAttributes) { + _handleNavButtonClick(srcItem, mouse, outAttributes) + } + + // NavButtonContextMenu + Menu { + id: navButtonContextMenu + + Repeater { + model: nodeEditor.currentAttributes + + delegate: MenuItem { + + contentItem: Text { + text: `${modelData.node.label}.${modelData.label}` + elide: Text.ElideLeft + color: Colors.sysPalette.text + } + + onTriggered: { + nodeEditor._selectNodesFromAttributes([nodeEditor.currentAttributes[index]]) + } + } + } + } + + function _selectNodesFromAttributes(attributes) { + /* + Retrieve the nodes from given attributes, and select its + */ + + if ( !attributes || attributes.length == 0) { return } + + graphEditor.uigraph.clearNodeSelection() + + const nodes = attributes.map( attr => attr.node) + + if (attributes.length == 1) { + _reconstruction.selectedNode = attributes[0].node + } + graphEditor.uigraph.selectNodes(nodes) + } + + function _openLinkAttributesContextMenu(srcItem, mouse, attributes) { + nodeEditor.currentAttributes = attributes + const srcGlobal = srcItem.mapToGlobal(0, 0) + const nodeEditorGlobal = nodeEditor.mapToGlobal(0, 0) + navButtonContextMenu.x = srcGlobal.x - nodeEditorGlobal.x + navButtonContextMenu.y = srcGlobal.y - nodeEditorGlobal.y - 14 // TODO: Couldn't found a way to avoid padding in position. 14 = navButtonOut.paddingTop * 2 + navButtonContextMenu.open() + } + + function _handleNavButtonClick(srcItem, mouse, attributes) { + + if (mouse.button === Qt.RightButton) { + nodeEditor._openLinkAttributesContextMenu(srcItem, mouse, attributes) + return + } + + nodeEditor._selectNodesFromAttributes(attributes) + + if (mouse.button === Qt.MiddleButton) { + graphEditor.fit() + } + } + } } } diff --git a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml index 940df7a1a2..8b4b0103be 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml @@ -16,6 +16,8 @@ ListView { signal upgradeRequest() signal attributeDoubleClicked(var mouse, var attribute) + signal inAttributeClicked(var srcItem, var mouse, var inAttributes) + signal outAttributeClicked(var srcItem, var mouse, var outAttributes) implicitHeight: contentHeight @@ -40,9 +42,17 @@ ListView { filterText: root.filterText objectsHideable: root.objectsHideable attribute: object + onDoubleClicked: function(mouse, attr) { root.attributeDoubleClicked(mouse, attr) } + onInAttributeClicked: function(srcItem, mouse, inAttributes) { + root.inAttributeClicked(srcItem, mouse, inAttributes) + } + onOutAttributeClicked: function(srcItem, mouse, outAttributes) { + root.outAttributeClicked(srcItem, mouse, outAttributes) + } + } onActiveChanged: height = active ? item.implicitHeight : -spacing diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 48ce84505e..baff5ab3ac 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -26,6 +26,8 @@ RowLayout { readonly property bool editable: !attribute.isOutput && !attribute.isLink && !readOnly signal doubleClicked(var mouse, var attr) + signal inAttributeClicked(var srcItem, var mouse, var inAttributes) + signal outAttributeClicked(var srcItem, var mouse, var outAttributes) spacing: 2 @@ -56,6 +58,29 @@ RowLayout { width: parent.width height: parent.height + // In connection + MaterialToolButton { + id: navButtonIn + + property bool shouldBeVisible: (object != undefined && object.isLinkNested) + + text: shouldBeVisible ? MaterialIcons.login : " " + enabled: shouldBeVisible + font.pointSize: 8 + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + topPadding: 7 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + + onClicked: function(mouse) { + root.inAttributeClicked(navButtonIn, mouse, object.linkedInAttributes) + } + } + + } + Label { id: parameterLabel @@ -167,6 +192,30 @@ RowLayout { } } } + + MaterialToolButton { + id: navButtonOut + + property bool shouldBeVisible: (attribute != undefined && attribute.hasOutputConnections) + + text: shouldBeVisible ? MaterialIcons.logout : " " + font.pointSize: 8 + enabled: shouldBeVisible + Layout.alignment: Qt.AlignTop | Qt.AlignRight + topPadding: 7 + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + + onClicked: function(mouse) { + root.outAttributeClicked(navButtonOut, mouse, attribute.linkedOutAttributes) + } + } + + + } + MaterialLabel { visible: attribute.desc.advanced text: MaterialIcons.build @@ -666,6 +715,8 @@ RowLayout { obj.label.horizontalAlignment = Text.AlignHCenter obj.label.verticalAlignment = Text.AlignVCenter obj.doubleClicked.connect(function(attr) { root.doubleClicked(attr) }) + obj.inAttributeClicked.connect(function(srcItem, mouse, inAttributes) { root.inAttributeClicked(srcItem, mouse, inAttributes) }) + obj.outAttributeClicked.connect(function(srcItem, mouse, outAttributes) { root.outAttributeClicked(srcItem, mouse, outAttributes) }) } ToolButton { enabled: root.editable diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index c6ddffbafa..00d4509053 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -21,6 +21,8 @@ Panel { property string nodeStartDateTime: "" signal attributeDoubleClicked(var mouse, var attribute) + signal inAttributeClicked(var srcItem, var mouse, var inAttributes) + signal outAttributeClicked(var srcItem, var mouse, var outAttributes) signal upgradeRequest() title: "Node" + (node !== null ? " - " + node.label + "" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "") @@ -301,6 +303,13 @@ Panel { onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } onUpgradeRequest: root.upgradeRequest() filterText: searchBar.text + + onInAttributeClicked: function(srcItem, mouse, inAttributes) { + root.inAttributeClicked(srcItem, mouse, inAttributes) + } + onOutAttributeClicked: function(srcItem, mouse, outAttributes) { + root.outAttributeClicked(srcItem, mouse, outAttributes) + } } Loader { diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000000..d8abd4d8dc --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,39 @@ +from meshroom.core.graph import Graph + + +def test_attribute_retrieve_linked_input_and_output_attributes(): + """ + Check that an attribute can retrieve the linked input and output attributes + """ + + # n0 -- n1 -- n2 + # \ \ + # ---------- n3 + + g = Graph('') + n0 = g.addNewNode('Ls', input='') + n1 = g.addNewNode('Ls', input=n0.output) + n2 = g.addNewNode('Ls', input=n1.output) + n3 = g.addNewNode('AppendFiles', input=n1.output, input2=n2.output) + + # check that the attribute can retrieve its linked input attributes + + assert n0.output.hasOutputConnections + assert not n3.output.hasOutputConnections + + assert len(n0.input.getLinkedInAttributes()) == 0 + assert len(n1.input.getLinkedInAttributes()) == 1 + assert n1.input.getLinkedInAttributes()[0] == n0.output + + assert len(n1.output.getLinkedOutAttributes()) == 2 + + assert n1.output.getLinkedOutAttributes()[0] == n2.input + assert n1.output.getLinkedOutAttributes()[1] == n3.input + + n0.graph = None + + # Bounding cases + assert not n0.output.hasOutputConnections + assert len(n0.input.getLinkedInAttributes()) == 0 + assert len(n0.output.getLinkedOutAttributes()) == 0 + \ No newline at end of file