Skip to content

Commit 077135e

Browse files
authored
Merge pull request #2836 from alicevision/dev/enable_math_expressions_on_text_fields
[ui] Eval expression directly in specific TextFields
2 parents e704e5b + 5bc6fdb commit 077135e

File tree

6 files changed

+220
-27
lines changed

6 files changed

+220
-27
lines changed

meshroom/core/desc/attribute.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ def __init__(self, name, label, description, value, range=None, group="allParams
341341
self._valueType = int
342342

343343
def validateValue(self, value):
344-
if value is None:
344+
if value is None or isinstance(value, str):
345345
return value
346346
# Handle unsigned int values that are translated to int by shiboken and may overflow
347347
try:
@@ -371,7 +371,7 @@ def __init__(self, name, label, description, value, range=None, group="allParams
371371
self._valueType = float
372372

373373
def validateValue(self, value):
374-
if value is None:
374+
if value is None or isinstance(value, str):
375375
return value
376376
try:
377377
return float(value)

meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,9 @@ RowLayout {
259259
switch (attribute.type) {
260260
case "IntParam":
261261
case "FloatParam":
262-
_reconstruction.setAttribute(root.attribute, Number(value))
262+
// We don't set a number because we want to keep the invalid expression
263+
// _reconstruction.setAttribute(root.attribute, Number(value))
264+
_reconstruction.setAttribute(root.attribute, value)
263265
updateAttributeLabel()
264266
break
265267
case "File":
@@ -605,14 +607,8 @@ RowLayout {
605607
Component {
606608
id: sliderComponent
607609
RowLayout {
608-
TextField {
609-
IntValidator {
610-
id: intValidator
611-
}
612-
DoubleValidator {
613-
id: doubleValidator
614-
locale: 'C' // Use '.' decimal separator disregarding the system locale
615-
}
610+
ExpressionTextField {
611+
id: expressionTextField
616612
implicitWidth: 100
617613
Layout.fillWidth: !slider.active
618614
enabled: root.editable
@@ -624,18 +620,31 @@ RowLayout {
624620
// When the value change keep the text align to the left to be able to read the most important part
625621
// of the number. When we are editing (item is in focus), the content should follow the editing.
626622
autoScroll: activeFocus
627-
validator: attribute.type === "FloatParam" ? doubleValidator : intValidator
628-
onEditingFinished: setTextFieldAttribute(text)
623+
isInt: attribute.type === "FloatParam" ? false : true
624+
625+
onEditingFinished: {
626+
if (hasExprError)
627+
setTextFieldAttribute(expressionTextField.text) // On the undo stack we keep the expression
628+
else
629+
setTextFieldAttribute(expressionTextField.evaluatedValue)
630+
}
629631
onAccepted: {
630-
setTextFieldAttribute(text)
631-
632+
if (hasExprError)
633+
setTextFieldAttribute(expressionTextField.text) // On the undo stack we keep the expression
634+
else
635+
setTextFieldAttribute(expressionTextField.evaluatedValue)
632636
// When the text is too long, display the left part
633637
// (with the most important values and cut the floating point details)
634638
ensureVisible(0)
635639
}
640+
636641
Component.onDestruction: {
637-
if (activeFocus)
638-
setTextFieldAttribute(text)
642+
if (activeFocus) {
643+
if (hasExprError)
644+
setTextFieldAttribute(expressionTextField.text)
645+
else
646+
setTextFieldAttribute(expressionTextField.evaluatedValue)
647+
}
639648
}
640649
Component.onCompleted: {
641650
// When the text is too long, display the left part
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import QtQuick
2+
import QtQuick.Controls
3+
4+
TextField {
5+
id: root
6+
7+
// evaluated numeric value (NaN if invalid)
8+
// It helps keeping the connection that text has so that we don't loose ability to undo/reset
9+
property real evaluatedValue: 0
10+
11+
property bool hasExprError: false
12+
property bool isInt: false
13+
property int decimals: 2
14+
15+
// Overlay for error state (red border on top of default background)
16+
Rectangle {
17+
anchors.fill: parent
18+
radius: 4
19+
border.color: "red"
20+
color: "transparent"
21+
visible: root.hasExprError
22+
z: 1
23+
}
24+
25+
function raiseError() {
26+
hasExprError = true
27+
}
28+
29+
function clearError() {
30+
hasExprError = false
31+
}
32+
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+
43+
function getEvalExpression(_text) {
44+
try {
45+
var result = MathEvaluator.eval(_text)
46+
if (isInt)
47+
result = Math.round(result)
48+
else
49+
result = result.toFixed(decimals)
50+
return result
51+
} catch (err) {
52+
console.error("Error evaluating expression (", _text, "):", err)
53+
return NaN
54+
}
55+
}
56+
57+
function refreshStatus() {
58+
if (isNaN(getEvalExpression(root.text))) {
59+
raiseError()
60+
} else {
61+
clearError()
62+
}
63+
}
64+
65+
function updateExpression() {
66+
var result = getEvalExpression(root.text)
67+
if (!isNaN(result)) {
68+
evaluatedValue = result
69+
clearError()
70+
return result
71+
} else {
72+
evaluatedValue = NaN
73+
raiseError()
74+
return NaN
75+
}
76+
}
77+
78+
// When user commits input, evaluate but do NOT overwrite text
79+
onAccepted: {
80+
updateExpression()
81+
}
82+
83+
onEditingFinished: {
84+
updateExpression()
85+
}
86+
87+
onTextChanged: {
88+
if (!activeFocus) {
89+
refreshStatus()
90+
}
91+
}
92+
93+
Component.onCompleted: {
94+
refreshStatus()
95+
}
96+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.pragma library
2+
3+
4+
var symbols = {
5+
pi: Math.PI,
6+
e: Math.E,
7+
};
8+
9+
var functions = {
10+
abs: Math.abs,
11+
min: Math.min,
12+
max: Math.max,
13+
sin: Math.sin,
14+
cos: Math.cos,
15+
tan: Math.tan,
16+
pow: Math.pow,
17+
sqrt: Math.sqrt,
18+
exp: Math.exp,
19+
log: Math.log
20+
};
21+
22+
23+
function removeLeadingZeros(str) {
24+
return str.replace(/\b0*(\d+(?:\.\d*)?)/g, (match, number) => {
25+
// If the number starts with a decimal, add a leading zero
26+
if (number.startsWith('.')) {
27+
return '0' + number;
28+
}
29+
// Otherwise just return the number without leading zeros
30+
return number;
31+
});
32+
}
33+
34+
35+
/**
36+
* Evaluate an expression
37+
*
38+
* @param {*} expr Math expression
39+
* @returns float or int
40+
*/
41+
function eval(expr) {
42+
// Warning : commas are for separating function args and dot to indicate decimals
43+
44+
// Only allow numbers, operators, parentheses, and function names
45+
if (!/^[0-9+\-*/^()e.,\s]*$/.test(expr.replace(/\b[a-zA-Z]+\b/g, ""))) {
46+
throw "Invalid characters in expression";
47+
}
48+
49+
expr = removeLeadingZeros(expr);
50+
51+
// Replace symbols and functions
52+
for (var symbol in symbols) {
53+
expr = expr.replace(new RegExp("\\b" + symbol + "\\b", "g"), symbols[symbol]);
54+
}
55+
for (var func in functions) { // Warning : not really a map
56+
expr = expr.replace(new RegExp("\\b" + func + "\\b", "g"), "Math." + func);
57+
}
58+
59+
// Eval with function to avoid issues with undeclared variables
60+
return Function('"use strict"; return (' + expr + ')')();
61+
}

meshroom/ui/qml/Utils/qmldir

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ 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
15+
ExpressionTextField 1.0 ExpressionTextField.qml

meshroom/ui/qml/Viewer/HdrImageToolbar.qml

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import QtQuick.Controls
33
import QtQuick.Layouts
44

55
import Controls 1.0
6+
import Utils 1.0
67

78
FloatingPane {
89
id: root
@@ -16,8 +17,8 @@ FloatingPane {
1617

1718

1819
property real slidersPowerValue: 4.0
19-
property real gainValue: Math.pow(gainCtrl.value, slidersPowerValue)
20-
property real gammaValue: Math.pow(gammaCtrl.value, slidersPowerValue)
20+
property real gainValue: Math.pow(gainCtrl.value, slidersPowerValue).toFixed(2)
21+
property real gammaValue: Math.pow(gammaCtrl.value, slidersPowerValue).toFixed(2)
2122
property alias channelModeValue: channelsCtrl.value
2223
property variant colorRGBA: null
2324
property variant mousePosition: ({x:0, y:0})
@@ -112,21 +113,32 @@ FloatingPane {
112113

113114
onClicked: {
114115
gainCtrl.value = gainDefaultValue
116+
gainLabel.reset(gainValue)
115117
}
116118
}
117-
TextField {
119+
ExpressionTextField {
118120
id: gainLabel
119121

120122
ToolTip.visible: ToolTip.text && hovered
121123
ToolTip.delay: 100
122124
ToolTip.text: "Color Gain (in linear colorspace)"
123125

124-
text: gainValue.toFixed(2)
126+
text: gainValue
127+
decimals: 2
125128
Layout.preferredWidth: textMetrics_gainValue.width
126129
selectByMouse: true
127-
validator: doubleValidator
128130
onAccepted: {
129-
gainCtrl.value = Math.pow(Number(gainLabel.text), 1.0 / slidersPowerValue)
131+
if (!gainLabel.hasExprError) {
132+
if (gainLabel.evaluatedValue <= 0) {
133+
gainLabel.evaluatedValue = 0
134+
gainCtrl.value = gainLabel.evaluatedValue
135+
} else {
136+
gainCtrl.value = Math.pow(Number(gainLabel.evaluatedValue), 1.0 / slidersPowerValue)
137+
}
138+
} else {
139+
gainLabel.evaluatedValue = 0
140+
gainCtrl.value = gainLabel.evaluatedValue
141+
}
130142
}
131143
}
132144
Slider {
@@ -136,6 +148,7 @@ FloatingPane {
136148
to: 2
137149
value: gainDefaultValue
138150
stepSize: 0.01
151+
onMoved: gainLabel.reset(Math.pow(value, slidersPowerValue))
139152
}
140153
}
141154

@@ -152,21 +165,32 @@ FloatingPane {
152165

153166
onClicked: {
154167
gammaCtrl.value = gammaDefaultValue;
168+
gammaLabel.reset(gammaValue)
155169
}
156170
}
157-
TextField {
171+
ExpressionTextField {
158172
id: gammaLabel
159173

160174
ToolTip.visible: ToolTip.text && hovered
161175
ToolTip.delay: 100
162176
ToolTip.text: "Apply Gamma (after Gain and in linear colorspace)"
163177

164-
text: gammaValue.toFixed(2)
178+
text: gammaValue
179+
decimals: 2
165180
Layout.preferredWidth: textMetrics_gainValue.width
166181
selectByMouse: true
167-
validator: doubleValidator
168182
onAccepted: {
169-
gammaCtrl.value = Math.pow(Number(gammaLabel.text), 1.0 / slidersPowerValue)
183+
if (!gammaLabel.hasExprError) {
184+
if (gammaLabel.evaluatedValue <= 0) {
185+
gammaLabel.evaluatedValue = 0
186+
gammaCtrl.value = gammaLabel.evaluatedValue
187+
} else {
188+
gammaCtrl.value = Math.pow(Number(gammaLabel.evaluatedValue), 1.0 / slidersPowerValue)
189+
}
190+
} else {
191+
gainLabel.evaluatedValue = 0
192+
gainCtrl.value = gainLabel.evaluatedValue
193+
}
170194
}
171195
}
172196
Slider {
@@ -176,6 +200,7 @@ FloatingPane {
176200
to: 2
177201
value: gammaDefaultValue
178202
stepSize: 0.01
203+
onMoved: gammaLabel.reset(Math.pow(value, slidersPowerValue))
179204
}
180205
}
181206

0 commit comments

Comments
 (0)