diff --git a/bin/meshroom_info b/bin/meshroom_info index df189fe13c..d6e094ff3a 100755 --- a/bin/meshroom_info +++ b/bin/meshroom_info @@ -29,6 +29,10 @@ version_mode_parser = subparsers.add_parser( "version", help="Display Meshroom version.") version_mode_parser.add_argument("-p", "--path", action="store_true") +# Attribute Converter info subparser +converters_mode_parser = subparsers.add_parser( + "attrconvert", help="Display AttributeConverter nodes info.") + # Node info subparser nodes_mode_parser = subparsers.add_parser( "nodeinfo", help="Display nodes info.") @@ -48,6 +52,18 @@ def get_version(args): print(f"Meshroom is located at {meshroom._MESHROOM_ROOT}") +# ===== ATTRIBUTE CONVERTERS ===== +def get_attributeconverter_info(args): + import meshroom.core + meshroom.core.initNodes() + converterNodes = meshroom.core.AttributeConverterRegistry.getAllConverters() + print(f"Available Attribute Converters ({len(converterNodes)}):") + for (srcType, dstType), converters in meshroom.core.AttributeConverterRegistry._converters.items(): + print(f"\n Convert from {srcType.__name__} to {dstType.__name__}:") + for converter in converters: + print(f" - {converter.__name__}") + + # ===== NODES ===== def get_nodes_info(args): import meshroom.core @@ -161,6 +177,8 @@ if __name__ == "__main__": if args.command == "version": get_version(args) + elif args.command == "attrconvert": + get_attributeconverter_info(args) elif args.command == "nodeinfo": get_nodes_info(args) elif args.command == "pipelines": diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index ae9d23f76d..7f7805762a 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -18,8 +18,15 @@ except Exception: pass -from meshroom.core.plugins import NodePlugin, NodePluginManager, Plugin, processEnvFactory, formatNodeDescriptionErrorMessage +from meshroom.core.plugins import ( + NodePlugin, + NodePluginManager, + Plugin, + processEnvFactory, + formatNodeDescriptionErrorMessage +) from meshroom.core.submitter import BaseSubmitter +from meshroom.core.attributeConverter import AttributeConverter, AttributeConverterRegistry from meshroom.env import EnvVar, meshroomFolder from . import desc from .desc import MrNodeType @@ -197,6 +204,23 @@ def loadClassesSubmitters(folder: str, packageName: str) -> list[BaseSubmitter]: return loadClasses(folder, packageName, BaseSubmitter) +def loadAttributeConverterClasses(folder: str, packageName: str) -> list[AttributeConverter]: + """ + Return the list of all the AttributeConverter nodes that were found during + the search of the Python module named "packageName" that located in the folder + "folder". An AttributeConverter node is found if a file within "packageName" + contains a class inheriting from `AttributeConverter`. + + Args: + folder: the folder to load the module from. + packageName: the name of the module to look for nodes in. + + Returns: + list[AttributeConverter]: a list of all the atribute converters that were found in the module. + """ + return loadClasses(folder, packageName, AttributeConverter) + + class Version: """ Version provides convenient properties and methods to manipulate and compare versions. @@ -411,6 +435,28 @@ def loadAllSubmitters(folder) -> list[BaseSubmitter]: return submitters +def registerAttributeConverter(converter: AttributeConverter): + AttributeConverterRegistry.add(converter()) + + +def loadAttributeConverter(folder, packageName) -> list[AttributeConverter]: + if not os.path.isdir(folder): + logging.error(f"AttributeConverter folder '{folder}' does not exist.") + return [] + + return loadAttributeConverterClasses(folder, packageName) + + +def loadAllAttributeConverters(folder) -> list[AttributeConverter]: + attributeConverters = [] + for _, package, ispkg in pkgutil.iter_modules([folder]): + if ispkg: + converters = loadAttributeConverter(folder, package) + if converters: + attributeConverters.extend(converters) + return attributeConverters + + def loadPipelineTemplates(folder: str): if not os.path.isdir(folder): logging.error(f"Pipeline templates folder '{folder}' does not exist.") @@ -424,6 +470,11 @@ def initNodes(): additionalNodesPath = EnvVar.getList(EnvVar.MESHROOM_NODES_PATH) nodesFolders = [os.path.join(meshroomFolder, "nodes")] + additionalNodesPath for f in nodesFolders: + # Load converter nodes + converterNodes = loadAllAttributeConverters(folder=f) + for cn in converterNodes: + registerAttributeConverter(cn) + # Load nodes plugins = loadAllNodes(folder=f) if plugins: for plugin in plugins: diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index 2836554749..c974b45451 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -14,7 +14,7 @@ from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot from meshroom.core.desc.validators import NotEmptyValidator from meshroom.core import desc, hashValue - +from meshroom.core.attributeConverter import AttributeConverterRegistry from meshroom.core.desc import Attribute as AttributeDescription from meshroom.core.keyValues import KeyValues @@ -239,7 +239,8 @@ def _getValue(self): if self.keyable: raise RuntimeError(f"Cannot get value of {self._getFullName()}, the attribute is keyable.") if self.isLink: - return self._getInputLink().value + edge = self.node.graph.edge(self) + return edge.resolvedValue() self._resolveValue() return self._value @@ -323,7 +324,7 @@ def _handleLinkValue(self, value) -> bool: self._linkExpression = value return True - def _applyExpr(self): + def _applyExpr(self, converterMap: dict = None): """ For string parameters with an expression (when loaded from file), this function convert the expression into a real edge in the graph @@ -352,7 +353,13 @@ def _applyExpr(self): attr = node.attribute(linkAttrName) if node.hasAttribute(linkAttrName) else node.internalAttribute(linkAttrName) if attr is None: raise InvalidEdgeError(self.fullName, link, "Source attribute does not exist.") - attr.connectTo(self) + connectedEdge, _ = attr.connectTo(self) + if connectedEdge: + src, dst = connectedEdge[0] + if converterMap and (converterName:=converterMap.get((src.fullName, dst.fullName))): + logging.info(f"Edge {src.fullName}->{dst.fullName}: set converter to {converterName}") + edge = self.node.graph.edge(dst) + edge.setConverter(converterName) except InvalidEdgeError as err: logging.warning(err) except Exception as err: @@ -667,7 +674,9 @@ def _validateIncomingConnection(self, connectingAttribute: Attribute) -> bool: Returns: True if the connection is valid, False otherwise. """ - return self.baseType == connectingAttribute.baseType + if self.baseType == connectingAttribute.baseType: + return True + return AttributeConverterRegistry.hasConverter(connectingAttribute.baseType, self.baseType) def connectTo(self, dstAttribute: Attribute) -> tuple[list[list[Attribute]], list[list[Attribute]]]: """ @@ -870,7 +879,9 @@ def __len__(self): def getValues(self): if (linkParam := self._getInputLink()) is not None: - return linkParam.getValues() + edges = [e for e in self.node.graph.edges.values() if e.dst == self] + if edges: + return edges[0].resolvedValues() return self._values if self._values is not None else self._desc._values def setValues(self, values): @@ -1035,9 +1046,9 @@ def _setValue(self, value): self.requestGraphUpdate() # Override - def _applyExpr(self): + def _applyExpr(self, converterMap: dict = None): if self._linkExpression: - super()._applyExpr() + super()._applyExpr(converterMap) else: for value in self._value: value._applyExpr() @@ -1284,12 +1295,12 @@ def _setValue(self, exportedValue): raise AttributeError(f"Failed to set on GroupAttribute: {str(value)}") # Override - def _applyExpr(self): + def _applyExpr(self, converterMap: dict = None): if self._linkExpression: - super()._applyExpr() + super()._applyExpr(converterMap) else: for value in self._value: - value._applyExpr() + value._applyExpr(converterMap) # Override def resetToDefaultValue(self): diff --git a/meshroom/core/attributeConverter.py b/meshroom/core/attributeConverter.py new file mode 100644 index 0000000000..2c6355ff1c --- /dev/null +++ b/meshroom/core/attributeConverter.py @@ -0,0 +1,102 @@ +""" +attributeConverter: base descriptors class for AttributeConverter nodes +""" + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, ClassVar +from collections import defaultdict +from itertools import chain + +if TYPE_CHECKING: + from meshroom.core.desc.attribute import Attribute + + +class AttributeConverter(ABC): + """ + Base class for converting the value of a source Attribute + into a value for a destination Attribute of a different type, + so a connection can be made between them. + """ + + name: ClassVar[str] = "" + description: ClassVar[str] = "" + priority: ClassVar[int] = 10 # Put a higher number to prioritize specific converters + + # Input / Output classes + srcType: ClassVar["Attribute"] = None + dstType: ClassVar["Attribute"] = None + + def __init__(self): + if not all ((self.srcType, self.dstType)): + raise TypeError( + f"Class '{self.__class__.__name__}' must define srcType and dstType." + ) + + @classmethod + def getName(cls): + return cls.name or cls.__name__ + + def canConvert(self, srcType, dstType): + """ Check if this converter corresponds to a source/destination attribute pair. + """ + return isinstance(srcType, self.srcType) and isinstance(dstType, self.dstType) + + @abstractmethod + def convert(self, value): + """ Convert a value from the source attribute's type to a value for + the destination attribute's type. + """ + return value + + def __repr__(self): + return f" {self.dstType.__name__})>" + + +class AttributeConverterRegistry: + """ + Registry of available converters + """ + + # { (srcType, dstType): [converters] } + _converters: dict[tuple["Attribute", "Attribute"], list[AttributeConverter]] = defaultdict(list) + + @classmethod + def add(cls, converter: AttributeConverter): + if not issubclass(converter.__class__, AttributeConverter): + raise TypeError(f"{converter} parent class must subclass AttributeConverter") + logging.info( + f"Add converter class: {converter.getName()} " + f"({converter.srcType.__name__} -> {converter.dstType.__name__})" + ) + cls._converters[(converter.srcType.__name__, converter.dstType.__name__)].append(converter) + + @classmethod + def getAllConverters(cls) -> list[AttributeConverter]: + return list(chain.from_iterable(cls._converters.values())) + + @classmethod + def getConverterByName(cls, name): + for c in cls.getAllConverters(): + if c.getName() == name: + return c + return None + + @classmethod + def hasConverter(cls, srcType: "Attribute", dstType: "Attribute") -> list[AttributeConverter]: + return ((srcType, dstType)) in cls._converters + + @classmethod + def getConverters(cls, srcType: "Attribute", dstType: "Attribute") -> list[AttributeConverter]: + """ Get priority-ordered converters. + """ + converters = cls._converters.get((srcType, dstType), []) + return sorted(converters, key=lambda c: -c.priority) + + @classmethod + def getConverter(cls, srcType: "Attribute", dstType: "Attribute") -> AttributeConverter: + """ Get highest priority converter. """ + converters = cls.getConverters(srcType, dstType) + if not converters: + return None + return converters[0] diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 6f63dc9a80..db6c8e42ab 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -23,6 +23,8 @@ from meshroom.core.nodeFactory import nodeFactory, getNodeConstructor from meshroom.core.mtyping import PathLike from meshroom.core.submitter import BaseSubmittedJob, jobManager +from meshroom.core.attributeConverter import AttributeConverter, AttributeConverterRegistry + # Replace default encoder to support Enums @@ -64,11 +66,19 @@ def GraphModification(graph): class Edge(BaseObject): - def __init__(self, src, dst, parent=None): + def __init__(self, src, dst, converter=None, parent=None): super().__init__(parent) self._src = weakref.ref(src) self._dst = weakref.ref(dst) - self._repr = f" {self._src()} -> {self._dst()}" + self._availableConverters = self.getAvailableConverters() + self._converter: "AttributeConverter" = converter + self._resolveConverter() + if self._converter: + self.converterChanged.emit() + + def __repr__(self): + converter = f">-({self._converter.getName()})->" if self._converter else "->" + return f"" @property def src(self): @@ -78,8 +88,86 @@ def src(self): def dst(self): return self._dst() + def getAvailableConverters(self) -> list[dict]: + converters = AttributeConverterRegistry.getConverters(self.src.desc.type, self.dst.desc.type) + return [ + { + "name": c.getName(), + "description": c.description + } + for c in converters + ] + + def isConverted(self): + return self._converter is not None + + def getConverterDescription(self) -> str: + if not self._converter: + return "" + desc = f"{self._converter.getName()}" + if self._converter.description: + desc += f":
{self._converter.description}" + return desc + + @Slot(str) + def setConverter(self, converterName: str): + """ Change the converter used on this edge. """ + converter = AttributeConverterRegistry.getConverterByName(converterName) + if not converter: + raise KeyError(f"No converter named {converterName}.") + oldConverter = self._converter + self._converter = converter + try: + self._resolveConverter() + self.converterChanged.emit() + except GraphCompatibilityError as e: + self._converter = oldConverter + self._resolveConverter() + raise e + + def _resolveConverter(self): + srcDesc, dstDesc = self.src.desc, self.dst.desc + if self._converter: + if not self._converter.canConvert(srcDesc, dstDesc): + raise InvalidEdgeError( + srcDesc.name, dstDesc.name, + f"Converter '{self._converter}' cannot convert " + f"{srcDesc.__class__.__name__} -> {dstDesc.__class__.__name__}" + ) + return + if self.src.baseType == self.dst.baseType: + self._converter = None + return + # Find a default converter + conv = AttributeConverterRegistry.getConverter(srcDesc.type, dstDesc.type) + if conv is None: + raise InvalidEdgeError( + srcDesc.name, dstDesc.name, + f"No AttributeConverter available for edge between attribute types " + f"{srcDesc.__class__.__name__} -> {dstDesc.__class__.__name__}" + ) + self._converter = conv + + def resolvedValue(self): + if self.isConverted(): + return self._converter.convert(self.src.value) + return self.src.value + + def resolvedValues(self): + if self.isConverted(): + srcAttr = self.src + if hasattr(srcAttr, "values"): + return srcAttr.values + return [srcAttr.value] + return self.src.values + src = Property(Attribute, src.fget, constant=True) dst = Property(Attribute, dst.fget, constant=True) + converterChanged = Signal() + hasConverter = Property(bool, isConverted, notify=converterChanged) + converterName = Property(str, lambda self: self._converter.getName() if self._converter else "", notify=converterChanged) + converterDescription = Property(str, getConverterDescription, notify=converterChanged) + availableConverters = Property("QVariantList", lambda self: self._availableConverters, constant=True) WHITE = 0 @@ -313,6 +401,8 @@ def _deserialize(self, graphData: dict): self.header = graphData.get(GraphIO.Keys.Header, {}) fileVersion = Version(self.header.get(GraphIO.Keys.FileVersion, "0.0")) graphContent = self._normalizeGraphContent(graphData, fileVersion) + graphConverters = graphData.get(GraphIO.Keys.Converters, {}) + converterMap = {tuple(v): k for k, v in graphConverters.items()} isTemplate = self.header.get(GraphIO.Keys.Template, False) explicitCachePaths = self.header.get(GraphIO.Keys.CacheDir) if explicitCachePaths: @@ -326,7 +416,7 @@ def _deserialize(self, graphData: dict): self._deserializeNode(nodeData, nodeName, self) # Create graph edges by resolving attributes expressions - self._applyExpr() + self._applyExpr(converterMap) # Templates are specific: they contain only the minimal amount of # serialized data to describe the graph structure. @@ -483,15 +573,30 @@ def importGraphContent(self, graph: "Graph") -> list[Node]: Returns: The list of newly created Nodes. """ + + edgesWithConverters = [e for e in graph.edges if e._converter] + converterMap = { + (e.src.fullName, e.dst.fullName): e._converter.getName() + for e in edgesWithConverters + } + + def replaceKey(src, dst, oldName, newName): + return (src.replace(oldName, newName, 1), dst.replace(oldName, newName, 1)) - def _renameClashingNodes(): + def _renameClashingNodes(converterMap): if not self.nodes: - return + return converterMap unavailableNames = set(self.nodes.keys()) for node in graph.nodes: - if node._name in unavailableNames: + oldName = node._name + if oldName in unavailableNames: node._name = self._createUniqueNodeName(node.nodeType, unavailableNames) + converterMap = { + replaceKey(src, dst, oldName, node._name): value + for (src, dst), value in converterMap.items() + } unavailableNames.add(node._name) + return converterMap def _importNodesAndEdges() -> list[Node]: importedNodes = [] @@ -502,10 +607,10 @@ def _importNodesAndEdges() -> list[Node]: for srcNode in nodes: node = self._deserializeNode(srcNode.toDict(), srcNode.name, graph) importedNodes.append(node) - self._applyExpr() + self._applyExpr(converterMap) return importedNodes - _renameClashingNodes() + converterMap = _renameClashingNodes(converterMap) importedNodes = _importNodesAndEdges() return importedNodes @@ -1401,10 +1506,10 @@ def discoverVertex(self, vertex, graph): self.dfs(visitor=visitor, startNodes=[startNode]) return visitor.canCompute + (2 * visitor.canSubmit) - def _applyExpr(self): + def _applyExpr(self, converterMap: dict = None): with GraphModification(self): for node in self._nodes: - node._applyExpr() + node._applyExpr(converterMap) def toDict(self): nodes = {k: node.toDict() for k, node in self._nodes.objects.items()} diff --git a/meshroom/core/graphIO.py b/meshroom/core/graphIO.py index 885b0d1ddc..278061dfc2 100644 --- a/meshroom/core/graphIO.py +++ b/meshroom/core/graphIO.py @@ -25,6 +25,7 @@ class Keys: FileVersion = "fileVersion" CacheDir = "cacheDir" Graph = "graph" + Converters = "converters" Template = "template" class Features(Enum): @@ -76,6 +77,7 @@ def serialize(self) -> dict: return { GraphIO.Keys.Header: self.serializeHeader(), GraphIO.Keys.Graph: self.serializeContent(), + GraphIO.Keys.Converters: self.serializeConverters(), } @property @@ -119,6 +121,13 @@ def serializeContent(self) -> dict: """Graph content serialization logic.""" return {node.name: self.serializeNode(node) for node in sorted(self.nodes, key=lambda n: n.name)} + def serializeConverters(self) -> dict: + """Graph converters serialization logic.""" + edgesWithConverters = [e for e in self._graph.edges if e._converter] + return { + e._converter.getName(): (e.src.fullName, e.dst.fullName) for e in edgesWithConverters + } + def serializeNode(self, node: Node) -> dict: """Node serialization logic.""" return node.toDict() diff --git a/meshroom/core/node.py b/meshroom/core/node.py index d3c99c1185..93d8f98c73 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1106,9 +1106,9 @@ def hasInternalAttribute(self, name): return p[0][0] in self._internalAttributes.keys() or p[0][1] in self._internalAttributes.keys() return name in self._internalAttributes.keys() - def _applyExpr(self): + def _applyExpr(self, converterMap: dict = None): for attr in self._attributes: - attr._applyExpr() + attr._applyExpr(converterMap) for attr in self._internalAttributes: attr._applyExpr() diff --git a/meshroom/core/plugins.py b/meshroom/core/plugins.py index 907d28c991..9e14e7dee6 100644 --- a/meshroom/core/plugins.py +++ b/meshroom/core/plugins.py @@ -7,7 +7,6 @@ import os import re import sys - from enum import Enum from inspect import getfile from pathlib import Path diff --git a/meshroom/nodes/converters/BoolToInt.py b/meshroom/nodes/converters/BoolToInt.py new file mode 100644 index 0000000000..0eb81abce3 --- /dev/null +++ b/meshroom/nodes/converters/BoolToInt.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class BoolToInt(AttributeConverter): + srcType = desc.BoolParam + dstType = desc.IntParam + + @classmethod + def convert(cls, value): + return int(bool(value)) diff --git a/meshroom/nodes/converters/ChoiceToString.py b/meshroom/nodes/converters/ChoiceToString.py new file mode 100644 index 0000000000..517441c57b --- /dev/null +++ b/meshroom/nodes/converters/ChoiceToString.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class ChoiceToString(AttributeConverter): + srcType = desc.ChoiceParam + dstType = desc.StringParam + + @classmethod + def convert(cls, value): + return str(value) diff --git a/meshroom/nodes/converters/FileToString.py b/meshroom/nodes/converters/FileToString.py new file mode 100644 index 0000000000..7848585d04 --- /dev/null +++ b/meshroom/nodes/converters/FileToString.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class FileToString(AttributeConverter): + srcType = desc.File + dstType = desc.StringParam + + @classmethod + def convert(cls, value): + return str(value) if value is not None else "" diff --git a/meshroom/nodes/converters/FloatToInt.py b/meshroom/nodes/converters/FloatToInt.py new file mode 100644 index 0000000000..f9de4660f0 --- /dev/null +++ b/meshroom/nodes/converters/FloatToInt.py @@ -0,0 +1,29 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class FloatToIntRound(AttributeConverter): + description = ( + "Convert a FloatParam to an IntParam by rounding the value." + ) + + srcType = desc.FloatParam + dstType = desc.IntParam + + @classmethod + def convert(cls, value): + return int(round(value)) + + +class FloatToIntTruncate(AttributeConverter): + description = ( + "Convert a FloatParam to an IntParam by truncating the value." + ) + + priority = 20 + srcType = desc.FloatParam + dstType = desc.IntParam + + @classmethod + def convert(cls, value): + return int(value) diff --git a/meshroom/nodes/converters/IntToBool.py b/meshroom/nodes/converters/IntToBool.py new file mode 100644 index 0000000000..540fe5c196 --- /dev/null +++ b/meshroom/nodes/converters/IntToBool.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class IntToBool(AttributeConverter): + srcType = desc.IntParam + dstType = desc.BoolParam + + @classmethod + def convert(cls, value): + return bool(value) diff --git a/meshroom/nodes/converters/IntToFloat.py b/meshroom/nodes/converters/IntToFloat.py new file mode 100644 index 0000000000..dabed2690f --- /dev/null +++ b/meshroom/nodes/converters/IntToFloat.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class IntToFloat(AttributeConverter): + srcType = desc.IntParam + dstType = desc.FloatParam + + @classmethod + def convert(cls, value): + return float(value) diff --git a/meshroom/nodes/converters/StringToChoice.py b/meshroom/nodes/converters/StringToChoice.py new file mode 100644 index 0000000000..2f8ac7e7d3 --- /dev/null +++ b/meshroom/nodes/converters/StringToChoice.py @@ -0,0 +1,16 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class StringToChoice(AttributeConverter): + description = ( + "Convert a StringParam to a ChoiceParam." + ) + + priority = 20 + srcType = desc.StringParam + dstType = desc.ChoiceParam + + @classmethod + def convert(cls, value): + return str(value) diff --git a/meshroom/nodes/converters/StringToFile.py b/meshroom/nodes/converters/StringToFile.py new file mode 100644 index 0000000000..aa724c8bea --- /dev/null +++ b/meshroom/nodes/converters/StringToFile.py @@ -0,0 +1,11 @@ +from meshroom.core import desc +from meshroom.core.attributeConverter import AttributeConverter + + +class StringToFile(AttributeConverter): + srcType = desc.StringParam + dstType = desc.File + + @classmethod + def convert(cls, value): + return str(value) diff --git a/meshroom/nodes/converters/__init__.py b/meshroom/nodes/converters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/meshroom/submitters/localFarm/localFarmSubmitter.py b/meshroom/submitters/localFarm/localFarmSubmitter.py index 0733e16e54..e1dbe28791 100644 --- a/meshroom/submitters/localFarm/localFarmSubmitter.py +++ b/meshroom/submitters/localFarm/localFarmSubmitter.py @@ -94,6 +94,7 @@ def rezWrapCommand(cmd: str, the final command to execute """ packages = set() + additionalEnv = additionalEnv or {} if useCurrentContext: # In this case we want to use the full context packages.update([p for p in os.environ.get('REZ_RESOLVE', '').split(" ") if p]) @@ -109,9 +110,10 @@ def rezWrapCommand(cmd: str, rezBin = os.path.join(os.environ["REZ_PACKAGES_ROOT"], "bin/rez") elif shutil.which("rez"): rezBin = shutil.which("rez") - if additionalEnv: - envVars = " ".join([f'{k}="{v}"' for k, v in additionalEnv.items()]) - return f"{rezBin} env {packagesStr} -- {envVars} {cmd}" + envVars = " ".join([f'{k}="{v}"' for k, v in additionalEnv.items()]) + cmd = f"{envVars} {cmd}" if envVars else cmd + if rezBin: + cmd = f"{rezBin} env {packagesStr} -- {cmd}" return cmd diff --git a/meshroom/ui/qml/GraphEditor/Edge.qml b/meshroom/ui/qml/GraphEditor/Edge.qml index 3066ebb728..675a9a4fe6 100644 --- a/meshroom/ui/qml/GraphEditor/Edge.qml +++ b/meshroom/ui/qml/GraphEditor/Edge.qml @@ -1,9 +1,11 @@ import QtQuick import QtQuick.Controls +import QtQuick.Layouts import QtQuick.Shapes 1.6 import GraphEditor 1.0 import MaterialIcons 2.2 +import Utils 1.0 /** * A cubic spline representing an edge, going from point1 to point2, providing mouse interaction. @@ -23,6 +25,8 @@ Item { property int loopSize: 0 property int iteration: 0 + readonly property bool hasConverter: edge !== undefined && edge !== null && edge.hasConverter === true + // Note: edgeArea is destroyed before path, so we need to test if not null to avoid warnings. readonly property bool containsMouse: (loopArea && loopArea.containsMouse) || (edgeArea && edgeArea.containsMouse) @@ -113,38 +117,76 @@ Item { // Place the label at the middle of the edge x: (root.startX + root.endX) / 2 y: (root.startY + root.endY) / 2 - visible: root.isForLoop + z: 1 + visible: root.isForLoop || root.hasConverter - Rectangle { + RowLayout { anchors.centerIn: parent - property int margin: 2 - width: icon.width + 2 * margin - height: icon.height + 2 * margin - radius: width - color: path.strokeColor - - MaterialToolLabel { - id: icon - anchors.centerIn: parent - - iconText: MaterialIcons.loop - label.text: (root.iteration + 1) + "/" + root.loopSize + " " - - labelIconColor: palette.base - ToolTip.text: "Foreach Loop" + spacing: 4 + + // For-loop badge + Rectangle { + visible: root.isForLoop + property int margin: 2 + Layout.preferredWidth: icon.width + 2 * margin + Layout.preferredHeight: icon.height + 2 * margin + radius: width + color: path.strokeColor + + MaterialToolLabel { + id: icon + anchors.centerIn: parent + + iconText: MaterialIcons.loop + label.text: (root.iteration + 1) + "/" + root.loopSize + " " + + labelIconColor: palette.base + ToolTip.text: "Foreach Loop" + } + + MouseArea { + id: loopArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.pressed(arguments[0]) + } } - MouseArea { - id: loopArea - anchors.fill: parent - hoverEnabled: true - onClicked: root.pressed(arguments[0]) + // Converter badge + Rectangle { + id: converterBadge + visible: root.hasConverter + property int margin: 2 + Layout.preferredWidth: 10 + Layout.preferredHeight: 10 + radius: width + color: Colors.lightpurple + border.color: "#aaa" + border.width: 0.5 + + MouseArea { + id: converterArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.RightButton + onClicked: (mouse) => converterMenu.popup() + } + + ToolTip { + visible: converterArea.containsMouse + delay: 400 + text: root.edge ? qsTr(root.edge.converterDescription) : "" + x: converterBadge.width + 4 + y: converterBadge.height - height / 2 + } } } } EdgeMouseArea { id: edgeArea + z: 0 anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton thickness: root.thickness + 4 @@ -157,4 +199,28 @@ Item { } } + + Menu { + id: converterMenu + title: "Convert Attribute" + + ButtonGroup { + id: converterChoices + } + + Instantiator { + model: root.edge ? root.edge.availableConverters : [] + RadioButton { + text: modelData.name + ButtonGroup.group: converterChoices + checked: root.edge && root.edge.converterName === modelData.name + onToggled: { + if (checked) root.edge.setConverter(modelData.name) + converterMenu.close() + } + } + onObjectAdded: (index, object) => converterMenu.insertItem(index, object) + onObjectRemoved: (index, object) => converterMenu.removeItem(object) + } + } } diff --git a/meshroom/ui/qml/Utils/Colors.qml b/meshroom/ui/qml/Utils/Colors.qml index c5ebe7a66c..f1c0780c79 100644 --- a/meshroom/ui/qml/Utils/Colors.qml +++ b/meshroom/ui/qml/Utils/Colors.qml @@ -23,6 +23,7 @@ QtObject { readonly property color grey: "#555555" readonly property color lightgrey: "#999999" readonly property color warning: "#FF9800" + readonly property color lightpurple: "#ab70b3" readonly property color darkpurple: "#5c4885" readonly property var statusColors: { diff --git a/tests/test_submit.py b/tests/test_submit.py index fb89b662e8..2232d70448 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -40,7 +40,14 @@ def getJobEnv(): """ Required to have meshroom recognize plugins that were created here """ pluginFolder = os.path.join(os.path.dirname(__file__), "plugins") return { - "MESHROOM_PLUGINS_PATH": pluginFolder + "MESHROOM_PLUGINS_PATH": pluginFolder, + # Disable all rez variables that could lead to using rez in the test env + "REZ_RESOLVE": "", + "REZ_BIN": "", + "REZ_PACKAGES_ROOT": "", + "REZ_REQUEST": "", + "REZ_USED_REQUEST": "", + "REZ_MESHROOM_VERSION": "" } @@ -50,7 +57,7 @@ def checkTask(task, taskType, nbDependencies): assert len(task.dependencies) == nbDependencies -def waitForNodeCompletion(job: LocalFarmJob, node: Node, timeout=15): +def waitForNodeCompletion(job: LocalFarmJob, node: Node, timeout=20): """ Wait for a node to complete processing """