diff --git a/.gitignore b/.gitignore index 2ec5af2b9b..a66c1086a7 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ run.sh CuraEngine /.coverage + +#Prevents import failures when plugin running tests +plugins/__init__.py diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index ed211ed7b4..6bcc5988f9 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -64,7 +64,7 @@ class BuildVolume(SceneNode): self._origin_mesh = None # type: Optional[MeshData] self._origin_line_length = 20 - self._origin_line_width = 0.5 + self._origin_line_width = 1.5 self._grid_mesh = None # type: Optional[MeshData] self._grid_shader = None diff --git a/cura/Machines/Models/DiscoveredPrintersModel.py b/cura/Machines/Models/DiscoveredPrintersModel.py index 803e1d21ba..33e0b7a4d9 100644 --- a/cura/Machines/Models/DiscoveredPrintersModel.py +++ b/cura/Machines/Models/DiscoveredPrintersModel.py @@ -75,7 +75,7 @@ class DiscoveredPrinter(QObject): def readableMachineType(self) -> str: from cura.CuraApplication import CuraApplication machine_manager = CuraApplication.getInstance().getMachineManager() - # In ClusterUM3OutputDevice, when it updates a printer information, it updates the machine type using the field + # In LocalClusterOutputDevice, when it updates a printer information, it updates the machine type using the field # "machine_variant", and for some reason, it's not the machine type ID/codename/... but a human-readable string # like "Ultimaker 3". The code below handles this case. if self._hasHumanReadableMachineTypeName(self._machine_type): diff --git a/cura/Settings/GlobalStack.py b/cura/Settings/GlobalStack.py index 3502088e2d..7efc263180 100755 --- a/cura/Settings/GlobalStack.py +++ b/cura/Settings/GlobalStack.py @@ -118,7 +118,7 @@ class GlobalStack(CuraContainerStack): ## \sa configuredConnectionTypes def removeConfiguredConnectionType(self, connection_type: int) -> None: configured_connection_types = self.configuredConnectionTypes - if connection_type in self.configured_connection_types: + if connection_type in configured_connection_types: # Store the values as a string. configured_connection_types.remove(connection_type) self.setMetaDataEntry("connection_type", ",".join([str(c_type) for c_type in configured_connection_types])) diff --git a/plugins/UM3NetworkPrinting/__init__.py b/plugins/UM3NetworkPrinting/__init__.py index 3da7795589..bd916f06fc 100644 --- a/plugins/UM3NetworkPrinting/__init__.py +++ b/plugins/UM3NetworkPrinting/__init__.py @@ -1,6 +1,5 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from .src import DiscoverUM3Action from .src import UM3OutputDevicePlugin @@ -10,6 +9,5 @@ def getMetaData(): def register(app): return { - "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin(), - "machine_action": DiscoverUM3Action.DiscoverUM3Action() + "output_device": UM3OutputDevicePlugin.UM3OutputDevicePlugin() } diff --git a/plugins/UM3NetworkPrinting/plugin.json b/plugins/UM3NetworkPrinting/plugin.json index 088b4dae6a..894fc41815 100644 --- a/plugins/UM3NetworkPrinting/plugin.json +++ b/plugins/UM3NetworkPrinting/plugin.json @@ -1,7 +1,7 @@ { - "name": "UM3 Network Connection", + "name": "Ultimaker Network Connection", "author": "Ultimaker B.V.", - "description": "Manages network connections to Ultimaker 3 printers.", + "description": "Manages network connections to Ultimaker networked printers.", "version": "1.0.1", "api": "6.0", "i18n-catalog": "cura" diff --git a/plugins/UM3NetworkPrinting/resources/qml/CameraButton.qml b/plugins/UM3NetworkPrinting/resources/qml/CameraButton.qml index c0369cac0b..2e3d17ceb0 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/CameraButton.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/CameraButton.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 @@ -53,4 +53,4 @@ Rectangle } } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/ExpandableCard.qml b/plugins/UM3NetworkPrinting/resources/qml/ExpandableCard.qml index fae8280488..5257361367 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/ExpandableCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/ExpandableCard.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -87,4 +87,4 @@ Item } } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/GenericPopUp.qml b/plugins/UM3NetworkPrinting/resources/qml/GenericPopUp.qml index 74d9377f3e..61981dab2c 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/GenericPopUp.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/GenericPopUp.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml index 619658a7eb..5d08422877 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorBuildplateConfiguration.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -35,7 +35,7 @@ Item { height: parent.height width: 32 * screenScaleFactor // Ensure the icon is centered under the extruder icon (same width) - + Rectangle { anchors.centerIn: parent @@ -56,7 +56,7 @@ Item visible: buildplate } } - + Label { id: buildplateLabel @@ -72,4 +72,4 @@ Item renderType: Text.NativeRendering } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorCarousel.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorCarousel.qml index 0d7a177dd3..08743ed777 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorCarousel.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorCarousel.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 @@ -23,7 +23,7 @@ Item height: centerSection.height width: maximumWidth - + // Enable keyboard navigation Keys.onLeftPressed: navigateTo(currentIndex - 1) Keys.onRightPressed: navigateTo(currentIndex + 1) @@ -131,7 +131,7 @@ Item } } spacing: 60 * screenScaleFactor // TODO: Theme! - + Repeater { model: printers @@ -255,4 +255,4 @@ Item currentIndex = i } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorConfigOverrideDialog.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorConfigOverrideDialog.qml index f0afb1fcaa..1fe766d9f7 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorConfigOverrideDialog.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorConfigOverrideDialog.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml index fbae66117e..34ca3c6df2 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenuButton.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenuButton.qml index e91e8b04d2..aa5d6de89b 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenuButton.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenuButton.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 @@ -29,4 +29,4 @@ Button hoverEnabled: enabled text: "\u22EE" //Unicode Three stacked points. width: 36 * screenScaleFactor // TODO: Theme! -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml index deed3ac5e6..63caaab433 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorExtruderConfiguration.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -56,7 +56,7 @@ Item Label { id: materialLabel - + color: UM.Theme.getColor("monitor_text_primary") elide: Text.ElideRight font: UM.Theme.getFont("default") // 12pt, regular @@ -86,7 +86,7 @@ Item Label { id: printCoreLabel - + color: UM.Theme.getColor("monitor_text_primary") elide: Text.ElideRight font: UM.Theme.getFont("default_bold") // 12pt, bold @@ -99,4 +99,4 @@ Item renderType: Text.NativeRendering } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorIconExtruder.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorIconExtruder.qml index f6b84d69b2..876215d65d 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorIconExtruder.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorIconExtruder.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -50,4 +50,4 @@ Item visible: position >= 0 renderType: Text.NativeRendering } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorInfoBlurb.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorInfoBlurb.qml index 0d2c7f8beb..32e19c1cdb 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorInfoBlurb.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorInfoBlurb.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorItem.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorItem.qml index 41b3a93a7b..1ac72b8f8e 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorItem.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorItem.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -42,4 +42,4 @@ Component { z: 1; } } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml index ea6da9c25d..14e95559ec 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml @@ -1,6 +1,5 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. - import QtQuick 2.2 import QtQuick.Controls 2.0 import UM 1.3 as UM @@ -76,6 +75,7 @@ Item anchors.verticalCenter: parent.verticalCenter height: 18 * screenScaleFactor // TODO: Theme! width: UM.Theme.getSize("monitor_column").width + Rectangle { color: UM.Theme.getColor("monitor_skeleton_loading") @@ -84,6 +84,7 @@ Item visible: !printJob radius: 2 * screenScaleFactor // TODO: Theme! } + Label { text: printJob ? OutputDevice.formatDuration(printJob.timeTotal) : "" @@ -179,13 +180,10 @@ Item id: printerConfiguration anchors.verticalCenter: parent.verticalCenter buildplate: catalog.i18nc("@label", "Glass") - configurations: - [ - base.printJob.configuration.extruderConfigurations[0], - base.printJob.configuration.extruderConfigurations[1] - ] + configurations: base.printJob.configuration.extruderConfigurations height: 72 * screenScaleFactor // TODO: Theme! } + Label { text: printJob && printJob.owner ? printJob.owner : "" color: UM.Theme.getColor("monitor_text_primary") diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobPreview.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobPreview.qml index a392571757..7492b4e8e4 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobPreview.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobPreview.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -41,7 +41,7 @@ Item UM.RecolorImage { id: ultiBotImage - + anchors.centerIn: printJobPreview color: UM.Theme.getColor("monitor_placeholder_image") height: printJobPreview.height @@ -98,4 +98,4 @@ Item visible: source != "" width: 0.5 * printJobPreview.width } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobProgressBar.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobProgressBar.qml index e6d09b68f6..48bab48a9f 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobProgressBar.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobProgressBar.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 @@ -107,4 +107,4 @@ Item verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 8562cec59d..e2b22312cd 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.3 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterConfiguration.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterConfiguration.qml index dbe085e18e..21d08a310c 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterConfiguration.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterConfiguration.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -55,4 +55,4 @@ Item anchors.bottom: parent.bottom buildplate: null } -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterPill.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterPill.qml index 3123631784..44aa1a1f8d 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterPill.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterPill.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml index 6727c7bd8c..b70759454a 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -186,17 +186,7 @@ Item } printJob: modelData } - model: - { - // When printing over the cloud we don't recieve print jobs until there is one, so - // unless there's at least one print job we'll be stuck with skeleton loading - // indefinitely. - if (Cura.MachineManager.activeMachineIsUsingCloudConnection || OutputDevice.receivedPrintJobs) - { - return OutputDevice.queuedPrintJobs - } - return [null, null] - } + model: OutputDevice.queuedPrintJobs spacing: 6 // TODO: Theme! } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml index e68418c21a..58e4263d2d 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -25,7 +25,7 @@ Component } width: maximumWidth color: UM.Theme.getColor("monitor_stage_background") - + // Enable keyboard navigation. NOTE: This is done here so that we can also potentially // forward to the queue items in the future. (Deleting selected print job, etc.) Keys.forwardTo: carousel @@ -50,17 +50,7 @@ Component MonitorCarousel { id: carousel - printers: - { - // When printing over the cloud we don't recieve print jobs until there is one, so - // unless there's at least one print job we'll be stuck with skeleton loading - // indefinitely. - if (Cura.MachineManager.activeMachineIsUsingCloudConnection || OutputDevice.receivedPrintJobs) - { - return OutputDevice.printers - } - return [null] - } + printers: OutputDevice.printers } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/PrintJobContextMenuItem.qml b/plugins/UM3NetworkPrinting/resources/qml/PrintJobContextMenuItem.qml index ff5635e45d..78b94ce259 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/PrintJobContextMenuItem.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/PrintJobContextMenuItem.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -21,4 +21,4 @@ Button { height: visible ? 39 * screenScaleFactor : 0; // TODO: Theme! hoverEnabled: true; width: parent.width; -} \ No newline at end of file +} diff --git a/plugins/UM3NetworkPrinting/resources/qml/PrintWindow.qml b/plugins/UM3NetworkPrinting/resources/qml/PrintWindow.qml index 548e5ce1ea..bcba60352c 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/PrintWindow.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/PrintWindow.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 diff --git a/plugins/UM3NetworkPrinting/resources/qml/PrinterVideoStream.qml b/plugins/UM3NetworkPrinting/resources/qml/PrinterVideoStream.qml index 77b481f6d8..cfbb30fdfb 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/PrinterVideoStream.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/PrinterVideoStream.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 diff --git a/plugins/UM3NetworkPrinting/resources/qml/UM3InfoComponents.qml b/plugins/UM3NetworkPrinting/resources/qml/UM3InfoComponents.qml index c99ed1688e..1da7c12e29 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/UM3InfoComponents.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/UM3InfoComponents.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2018 Ultimaker B.V. +// Copyright (c) 2019 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 30bdd8e774..06cabdc463 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import json from json import JSONDecodeError @@ -11,18 +11,19 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from UM.Logger import Logger from cura import UltimakerCloudAuthentication from cura.API import Account + from .ToolPathUploader import ToolPathUploader -from ..Models import BaseModel -from .Models.CloudClusterResponse import CloudClusterResponse -from .Models.CloudError import CloudError -from .Models.CloudClusterStatus import CloudClusterStatus -from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest -from .Models.CloudPrintResponse import CloudPrintResponse -from .Models.CloudPrintJobResponse import CloudPrintJobResponse +from ..Models.BaseModel import BaseModel +from ..Models.Http.CloudClusterResponse import CloudClusterResponse +from ..Models.Http.CloudError import CloudError +from ..Models.Http.CloudClusterStatus import CloudClusterStatus +from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest +from ..Models.Http.CloudPrintResponse import CloudPrintResponse +from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse ## The generic type variable used to document the methods below. -CloudApiClientModel = TypeVar("CloudApiClientModel", bound = BaseModel) +CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel) ## The cloud API client is responsible for handling the requests and responses from the cloud. @@ -69,8 +70,8 @@ class CloudApiClient: ## Requests the cloud to register the upload of a print job mesh. # \param request: The request object. # \param on_finished: The function to be called after the result is parsed. - def requestUpload(self, request: CloudPrintJobUploadRequest, on_finished: Callable[[CloudPrintJobResponse], Any] - ) -> None: + def requestUpload(self, request: CloudPrintJobUploadRequest, + on_finished: Callable[[CloudPrintJobResponse], Any]) -> None: url = "{}/jobs/upload".format(self.CURA_API_ROOT) body = json.dumps({"data": request.toDict()}) reply = self._manager.put(self._createEmptyRequest(url), body.encode()) @@ -100,14 +101,9 @@ class CloudApiClient: # \param cluster_id: The ID of the cluster. # \param cluster_job_id: The ID of the print job within the cluster. # \param action: The name of the action to execute. - def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str, data: Optional[Dict[str, Any]] = None) -> None: - body = b"" - if data: - try: - body = json.dumps({"data": data}).encode() - except JSONDecodeError as err: - Logger.log("w", "Could not encode body: %s", err) - return + def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str, + data: Optional[Dict[str, Any]] = None) -> None: + body = json.dumps({"data": data}).encode() if data else b"" url = "{}/clusters/{}/print_jobs/{}/action/{}".format(self.CLUSTER_API_ROOT, cluster_id, cluster_job_id, action) self._manager.post(self._createEmptyRequest(url), body) @@ -171,16 +167,16 @@ class CloudApiClient: reply: QNetworkReply, on_finished: Union[Callable[[CloudApiClientModel], Any], Callable[[List[CloudApiClientModel]], Any]], - model: Type[CloudApiClientModel], - ) -> None: + model: Type[CloudApiClientModel]) -> None: def parse() -> None: + self._anti_gc_callbacks.remove(parse) + # Don't try to parse the reply if we didn't get one if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: return + status_code, response = self._parseReply(reply) - self._anti_gc_callbacks.remove(parse) self._parseModels(response, on_finished, model) - return self._anti_gc_callbacks.append(parse) reply.finished.connect(parse) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index fc2cdae563..f273f537e3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,9 +1,7 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import os - from time import time -from typing import Dict, List, Optional, Set, cast +from typing import List, Optional, cast from PyQt5.QtCore import QObject, QUrl, pyqtProperty, pyqtSignal, pyqtSlot from PyQt5.QtGui import QDesktopServices @@ -13,29 +11,23 @@ from UM.Backend.Backend import BackendState from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Message import Message -from UM.PluginRegistry import PluginRegistry -from UM.Qt.Duration import Duration, DurationFormat from UM.Scene.SceneNode import SceneNode from UM.Version import Version - from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice -from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType -from .CloudOutputController import CloudOutputController -from ..MeshFormatHandler import MeshFormatHandler -from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel -from .CloudProgressMessage import CloudProgressMessage from .CloudApiClient import CloudApiClient -from .Models.CloudClusterResponse import CloudClusterResponse -from .Models.CloudClusterStatus import CloudClusterStatus -from .Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest -from .Models.CloudPrintResponse import CloudPrintResponse -from .Models.CloudPrintJobResponse import CloudPrintJobResponse -from .Models.CloudClusterPrinterStatus import CloudClusterPrinterStatus -from .Models.CloudClusterPrintJobStatus import CloudClusterPrintJobStatus -from .Utils import formatDateCompleted, formatTimeCompleted +from ..ExportFileJob import ExportFileJob +from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice +from ..Models.Http.CloudClusterResponse import CloudClusterResponse +from ..Models.Http.CloudClusterStatus import CloudClusterStatus +from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest +from ..Models.Http.CloudPrintResponse import CloudPrintResponse +from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse +from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus +from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus + I18N_CATALOG = i18nCatalog("cura") @@ -44,7 +36,8 @@ I18N_CATALOG = i18nCatalog("cura") # Currently it only supports viewing the printer and print job status and adding a new job to the queue. # As such, those methods have been implemented here. # Note that this device represents a single remote cluster, not a list of multiple clusters. -class CloudOutputDevice(NetworkedPrinterOutputDevice): +class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): + # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 10.0 # seconds @@ -78,44 +71,29 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): b"cluster_size": b"1" # cloud devices are always clusters of at least one } - super().__init__(device_id=cluster.cluster_id, address="", - connection_type=ConnectionType.CloudConnection, properties=properties, parent=parent) + super().__init__( + device_id=cluster.cluster_id, + address="", + connection_type=ConnectionType.CloudConnection, + properties=properties, + parent=parent + ) + self._api = api_client - self._cluster = cluster - - self._setInterfaceElements() - self._account = api_client.account - - # We use the Cura Connect monitor tab to get most functionality right away. - if PluginRegistry.getInstance() is not None: - plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting") - if plugin_path is None: - Logger.log("e", "Cloud not find plugin path for plugin UM3NetworkPrnting") - raise RuntimeError("Cloud not find plugin path for plugin UM3NetworkPrnting") - self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml") + self._cluster = cluster + self.setAuthenticationState(AuthState.NotAuthenticated) + self._setInterfaceElements() # Trigger the printersChanged signal when the private signal is triggered. self.printersChanged.connect(self._clusterPrintersChanged) - # We keep track of which printer is visible in the monitor page. - self._active_printer = None # type: Optional[PrinterOutputModel] - - # Properties to populate later on with received cloud data. - self._print_jobs = [] # type: List[UM3PrintJobOutputModel] - self._number_of_extruders = 2 # All networked printers are dual-extrusion Ultimaker machines. - - # We only allow a single upload at a time. - self._progress = CloudProgressMessage() - # Keep server string of the last generated time to avoid updating models more than once for the same response - self._received_printers = None # type: Optional[List[CloudClusterPrinterStatus]] - self._received_print_jobs = None # type: Optional[List[CloudClusterPrintJobStatus]] - - # A set of the user's job IDs that have finished - self._finished_jobs = set() # type: Set[str] + self._received_printers = None # type: Optional[List[ClusterPrinterStatus]] + self._received_print_jobs = None # type: Optional[List[ClusterPrintJobStatus]] # Reference to the uploaded print job / mesh + # We do this to prevent re-uploading the same file multiple times. self._tool_path = None # type: Optional[bytes] self._uploaded_print_job = None # type: Optional[CloudPrintJobResponse] @@ -126,9 +104,12 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): super().connect() Logger.log("i", "Connected to cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.connect(self._onBackendStateChange) + self._update() ## Disconnects the device def disconnect(self) -> None: + if not self.isConnected(): + return super().disconnect() Logger.log("i", "Disconnected from cluster %s", self.key) CuraApplication.getInstance().getBackend().backendStateChange.disconnect(self._onBackendStateChange) @@ -138,52 +119,61 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._tool_path = None self._uploaded_print_job = None - ## Gets the cluster response from which this device was created. - @property - def clusterData(self) -> CloudClusterResponse: - return self._cluster - - ## Updates the cluster data from the cloud. - @clusterData.setter - def clusterData(self, value: CloudClusterResponse) -> None: - self._cluster = value - ## Checks whether the given network key is found in the cloud's host name def matchesNetworkKey(self, network_key: str) -> bool: # Typically, a network key looks like "ultimakersystem-aabbccdd0011._ultimaker._tcp.local." # the host name should then be "ultimakersystem-aabbccdd0011" if network_key.startswith(self.clusterData.host_name): return True - # However, for manually added printers, the local IP address is used in lieu of a proper # network key, so check for that as well - if self.clusterData.host_internal_ip is not None and network_key.find(self.clusterData.host_internal_ip): + if self.clusterData.host_internal_ip is not None and network_key in self.clusterData.host_internal_ip: return True - return False - ## Set all the interface elements and texts for this output device. + ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self) -> None: - self.setPriority(2) # Make sure we end up below the local networking and above 'save to file' + self.setPriority(2) # Make sure we end up below the local networking and above 'save to file'. self.setName(self._id) self.setShortDescription(I18N_CATALOG.i18nc("@action:button", "Print via Cloud")) self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print via Cloud")) self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected via Cloud")) + ## Called when the network data should be updated. + def _update(self) -> None: + super()._update() + if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: + return # Avoid calling the cloud too often + if self._account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._last_request_time = time() + self._api.getClusterStatus(self.key, self._onStatusCallFinished) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) + + ## Method called when HTTP request to status endpoint is finished. + # Contains both printers and print jobs statuses in a single response. + def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: + # Update all data from the cluster. + self._last_response_time = time() + if self._received_printers != status.printers: + self._received_printers = status.printers + self._updatePrinters(status.printers) + if status.print_jobs != self._received_print_jobs: + self._received_print_jobs = status.print_jobs + self._updatePrintJobs(status.print_jobs) + ## Called when Cura requests an output device to receive a (G-code) file. - def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, - file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None: + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, + file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None: # Show an error message if we're already sending a job. if self._progress.visible: - message = Message( - text=I18N_CATALOG.i18nc("@info:status", - "Sending new jobs (temporarily) blocked, still sending the previous print job."), - title=I18N_CATALOG.i18nc("@info:title", "Cloud error"), + return Message( + text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."), + title=I18N_CATALOG.i18nc("@info:title", "Print error"), lifetime=10 - ) - message.show() - return + ).show() if self._uploaded_print_job: # The mesh didn't change, let's not upload it again @@ -193,154 +183,31 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # Indicate we have started sending a job. self.writeStarted.emit(self) - mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) - if not mesh_format.is_valid: - Logger.log("e", "Missing file or mesh writer!") - return self._onUploadError(I18N_CATALOG.i18nc("@info:status", "Could not export print job.")) + # Export the scene to the correct file type. + job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion) + job.finished.connect(self._onPrintJobCreated) + job.start() - mesh = mesh_format.getBytes(nodes) - - self._tool_path = mesh + ## Handler for when the print job was created locally. + # It can now be sent over the cloud. + def _onPrintJobCreated(self, job: ExportFileJob) -> None: + output = job.getOutput() + self._tool_path = output # store the tool path to prevent re-uploading when printing the same file again request = CloudPrintJobUploadRequest( - job_name=file_name or mesh_format.file_extension, - file_size=len(mesh), - content_type=mesh_format.mime_type, + job_name=job.getFileName(), + file_size=len(output), + content_type=job.getMimeType(), ) - self._api.requestUpload(request, self._onPrintJobCreated) - - ## Called when the network data should be updated. - def _update(self) -> None: - super()._update() - if self._last_request_time and time() - self._last_request_time < self.CHECK_CLUSTER_INTERVAL: - return # Avoid calling the cloud too often - - Logger.log("d", "Updating: %s - %s >= %s", time(), self._last_request_time, self.CHECK_CLUSTER_INTERVAL) - if self._account.isLoggedIn: - self.setAuthenticationState(AuthState.Authenticated) - self._last_request_time = time() - self._api.getClusterStatus(self.key, self._onStatusCallFinished) - else: - self.setAuthenticationState(AuthState.NotAuthenticated) - - ## Method called when HTTP request to status endpoint is finished. - # Contains both printers and print jobs statuses in a single response. - def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: - # Update all data from the cluster. - self._last_response_time = time() - if self._received_printers != status.printers: - self._received_printers = status.printers - self._updatePrinters(status.printers) - - if status.print_jobs != self._received_print_jobs: - self._received_print_jobs = status.print_jobs - self._updatePrintJobs(status.print_jobs) - - ## Updates the local list of printers with the list received from the cloud. - # \param remote_printers: The printers received from the cloud. - def _updatePrinters(self, remote_printers: List[CloudClusterPrinterStatus]) -> None: - - # Keep track of the new printers to show. - # We create a new list instead of changing the existing one to get the correct order. - new_printers = [] - - # Check which printers need to be created or updated. - for index, printer_data in enumerate(remote_printers): - printer = next(iter(printer for printer in self._printers if printer.key == printer_data.uuid), None) - if not printer: - new_printers.append(printer_data.createOutputModel(CloudOutputController(self))) - else: - printer_data.updateOutputModel(printer) - new_printers.append(printer) - - # Check which printers need to be removed (de-referenced). - remote_printers_keys = [printer_data.uuid for printer_data in remote_printers] - removed_printers = [printer for printer in self._printers if printer.key not in remote_printers_keys] - for removed_printer in removed_printers: - if self._active_printer and self._active_printer.key == removed_printer.key: - self.setActivePrinter(None) - - self._printers = new_printers - if self._printers and not self.activePrinter: - self.setActivePrinter(self._printers[0]) - - self.printersChanged.emit() - - ## Updates the local list of print jobs with the list received from the cloud. - # \param remote_jobs: The print jobs received from the cloud. - def _updatePrintJobs(self, remote_jobs: List[CloudClusterPrintJobStatus]) -> None: - - # Keep track of the new print jobs to show. - # We create a new list instead of changing the existing one to get the correct order. - new_print_jobs = [] - - # Check which print jobs need to be created or updated. - for index, print_job_data in enumerate(remote_jobs): - print_job = next( - iter(print_job for print_job in self._print_jobs if print_job.key == print_job_data.uuid), None) - if not print_job: - new_print_jobs.append(self._createPrintJobModel(print_job_data)) - else: - print_job_data.updateOutputModel(print_job) - if print_job_data.printer_uuid: - self._updateAssignedPrinter(print_job, print_job_data.printer_uuid) - new_print_jobs.append(print_job) - - # Check which print job need to be removed (de-referenced). - remote_job_keys = [print_job_data.uuid for print_job_data in remote_jobs] - removed_jobs = [print_job for print_job in self._print_jobs if print_job.key not in remote_job_keys] - for removed_job in removed_jobs: - if removed_job.assignedPrinter: - removed_job.assignedPrinter.updateActivePrintJob(None) - removed_job.stateChanged.disconnect(self._onPrintJobStateChanged) - - self._print_jobs = new_print_jobs - self.printJobsChanged.emit() - - ## Create a new print job model based on the remote status of the job. - # \param remote_job: The remote print job data. - def _createPrintJobModel(self, remote_job: CloudClusterPrintJobStatus) -> UM3PrintJobOutputModel: - model = remote_job.createOutputModel(CloudOutputController(self)) - model.stateChanged.connect(self._onPrintJobStateChanged) - if remote_job.printer_uuid: - self._updateAssignedPrinter(model, remote_job.printer_uuid) - return model - - ## Handles the event of a change in a print job state - def _onPrintJobStateChanged(self) -> None: - user_name = self._getUserName() - # TODO: confirm that notifications in Cura are still required - for job in self._print_jobs: - if job.state == "wait_cleanup" and job.key not in self._finished_jobs and job.owner == user_name: - self._finished_jobs.add(job.key) - Message( - title=I18N_CATALOG.i18nc("@info:status", "Print finished"), - text=(I18N_CATALOG.i18nc("@info:status", - "Printer '{printer_name}' has finished printing '{job_name}'.").format( - printer_name=job.assignedPrinter.name, - job_name=job.name - ) if job.assignedPrinter else - I18N_CATALOG.i18nc("@info:status", "The print job '{job_name}' was finished.").format( - job_name=job.name - )), - ).show() - - ## Updates the printer assignment for the given print job model. - def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None: - printer = next((p for p in self._printers if printer_uuid == p.key), None) - if not printer: - Logger.log("w", "Missing printer %s for job %s in %s", model.assignedPrinter, model.key, - [p.key for p in self._printers]) - return - printer.updateActivePrintJob(model) - model.updateAssignedPrinter(printer) + self._api.requestUpload(request, self._uploadPrintJob) ## Uploads the mesh when the print job was registered with the cloud API. # \param job_response: The response received from the cloud API. - def _onPrintJobCreated(self, job_response: CloudPrintJobResponse) -> None: + def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None: + if not self._tool_path: + return self._onUploadError() self._progress.show() - self._uploaded_print_job = job_response - tool_path = cast(bytes, self._tool_path) - self._api.uploadToolPath(job_response, tool_path, self._onPrintJobUploaded, self._progress.update, + self._uploaded_print_job = job_response # store the last uploaded job to prevent re-upload of the same file + self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update, self._onUploadError) ## Requests the print to be sent to the printer when we finished uploading the mesh. @@ -364,7 +231,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): ## Shows a message when the upload has succeeded # \param response: The response from the cloud API. def _onPrintUploadCompleted(self, response: CloudPrintResponse) -> None: - Logger.log("d", "The cluster will be printing this print job with the ID %s", response.cluster_job_id) self._progress.hide() Message( text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), @@ -382,98 +248,37 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): firmware_version = Version([version_number[0], version_number[1], version_number[2]]) return firmware_version >= self.PRINT_JOB_ACTIONS_MIN_VERSION - ## Gets the number of printers in the cluster. - # We use a minimum of 1 because cloud devices are always a cluster and printer discovery needs it. - @pyqtProperty(int, notify=_clusterPrintersChanged) - def clusterSize(self) -> int: - return max(1, len(self._printers)) - - ## Gets the remote printers. - @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) - def printers(self) -> List[PrinterOutputModel]: - return self._printers - - ## Get the active printer in the UI (monitor page). - @pyqtProperty(QObject, notify=activePrinterChanged) - def activePrinter(self) -> Optional[PrinterOutputModel]: - return self._active_printer - - ## Set the active printer in the UI (monitor page). - @pyqtSlot(QObject) - def setActivePrinter(self, printer: Optional[PrinterOutputModel] = None) -> None: - if printer != self._active_printer: - self._active_printer = printer - self.activePrinterChanged.emit() - - ## Get remote print jobs. - @pyqtProperty("QVariantList", notify=printJobsChanged) - def printJobs(self) -> List[UM3PrintJobOutputModel]: - return self._print_jobs - - ## Get remote print jobs that are still in the print queue. - @pyqtProperty("QVariantList", notify=printJobsChanged) - def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs - if print_job.state == "queued" or print_job.state == "error"] - - ## Get remote print jobs that are assigned to a printer. - @pyqtProperty("QVariantList", notify=printJobsChanged) - def activePrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs if - print_job.assignedPrinter is not None and print_job.state != "queued"] - ## Set the remote print job state. def setJobState(self, print_job_uuid: str, state: str) -> None: self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, state) - @pyqtSlot(str) + @pyqtSlot(str, name="sendJobToTop") def sendJobToTop(self, print_job_uuid: str) -> None: self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "move", {"list": "queued", "to_position": 0}) - @pyqtSlot(str) + @pyqtSlot(str, name="deleteJobFromQueue") def deleteJobFromQueue(self, print_job_uuid: str) -> None: self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "remove") - @pyqtSlot(str) + @pyqtSlot(str, name="forceSendJob") def forceSendJob(self, print_job_uuid: str) -> None: self._api.doPrintJobAction(self._cluster.cluster_id, print_job_uuid, "force") - @pyqtSlot(int, result=str) - def formatDuration(self, seconds: int) -> str: - return Duration(seconds).getDisplayString(DurationFormat.Format.Short) - - @pyqtSlot(int, result=str) - def getTimeCompleted(self, time_remaining: int) -> str: - return formatTimeCompleted(time_remaining) - - @pyqtSlot(int, result=str) - def getDateCompleted(self, time_remaining: int) -> str: - return formatDateCompleted(time_remaining) - - @pyqtProperty(bool, notify=printJobsChanged) - def receivedPrintJobs(self) -> bool: - return bool(self._print_jobs) - - @pyqtSlot() + @pyqtSlot(name="openPrintJobControlPanel") def openPrintJobControlPanel(self) -> None: QDesktopServices.openUrl(QUrl("https://mycloud.ultimaker.com")) - @pyqtSlot() + @pyqtSlot(name="openPrinterControlPanel") def openPrinterControlPanel(self) -> None: QDesktopServices.openUrl(QUrl("https://mycloud.ultimaker.com")) - ## TODO: The following methods are required by the monitor page QML, but are not actually available using cloud. - # TODO: We fake the methods here to not break the monitor page. + ## Gets the cluster response from which this device was created. + @property + def clusterData(self) -> CloudClusterResponse: + return self._cluster - @pyqtProperty(QUrl, notify=_clusterPrintersChanged) - def activeCameraUrl(self) -> "QUrl": - return QUrl() - - @pyqtSlot(QUrl) - def setActiveCameraUrl(self, camera_url: "QUrl") -> None: - pass - - @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) - def connectedPrintersTypeCount(self) -> List[Dict[str, str]]: - return [] + ## Updates the cluster data from the cloud. + @clusterData.setter + def clusterData(self, value: CloudClusterResponse) -> None: + self._cluster = value diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index ced53e347b..b31f1efa47 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,30 +1,27 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict, List +from typing import Dict, List, Optional from PyQt5.QtCore import QTimer from UM import i18nCatalog -from UM.Logger import Logger -from UM.Message import Message from UM.Signal import Signal from cura.API import Account from cura.CuraApplication import CuraApplication from cura.Settings.GlobalStack import GlobalStack + from .CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice -from .Models.CloudClusterResponse import CloudClusterResponse -from .Models.CloudError import CloudError -from .Utils import findChanges +from ..Models.Http.CloudClusterResponse import CloudClusterResponse -## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. -# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. -# -# API spec is available on https://api.ultimaker.com/docs/connect/spec/. -# +## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. +# Keeping all cloud related logic in this class instead of the UM3OutputDevicePlugin results in more readable code. +# API spec is available on https://api.ultimaker.com/docs/connect/spec/. class CloudOutputDeviceManager: + META_CLUSTER_ID = "um_cloud_cluster_id" + META_NETWORK_KEY = "um_network_key" # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 30.0 # seconds @@ -32,108 +29,119 @@ class CloudOutputDeviceManager: # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") - addedCloudCluster = Signal() - removedCloudCluster = Signal() + # Signal emitted when the list of discovered devices changed. + discoveredDevicesChanged = Signal() def __init__(self) -> None: # Persistent dict containing the remote clusters for the authenticated user. self._remote_clusters = {} # type: Dict[str, CloudOutputDevice] - - self._application = CuraApplication.getInstance() - self._output_device_manager = self._application.getOutputDeviceManager() - - self._account = self._application.getCuraAPI().account # type: Account - self._api = CloudApiClient(self._account, self._onApiError) + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._api = CloudApiClient(self._account, on_error=lambda error: print(error)) + self._account.loginStateChanged.connect(self._onLoginStateChanged) # Create a timer to update the remote cluster list self._update_timer = QTimer() self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000)) self._update_timer.setSingleShot(False) + # Ensure we don't start twice. self._running = False - # Called when the uses logs in or out + ## Starts running the cloud output device manager, thus periodically requesting cloud data. + def start(self): + if self._running: + return + if not self._account.isLoggedIn: + return + self._running = True + if not self._update_timer.isActive(): + self._update_timer.start() + self._getRemoteClusters() + self._update_timer.timeout.connect(self._getRemoteClusters) + + ## Stops running the cloud output device manager. + def stop(self): + if not self._running: + return + self._running = False + if self._update_timer.isActive(): + self._update_timer.stop() + self._onGetRemoteClustersFinished([]) # Make sure we remove all cloud output devices. + self._update_timer.timeout.disconnect(self._getRemoteClusters) + + ## Force refreshing connections. + def refreshConnections(self) -> None: + self._connectToActiveMachine() + + ## Called when the uses logs in or out def _onLoginStateChanged(self, is_logged_in: bool) -> None: - Logger.log("d", "Log in state changed to %s", is_logged_in) if is_logged_in: - if not self._update_timer.isActive(): - self._update_timer.start() - self._getRemoteClusters() + self.start() else: - if self._update_timer.isActive(): - self._update_timer.stop() + self.stop() - # Notify that all clusters have disappeared - self._onGetRemoteClustersFinished([]) - - ## Gets all remote clusters from the API. + ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: - Logger.log("d", "Retrieving remote clusters") self._api.getClusters(self._onGetRemoteClustersFinished) - ## Callback for when the request for getting the clusters. is finished. + ## Callback for when the request for getting the clusters is finished. def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None: online_clusters = {c.cluster_id: c for c in clusters if c.is_online} # type: Dict[str, CloudClusterResponse] + for device_id, cluster_data in online_clusters.items(): + if device_id not in self._remote_clusters: + self._onDeviceDiscovered(cluster_data) + else: + self._onDiscoveredDeviceUpdated(cluster_data) - removed_devices, added_clusters, updates = findChanges(self._remote_clusters, online_clusters) - - Logger.log("d", "Parsed remote clusters to %s", [cluster.toDict() for cluster in online_clusters.values()]) - Logger.log("d", "Removed: %s, added: %s, updates: %s", len(removed_devices), len(added_clusters), len(updates)) - - # Remove output devices that are gone - for device in removed_devices: - if device.isConnected(): - device.disconnect() - device.close() - self._output_device_manager.removeOutputDevice(device.key) - self._application.getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key) - self.removedCloudCluster.emit(device) - del self._remote_clusters[device.key] - - # Add an output device for each new remote cluster. - # We only add when is_online as we don't want the option in the drop down if the cluster is not online. - for cluster in added_clusters: - device = CloudOutputDevice(self._api, cluster) - self._remote_clusters[cluster.cluster_id] = device - self._application.getDiscoveredPrintersModel().addDiscoveredPrinter( - device.key, - device.key, - cluster.friendly_name, - self._createMachineFromDiscoveredPrinter, - device.printerType, - device - ) - self.addedCloudCluster.emit(cluster) - - # Update the output devices - for device, cluster in updates: - device.clusterData = cluster - self._application.getDiscoveredPrintersModel().updateDiscoveredPrinter( - device.key, - cluster.friendly_name, - device.printerType, - ) + removed_device_keys = set(self._remote_clusters.keys()) - set(online_clusters.keys()) + for device_id in removed_device_keys: + self._onDiscoveredDeviceRemoved(device_id) + def _onDeviceDiscovered(self, cluster_data: CloudClusterResponse) -> None: + device = CloudOutputDevice(self._api, cluster_data) + CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter( + ip_address=device.key, + key=device.getId(), + name=device.getName(), + create_callback=self._createMachineFromDiscoveredDevice, + machine_type=device.printerType, + device=device + ) + self._remote_clusters[device.getId()] = device + self.discoveredDevicesChanged.emit() self._connectToActiveMachine() - - def _createMachineFromDiscoveredPrinter(self, key: str) -> None: - device = self._remote_clusters[key] # type: CloudOutputDevice + + def _onDiscoveredDeviceUpdated(self, cluster_data: CloudClusterResponse) -> None: + device = self._remote_clusters.get(cluster_data.cluster_id) if not device: - Logger.log("e", "Could not find discovered device with key [%s]", key) return - - group_name = device.clusterData.friendly_name - machine_type_id = device.printerType - - Logger.log("i", "Creating machine from cloud device with key = [%s], group name = [%s], printer type = [%s]", - key, group_name, machine_type_id) - + CuraApplication.getInstance().getDiscoveredPrintersModel().updateDiscoveredPrinter( + ip_address=device.key, + name=cluster_data.friendly_name, + machine_type=device.printerType + ) + self.discoveredDevicesChanged.emit() + + def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: + device = self._remote_clusters.pop(device_id, None) # type: Optional[CloudOutputDevice] + if not device: + return + device.disconnect() + device.close() + CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.key) + self.discoveredDevicesChanged.emit() + + def _createMachineFromDiscoveredDevice(self, key: str) -> None: + device = self._remote_clusters[key] + if not device: + return + # The newly added machine is automatically activated. - self._application.getMachineManager().addMachine(machine_type_id, group_name) + machine_manager = CuraApplication.getInstance().getMachineManager() + machine_manager.addMachine(device.printerType, device.clusterData.friendly_name) active_machine = CuraApplication.getInstance().getGlobalContainerStack() if not active_machine: return - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) self._connectToOutputDevice(device, active_machine) @@ -143,69 +151,24 @@ class CloudOutputDeviceManager: if not active_machine: return - # Remove all output devices that we have registered. - # This is needed because when we switch machines we can only leave - # output devices that are meant for that machine. - for stored_cluster_id in self._remote_clusters: - self._output_device_manager.removeOutputDevice(stored_cluster_id) - - # Check if the stored cluster_id for the active machine is in our list of remote clusters. + output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() stored_cluster_id = active_machine.getMetaDataEntry(self.META_CLUSTER_ID) - if stored_cluster_id in self._remote_clusters: - device = self._remote_clusters[stored_cluster_id] - self._connectToOutputDevice(device, active_machine) - Logger.log("d", "Device connected by metadata cluster ID %s", stored_cluster_id) - else: - self._connectByNetworkKey(active_machine) - - ## Tries to match the local network key to the cloud cluster host name. - def _connectByNetworkKey(self, active_machine: GlobalStack) -> None: - # Check if the active printer has a local network connection and match this key to the remote cluster. - local_network_key = active_machine.getMetaDataEntry("um_network_key") - if not local_network_key: - return - - device = next((c for c in self._remote_clusters.values() if c.matchesNetworkKey(local_network_key)), None) - if not device: - return - - Logger.log("i", "Found cluster %s with network key %s", device, local_network_key) - active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) - self._connectToOutputDevice(device, active_machine) + local_network_key = active_machine.getMetaDataEntry(self.META_NETWORK_KEY) + for device in self._remote_clusters.values(): + if device.key == stored_cluster_id: + # Connect to it if the stored ID matches. + self._connectToOutputDevice(device, active_machine) + elif local_network_key and device.matchesNetworkKey(local_network_key): + # Connect to it if we can match the local network key that was already present. + active_machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key) + self._connectToOutputDevice(device, active_machine) + elif device.key in output_device_manager.getOutputDeviceIds(): + # Remove device if it is not meant for the active machine. + output_device_manager.removeOutputDevice(device.key) ## Connects to an output device and makes sure it is registered in the output device manager. - def _connectToOutputDevice(self, device: CloudOutputDevice, active_machine: GlobalStack) -> None: + @staticmethod + def _connectToOutputDevice(device: CloudOutputDevice, active_machine: GlobalStack) -> None: device.connect() - self._output_device_manager.addOutputDevice(device) active_machine.addConfiguredConnectionType(device.connectionType.value) - - ## Handles an API error received from the cloud. - # \param errors: The errors received - def _onApiError(self, errors: List[CloudError] = None) -> None: - Logger.log("w", str(errors)) - message = Message( - text = self.I18N_CATALOG.i18nc("@info:description", "There was an error connecting to the cloud."), - title = self.I18N_CATALOG.i18nc("@info:title", "Error"), - lifetime = 10 - ) - message.show() - - ## Starts running the cloud output device manager, thus periodically requesting cloud data. - def start(self): - if self._running: - return - self._account.loginStateChanged.connect(self._onLoginStateChanged) - # When switching machines we check if we have to activate a remote cluster. - self._application.globalContainerStackChanged.connect(self._connectToActiveMachine) - self._update_timer.timeout.connect(self._getRemoteClusters) - self._onLoginStateChanged(is_logged_in = self._account.isLoggedIn) - - ## Stops running the cloud output device manager. - def stop(self): - if not self._running: - return - self._account.loginStateChanged.disconnect(self._onLoginStateChanged) - # When switching machines we check if we have to activate a remote cluster. - self._application.globalContainerStackChanged.disconnect(self._connectToActiveMachine) - self._update_timer.timeout.disconnect(self._getRemoteClusters) - self._onLoginStateChanged(is_logged_in = False) + CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py deleted file mode 100644 index 4386bbb435..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterBuildPlate.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel - - -## Class representing a cluster printer -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterBuildPlate(BaseCloudModel): - ## Create a new build plate - # \param type: The type of buildplate glass or aluminium - def __init__(self, type: str = "glass", **kwargs) -> None: - self.type = type - super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py b/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py deleted file mode 100644 index f3f6970c54..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py index 176b7e6ab7..d5de7fe10a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # !/usr/bin/env python # -*- coding: utf-8 -*- from PyQt5.QtCore import QUrl @@ -6,7 +6,8 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManage from typing import Optional, Callable, Any, Tuple, cast from UM.Logger import Logger -from .Models.CloudPrintJobResponse import CloudPrintJobResponse + +from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse ## Class responsible for uploading meshes to the cloud in separate requests. @@ -53,7 +54,7 @@ class ToolPathUploader: def _createRequest(self) -> QNetworkRequest: request = QNetworkRequest(QUrl(self._print_job.upload_url)) request.setHeader(QNetworkRequest.ContentTypeHeader, self._print_job.content_type) - + first_byte, last_byte = self._chunkRange() content_range = "bytes {}-{}/{}".format(first_byte, last_byte - 1, len(self._data)) request.setRawHeader(b"Content-Range", content_range.encode()) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py b/plugins/UM3NetworkPrinting/src/Cloud/Utils.py deleted file mode 100644 index 5136e0e7db..0000000000 --- a/plugins/UM3NetworkPrinting/src/Cloud/Utils.py +++ /dev/null @@ -1,54 +0,0 @@ -from datetime import datetime, timedelta -from typing import TypeVar, Dict, Tuple, List - -from UM import i18nCatalog - -T = TypeVar("T") -U = TypeVar("U") - - -## Splits the given dictionaries into three lists (in a tuple): -# - `removed`: Items that were in the first argument but removed in the second one. -# - `added`: Items that were not in the first argument but were included in the second one. -# - `updated`: Items that were in both dictionaries. Both values are given in a tuple. -# \param previous: The previous items -# \param received: The received items -# \return: The tuple (removed, added, updated) as explained above. -def findChanges(previous: Dict[str, T], received: Dict[str, U]) -> Tuple[List[T], List[U], List[Tuple[T, U]]]: - previous_ids = set(previous) - received_ids = set(received) - - removed_ids = previous_ids.difference(received_ids) - new_ids = received_ids.difference(previous_ids) - updated_ids = received_ids.intersection(previous_ids) - - removed = [previous[removed_id] for removed_id in removed_ids] - added = [received[new_id] for new_id in new_ids] - updated = [(previous[updated_id], received[updated_id]) for updated_id in updated_ids] - - return removed, added, updated - - -def formatTimeCompleted(seconds_remaining: int) -> str: - completed = datetime.now() + timedelta(seconds=seconds_remaining) - return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute) - - -def formatDateCompleted(seconds_remaining: int) -> str: - now = datetime.now() - completed = now + timedelta(seconds=seconds_remaining) - days = (completed.date() - now.date()).days - i18n = i18nCatalog("cura") - - # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format - if days >= 7: - return completed.strftime("%a %b ") + "{day}".format(day = completed.day) - # If finishing date is within the next week, use "Monday at HH:MM" format - elif days >= 2: - return completed.strftime("%a") - # If finishing tomorrow, use "tomorrow at HH:MM" format - elif days >= 1: - return i18n.i18nc("@info:status", "tomorrow") - # If finishing today, use "today at HH:MM" format - else: - return i18n.i18nc("@info:status", "today") diff --git a/plugins/UM3NetworkPrinting/src/CloudFlowMessage.py b/plugins/UM3NetworkPrinting/src/CloudFlowMessage.py new file mode 100644 index 0000000000..4f2f7a71a2 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/CloudFlowMessage.py @@ -0,0 +1,41 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import os + +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices + +from UM import i18nCatalog +from UM.Message import Message +from cura.CuraApplication import CuraApplication + + +I18N_CATALOG = i18nCatalog("cura") + + +class CloudFlowMessage(Message): + + def __init__(self, address: str) -> None: + + image_path = os.path.join( + CuraApplication.getInstance().getPluginRegistry().getPluginPath("UM3NetworkPrinting") or "", + "resources", "svg", "cloud-flow-start.svg" + ) + + super().__init__( + text=I18N_CATALOG.i18nc("@info:status", + "Send and monitor print jobs from anywhere using your Ultimaker account."), + lifetime=0, + dismissable=True, + option_state=False, + image_source=image_path, + image_caption=I18N_CATALOG.i18nc("@info:status Ultimaker Cloud should not be translated.", + "Connect to Ultimaker Cloud"), + ) + self._address = address + self.addAction("", I18N_CATALOG.i18nc("@action", "Get started"), "", "") + self.actionTriggered.connect(self._onCloudFlowStarted) + + def _onCloudFlowStarted(self, messageId: str, actionId: str) -> None: + QDesktopServices.openUrl(QUrl("http://{}/cloud_connect".format(self._address))) + self.hide() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py b/plugins/UM3NetworkPrinting/src/ClusterOutputController.py similarity index 52% rename from plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py rename to plugins/UM3NetworkPrinting/src/ClusterOutputController.py index 8c09483990..02d8d174d1 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputController.py +++ b/plugins/UM3NetworkPrinting/src/ClusterOutputController.py @@ -1,19 +1,14 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel from cura.PrinterOutput.PrinterOutputController import PrinterOutputController - -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from .CloudOutputDevice import CloudOutputDevice +from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice -class CloudOutputController(PrinterOutputController): - def __init__(self, output_device: "CloudOutputDevice") -> None: +class ClusterOutputController(PrinterOutputController): + + def __init__(self, output_device: PrinterOutputDevice) -> None: super().__init__(output_device) - - # The cloud connection only supports fetching the printer and queue status and adding a job to the queue. - # To let the UI know this we mark all features below as False. self.can_pause = True self.can_abort = True self.can_pre_heat_bed = False @@ -22,5 +17,5 @@ class CloudOutputController(PrinterOutputController): self.can_control_manually = False self.can_update_firmware = False - def setJobState(self, job: "PrintJobOutputModel", state: str): + def setJobState(self, job: PrintJobOutputModel, state: str): self._output_device.setJobState(job.key, state) diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py deleted file mode 100644 index 177836bccd..0000000000 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3OutputDevice.py +++ /dev/null @@ -1,724 +0,0 @@ -# Copyright (c) 2019 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from typing import Any, cast, Tuple, Union, Optional, Dict, List -from time import time - -import io # To create the correct buffers for sending data to the printer. -import json -import os - -from UM.FileHandler.FileHandler import FileHandler -from UM.FileHandler.WriteFileJob import WriteFileJob # To call the file writer asynchronously. -from UM.i18n import i18nCatalog -from UM.Logger import Logger -from UM.Message import Message -from UM.PluginRegistry import PluginRegistry -from UM.Qt.Duration import Duration, DurationFormat -from UM.Scene.SceneNode import SceneNode # For typing. -from UM.Settings.ContainerRegistry import ContainerRegistry - -from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel -from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel -from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState, NetworkedPrinterOutputDevice -from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel -from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel -from cura.PrinterOutput.PrinterOutputDevice import ConnectionType - -from .Cloud.Utils import formatTimeCompleted, formatDateCompleted -from .ClusterUM3PrinterOutputController import ClusterUM3PrinterOutputController -from .ConfigurationChangeModel import ConfigurationChangeModel -from .MeshFormatHandler import MeshFormatHandler -from .SendMaterialJob import SendMaterialJob -from .UM3PrintJobOutputModel import UM3PrintJobOutputModel - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply -from PyQt5.QtGui import QDesktopServices, QImage -from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject - -i18n_catalog = i18nCatalog("cura") - - -class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): - printJobsChanged = pyqtSignal() - activePrinterChanged = pyqtSignal() - activeCameraUrlChanged = pyqtSignal() - receivedPrintJobsChanged = pyqtSignal() - - # Notify can only use signals that are defined by the class that they are in, not inherited ones. - # Therefore we create a private signal used to trigger the printersChanged signal. - _clusterPrintersChanged = pyqtSignal() - - def __init__(self, device_id, address, properties, parent = None) -> None: - super().__init__(device_id = device_id, address = address, properties=properties, connection_type = ConnectionType.NetworkConnection, parent = parent) - self._api_prefix = "/cluster-api/v1/" - - self._application = CuraApplication.getInstance() - - self._number_of_extruders = 2 - - self._dummy_lambdas = ( - "", {}, io.BytesIO() - ) # type: Tuple[Optional[str], Dict[str, Union[str, int, bool]], Union[io.StringIO, io.BytesIO]] - - self._print_jobs = [] # type: List[UM3PrintJobOutputModel] - self._received_print_jobs = False # type: bool - - if PluginRegistry.getInstance() is not None: - plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting") - if plugin_path is None: - Logger.log("e", "Cloud not find plugin path for plugin UM3NetworkPrnting") - raise RuntimeError("Cloud not find plugin path for plugin UM3NetworkPrnting") - self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml") - - # Trigger the printersChanged signal when the private signal is triggered - self.printersChanged.connect(self._clusterPrintersChanged) - - self._accepts_commands = True # type: bool - - # Cluster does not have authentication, so default to authenticated - self._authentication_state = AuthState.Authenticated - - self._error_message = None # type: Optional[Message] - self._write_job_progress_message = None # type: Optional[Message] - self._progress_message = None # type: Optional[Message] - - self._active_printer = None # type: Optional[PrinterOutputModel] - - self._printer_selection_dialog = None # type: QObject - - self.setPriority(3) # Make sure the output device gets selected above local file output - self.setName(self._id) - self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network")) - self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network")) - - self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network")) - - self._printer_uuid_to_unique_name_mapping = {} # type: Dict[str, str] - - self._finished_jobs = [] # type: List[UM3PrintJobOutputModel] - - self._cluster_size = int(properties.get(b"cluster_size", 0)) # type: int - - self._latest_reply_handler = None # type: Optional[QNetworkReply] - self._sending_job = None - - self._active_camera_url = QUrl() # type: QUrl - - def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, - file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None: - self.writeStarted.emit(self) - - self.sendMaterialProfiles() - - mesh_format = MeshFormatHandler(file_handler, self.firmwareVersion) - - # This function pauses with the yield, waiting on instructions on which printer it needs to print with. - if not mesh_format.is_valid: - Logger.log("e", "Missing file or mesh writer!") - return - self._sending_job = self._sendPrintJob(mesh_format, nodes) - if self._sending_job is not None: - self._sending_job.send(None) # Start the generator. - - if len(self._printers) > 1: # We need to ask the user. - self._spawnPrinterSelectionDialog() - is_job_sent = True - else: # Just immediately continue. - self._sending_job.send("") # No specifically selected printer. - is_job_sent = self._sending_job.send(None) - - def _spawnPrinterSelectionDialog(self): - if self._printer_selection_dialog is None: - if PluginRegistry.getInstance() is not None: - path = os.path.join( - PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), - "resources", "qml", "PrintWindow.qml" - ) - self._printer_selection_dialog = self._application.createQmlComponent(path, {"OutputDevice": self}) - if self._printer_selection_dialog is not None: - self._printer_selection_dialog.show() - - ## Whether the printer that this output device represents supports print job actions via the local network. - @pyqtProperty(bool, constant=True) - def supportsPrintJobActions(self) -> bool: - return True - - @pyqtProperty(int, constant=True) - def clusterSize(self) -> int: - return self._cluster_size - - ## Allows the user to choose a printer to print with from the printer - # selection dialogue. - # \param target_printer The name of the printer to target. - @pyqtSlot(str) - def selectPrinter(self, target_printer: str = "") -> None: - if self._sending_job is not None: - self._sending_job.send(target_printer) - - @pyqtSlot() - def cancelPrintSelection(self) -> None: - self._sending_gcode = False - - ## Greenlet to send a job to the printer over the network. - # - # This greenlet gets called asynchronously in requestWrite. It is a - # greenlet in order to optionally wait for selectPrinter() to select a - # printer. - # The greenlet yields exactly three times: First time None, - # \param mesh_format Object responsible for choosing the right kind of format to write with. - def _sendPrintJob(self, mesh_format: MeshFormatHandler, nodes: List[SceneNode]): - Logger.log("i", "Sending print job to printer.") - if self._sending_gcode: - self._error_message = Message( - i18n_catalog.i18nc("@info:status", - "Sending new jobs (temporarily) blocked, still sending the previous print job.")) - self._error_message.show() - yield #Wait on the user to select a target printer. - yield #Wait for the write job to be finished. - yield False #Return whether this was a success or not. - yield #Prevent StopIteration. - - self._sending_gcode = True - - # Potentially wait on the user to select a target printer. - target_printer = yield # type: Optional[str] - - # Using buffering greatly reduces the write time for many lines of gcode - - stream = mesh_format.createStream() - - job = WriteFileJob(mesh_format.writer, stream, nodes, mesh_format.file_mode) - - self._write_job_progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), - lifetime = 0, dismissable = False, progress = -1, - title = i18n_catalog.i18nc("@info:title", "Sending Data"), - use_inactivity_timer = False) - self._write_job_progress_message.show() - - if mesh_format.preferred_format is not None: - self._dummy_lambdas = (target_printer, mesh_format.preferred_format, stream) - job.finished.connect(self._sendPrintJobWaitOnWriteJobFinished) - job.start() - yield True # Return that we had success! - yield # To prevent having to catch the StopIteration exception. - - def _sendPrintJobWaitOnWriteJobFinished(self, job: WriteFileJob) -> None: - if self._write_job_progress_message: - self._write_job_progress_message.hide() - - self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, - dismissable = False, progress = -1, - title = i18n_catalog.i18nc("@info:title", "Sending Data")) - self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = "", - description = "") - self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered) - self._progress_message.show() - parts = [] - - target_printer, preferred_format, stream = self._dummy_lambdas - - # If a specific printer was selected, it should be printed with that machine. - if target_printer: - target_printer = self._printer_uuid_to_unique_name_mapping[target_printer] - parts.append(self._createFormPart("name=require_printer_name", bytes(target_printer, "utf-8"), "text/plain")) - - # Add user name to the print_job - parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) - - file_name = self._application.getPrintInformation().jobName + "." + preferred_format["extension"] - - output = stream.getvalue() # Either str or bytes depending on the output mode. - if isinstance(stream, io.StringIO): - output = cast(str, output).encode("utf-8") - output = cast(bytes, output) - - parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output)) - - self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, - on_finished = self._onPostPrintJobFinished, - on_progress = self._onUploadPrintJobProgress) - - @pyqtProperty(QObject, notify = activePrinterChanged) - def activePrinter(self) -> Optional[PrinterOutputModel]: - return self._active_printer - - @pyqtSlot(QObject) - def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: - if self._active_printer != printer: - self._active_printer = printer - self.activePrinterChanged.emit() - - @pyqtProperty(QUrl, notify = activeCameraUrlChanged) - def activeCameraUrl(self) -> "QUrl": - return self._active_camera_url - - @pyqtSlot(QUrl) - def setActiveCameraUrl(self, camera_url: "QUrl") -> None: - if self._active_camera_url != camera_url: - self._active_camera_url = camera_url - self.activeCameraUrlChanged.emit() - - def _onPostPrintJobFinished(self, reply: QNetworkReply) -> None: - if self._progress_message: - self._progress_message.hide() - self._compressing_gcode = False - self._sending_gcode = False - - ## The IP address of the printer. - @pyqtProperty(str, constant = True) - def address(self) -> str: - return self._address - - def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: - if bytes_total > 0: - new_progress = bytes_sent / bytes_total * 100 - # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get - # timeout responses if this happens. - self._last_response_time = time() - if self._progress_message is not None and new_progress != self._progress_message.getProgress(): - self._progress_message.show() # Ensure that the message is visible. - self._progress_message.setProgress(bytes_sent / bytes_total * 100) - - # If successfully sent: - if bytes_sent == bytes_total: - # Show a confirmation to the user so they know the job was sucessful and provide the option to switch to - # the monitor tab. - self._success_message = Message( - i18n_catalog.i18nc("@info:status", "Print job was successfully sent to the printer."), - lifetime=5, dismissable=True, - title=i18n_catalog.i18nc("@info:title", "Data Sent")) - self._success_message.addAction("View", i18n_catalog.i18nc("@action:button", "View in Monitor"), icon = "", - description="") - self._success_message.actionTriggered.connect(self._successMessageActionTriggered) - self._success_message.show() - else: - if self._progress_message is not None: - self._progress_message.setProgress(0) - self._progress_message.hide() - - def _progressMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None: - if action_id == "Abort": - Logger.log("d", "User aborted sending print to remote.") - if self._progress_message is not None: - self._progress_message.hide() - self._compressing_gcode = False - self._sending_gcode = False - self._application.getController().setActiveStage("PrepareStage") - - # After compressing the sliced model Cura sends data to printer, to stop receiving updates from the request - # the "reply" should be disconnected - if self._latest_reply_handler: - self._latest_reply_handler.disconnect() - self._latest_reply_handler = None - - def _successMessageActionTriggered(self, message_id: Optional[str] = None, action_id: Optional[str] = None) -> None: - if action_id == "View": - self._application.getController().setActiveStage("MonitorStage") - - @pyqtSlot() - def openPrintJobControlPanel(self) -> None: - Logger.log("d", "Opening print job control panel...") - QDesktopServices.openUrl(QUrl("http://" + self._address + "/print_jobs")) - - @pyqtSlot() - def openPrinterControlPanel(self) -> None: - Logger.log("d", "Opening printer control panel...") - QDesktopServices.openUrl(QUrl("http://" + self._address + "/printers")) - - @pyqtProperty("QVariantList", notify = printJobsChanged) - def printJobs(self)-> List[UM3PrintJobOutputModel]: - return self._print_jobs - - @pyqtProperty(bool, notify = receivedPrintJobsChanged) - def receivedPrintJobs(self) -> bool: - return self._received_print_jobs - - @pyqtProperty("QVariantList", notify = printJobsChanged) - def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs if print_job.state == "queued" or print_job.state == "error"] - - @pyqtProperty("QVariantList", notify = printJobsChanged) - def activePrintJobs(self) -> List[UM3PrintJobOutputModel]: - return [print_job for print_job in self._print_jobs if print_job.assignedPrinter is not None and print_job.state != "queued"] - - @pyqtProperty("QVariantList", notify = _clusterPrintersChanged) - def connectedPrintersTypeCount(self) -> List[Dict[str, str]]: - printer_count = {} # type: Dict[str, int] - for printer in self._printers: - if printer.type in printer_count: - printer_count[printer.type] += 1 - else: - printer_count[printer.type] = 1 - result = [] - for machine_type in printer_count: - result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])}) - return result - - @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) - def printers(self): - return self._printers - - @pyqtSlot(int, result = str) - def getTimeCompleted(self, time_remaining: int) -> str: - return formatTimeCompleted(time_remaining) - - @pyqtSlot(int, result = str) - def getDateCompleted(self, time_remaining: int) -> str: - return formatDateCompleted(time_remaining) - - @pyqtSlot(int, result = str) - def formatDuration(self, seconds: int) -> str: - return Duration(seconds).getDisplayString(DurationFormat.Format.Short) - - @pyqtSlot(str) - def sendJobToTop(self, print_job_uuid: str) -> None: - # This function is part of the output device (and not of the printjob output model) as this type of operation - # is a modification of the cluster queue and not of the actual job. - data = "{\"to_position\": 0}" - self.put("print_jobs/{uuid}/move_to_position".format(uuid = print_job_uuid), data, on_finished=None) - - @pyqtSlot(str) - def deleteJobFromQueue(self, print_job_uuid: str) -> None: - # This function is part of the output device (and not of the printjob output model) as this type of operation - # is a modification of the cluster queue and not of the actual job. - self.delete("print_jobs/{uuid}".format(uuid = print_job_uuid), on_finished=None) - - @pyqtSlot(str) - def forceSendJob(self, print_job_uuid: str) -> None: - data = "{\"force\": true}" - self.put("print_jobs/{uuid}".format(uuid=print_job_uuid), data, on_finished=None) - - # Set the remote print job state. - def setJobState(self, print_job_uuid: str, state: str) -> None: - # We rewrite 'resume' to 'print' here because we are using the old print job action endpoints. - action = "print" if state == "resume" else state - data = "{\"action\": \"%s\"}" % action - self.put("print_jobs/%s/action" % print_job_uuid, data, on_finished=None) - - def _printJobStateChanged(self) -> None: - username = self._getUserName() - - if username is None: - return # We only want to show notifications if username is set. - - finished_jobs = [job for job in self._print_jobs if job.state == "wait_cleanup"] - - newly_finished_jobs = [job for job in finished_jobs if job not in self._finished_jobs and job.owner == username] - for job in newly_finished_jobs: - if job.assignedPrinter: - job_completed_text = i18n_catalog.i18nc("@info:status", "Printer '{printer_name}' has finished printing '{job_name}'.").format(printer_name=job.assignedPrinter.name, job_name = job.name) - else: - job_completed_text = i18n_catalog.i18nc("@info:status", "The print job '{job_name}' was finished.").format(job_name = job.name) - job_completed_message = Message(text=job_completed_text, title = i18n_catalog.i18nc("@info:status", "Print finished")) - job_completed_message.show() - - # Ensure UI gets updated - self.printJobsChanged.emit() - - # Keep a list of all completed jobs so we know if something changed next time. - self._finished_jobs = finished_jobs - - ## Called when the connection to the cluster changes. - def connect(self) -> None: - super().connect() - self.sendMaterialProfiles() - - def _onGetPreviewImageFinished(self, reply: QNetworkReply) -> None: - reply_url = reply.url().toString() - - uuid = reply_url[reply_url.find("print_jobs/")+len("print_jobs/"):reply_url.rfind("/preview_image")] - - print_job = findByKey(self._print_jobs, uuid) - if print_job: - image = QImage() - image.loadFromData(reply.readAll()) - print_job.updatePreviewImage(image) - - def _update(self) -> None: - super()._update() - self.get("printers/", on_finished = self._onGetPrintersDataFinished) - self.get("print_jobs/", on_finished = self._onGetPrintJobsFinished) - - for print_job in self._print_jobs: - if print_job.getPreviewImage() is None: - self.get("print_jobs/{uuid}/preview_image".format(uuid=print_job.key), on_finished=self._onGetPreviewImageFinished) - - def _onGetPrintJobsFinished(self, reply: QNetworkReply) -> None: - self._received_print_jobs = True - self.receivedPrintJobsChanged.emit() - - if not checkValidGetReply(reply): - return - - result = loadJsonFromReply(reply) - if result is None: - return - - print_jobs_seen = [] - job_list_changed = False - for idx, print_job_data in enumerate(result): - print_job = findByKey(self._print_jobs, print_job_data["uuid"]) - if print_job is None: - print_job = self._createPrintJobModel(print_job_data) - job_list_changed = True - elif not job_list_changed: - # Check if the order of the jobs has changed since the last check - if self._print_jobs.index(print_job) != idx: - job_list_changed = True - - self._updatePrintJob(print_job, print_job_data) - - if print_job.state != "queued" and print_job.state != "error": # Print job should be assigned to a printer. - if print_job.state in ["failed", "finished", "aborted", "none"]: - # Print job was already completed, so don't attach it to a printer. - printer = None - else: - printer = self._getPrinterByKey(print_job_data["printer_uuid"]) - else: # The job can "reserve" a printer if some changes are required. - printer = self._getPrinterByKey(print_job_data["assigned_to"]) - - if printer: - printer.updateActivePrintJob(print_job) - - print_jobs_seen.append(print_job) - - # Check what jobs need to be removed. - removed_jobs = [print_job for print_job in self._print_jobs if print_job not in print_jobs_seen] - - for removed_job in removed_jobs: - job_list_changed = job_list_changed or self._removeJob(removed_job) - - if job_list_changed: - # Override the old list with the new list (either because jobs were removed / added or order changed) - self._print_jobs = print_jobs_seen - self.printJobsChanged.emit() # Do a single emit for all print job changes. - - def _onGetPrintersDataFinished(self, reply: QNetworkReply) -> None: - if not checkValidGetReply(reply): - return - - result = loadJsonFromReply(reply) - if result is None: - return - - printer_list_changed = False - printers_seen = [] - - for printer_data in result: - printer = findByKey(self._printers, printer_data["uuid"]) - - if printer is None: - printer = self._createPrinterModel(printer_data) - printer_list_changed = True - - printers_seen.append(printer) - - self._updatePrinter(printer, printer_data) - - removed_printers = [printer for printer in self._printers if printer not in printers_seen] - for printer in removed_printers: - self._removePrinter(printer) - - if removed_printers or printer_list_changed: - self.printersChanged.emit() - - def _createPrinterModel(self, data: Dict[str, Any]) -> PrinterOutputModel: - printer = PrinterOutputModel(output_controller = ClusterUM3PrinterOutputController(self), - number_of_extruders = self._number_of_extruders) - printer.setCameraUrl(QUrl("http://" + data["ip_address"] + ":8080/?action=stream")) - self._printers.append(printer) - return printer - - def _createPrintJobModel(self, data: Dict[str, Any]) -> UM3PrintJobOutputModel: - print_job = UM3PrintJobOutputModel(output_controller=ClusterUM3PrinterOutputController(self), - key=data["uuid"], name= data["name"]) - - configuration = PrinterConfigurationModel() - extruders = [ExtruderConfigurationModel(position = idx) for idx in range(0, self._number_of_extruders)] - for index in range(0, self._number_of_extruders): - try: - extruder_data = data["configuration"][index] - except IndexError: - continue - extruder = extruders[int(data["configuration"][index]["extruder_index"])] - extruder.setHotendID(extruder_data.get("print_core_id", "")) - extruder.setMaterial(self._createMaterialOutputModel(extruder_data.get("material", {}))) - - configuration.setExtruderConfigurations(extruders) - configuration.setPrinterType(data.get("machine_variant", "")) - print_job.updateConfiguration(configuration) - print_job.setCompatibleMachineFamilies(data.get("compatible_machine_families", [])) - print_job.stateChanged.connect(self._printJobStateChanged) - return print_job - - def _updatePrintJob(self, print_job: UM3PrintJobOutputModel, data: Dict[str, Any]) -> None: - print_job.updateTimeTotal(data["time_total"]) - print_job.updateTimeElapsed(data["time_elapsed"]) - impediments_to_printing = data.get("impediments_to_printing", []) - print_job.updateOwner(data["owner"]) - - status_set_by_impediment = False - for impediment in impediments_to_printing: - if impediment["severity"] == "UNFIXABLE": - status_set_by_impediment = True - print_job.updateState("error") - break - - if not status_set_by_impediment: - print_job.updateState(data["status"]) - - print_job.updateConfigurationChanges(self._createConfigurationChanges(data["configuration_changes_required"])) - - def _createConfigurationChanges(self, data: List[Dict[str, Any]]) -> List[ConfigurationChangeModel]: - result = [] - for change in data: - result.append(ConfigurationChangeModel(type_of_change=change["type_of_change"], - index=change["index"], - target_name=change["target_name"], - origin_name=change["origin_name"])) - return result - - def _createMaterialOutputModel(self, material_data: Dict[str, Any]) -> "MaterialOutputModel": - material_manager = self._application.getMaterialManager() - material_group_list = None - - # Avoid crashing if there is no "guid" field in the metadata - material_guid = material_data.get("guid") - if material_guid: - material_group_list = material_manager.getMaterialGroupListByGUID(material_guid) - - # This can happen if the connected machine has no material in one or more extruders (if GUID is empty), or the - # material is unknown to Cura, so we should return an "empty" or "unknown" material model. - if material_group_list is None: - material_name = i18n_catalog.i18nc("@label:material", "Empty") if len(material_data.get("guid", "")) == 0 \ - else i18n_catalog.i18nc("@label:material", "Unknown") - - return MaterialOutputModel(guid = material_data.get("guid", ""), - type = material_data.get("material", ""), - color = material_data.get("color", ""), - brand = material_data.get("brand", ""), - name = material_data.get("name", material_name) - ) - - # Sort the material groups by "is_read_only = True" first, and then the name alphabetically. - read_only_material_group_list = list(filter(lambda x: x.is_read_only, material_group_list)) - non_read_only_material_group_list = list(filter(lambda x: not x.is_read_only, material_group_list)) - material_group = None - if read_only_material_group_list: - read_only_material_group_list = sorted(read_only_material_group_list, key = lambda x: x.name) - material_group = read_only_material_group_list[0] - elif non_read_only_material_group_list: - non_read_only_material_group_list = sorted(non_read_only_material_group_list, key = lambda x: x.name) - material_group = non_read_only_material_group_list[0] - - if material_group: - container = material_group.root_material_node.getContainer() - color = container.getMetaDataEntry("color_code") - brand = container.getMetaDataEntry("brand") - material_type = container.getMetaDataEntry("material") - name = container.getName() - else: - Logger.log("w", - "Unable to find material with guid {guid}. Using data as provided by cluster".format( - guid=material_data["guid"])) - color = material_data["color"] - brand = material_data["brand"] - material_type = material_data["material"] - name = i18n_catalog.i18nc("@label:material", "Empty") if material_data["material"] == "empty" \ - else i18n_catalog.i18nc("@label:material", "Unknown") - return MaterialOutputModel(guid = material_data["guid"], type = material_type, - brand = brand, color = color, name = name) - - def _updatePrinter(self, printer: PrinterOutputModel, data: Dict[str, Any]) -> None: - # For some unknown reason the cluster wants UUID for everything, except for sending a job directly to a printer. - # Then we suddenly need the unique name. So in order to not have to mess up all the other code, we save a mapping. - self._printer_uuid_to_unique_name_mapping[data["uuid"]] = data["unique_name"] - - definitions = ContainerRegistry.getInstance().findDefinitionContainers(name = data["machine_variant"]) - if not definitions: - Logger.log("w", "Unable to find definition for machine variant %s", data["machine_variant"]) - return - - machine_definition = definitions[0] - - printer.updateName(data["friendly_name"]) - printer.updateKey(data["uuid"]) - printer.updateType(data["machine_variant"]) - - if data["status"] != "unreachable": - self._application.getDiscoveredPrintersModel().updateDiscoveredPrinter(data["ip_address"], - name = data["friendly_name"], - machine_type = data["machine_variant"]) - - # Do not store the build plate information that comes from connect if the current printer has not build plate information - if "build_plate" in data and machine_definition.getMetaDataEntry("has_variant_buildplates", False): - printer.updateBuildplate(data["build_plate"]["type"]) - if not data["enabled"]: - printer.updateState("disabled") - else: - printer.updateState(data["status"]) - - for index in range(0, self._number_of_extruders): - extruder = printer.extruders[index] - try: - extruder_data = data["configuration"][index] - except IndexError: - break - - extruder.updateHotendID(extruder_data.get("print_core_id", "")) - - material_data = extruder_data["material"] - if extruder.activeMaterial is None or extruder.activeMaterial.guid != material_data["guid"]: - material = self._createMaterialOutputModel(material_data) - extruder.updateActiveMaterial(material) - - def _removeJob(self, job: UM3PrintJobOutputModel) -> bool: - if job not in self._print_jobs: - return False - - if job.assignedPrinter: - job.assignedPrinter.updateActivePrintJob(None) - job.stateChanged.disconnect(self._printJobStateChanged) - self._print_jobs.remove(job) - - return True - - def _removePrinter(self, printer: PrinterOutputModel) -> None: - self._printers.remove(printer) - if self._active_printer == printer: - self._active_printer = None - self.activePrinterChanged.emit() - - ## Sync the material profiles in Cura with the printer. - # - # This gets called when connecting to a printer as well as when sending a - # print. - def sendMaterialProfiles(self) -> None: - job = SendMaterialJob(device = self) - job.run() - -def loadJsonFromReply(reply: QNetworkReply) -> Optional[List[Dict[str, Any]]]: - try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - return None - return result - - -def checkValidGetReply(reply: QNetworkReply) -> bool: - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - - if status_code != 200: - Logger.log("w", "Got status code {status_code} while trying to get data".format(status_code=status_code)) - return False - return True - - -def findByKey(lst: List[Union[UM3PrintJobOutputModel, PrinterOutputModel]], key: str) -> Optional[UM3PrintJobOutputModel]: - for item in lst: - if item.key == key: - return item - return None diff --git a/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py b/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py deleted file mode 100644 index 103be8b01e..0000000000 --- a/plugins/UM3NetworkPrinting/src/ClusterUM3PrinterOutputController.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2017 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from cura.PrinterOutput.PrinterOutputController import PrinterOutputController - -MYPY = False -if MYPY: - from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel - - -class ClusterUM3PrinterOutputController(PrinterOutputController): - def __init__(self, output_device): - super().__init__(output_device) - self.can_pre_heat_bed = False - self.can_pre_heat_hotends = False - self.can_control_manually = False - self.can_send_raw_gcode = False - - def setJobState(self, job: "PrintJobOutputModel", state: str) -> None: - self._output_device.setJobState(job.key, state) diff --git a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py b/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py deleted file mode 100644 index b67f4d7185..0000000000 --- a/plugins/UM3NetworkPrinting/src/DiscoverUM3Action.py +++ /dev/null @@ -1,179 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -import os.path -import time -from typing import Optional, TYPE_CHECKING - -from PyQt5.QtCore import pyqtSignal, pyqtProperty, pyqtSlot, QObject - -from UM.PluginRegistry import PluginRegistry -from UM.Logger import Logger -from UM.i18n import i18nCatalog - -from cura.CuraApplication import CuraApplication -from cura.MachineAction import MachineAction -from cura.Settings.CuraContainerRegistry import CuraContainerRegistry - -from .UM3OutputDevicePlugin import UM3OutputDevicePlugin - -if TYPE_CHECKING: - from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice - -catalog = i18nCatalog("cura") - - -class DiscoverUM3Action(MachineAction): - discoveredDevicesChanged = pyqtSignal() - - def __init__(self) -> None: - super().__init__("DiscoverUM3Action", catalog.i18nc("@action","Connect via Network")) - self._qml_url = "resources/qml/DiscoverUM3Action.qml" - - self._network_plugin = None #type: Optional[UM3OutputDevicePlugin] - - self.__additional_components_view = None #type: Optional[QObject] - - CuraApplication.getInstance().engineCreatedSignal.connect(self._createAdditionalComponentsView) - - self._last_zero_conf_event_time = time.time() #type: float - - # Time to wait after a zero-conf service change before allowing a zeroconf reset - self._zero_conf_change_grace_period = 0.25 #type: float - - # Overrides the one in MachineAction. - # This requires not attention from the user (any more), so we don't need to show any 'upgrade screens'. - def needsUserInteraction(self) -> bool: - return False - - @pyqtSlot() - def startDiscovery(self): - if not self._network_plugin: - Logger.log("d", "Starting device discovery.") - self._network_plugin = CuraApplication.getInstance().getOutputDeviceManager().getOutputDevicePlugin("UM3NetworkPrinting") - self._network_plugin.discoveredDevicesChanged.connect(self._onDeviceDiscoveryChanged) - self.discoveredDevicesChanged.emit() - - ## Re-filters the list of devices. - @pyqtSlot() - def reset(self): - Logger.log("d", "Reset the list of found devices.") - if self._network_plugin: - self._network_plugin.resetLastManualDevice() - self.discoveredDevicesChanged.emit() - - @pyqtSlot() - def restartDiscovery(self): - # Ensure that there is a bit of time after a printer has been discovered. - # This is a work around for an issue with Qt 5.5.1 up to Qt 5.7 which can segfault if we do this too often. - # It's most likely that the QML engine is still creating delegates, where the python side already deleted or - # garbage collected the data. - # Whatever the case, waiting a bit ensures that it doesn't crash. - if time.time() - self._last_zero_conf_event_time > self._zero_conf_change_grace_period: - if not self._network_plugin: - self.startDiscovery() - else: - self._network_plugin.startDiscovery() - - @pyqtSlot(str, str) - def removeManualDevice(self, key, address): - if not self._network_plugin: - return - - self._network_plugin.removeManualDevice(key, address) - - @pyqtSlot(str, str) - def setManualDevice(self, key, address): - if key != "": - # This manual printer replaces a current manual printer - self._network_plugin.removeManualDevice(key) - - if address != "": - self._network_plugin.addManualDevice(address) - - def _onDeviceDiscoveryChanged(self, *args): - self._last_zero_conf_event_time = time.time() - self.discoveredDevicesChanged.emit() - - @pyqtProperty("QVariantList", notify = discoveredDevicesChanged) - def foundDevices(self): - if self._network_plugin: - - printers = list(self._network_plugin.getDiscoveredDevices().values()) - printers.sort(key = lambda k: k.name) - return printers - else: - return [] - - @pyqtSlot(str) - def setGroupName(self, group_name: str) -> None: - Logger.log("d", "Attempting to set the group name of the active machine to %s", group_name) - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - if global_container_stack: - # Update a GlobalStacks in the same group with the new group name. - group_id = global_container_stack.getMetaDataEntry("group_id") - machine_manager = CuraApplication.getInstance().getMachineManager() - for machine in machine_manager.getMachinesInGroup(group_id): - machine.setMetaDataEntry("group_name", group_name) - - # Set the default value for "hidden", which is used when you have a group with multiple types of printers - global_container_stack.setMetaDataEntry("hidden", False) - - if self._network_plugin: - # Ensure that the connection states are refreshed. - self._network_plugin.refreshConnections() - - # Associates the currently active machine with the given printer device. The network connection information will be - # stored into the metadata of the currently active machine. - @pyqtSlot(QObject) - def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None: - if self._network_plugin: - self._network_plugin.associateActiveMachineWithPrinterDevice(printer_device) - - @pyqtSlot(result = str) - def getStoredKey(self) -> str: - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - if global_container_stack: - meta_data = global_container_stack.getMetaData() - if "um_network_key" in meta_data: - return global_container_stack.getMetaDataEntry("um_network_key") - - return "" - - @pyqtSlot(result = str) - def getLastManualEntryKey(self) -> str: - if self._network_plugin: - return self._network_plugin.getLastManualDevice() - return "" - - @pyqtSlot(str, result = bool) - def existsKey(self, key: str) -> bool: - metadata_filter = {"um_network_key": key} - containers = CuraContainerRegistry.getInstance().findContainerStacks(type="machine", **metadata_filter) - return bool(containers) - - @pyqtSlot() - def loadConfigurationFromPrinter(self) -> None: - machine_manager = CuraApplication.getInstance().getMachineManager() - hotend_ids = machine_manager.printerOutputDevices[0].hotendIds - for index in range(len(hotend_ids)): - machine_manager.printerOutputDevices[0].hotendIdChanged.emit(index, hotend_ids[index]) - material_ids = machine_manager.printerOutputDevices[0].materialIds - for index in range(len(material_ids)): - machine_manager.printerOutputDevices[0].materialIdChanged.emit(index, material_ids[index]) - - def _createAdditionalComponentsView(self) -> None: - Logger.log("d", "Creating additional ui components for UM3.") - - # Create networking dialog - plugin_path = PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting") - if not plugin_path: - return - path = os.path.join(plugin_path, "resources/qml/UM3InfoComponents.qml") - self.__additional_components_view = CuraApplication.getInstance().createQmlComponent(path, {"manager": self}) - if not self.__additional_components_view: - Logger.log("w", "Could not create ui components for UM3.") - return - - # Create extra components - CuraApplication.getInstance().addAdditionalComponent("monitorButtons", self.__additional_components_view.findChild(QObject, "networkPrinterConnectButton")) diff --git a/plugins/UM3NetworkPrinting/src/ExportFileJob.py b/plugins/UM3NetworkPrinting/src/ExportFileJob.py new file mode 100644 index 0000000000..56d15bc835 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/ExportFileJob.py @@ -0,0 +1,39 @@ +from typing import List, Optional + +from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.WriteFileJob import WriteFileJob +from UM.Logger import Logger +from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication + +from .MeshFormatHandler import MeshFormatHandler + + +## Job that exports the build plate to the correct file format for the target cluster. +class ExportFileJob(WriteFileJob): + + def __init__(self, file_handler: Optional[FileHandler], nodes: List[SceneNode], firmware_version: str) -> None: + + self._mesh_format_handler = MeshFormatHandler(file_handler, firmware_version) + if not self._mesh_format_handler.is_valid: + Logger.log("e", "Missing file or mesh writer!") + return + + super().__init__(self._mesh_format_handler.writer, self._mesh_format_handler.createStream(), nodes, + self._mesh_format_handler.file_mode) + + # Determine the filename. + job_name = CuraApplication.getInstance().getPrintInformation().jobName + extension = self._mesh_format_handler.preferred_format.get("extension", "") + self.setFileName("{}.{}".format(job_name, extension)) + + ## Get the mime type of the selected export file type. + def getMimeType(self) -> str: + return self._mesh_format_handler.mime_type + + ## Get the job result as bytes as that is what we need to upload to the cluster. + def getOutput(self) -> bytes: + output = self.getStream().getvalue() + if isinstance(output, str): + output = output.encode("utf-8") + return output diff --git a/plugins/UM3NetworkPrinting/src/LegacyUM3OutputDevice.py b/plugins/UM3NetworkPrinting/src/LegacyUM3OutputDevice.py deleted file mode 100644 index 7d759264e5..0000000000 --- a/plugins/UM3NetworkPrinting/src/LegacyUM3OutputDevice.py +++ /dev/null @@ -1,647 +0,0 @@ -from typing import List, Optional - -from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState -from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel -from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel -from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel -from cura.PrinterOutput.PrinterOutputDevice import ConnectionType - -from cura.Settings.ContainerManager import ContainerManager -from cura.Settings.ExtruderManager import ExtruderManager - -from UM.FileHandler.FileHandler import FileHandler -from UM.i18n import i18nCatalog -from UM.Logger import Logger -from UM.Message import Message -from UM.PluginRegistry import PluginRegistry -from UM.Scene.SceneNode import SceneNode -from UM.Settings.ContainerRegistry import ContainerRegistry - -from PyQt5.QtNetwork import QNetworkRequest -from PyQt5.QtCore import QTimer, QUrl -from PyQt5.QtWidgets import QMessageBox - -from .LegacyUM3PrinterOutputController import LegacyUM3PrinterOutputController - -from time import time - -import json -import os - - -i18n_catalog = i18nCatalog("cura") - - -## This is the output device for the "Legacy" API of the UM3. All firmware before 4.0.1 uses this API. -# Everything after that firmware uses the ClusterUM3Output. -# The Legacy output device can only have one printer (whereas the cluster can have 0 to n). -# -# Authentication is done in a number of steps; -# 1. Request an id / key pair by sending the application & user name. (state = authRequested) -# 2. Machine sends this back and will display an approve / deny message on screen. (state = AuthReceived) -# 3. OutputDevice will poll if the button was pressed. -# 4. At this point the machine either has the state Authenticated or AuthenticationDenied. -# 5. As a final step, we verify the authentication, as this forces the QT manager to setup the authenticator. -class LegacyUM3OutputDevice(NetworkedPrinterOutputDevice): - def __init__(self, device_id, address: str, properties, parent = None) -> None: - super().__init__(device_id = device_id, address = address, properties = properties, connection_type = ConnectionType.NetworkConnection, parent = parent) - self._api_prefix = "/api/v1/" - self._number_of_extruders = 2 - - self._authentication_id = None - self._authentication_key = None - - self._authentication_counter = 0 - self._max_authentication_counter = 5 * 60 # Number of attempts before authentication timed out (5 min) - - self._authentication_timer = QTimer() - self._authentication_timer.setInterval(1000) # TODO; Add preference for update interval - self._authentication_timer.setSingleShot(False) - - self._authentication_timer.timeout.connect(self._onAuthenticationTimer) - - # The messages are created when connect is called the first time. - # This ensures that the messages are only created for devices that actually want to connect. - self._authentication_requested_message = None - self._authentication_failed_message = None - self._authentication_succeeded_message = None - self._not_authenticated_message = None - - self.authenticationStateChanged.connect(self._onAuthenticationStateChanged) - - self.setPriority(3) # Make sure the output device gets selected above local file output - self.setName(self._id) - self.setShortDescription(i18n_catalog.i18nc("@action:button Preceded by 'Ready to'.", "Print over network")) - self.setDescription(i18n_catalog.i18nc("@properties:tooltip", "Print over network")) - - self.setIconName("print") - - self._output_controller = LegacyUM3PrinterOutputController(self) - - def _createMonitorViewFromQML(self) -> None: - if self._monitor_view_qml_path is None and PluginRegistry.getInstance() is not None: - self._monitor_view_qml_path = os.path.join( - PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), - "resources", "qml", "MonitorStage.qml" - ) - super()._createMonitorViewFromQML() - - def _onAuthenticationStateChanged(self): - # We only accept commands if we are authenticated. - self._setAcceptsCommands(self._authentication_state == AuthState.Authenticated) - - if self._authentication_state == AuthState.Authenticated: - self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network.")) - elif self._authentication_state == AuthState.AuthenticationRequested: - self.setConnectionText(i18n_catalog.i18nc("@info:status", - "Connected over the network. Please approve the access request on the printer.")) - elif self._authentication_state == AuthState.AuthenticationDenied: - self.setConnectionText(i18n_catalog.i18nc("@info:status", "Connected over the network. No access to control the printer.")) - - - def _setupMessages(self): - self._authentication_requested_message = Message(i18n_catalog.i18nc("@info:status", - "Access to the printer requested. Please approve the request on the printer"), - lifetime=0, dismissable=False, progress=0, - title=i18n_catalog.i18nc("@info:title", - "Authentication status")) - - self._authentication_failed_message = Message("", title=i18n_catalog.i18nc("@info:title", "Authentication Status")) - self._authentication_failed_message.addAction("Retry", i18n_catalog.i18nc("@action:button", "Retry"), None, - i18n_catalog.i18nc("@info:tooltip", "Re-send the access request")) - self._authentication_failed_message.actionTriggered.connect(self._messageCallback) - self._authentication_succeeded_message = Message( - i18n_catalog.i18nc("@info:status", "Access to the printer accepted"), - title=i18n_catalog.i18nc("@info:title", "Authentication Status")) - - self._not_authenticated_message = Message( - i18n_catalog.i18nc("@info:status", "No access to print with this printer. Unable to send print job."), - title=i18n_catalog.i18nc("@info:title", "Authentication Status")) - self._not_authenticated_message.addAction("Request", i18n_catalog.i18nc("@action:button", "Request Access"), - None, i18n_catalog.i18nc("@info:tooltip", - "Send access request to the printer")) - self._not_authenticated_message.actionTriggered.connect(self._messageCallback) - - def _messageCallback(self, message_id=None, action_id="Retry"): - if action_id == "Request" or action_id == "Retry": - if self._authentication_failed_message: - self._authentication_failed_message.hide() - if self._not_authenticated_message: - self._not_authenticated_message.hide() - - self._requestAuthentication() - - def connect(self): - super().connect() - self._setupMessages() - global_container = CuraApplication.getInstance().getGlobalContainerStack() - if global_container: - self._authentication_id = global_container.getMetaDataEntry("network_authentication_id", None) - self._authentication_key = global_container.getMetaDataEntry("network_authentication_key", None) - - def close(self): - super().close() - if self._authentication_requested_message: - self._authentication_requested_message.hide() - if self._authentication_failed_message: - self._authentication_failed_message.hide() - if self._authentication_succeeded_message: - self._authentication_succeeded_message.hide() - self._sending_gcode = False - self._compressing_gcode = False - self._authentication_timer.stop() - - ## Send all material profiles to the printer. - def _sendMaterialProfiles(self): - Logger.log("i", "Sending material profiles to printer") - - # TODO: Might want to move this to a job... - for container in ContainerRegistry.getInstance().findInstanceContainers(type="material"): - try: - xml_data = container.serialize() - if xml_data == "" or xml_data is None: - continue - - names = ContainerManager.getInstance().getLinkedMaterials(container.getId()) - if names: - # There are other materials that share this GUID. - if not container.isReadOnly(): - continue # If it's not readonly, it's created by user, so skip it. - - file_name = "none.xml" - - self.postForm("materials", "form-data; name=\"file\";filename=\"%s\"" % file_name, xml_data.encode(), on_finished=None) - - except NotImplementedError: - # If the material container is not the most "generic" one it can't be serialized an will raise a - # NotImplementedError. We can simply ignore these. - pass - - def requestWrite(self, nodes: List["SceneNode"], file_name: Optional[str] = None, limit_mimetypes: bool = False, - file_handler: Optional["FileHandler"] = None, filter_by_machine: bool = False, **kwargs) -> None: - if not self.activePrinter: - # No active printer. Unable to write - return - - if self.activePrinter.state not in ["idle", ""]: - # Printer is not able to accept commands. - return - - if self._authentication_state != AuthState.Authenticated: - # Not authenticated, so unable to send job. - return - - self.writeStarted.emit(self) - - gcode_dict = getattr(CuraApplication.getInstance().getController().getScene(), "gcode_dict", []) - active_build_plate_id = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate - gcode_list = gcode_dict[active_build_plate_id] - - if not gcode_list: - # Unable to find g-code. Nothing to send - return - - self._gcode = gcode_list - - errors = self._checkForErrors() - if errors: - text = i18n_catalog.i18nc("@label", "Unable to start a new print job.") - informative_text = i18n_catalog.i18nc("@label", - "There is an issue with the configuration of your Ultimaker, which makes it impossible to start the print. " - "Please resolve this issues before continuing.") - detailed_text = "" - for error in errors: - detailed_text += error + "\n" - - CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"), - text, - informative_text, - detailed_text, - buttons=QMessageBox.Ok, - icon=QMessageBox.Critical, - callback = self._messageBoxCallback - ) - return # Don't continue; Errors must block sending the job to the printer. - - # There might be multiple things wrong with the configuration. Check these before starting. - warnings = self._checkForWarnings() - - if warnings: - text = i18n_catalog.i18nc("@label", "Are you sure you wish to print with the selected configuration?") - informative_text = i18n_catalog.i18nc("@label", - "There is a mismatch between the configuration or calibration of the printer and Cura. " - "For the best result, always slice for the PrintCores and materials that are inserted in your printer.") - detailed_text = "" - for warning in warnings: - detailed_text += warning + "\n" - - CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Mismatched configuration"), - text, - informative_text, - detailed_text, - buttons=QMessageBox.Yes + QMessageBox.No, - icon=QMessageBox.Question, - callback=self._messageBoxCallback - ) - return - - # No warnings or errors, so we're good to go. - self._startPrint() - - # Notify the UI that a switch to the print monitor should happen - CuraApplication.getInstance().getController().setActiveStage("MonitorStage") - - def _startPrint(self): - Logger.log("i", "Sending print job to printer.") - if self._sending_gcode: - self._error_message = Message( - i18n_catalog.i18nc("@info:status", - "Sending new jobs (temporarily) blocked, still sending the previous print job.")) - self._error_message.show() - return - - self._sending_gcode = True - - self._send_gcode_start = time() - self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1, - i18n_catalog.i18nc("@info:title", "Sending Data")) - self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "") - self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered) - self._progress_message.show() - - compressed_gcode = self._compressGCode() - if compressed_gcode is None: - # Abort was called. - return - - file_name = "%s.gcode.gz" % CuraApplication.getInstance().getPrintInformation().jobName - self.postForm("print_job", "form-data; name=\"file\";filename=\"%s\"" % file_name, compressed_gcode, - on_finished=self._onPostPrintJobFinished) - - return - - def _progressMessageActionTriggered(self, message_id=None, action_id=None): - if action_id == "Abort": - Logger.log("d", "User aborted sending print to remote.") - self._progress_message.hide() - self._compressing_gcode = False - self._sending_gcode = False - CuraApplication.getInstance().getController().setActiveStage("PrepareStage") - - def _onPostPrintJobFinished(self, reply): - self._progress_message.hide() - self._sending_gcode = False - - def _onUploadPrintJobProgress(self, bytes_sent, bytes_total): - if bytes_total > 0: - new_progress = bytes_sent / bytes_total * 100 - # Treat upload progress as response. Uploading can take more than 10 seconds, so if we don't, we can get - # timeout responses if this happens. - self._last_response_time = time() - if new_progress > self._progress_message.getProgress(): - self._progress_message.show() # Ensure that the message is visible. - self._progress_message.setProgress(bytes_sent / bytes_total * 100) - else: - self._progress_message.setProgress(0) - - self._progress_message.hide() - - def _messageBoxCallback(self, button): - def delayedCallback(): - if button == QMessageBox.Yes: - self._startPrint() - else: - CuraApplication.getInstance().getController().setActiveStage("PrepareStage") - # For some unknown reason Cura on OSX will hang if we do the call back code - # immediately without first returning and leaving QML's event system. - - QTimer.singleShot(100, delayedCallback) - - def _checkForErrors(self): - errors = [] - print_information = CuraApplication.getInstance().getPrintInformation() - if not print_information.materialLengths: - Logger.log("w", "There is no material length information. Unable to check for errors.") - return errors - - for index, extruder in enumerate(self.activePrinter.extruders): - # Due to airflow issues, both slots must be loaded, regardless if they are actually used or not. - if extruder.hotendID == "": - # No Printcore loaded. - errors.append(i18n_catalog.i18nc("@info:status", "No Printcore loaded in slot {slot_number}".format(slot_number=index + 1))) - - if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0: - # The extruder is by this print. - if extruder.activeMaterial is None: - # No active material - errors.append(i18n_catalog.i18nc("@info:status", "No material loaded in slot {slot_number}".format(slot_number=index + 1))) - return errors - - def _checkForWarnings(self): - warnings = [] - print_information = CuraApplication.getInstance().getPrintInformation() - - if not print_information.materialLengths: - Logger.log("w", "There is no material length information. Unable to check for warnings.") - return warnings - - extruder_manager = ExtruderManager.getInstance() - - for index, extruder in enumerate(self.activePrinter.extruders): - if index < len(print_information.materialLengths) and print_information.materialLengths[index] != 0: - # The extruder is by this print. - - # TODO: material length check - - # Check if the right Printcore is active. - variant = extruder_manager.getExtruderStack(index).findContainer({"type": "variant"}) - if variant: - if variant.getName() != extruder.hotendID: - warnings.append(i18n_catalog.i18nc("@label", "Different PrintCore (Cura: {cura_printcore_name}, Printer: {remote_printcore_name}) selected for extruder {extruder_id}".format(cura_printcore_name = variant.getName(), remote_printcore_name = extruder.hotendID, extruder_id = index + 1))) - else: - Logger.log("w", "Unable to find variant.") - - # Check if the right material is loaded. - local_material = extruder_manager.getExtruderStack(index).findContainer({"type": "material"}) - if local_material: - if extruder.activeMaterial.guid != local_material.getMetaDataEntry("GUID"): - Logger.log("w", "Extruder %s has a different material (%s) as Cura (%s)", index + 1, extruder.activeMaterial.guid, local_material.getMetaDataEntry("GUID")) - warnings.append(i18n_catalog.i18nc("@label", "Different material (Cura: {0}, Printer: {1}) selected for extruder {2}").format(local_material.getName(), extruder.activeMaterial.name, index + 1)) - else: - Logger.log("w", "Unable to find material.") - - return warnings - - def _update(self): - if not super()._update(): - return - if self._authentication_state == AuthState.NotAuthenticated: - if self._authentication_id is None and self._authentication_key is None: - # This machine doesn't have any authentication, so request it. - self._requestAuthentication() - elif self._authentication_id is not None and self._authentication_key is not None: - # We have authentication info, but we haven't checked it out yet. Do so now. - self._verifyAuthentication() - elif self._authentication_state == AuthState.AuthenticationReceived: - # We have an authentication, but it's not confirmed yet. - self._checkAuthentication() - - # We don't need authentication for requesting info, so we can go right ahead with requesting this. - self.get("printer", on_finished=self._onGetPrinterDataFinished) - self.get("print_job", on_finished=self._onGetPrintJobFinished) - - def _resetAuthenticationRequestedMessage(self): - if self._authentication_requested_message: - self._authentication_requested_message.hide() - self._authentication_timer.stop() - self._authentication_counter = 0 - - def _onAuthenticationTimer(self): - self._authentication_counter += 1 - self._authentication_requested_message.setProgress( - self._authentication_counter / self._max_authentication_counter * 100) - if self._authentication_counter > self._max_authentication_counter: - self._authentication_timer.stop() - Logger.log("i", "Authentication timer ended. Setting authentication to denied for printer: %s" % self._id) - self.setAuthenticationState(AuthState.AuthenticationDenied) - self._resetAuthenticationRequestedMessage() - self._authentication_failed_message.show() - - def _verifyAuthentication(self): - Logger.log("d", "Attempting to verify authentication") - # This will ensure that the "_onAuthenticationRequired" is triggered, which will setup the authenticator. - self.get("auth/verify", on_finished=self._onVerifyAuthenticationCompleted) - - def _onVerifyAuthenticationCompleted(self, reply): - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code == 401: - # Something went wrong; We somehow tried to verify authentication without having one. - Logger.log("d", "Attempted to verify auth without having one.") - self._authentication_id = None - self._authentication_key = None - self.setAuthenticationState(AuthState.NotAuthenticated) - elif status_code == 403 and self._authentication_state != AuthState.Authenticated: - # If we were already authenticated, we probably got an older message back all of the sudden. Drop that. - Logger.log("d", - "While trying to verify the authentication state, we got a forbidden response. Our own auth state was %s. ", - self._authentication_state) - self.setAuthenticationState(AuthState.AuthenticationDenied) - self._authentication_failed_message.show() - elif status_code == 200: - self.setAuthenticationState(AuthState.Authenticated) - - def _checkAuthentication(self): - Logger.log("d", "Checking if authentication is correct for id %s and key %s", self._authentication_id, self._getSafeAuthKey()) - self.get("auth/check/" + str(self._authentication_id), on_finished=self._onCheckAuthenticationFinished) - - def _onCheckAuthenticationFinished(self, reply): - if str(self._authentication_id) not in reply.url().toString(): - Logger.log("w", "Got an old id response.") - # Got response for old authentication ID. - return - try: - data = json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.log("w", "Received an invalid authentication check from printer: Not valid JSON.") - return - - if data.get("message", "") == "authorized": - Logger.log("i", "Authentication was approved") - self.setAuthenticationState(AuthState.Authenticated) - self._saveAuthentication() - - # Double check that everything went well. - self._verifyAuthentication() - - # Notify the user. - self._resetAuthenticationRequestedMessage() - self._authentication_succeeded_message.show() - elif data.get("message", "") == "unauthorized": - Logger.log("i", "Authentication was denied.") - self.setAuthenticationState(AuthState.AuthenticationDenied) - self._authentication_failed_message.show() - - def _saveAuthentication(self) -> None: - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - if self._authentication_key is None: - Logger.log("e", "Authentication key is None, nothing to save.") - return - if self._authentication_id is None: - Logger.log("e", "Authentication id is None, nothing to save.") - return - if global_container_stack: - global_container_stack.setMetaDataEntry("network_authentication_key", self._authentication_key) - - global_container_stack.setMetaDataEntry("network_authentication_id", self._authentication_id) - - # Force save so we are sure the data is not lost. - CuraApplication.getInstance().saveStack(global_container_stack) - Logger.log("i", "Authentication succeeded for id %s and key %s", self._authentication_id, - self._getSafeAuthKey()) - else: - Logger.log("e", "Unable to save authentication for id %s and key %s", self._authentication_id, - self._getSafeAuthKey()) - - def _onRequestAuthenticationFinished(self, reply): - try: - data = json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.log("w", "Received an invalid authentication request reply from printer: Not valid JSON.") - self.setAuthenticationState(AuthState.NotAuthenticated) - return - - self.setAuthenticationState(AuthState.AuthenticationReceived) - self._authentication_id = data["id"] - self._authentication_key = data["key"] - Logger.log("i", "Got a new authentication ID (%s) and KEY (%s). Waiting for authorization.", - self._authentication_id, self._getSafeAuthKey()) - - def _requestAuthentication(self): - self._authentication_requested_message.show() - self._authentication_timer.start() - - # Reset any previous authentication info. If this isn't done, the "Retry" action on the failed message might - # give issues. - self._authentication_key = None - self._authentication_id = None - - self.post("auth/request", - json.dumps({"application": "Cura-" + CuraApplication.getInstance().getVersion(), - "user": self._getUserName()}), - on_finished=self._onRequestAuthenticationFinished) - - self.setAuthenticationState(AuthState.AuthenticationRequested) - - def _onAuthenticationRequired(self, reply, authenticator): - if self._authentication_id is not None and self._authentication_key is not None: - Logger.log("d", - "Authentication was required for printer: %s. Setting up authenticator with ID %s and key %s", - self._id, self._authentication_id, self._getSafeAuthKey()) - authenticator.setUser(self._authentication_id) - authenticator.setPassword(self._authentication_key) - else: - Logger.log("d", "No authentication is available to use for %s, but we did got a request for it.", self._id) - - def _onGetPrintJobFinished(self, reply): - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - - if not self._printers: - return # Ignore the data for now, we don't have info about a printer yet. - printer = self._printers[0] - - if status_code == 200: - try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.log("w", "Received an invalid print job state message: Not valid JSON.") - return - if printer.activePrintJob is None: - print_job = PrintJobOutputModel(output_controller=self._output_controller) - printer.updateActivePrintJob(print_job) - else: - print_job = printer.activePrintJob - print_job.updateState(result["state"]) - print_job.updateTimeElapsed(result["time_elapsed"]) - print_job.updateTimeTotal(result["time_total"]) - print_job.updateName(result["name"]) - elif status_code == 404: - # No job found, so delete the active print job (if any!) - printer.updateActivePrintJob(None) - else: - Logger.log("w", - "Got status code {status_code} while trying to get printer data".format(status_code=status_code)) - - def materialHotendChangedMessage(self, callback): - CuraApplication.getInstance().messageBox(i18n_catalog.i18nc("@window:title", "Sync with your printer"), - i18n_catalog.i18nc("@label", - "Would you like to use your current printer configuration in Cura?"), - i18n_catalog.i18nc("@label", - "The PrintCores and/or materials on your printer differ from those within your current project. For the best result, always slice for the PrintCores and materials that are inserted in your printer."), - buttons=QMessageBox.Yes + QMessageBox.No, - icon=QMessageBox.Question, - callback=callback - ) - - def _onGetPrinterDataFinished(self, reply): - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code == 200: - try: - result = json.loads(bytes(reply.readAll()).decode("utf-8")) - except json.decoder.JSONDecodeError: - Logger.log("w", "Received an invalid printer state message: Not valid JSON.") - return - - if not self._printers: - # Quickest way to get the firmware version is to grab it from the zeroconf. - firmware_version = self._properties.get(b"firmware_version", b"").decode("utf-8") - self._printers = [PrinterOutputModel(output_controller=self._output_controller, number_of_extruders=self._number_of_extruders, firmware_version=firmware_version)] - self._printers[0].setCameraUrl(QUrl("http://" + self._address + ":8080/?action=stream")) - for extruder in self._printers[0].extruders: - extruder.activeMaterialChanged.connect(self.materialIdChanged) - extruder.hotendIDChanged.connect(self.hotendIdChanged) - self.printersChanged.emit() - - # LegacyUM3 always has a single printer. - printer = self._printers[0] - printer.updateBedTemperature(result["bed"]["temperature"]["current"]) - printer.updateTargetBedTemperature(result["bed"]["temperature"]["target"]) - printer.updateState(result["status"]) - - try: - # If we're still handling the request, we should ignore remote for a bit. - if not printer.getController().isPreheatRequestInProgress(): - printer.updateIsPreheating(result["bed"]["pre_heat"]["active"]) - except KeyError: - # Older firmwares don't support preheating, so we need to fake it. - pass - - head_position = result["heads"][0]["position"] - printer.updateHeadPosition(head_position["x"], head_position["y"], head_position["z"]) - - for index in range(0, self._number_of_extruders): - temperatures = result["heads"][0]["extruders"][index]["hotend"]["temperature"] - extruder = printer.extruders[index] - extruder.updateTargetHotendTemperature(temperatures["target"]) - extruder.updateHotendTemperature(temperatures["current"]) - - material_guid = result["heads"][0]["extruders"][index]["active_material"]["guid"] - - if extruder.activeMaterial is None or extruder.activeMaterial.guid != material_guid: - # Find matching material (as we need to set brand, type & color) - containers = ContainerRegistry.getInstance().findInstanceContainers(type="material", - GUID=material_guid) - if containers: - color = containers[0].getMetaDataEntry("color_code") - brand = containers[0].getMetaDataEntry("brand") - material_type = containers[0].getMetaDataEntry("material") - name = containers[0].getName() - else: - # Unknown material. - color = "#00000000" - brand = "Unknown" - material_type = "Unknown" - name = "Unknown" - material = MaterialOutputModel(guid=material_guid, type=material_type, - brand=brand, color=color, name = name) - extruder.updateActiveMaterial(material) - - try: - hotend_id = result["heads"][0]["extruders"][index]["hotend"]["id"] - except KeyError: - hotend_id = "" - printer.extruders[index].updateHotendID(hotend_id) - - else: - Logger.log("w", - "Got status code {status_code} while trying to get printer data".format(status_code = status_code)) - - ## Convenience function to "blur" out all but the last 5 characters of the auth key. - # This can be used to debug print the key, without it compromising the security. - def _getSafeAuthKey(self): - if self._authentication_key is not None: - result = self._authentication_key[-5:] - result = "********" + result - return result - - return self._authentication_key diff --git a/plugins/UM3NetworkPrinting/src/LegacyUM3PrinterOutputController.py b/plugins/UM3NetworkPrinting/src/LegacyUM3PrinterOutputController.py deleted file mode 100644 index 9e372d4113..0000000000 --- a/plugins/UM3NetworkPrinting/src/LegacyUM3PrinterOutputController.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) 2019 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from cura.PrinterOutput.PrinterOutputController import PrinterOutputController -from PyQt5.QtCore import QTimer -from UM.Version import Version - -MYPY = False -if MYPY: - from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel - from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel - - -class LegacyUM3PrinterOutputController(PrinterOutputController): - def __init__(self, output_device): - super().__init__(output_device) - self._preheat_bed_timer = QTimer() - self._preheat_bed_timer.setSingleShot(True) - self._preheat_bed_timer.timeout.connect(self._onPreheatBedTimerFinished) - self._preheat_printer = None - - self.can_control_manually = False - self.can_send_raw_gcode = False - - # Are we still waiting for a response about preheat? - # We need this so we can already update buttons, so it feels more snappy. - self._preheat_request_in_progress = False - - def isPreheatRequestInProgress(self): - return self._preheat_request_in_progress - - def setJobState(self, job: "PrintJobOutputModel", state: str): - data = "{\"target\": \"%s\"}" % state - self._output_device.put("print_job/state", data, on_finished=None) - - def setTargetBedTemperature(self, printer: "PrinterOutputModel", temperature: float): - data = str(temperature) - self._output_device.put("printer/bed/temperature/target", data, on_finished = self._onPutBedTemperatureCompleted) - - def _onPutBedTemperatureCompleted(self, reply): - if Version(self._preheat_printer.firmwareVersion) < Version("3.5.92"): - # If it was handling a preheat, it isn't anymore. - self._preheat_request_in_progress = False - - def _onPutPreheatBedCompleted(self, reply): - self._preheat_request_in_progress = False - - def moveHead(self, printer: "PrinterOutputModel", x, y, z, speed): - head_pos = printer._head_position - new_x = head_pos.x + x - new_y = head_pos.y + y - new_z = head_pos.z + z - data = "{\n\"x\":%s,\n\"y\":%s,\n\"z\":%s\n}" %(new_x, new_y, new_z) - self._output_device.put("printer/heads/0/position", data, on_finished=None) - - def homeBed(self, printer): - self._output_device.put("printer/heads/0/position/z", "0", on_finished=None) - - def _onPreheatBedTimerFinished(self): - self.setTargetBedTemperature(self._preheat_printer, 0) - self._preheat_printer.updateIsPreheating(False) - self._preheat_request_in_progress = True - - def cancelPreheatBed(self, printer: "PrinterOutputModel"): - self.preheatBed(printer, temperature=0, duration=0) - self._preheat_bed_timer.stop() - printer.updateIsPreheating(False) - - def preheatBed(self, printer: "PrinterOutputModel", temperature, duration): - try: - temperature = round(temperature) # The API doesn't allow floating point. - duration = round(duration) - except ValueError: - return # Got invalid values, can't pre-heat. - - if duration > 0: - data = """{"temperature": "%i", "timeout": "%i"}""" % (temperature, duration) - else: - data = """{"temperature": "%i"}""" % temperature - - # Real bed pre-heating support is implemented from 3.5.92 and up. - - if Version(printer.firmwareVersion) < Version("3.5.92"): - # No firmware-side duration support then, so just set target bed temp and set a timer. - self.setTargetBedTemperature(printer, temperature=temperature) - self._preheat_bed_timer.setInterval(duration * 1000) - self._preheat_bed_timer.start() - self._preheat_printer = printer - printer.updateIsPreheating(True) - return - - self._output_device.put("printer/bed/pre_heat", data, on_finished = self._onPutPreheatBedCompleted) - printer.updateIsPreheating(True) - self._preheat_request_in_progress = True - - diff --git a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py index c3cd82a86d..9927bf744e 100644 --- a/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py +++ b/plugins/UM3NetworkPrinting/src/MeshFormatHandler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import io from typing import Optional, Dict, Union, List, cast @@ -32,7 +32,7 @@ class MeshFormatHandler: # \return A dict with the file format details, with the following keys: # {id: str, extension: str, description: str, mime_type: str, mode: int, hide_in_file_dialog: bool} @property - def preferred_format(self) -> Optional[Dict[str, Union[str, int, bool]]]: + def preferred_format(self) -> Dict[str, Union[str, int, bool]]: return self._preferred_format ## Gets the file writer for the given file handler and mime type. @@ -90,6 +90,7 @@ class MeshFormatHandler: machine_file_formats = global_stack.getMetaDataEntry("file_formats").split(";") machine_file_formats = [file_type.strip() for file_type in machine_file_formats] + # Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. if "application/x-ufp" not in machine_file_formats and Version(firmware_version) >= Version("4.4"): machine_file_formats = ["application/x-ufp"] + machine_file_formats diff --git a/plugins/UM3NetworkPrinting/src/Models.py b/plugins/UM3NetworkPrinting/src/Models.py deleted file mode 100644 index c5b9b16665..0000000000 --- a/plugins/UM3NetworkPrinting/src/Models.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - - -## Base model that maps kwargs to instance attributes. -class BaseModel: - def __init__(self, **kwargs) -> None: - self.__dict__.update(kwargs) - self.validate() - - # Validates the model, raising an exception if the model is invalid. - def validate(self) -> None: - pass - - -## Class representing a material that was fetched from the cluster API. -class ClusterMaterial(BaseModel): - def __init__(self, guid: str, version: int, **kwargs) -> None: - self.guid = guid # type: str - self.version = version # type: int - super().__init__(**kwargs) - - def validate(self) -> None: - if not self.guid: - raise ValueError("guid is required on ClusterMaterial") - if not self.version: - raise ValueError("version is required on ClusterMaterial") - - -## Class representing a local material that was fetched from the container registry. -class LocalMaterial(BaseModel): - def __init__(self, GUID: str, id: str, version: int, **kwargs) -> None: - self.GUID = GUID # type: str - self.id = id # type: str - self.version = version # type: int - super().__init__(**kwargs) - - # - def validate(self) -> None: - super().validate() - if not self.GUID: - raise ValueError("guid is required on LocalMaterial") - if not self.version: - raise ValueError("version is required on LocalMaterial") - if not self.id: - raise ValueError("id is required on LocalMaterial") diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py b/plugins/UM3NetworkPrinting/src/Models/BaseModel.py similarity index 81% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py rename to plugins/UM3NetworkPrinting/src/Models/BaseModel.py index 18a8cb5cba..3d38a4b116 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/BaseCloudModel.py +++ b/plugins/UM3NetworkPrinting/src/Models/BaseModel.py @@ -1,13 +1,23 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime, timezone -from typing import Dict, Union, TypeVar, Type, List, Any - -from ...Models import BaseModel +from typing import TypeVar, Dict, List, Any, Type, Union -## Base class for the models used in the interface with the Ultimaker cloud APIs. -class BaseCloudModel(BaseModel): +# Type variable used in the parse methods below, which should be a subclass of BaseModel. +T = TypeVar("T", bound="BaseModel") + + +class BaseModel: + + def __init__(self, **kwargs) -> None: + self.__dict__.update(kwargs) + self.validate() + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + pass + ## Checks whether the two models are equal. # \param other: The other model. # \return True if they are equal, False if they are different. @@ -24,9 +34,6 @@ class BaseCloudModel(BaseModel): def toDict(self) -> Dict[str, Any]: return self.__dict__ - # Type variable used in the parse methods below, which should be a subclass of BaseModel. - T = TypeVar("T", bound=BaseModel) - ## Parses a single model. # \param model_class: The model class. # \param values: The value of the model, which is usually a dictionary, but may also be already parsed. diff --git a/plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py b/plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py new file mode 100644 index 0000000000..a441f28292 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py @@ -0,0 +1,16 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseModel import BaseModel + + +class ClusterMaterial(BaseModel): + def __init__(self, guid: str, version: int, **kwargs) -> None: + self.guid = guid # type: str + self.version = version # type: int + super().__init__(**kwargs) + + def validate(self) -> None: + if not self.guid: + raise ValueError("guid is required on ClusterMaterial") + if not self.version: + raise ValueError("version is required on ClusterMaterial") diff --git a/plugins/UM3NetworkPrinting/src/ConfigurationChangeModel.py b/plugins/UM3NetworkPrinting/src/Models/ConfigurationChangeModel.py similarity index 78% rename from plugins/UM3NetworkPrinting/src/ConfigurationChangeModel.py rename to plugins/UM3NetworkPrinting/src/Models/ConfigurationChangeModel.py index 7136d8b93f..58fae03679 100644 --- a/plugins/UM3NetworkPrinting/src/ConfigurationChangeModel.py +++ b/plugins/UM3NetworkPrinting/src/Models/ConfigurationChangeModel.py @@ -1,17 +1,17 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from PyQt5.QtCore import pyqtProperty, QObject -from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot BLOCKING_CHANGE_TYPES = [ "material_insert", "buildplate_change" ] + class ConfigurationChangeModel(QObject): def __init__(self, type_of_change: str, index: int, target_name: str, origin_name: str) -> None: super().__init__() - self._type_of_change = type_of_change - # enum = ["material", "print_core_change"] + self._type_of_change = type_of_change # enum = ["material", "print_core_change"] self._can_override = self._type_of_change not in BLOCKING_CHANGE_TYPES self._index = index self._target_name = target_name @@ -35,4 +35,4 @@ class ConfigurationChangeModel(QObject): @pyqtProperty(bool, constant = True) def canOverride(self) -> bool: - return self._can_override \ No newline at end of file + return self._can_override diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterResponse.py similarity index 91% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterResponse.py index a872a6ba68..7ecfe8b0a3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterResponse.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterResponse.py @@ -1,13 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel ## Class representing a cloud connected cluster. -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterResponse(BaseCloudModel): +class CloudClusterResponse(BaseModel): + ## Creates a new cluster response object. # \param cluster_id: The secret unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. # \param host_guid: The unique identifier of the print cluster host, e.g. 'e90ae0ac-1257-4403-91ee-a44c9b7e8050'. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterStatus.py similarity index 52% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterStatus.py index b0250c2ebb..330e61d343 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudClusterStatus.py @@ -1,26 +1,26 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime from typing import List, Dict, Union, Any -from .CloudClusterPrinterStatus import CloudClusterPrinterStatus -from .CloudClusterPrintJobStatus import CloudClusterPrintJobStatus -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel +from .ClusterPrinterStatus import ClusterPrinterStatus +from .ClusterPrintJobStatus import ClusterPrintJobStatus # Model that represents the status of the cluster for the cloud -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterStatus(BaseCloudModel): +class CloudClusterStatus(BaseModel): + ## Creates a new cluster status model object. # \param printers: The latest status of each printer in the cluster. # \param print_jobs: The latest status of each print job in the cluster. # \param generated_time: The datetime when the object was generated on the server-side. def __init__(self, - printers: List[Union[CloudClusterPrinterStatus, Dict[str, Any]]], - print_jobs: List[Union[CloudClusterPrintJobStatus, Dict[str, Any]]], + printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]], + print_jobs: List[Union[ClusterPrintJobStatus, Dict[str, Any]]], generated_time: Union[str, datetime], **kwargs) -> None: self.generated_time = self.parseDate(generated_time) - self.printers = self.parseModels(CloudClusterPrinterStatus, printers) - self.print_jobs = self.parseModels(CloudClusterPrintJobStatus, print_jobs) + self.printers = self.parseModels(ClusterPrinterStatus, printers) + self.print_jobs = self.parseModels(ClusterPrintJobStatus, print_jobs) super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudError.py similarity index 88% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudError.py index b53361022e..9381e4b8cf 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudError.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudError.py @@ -1,13 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Dict, Optional, Any -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel ## Class representing errors generated by the cloud servers, according to the JSON-API standard. -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudError(BaseCloudModel): +class CloudError(BaseModel): + ## Creates a new error object. # \param id: Unique identifier for this particular occurrence of the problem. # \param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobResponse.py similarity index 88% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobResponse.py index 79196ee38c..a1880e8751 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobResponse.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobResponse.py @@ -1,13 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel # Model that represents the response received from the cloud after requesting to upload a print job -# Spec: https://api-staging.ultimaker.com/cura/v1/spec -class CloudPrintJobResponse(BaseCloudModel): +class CloudPrintJobResponse(BaseModel): + ## Creates a new print job response model. # \param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. # \param status: The status of the print job. @@ -28,6 +28,5 @@ class CloudPrintJobResponse(BaseCloudModel): self.upload_url = upload_url self.content_type = content_type self.status_description = status_description - # TODO: Implement slicing details self.slicing_details = slicing_details super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobUploadRequest.py similarity index 77% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobUploadRequest.py index e59c571558..ff705ae495 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintJobUploadRequest.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintJobUploadRequest.py @@ -1,11 +1,11 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel # Model that represents the request to upload a print job to the cloud -# Spec: https://api-staging.ultimaker.com/cura/v1/spec -class CloudPrintJobUploadRequest(BaseCloudModel): +class CloudPrintJobUploadRequest(BaseModel): + ## Creates a new print job upload request. # \param job_name: The name of the print job. # \param file_size: The size of the file in bytes. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintResponse.py similarity index 85% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py rename to plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintResponse.py index 919d1b3c3a..b108f40e27 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudPrintResponse.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/CloudPrintResponse.py @@ -1,14 +1,14 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from datetime import datetime from typing import Optional, Union -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel # Model that represents the responses received from the cloud after requesting a job to be printed. -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudPrintResponse(BaseCloudModel): +class CloudPrintResponse(BaseModel): + ## Creates a new print response object. # \param job_id: The unique ID of a print job inside of the cluster. This ID is generated by Cura Connect. # \param status: The status of the print request (queued or failed). diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterBuildPlate.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterBuildPlate.py new file mode 100644 index 0000000000..a5a392488d --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterBuildPlate.py @@ -0,0 +1,13 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ..BaseModel import BaseModel + + +## Class representing a cluster printer +class ClusterBuildPlate(BaseModel): + + ## Create a new build plate + # \param type: The type of build plate glass or aluminium + def __init__(self, type: str = "glass", **kwargs) -> None: + self.type = type + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py similarity index 81% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py index aba1cdb755..24c9a577f9 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintCoreConfiguration.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintCoreConfiguration.py @@ -1,26 +1,27 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Union, Dict, Optional, Any from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.Models.ExtruderOutputModel import ExtruderOutputModel -from .CloudClusterPrinterConfigurationMaterial import CloudClusterPrinterConfigurationMaterial -from .BaseCloudModel import BaseCloudModel + +from .ClusterPrinterConfigurationMaterial import ClusterPrinterConfigurationMaterial +from ..BaseModel import BaseModel ## Class representing a cloud cluster printer configuration -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrintCoreConfiguration(BaseCloudModel): +class ClusterPrintCoreConfiguration(BaseModel): + ## Creates a new cloud cluster printer configuration object # \param extruder_index: The position of the extruder on the machine as list index. Numbered from left to right. # \param material: The material of a configuration object in a cluster printer. May be in a dict or an object. # \param nozzle_diameter: The diameter of the print core at this position in millimeters, e.g. '0.4'. # \param print_core_id: The type of print core inserted at this position, e.g. 'AA 0.4'. def __init__(self, extruder_index: int, - material: Union[None, Dict[str, Any], CloudClusterPrinterConfigurationMaterial], + material: Union[None, Dict[str, Any], ClusterPrinterConfigurationMaterial], print_core_id: Optional[str] = None, **kwargs) -> None: self.extruder_index = extruder_index - self.material = self.parseModel(CloudClusterPrinterConfigurationMaterial, material) if material else None + self.material = self.parseModel(ClusterPrinterConfigurationMaterial, material) if material else None self.print_core_id = print_core_id super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConfigurationChange.py similarity index 84% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConfigurationChange.py index 9ff4154666..88251bbf53 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConfigurationChange.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConfigurationChange.py @@ -1,13 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel ## Model for the types of changes that are needed before a print job can start -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrintJobConfigurationChange(BaseCloudModel): +class ClusterPrintJobConfigurationChange(BaseModel): + ## Creates a new print job constraint. # \param type_of_change: The type of configuration change, one of: "material", "print_core_change" # \param index: The hotend slot or extruder index to change diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConstraint.py similarity index 75% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConstraint.py index 8236ec06b9..9239004b18 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobConstraint.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobConstraint.py @@ -1,13 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel ## Class representing a cloud cluster print job constraint -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrintJobConstraints(BaseCloudModel): +class ClusterPrintJobConstraints(BaseModel): + ## Creates a new print job constraint. # \param require_printer_name: Unique name of the printer that this job should be printed on. # Should be one of the unique_name field values in the cluster, e.g. 'ultimakersystem-ccbdd30044ec' diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobImpediment.py similarity index 68% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobImpediment.py index 12b67996c1..5a8f0aa46d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobImpediment.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobImpediment.py @@ -1,13 +1,14 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from .BaseCloudModel import BaseCloudModel +from ..BaseModel import BaseModel ## Class representing the reasons that prevent this job from being printed on the associated printer -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrintJobImpediment(BaseCloudModel): +class ClusterPrintJobImpediment(BaseModel): + ## Creates a new print job constraint. - # \param translation_key: A string indicating a reason the print cannot be printed, such as 'does_not_fit_in_build_volume' + # \param translation_key: A string indicating a reason the print cannot be printed, + # such as 'does_not_fit_in_build_volume' # \param severity: A number indicating the severity of the problem, with higher being more severe def __init__(self, translation_key: str, severity: int, **kwargs) -> None: self.translation_key = translation_key diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py similarity index 78% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py index 4a3823ccca..8b35fb7b5a 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrintJobStatus.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrintJobStatus.py @@ -1,22 +1,25 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import List, Optional, Union, Dict, Any +from PyQt5.QtCore import QUrl + from cura.PrinterOutput.Models.PrinterConfigurationModel import PrinterConfigurationModel -from ...UM3PrintJobOutputModel import UM3PrintJobOutputModel -from ...ConfigurationChangeModel import ConfigurationChangeModel -from ..CloudOutputController import CloudOutputController -from .BaseCloudModel import BaseCloudModel -from .CloudClusterBuildPlate import CloudClusterBuildPlate -from .CloudClusterPrintJobConfigurationChange import CloudClusterPrintJobConfigurationChange -from .CloudClusterPrintJobImpediment import CloudClusterPrintJobImpediment -from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration -from .CloudClusterPrintJobConstraint import CloudClusterPrintJobConstraints + +from .ClusterBuildPlate import ClusterBuildPlate +from .ClusterPrintJobConfigurationChange import ClusterPrintJobConfigurationChange +from .ClusterPrintJobImpediment import ClusterPrintJobImpediment +from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration +from .ClusterPrintJobConstraint import ClusterPrintJobConstraints +from ..UM3PrintJobOutputModel import UM3PrintJobOutputModel +from ..ConfigurationChangeModel import ConfigurationChangeModel +from ..BaseModel import BaseModel +from ...ClusterOutputController import ClusterOutputController ## Model for the status of a single print job in a cluster. -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrintJobStatus(BaseCloudModel): +class ClusterPrintJobStatus(BaseModel): + ## Creates a new cloud print job status model. # \param assigned_to: The name of the printer this job is assigned to while being queued. # \param configuration: The required print core configurations of this print job. @@ -45,21 +48,21 @@ class CloudClusterPrintJobStatus(BaseCloudModel): # printer def __init__(self, created_at: str, force: bool, machine_variant: str, name: str, started: bool, status: str, time_total: int, uuid: str, - configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]], - constraints: List[Union[Dict[str, Any], CloudClusterPrintJobConstraints]], + configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]], + constraints: List[Union[Dict[str, Any], ClusterPrintJobConstraints]], last_seen: Optional[float] = None, network_error_count: Optional[int] = None, owner: Optional[str] = None, printer_uuid: Optional[str] = None, time_elapsed: Optional[int] = None, assigned_to: Optional[str] = None, deleted_at: Optional[str] = None, printed_on_uuid: Optional[str] = None, configuration_changes_required: List[ - Union[Dict[str, Any], CloudClusterPrintJobConfigurationChange]] = None, - build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, + Union[Dict[str, Any], ClusterPrintJobConfigurationChange]] = None, + build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None, compatible_machine_families: List[str] = None, - impediments_to_printing: List[Union[Dict[str, Any], CloudClusterPrintJobImpediment]] = None, + impediments_to_printing: List[Union[Dict[str, Any], ClusterPrintJobImpediment]] = None, **kwargs) -> None: self.assigned_to = assigned_to - self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration) - self.constraints = self.parseModels(CloudClusterPrintJobConstraints, constraints) + self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration) + self.constraints = self.parseModels(ClusterPrintJobConstraints, constraints) self.created_at = created_at self.force = force self.last_seen = last_seen @@ -76,19 +79,19 @@ class CloudClusterPrintJobStatus(BaseCloudModel): self.deleted_at = deleted_at self.printed_on_uuid = printed_on_uuid - self.configuration_changes_required = self.parseModels(CloudClusterPrintJobConfigurationChange, + self.configuration_changes_required = self.parseModels(ClusterPrintJobConfigurationChange, configuration_changes_required) \ if configuration_changes_required else [] - self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None + self.build_plate = self.parseModel(ClusterBuildPlate, build_plate) if build_plate else None self.compatible_machine_families = compatible_machine_families if compatible_machine_families else [] - self.impediments_to_printing = self.parseModels(CloudClusterPrintJobImpediment, impediments_to_printing) \ + self.impediments_to_printing = self.parseModels(ClusterPrintJobImpediment, impediments_to_printing) \ if impediments_to_printing else [] super().__init__(**kwargs) ## Creates an UM3 print job output model based on this cloud cluster print job. # \param printer: The output model of the printer - def createOutputModel(self, controller: CloudOutputController) -> UM3PrintJobOutputModel: + def createOutputModel(self, controller: ClusterOutputController) -> UM3PrintJobOutputModel: model = UM3PrintJobOutputModel(controller, self.uuid, self.name) self.updateOutputModel(model) return model diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterConfigurationMaterial.py similarity index 82% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterConfigurationMaterial.py index db09133a14..378a885a3b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterConfigurationMaterial.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterConfigurationMaterial.py @@ -1,14 +1,16 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. from typing import Optional -from UM.Logger import Logger from cura.CuraApplication import CuraApplication from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel -from .BaseCloudModel import BaseCloudModel + +from ..BaseModel import BaseModel -## Class representing a cloud cluster printer configuration -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrinterConfigurationMaterial(BaseCloudModel): +## Class representing a cloud cluster printer configuration +class ClusterPrinterConfigurationMaterial(BaseModel): + ## Creates a new material configuration model. # \param brand: The brand of material in this print core, e.g. 'Ultimaker'. # \param color: The color of material in this print core, e.g. 'Blue'. @@ -45,11 +47,9 @@ class CloudClusterPrinterConfigurationMaterial(BaseCloudModel): material_type = container.getMetaDataEntry("material") name = container.getName() else: - Logger.log("w", "Unable to find material with guid {guid}. Using data as provided by cluster" - .format(guid = self.guid)) color = self.color brand = self.brand material_type = self.material name = "Empty" if self.material == "empty" else "Unknown" - return MaterialOutputModel(guid = self.guid, type = material_type, brand = brand, color = color, name = name) + return MaterialOutputModel(guid=self.guid, type=material_type, brand=brand, color=color, name=name) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py similarity index 83% rename from plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py index 0b76ba1bce..bd9d59b910 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models/CloudClusterPrinterStatus.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterStatus.py @@ -1,17 +1,20 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import List, Union, Dict, Optional, Any +from PyQt5.QtCore import QUrl + from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel -from .CloudClusterBuildPlate import CloudClusterBuildPlate -from .CloudClusterPrintCoreConfiguration import CloudClusterPrintCoreConfiguration -from .BaseCloudModel import BaseCloudModel + +from .ClusterBuildPlate import ClusterBuildPlate +from .ClusterPrintCoreConfiguration import ClusterPrintCoreConfiguration +from ..BaseModel import BaseModel ## Class representing a cluster printer -# Spec: https://api-staging.ultimaker.com/connect/v1/spec -class CloudClusterPrinterStatus(BaseCloudModel): +class ClusterPrinterStatus(BaseModel): + ## Creates a new cluster printer status # \param enabled: A printer can be disabled if it should not receive new jobs. By default every printer is enabled. # \param firmware_version: Firmware version installed on the printer. Can differ for each printer in a cluster. @@ -30,12 +33,12 @@ class CloudClusterPrinterStatus(BaseCloudModel): # \param build_plate: The build plate that is on the printer def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str, status: str, unique_name: str, uuid: str, - configuration: List[Union[Dict[str, Any], CloudClusterPrintCoreConfiguration]], + configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]], reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None, firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None, - build_plate: Union[Dict[str, Any], CloudClusterBuildPlate] = None, **kwargs) -> None: + build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None, **kwargs) -> None: - self.configuration = self.parseModels(CloudClusterPrintCoreConfiguration, configuration) + self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration) self.enabled = enabled self.firmware_version = firmware_version self.friendly_name = friendly_name @@ -48,7 +51,7 @@ class CloudClusterPrinterStatus(BaseCloudModel): self.maintenance_required = maintenance_required self.firmware_update_status = firmware_update_status self.latest_available_firmware = latest_available_firmware - self.build_plate = self.parseModel(CloudClusterBuildPlate, build_plate) if build_plate else None + self.build_plate = self.parseModel(ClusterBuildPlate, build_plate) if build_plate else None super().__init__(**kwargs) ## Creates a new output model. @@ -66,6 +69,7 @@ class CloudClusterPrinterStatus(BaseCloudModel): model.updateType(self.machine_variant) model.updateState(self.status if self.enabled else "disabled") model.updateBuildplate(self.build_plate.type if self.build_plate else "glass") + model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address))) for configuration, extruder_output, extruder_config in \ zip(self.configuration, model.extruders, model.printerConfiguration.extruderConfigurations): diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/PrinterSystemStatus.py b/plugins/UM3NetworkPrinting/src/Models/Http/PrinterSystemStatus.py new file mode 100644 index 0000000000..01539bd365 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/Http/PrinterSystemStatus.py @@ -0,0 +1,17 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from ..BaseModel import BaseModel + + +## Class representing the system status of a printer. +class PrinterSystemStatus(BaseModel): + + def __init__(self, guid: str, firmware: str, hostname: str, name: str, platform: str, variant: str, **kwargs + ) -> None: + self.guid = guid + self.firmware = firmware + self.hostname = hostname + self.name = name + self.platform = platform + self.variant = variant + super().__init__(**kwargs) diff --git a/plugins/UM3NetworkPrinting/src/Models/LocalMaterial.py b/plugins/UM3NetworkPrinting/src/Models/LocalMaterial.py new file mode 100644 index 0000000000..b45289e1c4 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Models/LocalMaterial.py @@ -0,0 +1,21 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseModel import BaseModel + + +class LocalMaterial(BaseModel): + + def __init__(self, GUID: str, id: str, version: int, **kwargs) -> None: + self.GUID = GUID # type: str + self.id = id # type: str + self.version = version # type: int + super().__init__(**kwargs) + + def validate(self) -> None: + super().validate() + if not self.GUID: + raise ValueError("guid is required on LocalMaterial") + if not self.version: + raise ValueError("version is required on LocalMaterial") + if not self.id: + raise ValueError("id is required on LocalMaterial") diff --git a/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py b/plugins/UM3NetworkPrinting/src/Models/UM3PrintJobOutputModel.py similarity index 69% rename from plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py rename to plugins/UM3NetworkPrinting/src/Models/UM3PrintJobOutputModel.py index b627b6e9c8..bfde233a35 100644 --- a/plugins/UM3NetworkPrinting/src/UM3PrintJobOutputModel.py +++ b/plugins/UM3NetworkPrinting/src/Models/UM3PrintJobOutputModel.py @@ -1,21 +1,22 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - from typing import List from PyQt5.QtCore import pyqtProperty, pyqtSignal +from PyQt5.QtGui import QImage from cura.PrinterOutput.Models.PrintJobOutputModel import PrintJobOutputModel from cura.PrinterOutput.PrinterOutputController import PrinterOutputController + from .ConfigurationChangeModel import ConfigurationChangeModel class UM3PrintJobOutputModel(PrintJobOutputModel): configurationChangesChanged = pyqtSignal() - def __init__(self, output_controller: "PrinterOutputController", key: str = "", name: str = "", parent=None) -> None: + def __init__(self, output_controller: PrinterOutputController, key: str = "", name: str = "", parent=None) -> None: super().__init__(output_controller, key, name, parent) - self._configuration_changes = [] # type: List[ConfigurationChangeModel] + self._configuration_changes = [] # type: List[ConfigurationChangeModel] @pyqtProperty("QVariantList", notify=configurationChangesChanged) def configurationChanges(self) -> List[ConfigurationChangeModel]: @@ -26,3 +27,8 @@ class UM3PrintJobOutputModel(PrintJobOutputModel): return self._configuration_changes = changes self.configurationChangesChanged.emit() + + def updatePreviewImageData(self, data: bytes) -> None: + image = QImage() + image.loadFromData(data) + self.updatePreviewImage(image) diff --git a/plugins/UM3NetworkPrinting/src/Models/__init__.py b/plugins/UM3NetworkPrinting/src/Models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py new file mode 100644 index 0000000000..1025e384d6 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -0,0 +1,155 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from json import JSONDecodeError +from typing import Callable, List, Optional, Dict, Union, Any, Type, cast, TypeVar, Tuple + +from PyQt5.QtCore import QUrl +from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply + +from UM.Logger import Logger + +from ..Models.BaseModel import BaseModel +from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus +from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus +from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus + + +## The generic type variable used to document the methods below. +ClusterApiClientModel = TypeVar("ClusterApiClientModel", bound=BaseModel) + + +## The ClusterApiClient is responsible for all network calls to local network clusters. +class ClusterApiClient: + + PRINTER_API_PREFIX = "/api/v1" + CLUSTER_API_PREFIX = "/cluster-api/v1" + + ## Initializes a new cluster API client. + # \param address: The network address of the cluster to call. + # \param on_error: The callback to be called whenever we receive errors from the server. + def __init__(self, address: str, on_error: Callable) -> None: + super().__init__() + self._manager = QNetworkAccessManager() + self._address = address + self._on_error = on_error + # In order to avoid garbage collection we keep the callbacks in this list. + self._anti_gc_callbacks = [] # type: List[Callable[[], None]] + + ## Get printer system information. + # \param on_finished: The callback in case the response is successful. + def getSystem(self, on_finished: Callable) -> None: + url = "{}/system/".format(self.PRINTER_API_PREFIX) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, on_finished, PrinterSystemStatus) + + ## Get the printers in the cluster. + # \param on_finished: The callback in case the response is successful. + def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None: + url = "{}/printers/".format(self.CLUSTER_API_PREFIX) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, on_finished, ClusterPrinterStatus) + + ## Get the print jobs in the cluster. + # \param on_finished: The callback in case the response is successful. + def getPrintJobs(self, on_finished: Callable[[List[ClusterPrintJobStatus]], Any]) -> None: + url = "{}/print_jobs/".format(self.CLUSTER_API_PREFIX) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, on_finished, ClusterPrintJobStatus) + + ## Move a print job to the top of the queue. + def movePrintJobToTop(self, print_job_uuid: str) -> None: + url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid) + self._manager.post(self._createEmptyRequest(url), json.dumps({"to_position": 0, "list": "queued"}).encode()) + + ## Delete a print job from the queue. + def deletePrintJob(self, print_job_uuid: str) -> None: + url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) + self._manager.deleteResource(self._createEmptyRequest(url)) + + ## Set the state of a print job. + def setPrintJobState(self, print_job_uuid: str, state: str) -> None: + url = "{}/print_jobs/{}/action".format(self.CLUSTER_API_PREFIX, print_job_uuid) + # We rewrite 'resume' to 'print' here because we are using the old print job action endpoints. + action = "print" if state == "resume" else state + self._manager.put(self._createEmptyRequest(url), json.dumps({"action": action}).encode()) + + ## Get the preview image data of a print job. + def getPrintJobPreviewImage(self, print_job_uuid: str, on_finished: Callable) -> None: + url = "{}/print_jobs/{}/preview_image".format(self.CLUSTER_API_PREFIX, print_job_uuid) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, on_finished) + + ## We override _createEmptyRequest in order to add the user credentials. + # \param url: The URL to request + # \param content_type: The type of the body contents. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + url = QUrl("http://" + self._address + path) + request = QNetworkRequest(url) + if content_type: + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) + return request + + ## Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. + # \param reply: The reply from the server. + # \return A tuple with a status code and a dictionary. + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + try: + response = bytes(reply.readAll()).decode() + return status_code, json.loads(response) + except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: + Logger.logException("e", "Could not parse the cluster response: %s", err) + return status_code, {"errors": [err]} + + ## Parses the given models and calls the correct callback depending on the result. + # \param response: The response from the server, after being converted to a dict. + # \param on_finished: The callback in case the response is successful. + # \param model_class: The type of the model to convert the response to. It may either be a single record or a list. + def _parseModels(self, response: Dict[str, Any], + on_finished: Union[Callable[[ClusterApiClientModel], Any], + Callable[[List[ClusterApiClientModel]], Any]], + model_class: Type[ClusterApiClientModel]) -> None: + if isinstance(response, list): + results = [model_class(**c) for c in response] # type: List[ClusterApiClientModel] + on_finished_list = cast(Callable[[List[ClusterApiClientModel]], Any], on_finished) + on_finished_list(results) + else: + result = model_class(**response) # type: ClusterApiClientModel + on_finished_item = cast(Callable[[ClusterApiClientModel], Any], on_finished) + on_finished_item(result) + + ## Creates a callback function so that it includes the parsing of the response into the correct model. + # The callback is added to the 'finished' signal of the reply. + # \param reply: The reply that should be listened to. + # \param on_finished: The callback in case the response is successful. + def _addCallback(self, + reply: QNetworkReply, + on_finished: Union[Callable[[ClusterApiClientModel], Any], + Callable[[List[ClusterApiClientModel]], Any]], + model: Type[ClusterApiClientModel] = None, + ) -> None: + + def parse() -> None: + self._anti_gc_callbacks.remove(parse) + + # Don't try to parse the reply if we didn't get one + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + return + + if reply.error() > 0: + self._on_error(reply.errorString()) + return + + # If no parse model is given, simply return the raw data in the callback. + if not model: + on_finished(reply.readAll()) + return + + # Otherwise parse the result and return the formatted data in the callback. + status_code, response = self._parseReply(reply) + self._parseModels(response, on_finished, model) + + self._anti_gc_callbacks.append(parse) + reply.finished.connect(parse) diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py new file mode 100644 index 0000000000..758760ce86 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -0,0 +1,172 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, Dict, List + +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty +from PyQt5.QtNetwork import QNetworkReply + +from UM.FileHandler.FileHandler import FileHandler +from UM.Message import Message +from UM.i18n import i18nCatalog +from UM.Scene.SceneNode import SceneNode +from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState +from cura.PrinterOutput.PrinterOutputDevice import ConnectionType + +from .ClusterApiClient import ClusterApiClient +from ..ExportFileJob import ExportFileJob +from ..SendMaterialJob import SendMaterialJob +from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice + + +I18N_CATALOG = i18nCatalog("cura") + + +class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): + + activeCameraUrlChanged = pyqtSignal() + + def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], parent=None) -> None: + + super().__init__( + device_id=device_id, + address=address, + properties=properties, + connection_type=ConnectionType.NetworkConnection, + parent=parent + ) + + # API client for making requests to the print cluster. + self._cluster_api = ClusterApiClient(address, on_error=lambda error: print(error)) + # We don't have authentication over local networking, so we're always authenticated. + self.setAuthenticationState(AuthState.Authenticated) + self._setInterfaceElements() + self._active_camera_url = QUrl() # type: QUrl + + ## Set all the interface elements and texts for this output device. + def _setInterfaceElements(self) -> None: + self.setPriority(3) # Make sure the output device gets selected above local file output + self.setName(self._id) + self.setShortDescription(I18N_CATALOG.i18nc("@action:button Preceded by 'Ready to'.", "Print over network")) + self.setDescription(I18N_CATALOG.i18nc("@properties:tooltip", "Print over network")) + self.setConnectionText(I18N_CATALOG.i18nc("@info:status", "Connected over the network")) + + ## Called when the connection to the cluster changes. + def connect(self) -> None: + super().connect() + self._update() + self.sendMaterialProfiles() + + @pyqtProperty(QUrl, notify=activeCameraUrlChanged) + def activeCameraUrl(self) -> QUrl: + return self._active_camera_url + + @pyqtSlot(QUrl, name="setActiveCameraUrl") + def setActiveCameraUrl(self, camera_url: QUrl) -> None: + if self._active_camera_url != camera_url: + self._active_camera_url = camera_url + self.activeCameraUrlChanged.emit() + + @pyqtSlot(name="openPrintJobControlPanel") + def openPrintJobControlPanel(self) -> None: + QDesktopServices.openUrl(QUrl("http://" + self._address + "/print_jobs")) + + @pyqtSlot(name="openPrinterControlPanel") + def openPrinterControlPanel(self) -> None: + QDesktopServices.openUrl(QUrl("http://" + self._address + "/printers")) + + @pyqtSlot(str, name="sendJobToTop") + def sendJobToTop(self, print_job_uuid: str) -> None: + self._cluster_api.movePrintJobToTop(print_job_uuid) + + @pyqtSlot(str, name="deleteJobFromQueue") + def deleteJobFromQueue(self, print_job_uuid: str) -> None: + self._cluster_api.deletePrintJob(print_job_uuid) + + @pyqtSlot(str, name="forceSendJob") + def forceSendJob(self, print_job_uuid: str) -> None: + pass # TODO + + ## Set the remote print job state. + # \param print_job_uuid: The UUID of the print job to set the state for. + # \param action: The action to undertake ('pause', 'resume', 'abort'). + def setJobState(self, print_job_uuid: str, action: str) -> None: + self._cluster_api.setPrintJobState(print_job_uuid, action) + + def _update(self) -> None: + super()._update() + self._cluster_api.getPrinters(self._updatePrinters) + self._cluster_api.getPrintJobs(self._updatePrintJobs) + self._updatePrintJobPreviewImages() + + ## Sync the material profiles in Cura with the printer. + # This gets called when connecting to a printer as well as when sending a print. + def sendMaterialProfiles(self) -> None: + job = SendMaterialJob(device=self) + job.run() + + ## Send a print job to the cluster. + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, + file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None: + + # Show an error message if we're already sending a job. + if self._progress.visible: + return Message( + text=I18N_CATALOG.i18nc("@info:status", "Please wait until the current job has been sent."), + title=I18N_CATALOG.i18nc("@info:title", "Print error"), + lifetime=10 + ).show() + + self.writeStarted.emit(self) + + # Make sure the printer is aware of all new materials as the new print job might contain one. + self.sendMaterialProfiles() + + # Export the scene to the correct file type. + job = ExportFileJob(file_handler=file_handler, nodes=nodes, firmware_version=self.firmwareVersion) + job.finished.connect(self._onPrintJobCreated) + job.start() + + ## Handler for when the print job was created locally. + # It can now be sent over the network. + def _onPrintJobCreated(self, job: ExportFileJob) -> None: + self._progress.show() + parts = [ + self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"), + self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput()) + ] + self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, + on_progress=self._onPrintJobUploadProgress) + + ## Handler for print job upload progress. + def _onPrintJobUploadProgress(self, bytes_sent: int, bytes_total: int) -> None: + percentage = (bytes_sent / bytes_total) if bytes_total else 0 + self._progress.setProgress(percentage * 100) + self.writeProgress.emit() + + ## Handler for when the print job was fully uploaded to the cluster. + def _onPrintUploadCompleted(self, _: QNetworkReply) -> None: + self._progress.hide() + Message( + text=I18N_CATALOG.i18nc("@info:status", "Print job was successfully sent to the printer."), + title=I18N_CATALOG.i18nc("@info:title", "Data Sent"), + lifetime=5 + ).show() + self.writeFinished.emit() + + ## Displays the given message if uploading the mesh has failed + # \param message: The message to display. + def _onUploadError(self, message: str = None) -> None: + self._progress.hide() + Message( + text=message or I18N_CATALOG.i18nc("@info:text", "Could not upload the data to the printer."), + title=I18N_CATALOG.i18nc("@info:title", "Network error"), + lifetime=10 + ).show() + self.writeError.emit() + + ## Download all the images from the cluster and load their data in the print job models. + def _updatePrintJobPreviewImages(self): + for print_job in self._print_jobs: + if print_job.getPreviewImage() is None: + self._cluster_api.getPrintJobPreviewImage(print_job.key, print_job.updatePreviewImageData) diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py new file mode 100644 index 0000000000..47a7df7faf --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDeviceManager.py @@ -0,0 +1,205 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Dict, Optional, Callable + +from UM import i18nCatalog +from UM.Signal import Signal +from UM.Version import Version + +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice +from cura.Settings.GlobalStack import GlobalStack + +from .ZeroConfClient import ZeroConfClient +from .ClusterApiClient import ClusterApiClient +from .LocalClusterOutputDevice import LocalClusterOutputDevice +from ..CloudFlowMessage import CloudFlowMessage +from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus + + +I18N_CATALOG = i18nCatalog("cura") + + +## The LocalClusterOutputDeviceManager is responsible for discovering and managing local networked clusters. +class LocalClusterOutputDeviceManager: + + META_NETWORK_KEY = "um_network_key" + + MANUAL_DEVICES_PREFERENCE_KEY = "um3networkprinting/manual_instances" + MIN_SUPPORTED_CLUSTER_VERSION = Version("4.0.0") + + # The translation catalog for this device. + I18N_CATALOG = i18nCatalog("cura") + + # Signal emitted when the list of discovered devices changed. + discoveredDevicesChanged = Signal() + + def __init__(self) -> None: + + # Persistent dict containing the networked clusters. + self._discovered_devices = {} # type: Dict[str, LocalClusterOutputDevice] + self._output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() + + # Hook up ZeroConf client. + self._zero_conf_client = ZeroConfClient() + self._zero_conf_client.addedNetworkCluster.connect(self._onDeviceDiscovered) + self._zero_conf_client.removedNetworkCluster.connect(self._onDiscoveredDeviceRemoved) + + # Persistent dict containing manually connected clusters. + self._manual_instances = {} # type: Dict[str, Optional[Callable]] + + ## Start the network discovery. + def start(self) -> None: + self._zero_conf_client.start() + # Load all manual devices. + self._manual_instances = self._getStoredManualInstances() + for address in self._manual_instances: + self.addManualDevice(address) + + ## Stop network discovery and clean up discovered devices. + def stop(self) -> None: + self._zero_conf_client.stop() + # Cleanup all manual devices. + for instance_name in list(self._discovered_devices): + self._onDiscoveredDeviceRemoved(instance_name) + + ## Add a networked printer manually by address. + def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: + self._manual_instances[address] = callback + new_manual_devices = ",".join(self._manual_instances.keys()) + CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_manual_devices) + api_client = ClusterApiClient(address, lambda error: print(error)) + api_client.getSystem(lambda status: self._onCheckManualDeviceResponse(address, status)) + + ## Remove a manually added networked printer. + def removeManualDevice(self, device_id: str, address: Optional[str] = None) -> None: + if device_id not in self._discovered_devices and address is not None: + device_id = "manual:{}".format(address) + + if device_id in self._discovered_devices: + address = address or self._discovered_devices[device_id].ipAddress + self._onDiscoveredDeviceRemoved(device_id) + + if address in self._manual_instances: + manual_instance_callback = self._manual_instances.pop(address) + new_devices = ",".join(self._manual_instances.keys()) + CuraApplication.getInstance().getPreferences().setValue(self.MANUAL_DEVICES_PREFERENCE_KEY, new_devices) + if manual_instance_callback: + CuraApplication.getInstance().callLater(manual_instance_callback, False, address) + + ## Force reset all network device connections. + def refreshConnections(self): + self._connectToActiveMachine() + + ## Callback for when the active machine was changed by the user or a new remote cluster was found. + def _connectToActiveMachine(self): + active_machine = CuraApplication.getInstance().getGlobalContainerStack() + if not active_machine: + return + + output_device_manager = CuraApplication.getInstance().getOutputDeviceManager() + stored_device_id = active_machine.getMetaDataEntry(self.META_NETWORK_KEY) + for device in self._discovered_devices.values(): + if device.key == stored_device_id: + # Connect to it if the stored key matches. + self._connectToOutputDevice(device, active_machine) + elif device.key in output_device_manager.getOutputDeviceIds(): + # Remove device if it is not meant for the active machine. + CuraApplication.getInstance().getOutputDeviceManager().removeOutputDevice(device.key) + + ## Callback for when a manual device check request was responded to. + def _onCheckManualDeviceResponse(self, address: str, status: PrinterSystemStatus) -> None: + callback = self._manual_instances.get(address, None) + if callback is None: + return + self._onDeviceDiscovered("manual:{}".format(address), address, { + b"name": status.name.encode("utf-8"), + b"address": address.encode("utf-8"), + b"manual": b"true", + b"incomplete": b"true", + b"temporary": b"true" + }) + CuraApplication.getInstance().callLater(callback, True, address) + + ## Returns a dict of printer BOM numbers to machine types. + # These numbers are available in the machine definition already so we just search for them here. + @staticmethod + def _getPrinterTypeIdentifiers() -> Dict[str, str]: + container_registry = CuraApplication.getInstance().getContainerRegistry() + ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.") + found_machine_type_identifiers = {} # type: Dict[str, str] + for machine in ultimaker_machines: + machine_bom_number = machine.get("firmware_update_info", {}).get("id", None) + machine_type = machine.get("id", None) + if machine_bom_number and machine_type: + found_machine_type_identifiers[str(machine_bom_number)] = machine_type + return found_machine_type_identifiers + + ## Add a new device. + def _onDeviceDiscovered(self, key: str, address: str, properties: Dict[bytes, bytes]) -> None: + cluster_size = int(properties.get(b"cluster_size", -1)) + machine_identifier = properties.get(b"machine", b"").decode("utf-8") + printer_type_identifiers = self._getPrinterTypeIdentifiers() + + # Detect the machine type based on the BOM number that is sent over the network. + properties[b"printer_type"] = b"Unknown" + for bom, p_type in printer_type_identifiers.items(): + if machine_identifier.startswith(bom): + properties[b"printer_type"] = bytes(p_type, encoding="utf8") + break + + # We no longer support legacy devices, so check that here. + if cluster_size == -1: + return + + device = LocalClusterOutputDevice(key, address, properties) + CuraApplication.getInstance().getDiscoveredPrintersModel().addDiscoveredPrinter( + ip_address=address, + key=device.getId(), + name=device.getName(), + create_callback=self._createMachineFromDiscoveredDevice, + machine_type=device.printerType, + device=device + ) + self._discovered_devices[device.getId()] = device + self.discoveredDevicesChanged.emit() + self._connectToActiveMachine() + + ## Remove a device. + def _onDiscoveredDeviceRemoved(self, device_id: str) -> None: + device = self._discovered_devices.pop(device_id, None) # type: Optional[LocalClusterOutputDevice] + if not device: + return + device.close() + CuraApplication.getInstance().getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address) + self.discoveredDevicesChanged.emit() + + ## Create a machine instance based on the discovered network printer. + def _createMachineFromDiscoveredDevice(self, device_id: str) -> None: + device = self._discovered_devices.get(device_id) + if device is None: + return + + # The newly added machine is automatically activated. + CuraApplication.getInstance().getMachineManager().addMachine(device.printerType, device.name) + active_machine = CuraApplication.getInstance().getGlobalContainerStack() + if not active_machine: + return + active_machine.setMetaDataEntry(self.META_NETWORK_KEY, device.key) + active_machine.setMetaDataEntry("group_name", device.name) + self._connectToOutputDevice(device, active_machine) + CloudFlowMessage(device.ipAddress).show() # Nudge the user to start using Ultimaker Cloud. + + ## Load the user-configured manual devices from Cura preferences. + def _getStoredManualInstances(self) -> Dict[str, Optional[Callable]]: + preferences = CuraApplication.getInstance().getPreferences() + preferences.addPreference(self.MANUAL_DEVICES_PREFERENCE_KEY, "") + manual_instances = preferences.getValue(self.MANUAL_DEVICES_PREFERENCE_KEY).split(",") + return {address: None for address in manual_instances} + + ## Add a device to the current active machine. + @staticmethod + def _connectToOutputDevice(device: PrinterOutputDevice, active_machine: GlobalStack) -> None: + device.connect() + active_machine.addConfiguredConnectionType(device.connectionType.value) + CuraApplication.getInstance().getOutputDeviceManager().addOutputDevice(device) diff --git a/plugins/UM3NetworkPrinting/src/Network/ZeroConfClient.py b/plugins/UM3NetworkPrinting/src/Network/ZeroConfClient.py new file mode 100644 index 0000000000..b6416b2bd0 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Network/ZeroConfClient.py @@ -0,0 +1,140 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from queue import Queue +from threading import Thread, Event +from time import time +from typing import Optional + +from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo + +from UM.Logger import Logger +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication + + +## The ZeroConfClient handles all network discovery logic. +# It emits signals when new network services were found or disappeared. +class ZeroConfClient: + + # The discovery protocol name for Ultimaker printers. + ZERO_CONF_NAME = u"_ultimaker._tcp.local." + + # Signals emitted when new services were discovered or removed on the network. + addedNetworkCluster = Signal() + removedNetworkCluster = Signal() + + def __init__(self) -> None: + self._zero_conf = None # type: Optional[Zeroconf] + self._zero_conf_browser = None # type: Optional[ServiceBrowser] + self._service_changed_request_queue = None # type: Optional[Queue] + self._service_changed_request_event = None # type: Optional[Event] + self._service_changed_request_thread = None # type: Optional[Thread] + + ## The ZeroConf service changed requests are handled in a separate thread so we don't block the UI. + # We can also re-schedule the requests when they fail to get detailed service info. + # Any new or re-reschedule requests will be appended to the request queue and the thread will process them. + def start(self) -> None: + self._service_changed_request_queue = Queue() + self._service_changed_request_event = Event() + self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True) + self._service_changed_request_thread.start() + self._zero_conf = Zeroconf() + self._zero_conf_browser = ServiceBrowser(self._zero_conf, self.ZERO_CONF_NAME, [self._queueService]) + + # Cleanup ZeroConf resources. + def stop(self) -> None: + if self._zero_conf is not None: + self._zero_conf.close() + self._zero_conf = None + if self._zero_conf_browser is not None: + self._zero_conf_browser.cancel() + self._zero_conf_browser = None + + ## Handles a change is discovered network services. + def _queueService(self, zeroconf: Zeroconf, service_type, name: str, state_change: ServiceStateChange) -> None: + item = (zeroconf, service_type, name, state_change) + if not self._service_changed_request_queue or not self._service_changed_request_event: + return + self._service_changed_request_queue.put(item) + self._service_changed_request_event.set() + + ## Callback for when a ZeroConf service has changes. + def _handleOnServiceChangedRequests(self) -> None: + if not self._service_changed_request_queue or not self._service_changed_request_event: + return + + while True: + # Wait for the event to be set + self._service_changed_request_event.wait(timeout=5.0) + + # Stop if the application is shutting down + if CuraApplication.getInstance().isShuttingDown(): + return + + self._service_changed_request_event.clear() + + # Handle all pending requests + reschedule_requests = [] # A list of requests that have failed so later they will get re-scheduled + while not self._service_changed_request_queue.empty(): + request = self._service_changed_request_queue.get() + zeroconf, service_type, name, state_change = request + try: + result = self._onServiceChanged(zeroconf, service_type, name, state_change) + if not result: + reschedule_requests.append(request) + except Exception: + Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled", + service_type, name) + reschedule_requests.append(request) + + # Re-schedule the failed requests if any + if reschedule_requests: + for request in reschedule_requests: + self._service_changed_request_queue.put(request) + + ## Handler for zeroConf detection. + # Return True or False indicating if the process succeeded. + # Note that this function can take over 3 seconds to complete. Be careful calling it from the main thread. + def _onServiceChanged(self, zero_conf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange + ) -> bool: + if state_change == ServiceStateChange.Added: + return self._onServiceAdded(zero_conf, service_type, name) + elif state_change == ServiceStateChange.Removed: + return self._onServiceRemoved(name) + return True + + ## Handler for when a ZeroConf service was added. + def _onServiceAdded(self, zero_conf: Zeroconf, service_type: str, name: str) -> bool: + # First try getting info from zero-conf cache + info = ServiceInfo(service_type, name, properties={}) + for record in zero_conf.cache.entries_with_name(name.lower()): + info.update_record(zero_conf, time(), record) + + for record in zero_conf.cache.entries_with_name(info.server): + info.update_record(zero_conf, time(), record) + if info.address: + break + + # Request more data if info is not complete + if not info.address: + info = zero_conf.get_service_info(service_type, name) + + if info: + type_of_device = info.properties.get(b"type", None) + if type_of_device: + if type_of_device == b"printer": + address = '.'.join(map(lambda n: str(n), info.address)) + self.addedNetworkCluster.emit(str(name), address, info.properties) + else: + Logger.log("w", "The type of the found device is '%s', not 'printer'." % type_of_device) + else: + Logger.log("w", "Could not get information about %s" % name) + return False + + return True + + ## Handler for when a ZeroConf service was removed. + def _onServiceRemoved(self, name: str) -> bool: + Logger.log("d", "ZeroConf service removed: %s" % name) + self.removedNetworkCluster.emit(str(name)) + return True diff --git a/plugins/UM3NetworkPrinting/src/Network/__init__.py b/plugins/UM3NetworkPrinting/src/Network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py b/plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py similarity index 89% rename from plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py rename to plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py index 943bef2bc1..bdbab008e3 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/PrintJobUploadProgressMessage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM import i18nCatalog from UM.Message import Message @@ -8,11 +8,11 @@ I18N_CATALOG = i18nCatalog("cura") ## Class responsible for showing a progress message while a mesh is being uploaded to the cloud. -class CloudProgressMessage(Message): +class PrintJobUploadProgressMessage(Message): def __init__(self): super().__init__( title = I18N_CATALOG.i18nc("@info:status", "Sending Print Job"), - text = I18N_CATALOG.i18nc("@info:status", "Uploading via Ultimaker Cloud"), + text = I18N_CATALOG.i18nc("@info:status", "Uploading print job to printer."), progress = -1, lifetime = 0, dismissable = False, diff --git a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py index f0fde818c4..697ba33a6b 100644 --- a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/SendMaterialJob.py @@ -1,6 +1,5 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. - import json import os from typing import Dict, TYPE_CHECKING, Set, Optional @@ -10,11 +9,11 @@ from UM.Job import Job from UM.Logger import Logger from cura.CuraApplication import CuraApplication -# Absolute imports don't work in plugins -from .Models import ClusterMaterial, LocalMaterial +from .Models.ClusterMaterial import ClusterMaterial +from .Models.LocalMaterial import LocalMaterial if TYPE_CHECKING: - from .ClusterUM3OutputDevice import ClusterUM3OutputDevice + from .Network.LocalClusterOutputDevice import LocalClusterOutputDevice ## Asynchronous job to send material profiles to the printer. @@ -22,9 +21,9 @@ if TYPE_CHECKING: # This way it won't freeze up the interface while sending those materials. class SendMaterialJob(Job): - def __init__(self, device: "ClusterUM3OutputDevice") -> None: + def __init__(self, device: "LocalClusterOutputDevice") -> None: super().__init__() - self.device = device # type: ClusterUM3OutputDevice + self.device = device # type: LocalClusterOutputDevice ## Send the request to the printer and register a callback def run(self) -> None: diff --git a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py index f1607334eb..64180491fe 100644 --- a/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py +++ b/plugins/UM3NetworkPrinting/src/UM3OutputDevicePlugin.py @@ -1,657 +1,55 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json -import os -from queue import Queue -from threading import Event, Thread -from time import time -from typing import Optional, TYPE_CHECKING, Dict, Callable, Union, Any - -from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager -from PyQt5.QtCore import QUrl -from PyQt5.QtGui import QDesktopServices +from typing import Optional, Callable from cura.CuraApplication import CuraApplication -from cura.PrinterOutput.PrinterOutputDevice import ConnectionType -from UM.i18n import i18nCatalog -from UM.Logger import Logger -from UM.Message import Message from UM.OutputDevice.OutputDeviceManager import ManualDeviceAdditionAttempt from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin -from UM.PluginRegistry import PluginRegistry -from UM.Signal import Signal, signalemitter -from UM.Version import Version -from . import ClusterUM3OutputDevice, LegacyUM3OutputDevice +from .Network.LocalClusterOutputDeviceManager import LocalClusterOutputDeviceManager from .Cloud.CloudOutputDeviceManager import CloudOutputDeviceManager -from .Cloud.CloudOutputDevice import CloudOutputDevice # typing - -if TYPE_CHECKING: - from PyQt5.QtNetwork import QNetworkReply - from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin - from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice - from cura.Settings.GlobalStack import GlobalStack -i18n_catalog = i18nCatalog("cura") - - -# -# Represents a request for adding a manual printer. It has the following fields: -# - address: The string of the (IP) address of the manual printer -# - callback: (Optional) Once the HTTP request to the printer to get printer information is done, whether successful -# or not, this callback will be invoked to notify about the result. The callback must have a signature of -# func(success: bool, address: str) -> None -# - network_reply: This is the QNetworkReply instance for this request if the request has been issued and still in -# progress. It is kept here so we can cancel a request when needed. -# -class ManualPrinterRequest: - def __init__(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: - self.address = address - self.callback = callback - self.network_reply = None # type: Optional["QNetworkReply"] - - -## This plugin handles the connection detection & creation of output device objects for the UM3 printer. -# Zero-Conf is used to detect printers, which are saved in a dict. -# If we discover a printer that has the same key as the active machine instance a connection is made. -@signalemitter +## This plugin handles the discovery and networking for Ultimaker 3D printers that support network and cloud printing. class UM3OutputDevicePlugin(OutputDevicePlugin): - addDeviceSignal = Signal() # Called '...Signal' to avoid confusion with function-names. - removeDeviceSignal = Signal() # Ditto ^^^. - discoveredDevicesChanged = Signal() - cloudFlowIsPossible = Signal() - def __init__(self): + def __init__(self) -> None: super().__init__() - self._zero_conf = None - self._zero_conf_browser = None - - self._application = CuraApplication.getInstance() + # Create a network output device manager that abstracts all network connection logic away. + self._network_output_device_manager = LocalClusterOutputDeviceManager() # Create a cloud output device manager that abstracts all cloud connection logic away. self._cloud_output_device_manager = CloudOutputDeviceManager() - # Because the model needs to be created in the same thread as the QMLEngine, we use a signal. - self.addDeviceSignal.connect(self._onAddDevice) - self.removeDeviceSignal.connect(self._onRemoveDevice) + # Refresh network connections when another machine was selected in Cura. + # This ensures no output devices are still connected that do not belong to the new active machine. + CuraApplication.getInstance().globalContainerStackChanged.connect(self.refreshConnections) - self._application.globalContainerStackChanged.connect(self.refreshConnections) - - self._discovered_devices = {} - - self._network_manager = QNetworkAccessManager() - self._network_manager.finished.connect(self._onNetworkRequestFinished) - - self._min_cluster_version = Version("4.0.0") - self._min_cloud_version = Version("5.2.0") - - self._api_version = "1" - self._api_prefix = "/api/v" + self._api_version + "/" - self._cluster_api_version = "1" - self._cluster_api_prefix = "/cluster-api/v" + self._cluster_api_version + "/" - - # Get list of manual instances from preferences - self._preferences = CuraApplication.getInstance().getPreferences() - self._preferences.addPreference("um3networkprinting/manual_instances", - "") # A comma-separated list of ip adresses or hostnames - - manual_instances = self._preferences.getValue("um3networkprinting/manual_instances").split(",") - self._manual_instances = {address: ManualPrinterRequest(address) - for address in manual_instances} # type: Dict[str, ManualPrinterRequest] - - # Store the last manual entry key - self._last_manual_entry_key = "" # type: str - - # The zero-conf service changed requests are handled in a separate thread, so we can re-schedule the requests - # which fail to get detailed service info. - # Any new or re-scheduled requests will be appended to the request queue, and the handling thread will pick - # them up and process them. - self._service_changed_request_queue = Queue() - self._service_changed_request_event = Event() - self._service_changed_request_thread = Thread(target=self._handleOnServiceChangedRequests, daemon=True) - self._service_changed_request_thread.start() - - self._account = self._application.getCuraAPI().account - - # Check if cloud flow is possible when user logs in - self._account.loginStateChanged.connect(self.checkCloudFlowIsPossible) - - # Check if cloud flow is possible when user switches machines - self._application.globalContainerStackChanged.connect(self._onMachineSwitched) - - # Listen for when cloud flow is possible - self.cloudFlowIsPossible.connect(self._onCloudFlowPossible) - - # Listen if cloud cluster was added - self._cloud_output_device_manager.addedCloudCluster.connect(self._onCloudPrintingConfigured) - - # Listen if cloud cluster was removed - self._cloud_output_device_manager.removedCloudCluster.connect(self.checkCloudFlowIsPossible) - - self._start_cloud_flow_message = None # type: Optional[Message] - self._cloud_flow_complete_message = None # type: Optional[Message] - - def getDiscoveredDevices(self): - return self._discovered_devices - - def getLastManualDevice(self) -> str: - return self._last_manual_entry_key - - def resetLastManualDevice(self) -> None: - self._last_manual_entry_key = "" - - ## Start looking for devices on network. + ## Start looking for devices in the network and cloud. def start(self): - self.startDiscovery() + self._network_output_device_manager.start() self._cloud_output_device_manager.start() - def startDiscovery(self): - self.stop() - if self._zero_conf_browser: - self._zero_conf_browser.cancel() - self._zero_conf_browser = None # Force the old ServiceBrowser to be destroyed. - - for instance_name in list(self._discovered_devices): - self._onRemoveDevice(instance_name) - - self._zero_conf = Zeroconf() - self._zero_conf_browser = ServiceBrowser(self._zero_conf, u'_ultimaker._tcp.local.', - [self._appendServiceChangedRequest]) - - # Look for manual instances from preference - for address in self._manual_instances: - if address: - self.addManualDevice(address) - self.resetLastManualDevice() - - # TODO: CHANGE TO HOSTNAME - def refreshConnections(self): - active_machine = CuraApplication.getInstance().getGlobalContainerStack() - if not active_machine: - return - - um_network_key = active_machine.getMetaDataEntry("um_network_key") - - for key in self._discovered_devices: - if key == um_network_key: - if not self._discovered_devices[key].isConnected(): - Logger.log("d", "Attempting to connect with [%s]" % key) - # It should already be set, but if it actually connects we know for sure it's supported! - active_machine.addConfiguredConnectionType(self._discovered_devices[key].connectionType.value) - self._discovered_devices[key].connect() - self._discovered_devices[key].connectionStateChanged.connect(self._onDeviceConnectionStateChanged) - else: - self._onDeviceConnectionStateChanged(key) - else: - if self._discovered_devices[key].isConnected(): - Logger.log("d", "Attempting to close connection with [%s]" % key) - self._discovered_devices[key].close() - self._discovered_devices[key].connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged) - - def _onDeviceConnectionStateChanged(self, key): - if key not in self._discovered_devices: - return - if self._discovered_devices[key].isConnected(): - # Sometimes the status changes after changing the global container and maybe the device doesn't belong to this machine - um_network_key = CuraApplication.getInstance().getGlobalContainerStack().getMetaDataEntry("um_network_key") - if key == um_network_key: - self.getOutputDeviceManager().addOutputDevice(self._discovered_devices[key]) - self.checkCloudFlowIsPossible(None) - else: - self.getOutputDeviceManager().removeOutputDevice(key) - - def stop(self): - if self._zero_conf is not None: - Logger.log("d", "zeroconf close...") - self._zero_conf.close() + # Stop network and cloud discovery. + def stop(self) -> None: + self._network_output_device_manager.stop() self._cloud_output_device_manager.stop() + ## Force refreshing the network connections. + def refreshConnections(self) -> None: + self._network_output_device_manager.refreshConnections() + self._cloud_output_device_manager.refreshConnections() + + ## Indicate that this plugin supports adding networked printers manually. def canAddManualDevice(self, address: str = "") -> ManualDeviceAdditionAttempt: - # This plugin should always be the fallback option (at least try it): - return ManualDeviceAdditionAttempt.POSSIBLE - - def removeManualDevice(self, key: str, address: Optional[str] = None) -> None: - if key not in self._discovered_devices and address is not None: - key = "manual:%s" % address - - if key in self._discovered_devices: - if not address: - address = self._discovered_devices[key].ipAddress - self._onRemoveDevice(key) - self.resetLastManualDevice() - - if address in self._manual_instances: - manual_printer_request = self._manual_instances.pop(address) - self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances.keys())) - - if manual_printer_request.network_reply is not None: - manual_printer_request.network_reply.abort() - - if manual_printer_request.callback is not None: - self._application.callLater(manual_printer_request.callback, False, address) + return ManualDeviceAdditionAttempt.PRIORITY + ## Add a networked printer manually based on its network address. def addManualDevice(self, address: str, callback: Optional[Callable[[bool, str], None]] = None) -> None: - self._manual_instances[address] = ManualPrinterRequest(address, callback = callback) - self._preferences.setValue("um3networkprinting/manual_instances", ",".join(self._manual_instances.keys())) + self._network_output_device_manager.addManualDevice(address, callback) - instance_name = "manual:%s" % address - properties = { - b"name": address.encode("utf-8"), - b"address": address.encode("utf-8"), - b"manual": b"true", - b"incomplete": b"true", - b"temporary": b"true" # Still a temporary device until all the info is retrieved in _onNetworkRequestFinished - } - - if instance_name not in self._discovered_devices: - # Add a preliminary printer instance - self._onAddDevice(instance_name, address, properties) - self._last_manual_entry_key = instance_name - - reply = self._checkManualDevice(address) - self._manual_instances[address].network_reply = reply - - def _createMachineFromDiscoveredPrinter(self, key: str) -> None: - discovered_device = self._discovered_devices.get(key) - if discovered_device is None: - Logger.log("e", "Could not find discovered device with key [%s]", key) - return - - group_name = discovered_device.getProperty("name") - machine_type_id = discovered_device.getProperty("printer_type") - - Logger.log("i", "Creating machine from network device with key = [%s], group name = [%s], printer type = [%s]", - key, group_name, machine_type_id) - - self._application.getMachineManager().addMachine(machine_type_id, group_name) - # connect the new machine to that network printer - self.associateActiveMachineWithPrinterDevice(discovered_device) - # ensure that the connection states are refreshed. - self.refreshConnections() - - def associateActiveMachineWithPrinterDevice(self, printer_device: Optional["PrinterOutputDevice"]) -> None: - if not printer_device: - return - - Logger.log("d", "Attempting to set the network key of the active machine to %s", printer_device.key) - - machine_manager = CuraApplication.getInstance().getMachineManager() - global_container_stack = machine_manager.activeMachine - if not global_container_stack: - return - - for machine in machine_manager.getMachinesInGroup(global_container_stack.getMetaDataEntry("group_id")): - machine.setMetaDataEntry("um_network_key", printer_device.key) - machine.setMetaDataEntry("group_name", printer_device.name) - - # Delete old authentication data. - Logger.log("d", "Removing old authentication id %s for device %s", - global_container_stack.getMetaDataEntry("network_authentication_id", None), printer_device.key) - - machine.removeMetaDataEntry("network_authentication_id") - machine.removeMetaDataEntry("network_authentication_key") - - # Ensure that these containers do know that they are configured for network connection - machine.addConfiguredConnectionType(printer_device.connectionType.value) - - self.refreshConnections() - - def _checkManualDevice(self, address: str) -> "QNetworkReply": - # Check if a UM3 family device exists at this address. - # If a printer responds, it will replace the preliminary printer created above - # origin=manual is for tracking back the origin of the call - url = QUrl("http://" + address + self._api_prefix + "system") - name_request = QNetworkRequest(url) - return self._network_manager.get(name_request) - - def _onNetworkRequestFinished(self, reply: "QNetworkReply") -> None: - reply_url = reply.url().toString() - - address = reply.url().host() - device = None - properties = {} # type: Dict[bytes, bytes] - - if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: - # Either: - # - Something went wrong with checking the firmware version! - # - Something went wrong with checking the amount of printers the cluster has! - # - Couldn't find printer at the address when trying to add it manually. - if address in self._manual_instances: - key = "manual:" + address - self.removeManualDevice(key, address) - return - - if "system" in reply_url: - try: - system_info = json.loads(bytes(reply.readAll()).decode("utf-8")) - except: - Logger.log("e", "Something went wrong converting the JSON.") - return - - if address in self._manual_instances: - manual_printer_request = self._manual_instances[address] - manual_printer_request.network_reply = None - if manual_printer_request.callback is not None: - self._application.callLater(manual_printer_request.callback, True, address) - - has_cluster_capable_firmware = Version(system_info["firmware"]) > self._min_cluster_version - instance_name = "manual:%s" % address - properties = { - b"name": (system_info["name"] + " (manual)").encode("utf-8"), - b"address": address.encode("utf-8"), - b"firmware_version": system_info["firmware"].encode("utf-8"), - b"manual": b"true", - b"machine": str(system_info['hardware']["typeid"]).encode("utf-8") - } - - if has_cluster_capable_firmware: - # Cluster needs an additional request, before it's completed. - properties[b"incomplete"] = b"true" - - # Check if the device is still in the list & re-add it with the updated - # information. - if instance_name in self._discovered_devices: - self._onRemoveDevice(instance_name) - self._onAddDevice(instance_name, address, properties) - - if has_cluster_capable_firmware: - # We need to request more info in order to figure out the size of the cluster. - cluster_url = QUrl("http://" + address + self._cluster_api_prefix + "printers/") - cluster_request = QNetworkRequest(cluster_url) - self._network_manager.get(cluster_request) - - elif "printers" in reply_url: - # So we confirmed that the device is in fact a cluster printer, and we should now know how big it is. - try: - cluster_printers_list = json.loads(bytes(reply.readAll()).decode("utf-8")) - except: - Logger.log("e", "Something went wrong converting the JSON.") - return - instance_name = "manual:%s" % address - if instance_name in self._discovered_devices: - device = self._discovered_devices[instance_name] - properties = device.getProperties().copy() - if b"incomplete" in properties: - del properties[b"incomplete"] - properties[b"cluster_size"] = str(len(cluster_printers_list)).encode("utf-8") - self._onRemoveDevice(instance_name) - self._onAddDevice(instance_name, address, properties) - - def _onRemoveDevice(self, device_id: str) -> None: - device = self._discovered_devices.pop(device_id, None) - if device: - if device.isConnected(): - device.disconnect() - try: - device.connectionStateChanged.disconnect(self._onDeviceConnectionStateChanged) - except TypeError: - # Disconnect already happened. - pass - self._application.getDiscoveredPrintersModel().removeDiscoveredPrinter(device.address) - self.discoveredDevicesChanged.emit() - - ## Returns a dict of printer BOM numbers to machine types. - # These numbers are available in the machine definition already so we just search for them here. - def _getPrinterTypeIdentifiers(self) -> Dict[str, str]: - container_registry = self._application.getContainerRegistry() - ultimaker_machines = container_registry.findContainersMetadata(type="machine", manufacturer="Ultimaker B.V.") - - found_machine_type_identifiers = {} # type: Dict[str, str] - for machine in ultimaker_machines: - machine_bom_number = machine.get("firmware_update_info", {}).get("id", None) - machine_type = machine.get("id", None) - if machine_bom_number and machine_type: - found_machine_type_identifiers[str(machine_bom_number)] = machine_type - - return found_machine_type_identifiers - - def _onAddDevice(self, name, address, properties): - # Check what kind of device we need to add; Depending on the firmware we either add a "Connect"/"Cluster" - # or "Legacy" UM3 device. - cluster_size = int(properties.get(b"cluster_size", -1)) - - printer_type = properties.get(b"machine", b"").decode("utf-8") - printer_type_identifiers = self._getPrinterTypeIdentifiers() - - for key, value in printer_type_identifiers.items(): - if printer_type.startswith(key): - properties[b"printer_type"] = bytes(value, encoding="utf8") - break - else: - properties[b"printer_type"] = b"Unknown" - if cluster_size >= 0: - device = ClusterUM3OutputDevice.ClusterUM3OutputDevice(name, address, properties) - else: - device = LegacyUM3OutputDevice.LegacyUM3OutputDevice(name, address, properties) - self._application.getDiscoveredPrintersModel().addDiscoveredPrinter( - address, device.getId(), properties[b"name"].decode("utf-8"), self._createMachineFromDiscoveredPrinter, - properties[b"printer_type"].decode("utf-8"), device) - self._discovered_devices[device.getId()] = device - self.discoveredDevicesChanged.emit() - - global_container_stack = CuraApplication.getInstance().getGlobalContainerStack() - if global_container_stack and device.getId() == global_container_stack.getMetaDataEntry("um_network_key"): - # Ensure that the configured connection type is set. - global_container_stack.addConfiguredConnectionType(device.connectionType.value) - device.connect() - device.connectionStateChanged.connect(self._onDeviceConnectionStateChanged) - - ## Appends a service changed request so later the handling thread will pick it up and processes it. - def _appendServiceChangedRequest(self, zeroconf, service_type, name, state_change): - # append the request and set the event so the event handling thread can pick it up - item = (zeroconf, service_type, name, state_change) - self._service_changed_request_queue.put(item) - self._service_changed_request_event.set() - - def _handleOnServiceChangedRequests(self): - while True: - # Wait for the event to be set - self._service_changed_request_event.wait(timeout = 5.0) - - # Stop if the application is shutting down - if CuraApplication.getInstance().isShuttingDown(): - return - - self._service_changed_request_event.clear() - - # Handle all pending requests - reschedule_requests = [] # A list of requests that have failed so later they will get re-scheduled - while not self._service_changed_request_queue.empty(): - request = self._service_changed_request_queue.get() - zeroconf, service_type, name, state_change = request - try: - result = self._onServiceChanged(zeroconf, service_type, name, state_change) - if not result: - reschedule_requests.append(request) - except Exception: - Logger.logException("e", "Failed to get service info for [%s] [%s], the request will be rescheduled", - service_type, name) - reschedule_requests.append(request) - - # Re-schedule the failed requests if any - if reschedule_requests: - for request in reschedule_requests: - self._service_changed_request_queue.put(request) - - ## Handler for zeroConf detection. - # Return True or False indicating if the process succeeded. - # Note that this function can take over 3 seconds to complete. Be careful - # calling it from the main thread. - def _onServiceChanged(self, zero_conf, service_type, name, state_change): - if state_change == ServiceStateChange.Added: - # First try getting info from zero-conf cache - info = ServiceInfo(service_type, name, properties = {}) - for record in zero_conf.cache.entries_with_name(name.lower()): - info.update_record(zero_conf, time(), record) - - for record in zero_conf.cache.entries_with_name(info.server): - info.update_record(zero_conf, time(), record) - if info.address: - break - - # Request more data if info is not complete - if not info.address: - info = zero_conf.get_service_info(service_type, name) - - if info: - type_of_device = info.properties.get(b"type", None) - if type_of_device: - if type_of_device == b"printer": - address = '.'.join(map(lambda n: str(n), info.address)) - self.addDeviceSignal.emit(str(name), address, info.properties) - else: - Logger.log("w", - "The type of the found device is '%s', not 'printer'! Ignoring.." % type_of_device) - else: - Logger.log("w", "Could not get information about %s" % name) - return False - - elif state_change == ServiceStateChange.Removed: - Logger.log("d", "Bonjour service removed: %s" % name) - self.removeDeviceSignal.emit(str(name)) - - return True - - ## Check if the prerequsites are in place to start the cloud flow - def checkCloudFlowIsPossible(self, cluster: Optional[CloudOutputDevice]) -> None: - Logger.log("d", "Checking if cloud connection is possible...") - - # Pre-Check: Skip if active machine already has been cloud connected or you said don't ask again - active_machine = self._application.getMachineManager().activeMachine # type: Optional[GlobalStack] - if active_machine: - # Check 1A: Printer isn't already configured for cloud - if ConnectionType.CloudConnection.value in active_machine.configuredConnectionTypes: - Logger.log("d", "Active machine was already configured for cloud.") - return - - # Check 1B: Printer isn't already configured for cloud - if active_machine.getMetaDataEntry("cloud_flow_complete", False): - Logger.log("d", "Active machine was already configured for cloud.") - return - - # Check 2: User did not already say "Don't ask me again" - if active_machine.getMetaDataEntry("do_not_show_cloud_message", False): - Logger.log("d", "Active machine shouldn't ask about cloud anymore.") - return - - # Check 3: User is logged in with an Ultimaker account - if not self._account.isLoggedIn: - Logger.log("d", "Cloud Flow not possible: User not logged in!") - return - - # Check 4: Machine is configured for network connectivity - if not self._application.getMachineManager().activeMachineHasNetworkConnection: - Logger.log("d", "Cloud Flow not possible: Machine is not connected!") - return - - # Check 5: Machine has correct firmware version - firmware_version = self._application.getMachineManager().activeMachineFirmwareVersion # type: str - if not Version(firmware_version) > self._min_cloud_version: - Logger.log("d", "Cloud Flow not possible: Machine firmware (%s) is too low! (Requires version %s)", - firmware_version, - self._min_cloud_version) - return - - Logger.log("d", "Cloud flow is possible!") - self.cloudFlowIsPossible.emit() - - def _onCloudFlowPossible(self) -> None: - # Cloud flow is possible, so show the message - if not self._start_cloud_flow_message: - self._createCloudFlowStartMessage() - if self._start_cloud_flow_message and not self._start_cloud_flow_message.visible: - self._start_cloud_flow_message.show() - - def _onCloudPrintingConfigured(self, device) -> None: - # Hide the cloud flow start message if it was hanging around already - # For example: if the user already had the browser openen and made the association themselves - if self._start_cloud_flow_message and self._start_cloud_flow_message.visible: - self._start_cloud_flow_message.hide() - - # Cloud flow is complete, so show the message - if not self._cloud_flow_complete_message: - self._createCloudFlowCompleteMessage() - if self._cloud_flow_complete_message and not self._cloud_flow_complete_message.visible: - self._cloud_flow_complete_message.show() - - # Set the machine's cloud flow as complete so we don't ask the user again and again for cloud connected printers - active_machine = self._application.getMachineManager().activeMachine - if active_machine: - - # The active machine _might_ not be the machine that was in the added cloud cluster and - # then this will hide the cloud message for the wrong machine. So we only set it if the - # host names match between the active machine and the newly added cluster - saved_host_name = active_machine.getMetaDataEntry("um_network_key", "").split('.')[0] - added_host_name = device.toDict()["host_name"] - - if added_host_name == saved_host_name: - active_machine.setMetaDataEntry("do_not_show_cloud_message", True) - - return - - def _onDontAskMeAgain(self, checked: bool) -> None: - active_machine = self._application.getMachineManager().activeMachine # type: Optional[GlobalStack] - if active_machine: - active_machine.setMetaDataEntry("do_not_show_cloud_message", checked) - if checked: - Logger.log("d", "Will not ask the user again to cloud connect for current printer.") - return - - def _onCloudFlowStarted(self, messageId: str, actionId: str) -> None: - address = self._application.getMachineManager().activeMachineAddress # type: str - if address: - QDesktopServices.openUrl(QUrl("http://" + address + "/cloud_connect")) - if self._start_cloud_flow_message: - self._start_cloud_flow_message.hide() - self._start_cloud_flow_message = None - return - - def _onReviewCloudConnection(self, messageId: str, actionId: str) -> None: - address = self._application.getMachineManager().activeMachineAddress # type: str - if address: - QDesktopServices.openUrl(QUrl("http://" + address + "/settings")) - return - - def _onMachineSwitched(self) -> None: - # Hide any left over messages - if self._start_cloud_flow_message is not None and self._start_cloud_flow_message.visible: - self._start_cloud_flow_message.hide() - if self._cloud_flow_complete_message is not None and self._cloud_flow_complete_message.visible: - self._cloud_flow_complete_message.hide() - - # Check for cloud flow again with newly selected machine - self.checkCloudFlowIsPossible(None) - - def _createCloudFlowStartMessage(self): - self._start_cloud_flow_message = Message( - text = i18n_catalog.i18nc("@info:status", "Send and monitor print jobs from anywhere using your Ultimaker account."), - lifetime = 0, - image_source = QUrl.fromLocalFile(os.path.join( - PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), - "resources", "svg", "cloud-flow-start.svg" - )), - image_caption = i18n_catalog.i18nc("@info:status Ultimaker Cloud is a brand name and shouldn't be translated.", "Connect to Ultimaker Cloud"), - option_text = i18n_catalog.i18nc("@action", "Don't ask me again for this printer."), - option_state = False - ) - self._start_cloud_flow_message.addAction("", i18n_catalog.i18nc("@action", "Get started"), "", "") - self._start_cloud_flow_message.optionToggled.connect(self._onDontAskMeAgain) - self._start_cloud_flow_message.actionTriggered.connect(self._onCloudFlowStarted) - - def _createCloudFlowCompleteMessage(self): - self._cloud_flow_complete_message = Message( - text = i18n_catalog.i18nc("@info:status", "You can now send and monitor print jobs from anywhere using your Ultimaker account."), - lifetime = 30, - image_source = QUrl.fromLocalFile(os.path.join( - PluginRegistry.getInstance().getPluginPath("UM3NetworkPrinting"), - "resources", "svg", "cloud-flow-completed.svg" - )), - image_caption = i18n_catalog.i18nc("@info:status", "Connected!") - ) - self._cloud_flow_complete_message.addAction("", i18n_catalog.i18nc("@action", "Review your connection"), "", "", 1) # TODO: Icon - self._cloud_flow_complete_message.actionTriggered.connect(self._onReviewCloudConnection) + ## Remove a manually connected networked printer. + def removeManualDevice(self, key: str, address: Optional[str] = None) -> None: + self._network_output_device_manager.removeManualDevice(key, address) diff --git a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py new file mode 100644 index 0000000000..70e85879cf --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py @@ -0,0 +1,262 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import os +from typing import List, Optional, Dict + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot, QUrl + +from UM.Logger import Logger +from UM.Qt.Duration import Duration, DurationFormat +from cura.CuraApplication import CuraApplication +from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel +from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState +from cura.PrinterOutput.PrinterOutputDevice import ConnectionType + +from .Utils import formatTimeCompleted, formatDateCompleted +from .ClusterOutputController import ClusterOutputController +from .PrintJobUploadProgressMessage import PrintJobUploadProgressMessage +from .Models.UM3PrintJobOutputModel import UM3PrintJobOutputModel +from .Models.Http.ClusterPrinterStatus import ClusterPrinterStatus +from .Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus + + +## Output device class that forms the basis of Ultimaker networked printer output devices. +# Currently used for local networking and cloud printing using Ultimaker Connect. +# This base class primarily contains all the Qt properties and slots needed for the monitor page to work. +class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): + + # Signal emitted when the status of the print jobs for this cluster were changed over the network. + printJobsChanged = pyqtSignal() + + # Signal emitted when the currently visible printer card in the UI was changed by the user. + activePrinterChanged = pyqtSignal() + + # Notify can only use signals that are defined by the class that they are in, not inherited ones. + # Therefore we create a private signal used to trigger the printersChanged signal. + _clusterPrintersChanged = pyqtSignal() + + # States indicating if a print job is queued. + QUEUED_PRINT_JOBS_STATES = {"queued", "error"} + + def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType, + parent=None) -> None: + super().__init__(device_id=device_id, address=address, properties=properties, connection_type=connection_type, + parent=parent) + + # Trigger the printersChanged signal when the private signal is triggered. + self.printersChanged.connect(self._clusterPrintersChanged) + + # Keeps track of all printers in the cluster. + self._printers = [] # type: List[PrinterOutputModel] + + # Keeps track of all print jobs in the cluster. + self._print_jobs = [] # type: List[UM3PrintJobOutputModel] + + # Keep track of the printer currently selected in the UI. + self._active_printer = None # type: Optional[PrinterOutputModel] + + # By default we are not authenticated. This state will be changed later. + self._authentication_state = AuthState.NotAuthenticated + + # Load the Monitor UI elements. + self._loadMonitorTab() + + # The job upload progress message modal. + self._progress = PrintJobUploadProgressMessage() + + ## The IP address of the printer. + @pyqtProperty(str, constant=True) + def address(self) -> str: + return self._address + + # Get all print jobs for this cluster. + @pyqtProperty("QVariantList", notify=printJobsChanged) + def printJobs(self) -> List[UM3PrintJobOutputModel]: + return self._print_jobs + + # Get all print jobs for this cluster that are queued. + @pyqtProperty("QVariantList", notify=printJobsChanged) + def queuedPrintJobs(self) -> List[UM3PrintJobOutputModel]: + return [print_job for print_job in self._print_jobs if print_job.state in self.QUEUED_PRINT_JOBS_STATES] + + # Get all print jobs for this cluster that are currently printing. + @pyqtProperty("QVariantList", notify=printJobsChanged) + def activePrintJobs(self) -> List[UM3PrintJobOutputModel]: + return [print_job for print_job in self._print_jobs if + print_job.assignedPrinter is not None and print_job.state not in self.QUEUED_PRINT_JOBS_STATES] + + @pyqtProperty(bool, notify=printJobsChanged) + def receivedPrintJobs(self) -> bool: + return bool(self._print_jobs) + + # Get the amount of printers in the cluster. + @pyqtProperty(int, notify=_clusterPrintersChanged) + def clusterSize(self) -> int: + return max(1, len(self._printers)) + + # Get the amount of printer in the cluster per type. + @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) + def connectedPrintersTypeCount(self) -> List[Dict[str, str]]: + printer_count = {} # type: Dict[str, int] + for printer in self._printers: + if printer.type in printer_count: + printer_count[printer.type] += 1 + else: + printer_count[printer.type] = 1 + result = [] + for machine_type in printer_count: + result.append({"machine_type": machine_type, "count": str(printer_count[machine_type])}) + return result + + # Get a list of all printers. + @pyqtProperty("QVariantList", notify=_clusterPrintersChanged) + def printers(self) -> List[PrinterOutputModel]: + return self._printers + + # Get the currently active printer in the UI. + @pyqtProperty(QObject, notify=activePrinterChanged) + def activePrinter(self) -> Optional[PrinterOutputModel]: + return self._active_printer + + # Set the currently active printer from the UI. + @pyqtSlot(QObject, name="setActivePrinter") + def setActivePrinter(self, printer: Optional[PrinterOutputModel]) -> None: + if self.activePrinter == printer: + return + self._active_printer = printer + self.activePrinterChanged.emit() + + ## Whether the printer that this output device represents supports print job actions via the local network. + @pyqtProperty(bool, constant=True) + def supportsPrintJobActions(self) -> bool: + return True + + ## Set the remote print job state. + def setJobState(self, print_job_uuid: str, state: str) -> None: + raise NotImplementedError("setJobState must be implemented") + + @pyqtSlot(str, name="sendJobToTop") + def sendJobToTop(self, print_job_uuid: str) -> None: + raise NotImplementedError("sendJobToTop must be implemented") + + @pyqtSlot(str, name="deleteJobFromQueue") + def deleteJobFromQueue(self, print_job_uuid: str) -> None: + raise NotImplementedError("deleteJobFromQueue must be implemented") + + @pyqtSlot(str, name="forceSendJob") + def forceSendJob(self, print_job_uuid: str) -> None: + raise NotImplementedError("forceSendJob must be implemented") + + @pyqtSlot(name="openPrintJobControlPanel") + def openPrintJobControlPanel(self) -> None: + raise NotImplementedError("openPrintJobControlPanel must be implemented") + + @pyqtSlot(name="openPrinterControlPanel") + def openPrinterControlPanel(self) -> None: + raise NotImplementedError("openPrinterControlPanel must be implemented") + + @pyqtProperty(QUrl, notify=_clusterPrintersChanged) + def activeCameraUrl(self) -> QUrl: + return QUrl() + + @pyqtSlot(QUrl, name="setActiveCameraUrl") + def setActiveCameraUrl(self, camera_url: QUrl) -> None: + pass + + @pyqtSlot(int, result=str, name="getTimeCompleted") + def getTimeCompleted(self, time_remaining: int) -> str: + return formatTimeCompleted(time_remaining) + + @pyqtSlot(int, result=str, name="getDateCompleted") + def getDateCompleted(self, time_remaining: int) -> str: + return formatDateCompleted(time_remaining) + + @pyqtSlot(int, result=str, name="formatDuration") + def formatDuration(self, seconds: int) -> str: + return Duration(seconds).getDisplayString(DurationFormat.Format.Short) + + ## Load Monitor tab QML. + def _loadMonitorTab(self): + plugin_registry = CuraApplication.getInstance().getPluginRegistry() + if not plugin_registry: + Logger.log("e", "Could not get plugin registry") + return + plugin_path = plugin_registry.getPluginPath("UM3NetworkPrinting") + if not plugin_path: + Logger.log("e", "Could not get plugin path") + return + self._monitor_view_qml_path = os.path.join(plugin_path, "resources", "qml", "MonitorStage.qml") + + def _updatePrinters(self, remote_printers: List[ClusterPrinterStatus]) -> None: + + # Keep track of the new printers to show. + # We create a new list instead of changing the existing one to get the correct order. + new_printers = [] # type: List[PrinterOutputModel] + + # Check which printers need to be created or updated. + for index, printer_data in enumerate(remote_printers): + printer = next(iter(printer for printer in self._printers if printer.key == printer_data.uuid), None) + if printer is None: + printer = printer_data.createOutputModel(ClusterOutputController(self)) + else: + printer_data.updateOutputModel(printer) + new_printers.append(printer) + + # Check which printers need to be removed (de-referenced). + remote_printers_keys = [printer_data.uuid for printer_data in remote_printers] + removed_printers = [printer for printer in self._printers if printer.key not in remote_printers_keys] + for removed_printer in removed_printers: + if self._active_printer and self._active_printer.key == removed_printer.key: + self.setActivePrinter(None) + + self._printers = new_printers + if self._printers and not self.activePrinter: + self.setActivePrinter(self._printers[0]) + + self.printersChanged.emit() + + ## Updates the local list of print jobs with the list received from the cluster. + # \param remote_jobs: The print jobs received from the cluster. + def _updatePrintJobs(self, remote_jobs: List[ClusterPrintJobStatus]) -> None: + + # Keep track of the new print jobs to show. + # We create a new list instead of changing the existing one to get the correct order. + new_print_jobs = [] + + # Check which print jobs need to be created or updated. + for index, print_job_data in enumerate(remote_jobs): + print_job = next( + iter(print_job for print_job in self._print_jobs if print_job.key == print_job_data.uuid), None) + if not print_job: + new_print_jobs.append(self._createPrintJobModel(print_job_data)) + else: + print_job_data.updateOutputModel(print_job) + if print_job_data.printer_uuid: + self._updateAssignedPrinter(print_job, print_job_data.printer_uuid) + new_print_jobs.append(print_job) + + # Check which print job need to be removed (de-referenced). + remote_job_keys = [print_job_data.uuid for print_job_data in remote_jobs] + removed_jobs = [print_job for print_job in self._print_jobs if print_job.key not in remote_job_keys] + for removed_job in removed_jobs: + if removed_job.assignedPrinter: + removed_job.assignedPrinter.updateActivePrintJob(None) + + self._print_jobs = new_print_jobs + self.printJobsChanged.emit() + + ## Create a new print job model based on the remote status of the job. + # \param remote_job: The remote print job data. + def _createPrintJobModel(self, remote_job: ClusterPrintJobStatus) -> UM3PrintJobOutputModel: + model = remote_job.createOutputModel(ClusterOutputController(self)) + if remote_job.printer_uuid: + self._updateAssignedPrinter(model, remote_job.printer_uuid) + return model + + ## Updates the printer assignment for the given print job model. + def _updateAssignedPrinter(self, model: UM3PrintJobOutputModel, printer_uuid: str) -> None: + printer = next((p for p in self._printers if printer_uuid == p.key), None) + if not printer: + return + printer.updateActivePrintJob(model) + model.updateAssignedPrinter(printer) diff --git a/plugins/UM3NetworkPrinting/src/Utils.py b/plugins/UM3NetworkPrinting/src/Utils.py new file mode 100644 index 0000000000..a628130416 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Utils.py @@ -0,0 +1,30 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime, timedelta + +from UM import i18nCatalog + + +def formatTimeCompleted(seconds_remaining: int) -> str: + completed = datetime.now() + timedelta(seconds=seconds_remaining) + return "{hour:02d}:{minute:02d}".format(hour = completed.hour, minute = completed.minute) + + +def formatDateCompleted(seconds_remaining: int) -> str: + now = datetime.now() + completed = now + timedelta(seconds=seconds_remaining) + days = (completed.date() - now.date()).days + i18n = i18nCatalog("cura") + + # If finishing date is more than 7 days out, using "Mon Dec 3 at HH:MM" format + if days >= 7: + return completed.strftime("%a %b ") + "{day}".format(day = completed.day) + # If finishing date is within the next week, use "Monday at HH:MM" format + elif days >= 2: + return completed.strftime("%a") + # If finishing tomorrow, use "tomorrow at HH:MM" format + elif days >= 1: + return i18n.i18nc("@info:status", "tomorrow") + # If finishing today, use "today at HH:MM" format + else: + return i18n.i18nc("@info:status", "today") diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py deleted file mode 100644 index 777afc92c2..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import json -import os - - -def readFixture(fixture_name: str) -> bytes: - with open("{}/{}.json".format(os.path.dirname(__file__), fixture_name), "rb") as f: - return f.read() - -def parseFixture(fixture_name: str) -> dict: - return json.loads(readFixture(fixture_name).decode()) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json deleted file mode 100644 index 4f9f47fc75..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusterStatusResponse.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "data": { - "generated_time": "2018-12-10T08:23:55.110Z", - "printers": [ - { - "configuration": [ - { - "extruder_index": 0, - "material": { - "material": "empty" - }, - "print_core_id": "AA 0.4" - }, - { - "extruder_index": 1, - "material": { - "material": "empty" - }, - "print_core_id": "AA 0.4" - } - ], - "enabled": true, - "firmware_version": "5.1.2.20180807", - "friendly_name": "Master-Luke", - "ip_address": "10.183.1.140", - "machine_variant": "Ultimaker 3", - "status": "maintenance", - "unique_name": "ultimakersystem-ccbdd30044ec", - "uuid": "b3a47ea3-1eeb-4323-9626-6f9c3c888f9e" - }, - { - "configuration": [ - { - "extruder_index": 0, - "material": { - "brand": "Generic", - "color": "Generic", - "guid": "506c9f0d-e3aa-4bd4-b2d2-23e2425b1aa9", - "material": "PLA" - }, - "print_core_id": "AA 0.4" - }, - { - "extruder_index": 1, - "material": { - "brand": "Ultimaker", - "color": "Red", - "guid": "9cfe5bf1-bdc5-4beb-871a-52c70777842d", - "material": "PLA" - }, - "print_core_id": "AA 0.4" - } - ], - "enabled": true, - "firmware_version": "4.3.3.20180529", - "friendly_name": "UM-Marijn", - "ip_address": "10.183.1.166", - "machine_variant": "Ultimaker 3", - "status": "idle", - "unique_name": "ultimakersystem-ccbdd30058ab", - "uuid": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a" - } - ], - "print_jobs": [ - { - "assigned_to": "6e62c40a-4601-4b0e-9fec-c7c02c59c30a", - "configuration": [ - { - "extruder_index": 0, - "material": { - "brand": "Ultimaker", - "color": "Black", - "guid": "3ee70a86-77d8-4b87-8005-e4a1bc57d2ce", - "material": "PLA" - }, - "print_core_id": "AA 0.4" - } - ], - "constraints": {}, - "created_at": "2018-12-10T08:28:04.108Z", - "force": false, - "last_seen": 500165.109491861, - "machine_variant": "Ultimaker 3", - "name": "UM3_dragon", - "network_error_count": 0, - "owner": "Daniel Testing", - "started": false, - "status": "queued", - "time_elapsed": 0, - "time_total": 14145, - "uuid": "d1c8bd52-5e9f-486a-8c25-a123cc8c7702" - } - ] - } -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json deleted file mode 100644 index 5200e3b971..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/getClusters.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": [{ - "cluster_id": "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq", - "host_guid": "e90ae0ac-1257-4403-91ee-a44c9b7e8050", - "host_name": "ultimakersystem-ccbdd30044ec", - "host_version": "5.0.0.20170101", - "is_online": true, - "status": "active" - }, { - "cluster_id": "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8", - "host_guid": "e0ace90a-91ee-1257-4403-e8050a44c9b7", - "host_name": "ultimakersystem-30044ecccbdd", - "host_version": "5.1.2.20180807", - "is_online": true, - "status": "active" - }] -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json deleted file mode 100644 index caedcd8732..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/postJobPrintResponse.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data": { - "cluster_job_id": "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd", - "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", - "status": "queued", - "generated_time": "2018-12-10T08:23:55.110Z" - } -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json b/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json deleted file mode 100644 index 1304f3a9f6..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Fixtures/putJobUploadResponse.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data": { - "content_type": "text/plain", - "job_id": "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=", - "job_name": "Ultimaker Robot v3.0", - "status": "uploading", - "upload_url": "https://api.ultimaker.com/print-job-upload" - } -} diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py deleted file mode 100644 index f3f6970c54..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/Models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py b/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py deleted file mode 100644 index e504509d67..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/NetworkManagerMock.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import json -from typing import Dict, Tuple, Union, Optional, Any -from unittest.mock import MagicMock - -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest - -from UM.Logger import Logger -from UM.Signal import Signal - - -class FakeSignal: - def __init__(self): - self._callbacks = [] - - def connect(self, callback): - self._callbacks.append(callback) - - def disconnect(self, callback): - self._callbacks.remove(callback) - - def emit(self, *args, **kwargs): - for callback in self._callbacks: - callback(*args, **kwargs) - - -## This class can be used to mock the QNetworkManager class and test the code using it. -# After patching the QNetworkManager class, requests are prepared before they can be executed. -# Any requests not prepared beforehand will cause KeyErrors. -class NetworkManagerMock: - - # An enumeration of the supported operations and their code for the network access manager. - _OPERATIONS = { - "GET": QNetworkAccessManager.GetOperation, - "POST": QNetworkAccessManager.PostOperation, - "PUT": QNetworkAccessManager.PutOperation, - "DELETE": QNetworkAccessManager.DeleteOperation, - "HEAD": QNetworkAccessManager.HeadOperation, - } # type: Dict[str, int] - - ## Initializes the network manager mock. - def __init__(self) -> None: - # A dict with the prepared replies, using the format {(http_method, url): reply} - self.replies = {} # type: Dict[Tuple[str, str], MagicMock] - self.request_bodies = {} # type: Dict[Tuple[str, str], bytes] - - # Signals used in the network manager. - self.finished = Signal() - self.authenticationRequired = Signal() - - ## Mock implementation of the get, post, put, delete and head methods from the network manager. - # Since the methods are very simple and the same it didn't make sense to repeat the code. - # \param method: The method being called. - # \return The mocked function, if the method name is known. Defaults to the standard getattr function. - def __getattr__(self, method: str) -> Any: - ## This mock implementation will simply return the reply from the prepared ones. - # it raises a KeyError if requests are done without being prepared. - def doRequest(request: QNetworkRequest, body: Optional[bytes] = None, *_): - key = method.upper(), request.url().toString() - if body: - self.request_bodies[key] = body - return self.replies[key] - - operation = self._OPERATIONS.get(method.upper()) - if operation: - return doRequest - - # the attribute is not one of the implemented methods, default to the standard implementation. - return getattr(super(), method) - - ## Prepares a server reply for the given parameters. - # \param method: The HTTP method. - # \param url: The URL being requested. - # \param status_code: The HTTP status code for the response. - # \param response: The response body from the server (generally json-encoded). - def prepareReply(self, method: str, url: str, status_code: int, response: Union[bytes, dict]) -> None: - reply_mock = MagicMock() - reply_mock.url().toString.return_value = url - reply_mock.operation.return_value = self._OPERATIONS[method] - reply_mock.attribute.return_value = status_code - reply_mock.finished = FakeSignal() - reply_mock.isFinished.return_value = False - reply_mock.readAll.return_value = response if isinstance(response, bytes) else json.dumps(response).encode() - self.replies[method, url] = reply_mock - Logger.log("i", "Prepared mock {}-response to {} {}", status_code, method, url) - - ## Gets the request that was sent to the network manager for the given method and URL. - # \param method: The HTTP method. - # \param url: The URL. - def getRequestBody(self, method: str, url: str) -> Optional[bytes]: - return self.request_bodies.get((method.upper(), url)) - - ## Emits the signal that the reply is ready to all prepared replies. - def flushReplies(self) -> None: - for key, reply in self.replies.items(): - Logger.log("i", "Flushing reply to {} {}", *key) - reply.isFinished.return_value = True - reply.finished.emit() - self.finished.emit(reply) - self.reset() - - ## Deletes all prepared replies - def reset(self) -> None: - self.replies.clear() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py deleted file mode 100644 index b79d009c31..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudApiClient.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from typing import List -from unittest import TestCase -from unittest.mock import patch, MagicMock - -from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot -from ...src.Cloud import CloudApiClient -from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse -from ...src.Cloud.Models.CloudClusterStatus import CloudClusterStatus -from ...src.Cloud.Models.CloudPrintJobResponse import CloudPrintJobResponse -from ...src.Cloud.Models.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest -from ...src.Cloud.Models.CloudError import CloudError -from .Fixtures import readFixture, parseFixture -from .NetworkManagerMock import NetworkManagerMock - - -class TestCloudApiClient(TestCase): - maxDiff = None - - def _errorHandler(self, errors: List[CloudError]): - raise Exception("Received unexpected error: {}".format(errors)) - - def setUp(self): - super().setUp() - self.account = MagicMock() - self.account.isLoggedIn.return_value = True - - self.network = NetworkManagerMock() - with patch.object(CloudApiClient, 'QNetworkAccessManager', return_value = self.network): - self.api = CloudApiClient.CloudApiClient(self.account, self._errorHandler) - - def test_getClusters(self): - result = [] - - response = readFixture("getClusters") - data = parseFixture("getClusters")["data"] - - self.network.prepareReply("GET", CuraCloudAPIRoot + "/connect/v1/clusters", 200, response) - # The callback is a function that adds the result of the call to getClusters to the result list - self.api.getClusters(lambda clusters: result.extend(clusters)) - - self.network.flushReplies() - - self.assertEqual([CloudClusterResponse(**data[0]), CloudClusterResponse(**data[1])], result) - - def test_getClusterStatus(self): - result = [] - - response = readFixture("getClusterStatusResponse") - data = parseFixture("getClusterStatusResponse")["data"] - - url = CuraCloudAPIRoot + "/connect/v1/clusters/R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC/status" - self.network.prepareReply("GET", url, 200, response) - self.api.getClusterStatus("R0YcLJwar1ugh0ikEZsZs8NWKV6vJP_LdYsXgXqAcaNC", lambda s: result.append(s)) - - self.network.flushReplies() - - self.assertEqual([CloudClusterStatus(**data)], result) - - def test_requestUpload(self): - - results = [] - - response = readFixture("putJobUploadResponse") - - self.network.prepareReply("PUT", CuraCloudAPIRoot + "/cura/v1/jobs/upload", 200, response) - request = CloudPrintJobUploadRequest(job_name = "job name", file_size = 143234, content_type = "text/plain") - self.api.requestUpload(request, lambda r: results.append(r)) - self.network.flushReplies() - - self.assertEqual(["text/plain"], [r.content_type for r in results]) - self.assertEqual(["uploading"], [r.status for r in results]) - - def test_uploadToolPath(self): - - results = [] - progress = MagicMock() - - data = parseFixture("putJobUploadResponse")["data"] - upload_response = CloudPrintJobResponse(**data) - - # Network client doesn't look into the reply - self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') - - mesh = ("1234" * 100000).encode() - self.api.uploadToolPath(upload_response, mesh, lambda: results.append("sent"), progress.advance, progress.error) - - for _ in range(10): - self.network.flushReplies() - self.network.prepareReply("PUT", upload_response.upload_url, 200, b'{}') - - self.assertEqual(["sent"], results) - - def test_requestPrint(self): - - results = [] - - response = readFixture("postJobPrintResponse") - - cluster_id = "NWKV6vJP_LdYsXgXqAcaNCR0YcLJwar1ugh0ikEZsZs8" - cluster_job_id = "9a59d8e9-91d3-4ff6-b4cb-9db91c4094dd" - job_id = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" - - self.network.prepareReply("POST", - CuraCloudAPIRoot + "/connect/v1/clusters/{}/print/{}" - .format(cluster_id, job_id), - 200, response) - - self.api.requestPrint(cluster_id, job_id, lambda r: results.append(r)) - - self.network.flushReplies() - - self.assertEqual([job_id], [r.job_id for r in results]) - self.assertEqual([cluster_job_id], [r.cluster_job_id for r in results]) - self.assertEqual(["queued"], [r.status for r in results]) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py deleted file mode 100644 index 352efb292e..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDevice.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import json -from unittest import TestCase -from unittest.mock import patch, MagicMock - -from UM.Scene.SceneNode import SceneNode -from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot -from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel -from ...src.Cloud import CloudApiClient -from ...src.Cloud.CloudOutputDevice import CloudOutputDevice -from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse -from .Fixtures import readFixture, parseFixture -from .NetworkManagerMock import NetworkManagerMock - - -class TestCloudOutputDevice(TestCase): - maxDiff = None - - CLUSTER_ID = "RIZ6cZbWA_Ua7RZVJhrdVfVpf0z-MqaSHQE4v8aRTtYq" - JOB_ID = "ABCDefGHIjKlMNOpQrSTUvYxWZ0-1234567890abcDE=" - HOST_NAME = "ultimakersystem-ccbdd30044ec" - HOST_GUID = "e90ae0ac-1257-4403-91ee-a44c9b7e8050" - HOST_VERSION = "5.2.0" - FRIENDLY_NAME = "My Friendly Printer" - - STATUS_URL = "{}/connect/v1/clusters/{}/status".format(CuraCloudAPIRoot, CLUSTER_ID) - PRINT_URL = "{}/connect/v1/clusters/{}/print/{}".format(CuraCloudAPIRoot, CLUSTER_ID, JOB_ID) - REQUEST_UPLOAD_URL = "{}/cura/v1/jobs/upload".format(CuraCloudAPIRoot) - - def setUp(self): - super().setUp() - self.app = MagicMock() - - self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app), - patch("UM.Application.Application.getInstance", return_value=self.app)] - for patched_method in self.patches: - patched_method.start() - - self.cluster = CloudClusterResponse(self.CLUSTER_ID, self.HOST_GUID, self.HOST_NAME, is_online=True, - status="active", host_version=self.HOST_VERSION, - friendly_name=self.FRIENDLY_NAME) - - self.network = NetworkManagerMock() - self.account = MagicMock(isLoggedIn=True, accessToken="TestAccessToken") - self.onError = MagicMock() - with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network): - self._api = CloudApiClient.CloudApiClient(self.account, self.onError) - - self.device = CloudOutputDevice(self._api, self.cluster) - self.cluster_status = parseFixture("getClusterStatusResponse") - self.network.prepareReply("GET", self.STATUS_URL, 200, readFixture("getClusterStatusResponse")) - - def tearDown(self): - try: - super().tearDown() - self.network.flushReplies() - finally: - for patched_method in self.patches: - patched_method.stop() - - # We test for these in order to make sure the correct file type is selected depending on the firmware version. - def test_properties(self): - self.assertEqual(self.device.firmwareVersion, self.HOST_VERSION) - self.assertEqual(self.device.name, self.FRIENDLY_NAME) - - def test_status(self): - self.device._update() - self.network.flushReplies() - - self.assertEqual([PrinterOutputModel, PrinterOutputModel], [type(printer) for printer in self.device.printers]) - - controller_fields = { - "_output_device": self.device, - "can_abort": True, - "can_control_manually": False, - "can_pause": True, - "can_pre_heat_bed": False, - "can_pre_heat_hotends": False, - "can_send_raw_gcode": False, - "can_update_firmware": False, - } - - self.assertEqual({printer["uuid"] for printer in self.cluster_status["data"]["printers"]}, - {printer.key for printer in self.device.printers}) - self.assertEqual([controller_fields, controller_fields], - [printer.getController().__dict__ for printer in self.device.printers]) - - self.assertEqual(["UM3PrintJobOutputModel"], [type(printer).__name__ for printer in self.device.printJobs]) - self.assertEqual({job["uuid"] for job in self.cluster_status["data"]["print_jobs"]}, - {job.key for job in self.device.printJobs}) - self.assertEqual({job["owner"] for job in self.cluster_status["data"]["print_jobs"]}, - {job.owner for job in self.device.printJobs}) - self.assertEqual({job["name"] for job in self.cluster_status["data"]["print_jobs"]}, - {job.name for job in self.device.printJobs}) - - def test_remove_print_job(self): - self.device._update() - self.network.flushReplies() - self.assertEqual(1, len(self.device.printJobs)) - - self.cluster_status["data"]["print_jobs"].clear() - self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) - - self.device._last_request_time = None - self.device._update() - self.network.flushReplies() - self.assertEqual([], self.device.printJobs) - - def test_remove_printers(self): - self.device._update() - self.network.flushReplies() - self.assertEqual(2, len(self.device.printers)) - - self.cluster_status["data"]["printers"].clear() - self.network.prepareReply("GET", self.STATUS_URL, 200, self.cluster_status) - - self.device._last_request_time = None - self.device._update() - self.network.flushReplies() - self.assertEqual([], self.device.printers) - - def test_print_to_cloud(self): - active_machine_mock = self.app.getGlobalContainerStack.return_value - active_machine_mock.getMetaDataEntry.side_effect = {"file_formats": "application/x-ufp"}.get - - request_upload_response = parseFixture("putJobUploadResponse") - request_print_response = parseFixture("postJobPrintResponse") - self.network.prepareReply("PUT", self.REQUEST_UPLOAD_URL, 201, request_upload_response) - self.network.prepareReply("PUT", request_upload_response["data"]["upload_url"], 201, b"{}") - self.network.prepareReply("POST", self.PRINT_URL, 200, request_print_response) - - file_handler = MagicMock() - file_handler.getSupportedFileTypesWrite.return_value = [{ - "extension": "ufp", - "mime_type": "application/x-ufp", - "mode": 2 - }, { - "extension": "gcode.gz", - "mime_type": "application/gzip", - "mode": 2, - }] - file_handler.getWriterByMimeType.return_value.write.side_effect = \ - lambda stream, nodes: stream.write(str(nodes).encode()) - - scene_nodes = [SceneNode()] - expected_mesh = str(scene_nodes).encode() - self.device.requestWrite(scene_nodes, file_handler=file_handler, file_name="FileName") - - self.network.flushReplies() - self.assertEqual( - {"data": {"content_type": "application/x-ufp", "file_size": len(expected_mesh), "job_name": "FileName"}}, - json.loads(self.network.getRequestBody("PUT", self.REQUEST_UPLOAD_URL).decode()) - ) - self.assertEqual(expected_mesh, - self.network.getRequestBody("PUT", request_upload_response["data"]["upload_url"])) - self.assertIsNone(self.network.getRequestBody("POST", self.PRINT_URL)) diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py deleted file mode 100644 index 869b39440c..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/TestCloudOutputDeviceManager.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -from unittest import TestCase -from unittest.mock import patch, MagicMock - -from UM.OutputDevice.OutputDeviceManager import OutputDeviceManager -from cura.UltimakerCloudAuthentication import CuraCloudAPIRoot -from ...src.Cloud import CloudApiClient -from ...src.Cloud import CloudOutputDeviceManager -from ...src.Cloud.Models.CloudClusterResponse import CloudClusterResponse -from .Fixtures import parseFixture, readFixture -from .NetworkManagerMock import NetworkManagerMock, FakeSignal - - -class TestCloudOutputDeviceManager(TestCase): - maxDiff = None - - URL = CuraCloudAPIRoot + "/connect/v1/clusters" - - def setUp(self): - super().setUp() - self.app = MagicMock() - self.device_manager = OutputDeviceManager() - self.app.getOutputDeviceManager.return_value = self.device_manager - - self.patches = [patch("UM.Qt.QtApplication.QtApplication.getInstance", return_value=self.app), - patch("UM.Application.Application.getInstance", return_value=self.app)] - for patched_method in self.patches: - patched_method.start() - - self.network = NetworkManagerMock() - self.timer = MagicMock(timeout = FakeSignal()) - with patch.object(CloudApiClient, "QNetworkAccessManager", return_value = self.network), \ - patch.object(CloudOutputDeviceManager, "QTimer", return_value = self.timer): - self.manager = CloudOutputDeviceManager.CloudOutputDeviceManager() - self.clusters_response = parseFixture("getClusters") - self.network.prepareReply("GET", self.URL, 200, readFixture("getClusters")) - - def tearDown(self): - try: - self._beforeTearDown() - - self.network.flushReplies() - self.manager.stop() - for patched_method in self.patches: - patched_method.stop() - finally: - super().tearDown() - - ## Before tear down method we check whether the state of the output device manager is what we expect based on the - # mocked API response. - def _beforeTearDown(self): - # let the network send replies - self.network.flushReplies() - # get the created devices - devices = self.device_manager.getOutputDevices() - # TODO: Check active device - - response_clusters = [] - for cluster in self.clusters_response.get("data", []): - response_clusters.append(CloudClusterResponse(**cluster).toDict()) - manager_clusters = sorted([device.clusterData.toDict() for device in self.manager._remote_clusters.values()], - key=lambda cluster: cluster['cluster_id'], reverse=True) - self.assertEqual(response_clusters, manager_clusters) - - ## Runs the initial request to retrieve the clusters. - def _loadData(self): - self.manager.start() - self.network.flushReplies() - - def test_device_is_created(self): - # just create the cluster, it is checked at tearDown - self._loadData() - - def test_device_is_updated(self): - self._loadData() - - # update the cluster from member variable, which is checked at tearDown - self.clusters_response["data"][0]["host_name"] = "New host name" - self.network.prepareReply("GET", self.URL, 200, self.clusters_response) - - self.manager._update_timer.timeout.emit() - - def test_device_is_removed(self): - self._loadData() - - # delete the cluster from member variable, which is checked at tearDown - del self.clusters_response["data"][1] - self.network.prepareReply("GET", self.URL, 200, self.clusters_response) - - self.manager._update_timer.timeout.emit() - - def test_device_connects_by_cluster_id(self): - active_machine_mock = self.app.getGlobalContainerStack.return_value - cluster1, cluster2 = self.clusters_response["data"] - cluster_id = cluster1["cluster_id"] - active_machine_mock.getMetaDataEntry.side_effect = {"um_cloud_cluster_id": cluster_id}.get - - self._loadData() - - self.assertTrue(self.device_manager.getOutputDevice(cluster1["cluster_id"]).isConnected()) - self.assertIsNone(self.device_manager.getOutputDevice(cluster2["cluster_id"])) - self.assertEqual([], active_machine_mock.setMetaDataEntry.mock_calls) - - def test_device_connects_by_network_key(self): - active_machine_mock = self.app.getGlobalContainerStack.return_value - - cluster1, cluster2 = self.clusters_response["data"] - network_key = cluster2["host_name"] + ".ultimaker.local" - active_machine_mock.getMetaDataEntry.side_effect = {"um_network_key": network_key}.get - - self._loadData() - - self.assertIsNone(self.device_manager.getOutputDevice(cluster1["cluster_id"])) - self.assertTrue(self.device_manager.getOutputDevice(cluster2["cluster_id"]).isConnected()) - - active_machine_mock.setMetaDataEntry.assert_called_with("um_cloud_cluster_id", cluster2["cluster_id"]) - - @patch.object(CloudOutputDeviceManager, "Message") - def test_api_error(self, message_mock): - self.clusters_response = { - "errors": [{"id": "notFound", "title": "Not found!", "http_status": "404", "code": "notFound"}] - } - self.network.prepareReply("GET", self.URL, 200, self.clusters_response) - self._loadData() - message_mock.return_value.show.assert_called_once_with() diff --git a/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py b/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py deleted file mode 100644 index f3f6970c54..0000000000 --- a/plugins/UM3NetworkPrinting/tests/Cloud/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py index 2cab110861..75be120ac5 100644 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py @@ -1,5 +1,5 @@ -# Copyright (c) 2018 Ultimaker B.V. -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import io import json diff --git a/plugins/UM3NetworkPrinting/tests/__init__.py b/plugins/UM3NetworkPrinting/tests/__init__.py index f3f6970c54..d5641e902f 100644 --- a/plugins/UM3NetworkPrinting/tests/__init__.py +++ b/plugins/UM3NetworkPrinting/tests/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. diff --git a/resources/definitions/creality_base.def.json b/resources/definitions/creality_base.def.json index 47f941e998..48311e0a5a 100644 --- a/resources/definitions/creality_base.def.json +++ b/resources/definitions/creality_base.def.json @@ -52,6 +52,7 @@ "speed_prime_tower": { "value": "speed_topbottom" }, "speed_support": { "value": "speed_wall_0" }, "speed_support_interface": { "value": "speed_topbottom" }, + "speed_z_hop": {"value": 5}, "skirt_brim_speed": { "value": "speed_layer_0" }, diff --git a/resources/definitions/felixpro2dual.def.json b/resources/definitions/felixpro2dual.def.json new file mode 100644 index 0000000000..0c978cdb71 --- /dev/null +++ b/resources/definitions/felixpro2dual.def.json @@ -0,0 +1,73 @@ +{ + "version": 2, + "name": "Felix Pro 2 Dual", + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "pnks", + "manufacturer": "Felix", + "platform": "FelixPro2_platform.obj", + "platform_offset": [-135, -0.5, 130], + "machine_extruder_trains": + { + "0": "felixpro2_dual_extruder_0", + "1": "felixpro2_dual_extruder_1" + }, + "file_formats": "text/x-gcode", + "has_variants": true, + "has_materials": true, + "preferred_variant_name": "0.35 mm", + "variants_name": "Nozzle diameter" + }, + "overrides": { + "machine_name": { "default_value": "FelixPro2Dual" }, + + "layer_height": { "default_value": 0.15 }, + "layer_height_0": { "default_value": 0.2 }, + "speed_layer_0": { "default_value": 20}, + + "infill_sparse_density": { "default_value": 20 }, + "wall_thickness": { "default_value": 1 }, + "top_bottom_thickness": { "default_value": 1 }, + + "machine_width": { "default_value": 240 }, + "machine_depth": { "default_value": 225 }, + "machine_height": { "default_value": 245 }, + + "machine_head_with_fans_polygon": + { + "default_value": [ + [ -60, 50 ], + [ -60, -50 ], + [ 70, 50 ], + [ 70, -50 ] + ] + }, + "gantry_height": { "value": "0" }, + "machine_extruder_count": { "default_value": 2 }, + "prime_tower_position_x": { "value": "250" }, + "prime_tower_position_y": { "value": "200" }, + + "machine_heated_bed": { "default_value": true }, + "machine_gcode_flavor": { "default_value": "Repetier" }, + "machine_center_is_zero": { "default_value": false }, + + "speed_print": { "default_value": 80 }, + "speed_travel": { "default_value": 200 }, + + "retraction_amount": { "default_value": 1 }, + "retraction_speed": { "default_value": 50}, + "material_flow": { "default_value": 100 }, + "material_flow_layer_0": { "default_value" : 110, "value": "material_flow * 1.1" }, + "adhesion_type": { "default_value": "skirt" }, + "skirt_brim_minimal_length": { "default_value": 130 }, + "skirt_line_count": { "default_value": 3 }, + + "machine_start_gcode": { + "default_value": "G90 ;absolute positioning\r\nM82 ;set extruder to absolute mode\r\nM107 ;start with the fan off\r\nG28 X0 Y0 ;move X\/Y to min endstops\r\nG28 Z0 ;move Z to min endstops\r\nG1 Z15.0 F9000 ;move the platform down 15mm\r\n\r\nT0 ;Switch to the 1st extruder\r\nG92 E0 ;zero the extruded length\r\nG1 F200 E6 ;extrude 6 mm of feed stock\r\nG92 E0 ;zero the extruded length again\r\n;G1 F9000\r\nM117 FPro2 printing...\r\n" + }, + "machine_end_gcode": { + "default_value": "; Endcode FELIXprinters Pro series\r\n; =================================\t; Move extruder to park position\r\nG91 \t\t\t\t\t; Make coordinates relative\r\nG1 Z2 F5000 \t\t\t\t; Move z 2mm up\r\nG90 \t\t\t\t\t; Use absolute coordinates again\t\t\r\nG1 X220 Y243 F7800 \t\t\t; Move bed and printhead to ergonomic position\r\n\r\n; =================================\t; Turn off heaters\r\nT0\t\t\t\t\t; Select left extruder\r\nM104 T0 S0\t\t\t\t; Turn off heater and continue\t\t\t\t\r\nG92 E0\t\t\t\t\t; Reset extruder position\r\nG1 E-8\t\t\t\t\t; Retract filament 8mm\r\nG1 E-5\t\t\t\t\t; Push back filament 3mm\r\nG92 E0\t\t\t\t\t; Reset extruder position\r\n\r\nT1\t\t\t\t\t; Select right extruder\r\nM104 T1 S0\t\t\t\t; Turn off heater and continu\r\nG92 E0\t\t\t\t\t; Reset extruder position\r\nG1 E-8\t\t\t\t\t; Retract filament 8mm\r\nG1 E-5\t\t\t\t\t; Push back filament 3mm\r\nG92 E0\t\t\t\t\t; Reset extruder position\r\nT0\t\t\t\t\t; Select left extruder\r\nM140 S0\t\t\t\t\t; Turn off bed heater\r\n\r\n; =================================\t; Turn the rest off\r\nM107 \t\t\t\t; Turn off fan\r\nM84\t\t\t\t\t; Disable steppers\r\nM117 Print Complete" + } + } +} diff --git a/resources/definitions/monoprice_ultimate.def.json b/resources/definitions/monoprice_ultimate.def.json index 48290f0941..445347b54e 100644 --- a/resources/definitions/monoprice_ultimate.def.json +++ b/resources/definitions/monoprice_ultimate.def.json @@ -1,52 +1,48 @@ { - "version": 2, - "name": "Monoprice Ultimate", - "inherits": "wanhao_d6", - "metadata": { - "visible": true, - "author": "Danny Tuppeny", - "manufacturer": "monoprice", - "file_formats": "text/x-gcode", - "icon": "wanhao-icon.png", - "has_materials": true, - "platform": "wanhao_200_200_platform.obj", - "platform_texture": "Wanhaobackplate.png", - "machine_extruder_trains": { - "0": "wanhao_d6_extruder_0" + "version": 2, + "name": "Monoprice Ultimate", + "inherits": "wanhao_d6", + "metadata": { + "visible": true, + "author": "Danny Tuppeny", + "manufacturer": "Monoprice", + "file_formats": "text/x-gcode", + "icon": "wanhao-icon.png", + "has_materials": true, + "platform": "wanhao_200_200_platform.obj", + "platform_texture": "Wanhaobackplate.png", + "machine_extruder_trains": { + "0": "wanhao_d6_extruder_0" + }, + "platform_offset": [0, -28, 0] }, - "platform_offset": [ - 0, - -28, - 0 - ] - }, - "overrides": { - "machine_name": { - "default_value": "Monoprice Ultimate" - }, - "machine_max_acceleration_x": { - "default_value": 3000 - }, - "machine_max_acceleration_y": { - "default_value": 3000 - }, - "machine_max_acceleration_z": { - "default_value": 100 - }, - "machine_max_acceleration_e": { - "default_value": 500 - }, - "machine_acceleration": { - "default_value": 800 - }, - "machine_max_jerk_xy": { - "default_value": 10.0 - }, - "machine_max_jerk_z": { - "default_value": 0.4 - }, - "machine_max_jerk_e": { - "default_value": 1.0 + "overrides": { + "machine_name": { + "default_value": "Monoprice Ultimate" + }, + "machine_max_acceleration_x": { + "default_value": 3000 + }, + "machine_max_acceleration_y": { + "default_value": 3000 + }, + "machine_max_acceleration_z": { + "default_value": 100 + }, + "machine_max_acceleration_e": { + "default_value": 500 + }, + "machine_acceleration": { + "default_value": 800 + }, + "machine_max_jerk_xy": { + "default_value": 10.0 + }, + "machine_max_jerk_z": { + "default_value": 0.4 + }, + "machine_max_jerk_e": { + "default_value": 1.0 + } } - } } diff --git a/resources/extruders/felixpro2_dual_extruder_0.def.json b/resources/extruders/felixpro2_dual_extruder_0.def.json new file mode 100644 index 0000000000..90c41a83b5 --- /dev/null +++ b/resources/extruders/felixpro2_dual_extruder_0.def.json @@ -0,0 +1,28 @@ +{ + "id": "felixpro2_dual_extruder_0", + "version": 2, + "name": "Left Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "felixpro2dual", + "position": "0" + }, + + "overrides": { + "extruder_nr": { + "default_value": 0, + "maximum_value": "1" + }, + "machine_nozzle_offset_x": { "default_value": 0 }, + "machine_nozzle_offset_y": { "default_value": 0 }, + "machine_nozzle_size": { "default_value": 0.35 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} diff --git a/resources/extruders/felixpro2_dual_extruder_1.def.json b/resources/extruders/felixpro2_dual_extruder_1.def.json new file mode 100644 index 0000000000..3ff0d401fd --- /dev/null +++ b/resources/extruders/felixpro2_dual_extruder_1.def.json @@ -0,0 +1,28 @@ +{ + "id": "felixpro2_dual_extruder_1", + "version": 2, + "name": "Right Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "felixpro2dual", + "position": "1" + }, + + "overrides": { + "extruder_nr": { + "default_value": 1, + "maximum_value": "2" + }, + "machine_nozzle_offset_x": { "default_value": 0 }, + "machine_nozzle_offset_y": { "default_value": 0 }, + "machine_nozzle_size": { "default_value": 0.35 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} diff --git a/resources/meshes/FelixPro2_platform.obj b/resources/meshes/FelixPro2_platform.obj new file mode 100644 index 0000000000..1d13cdd904 --- /dev/null +++ b/resources/meshes/FelixPro2_platform.obj @@ -0,0 +1,4485 @@ +# Blender v2.79 (sub 0) OBJ File: 'FelixPro2_platform.blend' +# www.blender.org +o Body1 +v 177.244446 31.941147 -22.999994 +v 177.244446 31.941143 -4.999995 +v 177.500000 30.000004 -22.999994 +v 177.500000 30.000000 -4.999995 +v 177.244446 28.058861 -22.999996 +v 177.244446 28.058857 -4.999995 +v 176.495193 26.250004 -22.999996 +v 176.495193 26.250000 -4.999996 +v 175.303299 24.696703 -22.999996 +v 175.303299 24.696699 -4.999996 +v 173.750000 23.504812 -22.999996 +v 173.750000 23.504808 -4.999996 +v 171.941147 22.755560 -22.999996 +v 171.941147 22.755556 -4.999996 +v 170.000000 22.500004 -22.999996 +v 170.000000 22.500000 -4.999996 +v 168.058853 22.755560 -22.999996 +v 168.058853 22.755556 -4.999996 +v 166.250000 23.504812 -22.999996 +v 166.250000 23.504808 -4.999996 +v 164.696701 24.696703 -22.999996 +v 164.696701 24.696699 -4.999996 +v 163.504807 26.250004 -22.999996 +v 163.504807 26.250000 -4.999996 +v 162.755554 28.058861 -22.999996 +v 162.755554 28.058857 -4.999995 +v 162.500000 30.000004 -22.999994 +v 162.500000 30.000000 -4.999995 +v 162.755554 31.941147 -22.999994 +v 162.755554 31.941143 -4.999995 +v 163.504807 33.750004 -22.999994 +v 163.504807 33.750000 -4.999994 +v 164.696701 35.303307 -22.999994 +v 164.696701 35.303303 -4.999994 +v 166.250000 36.495193 -22.999994 +v 166.250000 36.495190 -4.999994 +v 168.058853 37.244450 -22.999994 +v 168.058853 37.244446 -4.999994 +v 170.000000 37.500004 -22.999994 +v 170.000000 37.500000 -4.999994 +v 171.941147 37.244450 -22.999994 +v 171.941147 37.244446 -4.999994 +v 173.750000 36.495193 -22.999994 +v 173.750000 36.495190 -4.999994 +v 175.303299 35.303307 -22.999994 +v 175.303299 35.303303 -4.999994 +v 176.495193 33.750004 -22.999994 +v 176.495193 33.750000 -4.999994 +v 97.424881 121.941139 -22.999981 +v 97.424881 121.941139 -4.999980 +v 97.680435 120.000000 -22.999981 +v 97.680435 120.000000 -4.999980 +v 97.424881 118.058861 -22.999981 +v 97.424881 118.058861 -4.999981 +v 96.675629 116.250000 -22.999981 +v 96.675629 116.250000 -4.999981 +v 95.483734 114.696701 -22.999981 +v 95.483734 114.696701 -4.999981 +v 93.930435 113.504807 -22.999981 +v 93.930435 113.504807 -4.999981 +v 92.121582 112.755554 -22.999981 +v 92.121582 112.755554 -4.999981 +v 90.180435 112.500000 -22.999981 +v 90.180435 112.500000 -4.999982 +v 88.239296 112.755554 -22.999981 +v 88.239296 112.755554 -4.999981 +v 86.430435 113.504807 -22.999981 +v 86.430435 113.504807 -4.999981 +v 84.877136 114.696701 -22.999981 +v 84.877136 114.696701 -4.999981 +v 83.685249 116.250000 -22.999981 +v 83.685249 116.250000 -4.999981 +v 82.935989 118.058861 -22.999981 +v 82.935989 118.058861 -4.999981 +v 82.680435 120.000000 -22.999981 +v 82.680435 120.000000 -4.999980 +v 82.935989 121.941139 -22.999981 +v 82.935989 121.941139 -4.999980 +v 83.685249 123.750000 -22.999979 +v 83.685249 123.750000 -4.999980 +v 84.877136 125.303299 -22.999979 +v 84.877136 125.303299 -4.999979 +v 86.430435 126.495193 -22.999979 +v 86.430435 126.495193 -4.999979 +v 88.239296 127.244446 -22.999979 +v 88.239296 127.244446 -4.999979 +v 90.180435 127.500000 -22.999979 +v 90.180435 127.500000 -4.999979 +v 92.121582 127.244446 -22.999979 +v 92.121582 127.244446 -4.999979 +v 93.930435 126.495193 -22.999979 +v 93.930435 126.495193 -4.999979 +v 95.483734 125.303299 -22.999979 +v 95.483734 125.303299 -4.999979 +v 96.675629 123.750000 -22.999979 +v 96.675629 123.750000 -4.999980 +v 180.000000 20.000004 -22.999996 +v 160.000000 20.000004 -22.999996 +v 160.000000 49.896946 -22.999992 +v 105.938271 109.965530 -22.999983 +v 160.000000 190.103058 -22.999969 +v 173.750000 203.504807 -22.999968 +v 171.941147 202.755554 -22.999968 +v 175.303299 204.696701 -22.999968 +v 176.495193 206.250000 -22.999966 +v 177.244446 208.058853 -22.999966 +v 180.000000 220.000000 -22.999964 +v 102.087685 120.000000 -22.999981 +v 102.532974 116.372253 -22.999981 +v 103.842422 112.959900 -22.999981 +v 79.031021 109.965530 -22.999983 +v 76.935173 112.959900 -22.999981 +v 75.625732 116.372253 -22.999981 +v 75.180435 120.000000 -22.999981 +v 75.625732 123.627747 -22.999979 +v 76.935173 127.040100 -22.999979 +v 79.031021 130.034470 -22.999979 +v 105.938271 130.034470 -22.999979 +v 160.000000 220.000000 -22.999964 +v 103.842422 127.040100 -22.999979 +v 102.532974 123.627747 -22.999979 +v 162.500000 210.000000 -22.999966 +v 162.755554 211.941147 -22.999966 +v 170.000000 217.500000 -22.999964 +v 171.941147 217.244446 -22.999964 +v 173.750000 216.495193 -22.999966 +v 177.244446 211.941147 -22.999966 +v 177.500000 210.000000 -22.999966 +v 170.000000 202.500000 -22.999968 +v 168.058853 202.755554 -22.999968 +v 166.250000 203.504807 -22.999968 +v 164.696701 204.696701 -22.999968 +v 163.504807 206.250000 -22.999966 +v 162.755554 208.058853 -22.999966 +v 163.504807 213.750000 -22.999966 +v 164.696701 215.303299 -22.999966 +v 166.250000 216.495193 -22.999966 +v 168.058853 217.244446 -22.999964 +v 175.303299 215.303299 -22.999966 +v 176.495193 213.750000 -22.999966 +v 160.000000 220.000000 -29.999964 +v 134.000000 220.000000 -29.999964 +v 160.017288 220.019211 -30.195053 +v 134.000000 220.034073 -30.258783 +v 160.068512 220.076126 -30.382647 +v 134.000000 220.133972 -30.499964 +v 160.151672 220.168533 -30.555534 +v 134.000000 220.292892 -30.707071 +v 160.263611 220.292892 -30.707071 +v 160.399994 220.444427 -30.831434 +v 134.000000 220.500000 -30.865990 +v 160.555588 220.617310 -30.923843 +v 134.000000 220.741180 -30.965889 +v 160.724426 220.804916 -30.980749 +v 134.000000 221.000000 -30.999964 +v 160.899994 221.000000 -30.999964 +v 125.000000 229.000000 -29.999962 +v 125.034073 229.000000 -30.258781 +v 125.306671 226.670624 -29.999964 +v 125.339584 226.679443 -30.258783 +v 126.205772 224.500000 -29.999964 +v 126.235283 224.517044 -30.258783 +v 127.636040 222.636032 -29.999964 +v 127.660133 222.660126 -30.258783 +v 129.500000 221.205765 -29.999964 +v 129.517044 221.235275 -30.258783 +v 131.670624 220.306671 -29.999964 +v 131.679443 220.339584 -30.258783 +v 131.705307 220.436081 -30.499964 +v 131.746429 220.589584 -30.707071 +v 131.800034 220.789627 -30.865990 +v 131.862457 221.022598 -30.965889 +v 131.929443 221.272598 -30.999964 +v 125.133972 229.000000 -30.499962 +v 125.436073 226.705307 -30.499964 +v 126.321800 224.566986 -30.499964 +v 127.730774 222.730774 -30.499964 +v 129.566986 221.321793 -30.499964 +v 125.292892 229.000000 -30.707069 +v 125.589584 226.746429 -30.707071 +v 126.459427 224.646454 -30.707071 +v 127.843147 222.843140 -30.707071 +v 129.646454 221.459427 -30.707071 +v 125.500000 229.000000 -30.865988 +v 125.789627 226.800034 -30.865990 +v 126.638786 224.750000 -30.865990 +v 127.989594 222.989594 -30.865990 +v 129.750000 221.638779 -30.865990 +v 125.741180 229.000000 -30.965887 +v 126.022591 226.862457 -30.965889 +v 126.847656 224.870590 -30.965889 +v 128.160126 223.160126 -30.965889 +v 129.870590 221.847656 -30.965889 +v 126.000000 229.000000 -30.999962 +v 126.272591 226.929443 -30.999964 +v 127.071800 225.000000 -30.999964 +v 128.343140 223.343140 -30.999964 +v 130.000000 222.071793 -30.999964 +v 125.000000 251.000000 -29.999960 +v 125.034073 251.000000 -30.258780 +v 125.133972 251.000000 -30.499960 +v 125.292892 251.000000 -30.707067 +v 125.500000 251.000000 -30.865986 +v 125.741180 251.000000 -30.965885 +v 126.000000 251.000000 -30.999960 +v 134.000000 260.000000 -29.999958 +v 134.000000 259.965912 -30.258778 +v 131.670624 259.693329 -29.999958 +v 131.679443 259.660431 -30.258778 +v 129.500000 258.794220 -29.999958 +v 129.517044 258.764709 -30.258778 +v 127.636040 257.363953 -29.999958 +v 127.660133 257.339874 -30.258778 +v 126.205772 255.500000 -29.999958 +v 126.235283 255.482956 -30.258778 +v 125.306671 253.329376 -29.999958 +v 125.339584 253.320557 -30.258778 +v 125.436073 253.294693 -30.499958 +v 125.589584 253.253571 -30.707066 +v 125.789627 253.199966 -30.865984 +v 126.022591 253.137543 -30.965883 +v 126.272591 253.070557 -30.999958 +v 134.000000 259.866028 -30.499958 +v 131.705307 259.563934 -30.499958 +v 129.566986 258.678192 -30.499958 +v 127.730774 257.269226 -30.499958 +v 126.321800 255.433014 -30.499958 +v 134.000000 259.707092 -30.707066 +v 131.746429 259.410431 -30.707066 +v 129.646454 258.540588 -30.707066 +v 127.843147 257.156860 -30.707066 +v 126.459427 255.353546 -30.707066 +v 134.000000 259.500000 -30.865984 +v 131.800034 259.210358 -30.865984 +v 129.750000 258.361206 -30.865984 +v 127.989594 257.010406 -30.865984 +v 126.638786 255.250000 -30.865984 +v 134.000000 259.258820 -30.965883 +v 131.862457 258.977417 -30.965883 +v 129.870590 258.152344 -30.965883 +v 128.160126 256.839874 -30.965883 +v 126.847656 255.129410 -30.965883 +v 134.000000 259.000000 -30.999958 +v 131.929443 258.727417 -30.999958 +v 130.000000 257.928192 -30.999958 +v 128.343140 256.656860 -30.999958 +v 127.071800 255.000000 -30.999958 +v 186.000000 260.000000 -29.999958 +v 186.000000 259.965912 -30.258778 +v 186.000000 259.866028 -30.499958 +v 186.000000 259.707092 -30.707066 +v 186.000000 259.500000 -30.865984 +v 186.000000 259.258820 -30.965883 +v 186.000000 259.000000 -30.999958 +v 195.000000 251.000000 -29.999960 +v 194.965927 251.000000 -30.258780 +v 194.693329 253.329376 -29.999958 +v 194.660416 253.320557 -30.258778 +v 193.794235 255.500000 -29.999958 +v 193.764725 255.482956 -30.258778 +v 192.363968 257.363953 -29.999958 +v 192.339874 257.339874 -30.258778 +v 190.500000 258.794220 -29.999958 +v 190.482956 258.764709 -30.258778 +v 188.329376 259.693329 -29.999958 +v 188.320557 259.660431 -30.258778 +v 188.294693 259.563934 -30.499958 +v 188.253571 259.410431 -30.707066 +v 188.199966 259.210358 -30.865984 +v 188.137543 258.977417 -30.965883 +v 188.070557 258.727417 -30.999958 +v 194.866028 251.000000 -30.499960 +v 194.563919 253.294693 -30.499958 +v 193.678207 255.433014 -30.499958 +v 192.269226 257.269226 -30.499958 +v 190.433014 258.678192 -30.499958 +v 194.707108 251.000000 -30.707067 +v 194.410416 253.253571 -30.707066 +v 193.540573 255.353546 -30.707066 +v 192.156860 257.156860 -30.707066 +v 190.353546 258.540588 -30.707066 +v 194.500000 251.000000 -30.865986 +v 194.210373 253.199966 -30.865984 +v 193.361221 255.250000 -30.865984 +v 192.010406 257.010406 -30.865984 +v 190.250000 258.361206 -30.865984 +v 194.258820 251.000000 -30.965885 +v 193.977402 253.137543 -30.965883 +v 193.152344 255.129410 -30.965883 +v 191.839874 256.839874 -30.965883 +v 190.129410 258.152344 -30.965883 +v 194.000000 251.000000 -30.999960 +v 193.727402 253.070557 -30.999958 +v 192.928207 255.000000 -30.999958 +v 191.656860 256.656860 -30.999958 +v 190.000000 257.928192 -30.999958 +v 195.000000 229.000000 -29.999962 +v 194.965927 229.000000 -30.258781 +v 194.866028 229.000000 -30.499962 +v 194.707108 229.000000 -30.707069 +v 194.500000 229.000000 -30.865988 +v 194.258820 229.000000 -30.965887 +v 194.000000 229.000000 -30.999962 +v 186.000000 220.000000 -29.999964 +v 186.000000 220.034073 -30.258783 +v 188.329376 220.306671 -29.999964 +v 188.320557 220.339584 -30.258783 +v 190.500000 221.205765 -29.999964 +v 190.482956 221.235275 -30.258783 +v 192.363968 222.636032 -29.999964 +v 192.339874 222.660126 -30.258783 +v 193.794235 224.500000 -29.999964 +v 193.764725 224.517044 -30.258783 +v 194.693329 226.670624 -29.999964 +v 194.660416 226.679443 -30.258783 +v 194.563919 226.705307 -30.499964 +v 194.410416 226.746429 -30.707071 +v 194.210373 226.800034 -30.865990 +v 193.977402 226.862457 -30.965889 +v 193.727402 226.929443 -30.999964 +v 186.000000 220.133972 -30.499964 +v 188.294693 220.436081 -30.499964 +v 190.433014 221.321793 -30.499964 +v 192.269226 222.730774 -30.499964 +v 193.678207 224.566986 -30.499964 +v 186.000000 220.292892 -30.707071 +v 188.253571 220.589584 -30.707071 +v 190.353546 221.459427 -30.707071 +v 192.156860 222.843140 -30.707071 +v 193.540573 224.646454 -30.707071 +v 186.000000 220.500000 -30.865990 +v 188.199966 220.789627 -30.865990 +v 190.250000 221.638779 -30.865990 +v 192.010406 222.989594 -30.865990 +v 193.361221 224.750000 -30.865990 +v 186.000000 220.741180 -30.965889 +v 188.137543 221.022598 -30.965889 +v 190.129410 221.847656 -30.965889 +v 191.839874 223.160126 -30.965889 +v 193.152344 224.870590 -30.965889 +v 186.000000 221.000000 -30.999964 +v 188.070557 221.272598 -30.999964 +v 190.000000 222.071793 -30.999964 +v 191.656860 223.343140 -30.999964 +v 192.928207 225.000000 -30.999964 +v 180.000000 220.000000 -29.999964 +v 180.000000 220.034073 -30.258783 +v 180.000000 220.133972 -30.499964 +v 180.000000 220.292892 -30.707071 +v 180.000000 220.500000 -30.865990 +v 180.000000 220.741180 -30.965889 +v 180.000000 221.000000 -30.999964 +v 186.000000 220.000000 -13.999964 +v 188.329376 220.306671 -13.999964 +v 190.500000 221.205765 -13.999964 +v 192.363968 222.636032 -13.999964 +v 193.794235 224.500000 -13.999964 +v 194.693329 226.670624 -13.999963 +v 195.000000 229.000000 -13.999963 +v 195.000000 251.000000 -13.999959 +v 194.693329 253.329376 -13.999959 +v 193.794235 255.500000 -13.999958 +v 192.363968 257.363953 -13.999958 +v 190.500000 258.794220 -13.999958 +v 188.329376 259.693329 -13.999958 +v 186.000000 260.000000 -13.999958 +v 134.000000 260.000000 -13.999958 +v 131.670624 259.693329 -13.999958 +v 129.500000 258.794220 -13.999958 +v 127.636040 257.363953 -13.999958 +v 126.205772 255.500000 -13.999958 +v 125.306671 253.329376 -13.999959 +v 125.000000 251.000000 -13.999959 +v 125.000000 229.000000 -13.999963 +v 125.306671 226.670624 -13.999963 +v 126.205772 224.500000 -13.999964 +v 127.636040 222.636032 -13.999964 +v 129.500000 221.205765 -13.999964 +v 131.670624 220.306671 -13.999964 +v 134.000000 220.000000 -13.999964 +v 194.000000 251.000000 -12.999959 +v 194.000000 229.000000 -12.999963 +v 194.258820 251.000000 -13.034033 +v 194.258820 229.000000 -13.034037 +v 194.500000 251.000000 -13.133934 +v 194.500000 229.000000 -13.133938 +v 194.707108 251.000000 -13.292852 +v 194.707108 229.000000 -13.292856 +v 194.866028 251.000000 -13.499959 +v 194.866028 229.000000 -13.499963 +v 194.965927 251.000000 -13.741140 +v 194.965927 229.000000 -13.741144 +v 186.000000 259.965912 -13.741139 +v 188.320557 259.660431 -13.741139 +v 190.482956 258.764709 -13.741139 +v 192.339874 257.339874 -13.741139 +v 193.764725 255.482956 -13.741139 +v 194.660416 253.320557 -13.741140 +v 194.563919 253.294693 -13.499959 +v 194.410416 253.253571 -13.292852 +v 194.210373 253.199966 -13.133934 +v 193.977402 253.137543 -13.034033 +v 193.727402 253.070557 -12.999959 +v 186.000000 259.866028 -13.499958 +v 188.294693 259.563934 -13.499958 +v 190.433014 258.678192 -13.499958 +v 192.269226 257.269226 -13.499958 +v 193.678207 255.433014 -13.499958 +v 186.000000 259.707092 -13.292851 +v 188.253571 259.410431 -13.292851 +v 190.353546 258.540588 -13.292851 +v 192.156860 257.156860 -13.292851 +v 193.540573 255.353546 -13.292851 +v 186.000000 259.500000 -13.133933 +v 188.199966 259.210358 -13.133933 +v 190.250000 258.361206 -13.133933 +v 192.010406 257.010406 -13.133933 +v 193.361221 255.250000 -13.133933 +v 186.000000 259.258820 -13.034032 +v 188.137543 258.977417 -13.034032 +v 190.129410 258.152344 -13.034032 +v 191.839874 256.839874 -13.034032 +v 193.152344 255.129410 -13.034032 +v 186.000000 259.000000 -12.999958 +v 188.070557 258.727417 -12.999958 +v 190.000000 257.928192 -12.999958 +v 191.656860 256.656860 -12.999958 +v 192.928207 255.000000 -12.999958 +v 134.000000 259.965912 -13.741139 +v 134.000000 259.866028 -13.499958 +v 134.000000 259.707092 -13.292851 +v 134.000000 259.500000 -13.133933 +v 134.000000 259.258820 -13.034032 +v 134.000000 259.000000 -12.999958 +v 125.034073 251.000000 -13.741140 +v 125.339584 253.320557 -13.741140 +v 126.235283 255.482956 -13.741139 +v 127.660133 257.339874 -13.741139 +v 129.517044 258.764709 -13.741139 +v 131.679443 259.660431 -13.741139 +v 131.705307 259.563934 -13.499958 +v 131.746429 259.410431 -13.292851 +v 131.800034 259.210358 -13.133933 +v 131.862457 258.977417 -13.034032 +v 131.929443 258.727417 -12.999958 +v 125.133972 251.000000 -13.499959 +v 125.436073 253.294693 -13.499959 +v 126.321800 255.433014 -13.499958 +v 127.730774 257.269226 -13.499958 +v 129.566986 258.678192 -13.499958 +v 125.292892 251.000000 -13.292852 +v 125.589584 253.253571 -13.292852 +v 126.459427 255.353546 -13.292851 +v 127.843147 257.156860 -13.292851 +v 129.646454 258.540588 -13.292851 +v 125.500000 251.000000 -13.133934 +v 125.789627 253.199966 -13.133934 +v 126.638786 255.250000 -13.133933 +v 127.989594 257.010406 -13.133933 +v 129.750000 258.361206 -13.133933 +v 125.741180 251.000000 -13.034033 +v 126.022591 253.137543 -13.034033 +v 126.847656 255.129410 -13.034032 +v 128.160126 256.839874 -13.034032 +v 129.870590 258.152344 -13.034032 +v 126.000000 251.000000 -12.999959 +v 126.272591 253.070557 -12.999959 +v 127.071800 255.000000 -12.999958 +v 128.343140 256.656860 -12.999958 +v 130.000000 257.928192 -12.999958 +v 125.034073 229.000000 -13.741144 +v 125.133972 229.000000 -13.499963 +v 125.292892 229.000000 -13.292856 +v 125.500000 229.000000 -13.133938 +v 125.741180 229.000000 -13.034037 +v 126.000000 229.000000 -12.999963 +v 134.000000 220.034073 -13.741145 +v 131.679443 220.339584 -13.741145 +v 129.517044 221.235275 -13.741145 +v 127.660133 222.660126 -13.741145 +v 126.235283 224.517044 -13.741145 +v 125.339584 226.679443 -13.741144 +v 125.436073 226.705307 -13.499963 +v 125.589584 226.746429 -13.292856 +v 125.789627 226.800034 -13.133938 +v 126.022591 226.862457 -13.034037 +v 126.272591 226.929443 -12.999963 +v 134.000000 220.133972 -13.499964 +v 131.705307 220.436081 -13.499964 +v 129.566986 221.321793 -13.499964 +v 127.730774 222.730774 -13.499964 +v 126.321800 224.566986 -13.499964 +v 134.000000 220.292892 -13.292857 +v 131.746429 220.589584 -13.292857 +v 129.646454 221.459427 -13.292857 +v 127.843147 222.843140 -13.292857 +v 126.459427 224.646454 -13.292857 +v 134.000000 220.500000 -13.133939 +v 131.800034 220.789627 -13.133939 +v 129.750000 221.638779 -13.133939 +v 127.989594 222.989594 -13.133939 +v 126.638786 224.750000 -13.133939 +v 134.000000 220.741180 -13.034038 +v 131.862457 221.022598 -13.034038 +v 129.870590 221.847656 -13.034038 +v 128.160126 223.160126 -13.034038 +v 126.847656 224.870590 -13.034038 +v 134.000000 221.000000 -12.999964 +v 131.929443 221.272598 -12.999964 +v 130.000000 222.071793 -12.999964 +v 128.343140 223.343140 -12.999964 +v 127.071800 225.000000 -12.999964 +v 186.000000 220.034073 -13.741145 +v 186.000000 220.133972 -13.499964 +v 186.000000 220.292892 -13.292857 +v 186.000000 220.500000 -13.133939 +v 186.000000 220.741180 -13.034038 +v 186.000000 221.000000 -12.999964 +v 188.070557 221.272598 -12.999964 +v 188.137543 221.022598 -13.034038 +v 190.000000 222.071793 -12.999964 +v 190.129410 221.847656 -13.034038 +v 191.656860 223.343140 -12.999964 +v 191.839874 223.160126 -13.034038 +v 192.928207 225.000000 -12.999964 +v 193.152344 224.870590 -13.034038 +v 193.727402 226.929443 -12.999963 +v 193.977402 226.862457 -13.034037 +v 194.210373 226.800034 -13.133938 +v 194.410416 226.746429 -13.292856 +v 194.563919 226.705307 -13.499963 +v 194.660416 226.679443 -13.741144 +v 188.199966 220.789627 -13.133939 +v 190.250000 221.638779 -13.133939 +v 192.010406 222.989594 -13.133939 +v 193.361221 224.750000 -13.133939 +v 188.253571 220.589584 -13.292857 +v 190.353546 221.459427 -13.292857 +v 192.156860 222.843140 -13.292857 +v 193.540573 224.646454 -13.292857 +v 188.294693 220.436081 -13.499964 +v 190.433014 221.321793 -13.499964 +v 192.269226 222.730774 -13.499964 +v 193.678207 224.566986 -13.499964 +v 188.320557 220.339584 -13.741145 +v 190.482956 221.235275 -13.741145 +v 192.339874 222.660126 -13.741145 +v 193.764725 224.517044 -13.741145 +v 194.000000 3.974728 -31.000000 +v 194.000000 20.000006 -30.999996 +v 194.258820 3.974728 -30.965925 +v 194.258820 20.000006 -30.965921 +v 194.500000 3.974728 -30.866026 +v 194.500000 20.000006 -30.866022 +v 194.707108 3.974728 -30.707108 +v 194.707108 20.000006 -30.707104 +v 194.866028 3.974728 -30.500000 +v 194.866028 20.000006 -30.499996 +v 194.965927 3.974728 -30.258820 +v 194.965927 20.000006 -30.258816 +v 195.000000 3.974728 -30.000000 +v 195.000000 20.000006 -29.999996 +v 194.720581 -1.303926 -30.000000 +v 194.372025 -3.924798 -30.000000 +v 194.571930 -2.287664 -30.258820 +v 194.338379 -3.919414 -30.258820 +v 194.239746 -3.903631 -30.500000 +v 194.472824 -2.275143 -30.500000 +v 194.082809 -3.878523 -30.707108 +v 194.315155 -2.255225 -30.707108 +v 193.878311 -3.845802 -30.866026 +v 194.109680 -2.229268 -30.866026 +v 193.640152 -3.807698 -30.965925 +v 193.870392 -2.199040 -30.965925 +v 193.384598 -3.766807 -31.000000 +v 193.726166 -1.198353 -31.000000 +v 193.931488 1.384566 -31.000000 +v 194.930099 1.331706 -30.000000 +v 191.943054 -19.105928 -30.000004 +v 191.909409 -19.100544 -30.258823 +v 191.810760 -19.084761 -30.500004 +v 191.653839 -19.059652 -30.707111 +v 191.449326 -19.026932 -30.866030 +v 191.211182 -18.988829 -30.965929 +v 190.955612 -18.947937 -31.000004 +v 185.030960 -24.999994 -30.000004 +v 185.030960 -24.965919 -30.258823 +v 186.663300 -24.807013 -30.000004 +v 186.833878 -24.728561 -30.258823 +v 188.205627 -24.238708 -30.000004 +v 188.513931 -24.032663 -30.258823 +v 189.572906 -23.326416 -30.000004 +v 189.956619 -22.925648 -30.258823 +v 190.689758 -22.120438 -30.000004 +v 191.063629 -21.482958 -30.258823 +v 191.494598 -20.687267 -30.000004 +v 191.759537 -19.802908 -30.258823 +v 191.663040 -19.777052 -30.500004 +v 191.509537 -19.735922 -30.707111 +v 191.309479 -19.682318 -30.866030 +v 191.076523 -19.619896 -30.965929 +v 190.571228 -20.303371 -31.000004 +v 190.451263 -21.129404 -30.965929 +v 189.881363 -21.531803 -31.000004 +v 189.456619 -22.425648 -30.965929 +v 188.924057 -22.565498 -31.000004 +v 188.160370 -23.420290 -30.965929 +v 187.752106 -23.347464 -31.000004 +v 186.650864 -24.045549 -30.965929 +v 186.430099 -23.834581 -31.000004 +v 185.030960 -24.258814 -30.965929 +v 185.030960 -23.999994 -31.000004 +v 185.030960 -24.866020 -30.500004 +v 186.808029 -24.632065 -30.500004 +v 188.463974 -23.946146 -30.500004 +v 189.885986 -22.855007 -30.500004 +v 190.977112 -21.433008 -30.500004 +v 185.030960 -24.707102 -30.707111 +v 186.766891 -24.478561 -30.707111 +v 188.384521 -23.808519 -30.707111 +v 189.773605 -22.742636 -30.707111 +v 190.839493 -21.353548 -30.707111 +v 185.030960 -24.499994 -30.866030 +v 186.713287 -24.278513 -30.866030 +v 188.280960 -23.629160 -30.866030 +v 189.627167 -22.596188 -30.866030 +v 190.660126 -21.249994 -30.866030 +v 136.518784 -24.999994 -30.000004 +v 136.518784 -24.965919 -30.258823 +v 136.518784 -24.866020 -30.500004 +v 136.518784 -24.707102 -30.707111 +v 136.518784 -24.499994 -30.866030 +v 136.518784 -24.258814 -30.965929 +v 136.518784 -23.999994 -31.000004 +v 130.240479 -21.095625 -30.000004 +v 129.712067 -19.633606 -30.000004 +v 129.790207 -19.802908 -30.258823 +v 129.745193 -19.625654 -30.258823 +v 129.842346 -19.602339 -30.500004 +v 129.886703 -19.777052 -30.500004 +v 129.996872 -19.565250 -30.707111 +v 130.040207 -19.735922 -30.707111 +v 130.198257 -19.516918 -30.866030 +v 130.240265 -19.682318 -30.866030 +v 130.432785 -19.460634 -30.965929 +v 130.473221 -19.619896 -30.965929 +v 130.684448 -19.400232 -31.000004 +v 131.137375 -20.653393 -31.000004 +v 131.098480 -21.129404 -30.965929 +v 131.855713 -21.775684 -31.000004 +v 132.093124 -22.425648 -30.965929 +v 132.804031 -22.711758 -31.000004 +v 133.389374 -23.420290 -30.965929 +v 133.935577 -23.415442 -31.000004 +v 134.898880 -24.045549 -30.965929 +v 135.194519 -23.852032 -31.000004 +v 134.836456 -24.278513 -30.866030 +v 133.268784 -23.629160 -30.866030 +v 131.922577 -22.596188 -30.866030 +v 130.889618 -21.249994 -30.866030 +v 134.782852 -24.478561 -30.707111 +v 133.165222 -23.808519 -30.707111 +v 131.776138 -22.742636 -30.707111 +v 130.710251 -21.353548 -30.707111 +v 134.741714 -24.632065 -30.500004 +v 133.085770 -23.946146 -30.500004 +v 131.663773 -22.855007 -30.500004 +v 130.572632 -21.433008 -30.500004 +v 134.715866 -24.728561 -30.258823 +v 133.035812 -24.032663 -30.258823 +v 131.593124 -22.925648 -30.258823 +v 130.486115 -21.482958 -30.258823 +v 134.973816 -24.827372 -30.000004 +v 133.505035 -24.318016 -30.000004 +v 132.184921 -23.497051 -30.000004 +v 131.078537 -22.404966 -30.000004 +v 126.380638 -5.752640 -30.000000 +v 126.413765 -5.744688 -30.258820 +v 126.510910 -5.721374 -30.500000 +v 126.665443 -5.684287 -30.707108 +v 126.866829 -5.635954 -30.866026 +v 127.101349 -5.579669 -30.965925 +v 127.353020 -5.519267 -31.000000 +v 125.000000 5.916007 -29.999998 +v 125.034073 5.916007 -30.258818 +v 125.154037 1.994290 -30.000000 +v 125.615196 -1.903264 -30.000000 +v 125.133972 5.916007 -30.499998 +v 125.292892 5.916007 -30.707106 +v 125.500000 5.916007 -30.866024 +v 125.741180 5.916007 -30.965923 +v 126.602890 -1.746879 -31.000000 +v 126.000000 5.916007 -30.999998 +v 126.150955 2.072724 -31.000000 +v 125.000000 20.000006 -29.999996 +v 125.034073 20.000006 -30.258816 +v 125.133972 20.000006 -30.499996 +v 125.292892 20.000006 -30.707104 +v 125.500000 20.000006 -30.866022 +v 125.741180 20.000006 -30.965921 +v 126.000000 20.000006 -30.999996 +v 125.000000 5.916004 -13.999999 +v 125.154037 1.994287 -14.000000 +v 125.615196 -1.903267 -14.000000 +v 126.380638 -5.752643 -14.000001 +v 129.712067 -19.633610 -14.000003 +v 130.240479 -21.095629 -14.000004 +v 131.078537 -22.404970 -14.000004 +v 132.184921 -23.497055 -14.000004 +v 133.505035 -24.318020 -14.000004 +v 134.973816 -24.827375 -14.000004 +v 136.518784 -24.999998 -14.000004 +v 185.030960 -24.999998 -14.000004 +v 186.663300 -24.807016 -14.000004 +v 188.205627 -24.238712 -14.000004 +v 189.572906 -23.326420 -14.000004 +v 190.689758 -22.120441 -14.000004 +v 191.494598 -20.687271 -14.000004 +v 191.943054 -19.105932 -14.000003 +v 194.372025 -3.924801 -14.000001 +v 194.720581 -1.303929 -14.000000 +v 194.930099 1.331703 -14.000000 +v 195.000000 3.974725 -13.999999 +v 195.000000 20.000002 -13.999997 +v 194.965927 3.974725 -13.741180 +v 194.965927 20.000002 -13.741179 +v 194.866028 3.974725 -13.499999 +v 194.866028 20.000002 -13.499997 +v 194.707108 3.974725 -13.292892 +v 194.707108 20.000002 -13.292891 +v 194.500000 3.974725 -13.133974 +v 194.500000 20.000002 -13.133972 +v 194.258820 3.974725 -13.034073 +v 194.258820 20.000002 -13.034071 +v 194.000000 3.974725 -12.999999 +v 194.000000 20.000002 -12.999997 +v 193.384598 -3.766810 -13.000001 +v 193.640152 -3.807701 -13.034075 +v 193.726166 -1.198356 -13.000000 +v 193.931488 1.384563 -13.000000 +v 193.878311 -3.845805 -13.133976 +v 194.082809 -3.878526 -13.292894 +v 194.239746 -3.903634 -13.500001 +v 194.338379 -3.919417 -13.741182 +v 190.955612 -18.947941 -13.000003 +v 191.211182 -18.988832 -13.034077 +v 191.449326 -19.026936 -13.133978 +v 191.653839 -19.059656 -13.292896 +v 191.810760 -19.084764 -13.500003 +v 191.909409 -19.100548 -13.741184 +v 185.030960 -23.999998 -13.000004 +v 185.030960 -24.258818 -13.034078 +v 186.430099 -23.834585 -13.000004 +v 186.650864 -24.045553 -13.034078 +v 187.752106 -23.347467 -13.000004 +v 188.160370 -23.420294 -13.034078 +v 188.924057 -22.565502 -13.000004 +v 189.456619 -22.425652 -13.034078 +v 189.881363 -21.531807 -13.000004 +v 190.451263 -21.129408 -13.034078 +v 190.571228 -20.303375 -13.000003 +v 191.076523 -19.619900 -13.034077 +v 191.309479 -19.682322 -13.133978 +v 191.509537 -19.735926 -13.292896 +v 191.663040 -19.777056 -13.500003 +v 191.759537 -19.802912 -13.741184 +v 191.063629 -21.482962 -13.741185 +v 189.956619 -22.925652 -13.741185 +v 188.513931 -24.032667 -13.741185 +v 186.833878 -24.728565 -13.741185 +v 185.030960 -24.965923 -13.741185 +v 185.030960 -24.499998 -13.133979 +v 186.713287 -24.278517 -13.133979 +v 188.280960 -23.629164 -13.133979 +v 189.627167 -22.596191 -13.133979 +v 190.660126 -21.249998 -13.133979 +v 185.030960 -24.707106 -13.292897 +v 186.766891 -24.478565 -13.292897 +v 188.384521 -23.808523 -13.292897 +v 189.773605 -22.742640 -13.292897 +v 190.839493 -21.353552 -13.292897 +v 185.030960 -24.866024 -13.500004 +v 186.808029 -24.632069 -13.500004 +v 188.463974 -23.946150 -13.500004 +v 189.885986 -22.855011 -13.500004 +v 190.977112 -21.433012 -13.500004 +v 136.518784 -23.999998 -13.000004 +v 136.518784 -24.258818 -13.034078 +v 136.518784 -24.499998 -13.133979 +v 136.518784 -24.707106 -13.292897 +v 136.518784 -24.866024 -13.500004 +v 136.518784 -24.965923 -13.741185 +v 131.137375 -20.653397 -13.000004 +v 130.684448 -19.400236 -13.000003 +v 130.473221 -19.619900 -13.034077 +v 130.432785 -19.460638 -13.034077 +v 130.198257 -19.516922 -13.133978 +v 130.240265 -19.682322 -13.133978 +v 129.996872 -19.565254 -13.292896 +v 130.040207 -19.735926 -13.292896 +v 129.842346 -19.602343 -13.500003 +v 129.886703 -19.777056 -13.500003 +v 129.745193 -19.625658 -13.741184 +v 129.790207 -19.802912 -13.741184 +v 130.486115 -21.482962 -13.741185 +v 131.593124 -22.925652 -13.741185 +v 133.035812 -24.032667 -13.741185 +v 134.715866 -24.728565 -13.741185 +v 134.741714 -24.632069 -13.500004 +v 133.085770 -23.946150 -13.500004 +v 131.663773 -22.855011 -13.500004 +v 130.572632 -21.433012 -13.500004 +v 134.782852 -24.478565 -13.292897 +v 133.165222 -23.808523 -13.292897 +v 131.776138 -22.742640 -13.292897 +v 130.710251 -21.353552 -13.292897 +v 134.836456 -24.278517 -13.133979 +v 133.268784 -23.629164 -13.133979 +v 131.922577 -22.596191 -13.133979 +v 130.889618 -21.249998 -13.133979 +v 134.898880 -24.045553 -13.034078 +v 133.389374 -23.420294 -13.034078 +v 132.093124 -22.425652 -13.034078 +v 131.098480 -21.129408 -13.034078 +v 135.194519 -23.852036 -13.000004 +v 133.935577 -23.415445 -13.000004 +v 132.804031 -22.711761 -13.000004 +v 131.855713 -21.775688 -13.000004 +v 127.353020 -5.519270 -13.000001 +v 127.101349 -5.579672 -13.034075 +v 126.866829 -5.635957 -13.133976 +v 126.665443 -5.684289 -13.292894 +v 126.510910 -5.721376 -13.500001 +v 126.413765 -5.744690 -13.741182 +v 126.000000 5.916004 -12.999999 +v 125.741180 5.916004 -13.034073 +v 126.150955 2.072721 -13.000000 +v 126.129601 -0.257763 -13.034074 +v 126.602890 -1.746882 -13.000000 +v 125.500000 5.916004 -13.133974 +v 125.890320 -0.287991 -13.133975 +v 125.684853 -0.313948 -13.292893 +v 125.527184 -0.333866 -13.500000 +v 125.428070 -0.346387 -13.741181 +v 125.292892 5.916004 -13.292892 +v 125.133972 5.916004 -13.499999 +v 125.034073 5.916004 -13.741180 +v 126.000000 20.000002 -12.999997 +v 125.741180 20.000002 -13.034071 +v 125.500000 20.000002 -13.133972 +v 125.292892 20.000002 -13.292891 +v 125.133972 20.000002 -13.499997 +v 125.034073 20.000002 -13.741179 +v 125.000000 20.000002 -13.999997 +v 105.938271 109.965538 -30.999983 +v 103.842422 112.959908 -30.999981 +v 102.532974 116.372261 -30.999981 +v 102.087685 120.000008 -30.999981 +v 102.532974 123.627754 -30.999979 +v 103.842422 127.040108 -30.999979 +v 105.938271 130.034470 -30.999979 +v 160.000000 190.103058 -30.999969 +v 79.031021 109.965538 -30.999983 +v 76.935173 112.959908 -30.999981 +v 75.625732 116.372261 -30.999981 +v 75.180435 120.000008 -30.999981 +v 75.625732 123.627754 -30.999979 +v 76.935173 127.040108 -30.999979 +v 79.031021 130.034470 -30.999979 +v 180.000000 20.000006 -30.999996 +v 160.000000 20.000006 -30.999996 +v 160.000000 49.896946 -30.999992 +v 177.244446 211.941147 -4.999966 +v 177.500000 210.000000 -4.999966 +v 177.244446 208.058853 -4.999966 +v 176.495193 206.250000 -4.999967 +v 175.303299 204.696701 -4.999967 +v 173.750000 203.504807 -4.999967 +v 171.941147 202.755554 -4.999967 +v 170.000000 202.500000 -4.999967 +v 168.058853 202.755554 -4.999967 +v 166.250000 203.504807 -4.999967 +v 164.696701 204.696701 -4.999967 +v 163.504807 206.250000 -4.999967 +v 162.755554 208.058853 -4.999966 +v 162.500000 210.000000 -4.999966 +v 162.755554 211.941147 -4.999966 +v 163.504807 213.750000 -4.999965 +v 164.696701 215.303299 -4.999965 +v 166.250000 216.495193 -4.999965 +v 168.058853 217.244446 -4.999965 +v 170.000000 217.500000 -4.999965 +v 171.941147 217.244446 -4.999965 +v 173.750000 216.495193 -4.999965 +v 175.303299 215.303299 -4.999965 +v 176.495193 213.750000 -4.999965 +v 9.000000 260.000000 0.000042 +v 9.000000 260.000000 -4.999958 +v 6.670629 259.693329 0.000042 +v 6.670629 259.693329 -4.999958 +v 4.500000 258.794220 0.000042 +v 4.500000 258.794220 -4.999958 +v 2.636039 257.363953 0.000042 +v 2.636039 257.363953 -4.999958 +v 1.205771 255.500000 0.000042 +v 1.205771 255.500000 -4.999959 +v 0.306668 253.329376 0.000041 +v 0.306668 253.329376 -4.999959 +v 0.000000 251.000000 0.000041 +v 0.000000 251.000000 -4.999959 +v 0.000000 9.000001 -4.999999 +v 0.000000 9.000000 0.000001 +v 261.000000 0.000001 -5.000000 +v 270.000000 9.000001 -4.999999 +v 9.000000 0.000001 -5.000000 +v 270.000000 251.000000 -4.999959 +v 261.000000 260.000000 -4.999958 +v 263.329376 259.693329 -4.999958 +v 265.500000 258.794220 -4.999958 +v 267.363953 257.363953 -4.999958 +v 268.794220 255.500000 -4.999959 +v 269.693329 253.329376 -4.999959 +v 269.693329 6.670630 -4.999999 +v 268.794220 4.500001 -4.999999 +v 267.363953 2.636040 -5.000000 +v 265.500000 1.205772 -5.000000 +v 263.329376 0.306669 -5.000000 +v 6.670629 0.306669 -5.000000 +v 4.500000 1.205772 -5.000000 +v 2.636039 2.636040 -5.000000 +v 1.205771 4.500001 -4.999999 +v 0.306668 6.670630 -4.999999 +v 270.000000 251.000000 0.000041 +v 269.693329 253.329376 0.000041 +v 268.794220 255.500000 0.000042 +v 267.363953 257.363953 0.000042 +v 265.500000 258.794220 0.000042 +v 263.329376 259.693329 0.000042 +v 261.000000 260.000000 0.000042 +v 270.000000 9.000000 0.000001 +v 261.000000 0.000000 0.000000 +v 263.329376 0.306668 0.000000 +v 265.500000 1.205771 0.000000 +v 267.363953 2.636039 0.000000 +v 268.794220 4.500000 0.000001 +v 269.693329 6.670629 0.000001 +v 0.306668 6.670629 0.000001 +v 1.205771 4.500000 0.000001 +v 2.636039 2.636039 0.000000 +v 4.500000 1.205771 0.000000 +v 6.670629 0.306668 0.000000 +v 9.000000 0.000000 0.000000 +vt 0.217339 0.786783 +vt 0.192612 0.786783 +vt 0.217339 0.781403 +vt 0.192612 0.781403 +vt 0.217339 0.776024 +vt 0.192612 0.776024 +vt 0.217339 0.770644 +vt 0.192612 0.770644 +vt 0.217339 0.765265 +vt 0.192612 0.765265 +vt 0.217340 0.759886 +vt 0.192612 0.759886 +vt 0.217340 0.754507 +vt 0.192612 0.754507 +vt 0.217340 0.749127 +vt 0.192612 0.749127 +vt 0.217340 0.743748 +vt 0.192612 0.743748 +vt 0.217340 0.738368 +vt 0.192612 0.738368 +vt 0.217340 0.732989 +vt 0.192612 0.732989 +vt 0.217340 0.727610 +vt 0.192612 0.727610 +vt 0.217340 0.722231 +vt 0.192612 0.722231 +vt 0.217340 0.716851 +vt 0.192612 0.716851 +vt 0.217340 0.711472 +vt 0.192612 0.711472 +vt 0.217339 0.840576 +vt 0.192612 0.840576 +vt 0.217339 0.835197 +vt 0.192612 0.835197 +vt 0.217339 0.829817 +vt 0.192612 0.829817 +vt 0.217339 0.824438 +vt 0.192612 0.824438 +vt 0.217339 0.819059 +vt 0.192612 0.819059 +vt 0.217339 0.813680 +vt 0.192612 0.813680 +vt 0.217339 0.808300 +vt 0.192612 0.808300 +vt 0.217339 0.802921 +vt 0.192612 0.802921 +vt 0.217339 0.797541 +vt 0.192612 0.797541 +vt 0.217339 0.792162 +vt 0.192612 0.792162 +vt 0.217340 0.651321 +vt 0.192612 0.651321 +vt 0.217340 0.645942 +vt 0.192612 0.645942 +vt 0.217340 0.640562 +vt 0.192612 0.640562 +vt 0.217340 0.635183 +vt 0.192612 0.635183 +vt 0.217340 0.629804 +vt 0.192612 0.629804 +vt 0.217340 0.624424 +vt 0.192612 0.624424 +vt 0.217340 0.619045 +vt 0.192612 0.619045 +vt 0.217340 0.613666 +vt 0.192612 0.613666 +vt 0.217340 0.608286 +vt 0.192612 0.608286 +vt 0.217340 0.602907 +vt 0.192612 0.602907 +vt 0.217340 0.597528 +vt 0.192612 0.597528 +vt 0.217340 0.592148 +vt 0.192612 0.592148 +vt 0.217340 0.586769 +vt 0.192612 0.586769 +vt 0.217340 0.581390 +vt 0.192612 0.581390 +vt 0.217340 0.710494 +vt 0.192612 0.710494 +vt 0.217340 0.705115 +vt 0.192612 0.705115 +vt 0.217340 0.699735 +vt 0.192612 0.699735 +vt 0.217340 0.694356 +vt 0.192612 0.694356 +vt 0.217340 0.688976 +vt 0.192612 0.688976 +vt 0.217340 0.683597 +vt 0.192612 0.683597 +vt 0.217340 0.678218 +vt 0.192612 0.678218 +vt 0.217340 0.672839 +vt 0.192612 0.672839 +vt 0.217340 0.667459 +vt 0.192612 0.667459 +vt 0.217340 0.662080 +vt 0.192612 0.662080 +vt 0.217340 0.656700 +vt 0.192612 0.656700 +vt 0.310442 0.065987 +vt 0.310752 0.060445 +vt 0.314203 0.028452 +vt 0.310267 0.054869 +vt 0.308997 0.049687 +vt 0.306986 0.045373 +vt 0.304377 0.042377 +vt 0.301405 0.040977 +vt 0.298319 0.041245 +vt 0.282384 0.038962 +vt 0.295107 0.042958 +vt 0.292223 0.046072 +vt 0.289826 0.050257 +vt 0.287988 0.055189 +vt 0.286762 0.060657 +vt 0.286225 0.066429 +vt 0.282821 0.121322 +vt 0.211025 0.281065 +vt 0.286525 0.072171 +vt 0.287521 0.077556 +vt 0.289128 0.082241 +vt 0.291223 0.085947 +vt 0.293653 0.088477 +vt 0.296259 0.089725 +vt 0.298869 0.089639 +vt 0.301334 0.088262 +vt 0.283108 0.493049 +vt 0.301326 0.527104 +vt 0.298968 0.525452 +vt 0.304768 0.084951 +vt 0.304459 0.530106 +vt 0.307736 0.078943 +vt 0.307427 0.535493 +vt 0.310186 0.544468 +vt 0.314203 0.580411 +vt 0.199690 0.312837 +vt 0.200026 0.307700 +vt 0.205875 0.307678 +vt 0.199681 0.302565 +vt 0.206469 0.298068 +vt 0.198674 0.297786 +vt 0.208221 0.289015 +vt 0.197076 0.293693 +vt 0.194997 0.290567 +vt 0.192577 0.288625 +vt 0.189985 0.288003 +vt 0.175077 0.281428 +vt 0.187392 0.288684 +vt 0.184981 0.290679 +vt 0.182916 0.293849 +vt 0.181340 0.297968 +vt 0.180356 0.302748 +vt 0.172359 0.289391 +vt 0.180025 0.307871 +vt 0.170675 0.298405 +vt 0.170119 0.307958 +vt 0.170720 0.317498 +vt 0.172445 0.326473 +vt 0.180374 0.312988 +vt 0.175198 0.334374 +vt 0.181375 0.317749 +vt 0.182963 0.321839 +vt 0.185035 0.324973 +vt 0.187447 0.326929 +vt 0.190035 0.327570 +vt 0.192625 0.326897 +vt 0.211020 0.334174 +vt 0.283180 0.574246 +vt 0.195037 0.324911 +vt 0.197106 0.321751 +vt 0.208223 0.326280 +vt 0.198694 0.317630 +vt 0.206471 0.317271 +vt 0.286760 0.547097 +vt 0.287296 0.552594 +vt 0.298640 0.570034 +vt 0.301646 0.569879 +vt 0.304497 0.568138 +vt 0.310028 0.555298 +vt 0.310454 0.549755 +vt 0.296438 0.525141 +vt 0.293901 0.526169 +vt 0.291534 0.528492 +vt 0.289505 0.531992 +vt 0.287961 0.536460 +vt 0.287021 0.541609 +vt 0.288510 0.557767 +vt 0.290333 0.562366 +vt 0.292706 0.566149 +vt 0.295534 0.568798 +vt 0.306967 0.564918 +vt 0.308852 0.560501 +vt 0.175292 0.043640 +vt 0.173407 0.040307 +vt 0.174440 0.044040 +vt 0.172989 0.039817 +vt 0.173592 0.044475 +vt 0.172549 0.039414 +vt 0.172747 0.044945 +vt 0.172088 0.039101 +vt 0.171907 0.045452 +vt 0.171070 0.045990 +vt 0.171605 0.038886 +vt 0.170238 0.046564 +vt 0.171102 0.038770 +vt 0.169412 0.047170 +vt 0.170581 0.038762 +vt 0.168592 0.047806 +vt 0.174435 0.038840 +vt 0.174284 0.038936 +vt 0.173976 0.039115 +vt 0.173907 0.039122 +vt 0.173613 0.039341 +vt 0.173565 0.039339 +vt 0.173298 0.039531 +vt 0.173249 0.039525 +vt 0.173104 0.039767 +vt 0.173013 0.039739 +vt 0.173059 0.039970 +vt 0.172846 0.039862 +vt 0.172630 0.039755 +vt 0.172411 0.039653 +vt 0.172189 0.039558 +vt 0.171967 0.039472 +vt 0.171746 0.039400 +vt 0.174133 0.039001 +vt 0.173837 0.039129 +vt 0.173518 0.039339 +vt 0.173199 0.039520 +vt 0.172921 0.039713 +vt 0.173983 0.039034 +vt 0.173767 0.039136 +vt 0.173470 0.039339 +vt 0.173149 0.039516 +vt 0.172829 0.039689 +vt 0.173833 0.039037 +vt 0.173697 0.039143 +vt 0.173423 0.039341 +vt 0.173098 0.039513 +vt 0.172738 0.039669 +vt 0.173686 0.039008 +vt 0.173630 0.039150 +vt 0.173377 0.039345 +vt 0.173048 0.039511 +vt 0.172649 0.039655 +vt 0.173542 0.038946 +vt 0.173566 0.039157 +vt 0.173332 0.039351 +vt 0.172999 0.039513 +vt 0.172563 0.039649 +vt 0.175083 0.038334 +vt 0.175113 0.038199 +vt 0.175134 0.038064 +vt 0.175147 0.037928 +vt 0.175152 0.037794 +vt 0.175149 0.037662 +vt 0.175137 0.037533 +vt 0.175904 0.036019 +vt 0.175867 0.036155 +vt 0.175858 0.036378 +vt 0.175829 0.036442 +vt 0.175764 0.036670 +vt 0.175751 0.036728 +vt 0.175603 0.036913 +vt 0.175599 0.036954 +vt 0.175457 0.037347 +vt 0.175454 0.037344 +vt 0.175282 0.037800 +vt 0.175284 0.037754 +vt 0.175285 0.037708 +vt 0.175287 0.037663 +vt 0.175288 0.037619 +vt 0.175290 0.037577 +vt 0.175291 0.037537 +vt 0.175825 0.036280 +vt 0.175801 0.036506 +vt 0.175739 0.036788 +vt 0.175594 0.036993 +vt 0.175450 0.037342 +vt 0.175776 0.036391 +vt 0.175773 0.036570 +vt 0.175728 0.036848 +vt 0.175590 0.037031 +vt 0.175447 0.037339 +vt 0.175721 0.036490 +vt 0.175746 0.036633 +vt 0.175719 0.036910 +vt 0.175585 0.037069 +vt 0.175444 0.037337 +vt 0.175662 0.036573 +vt 0.175720 0.036694 +vt 0.175710 0.036975 +vt 0.175581 0.037105 +vt 0.175442 0.037336 +vt 0.175597 0.036640 +vt 0.175695 0.036754 +vt 0.175704 0.037042 +vt 0.175578 0.037142 +vt 0.175441 0.037335 +vt 0.175631 0.052174 +vt 0.175940 0.052558 +vt 0.176265 0.052875 +vt 0.176603 0.053124 +vt 0.176954 0.053302 +vt 0.177316 0.053406 +vt 0.177687 0.053433 +vt 0.176337 0.053465 +vt 0.176392 0.053463 +vt 0.176420 0.053309 +vt 0.176443 0.053323 +vt 0.176451 0.053149 +vt 0.176470 0.053157 +vt 0.176442 0.052986 +vt 0.176470 0.052993 +vt 0.176363 0.052804 +vt 0.176423 0.052820 +vt 0.176154 0.052586 +vt 0.176303 0.052664 +vt 0.176455 0.052742 +vt 0.176609 0.052818 +vt 0.176765 0.052890 +vt 0.176921 0.052956 +vt 0.177076 0.053015 +vt 0.176446 0.053474 +vt 0.176467 0.053337 +vt 0.176489 0.053166 +vt 0.176499 0.053001 +vt 0.176485 0.052836 +vt 0.176499 0.053496 +vt 0.176490 0.053352 +vt 0.176508 0.053175 +vt 0.176528 0.053008 +vt 0.176546 0.052851 +vt 0.176551 0.053531 +vt 0.176513 0.053367 +vt 0.176527 0.053183 +vt 0.176556 0.053016 +vt 0.176607 0.052864 +vt 0.176601 0.053577 +vt 0.176535 0.053383 +vt 0.176546 0.053191 +vt 0.176585 0.053023 +vt 0.176667 0.052875 +vt 0.176649 0.053637 +vt 0.176556 0.053398 +vt 0.176563 0.053199 +vt 0.176613 0.053029 +vt 0.176724 0.052881 +vt 0.176480 0.055043 +vt 0.176461 0.055290 +vt 0.176455 0.055536 +vt 0.176459 0.055777 +vt 0.176475 0.056014 +vt 0.176504 0.056245 +vt 0.176544 0.056467 +vt 0.177046 0.057279 +vt 0.177028 0.058060 +vt 0.176798 0.057464 +vt 0.176752 0.057846 +vt 0.176636 0.057165 +vt 0.176605 0.057352 +vt 0.176539 0.056739 +vt 0.176529 0.056854 +vt 0.176510 0.056251 +vt 0.176506 0.056348 +vt 0.176493 0.055716 +vt 0.176494 0.055837 +vt 0.176495 0.055959 +vt 0.176497 0.056081 +vt 0.176498 0.056201 +vt 0.176500 0.056319 +vt 0.176502 0.056432 +vt 0.176984 0.058837 +vt 0.176704 0.058231 +vt 0.176573 0.057539 +vt 0.176518 0.056970 +vt 0.176503 0.056445 +vt 0.176915 0.059612 +vt 0.176655 0.058618 +vt 0.176539 0.057725 +vt 0.176506 0.057085 +vt 0.176499 0.056541 +vt 0.176820 0.060384 +vt 0.176603 0.059007 +vt 0.176504 0.057910 +vt 0.176494 0.057200 +vt 0.176494 0.056637 +vt 0.176699 0.061154 +vt 0.176547 0.059393 +vt 0.176467 0.058090 +vt 0.176480 0.057313 +vt 0.176489 0.056732 +vt 0.176550 0.061923 +vt 0.176489 0.059775 +vt 0.176427 0.058263 +vt 0.176466 0.057423 +vt 0.176482 0.056825 +vt 0.178681 0.057080 +vt 0.178334 0.058660 +vt 0.177925 0.060177 +vt 0.177454 0.061623 +vt 0.176925 0.062989 +vt 0.176339 0.064268 +vt 0.175698 0.065452 +vt 0.177137 0.049396 +vt 0.176974 0.050249 +vt 0.176848 0.050984 +vt 0.176755 0.051615 +vt 0.176858 0.052209 +vt 0.176809 0.052743 +vt 0.176795 0.053207 +vt 0.176816 0.053614 +vt 0.176638 0.053012 +vt 0.176651 0.052923 +vt 0.176668 0.052824 +vt 0.176686 0.052712 +vt 0.176730 0.052612 +vt 0.176752 0.052462 +vt 0.176776 0.052299 +vt 0.176806 0.052126 +vt 0.178518 0.034772 +vt 0.178089 0.034727 +vt 0.177712 0.034685 +vt 0.177389 0.034659 +vt 0.176633 0.035719 +vt 0.175970 0.036494 +vt 0.175390 0.037434 +vt 0.174886 0.038527 +vt 0.175195 0.038182 +vt 0.174817 0.038616 +vt 0.174498 0.039157 +vt 0.174239 0.039800 +vt 0.173892 0.040663 +vt 0.173719 0.041452 +vt 0.173607 0.042355 +vt 0.173563 0.043373 +vt 0.178028 0.051056 +vt 0.175766 0.044710 +vt 0.269988 0.972405 +vt 0.298859 0.972541 +vt 0.270011 0.973256 +vt 0.298824 0.973474 +vt 0.270023 0.974113 +vt 0.298806 0.974415 +vt 0.270022 0.974977 +vt 0.298803 0.975364 +vt 0.270008 0.975847 +vt 0.298818 0.976322 +vt 0.269976 0.976724 +vt 0.298845 0.977288 +vt 0.269933 0.977610 +vt 0.298893 0.978260 +vt 0.254640 0.950521 +vt 0.255470 0.950291 +vt 0.257205 0.959361 +vt 0.257602 0.958884 +vt 0.259129 0.964581 +vt 0.259407 0.964150 +vt 0.261118 0.969246 +vt 0.261357 0.968753 +vt 0.263611 0.973131 +vt 0.263797 0.972535 +vt 0.266526 0.975864 +vt 0.266652 0.975164 +vt 0.266775 0.974473 +vt 0.266892 0.973783 +vt 0.267004 0.973095 +vt 0.267114 0.972409 +vt 0.267222 0.971724 +vt 0.256292 0.950169 +vt 0.258007 0.958445 +vt 0.259687 0.963728 +vt 0.261594 0.968267 +vt 0.263982 0.971949 +vt 0.257100 0.950145 +vt 0.258415 0.958026 +vt 0.259966 0.963301 +vt 0.261826 0.967778 +vt 0.264160 0.971363 +vt 0.257896 0.950207 +vt 0.258827 0.957628 +vt 0.260245 0.962876 +vt 0.262055 0.967291 +vt 0.264336 0.970781 +vt 0.258680 0.950372 +vt 0.259241 0.957250 +vt 0.260525 0.962452 +vt 0.262284 0.966805 +vt 0.264511 0.970200 +vt 0.259450 0.950643 +vt 0.259654 0.956888 +vt 0.260806 0.962030 +vt 0.262511 0.966319 +vt 0.264685 0.969620 +vt 0.254640 0.815252 +vt 0.255472 0.815496 +vt 0.256291 0.815635 +vt 0.257098 0.815668 +vt 0.257891 0.815585 +vt 0.258671 0.815399 +vt 0.259436 0.815110 +vt 0.269912 0.788684 +vt 0.269955 0.789560 +vt 0.266555 0.790389 +vt 0.266671 0.791083 +vt 0.263659 0.793034 +vt 0.263835 0.793633 +vt 0.261171 0.796827 +vt 0.261400 0.797329 +vt 0.259179 0.801394 +vt 0.259447 0.801835 +vt 0.257242 0.806497 +vt 0.257630 0.806993 +vt 0.258024 0.807461 +vt 0.258427 0.807890 +vt 0.258833 0.808292 +vt 0.259242 0.808673 +vt 0.259649 0.809037 +vt 0.269981 0.790434 +vt 0.266781 0.791775 +vt 0.264005 0.794228 +vt 0.261624 0.797829 +vt 0.259715 0.802277 +vt 0.269997 0.791302 +vt 0.266892 0.792461 +vt 0.264177 0.794813 +vt 0.261849 0.798320 +vt 0.259987 0.802711 +vt 0.270000 0.792165 +vt 0.267001 0.793143 +vt 0.264349 0.795393 +vt 0.262073 0.798808 +vt 0.260261 0.803139 +vt 0.269989 0.793022 +vt 0.267109 0.793824 +vt 0.264519 0.795971 +vt 0.262296 0.799294 +vt 0.260536 0.803565 +vt 0.269966 0.793873 +vt 0.267215 0.794504 +vt 0.264689 0.796547 +vt 0.262519 0.799779 +vt 0.260812 0.803989 +vt 0.298872 0.788102 +vt 0.298824 0.789084 +vt 0.298791 0.790053 +vt 0.298778 0.791014 +vt 0.298782 0.791964 +vt 0.298802 0.792906 +vt 0.298837 0.793838 +vt 0.314203 0.815751 +vt 0.313372 0.815981 +vt 0.311639 0.806911 +vt 0.311243 0.807387 +vt 0.309719 0.801694 +vt 0.309442 0.802122 +vt 0.307738 0.797035 +vt 0.307499 0.797525 +vt 0.305272 0.793144 +vt 0.305082 0.793732 +vt 0.302398 0.790274 +vt 0.302258 0.790984 +vt 0.302120 0.791688 +vt 0.301992 0.792393 +vt 0.301868 0.793097 +vt 0.301748 0.793800 +vt 0.301630 0.794502 +vt 0.312550 0.816103 +vt 0.310838 0.807826 +vt 0.309162 0.802544 +vt 0.307262 0.798007 +vt 0.304893 0.794313 +vt 0.311741 0.816127 +vt 0.310429 0.808245 +vt 0.308883 0.802969 +vt 0.307030 0.798492 +vt 0.304710 0.794894 +vt 0.310944 0.816064 +vt 0.310017 0.808641 +vt 0.308604 0.803392 +vt 0.306800 0.798975 +vt 0.304530 0.795473 +vt 0.310160 0.815898 +vt 0.309604 0.809018 +vt 0.308324 0.803815 +vt 0.306571 0.799459 +vt 0.304351 0.796050 +vt 0.309390 0.815627 +vt 0.309190 0.809379 +vt 0.308043 0.804236 +vt 0.306343 0.799941 +vt 0.304173 0.796626 +vt 0.314203 0.951134 +vt 0.313370 0.950890 +vt 0.312551 0.950751 +vt 0.311743 0.950718 +vt 0.310949 0.950802 +vt 0.310169 0.950989 +vt 0.309404 0.951279 +vt 0.309195 0.957353 +vt 0.309603 0.957717 +vt 0.308037 0.962403 +vt 0.308313 0.962825 +vt 0.306334 0.966618 +vt 0.306558 0.967101 +vt 0.304169 0.969863 +vt 0.304343 0.970437 +vt 0.301637 0.971928 +vt 0.301753 0.972625 +vt 0.301871 0.973322 +vt 0.301992 0.974020 +vt 0.302115 0.974720 +vt 0.302239 0.975425 +vt 0.302370 0.976130 +vt 0.310011 0.958096 +vt 0.308588 0.963250 +vt 0.306782 0.967583 +vt 0.304518 0.971011 +vt 0.310417 0.958498 +vt 0.308861 0.963677 +vt 0.307006 0.968067 +vt 0.304693 0.971587 +vt 0.310820 0.958926 +vt 0.309133 0.964109 +vt 0.307232 0.968555 +vt 0.304869 0.972167 +vt 0.311214 0.959393 +vt 0.309401 0.964549 +vt 0.307456 0.969051 +vt 0.305044 0.972756 +vt 0.311602 0.959889 +vt 0.309669 0.964989 +vt 0.307686 0.969549 +vt 0.305224 0.973349 +vt 0.026456 0.339805 +vt 0.027842 0.341046 +vt 0.026381 0.339822 +vt 0.027777 0.340745 +vt 0.026276 0.339803 +vt 0.027703 0.340452 +vt 0.026173 0.339775 +vt 0.027617 0.340174 +vt 0.026072 0.339739 +vt 0.027517 0.339913 +vt 0.025973 0.339695 +vt 0.027396 0.339667 +vt 0.025902 0.339704 +vt 0.027214 0.339403 +vt 0.024967 0.340828 +vt 0.024501 0.341159 +vt 0.024829 0.341238 +vt 0.024644 0.341353 +vt 0.024768 0.341586 +vt 0.024889 0.341446 +vt 0.024873 0.341854 +vt 0.024962 0.341638 +vt 0.024955 0.342154 +vt 0.025046 0.341808 +vt 0.025014 0.342482 +vt 0.025139 0.341953 +vt 0.025047 0.342836 +vt 0.025414 0.341689 +vt 0.025934 0.340752 +vt 0.025432 0.340238 +vt 0.022668 0.342089 +vt 0.022581 0.342214 +vt 0.022519 0.342357 +vt 0.022479 0.342510 +vt 0.022460 0.342668 +vt 0.022464 0.342826 +vt 0.022494 0.342975 +vt 0.020877 0.345119 +vt 0.020900 0.344600 +vt 0.021248 0.344299 +vt 0.021322 0.343997 +vt 0.021564 0.343801 +vt 0.021645 0.343593 +vt 0.021853 0.343426 +vt 0.021948 0.343251 +vt 0.022130 0.343071 +vt 0.022244 0.342888 +vt 0.022404 0.342665 +vt 0.022550 0.342469 +vt 0.022529 0.342545 +vt 0.022507 0.342630 +vt 0.022486 0.342716 +vt 0.022466 0.342795 +vt 0.022335 0.342836 +vt 0.022219 0.342887 +vt 0.022111 0.342918 +vt 0.021962 0.343035 +vt 0.021892 0.342996 +vt 0.021716 0.343108 +vt 0.021689 0.342971 +vt 0.021491 0.342993 +vt 0.021517 0.342715 +vt 0.021274 0.342754 +vt 0.021432 0.342378 +vt 0.020955 0.344098 +vt 0.021362 0.343750 +vt 0.021663 0.343474 +vt 0.021952 0.343199 +vt 0.022239 0.342890 +vt 0.021036 0.343621 +vt 0.021404 0.343500 +vt 0.021681 0.343353 +vt 0.021956 0.343145 +vt 0.022232 0.342891 +vt 0.021142 0.343172 +vt 0.021447 0.343247 +vt 0.021699 0.343230 +vt 0.021959 0.343091 +vt 0.022226 0.342890 +vt 0.018793 0.331271 +vt 0.018547 0.331242 +vt 0.018298 0.331288 +vt 0.018052 0.331399 +vt 0.017813 0.331576 +vt 0.017581 0.331821 +vt 0.017364 0.332144 +vt 0.018614 0.330744 +vt 0.018790 0.330532 +vt 0.018694 0.330613 +vt 0.018722 0.330665 +vt 0.018645 0.330753 +vt 0.018624 0.330677 +vt 0.018563 0.330812 +vt 0.018550 0.330730 +vt 0.018479 0.330844 +vt 0.018474 0.330767 +vt 0.018394 0.330846 +vt 0.018398 0.330788 +vt 0.018310 0.330799 +vt 0.018404 0.330909 +vt 0.018427 0.330938 +vt 0.018378 0.331106 +vt 0.018362 0.331179 +vt 0.018296 0.331329 +vt 0.018243 0.331440 +vt 0.018154 0.331576 +vt 0.018046 0.331723 +vt 0.017913 0.331861 +vt 0.018136 0.331631 +vt 0.018282 0.331395 +vt 0.018384 0.331149 +vt 0.018457 0.330913 +vt 0.018230 0.331548 +vt 0.018323 0.331351 +vt 0.018408 0.331119 +vt 0.018488 0.330886 +vt 0.018325 0.331474 +vt 0.018363 0.331309 +vt 0.018432 0.331091 +vt 0.018520 0.330859 +vt 0.018420 0.331406 +vt 0.018404 0.331269 +vt 0.018456 0.331063 +vt 0.018552 0.330831 +vt 0.018528 0.331344 +vt 0.018444 0.331277 +vt 0.018449 0.331131 +vt 0.018508 0.330946 +vt 0.019349 0.329300 +vt 0.019279 0.329219 +vt 0.019169 0.329097 +vt 0.019058 0.328975 +vt 0.018946 0.328853 +vt 0.018835 0.328733 +vt 0.018744 0.328639 +vt 0.019877 0.327636 +vt 0.019800 0.327481 +vt 0.019729 0.328168 +vt 0.019544 0.328733 +vt 0.019685 0.327282 +vt 0.019570 0.327082 +vt 0.019456 0.326881 +vt 0.019342 0.326678 +vt 0.018946 0.327978 +vt 0.019247 0.326500 +vt 0.019117 0.327272 +vt 0.020360 0.327136 +vt 0.020313 0.326856 +vt 0.020274 0.326611 +vt 0.020226 0.326377 +vt 0.020167 0.326155 +vt 0.020099 0.325943 +vt 0.020026 0.325739 +vt 0.019841 0.326729 +vt 0.019535 0.327301 +vt 0.019244 0.327915 +vt 0.018987 0.328525 +vt 0.018600 0.329583 +vt 0.018641 0.329712 +vt 0.018590 0.330012 +vt 0.018479 0.330350 +vt 0.018324 0.330703 +vt 0.018108 0.331062 +vt 0.017781 0.331427 +vt 0.017233 0.331831 +vt 0.018053 0.348729 +vt 0.019271 0.347684 +vt 0.020227 0.346853 +vt 0.021030 0.346108 +vt 0.021724 0.345403 +vt 0.022325 0.344756 +vt 0.022785 0.344333 +vt 0.022929 0.344353 +vt 0.024541 0.343541 +vt 0.025036 0.342868 +vt 0.025544 0.342145 +vt 0.025986 0.341420 +vt 0.027103 0.340513 +vt 0.025977 0.341144 +vt 0.026926 0.340129 +vt 0.025933 0.340772 +vt 0.026713 0.339902 +vt 0.025893 0.340397 +vt 0.026483 0.339742 +vt 0.025858 0.340019 +vt 0.026237 0.339638 +vt 0.025828 0.339637 +vt 0.025954 0.339602 +vt 0.025828 0.339329 +vt 0.025532 0.339720 +vt 0.023548 0.342878 +vt 0.023697 0.342927 +vt 0.024327 0.341707 +vt 0.025067 0.340526 +vt 0.023880 0.343052 +vt 0.024060 0.343186 +vt 0.024238 0.343329 +vt 0.024414 0.343479 +vt 0.023690 0.342360 +vt 0.023349 0.343003 +vt 0.023139 0.343311 +vt 0.022995 0.343573 +vt 0.022905 0.343831 +vt 0.022872 0.344088 +vt 0.019712 0.347915 +vt 0.019510 0.348188 +vt 0.020179 0.347775 +vt 0.020106 0.347687 +vt 0.020805 0.347336 +vt 0.020863 0.347091 +vt 0.021481 0.346679 +vt 0.021631 0.346293 +vt 0.022154 0.345819 +vt 0.022364 0.345285 +vt 0.022799 0.344719 +vt 0.023069 0.343984 +vt 0.023373 0.343311 +vt 0.023280 0.343161 +vt 0.022988 0.344032 +vt 0.022903 0.344121 +vt 0.022817 0.344227 +vt 0.022728 0.344331 +vt 0.022825 0.344084 +vt 0.022071 0.345151 +vt 0.021342 0.345958 +vt 0.020501 0.346768 +vt 0.019523 0.347616 +vt 0.018388 0.348761 +vt 0.019269 0.348417 +vt 0.019959 0.347666 +vt 0.020768 0.346998 +vt 0.021554 0.346197 +vt 0.022287 0.345237 +vt 0.019000 0.348591 +vt 0.019813 0.347649 +vt 0.020677 0.346916 +vt 0.021481 0.346111 +vt 0.022212 0.345202 +vt 0.018706 0.348707 +vt 0.019667 0.347633 +vt 0.020587 0.346840 +vt 0.021410 0.346032 +vt 0.022141 0.345174 +vt 0.018801 0.331201 +vt 0.018534 0.331105 +vt 0.018263 0.331099 +vt 0.017994 0.331170 +vt 0.017731 0.331315 +vt 0.017473 0.331533 +vt 0.018721 0.329759 +vt 0.018949 0.329339 +vt 0.018982 0.329456 +vt 0.019083 0.329409 +vt 0.019003 0.329585 +vt 0.018945 0.329537 +vt 0.018901 0.329679 +vt 0.018872 0.329602 +vt 0.018801 0.329722 +vt 0.018789 0.329646 +vt 0.018709 0.329723 +vt 0.018711 0.329681 +vt 0.018577 0.330074 +vt 0.018426 0.330484 +vt 0.018229 0.330898 +vt 0.017948 0.331302 +vt 0.018057 0.331239 +vt 0.018273 0.330865 +vt 0.018446 0.330455 +vt 0.018600 0.330038 +vt 0.018168 0.331181 +vt 0.018317 0.330831 +vt 0.018465 0.330426 +vt 0.018621 0.330001 +vt 0.018280 0.331129 +vt 0.018361 0.330797 +vt 0.018485 0.330395 +vt 0.018640 0.329962 +vt 0.018390 0.331083 +vt 0.018404 0.330764 +vt 0.018502 0.330364 +vt 0.018655 0.329920 +vt 0.018509 0.331076 +vt 0.018434 0.330838 +vt 0.018468 0.330522 +vt 0.018565 0.330157 +vt 0.019454 0.329185 +vt 0.018934 0.329352 +vt 0.019158 0.329384 +vt 0.018954 0.329220 +vt 0.019017 0.329548 +vt 0.018966 0.329075 +vt 0.018905 0.329647 +vt 0.018974 0.328932 +vt 0.018978 0.328789 +vt 0.018980 0.328649 +vt 0.020504 0.326826 +vt 0.020399 0.326838 +vt 0.019937 0.327626 +vt 0.019577 0.328038 +vt 0.019416 0.328433 +vt 0.020282 0.326826 +vt 0.019529 0.327961 +vt 0.019482 0.327884 +vt 0.019435 0.327805 +vt 0.019388 0.327725 +vt 0.020166 0.326807 +vt 0.020052 0.326781 +vt 0.019938 0.326749 +vt 0.020175 0.327047 +vt 0.020344 0.326695 +vt 0.020421 0.326544 +vt 0.020474 0.326435 +vt 0.020516 0.326332 +vt 0.020542 0.326233 +vt 0.020512 0.326171 +vt 0.217584 0.631713 +vt 0.233678 0.610235 +vt 0.269432 0.787124 +vt 0.287745 0.762686 +vt 0.238185 0.604720 +vt 0.290653 0.758580 +vt 0.242894 0.599928 +vt 0.293448 0.754172 +vt 0.296122 0.749474 +vt 0.247777 0.595889 +vt 0.311618 0.720772 +vt 0.312874 0.699527 +vt 0.312874 0.717826 +vt 0.313752 0.714376 +vt 0.314203 0.710612 +vt 0.314203 0.706741 +vt 0.313752 0.702977 +vt 0.275884 0.588655 +vt 0.266180 0.582335 +vt 0.267957 0.581459 +vt 0.269787 0.581390 +vt 0.271580 0.582129 +vt 0.273247 0.583642 +vt 0.274706 0.585854 +vt 0.041866 0.115036 +vt 0.043020 0.107791 +vt 0.048589 0.118325 +vt 0.049198 0.113228 +vt 0.044947 0.100979 +vt 0.050386 0.108695 +vt 0.052107 0.104882 +vt 0.047620 0.095047 +vt 0.054286 0.101950 +vt 0.050801 0.090152 +vt 0.054425 0.086726 +vt 0.056858 0.100058 +vt 0.058253 0.084831 +vt 0.059760 0.099306 +vt 0.142316 0.083872 +vt 0.139419 0.087089 +vt 0.014170 0.072258 +vt 0.015397 0.061413 +vt 0.022599 0.075285 +vt 0.023867 0.068264 +vt 0.017916 0.051130 +vt 0.025787 0.061644 +vt 0.028322 0.055753 +vt 0.021563 0.042091 +vt 0.031321 0.050777 +vt 0.026110 0.034835 +vt 0.031286 0.029810 +vt 0.034667 0.046999 +vt 0.036764 0.027343 +vt 0.038213 0.044551 +vt 0.029719 0.340077 +vt 0.022548 0.325778 +vt 0.040190 0.278081 +vt 0.166696 0.043968 +vt 0.166717 0.044600 +vt 0.166791 0.045205 +vt 0.166742 0.028933 +vt 0.166915 0.045771 +vt 0.167083 0.046291 +vt 0.167304 0.046774 +vt 0.167543 0.047172 +vt 0.167733 0.047462 +vt 0.041838 0.272141 +vt 0.014170 0.325778 +vt 0.180231 0.078961 +vt 0.035174 0.351683 +vt 0.175458 0.068806 +vt 0.175307 0.068385 +vt 0.175216 0.067903 +vt 0.175190 0.067386 +vt 0.175234 0.066840 +vt 0.175363 0.066272 +vt 0.020174 0.326147 +vt 0.020312 0.326838 +vt 0.020309 0.327038 +vt 0.022740 0.330459 +vt 0.020666 0.326018 +vt 0.020562 0.326316 +vt 0.020572 0.326213 +vt 0.020393 0.326598 +vt 0.026039 0.339139 +vt 0.027196 0.340158 +vt 0.027006 0.339657 +vt 0.027377 0.339494 +vt 0.026023 0.339515 +vt 0.026255 0.339595 +vt 0.026947 0.340044 +vt 0.027712 0.340443 +vt 0.198512 0.891009 +vt 0.198512 0.841554 +vt 0.201201 0.891009 +vt 0.201201 0.841554 +vt 0.203891 0.891009 +vt 0.203891 0.841554 +vt 0.206581 0.891009 +vt 0.206581 0.841554 +vt 0.209270 0.891009 +vt 0.209270 0.841554 +vt 0.211960 0.891009 +vt 0.211960 0.841554 +vt 0.214650 0.891009 +vt 0.214650 0.841554 +vt 0.217339 0.891009 +vt 0.217339 0.841554 +vt 0.254395 0.825757 +vt 0.229668 0.825757 +vt 0.254395 0.820378 +vt 0.229668 0.820378 +vt 0.254395 0.814999 +vt 0.229668 0.814999 +vt 0.254395 0.809619 +vt 0.229668 0.809619 +vt 0.254395 0.804240 +vt 0.229668 0.804240 +vt 0.254395 0.798861 +vt 0.229668 0.798861 +vt 0.254395 0.793481 +vt 0.229668 0.793481 +vt 0.254395 0.788102 +vt 0.229668 0.788102 +vt 0.171615 0.891009 +vt 0.171615 0.841554 +vt 0.174305 0.891009 +vt 0.174305 0.841554 +vt 0.176994 0.891009 +vt 0.176994 0.841554 +vt 0.179684 0.891009 +vt 0.179684 0.841554 +vt 0.182374 0.891009 +vt 0.182374 0.841554 +vt 0.185063 0.891009 +vt 0.185063 0.841554 +vt 0.187753 0.891009 +vt 0.187753 0.841554 +vt 0.190443 0.891009 +vt 0.190443 0.841554 +vt 0.193132 0.891009 +vt 0.193132 0.841554 +vt 0.195822 0.891009 +vt 0.195822 0.841554 +vt 0.496770 0.360900 +vt 0.494253 0.362711 +vt 0.496344 0.358534 +vt 0.494119 0.360392 +vt 0.495791 0.356126 +vt 0.493746 0.358519 +vt 0.495034 0.353878 +vt 0.493181 0.356986 +vt 0.494068 0.351903 +vt 0.492436 0.355749 +vt 0.492936 0.350248 +vt 0.491505 0.354803 +vt 0.491699 0.348859 +vt 0.490343 0.354114 +vt 0.375646 0.344190 +vt 0.490445 0.349374 +vt 0.375749 0.339450 +vt 0.377394 0.515644 +vt 0.378611 0.516112 +vt 0.357290 0.610646 +vt 0.379850 0.515952 +vt 0.362229 0.621886 +vt 0.381074 0.515145 +vt 0.382333 0.513486 +vt 0.470186 0.519810 +vt 0.383373 0.510938 +vt 0.469267 0.517200 +vt 0.383999 0.508203 +vt 0.468755 0.514421 +vt 0.428861 0.433622 +vt 0.468634 0.511614 +vt 0.430084 0.433434 +vt 0.468845 0.509246 +vt 0.431119 0.432698 +vt 0.469312 0.507246 +vt 0.431993 0.431501 +vt 0.470014 0.505563 +vt 0.432685 0.429903 +vt 0.470902 0.504307 +vt 0.433148 0.428013 +vt 0.471946 0.503517 +vt 0.433393 0.425798 +vt 0.473166 0.503208 +vt 0.433377 0.423282 +vt 0.384242 0.505321 +vt 0.427645 0.433165 +vt 0.384149 0.502829 +vt 0.426624 0.432259 +vt 0.383772 0.500666 +vt 0.425784 0.430905 +vt 0.383135 0.498782 +vt 0.425152 0.429177 +vt 0.382286 0.497301 +vt 0.424768 0.427198 +vt 0.381251 0.496267 +vt 0.424626 0.424929 +vt 0.380008 0.495691 +vt 0.371466 0.353005 +vt 0.378721 0.495724 +vt 0.377482 0.496417 +vt 0.376315 0.497744 +vt 0.375263 0.499714 +vt 0.374417 0.502331 +vt 0.373971 0.505379 +vt 0.374173 0.508446 +vt 0.374672 0.510996 +vt 0.375354 0.512991 +vt 0.376283 0.514578 +vt 0.433029 0.421049 +vt 0.432413 0.419104 +vt 0.431553 0.417492 +vt 0.430464 0.416272 +vt 0.429173 0.415545 +vt 0.427873 0.416118 +vt 0.426767 0.417159 +vt 0.425870 0.418598 +vt 0.425193 0.420395 +vt 0.424763 0.422519 +vt 0.472673 0.522685 +vt 0.473838 0.522978 +vt 0.494816 0.622129 +vt 0.474966 0.522686 +vt 0.499793 0.612545 +vt 0.476030 0.521845 +vt 0.476954 0.520495 +vt 0.477672 0.518727 +vt 0.478198 0.516501 +vt 0.478495 0.513775 +vt 0.478431 0.510693 +vt 0.477687 0.507988 +vt 0.476741 0.505878 +vt 0.475653 0.504364 +vt 0.474454 0.503460 +vt 0.471429 0.521684 +vt 0.499531 0.615319 +vt 0.498997 0.617527 +vt 0.498251 0.619267 +vt 0.497319 0.620592 +vt 0.496196 0.621528 +vt 0.360740 0.620948 +vt 0.359552 0.619689 +vt 0.358607 0.618064 +vt 0.357896 0.616064 +vt 0.357437 0.613638 +vt 0.371640 0.350548 +vt 0.372066 0.348566 +vt 0.372686 0.346978 +vt 0.373478 0.345740 +vt 0.374446 0.344828 +vt 0.496050 0.627989 +vt 0.497427 0.626828 +vt 0.498821 0.625423 +vt 0.500137 0.623588 +vt 0.501303 0.621257 +vt 0.502262 0.618493 +vt 0.503027 0.615451 +vt 0.494813 0.627609 +vt 0.362227 0.627365 +vt 0.354029 0.612867 +vt 0.354551 0.615931 +vt 0.355237 0.619056 +vt 0.356187 0.621983 +vt 0.357409 0.624569 +vt 0.358848 0.626749 +vt 0.360425 0.628591 +vt 0.374445 0.339142 +vt 0.373259 0.340271 +vt 0.372063 0.341617 +vt 0.370943 0.343334 +vt 0.369966 0.345474 +vt 0.369178 0.347978 +vt 0.368568 0.350711 +vt 0.354734 0.610083 +vt 0.368910 0.352442 +vt 0.496731 0.362491 +vt 0.502272 0.612325 +vt 0.549955 0.936679 +vt 0.546329 0.933675 +vt 0.553846 0.937703 +vt 0.543215 0.928896 +vt 0.540826 0.922668 +vt 0.539324 0.915416 +vt 0.538811 0.907633 +vt 0.989866 0.907633 +vt 0.989866 0.099076 +vt 0.538811 0.099076 +vt 0.974831 0.069007 +vt 0.553846 0.069007 +vt 0.539324 0.091294 +vt 0.540826 0.084041 +vt 0.543215 0.077814 +vt 0.546329 0.073035 +vt 0.549955 0.070031 +vt 0.978722 0.070032 +vt 0.982348 0.073035 +vt 0.985462 0.077814 +vt 0.987851 0.084041 +vt 0.989354 0.091294 +vt 0.974831 0.937702 +vt 0.989354 0.915416 +vt 0.987852 0.922668 +vt 0.985462 0.928895 +vt 0.982349 0.933674 +vt 0.978723 0.936678 +vn 0.9659 0.2588 0.0000 +vn 1.0000 -0.0000 0.0000 +vn 0.9659 -0.2588 -0.0000 +vn 0.8660 -0.5000 -0.0000 +vn 0.7071 -0.7071 -0.0000 +vn 0.5000 -0.8660 -0.0000 +vn 0.2588 -0.9659 -0.0000 +vn 0.0000 -1.0000 -0.0000 +vn -0.2588 -0.9659 -0.0000 +vn -0.5000 -0.8660 -0.0000 +vn -0.7071 -0.7071 -0.0000 +vn -0.8660 -0.5000 -0.0000 +vn -0.9659 -0.2588 -0.0000 +vn -1.0000 0.0000 -0.0000 +vn -0.9659 0.2588 0.0000 +vn -0.8660 0.5000 0.0000 +vn -0.7071 0.7071 0.0000 +vn -0.5000 0.8660 0.0000 +vn -0.2588 0.9659 0.0000 +vn -0.0000 1.0000 0.0000 +vn 0.2588 0.9659 0.0000 +vn 0.5000 0.8660 0.0000 +vn 0.7071 0.7071 0.0000 +vn 0.8660 0.5000 0.0000 +vn -0.0000 -0.0000 1.0000 +vn -0.0000 -0.9808 -0.1951 +vn 0.0000 -0.9659 -0.2588 +vn 0.0000 -0.9239 -0.3827 +vn -0.0000 -0.8660 -0.5000 +vn -0.0000 -0.8315 -0.5556 +vn -0.0000 -0.7071 -0.7071 +vn 0.0000 -0.5556 -0.8315 +vn -0.0000 -0.5000 -0.8660 +vn -0.0000 -0.3827 -0.9239 +vn -0.0000 -0.2588 -0.9659 +vn -0.0000 -0.1951 -0.9808 +vn -0.0000 0.0000 -1.0000 +vn -0.9659 0.0000 -0.2588 +vn -0.9330 -0.2500 -0.2589 +vn -0.8365 -0.4830 -0.2588 +vn -0.6830 -0.6830 -0.2588 +vn -0.4830 -0.8365 -0.2588 +vn -0.2500 -0.9330 -0.2588 +vn -0.2241 -0.8365 -0.5000 +vn -0.1830 -0.6830 -0.7071 +vn -0.1294 -0.4830 -0.8660 +vn -0.0670 -0.2500 -0.9659 +vn -0.8660 -0.0000 -0.5000 +vn -0.8365 -0.2241 -0.5000 +vn -0.7500 -0.4330 -0.5000 +vn -0.6124 -0.6124 -0.5000 +vn -0.4330 -0.7500 -0.5000 +vn -0.7071 -0.0000 -0.7071 +vn -0.6830 -0.1830 -0.7071 +vn -0.6124 -0.3536 -0.7071 +vn -0.5000 -0.5000 -0.7071 +vn -0.3536 -0.6124 -0.7071 +vn -0.5000 -0.0000 -0.8660 +vn -0.4830 -0.1294 -0.8660 +vn -0.4330 -0.2500 -0.8660 +vn -0.3536 -0.3536 -0.8660 +vn -0.2500 -0.4330 -0.8660 +vn -0.2588 0.0000 -0.9659 +vn -0.2500 -0.0670 -0.9659 +vn -0.2241 -0.1294 -0.9659 +vn -0.1830 -0.1830 -0.9659 +vn -0.1294 -0.2241 -0.9659 +vn 0.0000 0.9659 -0.2588 +vn -0.2500 0.9330 -0.2588 +vn -0.4830 0.8365 -0.2588 +vn -0.6830 0.6830 -0.2588 +vn -0.8365 0.4830 -0.2588 +vn -0.9330 0.2500 -0.2588 +vn -0.8365 0.2241 -0.5000 +vn -0.6830 0.1830 -0.7071 +vn -0.4830 0.1294 -0.8660 +vn -0.2500 0.0670 -0.9659 +vn -0.0000 0.8660 -0.5000 +vn -0.2241 0.8365 -0.5000 +vn -0.4330 0.7500 -0.5000 +vn -0.6124 0.6124 -0.5000 +vn -0.7500 0.4330 -0.5000 +vn -0.0000 0.7071 -0.7071 +vn -0.1830 0.6830 -0.7071 +vn -0.3536 0.6124 -0.7071 +vn -0.5000 0.5000 -0.7071 +vn -0.6124 0.3536 -0.7071 +vn -0.0000 0.5000 -0.8660 +vn -0.1294 0.4830 -0.8660 +vn -0.2500 0.4330 -0.8660 +vn -0.3535 0.3535 -0.8660 +vn -0.4330 0.2500 -0.8660 +vn 0.0000 0.2588 -0.9659 +vn -0.0670 0.2500 -0.9659 +vn -0.1294 0.2242 -0.9659 +vn -0.1830 0.1830 -0.9659 +vn -0.2241 0.1294 -0.9659 +vn 0.9659 -0.0000 -0.2588 +vn 0.9330 0.2500 -0.2588 +vn 0.8365 0.4830 -0.2588 +vn 0.6830 0.6830 -0.2588 +vn 0.4830 0.8365 -0.2588 +vn 0.2500 0.9330 -0.2588 +vn 0.2241 0.8365 -0.5000 +vn 0.1830 0.6830 -0.7071 +vn 0.1294 0.4830 -0.8660 +vn 0.0670 0.2500 -0.9659 +vn 0.8660 0.0000 -0.5000 +vn 0.8365 0.2241 -0.5000 +vn 0.7500 0.4330 -0.5000 +vn 0.6124 0.6124 -0.5000 +vn 0.4330 0.7500 -0.5000 +vn 0.7071 0.0000 -0.7071 +vn 0.6830 0.1830 -0.7071 +vn 0.6124 0.3536 -0.7071 +vn 0.5000 0.5000 -0.7071 +vn 0.3536 0.6124 -0.7071 +vn 0.5000 0.0000 -0.8660 +vn 0.4830 0.1294 -0.8660 +vn 0.4330 0.2500 -0.8660 +vn 0.3536 0.3536 -0.8660 +vn 0.2500 0.4330 -0.8660 +vn 0.2588 -0.0000 -0.9659 +vn 0.2500 0.0670 -0.9659 +vn 0.2241 0.1294 -0.9659 +vn 0.1830 0.1830 -0.9659 +vn 0.1294 0.2241 -0.9659 +vn 0.2500 -0.9330 -0.2588 +vn 0.4830 -0.8365 -0.2588 +vn 0.6830 -0.6830 -0.2588 +vn 0.8365 -0.4830 -0.2588 +vn 0.9330 -0.2500 -0.2588 +vn 0.8365 -0.2241 -0.5000 +vn 0.6830 -0.1830 -0.7071 +vn 0.4830 -0.1294 -0.8660 +vn 0.2500 -0.0670 -0.9659 +vn 0.2241 -0.8365 -0.5000 +vn 0.4330 -0.7500 -0.5000 +vn 0.6124 -0.6124 -0.5000 +vn 0.7500 -0.4330 -0.5000 +vn 0.1830 -0.6830 -0.7071 +vn 0.3536 -0.6124 -0.7071 +vn 0.5000 -0.5000 -0.7071 +vn 0.6124 -0.3536 -0.7071 +vn 0.1294 -0.4830 -0.8660 +vn 0.2500 -0.4330 -0.8660 +vn 0.3536 -0.3536 -0.8660 +vn 0.4330 -0.2500 -0.8660 +vn 0.0670 -0.2500 -0.9659 +vn 0.1294 -0.2242 -0.9659 +vn 0.1830 -0.1830 -0.9659 +vn 0.2242 -0.1294 -0.9659 +vn 0.0000 -0.9659 -0.2589 +vn 0.2588 -0.0000 0.9659 +vn 0.5000 0.0000 0.8660 +vn 0.7071 0.0000 0.7071 +vn 0.8660 0.0000 0.5000 +vn 0.9659 -0.0000 0.2588 +vn -0.0000 0.9659 0.2588 +vn 0.2500 0.9330 0.2588 +vn 0.4830 0.8365 0.2588 +vn 0.6830 0.6830 0.2588 +vn 0.8365 0.4830 0.2588 +vn 0.9330 0.2500 0.2589 +vn 0.8365 0.2241 0.5000 +vn 0.6830 0.1830 0.7071 +vn 0.4830 0.1294 0.8660 +vn 0.2500 0.0670 0.9659 +vn 0.0000 0.8660 0.5000 +vn 0.2241 0.8365 0.5000 +vn 0.4330 0.7500 0.5000 +vn 0.6124 0.6124 0.5000 +vn 0.7500 0.4330 0.5000 +vn 0.0000 0.7071 0.7071 +vn 0.1830 0.6830 0.7071 +vn 0.3535 0.6124 0.7071 +vn 0.5000 0.5000 0.7071 +vn 0.6124 0.3536 0.7071 +vn 0.0000 0.5000 0.8660 +vn 0.1294 0.4830 0.8660 +vn 0.2500 0.4330 0.8660 +vn 0.3535 0.3535 0.8660 +vn 0.4330 0.2500 0.8660 +vn -0.0000 0.2588 0.9659 +vn 0.0670 0.2500 0.9659 +vn 0.1294 0.2241 0.9659 +vn 0.1830 0.1830 0.9659 +vn 0.2241 0.1294 0.9659 +vn -0.9659 -0.0000 0.2588 +vn -0.9330 0.2500 0.2588 +vn -0.8365 0.4830 0.2588 +vn -0.6830 0.6830 0.2588 +vn -0.4830 0.8365 0.2588 +vn -0.2500 0.9330 0.2588 +vn -0.2241 0.8365 0.5000 +vn -0.1830 0.6830 0.7071 +vn -0.1294 0.4830 0.8660 +vn -0.0670 0.2500 0.9659 +vn -0.8660 0.0000 0.5000 +vn -0.8365 0.2241 0.5000 +vn -0.7500 0.4330 0.5000 +vn -0.6124 0.6124 0.5000 +vn -0.4330 0.7500 0.5000 +vn -0.7071 0.0000 0.7071 +vn -0.6830 0.1830 0.7071 +vn -0.6124 0.3535 0.7071 +vn -0.5000 0.5000 0.7071 +vn -0.3536 0.6124 0.7071 +vn -0.5000 0.0000 0.8660 +vn -0.4830 0.1294 0.8660 +vn -0.4330 0.2500 0.8660 +vn -0.3536 0.3536 0.8660 +vn -0.2500 0.4330 0.8660 +vn -0.2588 -0.0000 0.9659 +vn -0.2500 0.0670 0.9659 +vn -0.2241 0.1294 0.9659 +vn -0.1830 0.1830 0.9659 +vn -0.1294 0.2242 0.9659 +vn 0.0000 -0.9659 0.2588 +vn -0.2500 -0.9330 0.2588 +vn -0.4830 -0.8365 0.2588 +vn -0.6830 -0.6830 0.2588 +vn -0.8365 -0.4830 0.2588 +vn -0.9330 -0.2500 0.2588 +vn -0.8365 -0.2241 0.5000 +vn -0.6830 -0.1830 0.7071 +vn -0.4830 -0.1294 0.8660 +vn -0.2500 -0.0670 0.9659 +vn -0.0000 -0.8660 0.5000 +vn -0.2241 -0.8365 0.5000 +vn -0.4330 -0.7500 0.5000 +vn -0.6124 -0.6124 0.5000 +vn -0.7500 -0.4330 0.5000 +vn -0.0000 -0.7071 0.7071 +vn -0.1830 -0.6830 0.7071 +vn -0.3535 -0.6124 0.7071 +vn -0.5000 -0.5000 0.7071 +vn -0.6124 -0.3535 0.7071 +vn -0.0000 -0.5000 0.8660 +vn -0.1294 -0.4830 0.8660 +vn -0.2500 -0.4330 0.8660 +vn -0.3535 -0.3535 0.8660 +vn -0.4330 -0.2500 0.8660 +vn 0.0000 -0.2588 0.9659 +vn -0.0670 -0.2500 0.9659 +vn -0.1294 -0.2242 0.9659 +vn -0.1830 -0.1830 0.9659 +vn -0.2241 -0.1294 0.9659 +vn 0.0670 -0.2500 0.9659 +vn 0.1294 -0.2241 0.9659 +vn 0.1830 -0.1830 0.9659 +vn 0.2241 -0.1294 0.9659 +vn 0.2500 -0.0670 0.9659 +vn 0.4830 -0.1294 0.8660 +vn 0.6830 -0.1830 0.7071 +vn 0.8365 -0.2241 0.5000 +vn 0.9330 -0.2500 0.2588 +vn 0.1294 -0.4830 0.8660 +vn 0.2500 -0.4330 0.8660 +vn 0.3536 -0.3536 0.8660 +vn 0.4330 -0.2500 0.8660 +vn 0.1830 -0.6830 0.7071 +vn 0.3536 -0.6124 0.7071 +vn 0.5000 -0.5000 0.7071 +vn 0.6124 -0.3536 0.7071 +vn 0.2241 -0.8365 0.5000 +vn 0.4330 -0.7500 0.5000 +vn 0.6124 -0.6124 0.5000 +vn 0.7500 -0.4330 0.5000 +vn 0.2500 -0.9330 0.2588 +vn 0.4830 -0.8365 0.2588 +vn 0.6830 -0.6830 0.2588 +vn 0.8365 -0.4830 0.2588 +vn 0.9944 -0.1056 -0.0000 +vn 0.9874 -0.1580 0.0000 +vn 0.9583 -0.1211 -0.2588 +vn 0.9538 -0.1526 -0.2588 +vn 0.8552 -0.1368 -0.5000 +vn 0.8592 -0.1086 -0.5000 +vn 0.6982 -0.1117 -0.7071 +vn 0.7015 -0.0886 -0.7071 +vn 0.4937 -0.0790 -0.8660 +vn 0.4961 -0.0627 -0.8660 +vn 0.2556 -0.0409 -0.9659 +vn 0.2568 -0.0324 -0.9659 +vn 0.9986 -0.0528 -0.0000 +vn 0.8551 -0.1368 -0.5000 +vn 0.2332 -0.9724 0.0000 +vn 0.4535 -0.8912 -0.0000 +vn 0.4829 -0.8365 -0.2588 +vn 0.6489 -0.7609 0.0000 +vn 0.8084 -0.5886 0.0000 +vn 0.9234 -0.3839 -0.0000 +vn 0.8365 -0.2242 -0.5000 +vn 0.3535 -0.6124 -0.7071 +vn 0.3536 -0.3535 -0.8660 +vn -0.8969 -0.4422 0.0000 +vn -0.9724 -0.2334 -0.0000 +vn -0.9330 -0.2500 -0.2588 +vn -0.9393 -0.2254 -0.2588 +vn -0.8421 -0.2021 -0.5000 +vn -0.8365 -0.2242 -0.5000 +vn -0.6876 -0.1650 -0.7071 +vn -0.4862 -0.1167 -0.8660 +vn -0.2517 -0.0604 -0.9659 +vn -0.1294 -0.2242 -0.9659 +vn -0.3536 -0.3535 -0.8660 +vn -0.2207 -0.9753 0.0000 +vn -0.4305 -0.9026 -0.0000 +vn -0.6191 -0.7853 -0.0000 +vn -0.7772 -0.6293 0.0000 +vn -0.9392 -0.2254 -0.2588 +vn -0.9969 -0.0784 0.0000 +vn -0.9877 -0.1564 0.0000 +vn 0.0000 0.0001 -1.0000 +vn 0.0000 -0.0001 -1.0000 +vn 0.2556 -0.0409 0.9659 +vn 0.4937 -0.0790 0.8660 +vn 0.6982 -0.1117 0.7071 +vn 0.8552 -0.1368 0.5000 +vn 0.9538 -0.1526 0.2588 +vn 0.8551 -0.1368 0.5000 +vn 0.1294 -0.2242 0.9659 +vn 0.8365 -0.2242 0.5000 +vn 0.4829 -0.8365 0.2588 +vn 0.3536 -0.3535 0.8660 +vn -0.2517 -0.0604 0.9659 +vn -0.4862 -0.1167 0.8660 +vn -0.6876 -0.1650 0.7071 +vn -0.8421 -0.2021 0.5000 +vn -0.9392 -0.2254 0.2588 +vn -0.3536 -0.6124 0.7071 +vn -0.3536 -0.3535 0.8660 +vn -0.1294 -0.2241 0.9659 +vn -0.9393 -0.2254 0.2588 +vn -0.2568 -0.0324 0.9659 +vn -0.4960 -0.0627 0.8660 +vn -0.7015 -0.0886 0.7071 +vn -0.8592 -0.1085 0.5000 +vn -0.9583 -0.1211 0.2588 +vn 0.7433 0.6690 0.0000 +vn 0.8830 0.4693 0.0000 +vn 0.7433 0.6689 0.0000 +vn 0.9703 0.2419 0.0000 +vn 0.9703 0.2418 0.0000 +vn 0.9703 -0.2419 -0.0000 +vn 0.9703 -0.2418 -0.0000 +vn 0.8830 -0.4693 -0.0000 +vn 0.7433 -0.6690 0.0000 +vn -0.7433 -0.6690 -0.0000 +vn -0.8830 -0.4693 -0.0000 +vn -0.9703 -0.2418 -0.0000 +vn -0.9703 -0.2419 -0.0000 +vn -0.9703 0.2418 0.0000 +vn -0.9703 0.2419 0.0000 +vn -0.8830 0.4693 0.0000 +vn -0.7433 0.6690 0.0000 +vn -0.7433 0.6690 -0.0001 +vn -0.7433 0.6690 0.0001 +vn -0.7433 0.6689 -0.0002 +s 1 +f 1/1/1 2/2/1 3/3/2 +f 3/3/2 2/2/1 4/4/2 +f 3/3/2 4/4/2 5/5/3 +f 5/5/3 4/4/2 6/6/3 +f 5/5/3 6/6/3 7/7/4 +f 7/7/4 6/6/3 8/8/4 +f 7/7/4 8/8/4 9/9/5 +f 9/9/5 8/8/4 10/10/5 +f 9/9/5 10/10/5 11/11/6 +f 11/11/6 10/10/5 12/12/6 +f 11/11/6 12/12/6 13/13/7 +f 13/13/7 12/12/6 14/14/7 +f 13/13/7 14/14/7 15/15/8 +f 15/15/8 14/14/7 16/16/8 +f 15/15/8 16/16/8 17/17/9 +f 17/17/9 16/16/8 18/18/9 +f 17/17/9 18/18/9 19/19/10 +f 19/19/10 18/18/9 20/20/10 +f 19/19/10 20/20/10 21/21/11 +f 21/21/11 20/20/10 22/22/11 +f 21/21/11 22/22/11 23/23/12 +f 23/23/12 22/22/11 24/24/12 +f 23/23/12 24/24/12 25/25/13 +f 25/25/13 24/24/12 26/26/13 +f 25/25/13 26/26/13 27/27/14 +f 27/27/14 26/26/13 28/28/14 +f 27/27/14 28/28/14 29/29/15 +f 29/29/15 28/28/14 30/30/15 +f 29/31/15 30/32/15 31/33/16 +f 31/33/16 30/32/15 32/34/16 +f 31/33/16 32/34/16 33/35/17 +f 33/35/17 32/34/16 34/36/17 +f 33/35/17 34/36/17 35/37/18 +f 35/37/18 34/36/17 36/38/18 +f 35/37/18 36/38/18 37/39/19 +f 37/39/19 36/38/18 38/40/19 +f 37/39/19 38/40/19 39/41/20 +f 39/41/20 38/40/19 40/42/20 +f 39/41/20 40/42/20 41/43/21 +f 41/43/21 40/42/20 42/44/21 +f 41/43/21 42/44/21 43/45/22 +f 43/45/22 42/44/21 44/46/22 +f 43/45/22 44/46/22 45/47/23 +f 45/47/23 44/46/22 46/48/23 +f 45/47/23 46/48/23 47/49/24 +f 47/49/24 46/48/23 48/50/24 +f 47/49/24 48/50/24 1/1/1 +f 1/1/1 48/50/24 2/2/1 +f 49/51/1 50/52/1 51/53/2 +f 51/53/2 50/52/1 52/54/2 +f 51/53/2 52/54/2 53/55/3 +f 53/55/3 52/54/2 54/56/3 +f 53/55/3 54/56/3 55/57/4 +f 55/57/4 54/56/3 56/58/4 +f 55/57/4 56/58/4 57/59/5 +f 57/59/5 56/58/4 58/60/5 +f 57/59/5 58/60/5 59/61/6 +f 59/61/6 58/60/5 60/62/6 +f 59/61/6 60/62/6 61/63/7 +f 61/63/7 60/62/6 62/64/7 +f 61/63/7 62/64/7 63/65/8 +f 63/65/8 62/64/7 64/66/8 +f 63/65/8 64/66/8 65/67/9 +f 65/67/9 64/66/8 66/68/9 +f 65/67/9 66/68/9 67/69/10 +f 67/69/10 66/68/9 68/70/10 +f 67/69/10 68/70/10 69/71/11 +f 69/71/11 68/70/10 70/72/11 +f 69/71/11 70/72/11 71/73/12 +f 71/73/12 70/72/11 72/74/12 +f 71/73/12 72/74/12 73/75/13 +f 73/75/13 72/74/12 74/76/13 +f 73/75/13 74/76/13 75/77/14 +f 75/77/14 74/76/13 76/78/14 +f 75/79/14 76/80/14 77/81/15 +f 77/81/15 76/80/14 78/82/15 +f 77/81/15 78/82/15 79/83/16 +f 79/83/16 78/82/15 80/84/16 +f 79/83/16 80/84/16 81/85/17 +f 81/85/17 80/84/16 82/86/17 +f 81/85/17 82/86/17 83/87/18 +f 83/87/18 82/86/17 84/88/18 +f 83/87/18 84/88/18 85/89/19 +f 85/89/19 84/88/18 86/90/19 +f 85/89/19 86/90/19 87/91/20 +f 87/91/20 86/90/19 88/92/20 +f 87/91/20 88/92/20 89/93/21 +f 89/93/21 88/92/20 90/94/21 +f 89/93/21 90/94/21 91/95/22 +f 91/95/22 90/94/21 92/96/22 +f 91/95/22 92/96/22 93/97/23 +f 93/97/23 92/96/22 94/98/23 +f 93/97/23 94/98/23 95/99/24 +f 95/99/24 94/98/23 96/100/24 +f 95/99/24 96/100/24 49/51/1 +f 49/51/1 96/100/24 50/52/1 +f 1/101/25 3/102/25 97/103/25 +f 97/103/25 3/102/25 5/104/25 +f 97/103/25 5/104/25 7/105/25 +f 7/105/25 9/106/25 97/103/25 +f 97/103/25 9/106/25 11/107/25 +f 97/103/25 11/107/25 13/108/25 +f 13/108/25 15/109/25 97/103/25 +f 97/103/25 15/109/25 98/110/25 +f 98/110/25 15/109/25 17/111/25 +f 98/110/25 17/111/25 19/112/25 +f 19/112/25 21/113/25 98/110/25 +f 98/110/25 21/113/25 23/114/25 +f 98/110/25 23/114/25 25/115/25 +f 25/115/25 27/116/25 98/110/25 +f 98/110/25 27/116/25 99/117/25 +f 98/110/25 99/117/25 100/118/25 +f 27/116/25 29/119/25 99/117/25 +f 99/117/25 29/119/25 31/120/25 +f 99/117/25 31/120/25 33/121/25 +f 33/121/25 35/122/25 99/117/25 +f 99/117/25 35/122/25 37/123/25 +f 99/117/25 37/123/25 39/124/25 +f 39/124/25 41/125/25 99/117/25 +f 99/117/25 41/125/25 43/126/25 +f 99/117/25 43/126/25 101/127/25 +f 101/127/25 43/126/25 102/128/25 +f 101/127/25 102/128/25 103/129/25 +f 43/126/25 45/130/25 102/128/25 +f 102/128/25 45/130/25 104/131/25 +f 104/131/25 45/130/25 47/132/25 +f 104/131/25 47/132/25 105/133/25 +f 105/133/25 47/132/25 1/101/25 +f 105/133/25 1/101/25 106/134/25 +f 106/134/25 1/101/25 97/103/25 +f 106/134/25 97/103/25 107/135/25 +f 49/136/25 51/137/25 108/138/25 +f 108/138/25 51/137/25 53/139/25 +f 108/138/25 53/139/25 109/140/25 +f 109/140/25 53/139/25 55/141/25 +f 109/140/25 55/141/25 110/142/25 +f 110/142/25 55/141/25 57/143/25 +f 110/142/25 57/143/25 100/118/25 +f 100/118/25 57/143/25 59/144/25 +f 100/118/25 59/144/25 61/145/25 +f 61/145/25 63/146/25 100/118/25 +f 100/118/25 63/146/25 98/110/25 +f 98/110/25 63/146/25 111/147/25 +f 111/147/25 63/146/25 65/148/25 +f 111/147/25 65/148/25 67/149/25 +f 67/149/25 69/150/25 111/147/25 +f 111/147/25 69/150/25 71/151/25 +f 111/147/25 71/151/25 73/152/25 +f 111/147/25 73/152/25 112/153/25 +f 112/153/25 73/152/25 75/154/25 +f 112/153/25 75/154/25 113/155/25 +f 113/155/25 75/154/25 114/156/25 +f 114/156/25 75/154/25 115/157/25 +f 115/157/25 75/154/25 116/158/25 +f 116/158/25 75/154/25 77/159/25 +f 116/158/25 77/159/25 117/160/25 +f 117/160/25 77/159/25 79/161/25 +f 117/160/25 79/161/25 81/162/25 +f 81/162/25 83/163/25 117/160/25 +f 117/160/25 83/163/25 85/164/25 +f 117/160/25 85/164/25 87/165/25 +f 89/166/25 118/167/25 87/165/25 +f 87/165/25 118/167/25 119/168/25 +f 87/165/25 119/168/25 117/160/25 +f 89/166/25 91/169/25 118/167/25 +f 118/167/25 91/169/25 93/170/25 +f 118/167/25 93/170/25 120/171/25 +f 120/171/25 93/170/25 95/172/25 +f 120/171/25 95/172/25 121/173/25 +f 121/173/25 95/172/25 49/136/25 +f 121/173/25 49/136/25 108/138/25 +f 118/167/25 101/127/25 119/168/25 +f 119/168/25 101/127/25 122/174/25 +f 119/168/25 122/174/25 123/175/25 +f 119/168/25 124/176/25 107/135/25 +f 107/135/25 124/176/25 125/177/25 +f 107/135/25 125/177/25 126/178/25 +f 127/179/25 128/180/25 107/135/25 +f 107/135/25 128/180/25 106/134/25 +f 103/129/25 129/181/25 101/127/25 +f 101/127/25 129/181/25 130/182/25 +f 101/127/25 130/182/25 131/183/25 +f 131/183/25 132/184/25 101/127/25 +f 101/127/25 132/184/25 133/185/25 +f 101/127/25 133/185/25 134/186/25 +f 134/186/25 122/174/25 101/127/25 +f 123/175/25 135/187/25 119/168/25 +f 119/168/25 135/187/25 136/188/25 +f 119/168/25 136/188/25 137/189/25 +f 137/189/25 138/190/25 119/168/25 +f 119/168/25 138/190/25 124/176/25 +f 126/178/25 139/191/25 107/135/25 +f 107/135/25 139/191/25 140/192/25 +f 107/135/25 140/192/25 127/179/25 +f 141/193/8 142/194/8 143/195/26 +f 143/195/26 142/194/8 144/196/27 +f 143/195/26 144/196/27 145/197/28 +f 145/197/28 144/196/27 146/198/29 +f 145/197/28 146/198/29 147/199/30 +f 147/199/30 146/198/29 148/200/31 +f 147/199/30 148/200/31 149/201/31 +f 149/201/31 148/200/31 150/202/32 +f 150/202/32 148/200/31 151/203/33 +f 150/202/32 151/203/33 152/204/34 +f 152/204/34 151/203/33 153/205/35 +f 152/204/34 153/205/35 154/206/36 +f 154/206/36 153/205/35 155/207/37 +f 154/206/36 155/207/37 156/208/37 +f 157/209/14 158/210/38 159/211/13 +f 159/211/13 158/210/38 160/212/39 +f 159/211/13 160/212/39 161/213/12 +f 161/213/12 160/212/39 162/214/40 +f 161/213/12 162/214/40 163/215/11 +f 163/215/11 162/214/40 164/216/41 +f 163/215/11 164/216/41 165/217/10 +f 165/217/10 164/216/41 166/218/42 +f 165/217/10 166/218/42 167/219/9 +f 167/219/9 166/218/42 168/220/43 +f 167/219/9 168/220/43 142/194/8 +f 142/194/8 168/220/43 144/196/27 +f 144/196/27 168/220/43 169/221/44 +f 144/196/27 169/221/44 146/198/29 +f 146/198/29 169/221/44 170/222/45 +f 146/198/29 170/222/45 148/200/31 +f 148/200/31 170/222/45 171/223/46 +f 148/200/31 171/223/46 151/203/33 +f 151/203/33 171/223/46 172/224/47 +f 151/203/33 172/224/47 153/205/35 +f 153/205/35 172/224/47 173/225/37 +f 153/205/35 173/225/37 155/207/37 +f 158/210/38 174/226/48 160/212/39 +f 160/212/39 174/226/48 175/227/49 +f 160/212/39 175/227/49 162/214/40 +f 162/214/40 175/227/49 176/228/50 +f 162/214/40 176/228/50 164/216/41 +f 164/216/41 176/228/50 177/229/51 +f 164/216/41 177/229/51 166/218/42 +f 166/218/42 177/229/51 178/230/52 +f 166/218/42 178/230/52 168/220/43 +f 168/220/43 178/230/52 169/221/44 +f 174/226/48 179/231/53 175/227/49 +f 175/227/49 179/231/53 180/232/54 +f 175/227/49 180/232/54 176/228/50 +f 176/228/50 180/232/54 181/233/55 +f 176/228/50 181/233/55 177/229/51 +f 177/229/51 181/233/55 182/234/56 +f 177/229/51 182/234/56 178/230/52 +f 178/230/52 182/234/56 183/235/57 +f 178/230/52 183/235/57 169/221/44 +f 169/221/44 183/235/57 170/222/45 +f 179/231/53 184/236/58 180/232/54 +f 180/232/54 184/236/58 185/237/59 +f 180/232/54 185/237/59 181/233/55 +f 181/233/55 185/237/59 186/238/60 +f 181/233/55 186/238/60 182/234/56 +f 182/234/56 186/238/60 187/239/61 +f 182/234/56 187/239/61 183/235/57 +f 183/235/57 187/239/61 188/240/62 +f 183/235/57 188/240/62 170/222/45 +f 170/222/45 188/240/62 171/223/46 +f 184/236/58 189/241/63 185/237/59 +f 185/237/59 189/241/63 190/242/64 +f 185/237/59 190/242/64 186/238/60 +f 186/238/60 190/242/64 191/243/65 +f 186/238/60 191/243/65 187/239/61 +f 187/239/61 191/243/65 192/244/66 +f 187/239/61 192/244/66 188/240/62 +f 188/240/62 192/244/66 193/245/67 +f 188/240/62 193/245/67 171/223/46 +f 171/223/46 193/245/67 172/224/47 +f 189/241/63 194/246/37 190/242/64 +f 190/242/64 194/246/37 195/247/37 +f 190/242/64 195/247/37 191/243/65 +f 191/243/65 195/247/37 196/248/37 +f 191/243/65 196/248/37 192/244/66 +f 192/244/66 196/248/37 197/249/37 +f 192/244/66 197/249/37 193/245/67 +f 193/245/67 197/249/37 198/250/37 +f 193/245/67 198/250/37 172/224/47 +f 172/224/47 198/250/37 173/225/37 +f 157/209/14 199/251/14 158/210/38 +f 158/210/38 199/251/14 200/252/38 +f 158/210/38 200/252/38 174/226/48 +f 174/226/48 200/252/38 201/253/48 +f 174/226/48 201/253/48 179/231/53 +f 179/231/53 201/253/48 202/254/53 +f 179/231/53 202/254/53 184/236/58 +f 184/236/58 202/254/53 203/255/58 +f 184/236/58 203/255/58 189/241/63 +f 189/241/63 203/255/58 204/256/63 +f 189/241/63 204/256/63 194/246/37 +f 194/246/37 204/256/63 205/257/37 +f 206/258/20 207/259/68 208/260/19 +f 208/260/19 207/259/68 209/261/69 +f 208/260/19 209/261/69 210/262/18 +f 210/262/18 209/261/69 211/263/70 +f 210/262/18 211/263/70 212/264/17 +f 212/264/17 211/263/70 213/265/71 +f 212/264/17 213/265/71 214/266/16 +f 214/266/16 213/265/71 215/267/72 +f 214/266/16 215/267/72 216/268/15 +f 216/268/15 215/267/72 217/269/73 +f 216/268/15 217/269/73 199/251/14 +f 199/251/14 217/269/73 200/252/38 +f 200/252/38 217/269/73 218/270/74 +f 200/252/38 218/270/74 201/253/48 +f 201/253/48 218/270/74 219/271/75 +f 201/253/48 219/271/75 202/254/53 +f 202/254/53 219/271/75 220/272/76 +f 202/254/53 220/272/76 203/255/58 +f 203/255/58 220/272/76 221/273/77 +f 203/255/58 221/273/77 204/256/63 +f 204/256/63 221/273/77 222/274/37 +f 204/256/63 222/274/37 205/257/37 +f 207/259/68 223/275/78 209/261/69 +f 209/261/69 223/275/78 224/276/79 +f 209/261/69 224/276/79 211/263/70 +f 211/263/70 224/276/79 225/277/80 +f 211/263/70 225/277/80 213/265/71 +f 213/265/71 225/277/80 226/278/81 +f 213/265/71 226/278/81 215/267/72 +f 215/267/72 226/278/81 227/279/82 +f 215/267/72 227/279/82 217/269/73 +f 217/269/73 227/279/82 218/270/74 +f 223/275/78 228/280/83 224/276/79 +f 224/276/79 228/280/83 229/281/84 +f 224/276/79 229/281/84 225/277/80 +f 225/277/80 229/281/84 230/282/85 +f 225/277/80 230/282/85 226/278/81 +f 226/278/81 230/282/85 231/283/86 +f 226/278/81 231/283/86 227/279/82 +f 227/279/82 231/283/86 232/284/87 +f 227/279/82 232/284/87 218/270/74 +f 218/270/74 232/284/87 219/271/75 +f 228/280/83 233/285/88 229/281/84 +f 229/281/84 233/285/88 234/286/89 +f 229/281/84 234/286/89 230/282/85 +f 230/282/85 234/286/89 235/287/90 +f 230/282/85 235/287/90 231/283/86 +f 231/283/86 235/287/90 236/288/91 +f 231/283/86 236/288/91 232/284/87 +f 232/284/87 236/288/91 237/289/92 +f 232/284/87 237/289/92 219/271/75 +f 219/271/75 237/289/92 220/272/76 +f 233/285/88 238/290/93 234/286/89 +f 234/286/89 238/290/93 239/291/94 +f 234/286/89 239/291/94 235/287/90 +f 235/287/90 239/291/94 240/292/95 +f 235/287/90 240/292/95 236/288/91 +f 236/288/91 240/292/95 241/293/96 +f 236/288/91 241/293/96 237/289/92 +f 237/289/92 241/293/96 242/294/97 +f 237/289/92 242/294/97 220/272/76 +f 220/272/76 242/294/97 221/273/77 +f 238/290/93 243/295/37 239/291/94 +f 239/291/94 243/295/37 244/296/37 +f 239/291/94 244/296/37 240/292/95 +f 240/292/95 244/296/37 245/297/37 +f 240/292/95 245/297/37 241/293/96 +f 241/293/96 245/297/37 246/298/37 +f 241/293/96 246/298/37 242/294/97 +f 242/294/97 246/298/37 247/299/37 +f 242/294/97 247/299/37 221/273/77 +f 221/273/77 247/299/37 222/274/37 +f 206/258/20 248/300/20 207/259/68 +f 207/259/68 248/300/20 249/301/68 +f 207/259/68 249/301/68 223/275/78 +f 223/275/78 249/301/68 250/302/78 +f 223/275/78 250/302/78 228/280/83 +f 228/280/83 250/302/78 251/303/83 +f 228/280/83 251/303/83 233/285/88 +f 233/285/88 251/303/83 252/304/88 +f 233/285/88 252/304/88 238/290/93 +f 238/290/93 252/304/88 253/305/93 +f 238/290/93 253/305/93 243/295/37 +f 243/295/37 253/305/93 254/306/37 +f 255/307/2 256/308/98 257/309/1 +f 257/309/1 256/308/98 258/310/99 +f 257/309/1 258/310/99 259/311/24 +f 259/311/24 258/310/99 260/312/100 +f 259/311/24 260/312/100 261/313/23 +f 261/313/23 260/312/100 262/314/101 +f 261/313/23 262/314/101 263/315/22 +f 263/315/22 262/314/101 264/316/102 +f 263/315/22 264/316/102 265/317/21 +f 265/317/21 264/316/102 266/318/103 +f 265/317/21 266/318/103 248/300/20 +f 248/300/20 266/318/103 249/301/68 +f 249/301/68 266/318/103 267/319/104 +f 249/301/68 267/319/104 250/302/78 +f 250/302/78 267/319/104 268/320/105 +f 250/302/78 268/320/105 251/303/83 +f 251/303/83 268/320/105 269/321/106 +f 251/303/83 269/321/106 252/304/88 +f 252/304/88 269/321/106 270/322/107 +f 252/304/88 270/322/107 253/305/93 +f 253/305/93 270/322/107 271/323/37 +f 253/305/93 271/323/37 254/306/37 +f 256/308/98 272/324/108 258/310/99 +f 258/310/99 272/324/108 273/325/109 +f 258/310/99 273/325/109 260/312/100 +f 260/312/100 273/325/109 274/326/110 +f 260/312/100 274/326/110 262/314/101 +f 262/314/101 274/326/110 275/327/111 +f 262/314/101 275/327/111 264/316/102 +f 264/316/102 275/327/111 276/328/112 +f 264/316/102 276/328/112 266/318/103 +f 266/318/103 276/328/112 267/319/104 +f 272/324/108 277/329/113 273/325/109 +f 273/325/109 277/329/113 278/330/114 +f 273/325/109 278/330/114 274/326/110 +f 274/326/110 278/330/114 279/331/115 +f 274/326/110 279/331/115 275/327/111 +f 275/327/111 279/331/115 280/332/116 +f 275/327/111 280/332/116 276/328/112 +f 276/328/112 280/332/116 281/333/117 +f 276/328/112 281/333/117 267/319/104 +f 267/319/104 281/333/117 268/320/105 +f 277/329/113 282/334/118 278/330/114 +f 278/330/114 282/334/118 283/335/119 +f 278/330/114 283/335/119 279/331/115 +f 279/331/115 283/335/119 284/336/120 +f 279/331/115 284/336/120 280/332/116 +f 280/332/116 284/336/120 285/337/121 +f 280/332/116 285/337/121 281/333/117 +f 281/333/117 285/337/121 286/338/122 +f 281/333/117 286/338/122 268/320/105 +f 268/320/105 286/338/122 269/321/106 +f 282/334/118 287/339/123 283/335/119 +f 283/335/119 287/339/123 288/340/124 +f 283/335/119 288/340/124 284/336/120 +f 284/336/120 288/340/124 289/341/125 +f 284/336/120 289/341/125 285/337/121 +f 285/337/121 289/341/125 290/342/126 +f 285/337/121 290/342/126 286/338/122 +f 286/338/122 290/342/126 291/343/127 +f 286/338/122 291/343/127 269/321/106 +f 269/321/106 291/343/127 270/322/107 +f 287/339/123 292/344/37 288/340/124 +f 288/340/124 292/344/37 293/345/37 +f 288/340/124 293/345/37 289/341/125 +f 289/341/125 293/345/37 294/346/37 +f 289/341/125 294/346/37 290/342/126 +f 290/342/126 294/346/37 295/347/37 +f 290/342/126 295/347/37 291/343/127 +f 291/343/127 295/347/37 296/348/37 +f 291/343/127 296/348/37 270/322/107 +f 270/322/107 296/348/37 271/323/37 +f 255/307/2 297/349/2 256/308/98 +f 256/308/98 297/349/2 298/350/98 +f 256/308/98 298/350/98 272/324/108 +f 272/324/108 298/350/98 299/351/108 +f 272/324/108 299/351/108 277/329/113 +f 277/329/113 299/351/108 300/352/113 +f 277/329/113 300/352/113 282/334/118 +f 282/334/118 300/352/113 301/353/118 +f 282/334/118 301/353/118 287/339/123 +f 287/339/123 301/353/118 302/354/123 +f 287/339/123 302/354/123 292/344/37 +f 292/344/37 302/354/123 303/355/37 +f 304/356/8 305/357/27 306/358/7 +f 306/358/7 305/357/27 307/359/128 +f 306/358/7 307/359/128 308/360/6 +f 308/360/6 307/359/128 309/361/129 +f 308/360/6 309/361/129 310/362/5 +f 310/362/5 309/361/129 311/363/130 +f 310/362/5 311/363/130 312/364/4 +f 312/364/4 311/363/130 313/365/131 +f 312/364/4 313/365/131 314/366/3 +f 314/366/3 313/365/131 315/367/132 +f 314/366/3 315/367/132 297/349/2 +f 297/349/2 315/367/132 298/350/98 +f 298/350/98 315/367/132 316/368/133 +f 298/350/98 316/368/133 299/351/108 +f 299/351/108 316/368/133 317/369/134 +f 299/351/108 317/369/134 300/352/113 +f 300/352/113 317/369/134 318/370/135 +f 300/352/113 318/370/135 301/353/118 +f 301/353/118 318/370/135 319/371/136 +f 301/353/118 319/371/136 302/354/123 +f 302/354/123 319/371/136 320/372/37 +f 302/354/123 320/372/37 303/355/37 +f 305/357/27 321/373/29 307/359/128 +f 307/359/128 321/373/29 322/374/137 +f 307/359/128 322/374/137 309/361/129 +f 309/361/129 322/374/137 323/375/138 +f 309/361/129 323/375/138 311/363/130 +f 311/363/130 323/375/138 324/376/139 +f 311/363/130 324/376/139 313/365/131 +f 313/365/131 324/376/139 325/377/140 +f 313/365/131 325/377/140 315/367/132 +f 315/367/132 325/377/140 316/368/133 +f 321/373/29 326/378/31 322/374/137 +f 322/374/137 326/378/31 327/379/141 +f 322/374/137 327/379/141 323/375/138 +f 323/375/138 327/379/141 328/380/142 +f 323/375/138 328/380/142 324/376/139 +f 324/376/139 328/380/142 329/381/143 +f 324/376/139 329/381/143 325/377/140 +f 325/377/140 329/381/143 330/382/144 +f 325/377/140 330/382/144 316/368/133 +f 316/368/133 330/382/144 317/369/134 +f 326/378/31 331/383/33 327/379/141 +f 327/379/141 331/383/33 332/384/145 +f 327/379/141 332/384/145 328/380/142 +f 328/380/142 332/384/145 333/385/146 +f 328/380/142 333/385/146 329/381/143 +f 329/381/143 333/385/146 334/386/147 +f 329/381/143 334/386/147 330/382/144 +f 330/382/144 334/386/147 335/387/148 +f 330/382/144 335/387/148 317/369/134 +f 317/369/134 335/387/148 318/370/135 +f 331/383/33 336/388/35 332/384/145 +f 332/384/145 336/388/35 337/389/149 +f 332/384/145 337/389/149 333/385/146 +f 333/385/146 337/389/149 338/390/150 +f 333/385/146 338/390/150 334/386/147 +f 334/386/147 338/390/150 339/391/151 +f 334/386/147 339/391/151 335/387/148 +f 335/387/148 339/391/151 340/392/152 +f 335/387/148 340/392/152 318/370/135 +f 318/370/135 340/392/152 319/371/136 +f 336/388/35 341/393/37 337/389/149 +f 337/389/149 341/393/37 342/394/37 +f 337/389/149 342/394/37 338/390/150 +f 338/390/150 342/394/37 343/395/37 +f 338/390/150 343/395/37 339/391/151 +f 339/391/151 343/395/37 344/396/37 +f 339/391/151 344/396/37 340/392/152 +f 340/392/152 344/396/37 345/397/37 +f 340/392/152 345/397/37 319/371/136 +f 319/371/136 345/397/37 320/372/37 +f 304/356/8 346/398/8 305/357/27 +f 305/357/27 346/398/8 347/399/153 +f 305/357/27 347/399/153 321/373/29 +f 321/373/29 347/399/153 348/400/29 +f 321/373/29 348/400/29 326/378/31 +f 326/378/31 348/400/29 349/401/31 +f 326/378/31 349/401/31 331/383/33 +f 331/383/33 349/401/31 350/402/33 +f 331/383/33 350/402/33 336/388/35 +f 336/388/35 350/402/33 351/403/35 +f 336/388/35 351/403/35 341/393/37 +f 341/393/37 351/403/35 352/404/37 +f 353/405/8 304/356/8 354/406/7 +f 354/406/7 304/356/8 306/358/7 +f 354/406/7 306/358/7 355/407/6 +f 355/407/6 306/358/7 308/360/6 +f 355/407/6 308/360/6 356/408/5 +f 356/408/5 308/360/6 310/362/5 +f 356/409/5 310/362/5 357/410/4 +f 357/410/4 310/362/5 312/364/4 +f 357/410/4 312/364/4 358/411/3 +f 358/411/3 312/364/4 314/366/3 +f 358/411/3 314/366/3 359/412/2 +f 359/412/2 314/366/3 297/349/2 +f 255/307/2 360/413/2 297/349/2 +f 297/349/2 360/413/2 359/412/2 +f 360/413/2 255/307/2 361/414/1 +f 361/414/1 255/307/2 257/309/1 +f 361/414/1 257/309/1 362/415/24 +f 362/415/24 257/309/1 259/311/24 +f 362/415/24 259/311/24 363/416/23 +f 363/416/23 259/311/24 261/313/23 +f 363/417/23 261/313/23 364/418/22 +f 364/418/22 261/313/23 263/315/22 +f 364/418/22 263/315/22 365/419/21 +f 365/419/21 263/315/22 265/317/21 +f 365/419/21 265/317/21 366/420/20 +f 366/420/20 265/317/21 248/300/20 +f 206/258/20 367/421/20 248/300/20 +f 248/300/20 367/421/20 366/420/20 +f 367/421/20 206/258/20 368/422/19 +f 368/422/19 206/258/20 208/260/19 +f 368/422/19 208/260/19 369/423/18 +f 369/423/18 208/260/19 210/262/18 +f 369/423/18 210/262/18 370/424/17 +f 370/424/17 210/262/18 212/264/17 +f 370/425/17 212/264/17 371/426/16 +f 371/426/16 212/264/17 214/266/16 +f 371/426/16 214/266/16 372/427/15 +f 372/427/15 214/266/16 216/268/15 +f 372/427/15 216/268/15 373/428/14 +f 373/428/14 216/268/15 199/251/14 +f 157/209/14 374/429/14 199/251/14 +f 199/251/14 374/429/14 373/428/14 +f 374/429/14 157/209/14 375/430/13 +f 375/430/13 157/209/14 159/211/13 +f 375/430/13 159/211/13 376/431/12 +f 376/431/12 159/211/13 161/213/12 +f 376/431/12 161/213/12 377/432/11 +f 377/432/11 161/213/12 163/215/11 +f 377/433/11 163/215/11 378/434/10 +f 378/434/10 163/215/11 165/217/10 +f 378/434/10 165/217/10 379/435/9 +f 379/435/9 165/217/10 167/219/9 +f 379/435/9 167/219/9 380/436/8 +f 380/436/8 167/219/9 142/194/8 +f 346/398/8 304/356/8 107/437/8 +f 107/437/8 304/356/8 353/405/8 +f 107/437/8 353/405/8 119/438/8 +f 119/438/8 353/405/8 380/436/8 +f 119/438/8 380/436/8 142/194/8 +f 142/194/8 141/193/8 119/438/8 +f 381/439/25 382/440/25 383/441/154 +f 383/441/154 382/440/25 384/442/154 +f 383/441/154 384/442/154 385/443/155 +f 385/443/155 384/442/154 386/444/155 +f 385/443/155 386/444/155 387/445/156 +f 387/445/156 386/444/155 388/446/156 +f 387/445/156 388/446/156 389/447/157 +f 389/447/157 388/446/156 390/448/157 +f 389/447/157 390/448/157 391/449/158 +f 391/449/158 390/448/157 392/450/158 +f 391/449/158 392/450/158 360/451/2 +f 360/451/2 392/450/158 359/452/2 +f 366/453/20 393/454/159 365/455/21 +f 365/455/21 393/454/159 394/456/160 +f 365/455/21 394/456/160 364/457/22 +f 364/457/22 394/456/160 395/458/161 +f 364/457/22 395/458/161 363/459/23 +f 363/459/23 395/458/161 396/460/162 +f 363/459/23 396/460/162 362/461/24 +f 362/461/24 396/460/162 397/462/163 +f 362/461/24 397/462/163 361/463/1 +f 361/463/1 397/462/163 398/464/164 +f 361/463/1 398/464/164 360/451/2 +f 360/451/2 398/464/164 391/449/158 +f 391/449/158 398/464/164 399/465/165 +f 391/449/158 399/465/165 389/447/157 +f 389/447/157 399/465/165 400/466/166 +f 389/447/157 400/466/166 387/445/156 +f 387/445/156 400/466/166 401/467/167 +f 387/445/156 401/467/167 385/443/155 +f 385/443/155 401/467/167 402/468/168 +f 385/443/155 402/468/168 383/441/154 +f 383/441/154 402/468/168 403/469/25 +f 383/441/154 403/469/25 381/439/25 +f 393/454/159 404/470/169 394/456/160 +f 394/456/160 404/470/169 405/471/170 +f 394/456/160 405/471/170 395/458/161 +f 395/458/161 405/471/170 406/472/171 +f 395/458/161 406/472/171 396/460/162 +f 396/460/162 406/472/171 407/473/172 +f 396/460/162 407/473/172 397/462/163 +f 397/462/163 407/473/172 408/474/173 +f 397/462/163 408/474/173 398/464/164 +f 398/464/164 408/474/173 399/465/165 +f 404/470/169 409/475/174 405/471/170 +f 405/471/170 409/475/174 410/476/175 +f 405/471/170 410/476/175 406/472/171 +f 406/472/171 410/476/175 411/477/176 +f 406/472/171 411/477/176 407/473/172 +f 407/473/172 411/477/176 412/478/177 +f 407/473/172 412/478/177 408/474/173 +f 408/474/173 412/478/177 413/479/178 +f 408/474/173 413/479/178 399/465/165 +f 399/465/165 413/479/178 400/466/166 +f 409/475/174 414/480/179 410/476/175 +f 410/476/175 414/480/179 415/481/180 +f 410/476/175 415/481/180 411/477/176 +f 411/477/176 415/481/180 416/482/181 +f 411/477/176 416/482/181 412/478/177 +f 412/478/177 416/482/181 417/483/182 +f 412/478/177 417/483/182 413/479/178 +f 413/479/178 417/483/182 418/484/183 +f 413/479/178 418/484/183 400/466/166 +f 400/466/166 418/484/183 401/467/167 +f 414/480/179 419/485/184 415/481/180 +f 415/481/180 419/485/184 420/486/185 +f 415/481/180 420/486/185 416/482/181 +f 416/482/181 420/486/185 421/487/186 +f 416/482/181 421/487/186 417/483/182 +f 417/483/182 421/487/186 422/488/187 +f 417/483/182 422/488/187 418/484/183 +f 418/484/183 422/488/187 423/489/188 +f 418/484/183 423/489/188 401/467/167 +f 401/467/167 423/489/188 402/468/168 +f 419/485/184 424/490/25 420/486/185 +f 420/486/185 424/490/25 425/491/25 +f 420/486/185 425/491/25 421/487/186 +f 421/487/186 425/491/25 426/492/25 +f 421/487/186 426/492/25 422/488/187 +f 422/488/187 426/492/25 427/493/25 +f 422/488/187 427/493/25 423/489/188 +f 423/489/188 427/493/25 428/494/25 +f 423/489/188 428/494/25 402/468/168 +f 402/468/168 428/494/25 403/469/25 +f 366/453/20 367/495/20 393/454/159 +f 393/454/159 367/495/20 429/496/159 +f 393/454/159 429/496/159 404/470/169 +f 404/470/169 429/496/159 430/497/169 +f 404/470/169 430/497/169 409/475/174 +f 409/475/174 430/497/169 431/498/174 +f 409/475/174 431/498/174 414/480/179 +f 414/480/179 431/498/174 432/499/179 +f 414/480/179 432/499/179 419/485/184 +f 419/485/184 432/499/179 433/500/184 +f 419/485/184 433/500/184 424/490/25 +f 424/490/25 433/500/184 434/501/25 +f 373/502/14 435/503/189 372/504/15 +f 372/504/15 435/503/189 436/505/190 +f 372/504/15 436/505/190 371/506/16 +f 371/506/16 436/505/190 437/507/191 +f 371/506/16 437/507/191 370/508/17 +f 370/508/17 437/507/191 438/509/192 +f 370/508/17 438/509/192 369/510/18 +f 369/510/18 438/509/192 439/511/193 +f 369/510/18 439/511/193 368/512/19 +f 368/512/19 439/511/193 440/513/194 +f 368/512/19 440/513/194 367/495/20 +f 367/495/20 440/513/194 429/496/159 +f 429/496/159 440/513/194 441/514/195 +f 429/496/159 441/514/195 430/497/169 +f 430/497/169 441/514/195 442/515/196 +f 430/497/169 442/515/196 431/498/174 +f 431/498/174 442/515/196 443/516/197 +f 431/498/174 443/516/197 432/499/179 +f 432/499/179 443/516/197 444/517/198 +f 432/499/179 444/517/198 433/500/184 +f 433/500/184 444/517/198 445/518/25 +f 433/500/184 445/518/25 434/501/25 +f 435/503/189 446/519/199 436/505/190 +f 436/505/190 446/519/199 447/520/200 +f 436/505/190 447/520/200 437/507/191 +f 437/507/191 447/520/200 448/521/201 +f 437/507/191 448/521/201 438/509/192 +f 438/509/192 448/521/201 449/522/202 +f 438/509/192 449/522/202 439/511/193 +f 439/511/193 449/522/202 450/523/203 +f 439/511/193 450/523/203 440/513/194 +f 440/513/194 450/523/203 441/514/195 +f 446/519/199 451/524/204 447/520/200 +f 447/520/200 451/524/204 452/525/205 +f 447/520/200 452/525/205 448/521/201 +f 448/521/201 452/525/205 453/526/206 +f 448/521/201 453/526/206 449/522/202 +f 449/522/202 453/526/206 454/527/207 +f 449/522/202 454/527/207 450/523/203 +f 450/523/203 454/527/207 455/528/208 +f 450/523/203 455/528/208 441/514/195 +f 441/514/195 455/528/208 442/515/196 +f 451/524/204 456/529/209 452/525/205 +f 452/525/205 456/529/209 457/530/210 +f 452/525/205 457/530/210 453/526/206 +f 453/526/206 457/530/210 458/531/211 +f 453/526/206 458/531/211 454/527/207 +f 454/527/207 458/531/211 459/532/212 +f 454/527/207 459/532/212 455/528/208 +f 455/528/208 459/532/212 460/533/213 +f 455/528/208 460/533/213 442/515/196 +f 442/515/196 460/533/213 443/516/197 +f 456/529/209 461/534/214 457/530/210 +f 457/530/210 461/534/214 462/535/215 +f 457/530/210 462/535/215 458/531/211 +f 458/531/211 462/535/215 463/536/216 +f 458/531/211 463/536/216 459/532/212 +f 459/532/212 463/536/216 464/537/217 +f 459/532/212 464/537/217 460/533/213 +f 460/533/213 464/537/217 465/538/218 +f 460/533/213 465/538/218 443/516/197 +f 443/516/197 465/538/218 444/517/198 +f 461/534/214 466/539/25 462/535/215 +f 462/535/215 466/539/25 467/540/25 +f 462/535/215 467/540/25 463/536/216 +f 463/536/216 467/540/25 468/541/25 +f 463/536/216 468/541/25 464/537/217 +f 464/537/217 468/541/25 469/542/25 +f 464/537/217 469/542/25 465/538/218 +f 465/538/218 469/542/25 470/543/25 +f 465/538/218 470/543/25 444/517/198 +f 444/517/198 470/543/25 445/518/25 +f 373/502/14 374/544/14 435/503/189 +f 435/503/189 374/544/14 471/545/189 +f 435/503/189 471/545/189 446/519/199 +f 446/519/199 471/545/189 472/546/199 +f 446/519/199 472/546/199 451/524/204 +f 451/524/204 472/546/199 473/547/204 +f 451/524/204 473/547/204 456/529/209 +f 456/529/209 473/547/204 474/548/209 +f 456/529/209 474/548/209 461/534/214 +f 461/534/214 474/548/209 475/549/214 +f 461/534/214 475/549/214 466/539/25 +f 466/539/25 475/549/214 476/550/25 +f 380/551/8 477/552/219 379/553/9 +f 379/553/9 477/552/219 478/554/220 +f 379/553/9 478/554/220 378/555/10 +f 378/555/10 478/554/220 479/556/221 +f 378/555/10 479/556/221 377/557/11 +f 377/557/11 479/556/221 480/558/222 +f 377/557/11 480/558/222 376/559/12 +f 376/559/12 480/558/222 481/560/223 +f 376/559/12 481/560/223 375/561/13 +f 375/561/13 481/560/223 482/562/224 +f 375/561/13 482/562/224 374/544/14 +f 374/544/14 482/562/224 471/545/189 +f 471/545/189 482/562/224 483/563/225 +f 471/545/189 483/563/225 472/546/199 +f 472/546/199 483/563/225 484/564/226 +f 472/546/199 484/564/226 473/547/204 +f 473/547/204 484/564/226 485/565/227 +f 473/547/204 485/565/227 474/548/209 +f 474/548/209 485/565/227 486/566/228 +f 474/548/209 486/566/228 475/549/214 +f 475/549/214 486/566/228 487/567/25 +f 475/549/214 487/567/25 476/550/25 +f 477/552/219 488/568/229 478/554/220 +f 478/554/220 488/568/229 489/569/230 +f 478/554/220 489/569/230 479/556/221 +f 479/556/221 489/569/230 490/570/231 +f 479/556/221 490/570/231 480/558/222 +f 480/558/222 490/570/231 491/571/232 +f 480/558/222 491/571/232 481/560/223 +f 481/560/223 491/571/232 492/572/233 +f 481/560/223 492/572/233 482/562/224 +f 482/562/224 492/572/233 483/563/225 +f 488/568/229 493/573/234 489/569/230 +f 489/569/230 493/573/234 494/574/235 +f 489/569/230 494/574/235 490/570/231 +f 490/570/231 494/574/235 495/575/236 +f 490/570/231 495/575/236 491/571/232 +f 491/571/232 495/575/236 496/576/237 +f 491/571/232 496/576/237 492/572/233 +f 492/572/233 496/576/237 497/577/238 +f 492/572/233 497/577/238 483/563/225 +f 483/563/225 497/577/238 484/564/226 +f 493/573/234 498/578/239 494/574/235 +f 494/574/235 498/578/239 499/579/240 +f 494/574/235 499/579/240 495/575/236 +f 495/575/236 499/579/240 500/580/241 +f 495/575/236 500/580/241 496/576/237 +f 496/576/237 500/580/241 501/581/242 +f 496/576/237 501/581/242 497/577/238 +f 497/577/238 501/581/242 502/582/243 +f 497/577/238 502/582/243 484/564/226 +f 484/564/226 502/582/243 485/565/227 +f 498/578/239 503/583/244 499/579/240 +f 499/579/240 503/583/244 504/584/245 +f 499/579/240 504/584/245 500/580/241 +f 500/580/241 504/584/245 505/585/246 +f 500/580/241 505/585/246 501/581/242 +f 501/581/242 505/585/246 506/586/247 +f 501/581/242 506/586/247 502/582/243 +f 502/582/243 506/586/247 507/587/248 +f 502/582/243 507/587/248 485/565/227 +f 485/565/227 507/587/248 486/566/228 +f 503/583/244 508/588/25 504/584/245 +f 504/584/245 508/588/25 509/589/25 +f 504/584/245 509/589/25 505/585/246 +f 505/585/246 509/589/25 510/590/25 +f 505/585/246 510/590/25 506/586/247 +f 506/586/247 510/590/25 511/591/25 +f 506/586/247 511/591/25 507/587/248 +f 507/587/248 511/591/25 512/592/25 +f 507/587/248 512/592/25 486/566/228 +f 486/566/228 512/592/25 487/567/25 +f 380/551/8 353/593/8 477/552/219 +f 477/552/219 353/593/8 513/594/219 +f 477/552/219 513/594/219 488/568/229 +f 488/568/229 513/594/219 514/595/229 +f 488/568/229 514/595/229 493/573/234 +f 493/573/234 514/595/229 515/596/234 +f 493/573/234 515/596/234 498/578/239 +f 498/578/239 515/596/234 516/597/239 +f 498/578/239 516/597/239 503/583/244 +f 503/583/244 516/597/239 517/598/244 +f 503/583/244 517/598/244 508/588/25 +f 508/588/25 517/598/244 518/599/25 +f 518/599/25 517/598/244 519/600/25 +f 519/600/25 517/598/244 520/601/249 +f 519/600/25 520/601/249 521/602/25 +f 521/602/25 520/601/249 522/603/250 +f 521/602/25 522/603/250 523/604/25 +f 523/604/25 522/603/250 524/605/251 +f 523/604/25 524/605/251 525/606/25 +f 525/606/25 524/605/251 526/607/252 +f 525/606/25 526/607/252 527/608/25 +f 527/608/25 526/607/252 528/609/253 +f 527/608/25 528/609/253 382/440/25 +f 382/440/25 528/609/253 384/442/154 +f 384/442/154 528/609/253 529/610/254 +f 384/442/154 529/610/254 386/444/155 +f 386/444/155 529/610/254 530/611/255 +f 386/444/155 530/611/255 388/446/156 +f 388/446/156 530/611/255 531/612/256 +f 388/446/156 531/612/256 390/448/157 +f 390/448/157 531/612/256 532/613/257 +f 390/448/157 532/613/257 392/450/158 +f 392/450/158 532/613/257 358/614/3 +f 392/450/158 358/614/3 359/452/2 +f 517/598/244 516/597/239 520/601/249 +f 520/601/249 516/597/239 533/615/258 +f 520/601/249 533/615/258 522/603/250 +f 522/603/250 533/615/258 534/616/259 +f 522/603/250 534/616/259 524/605/251 +f 524/605/251 534/616/259 535/617/260 +f 524/605/251 535/617/260 526/607/252 +f 526/607/252 535/617/260 536/618/261 +f 526/607/252 536/618/261 528/609/253 +f 528/609/253 536/618/261 529/610/254 +f 516/597/239 515/596/234 533/615/258 +f 533/615/258 515/596/234 537/619/262 +f 533/615/258 537/619/262 534/616/259 +f 534/616/259 537/619/262 538/620/263 +f 534/616/259 538/620/263 535/617/260 +f 535/617/260 538/620/263 539/621/264 +f 535/617/260 539/621/264 536/618/261 +f 536/618/261 539/621/264 540/622/265 +f 536/618/261 540/622/265 529/610/254 +f 529/610/254 540/622/265 530/611/255 +f 515/596/234 514/595/229 537/619/262 +f 537/619/262 514/595/229 541/623/266 +f 537/619/262 541/623/266 538/620/263 +f 538/620/263 541/623/266 542/624/267 +f 538/620/263 542/624/267 539/621/264 +f 539/621/264 542/624/267 543/625/268 +f 539/621/264 543/625/268 540/622/265 +f 540/622/265 543/625/268 544/626/269 +f 540/622/265 544/626/269 530/611/255 +f 530/611/255 544/626/269 531/612/256 +f 514/595/229 513/594/219 541/623/266 +f 541/623/266 513/594/219 545/627/270 +f 541/623/266 545/627/270 542/624/267 +f 542/624/267 545/627/270 546/628/271 +f 542/624/267 546/628/271 543/625/268 +f 543/625/268 546/628/271 547/629/272 +f 543/625/268 547/629/272 544/626/269 +f 544/626/269 547/629/272 548/630/273 +f 544/626/269 548/630/273 531/612/256 +f 531/612/256 548/630/273 532/613/257 +f 513/594/219 353/593/8 545/627/270 +f 545/627/270 353/593/8 354/631/7 +f 545/627/270 354/631/7 546/628/271 +f 546/628/271 354/631/7 355/632/6 +f 546/628/271 355/632/6 547/629/272 +f 547/629/272 355/632/6 356/633/5 +f 547/629/272 356/633/5 548/630/273 +f 548/630/273 356/633/5 357/634/4 +f 548/630/273 357/634/4 532/613/257 +f 532/613/257 357/634/4 358/614/3 +f 381/439/25 466/539/25 382/440/25 +f 382/440/25 466/539/25 476/550/25 +f 382/440/25 476/550/25 518/599/25 +f 518/599/25 476/550/25 508/588/25 +f 508/588/25 476/550/25 487/567/25 +f 508/588/25 487/567/25 512/592/25 +f 403/469/25 424/490/25 381/439/25 +f 381/439/25 424/490/25 434/501/25 +f 381/439/25 434/501/25 466/539/25 +f 466/539/25 434/501/25 467/540/25 +f 467/540/25 434/501/25 468/541/25 +f 468/541/25 434/501/25 469/542/25 +f 469/542/25 434/501/25 470/543/25 +f 470/543/25 434/501/25 445/518/25 +f 403/469/25 428/494/25 424/490/25 +f 424/490/25 428/494/25 427/493/25 +f 424/490/25 427/493/25 426/492/25 +f 426/492/25 425/491/25 424/490/25 +f 512/592/25 511/591/25 508/588/25 +f 508/588/25 511/591/25 510/590/25 +f 508/588/25 510/590/25 509/589/25 +f 519/600/25 521/602/25 518/599/25 +f 518/599/25 521/602/25 523/604/25 +f 518/599/25 523/604/25 525/606/25 +f 525/606/25 527/608/25 518/599/25 +f 518/599/25 527/608/25 382/440/25 +f 549/635/37 550/636/37 551/637/123 +f 551/637/123 550/636/37 552/638/123 +f 551/637/123 552/638/123 553/639/118 +f 553/639/118 552/638/123 554/640/118 +f 553/639/118 554/640/118 555/641/113 +f 555/641/113 554/640/118 556/642/113 +f 555/641/113 556/642/113 557/643/108 +f 557/643/108 556/642/113 558/644/108 +f 557/643/108 558/644/108 559/645/98 +f 559/645/98 558/644/108 560/646/98 +f 559/645/98 560/646/98 561/647/2 +f 561/647/2 560/646/98 562/648/2 +f 563/649/274 564/650/275 565/651/276 +f 565/651/276 564/650/275 566/652/277 +f 565/651/276 566/652/277 567/653/278 +f 565/651/276 567/653/278 568/654/279 +f 568/654/279 567/653/278 569/655/280 +f 568/654/279 569/655/280 570/656/281 +f 570/656/281 569/655/280 571/657/282 +f 570/656/281 571/657/282 572/658/283 +f 572/658/283 571/657/282 573/659/284 +f 572/658/283 573/659/284 574/660/285 +f 574/660/285 573/659/284 575/661/37 +f 574/660/285 575/661/37 576/662/37 +f 576/662/37 577/663/37 574/660/285 +f 574/660/285 577/663/37 551/637/123 +f 574/660/285 551/637/123 553/639/118 +f 577/663/37 549/635/37 551/637/123 +f 574/660/285 553/639/118 572/658/283 +f 572/658/283 553/639/118 555/641/113 +f 572/658/283 555/641/113 570/656/281 +f 570/656/281 555/641/113 557/643/108 +f 570/656/281 557/643/108 568/654/279 +f 568/654/279 557/643/108 559/645/98 +f 568/654/279 559/645/98 565/651/276 +f 565/651/276 559/645/98 578/664/286 +f 565/651/276 578/664/286 563/649/274 +f 559/645/98 561/647/2 578/664/286 +f 564/650/275 579/665/275 566/652/277 +f 566/652/277 579/665/275 580/666/277 +f 566/652/277 580/666/277 567/653/278 +f 567/653/278 580/666/277 581/667/287 +f 567/653/278 581/667/287 569/655/280 +f 569/655/280 581/667/287 582/668/280 +f 569/655/280 582/668/280 571/657/282 +f 571/657/282 582/668/280 583/669/282 +f 571/657/282 583/669/282 573/659/284 +f 573/659/284 583/669/282 584/670/284 +f 573/659/284 584/670/284 575/661/37 +f 575/661/37 584/670/284 585/671/37 +f 586/672/8 587/673/27 588/674/288 +f 588/674/288 587/673/27 589/675/128 +f 588/674/288 589/675/128 590/676/289 +f 590/676/289 589/675/128 591/677/290 +f 590/676/289 591/677/290 592/678/291 +f 592/678/291 591/677/290 593/679/130 +f 592/678/291 593/679/130 594/680/292 +f 594/680/292 593/679/130 595/681/131 +f 594/680/292 595/681/131 596/682/293 +f 596/682/293 595/681/131 597/683/132 +f 596/682/293 597/683/132 579/665/275 +f 579/665/275 597/683/132 580/666/277 +f 580/666/277 597/683/132 598/684/294 +f 580/666/277 598/684/294 581/667/287 +f 581/667/287 598/684/294 599/685/134 +f 581/667/287 599/685/134 582/668/280 +f 582/668/280 599/685/134 600/686/135 +f 582/668/280 600/686/135 583/669/282 +f 583/669/282 600/686/135 601/687/136 +f 583/669/282 601/687/136 584/670/284 +f 584/670/284 601/687/136 585/671/37 +f 585/671/37 601/687/136 602/688/37 +f 602/688/37 601/687/136 603/689/152 +f 602/688/37 603/689/152 604/690/37 +f 604/690/37 603/689/152 605/691/151 +f 604/690/37 605/691/151 606/692/37 +f 606/692/37 605/691/151 607/693/150 +f 606/692/37 607/693/150 608/694/37 +f 608/694/37 607/693/150 609/695/149 +f 608/694/37 609/695/149 610/696/37 +f 610/696/37 609/695/149 611/697/35 +f 610/696/37 611/697/35 612/698/37 +f 587/673/27 613/699/29 589/675/128 +f 589/675/128 613/699/29 614/700/137 +f 589/675/128 614/700/137 591/677/290 +f 591/677/290 614/700/137 615/701/138 +f 591/677/290 615/701/138 593/679/130 +f 593/679/130 615/701/138 616/702/139 +f 593/679/130 616/702/139 595/681/131 +f 595/681/131 616/702/139 617/703/140 +f 595/681/131 617/703/140 597/683/132 +f 597/683/132 617/703/140 598/684/294 +f 613/699/29 618/704/31 614/700/137 +f 614/700/137 618/704/31 619/705/141 +f 614/700/137 619/705/141 615/701/138 +f 615/701/138 619/705/141 620/706/295 +f 615/701/138 620/706/295 616/702/139 +f 616/702/139 620/706/295 621/707/143 +f 616/702/139 621/707/143 617/703/140 +f 617/703/140 621/707/143 622/708/144 +f 617/703/140 622/708/144 598/684/294 +f 598/684/294 622/708/144 599/685/134 +f 618/704/31 623/709/33 619/705/141 +f 619/705/141 623/709/33 624/710/145 +f 619/705/141 624/710/145 620/706/295 +f 620/706/295 624/710/145 625/711/146 +f 620/706/295 625/711/146 621/707/143 +f 621/707/143 625/711/146 626/712/296 +f 621/707/143 626/712/296 622/708/144 +f 622/708/144 626/712/296 627/713/148 +f 622/708/144 627/713/148 599/685/134 +f 599/685/134 627/713/148 600/686/135 +f 623/709/33 611/697/35 624/710/145 +f 624/710/145 611/697/35 609/695/149 +f 624/710/145 609/695/149 625/711/146 +f 625/711/146 609/695/149 607/693/150 +f 625/711/146 607/693/150 626/712/296 +f 626/712/296 607/693/150 605/691/151 +f 626/712/296 605/691/151 627/713/148 +f 627/713/148 605/691/151 603/689/152 +f 627/713/148 603/689/152 600/686/135 +f 600/686/135 603/689/152 601/687/136 +f 586/672/8 628/714/8 587/673/27 +f 587/673/27 628/714/8 629/715/27 +f 587/673/27 629/715/27 613/699/29 +f 613/699/29 629/715/27 630/716/29 +f 613/699/29 630/716/29 618/704/31 +f 618/704/31 630/716/29 631/717/31 +f 618/704/31 631/717/31 623/709/33 +f 623/709/33 631/717/31 632/718/33 +f 623/709/33 632/718/33 611/697/35 +f 611/697/35 632/718/33 633/719/35 +f 611/697/35 633/719/35 612/698/37 +f 612/698/37 633/719/35 634/720/37 +f 635/721/297 636/722/298 637/723/299 +f 637/723/299 636/722/298 638/724/300 +f 637/723/299 638/724/300 639/725/301 +f 637/723/299 639/725/301 640/726/302 +f 640/726/302 639/725/301 641/727/303 +f 640/726/302 641/727/303 642/728/54 +f 642/728/54 641/727/303 643/729/304 +f 642/728/54 643/729/304 644/730/59 +f 644/730/59 643/729/304 645/731/305 +f 644/730/59 645/731/305 646/732/64 +f 646/732/64 645/731/305 647/733/37 +f 646/732/64 647/733/37 648/734/37 +f 646/732/64 648/734/37 649/735/65 +f 649/735/65 648/734/37 650/736/37 +f 649/735/65 650/736/37 651/737/66 +f 651/737/66 650/736/37 652/738/37 +f 651/737/66 652/738/37 653/739/306 +f 653/739/306 652/738/37 654/740/37 +f 653/739/306 654/740/37 655/741/47 +f 655/741/47 654/740/37 656/742/37 +f 655/741/47 656/742/37 633/719/35 +f 633/719/35 656/742/37 634/720/37 +f 633/719/35 632/718/33 655/741/47 +f 655/741/47 632/718/33 657/743/46 +f 655/741/47 657/743/46 653/739/306 +f 653/739/306 657/743/46 658/744/62 +f 653/739/306 658/744/62 651/737/66 +f 651/737/66 658/744/62 659/745/307 +f 651/737/66 659/745/307 649/735/65 +f 649/735/65 659/745/307 660/746/60 +f 649/735/65 660/746/60 646/732/64 +f 646/732/64 660/746/60 644/730/59 +f 632/718/33 631/717/31 657/743/46 +f 657/743/46 631/717/31 661/747/45 +f 657/743/46 661/747/45 658/744/62 +f 658/744/62 661/747/45 662/748/57 +f 658/744/62 662/748/57 659/745/307 +f 659/745/307 662/748/57 663/749/56 +f 659/745/307 663/749/56 660/746/60 +f 660/746/60 663/749/56 664/750/55 +f 660/746/60 664/750/55 644/730/59 +f 644/730/59 664/750/55 642/728/54 +f 631/717/31 630/716/29 661/747/45 +f 661/747/45 630/716/29 665/751/44 +f 661/747/45 665/751/44 662/748/57 +f 662/748/57 665/751/44 666/752/52 +f 662/748/57 666/752/52 663/749/56 +f 663/749/56 666/752/52 667/753/51 +f 663/749/56 667/753/51 664/750/55 +f 664/750/55 667/753/51 668/754/50 +f 664/750/55 668/754/50 642/728/54 +f 642/728/54 668/754/50 640/726/302 +f 630/716/29 629/715/27 665/751/44 +f 665/751/44 629/715/27 669/755/43 +f 665/751/44 669/755/43 666/752/52 +f 666/752/52 669/755/43 670/756/42 +f 666/752/52 670/756/42 667/753/51 +f 667/753/51 670/756/42 671/757/41 +f 667/753/51 671/757/41 668/754/50 +f 668/754/50 671/757/41 672/758/40 +f 668/754/50 672/758/40 640/726/302 +f 640/726/302 672/758/40 637/723/299 +f 628/714/8 673/759/308 629/715/27 +f 629/715/27 673/759/308 669/755/43 +f 673/759/308 674/760/309 669/755/43 +f 669/755/43 674/760/309 670/756/42 +f 674/760/309 675/761/310 670/756/42 +f 670/756/42 675/761/310 671/757/41 +f 675/761/310 676/762/311 671/757/41 +f 671/757/41 676/762/311 672/758/40 +f 676/762/311 635/721/297 672/758/40 +f 672/758/40 635/721/297 637/723/299 +f 636/722/298 677/763/298 638/724/300 +f 638/724/300 677/763/298 678/764/312 +f 638/724/300 678/764/312 639/725/301 +f 639/725/301 678/764/312 679/765/301 +f 639/725/301 679/765/301 641/727/303 +f 641/727/303 679/765/301 680/766/303 +f 641/727/303 680/766/303 643/729/304 +f 643/729/304 680/766/303 681/767/304 +f 643/729/304 681/767/304 645/731/305 +f 645/731/305 681/767/304 682/768/305 +f 645/731/305 682/768/305 647/733/37 +f 647/733/37 682/768/305 683/769/37 +f 684/770/14 685/771/38 686/772/313 +f 686/772/313 685/771/38 687/773/314 +f 687/773/314 685/771/38 678/764/312 +f 687/773/314 678/764/312 677/763/298 +f 685/771/38 688/774/48 678/764/312 +f 678/764/312 688/774/48 679/765/301 +f 679/765/301 688/774/48 689/775/53 +f 679/765/301 689/775/53 680/766/303 +f 680/766/303 689/775/53 690/776/58 +f 680/766/303 690/776/58 681/767/304 +f 681/767/304 690/776/58 691/777/63 +f 681/767/304 691/777/63 682/768/305 +f 682/768/305 691/777/63 692/778/315 +f 682/768/305 692/778/315 683/769/37 +f 693/779/37 694/780/316 691/777/63 +f 691/777/63 694/780/316 692/778/315 +f 684/770/14 695/781/14 685/771/38 +f 685/771/38 695/781/14 696/782/38 +f 685/771/38 696/782/38 688/774/48 +f 688/774/48 696/782/38 697/783/48 +f 688/774/48 697/783/48 689/775/53 +f 689/775/53 697/783/48 698/784/53 +f 689/775/53 698/784/53 690/776/58 +f 690/776/58 698/784/53 699/785/58 +f 690/776/58 699/785/58 691/777/63 +f 691/777/63 699/785/58 700/786/63 +f 691/777/63 700/786/63 693/779/37 +f 693/779/37 700/786/63 701/787/37 +f 702/788/14 684/770/14 703/789/313 +f 703/789/313 684/770/14 686/772/313 +f 703/789/313 686/772/313 704/790/314 +f 704/790/314 686/772/313 687/773/314 +f 704/790/314 687/773/314 705/791/298 +f 705/791/298 687/773/314 677/763/298 +f 677/763/298 636/722/298 705/791/298 +f 705/791/298 636/722/298 706/792/298 +f 636/722/298 635/721/297 706/793/298 +f 706/793/298 635/721/297 707/794/297 +f 707/794/297 635/721/297 676/762/311 +f 707/794/297 676/762/311 708/795/311 +f 708/795/311 676/762/311 709/796/310 +f 709/796/310 676/762/311 675/761/310 +f 709/796/310 675/761/310 710/797/309 +f 710/797/309 675/761/310 674/760/309 +f 710/797/309 674/760/309 673/759/308 +f 710/797/309 673/759/308 711/798/308 +f 711/798/308 673/759/308 628/714/8 +f 711/798/308 628/714/8 712/799/8 +f 628/714/8 586/672/8 712/799/8 +f 712/799/8 586/672/8 713/800/8 +f 586/672/8 588/674/288 713/800/8 +f 713/800/8 588/674/288 714/801/288 +f 714/801/288 588/674/288 590/676/289 +f 714/801/288 590/676/289 715/802/289 +f 715/802/289 590/676/289 716/803/291 +f 716/803/291 590/676/289 592/678/291 +f 716/803/291 592/678/291 717/804/292 +f 717/804/292 592/678/291 594/680/292 +f 717/804/292 594/680/292 596/682/293 +f 717/804/292 596/682/293 718/805/293 +f 718/805/293 596/682/293 579/665/275 +f 718/805/293 579/665/275 719/806/275 +f 579/665/275 564/650/275 719/807/275 +f 719/807/275 564/650/275 720/808/275 +f 720/808/275 564/650/275 721/809/274 +f 721/809/274 564/650/275 563/649/274 +f 721/809/274 563/649/274 722/810/286 +f 722/810/286 563/649/274 578/664/286 +f 722/810/286 578/664/286 723/811/2 +f 723/811/2 578/664/286 561/647/2 +f 561/647/2 562/648/2 723/811/2 +f 723/811/2 562/648/2 724/812/2 +f 723/811/2 724/812/2 725/813/158 +f 725/813/158 724/812/2 726/814/158 +f 725/813/158 726/814/158 727/815/157 +f 727/815/157 726/814/158 728/816/157 +f 727/815/157 728/816/157 729/817/156 +f 729/817/156 728/816/157 730/818/156 +f 729/817/156 730/818/156 731/819/155 +f 731/819/155 730/818/156 732/820/155 +f 731/819/155 732/820/155 733/821/154 +f 733/821/154 732/820/155 734/822/154 +f 733/821/154 734/822/154 735/823/25 +f 735/823/25 734/822/154 736/824/25 +f 737/825/25 738/826/317 739/827/25 +f 739/827/25 738/826/317 733/821/154 +f 739/827/25 733/821/154 740/828/25 +f 740/828/25 733/821/154 735/823/25 +f 738/826/317 741/829/318 733/821/154 +f 733/821/154 741/829/318 731/819/155 +f 731/819/155 741/829/318 742/830/319 +f 731/819/155 742/830/319 729/817/156 +f 729/817/156 742/830/319 743/831/320 +f 729/817/156 743/831/320 727/815/157 +f 727/815/157 743/831/320 744/832/321 +f 727/815/157 744/832/321 725/813/158 +f 725/813/158 744/832/321 721/809/274 +f 725/813/158 721/809/274 722/810/286 +f 744/832/321 720/808/275 721/809/274 +f 722/810/286 723/811/2 725/813/158 +f 737/825/25 745/833/25 738/826/317 +f 738/826/317 745/833/25 746/834/317 +f 738/826/317 746/834/317 741/829/318 +f 741/829/318 746/834/317 747/835/318 +f 741/829/318 747/835/318 742/830/319 +f 742/830/319 747/835/318 748/836/319 +f 742/830/319 748/836/319 743/831/320 +f 743/831/320 748/836/319 749/837/322 +f 743/831/320 749/837/322 744/832/321 +f 744/832/321 749/837/322 750/838/321 +f 744/832/321 750/838/321 720/808/275 +f 720/808/275 750/838/321 719/807/275 +f 751/839/25 752/840/244 753/841/25 +f 753/841/25 752/840/244 754/842/249 +f 753/841/25 754/842/249 755/843/25 +f 755/843/25 754/842/249 756/844/323 +f 755/843/25 756/844/323 757/845/25 +f 757/845/25 756/844/323 758/846/251 +f 757/845/25 758/846/251 759/847/25 +f 759/847/25 758/846/251 760/848/252 +f 759/847/25 760/848/252 761/849/25 +f 761/849/25 760/848/252 762/850/253 +f 761/849/25 762/850/253 745/851/25 +f 745/851/25 762/850/253 746/852/317 +f 746/852/317 762/850/253 763/853/254 +f 746/852/317 763/853/254 747/835/318 +f 747/835/318 763/853/254 764/854/255 +f 747/835/318 764/854/255 748/836/319 +f 748/836/319 764/854/255 765/855/324 +f 748/836/319 765/855/324 749/837/322 +f 749/837/322 765/855/324 766/856/257 +f 749/837/322 766/856/257 750/857/321 +f 750/857/321 766/856/257 719/806/275 +f 719/806/275 766/856/257 718/805/293 +f 718/805/293 766/856/257 767/858/273 +f 718/805/293 767/858/273 717/804/292 +f 717/804/292 767/858/273 768/859/272 +f 717/804/292 768/859/272 716/803/291 +f 716/803/291 768/859/272 769/860/325 +f 716/803/291 769/860/325 715/802/289 +f 715/802/289 769/860/325 770/861/270 +f 715/802/289 770/861/270 714/801/288 +f 714/801/288 770/861/270 771/862/219 +f 714/801/288 771/862/219 713/800/8 +f 752/840/244 772/863/239 754/842/249 +f 754/842/249 772/863/239 773/864/258 +f 754/842/249 773/864/258 756/844/323 +f 756/844/323 773/864/258 774/865/259 +f 756/844/323 774/865/259 758/846/251 +f 758/846/251 774/865/259 775/866/326 +f 758/846/251 775/866/326 760/848/252 +f 760/848/252 775/866/326 776/867/261 +f 760/848/252 776/867/261 762/850/253 +f 762/850/253 776/867/261 763/853/254 +f 772/863/239 777/868/234 773/864/258 +f 773/864/258 777/868/234 778/869/262 +f 773/864/258 778/869/262 774/865/259 +f 774/865/259 778/869/262 779/870/263 +f 774/865/259 779/870/263 775/866/326 +f 775/866/326 779/870/263 780/871/264 +f 775/866/326 780/871/264 776/867/261 +f 776/867/261 780/871/264 781/872/265 +f 776/867/261 781/872/265 763/853/254 +f 763/853/254 781/872/265 764/854/255 +f 777/868/234 782/873/229 778/869/262 +f 778/869/262 782/873/229 783/874/266 +f 778/869/262 783/874/266 779/870/263 +f 779/870/263 783/874/266 784/875/267 +f 779/870/263 784/875/267 780/871/264 +f 780/871/264 784/875/267 785/876/268 +f 780/871/264 785/876/268 781/872/265 +f 781/872/265 785/876/268 786/877/269 +f 781/872/265 786/877/269 764/854/255 +f 764/854/255 786/877/269 765/855/324 +f 782/873/229 771/862/219 783/874/266 +f 783/874/266 771/862/219 770/861/270 +f 783/874/266 770/861/270 784/875/267 +f 784/875/267 770/861/270 769/860/325 +f 784/875/267 769/860/325 785/876/268 +f 785/876/268 769/860/325 768/859/272 +f 785/876/268 768/859/272 786/877/269 +f 786/877/269 768/859/272 767/858/273 +f 786/877/269 767/858/273 765/855/324 +f 765/855/324 767/858/273 766/856/257 +f 751/839/25 787/878/25 752/840/244 +f 752/840/244 787/878/25 788/879/244 +f 752/840/244 788/879/244 772/863/239 +f 772/863/239 788/879/244 789/880/239 +f 772/863/239 789/880/239 777/868/234 +f 777/868/234 789/880/239 790/881/234 +f 777/868/234 790/881/234 782/873/229 +f 782/873/229 790/881/234 791/882/229 +f 782/873/229 791/882/229 771/862/219 +f 771/862/219 791/882/229 792/883/219 +f 771/862/219 792/883/219 713/800/8 +f 713/800/8 792/883/219 712/799/8 +f 793/884/25 794/885/25 795/886/228 +f 795/886/228 794/885/25 796/887/327 +f 795/886/228 796/887/327 797/888/328 +f 795/886/228 797/888/328 798/889/227 +f 798/889/227 797/888/328 799/890/329 +f 798/889/227 799/890/329 800/891/226 +f 800/891/226 799/890/329 801/892/330 +f 800/891/226 801/892/330 802/893/225 +f 802/893/225 801/892/330 803/894/331 +f 802/893/225 803/894/331 804/895/224 +f 804/895/224 803/894/331 706/793/298 +f 804/895/224 706/793/298 707/794/297 +f 804/895/224 707/794/297 805/896/223 +f 805/896/223 707/794/297 708/795/311 +f 805/896/223 708/795/311 806/897/222 +f 806/897/222 708/795/311 709/796/310 +f 806/897/222 709/796/310 807/898/221 +f 807/898/221 709/796/310 710/797/309 +f 807/898/221 710/797/309 808/899/220 +f 808/899/220 710/797/309 711/798/308 +f 808/899/220 711/798/308 792/883/219 +f 792/883/219 711/798/308 712/799/8 +f 792/883/219 791/882/229 808/899/220 +f 808/899/220 791/882/229 809/900/230 +f 808/899/220 809/900/230 807/898/221 +f 807/898/221 809/900/230 810/901/231 +f 807/898/221 810/901/231 806/897/222 +f 806/897/222 810/901/231 811/902/232 +f 806/897/222 811/902/232 805/896/223 +f 805/896/223 811/902/232 812/903/233 +f 805/896/223 812/903/233 804/895/224 +f 804/895/224 812/903/233 802/893/225 +f 791/882/229 790/881/234 809/900/230 +f 809/900/230 790/881/234 813/904/235 +f 809/900/230 813/904/235 810/901/231 +f 810/901/231 813/904/235 814/905/332 +f 810/901/231 814/905/332 811/902/232 +f 811/902/232 814/905/332 815/906/237 +f 811/902/232 815/906/237 812/903/233 +f 812/903/233 815/906/237 816/907/238 +f 812/903/233 816/907/238 802/893/225 +f 802/893/225 816/907/238 800/891/226 +f 790/881/234 789/880/239 813/904/235 +f 813/904/235 789/880/239 817/908/240 +f 813/904/235 817/908/240 814/905/332 +f 814/905/332 817/908/240 818/909/241 +f 814/905/332 818/909/241 815/906/237 +f 815/906/237 818/909/241 819/910/333 +f 815/906/237 819/910/333 816/907/238 +f 816/907/238 819/910/333 820/911/243 +f 816/907/238 820/911/243 800/891/226 +f 800/891/226 820/911/243 798/889/227 +f 789/880/239 788/879/244 817/908/240 +f 817/908/240 788/879/244 821/912/245 +f 817/908/240 821/912/245 818/909/241 +f 818/909/241 821/912/245 822/913/334 +f 818/909/241 822/913/334 819/910/333 +f 819/910/333 822/913/334 823/914/247 +f 819/910/333 823/914/247 820/911/243 +f 820/911/243 823/914/247 824/915/248 +f 820/911/243 824/915/248 798/889/227 +f 798/889/227 824/915/248 795/886/228 +f 787/878/25 825/916/25 788/879/244 +f 788/879/244 825/916/25 821/912/245 +f 825/916/25 826/917/25 821/912/245 +f 821/912/245 826/917/25 822/913/334 +f 826/917/25 827/918/25 822/913/334 +f 822/913/334 827/918/25 823/914/247 +f 827/918/25 828/919/25 823/914/247 +f 823/914/247 828/919/25 824/915/248 +f 828/919/25 793/884/25 824/915/248 +f 824/915/248 793/884/25 795/886/228 +f 794/920/25 829/921/25 796/922/327 +f 796/922/327 829/921/25 830/923/327 +f 796/922/327 830/923/327 797/924/328 +f 797/924/328 830/923/327 831/925/328 +f 797/924/328 831/925/328 799/926/329 +f 799/926/329 831/925/328 832/927/329 +f 799/926/329 832/927/329 801/892/330 +f 801/892/330 832/927/329 833/928/330 +f 801/892/330 833/928/330 803/894/331 +f 803/894/331 833/928/330 834/929/335 +f 803/894/331 834/929/335 706/792/298 +f 706/792/298 834/929/335 705/791/298 +f 835/930/25 836/931/214 837/932/25 +f 837/932/25 836/931/214 838/933/336 +f 837/932/25 838/933/336 839/934/25 +f 839/934/25 838/933/336 830/923/327 +f 839/934/25 830/923/327 829/921/25 +f 836/931/214 840/935/209 838/933/336 +f 838/933/336 840/935/209 841/936/337 +f 838/933/336 841/936/337 830/923/327 +f 830/923/327 841/936/337 831/925/328 +f 831/925/328 841/936/337 842/937/338 +f 831/925/328 842/937/338 832/927/329 +f 832/927/329 842/937/338 843/938/339 +f 832/927/329 843/938/339 833/928/330 +f 833/928/330 843/938/339 844/939/340 +f 833/928/330 844/939/340 834/929/335 +f 834/929/335 844/939/340 704/790/314 +f 834/929/335 704/790/314 705/791/298 +f 840/935/209 845/940/204 841/936/337 +f 841/936/337 845/940/204 842/937/338 +f 845/940/204 846/941/199 842/937/338 +f 842/937/338 846/941/199 843/938/339 +f 846/941/199 847/942/189 843/938/339 +f 843/938/339 847/942/189 844/939/340 +f 702/788/14 703/789/313 847/942/189 +f 847/942/189 703/789/313 844/939/340 +f 703/789/313 704/790/314 844/939/340 +f 835/930/25 848/943/25 836/931/214 +f 836/931/214 848/943/25 849/944/214 +f 836/931/214 849/944/214 840/935/209 +f 840/935/209 849/944/214 850/945/209 +f 840/935/209 850/945/209 845/940/204 +f 845/940/204 850/945/209 851/946/204 +f 845/940/204 851/946/204 846/941/199 +f 846/941/199 851/946/204 852/947/199 +f 846/941/199 852/947/199 847/942/189 +f 847/942/189 852/947/199 853/948/189 +f 847/942/189 853/948/189 702/788/14 +f 702/788/14 853/948/189 854/949/14 +f 848/950/25 835/951/25 736/952/25 +f 736/952/25 835/951/25 735/953/25 +f 735/953/25 835/951/25 837/954/25 +f 735/953/25 837/954/25 740/955/25 +f 740/955/25 837/954/25 839/956/25 +f 740/955/25 839/956/25 739/957/25 +f 739/957/25 839/956/25 737/958/25 +f 737/958/25 839/956/25 829/959/25 +f 737/958/25 829/959/25 745/960/25 +f 745/960/25 829/959/25 751/961/25 +f 745/960/25 751/961/25 761/962/25 +f 761/962/25 751/961/25 759/963/25 +f 759/963/25 751/961/25 757/964/25 +f 757/964/25 751/961/25 755/965/25 +f 755/965/25 751/961/25 753/966/25 +f 751/961/25 829/959/25 787/967/25 +f 787/967/25 829/959/25 794/968/25 +f 787/967/25 794/968/25 793/969/25 +f 793/969/25 828/970/25 787/967/25 +f 787/967/25 828/970/25 827/971/25 +f 787/967/25 827/971/25 826/972/25 +f 826/972/25 825/973/25 787/967/25 +f 855/974/341 856/975/342 100/976/343 +f 100/976/343 856/975/342 110/977/342 +f 110/977/342 856/975/342 857/978/344 +f 110/977/342 857/978/344 109/979/345 +f 109/979/345 857/978/344 108/980/2 +f 108/980/2 857/978/344 858/981/2 +f 108/980/2 858/981/2 121/982/346 +f 121/982/346 858/981/2 859/983/347 +f 121/982/346 859/983/347 860/984/348 +f 121/982/346 860/984/348 120/985/348 +f 120/985/348 860/984/348 861/986/349 +f 120/985/348 861/986/349 118/987/349 +f 861/986/349 862/988/349 118/987/349 +f 118/987/349 862/988/349 101/989/349 +f 111/990/350 112/991/351 863/992/350 +f 863/992/350 112/991/351 864/993/351 +f 864/993/351 112/991/351 113/994/352 +f 864/993/351 113/994/352 865/995/353 +f 865/995/353 113/994/352 866/996/14 +f 866/996/14 113/994/352 114/997/14 +f 866/996/14 114/997/14 867/998/354 +f 867/998/354 114/997/14 115/999/355 +f 867/998/354 115/999/355 116/1000/356 +f 867/998/354 116/1000/356 868/1001/356 +f 868/1001/356 116/1000/356 117/1002/357 +f 868/1001/356 117/1002/357 869/1003/357 +f 870/1004/37 862/988/37 352/404/37 +f 352/404/37 862/988/37 156/208/37 +f 352/404/37 156/208/37 254/306/37 +f 254/306/37 156/208/37 243/295/37 +f 243/295/37 156/208/37 155/207/37 +f 243/295/37 155/207/37 173/225/37 +f 550/636/37 549/635/37 870/1004/37 +f 870/1004/37 549/635/37 577/663/37 +f 870/1004/37 577/663/37 576/662/37 +f 576/662/37 575/661/37 870/1004/37 +f 870/1004/37 575/661/37 612/698/37 +f 870/1004/37 612/698/37 871/1005/37 +f 871/1005/37 612/698/37 634/720/37 +f 871/1005/37 634/720/37 693/779/37 +f 693/779/37 634/720/37 683/769/37 +f 693/779/37 683/769/37 692/778/315 +f 612/698/37 575/661/37 610/696/37 +f 610/696/37 575/661/37 585/671/37 +f 610/696/37 585/671/37 608/694/37 +f 608/694/37 585/671/37 606/692/37 +f 606/692/37 585/671/37 604/690/37 +f 604/690/37 585/671/37 602/688/37 +f 634/720/37 656/742/37 683/769/37 +f 683/769/37 656/742/37 647/733/37 +f 647/733/37 656/742/37 654/740/37 +f 647/733/37 654/740/37 652/738/37 +f 652/738/37 650/736/37 647/733/37 +f 647/733/37 650/736/37 648/734/37 +f 692/778/315 694/780/316 693/779/37 +f 693/779/37 701/787/37 871/1005/37 +f 863/992/37 855/974/37 871/1005/37 +f 871/1005/37 855/974/37 872/1006/37 +f 871/1005/37 872/1006/37 870/1004/37 +f 870/1004/37 872/1006/37 862/988/37 +f 864/993/37 869/1003/37 863/992/37 +f 863/992/37 869/1003/37 858/981/37 +f 863/992/37 858/981/37 857/978/37 +f 869/1003/37 864/993/37 868/1001/37 +f 868/1001/37 864/993/37 865/995/37 +f 868/1001/37 865/995/37 867/998/37 +f 867/998/37 865/995/37 866/996/37 +f 156/208/37 861/986/37 869/1003/37 +f 869/1003/37 861/986/37 860/984/37 +f 869/1003/37 860/984/37 859/983/37 +f 243/295/37 173/225/37 244/296/37 +f 244/296/37 173/225/37 198/250/37 +f 244/296/37 198/250/37 245/297/37 +f 245/297/37 198/250/37 194/246/37 +f 245/297/37 194/246/37 205/257/37 +f 198/250/37 197/249/37 194/246/37 +f 194/246/37 197/249/37 196/248/37 +f 194/246/37 196/248/37 195/247/37 +f 222/274/37 247/299/37 205/257/37 +f 205/257/37 247/299/37 246/298/37 +f 205/257/37 246/298/37 245/297/37 +f 271/323/37 342/394/37 254/306/37 +f 254/306/37 342/394/37 341/393/37 +f 254/306/37 341/393/37 352/404/37 +f 342/394/37 271/323/37 343/395/37 +f 343/395/37 271/323/37 296/348/37 +f 343/395/37 296/348/37 303/355/37 +f 303/355/37 296/348/37 292/344/37 +f 292/344/37 296/348/37 295/347/37 +f 292/344/37 295/347/37 294/346/37 +f 294/346/37 293/345/37 292/344/37 +f 320/372/37 345/397/37 303/355/37 +f 303/355/37 345/397/37 344/396/37 +f 303/355/37 344/396/37 343/395/37 +f 862/988/37 861/986/37 156/208/37 +f 859/983/37 858/981/37 869/1003/37 +f 857/978/37 856/975/37 863/992/37 +f 863/992/37 856/975/37 855/974/37 +f 117/1002/357 141/1007/357 869/1003/357 +f 869/1003/357 141/1007/357 143/1008/357 +f 869/1003/357 143/1008/357 145/1009/357 +f 117/1002/357 119/1010/357 141/1007/357 +f 145/1009/357 147/1011/358 869/1003/357 +f 869/1003/357 147/1011/358 149/1012/357 +f 869/1003/357 149/1012/357 150/1013/359 +f 150/1013/359 152/1014/360 869/1003/357 +f 869/1003/357 152/1014/360 154/1015/357 +f 869/1003/357 154/1015/357 156/208/358 +f 100/976/343 99/1016/341 855/974/341 +f 855/974/341 99/1016/341 872/1006/341 +f 863/992/350 871/1005/350 111/990/350 +f 111/990/350 871/1005/350 98/1017/350 +f 101/989/14 862/988/14 99/1016/14 +f 99/1016/14 862/988/14 872/1006/14 +f 107/1018/2 97/1019/2 346/1020/2 +f 346/1020/2 97/1019/2 870/1004/2 +f 346/1020/2 870/1004/2 347/1021/2 +f 347/1021/2 870/1004/2 348/1022/2 +f 348/1022/2 870/1004/2 349/1023/2 +f 349/1023/2 870/1004/2 350/1024/2 +f 350/1024/2 870/1004/2 351/1025/2 +f 351/1025/2 870/1004/2 352/404/2 +f 695/781/14 684/770/14 854/949/14 +f 854/949/14 684/770/14 702/788/14 +f 700/786/20 699/1026/20 701/787/20 +f 701/787/20 699/1026/20 698/784/20 +f 701/787/20 698/784/20 697/783/20 +f 697/783/20 696/1027/20 701/787/20 +f 701/787/20 696/1027/20 695/1028/20 +f 701/787/20 695/1028/20 871/1005/20 +f 871/1005/20 695/1028/20 98/1029/20 +f 98/1029/20 695/1028/20 854/1030/20 +f 98/1029/20 854/1030/20 848/1031/20 +f 848/1031/20 854/1030/20 853/1032/20 +f 848/1031/20 853/1032/20 852/947/20 +f 852/947/20 851/946/20 848/1031/20 +f 848/1031/20 851/946/20 850/945/20 +f 848/1031/20 850/945/20 849/1033/20 +f 848/1031/20 736/1034/20 98/1029/20 +f 98/1029/20 736/1034/20 724/1035/20 +f 98/1029/20 724/1035/20 97/1036/20 +f 97/1036/20 724/1035/20 562/1037/20 +f 97/1036/20 562/1037/20 870/1004/20 +f 870/1004/20 562/1037/20 550/636/20 +f 550/636/20 562/1037/20 560/646/20 +f 550/636/20 560/646/20 558/644/20 +f 734/1038/20 732/1039/20 736/1034/20 +f 736/1034/20 732/1039/20 730/818/20 +f 736/1034/20 730/818/20 728/816/20 +f 728/816/20 726/1040/20 736/1034/20 +f 736/1034/20 726/1040/20 724/1035/20 +f 558/644/20 556/642/20 550/636/20 +f 550/636/20 556/642/20 554/1041/20 +f 550/636/20 554/1041/20 552/638/20 +f 127/1042/1 873/1043/1 128/1044/2 +f 128/1044/2 873/1043/1 874/1045/2 +f 128/1044/2 874/1045/2 106/1046/3 +f 106/1046/3 874/1045/2 875/1047/3 +f 106/1046/3 875/1047/3 105/1048/4 +f 105/1048/4 875/1047/3 876/1049/4 +f 105/1048/4 876/1049/4 104/1050/5 +f 104/1050/5 876/1049/4 877/1051/5 +f 104/1050/5 877/1051/5 102/1052/6 +f 102/1052/6 877/1051/5 878/1053/6 +f 102/1052/6 878/1053/6 103/1054/7 +f 103/1054/7 878/1053/6 879/1055/7 +f 103/1054/7 879/1055/7 129/1056/8 +f 129/1056/8 879/1055/7 880/1057/8 +f 129/1058/8 880/1059/8 130/1060/9 +f 130/1060/9 880/1059/8 881/1061/9 +f 130/1060/9 881/1061/9 131/1062/10 +f 131/1062/10 881/1061/9 882/1063/10 +f 131/1062/10 882/1063/10 132/1064/11 +f 132/1064/11 882/1063/10 883/1065/11 +f 132/1064/11 883/1065/11 133/1066/12 +f 133/1066/12 883/1065/11 884/1067/12 +f 133/1066/12 884/1067/12 134/1068/13 +f 134/1068/13 884/1067/12 885/1069/13 +f 134/1068/13 885/1069/13 122/1070/14 +f 122/1070/14 885/1069/13 886/1071/14 +f 122/1070/14 886/1071/14 123/1072/15 +f 123/1072/15 886/1071/14 887/1073/15 +f 123/1074/15 887/1075/15 135/1076/16 +f 135/1076/16 887/1075/15 888/1077/16 +f 135/1076/16 888/1077/16 136/1078/17 +f 136/1078/17 888/1077/16 889/1079/17 +f 136/1078/17 889/1079/17 137/1080/18 +f 137/1080/18 889/1079/17 890/1081/18 +f 137/1080/18 890/1081/18 138/1082/19 +f 138/1082/19 890/1081/18 891/1083/19 +f 138/1082/19 891/1083/19 124/1084/20 +f 124/1084/20 891/1083/19 892/1085/20 +f 124/1084/20 892/1085/20 125/1086/21 +f 125/1086/21 892/1085/20 893/1087/21 +f 125/1086/21 893/1087/21 126/1088/22 +f 126/1088/22 893/1087/21 894/1089/22 +f 126/1088/22 894/1089/22 139/1090/23 +f 139/1090/23 894/1089/22 895/1091/23 +f 139/1090/23 895/1091/23 140/1092/24 +f 140/1092/24 895/1091/23 896/1093/24 +f 140/1092/24 896/1093/24 127/1042/1 +f 127/1042/1 896/1093/24 873/1043/1 +f 897/1094/20 898/1095/20 899/1096/19 +f 899/1096/19 898/1095/20 900/1097/19 +f 899/1096/19 900/1097/19 901/1098/18 +f 901/1098/18 900/1097/19 902/1099/18 +f 901/1098/18 902/1099/18 903/1100/17 +f 903/1100/17 902/1099/18 904/1101/17 +f 903/1100/17 904/1101/17 905/1102/16 +f 905/1102/16 904/1101/17 906/1103/16 +f 905/1102/16 906/1103/16 907/1104/15 +f 907/1104/15 906/1103/16 908/1105/15 +f 907/1104/15 908/1105/15 909/1106/14 +f 909/1106/14 908/1105/15 910/1107/14 +f 910/1107/14 911/1108/14 909/1109/14 +f 909/1109/14 911/1108/14 912/1110/14 +f 6/1111/37 4/1112/37 913/1113/37 +f 913/1113/37 4/1112/37 2/1114/37 +f 913/1113/37 2/1114/37 914/1115/37 +f 914/1115/37 2/1114/37 48/1116/37 +f 914/1115/37 48/1116/37 46/1117/37 +f 914/1115/37 46/1117/37 877/1118/37 +f 877/1118/37 46/1117/37 44/1119/37 +f 877/1118/37 44/1119/37 878/1120/37 +f 878/1120/37 44/1119/37 42/1121/37 +f 878/1120/37 42/1121/37 879/1122/37 +f 879/1122/37 42/1121/37 52/1123/37 +f 879/1122/37 52/1123/37 880/1124/37 +f 880/1124/37 52/1123/37 50/1125/37 +f 880/1124/37 50/1125/37 881/1126/37 +f 881/1126/37 50/1125/37 96/1127/37 +f 881/1126/37 96/1127/37 882/1128/37 +f 882/1128/37 96/1127/37 94/1129/37 +f 882/1128/37 94/1129/37 883/1130/37 +f 883/1130/37 94/1129/37 92/1131/37 +f 883/1130/37 92/1131/37 884/1132/37 +f 884/1132/37 92/1131/37 90/1133/37 +f 884/1132/37 90/1133/37 885/1134/37 +f 885/1134/37 90/1133/37 88/1135/37 +f 885/1134/37 88/1135/37 886/1136/37 +f 886/1136/37 88/1135/37 86/1137/37 +f 886/1136/37 86/1137/37 898/1095/37 +f 898/1095/37 86/1137/37 910/1107/37 +f 898/1095/37 910/1107/37 908/1105/37 +f 42/1121/37 40/1138/37 52/1123/37 +f 52/1123/37 40/1138/37 54/1139/37 +f 54/1139/37 40/1138/37 38/1140/37 +f 54/1139/37 38/1140/37 56/1141/37 +f 56/1141/37 38/1140/37 36/1142/37 +f 56/1141/37 36/1142/37 58/1143/37 +f 58/1143/37 36/1142/37 34/1144/37 +f 58/1143/37 34/1144/37 60/1145/37 +f 60/1145/37 34/1144/37 32/1146/37 +f 60/1145/37 32/1146/37 62/1147/37 +f 62/1147/37 32/1146/37 30/1148/37 +f 62/1147/37 30/1148/37 64/1149/37 +f 64/1149/37 30/1148/37 28/1150/37 +f 64/1149/37 28/1150/37 915/1151/37 +f 915/1151/37 28/1150/37 26/1152/37 +f 915/1151/37 26/1152/37 24/1153/37 +f 24/1153/37 22/1154/37 915/1151/37 +f 915/1151/37 22/1154/37 20/1155/37 +f 915/1151/37 20/1155/37 18/1156/37 +f 18/1156/37 16/1157/37 915/1151/37 +f 915/1151/37 16/1157/37 913/1113/37 +f 913/1113/37 16/1157/37 14/1158/37 +f 913/1113/37 14/1158/37 12/1159/37 +f 12/1159/37 10/1160/37 913/1113/37 +f 913/1113/37 10/1160/37 8/1161/37 +f 913/1113/37 8/1161/37 6/1111/37 +f 86/1137/37 84/1162/37 910/1107/37 +f 910/1107/37 84/1162/37 82/1163/37 +f 910/1107/37 82/1163/37 80/1164/37 +f 80/1164/37 78/1165/37 910/1107/37 +f 910/1107/37 78/1165/37 76/1166/37 +f 910/1107/37 76/1166/37 911/1108/37 +f 911/1108/37 76/1166/37 74/1167/37 +f 911/1108/37 74/1167/37 72/1168/37 +f 72/1168/37 70/1169/37 911/1108/37 +f 911/1108/37 70/1169/37 68/1170/37 +f 911/1108/37 68/1170/37 915/1151/37 +f 915/1151/37 68/1170/37 66/1171/37 +f 915/1151/37 66/1171/37 64/1149/37 +f 875/1172/37 874/1173/37 916/1174/37 +f 916/1174/37 874/1173/37 873/1175/37 +f 916/1174/37 873/1175/37 917/1176/37 +f 917/1176/37 873/1175/37 896/1177/37 +f 917/1176/37 896/1177/37 895/1178/37 +f 895/1178/37 894/1179/37 917/1176/37 +f 917/1176/37 894/1179/37 893/1180/37 +f 917/1176/37 893/1180/37 892/1181/37 +f 892/1181/37 891/1182/37 917/1176/37 +f 917/1176/37 891/1182/37 898/1095/37 +f 898/1095/37 891/1182/37 890/1183/37 +f 898/1095/37 890/1183/37 889/1184/37 +f 889/1184/37 888/1185/37 898/1095/37 +f 898/1095/37 888/1185/37 887/1186/37 +f 898/1095/37 887/1186/37 886/1136/37 +f 914/1115/37 877/1118/37 916/1174/37 +f 916/1174/37 877/1118/37 876/1187/37 +f 916/1174/37 876/1187/37 875/1172/37 +f 908/1105/37 906/1103/37 898/1095/37 +f 898/1095/37 906/1103/37 904/1101/37 +f 898/1095/37 904/1101/37 902/1099/37 +f 902/1099/37 900/1097/37 898/1095/37 +f 918/1188/37 919/1189/37 917/1176/37 +f 917/1176/37 919/1189/37 920/1190/37 +f 917/1176/37 920/1190/37 921/1191/37 +f 921/1191/37 922/1192/37 917/1176/37 +f 917/1176/37 922/1192/37 916/1174/37 +f 914/1115/37 923/1193/37 913/1113/37 +f 913/1113/37 923/1193/37 924/1194/37 +f 913/1113/37 924/1194/37 925/1195/37 +f 925/1195/37 926/1196/37 913/1113/37 +f 913/1113/37 926/1196/37 927/1197/37 +f 928/1198/37 929/1199/37 915/1151/37 +f 915/1151/37 929/1199/37 930/1200/37 +f 915/1151/37 930/1200/37 931/1201/37 +f 931/1201/37 932/1202/37 915/1151/37 +f 915/1151/37 932/1202/37 911/1108/37 +f 933/1203/2 916/1174/2 934/1204/1 +f 934/1204/1 916/1174/2 922/1192/1 +f 934/1204/1 922/1192/1 935/1205/24 +f 935/1205/24 922/1192/1 921/1191/24 +f 935/1205/24 921/1191/24 936/1206/23 +f 936/1206/23 921/1191/24 920/1190/23 +f 936/1206/23 920/1190/23 937/1207/22 +f 937/1207/22 920/1190/23 919/1189/22 +f 937/1207/22 919/1189/22 938/1208/21 +f 938/1208/21 919/1189/22 918/1188/21 +f 938/1208/21 918/1188/21 939/1209/20 +f 939/1209/20 918/1188/21 917/1176/20 +f 933/1210/2 940/1211/2 916/1174/2 +f 916/1174/2 940/1211/2 914/1115/2 +f 941/1212/8 913/1113/8 942/1213/7 +f 942/1213/7 913/1113/8 927/1197/7 +f 942/1213/7 927/1197/7 943/1214/6 +f 943/1214/6 927/1197/7 926/1196/6 +f 943/1214/6 926/1196/6 944/1215/5 +f 944/1215/5 926/1196/6 925/1195/5 +f 944/1215/5 925/1195/5 945/1216/4 +f 945/1216/4 925/1195/5 924/1194/4 +f 945/1216/4 924/1194/4 946/1217/3 +f 946/1217/3 924/1194/4 923/1193/3 +f 946/1217/3 923/1193/3 940/1218/2 +f 940/1218/2 923/1193/3 914/1115/2 +f 912/1219/14 911/1108/14 947/1220/13 +f 947/1220/13 911/1108/14 932/1202/13 +f 947/1220/13 932/1202/13 948/1221/12 +f 948/1221/12 932/1202/13 931/1201/12 +f 948/1221/12 931/1201/12 949/1222/11 +f 949/1222/11 931/1201/12 930/1200/11 +f 949/1222/11 930/1200/11 950/1223/10 +f 950/1223/10 930/1200/11 929/1199/10 +f 950/1223/10 929/1199/10 951/1224/9 +f 951/1224/9 929/1199/10 928/1198/9 +f 951/1224/9 928/1198/9 952/1225/8 +f 952/1225/8 928/1198/9 915/1151/8 +f 941/1226/8 952/1227/8 913/1113/8 +f 913/1113/8 952/1227/8 915/1151/8 +f 897/1228/20 939/1229/20 898/1095/20 +f 898/1095/20 939/1229/20 917/1176/20 +f 899/1230/25 901/1231/25 897/1232/25 +f 897/1232/25 901/1231/25 903/1233/25 +f 897/1232/25 903/1233/25 905/1234/25 +f 905/1234/25 907/1235/25 897/1232/25 +f 897/1232/25 907/1235/25 909/1236/25 +f 897/1232/25 909/1236/25 933/1237/25 +f 933/1237/25 909/1236/25 940/1238/25 +f 940/1238/25 909/1236/25 912/1239/25 +f 940/1238/25 912/1239/25 941/1240/25 +f 941/1240/25 912/1239/25 952/1241/25 +f 952/1241/25 912/1239/25 947/1242/25 +f 952/1241/25 947/1242/25 948/1243/25 +f 948/1243/25 949/1244/25 952/1241/25 +f 952/1241/25 949/1244/25 950/1245/25 +f 952/1241/25 950/1245/25 951/1246/25 +f 942/1247/25 943/1248/25 941/1240/25 +f 941/1240/25 943/1248/25 944/1249/25 +f 941/1240/25 944/1249/25 945/1250/25 +f 945/1250/25 946/1251/25 941/1240/25 +f 941/1240/25 946/1251/25 940/1238/25 +f 897/1232/25 933/1237/25 939/1252/25 +f 939/1252/25 933/1237/25 934/1253/25 +f 939/1252/25 934/1253/25 935/1254/25 +f 935/1254/25 936/1255/25 939/1252/25 +f 939/1252/25 936/1255/25 937/1256/25 +f 939/1252/25 937/1256/25 938/1257/25 diff --git a/resources/variants/felixpro2_0.25.inst.cfg b/resources/variants/felixpro2_0.25.inst.cfg new file mode 100644 index 0000000000..9eb89502b6 --- /dev/null +++ b/resources/variants/felixpro2_0.25.inst.cfg @@ -0,0 +1,14 @@ +[general] +name = 0.25 mm +version = 4 +definition = felixpro2dual + +[metadata] +author = pnks +type = variant +setting_version = 9 +hardware_type = nozzle + +[values] +machine_nozzle_size = 0.25 +layer_height_0 = 0.15 diff --git a/resources/variants/felixpro2_0.35.inst.cfg b/resources/variants/felixpro2_0.35.inst.cfg new file mode 100644 index 0000000000..a4d0848f63 --- /dev/null +++ b/resources/variants/felixpro2_0.35.inst.cfg @@ -0,0 +1,14 @@ +[general] +name = 0.35 mm +version = 4 +definition = felixpro2dual + +[metadata] +author = pnks +type = variant +setting_version = 9 +hardware_type = nozzle + +[values] +machine_nozzle_size = 0.35 +layer_height_0 = 0.2 diff --git a/resources/variants/felixpro2_0.50.inst.cfg b/resources/variants/felixpro2_0.50.inst.cfg new file mode 100644 index 0000000000..2c7ff3bb6c --- /dev/null +++ b/resources/variants/felixpro2_0.50.inst.cfg @@ -0,0 +1,13 @@ +[general] +name = 0.50 mm +version = 4 +definition = felixpro2dual + +[metadata] +author = pnks +type = variant +setting_version = 9 +hardware_type = nozzle + +[values] +machine_nozzle_size = 0.5 diff --git a/resources/variants/felixpro2_0.70.inst.cfg b/resources/variants/felixpro2_0.70.inst.cfg new file mode 100644 index 0000000000..b5de103f9d --- /dev/null +++ b/resources/variants/felixpro2_0.70.inst.cfg @@ -0,0 +1,14 @@ +[general] +name = 0.70 mm +version = 4 +definition = felixpro2dual + +[metadata] +author = pnks +type = variant +setting_version = 9 +hardware_type = nozzle + +[values] +machine_nozzle_size = 0.70 +layer_height_0 = 0.4