diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index dd69db1251..fffd4b511a 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -362,7 +362,10 @@ def getValueStr(self, withQuotes=True): If it is a list with one empty string element, it will returns 2 quotes. ''' # ChoiceParam with multiple values should be combined - if isinstance(self.attributeDesc, desc.ChoiceParam) and not self.attributeDesc.exclusive: + if ( + isinstance(self.attributeDesc, (desc.ChoiceParam, desc.DynamicChoiceParam)) + and not self.attributeDesc.exclusive + ): # Ensure value is a list as expected assert (isinstance(self.value, Sequence) and not isinstance(self.value, str)) v = self.attributeDesc.joinChar.join(self.getEvalValue()) @@ -370,7 +373,10 @@ def getValueStr(self, withQuotes=True): return '"{}"'.format(v) return v # String, File, single value Choice are based on strings and should includes quotes to deal with spaces - if withQuotes and isinstance(self.attributeDesc, (desc.StringParam, desc.File, desc.ChoiceParam)): + if withQuotes and isinstance( + self.attributeDesc, + (desc.StringParam, desc.File, desc.ChoiceParam, desc.DynamicChoiceParam), + ): return '"{}"'.format(self.getEvalValue()) return str(self.getEvalValue()) @@ -529,6 +535,8 @@ def resetToDefaultValue(self): self.valueChanged.emit() def _set_value(self, value): + if isinstance(value, list) and value == self.getExportValue(): + return if self.node.graph: self.remove(0, len(self)) # Link to another attribute @@ -797,3 +805,42 @@ def matchText(self, text): # Override value property value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged) + + +class DynamicChoiceParam(GroupAttribute): + def __init__(self, node, attributeDesc, isOutput, root=None, parent=None): + super().__init__(node, attributeDesc, isOutput, root, parent) + # Granularity (and performance) could be improved by using the 'valueChanged' signals of sub-attributes. + # But as there are situations where: + # * the whole GroupAttribute is 'changed' (eg: connection/disconnection) + # * the sub-attributes are re-created (eg: resetToDefaultValue) + # it is simpler to use the GroupAttribute's 'valueChanged' signal as the main trigger for updates. + self.valueChanged.connect(self.choiceValueChanged) + self.valueChanged.connect(self.choiceValuesChanged) + + def _get_value(self): + if self.isLink: + return super()._get_value() + return self.choiceValue.value + + def _set_value(self, value): + if isinstance(value, dict) or Attribute.isLinkExpression(value): + super()._set_value(value) + else: + self.choiceValue.value = value + + def getValues(self): + if self.isLink: + return self.linkParam.getValues() + return self.choiceValues.getExportValue() or self.desc.values + + def setValues(self, values): + self.choiceValues.value = values + + def getValueStr(self, withQuotes=True): + return Attribute.getValueStr(self, withQuotes) + + choiceValueChanged = Signal() + value = Property(Variant, _get_value, _set_value, notify=choiceValueChanged) + choiceValuesChanged = Signal() + values = Property(Variant, getValues, setValues, notify=choiceValuesChanged) diff --git a/meshroom/core/desc/__init__.py b/meshroom/core/desc/__init__.py index b60a9d5164..05cb976ee9 100644 --- a/meshroom/core/desc/__init__.py +++ b/meshroom/core/desc/__init__.py @@ -3,6 +3,7 @@ BoolParam, ChoiceParam, ColorParam, + DynamicChoiceParam, File, FloatParam, GroupAttribute, @@ -33,6 +34,7 @@ "BoolParam", "ChoiceParam", "ColorParam", + "DynamicChoiceParam", "File", "FloatParam", "GroupAttribute", diff --git a/meshroom/core/desc/attribute.py b/meshroom/core/desc/attribute.py index e1f6c4f339..b8c17545d7 100644 --- a/meshroom/core/desc/attribute.py +++ b/meshroom/core/desc/attribute.py @@ -2,7 +2,8 @@ import distutils.util import os import types -from collections.abc import Iterable +from collections.abc import Iterable, Sequence +from typing import Union from meshroom.common import BaseObject, JSValue, Property, Variant, VariantList @@ -526,3 +527,100 @@ def validateValue(self, value): 'color code (param: {}, value: {}, type: {})'.format(self.name, value, type(value))) return value + +class DynamicChoiceParam(GroupAttribute): + """ + Attribute supporting a single or multiple values, providing a list of predefined options that can be + modified at runtime and serialized. + """ + + _PYTHON_BUILTIN_TO_PARAM_TYPE = { + str: StringParam, + int: IntParam, + } + + def __init__( + self, + name: str, + label: str, + description: str, + value: Union[str, int, Sequence[Union[str, int]]], + values: Union[Sequence[str], Sequence[int]], + exclusive: bool=True, + group: str="allParams", + joinChar: str=" ", + advanced: bool=False, + enabled: bool=True, + invalidate: bool=True, + semantic: str="", + validValue: bool=True, + errorMessage: str="", + visible: bool=True, + exposed: bool=False, + ): + # DynamicChoiceParam is a composed of: + # - a child ChoiceParam to hold the attribute value and as a backend to expose a ChoiceParam-compliant API + # - a child ListAttribute to hold the list of possible values + + self._valueParam = ChoiceParam( + name="choiceValue", + label="Value", + description="", + value=value, + # Initialize the list of possible values to pass description validation. + values=values, + exclusive=exclusive, + group="", + joinChar=joinChar, + advanced=advanced, + enabled=enabled, + invalidate=invalidate, + semantic=semantic, + validValue=validValue, + errorMessage=errorMessage, + visible=visible, + exposed=exposed, + ) + + valueType: type = self._valueParam._valueType + paramType = DynamicChoiceParam._PYTHON_BUILTIN_TO_PARAM_TYPE[valueType] + + self._valuesParam = ListAttribute( + name="choiceValues", + label="Values", + elementDesc=paramType( + name="choiceEntry", + label="Choice entry", + description="A possible choice value", + invalidate=False, + value=valueType(), + ), + description="List of possible choice values", + group="", + advanced=True, + visible=False, + exposed=False, + ) + self._valuesParam._value = values + + super().__init__( + name=name, + label=label, + description=description, + group=group, + groupDesc=[self._valueParam, self._valuesParam], + advanced=advanced, + semantic=semantic, + enabled=enabled, + visible=visible, + exposed=exposed, + ) + + def getInstanceType(self): + from meshroom.core.attribute import DynamicChoiceParam + + return DynamicChoiceParam + + values = Property(VariantList, lambda self: self._valuesParam._value, constant=True) + exclusive = Property(bool, lambda self: self._valueParam.exclusive, constant=True) + joinChar = Property(str, lambda self: self._valueParam.joinChar, constant=True) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 7808f1e235..078a4cb555 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -599,13 +599,19 @@ def copyNode(self, srcNode, withEdges=False): # edges are declared in input with an expression linking # to another param (which could be an output) continue + value = attr.value + if isinstance(attr, GroupAttribute): + # GroupAttribute subclasses can override their `value` property to return the value + # of a child attribute. Here, we need to evaluate the group's value, hence + # the use of GroupAttribute's `value` getter. + value = GroupAttribute.value.fget(attr) # find top-level links - if Attribute.isLinkExpression(attr.value): - skippedEdges[attr] = attr.value + if Attribute.isLinkExpression(value): + skippedEdges[attr] = value attr.resetToDefaultValue() # find links in ListAttribute children elif isinstance(attr, (ListAttribute, GroupAttribute)): - for child in attr.value: + for child in value: if Attribute.isLinkExpression(child.value): skippedEdges[child] = child.value child.resetToDefaultValue() diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 7914dd86c5..0c689cfcb1 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -208,6 +208,7 @@ RowLayout { case "PushButtonParam": return pushButtonComponent case "ChoiceParam": + case "DynamicChoiceParam": return attribute.desc.exclusive ? comboBoxComponent : multiChoiceComponent case "IntParam": return sliderComponent case "FloatParam": diff --git a/tests/test_attributeChoiceParam.py b/tests/test_attributeChoiceParam.py new file mode 100644 index 0000000000..66c68cf308 --- /dev/null +++ b/tests/test_attributeChoiceParam.py @@ -0,0 +1,116 @@ +from meshroom.core import desc, registerNodeType, unregisterNodeType +from meshroom.core.graph import Graph, loadGraph + + +class NodeWithStaticChoiceParam(desc.Node): + inputs = [ + desc.ChoiceParam( + name="choice", + label="Choice", + description="A static choice parameter", + value="A", + values=["A", "B", "C"], + exclusive=True, + exposed=True, + ), + ] + +class NodeWithDynamicChoiceParam(desc.Node): + inputs = [ + desc.DynamicChoiceParam( + name="dynChoice", + label="Dynamic Choice", + description="A dynamic choice parameter", + value="A", + values=["A", "B", "C"], + exclusive=True, + exposed=True, + ), + ] + + +class TestStaticChoiceParam: + @classmethod + def setup_class(cls): + registerNodeType(NodeWithStaticChoiceParam) + + @classmethod + def teardown_class(cls): + unregisterNodeType(NodeWithStaticChoiceParam) + + def test_customValuesAreNotSerialized(self, graphSavedOnDisk): + graph: Graph = graphSavedOnDisk + node = graph.addNewNode(NodeWithStaticChoiceParam.__name__) + node.choice.values = ["D", "E", "F"] + + graph.save() + loadedGraph = loadGraph(graph.filepath) + loadedNode = loadedGraph.node(node.name) + + assert loadedNode.choice.values == ["A", "B", "C"] + + def test_customValueIsSerialized(self, graphSavedOnDisk): + graph: Graph = graphSavedOnDisk + + node = graph.addNewNode(NodeWithStaticChoiceParam.__name__) + node.choice.value = "CustomValue" + graph.save() + + loadedGraph = loadGraph(graph.filepath) + loadedNode = loadedGraph.node(node.name) + + assert loadedNode.choice.value == "CustomValue" + + +class TestDynamicChoiceParam: + @classmethod + def setup_class(cls): + registerNodeType(NodeWithDynamicChoiceParam) + + @classmethod + def teardown_class(cls): + unregisterNodeType(NodeWithDynamicChoiceParam) + + def test_resetDefaultValues(self, graphSavedOnDisk): + graph: Graph = graphSavedOnDisk + + node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__) + node.dynChoice.values = ["D", "E", "F"] + node.dynChoice.value = "D" + node.dynChoice.resetToDefaultValue() + assert node.dynChoice.values == ["A", "B", "C"] + assert node.dynChoice.value == "A" + + def test_customValueIsSerialized(self, graphSavedOnDisk): + graph: Graph = graphSavedOnDisk + + node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__) + node.dynChoice.value = "CustomValue" + graph.save() + + loadedGraph = loadGraph(graph.filepath) + loadedNode = loadedGraph.node(node.name) + + assert loadedNode.dynChoice.value == "CustomValue" + + def test_customValuesAreSerialized(self, graphSavedOnDisk): + graph: Graph = graphSavedOnDisk + + node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__) + node.dynChoice.values = ["D", "E", "F"] + + graph.save() + loadedGraph = loadGraph(graph.filepath) + loadedNode = loadedGraph.node(node.name) + + assert loadedNode.dynChoice.values == ["D", "E", "F"] + + def test_duplicateNodeWithGroupAttributeDerivedAttribute(self): + graph = Graph("") + node = graph.addNewNode(NodeWithDynamicChoiceParam.__name__) + node.dynChoice.values = ["D", "E", "F"] + node.dynChoice.value = "G" + duplicates = graph.duplicateNodes([node]) + duplicate = duplicates[node][0] + assert duplicate.dynChoice.value == node.dynChoice.value + assert duplicate.dynChoice.values == node.dynChoice.values