diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index a80a3fb667..4256cd19b4 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -77,6 +77,9 @@ def __init__(self, node, attributeDesc: desc.Attribute, isOutput: bool, root=Non self._description: str = attributeDesc.description self._invalidate = False if self._isOutput else attributeDesc.invalidate + self._exposed = root.exposed if root is not None else attributeDesc.exposed + self._depth = root.depth + 1 if root is not None else 0 + # invalidation value for output attributes self._invalidationValue = "" @@ -92,6 +95,12 @@ def node(self): def root(self): return self._root() if self._root else None + def getDepth(self): + return self._depth + + def getExposed(self) -> bool: + return self._exposed + def getName(self) -> str: """ Attribute name """ return self._name @@ -379,7 +388,7 @@ def _applyExpr(self): elif self.isInput and Attribute.isLinkExpression(v): # value is a link to another attribute link = v[1:-1] - linkNodeName, linkAttrName = link.split('.') + linkNodeName, linkAttrName = link.split('.', 1) try: node = g.node(linkNodeName) if not node: @@ -460,6 +469,13 @@ def updateInternals(self): # Emit if the enable status has changed self.setEnabled(self.getEnabled()) + def getFlatStaticChildren(self): + """ Return a list of all the attributes that refer to this instance as their parent through + the 'root' property. If no such attribute exist, return an empty list. The depth difference is not + taken into account in the list, which is thus always flat. """ + # For all attributes but GroupAttributes, there cannot be any child + return [] + def _is3D(self) -> bool: """ Return True if the current attribute is considered as a 3d file """ @@ -493,6 +509,7 @@ def _is2D(self) -> bool: type = Property(str, getType, constant=True) baseType = Property(str, getType, constant=True) isReadOnly = Property(bool, _isReadOnly, constant=True) + exposed = Property(bool, getExposed, constant=True) is3D = Property(bool, _is3D, constant=True) is2D = Property(bool, _is2D, constant=True) @@ -531,6 +548,8 @@ def _is2D(self) -> bool: validValueChanged = Signal() validValue = Property(bool, getValidValue, setValidValue, notify=validValueChanged) root = Property(BaseObject, root.fget, constant=True) + depth = Property(int, getDepth, constant=True) + flatStaticChildren = Property(Variant, getFlatStaticChildren, constant=True) def raiseIfLink(func): @@ -945,6 +964,20 @@ def updateInternals(self): for attr in self._value: attr.updateInternals() + def getFlatStaticChildren(self): + """ Return a list of all the attributes that refer to this instance of GroupAttribute as their parent + through the 'root' property. In the case of GroupAttributes, any attribute within said group will be + a child. The depth difference is not taken into account when generating the list, which is thus always + flat. """ + attributes = [] + + # Iterate over the values and add the flat children of every child (if they exist) + for attribute in self._value: + attributes.append(attribute) + attributes = attributes + attribute.getFlatStaticChildren() + + return attributes + @Slot(str, result=bool) def matchText(self, text: str) -> bool: return super().matchText(text) or any(c.matchText(text) for c in self._value) @@ -952,3 +985,4 @@ def matchText(self, text: str) -> bool: # Override value property value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged) + flatStaticChildren = Property(Variant, getFlatStaticChildren, constant=True) diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 50ee7ba617..bc10c5253d 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1947,9 +1947,18 @@ def attributeDescFromName(refAttributes, name, value, strict=True): attrDesc = next((d for d in refAttributes if d.name == name), None) if attrDesc is None: return None + # We have found a description, and we still need to # check if the value matches the attribute description. - # + + # If it is a GroupAttribute, all attributes within the group should be matched individually + # so that links that can be correctly evaluated + if isinstance(attrDesc, desc.GroupAttribute): + for k, v in value.items(): + if CompatibilityNode.attributeDescFromName(attrDesc.groupDesc, k, v, strict=True) is None: + return None + return attrDesc + # If it is a serialized link expression (no proper value to set/evaluate) if Attribute.isLinkExpression(value): return attrDesc diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 699d3a8a83..5a66084ca7 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -780,7 +780,8 @@ def duplicateNodesFrom(self, nodes: list[Node]) -> list[Node]: def canExpandForLoop(self, currentEdge): """ Check if the list attribute can be expanded by looking at all the edges connected to it. """ listAttribute = currentEdge.src.root - if not listAttribute: + # Check that the parent is indeed a ListAttribute (it could be a GroupAttribute, for instance) + if not listAttribute or not isinstance(listAttribute, ListAttribute): return False srcIndex = listAttribute.index(currentEdge.src) allSrc = [e.src for e in self._graph.edges.values()] diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 7a830d6e3f..7d28bea13a 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -39,8 +39,9 @@ RowLayout { var tooltip = "" if (!attribute.validValue && attribute.desc.errorMessage !== "") tooltip += "Error: " + Format.plainToHtml(attribute.desc.errorMessage) + "

" - tooltip += " " + attribute.desc.name + ": " + attribute.type + "
" + Format.plainToHtml(attribute.desc.description) + tooltip += " " + attribute.fullName + ": " + attribute.type + "
" + Format.plainToHtml(attribute.desc.description) + console.warn(attribute.fullName) parameterTooltip.text = tooltip } } @@ -113,7 +114,7 @@ RowLayout { var tooltip = "" if (!object.validValue && object.desc.errorMessage !== "") tooltip += "Error: " + Format.plainToHtml(object.desc.errorMessage) + "

" - tooltip += "" + object.desc.name + ": " + attribute.type + "
" + Format.plainToHtml(object.description) + tooltip += "" + object.fullName + ": " + attribute.type + "
" + Format.plainToHtml(object.description) return tooltip } visible: parameterMA.containsMouse diff --git a/meshroom/ui/qml/GraphEditor/AttributePin.qml b/meshroom/ui/qml/GraphEditor/AttributePin.qml index d435601bd5..5f16490c6c 100755 --- a/meshroom/ui/qml/GraphEditor/AttributePin.qml +++ b/meshroom/ui/qml/GraphEditor/AttributePin.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Utils 1.0 +import MaterialIcons 2.2 /** * The representation of an Attribute on a Node. @@ -13,6 +14,7 @@ RowLayout { property var nodeItem property var attribute + property bool expanded: false property bool readOnly: false /// Whether to display an output pin for input attribute property bool displayOutputPinForInput: true @@ -25,37 +27,48 @@ RowLayout { outputAnchor.y + outputAnchor.height / 2) readonly property bool isList: attribute && attribute.type === "ListAttribute" + readonly property bool isGroup: attribute && attribute.type === "GroupAttribute" + readonly property bool isChild: attribute && attribute.root + readonly property bool isConnected: attribute.isLinkNested || attribute.hasOutputConnections signal childPinCreated(var childAttribute, var pin) signal childPinDeleted(var childAttribute, var pin) signal pressed(var mouse) signal edgeAboutToBeRemoved(var input) + signal clicked() objectName: attribute ? attribute.name + "." : "" layoutDirection: Qt.LeftToRight spacing: 3 ToolTip { - text: attribute.name + ": " + attribute.type + text: attribute.fullName + ": " + attribute.type visible: nameLabel.hovered + delay: 500 - y: nameLabel.y + nameLabel.height x: nameLabel.x + y: nameLabel.y + nameLabel.height } - function updatePin(isSrc, isVisible) { - if (isSrc) { - innerOutputAnchor.linkEnabled = isVisible - } else { - innerInputAnchor.linkEnabled = isVisible + function updateLabel() { + var label = "" + var expandedGroup = expanded ? "-" : "+" + if (attribute && attribute.label !== undefined) { + label = attribute.label + if (isGroup && attribute.isOutput) { + label = label + " " + expandedGroup + } else if (isGroup && !attribute.isOutput) { + label = expandedGroup + " " + label + } } + return label } // Instantiate empty Items for each child attribute Repeater { id: childrenRepeater - model: isList && !attribute.isLink ? attribute.value : 0 + model: root.isList && !root.attribute.isLink ? root.attribute.value : 0 onItemAdded: function(index, item) { childPinCreated(item.childAttribute, root) } onItemRemoved: function(index, item) { childPinDeleted(item.childAttribute, root) } delegate: Item { @@ -64,125 +77,153 @@ RowLayout { } } - Rectangle { - visible: !attribute.isOutput - id: inputAnchor - - width: 8 - height: width - radius: isList ? 0 : width / 2 + Item { + width: childrenRect.width Layout.alignment: Qt.AlignVCenter - - border.color: Colors.sysPalette.mid - color: Colors.sysPalette.base + Layout.fillWidth: false + Layout.fillHeight: true Rectangle { - id: innerInputAnchor - property bool linkEnabled: true - visible: inputConnectMA.containsMouse || childrenRepeater.count > 0 || (attribute && attribute.isLink && linkEnabled) || inputConnectMA.drag.active || inputDropArea.containsDrag - radius: isList ? 0 : 2 - anchors.fill: parent - anchors.margins: 2 - color: { - if (inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop)) - return Colors.sysPalette.highlight - return Colors.sysPalette.text - } - } + visible: !root.attribute.isOutput + id: inputAnchor - DropArea { - id: inputDropArea + width: 8 + height: width + radius: root.isList ? 0 : width / 2 + anchors.verticalCenter: parent.verticalCenter - property bool acceptableDrop: false + border.color: Colors.sysPalette.mid + color: Colors.sysPalette.base + + Rectangle { + id: innerInputAnchor + property bool linkEnabled: true + visible: inputConnectMA.containsMouse || childrenRepeater.count > 0 || (root.attribute && root.attribute.isLink && linkEnabled) || inputConnectMA.drag.active || inputDropArea.containsDrag + radius: root.isList ? 0 : 2 + anchors.fill: parent + anchors.margins: 2 + color: { + if (inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop)) + return Colors.sysPalette.highlight + return Colors.sysPalette.text + } + } - // Add negative margins for DropArea to make the connection zone easier to reach - anchors.fill: parent - anchors.margins: -2 - // Add horizontal negative margins according to the current layout - anchors.rightMargin: -root.width * 0.3 + DropArea { + id: inputDropArea + + property bool acceptableDrop: false + + // Add negative margins for DropArea to make the connection zone easier to reach + anchors.fill: parent + anchors.margins: -2 + // Add horizontal negative margins according to the current layout + anchors.rightMargin: -root.width * 0.3 + + keys: [inputDragTarget.objectName] + onEntered: function(drag) { + // Check if attributes are compatible to create a valid connection + if (root.readOnly // Cannot connect on a read-only attribute + || drag.source.objectName != inputDragTarget.objectName // Not an edge connector + || drag.source.baseType !== inputDragTarget.baseType // Not the same base type + || drag.source.nodeItem === inputDragTarget.nodeItem // Connection between attributes of the same node + || (drag.source.isList && childrenRepeater.count) // Source/target are lists but target already has children + || drag.source.connectorType === "input" // Refuse to connect an "input pin" on another one (input attr can be connected to input attr, but not the graphical pin) + || (drag.source.isGroup || inputDragTarget.isGroup) // Refuse connection between Groups, which is unsupported + ) { + // Refuse attributes connection + drag.accepted = false + } else if (inputDragTarget.attribute.isLink) { // Already connected attribute + root.edgeAboutToBeRemoved(inputDragTarget.attribute) + } + inputDropArea.acceptableDrop = drag.accepted + } - keys: [inputDragTarget.objectName] - onEntered: function(drag) { - // Check if attributes are compatible to create a valid connection - if (root.readOnly // Cannot connect on a read-only attribute - || drag.source.objectName != inputDragTarget.objectName // Not an edge connector - || drag.source.baseType !== inputDragTarget.baseType // Not the same base type - || drag.source.nodeItem === inputDragTarget.nodeItem // Connection between attributes of the same node - || (drag.source.isList && childrenRepeater.count) // Source/target are lists but target already has children - || drag.source.connectorType === "input" // Refuse to connect an "input pin" on another one (input attr can be connected to input attr, but not the graphical pin) - ) { - // Refuse attributes connection - drag.accepted = false - } else if (inputDragTarget.attribute.isLink) { // Already connected attribute - root.edgeAboutToBeRemoved(inputDragTarget.attribute) + onExited: { + if (inputDragTarget.attribute.isLink) { // Already connected attribute + root.edgeAboutToBeRemoved(undefined) + } + acceptableDrop = false + drag.source.dropAccepted = false } - inputDropArea.acceptableDrop = drag.accepted - } - onExited: { - if (inputDragTarget.attribute.isLink) { // Already connected attribute + onDropped: function(drop) { root.edgeAboutToBeRemoved(undefined) + _reconstruction.addEdge(drag.source.attribute, inputDragTarget.attribute) } - acceptableDrop = false - drag.source.dropAccepted = false } - onDropped: function(drop) { - root.edgeAboutToBeRemoved(undefined) - _reconstruction.addEdge(drag.source.attribute, inputDragTarget.attribute) + Item { + id: inputDragTarget + objectName: "edgeConnector" + readonly property string connectorType: "input" + readonly property alias attribute: root.attribute + readonly property alias nodeItem: root.nodeItem + readonly property bool isOutput: Boolean(attribute.isOutput) + readonly property string baseType: attribute.baseType !== undefined ? attribute.baseType : "" + readonly property alias isList: root.isList + readonly property alias isGroup: root.isGroup + property bool dragAccepted: false + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + height: parent.height + Drag.keys: [inputDragTarget.objectName] + Drag.active: inputConnectMA.drag.active + Drag.hotSpot.x: width * 0.5 + Drag.hotSpot.y: height * 0.5 } - } - Item { - id: inputDragTarget - objectName: "edgeConnector" - readonly property string connectorType: "input" - readonly property alias attribute: root.attribute - readonly property alias nodeItem: root.nodeItem - readonly property bool isOutput: Boolean(attribute.isOutput) - readonly property string baseType: attribute.baseType !== undefined ? attribute.baseType : "" - readonly property alias isList: root.isList - property bool dragAccepted: false - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - height: parent.height - Drag.keys: [inputDragTarget.objectName] - Drag.active: inputConnectMA.drag.active - Drag.hotSpot.x: width * 0.5 - Drag.hotSpot.y: height * 0.5 - } - - MouseArea { - id: inputConnectMA - drag.target: attribute.isReadOnly ? undefined : inputDragTarget - drag.threshold: 0 - // Move the edge's tip straight to the the current mouse position instead of waiting after the drag operation has started - drag.smoothed: false - enabled: !root.readOnly - anchors.fill: parent - // Use the same negative margins as DropArea to ease pin selection - anchors.margins: inputDropArea.anchors.margins - anchors.leftMargin: inputDropArea.anchors.leftMargin - anchors.rightMargin: inputDropArea.anchors.rightMargin - onPressed: function(mouse) { - root.pressed(mouse) - } - onReleased: { - inputDragTarget.Drag.drop() + MouseArea { + id: inputConnectMA + drag.target: root.attribute.isReadOnly ? undefined : inputDragTarget + drag.threshold: 0 + // Move the edge's tip straight to the the current mouse position instead of waiting after the drag operation has started + drag.smoothed: false + enabled: !root.readOnly + anchors.fill: parent + // Use the same negative margins as DropArea to ease pin selection + anchors.margins: inputDropArea.anchors.margins + anchors.leftMargin: inputDropArea.anchors.leftMargin + anchors.rightMargin: inputDropArea.anchors.rightMargin + + property bool dragTriggered: false // An edge is being dragged from the output connector + property bool isPressed: false // The mouse has been pressed but not yet released + property double initialX: 0.0 + property double initialY: 0.0 + + onPressed: function(mouse) { + root.pressed(mouse) + isPressed = true + initialX = mouse.x + initialY = mouse.y + } + onReleased: function(mouse) { + inputDragTarget.Drag.drop() + isPressed = false + dragTriggered = false + } + onClicked: root.clicked() + onPositionChanged: function(mouse) { + // If there's been a significant (10px along the X- or Y- axis) while the mouse is being pressed, + // then we can consider being in the dragging state + if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) { + dragTriggered = true + } + } + hoverEnabled: root.visible } - hoverEnabled: true - } - Edge { - id: inputConnectEdge - visible: false - point1x: inputDragTarget.x + inputDragTarget.width / 2 - point1y: inputDragTarget.y + inputDragTarget.height / 2 - point2x: parent.width / 2 - point2y: parent.width / 2 - color: palette.highlight - thickness: outputDragTarget.dropAccepted ? 2 : 1 + Edge { + id: inputConnectEdge + visible: false + point1x: inputDragTarget.x + inputDragTarget.width / 2 + point1y: inputDragTarget.y + inputDragTarget.height / 2 + point2x: parent.width / 2 + point2y: parent.width / 2 + color: palette.highlight + thickness: outputDragTarget.dropAccepted ? 2 : 1 + } } } @@ -191,35 +232,70 @@ RowLayout { id: nameContainer implicitHeight: childrenRect.height Layout.fillWidth: true + Layout.fillHeight: true Layout.alignment: Qt.AlignVCenter - Label { + MaterialToolLabel { id: nameLabel + anchors.fill: parent + anchors.verticalCenter: parent.verticalCenter + + anchors.margins: 0 + labelIconRow.layoutDirection: root.attribute.isOutput ? Qt.RightToLeft : Qt.LeftToRight + labelIconRow.spacing: 0 + enabled: !root.readOnly - property bool hovered: (inputConnectMA.containsMouse || inputConnectMA.drag.active || inputDropArea.containsDrag || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag) - text: (attribute && attribute.label) !== undefined ? attribute.label : "" - elide: hovered ? Text.ElideNone : Text.ElideMiddle - width: hovered ? contentWidth : parent.width - font.pointSize: 7 - horizontalAlignment: attribute && attribute.isOutput ? Text.AlignRight : Text.AlignLeft - anchors.right: attribute && attribute.isOutput ? parent.right : undefined - rightPadding: 0 - color: { - if ((object.hasOutputConnections || object.isLink) && !object.enabled) + visible: true + property bool parentNotReady: nameContainer.width == 0 // Allows to trigger a change of state once the parent is ready, + // ensuring the correct width of the elements upon their first + // display without waiting for a mouse interaction + property bool hovered: parentNotReady || + (inputConnectMA.containsMouse || inputConnectMA.drag.active || + inputDropArea.containsDrag || outputConnectMA.containsMouse || + outputConnectMA.drag.active || outputDropArea.containsDrag) + + labelIconColor: { + if ((root.attribute.hasOutputConnections || root.attribute.isLink) && !root.attribute.enabled) { return Colors.lightgrey - return hovered ? palette.highlight : palette.text + } else if (hovered) { + return palette.highlight + } + return palette.text + } + labelIconMouseArea.enabled: false // Prevent mixing mouse interactions between the label and the pin context + + // Text + label.text: root.attribute.label + label.font.pointSize: 7 + label.elide: Text.ElideRight + label.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft + label.verticalAlignment: Text.AlignVCenter + + // Icon + iconText: { + if (root.isGroup) { + return root.expanded ? MaterialIcons.expand_more : MaterialIcons.chevron_right + } + return "" } + iconSize: 7 + icon.horizontalAlignment: root.attribute && root.attribute.isOutput ? Text.AlignRight : Text.AlignLeft + + // Handle tree view for nested attributes + property int groupPaddingWidth: root.attribute.depth * 10 + icon.leftPadding: root.attribute.isOutput ? 0 : groupPaddingWidth + icon.rightPadding: root.attribute.isOutput ? groupPaddingWidth : 0 } } Rectangle { id: outputAnchor - visible: displayOutputPinForInput || attribute.isOutput + visible: root.displayOutputPinForInput || root.attribute.isOutput width: 8 height: width - radius: isList ? 0 : width / 2 + radius: root.isList ? 0 : width / 2 Layout.alignment: Qt.AlignVCenter @@ -229,13 +305,13 @@ RowLayout { Rectangle { id: innerOutputAnchor property bool linkEnabled: true - visible: (attribute.hasOutputConnections && linkEnabled) || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag - radius: isList ? 0 : 2 + visible: (root.attribute.hasOutputConnections && linkEnabled) || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag + radius: root.isList ? 0 : 2 anchors.fill: parent anchors.margins: 2 color: { - if (object.enabled && (outputConnectMA.containsMouse || outputConnectMA.drag.active || - (outputDropArea.containsDrag && outputDropArea.acceptableDrop))) + if (modelData.enabled && (outputConnectMA.containsMouse || outputConnectMA.drag.active || + (outputDropArea.containsDrag && outputDropArea.acceptableDrop))) return Colors.sysPalette.highlight return Colors.sysPalette.text } @@ -261,6 +337,7 @@ RowLayout { || (!drag.source.isList && outputDragTarget.isList) // Connection between a list and a simple attribute || (drag.source.isList && childrenRepeater.count) // Source/target are lists but target already has children || drag.source.connectorType === "output" // Refuse to connect an output pin on another one + || (drag.source.isGroup || outputDragTarget.isGroup) // Refuse connection between Groups, which is unsupported ) { // Refuse attributes connection drag.accepted = false @@ -288,7 +365,8 @@ RowLayout { readonly property alias nodeItem: root.nodeItem readonly property bool isOutput: Boolean(attribute.isOutput) readonly property alias isList: root.isList - readonly property string baseType: attribute.baseType !== undefined ? attribute.baseType : "" + readonly property alias isGroup: root.isGroup + readonly property string baseType: root.attribute.baseType !== undefined ? attribute.baseType : "" property bool dropAccepted: false anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter @@ -312,10 +390,32 @@ RowLayout { anchors.leftMargin: outputDropArea.anchors.leftMargin anchors.rightMargin: outputDropArea.anchors.rightMargin - onPressed: function(mouse) { root.pressed(mouse) } - onReleased: outputDragTarget.Drag.drop() + property bool dragTriggered: false // An edge is being dragged from the output connector + property bool isPressed: false // The mouse has been pressed but not yet released + property double initialX: 0.0 + property double initialY: 0.0 + + onPressed: function(mouse) { + root.pressed(mouse) + isPressed = true + initialX = mouse.x + initialY = mouse.y + } + onReleased: function(mouse) { + outputDragTarget.Drag.drop() + isPressed = false + dragTriggered = false + } + onClicked: root.clicked() + onPositionChanged: function(mouse) { + // If there's been a significant (10px along the X- or Y- axis) while the mouse is being pressed, + // then we can consider being in the dragging state + if (isPressed && (Math.abs(mouse.x - initialX) >= 5.0 || Math.abs(mouse.y - initialY) >= 5.0)) { + dragTriggered = true + } + } - hoverEnabled: true + hoverEnabled: root.visible } Edge { @@ -330,7 +430,7 @@ RowLayout { } } - state: (inputConnectMA.pressed) ? "DraggingInput" : outputConnectMA.pressed ? "DraggingOutput" : "" + state: inputConnectMA.dragTriggered ? "DraggingInput" : outputConnectMA.dragTriggered ? "DraggingOutput" : "" states: [ State { diff --git a/meshroom/ui/qml/GraphEditor/Edge.qml b/meshroom/ui/qml/GraphEditor/Edge.qml index 36d4a909c8..c4b80bb50c 100644 --- a/meshroom/ui/qml/GraphEditor/Edge.qml +++ b/meshroom/ui/qml/GraphEditor/Edge.qml @@ -128,7 +128,7 @@ Item { anchors.centerIn: parent iconText: MaterialIcons.loop - label: (root.iteration + 1) + "/" + root.loopSize + " " + label.text: (root.iteration + 1) + "/" + root.loopSize + " " labelIconColor: palette.base ToolTip.text: "Foreach Loop" diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 54300a3a12..4edf8cdd04 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -519,18 +519,6 @@ Item { } } } - - Component.onDestruction: { - // Handles the case where the edge is destroyed while hidden because it is replaced: the pins should be re-enabled - if (!_window.isClosing) { - // When the window is closing, the QML context becomes invalid, which causes function calls to result in errors - if (src && src !== undefined) - src.updatePin(true, true) // isSrc = true, isVisible = true - if (dst && dst !== undefined) - dst.updatePin(false, true) // isSrc = false, isVisible = true - } - } - } } diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index 28969f68b3..b430a63165 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -233,6 +233,58 @@ Item { return str } + function updateChildPin(attribute, parentPins, pin) { + /* + * Update the pin of a child attribute: if the attribute is enabled and its parent is a GroupAttribute, + * the visibility is determined based on the parent pin's "expanded" state, using the "parentPins" map to + * access the status. + * If the current pin is also a GroupAttribute and is expanded while its newly "visible" state is false, + * it is reset. + */ + if (Boolean(attribute.enabled)) { + // If the parent's a GroupAttribute, use status of the parent's pin to determine visibility UNLESS the + // child attribute is already connected + if (attribute.root && attribute.root.type === "GroupAttribute") { + var visible = Boolean(parentPins.get(attribute.root.name) || attribute.hasOutputConnections || attribute.isLinkNested) + if (!visible && parentPins.has(attribute.name) && parentPins.get(attribute.name) === true) { + parentPins.set(attribute.name, false) + pin.expanded = false + } + return visible + } + return true + } + return false + } + + function generateAttributesModel(isOutput, parentPins) { + if (!node) + return undefined + + const attributes = [] + for (let i = 0; i < node.attributes.count; ++i) { + let attr = node.attributes.at(i) + if (attr.isOutput == isOutput) { + // Add the attribute to the model + attributes.push(attr) + if (attr.type === "GroupAttribute") { + // If it is a GroupAttribute, initialize its pin status + parentPins.set(attr.name, false) + } + + // Check and add any child this attribute might have + attr.flatStaticChildren.forEach((child) => { + attributes.push(child) + if (child.type === "GroupAttribute") { + parentPins.set(child.name, false) + } + }) + } + } + + return attributes + } + // Main Layout MouseArea { id: mouseArea @@ -524,26 +576,60 @@ Item { width: parent.width spacing: 3 + property var parentPins: new Map() + signal parentPinsUpdated() + Repeater { - model: node ? node.attributes : undefined + model: root.generateAttributesModel(true, outputs.parentPins) // isOutput = true delegate: Loader { id: outputLoader - active: Boolean(object.isOutput && object.desc.visible) - visible: Boolean(object.enabled || object.hasOutputConnections) + active: Boolean(modelData.isOutput && modelData.desc.visible) + + visible: { + if (Boolean(modelData.enabled || modelData.hasOutputConnections || modelData.isLinkNested)) { + if (modelData.root && modelData.root.type === "GroupAttribute") { + return Boolean(outputs.parentPins.get(modelData.root.name) || + modelData.hasOutputConnections || modelData.isLinkNested) + } + return true + } + return false + } anchors.right: parent.right width: outputs.width + Connections { + target: outputs + + function onParentPinsUpdated() { + visible = updateChildPin(modelData, outputs.parentPins, outputLoader.item) + } + } + sourceComponent: AttributePin { id: outPin nodeItem: root - attribute: object + attribute: modelData property real globalX: root.x + nodeAttributes.x + outputs.x + outputLoader.x + outPin.x property real globalY: root.y + nodeAttributes.y + outputs.y + outputLoader.y + outPin.y + onIsConnectedChanged: function() { + outputs.parentPinsUpdated() + } + onPressed: function(mouse) { root.pressed(mouse) } - onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } + onClicked: { + expanded = !expanded + if (outputs.parentPins.has(modelData.name)) { + outputs.parentPins.set(modelData.name, expanded) + outputs.parentPinsUpdated() + } + } + onEdgeAboutToBeRemoved: function(input) { + root.edgeAboutToBeRemoved(input) + } Component.onCompleted: attributePinCreated(attribute, outPin) onChildPinCreated: attributePinCreated(childAttribute, outPin) @@ -559,28 +645,62 @@ Item { width: parent.width spacing: 3 + property var parentPins: new Map() + signal parentPinsUpdated() + Repeater { - model: node ? node.attributes : undefined + model: root.generateAttributesModel(false, inputs.parentPins) // isOutput = false delegate: Loader { id: inputLoader - active: !object.isOutput && object.desc.exposed && object.desc.visible - visible: Boolean(object.enabled) + active: !modelData.isOutput && modelData.exposed && modelData.desc.visible + visible: { + if (Boolean(modelData.enabled)) { + if (modelData.root && modelData.root.type === "GroupAttribute") { + return Boolean(inputs.parentPins.get(modelData.root.name) || + modelData.hasOutputConnections || modelData.isLinkNested) + } + return true + } + return false + } width: inputs.width + Connections { + target: inputs + + function onParentPinsUpdated() { + visible = updateChildPin(modelData, inputs.parentPins, inputLoader.item) + } + } + sourceComponent: AttributePin { id: inPin nodeItem: root - attribute: object + attribute: modelData property real globalX: root.x + nodeAttributes.x + inputs.x + inputLoader.x + inPin.x property real globalY: root.y + nodeAttributes.y + inputs.y + inputLoader.y + inPin.y + onIsConnectedChanged: function() { + inputs.parentPinsUpdated() + } + readOnly: Boolean(root.readOnly || object.isReadOnly) Component.onCompleted: attributePinCreated(attribute, inPin) Component.onDestruction: attributePinDeleted(attribute, inPin) onPressed: function(mouse) { root.pressed(mouse) } - onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } + onClicked: { + expanded = !expanded + if (inputs.parentPins.has(modelData.name)) { + inputs.parentPins.set(modelData.name, expanded) + inputs.parentPinsUpdated() + } + } + onEdgeAboutToBeRemoved: function(input) { + root.edgeAboutToBeRemoved(input) + } + onChildPinCreated: function(childAttribute, inPin) { attributePinCreated(childAttribute, inPin) } onChildPinDeleted: function(childAttribute, inPin) { attributePinDeleted(childAttribute, inPin) } } @@ -619,31 +739,68 @@ Item { id: inputParams width: parent.width spacing: 3 + + property var parentPins: new Map() + signal parentPinsUpdated() + Repeater { - id: inputParamsRepeater - model: node ? node.attributes : undefined + model: root.generateAttributesModel(false, inputParams.parentPins) // isOutput = false + delegate: Loader { id: paramLoader - active: !object.isOutput && !object.desc.exposed && object.desc.visible - visible: Boolean(object.enabled || object.isLinkNested || object.hasOutputConnections) - property bool isFullyActive: Boolean(m.displayParams || object.isLinkNested || object.hasOutputConnections) + active: !modelData.isOutput && !modelData.exposed && modelData.desc.visible + visible: { + if (Boolean(modelData.enabled || modelData.isLinkNested || modelData.hasOutputConnections)) { + if (modelData.root && modelData.root.type === "GroupAttribute") { + return Boolean(inputParams.parentPins.get(modelData.root.name) || + modelData.hasOutputConnections || modelData.isLinkNested) + } + return true + } + return false + } + property bool isFullyActive: Boolean(m.displayParams || modelData.isLinkNested || modelData.hasOutputConnections) width: parent.width + Connections { + target: inputParams + + function onParentPinsUpdated() { + visible = updateChildPin(modelData, inputParams.parentPins, paramLoader.item) + } + } + sourceComponent: AttributePin { id: inParamsPin nodeItem: root + attribute: modelData + property real globalX: root.x + nodeAttributes.x + inputParamsRect.x + paramLoader.x + inParamsPin.x property real globalY: root.y + nodeAttributes.y + inputParamsRect.y + paramLoader.y + inParamsPin.y + onIsConnectedChanged: function() { + inputParams.parentPinsUpdated() + } + height: isFullyActive ? childrenRect.height : 0 Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } visible: (height == childrenRect.height) - attribute: object - readOnly: Boolean(root.readOnly || object.isReadOnly) + + readOnly: Boolean(root.readOnly || modelData.isReadOnly) Component.onCompleted: attributePinCreated(attribute, inParamsPin) Component.onDestruction: attributePinDeleted(attribute, inParamsPin) onPressed: function(mouse) { root.pressed(mouse) } - onEdgeAboutToBeRemoved: function(input) { root.edgeAboutToBeRemoved(input) } + onClicked: { + expanded = !expanded + if (inputParams.parentPins.has(modelData.name)) { + inputParams.parentPins.set(modelData.name, expanded) + inputParams.parentPinsUpdated() + } + } + onEdgeAboutToBeRemoved: function(input) { + root.edgeAboutToBeRemoved(input) + } + onChildPinCreated: function(childAttribute, inParamsPin) { attributePinCreated(childAttribute, inParamsPin) } onChildPinDeleted: function(childAttribute, inParamsPin) { attributePinDeleted(childAttribute, inParamsPin) } } diff --git a/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml b/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml index 92b7be1c7a..6b4fc847ec 100644 --- a/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml +++ b/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml @@ -9,27 +9,37 @@ import QtQuick.Layouts Item { id: control + property alias icon: iconItem property alias iconText: iconItem.text property alias iconSize: iconItem.font.pointSize - property alias label: labelItem.text + property alias label: labelItem + property alias labelIconRow: contentRow property var labelIconColor: palette.text + property alias labelIconMouseArea: mouseArea implicitWidth: childrenRect.width implicitHeight: childrenRect.height - anchors.rightMargin: 5 RowLayout { + id: contentRow + // If we are fitting to a top container, we need to propagate the "anchors.fill: parent" + // Otherwise, the component defines its own size based on its children. + anchors.fill: control.anchors.fill ? parent : undefined Label { id: iconItem font.family: MaterialIcons.fontFamily font.pointSize: 13 padding: 0 text: "" - color: labelIconColor + color: control.labelIconColor + Layout.fillWidth: false + Layout.fillHeight: true } Label { id: labelItem text: "" - color: labelIconColor + color: control.labelIconColor + Layout.fillWidth: true + Layout.fillHeight: true } } diff --git a/meshroom/ui/qml/Viewer3D/Inspector3D.qml b/meshroom/ui/qml/Viewer3D/Inspector3D.qml index 43a08095c1..f35e0377a4 100644 --- a/meshroom/ui/qml/Viewer3D/Inspector3D.qml +++ b/meshroom/ui/qml/Viewer3D/Inspector3D.qml @@ -241,7 +241,7 @@ FloatingPane { MaterialToolLabel { iconText: MaterialIcons.stop - label: { + label.text: { var id = undefined // Ensure there are entries in resectionGroups and a valid resectionId before accessing anything if (Viewer3DSettings.resectionId !== undefined && Viewer3DSettings.resectionGroups && @@ -259,7 +259,7 @@ FloatingPane { MaterialToolLabel { iconText: MaterialIcons.auto_awesome_motion - label: { + label.text: { let currentCameras = 0 if (Viewer3DSettings.resectionGroups) { for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) { @@ -277,7 +277,7 @@ FloatingPane { MaterialToolLabel { iconText: MaterialIcons.videocam - label: { + label.text: { let totalCameras = 0 if (Viewer3DSettings.resectionGroups) { for (let i = 0; i <= Viewer3DSettings.resectionIdCount; i++) { diff --git a/tests/nodes/test/GroupAttributes.py b/tests/nodes/test/GroupAttributes.py new file mode 100644 index 0000000000..028fcf7117 --- /dev/null +++ b/tests/nodes/test/GroupAttributes.py @@ -0,0 +1,152 @@ +from meshroom.core import desc + +class GroupAttributes(desc.Node): + documentation = """ Test node to connect GroupAttributes to other GroupAttributes. """ + category = 'Test' + + # Inputs to the node + inputs = [ + desc.GroupAttribute( + name="firstGroup", + label="First Group", + description="Group at the root level.", + group=None, + exposed=True, + groupDesc=[ + desc.IntParam( + name="firstGroupIntA", + label="Integer A", + description="First integer in group.", + value=1024, + range=(-1, 2000, 10), + exposed=True, + ), + desc.BoolParam( + name="firstGroupBool", + label="Boolean", + description="Boolean in group.", + value=True, + advanced=True, + exposed=True, + ), + desc.ChoiceParam( + name="firstGroupExclusiveChoiceParam", + label="Exclusive Choice Param", + description="Exclusive choice parameter.", + value="one", + values=["one", "two", "three", "four"], + exclusive=True, + exposed=True, + ), + desc.ChoiceParam( + name="firstGroupChoiceParam", + label="ChoiceParam", + description="Non-exclusive choice parameter.", + value=["one", "two"], + values=["one", "two", "three", "four"], + exclusive=False, + exposed=True + ), + desc.GroupAttribute( + name="nestedGroup", + label="Nested Group", + description="A group within a group.", + group=None, + exposed=True, + groupDesc=[ + desc.FloatParam( + name="nestedGroupFloat", + label="Floating Number", + description="Floating number in group.", + value=1.0, + range=(0.0, 100.0, 0.01), + exposed=True + ), + ], + ), + desc.ListAttribute( + name="groupedList", + label="Grouped List", + description="List of groups within a group.", + advanced=True, + exposed=True, + elementDesc=desc.GroupAttribute( + name="listedGroup", + label="Listed Group", + description="Group in a list within a group.", + joinChar=":", + group=None, + groupDesc=[ + desc.IntParam( + name="listedGroupInt", + label="Integer 1", + description="Integer in a group in a list within a group.", + value=12, + range=(3, 24, 1), + exposed=True, + ), + ], + ), + ), + desc.ListAttribute( + name="singleGroupedList", + label="Grouped List With Single Element", + description="List of integers within a group.", + advanced=True, + exposed=True, + elementDesc=desc.IntParam( + name="listedInt", + label="Integer In List", + description="Integer in a list within a group.", + value=40, + ), + ), + ], + ), + desc.IntParam( + name="exposedInt", + label="Exposed Integer", + description="Integer at the rool level, exposed.", + value=1000, + exposed=True, + ), + desc.BoolParam( + name="unexposedBool", + label="Unexposed Boolean", + description="Boolean at the root level, unexposed.", + value=True, + ), + desc.GroupAttribute( + name="inputGroup", + label="Input Group", + description="A group set as an input.", + group=None, + groupDesc=[ + desc.BoolParam( + name="inputBool", + label="Input Bool", + description="", + value=False, + ), + ], + ), + ] + + outputs = [ + desc.GroupAttribute( + name="outputGroup", + label="Output Group", + description="A group set as an output.", + group=None, + exposed=True, + groupDesc=[ + desc.BoolParam( + name="outputBool", + label="Output Bool", + description="", + value=False, + exposed=True, + ), + ], + ), + ] \ No newline at end of file diff --git a/tests/test_groupAttributes.py b/tests/test_groupAttributes.py new file mode 100644 index 0000000000..89d9887ed0 --- /dev/null +++ b/tests/test_groupAttributes.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# coding:utf-8 + +import os +import tempfile + +from meshroom.core.graph import Graph, loadGraph +from meshroom.core.node import CompatibilityNode +from meshroom.core.attribute import GroupAttribute + +GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN = 8 # 1 int, 1 exclusive choice param, 1 choice param, 1 bool, 1 group, 1 float nested in the group, 2 lists +GROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN = 1 # 1 float +GROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN = 1 # 1 bool +GROUPATTRIBUTES_FIRSTGROUP_DEPTHS = [1, 1, 1, 1, 1, 2, 1, 1] + +def test_saveLoadGroupConnections(): + """ + Ensure that connecting attributes that are part of GroupAttributes does not cause + their nodes to have CompatibilityIssues when re-opening them. + """ + graph = Graph("Connections between GroupAttributes") + + # Create two "GroupAttributes" nodes with their default parameters + nodeA = graph.addNewNode("GroupAttributes") + nodeB = graph.addNewNode("GroupAttributes") + + # Connect attributes within groups at different depth levels + graph.addEdges( + (nodeA.firstGroup.firstGroupIntA, nodeB.firstGroup.firstGroupIntA), + (nodeA.firstGroup.nestedGroup.nestedGroupFloat, nodeB.firstGroup.nestedGroup.nestedGroupFloat) + ) + + # Save the graph in a file + graphFile = os.path.join(tempfile.mkdtemp(), "test_io_group_connections.mg") + graph.save(graphFile) + + # Reload the graph + graph = loadGraph(graphFile) + + # Ensure the nodes are not CompatibilityNodes + for node in graph.nodes: + assert not isinstance(node, CompatibilityNode) + + +def test_groupAttributesFlatChildren(): + """ + Check that the list of static flat children is correct, even with list elements. + """ + graph = Graph("Children of GroupAttributes") + + # Create two "GroupAttributes" nodes with their default parameters + node = graph.addNewNode("GroupAttributes") + + intAttr = node.attribute("exposedInt") + assert not isinstance(intAttr, GroupAttribute) + assert len(intAttr.flatStaticChildren) == 0 # Not a Group, cannot have any child + + inputGroup = node.attribute("firstGroup") + assert isinstance(inputGroup, GroupAttribute) + assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN + + # Add an element to a list within the group and check the number of children hasn't changed + groupedList = node.attribute("firstGroup.singleGroupedList") + groupedList.insert(0, 30) + assert len(groupedList.flatStaticChildren) == 0 # Not a Group, elements are not counted as children + assert len(inputGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NB_CHILDREN + + nestedGroup = node.attribute("firstGroup.nestedGroup") + assert isinstance(nestedGroup, GroupAttribute) + assert len(nestedGroup.flatStaticChildren) == GROUPATTRIBUTES_FIRSTGROUP_NESTED_NB_CHILDREN + + outputGroup = node.attribute("outputGroup") + assert isinstance(outputGroup, GroupAttribute) + assert len(outputGroup.flatStaticChildren) == GROUPATTRIBUTES_OUTPUTGROUP_NB_CHILDREN + + +def test_groupAttributesDepthLevels(): + """ + Check that the depth level of children attributes is correctly set. + """ + graph = Graph("Children of GroupAttributes") + + # Create two "GroupAttributes" nodes with their default parameters + node = graph.addNewNode("GroupAttributes") + inputGroup = node.attribute("firstGroup") + assert isinstance(inputGroup, GroupAttribute) + assert inputGroup.depth == 0 # Root level + + cnt = 0 + for child in inputGroup.flatStaticChildren: + assert child.depth == GROUPATTRIBUTES_FIRSTGROUP_DEPTHS[cnt] + cnt = cnt + 1 + + outputGroup = node.attribute("outputGroup") + assert isinstance(outputGroup, GroupAttribute) + assert outputGroup.depth == 0 + for child in outputGroup.flatStaticChildren: # Single element in the group + assert child.depth == 1 + + + intAttr = node.attribute("exposedInt") + assert not isinstance(intAttr, GroupAttribute) + assert intAttr.depth == 0 \ No newline at end of file