Skip to content

Commit 1b2d5e2

Browse files
authored
Merge pull request #2884 from alicevision/dev/node_actions
[ui] Add NodeActions to launch computation Creates a header on top of the nodes with buttons to launch / force relaunch / submit the node. The NodeActions follows the node but keeps a uniform scale.
2 parents b39e664 + 82c17f2 commit 1b2d5e2

File tree

4 files changed

+349
-2
lines changed

4 files changed

+349
-2
lines changed

meshroom/ui/graph.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,8 +1169,29 @@ def pasteNodes(self, serializedData: str, position: Optional[QPoint]=None) -> li
11691169
logging.warning("Content is not a valid graph data.")
11701170
return []
11711171
return result
1172-
1173-
1172+
1173+
@Slot(Node, result=bool)
1174+
def canComputeNode(self, node: Node) -> bool:
1175+
""" Check if the node can be computed """
1176+
if node.isCompatibilityNode or not node.isComputableType or node.getLocked():
1177+
return False
1178+
if node.isComputed:
1179+
return True
1180+
if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node) % 2 == 1:
1181+
return True
1182+
return False
1183+
1184+
@Slot(Node, result=bool)
1185+
def canSubmitNode(self, node: Node) -> bool:
1186+
""" Check if the node can be submitted """
1187+
if node.isCompatibilityNode or not node.isComputableType or node.getLocked():
1188+
return False
1189+
if node.isComputed:
1190+
return True
1191+
if self._graph.canComputeTopologically(node) and self._graph.canSubmitOrCompute(node)> 1:
1192+
return True
1193+
return False
1194+
11741195
undoStack = Property(QObject, lambda self: self._undoStack, constant=True)
11751196
graphChanged = Signal()
11761197
graph = Property(Graph, lambda self: self._graph, notify=graphChanged)
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import QtQuick
2+
import QtQuick.Controls
3+
import QtQuick.Layouts
4+
5+
import MaterialIcons 2.2
6+
import Utils 1.0
7+
8+
Item {
9+
id: root
10+
11+
// Settings
12+
readonly property real headerOffset: 10 // Distance above the node in screen pixels
13+
readonly property real _opacity: 0.9
14+
15+
// Objects passed from the graph editor
16+
property var uigraph: null
17+
property var draggable: null // The draggable container from GraphEditor
18+
property var nodeRepeater: null // Reference to nodeRepeater to find delegates
19+
20+
// Signals
21+
signal computeRequest(var node)
22+
signal stopComputeRequest(var node)
23+
signal deleteDataRequest(var node)
24+
signal submitRequest(var node)
25+
26+
SystemPalette { id: activePalette }
27+
28+
/**
29+
* Get the node delegate
30+
*/
31+
function nodeDelegate(node) {
32+
if (!nodeRepeater)
33+
return null
34+
for (var i = 0; i < nodeRepeater.count; ++i) {
35+
if (nodeRepeater.itemAt(i).node === node)
36+
return nodeRepeater.itemAt(i)
37+
}
38+
return null
39+
}
40+
41+
enum ButtonState {
42+
DISABLED = 0,
43+
LAUNCHABLE = 1,
44+
DELETABLE = 2,
45+
STOPPABLE = 3
46+
}
47+
48+
Rectangle {
49+
id: actionHeader
50+
51+
readonly property bool hasSelectedNode: uigraph && uigraph.nodeSelection.selectedIndexes.length === 1
52+
readonly property var selectedNode: hasSelectedNode ? uigraph.selectedNode : null
53+
readonly property var selectedNodeDelegate: selectedNode ? root.nodeDelegate(selectedNode) : null
54+
55+
visible: selectedNodeDelegate !== null
56+
color: "transparent"
57+
width: actionItemsRow.width
58+
height: actionItemsRow.height
59+
60+
//
61+
// ===== Manage NodeActions position =====
62+
//
63+
64+
// Prevents losing focus on the node when we click on buttons of the actionItems
65+
MouseArea {
66+
anchors.fill: parent
67+
onPressed: function(mouse) { mouse.accepted = true }
68+
onReleased: function(mouse) { mouse.accepted = true }
69+
onClicked: function(mouse) { mouse.accepted = true }
70+
onDoubleClicked: function(mouse) { mouse.accepted = true }
71+
hoverEnabled: false
72+
}
73+
74+
// Update position
75+
function updatePosition() {
76+
if (!selectedNodeDelegate || !draggable) return
77+
// Calculate node position in screen coordinates
78+
const nodeScreenX = selectedNodeDelegate.x * draggable.scale + draggable.x
79+
const nodeScreenY = selectedNodeDelegate.y * draggable.scale + draggable.y
80+
// Position header above the node (fixed offset in screen pixels)
81+
x = nodeScreenX + (selectedNodeDelegate.width * draggable.scale - width) / 2
82+
y = nodeScreenY - height - headerOffset
83+
}
84+
85+
onWidthChanged: {
86+
updatePosition()
87+
}
88+
89+
// Update position when the user moves on the graph
90+
Connections {
91+
target: root.draggable
92+
function onXChanged() { actionHeader.updatePosition() }
93+
function onYChanged() { actionHeader.updatePosition() }
94+
function onScaleChanged() { actionHeader.updatePosition() }
95+
}
96+
97+
// Update position when nodes are moved
98+
Connections {
99+
target: actionHeader.selectedNodeDelegate
100+
function onXChanged() { actionHeader.updatePosition() }
101+
function onYChanged() { actionHeader.updatePosition() }
102+
ignoreUnknownSignals: true
103+
}
104+
105+
//
106+
// ===== Manage buttons =====
107+
//
108+
109+
property bool nodeIsLocked: false
110+
property bool canComputeNode: false
111+
property bool canStopNode: false
112+
property bool canRestartNode: false
113+
property bool canSubmitNode: false
114+
property bool nodeSubmitted: false
115+
116+
property int computeButtonState: NodeActions.ButtonState.LAUNCHABLE
117+
property string computeButtonIcon: {
118+
switch (computeButtonState) {
119+
case NodeActions.ButtonState.STOPPABLE: return MaterialIcons.cancel_schedule_send
120+
default: return MaterialIcons.send
121+
}
122+
}
123+
property int submitButtonState: NodeActions.ButtonState.LAUNCHABLE
124+
125+
function getComputeButtonState(node) {
126+
if (actionHeader.canStopNode)
127+
return NodeActions.ButtonState.STOPPABLE
128+
if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS")
129+
return NodeActions.ButtonState.DELETABLE
130+
if (actionHeader.canComputeNode)
131+
return NodeActions.ButtonState.LAUNCHABLE
132+
return NodeActions.ButtonState.DISABLED
133+
}
134+
135+
function getSubmitButtonState(node) {
136+
if (actionHeader.nodeIsLocked || actionHeader.canStopNode)
137+
return NodeActions.ButtonState.DISABLED
138+
if (!actionHeader.nodeIsLocked && node.globalStatus == "SUCCESS")
139+
return NodeActions.ButtonState.DISABLED
140+
if (actionHeader.canSubmitNode)
141+
return NodeActions.ButtonState.LAUNCHABLE
142+
return NodeActions.ButtonState.DISABLED
143+
}
144+
145+
function isSubmittedExternally(node) {
146+
return node.globalExecMode == "EXTERN" && ["RUNNING", "SUBMITTED"].includes(node.globalStatus)
147+
}
148+
149+
function isNodeRestartable(node) {
150+
return actionHeader.computeButtonState == NodeActions.ButtonState.LAUNCHABLE &&
151+
["ERROR", "STOPPED", "KILLED"].includes(node.globalStatus)
152+
}
153+
154+
function updateProperties(node) {
155+
if (!node) return
156+
// Update properties values
157+
actionHeader.canComputeNode = uigraph.canComputeNode(node)
158+
actionHeader.canSubmitNode = uigraph.canSubmitNode(node)
159+
actionHeader.canStopNode = node.canBeStopped() || node.canBeCanceled()
160+
actionHeader.nodeIsLocked = node.locked
161+
actionHeader.nodeSubmitted = isSubmittedExternally(node)
162+
// Update button states
163+
actionHeader.computeButtonState = getComputeButtonState(node)
164+
actionHeader.submitButtonState = getSubmitButtonState(node)
165+
actionHeader.canRestartNode = isNodeRestartable(node)
166+
}
167+
168+
// Set initial state & position
169+
onSelectedNodeDelegateChanged: {
170+
if (actionHeader.selectedNode) {
171+
actionHeader.updateProperties(actionHeader.selectedNode)
172+
Qt.callLater(actionHeader.updatePosition)
173+
}
174+
}
175+
176+
// Listen to updates to status
177+
Connections {
178+
target: actionHeader.selectedNode
179+
function onGlobalStatusChanged() {
180+
actionHeader.updateProperties(target)
181+
}
182+
function onLockedChanged() {
183+
actionHeader.nodeIsLocked = target.locked
184+
}
185+
ignoreUnknownSignals: true
186+
}
187+
188+
// Listen to updates from nodes that are not selected
189+
Connections {
190+
target: root.uigraph
191+
function onComputingChanged() {
192+
actionHeader.updateProperties(actionHeader.selectedNode)
193+
}
194+
ignoreUnknownSignals: true
195+
}
196+
197+
Row {
198+
id: actionItemsRow
199+
anchors.centerIn: parent
200+
spacing: 2
201+
202+
// Compute button
203+
MaterialToolButton {
204+
id: computeButton
205+
font.pointSize: 16
206+
text: actionHeader.computeButtonIcon
207+
padding: 6
208+
ToolTip.text: "Start/Stop/Restart Compute"
209+
ToolTip.visible: hovered
210+
ToolTip.delay: 1000
211+
visible: actionHeader.computeButtonState != NodeActions.ButtonState.DELETABLE
212+
enabled: actionHeader.computeButtonState % 2 == 1 // Launchable & Stoppable
213+
background: Rectangle {
214+
color: {
215+
if (!computeButton.enabled) {
216+
if (actionHeader.nodeSubmitted)
217+
return Qt.darker(Colors.statusColors["SUBMITTED"], 1.2)
218+
return activePalette.button
219+
}
220+
if (actionHeader.computeButtonState == NodeActions.ButtonState.STOPPABLE)
221+
return computeButton.hovered ? Colors.orange : Qt.darker(Colors.orange, 1.3)
222+
return computeButton.hovered ? activePalette.highlight : activePalette.button
223+
}
224+
opacity: computeButton.hovered ? 1 : root._opacity
225+
border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)
226+
border.width: 1
227+
radius: 3
228+
}
229+
onClicked: {
230+
switch (actionHeader.computeButtonState) {
231+
case NodeActions.ButtonState.STOPPABLE:
232+
root.stopComputeRequest(actionHeader.selectedNode)
233+
break
234+
case NodeActions.ButtonState.LAUNCHABLE:
235+
root.computeRequest(actionHeader.selectedNode)
236+
break
237+
}
238+
}
239+
}
240+
241+
// Clear node
242+
MaterialToolButton {
243+
id: deleteDataButton
244+
font.pointSize: 16
245+
text: MaterialIcons.delete_
246+
padding: 6
247+
ToolTip.text: "Delete data"
248+
ToolTip.visible: hovered
249+
ToolTip.delay: 1000
250+
visible: actionHeader.canRestartNode || actionHeader.computeButtonState == NodeActions.ButtonState.DELETABLE
251+
enabled: visible
252+
background: Rectangle {
253+
color: computeButton.hovered ? Colors.red : Qt.darker(Colors.red, 1.3)
254+
opacity: computeButton.hovered ? 1 : root._opacity
255+
border.color: computeButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)
256+
border.width: 1
257+
radius: 3
258+
}
259+
onClicked: {
260+
root.deleteDataRequest(actionHeader.selectedNode)
261+
}
262+
}
263+
264+
// Submit button
265+
MaterialToolButton {
266+
id: submitButton
267+
font.pointSize: 16
268+
text: MaterialIcons.rocket_launch
269+
padding: 6
270+
ToolTip.text: "Submit on Render Farm"
271+
ToolTip.visible: hovered
272+
ToolTip.delay: 1000
273+
visible: root.uigraph ? root.uigraph.canSubmit : false
274+
enabled: actionHeader.submitButtonState != NodeActions.ButtonState.DISABLED
275+
background: Rectangle {
276+
color: {
277+
if (!submitButton.enabled) {
278+
if (actionHeader.nodeSubmitted)
279+
return Qt.darker(Colors.statusColors["SUBMITTED"], 1.2)
280+
return activePalette.button
281+
}
282+
return submitButton.hovered ? activePalette.highlight : activePalette.button
283+
}
284+
opacity: submitButton.hovered ? 1 : root._opacity
285+
border.color: submitButton.hovered ? activePalette.highlight : Qt.darker(activePalette.window, 1.3)
286+
border.width: 1
287+
radius: 3
288+
}
289+
onClicked: {
290+
if (actionHeader.selectedNode) {
291+
root.submitRequest(actionHeader.selectedNode)
292+
}
293+
}
294+
}
295+
}
296+
}
297+
}

meshroom/ui/qml/Controls/qmldir

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ SelectionLine 1.0 SelectionLine.qml
2222
DelegateSelectionBox 1.0 DelegateSelectionBox.qml
2323
DelegateSelectionLine 1.0 DelegateSelectionLine.qml
2424
StatusBar 1.0 StatusBar.qml
25+
NodeActions 1.0 NodeActions.qml

meshroom/ui/qml/GraphEditor/GraphEditor.qml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,34 @@ Item {
10751075
}
10761076
}
10771077

1078+
NodeActions {
1079+
id: nodeActions
1080+
uigraph: root.uigraph
1081+
draggable: draggable
1082+
nodeRepeater: nodeRepeater
1083+
anchors.fill: parent
1084+
1085+
onComputeRequest: function(node) {
1086+
root.computeRequest([node])
1087+
}
1088+
1089+
onStopComputeRequest: function(node) {
1090+
if (node.canBeStopped()) {
1091+
uigraph.stopNodeComputation(node)
1092+
} else if (node.canBeCanceled()) {
1093+
uigraph.cancelNodeComputation(node)
1094+
}
1095+
}
1096+
1097+
onDeleteDataRequest: function(node) {
1098+
uigraph.clearSelectedNodesData();
1099+
}
1100+
1101+
onSubmitRequest: function(node) {
1102+
root.submitRequest([node])
1103+
}
1104+
}
1105+
10781106
MessageDialog {
10791107
id: errorDialog
10801108

0 commit comments

Comments
 (0)