From c1ea1320f08cf8a4c1a4e961675e4f449ebc4e38 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 16 Mar 2018 15:55:49 +0100 Subject: [PATCH 1/9] Implement GCodeGzWriter This plug-in outputs g-code to a gzipped archive. It is used for sending prints to the Ultimaker 3 family faster. Contributes to issue CURA-5097. --- plugins/GCodeGzWriter/GCodeGzWriter.py | 41 ++++++++++++++++++++++++++ plugins/GCodeGzWriter/__init__.py | 22 ++++++++++++++ plugins/GCodeGzWriter/plugin.json | 8 +++++ 3 files changed, 71 insertions(+) create mode 100644 plugins/GCodeGzWriter/GCodeGzWriter.py create mode 100644 plugins/GCodeGzWriter/__init__.py create mode 100644 plugins/GCodeGzWriter/plugin.json diff --git a/plugins/GCodeGzWriter/GCodeGzWriter.py b/plugins/GCodeGzWriter/GCodeGzWriter.py new file mode 100644 index 0000000000..4ced6d03ba --- /dev/null +++ b/plugins/GCodeGzWriter/GCodeGzWriter.py @@ -0,0 +1,41 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import gzip +from io import StringIO, BufferedIOBase #To write the g-code to a temporary buffer, and for typing. +from typing import List + +from UM.Logger import Logger +from UM.Mesh.MeshWriter import MeshWriter #The class we're extending/implementing. +from UM.PluginRegistry import PluginRegistry +from UM.Scene.SceneNode import SceneNode #For typing. + +## A file writer that writes gzipped g-code. +# +# If you're zipping g-code, you might as well use gzip! +class GCodeGzWriter(MeshWriter): + ## Writes the gzipped g-code to a stream. + # + # Note that even though the function accepts a collection of nodes, the + # entire scene is always written to the file since it is not possible to + # separate the g-code for just specific nodes. + # + # \param stream The stream to write the gzipped g-code to. + # \param nodes This is ignored. + # \param mode Additional information on what type of stream to use. This + # must always be binary mode. + # \return Whether the write was successful. + def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode = MeshWriter.OutputMode.BinaryMode) -> bool: + if mode != MeshWriter.OutputMode.BinaryMode: + Logger.log("e", "GCodeGzWriter does not support text mode.") + return False + + #Get the g-code from the g-code writer. + gcode_textio = StringIO() #We have to convert the g-code into bytes. + success = PluginRegistry.getInstance().getPluginObject("GCodeWriter").write(gcode_textio, None) + if not success: #Writing the g-code failed. Then I can also not write the gzipped g-code. + return False + + result = gzip.compress(gcode_textio.getvalue()) + stream.write(result) + return True diff --git a/plugins/GCodeGzWriter/__init__.py b/plugins/GCodeGzWriter/__init__.py new file mode 100644 index 0000000000..c001467b3d --- /dev/null +++ b/plugins/GCodeGzWriter/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from . import GCodeGzWriter + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +def getMetaData(): + return { + "mesh_writer": { + "output": [{ + "extension": "gcode.gz", + "description": catalog.i18nc("@item:inlistbox", "Compressed G-code File"), + "mime_type": "application/gzip", + "mode": GCodeGzWriter.GCodeGzWriter.OutputMode.BinaryMode + }] + } + } + +def register(app): + return { "mesh_writer": GCodeGzWriter.GCodeGzWriter() } diff --git a/plugins/GCodeGzWriter/plugin.json b/plugins/GCodeGzWriter/plugin.json new file mode 100644 index 0000000000..9774e9a25c --- /dev/null +++ b/plugins/GCodeGzWriter/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "Compressed G-code Writer", + "author": "Ultimaker B.V.", + "version": "1.0.0", + "description": "Writes g-code to a compressed archive.", + "api": 4, + "i18n-catalog": "cura" +} From 88912e39735ab8ea25a9823ebc49c970efcc5e04 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 16 Mar 2018 16:05:51 +0100 Subject: [PATCH 2/9] Encode as UTF-8 before writing to gz Turns out that gzip only accepts bytes as input, not str. Contributes to issue CURA-5097. --- plugins/GCodeGzWriter/GCodeGzWriter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/GCodeGzWriter/GCodeGzWriter.py b/plugins/GCodeGzWriter/GCodeGzWriter.py index 4ced6d03ba..06fafb5995 100644 --- a/plugins/GCodeGzWriter/GCodeGzWriter.py +++ b/plugins/GCodeGzWriter/GCodeGzWriter.py @@ -36,6 +36,6 @@ class GCodeGzWriter(MeshWriter): if not success: #Writing the g-code failed. Then I can also not write the gzipped g-code. return False - result = gzip.compress(gcode_textio.getvalue()) + result = gzip.compress(gcode_textio.getvalue().encode("utf-8")) stream.write(result) return True From 0efde6bae647d74868a98fdb6b4d9133df2b8405 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 16 Mar 2018 16:08:49 +0100 Subject: [PATCH 3/9] Use gzipped gcode by default for UM3 For the network printing output device this doesn't work yet, but for removable drives it will now put g-code in a gz archive when storing it. Contributes to issue CURA-5097. --- resources/definitions/ultimaker3.def.json | 2 +- resources/definitions/ultimaker3_extended.def.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/definitions/ultimaker3.def.json b/resources/definitions/ultimaker3.def.json index ef41686752..05f74c6342 100644 --- a/resources/definitions/ultimaker3.def.json +++ b/resources/definitions/ultimaker3.def.json @@ -6,7 +6,7 @@ "author": "Ultimaker", "manufacturer": "Ultimaker B.V.", "visible": true, - "file_formats": "text/x-gcode", + "file_formats": "application/gzip;text/x-gcode", "platform": "ultimaker3_platform.obj", "platform_texture": "Ultimaker3backplate.png", "platform_offset": [0, 0, 0], diff --git a/resources/definitions/ultimaker3_extended.def.json b/resources/definitions/ultimaker3_extended.def.json index 3a1be3a303..1e6c322c73 100644 --- a/resources/definitions/ultimaker3_extended.def.json +++ b/resources/definitions/ultimaker3_extended.def.json @@ -7,7 +7,7 @@ "manufacturer": "Ultimaker B.V.", "quality_definition": "ultimaker3", "visible": true, - "file_formats": "text/x-gcode", + "file_formats": "application/gzip;text/x-gcode", "platform": "ultimaker3_platform.obj", "platform_texture": "Ultimaker3Extendedbackplate.png", "platform_offset": [0, 0, 0], From c1f9b455bbe0630e9c4e04f1622352ee8c3fd9ce Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 11:22:55 +0100 Subject: [PATCH 4/9] Remove unnecessary import This import is not used. Contributes to issue CURA-5097. --- cura/PrinterOutput/NetworkedPrinterOutputDevice.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 1537d51919..9da57a812e 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -1,9 +1,8 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM.Application import Application from UM.Logger import Logger -from UM.Settings.ContainerRegistry import ContainerRegistry from cura.CuraApplication import CuraApplication from cura.PrinterOutputDevice import PrinterOutputDevice, ConnectionState @@ -232,7 +231,6 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): reply.uploadProgress.connect(onProgress) self._registerOnFinishedCallback(reply, onFinished) - return reply def postForm(self, target: str, header_data: str, body_data: bytes, onFinished: Optional[Callable[[Any, QNetworkReply], None]], onProgress: Callable = None) -> None: From 5bb20f61334c6b3c3a1e82ff1c27d02e7adf3286 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 11:24:15 +0100 Subject: [PATCH 5/9] Use preferred output writer This introduces a greenlet to allow optional interrupting of a function for waiting on the selectPrinter function. Contributes to issue CURA-5097. --- .../ClusterUM3OutputDevice.py | 115 +++++++++++++----- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py index c19c86d6ce..dfde76b233 100644 --- a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py @@ -1,12 +1,16 @@ -# Copyright (c) 2017 Ultimaker B.V. +# Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from UM.FileHandler.FileWriter import FileWriter #To choose based on the output file mode (text vs. binary). +from UM.FileHandler.WriteFileJob import WriteFileJob #To call the file writer asynchronously. from UM.Logger import Logger from UM.Application import Application from UM.Settings.ContainerRegistry import ContainerRegistry from UM.i18n import i18nCatalog from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat +from UM.OutputDevice import OutputDeviceError #To show that something went wrong when writing. +from UM.Scene.SceneNode import SceneNode #For typing. from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel @@ -20,10 +24,11 @@ from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from PyQt5.QtGui import QDesktopServices from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject -from time import time +from time import time, sleep from datetime import datetime from typing import Optional, Dict, List +import io #To create the correct buffers for sending data to the printer. import json import os @@ -79,24 +84,44 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): self._latest_reply_handler = None - def requestWrite(self, nodes, file_name=None, filter_by_machine=False, file_handler=None, **kwargs): + def requestWrite(self, nodes: List[SceneNode], file_name=None, filter_by_machine=False, file_handler=None, **kwargs): self.writeStarted.emit(self) - gcode_dict = getattr(Application.getInstance().getController().getScene(), "gcode_dict", []) - active_build_plate_id = Application.getInstance().getMultiBuildPlateModel().activeBuildPlate - gcode_list = gcode_dict[active_build_plate_id] - - if not gcode_list: - # Unable to find g-code. Nothing to send - return - - self._gcode = gcode_list - - is_job_sent = True - if len(self._printers) > 1: - self._spawnPrinterSelectionDialog() + #Formats supported by this application (file types that we can actually write). + if file_handler: + file_formats = file_handler.getSupportedFileTypesWrite() else: - is_job_sent = self.sendPrintJob() + file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() + + #Create a list from the supported file formats string. + container = Application.getInstance().getGlobalContainerStack().findContainer({"file_formats": "*"}) + machine_file_formats = [file_type.strip() for file_type in container.getMetaDataEntry("file_formats").split(";")] + + # Take the intersection between file_formats and machine_file_formats. + format_by_mimetype = {format["mime_type"]: format for format in file_formats} + file_formats = [format_by_mimetype[mimetype] for mimetype in machine_file_formats] #Keep them ordered according to the preference in machine_file_formats. + + if len(file_formats) == 0: + Logger.log("e", "There are no file formats available to write with!") + raise OutputDeviceError.WriteRequestFailedError(i18n_catalog.i18nc("@info:status", "There are no file formats available to write with!")) + preferred_format = file_formats[0] + + #Just take the first file format available. + if file_handler is not None: + writer = file_handler.getWriterByMimeType(preferred_format["mime_type"]) + else: + writer = Application.getInstance().getMeshFileHandler().getWriterByMimeType(preferred_format["mime_type"]) + + #This function pauses with the yield, waiting on instructions on which printer it needs to print with. + self._sending_job = self._sendPrintJob(writer, preferred_format, nodes) + self._sending_job.send(None) #Start the generator. + + if len(self._printers) > 1: #We need to ask the user. + self._spawnPrinterSelectionDialog() + is_job_sent = True + else: #Just immediately continue. + self._sending_job.send("") #No specifically selected printer. + is_job_sent = self._sending_job.send(None) # Notify the UI that a switch to the print monitor should happen if is_job_sent: @@ -113,29 +138,54 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): def clusterSize(self): return self._cluster_size - @pyqtSlot() + ## Allows the user to choose a printer to print with from the printer + # selection dialogue. + # \param target_printer The name of the printer to target. @pyqtSlot(str) - def sendPrintJob(self, target_printer: str = ""): + def selectPrinter(self, target_printer: str = "") -> None: + self._sending_job.send(target_printer) + + ## Greenlet to send a job to the printer over the network. + # + # This greenlet gets called asynchronously in requestWrite. It is a + # greenlet in order to optionally wait for selectPrinter() to select a + # printer. + # The greenlet yields exactly three times: First time None, + # \param writer The file writer to use to create the data. + # \param preferred_format A dictionary containing some information about + # what format to write to. This is necessary to create the correct buffer + # types and file extension and such. + def _sendPrintJob(self, writer: FileWriter, preferred_format: Dict, nodes: List[SceneNode]): Logger.log("i", "Sending print job to printer.") if self._sending_gcode: self._error_message = Message( i18n_catalog.i18nc("@info:status", "Sending new jobs (temporarily) blocked, still sending the previous print job.")) self._error_message.show() - return False + yield #Wait on the user to select a target printer. + yield #Wait for the write job to be finished. + yield False #Return whether this was a success or not. + yield #Prevent StopIteration. self._sending_gcode = True - self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), 0, False, -1, - i18n_catalog.i18nc("@info:title", "Sending Data")) - self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), None, "") + target_printer = yield #Potentially wait on the user to select a target printer. + + # Using buffering greatly reduces the write time for many lines of gcode + if preferred_format["mode"] == FileWriter.OutputMode.TextMode: + stream = io.StringIO() + else: #Binary mode. + stream = io.BytesIO() + + job = WriteFileJob(writer, stream, nodes, preferred_format["mode"]) + + self._progress_message = Message(i18n_catalog.i18nc("@info:status", "Sending data to printer"), lifetime = 0, dismissable = False, progress = -1, + title = i18n_catalog.i18nc("@info:title", "Sending Data")) + self._progress_message.addAction("Abort", i18n_catalog.i18nc("@action:button", "Cancel"), icon = None, description = "") self._progress_message.actionTriggered.connect(self._progressMessageActionTriggered) self._progress_message.show() - compressed_gcode = self._compressGCode() - if compressed_gcode is None: - # Abort was called. - return False + job.start() parts = [] @@ -149,11 +199,18 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName - parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, compressed_gcode)) + while not job.isFinished(): + sleep(0.1) + output = stream.getvalue() #Either str or bytes depending on the output mode. + if isinstance(stream, io.StringIO): + output = output.encode("utf-8") + + parts.append(self._createFormPart("name=\"file\"; filename=\"%s\"" % file_name, output)) self._latest_reply_handler = self.postFormWithParts("print_jobs/", parts, onFinished=self._onPostPrintJobFinished, onProgress=self._onUploadPrintJobProgress) - return True + yield True #Return that we had success! + yield #To prevent having to catch the StopIteration exception. @pyqtProperty(QObject, notify=activePrinterChanged) def activePrinter(self) -> Optional[PrinterOutputModel]: From 7a464a92ca6c8a630abeca75457e54902b301685 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 11:39:41 +0100 Subject: [PATCH 6/9] Fix callback to output device with selected printer This is a new function that just selects the printer. Contributes to issue CURA-5097. --- plugins/UM3NetworkPrinting/PrintWindow.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/PrintWindow.qml b/plugins/UM3NetworkPrinting/PrintWindow.qml index d84b0f30ec..43afbcdfe0 100644 --- a/plugins/UM3NetworkPrinting/PrintWindow.qml +++ b/plugins/UM3NetworkPrinting/PrintWindow.qml @@ -101,7 +101,7 @@ UM.Dialog enabled: true onClicked: { base.visible = false; - OutputDevice.sendPrintJob(printerSelectionCombobox.model.get(printerSelectionCombobox.currentIndex).key) + OutputDevice.selectPrinter(printerSelectionCombobox.model.get(printerSelectionCombobox.currentIndex).key) // reset to defaults printerSelectionCombobox.currentIndex = 0 } From c9a23d5ca35452a7b366a28263020495439d6bf6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 14:21:41 +0100 Subject: [PATCH 7/9] Get the file formats directly from the stack Instead of finding the container that contains the entry first and then getting the metadata from there. Contributes to issue CURA-5097. --- plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py index dfde76b233..431adec145 100644 --- a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py @@ -94,8 +94,8 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): file_formats = Application.getInstance().getMeshFileHandler().getSupportedFileTypesWrite() #Create a list from the supported file formats string. - container = Application.getInstance().getGlobalContainerStack().findContainer({"file_formats": "*"}) - machine_file_formats = [file_type.strip() for file_type in container.getMetaDataEntry("file_formats").split(";")] + machine_file_formats = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("file_formats").split(";") + machine_file_formats = [file_type.strip() for file_type in machine_file_formats] # Take the intersection between file_formats and machine_file_formats. format_by_mimetype = {format["mime_type"]: format for format in file_formats} From 6f9e0431bb5352f6b63dead481f16621e6019f38 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 15:17:10 +0100 Subject: [PATCH 8/9] Fix extension when sending to printer It should use the extension of the preferred format. Contributes to issue CURA-5097. --- plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py index 431adec145..eda5841310 100644 --- a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py @@ -197,7 +197,7 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): # Add user name to the print_job parts.append(self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain")) - file_name = "%s.gcode.gz" % Application.getInstance().getPrintInformation().jobName + file_name = Application.getInstance().getPrintInformation().jobName + "." + preferred_format["extension"] while not job.isFinished(): sleep(0.1) From 864cbe9c636c3a88fbdd8516307cd995fb938941 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 19 Mar 2018 16:24:07 +0100 Subject: [PATCH 9/9] Add exception for UM3 to add UFP as supported format We can't just add it to the supported file formats in the definition because we don't want the removable drive output to write it. Contributes to issue CURA-5097. --- plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py index eda5841310..c804200701 100644 --- a/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py +++ b/plugins/UM3NetworkPrinting/ClusterUM3OutputDevice.py @@ -11,6 +11,7 @@ from UM.Message import Message from UM.Qt.Duration import Duration, DurationFormat from UM.OutputDevice import OutputDeviceError #To show that something went wrong when writing. from UM.Scene.SceneNode import SceneNode #For typing. +from UM.Version import Version #To check against firmware versions for support. from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel @@ -96,6 +97,9 @@ class ClusterUM3OutputDevice(NetworkedPrinterOutputDevice): #Create a list from the supported file formats string. machine_file_formats = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("file_formats").split(";") machine_file_formats = [file_type.strip() for file_type in machine_file_formats] + #Exception for UM3 firmware version >=4.4: UFP is now supported and should be the preferred file format. + if "application/x-ufp" not in machine_file_formats and self.printerType == "ultimaker3" and Version(self.firmwareVersion) >= Version("4.4"): + machine_file_formats = ["application/x-ufp"] + machine_file_formats # Take the intersection between file_formats and machine_file_formats. format_by_mimetype = {format["mime_type"]: format for format in file_formats}