Beispiel #1
0
    def _subscriptionMessages(self, messageCode, prod=None):
        notification_message = Message(lifetime=0)

        if messageCode == self.SubscriptionTypes.trialExpired:
            notification_message.setText(
                i18n_catalog.i18nc(
                    "@info:status",
                    "Your free trial has expired! Please subscribe to submit jobs."
                ))
        elif messageCode == self.SubscriptionTypes.subscriptionExpired:
            notification_message.setText(
                i18n_catalog.i18nc(
                    "@info:status",
                    "Your subscription has expired! Please renew your subscription to submit jobs."
                ))

        notification_message.addAction(
            action_id="subscribe_link",
            name="<h3><b>Manage Subscription</b></h3>",
            icon="",
            description="Click here to subscribe!",
            button_style=Message.ActionButtonStyle.LINK)

        notification_message.actionTriggered.connect(
            self._openSubscriptionPage)
        notification_message.show()
Beispiel #2
0
    def run(self) -> None:
        if not self.job_type:
            error_message = Message()
            error_message.setTitle("Smart Slice")
            error_message.setText(i18n_catalog.i18nc("@info:status", "Job type not set for processing:\nDon't know what to do!"))
            error_message.show()
            self.connector.cancelCurrentJob()

        Job.yieldThread()  # Should allow the UI to update earlier

        try:
            job = self.prepareJob()
            Logger.log("i", "Smart Slice job prepared")
        except SmartSliceCloudJob.JobException as exc:
            Logger.log("w", "Smart Slice job cannot be prepared: {}".format(exc.problem))

            self.setError(exc)
            return

        task = self.processCloudJob(job)

        try:
            os.remove(job)
        except:
            Logger.log("w", "Unable to remove temporary 3MF {}".format(job))

        if task and task.result:
            self._result = task.result
Beispiel #3
0
    def run(self) -> None:
        upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."), title = self.MESSAGE_TITLE, progress = -1)
        upload_message.show()
        CuraApplication.getInstance().processEvents()
        cura_api = CuraApplication.getInstance().getCuraAPI()
        self._backup_zip, backup_meta_data = cura_api.backups.createBackup()

        if not self._backup_zip or not backup_meta_data:
            self.backup_upload_error_message = catalog.i18nc("@info:backup_status", "There was an error while creating your backup.")
            upload_message.hide()
            return

        upload_message.setText(catalog.i18nc("@info:backup_status", "Uploading your backup..."))
        CuraApplication.getInstance().processEvents()

        # Create an upload entry for the backup.
        timestamp = datetime.now().isoformat()
        backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
        self._requestUploadSlot(backup_meta_data, len(self._backup_zip))

        self._job_done.wait()
        if self.backup_upload_error_message == "":
            upload_message.setText(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."))
            upload_message.setProgress(None)  # Hide progress bar
        else:
            # some error occurred. This error is presented to the user by DrivePluginExtension
            upload_message.hide()
Beispiel #4
0
    def checkQueuedNodes(self):
        for node in self._check_node_queue:
            tri_node = self._toTriMesh(node.getMeshData())
            if tri_node.is_watertight:
                continue

            file_name = node.getMeshData().getFileName()
            base_name = os.path.basename(file_name)

            if file_name in self._mesh_not_watertight_messages:
                self._mesh_not_watertight_messages[file_name].hide()

            message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
            body = catalog.i18nc(
                "@info:status",
                "Model %s is not watertight, and may not print properly."
            ) % base_name

            # XRayView may not be available if the plugin has been disabled
            if "XRayView" in self._controller.getAllViews(
            ) and self._controller.getActiveView().getPluginId() != "XRayView":
                body += " " + catalog.i18nc(
                    "@info:status",
                    "Check X-Ray View and repair the model before printing it."
                )
                message.addAction(
                    "X-Ray", catalog.i18nc("@action:button",
                                           "Show X-Ray View"), None, "")
                message.actionTriggered.connect(self._showXRayView)
            else:
                body += " " + catalog.i18nc(
                    "@info:status", "Repair the model before printing it.")

            message.setText(body)
            message.show()

            self._mesh_not_watertight_messages[file_name] = message

        self._check_node_queue = []
Beispiel #5
0
class MeshTools(
        Extension,
        QObject,
):
    def __init__(self, parent=None):
        QObject.__init__(self, parent)
        Extension.__init__(self)

        self._application = Application.getInstance()
        self._controller = self._application.getController()

        self._application.fileLoaded.connect(self._onFileLoaded)
        self._application.fileCompleted.connect(self._onFileCompleted)
        self._controller.getScene().sceneChanged.connect(self._onSceneChanged)

        self._currently_loading_files = []  #type: List[str]
        self._check_node_queue = []  #type: List[SceneNode]
        self._mesh_not_watertight_messages = {}  #type: Dict[str, Message]

        self.addMenuItem(catalog.i18nc("@item:inmenu", "Check models"),
                         self.checkMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Fix simple holes"),
                         self.fixSimpleHolesForMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Fix model normals"),
                         self.fixNormalsForMeshes)
        self.addMenuItem(
            catalog.i18nc("@item:inmenu", "Split model into parts"),
            self.splitMeshes)

        self._message = Message(
            title=catalog.i18nc("@info:title", "Mesh Tools"))

    def _onFileLoaded(self, file_name):
        self._currently_loading_files.append(file_name)

    def _onFileCompleted(self, file_name):
        if file_name in self._currently_loading_files:
            self._currently_loading_files.remove(file_name)

    def _onSceneChanged(self, node):
        if not node or not node.getMeshData():
            return

        # only check meshes that have just been loaded
        if node.getMeshData().getFileName(
        ) not in self._currently_loading_files:
            return

        # the scene may change multiple times while loading a mesh,
        # but we want to check the mesh only once
        if node not in self._check_node_queue:
            self._check_node_queue.append(node)
            self._application.callLater(self.checkQueuedNodes)

    def checkQueuedNodes(self):
        for node in self._check_node_queue:
            tri_node = self._toTriMesh(node.getMeshData())
            if tri_node.is_watertight:
                continue

            file_name = node.getMeshData().getFileName()
            base_name = os.path.basename(file_name)

            if file_name in self._mesh_not_watertight_messages:
                self._mesh_not_watertight_messages[file_name].hide()

            message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
            body = catalog.i18nc(
                "@info:status",
                "Model %s is not watertight, and may not print properly."
            ) % base_name

            # XRayView may not be available if the plugin has been disabled
            if "XRayView" in self._controller.getAllViews(
            ) and self._controller.getActiveView().getPluginId() != "XRayView":
                body += " " + catalog.i18nc(
                    "@info:status",
                    "Check X-Ray View and repair the model before printing it."
                )
                message.addAction(
                    "X-Ray", catalog.i18nc("@action:button",
                                           "Show X-Ray View"), None, "")
                message.actionTriggered.connect(self._showXRayView)
            else:
                body += " " + catalog.i18nc(
                    "@info:status", "Repair the model before printing it.")

            message.setText(body)
            message.show()

            self._mesh_not_watertight_messages[file_name] = message

        self._check_node_queue = []

    def _showXRayView(self, message, action):
        try:
            major_api_version = Application.getInstance().getAPIVersion(
            ).getMajor()
        except AttributeError:
            # UM.Application.getAPIVersion was added for API > 6 (Cura 4)
            # Since this plugin version is only compatible with Cura 3.5 and newer, it is safe to assume API 5
            major_api_version = 5

        if major_api_version >= 6:
            # in Cura 4, X-Ray view is in the preview stage
            self._controller.setActiveStage("PreviewStage")

        self._controller.setActiveView("XRayView")
        message.hide()

    def _getAllSelectedNodes(self):
        self._message.hide()
        selection = Selection.getAllSelectedObjects()[:]
        if selection:
            deep_selection = []
            for selected_node in selection:
                if selected_node.hasChildren():
                    deep_selection = deep_selection + selected_node.getAllChildren(
                    )
                if selected_node.getMeshData() != None:
                    deep_selection.append(selected_node)
            if deep_selection:
                return deep_selection

        self._message.setText(
            catalog.i18nc("@info:status",
                          "Please select one or more models first"))
        self._message.show()

    def checkMeshes(self):
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        message_body = catalog.i18nc("@info:status", "Check summary:")
        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            message_body = message_body + "\n - %s" % node.getName()
            if tri_node.is_watertight:
                message_body = message_body + " " + catalog.i18nc(
                    "@info:status", "is watertight")
            else:
                message_body = message_body + " " + catalog.i18nc(
                    "@info:status",
                    "is not watertight and may not print properly")
            if tri_node.body_count > 1:
                message_body = message_body + " " + catalog.i18nc(
                    "@info:status",
                    "and consists of {body_count} submeshes").format(
                        body_count=tri_node.body_count)

        self._message.setText(message_body)
        self._message.show()

    def fixSimpleHolesForMeshes(self):
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            success = tri_node.fill_holes()
            self._replaceSceneNode(node, [tri_node])
            if not success:
                self._message.setText(
                    catalog.i18nc(
                        "@info:status",
                        "The mesh needs more extensive repair to become watertight"
                    ))
                self._message.show()

    def fixNormalsForMeshes(self):
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            tri_node.fix_normals()
            self._replaceSceneNode(node, [tri_node])

    def splitMeshes(self):
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        message_body = catalog.i18nc("@info:status", "Split result:")
        for node in nodes_list:
            message_body = message_body + "\n - %s" % node.getName()
            tri_node = self._toTriMesh(node.getMeshData())
            if tri_node.body_count > 1:
                self._replaceSceneNode(node,
                                       tri_node.split(only_watertight=False))
                message_body = message_body + " " + catalog.i18nc(
                    "@info:status",
                    "was split in %d submeshes") % tri_node.body_count
            else:
                message_body = message_body + " " + catalog.i18nc(
                    "@info:status", "could not be split into submeshes")

        self._message.setText(message_body)
        self._message.show()

    def _replaceSceneNode(self, existing_node, trimeshes):
        name = existing_node.getName()
        file_name = existing_node.getMeshData().getFileName()
        transformation = existing_node.getWorldTransformation()
        parent = existing_node.getParent()
        extruder_id = existing_node.callDecoration("getActiveExtruder")
        build_plate = existing_node.callDecoration("getBuildPlateNumber")
        selected = Selection.isSelected(existing_node)

        children = existing_node.getChildren()
        new_nodes = []

        op = GroupedOperation()
        op.addOperation(RemoveSceneNodeOperation(existing_node))

        for i, tri_node in enumerate(trimeshes):
            mesh_data = self._toMeshData(tri_node)

            new_node = CuraSceneNode()
            new_node.setSelectable(True)
            new_node.setMeshData(mesh_data)
            new_node.setName(name if i == 0 else "%s %d" % (name, i))
            new_node.callDecoration("setActiveExtruder", extruder_id)
            new_node.addDecorator(BuildPlateDecorator(build_plate))
            new_node.addDecorator(SliceableObjectDecorator())

            op.addOperation(AddSceneNodeOperation(new_node, parent))
            op.addOperation(
                SetTransformMatrixOperation(new_node, transformation))

            new_nodes.append(new_node)

            if selected:
                Selection.add(new_node)

        for child in children:
            child_bounding_box = child.getMeshData().getTransformed(
                child.getWorldTransformation()).getExtents()
            new_parent = None
            for potential_parent in new_nodes:
                parent_bounding_box = potential_parent.getMeshData(
                ).getTransformed(
                    potential_parent.getWorldTransformation()).getExtents()
                if child_bounding_box.intersectsBox(
                        parent_bounding_box
                ) != AxisAlignedBox.IntersectionResult.NoIntersection:
                    new_parent = potential_parent
                    break
            if not new_parent:
                new_parent = new_nodes[0]
            op.addOperation(SetParentOperationSimplified(child, new_parent))

        op.push()

    def _toTriMesh(self, mesh_data: MeshData) -> trimesh.base.Trimesh:
        indices = mesh_data.getIndices()
        if indices is None:
            # some file formats (eg 3mf) don't supply indices, but have unique vertices per face
            indices = numpy.arange(mesh_data.getVertexCount()).reshape(-1, 3)

        return trimesh.base.Trimesh(vertices=mesh_data.getVertices(),
                                    faces=indices,
                                    vertex_normals=mesh_data.getNormals())

    def _toMeshData(self, tri_node: trimesh.base.Trimesh) -> MeshData:
        tri_faces = tri_node.faces
        tri_vertices = tri_node.vertices

        indices = []
        vertices = []

        index_count = 0
        face_count = 0
        for tri_face in tri_faces:
            face = []
            for tri_index in tri_face:
                vertices.append(tri_vertices[tri_index])
                face.append(index_count)
                index_count += 1
            indices.append(face)
            face_count += 1

        vertices = numpy.asarray(vertices, dtype=numpy.float32)
        indices = numpy.asarray(indices, dtype=numpy.int32)
        normals = calculateNormalsFromIndexedVertices(vertices, indices,
                                                      face_count)

        mesh_data = MeshData(vertices=vertices,
                             indices=indices,
                             normals=normals)
        return mesh_data
Beispiel #6
0
class SmartSliceAPIClient(QObject):
    class ConnectionErrorCodes(Enum):
        genericInternetConnectionError = 1
        loginCredentialsError = 2

    def __init__(self, connector):
        super().__init__()
        self._client = None
        self.connector = connector
        self.extension = connector.extension
        self._token = None
        self._error_message = None

        self._number_of_timeouts = 20
        self._timeout_sleep = 3

        self._username_preference = "smartslice/username"
        self._app_preferences = Application.getInstance().getPreferences()

        #Login properties
        self._login_username = ""
        self._login_password = ""
        self._badCredentials = False

        self._plugin_metadata = connector.extension.metadata

    # If the user has logged in before, we will hold on to the email. If they log out, or
    #   the login is unsuccessful, the email will not persist.
    def _usernamePreferenceExists(self):
        username = self._app_preferences.getValue(self._username_preference)
        if username is not None and username != "" and self._login_username == "":
            self._login_username = username
        else:
            self._app_preferences.addPreference(self._username_preference, "")

    @property
    def _token_file_path(self):
        config_path = os.path.join(
            QStandardPaths.writableLocation(QStandardPaths.GenericConfigLocation), "smartslice"
        )

        if not os.path.exists(config_path):
            os.makedirs(config_path)

        return os.path.join(config_path, ".token")

    # Opening a connection is our main goal with the API client object. We get the address to connect to,
    #   then we check to see if the user has a valid token, if they are already logged in. If not, we log
    #   them in.
    def openConnection(self):
        url = urlparse(self._plugin_metadata.url)

        protocol = url.scheme
        hostname = url.hostname
        if url.port:
            port = url.port
        else:
            port = 443

        self._usernamePreferenceExists()

        if type(port) is not int:
            port = int(port)

        self._client = pywim.http.thor.Client(
            protocol=protocol,
            hostname=hostname,
            port=port,
            cluster=self._plugin_metadata.cluster
        )

        # To ensure that the user is tracked and has a proper subscription, we let them login and then use the token we recieve
        # to track them and their login status.
        self._getToken()

        #If there is a token, ensure it is a valid token
        self._checkToken()

        #If invalid token, attempt to Login.
        if not self.logged_in:
            self.loggedInChanged.emit()
            self._login()

        #If now a valid token, allow access
        if self.logged_in:
            self.loggedInChanged.emit()

        Logger.log("d", "SmartSlice HTTP Client: {}".format(self._client.address))

    def _connectionCheck(self):
        try:
            self._client.info()
        except Exception as error:
            Logger.log("e", "An error has occured checking the internet connection: {}".format(error))
            return (self.ConnectionErrorCodes.genericInternetConnectionError)

        return None

    # API calls need to be executed through this function using a lambda passed in, as well as a failure code.
    #  This prevents a fatal crash of Cura in some circumstances, as well as allows for a timeout/retry system.
    #  The failure codes give us better control over the messages that come from an internet disconnect issue.
    def executeApiCall(self, endpoint: Callable[[], Tuple[int, object]], failure_code):
        api_code = self._connectionCheck()
        timeout_counter = 0
        self.clearErrorMessage()

        if api_code is not None:
            return api_code, None

        while api_code is None and timeout_counter < self._number_of_timeouts:
            try:
                api_code, api_result = endpoint()
            except Exception as error:
                # If this error occurs, there was a connection issue
                Logger.log("e", "An error has occured with an API call: {}".format(error))
                timeout_counter += 1
                time.sleep(self._timeout_sleep)

            if timeout_counter == self._number_of_timeouts:
                return failure_code, None

        self.clearErrorMessage()

        return api_code, api_result

    def clearErrorMessage(self):
        if self._error_message is not None:
            self._error_message.hide()
            self._error_message = None

    # Login is fairly simple, the email and password is pulled from the Login popup that is displayed
    #   on the Cura stage, and then sent to the API.
    def _login(self):
        username = self._login_username
        password = self._login_password

        if self._token is None:
            self.loggedInChanged.emit()

        if password != "":
            api_code, user_auth = self.executeApiCall(
                lambda: self._client.basic_auth_login(username, password),
                self.ConnectionErrorCodes.loginCredentialsError
            )

            if api_code != 200:
                # If we get bad login credentials, this will set the flag that alerts the user on the popup
                if api_code == 400:
                    Logger.log("d", "API Code 400")
                    self.badCredentials = True
                    self._login_password = ""
                    self.badCredentialsChanged.emit()
                    self._token = None

                else:
                    self._handleThorErrors(api_code, user_auth)

            # If all goes well, we will be able to store the login token for the user
            else:
                self.clearErrorMessage()
                self.badCredentials = False
                self._login_password = ""
                self._app_preferences.setValue(self._username_preference, username)
                self._token = self._client.get_token()
                self._createTokenFile()

    # Logout removes the current token, clears the last logged in username and signals the popup to reappear.
    def logout(self):
        self._token = None
        self._login_password = ""
        self._createTokenFile()
        self._app_preferences.setValue(self._username_preference, "")
        self.loggedInChanged.emit()

    # If our user has logged in before, their login token will be in the file.
    def _getToken(self):
        #TODO: If no token file, try to login and create one. For now, we will just create a token file.
        if not os.path.exists(self._token_file_path):
            self._token = None
        else:
            try:
                with open(self._token_file_path, "r") as token_file:
                    self._token = json.load(token_file)
            except:
                Logger.log("d", "Unable to read Token JSON")
                self._token = None

            if self._token == "" or self._token is None:
                self._token = None

    # Once we have pulled the token, we want to check with the API to make sure the token is valid.
    def _checkToken(self):
        self._client.set_token(self._token)
        api_code, api_result = self.executeApiCall(
            lambda: self._client.whoami(),
            self.ConnectionErrorCodes.loginCredentialsError
        )

        if api_code != 200:
            self._token = None
            self._createTokenFile()

    # If there is no token in the file, or the file does not exist, we create one.
    def _createTokenFile(self):
        with open(self._token_file_path, "w") as token_file:
            json.dump(self._token, token_file)

    def getSubscription(self):
        api_code, api_result = self.executeApiCall(
            lambda: self._client.smartslice_subscription(),
            self.ConnectionErrorCodes.genericInternetConnectionError
        )

        if api_code != 200:
            self._handleThorErrors(api_code, api_result)
            return None

        return api_result

    def cancelJob(self, job_id):
        api_code, api_result = self.executeApiCall(
            lambda: self._client.smartslice_job_abort(job_id),
            self.ConnectionErrorCodes.genericInternetConnectionError
        )

        if api_code != 200:
            self._handleThorErrors(api_code, api_result)

    # If the user is correctly logged in, and has a valid token, we can use the 3mf data from
    #    the plugin to submit a job to the API, and the results will be handled when they are returned.
    def submitSmartSliceJob(self, cloud_job, threemf_data):
        thor_status_code, task = self.executeApiCall(
            lambda: self._client.new_smartslice_job(threemf_data),
            self.ConnectionErrorCodes.genericInternetConnectionError
        )

        job_status_tracker = JobStatusTracker(self.connector, self.connector.status)

        Logger.log("d", "API Status after posting: {}".format(thor_status_code))

        if thor_status_code != 200:
            self._handleThorErrors(thor_status_code, task)
            self.connector.cancelCurrentJob()

        if getattr(task, 'status', None):
            Logger.log("d", "Job status after posting: {}".format(task.status))

        # While the task status is not finished/failed/crashed/aborted continue to
        # wait on the status using the API.
        thor_status_code = None
        while thor_status_code != self.ConnectionErrorCodes.genericInternetConnectionError and not cloud_job.canceled and task.status not in (
            pywim.http.thor.JobInfo.Status.failed,
            pywim.http.thor.JobInfo.Status.crashed,
            pywim.http.thor.JobInfo.Status.aborted,
            pywim.http.thor.JobInfo.Status.finished
        ):

            self.job_status = task.status
            cloud_job.api_job_id = task.id

            thor_status_code, task = self.executeApiCall(
                lambda: self._client.smartslice_job_wait(task.id, callback=job_status_tracker),
                self.ConnectionErrorCodes.genericInternetConnectionError
            )

            if thor_status_code == 200:
                thor_status_code, task = self.executeApiCall(
                    lambda: self._client.smartslice_job_wait(task.id, callback=job_status_tracker),
                    self.ConnectionErrorCodes.genericInternetConnectionError
                )

            if thor_status_code not in (200, None):
                self._handleThorErrors(thor_status_code, task)
                self.connector.cancelCurrentJob()

        if not cloud_job.canceled:
            self.connector.propertyHandler._cancelChanges = False

            if task.status == pywim.http.thor.JobInfo.Status.failed:
                error_message = Message()
                error_message.setTitle("Smart Slice Solver")
                error_message.setText(i18n_catalog.i18nc(
                    "@info:status",
                    "Error while processing the job:\n{}".format(task.errors[0].message)
                ))
                error_message.show()

                self.connector.cancelCurrentJob()
                cloud_job.setError(SmartSliceCloudJob.JobException(error_message.getText()))

                Logger.log(
                    "e",
                    "An error occured while sending and receiving cloud job: {}".format(error_message.getText())
                )
                self.connector.propertyHandler._cancelChanges = False
                return None
            elif task.status == pywim.http.thor.JobInfo.Status.finished:
                return task
            elif len(task.errors) > 0:
                error_message = Message()
                error_message.setTitle("Smart Slice Solver")
                error_message.setText(i18n_catalog.i18nc(
                    "@info:status",
                    "Unexpected status occured:\n{}".format(task.errors[0].message)
                ))
                error_message.show()

                self.connector.cancelCurrentJob()
                cloud_job.setError(SmartSliceCloudJob.JobException(error_message.getText()))

                Logger.log(
                    "e",
                    "An unexpected status occured while sending and receiving cloud job: {}".format(error_message.getText())
                )
                self.connector.propertyHandler._cancelChanges = False
                return None


    # When something goes wrong with the API, the errors are sent here. The http_error_code is an int that indicates
    #   the problem that has occurred. The returned object may hold additional information about the error, or it may be None.
    def _handleThorErrors(self, http_error_code, returned_object):
        if self._error_message is not None:
            self._error_message.hide()

        self._error_message = Message(lifetime= 180)
        self._error_message.setTitle("Smart Slice API")

        if http_error_code == 400:
            if returned_object.error.startswith('User\'s maximum job queue count reached'):
                print(self._error_message.getActions())
                self._error_message.setTitle("")
                self._error_message.setText("You have exceeded the maximum allowable "
                                      "number of queued\n jobs. Please cancel a "
                                      "queued job or wait for your queue to clear.")
                self._error_message.addAction(
                    "continue",
                    i18n_catalog.i18nc("@action", "Ok"),
                    "", ""
                )
                self._error_message.actionTriggered.connect(self.errorMessageAction)
            else:
                self._error_message.setText(i18n_catalog.i18nc("@info:status", "SmartSlice Server Error (400: Bad Request):\n{}".format(returned_object.error)))
        elif http_error_code == 401:
            self._error_message.setText(i18n_catalog.i18nc("@info:status", "SmartSlice Server Error (401: Unauthorized):\nAre you logged in?"))
        elif http_error_code == 429:
            self._error_message.setText(i18n_catalog.i18nc("@info:status", "SmartSlice Server Error (429: Too Many Attempts)"))
        elif http_error_code == self.ConnectionErrorCodes.genericInternetConnectionError:
            self._error_message.setText(i18n_catalog.i18nc("@info:status", "Internet connection issue:\nPlease check your connection and try again."))
        elif http_error_code == self.ConnectionErrorCodes.loginCredentialsError:
            self._error_message.setText(i18n_catalog.i18nc("@info:status", "Internet connection issue:\nCould not verify your login credentials."))
        else:
            self._error_message.setText(i18n_catalog.i18nc("@info:status", "SmartSlice Server Error (HTTP Error: {})".format(http_error_code)))
        self._error_message.show()

    @staticmethod
    def errorMessageAction(msg, action):
        msg.hide()

    @pyqtSlot()
    def onLoginButtonClicked(self):
        self.openConnection()

    @pyqtProperty(str, constant=True)
    def smartSliceUrl(self):
        return self._plugin_metadata.url

    badCredentialsChanged = pyqtSignal()
    loggedInChanged = pyqtSignal()

    @pyqtProperty(bool, notify=loggedInChanged)
    def logged_in(self):
        return self._token is not None

    @pyqtProperty(str, constant=True)
    def loginUsername(self):
        return self._login_username

    @loginUsername.setter
    def loginUsername(self,value):
        self._login_username = value

    @pyqtProperty(str, constant=True)
    def loginPassword(self):
        return self._login_password

    @loginPassword.setter
    def loginPassword(self,value):
        self._login_password = value

    @pyqtProperty(bool, notify=badCredentialsChanged)
    def badCredentials(self):
        return self._badCredentials

    @badCredentials.setter
    def badCredentials(self, value):
        self._badCredentials = value
        self.badCredentialsChanged.emit()
Beispiel #7
0
    def checkQueuedNodes(self) -> None:
        global_container_stack = self._application.getGlobalContainerStack()
        if global_container_stack:
            disallowed_edge = self._application.getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
            max_x_coordinate = (global_container_stack.getProperty("machine_width", "value") / 2) - disallowed_edge
            max_y_coordinate = (global_container_stack.getProperty("machine_depth", "value") / 2) - disallowed_edge

        for node in self._node_queue:
            mesh_data = node.getMeshData()
            if not mesh_data:
                continue
            file_name = mesh_data.getFileName()

            if self._preferences.getValue("meshtools/randomise_location_on_load") and global_container_stack != None:
                if file_name and os.path.splitext(file_name)[1].lower() == ".3mf": # don't randomise project files
                    continue

                node_bounds = node.getBoundingBox()
                position = self._randomLocation(node_bounds, max_x_coordinate, max_y_coordinate)
                node.setPosition(position)

            if (
                self._preferences.getValue("meshtools/check_models_on_load") or
                self._preferences.getValue("meshtools/fix_normals_on_load") or
                self._preferences.getValue("meshtools/model_unit_factor") != 1
            ):

                tri_node = self._toTriMesh(mesh_data)

            if self._preferences.getValue("meshtools/model_unit_factor") != 1:
                if file_name and os.path.splitext(file_name)[1].lower() not in [".stl", ".obj", ".ply"]:
                    # only resize models that don't have an intrinsic unit set
                    continue

                scale_matrix = Matrix()
                scale_matrix.setByScaleFactor(float(self._preferences.getValue("meshtools/model_unit_factor")))
                tri_node.apply_transform(scale_matrix.getData())

                self._replaceSceneNode(node, [tri_node])

            if self._preferences.getValue("meshtools/check_models_on_load") and not tri_node.is_watertight:
                if not file_name:
                    file_name = catalog.i18nc("@text Print job name", "Untitled")
                base_name = os.path.basename(file_name)

                if file_name in self._mesh_not_watertight_messages:
                    self._mesh_not_watertight_messages[file_name].hide()

                message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
                body = catalog.i18nc("@info:status", "Model %s is not watertight, and may not print properly.") % base_name

                # XRayView may not be available if the plugin has been disabled
                active_view = self._controller.getActiveView()
                if active_view and "XRayView" in self._controller.getAllViews() and active_view.getPluginId() != "XRayView":
                    body += " " + catalog.i18nc("@info:status", "Check X-Ray View and repair the model before printing it.")
                    message.addAction("X-Ray", catalog.i18nc("@action:button", "Show X-Ray View"), "", "")
                    message.actionTriggered.connect(self._showXRayView)
                else:
                    body += " " +catalog.i18nc("@info:status", "Repair the model before printing it.")

                message.setText(body)
                message.show()

                self._mesh_not_watertight_messages[file_name] = message

            if self._preferences.getValue("meshtools/fix_normals_on_load") and tri_node.is_watertight:
                tri_node.fix_normals()
                self._replaceSceneNode(node, [tri_node])

        self._node_queue = []
Beispiel #8
0
class MeshTools(Extension, QObject,):
    def __init__(self, parent = None) -> None:
        QObject.__init__(self, parent)
        Extension.__init__(self)

        self._application = CuraApplication.getInstance()
        self._controller = self._application.getController()

        self._application.engineCreatedSignal.connect(self._onEngineCreated)
        self._application.fileLoaded.connect(self._onFileLoaded)
        self._application.fileCompleted.connect(self._onFileCompleted)
        self._controller.getScene().sceneChanged.connect(self._onSceneChanged)

        self._currently_loading_files = [] #type: List[str]
        self._node_queue = [] #type: List[SceneNode]
        self._mesh_not_watertight_messages = {} #type: Dict[str, Message]

        self._settings_dialog = None
        self._rename_dialog = None

        self._preferences = self._application.getPreferences()
        self._preferences.addPreference("meshtools/check_models_on_load", True)
        self._preferences.addPreference("meshtools/fix_normals_on_load", False)
        self._preferences.addPreference("meshtools/randomise_location_on_load", False)
        self._preferences.addPreference("meshtools/model_unit_factor", 1)

        self.addMenuItem(catalog.i18nc("@item:inmenu", "Reload model"), self.reloadMesh)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Rename model..."), self.renameMesh)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Replace models..."), self.replaceMeshes)
        self.addMenuItem("", lambda: None)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Check models"), self.checkMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Analyse models"), self.analyseMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Fix simple holes"), self.fixSimpleHolesForMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Fix model normals"), self.fixNormalsForMeshes)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Split model into parts"), self.splitMeshes)
        self.addMenuItem(" ", lambda: None)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Randomise location"), self.randomiseMeshLocation)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Apply transformations to mesh"), self.bakeMeshTransformation)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Reset origin to center of mesh"), self.resetMeshOrigin)
        self.addMenuItem("  ", lambda: None)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Mesh Tools settings..."), self.showSettingsDialog)

        self._message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
        self._additional_menu = None  # type: Optional[QObject]

    def showSettingsDialog(self) -> None:
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qml", "SettingsDialog.qml")

        self._settings_dialog = self._application.createQmlComponent(path, {"manager": self})
        if self._settings_dialog:
            self._settings_dialog.show()

    def _onEngineCreated(self) -> None:
        # To add items to the ContextMenu, we need access to the QML engine
        # There is no way to access the context menu directly, so we have to search for it
        main_window = self._application.getMainWindow()
        if not main_window:
            return

        context_menu = None
        for child in main_window.contentItem().children():
            try:
                test = child.findItemIndex # only ContextMenu has a findItemIndex function
                context_menu = child
                break
            except:
                pass

        if not context_menu:
            return

        Logger.log("d", "Inserting item in context menu")
        context_menu.insertSeparator(0)
        context_menu.insertMenu(0, catalog.i18nc("@info:title", "Mesh Tools"))

        qml_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qml", "MeshToolsMenu.qml")
        self._additional_menu = self._application.createQmlComponent(qml_path, {"manager": self})
        if not self._additional_menu:
            return
        # Move additional menu items into context menu
        # This is handled in QML, because PyQt does not handle QtQuick1 objects very well
        self._additional_menu.moveToContextMenu(context_menu, 0)

    def _onFileLoaded(self, file_name) -> None:
        self._currently_loading_files.append(file_name)

    def _onFileCompleted(self, file_name) -> None:
        if file_name in self._currently_loading_files:
            self._currently_loading_files.remove(file_name)

    def _onSceneChanged(self, node) -> None:
        if not node or not node.getMeshData():
            return

        # only check meshes that have just been loaded
        if node.getMeshData().getFileName() not in self._currently_loading_files:
            return

        # the scene may change multiple times while loading a mesh,
        # but we want to process the mesh only once
        if node not in self._node_queue:
            self._node_queue.append(node)
            self._application.callLater(self.checkQueuedNodes)

    def checkQueuedNodes(self) -> None:
        global_container_stack = self._application.getGlobalContainerStack()
        if global_container_stack:
            disallowed_edge = self._application.getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
            max_x_coordinate = (global_container_stack.getProperty("machine_width", "value") / 2) - disallowed_edge
            max_y_coordinate = (global_container_stack.getProperty("machine_depth", "value") / 2) - disallowed_edge

        for node in self._node_queue:
            mesh_data = node.getMeshData()
            if not mesh_data:
                continue
            file_name = mesh_data.getFileName()

            if self._preferences.getValue("meshtools/randomise_location_on_load") and global_container_stack != None:
                if file_name and os.path.splitext(file_name)[1].lower() == ".3mf": # don't randomise project files
                    continue

                node_bounds = node.getBoundingBox()
                position = self._randomLocation(node_bounds, max_x_coordinate, max_y_coordinate)
                node.setPosition(position)

            if (
                self._preferences.getValue("meshtools/check_models_on_load") or
                self._preferences.getValue("meshtools/fix_normals_on_load") or
                self._preferences.getValue("meshtools/model_unit_factor") != 1
            ):

                tri_node = self._toTriMesh(mesh_data)

            if self._preferences.getValue("meshtools/model_unit_factor") != 1:
                if file_name and os.path.splitext(file_name)[1].lower() not in [".stl", ".obj", ".ply"]:
                    # only resize models that don't have an intrinsic unit set
                    continue

                scale_matrix = Matrix()
                scale_matrix.setByScaleFactor(float(self._preferences.getValue("meshtools/model_unit_factor")))
                tri_node.apply_transform(scale_matrix.getData())

                self._replaceSceneNode(node, [tri_node])

            if self._preferences.getValue("meshtools/check_models_on_load") and not tri_node.is_watertight:
                if not file_name:
                    file_name = catalog.i18nc("@text Print job name", "Untitled")
                base_name = os.path.basename(file_name)

                if file_name in self._mesh_not_watertight_messages:
                    self._mesh_not_watertight_messages[file_name].hide()

                message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
                body = catalog.i18nc("@info:status", "Model %s is not watertight, and may not print properly.") % base_name

                # XRayView may not be available if the plugin has been disabled
                active_view = self._controller.getActiveView()
                if active_view and "XRayView" in self._controller.getAllViews() and active_view.getPluginId() != "XRayView":
                    body += " " + catalog.i18nc("@info:status", "Check X-Ray View and repair the model before printing it.")
                    message.addAction("X-Ray", catalog.i18nc("@action:button", "Show X-Ray View"), "", "")
                    message.actionTriggered.connect(self._showXRayView)
                else:
                    body += " " +catalog.i18nc("@info:status", "Repair the model before printing it.")

                message.setText(body)
                message.show()

                self._mesh_not_watertight_messages[file_name] = message

            if self._preferences.getValue("meshtools/fix_normals_on_load") and tri_node.is_watertight:
                tri_node.fix_normals()
                self._replaceSceneNode(node, [tri_node])

        self._node_queue = []

    def _showXRayView(self, message, action) -> None:
        try:
            major_api_version = self._application.getAPIVersion().getMajor()
        except AttributeError:
            # UM.Application.getAPIVersion was added for API > 6 (Cura 4)
            # Since this plugin version is only compatible with Cura 3.5 and newer, it is safe to assume API 5
            major_api_version = 5

        if major_api_version >= 6 and "SidebarGUIPlugin" not in PluginRegistry.getInstance().getActivePlugins():
            # in Cura 4.x, X-Ray view is in the preview stage
            self._controller.setActiveStage("PreviewStage")
        else:
            # in Cura 3.x, and in 4.x with the Sidebar GUI Plugin, X-Ray view is in the prepare stage
            self._controller.setActiveStage("PrepareStage")

        self._controller.setActiveView("XRayView")
        message.hide()

    def _getSelectedNodes(self, force_single = False) -> List[SceneNode]:
        self._message.hide()
        selection = Selection.getAllSelectedObjects()[:]
        if force_single:
            if len(selection) == 1:
                return selection[:]

            self._message.setText(catalog.i18nc("@info:status", "Please select a single model first"))
        else:
            if len(selection) >= 1:
                return selection[:]

            self._message.setText(catalog.i18nc("@info:status", "Please select one or more models first"))

        self._message.show()
        return []

    def _getAllSelectedNodes(self) -> List[SceneNode]:
        self._message.hide()
        selection = Selection.getAllSelectedObjects()[:]
        if selection:
            deep_selection = []  # type: List[SceneNode]
            for selected_node in selection:
                if selected_node.hasChildren():
                    deep_selection = deep_selection + selected_node.getAllChildren()
                if selected_node.getMeshData() != None:
                    deep_selection.append(selected_node)
            if deep_selection:
                return deep_selection

        self._message.setText(catalog.i18nc("@info:status", "Please select one or more models first"))
        self._message.show()

        return []

    @pyqtSlot()
    def checkMeshes(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        message_body = catalog.i18nc("@info:status", "Check summary:")
        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            message_body = message_body + "\n - %s" % node.getName()
            if tri_node.is_watertight:
                message_body = message_body + " " + catalog.i18nc("@info:status", "is watertight")
            else:
                message_body = message_body + " " + catalog.i18nc("@info:status", "is not watertight and may not print properly")
            if tri_node.body_count > 1:
                message_body = message_body + " " + catalog.i18nc("@info:status", "and consists of {body_count} submeshes").format(body_count = tri_node.body_count)

        self._message.setText(message_body)
        self._message.show()

    @pyqtSlot()
    def analyseMeshes(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        message_body = catalog.i18nc("@info:status", "Analysis summary:")
        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshDataTransformed())
            message_body = message_body + "\n - %s:" % node.getName()
            message_body += "\n\t" + "%d vertices, %d faces" % (len(tri_node.vertices), len(tri_node.faces))
            if tri_node.is_watertight:
                message_body += "\n\t" + "area: %d mm2, volume: %d mm3" % (tri_node.area, tri_node.volume)

        self._message.setText(message_body)
        self._message.show()

    @pyqtSlot()
    def fixSimpleHolesForMeshes(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            success = tri_node.fill_holes()
            self._replaceSceneNode(node, [tri_node])
            if not success:
                self._message.setText(catalog.i18nc("@info:status", "The mesh needs more extensive repair to become watertight"))
                self._message.show()

    @pyqtSlot()
    def fixNormalsForMeshes(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        for node in nodes_list:
            tri_node = self._toTriMesh(node.getMeshData())
            tri_node.fix_normals()
            self._replaceSceneNode(node, [tri_node])

    @pyqtSlot()
    def splitMeshes(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        message_body = catalog.i18nc("@info:status", "Split result:")
        for node in nodes_list:
            message_body = message_body + "\n - %s" % node.getName()
            tri_node = self._toTriMesh(node.getMeshData())
            if tri_node.body_count > 1:
                self._replaceSceneNode(node, tri_node.split(only_watertight=False))
                message_body = message_body + " " + catalog.i18nc("@info:status", "was split in %d submeshes") % tri_node.body_count
            else:
                message_body = message_body + " " + catalog.i18nc("@info:status", "could not be split into submeshes")

        self._message.setText(message_body)
        self._message.show()

    @pyqtSlot()
    def replaceMeshes(self) -> None:
        self._node_queue = self._getSelectedNodes()
        if not self._node_queue:
            return

        for node in self._node_queue:
            mesh_data = node.getMeshData()
            if not mesh_data:
                self._message.setText(catalog.i18nc("@info:status", "Replacing a group is not supported"))
                self._message.show()
                self._node_queue = [] #type: List[SceneNode]
                return

        options = QFileDialog.Options()
        if sys.platform == "linux" and "KDE_FULL_SESSION" in os.environ:
            options |= QFileDialog.DontUseNativeDialog
        filter_types = ";;".join(self._application.getMeshFileHandler().supportedReadFileTypes)

        directory = None  # type: Optional[str]
        mesh_data = self._node_queue[0].getMeshData()
        if mesh_data:
            directory = mesh_data.getFileName()
        if not directory:
            directory = self._application.getDefaultPath("dialog_load_path").toLocalFile()

        file_name, _ = QFileDialog.getOpenFileName(
            parent=None,
            caption=catalog.i18nc("@title:window", "Select Replacement Mesh File"),
            directory=directory, options=options, filter=filter_types
        )
        if not file_name:
            self._node_queue = [] #type: List[SceneNode]
            return

        job = ReadMeshJob(file_name)
        job.finished.connect(self._readMeshFinished)
        job.start()

    @pyqtSlot()
    def renameMesh(self) -> None:
        self._node_queue = self._getSelectedNodes(force_single=True)
        if not self._node_queue:
            return

        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "qml", "RenameDialog.qml")
        self._rename_dialog = self._application.createQmlComponent(path, {"manager": self})
        if not self._rename_dialog:
            return
        self._rename_dialog.show()
        self._rename_dialog.setName(self._node_queue[0].getName())

    @pyqtSlot(str)
    def setSelectedMeshName(self, new_name:str) -> None:
        node = self._node_queue[0]
        node.setName(new_name)
        Selection.remove(node)
        Selection.add(node)

    @pyqtSlot()
    def reloadMesh(self) -> None:
        self._node_queue = self._getSelectedNodes(force_single=True)
        if not self._node_queue:
            return

        mesh_data = self._node_queue[0].getMeshData()
        if not mesh_data:
            self._message.setText(catalog.i18nc("@info:status", "Reloading a group is not supported"))
            self._message.show()
            self._node_queue = [] #type: List[SceneNode]
            return

        file_name = mesh_data.getFileName()
        if not file_name:
            self._message.setText(catalog.i18nc("@info:status", "No link to the original file was found"))
            self._message.show()
            self._node_queue = [] #type: List[SceneNode]
            return

        job = ReadMeshJob(file_name)
        job.finished.connect(self._readMeshFinished)
        job.start()

    def _readMeshFinished(self, job) -> None:
        job_result = job.getResult()
        if len(job_result) == 0:
            self._message.setText(catalog.i18nc("@info:status", "Failed to load mesh"))
            self._message.show()
            self._node_queue = [] #type: List[SceneNode]
            return

        mesh_data = job_result[0].getMeshData()
        if not mesh_data:
            self._message.setText(catalog.i18nc("@info:status", "Replacing meshes with a group of meshes is not supported"))
            self._message.show()
            self._node_queue = [] #type: List[SceneNode]
            return

        file_name = mesh_data.getFileName()
        if file_name:
            mesh_name = os.path.basename(file_name)
        else:
            mesh_name = catalog.i18nc("@text Print job name", "Untitled")

        has_merged_nodes = False

        op = GroupedOperation()
        for node in self._node_queue:
            op.addOperation(SetMeshDataAndNameOperation(node, mesh_data, mesh_name))

            if not isinstance(node, CuraSceneNode) or not node.getMeshData():
                if node.getName() == "MergedMesh":
                    has_merged_nodes = True
        op.push()

        if has_merged_nodes:
            self._application.updateOriginOfMergedMeshes(None)

        self._node_queue = [] #type: List[SceneNode]

    @pyqtSlot()
    def randomiseMeshLocation(self) -> None:
        nodes_list = self._getAllSelectedNodes()
        if not nodes_list:
            return

        global_container_stack = self._application.getGlobalContainerStack()
        if not global_container_stack:
            return

        disallowed_edge = self._application.getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
        max_x_coordinate = (global_container_stack.getProperty("machine_width", "value") / 2) - disallowed_edge
        max_y_coordinate = (global_container_stack.getProperty("machine_depth", "value") / 2) - disallowed_edge

        op = GroupedOperation()
        for node in nodes_list:
            node_bounds = node.getBoundingBox()
            position = self._randomLocation(node_bounds, max_x_coordinate, max_y_coordinate)
            op.addOperation(SetTransformOperation(node, translation=position))
        op.push()

    def _randomLocation(self, node_bounds, max_x_coordinate, max_y_coordinate):
        return Vector(
            (2 * random.random() - 1) * (max_x_coordinate - (node_bounds.width / 2)),
            node_bounds.height / 2,
            (2 * random.random() - 1) * (max_y_coordinate - (node_bounds.depth / 2))
        )

    @pyqtSlot()
    def bakeMeshTransformation(self) -> None:
        nodes_list = self._getSelectedNodes()
        if not nodes_list:
            return

        op = GroupedOperation()
        for node in nodes_list:
            mesh_data = node.getMeshData()
            if not mesh_data:
                continue
            mesh_name = node.getName()
            if not mesh_name:
                file_name = mesh_data.getFileName()
                if not file_name:
                    file_name = ""
                mesh_name = os.path.basename(file_name)
                if not mesh_name:
                    mesh_name = catalog.i18nc("@text Print job name", "Untitled")

            local_transformation = node.getLocalTransformation()
            position = local_transformation.getTranslation()
            local_transformation.setTranslation(Vector(0,0,0))
            transformed_mesh_data = mesh_data.getTransformed(local_transformation)
            new_transformation = Matrix()
            new_transformation.setTranslation(position)

            op.addOperation(SetMeshDataAndNameOperation(node, transformed_mesh_data, mesh_name))
            op.addOperation(SetTransformMatrixOperation(node, new_transformation))

        op.push()


    @pyqtSlot()
    def resetMeshOrigin(self) -> None:
        nodes_list = self._getSelectedNodes()
        if not nodes_list:
            return

        op = GroupedOperation()
        for node in nodes_list:
            mesh_data = node.getMeshData()
            if not mesh_data:
                continue

            extents = mesh_data.getExtents()
            center = Vector(extents.center.x, extents.center.y, extents.center.z)

            translation = Matrix()
            translation.setByTranslation(-center)
            transformed_mesh_data = mesh_data.getTransformed(translation).set(zero_position=Vector())

            new_transformation = Matrix(node.getLocalTransformation().getData())  # Matrix.copy() is not available in Cura 3.5-4.0
            new_transformation.translate(center)

            op.addOperation(SetMeshDataAndNameOperation(node, transformed_mesh_data, node.getName()))
            op.addOperation(SetTransformMatrixOperation(node, new_transformation))

        op.push()


    def _replaceSceneNode(self, existing_node, trimeshes) -> None:
        name = existing_node.getName()
        file_name = existing_node.getMeshData().getFileName()
        transformation = existing_node.getWorldTransformation()
        parent = existing_node.getParent()
        extruder_id = existing_node.callDecoration("getActiveExtruder")
        build_plate = existing_node.callDecoration("getBuildPlateNumber")
        selected = Selection.isSelected(existing_node)

        children = existing_node.getChildren()
        new_nodes = []

        op = GroupedOperation()
        op.addOperation(RemoveSceneNodeOperation(existing_node))

        for i, tri_node in enumerate(trimeshes):
            mesh_data = self._toMeshData(tri_node, file_name)

            new_node = CuraSceneNode()
            new_node.setSelectable(True)
            new_node.setMeshData(mesh_data)
            new_node.setName(name if i==0 else "%s %d" % (name, i))
            new_node.callDecoration("setActiveExtruder", extruder_id)
            new_node.addDecorator(BuildPlateDecorator(build_plate))
            new_node.addDecorator(SliceableObjectDecorator())

            op.addOperation(AddSceneNodeOperation(new_node, parent))
            op.addOperation(SetTransformMatrixOperation(new_node, transformation))

            new_nodes.append(new_node)

            if selected:
                Selection.add(new_node)

        for child in children:
            mesh_data = child.getMeshData()
            if not mesh_data:
                continue
            child_bounding_box = mesh_data.getTransformed(child.getWorldTransformation()).getExtents()
            if not child_bounding_box:
                continue
            new_parent = None
            for potential_parent in new_nodes:
                parent_mesh_data = potential_parent.getMeshData()
                if not parent_mesh_data:
                    continue
                parent_bounding_box = parent_mesh_data.getTransformed(potential_parent.getWorldTransformation()).getExtents()
                if not parent_bounding_box:
                    continue
                intersection = child_bounding_box.intersectsBox(parent_bounding_box)
                if intersection != AxisAlignedBox.IntersectionResult.NoIntersection:
                    new_parent = potential_parent
                    break
            if not new_parent:
                new_parent = new_nodes[0]
            op.addOperation(SetParentOperationSimplified(child, new_parent))

        op.push()

    def _toTriMesh(self, mesh_data: Optional[MeshData]) -> trimesh.base.Trimesh:
        if not mesh_data:
            return trimesh.base.Trimesh()

        indices = mesh_data.getIndices()
        if indices is None:
            # some file formats (eg 3mf) don't supply indices, but have unique vertices per face
            indices = numpy.arange(mesh_data.getVertexCount()).reshape(-1, 3)

        return trimesh.base.Trimesh(vertices=mesh_data.getVertices(), faces=indices)

    def _toMeshData(self, tri_node: trimesh.base.Trimesh, file_name: str = "") -> MeshData:
        tri_faces = tri_node.faces
        tri_vertices = tri_node.vertices

        indices = []
        vertices = []

        index_count = 0
        face_count = 0
        for tri_face in tri_faces:
            face = []
            for tri_index in tri_face:
                vertices.append(tri_vertices[tri_index])
                face.append(index_count)
                index_count += 1
            indices.append(face)
            face_count += 1

        vertices = numpy.asarray(vertices, dtype=numpy.float32)
        indices = numpy.asarray(indices, dtype=numpy.int32)
        normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)

        mesh_data = MeshData(file_name = file_name, vertices=vertices, indices=indices, normals=normals)
        return mesh_data
class CloudOutputDeviceManager:
    """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/.
    """

    META_CLUSTER_ID = "um_cloud_cluster_id"
    META_HOST_GUID = "host_guid"
    META_NETWORK_KEY = "um_network_key"

    SYNC_SERVICE_NAME = "CloudOutputDeviceManager"

    # 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 remote clusters for the authenticated user.
        self._remote_clusters = {}  # type: Dict[str, CloudOutputDevice]

        # Dictionary containing all the cloud printers loaded in Cura
        self._um_cloud_printers = {}  # type: Dict[str, GlobalStack]

        self._account = CuraApplication.getInstance().getCuraAPI(
        ).account  # type: Account
        self._api = CloudApiClient(
            CuraApplication.getInstance(),
            on_error=lambda error: Logger.log("e", str(error)))
        self._account.loginStateChanged.connect(self._onLoginStateChanged)
        self._removed_printers_message = None  # type: Optional[Message]

        # Ensure we don't start twice.
        self._running = False

        self._syncing = False

        CuraApplication.getInstance().getContainerRegistry(
        ).containerRemoved.connect(self._printerRemoved)

    def start(self):
        """Starts running the cloud output device manager, thus periodically requesting cloud data."""

        if self._running:
            return
        if not self._account.isLoggedIn:
            return
        self._running = True
        self._getRemoteClusters()

        self._account.syncRequested.connect(self._getRemoteClusters)

    def stop(self):
        """Stops running the cloud output device manager."""

        if not self._running:
            return
        self._running = False
        self._onGetRemoteClustersFinished(
            [])  # Make sure we remove all cloud output devices.

    def refreshConnections(self) -> None:
        """Force refreshing connections."""

        self._connectToActiveMachine()

    def _onLoginStateChanged(self, is_logged_in: bool) -> None:
        """Called when the uses logs in or out"""

        if is_logged_in:
            self.start()
        else:
            self.stop()

    def _getRemoteClusters(self) -> None:
        """Gets all remote clusters from the API."""

        if self._syncing:
            return

        self._syncing = True
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
        self._api.getClusters(self._onGetRemoteClustersFinished,
                              self._onGetRemoteClusterFailed)

    def _onGetRemoteClustersFinished(
            self, clusters: List[CloudClusterResponse]) -> None:
        """Callback for when the request for getting the clusters is successful and finished."""

        self._um_cloud_printers = {
            m.getMetaDataEntry(self.META_CLUSTER_ID): m
            for m in CuraApplication.getInstance().getContainerRegistry().
            findContainerStacks(type="machine")
            if m.getMetaDataEntry(self.META_CLUSTER_ID, None)
        }
        new_clusters = []
        all_clusters = {c.cluster_id: c
                        for c in clusters
                        }  # type: Dict[str, CloudClusterResponse]
        online_clusters = {c.cluster_id: c
                           for c in clusters if c.is_online
                           }  # type: Dict[str, CloudClusterResponse]

        # Add the new printers in Cura.
        for device_id, cluster_data in all_clusters.items():
            if device_id not in self._remote_clusters:
                new_clusters.append(cluster_data)
            if device_id in self._um_cloud_printers:
                # Existing cloud printers may not have the host_guid meta-data entry. If that's the case, add it.
                if not self._um_cloud_printers[device_id].getMetaDataEntry(
                        self.META_HOST_GUID, None):
                    self._um_cloud_printers[device_id].setMetaDataEntry(
                        self.META_HOST_GUID, cluster_data.host_guid)
                # If a printer was previously not linked to the account and is rediscovered, mark the printer as linked
                # to the current account
                if not parseBool(
                        self._um_cloud_printers[device_id].getMetaDataEntry(
                            META_UM_LINKED_TO_ACCOUNT, "true")):
                    self._um_cloud_printers[device_id].setMetaDataEntry(
                        META_UM_LINKED_TO_ACCOUNT, True)
        self._onDevicesDiscovered(new_clusters)

        # Hide the current removed_printers_message, if there is any
        if self._removed_printers_message:
            self._removed_printers_message.actionTriggered.disconnect(
                self._onRemovedPrintersMessageActionTriggered)
            self._removed_printers_message.hide()

        # Remove the CloudOutput device for offline printers
        offline_device_keys = set(self._remote_clusters.keys()) - set(
            online_clusters.keys())
        for device_id in offline_device_keys:
            self._onDiscoveredDeviceRemoved(device_id)

        # Handle devices that were previously added in Cura but do not exist in the account anymore (i.e. they were
        # removed from the account)
        removed_device_keys = set(self._um_cloud_printers.keys()) - set(
            all_clusters.keys())
        if removed_device_keys:
            self._devicesRemovedFromAccount(removed_device_keys)

        if new_clusters or offline_device_keys or removed_device_keys:
            self.discoveredDevicesChanged.emit()
        if offline_device_keys:
            # If the removed device was active we should connect to the new active device
            self._connectToActiveMachine()

        self._syncing = False
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)

    def _onGetRemoteClusterFailed(self, reply: QNetworkReply,
                                  error: QNetworkReply.NetworkError) -> None:
        self._syncing = False
        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)

    def _onDevicesDiscovered(self,
                             clusters: List[CloudClusterResponse]) -> None:
        """**Synchronously** create machines for discovered devices

        Any new machines are made available to the user.
        May take a long time to complete. As this code needs access to the Application
        and blocks the GIL, creating a Job for this would not make sense.
        Shows a Message informing the user of progress.
        """
        new_devices = []
        remote_clusters_added = False
        host_guid_map = {
            machine.getMetaDataEntry(self.META_HOST_GUID): device_cluster_id
            for device_cluster_id, machine in self._um_cloud_printers.items()
            if machine.getMetaDataEntry(self.META_HOST_GUID)
        }
        machine_manager = CuraApplication.getInstance().getMachineManager()

        for cluster_data in clusters:
            device = CloudOutputDevice(self._api, cluster_data)
            # If the machine already existed before, it will be present in the host_guid_map
            if cluster_data.host_guid in host_guid_map:
                machine = machine_manager.getMachine(
                    device.printerType,
                    {self.META_HOST_GUID: cluster_data.host_guid})
                if machine and machine.getMetaDataEntry(
                        self.META_CLUSTER_ID) != device.key:
                    # If the retrieved device has a different cluster_id than the existing machine, bring the existing
                    # machine up-to-date.
                    self._updateOutdatedMachine(outdated_machine=machine,
                                                new_cloud_output_device=device)

            # Create a machine if we don't already have it. Do not make it the active machine.
            # We only need to add it if it wasn't already added by "local" network or by cloud.
            if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \
                    and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None:  # The host name is part of the network key.
                new_devices.append(device)
            elif device.getId() not in self._remote_clusters:
                self._remote_clusters[device.getId()] = device
                remote_clusters_added = True
            # If a printer that was removed from the account is re-added, change its metadata to mark it not removed
            # from the account
            elif not parseBool(
                    self._um_cloud_printers[device.key].getMetaDataEntry(
                        META_UM_LINKED_TO_ACCOUNT, "true")):
                self._um_cloud_printers[device.key].setMetaDataEntry(
                    META_UM_LINKED_TO_ACCOUNT, True)

        # Inform the Cloud printers model about new devices.
        new_devices_list_of_dicts = [{
            "key": d.getId(),
            "name": d.name,
            "machine_type": d.printerTypeName,
            "firmware_version": d.firmwareVersion
        } for d in new_devices]
        discovered_cloud_printers_model = CuraApplication.getInstance(
        ).getDiscoveredCloudPrintersModel()
        discovered_cloud_printers_model.addDiscoveredCloudPrinters(
            new_devices_list_of_dicts)

        if not new_devices:
            if remote_clusters_added:
                self._connectToActiveMachine()
            return

        # Sort new_devices on online status first, alphabetical second.
        # Since the first device might be activated in case there is no active printer yet,
        # it would be nice to prioritize online devices
        online_cluster_names = {
            c.friendly_name.lower()
            for c in clusters if c.is_online and not c.friendly_name is None
        }
        new_devices.sort(key=lambda x: ("a{}" if x.name.lower(
        ) in online_cluster_names else "b{}").format(x.name.lower()))

        image_path = os.path.join(
            CuraApplication.getInstance().getPluginRegistry().getPluginPath(
                "UM3NetworkPrinting") or "", "resources", "svg",
            "cloud-flow-completed.svg")

        message = Message(title=self.I18N_CATALOG.i18ncp(
            "info:status", "New printer detected from your Ultimaker account",
            "New printers detected from your Ultimaker account",
            len(new_devices)),
                          progress=0,
                          lifetime=0,
                          image_source=image_path)
        message.show()

        for idx, device in enumerate(new_devices):
            message_text = self.I18N_CATALOG.i18nc(
                "info:status", "Adding printer {} ({}) from your account",
                device.name, device.printerTypeName)
            message.setText(message_text)
            if len(new_devices) > 1:
                message.setProgress((idx / len(new_devices)) * 100)
            CuraApplication.getInstance().processEvents()
            self._remote_clusters[device.getId()] = device

            # If there is no active machine, activate the first available cloud printer
            activate = not CuraApplication.getInstance().getMachineManager(
            ).activeMachine
            self._createMachineFromDiscoveredDevice(device.getId(),
                                                    activate=activate)

        message.setProgress(None)

        max_disp_devices = 3
        if len(new_devices) > max_disp_devices:
            num_hidden = len(new_devices) - max_disp_devices
            device_name_list = [
                "<li>{} ({})</li>".format(device.name, device.printerTypeName)
                for device in new_devices[0:max_disp_devices]
            ]
            device_name_list.append(
                self.I18N_CATALOG.i18nc("info:hidden list items",
                                        "<li>... and {} others</li>",
                                        num_hidden))
            device_names = "".join(device_name_list)
        else:
            device_names = "".join([
                "<li>{} ({})</li>".format(device.name, device.printerTypeName)
                for device in new_devices
            ])

        message_text = self.I18N_CATALOG.i18nc(
            "info:status",
            "Cloud printers added from your account:<ul>{}</ul>", device_names)
        message.setText(message_text)

    def _updateOutdatedMachine(
            self, outdated_machine: GlobalStack,
            new_cloud_output_device: CloudOutputDevice) -> None:
        """
         Update the cloud metadata of a pre-existing machine that is rediscovered (e.g. if the printer was removed and
         re-added to the account) and delete the old CloudOutputDevice related to this machine.

        :param outdated_machine: The cloud machine that needs to be brought up-to-date with the new data received from
                                 the account
        :param new_cloud_output_device: The new CloudOutputDevice that should be linked to the pre-existing machine
        :return: None
        """
        old_cluster_id = outdated_machine.getMetaDataEntry(
            self.META_CLUSTER_ID)
        outdated_machine.setMetaDataEntry(self.META_CLUSTER_ID,
                                          new_cloud_output_device.key)
        outdated_machine.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True)
        # Cleanup the remainings of the old CloudOutputDevice(old_cluster_id)
        self._um_cloud_printers[
            new_cloud_output_device.key] = self._um_cloud_printers.pop(
                old_cluster_id)
        output_device_manager = CuraApplication.getInstance(
        ).getOutputDeviceManager()
        if old_cluster_id in output_device_manager.getOutputDeviceIds():
            output_device_manager.removeOutputDevice(old_cluster_id)
        if old_cluster_id in self._remote_clusters:
            # We need to close the device so that it stops checking for its status
            self._remote_clusters[old_cluster_id].close()
            del self._remote_clusters[old_cluster_id]
            self._remote_clusters[
                new_cloud_output_device.key] = new_cloud_output_device

    def _devicesRemovedFromAccount(self, removed_device_ids: Set[str]) -> None:
        """
        Removes the CloudOutputDevice from the received device ids and marks the specific printers as "removed from
        account". In addition, it generates a message to inform the user about the printers that are no longer linked to
        his/her account. The message is not generated if all the printers have been previously reported as not linked
        to the account.

        :param removed_device_ids: Set of device ids, whose CloudOutputDevice needs to be removed
        :return: None
        """

        if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn:
            return

        # Do not report device ids which have been previously marked as non-linked to the account
        ignored_device_ids = set()
        for device_id in removed_device_ids:
            if not parseBool(
                    self._um_cloud_printers[device_id].getMetaDataEntry(
                        META_UM_LINKED_TO_ACCOUNT, "true")):
                ignored_device_ids.add(device_id)
        # Keep the reported_device_ids list in a class variable, so that the message button actions can access it and
        # take the necessary steps to fulfill their purpose.
        self.reported_device_ids = removed_device_ids - ignored_device_ids
        if not self.reported_device_ids:
            return

        # Generate message
        self._removed_printers_message = Message(
            title=self.I18N_CATALOG.i18ncp(
                "info:status",
                "Cloud connection is not available for a printer",
                "Cloud connection is not available for some printers",
                len(self.reported_device_ids)))
        device_names = "\n".join([
            "<li>{} ({})</li>".format(
                self._um_cloud_printers[device].name,
                self._um_cloud_printers[device].definition.name)
            for device in self.reported_device_ids
        ])
        message_text = self.I18N_CATALOG.i18ncp(
            "info:status",
            "The following cloud printer is not linked to your account:\n",
            "The following cloud printers are not linked to your account:\n",
            len(self.reported_device_ids))
        message_text += self.I18N_CATALOG.i18nc(
            "info:status",
            "<ul>{}</ul>\nTo establish a connection, please visit the "
            "<a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>.",
            device_names)
        self._removed_printers_message.setText(message_text)
        self._removed_printers_message.addAction(
            "keep_printer_configurations_action",
            name=self.I18N_CATALOG.i18nc("@action:button",
                                         "Keep printer configurations"),
            icon="",
            description=
            "Keep the configuration of the cloud printer(s) synced with Cura which are not linked to your account.",
            button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
        self._removed_printers_message.addAction(
            "remove_printers_action",
            name=self.I18N_CATALOG.i18nc("@action:button", "Remove printers"),
            icon="",
            description=
            "Remove the cloud printer(s) which are not linked to your account.",
            button_style=Message.ActionButtonStyle.SECONDARY,
            button_align=Message.ActionButtonAlignment.ALIGN_LEFT)
        self._removed_printers_message.actionTriggered.connect(
            self._onRemovedPrintersMessageActionTriggered)

        output_device_manager = CuraApplication.getInstance(
        ).getOutputDeviceManager()

        # Remove the output device from the printers
        for device_id in removed_device_ids:
            device = self._um_cloud_printers.get(
                device_id, None)  # type: Optional[GlobalStack]
            if not device:
                continue
            if device_id in output_device_manager.getOutputDeviceIds():
                output_device_manager.removeOutputDevice(device_id)
            if device_id in self._remote_clusters:
                del self._remote_clusters[device_id]

            # Update the printer's metadata to mark it as not linked to the account
            device.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, False)

        self._removed_printers_message.show()

    def _onDiscoveredDeviceRemoved(self, device_id: str) -> None:
        device = self._remote_clusters.pop(
            device_id, None)  # type: Optional[CloudOutputDevice]
        if not device:
            return
        device.close()
        output_device_manager = CuraApplication.getInstance(
        ).getOutputDeviceManager()
        if device.key in output_device_manager.getOutputDeviceIds():
            output_device_manager.removeOutputDevice(device.key)

    def _createMachineFromDiscoveredDevice(self,
                                           key: str,
                                           activate: bool = True) -> None:
        device = self._remote_clusters[key]
        if not device:
            return

        # Create a new machine.
        # We do not use use MachineManager.addMachine here because we need to set the cluster ID before activating it.
        new_machine = CuraStackBuilder.createMachine(device.name,
                                                     device.printerType)
        if not new_machine:
            Logger.log("e", "Failed creating a new machine")
            return

        self._setOutputDeviceMetadata(device, new_machine)

        if activate:
            CuraApplication.getInstance().getMachineManager().setActiveMachine(
                new_machine.getId())

    def _connectToActiveMachine(self) -> None:
        """Callback for when the active machine was changed by the user"""

        active_machine = CuraApplication.getInstance().getGlobalContainerStack(
        )
        if not active_machine:
            return

        output_device_manager = CuraApplication.getInstance(
        ).getOutputDeviceManager()
        stored_cluster_id = active_machine.getMetaDataEntry(
            self.META_CLUSTER_ID)
        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.
                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)

    def _setOutputDeviceMetadata(self, device: CloudOutputDevice,
                                 machine: GlobalStack):
        machine.setName(device.name)
        machine.setMetaDataEntry(self.META_CLUSTER_ID, device.key)
        machine.setMetaDataEntry(self.META_HOST_GUID,
                                 device.clusterData.host_guid)
        machine.setMetaDataEntry("group_name", device.name)
        machine.setMetaDataEntry("group_size", device.clusterSize)
        machine.setMetaDataEntry(
            "removal_warning",
            self.I18N_CATALOG.i18nc(
                "@label ({} is printer name)",
                "{} will be removed until the next account sync. <br> To remove {} permanently, "
                "visit <a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>. "
                "<br><br>Are you sure you want to remove {} temporarily?",
                device.name, device.name, device.name))
        machine.addConfiguredConnectionType(device.connectionType.value)

    def _connectToOutputDevice(self, device: CloudOutputDevice,
                               machine: GlobalStack) -> None:
        """Connects to an output device and makes sure it is registered in the output device manager."""

        self._setOutputDeviceMetadata(device, machine)

        if not device.isConnected():
            device.connect()

        output_device_manager = CuraApplication.getInstance(
        ).getOutputDeviceManager()
        if device.key not in output_device_manager.getOutputDeviceIds():
            output_device_manager.addOutputDevice(device)

    def _printerRemoved(self, container: ContainerInterface) -> None:
        """
        Callback connected to the containerRemoved signal. Invoked when a cloud printer is removed from Cura to remove
        the printer's reference from the _remote_clusters.

        :param container: The ContainerInterface passed to this function whenever the ContainerRemoved signal is emitted
        :return: None
        """
        if isinstance(container, GlobalStack):
            container_cluster_id = container.getMetaDataEntry(
                self.META_CLUSTER_ID, None)
            if container_cluster_id in self._remote_clusters.keys():
                del self._remote_clusters[container_cluster_id]

    def _onRemovedPrintersMessageActionTriggered(
            self, removed_printers_message: Message, action: str) -> None:
        if action == "keep_printer_configurations_action":
            removed_printers_message.hide()
        elif action == "remove_printers_action":
            machine_manager = CuraApplication.getInstance().getMachineManager()
            remove_printers_ids = {
                self._um_cloud_printers[i].getId()
                for i in self.reported_device_ids
            }
            all_ids = {
                m.getId()
                for m in CuraApplication.getInstance().getContainerRegistry().
                findContainerStacks(type="machine")
            }

            question_title = self.I18N_CATALOG.i18nc("@title:window",
                                                     "Remove printers?")
            question_content = self.I18N_CATALOG.i18nc(
                "@label",
                "You are about to remove {} printer(s) from Cura. This action cannot be undone. \nAre you sure you want to continue?"
                .format(len(remove_printers_ids)))
            if remove_printers_ids == all_ids:
                question_content = self.I18N_CATALOG.i18nc(
                    "@label",
                    "You are about to remove all printers from Cura. This action cannot be undone. \nAre you sure you want to continue?"
                )
            result = QMessageBox.question(None, question_title,
                                          question_content)
            if result == QMessageBox.No:
                return

            for machine_cloud_id in self.reported_device_ids:
                machine_manager.setActiveMachine(
                    self._um_cloud_printers[machine_cloud_id].getId())
                machine_manager.removeMachine(
                    self._um_cloud_printers[machine_cloud_id].getId())
            removed_printers_message.hide()
Beispiel #10
0
class RawMouse(
        Extension,
        QObject,
):
    def __init__(self, parent=None):
        QObject.__init__(self, parent)
        Extension.__init__(self)

        self._decoders = {
            "spacemouse": self._decodeSpacemouseEvent,
            "tiltpad": self._decodeTiltpadEvent
        }

        self._application = None
        self._main_window = None
        self._controller = None
        self._scene = None
        self._camera_tool = None

        self.setMenuName(catalog.i18nc("@item:inmenu", "RawMouse"))
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Stop"), self._stop)
        self.addMenuItem(catalog.i18nc("@item:inmenu", "Restart"),
                         self._restart)
        self.addMenuItem(
            catalog.i18nc("@item:inmenu", "Show Device Information"),
            self._showDeviceInformation)

        self._buttons = 0
        self._running = False
        self._runner = None
        self._battery_level = None
        self._message = None
        self._redraw_pending = False
        self._roll = 0
        self._hidapi = None

        self._clearAxisWork()
        self._clearButtonWork()

        self.processAxes.connect(self._processAxes)
        self.processButtons.connect(self._processButtons)

        self._reload(False)
        self._start()

    def _getComponents(self):
        if self._application is None:
            self._application = CuraApplication.getInstance()
        elif self._controller is None:
            self._controller = self._application.getController()
        elif self._camera_tool is None:
            self._camera_tool = self._controller.getCameraTool()
            self._scene = self._controller.getScene()
        elif self._main_window is None:
            self._main_window = self._application.getMainWindow()

    def _restart(self):
        self._stop()
        self._reload(True)
        self._start()

    def _reload(self, restarted):
        self._config = {}
        try:
            with open(os.path.join(os.path.dirname(os.path.realpath(__file__)),
                                   "config.json"),
                      "r",
                      encoding="utf-8") as f:
                self._config = json.load(f)
        except Exception as e:
            Logger.log("e", "Exception loading configuration: %s", e)
            if restarted:
                self._showMessage("Exception loading configuration: " + str(e))

    def _cacheProfileValues(self, profile_name):
        self._profile_name = profile_name
        self._profile = self._config["profiles"][profile_name]
        self._min_camera_update_period = 1000 / (int(
            self._config["maxhz"]) if "maxhz" in self._config else 30)
        if "verbose" in self._config:
            self._verbose = self._config["verbose"]
        else:
            self._verbose = 0
        if "fastview" in self._config:
            self._auto_fast_view = self._config["fastview"]
        else:
            self._auto_fast_view = 0
        self._axis_threshold = []
        self._axis_scale = []
        self._axis_offset = []
        self._axis_target = []
        self._axis_value = []
        self._layer_change_increment = 1
        if self._profile_name in self._decoders:
            self._decoder = self._decoders[self._profile_name]
        else:
            self._decoder = self._decodeUnknownEvent
        profile_axes = self._profile["axes"]
        if self._hid_dev is not None:
            Logger.log("d", "Device %s / %s, profile %s",
                       self._hid_dev["manufacturer_string"],
                       self._hid_dev["product_string"], self._profile_name)
        for i in range(0, len(profile_axes)):
            axis_vals = profile_axes[i]
            self._axis_threshold.append(axis_vals["threshold"])
            self._axis_scale.append(axis_vals["scale"])
            self._axis_offset.append(axis_vals["offset"])
            target = ""
            if "target" in axis_vals:
                target = axis_vals["target"]
                aliases = {"rotx": "rotyaw", "roty": "rotpitch"}
                if target in aliases:
                    target = aliases[target]
                if target == "movy" and axis_vals["scale"] > 0.0:
                    self._layer_change_increment = -1
            self._axis_target.append(target)
            self._axis_value.append(0.0)
            Logger.log(
                "d",
                "axis %d, scale = %f, threshold = %f, offset = %f, target = %s",
                i, self._axis_scale[i], self._axis_threshold[i],
                self._axis_offset[i], self._axis_target[i])

    def _start(self):
        self._hid_dev = None
        if "devices" in self._config:
            try:
                if self._hidapi is None:
                    pv = ".".join(platform.python_version_tuple()[0:2])
                    if sys.platform == "linux":
                        sys_name = "linux-" + os.uname().machine
                    elif sys.platform == "win32":
                        sys_name = "win-amd64"
                    elif sys.platform == "darwin":
                        sys_name = "macosx-10.13-" + ("intel" if pv == "3.5"
                                                      else os.uname().machine)
                    else:
                        sys_name = "unknown"
                    egg_name = "hidapi-0.9.0-py" + pv + "-" + sys_name + ".egg"
                    sys.path.append(
                        os.path.join(
                            os.path.dirname(os.path.realpath(__file__)),
                            "hidapi", egg_name))
                    import hid
                    Logger.log("d", "Imported %s", str(hid))
                    self._hidapi = hid
                    del sys.path[-1]

                for hid_dev in self._hidapi.enumerate():
                    for known_dev in self._config["devices"]:
                        if hid_dev["vendor_id"] == int(
                                known_dev[0],
                                base=16) and hid_dev["product_id"] == int(
                                    known_dev[1], base=16):
                            if len(known_dev) > 4:
                                options = known_dev[4]
                                if "platform" in options and platform.system(
                                ) != options["platform"]:
                                    continue
                                if "usage_page" in options and hid_dev[
                                        "usage_page"] != options["usage_page"]:
                                    continue
                                if "usage" in options and hid_dev[
                                        "usage"] != options["usage"]:
                                    continue
                                if "interface_number" in options and hid_dev[
                                        "interface_number"] != options[
                                            "interface_number"]:
                                    continue
                            self._hid_dev = hid_dev
                            Logger.log(
                                "d",
                                "Found HID device with vendor_id = %x, product_id = %x, interface_number = %x",
                                self._hid_dev["vendor_id"],
                                self._hid_dev["product_id"],
                                self._hid_dev["interface_number"])
                            self._cacheProfileValues(known_dev[2])
                            break
                    if self._hid_dev:
                        break
            except Exception as e:
                Logger.log("e", "Exception initialising profile: %s", e)

        if self._hid_dev:
            self._runner = Thread(target=self._run_hid,
                                  daemon=True,
                                  name="RawMouse")
            self._runner.start()
        elif "libspnav" in self._config and os.path.exists(
                self._config["libspnav"]):
            Logger.log("d", "Trying libspnav...")
            global libspnav
            if libspnav is None:
                try:
                    libspnav = cdll.LoadLibrary(self._config["libspnav"])
                    setup_libspnav_fns()
                    Logger.log("d", "Initialised libspnav")
                except Exception as e:
                    Logger.log("e", "Exception initialising libspnav: %s", e)
            try:
                self._cacheProfileValues("libspnav")
            except Exception as e:
                Logger.log("e", "Exception initialising profile: %s", e)
            if libspnav is not None:
                self._runner = Thread(target=self._run_libspnav,
                                      daemon=True,
                                      name="RawMouse")
                self._runner.start()
        if self._runner is None:
            Logger.log("w", "No mouse found!")

    def _stop(self):
        self._running = False
        while self._runner:
            self._runner.join(timeout=2.0)

    def _run_hid(self):
        runner_started_at = QTime()
        runner_started_at.start()
        auto_restart = False
        self._running = True
        try:
            h = self._hidapi.device()
            if self._hid_dev["path"]:
                Logger.log("d", "Trying to open %s",
                           self._hid_dev["path"].decode("utf-8"))
                h.open_path(self._hid_dev["path"])
            else:
                Logger.log("d", "Trying to open [%x,%x]",
                           self._hid_dev["vendor_id"],
                           self._hid_dev["product_id"])
                h.open(self._hid_dev["vendor_id"], self._hid_dev["product_id"])

            Logger.log("i", "Manufacturer: %s", h.get_manufacturer_string())
            Logger.log("i", "Product: %s", h.get_product_string())
            #Logger.log("i", "Serial No: %s", h.get_serial_number_string())

            self._last_camera_update_at = QTime()
            self._last_camera_update_at.start()
            self._fast_view = False
            while self._running:
                if self._main_window:
                    d = h.read(64, 50 if self._fast_view else 1000)
                    if d:
                        if self._main_window.isActive():
                            self._decoder(d)
                    elif self._fast_view:
                        self._controller.setActiveView("SimulationView")
                        self._fast_view = False
                else:
                    self._getComponents()
                    time.sleep(0.1)
            h.close()
        except IOError as e:
            Logger.log("e", "IOError while reading HID events: %s", e)
            auto_restart = (sys.platform == "win32")
        except Exception as e:
            Logger.log("e", "Exception while reading HID events: %s", e)
        self._running = False
        if auto_restart:
            # throttle restarts to avoid hogging the CPU
            min_restart_seconds = 5
            run_time = runner_started_at.elapsed() / 1000
            if run_time < min_restart_seconds:
                Logger.log("d", "Delaying restart...")
                time.sleep(min_restart_seconds - run_time)
            if not self._running:
                self._runner = None
                self._restart()
        else:
            self._runner = None

    def _clearAxisWork(self):
        self._axis_work = {
            "movx": 0.0,
            "movy": 0.0,
            "rotyaw": 0.0,
            "rotpitch": 0.0,
            "rotroll": 0.0,
            "zoom": 0.0
        }

    def _clearButtonWork(self):
        self._button_work = {
            "resetview": None,
            "toggleview": None,
            "maxlayer": None,
            "minlayer": None,
            "colorscheme": None,
            "cameramode": None,
            "centerobj": None
        }

    processAxes = Signal()

    processButtons = Signal()

    def _processButtons(self):
        try:
            modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
            shift_is_active = (
                modifiers & QtCore.Qt.ShiftModifier) == QtCore.Qt.ShiftModifier
            current_view = self._controller.getActiveView()
            if self._button_work["resetview"]:
                self._roll = 0
                self._controller.setCameraRotation(
                    *self._button_work["resetview"])
            elif self._button_work["toggleview"]:
                if self._controller.getActiveView().getPluginId(
                ) == "SimulationView":
                    self._controller.setActiveStage("PrepareStage")
                    self._controller.setActiveView("SolidView")
                else:
                    self._controller.setActiveStage("PreviewStage")
                    self._controller.setActiveView("SimulationView")
            elif self._button_work["maxlayer"]:
                if current_view.getPluginId() == "SimulationView":
                    layer = self._button_work["maxlayer"]
                    if layer == "max":
                        current_view.setLayer(current_view.getMaxLayers())
                    elif layer == "min":
                        current_view.setLayer(0)
                    elif isinstance(layer, int):
                        delta = layer * (10 if shift_is_active else 1)
                        current_view.setLayer(current_view.getCurrentLayer() +
                                              delta)
            elif self._button_work["minlayer"]:
                if current_view.getPluginId() == "SimulationView":
                    layer = self._button_work["minlayer"]
                    if layer == "max":
                        current_view.setMinimumLayerLayer(
                            current_view.getMaxLayers())
                    elif layer == "min":
                        current_view.setMinimumLayer(0)
                    elif isinstance(layer, int):
                        delta = layer * (10 if shift_is_active else 1)
                        current_view.setMinimumLayer(
                            current_view.getMinimumLayer() + delta)
            elif isinstance(self._button_work["colorscheme"], int):
                if current_view.getPluginId() == "SimulationView":
                    color_scheme = self._button_work["colorscheme"]
                    if color_scheme >= 0 and color_scheme <= 3:
                        self._application.getPreferences().setValue(
                            "layerview/layer_view_type", color_scheme)
            elif self._button_work["colorscheme"]:
                if current_view.getPluginId() == "SimulationView":
                    if self._button_work["colorscheme"] == "next":
                        color_scheme = current_view.getSimulationViewType() + 1
                        if color_scheme > 3:
                            color_scheme = 0
                        self._application.getPreferences().setValue(
                            "layerview/layer_view_type", color_scheme)
                    elif self._button_work["colorscheme"] == "prev":
                        color_scheme = current_view.getSimulationViewType() - 1
                        if color_scheme < 0:
                            color_scheme = 3
                        self._application.getPreferences().setValue(
                            "layerview/layer_view_type", color_scheme)
            elif self._button_work["cameramode"]:
                camera_mode = self._button_work["cameramode"]
                if camera_mode != "perspective" and camera_mode != "orthographic":
                    camera_mode = self._application.getPreferences().getValue(
                        "general/camera_perspective_mode")
                    camera_mode = "perspective" if camera_mode == "orthographic" else "orthographic"
                self._application.getPreferences().setValue(
                    "general/camera_perspective_mode", camera_mode)
            elif self._button_work["centerobj"]:
                target_node = None
                if Selection.getSelectedObject(0):
                    target_node = Selection.getSelectedObject(0)
                else:
                    for node in DepthFirstIterator(self._scene.getRoot()):
                        if isinstance(node, SceneNode) and node.getMeshData(
                        ) and node.isSelectable():
                            target_node = node
                            break
                if target_node:
                    self._camera_tool.setOrigin(target_node.getWorldPosition())
                    camera = self._scene.getActiveCamera()
                    camera_pos = camera.getWorldPosition()
                    #Logger.log("d", "Camera pos = " + str(camera_pos))
                    if camera_pos.y < 0:
                        camera.setPosition(
                            Vector(camera_pos.x,
                                   target_node.getBoundingBox().height,
                                   camera_pos.z))
                        camera.lookAt(target_node.getWorldPosition())
                    if isinstance(self._button_work["centerobj"], float):
                        # simple fit object to screen based on object's longest dimension
                        bb = target_node.getBoundingBox()
                        target_size = max(bb.height, bb.width, bb.depth)
                        if camera.isPerspective():
                            #Logger.log("d", "target at " + str(target_node.getWorldPosition()) + ", camera at " + str(camera.getWorldPosition()))
                            move_vector = (
                                camera.getWorldPosition() -
                                target_node.getWorldPosition()).normalized(
                                ) * target_size * 2 / self._button_work[
                                    "centerobj"]
                            #Logger.log("d", "target size is " + str(target_size) + " move vector is " + str(move_vector))
                            camera.setPosition(target_node.getWorldPosition() +
                                               move_vector)
                        else:
                            zoom_factor = camera.getDefaultZoomFactor() * (
                                1 + 3.0 * self._button_work["centerobj"] /
                                math.sqrt(target_size))
                            if zoom_factor > 1:
                                zoom_factor = 1
                            elif zoom_factor < -0.495:
                                zoom_factor = -0.495
                            #Logger.log("d", "zoom factor is " + str(zoom_factor))
                            camera.setZoomFactor(zoom_factor)
                else:
                    self._controller.setCameraRotation("3d", 0)
                self._roll = 0
        except Exception as e:
            Logger.log("e", "Exception while processing buttons: %s", e)
        self._clearButtonWork()

    def _processAxes(self):
        try:
            modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
            ctrl_is_active = (
                modifiers
                & QtCore.Qt.ControlModifier) == QtCore.Qt.ControlModifier
            shift_is_active = (
                modifiers & QtCore.Qt.ShiftModifier) == QtCore.Qt.ShiftModifier
            alt_is_active = (modifiers
                             & QtCore.Qt.AltModifier) == QtCore.Qt.AltModifier
            current_view = self._controller.getActiveView()
            if self._last_camera_update_at.elapsed(
            ) > self._min_camera_update_period:
                if self._auto_fast_view or ctrl_is_active:
                    if self._controller.getActiveStage().getPluginId(
                    ) == "PreviewStage" and self._controller.getActiveView(
                    ).getPluginId() != "FastView":
                        self._controller.setActiveView("FastView")
                        self._fast_view = True
                elif self._fast_view:
                    self._controller.setActiveView("SimulationView")
                    self._fast_view = False
                if (shift_is_active or alt_is_active
                    ) and current_view.getPluginId() == "SimulationView":
                    if self._axis_work["movy"] != 0.0:
                        delta = self._layer_change_increment if self._axis_work[
                            "movy"] > 0 else -self._layer_change_increment
                        self._last_camera_update_at.start()
                        if shift_is_active:
                            current_view.setLayer(
                                current_view.getCurrentLayer() + delta)
                        if alt_is_active:
                            current_view.setMinimumLayer(
                                current_view.getMinimumLayer() + delta)
                else:
                    if self._axis_work["movx"] != 0.0 or self._axis_work[
                            "movy"] != 0.0:
                        self._last_camera_update_at.start()
                        self._camera_tool._moveCamera(
                            MouseEvent(MouseEvent.MouseMoveEvent,
                                       self._axis_work["movx"],
                                       self._axis_work["movy"], 0, 0))
                    if self._axis_work["rotyaw"] != 0 or self._axis_work[
                            "rotpitch"] != 0 or self._axis_work["rotroll"] != 0:
                        self._last_camera_update_at.start()
                        self._rotateCamera(self._axis_work["rotyaw"],
                                           self._axis_work["rotpitch"],
                                           self._axis_work["rotroll"])
                    if self._axis_work["zoom"] != 0:
                        self._last_camera_update_at.start()
                        self._camera_tool._zoomCamera(self._axis_work["zoom"])
        except Exception as e:
            Logger.log("e", "Exception while processing axes: %s", e)
        self._redraw_pending = False
        self._clearAxisWork()

    def _decodeSpacemouseEvent(self, buf):
        scale = 1.0 / 350.0
        if len(buf) == 7 and (buf[0] == 1 or buf[0] == 2):
            for a in range(0, 3):
                val = buf[2 * a + 1] | buf[2 * a + 2] << 8
                if val & 0x8000:
                    val = val - 0x10000
                axis = (buf[0] - 1) * 3 + a
                self._axis_value[axis] = val * scale * self._axis_scale[
                    axis] + self._axis_offset[axis]
            self._spacemouseAxisEvent(self._axis_value)
        elif len(buf) == 13 and buf[0] == 1:
            for a in range(0, 6):
                val = buf[2 * a + 1] | buf[2 * a + 2] << 8
                if val & 0x8000:
                    val = val - 0x10000
                self._axis_value[a] = val * scale * self._axis_scale[
                    a] + self._axis_offset[a]
            self._spacemouseAxisEvent(self._axis_value)
        elif len(buf) >= 3 and buf[0] == 3:
            buttons = buf[1] | buf[2] << 8
            for b in range(0, 16):
                mask = 1 << b
                if ((buttons & mask) != (self._buttons & mask)):
                    self._spacemouseButtonEvent(b + 1, (buttons & mask) >> b)
            self._buttons = buttons
        elif len(buf) >= 3 and buf[0] == 0x17:
            if buf[1] != self._battery_level:
                self._battery_level = buf[1]
                Logger.log("d", "Spacemouse battery level %d%%", buf[1])
        else:
            Logger.log("d", "Unknown spacemouse event: code = %x, len = %d",
                       buf[0], len(buf))

    def _spacemouseAxisEvent(self, vals):
        if self._verbose > 0:
            Logger.log("d", "Axes [%f,%f,%f,%f,%f,%f]", vals[0], vals[1],
                       vals[2], vals[3], vals[4], vals[5])
        process = False
        scale = self._getScalingDueToZoom()
        for i in range(0, 6):
            if vals[i] > self._axis_threshold[i]:
                self._axis_work[self._axis_target[i]] = (
                    vals[i] - self._axis_threshold[i]) * scale
                process = True
            elif vals[i] < -self._axis_threshold[i]:
                self._axis_work[self._axis_target[i]] = (
                    vals[i] + self._axis_threshold[i]) * scale
                process = True
        if process:
            if not self._redraw_pending:
                self._redraw_pending = True
                self.processAxes.emit()

    def _spacemouseButtonEvent(self, button, val):
        if self._verbose > 0:
            Logger.log("d", "button[%d] = %f", button, val)
        if val == 1:
            button_defs = self._profile["buttons"]
            for b in button_defs:
                if button == int(b):
                    self._button_work[button_defs[b]
                                      ["target"]] = button_defs[b]["value"]
                    self.processButtons.emit()
                    return

    def _decodeTiltpadEvent(self, buf):
        scale = self._getScalingDueToZoom()
        process_axes = False
        #tilt
        for a in range(0, 2):
            val = (buf[a] - 127) * self._axis_scale[a] + self._axis_offset[a]
            if val > self._axis_threshold[a]:
                self._axis_work[self._axis_target[a]] = (
                    val - self._axis_threshold[a]) * scale
                process_axes = True
            elif val < -self._axis_threshold[a]:
                self._axis_work[self._axis_target[a]] = (
                    val + self._axis_threshold[a]) * scale
                process_axes = True
        if process_axes:
            if not self._redraw_pending:
                self._redraw_pending = True
                self.processAxes.emit()
        buttons = buf[3] & 0x7f
        if buttons != 0:
            button_defs = self._profile["buttons"]
            for b in button_defs:
                if buttons == int(b, base=16):
                    self._button_work[button_defs[b]
                                      ["target"]] = button_defs[b]["value"]
                    self.processButtons.emit()
                    return

    def _decodeUnknownEvent(self, buf):
        Logger.log("d", "Unknown event: len = %d [0] = %x", len(buf), buf[0])

    def _getScalingDueToZoom(self):
        scale = 1.0
        if self._scene:
            zoom_factor = self._scene.getActiveCamera().getZoomFactor()
            if zoom_factor < -0.4:
                scale = 0.1 + 9 * (zoom_factor - -0.5)
                #Logger.log("d", "scale = %f", scale)
        return scale

    def _showDeviceInformation(self):
        try:
            message = "No device found"
            if self._hid_dev:
                message = "Manufacturer: " + self._hid_dev[
                    "manufacturer_string"] + "\nProduct: " + self._hid_dev[
                        "product_string"] + "\nProfile: " + self._profile_name
            elif libspnav is not None:
                message = "Using libspnav"
            if self._battery_level is not None:
                message += "\nBattery level: " + str(self._battery_level) + "%"
            if self._profile:
                message += "\nAxes:"
                for i in range(0, len(self._axis_scale)):
                    message += "\n&nbsp;[" + str(i) + "] scale " + str(
                        self._axis_scale[i]) + " threshold " + str(
                            self._axis_threshold[i]) + " offset " + str(
                                self._axis_offset[i]
                            ) + " -> " + self._axis_target[i]
                message += "\nButttons:"
                button_defs = self._profile["buttons"]
                for b in sorted(button_defs):
                    message += "\n&nbsp;[" + b + "] target " + button_defs[b][
                        "target"] + " value " + str(button_defs[b]["value"])
            message += "\nModifiers:\n " + (
                "Cmd" if sys.platform == "darwin" else "Ctrl"
            ) + " = switch from preview to fastview\n Shift-movy = move max layer slider\n Alt-movy = move min layer slider"
            self._showMessage(message)
        except Exception as e:
            Logger.log("e", "Exception while showing device information: %s",
                       e)

    def _showMessage(self, str):
        if self._message is None:
            self._message = Message(
                title=catalog.i18nc("@info:title", "RawMouse " +
                                    self.getVersion()))
        self._message.hide()
        self._message.setText(catalog.i18nc("@info:status", str))
        self._message.show()

    def _rotateCamera(self, yaw: float, pitch: float, roll: float) -> None:
        camera = self._scene.getActiveCamera()
        if not camera or not camera.isEnabled():
            return

        dyaw = math.radians(yaw * 180.0)
        dpitch = math.radians(pitch * 180.0)
        droll = math.radians(roll * 180.0)

        diff = camera.getPosition() - self._camera_tool._origin

        myaw = Matrix()
        myaw.setByRotationAxis(dyaw, Vector.Unit_Y)

        mpitch = Matrix(myaw.getData())
        mpitch.rotateByAxis(dpitch, Vector.Unit_Y.cross(diff))

        n = diff.multiply(mpitch)

        try:
            angle = math.acos(Vector.Unit_Y.dot(n.normalized()))
        except ValueError:
            return

        if angle < 0.1 or angle > math.pi - 0.1:
            n = diff.multiply(myaw)

        n += self._camera_tool._origin

        camera.setPosition(n)

        if abs(self._roll + droll) < math.pi * 0.45:
            self._roll += droll
        mroll = Matrix()
        mroll.setByRotationAxis(self._roll, (n - self._camera_tool._origin))
        camera.lookAt(self._camera_tool._origin, Vector.Unit_Y.multiply(mroll))

    def _run_libspnav(self):
        self._running = True
        Logger.log("d", "Reading events from libspnav...")
        try:
            if spnavOpen() == False:
                self._last_camera_update_at = QTime()
                self._last_camera_update_at.start()
                self._fast_view = False
                while self._running:
                    if self._main_window:
                        event = spnavWaitEvent()
                        if event is not None:
                            if self._main_window.isActive():
                                if event.type == SPNAV_EVENT_MOTION:
                                    if event.motion.x == 0 and event.motion.y == 0 and event.motion.z == 0 and event.motion.rx == 0 and event.motion.ry == 0 and event.motion.rz == 0:
                                        if self._fast_view:
                                            self._controller.setActiveView(
                                                "SimulationView")
                                            self._fast_view = False
                                    scale = 1 / 500.0
                                    self._spacemouseAxisEvent([
                                        event.motion.x * scale *
                                        self._axis_scale[0] +
                                        self._axis_offset[0], event.motion.y *
                                        scale * self._axis_scale[1] +
                                        self._axis_offset[1], event.motion.z *
                                        scale * self._axis_scale[2] +
                                        self._axis_offset[2], event.motion.rx *
                                        scale * self._axis_scale[3] +
                                        self._axis_offset[3], event.motion.ry *
                                        scale * self._axis_scale[4] +
                                        self._axis_offset[4], event.motion.rz *
                                        scale * self._axis_scale[5] +
                                        self._axis_offset[5]
                                    ])
                                elif event.type == SPNAV_EVENT_BUTTON:
                                    self._spacemouseButtonEvent(
                                        event.button.bnum, event.button.press)
                    else:
                        self._getComponents()
                        time.sleep(0.1)
                spnavClose()
            else:
                Logger.log("e", "spnavOpen() failed")
        except Exception as e:
            Logger.log("e", "Exception while reading libspnav events: %s", e)
        self._running = False
        self._runner = None
Beispiel #11
0
class ModelChecker(QObject, Extension):
    ##  Signal that gets emitted when anything changed that we need to check.
    onChanged = pyqtSignal()

    def __init__(self):
        super().__init__()

        self._button_view = None

        self._caution_message = Message(
            "",  #Message text gets set when the message gets shown, to display the models in question.
            lifetime=0,
            title=catalog.i18nc("@info:title", "3D Model Assistant"))

        self._change_timer = QTimer()
        self._change_timer.setInterval(200)
        self._change_timer.setSingleShot(True)
        self._change_timer.timeout.connect(self.onChanged)

        Application.getInstance().initializationFinished.connect(
            self._pluginsInitialized)
        Application.getInstance().getController().getScene(
        ).sceneChanged.connect(self._onChanged)
        Application.getInstance().globalContainerStackChanged.connect(
            self._onChanged)

    def _onChanged(self, *args, **kwargs):
        # Ignore camera updates.
        if len(args) == 0:
            self._change_timer.start()
            return
        if not isinstance(args[0], Camera):
            self._change_timer.start()

    ##  Called when plug-ins are initialized.
    #
    #   This makes sure that we listen to changes of the material and that the
    #   button is created that indicates warnings with the current set-up.
    def _pluginsInitialized(self):
        Application.getInstance().getMachineManager(
        ).rootMaterialChanged.connect(self.onChanged)
        self._createView()

    def checkObjectsForShrinkage(self):
        shrinkage_threshold = 0.5  #From what shrinkage percentage a warning will be issued about the model size.
        warning_size_xy = 150  #The horizontal size of a model that would be too large when dealing with shrinking materials.
        warning_size_z = 100  #The vertical size of a model that would be too large when dealing with shrinking materials.

        # This function can be triggered in the middle of a machine change, so do not proceed if the machine change
        # has not done yet.
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if global_container_stack is None:
            return False

        material_shrinkage = self._getMaterialShrinkage()

        warning_nodes = []

        # Check node material shrinkage and bounding box size
        for node in self.sliceableNodes():
            node_extruder_position = node.callDecoration(
                "getActiveExtruderPosition")

            # This function can be triggered in the middle of a machine change, so do not proceed if the machine change
            # has not done yet.
            try:
                extruder = global_container_stack.extruderList[int(
                    node_extruder_position)]
            except IndexError:
                Application.getInstance().callLater(
                    lambda: self.onChanged.emit())
                return False

            if material_shrinkage[node_extruder_position] > shrinkage_threshold:
                bbox = node.getBoundingBox()
                if bbox.width >= warning_size_xy or bbox.depth >= warning_size_xy or bbox.height >= warning_size_z:
                    warning_nodes.append(node)

        self._caution_message.setText(
            catalog.i18nc(
                "@info:status",
                "<p>One or more 3D models may not print optimally due to the model size and material configuration:</p>\n"
                "<p>{model_names}</p>\n"
                "<p>Find out how to ensure the best possible print quality and reliability.</p>\n"
                "<p><a href=\"https://ultimaker.com/3D-model-assistant\">View print quality guide</a></p>"
            ).format(
                model_names=", ".join([n.getName() for n in warning_nodes])))

        return len(warning_nodes) > 0

    def sliceableNodes(self):
        # Add all sliceable scene nodes to check
        scene = Application.getInstance().getController().getScene()
        for node in DepthFirstIterator(scene.getRoot()):
            if node.callDecoration("isSliceable"):
                yield node

    ##  Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
    def _createView(self):
        Logger.log("d", "Creating model checker view.")

        # Create the plugin dialog component
        path = os.path.join(
            PluginRegistry.getInstance().getPluginPath("ModelChecker"),
            "ModelChecker.qml")
        self._button_view = Application.getInstance().createQmlComponent(
            path, {"manager": self})

        # The qml is only the button
        Application.getInstance().addAdditionalComponent(
            "jobSpecsButton", self._button_view)

        Logger.log("d", "Model checker view created.")

    @pyqtProperty(bool, notify=onChanged)
    def hasWarnings(self):
        danger_shrinkage = self.checkObjectsForShrinkage()
        return any((danger_shrinkage,
                    ))  #If any of the checks fail, show the warning button.

    @pyqtSlot()
    def showWarnings(self):
        self._caution_message.show()

    def _getMaterialShrinkage(self):
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if global_container_stack is None:
            return {}

        material_shrinkage = {}
        # Get all shrinkage values of materials used
        for extruder_position, extruder in enumerate(
                global_container_stack.extruderList):
            shrinkage = extruder.material.getProperty(
                "material_shrinkage_percentage", "value")
            if shrinkage is None:
                shrinkage = 0
            material_shrinkage[str(extruder_position)] = shrinkage
        return material_shrinkage
Beispiel #12
0
    def _devicesRemovedFromAccount(self, removed_device_ids: Set[str]) -> None:
        """
        Removes the CloudOutputDevice from the received device ids and marks the specific printers as "removed from
        account". In addition, it generates a message to inform the user about the printers that are no longer linked to
        his/her account. The message is not generated if all the printers have been previously reported as not linked
        to the account.

        :param removed_device_ids: Set of device ids, whose CloudOutputDevice needs to be removed
        :return: None
        """

        if not CuraApplication.getInstance().getCuraAPI().account.isLoggedIn:
            return

        # Do not report device ids which have been previously marked as non-linked to the account
        ignored_device_ids = set()
        for device_id in removed_device_ids:
            if not parseBool(self._um_cloud_printers[device_id].getMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, "true")):
                ignored_device_ids.add(device_id)
        # Keep the reported_device_ids list in a class variable, so that the message button actions can access it and
        # take the necessary steps to fulfill their purpose.
        self.reported_device_ids = removed_device_ids - ignored_device_ids
        if not self.reported_device_ids:
            return

        # Generate message
        removed_printers_message = Message(
                title = self.I18N_CATALOG.i18ncp(
                        "info:status",
                        "Cloud connection is not available for a printer",
                        "Cloud connection is not available for some printers",
                        len(self.reported_device_ids)
                ),
                lifetime = 0
        )
        device_names = "\n".join(["<li>{} ({})</li>".format(self._um_cloud_printers[device].name, self._um_cloud_printers[device].definition.name) for device in self.reported_device_ids])
        message_text = self.I18N_CATALOG.i18ncp(
                "info:status",
                "The following cloud printer is not linked to your account:\n",
                "The following cloud printers are not linked to your account:\n",
                len(self.reported_device_ids)
        )
        message_text += self.I18N_CATALOG.i18nc(
                "info:status",
                "<ul>{}</ul>\nTo establish a connection, please visit the "
                "<a href='https://mycloud.ultimaker.com/'>Ultimaker Digital Factory</a>.",
                device_names
        )
        removed_printers_message.setText(message_text)
        removed_printers_message.addAction("keep_printer_configurations_action",
                               name = self.I18N_CATALOG.i18nc("@action:button", "Keep printer configurations"),
                               icon = "",
                               description = "Keep the configuration of the cloud printer(s) synced with Cura which are not linked to your account.",
                               button_align = Message.ActionButtonAlignment.ALIGN_RIGHT)
        removed_printers_message.actionTriggered.connect(self._onRemovedPrintersMessageActionTriggered)

        output_device_manager = CuraApplication.getInstance().getOutputDeviceManager()

        # Remove the output device from the printers
        for device_id in removed_device_ids:
            device = self._um_cloud_printers.get(device_id, None)  # type: Optional[GlobalStack]
            if not device:
                continue
            if device_id in output_device_manager.getOutputDeviceIds():
                output_device_manager.removeOutputDevice(device_id)
            if device_id in self._remote_clusters:
                del self._remote_clusters[device_id]

            # Update the printer's metadata to mark it as not linked to the account
            device.setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, False)

        removed_printers_message.show()
Beispiel #13
0
class ModelChecker(QObject, Extension):
    ##  Signal that gets emitted when anything changed that we need to check.
    onChanged = pyqtSignal()

    def __init__(self):
        super().__init__()

        self._button_view = None

        self._caution_message = Message(
            "",  #Message text gets set when the message gets shown, to display the models in question.
            lifetime=0,
            title=catalog.i18nc("@info:title", "Model Checker Warning"))

        Application.getInstance().initializationFinished.connect(
            self._pluginsInitialized)
        Application.getInstance().getController().getScene(
        ).sceneChanged.connect(self._onChanged)

    ##  Pass-through to allow UM.Signal to connect with a pyqtSignal.
    def _onChanged(self, _):
        self.onChanged.emit()

    ##  Called when plug-ins are initialized.
    #
    #   This makes sure that we listen to changes of the material and that the
    #   button is created that indicates warnings with the current set-up.
    def _pluginsInitialized(self):
        Application.getInstance().getMachineManager(
        ).rootMaterialChanged.connect(self.onChanged)
        self._createView()

    def checkObjectsForShrinkage(self):
        shrinkage_threshold = 0.5  #From what shrinkage percentage a warning will be issued about the model size.
        warning_size_xy = 150  #The horizontal size of a model that would be too large when dealing with shrinking materials.
        warning_size_z = 100  #The vertical size of a model that would be too large when dealing with shrinking materials.

        material_shrinkage = self._getMaterialShrinkage()

        warning_nodes = []

        # Check node material shrinkage and bounding box size
        for node in self.sliceableNodes():
            node_extruder_position = node.callDecoration(
                "getActiveExtruderPosition")
            if material_shrinkage[node_extruder_position] > shrinkage_threshold:
                bbox = node.getBoundingBox()
                if bbox.width >= warning_size_xy or bbox.depth >= warning_size_xy or bbox.height >= warning_size_z:
                    warning_nodes.append(node)

        self._caution_message.setText(
            catalog.i18nc(
                "@info:status",
                "Some models may not be printed optimal due to object size and chosen material for models: {model_names}.\n"
                "Tips that may be useful to improve the print quality:\n"
                "1) Use rounded corners\n"
                "2) Turn the fan off (only if the are no tiny details on the model)\n"
                "3) Use a different material").format(
                    model_names=", ".join([n.getName()
                                           for n in warning_nodes])))

        return len(warning_nodes) > 0

    def sliceableNodes(self):
        # Add all sliceable scene nodes to check
        scene = Application.getInstance().getController().getScene()
        for node in DepthFirstIterator(scene.getRoot()):
            if node.callDecoration("isSliceable"):
                yield node

    ##  Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
    def _createView(self):
        Logger.log("d", "Creating model checker view.")

        # Create the plugin dialog component
        path = os.path.join(
            PluginRegistry.getInstance().getPluginPath("ModelChecker"),
            "ModelChecker.qml")
        self._button_view = Application.getInstance().createQmlComponent(
            path, {"manager": self})

        # The qml is only the button
        Application.getInstance().addAdditionalComponent(
            "jobSpecsButton", self._button_view)

        Logger.log("d", "Model checker view created.")

    @pyqtProperty(bool, notify=onChanged)
    def runChecks(self):
        danger_shrinkage = self.checkObjectsForShrinkage()

        return any((danger_shrinkage,
                    ))  #If any of the checks fail, show the warning button.

    @pyqtSlot()
    def showWarnings(self):
        self._caution_message.show()

    def _getMaterialShrinkage(self):
        global_container_stack = Application.getInstance(
        ).getGlobalContainerStack()
        if global_container_stack is None:
            return {}

        material_shrinkage = {}
        # Get all shrinkage values of materials used
        for extruder_position, extruder in global_container_stack.extruders.items(
        ):
            shrinkage = extruder.material.getProperty(
                "material_shrinkage_percentage", "value")
            if shrinkage is None:
                shrinkage = 0
            material_shrinkage[extruder_position] = shrinkage
        return material_shrinkage
class SmartSliceJobHandler:

    INFILL_CURA_SMARTSLICE = {
        "grid": pywim.am.InfillType.grid,
        "triangles": pywim.am.InfillType.triangle,
        #"cubic": pywim.am.InfillType.cubic
    }
    INFILL_SMARTSLICE_CURA = {value: key for key, value in INFILL_CURA_SMARTSLICE.items()}

    INFILL_DIRECTION = 45

    materialWarning = Signal()

    def __init__(self, handler: SmartSlicePropertyHandler):
        self._all_extruders_settings = None
        self._propertyHandler = handler

        self._material_warning = Message(lifetime=0)
        self._material_warning.addAction(
            action_id="supported_materials_link",
            name="<h4><b>Supported Materials</b></h4>",
            icon="",
            description="List of tested and supported materials",
            button_style=Message.ActionButtonStyle.LINK
        )
        self._material_warning.actionTriggered.connect(self._openMaterialsPage)
        self.materialWarning.connect(handler.materialWarned)


    # Builds and checks a smart slice job for errors based on current setup defined by the property handler
    # Will return the job, and a dictionary of error keys and associated error resolutions
    def checkJob(self, machine_name="printer", show_extruder_warnings=False) -> Tuple[pywim.smartslice.job.Job, Dict[str, str]]:

        if len(getPrintableNodes()) == 0:
            return None, {}

        # Create a new instance of errors. We will use this to replace the old errors and
        # emit a signal to replace them
        errors = []

        if len(getPrintableNodes()) != 1:
            errors.append(pywim.smartslice.val.InvalidSetup(
                "Invalid number of printable models on the build tray",
                "Only 1 printable model is currently supported"
            ))

        # We build a new job from scratch evertime - it's easier than trying to manage a whole bunch of changes
        job = pywim.smartslice.job.Job()

        # Extruder Manager
        extruderManager = Application.getInstance().getExtruderManager()
        emActive = extruderManager._active_extruder_index

        # Normal mesh
        normal_mesh = getPrintableNodes()[0]

        # Get all nodes to cycle through
        nodes = [normal_mesh] + getModifierMeshes()

        # Cycle through all of the meshes and check extruder
        for node in nodes:
            active_extruder = getNodeActiveExtruder(node)

            # Build the data for Smart Slice error checking
            mesh = pywim.chop.mesh.Mesh(node.getName())
            mesh.print_config.auxiliary = self._getAuxDict(node.callDecoration("getStack"))
            job.chop.meshes.add(mesh)

            # Check the active extruder
            any_individual_extruder = all(map(lambda k : (int(active_extruder.getProperty(k, "value")) <= 0), ExtruderProperty.EXTRUDER_KEYS))
            if not ( int(active_extruder.getMetaDataEntry("position")) == 0 and int(emActive) == 0 and any_individual_extruder ):
                errors.append(pywim.smartslice.val.InvalidSetup(
                    "Invalid extruder selected for <i>{}</i>".format(node.getName()),
                    "Change active extruder to Extruder 1"
                ))
                show_extruder_warnings = False # Turn the warnings off - we aren't on the right extruder

        # Check the material
        machine_extruder = getNodeActiveExtruder(normal_mesh)
        guid = machine_extruder.material.getMetaData().get("GUID", "")
        material, tested = self.getMaterial(guid)

        if not material:
            errors.append(pywim.smartslice.val.InvalidSetup(
                "Material <i>{}</i> is not currently supported for Smart Slice".format(machine_extruder.material.name),
                "Please select a supported material."
            ))
        else:
            if not tested and show_extruder_warnings:
                self._material_warning.setText(i18n_catalog.i18nc(
                    "@info:status", "Material <b>{}</b> has not been tested for Smart Slice. A generic equivalent will be used.".format(machine_extruder.material.name)
                ))
                self._material_warning.show()

                self.materialWarning.emit(guid)
            elif tested:
                self._material_warning.hide()

            job.bulk.add(
                pywim.fea.model.Material.from_dict(material)
            )

        # Use Cases
        smart_sliceScene_node = findChildSceneNode(getPrintableNodes()[0], Root)
        if smart_sliceScene_node:
            job.chop.steps = smart_sliceScene_node.createSteps()

        # Requirements
        req_tool = SmartSliceRequirements.getInstance()
        job.optimization.min_safety_factor = req_tool.targetSafetyFactor
        job.optimization.max_displacement = req_tool.maxDisplacement

        # Global print config -- assuming only 1 extruder is active for ALL meshes right now
        print_config = pywim.am.Config()
        print_config.layer_height = self._propertyHandler.getGlobalProperty("layer_height")
        print_config.layer_width = self._propertyHandler.getExtruderProperty("line_width")
        print_config.walls = self._propertyHandler.getExtruderProperty("wall_line_count")
        print_config.bottom_layers = self._propertyHandler.getExtruderProperty("top_layers")
        print_config.top_layers = self._propertyHandler.getExtruderProperty("bottom_layers")

        # > https://github.com/Ultimaker/CuraEngine/blob/master/src/FffGcodeWriter.cpp#L402
        skin_angles = self._propertyHandler.getExtruderProperty("skin_angles")
        if type(skin_angles) is str:
            skin_angles = SettingFunction(skin_angles)(self._propertyHandler)
        if len(skin_angles) > 0:
            print_config.skin_orientations.extend(tuple(skin_angles))
        else:
            print_config.skin_orientations.extend((45, 135))

        infill_pattern = self._propertyHandler.getExtruderProperty("infill_pattern")
        print_config.infill.density = self._propertyHandler.getExtruderProperty("infill_sparse_density")
        if infill_pattern in self.INFILL_CURA_SMARTSLICE.keys():
            print_config.infill.pattern = self.INFILL_CURA_SMARTSLICE[infill_pattern]
        else:
            print_config.infill.pattern = infill_pattern # The job validation will handle the error

        # > https://github.com/Ultimaker/CuraEngine/blob/master/src/FffGcodeWriter.cpp#L366
        infill_angles = self._propertyHandler.getExtruderProperty("infill_angles")
        if type(infill_angles) is str:
            infill_angles = SettingFunction(infill_angles)(self._propertyHandler)
        if len(infill_angles) == 0:
            print_config.infill.orientation = self.INFILL_DIRECTION
        else:
            if len(infill_angles) > 1:
                Logger.log("w", "More than one infill angle is set! Only the first will be taken!")
                Logger.log("d", "Ignoring the angles: {}".format(infill_angles[1:]))
            print_config.infill.orientation = infill_angles[0]

        print_config.auxiliary = self._getAuxDict(
            Application.getInstance().getGlobalContainerStack()
        )

        # Extruder config
        extruders = ()
        machine_extruder = getNodeActiveExtruder(normal_mesh)
        for extruder_stack in [machine_extruder]:
            extruder = pywim.chop.machine.Extruder(diameter=extruder_stack.getProperty("machine_nozzle_size", "value"))
            extruder.print_config.auxiliary = self._getAuxDict(extruder_stack)
            extruders += (extruder,)

        printer = pywim.chop.machine.Printer(name=machine_name, extruders=extruders)
        job.chop.slicer = pywim.chop.slicer.CuraEngine(config=print_config, printer=printer)

        # Check the job and add the errors
        errors = errors + job.validate()

        error_dict = {}
        for err in errors:
            error_dict[err.error()] = err.resolution()

        return job, error_dict

    # Builds a complete smart slice job to be written to a 3MF
    def buildJobFor3mf(self, machine_name="printer") -> pywim.smartslice.job.Job:

        job, errors = self.checkJob(machine_name)

        # Clear out the data we don't need or will override
        job.chop.meshes.clear()
        job.extruders.clear()

        if len(errors) > 0:
            Logger.log("w", "Unresolved errors in the Smart Slice setup!")
            return None

        normal_mesh = getPrintableNodes()[0]

        # The am.Config contains an "auxiliary" dictionary which should
        # be used to define the slicer specific settings. These will be
        # passed on directly to the slicer (CuraEngine).
        print_config = job.chop.slicer.print_config
        print_config.auxiliary = self._buildGlobalSettingsMessage()

        # Setup the slicer configuration. See each class for more
        # information.
        extruders = ()
        machine_extruder = getNodeActiveExtruder(normal_mesh)
        for extruder_stack in [machine_extruder]:
            extruder_nr = extruder_stack.getProperty("extruder_nr", "value")
            extruder_object = pywim.chop.machine.Extruder(diameter=extruder_stack.getProperty("machine_nozzle_size", "value"))
            pickled_info = self._buildExtruderMessage(extruder_stack)
            extruder_object.id = pickled_info["id"]
            extruder_object.print_config.auxiliary = pickled_info["settings"]
            extruders += (extruder_object,)

            # Create the extruder object in the smart slice job that defines
            # the usable bulk materials for this extruder. Currently, all materials
            # are usable in each extruder (should only be one extruder right now).
            extruder_materials = pywim.smartslice.job.Extruder(number=extruder_nr)
            extruder_materials.usable_materials.extend(
                [m.name for m in job.bulk]
            )

            job.extruders.append(extruder_materials)

        if len(extruders) == 0:
            Logger.log("e", "Did not find the extruder with position %i", machine_extruder.position)

        printer = pywim.chop.machine.Printer(name=machine_name, extruders=extruders)

        # And finally set the slicer to the Cura Engine with the config and printer defined above
        job.chop.slicer = pywim.chop.slicer.CuraEngine(config=print_config, printer=printer)

        return job

    # Writes a smartslice job to a 3MF file
    @classmethod
    def write3mf(self, threemf_path, mesh_nodes, job: pywim.smartslice.job.Job):
        # Getting 3MF writer and write our file
        threeMF_Writer = Application.getInstance().getMeshFileHandler().getWriter("3MFWriter")
        if threeMF_Writer is not None:
            threeMF_Writer.write(threemf_path, mesh_nodes)

            threemf_file = zipfile.ZipFile(threemf_path, 'a')
            threemf_file.writestr('SmartSlice/job.json', job.to_json() )
            threemf_file.close()

            return True

        return False

    # Reads a 3MF file into a smartslice job
    @classmethod
    def extractSmartSliceJobFrom3MF(self, file) -> pywim.smartslice.job.Job:
        tmf = threemf.ThreeMF()

        tmf_reader = threemf.io.Reader()
        tmf_reader.register_extension(pywim.smartslice.ThreeMFExtension)

        tmf_reader.read(tmf, file)

        if len(tmf.extensions) != 1:
            raise Exception('3MF extension count is not 1')

        ext = tmf.extensions[0]

        job_assets = list(
            filter(lambda a: isinstance(a, pywim.smartslice.JobThreeMFAsset), ext.assets)
        )

        if len(job_assets) == 0:
            raise SmartSliceCloudJob.JobException('Could not find smart slice information in 3MF')

        return job_assets[0].content

    @classmethod
    def getMaterial(self, guid):
        '''
            Returns a dictionary of the material definition and whether the material is tested.
            Will return a None material if it is not supported
        '''
        this_dir = os.path.split(__file__)[0]
        database_location = os.path.join(this_dir, "data", "POC_material_database.json")
        jdata = json.loads(open(database_location).read())

        for material in jdata["materials"]:

            if "cura-tested-guid" in material and guid in material["cura-tested-guid"]:
                return material, True
            elif "cura-generic-guid" in material and guid in material["cura-generic-guid"]:
                return material, False
            elif "cura-guid" in material and guid in material["cura-guid"]: # Backwards compatibility (don't think this should ever happen)
                return material, True

        return None, False

    def _openMaterialsPage(self, msg, action):
        QDesktopServices.openUrl(QUrl("https://help.tetonsim.com/supported-materials"))

    def _getAuxDict(self, stack):
        aux = {}
        for prop in pywim.smartslice.val.NECESSARY_PRINT_PARAMETERS:
            val = stack.getProperty(prop, "value")
            if val:
                aux[prop] = str(val)

        return aux

    ##  Check if a node has per object settings and ensure that they are set correctly in the message
    #   \param node Node to check.
    #   \param message object_lists message to put the per object settings in
    def _handlePerObjectSettings(self, node):
        stack = node.callDecoration("getStack")

        # Check if the node has a stack attached to it and the stack has any settings in the top container.
        if not stack:
            return

        # Check all settings for relations, so we can also calculate the correct values for dependent settings.
        top_of_stack = stack.getTop()  # Cache for efficiency.
        changed_setting_keys = top_of_stack.getAllKeys()

        # Add all relations to changed settings as well.
        for key in top_of_stack.getAllKeys():
            instance = top_of_stack.getInstance(key)
            self._addRelations(changed_setting_keys, instance.definition.relations)

        # Ensure that the engine is aware what the build extruder is.
        changed_setting_keys.add("extruder_nr")

        settings = []
        # Get values for all changed settings
        for key in changed_setting_keys:
            setting = {}
            setting["name"] = key
            extruder = int(round(float(stack.getProperty(key, "limit_to_extruder"))))

            # Check if limited to a specific extruder, but not overridden by per-object settings.
            if extruder >= 0 and key not in changed_setting_keys:
                limited_stack = ExtruderManager.getInstance().getActiveExtruderStacks()[extruder]
            else:
                limited_stack = stack

            setting["value"] = str(limited_stack.getProperty(key, "value"))

            settings.append(setting)

        return settings

    def _cacheAllExtruderSettings(self):
        global_stack = Application.getInstance().getGlobalContainerStack()

        # NB: keys must be strings for the string formatter
        self._all_extruders_settings = {
            "-1": self._buildReplacementTokens(global_stack)
        }
        for extruder_stack in ExtruderManager.getInstance().getActiveExtruderStacks():
            extruder_nr = extruder_stack.getProperty("extruder_nr", "value")
            self._all_extruders_settings[str(extruder_nr)] = self._buildReplacementTokens(extruder_stack)

    # #  Creates a dictionary of tokens to replace in g-code pieces.
    #
    #   This indicates what should be replaced in the start and end g-codes.
    #   \param stack The stack to get the settings from to replace the tokens
    #   with.
    #   \return A dictionary of replacement tokens to the values they should be
    #   replaced with.
    def _buildReplacementTokens(self, stack):

        result = {}
        for key in stack.getAllKeys():
            value = stack.getProperty(key, "value")
            result[key] = value

        result["print_bed_temperature"] = result["material_bed_temperature"]  # Renamed settings.
        result["print_temperature"] = result["material_print_temperature"]
        result["travel_speed"] = result["speed_travel"]
        result["time"] = time.strftime("%H:%M:%S")  # Some extra settings.
        result["date"] = time.strftime("%d-%m-%Y")
        result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))]

        initial_extruder_stack = Application.getInstance().getExtruderManager().getUsedExtruderStacks()[0]
        initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value")
        result["initial_extruder_nr"] = initial_extruder_nr

        return result

    # #  Replace setting tokens in a piece of g-code.
    #   \param value A piece of g-code to replace tokens in.
    #   \param default_extruder_nr Stack nr to use when no stack nr is specified, defaults to the global stack
    def _expandGcodeTokens(self, value, default_extruder_nr) -> str:
        self._cacheAllExtruderSettings()

        try:
            # any setting can be used as a token
            fmt = GcodeStartEndFormatter(default_extruder_nr=default_extruder_nr)
            if self._all_extruders_settings is None:
                return ""
            settings = self._all_extruders_settings.copy()
            settings["default_extruder_nr"] = default_extruder_nr
            return str(fmt.format(value, **settings))
        except:
            Logger.logException("w", "Unable to do token replacement on start/end g-code")
            return str(value)

    def _modifyInfillAnglesInSettingDict(self, settings):
        for key, value in settings.items():
            if key == "infill_angles":
                if type(value) is str:
                    value = SettingFunction(value)(self._propertyHandler)
                if len(value) == 0:
                    settings[key] = [self.INFILL_DIRECTION]
                else:
                    settings[key] = [value[0]]

        return settings

    # #  Sends all global settings to the engine.
    #
    #   The settings are taken from the global stack. This does not include any
    #   per-extruder settings or per-object settings.
    def _buildGlobalSettingsMessage(self, stack=None):
        if not stack:
            stack = Application.getInstance().getGlobalContainerStack()

        if not stack:
            return

        self._cacheAllExtruderSettings()

        if self._all_extruders_settings is None:
            return

        settings = self._all_extruders_settings["-1"].copy()

        # Pre-compute material material_bed_temp_prepend and material_print_temp_prepend
        start_gcode = settings["machine_start_gcode"]
        bed_temperature_settings = ["material_bed_temperature", "material_bed_temperature_layer_0"]
        pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(bed_temperature_settings)  # match {setting} as well as {setting, extruder_nr}
        settings["material_bed_temp_prepend"] = re.search(pattern, start_gcode) == None
        print_temperature_settings = ["material_print_temperature", "material_print_temperature_layer_0", "default_material_print_temperature", "material_initial_print_temperature", "material_final_print_temperature", "material_standby_temperature"]
        pattern = r"\{(%s)(,\s?\w+)?\}" % "|".join(print_temperature_settings)  # match {setting} as well as {setting, extruder_nr}
        settings["material_print_temp_prepend"] = re.search(pattern, start_gcode) == None

        # Replace the setting tokens in start and end g-code.
        # Use values from the first used extruder by default so we get the expected temperatures
        initial_extruder_stack = Application.getInstance().getExtruderManager().getUsedExtruderStacks()[0]
        initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value")

        settings["machine_start_gcode"] = self._expandGcodeTokens(settings["machine_start_gcode"], initial_extruder_nr)
        settings["machine_end_gcode"] = self._expandGcodeTokens(settings["machine_end_gcode"], initial_extruder_nr)

        settings = self._modifyInfillAnglesInSettingDict(settings)

        for key, value in settings.items():
            if type(value) is not str:
                settings[key] = str(value)

        return settings

    # #  Sends for some settings which extruder they should fallback to if not
    #   set.
    #
    #   This is only set for settings that have the limit_to_extruder
    #   property.
    #
    #   \param stack The global stack with all settings, from which to read the
    #   limit_to_extruder property.
    def _buildGlobalInheritsStackMessage(self, stack):
        limit_to_extruder_message = []
        for key in stack.getAllKeys():
            extruder_position = int(round(float(stack.getProperty(key, "limit_to_extruder"))))
            if extruder_position >= 0:  # Set to a specific extruder.
                setting_extruder = {}
                setting_extruder["name"] = key
                setting_extruder["extruder"] = extruder_position
                limit_to_extruder_message.append(setting_extruder)
        return limit_to_extruder_message

    # #  Create extruder message from stack
    def _buildExtruderMessage(self, stack) -> Optional[Dict]:
        extruder_message = {}
        extruder_message["id"] = int(stack.getMetaDataEntry("position"))
        self._cacheAllExtruderSettings()

        if self._all_extruders_settings is None:
            return

        extruder_nr = stack.getProperty("extruder_nr", "value")
        settings = self._all_extruders_settings[str(extruder_nr)].copy()

        # Also send the material GUID. This is a setting in fdmprinter, but we have no interface for it.
        settings["material_guid"] = stack.material.getMetaDataEntry("GUID", "")

        # Replace the setting tokens in start and end g-code.
        extruder_nr = stack.getProperty("extruder_nr", "value")
        settings["machine_extruder_start_code"] = self._expandGcodeTokens(settings["machine_extruder_start_code"], extruder_nr)
        settings["machine_extruder_end_code"] = self._expandGcodeTokens(settings["machine_extruder_end_code"], extruder_nr)

        settings = self._modifyInfillAnglesInSettingDict(settings)

        for key, value in settings.items():
            if type(value) is not str:
                settings[key] = str(value)

        extruder_message["settings"] = settings

        return extruder_message
Beispiel #15
0
class MoonrakerOutputDevice(OutputDevice):
    def __init__(self, config):
        self._name_id = "moonraker-upload"
        super().__init__(self._name_id)

        self._url = config.get("url", "")
        self._api_key = config.get("api_key", "")
        self._power_device = config.get("power_device", "")
        self._output_format = config.get("output_format", "gcode")
        if self._output_format and self._output_format != "ufp":
            self._output_format = "gcode"
        self._upload_start_print_job = config.get("upload_start_print_job",
                                                  False)
        self._upload_remember_state = config.get("upload_remember_state",
                                                 False)
        self._upload_autohide_messagebox = config.get(
            "upload_autohide_messagebox", False)
        self._trans_input = config.get("trans_input", "")
        self._trans_output = config.get("trans_output", "")
        self._trans_remove = config.get("trans_remove", "")

        self.application = CuraApplication.getInstance()
        global_container_stack = self.application.getGlobalContainerStack()
        self._name = global_container_stack.getName()

        description = catalog.i18nc("@action:button",
                                    "Upload to {0}").format(self._name)
        self.setShortDescription(description)
        self.setDescription(description)

        self._stage = OutputStage.ready
        self._stream = None
        self._message = None

        self._timeout_cnt = 0

        Logger.log(
            "d",
            "New MoonrakerOutputDevice '{}' created | URL: {} | API-Key: {}".
            format(
                self._name_id,
                self._url,
                self._api_key,
            ))
        self._resetState()

    def requestWrite(self, node, fileName=None, *args, **kwargs):
        if self._stage != OutputStage.ready:
            raise OutputDeviceError.DeviceBusyError()

        # Make sure post-processing plugin are run on the gcode
        self.writeStarted.emit(self)

        # The presliced print should always be send using `GCodeWriter`
        print_info = CuraApplication.getInstance().getPrintInformation()
        if self._output_format != "ufp" or not print_info or print_info.preSliced:
            self._output_format = "gcode"
            code_writer = cast(
                MeshWriter,
                PluginRegistry.getInstance().getPluginObject("GCodeWriter"))
            self._stream = StringIO()
        else:
            code_writer = cast(
                MeshWriter,
                PluginRegistry.getInstance().getPluginObject("UFPWriter"))
            self._stream = BytesIO()

        if not code_writer.write(self._stream, None):
            Logger.log("e",
                       "MeshWriter failed: %s" % code_writer.getInformation())
            return

        # Prepare filename for upload
        if fileName:
            fileName = os.path.basename(fileName)
        else:
            fileName = "%s." % Application.getInstance().getPrintInformation(
            ).jobName

        # Translate filename
        if self._trans_input and self._trans_output:
            transFileName = fileName.translate(
                fileName.maketrans(
                    self._trans_input, self._trans_output,
                    self._trans_remove if self._trans_remove else ""))
            fileName = transFileName

        self._fileName = fileName + "." + self._output_format

        # Display upload dialog
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            'resources', 'qml', 'MoonrakerUpload.qml')
        self._dialog = CuraApplication.getInstance().createQmlComponent(
            path, {"manager": self})
        self._dialog.textChanged.connect(self.onFilenameChanged)
        self._dialog.accepted.connect(self.onFilenameAccepted)
        self._dialog.show()
        self._dialog.findChild(QObject, "nameField").setProperty(
            'text', self._fileName)
        self._dialog.findChild(QObject, "nameField").select(
            0,
            len(self._fileName) - len(self._output_format) - 1)
        self._dialog.findChild(QObject, "nameField").setProperty('focus', True)
        self._dialog.findChild(QObject, "printField").setProperty(
            'checked', self._upload_start_print_job)

    def onFilenameChanged(self):
        fileName = self._dialog.findChild(
            QObject, "nameField").property('text').strip()

        forbidden_characters = ":*?\"<>|"
        for forbidden_character in forbidden_characters:
            if forbidden_character in fileName:
                self._dialog.setProperty('validName', False)
                self._dialog.setProperty(
                    'validationError',
                    '*cannot contain {}'.format(forbidden_characters))
                return

        if fileName == '.' or fileName == '..':
            self._dialog.setProperty('validName', False)
            self._dialog.setProperty('validationError',
                                     '*cannot be "." or ".."')
            return

        self._dialog.setProperty('validName', len(fileName) > 0)
        self._dialog.setProperty('validationError', 'Filename too short')

    def onFilenameAccepted(self):
        self._fileName = self._dialog.findChild(
            QObject, "nameField").property('text').strip()
        if not self._fileName.endswith(
                '.' + self._output_format) and '.' not in self._fileName:
            self._fileName += '.' + self._output_format
        Logger.log("d", "Filename set to: " + self._fileName)

        self._startPrint = self._dialog.findChild(
            QObject, "printField").property('checked')
        if self._upload_remember_state:
            self._upload_start_print_job = self._startPrint
            s = get_config()
            s["upload_start_print_job"] = self._startPrint
            save_config(s)
        Logger.log("d", "Print set to: " + str(self._startPrint))

        self._dialog.deleteLater()

        Logger.log("d", "Connecting to Moonraker at {} ...".format(self._url))
        # show a status message with spinner
        messageText = self._getConnectMsgText()
        self._message = Message(catalog.i18nc("@info:status", messageText), 0,
                                False)
        self._message.show()

        if self._power_device:
            self.getPrinterDeviceStatus()
        else:
            self.getPrinterInfo()

    def checkPrinterState(self, reply=None):
        if reply:
            res = self._verifyReply(reply)
            state = res['result']['state']

            if self._startPrint:
                if state == 'ready':
                    # printer is online
                    self.onInstanceOnline(reply)
                else:
                    self.handlePrinterConnection()
            elif state != 'error':
                # printer can queue job
                self.onInstanceOnline(reply)
            else:  # set counter to max before call?
                self.handlePrinterConnection()

    def checkPrinterDeviceStatus(self, reply):
        if reply:
            res = self._verifyReply(reply)
            device = self._power_device
            if "," in device:
                devices = [x.strip() for x in self._power_device.split(',')]
                device = devices[0]

            power_status = res['result'][device]
            log_msg = "Power device [power {}] status == '{}';".format(
                device, power_status)
            log_msg += " self._startPrint is {}".format(self._startPrint)

            if power_status == 'on':
                log_msg += " Calling getPrinterInfo()"
                Logger.log("d", log_msg)
                self.getPrinterInfo()
            elif power_status == 'off':
                if self._startPrint:
                    log_msg += " Calling postPrinterDevicePowerOn()"
                    Logger.log("d", log_msg)
                    self.postPrinterDevicePowerOn()
                else:
                    log_msg += " Sending FIRMWARE_RESTART before calling getPrinterInfo()"
                    Logger.log("d", log_msg)
                    postData = json.dumps({}).encode()
                    self._sendRequest('printer/firmware_restart',
                                      data=postData,
                                      dataIsJSON=True,
                                      on_success=self.getPrinterInfo)

    def getPrinterDeviceStatus(self):
        device = self._power_device
        if "," in device:
            devices = [x.strip() for x in self._power_device.split(',')]
            device = devices[0]

        Logger.log("d",
                   "Checking printer device [power {}] status".format(device))
        self._sendRequest(
            'machine/device_power/device?device={}'.format(device),
            on_success=self.checkPrinterDeviceStatus)

    def postPrinterDevicePowerOn(self, reply=None):
        device = self._power_device
        if "," in device:
            devices = [x.strip() for x in self._power_device.split(',')]
            for dev in devices:
                Logger.log(
                    "d",
                    "Turning on Moonraker power device [power {}]".format(dev))
                postJSON = '{}'.encode()
                params = {'device': dev, 'action': 'on'}
                req = 'machine/device_power/device?' + urllib.parse.urlencode(
                    params)
                self._sendRequest(req,
                                  data=postJSON,
                                  dataIsJSON=True,
                                  on_success=self.getPrinterInfo)
        else:
            Logger.log(
                "d",
                "Turning on (single) Moonraker power device [power {}]".format(
                    self._power_device))
            postJSON = '{}'.encode()
            params = {'device': self._power_device, 'action': 'on'}
            req = 'machine/device_power/device?' + urllib.parse.urlencode(
                params)
            self._sendRequest(req,
                              data=postJSON,
                              dataIsJSON=True,
                              on_success=self.getPrinterInfo)

    def onMoonrakerConnectionTimeoutError(self):
        messageText = "Error: Connection to Moonraker at {} timed out.".format(
            self._url)
        self._message.setLifetimeTimer(0)
        self._message.setText(messageText)

        browseMessageText = "Check your Moonraker and Klipper settings."
        browseMessageText += "\nA FIRMWARE_RESTART may be necessary."
        if self._power_device:
            browseMessageText += "\nAlso check [power {}] stanza in moonraker.conf".format(
                self._power_device)

        self._message = Message(
            catalog.i18nc("@info:status", browseMessageText), 0, False)
        self._message.addAction(
            "open_browser", catalog.i18nc("@action:button", "Open Browser"),
            "globe",
            catalog.i18nc("@info:tooltip", "Open browser to Moonraker."))
        self._message.actionTriggered.connect(self._onMessageActionTriggered)
        self._message.show()

        self.writeError.emit(self)
        self._resetState()

    def handlePrinterConnection(self, reply=None, error=None):
        self._timeout_cnt += 1
        timeout_cnt_max = 20

        if self._timeout_cnt > timeout_cnt_max:
            self.onMoonrakerConnectionTimeoutError()
        else:
            sleep(0.5)
            self._message.setText(self._getConnectMsgText())
            self.getPrinterInfo()

    def getPrinterInfo(self, reply=None):
        self._sendRequest('printer/info',
                          on_success=self.checkPrinterState,
                          on_error=self.handlePrinterConnection)

    def onInstanceOnline(self, reply):
        # remove connection timeout message
        self._timeout_cnt
        self._message.hide()
        self._message = None

        self._stage = OutputStage.writing
        # show a progress message
        self._message = Message(
            catalog.i18nc("@info:progress",
                          "Uploading to {}...").format(self._name), 0, False,
            -1)
        self._message.show()

        if self._stage != OutputStage.writing:
            return  # never gets here now?
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Uploading " + self._output_format + "...")
        self._stream.seek(0)
        self._postData = QByteArray()
        if isinstance(self._stream, BytesIO):
            self._postData.append(self._stream.getvalue())
        else:
            self._postData.append(self._stream.getvalue().encode())
        self._sendRequest('server/files/upload',
                          name=self._fileName,
                          data=self._postData,
                          on_success=self.onCodeUploaded)

    def onCodeUploaded(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Upload completed")
        self._stream.close()
        self._stream = None

        if self._message:
            self._message.hide()
            self._message = None

        messageText = "Upload of '{}' to {} successfully completed"

        if self._startPrint:
            messageText += " and print job initialized."
        else:
            messageText += "."
        self._message = Message(
            catalog.i18nc(
                "@info:status",
                messageText.format(os.path.basename(self._fileName),
                                   self._name)),
            30 if self._upload_autohide_messagebox else 0, True)
        self._message.addAction(
            "open_browser", catalog.i18nc("@action:button", "Open Browser"),
            "globe",
            catalog.i18nc("@info:tooltip", "Open browser to Moonraker."))
        self._message.actionTriggered.connect(self._onMessageActionTriggered)
        self._message.show()

        self.writeSuccess.emit(self)
        self._resetState()

    def _onProgress(self, progress):
        if self._message:
            self._message.setProgress(progress)
        self.writeProgress.emit(self, progress)

    def _getConnectMsgText(self):
        spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
        return "Connecting to Moonraker at {}     {}".format(
            self._url, spinner[self._timeout_cnt % len(spinner)])

    def _resetState(self):
        Logger.log("d", "Reset state")
        if self._stream:
            self._stream.close()
        self._stream = None
        self._stage = OutputStage.ready
        self._fileName = None
        self._startPrint = None
        self._postData = None
        self._timeout_cnt = 0

    def _onMessageActionTriggered(self, message, action):
        if action == "open_browser":
            QDesktopServices.openUrl(QUrl(self._url))
            if self._message:
                self._message.hide()
                self._message = None

    def _onUploadProgress(self, bytesSent, bytesTotal):
        if bytesTotal > 0:
            self._onProgress(int(bytesSent * 100 / bytesTotal))

    def _onNetworkError(self, reply, error):
        Logger.log("e", repr(error))
        if self._message:
            self._message.hide()
            self._message = None

        errorString = ''
        if reply:
            errorString = reply.errorString()

        message = Message(
            catalog.i18nc("@info:status",
                          "There was a network error: {} {}").format(
                              error, errorString), 0, False)
        message.show()

        self.writeError.emit(self)
        self._resetState()

    def _verifyReply(self, reply):
        # Logger.log("d", "reply: %s" % str(byte_string, 'utf-8'))

        byte_string = reply.readAll()
        response = ''
        try:
            response = json.loads(str(byte_string, 'utf-8'))
        except json.JSONDecodeError:
            Logger.log("d",
                       "Reply is not a JSON: %s" % str(byte_string, 'utf-8'))
            self.handlePrinterConnection()

        return response

    def _sendRequest(self,
                     path,
                     name=None,
                     data=None,
                     dataIsJSON=False,
                     on_success=None,
                     on_error=None):
        url = self._url + path

        headers = {
            'User-Agent': 'Cura Plugin Moonraker',
            'Accept': 'application/json, text/plain',
            'Connection': 'keep-alive'
        }

        if self._api_key:
            headers['X-API-Key'] = self._api_key

        postData = data
        if data is not None:
            if not dataIsJSON:
                # Create multi_part request
                parts = QHttpMultiPart(QHttpMultiPart.FormDataType)

                part_file = QHttpPart()
                part_file.setHeader(
                    QNetworkRequest.ContentDispositionHeader,
                    QVariant('form-data; name="file"; filename="/' + name +
                             '"'))
                part_file.setHeader(QNetworkRequest.ContentTypeHeader,
                                    QVariant('application/octet-stream'))
                part_file.setBody(data)
                parts.append(part_file)

                part_root = QHttpPart()
                part_root.setHeader(QNetworkRequest.ContentDispositionHeader,
                                    QVariant('form-data; name="root"'))
                part_root.setBody(b"gcodes")
                parts.append(part_root)

                if self._startPrint:
                    part_print = QHttpPart()
                    part_print.setHeader(
                        QNetworkRequest.ContentDispositionHeader,
                        QVariant('form-data; name="print"'))
                    part_print.setBody(b"true")
                    parts.append(part_print)

                headers[
                    'Content-Type'] = 'multipart/form-data; boundary=' + str(
                        parts.boundary().data(), encoding='utf-8')

                postData = parts
            else:
                # postData is JSON
                headers['Content-Type'] = 'application/json'

            self.application.getHttpRequestManager().post(
                url,
                headers,
                postData,
                callback=on_success,
                error_callback=on_error if on_error else self._onNetworkError,
                upload_progress_callback=self._onUploadProgress
                if not dataIsJSON else None)
        else:
            self.application.getHttpRequestManager().get(
                url,
                headers,
                callback=on_success,
                error_callback=on_error if on_error else self._onNetworkError)
Beispiel #16
0
class StartOptimiser(
        Extension,
        QObject,
):
    def __init__(self, parent=None) -> None:
        QObject.__init__(self, parent)
        Extension.__init__(self)

        self._application = CuraApplication.getInstance()

        self.setMenuName(catalog.i18nc("@item:inmenu", "Startup Optimiser"))

        self.addMenuItem(
            catalog.i18nc("@item:inmenu",
                          "Disable loading unused configuration files"),
            self.removeUnusedContainers)
        self.addMenuItem(
            catalog.i18nc("@item:inmenu",
                          "Load only 'generic' and custom materials"),
            self.removeBrandedMaterials)
        self.addMenuItem("", lambda: None)
        self.addMenuItem(
            catalog.i18nc("@item:inmenu", "Restore all configuration files"),
            self.resetOptimisations)

        self._local_container_provider_patches = None  # type: Optional[LocalContainerProviderPatches.LocalContainerProviderPatches]
        self._application.pluginsLoaded.connect(self._onPluginsLoaded)
        self._application.getContainerRegistry().containerAdded.connect(
            self._onContainerAdded)

        self._application.getPreferences().addPreference(
            "start_optimiser/container_blacklist", "")
        black_list = set(self._application.getPreferences().getValue(
            "start_optimiser/container_blacklist").split(";"))
        Logger.log(
            "i", "%d containers are blacklisted by StartOptimiser" %
            len(black_list))

        self._message = Message(
            title=catalog.i18nc("@info:title", "Startup Optimiser"))

    def _onPluginsLoaded(self) -> None:
        local_container_provider = self._application.getPluginRegistry(
        ).getPluginObject("LocalContainerProvider")
        self._local_container_provider_patches = LocalContainerProviderPatches.LocalContainerProviderPatches(
            local_container_provider)

        configuration_error_message = ConfigurationErrorMessage.getInstance()
        configuration_error_message.addAction(
            action_id="startoptimiser_clean",
            name=catalog.i18nc("@action:button", "Disable affected profiles"),
            icon="",
            description=catalog.i18nc(
                "@action:tooltip",
                "Disable loading the corrupted configuration files but attempt to leave the rest intact."
            ))
        configuration_error_message.actionTriggered.connect(
            self._configurationErrorMessageActionTriggered)

    def _onContainerAdded(self, container: "ContainerInterface") -> None:
        # make sure that this container also gets loaded the next time Cura starts
        black_list = set(self._application.getPreferences().getValue(
            "start_optimiser/container_blacklist").split(";"))
        try:
            black_list.remove(container.id)
            self._application.getPreferences().setValue(
                "start_optimiser/container_blacklist",
                ";".join(list(black_list)))
        except KeyError:
            pass

    def removeUnusedContainers(self) -> None:
        if not self._local_container_provider_patches:
            return

        local_container_ids = self._local_container_provider_patches.getLocalContainerIds(
        )

        active_stack_ids = set()
        active_definition_ids = set()
        active_container_ids = set()

        container_registry = self._application.getContainerRegistry()

        active_machine_stacks = set(
            container_registry.findContainerStacks(type="machine"))
        active_extruder_stacks = set()
        for stack in active_machine_stacks:
            extruders = container_registry.findContainerStacks(
                type="extruder_train", machine=stack.id)
            active_extruder_stacks.update(extruders)

        for stack in active_machine_stacks | active_extruder_stacks:
            active_stack_ids.add(stack.id)
            active_definition_ids.add(stack.definition.id)

            # add inherited definitions
            active_definition_ids.update([
                self._local_container_provider_patches._pathToId(p)
                for p in stack.definition.getInheritedFiles()
            ])

            # add quality_definition
            quality_definition_id = stack.getMetaDataEntry(
                "quality_definition", "")
            if quality_definition_id:
                active_definition_ids.add(quality_definition_id)

        for definition_id in active_definition_ids:
            instance_containers_metadata = container_registry.findInstanceContainersMetadata(
                definition=definition_id)
            for metadata in instance_containers_metadata:
                container_id = metadata["id"]
                if metadata["type"] == "material":
                    container_id = metadata["base_file"]
                active_container_ids.add(container_id)

        unused_container_ids = local_container_ids - (
            active_stack_ids | active_definition_ids | active_container_ids)
        self._addToBlackList(unused_container_ids)

    def removeBrandedMaterials(self) -> None:
        branded_materials = set()
        keep_branded_materials = set()

        container_registry = self._application.getContainerRegistry()
        container_stacks = container_registry.findContainerStacks()

        for stack in container_stacks:
            if stack.getMetaDataEntry("type") not in [
                    "machine", "extruder_train"
            ]:
                continue

            if stack.material.getMetaDataEntry(
                    "brand", default="generic").lower() != "generic":
                keep_branded_materials.add(
                    stack.material.getMetaDataEntry("base_file"))

        materials_metadata = container_registry.findInstanceContainersMetadata(
            type="material")
        for metadata in materials_metadata:
            if metadata["id"] == "empty_material":
                continue
            if "brand" not in metadata or metadata["brand"].lower(
            ) != "generic":
                branded_materials.add(metadata["base_file"])
            if not container_registry.getInstance().isReadOnly(metadata["id"]):
                # keep custom materials
                keep_branded_materials.add(metadata["base_file"])

        unused_branded_materials = branded_materials - keep_branded_materials
        self._addToBlackList(unused_branded_materials)

    def resetOptimisations(self) -> None:
        self._application.getPreferences().setValue(
            "start_optimiser/container_blacklist", "")
        self._message.hide()
        self._message.setText(
            catalog.i18nc(
                "@info:status",
                "Please restart Cura to restore loading all configuration files"
            ))
        self._message.show()

    def _addToBlackList(self, container_ids: Set[str]) -> None:
        black_list = set(self._application.getPreferences().getValue(
            "start_optimiser/container_blacklist").split(";"))
        black_list.update(container_ids)
        self._application.getPreferences().setValue(
            "start_optimiser/container_blacklist", ";".join(list(black_list)))

        self._message.hide()
        self._message.setText(
            catalog.i18nc(
                "@info:status",
                "On the next start of Cura %d configuration files will be skipped"
            ) % len(black_list))
        self._message.show()

    def _configurationErrorMessageActionTriggered(self, _, action_id):
        if action_id == "startoptimiser_clean":
            configuration_error_message = ConfigurationErrorMessage.getInstance(
            )
            configuration_error_message.hide()
            self._addToBlackList(
                configuration_error_message._faulty_containers)
Beispiel #17
0
class ModelChecker(QObject, Extension):
    ##  Signal that gets emitted when anything changed that we need to check.
    onChanged = pyqtSignal()

    def __init__(self):
        super().__init__()

        self._button_view = None

        self._caution_message = Message("", #Message text gets set when the message gets shown, to display the models in question.
            lifetime = 0,
            title = catalog.i18nc("@info:title", "Model Checker Warning"))

        Application.getInstance().initializationFinished.connect(self._pluginsInitialized)
        Application.getInstance().getController().getScene().sceneChanged.connect(self._onChanged)
        Application.getInstance().globalContainerStackChanged.connect(self._onChanged)

    ##  Pass-through to allow UM.Signal to connect with a pyqtSignal.
    def _onChanged(self, *args, **kwargs):
        self.onChanged.emit()

    ##  Called when plug-ins are initialized.
    #
    #   This makes sure that we listen to changes of the material and that the
    #   button is created that indicates warnings with the current set-up.
    def _pluginsInitialized(self):
        Application.getInstance().getMachineManager().rootMaterialChanged.connect(self.onChanged)
        self._createView()

    def checkObjectsForShrinkage(self):
        shrinkage_threshold = 0.5 #From what shrinkage percentage a warning will be issued about the model size.
        warning_size_xy = 150 #The horizontal size of a model that would be too large when dealing with shrinking materials.
        warning_size_z = 100 #The vertical size of a model that would be too large when dealing with shrinking materials.

        # This function can be triggered in the middle of a machine change, so do not proceed if the machine change
        # has not done yet.
        global_container_stack = Application.getInstance().getGlobalContainerStack()
        if global_container_stack is None:
            return False

        material_shrinkage = self._getMaterialShrinkage()

        warning_nodes = []

        # Check node material shrinkage and bounding box size
        for node in self.sliceableNodes():
            node_extruder_position = node.callDecoration("getActiveExtruderPosition")

            # This function can be triggered in the middle of a machine change, so do not proceed if the machine change
            # has not done yet.
            if str(node_extruder_position) not in global_container_stack.extruders:
                Application.getInstance().callLater(lambda: self.onChanged.emit())
                return False

            if material_shrinkage[node_extruder_position] > shrinkage_threshold:
                bbox = node.getBoundingBox()
                if bbox.width >= warning_size_xy or bbox.depth >= warning_size_xy or bbox.height >= warning_size_z:
                    warning_nodes.append(node)

        self._caution_message.setText(catalog.i18nc(
            "@info:status",
            "<p>One or more 3D models may not print optimally due to the model size and material configuration:</p>\n"
            "<p>{model_names}</p>\n"
            "<p>Find out how to ensure the best possible print quality and reliability.</p>\n"
            "<p><a href=\"https://ultimaker.com/3D-model-assistant\">View print quality guide</a></p>"
            ).format(model_names = ", ".join([n.getName() for n in warning_nodes])))

        return len(warning_nodes) > 0

    def sliceableNodes(self):
        # Add all sliceable scene nodes to check
        scene = Application.getInstance().getController().getScene()
        for node in DepthFirstIterator(scene.getRoot()):
            if node.callDecoration("isSliceable"):
                yield node

    ##  Creates the view used by show popup. The view is saved because of the fairly aggressive garbage collection.
    def _createView(self):
        Logger.log("d", "Creating model checker view.")

        # Create the plugin dialog component
        path = os.path.join(PluginRegistry.getInstance().getPluginPath("ModelChecker"), "ModelChecker.qml")
        self._button_view = Application.getInstance().createQmlComponent(path, {"manager": self})

        # The qml is only the button
        Application.getInstance().addAdditionalComponent("jobSpecsButton", self._button_view)

        Logger.log("d", "Model checker view created.")

    @pyqtProperty(bool, notify = onChanged)
    def hasWarnings(self):
        danger_shrinkage = self.checkObjectsForShrinkage()
        return any((danger_shrinkage, )) #If any of the checks fail, show the warning button.

    @pyqtSlot()
    def showWarnings(self):
        self._caution_message.show()

    def _getMaterialShrinkage(self):
        global_container_stack = Application.getInstance().getGlobalContainerStack()
        if global_container_stack is None:
            return {}

        material_shrinkage = {}
        # Get all shrinkage values of materials used
        for extruder_position, extruder in global_container_stack.extruders.items():
            shrinkage = extruder.material.getProperty("material_shrinkage_percentage", "value")
            if shrinkage is None:
                shrinkage = 0
            material_shrinkage[extruder_position] = shrinkage
        return material_shrinkage
    def _onDevicesDiscovered(self,
                             clusters: List[CloudClusterResponse]) -> None:
        """**Synchronously** create machines for discovered devices

        Any new machines are made available to the user.
        May take a long time to complete. As this code needs access to the Application
        and blocks the GIL, creating a Job for this would not make sense.
        Shows a Message informing the user of progress.
        """
        new_devices = []
        remote_clusters_added = False
        for cluster_data in clusters:
            device = CloudOutputDevice(self._api, cluster_data)
            # Create a machine if we don't already have it. Do not make it the active machine.
            machine_manager = CuraApplication.getInstance().getMachineManager()

            # We only need to add it if it wasn't already added by "local" network or by cloud.
            if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \
                    and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None:  # The host name is part of the network key.
                new_devices.append(device)

            elif device.getId() not in self._remote_clusters:
                self._remote_clusters[device.getId()] = device
                remote_clusters_added = True

        # Inform the Cloud printers model about new devices.
        new_devices_list_of_dicts = [{
            "key": d.getId(),
            "name": d.name,
            "machine_type": d.printerTypeName,
            "firmware_version": d.firmwareVersion
        } for d in new_devices]
        discovered_cloud_printers_model = CuraApplication.getInstance(
        ).getDiscoveredCloudPrintersModel()
        discovered_cloud_printers_model.addDiscoveredCloudPrinters(
            new_devices_list_of_dicts)

        if not new_devices:
            if remote_clusters_added:
                self._connectToActiveMachine()
            return

        new_devices.sort(key=lambda x: x.name.lower())

        image_path = os.path.join(
            CuraApplication.getInstance().getPluginRegistry().getPluginPath(
                "UM3NetworkPrinting") or "", "resources", "svg",
            "cloud-flow-completed.svg")

        message = Message(title=self.I18N_CATALOG.i18ncp(
            "info:status", "New printer detected from your Ultimaker account",
            "New printers detected from your Ultimaker account",
            len(new_devices)),
                          progress=0,
                          lifetime=0,
                          image_source=image_path)
        message.show()

        for idx, device in enumerate(new_devices):
            message_text = self.I18N_CATALOG.i18nc(
                "info:status", "Adding printer {} ({}) from your account",
                device.name, device.printerTypeName)
            message.setText(message_text)
            if len(new_devices) > 1:
                message.setProgress((idx / len(new_devices)) * 100)
            CuraApplication.getInstance().processEvents()
            self._remote_clusters[device.getId()] = device

            # If there is no active machine, activate the first available cloud printer
            activate = not CuraApplication.getInstance().getMachineManager(
            ).activeMachine
            self._createMachineFromDiscoveredDevice(device.getId(),
                                                    activate=activate)

        message.setProgress(None)

        max_disp_devices = 3
        if len(new_devices) > max_disp_devices:
            num_hidden = len(new_devices) - max_disp_devices + 1
            device_name_list = [
                "- {} ({})".format(device.name, device.printerTypeName)
                for device in new_devices[0:num_hidden]
            ]
            device_name_list.append(
                self.I18N_CATALOG.i18nc("info:hidden list items",
                                        "- and {} others", num_hidden))
            device_names = "\n".join(device_name_list)
        else:
            device_names = "\n".join([
                "- {} ({})".format(device.name, device.printerTypeName)
                for device in new_devices
            ])

        message_text = self.I18N_CATALOG.i18nc(
            "info:status", "Cloud printers added from your account:\n{}",
            device_names)
        message.setText(message_text)
Beispiel #19
0
    def submitSmartSliceJob(self, cloud_job, threemf_data):
        thor_status_code, task = self.executeApiCall(
            lambda: self._client.new_smartslice_job(threemf_data),
            self.ConnectionErrorCodes.genericInternetConnectionError
        )

        job_status_tracker = JobStatusTracker(self.connector, self.connector.status)

        Logger.log("d", "API Status after posting: {}".format(thor_status_code))

        if thor_status_code != 200:
            self._handleThorErrors(thor_status_code, task)
            self.connector.cancelCurrentJob()

        if getattr(task, 'status', None):
            Logger.log("d", "Job status after posting: {}".format(task.status))

        # While the task status is not finished/failed/crashed/aborted continue to
        # wait on the status using the API.
        thor_status_code = None
        while thor_status_code != self.ConnectionErrorCodes.genericInternetConnectionError and not cloud_job.canceled and task.status not in (
            pywim.http.thor.JobInfo.Status.failed,
            pywim.http.thor.JobInfo.Status.crashed,
            pywim.http.thor.JobInfo.Status.aborted,
            pywim.http.thor.JobInfo.Status.finished
        ):

            self.job_status = task.status
            cloud_job.api_job_id = task.id

            thor_status_code, task = self.executeApiCall(
                lambda: self._client.smartslice_job_wait(task.id, callback=job_status_tracker),
                self.ConnectionErrorCodes.genericInternetConnectionError
            )

            if thor_status_code == 200:
                thor_status_code, task = self.executeApiCall(
                    lambda: self._client.smartslice_job_wait(task.id, callback=job_status_tracker),
                    self.ConnectionErrorCodes.genericInternetConnectionError
                )

            if thor_status_code not in (200, None):
                self._handleThorErrors(thor_status_code, task)
                self.connector.cancelCurrentJob()

        if not cloud_job.canceled:
            self.connector.propertyHandler._cancelChanges = False

            if task.status == pywim.http.thor.JobInfo.Status.failed:
                error_message = Message()
                error_message.setTitle("Smart Slice Solver")
                error_message.setText(i18n_catalog.i18nc(
                    "@info:status",
                    "Error while processing the job:\n{}".format(task.errors[0].message)
                ))
                error_message.show()

                self.connector.cancelCurrentJob()
                cloud_job.setError(SmartSliceCloudJob.JobException(error_message.getText()))

                Logger.log(
                    "e",
                    "An error occured while sending and receiving cloud job: {}".format(error_message.getText())
                )
                self.connector.propertyHandler._cancelChanges = False
                return None
            elif task.status == pywim.http.thor.JobInfo.Status.finished:
                return task
            elif len(task.errors) > 0:
                error_message = Message()
                error_message.setTitle("Smart Slice Solver")
                error_message.setText(i18n_catalog.i18nc(
                    "@info:status",
                    "Unexpected status occured:\n{}".format(task.errors[0].message)
                ))
                error_message.show()

                self.connector.cancelCurrentJob()
                cloud_job.setError(SmartSliceCloudJob.JobException(error_message.getText()))

                Logger.log(
                    "e",
                    "An unexpected status occured while sending and receiving cloud job: {}".format(error_message.getText())
                )
                self.connector.propertyHandler._cancelChanges = False
                return None
    def _onDevicesDiscovered(self,
                             clusters: List[CloudClusterResponse]) -> None:
        """**Synchronously** create machines for discovered devices

        Any new machines are made available to the user.
        May take a long time to complete. As this code needs access to the Application
        and blocks the GIL, creating a Job for this would not make sense.
        Shows a Message informing the user of progress.
        """
        new_devices = []
        remote_clusters_added = False
        host_guid_map = {
            machine.getMetaDataEntry(self.META_HOST_GUID): device_cluster_id
            for device_cluster_id, machine in self._um_cloud_printers.items()
            if machine.getMetaDataEntry(self.META_HOST_GUID)
        }
        machine_manager = CuraApplication.getInstance().getMachineManager()

        for cluster_data in clusters:
            device = CloudOutputDevice(self._api, cluster_data)
            # If the machine already existed before, it will be present in the host_guid_map
            if cluster_data.host_guid in host_guid_map:
                machine = machine_manager.getMachine(
                    device.printerType,
                    {self.META_HOST_GUID: cluster_data.host_guid})
                if machine and machine.getMetaDataEntry(
                        self.META_CLUSTER_ID) != device.key:
                    # If the retrieved device has a different cluster_id than the existing machine, bring the existing
                    # machine up-to-date.
                    self._updateOutdatedMachine(outdated_machine=machine,
                                                new_cloud_output_device=device)

            # Create a machine if we don't already have it. Do not make it the active machine.
            # We only need to add it if it wasn't already added by "local" network or by cloud.
            if machine_manager.getMachine(device.printerType, {self.META_CLUSTER_ID: device.key}) is None \
                    and machine_manager.getMachine(device.printerType, {self.META_NETWORK_KEY: cluster_data.host_name + "*"}) is None:  # The host name is part of the network key.
                new_devices.append(device)
            elif device.getId() not in self._remote_clusters:
                self._remote_clusters[device.getId()] = device
                remote_clusters_added = True
            # If a printer that was removed from the account is re-added, change its metadata to mark it not removed
            # from the account
            elif not parseBool(
                    self._um_cloud_printers[device.key].getMetaDataEntry(
                        META_UM_LINKED_TO_ACCOUNT, "true")):
                self._um_cloud_printers[device.key].setMetaDataEntry(
                    META_UM_LINKED_TO_ACCOUNT, True)

        # Inform the Cloud printers model about new devices.
        new_devices_list_of_dicts = [{
            "key": d.getId(),
            "name": d.name,
            "machine_type": d.printerTypeName,
            "firmware_version": d.firmwareVersion
        } for d in new_devices]
        discovered_cloud_printers_model = CuraApplication.getInstance(
        ).getDiscoveredCloudPrintersModel()
        discovered_cloud_printers_model.addDiscoveredCloudPrinters(
            new_devices_list_of_dicts)

        if not new_devices:
            if remote_clusters_added:
                self._connectToActiveMachine()
            return

        # Sort new_devices on online status first, alphabetical second.
        # Since the first device might be activated in case there is no active printer yet,
        # it would be nice to prioritize online devices
        online_cluster_names = {
            c.friendly_name.lower()
            for c in clusters if c.is_online and not c.friendly_name is None
        }
        new_devices.sort(key=lambda x: ("a{}" if x.name.lower(
        ) in online_cluster_names else "b{}").format(x.name.lower()))

        image_path = os.path.join(
            CuraApplication.getInstance().getPluginRegistry().getPluginPath(
                "UM3NetworkPrinting") or "", "resources", "svg",
            "cloud-flow-completed.svg")

        message = Message(title=self.I18N_CATALOG.i18ncp(
            "info:status", "New printer detected from your Ultimaker account",
            "New printers detected from your Ultimaker account",
            len(new_devices)),
                          progress=0,
                          lifetime=0,
                          image_source=image_path)
        message.show()

        for idx, device in enumerate(new_devices):
            message_text = self.I18N_CATALOG.i18nc(
                "info:status", "Adding printer {} ({}) from your account",
                device.name, device.printerTypeName)
            message.setText(message_text)
            if len(new_devices) > 1:
                message.setProgress((idx / len(new_devices)) * 100)
            CuraApplication.getInstance().processEvents()
            self._remote_clusters[device.getId()] = device

            # If there is no active machine, activate the first available cloud printer
            activate = not CuraApplication.getInstance().getMachineManager(
            ).activeMachine
            self._createMachineFromDiscoveredDevice(device.getId(),
                                                    activate=activate)

        message.setProgress(None)

        max_disp_devices = 3
        if len(new_devices) > max_disp_devices:
            num_hidden = len(new_devices) - max_disp_devices
            device_name_list = [
                "<li>{} ({})</li>".format(device.name, device.printerTypeName)
                for device in new_devices[0:max_disp_devices]
            ]
            device_name_list.append(
                self.I18N_CATALOG.i18nc("info:hidden list items",
                                        "<li>... and {} others</li>",
                                        num_hidden))
            device_names = "".join(device_name_list)
        else:
            device_names = "".join([
                "<li>{} ({})</li>".format(device.name, device.printerTypeName)
                for device in new_devices
            ])

        message_text = self.I18N_CATALOG.i18nc(
            "info:status",
            "Cloud printers added from your account:<ul>{}</ul>", device_names)
        message.setText(message_text)
class DuetRRFOutputDevice(OutputDevice):
    def __init__(self, settings, device_type):
        self._name_id = "duetrrf-{}".format(device_type.name)
        super().__init__(self._name_id)

        self._url = settings["url"]
        self._duet_password = settings["duet_password"]
        self._http_user = settings["http_user"]
        self._http_password = settings["http_password"]

        self.application = CuraApplication.getInstance()
        global_container_stack = self.application.getGlobalContainerStack()
        self._name = global_container_stack.getName()

        self._device_type = device_type
        if device_type == DuetRRFDeviceType.print:
            description = catalog.i18nc("@action:button",
                                        "Print on {0}").format(self._name)
            priority = 30
        elif device_type == DuetRRFDeviceType.simulate:
            description = catalog.i18nc("@action:button",
                                        "Simulate on {0}").format(self._name)
            priority = 20
        elif device_type == DuetRRFDeviceType.upload:
            description = catalog.i18nc("@action:button",
                                        "Upload to {0}").format(self._name)
            priority = 10
        else:
            assert False

        self.setShortDescription(description)
        self.setDescription(description)
        self.setPriority(priority)

        self._stage = OutputStage.ready
        self._device_type = device_type
        self._stream = None
        self._message = None

        self._use_rrf_http_api = True  # by default we try to connect to the RRF HTTP API via rr_connect

        Logger.log(
            "d",
            "New {} DuetRRFOutputDevice created | URL: {} | Duet password: {} | HTTP Basic Auth: user:{}, password:{}"
            .format(
                self._name_id,
                self._url,
                "set" if self._duet_password else "<empty>",
                self._http_user if self._http_user else "<empty>",
                "set" if self._http_password else "<empty>",
            ))

        self._resetState()

    def _timestamp(self):
        return ("time", datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))

    def _send(self,
              command,
              query=None,
              next_stage=None,
              data=None,
              on_error=None,
              method='POST'):
        url = self._url + command

        if not query:
            query = dict()
        enc_query = urllib.parse.urlencode(query, quote_via=urllib.parse.quote)
        if enc_query:
            url += '?' + enc_query

        headers = {
            'User-Agent': 'Cura Plugin DuetRRF',
            'Accept': 'application/json, text/javascript',
            'Connection': 'keep-alive',
        }

        if self._http_user and self._http_password:
            auth = "{}:{}".format(self._http_user,
                                  self._http_password).encode()
            headers['Authorization'] = 'Basic ' + base64.b64encode(auth)

        if data:
            headers['Content-Type'] = 'application/octet-stream'
            if method == 'PUT':
                self.application.getHttpRequestManager().put(
                    url,
                    headers,
                    data,
                    callback=next_stage,
                    error_callback=on_error
                    if on_error else self._onNetworkError,
                    upload_progress_callback=self._onUploadProgress,
                )
            else:
                self.application.getHttpRequestManager().post(
                    url,
                    headers,
                    data,
                    callback=next_stage,
                    error_callback=on_error
                    if on_error else self._onNetworkError,
                    upload_progress_callback=self._onUploadProgress,
                )
        else:
            self.application.getHttpRequestManager().get(
                url,
                headers,
                callback=next_stage,
                error_callback=on_error if on_error else self._onNetworkError,
            )

    def requestWrite(self, node, fileName=None, *args, **kwargs):
        if self._stage != OutputStage.ready:
            raise OutputDeviceError.DeviceBusyError()

        if fileName:
            fileName = os.path.splitext(fileName)[0] + '.gcode'
        else:
            fileName = "%s.gcode" % Application.getInstance(
            ).getPrintInformation().jobName
        self._fileName = fileName

        path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            'resources', 'qml', 'UploadFilename.qml')
        self._dialog = CuraApplication.getInstance().createQmlComponent(
            path, {"manager": self})
        self._dialog.textChanged.connect(self._onFilenameChanged)
        self._dialog.accepted.connect(self._onFilenameAccepted)
        self._dialog.show()
        self._dialog.findChild(QObject, "nameField").setProperty(
            'text', self._fileName)
        self._dialog.findChild(QObject,
                               "nameField").select(0,
                                                   len(self._fileName) - 6)
        self._dialog.findChild(QObject, "nameField").setProperty('focus', True)

    def _onFilenameChanged(self):
        fileName = self._dialog.findChild(
            QObject, "nameField").property('text').strip()

        forbidden_characters = "\"'´`<>()[]?*\,;:&%#$!"
        for forbidden_character in forbidden_characters:
            if forbidden_character in fileName:
                self._dialog.setProperty('validName', False)
                self._dialog.setProperty(
                    'validationError',
                    'Filename cannot contain {}'.format(forbidden_characters))
                return

        if fileName == '.' or fileName == '..':
            self._dialog.setProperty('validName', False)
            self._dialog.setProperty('validationError',
                                     'Filename cannot be "." or ".."')
            return

        self._dialog.setProperty('validName', len(fileName) > 0)
        self._dialog.setProperty('validationError', 'Filename too short')

    def _onFilenameAccepted(self):
        self._fileName = self._dialog.findChild(
            QObject, "nameField").property('text').strip()
        if not self._fileName.endswith('.gcode') and '.' not in self._fileName:
            self._fileName += '.gcode'
        Logger.log("d", "Filename set to: " + self._fileName)

        self._dialog.deleteLater()

        self._stage = OutputStage.writing
        self.writeStarted.emit(self)

        # show a progress message
        self._message = Message(
            "Serializing gcode...",
            lifetime=0,
            dismissable=False,
            progress=-1,
            title="DuetRRF: " + self._name,
        )
        self._message.show()

        # get the gcode through the GCodeWrite plugin
        # this serializes the actual scene and should produce the same output as "Save to File"
        gcode_stream = self._serializing_scene_to_gcode()

        # generate model thumbnail and embedd in gcode file
        self._message.setText("Rendering thumbnail image...")
        thumbnail_stream = generate_thumbnail()

        # assemble everything and inject custom data
        self._message.setText("Assembling final gcode file...")
        self._stream = self._assemble_final_gcode(gcode_stream,
                                                  thumbnail_stream)

        # start upload workflow
        self._message.setText("Uploading {} ...".format(self._fileName))
        Logger.log("d", "Connecting...")
        self._send(
            'rr_connect',
            query=[("password", self._duet_password),
                   self._timestamp()],
            next_stage=self._onUploadReady,
            on_error=self._check_duet3_sbc,
        )

    def _serializing_scene_to_gcode(self):
        Logger.log("d", "Serializing gcode...")
        gcode_writer = cast(
            MeshWriter,
            PluginRegistry.getInstance().getPluginObject("GCodeWriter"))
        gcode_stream = StringIO()
        success = gcode_writer.write(gcode_stream, None)
        if not success:
            Logger.log("e", "GCodeWriter failed.")
            return None
        return gcode_stream

    def _assemble_final_gcode(self, gcode_stream, thumbnail_stream):
        Logger.log("d", "Assembling final gcode file...")

        final_stream = StringIO()
        gcode_stream.seek(0)
        for l in gcode_stream.readlines():
            final_stream.write(l)
            if l.startswith(";Generated with"):
                version = DuetRRFSettings.get_plugin_version()
                final_stream.write(
                    f";Exported with Cura-DuetRRF v{version} plugin by Thomas Kriechbaumer\n"
                )
                final_stream.write(thumbnail_stream.getvalue())

        return final_stream

    def _check_duet3_sbc(self, reply, error):
        Logger.log("d", "rr_connect failed with error " + str(error))
        if error == QNetworkReply.ContentNotFoundError:
            Logger.log(
                "d",
                "error indicates Duet3+SBC - let's try the DuetSoftwareFramework API instead..."
            )
            self._use_rrf_http_api = False  # let's try the newer DuetSoftwareFramework for Duet3+SBC API instead
            self._send('machine/status', next_stage=self._onUploadReady)
        else:
            self._onNetworkError(reply, error)

    def _onUploadReady(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Uploading...")

        self._postData = QByteArray()
        self._postData.append(self._stream.getvalue().encode())

        if self._use_rrf_http_api:
            self._send(
                'rr_upload',
                query=[("name", "0:/gcodes/" + self._fileName),
                       self._timestamp()],
                next_stage=self._onUploadDone,
                data=self._postData,
            )
        else:
            self._send(
                'machine/file/gcodes/' + self._fileName,
                next_stage=self._onUploadDone,
                data=self._postData,
                method='PUT',
            )

    def _onUploadDone(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Upload done")

        self._stream.close()
        self._stream = None

        if self._device_type == DuetRRFDeviceType.simulate:
            Logger.log("d", "Simulating...")
            if self._message:
                self._message.hide()
                self._message = None

            self._message = Message(
                "Simulating print {}...\nPlease close DWC and DO NOT interact with the printer!"
                .format(self._fileName),
                lifetime=0,
                dismissable=False,
                progress=-1,
                title="DuetRRF: " + self._name,
            )
            self._message.show()

            gcode = 'M37 P"0:/gcodes/' + self._fileName + '"'
            Logger.log("d", "Sending gcode:" + gcode)
            if self._use_rrf_http_api:
                self._send(
                    'rr_gcode',
                    query=[("gcode", gcode)],
                    next_stage=self._onSimulationPrintStarted,
                )
            else:
                self._send(
                    'machine/code',
                    data=gcode.encode(),
                    next_stage=self._onSimulationPrintStarted,
                )
        elif self._device_type == DuetRRFDeviceType.print:
            self._onReadyToPrint()
        elif self._device_type == DuetRRFDeviceType.upload:
            if self._use_rrf_http_api:
                self._send('rr_disconnect')
            if self._message:
                self._message.hide()
                self._message = None

            self._message = Message(
                "Uploaded file: {}".format(self._fileName),
                lifetime=15,
                title="DuetRRF: " + self._name,
            )
            self._message.addAction(
                "open_browser", catalog.i18nc("@action:button",
                                              "Open Browser"), "globe",
                catalog.i18nc("@info:tooltip",
                              "Open browser to DuetWebControl."))
            self._message.actionTriggered.connect(
                self._onMessageActionTriggered)
            self._message.show()

            self.writeSuccess.emit(self)
            self._resetState()

    def _onReadyToPrint(self):
        if self._stage != OutputStage.writing:
            return

        Logger.log("d", "Ready to print")

        gcode = 'M32 "0:/gcodes/' + self._fileName + '"'
        Logger.log("d", "Sending gcode:" + gcode)
        if self._use_rrf_http_api:
            self._send(
                'rr_gcode',
                query=[("gcode", gcode)],
                next_stage=self._onPrintStarted,
            )
        else:
            self._send(
                'machine/code',
                data=gcode.encode(),
                next_stage=self._onPrintStarted,
            )

    def _onPrintStarted(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Print started")

        if self._use_rrf_http_api:
            self._send('rr_disconnect')
        if self._message:
            self._message.hide()
            self._message = None

        self._message = Message(
            "Print started: {}".format(self._fileName),
            lifetime=15,
            title="DuetRRF: " + self._name,
        )
        self._message.addAction(
            "open_browser", catalog.i18nc("@action:button", "Open Browser"),
            "globe",
            catalog.i18nc("@info:tooltip", "Open browser to DuetWebControl."))
        self._message.actionTriggered.connect(self._onMessageActionTriggered)
        self._message.show()

        self.writeSuccess.emit(self)
        self._resetState()

    def _onSimulationPrintStarted(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Simulation print started for file " + self._fileName)

        # give it some to start the simulation
        QTimer.singleShot(2000, self._onCheckStatus)

    def _onCheckStatus(self):
        if self._stage != OutputStage.writing:
            return

        Logger.log("d", "Checking status...")

        if self._use_rrf_http_api:
            self._send(
                'rr_status',
                query=[("type", "3")],
                next_stage=self._onStatusReceived,
            )
        else:
            self._send(
                'machine/status',
                next_stage=self._onStatusReceived,
            )

    def _onStatusReceived(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Status received - decoding...")
        reply_body = bytes(reply.readAll()).decode()
        Logger.log("d", "Status: " + reply_body)

        status = json.loads(reply_body)
        if self._use_rrf_http_api:
            # RRF 1.21RC2 and earlier used P while simulating
            # RRF 1.21RC3 and later uses M while simulating
            busy = status["status"] in ['P', 'M']
        else:
            busy = status["result"]["state"]["status"] == 'simulating'

        if busy:
            # still simulating
            if self._message and "fractionPrinted" in status:
                self._message.setProgress(float(status["fractionPrinted"]))
            QTimer.singleShot(1000, self._onCheckStatus)
        else:
            Logger.log("d", "Simulation print finished")

            gcode = 'M37'
            Logger.log("d", "Sending gcode:" + gcode)
            if self._use_rrf_http_api:
                self._send(
                    'rr_gcode',
                    query=[("gcode", gcode)],
                    next_stage=self._onM37Reported,
                )
            else:
                self._send(
                    'machine/code',
                    data=gcode.encode(),
                    next_stage=self._onReported,
                )

    def _onM37Reported(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "M37 finished - let's get it's reply...")
        reply_body = bytes(reply.readAll()).decode().strip()
        Logger.log("d", "M37 gcode reply | " + reply_body)

        self._send(
            'rr_reply',
            next_stage=self._onReported,
        )

    def _onReported(self, reply):
        if self._stage != OutputStage.writing:
            return
        if reply.error() != QNetworkReply.NoError:
            Logger.log("d", "Stopping due to reply error: " + reply.error())
            return

        Logger.log("d", "Simulation status received - decoding...")
        reply_body = bytes(reply.readAll()).decode().strip()
        Logger.log("d", "Reported | " + reply_body)

        if self._message:
            self._message.hide()
            self._message = None

        self._message = Message(
            "Simulation finished!\n\n{}".format(reply_body),
            lifetime=0,
            title="DuetRRF: " + self._name,
        )
        self._message.addAction(
            "open_browser", catalog.i18nc("@action:button", "Open Browser"),
            "globe",
            catalog.i18nc("@info:tooltip", "Open browser to DuetWebControl."))
        self._message.actionTriggered.connect(self._onMessageActionTriggered)
        self._message.show()

        if self._use_rrf_http_api:
            self._send('rr_disconnect')
        self.writeSuccess.emit(self)
        self._resetState()

    def _resetState(self):
        Logger.log("d", "called")
        if self._stream:
            self._stream.close()
        self._stream = None
        self._stage = OutputStage.ready
        self._fileName = None

    def _onMessageActionTriggered(self, message, action):
        if action == "open_browser":
            QDesktopServices.openUrl(QUrl(self._url))
            if self._message:
                self._message.hide()
                self._message = None

    def _onUploadProgress(self, bytesSent, bytesTotal):
        if bytesTotal > 0:
            progress = int(bytesSent * 100 / bytesTotal)
            if self._message:
                self._message.setProgress(progress)
            self.writeProgress.emit(self, progress)

    def _onNetworkError(self, reply, error):
        # https://doc.qt.io/qt-5/qnetworkreply.html#NetworkError-enum
        Logger.log("e", repr(error))
        if self._message:
            self._message.hide()
            self._message = None

        errorString = ''
        if reply:
            errorString = reply.errorString()

        message = Message(
            "There was a network error: {} {}".format(error, errorString),
            lifetime=0,
            title="DuetRRF: " + self._name,
        )
        message.show()

        self.writeError.emit(self)
        self._resetState()