diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index 4256cd19b4..efaf719da9 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -9,10 +9,10 @@ from collections.abc import Iterable, Sequence from string import Template from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot +from meshroom.core.exception import InvalidEdgeError from meshroom.core import desc, hashValue -from typing import TYPE_CHECKING - +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from meshroom.core.graph import Edge @@ -45,7 +45,6 @@ def attributeFactory(description: str, value, isOutput: bool, node, root=None, p return attr - class Attribute(BaseObject): """ """ @@ -121,7 +120,7 @@ def getFullNameToGraph(self) -> str: """ Name inside the Graph: graphName.nodeName.groupName.name """ graphName = self.node.graph.name if self.node.graph else "UNDEFINED" return f'{graphName}.{self.getFullNameToNode()}' - + def asLinkExpr(self) -> str: """ Return link expression for this Attribute """ return "{" + self.getFullNameToNode() + "}" @@ -237,6 +236,26 @@ def _set_value(self, value): self.valueChanged.emit() self.validValueChanged.emit() + def _handleLinkValue(self, value) -> bool: + """ + Handle assignment of a link if `value` is a serialized link expression or in-memory Attribute reference. + Returns: Whether the value has been handled as a link, False otherwise. + """ + isAttribute = isinstance(value, Attribute) + isLinkExpression = Attribute.isLinkExpression(value) + + if not isAttribute and not isLinkExpression: + return False + + if isAttribute: + self._linkExpression = value.asLinkExpr() + # If the value is a direct reference to an attribute, it can be directly converted to an edge as + # the source attribute already exists in memory. + self._applyExpr() + elif isLinkExpression: + self._linkExpression = value + return True + @Slot() def _onValueChanged(self): self.node._onAttributeChanged(self) @@ -315,8 +334,8 @@ def isLink(self) -> bool: """ Whether the input attribute is a link to another attribute. """ # note: directly use self.node.graph._edges to avoid using the property that may become # invalid at some point - return self.node.graph and self.isInput and self.node.graph._edges and \ - self in self.node.graph._edges.keys() + return bool(self.node.graph and self.isInput and self.node.graph._edges and \ + self in self.node.graph._edges.keys()) @staticmethod def isLinkExpression(value) -> bool: @@ -383,7 +402,7 @@ def _applyExpr(self): if not g: return if isinstance(v, Attribute): - g.addEdge(v, self) + v.connectTo(self) self.resetToDefaultValue() elif self.isInput and Attribute.isLinkExpression(v): # value is a link to another attribute @@ -393,7 +412,7 @@ def _applyExpr(self): node = g.node(linkNodeName) if not node: raise KeyError(f"Node '{linkNodeName}' not found") - g.addEdge(node.attribute(linkAttrName), self) + node.attribute(linkAttrName).connectTo(self) except KeyError as err: logging.warning('Connect Attribute from Expression failed.') logging.warning(f'Expression: "{v}"\nError: "{err}".') @@ -497,6 +516,43 @@ def _is2D(self) -> bool: return next((imageSemantic for imageSemantic in Attribute.VALID_IMAGE_SEMANTICS if self.desc.semantic == imageSemantic), None) is not None + @Slot(BaseObject, result=bool) + def isCompatibleWith(self, otherAttribute: "Attribute") -> bool: + """ Check if the given attribute can be conected to the current Attribute + """ + return self._isCompatibleWith(otherAttribute) + + def _isCompatibleWith(self, otherAttribute: "Attribute") -> bool: + """ Implementation of the connection validation + .. note: + Override this method to use custom connection validation logic + """ + return self.baseType == otherAttribute.baseType + + def connectTo(self, otherAttribute: "Attribute") -> Optional["Edge"]: + """ Connect the current attribute as the source of the given one + """ + + if not (graph := self.node.graph): + return None + + if isinstance(otherAttribute.root, Attribute): + otherAttribute.root.disconnectEdge() + + return graph.addEdge(self, otherAttribute) + + def disconnectEdge(self): + """ Disconnect the current attribute + """ + + if not (graph := self.node.graph): + return + + graph.removeEdge(self) + + if isinstance(self.root, Attribute): + self.root.disconnectEdge() + name = Property(str, getName, constant=True) fullName = Property(str, getFullName, constant=True) fullNameToNode = Property(str, getFullNameToNode, constant=True) @@ -560,7 +616,6 @@ def wrapper(attr, *args, **kwargs): return func(attr, *args, **kwargs) return wrapper - class PushButtonParam(Attribute): def __init__(self, node, attributeDesc: desc.PushButtonParam, isOutput: bool, root=None, parent=None): @@ -836,7 +891,6 @@ def getOutputConnections(self) -> list["Edge"]: hasOutputConnections = Property(bool, hasOutputConnections.fget, notify=Attribute.hasOutputConnectionsChanged) - class GroupAttribute(Attribute): def __init__(self, node, attributeDesc: desc.GroupAttribute, isOutput: bool, @@ -852,7 +906,14 @@ def __getattr__(self, key): except KeyError: raise AttributeError(key) + def _get_value(self): + return self._value + def _set_value(self, exportedValue): + + if self._handleLinkValue(exportedValue): + return + value = self.validateValue(exportedValue) if isinstance(value, dict): # set individual child attribute values @@ -918,10 +979,38 @@ def uid(self): return hashValue(uids) def _applyExpr(self): - for value in self._value: - value._applyExpr() + + if not self._linkExpression: + for value in self._value: + value._applyExpr() + return + + if not self.isInput or not (graph := self.node.graph): + return + + link = self._linkExpression[1:-1] + linkNodeName, linkAttrName = link.split(".", 1) + try: + node = graph.node(linkNodeName) + if node is None: + raise InvalidEdgeError(self.fullNameToNode, link, "Source node does not exist") + attr = node.attribute(linkAttrName) + if attr is None: + raise InvalidEdgeError(self.fullNameToNode, link, "Source attribute does not exist") + attr.connectTo(self) + except InvalidEdgeError as err: + logging.warning(err) + except Exception as err: + logging.warning("Unexpected error happened during edge creation") + logging.warning(f"Expression '{self._linkExpression}': {err}") + + self._linkExpression = None + self.resetToDefaultValue() def getExportValue(self): + if self.isLink: + return self.getLinkParam().asLinkExpr() + return {key: attr.getExportValue() for key, attr in self._value.objects.items()} def _isDefault(self): @@ -982,7 +1071,49 @@ def getFlatStaticChildren(self): def matchText(self, text: str) -> bool: return super().matchText(text) or any(c.matchText(text) for c in self._value) + def _isCompatibleWith(self, otherAttribute: Attribute): + isCompatible = super()._isCompatibleWith(otherAttribute) + + if not isCompatible: + return False + + return self._haveSameStructure(otherAttribute=otherAttribute) + + def _haveSameStructure(self, otherAttribute: Attribute) -> bool: + """ Does the given attribute have the same number of attributes, and all ordered attributes have the same baseType + """ + + if isinstance(otherAttribute._value, Iterable) and len(otherAttribute._value) != len(self._value): + return False + + for i, attr in enumerate(self.getSubAttributes()): + otherAttr = list(otherAttribute._value)[i] + if isinstance(attr, GroupAttribute): + return attr._haveSameStructure(otherAttr) + elif not otherAttr: + return False + elif attr.baseType != otherAttr.baseType: + return False + + return True + + def getSubAttributes(self): + return list(self._value) + + def connectTo(self, otherAttribute: "GroupAttribute") -> Optional["Edge"]: + """ Connect the current attribute as the source of the given one + + It connects automatically the subgroups + """ + + otherSubChildren = otherAttribute.getSubAttributes() + + for idx, subAttr in enumerate(self.getSubAttributes()): + subAttr.connectTo(otherSubChildren[idx]) + + return super().connectTo(otherAttribute) + # Override value property - value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) + value = Property(Variant, _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/exception.py b/meshroom/core/exception.py index 4443a8962f..93f742a319 100644 --- a/meshroom/core/exception.py +++ b/meshroom/core/exception.py @@ -57,3 +57,15 @@ class StopGraphVisit(GraphVisitMessage): class StopBranchVisit(GraphVisitMessage): """ Immediately stop branch visit. """ pass + + +class AttributeCompatibilityError(GraphException): + """ + Raised when trying to connect attributes that are incompatible + """ + +class InvalidEdgeError(GraphException): + """Raised when an edge between two attributes cannot be created.""" + + def __init__(self, srcAttrName: str, dstAttrName: str, msg: str) -> None: + super().__init__(f"Failed to connect {srcAttrName}->{dstAttrName}: {msg}") \ No newline at end of file diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index a5bff2a250..9d9bbf7b33 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -15,7 +15,7 @@ from meshroom.common import BaseObject, DictModel, Slot, Signal, Property from meshroom.core import Version from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute -from meshroom.core.exception import GraphCompatibilityError, StopGraphVisit, StopBranchVisit +from meshroom.core.exception import GraphCompatibilityError, StopGraphVisit, StopBranchVisit, AttributeCompatibilityError from meshroom.core.graphIO import GraphIO, GraphSerializer, TemplateGraphSerializer, PartialGraphSerializer from meshroom.core.node import BaseNode, Status, Node, CompatibilityNode from meshroom.core.nodeFactory import nodeFactory @@ -24,7 +24,7 @@ # Replace default encoder to support Enums DefaultJSONEncoder = json.JSONEncoder # store the original one - +logger = logging.getLogger(__name__) class MyJSONEncoder(DefaultJSONEncoder): # declare a new one with Enum support def default(self, obj): @@ -893,12 +893,17 @@ def getRootNodes(self, dependenciesOnly): @changeTopology def addEdge(self, srcAttr, dstAttr): + assert isinstance(srcAttr, Attribute) assert isinstance(dstAttr, Attribute) + if srcAttr.node.graph != self or dstAttr.node.graph != self: raise RuntimeError('The attributes of the edge should be part of a common graph.') if dstAttr in self.edges.keys(): - raise RuntimeError(f'Destination attribute "{dstAttr.getFullNameToNode()}" is already connected.') + self.removeEdge(dstAttr) + if not dstAttr.isCompatibleWith(srcAttr): + raise AttributeCompatibilityError(f'Attribute: "{srcAttr.name}" can not be connected to "{dstAttr.name}" because they are not compatible') + edge = Edge(srcAttr, dstAttr) self.edges.add(edge) self.markNodesDirty(dstAttr.node) @@ -913,9 +918,11 @@ def addEdges(self, *edges): self.addEdge(*edge) @changeTopology - def removeEdge(self, dstAttr): - if dstAttr not in self.edges.keys(): - raise RuntimeError(f'Attribute "{dstAttr.getFullNameToNode()}" is not connected') + def removeEdge(self, dstAttr: 'Attribute'): + + if not self.edges.get(dstAttr): + return + edge = self.edges.pop(dstAttr) self.markNodesDirty(dstAttr.node) dstAttr.valueChanged.emit() @@ -1594,7 +1601,6 @@ def setVerbose(self, v): canComputeLeavesChanged = Signal() canComputeLeaves = Property(bool, lambda self: self._canComputeLeaves, notify=canComputeLeavesChanged) - def loadGraph(filepath, strictCompatibility: bool = False) -> Graph: """ Load a Graph from a Meshroom Graph (.mg) file. diff --git a/meshroom/core/node.py b/meshroom/core/node.py index bc10c5253d..49c8dce560 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -875,7 +875,7 @@ def _computeUid(self): for attr in self.invalidatingAttributes: if not attr.enabled: continue # Disabled params do not contribute to the uid - dynamicOutputAttr = attr.isLink and attr.getLinkParam(recursive=True).desc.isDynamicValue + dynamicOutputAttr = attr.getLinkParam(recursive=True) and attr.getLinkParam(recursive=True).desc.isDynamicValue # For dynamic output attributes, the UID does not depend on the attribute value. # In particular, when loading a project file, the UIDs are updated first, # and the node status and the dynamic output values are not yet loaded, @@ -1948,6 +1948,11 @@ def attributeDescFromName(refAttributes, name, value, strict=True): if attrDesc is None: return None + # If it is a serialized link expression (no proper value to set/evaluate) + if Attribute.isLinkExpression(value): + return attrDesc + + # We have found a description, and we still need to # check if the value matches the attribute description. @@ -1959,10 +1964,7 @@ def attributeDescFromName(refAttributes, name, value, strict=True): return None return attrDesc - # If it is a serialized link expression (no proper value to set/evaluate) - if Attribute.isLinkExpression(value): - return attrDesc - + # If it passes the 'matchDescription' test if attrDesc.matchDescription(value, strict): return attrDesc diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index dcbcac0f49..a8f8032d75 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -5,7 +5,7 @@ from PySide6.QtGui import QUndoCommand, QUndoStack from PySide6.QtCore import Property, Signal -from meshroom.core.attribute import ListAttribute, Attribute +from meshroom.core.attribute import ListAttribute, Attribute, GroupAttribute from meshroom.core.graph import Graph, GraphModification from meshroom.core.node import Position, CompatibilityIssue from meshroom.core.nodeFactory import nodeFactory @@ -306,40 +306,98 @@ def undoImpl(self): else: self.graph.internalAttribute(self.attrName).value = self.oldValue +class EdgeCommand(GraphCommand): + """ This command handle the undoImplementation to re-apply all values and expressions to the node implied in the connection + """ + + class StoredAttribute(): + """ Dataclass to store the given attribute to avoid too broad attribute serialization """ -class AddEdgeCommand(GraphCommand): + def __init__(self, attribute): + self.fullName = attribute.getFullNameToNode() + self.value = attribute.getExportValue() + self.isGroup = isinstance(attribute, GroupAttribute) + self.linkParam = attribute.getLinkParam().getFullNameToNode() if attribute.isLink else None + def __init__(self, graph, src, dst, parent=None): super().__init__(graph, parent) + self.srcAttr = src.getFullNameToNode() self.dstAttr = dst.getFullNameToNode() - self.setText(f"Connect '{self.srcAttr}'->'{self.dstAttr}'") + self._srcNodeAttributesStates: list[self.StoredAttribute] = [] + self._dstNodeAttributesStates: list[self.StoredAttribute] = [] + + def _storeNodeAttributesTo(self, node, storedList, filteringPredicate=None): + + for currentSrcAttribute in node.getAttributes(): + if filteringPredicate and filteringPredicate(currentSrcAttribute) == False: + continue + storedList.append(self.StoredAttribute(currentSrcAttribute)) + if isinstance(currentSrcAttribute, GroupAttribute): + for subAttribute in currentSrcAttribute.getFlatStaticChildren(): + storedList.append(self.StoredAttribute(subAttribute)) + + def _storeAttributes(self): + self._storeNodeAttributesTo(self._getSrcAttribute().node, self._srcNodeAttributesStates, lambda attr: not attr.isInput) + self._storeNodeAttributesTo(self._getDstAttribute().node, self._dstNodeAttributesStates, lambda attr: attr.isInput) + + def _applyStoredAttributes(self): + srcNode = self._getSrcAttribute().node + dstNode = self._getDstAttribute().node + + if not(graph := srcNode.graph) and not(graph := dstNode.graph): + return - if src.baseType != dst.baseType: - raise ValueError(f"Attribute types are not compatible and cannot be connected: '{self.srcAttr}'({src.baseType})->'{self.dstAttr}'({dst.baseType})") + attributesWithGroupAtEnd = sorted(self._srcNodeAttributesStates + self._dstNodeAttributesStates, + key=lambda storedAttribute: storedAttribute.isGroup) + + # Apply the group at last to ensure no side effects happen on connectAttribute() + for storedAttribute in attributesWithGroupAtEnd: + attribute = graph.attribute(storedAttribute.fullName) + graph.removeEdge(attribute) + attribute.value = storedAttribute.value + + if storedAttribute.linkParam: + graph.addEdge(graph.attribute(storedAttribute.linkParam), attribute) + + #attribute._applyExpr() + + def _getSrcAttribute(self): + return self.graph.attribute(self.srcAttr) + + def _getDstAttribute(self): + return self.graph.attribute(self.dstAttr) def redoImpl(self): - self.graph.addEdge(self.graph.attribute(self.srcAttr), self.graph.attribute(self.dstAttr)) - return True + self._storeAttributes() def undoImpl(self): - self.graph.removeEdge(self.graph.attribute(self.dstAttr)) + self._applyStoredAttributes() -class RemoveEdgeCommand(GraphCommand): - def __init__(self, graph, edge, parent=None): - super().__init__(graph, parent) - self.srcAttr = edge.src.getFullNameToNode() - self.dstAttr = edge.dst.getFullNameToNode() - self.setText(f"Disconnect '{self.srcAttr}'->'{self.dstAttr}'") + +class AddEdgeCommand(EdgeCommand): + def __init__(self, graph, src, dst, parent=None): + super().__init__(graph, src, dst, parent) + self.setText(f"Connect '{self.srcAttr}'->'{self.dstAttr}'") + + if src.baseType != dst.baseType: + raise ValueError(f"Attribute types are not compatible and cannot be connected: '{self.srcAttr}'({src.baseType})->'{self.dstAttr}'({dst.baseType})") def redoImpl(self): - self.graph.removeEdge(self.graph.attribute(self.dstAttr)) + super().redoImpl() + self._getSrcAttribute().connectTo(self._getDstAttribute()) return True - def undoImpl(self): - self.graph.addEdge(self.graph.attribute(self.srcAttr), - self.graph.attribute(self.dstAttr)) +class RemoveEdgeCommand(EdgeCommand): + def __init__(self, graph, edge, parent=None): + super().__init__(graph, edge.src, edge.dst, parent) + self.setText(f"Disconnect '{self.srcAttr}'->'{self.dstAttr}'") + def redoImpl(self): + super().redoImpl() + self._getDstAttribute().disconnectEdge() + return True class ListAttributeAppendCommand(GraphCommand): def __init__(self, graph, listAttribute, value, parent=None): diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 5a66084ca7..6b78ddfe4c 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -23,7 +23,7 @@ from meshroom.core import sessionUid from meshroom.common.qt import QObjectListModel -from meshroom.core.attribute import Attribute, ListAttribute +from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute from meshroom.core.graph import Graph, Edge, generateTempProjectFilepath from meshroom.core.graphIO import GraphIO @@ -384,6 +384,7 @@ def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, pare self.computeStatusChanged.connect(self.updateLockedUndoStack) self.filePollerRefreshChanged.connect(self._chunksMonitor.filePollerRefreshChanged) + def setGraph(self, g): """ Set the internal graph. """ if self._graph: @@ -853,6 +854,7 @@ def addEdge(self, src, dst): with self.groupedGraphModification(f"Insert and Add Edge on {dst.getFullNameToNode()}"): self.appendAttribute(dst) self._addEdge(src, dst.at(-1)) + else: self._addEdge(src, dst) @@ -864,11 +866,13 @@ def _addEdge(self, src, dst): @Slot(Edge) def removeEdge(self, edge): - if isinstance(edge.dst.root, ListAttribute): - with self.groupedGraphModification(f"Remove Edge and Delete {edge.dst.getFullNameToNode()}"): - self.push(commands.RemoveEdgeCommand(self._graph, edge)) - self.removeAttribute(edge.dst) - else: + with self.groupedGraphModification(f"Remove edge {edge.dst.getFullNameToNode()}"): + if isinstance(edge.dst.root, ListAttribute): + with self.groupedGraphModification(f"Remove Edge and Delete {edge.dst.getFullNameToNode()}"): + self.push(commands.RemoveEdgeCommand(self._graph, edge)) + self.removeAttribute(edge.dst) + return + self.push(commands.RemoveEdgeCommand(self._graph, edge)) @Slot(list) diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 7d28bea13a..c9734c85f3 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -777,6 +777,8 @@ RowLayout { }) obj.Layout.fillWidth = true; obj.attributeDoubleClicked.connect(function(attr) {root.doubleClicked(attr)}) + obj.onInAttributeClicked.connect(function(srcItem, mouse, inAttributes) {root.inAttributeClicked(srcItem, mouse, inAttributes)}) + obj.onOutAttributeClicked.connect(function(srcItem, mouse, outAttributes) {root.outAttributeClicked(srcItem, mouse, outAttributes)}) } } } diff --git a/meshroom/ui/qml/GraphEditor/AttributePin.qml b/meshroom/ui/qml/GraphEditor/AttributePin.qml index 5f16490c6c..89013ee2fb 100755 --- a/meshroom/ui/qml/GraphEditor/AttributePin.qml +++ b/meshroom/ui/qml/GraphEditor/AttributePin.qml @@ -99,12 +99,14 @@ RowLayout { 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)) + if (inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop)) { return Colors.sysPalette.highlight + } return Colors.sysPalette.text } } @@ -125,11 +127,10 @@ RowLayout { // 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 + || !inputDragTarget.attribute.isCompatibleWith(drag.source.attribute) // || 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 @@ -332,12 +333,11 @@ RowLayout { onEntered: function(drag) { // Check if attributes are compatible to create a valid connection if (drag.source.objectName != outputDragTarget.objectName // Not an edge connector - || drag.source.baseType !== outputDragTarget.baseType // Not the same base type + || !outputDragTarget.attribute.isCompatibleWith(drag.source.attribute) // || drag.source.nodeItem === outputDragTarget.nodeItem // Connection between attributes of the same node || (!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 diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 4edf8cdd04..0aa756b671 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -479,10 +479,10 @@ Item { model: nodeRepeater.loaded && root.graph ? root.graph.edges : undefined delegate: Edge { - property var src: root._attributeToDelegate[edge.src] - property var dst: root._attributeToDelegate[edge.dst] + property var src: recursivelyRetrieveAttributePin(edge.src) + property var dst: recursivelyRetrieveAttributePin(edge.dst) property bool isValidEdge: src !== undefined && dst !== undefined - visible: isValidEdge && src.visible && dst.visible + visible: isValidEdge && src && dst property bool forLoop: src.attribute.type === "ListAttribute" && dst.attribute.type != "ListAttribute" @@ -519,6 +519,36 @@ Item { } } } + + function recursivelyRetrieveAttributePin(attribute) { + /* + Will try to retrieve thef first visible parent atribute of the given attribute + */ + let dstAttributeDelegate = root._attributeToDelegate[attribute] + if (dstAttributeDelegate && dstAttributeDelegate.visible) { return dstAttributeDelegate } + + if (!attribute || !attribute.root ) { + return + } + + let index = attribute.root.value.indexOf(attribute) + let groupAttributeDelegate = null; + let groupAttribute = attribute; + + while (groupAttribute && !groupAttributeDelegate || ( groupAttributeDelegate && !groupAttributeDelegate.visible && groupAttribute && groupAttribute.root)) { + + groupAttribute = groupAttribute ? groupAttribute.root : null + + if (groupAttribute) { + groupAttributeDelegate = root._attributeToDelegate[groupAttribute] + } + } + if (groupAttributeDelegate) { + return groupAttributeDelegate + } + + return dstAttributeDelegate + } } } diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index b430a63165..51fb80e3ec 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -243,9 +243,10 @@ Item { */ 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 + // child attribute is already connected with a visible edge if (attribute.root && attribute.root.type === "GroupAttribute") { - var visible = Boolean(parentPins.get(attribute.root.name) || attribute.hasOutputConnections || attribute.isLinkNested) + var visible = Boolean(parentPins.get(attribute.root.name)) + if (!visible && parentPins.has(attribute.name) && parentPins.get(attribute.name) === true) { parentPins.set(attribute.name, false) pin.expanded = false diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index 7a43db7f6a..a853a8d66c 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -379,6 +379,12 @@ 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) + } } } } diff --git a/tests/nodes/test/color.py b/tests/nodes/test/color.py new file mode 100644 index 0000000000..83df6bee1c --- /dev/null +++ b/tests/nodes/test/color.py @@ -0,0 +1,40 @@ +from meshroom.core import desc + +class Color(desc.Node): + + inputs = [ + desc.GroupAttribute( + name="rgb", + label="rgb", + description="rgb", + exposed=True, + groupDesc=[ + desc.FloatParam(name="r", label="r", description="r", value=0.0), + desc.FloatParam(name="g", label="g", description="g", value=0.0), + desc.FloatParam(name="b", label="b", description="b", value=0.0) + ] + ) + ] + +class NestedColor(desc.Node): + + inputs = [ + desc.GroupAttribute( + name="rgb", + label="rgb", + description="rgb", + exposed=True, + groupDesc=[ + desc.FloatParam(name="r", label="r", description="r", value=0.0), + desc.FloatParam(name="g", label="g", description="g", value=0.0), + desc.FloatParam(name="b", label="b", description="b", value=0.0), + desc.GroupAttribute(label="test", name="test", description="", + groupDesc=[ + desc.FloatParam(name="r", label="r", description="r", value=0.0), + desc.FloatParam(name="g", label="g", description="g", value=0.0), + desc.FloatParam(name="b", label="b", description="b", value=0.0), + + ]) + ] + ) + ] \ No newline at end of file diff --git a/tests/nodes/test/position.py b/tests/nodes/test/position.py new file mode 100644 index 0000000000..c53d251102 --- /dev/null +++ b/tests/nodes/test/position.py @@ -0,0 +1,40 @@ +from meshroom.core import desc + + +class Position(desc.Node): + + inputs = [ + desc.GroupAttribute( + name="xyz", + label="xyz", + description="xyz", + exposed=True, + groupDesc=[ + desc.FloatParam(name="x", label="x", description="x", value=0.0), + desc.FloatParam(name="y", label="y", description="y", value=0.0), + desc.FloatParam(name="z", label="z", description="z", value=0.0) + ] + ) + ] + +class NestedPosition(desc.Node): + + inputs = [ + desc.GroupAttribute( + name="xyz", + label="xyz", + description="xyz", + exposed=True, + groupDesc=[ + desc.FloatParam(name="x", label="x", description="x", value=0.0), + desc.FloatParam(name="y", label="y", description="y", value=0.0), + desc.FloatParam(name="z", label="z", description="z", value=0.0), + desc.GroupAttribute(label="test", name="test", description="", + groupDesc=[ + desc.FloatParam(name="x", label="x", description="x", value=0.0), + desc.FloatParam(name="y", label="y", description="y", value=0.0), + desc.FloatParam(name="z", label="z", description="z", value=0.0), + ]) + ] + ) + ] \ No newline at end of file diff --git a/tests/nodes/test/test copy.py b/tests/nodes/test/test copy.py new file mode 100644 index 0000000000..44bb3aa254 --- /dev/null +++ b/tests/nodes/test/test copy.py @@ -0,0 +1,23 @@ +from meshroom.core import desc + +class NestedTest(desc.Node): + + inputs = [ + desc.GroupAttribute( + name="xyz", + label="xyz", + description="xyz", + exposed=True, + groupDesc=[ + desc.FloatParam(name="x", label="x", description="x", value=0.0), + desc.FloatParam(name="y", label="z", description="z", value=0.0), + desc.FloatParam(name="z", label="z", description="z", value=0.0), + desc.GroupAttribute(label="test", name="test", description="", + groupDesc=[ + desc.StringParam(name="x", label="x", description="x", value="test"), + desc.FloatParam(name="y", label="z", description="z", value=0.0), + desc.FloatParam(name="z", label="z", description="z", value=0.0), + ]) + ] + ) + ] \ No newline at end of file diff --git a/tests/test_groupAttributes.py b/tests/test_groupAttributes.py index 89d9887ed0..e2ba937696 100644 --- a/tests/test_groupAttributes.py +++ b/tests/test_groupAttributes.py @@ -3,6 +3,7 @@ import os import tempfile +import math from meshroom.core.graph import Graph, loadGraph from meshroom.core.node import CompatibilityNode @@ -40,6 +41,9 @@ def test_saveLoadGroupConnections(): # Ensure the nodes are not CompatibilityNodes for node in graph.nodes: assert not isinstance(node, CompatibilityNode) + + + def test_groupAttributesFlatChildren(): @@ -100,4 +104,119 @@ def test_groupAttributesDepthLevels(): intAttr = node.attribute("exposedInt") assert not isinstance(intAttr, GroupAttribute) - assert intAttr.depth == 0 \ No newline at end of file + assert intAttr.depth == 0 + +def test_saveLoadGroupDirectConnections(): + """ + + """ + 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, nodeB.firstGroup), + (nodeA.firstGroup, nodeB.firstGroup), + ) + + # 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) + + assert graph.node("GroupAttributes_2").firstGroup.getLinkParam() == graph.node("GroupAttributes_1").firstGroup + + +def test_groupAttributes_with_same_structure_should_allow_connection(): + + # Given + graph = Graph() + nestedPosition = graph.addNewNode("NestedPosition") + nestedColor = graph.addNewNode("NestedColor") + + # When + acceptedConnection = nestedPosition.xyz.isCompatibleWith(nestedColor.rgb) + + # Then + assert acceptedConnection == True + +def test_groupAttributes_with_different_structure_should_not_allow_connection(): + + # Given + graph = Graph() + nestedPosition = graph.addNewNode("NestedPosition") + nestedTest = graph.addNewNode("NestedTest") + + # When + acceptedConnection = nestedPosition.xyz.isCompatibleWith(nestedTest.xyz) + + # Then + assert acceptedConnection == False + +def test_groupAttributes_connection_should_connect_all_subAttributes(): + # Given + graph = Graph() + + nestedColor = graph.addNewNode("NestedColor") + nestedPosition = graph.addNewNode("NestedPosition") + + assert nestedPosition.xyz.isLink == False + assert nestedPosition.xyz.x.isLink == False + assert nestedPosition.xyz.y.isLink == False + assert nestedPosition.xyz.z.isLink == False + assert nestedPosition.xyz.test.isLink == False + assert nestedPosition.xyz.test.x.isLink == False + assert nestedPosition.xyz.test.y.isLink == False + assert nestedPosition.xyz.test.z.isLink == False + + # When + nestedColor.rgb.connectTo(nestedPosition.xyz) + + # Then + assert nestedPosition.xyz.isLink == True + assert nestedPosition.xyz.x.isLink == True + assert nestedPosition.xyz.y.isLink == True + assert nestedPosition.xyz.z.isLink == True + assert nestedPosition.xyz.test.isLink == True + assert nestedPosition.xyz.test.x.isLink == True + assert nestedPosition.xyz.test.y.isLink == True + assert nestedPosition.xyz.test.z.isLink == True + +def test_connecting_a_subAttribute_should_disconnect_the_parent_groupAttribute(): + # Given + graph = Graph() + + nestedColor = graph.addNewNode("NestedColor") + nestedPosition = graph.addNewNode("NestedPosition") + + nestedColor.rgb.connectTo(nestedPosition.xyz) + + assert nestedPosition.xyz.isLink == True + assert nestedPosition.xyz.x.isLink == True + assert nestedPosition.xyz.y.isLink == True + assert nestedPosition.xyz.z.isLink == True + assert nestedPosition.xyz.test.isLink == True + assert nestedPosition.xyz.test.x.isLink == True + assert nestedPosition.xyz.test.y.isLink == True + assert nestedPosition.xyz.test.z.isLink == True + + # When + r = nestedColor.rgb.r + z = nestedPosition.xyz.test.z + r.connectTo(z) + + # Then + + assert nestedPosition.xyz.isLink == False # Disconnected because sub GroupAttribute has been disconnected + assert nestedPosition.xyz.x.isLink == True + assert nestedPosition.xyz.y.isLink == True + assert nestedPosition.xyz.z.isLink == True + assert nestedPosition.xyz.test.isLink == False # Disconnected because nestedPosition.xyz.test.z has been reconnected + assert nestedPosition.xyz.test.x.isLink == True + assert nestedPosition.xyz.test.y.isLink == True + assert nestedPosition.xyz.test.z.isLink == True \ No newline at end of file