Merge branch 'master' into graphics_buffer_update

This commit is contained in:
Remco Burema 2021-07-25 22:30:39 +02:00
commit 458fbd35f1
4327 changed files with 77905 additions and 22116 deletions

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from configparser import ConfigParser
@ -412,7 +412,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
quality_container_id = parser["containers"][str(_ContainerIndexes.Quality)]
quality_type = "empty_quality"
if quality_container_id not in ("empty", "empty_quality"):
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
if quality_container_id in instance_container_info_dict:
quality_type = instance_container_info_dict[quality_container_id].parser["metadata"]["quality_type"]
else: # If a version upgrade changed the quality profile in the stack, we'll need to look for it in the built-in profiles instead of the workspace.
quality_matches = ContainerRegistry.getInstance().findContainersMetadata(id = quality_container_id)
if quality_matches: # If there's no profile with this ID, leave it empty_quality.
quality_type = quality_matches[0]["quality_type"]
# Get machine info
serialized = archive.open(global_stack_file).read().decode("utf-8")
@ -1157,7 +1162,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return
machine_manager.setQualityChangesGroup(quality_changes_group, no_dialog = True)
else:
self._quality_type_to_apply = self._quality_type_to_apply.lower()
self._quality_type_to_apply = self._quality_type_to_apply.lower() if self._quality_type_to_apply else None
quality_group_dict = container_tree.getCurrentQualityGroups()
if self._quality_type_to_apply in quality_group_dict:
quality_group = quality_group_dict[self._quality_type_to_apply]

View file

@ -419,7 +419,7 @@ UM.Dialog
width: warningLabel.height
height: width
source: UM.Theme.getIcon("notice")
source: UM.Theme.getIcon("Information")
color: palette.text
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for reading 3MF files.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for writing 3MF files.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -157,22 +157,22 @@ class AMFReader(MeshReader):
tri_faces = tri_node.faces
tri_vertices = tri_node.vertices
indices = []
vertices = []
indices_list = []
vertices_list = []
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])
vertices_list.append(tri_vertices[tri_index])
face.append(index_count)
index_count += 1
indices.append(face)
indices_list.append(face)
face_count += 1
vertices = numpy.asarray(vertices, dtype = numpy.float32)
indices = numpy.asarray(indices, dtype = numpy.int32)
vertices = numpy.asarray(vertices_list, dtype = numpy.float32)
indices = numpy.asarray(indices_list, dtype = numpy.int32)
normals = calculateNormalsFromIndexedVertices(vertices, indices, face_count)
mesh_data = MeshData(vertices = vertices, indices = indices, normals = normals,file_name = file_name)

View file

@ -3,5 +3,5 @@
"author": "fieldOfView",
"version": "1.0.0",
"description": "Provides support for reading AMF files.",
"api": "7.4.0"
"api": 7
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Backup and restore your configuration.",
"version": "1.2.0",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -5,7 +5,6 @@ import threading
from datetime import datetime
from typing import Any, Dict, Optional
import sentry_sdk
from PyQt5.QtNetwork import QNetworkReply
from UM.Job import Job
@ -99,13 +98,7 @@ class CreateBackupJob(Job):
if HttpRequestManager.safeHttpStatus(reply) == 400:
errors = json.loads(replyText)["errors"]
if "moreThanMaximum" in [error["code"] for error in errors if error["meta"] and error["meta"]["field_name"] == "backup_size"]:
if self._backup_zip is None: # will never happen; keep mypy happy
zip_error = "backup is None."
else:
zip_error = "{} exceeds max size.".format(str(len(self._backup_zip)))
sentry_sdk.capture_message("backup failed: {}".format(zip_error), level ="warning")
self.backup_upload_error_message = catalog.i18nc("@error:file_size", "The backup exceeds the maximum file size.")
from sentry_sdk import capture_message
self._job_done.set()
return

View file

@ -93,7 +93,7 @@ class DriveApiService:
def _onRestoreFinished(self, job: "RestoreBackupJob") -> None:
if job.restore_backup_error_message != "":
# If the job contains an error message we pass it along so the UI can display it.
self.restoringStateChanged.emit(is_restoring=False)
self.restoringStateChanged.emit(is_restoring = False)
else:
self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message)

View file

@ -34,6 +34,9 @@ class DrivePluginExtension(QObject, Extension):
# Signal emitted when preferences changed (like auto-backup).
preferencesChanged = pyqtSignal()
# Signal emitted when the id of the backup-to-be-restored is changed
backupIdBeingRestoredChanged = pyqtSignal(arguments = ["backup_id_being_restored"])
DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
def __init__(self) -> None:
@ -45,6 +48,7 @@ class DrivePluginExtension(QObject, Extension):
self._backups = [] # type: List[Dict[str, Any]]
self._is_restoring_backup = False
self._is_creating_backup = False
self._backup_id_being_restored = ""
# Initialize services.
preferences = CuraApplication.getInstance().getPreferences()
@ -52,6 +56,7 @@ class DrivePluginExtension(QObject, Extension):
# Attach signals.
CuraApplication.getInstance().getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
CuraApplication.getInstance().applicationShuttingDown.connect(self._onApplicationShuttingDown)
self._drive_api_service.restoringStateChanged.connect(self._onRestoringStateChanged)
self._drive_api_service.creatingStateChanged.connect(self._onCreatingStateChanged)
@ -75,6 +80,10 @@ class DrivePluginExtension(QObject, Extension):
if self._drive_window:
self._drive_window.show()
def _onApplicationShuttingDown(self):
if self._drive_window:
self._drive_window.hide()
def _autoBackup(self) -> None:
preferences = CuraApplication.getInstance().getPreferences()
if preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._isLastBackupTooLongAgo():
@ -100,10 +109,11 @@ class DrivePluginExtension(QObject, Extension):
if logged_in:
self.refreshBackups()
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: Optional[str] = None) -> None:
self._is_restoring_backup = is_restoring
self.restoringStateChanged.emit()
if error_message:
self.backupIdBeingRestored = ""
Message(error_message, title = catalog.i18nc("@info:title", "Backup")).show()
def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
@ -152,6 +162,7 @@ class DrivePluginExtension(QObject, Extension):
for backup in self._backups:
if backup.get("backup_id") == backup_id:
self._drive_api_service.restoreBackup(backup)
self.setBackupIdBeingRestored(backup_id)
return
Logger.log("w", "Unable to find backup with the ID %s", backup_id)
@ -166,3 +177,12 @@ class DrivePluginExtension(QObject, Extension):
def _backupDeletedCallback(self, success: bool):
if success:
self.refreshBackups()
def setBackupIdBeingRestored(self, backup_id_being_restored: str) -> None:
if backup_id_being_restored != self._backup_id_being_restored:
self._backup_id_being_restored = backup_id_being_restored
self.backupIdBeingRestoredChanged.emit()
@pyqtProperty(str, fset = setBackupIdBeingRestored, notify = backupIdBeingRestoredChanged)
def backupIdBeingRestored(self) -> str:
return self._backup_id_being_restored

View file

@ -1,3 +1,6 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import base64
import hashlib
import threading
@ -56,14 +59,20 @@ class RestoreBackupJob(Job):
return
# We store the file in a temporary path fist to ensure integrity.
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
app = CuraApplication.getInstance()
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
write_backup.write(bytes_read)
try:
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
app = CuraApplication.getInstance()
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents()
while bytes_read:
write_backup.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents()
except EnvironmentError as e:
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
# Don't restore the backup if the MD5 hashes do not match.

View file

@ -20,7 +20,7 @@ RowLayout
{
id: infoButton
text: catalog.i18nc("@button", "Want more?")
iconSource: UM.Theme.getIcon("info")
iconSource: UM.Theme.getIcon("Information")
onClicked: Qt.openUrlExternally("https://goo.gl/forms/QACEP8pP3RV60QYG2")
visible: backupListFooter.showInfoButton
}
@ -29,7 +29,7 @@ RowLayout
{
id: createBackupButton
text: catalog.i18nc("@button", "Backup Now")
iconSource: UM.Theme.getIcon("plus")
iconSource: UM.Theme.getIcon("Plus")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
onClicked: CuraDrive.createBackup()
busy: CuraDrive.isCreatingBackup

View file

@ -38,7 +38,7 @@ Item
height: UM.Theme.getSize("section_icon").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("info")
iconSource: UM.Theme.getIcon("Information")
onClicked: backupListItem.showDetails = !backupListItem.showDetails
}
@ -71,6 +71,7 @@ Item
text: catalog.i18nc("@button", "Restore")
enabled: !CuraDrive.isCreatingBackup && !CuraDrive.isRestoringBackup
onClicked: confirmRestoreDialog.visible = true
busy: CuraDrive.backupIdBeingRestored == modelData.backup_id && CuraDrive.isRestoringBackup
}
UM.SimpleButton
@ -79,7 +80,7 @@ Item
height: UM.Theme.getSize("message_close").height
color: UM.Theme.getColor("small_button_text")
hoverColor: UM.Theme.getColor("small_button_text_hover")
iconSource: UM.Theme.getIcon("cross1")
iconSource: UM.Theme.getIcon("Cancel")
onClicked: confirmDeleteDialog.visible = true
}
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
@ -17,7 +17,7 @@ ColumnLayout
// Cura version
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("application")
iconSource: UM.Theme.getIcon("UltimakerCura")
label: catalog.i18nc("@backuplist:label", "Cura Version")
value: backupDetailsData.metadata.cura_release
}
@ -25,7 +25,7 @@ ColumnLayout
// Machine count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("printer_single")
iconSource: UM.Theme.getIcon("Printer")
label: catalog.i18nc("@backuplist:label", "Machines")
value: backupDetailsData.metadata.machine_count
}
@ -33,7 +33,7 @@ ColumnLayout
// Material count
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("category_material")
iconSource: UM.Theme.getIcon("Spool")
label: catalog.i18nc("@backuplist:label", "Materials")
value: backupDetailsData.metadata.material_count
}
@ -41,7 +41,7 @@ ColumnLayout
// Profile count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("settings")
iconSource: UM.Theme.getIcon("Sliders")
label: catalog.i18nc("@backuplist:label", "Profiles")
value: backupDetailsData.metadata.profile_count
}
@ -49,7 +49,7 @@ ColumnLayout
// Plugin count.
BackupListItemDetailsRow
{
iconSource: UM.Theme.getIcon("plugin")
iconSource: UM.Theme.getIcon("Plugin")
label: catalog.i18nc("@backuplist:label", "Plugins")
value: backupDetailsData.metadata.plugin_count
}

View file

@ -4,12 +4,12 @@
import argparse #To run the engine in debug mode if the front-end is in debug mode.
from collections import defaultdict
import os
from PyQt5.QtCore import QObject, QTimer, pyqtSlot
from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSlot
import sys
from time import time
from typing import Any, cast, Dict, List, Optional, Set, TYPE_CHECKING
from PyQt5.QtGui import QImage
from PyQt5.QtGui import QDesktopServices, QImage
from UM.Backend.Backend import Backend, BackendState
from UM.Scene.SceneNode import SceneNode
@ -157,6 +157,18 @@ class CuraEngineBackend(QObject, Backend):
self.determineAutoSlicing()
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
self._slicing_error_message = Message(
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."),
title = catalog.i18nc("@message:title", "Slicing failed")
)
self._slicing_error_message.addAction(
action_id = "report_bug",
name = catalog.i18nc("@message:button", "Report a bug"),
description = catalog.i18nc("@message:description", "Report a bug on Ultimaker Cura's issue tracker."),
icon = "[no_icon]"
)
self._slicing_error_message.actionTriggered.connect(self._reportBackendError)
self._snapshot = None #type: Optional[QImage]
application.initializationFinished.connect(self.initialize)
@ -922,9 +934,22 @@ class CuraEngineBackend(QObject, Backend):
if not self._restart:
if self._process: # type: ignore
Logger.log("d", "Backend quit with return code %s. Resetting process and socket.", self._process.wait()) # type: ignore
return_code = self._process.wait()
if return_code != 0:
Logger.log("e", f"Backend exited abnormally with return code {return_code}!")
self._slicing_error_message.show()
self.setState(BackendState.Error)
self.stopSlicing()
else:
Logger.log("d", "Backend finished slicing. Resetting process and socket.")
self._process = None # type: ignore
def _reportBackendError(self, _message_id: str, _action_id: str) -> None:
"""
Triggered when the user wants to report an error in the back-end.
"""
QDesktopServices.openUrl(QUrl("https://github.com/Ultimaker/Cura/issues/new/choose"))
def _onGlobalStackChanged(self) -> None:
"""Called when the global container stack changes"""

View file

@ -2,7 +2,7 @@
"name": "CuraEngine Backend",
"author": "Ultimaker B.V.",
"description": "Provides the link to the CuraEngine slicing backend.",
"api": "7.4.0",
"api": 7,
"version": "1.0.1",
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing Cura profiles.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for exporting Cura profiles.",
"api": "7.4.0",
"api": 7,
"i18n-catalog":"cura"
}

View file

@ -0,0 +1,17 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController
def getMetaData():
return {}
def register(app):
df_controller = DigitalFactoryController.DigitalFactoryController(app)
return {
"file_provider": DigitalFactoryFileProvider.DigitalFactoryFileProvider(df_controller),
"output_device": DigitalFactoryOutputDevicePlugin.DigitalFactoryOutputDevicePlugin(df_controller)
}

View file

@ -0,0 +1,8 @@
{
"name": "Ultimaker Digital Library",
"author": "Ultimaker B.V.",
"description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
"version": "1.0.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<polygon style="fill:#000E1A;" points="19.7,13.3 18.3,11.9 13,17.2 13,3 11,3 11,17.2 5.7,11.9 4.3,13.3 12,21 "/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M24,44,7,33.4V14.6L24,4,41,14.6V33.4ZM9,32.3l15,9.3,15-9.3V15.7L24,6.4,9,15.7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 378.13 348.13" version="1.1">
<defs
id="defs7">
<style
id="style2">
.cls-2,.cls-6{fill:#c5dbfb;}
.cls-6,.cls-7{stroke-width:2px;}
.cls-7{fill:#f3f8fe;}
.cls-6,.cls-7{stroke:#061884;}
</style>
</defs>
<path class="cls-2" d="M43,17V3H83a2,2,0,0,1,2,2V17Z" />
<path fill="white" d="M 3 1 C 1.8954305 1 1 1.8954305 1 3 L 1 67 C 1 68.104569 1.8954305 69 3 69 L 72.152344 69 A 100 100 0 0 1 89 49.873047 L 89 19 C 89 17.895431 88.104569 17 87 17 L 56 17 L 40 1 L 3 1 z " />
<path fill="#c5dbfb" d="M 3 0 C 1.3549904 0 0 1.3549904 0 3 L 0 67 C 0 68.64501 1.3549904 70 3 70 L 71.484375 70 A 100 100 0 0 1 72.835938 68 L 3 68 C 2.4358706 68 2 67.564129 2 67 L 2 3 C 2 2.4358706 2.4358706 2 3 2 L 39.585938 2 L 55.585938 18 L 87 18 C 87.564129 18 88 18.435871 88 19 L 88 50.765625 A 100 100 0 0 1 90 49.007812 L 90 19 C 90 17.35499 88.64501 16 87 16 L 56.414062 16 L 40.414062 0 L 3 0 z " />
<path class="cls-2" d="M153,17V3h40a2,2,0,0,1,2,2V17Z" />
<path fill="white" d="M 113 1 C 111.89543 1 111 1.8954305 111 3 L 111 35.201172 A 100 100 0 0 1 155 25 A 100 100 0 0 1 199 35.201172 L 199 19 C 199 17.895431 198.10457 17 197 17 L 166 17 L 150 1 L 113 1 z " />
<path fill="#c5dbfb" d="M 113 0 C 111.35499 0 110 1.3549904 110 3 L 110 35.699219 A 100 100 0 0 1 112 34.716797 L 112 3 C 112 2.4358706 112.43587 2 113 2 L 149.58594 2 L 165.58594 18 L 197 18 C 197.56413 18 198 18.435871 198 19 L 198 34.716797 A 100 100 0 0 1 200 35.699219 L 200 19 C 200 17.35499 198.64501 16 197 16 L 166.41406 16 L 150.41406 0 L 113 0 z " />
<path class="cls-2" d="M263,17V3h40a2,2,0,0,1,2,2V17Z" />
<path fill="white" d="M 223 1 C 221.89543 1 221 1.8954305 221 3 L 221 49.875 A 100 100 0 0 1 237.84961 69 L 307 69 C 308.10457 69 309 68.104569 309 67 L 309 19 C 309 17.895431 308.10457 17 307 17 L 276 17 L 260 1 L 223 1 z " />
<path fill="#c5dbfb" d="M 223 0 C 221.35499 0 220 1.3549904 220 3 L 220 49.005859 A 100 100 0 0 1 222 50.765625 L 222 3 C 222 2.4358706 222.43587 2 223 2 L 259.58594 2 L 275.58594 18 L 307 18 C 307.56413 18 308 18.435871 308 19 L 308 67 C 308 67.564129 307.56413 68 307 68 L 237.16406 68 A 100 100 0 0 1 238.51562 70 L 307 70 C 308.64501 70 310 68.64501 310 67 L 310 19 C 310 17.35499 308.64501 16 307 16 L 276.41406 16 L 260.41406 0 L 223 0 z " />
<path fill="#c5dbfb" d="M 43 93 L 43 107 L 56.634766 107 A 100 100 0 0 1 60.259766 93 L 43 93 z " />
<path fill="white" d="M 3 91 C 1.8954305 91 1 91.895431 1 93 L 1 157 C 1 158.10457 1.8954305 159 3 159 L 60.958984 159 A 100 100 0 0 1 55 125 A 100 100 0 0 1 56.634766 107 L 56 107 L 40 91 L 3 91 z " />
<path fill="#c5dbfb" d="M 3 90 C 1.3549904 90 0 91.35499 0 93 L 0 157 C 0 158.64501 1.3549904 160 3 160 L 61.324219 160 A 100 100 0 0 1 60.603516 158 L 3 158 C 2.4358706 158 2 157.56413 2 157 L 2 93 C 2 92.435871 2.4358706 92 3 92 L 39.585938 92 L 55.585938 108 L 56.455078 108 A 100 100 0 0 1 56.822266 106 L 56.414062 106 L 40.414062 90 L 3 90 z " />
<path class="cls-2" d="M263,107V93h40a2,2,0,0,1,2,2v12Z" />
<path fill="white" d="M 249.04102 91 A 100 100 0 0 1 255 125 A 100 100 0 0 1 249.04102 159 L 307 159 C 308.10457 159 309 158.10457 309 157 L 309 109 C 309 107.89543 308.10457 107 307 107 L 276 107 L 260 91 L 249.04102 91 z " />
<path fill="#c5dbfb" d="M 248.67578 90 A 100 100 0 0 1 249.39648 92 L 259.58594 92 L 275.58594 108 L 307 108 C 307.56413 108 308 108.43587 308 109 L 308 157 C 308 157.56413 307.56413 158 307 158 L 249.39844 158 A 100 100 0 0 1 248.67383 160 L 307 160 C 308.64501 160 310 158.64501 310 157 L 310 109 C 310 107.35499 308.64501 106 307 106 L 276.41406 106 L 260.41406 90 L 248.67578 90 z " />
<path fill="#c5dbfb" d="M 43 183 L 43 197 L 85 197 L 85 196.41406 A 100 100 0 0 1 73.539062 183 L 43 183 z " />
<path fill="white" d="M 3 181 C 1.8954305 181 1 181.89543 1 183 L 1 247 C 1 248.10457 1.8954305 249 3 249 L 87 249 C 88.104569 249 89 248.10457 89 247 L 89 200.125 A 100 100 0 0 1 85.603516 197 L 56 197 L 40 181 L 3 181 z " />
<path fill="#c5dbfb" d="M 3 180 C 1.3549904 180 0 181.35499 0 183 L 0 247 C 0 248.64501 1.3549904 250 3 250 L 87 250 C 88.64501 250 90 248.64501 90 247 L 90 200.99414 A 100 100 0 0 1 88 199.23438 L 88 247 C 88 247.56413 87.564129 248 87 248 L 3 248 C 2.4358706 248 2 247.56413 2 247 L 2 183 C 2 182.43587 2.4358706 182 3 182 L 39.585938 182 L 55.585938 198 L 86.65625 198 A 100 100 0 0 1 84.580078 196 L 56.414062 196 L 40.414062 180 L 3 180 z " />
<path fill="white" d="M 111 214.79883 L 111 247 C 111 248.10457 111.89543 249 113 249 L 197 249 C 198.10457 249 199 248.10457 199 247 L 199 214.79883 A 100 100 0 0 1 155 225 A 100 100 0 0 1 111 214.79883 z " />
<path fill="#c5dbfb" d="M 110 214.30078 L 110 247 C 110 248.64501 111.35499 250 113 250 L 197 250 C 198.64501 250 200 248.64501 200 247 L 200 214.30078 A 100 100 0 0 1 198 215.2832 L 198 247 C 198 247.56413 197.56413 248 197 248 L 113 248 C 112.43587 248 112 247.56413 112 247 L 112 215.2832 A 100 100 0 0 1 110 214.30078 z " />
<path class="cls-2" d="M263,197V183h40a2,2,0,0,1,2,2v12Z" />
<path fill="white" d="M 237.84766 181 A 100 100 0 0 1 221 200.12695 L 221 247 C 221 248.10457 221.89543 249 223 249 L 307 249 C 308.10457 249 309 248.10457 309 247 L 309 199 C 309 197.89543 308.10457 197 307 197 L 276 197 L 260 181 L 237.84766 181 z " />
<path fill="#c5dbfb" d="M 238.51562 180 A 100 100 0 0 1 237.16406 182 L 259.58594 182 L 275.58594 198 L 307 198 C 307.56413 198 308 198.43587 308 199 L 308 247 C 308 247.56413 307.56413 248 307 248 L 223 248 C 222.43587 248 222 247.56413 222 247 L 222 199.23438 A 100 100 0 0 1 220 200.99219 L 220 247 C 220 248.64501 221.35499 250 223 250 L 307 250 C 308.64501 250 310 248.64501 310 247 L 310 199 C 310 197.35499 308.64501 196 307 196 L 276.41406 196 L 260.41406 180 L 238.51562 180 z " />
<path class="cls-6" d="M351.12,322.62h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7A10,10,0,0,1,351.12,322.62Z" transform="translate(850.61 309.91) rotate(135)" />
<rect class="cls-7" x="293.75" y="225.25" width="40" height="117" transform="translate(-108.74 304.96) rotate(-45)" />
<polyline class="cls-7" points="213.69 199.25 252.58 238.14 267.43 223.29 228.54 184.4" />
<path fill="white" stroke="#061884" stroke-width="2px" d="M 154.94141 30 A 95 95 0 0 0 60 125 A 95 95 0 0 0 155 220 A 95 95 0 0 0 250 125 A 95 95 0 0 0 155 30 A 95 95 0 0 0 154.94141 30 z M 154.82812 40 A 85 85 0 0 1 155 40 A 85 85 0 0 1 240 125 A 85 85 0 0 1 155 210 A 85 85 0 0 1 70 125 A 85 85 0 0 1 154.82812 40 z " />
<path class="cls-6" d="M256.37,227.87h20a10,10,0,0,1,10,10v7a0,0,0,0,1,0,0h-40a0,0,0,0,1,0,0v-7a10,10,0,0,1,10-10Z" transform="translate(-89.12 257.58) rotate(-45)" />
<path fill="white" d="M 154.94141 45 A 80 80 0 0 0 111 58.185547 L 111 67 C 111 68.104569 111.89543 69 113 69 L 197 69 C 198.10457 69 199 68.104569 199 67 L 199 58.1875 A 80 80 0 0 0 155 45 A 80 80 0 0 0 154.94141 45 z " />
<path fill="#061884" d="M 112 57.539062 A 80 80 0 0 0 110 58.857422 L 110 67 C 110 68.64501 111.35499 70 113 70 L 197 70 C 198.64501 70 200 68.64501 200 67 L 200 58.857422 A 80 80 0 0 0 198 57.541016 L 198 67 C 198 67.564129 197.56413 68 197 68 L 113 68 C 112.43587 68 112 67.564129 112 67 L 112 57.539062 z " />
<path fill="#196ef0" d="M 81.679688 93 A 80 80 0 0 0 77.050781 107 L 85 107 L 85 95 A 2 2 0 0 0 83 93 L 81.679688 93 z " />
<path fill="white" d="M 77.050781 107 A 80 80 0 0 0 75 125 A 80 80 0 0 0 82.585938 159 L 87 159 C 88.104569 159 89 158.10457 89 157 L 89 109 C 89 107.89543 88.104569 107 87 107 L 77.050781 107 z " />
<path fill="#061884" d="M 77.289062 106 A 80 80 0 0 0 76.828125 108 L 87 108 C 87.564129 108 88 108.43587 88 109 L 88 157 C 88 157.56413 87.564129 158 87 158 L 82.125 158 A 80 80 0 0 0 83.0625 160 L 87 160 C 88.64501 160 90 158.64501 90 157 L 90 109 C 90 107.35499 88.64501 106 87 106 L 77.289062 106 z " />
<path fill="white" d="M 223 91 C 221.89543 91 221 91.895431 221 93 L 221 157 C 221 158.10457 221.89543 159 223 159 L 227.41406 159 A 80 80 0 0 0 235 125 A 80 80 0 0 0 227.41406 91 L 223 91 z " />
<path fill="#061884" d="M 223 90 C 221.35499 90 220 91.35499 220 93 L 220 157 C 220 158.64501 221.35499 160 223 160 L 226.9375 160 A 80 80 0 0 0 227.87695 158 L 223 158 C 222.43587 158 222 157.56413 222 157 L 222 93 C 222 92.435871 222.43587 92 223 92 L 227.875 92 A 80 80 0 0 0 226.9375 90 L 223 90 z " />
<path fill="#196ef0" d="M 153 183 L 153 197 L 189.86914 197 A 80 80 0 0 0 195 194.28125 L 195 185 A 2 2 0 0 0 193 183 L 153 183 z "/>
<path fill="white" d="M 113 181 C 111.89543 181 111 181.89543 111 183 L 111 191.8125 A 80 80 0 0 0 155 205 A 80 80 0 0 0 189.86914 197 L 166 197 L 150 181 L 113 181 z " />
<path fill="#061884" d="M 113 180 C 111.35499 180 110 181.35499 110 183 L 110 191.14258 A 80 80 0 0 0 112 192.45898 L 112 183 C 112 182.43587 112.43587 182 113 182 L 149.58594 182 L 165.58594 198 L 187.72461 198 A 80 80 0 0 0 191.86328 196 L 166.41406 196 L 150.41406 180 L 113 180 z " />
<path fill="#061884" d="m 149.18,133.69 v -3.48 a 14.36,14.36 0 0 1 1.74,-7.25 20.17,20.17 0 0 1 6.4,-6.17 25.87,25.87 0 0 0 5.68,-4.79 7,7 0 0 0 1.48,-4.34 4.13,4.13 0 0 0 -1.93,-3.62 9,9 0 0 0 -5.14,-1.3 24.94,24.94 0 0 0 -7.34,1.16 45.2,45.2 0 0 0 -7.78,3.31 l -5.37,-10.64 a 48.41,48.41 0 0 1 9.89,-4.21 40.25,40.25 0 0 1 11.67,-1.61 q 9.57,0 14.9,4.43 a 14.16,14.16 0 0 1 5.32,11.41 15.41,15.41 0 0 1 -2.55,9 30.38,30.38 0 0 1 -7.92,7.34 32.11,32.11 0 0 0 -5.23,4.37 5.91,5.91 0 0 0 -1.34,4 v 2.41 z m -1.61,15.12 q 0,-4.38 2.46,-6.12 a 10,10 0 0 1 5.95,-1.75 9.69,9.69 0 0 1 5.77,1.75 q 2.46,1.74 2.46,6.12 0,4.22 -2.46,6 a 9.42,9.42 0 0 1 -5.77,1.84 9.69,9.69 0 0 1 -5.95,-1.84 q -2.46,-1.81 -2.46,-6 z" />
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M12,19.6L4.4,12L7,9.4V12h2V6H3v2h2.6L3.7,9.9c-1.2,1.2-1.2,3.1,0,4.2l6.2,6.2c1.2,1.2,3.1,1.2,4.2,0l0.6-0.6l-1.4-1.4
L12,19.6z"/>
<path d="M20.3,9.9l-6.2-6.2c-1.2-1.2-3.1-1.2-4.2,0L9.3,4.3l1.4,1.4L12,4.4l7.6,7.6L17,14.6V12h-2v6h6v-2h-2.6l1.9-1.9
C21.5,12.9,21.5,11.1,20.3,9.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View file

@ -0,0 +1,159 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Popup
{
id: base
padding: UM.Theme.getSize("default_margin").width
closePolicy: Popup.CloseOnEscape
focus: true
modal: true
background: Cura.RoundedRectangle
{
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
width: parent.width
height: parent.height
color: UM.Theme.getColor("main_background")
}
Connections
{
target: manager
function onCreatingNewProjectStatusChanged(status)
{
if (status == DF.RetrievalStatus.Success)
{
base.close();
}
}
}
onOpened:
{
newProjectNameTextField.text = ""
newProjectNameTextField.focus = true
}
Label
{
id: createNewLibraryProjectLabel
text: "Create new Library project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
}
Label
{
id: projectNameLabel
text: "Project Name"
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
anchors
{
top: createNewLibraryProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").width
left: parent.left
right: parent.right
}
}
Cura.TextField
{
id: newProjectNameTextField
width: parent.width
anchors
{
top: projectNameLabel.bottom
topMargin: UM.Theme.getSize("thin_margin").width
left: parent.left
right: parent.right
}
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,99}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("default")
placeholderText: "Enter a name for your new project."
onAccepted:
{
if (verifyProjectCreationButton.enabled)
{
verifyProjectCreationButton.clicked()
}
}
}
Label
{
id: errorWhileCreatingProjectLabel
text: manager.projectCreationErrorText
width: parent.width
wrapMode: Text.WordWrap
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("error")
visible: manager.creatingNewProjectStatus == DF.RetrievalStatus.Failed
anchors
{
top: newProjectNameTextField.bottom
left: parent.left
right: parent.right
}
}
Cura.SecondaryButton
{
id: cancelProjectCreationButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Cancel"
onClicked:
{
base.close()
}
busy: false
}
Cura.PrimaryButton
{
id: verifyProjectCreationButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Create"
enabled: newProjectNameTextField.text.length >= 2 && !busy
onClicked:
{
manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text)
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
}

View file

@ -0,0 +1,61 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactoryOpenDialogBase
title: "Open file from Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactoryOpenDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: false
}
OpenProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onOpenFilePressed: digitalFactoryOpenDialogBase.close()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactorySaveDialogBase
title: "Save Cura project to Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactorySaveDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: true
}
SaveProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onSavePressed: digitalFactorySaveDialogBase.close()
onSelectDifferentProjectPressed: manager.clearProjectSelection()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,129 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
signal clicked()
property var hasMoreProjectsToLoad
enabled: hasMoreProjectsToLoad
color: UM.Theme.getColor("main_background")
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
}
Row
{
id: projectInformationRow
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
UM.RecolorImage
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("section").height
height: width
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
Label
{
id: displayNameLabel
anchors.verticalCenter: parent.verticalCenter
text: "Load more projects"
color: UM.Theme.getColor("text_link")
font: UM.Theme.getFont("medium_bold")
}
}
Component.onCompleted:
{
cardMouseArea.clicked.connect(base.clicked)
}
states:
[
State
{
name: "canLoadMoreProjectsAndHovered";
when: base.hasMoreProjectsToLoad && cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_hovered")
}
},
State
{
name: "canLoadMoreProjectsAndNotHovered";
when: base.hasMoreProjectsToLoad && !cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("main_background")
}
},
State
{
name: "noMoreProjectsToLoad"
when: !base.hasMoreProjectsToLoad
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("action_button_disabled_text")
source: "../images/update.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("action_button_disabled_text")
text: "No more projects to load"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_disabled")
}
}
]
}

View file

@ -0,0 +1,203 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal openFilePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").width
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.SingleSelection
onDoubleClicked:
{
manager.setSelectedFileIndices([row]);
openFilesButton.clicked();
}
OldControls.TableViewColumn
{
id: fileNameColumn
role: "fileName"
title: "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
}
Connections
{
target: filesTableView.selection
function onSelectionChanged()
{
let newSelection = [];
filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); });
manager.setSelectedFileIndices(newSelection);
}
}
}
Label
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged(newProjectIndex)
{
emptyProjectLabel.visible = (newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.clearProjectSelection()
}
busy: false
}
Cura.PrimaryButton
{
id: openFilesButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Open"
enabled: filesTableView.selection.count > 0
onClicked:
{
manager.openSelectedFiles()
}
busy: false
}
Component.onCompleted:
{
openFilesButton.clicked.connect(base.openFilePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,92 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
width: parent.width
height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
signal clicked()
property alias imageSource: projectImage.source
property alias projectNameText: displayNameLabel.text
property alias projectUsernameText: usernameLabel.text
property alias projectLastUpdatedText: lastUpdatedLabel.text
property alias cardMouseAreaEnabled: cardMouseArea.enabled
onVisibleChanged: color = UM.Theme.getColor("main_background")
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: base.color = UM.Theme.getColor("action_button_hovered")
onExited: base.color = UM.Theme.getColor("main_background")
onClicked: base.clicked()
}
Row
{
id: projectInformationRow
width: parent.width
padding: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("default_margin").width
Image
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("toolbox_thumbnail_small").width
height: Math.round(width * 3/4)
sourceSize.width: width
sourceSize.height: height
fillMode: Image.PreserveAspectFit
mipmap: true
}
Column
{
id: projectLabelsColumn
height: projectImage.height
width: parent.width - x - UM.Theme.getSize("default_margin").width
anchors.verticalCenter: parent.verticalCenter
Label
{
id: displayNameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("text")
font: UM.Theme.getFont("default_bold")
}
Label
{
id: usernameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
Label
{
id: lastUpdatedLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
}
}
}

View file

@ -0,0 +1,259 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal savePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Label
{
id: fileNameLabel
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").height
text: "Cura project name"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
}
Cura.TextField
{
id: dfFilenameTextfield
width: parent.width
anchors.left: parent.left
anchors.top: fileNameLabel.bottom
anchors.topMargin: UM.Theme.getSize("thin_margin").height
validator: RegExpValidator
{
regExp: /^[\w\-\. ()]{0,255}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("medium")
placeholderText: "Enter the name of the file."
onAccepted: { if (saveButton.enabled) {saveButton.clicked()}}
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: dfFilenameTextfield.bottom
anchors.topMargin: UM.Theme.getSize("wide_margin").height
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.NoSelection
OldControls.TableViewColumn
{
id: fileNameColumn
role: "fileName"
title: "@tableViewColumn:title", "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
}
}
Label
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged()
{
emptyProjectLabel.visible = (manager.newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.selectedProjectIndex = -1
}
busy: false
}
Cura.PrimaryButton
{
id: saveButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Save"
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text.length >= 1
onClicked:
{
let saveAsFormats = [];
if (asProjectCheckbox.checked)
{
saveAsFormats.push("3mf");
}
if (asSlicedCheckbox.checked)
{
saveAsFormats.push("ufp");
}
manager.saveFileToSelectedProject(dfFilenameTextfield.text, saveAsFormats);
}
busy: false
}
Row
{
id: saveAsFormatRow
anchors.verticalCenter: saveButton.verticalCenter
anchors.right: saveButton.left
anchors.rightMargin: UM.Theme.getSize("thin_margin").height
width: childrenRect.width
spacing: UM.Theme.getSize("default_margin").width
Cura.CheckBox
{
id: asProjectCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
checked: true
text: "Save Cura project"
font: UM.Theme.getFont("medium")
}
Cura.CheckBox
{
id: asSlicedCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
enabled: UM.Backend.state == UM.Backend.Done
checked: UM.Backend.state == UM.Backend.Done
text: "Save print file"
font: UM.Theme.getFont("medium")
}
}
Component.onCompleted:
{
saveButton.clicked.connect(base.savePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,231 @@
// Copyright (C) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import QtQuick.Layouts 1.1
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property bool createNewProjectButtonVisible: true
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
margins: UM.Theme.getSize("default_margin").width
}
RowLayout
{
id: headerRow
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
height: childrenRect.height
spacing: UM.Theme.getSize("default_margin").width
Cura.TextField
{
id: searchBar
Layout.fillWidth: true
implicitHeight: createNewProjectButton.height
onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field.
leftIcon: UM.Theme.getIcon("Magnifier")
placeholderText: "Search"
}
Cura.SecondaryButton
{
id: createNewProjectButton
text: "New Library project"
visible: createNewProjectButtonVisible && manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
onClicked:
{
createNewProjectPopup.open()
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
Cura.SecondaryButton
{
id: upgradePlanButton
text: "Upgrade plan"
iconSource: UM.Theme.getIcon("LinkExternal")
visible: createNewProjectButtonVisible && !manager.userAccountCanCreateNewLibraryProject && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
tooltip: "You have reached the maximum number of projects allowed by your subscription. Please upgrade to the Professional subscription to create more projects."
tooltipWidth: parent.width * 0.5
onClicked: Qt.openUrlExternally("https://ultimaker.com/software/ultimaker-essentials/sign-up-cura?utm_source=cura&utm_medium=software&utm_campaign=lib-max")
}
}
Item
{
id: noLibraryProjectsContainer
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
Column
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").height
Image
{
id: digitalFactoryImage
anchors.horizontalCenter: parent.horizontalCenter
source: searchBar.text === "" ? "../images/digital_factory.svg" : "../images/projects_not_found.svg"
fillMode: Image.PreserveAspectFit
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
}
Label
{
id: noLibraryProjectsLabel
anchors.horizontalCenter: parent.horizontalCenter
text: searchBar.text === "" ? "It appears that you don't have any projects in the Library yet." : "No projects found that match the search query."
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
}
Cura.TertiaryButton
{
id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library.
}
}
}
Item
{
id: projectListContainer
anchors
{
top: headerRow.bottom
topMargin: UM.Theme.getSize("default_margin").height
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count > 0
// Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot
// have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard
// in it.
Flickable
{
id: flickableView
clip: true
contentWidth: parent.width
contentHeight: projectsListView.implicitHeight
anchors.fill: parent
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: flickableView.contentHeight > flickableView.height
background: Rectangle
{
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: UM.Theme.getColor("scrollbar_background")
}
contentItem: Rectangle
{
id: scrollViewHandle
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle")
Behavior on color { ColorAnimation { duration: 50; } }
}
}
Column
{
id: projectsListView
width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width
anchors.top: parent.top
spacing: UM.Theme.getSize("narrow_margin").width
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
onClicked:
{
manager.selectedProjectIndex = index
}
}
}
LoadMoreProjectsCard
{
id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height
width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
onClicked:
{
manager.loadMoreProjects()
}
}
}
}
}
CreateNewProjectPopup
{
id: createNewProjectPopup
width: 400 * screenScaleFactor
height: 220 * screenScaleFactor
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}

View file

@ -0,0 +1,74 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime, timezone
from typing import TypeVar, Dict, List, Any, Type, Union
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
T = TypeVar("T", bound="BaseModel")
class BaseModel:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
self.validate()
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
pass
def __eq__(self, other):
"""Checks whether the two models are equal.
:param other: The other model.
:return: True if they are equal, False if they are different.
"""
return type(self) == type(other) and self.toDict() == other.toDict()
def __ne__(self, other) -> bool:
"""Checks whether the two models are different.
:param other: The other model.
:return: True if they are different, False if they are the same.
"""
return type(self) != type(other) or self.toDict() != other.toDict()
def toDict(self) -> Dict[str, Any]:
"""Converts the model into a serializable dictionary"""
return self.__dict__
@staticmethod
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
"""Parses a single model.
:param model_class: The model class.
:param values: The value of the model, which is usually a dictionary, but may also be already parsed.
:return: An instance of the model_class given.
"""
if isinstance(values, dict):
return model_class(**values)
return values
@classmethod
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
"""Parses a list of models.
:param model_class: The model class.
:param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
:return: A list of instances of the model_class given.
"""
return [cls.parseModel(model_class, value) for value in values]
@staticmethod
def parseDate(date: Union[str, datetime]) -> datetime:
"""Parses the given date string.
:param date: The date to parse.
:return: The parsed date.
"""
if isinstance(date, datetime):
return date
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)

View file

@ -0,0 +1,31 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Any
from .BaseModel import BaseModel
class CloudError(BaseModel):
"""Class representing errors generated by the servers, according to the JSON-API standard."""
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
"""Creates a new error object.
:param id: Unique identifier for this particular occurrence of the problem.
:param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
of the problem, except for purposes of localization.
:param code: An application-specific error code, expressed as a string value.
:param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
value can be localized.
:param http_status: The HTTP status code applicable to this problem, converted to string.
:param meta: Non-standard meta-information about the error, depending on the error code.
"""
self.id = id
self.code = code
self.http_status = http_status
self.title = title
self.detail = detail
self.meta = meta
super().__init__(**kwargs)

View file

@ -0,0 +1,373 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import threading
from json import JSONDecodeError
from typing import List, Dict, Any, Callable, Union, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .ExportFileJob import ExportFileJob
class DFFileExportAndUploadManager:
"""
Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf
and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for
both files and updates those messages according to the progress of both the file job uploads.
"""
def __init__(self, file_handlers: Dict[str, FileHandler],
nodes: List[SceneNode],
library_project_id: str,
library_project_name: str,
file_name: str,
formats: List[str],
on_upload_error: Callable[[], Any],
on_upload_success: Callable[[], Any],
on_upload_finished: Callable[[], Any] ,
on_upload_progress: Callable[[int], Any]) -> None:
self._file_handlers = file_handlers # type: Dict[str, FileHandler]
self._nodes = nodes # type: List[SceneNode]
self._library_project_id = library_project_id # type: str
self._library_project_name = library_project_name # type: str
self._file_name = file_name # type: str
self._upload_jobs = [] # type: List[ExportFileJob]
self._formats = formats # type: List[str]
self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
# Functions of the parent class that should be called based on the upload process output
self._on_upload_error = on_upload_error
self._on_upload_success = on_upload_success
self._on_upload_finished = on_upload_finished
self._on_upload_progress = on_upload_progress
# Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or
# show the success message (once both upload jobs are done)
self._message_lock = threading.Lock()
self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]]
self.progress_message = Message(
title = "Uploading...",
text = "Uploading files to '{}'".format(self._library_project_name),
progress = -1,
lifetime = 0,
dismissable = False,
use_inactivity_timer = False
)
self._generic_success_message = Message(
text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name),
title = "Upload successful",
lifetime = 0,
)
self._generic_success_message.addAction(
"open_df_project",
"Open project",
"open-folder", "Open the project containing the file in Digital Library"
)
self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered)
def _onCuraProjectFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library workspace file (3MF) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFLibraryFileUploadRequest(
content_type = job.getMimeType(),
file_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed)
def _onPrintFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library print job file (UFP) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFPrintJobUploadRequest(
content_type = job.getMimeType(),
job_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed)
def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None:
"""Uploads the exported file data after the file or print job upload has been registered at the Digital Factory
Library API.
:param file_upload_response: The response received from the Digital Factory Library API.
"""
if isinstance(file_upload_response, DFLibraryFileUploadResponse):
file_name = file_upload_response.file_name
elif isinstance(file_upload_response, DFPrintJobUploadResponse):
file_name = file_upload_response.job_name if file_upload_response.job_name is not None else ""
else:
Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library")
return
with self._message_lock:
self.progress_message.show()
self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response
job_output = self._file_upload_job_metadata[file_name]["export_job_output"]
with self._message_lock:
self._file_upload_job_metadata[file_name]["upload_status"] = "uploading"
self._api.uploadExportedFileData(file_upload_response,
job_output,
on_finished = self._onFileUploadFinished,
on_success = self._onUploadSuccess,
on_progress = self._onUploadProgress,
on_error = self._onUploadError)
def _onUploadProgress(self, filename: str, progress: int) -> None:
"""
Updates the progress message according to the total progress of the two files and displays it to the user. It is
made thread-safe with a lock, since the progress can be updated by two separate upload jobs
:param filename: The name of the file for which we have progress (including the extension).
:param progress: The progress percentage
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_progress"] = progress
self._file_upload_job_metadata[filename]["upload_status"] = "uploading"
total_progress = self.getTotalProgress()
self.progress_message.setProgress(total_progress)
self.progress_message.show()
self._on_upload_progress(progress)
def _onUploadSuccess(self, filename: str) -> None:
"""
Sets the upload status to success and the progress of the file with the given filename to 100%. This function is
should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the
upload process).
:param filename: The name of the file that was uploaded successfully (including the extension).
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_status"] = "success"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._on_upload_success()
def _onFileUploadFinished(self, filename: str) -> None:
"""
Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs.
This function is called whenever an upload job has finished, regardless if it had errors or was successful.
Both jobs have to have finished for the messages to show.
:param filename: The name of the file that has finished uploading (including the extension).
"""
with self._message_lock:
# All files have finished their uploading process
if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]):
# Reset and hide the progress message
self.progress_message.setProgress(-1)
self.progress_message.hide()
# All files were successfully uploaded.
if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]):
# Show a single generic success message for all files
self._generic_success_message.show()
else: # One or more files failed to upload.
# Show individual messages for each file, according to their statuses
for filename, upload_job_metadata in self._file_upload_job_metadata.items():
if upload_job_metadata["upload_status"] == "success":
upload_job_metadata["file_upload_success_message"].show()
else:
upload_job_metadata["file_upload_failed_message"].show()
# Call the parent's finished function
self._on_upload_finished()
def _onJobExportError(self, filename: str) -> None:
"""
Displays an appropriate message when the process to export a file fails.
:param filename: The name of the file that failed to be exported (including the extension).
"""
Logger.log("d", "Error while exporting file '{}'".format(filename))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename)
def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_3mf = self._file_name + ".3mf"
Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed"
self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message(
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_3mf)
def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_ufp = self._file_name + ".ufp"
Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed"
self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_ufp)
@staticmethod
def extractErrorTitle(reply_body: Optional[str]) -> str:
error_title = ""
if reply_body:
try:
reply_dict = json.loads(reply_body)
except JSONDecodeError:
Logger.logException("w", "Unable to extract title from reply body")
return error_title
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
error_title = reply_dict["errors"][0]["title"]
return error_title
def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
"""
Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection).
If one of the two files fail, this error function will set its progress as finished, to make sure that the
progress message doesn't get stuck.
:param filename: The name of the file that failed to upload (including the extension).
"""
reply_string = bytes(reply.readAll()).decode()
Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
def getTotalProgress(self) -> int:
"""
Returns the total upload progress of all the upload jobs
:return: The average progress percentage
"""
return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values()))
def _onMessageActionTriggered(self, message, action):
if action == "open_df_project":
project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
QDesktopServices.openUrl(QUrl(project_url))
message.hide()
def start(self) -> None:
for job in self._upload_jobs:
job.start()
def initializeFileUploadJobMetadata(self) -> Dict[str, Any]:
metadata = {}
self._upload_jobs = []
if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]:
filename_3mf = self._file_name + ".3mf"
metadata[filename_3mf] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
job_3mf.finished.connect(self._onCuraProjectFileExported)
self._upload_jobs.append(job_3mf)
if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]:
filename_ufp = self._file_name + ".ufp"
metadata[filename_ufp] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")
job_ufp.finished.connect(self._onPrintFileExported)
self._upload_jobs.append(job_ufp)
return metadata

View file

@ -0,0 +1,149 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from typing import Callable, Any, cast, Optional, Union
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
class DFFileUploader:
"""Class responsible for uploading meshes to the the digital factory library in separate requests."""
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
# The HTTP codes that should trigger a retry.
RETRY_HTTP_CODES = {500, 502, 503, 504}
def __init__(self,
http: HttpRequestManager,
df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
data: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]
) -> None:
"""Creates a mesh upload object.
:param http: The network access manager that will handle the HTTP requests.
:param df_file: The file response that was received by the Digital Factory after registering the upload.
:param data: The mesh bytes to be uploaded.
:param on_finished: The method to be called when done.
:param on_success: The method to be called when the upload is successful.
:param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
:param on_error: The method to be called when an error occurs.
"""
self._http = http # type: HttpRequestManager
self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]
self._file_name = ""
if isinstance(self._df_file, DFLibraryFileUploadResponse):
self._file_name = self._df_file.file_name
elif isinstance(self._df_file, DFPrintJobUploadResponse):
if self._df_file.job_name is not None:
self._file_name = self._df_file.job_name
else:
self._file_name = ""
else:
raise TypeError("Incorrect input type")
self._data = data # type: bytes
self._on_finished = on_finished
self._on_success = on_success
self._on_progress = on_progress
self._on_error = on_error
self._retries = 0
self._finished = False
def start(self) -> None:
"""Starts uploading the mesh."""
if self._finished:
# reset state.
self._retries = 0
self._finished = False
self._upload()
def stop(self):
"""Stops uploading the mesh, marking it as finished."""
Logger.log("i", "Finished uploading")
self._finished = True # Signal to any ongoing retries that we should stop retrying.
self._on_finished(self._file_name)
def _upload(self) -> None:
"""
Uploads the file to the Digital Factory Library project
"""
if self._finished:
raise ValueError("The upload is already finished")
if isinstance(self._df_file, DFLibraryFileUploadResponse):
Logger.log("i", "Uploading Cura project file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.file_name, upload_url = self._df_file.upload_url))
elif isinstance(self._df_file, DFPrintJobUploadResponse):
Logger.log("i", "Uploading Cura print file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.job_name, upload_url = self._df_file.upload_url))
self._http.put(
url = cast(str, self._df_file.upload_url),
headers_dict = {"Content-Type": cast(str, self._df_file.content_type)},
data = self._data,
callback = self._onUploadFinished,
error_callback = self._onUploadError,
upload_progress_callback = self._onUploadProgressChanged
)
def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None:
"""Handles an update to the upload progress
:param bytes_sent: The amount of bytes sent in the current request.
:param bytes_total: The amount of bytes to send in the current request.
"""
Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total)
if bytes_total:
self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100))
def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""Handles an error uploading."""
body = bytes(reply.peek(reply.bytesAvailable())).decode()
Logger.log("e", "Received error while uploading: %s", body)
self._on_error(self._file_name, reply, error)
self.stop()
def _onUploadFinished(self, reply: QNetworkReply) -> None:
"""
Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
"""
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int]
if not status_code:
Logger.log("e", "Reply contained no status code.")
self._onUploadError(reply, None)
return
# check if we should retry the last chunk
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
self._retries += 1
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
try:
self._upload()
except ValueError: # Asynchronously it could have completed in the meanwhile.
pass
return
# Http codes that are not to be retried are assumed to be errors.
if status_code > 308:
self._onUploadError(reply, None)
return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
self._on_success(self._file_name)
self.stop()

View file

@ -0,0 +1,16 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# Model that represents the request to upload a file to a DF Library project
from .BaseModel import BaseModel
class DFLibraryFileUploadRequest(BaseModel):
def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None:
self.content_type = content_type
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,49 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional
from .BaseModel import BaseModel
class DFLibraryFileUploadResponse(BaseModel):
"""
Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project
"""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None,
file_size: Optional[int] = None, status_description: Optional[str] = None,
upload_url: Optional[str] = None, **kwargs) -> None:
"""
:param client_id: The ID of the OAuth2 client that uploaded this file
:param content_type: The content type of the Digital Library project file
:param file_id: The ID of the library project file
:param file_name: The name of the file
:param library_project_id: The ID of the library project, in which the file will be uploaded
:param status: The status of the Digital Library project file
:param uploaded_at: The time on which the file was uploaded
:param user_id: The ID of the user that uploaded this file
:param username: The user's unique username
:param download_url: A signed URL to download the resulting file. Only available when the job is finished
:param file_size: The size of the uploaded file (in bytes)
:param status_description: Contains more details about the status, e.g. the cause of failures
:param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading)
:param kwargs: Other keyword arguments that may be included in the response
"""
self.client_id = client_id # type: str
self.content_type = content_type # type: str
self.file_id = file_id # type: str
self.file_name = file_name # type: str
self.library_project_id = library_project_id # type: str
self.status = status # type: str
self.uploaded_at = self.parseDate(uploaded_at) # type: datetime
self.user_id = user_id # type: str
self.username = username # type: str
self.download_url = download_url # type: Optional[str]
self.file_size = file_size # type: Optional[int]
self.status_description = status_description # type: Optional[str]
self.upload_url = upload_url # type: Optional[str]
super().__init__(**kwargs)

View file

@ -0,0 +1,21 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .BaseModel import BaseModel
# Model that represents the request to upload a print job to the cloud
class DFPrintJobUploadRequest(BaseModel):
def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None:
"""Creates a new print job upload request.
:param job_name: The name of the print job.
:param file_size: The size of the file in bytes.
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
"""
self.job_name = job_name
self.file_size = file_size
self.content_type = content_type
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,35 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from .BaseModel import BaseModel
# Model that represents the response received from the cloud after requesting to upload a print job
class DFPrintJobUploadResponse(BaseModel):
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
upload_url: Optional[str] = None, content_type: Optional[str] = None,
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
"""Creates a new print job response model.
:param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
:param status: The status of the print job.
:param status_description: Contains more details about the status, e.g. the cause of failures.
:param download_url: A signed URL to download the resulting status. Only available when the job is finished.
:param job_name: The name of the print job.
:param slicing_details: Model for slice information.
:param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
:param generated_time: The datetime when the object was generated on the server-side.
"""
self.job_id = job_id
self.status = status
self.download_url = download_url
self.job_name = job_name
self.upload_url = upload_url
self.content_type = content_type
self.status_description = status_description
self.slicing_details = slicing_details
super().__init__(**kwargs)

View file

@ -0,0 +1,381 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from json import JSONDecodeError
import re
from time import time
from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud import UltimakerCloudConstants
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .BaseModel import BaseModel
from .CloudError import CloudError
from .DFFileUploader import DFFileUploader
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DigitalFactoryFeatureBudgetResponse import DigitalFactoryFeatureBudgetResponse
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
from .PaginationLinks import PaginationLinks
from .PaginationManager import PaginationManager
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
class DigitalFactoryApiClient:
# The URL to access the digital factory.
ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None:
"""Initializes a new digital factory API client.
:param application:
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._application = application
self._account = application.getCuraAPI().account
self._scope = JsonDecoratorScope(UltimakerCloudScope(application))
self._http = HttpRequestManager.getInstance()
self._on_error = on_error
self._file_uploader = None # type: Optional[DFFileUploader]
self._library_max_private_projects: Optional[int] = None
self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager]
def checkUserHasAccess(self, callback: Callable) -> None:
"""Checks if the user has any sort of access to the digital library.
A user is considered to have access if the max-# of private projects is greater then 0 (or -1 for unlimited).
"""
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
if (response is not None and isinstance(response, DigitalFactoryFeatureBudgetResponse) and
response.library_max_private_projects is not None):
callback(
response.library_max_private_projects == -1 or # Note: -1 is unlimited
response.library_max_private_projects > 0)
self._library_max_private_projects = response.library_max_private_projects
else:
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
callback(False)
self._http.get(f"{self.CURA_API_ROOT}/feature_budgets",
scope = self._scope,
callback = self._parseCallback(callbackWrap, DigitalFactoryFeatureBudgetResponse, callbackWrap),
error_callback = callbackWrap,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def checkUserCanCreateNewLibraryProject(self, callback: Callable) -> None:
"""
Checks if the user is allowed to create new library projects.
A user is allowed to create new library projects if the haven't reached their maximum allowed private projects.
"""
def callbackWrap(response: Optional[Any] = None, *args, **kwargs) -> None:
if response is not None:
if isinstance(response, DigitalFactoryProjectResponse): # The user has only one private project
callback(True)
elif isinstance(response, list) and all(isinstance(r, DigitalFactoryProjectResponse) for r in response):
callback(len(response) < cast(int, self._library_max_private_projects))
else:
Logger.warning(f"Digital Factory: Incorrect response type received when requesting private projects: {str(response)}")
callback(False)
else:
Logger.warning(f"Digital Factory: Response is empty, likely an error: {str(response)}")
callback(False)
if self._library_max_private_projects is not None and self._library_max_private_projects > 0:
# The user has a limit in the number of private projects they can create. Check whether they have already
# reached that limit.
# Note: Set the pagination manager to None when doing this get request, or else the next/previous links
# of the pagination will become corrupted
url = f"{self.CURA_API_ROOT}/projects?shared=false&limit={self._library_max_private_projects}"
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(callbackWrap, DigitalFactoryProjectResponse, callbackWrap, pagination_manager = None),
error_callback = callbackWrap,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
else:
# If the limit is -1, then the user is allowed unlimited projects. If its 0 then they are not allowed to
# create any projects
callback(self._library_max_private_projects == -1)
def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None:
"""
Retrieves a digital factory project by its library project id.
:param library_project_id: The id of the library project
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getProjectsFirstPage(self, search_filter: str, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None:
"""
Retrieves digital factory projects for the user that is currently logged in.
If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list,
according to the limit set in the pagination manager. If there is no projects pagination manager, this function
leaves the project limit to the default set on the server side (999999).
:param search_filter: Text to filter the search results. If given an empty string, results are not filtered.
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = f"{self.CURA_API_ROOT}/projects"
query_character = "?"
if self._projects_pagination_mgr:
self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata
url += f"{query_character}limit={self._projects_pagination_mgr.limit}"
query_character = "&"
if search_filter != "":
url += f"{query_character}search={search_filter}"
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getMoreProjects(self,
on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any],
failed: Callable) -> None:
"""Retrieves the next page of the paginated projects list from the API, provided that there is any.
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
if self.hasMoreProjectsToLoad():
url = cast(PaginationLinks, cast(PaginationManager, self._projects_pagination_mgr).links).next_page
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
else:
Logger.log("d", "There are no more projects to load.")
def hasMoreProjectsToLoad(self) -> bool:
"""
Determines whether the client can get more pages of projects list from the API.
:return: Whether there are more pages in the projects list available to be retrieved from the API.
"""
return self._projects_pagination_mgr is not None and self._projects_pagination_mgr.links is not None and self._projects_pagination_mgr.links.next_page is not None
def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None:
"""Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library.
:param library_project_id: The id of the digital factory library project in which the files are included
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def _parseCallback(self,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None,
pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]:
"""
Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply. If a paginated request was made and a pagination
manager is given, the pagination metadata will be held there.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
a list or a single item.
:param model: The type of the model to convert the response to.
:param on_error: The callback in case the response is ... less successful.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
def parse(reply: QNetworkReply) -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
if on_error is not None:
on_error()
return
status_code, response = self._parseReply(reply)
if status_code >= 300 and on_error is not None:
on_error()
else:
self._parseModels(response, on_finished, model, pagination_manager = pagination_manager)
self._anti_gc_callbacks.append(parse)
return parse
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
:param reply: The reply from the server.
:return: A tuple with a status code and a dictionary.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
return status_code, json.loads(response)
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code),
id = str(time()), http_status = "500")
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
def _parseModels(self,
response: Dict[str, Any],
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel],
pagination_manager: Optional[PaginationManager] = None) -> None:
"""Parses the given models and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
if "data" in response:
data = response["data"]
if "meta" in response and pagination_manager:
pagination_manager.setResponseMeta(response["meta"])
if "links" in response and pagination_manager:
pagination_manager.setLinks(response["links"])
if isinstance(data, list):
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
on_finished_list(results)
else:
result = model_class(**data) # type: CloudApiClientModel
on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished)
on_finished_item(result)
elif "errors" in response:
self._on_error([CloudError(**error) for error in response["errors"]])
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
def requestUpload3MF(self, request: DFLibraryFileUploadRequest,
on_finished: Callable[[DFLibraryFileUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/files/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def requestUploadUFP(self, request: DFPrintJobUploadRequest,
on_finished: Callable[[DFPrintJobUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFPrintJobUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def uploadExportedFileData(self,
df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
mesh: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None:
"""Uploads an exported file (in bytes) to the Digital Factory Library.
:param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`.
:param mesh: The mesh data (in bytes) to be uploaded.
:param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error.
It receives the name of the file that has finished uploading.
:param on_success: The function to be called if the upload was successful.
It receives the name of the file that was uploaded successfully.
:param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
It receives the name of the file for which the upload progress should be updated.
:param on_error: A function to be called if the upload fails.
It receives the name of the file that produced errors during the upload process.
"""
self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error)
self._file_uploader.start()
def createNewProject(self, project_name: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], on_error: Callable) -> None:
""" Create a new project in the Digital Factory.
:param project_name: Name of the new to be created project.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The function to be called if anything goes wrong.
"""
Logger.log("i", "Attempt to create new DF project '{}'.".format(project_name))
url = "{}/projects".format(self.CURA_API_ROOT)
data = json.dumps({"data": {"display_name": project_name}}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def clear(self) -> None:
if self._projects_pagination_mgr is not None:
self._projects_pagination_mgr.reset()

View file

@ -0,0 +1,620 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import math
import os
import tempfile
import threading
from enum import IntEnum
from pathlib import Path
from typing import Optional, List, Dict, Any, cast
from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QTimer, QUrl
from PyQt5.QtNetwork import QNetworkReply
from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .DigitalFactoryFileModel import DigitalFactoryFileModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectModel import DigitalFactoryProjectModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
class RetrievalStatus(IntEnum):
"""
The status of an http get request.
This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums.
"""
Idle = 0
InProgress = 1
Success = 2
Failed = 3
class DFRetrievalStatus(QObject):
"""
Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can
be used within QML objects as DigitalFactory.RetrievalStatus.<status>
"""
Q_ENUMS(RetrievalStatus)
class DigitalFactoryController(QObject):
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"])
"""Signal emitted whenever the selected project is changed in the projects dropdown menu"""
selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"])
"""Signal emitted whenever the selected file is changed in the files table"""
retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving projects' http get request is changed"""
retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving files in project' http get request is changed"""
creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'create new library project' http get request is changed"""
hasMoreProjectsToLoadChanged = pyqtSignal()
"""Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if
the paginated list of projects has more pages to show"""
preselectedProjectChanged = pyqtSignal()
"""Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is
the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be
retrieved again."""
projectCreationErrorTextChanged = pyqtSignal()
"""Signal emitted whenever the creation of a new project fails and a specific error message is returned from the
server."""
"""Signals to inform about the process of the file upload"""
uploadStarted = Signal()
uploadFileProgress = Signal()
uploadFileSuccess = Signal()
uploadFileError = Signal()
uploadFileFinished = Signal()
"""Signal to inform about the state of user access."""
userAccessStateChanged = pyqtSignal(bool)
"""Signal to inform whether the user is allowed to create more Library projects."""
userCanCreateNewLibraryProjectChanged = pyqtSignal(bool)
def __init__(self, application: CuraApplication) -> None:
super().__init__(parent = None)
self._application = application
self._dialog = None # type: Optional["QObject"]
self.file_handlers = {} # type: Dict[str, FileHandler]
self.nodes = None # type: Optional[List[SceneNode]]
self.file_upload_manager = None # type: Optional[DFFileExportAndUploadManager]
self._has_preselected_project = False # type: bool
self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20)
# Indicates whether there are more pages of projects that can be loaded from the API
self._has_more_projects_to_load = False
self._account = self._application.getInstance().getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
# Initialize the project model
self._project_model = DigitalFactoryProjectModel()
self._selected_project_idx = -1
self._project_creation_error_text = "Something went wrong while creating a new project. Please try again."
self._project_filter = ""
self._project_filter_change_timer = QTimer()
self._project_filter_change_timer.setInterval(200)
self._project_filter_change_timer.setSingleShot(True)
self._project_filter_change_timer.timeout.connect(self._applyProjectFilter)
# Initialize the file model
self._file_model = DigitalFactoryFileModel()
self._selected_file_indices = [] # type: List[int]
# Filled after the application has been initialized
self._supported_file_types = {} # type: Dict[str, str]
# For cleaning up the files afterwards:
self._erase_temp_files_lock = threading.Lock()
# The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API
self.retrieving_files_status = RetrievalStatus.Idle
self.retrieving_projects_status = RetrievalStatus.Idle
self.creating_new_project_status = RetrievalStatus.Idle
self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._application.initializationFinished.connect(self._applicationInitializationFinished)
self._user_has_access = False
self._user_account_can_create_new_project = False
def clear(self) -> None:
self._project_model.clearProjects()
self._api.clear()
self._has_preselected_project = False
self.preselectedProjectChanged.emit()
self.setRetrievingFilesStatus(RetrievalStatus.Idle)
self.setRetrievingProjectsStatus(RetrievalStatus.Idle)
self.setCreatingNewProjectStatus(RetrievalStatus.Idle)
self.setSelectedProjectIndex(-1)
def _onLoginStateChanged(self, logged_in: bool) -> None:
def callback(has_access, **kwargs):
self._user_has_access = has_access
self.userAccessStateChanged.emit(logged_in)
self._api.checkUserHasAccess(callback)
def userAccountHasLibraryAccess(self) -> bool:
"""
Checks whether the currently logged in user account has access to the Digital Library
:return: True if the user account has Digital Library access, else False
"""
if self._user_has_access:
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
return self._user_has_access
def initialize(self, preselected_project_id: Optional[str] = None) -> None:
self.clear()
if self._account.isLoggedIn and self.userAccountHasLibraryAccess():
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
if preselected_project_id:
self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed)
else:
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None:
"""
Sets the received df_project as the preselected one. When a project is preselected, it should be the only
project inside the model, so this function first makes sure to clear the projects model.
:param df_project: The library project intended to be set as preselected
"""
self._project_model.clearProjects()
self._project_model.setProjects([df_project])
self.setSelectedProjectIndex(0)
self.setHasPreselectedProject(True)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
self.setCreatingNewProjectStatus(RetrievalStatus.Success)
def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self.setHasPreselectedProject(False)
Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string))
def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the first page of projects received from the digital factory library in the project model. Called whenever
the retrieval of the first page of projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.setProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
@pyqtSlot()
def loadMoreProjects(self) -> None:
"""
Initiates the process of retrieving the next page of the projects list from the API.
"""
self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the projects received from the digital factory library in the project model. Called whenever the retrieval
of the projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.extendProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
Error function, called whenever the retrieval of projects fails.
"""
self.setRetrievingProjectsStatus(RetrievalStatus.Failed)
Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error))
def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
"""
Set the files received from the digital factory library in the file model. The files are filtered to only
contain the files which can be opened by Cura.
Called whenever the retrieval of the files is successful.
:param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project
"""
# Filter to show only the files that can be opened in Cura
self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start
self._file_model.setFiles(df_files_in_project)
self.setRetrievingFilesStatus(RetrievalStatus.Success)
def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
Error function, called whenever the retrieval of the files in a library project fails.
"""
Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx]))
self.setRetrievingFilesStatus(RetrievalStatus.Failed)
@pyqtSlot()
def clearProjectSelection(self) -> None:
"""
Clear the selected project.
"""
if self._has_preselected_project:
self.setHasPreselectedProject(False)
else:
self.setSelectedProjectIndex(-1)
@pyqtSlot(int)
def setSelectedProjectIndex(self, project_idx: int) -> None:
"""
Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of
that project to retrieve the list of files included in that project and display it in the interface.
:param project_idx: The index of the currently selected project
"""
if project_idx < -1 or project_idx >= len(self._project_model.items):
Logger.log("w", "The selected project index is invalid.")
project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected"
self._selected_project_idx = project_idx
self.selectedProjectIndexChanged.emit(project_idx)
# Clear the files from the previously-selected project and refresh the files model with the newly-selected-
# project's files
self._file_model.clearFiles()
self.selectedFileIndicesChanged.emit([])
if 0 <= project_idx < len(self._project_model.items):
library_project_id = self._project_model.items[project_idx]["libraryProjectId"]
self.setRetrievingFilesStatus(RetrievalStatus.InProgress)
self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed)
@pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged)
def selectedProjectIndex(self) -> int:
return self._selected_project_idx
@pyqtSlot("QList<int>")
def setSelectedFileIndices(self, file_indices: List[int]) -> None:
"""
Sets the index of the file which is currently selected in the list of files.
:param file_indices: The index of the currently selected file
"""
if file_indices != self._selected_file_indices:
self._selected_file_indices = file_indices
self.selectedFileIndicesChanged.emit(file_indices)
def setProjectFilter(self, new_filter: str) -> None:
"""
Called when the user wants to change the search filter for projects.
The filter is not immediately applied. There is some delay to allow the user to finish typing.
:param new_filter: The new filter that the user wants to apply.
"""
self._project_filter = new_filter
self._project_filter_change_timer.start()
"""
Signal to notify Qt that the applied filter has changed.
"""
projectFilterChanged = pyqtSignal()
@pyqtProperty(str, notify = projectFilterChanged, fset = setProjectFilter)
def projectFilter(self) -> str:
"""
The current search filter being applied to the project list.
:return: The current search filter being applied to the project list.
"""
return self._project_filter
def _applyProjectFilter(self) -> None:
"""
Actually apply the current filter to search for projects with the user-defined search string.
:return:
"""
self.clear()
self.projectFilterChanged.emit()
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
@pyqtProperty(QObject, constant = True)
def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel":
return self._project_model
@pyqtProperty(QObject, constant = True)
def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel":
return self._file_model
def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None:
"""
Set the value that indicates whether there are more pages of projects that can be loaded from the API
:param has_more_projects_to_load: Whether there are more pages of projects
"""
if has_more_projects_to_load != self._has_more_projects_to_load:
self._has_more_projects_to_load = has_more_projects_to_load
self.hasMoreProjectsToLoadChanged.emit()
@pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged)
def hasMoreProjectsToLoad(self) -> bool:
"""
:return: whether there are more pages for projects that can be loaded from the API
"""
return self._has_more_projects_to_load
@pyqtSlot(str)
def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None:
"""
Creates a new project with the given name in the Digital Library.
:param project_name: The name that will be used for the new project
"""
if project_name:
self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed)
self.setCreatingNewProjectStatus(RetrievalStatus.InProgress)
else:
Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.")
def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self._project_creation_error_text = "Something went wrong while creating the new project. Please try again."
if reply_string:
reply_dict = json.loads(reply_string)
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"])
self.projectCreationErrorTextChanged.emit()
self.setCreatingNewProjectStatus(RetrievalStatus.Failed)
Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string))
def setRetrievingProjectsStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving library projects" http call.
:param new_status: The new status
"""
self.retrieving_projects_status = new_status
self.retrievingProjectsStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged)
def retrievingProjectsStatus(self) -> int:
return int(self.retrieving_projects_status)
def setRetrievingFilesStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving files list in the selected library project" http call.
:param new_status: The new status
"""
self.retrieving_files_status = new_status
self.retrievingFilesStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged)
def retrievingFilesStatus(self) -> int:
return int(self.retrieving_files_status)
def setCreatingNewProjectStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "creating new library project" http call.
:param new_status: The new status
"""
self.creating_new_project_status = new_status
self.creatingNewProjectStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged)
def creatingNewProjectStatus(self) -> int:
return int(self.creating_new_project_status)
@staticmethod
def _onEngineCreated() -> None:
qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type")
def _applicationInitializationFinished(self) -> None:
self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead()
# Although Cura supports these, it's super confusing in this context to show them.
for extension in ["jpg", "jpeg", "png", "bmp", "gif"]:
if extension in self._supported_file_types:
del self._supported_file_types[extension]
@pyqtSlot()
def openSelectedFiles(self) -> None:
""" Downloads, then opens all files selected in the Qt frontend open dialog.
"""
temp_dir = tempfile.mkdtemp()
if temp_dir is None or temp_dir == "":
Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.")
return
if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1:
Logger.error("Digital Library: No project or no file selected on open action.")
return
to_erase_on_done_set = {
os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/')
for i in self._selected_file_indices}
def onLoadedCallback(filename_done: str) -> None:
filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/')
with self._erase_temp_files_lock:
if filename_done in to_erase_on_done_set:
try:
os.remove(filename_done)
to_erase_on_done_set.remove(filename_done)
if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir):
os.rmdir(temp_dir)
except (IOError, OSError) as ex:
Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex))
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id)
# Disconnect the signals so that they are not fired every time another (project) file is loaded
app.fileLoaded.disconnect(onLoadedCallback)
app.workspaceLoaded.disconnect(onLoadedCallback)
app = CuraApplication.getInstance()
app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded
app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded
project_name = self._project_model.getItem(self._selected_project_idx)["displayName"]
for file_index in self._selected_file_indices:
file_item = self._file_model.getItem(file_index)
file_name = file_item["fileName"]
download_url = file_item["downloadUrl"]
library_project_id = file_item["libraryProjectId"]
self._openSelectedFile(temp_dir, project_name, file_name, download_url)
def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None:
""" Downloads, then opens, the single specified file.
:param temp_dir: The already created temporary directory where the files will be stored.
:param project_name: Name of the project the file belongs to (used for error reporting).
:param file_name: Name of the file to be downloaded and opened (used for error reporting).
:param download_url: This url will be downloaded, then the downloaded file will be opened in Cura.
"""
if not download_url:
Logger.log("e", "No download url for file '{}'".format(file_name))
return
progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0,
progress = 0, title = "Downloading...")
progress_message.setProgress(0)
progress_message.show()
def progressCallback(rx: int, rt: int) -> None:
progress_message.setProgress(math.floor(rx * 100.0 / rt))
def finishedCallback(reply: QNetworkReply) -> None:
progress_message.hide()
try:
with open(os.path.join(temp_dir, file_name), "wb+") as temp_file:
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
temp_file.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
CuraApplication.getInstance().processEvents()
temp_file_name = temp_file.name
except IOError as ex:
Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
ex, project_name, file_name, temp_dir)
Message(
text = "Failed to write to temporary file for '{}'.".format(file_name),
title = "File-system error",
lifetime = 10
).show()
return
CuraApplication.getInstance().readLocalFile(
QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False)
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name,
f = file_name) -> None:
progress_message.hide()
Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
Message(
text = "Failed Digital Library download for '{}'.".format(f),
title = "Network error {}".format(error),
lifetime = 10
).show()
download_manager = HttpRequestManager.getInstance()
download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback,
error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance()))
def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None:
if not new_has_preselected_project:
# The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to
# false, we also need to clean it from the projects model
self._project_model.clearProjects()
self.setSelectedProjectIndex(-1)
self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
self._has_preselected_project = new_has_preselected_project
self.preselectedProjectChanged.emit()
@pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged)
def hasPreselectedProject(self) -> bool:
return self._has_preselected_project
def setCanCreateNewLibraryProject(self, can_create_new_library_project: bool) -> None:
self._user_account_can_create_new_project = can_create_new_library_project
self.userCanCreateNewLibraryProjectChanged.emit(self._user_account_can_create_new_project)
@pyqtProperty(bool, fset = setCanCreateNewLibraryProject, notify = userCanCreateNewLibraryProjectChanged)
def userAccountCanCreateNewLibraryProject(self) -> bool:
return self._user_account_can_create_new_project
@pyqtSlot(str, "QStringList")
def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None:
"""
Function triggered whenever the Save button is pressed.
:param filename: The name (without the extension) that will be used for the files
:param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both
"""
if self._selected_project_idx == -1:
Logger.log("e", "No DF Library project is selected.")
return
if filename == "":
Logger.log("w", "The file name cannot be empty.")
Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", lifetime = 0).show()
return
self._saveFileToSelectedProjectHelper(filename, formats)
def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
# Indicate we have started sending a job.
self.uploadStarted.emit()
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]
# Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project
self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = cast(List[SceneNode], self.nodes),
library_project_id = library_project_id,
library_project_name = library_project_name,
file_name = filename, formats = formats,
on_upload_error = self.uploadFileError.emit,
on_upload_success = self.uploadFileSuccess.emit,
on_upload_finished = self.uploadFileFinished.emit,
on_upload_progress = self.uploadFileProgress.emit)
self.file_upload_manager.start()
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id)
@pyqtProperty(str, notify = projectCreationErrorTextChanged)
def projectCreationErrorText(self) -> str:
return self._project_creation_error_text

View file

@ -0,0 +1,43 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .BaseModel import BaseModel
from typing import Optional
class DigitalFactoryFeatureBudgetResponse(BaseModel):
"""Class representing the capabilities of a user account for Digital Library.
NOTE: For each max_..._projects fields, '-1' means unlimited!
"""
def __init__(self,
library_can_use_business_value: Optional[bool] = False,
library_can_use_comments: Optional[bool] = False,
library_can_use_status: Optional[bool] = False,
library_can_use_tags: Optional[bool] = False,
library_can_use_technical_requirements: Optional[bool] = False,
library_max_organization_shared_projects: Optional[int] = None, # -1 means unlimited
library_max_private_projects: Optional[int] = None, # -1 means unlimited
library_max_team_shared_projects: Optional[int] = None, # -1 means unlimited
**kwargs) -> None:
self.library_can_use_business_value = library_can_use_business_value
self.library_can_use_comments = library_can_use_comments
self.library_can_use_status = library_can_use_status
self.library_can_use_tags = library_can_use_tags
self.library_can_use_technical_requirements = library_can_use_technical_requirements
self.library_max_organization_shared_projects = library_max_organization_shared_projects # -1 means unlimited
self.library_max_private_projects = library_max_private_projects # -1 means unlimited
self.library_max_team_shared_projects = library_max_team_shared_projects # -1 means unlimited
super().__init__(**kwargs)
def __repr__(self) -> str:
return "max private: {}, max org: {}, max team: {}".format(
self.library_max_private_projects,
self.library_max_organization_shared_projects,
self.library_max_team_shared_projects)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
# No validation for now, as the response can be "data: []", which should be interpreted as all False and 0's

View file

@ -0,0 +1,116 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Dict, Callable
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M"
class DigitalFactoryFileModel(ListModel):
FileNameRole = Qt.UserRole + 1
FileIdRole = Qt.UserRole + 2
FileSizeRole = Qt.UserRole + 3
LibraryProjectIdRole = Qt.UserRole + 4
DownloadUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
UploadedAtRole = Qt.UserRole + 7
dfFileModelChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.FileNameRole, "fileName")
self.addRoleName(self.FileIdRole, "fileId")
self.addRoleName(self.FileSizeRole, "fileSize")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DownloadUrlRole, "downloadUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.UploadedAtRole, "uploadedAt")
self._files = [] # type: List[DigitalFactoryFileResponse]
self._filters = {} # type: Dict[str, Callable]
def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
if self._files == df_files_in_project:
return
self.clear()
self._files = df_files_in_project
self._update()
def clearFiles(self) -> None:
self.clear()
self._files.clear()
self.dfFileModelChanged.emit()
def _update(self) -> None:
filtered_files_list = self.getFilteredFilesList()
for file in filtered_files_list:
self.appendItem({
"fileName" : file.file_name,
"fileId" : file.file_id,
"fileSize": file.file_size,
"libraryProjectId": file.library_project_id,
"downloadUrl": file.download_url,
"username": file.username,
"uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT)
})
self.dfFileModelChanged.emit()
def setFilters(self, filters: Dict[str, Callable]) -> None:
"""
Sets the filters and updates the files model to contain only the files that meet all of the filters.
:param filters: The filters to be applied
example:
{
"attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1,
"attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2
}
"""
self.clear()
self._filters = filters
self._update()
def clearFilters(self) -> None:
"""
Clears all the model filters
"""
self.setFilters({})
def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]:
"""
Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each
filter function on the corresponding attribute for all the filters in the self._filters. If all of them are
true, the file is added to the filtered files list.
In order for this to work, the self._filters should be in the format:
{
"attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute
}
:return: The list of files that meet all the specified filters
"""
if not self._filters:
return self._files
filtered_files_list = []
for file in self._files:
filter_results = []
for attribute, filter_func in self._filters.items():
try:
filter_results.append(filter_func(getattr(file, attribute)))
except AttributeError:
Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file)))
all_filters_met = all(filter_results)
if all_filters_met:
filtered_files_list.append(file)
return filtered_files_list

View file

@ -0,0 +1,62 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from UM.FileProvider import FileProvider
from UM.Logger import Logger
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryFileProvider(FileProvider):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self._controller = df_controller
self.menu_item_display_text = "From Digital Library"
self.shortcut = "Ctrl+Shift+O"
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml")
self._dialog = None
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self.priority = 10
def run(self) -> None:
"""
Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered
"""
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.initialize()
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
def _onUserAccessStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()

View file

@ -0,0 +1,57 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional
from .BaseModel import BaseModel
DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
class DigitalFactoryFileResponse(BaseModel):
"""Class representing a file in a digital factory project."""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "",
file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None:
"""
Creates a new DF file response object
:param client_id:
:param content_type:
:param file_id:
:param file_name:
:param library_project_id:
:param status:
:param user_id:
:param username:
:param download_url:
:param status_description:
:param file_size:
:param upload_url:
:param kwargs:
"""
self.client_id = client_id
self.content_type = content_type
self.download_url = download_url
self.file_id = file_id
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
self.status = status
self.status_description = status_description
self.upload_url = upload_url
self.user_id = user_id
self.username = username
self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT)
super().__init__(**kwargs)
def __repr__(self) -> str:
return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.file_id:
raise ValueError("file_id is required in Digital Library file")

View file

@ -0,0 +1,118 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
import os
from typing import Optional, List
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.OutputDevice import OutputDeviceError
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Scene.SceneNode import SceneNode
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevice(ProjectOutputDevice):
"""Implements an OutputDevice that supports saving to the digital factory library."""
def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None:
super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent)
self.setName("Digital Library") # Doesn't need to be translated
self.setShortDescription("Save to Library")
self.setDescription("Save to Library")
self.setIconName("save")
self.menu_entry_text = "To Digital Library"
self.shortcut = "Ctrl+Shift+S"
self._plugin_id = plugin_id
self._controller = df_controller
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml")
self._dialog = None
# Connect the write signals
self._controller.uploadStarted.connect(self._onWriteStarted)
self._controller.uploadFileProgress.connect(self.writeProgress.emit)
self._controller.uploadFileError.connect(self._onWriteError)
self._controller.uploadFileSuccess.connect(self.writeSuccess.emit)
self._controller.uploadFileFinished.connect(self._onWriteFinished)
self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0)
self._application = CuraApplication.getInstance()
self._writing = False
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._controller.userAccessStateChanged.connect(self._onUserAccessStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None:
"""Request the specified nodes to be written.
Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the
"Save to Library" action button is pressed (upon slicing).
:param nodes: A collection of scene nodes that should be written to the file.
:param file_name: A suggestion for the file name to write to.
:param limit_mimetypes: Limit the possible mimetypes to use for writing to these types.
:param file_handler: The handler responsible for reading and writing mesh files.
:param kwargs: Keyword arguments.
"""
if self._writing:
raise OutputDeviceError.DeviceBusyError()
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.nodes = nodes
df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory")
self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id"))
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
if not self._controller.file_handlers:
self._controller.file_handlers = {
"3mf": CuraApplication.getInstance().getWorkspaceFileHandler(),
"ufp": CuraApplication.getInstance().getMeshFileHandler()
}
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
def _onUserAccessStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()
def _onWriteStarted(self) -> None:
self._writing = True
self.writeStarted.emit(self)
def _onWriteFinished(self) -> None:
self._writing = False
self.writeFinished.emit(self)
def _onWriteError(self) -> None:
self._writing = False
self.writeError.emit(self)

View file

@ -0,0 +1,18 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self.df_controller = df_controller
def start(self) -> None:
self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True))
def stop(self) -> None:
self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory")

View file

@ -0,0 +1,64 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y"
class DigitalFactoryProjectModel(ListModel):
DisplayNameRole = Qt.UserRole + 1
LibraryProjectIdRole = Qt.UserRole + 2
DescriptionRole = Qt.UserRole + 3
ThumbnailUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
LastUpdatedRole = Qt.UserRole + 7
dfProjectModelChanged = pyqtSignal()
def __init__(self, parent = None) -> None:
super().__init__(parent)
self.addRoleName(self.DisplayNameRole, "displayName")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DescriptionRole, "description")
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
self._projects = [] # type: List[DigitalFactoryProjectResponse]
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if self._projects == df_projects:
return
self._items.clear()
self._projects = df_projects
# self.sortProjectsBy("display_name")
self._update(df_projects)
def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if not df_projects:
return
self._projects.extend(df_projects)
# self.sortProjectsBy("display_name")
self._update(df_projects)
def clearProjects(self) -> None:
self.clear()
self._projects.clear()
self.dfProjectModelChanged.emit()
def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
for project in df_projects:
self.appendItem({
"displayName" : project.display_name,
"libraryProjectId" : project.library_project_id,
"description": project.description,
"thumbnailUrl": project.thumbnail_url,
"username": project.username,
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
})
self.dfProjectModelChanged.emit()

View file

@ -0,0 +1,65 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional, List, Dict, Any
from .BaseModel import BaseModel
from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT
class DigitalFactoryProjectResponse(BaseModel):
"""Class representing a cloud project."""
def __init__(self,
library_project_id: str,
display_name: str,
username: str,
organization_shared: bool,
last_updated: Optional[str] = None,
created_at: Optional[str] = None,
thumbnail_url: Optional[str] = None,
organization_id: Optional[str] = None,
created_by_user_id: Optional[str] = None,
description: Optional[str] = "",
tags: Optional[List[str]] = None,
team_ids: Optional[List[str]] = None,
status: Optional[str] = None,
technical_requirements: Optional[Dict[str, Any]] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param library_project_id:
:param display_name:
:param username:
:param organization_shared:
:param thumbnail_url:
:param created_by_user_id:
:param description:
:param tags:
:param kwargs:
"""
self.library_project_id = library_project_id
self.display_name = display_name
self.description = description
self.username = username
self.organization_shared = organization_shared
self.organization_id = organization_id
self.created_by_user_id = created_by_user_id
self.thumbnail_url = thumbnail_url
self.tags = tags
self.team_ids = team_ids
self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None
self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None
self.status = status
self.technical_requirements = technical_requirements
super().__init__(**kwargs)
def __str__(self) -> str:
return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.library_project_id:
raise ValueError("library_project_id is required on cloud project")

View file

@ -0,0 +1,55 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
from typing import List, Optional, Union
from UM.FileHandler.FileHandler import FileHandler
from UM.FileHandler.FileWriter import FileWriter
from UM.FileHandler.WriteFileJob import WriteFileJob
from UM.Logger import Logger
from UM.MimeTypeDatabase import MimeTypeDatabase
from UM.OutputDevice import OutputDeviceError
from UM.Scene.SceneNode import SceneNode
class ExportFileJob(WriteFileJob):
"""Job that exports the build plate to the correct file format for the Digital Factory Library project."""
def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None:
file_types = file_handler.getSupportedFileTypesWrite()
if len(file_types) == 0:
Logger.log("e", "There are no file types available to write with!")
raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!")
mode = None
file_writer = None
for file_type in file_types:
if file_type["extension"] == extension:
file_writer = file_handler.getWriter(file_type["id"])
mode = file_type.get("mode")
super().__init__(file_writer, self.createStream(mode = mode), nodes, mode)
# Determine the filename.
self.setFileName("{}.{}".format(job_name, extension))
def getOutput(self) -> bytes:
"""Get the job result as bytes as that is what we need to upload to the Digital Factory Library."""
output = self.getStream().getvalue()
if isinstance(output, str):
output = output.encode("utf-8")
return output
def getMimeType(self) -> str:
"""Get the mime type of the selected export file type."""
return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name
@staticmethod
def createStream(mode) -> Union[io.BytesIO, io.StringIO]:
"""Creates the right kind of stream based on the preferred format."""
if mode == FileWriter.OutputMode.TextMode:
return io.StringIO()
else:
return io.BytesIO()

View file

@ -0,0 +1,30 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationLinks:
"""Model containing pagination links."""
def __init__(self,
first: Optional[str] = None,
last: Optional[str] = None,
next: Optional[str] = None,
prev: Optional[str] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param first: The URL for the first page.
:param last: The URL for the last page.
:param next: The URL for the next page.
:param prev: The URL for the prev page.
:param kwargs:
"""
self.first_page = first
self.last_page = last
self.next_page = next
self.prev_page = prev
def __str__(self) -> str:
return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page)

View file

@ -0,0 +1,43 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional, Dict, Any
from .PaginationLinks import PaginationLinks
from .PaginationMetadata import PaginationMetadata
from .ResponseMeta import ResponseMeta
class PaginationManager:
def __init__(self, limit: int) -> None:
self.limit = limit # The limit of items per page
self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response
self.links = None # type: Optional[PaginationLinks] # The pagination-related links
def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None:
self.meta = None
if meta:
page = None
if "page" in meta:
page = PaginationMetadata(**meta["page"])
self.meta = ResponseMeta(page)
def setLinks(self, links: Optional[Dict[str, str]]) -> None:
self.links = PaginationLinks(**links) if links else None
def setLimit(self, new_limit: int) -> None:
"""
Sets the limit of items per page.
:param new_limit: The new limit of items per page
"""
self.limit = new_limit
self.reset()
def reset(self) -> None:
"""
Sets the metadata and links to None.
"""
self.meta = None
self.links = None

View file

@ -0,0 +1,25 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationMetadata:
"""Class representing the metadata related to pagination."""
def __init__(self,
total_count: Optional[int] = None,
total_pages: Optional[int] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param total_count: The total count of items.
:param total_pages: The total number of pages when pagination is applied.
:param kwargs:
"""
self.total_count = total_count
self.total_pages = total_pages
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages)

View file

@ -0,0 +1,24 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
from .PaginationMetadata import PaginationMetadata
class ResponseMeta:
"""Class representing the metadata included in a Digital Library response (if any)"""
def __init__(self,
page: Optional[PaginationMetadata] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param page: Metadata related to pagination
:param kwargs:
"""
self.page = page
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "Response Meta | {}".format(self.page)

View file

View file

@ -0,0 +1,48 @@
from unittest.mock import MagicMock, patch
import pytest
from src.DFFileExportAndUploadManager import DFFileExportAndUploadManager
@pytest.fixture
def upload_manager():
file_handler = MagicMock(name = "file_handler")
file_handler.getSupportedFileTypesWrite = MagicMock(return_value = [{
"id": "test",
"extension": ".3mf",
"description": "nope",
"mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
"mode": "binary",
"hide_in_file_dialog": True,
}])
node = MagicMock(name = "SceneNode")
application = MagicMock(name = "CuraApplication")
with patch("cura.CuraApplication.CuraApplication.getInstance", MagicMock(return_value = application)):
return DFFileExportAndUploadManager(file_handlers = {"3mf": file_handler},
nodes = [node],
library_project_id = "test_library_project_id",
library_project_name = "test_library_project_name",
file_name = "file_name",
formats = ["3mf"],
on_upload_error = MagicMock(),
on_upload_success = MagicMock(),
on_upload_finished = MagicMock(),
on_upload_progress = MagicMock())
@pytest.mark.parametrize("input,expected_result",
[("", ""),
("invalid json! {}", ""),
("{\"errors\": [{}]}", ""),
("{\"errors\": [{\"title\": \"some title\"}]}", "some title")])
def test_extractErrorTitle(upload_manager, input, expected_result):
assert upload_manager.extractErrorTitle(input) == expected_result
def test_exportJobError(upload_manager):
mocked_application = MagicMock()
with patch("UM.Application.Application.getInstance", MagicMock(return_value = mocked_application)):
upload_manager._onJobExportError("file_name.3mf")
# Ensure that message was displayed
mocked_application.showMessageSignal.emit.assert_called_once()

View file

@ -0,0 +1,73 @@
from pathlib import Path
from src.DigitalFactoryFileModel import DigitalFactoryFileModel
from src.DigitalFactoryFileResponse import DigitalFactoryFileResponse
file_1 = DigitalFactoryFileResponse(client_id = "client_id_1",
content_type = "zomg",
file_name = "file_1.3mf",
file_id = "file_id_1",
library_project_id = "project_id_1",
status = "test",
user_id = "user_id_1",
username = "username_1",
uploaded_at = "2021-04-07T10:33:25.000Z")
file_2 = DigitalFactoryFileResponse(client_id ="client_id_2",
content_type = "zomg",
file_name = "file_2.3mf",
file_id = "file_id_2",
library_project_id = "project_id_2",
status = "test",
user_id = "user_id_2",
username = "username_2",
uploaded_at = "2021-02-06T09:33:22.000Z")
file_wtf = DigitalFactoryFileResponse(client_id ="client_id_1",
content_type = "zomg",
file_name = "file_3.wtf",
file_id = "file_id_3",
library_project_id = "project_id_1",
status = "test",
user_id = "user_id_1",
username = "username_1",
uploaded_at = "2021-04-06T12:33:25.000Z")
def test_setFiles():
model = DigitalFactoryFileModel()
assert model.count == 0
model.setFiles([file_1, file_2])
assert model.count == 2
assert model.getItem(0)["fileName"] == "file_1.3mf"
assert model.getItem(1)["fileName"] == "file_2.3mf"
def test_clearProjects():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2])
model.clearFiles()
assert model.count == 0
def test_setProjectMultipleTimes():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2])
model.setFiles([file_2])
assert model.count == 1
assert model.getItem(0)["fileName"] == "file_2.3mf"
def test_setFilter():
model = DigitalFactoryFileModel()
model.setFiles([file_1, file_2, file_wtf])
model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in ["3mf"]})
assert model.count == 2
model.clearFilters()
assert model.count == 3

View file

@ -0,0 +1,55 @@
from src.DigitalFactoryProjectModel import DigitalFactoryProjectModel
from src.DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
project_1 = DigitalFactoryProjectResponse(library_project_id = "omg",
display_name = "zomg",
username = "nope",
organization_shared = True)
project_2 = DigitalFactoryProjectResponse(library_project_id = "omg2",
display_name = "zomg2",
username = "nope",
organization_shared = False)
def test_setProjects():
model = DigitalFactoryProjectModel()
assert model.count == 0
model.setProjects([project_1, project_2])
assert model.count == 2
assert model.getItem(0)["displayName"] == "zomg"
assert model.getItem(1)["displayName"] == "zomg2"
def test_clearProjects():
model = DigitalFactoryProjectModel()
model.setProjects([project_1, project_2])
model.clearProjects()
assert model.count == 0
def test_setProjectMultipleTimes():
model = DigitalFactoryProjectModel()
model.setProjects([project_1, project_2])
model.setProjects([project_2])
assert model.count == 1
assert model.getItem(0)["displayName"] == "zomg2"
def test_extendProjects():
model = DigitalFactoryProjectModel()
assert model.count == 0
model.setProjects([project_1])
assert model.count == 1
model.extendProjects([project_2])
assert model.count == 2
assert model.getItem(0)["displayName"] == "zomg"
assert model.getItem(1)["displayName"] == "zomg2"

View file

@ -0,0 +1,89 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from unittest.mock import MagicMock
import pytest
from cura.CuraApplication import CuraApplication
from src.DigitalFactoryApiClient import DigitalFactoryApiClient
from src.PaginationManager import PaginationManager
@pytest.fixture
def application():
app = MagicMock(spec=CuraApplication, name = "Mocked Cura Application")
return app
@pytest.fixture
def pagination_manager():
manager = MagicMock(name = "Mocked Pagination Manager")
return manager
@pytest.fixture
def api_client(application, pagination_manager):
api_client = DigitalFactoryApiClient(application, MagicMock())
api_client._projects_pagination_mgr = pagination_manager
return api_client
def test_getProjectsFirstPage(api_client):
# setup
http_manager = MagicMock()
api_client._http = http_manager
pagination_manager = api_client._projects_pagination_mgr
pagination_manager.limit = 20
finished_callback = MagicMock()
failed_callback = MagicMock()
# Call
api_client.getProjectsFirstPage(search_filter = "filter", on_finished = finished_callback, failed = failed_callback)
# Asserts
pagination_manager.reset.assert_called_once() # Should be called since we asked for new set of projects
http_manager.get.assert_called_once()
args = http_manager.get.call_args_list[0]
# Ensure that it's called with the right limit
assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=20&search=filter"
# Change the limit & try again
http_manager.get.reset_mock()
pagination_manager.limit = 80
api_client.getProjectsFirstPage(search_filter = "filter", on_finished = finished_callback, failed = failed_callback)
args = http_manager.get.call_args_list[0]
# Ensure that it's called with the right limit
assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=80&search=filter"
def test_getMoreProjects_noNewProjects(api_client):
api_client.hasMoreProjectsToLoad = MagicMock(return_value = False)
http_manager = MagicMock()
api_client._http = http_manager
finished_callback = MagicMock()
failed_callback = MagicMock()
api_client.getMoreProjects(finished_callback, failed_callback)
http_manager.get.assert_not_called()
def test_getMoreProjects_hasNewProjects(api_client):
api_client.hasMoreProjectsToLoad = MagicMock(return_value = True)
http_manager = MagicMock()
api_client._http = http_manager
finished_callback = MagicMock()
failed_callback = MagicMock()
api_client.getMoreProjects(finished_callback, failed_callback)
http_manager.get.assert_called_once()
def test_clear(api_client):
api_client.clear()
api_client._projects_pagination_mgr.reset.assert_called_once()

View file

@ -0,0 +1,5 @@
# Ensure that the importing for all tests work
import sys
import os
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Checks for firmware updates.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a machine actions for updating firmware.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Reads g-code from a compressed archive.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a compressed archive.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from g-code files.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Victor Larchenko, Ultimaker B.V.",
"version": "1.0.1",
"description": "Allows loading and displaying G-code files.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Writes g-code to a file.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Enables ability to generate printable geometry from 2D image files.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides support for importing profiles from legacy Cura versions.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -27,7 +27,7 @@ Cura.MachineAction
Connections
{
target: extrudersModel
onItemsChanged: tabNameModel.update()
function onItemsChanged() { tabNameModel.update() }
}
ListModel

View file

@ -326,7 +326,7 @@ Item
Connections
{
target: Cura.MachineManager
onGlobalContainerChanged: extruderCountModel.update()
function onGlobalContainerChanged() { extruderCountModel.update() }
}
}

View file

@ -3,6 +3,6 @@
"author": "fieldOfView, Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a way to change machine settings (such as build volume, nozzle size, etc.).",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -2,7 +2,7 @@
"name": "Model Checker",
"author": "Ultimaker B.V.",
"version": "1.0.1",
"api": "7.4.0",
"api": 7,
"description": "Checks models and print configuration for possible printing issues and give suggestions.",
"i18n-catalog": "cura"
}

View file

@ -137,7 +137,7 @@ Rectangle
id: externalLinkIcon
anchors.verticalCenter: parent.verticalCenter
color: UM.Theme.getColor("text_link")
source: UM.Theme.getIcon("external_link")
source: UM.Theme.getIcon("LinkExternal")
width: UM.Theme.getSize("monitor_external_link_icon").width
height: UM.Theme.getSize("monitor_external_link_icon").height
}
@ -164,5 +164,16 @@ Rectangle
onExited: manageQueueText.font.underline = false
}
}
Label
{
id: noConnectionLabel
anchors.horizontalCenter: parent.horizontalCenter
visible: !isNetworkConfigurable
text: catalog.i18nc("@info", "In order to monitor your print from Cura, please connect the printer.")
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
wrapMode: Text.WordWrap
width: contentWidth
}
}
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a monitor stage in Cura.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -24,7 +24,7 @@ Button {
anchors.verticalCenter: parent.verticalCenter
height: (label.height / 2) | 0
width: height
source: control.checked ? UM.Theme.getIcon("arrow_bottom") : UM.Theme.getIcon("arrow_right");
source: control.checked ? UM.Theme.getIcon("ChevronSingleDown") : UM.Theme.getIcon("ChevronSingleRight");
color: control.hovered ? palette.highlight : palette.buttonText
}
UM.RecolorImage

View file

@ -35,7 +35,7 @@ UM.TooltipArea
Connections
{
target: addedSettingsModel
onVisibleCountChanged:
function onVisibleCountChanged()
{
check.checked = addedSettingsModel.getVisible(model.key)
}

View file

@ -73,38 +73,40 @@ class PerObjectSettingVisibilityHandler(UM.Settings.Models.SettingVisibilityHand
# Add all instances that are not added, but are in visibility list
for item in visible:
if settings.getInstance(item) is None: # Setting was not added already.
definition = self._stack.getSettingDefinition(item)
if definition:
new_instance = SettingInstance(definition, settings)
if settings.getInstance(item) is not None: # Setting was added already.
continue
definition = self._stack.getSettingDefinition(item)
if not definition:
Logger.log("w", f"Unable to add instance ({item}) to per-object visibility because we couldn't find the matching definition.")
continue
new_instance = SettingInstance(definition, settings)
stack_nr = -1
stack = None
# Check from what stack we should copy the raw property of the setting from.
if self._stack.getProperty("machine_extruder_count", "value") > 1:
if definition.limit_to_extruder != "-1":
# A limit to extruder function was set and it's a multi extrusion machine. Check what stack we do need to use.
stack_nr = str(int(round(float(self._stack.getProperty(item, "limit_to_extruder")))))
# Check if the found stack_number is in the extruder list of extruders.
if stack_nr not in ExtruderManager.getInstance().extruderIds and self._stack.getProperty("extruder_nr", "value") is not None:
stack_nr = -1
stack = None
# Check from what stack we should copy the raw property of the setting from.
if self._stack.getProperty("machine_extruder_count", "value") > 1:
if definition.limit_to_extruder != "-1":
# A limit to extruder function was set and it's a multi extrusion machine. Check what stack we do need to use.
stack_nr = str(int(round(float(self._stack.getProperty(item, "limit_to_extruder")))))
# Check if the found stack_number is in the extruder list of extruders.
if stack_nr not in ExtruderManager.getInstance().extruderIds and self._stack.getProperty("extruder_nr", "value") is not None:
stack_nr = -1
# Use the found stack number to get the right stack to copy the value from.
if stack_nr in ExtruderManager.getInstance().extruderIds:
stack = ContainerRegistry.getInstance().findContainerStacks(id = ExtruderManager.getInstance().extruderIds[stack_nr])[0]
else:
stack = self._stack
# Use the found stack number to get the right stack to copy the value from.
if stack_nr in ExtruderManager.getInstance().extruderIds:
stack = ContainerRegistry.getInstance().findContainerStacks(id = ExtruderManager.getInstance().extruderIds[stack_nr])[0]
else:
stack = self._stack
# Use the raw property to set the value (so the inheritance doesn't break)
if stack is not None:
new_instance.setProperty("value", stack.getRawProperty(item, "value"))
else:
new_instance.setProperty("value", None)
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
visibility_changed = True
else:
Logger.log("w", "Unable to add instance (%s) to per-object visibility because we couldn't find the matching definition", item)
# Use the raw property to set the value (so the inheritance doesn't break)
if stack is not None:
new_instance.setProperty("value", stack.getRawProperty(item, "value"))
else:
new_instance.setProperty("value", None)
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
visibility_changed = True
if visibility_changed:
self.visibilityChanged.emit()

View file

@ -1,4 +1,4 @@
// Copyright (c) 2017 Ultimaker B.V.
// Copyright (c) 2021 Ultimaker B.V.
// Uranium is released under the terms of the LGPLv3 or higher.
import QtQuick 2.2
@ -80,7 +80,7 @@ Item
{
id: normalButton
text: catalog.i18nc("@label", "Normal model")
iconSource: UM.Theme.getIcon("pos_normal");
iconSource: UM.Theme.getIcon("Infill0");
property bool needBorder: true
checkable: true
onClicked: setMeshType(normalMeshType);
@ -92,7 +92,7 @@ Item
{
id: supportMeshButton
text: catalog.i18nc("@label", "Print as support")
iconSource: UM.Theme.getIcon("pos_print_as_support");
iconSource: UM.Theme.getIcon("MeshTypeSupport");
property bool needBorder: true
checkable:true
onClicked: setMeshType(supportMeshType)
@ -104,7 +104,7 @@ Item
{
id: overlapMeshButton
text: catalog.i18nc("@label", "Modify settings for overlaps")
iconSource: UM.Theme.getIcon("pos_modify_overlaps");
iconSource: UM.Theme.getIcon("MeshTypeIntersect");
property bool needBorder: true
checkable:true
onClicked: setMeshType(infillMeshType)
@ -116,7 +116,7 @@ Item
{
id: antiOverhangMeshButton
text: catalog.i18nc("@label", "Don't support overlaps")
iconSource: UM.Theme.getIcon("pos_modify_dont_support_overlap");
iconSource: UM.Theme.getIcon("BlockSupportOverlaps");
property bool needBorder: true
checkable: true
onClicked: setMeshType(antiOverhangMeshType)
@ -136,10 +136,12 @@ Item
}
ComboBox
Cura.ComboBox
{
id: infillOnlyComboBox
width: parent.width / 2 - UM.Theme.getSize("default_margin").width
height: UM.Theme.getSize("setting_control").height
textRole: "text"
model: ListModel
{
@ -304,7 +306,7 @@ Item
height: width
sourceSize.height: width
color: control.hovered ? UM.Theme.getColor("setting_control_button_hover") : UM.Theme.getColor("setting_control_button")
source: UM.Theme.getIcon("minus")
source: UM.Theme.getIcon("Minus")
}
}
}
@ -334,13 +336,13 @@ Item
Connections
{
target: inheritStackProvider
onPropertiesChanged: provider.forcePropertiesChanged()
function onPropertiesChanged() { provider.forcePropertiesChanged() }
}
Connections
{
target: UM.ActiveTool
onPropertiesChanged:
function onPropertiesChanged()
{
// the values cannot be bound with UM.ActiveTool.properties.getValue() calls,
// so here we connect to the signal and update the those values.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Logger import Logger
@ -103,20 +103,27 @@ class PerObjectSettingsTool(Tool):
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
for property_key in ["top_bottom_thickness", "wall_thickness", "wall_line_count"]:
# Override some settings to ensure that the infill mesh by default adds no skin or walls. Or remove them if not an infill mesh.
specialized_settings = {
"top_bottom_thickness": 0,
"top_thickness": "=top_bottom_thickness",
"bottom_thickness": "=top_bottom_thickness",
"top_layers": "=0 if infill_sparse_density == 100 else math.ceil(round(top_thickness / resolveOrValue('layer_height'), 4))",
"bottom_layers": "=0 if infill_sparse_density == 100 else math.ceil(round(bottom_thickness / resolveOrValue('layer_height'), 4))",
"wall_thickness": 0,
"wall_line_count": "=max(1, round((wall_thickness - wall_line_width_0) / wall_line_width_x) + 1) if wall_thickness != 0 else 0"
}
for property_key in specialized_settings:
if mesh_type == "infill_mesh":
if settings.getInstance(property_key) is None:
definition = stack.getSettingDefinition(property_key)
new_instance = SettingInstance(definition, settings)
# We just want the wall_line count to be there in case it was overriden in the global stack.
# as such, we don't need to set a value.
if property_key != "wall_line_count":
new_instance.setProperty("value", 0)
new_instance.setProperty("value", specialized_settings[property_key])
new_instance.resetState() # Ensure that the state is not seen as a user state.
settings.addInstance(new_instance)
settings_visibility_changed = True
elif old_mesh_type == "infill_mesh" and settings.getInstance(property_key) and (settings.getProperty(property_key, "value") == 0 or property_key == "wall_line_count"):
elif old_mesh_type == "infill_mesh" and settings.getInstance(property_key) and property_key in specialized_settings:
settings.removeInstance(property_key)
settings_visibility_changed = True

View file

@ -13,7 +13,7 @@ def getMetaData():
"tool": {
"name": i18n_catalog.i18nc("@label", "Per Model Settings"),
"description": i18n_catalog.i18nc("@info:tooltip", "Configure Per Model Settings"),
"icon": "tool_icon.svg",
"icon": "MeshType",
"tool_panel": "PerObjectSettingsPanel.qml",
"weight": 3
},

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides the Per Model Settings.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="30px" height="30px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 57.1 (83088) - https://sketch.com -->
<title>per_model_settings</title>
<desc>Created with Sketch.</desc>
<defs>
<path d="M9.73076923,0 L9.226,1.345 L0.449,11 L0,11 L0.639,9.084 L8.896,0 L9.73076923,0 Z M8.49,3.472 L8.907,4.721 L3.199,11 L1.647,11 L8.49,3.472 Z M9.228,5.685 L9.645,6.935 L5.949,11 L4.397,11 L9.228,5.685 Z M9.966,7.899 L10.382,9.148 L8.699,11 L7.147,11 L9.966,7.899 Z M10.704,10.112 L11,11 L9.896,11 L10.704,10.112 Z M7.698,0 L1.332,7.004 L2.23,4.308 L6.146,0 L7.698,0 Z M4.948,0 L2.344,2.866 L1.89,1.656 L3.396,0 L4.948,0 Z M2.198,0 L1.54,0.724 L1.26923077,0 L2.198,0 Z" id="path-1"></path>
</defs>
<g id="per_model_settings" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Per-model" transform="translate(2.000000, 2.000000)">
<polygon id="Path-Copy-5" fill="#000" points="1.26923077 0 9.73076923 0 8.46153846 3.38461538 11 11 0 11 2.53846154 3.38461538"></polygon>
<polygon id="Path-Copy-8" fill="#000" points="14.2692308 13 22.7307692 13 21.4615385 16.3846154 24 24 13 24 15.5384615 16.3846154"></polygon>
<g id="stripe" transform="translate(13.000000, 0.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="Combined-Shape" fill="#000" xlink:href="#path-1"></use>
</g>
<path d="M1.990731,13.5 L3.06878027,16.374798 L0.693712943,23.5 L10.3062871,23.5 L7.93121973,16.374798 L9.009269,13.5 L1.990731,13.5 Z" id="Path-Copy-7" stroke="#000"></path>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -3,7 +3,9 @@
import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Controls.Styles 1.1
import QtQml.Models 2.15 as Models
import QtQuick.Layouts 1.1
import QtQuick.Dialogs 1.1
import QtQuick.Window 2.2
@ -152,7 +154,7 @@ UM.Dialog
height: Math.round(control.height / 2.7)
sourceSize.height: width
color: palette.text
source: UM.Theme.getIcon("cross1")
source: UM.Theme.getIcon("Cancel")
}
}
}
@ -186,7 +188,7 @@ UM.Dialog
height: Math.round(control.height / 2.5)
sourceSize.height: width
color: control.enabled ? palette.text : disabledPalette.text
source: UM.Theme.getIcon("arrow_bottom")
source: UM.Theme.getIcon("ChevronSingleDown")
}
}
}
@ -220,7 +222,7 @@ UM.Dialog
height: Math.round(control.height / 2.5)
sourceSize.height: width
color: control.enabled ? palette.text : disabledPalette.text
source: UM.Theme.getIcon("arrow_top")
source: UM.Theme.getIcon("ChevronSingleUp")
}
}
}
@ -235,7 +237,7 @@ UM.Dialog
anchors.leftMargin: base.textMargin
anchors.top: activeScriptsList.bottom
anchors.topMargin: base.textMargin
menu: scriptsMenu
onClicked: scriptsMenu.open()
style: ButtonStyle
{
label: Label
@ -244,15 +246,16 @@ UM.Dialog
}
}
}
Menu
QQC2.Menu
{
id: scriptsMenu
width: parent.width
Instantiator
Models.Instantiator
{
model: manager.loadedScriptList
MenuItem
QQC2.MenuItem
{
text: manager.getScriptLabelByKey(modelData.toString())
onTriggered: manager.addScriptToList(modelData.toString())
@ -414,7 +417,7 @@ UM.Dialog
{
target: item
onShowTooltip:
function onShowTooltip(text)
{
tooltip.text = text
var position = settingLoader.mapToItem(settingsPanel, settingsPanel.x, 0)
@ -422,7 +425,7 @@ UM.Dialog
tooltip.target.x = position.x + 1
}
onHideTooltip: tooltip.hide()
function onHideTooltip() { tooltip.hide() }
}
}
}
@ -514,7 +517,7 @@ UM.Dialog
}
toolTipContentAlignment: Cura.ToolTip.ContentAlignment.AlignLeft
onClicked: dialog.show()
iconSource: "postprocessing.svg"
iconSource: "Script.svg"
fixedWidthMode: false
}
@ -533,4 +536,4 @@ UM.Dialog
labelText: activeScriptsList.count
}
}
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<polygon points="4.4,12 8.2,15.8 6.8,17.2 1.6,12 6.8,6.8 8.2,8.2" />
<polygon points="22.4,12 17.2,17.2 15.8,15.8 19.6,12 15.8,8.2 17.2,6.8" />
<rect x="3.9" y="11" transform="matrix(0.1236 -0.9923 0.9923 0.1236 -1.429 22.4317)" width="16.1" height="2" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

View file

@ -1,14 +1,6 @@
# Copyright (c) 2020 Jaime van Kessel, Ultimaker B.V.
# The PostProcessingPlugin is released under the terms of the AGPLv3 or higher.
# Workaround for a race condition on certain systems where there
# is a race condition between Arcus and PyQt. Importing Arcus
# first seems to prevent Sip from going into a state where it
# tries to create PyQt objects on a non-main thread.
import Arcus # @UnusedImport
import Savitar # @UnusedImport
import pynest2d # @UnusedImport
from . import PostProcessingPlugin

View file

@ -2,7 +2,7 @@
"name": "Post Processing",
"author": "Ultimaker",
"version": "2.2.1",
"api": "7.4.0",
"api": 7,
"description": "Extension that allows for user created scripts for post processing",
"catalog": "cura"
}

View file

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 16.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
x="0px"
y="0px"
width="512px"
height="512px"
viewBox="0 0 512 512"
style="enable-background:new 0 0 512 512;"
xml:space="preserve"
inkscape:version="0.91 r13725"
sodipodi:docname="postprocessing.svg"><metadata
id="metadata9"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs7" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1104"
inkscape:window-height="1006"
id="namedview5"
showgrid="false"
inkscape:zoom="1.3359375"
inkscape:cx="256"
inkscape:cy="256"
inkscape:window-x="701"
inkscape:window-y="121"
inkscape:window-maximized="0"
inkscape:current-layer="Layer_1" /><path
d="M 402.15234 0 C 371.74552 4.7369516e-015 345.79114 10.752017 324.21875 32.324219 C 302.57788 53.89652 291.82617 79.851497 291.82617 110.18945 C 291.82617 127.34315 295.26662 143.09419 302.16602 157.44531 L 238.38477 221.20312 C 227.77569 210.95036 218.04331 201.50935 209.66016 193.32422 C 207.33386 190.99792 202.68042 189.48707 198.60938 191.92969 L 191.74609 196.11719 C 165.34252 169.24836 154.17609 158.42965 150.57031 145.40234 C 146.84822 131.79345 150.22148 113.64862 153.71094 106.90234 C 156.61882 101.55183 165.69233 96.550326 173.36914 95.96875 L 183.37109 106.20508 C 185.69739 108.53139 189.30456 108.53139 191.63086 106.20508 L 227.57227 69.681641 C 229.89858 67.355335 229.89858 63.517712 227.57227 61.191406 L 169.53125 2.21875 C 167.20494 -0.10755598 163.48147 -0.10755598 161.27148 2.21875 L 125.33008 38.742188 C 123.00378 41.068494 123.00378 44.906116 125.33008 47.232422 L 129.16992 51.1875 C 129.16992 56.88695 128.35573 65.727167 123.70312 70.496094 C 116.49157 77.823958 102.18413 69.332919 92.878906 75.962891 C 83.689998 82.476548 72.05746 92.944493 64.613281 100.38867 C 57.285417 107.83285 29.138171 137.37722 9.015625 187.16016 C -11.106922 236.94311 4.3632369 283.12 15.296875 295.2168 C 21.11264 301.61414 31.696982 308.12804 29.835938 296.03125 C 27.974892 283.81815 24.951448 241.47942 38.792969 224.14844 C 52.634489 206.81746 70.894726 192.62799 94.623047 191.46484 C 117.42084 190.30169 130.56529 198.09417 160.10938 228.10352 L 156.85156 234.15234 C 154.75788 238.10706 155.92175 243.10728 158.24805 245.43359 C 161.95717 248.74082 172.37305 258.96006 186.52539 273.04297 L 6.9511719 452.54883 C 2.2984329 457.14417 1.1842379e-015 462.71497 0 469.14844 C -1.1842379e-015 475.69681 2.2984329 481.15473 6.9511719 485.51953 L 26.308594 505.22266 C 31.018838 509.76054 36.589603 512 42.908203 512 C 49.341623 512 54.800053 509.76054 59.337891 505.22266 L 238.96875 325.6582 C 317.6609 404.95524 424.21289 513.40234 424.21289 513.40234 L 482.25391 454.43164 C 437.71428 411.9686 358.71135 336.76293 291.93164 272.71484 L 354.68945 209.98047 C 369.08663 216.91399 384.90203 220.37891 402.15234 220.37891 C 425.29988 220.37891 446.52947 213.53073 465.77344 199.83398 C 485.08493 186.1372 498.57775 168.33291 506.31641 146.34961 C 510.08303 135.39222 512 126.69334 512 120.25586 C 512 117.79044 511.24662 115.80572 509.87695 114.16211 C 508.50726 112.5185 506.59041 111.69531 504.125 111.69531 C 502.61835 111.69531 496.86414 114.5734 486.72852 120.39453 C 476.6614 126.21564 465.50054 132.85752 453.37891 140.32227 C 441.18878 147.78698 434.7515 151.75888 433.92969 152.23828 L 386.40234 125.94141 L 386.40234 70.8125 L 458.51562 29.242188 C 461.18649 27.461587 462.48633 25.202356 462.48633 22.394531 C 462.48633 19.586706 461.1865 17.325625 458.51562 15.476562 C 451.32484 10.545729 442.4896 6.780346 432.08008 4.0410156 C 421.60206 1.3701797 411.67159 0 402.15234 0 z "
id="path3" /></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -77,7 +77,7 @@ class DisplayProgressOnLCD(Script):
current_time_string = "{:d}h{:02d}m{:02d}s".format(int(h), int(m), int(s))
# And now insert that into the GCODE
lines.insert(line_index, "M117 Time Left {}".format(current_time_string))
else: # Must be m73.
else:
mins = int(60 * h + m + s / 30)
lines.insert(line_index, "M73 R{}".format(mins))

View file

@ -1,6 +1,9 @@
# Copyright (c) 2019 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# The PostProcessingPlugin is released under the terms of the AGPLv3 or higher.
# Modification 06.09.2020
# add checkbox, now you can choose and use configuration from the firmware itself.
from typing import List
from ..Script import Script
@ -13,7 +16,7 @@ class FilamentChange(Script):
def getSettingDataString(self):
return """{
"name":"Filament Change",
"name": "Filament Change",
"key": "FilamentChange",
"metadata": {},
"version": 2,
@ -27,14 +30,21 @@ class FilamentChange(Script):
"type": "str",
"default_value": "1"
},
"firmware_config":
{
"label": "Use Firmware Configuration",
"description": "Use the settings in your firmware, or customise the parameters of the filament change here.",
"type": "bool",
"default_value": false
},
"initial_retract":
{
"label": "Initial Retraction",
"description": "Initial filament retraction distance. The filament will be retracted with this amount before moving the nozzle away from the ongoing print.",
"unit": "mm",
"type": "float",
"default_value": 30.0
"default_value": 30.0,
"enabled": "not firmware_config"
},
"later_retract":
{
@ -42,7 +52,8 @@ class FilamentChange(Script):
"description": "Later filament retraction distance for removal. The filament will be retracted all the way out of the printer so that you can change the filament.",
"unit": "mm",
"type": "float",
"default_value": 300.0
"default_value": 300.0,
"enabled": "not firmware_config"
},
"x_position":
{
@ -50,7 +61,8 @@ class FilamentChange(Script):
"description": "Extruder X position. The print head will move here for filament change.",
"unit": "mm",
"type": "float",
"default_value": 0
"default_value": 0,
"enabled": "not firmware_config"
},
"y_position":
{
@ -58,7 +70,17 @@ class FilamentChange(Script):
"description": "Extruder Y position. The print head will move here for filament change.",
"unit": "mm",
"type": "float",
"default_value": 0
"default_value": 0,
"enabled": "not firmware_config"
},
"z_position":
{
"label": "Z Position (relative)",
"description": "Extruder relative Z position. Move the print head up for filament change.",
"unit": "mm",
"type": "float",
"default_value": 0,
"minimum_value": 0
}
}
}"""
@ -74,20 +96,26 @@ class FilamentChange(Script):
later_retract = self.getSettingValueByKey("later_retract")
x_pos = self.getSettingValueByKey("x_position")
y_pos = self.getSettingValueByKey("y_position")
z_pos = self.getSettingValueByKey("z_position")
firmware_config = self.getSettingValueByKey("firmware_config")
color_change = "M600"
if initial_retract is not None and initial_retract > 0.:
color_change = color_change + (" E%.2f" % initial_retract)
if not firmware_config:
if initial_retract is not None and initial_retract > 0.:
color_change = color_change + (" E%.2f" % initial_retract)
if later_retract is not None and later_retract > 0.:
color_change = color_change + (" L%.2f" % later_retract)
if later_retract is not None and later_retract > 0.:
color_change = color_change + (" L%.2f" % later_retract)
if x_pos is not None:
color_change = color_change + (" X%.2f" % x_pos)
if y_pos is not None:
color_change = color_change + (" Y%.2f" % y_pos)
if x_pos is not None:
color_change = color_change + (" X%.2f" % x_pos)
if y_pos is not None:
color_change = color_change + (" Y%.2f" % y_pos)
if z_pos is not None and z_pos > 0.:
color_change = color_change + (" Z%.2f" % z_pos)
color_change = color_change + " ; Generated by FilamentChange plugin\n"
@ -101,4 +129,4 @@ class FilamentChange(Script):
if 0 < layer_num < len(data):
data[layer_num] = color_change + data[layer_num]
return data
return data

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from ..Script import Script
@ -338,11 +338,6 @@ class PauseAtHeight(Script):
if current_layer < pause_layer - nbr_negative_layers:
continue
# Get X and Y from the next layer (better position for
# the nozzle)
next_layer = data[index + 1]
x, y = self.getNextXY(next_layer)
prev_layer = data[index - 1]
prev_lines = prev_layer.split("\n")
current_e = 0.
@ -353,6 +348,13 @@ class PauseAtHeight(Script):
current_e = self.getValue(prevLine, "E", -1)
if current_e >= 0:
break
# and also find last X,Y
for prevLine in reversed(prev_lines):
if prevLine.startswith(("G0", "G1", "G2", "G3")):
if self.getValue(prevLine, "X") is not None and self.getValue(prevLine, "Y") is not None:
x = self.getValue(prevLine, "X")
y = self.getValue(prevLine, "Y")
break
# Maybe redo the last layer.
if redo_layer:
@ -385,7 +387,7 @@ class PauseAtHeight(Script):
#Retraction
prepend_gcode += self.putValue(M = 83) + " ; switch to relative E values for any needed retraction\n"
if retraction_amount != 0:
prepend_gcode += self.putValue(G = 1, E = retraction_amount, F = 6000) + "\n"
prepend_gcode += self.putValue(G = 1, E = -retraction_amount, F = 6000) + "\n"
#Move the head away
prepend_gcode += self.putValue(G = 1, Z = current_z + 1, F = 300) + " ; move up a millimeter to get out of the way\n"
@ -454,7 +456,7 @@ class PauseAtHeight(Script):
prepend_gcode += self.putValue(G = 1, E = -retraction_amount, F = 6000) + "\n"
#Move the head back
prepend_gcode += self.putValue(G = 1, Z = current_z + 1, F = 300) + "\n"
prepend_gcode += self.putValue(G = 1, Z = current_z, F = 300) + "\n"
prepend_gcode += self.putValue(G = 1, X = x, Y = y, F = 9000) + "\n"
if retraction_amount != 0:
prepend_gcode += self.putValue(G = 1, E = retraction_amount, F = 6000) + "\n"
@ -505,10 +507,23 @@ class PauseAtHeight(Script):
else:
Logger.log("w", "No previous feedrate found in gcode, feedrate for next layer(s) might be incorrect")
prepend_gcode += self.putValue(M = 82) + " ; switch back to absolute E values\n"
extrusion_mode_string = "absolute"
extrusion_mode_numeric = 82
# reset extrude value to pre pause value
prepend_gcode += self.putValue(G = 92, E = current_e) + "\n"
relative_extrusion = Application.getInstance().getGlobalContainerStack().getProperty("relative_extrusion", "value")
if relative_extrusion:
extrusion_mode_string = "relative"
extrusion_mode_numeric = 83
prepend_gcode += self.putValue(M = extrusion_mode_numeric) + " ; switch back to " + extrusion_mode_string + " E values\n"
# reset extrude value to pre pause value
prepend_gcode += self.putValue(G = 92, E = current_e) + "\n"
elif redo_layer:
# All other options reset the E value to what it was before the pause because E things were added.
# If it's not yet reset, it still needs to be reset if there were any redo layers.
prepend_gcode += self.putValue(G = 92, E = current_e) + "\n"
layer = prepend_gcode + layer

View file

@ -1,19 +1,20 @@
// Copyright (c) 2018 Ultimaker B.V.
// Copyright (c) 2021 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.7
import QtQuick 2.9
import QtQuick.Layouts 1.1
import QtQuick.Controls 2.3
import UM 1.3 as UM
import Cura 1.1 as Cura
import QtGraphicalEffects 1.0 // For the dropshadow
Item
{
id: prepareMenu
property var fileProviderModel: CuraApplication.getFileProviderModel()
UM.I18nCatalog
{
id: catalog
@ -24,60 +25,44 @@ Item
{
left: parent.left
right: parent.right
leftMargin: UM.Theme.getSize("wide_margin").width
rightMargin: UM.Theme.getSize("wide_margin").width
leftMargin: UM.Theme.getSize("wide_margin").width * 2
rightMargin: UM.Theme.getSize("wide_margin").width * 2
}
// Item to ensure that all of the buttons are nicely centered.
Item
{
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * UM.Theme.getSize("wide_margin").width
height: parent.height
anchors.fill: parent
RowLayout
{
id: itemRow
anchors.left: openFileButton.right
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: UM.Theme.getSize("default_margin").width
anchors.leftMargin: UM.Theme.getSize("default_margin").width + openFileButton.width + openFileMenu.width
property int machineSelectorWidth: Math.round((width - printSetupSelectorItem.width) / 3)
height: parent.height
spacing: 0
// This is a trick to make sure that the borders of the two adjacent buttons' borders overlap. Otherwise
// there will be double border (one from each button)
spacing: -UM.Theme.getSize("default_lining").width
Cura.MachineSelector
{
id: machineSelection
headerCornerSide: Cura.RoundedRectangle.Direction.Left
Layout.minimumWidth: UM.Theme.getSize("machine_selector_widget").width
Layout.maximumWidth: UM.Theme.getSize("machine_selector_widget").width
Layout.preferredWidth: parent.machineSelectorWidth
Layout.fillWidth: true
Layout.fillHeight: true
}
// Separator line
Rectangle
{
height: parent.height
width: UM.Theme.getSize("default_lining").width
color: UM.Theme.getColor("lining")
}
Cura.ConfigurationMenu
{
id: printerSetup
Layout.fillHeight: true
Layout.fillWidth: true
Layout.preferredWidth: itemRow.width - machineSelection.width - printSetupSelectorItem.width - 2 * UM.Theme.getSize("default_lining").width
}
// Separator line
Rectangle
{
height: parent.height
width: UM.Theme.getSize("default_lining").width
color: UM.Theme.getColor("lining")
Layout.preferredWidth: parent.machineSelectorWidth * 2
}
Item
@ -91,22 +76,116 @@ Item
}
}
//Pop-up shown when there are multiple items to select from.
Cura.ExpandablePopup
{
id: openFileMenu
visible: prepareMenu.fileProviderModel.count > 1
contentAlignment: Cura.ExpandablePopup.ContentAlignment.AlignLeft
headerCornerSide: Cura.RoundedRectangle.Direction.All
headerPadding: Math.round((parent.height - UM.Theme.getSize("button_icon").height) / 2)
contentPadding: UM.Theme.getSize("default_lining").width
enabled: visible
height: parent.height
width: visible ? (headerPadding * 3 + UM.Theme.getSize("button_icon").height + iconSize) : 0
headerItem: UM.RecolorImage
{
id: menuIcon
source: UM.Theme.getIcon("Folder", "medium")
color: UM.Theme.getColor("icon")
sourceSize.height: height
}
contentItem: Item
{
id: popup
Column
{
id: openProviderColumn
//The column doesn't automatically listen to its children rect if the children change internally, so we need to explicitly update the size.
onChildrenRectChanged:
{
popup.height = childrenRect.height
popup.width = childrenRect.width
}
onPositioningComplete:
{
popup.height = childrenRect.height
popup.width = childrenRect.width
}
Repeater
{
model: prepareMenu.fileProviderModel
delegate: Button
{
leftPadding: UM.Theme.getSize("default_margin").width
rightPadding: UM.Theme.getSize("default_margin").width
width: contentItem.width + leftPadding + rightPadding
height: UM.Theme.getSize("action_button").height
hoverEnabled: true
contentItem: Label
{
text: model.displayText
color: UM.Theme.getColor("text")
font: UM.Theme.getFont("medium")
renderType: Text.NativeRendering
verticalAlignment: Text.AlignVCenter
width: contentWidth
height: parent.height
}
onClicked:
{
if(model.index == 0) //The 0th element is the "From Disk" option, which should activate the open local file dialog.
{
Cura.Actions.open.trigger();
}
else
{
prepareMenu.fileProviderModel.trigger(model.name);
}
}
background: Rectangle
{
color: parent.hovered ? UM.Theme.getColor("action_button_hovered") : "transparent"
radius: UM.Theme.getSize("action_button_radius").width
width: popup.width
}
}
}
}
}
}
//If there is just a single item, show a button instead that directly chooses the one option.
Button
{
id: openFileButton
height: UM.Theme.getSize("stage_menu").height
width: UM.Theme.getSize("stage_menu").height
visible: prepareMenu.fileProviderModel.count <= 1
height: parent.height
width: visible ? height : 0 //Square button (and don't take up space if invisible).
onClicked: Cura.Actions.open.trigger()
enabled: visible && prepareMenu.fileProviderModel.count > 0
hoverEnabled: true
contentItem: Item
{
anchors.fill: parent
UM.RecolorImage
{
id: buttonIcon
source: UM.Theme.getIcon("Folder", "medium")
anchors.centerIn: parent
source: UM.Theme.getIcon("load")
width: UM.Theme.getSize("button_icon").width
height: UM.Theme.getSize("button_icon").height
color: UM.Theme.getColor("icon")
@ -118,26 +197,14 @@ Item
background: Rectangle
{
id: background
height: UM.Theme.getSize("stage_menu").height
width: UM.Theme.getSize("stage_menu").height
height: parent.height
width: parent.width
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: openFileButton.hovered ? UM.Theme.getColor("action_button_hovered") : UM.Theme.getColor("action_button")
}
DropShadow
{
id: shadow
// Don't blur the shadow
radius: 0
anchors.fill: background
source: background
verticalOffset: 2
visible: true
color: UM.Theme.getColor("action_button_shadow")
// Should always be drawn behind the background.
z: background.z - 1
}
}
}
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a prepare stage in Cura.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -24,54 +24,36 @@ Item
{
left: parent.left
right: parent.right
leftMargin: UM.Theme.getSize("wide_margin").width
rightMargin: UM.Theme.getSize("wide_margin").width
leftMargin: UM.Theme.getSize("wide_margin").width * 2
rightMargin: UM.Theme.getSize("wide_margin").width * 2
}
Row
{
id: stageMenuRow
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * UM.Theme.getSize("wide_margin").width
height: parent.height
anchors.fill: parent
// This is a trick to make sure that the borders of the two adjacent buttons' borders overlap. Otherwise
// there will be double border (one from each button)
spacing: -UM.Theme.getSize("default_lining").width
Cura.ViewsSelector
{
id: viewsSelector
height: parent.height
width: UM.Theme.getSize("views_selector").width
width: Math.max(Math.round((parent.width - printSetupSelectorItem.width) / 3), UM.Theme.getSize("views_selector").width)
headerCornerSide: Cura.RoundedRectangle.Direction.Left
}
// Separator line
Rectangle
{
height: parent.height
// If there is no viewPanel, we only need a single spacer, so hide this one.
visible: viewPanel.source != ""
width: visible ? UM.Theme.getSize("default_lining").width : 0
color: UM.Theme.getColor("lining")
}
// This component will grow freely up to complete the width of the row.
Loader
{
id: viewPanel
height: parent.height
width: source != "" ? (previewMenu.width - viewsSelector.width - printSetupSelectorItem.width - 2 * (UM.Theme.getSize("wide_margin").width + UM.Theme.getSize("default_lining").width)) : 0
width: source != "" ? (parent.width - viewsSelector.width - printSetupSelectorItem.width) : 0
source: UM.Controller.activeView != null && UM.Controller.activeView.stageMenuComponent != null ? UM.Controller.activeView.stageMenuComponent : ""
}
// Separator line
Rectangle
{
height: parent.height
width: UM.Theme.getSize("default_lining").width
color: UM.Theme.getColor("lining")
}
Item
{
id: printSetupSelectorItem

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.1",
"description": "Provides a preview stage in Cura.",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"description": "Provides removable drive hotplugging and writing support.",
"version": "1.0.1",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -3,6 +3,6 @@
"author": "Ultimaker B.V.",
"version": "1.0.0",
"description": "Logs certain events so that they can be used by the crash reporter",
"api": "7.4.0",
"api": 7,
"i18n-catalog": "cura"
}

View file

@ -65,7 +65,7 @@ class SimulationPass(RenderPass):
self._layer_shader.setUniformValue("u_active_extruder", float(max(0, self._extruder_manager.activeExtruderIndex)))
if not self._compatibility_mode:
self._layer_shader.setUniformValue("u_starts_color", Color(*Application.getInstance().getTheme().getColor("layerview_starts").getRgb()))
if self._layer_view:
self._layer_shader.setUniformValue("u_max_feedrate", self._layer_view.getMaxFeedrate())
self._layer_shader.setUniformValue("u_min_feedrate", self._layer_view.getMinFeedrate())
@ -73,6 +73,8 @@ class SimulationPass(RenderPass):
self._layer_shader.setUniformValue("u_min_thickness", self._layer_view.getMinThickness())
self._layer_shader.setUniformValue("u_max_line_width", self._layer_view.getMaxLineWidth())
self._layer_shader.setUniformValue("u_min_line_width", self._layer_view.getMinLineWidth())
self._layer_shader.setUniformValue("u_max_flow_rate", self._layer_view.getMaxFlowRate())
self._layer_shader.setUniformValue("u_min_flow_rate", self._layer_view.getMinFlowRate())
self._layer_shader.setUniformValue("u_layer_view_type", self._layer_view.getSimulationViewType())
self._layer_shader.setUniformValue("u_extruder_opacity", self._layer_view.getExtruderOpacities())
self._layer_shader.setUniformValue("u_show_travel_moves", self._layer_view.getShowTravelMoves())
@ -86,6 +88,8 @@ class SimulationPass(RenderPass):
self._layer_shader.setUniformValue("u_min_feedrate", 0)
self._layer_shader.setUniformValue("u_max_thickness", 1)
self._layer_shader.setUniformValue("u_min_thickness", 0)
self._layer_shader.setUniformValue("u_max_flow_rate", 1)
self._layer_shader.setUniformValue("u_min_flow_rate", 0)
self._layer_shader.setUniformValue("u_max_line_width", 1)
self._layer_shader.setUniformValue("u_min_line_width", 0)
self._layer_shader.setUniformValue("u_layer_view_type", 1)
@ -177,9 +181,9 @@ class SimulationPass(RenderPass):
self._switching_layers = True
# The first line does not have a previous line: add a MoveCombingType in front for start detection
# this way the first start of the layer can also be drawn
# this way the first start of the layer can also be drawn
prev_line_types = numpy.concatenate([numpy.asarray([LayerPolygon.MoveCombingType], dtype = numpy.float32), layer_data._attributes["line_types"]["value"]])
# Remove the last element
# Remove the last element
prev_line_types = prev_line_types[0:layer_data._attributes["line_types"]["value"].size]
layer_data._attributes["prev_line_types"] = {'opengl_type': 'float', 'value': prev_line_types, 'opengl_name': 'a_prev_line_type'}

Some files were not shown because too many files have changed in this diff Show more