")
+ 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: A list of the indices of the currently selected files
+ """
+ self._selected_file_indices = file_indices
+ self.selectedFileIndicesChanged.emit(file_indices)
+
+ @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(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
+ 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
+
+ @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 = 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)
+
+ # 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
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
new file mode 100644
index 0000000000..718bd11cd2
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
@@ -0,0 +1,115 @@
+# 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._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
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
new file mode 100644
index 0000000000..c2fe6b969c
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
@@ -0,0 +1,58 @@
+# 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._account.loginStateChanged.connect(self._onLoginStateChanged)
+ 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()
+ 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 _onLoginStateChanged(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()
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
new file mode 100644
index 0000000000..eb7e71fbb6
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
@@ -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")
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
new file mode 100644
index 0000000000..852c565b5e
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
@@ -0,0 +1,114 @@
+# 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._account.loginStateChanged.connect(self._onLoginStateChanged)
+ 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"))
+ 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 _onLoginStateChanged(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)
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py
new file mode 100644
index 0000000000..1a0e4f2772
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py
@@ -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")
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
new file mode 100644
index 0000000000..b35e760998
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
@@ -0,0 +1,71 @@
+# 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):
+ 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 sortProjectsBy(self, sort_by: Optional[str]):
+ if sort_by:
+ try:
+ self._projects.sort(key = lambda p: getattr(p, sort_by))
+ except AttributeError:
+ Logger.log("e", "The projects cannot be sorted by '{}'. No such attribute exists.".format(sort_by))
+
+ 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()
diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
new file mode 100644
index 0000000000..a511a11bd5
--- /dev/null
+++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
@@ -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")
diff --git a/plugins/DigitalLibrary/src/ExportFileJob.py b/plugins/DigitalLibrary/src/ExportFileJob.py
new file mode 100644
index 0000000000..3e4c6dfea2
--- /dev/null
+++ b/plugins/DigitalLibrary/src/ExportFileJob.py
@@ -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()
diff --git a/plugins/DigitalLibrary/src/PaginationLinks.py b/plugins/DigitalLibrary/src/PaginationLinks.py
new file mode 100644
index 0000000000..06ed183944
--- /dev/null
+++ b/plugins/DigitalLibrary/src/PaginationLinks.py
@@ -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)
diff --git a/plugins/DigitalLibrary/src/PaginationManager.py b/plugins/DigitalLibrary/src/PaginationManager.py
new file mode 100644
index 0000000000..f2b7c8f5bd
--- /dev/null
+++ b/plugins/DigitalLibrary/src/PaginationManager.py
@@ -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
diff --git a/plugins/DigitalLibrary/src/PaginationMetadata.py b/plugins/DigitalLibrary/src/PaginationMetadata.py
new file mode 100644
index 0000000000..7f11e43d30
--- /dev/null
+++ b/plugins/DigitalLibrary/src/PaginationMetadata.py
@@ -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)
diff --git a/plugins/DigitalLibrary/src/ResponseMeta.py b/plugins/DigitalLibrary/src/ResponseMeta.py
new file mode 100644
index 0000000000..a1dbc949db
--- /dev/null
+++ b/plugins/DigitalLibrary/src/ResponseMeta.py
@@ -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)
diff --git a/plugins/DigitalLibrary/src/__init__.py b/plugins/DigitalLibrary/src/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/resources/bundled_packages/cura.json b/resources/bundled_packages/cura.json
index 77d7d001ad..ccb301ce4a 100644
--- a/resources/bundled_packages/cura.json
+++ b/resources/bundled_packages/cura.json
@@ -118,6 +118,23 @@
}
}
},
+ "DigitalLibrary": {
+ "package_info": {
+ "package_id": "DigitalLibrary",
+ "package_type": "plugin",
+ "display_name": "Ultimaker Digital Library",
+ "description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
+ "package_version": "1.0.0",
+ "sdk_version": "7.5.0",
+ "website": "https://ultimaker.com",
+ "author": {
+ "author_id": "UltimakerPackages",
+ "display_name": "Ultimaker B.V.",
+ "email": "plugins@ultimaker.com",
+ "website": "https://ultimaker.com"
+ }
+ }
+ },
"FirmwareUpdateChecker": {
"package_info": {
"package_id": "FirmwareUpdateChecker",
diff --git a/resources/images/whats_new/0.png b/resources/images/whats_new/0.png
index 6fbc4f3f85..873ae03e0a 100644
Binary files a/resources/images/whats_new/0.png and b/resources/images/whats_new/0.png differ
diff --git a/resources/images/whats_new/2.png b/resources/images/whats_new/2.png
index 3f6559f29e..ccf694b67a 100644
Binary files a/resources/images/whats_new/2.png and b/resources/images/whats_new/2.png differ
diff --git a/resources/images/whats_new/3.gif b/resources/images/whats_new/3.gif
new file mode 100644
index 0000000000..8db3ef9666
Binary files /dev/null and b/resources/images/whats_new/3.gif differ
diff --git a/resources/texts/change_log.txt b/resources/texts/change_log.txt
index b9fd5c32ee..13d268767f 100644
--- a/resources/texts/change_log.txt
+++ b/resources/texts/change_log.txt
@@ -1,4 +1,8 @@
-[4.9.0 Beta]
+[4.9.0]
+For an overview of the new features in Cura 4.9, please watch our video.
+
+* Digital factory integration.
+Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription. Learn more
* "Line type" is now the default color scheme.
When entering the Preview mode, you don't have to switch manually to line type.
@@ -20,19 +24,11 @@ The shell category was a mix of settings about walls and settings about top/bott
The ability to have thumbnails embedded. Contributed by Gravedigger7789.
* Add checkbox for Extruder Offsets.
-Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
+Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
* Cura should work properly on MacOS 'Big Sur' now, afforded by upgrades to Python (to 3.8) and Qt (to 5.15).
If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS versions, like Big Sur, you should be able to use this new version.
-* Known UX issues that will be fixed before final in our current plan
-- Custom menu Materials and Nozzle menu now open at cursor position instead of under the menu button.
-- Visuals of Preference screen are large.
-- Drop downs in Preference screen don't react to mouse-scroll.
-- Default language not selected in Preference screen.
-- Changelog takes long too load.
-- Setting Visibility submenu items in the Preference screen are greyed-out and can't be selected on Mac OSX.
-
* Bug Fixes
- Fixed a security vulnerability on windows permitting the openssl library used to launch other programs. Thanks to Xavier Danest for raising this bug.
- Fixed Connect Top/Bottom Polygons.
@@ -63,7 +59,8 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- Fixed message for non manifold models.
- Fixed setting category arrows. Contributed by fieldOfView.
- Fixed metadate tags for 3MF files.
-- Fixed engine crash when using low-density Cross Infill
+- Fixed engine crash when using low-density Cross Infill.
+- Improved performance of loading .def.json files.
* Printer definitions, profiles and materials
- 3DFuel Pro PLA and SnapSupport materials, contributed by grk3010.
@@ -87,6 +84,7 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- TwoTrees Bluer, contributed by WashingtonJunior.
- Update Hellbot Magna 1 and Hellbot Magna dual, contributed by DevelopmentHellbot.
- Update Rigid3D and added Rigid3D Mucit2, contributed by mehmetsutas.
+- Update TPU profiles for 0.6mm nozzle of UM2+C.
- ZAV series, contributed by kimer2002.
[4.8.0]
diff --git a/resources/texts/whats_new/0.html b/resources/texts/whats_new/0.html
index ae9560362e..8d1868b043 100644
--- a/resources/texts/whats_new/0.html
+++ b/resources/texts/whats_new/0.html
@@ -1,4 +1,5 @@
-“Line type” is now the default color scheme.
+Seamless workflow with the Digital Library in Ultimaker Digital Factory
- This improves your user experience – as you will no longer have to manually switch to “line type” each time you enter Preview mode.
+ Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription.
+ Learn more
diff --git a/resources/texts/whats_new/2.html b/resources/texts/whats_new/2.html
index 55142cdbcd..ae9560362e 100644
--- a/resources/texts/whats_new/2.html
+++ b/resources/texts/whats_new/2.html
@@ -1,3 +1,4 @@
-There is a lot more..
-Want more information on new features, bug fixes, and more for Ultimaker Cura 4.9 beta?
-Read the full blog post here. And don't forget to give us your feedback on Github!
+“Line type” is now the default color scheme.
+
+ This improves your user experience – as you will no longer have to manually switch to “line type” each time you enter Preview mode.
+
diff --git a/resources/texts/whats_new/3.html b/resources/texts/whats_new/3.html
new file mode 100644
index 0000000000..9e90b01e2c
--- /dev/null
+++ b/resources/texts/whats_new/3.html
@@ -0,0 +1,3 @@
+Learn more
+Want more information for Ultimaker Cura 4.9?
+Read the blog post or watch the video. And don't forget to give us your feedback!