Skip to content

Commit 35cf5ba

Browse files
Enable connecting multiple list Attributes
1 parent 912a71d commit 35cf5ba

5 files changed

Lines changed: 206 additions & 4 deletions

File tree

meshroom/core/graph.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,73 @@ def addEdge(self, srcAttr: Attribute, dstAttr: Attribute) -> tuple[list[Attribut
953953
srcAttr.outputLinksChanged.emit()
954954
return [edge.src, edge.dst], deletedEdge
955955

956+
@changeTopology
957+
def addListEdges(self, srcAttr: Attribute, dstAttr: Attribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]:
958+
"""
959+
Connect srcAttr to dstAttr, handling ListAttribute specifics:
960+
- List -> Scalar: connect first element of the source list to the destination.
961+
- Scalar -> List: append an element to the destination list, then connect.
962+
- List -> List: if the destination is already linked (as a "view"), decompose the
963+
existing link into individual element links, then append and connect each element
964+
of the source list individually.
965+
- Scalar -> Scalar: delegate to addEdge directly.
966+
967+
Args:
968+
srcAttr: the source Attribute
969+
dstAttr: the destination Attribute
970+
971+
Returns:
972+
A tuple containing:
973+
- a list of [src, dst] pairs for every created edge
974+
- a list of [src, dst] pairs for every deleted edge
975+
"""
976+
createdEdges = []
977+
deletedEdges = []
978+
979+
if isinstance(srcAttr, ListAttribute) and not isinstance(dstAttr, ListAttribute):
980+
# List -> Scalar: connect first element of src to dst
981+
connected, deleted = srcAttr.at(0).connectTo(dstAttr)
982+
createdEdges += connected
983+
deletedEdges += deleted
984+
985+
elif isinstance(dstAttr, ListAttribute) and not isinstance(srcAttr, ListAttribute):
986+
# Scalar -> List: append a new element to dst and connect src to it
987+
with GraphModification(self):
988+
dstAttr.append("")
989+
connected, deleted = srcAttr.connectTo(dstAttr.at(-1))
990+
createdEdges += connected
991+
deletedEdges += deleted
992+
993+
elif isinstance(srcAttr, ListAttribute) and isinstance(dstAttr, ListAttribute):
994+
# List -> List
995+
with GraphModification(self):
996+
if dstAttr.isLink:
997+
# Destination is a "view" on another ListAttribute.
998+
# Decompose the existing link into individual element links.
999+
existingEdge = self.edge(dstAttr)
1000+
existingSrc = existingEdge.src # the previously connected ListAttribute
1001+
deleted = dstAttr.disconnectEdge()
1002+
deletedEdges += deleted
1003+
for j in range(len(existingSrc)):
1004+
dstAttr.append("")
1005+
connected, deleted = existingSrc.at(j).connectTo(dstAttr.at(-1))
1006+
createdEdges += connected
1007+
deletedEdges += deleted
1008+
1009+
# Append and connect each element of src individually
1010+
for i in range(len(srcAttr)):
1011+
dstAttr.append("")
1012+
connected, deleted = srcAttr.at(i).connectTo(dstAttr.at(-1))
1013+
createdEdges += connected
1014+
deletedEdges += deleted
1015+
else:
1016+
# Scalar -> Scalar
1017+
connected, deleted = srcAttr.connectTo(dstAttr)
1018+
createdEdges += connected
1019+
deletedEdges += deleted
1020+
1021+
return createdEdges, deletedEdges
1022+
9561023
@changeTopology
9571024
def removeEdge(self, dstAttr: Attribute):
9581025
if not self.edges.get(dstAttr):

meshroom/ui/commands.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,42 @@ def undoImpl(self) -> bool:
487487
return True
488488

489489

490+
class AddListEdgesCommand(GraphCommand):
491+
"""
492+
Command to connect two ListAttributes by decomposing them into individual
493+
element-level edges. Handles the case where the destination already has
494+
a list-level link (decomposes it first).
495+
"""
496+
def __init__(self, graph, src, dst, parent=None):
497+
super().__init__(graph, parent)
498+
self.srcAttr = src.fullName
499+
self.dstAttr = dst.fullName
500+
self.createdEdges = [] # List of all edges created
501+
self.deletedEdges = [] # List of all edges deleted
502+
self.initialDstLen = len(dst) # Length of dst before any modification
503+
self.setText(f"Connect Lists '{self.srcAttr}' -> '{self.dstAttr}'")
504+
505+
def redoImpl(self) -> bool:
506+
srcAttr = self.graph.attribute(self.srcAttr)
507+
dstAttr = self.graph.attribute(self.dstAttr)
508+
self.createdEdges, self.deletedEdges = self.graph.addListEdges(srcAttr, dstAttr)
509+
return True
510+
511+
def undoImpl(self) -> bool:
512+
dstAttr = self.graph.attribute(self.dstAttr)
513+
# Disconnect all created edges
514+
for edge in self.createdEdges:
515+
edge[1].disconnectEdge()
516+
# Remove all appended elements (restore dst to its initial length)
517+
currentLen = len(dstAttr)
518+
if currentLen > self.initialDstLen:
519+
dstAttr.remove(self.initialDstLen, currentLen - self.initialDstLen)
520+
# Restore all deleted edges
521+
for edge in self.deletedEdges:
522+
edge[0].connectTo(edge[1])
523+
return True
524+
525+
490526
class ListAttributeAppendCommand(GraphCommand):
491527
def __init__(self, graph, listAttribute, value, parent=None):
492528
super().__init__(graph, parent)

meshroom/ui/graph.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,11 +1231,19 @@ def clearDataFrom(self, nodes: list[Node]):
12311231
@Slot(Attribute, Attribute)
12321232
def addEdge(self, src, dst):
12331233
if isinstance(src, ListAttribute) and not isinstance(dst, ListAttribute):
1234+
# Source is a list, Destination is not a list
1235+
# Connect first attribute of the list to the destination
12341236
self._addEdge(src.at(0), dst)
12351237
elif isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute):
1238+
# Source is not a list, Destination is a list
1239+
# Append the source to the destination's list attributes
12361240
with self.groupedGraphModification(f"Insert and Add Edge on {dst.fullName}"):
12371241
self.appendAttribute(dst)
12381242
self._addEdge(src, dst.at(-1))
1243+
elif isinstance(src, ListAttribute) and isinstance(dst, ListAttribute):
1244+
# Both Source and Destination attributes are listAttributes
1245+
with self.groupedGraphModification(f"Insert and Add Edges on {dst.fullName}"):
1246+
self.push(commands.AddListEdgesCommand(self._graph, src, dst))
12391247
else:
12401248
self._addEdge(src, dst)
12411249

meshroom/ui/qml/GraphEditor/AttributePin.qml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,11 @@ RowLayout {
130130
|| drag.source.objectName != inputDragTarget.objectName // Not an edge connector
131131
|| !validIncomingConnection // Connection is not allowed
132132
|| drag.source.nodeItem === inputDragTarget.nodeItem // Connection between attributes of the same node
133-
|| drag.source.isList && childrenRepeater.count // Source/target are lists but target already has children
134133
|| 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)
135134
) {
136135
// Refuse attributes connection
137136
drag.accepted = false
138-
} else if (inputDragTarget.attribute.isLink) { // Already connected attribute
137+
} else if (inputDragTarget.attribute.isLink && !(drag.source.isList && root.isList)) { // Already connected attribute (not list-to-list, which is additive)
139138
root.edgeAboutToBeRemoved(inputDragTarget.attribute)
140139
}
141140
inputDropArea.acceptableDrop = drag.accepted
@@ -367,12 +366,11 @@ RowLayout {
367366
|| !validIncomingConnection // Connection is not allowed
368367
|| drag.source.nodeItem === outputDragTarget.nodeItem // Connection between attributes of the same node
369368
|| (!drag.source.isList && outputDragTarget.isList) // Connection between a list and a simple attribute
370-
|| (drag.source.isList && childrenRepeater.count) // Source/target are lists but target already has children
371369
|| drag.source.connectorType === "output" // Refuse to connect an output pin on another one
372370
) {
373371
// Refuse attributes connection
374372
drag.accepted = false
375-
} else if (drag.source.attribute.isLink) { // Already connected attribute
373+
} else if (drag.source.attribute.isLink && !(drag.source.isList && root.isList)) { // Already connected attribute (not list-to-list, which is additive)
376374
root.edgeAboutToBeRemoved(drag.source.attribute)
377375
}
378376
outputDropArea.acceptableDrop = drag.accepted

tests/test_listAttribute.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,96 @@ def test_elementAccessUsesLinkParam(self):
6262

6363
assert nodeB.listInput.at(0).node == nodeA
6464
assert nodeB.listInput.index(nodeB.listInput.at(0)) == 0
65+
66+
def test_connectToTwoListAttributesReplacesLink(self):
67+
"""
68+
Test that connecting two different ListAttributes to the same destination
69+
ListAttribute using connectTo replaces the first link with the second one.
70+
"""
71+
graph = Graph("")
72+
73+
nodeA = graph.addNewNode(NodeWithListAttribute.__name__)
74+
nodeB = graph.addNewNode(NodeWithListAttribute.__name__)
75+
nodeC = graph.addNewNode(NodeWithListAttribute.__name__)
76+
77+
nodeA.listInput.extend(["A1", "A2", "A3"])
78+
nodeB.listInput.extend(["B1", "B2"])
79+
80+
# First connection: nodeA.listInput -> nodeC.listInput
81+
nodeA.listInput.connectTo(nodeC.listInput)
82+
assert nodeC.listInput.isLink
83+
assert len(nodeC.listInput) == 3
84+
assert nodeC.listInput.at(0).node == nodeA
85+
86+
# Second connection replaces the first (connectTo disconnects root)
87+
nodeB.listInput.connectTo(nodeC.listInput)
88+
assert nodeC.listInput.isLink
89+
assert len(nodeC.listInput) == 2
90+
assert nodeC.listInput.at(0).node == nodeB
91+
assert nodeC.listInput.at(1).node == nodeB
92+
assert not nodeA.listInput.hasAnyOutputLinks
93+
94+
def test_addListEdgesAccumulatesElements(self):
95+
"""
96+
Test that Graph.addListEdges correctly decomposes an existing list-level
97+
link and accumulates individual element-level links from multiple sources.
98+
"""
99+
graph = Graph("")
100+
101+
nodeA = graph.addNewNode(NodeWithListAttribute.__name__)
102+
nodeB = graph.addNewNode(NodeWithListAttribute.__name__)
103+
nodeC = graph.addNewNode(NodeWithListAttribute.__name__)
104+
105+
nodeA.listInput.extend(["A1", "A2"])
106+
nodeB.listInput.extend(["B1", "B2"])
107+
108+
# First addListEdges: nodeA -> nodeC
109+
createdEdges, deletedEdges = graph.addListEdges(nodeA.listInput, nodeC.listInput)
110+
assert len(createdEdges) == 2
111+
assert len(deletedEdges) == 0
112+
assert len(nodeC.listInput) == 2
113+
assert nodeC.listInput.at(0).isLink
114+
assert nodeC.listInput.at(1).isLink
115+
assert nodeC.listInput.at(0).inputLink.node == nodeA
116+
assert nodeC.listInput.at(1).inputLink.node == nodeA
117+
118+
# Second addListEdges: nodeB -> nodeC (accumulates)
119+
createdEdges, deletedEdges = graph.addListEdges(nodeB.listInput, nodeC.listInput)
120+
assert len(createdEdges) == 2
121+
assert len(deletedEdges) == 0
122+
123+
# All 4 element-level links should be preserved
124+
assert len(nodeC.listInput) == 4
125+
assert nodeC.listInput.at(0).inputLink.node == nodeA
126+
assert nodeC.listInput.at(1).inputLink.node == nodeA
127+
assert nodeC.listInput.at(2).inputLink.node == nodeB
128+
assert nodeC.listInput.at(3).inputLink.node == nodeB
129+
130+
def test_addListEdgesDecomposesExistingViewLink(self):
131+
"""
132+
Test that Graph.addListEdges decomposes an existing list-level 'view' link
133+
into individual element links before adding new ones.
134+
"""
135+
graph = Graph("")
136+
137+
nodeA = graph.addNewNode(NodeWithListAttribute.__name__)
138+
nodeB = graph.addNewNode(NodeWithListAttribute.__name__)
139+
nodeC = graph.addNewNode(NodeWithListAttribute.__name__)
140+
141+
nodeA.listInput.extend(["A1", "A2"])
142+
nodeB.listInput.extend(["B1"])
143+
144+
# Create a list-level "view" link: nodeA.listInput -> nodeC.listInput
145+
nodeA.listInput.connectTo(nodeC.listInput)
146+
assert nodeC.listInput.isLink
147+
assert len(nodeC.listInput) == 2
148+
149+
# addListEdges should decompose the view and add nodeB's elements
150+
createdEdges, deletedEdges = graph.addListEdges(nodeB.listInput, nodeC.listInput)
151+
152+
# The view link was deleted, individual links from nodeA were re-created,
153+
# plus a new link from nodeB
154+
assert len(nodeC.listInput) == 3
155+
assert nodeC.listInput.at(0).inputLink.node == nodeA
156+
assert nodeC.listInput.at(1).inputLink.node == nodeA
157+
assert nodeC.listInput.at(2).inputLink.node == nodeB

0 commit comments

Comments
 (0)