diff --git a/bin/meshroom_photogrammetry b/bin/meshroom_photogrammetry index 556be74fdd..3abfd9d19a 100755 --- a/bin/meshroom_photogrammetry +++ b/bin/meshroom_photogrammetry @@ -72,7 +72,7 @@ args = parser.parse_args() def getOnlyNodeOfType(g, nodeType): """ Helper function to get a node of 'nodeType' in the graph 'g' and raise if no or multiple candidates. """ - nodes = g.nodesByType(nodeType) + nodes = g.nodesOfType(nodeType) if len(nodes) != 1: raise RuntimeError("meshroom_photogrammetry requires a pipeline graph with exactly one '{}' node, {} found." .format(nodeType, len(nodes))) @@ -163,10 +163,10 @@ with multiview.GraphModification(graph): raise ValueError('Invalid param override: ' + str(p)) node, t, param, value = result.groups() if t == ':': - nodesByType = graph.nodesByType(node) - if not nodesByType: + nodesOfType = graph.nodesOfType(node) + if not nodesOfType: raise ValueError('No node with the type "{}" in the scene.'.format(node)) - for n in nodesByType: + for n in nodesOfType: print('Overrides {node}.{param}={value}'.format(node=node, param=param, value=value)) n.attribute(param).value = value elif t == '.': @@ -178,7 +178,7 @@ with multiview.GraphModification(graph): # setup DepthMap downscaling if args.scale > 0: - for node in graph.nodesByType('DepthMap'): + for node in graph.nodesOfType('DepthMap'): node.downscale.value = args.scale # setup cache directory diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index c0651de274..8291580ba6 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -93,6 +93,9 @@ def getName(self): def getType(self): return self.attributeDesc.__class__.__name__ + def _isReadOnly(self): + return not self._isOutput and self.node.isCompatibilityNode + def getBaseType(self): return self.getType() @@ -262,6 +265,7 @@ def updateInternals(self): label = Property(str, getLabel, constant=True) type = Property(str, getType, constant=True) baseType = Property(str, getType, constant=True) + isReadOnly = Property(bool, _isReadOnly, constant=True) desc = Property(desc.Attribute, lambda self: self.attributeDesc, constant=True) valueChanged = Signal() value = Property(Variant, _get_value, _set_value, notify=valueChanged) diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 27d2e56e94..d2703b38fb 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -548,7 +548,7 @@ def sortNodesByIndex(nodes): """ return sorted(nodes, key=lambda x: Graph.getNodeIndexFromName(x.name)) - def nodesByType(self, nodeType, sortedByIndex=True): + def nodesOfType(self, nodeType, sortedByIndex=True): """ Returns all Nodes of the given nodeType. diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 00646e6c6e..42c2bc5713 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -58,12 +58,12 @@ class ExecMode(Enum): EXTERN = 2 -class StatusData: +class StatusData(BaseObject): """ """ dateTimeFormatting = '%Y-%m-%d %H:%M:%S.%f' - def __init__(self, nodeName, nodeType, packageName, packageVersion): + def __init__(self, nodeName='', nodeType='', packageName='', packageVersion=''): self.status = Status.NONE self.execMode = ExecMode.NONE self.nodeName = nodeName @@ -79,6 +79,11 @@ def __init__(self, nodeName, nodeType, packageName, packageVersion): self.hostname = "" self.sessionUid = meshroom.core.sessionUid + def merge(self, other): + self.startDateTime = min(self.startDateTime, other.startDateTime) + self.endDateTime = max(self.endDateTime, other.endDateTime) + self.elapsedTime += other.elapsedTime + def reset(self): self.status = Status.NONE self.execMode = ExecMode.NONE @@ -112,8 +117,12 @@ def toDict(self): return d def fromDict(self, d): - self.status = getattr(Status, d.get('status', ''), Status.NONE) - self.execMode = getattr(ExecMode, d.get('execMode', ''), ExecMode.NONE) + self.status = d.get('status', Status.NONE) + if not isinstance(self.status, Status): + self.status = Status[self.status] + self.execMode = d.get('execMode', ExecMode.NONE) + if not isinstance(self.execMode, ExecMode): + self.execMode = ExecMode[self.execMode] self.nodeName = d.get('nodeName', '') self.nodeType = d.get('nodeType', '') self.packageName = d.get('packageName', '') @@ -236,7 +245,7 @@ def __init__(self, node, range, parent=None): self.node = node self.range = range self.logManager = LogManager(self) - self.status = StatusData(node.name, node.nodeType, node.packageName, node.packageVersion) + self._status = StatusData(node.name, node.nodeType, node.packageName, node.packageVersion) self.statistics = stats.Statistics() self.statusFileLastModTime = -1 self._subprocess = None @@ -258,7 +267,7 @@ def name(self): @property def statusName(self): - return self.status.status.name + return self._status.status.name @property def logger(self): @@ -266,24 +275,24 @@ def logger(self): @property def execModeName(self): - return self.status.execMode.name + return self._status.execMode.name def updateStatusFromCache(self): """ Update node status based on status file content/existence. """ statusFile = self.statusFile - oldStatus = self.status.status + oldStatus = self._status.status # No status file => reset status to Status.None if not os.path.exists(statusFile): self.statusFileLastModTime = -1 - self.status.reset() + self._status.reset() else: with open(statusFile, 'r') as jsonFile: statusData = json.load(jsonFile) - self.status.fromDict(statusData) + self._status.fromDict(statusData) self.statusFileLastModTime = os.path.getmtime(statusFile) - if oldStatus != self.status.status: + if oldStatus != self._status.status: self.statusChanged.emit() @property @@ -311,7 +320,7 @@ def saveStatusFile(self): """ Write node status on disk. """ - data = self.status.toDict() + data = self._status.toDict() statusFilepath = self.statusFile folder = os.path.dirname(statusFilepath) if not os.path.exists(folder): @@ -322,16 +331,16 @@ def saveStatusFile(self): renameWritingToFinalPath(statusFilepathWriting, statusFilepath) def upgradeStatusTo(self, newStatus, execMode=None): - if newStatus.value <= self.status.status.value: - print('WARNING: downgrade status on node "{}" from {} to {}'.format(self.name, self.status.status, - newStatus)) + if newStatus.value <= self._status.status.value: + logging.warning('Downgrade status on node "{}" from {} to {}'.format(self.name, self._status.status, + newStatus)) if newStatus == Status.SUBMITTED: - self.status = StatusData(self.node.name, self.node.nodeType, self.node.packageName, self.node.packageVersion) + self._status = StatusData(self.node.name, self.node.nodeType, self.node.packageName, self.node.packageVersion) if execMode is not None: - self.status.execMode = execMode + self._status.execMode = execMode self.execModeNameChanged.emit() - self.status.status = newStatus + self._status.status = newStatus self.saveStatusFile() self.statusChanged.emit() @@ -360,24 +369,24 @@ def saveStatistics(self): renameWritingToFinalPath(statisticsFilepathWriting, statisticsFilepath) def isAlreadySubmitted(self): - return self.status.status in (Status.SUBMITTED, Status.RUNNING) + return self._status.status in (Status.SUBMITTED, Status.RUNNING) def isAlreadySubmittedOrFinished(self): - return self.status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) + return self._status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) def isFinishedOrRunning(self): - return self.status.status in (Status.SUCCESS, Status.RUNNING) + return self._status.status in (Status.SUCCESS, Status.RUNNING) def isStopped(self): - return self.status.status == Status.STOPPED + return self._status.status == Status.STOPPED def process(self, forceCompute=False): - if not forceCompute and self.status.status == Status.SUCCESS: - print("Node chunk already computed:", self.name) + if not forceCompute and self._status.status == Status.SUCCESS: + logging.info("Node chunk already computed: {}".format(self.name)) return global runningProcesses runningProcesses[self.name] = self - self.status.initStartCompute() + self._status.initStartCompute() startTime = time.time() self.upgradeStatusTo(Status.RUNNING) self.statThread = stats.StatisticsThread(self) @@ -385,16 +394,16 @@ def process(self, forceCompute=False): try: self.node.nodeDesc.processChunk(self) except Exception as e: - if self.status.status != Status.STOPPED: + if self._status.status != Status.STOPPED: self.upgradeStatusTo(Status.ERROR) raise except (KeyboardInterrupt, SystemError, GeneratorExit) as e: self.upgradeStatusTo(Status.STOPPED) raise finally: - self.status.initEndCompute() - self.status.elapsedTime = time.time() - startTime - print(' - elapsed time:', self.status.elapsedTimeStr) + self._status.initEndCompute() + self._status.elapsedTime = time.time() - startTime + logging.info(' - elapsed time: {}'.format(self._status.elapsedTimeStr)) # ask and wait for the stats thread to stop self.statThread.stopRequest() self.statThread.join() @@ -408,9 +417,10 @@ def stopProcess(self): self.node.nodeDesc.stopProcess(self) def isExtern(self): - return self.status.execMode == ExecMode.EXTERN + return self._status.execMode == ExecMode.EXTERN statusChanged = Signal() + status = Property(Variant, lambda self: self._status, notify=statusChanged) statusName = Property(str, statusName.fget, notify=statusChanged) execModeNameChanged = Signal() execModeName = Property(str, execModeName.fget, notify=execModeNameChanged) @@ -422,7 +432,7 @@ def isExtern(self): statisticsFile = Property(str, statisticsFile.fget, notify=nodeFolderChanged) nodeName = Property(str, lambda self: self.node.name, constant=True) - statusNodeName = Property(str, lambda self: self.status.nodeName, constant=True) + statusNodeName = Property(str, lambda self: self._status.nodeName, constant=True) # simple structure for storing node position @@ -837,6 +847,27 @@ def getGlobalStatus(self): return Status.NONE + @Slot(result=StatusData) + def getFusedStatus(self): + fusedStatus = StatusData() + if self._chunks: + fusedStatus.fromDict(self._chunks[0].status.toDict()) + for chunk in self._chunks[1:]: + fusedStatus.merge(chunk.status) + fusedStatus.status = self.getGlobalStatus() + return fusedStatus + + @Slot(result=StatusData) + def getRecursiveFusedStatus(self): + fusedStatus = self.getFusedStatus() + nodes = self.getInputNodes(recursive=True, dependenciesOnly=True) + for node in nodes: + fusedStatus.merge(node.fusedStatus) + return fusedStatus + + def _isCompatibilityNode(self): + return False + @property def globalExecMode(self): return self._chunks.at(0).execModeName @@ -1000,6 +1031,11 @@ def canBeCanceled(self): size = Property(int, getSize, notify=sizeChanged) globalStatusChanged = Signal() globalStatus = Property(str, lambda self: self.getGlobalStatus().name, notify=globalStatusChanged) + fusedStatus = Property(StatusData, getFusedStatus, notify=globalStatusChanged) + elapsedTime = Property(float, lambda self: self.getFusedStatus().elapsedTime, notify=globalStatusChanged) + recursiveElapsedTime = Property(float, lambda self: self.getRecursiveFusedStatus().elapsedTime, notify=globalStatusChanged) + isCompatibilityNode = Property(bool, lambda self: self._isCompatibilityNode(), constant=True) # need lambda to evaluate the virtual function + globalExecModeChanged = Signal() globalExecMode = Property(str, globalExecMode.fget, notify=globalExecModeChanged) isComputed = Property(bool, _isComputed, notify=globalStatusChanged) @@ -1135,6 +1171,9 @@ def __init__(self, nodeType, nodeDict, position=None, issue=CompatibilityIssue.U for i in range(self.splitCount) ]) + def _isCompatibilityNode(self): + return True + @staticmethod def attributeDescFromValue(attrName, value, isOutput): """ diff --git a/meshroom/multiview.py b/meshroom/multiview.py index 76ccce6ca8..776338e20b 100644 --- a/meshroom/multiview.py +++ b/meshroom/multiview.py @@ -178,11 +178,11 @@ def panoramaFisheyeHdr(inputImages=None, inputViewpoints=None, inputIntrinsics=N graph = Graph('PanoramaFisheyeHDR') with GraphModification(graph): panoramaHdr(inputImages, inputViewpoints, inputIntrinsics, output, graph) - for panoramaInit in graph.nodesByType("PanoramaInit"): + for panoramaInit in graph.nodesOfType("PanoramaInit"): panoramaInit.attribute("useFisheye").value = True # when using fisheye images, the overlap between images can be small # and thus requires many features to get enough correspondances for cameras estimation - for featureExtraction in graph.nodesByType("FeatureExtraction"): + for featureExtraction in graph.nodesOfType("FeatureExtraction"): featureExtraction.attribute("describerPreset").value = 'high' return graph diff --git a/meshroom/nodes/aliceVision/LdrToHdrMerge.py b/meshroom/nodes/aliceVision/LdrToHdrMerge.py index af6f043503..e1b1c88cfb 100644 --- a/meshroom/nodes/aliceVision/LdrToHdrMerge.py +++ b/meshroom/nodes/aliceVision/LdrToHdrMerge.py @@ -104,6 +104,15 @@ class LdrToHdrMerge(desc.CommandLineNode): advanced=True, enabled= lambda node: node.byPass.enabled and not node.byPass.value, ), + desc.BoolParam( + name='enableHighlight', + label='Enable Highlight', + description="Enable highlights correction.", + value=True, + uid=[0], + group='user', # not used directly on the command line + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), desc.FloatParam( name='highlightCorrectionFactor', label='Highlights Correction', @@ -115,7 +124,7 @@ class LdrToHdrMerge(desc.CommandLineNode): value=1.0, range=(0.0, 1.0, 0.01), uid=[0], - enabled= lambda node: node.byPass.enabled and not node.byPass.value, + enabled= lambda node: node.enableHighlight.enabled and node.enableHighlight.value, ), desc.FloatParam( name='highlightTargetLux', @@ -138,7 +147,7 @@ class LdrToHdrMerge(desc.CommandLineNode): value=120000.0, range=(1000.0, 150000.0, 1.0), uid=[0], - enabled= lambda node: node.byPass.enabled and not node.byPass.value and node.highlightCorrectionFactor.value != 0, + enabled= lambda node: node.enableHighlight.enabled and node.enableHighlight.value and node.highlightCorrectionFactor.value != 0, ), desc.ChoiceParam( name='storageDataType', diff --git a/meshroom/nodes/aliceVision/PanoramaEstimation.py b/meshroom/nodes/aliceVision/PanoramaEstimation.py index b89ae370da..1cc147fa92 100644 --- a/meshroom/nodes/aliceVision/PanoramaEstimation.py +++ b/meshroom/nodes/aliceVision/PanoramaEstimation.py @@ -98,6 +98,21 @@ class PanoramaEstimation(desc.CommandLineNode): uid=[0], advanced=True, ), + desc.BoolParam( + name='rotationAveragingWeighting', + label='Rotation Averaging Weighting', + description='Rotation averaging weighting based on the number of feature matches.', + value=True, + uid=[0], + advanced=True, + ), + desc.BoolParam( + name='filterMatches', + label='Filter Matches', + description='Filter Matches', + value=False, + uid=[0], + ), desc.BoolParam( name='refine', label='Refine', diff --git a/meshroom/nodes/aliceVision/PanoramaInit.py b/meshroom/nodes/aliceVision/PanoramaInit.py index 740f791a11..c95fe6ff04 100644 --- a/meshroom/nodes/aliceVision/PanoramaInit.py +++ b/meshroom/nodes/aliceVision/PanoramaInit.py @@ -27,12 +27,45 @@ class PanoramaInit(desc.CommandLineNode): value='', uid=[0], ), + desc.ChoiceParam( + name='initializeCameras', + label='Initialize Cameras', + description='Initialize cameras.', + value='No', + values=['No', 'File', 'Horizontal', 'Horizontal+Zenith', 'Zenith+Horizontal', 'Spherical'], + exclusive=True, + uid=[0], + ), desc.File( name='config', label='Xml Config', description="XML Data File", value='', uid=[0], + enabled=lambda node: node.initializeCameras.value == 'File', + ), + desc.BoolParam( + name='yawCW', + label='Yaw CW', + description="Yaw ClockWise or CounterClockWise", + value=1, + uid=[0], + enabled=lambda node: ('Horizontal' in node.initializeCameras.value) or (node.initializeCameras.value == "Spherical"), + ), + desc.ListAttribute( + elementDesc=desc.IntParam( + name='nbViews', + label='', + description='', + value=-1, + range=[-1, 20], + uid=[0], + ), + name='nbViewsPerLine', + label='Spherical: Nb Views Per Line', + description='Number of views per line in Spherical acquisition. Assumes angles from [-90°,+90°] for pitch and [-180°,+180°] for yaw. Use -1 to estimate the number of images automatically.', + joinChar=',', + enabled=lambda node: node.initializeCameras.value == 'Spherical', ), desc.ListAttribute( elementDesc=desc.File( diff --git a/meshroom/ui/qml/Controls/KeyValue.qml b/meshroom/ui/qml/Controls/KeyValue.qml new file mode 100644 index 0000000000..0f0f10592e --- /dev/null +++ b/meshroom/ui/qml/Controls/KeyValue.qml @@ -0,0 +1,52 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 + +/** + * KeyValue allows to create a list of key/value, like a table. + */ +Rectangle { + property alias key: keyLabel.text + property alias value: valueText.text + + color: activePalette.window + + width: parent.width + height: childrenRect.height + + RowLayout { + width: parent.width + Rectangle { + anchors.margins: 2 + color: Qt.darker(activePalette.window, 1.1) + // Layout.preferredWidth: sizeHandle.x + Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize + Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize + Layout.fillWidth: false + Layout.fillHeight: true + Label { + id: keyLabel + text: "test" + anchors.fill: parent + anchors.top: parent.top + topPadding: 4 + leftPadding: 6 + verticalAlignment: TextEdit.AlignTop + elide: Text.ElideRight + } + } + TextArea { + id: valueText + text: "" + anchors.margins: 2 + Layout.fillWidth: true + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + textFormat: TextEdit.PlainText + + readOnly: true + selectByMouse: true + background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index a1fc1f6a87..c1e59083b1 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -3,6 +3,7 @@ module Controls ColorChart 1.0 ColorChart.qml FloatingPane 1.0 FloatingPane.qml Group 1.0 Group.qml +KeyValue 1.0 KeyValue.qml MessageDialog 1.0 MessageDialog.qml Panel 1.0 Panel.qml SearchBar 1.0 SearchBar.qml diff --git a/meshroom/ui/qml/GraphEditor/AttributePin.qml b/meshroom/ui/qml/GraphEditor/AttributePin.qml index d7db4cfb9c..705c3e5f4a 100755 --- a/meshroom/ui/qml/GraphEditor/AttributePin.qml +++ b/meshroom/ui/qml/GraphEditor/AttributePin.qml @@ -131,7 +131,7 @@ RowLayout { MouseArea { id: inputConnectMA // If an input attribute is connected (isLink), we disable drag&drop - drag.target: attribute.isLink ? undefined : inputDragTarget + drag.target: (attribute.isLink || attribute.isReadOnly) ? undefined : inputDragTarget drag.threshold: 0 enabled: !root.readOnly anchors.fill: parent diff --git a/meshroom/ui/qml/GraphEditor/ChunksListView.qml b/meshroom/ui/qml/GraphEditor/ChunksListView.qml index f0479726b2..f2ec5544e1 100644 --- a/meshroom/ui/qml/GraphEditor/ChunksListView.qml +++ b/meshroom/ui/qml/GraphEditor/ChunksListView.qml @@ -10,57 +10,87 @@ import "common.js" as Common /** * ChunkListView */ -ListView { - id: chunksLV +ColumnLayout { + id: root + property variant chunks + property int currentIndex: 0 + property variant currentChunk: (chunks && currentIndex >= 0) ? chunks.at(currentIndex) : undefined - // model: node.chunks + onChunksChanged: { + // When the list changes, ensure the current index is in the new range + if(currentIndex >= chunks.count) + currentIndex = chunks.count-1 + } - property variant currentChunk: currentItem ? currentItem.chunk : undefined + // chunksSummary is in sync with allChunks button (but not directly accessible as it is in a Component) + property bool chunksSummary: (currentIndex === -1) width: 60 - Layout.fillHeight: true - highlightFollowsCurrentItem: true - keyNavigationEnabled: true - focus: true - currentIndex: 0 - signal changeCurrentChunk(int chunkIndex) + ListView { + id: chunksLV + Layout.fillWidth: true + Layout.fillHeight: true - header: Component { - Label { - width: chunksLV.width - elide: Label.ElideRight - text: "Chunks" - padding: 4 - z: 10 - background: Rectangle { color: parent.palette.window } - } - } + model: root.chunks - highlight: Component { - Rectangle { - color: activePalette.highlight - opacity: 0.3 - z: 2 + highlightFollowsCurrentItem: (root.chunksSummary === false) + keyNavigationEnabled: true + focus: true + currentIndex: root.currentIndex + onCurrentIndexChanged: { + if(chunksLV.currentIndex !== root.currentIndex) + { + // When the list is resized, the currentIndex is reset to 0. + // So here we force it to keep the binding. + chunksLV.currentIndex = Qt.binding(function() { return root.currentIndex }) + } } - } - highlightMoveDuration: 0 - highlightResizeDuration: 0 - delegate: ItemDelegate { - id: chunkDelegate - property var chunk: object - text: index - width: parent.width - leftPadding: 8 - onClicked: { - chunksLV.forceActiveFocus() - chunksLV.changeCurrentChunk(index) + header: Component { + Button { + id: allChunks + text: "Chunks" + width: parent.width + flat: true + checkable: true + property bool summaryEnabled: root.chunksSummary + checked: summaryEnabled + onSummaryEnabledChanged: { + checked = summaryEnabled + } + onClicked: { + root.currentIndex = -1 + checked = true + } + } + } + highlight: Component { + Rectangle { + visible: true // !root.chunksSummary + color: activePalette.highlight + opacity: 0.3 + z: 2 + } } - Rectangle { - width: 4 - height: parent.height - color: Common.getChunkColor(parent.chunk) + highlightMoveDuration: 0 + highlightResizeDuration: 0 + + delegate: ItemDelegate { + id: chunkDelegate + property var chunk: object + text: index + width: parent.width + leftPadding: 8 + onClicked: { + chunksLV.forceActiveFocus() + root.currentIndex = index + } + Rectangle { + width: 4 + height: parent.height + color: Common.getChunkColor(parent.chunk) + } } } } diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index 9b6c2e11d9..897d580219 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -222,7 +222,7 @@ Item { id: edgeMenu property var currentEdge: null MenuItem { - enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked + enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked && !edgeMenu.currentEdge.dst.isReadOnly text: "Remove" onTriggered: uigraph.removeEdge(edgeMenu.currentEdge) } diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index 2825dbbce7..be2a7cf051 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -270,8 +270,6 @@ Item { height: childrenRect.height anchors.horizontalCenter: parent.horizontalCenter - enabled: !root.isCompatibilityNode - Column { id: attributesColumn width: parent.width @@ -311,6 +309,7 @@ Item { id: inputs width: parent.width spacing: 3 + Repeater { model: node ? node.attributes : undefined delegate: Loader { @@ -326,7 +325,7 @@ Item { property real globalX: root.x + nodeAttributes.x + inputs.x + inputLoader.x + inPin.x property real globalY: root.y + nodeAttributes.y + inputs.y + inputLoader.y + inPin.y - readOnly: root.readOnly + readOnly: root.readOnly || object.isReadOnly Component.onCompleted: attributePinCreated(attribute, inPin) Component.onDestruction: attributePinDeleted(attribute, inPin) onPressed: root.pressed(mouse) @@ -387,7 +386,7 @@ Item { Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } visible: (height == childrenRect.height) attribute: object - readOnly: root.readOnly + readOnly: root.readOnly || object.isReadOnly Component.onCompleted: attributePinCreated(attribute, inPin) Component.onDestruction: attributePinDeleted(attribute, inPin) onPressed: root.pressed(mouse) diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index ba3e33bf61..42c361db8a 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -1,5 +1,6 @@ import QtQuick 2.9 import QtQuick.Controls 2.4 +import QtQuick.Controls 1.4 as Controls1 // SplitView import QtQuick.Layouts 1.3 import MaterialIcons 2.2 import Controls 1.0 @@ -19,15 +20,6 @@ Panel { signal attributeDoubleClicked(var mouse, var attribute) signal upgradeRequest() - Item { - id: m - property int chunkCurrentIndex: 0 - } - - onNodeChanged: { - m.chunkCurrentIndex = 0 // Needed to avoid invalid state of ChunksListView - } - title: "Node" + (node !== null ? " - " + node.label + "" : "") icon: MaterialLabel { text: MaterialIcons.tune } @@ -114,7 +106,16 @@ Panel { Component { id: editor_component - ColumnLayout { + Controls1.SplitView { + anchors.fill: parent + + // The list of chunks + ChunksListView { + id: chunksLV + visible: (tabBar.currentIndex >= 1 && tabBar.currentIndex <= 3) + chunks: root.node.chunks + } + StackLayout { Layout.fillHeight: true Layout.fillWidth: true @@ -122,35 +123,65 @@ Panel { currentIndex: tabBar.currentIndex AttributeEditor { + Layout.fillHeight: true + Layout.fillWidth: true model: root.node.attributes readOnly: root.readOnly || root.isCompatibilityNode onAttributeDoubleClicked: root.attributeDoubleClicked(mouse, attribute) onUpgradeRequest: root.upgradeRequest() } - NodeLog { - id: nodeLog - node: root.node - chunkCurrentIndex: m.chunkCurrentIndex - onChangeCurrentChunk: { m.chunkCurrentIndex = chunkIndex } + Loader { + active: (tabBar.currentIndex === 1) + Layout.fillHeight: true + Layout.fillWidth: true + sourceComponent: NodeLog { + // anchors.fill: parent + Layout.fillHeight: true + Layout.fillWidth: true + width: parent.width + height: parent.height + id: nodeLog + node: root.node + currentChunkIndex: chunksLV.currentIndex + currentChunk: chunksLV.currentChunk + } } - NodeStatistics { - id: nodeStatistics - node: root.node - chunkCurrentIndex: m.chunkCurrentIndex - onChangeCurrentChunk: { m.chunkCurrentIndex = chunkIndex } + Loader { + active: (tabBar.currentIndex === 2) + Layout.fillHeight: true + Layout.fillWidth: true + sourceComponent: NodeStatistics { + id: nodeStatistics + + Layout.fillHeight: true + Layout.fillWidth: true + node: root.node + currentChunkIndex: chunksLV.currentIndex + currentChunk: chunksLV.currentChunk + } } - NodeStatus { - id: nodeStatus - node: root.node - chunkCurrentIndex: m.chunkCurrentIndex - onChangeCurrentChunk: { m.chunkCurrentIndex = chunkIndex } + Loader { + active: (tabBar.currentIndex === 3) + Layout.fillHeight: true + Layout.fillWidth: true + sourceComponent: NodeStatus { + id: nodeStatus + + Layout.fillHeight: true + Layout.fillWidth: true + node: root.node + currentChunkIndex: chunksLV.currentIndex + currentChunk: chunksLV.currentChunk + } } NodeDocumentation { id: nodeDocumentation + + Layout.fillHeight: true Layout.fillWidth: true node: root.node } diff --git a/meshroom/ui/qml/GraphEditor/NodeLog.qml b/meshroom/ui/qml/GraphEditor/NodeLog.qml index bea42622aa..941d5099f9 100644 --- a/meshroom/ui/qml/GraphEditor/NodeLog.qml +++ b/meshroom/ui/qml/GraphEditor/NodeLog.qml @@ -1,6 +1,5 @@ import QtQuick 2.11 import QtQuick.Controls 2.3 -import QtQuick.Controls 1.4 as Controls1 // SplitView import QtQuick.Layouts 1.3 import MaterialIcons 2.2 import Controls 1.0 @@ -16,53 +15,34 @@ import "common.js" as Common FocusScope { id: root property variant node - property alias chunkCurrentIndex: chunksLV.currentIndex - signal changeCurrentChunk(int chunkIndex) + property int currentChunkIndex + property variant currentChunk + + Layout.fillWidth: true + Layout.fillHeight: true SystemPalette { id: activePalette } - Controls1.SplitView { + Loader { + id: componentLoader + clip: true anchors.fill: parent - // The list of chunks - ChunksListView { - id: chunksLV - Layout.fillHeight: true - model: node.chunks - onChangeCurrentChunk: root.changeCurrentChunk(chunkIndex) - } - - Loader { - id: componentLoader - clip: true - Layout.fillWidth: true - Layout.fillHeight: true - property url source - - property string currentFile: chunksLV.currentChunk ? chunksLV.currentChunk["logFile"] : "" - onCurrentFileChanged: { - // only set text file viewer source when ListView is fully ready - // (either empty or fully populated with a valid currentChunk) - // to avoid going through an empty url when switching between two nodes + property string currentFile: (root.currentChunkIndex >= 0 && root.currentChunk) ? root.currentChunk["logFile"] : "" + property url source: Filepath.stringToUrl(currentFile) - if(!chunksLV.count || chunksLV.currentChunk) - componentLoader.source = Filepath.stringToUrl(currentFile); - - } + sourceComponent: textFileViewerComponent + } - sourceComponent: textFileViewerComponent - } + Component { + id: textFileViewerComponent - Component { - id: textFileViewerComponent - TextFileViewer { - id: textFileViewer - source: componentLoader.source - Layout.fillWidth: true - Layout.fillHeight: true - autoReload: chunksLV.currentChunk !== undefined && chunksLV.currentChunk.statusName === "RUNNING" - // source is set in fileSelector - } + TextFileViewer { + id: textFileViewer + anchors.fill: parent + source: componentLoader.source + autoReload: root.currentChunk !== undefined && root.currentChunk.statusName === "RUNNING" + // source is set in fileSelector } } } diff --git a/meshroom/ui/qml/GraphEditor/NodeStatistics.qml b/meshroom/ui/qml/GraphEditor/NodeStatistics.qml index 5e4ec3e80a..ee943693a3 100644 --- a/meshroom/ui/qml/GraphEditor/NodeStatistics.qml +++ b/meshroom/ui/qml/GraphEditor/NodeStatistics.qml @@ -4,6 +4,7 @@ import QtQuick.Controls 1.4 as Controls1 // SplitView import QtQuick.Layouts 1.3 import MaterialIcons 2.2 import Controls 1.0 +import Utils 1.0 import "common.js" as Common @@ -15,50 +16,46 @@ import "common.js" as Common */ FocusScope { id: root + property variant node - property alias chunkCurrentIndex: chunksLV.currentIndex - signal changeCurrentChunk(int chunkIndex) + property variant currentChunkIndex + property variant currentChunk SystemPalette { id: activePalette } - Controls1.SplitView { + Loader { + id: componentLoader + clip: true anchors.fill: parent + property string currentFile: currentChunk ? currentChunk["statisticsFile"] : "" + property url source: Filepath.stringToUrl(currentFile) - // The list of chunks - ChunksListView { - id: chunksLV - Layout.fillHeight: true - model: node.chunks - onChangeCurrentChunk: root.changeCurrentChunk(chunkIndex) - } + sourceComponent: chunksLV.chunksSummary ? statViewerComponent : chunkStatViewerComponent + } - Loader { - id: componentLoader - clip: true - Layout.fillWidth: true - Layout.fillHeight: true - property url source + Component { + id: chunkStatViewerComponent + StatViewer { + id: statViewer + anchors.fill: parent + source: componentLoader.source + } + } - property string currentFile: chunksLV.currentChunk ? chunksLV.currentChunk["statisticsFile"] : "" - onCurrentFileChanged: { - // only set text file viewer source when ListView is fully ready - // (either empty or fully populated with a valid currentChunk) - // to avoid going through an empty url when switching between two nodes + Component { + id: statViewerComponent - if(!chunksLV.count || chunksLV.currentChunk) - componentLoader.source = Filepath.stringToUrl(currentFile); + Column { + spacing: 2 + KeyValue { + key: "Time" + property real time: node.elapsedTime + value: time > 0.0 ? Format.sec2time(time) : "-" } - - sourceComponent: statViewerComponent - } - - Component { - id: statViewerComponent - StatViewer { - id: statViewer - Layout.fillWidth: true - Layout.fillHeight: true - source: componentLoader.source + KeyValue { + key: "Cumulated Time" + property real time: node.recursiveElapsedTime + value: time > 0.0 ? Format.sec2time(time) : "-" } } } diff --git a/meshroom/ui/qml/GraphEditor/NodeStatus.qml b/meshroom/ui/qml/GraphEditor/NodeStatus.qml index 063bcae583..8de87fbc3e 100644 --- a/meshroom/ui/qml/GraphEditor/NodeStatus.qml +++ b/meshroom/ui/qml/GraphEditor/NodeStatus.qml @@ -1,6 +1,5 @@ import QtQuick 2.11 import QtQuick.Controls 2.3 -import QtQuick.Controls 1.4 as Controls1 // SplitView import QtQuick.Layouts 1.3 import MaterialIcons 2.2 import Controls 1.0 @@ -16,169 +15,148 @@ import "common.js" as Common FocusScope { id: root property variant node - property alias chunkCurrentIndex: chunksLV.currentIndex - signal changeCurrentChunk(int chunkIndex) + property variant currentChunkIndex + property variant currentChunk SystemPalette { id: activePalette } - Controls1.SplitView { + Loader { + id: componentLoader + clip: true anchors.fill: parent - // The list of chunks - ChunksListView { - id: chunksLV - Layout.fillHeight: true - model: node.chunks - onChangeCurrentChunk: root.changeCurrentChunk(chunkIndex) - } + property string currentFile: (root.currentChunkIndex >= 0) ? root.currentChunk["statusFile"] : "" + property url source: Filepath.stringToUrl(currentFile) + + sourceComponent: statViewerComponent + } - Loader { - id: componentLoader - clip: true - Layout.fillWidth: true - Layout.fillHeight: true - property url source - - property string currentFile: chunksLV.currentChunk ? chunksLV.currentChunk["statusFile"] : "" - onCurrentFileChanged: { - // only set text file viewer source when ListView is fully ready - // (either empty or fully populated with a valid currentChunk) - // to avoid going through an empty url when switching between two nodes - - if(!chunksLV.count || chunksLV.currentChunk) - componentLoader.source = Filepath.stringToUrl(currentFile); + Component { + id: statViewerComponent + Item { + id: statusViewer + property url source: componentLoader.source + property var lastModified: undefined + + onSourceChanged: { + statusListModel.readSourceFile() } - sourceComponent: statViewerComponent - } + ListModel { + id: statusListModel - Component { - id: statViewerComponent - Item { - id: statusViewer - property url source: componentLoader.source - property var lastModified: undefined + function readSourceFile() { + // make sure we are trying to load a statistics file + if(!Filepath.urlToString(source).endsWith("status")) + return; - onSourceChanged: { - statusListModel.readSourceFile() - } + var xhr = new XMLHttpRequest; + xhr.open("GET", source); - ListModel { - id: statusListModel - - function readSourceFile() { - // make sure we are trying to load a statistics file - if(!Filepath.urlToString(source).endsWith("status")) - return; - - var xhr = new XMLHttpRequest; - xhr.open("GET", source); - - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { - // console.warn("StatusListModel: read valid file") - if(lastModified === undefined || lastModified !== xhr.getResponseHeader('Last-Modified')) { - lastModified = xhr.getResponseHeader('Last-Modified') - try { - var jsonObject = JSON.parse(xhr.responseText); - - var entries = []; - // prepare data to populate the ListModel from the input json object - for(var key in jsonObject) - { - var entry = {}; - entry["key"] = key; - entry["value"] = String(jsonObject[key]); - entries.push(entry); - } - // reset the model with prepared data (limit to one update event) - statusListModel.clear(); - statusListModel.append(entries); - } - catch(exc) + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { + // console.warn("StatusListModel: read valid file") + if(lastModified === undefined || lastModified !== xhr.getResponseHeader('Last-Modified')) { + lastModified = xhr.getResponseHeader('Last-Modified') + try { + var jsonObject = JSON.parse(xhr.responseText); + + var entries = []; + // prepare data to populate the ListModel from the input json object + for(var key in jsonObject) { - // console.warn("StatusListModel: failed to read file") - lastModified = undefined; - statusListModel.clear(); + var entry = {}; + entry["key"] = key; + entry["value"] = String(jsonObject[key]); + entries.push(entry); } + // reset the model with prepared data (limit to one update event) + statusListModel.clear(); + statusListModel.append(entries); + } + catch(exc) + { + // console.warn("StatusListModel: failed to read file") + lastModified = undefined; + statusListModel.clear(); } } - else - { - // console.warn("StatusListModel: invalid file") - lastModified = undefined; - statusListModel.clear(); - } - }; - xhr.send(); - } + } + else + { + // console.warn("StatusListModel: invalid file") + lastModified = undefined; + statusListModel.clear(); + } + }; + xhr.send(); } + } - ListView { - id: statusListView - anchors.fill: parent - spacing: 3 - model: statusListModel - - delegate: Rectangle { - color: activePalette.window + ListView { + id: statusListView + anchors.fill: parent + spacing: 3 + model: statusListModel + + delegate: Rectangle { + color: activePalette.window + width: parent.width + height: childrenRect.height + RowLayout { width: parent.width - height: childrenRect.height - RowLayout { - width: parent.width - Rectangle { - id: statusKey - anchors.margins: 2 - // height: statusValue.height - color: Qt.darker(activePalette.window, 1.1) - Layout.preferredWidth: sizeHandle.x - Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize - Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize - Layout.fillWidth: false - Layout.fillHeight: true - Label { - text: key - anchors.fill: parent - anchors.top: parent.top - topPadding: 4 - leftPadding: 6 - verticalAlignment: TextEdit.AlignTop - elide: Text.ElideRight - } - } - TextArea { - id: statusValue - text: value - anchors.margins: 2 - Layout.fillWidth: true - wrapMode: Label.WrapAtWordBoundaryOrAnywhere - textFormat: TextEdit.PlainText - - readOnly: true - selectByMouse: true - background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) } + Rectangle { + id: statusKey + anchors.margins: 2 + // height: statusValue.height + color: Qt.darker(activePalette.window, 1.1) + Layout.preferredWidth: sizeHandle.x + Layout.minimumWidth: 10.0 * Qt.application.font.pixelSize + Layout.maximumWidth: 15.0 * Qt.application.font.pixelSize + Layout.fillWidth: false + Layout.fillHeight: true + Label { + text: key + anchors.fill: parent + anchors.top: parent.top + topPadding: 4 + leftPadding: 6 + verticalAlignment: TextEdit.AlignTop + elide: Text.ElideRight } } + TextArea { + id: statusValue + text: value + anchors.margins: 2 + Layout.fillWidth: true + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + textFormat: TextEdit.PlainText + + readOnly: true + selectByMouse: true + background: Rectangle { anchors.fill: parent; color: Qt.darker(activePalette.window, 1.05) } + } } } + } - // Categories resize handle - Rectangle { - id: sizeHandle - height: parent.contentHeight - width: 1 - x: parent.width * 0.2 - MouseArea { - anchors.fill: parent - anchors.margins: -4 - cursorShape: Qt.SizeHorCursor - drag { - target: parent - axis: Drag.XAxis - threshold: 0 - minimumX: statusListView.width * 0.2 - maximumX: statusListView.width * 0.8 - } + // Categories resize handle + Rectangle { + id: sizeHandle + height: parent.contentHeight + width: 1 + x: parent.width * 0.2 + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.SizeHorCursor + drag { + target: parent + axis: Drag.XAxis + threshold: 0 + minimumX: statusListView.width * 0.2 + maximumX: statusListView.width * 0.8 } } } diff --git a/meshroom/ui/qml/GraphEditor/StatViewer.qml b/meshroom/ui/qml/GraphEditor/StatViewer.qml index 3923ef4013..8db23ddce0 100644 --- a/meshroom/ui/qml/GraphEditor/StatViewer.qml +++ b/meshroom/ui/qml/GraphEditor/StatViewer.qml @@ -257,12 +257,14 @@ Item { var gpuUsedMemorySerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Memory", valueGpuX, valueGpuY) var gpuTemperatureSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Temperature", valueGpuX, valueGpuY) + var gpuMemoryRatio = root.gpuTotalMemory > 0 ? (100 / root.gpuTotalMemory) : 1; + if(gpuUsedMemory.length === 1) { gpuUsedSerie.append(0, gpuUsed[0]) gpuUsedSerie.append(1 * root.deltaTime, gpuUsed[0]) - gpuUsedMemorySerie.append(0, gpuUsedMemory[0] / root.gpuTotalMemory * 100) - gpuUsedMemorySerie.append(1 * root.deltaTime, gpuUsedMemory[0] / root.gpuTotalMemory * 100) + gpuUsedMemorySerie.append(0, gpuUsedMemory[0] * gpuMemoryRatio) + gpuUsedMemorySerie.append(1 * root.deltaTime, gpuUsedMemory[0] * gpuMemoryRatio) gpuTemperatureSerie.append(0, gpuTemperature[0]) gpuTemperatureSerie.append(1 * root.deltaTime, gpuTemperature[0]) @@ -271,7 +273,7 @@ Item { for(var i = 0; i < gpuUsedMemory.length; i++) { gpuUsedSerie.append(i * root.deltaTime, gpuUsed[i]) - gpuUsedMemorySerie.append(i * root.deltaTime, gpuUsedMemory[i] / root.gpuTotalMemory * 100) + gpuUsedMemorySerie.append(i * root.deltaTime, gpuUsedMemory[i] * gpuMemoryRatio) gpuTemperatureSerie.append(i * root.deltaTime, gpuTemperature[i]) root.gpuMaxAxis = Math.max(gpuMaxAxis, gpuTemperature[i]) diff --git a/meshroom/ui/qml/Utils/format.js b/meshroom/ui/qml/Utils/format.js index 72b732e08d..c439c0b1d4 100644 --- a/meshroom/ui/qml/Utils/format.js +++ b/meshroom/ui/qml/Utils/format.js @@ -13,3 +13,12 @@ function plainToHtml(t) { var escaped = t.replace(/&/g, '&').replace(//g, '>'); // escape text return escaped.replace(/\n/g, '
'); // replace line breaks } + +function sec2time(time) { + var pad = function(num, size) { return ('000' + num).slice(size * -1); }, + hours = Math.floor(time / 60 / 60), + minutes = Math.floor(time / 60) % 60, + seconds = Math.floor(time - minutes * 60); + + return pad(hours, 2) + ':' + pad(minutes, 2) + ':' + pad(seconds, 2) +} diff --git a/meshroom/ui/qml/Viewer3D/AlembicLoader.qml b/meshroom/ui/qml/Viewer3D/AlembicLoader.qml index 641b3c2e2b..9b8bbf5f33 100644 --- a/meshroom/ui/qml/Viewer3D/AlembicLoader.qml +++ b/meshroom/ui/qml/Viewer3D/AlembicLoader.qml @@ -46,13 +46,25 @@ AlembicEntity { // Qt 5.13: binding cameraPicker.enabled to cameraPickerEnabled // causes rendering issues when entity gets disabled. // set CuboidMesh extent to 0 to disable picking. + property color customColor: Qt.hsva((parseInt(viewId) / 255.0) % 1.0, 0.3, 1.0, 1.0) property real extent: cameraPickingEnabled ? 0.2 : 0 components: [ - CuboidMesh { xExtent: parent.extent; yExtent: xExtent; zExtent: xExtent }, + // Use cuboid to represent the camera + Transform { + translation: Qt.vector3d(0, 0, 0.5 * cameraBack.zExtent) + }, + CuboidMesh { id: cameraBack; xExtent: parent.extent; yExtent: xExtent; zExtent: xExtent * 0.2 }, + /* + // Use a stick to represent the camera + Transform { + translation: Qt.vector3d(0, 0, 0.5 * cameraStick.zExtent) + }, + CuboidMesh { id: cameraStick; xExtent: parent.extent * 0.2; yExtent: xExtent; zExtent: xExtent * 50.0 }, + */ PhongMaterial{ id: mat - ambient: viewId === _reconstruction.selectedViewId ? activePalette.highlight : "#CCC" + ambient: viewId === _reconstruction.selectedViewId ? activePalette.highlight : customColor // "#CCC" diffuse: cameraPicker.containsMouse ? Qt.lighter(activePalette.highlight, 1.2) : ambient }, ObjectPicker { diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index 54e99198bc..c37ffafc67 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -570,7 +570,7 @@ def getViewpoints(self): return self._cameraInit.viewpoints.value if self._cameraInit else QObjectListModel(parent=self) def updateCameraInits(self): - cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) + cameraInits = self._graph.nodesOfType("CameraInit", sortedByIndex=True) if set(self._cameraInits.objectList()) == set(cameraInits): return self._cameraInits.setObjectList(cameraInits) @@ -739,9 +739,10 @@ def handleFilesDrop(self, drop, cameraInit): "", )) else: - panoramaInitNodes = self.graph.nodesByType('PanoramaInit') + panoramaInitNodes = self.graph.nodesOfType('PanoramaInit') for panoramaInfoFile in filesByType.panoramaInfo: for panoramaInitNode in panoramaInitNodes: + panoramaInitNode.attribute('initializeCameras').value = 'File' panoramaInitNode.attribute('config').value = panoramaInfoFile if panoramaInitNodes: self.info.emit( diff --git a/tests/test_graph.py b/tests/test_graph.py index ed92447524..87d52c467a 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -237,7 +237,7 @@ def test_graph_nodes_sorting(): ls1 = graph.addNewNode('Ls') ls2 = graph.addNewNode('Ls') - assert graph.nodesByType('Ls', sortedByIndex=True) == [ls0, ls1, ls2] + assert graph.nodesOfType('Ls', sortedByIndex=True) == [ls0, ls1, ls2] graph = Graph('') # 'Random' creation order (what happens when loading a file) @@ -245,7 +245,7 @@ def test_graph_nodes_sorting(): ls0 = graph.addNewNode('Ls', name='Ls_0') ls1 = graph.addNewNode('Ls', name='Ls_1') - assert graph.nodesByType('Ls', sortedByIndex=True) == [ls0, ls1, ls2] + assert graph.nodesOfType('Ls', sortedByIndex=True) == [ls0, ls1, ls2] def test_duplicate_nodes():