diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f74a5..28e401c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog of Cura-DuetRRFPlugin +## v1.2.5: 2021- + * embed a QOI thumbnail image of the sliced scene into the uploaded gcode file + ## v1.2.4: 2021-09-19 * auto-dismiss success message notifications after 15sec diff --git a/DuetRRFOutputDevice.py b/DuetRRFOutputDevice.py index 20cea49..f7081f3 100644 --- a/DuetRRFOutputDevice.py +++ b/DuetRRFOutputDevice.py @@ -1,21 +1,29 @@ +import array import os.path import datetime import base64 import urllib import json from io import StringIO -from time import time, sleep from typing import cast from enum import Enum -from PyQt5 import QtNetwork +from PyQt5 import QtNetwork, QtCore from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtCore import QFile, QUrl, QObject, QCoreApplication, QByteArray, QTimer, pyqtProperty, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QDesktopServices +from PyQt5.QtGui import QDesktopServices, QImage from PyQt5.QtQml import QQmlComponent, QQmlContext from cura.CuraApplication import CuraApplication +from cura.Snapshot import Snapshot +from cura.PreviewPass import PreviewPass + +from UM.Application import Application +from UM.Math.Matrix import Matrix +from UM.Math.Vector import Vector +from UM.Scene.Camera import Camera +from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Application import Application from UM.Logger import Logger @@ -27,6 +35,8 @@ from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") +from .qoi import QOIEncoder + class OutputStage(Enum): ready = 0 @@ -180,6 +190,99 @@ def requestWrite(self, node, fileName=None, *args, **kwargs): self._dialog.findChild(QObject, "nameField").select(0, len(self._fileName) - 6) self._dialog.findChild(QObject, "nameField").setProperty('focus', True) + def create_thumbnail(self, width, height): + scene = Application.getInstance().getController().getScene() + active_camera = scene.getActiveCamera() + render_width, render_height = active_camera.getWindowSize() + render_width = int(render_width) + render_height = int(render_height) + Logger.log("d", f"Found active camera with {render_width=} {render_height=}") + + QCoreApplication.processEvents() + + satisfied = False + zooms = 0 + preview_pass = PreviewPass(render_width, render_height) + size = None + fovy = 30 + while not satisfied and zooms < 5: + preview_pass.render() + pixel_output = preview_pass.getOutput().convertToFormat(QImage.Format_ARGB32) + pixel_output.save(os.path.expanduser(f"~/Downloads/foo-a-zoom-{zooms}.png"), "PNG") + + min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output) + size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height) + if size > 0.5 or satisfied: + satisfied = True + else: + # make it big and allow for some empty space around + zooms += 1 + fovy *= 0.75 + projection_matrix = Matrix() + projection_matrix.setPerspective(fovy, render_width / render_height, 1, 500) + active_camera.setProjectionMatrix(projection_matrix) + + Logger.log("d", f"Rendered thumbnail: {zooms=}, {size=}, {min_x=}, {max_x=}, {min_y=}, {max_y=}, {fovy=}") + + # crop to content + pixel_output = pixel_output.copy(min_x, min_y, max_x - min_x, max_y - min_y) + Logger.log("d", f"Cropped thumbnail to {min_x}, {min_y}, {max_x - min_x}, {max_y - min_y}.") + pixel_output.save(os.path.expanduser("~/Downloads/foo-b-cropped.png"), "PNG") + + # scale to desired width and height + pixel_output = pixel_output.scaled( + width, height, + aspectRatioMode=QtCore.Qt.KeepAspectRatio, + transformMode=QtCore.Qt.SmoothTransformation + ) + Logger.log("d", f"Scaled thumbnail to {width=}, {height=}.") + pixel_output.save(os.path.expanduser("~/Downloads/foo-c-scaled.png"), "PNG") + + # center image within desired width and height if one dimension is too small + if pixel_output.width() < width: + d = (width - pixel_output.width()) / 2. + pixel_output = pixel_output.copy(-d, 0, width, pixel_output.height()) + Logger.log("d", f"Centered thumbnail horizontally {d=}.") + if pixel_output.height() < height: + d = (height - pixel_output.height()) / 2. + pixel_output = pixel_output.copy(0, -d, pixel_output.width(), height) + Logger.log("d", f"Centered thumbnail vertically {d=}.") + pixel_output.save(os.path.expanduser("~/Downloads/foo-d-aspect-fixed.png"), "PNG") + + Logger.log("d", "Successfully created thumbnail of scene.") + return pixel_output + + def embed_thumbnail(self): + Logger.log("d", "Creating thumbnail in QOI format to include in gcode file...") + try: + # thumbnail = Snapshot.snapshot(width, height) + + # PanelDue: 480×272 (4.3" displays) or 800×480 pixels (5" and 7" displays) + + width = 320 + height = 320 + thumbnail = self.create_thumbnail(width, height) + + # https://qoiformat.org/qoi-specification.pdf + pixels = [thumbnail.pixel(x, y) for y in range(height) for x in range(width)] + pixels = [(unsigned_p ^ (1 << 31)) - (1 << 31) for unsigned_p in pixels] + encoder = QOIEncoder() + r = encoder.encode(width, height, pixels, alpha=thumbnail.hasAlphaChannel(), linear_colorspace=False) + if not r: + raise ValueError("image size unsupported") + + Logger.log("d", "Successfully encoded thumbnail in QOI format.") + + thumbnail.save(os.path.expanduser("~/Downloads/snapshot.png"), "PNG") + with open(os.path.expanduser("~/Downloads/snapshot.qoi"), "wb") as f: + actual_size = encoder.get_encoded_size() + f.write(encoder.get_encoded()[:actual_size]) + except Exception as e: + Logger.log("d", "failed to create snapshot: " + str(e)) + import traceback + Logger.log("d", traceback.format_stack()) + # continue without the QOI snapshot + def onFilenameChanged(self): fileName = self._dialog.findChild(QObject, "nameField").property('text').strip() @@ -213,7 +316,7 @@ def onFilenameAccepted(self): # show a progress message self._message = Message( - "Uploading {} ...".format(self._fileName), + "Rendering thumbnail image...", lifetime=0, dismissable=False, progress=-1, @@ -221,10 +324,14 @@ def onFilenameAccepted(self): ) self._message.show() - Logger.log("d", "Loading gcode...") + # generate model thumbnail and embedd in gcode file + self.embed_thumbnail() + + self._message.setText("Uploading {} ...".format(self._fileName)) # get the g-code through the GCodeWrite plugin # this serializes the actual scene and should produce the same output as "Save to File" + Logger.log("d", "Loading gcode...") gcode_writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter")) success = gcode_writer.write(self._stream, None) if not success: @@ -449,6 +556,7 @@ def onStatusReceived(self, reply): data=gcode.encode(), next_stage=self.onReported, ) + def onM37Reported(self, reply): if self._stage != OutputStage.writing: return diff --git a/plugin.json b/plugin.json index 7bf0583..4b8e948 100644 --- a/plugin.json +++ b/plugin.json @@ -2,6 +2,6 @@ "name": "DuetRRF", "author": "Thomas Kriechbaumer", "description": "Upload and Print to Duet 2 Wifi / Duet 2 Ethernet / Duet 2 Maestro / Duet 3 with RepRapFirmware.", - "version": "1.2.4", - "supported_sdk_versions": ["7.3.0", "7.4.0", "7.5.0", "7.6.0", "7.7.0"] + "version": "1.2.5", + "supported_sdk_versions": ["7.3.0", "7.4.0", "7.5.0", "7.6.0", "7.7.0", "7.8.0", "7.9.0"] } diff --git a/qoi.py b/qoi.py new file mode 100644 index 0000000..0073051 --- /dev/null +++ b/qoi.py @@ -0,0 +1,241 @@ +# imported from https://github.com/pfusik/qoi-ci/blob/49b3dc16414ed14bd1600b97f7148b6af9e929ad/transpiled/QOI.py +# This code is licensed under the standard MIT license. +# Copyright (C) 2021 Piotr Fusik + +# Generated automatically with "cito". Do not edit. +import array + +class QOIEncoder: + """Encoder of the "Quite OK Image" (QOI) format. + + Losslessly compresses an image to a byte array.""" + + _HEADER_SIZE = 14 + + _PADDING_SIZE = 8 + + def __init__(self): + """Constructs the encoder. + + The encoder can be used for several images, one after another.""" + + @staticmethod + def can_encode(width, height, alpha): + """Determines if an image of given size can be encoded. + + :param width: Image width in pixels. + :param height: Image height in pixels. + :param alpha: Whether the image has the alpha channel (transparency). + """ + return width > 0 and height > 0 and height <= int(int(2147483625 / width) / (5 if alpha else 4)) + + def encode(self, width, height, pixels, alpha, linear_colorspace): + """Encodes the given image. + + Returns `true` if encoded successfully. + + :param width: Image width in pixels. + :param height: Image height in pixels. + :param pixels: Pixels of the image, top-down, left-to-right. + :param alpha: `false` specifies that all pixels are opaque. High bytes of `pixels` elements are ignored then. + :param linear_colorspace: Specifies the color space. + """ + if pixels is None or not QOIEncoder.can_encode(width, height, alpha): + return False + pixels_size = width * height + encoded = bytearray(14 + pixels_size * (5 if alpha else 4) + 8) + encoded[0] = 113 + encoded[1] = 111 + encoded[2] = 105 + encoded[3] = 102 + encoded[4] = width >> 24 + encoded[5] = width >> 16 & 255 + encoded[6] = width >> 8 & 255 + encoded[7] = width & 255 + encoded[8] = height >> 24 + encoded[9] = height >> 16 & 255 + encoded[10] = height >> 8 & 255 + encoded[11] = height & 255 + encoded[12] = 4 if alpha else 3 + encoded[13] = 1 if linear_colorspace else 0 + index = array.array("i", [ 0 ]) * 64 + encoded_offset = 14 + last_pixel = -16777216 + run = 0 + pixels_offset = 0 + while pixels_offset < pixels_size: + pixel = pixels[pixels_offset] + pixels_offset += 1 + if not alpha: + pixel |= -16777216 + if pixel == last_pixel: + run += 1 + if run == 62 or pixels_offset >= pixels_size: + encoded[encoded_offset] = 191 + run + encoded_offset += 1 + run = 0 + else: + if run > 0: + encoded[encoded_offset] = 191 + run + encoded_offset += 1 + run = 0 + index_offset = ((pixel >> 16) * 3 + (pixel >> 8) * 5 + (pixel & 63) * 7 + (pixel >> 24) * 11) & 63 + if pixel == index[index_offset]: + encoded[encoded_offset] = index_offset + encoded_offset += 1 + else: + index[index_offset] = pixel + r = pixel >> 16 & 255 + g = pixel >> 8 & 255 + b = pixel & 255 + a = pixel >> 24 & 255 + if (pixel ^ last_pixel) >> 24 != 0: + encoded[encoded_offset] = 255 + encoded[encoded_offset + 1] = r + encoded[encoded_offset + 2] = g + encoded[encoded_offset + 3] = b + encoded[encoded_offset + 4] = a + encoded_offset += 5 + else: + dr = r - (last_pixel >> 16 & 255) + dg = g - (last_pixel >> 8 & 255) + db = b - (last_pixel & 255) + if dr >= -2 and dr <= 1 and dg >= -2 and dg <= 1 and db >= -2 and db <= 1: + encoded[encoded_offset] = 106 + (dr << 4) + (dg << 2) + db + encoded_offset += 1 + else: + dr -= dg + db -= dg + if dr >= -8 and dr <= 7 and dg >= -32 and dg <= 31 and db >= -8 and db <= 7: + encoded[encoded_offset] = 160 + dg + encoded[encoded_offset + 1] = 136 + (dr << 4) + db + encoded_offset += 2 + else: + encoded[encoded_offset] = 254 + encoded[encoded_offset + 1] = r + encoded[encoded_offset + 2] = g + encoded[encoded_offset + 3] = b + encoded_offset += 4 + last_pixel = pixel + encoded[encoded_offset:encoded_offset + 7] = bytearray(7) + encoded[encoded_offset + 8 - 1] = 1 + self._encoded = encoded + self._encoded_size = encoded_offset + 8 + return True + + def get_encoded(self): + """Returns the encoded file contents. + + This method can only be called after `Encode` returned `true`. + The allocated array is usually larger than the encoded data. + Call `GetEncodedSize` to retrieve the number of leading bytes that are significant.""" + return self._encoded + + def get_encoded_size(self): + """Returns the encoded file length.""" + return self._encoded_size + +class QOIDecoder: + """Decoder of the "Quite OK Image" (QOI) format.""" + + def __init__(self): + """Constructs the decoder. + + The decoder can be used for several images, one after another.""" + + def decode(self, encoded, encoded_size): + """Decodes the given QOI file contents. + + Returns `true` if decoded successfully. + + :param encoded: QOI file contents. Only the first `encodedSize` bytes are accessed. + :param encoded_size: QOI file length. + """ + if encoded is None or encoded_size < 23 or encoded[0] != 113 or encoded[1] != 111 or encoded[2] != 105 or encoded[3] != 102: + return False + width = encoded[4] << 24 | encoded[5] << 16 | encoded[6] << 8 | encoded[7] + height = encoded[8] << 24 | encoded[9] << 16 | encoded[10] << 8 | encoded[11] + if width <= 0 or height <= 0 or height > int(2147483647 / width): + return False + if encoded[12] == 3: + self._alpha = False + elif encoded[12] == 4: + self._alpha = True + else: + return False + if encoded[13] == 0: + self._linear_colorspace = False + elif encoded[13] == 1: + self._linear_colorspace = True + else: + return False + pixels_size = width * height + pixels = array.array("i", [ 0 ]) * pixels_size + encoded_size -= 8 + encoded_offset = 14 + index = array.array("i", [ 0 ]) * 64 + pixel = -16777216 + pixels_offset = 0 + while pixels_offset < pixels_size: + if encoded_offset >= encoded_size: + return False + e = encoded[encoded_offset] + encoded_offset += 1 + ci_switch_tmp = e >> 6 + if ci_switch_tmp == 0: + pixels[pixels_offset] = pixel = index[e] + pixels_offset += 1 + continue + elif ci_switch_tmp == 1: + pixel = (pixel & -16777216) | ((pixel + (((e >> 4) - 4 - 2) << 16)) & 16711680) | ((pixel + (((e >> 2 & 3) - 2) << 8)) & 65280) | ((pixel + (e & 3) - 2) & 255) + elif ci_switch_tmp == 2: + e -= 160 + rb = encoded[encoded_offset] + encoded_offset += 1 + pixel = (pixel & -16777216) | ((pixel + ((e + (rb >> 4) - 8) << 16)) & 16711680) | ((pixel + (e << 8)) & 65280) | ((pixel + e + (rb & 15) - 8) & 255) + else: + if e < 254: + e -= 191 + if pixels_offset + e > pixels_size: + return False + pixels[pixels_offset:pixels_offset + e] = array.array("i", [ pixel ]) * e + pixels_offset += e + continue + if e == 254: + pixel = (pixel & -16777216) | encoded[encoded_offset] << 16 | encoded[encoded_offset + 1] << 8 | encoded[encoded_offset + 2] + encoded_offset += 3 + else: + pixel = encoded[encoded_offset + 3] << 24 | encoded[encoded_offset] << 16 | encoded[encoded_offset + 1] << 8 | encoded[encoded_offset + 2] + encoded_offset += 4 + pixels[pixels_offset] = index[((pixel >> 16) * 3 + (pixel >> 8) * 5 + (pixel & 63) * 7 + (pixel >> 24) * 11) & 63] = pixel + pixels_offset += 1 + if encoded_offset != encoded_size: + return False + self._width = width + self._height = height + self._pixels = pixels + return True + + def get_width(self): + """Returns the width of the decoded image in pixels.""" + return self._width + + def get_height(self): + """Returns the height of the decoded image in pixels.""" + return self._height + + def get_pixels(self): + """Returns the pixels of the decoded image, top-down, left-to-right. + + Each pixel is a 32-bit integer 0xAARRGGBB.""" + return self._pixels + + def has_alpha(self): + """Returns the information about the alpha channel from the file header.""" + return self._alpha + + def is_linear_colorspace(self): + """Returns the color space information from the file header. + + `false` = sRGB with linear alpha channel.`true` = all channels linear.""" + return self._linear_colorspace