Skip to content

Commit b39e664

Browse files
authored
Merge pull request #2892 from alicevision/dev/expression_text_field_update
[nodes] ExpressionTextField improvements
2 parents aabe589 + 5c650c9 commit b39e664

File tree

7 files changed

+129
-102
lines changed

7 files changed

+129
-102
lines changed

meshroom/core/evaluation.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env python
2+
3+
import ast, math
4+
5+
6+
class MathEvaluator:
7+
""" Evaluate math expressions
8+
9+
..code::py
10+
# Example usage
11+
mev = MathEvaluator()
12+
print(mev.evaluate("e-1+cos(2*pi)"))
13+
print(mev.evaluate("pow(2, 8)"))
14+
print(mev.evaluate("round(sin(pi), 3)"))
15+
"""
16+
17+
# Allowed math symbols
18+
allowed_symbols = {
19+
"e": math.e, "pi": math.pi,
20+
"cos": math.cos, "sin": math.sin, "tan": math.tan, "exp": math.exp,
21+
"pow": pow, "round": round, "abs": abs, "min": min, "max": max,
22+
"sqrt": math.sqrt, "log": math.log
23+
}
24+
25+
# Allowed AST node types
26+
allowed_nodes = (
27+
ast.Expression, ast.BinOp, ast.UnaryOp, ast.Call, ast.Name, ast.Load,
28+
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod, ast.FloorDiv,
29+
ast.USub, ast.UAdd, ast.BitXor, ast.BitOr, ast.BitAnd,
30+
ast.LShift, ast.RShift, ast.Invert,
31+
ast.Constant
32+
)
33+
34+
def _validate_ast(self, node):
35+
for child in ast.walk(node):
36+
if not isinstance(child, self.allowed_nodes):
37+
raise ValueError(f"Bad expression: {ast.dump(child)}")
38+
# Check that all variable/function names are whitelisted
39+
if isinstance(child, ast.Name):
40+
if child.id not in self.allowed_symbols:
41+
raise ValueError(f"Unknown symbol: {child.id}")
42+
43+
def evaluate(self, expr: str):
44+
if any(bad in expr for bad in ('\n', '#')):
45+
raise ValueError(f"Invalid expression: {expr}")
46+
try:
47+
node = ast.parse(expr.strip(), mode="eval")
48+
self._validate_ast(node)
49+
return eval(compile(node, "<expr>", "eval"), {"__builtins__": {}}, self.allowed_symbols)
50+
except Exception:
51+
raise ValueError(f"Invalid expression: {expr}")

meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,12 +622,18 @@ RowLayout {
622622
isInt: attribute.type === "FloatParam" ? false : true
623623

624624
onEditingFinished: {
625-
if (!hasExprError)
625+
if (!hasExprError) {
626626
setTextFieldAttribute(expressionTextField.evaluatedValue)
627+
// Restore binding
628+
expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); })
629+
}
627630
}
628631
onAccepted: {
629-
if (!hasExprError)
632+
if (!hasExprError) {
630633
setTextFieldAttribute(expressionTextField.evaluatedValue)
634+
// Restore binding
635+
expressionTextField.text = Qt.binding(function() { return String(expressionTextField.displayValue); })
636+
}
631637
// When the text is too long, display the left part
632638
// (with the most important values and cut the floating point details)
633639
ensureVisible(0)

meshroom/ui/qml/Utils/ExpressionTextField.qml

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ TextField {
66

77
// evaluated numeric value (NaN if invalid)
88
// It helps keeping the connection that text has so that we don't loose ability to undo/reset
9+
property bool textChanged: false
910
property real evaluatedValue: 0
1011

1112
property bool hasExprError: false
@@ -30,26 +31,16 @@ TextField {
3031
hasExprError = false
3132
}
3233

33-
function reset(_value) {
34-
clearError()
35-
evaluatedValue = _value
36-
if (isInt) {
37-
root.text = _value.toFixed(0)
38-
} else {
39-
root.text = _value.toFixed(decimals)
40-
}
41-
}
42-
4334
function getEvalExpression(_text) {
44-
try {
45-
var result = MathEvaluator.eval(_text)
35+
var [_res, _err] = _reconstruction.evaluateMathExpression(_text)
36+
if (_err == false) {
4637
if (isInt)
47-
result = Math.round(result)
38+
_res = Math.round(_res)
4839
else
49-
result = result.toFixed(decimals)
50-
return result
51-
} catch (err) {
52-
console.error("Error evaluating expression (", _text, "):", err)
40+
_res = _res.toFixed(decimals)
41+
return _res
42+
} else {
43+
console.error("Error: Expression", _text, "is invalid")
5344
return NaN
5445
}
5546
}
@@ -63,34 +54,61 @@ TextField {
6354
}
6455

6556
function updateExpression() {
57+
var previousEvaluatedValue = evaluatedValue
6658
var result = getEvalExpression(root.text)
6759
if (!isNaN(result)) {
6860
evaluatedValue = result
6961
clearError()
70-
return result
7162
} else {
72-
evaluatedValue = NaN
63+
evaluatedValue = previousEvaluatedValue
7364
raiseError()
74-
return NaN
7565
}
66+
textChanged = false
7667
}
7768

78-
// When user commits input, evaluate but do NOT overwrite text
69+
// onAccepted and onEditingFinished will break the bindings to text
70+
// so if used on fields that needs to be driven by sliders or other qml element,
71+
// the binding needs to be restored
72+
// No need to restore the binding if the expression has an error because we don't break it
73+
7974
onAccepted: {
80-
updateExpression()
75+
if (textChanged)
76+
{
77+
updateExpression()
78+
if (!hasExprError && !isNaN(evaluatedValue)) {
79+
// Commit the result value to the text field
80+
if (isInt)
81+
root.text = Number(evaluatedValue).toFixed(0)
82+
else
83+
root.text = Number(evaluatedValue).toFixed(decimals)
84+
}
85+
}
8186
}
8287

8388
onEditingFinished: {
84-
updateExpression()
89+
if (textChanged)
90+
{
91+
updateExpression()
92+
if (!hasExprError && !isNaN(evaluatedValue)) {
93+
if (isInt)
94+
root.text = Number(evaluatedValue).toFixed(0)
95+
else
96+
root.text = Number(evaluatedValue).toFixed(decimals)
97+
}
98+
}
8599
}
86100

87101
onTextChanged: {
88102
if (!activeFocus) {
89103
refreshStatus()
104+
} else {
105+
textChanged = true
90106
}
91107
}
92108

93-
Component.onCompleted: {
94-
refreshStatus()
109+
Component.onDestruction: {
110+
if (textChanged) {
111+
root.accepted()
112+
}
95113
}
96114
}

meshroom/ui/qml/Utils/mathEvaluator.js

Lines changed: 0 additions & 61 deletions
This file was deleted.

meshroom/ui/qml/Utils/qmldir

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,4 @@ singleton ExifOrientation 1.0 ExifOrientation.qml
1111
# singleton Filepath 1.0 Filepath.qml
1212
# singleton Scene3DHelper 1.0 Scene3DHelper.qml
1313
# singleton Transformations3DHelper 1.0 Transformations3DHelper.qml
14-
MathEvaluator 1.0 mathEvaluator.js
1514
ExpressionTextField 1.0 ExpressionTextField.qml

meshroom/ui/qml/Viewer/HdrImageToolbar.qml

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ FloatingPane {
112112
ToolTip.text: "Reset Gain"
113113

114114
onClicked: {
115-
gainCtrl.value = gainDefaultValue
116-
gainLabel.reset(gainValue)
115+
gainLabel.text = gainDefaultValue
116+
gainCtrl.value = gainLabel.text
117117
}
118118
}
119119
ExpressionTextField {
@@ -129,15 +129,12 @@ FloatingPane {
129129
selectByMouse: true
130130
onAccepted: {
131131
if (!gainLabel.hasExprError) {
132-
if (gainLabel.evaluatedValue <= 0) {
132+
if (gainLabel.text <= 0) {
133133
gainLabel.evaluatedValue = 0
134134
gainCtrl.value = gainLabel.evaluatedValue
135135
} else {
136136
gainCtrl.value = Math.pow(Number(gainLabel.evaluatedValue), 1.0 / slidersPowerValue)
137137
}
138-
} else {
139-
gainLabel.evaluatedValue = 0
140-
gainCtrl.value = gainLabel.evaluatedValue
141138
}
142139
}
143140
}
@@ -148,7 +145,9 @@ FloatingPane {
148145
to: 2
149146
value: gainDefaultValue
150147
stepSize: 0.01
151-
onMoved: gainLabel.reset(Math.pow(value, slidersPowerValue))
148+
onMoved: {
149+
gainLabel.text = Math.pow(value, slidersPowerValue).toFixed(2)
150+
}
152151
}
153152
}
154153

@@ -164,8 +163,8 @@ FloatingPane {
164163
ToolTip.text: "Reset Gamma"
165164

166165
onClicked: {
167-
gammaCtrl.value = gammaDefaultValue;
168-
gammaLabel.reset(gammaValue)
166+
gammaLabel.text = gammaDefaultValue
167+
gammaCtrl.value = gammaLabel.text;
169168
}
170169
}
171170
ExpressionTextField {
@@ -187,9 +186,6 @@ FloatingPane {
187186
} else {
188187
gammaCtrl.value = Math.pow(Number(gammaLabel.evaluatedValue), 1.0 / slidersPowerValue)
189188
}
190-
} else {
191-
gainLabel.evaluatedValue = 0
192-
gainCtrl.value = gainLabel.evaluatedValue
193189
}
194190
}
195191
}
@@ -200,7 +196,9 @@ FloatingPane {
200196
to: 2
201197
value: gammaDefaultValue
202198
stepSize: 0.01
203-
onMoved: gammaLabel.reset(Math.pow(value, slidersPowerValue))
199+
onMoved: {
200+
gammaLabel.text = Math.pow(value, slidersPowerValue).toFixed(2)
201+
}
204202
}
205203
}
206204

meshroom/ui/reconstruction.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from meshroom.core import Version
1919
from meshroom.core.node import Node, CompatibilityNode, Status, Position, CompatibilityIssue
2020
from meshroom.core.taskManager import TaskManager
21+
from meshroom.core.evaluation import MathEvaluator
2122

2223
from meshroom.ui import commands
2324
from meshroom.ui.graph import UIGraph
@@ -1166,6 +1167,21 @@ def setCurrentViewPath(self, path):
11661167
self._currentViewPath = path
11671168
self.currentViewPathChanged.emit()
11681169

1170+
@Slot(str, result="QVariantList")
1171+
def evaluateMathExpression(self, expr):
1172+
""" Evaluate a mathematical expression and return the result as a string
1173+
Returns a list of 2 values :
1174+
- the result value
1175+
- a boolean that indicate if an error occured
1176+
"""
1177+
mev = MathEvaluator()
1178+
try:
1179+
res = mev.evaluate(expr)
1180+
return [res, False]
1181+
except Exception as err:
1182+
self.parent().showMessage(f"Invalid field expression: {expr}", "error")
1183+
return [None, True]
1184+
11691185
selectedViewIdChanged = Signal()
11701186
selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged)
11711187
selectedViewpointChanged = Signal()

0 commit comments

Comments
 (0)