diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index efa9f50b2c..215f2c2847 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -49,6 +49,9 @@ class Attribute(BaseObject): """ """ stringIsLinkRe = re.compile(r'^\{[A-Za-z]+[A-Za-z0-9_.\[\]]*\}$') + + VALID_IMAGE_SEMANTICS = ["image", "imageList", "sequence"] + VALID_3D_EXTENSIONS = [".obj", ".stl", ".fbx", ".gltf", ".abc", ".ply"] def __init__(self, node, attributeDesc, isOutput, root=None, parent=None): """ @@ -446,6 +449,27 @@ def updateInternals(self): # Emit if the enable status has changed self.setEnabled(self.getEnabled()) + def _is3D(self) -> bool: + """ Return True if the current attribute is considered as a 3d file """ + + if self.desc.semantic == "3d": + return True + + # If the attribute is a File attribute, it is an instance of str and can be iterated over + hasSupportedExt = isinstance(self.value, str) and any(ext in self.value for ext in Attribute.VALID_3D_EXTENSIONS) + if hasSupportedExt: + return True + + return False + + def _is2D(self) -> bool: + """ Return True if the current attribute is considered as a 2d file """ + + if not self.desc.semantic: + return False + + return next((imageSemantic for imageSemantic in Attribute.VALID_IMAGE_SEMANTICS if self.desc.semantic == imageSemantic), None) is not None + name = Property(str, getName, constant=True) fullName = Property(str, getFullName, constant=True) fullNameToNode = Property(str, getFullNameToNode, constant=True) @@ -458,6 +482,8 @@ def updateInternals(self): type = Property(str, getType, constant=True) baseType = Property(str, getType, constant=True) isReadOnly = Property(bool, _isReadOnly, constant=True) + is3D = Property(bool, _is3D, constant=True) + is2D = Property(bool, _is2D, constant=True) # Description of the attribute descriptionChanged = Signal() diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 392e3316aa..a431a11f1c 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -1617,18 +1617,8 @@ def has3DOutputAttribute(self): Return True if at least one attribute is a File that can be loaded in the 3D Viewer, False otherwise. """ - # List of supported extensions, taken from Viewer3DSettings - supportedExts = ['.obj', '.stl', '.fbx', '.gltf', '.abc', '.ply'] - for attr in self._attributes: - if not attr.enabled or not attr.isOutput: - continue - if attr.desc.semantic == "3d": - return True - # If the attribute is a File attribute, it is an instance of str and can be iterated over - hasSupportedExt = isinstance(attr.value, str) and any(ext in attr.value for ext in supportedExts) - if hasSupportedExt: - return True - return False + + return next((attr for attr in self._attributes if attr.enabled and attr.isOutput and attr.is3D), None) is not None name = Property(str, getName, constant=True) defaultLabel = Property(str, getDefaultLabel, constant=True) diff --git a/meshroom/ui/qml/Application.qml b/meshroom/ui/qml/Application.qml index 85f04520e9..de7201449c 100644 --- a/meshroom/ui/qml/Application.qml +++ b/meshroom/ui/qml/Application.qml @@ -1105,22 +1105,45 @@ Page { for (var i = 0; i < node.attributes.count; i++) { var attr = node.attributes.at(i) if (attr.isOutput && attr.desc.semantic !== "image") - if (!alreadyDisplay || attr.desc.semantic == "3D") + if (!alreadyDisplay || attr.desc.semantic == "3d") { if (workspaceView.viewIn3D(attr, mouse)) alreadyDisplay = true + } + } } + function viewIn2D(attribute, mouse) { + workspaceView.viewer2D.tryLoadNode(attribute.node) + workspaceView.viewer2D.setAttributeName(attribute.name) + } + function viewIn3D(attribute, mouse) { - if (!panel3dViewer || (!attribute.node.has3DOutput && !attribute.node.hasAttribute("useBoundingBox"))) + + if (!panel3dViewer || (!attribute.node.has3DOutput && !attribute.node.hasAttribute("useBoundingBox"))) { return false + } var loaded = panel3dViewer.viewer3D.view(attribute) // solo media if Control modifier was held - if (loaded && mouse && mouse.modifiers & Qt.ControlModifier) + if (loaded && mouse && mouse.modifiers & Qt.ControlModifier) { panel3dViewer.viewer3D.solo(attribute) + } return loaded } + + function viewAttributeInViewer(mouse, attribute) { + /* Display the current attribute in the corresponding viewer */ + + if (attribute.is2D) { + workspaceView.viewIn2D(attribute, mouse) + } + + else if (attribute.is3D) { + workspaceView.viewIn3D(attribute, mouse) + } + + } } MSplitView { @@ -1417,8 +1440,18 @@ Page { } } + + onShowAttributeInViewer: function(attribute) { + workspaceView.viewAttributeInViewer(null, attribute) + } + + onAttributeDoubleClicked: function(mouse, attribute) { + workspaceView.viewAttributeInViewer(mouse, attribute) + } + } } } } + } \ No newline at end of file diff --git a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml index 8b4b0103be..16220cbfb3 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml @@ -18,6 +18,7 @@ ListView { signal attributeDoubleClicked(var mouse, var attribute) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) + signal showInViewer(var attribute) implicitHeight: contentHeight @@ -53,6 +54,9 @@ ListView { root.outAttributeClicked(srcItem, mouse, outAttributes) } + onShowInViewer: function(attr) { + root.showInViewer(attr) + } } onActiveChanged: height = active ? item.implicitHeight : -spacing diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index baff5ab3ac..7a830d6e3f 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -28,6 +28,7 @@ RowLayout { signal doubleClicked(var mouse, var attr) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) + signal showInViewer(var attr) spacing: 2 @@ -64,11 +65,11 @@ RowLayout { property bool shouldBeVisible: (object != undefined && object.isLinkNested) - text: shouldBeVisible ? MaterialIcons.login : " " + text: MaterialIcons.login enabled: shouldBeVisible font.pointSize: 8 - Layout.alignment: Qt.AlignTop | Qt.AlignLeft - topPadding: 7 + Layout.fillHeight: true + visible: shouldBeVisible MouseArea { anchors.fill: parent @@ -78,7 +79,7 @@ RowLayout { root.inAttributeClicked(navButtonIn, mouse, object.linkedInAttributes) } } - + } Label { @@ -87,6 +88,7 @@ RowLayout { Layout.fillHeight: true Layout.fillWidth: true horizontalAlignment: attribute.isOutput ? Qt.AlignRight : Qt.AlignLeft + verticalAlignment: Text.AlignVCenter elide: Label.ElideRight padding: 5 wrapMode: Label.WrapAtWordBoundaryOrAnywhere @@ -129,7 +131,7 @@ RowLayout { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.AllButtons - onDoubleClicked: function(mouse) { root.doubleClicked(mouse, root.attribute) } + onDoubleClicked: function(mouse) { root.doubleClicked(mouse, root.attribute) } property Component menuComp: Menu { id: paramMenu @@ -180,6 +182,18 @@ RowLayout { text: "Open File" onClicked: Qt.openUrlExternally(Filepath.stringToUrl(attribute.evalValue)) } + + MenuItem { + visible: attribute.isOutput && (attribute.is2D || attribute.is3D) + height: visible ? implicitHeight : 0 + text: { + if (attribute.is2D) + return "Show in 2D Viewer" + return "Show in 3D Viewer" + } + onClicked: root.showInViewer(attribute) + } + } onClicked: function(mouse) { @@ -193,26 +207,38 @@ RowLayout { } } + MaterialLabel { + property bool isDisplayable: attribute.isOutput && (attribute.is2D || attribute.is3D) + property bool isDisplayed: attribute === _reconstruction.displayedAttr2D || _reconstruction.displayedAttrs3D.count && _reconstruction.displayedAttrs3D.contains(attribute) + text: isDisplayed ? MaterialIcons.visibility : MaterialIcons.visibility_off + enabled: isDisplayed + visible: isDisplayable + ToolTip.text: `This attribute is displayable in the ${attribute.is2D ? "2D" : "3D"} viewer.` + + padding: 4 + font.pointSize: 8 + } + MaterialToolButton { id: navButtonOut property bool shouldBeVisible: (attribute != undefined && attribute.hasOutputConnections) - text: shouldBeVisible ? MaterialIcons.logout : " " + text: MaterialIcons.logout font.pointSize: 8 enabled: shouldBeVisible - Layout.alignment: Qt.AlignTop | Qt.AlignRight - topPadding: 7 + Layout.fillHeight: true + visible: shouldBeVisible MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton - onClicked: function(mouse) { - root.outAttributeClicked(navButtonOut, mouse, attribute.linkedOutAttributes) + onClicked: function(mouse) { + root.outAttributeClicked(navButtonOut, mouse, attribute.linkedOutAttributes) } } - + } diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index 00d4509053..7a43db7f6a 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -23,6 +23,7 @@ Panel { signal attributeDoubleClicked(var mouse, var attribute) signal inAttributeClicked(var srcItem, var mouse, var inAttributes) signal outAttributeClicked(var srcItem, var mouse, var outAttributes) + signal showAttributeInViewer(var attribute) signal upgradeRequest() title: "Node" + (node !== null ? " - " + node.label + "" + (node.label !== node.defaultLabel ? " (" + node.defaultLabel + ")" : "") : "") @@ -302,6 +303,7 @@ Panel { readOnly: root.readOnly || root.isCompatibilityNode onAttributeDoubleClicked: function(mouse, attribute) { root.attributeDoubleClicked(mouse, attribute) } onUpgradeRequest: root.upgradeRequest() + onShowInViewer: function (attribute) {root.showAttributeInViewer(attribute)} filterText: searchBar.text onInAttributeClicked: function(srcItem, mouse, inAttributes) { diff --git a/meshroom/ui/qml/Viewer/Viewer2D.qml b/meshroom/ui/qml/Viewer/Viewer2D.qml index 48a672e76c..efb4d8f6c3 100644 --- a/meshroom/ui/qml/Viewer/Viewer2D.qml +++ b/meshroom/ui/qml/Viewer/Viewer2D.qml @@ -343,6 +343,10 @@ FocusScope { return [] } + function setAttributeName(attrName) { + outputAttribute.setName(attrName) + } + onDisplayedNodeChanged: { if (!displayedNode) { root.source = "" @@ -379,6 +383,10 @@ FocusScope { } } + onDisplayedAttrChanged: { + _reconstruction.displayedAttr2D = displayedAttr + } + Connections { target: _reconstruction function onSelectedViewIdChanged() { @@ -1604,6 +1612,13 @@ FocusScope { root.source = getImageFile() root.sequence = getSequence() } + + function setName(attrName) { + const attrIndex = outputAttribute.names.indexOf(attrName) + if (attrIndex > -1) { + outputAttribute.currentIndex = attrIndex + } + } } MaterialToolButton { diff --git a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml index bb305d750e..54cf1af5fe 100644 --- a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml +++ b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml @@ -107,6 +107,7 @@ Entity { "label": label ? label : Filepath.basename(pathStr), "section": "External" })) + } function view(attribute) { @@ -115,15 +116,16 @@ Entity { return } - var attrLabel = attribute.isOutput ? "" : attribute.fullName.replace(attribute.node.name, "") var section = attribute.node.label + // Add file to the internal ListModel m.mediaModel.append( makeElement({ - "label": section + attrLabel, + "label": `${section}.${attribute.label}`, "section": section, "attribute": attribute })) + } function remove(index) { @@ -384,11 +386,15 @@ Entity { onObjectAdded: function(index, object) { // Notify object that it is now fully instantiated object.fullyInstantiated = true + _reconstruction.displayedAttrs3D.append(object.modelSource) } onObjectRemoved: function(index, object) { if (m.sourceToEntity[object.modelSource]) + delete m.sourceToEntity[object.modelSource] + _reconstruction.displayedAttrs3D.remove(object.modelSource) } + } } diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index d398f2214d..3098f59b1b 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -474,6 +474,10 @@ def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, defa self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType") self.initActiveNodes() + # initialize activeAttributes (attributes currently visible in some viewers) + self._displayedAttr2D = None + self._displayedAttrs3D = meshroom.common.ListModel() + # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel(parent=self) # all CameraInit nodes @@ -512,7 +516,6 @@ def __del__(self): def setActive(self, active): self._active = active - @Slot() def clear(self): self.clearActiveNodes() super().clear() @@ -1065,6 +1068,12 @@ def setBuildingIntrinsics(self, value): buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) liveSfmManager = Property(QObject, lambda self: self._liveSfmManager, constant=True) + displayedAttr2DChanged = Signal() + displayedAttr2D = makeProperty(QObject, "_displayedAttr2D", displayedAttr2DChanged) + + displayedAttrs3DChanged = Signal() + displayedAttrs3D = Property(QObject, lambda self: self._displayedAttrs3D, notify=displayedAttrs3DChanged) + @Slot(QObject) def setActiveNode(self, node, categories=True, inputs=True): """ Set node as the active node of its type and of its categories. diff --git a/tests/test_attributes.py b/tests/test_attributes.py index d8abd4d8dc..9abfd67e43 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,4 +1,14 @@ from meshroom.core.graph import Graph +import pytest + +import logging +logger = logging.getLogger('test') + +valid3DExtensionFiles = [(f'test.{ext}', True) for ext in ('obj', 'stl', 'fbx', 'gltf', 'abc', 'ply')] +invalid3DExtensionFiles = [(f'test.{ext}', False) for ext in ('', 'exe', 'jpg', 'png', 'py')] + +valid2DSemantics= [(semantic, True) for semantic in ('image', 'imageList', 'sequence')] +invalid2DSemantics = [(semantic, False) for semantic in ('3d', '', 'multiline', 'color/hue')] def test_attribute_retrieve_linked_input_and_output_attributes(): @@ -36,4 +46,56 @@ def test_attribute_retrieve_linked_input_and_output_attributes(): assert not n0.output.hasOutputConnections assert len(n0.input.getLinkedInAttributes()) == 0 assert len(n0.output.getLinkedOutAttributes()) == 0 - \ No newline at end of file + +@pytest.mark.parametrize("givenFile,expected", valid3DExtensionFiles + invalid3DExtensionFiles) +def test_attribute_is3D_file_extensions(givenFile, expected): + """ + Check what makes an attribute a valid 3d media + """ + + g = Graph('') + n0 = g.addNewNode('Ls', input='') + + # Given + assert not n0.input.is3D + + # When + n0.input.value = givenFile + + # Then + assert n0.input.is3D == expected + + +def test_attribute_i3D_by_description_semantic(): + """ """ + + # Given + g = Graph('') + n0 = g.addNewNode('Ls', input='') + + assert not n0.output.is3D + + # When + n0.output.desc._semantic = "3d" + + # Then + assert n0.output.is3D + +@pytest.mark.parametrize("givenSemantic,expected", valid2DSemantics + invalid2DSemantics) +def test_attribute_is2D_file_semantic(givenSemantic, expected): + """ + Check what makes an attribute a valid 2d media + """ + + g = Graph('') + n0 = g.addNewNode('Ls', input='') + + # Given + n0.input.desc._semantic = "" + assert not n0.input.is2D + + # When + n0.input.desc._semantic = givenSemantic + + # Then + assert n0.input.is2D == expected \ No newline at end of file