Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new attribute type: DynamicChoiceParam #2661

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions meshroom/core/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,15 +362,21 @@
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())
if withQuotes and v:
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())

Expand Down Expand Up @@ -529,6 +535,8 @@
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
Expand Down Expand Up @@ -797,3 +805,42 @@
# 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()

Check warning on line 823 in meshroom/core/attribute.py

View check run for this annotation

Codecov / codecov/patch

meshroom/core/attribute.py#L823

Added line #L823 was not covered by tests
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()

Check warning on line 834 in meshroom/core/attribute.py

View check run for this annotation

Codecov / codecov/patch

meshroom/core/attribute.py#L834

Added line #L834 was not covered by tests
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)
2 changes: 2 additions & 0 deletions meshroom/core/desc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
BoolParam,
ChoiceParam,
ColorParam,
DynamicChoiceParam,
File,
FloatParam,
GroupAttribute,
Expand Down Expand Up @@ -33,6 +34,7 @@
"BoolParam",
"ChoiceParam",
"ColorParam",
"DynamicChoiceParam",
"File",
"FloatParam",
"GroupAttribute",
Expand Down
100 changes: 99 additions & 1 deletion meshroom/core/desc/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
12 changes: 9 additions & 3 deletions meshroom/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
116 changes: 116 additions & 0 deletions tests/test_attributeChoiceParam.py
Original file line number Diff line number Diff line change
@@ -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